// Markdown <-> ProseMirror JSON helpers. Deliberately minimal: paragraphs, // headings 1-3, bullet/ordered lists (flat), code blocks, blockquotes, // horizontal rules, $-delimited math; inline bold/italic/code/strike/links. // --- ProseMirror JSON -> markdown --- export function pmToMarkdown(pmDoc) { return (pmDoc.content || []).map(blockToMarkdown).filter(s => s !== null).join('\n\n') } export function blockToMarkdown(node) { switch (node.type) { case 'paragraph': return inlineToMarkdown(node.content) case 'heading': return '#'.repeat(node.attrs?.level || 1) + ' ' + inlineToMarkdown(node.content) case 'bulletList': return (node.content || []).map(li => '- ' + listItemText(li)).join('\n') case 'orderedList': return (node.content || []).map((li, i) => `${i + 1}. ` + listItemText(li)).join('\n') case 'taskList': return (node.content || []).map(li => `- [${li.attrs?.checked ? 'x' : ' '}] ` + listItemText(li)).join('\n') case 'table': return tableToMarkdown(node) case 'codeBlock': return '```' + (node.attrs?.language || '') + '\n' + textOf(node) + '\n```' case 'htmlBlock': return '```html-embed\n' + (node.attrs?.html || '') + '\n```' case 'blockquote': return (node.content || []).map(blockToMarkdown).join('\n\n').split('\n').map(l => '> ' + l).join('\n') case 'mathBlock': // canonical fenced form; the single-line `$$x$$` spelling also parses return '$$\n' + (node.attrs?.latex || '').trim() + '\n$$' case 'horizontalRule': return '---' case 'image': return `![${node.attrs?.alt || ''}](${node.attrs?.src || ''})` default: return textOf(node) || null } } function listItemText(li) { return (li.content || []).map(blockToMarkdown).join(' ') } function cellText(cell) { return (cell.content || []) .map(b => inlineToMarkdown(b.content)) .join(' ') .replace(/\|/g, '\\|') .trim() } function tableToMarkdown(node) { const rows = (node.content || []).map(r => (r.content || []).map(cellText)) if (!rows.length) return '' const cols = Math.max(...rows.map(r => r.length)) const pad = r => { const c = r.slice() while (c.length < cols) c.push('') return c } const line = cells => '| ' + pad(cells).join(' | ') + ' |' // first row is the header (GFM requires one); a delimiter row follows it const out = [line(rows[0]), '| ' + Array(cols).fill('---').join(' | ') + ' |'] for (const r of rows.slice(1)) out.push(line(r)) return out.join('\n') } function textOf(node) { if (node.text) return node.text return (node.content || []).map(textOf).join('') } function inlineToMarkdown(content) { if (!content) return '' return content.map(n => { if (n.type === 'hardBreak') return ' \n' // an inline atom: its markdown IS its source, and it carries no marks if (n.type === 'mathInline') return '$' + (n.attrs?.latex || '') + '$' let text = n.text || '' // Nesting order is not free: a code span's body is literal, so `code` has to // wrap innermost or `**bold**` inside it would serialize to visible asterisks // (`` `**t**` ``). A link wraps outermost. ProseMirror stores marks in schema // order, so sort rather than trusting the array's order. const NESTING = ['code', 'bold', 'italic', 'strike', 'link'] const marks = [...(n.marks || [])].sort((a, b) => NESTING.indexOf(a.type) - NESTING.indexOf(b.type)) for (const mark of marks) { if (mark.type === 'bold') text = `**${text}**` else if (mark.type === 'italic') text = `*${text}*` else if (mark.type === 'code') text = '`' + text + '`' else if (mark.type === 'strike') text = `~~${text}~~` else if (mark.type === 'link') text = `[${text}](${mark.attrs?.href || ''})` } return text }).join('') } // --- markdown -> block descriptors --- // Each descriptor: { type, attrs?, inline? (segments), items? (arrays of segments), text? } export function markdownToBlocks(md) { const blocks = [] const lines = (md || '').replace(/\r\n/g, '\n').split('\n') let i = 0 while (i < lines.length) { const line = lines[i] if (!line.trim()) { i++; continue } const fence = line.match(/^```([\w-]*)\s*$/) if (fence) { const body = [] i++ while (i < lines.length && !/^```\s*$/.test(lines[i])) { body.push(lines[i]); i++ } i++ // ```html-embed fences carry a live (sandboxed) HTML snippet, not code if (fence[1] === 'html-embed') blocks.push({ type: 'htmlBlock', text: body.join('\n') }) else blocks.push({ type: 'codeBlock', attrs: fence[1] ? { language: fence[1] } : {}, text: body.join('\n') }) continue } if (/^(---|\*\*\*)\s*$/.test(line.trim())) { blocks.push({ type: 'horizontalRule' }); i++; continue } // display math: `$$ ... $$` on one line, or fenced over several if (line.trim().startsWith('$$')) { const oneLine = line.trim().match(/^\$\$(.+)\$\$$/) if (oneLine) { blocks.push({ type: 'mathBlock', attrs: { latex: oneLine[1].trim() } }) i++ continue } const body = [] i++ while (i < lines.length && !lines[i].trim().endsWith('$$')) { body.push(lines[i]); i++ } if (i < lines.length) { // the closing line may carry the last of the formula: `... \end{aligned}$$` const tail = lines[i].trim().replace(/\$\$$/, '') if (tail) body.push(tail) i++ } blocks.push({ type: 'mathBlock', attrs: { latex: body.join('\n').trim() } }) continue } const image = line.match(/^!\[([^\]]*)\]\(([^)\s]+)\)\s*$/) if (image) { blocks.push({ type: 'image', attrs: { alt: image[1] || null, src: image[2] } }) i++ continue } const heading = line.match(/^(#{1,6})\s+(.*)$/) if (heading) { blocks.push({ type: 'heading', attrs: { level: Math.min(heading[1].length, 3) }, inline: tokenizeInline(heading[2]) }) i++ continue } // task list: "- [ ] text" / "- [x] text" (checked before plain bullets) if (/^\s*[-*]\s+\[[ xX]\]\s+/.test(line)) { const items = [] while (i < lines.length && /^\s*[-*]\s+\[[ xX]\]\s+/.test(lines[i])) { const m = lines[i].match(/^\s*[-*]\s+\[([ xX])\]\s+(.*)$/) items.push({ checked: m[1].toLowerCase() === 'x', inline: tokenizeInline(m[2]) }) i++ } blocks.push({ type: 'taskList', items }) continue } if (/^\s*[-*]\s+/.test(line)) { const items = [] while (i < lines.length && /^\s*[-*]\s+/.test(lines[i]) && !/^\s*[-*]\s+\[[ xX]\]\s+/.test(lines[i])) { items.push(tokenizeInline(lines[i].replace(/^\s*[-*]\s+/, ''))) i++ } blocks.push({ type: 'bulletList', items }) continue } // GFM table: a "| ... |" row immediately followed by a delimiter row if (line.includes('|') && i + 1 < lines.length && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) { const splitCells = l => l .trim() .replace(/^\||\|$/g, '') .split(/(? c.replace(/\\\|/g, '|').trim()) const rows = [splitCells(line)] i += 2 // header + delimiter while (i < lines.length && lines[i].includes('|') && lines[i].trim()) { rows.push(splitCells(lines[i])) i++ } blocks.push({ type: 'table', rows: rows.map(cells => cells.map(tokenizeInline)) }) continue } if (/^\s*\d+[.)]\s+/.test(line)) { const items = [] while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) { items.push(tokenizeInline(lines[i].replace(/^\s*\d+[.)]\s+/, ''))) i++ } blocks.push({ type: 'orderedList', items }) continue } if (/^>\s?/.test(line)) { const body = [] while (i < lines.length && /^>\s?/.test(lines[i])) { body.push(lines[i].replace(/^>\s?/, '')) i++ } blocks.push({ type: 'blockquote', inline: tokenizeInline(body.join(' ')) }) continue } // paragraph: consume until blank line or block marker const body = [] while (i < lines.length && lines[i].trim() && !/^(#{1,6}\s|```|\$\$|\s*[-*]\s|\s*\d+[.)]\s|>\s?|---\s*$)/.test(lines[i])) { body.push(lines[i]) i++ } blocks.push({ type: 'paragraph', inline: tokenizeInline(body.join(' ')) }) } return blocks } // --- inline tokenizer: returns [{ text, attrs }] where attrs maps mark name -> attrs object --- // Emphasis: `_`/`__` do NOT open/close inside a word, so an underscore flanked // by alphanumerics (e.g. `icm2026_schedule.html`) is literal — the boundary // lookarounds gate the delimiters and let the span's body span such underscores // (body is `.+?`, not `[^_]+`). Without this, `_..._` emphasis containing a // filename underscore never matched and rendered as plain text. // // Every body is a lazy `[\s\S]+?` rather than a "no delimiter inside" run, // because inline marks NEST: the body is handed back to tokenizeInline below. // A `[^*]+` body could not hold `**bold with *italic* inside**` at all, and the // single-`*` rule would then match the wrong pair of stars and mangle it. // // Rules are tried in order and the leftmost match wins, so `**` is listed // before `*` and math before all emphasis. `*` also guards both delimiters with // (? ({ code: {} }), literal: true }, { re: /\[([^\]]+)\]\(([^)\s]+)\)/, attrs: m => ({ link: { href: m[2] } }) }, // `***x***` is bold+italic, and it needs its own rule ahead of `**`: given a // run of three stars the `**` rule would close on the wrong pair and leave a // stray `*` behind (`***t***` -> bold("*t") + "*"). { re: /\*\*\*(?=\S)([\s\S]+?)(?<=\S)\*\*\*/, attrs: () => ({ bold: {}, italic: {} }) }, { re: /(? ({ bold: {}, italic: {} }) }, { re: /\*\*(?=\S)([\s\S]+?)(?<=\S)\*\*/, attrs: () => ({ bold: {} }) }, { re: /(? ({ bold: {} }) }, { re: /~~(?=\S)([\s\S]+?)(?<=\S)~~/, attrs: () => ({ strike: {} }) }, { re: /(? ({ italic: {} }) }, { re: /(? ({ italic: {} }) }, ] // Inline math, `$...$`. Matched BEFORE any emphasis so a formula's `*`, `_`, // backticks and brackets cannot be eaten as markdown — `$\{x\}_{i}$ and // $\{y\}_{j}$` used to turn into italics spanning both formulas. // // The delimiters are deliberately strict, because `$` is also money: no space // just inside either delimiter (so "costs $5 and $10 more" is left alone), no // newline, and never adjacent to a second `$` (so a `$$...$$` display block is // not chewed into inline math from its second character). const MATH_RE = /(? 0) segments.push({ text: rest.slice(0, best.index), attrs: {} }) if (best.math) { segments.push(mathSegment(best.m[1])) } else if (best.rule.literal) { segments.push({ text: best.m[1], attrs: best.rule.attrs(best.m) }) } else { // marks nest: tokenize the body and fold this mark into each child, so // `**a `c` [l](u) $x$**` keeps its code span, link and formula const attrs = best.rule.attrs(best.m) for (const child of tokenizeInline(best.m[1])) segments.push({ ...child, attrs: { ...attrs, ...child.attrs } }) } rest = rest.slice(best.index + best.m[0].length) } return segments.filter(s => s.text) }