99 lines
3.6 KiB
JavaScript
99 lines
3.6 KiB
JavaScript
// 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 = () => '<table class="md-table">';
|
|||
|
|
inst.renderer.rules.table_close = () => '</table>';
|
|||
|
|
inst.renderer.rules.thead_open = () => '<thead>';
|
|||
|
|
inst.renderer.rules.tbody_open = () => '<tbody>';
|
|||
|
|
inst.renderer.rules.th_open = () => '<th>';
|
|||
|
|
inst.renderer.rules.td_open = () => '<td>';
|
|||
|
|
// 围栏代码块(```)走 fence 规则,缩进代码块走 code_block 规则,两者统一挂 code-block 类
|
|||
|
|
const codeBlock = (tokens, idx) =>
|
|||
|
|
`<div class="code-block">${inst.utils.escapeHtml(tokens[idx].content)}</div>`;
|
|||
|
|
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(/<td/, '<td class="mono"') : open;
|
|||
|
|
};
|
|||
|
|
md = inst;
|
|||
|
|
return md;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** DOMPurify 实例:浏览器/jsdoms 默认导出即实例;工厂形态(Node 裸环境)按需构造 */
|
|||
|
|
function getPurify() {
|
|||
|
|
if (DOMPurify && typeof DOMPurify.sanitize === 'function') return DOMPurify;
|
|||
|
|
if (typeof DOMPurify === 'function' && typeof window !== 'undefined') {
|
|||
|
|
try {
|
|||
|
|
return DOMPurify(window);
|
|||
|
|
} catch {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** markdown 文本 → 消毒后的 HTML(失败或无消毒环境返回转义原文,不抛异常) */
|
|||
|
|
export function renderMarkdown(text) {
|
|||
|
|
const src = String(text == null ? '' : text);
|
|||
|
|
try {
|
|||
|
|
const html = ensureRenderer().render(src);
|
|||
|
|
const purify = getPurify();
|
|||
|
|
return purify ? purify.sanitize(html) : html;
|
|||
|
|
} catch {
|
|||
|
|
const purify = getPurify();
|
|||
|
|
return purify ? purify.sanitize(escapeHtml(src)) : escapeHtml(src);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 已渲染 HTML(块联合 text.html:创建时已由 renderMarkdown 生成,
|
|||
|
|
* 或后端落库的消毒 HTML)→ 渲染前再消毒一次(纵深防御:绝不信任持久化内容)。
|
|||
|
|
*/
|
|||
|
|
export function sanitizeHtml(html) {
|
|||
|
|
const purify = getPurify();
|
|||
|
|
if (!purify) return String(html == null ? '' : html);
|
|||
|
|
return purify.sanitize(String(html == null ? '' : html));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function escapeHtml(s) {
|
|||
|
|
return s
|
|||
|
|
.replaceAll('&', '&')
|
|||
|
|
.replaceAll('<', '<')
|
|||
|
|
.replaceAll('>', '>')
|
|||
|
|
.replaceAll('"', '"')
|
|||
|
|
.replaceAll("'", ''');
|
|||
|
|
}
|