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.
233pub fn extract_headers(content: &str) -> Vec<(String, String, u8)> {
234    let mut headers = Vec::new();
235
236    for caps in HEADING_RE.captures_iter(content) {
237        let level = caps[1].len() as u8;
238        let title = caps[2].trim().to_string();
239        let id = slugify(&title);
240        headers.push((id, title, level));
241    }
242
243    headers
244}
245
246/// Convert a title to a URL-friendly slug.
247///
248/// Standard HTML entities are decoded and markdown link syntax is reduced to
249/// its text first, so the slug is identical whether the input is raw markdown
250/// heading text (TOC, search index, build-time anchor checks) or the
251/// HTML-escaped, tag-stripped heading the renderer injects ids from.
252pub fn slugify(text: &str) -> String {
253    let text = text
254        .replace("&lt;", "<")
255        .replace("&gt;", ">")
256        .replace("&quot;", "\"")
257        .replace("&#39;", "'")
258        .replace("&amp;", "&");
259    let text = strip_markdown_links(&text);
260    text.to_lowercase()
261        .chars()
262        .filter_map(|c| {
263            if c.is_alphanumeric() {
264                Some(c)
265            } else if c.is_whitespace() || c == '-' || c == '_' || c == '.' {
266                Some('-')
267            } else {
268                None
269            }
270        })
271        .collect::<String>()
272        .split('-')
273        .filter(|s| !s.is_empty())
274        .collect::<Vec<_>>()
275        .join("-")
276}
277
278/// Reduce markdown links/images `[text](url)` to their text. The renderer
279/// slugs from HTML where the `<a>` tag is already stripped, so raw heading
280/// text must shed the link syntax to produce the same slug.
281fn strip_markdown_links(text: &str) -> String {
282    let mut out = String::new();
283    let mut rest = text;
284    while let Some(open) = rest.find('[') {
285        if let Some(mid) = rest[open..].find("](") {
286            let mid = open + mid;
287            if let Some(close) = rest[mid..].find(')') {
288                out.push_str(&rest[..open]);
289                out.push_str(&rest[open + 1..mid]);
290                rest = &rest[mid + close + 1..];
291                continue;
292            }
293        }
294        out.push_str(&rest[..=open]);
295        rest = &rest[open + 1..];
296    }
297    out.push_str(rest);
298    out
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn slugify_normalizes_entities_and_links() {
307        assert_eq!(slugify("Tips & Tricks"), "tips-tricks");
308        assert_eq!(slugify("Tips &amp; Tricks"), "tips-tricks");
309        assert_eq!(slugify("Q&A"), "qa");
310        assert_eq!(slugify("Q&amp;A"), "qa");
311        assert_eq!(slugify("a < b"), "a-b");
312        assert_eq!(slugify("a &lt; b"), "a-b");
313        assert_eq!(slugify("See [the docs](https://x.y/z)"), "see-the-docs");
314        assert_eq!(slugify("See the docs"), "see-the-docs");
315        assert_eq!(slugify("Use `cargo build`"), "use-cargo-build");
316    }
317
318    #[test]
319    fn test_extract_headers() {
320        let content = r#"
321## Introduction
322
323Some text.
324
325### Getting Started
326
327More text.
328
329## Configuration
330
331### Advanced Options
332"#;
333
334        let headers = extract_headers(content);
335        assert_eq!(headers.len(), 4);
336        assert_eq!(
337            headers[0],
338            ("introduction".to_string(), "Introduction".to_string(), 2)
339        );
340        assert_eq!(
341            headers[1],
342            (
343                "getting-started".to_string(),
344                "Getting Started".to_string(),
345                3
346            )
347        );
348        assert_eq!(
349            headers[2],
350            ("configuration".to_string(), "Configuration".to_string(), 2)
351        );
352        assert_eq!(
353            headers[3],
354            (
355                "advanced-options".to_string(),
356                "Advanced Options".to_string(),
357                3
358            )
359        );
360    }
361
362    #[test]
363    fn test_slugify() {
364        assert_eq!(slugify("Hello World"), "hello-world");
365        assert_eq!(slugify("Getting Started!"), "getting-started");
366        assert_eq!(slugify("API v1.0"), "api-v1-0");
367    }
368}