// chat/markdown.js —— LLM 输出的 Markdown → 消毒 HTML(B2 归属) // FR-CHAT-004:markdown-it 解析 → DOMPurify 消毒 → v-html。 // 自定义渲染规则挂 `md-table` / `code-block` 类(UI_SPEC §4.3)。 import MarkdownIt from 'markdown-it'; import DOMPurify from 'dompurify'; /** 表头为「说明」的列保持比例字体,其余列 mono(UI_SPEC §4.3) */ export const NON_MONO_HEADER = '说明'; let md = null; function ensureRenderer() { if (md) return md; const inst = new MarkdownIt({ html: false, // LLM 输出先不解析内联 HTML,统一由 DOMPurify 兜底 linkify: true, breaks: false, }); inst.renderer.rules.table_open = () => ''; inst.renderer.rules.table_close = () => '
'; inst.renderer.rules.thead_open = () => ''; inst.renderer.rules.tbody_open = () => ''; inst.renderer.rules.th_open = () => ''; inst.renderer.rules.td_open = () => ''; // 围栏代码块(```)走 fence 规则,缩进代码块走 code_block 规则,两者统一挂 code-block 类 const codeBlock = (tokens, idx) => `
${inst.utils.escapeHtml(tokens[idx].content)}
`; inst.renderer.rules.fence = codeBlock; inst.renderer.rules.code_block = codeBlock; // 数据格 mono:表头为「说明」的列除外。 // markdown-it 的 td 行内内容在 td_open 时尚不可用,但 thead 一定先于 tbody 渲染, // 故在 th_open 时记录当前列表头文本即可(渲染为同步单线程,无并发交错)。 const cellText = (token) => { let s = ''; for (const t of token.children || []) s += t.content || ''; return s; }; const prevTh = { value: '' }; const _thOpen = inst.renderer.rules.th_open; const _tdOpen = inst.renderer.rules.td_open; inst.renderer.rules.th_open = (tokens, idx, options, env, self) => { prevTh.value = cellText(tokens[idx]); return _thOpen(tokens, idx, options, env, self); }; inst.renderer.rules.td_open = (tokens, idx, options, env, self) => { const open = _tdOpen(tokens, idx, options, env, self); return prevTh.value !== NON_MONO_HEADER ? open.replace(/', '>') .replaceAll('"', '"') .replaceAll("'", '''); }