/**
* 极简、零依赖、先转义后渲染的 Markdown→HTML。仅覆盖聊天场景常用语法:
* 标题 / 粗斜体 / 行内代码 / 围栏代码块 / 有序无序列表 / 引用 / 链接 / 段落与换行。
* 安全策略:所有文本先 HTML 转义,仅注入我们自己生成的标签;链接仅允许 http(s)/相对/锚点。
* 如需完整 CommonMark,可后续替换为 markdown-it(届时记得保留 XSS 防护)。
*/
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
}
function safeUrl(url: string): string | null {
const u = url.trim()
if (/^https?:\/\//i.test(u) || u.startsWith('/') || u.startsWith('#')) return u
return null
}
/** 行内格式:行内代码先占位保护,再处理链接/粗体/斜体,最后还原代码。入参须已 HTML 转义。 */
function inline(escaped: string): string {
const codes: string[] = []
let s = escaped.replace(/`([^`]+)`/g, (_m, c: string) => {
codes.push(`${c}`)
return `\u0001${codes.length - 1}\u0001`
})
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text: string, url: string) => {
const safe = safeUrl(url)
if (!safe) return `[${text}](${url})`
return `${text}`
})
s = s.replace(/\*\*([^*]+)\*\*/g, '$1')
s = s.replace(/__([^_]+)__/g, '$1')
s = s.replace(/(^|[^*])\*([^*\s][^*]*)\*/g, '$1$2')
s = s.replace(/(^|[^_])_([^_\s][^_]*)_/g, '$1$2')
s = s.replace(/\u0001(\d+)\u0001/g, (_m, i: string) => codes[Number(i)] ?? '')
return s
}
export function renderMarkdown(src: string): string {
if (!src) return ''
const text = src.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const blocks: string[] = []
const withTokens = text.replace(/```([^\n]*)\n([\s\S]*?)```/g, (_m, lang: string, code: string) => {
const cls = (lang || '').trim()
const body = escapeHtml(code.replace(/\n$/, ''))
const clsAttr = cls ? ` class="language-${escapeHtml(cls)}"` : ''
blocks.push(`
${body}`)
return `\u0000${blocks.length - 1}\u0000`
})
const lines = withTokens.split('\n')
const out: string[] = []
let para: string[] = []
let i = 0
const flushPara = (): void => {
if (para.length) {
out.push(`${inline(escapeHtml(para.join(' ')))}
`) para = [] } } while (i < lines.length) { const line = lines[i] const codeToken = line.match(/^\u0000(\d+)\u0000\s*$/) if (codeToken) { flushPara() out.push(blocks[Number(codeToken[1])] ?? '') i++ continue } if (/^\s*$/.test(line)) { flushPara() i++ continue } const h = line.match(/^(#{1,6})\s+(.*)$/) if (h) { flushPara() const lvl = h[1].length const tag = lvl <= 2 ? 'h3' : lvl === 3 ? 'h4' : 'h5' out.push(`<${tag} class="mmd-h">${inline(escapeHtml(h[2]))}${tag}>`) i++ continue } if (/^\s*>\s?/.test(line)) { flushPara() const items: string[] = [] while (i < lines.length && /^\s*>\s?/.test(lines[i])) { items.push(lines[i].replace(/^\s*>\s?/, '')) i++ } out.push(`${inline(escapeHtml(items.join(' ')))}`) continue } if (/^\s*[-*+]\s+/.test(line)) { flushPara() const items: string[] = [] while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*[-*+]\s+/, '')) i++ } out.push(`