Skip to main content

rdx_transform/transforms/
slug.rs

1use rdx_ast::*;
2
3use crate::{Transform, collect_text};
4
5/// Generates URL-safe slugs for headings and sets the `id` field.
6///
7/// Slug algorithm:
8/// 1. Extract plain text from heading children
9/// 2. Lowercase, replace non-alphanumeric runs with `-`, trim `-`
10/// 3. Deduplicate by appending `-1`, `-2`, etc.
11///
12/// # Example
13///
14/// ```rust
15/// use rdx_transform::{Pipeline, AutoSlug, parse};
16///
17/// let root = Pipeline::new().add(AutoSlug::new()).run("# Hello World\n");
18/// // heading.id == Some("hello-world")
19/// ```
20pub struct AutoSlug {
21    _private: (),
22}
23
24impl AutoSlug {
25    pub fn new() -> Self {
26        AutoSlug { _private: () }
27    }
28}
29
30impl Default for AutoSlug {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36impl Transform for AutoSlug {
37    fn name(&self) -> &str {
38        "auto-slug"
39    }
40
41    fn transform(&self, root: &mut Root, _source: &str) {
42        let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
43        slugify_headings(&mut root.children, &mut seen);
44    }
45}
46
47fn slugify_headings(nodes: &mut [Node], seen: &mut std::collections::HashMap<String, usize>) {
48    for node in nodes.iter_mut() {
49        match node {
50            Node::Heading(block) => {
51                if block.id.is_none() {
52                    let text = collect_text(&block.children);
53                    let base = to_slug(&text);
54                    if base.is_empty() {
55                        continue;
56                    }
57                    let count = seen.entry(base.clone()).or_insert(0);
58                    let slug = if *count == 0 {
59                        base
60                    } else {
61                        format!("{}-{}", base, count)
62                    };
63                    *count += 1;
64                    block.id = Some(slug);
65                }
66                // Recurse into heading children (unlikely to contain headings, but be thorough)
67                slugify_headings(&mut block.children, seen);
68            }
69            Node::Paragraph(b)
70            | Node::List(b)
71            | Node::ListItem(b)
72            | Node::Blockquote(b)
73            | Node::Html(b)
74            | Node::Table(b)
75            | Node::TableRow(b)
76            | Node::TableCell(b)
77            | Node::Emphasis(b)
78            | Node::Strong(b)
79            | Node::Strikethrough(b)
80            | Node::ThematicBreak(b) => {
81                slugify_headings(&mut b.children, seen);
82            }
83            Node::Link(l) => slugify_headings(&mut l.children, seen),
84            Node::Image(i) => slugify_headings(&mut i.children, seen),
85            Node::Component(c) => slugify_headings(&mut c.children, seen),
86            Node::FootnoteDefinition(f) => slugify_headings(&mut f.children, seen),
87            _ => {}
88        }
89    }
90}
91
92fn to_slug(text: &str) -> String {
93    let mut slug = String::with_capacity(text.len());
94    let mut prev_dash = true; // prevent leading dash
95    for ch in text.chars() {
96        if ch.is_ascii_alphanumeric() {
97            slug.push(ch.to_ascii_lowercase());
98            prev_dash = false;
99        } else if !prev_dash {
100            slug.push('-');
101            prev_dash = true;
102        }
103    }
104    // Trim trailing dash
105    if slug.ends_with('-') {
106        slug.pop();
107    }
108    slug
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn slug_generation() {
117        assert_eq!(to_slug("Hello World"), "hello-world");
118        assert_eq!(to_slug("API v2.0 Reference"), "api-v2-0-reference");
119        assert_eq!(to_slug("  Leading Spaces  "), "leading-spaces");
120        assert_eq!(to_slug("camelCase"), "camelcase");
121        assert_eq!(to_slug("ALLCAPS"), "allcaps");
122    }
123
124    #[test]
125    fn auto_slug_sets_id() {
126        let mut root = rdx_parser::parse("# Hello\n\n## World\n");
127        let slug = AutoSlug::new();
128        slug.transform(&mut root, "");
129        match &root.children[0] {
130            Node::Heading(h) => assert_eq!(h.id.as_deref(), Some("hello")),
131            other => panic!("Expected heading, got {:?}", other),
132        }
133    }
134
135    #[test]
136    fn preserves_existing_id() {
137        let mut root = rdx_parser::parse("# Test\n");
138        // Manually set an id
139        if let Node::Heading(ref mut h) = root.children[0] {
140            h.id = Some("custom-id".to_string());
141        }
142        AutoSlug::new().transform(&mut root, "");
143        match &root.children[0] {
144            Node::Heading(h) => assert_eq!(h.id.as_deref(), Some("custom-id")),
145            other => panic!("Expected heading, got {:?}", other),
146        }
147    }
148}