Skip to main content

rdx_transform/transforms/
toc.rs

1use rdx_ast::*;
2
3use crate::{Transform, collect_text, 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)
16pub 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/// A single entry in the generated table of contents.
33#[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        // Look for a <TableOfContents /> placeholder
54        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    // Build a flat list of links, using depth for nested structure
86    let mut items = Vec::new();
87    // Synthetic position for generated nodes — use usize::MAX to clearly
88    // distinguish from parser-produced positions (which are 1-based).
89    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        // First child should be the TOC component
164        match &root.children[0] {
165            Node::Component(c) => {
166                assert_eq!(c.name, "TableOfContents");
167                // Should have a list with 4 entries (h1 + h2 + h3 + h2)
168                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        // The placeholder should be replaced
185        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()) // auto_insert = false
199            .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}