1use std::collections::HashMap;
7use std::fs;
8use std::path::Path;
9
10use serde_json::{Map, Value};
11
12use crate::error::{Error, Result};
13use crate::markdown::{render_markdown, Highlighter};
14use crate::state::{load_render_states, merge_page_state, LoadedStates};
15use crate::types::{DocsConfig, PageDescriptor, SidebarItem, SidebarSection};
16
17fn normalize_link(base: &str, link: &str) -> String {
23 if link.is_empty() {
24 return String::new();
25 }
26 let stripped = link.strip_prefix('/').unwrap_or(link);
30 if link.contains('#') {
31 return format!("{base}{stripped}");
33 }
34 let cleaned = stripped.trim_end_matches('/');
35 if cleaned.is_empty() {
36 base.to_string()
38 } else {
39 format!("{base}{cleaned}")
40 }
41}
42
43fn json_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
45 let mut map = Map::with_capacity(N);
46 for (k, v) in entries {
47 map.insert(k.to_string(), v);
48 }
49 Value::Object(map)
50}
51
52#[derive(Debug)]
54struct Frontmatter {
55 title: Option<String>,
56 description: Option<String>,
57 layout: Option<String>,
58}
59
60fn parse_frontmatter(content: &str) -> Result<(Frontmatter, &str)> {
61 if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
62 return Ok((
63 Frontmatter {
64 title: None,
65 description: None,
66 layout: None,
67 },
68 content,
69 ));
70 }
71
72 let body_start_marker = if content.starts_with("---\r\n") { 5 } else { 4 };
74 let after_open = &content[body_start_marker..];
75
76 let (yaml_end_offset, close_skip) = if let Some(i) = after_open.find("\r\n---") {
78 (i, "\r\n---".len())
79 } else if let Some(i) = after_open.find("\n---") {
80 (i, "\n---".len())
81 } else {
82 (after_open.len(), 0)
83 };
84
85 let yaml_str = &after_open[..yaml_end_offset];
86 let body_offset = body_start_marker + yaml_end_offset + close_skip;
87 let body = if body_offset < content.len() {
88 let rest = &content[body_offset..];
90 rest.strip_prefix("\r\n")
91 .or_else(|| rest.strip_prefix('\n'))
92 .unwrap_or(rest)
93 } else {
94 ""
95 };
96
97 let yaml: HashMap<String, serde_yaml::Value> = serde_yaml::from_str(yaml_str)
98 .map_err(|e| crate::error::Error::Markdown(format!("Invalid frontmatter YAML: {e}")))?;
99
100 Ok((
101 Frontmatter {
102 title: yaml.get("title").and_then(|v| v.as_str()).map(String::from),
103 description: yaml
104 .get("description")
105 .and_then(|v| v.as_str())
106 .map(String::from),
107 layout: yaml
108 .get("layout")
109 .and_then(|v| v.as_str())
110 .map(String::from),
111 },
112 body,
113 ))
114}
115
116fn extract_h1(content: &str) -> Option<String> {
117 for line in content.lines() {
118 if let Some(heading) = line.strip_prefix("# ") {
119 return Some(heading.trim().to_string());
120 }
121 }
122 None
123}
124
125fn collect_sidebar_links(items: &[SidebarItem]) -> Vec<&str> {
127 let mut links = Vec::new();
128 let mut stack: Vec<&SidebarItem> = items.iter().rev().collect();
129 while let Some(item) = stack.pop() {
130 if !item.link.is_empty() {
131 links.push(item.link.as_str());
132 }
133 for child in item.items.iter().rev() {
134 stack.push(child);
135 }
136 }
137 links
138}
139
140fn normalize_path_as_index(path: &str) -> &str {
148 let Some(slash_pos) = path.rfind('/') else {
150 return path;
151 };
152
153 let parent = &path[..slash_pos];
154 let filename = &path[slash_pos + 1..];
155
156 let folder_name = parent.rsplit('/').next().unwrap_or("");
158
159 if folder_name == filename {
161 parent
162 } else {
163 path
164 }
165}
166
167fn build_page_registry(content_dir: &Path, base_path: &str) -> Vec<(String, std::path::PathBuf)> {
168 let mut pages = Vec::new();
169 let mut stack: Vec<std::path::PathBuf> = vec![content_dir.to_path_buf()];
170
171 while let Some(dir) = stack.pop() {
172 let Ok(entries) = fs::read_dir(&dir) else {
173 continue;
174 };
175 for entry in entries.flatten() {
176 let path = entry.path();
177 let name = entry.file_name();
179 let name_str = name.to_string_lossy();
180 if name_str.starts_with('.') || name_str == "dist" || name_str == "node_modules" {
181 continue;
182 }
183 if path.is_dir() {
184 stack.push(path);
185 continue;
186 }
187 if path.extension().and_then(|s| s.to_str()) != Some("md") {
188 continue;
189 }
190
191 let rel = match path.strip_prefix(content_dir) {
194 Ok(r) => r,
195 Err(_) => continue,
196 };
197 let rel_str = rel.to_string_lossy().replace('\\', "/");
198 let trimmed = rel_str.trim_end_matches(".md");
199
200 let trimmed = trimmed.strip_suffix("/index").unwrap_or(trimmed);
202
203 let trimmed = normalize_path_as_index(trimmed);
206
207 let url_path = if trimmed.is_empty() || trimmed == "index" {
209 if base_path.is_empty() {
210 "/".to_string()
211 } else {
212 base_path.trim_end_matches('/').to_string() + "/"
213 }
214 } else {
215 let prefix = base_path.trim_end_matches('/');
216 format!("{prefix}/{trimmed}")
217 };
218 pages.push((url_path, path));
219 }
220 }
221
222 pages.sort_by(|a, b| a.0.cmp(&b.0));
223 pages
224}
225
226fn build_sidebar_state(url_path: &str, config: &DocsConfig) -> serde_json::Value {
227 let base = &config.base_path;
228
229 let active_sidebar = config
231 .sidebar_groups
232 .iter()
233 .find(|(prefix, _)| {
234 let full_prefix = format!("{}{}", base, prefix.strip_prefix('/').unwrap_or(prefix));
235 let trimmed = full_prefix.trim_end_matches('/');
236 url_path == trimmed || url_path.starts_with(&format!("{trimmed}/"))
237 })
238 .map(|(_, s)| s.as_slice())
239 .unwrap_or(&config.sidebar);
240
241 let item_to_value = |item: &SidebarItem| -> Value {
243 let item_path = normalize_link(base, &item.link);
244 let active = !item_path.is_empty() && item_path == url_path;
245 let children: Vec<Value> = item
246 .items
247 .iter()
248 .map(|child| {
249 let child_path = normalize_link(base, &child.link);
250 let child_active = !child_path.is_empty() && child_path == url_path;
251 json_obj([
252 ("text", Value::String(child.text.clone())),
253 ("link", Value::String(child_path)),
254 ("active", Value::Bool(child_active)),
255 ("hasChildren", Value::Bool(false)),
256 ("children", Value::Array(vec![])),
257 ])
258 })
259 .collect();
260 json_obj([
261 ("text", Value::String(item.text.clone())),
262 ("link", Value::String(item_path)),
263 ("active", Value::Bool(active)),
264 ("hasChildren", Value::Bool(!children.is_empty())),
265 ("children", Value::Array(children)),
266 ])
267 };
268
269 let sections: Vec<Value> = active_sidebar
270 .iter()
271 .map(|section| {
272 let items: Vec<Value> = section.items.iter().map(item_to_value).collect();
273 json_obj([
274 ("title", Value::String(section.title.clone())),
275 ("items", Value::Array(items)),
276 ])
277 })
278 .collect();
279
280 json_obj([("sections", Value::Array(sections))])
281}
282
283fn find_sidebar_text(url_path: &str, config: &DocsConfig) -> Option<String> {
284 let base = &config.base_path;
285 let all: Vec<&[SidebarSection]> = std::iter::once(config.sidebar.as_slice())
286 .chain(config.sidebar_groups.values().map(|v| v.as_slice()))
287 .collect();
288
289 for sidebar in all {
290 for section in sidebar {
291 let mut stack: Vec<&SidebarItem> = section.items.iter().collect();
293 while let Some(item) = stack.pop() {
294 if !item.link.is_empty() {
295 let item_path = normalize_link(base, &item.link);
296 if item_path == url_path {
297 return Some(item.text.clone());
298 }
299 }
300 for child in &item.items {
301 stack.push(child);
302 }
303 }
304 }
305 }
306 None
307}
308
309#[allow(dead_code)] pub fn process_content(
320 config: &DocsConfig,
321 config_dir: &Path,
322 highlighter: &Highlighter,
323 head_injection: &str,
324) -> Result<Vec<PageDescriptor>> {
325 let states = load_render_states(config, config_dir)?;
326 process_content_with_states(config, highlighter, head_injection, &states)
327}
328
329pub(crate) fn process_content_with_states(
330 config: &DocsConfig,
331 highlighter: &Highlighter,
332 head_injection: &str,
333 states: &LoadedStates,
334) -> Result<Vec<PageDescriptor>> {
335 let content_dir = Path::new(&config.content_dir);
336 let base_path = &config.base_path;
337
338 let nav_links: Vec<Value> = config
339 .nav
340 .iter()
341 .map(|item| {
342 let (link, section) = if item.link.starts_with("http") {
343 (item.link.clone(), item.link.clone())
347 } else {
348 let full = format!("{}{}", base_path, &item.link[1..]);
349 let slug = item
351 .link
352 .trim_start_matches('/')
353 .split('/')
354 .next()
355 .unwrap_or("")
356 .to_string();
357 (full, slug)
358 };
359 json_obj([
360 ("text", Value::String(item.text.clone())),
361 ("link", Value::String(link)),
362 ("section", Value::String(section)),
363 ])
364 })
365 .collect();
366
367 let mut registry = build_page_registry(content_dir, base_path);
368
369 for page_link in config.custom_pages.keys() {
371 let normalized = if page_link.ends_with('/') {
372 page_link.clone()
373 } else {
374 format!("{}/", page_link)
375 };
376 let url_path = format!("{}{}", base_path, &normalized[1..]);
377 let src_file = format!("{}index.md", &normalized[1..]);
378 let full_path = content_dir.join(&src_file);
379 if !registry.iter().any(|(p, _)| p == &url_path) {
380 registry.push((url_path, full_path));
381 }
382 }
383
384 use rayon::prelude::*;
385
386 let results: Vec<(usize, PageDescriptor)> = registry
392 .par_iter()
393 .enumerate()
394 .map(
395 |(idx, (url_path, full_path))| -> Result<(usize, PageDescriptor)> {
396 let mut is_home = false;
397 let mut html = String::new();
398 let mut title = config.site.title.clone();
399 let mut description = config.site.description.clone();
400 let mut layout = "doc".to_string();
401
402 let logical_path = format!("/{}", &url_path[base_path.len()..]);
404 let custom_entry = config
405 .custom_pages
406 .get(&logical_path)
407 .or_else(|| config.custom_pages.get(logical_path.trim_end_matches('/')));
408 let custom_state = states.custom_page_state(&logical_path);
409
410 if let Some(entry) = custom_entry {
411 html = entry.html().to_string();
412 layout = entry.layout().to_string();
413 } else if full_path.exists() {
414 let raw = fs::read_to_string(full_path)
415 .map_err(|e| crate::error::Error::Io(e.to_string()))?;
416 let (fm, body) = parse_frontmatter(&raw).map_err(|e| {
417 crate::error::Error::Markdown(format!("{}: {e}", full_path.display()))
418 })?;
419
420 is_home = fm.layout.as_deref() == Some("home");
421 layout = if is_home {
422 "home".to_string()
423 } else {
424 fm.layout.unwrap_or_else(|| "doc".to_string())
425 };
426
427 if !is_home {
428 let page_url = if url_path.ends_with('/') {
433 url_path.to_string()
434 } else {
435 format!("{url_path}/")
436 };
437 html = render_markdown(body, highlighter, base_path, &page_url)?;
438 }
439
440 if let Some(d) = fm.description {
441 description = d;
442 }
443
444 let h1 = extract_h1(body);
445 let sidebar_text = find_sidebar_text(url_path, config);
446 title = fm
447 .title
448 .or(h1)
449 .or(sidebar_text)
450 .unwrap_or_else(|| config.site.title.clone());
451 }
452
453 let hero_val = if is_home {
454 config
455 .hero
456 .as_ref()
457 .map(|h| {
458 json_obj([
459 (
460 "text",
461 h.text
462 .as_ref()
463 .map_or(Value::Null, |t| Value::String(t.clone())),
464 ),
465 ("tagline", Value::String(h.tagline.clone())),
466 (
467 "manifesto",
468 h.manifesto
469 .as_ref()
470 .map_or(Value::Null, |m| Value::String(m.clone())),
471 ),
472 (
473 "actions",
474 Value::Array(
475 h.actions
476 .iter()
477 .map(|a| {
478 json_obj([
479 ("text", Value::String(a.text.clone())),
480 ("link", Value::String(a.link.clone())),
481 ("brand", Value::Bool(a.brand)),
482 ])
483 })
484 .collect(),
485 ),
486 ),
487 (
488 "features",
489 Value::Array(
490 h.features
491 .iter()
492 .map(|f| {
493 json_obj([
494 ("icon", Value::String(f.icon.clone())),
495 ("title", Value::String(f.title.clone())),
496 (
497 "description",
498 Value::String(f.description.clone()),
499 ),
500 ])
501 })
502 .collect(),
503 ),
504 ),
505 ])
506 })
507 .unwrap_or(Value::Null)
508 } else {
509 Value::Null
510 };
511
512 let footer_val = config
513 .footer
514 .as_ref()
515 .map(|f| json_obj([("html", Value::String(f.html.clone()))]))
516 .unwrap_or(Value::Null);
517
518 let page_section = logical_path
522 .trim_start_matches('/')
523 .split('/')
524 .next()
525 .unwrap_or("")
526 .to_string();
527
528 let state = json_obj([
529 (
530 "site",
531 json_obj([
532 ("title", Value::String(config.site.title.clone())),
533 ("base", Value::String(base_path.to_string())),
534 ]),
535 ),
536 ("navigation", Value::Array(nav_links.clone())),
537 ("sidebar", build_sidebar_state(url_path, config)),
538 (
539 "page",
540 json_obj([
541 ("title", Value::String(title.clone())),
542 ("description", Value::String(description.clone())),
543 ("content", Value::String(html.clone())),
544 ("isHome", Value::Bool(is_home)),
545 ("layout", Value::String(layout)),
546 ("section", Value::String(page_section)),
547 ]),
548 ),
549 ("hero", hero_val),
550 ("footer", footer_val),
551 ("prev", Value::Null),
552 ("next", Value::Null),
553 ("pageData", custom_state.cloned().unwrap_or(Value::Null)),
554 ("headTags", Value::String(head_injection.to_string())),
555 ("label", Value::String("Copy".to_string())),
556 ("icon", Value::String("🌙".to_string())),
557 ]);
558
559 let state = merge_page_state(state, states.global(), custom_state);
564
565 Ok((
566 idx,
567 PageDescriptor {
568 path: url_path.clone(),
569 is_home,
570 state,
571 },
572 ))
573 },
574 )
575 .collect::<Result<Vec<_>>>()?;
576
577 let resolve_sidebar = |page_path: &str| -> Vec<SidebarItem> {
581 let active = config
582 .sidebar_groups
583 .iter()
584 .find(|(prefix, _)| {
585 let full_prefix = format!(
586 "{}{}",
587 base_path,
588 prefix.strip_prefix('/').unwrap_or(prefix)
589 );
590 let trimmed = full_prefix.trim_end_matches('/');
591 page_path == trimmed || page_path.starts_with(&format!("{trimmed}/"))
592 })
593 .map(|(_, s)| s.as_slice())
594 .unwrap_or(&config.sidebar);
595 active.iter().flat_map(|s| s.items.clone()).collect()
596 };
597
598 let mut pages: Vec<Option<PageDescriptor>> = (0..results.len()).map(|_| None).collect();
603 for (idx, mut desc) in results {
604 let sidebar_items = resolve_sidebar(&desc.path);
605 let flat_links = collect_sidebar_links(&sidebar_items);
606 let all_links: Vec<(String, String)> = flat_links
607 .iter()
608 .map(|link| {
609 let path = normalize_link(base_path, link);
610 let text = find_sidebar_text(&path, config).unwrap_or_default();
611 (path, text)
612 })
613 .collect();
614 if let Some(pos) = all_links.iter().position(|(p, _)| p == &desc.path) {
615 if pos > 0 {
616 let (link, text) = &all_links[pos - 1];
617 desc.state["prev"] = json_obj([
618 ("link", Value::String(link.clone())),
619 ("text", Value::String(text.clone())),
620 ]);
621 }
622 if pos + 1 < all_links.len() {
623 let (link, text) = &all_links[pos + 1];
624 desc.state["next"] = json_obj([
625 ("link", Value::String(link.clone())),
626 ("text", Value::String(text.clone())),
627 ]);
628 }
629 }
630 pages[idx] = Some(desc);
631 }
632 let pages: Vec<PageDescriptor> = pages
633 .into_iter()
634 .enumerate()
635 .map(|(i, slot)| {
636 slot.ok_or_else(|| Error::Build(format!("missing page at registry index {i}")))
637 })
638 .collect::<Result<Vec<_>>>()?;
639
640 Ok(pages)
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646
647 #[test]
650 fn normalize_link_empty_returns_empty() {
651 assert_eq!(normalize_link("/webui/", ""), "");
652 }
653
654 #[test]
655 fn normalize_link_root_slash_returns_base() {
656 assert_eq!(normalize_link("/webui/", "/"), "/webui/");
657 }
658
659 #[test]
660 fn normalize_link_trims_trailing_slash() {
661 assert_eq!(
662 normalize_link("/webui/", "/guide/intro/"),
663 "/webui/guide/intro"
664 );
665 assert_eq!(
666 normalize_link("/webui/", "/guide/intro"),
667 "/webui/guide/intro"
668 );
669 }
670
671 #[test]
672 fn normalize_link_preserves_fragment() {
673 assert_eq!(
674 normalize_link("/webui/", "/guide/intro#section"),
675 "/webui/guide/intro#section"
676 );
677 }
678
679 #[test]
680 fn normalize_link_tolerates_missing_leading_slash() {
681 assert_eq!(
683 normalize_link("/webui/", "guide/intro"),
684 "/webui/guide/intro"
685 );
686 }
687
688 #[test]
689 fn normalize_link_tolerates_non_ascii_first_char() {
690 let out = normalize_link("/webui/", "é-page");
692 assert!(out.ends_with("é-page"), "got {out}");
693 }
694
695 #[test]
698 fn normalize_path_as_index_strips_duplicate_folder_name() {
699 assert_eq!(
700 normalize_path_as_index("webui-button/webui-button"),
701 "webui-button"
702 );
703 }
704
705 #[test]
706 fn normalize_path_as_index_keeps_different_filename() {
707 assert_eq!(
708 normalize_path_as_index("webui-button/usage"),
709 "webui-button/usage"
710 );
711 }
712
713 #[test]
714 fn normalize_path_as_index_nested_folders() {
715 assert_eq!(
716 normalize_path_as_index("components/webui-button/webui-button"),
717 "components/webui-button"
718 );
719 }
720
721 #[test]
722 fn normalize_path_as_index_no_slash_returns_unchanged() {
723 assert_eq!(normalize_path_as_index("webui-button"), "webui-button");
724 }
725
726 #[test]
727 fn normalize_path_as_index_deep_nesting_with_match() {
728 assert_eq!(
729 normalize_path_as_index("a/b/c/webui-card/webui-card"),
730 "a/b/c/webui-card"
731 );
732 }
733
734 #[test]
735 fn normalize_path_as_index_deep_nesting_without_match() {
736 assert_eq!(
737 normalize_path_as_index("a/b/c/webui-card/examples"),
738 "a/b/c/webui-card/examples"
739 );
740 }
741
742 #[test]
745 fn parse_frontmatter_no_frontmatter_returns_full_body() {
746 let (fm, body) = parse_frontmatter("# Hello\nworld\n").expect("ok");
747 assert!(fm.title.is_none());
748 assert!(fm.layout.is_none());
749 assert_eq!(body, "# Hello\nworld\n");
750 }
751
752 #[test]
753 fn parse_frontmatter_valid_yaml_extracts_fields() {
754 let raw = "---\ntitle: Hello\ndescription: World\nlayout: home\n---\n# Body\n";
755 let (fm, body) = parse_frontmatter(raw).expect("ok");
756 assert_eq!(fm.title.as_deref(), Some("Hello"));
757 assert_eq!(fm.description.as_deref(), Some("World"));
758 assert_eq!(fm.layout.as_deref(), Some("home"));
759 assert_eq!(body, "# Body\n");
760 }
761
762 #[test]
763 fn parse_frontmatter_handles_crlf_line_endings() {
764 let raw = "---\r\ntitle: CRLF\r\nlayout: doc\r\n---\r\n# Body\r\n";
765 let (fm, body) = parse_frontmatter(raw).expect("ok");
766 assert_eq!(fm.title.as_deref(), Some("CRLF"));
767 assert_eq!(fm.layout.as_deref(), Some("doc"));
768 assert_eq!(body, "# Body\r\n");
769 }
770
771 #[test]
772 fn parse_frontmatter_malformed_yaml_returns_error() {
773 let raw = "---\ntitle: : : :\n---\nBody\n";
775 let result = parse_frontmatter(raw);
776 assert!(result.is_err(), "expected error, got {result:?}");
777 }
778}