import MarkdownIt from 'markdown-it'; import type StateInline from 'markdown-it/lib/rules_inline/state_inline.mjs'; import Token from 'markdown-it/lib/token.mjs'; import { Mark, Node } from 'prosemirror-model'; import { MarkdownParser, MarkdownSerializer, MarkdownSerializerState } from 'prosemirror-markdown'; import { editorSchema } from './schema'; /** * markdown-it's default preset already tokenizes GFM tables and * strikethrough; task lists are GFM-only and not tokenized by markdown-it * itself, so {@link transformTokens} rewrites the raw token stream before * `prosemirror-markdown` turns it into a document (ADR 0004). */ function findMatchingClose(tokens: Token[], openIndex: number): number { let depth = 1; for (let i = openIndex + 1; i < tokens.length; i += 1) { const type = tokens[i]?.type ?? ''; if (type.endsWith('_open')) depth += 1; else if (type.endsWith('_close')) depth -= 1; if (depth === 0) return i; } throw new Error(`Unbalanced markdown-it token stream at index ${openIndex}`); } /** Indices of `type` tokens directly inside `[start, end)`, not in a nested container. */ function directChildOpens(tokens: Token[], start: number, end: number, type: string): number[] { const result: number[] = []; let depth = 0; for (let i = start; i < end; i += 1) { const tok = tokens[i]; if (!tok) continue; if (depth === 0 && tok.type === type) result.push(i); if (tok.type.endsWith('_open')) depth += 1; else if (tok.type.endsWith('_close')) depth -= 1; } return result; } const TASK_MARKER = /^\[([ xX])\]\s+/; /** * Reads (and strips) the `[ ] `/`[x] ` prefix from a list item's first * paragraph. Returns `null` when the item has no checkbox marker, which * also signals "this list is not a task list" to the caller. */ function taskCheckedFor(tokens: Token[], itemOpen: number): boolean | null { const paragraphOpen = tokens[itemOpen + 1]; if (paragraphOpen?.type !== 'paragraph_open') return null; const inline = tokens[itemOpen + 2]; if (inline?.type !== 'inline') return null; const firstChild = inline.children?.[0]; if (!firstChild || firstChild.type !== 'text') return null; const match = TASK_MARKER.exec(firstChild.content); if (!match) return null; firstChild.content = firstChild.content.slice(match[0].length); return (match[1] ?? '').toLowerCase() === 'x'; } function retype(token: Token, type: string): Token { const clone = new Token(type, token.tag, token.nesting); Object.assign(clone, token, { type }); return clone; } /** * Rewrites the markdown-it token stream so it matches the editor schema: * - a bullet list where every item carries a `[ ]`/`[x]` marker becomes a * `task_list` of `task_item`s (mixed lists are left as plain bullet * lists — GFM does not define their meaning either); * - table cell content (a bare `inline` token in markdown-it) is wrapped * in a synthetic paragraph, matching `table_cell`/`table_header`'s * `block+` content. */ function transformTokens(tokens: Token[]): Token[] { const out: Token[] = []; let i = 0; while (i < tokens.length) { const tok = tokens[i]; if (!tok) { i += 1; continue; } if (tok.type === 'bullet_list_open') { const close = findMatchingClose(tokens, i); const itemOpens = directChildOpens(tokens, i + 1, close, 'list_item_open'); const checks = itemOpens.map((idx) => taskCheckedFor(tokens, idx)); const isTaskList = checks.length > 0 && checks.every((c) => c !== null); if (isTaskList) { out.push(retype(tok, 'task_list_open')); let itemPos = 0; for (let j = i + 1; j < close; j += 1) { const t = tokens[j]; if (!t) continue; if (t.type === 'list_item_open') { const retyped = retype(t, 'task_item_open'); retyped.attrSet('checked', String(checks[itemPos] === true)); itemPos += 1; out.push(retyped); } else if (t.type === 'list_item_close') { out.push(retype(t, 'task_item_close')); } else { out.push(t); } } const closeTok = tokens[close]; if (closeTok) out.push(retype(closeTok, 'task_list_close')); i = close + 1; continue; } } if (tok.type === 'th_open' || tok.type === 'td_open') { const close = findMatchingClose(tokens, i); out.push(tok); out.push(Object.assign(new Token('paragraph_open', 'p', 1), { hidden: true })); for (let j = i + 1; j < close; j += 1) { const t = tokens[j]; if (t) out.push(t); } out.push(Object.assign(new Token('paragraph_close', 'p', -1), { hidden: true })); const closeTok = tokens[close]; if (closeTok) out.push(closeTok); i = close + 1; continue; } out.push(tok); i += 1; } return out; } /** markdown-it inline rule for `[[slug]]` / `[[slug|display]]` (issue #46). */ function wikilinkRule(state: StateInline, silent: boolean): boolean { const start = state.pos; // Two opening brackets. if (state.src.charCodeAt(start) !== 0x5b || state.src.charCodeAt(start + 1) !== 0x5b) { return false; } const close = state.src.indexOf(']]', start + 2); if (close < 0) return false; const inner = state.src.slice(start + 2, close); // No nesting or line breaks inside a wikilink. if (inner.includes('[') || inner.includes(']') || inner.includes('\n')) return false; const pipe = inner.indexOf('|'); const slug = (pipe >= 0 ? inner.slice(0, pipe) : inner).trim(); const display = pipe >= 0 ? inner.slice(pipe + 1).trim() : ''; if (!slug) return false; if (!silent) { const token = state.push('wikilink', '', 0); token.attrs = display ? [ ['target', slug], ['display', display], ] : [['target', slug]]; } state.pos = close + 2; return true; } function createTokenizer(): MarkdownIt { const md = new MarkdownIt('default', { html: false }); // Run before `link` so `[[…]]` is not first eaten as two nested `[…]` links. md.inline.ruler.before('link', 'wikilink', wikilinkRule); const rawParse = md.parse.bind(md); md.parse = (src, env) => transformTokens(rawParse(src, env)); return md; } const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), { blockquote: { block: 'blockquote' }, paragraph: { block: 'paragraph' }, list_item: { block: 'list_item' }, task_item: { block: 'task_item', getAttrs: (tok) => ({ checked: tok.attrGet('checked') === 'true' }), }, bullet_list: { block: 'bullet_list' }, task_list: { block: 'task_list' }, ordered_list: { block: 'ordered_list', getAttrs: (tok) => ({ order: Number(tok.attrGet('start')) || 1 }), }, heading: { block: 'heading', // The schema only defines levels 1-4; deeper markdown headings clamp // down rather than fail the parse. getAttrs: (tok) => ({ level: Math.min(4, Number(tok.tag.slice(1)) || 1) }), }, code_block: { block: 'code_block', noCloseToken: true }, fence: { block: 'code_block', noCloseToken: true }, hr: { node: 'horizontal_rule' }, image: { node: 'image', // The "src" slot carries the opaque fileId; the API's markdown import/ // export endpoint (#30) is responsible for resolving it to a servable // URL and back — that boundary is not this package's concern. getAttrs: (tok) => ({ fileId: tok.attrGet('src') ?? '', alt: tok.children?.[0]?.content ?? '', width: null, }), }, hardbreak: { node: 'hard_break' }, wikilink: { node: 'wikilink', getAttrs: (tok) => ({ targetSlug: tok.attrGet('target') ?? '', displayText: tok.attrGet('display') || null, }), }, em: { mark: 'italic' }, strong: { mark: 'bold' }, s: { mark: 'strikethrough' }, link: { mark: 'link', getAttrs: (tok) => ({ href: tok.attrGet('href') ?? '' }) }, code_inline: { mark: 'code', noCloseToken: true }, table: { block: 'table' }, thead: { ignore: true }, tbody: { ignore: true }, tr: { block: 'table_row' }, th: { block: 'table_header' }, td: { block: 'table_cell' }, }); /** Parses Markdown into a document of {@link editorSchema}. */ export function markdownToDoc(markdown: string): Node { return markdownParser.parse(markdown); } function renderTable(state: MarkdownSerializerState, node: Node): void { const rows: string[][] = []; node.forEach((row) => { const cells: string[] = []; row.forEach((cell) => { cells.push(cell.textContent.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim()); }); rows.push(cells); }); const headerRow = rows[0]; if (!headerRow) { state.closeBlock(node); return; } const lines = [ `| ${headerRow.join(' | ')} |`, `| ${headerRow.map(() => '---').join(' | ')} |`, ...rows.slice(1).map((row) => `| ${row.join(' | ')} |`), ]; state.write(lines.join('\n')); state.closeBlock(node); } /** Serializes a document of {@link editorSchema} back to Markdown. */ const markdownSerializer = new MarkdownSerializer( { blockquote(state, node) { state.wrapBlock('> ', null, node, () => state.renderContent(node)); }, code_block(state, node) { const backticks = node.textContent.match(/`{3,}/gm); const fence = backticks ? `${[...backticks].sort().slice(-1)[0]}\`` : '```'; state.write(`${fence}\n`); state.text(node.textContent, false); state.write('\n'); state.write(fence); state.closeBlock(node); }, heading(state, node) { state.write(`${state.repeat('#', node.attrs.level as number)} `); state.renderInline(node, false); state.closeBlock(node); }, horizontal_rule(state, node) { state.write('---'); state.closeBlock(node); }, bullet_list(state, node) { state.renderList(node, ' ', () => '- '); }, task_list(state, node) { state.renderList(node, ' ', (i) => `- [${node.child(i).attrs.checked ? 'x' : ' '}] `); }, ordered_list(state, node) { const start = (node.attrs.order as number) || 1; const maxWidth = String(start + node.childCount - 1).length; const space = state.repeat(' ', maxWidth + 2); state.renderList(node, space, (i) => { const numeral = String(start + i); return state.repeat(' ', maxWidth - numeral.length) + numeral + '. '; }); }, list_item(state, node) { state.renderContent(node); }, task_item(state, node) { state.renderContent(node); }, paragraph(state, node) { state.renderInline(node); state.closeBlock(node); }, image(state, node) { const alt = state.esc((node.attrs.alt as string) || ''); const fileId = (node.attrs.fileId as string).replace(/[()]/g, '\\$&'); state.write(`![${alt}](${fileId})`); }, wikilink(state, node) { const slug = node.attrs.targetSlug as string; const display = node.attrs.displayText as string | null; state.write(display ? `[[${slug}|${display}]]` : `[[${slug}]]`); }, hard_break(state, node, parent, index) { for (let i = index + 1; i < parent.childCount; i += 1) { if (parent.child(i).type !== node.type) { state.write('\\\n'); return; } } }, text(state, node) { state.text(node.text ?? ''); }, table: renderTable, }, { italic: { open: '*', close: '*', mixable: true, expelEnclosingWhitespace: true }, bold: { open: '**', close: '**', mixable: true, expelEnclosingWhitespace: true }, strikethrough: { open: '~~', close: '~~', mixable: true, expelEnclosingWhitespace: true }, link: { open: '[', close: (_state, mark: Mark) => `](${(mark.attrs.href as string).replace(/[()]/g, '\\$&')})`, mixable: true, }, code: { open: (_state, _mark, parent, index) => backticksFor(parent.child(index), -1), close: (_state, _mark, parent, index) => backticksFor(parent.child(index - 1), 1), escape: false, }, }, ); function backticksFor(node: Node, side: -1 | 1): string { const text = node.isText ? (node.text ?? '') : ''; const matches = text.match(/`+/g); const len = matches ? Math.max(...matches.map((m) => m.length)) : 0; let result = len > 0 && side > 0 ? ' `' : '`'; result += '`'.repeat(len); if (len > 0 && side < 0) result += ' '; return result; } export function docToMarkdown(doc: Node): string { // The schema has no `tight`/`loose` list attribute (out of scope for v1) // — every list renders without blank lines between items. return markdownSerializer.serialize(doc, { tightLists: true }); }