| |
| |
| |
| |
| |
| |
| |
|
|
| |
| export function renderMarkdown(md: string): string { |
| const lines = md.split('\n') |
| const out: string[] = [] |
| |
| let i = 0 |
| |
| let para: string[] = [] |
| |
| let listItems: string[] = [] |
| let listOrdered = false |
| |
| let tableRows: string[][] = [] |
| let tableHasHeader = false |
|
|
| const flushPara = () => { |
| if (para.length === 0) return |
| const joined = para.join('\n') |
| out.push(`<p>${renderInline(escaped(joined))}</p>`) |
| 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 = '<table>' |
| for (let r = 0; r < rows; r++) { |
| const cells = tableRows[r]! |
| const isHeader = tableHasHeader && r === 0 |
| const cellTag = isHeader ? 'th' : 'td' |
| html += '<tr>' |
| for (const c of cells) html += `<${cellTag}>${renderInline(c)}</${cellTag}>` |
| html += '</tr>' |
| } |
| html += '</table>' |
| out.push(html) |
| tableRows = [] |
| tableHasHeader = false |
| } |
| const flushAll = () => { |
| flushPara() |
| flushList() |
| flushTable() |
| } |
|
|
| while (i < lines.length) { |
| const line = lines[i]! |
|
|
| |
| const fence = matchFence(line) |
| if (fence) { |
| flushAll() |
| const { lang, raw } = fence |
| const body: string[] = [] |
| i++ |
| while (i < lines.length && !isFenceClose(lines[i]!)) { |
| body.push(lines[i]!) |
| i++ |
| } |
| |
| if (i < lines.length) i++ |
| const content = escaped(body.join('\n')) |
| if (lang === 'mermaid') { |
| out.push(`<pre class="mermaid-raw">${content}</pre>`) |
| } else if (lang === 'html') { |
| out.push(`<pre class="html-raw">${content}</pre>`) |
| } else { |
| const cls = lang ? ` class="language-${escapedAttr(lang)}"` : '' |
| out.push(`<pre><code${cls}>${content}</code></pre>`) |
| } |
| continue |
| } |
|
|
| |
| if (line.trim() === '') { |
| flushAll() |
| i++ |
| continue |
| } |
|
|
| |
| const heading = matchHeading(line) |
| if (heading) { |
| flushAll() |
| out.push(`<h${heading.level}>${renderInline(escaped(heading.text))}</h${heading.level}>`) |
| i++ |
| continue |
| } |
|
|
| |
| if (isThematicBreak(line)) { |
| flushAll() |
| out.push('<hr>') |
| i++ |
| continue |
| } |
|
|
| |
| const quoteMatch = /^>\s?(.*)$/.exec(line) |
| if (quoteMatch) { |
| flushAll() |
| const quoteLines: string[] = [] |
| while (i < lines.length) { |
| const m = /^>\s?(.*)$/.exec(lines[i]!) |
| if (!m) break |
| quoteLines.push(m[1]!) |
| i++ |
| } |
| |
| out.push(`<blockquote>${renderInline(escaped(quoteLines.join('\n')))}</blockquote>`) |
| continue |
| } |
|
|
| |
| const cells = parseTableRow(line) |
| if (cells !== null && i + 1 < lines.length && isTableSeparator(lines[i + 1]!)) { |
| flushAll() |
| tableHasHeader = true |
| tableRows.push(cells) |
| i += 2 |
| while (i < lines.length) { |
| const row = parseTableRow(lines[i]!) |
| if (row === null) break |
| tableRows.push(row) |
| i++ |
| } |
| flushTable() |
| continue |
| } |
|
|
| |
| const ulMatch = /^[-*+]\s+(.*)$/.exec(line) |
| if (ulMatch) { |
| |
| flushPara() |
| flushTable() |
| |
| if (listOrdered) flushList() |
| listOrdered = false |
| listItems.push(`<li>${renderInline(escaped(ulMatch[1]!))}</li>`) |
| i++ |
| |
| while (i < lines.length) { |
| const m = /^[-*+]\s+(.*)$/.exec(lines[i]!) |
| if (!m) { |
| if (lines[i]!.trim() === '') break |
| |
| if (/^\d+\.\s+/.test(lines[i]!)) break |
| break |
| } |
| listItems.push(`<li>${renderInline(escaped(m[1]!))}</li>`) |
| i++ |
| } |
| continue |
| } |
|
|
| |
| const olMatch = /^(\d+)\.\s+(.*)$/.exec(line) |
| if (olMatch) { |
| flushPara() |
| flushTable() |
| if (!listOrdered) flushList() |
| listOrdered = true |
| listItems.push(`<li>${renderInline(escaped(olMatch[2]!))}</li>`) |
| i++ |
| while (i < lines.length) { |
| const m = /^(\d+)\.\s+(.*)$/.exec(lines[i]!) |
| if (!m) { |
| if (lines[i]!.trim() === '') break |
| if (/^[-*+]\s+/.test(lines[i]!)) break |
| break |
| } |
| listItems.push(`<li>${renderInline(escaped(m[2]!))}</li>`) |
| i++ |
| } |
| continue |
| } |
|
|
| |
| para.push(line) |
| i++ |
| } |
|
|
| |
| flushAll() |
|
|
| return out.join('\n') |
| } |
|
|
| |
|
|
| const HTML_ENTITIES: Record<string, string> = { |
| '&': '&', |
| '<': '<', |
| '>': '>', |
| '"': '"', |
| "'": ''', |
| } |
|
|
| function escaped(s: string): string { |
| |
| let out = '' |
| for (const ch of s) { |
| out += HTML_ENTITIES[ch] ?? ch |
| } |
| return out |
| } |
|
|
| function escapedAttr(s: string): string { |
| |
| return escaped(s) |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function safeHref(url: string): string { |
| const decoded = url |
| .replace(/"/g, '"') |
| .replace(/'/g, "'") |
| .replace(/</g, '<') |
| .replace(/>/g, '>') |
| .replace(/&/g, '&') |
| .trim() |
| .toLowerCase() |
| if ( |
| decoded.startsWith('http://') || |
| decoded.startsWith('https://') || |
| decoded.startsWith('mailto:') || |
| decoded.startsWith('#') || |
| decoded.startsWith('/') || |
| decoded.startsWith('./') || |
| decoded.startsWith('../') || |
| !decoded.includes(':') |
| ) { |
| return url |
| } |
| return '#' |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function renderInline(text: string): string { |
| |
| |
| |
| let out = '' |
| let i = 0 |
| const n = text.length |
|
|
| const isMarker = (ch: string) => ch === '*' || ch === '_' || ch === '`' || ch === '[' |
|
|
| while (i < n) { |
| const ch = text[i]! |
|
|
| if (ch === '`') { |
| |
| const end = text.indexOf('`', i + 1) |
| if (end === -1) { |
| out += ch |
| i++ |
| continue |
| } |
| out += `<code>${text.slice(i + 1, end)}</code>` |
| i = end + 1 |
| continue |
| } |
|
|
| if (ch === '*' || ch === '_') { |
| |
| if (text[i + 1] === ch) { |
| const close = text.indexOf(ch + ch, i + 2) |
| if (close !== -1) { |
| out += `<strong>${renderInline(text.slice(i + 2, close))}</strong>` |
| i = close + 2 |
| continue |
| } |
| } else { |
| |
| |
| if (text[i + 1] !== ' ' && text[i + 1] !== '\t' && text[i + 1] !== ch) { |
| let j = i + 1 |
| while (j < n) { |
| |
| 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 += `<em>${renderInline(text.slice(i + 1, j))}</em>` |
| i = j + 1 |
| continue |
| } |
| } |
| } |
| |
| out += ch |
| i++ |
| continue |
| } |
|
|
| if (ch === '[') { |
| |
| |
| |
| |
| 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 += `<a href="${safeHref(url)}">${renderInline(linkText)}</a>` |
| i = closeParen + 1 |
| continue |
| } |
| } |
| out += ch |
| i++ |
| continue |
| } |
|
|
| out += ch |
| i++ |
| } |
|
|
| return out |
| } |
|
|
| |
|
|
| 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 |
| } |
| |
| let count = 0 |
| for (const c of t) if (c === ch) count++ |
| return count >= 3 |
| } |
|
|
| interface FenceMatch { |
| lang: string |
| raw: string |
| } |
|
|
| function matchFence(line: string): FenceMatch | null { |
| const t = line.trimStart() |
| |
| if (t.startsWith('```')) { |
| const rest = t.slice(3) |
| const lang = rest.trim().split(/\s+/)[0] ?? '' |
| return { lang, raw: rest } |
| } |
| |
| 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 |
| |
| let inner = t |
| if (inner.startsWith('|')) inner = inner.slice(1) |
| if (inner.endsWith('|') && !inner.endsWith('\\|')) inner = inner.slice(0, -1) |
| |
| 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())) |
| |
| 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) { |
| |
| if (!/^[-:\s]+$/.test(c)) return false |
| if (!c.includes('-')) return false |
| } |
| return true |
| } |
|
|