/** * Document → Markdown projection. A best-effort, dependency-free serializer * covering the structural nodes and the marks Markdown can represent. Styling * marks with no Markdown equivalent (color, background, font family/size) are * dropped to plain text; underline/sub/sup fall back to inline HTML, which * GitHub-flavored Markdown renders. For loss-free round-trips use getHTML(). */ import { Node as PMNode, Mark } from 'prosemirror-model'; /** Escapes the inline characters that would otherwise be read as Markdown. */ function escapeText(text: string): string { return text.replace(/([\\`*_[\]<>])/g, '\\$1'); } /** Wraps a text run in its marks, innermost (code) to outermost (link). */ function serializeMarks(text: string, marks: readonly Mark[]): string { const has = (name: string): boolean => marks.some(m => m.type.name === name); let out: string; if (has('code')) { const ticks = text.includes('`') ? '``' : '`'; out = `${ticks}${text}${ticks}`; } else { out = escapeText(text); } if (has('subscript')) out = `${out}`; if (has('superscript')) out = `${out}`; if (has('underline')) out = `${out}`; if (has('strike')) out = `~~${out}~~`; if (has('italic')) out = `*${out}*`; if (has('bold')) out = `**${out}**`; const link = marks.find(m => m.type.name === 'link'); if (link) out = `[${out}](${link.attrs.href})`; return out; } /** Inline content of a textblock (text runs, breaks, images, mentions). */ function serializeInline(node: PMNode): string { let result = ''; node.forEach(child => { if (child.isText) { result += serializeMarks(child.text ?? '', child.marks); } else if (child.type.name === 'hard_break') { result += ' \n'; } else if (child.type.name === 'image') { result += `![${child.attrs.alt ?? ''}](${child.attrs.src})`; } else if (child.type.name === 'mention') { result += `${child.attrs.trigger}${child.attrs.label}`; } }); return result; } function serializeChildren(node: PMNode): string { const parts: string[] = []; node.forEach(child => parts.push(serializeBlock(child))); return parts.join('\n\n'); } /** One list item: first paragraph on the marker line, the rest indented. */ function serializeListItem(item: PMNode, marker: string, indent: string): string { const contPad = indent + ' '.repeat(marker.length); const parts: string[] = []; item.forEach(child => { const name = child.type.name; if (name === 'bullet_list') { parts.push(serializeList(child, contPad, null)); } else if (name === 'ordered_list') { parts.push(serializeList(child, contPad, child.attrs.order ?? 1)); } else if (name === 'task_list') { parts.push(serializeTaskList(child, contPad)); } else { parts.push( serializeBlock(child) .split('\n') .map(line => contPad + line) .join('\n') ); } }); // The first line was padded like a continuation; swap that pad for the // marker (same width, so nested content stays aligned). const body = parts.join('\n'); return indent + marker + body.slice(contPad.length); } function serializeList(list: PMNode, indent: string, startOrder: number | null): string { const lines: string[] = []; let order = startOrder ?? 1; list.forEach(item => { lines.push(serializeListItem(item, startOrder === null ? '- ' : `${order}. `, indent)); order += 1; }); return lines.join('\n'); } function serializeTaskList(list: PMNode, indent: string): string { const lines: string[] = []; list.forEach(item => { const box = item.attrs.checked ? '[x] ' : '[ ] '; lines.push(serializeListItem(item, `- ${box}`, indent)); }); return lines.join('\n'); } /** GitHub-flavored table. Cells use the first paragraph's inline text. */ function serializeTable(table: PMNode): string { const rows: string[][] = []; table.forEach(row => { const cells: string[] = []; row.forEach(cell => { const text = cell.firstChild ? serializeInline(cell.firstChild) : ''; cells.push(text.replace(/\n/g, ' ').replace(/\|/g, '\\|').trim()); }); rows.push(cells); }); if (rows.length === 0) return ''; const cols = Math.max(...rows.map(r => r.length)); const pad = (r: string[]): string[] => { const copy = r.slice(); while (copy.length < cols) copy.push(''); return copy; }; const fmt = (r: string[]): string => `| ${pad(r).join(' | ')} |`; return [ fmt(rows[0]), fmt(new Array(cols).fill('---')), ...rows.slice(1).map(fmt), ].join('\n'); } function serializeBlock(node: PMNode): string { switch (node.type.name) { case 'paragraph': return serializeInline(node); case 'heading': return `${'#'.repeat(node.attrs.level as number)} ${serializeInline(node)}`; case 'blockquote': return serializeChildren(node) .split('\n') .map(line => (line ? `> ${line}` : '>')) .join('\n'); case 'code_block': return `\`\`\`${node.attrs.language ?? ''}\n${node.textContent}\n\`\`\``; case 'horizontal_rule': return '---'; case 'bullet_list': return serializeList(node, '', null); case 'ordered_list': return serializeList(node, '', (node.attrs.order as number) ?? 1); case 'task_list': return serializeTaskList(node, ''); case 'table': return serializeTable(node); case 'embed': return node.attrs.src as string; default: return node.isTextblock ? serializeInline(node) : serializeChildren(node); } } /** Serializes a document node to a Markdown string. */ export function docToMarkdown(doc: PMNode): string { const body = serializeChildren(doc).replace(/\n{3,}/g, '\n\n').trim(); return body ? `${body}\n` : ''; }