Skip to main content

docgen_core/
headings.rs

1//! Extract the `h2`/`h3` heading outline of a document and stamp matching
2//! `id` anchors onto the rendered heading tags.
3//!
4//! The right-rail "On this page" table of contents and the scroll-spy island
5//! both key off `id` attributes on `<h2>`/`<h3>` in the rendered article. Comrak
6//! *can* emit heading ids, but it places them on a nested
7//! `<a class="anchor" id="…">` element rather than the heading itself, which the
8//! `h2[id]` / `h3[id]` selectors the scroll-spy uses would never match. So we
9//! anchorize the heading text ourselves (with comrak's own [`Anchorizer`], so
10//! the slugs are byte-for-byte what comrak would have produced) and inject the
11//! `id` directly onto the heading's opening tag.
12
13use comrak::html::collect_text;
14use comrak::nodes::{AstNode, NodeValue};
15use comrak::Anchorizer;
16use serde::Serialize;
17
18/// One entry in a page's heading outline. Only `h2`/`h3` are collected — `h1`
19/// is the (hidden) page title and `h4`+ are too deep for the rail TOC.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21pub struct Heading {
22    /// Stable anchor id (matches the `id` stamped on the rendered heading tag).
23    pub id: String,
24    /// Human-readable heading text.
25    pub text: String,
26    /// Heading level: `2` or `3`.
27    pub depth: u8,
28}
29
30/// Anchorize a sequence of heading texts into the exact ids the rendered page
31/// carries. One [`Anchorizer`] per call, so the `-1`, `-2`, … de-duplication
32/// suffixes match [`collect_headings`] when fed the same `h2`/`h3` sequence.
33/// Used by the linter to validate `[[page#anchor]]` targets without rendering.
34pub 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
39/// Walk the AST in document order and collect the `h2`/`h3` outline, anchorizing
40/// each heading's text into a unique id. One [`Anchorizer`] per call guarantees
41/// the `-1`, `-2`, … de-duplication suffixes match comrak's own scheme.
42pub 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
61/// Inject `id="…"` onto the `<h2>`/`<h3>` opening tags of `html`, in document
62/// order, using the ids from [`collect_headings`].
63///
64/// `headings` MUST be in the same order the tags appear in `html` (it is, since
65/// both derive from one AST walk). Each heading consumes the next matching
66/// `<h2>`/`<h3>` occurrence. Comrak emits bare `<h2>` / `<h3>` (no sourcepos,
67/// no existing id), so a plain ordered text rewrite is exact and unambiguous.
68pub 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        // Find the next `<h2>` or `<h3>` opening tag.
75        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; // "<hN>"
96        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            // Misalignment (shouldn't happen): leave the tag untouched.
102            _ => out.push_str(&rest[pos..pos + tag_len]),
103        }
104        rest = &rest[pos + tag_len..];
105    }
106
107    out
108}
109
110/// Minimal attribute escaping for an anchorized id (anchorize already strips
111/// most markup-significant characters; this guards the remainder).
112fn escape_attr(s: &str) -> String {
113    s.replace('&', "&amp;")
114        .replace('"', "&quot;")
115        .replace('<', "&lt;")
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}