1use comrak::html::collect_text;
14use comrak::nodes::{AstNode, NodeValue};
15use comrak::Anchorizer;
16use serde::Serialize;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21pub struct Heading {
22 pub id: String,
24 pub text: String,
26 pub depth: u8,
28}
29
30pub fn anchor_ids<'a>(texts: impl IntoIterator<Item = &'a str>) -> Vec<String> {
35 let mut anchorizer = Anchorizer::new();
36 texts.into_iter().map(|t| anchorizer.anchorize(t)).collect()
37}
38
39pub fn collect_headings<'a>(root: &'a AstNode<'a>) -> Vec<Heading> {
43 let mut anchorizer = Anchorizer::new();
44 let mut out = Vec::new();
45 for node in root.descendants() {
46 if let NodeValue::Heading(h) = &node.data.borrow().value {
47 if h.level == 2 || h.level == 3 {
48 let text = collect_text(node);
49 let id = anchorizer.anchorize(&text);
50 out.push(Heading {
51 id,
52 text: text.trim().to_string(),
53 depth: h.level,
54 });
55 }
56 }
57 }
58 out
59}
60
61pub fn stamp_heading_ids(html: &str, headings: &[Heading]) -> String {
69 let mut out = String::with_capacity(html.len() + headings.len() * 24);
70 let mut rest = html;
71 let mut iter = headings.iter();
72
73 loop {
74 let h2 = rest.find("<h2>");
76 let h3 = rest.find("<h3>");
77 let next = match (h2, h3) {
78 (None, None) => None,
79 (Some(a), None) => Some((a, 2u8)),
80 (None, Some(b)) => Some((b, 3u8)),
81 (Some(a), Some(b)) => {
82 if a < b {
83 Some((a, 2))
84 } else {
85 Some((b, 3))
86 }
87 }
88 };
89
90 let Some((pos, level)) = next else {
91 out.push_str(rest);
92 break;
93 };
94
95 let tag_len = 4; out.push_str(&rest[..pos]);
97 match iter.next() {
98 Some(h) if h.depth == level => {
99 out.push_str(&format!("<h{} id=\"{}\">", level, escape_attr(&h.id)));
100 }
101 _ => out.push_str(&rest[pos..pos + tag_len]),
103 }
104 rest = &rest[pos + tag_len..];
105 }
106
107 out
108}
109
110fn escape_attr(s: &str) -> String {
113 s.replace('&', "&")
114 .replace('"', """)
115 .replace('<', "<")
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use comrak::{parse_document, Arena};
122
123 #[test]
124 fn collects_h2_and_h3_skips_h1_and_h4() {
125 let arena = Arena::new();
126 let root = parse_document(
127 &arena,
128 "# Title\n\n## Alpha\n\n### Beta\n\n#### Deep\n",
129 &crate::markdown::comrak_options(),
130 );
131 let hs = collect_headings(root);
132 assert_eq!(hs.len(), 2);
133 assert_eq!(
134 hs[0],
135 Heading {
136 id: "alpha".into(),
137 text: "Alpha".into(),
138 depth: 2
139 }
140 );
141 assert_eq!(
142 hs[1],
143 Heading {
144 id: "beta".into(),
145 text: "Beta".into(),
146 depth: 3
147 }
148 );
149 }
150
151 #[test]
152 fn duplicate_headings_get_unique_suffixes() {
153 let arena = Arena::new();
154 let root = parse_document(
155 &arena,
156 "## Notes\n\n## Notes\n",
157 &crate::markdown::comrak_options(),
158 );
159 let hs = collect_headings(root);
160 assert_eq!(hs[0].id, "notes");
161 assert_eq!(hs[1].id, "notes-1");
162 }
163
164 #[test]
165 fn stamps_ids_onto_heading_tags_in_order() {
166 let html = "<h2>Alpha</h2>\n<p>x</p>\n<h3>Beta</h3>\n";
167 let headings = vec![
168 Heading {
169 id: "alpha".into(),
170 text: "Alpha".into(),
171 depth: 2,
172 },
173 Heading {
174 id: "beta".into(),
175 text: "Beta".into(),
176 depth: 3,
177 },
178 ];
179 let out = stamp_heading_ids(html, &headings);
180 assert!(out.contains(r#"<h2 id="alpha">Alpha</h2>"#));
181 assert!(out.contains(r#"<h3 id="beta">Beta</h3>"#));
182 }
183
184 #[test]
185 fn stamp_is_noop_without_headings() {
186 let html = "<p>no headings here</p>";
187 assert_eq!(stamp_heading_ids(html, &[]), html);
188 }
189}