zcbot/web/static/js/markdown.js

174 lines
5.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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) {}
});
}