/** * Mermaid 流程图懒加载渲染。 * * 扫描 `pre > code.language-mermaid` 代码块,在进入视口时动态 import 独立 bundle * `public/mermaid/mermaid.js`(~1MB,只在有图且可见时加载,不影响无图文章首屏), * 把 mermaid 源码渲染成 SVG 注入到父
。
*
* 范式照搬 post-content.ts:querySelectorAll + 幂等守卫 + 注入 DOM。
* mermaid 无官方 SSR 支持,纯客户端渲染;服务端 markdown.rs 只产出带
* `language-mermaid` class 的普通代码块,不挂 data 属性。
*/
import type { ThemeName } from '@yggdrasil/shared';
type MermaidApi = {
initialize: (config: Record) => void;
render: (id: string, text: string) => Promise<{ svg: string }>;
};
let mermaidPromise: Promise | null = null;
/**
* 动态加载 mermaid 独立 bundle 的底层函数(可注入以便测试)。
*
* 用绝对路径 '/mermaid/mermaid.js' 动态 import:Vite 无法静态分析此字面量,
* 故加 @vite-ignore 避免构建时报错,且该路径无类型声明需用函数间接构造 import
* 字面量以绕过 tsc 的模块解析(TS2307)。bundle 加载后 default export 即 mermaid API。
* 测试时可通过重赋值 `loadMermaidBundle` 替换为 mock。
*/
export let loadMermaidBundle: () => Promise = async () => {
const url = '/mermaid/mermaid.js';
const mod = (await import(/* @vite-ignore */ url)) as { default: MermaidApi };
return mod.default;
};
/** 重置加载函数(测试用,重新注入 mock 后必须重置 mermaidPromise 缓存)。 */
export function _resetMermaidLoader(loader?: () => Promise): void {
mermaidPromise = null;
if (loader) loadMermaidBundle = loader;
}
/**
* 动态加载 mermaid 独立 bundle(单例缓存,失败清空允许重试)。
*/
function loadMermaid(): Promise {
if (!mermaidPromise) {
mermaidPromise = loadMermaidBundle().catch((err) => {
mermaidPromise = null;
throw err;
});
}
return mermaidPromise;
}
/**
* 为单个 mermaid 注册 IntersectionObserver:进入视口才渲染。
*
* 无 IntersectionObserver(SSR / 旧环境)时直接同步渲染。
* rootMargin 200px 让图在接近视口时提前加载,避免滚到才白屏。
*/
function observeBlock(pre: HTMLPreElement, render: () => Promise): void {
if (typeof IntersectionObserver === 'undefined') {
void render();
return;
}
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
io.disconnect();
void render();
}
},
{ rootMargin: '200px' },
);
io.observe(pre);
}
/**
* 初始化文章正文里的 mermaid 代码块。
*
* @param selector 文章正文容器选择器(如 '.post-content')
* @param theme 当前生效主题,传给 mermaid 适配暗色
*/
export function initMermaid(selector: string, theme: ThemeName): void {
const root = document.querySelector(selector);
if (!root) return;
const blocks = root.querySelectorAll('pre > code.language-mermaid');
if (blocks.length === 0) return;
blocks.forEach((code, i) => {
const pre = code.parentElement as HTMLPreElement | null;
if (!pre) return;
if (pre.dataset.mermaidRendered) return; // 幂等:上下篇切换重复调用不重渲染
const source = code.textContent || '';
observeBlock(pre, async () => {
try {
const mermaid = await loadMermaid();
mermaid.initialize({
startOnLoad: false,
theme: theme === 'dark' ? 'dark' : 'default',
securityLevel: 'strict',
});
const { svg } = await mermaid.render(`mermaid-svg-${i}`, source);
// 替换整个 内容为 SVG,丢弃 copy 按钮(图不需要复制源码)。
pre.innerHTML = svg;
pre.dataset.mermaidRendered = 'true';
} catch (err) {
// 渲染失败(语法错误 / bundle 加载失败):保留原始源码,加错误标记 class
// 便于用户发现是 mermaid 源写错了。不破坏页面其余内容。
console.error('mermaid render failed:', err);
pre.classList.add('mermaid-error');
}
});
});
}