Skip to main content

code_repo_wiki/output/
html.rs

1use std::path::Path;
2
3use anyhow::{Context, Result};
4use pulldown_cmark::{Options, Parser, html};
5
6use crate::config::schema::WikiConfig;
7use crate::model::{KnowledgeCard, WikiDocument};
8use crate::output::{ExportModuleSnapshot, card_page_path, wiki_page_html_path};
9
10/// 将整个 Wiki 输出导出为 HTML 页面集合
11///
12/// 输出结构(与 markdown 产物同构,命名规则一致仅扩展名不同):
13///   {output.dir}/wiki/{lang}/{stem}.html — 每个文档的 HTML 页面(按语言分目录)
14///   {output.dir}/index.html             — 目录页
15///   {output.dir}/style.css              — 基本样式
16///   {output.dir}/cards/{lang}/{stem}.html — Knowledge Card 页面(随关联文档语言落盘)
17///   {output.dir}/assets/module-deps.html — Mermaid 模块依赖图
18///
19/// `modules` 为快照形态的模块摘要(export_modules 产出),不依赖完整图,
20/// 使 export --skip-generate 可直接从导出快照构造调用。
21pub fn export_html(
22    documents: &[WikiDocument],
23    cards: &[KnowledgeCard],
24    modules: &[ExportModuleSnapshot],
25    config: &WikiConfig,
26) -> Result<()> {
27    let output_dir = config.output_dir();
28    let wiki_dir = output_dir.join("wiki");
29    let cards_dir = output_dir.join("cards");
30    let assets_dir = output_dir.join("assets");
31
32    std::fs::create_dir_all(&wiki_dir)
33        .with_context(|| format!("创建 wiki 目录失败: {}", wiki_dir.display()))?;
34    std::fs::create_dir_all(&cards_dir)
35        .with_context(|| format!("创建 cards 目录失败: {}", cards_dir.display()))?;
36    std::fs::create_dir_all(&assets_dir)
37        .with_context(|| format!("创建 assets 目录失败: {}", assets_dir.display()))?;
38
39    // 每个 WikiDocument → wiki/{lang}/{stem}.html(命名与 markdown 同构;
40    // 链接重写:正文中的内部 .md 相对链接在转换前改为 .html)
41    for doc in documents {
42        let body = md_to_html(&rewrite_md_links_to_html(&doc.content));
43        let html = wrap_html(&doc.title, &body, "../../style.css");
44        let path = wiki_page_html_path(output_dir, doc);
45        write_html_file(&path, &html)?;
46    }
47
48    // 生成 index.html(目录页):按模块分组(与 _toc.md 的 index 优先导航一致)。
49    // 链接指向 wiki/{doc.language}/{stem}.html(与页面落盘路径同一规则)。
50    let mut module_groups: std::collections::BTreeMap<String, Vec<&WikiDocument>> = Default::default();
51    let mut global_docs: Vec<&WikiDocument> = Vec::new();
52    for doc in documents {
53        if doc.module_path.is_empty() {
54            global_docs.push(doc);
55        } else {
56            module_groups
57                .entry(doc.module_path.join("::"))
58                .or_default()
59                .push(doc);
60        }
61    }
62    let mut toc_items = String::new();
63    if !global_docs.is_empty() {
64        toc_items.push_str("<h2>全局文档</h2>\n<ul>\n");
65        for doc in &global_docs {
66            toc_items.push_str(&format!(
67                "<li><a href=\"{}\">{}</a></li>\n",
68                wiki_html_link(output_dir, doc),
69                escape_html(&doc.title)
70            ));
71        }
72        toc_items.push_str("</ul>\n");
73    }
74    toc_items.push_str("<h2>模块</h2>\n");
75    for (module, docs) in &module_groups {
76        toc_items.push_str(&format!("<h3>{}</h3>\n<ul>\n", escape_html(module)));
77        for doc in docs {
78            toc_items.push_str(&format!(
79                "<li><a href=\"{}\">{}</a></li>\n",
80                wiki_html_link(output_dir, doc),
81                escape_html(&doc.title)
82            ));
83        }
84        toc_items.push_str("</ul>\n");
85    }
86    let toc_body = format!(
87        "<h1>Wiki 目录</h1>\n<p>共 {} 个文档</p>\n{}\n",
88        documents.len(),
89        toc_items
90    );
91    let toc_html = wrap_html("Wiki 目录", &toc_body, "style.css");
92    write_html_file(&output_dir.join("index.html"), &toc_html)?;
93
94    // 生成 style.css
95    let css = r#"body {
96    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
97    line-height: 1.6;
98    max-width: 960px;
99    margin: 0 auto;
100    padding: 20px;
101    color: #333;
102}
103h1, h2, h3, h4 { margin-top: 1.5em; margin-bottom: 0.5em; }
104code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
105pre { background: #f4f4f4; padding: 16px; border-radius: 6px; overflow-x: auto; }
106pre code { background: none; padding: 0; }
107table { border-collapse: collapse; width: 100%; margin: 1em 0; }
108th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
109th { background: #f8f8f8; }
110a { color: #0366d6; text-decoration: none; }
111a:hover { text-decoration: underline; }
112ul, ol { padding-left: 24px; }
113blockquote { border-left: 4px solid #ddd; margin: 0; padding: 0 16px; color: #666; }
114"#;
115    write_html_file(&output_dir.join("style.css"), css)?;
116
117    // 生成 assets/module-deps.html(通过 CDN 嵌入 Mermaid)
118    if !modules.is_empty() {
119        let mut mermaid_lines = vec!["graph TD".to_string()];
120        // U05/D9:节点名规范化与依赖边共用同一规则(非字母数字 → _),
121        // 保证节点声明与边引用同名(此前只有节点行零边——快照无依赖字段)
122        let node_id = |name: &str| name.replace(|c: char| !c.is_alphanumeric(), "_");
123        for module in modules {
124            if !module.files.is_empty() {
125                // ponytail: 简化为模块级节点,不展开到每个实体
126                mermaid_lines.push(format!(
127                    "    {}[\"{}\"]",
128                    node_id(&module.name),
129                    escape_html(&module.name)
130                ));
131            }
132        }
133        // 依赖边:模块 → 依赖模块(BTreeSet 字典序,输出确定性)
134        for module in modules {
135            for dep in &module.dependencies {
136                mermaid_lines.push(format!("    {} --> {}", node_id(&module.name), node_id(dep)));
137            }
138        }
139        let mermaid_code = mermaid_lines.join("\n");
140
141        let mermaid_html = format!(
142            r#"<!DOCTYPE html>
143<html lang="zh">
144<head><meta charset="utf-8"><title>模块依赖图</title>
145<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
146<script>mermaid.initialize({{startOnLoad:true}});</script>
147<style>body{{font-family:sans-serif;padding:20px;}}</style>
148</head>
149<body>
150<h1>模块依赖图</h1>
151<div class="mermaid">
152{}
153</div>
154</body>
155</html>"#,
156            mermaid_code
157        );
158        write_html_file(&assets_dir.join("module-deps.html"), &mermaid_html)?;
159    }
160
161    // KnowledgeCard → cards/{lang}/{stem}.html(精确关联:卡片随其模块文档
162    // 的语言落盘,与 render_all 的写盘规则一致;无匹配文档的卡片不写盘)
163    for doc in documents {
164        let doc_module = doc.module_path.join("::");
165        for card in cards {
166            if card.module_name != doc_module {
167                continue;
168            }
169            let html = wrap_html(&card.module_name, &render_card_body(card), "../../style.css");
170            let path = card_page_path(output_dir, &doc.language, &card.module_name).with_extension("html");
171            write_html_file(&path, &html)?;
172        }
173    }
174
175    Ok(())
176}
177
178/// 渲染卡片正文(标题、摘要、实体、依赖、模式、人工修改待同步)
179fn render_card_body(card: &KnowledgeCard) -> String {
180    let mut body = format!(
181        "<h1>{}</h1>\n<p><strong>类型:</strong> {}</p>\n<p>{}</p>\n",
182        escape_html(&card.module_name),
183        escape_html(&card.module_type),
184        escape_html(&card.summary)
185    );
186
187    if !card.key_entities.is_empty() {
188        body.push_str("<h2>关键实体</h2>\n<ul>\n");
189        for entity in &card.key_entities {
190            body.push_str(&format!(
191                "  <li><strong>{}</strong> ({}) — {}</li>\n",
192                escape_html(&entity.name),
193                escape_html(&entity.kind),
194                entity.doc.as_deref().unwrap_or("")
195            ));
196        }
197        body.push_str("</ul>\n");
198    }
199
200    if !card.dependencies.is_empty() {
201        body.push_str(&format!(
202            "<h2>依赖</h2>\n<p>{}</p>\n",
203            card.dependencies
204                .iter()
205                .map(|d| escape_html(d))
206                .collect::<Vec<_>>()
207                .join(", ")
208        ));
209    }
210
211    if !card.design_patterns.is_empty() {
212        body.push_str(&format!(
213            "<h2>设计模式</h2>\n<ul>\n<li>{}</li>\n</ul>\n",
214            card.design_patterns
215                .iter()
216                .map(|p| escape_html(p))
217                .collect::<Vec<_>>()
218                .join("</li>\n<li>")
219        ));
220    }
221
222    // 人工修改待同步(与 markdown 渲染对应,仅非空时输出)
223    if !card.pending_manual_edits.is_empty() {
224        body.push_str("<h2>人工修改待同步</h2>\n<ul>\n");
225        for note in &card.pending_manual_edits {
226            body.push_str(&format!("  <li>{}</li>\n", escape_html(note)));
227        }
228        body.push_str("</ul>\n");
229    }
230
231    body
232}
233
234/// 文档的 HTML 页面相对链接(相对 output_dir,正斜杠分隔,供 index.html 使用)
235fn wiki_html_link(output_dir: &Path, doc: &WikiDocument) -> String {
236    wiki_page_html_path(output_dir, doc)
237        .strip_prefix(output_dir)
238        .map(|p| p.to_string_lossy().replace('\\', "/"))
239        .unwrap_or_default()
240}
241
242/// 将 markdown 文本中的内部 .md 相对链接重写为 .html(HTML 导出用,纯函数)
243///
244/// 只重写 `](target)` 形态的链接且 target 含 ".md"(相对链接,如
245/// `wiki/zh/a.md`、`a.md#锚点`——锚点保留);外部链接(含 `://`,如
246/// `https://x.com/a.md`)与源码定位链接(`[源码:path]`,无 .md 后缀)不重写。
247pub fn rewrite_md_links_to_html(md: &str) -> String {
248    let mut out = String::new();
249    let mut rest = md;
250    while let Some(start) = rest.find("](") {
251        out.push_str(&rest[..start + 2]);
252        let after = &rest[start + 2..];
253        let end = after.find(')').unwrap_or(after.len());
254        let target = &after[..end];
255        if !target.contains("://") {
256            if let Some(md_end) = target.find(".md") {
257                out.push_str(&target[..md_end]);
258                out.push_str(".html");
259                out.push_str(&target[md_end + 3..]);
260            } else {
261                out.push_str(target);
262            }
263        } else {
264            out.push_str(target);
265        }
266        // 只移动搜索起点,剩余段由循环后的 out.push_str(rest) 统一收尾——
267        // 这里若再 push 剩余段会与最终收尾重复累积。
268        rest = &after[end..];
269    }
270    out.push_str(rest);
271    out
272}
273
274/// Mermaid 渲染脚本(U05/D9):CDN 加载 mermaid.js 后初始化;
275/// CDN 不可达(离线/网络隔离)时 onerror 降级——.mermaid div 的源码
276/// 转为 pre 文本展示,图不渲染但信息不丢失(与生成层 degrade 语义一致)。
277const MERMAID_SCRIPT: &str = r#"<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"
278 onerror="window.__repoWikiMermaidFallback&&window.__repoWikiMermaidFallback()"></script>
279<script>
280function __repoWikiMermaidFallback() {
281  document.querySelectorAll('.mermaid').forEach(function (el) {
282    var pre = document.createElement('pre');
283    pre.textContent = el.textContent;
284    el.replaceWith(pre);
285  });
286}
287window.__repoWikiMermaidFallback = __repoWikiMermaidFallback;
288if (window.mermaid) { mermaid.initialize({ startOnLoad: true }); }
289</script>"#;
290
291/// 生成完整 HTML 文档,包含 <!DOCTYPE>、<head> 和样式链接
292/// 包装完整 HTML 文档。
293///
294/// `css_href` 为样式表相对路径:index.html 位于产物根(与 style.css 同目录,
295/// 用 `style.css`);wiki/ 与 cards/ 下的页面在子目录(用 `../style.css`)。
296/// 硬编码单一路径会让 index.html 引用错误位置(样式失效)。
297fn wrap_html(title: &str, body: &str, css_href: &str) -> String {
298    format!(
299        r#"<!DOCTYPE html>
300<html lang="zh">
301<head>
302<meta charset="utf-8">
303<meta name="viewport" content="width=device-width, initial-scale=1">
304<title>{title}</title>
305<link rel="stylesheet" href="{css_href}">
306{MERMAID_SCRIPT}
307</head>
308<body>
309{body}
310</body>
311</html>"#,
312        title = escape_html(title),
313        body = body,
314        css_href = css_href
315    )
316}
317
318/// 使用 pulldown-cmark 将 Markdown 文本转换为 HTML
319///
320/// U05/D9:mermaid 围栏块不输出为 <pre><code> 源码,改为
321/// `<div class="mermaid">` 容器(由 wrap_html 注入的 mermaid.js 渲染);
322/// 其余事件逐条委托 pulldown-cmark 默认渲染。
323fn md_to_html(markdown: &str) -> String {
324    use pulldown_cmark::{CodeBlockKind, Event, Tag, TagEnd};
325
326    let options = Options::all();
327    let parser = Parser::new_ext(markdown, options);
328    let mut html_output = String::new();
329    let mut mermaid_buf: Option<String> = None;
330
331    for event in parser {
332        match event {
333            Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang)))
334                if lang.eq_ignore_ascii_case("mermaid") =>
335            {
336                mermaid_buf = Some(String::new());
337            }
338            Event::Text(text) if mermaid_buf.is_some() => {
339                mermaid_buf.as_mut().unwrap().push_str(&text);
340            }
341            Event::End(TagEnd::CodeBlock) if mermaid_buf.is_some() => {
342                let buf = mermaid_buf.take().unwrap_or_default();
343                html_output.push_str(&format!(
344                    "<div class=\"mermaid\">\n{}\n</div>\n",
345                    escape_html(&buf)
346                ));
347            }
348            other => {
349                // 逐事件委托默认渲染(含 mermaid 外的普通围栏)
350                let mut tmp = String::new();
351                html::push_html(&mut tmp, std::iter::once(other));
352                html_output.push_str(&tmp);
353            }
354        }
355    }
356    html_output
357}
358
359/// 写入 HTML 文件到磁盘,自动创建父目录
360fn write_html_file(path: &Path, content: &str) -> Result<()> {
361    if let Some(parent) = path.parent() {
362        std::fs::create_dir_all(parent)
363            .with_context(|| format!("创建目录失败: {}", parent.display()))?;
364    }
365    std::fs::write(path, content)
366        .with_context(|| format!("写入文件失败: {}", path.display()))
367}
368
369/// HTML 转义
370fn escape_html(s: &str) -> String {
371    s.replace('&', "&amp;")
372        .replace('<', "&lt;")
373        .replace('>', "&gt;")
374        .replace('"', "&quot;")
375        .replace('\'', "&#39;")
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use crate::model::{EntitySummary, KnowledgeCard, WikiDocument};
382    use crate::config::schema::{WikiConfig};
383
384    fn test_config() -> WikiConfig {
385        WikiConfig {
386            output_dir: Some(std::path::PathBuf::from(".code-repo-wiki")),
387            ..Default::default()
388        }
389    }
390
391    #[test]
392    fn test_wrap_html_has_doctype_and_closing_tags() {
393        let html = wrap_html("测试页面", "<p>Hello</p>", "style.css");
394        assert!(html.starts_with("<!DOCTYPE html>"), "应该以 DOCTYPE 开头");
395        assert!(html.contains("<title>测试页面</title>"), "应该包含 title");
396        assert!(html.contains("</html>"), "应该包含闭合 html 标签");
397        assert!(html.contains("</body>"), "应该包含闭合 body 标签");
398        assert!(html.contains("style.css"), "应该引用 style.css");
399    }
400
401    /// 样式表相对路径随文档层级变化:index.html 在产物根用 style.css,
402    /// wiki/cards 子目录页面用 ../style.css(样式才能正确加载)
403    #[test]
404    fn test_wrap_html_css_href_follows_depth() {
405        let index = wrap_html("目录", "<p>x</p>", "style.css");
406        assert!(
407            index.contains(r#"href="style.css""#),
408            "index 应引用 style.css, 实际: {index}"
409        );
410        let page = wrap_html("页面", "<p>x</p>", "../style.css");
411        assert!(
412            page.contains(r#"href="../style.css""#),
413            "子目录页面应引用 ../style.css, 实际: {page}"
414        );
415    }
416
417    #[test]
418    fn test_wrap_html_escapes_title() {
419        let html = wrap_html("<script>alert('xss')</script>", "<p>body</p>", "style.css");
420        // title 必须转义(页面本身含 mermaid 脚本标签,断言不能再用
421        // 全页 contains("<script>")——改为精确匹配 title 位置的转义形态)
422        assert!(
423            html.contains("<title>&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;</title>"),
424            "title 中的 HTML 应被转义, 实际: {html}"
425        );
426        assert!(html.contains("mermaid.min.js"), "页面应引入 mermaid.js(U05/D9)");
427        assert!(html.contains("__repoWikiMermaidFallback"), "应含离线降级脚本(U05/D9)");
428    }
429
430    /// U05/D9:mermaid 围栏渲染为 div.mermaid 容器(内容转义),
431    /// 普通代码围栏保持 <pre> 原样
432    #[test]
433    fn test_md_to_html_renders_mermaid_container() {
434        let result = md_to_html("```mermaid\nflowchart LR\nA[Start] --> B[End]\n```\n");
435        assert!(result.contains("<div class=\"mermaid\">"), "mermaid 应渲染为 div 容器: {result}");
436        assert!(result.contains("flowchart LR"), "内容应保留");
437        assert!(!result.contains("<pre>"), "mermaid 不应输出为 pre 代码块: {result}");
438        assert!(result.contains("A[Start] --&gt; B[End]"), "内容应 HTML 转义: {result}");
439    }
440
441    #[test]
442    fn test_md_to_html_plain_code_block_unchanged() {
443        let result = md_to_html("```rust\nfn main() {}\n```\n");
444        assert!(result.contains("<pre>"), "普通代码块应保持 pre: {result}");
445        assert!(!result.contains("mermaid"), "普通代码块不应触发 mermaid 渲染: {result}");
446    }
447
448    #[test]
449    fn test_md_to_html_renders_paragraph() {
450        let result = md_to_html("Hello **world**");
451        assert!(result.contains("<p>"), "应该生成 p 标签");
452        assert!(result.contains("<strong>"), "应该生成 strong 标签");
453        assert!(result.contains("world"), "应该保留文本内容");
454    }
455
456    #[test]
457    fn test_md_to_html_renders_code_block() {
458        let result = md_to_html("```rust\nfn main() {}\n```");
459        assert!(result.contains("<pre>"), "代码块应该生成 pre 标签");
460        // pulldown-cmark 输出为 <code class=language-xxx>...</code>,<code> 后有 class 属性
461        assert!(result.contains("<code "), "代码块应该生成 code 标签");
462    }
463
464    #[test]
465    fn test_md_to_html_renders_table() {
466        let result = md_to_html("| A | B |\n|---|---|\n| 1 | 2 |\n");
467        assert!(result.contains("<table>"), "表格应该生成 table 标签");
468    }
469
470    #[test]
471    fn test_escape_html_escapes_special_chars() {
472        assert_eq!(escape_html("<>&'\""), "&lt;&gt;&amp;&#39;&quot;");
473        assert_eq!(escape_html("plain text"), "plain text");
474    }
475
476    /// 链接重写纯函数:内部 .md 相对链接 → .html;外部链接与源码定位不重写
477    #[test]
478    fn test_rewrite_md_links_to_html() {
479        let md = "见 [B](wiki/zh/b.md) 与 [C](a.md#锚点),外部 [D](https://x.com/a.md),源码 [E](src/lib.rs:12)";
480        let rewritten = rewrite_md_links_to_html(md);
481        assert!(rewritten.contains("](wiki/zh/b.html)"), "wiki/zh/b.md 应重写为 .html, 实际: {rewritten}");
482        assert!(rewritten.contains("](a.html#锚点)"), "带锚点的 .md 链接应保留锚点, 实际: {rewritten}");
483        assert!(rewritten.contains("](https://x.com/a.md)"), "外部链接不应重写, 实际: {rewritten}");
484        assert!(rewritten.contains("](src/lib.rs:12)"), "源码定位链接不应重写, 实际: {rewritten}");
485        // 内部 .md 链接必须全部重写(外部/源码定位天然含 .md,不在此断言内)
486        assert!(!rewritten.contains("](wiki/zh/b.md)") && !rewritten.contains("](a.md"), "内部 .md 链接应全部重写, 实际: {rewritten}");
487    }
488
489    #[test]
490    fn test_export_html_creates_files() -> Result<()> {
491        let dir = std::env::temp_dir().join("code-repo-wiki-test-html-export");
492        let _ = std::fs::remove_dir_all(&dir);
493
494        let mut config = test_config();
495        config.output_dir = Some((dir).to_path_buf());
496
497        // 文档与卡片同模块(精确关联的前提):module_path ["核心","模块"] 与
498        // module_name "核心::模块" 精确相等,卡片随文档语言落盘 cards/zh/
499        let doc = WikiDocument {
500            title: "核心模块".to_string(),
501            kind: crate::model::DocumentKind::WikiPage,
502            content: "# 测试\n\nHello world.".to_string(),
503            language: "zh".to_string(),
504            module_path: vec!["核心".to_string(), "模块".to_string()],
505            references: vec![],
506            last_updated: "2025-01-01".to_string(),
507            based_on_commit: None,
508            fingerprint: None,
509        };
510
511        let card = KnowledgeCard {
512            module_name: "核心::模块".to_string(),
513            module_type: "库".to_string(),
514            summary: "负责核心功能".to_string(),
515            key_entities: vec![EntitySummary {
516                name: "run".to_string(),
517                kind: "函数".to_string(),
518                visibility: "pub".to_string(),
519                doc: Some("入口函数".to_string()),
520                source: None,
521            }],
522            dependencies: vec!["serde".to_string()],
523            dependents: vec![],
524            design_patterns: vec!["工厂模式".to_string()],
525            todo_notes: vec![],
526            related_files: vec![],
527            coding_spec: None,
528            tech_stack: vec![],
529            architecture: None,
530            pending_manual_edits: vec!["人工修改待同步: wiki/zh/核心_模块.md 内容摘要: 手动改".into()],
531            features: Vec::new(),
532        };
533
534        // 快照形态的模块摘要(含文件列表才会出现在 Mermaid 图中)
535        let modules = vec![ExportModuleSnapshot {
536            name: "核心::模块".to_string(),
537            files: vec!["src/core/mod.rs".to_string()],
538            cohesion: 0.8,
539            coupling: 0.2,
540            features: vec![],
541            dependencies: vec![],
542        }];
543
544        export_html(&[doc], &[card], &modules, &config)?;
545
546        assert!(dir.join("index.html").exists(), "index.html 应该存在");
547        assert!(dir.join("style.css").exists(), "style.css 应该存在");
548        assert!(
549            dir.join("wiki").join("zh").join("核心_模块.html").exists(),
550            "wiki 页面应写到 wiki/zh/ 语言目录(与 markdown 命名同构)"
551        );
552        assert!(
553            dir.join("cards").join("zh").join("核心_模块.html").exists(),
554            "card 页面应随文档语言写到 cards/zh/"
555        );
556        assert!(dir.join("assets").join("module-deps.html").exists(), "Mermaid 页面应该存在");
557
558        let index = std::fs::read_to_string(dir.join("index.html"))?;
559        assert!(index.contains("核心模块"), "目录页应该包含文档标题");
560        assert!(
561            index.contains("wiki/zh/核心_模块.html"),
562            "目录页链接应指向语言目录下的 .html, 实际: {index}"
563        );
564
565        let card_html = std::fs::read_to_string(dir.join("cards").join("zh").join("核心_模块.html"))?;
566        assert!(card_html.contains("人工修改待同步"), "卡片 HTML 应包含人工修改待同步节");
567        assert!(card_html.contains("手动改"), "卡片 HTML 应包含记录内容");
568
569        let _ = std::fs::remove_dir_all(&dir);
570        Ok(())
571    }
572}