use std::collections::HashMap;
use std::fs;
use std::path::Path;
use serde_json::{Map, Value};
use crate::error::{Error, Result};
use crate::markdown::{render_markdown, Highlighter};
use crate::state::{load_render_states, merge_page_state, LoadedStates};
use crate::types::{DocsConfig, NavLink, PageDescriptor, SidebarItem, SidebarSection};
fn normalize_link(base: &str, link: &str) -> String {
if link.is_empty() {
return String::new();
}
let stripped = link.strip_prefix('/').unwrap_or(link);
if link.contains('#') {
return format!("{base}{stripped}");
}
let cleaned = stripped.trim_end_matches('/');
if cleaned.is_empty() {
base.to_string()
} else {
format!("{base}{cleaned}")
}
}
fn json_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
let mut map = Map::with_capacity(N);
for (k, v) in entries {
map.insert(k.to_string(), v);
}
Value::Object(map)
}
#[derive(Debug)]
struct Frontmatter {
title: Option<String>,
description: Option<String>,
layout: Option<String>,
}
fn parse_frontmatter(content: &str) -> Result<(Frontmatter, &str)> {
if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
return Ok((
Frontmatter {
title: None,
description: None,
layout: None,
},
content,
));
}
let body_start_marker = if content.starts_with("---\r\n") { 5 } else { 4 };
let after_open = &content[body_start_marker..];
let (yaml_end_offset, close_skip) = if let Some(i) = after_open.find("\r\n---") {
(i, "\r\n---".len())
} else if let Some(i) = after_open.find("\n---") {
(i, "\n---".len())
} else {
(after_open.len(), 0)
};
let yaml_str = &after_open[..yaml_end_offset];
let body_offset = body_start_marker + yaml_end_offset + close_skip;
let body = if body_offset < content.len() {
let rest = &content[body_offset..];
rest.strip_prefix("\r\n")
.or_else(|| rest.strip_prefix('\n'))
.unwrap_or(rest)
} else {
""
};
let yaml: HashMap<String, serde_yaml::Value> = serde_yaml::from_str(yaml_str)
.map_err(|e| crate::error::Error::Markdown(format!("Invalid frontmatter YAML: {e}")))?;
Ok((
Frontmatter {
title: yaml.get("title").and_then(|v| v.as_str()).map(String::from),
description: yaml
.get("description")
.and_then(|v| v.as_str())
.map(String::from),
layout: yaml
.get("layout")
.and_then(|v| v.as_str())
.map(String::from),
},
body,
))
}
fn extract_h1(content: &str) -> Option<String> {
for line in content.lines() {
if let Some(heading) = line.strip_prefix("# ") {
return Some(heading.trim().to_string());
}
}
None
}
fn collect_sidebar_links(items: &[SidebarItem]) -> Vec<&str> {
let mut links = Vec::new();
let mut stack: Vec<&SidebarItem> = items.iter().rev().collect();
while let Some(item) = stack.pop() {
if !item.link.is_empty() {
links.push(item.link.as_str());
}
for child in item.items.iter().rev() {
stack.push(child);
}
}
links
}
fn normalize_path_as_index(path: &str) -> &str {
let Some(slash_pos) = path.rfind('/') else {
return path;
};
let parent = &path[..slash_pos];
let filename = &path[slash_pos + 1..];
let folder_name = parent.rsplit('/').next().unwrap_or("");
if folder_name == filename {
parent
} else {
path
}
}
fn page_url_path(rel_path: &str, base_path: &str, sources: &HashMap<&str, &str>) -> String {
let prefix = base_path.trim_end_matches('/');
if let Some(&link) = sources.get(rel_path) {
let end = link.find(['#', '?']).unwrap_or(link.len());
let link = &link[..end];
let cleaned = link.trim_matches('/');
return if cleaned.is_empty() {
format!("{prefix}/")
} else {
format!("{prefix}/{cleaned}")
};
}
let trimmed = rel_path.trim_end_matches(".md");
let trimmed = trimmed.strip_suffix("/index").unwrap_or(trimmed);
let trimmed = normalize_path_as_index(trimmed);
if trimmed.is_empty() || trimmed == "index" {
format!("{prefix}/")
} else {
format!("{prefix}/{trimmed}")
}
}
fn markdown_link_base_url(source_path: &Path, content_dir: &Path, base_path: &str) -> String {
let parent = source_path
.strip_prefix(content_dir)
.ok()
.and_then(Path::parent);
let parent = parent
.filter(|path| !path.as_os_str().is_empty())
.map(|path| path.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
let prefix = base_path.trim_end_matches('/');
let parent = parent.trim_matches('/');
let mut url = String::with_capacity(prefix.len() + parent.len() + 2);
url.push_str(prefix);
url.push('/');
if !parent.is_empty() {
url.push_str(parent);
url.push('/');
}
url
}
fn nav_source_overrides(nav: &[NavLink]) -> HashMap<&str, &str> {
nav.iter()
.filter_map(|item| {
let source = item.source.as_deref()?;
if item.link.starts_with("http") {
return None;
}
Some((source, item.link.as_str()))
})
.collect()
}
fn build_page_registry(
content_dir: &Path,
base_path: &str,
sources: &HashMap<&str, &str>,
) -> Vec<(String, std::path::PathBuf)> {
let mut pages = Vec::new();
let mut stack: Vec<std::path::PathBuf> = vec![content_dir.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.') || name_str == "dist" || name_str == "node_modules" {
continue;
}
if path.is_dir() {
stack.push(path);
continue;
}
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
let rel = match path.strip_prefix(content_dir) {
Ok(r) => r,
Err(_) => continue,
};
let rel_str = rel.to_string_lossy().replace('\\', "/");
pages.push((page_url_path(&rel_str, base_path, sources), path));
}
}
pages.sort_by(|a, b| a.0.cmp(&b.0));
pages
}
fn build_sidebar_state(url_path: &str, config: &DocsConfig) -> serde_json::Value {
let base = &config.base_path;
let active_sidebar = config
.sidebar_groups
.iter()
.find(|(prefix, _)| {
let full_prefix = format!("{}{}", base, prefix.strip_prefix('/').unwrap_or(prefix));
let trimmed = full_prefix.trim_end_matches('/');
url_path == trimmed || url_path.starts_with(&format!("{trimmed}/"))
})
.map(|(_, s)| s.as_slice())
.unwrap_or(&config.sidebar);
let item_to_value = |item: &SidebarItem| -> Value {
let item_path = normalize_link(base, &item.link);
let active = !item_path.is_empty() && item_path == url_path;
let children: Vec<Value> = item
.items
.iter()
.map(|child| {
let child_path = normalize_link(base, &child.link);
let child_active = !child_path.is_empty() && child_path == url_path;
json_obj([
("text", Value::String(child.text.clone())),
("link", Value::String(child_path)),
("active", Value::Bool(child_active)),
("hasChildren", Value::Bool(false)),
("children", Value::Array(vec![])),
])
})
.collect();
json_obj([
("text", Value::String(item.text.clone())),
("link", Value::String(item_path)),
("active", Value::Bool(active)),
("hasChildren", Value::Bool(!children.is_empty())),
("children", Value::Array(children)),
])
};
let sections: Vec<Value> = active_sidebar
.iter()
.map(|section| {
let items: Vec<Value> = section.items.iter().map(item_to_value).collect();
json_obj([
("title", Value::String(section.title.clone())),
("items", Value::Array(items)),
])
})
.collect();
json_obj([("sections", Value::Array(sections))])
}
fn find_sidebar_text(url_path: &str, config: &DocsConfig) -> Option<String> {
let base = &config.base_path;
let all: Vec<&[SidebarSection]> = std::iter::once(config.sidebar.as_slice())
.chain(config.sidebar_groups.values().map(|v| v.as_slice()))
.collect();
for sidebar in all {
for section in sidebar {
let mut stack: Vec<&SidebarItem> = section.items.iter().collect();
while let Some(item) = stack.pop() {
if !item.link.is_empty() {
let item_path = normalize_link(base, &item.link);
if item_path == url_path {
return Some(item.text.clone());
}
}
for child in &item.items {
stack.push(child);
}
}
}
}
None
}
#[allow(dead_code)] pub fn process_content(
config: &DocsConfig,
config_dir: &Path,
highlighter: &Highlighter,
head_injection: &str,
) -> Result<Vec<PageDescriptor>> {
let states = load_render_states(config, config_dir)?;
process_content_with_states(config, highlighter, head_injection, &states)
}
pub(crate) fn process_content_with_states(
config: &DocsConfig,
highlighter: &Highlighter,
head_injection: &str,
states: &LoadedStates,
) -> Result<Vec<PageDescriptor>> {
let content_dir = Path::new(&config.content_dir);
let base_path = &config.base_path;
let nav_links: Vec<Value> = config
.nav
.iter()
.map(|item| {
let (link, section) = if item.link.starts_with("http") {
(item.link.clone(), item.link.clone())
} else {
let full = format!("{}{}", base_path, &item.link[1..]);
let slug = item
.link
.trim_start_matches('/')
.split('/')
.next()
.unwrap_or("")
.to_string();
(full, slug)
};
json_obj([
("text", Value::String(item.text.clone())),
("link", Value::String(link)),
("section", Value::String(section)),
])
})
.collect();
let mut registry =
build_page_registry(content_dir, base_path, &nav_source_overrides(&config.nav));
for page_link in config.custom_pages.keys() {
let normalized = if page_link.ends_with('/') {
page_link.clone()
} else {
format!("{}/", page_link)
};
let url_path = format!("{}{}", base_path, &normalized[1..]);
let src_file = format!("{}index.md", &normalized[1..]);
let full_path = content_dir.join(&src_file);
if !registry.iter().any(|(p, _)| p == &url_path) {
registry.push((url_path, full_path));
}
}
use rayon::prelude::*;
let results: Vec<(usize, PageDescriptor)> = registry
.par_iter()
.enumerate()
.map(
|(idx, (url_path, full_path))| -> Result<(usize, PageDescriptor)> {
let mut is_home = false;
let mut html = String::new();
let mut title = config.site.title.clone();
let mut description = config.site.description.clone();
let mut layout = "doc".to_string();
let logical_path = format!("/{}", &url_path[base_path.len()..]);
let custom_entry = config
.custom_pages
.get(&logical_path)
.or_else(|| config.custom_pages.get(logical_path.trim_end_matches('/')));
let custom_state = states.custom_page_state(&logical_path);
if let Some(entry) = custom_entry {
html = entry.html().to_string();
layout = entry.layout().to_string();
} else if full_path.exists() {
let raw = fs::read_to_string(full_path)
.map_err(|e| crate::error::Error::Io(e.to_string()))?;
let (fm, body) = parse_frontmatter(&raw).map_err(|e| {
crate::error::Error::Markdown(format!("{}: {e}", full_path.display()))
})?;
is_home = fm.layout.as_deref() == Some("home");
layout = if is_home {
"home".to_string()
} else {
fm.layout.unwrap_or_else(|| "doc".to_string())
};
if !is_home {
let page_url = if url_path.ends_with('/') {
url_path.to_string()
} else {
format!("{url_path}/")
};
let link_base_url =
markdown_link_base_url(full_path, content_dir, base_path);
html = render_markdown(
body,
highlighter,
base_path,
&page_url,
&link_base_url,
)?;
}
if let Some(d) = fm.description {
description = d;
}
let h1 = extract_h1(body);
let sidebar_text = find_sidebar_text(url_path, config);
title = fm
.title
.or(h1)
.or(sidebar_text)
.unwrap_or_else(|| config.site.title.clone());
}
let hero_val = if is_home {
config
.hero
.as_ref()
.map(|h| {
json_obj([
(
"text",
h.text
.as_ref()
.map_or(Value::Null, |t| Value::String(t.clone())),
),
("tagline", Value::String(h.tagline.clone())),
(
"manifesto",
h.manifesto
.as_ref()
.map_or(Value::Null, |m| Value::String(m.clone())),
),
(
"actions",
Value::Array(
h.actions
.iter()
.map(|a| {
json_obj([
("text", Value::String(a.text.clone())),
("link", Value::String(a.link.clone())),
("brand", Value::Bool(a.brand)),
])
})
.collect(),
),
),
(
"features",
Value::Array(
h.features
.iter()
.map(|f| {
json_obj([
("icon", Value::String(f.icon.clone())),
("title", Value::String(f.title.clone())),
(
"description",
Value::String(f.description.clone()),
),
])
})
.collect(),
),
),
])
})
.unwrap_or(Value::Null)
} else {
Value::Null
};
let footer_val = config
.footer
.as_ref()
.map(|f| json_obj([("html", Value::String(f.html.clone()))]))
.unwrap_or(Value::Null);
let page_section = logical_path
.trim_start_matches('/')
.split('/')
.next()
.unwrap_or("")
.to_string();
let state = json_obj([
(
"site",
json_obj([
("title", Value::String(config.site.title.clone())),
("base", Value::String(base_path.to_string())),
]),
),
("navigation", Value::Array(nav_links.clone())),
("sidebar", build_sidebar_state(url_path, config)),
(
"page",
json_obj([
("title", Value::String(title.clone())),
("description", Value::String(description.clone())),
("content", Value::String(html.clone())),
("isHome", Value::Bool(is_home)),
("layout", Value::String(layout)),
("section", Value::String(page_section)),
]),
),
("hero", hero_val),
("footer", footer_val),
("prev", Value::Null),
("next", Value::Null),
("pageData", custom_state.cloned().unwrap_or(Value::Null)),
("headTags", Value::String(head_injection.to_string())),
("label", Value::String("Copy".to_string())),
("icon", Value::String("🌙".to_string())),
]);
let state = merge_page_state(state, states.global(), custom_state);
Ok((
idx,
PageDescriptor {
path: url_path.clone(),
is_home,
state,
},
))
},
)
.collect::<Result<Vec<_>>>()?;
let resolve_sidebar = |page_path: &str| -> Vec<SidebarItem> {
let active = config
.sidebar_groups
.iter()
.find(|(prefix, _)| {
let full_prefix = format!(
"{}{}",
base_path,
prefix.strip_prefix('/').unwrap_or(prefix)
);
let trimmed = full_prefix.trim_end_matches('/');
page_path == trimmed || page_path.starts_with(&format!("{trimmed}/"))
})
.map(|(_, s)| s.as_slice())
.unwrap_or(&config.sidebar);
active.iter().flat_map(|s| s.items.clone()).collect()
};
let mut pages: Vec<Option<PageDescriptor>> = (0..results.len()).map(|_| None).collect();
for (idx, mut desc) in results {
let sidebar_items = resolve_sidebar(&desc.path);
let flat_links = collect_sidebar_links(&sidebar_items);
let all_links: Vec<(String, String)> = flat_links
.iter()
.map(|link| {
let path = normalize_link(base_path, link);
let text = find_sidebar_text(&path, config).unwrap_or_default();
(path, text)
})
.collect();
if let Some(pos) = all_links.iter().position(|(p, _)| p == &desc.path) {
if pos > 0 {
let (link, text) = &all_links[pos - 1];
desc.state["prev"] = json_obj([
("link", Value::String(link.clone())),
("text", Value::String(text.clone())),
]);
}
if pos + 1 < all_links.len() {
let (link, text) = &all_links[pos + 1];
desc.state["next"] = json_obj([
("link", Value::String(link.clone())),
("text", Value::String(text.clone())),
]);
}
}
pages[idx] = Some(desc);
}
let pages: Vec<PageDescriptor> = pages
.into_iter()
.enumerate()
.map(|(i, slot)| {
slot.ok_or_else(|| Error::Build(format!("missing page at registry index {i}")))
})
.collect::<Result<Vec<_>>>()?;
Ok(pages)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_link_empty_returns_empty() {
assert_eq!(normalize_link("/webui/", ""), "");
}
#[test]
fn normalize_link_root_slash_returns_base() {
assert_eq!(normalize_link("/webui/", "/"), "/webui/");
}
#[test]
fn normalize_link_trims_trailing_slash() {
assert_eq!(
normalize_link("/webui/", "/guide/intro/"),
"/webui/guide/intro"
);
assert_eq!(
normalize_link("/webui/", "/guide/intro"),
"/webui/guide/intro"
);
}
#[test]
fn normalize_link_preserves_fragment() {
assert_eq!(
normalize_link("/webui/", "/guide/intro#section"),
"/webui/guide/intro#section"
);
}
#[test]
fn normalize_link_tolerates_missing_leading_slash() {
assert_eq!(
normalize_link("/webui/", "guide/intro"),
"/webui/guide/intro"
);
}
#[test]
fn normalize_link_tolerates_non_ascii_first_char() {
let out = normalize_link("/webui/", "é-page");
assert!(out.ends_with("é-page"), "got {out}");
}
#[test]
fn normalize_path_as_index_strips_duplicate_folder_name() {
assert_eq!(
normalize_path_as_index("webui-button/webui-button"),
"webui-button"
);
}
#[test]
fn normalize_path_as_index_keeps_different_filename() {
assert_eq!(
normalize_path_as_index("webui-button/usage"),
"webui-button/usage"
);
}
#[test]
fn normalize_path_as_index_nested_folders() {
assert_eq!(
normalize_path_as_index("components/webui-button/webui-button"),
"components/webui-button"
);
}
#[test]
fn normalize_path_as_index_no_slash_returns_unchanged() {
assert_eq!(normalize_path_as_index("webui-button"), "webui-button");
}
fn sources(pairs: &[(&'static str, &'static str)]) -> HashMap<&'static str, &'static str> {
pairs.iter().copied().collect()
}
#[test]
fn page_url_path_derives_url_from_filesystem() {
let none = sources(&[]);
assert_eq!(
page_url_path("guide/install.md", "/webui/", &none),
"/webui/guide/install"
);
}
#[test]
fn page_url_path_collapses_index_and_root() {
let none = sources(&[]);
assert_eq!(
page_url_path("guide/index.md", "/webui/", &none),
"/webui/guide"
);
assert_eq!(page_url_path("index.md", "/webui/", &none), "/webui/");
assert_eq!(page_url_path("index.md", "", &none), "/");
}
#[test]
fn page_url_path_collapses_filename_repeating_its_folder() {
let none = sources(&[]);
assert_eq!(
page_url_path("components/webui-button/webui-button.md", "/webui/", &none),
"/webui/components/webui-button"
);
}
#[test]
fn page_url_path_nav_source_overrides_derived_url() {
let map = sources(&[("ai/SKILL.md", "/ai")]);
assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
}
#[test]
fn page_url_path_nav_source_tolerates_surrounding_slashes() {
let map = sources(&[("ai/SKILL.md", "ai/"), ("b/SKILL.md", "/b")]);
assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
assert_eq!(page_url_path("b/SKILL.md", "", &map), "/b");
}
#[test]
fn page_url_path_nav_source_can_target_site_root() {
let map = sources(&[("home/landing.md", "/")]);
assert_eq!(page_url_path("home/landing.md", "/webui/", &map), "/webui/");
}
#[test]
fn page_url_path_ignores_nav_source_for_other_files() {
let map = sources(&[("ai/SKILL.md", "/ai")]);
assert_eq!(
page_url_path("guide/SKILL.md", "/webui/", &map),
"/webui/guide/SKILL"
);
}
#[test]
fn page_url_path_nav_source_strips_fragment_from_override() {
let map = sources(&[("ai/SKILL.md", "/ai#rules")]);
assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
}
#[test]
fn page_url_path_nav_source_strips_query_from_override() {
let map = sources(&[("ai/SKILL.md", "/ai?v=2")]);
assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
}
#[test]
fn markdown_link_base_uses_source_directory_for_index_and_leaf_pages() {
let content_dir = Path::new("docs");
assert_eq!(
markdown_link_base_url(Path::new("docs/guide/index.md"), content_dir, "/webui/"),
"/webui/guide/"
);
assert_eq!(
markdown_link_base_url(
Path::new("docs/guide/installation.md"),
content_dir,
"/webui/"
),
"/webui/guide/"
);
assert_eq!(
markdown_link_base_url(
Path::new("docs/guide/topic/index.md"),
content_dir,
"/webui/"
),
"/webui/guide/topic/"
);
}
fn nav_link(text: &str, link: &str, source: Option<&str>) -> NavLink {
NavLink {
text: text.to_string(),
link: link.to_string(),
source: source.map(String::from),
}
}
#[test]
fn nav_source_overrides_collects_only_entries_declaring_a_source() {
let nav = vec![
nav_link("Guide", "/guide/", None),
nav_link("AI", "/ai", Some("ai/SKILL.md")),
nav_link("GitHub", "https://github.com/microsoft/webui", None),
];
let map = nav_source_overrides(&nav);
assert_eq!(map.len(), 1);
assert_eq!(map.get("ai/SKILL.md"), Some(&"/ai"));
}
#[test]
fn nav_source_overrides_empty_nav_yields_no_overrides() {
assert!(nav_source_overrides(&[]).is_empty());
}
#[test]
fn nav_source_overrides_excludes_external_link_with_source() {
let nav = vec![
nav_link("AI", "/ai", Some("ai/SKILL.md")),
nav_link(
"GitHub",
"https://github.com/microsoft/webui",
Some("github.md"),
),
];
let map = nav_source_overrides(&nav);
assert_eq!(map.len(), 1);
assert_eq!(map.get("ai/SKILL.md"), Some(&"/ai"));
assert!(!map.contains_key("github.md"));
}
#[test]
fn normalize_path_as_index_deep_nesting_with_match() {
assert_eq!(
normalize_path_as_index("a/b/c/webui-card/webui-card"),
"a/b/c/webui-card"
);
}
#[test]
fn normalize_path_as_index_deep_nesting_without_match() {
assert_eq!(
normalize_path_as_index("a/b/c/webui-card/examples"),
"a/b/c/webui-card/examples"
);
}
#[test]
fn parse_frontmatter_no_frontmatter_returns_full_body() {
let (fm, body) = parse_frontmatter("# Hello\nworld\n").expect("ok");
assert!(fm.title.is_none());
assert!(fm.layout.is_none());
assert_eq!(body, "# Hello\nworld\n");
}
#[test]
fn parse_frontmatter_valid_yaml_extracts_fields() {
let raw = "---\ntitle: Hello\ndescription: World\nlayout: home\n---\n# Body\n";
let (fm, body) = parse_frontmatter(raw).expect("ok");
assert_eq!(fm.title.as_deref(), Some("Hello"));
assert_eq!(fm.description.as_deref(), Some("World"));
assert_eq!(fm.layout.as_deref(), Some("home"));
assert_eq!(body, "# Body\n");
}
#[test]
fn parse_frontmatter_handles_crlf_line_endings() {
let raw = "---\r\ntitle: CRLF\r\nlayout: doc\r\n---\r\n# Body\r\n";
let (fm, body) = parse_frontmatter(raw).expect("ok");
assert_eq!(fm.title.as_deref(), Some("CRLF"));
assert_eq!(fm.layout.as_deref(), Some("doc"));
assert_eq!(body, "# Body\r\n");
}
#[test]
fn parse_frontmatter_malformed_yaml_returns_error() {
let raw = "---\ntitle: : : :\n---\nBody\n";
let result = parse_frontmatter(raw);
assert!(result.is_err(), "expected error, got {result:?}");
}
}