1use std::collections::HashMap;
7use std::fs;
8use std::path::Path;
9
10use serde_json::{Map, Value};
11
12use crate::error::{Error, Result};
13use crate::markdown::{render_markdown, Highlighter};
14use crate::state::{load_render_states, merge_page_state, LoadedStates};
15use crate::types::{DocsConfig, NavLink, PageDescriptor, SidebarItem, SidebarSection};
16
17fn normalize_link(base: &str, link: &str) -> String {
23 if link.is_empty() {
24 return String::new();
25 }
26 let stripped = link.strip_prefix('/').unwrap_or(link);
30 if link.contains('#') {
31 return format!("{base}{stripped}");
33 }
34 let cleaned = stripped.trim_end_matches('/');
35 if cleaned.is_empty() {
36 base.to_string()
38 } else {
39 format!("{base}{cleaned}")
40 }
41}
42
43fn json_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
45 let mut map = Map::with_capacity(N);
46 for (k, v) in entries {
47 map.insert(k.to_string(), v);
48 }
49 Value::Object(map)
50}
51
52#[derive(Debug)]
54struct Frontmatter {
55 title: Option<String>,
56 description: Option<String>,
57 layout: Option<String>,
58}
59
60fn parse_frontmatter(content: &str) -> Result<(Frontmatter, &str)> {
61 if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
62 return Ok((
63 Frontmatter {
64 title: None,
65 description: None,
66 layout: None,
67 },
68 content,
69 ));
70 }
71
72 let body_start_marker = if content.starts_with("---\r\n") { 5 } else { 4 };
74 let after_open = &content[body_start_marker..];
75
76 let (yaml_end_offset, close_skip) = if let Some(i) = after_open.find("\r\n---") {
78 (i, "\r\n---".len())
79 } else if let Some(i) = after_open.find("\n---") {
80 (i, "\n---".len())
81 } else {
82 (after_open.len(), 0)
83 };
84
85 let yaml_str = &after_open[..yaml_end_offset];
86 let body_offset = body_start_marker + yaml_end_offset + close_skip;
87 let body = if body_offset < content.len() {
88 let rest = &content[body_offset..];
90 rest.strip_prefix("\r\n")
91 .or_else(|| rest.strip_prefix('\n'))
92 .unwrap_or(rest)
93 } else {
94 ""
95 };
96
97 let yaml: HashMap<String, serde_yaml::Value> = serde_yaml::from_str(yaml_str)
98 .map_err(|e| crate::error::Error::Markdown(format!("Invalid frontmatter YAML: {e}")))?;
99
100 Ok((
101 Frontmatter {
102 title: yaml.get("title").and_then(|v| v.as_str()).map(String::from),
103 description: yaml
104 .get("description")
105 .and_then(|v| v.as_str())
106 .map(String::from),
107 layout: yaml
108 .get("layout")
109 .and_then(|v| v.as_str())
110 .map(String::from),
111 },
112 body,
113 ))
114}
115
116fn extract_h1(content: &str) -> Option<String> {
117 for line in content.lines() {
118 if let Some(heading) = line.strip_prefix("# ") {
119 return Some(heading.trim().to_string());
120 }
121 }
122 None
123}
124
125fn collect_sidebar_links(items: &[SidebarItem]) -> Vec<&str> {
127 let mut links = Vec::new();
128 let mut stack: Vec<&SidebarItem> = items.iter().rev().collect();
129 while let Some(item) = stack.pop() {
130 if !item.link.is_empty() {
131 links.push(item.link.as_str());
132 }
133 for child in item.items.iter().rev() {
134 stack.push(child);
135 }
136 }
137 links
138}
139
140fn normalize_path_as_index(path: &str) -> &str {
145 let Some(slash_pos) = path.rfind('/') else {
147 return path;
148 };
149
150 let parent = &path[..slash_pos];
151 let filename = &path[slash_pos + 1..];
152
153 let folder_name = parent.rsplit('/').next().unwrap_or("");
155
156 if folder_name == filename {
158 parent
159 } else {
160 path
161 }
162}
163
164fn page_url_path(rel_path: &str, base_path: &str, sources: &HashMap<&str, &str>) -> String {
177 let prefix = base_path.trim_end_matches('/');
178
179 if let Some(&link) = sources.get(rel_path) {
180 let end = link.find(['#', '?']).unwrap_or(link.len());
183 let link = &link[..end];
184 let cleaned = link.trim_matches('/');
185 return if cleaned.is_empty() {
186 format!("{prefix}/")
187 } else {
188 format!("{prefix}/{cleaned}")
189 };
190 }
191
192 let trimmed = rel_path.trim_end_matches(".md");
193 let trimmed = trimmed.strip_suffix("/index").unwrap_or(trimmed);
194 let trimmed = normalize_path_as_index(trimmed);
195
196 if trimmed.is_empty() || trimmed == "index" {
197 format!("{prefix}/")
198 } else {
199 format!("{prefix}/{trimmed}")
200 }
201}
202
203fn markdown_link_base_url(source_path: &Path, content_dir: &Path, base_path: &str) -> String {
204 let parent = source_path
205 .strip_prefix(content_dir)
206 .ok()
207 .and_then(Path::parent);
208 let parent = parent
209 .filter(|path| !path.as_os_str().is_empty())
210 .map(|path| path.to_string_lossy().replace('\\', "/"))
211 .unwrap_or_default();
212
213 let prefix = base_path.trim_end_matches('/');
214 let parent = parent.trim_matches('/');
215 let mut url = String::with_capacity(prefix.len() + parent.len() + 2);
216 url.push_str(prefix);
217 url.push('/');
218 if !parent.is_empty() {
219 url.push_str(parent);
220 url.push('/');
221 }
222 url
223}
224
225fn nav_source_overrides(nav: &[NavLink]) -> HashMap<&str, &str> {
232 nav.iter()
233 .filter_map(|item| {
234 let source = item.source.as_deref()?;
235 if item.link.starts_with("http") {
236 return None;
237 }
238 Some((source, item.link.as_str()))
239 })
240 .collect()
241}
242
243fn build_page_registry(
248 content_dir: &Path,
249 base_path: &str,
250 sources: &HashMap<&str, &str>,
251) -> Vec<(String, std::path::PathBuf)> {
252 let mut pages = Vec::new();
253 let mut stack: Vec<std::path::PathBuf> = vec![content_dir.to_path_buf()];
254
255 while let Some(dir) = stack.pop() {
256 let Ok(entries) = fs::read_dir(&dir) else {
257 continue;
258 };
259 for entry in entries.flatten() {
260 let path = entry.path();
261 let name = entry.file_name();
263 let name_str = name.to_string_lossy();
264 if name_str.starts_with('.') || name_str == "dist" || name_str == "node_modules" {
265 continue;
266 }
267 if path.is_dir() {
268 stack.push(path);
269 continue;
270 }
271 if path.extension().and_then(|s| s.to_str()) != Some("md") {
272 continue;
273 }
274
275 let rel = match path.strip_prefix(content_dir) {
276 Ok(r) => r,
277 Err(_) => continue,
278 };
279 let rel_str = rel.to_string_lossy().replace('\\', "/");
280 pages.push((page_url_path(&rel_str, base_path, sources), path));
281 }
282 }
283
284 pages.sort_by(|a, b| a.0.cmp(&b.0));
285 pages
286}
287
288fn build_sidebar_state(url_path: &str, config: &DocsConfig) -> serde_json::Value {
289 let base = &config.base_path;
290
291 let active_sidebar = config
293 .sidebar_groups
294 .iter()
295 .find(|(prefix, _)| {
296 let full_prefix = format!("{}{}", base, prefix.strip_prefix('/').unwrap_or(prefix));
297 let trimmed = full_prefix.trim_end_matches('/');
298 url_path == trimmed || url_path.starts_with(&format!("{trimmed}/"))
299 })
300 .map(|(_, s)| s.as_slice())
301 .unwrap_or(&config.sidebar);
302
303 let item_to_value = |item: &SidebarItem| -> Value {
305 let item_path = normalize_link(base, &item.link);
306 let active = !item_path.is_empty() && item_path == url_path;
307 let children: Vec<Value> = item
308 .items
309 .iter()
310 .map(|child| {
311 let child_path = normalize_link(base, &child.link);
312 let child_active = !child_path.is_empty() && child_path == url_path;
313 json_obj([
314 ("text", Value::String(child.text.clone())),
315 ("link", Value::String(child_path)),
316 ("active", Value::Bool(child_active)),
317 ("hasChildren", Value::Bool(false)),
318 ("children", Value::Array(vec![])),
319 ])
320 })
321 .collect();
322 json_obj([
323 ("text", Value::String(item.text.clone())),
324 ("link", Value::String(item_path)),
325 ("active", Value::Bool(active)),
326 ("hasChildren", Value::Bool(!children.is_empty())),
327 ("children", Value::Array(children)),
328 ])
329 };
330
331 let sections: Vec<Value> = active_sidebar
332 .iter()
333 .map(|section| {
334 let items: Vec<Value> = section.items.iter().map(item_to_value).collect();
335 json_obj([
336 ("title", Value::String(section.title.clone())),
337 ("items", Value::Array(items)),
338 ])
339 })
340 .collect();
341
342 json_obj([("sections", Value::Array(sections))])
343}
344
345fn find_sidebar_text(url_path: &str, config: &DocsConfig) -> Option<String> {
346 let base = &config.base_path;
347 let all: Vec<&[SidebarSection]> = std::iter::once(config.sidebar.as_slice())
348 .chain(config.sidebar_groups.values().map(|v| v.as_slice()))
349 .collect();
350
351 for sidebar in all {
352 for section in sidebar {
353 let mut stack: Vec<&SidebarItem> = section.items.iter().collect();
355 while let Some(item) = stack.pop() {
356 if !item.link.is_empty() {
357 let item_path = normalize_link(base, &item.link);
358 if item_path == url_path {
359 return Some(item.text.clone());
360 }
361 }
362 for child in &item.items {
363 stack.push(child);
364 }
365 }
366 }
367 }
368 None
369}
370
371#[allow(dead_code)] pub fn process_content(
382 config: &DocsConfig,
383 config_dir: &Path,
384 highlighter: &Highlighter,
385 head_injection: &str,
386) -> Result<Vec<PageDescriptor>> {
387 let states = load_render_states(config, config_dir)?;
388 process_content_with_states(config, highlighter, head_injection, &states)
389}
390
391pub(crate) fn process_content_with_states(
392 config: &DocsConfig,
393 highlighter: &Highlighter,
394 head_injection: &str,
395 states: &LoadedStates,
396) -> Result<Vec<PageDescriptor>> {
397 let content_dir = Path::new(&config.content_dir);
398 let base_path = &config.base_path;
399
400 let nav_links: Vec<Value> = config
401 .nav
402 .iter()
403 .map(|item| {
404 let (link, section) = if item.link.starts_with("http") {
405 (item.link.clone(), item.link.clone())
409 } else {
410 let full = format!("{}{}", base_path, &item.link[1..]);
411 let slug = item
413 .link
414 .trim_start_matches('/')
415 .split('/')
416 .next()
417 .unwrap_or("")
418 .to_string();
419 (full, slug)
420 };
421 json_obj([
422 ("text", Value::String(item.text.clone())),
423 ("link", Value::String(link)),
424 ("section", Value::String(section)),
425 ])
426 })
427 .collect();
428
429 let mut registry =
430 build_page_registry(content_dir, base_path, &nav_source_overrides(&config.nav));
431
432 for page_link in config.custom_pages.keys() {
434 let normalized = if page_link.ends_with('/') {
435 page_link.clone()
436 } else {
437 format!("{}/", page_link)
438 };
439 let url_path = format!("{}{}", base_path, &normalized[1..]);
440 let src_file = format!("{}index.md", &normalized[1..]);
441 let full_path = content_dir.join(&src_file);
442 if !registry.iter().any(|(p, _)| p == &url_path) {
443 registry.push((url_path, full_path));
444 }
445 }
446
447 use rayon::prelude::*;
448
449 let results: Vec<(usize, PageDescriptor)> = registry
455 .par_iter()
456 .enumerate()
457 .map(
458 |(idx, (url_path, full_path))| -> Result<(usize, PageDescriptor)> {
459 let mut is_home = false;
460 let mut html = String::new();
461 let mut title = config.site.title.clone();
462 let mut description = config.site.description.clone();
463 let mut layout = "doc".to_string();
464
465 let logical_path = format!("/{}", &url_path[base_path.len()..]);
467 let custom_entry = config
468 .custom_pages
469 .get(&logical_path)
470 .or_else(|| config.custom_pages.get(logical_path.trim_end_matches('/')));
471 let custom_state = states.custom_page_state(&logical_path);
472
473 if let Some(entry) = custom_entry {
474 html = entry.html().to_string();
475 layout = entry.layout().to_string();
476 } else if full_path.exists() {
477 let raw = fs::read_to_string(full_path)
478 .map_err(|e| crate::error::Error::Io(e.to_string()))?;
479 let (fm, body) = parse_frontmatter(&raw).map_err(|e| {
480 crate::error::Error::Markdown(format!("{}: {e}", full_path.display()))
481 })?;
482
483 is_home = fm.layout.as_deref() == Some("home");
484 layout = if is_home {
485 "home".to_string()
486 } else {
487 fm.layout.unwrap_or_else(|| "doc".to_string())
488 };
489
490 if !is_home {
491 let page_url = if url_path.ends_with('/') {
496 url_path.to_string()
497 } else {
498 format!("{url_path}/")
499 };
500 let link_base_url =
501 markdown_link_base_url(full_path, content_dir, base_path);
502 html = render_markdown(
503 body,
504 highlighter,
505 base_path,
506 &page_url,
507 &link_base_url,
508 )?;
509 }
510
511 if let Some(d) = fm.description {
512 description = d;
513 }
514
515 let h1 = extract_h1(body);
516 let sidebar_text = find_sidebar_text(url_path, config);
517 title = fm
518 .title
519 .or(h1)
520 .or(sidebar_text)
521 .unwrap_or_else(|| config.site.title.clone());
522 }
523
524 let hero_val = if is_home {
525 config
526 .hero
527 .as_ref()
528 .map(|h| {
529 json_obj([
530 (
531 "text",
532 h.text
533 .as_ref()
534 .map_or(Value::Null, |t| Value::String(t.clone())),
535 ),
536 ("tagline", Value::String(h.tagline.clone())),
537 (
538 "manifesto",
539 h.manifesto
540 .as_ref()
541 .map_or(Value::Null, |m| Value::String(m.clone())),
542 ),
543 (
544 "actions",
545 Value::Array(
546 h.actions
547 .iter()
548 .map(|a| {
549 json_obj([
550 ("text", Value::String(a.text.clone())),
551 ("link", Value::String(a.link.clone())),
552 ("brand", Value::Bool(a.brand)),
553 ])
554 })
555 .collect(),
556 ),
557 ),
558 (
559 "features",
560 Value::Array(
561 h.features
562 .iter()
563 .map(|f| {
564 json_obj([
565 ("icon", Value::String(f.icon.clone())),
566 ("title", Value::String(f.title.clone())),
567 (
568 "description",
569 Value::String(f.description.clone()),
570 ),
571 ])
572 })
573 .collect(),
574 ),
575 ),
576 ])
577 })
578 .unwrap_or(Value::Null)
579 } else {
580 Value::Null
581 };
582
583 let footer_val = config
584 .footer
585 .as_ref()
586 .map(|f| json_obj([("html", Value::String(f.html.clone()))]))
587 .unwrap_or(Value::Null);
588
589 let page_section = logical_path
593 .trim_start_matches('/')
594 .split('/')
595 .next()
596 .unwrap_or("")
597 .to_string();
598
599 let state = json_obj([
600 (
601 "site",
602 json_obj([
603 ("title", Value::String(config.site.title.clone())),
604 ("base", Value::String(base_path.to_string())),
605 ]),
606 ),
607 ("navigation", Value::Array(nav_links.clone())),
608 ("sidebar", build_sidebar_state(url_path, config)),
609 (
610 "page",
611 json_obj([
612 ("title", Value::String(title.clone())),
613 ("description", Value::String(description.clone())),
614 ("content", Value::String(html.clone())),
615 ("isHome", Value::Bool(is_home)),
616 ("layout", Value::String(layout)),
617 ("section", Value::String(page_section)),
618 ]),
619 ),
620 ("hero", hero_val),
621 ("footer", footer_val),
622 ("prev", Value::Null),
623 ("next", Value::Null),
624 ("pageData", custom_state.cloned().unwrap_or(Value::Null)),
625 ("headTags", Value::String(head_injection.to_string())),
626 ("label", Value::String("Copy".to_string())),
627 ("icon", Value::String("🌙".to_string())),
628 ]);
629
630 let state = merge_page_state(state, states.global(), custom_state);
635
636 Ok((
637 idx,
638 PageDescriptor {
639 path: url_path.clone(),
640 is_home,
641 state,
642 },
643 ))
644 },
645 )
646 .collect::<Result<Vec<_>>>()?;
647
648 let resolve_sidebar = |page_path: &str| -> Vec<SidebarItem> {
652 let active = config
653 .sidebar_groups
654 .iter()
655 .find(|(prefix, _)| {
656 let full_prefix = format!(
657 "{}{}",
658 base_path,
659 prefix.strip_prefix('/').unwrap_or(prefix)
660 );
661 let trimmed = full_prefix.trim_end_matches('/');
662 page_path == trimmed || page_path.starts_with(&format!("{trimmed}/"))
663 })
664 .map(|(_, s)| s.as_slice())
665 .unwrap_or(&config.sidebar);
666 active.iter().flat_map(|s| s.items.clone()).collect()
667 };
668
669 let mut pages: Vec<Option<PageDescriptor>> = (0..results.len()).map(|_| None).collect();
674 for (idx, mut desc) in results {
675 let sidebar_items = resolve_sidebar(&desc.path);
676 let flat_links = collect_sidebar_links(&sidebar_items);
677 let all_links: Vec<(String, String)> = flat_links
678 .iter()
679 .map(|link| {
680 let path = normalize_link(base_path, link);
681 let text = find_sidebar_text(&path, config).unwrap_or_default();
682 (path, text)
683 })
684 .collect();
685 if let Some(pos) = all_links.iter().position(|(p, _)| p == &desc.path) {
686 if pos > 0 {
687 let (link, text) = &all_links[pos - 1];
688 desc.state["prev"] = json_obj([
689 ("link", Value::String(link.clone())),
690 ("text", Value::String(text.clone())),
691 ]);
692 }
693 if pos + 1 < all_links.len() {
694 let (link, text) = &all_links[pos + 1];
695 desc.state["next"] = json_obj([
696 ("link", Value::String(link.clone())),
697 ("text", Value::String(text.clone())),
698 ]);
699 }
700 }
701 pages[idx] = Some(desc);
702 }
703 let pages: Vec<PageDescriptor> = pages
704 .into_iter()
705 .enumerate()
706 .map(|(i, slot)| {
707 slot.ok_or_else(|| Error::Build(format!("missing page at registry index {i}")))
708 })
709 .collect::<Result<Vec<_>>>()?;
710
711 Ok(pages)
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717
718 #[test]
721 fn normalize_link_empty_returns_empty() {
722 assert_eq!(normalize_link("/webui/", ""), "");
723 }
724
725 #[test]
726 fn normalize_link_root_slash_returns_base() {
727 assert_eq!(normalize_link("/webui/", "/"), "/webui/");
728 }
729
730 #[test]
731 fn normalize_link_trims_trailing_slash() {
732 assert_eq!(
733 normalize_link("/webui/", "/guide/intro/"),
734 "/webui/guide/intro"
735 );
736 assert_eq!(
737 normalize_link("/webui/", "/guide/intro"),
738 "/webui/guide/intro"
739 );
740 }
741
742 #[test]
743 fn normalize_link_preserves_fragment() {
744 assert_eq!(
745 normalize_link("/webui/", "/guide/intro#section"),
746 "/webui/guide/intro#section"
747 );
748 }
749
750 #[test]
751 fn normalize_link_tolerates_missing_leading_slash() {
752 assert_eq!(
754 normalize_link("/webui/", "guide/intro"),
755 "/webui/guide/intro"
756 );
757 }
758
759 #[test]
760 fn normalize_link_tolerates_non_ascii_first_char() {
761 let out = normalize_link("/webui/", "é-page");
763 assert!(out.ends_with("é-page"), "got {out}");
764 }
765
766 #[test]
769 fn normalize_path_as_index_strips_duplicate_folder_name() {
770 assert_eq!(
771 normalize_path_as_index("webui-button/webui-button"),
772 "webui-button"
773 );
774 }
775
776 #[test]
777 fn normalize_path_as_index_keeps_different_filename() {
778 assert_eq!(
779 normalize_path_as_index("webui-button/usage"),
780 "webui-button/usage"
781 );
782 }
783
784 #[test]
785 fn normalize_path_as_index_nested_folders() {
786 assert_eq!(
787 normalize_path_as_index("components/webui-button/webui-button"),
788 "components/webui-button"
789 );
790 }
791
792 #[test]
793 fn normalize_path_as_index_no_slash_returns_unchanged() {
794 assert_eq!(normalize_path_as_index("webui-button"), "webui-button");
795 }
796
797 fn sources(pairs: &[(&'static str, &'static str)]) -> HashMap<&'static str, &'static str> {
800 pairs.iter().copied().collect()
801 }
802
803 #[test]
804 fn page_url_path_derives_url_from_filesystem() {
805 let none = sources(&[]);
806 assert_eq!(
807 page_url_path("guide/install.md", "/webui/", &none),
808 "/webui/guide/install"
809 );
810 }
811
812 #[test]
813 fn page_url_path_collapses_index_and_root() {
814 let none = sources(&[]);
815 assert_eq!(
816 page_url_path("guide/index.md", "/webui/", &none),
817 "/webui/guide"
818 );
819 assert_eq!(page_url_path("index.md", "/webui/", &none), "/webui/");
820 assert_eq!(page_url_path("index.md", "", &none), "/");
821 }
822
823 #[test]
824 fn page_url_path_collapses_filename_repeating_its_folder() {
825 let none = sources(&[]);
826 assert_eq!(
827 page_url_path("components/webui-button/webui-button.md", "/webui/", &none),
828 "/webui/components/webui-button"
829 );
830 }
831
832 #[test]
833 fn page_url_path_nav_source_overrides_derived_url() {
834 let map = sources(&[("ai/SKILL.md", "/ai")]);
837 assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
838 }
839
840 #[test]
841 fn page_url_path_nav_source_tolerates_surrounding_slashes() {
842 let map = sources(&[("ai/SKILL.md", "ai/"), ("b/SKILL.md", "/b")]);
843 assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
844 assert_eq!(page_url_path("b/SKILL.md", "", &map), "/b");
845 }
846
847 #[test]
848 fn page_url_path_nav_source_can_target_site_root() {
849 let map = sources(&[("home/landing.md", "/")]);
850 assert_eq!(page_url_path("home/landing.md", "/webui/", &map), "/webui/");
851 }
852
853 #[test]
854 fn page_url_path_ignores_nav_source_for_other_files() {
855 let map = sources(&[("ai/SKILL.md", "/ai")]);
856 assert_eq!(
857 page_url_path("guide/SKILL.md", "/webui/", &map),
858 "/webui/guide/SKILL"
859 );
860 }
861
862 #[test]
863 fn page_url_path_nav_source_strips_fragment_from_override() {
864 let map = sources(&[("ai/SKILL.md", "/ai#rules")]);
866 assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
867 }
868
869 #[test]
870 fn page_url_path_nav_source_strips_query_from_override() {
871 let map = sources(&[("ai/SKILL.md", "/ai?v=2")]);
873 assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
874 }
875
876 #[test]
877 fn markdown_link_base_uses_source_directory_for_index_and_leaf_pages() {
878 let content_dir = Path::new("docs");
879 assert_eq!(
880 markdown_link_base_url(Path::new("docs/guide/index.md"), content_dir, "/webui/"),
881 "/webui/guide/"
882 );
883 assert_eq!(
884 markdown_link_base_url(
885 Path::new("docs/guide/installation.md"),
886 content_dir,
887 "/webui/"
888 ),
889 "/webui/guide/"
890 );
891 assert_eq!(
892 markdown_link_base_url(
893 Path::new("docs/guide/topic/index.md"),
894 content_dir,
895 "/webui/"
896 ),
897 "/webui/guide/topic/"
898 );
899 }
900
901 fn nav_link(text: &str, link: &str, source: Option<&str>) -> NavLink {
904 NavLink {
905 text: text.to_string(),
906 link: link.to_string(),
907 source: source.map(String::from),
908 }
909 }
910
911 #[test]
912 fn nav_source_overrides_collects_only_entries_declaring_a_source() {
913 let nav = vec![
914 nav_link("Guide", "/guide/", None),
915 nav_link("AI", "/ai", Some("ai/SKILL.md")),
916 nav_link("GitHub", "https://github.com/microsoft/webui", None),
917 ];
918 let map = nav_source_overrides(&nav);
919 assert_eq!(map.len(), 1);
920 assert_eq!(map.get("ai/SKILL.md"), Some(&"/ai"));
921 }
922
923 #[test]
924 fn nav_source_overrides_empty_nav_yields_no_overrides() {
925 assert!(nav_source_overrides(&[]).is_empty());
926 }
927
928 #[test]
929 fn nav_source_overrides_excludes_external_link_with_source() {
930 let nav = vec![
933 nav_link("AI", "/ai", Some("ai/SKILL.md")),
934 nav_link(
935 "GitHub",
936 "https://github.com/microsoft/webui",
937 Some("github.md"),
938 ),
939 ];
940 let map = nav_source_overrides(&nav);
941 assert_eq!(map.len(), 1);
942 assert_eq!(map.get("ai/SKILL.md"), Some(&"/ai"));
943 assert!(!map.contains_key("github.md"));
944 }
945
946 #[test]
947 fn normalize_path_as_index_deep_nesting_with_match() {
948 assert_eq!(
949 normalize_path_as_index("a/b/c/webui-card/webui-card"),
950 "a/b/c/webui-card"
951 );
952 }
953
954 #[test]
955 fn normalize_path_as_index_deep_nesting_without_match() {
956 assert_eq!(
957 normalize_path_as_index("a/b/c/webui-card/examples"),
958 "a/b/c/webui-card/examples"
959 );
960 }
961
962 #[test]
965 fn parse_frontmatter_no_frontmatter_returns_full_body() {
966 let (fm, body) = parse_frontmatter("# Hello\nworld\n").expect("ok");
967 assert!(fm.title.is_none());
968 assert!(fm.layout.is_none());
969 assert_eq!(body, "# Hello\nworld\n");
970 }
971
972 #[test]
973 fn parse_frontmatter_valid_yaml_extracts_fields() {
974 let raw = "---\ntitle: Hello\ndescription: World\nlayout: home\n---\n# Body\n";
975 let (fm, body) = parse_frontmatter(raw).expect("ok");
976 assert_eq!(fm.title.as_deref(), Some("Hello"));
977 assert_eq!(fm.description.as_deref(), Some("World"));
978 assert_eq!(fm.layout.as_deref(), Some("home"));
979 assert_eq!(body, "# Body\n");
980 }
981
982 #[test]
983 fn parse_frontmatter_handles_crlf_line_endings() {
984 let raw = "---\r\ntitle: CRLF\r\nlayout: doc\r\n---\r\n# Body\r\n";
985 let (fm, body) = parse_frontmatter(raw).expect("ok");
986 assert_eq!(fm.title.as_deref(), Some("CRLF"));
987 assert_eq!(fm.layout.as_deref(), Some("doc"));
988 assert_eq!(body, "# Body\r\n");
989 }
990
991 #[test]
992 fn parse_frontmatter_malformed_yaml_returns_error() {
993 let raw = "---\ntitle: : : :\n---\nBody\n";
995 let result = parse_frontmatter(raw);
996 assert!(result.is_err(), "expected error, got {result:?}");
997 }
998}