1use 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)> {
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(_) => {} }
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
268pub fn slugify(text: &str) -> String {
275 let text = text
276 .replace("<", "<")
277 .replace(">", ">")
278 .replace(""", "\"")
279 .replace("'", "'")
280 .replace("&", "&");
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
300fn 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 & Tricks"), "tips-tricks");
331 assert_eq!(slugify("Q&A"), "qa");
332 assert_eq!(slugify("Q&A"), "qa");
333 assert_eq!(slugify("a < b"), "a-b");
334 assert_eq!(slugify("a < 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 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}