Skip to main content

base_markdown/
headings.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4use crate::{stream::EventKind, NodeId};
5use alloc::{collections::BTreeMap, string::String};
6
7/// Borrow semantic text with the same inline rules for heading IDs and AST text.
8pub(crate) fn semantic_text<'a, const HEADING: bool>(
9    kind: &'a EventKind<'_>,
10    source: &'a str,
11) -> Option<&'a str> {
12    let value = match kind {
13        EventKind::Text(value)
14        | EventKind::SoftBreak(value)
15        | EventKind::Math(value)
16        | EventKind::Code { value, .. }
17        | EventKind::Html { value, .. } => value,
18        EventKind::CodeBlock { value, .. } if !HEADING => value,
19        EventKind::HardBreak => return Some("\n"),
20        _ => return None,
21    };
22    Some(
23        value
24            .resolve(source)
25            .expect("semantic text values are validated"),
26    )
27}
28
29pub(crate) fn append_text<const HEADING: bool>(
30    kind: &EventKind<'_>,
31    source: &str,
32    text: &mut String,
33) {
34    if let Some(value) = semantic_text::<HEADING>(kind, source) {
35        text.push_str(value);
36    }
37}
38
39/// Heading levels to include when building a table of contents.
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub struct HeadingOptions {
42    /// Shallowest included level.
43    pub min_level: u8,
44    /// Deepest included level.
45    pub max_level: u8,
46}
47impl Default for HeadingOptions {
48    fn default() -> Self {
49        Self {
50            min_level: 1,
51            max_level: 6,
52        }
53    }
54}
55/// A heading's stable node ID, plain text and unique HTML anchor.
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct HeadingEntry {
58    /// Heading node.
59    pub node: NodeId,
60    /// Heading level.
61    pub level: u8,
62    /// Semantic plain text.
63    pub text: String,
64    /// Anchor shared with the HTML writer's heading IDs.
65    pub anchor: String,
66}
67
68#[derive(Default)]
69pub(crate) struct AnchorGenerator {
70    next_suffix: BTreeMap<String, usize>,
71}
72
73impl AnchorGenerator {
74    pub(crate) fn anchor_for(&mut self, text: &str) -> String {
75        let base = slug(text);
76        let Some(mut suffix) = self.next_suffix.get(&base).copied() else {
77            self.next_suffix.insert(base.clone(), 2);
78            return base;
79        };
80
81        let anchor = loop {
82            let candidate = alloc::format!("{base}-{suffix}");
83            suffix += 1;
84            if !self.next_suffix.contains_key(&candidate) {
85                break candidate;
86            }
87        };
88
89        self.next_suffix.insert(base, suffix);
90        self.next_suffix.insert(anchor.clone(), 2);
91        anchor
92    }
93}
94
95fn slug(text: &str) -> String {
96    let mut result = String::new();
97    let mut previous_was_whitespace = false;
98    let lowercase = text.to_lowercase();
99
100    for character in lowercase.chars() {
101        if character.is_whitespace() {
102            if !previous_was_whitespace {
103                result.push('-');
104            }
105            previous_was_whitespace = true;
106        } else {
107            result.push(character);
108            previous_was_whitespace = false;
109        }
110    }
111
112    result
113}