use serde::Deserialize;
use std::collections::{HashMap, HashSet};
use std::env;
use std::fs;
use std::path::Path;
#[derive(Deserialize)]
struct NavConfig {
groups: Vec<NavGroup>,
}
#[derive(Deserialize)]
struct NavGroup {
pages: Vec<String>,
}
fn include_path(manifest_dir: &str, relative: &str) -> String {
format!("{manifest_dir}/{relative}").replace('\\', "/")
}
fn emit_entry(code: &mut String, manifest_dir: &str, key: &str, relative: &str) {
let full_path = include_path(manifest_dir, relative);
println!("cargo:rerun-if-changed={relative}");
if !Path::new(&full_path).exists() {
println!(
"cargo:warning=\"{key}\" is listed in the nav/manifest but {full_path} does not exist — the page will 404. Create the file or remove the entry."
);
return;
}
code.push_str(&format!(
" map.insert(\"{key}\", include_str!(\"{full_path}\"));\n"
));
}
pub fn generate_content_map(nav_json_path: &str) {
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
println!("cargo:rerun-if-changed={nav_json_path}");
let json = fs::read_to_string(nav_json_path)
.unwrap_or_else(|e| panic!("Failed to read {nav_json_path}: {e}"));
let nav: NavConfig = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {nav_json_path}: {e}"));
let docs_dir = Path::new(nav_json_path)
.parent()
.and_then(|p| p.to_str())
.unwrap_or("docs");
let mut code = String::from("// Auto-generated by dioxus-docs-kit-build — do not edit\n{\n");
code.push_str(" let mut map = std::collections::HashMap::new();\n");
for group in &nav.groups {
for page in &group.pages {
let mdx_path = format!("{docs_dir}/{page}.mdx");
emit_entry(&mut code, &manifest_dir, page, &mdx_path);
}
}
code.push_str(" map\n}\n");
let out_dir = env::var("OUT_DIR").unwrap();
let dest = Path::new(&out_dir).join("doc_content_generated.rs");
fs::write(&dest, code).expect("Failed to write generated file");
let pages: Vec<String> = nav
.groups
.iter()
.flat_map(|g| g.pages.iter().cloned())
.collect();
validate_docs(&manifest_dir, docs_dir, &pages);
}
#[derive(Deserialize)]
struct BlogManifest {
posts: Vec<String>,
}
pub fn generate_blog_content_map(manifest_path: &str) {
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
println!("cargo:rerun-if-changed={manifest_path}");
let json = fs::read_to_string(manifest_path)
.unwrap_or_else(|e| panic!("Failed to read {manifest_path}: {e}"));
let manifest: BlogManifest = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {manifest_path}: {e}"));
let blog_dir = Path::new(manifest_path)
.parent()
.and_then(|p| p.to_str())
.unwrap_or("blog");
let mut code = String::from("// Auto-generated by dioxus-docs-kit-build — do not edit\n{\n");
code.push_str(" let mut map = std::collections::HashMap::new();\n");
let manifest_full_path = include_path(&manifest_dir, manifest_path);
code.push_str(&format!(
" map.insert(\"__manifest__\", include_str!(\"{manifest_full_path}\"));\n"
));
for slug in &manifest.posts {
let mdx_path = format!("{blog_dir}/{slug}.mdx");
emit_entry(&mut code, &manifest_dir, slug, &mdx_path);
let full_path = include_path(&manifest_dir, &mdx_path);
if let Ok(content) = fs::read_to_string(&full_path) {
validate_blog_frontmatter(&mdx_path, &content);
}
}
code.push_str(" map\n}\n");
let out_dir = env::var("OUT_DIR").unwrap();
let dest = Path::new(&out_dir).join("blog_content_generated.rs");
fs::write(&dest, code).expect("Failed to write generated file");
}
fn slugify(text: &str) -> String {
let text = text
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("&", "&");
let text = strip_markdown_links(&text);
text.to_lowercase()
.chars()
.filter_map(|c| {
if c.is_alphanumeric() {
Some(c)
} else if c.is_whitespace() || c == '-' || c == '_' || c == '.' {
Some('-')
} else {
None
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
fn strip_markdown_links(text: &str) -> String {
let mut out = String::new();
let mut rest = text;
while let Some(open) = rest.find('[') {
if let Some(mid) = rest[open..].find("](") {
let mid = open + mid;
if let Some(close) = rest[mid..].find(')') {
out.push_str(&rest[..open]);
out.push_str(&rest[open + 1..mid]);
rest = &rest[mid + close + 1..];
continue;
}
}
out.push_str(&rest[..=open]);
rest = &rest[open + 1..];
}
out.push_str(rest);
out
}
fn strip_code_fences(content: &str) -> String {
let mut out = String::new();
let mut fence: Option<char> = None;
for line in content.lines() {
let trimmed = line.trim_start();
let marker = if trimmed.starts_with("```") {
Some('`')
} else if trimmed.starts_with("~~~") {
Some('~')
} else {
None
};
match (fence, marker) {
(None, Some(m)) => fence = Some(m), (Some(open), Some(m)) if open == m => fence = None, (None, None) => {
out.push_str(line);
out.push('\n');
}
_ => {} }
}
out
}
fn extract_heading_slugs(content: &str) -> Vec<String> {
let mut slugs = Vec::new();
for line in content.lines() {
let hashes = line.bytes().take_while(|&b| b == b'#').count();
if (2..=4).contains(&hashes) && matches!(line.as_bytes().get(hashes), Some(b' ' | b'\t')) {
let title = line[hashes..].trim();
if !title.is_empty() {
slugs.push(slugify(title));
}
}
}
slugs
}
fn extract_links(content: &str) -> Vec<String> {
let bytes = content.as_bytes();
let mut links = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'[' {
let is_image = i > 0 && bytes[i - 1] == b'!';
if let Some(close) = (i + 1..bytes.len()).find(|&j| bytes[j] == b']') {
if bytes.get(close + 1) == Some(&b'(') {
if let Some(pclose) = (close + 2..bytes.len()).find(|&j| bytes[j] == b')') {
if !is_image {
if let Some(tok) = content[close + 2..pclose].split_whitespace().next()
{
let tok = tok.trim_start_matches('<').trim_end_matches('>');
if !tok.is_empty() {
links.push(tok.to_string());
}
}
}
i = pclose + 1;
continue;
}
}
i = close + 1;
continue;
}
}
i += 1;
}
links
}
fn has_scheme(target: &str) -> bool {
let mut chars = target.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() => {}
_ => return false,
}
for c in chars {
if c == ':' {
return true;
}
if !(c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') {
return false;
}
}
false
}
fn normalize_page_key(s: &str) -> String {
let s = s.trim_end_matches('/');
let s = s
.strip_suffix(".mdx")
.or_else(|| s.strip_suffix(".md"))
.unwrap_or(s);
s.to_string()
}
fn resolve_relative(current_page: &str, path: &str) -> Option<String> {
let mut base: Vec<&str> = current_page.split('/').collect();
base.pop(); for seg in path.split('/') {
match seg {
"" | "." => {}
".." => {
base.pop()?;
}
s => base.push(s),
}
}
Some(base.join("/"))
}
enum LinkResolution {
Valid(String),
Broken,
Skip,
}
fn group_is_validatable(page: &str, group_counts: &HashMap<&str, usize>) -> bool {
page.split_once('/')
.map(|(g, _)| group_counts.get(g).copied().unwrap_or(0) >= 2)
.unwrap_or(false)
}
fn classify_root_absolute(
rest: &str,
page_set: &HashSet<&str>,
group_counts: &HashMap<&str, usize>,
) -> LinkResolution {
let full = normalize_page_key(rest);
let stripped = rest.split_once('/').map(|(_, s)| normalize_page_key(s));
if page_set.contains(full.as_str()) {
return LinkResolution::Valid(full);
}
if let Some(s) = &stripped {
if page_set.contains(s.as_str()) {
return LinkResolution::Valid(s.clone());
}
}
let clearly_docs = group_is_validatable(&full, group_counts)
|| stripped
.as_ref()
.is_some_and(|s| group_is_validatable(s, group_counts));
if clearly_docs {
LinkResolution::Broken
} else {
LinkResolution::Skip
}
}
fn classify_relative(
current_page: &str,
path: &str,
page_set: &HashSet<&str>,
group_counts: &HashMap<&str, usize>,
) -> LinkResolution {
let Some(resolved) = resolve_relative(current_page, path) else {
return LinkResolution::Skip;
};
let resolved = normalize_page_key(&resolved);
if resolved.is_empty() {
return LinkResolution::Skip;
}
if page_set.contains(resolved.as_str()) {
return LinkResolution::Valid(resolved);
}
if group_is_validatable(&resolved, group_counts) {
LinkResolution::Broken
} else {
LinkResolution::Skip
}
}
fn check_link(
src: &str,
current_page: &str,
target: &str,
page_set: &HashSet<&str>,
group_counts: &HashMap<&str, usize>,
headings: &HashMap<&str, HashSet<String>>,
) {
let (path_part, fragment) = match target.split_once('#') {
Some((p, f)) => (p, Some(f)),
None => (target, None),
};
if path_part.is_empty() {
if let Some(frag) = fragment {
check_anchor(src, current_page, target, frag, headings);
}
return;
}
if path_part.starts_with("//") || has_scheme(path_part) {
return;
}
let resolution = if let Some(rest) = path_part.strip_prefix('/') {
classify_root_absolute(rest, page_set, group_counts)
} else {
classify_relative(current_page, path_part, page_set, group_counts)
};
match resolution {
LinkResolution::Valid(page) => {
if let Some(frag) = fragment {
check_anchor(src, &page, target, frag, headings);
}
}
LinkResolution::Broken => {
println!(
"cargo:warning={src}: internal link target \"{target}\" does not match any known docs page"
);
}
LinkResolution::Skip => {}
}
}
fn check_anchor(
src: &str,
page: &str,
target: &str,
fragment: &str,
headings: &HashMap<&str, HashSet<String>>,
) {
if fragment.is_empty() {
return;
}
if let Some(anchors) = headings.get(page) {
if !anchors.contains(&slugify(fragment)) {
println!(
"cargo:warning={src}: link \"{target}\" points to \"#{fragment}\" but no heading with that anchor exists in {page}"
);
}
}
}
fn validate_docs(manifest_dir: &str, docs_dir: &str, pages: &[String]) {
let mut contents: Vec<(String, String)> = Vec::new();
for page in pages {
let mdx_path = format!("{docs_dir}/{page}.mdx");
let full_path = include_path(manifest_dir, &mdx_path);
if let Ok(raw) = fs::read_to_string(&full_path) {
validate_docs_frontmatter(&mdx_path, &raw);
contents.push((page.clone(), raw));
}
}
let page_set: HashSet<&str> = pages.iter().map(String::as_str).collect();
let mut group_counts: HashMap<&str, usize> = HashMap::new();
for page in pages {
if let Some((group, _)) = page.split_once('/') {
*group_counts.entry(group).or_insert(0) += 1;
}
}
let stripped: Vec<(String, String)> = contents
.iter()
.map(|(page, raw)| (page.clone(), strip_code_fences(raw)))
.collect();
let mut headings: HashMap<&str, HashSet<String>> = HashMap::new();
for (page, body) in &stripped {
headings.insert(
page.as_str(),
extract_heading_slugs(body).into_iter().collect(),
);
}
for (page, body) in &stripped {
let src = format!("{docs_dir}/{page}.mdx");
for target in extract_links(body) {
check_link(&src, page, &target, &page_set, &group_counts, &headings);
}
}
}
fn validate_docs_frontmatter(path: &str, content: &str) {
let content = content.trim();
if !content.starts_with("---") {
return;
}
let after = &content[3..];
let Some(end) = after.find("\n---") else {
return;
};
let yaml = after[..end].trim();
if yaml.is_empty() {
return; }
match serde_yaml::from_str::<serde_yaml::Value>(yaml) {
Ok(serde_yaml::Value::Mapping(_)) => {}
Ok(_) => println!(
"cargo:warning={path}: leading --- block is not a YAML mapping and will render as page content, not frontmatter"
),
Err(e) => println!(
"cargo:warning={path}: leading --- block is not valid YAML ({e}) and will render as page content, not frontmatter"
),
}
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct BlogFrontmatterCheck {
title: String,
#[serde(default)]
description: Option<String>,
date: String,
author: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default, rename = "coverImage")]
cover_image: Option<String>,
#[serde(default)]
draft: bool,
#[serde(default)]
featured: bool,
}
fn validate_blog_frontmatter(path: &str, content: &str) {
let content = content.trim();
if !content.starts_with("---") {
panic!("{path}: missing frontmatter block (expected leading ---)");
}
let after = &content[3..];
let Some(end) = after.find("\n---") else {
panic!("{path}: unclosed frontmatter block (missing closing ---)");
};
let yaml = after[..end].trim();
if let Err(e) = serde_yaml::from_str::<BlogFrontmatterCheck>(yaml) {
panic!("{path}: malformed frontmatter: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn include_path_joins_with_forward_slash() {
assert_eq!(
include_path("/home/me/project", "docs/intro.mdx"),
"/home/me/project/docs/intro.mdx"
);
}
#[test]
fn include_path_normalizes_windows_backslashes() {
assert_eq!(
include_path("C:\\Users\\me\\project", "docs\\intro.mdx"),
"C:/Users/me/project/docs/intro.mdx"
);
}
#[test]
fn slugify_matches_mdx() {
assert_eq!(slugify("Hello World"), "hello-world");
assert_eq!(slugify("Getting Started!"), "getting-started");
assert_eq!(slugify("API v1.0"), "api-v1-0");
assert_eq!(slugify("Tips & Tricks"), "tips-tricks");
assert_eq!(slugify("Tips & Tricks"), "tips-tricks");
assert_eq!(slugify("Q&A"), "qa");
assert_eq!(slugify("a < b"), "a-b");
assert_eq!(slugify("See [the docs](https://x.y/z)"), "see-the-docs");
}
#[test]
fn extract_links_skips_images() {
let md = "see  and [Quickstart](/docs/getting-started/quickstart)";
assert_eq!(
extract_links(md),
vec!["/docs/getting-started/quickstart".to_string()]
);
}
#[test]
fn extract_links_strips_title_and_angle_brackets() {
let md = "[a](/docs/x \"the title\") and [b](</docs/y>)";
assert_eq!(
extract_links(md),
vec!["/docs/x".to_string(), "/docs/y".to_string()]
);
}
#[test]
fn strip_code_fences_removes_fenced_links() {
let md = "before\n```\n[not a link](/docs/nope)\n```\nafter [real](/docs/real)";
let body = strip_code_fences(md);
assert!(!body.contains("nope"));
assert_eq!(extract_links(&body), vec!["/docs/real".to_string()]);
}
#[test]
fn has_scheme_detects_external() {
assert!(has_scheme("https://example.com"));
assert!(has_scheme("mailto:me@example.com"));
assert!(!has_scheme("/docs/guides/x"));
assert!(!has_scheme("guides/x"));
assert!(!has_scheme("../guides/x"));
}
#[test]
fn resolve_relative_resolves_against_dir() {
assert_eq!(
resolve_relative("guides/blog", "customization").as_deref(),
Some("guides/customization")
);
assert_eq!(
resolve_relative("guides/blog", "../guides/customization").as_deref(),
Some("guides/customization")
);
assert_eq!(
resolve_relative("getting-started/introduction", "../guides/basic-usage").as_deref(),
Some("guides/basic-usage")
);
assert_eq!(resolve_relative("changelog", "../../x"), None);
}
#[test]
fn extract_heading_slugs_covers_h2_to_h4_only() {
let md = "# Title\n## Section One\n### Sub Section\n##### Too Deep\ntext\n";
assert_eq!(
extract_heading_slugs(md),
vec!["section-one".to_string(), "sub-section".to_string()]
);
}
fn sample_page_data() -> (Vec<&'static str>, HashMap<&'static str, usize>) {
let pages = vec![
"getting-started/introduction",
"getting-started/quickstart",
"guides/basic-usage",
"guides/customization",
"guides/integration",
"guides/blog",
"api-reference/overview",
"changelog",
];
let mut group_counts: HashMap<&str, usize> = HashMap::new();
for p in &pages {
if let Some((g, _)) = p.split_once('/') {
*group_counts.entry(g).or_insert(0) += 1;
}
}
(pages, group_counts)
}
#[test]
fn root_absolute_heuristic() {
let (pages, group_counts) = sample_page_data();
let page_set: HashSet<&str> = pages.iter().copied().collect();
assert!(matches!(
classify_root_absolute("docs/guides/basic-usage", &page_set, &group_counts),
LinkResolution::Valid(_)
));
assert!(matches!(
classify_root_absolute("getting-started/introduction", &page_set, &group_counts),
LinkResolution::Valid(_)
));
assert!(matches!(
classify_root_absolute("docs/guides/nope", &page_set, &group_counts),
LinkResolution::Broken
));
assert!(matches!(
classify_root_absolute("docs/api-reference/getUser", &page_set, &group_counts),
LinkResolution::Skip
));
assert!(matches!(
classify_root_absolute("blog/hello", &page_set, &group_counts),
LinkResolution::Skip
));
}
#[test]
fn relative_heuristic() {
let (pages, group_counts) = sample_page_data();
let page_set: HashSet<&str> = pages.iter().copied().collect();
assert!(matches!(
classify_relative(
"guides/basic-usage",
"customization",
&page_set,
&group_counts
),
LinkResolution::Valid(_)
));
assert!(matches!(
classify_relative("guides/basic-usage", "nope", &page_set, &group_counts),
LinkResolution::Broken
));
assert!(matches!(
classify_relative(
"api-reference/overview",
"get-user",
&page_set,
&group_counts
),
LinkResolution::Skip
));
}
#[test]
fn docs_frontmatter_valid_and_empty_ok() {
validate_docs_frontmatter("x.mdx", "---\ntitle: Hi\n---\nbody");
validate_docs_frontmatter("x.mdx", "---\n---\nbody");
validate_docs_frontmatter("x.mdx", "no frontmatter here");
}
#[test]
fn docs_frontmatter_unparseable_block_warns_but_does_not_panic() {
validate_docs_frontmatter("x.mdx", "---\ntitle: [unclosed\n---\nbody");
validate_docs_frontmatter("x.mdx", "---\n- a\n- b\n---\nbody");
validate_docs_frontmatter("x.mdx", "---\nJust a fenced paragraph.\n---\nbody");
}
#[test]
fn blog_frontmatter_valid_ok() {
validate_blog_frontmatter(
"p.mdx",
"---\ntitle: Hi\ndate: \"2026-01-01\"\nauthor: jane\n---\nbody",
);
}
#[test]
#[should_panic(expected = "malformed frontmatter")]
fn blog_frontmatter_bad_yaml_panics() {
validate_blog_frontmatter(
"p.mdx",
"---\ntitle: [x\ndate: \"2026\"\nauthor: jane\n---\nbody",
);
}
#[test]
#[should_panic(expected = "missing field")]
fn blog_frontmatter_missing_field_panics() {
validate_blog_frontmatter("p.mdx", "---\ntitle: Hi\nauthor: jane\n---\nbody");
}
#[test]
#[should_panic(expected = "missing frontmatter")]
fn blog_frontmatter_no_block_panics() {
validate_blog_frontmatter("p.mdx", "just body, no frontmatter");
}
#[test]
#[should_panic(expected = "invalid type")]
fn blog_frontmatter_wrong_typed_optional_field_panics() {
validate_blog_frontmatter(
"p.mdx",
"---\ntitle: Hi\ndate: \"2026-01-01\"\nauthor: jane\ntags: rust\n---\nbody",
);
}
#[test]
fn blog_frontmatter_optional_fields_ok() {
validate_blog_frontmatter(
"p.mdx",
"---\ntitle: Hi\ndate: \"2026-01-01\"\nauthor: jane\ntags: [rust, web]\ndraft: true\ncoverImage: cover.png\n---\nbody",
);
}
}