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 && let Some(pclose) = (close + 2..bytes.len()).find(|&j| bytes[j] == b')')
291 {
292 if !is_image
293 && let Some(tok) = content[close + 2..pclose].split_whitespace().next()
294 {
295 let tok = tok.trim_start_matches('<').trim_end_matches('>');
296 if !tok.is_empty() {
297 links.push(tok.to_string());
298 }
299 }
300 i = pclose + 1;
301 continue;
302 }
303 i = close + 1;
304 continue;
305 }
306 }
307 i += 1;
308 }
309 links
310}
311
312fn has_scheme(target: &str) -> bool {
314 let mut chars = target.chars();
315 match chars.next() {
316 Some(c) if c.is_ascii_alphabetic() => {}
317 _ => return false,
318 }
319 for c in chars {
320 if c == ':' {
321 return true;
322 }
323 if !(c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') {
324 return false;
325 }
326 }
327 false
328}
329
330fn normalize_page_key(s: &str) -> String {
333 let s = s.trim_end_matches('/');
334 let s = s
335 .strip_suffix(".mdx")
336 .or_else(|| s.strip_suffix(".md"))
337 .unwrap_or(s);
338 s.to_string()
339}
340
341fn resolve_relative(current_page: &str, path: &str) -> Option<String> {
344 let mut base: Vec<&str> = current_page.split('/').collect();
345 base.pop(); for seg in path.split('/') {
347 match seg {
348 "" | "." => {}
349 ".." => {
350 base.pop()?;
351 }
352 s => base.push(s),
353 }
354 }
355 Some(base.join("/"))
356}
357
358enum LinkResolution {
360 Valid(String),
362 Broken,
364 Skip,
366}
367
368fn group_is_validatable(page: &str, group_counts: &HashMap<&str, usize>) -> bool {
373 page.split_once('/')
374 .map(|(g, _)| group_counts.get(g).copied().unwrap_or(0) >= 2)
375 .unwrap_or(false)
376}
377
378fn classify_root_absolute(
382 rest: &str,
383 page_set: &HashSet<&str>,
384 group_counts: &HashMap<&str, usize>,
385) -> LinkResolution {
386 let full = normalize_page_key(rest);
387 let stripped = rest.split_once('/').map(|(_, s)| normalize_page_key(s));
388
389 if page_set.contains(full.as_str()) {
390 return LinkResolution::Valid(full);
391 }
392 if let Some(s) = &stripped
393 && page_set.contains(s.as_str())
394 {
395 return LinkResolution::Valid(s.clone());
396 }
397
398 let clearly_docs = group_is_validatable(&full, group_counts)
399 || stripped
400 .as_ref()
401 .is_some_and(|s| group_is_validatable(s, group_counts));
402 if clearly_docs {
403 LinkResolution::Broken
404 } else {
405 LinkResolution::Skip
406 }
407}
408
409fn classify_relative(
412 current_page: &str,
413 path: &str,
414 page_set: &HashSet<&str>,
415 group_counts: &HashMap<&str, usize>,
416) -> LinkResolution {
417 let Some(resolved) = resolve_relative(current_page, path) else {
418 return LinkResolution::Skip;
419 };
420 let resolved = normalize_page_key(&resolved);
421 if resolved.is_empty() {
422 return LinkResolution::Skip;
423 }
424 if page_set.contains(resolved.as_str()) {
425 return LinkResolution::Valid(resolved);
426 }
427 if group_is_validatable(&resolved, group_counts) {
428 LinkResolution::Broken
429 } else {
430 LinkResolution::Skip
431 }
432}
433
434fn check_link(
437 src: &str,
438 current_page: &str,
439 target: &str,
440 page_set: &HashSet<&str>,
441 group_counts: &HashMap<&str, usize>,
442 headings: &HashMap<&str, HashSet<String>>,
443) {
444 let (path_part, fragment) = match target.split_once('#') {
445 Some((p, f)) => (p, Some(f)),
446 None => (target, None),
447 };
448
449 if path_part.is_empty() {
451 if let Some(frag) = fragment {
452 check_anchor(src, current_page, target, frag, headings);
453 }
454 return;
455 }
456
457 if path_part.starts_with("//") || has_scheme(path_part) {
459 return;
460 }
461
462 let resolution = if let Some(rest) = path_part.strip_prefix('/') {
463 classify_root_absolute(rest, page_set, group_counts)
464 } else {
465 classify_relative(current_page, path_part, page_set, group_counts)
466 };
467
468 match resolution {
469 LinkResolution::Valid(page) => {
470 if let Some(frag) = fragment {
471 check_anchor(src, &page, target, frag, headings);
472 }
473 }
474 LinkResolution::Broken => {
475 println!(
476 "cargo:warning={src}: internal link target \"{target}\" does not match any known docs page"
477 );
478 }
479 LinkResolution::Skip => {}
480 }
481}
482
483fn check_anchor(
486 src: &str,
487 page: &str,
488 target: &str,
489 fragment: &str,
490 headings: &HashMap<&str, HashSet<String>>,
491) {
492 if fragment.is_empty() {
493 return;
494 }
495 if let Some(anchors) = headings.get(page)
496 && !anchors.contains(&slugify(fragment))
497 {
498 println!(
499 "cargo:warning={src}: link \"{target}\" points to \"#{fragment}\" but no heading with that anchor exists in {page}"
500 );
501 }
502}
503
504fn validate_docs(manifest_dir: &str, docs_dir: &str, pages: &[String]) {
507 let mut contents: Vec<(String, String)> = Vec::new();
509 for page in pages {
510 let mdx_path = format!("{docs_dir}/{page}.mdx");
511 let full_path = include_path(manifest_dir, &mdx_path);
512 if let Ok(raw) = fs::read_to_string(&full_path) {
513 validate_docs_frontmatter(&mdx_path, &raw);
514 contents.push((page.clone(), raw));
515 }
516 }
517
518 let page_set: HashSet<&str> = pages.iter().map(String::as_str).collect();
519
520 let mut group_counts: HashMap<&str, usize> = HashMap::new();
521 for page in pages {
522 if let Some((group, _)) = page.split_once('/') {
523 *group_counts.entry(group).or_insert(0) += 1;
524 }
525 }
526
527 let stripped: Vec<(String, String)> = contents
529 .iter()
530 .map(|(page, raw)| (page.clone(), strip_code_fences(raw)))
531 .collect();
532
533 let mut headings: HashMap<&str, HashSet<String>> = HashMap::new();
534 for (page, body) in &stripped {
535 headings.insert(
536 page.as_str(),
537 extract_heading_slugs(body).into_iter().collect(),
538 );
539 }
540
541 for (page, body) in &stripped {
542 let src = format!("{docs_dir}/{page}.mdx");
543 for target in extract_links(body) {
544 check_link(&src, page, &target, &page_set, &group_counts, &headings);
545 }
546 }
547}
548
549fn validate_docs_frontmatter(path: &str, content: &str) {
552 let content = content.trim();
553 if !content.starts_with("---") {
554 return;
555 }
556 let after = &content[3..];
557 let Some(end) = after.find("\n---") else {
559 return;
560 };
561 let yaml = after[..end].trim();
562 if yaml.is_empty() {
563 return; }
565 match serde_yaml::from_str::<serde_yaml::Value>(yaml) {
566 Ok(serde_yaml::Value::Mapping(_)) => {}
567 Ok(_) => println!(
571 "cargo:warning={path}: leading --- block is not a YAML mapping and will render as page content, not frontmatter"
572 ),
573 Err(e) => println!(
574 "cargo:warning={path}: leading --- block is not valid YAML ({e}) and will render as page content, not frontmatter"
575 ),
576 }
577}
578
579#[derive(Deserialize)]
586#[allow(dead_code)]
587struct BlogFrontmatterCheck {
588 title: String,
589 #[serde(default)]
590 description: Option<String>,
591 date: String,
592 author: String,
593 #[serde(default)]
594 tags: Vec<String>,
595 #[serde(default, rename = "coverImage")]
596 cover_image: Option<String>,
597 #[serde(default)]
598 draft: bool,
599 #[serde(default)]
600 featured: bool,
601}
602
603fn validate_blog_frontmatter(path: &str, content: &str) {
607 let content = content.trim();
608 if !content.starts_with("---") {
609 panic!("{path}: missing frontmatter block (expected leading ---)");
610 }
611 let after = &content[3..];
612 let Some(end) = after.find("\n---") else {
613 panic!("{path}: unclosed frontmatter block (missing closing ---)");
614 };
615 let yaml = after[..end].trim();
616 if let Err(e) = serde_yaml::from_str::<BlogFrontmatterCheck>(yaml) {
617 panic!("{path}: malformed frontmatter: {e}");
618 }
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624
625 #[test]
626 fn include_path_joins_with_forward_slash() {
627 assert_eq!(
628 include_path("/home/me/project", "docs/intro.mdx"),
629 "/home/me/project/docs/intro.mdx"
630 );
631 }
632
633 #[test]
634 fn include_path_normalizes_windows_backslashes() {
635 assert_eq!(
636 include_path("C:\\Users\\me\\project", "docs\\intro.mdx"),
637 "C:/Users/me/project/docs/intro.mdx"
638 );
639 }
640
641 #[test]
645 fn slugify_matches_mdx() {
646 assert_eq!(slugify("Hello World"), "hello-world");
647 assert_eq!(slugify("Getting Started!"), "getting-started");
648 assert_eq!(slugify("API v1.0"), "api-v1-0");
649 assert_eq!(slugify("Tips & Tricks"), "tips-tricks");
650 assert_eq!(slugify("Tips & Tricks"), "tips-tricks");
651 assert_eq!(slugify("Q&A"), "qa");
652 assert_eq!(slugify("a < b"), "a-b");
653 assert_eq!(slugify("See [the docs](https://x.y/z)"), "see-the-docs");
654 }
655
656 #[test]
657 fn extract_links_skips_images() {
658 let md = "see  and [Quickstart](/docs/getting-started/quickstart)";
659 assert_eq!(
660 extract_links(md),
661 vec!["/docs/getting-started/quickstart".to_string()]
662 );
663 }
664
665 #[test]
666 fn extract_links_strips_title_and_angle_brackets() {
667 let md = "[a](/docs/x \"the title\") and [b](</docs/y>)";
668 assert_eq!(
669 extract_links(md),
670 vec!["/docs/x".to_string(), "/docs/y".to_string()]
671 );
672 }
673
674 #[test]
675 fn strip_code_fences_removes_fenced_links() {
676 let md = "before\n```\n[not a link](/docs/nope)\n```\nafter [real](/docs/real)";
677 let body = strip_code_fences(md);
678 assert!(!body.contains("nope"));
679 assert_eq!(extract_links(&body), vec!["/docs/real".to_string()]);
680 }
681
682 #[test]
683 fn has_scheme_detects_external() {
684 assert!(has_scheme("https://example.com"));
685 assert!(has_scheme("mailto:me@example.com"));
686 assert!(!has_scheme("/docs/guides/x"));
687 assert!(!has_scheme("guides/x"));
688 assert!(!has_scheme("../guides/x"));
689 }
690
691 #[test]
692 fn resolve_relative_resolves_against_dir() {
693 assert_eq!(
694 resolve_relative("guides/blog", "customization").as_deref(),
695 Some("guides/customization")
696 );
697 assert_eq!(
698 resolve_relative("guides/blog", "../guides/customization").as_deref(),
699 Some("guides/customization")
700 );
701 assert_eq!(
702 resolve_relative("getting-started/introduction", "../guides/basic-usage").as_deref(),
703 Some("guides/basic-usage")
704 );
705 assert_eq!(resolve_relative("changelog", "../../x"), None);
707 }
708
709 #[test]
710 fn extract_heading_slugs_covers_h2_to_h4_only() {
711 let md = "# Title\n## Section One\n### Sub Section\n##### Too Deep\ntext\n";
712 assert_eq!(
713 extract_heading_slugs(md),
714 vec!["section-one".to_string(), "sub-section".to_string()]
715 );
716 }
717
718 fn sample_page_data() -> (Vec<&'static str>, HashMap<&'static str, usize>) {
719 let pages = vec![
720 "getting-started/introduction",
721 "getting-started/quickstart",
722 "guides/basic-usage",
723 "guides/customization",
724 "guides/integration",
725 "guides/blog",
726 "api-reference/overview",
727 "changelog",
728 ];
729 let mut group_counts: HashMap<&str, usize> = HashMap::new();
730 for p in &pages {
731 if let Some((g, _)) = p.split_once('/') {
732 *group_counts.entry(g).or_insert(0) += 1;
733 }
734 }
735 (pages, group_counts)
736 }
737
738 #[test]
739 fn root_absolute_heuristic() {
740 let (pages, group_counts) = sample_page_data();
741 let page_set: HashSet<&str> = pages.iter().copied().collect();
742
743 assert!(matches!(
745 classify_root_absolute("docs/guides/basic-usage", &page_set, &group_counts),
746 LinkResolution::Valid(_)
747 ));
748 assert!(matches!(
750 classify_root_absolute("getting-started/introduction", &page_set, &group_counts),
751 LinkResolution::Valid(_)
752 ));
753 assert!(matches!(
755 classify_root_absolute("docs/guides/nope", &page_set, &group_counts),
756 LinkResolution::Broken
757 ));
758 assert!(matches!(
761 classify_root_absolute("docs/api-reference/getUser", &page_set, &group_counts),
762 LinkResolution::Skip
763 ));
764 assert!(matches!(
766 classify_root_absolute("blog/hello", &page_set, &group_counts),
767 LinkResolution::Skip
768 ));
769 }
770
771 #[test]
772 fn relative_heuristic() {
773 let (pages, group_counts) = sample_page_data();
774 let page_set: HashSet<&str> = pages.iter().copied().collect();
775
776 assert!(matches!(
777 classify_relative(
778 "guides/basic-usage",
779 "customization",
780 &page_set,
781 &group_counts
782 ),
783 LinkResolution::Valid(_)
784 ));
785 assert!(matches!(
786 classify_relative("guides/basic-usage", "nope", &page_set, &group_counts),
787 LinkResolution::Broken
788 ));
789 assert!(matches!(
791 classify_relative(
792 "api-reference/overview",
793 "get-user",
794 &page_set,
795 &group_counts
796 ),
797 LinkResolution::Skip
798 ));
799 }
800
801 #[test]
804 fn docs_frontmatter_valid_and_empty_ok() {
805 validate_docs_frontmatter("x.mdx", "---\ntitle: Hi\n---\nbody");
806 validate_docs_frontmatter("x.mdx", "---\n---\nbody");
807 validate_docs_frontmatter("x.mdx", "no frontmatter here");
808 }
809
810 #[test]
811 fn docs_frontmatter_unparseable_block_warns_but_does_not_panic() {
812 validate_docs_frontmatter("x.mdx", "---\ntitle: [unclosed\n---\nbody");
815 validate_docs_frontmatter("x.mdx", "---\n- a\n- b\n---\nbody");
816 validate_docs_frontmatter("x.mdx", "---\nJust a fenced paragraph.\n---\nbody");
817 }
818
819 #[test]
820 fn blog_frontmatter_valid_ok() {
821 validate_blog_frontmatter(
822 "p.mdx",
823 "---\ntitle: Hi\ndate: \"2026-01-01\"\nauthor: jane\n---\nbody",
824 );
825 }
826
827 #[test]
828 #[should_panic(expected = "malformed frontmatter")]
829 fn blog_frontmatter_bad_yaml_panics() {
830 validate_blog_frontmatter(
831 "p.mdx",
832 "---\ntitle: [x\ndate: \"2026\"\nauthor: jane\n---\nbody",
833 );
834 }
835
836 #[test]
837 #[should_panic(expected = "missing field")]
838 fn blog_frontmatter_missing_field_panics() {
839 validate_blog_frontmatter("p.mdx", "---\ntitle: Hi\nauthor: jane\n---\nbody");
841 }
842
843 #[test]
844 #[should_panic(expected = "missing frontmatter")]
845 fn blog_frontmatter_no_block_panics() {
846 validate_blog_frontmatter("p.mdx", "just body, no frontmatter");
847 }
848
849 #[test]
850 #[should_panic(expected = "invalid type")]
851 fn blog_frontmatter_wrong_typed_optional_field_panics() {
852 validate_blog_frontmatter(
855 "p.mdx",
856 "---\ntitle: Hi\ndate: \"2026-01-01\"\nauthor: jane\ntags: rust\n---\nbody",
857 );
858 }
859
860 #[test]
861 fn blog_frontmatter_optional_fields_ok() {
862 validate_blog_frontmatter(
863 "p.mdx",
864 "---\ntitle: Hi\ndate: \"2026-01-01\"\nauthor: jane\ntags: [rust, web]\ndraft: true\ncoverImage: cover.png\n---\nbody",
865 );
866 }
867}