Skip to main content

dioxus_mdx/components/
toc.rs

1//! Table of contents component for documentation pages.
2//!
3//! Features:
4//! - Displays page headers in a sidebar navigation
5//! - Tracks scroll position and highlights the current section
6//! - Uses IntersectionObserver for performant scroll tracking
7
8use std::sync::LazyLock;
9
10use dioxus::prelude::*;
11use dioxus_free_icons::{Icon, icons::ld_icons::LdList};
12
13static HEADING_RE: LazyLock<regex::Regex> =
14    LazyLock::new(|| regex::Regex::new(r"(?m)^(#{2,4})\s+(.+)$").unwrap());
15
16/// Props for DocTableOfContents component.
17#[derive(Props, Clone, PartialEq)]
18pub struct DocTableOfContentsProps {
19    /// List of headers: (id, title, level).
20    pub headers: Vec<(String, String, u8)>,
21}
22
23/// Table of contents sidebar component with scroll tracking.
24///
25/// Scroll tracking is handled client-side via JavaScript for performance.
26/// The component uses data attributes and CSS for active state styling.
27#[component]
28pub fn DocTableOfContents(props: DocTableOfContentsProps) -> Element {
29    // Extract header IDs for the observer
30    #[allow(unused_variables)]
31    let header_ids: Vec<String> = props.headers.iter().map(|(id, _, _)| id.clone()).collect();
32
33    // Set up IntersectionObserver to track visible sections (client-side only)
34    #[cfg(target_arch = "wasm32")]
35    {
36        let header_ids_for_effect = header_ids.clone();
37        use_effect(use_reactive!(|header_ids_for_effect| {
38            let ids = header_ids_for_effect.clone();
39            if ids.is_empty() {
40                return;
41            }
42
43            // Set up IntersectionObserver and scroll listener via JavaScript
44            // Uses data-toc-link attributes to find and update TOC links
45            let js = format!(
46                r#"
47                (function() {{
48                    // Remove the previous page's scroll listener before adding a
49                    // new one, so navigation doesn't accumulate handlers.
50                    if (window.tocCleanup) {{ window.tocCleanup(); }}
51
52                    const ids = {};
53
54                    // Update active TOC item
55                    function setActiveTocItem(activeId) {{
56                        // Remove active class from all TOC links
57                        document.querySelectorAll('[data-toc-link]').forEach(link => {{
58                            link.classList.remove('toc-active');
59                            link.classList.add('toc-inactive');
60                        }});
61
62                        // Add active class to the current link
63                        if (activeId) {{
64                            const activeLink = document.querySelector(`[data-toc-link="${{activeId}}"]`);
65                            if (activeLink) {{
66                                activeLink.classList.remove('toc-inactive');
67                                activeLink.classList.add('toc-active');
68                            }}
69                        }}
70                    }}
71
72                    // Find the currently active heading based on scroll position
73                    function updateActiveHeading() {{
74                        let activeId = null;
75                        const scrollPos = window.scrollY + 100; // Offset for fixed header
76
77                        for (const id of ids) {{
78                            const el = document.getElementById(id);
79                            if (el) {{
80                                const rect = el.getBoundingClientRect();
81                                const absoluteTop = rect.top + window.scrollY;
82                                if (absoluteTop <= scrollPos) {{
83                                    activeId = id;
84                                }}
85                            }}
86                        }}
87
88                        setActiveTocItem(activeId);
89                    }}
90
91                    // Debounce scroll handler
92                    let scrollTimeout;
93                    function handleScroll() {{
94                        clearTimeout(scrollTimeout);
95                        scrollTimeout = setTimeout(updateActiveHeading, 10);
96                    }}
97
98                    // Set up scroll listener
99                    window.addEventListener('scroll', handleScroll, {{ passive: true }});
100
101                    // Initial update
102                    setTimeout(updateActiveHeading, 100);
103
104                    // Store cleanup function
105                    window.tocCleanup = () => {{
106                        window.removeEventListener('scroll', handleScroll);
107                        clearTimeout(scrollTimeout);
108                        window.tocCleanup = null;
109                    }};
110                }})();
111                "#,
112                serde_json::to_string(&ids).unwrap_or_default()
113            );
114
115            // Run the JavaScript
116            spawn(async move {
117                let _ = document::eval(&js);
118            });
119        }));
120
121        // Remove the scroll listener when the TOC unmounts (leaving docs pages).
122        use_drop(|| {
123            let _ = document::eval("if (window.tocCleanup) { window.tocCleanup(); }");
124        });
125    }
126
127    if props.headers.is_empty() {
128        return rsx! {};
129    }
130
131    rsx! {
132        nav { class: "text-sm",
133            h4 { class: "font-semibold text-base-content mb-4 text-xs uppercase tracking-wider flex items-center gap-1.5",
134                Icon { class: "size-3.5", icon: LdList }
135                "On this page"
136            }
137            ul { class: "space-y-2.5",
138                for (i, (id, title, level)) in props.headers.iter().enumerate() {
139                    TocItem {
140                        key: "{i}",
141                        id: id.clone(),
142                        title: title.clone(),
143                        level: *level,
144                    }
145                }
146            }
147        }
148        // CSS for active/inactive states (injected once)
149        style {
150            r#"
151            .toc-active {{
152                color: oklch(var(--p)) !important;
153                font-weight: 500;
154            }}
155            .toc-active::before {{
156                content: '';
157                position: absolute;
158                left: -14px;
159                top: 50%;
160                transform: translateY(-50%);
161                width: 3px;
162                height: 18px;
163                background: oklch(var(--p));
164                border-radius: 9999px;
165                transition: all 0.15s ease-out;
166            }}
167            .toc-inactive {{
168                color: oklch(var(--bc) / 0.55);
169                transition: color 0.15s ease-out;
170            }}
171            .toc-inactive:hover {{
172                color: oklch(var(--bc) / 0.9);
173            }}
174            "#
175        }
176    }
177}
178
179/// Props for TocItem.
180#[derive(Props, Clone, PartialEq)]
181struct TocItemProps {
182    id: String,
183    title: String,
184    level: u8,
185}
186
187/// Individual TOC item.
188#[component]
189fn TocItem(props: TocItemProps) -> Element {
190    let (indent_class, text_class) = match props.level {
191        2 => ("", ""),
192        3 => ("ml-4", "text-[13px]"),
193        _ => ("ml-6", "text-xs"),
194    };
195
196    rsx! {
197        li {
198            class: "{indent_class} relative",
199            a {
200                href: "#{props.id}",
201                class: "toc-inactive block py-0.5 {text_class}",
202                "data-toc-link": "{props.id}",
203                onclick: move |evt| {
204                    evt.prevent_default();
205                    // Smooth scroll to the heading (client-side only)
206                    #[cfg(target_arch = "wasm32")]
207                    {
208                        let id = props.id.clone();
209                        spawn(async move {
210                            let js = format!(
211                                r#"
212                                const el = document.getElementById({});
213                                if (el) {{
214                                    el.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
215                                    // Update URL hash without jumping
216                                    history.pushState(null, '', '#' + {});
217                                }}
218                                "#,
219                                serde_json::to_string(&id).unwrap_or_default(),
220                                serde_json::to_string(&id).unwrap_or_default()
221                            );
222                            let _ = document::eval(&js);
223                        });
224                    }
225                },
226                "{props.title}"
227            }
228        }
229    }
230}
231
232/// Extract headers from markdown content for table of contents.
233///
234/// Fenced code blocks are skipped so a `## Setup` inside a sample does not
235/// become a TOC entry linking to an anchor the renderer never emits. Mirrors
236/// the fence handling in the docs-kit search splitter and build-time anchor
237/// validator.
238pub fn extract_headers(content: &str) -> Vec<(String, String, u8)> {
239    let mut headers = Vec::new();
240    let mut fence: Option<char> = None;
241
242    for line in content.lines() {
243        let trimmed = line.trim_start();
244        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
245            let marker = if trimmed.starts_with("```") { '`' } else { '~' };
246            match fence {
247                None => fence = Some(marker),
248                Some(open) if open == marker => fence = None,
249                Some(_) => {} // the other marker inside a fence is literal content
250            }
251            continue;
252        }
253        if fence.is_some() {
254            continue;
255        }
256
257        if let Some(caps) = HEADING_RE.captures(line) {
258            let level = caps[1].len() as u8;
259            let title = caps[2].trim().to_string();
260            let id = slugify(&title);
261            headers.push((id, title, level));
262        }
263    }
264
265    headers
266}
267
268/// Convert a title to a URL-friendly slug.
269///
270/// Standard HTML entities are decoded and markdown link syntax is reduced to
271/// its text first, so the slug is identical whether the input is raw markdown
272/// heading text (TOC, search index, build-time anchor checks) or the
273/// HTML-escaped, tag-stripped heading the renderer injects ids from.
274pub fn slugify(text: &str) -> String {
275    let text = text
276        .replace("&lt;", "<")
277        .replace("&gt;", ">")
278        .replace("&quot;", "\"")
279        .replace("&#39;", "'")
280        .replace("&amp;", "&");
281    let text = strip_markdown_links(&text);
282    text.to_lowercase()
283        .chars()
284        .filter_map(|c| {
285            if c.is_alphanumeric() {
286                Some(c)
287            } else if c.is_whitespace() || c == '-' || c == '_' || c == '.' {
288                Some('-')
289            } else {
290                None
291            }
292        })
293        .collect::<String>()
294        .split('-')
295        .filter(|s| !s.is_empty())
296        .collect::<Vec<_>>()
297        .join("-")
298}
299
300/// Reduce markdown links/images `[text](url)` to their text. The renderer
301/// slugs from HTML where the `<a>` tag is already stripped, so raw heading
302/// text must shed the link syntax to produce the same slug.
303fn strip_markdown_links(text: &str) -> String {
304    let mut out = String::new();
305    let mut rest = text;
306    while let Some(open) = rest.find('[') {
307        if let Some(mid) = rest[open..].find("](") {
308            let mid = open + mid;
309            if let Some(close) = rest[mid..].find(')') {
310                out.push_str(&rest[..open]);
311                out.push_str(&rest[open + 1..mid]);
312                rest = &rest[mid + close + 1..];
313                continue;
314            }
315        }
316        out.push_str(&rest[..=open]);
317        rest = &rest[open + 1..];
318    }
319    out.push_str(rest);
320    out
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn slugify_normalizes_entities_and_links() {
329        assert_eq!(slugify("Tips & Tricks"), "tips-tricks");
330        assert_eq!(slugify("Tips &amp; Tricks"), "tips-tricks");
331        assert_eq!(slugify("Q&A"), "qa");
332        assert_eq!(slugify("Q&amp;A"), "qa");
333        assert_eq!(slugify("a < b"), "a-b");
334        assert_eq!(slugify("a &lt; b"), "a-b");
335        assert_eq!(slugify("See [the docs](https://x.y/z)"), "see-the-docs");
336        assert_eq!(slugify("See the docs"), "see-the-docs");
337        assert_eq!(slugify("Use `cargo build`"), "use-cargo-build");
338    }
339
340    #[test]
341    fn test_extract_headers() {
342        let content = r#"
343## Introduction
344
345Some text.
346
347### Getting Started
348
349More text.
350
351## Configuration
352
353### Advanced Options
354"#;
355
356        let headers = extract_headers(content);
357        assert_eq!(headers.len(), 4);
358        assert_eq!(
359            headers[0],
360            ("introduction".to_string(), "Introduction".to_string(), 2)
361        );
362        assert_eq!(
363            headers[1],
364            (
365                "getting-started".to_string(),
366                "Getting Started".to_string(),
367                3
368            )
369        );
370        assert_eq!(
371            headers[2],
372            ("configuration".to_string(), "Configuration".to_string(), 2)
373        );
374        assert_eq!(
375            headers[3],
376            (
377                "advanced-options".to_string(),
378                "Advanced Options".to_string(),
379                3
380            )
381        );
382    }
383
384    #[test]
385    fn test_slugify() {
386        assert_eq!(slugify("Hello World"), "hello-world");
387        assert_eq!(slugify("Getting Started!"), "getting-started");
388        assert_eq!(slugify("API v1.0"), "api-v1-0");
389    }
390
391    #[test]
392    fn extract_headers_skips_headings_inside_code_fences() {
393        let content = "## Real One\n\n```md\n## Fake Heading\n```\n\n### Real Two\n";
394        let headers = extract_headers(content);
395        let titles: Vec<&str> = headers.iter().map(|(_, t, _)| t.as_str()).collect();
396        assert_eq!(titles, vec!["Real One", "Real Two"]);
397    }
398
399    #[test]
400    fn extract_headers_skips_headings_inside_tilde_fences() {
401        let content = "## Real\n\n~~~\n## Fake\n~~~\n";
402        let headers = extract_headers(content);
403        assert_eq!(headers.len(), 1);
404        assert_eq!(headers[0].1, "Real");
405    }
406
407    #[test]
408    fn extract_headers_treats_other_marker_inside_fence_as_content() {
409        // A ``` line inside a ~~~ fence is literal text, not a fence toggle.
410        let content = "~~~\n```\n## Fake\n~~~\n\n## Real\n";
411        let headers = extract_headers(content);
412        assert_eq!(headers.len(), 1);
413        assert_eq!(headers[0].1, "Real");
414    }
415}