rdx_transform/transforms/
toc.rs1use rdx_ast::*;
2
3use crate::{Transform, collect_text, synthetic_pos, walk};
4
5pub struct TableOfContents {
18 pub min_depth: u8,
19 pub max_depth: u8,
20 pub auto_insert: bool,
21 pub numbered: bool,
22}
23
24impl Default for TableOfContents {
25 fn default() -> Self {
26 TableOfContents {
27 min_depth: 2,
28 max_depth: 3,
29 auto_insert: false,
30 numbered: false,
31 }
32 }
33}
34
35#[derive(Debug, Clone)]
37struct TocEntry {
38 depth: u8,
39 text: String,
40 id: Option<String>,
41 number: Option<String>,
43}
44
45impl Transform for TableOfContents {
46 fn name(&self) -> &str {
47 "table-of-contents"
48 }
49
50 fn transform(&self, root: &mut Root, _source: &str) {
51 let mut entries = collect_headings(&root.children, self.min_depth, self.max_depth);
52 if entries.is_empty() {
53 return;
54 }
55
56 if self.numbered {
57 assign_numbers(&mut entries);
58 }
59
60 let toc_node = build_toc_list(&entries);
61
62 let placeholder_idx = root.children.iter().position(|n| {
64 matches!(n, Node::Component(c) if c.name == "TableOfContents" && c.children.is_empty())
65 });
66
67 if let Some(idx) = placeholder_idx {
68 root.children[idx] = toc_node;
69 } else if self.auto_insert {
70 root.children.insert(0, toc_node);
71 }
72 }
73}
74
75fn collect_headings(nodes: &[Node], min: u8, max: u8) -> Vec<TocEntry> {
76 let mut entries = Vec::new();
77 walk(nodes, &mut |node| {
78 if let Node::Heading(h) = node
79 && let Some(depth) = h.depth
80 && depth >= min
81 && depth <= max
82 {
83 entries.push(TocEntry {
84 depth,
85 text: collect_text(&h.children),
86 id: h.id.clone(),
87 number: None,
88 });
89 }
90 });
91 entries
92}
93
94fn assign_numbers(entries: &mut [TocEntry]) {
96 if entries.is_empty() {
97 return;
98 }
99 let base_depth = entries.iter().map(|e| e.depth).min().unwrap();
100 let mut counters = [0u32; 7]; for entry in entries.iter_mut() {
104 let rel = (entry.depth - base_depth) as usize;
105 counters[rel] += 1;
106 for c in counters.iter_mut().skip(rel + 1) {
108 *c = 0;
109 }
110 let parts: Vec<String> = counters[..=rel].iter().map(|n| n.to_string()).collect();
112 entry.number = Some(parts.join("."));
113 }
114}
115
116fn build_toc_list(entries: &[TocEntry]) -> Node {
117 let mut items = Vec::new();
119 let pos = synthetic_pos();
122
123 for entry in entries {
124 let href = entry
125 .id
126 .as_ref()
127 .map(|id| format!("#{}", id))
128 .unwrap_or_default();
129
130 let display = match &entry.number {
131 Some(num) => format!("{} {}", num, entry.text),
132 None => entry.text.clone(),
133 };
134
135 let link = Node::Link(LinkNode {
136 url: href,
137 title: None,
138 children: vec![Node::Text(TextNode {
139 value: display,
140 position: pos.clone(),
141 })],
142 position: pos.clone(),
143 });
144
145 let list_item = Node::ListItem(StandardBlockNode {
146 depth: Some(entry.depth),
147 ordered: None,
148 checked: None,
149 id: None,
150 children: vec![link],
151 position: pos.clone(),
152 });
153
154 items.push(list_item);
155 }
156
157 Node::Component(ComponentNode {
158 name: "TableOfContents".to_string(),
159 is_inline: false,
160 attributes: vec![],
161 children: vec![Node::List(StandardBlockNode {
162 depth: None,
163 ordered: None,
164 checked: None,
165 id: None,
166 children: items,
167 position: pos.clone(),
168 })],
169 raw_content: String::new(),
170 position: pos,
171 })
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::{AutoSlug, Pipeline};
178
179 #[test]
180 fn toc_from_headings() {
181 let root = Pipeline::new()
182 .add(AutoSlug::new())
183 .add(TableOfContents {
184 min_depth: 1,
185 max_depth: 3,
186 auto_insert: true,
187 numbered: false,
188 })
189 .run("# Intro\n\n## Setup\n\n### Details\n\n## Usage\n");
190
191 match &root.children[0] {
193 Node::Component(c) => {
194 assert_eq!(c.name, "TableOfContents");
195 match &c.children[0] {
197 Node::List(l) => assert_eq!(l.children.len(), 4),
198 other => panic!("Expected list, got {:?}", other),
199 }
200 }
201 other => panic!("Expected TOC component, got {:?}", other),
202 }
203 }
204
205 #[test]
206 fn toc_replaces_placeholder() {
207 let root = Pipeline::new()
208 .add(AutoSlug::new())
209 .add(TableOfContents::default())
210 .run("# Title\n\n<TableOfContents />\n\n## First\n\n## Second\n");
211
212 let toc = root.children.iter().find(|n| {
214 matches!(n, Node::Component(c) if c.name == "TableOfContents" && !c.children.is_empty())
215 });
216 assert!(
217 toc.is_some(),
218 "TOC should replace placeholder: {:?}",
219 root.children
220 );
221 }
222
223 #[test]
224 fn numbered_toc() {
225 let root = Pipeline::new()
226 .add(AutoSlug::new())
227 .add(TableOfContents {
228 min_depth: 1,
229 max_depth: 3,
230 auto_insert: true,
231 numbered: true,
232 })
233 .run("# Intro\n\n## Setup\n\n### Details\n\n## Usage\n\n# Advanced\n\n## Config\n");
234
235 if let Node::Component(c) = &root.children[0]
237 && let Node::List(l) = &c.children[0]
238 {
239 let texts: Vec<String> = l
241 .children
242 .iter()
243 .filter_map(|item| {
244 if let Node::ListItem(li) = item
245 && let Node::Link(link) = &li.children[0]
246 && let Node::Text(t) = &link.children[0]
247 {
248 return Some(t.value.clone());
249 }
250 None
251 })
252 .collect();
253 assert_eq!(texts[0], "1 Intro");
254 assert_eq!(texts[1], "1.1 Setup");
255 assert_eq!(texts[2], "1.1.1 Details");
256 assert_eq!(texts[3], "1.2 Usage");
257 assert_eq!(texts[4], "2 Advanced");
258 assert_eq!(texts[5], "2.1 Config");
259 }
260 }
261
262 #[test]
263 fn no_toc_without_placeholder_or_auto() {
264 let root = Pipeline::new()
265 .add(TableOfContents::default()) .run("## First\n\n## Second\n");
267
268 let has_toc = root
269 .children
270 .iter()
271 .any(|n| matches!(n, Node::Component(c) if c.name == "TableOfContents"));
272 assert!(!has_toc, "Should not auto-insert TOC");
273 }
274}