lvwerra's picture
lvwerra HF Staff
Inline markdown nests: parse code/links/math/emphasis inside bold, italic, strike and links; serialize marks in a round-trippable order
40eb19e verified
Raw
History Blame Contribute Delete
12.8 kB
// 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(/(?<!\\)\|/)
.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)
}