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::types::{DocsConfig, PageDescriptor, SidebarItem, SidebarSection};
15
16fn normalize_link(base: &str, link: &str) -> String {
22 if link.is_empty() {
23 return String::new();
24 }
25 let stripped = link.strip_prefix('/').unwrap_or(link);
29 if link.contains('#') {
30 return format!("{base}{stripped}");
32 }
33 let cleaned = stripped.trim_end_matches('/');
34 if cleaned.is_empty() {
35 base.to_string()
37 } else {
38 format!("{base}{cleaned}")
39 }
40}
41
42fn json_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
44 let mut map = Map::with_capacity(N);
45 for (k, v) in entries {
46 map.insert(k.to_string(), v);
47 }
48 Value::Object(map)
49}
50
51const RESERVED_STATE_KEYS: &[&str] = &[
56 "site",
57 "navigation",
58 "sidebar",
59 "page",
60 "hero",
61 "footer",
62 "prev",
63 "next",
64 "pageData",
65];
66
67#[derive(Debug)]
69struct Frontmatter {
70 title: Option<String>,
71 description: Option<String>,
72 layout: Option<String>,
73}
74
75fn parse_frontmatter(content: &str) -> Result<(Frontmatter, &str)> {
76 if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
77 return Ok((
78 Frontmatter {
79 title: None,
80 description: None,
81 layout: None,
82 },
83 content,
84 ));
85 }
86
87 let body_start_marker = if content.starts_with("---\r\n") { 5 } else { 4 };
89 let after_open = &content[body_start_marker..];
90
91 let (yaml_end_offset, close_skip) = if let Some(i) = after_open.find("\r\n---") {
93 (i, "\r\n---".len())
94 } else if let Some(i) = after_open.find("\n---") {
95 (i, "\n---".len())
96 } else {
97 (after_open.len(), 0)
98 };
99
100 let yaml_str = &after_open[..yaml_end_offset];
101 let body_offset = body_start_marker + yaml_end_offset + close_skip;
102 let body = if body_offset < content.len() {
103 let rest = &content[body_offset..];
105 rest.strip_prefix("\r\n")
106 .or_else(|| rest.strip_prefix('\n'))
107 .unwrap_or(rest)
108 } else {
109 ""
110 };
111
112 let yaml: HashMap<String, serde_yaml::Value> = serde_yaml::from_str(yaml_str)
113 .map_err(|e| crate::error::Error::Markdown(format!("Invalid frontmatter YAML: {e}")))?;
114
115 Ok((
116 Frontmatter {
117 title: yaml.get("title").and_then(|v| v.as_str()).map(String::from),
118 description: yaml
119 .get("description")
120 .and_then(|v| v.as_str())
121 .map(String::from),
122 layout: yaml
123 .get("layout")
124 .and_then(|v| v.as_str())
125 .map(String::from),
126 },
127 body,
128 ))
129}
130
131fn extract_h1(content: &str) -> Option<String> {
132 for line in content.lines() {
133 if let Some(heading) = line.strip_prefix("# ") {
134 return Some(heading.trim().to_string());
135 }
136 }
137 None
138}
139
140fn collect_sidebar_links(items: &[SidebarItem]) -> Vec<&str> {
142 let mut links = Vec::new();
143 let mut stack: Vec<&SidebarItem> = items.iter().rev().collect();
144 while let Some(item) = stack.pop() {
145 if !item.link.is_empty() {
146 links.push(item.link.as_str());
147 }
148 for child in item.items.iter().rev() {
149 stack.push(child);
150 }
151 }
152 links
153}
154
155fn normalize_path_as_index(path: &str) -> &str {
163 let Some(slash_pos) = path.rfind('/') else {
165 return path;
166 };
167
168 let parent = &path[..slash_pos];
169 let filename = &path[slash_pos + 1..];
170
171 let folder_name = parent.rsplit('/').next().unwrap_or("");
173
174 if folder_name == filename {
176 parent
177 } else {
178 path
179 }
180}
181
182fn build_page_registry(content_dir: &Path, base_path: &str) -> Vec<(String, std::path::PathBuf)> {
183 let mut pages = Vec::new();
184 let mut stack: Vec<std::path::PathBuf> = vec![content_dir.to_path_buf()];
185
186 while let Some(dir) = stack.pop() {
187 let Ok(entries) = fs::read_dir(&dir) else {
188 continue;
189 };
190 for entry in entries.flatten() {
191 let path = entry.path();
192 let name = entry.file_name();
194 let name_str = name.to_string_lossy();
195 if name_str.starts_with('.') || name_str == "dist" || name_str == "node_modules" {
196 continue;
197 }
198 if path.is_dir() {
199 stack.push(path);
200 continue;
201 }
202 if path.extension().and_then(|s| s.to_str()) != Some("md") {
203 continue;
204 }
205
206 let rel = match path.strip_prefix(content_dir) {
209 Ok(r) => r,
210 Err(_) => continue,
211 };
212 let rel_str = rel.to_string_lossy().replace('\\', "/");
213 let trimmed = rel_str.trim_end_matches(".md");
214
215 let trimmed = trimmed.strip_suffix("/index").unwrap_or(trimmed);
217
218 let trimmed = normalize_path_as_index(trimmed);
221
222 let url_path = if trimmed.is_empty() || trimmed == "index" {
224 if base_path.is_empty() {
225 "/".to_string()
226 } else {
227 base_path.trim_end_matches('/').to_string() + "/"
228 }
229 } else {
230 let prefix = base_path.trim_end_matches('/');
231 format!("{prefix}/{trimmed}")
232 };
233 pages.push((url_path, path));
234 }
235 }
236
237 pages.sort_by(|a, b| a.0.cmp(&b.0));
238 pages
239}
240
241fn build_sidebar_state(url_path: &str, config: &DocsConfig) -> serde_json::Value {
242 let base = &config.base_path;
243
244 let active_sidebar = config
246 .sidebar_groups
247 .iter()
248 .find(|(prefix, _)| {
249 let full_prefix = format!("{}{}", base, prefix.strip_prefix('/').unwrap_or(prefix));
250 let trimmed = full_prefix.trim_end_matches('/');
251 url_path == trimmed || url_path.starts_with(&format!("{trimmed}/"))
252 })
253 .map(|(_, s)| s.as_slice())
254 .unwrap_or(&config.sidebar);
255
256 let item_to_value = |item: &SidebarItem| -> Value {
258 let item_path = normalize_link(base, &item.link);
259 let active = !item_path.is_empty() && item_path == url_path;
260 let children: Vec<Value> = item
261 .items
262 .iter()
263 .map(|child| {
264 let child_path = normalize_link(base, &child.link);
265 let child_active = !child_path.is_empty() && child_path == url_path;
266 json_obj([
267 ("text", Value::String(child.text.clone())),
268 ("link", Value::String(child_path)),
269 ("active", Value::Bool(child_active)),
270 ("hasChildren", Value::Bool(false)),
271 ("children", Value::Array(vec![])),
272 ])
273 })
274 .collect();
275 json_obj([
276 ("text", Value::String(item.text.clone())),
277 ("link", Value::String(item_path)),
278 ("active", Value::Bool(active)),
279 ("hasChildren", Value::Bool(!children.is_empty())),
280 ("children", Value::Array(children)),
281 ])
282 };
283
284 let sections: Vec<Value> = active_sidebar
285 .iter()
286 .map(|section| {
287 let items: Vec<Value> = section.items.iter().map(item_to_value).collect();
288 json_obj([
289 ("title", Value::String(section.title.clone())),
290 ("items", Value::Array(items)),
291 ])
292 })
293 .collect();
294
295 json_obj([("sections", Value::Array(sections))])
296}
297
298fn find_sidebar_text(url_path: &str, config: &DocsConfig) -> Option<String> {
299 let base = &config.base_path;
300 let all: Vec<&[SidebarSection]> = std::iter::once(config.sidebar.as_slice())
301 .chain(config.sidebar_groups.values().map(|v| v.as_slice()))
302 .collect();
303
304 for sidebar in all {
305 for section in sidebar {
306 let mut stack: Vec<&SidebarItem> = section.items.iter().collect();
308 while let Some(item) = stack.pop() {
309 if !item.link.is_empty() {
310 let item_path = normalize_link(base, &item.link);
311 if item_path == url_path {
312 return Some(item.text.clone());
313 }
314 }
315 for child in &item.items {
316 stack.push(child);
317 }
318 }
319 }
320 }
321 None
322}
323
324fn load_custom_page_states(
331 config: &DocsConfig,
332 config_dir: &Path,
333) -> Result<HashMap<String, Value>> {
334 let mut cache: HashMap<std::path::PathBuf, Value> = HashMap::new();
335 let mut out: HashMap<String, Value> = HashMap::with_capacity(config.custom_pages.len());
336
337 for (link, page) in &config.custom_pages {
338 let inline = page.inline_state();
339 let path = page.state_file();
340
341 if inline.is_some() && path.is_some() {
342 return Err(crate::error::Error::Build(format!(
343 "Custom page {link}: 'state' and 'stateFile' are mutually exclusive — pick one."
344 )));
345 }
346
347 if let Some(value) = inline {
348 out.insert(link.clone(), value.clone());
349 continue;
350 }
351
352 if let Some(rel) = path {
353 let abs = config_dir.join(rel);
354 let key = fs::canonicalize(&abs).unwrap_or_else(|_| abs.clone());
355 let value = if let Some(cached) = cache.get(&key) {
356 cached.clone()
357 } else {
358 let raw = fs::read_to_string(&abs).map_err(|e| {
359 crate::error::Error::Build(format!(
360 "Custom page {link}: cannot read stateFile {}: {e}",
361 abs.display()
362 ))
363 })?;
364 let parsed: Value = serde_json::from_str(&raw).map_err(|e| {
365 crate::error::Error::Build(format!(
366 "Custom page {link}: stateFile {} is not valid JSON: {e}",
367 abs.display()
368 ))
369 })?;
370 cache.insert(key, parsed.clone());
371 parsed
372 };
373 out.insert(link.clone(), value);
374 }
375 }
376
377 Ok(out)
378}
379
380pub fn process_content(
390 config: &DocsConfig,
391 config_dir: &Path,
392 highlighter: &Highlighter,
393 head_injection: &str,
394) -> Result<Vec<PageDescriptor>> {
395 let content_dir = Path::new(&config.content_dir);
396 let base_path = &config.base_path;
397
398 let custom_states = load_custom_page_states(config, config_dir)?;
400
401 let nav_links: Vec<Value> = config
402 .nav
403 .iter()
404 .map(|item| {
405 let (link, section) = if item.link.starts_with("http") {
406 (item.link.clone(), item.link.clone())
410 } else {
411 let full = format!("{}{}", base_path, &item.link[1..]);
412 let slug = item
414 .link
415 .trim_start_matches('/')
416 .split('/')
417 .next()
418 .unwrap_or("")
419 .to_string();
420 (full, slug)
421 };
422 json_obj([
423 ("text", Value::String(item.text.clone())),
424 ("link", Value::String(link)),
425 ("section", Value::String(section)),
426 ])
427 })
428 .collect();
429
430 let mut registry = build_page_registry(content_dir, base_path);
431
432 for page_link in config.custom_pages.keys() {
434 let normalized = if page_link.ends_with('/') {
435 page_link.clone()
436 } else {
437 format!("{}/", page_link)
438 };
439 let url_path = format!("{}{}", base_path, &normalized[1..]);
440 let src_file = format!("{}index.md", &normalized[1..]);
441 let full_path = content_dir.join(&src_file);
442 if !registry.iter().any(|(p, _)| p == &url_path) {
443 registry.push((url_path, full_path));
444 }
445 }
446
447 use rayon::prelude::*;
448
449 let results: Vec<(usize, PageDescriptor)> = registry
455 .par_iter()
456 .enumerate()
457 .map(
458 |(idx, (url_path, full_path))| -> Result<(usize, PageDescriptor)> {
459 let mut is_home = false;
460 let mut html = String::new();
461 let mut title = config.site.title.clone();
462 let mut description = config.site.description.clone();
463 let mut layout = "doc".to_string();
464
465 let logical_path = format!("/{}", &url_path[base_path.len()..]);
467 let custom_entry = config
468 .custom_pages
469 .get(&logical_path)
470 .or_else(|| config.custom_pages.get(logical_path.trim_end_matches('/')));
471
472 if let Some(entry) = custom_entry {
473 html = entry.html().to_string();
474 layout = entry.layout().to_string();
475 } else if full_path.exists() {
476 let raw = fs::read_to_string(full_path)
477 .map_err(|e| crate::error::Error::Io(e.to_string()))?;
478 let (fm, body) = parse_frontmatter(&raw).map_err(|e| {
479 crate::error::Error::Markdown(format!("{}: {e}", full_path.display()))
480 })?;
481
482 is_home = fm.layout.as_deref() == Some("home");
483 layout = if is_home {
484 "home".to_string()
485 } else {
486 fm.layout.unwrap_or_else(|| "doc".to_string())
487 };
488
489 if !is_home {
490 html = render_markdown(body, highlighter, base_path)?;
491 }
492
493 if let Some(d) = fm.description {
494 description = d;
495 }
496
497 let h1 = extract_h1(body);
498 let sidebar_text = find_sidebar_text(url_path, config);
499 title = fm
500 .title
501 .or(h1)
502 .or(sidebar_text)
503 .unwrap_or_else(|| config.site.title.clone());
504 }
505
506 let hero_val = if is_home {
507 config
508 .hero
509 .as_ref()
510 .map(|h| {
511 json_obj([
512 (
513 "text",
514 h.text
515 .as_ref()
516 .map_or(Value::Null, |t| Value::String(t.clone())),
517 ),
518 ("tagline", Value::String(h.tagline.clone())),
519 (
520 "manifesto",
521 h.manifesto
522 .as_ref()
523 .map_or(Value::Null, |m| Value::String(m.clone())),
524 ),
525 (
526 "actions",
527 Value::Array(
528 h.actions
529 .iter()
530 .map(|a| {
531 json_obj([
532 ("text", Value::String(a.text.clone())),
533 ("link", Value::String(a.link.clone())),
534 ("brand", Value::Bool(a.brand)),
535 ])
536 })
537 .collect(),
538 ),
539 ),
540 (
541 "features",
542 Value::Array(
543 h.features
544 .iter()
545 .map(|f| {
546 json_obj([
547 ("icon", Value::String(f.icon.clone())),
548 ("title", Value::String(f.title.clone())),
549 (
550 "description",
551 Value::String(f.description.clone()),
552 ),
553 ])
554 })
555 .collect(),
556 ),
557 ),
558 ])
559 })
560 .unwrap_or(Value::Null)
561 } else {
562 Value::Null
563 };
564
565 let footer_val = config
566 .footer
567 .as_ref()
568 .map(|f| json_obj([("html", Value::String(f.html.clone()))]))
569 .unwrap_or(Value::Null);
570
571 let page_section = logical_path
575 .trim_start_matches('/')
576 .split('/')
577 .next()
578 .unwrap_or("")
579 .to_string();
580
581 let state = json_obj([
582 (
583 "site",
584 json_obj([
585 ("title", Value::String(config.site.title.clone())),
586 ("base", Value::String(base_path.to_string())),
587 ]),
588 ),
589 ("navigation", Value::Array(nav_links.clone())),
590 ("sidebar", build_sidebar_state(url_path, config)),
591 (
592 "page",
593 json_obj([
594 ("title", Value::String(title.clone())),
595 ("description", Value::String(description.clone())),
596 ("content", Value::String(html.clone())),
597 ("isHome", Value::Bool(is_home)),
598 ("layout", Value::String(layout)),
599 ("section", Value::String(page_section)),
600 ]),
601 ),
602 ("hero", hero_val),
603 ("footer", footer_val),
604 ("prev", Value::Null),
605 ("next", Value::Null),
606 (
607 "pageData",
608 custom_states
609 .get(&logical_path)
610 .or_else(|| custom_states.get(logical_path.trim_end_matches('/')))
611 .cloned()
612 .unwrap_or(Value::Null),
613 ),
614 ("headTags", Value::String(head_injection.to_string())),
615 ("label", Value::String("Copy".to_string())),
616 ("icon", Value::String("🌙".to_string())),
617 ]);
618
619 let state = if let Some(Value::Object(extra)) = custom_states
625 .get(&logical_path)
626 .or_else(|| custom_states.get(logical_path.trim_end_matches('/')))
627 {
628 let mut map = match state {
629 Value::Object(m) => m,
630 other => {
631 let mut m = Map::new();
632 m.insert("_root".to_string(), other);
633 m
634 }
635 };
636 for (k, v) in extra {
637 if !RESERVED_STATE_KEYS.contains(&k.as_str()) && !map.contains_key(k) {
638 map.insert(k.clone(), v.clone());
639 }
640 }
641 Value::Object(map)
642 } else {
643 state
644 };
645
646 Ok((
647 idx,
648 PageDescriptor {
649 path: url_path.clone(),
650 is_home,
651 state,
652 },
653 ))
654 },
655 )
656 .collect::<Result<Vec<_>>>()?;
657
658 let resolve_sidebar = |page_path: &str| -> Vec<SidebarItem> {
662 let active = config
663 .sidebar_groups
664 .iter()
665 .find(|(prefix, _)| {
666 let full_prefix = format!(
667 "{}{}",
668 base_path,
669 prefix.strip_prefix('/').unwrap_or(prefix)
670 );
671 let trimmed = full_prefix.trim_end_matches('/');
672 page_path == trimmed || page_path.starts_with(&format!("{trimmed}/"))
673 })
674 .map(|(_, s)| s.as_slice())
675 .unwrap_or(&config.sidebar);
676 active.iter().flat_map(|s| s.items.clone()).collect()
677 };
678
679 let mut pages: Vec<Option<PageDescriptor>> = (0..results.len()).map(|_| None).collect();
684 for (idx, mut desc) in results {
685 let sidebar_items = resolve_sidebar(&desc.path);
686 let flat_links = collect_sidebar_links(&sidebar_items);
687 let all_links: Vec<(String, String)> = flat_links
688 .iter()
689 .map(|link| {
690 let path = normalize_link(base_path, link);
691 let text = find_sidebar_text(&path, config).unwrap_or_default();
692 (path, text)
693 })
694 .collect();
695 if let Some(pos) = all_links.iter().position(|(p, _)| p == &desc.path) {
696 if pos > 0 {
697 let (link, text) = &all_links[pos - 1];
698 desc.state["prev"] = json_obj([
699 ("link", Value::String(link.clone())),
700 ("text", Value::String(text.clone())),
701 ]);
702 }
703 if pos + 1 < all_links.len() {
704 let (link, text) = &all_links[pos + 1];
705 desc.state["next"] = json_obj([
706 ("link", Value::String(link.clone())),
707 ("text", Value::String(text.clone())),
708 ]);
709 }
710 }
711 pages[idx] = Some(desc);
712 }
713 let pages: Vec<PageDescriptor> = pages
714 .into_iter()
715 .enumerate()
716 .map(|(i, slot)| {
717 slot.ok_or_else(|| Error::Build(format!("missing page at registry index {i}")))
718 })
719 .collect::<Result<Vec<_>>>()?;
720
721 Ok(pages)
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727
728 #[test]
731 fn normalize_link_empty_returns_empty() {
732 assert_eq!(normalize_link("/webui/", ""), "");
733 }
734
735 #[test]
736 fn normalize_link_root_slash_returns_base() {
737 assert_eq!(normalize_link("/webui/", "/"), "/webui/");
738 }
739
740 #[test]
741 fn normalize_link_trims_trailing_slash() {
742 assert_eq!(
743 normalize_link("/webui/", "/guide/intro/"),
744 "/webui/guide/intro"
745 );
746 assert_eq!(
747 normalize_link("/webui/", "/guide/intro"),
748 "/webui/guide/intro"
749 );
750 }
751
752 #[test]
753 fn normalize_link_preserves_fragment() {
754 assert_eq!(
755 normalize_link("/webui/", "/guide/intro#section"),
756 "/webui/guide/intro#section"
757 );
758 }
759
760 #[test]
761 fn normalize_link_tolerates_missing_leading_slash() {
762 assert_eq!(
764 normalize_link("/webui/", "guide/intro"),
765 "/webui/guide/intro"
766 );
767 }
768
769 #[test]
770 fn normalize_link_tolerates_non_ascii_first_char() {
771 let out = normalize_link("/webui/", "é-page");
773 assert!(out.ends_with("é-page"), "got {out}");
774 }
775
776 #[test]
779 fn normalize_path_as_index_strips_duplicate_folder_name() {
780 assert_eq!(
781 normalize_path_as_index("webui-button/webui-button"),
782 "webui-button"
783 );
784 }
785
786 #[test]
787 fn normalize_path_as_index_keeps_different_filename() {
788 assert_eq!(
789 normalize_path_as_index("webui-button/usage"),
790 "webui-button/usage"
791 );
792 }
793
794 #[test]
795 fn normalize_path_as_index_nested_folders() {
796 assert_eq!(
797 normalize_path_as_index("components/webui-button/webui-button"),
798 "components/webui-button"
799 );
800 }
801
802 #[test]
803 fn normalize_path_as_index_no_slash_returns_unchanged() {
804 assert_eq!(normalize_path_as_index("webui-button"), "webui-button");
805 }
806
807 #[test]
808 fn normalize_path_as_index_deep_nesting_with_match() {
809 assert_eq!(
810 normalize_path_as_index("a/b/c/webui-card/webui-card"),
811 "a/b/c/webui-card"
812 );
813 }
814
815 #[test]
816 fn normalize_path_as_index_deep_nesting_without_match() {
817 assert_eq!(
818 normalize_path_as_index("a/b/c/webui-card/examples"),
819 "a/b/c/webui-card/examples"
820 );
821 }
822
823 #[test]
826 fn parse_frontmatter_no_frontmatter_returns_full_body() {
827 let (fm, body) = parse_frontmatter("# Hello\nworld\n").expect("ok");
828 assert!(fm.title.is_none());
829 assert!(fm.layout.is_none());
830 assert_eq!(body, "# Hello\nworld\n");
831 }
832
833 #[test]
834 fn parse_frontmatter_valid_yaml_extracts_fields() {
835 let raw = "---\ntitle: Hello\ndescription: World\nlayout: home\n---\n# Body\n";
836 let (fm, body) = parse_frontmatter(raw).expect("ok");
837 assert_eq!(fm.title.as_deref(), Some("Hello"));
838 assert_eq!(fm.description.as_deref(), Some("World"));
839 assert_eq!(fm.layout.as_deref(), Some("home"));
840 assert_eq!(body, "# Body\n");
841 }
842
843 #[test]
844 fn parse_frontmatter_handles_crlf_line_endings() {
845 let raw = "---\r\ntitle: CRLF\r\nlayout: doc\r\n---\r\n# Body\r\n";
846 let (fm, body) = parse_frontmatter(raw).expect("ok");
847 assert_eq!(fm.title.as_deref(), Some("CRLF"));
848 assert_eq!(fm.layout.as_deref(), Some("doc"));
849 assert_eq!(body, "# Body\r\n");
850 }
851
852 #[test]
853 fn parse_frontmatter_malformed_yaml_returns_error() {
854 let raw = "---\ntitle: : : :\n---\nBody\n";
856 let result = parse_frontmatter(raw);
857 assert!(result.is_err(), "expected error, got {result:?}");
858 }
859}