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 position: pos,
144 })
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use crate::{AutoSlug, Pipeline};
151
152 #[test]
153 fn toc_from_headings() {
154 let root = Pipeline::new()
155 .add(AutoSlug::new())
156 .add(TableOfContents {
157 min_depth: 1,
158 max_depth: 3,
159 auto_insert: true,
160 })
161 .run("# Intro\n\n## Setup\n\n### Details\n\n## Usage\n");
162
163 match &root.children[0] {
165 Node::Component(c) => {
166 assert_eq!(c.name, "TableOfContents");
167 match &c.children[0] {
169 Node::List(l) => assert_eq!(l.children.len(), 4),
170 other => panic!("Expected list, got {:?}", other),
171 }
172 }
173 other => panic!("Expected TOC component, got {:?}", other),
174 }
175 }
176
177 #[test]
178 fn toc_replaces_placeholder() {
179 let root = Pipeline::new()
180 .add(AutoSlug::new())
181 .add(TableOfContents::default())
182 .run("# Title\n\n<TableOfContents />\n\n## First\n\n## Second\n");
183
184 let toc = root.children.iter().find(|n| {
186 matches!(n, Node::Component(c) if c.name == "TableOfContents" && !c.children.is_empty())
187 });
188 assert!(
189 toc.is_some(),
190 "TOC should replace placeholder: {:?}",
191 root.children
192 );
193 }
194
195 #[test]
196 fn no_toc_without_placeholder_or_auto() {
197 let root = Pipeline::new()
198 .add(TableOfContents::default()) .run("## First\n\n## Second\n");
200
201 let has_toc = root
202 .children
203 .iter()
204 .any(|n| matches!(n, Node::Component(c) if c.name == "TableOfContents"));
205 assert!(!has_toc, "Should not auto-insert TOC");
206 }
207}