| 1 |
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; |
| 2 |
|
| 3 |
use crate::escape::html_escape; |
| 4 |
|
| 5 |
|
| 6 |
#[derive(Debug, Clone, PartialEq, Eq)] |
| 7 |
pub struct TocEntry { |
| 8 |
pub level: u8, |
| 9 |
pub text: String, |
| 10 |
pub anchor: String, |
| 11 |
} |
| 12 |
|
| 13 |
|
| 14 |
pub fn extract_toc(markdown: &str) -> Vec<TocEntry> { |
| 15 |
let mut options = Options::empty(); |
| 16 |
options.insert(Options::ENABLE_TABLES); |
| 17 |
options.insert(Options::ENABLE_STRIKETHROUGH); |
| 18 |
|
| 19 |
let parser = Parser::new_ext(markdown, options); |
| 20 |
let mut entries = Vec::new(); |
| 21 |
let mut in_heading: Option<u8> = None; |
| 22 |
let mut heading_text = String::new(); |
| 23 |
|
| 24 |
for event in parser { |
| 25 |
match event { |
| 26 |
Event::Start(Tag::Heading { level, .. }) => { |
| 27 |
in_heading = Some(level as u8); |
| 28 |
heading_text.clear(); |
| 29 |
} |
| 30 |
Event::Text(text) if in_heading.is_some() => { |
| 31 |
heading_text.push_str(&text); |
| 32 |
} |
| 33 |
Event::Code(code) if in_heading.is_some() => { |
| 34 |
heading_text.push_str(&code); |
| 35 |
} |
| 36 |
Event::End(TagEnd::Heading(_)) => { |
| 37 |
if let Some(level) = in_heading.take() { |
| 38 |
let anchor = make_anchor(&heading_text); |
| 39 |
entries.push(TocEntry { |
| 40 |
level, |
| 41 |
text: heading_text.clone(), |
| 42 |
anchor, |
| 43 |
}); |
| 44 |
} |
| 45 |
} |
| 46 |
_ => {} |
| 47 |
} |
| 48 |
} |
| 49 |
entries |
| 50 |
} |
| 51 |
|
| 52 |
|
| 53 |
pub fn render_toc_html(entries: &[TocEntry]) -> String { |
| 54 |
if entries.is_empty() { |
| 55 |
return String::new(); |
| 56 |
} |
| 57 |
let mut html = String::from("<nav class=\"toc\"><ul>\n"); |
| 58 |
for entry in entries { |
| 59 |
html.push_str(&format!( |
| 60 |
"<li class=\"toc-h{}\"><a href=\"#{}\">{}</a></li>\n", |
| 61 |
entry.level, |
| 62 |
html_escape(&entry.anchor), |
| 63 |
html_escape(&entry.text), |
| 64 |
)); |
| 65 |
} |
| 66 |
html.push_str("</ul></nav>"); |
| 67 |
html |
| 68 |
} |
| 69 |
|
| 70 |
|
| 71 |
|
| 72 |
fn make_anchor(text: &str) -> String { |
| 73 |
text.to_lowercase() |
| 74 |
.chars() |
| 75 |
.map(|c| if c == ' ' { '-' } else { c }) |
| 76 |
.filter(|c| c.is_alphanumeric() || *c == '-') |
| 77 |
.collect() |
| 78 |
} |
| 79 |
|
| 80 |
#[cfg(test)] |
| 81 |
mod tests { |
| 82 |
use super::*; |
| 83 |
|
| 84 |
#[test] |
| 85 |
fn extract_basic_headings() { |
| 86 |
let md = "# Title\n\n## Section One\n\nBody\n\n## Section Two\n\nMore"; |
| 87 |
let toc = extract_toc(md); |
| 88 |
assert_eq!(toc.len(), 3); |
| 89 |
assert_eq!(toc[0].level, 1); |
| 90 |
assert_eq!(toc[0].text, "Title"); |
| 91 |
assert_eq!(toc[0].anchor, "title"); |
| 92 |
assert_eq!(toc[1].level, 2); |
| 93 |
assert_eq!(toc[1].text, "Section One"); |
| 94 |
assert_eq!(toc[1].anchor, "section-one"); |
| 95 |
assert_eq!(toc[2].level, 2); |
| 96 |
assert_eq!(toc[2].text, "Section Two"); |
| 97 |
} |
| 98 |
|
| 99 |
#[test] |
| 100 |
fn anchor_strips_special_chars() { |
| 101 |
assert_eq!(make_anchor("Hello, World!"), "hello-world"); |
| 102 |
assert_eq!(make_anchor("C++ & Rust"), "c--rust"); |
| 103 |
assert_eq!(make_anchor("Version 2.0"), "version-20"); |
| 104 |
} |
| 105 |
|
| 106 |
#[test] |
| 107 |
fn extract_empty() { |
| 108 |
let toc = extract_toc("No headings here, just text."); |
| 109 |
assert!(toc.is_empty()); |
| 110 |
} |
| 111 |
|
| 112 |
#[test] |
| 113 |
fn extract_nested_levels() { |
| 114 |
let md = "# H1\n## H2\n### H3\n#### H4"; |
| 115 |
let toc = extract_toc(md); |
| 116 |
assert_eq!(toc.len(), 4); |
| 117 |
assert_eq!(toc[0].level, 1); |
| 118 |
assert_eq!(toc[1].level, 2); |
| 119 |
assert_eq!(toc[2].level, 3); |
| 120 |
assert_eq!(toc[3].level, 4); |
| 121 |
} |
| 122 |
|
| 123 |
#[test] |
| 124 |
fn heading_with_inline_code() { |
| 125 |
let md = "## Using `render()` function"; |
| 126 |
let toc = extract_toc(md); |
| 127 |
assert_eq!(toc.len(), 1); |
| 128 |
assert_eq!(toc[0].text, "Using render() function"); |
| 129 |
assert_eq!(toc[0].anchor, "using-render-function"); |
| 130 |
} |
| 131 |
|
| 132 |
#[test] |
| 133 |
fn render_toc_html_basic() { |
| 134 |
let entries = vec![ |
| 135 |
TocEntry { |
| 136 |
level: 1, |
| 137 |
text: "Title".to_string(), |
| 138 |
anchor: "title".to_string(), |
| 139 |
}, |
| 140 |
TocEntry { |
| 141 |
level: 2, |
| 142 |
text: "Section".to_string(), |
| 143 |
anchor: "section".to_string(), |
| 144 |
}, |
| 145 |
]; |
| 146 |
let html = render_toc_html(&entries); |
| 147 |
assert!(html.contains("<nav class=\"toc\">")); |
| 148 |
assert!(html.contains("toc-h1")); |
| 149 |
assert!(html.contains("toc-h2")); |
| 150 |
assert!(html.contains(r##"href="#title""##)); |
| 151 |
assert!(html.contains(r##"href="#section""##)); |
| 152 |
} |
| 153 |
|
| 154 |
#[test] |
| 155 |
fn render_toc_empty() { |
| 156 |
assert_eq!(render_toc_html(&[]), ""); |
| 157 |
} |
| 158 |
|
| 159 |
#[test] |
| 160 |
fn toc_escapes_html_in_text() { |
| 161 |
let entries = vec![TocEntry { |
| 162 |
level: 2, |
| 163 |
text: "A & B <C>".to_string(), |
| 164 |
anchor: "a--b-c".to_string(), |
| 165 |
}]; |
| 166 |
let html = render_toc_html(&entries); |
| 167 |
assert!(html.contains("A & B <C>")); |
| 168 |
} |
| 169 |
} |
| 170 |
|