rdx_transform/transforms/
toc.rs1use rdx_ast::*;
2
3use crate::{Transform, collect_text, walk};
4
5pub struct TableOfContents {
17 pub min_depth: u8,
18 pub max_depth: u8,
19 pub auto_insert: bool,
20}
21
22impl Default for TableOfContents {
23 fn default() -> Self {
24 TableOfContents {
25 min_depth: 2,
26 max_depth: 3,
27 auto_insert: false,
28 }
29 }
30}
31
32#[derive(Debug, Clone)]
34struct TocEntry {
35 depth: u8,
36 text: String,
37 id: Option<String>,
38}
39
40impl Transform for TableOfContents {
41 fn name(&self) -> &str {
42 "table-of-contents"
43 }
44
45 fn transform(&self, root: &mut Root, _source: &str) {
46 let entries = collect_headings(&root.children, self.min_depth, self.max_depth);
47 if entries.is_empty() {
48 return;
49 }
50
51 let toc_node = build_toc_list(&entries);
52
53 let placeholder_idx = root.children.iter().position(|n| {
55 matches!(n, Node::Component(c) if c.name == "TableOfContents" && c.children.is_empty())
56 });
57
58 if let Some(idx) = placeholder_idx {
59 root.children[idx] = toc_node;
60 } else if self.auto_insert {
61 root.children.insert(0, toc_node);
62 }
63 }
64}
65
66fn collect_headings(nodes: &[Node], min: u8, max: u8) -> Vec<TocEntry> {
67 let mut entries = Vec::new();
68 walk(nodes, &mut |node| {
69 if let Node::Heading(h) = node
70 && let Some(depth) = h.depth
71 && depth >= min
72 && depth <= max
73 {
74 entries.push(TocEntry {
75 depth,
76 text: collect_text(&h.children),
77 id: h.id.clone(),
78 });
79 }
80 });
81 entries
82}
83
84fn build_toc_list(entries: &[TocEntry]) -> Node {
85 let mut items = Vec::new();
87 let pos = Position {
90 start: Point {
91 line: usize::MAX,
92 column: usize::MAX,
93 offset: usize::MAX,
94 },
95 end: Point {
96 line: usize::MAX,
97 column: usize::MAX,
98 offset: usize::MAX,
99 },
100 };
101
102 for entry in entries {
103 let href = entry
104 .id
105 .as_ref()
106 .map(|id| format!("#{}", id))
107 .unwrap_or_default();
108
109 let link = Node::Link(LinkNode {
110 url: href,
111 title: None,
112 children: vec![Node::Text(TextNode {
113 value: entry.text.clone(),
114 position: pos.clone(),
115 })],
116 position: pos.clone(),
117 });
118
119 let list_item = Node::ListItem(StandardBlockNode {
120 depth: Some(entry.depth),
121 ordered: None,
122 checked: None,
123 id: None,
124 children: vec![link],
125 position: pos.clone(),
126 });
127
128 items.push(list_item);
129 }
130
131 Node::Component(ComponentNode {
132 name: "TableOfContents".to_string(),
133 is_inline: false,
134 attributes: vec![],
135 children: vec![Node::List(StandardBlockNode {
136 depth: None,
137 ordered: None,
138 checked: None,
139 id: None,
140 children: items,
141 position: pos.clone(),
142 })],
143 raw_content: String::new(),
144 position: pos,
145 })
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use crate::{AutoSlug, Pipeline};
152
153 #[test]
154 fn toc_from_headings() {
155 let root = Pipeline::new()
156 .add(AutoSlug::new())
157 .add(TableOfContents {
158 min_depth: 1,
159 max_depth: 3,
160 auto_insert: true,
161 })
162 .run("# Intro\n\n## Setup\n\n### Details\n\n## Usage\n");
163
164 match &root.children[0] {
166 Node::Component(c) => {
167 assert_eq!(c.name, "TableOfContents");
168 match &c.children[0] {
170 Node::List(l) => assert_eq!(l.children.len(), 4),
171 other => panic!("Expected list, got {:?}", other),
172 }
173 }
174 other => panic!("Expected TOC component, got {:?}", other),
175 }
176 }
177
178 #[test]
179 fn toc_replaces_placeholder() {
180 let root = Pipeline::new()
181 .add(AutoSlug::new())
182 .add(TableOfContents::default())
183 .run("# Title\n\n<TableOfContents />\n\n## First\n\n## Second\n");
184
185 let toc = root.children.iter().find(|n| {
187 matches!(n, Node::Component(c) if c.name == "TableOfContents" && !c.children.is_empty())
188 });
189 assert!(
190 toc.is_some(),
191 "TOC should replace placeholder: {:?}",
192 root.children
193 );
194 }
195
196 #[test]
197 fn no_toc_without_placeholder_or_auto() {
198 let root = Pipeline::new()
199 .add(TableOfContents::default()) .run("## First\n\n## Second\n");
201
202 let has_toc = root
203 .children
204 .iter()
205 .any(|n| matches!(n, Node::Component(c) if c.name == "TableOfContents"));
206 assert!(!has_toc, "Should not auto-insert TOC");
207 }
208}