rdx_transform/transforms/
slug.rs1use rdx_ast::*;
2
3use crate::{Transform, collect_text};
4
5pub 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 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; 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 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 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}