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