dioxus_mdx/components/
toc.rs1use 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#[derive(Props, Clone, PartialEq)]
18pub struct DocTableOfContentsProps {
19 pub headers: Vec<(String, String, u8)>,
21}
22
23#[component]
28pub fn DocTableOfContents(props: DocTableOfContentsProps) -> Element {
29 #[allow(unused_variables)]
31 let header_ids: Vec<String> = props.headers.iter().map(|(id, _, _)| id.clone()).collect();
32
33 #[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 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 spawn(async move {
117 let _ = document::eval(&js);
118 });
119 }));
120
121 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 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#[derive(Props, Clone, PartialEq)]
181struct TocItemProps {
182 id: String,
183 title: String,
184 level: u8,
185}
186
187#[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 #[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
232pub 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
246pub fn slugify(text: &str) -> String {
253 let text = text
254 .replace("<", "<")
255 .replace(">", ">")
256 .replace(""", "\"")
257 .replace("'", "'")
258 .replace("&", "&");
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
278fn 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 & Tricks"), "tips-tricks");
309 assert_eq!(slugify("Q&A"), "qa");
310 assert_eq!(slugify("Q&A"), "qa");
311 assert_eq!(slugify("a < b"), "a-b");
312 assert_eq!(slugify("a < 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}