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 build_page_registry(content_dir: &Path, base_path: &str) -> Vec<(String, std::path::PathBuf)> {
160 let mut pages = Vec::new();
161 let mut stack: Vec<std::path::PathBuf> = vec![content_dir.to_path_buf()];
162
163 while let Some(dir) = stack.pop() {
164 let Ok(entries) = fs::read_dir(&dir) else {
165 continue;
166 };
167 for entry in entries.flatten() {
168 let path = entry.path();
169 let name = entry.file_name();
171 let name_str = name.to_string_lossy();
172 if name_str.starts_with('.') || name_str == "dist" || name_str == "node_modules" {
173 continue;
174 }
175 if path.is_dir() {
176 stack.push(path);
177 continue;
178 }
179 if path.extension().and_then(|s| s.to_str()) != Some("md") {
180 continue;
181 }
182
183 let rel = match path.strip_prefix(content_dir) {
186 Ok(r) => r,
187 Err(_) => continue,
188 };
189 let rel_str = rel.to_string_lossy().replace('\\', "/");
190 let trimmed = rel_str.trim_end_matches(".md");
191 let trimmed = trimmed.strip_suffix("/index").unwrap_or(trimmed);
192 let url_path = if trimmed.is_empty() || trimmed == "index" {
194 if base_path.is_empty() {
195 "/".to_string()
196 } else {
197 base_path.trim_end_matches('/').to_string() + "/"
198 }
199 } else {
200 let prefix = base_path.trim_end_matches('/');
201 format!("{prefix}/{trimmed}")
202 };
203 pages.push((url_path, path));
204 }
205 }
206
207 pages.sort_by(|a, b| a.0.cmp(&b.0));
208 pages
209}
210
211fn build_sidebar_state(url_path: &str, config: &DocsConfig) -> serde_json::Value {
212 let base = &config.base_path;
213
214 let active_sidebar = config
216 .sidebar_groups
217 .iter()
218 .find(|(prefix, _)| {
219 let full_prefix = format!("{}{}", base, prefix.strip_prefix('/').unwrap_or(prefix));
220 let trimmed = full_prefix.trim_end_matches('/');
221 url_path == trimmed || url_path.starts_with(&format!("{trimmed}/"))
222 })
223 .map(|(_, s)| s.as_slice())
224 .unwrap_or(&config.sidebar);
225
226 let item_to_value = |item: &SidebarItem| -> Value {
228 let item_path = normalize_link(base, &item.link);
229 let active = !item_path.is_empty() && item_path == url_path;
230 let children: Vec<Value> = item
231 .items
232 .iter()
233 .map(|child| {
234 let child_path = normalize_link(base, &child.link);
235 let child_active = !child_path.is_empty() && child_path == url_path;
236 json_obj([
237 ("text", Value::String(child.text.clone())),
238 ("link", Value::String(child_path)),
239 ("active", Value::Bool(child_active)),
240 ("hasChildren", Value::Bool(false)),
241 ("children", Value::Array(vec![])),
242 ])
243 })
244 .collect();
245 json_obj([
246 ("text", Value::String(item.text.clone())),
247 ("link", Value::String(item_path)),
248 ("active", Value::Bool(active)),
249 ("hasChildren", Value::Bool(!children.is_empty())),
250 ("children", Value::Array(children)),
251 ])
252 };
253
254 let sections: Vec<Value> = active_sidebar
255 .iter()
256 .map(|section| {
257 let items: Vec<Value> = section.items.iter().map(item_to_value).collect();
258 json_obj([
259 ("title", Value::String(section.title.clone())),
260 ("items", Value::Array(items)),
261 ])
262 })
263 .collect();
264
265 json_obj([("sections", Value::Array(sections))])
266}
267
268fn find_sidebar_text(url_path: &str, config: &DocsConfig) -> Option<String> {
269 let base = &config.base_path;
270 let all: Vec<&[SidebarSection]> = std::iter::once(config.sidebar.as_slice())
271 .chain(config.sidebar_groups.values().map(|v| v.as_slice()))
272 .collect();
273
274 for sidebar in all {
275 for section in sidebar {
276 let mut stack: Vec<&SidebarItem> = section.items.iter().collect();
278 while let Some(item) = stack.pop() {
279 if !item.link.is_empty() {
280 let item_path = normalize_link(base, &item.link);
281 if item_path == url_path {
282 return Some(item.text.clone());
283 }
284 }
285 for child in &item.items {
286 stack.push(child);
287 }
288 }
289 }
290 }
291 None
292}
293
294fn load_custom_page_states(
301 config: &DocsConfig,
302 config_dir: &Path,
303) -> Result<HashMap<String, Value>> {
304 let mut cache: HashMap<std::path::PathBuf, Value> = HashMap::new();
305 let mut out: HashMap<String, Value> = HashMap::with_capacity(config.custom_pages.len());
306
307 for (link, page) in &config.custom_pages {
308 let inline = page.inline_state();
309 let path = page.state_file();
310
311 if inline.is_some() && path.is_some() {
312 return Err(crate::error::Error::Build(format!(
313 "Custom page {link}: 'state' and 'stateFile' are mutually exclusive — pick one."
314 )));
315 }
316
317 if let Some(value) = inline {
318 out.insert(link.clone(), value.clone());
319 continue;
320 }
321
322 if let Some(rel) = path {
323 let abs = config_dir.join(rel);
324 let key = fs::canonicalize(&abs).unwrap_or_else(|_| abs.clone());
325 let value = if let Some(cached) = cache.get(&key) {
326 cached.clone()
327 } else {
328 let raw = fs::read_to_string(&abs).map_err(|e| {
329 crate::error::Error::Build(format!(
330 "Custom page {link}: cannot read stateFile {}: {e}",
331 abs.display()
332 ))
333 })?;
334 let parsed: Value = serde_json::from_str(&raw).map_err(|e| {
335 crate::error::Error::Build(format!(
336 "Custom page {link}: stateFile {} is not valid JSON: {e}",
337 abs.display()
338 ))
339 })?;
340 cache.insert(key, parsed.clone());
341 parsed
342 };
343 out.insert(link.clone(), value);
344 }
345 }
346
347 Ok(out)
348}
349
350pub fn process_content(
360 config: &DocsConfig,
361 config_dir: &Path,
362 highlighter: &Highlighter,
363 head_injection: &str,
364) -> Result<Vec<PageDescriptor>> {
365 let content_dir = Path::new(&config.content_dir);
366 let base_path = &config.base_path;
367
368 let custom_states = load_custom_page_states(config, config_dir)?;
370
371 let nav_links: Vec<Value> = config
372 .nav
373 .iter()
374 .map(|item| {
375 let (link, section) = if item.link.starts_with("http") {
376 (item.link.clone(), item.link.clone())
380 } else {
381 let full = format!("{}{}", base_path, &item.link[1..]);
382 let slug = item
384 .link
385 .trim_start_matches('/')
386 .split('/')
387 .next()
388 .unwrap_or("")
389 .to_string();
390 (full, slug)
391 };
392 json_obj([
393 ("text", Value::String(item.text.clone())),
394 ("link", Value::String(link)),
395 ("section", Value::String(section)),
396 ])
397 })
398 .collect();
399
400 let mut registry = build_page_registry(content_dir, base_path);
401
402 for page_link in config.custom_pages.keys() {
404 let normalized = if page_link.ends_with('/') {
405 page_link.clone()
406 } else {
407 format!("{}/", page_link)
408 };
409 let url_path = format!("{}{}", base_path, &normalized[1..]);
410 let src_file = format!("{}index.md", &normalized[1..]);
411 let full_path = content_dir.join(&src_file);
412 if !registry.iter().any(|(p, _)| p == &url_path) {
413 registry.push((url_path, full_path));
414 }
415 }
416
417 use rayon::prelude::*;
418
419 let results: Vec<(usize, PageDescriptor)> = registry
425 .par_iter()
426 .enumerate()
427 .map(
428 |(idx, (url_path, full_path))| -> Result<(usize, PageDescriptor)> {
429 let mut is_home = false;
430 let mut html = String::new();
431 let mut title = config.site.title.clone();
432 let mut description = config.site.description.clone();
433 let mut layout = "doc".to_string();
434
435 let logical_path = format!("/{}", &url_path[base_path.len()..]);
437 let custom_entry = config
438 .custom_pages
439 .get(&logical_path)
440 .or_else(|| config.custom_pages.get(logical_path.trim_end_matches('/')));
441
442 if let Some(entry) = custom_entry {
443 html = entry.html().to_string();
444 layout = entry.layout().to_string();
445 } else if full_path.exists() {
446 let raw = fs::read_to_string(full_path)
447 .map_err(|e| crate::error::Error::Io(e.to_string()))?;
448 let (fm, body) = parse_frontmatter(&raw).map_err(|e| {
449 crate::error::Error::Markdown(format!("{}: {e}", full_path.display()))
450 })?;
451
452 is_home = fm.layout.as_deref() == Some("home");
453 layout = if is_home {
454 "home".to_string()
455 } else {
456 fm.layout.unwrap_or_else(|| "doc".to_string())
457 };
458
459 if !is_home {
460 html = render_markdown(body, highlighter, base_path)?;
461 }
462
463 if let Some(d) = fm.description {
464 description = d;
465 }
466
467 let h1 = extract_h1(body);
468 let sidebar_text = find_sidebar_text(url_path, config);
469 title = fm
470 .title
471 .or(h1)
472 .or(sidebar_text)
473 .unwrap_or_else(|| config.site.title.clone());
474 }
475
476 let hero_val = if is_home {
477 config
478 .hero
479 .as_ref()
480 .map(|h| {
481 json_obj([
482 (
483 "text",
484 h.text
485 .as_ref()
486 .map_or(Value::Null, |t| Value::String(t.clone())),
487 ),
488 ("tagline", Value::String(h.tagline.clone())),
489 (
490 "manifesto",
491 h.manifesto
492 .as_ref()
493 .map_or(Value::Null, |m| Value::String(m.clone())),
494 ),
495 (
496 "actions",
497 Value::Array(
498 h.actions
499 .iter()
500 .map(|a| {
501 json_obj([
502 ("text", Value::String(a.text.clone())),
503 ("link", Value::String(a.link.clone())),
504 ("brand", Value::Bool(a.brand)),
505 ])
506 })
507 .collect(),
508 ),
509 ),
510 (
511 "features",
512 Value::Array(
513 h.features
514 .iter()
515 .map(|f| {
516 json_obj([
517 ("icon", Value::String(f.icon.clone())),
518 ("title", Value::String(f.title.clone())),
519 (
520 "description",
521 Value::String(f.description.clone()),
522 ),
523 ])
524 })
525 .collect(),
526 ),
527 ),
528 ])
529 })
530 .unwrap_or(Value::Null)
531 } else {
532 Value::Null
533 };
534
535 let footer_val = config
536 .footer
537 .as_ref()
538 .map(|f| json_obj([("html", Value::String(f.html.clone()))]))
539 .unwrap_or(Value::Null);
540
541 let page_section = logical_path
545 .trim_start_matches('/')
546 .split('/')
547 .next()
548 .unwrap_or("")
549 .to_string();
550
551 let state = json_obj([
552 (
553 "site",
554 json_obj([
555 ("title", Value::String(config.site.title.clone())),
556 ("base", Value::String(base_path.to_string())),
557 ]),
558 ),
559 ("navigation", Value::Array(nav_links.clone())),
560 ("sidebar", build_sidebar_state(url_path, config)),
561 (
562 "page",
563 json_obj([
564 ("title", Value::String(title.clone())),
565 ("description", Value::String(description.clone())),
566 ("content", Value::String(html.clone())),
567 ("isHome", Value::Bool(is_home)),
568 ("layout", Value::String(layout)),
569 ("section", Value::String(page_section)),
570 ]),
571 ),
572 ("hero", hero_val),
573 ("footer", footer_val),
574 ("prev", Value::Null),
575 ("next", Value::Null),
576 (
577 "pageData",
578 custom_states
579 .get(&logical_path)
580 .or_else(|| custom_states.get(logical_path.trim_end_matches('/')))
581 .cloned()
582 .unwrap_or(Value::Null),
583 ),
584 ("headTags", Value::String(head_injection.to_string())),
585 ("label", Value::String("Copy".to_string())),
586 ("icon", Value::String("🌙".to_string())),
587 ]);
588
589 let state = if let Some(Value::Object(extra)) = custom_states
595 .get(&logical_path)
596 .or_else(|| custom_states.get(logical_path.trim_end_matches('/')))
597 {
598 let mut map = match state {
599 Value::Object(m) => m,
600 other => {
601 let mut m = Map::new();
602 m.insert("_root".to_string(), other);
603 m
604 }
605 };
606 for (k, v) in extra {
607 if !RESERVED_STATE_KEYS.contains(&k.as_str()) && !map.contains_key(k) {
608 map.insert(k.clone(), v.clone());
609 }
610 }
611 Value::Object(map)
612 } else {
613 state
614 };
615
616 Ok((
617 idx,
618 PageDescriptor {
619 path: url_path.clone(),
620 is_home,
621 state,
622 },
623 ))
624 },
625 )
626 .collect::<Result<Vec<_>>>()?;
627
628 let resolve_sidebar = |page_path: &str| -> Vec<SidebarItem> {
632 let active = config
633 .sidebar_groups
634 .iter()
635 .find(|(prefix, _)| {
636 let full_prefix = format!(
637 "{}{}",
638 base_path,
639 prefix.strip_prefix('/').unwrap_or(prefix)
640 );
641 let trimmed = full_prefix.trim_end_matches('/');
642 page_path == trimmed || page_path.starts_with(&format!("{trimmed}/"))
643 })
644 .map(|(_, s)| s.as_slice())
645 .unwrap_or(&config.sidebar);
646 active.iter().flat_map(|s| s.items.clone()).collect()
647 };
648
649 let mut pages: Vec<Option<PageDescriptor>> = (0..results.len()).map(|_| None).collect();
654 for (idx, mut desc) in results {
655 let sidebar_items = resolve_sidebar(&desc.path);
656 let flat_links = collect_sidebar_links(&sidebar_items);
657 let all_links: Vec<(String, String)> = flat_links
658 .iter()
659 .map(|link| {
660 let path = normalize_link(base_path, link);
661 let text = find_sidebar_text(&path, config).unwrap_or_default();
662 (path, text)
663 })
664 .collect();
665 if let Some(pos) = all_links.iter().position(|(p, _)| p == &desc.path) {
666 if pos > 0 {
667 let (link, text) = &all_links[pos - 1];
668 desc.state["prev"] = json_obj([
669 ("link", Value::String(link.clone())),
670 ("text", Value::String(text.clone())),
671 ]);
672 }
673 if pos + 1 < all_links.len() {
674 let (link, text) = &all_links[pos + 1];
675 desc.state["next"] = json_obj([
676 ("link", Value::String(link.clone())),
677 ("text", Value::String(text.clone())),
678 ]);
679 }
680 }
681 pages[idx] = Some(desc);
682 }
683 let pages: Vec<PageDescriptor> = pages
684 .into_iter()
685 .enumerate()
686 .map(|(i, slot)| {
687 slot.ok_or_else(|| Error::Build(format!("missing page at registry index {i}")))
688 })
689 .collect::<Result<Vec<_>>>()?;
690
691 Ok(pages)
692}
693
694#[cfg(test)]
695mod tests {
696 use super::*;
697
698 #[test]
701 fn normalize_link_empty_returns_empty() {
702 assert_eq!(normalize_link("/webui/", ""), "");
703 }
704
705 #[test]
706 fn normalize_link_root_slash_returns_base() {
707 assert_eq!(normalize_link("/webui/", "/"), "/webui/");
708 }
709
710 #[test]
711 fn normalize_link_trims_trailing_slash() {
712 assert_eq!(
713 normalize_link("/webui/", "/guide/intro/"),
714 "/webui/guide/intro"
715 );
716 assert_eq!(
717 normalize_link("/webui/", "/guide/intro"),
718 "/webui/guide/intro"
719 );
720 }
721
722 #[test]
723 fn normalize_link_preserves_fragment() {
724 assert_eq!(
725 normalize_link("/webui/", "/guide/intro#section"),
726 "/webui/guide/intro#section"
727 );
728 }
729
730 #[test]
731 fn normalize_link_tolerates_missing_leading_slash() {
732 assert_eq!(
734 normalize_link("/webui/", "guide/intro"),
735 "/webui/guide/intro"
736 );
737 }
738
739 #[test]
740 fn normalize_link_tolerates_non_ascii_first_char() {
741 let out = normalize_link("/webui/", "é-page");
743 assert!(out.ends_with("é-page"), "got {out}");
744 }
745
746 #[test]
749 fn parse_frontmatter_no_frontmatter_returns_full_body() {
750 let (fm, body) = parse_frontmatter("# Hello\nworld\n").expect("ok");
751 assert!(fm.title.is_none());
752 assert!(fm.layout.is_none());
753 assert_eq!(body, "# Hello\nworld\n");
754 }
755
756 #[test]
757 fn parse_frontmatter_valid_yaml_extracts_fields() {
758 let raw = "---\ntitle: Hello\ndescription: World\nlayout: home\n---\n# Body\n";
759 let (fm, body) = parse_frontmatter(raw).expect("ok");
760 assert_eq!(fm.title.as_deref(), Some("Hello"));
761 assert_eq!(fm.description.as_deref(), Some("World"));
762 assert_eq!(fm.layout.as_deref(), Some("home"));
763 assert_eq!(body, "# Body\n");
764 }
765
766 #[test]
767 fn parse_frontmatter_handles_crlf_line_endings() {
768 let raw = "---\r\ntitle: CRLF\r\nlayout: doc\r\n---\r\n# Body\r\n";
769 let (fm, body) = parse_frontmatter(raw).expect("ok");
770 assert_eq!(fm.title.as_deref(), Some("CRLF"));
771 assert_eq!(fm.layout.as_deref(), Some("doc"));
772 assert_eq!(body, "# Body\r\n");
773 }
774
775 #[test]
776 fn parse_frontmatter_malformed_yaml_returns_error() {
777 let raw = "---\ntitle: : : :\n---\nBody\n";
779 let result = parse_frontmatter(raw);
780 assert!(result.is_err(), "expected error, got {result:?}");
781 }
782}