Skip to main content

rdx_transform/transforms/
toc.rs

1use rdx_ast::*;
2
3use crate::{Transform, collect_text, synthetic_pos, walk};
4
5/// Generates a table of contents from document headings and inserts it
6/// as a `<TableOfContents>` component node.
7///
8/// The TOC is inserted at the position of an existing `<TableOfContents />`
9/// placeholder component, or prepended after frontmatter if no placeholder exists
10/// and `auto_insert` is true.
11///
12/// # Configuration
13///
14/// - `min_depth` / `max_depth`: heading levels to include (default: 2..=3)
15/// - `auto_insert`: whether to insert TOC when no placeholder exists (default: false)
16/// - `numbered`: if true, prefix entries with hierarchical numbers (1, 1.1, 1.1.2)
17pub 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/// A single entry in the generated table of contents.
36#[derive(Debug, Clone)]
37struct TocEntry {
38    depth: u8,
39    text: String,
40    id: Option<String>,
41    /// Hierarchical number like "1.2.3", set when `numbered` is true.
42    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        // Look for a <TableOfContents /> placeholder
63        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
94/// Assign hierarchical numbers (1, 1.1, 1.1.2, 2, 2.1, etc.) to TOC entries.
95fn 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    // Counters indexed by relative depth (0 = shallowest heading in range)
101    let mut counters = [0u32; 7]; // supports up to 6 nesting levels
102
103    for entry in entries.iter_mut() {
104        let rel = (entry.depth - base_depth) as usize;
105        counters[rel] += 1;
106        // Reset all deeper counters
107        for c in counters.iter_mut().skip(rel + 1) {
108            *c = 0;
109        }
110        // Build number string from counters[0..=rel]
111        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    // Build a flat list of links, using depth for nested structure
118    let mut items = Vec::new();
119    // Synthetic position for generated nodes (line 0 / col 0 / offset 0
120    // distinguishes them from parser-produced positions, which are 1-based).
121    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        // First child should be the TOC component
192        match &root.children[0] {
193            Node::Component(c) => {
194                assert_eq!(c.name, "TableOfContents");
195                // Should have a list with 4 entries (h1 + h2 + h3 + h2)
196                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        // The placeholder should be replaced
213        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        // First child should be the TOC
236        if let Node::Component(c) = &root.children[0]
237            && let Node::List(l) = &c.children[0]
238        {
239            // Extract link text from each list item
240            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()) // auto_insert = false
266            .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}