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, parse_markdown_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 state: Option<Value>,
63}
64
65fn parse_frontmatter(content: &str) -> Result<(Frontmatter, &str)> {
66 if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
67 return Ok((
68 Frontmatter {
69 title: None,
70 description: None,
71 layout: None,
72 state: None,
73 },
74 content,
75 ));
76 }
77
78 let body_start_marker = if content.starts_with("---\r\n") { 5 } else { 4 };
80 let after_open = &content[body_start_marker..];
81
82 let (yaml_end_offset, close_skip) = if let Some(i) = after_open.find("\r\n---") {
84 (i, "\r\n---".len())
85 } else if let Some(i) = after_open.find("\n---") {
86 (i, "\n---".len())
87 } else {
88 (after_open.len(), 0)
89 };
90
91 let yaml_str = &after_open[..yaml_end_offset];
92 let body_offset = body_start_marker + yaml_end_offset + close_skip;
93 let body = if body_offset < content.len() {
94 let rest = &content[body_offset..];
96 rest.strip_prefix("\r\n")
97 .or_else(|| rest.strip_prefix('\n'))
98 .unwrap_or(rest)
99 } else {
100 ""
101 };
102
103 let yaml: serde_yaml::Mapping = serde_yaml::from_str(yaml_str).map_err(|e| {
104 Error::Markdown(format!(
105 "Invalid frontmatter YAML: {e}\nhelp: Use valid YAML with unique object keys between the --- delimiters."
106 ))
107 })?;
108
109 Ok((
110 Frontmatter {
111 title: yaml.get("title").and_then(|v| v.as_str()).map(String::from),
112 description: yaml
113 .get("description")
114 .and_then(|v| v.as_str())
115 .map(String::from),
116 layout: yaml
117 .get("layout")
118 .and_then(|v| v.as_str())
119 .map(String::from),
120 state: yaml.get("state").map(parse_markdown_state).transpose()?,
121 },
122 body,
123 ))
124}
125
126fn extract_h1(content: &str) -> Option<String> {
127 for line in content.lines() {
128 if let Some(heading) = line.strip_prefix("# ") {
129 return Some(heading.trim().to_string());
130 }
131 }
132 None
133}
134
135fn collect_sidebar_links(items: &[SidebarItem]) -> Vec<&str> {
137 let mut links = Vec::new();
138 let mut stack: Vec<&SidebarItem> = items.iter().rev().collect();
139 while let Some(item) = stack.pop() {
140 if !item.link.is_empty() {
141 links.push(item.link.as_str());
142 }
143 for child in item.items.iter().rev() {
144 stack.push(child);
145 }
146 }
147 links
148}
149
150fn normalize_path_as_index(path: &str) -> &str {
155 let Some(slash_pos) = path.rfind('/') else {
157 return path;
158 };
159
160 let parent = &path[..slash_pos];
161 let filename = &path[slash_pos + 1..];
162
163 let folder_name = parent.rsplit('/').next().unwrap_or("");
165
166 if folder_name == filename {
168 parent
169 } else {
170 path
171 }
172}
173
174fn page_url_path(rel_path: &str, base_path: &str, sources: &HashMap<&str, &str>) -> String {
187 let prefix = base_path.trim_end_matches('/');
188
189 if let Some(&link) = sources.get(rel_path) {
190 let end = link.find(['#', '?']).unwrap_or(link.len());
193 let link = &link[..end];
194 let cleaned = link.trim_matches('/');
195 return if cleaned.is_empty() {
196 format!("{prefix}/")
197 } else {
198 format!("{prefix}/{cleaned}")
199 };
200 }
201
202 let trimmed = rel_path.trim_end_matches(".md");
203 let trimmed = trimmed.strip_suffix("/index").unwrap_or(trimmed);
204 let trimmed = normalize_path_as_index(trimmed);
205
206 if trimmed.is_empty() || trimmed == "index" {
207 format!("{prefix}/")
208 } else {
209 format!("{prefix}/{trimmed}")
210 }
211}
212
213fn markdown_link_base_url(source_path: &Path, content_dir: &Path, base_path: &str) -> String {
214 let parent = source_path
215 .strip_prefix(content_dir)
216 .ok()
217 .and_then(Path::parent);
218 let parent = parent
219 .filter(|path| !path.as_os_str().is_empty())
220 .map(|path| path.to_string_lossy().replace('\\', "/"))
221 .unwrap_or_default();
222
223 let prefix = base_path.trim_end_matches('/');
224 let parent = parent.trim_matches('/');
225 let mut url = String::with_capacity(prefix.len() + parent.len() + 2);
226 url.push_str(prefix);
227 url.push('/');
228 if !parent.is_empty() {
229 url.push_str(parent);
230 url.push('/');
231 }
232 url
233}
234
235fn nav_source_overrides(nav: &[NavLink]) -> HashMap<&str, &str> {
242 nav.iter()
243 .filter_map(|item| {
244 let source = item.source.as_deref()?;
245 if item.link.starts_with("http") {
246 return None;
247 }
248 Some((source, item.link.as_str()))
249 })
250 .collect()
251}
252
253fn build_page_registry(
258 content_dir: &Path,
259 base_path: &str,
260 sources: &HashMap<&str, &str>,
261) -> Vec<(String, std::path::PathBuf)> {
262 let mut pages = Vec::new();
263 let mut stack: Vec<std::path::PathBuf> = vec![content_dir.to_path_buf()];
264
265 while let Some(dir) = stack.pop() {
266 let Ok(entries) = fs::read_dir(&dir) else {
267 continue;
268 };
269 for entry in entries.flatten() {
270 let path = entry.path();
271 let name = entry.file_name();
273 let name_str = name.to_string_lossy();
274 if name_str.starts_with('.') || name_str == "dist" || name_str == "node_modules" {
275 continue;
276 }
277 if path.is_dir() {
278 stack.push(path);
279 continue;
280 }
281 if path.extension().and_then(|s| s.to_str()) != Some("md") {
282 continue;
283 }
284
285 let rel = match path.strip_prefix(content_dir) {
286 Ok(r) => r,
287 Err(_) => continue,
288 };
289 let rel_str = rel.to_string_lossy().replace('\\', "/");
290 pages.push((page_url_path(&rel_str, base_path, sources), path));
291 }
292 }
293
294 pages.sort_by(|a, b| a.0.cmp(&b.0));
295 pages
296}
297
298fn build_sidebar_item_state(item: &SidebarItem, base: &str, url_path: &str) -> (Value, bool) {
299 let item_path = normalize_link(base, &item.link);
300 let active = !item_path.is_empty() && item_path == url_path;
301 let mut descendant_active = false;
302 let children: Vec<Value> = item
303 .items
304 .iter()
305 .map(|child| {
306 let child_path = normalize_link(base, &child.link);
307 let child_active = !child_path.is_empty() && child_path == url_path;
308 descendant_active |= child_active;
309 json_obj([
310 ("text", Value::String(child.text.clone())),
311 ("link", Value::String(child_path)),
312 ("active", Value::Bool(child_active)),
313 ("hasChildren", Value::Bool(false)),
314 ("children", Value::Array(vec![])),
315 ])
316 })
317 .collect();
318 let expanded = active || descendant_active;
319 (
320 json_obj([
321 ("text", Value::String(item.text.clone())),
322 ("link", Value::String(item_path)),
323 ("active", Value::Bool(active)),
324 ("expanded", Value::Bool(expanded)),
325 ("hasChildren", Value::Bool(!children.is_empty())),
326 ("children", Value::Array(children)),
327 ]),
328 expanded,
329 )
330}
331
332fn build_sidebar_state(url_path: &str, config: &DocsConfig) -> serde_json::Value {
333 let base = &config.base_path;
334
335 let active_sidebar = config
337 .sidebar_groups
338 .iter()
339 .find(|(prefix, _)| {
340 let full_prefix = format!("{}{}", base, prefix.strip_prefix('/').unwrap_or(prefix));
341 let trimmed = full_prefix.trim_end_matches('/');
342 url_path == trimmed || url_path.starts_with(&format!("{trimmed}/"))
343 })
344 .map(|(_, s)| s.as_slice())
345 .unwrap_or(&config.sidebar);
346
347 let mut current_section = String::new();
348 let sections: Vec<Value> = active_sidebar
349 .iter()
350 .map(|section| {
351 let mut section_active = false;
352 let items: Vec<Value> = section
353 .items
354 .iter()
355 .map(|item| {
356 let (value, branch_active) = build_sidebar_item_state(item, base, url_path);
357 section_active |= branch_active;
358 value
359 })
360 .collect();
361 if section_active {
362 current_section.clone_from(§ion.title);
363 }
364 json_obj([
365 ("title", Value::String(section.title.clone())),
366 ("items", Value::Array(items)),
367 ])
368 })
369 .collect();
370
371 json_obj([
372 ("sections", Value::Array(sections)),
373 ("currentSection", Value::String(current_section)),
374 ])
375}
376
377fn find_sidebar_text(url_path: &str, config: &DocsConfig) -> Option<String> {
378 let base = &config.base_path;
379 let all: Vec<&[SidebarSection]> = std::iter::once(config.sidebar.as_slice())
380 .chain(config.sidebar_groups.values().map(|v| v.as_slice()))
381 .collect();
382
383 for sidebar in all {
384 for section in sidebar {
385 let mut stack: Vec<&SidebarItem> = section.items.iter().collect();
387 while let Some(item) = stack.pop() {
388 if !item.link.is_empty() {
389 let item_path = normalize_link(base, &item.link);
390 if item_path == url_path {
391 return Some(item.text.clone());
392 }
393 }
394 for child in &item.items {
395 stack.push(child);
396 }
397 }
398 }
399 }
400 None
401}
402
403#[allow(dead_code)] pub fn process_content(
414 config: &DocsConfig,
415 config_dir: &Path,
416 highlighter: &Highlighter,
417 head_injection: &str,
418) -> Result<Vec<PageDescriptor>> {
419 let states = load_render_states(config, config_dir)?;
420 process_content_with_states(config, highlighter, head_injection, &states)
421}
422
423pub(crate) fn process_content_with_states(
424 config: &DocsConfig,
425 highlighter: &Highlighter,
426 head_injection: &str,
427 states: &LoadedStates,
428) -> Result<Vec<PageDescriptor>> {
429 let content_dir = Path::new(&config.content_dir);
430 let base_path = &config.base_path;
431
432 let nav_links: Vec<Value> = config
433 .nav
434 .iter()
435 .map(|item| {
436 let full_layout = !item.link.starts_with("http")
437 && config.custom_pages.iter().any(|(path, page)| {
438 config_paths_match(path, &item.link) && page.layout() == "full"
439 });
440 let (link, section) = if item.link.starts_with("http") {
441 (item.link.clone(), item.link.clone())
445 } else {
446 let full = format!("{}{}", base_path, &item.link[1..]);
447 let slug = item
449 .link
450 .trim_start_matches('/')
451 .split('/')
452 .next()
453 .unwrap_or("")
454 .to_string();
455 (full, slug)
456 };
457 json_obj([
458 ("text", Value::String(item.text.clone())),
459 ("link", Value::String(link)),
460 ("section", Value::String(section)),
461 ("fullLayout", Value::Bool(full_layout)),
462 ])
463 })
464 .collect();
465
466 let mut registry =
467 build_page_registry(content_dir, base_path, &nav_source_overrides(&config.nav));
468
469 for page_link in config.custom_pages.keys() {
471 let normalized = if page_link.ends_with('/') {
472 page_link.clone()
473 } else {
474 format!("{}/", page_link)
475 };
476 let url_path = format!("{}{}", base_path, &normalized[1..]);
477 let src_file = format!("{}index.md", &normalized[1..]);
478 let full_path = content_dir.join(&src_file);
479 if !registry.iter().any(|(p, _)| p == &url_path) {
480 registry.push((url_path, full_path));
481 }
482 }
483
484 use rayon::prelude::*;
485
486 let results: Vec<(usize, PageDescriptor)> = registry
492 .par_iter()
493 .enumerate()
494 .map(
495 |(idx, (url_path, full_path))| -> Result<(usize, PageDescriptor)> {
496 let mut is_home = false;
497 let mut html = String::new();
498 let mut title = config.site.title.clone();
499 let mut description = config.site.description.clone();
500 let mut layout = "doc".to_string();
501 let mut markdown_state = None;
502
503 let logical_path = format!("/{}", &url_path[base_path.len()..]);
505 let custom_entry = config
506 .custom_pages
507 .get(&logical_path)
508 .or_else(|| config.custom_pages.get(logical_path.trim_end_matches('/')));
509 let custom_state = states.custom_page_state(&logical_path);
510
511 if let Some(entry) = custom_entry {
512 html = entry.html().to_string();
513 layout = entry.layout().to_string();
514 } else if full_path.exists() {
515 let raw = fs::read_to_string(full_path)
516 .map_err(|e| crate::error::Error::Io(e.to_string()))?;
517 let (fm, body) = parse_frontmatter(&raw).map_err(|e| {
518 crate::error::Error::Markdown(format!("{}: {e}", full_path.display()))
519 })?;
520 markdown_state = fm.state;
521
522 is_home = fm.layout.as_deref() == Some("home");
523 layout = if is_home {
524 "home".to_string()
525 } else {
526 fm.layout.unwrap_or_else(|| "doc".to_string())
527 };
528
529 if !is_home || config.show == ShowMode::Content {
530 let page_url = if url_path.ends_with('/') {
535 url_path.to_string()
536 } else {
537 format!("{url_path}/")
538 };
539 let link_base_url =
540 markdown_link_base_url(full_path, content_dir, base_path);
541 html = render_markdown(
542 body,
543 highlighter,
544 base_path,
545 &page_url,
546 &link_base_url,
547 )?;
548 }
549
550 if let Some(d) = fm.description {
551 description = d;
552 }
553
554 let h1 = extract_h1(body);
555 let sidebar_text = find_sidebar_text(url_path, config);
556 title = fm
557 .title
558 .or(h1)
559 .or(sidebar_text)
560 .unwrap_or_else(|| config.site.title.clone());
561 }
562
563 let hero_val = if is_home {
564 config
565 .hero
566 .as_ref()
567 .map(|h| {
568 json_obj([
569 (
570 "text",
571 h.text
572 .as_ref()
573 .map_or(Value::Null, |t| Value::String(t.clone())),
574 ),
575 ("tagline", Value::String(h.tagline.clone())),
576 (
577 "manifesto",
578 h.manifesto
579 .as_ref()
580 .map_or(Value::Null, |m| Value::String(m.clone())),
581 ),
582 (
583 "actions",
584 Value::Array(
585 h.actions
586 .iter()
587 .map(|a| {
588 json_obj([
589 ("text", Value::String(a.text.clone())),
590 ("link", Value::String(a.link.clone())),
591 ("brand", Value::Bool(a.brand)),
592 ])
593 })
594 .collect(),
595 ),
596 ),
597 (
598 "features",
599 Value::Array(
600 h.features
601 .iter()
602 .map(|f| {
603 json_obj([
604 ("icon", Value::String(f.icon.clone())),
605 ("title", Value::String(f.title.clone())),
606 (
607 "description",
608 Value::String(f.description.clone()),
609 ),
610 ])
611 })
612 .collect(),
613 ),
614 ),
615 ])
616 })
617 .unwrap_or(Value::Null)
618 } else {
619 Value::Null
620 };
621
622 let footer_val = config
623 .footer
624 .as_ref()
625 .map(|f| json_obj([("html", Value::String(f.html.clone()))]))
626 .unwrap_or(Value::Null);
627
628 let page_section = logical_path
632 .trim_start_matches('/')
633 .split('/')
634 .next()
635 .unwrap_or("")
636 .to_string();
637
638 let state = json_obj([
639 (
640 "site",
641 json_obj([
642 ("title", Value::String(config.site.title.clone())),
643 ("base", Value::String(base_path.to_string())),
644 ]),
645 ),
646 ("navigation", Value::Array(nav_links.clone())),
647 ("sidebar", build_sidebar_state(url_path, config)),
648 (
649 "page",
650 json_obj([
651 ("title", Value::String(title.clone())),
652 ("description", Value::String(description.clone())),
653 ("content", Value::String(html.clone())),
654 ("isHome", Value::Bool(is_home)),
655 ("layout", Value::String(layout)),
656 ("section", Value::String(page_section)),
657 ("mobileNavigationOpen", Value::Bool(false)),
658 ]),
659 ),
660 ("hero", hero_val),
661 ("footer", footer_val),
662 ("prev", Value::Null),
663 ("next", Value::Null),
664 ("pageData", custom_state.cloned().unwrap_or(Value::Null)),
665 ("headTags", Value::String(head_injection.to_string())),
666 ("label", Value::String("Copy".to_string())),
667 ("icon", Value::String("🌙".to_string())),
668 ]);
669
670 let state = merge_page_state(
671 state,
672 states.global(),
673 custom_state.or(markdown_state.as_ref()),
674 );
675
676 Ok((
677 idx,
678 PageDescriptor {
679 path: url_path.clone(),
680 is_home,
681 state,
682 },
683 ))
684 },
685 )
686 .collect::<Result<Vec<_>>>()?;
687
688 let resolve_sidebar = |page_path: &str| -> Vec<SidebarItem> {
692 let active = config
693 .sidebar_groups
694 .iter()
695 .find(|(prefix, _)| {
696 let full_prefix = format!(
697 "{}{}",
698 base_path,
699 prefix.strip_prefix('/').unwrap_or(prefix)
700 );
701 let trimmed = full_prefix.trim_end_matches('/');
702 page_path == trimmed || page_path.starts_with(&format!("{trimmed}/"))
703 })
704 .map(|(_, s)| s.as_slice())
705 .unwrap_or(&config.sidebar);
706 active.iter().flat_map(|s| s.items.clone()).collect()
707 };
708
709 let mut pages: Vec<Option<PageDescriptor>> = (0..results.len()).map(|_| None).collect();
714 for (idx, mut desc) in results {
715 let sidebar_items = resolve_sidebar(&desc.path);
716 let flat_links = collect_sidebar_links(&sidebar_items);
717 let all_links: Vec<(String, String)> = flat_links
718 .iter()
719 .map(|link| {
720 let path = normalize_link(base_path, link);
721 let text = find_sidebar_text(&path, config).unwrap_or_default();
722 (path, text)
723 })
724 .collect();
725 if let Some(pos) = all_links.iter().position(|(p, _)| p == &desc.path) {
726 if pos > 0 {
727 let (link, text) = &all_links[pos - 1];
728 desc.state["prev"] = json_obj([
729 ("link", Value::String(link.clone())),
730 ("text", Value::String(text.clone())),
731 ]);
732 }
733 if pos + 1 < all_links.len() {
734 let (link, text) = &all_links[pos + 1];
735 desc.state["next"] = json_obj([
736 ("link", Value::String(link.clone())),
737 ("text", Value::String(text.clone())),
738 ]);
739 }
740 }
741 pages[idx] = Some(desc);
742 }
743 let pages: Vec<PageDescriptor> = pages
744 .into_iter()
745 .enumerate()
746 .map(|(i, slot)| {
747 slot.ok_or_else(|| Error::Build(format!("missing page at registry index {i}")))
748 })
749 .collect::<Result<Vec<_>>>()?;
750
751 Ok(pages)
752}
753
754#[cfg(test)]
755mod tests {
756 use super::*;
757
758 #[test]
761 fn normalize_link_empty_returns_empty() {
762 assert_eq!(normalize_link("/webui/", ""), "");
763 }
764
765 #[test]
766 fn normalize_link_root_slash_returns_base() {
767 assert_eq!(normalize_link("/webui/", "/"), "/webui/");
768 }
769
770 #[test]
771 fn normalize_link_trims_trailing_slash() {
772 assert_eq!(
773 normalize_link("/webui/", "/guide/intro/"),
774 "/webui/guide/intro"
775 );
776 assert_eq!(
777 normalize_link("/webui/", "/guide/intro"),
778 "/webui/guide/intro"
779 );
780 }
781
782 #[test]
783 fn normalize_link_preserves_fragment() {
784 assert_eq!(
785 normalize_link("/webui/", "/guide/intro#section"),
786 "/webui/guide/intro#section"
787 );
788 }
789
790 #[test]
791 fn normalize_link_tolerates_missing_leading_slash() {
792 assert_eq!(
794 normalize_link("/webui/", "guide/intro"),
795 "/webui/guide/intro"
796 );
797 }
798
799 #[test]
800 fn normalize_link_tolerates_non_ascii_first_char() {
801 let out = normalize_link("/webui/", "é-page");
803 assert!(out.ends_with("é-page"), "got {out}");
804 }
805
806 #[test]
807 fn config_paths_match_with_or_without_trailing_slash() {
808 assert!(config_paths_match("/playground", "/playground/"));
809 assert!(config_paths_match("/playground/", "/playground"));
810 assert!(!config_paths_match("/playground", "/guide/"));
811 }
812
813 #[test]
814 fn sidebar_item_expands_for_active_descendant() {
815 let item = SidebarItem {
816 text: "Template Syntax".to_string(),
817 link: "/guide/concepts/directives".to_string(),
818 items: vec![SidebarItem {
819 text: "Signals".to_string(),
820 link: "/guide/concepts/directives/signals".to_string(),
821 items: Vec::new(),
822 }],
823 };
824
825 let (state, branch_active) =
826 build_sidebar_item_state(&item, "/webui/", "/webui/guide/concepts/directives/signals");
827
828 assert!(branch_active);
829 assert_eq!(state["expanded"], Value::Bool(true));
830 assert_eq!(state["children"][0]["active"], Value::Bool(true));
831 }
832
833 #[test]
836 fn normalize_path_as_index_strips_duplicate_folder_name() {
837 assert_eq!(
838 normalize_path_as_index("webui-button/webui-button"),
839 "webui-button"
840 );
841 }
842
843 #[test]
844 fn normalize_path_as_index_keeps_different_filename() {
845 assert_eq!(
846 normalize_path_as_index("webui-button/usage"),
847 "webui-button/usage"
848 );
849 }
850
851 #[test]
852 fn normalize_path_as_index_nested_folders() {
853 assert_eq!(
854 normalize_path_as_index("components/webui-button/webui-button"),
855 "components/webui-button"
856 );
857 }
858
859 #[test]
860 fn normalize_path_as_index_no_slash_returns_unchanged() {
861 assert_eq!(normalize_path_as_index("webui-button"), "webui-button");
862 }
863
864 fn sources(pairs: &[(&'static str, &'static str)]) -> HashMap<&'static str, &'static str> {
867 pairs.iter().copied().collect()
868 }
869
870 #[test]
871 fn page_url_path_derives_url_from_filesystem() {
872 let none = sources(&[]);
873 assert_eq!(
874 page_url_path("guide/install.md", "/webui/", &none),
875 "/webui/guide/install"
876 );
877 }
878
879 #[test]
880 fn page_url_path_collapses_index_and_root() {
881 let none = sources(&[]);
882 assert_eq!(
883 page_url_path("guide/index.md", "/webui/", &none),
884 "/webui/guide"
885 );
886 assert_eq!(page_url_path("index.md", "/webui/", &none), "/webui/");
887 assert_eq!(page_url_path("index.md", "", &none), "/");
888 }
889
890 #[test]
891 fn page_url_path_collapses_filename_repeating_its_folder() {
892 let none = sources(&[]);
893 assert_eq!(
894 page_url_path("components/webui-button/webui-button.md", "/webui/", &none),
895 "/webui/components/webui-button"
896 );
897 }
898
899 #[test]
900 fn page_url_path_nav_source_overrides_derived_url() {
901 let map = sources(&[("ai/SKILL.md", "/ai")]);
904 assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
905 }
906
907 #[test]
908 fn page_url_path_nav_source_tolerates_surrounding_slashes() {
909 let map = sources(&[("ai/SKILL.md", "ai/"), ("b/SKILL.md", "/b")]);
910 assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
911 assert_eq!(page_url_path("b/SKILL.md", "", &map), "/b");
912 }
913
914 #[test]
915 fn page_url_path_nav_source_can_target_site_root() {
916 let map = sources(&[("home/landing.md", "/")]);
917 assert_eq!(page_url_path("home/landing.md", "/webui/", &map), "/webui/");
918 }
919
920 #[test]
921 fn page_url_path_ignores_nav_source_for_other_files() {
922 let map = sources(&[("ai/SKILL.md", "/ai")]);
923 assert_eq!(
924 page_url_path("guide/SKILL.md", "/webui/", &map),
925 "/webui/guide/SKILL"
926 );
927 }
928
929 #[test]
930 fn page_url_path_nav_source_strips_fragment_from_override() {
931 let map = sources(&[("ai/SKILL.md", "/ai#rules")]);
933 assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
934 }
935
936 #[test]
937 fn page_url_path_nav_source_strips_query_from_override() {
938 let map = sources(&[("ai/SKILL.md", "/ai?v=2")]);
940 assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
941 }
942
943 #[test]
944 fn markdown_link_base_uses_source_directory_for_index_and_leaf_pages() {
945 let content_dir = Path::new("docs");
946 assert_eq!(
947 markdown_link_base_url(Path::new("docs/guide/index.md"), content_dir, "/webui/"),
948 "/webui/guide/"
949 );
950 assert_eq!(
951 markdown_link_base_url(
952 Path::new("docs/guide/installation.md"),
953 content_dir,
954 "/webui/"
955 ),
956 "/webui/guide/"
957 );
958 assert_eq!(
959 markdown_link_base_url(
960 Path::new("docs/guide/topic/index.md"),
961 content_dir,
962 "/webui/"
963 ),
964 "/webui/guide/topic/"
965 );
966 }
967
968 fn nav_link(text: &str, link: &str, source: Option<&str>) -> NavLink {
971 NavLink {
972 text: text.to_string(),
973 link: link.to_string(),
974 source: source.map(String::from),
975 }
976 }
977
978 #[test]
979 fn nav_source_overrides_collects_only_entries_declaring_a_source() {
980 let nav = vec![
981 nav_link("Guide", "/guide/", None),
982 nav_link("AI", "/ai", Some("ai/SKILL.md")),
983 nav_link("GitHub", "https://github.com/microsoft/webui", None),
984 ];
985 let map = nav_source_overrides(&nav);
986 assert_eq!(map.len(), 1);
987 assert_eq!(map.get("ai/SKILL.md"), Some(&"/ai"));
988 }
989
990 #[test]
991 fn nav_source_overrides_empty_nav_yields_no_overrides() {
992 assert!(nav_source_overrides(&[]).is_empty());
993 }
994
995 #[test]
996 fn nav_source_overrides_excludes_external_link_with_source() {
997 let nav = vec![
1000 nav_link("AI", "/ai", Some("ai/SKILL.md")),
1001 nav_link(
1002 "GitHub",
1003 "https://github.com/microsoft/webui",
1004 Some("github.md"),
1005 ),
1006 ];
1007 let map = nav_source_overrides(&nav);
1008 assert_eq!(map.len(), 1);
1009 assert_eq!(map.get("ai/SKILL.md"), Some(&"/ai"));
1010 assert!(!map.contains_key("github.md"));
1011 }
1012
1013 #[test]
1014 fn normalize_path_as_index_deep_nesting_with_match() {
1015 assert_eq!(
1016 normalize_path_as_index("a/b/c/webui-card/webui-card"),
1017 "a/b/c/webui-card"
1018 );
1019 }
1020
1021 #[test]
1022 fn normalize_path_as_index_deep_nesting_without_match() {
1023 assert_eq!(
1024 normalize_path_as_index("a/b/c/webui-card/examples"),
1025 "a/b/c/webui-card/examples"
1026 );
1027 }
1028
1029 #[test]
1032 fn parse_frontmatter_no_frontmatter_returns_full_body() {
1033 let (fm, body) = parse_frontmatter("# Hello\nworld\n").expect("ok");
1034 assert!(fm.title.is_none());
1035 assert!(fm.layout.is_none());
1036 assert!(fm.state.is_none());
1037 assert_eq!(body, "# Hello\nworld\n");
1038 }
1039
1040 #[test]
1041 fn parse_frontmatter_valid_yaml_extracts_fields() {
1042 let raw = "---\ntitle: Hello\ndescription: World\nlayout: home\n---\n# Body\n";
1043 let (fm, body) = parse_frontmatter(raw).expect("ok");
1044 assert_eq!(fm.title.as_deref(), Some("Hello"));
1045 assert_eq!(fm.description.as_deref(), Some("World"));
1046 assert_eq!(fm.layout.as_deref(), Some("home"));
1047 assert_eq!(body, "# Body\n");
1048 }
1049
1050 #[test]
1051 fn parse_frontmatter_handles_crlf_line_endings() {
1052 let raw = "---\r\ntitle: CRLF\r\nlayout: doc\r\n---\r\n# Body\r\n";
1053 let (fm, body) = parse_frontmatter(raw).expect("ok");
1054 assert_eq!(fm.title.as_deref(), Some("CRLF"));
1055 assert_eq!(fm.layout.as_deref(), Some("doc"));
1056 assert_eq!(body, "# Body\r\n");
1057 }
1058
1059 #[test]
1060 fn parse_frontmatter_malformed_yaml_returns_error() {
1061 let raw = "---\ntitle: : : :\n---\nBody\n";
1063 let result = parse_frontmatter(raw);
1064 assert!(result.is_err(), "expected error, got {result:?}");
1065 }
1066
1067 #[test]
1068 fn parse_frontmatter_preserves_json_compatible_state() {
1069 let raw = concat!(
1070 "---\nstate:\n example:\n label: 'Save'\n count: 2\n",
1071 " enabled: false\n optional: null\n ratio: 0.5\n",
1072 " items: [one, two]\n '1': string-key\n site: nested-is-allowed\n",
1073 "---\n# Example\n"
1074 );
1075 let (fm, body) = parse_frontmatter(raw).expect("valid state");
1076 let state = fm.state.expect("local state");
1077 assert_eq!(state["example"]["label"], "Save");
1078 assert_eq!(state["example"]["count"], 2);
1079 assert_eq!(state["example"]["enabled"], false);
1080 assert_eq!(state["example"]["optional"], Value::Null);
1081 assert_eq!(state["example"]["ratio"], 0.5);
1082 assert_eq!(state["example"]["items"][1], "two");
1083 assert_eq!(state["example"]["1"], "string-key");
1084 assert_eq!(state["example"]["site"], "nested-is-allowed");
1085 assert_eq!(body, "# Example\n");
1086 }
1087
1088 #[test]
1089 fn parse_frontmatter_rejects_invalid_state() {
1090 for state in [
1091 "null",
1092 "example",
1093 "42",
1094 "false",
1095 "[]",
1096 "{nested: {1: value}}",
1097 "{nested: {true: value}}",
1098 "{nested: {.nan: value}}",
1099 "{nested: {value: .inf}}",
1100 "{nested: [1, .nan]}",
1101 "{nested: !example value}",
1102 "!example {value: tagged}",
1103 "{nested: {value: first, value: second}}",
1104 "{value: first}\nstate: {value: second}",
1105 "{broken: [}",
1106 ] {
1107 let raw = format!("---\nstate: {state}\n---\n\n# Invalid");
1108 let message = parse_frontmatter(&raw).expect_err(state).to_string();
1109 assert!(
1110 message.contains("state") || message.contains("YAML"),
1111 "{message}"
1112 );
1113 assert!(message.contains("help:"), "{message}");
1114 }
1115 }
1116
1117 #[test]
1118 fn parse_frontmatter_rejects_reserved_state_keys() {
1119 for key in [
1120 "site",
1121 "navigation",
1122 "sidebar",
1123 "page",
1124 "hero",
1125 "footer",
1126 "prev",
1127 "next",
1128 "pageData",
1129 "regions",
1130 "headTags",
1131 "tokens",
1132 "label",
1133 "icon",
1134 ] {
1135 let raw = format!("---\nstate:\n {key}: override\n---\n\n# Reserved");
1136 let message = parse_frontmatter(&raw).expect_err(key).to_string();
1137 assert!(
1138 message.contains(key) && message.contains("reserved"),
1139 "{message}"
1140 );
1141 assert!(message.contains("help:"), "{message}");
1142 }
1143 }
1144}