Spaces:
Sleeping
Sleeping
| // 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 `` | |
| 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(/(?<!\\)\|/) | |
| .map(c => 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 | |
| // (?<!\*)/(?!\*) so a single-star span never opens or closes on half of a `**`. | |
| const INLINE_RULES = [ | |
| // literal: a code span's body is text by definition — `**` inside it is not bold | |
| { re: /`([^`]+)`/, attrs: () => ({ 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: /(?<![A-Za-z0-9])___(?=\S)([\s\S]+?)(?<=\S)___(?![A-Za-z0-9])/, attrs: () => ({ bold: {}, italic: {} }) }, | |
| { re: /\*\*(?=\S)([\s\S]+?)(?<=\S)\*\*/, attrs: () => ({ bold: {} }) }, | |
| { re: /(?<![A-Za-z0-9])__(?=\S)([\s\S]+?)(?<=\S)__(?![A-Za-z0-9])/, attrs: () => ({ bold: {} }) }, | |
| { re: /~~(?=\S)([\s\S]+?)(?<=\S)~~/, attrs: () => ({ strike: {} }) }, | |
| { re: /(?<!\*)\*(?!\*)(?=\S)([\s\S]+?)(?<=\S)(?<!\*)\*(?!\*)/, attrs: () => ({ italic: {} }) }, | |
| { re: /(?<![A-Za-z0-9])_(?=\S)(?!_)([\s\S]+?)(?<=\S)_(?![A-Za-z0-9])/, attrs: () => ({ 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 = /(?<!\$)\$(?![\s$])([^$\n]*[^\s$])\$(?!\$)/ | |
| // A math segment carries BOTH its source text and its latex: every consumer | |
| // that only reads `.text` (diffing, signatures, plain-text descriptions) then | |
| // sees `$x^2$` and behaves correctly, and only the few places that build real | |
| // nodes — ProseMirror JSON, Yjs XML, preview HTML — look at `.math`. | |
| function mathSegment(latex) { | |
| return { text: '$' + latex + '$', attrs: {}, math: latex } | |
| } | |
| export function tokenizeInline(text) { | |
| const segments = [] | |
| let rest = text | |
| while (rest) { | |
| const math = rest.match(MATH_RE) | |
| // leftmost match wins; math ties break in math's favour so `$a * b$` stays math | |
| let best = math ? { index: math.index, m: math, math: true } : null | |
| for (const rule of INLINE_RULES) { | |
| const m = rest.match(rule.re) | |
| if (m && (!best || m.index < best.index)) best = { index: m.index, m, rule } | |
| } | |
| if (!best) { segments.push({ text: rest, attrs: {} }); break } | |
| if (best.index > 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) | |
| } | |