md2biki
Version:
Convert Markdown documents to Backlog Wiki format
177 lines • 6.64 kB
JavaScript
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
export class MarkdownToBacklogConverter {
listDepth = 0;
orderedListStack = [];
async convert(markdown) {
const processor = unified()
.use(remarkParse)
.use(remarkGfm); // Add GFM support for tables, strikethrough, etc.
const tree = processor.parse(markdown);
return this.processNode(tree);
}
processNode(node, parent) {
switch (node.type) {
case 'root':
return this.processChildren(node);
case 'heading':
return this.processHeading(node);
case 'paragraph':
// Handle line breaks within paragraphs
const paragraphContent = this.processChildren(node);
// In lists, don't add extra newlines
if (parent && parent.type === 'listItem') {
return paragraphContent;
}
return paragraphContent + '\n\n';
case 'text':
const text = node.value;
// Convert line breaks within text to &br;
const withLineBreaks = text.split('\n').join('&br;');
return this.escapeText(withLineBreaks);
case 'strong':
return `''${this.processChildren(node)}''`;
case 'emphasis':
return `'''${this.processChildren(node)}'''`;
case 'delete':
return `%%${this.processChildren(node)}%%`;
case 'inlineCode':
return `{code}${node.value}{/code}`;
case 'code':
return this.processCodeBlock(node);
case 'link':
return this.processLink(node);
case 'image':
return this.processImage(node);
case 'list':
return this.processList(node);
case 'listItem':
return this.processListItem(node);
case 'blockquote':
return this.processBlockquote(node);
case 'table':
return this.processTable(node);
case 'tableRow':
return this.processTableRow(node);
case 'tableCell':
return this.processChildren(node);
case 'break':
return '&br;';
case 'thematicBreak':
return '----\n\n';
default:
return this.processChildren(node);
}
}
processChildren(node) {
if (!node.children)
return '';
return node.children.map(child => this.processNode(child, node)).join('');
}
processHeading(node) {
// Backlog Wiki supports up to 6 levels of headings
const depth = Math.min(node.depth, 6);
const stars = '*'.repeat(depth);
const content = this.processChildren(node);
return `${stars} ${content}\n\n`;
}
processCodeBlock(node) {
// Backlog Wiki doesn't support language specification in code blocks
return `{code}\n${node.value}\n{/code}\n\n`;
}
processLink(node) {
const text = this.processChildren(node);
const url = node.url;
if (text === url) {
return url;
}
return `[[${text}>${url}]]`;
}
processImage(node) {
const url = node.url;
if (url.startsWith('http://') || url.startsWith('https://')) {
return `#image(${url})\n`;
}
return `#image(${url})\n`;
}
processList(node) {
const wasInList = this.listDepth > 0;
this.listDepth++;
this.orderedListStack.push(node.ordered || false);
const result = node.children
.map(child => this.processNode(child, node))
.join('');
this.orderedListStack.pop();
this.listDepth--;
return wasInList ? result : result + '\n';
}
processListItem(node) {
const isOrdered = this.orderedListStack[this.orderedListStack.length - 1];
const bullet = isOrdered ? '+' : '-';
const prefix = bullet.repeat(this.listDepth);
let content = '';
let hasNestedList = false;
node.children.forEach((child, index) => {
if (child.type === 'paragraph') {
content += this.processChildren(child);
}
else if (child.type === 'list') {
hasNestedList = true;
content += '\n' + this.processNode(child, node);
}
else {
content += this.processNode(child, node);
}
});
content = content.trim();
// If there's a nested list, don't add extra newline
if (hasNestedList) {
return `${prefix} ${content.split('\n')[0]}\n${content.split('\n').slice(1).join('\n')}`;
}
return `${prefix} ${content}\n`;
}
processBlockquote(node) {
const content = this.processChildren(node).trim();
// Check if content contains &br; (which means multiple lines)
const hasMultipleLines = content.includes('&br;');
if (hasMultipleLines) {
// Replace &br; with newlines for multi-line quotes
const cleanContent = content.replace(/&br;/g, '\n');
return `{quote}\n${cleanContent}\n{/quote}\n\n`;
}
return `>${content}\n\n`;
}
processTable(node) {
let result = '';
let headerProcessed = false;
node.children.forEach((row, index) => {
const cells = row.children.map(cell => this.processChildren(cell).trim());
// Skip separator row (typically the second row with ---)
if (index === 1 && cells.every(cell => cell.match(/^-+$/))) {
headerProcessed = true;
return;
}
// First actual content row is the header
if (index === 0 || (index === 2 && headerProcessed)) {
result += `|${cells.join('|')}|h\n`;
}
else {
result += `|${cells.join('|')}|\n`;
}
});
return result + '\n';
}
processTableRow(node) {
const cells = node.children.map(cell => this.processChildren(cell));
return `|${cells.join('|')}|\n`;
}
escapeText(text) {
return text
.replace(/\|\|/g, '\\|\\|')
.replace(/%%/g, '\\%\\%')
.replace(/\[\[/g, '\\[\\[')
.replace(/\]\]/g, '\\]\\]');
}
}
//# sourceMappingURL=converter.js.map