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 编译通过。
This commit is contained in:
xfy 2026-07-16 14:22:43 +08:00
parent 218533402b
commit 70edc5e46c
12 changed files with 1118 additions and 0 deletions

1
.gitignore vendored
View File

@ -14,6 +14,7 @@ public/codemirror
public/lightbox
public/yggdrasil-core
public/xterm
public/mermaid
public/doc
/static
.env

View File

@ -107,6 +107,7 @@ build-codemirror: ; @cd libs && pnpm --filter @yggdrasil/codemirror-editor run b
build-lightbox: ; @cd libs && pnpm --filter @yggdrasil/lightbox run build
build-core: ; @cd libs && pnpm --filter @yggdrasil/core run build
build-xterm: ; @cd libs && pnpm --filter @yggdrasil/xterm-terminal run build
build-mermaid: ; @cd libs && pnpm --filter @yggdrasil/mermaid-renderer run build
dev: build-libs highlight-css katex-css
@echo "Cleaning static/..."
@ -202,6 +203,7 @@ clean:
@cargo clean
@rm -f public/style.css public/highlight.css
@rm -rf public/katex
@rm -rf public/mermaid
@rm -rf public/doc
@rm -rf uploads/.cache
@rm -rf libs/node_modules libs/*/node_modules

View File

@ -0,0 +1,14 @@
{
"name": "@yggdrasil/mermaid-renderer",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc --noEmit && vite build",
"dev": "vite",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"mermaid": "^11.16.0"
}
}

View File

@ -0,0 +1,5 @@
// 把 mermaid 默认导出包成 IIFE 全局 bundle。
// yggdrasil-core 的 mermaid.ts 通过动态 import('/mermaid/mermaid.js') 拿到 .default。
import mermaid from 'mermaid';
export default mermaid;

View File

@ -0,0 +1,4 @@
{
"extends": "../tsconfig.base.json",
"include": ["src"]
}

View File

@ -0,0 +1,27 @@
import { resolve } from 'node:path';
import { defineConfig } from 'vite';
// mermaid 独立 IIFE bundle~1MB不打进 yggdrasil-core避免拖累每篇文章首屏
// 由 yggdrasil-core 的 mermaid.ts 在 IntersectionObserver 视口可见时动态 import。
// 输出到 public/mermaid/mermaid.jsdx build 会作为静态资源拷贝。
export default defineConfig({
build: {
outDir: resolve(__dirname, '../../public/mermaid'),
emptyOutDir: true,
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'MermaidRenderer',
fileName: () => 'mermaid.js',
formats: ['iife'],
},
// mermaid 全量打进单文件(含其依赖 cytoscape/dagre-d3 等)。
rolldownOptions: {
output: {
inlineDynamicImports: true,
},
},
cssCodeSplit: false,
minify: true,
sourcemap: true,
},
});

806
libs/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,15 @@
import { initAnchorClick } from './anchor-click';
import { scrollToHash } from './hash-scroll';
import { initMermaid } from './mermaid';
import { initPostContent } from './post-content';
import { applyResolvedTheme, startThemeTransition } from './theme-transition';
import type { ThemeName } from '@yggdrasil/shared';
import './style.css';
declare global {
interface Window {
__initPostContent: (selector: string) => void;
__initMermaid: (selector: string, theme: ThemeName) => void;
__initAnchorClick: () => void;
__scrollToHash: () => void;
__startThemeTransition: (x: number, y: number) => void;
@ -15,6 +18,7 @@ declare global {
}
window.__initPostContent = initPostContent;
window.__initMermaid = initMermaid;
window.__initAnchorClick = initAnchorClick;
window.__scrollToHash = scrollToHash;
window.__startThemeTransition = startThemeTransition;

View File

@ -0,0 +1,120 @@
/**
* mermaid 退
* window.__initMermaid mock IntersectionObserver mermaid bundle
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import './index';
import { _resetMermaidLoader } from './mermaid';
// mock IntersectionObserverobserve 时立即异步触发 isIntersecting 回调,模拟块进视口。
const disconnect = vi.fn();
const observe = vi.fn();
let intersectCallback: ((entries: { isIntersecting: boolean }[]) => void) | null = null;
vi.stubGlobal(
'IntersectionObserver',
class {
constructor(cb: (entries: { isIntersecting: boolean }[]) => void) {
intersectCallback = cb;
}
observe() {
observe();
// 立即触发可见,模拟块已在视口内。
if (intersectCallback) intersectCallback([{ isIntersecting: true }]);
}
disconnect() {
disconnect();
}
},
);
describe('initMermaid', () => {
const mockRender = vi.fn().mockResolvedValue({ svg: '<svg>diagram</svg>' });
const mockInitialize = vi.fn();
beforeEach(() => {
document.body.innerHTML = '';
mockRender.mockResolvedValue({ svg: '<svg>diagram</svg>' });
mockRender.mockClear();
mockInitialize.mockClear();
observe.mockClear();
disconnect.mockClear();
// 注入 mock mermaid bundle 加载函数
_resetMermaidLoader(async () => ({ initialize: mockInitialize, render: mockRender }));
});
afterEach(() => {
document.body.innerHTML = '';
});
it('扫描 language-mermaid 块并渲染成 SVG', async () => {
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-mermaid">graph TD; A--&gt;B</code></pre>';
document.body.appendChild(root);
window.__initMermaid('.post-content', 'light');
await vi.waitFor(() => {
expect(root.querySelector('pre')?.innerHTML).toContain('<svg>diagram</svg>');
});
});
it('用 dark 主题初始化 mermaid', async () => {
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-mermaid">graph TD; A--&gt;B</code></pre>';
document.body.appendChild(root);
window.__initMermaid('.post-content', 'dark');
await vi.waitFor(() => {
expect(mockInitialize).toHaveBeenCalledWith(expect.objectContaining({ theme: 'dark' }));
});
});
it('幂等:重复调用不重复渲染已处理的块', async () => {
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-mermaid">graph TD; A--&gt;B</code></pre>';
document.body.appendChild(root);
window.__initMermaid('.post-content', 'light');
await vi.waitFor(() => {
expect(root.querySelector('pre')?.dataset.mermaidRendered).toBe('true');
});
mockRender.mockClear();
// 第二次调用(模拟上下篇切换):不应再次 render
window.__initMermaid('.post-content', 'light');
await new Promise((r) => setTimeout(r, 50));
expect(mockRender).not.toHaveBeenCalled();
});
it('非 mermaid 代码块不受影响', () => {
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-rust">fn main() {}</code></pre>';
document.body.appendChild(root);
expect(() => window.__initMermaid('.post-content', 'light')).not.toThrow();
// 不应尝试渲染 rust 块
expect(observe).not.toHaveBeenCalled();
});
it('selector 未命中时不报错', () => {
expect(() => window.__initMermaid('.not-exist', 'light')).not.toThrow();
});
it('渲染失败时加 mermaid-error class', async () => {
mockRender.mockRejectedValueOnce(new Error('syntax error'));
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-mermaid">bad syntax</code></pre>';
document.body.appendChild(root);
window.__initMermaid('.post-content', 'light');
await vi.waitFor(() => {
expect(root.querySelector('pre')?.classList.contains('mermaid-error')).toBe(true);
});
});
});

View File

@ -0,0 +1,116 @@
/**
* 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 TS2307bundle 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');
}
});
});
}

View File

@ -11,6 +11,8 @@ function initCopyButtons(root: Element): void {
const pre = code.parentElement;
if (!pre) return;
if (pre.querySelector('.copy-code')) return;
// mermaid 代码块由 mermaid.ts 渲染成 SVG不注入 copy 按钮(图无需复制源码)。
if (code.classList.contains('language-mermaid')) return;
const btn = document.createElement('button');
btn.className = 'copy-code';

View File

@ -136,6 +136,13 @@ pub fn PostContent(content_html: String) -> Element {
// 每次渲染重新解析开销可控。
let fragments = split_content_fragments(&content_html);
// mermaid 流程图主题需随当前生效主题light/dark切换。读 use_resolved_theme()
// 建立订阅:主题变化时下方 use_effect 重跑,重新调用 __initMermaid幂等
// 已渲染的块由 dataset.mermaidRendered 守卫跳过;主题切换暂不强制重渲染图,
// 后续如需可在 mermaid.ts 监听 THEME_CHANGE_EVENT
#[cfg(target_arch = "wasm32")]
let resolved_theme = crate::theme::use_resolved_theme();
#[cfg(target_arch = "wasm32")]
use_effect(move || {
let window = web_sys::window().unwrap();
@ -144,6 +151,16 @@ pub fn PostContent(content_html: String) -> Element {
// (与旧 eval 中的 if 守卫语义一致)。
invoke_optional_global(&window, "__initPostContent", &[".post-content".into()]);
// mermaid 流程图懒加载渲染:扫描 .post-content 下的 language-mermaid 代码块,
// IntersectionObserver 视口可见时动态 import /mermaid/mermaid.js 渲染成 SVG。
// 读取 resolved_theme() 既是为了传主题,也建立订阅让主题切换重跑此 effect。
let theme_str: String = if resolved_theme() == crate::theme::ResolvedTheme::Dark {
"dark".into()
} else {
"light".into()
};
invoke_optional_global(&window, "__initMermaid", &[".post-content".into(), theme_str.into()]);
// lightbox 改由 Dioxus.toml 全局 <script src> 加载(不再 include_str!)。
// 双保险契约:先设配置,若 lightbox.js 已加载则立即调用;
// 否则 lightbox.js 加载完后其 IIFE 尾部读到配置自启动。