xfy 70edc5e46c feat(frontend): mermaid 流程图懒加载渲染
mermaid 无官方 SSR 支持,采用客户端 IntersectionObserver 懒加载:服务端
markdown.rs 只产出普通 <pre><code class="language-mermaid"> 代码块,
前端在块进视口时动态 import 独立 bundle(~1MB)渲染成 SVG。

新增 libs/mermaid-renderer/:mermaid 11.16 IIFE bundle,输出 public/mermaid/
yggdrasil-core/mermaid.ts:扫描 language-mermaid 块,视口可见时动态 import
  /mermaid/mermaid.js(单例缓存),mermaid.initialize 适配 light/dark 主题,
  securityLevel=strict;渲染失败加 .mermaid-error class 保留源码;幂等守卫
post-content.ts:initCopyButtons 跳过 language-mermaid 块(图无需 copy)
post_content.rs:use_effect 调 __initMermaid('.post-content', theme),
  读 use_resolved_theme() 传主题并建立订阅

新增 6 个 mermaid 单测(扫描/主题/幂等/非mermaid/未命中/错误回退)。
550 Rust + 28 前端测试全过,双 target 编译通过。
2026-07-16 14:22:43 +08:00

117 lines
4.2 KiB
TypeScript
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.

/**
* Mermaid 流程图懒加载渲染。
*
* 扫描 `pre > code.language-mermaid` 代码块,在进入视口时动态 import 独立 bundle
* `public/mermaid/mermaid.js`~1MB只在有图且可见时加载不影响无图文章首屏
* 把 mermaid 源码渲染成 SVG 注入到父 <pre>。
*
* 范式照搬 post-content.tsquerySelectorAll + 幂等守卫 + 注入 DOM。
* mermaid 无官方 SSR 支持,纯客户端渲染;服务端 markdown.rs 只产出带
* `language-mermaid` class 的普通代码块,不挂 data 属性。
*/
import type { ThemeName } from '@yggdrasil/shared';
type MermaidApi = {
initialize: (config: Record<string, unknown>) => void;
render: (id: string, text: string) => Promise<{ svg: string }>;
};
let mermaidPromise: Promise<MermaidApi> | null = null;
/**
* 动态加载 mermaid 独立 bundle 的底层函数(可注入以便测试)。
*
* 用绝对路径 '/mermaid/mermaid.js' 动态 importVite 无法静态分析此字面量,
* 故加 @vite-ignore 避免构建时报错,且该路径无类型声明需用函数间接构造 import
* 字面量以绕过 tsc 的模块解析TS2307。bundle 加载后 default export 即 mermaid API。
* 测试时可通过重赋值 `loadMermaidBundle` 替换为 mock。
*/
export let loadMermaidBundle: () => Promise<MermaidApi> = 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<MermaidApi>): void {
mermaidPromise = null;
if (loader) loadMermaidBundle = loader;
}
/**
* 动态加载 mermaid 独立 bundle单例缓存失败清空允许重试
*/
function loadMermaid(): Promise<MermaidApi> {
if (!mermaidPromise) {
mermaidPromise = loadMermaidBundle().catch((err) => {
mermaidPromise = null;
throw err;
});
}
return mermaidPromise;
}
/**
* 为单个 mermaid <pre> 注册 IntersectionObserver进入视口才渲染。
*
* 无 IntersectionObserverSSR / 旧环境)时直接同步渲染。
* rootMargin 200px 让图在接近视口时提前加载,避免滚到才白屏。
*/
function observeBlock(pre: HTMLPreElement, render: () => Promise<void>): 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<HTMLPreElement>('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);
// 替换整个 <pre> 内容为 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');
}
});
});
}