1use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use serde_json::{Map, Value};
11
12use crate::error::{Error, Result};
13use crate::markdown::{render_markdown, Highlighter};
14use crate::state::{
15 load_render_states, merge_page_state, parse_markdown_state, parse_markdown_state_file,
16 LoadedStates, StateDependencies, StateLoader,
17};
18use crate::types::{DocsConfig, NavLink, PageDescriptor, ShowMode, SidebarItem, SidebarSection};
19
20fn normalize_link(base: &str, link: &str) -> String {
26 if link.is_empty() {
27 return String::new();
28 }
29 let stripped = link.strip_prefix('/').unwrap_or(link);
33 if link.contains('#') {
34 return format!("{base}{stripped}");
36 }
37 let cleaned = stripped.trim_end_matches('/');
38 if cleaned.is_empty() {
39 base.to_string()
41 } else {
42 format!("{base}{cleaned}")
43 }
44}
45
46fn config_paths_match(left: &str, right: &str) -> bool {
47 left.trim_end_matches('/') == right.trim_end_matches('/')
48}
49
50fn json_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
52 let mut map = Map::with_capacity(N);
53 for (k, v) in entries {
54 map.insert(k.to_string(), v);
55 }
56 Value::Object(map)
57}
58
59#[derive(Debug)]
61struct Frontmatter {
62 title: Option<String>,
63 description: Option<String>,
64 layout: Option<String>,
65 state: Option<Value>,
66 state_file: Option<String>,
67}
68
69fn parse_frontmatter(content: &str) -> Result<(Frontmatter, &str)> {
70 if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
71 return Ok((
72 Frontmatter {
73 title: None,
74 description: None,
75 layout: None,
76 state: None,
77 state_file: None,
78 },
79 content,
80 ));
81 }
82
83 let body_start_marker = if content.starts_with("---\r\n") { 5 } else { 4 };
85 let after_open = &content[body_start_marker..];
86
87 let (yaml_end_offset, close_skip) = if let Some(i) = after_open.find("\r\n---") {
89 (i, "\r\n---".len())
90 } else if let Some(i) = after_open.find("\n---") {
91 (i, "\n---".len())
92 } else {
93 (after_open.len(), 0)
94 };
95
96 let yaml_str = &after_open[..yaml_end_offset];
97 let body_offset = body_start_marker + yaml_end_offset + close_skip;
98 let body = if body_offset < content.len() {
99 let rest = &content[body_offset..];
101 rest.strip_prefix("\r\n")
102 .or_else(|| rest.strip_prefix('\n'))
103 .unwrap_or(rest)
104 } else {
105 ""
106 };
107
108 let yaml: serde_yaml::Mapping = serde_yaml::from_str(yaml_str).map_err(|e| {
109 Error::Markdown(format!(
110 "Invalid frontmatter YAML: {e}\nhelp: Use valid YAML with unique object keys between the --- delimiters."
111 ))
112 })?;
113
114 Ok((
115 Frontmatter {
116 title: yaml.get("title").and_then(|v| v.as_str()).map(String::from),
117 description: yaml
118 .get("description")
119 .and_then(|v| v.as_str())
120 .map(String::from),
121 layout: yaml
122 .get("layout")
123 .and_then(|v| v.as_str())
124 .map(String::from),
125 state: yaml.get("state").map(parse_markdown_state).transpose()?,
126 state_file: yaml
127 .get("stateFile")
128 .map(parse_markdown_state_file)
129 .transpose()?,
130 },
131 body,
132 ))
133}
134
135struct MarkdownSource {
136 frontmatter: Frontmatter,
137 body: String,
138}
139
140fn process_markdown_batches<T>(
141 registry: &[(String, PathBuf)],
142 config: &DocsConfig,
143 dependencies: &mut StateDependencies,
144 mut process: impl FnMut(usize, &[(String, PathBuf)], Vec<Option<MarkdownSource>>) -> Result<Vec<T>>,
145) -> Result<Vec<T>> {
146 let mut loader = StateLoader::new();
147 let batch_size = rayon::current_num_threads();
149 let results = registry.chunks(batch_size).enumerate().try_fold(
150 Vec::with_capacity(registry.len()),
151 |mut results, (batch_index, batch)| {
152 let sources = batch
153 .iter()
154 .map(|(url, source)| {
155 let logical = format!("/{}", &url[config.base_path.len()..]);
156 if config
157 .custom_pages
158 .keys()
159 .any(|key| config_paths_match(key, &logical))
160 || !source.exists()
161 {
162 return Ok(None);
163 }
164 let raw = fs::read_to_string(source).map_err(|error| {
165 Error::Io(format!("Cannot read {}: {error}", source.display()))
166 })?;
167 let (mut frontmatter, body) = parse_frontmatter(&raw).map_err(|error| {
168 Error::Markdown(format!("{}: {error}", source.display()))
169 })?;
170 frontmatter.state = loader
171 .load_markdown_state(
172 frontmatter.state_file.as_deref(),
173 frontmatter.state.take(),
174 source,
175 )
176 .map_err(|error| {
177 Error::Markdown(format!("{}: {error}", source.display()))
178 })?;
179 Ok(Some(MarkdownSource {
180 frontmatter,
181 body: body.to_string(),
182 }))
183 })
184 .collect::<Result<Vec<_>>>()?;
185 results.extend(process(batch_index * batch_size, batch, sources)?);
186 Ok(results)
187 },
188 );
189 *dependencies = loader.into_dependencies();
190 results
191}
192
193fn extract_h1(content: &str) -> Option<String> {
194 for line in content.lines() {
195 if let Some(heading) = line.strip_prefix("# ") {
196 return Some(heading.trim().to_string());
197 }
198 }
199 None
200}
201
202fn collect_sidebar_links(items: &[SidebarItem]) -> Vec<&str> {
204 let mut links = Vec::new();
205 let mut stack: Vec<&SidebarItem> = items.iter().rev().collect();
206 while let Some(item) = stack.pop() {
207 if !item.link.is_empty() {
208 links.push(item.link.as_str());
209 }
210 for child in item.items.iter().rev() {
211 stack.push(child);
212 }
213 }
214 links
215}
216
217fn normalize_path_as_index(path: &str) -> &str {
222 let Some(slash_pos) = path.rfind('/') else {
224 return path;
225 };
226
227 let parent = &path[..slash_pos];
228 let filename = &path[slash_pos + 1..];
229
230 let folder_name = parent.rsplit('/').next().unwrap_or("");
232
233 if folder_name == filename {
235 parent
236 } else {
237 path
238 }
239}
240
241fn page_url_path(rel_path: &str, base_path: &str, sources: &HashMap<&str, &str>) -> String {
254 let prefix = base_path.trim_end_matches('/');
255
256 if let Some(&link) = sources.get(rel_path) {
257 let end = link.find(['#', '?']).unwrap_or(link.len());
260 let link = &link[..end];
261 let cleaned = link.trim_matches('/');
262 return if cleaned.is_empty() {
263 format!("{prefix}/")
264 } else {
265 format!("{prefix}/{cleaned}")
266 };
267 }
268
269 let trimmed = rel_path.trim_end_matches(".md");
270 let trimmed = trimmed.strip_suffix("/index").unwrap_or(trimmed);
271 let trimmed = normalize_path_as_index(trimmed);
272
273 if trimmed.is_empty() || trimmed == "index" {
274 format!("{prefix}/")
275 } else {
276 format!("{prefix}/{trimmed}")
277 }
278}
279
280fn markdown_link_base_url(source_path: &Path, content_dir: &Path, base_path: &str) -> String {
281 let parent = source_path
282 .strip_prefix(content_dir)
283 .ok()
284 .and_then(Path::parent);
285 let parent = parent
286 .filter(|path| !path.as_os_str().is_empty())
287 .map(|path| path.to_string_lossy().replace('\\', "/"))
288 .unwrap_or_default();
289
290 let prefix = base_path.trim_end_matches('/');
291 let parent = parent.trim_matches('/');
292 let mut url = String::with_capacity(prefix.len() + parent.len() + 2);
293 url.push_str(prefix);
294 url.push('/');
295 if !parent.is_empty() {
296 url.push_str(parent);
297 url.push('/');
298 }
299 url
300}
301
302fn nav_source_overrides(nav: &[NavLink]) -> HashMap<&str, &str> {
309 nav.iter()
310 .filter_map(|item| {
311 let source = item.source.as_deref()?;
312 if item.link.starts_with("http") {
313 return None;
314 }
315 Some((source, item.link.as_str()))
316 })
317 .collect()
318}
319
320fn build_page_registry(
325 content_dir: &Path,
326 base_path: &str,
327 sources: &HashMap<&str, &str>,
328) -> Vec<(String, std::path::PathBuf)> {
329 let mut pages = Vec::new();
330 let mut stack: Vec<std::path::PathBuf> = vec![content_dir.to_path_buf()];
331
332 while let Some(dir) = stack.pop() {
333 let Ok(entries) = fs::read_dir(&dir) else {
334 continue;
335 };
336 for entry in entries.flatten() {
337 let path = entry.path();
338 let name = entry.file_name();
340 let name_str = name.to_string_lossy();
341 if name_str.starts_with('.') || name_str == "dist" || name_str == "node_modules" {
342 continue;
343 }
344 if path.is_dir() {
345 stack.push(path);
346 continue;
347 }
348 if path.extension().and_then(|s| s.to_str()) != Some("md") {
349 continue;
350 }
351
352 let rel = match path.strip_prefix(content_dir) {
353 Ok(r) => r,
354 Err(_) => continue,
355 };
356 let rel_str = rel.to_string_lossy().replace('\\', "/");
357 pages.push((page_url_path(&rel_str, base_path, sources), path));
358 }
359 }
360
361 pages.sort_by(|a, b| a.0.cmp(&b.0));
362 pages
363}
364
365fn build_sidebar_item_state(item: &SidebarItem, base: &str, url_path: &str) -> (Value, bool) {
366 let item_path = normalize_link(base, &item.link);
367 let active = !item_path.is_empty() && item_path == url_path;
368 let mut descendant_active = false;
369 let children: Vec<Value> = item
370 .items
371 .iter()
372 .map(|child| {
373 let child_path = normalize_link(base, &child.link);
374 let child_active = !child_path.is_empty() && child_path == url_path;
375 descendant_active |= child_active;
376 json_obj([
377 ("text", Value::String(child.text.clone())),
378 ("link", Value::String(child_path)),
379 ("active", Value::Bool(child_active)),
380 ("hasChildren", Value::Bool(false)),
381 ("children", Value::Array(vec![])),
382 ])
383 })
384 .collect();
385 let expanded = active || descendant_active;
386 (
387 json_obj([
388 ("text", Value::String(item.text.clone())),
389 ("link", Value::String(item_path)),
390 ("active", Value::Bool(active)),
391 ("expanded", Value::Bool(expanded)),
392 ("hasChildren", Value::Bool(!children.is_empty())),
393 ("children", Value::Array(children)),
394 ]),
395 expanded,
396 )
397}
398
399fn build_sidebar_state(url_path: &str, config: &DocsConfig) -> serde_json::Value {
400 let base = &config.base_path;
401
402 let active_sidebar = config
404 .sidebar_groups
405 .iter()
406 .find(|(prefix, _)| {
407 let full_prefix = format!("{}{}", base, prefix.strip_prefix('/').unwrap_or(prefix));
408 let trimmed = full_prefix.trim_end_matches('/');
409 url_path == trimmed || url_path.starts_with(&format!("{trimmed}/"))
410 })
411 .map(|(_, s)| s.as_slice())
412 .unwrap_or(&config.sidebar);
413
414 let mut current_section = String::new();
415 let sections: Vec<Value> = active_sidebar
416 .iter()
417 .map(|section| {
418 let mut section_active = false;
419 let items: Vec<Value> = section
420 .items
421 .iter()
422 .map(|item| {
423 let (value, branch_active) = build_sidebar_item_state(item, base, url_path);
424 section_active |= branch_active;
425 value
426 })
427 .collect();
428 if section_active {
429 current_section.clone_from(§ion.title);
430 }
431 json_obj([
432 ("title", Value::String(section.title.clone())),
433 ("items", Value::Array(items)),
434 ])
435 })
436 .collect();
437
438 json_obj([
439 ("sections", Value::Array(sections)),
440 ("currentSection", Value::String(current_section)),
441 ])
442}
443
444fn find_sidebar_text(url_path: &str, config: &DocsConfig) -> Option<String> {
445 let base = &config.base_path;
446 let all: Vec<&[SidebarSection]> = std::iter::once(config.sidebar.as_slice())
447 .chain(config.sidebar_groups.values().map(|v| v.as_slice()))
448 .collect();
449
450 for sidebar in all {
451 for section in sidebar {
452 let mut stack: Vec<&SidebarItem> = section.items.iter().collect();
454 while let Some(item) = stack.pop() {
455 if !item.link.is_empty() {
456 let item_path = normalize_link(base, &item.link);
457 if item_path == url_path {
458 return Some(item.text.clone());
459 }
460 }
461 for child in &item.items {
462 stack.push(child);
463 }
464 }
465 }
466 }
467 None
468}
469
470#[allow(dead_code)] pub fn process_content(
481 config: &DocsConfig,
482 config_dir: &Path,
483 highlighter: &Highlighter,
484 head_injection: &str,
485) -> Result<Vec<PageDescriptor>> {
486 let states = load_render_states(config, config_dir)?;
487 process_content_with_states(
488 config,
489 highlighter,
490 head_injection,
491 &states,
492 &mut StateDependencies::default(),
493 )
494}
495
496pub(crate) fn process_content_with_states(
497 config: &DocsConfig,
498 highlighter: &Highlighter,
499 head_injection: &str,
500 states: &LoadedStates,
501 dependencies: &mut StateDependencies,
502) -> Result<Vec<PageDescriptor>> {
503 let content_dir = Path::new(&config.content_dir);
504 let base_path = &config.base_path;
505
506 let nav_links: Vec<Value> = config
507 .nav
508 .iter()
509 .map(|item| {
510 let full_layout = !item.link.starts_with("http")
511 && config.custom_pages.iter().any(|(path, page)| {
512 config_paths_match(path, &item.link) && page.layout() == "full"
513 });
514 let (link, section) = if item.link.starts_with("http") {
515 (item.link.clone(), item.link.clone())
519 } else {
520 let full = format!("{}{}", base_path, &item.link[1..]);
521 let slug = item
523 .link
524 .trim_start_matches('/')
525 .split('/')
526 .next()
527 .unwrap_or("")
528 .to_string();
529 (full, slug)
530 };
531 json_obj([
532 ("text", Value::String(item.text.clone())),
533 ("link", Value::String(link)),
534 ("section", Value::String(section)),
535 ("fullLayout", Value::Bool(full_layout)),
536 ])
537 })
538 .collect();
539
540 let mut registry =
541 build_page_registry(content_dir, base_path, &nav_source_overrides(&config.nav));
542
543 for page_link in config.custom_pages.keys() {
545 let normalized = if page_link.ends_with('/') {
546 page_link.clone()
547 } else {
548 format!("{}/", page_link)
549 };
550 let url_path = format!("{}{}", base_path, &normalized[1..]);
551 let src_file = format!("{}index.md", &normalized[1..]);
552 let full_path = content_dir.join(&src_file);
553 if !registry.iter().any(|(p, _)| p == &url_path) {
554 registry.push((url_path, full_path));
555 }
556 }
557
558 use rayon::prelude::*;
559
560 let results: Vec<(usize, PageDescriptor)> =
566 process_markdown_batches(
567 ®istry,
568 config,
569 dependencies,
570 |offset, registry, markdown_sources| {
571 registry
572 .par_iter()
573 .zip(markdown_sources.into_par_iter())
574 .enumerate()
575 .map(
576 |(idx, ((url_path, full_path), markdown))| -> Result<(usize, PageDescriptor)> {
577 let mut is_home = false;
578 let mut html = String::new();
579 let mut title = config.site.title.clone();
580 let mut description = config.site.description.clone();
581 let mut layout = "doc".to_string();
582 let mut markdown_state = None;
583
584 let logical_path = format!("/{}", &url_path[base_path.len()..]);
586 let custom_entry = config
587 .custom_pages
588 .get(&logical_path)
589 .or_else(|| config.custom_pages.get(logical_path.trim_end_matches('/')));
590 let custom_state = states.custom_page_state(&logical_path);
591
592 if let Some(entry) = custom_entry {
593 html = entry.html().to_string();
594 layout = entry.layout().to_string();
595 } else if let Some(MarkdownSource {
596 frontmatter: fm,
597 body,
598 }) = markdown
599 {
600 markdown_state = fm.state;
601
602 is_home = fm.layout.as_deref() == Some("home");
603 layout = if is_home {
604 "home".to_string()
605 } else {
606 fm.layout.unwrap_or_else(|| "doc".to_string())
607 };
608 if !is_home || config.show == ShowMode::Content {
609 let page_url = if url_path.ends_with('/') {
610 url_path.to_string()
611 } else {
612 format!("{url_path}/")
613 };
614 let link_base_url =
615 markdown_link_base_url(full_path, content_dir, base_path);
616 html = render_markdown(
617 &body,
618 highlighter,
619 base_path,
620 &page_url,
621 &link_base_url,
622 )?;
623 }
624
625 if let Some(d) = fm.description {
626 description = d;
627 }
628
629 let h1 = extract_h1(&body);
630 let sidebar_text = find_sidebar_text(url_path, config);
631 title = fm
632 .title
633 .or(h1)
634 .or(sidebar_text)
635 .unwrap_or_else(|| config.site.title.clone());
636 }
637
638 let hero_val = if is_home {
639 config
640 .hero
641 .as_ref()
642 .map(|h| {
643 json_obj([
644 (
645 "text",
646 h.text
647 .as_ref()
648 .map_or(Value::Null, |t| Value::String(t.clone())),
649 ),
650 ("tagline", Value::String(h.tagline.clone())),
651 (
652 "manifesto",
653 h.manifesto
654 .as_ref()
655 .map_or(Value::Null, |m| Value::String(m.clone())),
656 ),
657 (
658 "actions",
659 Value::Array(
660 h.actions
661 .iter()
662 .map(|a| {
663 json_obj([
664 ("text", Value::String(a.text.clone())),
665 ("link", Value::String(a.link.clone())),
666 ("brand", Value::Bool(a.brand)),
667 ])
668 })
669 .collect(),
670 ),
671 ),
672 (
673 "features",
674 Value::Array(
675 h.features
676 .iter()
677 .map(|f| {
678 json_obj([
679 ("icon", Value::String(f.icon.clone())),
680 ("title", Value::String(f.title.clone())),
681 (
682 "description",
683 Value::String(f.description.clone()),
684 ),
685 ])
686 })
687 .collect(),
688 ),
689 ),
690 ])
691 })
692 .unwrap_or(Value::Null)
693 } else {
694 Value::Null
695 };
696
697 let footer_val = config
698 .footer
699 .as_ref()
700 .map(|f| json_obj([("html", Value::String(f.html.clone()))]))
701 .unwrap_or(Value::Null);
702
703 let page_section = logical_path
707 .trim_start_matches('/')
708 .split('/')
709 .next()
710 .unwrap_or("")
711 .to_string();
712
713 let state = json_obj([
714 (
715 "site",
716 json_obj([
717 ("title", Value::String(config.site.title.clone())),
718 ("base", Value::String(base_path.to_string())),
719 ]),
720 ),
721 ("navigation", Value::Array(nav_links.clone())),
722 ("sidebar", build_sidebar_state(url_path, config)),
723 (
724 "page",
725 json_obj([
726 ("title", Value::String(title.clone())),
727 ("description", Value::String(description.clone())),
728 ("content", Value::String(html.clone())),
729 ("isHome", Value::Bool(is_home)),
730 ("layout", Value::String(layout)),
731 ("section", Value::String(page_section)),
732 ("mobileNavigationOpen", Value::Bool(false)),
733 ]),
734 ),
735 ("hero", hero_val),
736 ("footer", footer_val),
737 ("prev", Value::Null),
738 ("next", Value::Null),
739 ("pageData", custom_state.cloned().unwrap_or(Value::Null)),
740 ("headTags", Value::String(head_injection.to_string())),
741 ("label", Value::String("Copy".to_string())),
742 ("icon", Value::String("🌙".to_string())),
743 ]);
744
745 let state = merge_page_state(
746 state,
747 states.global(),
748 custom_state.or(markdown_state.as_ref()),
749 );
750
751 Ok((
752 offset + idx,
753 PageDescriptor {
754 path: url_path.clone(),
755 is_home,
756 state,
757 },
758 ))
759 },
760 )
761 .collect::<Result<Vec<_>>>()
762 },
763 )?;
764
765 let resolve_sidebar = |page_path: &str| -> Vec<SidebarItem> {
769 let active = config
770 .sidebar_groups
771 .iter()
772 .find(|(prefix, _)| {
773 let full_prefix = format!(
774 "{}{}",
775 base_path,
776 prefix.strip_prefix('/').unwrap_or(prefix)
777 );
778 let trimmed = full_prefix.trim_end_matches('/');
779 page_path == trimmed || page_path.starts_with(&format!("{trimmed}/"))
780 })
781 .map(|(_, s)| s.as_slice())
782 .unwrap_or(&config.sidebar);
783 active.iter().flat_map(|s| s.items.clone()).collect()
784 };
785
786 let mut pages: Vec<Option<PageDescriptor>> = (0..results.len()).map(|_| None).collect();
791 for (idx, mut desc) in results {
792 let sidebar_items = resolve_sidebar(&desc.path);
793 let flat_links = collect_sidebar_links(&sidebar_items);
794 let all_links: Vec<(String, String)> = flat_links
795 .iter()
796 .map(|link| {
797 let path = normalize_link(base_path, link);
798 let text = find_sidebar_text(&path, config).unwrap_or_default();
799 (path, text)
800 })
801 .collect();
802 if let Some(pos) = all_links.iter().position(|(p, _)| p == &desc.path) {
803 if pos > 0 {
804 let (link, text) = &all_links[pos - 1];
805 desc.state["prev"] = json_obj([
806 ("link", Value::String(link.clone())),
807 ("text", Value::String(text.clone())),
808 ]);
809 }
810 if pos + 1 < all_links.len() {
811 let (link, text) = &all_links[pos + 1];
812 desc.state["next"] = json_obj([
813 ("link", Value::String(link.clone())),
814 ("text", Value::String(text.clone())),
815 ]);
816 }
817 }
818 pages[idx] = Some(desc);
819 }
820 let pages: Vec<PageDescriptor> = pages
821 .into_iter()
822 .enumerate()
823 .map(|(i, slot)| {
824 slot.ok_or_else(|| Error::Build(format!("missing page at registry index {i}")))
825 })
826 .collect::<Result<Vec<_>>>()?;
827
828 Ok(pages)
829}
830
831#[cfg(test)]
832mod tests {
833 use super::*;
834
835 #[test]
836 fn markdown_batches_bound_resident_bodies_and_share_include_cache(
837 ) -> std::result::Result<(), Box<dyn std::error::Error>> {
838 let root = tempfile::tempdir_in(std::env::current_dir()?)?;
839 let include = root.path().join("shared.json");
840 fs::write(&include, r#"{"example":1}"#)?;
841 let body = "# Page\n".repeat(1024);
842 let mut registry = Vec::with_capacity(5);
843 for index in 0..5 {
844 let source = root.path().join(format!("{index}.md"));
845 fs::write(
846 &source,
847 format!("---\nstateFile: ./shared.json\n---\n{body}"),
848 )?;
849 registry.push((format!("/{index}"), source));
850 }
851 let config: DocsConfig = serde_json::from_str(
852 r#"{"site":{"title":"Test"},"basePath":"/","contentDir":".","nav":[],"sidebar":[]}"#,
853 )?;
854 let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build()?;
855 let mut dependencies = StateDependencies::default();
856 let mut batches = 0;
857 let paths = pool.install(|| {
858 process_markdown_batches(
859 ®istry,
860 &config,
861 &mut dependencies,
862 |offset, batch, sources| {
863 assert!(sources.len() <= 2, "all Markdown bodies were preloaded");
864 assert_eq!(offset, batches * 2);
865 assert_eq!(sources.len(), batch.len());
866 for source in sources.into_iter().flatten() {
867 assert_eq!(source.body, body);
868 assert_eq!(
869 source.frontmatter.state.as_ref().map(|v| &v["example"]),
870 Some(&Value::from(1))
871 );
872 }
873 if batches == 0 {
874 fs::write(&include, r#"{"example":2}"#)
875 .map_err(|error| Error::Io(error.to_string()))?;
876 }
877 batches += 1;
878 Ok(batch.iter().map(|(url, _)| url.clone()).collect())
879 },
880 )
881 })?;
882 assert_eq!(batches, 3);
883 assert_eq!(
884 paths,
885 registry
886 .iter()
887 .map(|(url, _)| url.clone())
888 .collect::<Vec<_>>()
889 );
890 assert_eq!(dependencies.paths.len(), 1);
891 assert_eq!(dependencies.changed_paths(), dependencies.paths);
892 Ok(())
893 }
894
895 #[test]
898 fn normalize_link_empty_returns_empty() {
899 assert_eq!(normalize_link("/webui/", ""), "");
900 }
901
902 #[test]
903 fn normalize_link_root_slash_returns_base() {
904 assert_eq!(normalize_link("/webui/", "/"), "/webui/");
905 }
906
907 #[test]
908 fn normalize_link_trims_trailing_slash() {
909 assert_eq!(
910 normalize_link("/webui/", "/guide/intro/"),
911 "/webui/guide/intro"
912 );
913 assert_eq!(
914 normalize_link("/webui/", "/guide/intro"),
915 "/webui/guide/intro"
916 );
917 }
918
919 #[test]
920 fn normalize_link_preserves_fragment() {
921 assert_eq!(
922 normalize_link("/webui/", "/guide/intro#section"),
923 "/webui/guide/intro#section"
924 );
925 }
926
927 #[test]
928 fn normalize_link_tolerates_missing_leading_slash() {
929 assert_eq!(
931 normalize_link("/webui/", "guide/intro"),
932 "/webui/guide/intro"
933 );
934 }
935
936 #[test]
937 fn normalize_link_tolerates_non_ascii_first_char() {
938 let out = normalize_link("/webui/", "é-page");
940 assert!(out.ends_with("é-page"), "got {out}");
941 }
942
943 #[test]
944 fn config_paths_match_with_or_without_trailing_slash() {
945 assert!(config_paths_match("/playground", "/playground/"));
946 assert!(config_paths_match("/playground/", "/playground"));
947 assert!(!config_paths_match("/playground", "/guide/"));
948 }
949
950 #[test]
951 fn sidebar_item_expands_for_active_descendant() {
952 let item = SidebarItem {
953 text: "Template Syntax".to_string(),
954 link: "/guide/concepts/directives".to_string(),
955 items: vec![SidebarItem {
956 text: "Signals".to_string(),
957 link: "/guide/concepts/directives/signals".to_string(),
958 items: Vec::new(),
959 }],
960 };
961
962 let (state, branch_active) =
963 build_sidebar_item_state(&item, "/webui/", "/webui/guide/concepts/directives/signals");
964
965 assert!(branch_active);
966 assert_eq!(state["expanded"], Value::Bool(true));
967 assert_eq!(state["children"][0]["active"], Value::Bool(true));
968 }
969
970 #[test]
973 fn normalize_path_as_index_strips_duplicate_folder_name() {
974 assert_eq!(
975 normalize_path_as_index("webui-button/webui-button"),
976 "webui-button"
977 );
978 }
979
980 #[test]
981 fn normalize_path_as_index_keeps_different_filename() {
982 assert_eq!(
983 normalize_path_as_index("webui-button/usage"),
984 "webui-button/usage"
985 );
986 }
987
988 #[test]
989 fn normalize_path_as_index_nested_folders() {
990 assert_eq!(
991 normalize_path_as_index("components/webui-button/webui-button"),
992 "components/webui-button"
993 );
994 }
995
996 #[test]
997 fn normalize_path_as_index_no_slash_returns_unchanged() {
998 assert_eq!(normalize_path_as_index("webui-button"), "webui-button");
999 }
1000
1001 fn sources(pairs: &[(&'static str, &'static str)]) -> HashMap<&'static str, &'static str> {
1004 pairs.iter().copied().collect()
1005 }
1006
1007 #[test]
1008 fn page_url_path_derives_url_from_filesystem() {
1009 let none = sources(&[]);
1010 assert_eq!(
1011 page_url_path("guide/install.md", "/webui/", &none),
1012 "/webui/guide/install"
1013 );
1014 }
1015
1016 #[test]
1017 fn page_url_path_collapses_index_and_root() {
1018 let none = sources(&[]);
1019 assert_eq!(
1020 page_url_path("guide/index.md", "/webui/", &none),
1021 "/webui/guide"
1022 );
1023 assert_eq!(page_url_path("index.md", "/webui/", &none), "/webui/");
1024 assert_eq!(page_url_path("index.md", "", &none), "/");
1025 }
1026
1027 #[test]
1028 fn page_url_path_collapses_filename_repeating_its_folder() {
1029 let none = sources(&[]);
1030 assert_eq!(
1031 page_url_path("components/webui-button/webui-button.md", "/webui/", &none),
1032 "/webui/components/webui-button"
1033 );
1034 }
1035
1036 #[test]
1037 fn page_url_path_nav_source_overrides_derived_url() {
1038 let map = sources(&[("ai/SKILL.md", "/ai")]);
1041 assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
1042 }
1043
1044 #[test]
1045 fn page_url_path_nav_source_tolerates_surrounding_slashes() {
1046 let map = sources(&[("ai/SKILL.md", "ai/"), ("b/SKILL.md", "/b")]);
1047 assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
1048 assert_eq!(page_url_path("b/SKILL.md", "", &map), "/b");
1049 }
1050
1051 #[test]
1052 fn page_url_path_nav_source_can_target_site_root() {
1053 let map = sources(&[("home/landing.md", "/")]);
1054 assert_eq!(page_url_path("home/landing.md", "/webui/", &map), "/webui/");
1055 }
1056
1057 #[test]
1058 fn page_url_path_ignores_nav_source_for_other_files() {
1059 let map = sources(&[("ai/SKILL.md", "/ai")]);
1060 assert_eq!(
1061 page_url_path("guide/SKILL.md", "/webui/", &map),
1062 "/webui/guide/SKILL"
1063 );
1064 }
1065
1066 #[test]
1067 fn page_url_path_nav_source_strips_fragment_from_override() {
1068 let map = sources(&[("ai/SKILL.md", "/ai#rules")]);
1070 assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
1071 }
1072
1073 #[test]
1074 fn page_url_path_nav_source_strips_query_from_override() {
1075 let map = sources(&[("ai/SKILL.md", "/ai?v=2")]);
1077 assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
1078 }
1079
1080 #[test]
1081 fn markdown_link_base_uses_source_directory_for_index_and_leaf_pages() {
1082 let content_dir = Path::new("docs");
1083 assert_eq!(
1084 markdown_link_base_url(Path::new("docs/guide/index.md"), content_dir, "/webui/"),
1085 "/webui/guide/"
1086 );
1087 assert_eq!(
1088 markdown_link_base_url(
1089 Path::new("docs/guide/installation.md"),
1090 content_dir,
1091 "/webui/"
1092 ),
1093 "/webui/guide/"
1094 );
1095 assert_eq!(
1096 markdown_link_base_url(
1097 Path::new("docs/guide/topic/index.md"),
1098 content_dir,
1099 "/webui/"
1100 ),
1101 "/webui/guide/topic/"
1102 );
1103 }
1104
1105 fn nav_link(text: &str, link: &str, source: Option<&str>) -> NavLink {
1108 NavLink {
1109 text: text.to_string(),
1110 link: link.to_string(),
1111 source: source.map(String::from),
1112 }
1113 }
1114
1115 #[test]
1116 fn nav_source_overrides_collects_only_entries_declaring_a_source() {
1117 let nav = vec![
1118 nav_link("Guide", "/guide/", None),
1119 nav_link("AI", "/ai", Some("ai/SKILL.md")),
1120 nav_link("GitHub", "https://github.com/microsoft/webui", None),
1121 ];
1122 let map = nav_source_overrides(&nav);
1123 assert_eq!(map.len(), 1);
1124 assert_eq!(map.get("ai/SKILL.md"), Some(&"/ai"));
1125 }
1126
1127 #[test]
1128 fn nav_source_overrides_empty_nav_yields_no_overrides() {
1129 assert!(nav_source_overrides(&[]).is_empty());
1130 }
1131
1132 #[test]
1133 fn nav_source_overrides_excludes_external_link_with_source() {
1134 let nav = vec![
1137 nav_link("AI", "/ai", Some("ai/SKILL.md")),
1138 nav_link(
1139 "GitHub",
1140 "https://github.com/microsoft/webui",
1141 Some("github.md"),
1142 ),
1143 ];
1144 let map = nav_source_overrides(&nav);
1145 assert_eq!(map.len(), 1);
1146 assert_eq!(map.get("ai/SKILL.md"), Some(&"/ai"));
1147 assert!(!map.contains_key("github.md"));
1148 }
1149
1150 #[test]
1151 fn normalize_path_as_index_deep_nesting_with_match() {
1152 assert_eq!(
1153 normalize_path_as_index("a/b/c/webui-card/webui-card"),
1154 "a/b/c/webui-card"
1155 );
1156 }
1157
1158 #[test]
1159 fn normalize_path_as_index_deep_nesting_without_match() {
1160 assert_eq!(
1161 normalize_path_as_index("a/b/c/webui-card/examples"),
1162 "a/b/c/webui-card/examples"
1163 );
1164 }
1165
1166 #[test]
1169 fn parse_frontmatter_no_frontmatter_returns_full_body() {
1170 let (fm, body) = parse_frontmatter("# Hello\nworld\n").expect("ok");
1171 assert!(fm.title.is_none());
1172 assert!(fm.layout.is_none());
1173 assert!(fm.state.is_none());
1174 assert_eq!(body, "# Hello\nworld\n");
1175 }
1176
1177 #[test]
1178 fn parse_frontmatter_valid_yaml_extracts_fields() {
1179 let raw = "---\ntitle: Hello\ndescription: World\nlayout: home\n---\n# Body\n";
1180 let (fm, body) = parse_frontmatter(raw).expect("ok");
1181 assert_eq!(fm.title.as_deref(), Some("Hello"));
1182 assert_eq!(fm.description.as_deref(), Some("World"));
1183 assert_eq!(fm.layout.as_deref(), Some("home"));
1184 assert_eq!(body, "# Body\n");
1185 }
1186
1187 #[test]
1188 fn parse_frontmatter_handles_crlf_line_endings() {
1189 let raw = "---\r\ntitle: CRLF\r\nlayout: doc\r\n---\r\n# Body\r\n";
1190 let (fm, body) = parse_frontmatter(raw).expect("ok");
1191 assert_eq!(fm.title.as_deref(), Some("CRLF"));
1192 assert_eq!(fm.layout.as_deref(), Some("doc"));
1193 assert_eq!(body, "# Body\r\n");
1194 }
1195
1196 #[test]
1197 fn parse_frontmatter_malformed_yaml_returns_error() {
1198 let raw = "---\ntitle: : : :\n---\nBody\n";
1200 let result = parse_frontmatter(raw);
1201 assert!(result.is_err(), "expected error, got {result:?}");
1202 }
1203
1204 #[test]
1205 fn parse_frontmatter_preserves_json_compatible_state() {
1206 let raw = concat!(
1207 "---\nstate:\n example:\n label: 'Save'\n count: 2\n",
1208 " enabled: false\n optional: null\n ratio: 0.5\n",
1209 " items: [one, two]\n '1': string-key\n site: nested-is-allowed\n",
1210 "---\n# Example\n"
1211 );
1212 let (fm, body) = parse_frontmatter(raw).expect("valid state");
1213 let state = fm.state.expect("local state");
1214 assert_eq!(state["example"]["label"], "Save");
1215 assert_eq!(state["example"]["count"], 2);
1216 assert_eq!(state["example"]["enabled"], false);
1217 assert_eq!(state["example"]["optional"], Value::Null);
1218 assert_eq!(state["example"]["ratio"], 0.5);
1219 assert_eq!(state["example"]["items"][1], "two");
1220 assert_eq!(state["example"]["1"], "string-key");
1221 assert_eq!(state["example"]["site"], "nested-is-allowed");
1222 assert_eq!(body, "# Example\n");
1223 }
1224
1225 #[test]
1226 fn parse_frontmatter_preserves_yaml_scalar_and_alias_semantics() {
1227 let raw = concat!(
1228 "---\ntitle: 42\ndescription: false\nlayout: [doc]\n",
1229 "state:\n example:\n word: yes\n quoted: 'false'\n",
1230 " maximum: 18446744073709551615\n minimum: -9223372036854775808\n",
1231 " explicit: !!str 42\n base: &base {count: 2}\n",
1232 " alias: *base\n merged: {<<: *base}\n",
1233 "---\nBody\n"
1234 );
1235 let (fm, body) = parse_frontmatter(raw).expect("valid YAML scalars");
1236 assert!(fm.title.is_none());
1237 assert!(fm.description.is_none());
1238 assert!(fm.layout.is_none());
1239 let state = fm.state.expect("state");
1240 assert_eq!(state["example"]["word"], "yes");
1241 assert_eq!(state["example"]["quoted"], "false");
1242 assert_eq!(state["example"]["maximum"].as_u64(), Some(u64::MAX));
1243 assert_eq!(state["example"]["minimum"].as_i64(), Some(i64::MIN));
1244 assert_eq!(state["example"]["explicit"], "42");
1245 assert_eq!(state["example"]["alias"]["count"], 2);
1246 assert_eq!(state["example"]["merged"]["<<"]["count"], 2);
1247 assert_eq!(body, "Body\n");
1248 }
1249
1250 #[test]
1251 fn parse_frontmatter_preserves_leading_zero_and_underscored_strings() {
1252 let raw = concat!(
1253 "---\ntitle: 00123\ndescription: 1_000\nlayout: 00\n",
1254 "state: {example: {00123: leading, 1_000: underscored, ",
1255 "leading: 00123, underscored: 1_000}}\n---\nBody\n"
1256 );
1257 let (fm, _) = parse_frontmatter(raw).expect("string scalar classification");
1258 assert_eq!(fm.title.as_deref(), Some("00123"));
1259 assert_eq!(fm.description.as_deref(), Some("1_000"));
1260 assert_eq!(fm.layout.as_deref(), Some("00"));
1261 let state = fm.state.expect("state");
1262 assert_eq!(state["example"]["00123"], "leading");
1263 assert_eq!(state["example"]["1_000"], "underscored");
1264 assert_eq!(state["example"]["leading"], "00123");
1265 assert_eq!(state["example"]["underscored"], "1_000");
1266 }
1267
1268 #[test]
1269 fn parse_frontmatter_keeps_validation_scoped_to_state() {
1270 for metadata in [
1271 "!str value",
1272 ".inf",
1273 "{42: value}",
1274 "{nested: !example value}",
1275 "{nested: [one, .nan]}",
1276 ] {
1277 let raw = format!(
1278 "---\nignored: &metadata {metadata}\nstate: {{example: {{count: 2}}}}\n---\nBody\n"
1279 );
1280 let (fm, _) = parse_frontmatter(&raw).expect(metadata);
1281 assert_eq!(fm.state.expect("state")["example"]["count"], 2);
1282
1283 let raw = format!(
1284 "---\nignored: &metadata {metadata}\nstate: {{example: *metadata}}\n---\nBody\n"
1285 );
1286 let error = parse_frontmatter(&raw).expect_err(metadata).to_string();
1287 assert!(
1288 error.contains("state") && error.contains("help:"),
1289 "{error}"
1290 );
1291 }
1292 let (fm, _) = parse_frontmatter(
1293 "---\ntitle: !str Ignored\ndescription: .inf\n42: ignored\nstate: {count: 2}\n---\nBody\n",
1294 ).expect("ignored non-JSON metadata");
1295 assert_eq!(fm.title.as_deref(), Some("Ignored"));
1296 assert!(fm.description.is_none());
1297 assert_eq!(fm.state.expect("state")["count"], 2);
1298 }
1299
1300 #[test]
1301 fn parse_frontmatter_preserves_text_and_standard_tags() {
1302 let raw = concat!(
1303 "---\ntitle: !!str 00123\nstate: !!map\n",
1304 " quoted: ' 00123 '\n explicit: !!str 1_000\n",
1305 " block: |\n 00123\n 1_000\n",
1306 " yes: yes\n exponent: 1e999\n",
1307 " boolean: !!bool TRUE\n count: !!int 0x10\n",
1308 " ratio: !!float 1.5\n empty: !!null null\n",
1309 " items: !!seq [one, two]\n---\nBody\n",
1310 );
1311 let (fm, _) = parse_frontmatter(raw).expect("standard YAML core tags");
1312 assert_eq!(fm.title.as_deref(), Some("00123"));
1313 let state = fm.state.expect("state");
1314 assert_eq!(state["quoted"], " 00123 ");
1315 assert_eq!(state["explicit"], "1_000");
1316 assert_eq!(state["block"], "00123\n1_000\n");
1317 assert_eq!(state["yes"], "yes");
1318 assert_eq!(state["exponent"], "1e999");
1319 assert_eq!(state["boolean"], true);
1320 assert_eq!(state["count"], 16);
1321 assert_eq!(state["ratio"], 1.5);
1322 assert!(state["empty"].is_null());
1323 assert_eq!(state["items"][1], "two");
1324 }
1325
1326 #[test]
1327 fn parse_frontmatter_rejects_invalid_state() {
1328 for state in [
1329 "null",
1330 "example",
1331 "42",
1332 "false",
1333 "[]",
1334 "{nested: {1: value}}",
1335 "{nested: {true: value}}",
1336 "{nested: {.nan: value}}",
1337 "{nested: {null: value}}",
1338 "{nested: {1.5: value}}",
1339 "{nested: {? [one, two]: value}}",
1340 "{nested: {value: .inf}}",
1341 "{nested: {value: -.inf}}",
1342 "{nested: {value: !!float .nan}}",
1343 "{nested: {id: 18446744073709551617}}",
1344 "{nested: {id: -9223372036854775809}}",
1345 "{nested: [1, .nan]}",
1346 "{nested: !example value}",
1347 "!example {value: tagged}",
1348 "{nested: !example [one, two]}",
1349 "{nested: !str value}",
1350 "{nested: !int 42}",
1351 "{nested: !time value}",
1352 "{nested: {value: first, value: second}}",
1353 "{value: first}\nstate: {value: second}",
1354 "{broken: [}",
1355 ] {
1356 let raw = format!("---\nstate: {state}\n---\n\n# Invalid");
1357 let message = parse_frontmatter(&raw).expect_err(state).to_string();
1358 assert!(
1359 message.contains("state") || message.contains("YAML"),
1360 "{message}"
1361 );
1362 assert!(message.contains("help:"), "{message}");
1363 }
1364 }
1365
1366 #[test]
1367 fn parse_frontmatter_rejects_reserved_state_keys() {
1368 for key in [
1369 "site",
1370 "navigation",
1371 "sidebar",
1372 "page",
1373 "hero",
1374 "footer",
1375 "prev",
1376 "next",
1377 "pageData",
1378 "regions",
1379 "headTags",
1380 "tokens",
1381 "label",
1382 "icon",
1383 ] {
1384 let raw = format!("---\nstate:\n {key}: override\n---\n\n# Reserved");
1385 let message = parse_frontmatter(&raw).expect_err(key).to_string();
1386 assert!(
1387 message.contains(key) && message.contains("reserved"),
1388 "{message}"
1389 );
1390 assert!(message.contains("help:"), "{message}");
1391 }
1392 }
1393}