use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; use crate::escape::html_escape; /// A single entry in a table of contents. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TocEntry { pub level: u8, pub text: String, pub anchor: String, } /// Extract a table of contents from markdown headings. pub fn extract_toc(markdown: &str) -> Vec { let mut options = Options::empty(); options.insert(Options::ENABLE_TABLES); options.insert(Options::ENABLE_STRIKETHROUGH); let parser = Parser::new_ext(markdown, options); let mut entries = Vec::new(); let mut in_heading: Option = None; let mut heading_text = String::new(); for event in parser { match event { Event::Start(Tag::Heading { level, .. }) => { in_heading = Some(level as u8); heading_text.clear(); } Event::Text(text) if in_heading.is_some() => { heading_text.push_str(&text); } Event::Code(code) if in_heading.is_some() => { heading_text.push_str(&code); } Event::End(TagEnd::Heading(_)) => { if let Some(level) = in_heading.take() { let anchor = make_anchor(&heading_text); entries.push(TocEntry { level, text: heading_text.clone(), anchor, }); } } _ => {} } } entries } /// Render TOC entries as an HTML nested list. pub fn render_toc_html(entries: &[TocEntry]) -> String { if entries.is_empty() { return String::new(); } let mut html = String::from(""); html } /// GitHub-style anchor generation: lowercase, spaces to hyphens, strip /// non-alphanumeric (except hyphens). fn make_anchor(text: &str) -> String { text.to_lowercase() .chars() .map(|c| if c == ' ' { '-' } else { c }) .filter(|c| c.is_alphanumeric() || *c == '-') .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn extract_basic_headings() { let md = "# Title\n\n## Section One\n\nBody\n\n## Section Two\n\nMore"; let toc = extract_toc(md); assert_eq!(toc.len(), 3); assert_eq!(toc[0].level, 1); assert_eq!(toc[0].text, "Title"); assert_eq!(toc[0].anchor, "title"); assert_eq!(toc[1].level, 2); assert_eq!(toc[1].text, "Section One"); assert_eq!(toc[1].anchor, "section-one"); assert_eq!(toc[2].level, 2); assert_eq!(toc[2].text, "Section Two"); } #[test] fn anchor_strips_special_chars() { assert_eq!(make_anchor("Hello, World!"), "hello-world"); assert_eq!(make_anchor("C++ & Rust"), "c--rust"); assert_eq!(make_anchor("Version 2.0"), "version-20"); } #[test] fn extract_empty() { let toc = extract_toc("No headings here, just text."); assert!(toc.is_empty()); } #[test] fn extract_nested_levels() { let md = "# H1\n## H2\n### H3\n#### H4"; let toc = extract_toc(md); assert_eq!(toc.len(), 4); assert_eq!(toc[0].level, 1); assert_eq!(toc[1].level, 2); assert_eq!(toc[2].level, 3); assert_eq!(toc[3].level, 4); } #[test] fn heading_with_inline_code() { let md = "## Using `render()` function"; let toc = extract_toc(md); assert_eq!(toc.len(), 1); assert_eq!(toc[0].text, "Using render() function"); assert_eq!(toc[0].anchor, "using-render-function"); } #[test] fn render_toc_html_basic() { let entries = vec![ TocEntry { level: 1, text: "Title".to_string(), anchor: "title".to_string(), }, TocEntry { level: 2, text: "Section".to_string(), anchor: "section".to_string(), }, ]; let html = render_toc_html(&entries); assert!(html.contains("