/**
* 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 += ``;
} 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` : '';
}