use std::path::Path;
use anyhow::{Context, Result};
use pulldown_cmark::{Options, Parser, html};
use crate::config::schema::WikiConfig;
use crate::model::{KnowledgeCard, WikiDocument};
use crate::output::{ExportModuleSnapshot, card_page_path, wiki_page_html_path};
pub fn export_html(
documents: &[WikiDocument],
cards: &[KnowledgeCard],
modules: &[ExportModuleSnapshot],
config: &WikiConfig,
) -> Result<()> {
let output_dir = config.output_dir();
let wiki_dir = output_dir.join("wiki");
let cards_dir = output_dir.join("cards");
let assets_dir = output_dir.join("assets");
std::fs::create_dir_all(&wiki_dir)
.with_context(|| format!("创建 wiki 目录失败: {}", wiki_dir.display()))?;
std::fs::create_dir_all(&cards_dir)
.with_context(|| format!("创建 cards 目录失败: {}", cards_dir.display()))?;
std::fs::create_dir_all(&assets_dir)
.with_context(|| format!("创建 assets 目录失败: {}", assets_dir.display()))?;
for doc in documents {
let body = md_to_html(&rewrite_md_links_to_html(&doc.content));
let html = wrap_html(&doc.title, &body, "../../style.css");
let path = wiki_page_html_path(output_dir, doc);
write_html_file(&path, &html)?;
}
let mut module_groups: std::collections::BTreeMap<String, Vec<&WikiDocument>> = Default::default();
let mut global_docs: Vec<&WikiDocument> = Vec::new();
for doc in documents {
if doc.module_path.is_empty() {
global_docs.push(doc);
} else {
module_groups
.entry(doc.module_path.join("::"))
.or_default()
.push(doc);
}
}
let mut toc_items = String::new();
if !global_docs.is_empty() {
toc_items.push_str("<h2>全局文档</h2>\n<ul>\n");
for doc in &global_docs {
toc_items.push_str(&format!(
"<li><a href=\"{}\">{}</a></li>\n",
wiki_html_link(output_dir, doc),
escape_html(&doc.title)
));
}
toc_items.push_str("</ul>\n");
}
toc_items.push_str("<h2>模块</h2>\n");
for (module, docs) in &module_groups {
toc_items.push_str(&format!("<h3>{}</h3>\n<ul>\n", escape_html(module)));
for doc in docs {
toc_items.push_str(&format!(
"<li><a href=\"{}\">{}</a></li>\n",
wiki_html_link(output_dir, doc),
escape_html(&doc.title)
));
}
toc_items.push_str("</ul>\n");
}
let toc_body = format!(
"<h1>Wiki 目录</h1>\n<p>共 {} 个文档</p>\n{}\n",
documents.len(),
toc_items
);
let toc_html = wrap_html("Wiki 目录", &toc_body, "style.css");
write_html_file(&output_dir.join("index.html"), &toc_html)?;
let css = r#"body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
max-width: 960px;
margin: 0 auto;
padding: 20px;
color: #333;
}
h1, h2, h3, h4 { margin-top: 1.5em; margin-bottom: 0.5em; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
pre { background: #f4f4f4; padding: 16px; border-radius: 6px; overflow-x: auto; }
pre code { background: none; padding: 0; }
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f8f8f8; }
a { color: #0366d6; text-decoration: none; }
a:hover { text-decoration: underline; }
ul, ol { padding-left: 24px; }
blockquote { border-left: 4px solid #ddd; margin: 0; padding: 0 16px; color: #666; }
"#;
write_html_file(&output_dir.join("style.css"), css)?;
if !modules.is_empty() {
let mut mermaid_lines = vec!["graph TD".to_string()];
let node_id = |name: &str| name.replace(|c: char| !c.is_alphanumeric(), "_");
for module in modules {
if !module.files.is_empty() {
mermaid_lines.push(format!(
" {}[\"{}\"]",
node_id(&module.name),
escape_html(&module.name)
));
}
}
for module in modules {
for dep in &module.dependencies {
mermaid_lines.push(format!(" {} --> {}", node_id(&module.name), node_id(dep)));
}
}
let mermaid_code = mermaid_lines.join("\n");
let mermaid_html = format!(
r#"<!DOCTYPE html>
<html lang="zh">
<head><meta charset="utf-8"><title>模块依赖图</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<script>mermaid.initialize({{startOnLoad:true}});</script>
<style>body{{font-family:sans-serif;padding:20px;}}</style>
</head>
<body>
<h1>模块依赖图</h1>
<div class="mermaid">
{}
</div>
</body>
</html>"#,
mermaid_code
);
write_html_file(&assets_dir.join("module-deps.html"), &mermaid_html)?;
}
for doc in documents {
let doc_module = doc.module_path.join("::");
for card in cards {
if card.module_name != doc_module {
continue;
}
let html = wrap_html(&card.module_name, &render_card_body(card), "../../style.css");
let path = card_page_path(output_dir, &doc.language, &card.module_name).with_extension("html");
write_html_file(&path, &html)?;
}
}
Ok(())
}
fn render_card_body(card: &KnowledgeCard) -> String {
let mut body = format!(
"<h1>{}</h1>\n<p><strong>类型:</strong> {}</p>\n<p>{}</p>\n",
escape_html(&card.module_name),
escape_html(&card.module_type),
escape_html(&card.summary)
);
if !card.key_entities.is_empty() {
body.push_str("<h2>关键实体</h2>\n<ul>\n");
for entity in &card.key_entities {
body.push_str(&format!(
" <li><strong>{}</strong> ({}) — {}</li>\n",
escape_html(&entity.name),
escape_html(&entity.kind),
entity.doc.as_deref().unwrap_or("")
));
}
body.push_str("</ul>\n");
}
if !card.dependencies.is_empty() {
body.push_str(&format!(
"<h2>依赖</h2>\n<p>{}</p>\n",
card.dependencies
.iter()
.map(|d| escape_html(d))
.collect::<Vec<_>>()
.join(", ")
));
}
if !card.design_patterns.is_empty() {
body.push_str(&format!(
"<h2>设计模式</h2>\n<ul>\n<li>{}</li>\n</ul>\n",
card.design_patterns
.iter()
.map(|p| escape_html(p))
.collect::<Vec<_>>()
.join("</li>\n<li>")
));
}
if !card.pending_manual_edits.is_empty() {
body.push_str("<h2>人工修改待同步</h2>\n<ul>\n");
for note in &card.pending_manual_edits {
body.push_str(&format!(" <li>{}</li>\n", escape_html(note)));
}
body.push_str("</ul>\n");
}
body
}
fn wiki_html_link(output_dir: &Path, doc: &WikiDocument) -> String {
wiki_page_html_path(output_dir, doc)
.strip_prefix(output_dir)
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default()
}
pub fn rewrite_md_links_to_html(md: &str) -> String {
let mut out = String::new();
let mut rest = md;
while let Some(start) = rest.find("](") {
out.push_str(&rest[..start + 2]);
let after = &rest[start + 2..];
let end = after.find(')').unwrap_or(after.len());
let target = &after[..end];
if !target.contains("://") {
if let Some(md_end) = target.find(".md") {
out.push_str(&target[..md_end]);
out.push_str(".html");
out.push_str(&target[md_end + 3..]);
} else {
out.push_str(target);
}
} else {
out.push_str(target);
}
rest = &after[end..];
}
out.push_str(rest);
out
}
const MERMAID_SCRIPT: &str = r#"<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"
onerror="window.__repoWikiMermaidFallback&&window.__repoWikiMermaidFallback()"></script>
<script>
function __repoWikiMermaidFallback() {
document.querySelectorAll('.mermaid').forEach(function (el) {
var pre = document.createElement('pre');
pre.textContent = el.textContent;
el.replaceWith(pre);
});
}
window.__repoWikiMermaidFallback = __repoWikiMermaidFallback;
if (window.mermaid) { mermaid.initialize({ startOnLoad: true }); }
</script>"#;
fn wrap_html(title: &str, body: &str, css_href: &str) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<link rel="stylesheet" href="{css_href}">
{MERMAID_SCRIPT}
</head>
<body>
{body}
</body>
</html>"#,
title = escape_html(title),
body = body,
css_href = css_href
)
}
fn md_to_html(markdown: &str) -> String {
use pulldown_cmark::{CodeBlockKind, Event, Tag, TagEnd};
let options = Options::all();
let parser = Parser::new_ext(markdown, options);
let mut html_output = String::new();
let mut mermaid_buf: Option<String> = None;
for event in parser {
match event {
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang)))
if lang.eq_ignore_ascii_case("mermaid") =>
{
mermaid_buf = Some(String::new());
}
Event::Text(text) if mermaid_buf.is_some() => {
mermaid_buf.as_mut().unwrap().push_str(&text);
}
Event::End(TagEnd::CodeBlock) if mermaid_buf.is_some() => {
let buf = mermaid_buf.take().unwrap_or_default();
html_output.push_str(&format!(
"<div class=\"mermaid\">\n{}\n</div>\n",
escape_html(&buf)
));
}
other => {
let mut tmp = String::new();
html::push_html(&mut tmp, std::iter::once(other));
html_output.push_str(&tmp);
}
}
}
html_output
}
fn write_html_file(path: &Path, content: &str) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
}
std::fs::write(path, content)
.with_context(|| format!("写入文件失败: {}", path.display()))
}
fn escape_html(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{EntitySummary, KnowledgeCard, WikiDocument};
use crate::config::schema::{WikiConfig};
fn test_config() -> WikiConfig {
WikiConfig {
output_dir: Some(std::path::PathBuf::from(".code-repo-wiki")),
..Default::default()
}
}
#[test]
fn test_wrap_html_has_doctype_and_closing_tags() {
let html = wrap_html("测试页面", "<p>Hello</p>", "style.css");
assert!(html.starts_with("<!DOCTYPE html>"), "应该以 DOCTYPE 开头");
assert!(html.contains("<title>测试页面</title>"), "应该包含 title");
assert!(html.contains("</html>"), "应该包含闭合 html 标签");
assert!(html.contains("</body>"), "应该包含闭合 body 标签");
assert!(html.contains("style.css"), "应该引用 style.css");
}
#[test]
fn test_wrap_html_css_href_follows_depth() {
let index = wrap_html("目录", "<p>x</p>", "style.css");
assert!(
index.contains(r#"href="style.css""#),
"index 应引用 style.css, 实际: {index}"
);
let page = wrap_html("页面", "<p>x</p>", "../style.css");
assert!(
page.contains(r#"href="../style.css""#),
"子目录页面应引用 ../style.css, 实际: {page}"
);
}
#[test]
fn test_wrap_html_escapes_title() {
let html = wrap_html("<script>alert('xss')</script>", "<p>body</p>", "style.css");
assert!(
html.contains("<title><script>alert('xss')</script></title>"),
"title 中的 HTML 应被转义, 实际: {html}"
);
assert!(html.contains("mermaid.min.js"), "页面应引入 mermaid.js(U05/D9)");
assert!(html.contains("__repoWikiMermaidFallback"), "应含离线降级脚本(U05/D9)");
}
#[test]
fn test_md_to_html_renders_mermaid_container() {
let result = md_to_html("```mermaid\nflowchart LR\nA[Start] --> B[End]\n```\n");
assert!(result.contains("<div class=\"mermaid\">"), "mermaid 应渲染为 div 容器: {result}");
assert!(result.contains("flowchart LR"), "内容应保留");
assert!(!result.contains("<pre>"), "mermaid 不应输出为 pre 代码块: {result}");
assert!(result.contains("A[Start] --> B[End]"), "内容应 HTML 转义: {result}");
}
#[test]
fn test_md_to_html_plain_code_block_unchanged() {
let result = md_to_html("```rust\nfn main() {}\n```\n");
assert!(result.contains("<pre>"), "普通代码块应保持 pre: {result}");
assert!(!result.contains("mermaid"), "普通代码块不应触发 mermaid 渲染: {result}");
}
#[test]
fn test_md_to_html_renders_paragraph() {
let result = md_to_html("Hello **world**");
assert!(result.contains("<p>"), "应该生成 p 标签");
assert!(result.contains("<strong>"), "应该生成 strong 标签");
assert!(result.contains("world"), "应该保留文本内容");
}
#[test]
fn test_md_to_html_renders_code_block() {
let result = md_to_html("```rust\nfn main() {}\n```");
assert!(result.contains("<pre>"), "代码块应该生成 pre 标签");
assert!(result.contains("<code "), "代码块应该生成 code 标签");
}
#[test]
fn test_md_to_html_renders_table() {
let result = md_to_html("| A | B |\n|---|---|\n| 1 | 2 |\n");
assert!(result.contains("<table>"), "表格应该生成 table 标签");
}
#[test]
fn test_escape_html_escapes_special_chars() {
assert_eq!(escape_html("<>&'\""), "<>&'"");
assert_eq!(escape_html("plain text"), "plain text");
}
#[test]
fn test_rewrite_md_links_to_html() {
let md = "见 [B](wiki/zh/b.md) 与 [C](a.md#锚点),外部 [D](https://x.com/a.md),源码 [E](src/lib.rs:12)";
let rewritten = rewrite_md_links_to_html(md);
assert!(rewritten.contains("](wiki/zh/b.html)"), "wiki/zh/b.md 应重写为 .html, 实际: {rewritten}");
assert!(rewritten.contains("](a.html#锚点)"), "带锚点的 .md 链接应保留锚点, 实际: {rewritten}");
assert!(rewritten.contains("](https://x.com/a.md)"), "外部链接不应重写, 实际: {rewritten}");
assert!(rewritten.contains("](src/lib.rs:12)"), "源码定位链接不应重写, 实际: {rewritten}");
assert!(!rewritten.contains("](wiki/zh/b.md)") && !rewritten.contains("](a.md"), "内部 .md 链接应全部重写, 实际: {rewritten}");
}
#[test]
fn test_export_html_creates_files() -> Result<()> {
let dir = std::env::temp_dir().join("code-repo-wiki-test-html-export");
let _ = std::fs::remove_dir_all(&dir);
let mut config = test_config();
config.output_dir = Some((dir).to_path_buf());
let doc = WikiDocument {
title: "核心模块".to_string(),
kind: crate::model::DocumentKind::WikiPage,
content: "# 测试\n\nHello world.".to_string(),
language: "zh".to_string(),
module_path: vec!["核心".to_string(), "模块".to_string()],
references: vec![],
last_updated: "2025-01-01".to_string(),
based_on_commit: None,
fingerprint: None,
};
let card = KnowledgeCard {
module_name: "核心::模块".to_string(),
module_type: "库".to_string(),
summary: "负责核心功能".to_string(),
key_entities: vec![EntitySummary {
name: "run".to_string(),
kind: "函数".to_string(),
visibility: "pub".to_string(),
doc: Some("入口函数".to_string()),
source: None,
}],
dependencies: vec!["serde".to_string()],
dependents: vec![],
design_patterns: vec!["工厂模式".to_string()],
todo_notes: vec![],
related_files: vec![],
coding_spec: None,
tech_stack: vec![],
architecture: None,
pending_manual_edits: vec!["人工修改待同步: wiki/zh/核心_模块.md 内容摘要: 手动改".into()],
features: Vec::new(),
};
let modules = vec![ExportModuleSnapshot {
name: "核心::模块".to_string(),
files: vec!["src/core/mod.rs".to_string()],
cohesion: 0.8,
coupling: 0.2,
features: vec![],
dependencies: vec![],
}];
export_html(&[doc], &[card], &modules, &config)?;
assert!(dir.join("index.html").exists(), "index.html 应该存在");
assert!(dir.join("style.css").exists(), "style.css 应该存在");
assert!(
dir.join("wiki").join("zh").join("核心_模块.html").exists(),
"wiki 页面应写到 wiki/zh/ 语言目录(与 markdown 命名同构)"
);
assert!(
dir.join("cards").join("zh").join("核心_模块.html").exists(),
"card 页面应随文档语言写到 cards/zh/"
);
assert!(dir.join("assets").join("module-deps.html").exists(), "Mermaid 页面应该存在");
let index = std::fs::read_to_string(dir.join("index.html"))?;
assert!(index.contains("核心模块"), "目录页应该包含文档标题");
assert!(
index.contains("wiki/zh/核心_模块.html"),
"目录页链接应指向语言目录下的 .html, 实际: {index}"
);
let card_html = std::fs::read_to_string(dir.join("cards").join("zh").join("核心_模块.html"))?;
assert!(card_html.contains("人工修改待同步"), "卡片 HTML 应包含人工修改待同步节");
assert!(card_html.contains("手动改"), "卡片 HTML 应包含记录内容");
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
}