use crate::page::html_escape;
use crate::types::Heading;
pub const ANCHOR_CLASS: &str = "heading-anchor";
pub fn anchor_headings(html: &str) -> (String, Vec<Heading>) {
let mut out = String::with_capacity(html.len() + html.len() / 8);
let mut headings = Vec::new();
let mut taken: Vec<String> = Vec::new();
let mut rest = html;
while let Some(at) = find_heading_open(rest) {
out.push_str(&rest[..at]);
rest = &rest[at..];
let Some(open) = split_open_tag(rest) else {
break;
};
let close = format!("</h{}>", open.level);
let after_open = &rest[open.len..];
let Some(end) = after_open.find(&close) else {
break;
};
let inner = &after_open[..end];
let text = decode_entities(&strip_tags(inner));
let id = match open.id {
Some(id) => id.to_string(),
None => unique_id(&prov::link::slug(&text), &taken),
};
taken.push(id.clone());
let escaped_id = html_escape(&id);
out.push_str(&format!("<h{}", open.level));
if open.id.is_none() {
out.push_str(&format!(r#" id="{escaped_id}""#));
}
out.push_str(open.attrs);
out.push('>');
out.push_str(inner);
out.push_str(&format!(
r##" <a class="{ANCHOR_CLASS}" href="#{escaped_id}" aria-label="Link to this section">#</a>"##
));
out.push_str(&close);
headings.push(Heading {
level: open.level,
id,
text,
});
rest = &after_open[end + close.len()..];
}
out.push_str(rest);
(out, headings)
}
fn find_heading_open(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
let mut from = 0;
while let Some(rel) = s[from..].find("<h") {
let at = from + rel;
if let (Some(level), Some(next)) = (bytes.get(at + 2), bytes.get(at + 3))
&& (b'1'..=b'6').contains(level)
&& (next.is_ascii_whitespace() || *next == b'>' || *next == b'/')
{
return Some(at);
}
from = at + 2;
}
None
}
struct OpenTag<'a> {
level: u8,
id: Option<&'a str>,
attrs: &'a str,
len: usize,
}
fn split_open_tag(s: &str) -> Option<OpenTag<'_>> {
let level = s.as_bytes()[2] - b'0';
let gt = s.find('>')?;
let attrs = &s[3..gt];
Some(OpenTag {
level,
id: attribute(attrs, "id"),
attrs,
len: gt + 1,
})
}
fn attribute<'a>(attrs: &'a str, name: &str) -> Option<&'a str> {
let mut from = 0;
while let Some(rel) = attrs[from..].find(name) {
let at = from + rel;
let before_ok = at == 0 || attrs.as_bytes()[at - 1].is_ascii_whitespace();
let after = &attrs[at + name.len()..];
let after = after.trim_start();
if before_ok && let Some(value) = after.strip_prefix('=') {
let value = value.trim_start();
let quote = value.chars().next()?;
if quote == '"' || quote == '\'' {
let body = &value[1..];
let end = body.find(quote)?;
return Some(&body[..end]);
}
let end = value
.find(|c: char| c.is_ascii_whitespace())
.unwrap_or(value.len());
return Some(&value[..end]);
}
from = at + name.len();
}
None
}
fn unique_id(slug: &str, taken: &[String]) -> String {
if !taken.iter().any(|t| t == slug) {
return slug.to_string();
}
let mut n = 2;
loop {
let candidate = format!("{slug}-{n}");
if !taken.contains(&candidate) {
return candidate;
}
n += 1;
}
}
fn strip_tags(html: &str) -> String {
let mut text = String::with_capacity(html.len());
let mut in_tag = false;
for ch in html.chars() {
match ch {
'<' => in_tag = true,
'>' if in_tag => in_tag = false,
_ if !in_tag => text.push(ch),
_ => {}
}
}
text
}
fn decode_entities(text: &str) -> String {
text.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("&", "&")
}
pub fn render_toc(headings: &[Heading]) -> String {
let listed: Vec<&Heading> = headings
.iter()
.filter(|h| h.level == 2 || h.level == 3)
.collect();
if listed.len() < 2 {
return String::new();
}
let mut out = String::from(
r#"<nav class="toc" aria-label="On this page"><details open><summary>On this page</summary><ul>"#,
);
let mut nested = false;
for (i, h) in listed.iter().enumerate() {
match (h.level, nested) {
(2, true) => {
out.push_str("</li></ul></li>");
nested = false;
}
(2, false) if i > 0 => out.push_str("</li>"),
(3, false) => {
if i == 0 {
out.push_str("<li>");
}
out.push_str("<ul>");
nested = true;
}
(3, true) => out.push_str("</li>"),
_ => {}
}
out.push_str(&format!(
r##"<li><a href="#{}">{}</a>"##,
html_escape(&h.id),
html_escape(&h.text)
));
}
if nested {
out.push_str("</li></ul>");
}
out.push_str("</li></ul></details></nav>");
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_heading_gets_an_id_and_an_anchor() {
let (html, headings) = anchor_headings("<h1>Title</h1>\n<p>x</p>\n<h2>A Section</h2>");
assert_eq!(
html,
"<h1 id=\"title\">Title <a class=\"heading-anchor\" href=\"#title\" aria-label=\"Link to this section\">#</a></h1>\n\
<p>x</p>\n\
<h2 id=\"a-section\">A Section <a class=\"heading-anchor\" href=\"#a-section\" aria-label=\"Link to this section\">#</a></h2>"
);
assert_eq!(headings.len(), 2);
assert_eq!((headings[0].level, headings[0].id.as_str()), (1, "title"));
assert_eq!(headings[1].text, "A Section");
}
#[test]
fn a_repeated_heading_is_numbered() {
let (html, headings) = anchor_headings("<h2>Status</h2><h2>Status</h2><h2>Status</h2>");
assert!(html.contains(r##"id="status">"##));
assert!(html.contains(r##"id="status-2">"##));
assert!(html.contains(r##"id="status-3">"##));
assert_eq!(headings[2].id, "status-3");
}
#[test]
fn an_existing_id_is_kept() {
let (html, headings) = anchor_headings(r#"<h2 id="custom" class="x">Custom</h2>"#);
assert!(
html.starts_with(r#"<h2 id="custom" class="x">Custom "#),
"got {html}"
);
assert!(html.contains(r##"href="#custom""##));
assert_eq!(headings[0].id, "custom");
let (_, headings) = anchor_headings(r#"<h2 id="status">A</h2><h2>Status</h2>"#);
assert_eq!(headings[1].id, "status-2");
}
#[test]
fn heading_text_is_the_text_the_reader_sees() {
let (html, headings) = anchor_headings("<h2>Ben & <em>Co</em></h2>");
assert_eq!(headings[0].text, "Ben & Co");
assert_eq!(headings[0].id, "ben-co");
assert!(html.contains("<em>Co</em>"), "the markup survives in place");
}
#[test]
fn only_headings_are_touched() {
let source =
"<header><h2>In</h2></header><hr><pre><code><h2>no</h2></code></pre>";
let (html, headings) = anchor_headings(source);
assert_eq!(headings.len(), 1);
assert_eq!(headings[0].id, "in");
assert!(html.contains("<header>"));
assert!(html.contains("<hr>"));
assert!(html.contains("<h2>no</h2>"));
}
#[test]
fn a_body_with_no_headings_is_itself() {
let (html, headings) = anchor_headings("<p>plain</p>");
assert_eq!(html, "<p>plain</p>");
assert!(headings.is_empty());
}
fn h(level: u8, id: &str) -> Heading {
Heading {
level,
id: id.to_string(),
text: id.to_uppercase(),
}
}
#[test]
fn the_outline_nests_h3_under_h2_and_lists_nothing_else() {
let toc = render_toc(&[
h(1, "title"),
h(2, "a"),
h(3, "a1"),
h(3, "a2"),
h(2, "b"),
h(4, "deep"),
]);
assert_eq!(
toc,
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>"##
);
}
#[test]
fn an_outline_needs_two_entries() {
assert_eq!(render_toc(&[h(1, "t"), h(2, "only")]), "");
assert!(!render_toc(&[h(2, "a"), h(2, "b")]).is_empty());
}
}