// markdown 渲染 + 代码高亮。依赖 vendor 全局(window.marked / DOMPurify / hljs)。 // 三个库任一缺失 → 优雅降级回
escapeHtml(plain text wrap)。 import { escapeHtml } from "./format.js"; if (window.marked && window.marked.setOptions) { window.marked.setOptions({ gfm: true, breaks: true, headerIds: false, mangle: false }); } const FENCE_RE = /^( {0,3})(`{3,}|~{3,})([^\r\n]*)(\r?\n)?$/; const MERMAID_CONFIG = Object.freeze({ startOnLoad: false, securityLevel: "strict", suppressErrorRendering: true, theme: "neutral", maxTextSize: 50000, maxEdges: 500, fontFamily: '-apple-system, "Segoe UI", "Microsoft YaHei", sans-serif', }); let mermaidInitialized = false; let mermaidSeq = 0; function parseFence(line) { const m = String(line || "").match(FENCE_RE); if (!m) return null; return { indent: m[1], char: m[2][0], len: m[2].length, info: m[3].trim() }; } function nextNonblank(lines, start) { for (let i = start; i < lines.length; i++) { if (lines[i].trim()) return i; } return -1; } function replaceFence(line, length) { const m = String(line || "").match(FENCE_RE); if (!m) return line; return m[1] + m[2][0].repeat(length) + m[3] + (m[4] || ""); } // 只修复明确的「markdown 外层与内层语言块使用同长围栏」形态。 // 其他残缺 Markdown 原样交给 marked,避免猜测作者意图。 export function normalizeMarkdownFences(text) { const lines = String(text || "").match(/.*(?:\r\n|\n|$)/g).filter(Boolean); for (let i = 0; i < lines.length;) { const outer = parseFence(lines[i]); if (!outer || !["markdown", "md"].includes(outer.info.toLowerCase())) { i++; continue; } const innerIdx = nextNonblank(lines, i + 1); const inner = innerIdx >= 0 ? parseFence(lines[innerIdx]) : null; if (!inner || !inner.info || inner.char !== outer.char || inner.len < outer.len) { i++; continue; } let innerCloseIdx = -1; for (let j = innerIdx + 1; j < lines.length; j++) { const close = parseFence(lines[j]); if (close && !close.info && close.char === inner.char && close.len >= inner.len) { innerCloseIdx = j; break; } } if (innerCloseIdx < 0) { i++; continue; } const outerCloseIdx = nextNonblank(lines, innerCloseIdx + 1); const outerClose = outerCloseIdx >= 0 ? parseFence(lines[outerCloseIdx]) : null; if (!outerClose || outerClose.info || outerClose.char !== outer.char || outerClose.len < outer.len) { i++; continue; } const repairedLen = Math.max(outer.len, inner.len) + 1; lines[i] = replaceFence(lines[i], repairedLen); lines[outerCloseIdx] = replaceFence(lines[outerCloseIdx], repairedLen); i = outerCloseIdx + 1; } return lines.join(""); } export function renderMd(text) { const raw = normalizeMarkdownFences(text); if (!window.marked || !window.marked.parse) { return `
${escapeHtml(raw)}`;
}
let html = window.marked.parse(raw);
if (window.DOMPurify) {
html = window.DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
}
return html;
}
function getMermaidApi() {
const api = window.mermaid;
if (!api || typeof api.initialize !== "function" || typeof api.render !== "function") {
return null;
}
if (!mermaidInitialized) {
api.initialize(MERMAID_CONFIG);
mermaidInitialized = true;
}
return api;
}
function mermaidNotice(pre, className, text) {
const doc = pre.ownerDocument || document;
const notice = doc.createElement("div");
notice.className = className;
notice.textContent = text;
pre.before(notice);
}
/** Render completed assistant Mermaid blocks, preserving source on failure. */
export async function renderMermaidIn(container) {
const result = { rendered: 0, failed: 0, unavailable: 0 };
if (!container || typeof container.querySelectorAll !== "function") return result;
const blocks = Array.from(container.querySelectorAll("pre > code.language-mermaid"))
.filter((code) => !code.dataset.mermaidState);
if (!blocks.length) return result;
const api = getMermaidApi();
if (!api) {
for (const code of blocks) {
code.dataset.mermaidState = "unavailable";
mermaidNotice(code.parentElement, "mermaid-notice", "图表组件未加载,已保留 Mermaid 源码。");
result.unavailable++;
}
return result;
}
// Mermaid owns shared configuration/state; render sequentially to avoid races.
for (const code of blocks) {
const pre = code.parentElement;
code.dataset.mermaidState = "rendering";
try {
const id = `zcbot-mermaid-${Date.now()}-${++mermaidSeq}`;
const rendered = await api.render(id, code.textContent || "");
if (!pre || !pre.isConnected) continue;
const doc = pre.ownerDocument || document;
const figure = doc.createElement("figure");
figure.className = "mermaid-diagram";
figure.setAttribute("role", "img");
figure.setAttribute("aria-label", "Mermaid 图表");
const viewport = doc.createElement("div");
viewport.className = "mermaid-viewport";
viewport.innerHTML = rendered.svg;
figure.appendChild(viewport);
pre.replaceWith(figure);
if (typeof rendered.bindFunctions === "function") rendered.bindFunctions(viewport);
result.rendered++;
} catch (_error) {
code.dataset.mermaidState = "error";
if (pre && pre.isConnected) {
mermaidNotice(pre, "mermaid-notice error", "Mermaid 图表语法有误,已保留源码。");
}
result.failed++;
}
}
return result;
}
export function highlightIn(container) {
if (!window.hljs || !container) return;
container.querySelectorAll("pre code").forEach((b) => {
if (b.dataset.hl === "1") return;
if (b.classList && b.classList.contains("language-mermaid")) return;
try { window.hljs.highlightElement(b); b.dataset.hl = "1"; } catch (e) {}
});
}