Skip to main content

plates_render/
headings.rs

1//! Heading anchors and the page outline, as a pass over rendered HTML.
2//!
3//! Every `<h1>`–`<h6>` in a body leaves here with an `id` and a link to
4//! itself, and the pass hands back the list of what it found — which is what
5//! the `toc` shell slot, the `headings` template key and the built-in "On this
6//! page" block are all made of. One pass, on the output, on the precedent
7//! [`crate::syntax`] set: twig's serializer offers no heading-id option, and a
8//! pass over the HTML covers Markdown, Djot and hand-written HTML bodies with
9//! one implementation.
10//!
11//! # The id
12//!
13//! [`prov::link::slug`] of the heading's text — the same function
14//! [`crate::page::title_to_anchor`] uses, so a single-file render and a site
15//! render agree on what `## Status` is called. A second heading with the same
16//! slug on one page gets `-2`, then `-3`. A heading that already carries an
17//! `id` — an HTML body, a Djot `{#custom}` attribute — keeps it, and still
18//! counts towards the numbering so a later `## Status` cannot collide with it.
19//!
20//! # The anchor
21//!
22//! ```html
23//! <h2 id="status">Status <a class="heading-anchor" href="#status" aria-label="Link to this section">#</a></h2>
24//! ```
25//!
26//! Inside the heading rather than beside it, so the heading's text is what a
27//! screen reader reads first and the link is one tab stop after it. The
28//! stylesheet hides the mark until the heading is hovered or the link focused.
29
30use crate::page::html_escape;
31use crate::types::Heading;
32
33/// The class on the anchor link this pass appends to every heading.
34pub const ANCHOR_CLASS: &str = "heading-anchor";
35
36/// Give every heading in `html` an `id` and an anchor, and list them.
37///
38/// Headings are returned in document order, each with the id it ended up
39/// with and its text with markup stripped and entities decoded — the string a
40/// template or an outline wants to print, not the bytes twig wrote.
41pub 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            // `<h2` that never closes its tag: not a heading, and nothing after
52            // it can be either — publish the remainder as it is.
53            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
94/// The byte offset of the next `<h1`–`<h6` tag opening in `s`, when there is
95/// one and it is a tag rather than the start of a longer name (`<h2>` yes,
96/// `<header>` no).
97fn 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
113/// An opening heading tag, taken apart.
114struct OpenTag<'a> {
115    level: u8,
116    /// The value of an `id` attribute the tag already carries.
117    id: Option<&'a str>,
118    /// Everything between the tag name and the `>`, to be written back as it
119    /// was: an HTML body's own `class`, a Djot attribute's `id`.
120    attrs: &'a str,
121    /// How many bytes of the input the opening tag occupies.
122    len: usize,
123}
124
125/// Split the opening tag at the start of `s`, which is known to begin `<hN`.
126fn 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
138/// The value of `name="…"` (or `name='…'`) among a tag's attributes.
139fn 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
164/// `slug`, or `slug-2`, `slug-3`, … — the first spelling not already taken.
165fn 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
179/// The text of a fragment of HTML, tags removed.
180fn 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
194/// The five entities twig's escaper writes, put back — the text of a heading
195/// called `Ben & Co` is `Ben & Co`, and its slug is `ben-co` rather than
196/// `ben-amp-co`.
197fn decode_entities(text: &str) -> String {
198    text.replace("&lt;", "<")
199        .replace("&gt;", ">")
200        .replace("&quot;", "\"")
201        .replace("&#39;", "'")
202        .replace("&amp;", "&")
203}
204
205/// The outline an `<nav class="toc">` holds: a nested list of the page's
206/// `h2`–`h3` headings, each linking to its anchor.
207///
208/// Empty — no element at all — when there are fewer than two of them: an
209/// outline of one entry is a heading the reader can already see. Levels above
210/// `h2` are left out because a body's `h1` is its title, and below `h3`
211/// because an outline that lists every `h5` is the page again.
212pub 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    // Whether the cursor is inside an `h3` sub-list. Two levels is the whole
225    // grammar, so a flag is the whole state.
226    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                // A leading `h3` with no `h2` above it still gets an item to
236                // hang from; the item is just empty.
237                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    /// The same slug twice on one page is numbered, so both are addressable.
278    #[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    /// An `id` the body already carries — an HTML body's, or a Djot
288    /// `{#custom}` — is the heading's name, and the pass does not rename it.
289    #[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        // …and it is taken, so a heading that would slug to it is numbered.
299        let (_, headings) = anchor_headings(r#"<h2 id="status">A</h2><h2>Status</h2>"#);
300        assert_eq!(headings[1].id, "status-2");
301    }
302
303    /// Text is what a reader sees: markup gone, entities put back, and the
304    /// slug is made from that rather than from `&amp;`.
305    #[test]
306    fn heading_text_is_the_text_the_reader_sees() {
307        let (html, headings) = anchor_headings("<h2>Ben &amp; <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    /// `<header>` and `<hr>` are not headings, and a heading inside a code
314    /// block is text twig already escaped.
315    #[test]
316    fn only_headings_are_touched() {
317        let source =
318            "<header><h2>In</h2></header><hr><pre><code>&lt;h2&gt;no&lt;/h2&gt;</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("&lt;h2&gt;no&lt;/h2&gt;"));
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    /// One entry is not an outline.
359    #[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}