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 nav_source_overrides(nav: &[NavLink]) -> HashMap<&str, &str> {
210 nav.iter()
211 .filter_map(|item| {
212 let source = item.source.as_deref()?;
213 if item.link.starts_with("http") {
214 return None;
215 }
216 Some((source, item.link.as_str()))
217 })
218 .collect()
219}
220
221fn build_page_registry(
226 content_dir: &Path,
227 base_path: &str,
228 sources: &HashMap<&str, &str>,
229) -> Vec<(String, std::path::PathBuf)> {
230 let mut pages = Vec::new();
231 let mut stack: Vec<std::path::PathBuf> = vec![content_dir.to_path_buf()];
232
233 while let Some(dir) = stack.pop() {
234 let Ok(entries) = fs::read_dir(&dir) else {
235 continue;
236 };
237 for entry in entries.flatten() {
238 let path = entry.path();
239 let name = entry.file_name();
241 let name_str = name.to_string_lossy();
242 if name_str.starts_with('.') || name_str == "dist" || name_str == "node_modules" {
243 continue;
244 }
245 if path.is_dir() {
246 stack.push(path);
247 continue;
248 }
249 if path.extension().and_then(|s| s.to_str()) != Some("md") {
250 continue;
251 }
252
253 let rel = match path.strip_prefix(content_dir) {
254 Ok(r) => r,
255 Err(_) => continue,
256 };
257 let rel_str = rel.to_string_lossy().replace('\\', "/");
258 pages.push((page_url_path(&rel_str, base_path, sources), path));
259 }
260 }
261
262 pages.sort_by(|a, b| a.0.cmp(&b.0));
263 pages
264}
265
266fn build_sidebar_state(url_path: &str, config: &DocsConfig) -> serde_json::Value {
267 let base = &config.base_path;
268
269 let active_sidebar = config
271 .sidebar_groups
272 .iter()
273 .find(|(prefix, _)| {
274 let full_prefix = format!("{}{}", base, prefix.strip_prefix('/').unwrap_or(prefix));
275 let trimmed = full_prefix.trim_end_matches('/');
276 url_path == trimmed || url_path.starts_with(&format!("{trimmed}/"))
277 })
278 .map(|(_, s)| s.as_slice())
279 .unwrap_or(&config.sidebar);
280
281 let item_to_value = |item: &SidebarItem| -> Value {
283 let item_path = normalize_link(base, &item.link);
284 let active = !item_path.is_empty() && item_path == url_path;
285 let children: Vec<Value> = item
286 .items
287 .iter()
288 .map(|child| {
289 let child_path = normalize_link(base, &child.link);
290 let child_active = !child_path.is_empty() && child_path == url_path;
291 json_obj([
292 ("text", Value::String(child.text.clone())),
293 ("link", Value::String(child_path)),
294 ("active", Value::Bool(child_active)),
295 ("hasChildren", Value::Bool(false)),
296 ("children", Value::Array(vec![])),
297 ])
298 })
299 .collect();
300 json_obj([
301 ("text", Value::String(item.text.clone())),
302 ("link", Value::String(item_path)),
303 ("active", Value::Bool(active)),
304 ("hasChildren", Value::Bool(!children.is_empty())),
305 ("children", Value::Array(children)),
306 ])
307 };
308
309 let sections: Vec<Value> = active_sidebar
310 .iter()
311 .map(|section| {
312 let items: Vec<Value> = section.items.iter().map(item_to_value).collect();
313 json_obj([
314 ("title", Value::String(section.title.clone())),
315 ("items", Value::Array(items)),
316 ])
317 })
318 .collect();
319
320 json_obj([("sections", Value::Array(sections))])
321}
322
323fn find_sidebar_text(url_path: &str, config: &DocsConfig) -> Option<String> {
324 let base = &config.base_path;
325 let all: Vec<&[SidebarSection]> = std::iter::once(config.sidebar.as_slice())
326 .chain(config.sidebar_groups.values().map(|v| v.as_slice()))
327 .collect();
328
329 for sidebar in all {
330 for section in sidebar {
331 let mut stack: Vec<&SidebarItem> = section.items.iter().collect();
333 while let Some(item) = stack.pop() {
334 if !item.link.is_empty() {
335 let item_path = normalize_link(base, &item.link);
336 if item_path == url_path {
337 return Some(item.text.clone());
338 }
339 }
340 for child in &item.items {
341 stack.push(child);
342 }
343 }
344 }
345 }
346 None
347}
348
349#[allow(dead_code)] pub fn process_content(
360 config: &DocsConfig,
361 config_dir: &Path,
362 highlighter: &Highlighter,
363 head_injection: &str,
364) -> Result<Vec<PageDescriptor>> {
365 let states = load_render_states(config, config_dir)?;
366 process_content_with_states(config, highlighter, head_injection, &states)
367}
368
369pub(crate) fn process_content_with_states(
370 config: &DocsConfig,
371 highlighter: &Highlighter,
372 head_injection: &str,
373 states: &LoadedStates,
374) -> Result<Vec<PageDescriptor>> {
375 let content_dir = Path::new(&config.content_dir);
376 let base_path = &config.base_path;
377
378 let nav_links: Vec<Value> = config
379 .nav
380 .iter()
381 .map(|item| {
382 let (link, section) = if item.link.starts_with("http") {
383 (item.link.clone(), item.link.clone())
387 } else {
388 let full = format!("{}{}", base_path, &item.link[1..]);
389 let slug = item
391 .link
392 .trim_start_matches('/')
393 .split('/')
394 .next()
395 .unwrap_or("")
396 .to_string();
397 (full, slug)
398 };
399 json_obj([
400 ("text", Value::String(item.text.clone())),
401 ("link", Value::String(link)),
402 ("section", Value::String(section)),
403 ])
404 })
405 .collect();
406
407 let mut registry =
408 build_page_registry(content_dir, base_path, &nav_source_overrides(&config.nav));
409
410 for page_link in config.custom_pages.keys() {
412 let normalized = if page_link.ends_with('/') {
413 page_link.clone()
414 } else {
415 format!("{}/", page_link)
416 };
417 let url_path = format!("{}{}", base_path, &normalized[1..]);
418 let src_file = format!("{}index.md", &normalized[1..]);
419 let full_path = content_dir.join(&src_file);
420 if !registry.iter().any(|(p, _)| p == &url_path) {
421 registry.push((url_path, full_path));
422 }
423 }
424
425 use rayon::prelude::*;
426
427 let results: Vec<(usize, PageDescriptor)> = registry
433 .par_iter()
434 .enumerate()
435 .map(
436 |(idx, (url_path, full_path))| -> Result<(usize, PageDescriptor)> {
437 let mut is_home = false;
438 let mut html = String::new();
439 let mut title = config.site.title.clone();
440 let mut description = config.site.description.clone();
441 let mut layout = "doc".to_string();
442
443 let logical_path = format!("/{}", &url_path[base_path.len()..]);
445 let custom_entry = config
446 .custom_pages
447 .get(&logical_path)
448 .or_else(|| config.custom_pages.get(logical_path.trim_end_matches('/')));
449 let custom_state = states.custom_page_state(&logical_path);
450
451 if let Some(entry) = custom_entry {
452 html = entry.html().to_string();
453 layout = entry.layout().to_string();
454 } else if full_path.exists() {
455 let raw = fs::read_to_string(full_path)
456 .map_err(|e| crate::error::Error::Io(e.to_string()))?;
457 let (fm, body) = parse_frontmatter(&raw).map_err(|e| {
458 crate::error::Error::Markdown(format!("{}: {e}", full_path.display()))
459 })?;
460
461 is_home = fm.layout.as_deref() == Some("home");
462 layout = if is_home {
463 "home".to_string()
464 } else {
465 fm.layout.unwrap_or_else(|| "doc".to_string())
466 };
467
468 if !is_home {
469 let page_url = if url_path.ends_with('/') {
474 url_path.to_string()
475 } else {
476 format!("{url_path}/")
477 };
478 html = render_markdown(body, highlighter, base_path, &page_url)?;
479 }
480
481 if let Some(d) = fm.description {
482 description = d;
483 }
484
485 let h1 = extract_h1(body);
486 let sidebar_text = find_sidebar_text(url_path, config);
487 title = fm
488 .title
489 .or(h1)
490 .or(sidebar_text)
491 .unwrap_or_else(|| config.site.title.clone());
492 }
493
494 let hero_val = if is_home {
495 config
496 .hero
497 .as_ref()
498 .map(|h| {
499 json_obj([
500 (
501 "text",
502 h.text
503 .as_ref()
504 .map_or(Value::Null, |t| Value::String(t.clone())),
505 ),
506 ("tagline", Value::String(h.tagline.clone())),
507 (
508 "manifesto",
509 h.manifesto
510 .as_ref()
511 .map_or(Value::Null, |m| Value::String(m.clone())),
512 ),
513 (
514 "actions",
515 Value::Array(
516 h.actions
517 .iter()
518 .map(|a| {
519 json_obj([
520 ("text", Value::String(a.text.clone())),
521 ("link", Value::String(a.link.clone())),
522 ("brand", Value::Bool(a.brand)),
523 ])
524 })
525 .collect(),
526 ),
527 ),
528 (
529 "features",
530 Value::Array(
531 h.features
532 .iter()
533 .map(|f| {
534 json_obj([
535 ("icon", Value::String(f.icon.clone())),
536 ("title", Value::String(f.title.clone())),
537 (
538 "description",
539 Value::String(f.description.clone()),
540 ),
541 ])
542 })
543 .collect(),
544 ),
545 ),
546 ])
547 })
548 .unwrap_or(Value::Null)
549 } else {
550 Value::Null
551 };
552
553 let footer_val = config
554 .footer
555 .as_ref()
556 .map(|f| json_obj([("html", Value::String(f.html.clone()))]))
557 .unwrap_or(Value::Null);
558
559 let page_section = logical_path
563 .trim_start_matches('/')
564 .split('/')
565 .next()
566 .unwrap_or("")
567 .to_string();
568
569 let state = json_obj([
570 (
571 "site",
572 json_obj([
573 ("title", Value::String(config.site.title.clone())),
574 ("base", Value::String(base_path.to_string())),
575 ]),
576 ),
577 ("navigation", Value::Array(nav_links.clone())),
578 ("sidebar", build_sidebar_state(url_path, config)),
579 (
580 "page",
581 json_obj([
582 ("title", Value::String(title.clone())),
583 ("description", Value::String(description.clone())),
584 ("content", Value::String(html.clone())),
585 ("isHome", Value::Bool(is_home)),
586 ("layout", Value::String(layout)),
587 ("section", Value::String(page_section)),
588 ]),
589 ),
590 ("hero", hero_val),
591 ("footer", footer_val),
592 ("prev", Value::Null),
593 ("next", Value::Null),
594 ("pageData", custom_state.cloned().unwrap_or(Value::Null)),
595 ("headTags", Value::String(head_injection.to_string())),
596 ("label", Value::String("Copy".to_string())),
597 ("icon", Value::String("🌙".to_string())),
598 ]);
599
600 let state = merge_page_state(state, states.global(), custom_state);
605
606 Ok((
607 idx,
608 PageDescriptor {
609 path: url_path.clone(),
610 is_home,
611 state,
612 },
613 ))
614 },
615 )
616 .collect::<Result<Vec<_>>>()?;
617
618 let resolve_sidebar = |page_path: &str| -> Vec<SidebarItem> {
622 let active = config
623 .sidebar_groups
624 .iter()
625 .find(|(prefix, _)| {
626 let full_prefix = format!(
627 "{}{}",
628 base_path,
629 prefix.strip_prefix('/').unwrap_or(prefix)
630 );
631 let trimmed = full_prefix.trim_end_matches('/');
632 page_path == trimmed || page_path.starts_with(&format!("{trimmed}/"))
633 })
634 .map(|(_, s)| s.as_slice())
635 .unwrap_or(&config.sidebar);
636 active.iter().flat_map(|s| s.items.clone()).collect()
637 };
638
639 let mut pages: Vec<Option<PageDescriptor>> = (0..results.len()).map(|_| None).collect();
644 for (idx, mut desc) in results {
645 let sidebar_items = resolve_sidebar(&desc.path);
646 let flat_links = collect_sidebar_links(&sidebar_items);
647 let all_links: Vec<(String, String)> = flat_links
648 .iter()
649 .map(|link| {
650 let path = normalize_link(base_path, link);
651 let text = find_sidebar_text(&path, config).unwrap_or_default();
652 (path, text)
653 })
654 .collect();
655 if let Some(pos) = all_links.iter().position(|(p, _)| p == &desc.path) {
656 if pos > 0 {
657 let (link, text) = &all_links[pos - 1];
658 desc.state["prev"] = json_obj([
659 ("link", Value::String(link.clone())),
660 ("text", Value::String(text.clone())),
661 ]);
662 }
663 if pos + 1 < all_links.len() {
664 let (link, text) = &all_links[pos + 1];
665 desc.state["next"] = json_obj([
666 ("link", Value::String(link.clone())),
667 ("text", Value::String(text.clone())),
668 ]);
669 }
670 }
671 pages[idx] = Some(desc);
672 }
673 let pages: Vec<PageDescriptor> = pages
674 .into_iter()
675 .enumerate()
676 .map(|(i, slot)| {
677 slot.ok_or_else(|| Error::Build(format!("missing page at registry index {i}")))
678 })
679 .collect::<Result<Vec<_>>>()?;
680
681 Ok(pages)
682}
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687
688 #[test]
691 fn normalize_link_empty_returns_empty() {
692 assert_eq!(normalize_link("/webui/", ""), "");
693 }
694
695 #[test]
696 fn normalize_link_root_slash_returns_base() {
697 assert_eq!(normalize_link("/webui/", "/"), "/webui/");
698 }
699
700 #[test]
701 fn normalize_link_trims_trailing_slash() {
702 assert_eq!(
703 normalize_link("/webui/", "/guide/intro/"),
704 "/webui/guide/intro"
705 );
706 assert_eq!(
707 normalize_link("/webui/", "/guide/intro"),
708 "/webui/guide/intro"
709 );
710 }
711
712 #[test]
713 fn normalize_link_preserves_fragment() {
714 assert_eq!(
715 normalize_link("/webui/", "/guide/intro#section"),
716 "/webui/guide/intro#section"
717 );
718 }
719
720 #[test]
721 fn normalize_link_tolerates_missing_leading_slash() {
722 assert_eq!(
724 normalize_link("/webui/", "guide/intro"),
725 "/webui/guide/intro"
726 );
727 }
728
729 #[test]
730 fn normalize_link_tolerates_non_ascii_first_char() {
731 let out = normalize_link("/webui/", "é-page");
733 assert!(out.ends_with("é-page"), "got {out}");
734 }
735
736 #[test]
739 fn normalize_path_as_index_strips_duplicate_folder_name() {
740 assert_eq!(
741 normalize_path_as_index("webui-button/webui-button"),
742 "webui-button"
743 );
744 }
745
746 #[test]
747 fn normalize_path_as_index_keeps_different_filename() {
748 assert_eq!(
749 normalize_path_as_index("webui-button/usage"),
750 "webui-button/usage"
751 );
752 }
753
754 #[test]
755 fn normalize_path_as_index_nested_folders() {
756 assert_eq!(
757 normalize_path_as_index("components/webui-button/webui-button"),
758 "components/webui-button"
759 );
760 }
761
762 #[test]
763 fn normalize_path_as_index_no_slash_returns_unchanged() {
764 assert_eq!(normalize_path_as_index("webui-button"), "webui-button");
765 }
766
767 fn sources(pairs: &[(&'static str, &'static str)]) -> HashMap<&'static str, &'static str> {
770 pairs.iter().copied().collect()
771 }
772
773 #[test]
774 fn page_url_path_derives_url_from_filesystem() {
775 let none = sources(&[]);
776 assert_eq!(
777 page_url_path("guide/install.md", "/webui/", &none),
778 "/webui/guide/install"
779 );
780 }
781
782 #[test]
783 fn page_url_path_collapses_index_and_root() {
784 let none = sources(&[]);
785 assert_eq!(
786 page_url_path("guide/index.md", "/webui/", &none),
787 "/webui/guide"
788 );
789 assert_eq!(page_url_path("index.md", "/webui/", &none), "/webui/");
790 assert_eq!(page_url_path("index.md", "", &none), "/");
791 }
792
793 #[test]
794 fn page_url_path_collapses_filename_repeating_its_folder() {
795 let none = sources(&[]);
796 assert_eq!(
797 page_url_path("components/webui-button/webui-button.md", "/webui/", &none),
798 "/webui/components/webui-button"
799 );
800 }
801
802 #[test]
803 fn page_url_path_nav_source_overrides_derived_url() {
804 let map = sources(&[("ai/SKILL.md", "/ai")]);
807 assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
808 }
809
810 #[test]
811 fn page_url_path_nav_source_tolerates_surrounding_slashes() {
812 let map = sources(&[("ai/SKILL.md", "ai/"), ("b/SKILL.md", "/b")]);
813 assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
814 assert_eq!(page_url_path("b/SKILL.md", "", &map), "/b");
815 }
816
817 #[test]
818 fn page_url_path_nav_source_can_target_site_root() {
819 let map = sources(&[("home/landing.md", "/")]);
820 assert_eq!(page_url_path("home/landing.md", "/webui/", &map), "/webui/");
821 }
822
823 #[test]
824 fn page_url_path_ignores_nav_source_for_other_files() {
825 let map = sources(&[("ai/SKILL.md", "/ai")]);
826 assert_eq!(
827 page_url_path("guide/SKILL.md", "/webui/", &map),
828 "/webui/guide/SKILL"
829 );
830 }
831
832 #[test]
833 fn page_url_path_nav_source_strips_fragment_from_override() {
834 let map = sources(&[("ai/SKILL.md", "/ai#rules")]);
836 assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
837 }
838
839 #[test]
840 fn page_url_path_nav_source_strips_query_from_override() {
841 let map = sources(&[("ai/SKILL.md", "/ai?v=2")]);
843 assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
844 }
845
846 fn nav_link(text: &str, link: &str, source: Option<&str>) -> NavLink {
849 NavLink {
850 text: text.to_string(),
851 link: link.to_string(),
852 source: source.map(String::from),
853 }
854 }
855
856 #[test]
857 fn nav_source_overrides_collects_only_entries_declaring_a_source() {
858 let nav = vec![
859 nav_link("Guide", "/guide/", None),
860 nav_link("AI", "/ai", Some("ai/SKILL.md")),
861 nav_link("GitHub", "https://github.com/microsoft/webui", None),
862 ];
863 let map = nav_source_overrides(&nav);
864 assert_eq!(map.len(), 1);
865 assert_eq!(map.get("ai/SKILL.md"), Some(&"/ai"));
866 }
867
868 #[test]
869 fn nav_source_overrides_empty_nav_yields_no_overrides() {
870 assert!(nav_source_overrides(&[]).is_empty());
871 }
872
873 #[test]
874 fn nav_source_overrides_excludes_external_link_with_source() {
875 let nav = vec![
878 nav_link("AI", "/ai", Some("ai/SKILL.md")),
879 nav_link(
880 "GitHub",
881 "https://github.com/microsoft/webui",
882 Some("github.md"),
883 ),
884 ];
885 let map = nav_source_overrides(&nav);
886 assert_eq!(map.len(), 1);
887 assert_eq!(map.get("ai/SKILL.md"), Some(&"/ai"));
888 assert!(!map.contains_key("github.md"));
889 }
890
891 #[test]
892 fn normalize_path_as_index_deep_nesting_with_match() {
893 assert_eq!(
894 normalize_path_as_index("a/b/c/webui-card/webui-card"),
895 "a/b/c/webui-card"
896 );
897 }
898
899 #[test]
900 fn normalize_path_as_index_deep_nesting_without_match() {
901 assert_eq!(
902 normalize_path_as_index("a/b/c/webui-card/examples"),
903 "a/b/c/webui-card/examples"
904 );
905 }
906
907 #[test]
910 fn parse_frontmatter_no_frontmatter_returns_full_body() {
911 let (fm, body) = parse_frontmatter("# Hello\nworld\n").expect("ok");
912 assert!(fm.title.is_none());
913 assert!(fm.layout.is_none());
914 assert_eq!(body, "# Hello\nworld\n");
915 }
916
917 #[test]
918 fn parse_frontmatter_valid_yaml_extracts_fields() {
919 let raw = "---\ntitle: Hello\ndescription: World\nlayout: home\n---\n# Body\n";
920 let (fm, body) = parse_frontmatter(raw).expect("ok");
921 assert_eq!(fm.title.as_deref(), Some("Hello"));
922 assert_eq!(fm.description.as_deref(), Some("World"));
923 assert_eq!(fm.layout.as_deref(), Some("home"));
924 assert_eq!(body, "# Body\n");
925 }
926
927 #[test]
928 fn parse_frontmatter_handles_crlf_line_endings() {
929 let raw = "---\r\ntitle: CRLF\r\nlayout: doc\r\n---\r\n# Body\r\n";
930 let (fm, body) = parse_frontmatter(raw).expect("ok");
931 assert_eq!(fm.title.as_deref(), Some("CRLF"));
932 assert_eq!(fm.layout.as_deref(), Some("doc"));
933 assert_eq!(body, "# Body\r\n");
934 }
935
936 #[test]
937 fn parse_frontmatter_malformed_yaml_returns_error() {
938 let raw = "---\ntitle: : : :\n---\nBody\n";
940 let result = parse_frontmatter(raw);
941 assert!(result.is_err(), "expected error, got {result:?}");
942 }
943}