Skip to main content

max / makenotwork

5.0 KB · 170 lines History Blame Raw
1 use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
2
3 use crate::escape::html_escape;
4
5 /// A single entry in a table of contents.
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 /// Extract a table of contents from markdown headings.
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 /// Render TOC entries as an HTML nested list.
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 /// GitHub-style anchor generation: lowercase, spaces to hyphens, strip
71 /// non-alphanumeric (except hyphens).
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 &amp; B &lt;C&gt;"));
168 }
169 }
170