UNPKG

md2biki

Version:

Convert Markdown documents to Backlog Wiki format

177 lines 6.64 kB
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