// 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.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("'", ''');
}
|