//! Markdown 渲染与目录生成。 //! //! 使用 pulldown-cmark 解析 Markdown,为标题生成锚点与目录(TOC), //! 代码块调用 `highlight` 模块进行语法高亮,最终通过 sanitizer 清理 HTML。 //! 仅在 `feature = "server"` 时执行实际渲染。 #![allow(clippy::unused_unit, deprecated)] #[cfg(feature = "server")] /// 对外暴露的 HTML 清理函数,委托给 sanitizer 模块。 pub fn clean_html(input: &str) -> String { crate::api::sanitizer::clean_html(input) } #[cfg(feature = "server")] /// 将标题纯文本转义,用于安全地拼进 TOC 的 `aria-label="..."` 与 `` 正文。 /// /// 复用 `hooks::comment_storage::escape_html`(转义 `& < > " '`),避免在仓库内 /// 维护第二份转义实现。原先用 `clean_html` 处理属性上下文会漏掉 `"`,标题形如 /// `" onmouseover="alert(1)` 会越出属性边界。 fn escape_heading_text(s: &str) -> String { crate::hooks::comment_storage::escape_html(s) } #[derive(Debug, Clone)] #[cfg(feature = "server")] /// Markdown 渲染结果。 pub struct RenderedContent { /// 清理后的正文 HTML。 pub html: String, /// 目录 HTML(无标题时为空字符串)。 pub toc_html: String, } #[cfg(feature = "server")] /// 增强版 Markdown 渲染:生成 TOC、标题锚点与语法高亮代码块。 pub fn render_markdown_enhanced(md: &str) -> RenderedContent { use pulldown_cmark::{Event, HeadingLevel, Options, Tag, TagEnd}; // 两遍解析使用相同的 Options,避免 TOC 收集与正文渲染对 Markdown 扩展语法 // (表格、删除线、脚注等)的处理不一致。 let opts = Options::all(); // 1. Parse markdown and collect headings for TOC let parser = pulldown_cmark::Parser::new_ext(md, opts); // (level, text, id) let mut headings: Vec<(u8, String, String)> = Vec::new(); let mut current_heading: Option<(u8, String)> = None; for event in parser { match event { Event::Start(Tag::Heading { level, .. }) => { let lvl = match level { HeadingLevel::H1 => 1, HeadingLevel::H2 => 2, HeadingLevel::H3 => 3, HeadingLevel::H4 => 4, HeadingLevel::H5 => 5, HeadingLevel::H6 => 6, }; current_heading = Some((lvl, String::new())); } Event::Text(text) => { if let Some((_, ref mut content)) = current_heading { content.push_str(&text); } } Event::Code(code) => { if let Some((_, ref mut content)) = current_heading { content.push_str(&code); } } Event::End(TagEnd::Heading(_)) => { if let Some((lvl, text)) = current_heading.take() { let id = slugify_heading(&text); headings.push((lvl, text, id)); } } _ => {} } } // 2. Generate TOC HTML let toc_html = generate_toc_html(&headings); // 3. Generate HTML with heading anchors let parser = pulldown_cmark::Parser::new_ext(md, opts); let mut html = String::new(); let mut heading_idx = 0; let mut in_heading = false; let mut in_codeblock = false; let mut code_lang: Option = None; let mut code_buffer = String::new(); let mut non_heading_events: Vec = Vec::new(); for event in parser { match event { Event::Start(Tag::Heading { level, .. }) => { // 先把累积的普通事件刷入 HTML,再开始新标题。 if !non_heading_events.is_empty() { pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter()); non_heading_events = Vec::new(); } in_heading = true; if heading_idx < headings.len() { let (_, _, ref id) = headings[heading_idx]; let tag = match level { HeadingLevel::H1 => "h1", HeadingLevel::H2 => "h2", HeadingLevel::H3 => "h3", HeadingLevel::H4 => "h4", HeadingLevel::H5 => "h5", HeadingLevel::H6 => "h6", }; html.push_str(&format!("<{} id=\"{}\">", tag, id)); } } Event::End(TagEnd::Heading(level)) => { if heading_idx < headings.len() { let (_, _, ref id) = headings[heading_idx]; let tag = match level { HeadingLevel::H1 => "h1", HeadingLevel::H2 => "h2", HeadingLevel::H3 => "h3", HeadingLevel::H4 => "h4", HeadingLevel::H5 => "h5", HeadingLevel::H6 => "h6", }; html.push_str(&format!( "#", id, tag )); heading_idx += 1; } in_heading = false; } Event::Start(Tag::CodeBlock(kind)) => { // 代码块开始前同样先刷入未处理的普通事件。 if !non_heading_events.is_empty() { pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter()); non_heading_events = Vec::new(); } in_codeblock = true; code_lang = match kind { pulldown_cmark::CodeBlockKind::Fenced(lang) => { if lang.is_empty() { None } else { Some(lang.to_string()) } } _ => None, }; code_buffer.clear(); } Event::Text(text) if in_codeblock => { code_buffer.push_str(&text); } Event::End(TagEnd::CodeBlock) => { // 使用 syntect 对代码块进行服务端语法高亮。 let highlighted = crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref()); html.push_str("
');
                html.push_str(&highlighted);
                html.push_str("
"); in_codeblock = false; } _ => { if in_heading { // 标题内部只保留文本与行内代码,避免嵌套块级元素。 match event { Event::Text(text) => html.push_str(&clean_html(&text)), Event::Code(code) => { html.push_str(""); html.push_str(&clean_html(&code)); html.push_str(""); } _ => {} } } else if !in_codeblock { non_heading_events.push(event); } } } } // Flush remaining non-heading events if !non_heading_events.is_empty() { pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter()); } RenderedContent { html: clean_html(&html), toc_html, } } #[cfg(feature = "server")] /// 根据标题层级生成嵌套目录 HTML。 fn generate_toc_html(headings: &[(u8, String, String)]) -> String { if headings.is_empty() { return String::new(); } let mut html = String::from(""); stack.pop(); } html.push_str(""); html } #[cfg(feature = "server")] /// 将标题文本转换为可用于锚点的 slug。 fn slugify_heading(text: &str) -> String { let mut slug = String::new(); let mut prev_dash = true; for c in text.to_lowercase().chars() { if c.is_alphanumeric() { slug.push(c); prev_dash = false; } else if !prev_dash { slug.push('-'); prev_dash = true; } } if slug.ends_with('-') { slug.pop(); } if slug.is_empty() { slug.push_str("heading"); } slug } #[cfg(all(test, feature = "server"))] mod tests { use super::*; #[test] fn slugify_heading_simple() { assert_eq!(slugify_heading("Hello World"), "hello-world"); } #[test] fn slugify_heading_special_chars() { assert_eq!(slugify_heading("What's new? (2024)"), "what-s-new-2024"); } #[test] fn slugify_heading_chinese() { let slug = slugify_heading("你好世界"); assert!(!slug.is_empty()); } #[test] fn slugify_heading_collapses_dashes() { assert_eq!(slugify_heading("a--b"), "a-b"); } #[test] fn slugify_heading_strips_trailing_dash() { assert_eq!(slugify_heading("hello!"), "hello"); } #[test] fn slugify_heading_empty_returns_heading() { assert_eq!(slugify_heading(""), "heading"); } #[test] fn clean_html_allows_safe_tags() { let input = "

Hello world

"; assert_eq!(clean_html(input), input); } #[test] fn clean_html_removes_script() { let input = "

safe

"; let result = clean_html(input); assert!(!result.contains("script")); assert!(result.contains("safe")); } #[test] fn clean_html_allows_id_attribute() { let input = "

Title

"; let result = clean_html(input); assert!(result.contains("id=\"my-heading\"")); } #[test] fn clean_html_allows_class_attribute() { let input = "text"; let result = clean_html(input); assert!(result.contains("class=\"highlight\"")); } #[test] fn generate_toc_html_empty() { assert_eq!(generate_toc_html(&[]), ""); } #[test] fn generate_toc_html_single_heading() { let headings = vec![(2u8, "Title".to_string(), "title".to_string())]; let html = generate_toc_html(&headings); assert!(html.contains("href=\"#title\"")); assert!(html.contains("")); } #[test] fn generate_toc_html_nested() { let headings = vec![ (2u8, "Chapter".to_string(), "chapter".to_string()), (3u8, "Section".to_string(), "section".to_string()), ]; let html = generate_toc_html(&headings); assert!(html.contains("href=\"#chapter\"")); assert!(html.contains("href=\"#section\"")); let ul_count = html.matches("