rs_web/markdown/transforms/
heading_anchors.rs1use pulldown_cmark::{CowStr, Event, HeadingLevel, Tag, TagEnd};
2
3use super::AstTransform;
4use crate::markdown::TransformContext;
5
6pub 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 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 let base_slug = slugify(&heading_text);
55
56 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 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 ' ' }
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}