1use crate::page::html_escape;
31use crate::types::Heading;
32
33pub const ANCHOR_CLASS: &str = "heading-anchor";
35
36pub fn anchor_headings(html: &str) -> (String, Vec<Heading>) {
42 let mut out = String::with_capacity(html.len() + html.len() / 8);
43 let mut headings = Vec::new();
44 let mut taken: Vec<String> = Vec::new();
45 let mut rest = html;
46
47 while let Some(at) = find_heading_open(rest) {
48 out.push_str(&rest[..at]);
49 rest = &rest[at..];
50 let Some(open) = split_open_tag(rest) else {
51 break;
54 };
55 let close = format!("</h{}>", open.level);
56 let after_open = &rest[open.len..];
57 let Some(end) = after_open.find(&close) else {
58 break;
59 };
60 let inner = &after_open[..end];
61 let text = decode_entities(&strip_tags(inner));
62
63 let id = match open.id {
64 Some(id) => id.to_string(),
65 None => unique_id(&prov::link::slug(&text), &taken),
66 };
67 taken.push(id.clone());
68
69 let escaped_id = html_escape(&id);
70 out.push_str(&format!("<h{}", open.level));
71 if open.id.is_none() {
72 out.push_str(&format!(r#" id="{escaped_id}""#));
73 }
74 out.push_str(open.attrs);
75 out.push('>');
76 out.push_str(inner);
77 out.push_str(&format!(
78 r##" <a class="{ANCHOR_CLASS}" href="#{escaped_id}" aria-label="Link to this section">#</a>"##
79 ));
80 out.push_str(&close);
81
82 headings.push(Heading {
83 level: open.level,
84 id,
85 text,
86 });
87 rest = &after_open[end + close.len()..];
88 }
89
90 out.push_str(rest);
91 (out, headings)
92}
93
94fn find_heading_open(s: &str) -> Option<usize> {
98 let bytes = s.as_bytes();
99 let mut from = 0;
100 while let Some(rel) = s[from..].find("<h") {
101 let at = from + rel;
102 if let (Some(level), Some(next)) = (bytes.get(at + 2), bytes.get(at + 3))
103 && (b'1'..=b'6').contains(level)
104 && (next.is_ascii_whitespace() || *next == b'>' || *next == b'/')
105 {
106 return Some(at);
107 }
108 from = at + 2;
109 }
110 None
111}
112
113struct OpenTag<'a> {
115 level: u8,
116 id: Option<&'a str>,
118 attrs: &'a str,
121 len: usize,
123}
124
125fn split_open_tag(s: &str) -> Option<OpenTag<'_>> {
127 let level = s.as_bytes()[2] - b'0';
128 let gt = s.find('>')?;
129 let attrs = &s[3..gt];
130 Some(OpenTag {
131 level,
132 id: attribute(attrs, "id"),
133 attrs,
134 len: gt + 1,
135 })
136}
137
138fn attribute<'a>(attrs: &'a str, name: &str) -> Option<&'a str> {
140 let mut from = 0;
141 while let Some(rel) = attrs[from..].find(name) {
142 let at = from + rel;
143 let before_ok = at == 0 || attrs.as_bytes()[at - 1].is_ascii_whitespace();
144 let after = &attrs[at + name.len()..];
145 let after = after.trim_start();
146 if before_ok && let Some(value) = after.strip_prefix('=') {
147 let value = value.trim_start();
148 let quote = value.chars().next()?;
149 if quote == '"' || quote == '\'' {
150 let body = &value[1..];
151 let end = body.find(quote)?;
152 return Some(&body[..end]);
153 }
154 let end = value
155 .find(|c: char| c.is_ascii_whitespace())
156 .unwrap_or(value.len());
157 return Some(&value[..end]);
158 }
159 from = at + name.len();
160 }
161 None
162}
163
164fn unique_id(slug: &str, taken: &[String]) -> String {
166 if !taken.iter().any(|t| t == slug) {
167 return slug.to_string();
168 }
169 let mut n = 2;
170 loop {
171 let candidate = format!("{slug}-{n}");
172 if !taken.contains(&candidate) {
173 return candidate;
174 }
175 n += 1;
176 }
177}
178
179fn strip_tags(html: &str) -> String {
181 let mut text = String::with_capacity(html.len());
182 let mut in_tag = false;
183 for ch in html.chars() {
184 match ch {
185 '<' => in_tag = true,
186 '>' if in_tag => in_tag = false,
187 _ if !in_tag => text.push(ch),
188 _ => {}
189 }
190 }
191 text
192}
193
194fn decode_entities(text: &str) -> String {
198 text.replace("<", "<")
199 .replace(">", ">")
200 .replace(""", "\"")
201 .replace("'", "'")
202 .replace("&", "&")
203}
204
205pub fn render_toc(headings: &[Heading]) -> String {
213 let listed: Vec<&Heading> = headings
214 .iter()
215 .filter(|h| h.level == 2 || h.level == 3)
216 .collect();
217 if listed.len() < 2 {
218 return String::new();
219 }
220
221 let mut out = String::from(
222 r#"<nav class="toc" aria-label="On this page"><details open><summary>On this page</summary><ul>"#,
223 );
224 let mut nested = false;
227 for (i, h) in listed.iter().enumerate() {
228 match (h.level, nested) {
229 (2, true) => {
230 out.push_str("</li></ul></li>");
231 nested = false;
232 }
233 (2, false) if i > 0 => out.push_str("</li>"),
234 (3, false) => {
235 if i == 0 {
238 out.push_str("<li>");
239 }
240 out.push_str("<ul>");
241 nested = true;
242 }
243 (3, true) => out.push_str("</li>"),
244 _ => {}
245 }
246 out.push_str(&format!(
247 r##"<li><a href="#{}">{}</a>"##,
248 html_escape(&h.id),
249 html_escape(&h.text)
250 ));
251 }
252 if nested {
253 out.push_str("</li></ul>");
254 }
255 out.push_str("</li></ul></details></nav>");
256 out
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 #[test]
264 fn every_heading_gets_an_id_and_an_anchor() {
265 let (html, headings) = anchor_headings("<h1>Title</h1>\n<p>x</p>\n<h2>A Section</h2>");
266 assert_eq!(
267 html,
268 "<h1 id=\"title\">Title <a class=\"heading-anchor\" href=\"#title\" aria-label=\"Link to this section\">#</a></h1>\n\
269 <p>x</p>\n\
270 <h2 id=\"a-section\">A Section <a class=\"heading-anchor\" href=\"#a-section\" aria-label=\"Link to this section\">#</a></h2>"
271 );
272 assert_eq!(headings.len(), 2);
273 assert_eq!((headings[0].level, headings[0].id.as_str()), (1, "title"));
274 assert_eq!(headings[1].text, "A Section");
275 }
276
277 #[test]
279 fn a_repeated_heading_is_numbered() {
280 let (html, headings) = anchor_headings("<h2>Status</h2><h2>Status</h2><h2>Status</h2>");
281 assert!(html.contains(r##"id="status">"##));
282 assert!(html.contains(r##"id="status-2">"##));
283 assert!(html.contains(r##"id="status-3">"##));
284 assert_eq!(headings[2].id, "status-3");
285 }
286
287 #[test]
290 fn an_existing_id_is_kept() {
291 let (html, headings) = anchor_headings(r#"<h2 id="custom" class="x">Custom</h2>"#);
292 assert!(
293 html.starts_with(r#"<h2 id="custom" class="x">Custom "#),
294 "got {html}"
295 );
296 assert!(html.contains(r##"href="#custom""##));
297 assert_eq!(headings[0].id, "custom");
298 let (_, headings) = anchor_headings(r#"<h2 id="status">A</h2><h2>Status</h2>"#);
300 assert_eq!(headings[1].id, "status-2");
301 }
302
303 #[test]
306 fn heading_text_is_the_text_the_reader_sees() {
307 let (html, headings) = anchor_headings("<h2>Ben & <em>Co</em></h2>");
308 assert_eq!(headings[0].text, "Ben & Co");
309 assert_eq!(headings[0].id, "ben-co");
310 assert!(html.contains("<em>Co</em>"), "the markup survives in place");
311 }
312
313 #[test]
316 fn only_headings_are_touched() {
317 let source =
318 "<header><h2>In</h2></header><hr><pre><code><h2>no</h2></code></pre>";
319 let (html, headings) = anchor_headings(source);
320 assert_eq!(headings.len(), 1);
321 assert_eq!(headings[0].id, "in");
322 assert!(html.contains("<header>"));
323 assert!(html.contains("<hr>"));
324 assert!(html.contains("<h2>no</h2>"));
325 }
326
327 #[test]
328 fn a_body_with_no_headings_is_itself() {
329 let (html, headings) = anchor_headings("<p>plain</p>");
330 assert_eq!(html, "<p>plain</p>");
331 assert!(headings.is_empty());
332 }
333
334 fn h(level: u8, id: &str) -> Heading {
335 Heading {
336 level,
337 id: id.to_string(),
338 text: id.to_uppercase(),
339 }
340 }
341
342 #[test]
343 fn the_outline_nests_h3_under_h2_and_lists_nothing_else() {
344 let toc = render_toc(&[
345 h(1, "title"),
346 h(2, "a"),
347 h(3, "a1"),
348 h(3, "a2"),
349 h(2, "b"),
350 h(4, "deep"),
351 ]);
352 assert_eq!(
353 toc,
354 r##"<nav class="toc" aria-label="On this page"><details open><summary>On this page</summary><ul><li><a href="#a">A</a><ul><li><a href="#a1">A1</a></li><li><a href="#a2">A2</a></li></ul></li><li><a href="#b">B</a></li></ul></details></nav>"##
355 );
356 }
357
358 #[test]
360 fn an_outline_needs_two_entries() {
361 assert_eq!(render_toc(&[h(1, "t"), h(2, "only")]), "");
362 assert!(!render_toc(&[h(2, "a"), h(2, "b")]).is_empty());
363 }
364}