rs_web/markdown/transforms/
heading_anchors.rs

1use pulldown_cmark::{CowStr, Event, HeadingLevel, Tag, TagEnd};
2
3use super::AstTransform;
4use crate::markdown::TransformContext;
5
6/// Transform that adds anchor IDs to headings
7pub struct HeadingAnchorsTransform;
8
9impl HeadingAnchorsTransform {
10    pub fn new() -> Self {
11        Self
12    }
13}
14
15impl Default for HeadingAnchorsTransform {
16    fn default() -> Self {
17        Self::new()
18    }
19}
20
21impl AstTransform for HeadingAnchorsTransform {
22    fn name(&self) -> &'static str {
23        "heading_anchors"
24    }
25
26    fn priority(&self) -> i32 {
27        60
28    }
29
30    fn transform<'a>(&self, events: Vec<Event<'a>>, _ctx: &TransformContext<'_>) -> Vec<Event<'a>> {
31        let mut result = Vec::with_capacity(events.len() + 20);
32        let mut in_heading = false;
33        let mut heading_level: Option<HeadingLevel> = None;
34        let mut heading_text = String::new();
35        let mut heading_counts = std::collections::HashMap::new();
36
37        for event in events {
38            match &event {
39                Event::Start(Tag::Heading { level, .. }) => {
40                    in_heading = true;
41                    heading_level = Some(*level);
42                    heading_text.clear();
43                    // Push the start event - we'll replace it later
44                    result.push(event);
45                }
46                Event::Text(text) if in_heading => {
47                    heading_text.push_str(text);
48                    result.push(event);
49                }
50                Event::End(TagEnd::Heading(_)) if in_heading => {
51                    in_heading = false;
52
53                    // Generate slug
54                    let base_slug = slugify(&heading_text);
55
56                    // Handle duplicate slugs
57                    let count = heading_counts.entry(base_slug.clone()).or_insert(0);
58                    let slug = if *count > 0 {
59                        format!("{}-{}", base_slug, count)
60                    } else {
61                        base_slug
62                    };
63                    *count += 1;
64
65                    if let Some(level) = heading_level.take() {
66                        // Find and replace the Start event with HTML that has an ID
67                        let start_idx = result
68                            .iter()
69                            .rposition(|e| matches!(e, Event::Start(Tag::Heading { .. })));
70
71                        if let Some(idx) = start_idx {
72                            result[idx] = Event::Html(CowStr::from(format!(
73                                r#"<h{} id="{}">"#,
74                                heading_level_to_num(level),
75                                slug
76                            )));
77                            result.push(Event::Html(CowStr::from(format!(
78                                r#"</h{}>"#,
79                                heading_level_to_num(level)
80                            ))));
81                        } else {
82                            result.push(event);
83                        }
84                    }
85                }
86                _ => {
87                    result.push(event);
88                }
89            }
90        }
91
92        result
93    }
94}
95
96fn slugify(text: &str) -> String {
97    text.to_lowercase()
98        .chars()
99        .map(|c| {
100            if c.is_alphanumeric() {
101                c
102            } else if c.is_whitespace() || c == '-' || c == '_' {
103                '-'
104            } else {
105                ' ' // Will be filtered out
106            }
107        })
108        .filter(|c| *c != ' ')
109        .collect::<String>()
110        .split('-')
111        .filter(|s| !s.is_empty())
112        .collect::<Vec<_>>()
113        .join("-")
114}
115
116fn heading_level_to_num(level: HeadingLevel) -> u8 {
117    match level {
118        HeadingLevel::H1 => 1,
119        HeadingLevel::H2 => 2,
120        HeadingLevel::H3 => 3,
121        HeadingLevel::H4 => 4,
122        HeadingLevel::H5 => 5,
123        HeadingLevel::H6 => 6,
124    }
125}