174 lines
5.9 KiB
JavaScript
174 lines
5.9 KiB
JavaScript
// markdown 渲染 + 代码高亮。依赖 vendor 全局(window.marked / DOMPurify / hljs)。
|
||
// 三个库任一缺失 → 优雅降级回 <pre>escapeHtml</pre>(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 `<pre style="white-space:pre-wrap;word-break:break-word;font-family:inherit;margin:0;">${escapeHtml(raw)}</pre>`;
|
||
}
|
||
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) {}
|
||
});
|
||
}
|