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        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        // First child should be the TOC component
165        match &root.children[0] {
166            Node::Component(c) => {
167                assert_eq!(c.name, "TableOfContents");
168                // Should have a list with 4 entries (h1 + h2 + h3 + h2)
169                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        // The placeholder should be replaced
186        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()) // auto_insert = false
200            .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}