fix(mermaid): 改用 script 标签加载 IIFE bundle,修正全局变量取值

根因:mermaid bundle 是 IIFE 格式(与项目其他前端库一致),挂全局变量
而非 ES module export。但 mermaid.ts 错用了 import() 取 .default——
IIFE 无 export,mod.default 是 undefined,导致 mermaid.initialize 调用
TypeError,流程图永远不渲染(数学公式不受影响,因为是服务端渲染)。

修复:
- mermaid.ts: loadMermaidBundle 改用动态注入 <script src=/mermaid/mermaid.js>
  标签,加载后从 window.MermaidRenderer 取(与 codemirror/lightbox 的
  Reflect::get(window,...) 模式一致)
- mermaid-renderer/index.ts: 显式 globalThis.MermaidRenderer = mermaid,
  避免 vite IIFE 把 export default 编译成 .default 多一层
- markdown.rs: mermaid 代码块跳过 syntect 高亮(无语法定义且会包 <span>
  污染 textContent),直接输出转义纯源码

前端 28 测试 + Rust 557 测试 + 双 target 编译通过。
This commit is contained in:
xfy 2026-07-16 15:36:07 +08:00
parent ac04b23b59
commit 445b92f407
3 changed files with 78 additions and 13 deletions

View File

@ -1,5 +1,12 @@
// 把 mermaid 默认导出包成 IIFE 全局 bundle。
// yggdrasil-core 的 mermaid.ts 通过动态 import('/mermaid/mermaid.js') 拿到 .default。
// 把 mermaid 包成 IIFE 全局 bundle。
//
// 关键vite IIFE + `export default X` 会产出 `window.MermaidRenderer.default = X`
// (多一层 .default。为避免 mermaid.ts 取值时纠结这一层,这里把 mermaid 对象
// 直接赋给 window.MermaidRenderer 作为构建时副作用,使 window.MermaidRenderer
// 本身即 mermaid APIinitialize/render
import mermaid from 'mermaid';
// 构建时副作用:把 mermaid 挂到全局。ES module 的顶层赋值会被 vite 保留。
;(globalThis as unknown as { MermaidRenderer: typeof mermaid }).MermaidRenderer = mermaid;
export default mermaid;

View File

@ -1,10 +1,14 @@
/**
* Mermaid
*
* `pre > code.language-mermaid` import bundle
* `pre > code.language-mermaid` IIFE bundle
* `public/mermaid/mermaid.js`~1MB
* mermaid SVG <pre>
*
* bundle IIFE `window.MermaidRenderer`
* ES module `import().default`IIFE export
* `<script src="/mermaid/mermaid.js">` window MermaidRenderer
*
* post-content.tsquerySelectorAll + + DOM
* mermaid SSR markdown.rs
* `language-mermaid` class data
@ -17,21 +21,40 @@ type MermaidApi = {
render: (id: string, text: string) => Promise<{ svg: string }>;
};
declare global {
interface Window {
MermaidRenderer?: MermaidApi;
}
}
let mermaidPromise: Promise<MermaidApi> | null = null;
/**
* mermaid bundle 便
* mermaid IIFE bundle 便
*
* '/mermaid/mermaid.js' importVite
* @vite-ignore import
* tsc TS2307bundle default export mermaid API
* `loadMermaidBundle` mock
* IIFE bundle `window.MermaidRenderer` ES module export
* `<script>` onload window
* [`_resetMermaidLoader`] 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;
};
export let loadMermaidBundle: () => Promise<MermaidApi> = () =>
new Promise((resolve, reject) => {
// 已加载(同页多次调用 / SPA 导航)直接复用。
if (window.MermaidRenderer) {
resolve(window.MermaidRenderer);
return;
}
const script = document.createElement('script');
script.src = '/mermaid/mermaid.js';
script.onload = () => {
if (window.MermaidRenderer) {
resolve(window.MermaidRenderer);
} else {
reject(new Error('mermaid bundle loaded but window.MermaidRenderer undefined'));
}
};
script.onerror = () => reject(new Error('failed to load /mermaid/mermaid.js'));
document.head.appendChild(script);
});
/** 重置加载函数(测试用,重新注入 mock 后必须重置 mermaidPromise 缓存)。 */
export function _resetMermaidLoader(loader?: () => Promise<MermaidApi>): void {

View File

@ -183,6 +183,21 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
code_buffer.push_str(&text);
}
Event::End(TagEnd::CodeBlock) => {
// mermaid 代码块:前端 yggdrasil-core 扫描 language-mermaid 渲染成 SVG
// 源码不应被 syntect 高亮(无语法定义,且会包 <span> 污染 textContent 提取)。
// 直接输出转义后的纯源码,前端 textContent 无损拿到原始 mermaid 文本。
let is_mermaid = code_lang
.as_deref()
.map(|l| l.split_whitespace().next() == Some("mermaid"))
.unwrap_or(false);
if is_mermaid {
let escaped = crate::utils::html::escape_html(&code_buffer);
html.push_str(r#"<pre><code class="language-mermaid">"#);
html.push_str(&escaped);
html.push_str("</code></pre>");
in_codeblock = false;
continue;
}
// 使用 syntect 对代码块进行服务端语法高亮。
let highlighted =
crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref());
@ -907,4 +922,24 @@ console.log(1)
result.html
);
}
#[test]
fn render_markdown_mermaid_block_not_highlighted() {
// mermaid 块跳过 syntect 高亮:源码应是转义纯文本,不被 <span> 包裹。
// 前端 mermaid.ts 用 textContent 无损提取渲染成 SVG。
let result = render_markdown_enhanced("```mermaid\ngraph LR\n A --> B\n```");
assert!(
result.html.contains(r#"class="language-mermaid""#),
"应保留 language-mermaid class, got: {}",
result.html
);
// 不应被 syntect 高亮(无 text plain span
assert!(
!result.html.contains("text plain"),
"mermaid 源码不应被 syntect 高亮, got: {}",
result.html
);
// 源码内容保留HTML 转义后)。
assert!(result.html.contains("graph LR"));
}
}