import type { SyntaxNode } from '@lezer/common' import { Emoji, GFM, parser as commonmarkParser, Subscript, Superscript, type MarkdownParser } from '@lezer/markdown' import { typoraExtensions } from './extensions' // 与编辑器一致的语法集(lang-markdown 的 markdownLanguage 加 Typora 扩展) export const markdownParser = commonmarkParser.configure([GFM, Subscript, Superscript, Emoji, ...typoraExtensions]) export { commonmarkParser } interface Line { rest: string col: number } interface LinkDefinition { href: string title: string | null } const BLOCK_CONTAINERS = new Set(['Blockquote', 'ListItem']) const footnoteId = (label: string) => 'fn-' + label.toLowerCase().replace(/\s+/g, '-') export function escapeHtml(s: string): string { return s.replace(/[&<>"]/g, (c) => (c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : '"')) } // 属性值中保留实体引用(交给浏览器解码),只转义其余的特殊字符 function escapeAttr(s: string): string { return s.replace(/&(?!(?:#\d{1,7}|#[xX][\da-fA-F]{1,6}|[A-Za-z][A-Za-z\d]{1,31});)|[<>"]/g, (c) => c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : '"', ) } function unescapeBackslashes(s: string): string { return s.replace(/\\([!-/:-@[-`{-~])/g, '$1') } // 对非法 URL 字符做百分号编码,已有的 %XX 保留 function normalizeUrl(s: string): string { return s.replace(/%(?![\da-fA-F]{2})|[^\w\-.~:/?#[\]@!$&'()*+,;=%]/g, (c) => encodeURIComponent(c)) } function normalizeLabel(s: string): string { return s.slice(1, -1).trim().replace(/\s+/g, ' ').toLowerCase().toUpperCase() } function tabWidth(col: number): number { return 4 - (col % 4) } export interface RenderOptions { parser?: MarkdownParser // 为 true 时原样输出原始 HTML、不过滤危险链接协议;仅用于可信内容(如规范测试) unsafe?: boolean } const UNSAFE_PROTOCOL = /^\s*(javascript|vbscript|file|data):/i const SAFE_DATA_IMAGE = /^\s*data:image\/(png|gif|jpeg|webp);/i function safeHref(href: string): string { // 协议由 ASCII 组成;属性实体会被浏览器解码,URL 解析还会忽略制表符和换行。 const protocol = href.replace(/&#(?:([0-9]{1,7})|[xX]([\da-fA-F]{1,6}));|&(colon|Tab|NewLine);/g, (entity, decimal, hex, named) => { if (named) return named === 'colon' ? ':' : named === 'Tab' ? '\t' : '\n' const code = decimal ? Number(decimal) : parseInt(hex, 16) return code <= 127 ? String.fromCharCode(code) : entity }).replace(/[\t\r\n]/g, '').replace(/^[\x00-\x20]+/, '') return UNSAFE_PROTOCOL.test(protocol) && !SAFE_DATA_IMAGE.test(protocol) ? '#' : href } export function renderMarkdown(src: string, { parser = markdownParser, unsafe = false }: RenderOptions = {}): string { const tree = parser.parse(src) const linkHref = (href: string) => unsafe ? href : safeHref(href) const rawHtml = (s: string) => (unsafe ? s : escapeHtml(s)) const text = (from: number, to: number) => src.slice(from, to) const defs = new Map() tree.iterate({ enter(ref) { if (ref.name !== 'LinkReference') return const node = ref.node const label = node.getChild('LinkLabel') const url = node.getChild('URL') if (!label || !url) return false const key = normalizeLabel(text(label.from, label.to)) if (!defs.has(key)) defs.set(key, { href: destination(url), title: linkTitle(node.getChild('LinkTitle')) }) return false }, }) function destination(url: SyntaxNode): string { let s = text(url.from, url.to) if (s.startsWith('<') && s.endsWith('>')) s = s.slice(1, -1) return normalizeUrl(unescapeBackslashes(s)) } function linkTitle(node: SyntaxNode | null): string | null { return node ? unescapeBackslashes(text(node.from + 1, node.to - 1)) : null } // ---- 容器前缀剥离:代码块、HTML 块需要按 CommonMark 的列规则去掉引用符与列表缩进 ---- function containers(node: SyntaxNode): SyntaxNode[] { const out: SyntaxNode[] = [] for (let p = node.parent; p; p = p.parent) if (BLOCK_CONTAINERS.has(p.name)) out.unshift(p) return out } function lineStart(pos: number): number { return src.lastIndexOf('\n', pos - 1) + 1 } function lineEnd(pos: number): number { const i = src.indexOf('\n', pos) return i < 0 ? src.length : i } function columnAt(pos: number): number { let col = 0 for (let i = lineStart(pos); i < pos; i++) col += src[i] === '\t' ? tabWidth(col) : 1 return col } // 列表项内容的起始列(绝对列号) function itemContentColumn(item: SyntaxNode): number { const mark = item.getChild('ListMark')! const markEnd = columnAt(mark.to) let col = markEnd let i = mark.to const end = lineEnd(mark.to) while (i < end && (src[i] === ' ' || src[i] === '\t')) col += src[i++] === '\t' ? tabWidth(col) : 1 if (i === end || col - markEnd > 4) return markEnd + 1 return col } // 去掉一行开头属于容器的部分,返回剩余文本(被拆开的制表符以空格补齐) function stripContainers(lineFrom: number, cs: SyntaxNode[]): { rest: string; col: number } { const end = lineEnd(lineFrom) let i = lineFrom let col = 0 let pad = 0 const eatColumns = (target: number) => { while (col + pad < target && i < end && (src[i] === ' ' || src[i] === '\t')) { const w = src[i] === '\t' ? tabWidth(col) : 1 if (col + w > target) { pad = col + w - target col = target i++ return } col += w i++ } } for (const c of cs) { if (c.name === 'Blockquote') { const base = col eatColumns(base + 3) if (src[i] === '>') { i++ col++ pad = 0 if (src[i] === ' ') { i++ col++ } else if (src[i] === '\t') { const w = tabWidth(col) i++ col++ pad = w - 1 } } } else { const target = itemContentColumn(c) if (lineStart(c.from) === lineFrom) { // 列表项首行:跳过列表标记本身 const mark = c.getChild('ListMark')! col = columnAt(mark.to) i = mark.to pad = 0 } if (pad) { const use = Math.min(pad, target - col) pad -= use col += use } eatColumns(target) } } // pad 个空格(被拆开的制表符剩余部分)从第 col 列开始 return { rest: ' '.repeat(pad) + src.slice(i, end), col } } // 去掉至多 n 列的前导空白;startCol 为该行文本起始的绝对列,用于计算制表符宽度 function stripIndent({ rest: line, col: startCol }: Line, n: number): string { let col = 0 let i = 0 while (col < n && i < line.length && (line[i] === ' ' || line[i] === '\t')) { const w = line[i] === '\t' ? tabWidth(startCol + col) : 1 if (col + w > n) return ' '.repeat(col + w - n) + line.slice(i + 1) col += w i++ } return line.slice(i) } function contentLines(node: SyntaxNode, skipFirst: boolean, skipLast: boolean): Line[] { const cs = containers(node) const lines: Line[] = [] let pos = lineStart(node.from) // 未闭合的块会延伸到文末换行之后,那一行不属于内容 const last = lineStart(node.to > node.from && src[node.to - 1] === '\n' ? node.to - 1 : node.to) for (let first = true; pos <= last; first = false) { const isLast = pos === last if (!(first && skipFirst) && !(isLast && skipLast)) lines.push(stripContainers(pos, cs)) if (pos >= src.length) break pos = lineEnd(pos) + 1 } return lines } // ---- 行内 ---- function inline(parent: SyntaxNode, from: number, to: number): string { let out = '' let pos = from // 引用符和硬换行之后的行首空白不属于内容 let afterQuote = false for (let ch = parent.firstChild; ch; ch = ch.nextSibling) { if (ch.to <= from || ch.from >= to) continue if (ch.from > pos) out += textSegment(text(pos, ch.from), afterQuote) afterQuote = ch.name === 'QuoteMark' || ch.name === 'HardBreak' out += inlineNode(ch) pos = ch.to } if (pos < to) out += textSegment(text(pos, to), afterQuote) return out } function textSegment(s: string, afterQuote: boolean): string { s = s.replace(/[ \t]*\n[ \t]*/g, '\n') if (afterQuote) s = s.replace(/^[ \t]+/, '') return escapeHtml(s) } function innerOf(node: SyntaxNode, markName: string): string { const marks = node.getChildren(markName) if (marks.length < 2) return inline(node, node.from, node.to) return inline(node, marks[0].to, marks[marks.length - 1].from) } function plainText(html: string): string { return html.replace(/]*?alt="([^"]*)"[^>]*>/g, '$1').replace(/<[^>]*>/g, '') } function inlineNode(node: SyntaxNode): string { switch (node.name) { case 'QuoteMark': case 'HeaderMark': return '' case 'Escape': return escapeHtml(text(node.from + 1, node.to)) case 'Entity': { const ent = text(node.from, node.to) return /^&#(\d{8,}|[xX][\da-fA-F]{7,});$/.test(ent) ? escapeHtml(ent) : ent } case 'HardBreak': return '
\n' case 'Emphasis': return `${innerOf(node, 'EmphasisMark')}` case 'StrongEmphasis': return `${innerOf(node, 'EmphasisMark')}` case 'Strikethrough': return `${innerOf(node, 'StrikethroughMark')}` case 'Superscript': return `${innerOf(node, 'SuperscriptMark')}` case 'Subscript': return `${innerOf(node, 'SubscriptMark')}` case 'Highlight': return `${innerOf(node, 'HighlightMark')}` // 公式与 emoji 输出占位元素,由 render.ts 的 hydrate 按需加载依赖后填充 case 'InlineMath': { const tex = text(node.from + 1, node.to - 1) return `${escapeHtml(tex)}` } case 'Emoji': { const raw = text(node.from, node.to) return `${escapeHtml(raw)}` } case 'FootnoteReference': { const label = text(node.from + 2, node.to - 1) return `${escapeHtml(label)}` } case 'InlineCode': { const marks = node.getChildren('CodeMark') let code = text(marks[0].to, marks[marks.length - 1].from).replace(/\n/g, ' ') if (/^ .*[^ ].* $/s.test(code)) code = code.slice(1, -1) return `${escapeHtml(code)}` } case 'HTMLTag': case 'Comment': case 'ProcessingInstruction': return rawHtml(text(node.from, node.to)) case 'Autolink': { const url = node.getChild('URL') if (!url) return escapeHtml(text(node.from, node.to)) const raw = text(url.from, url.to) const href = /^[^@\s]+@[^@\s]+$/.test(raw) && !raw.includes(':') ? 'mailto:' + raw : raw return `${escapeHtml(raw)}` } case 'URL': { // GFM 裸链接 const raw = text(node.from, node.to) const href = raw.startsWith('www.') ? 'http://' + raw : raw.includes('@') && !raw.includes(':') ? 'mailto:' + raw : raw return `${escapeHtml(raw)}` } case 'Link': case 'Image': return linkOrImage(node) default: return escapeHtml(text(node.from, node.to)) } } function linkOrImage(node: SyntaxNode): string { const isImage = node.name === 'Image' const marks = node.getChildren('LinkMark') const labelFrom = node.from + (isImage ? 2 : 1) const closeBracket = marks.find((m) => m.from >= labelFrom && text(m.from, m.to) === ']') if (!closeBracket) return escapeHtml(text(node.from, node.to)) const content = inline(node, labelFrom, closeBracket.from) let def: LinkDefinition | undefined const url = node.getChild('URL') if (marks.some((m) => text(m.from, m.to) === '(')) { def = { href: url ? destination(url) : '', title: linkTitle(node.getChild('LinkTitle')) } } else { const label = node.getChild('LinkLabel') const key = label && text(label.from, label.to) !== '[]' ? text(label.from, label.to) : text(labelFrom - 1, closeBracket.to) def = defs.get(normalizeLabel(key)) if (!def) { // 引用未定义:按字面文本输出 const tail = label ? escapeHtml(unescapeBackslashes(text(label.from, label.to))) : '' return (isImage ? '!' : '') + '[' + content + ']' + tail } } const title = def.title !== null ? ` title="${escapeAttr(def.title)}"` : '' if (isImage) return `${escapeAttr(plainText(content))}` return `${content}` } // ---- 块级 ---- function isBlank(line: string): boolean { return /^[ \t>]*$/.test(line) } function hasBlankLineBetween(a: SyntaxNode, b: SyntaxNode): boolean { const lines = text(a.to, b.from).split('\n') return lines.slice(1, -1).some(isBlank) } function blockChildren(node: SyntaxNode, withDefinitions = false): SyntaxNode[] { const out: SyntaxNode[] = [] for (let ch = node.firstChild; ch; ch = ch.nextSibling) { if (/Mark$/.test(ch.name)) continue if (ch.name !== 'LinkReference' || withDefinitions) out.push(ch) } return out } function isLoose(list: SyntaxNode): boolean { const items = list.getChildren('ListItem') for (let i = 0; i + 1 < items.length; i++) if (hasBlankLineBetween(items[i], items[i + 1])) return true for (const item of items) { const kids = blockChildren(item, true) for (let i = 0; i + 1 < kids.length; i++) if (hasBlankLineBetween(kids[i], kids[i + 1])) return true } return false } function blocks(node: SyntaxNode, tight = false): string { let out = '' for (let ch = node.firstChild; ch; ch = ch.nextSibling) out += block(ch, tight) return out } function block(node: SyntaxNode, tight: boolean): string { const name = node.name if (name.startsWith('ATXHeading')) { const level = name.slice(-1) const marks = node.getChildren('HeaderMark') const from = marks.length ? marks[0].to : node.from const to = marks.length > 1 ? marks[marks.length - 1].from : node.to return `${inline(node, from, to).trim()}\n` } if (name.startsWith('SetextHeading')) { const level = name.slice(-1) const mark = node.getChildren('HeaderMark').pop()! return `${inline(node, node.from, mark.from).trim()}\n` } switch (name) { case 'Paragraph': { const body = inline(node, node.from, node.to).trim() return tight ? body + '\n' : `

${body}

\n` } case 'Task': { const marker = node.getChild('TaskMarker')! const checked = text(marker.from + 1, marker.from + 2) !== ' ' ? ' checked=""' : '' const body = ` ${inline(node, marker.to, node.to).trim()}` return tight ? body + '\n' : `

${body}

\n` } case 'Blockquote': return `
\n${blocks(node)}
\n` case 'BulletList': case 'OrderedList': { const loose = isLoose(node) const tag = name === 'BulletList' ? 'ul' : 'ol' let attrs = '' if (tag === 'ol') { const first = node.getChild('ListItem')!.getChild('ListMark')! const start = parseInt(text(first.from, first.to), 10) if (start !== 1) attrs = ` start="${start}"` } let items = '' for (const item of node.getChildren('ListItem')) items += listItem(item, !loose) return `<${tag}${attrs}>\n${items}\n` } case 'HorizontalRule': return '
\n' case 'FencedCode': { const marks = node.getChildren('CodeMark') const closed = marks.length > 1 const info = node.getChild('CodeInfo') const lang = info ? unescapeBackslashes(text(info.from, info.to).trim().split(/\s/)[0]) : '' const cs = containers(node) const fenceIndent = columnAt(marks[0].from) - stripContainers(lineStart(marks[0].from), cs).col const lines = contentLines(node, true, closed).map((l) => stripIndent(l, fenceIndent)) const cls = lang ? ` class="language-${escapeAttr(lang)}"` : '' const code = lines.length ? lines.join('\n') + '\n' : '' return `
${escapeHtml(code)}
\n` } case 'CodeBlock': { const lines = contentLines(node, false, false).map((l) => stripIndent(l, 4)) while (lines.length && !lines[lines.length - 1].trim()) lines.pop() return `
${escapeHtml(lines.join('\n') + '\n')}
\n` } case 'HTMLBlock': case 'CommentBlock': case 'ProcessingInstructionBlock': return rawHtml(contentLines(node, false, false).map((l) => l.rest).join('\n')) + '\n' case 'Table': return table(node) case 'BlockMath': { const marks = node.getChildren('BlockMathMark') const from = Math.min(lineEnd(node.from) + 1, node.to) const to = marks.length > 1 ? Math.max(from, lineStart(marks[marks.length - 1].from) - 1) : node.to const tex = text(from, to) return `
${escapeHtml(tex)}
\n` } case 'FootnoteDefinition': { const label = node.getChild('FootnoteLabel')! const name = text(label.from + 2, label.to - 2) return `
${escapeHtml(name)} ${inline(node, label.to, node.to).trim()}
\n` } // 目录占位,由分享页根据标题填充 case 'TableOfContents': return '\n' // front matter 是元数据,不输出 default: return '' } } function listItem(item: SyntaxNode, tight: boolean): string { const kids = blockChildren(item) if (!kids.length) return '
  • \n' let body = '' for (const k of kids) body += block(k, tight) const startsInline = tight && (kids[0].name === 'Paragraph' || kids[0].name === 'Task') const last = kids[kids.length - 1].name const endsInline = tight && (last === 'Paragraph' || last === 'Task') if (!startsInline) body = '\n' + body if (endsInline) body = body.replace(/\n$/, '') return `
  • ${body}
  • \n` } function table(node: SyntaxNode): string { const delimiter = node.getChild('TableDelimiter') const aligns = delimiter ? text(delimiter.from, delimiter.to) .replace(/^\s*\|?|\|?\s*$/g, '') .split('|') .map((c) => { const s = c.trim() return s.startsWith(':') && s.endsWith(':') ? 'center' : s.endsWith(':') ? 'right' : s.startsWith(':') ? 'left' : '' }) : [] const row = (r: SyntaxNode, tag: string) => { const cells = r.getChildren('TableCell') let out = '\n' for (let i = 0; i < aligns.length; i++) { const align = aligns[i] ? ` align="${aligns[i]}"` : '' const cell = cells[i] out += `<${tag}${align}>${cell ? inline(cell, cell.from, cell.to).trim() : ''}\n` } return out + '\n' } const header = node.getChild('TableHeader') let out = '\n' if (header) out += `\n${row(header, 'th')}\n` const rows = node.getChildren('TableRow') if (rows.length) out += `\n${rows.map((r) => row(r, 'td')).join('')}\n` return out + '
    \n' } return blocks(tree.topNode) } // 渲染单行行内 Markdown(表格单元格等),不产生块级结构 export function renderInlineMarkdown(src: string, options?: RenderOptions): string { const html = renderMarkdown(src, options) const m = /^

    ([\s\S]*)<\/p>\n$/.exec(html) return m ? m[1] : escapeHtml(src) }