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
10pub 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 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 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 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 if !modules.is_empty() {
119 let mut mermaid_lines = vec!["graph TD".to_string()];
120 let node_id = |name: &str| name.replace(|c: char| !c.is_alphanumeric(), "_");
123 for module in modules {
124 if !module.files.is_empty() {
125 mermaid_lines.push(format!(
127 " {}[\"{}\"]",
128 node_id(&module.name),
129 escape_html(&module.name)
130 ));
131 }
132 }
133 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 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
178fn 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 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
234fn 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
242pub 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 rest = &after[end..];
269 }
270 out.push_str(rest);
271 out
272}
273
274const 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
291fn 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
318fn 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 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
359fn 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
369fn escape_html(s: &str) -> String {
371 s.replace('&', "&")
372 .replace('<', "<")
373 .replace('>', ">")
374 .replace('"', """)
375 .replace('\'', "'")
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 #[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 assert!(
423 html.contains("<title><script>alert('xss')</script></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 #[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] --> 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 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("<>&'\""), "<>&'"");
473 assert_eq!(escape_html("plain text"), "plain text");
474 }
475
476 #[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 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 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 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}