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:
parent
218533402b
commit
70edc5e46c
1
.gitignore
vendored
1
.gitignore
vendored
@ -14,6 +14,7 @@ public/codemirror
|
||||
public/lightbox
|
||||
public/yggdrasil-core
|
||||
public/xterm
|
||||
public/mermaid
|
||||
public/doc
|
||||
/static
|
||||
.env
|
||||
|
||||
2
Makefile
2
Makefile
@ -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
|
||||
|
||||
14
libs/mermaid-renderer/package.json
Normal file
14
libs/mermaid-renderer/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
5
libs/mermaid-renderer/src/index.ts
Normal file
5
libs/mermaid-renderer/src/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// 把 mermaid 默认导出包成 IIFE 全局 bundle。
|
||||
// yggdrasil-core 的 mermaid.ts 通过动态 import('/mermaid/mermaid.js') 拿到 .default。
|
||||
import mermaid from 'mermaid';
|
||||
|
||||
export default mermaid;
|
||||
4
libs/mermaid-renderer/tsconfig.json
Normal file
4
libs/mermaid-renderer/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
27
libs/mermaid-renderer/vite.config.ts
Normal file
27
libs/mermaid-renderer/vite.config.ts
Normal 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.js,dx 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
806
libs/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
|
||||
120
libs/yggdrasil-core/src/mermaid.test.ts
Normal file
120
libs/yggdrasil-core/src/mermaid.test.ts
Normal 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 IntersectionObserver:observe 时立即异步触发 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-->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-->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-->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);
|
||||
});
|
||||
});
|
||||
});
|
||||
116
libs/yggdrasil-core/src/mermaid.ts
Normal file
116
libs/yggdrasil-core/src/mermaid.ts
Normal file
@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Mermaid 流程图懒加载渲染。
|
||||
*
|
||||
* 扫描 `pre > code.language-mermaid` 代码块,在进入视口时动态 import 独立 bundle
|
||||
* `public/mermaid/mermaid.js`(~1MB,只在有图且可见时加载,不影响无图文章首屏),
|
||||
* 把 mermaid 源码渲染成 SVG 注入到父 <pre>。
|
||||
*
|
||||
* 范式照搬 post-content.ts:querySelectorAll + 幂等守卫 + 注入 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' 动态 import:Vite 无法静态分析此字面量,
|
||||
* 故加 @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:进入视口才渲染。
|
||||
*
|
||||
* 无 IntersectionObserver(SSR / 旧环境)时直接同步渲染。
|
||||
* 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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 尾部读到配置自启动。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user