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