// v0.20 rendering base: zero-dependency markdown → HTML pure function. // // Supports a 10-item markdown subset (ATX headings, paragraphs, inline // formatting, ordered/unordered lists, pipe tables, fenced code blocks, // blockquotes, links, thematic breaks) plus mermaid/html fence passthrough. // All text is HTML-escaped for XSS safety. Block-level parsing is a per-line // state machine; inline parsing runs on already-escaped text within a block. /** md → html. 零依赖. mermaid/html 围栏透传为
, 归前端渲染. */
export function renderMarkdown(md: string): string {
const lines = md.split('\n')
const out: string[] = []
// current block state
let i = 0
// accumulate paragraph lines before flushing
let para: string[] = []
// accumulate list items (${renderInline(escaped(joined))}
`) para = [] } const flushList = () => { if (listItems.length === 0) return const tag = listOrdered ? 'ol' : 'ul' out.push(`<${tag}>${listItems.join('')}${tag}>`) listItems = [] listOrdered = false } const flushTable = () => { if (tableRows.length === 0) return const rows = tableRows.length let html = '${content}`)
} else if (lang === 'html') {
out.push(`${content}`)
} else {
const cls = lang ? ` class="language-${escapedAttr(lang)}"` : ''
out.push(`${content}`)
}
continue
}
// --- blank line: block separator ---
if (line.trim() === '') {
flushAll()
i++
continue
}
// --- ATX heading ---
const heading = matchHeading(line)
if (heading) {
flushAll()
out.push(`${renderInline(escaped(quoteLines.join('\n')))}`) continue } // --- table (pipe table) --- const cells = parseTableRow(line) if (cells !== null && i + 1 < lines.length && isTableSeparator(lines[i + 1]!)) { flushAll() tableHasHeader = true tableRows.push(cells) i += 2 // header + separator while (i < lines.length) { const row = parseTableRow(lines[i]!) if (row === null) break tableRows.push(row) i++ } flushTable() continue } // --- unordered list --- const ulMatch = /^[-*+]\s+(.*)$/.exec(line) if (ulMatch) { // flush non-list blocks flushPara() flushTable() // if previous list was ordered or none, start fresh if (listOrdered) flushList() listOrdered = false listItems.push(`
${text.slice(i + 1, end)}`
i = end + 1
continue
}
if (ch === '*' || ch === '_') {
// ** / __ → bold ; * / _ → italic. Try double first.
if (text[i + 1] === ch) {
const close = text.indexOf(ch + ch, i + 2)
if (close !== -1) {
out += `${renderInline(text.slice(i + 2, close))}`
i = close + 2
continue
}
} else {
// single: italic. Opening marker must not be followed by whitespace
// (CommonMark flanking). Closing marker must not be preceded by whitespace.
if (text[i + 1] !== ' ' && text[i + 1] !== '\t' && text[i + 1] !== ch) {
let j = i + 1
while (j < n) {
// skip ** (bold delimiter) sequences entirely
if (text[j] === ch && text[j + 1] === ch) {
j += 2
continue
}
if (text[j] === ch && text[j + 1] !== ch && text[j - 1] !== ' ' && text[j - 1] !== '\t') break
j++
}
if (j < n) {
out += `${renderInline(text.slice(i + 1, j))}`
i = j + 1
continue
}
}
}
// no close: literal
out += ch
i++
continue
}
if (ch === '[') {
// [text](url) — note url in source may contain chars that are now
// escaped (e.g. & → &). We decode back? Simpler: scan raw, but text
// is escaped. We match ]( pattern on escaped text; url portion keeps its
// escaped form which is valid inside an attribute.
const closeBracket = text.indexOf('](', i + 1)
if (closeBracket !== -1) {
const closeParen = text.indexOf(')', closeBracket + 2)
if (closeParen !== -1) {
const linkText = text.slice(i + 1, closeBracket)
const url = text.slice(closeBracket + 2, closeParen)
out += `${renderInline(linkText)}`
i = closeParen + 1
continue
}
}
out += ch
i++
continue
}
out += ch
i++
}
return out
}
// ---------- block-level matchers ----------
interface Heading {
level: number
text: string
}
function matchHeading(line: string): Heading | null {
const m = /^(#{1,6})\s+(.*?)(?:\s+#+\s*)?$/.exec(line)
if (!m) return null
return { level: m[1]!.length, text: m[2]!.trim() }
}
function isThematicBreak(line: string): boolean {
const t = line.trim()
if (t.length < 3) return false
const ch = t[0]!
if (ch !== '-' && ch !== '*' && ch !== '_') return false
for (const c of t) {
if (c !== ch && c !== ' ') return false
}
// must contain at least 3 of ch
let count = 0
for (const c of t) if (c === ch) count++
return count >= 3
}
interface FenceMatch {
lang: string
raw: string // raw lang token (may include extra)
}
function matchFence(line: string): FenceMatch | null {
const t = line.trimStart()
// 3+ backticks
if (t.startsWith('```')) {
const rest = t.slice(3)
const lang = rest.trim().split(/\s+/)[0] ?? ''
return { lang, raw: rest }
}
// 3+ tildes
if (t.startsWith('~~~')) {
const rest = t.slice(3)
const lang = rest.trim().split(/\s+/)[0] ?? ''
return { lang, raw: rest }
}
return null
}
function isFenceClose(line: string): boolean {
const t = line.trim()
return t === '```' || t === '~~~'
}
function parseTableRow(line: string): string[] | null {
const t = line.trim()
if (!t.includes('|')) return null
// strip leading/trailing pipe
let inner = t
if (inner.startsWith('|')) inner = inner.slice(1)
if (inner.endsWith('|') && !inner.endsWith('\\|')) inner = inner.slice(0, -1)
// split on unescaped pipes
const cells: string[] = []
let cur = ''
let j = 0
while (j < inner.length) {
const c = inner[j]!
if (c === '\\' && inner[j + 1] === '|') {
cur += '|'
j += 2
continue
}
if (c === '|') {
cells.push(escaped(cur.trim()))
cur = ''
j++
continue
}
cur += c
j++
}
cells.push(escaped(cur.trim()))
// must have at least one cell and at least one pipe was present
if (cells.length < 1) return null
return cells
}
function isTableSeparator(line: string): boolean {
const cells = parseTableRow(line)
if (!cells) return false
if (cells.length === 0) return false
for (const c of cells) {
// c is escaped; separators are - : | space, all unchanged by escaping.
if (!/^[-:\s]+$/.test(c)) return false
if (!c.includes('-')) return false
}
return true
}