1use serde::Deserialize;
2use std::collections::{HashMap, HashSet};
3use std::env;
4use std::fs;
5use std::path::Path;
6
7#[derive(Deserialize)]
8struct NavConfig {
9 groups: Vec<NavGroup>,
10}
11
12#[derive(Deserialize)]
13struct NavGroup {
14 pages: Vec<String>,
15}
16
17fn include_path(manifest_dir: &str, relative: &str) -> String {
23 format!("{manifest_dir}/{relative}").replace('\\', "/")
24}
25
26fn emit_entry(code: &mut String, manifest_dir: &str, key: &str, relative: &str) {
32 let full_path = include_path(manifest_dir, relative);
33
34 println!("cargo:rerun-if-changed={relative}");
35
36 if !Path::new(&full_path).exists() {
37 println!(
38 "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."
39 );
40 return;
41 }
42
43 code.push_str(&format!(
45 " map.insert(\"{key}\", include_str!(\"{full_path}\"));\n"
46 ));
47}
48
49pub fn generate_content_map(nav_json_path: &str) {
65 let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
66
67 println!("cargo:rerun-if-changed={nav_json_path}");
68
69 let json = fs::read_to_string(nav_json_path)
70 .unwrap_or_else(|e| panic!("Failed to read {nav_json_path}: {e}"));
71 let nav: NavConfig = serde_json::from_str(&json)
72 .unwrap_or_else(|e| panic!("Failed to parse {nav_json_path}: {e}"));
73
74 let docs_dir = Path::new(nav_json_path)
76 .parent()
77 .and_then(|p| p.to_str())
78 .unwrap_or("docs");
79
80 let mut code = String::from("// Auto-generated by dioxus-docs-kit-build — do not edit\n{\n");
81 code.push_str(" let mut map = std::collections::HashMap::new();\n");
82
83 for group in &nav.groups {
84 for page in &group.pages {
85 let mdx_path = format!("{docs_dir}/{page}.mdx");
86 emit_entry(&mut code, &manifest_dir, page, &mdx_path);
87 }
88 }
89
90 code.push_str(" map\n}\n");
91
92 let out_dir = env::var("OUT_DIR").unwrap();
93 let dest = Path::new(&out_dir).join("doc_content_generated.rs");
94 fs::write(&dest, code).expect("Failed to write generated file");
95
96 let pages: Vec<String> = nav
99 .groups
100 .iter()
101 .flat_map(|g| g.pages.iter().cloned())
102 .collect();
103 validate_docs(&manifest_dir, docs_dir, &pages);
104}
105
106#[derive(Deserialize)]
111struct BlogManifest {
112 posts: Vec<String>,
113}
114
115pub fn generate_blog_content_map(manifest_path: &str) {
134 let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
135
136 println!("cargo:rerun-if-changed={manifest_path}");
137
138 let json = fs::read_to_string(manifest_path)
139 .unwrap_or_else(|e| panic!("Failed to read {manifest_path}: {e}"));
140 let manifest: BlogManifest = serde_json::from_str(&json)
141 .unwrap_or_else(|e| panic!("Failed to parse {manifest_path}: {e}"));
142
143 let blog_dir = Path::new(manifest_path)
145 .parent()
146 .and_then(|p| p.to_str())
147 .unwrap_or("blog");
148
149 let mut code = String::from("// Auto-generated by dioxus-docs-kit-build — do not edit\n{\n");
150 code.push_str(" let mut map = std::collections::HashMap::new();\n");
151
152 let manifest_full_path = include_path(&manifest_dir, manifest_path);
154 code.push_str(&format!(
155 " map.insert(\"__manifest__\", include_str!(\"{manifest_full_path}\"));\n"
156 ));
157
158 for slug in &manifest.posts {
159 let mdx_path = format!("{blog_dir}/{slug}.mdx");
160 emit_entry(&mut code, &manifest_dir, slug, &mdx_path);
161
162 let full_path = include_path(&manifest_dir, &mdx_path);
165 if let Ok(content) = fs::read_to_string(&full_path) {
166 validate_blog_frontmatter(&mdx_path, &content);
167 }
168 }
169
170 code.push_str(" map\n}\n");
171
172 let out_dir = env::var("OUT_DIR").unwrap();
173 let dest = Path::new(&out_dir).join("blog_content_generated.rs");
174 fs::write(&dest, code).expect("Failed to write generated file");
175}
176
177fn slugify(text: &str) -> String {
188 let text = text
189 .replace("<", "<")
190 .replace(">", ">")
191 .replace(""", "\"")
192 .replace("'", "'")
193 .replace("&", "&");
194 let text = strip_markdown_links(&text);
195 text.to_lowercase()
196 .chars()
197 .filter_map(|c| {
198 if c.is_alphanumeric() {
199 Some(c)
200 } else if c.is_whitespace() || c == '-' || c == '_' || c == '.' {
201 Some('-')
202 } else {
203 None
204 }
205 })
206 .collect::<String>()
207 .split('-')
208 .filter(|s| !s.is_empty())
209 .collect::<Vec<_>>()
210 .join("-")
211}
212
213fn strip_markdown_links(text: &str) -> String {
216 let mut out = String::new();
217 let mut rest = text;
218 while let Some(open) = rest.find('[') {
219 if let Some(mid) = rest[open..].find("](") {
220 let mid = open + mid;
221 if let Some(close) = rest[mid..].find(')') {
222 out.push_str(&rest[..open]);
223 out.push_str(&rest[open + 1..mid]);
224 rest = &rest[mid + close + 1..];
225 continue;
226 }
227 }
228 out.push_str(&rest[..=open]);
229 rest = &rest[open + 1..];
230 }
231 out.push_str(rest);
232 out
233}
234
235fn strip_code_fences(content: &str) -> String {
238 let mut out = String::new();
239 let mut fence: Option<char> = None;
240 for line in content.lines() {
241 let trimmed = line.trim_start();
242 let marker = if trimmed.starts_with("```") {
243 Some('`')
244 } else if trimmed.starts_with("~~~") {
245 Some('~')
246 } else {
247 None
248 };
249 match (fence, marker) {
250 (None, Some(m)) => fence = Some(m), (Some(open), Some(m)) if open == m => fence = None, (None, None) => {
253 out.push_str(line);
254 out.push('\n');
255 }
256 _ => {} }
258 }
259 out
260}
261
262fn extract_heading_slugs(content: &str) -> Vec<String> {
266 let mut slugs = Vec::new();
267 for line in content.lines() {
268 let hashes = line.bytes().take_while(|&b| b == b'#').count();
269 if (2..=4).contains(&hashes) && matches!(line.as_bytes().get(hashes), Some(b' ' | b'\t')) {
270 let title = line[hashes..].trim();
271 if !title.is_empty() {
272 slugs.push(slugify(title));
273 }
274 }
275 }
276 slugs
277}
278
279fn extract_links(content: &str) -> Vec<String> {
282 let bytes = content.as_bytes();
283 let mut links = Vec::new();
284 let mut i = 0;
285 while i < bytes.len() {
286 if bytes[i] == b'[' {
287 let is_image = i > 0 && bytes[i - 1] == b'!';
288 if let Some(close) = (i + 1..bytes.len()).find(|&j| bytes[j] == b']') {
289 if bytes.get(close + 1) == Some(&b'(') {
290 if let Some(pclose) = (close + 2..bytes.len()).find(|&j| bytes[j] == b')') {
291 if !is_image {
292 if let Some(tok) = content[close + 2..pclose].split_whitespace().next()
293 {
294 let tok = tok.trim_start_matches('<').trim_end_matches('>');
295 if !tok.is_empty() {
296 links.push(tok.to_string());
297 }
298 }
299 }
300 i = pclose + 1;
301 continue;
302 }
303 }
304 i = close + 1;
305 continue;
306 }
307 }
308 i += 1;
309 }
310 links
311}
312
313fn has_scheme(target: &str) -> bool {
315 let mut chars = target.chars();
316 match chars.next() {
317 Some(c) if c.is_ascii_alphabetic() => {}
318 _ => return false,
319 }
320 for c in chars {
321 if c == ':' {
322 return true;
323 }
324 if !(c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') {
325 return false;
326 }
327 }
328 false
329}
330
331fn normalize_page_key(s: &str) -> String {
334 let s = s.trim_end_matches('/');
335 let s = s
336 .strip_suffix(".mdx")
337 .or_else(|| s.strip_suffix(".md"))
338 .unwrap_or(s);
339 s.to_string()
340}
341
342fn resolve_relative(current_page: &str, path: &str) -> Option<String> {
345 let mut base: Vec<&str> = current_page.split('/').collect();
346 base.pop(); for seg in path.split('/') {
348 match seg {
349 "" | "." => {}
350 ".." => {
351 base.pop()?;
352 }
353 s => base.push(s),
354 }
355 }
356 Some(base.join("/"))
357}
358
359enum LinkResolution {
361 Valid(String),
363 Broken,
365 Skip,
367}
368
369fn group_is_validatable(page: &str, group_counts: &HashMap<&str, usize>) -> bool {
374 page.split_once('/')
375 .map(|(g, _)| group_counts.get(g).copied().unwrap_or(0) >= 2)
376 .unwrap_or(false)
377}
378
379fn classify_root_absolute(
383 rest: &str,
384 page_set: &HashSet<&str>,
385 group_counts: &HashMap<&str, usize>,
386) -> LinkResolution {
387 let full = normalize_page_key(rest);
388 let stripped = rest.split_once('/').map(|(_, s)| normalize_page_key(s));
389
390 if page_set.contains(full.as_str()) {
391 return LinkResolution::Valid(full);
392 }
393 if let Some(s) = &stripped {
394 if page_set.contains(s.as_str()) {
395 return LinkResolution::Valid(s.clone());
396 }
397 }
398
399 let clearly_docs = group_is_validatable(&full, group_counts)
400 || stripped
401 .as_ref()
402 .is_some_and(|s| group_is_validatable(s, group_counts));
403 if clearly_docs {
404 LinkResolution::Broken
405 } else {
406 LinkResolution::Skip
407 }
408}
409
410fn classify_relative(
413 current_page: &str,
414 path: &str,
415 page_set: &HashSet<&str>,
416 group_counts: &HashMap<&str, usize>,
417) -> LinkResolution {
418 let Some(resolved) = resolve_relative(current_page, path) else {
419 return LinkResolution::Skip;
420 };
421 let resolved = normalize_page_key(&resolved);
422 if resolved.is_empty() {
423 return LinkResolution::Skip;
424 }
425 if page_set.contains(resolved.as_str()) {
426 return LinkResolution::Valid(resolved);
427 }
428 if group_is_validatable(&resolved, group_counts) {
429 LinkResolution::Broken
430 } else {
431 LinkResolution::Skip
432 }
433}
434
435fn check_link(
438 src: &str,
439 current_page: &str,
440 target: &str,
441 page_set: &HashSet<&str>,
442 group_counts: &HashMap<&str, usize>,
443 headings: &HashMap<&str, HashSet<String>>,
444) {
445 let (path_part, fragment) = match target.split_once('#') {
446 Some((p, f)) => (p, Some(f)),
447 None => (target, None),
448 };
449
450 if path_part.is_empty() {
452 if let Some(frag) = fragment {
453 check_anchor(src, current_page, target, frag, headings);
454 }
455 return;
456 }
457
458 if path_part.starts_with("//") || has_scheme(path_part) {
460 return;
461 }
462
463 let resolution = if let Some(rest) = path_part.strip_prefix('/') {
464 classify_root_absolute(rest, page_set, group_counts)
465 } else {
466 classify_relative(current_page, path_part, page_set, group_counts)
467 };
468
469 match resolution {
470 LinkResolution::Valid(page) => {
471 if let Some(frag) = fragment {
472 check_anchor(src, &page, target, frag, headings);
473 }
474 }
475 LinkResolution::Broken => {
476 println!(
477 "cargo:warning={src}: internal link target \"{target}\" does not match any known docs page"
478 );
479 }
480 LinkResolution::Skip => {}
481 }
482}
483
484fn check_anchor(
487 src: &str,
488 page: &str,
489 target: &str,
490 fragment: &str,
491 headings: &HashMap<&str, HashSet<String>>,
492) {
493 if fragment.is_empty() {
494 return;
495 }
496 if let Some(anchors) = headings.get(page) {
497 if !anchors.contains(&slugify(fragment)) {
498 println!(
499 "cargo:warning={src}: link \"{target}\" points to \"#{fragment}\" but no heading with that anchor exists in {page}"
500 );
501 }
502 }
503}
504
505fn validate_docs(manifest_dir: &str, docs_dir: &str, pages: &[String]) {
508 let mut contents: Vec<(String, String)> = Vec::new();
510 for page in pages {
511 let mdx_path = format!("{docs_dir}/{page}.mdx");
512 let full_path = include_path(manifest_dir, &mdx_path);
513 if let Ok(raw) = fs::read_to_string(&full_path) {
514 validate_docs_frontmatter(&mdx_path, &raw);
515 contents.push((page.clone(), raw));
516 }
517 }
518
519 let page_set: HashSet<&str> = pages.iter().map(String::as_str).collect();
520
521 let mut group_counts: HashMap<&str, usize> = HashMap::new();
522 for page in pages {
523 if let Some((group, _)) = page.split_once('/') {
524 *group_counts.entry(group).or_insert(0) += 1;
525 }
526 }
527
528 let stripped: Vec<(String, String)> = contents
530 .iter()
531 .map(|(page, raw)| (page.clone(), strip_code_fences(raw)))
532 .collect();
533
534 let mut headings: HashMap<&str, HashSet<String>> = HashMap::new();
535 for (page, body) in &stripped {
536 headings.insert(
537 page.as_str(),
538 extract_heading_slugs(body).into_iter().collect(),
539 );
540 }
541
542 for (page, body) in &stripped {
543 let src = format!("{docs_dir}/{page}.mdx");
544 for target in extract_links(body) {
545 check_link(&src, page, &target, &page_set, &group_counts, &headings);
546 }
547 }
548}
549
550fn validate_docs_frontmatter(path: &str, content: &str) {
553 let content = content.trim();
554 if !content.starts_with("---") {
555 return;
556 }
557 let after = &content[3..];
558 let Some(end) = after.find("\n---") else {
560 return;
561 };
562 let yaml = after[..end].trim();
563 if yaml.is_empty() {
564 return; }
566 match serde_yaml::from_str::<serde_yaml::Value>(yaml) {
567 Ok(serde_yaml::Value::Mapping(_)) => {}
568 Ok(_) => println!(
572 "cargo:warning={path}: leading --- block is not a YAML mapping and will render as page content, not frontmatter"
573 ),
574 Err(e) => println!(
575 "cargo:warning={path}: leading --- block is not valid YAML ({e}) and will render as page content, not frontmatter"
576 ),
577 }
578}
579
580#[derive(Deserialize)]
587#[allow(dead_code)]
588struct BlogFrontmatterCheck {
589 title: String,
590 #[serde(default)]
591 description: Option<String>,
592 date: String,
593 author: String,
594 #[serde(default)]
595 tags: Vec<String>,
596 #[serde(default, rename = "coverImage")]
597 cover_image: Option<String>,
598 #[serde(default)]
599 draft: bool,
600 #[serde(default)]
601 featured: bool,
602}
603
604fn validate_blog_frontmatter(path: &str, content: &str) {
608 let content = content.trim();
609 if !content.starts_with("---") {
610 panic!("{path}: missing frontmatter block (expected leading ---)");
611 }
612 let after = &content[3..];
613 let Some(end) = after.find("\n---") else {
614 panic!("{path}: unclosed frontmatter block (missing closing ---)");
615 };
616 let yaml = after[..end].trim();
617 if let Err(e) = serde_yaml::from_str::<BlogFrontmatterCheck>(yaml) {
618 panic!("{path}: malformed frontmatter: {e}");
619 }
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625
626 #[test]
627 fn include_path_joins_with_forward_slash() {
628 assert_eq!(
629 include_path("/home/me/project", "docs/intro.mdx"),
630 "/home/me/project/docs/intro.mdx"
631 );
632 }
633
634 #[test]
635 fn include_path_normalizes_windows_backslashes() {
636 assert_eq!(
637 include_path("C:\\Users\\me\\project", "docs\\intro.mdx"),
638 "C:/Users/me/project/docs/intro.mdx"
639 );
640 }
641
642 #[test]
646 fn slugify_matches_mdx() {
647 assert_eq!(slugify("Hello World"), "hello-world");
648 assert_eq!(slugify("Getting Started!"), "getting-started");
649 assert_eq!(slugify("API v1.0"), "api-v1-0");
650 assert_eq!(slugify("Tips & Tricks"), "tips-tricks");
651 assert_eq!(slugify("Tips & Tricks"), "tips-tricks");
652 assert_eq!(slugify("Q&A"), "qa");
653 assert_eq!(slugify("a < b"), "a-b");
654 assert_eq!(slugify("See [the docs](https://x.y/z)"), "see-the-docs");
655 }
656
657 #[test]
658 fn extract_links_skips_images() {
659 let md = "see  and [Quickstart](/docs/getting-started/quickstart)";
660 assert_eq!(
661 extract_links(md),
662 vec!["/docs/getting-started/quickstart".to_string()]
663 );
664 }
665
666 #[test]
667 fn extract_links_strips_title_and_angle_brackets() {
668 let md = "[a](/docs/x \"the title\") and [b](</docs/y>)";
669 assert_eq!(
670 extract_links(md),
671 vec!["/docs/x".to_string(), "/docs/y".to_string()]
672 );
673 }
674
675 #[test]
676 fn strip_code_fences_removes_fenced_links() {
677 let md = "before\n```\n[not a link](/docs/nope)\n```\nafter [real](/docs/real)";
678 let body = strip_code_fences(md);
679 assert!(!body.contains("nope"));
680 assert_eq!(extract_links(&body), vec!["/docs/real".to_string()]);
681 }
682
683 #[test]
684 fn has_scheme_detects_external() {
685 assert!(has_scheme("https://example.com"));
686 assert!(has_scheme("mailto:me@example.com"));
687 assert!(!has_scheme("/docs/guides/x"));
688 assert!(!has_scheme("guides/x"));
689 assert!(!has_scheme("../guides/x"));
690 }
691
692 #[test]
693 fn resolve_relative_resolves_against_dir() {
694 assert_eq!(
695 resolve_relative("guides/blog", "customization").as_deref(),
696 Some("guides/customization")
697 );
698 assert_eq!(
699 resolve_relative("guides/blog", "../guides/customization").as_deref(),
700 Some("guides/customization")
701 );
702 assert_eq!(
703 resolve_relative("getting-started/introduction", "../guides/basic-usage").as_deref(),
704 Some("guides/basic-usage")
705 );
706 assert_eq!(resolve_relative("changelog", "../../x"), None);
708 }
709
710 #[test]
711 fn extract_heading_slugs_covers_h2_to_h4_only() {
712 let md = "# Title\n## Section One\n### Sub Section\n##### Too Deep\ntext\n";
713 assert_eq!(
714 extract_heading_slugs(md),
715 vec!["section-one".to_string(), "sub-section".to_string()]
716 );
717 }
718
719 fn sample_page_data() -> (Vec<&'static str>, HashMap<&'static str, usize>) {
720 let pages = vec![
721 "getting-started/introduction",
722 "getting-started/quickstart",
723 "guides/basic-usage",
724 "guides/customization",
725 "guides/integration",
726 "guides/blog",
727 "api-reference/overview",
728 "changelog",
729 ];
730 let mut group_counts: HashMap<&str, usize> = HashMap::new();
731 for p in &pages {
732 if let Some((g, _)) = p.split_once('/') {
733 *group_counts.entry(g).or_insert(0) += 1;
734 }
735 }
736 (pages, group_counts)
737 }
738
739 #[test]
740 fn root_absolute_heuristic() {
741 let (pages, group_counts) = sample_page_data();
742 let page_set: HashSet<&str> = pages.iter().copied().collect();
743
744 assert!(matches!(
746 classify_root_absolute("docs/guides/basic-usage", &page_set, &group_counts),
747 LinkResolution::Valid(_)
748 ));
749 assert!(matches!(
751 classify_root_absolute("getting-started/introduction", &page_set, &group_counts),
752 LinkResolution::Valid(_)
753 ));
754 assert!(matches!(
756 classify_root_absolute("docs/guides/nope", &page_set, &group_counts),
757 LinkResolution::Broken
758 ));
759 assert!(matches!(
762 classify_root_absolute("docs/api-reference/getUser", &page_set, &group_counts),
763 LinkResolution::Skip
764 ));
765 assert!(matches!(
767 classify_root_absolute("blog/hello", &page_set, &group_counts),
768 LinkResolution::Skip
769 ));
770 }
771
772 #[test]
773 fn relative_heuristic() {
774 let (pages, group_counts) = sample_page_data();
775 let page_set: HashSet<&str> = pages.iter().copied().collect();
776
777 assert!(matches!(
778 classify_relative(
779 "guides/basic-usage",
780 "customization",
781 &page_set,
782 &group_counts
783 ),
784 LinkResolution::Valid(_)
785 ));
786 assert!(matches!(
787 classify_relative("guides/basic-usage", "nope", &page_set, &group_counts),
788 LinkResolution::Broken
789 ));
790 assert!(matches!(
792 classify_relative(
793 "api-reference/overview",
794 "get-user",
795 &page_set,
796 &group_counts
797 ),
798 LinkResolution::Skip
799 ));
800 }
801
802 #[test]
805 fn docs_frontmatter_valid_and_empty_ok() {
806 validate_docs_frontmatter("x.mdx", "---\ntitle: Hi\n---\nbody");
807 validate_docs_frontmatter("x.mdx", "---\n---\nbody");
808 validate_docs_frontmatter("x.mdx", "no frontmatter here");
809 }
810
811 #[test]
812 fn docs_frontmatter_unparseable_block_warns_but_does_not_panic() {
813 validate_docs_frontmatter("x.mdx", "---\ntitle: [unclosed\n---\nbody");
816 validate_docs_frontmatter("x.mdx", "---\n- a\n- b\n---\nbody");
817 validate_docs_frontmatter("x.mdx", "---\nJust a fenced paragraph.\n---\nbody");
818 }
819
820 #[test]
821 fn blog_frontmatter_valid_ok() {
822 validate_blog_frontmatter(
823 "p.mdx",
824 "---\ntitle: Hi\ndate: \"2026-01-01\"\nauthor: jane\n---\nbody",
825 );
826 }
827
828 #[test]
829 #[should_panic(expected = "malformed frontmatter")]
830 fn blog_frontmatter_bad_yaml_panics() {
831 validate_blog_frontmatter(
832 "p.mdx",
833 "---\ntitle: [x\ndate: \"2026\"\nauthor: jane\n---\nbody",
834 );
835 }
836
837 #[test]
838 #[should_panic(expected = "missing field")]
839 fn blog_frontmatter_missing_field_panics() {
840 validate_blog_frontmatter("p.mdx", "---\ntitle: Hi\nauthor: jane\n---\nbody");
842 }
843
844 #[test]
845 #[should_panic(expected = "missing frontmatter")]
846 fn blog_frontmatter_no_block_panics() {
847 validate_blog_frontmatter("p.mdx", "just body, no frontmatter");
848 }
849
850 #[test]
851 #[should_panic(expected = "invalid type")]
852 fn blog_frontmatter_wrong_typed_optional_field_panics() {
853 validate_blog_frontmatter(
856 "p.mdx",
857 "---\ntitle: Hi\ndate: \"2026-01-01\"\nauthor: jane\ntags: rust\n---\nbody",
858 );
859 }
860
861 #[test]
862 fn blog_frontmatter_optional_fields_ok() {
863 validate_blog_frontmatter(
864 "p.mdx",
865 "---\ntitle: Hi\ndate: \"2026-01-01\"\nauthor: jane\ntags: [rust, web]\ndraft: true\ncoverImage: cover.png\n---\nbody",
866 );
867 }
868}