Skip to main content

dioxus_docs_kit_build/
lib.rs

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
17/// Builds the absolute path used inside the generated `include_str!()`.
18///
19/// Backslashes are normalized to forward slashes so the generated string
20/// literal is valid on Windows (`C:\Users\...` would otherwise contain
21/// invalid escape sequences).
22fn include_path(manifest_dir: &str, relative: &str) -> String {
23    format!("{manifest_dir}/{relative}").replace('\\', "/")
24}
25
26/// Emits a `map.insert(...)` line for `relative` if the file exists.
27///
28/// Missing files are skipped with a warning, but still registered via
29/// `rerun-if-changed` so the build script re-runs once the file is created
30/// (cargo re-runs when a watched path does not exist).
31fn 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    // Use absolute path so include_str! works from OUT_DIR
44    code.push_str(&format!(
45        "    map.insert(\"{key}\", include_str!(\"{full_path}\"));\n"
46    ));
47}
48
49/// Generates `doc_content_generated.rs` in `OUT_DIR` from a `_nav.json` file.
50///
51/// Call this from your `build.rs`:
52///
53/// ```rust,ignore
54/// fn main() {
55///     dioxus_docs_kit_build::generate_content_map("docs/_nav.json");
56/// }
57/// ```
58///
59/// The generated file is an expression that returns a `HashMap<&'static str, &'static str>`
60/// and is intended to be used with `include!()`.
61///
62/// The docs directory is inferred from the parent of `nav_json_path`
63/// (e.g. `"docs/_nav.json"` → `"docs"`).
64pub 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    // Infer docs directory from nav path parent (e.g. "docs/_nav.json" → "docs")
75    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    // Fail the build on malformed docs frontmatter, and warn about broken
97    // internal links (warnings only — never fail the build for links).
98    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// ============================================================================
107// Blog content map generation
108// ============================================================================
109
110#[derive(Deserialize)]
111struct BlogManifest {
112    posts: Vec<String>,
113}
114
115/// Generates `blog_content_generated.rs` in `OUT_DIR` from a `_blog.json` file.
116///
117/// Call this from your `build.rs`:
118///
119/// ```rust,ignore
120/// fn main() {
121///     dioxus_docs_kit_build::generate_blog_content_map("blog/_blog.json");
122/// }
123/// ```
124///
125/// The generated file is an expression that returns a `HashMap<&'static str, &'static str>`
126/// and is intended to be used with `include!()`.
127///
128/// The blog directory is inferred from the parent of `manifest_path`
129/// (e.g. `"blog/_blog.json"` → `"blog"`).
130///
131/// The manifest JSON itself is embedded under the key `"__manifest__"` so the
132/// runtime library can parse author definitions and other metadata.
133pub 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    // Infer blog directory from manifest path parent
144    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    // Embed the manifest JSON itself
153    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        // Fail the build on malformed frontmatter instead of letting the post
163        // silently vanish from the site at runtime.
164        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
177// ============================================================================
178// Build-time validation: internal links + frontmatter
179// ============================================================================
180
181/// Convert a heading title to a URL anchor slug.
182///
183/// Mirrors `dioxus_mdx`'s `slugify` (in `components/toc.rs`) exactly, so
184/// build-time anchor checks resolve to the same ids the renderer injects. The
185/// build crate cannot depend on `dioxus-mdx` (that would pull `dioxus` into
186/// every consumer's build-dependencies), so this small function is duplicated.
187fn slugify(text: &str) -> String {
188    let text = text
189        .replace("&lt;", "<")
190        .replace("&gt;", ">")
191        .replace("&quot;", "\"")
192        .replace("&#39;", "'")
193        .replace("&amp;", "&");
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
213/// Reduce markdown links/images `[text](url)` to their text (mirrors
214/// `dioxus_mdx`'s `strip_markdown_links`, for the same slug-agreement reason).
215fn 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
235/// Remove fenced code blocks (``` or ~~~) so markdown-looking text inside code
236/// samples is not mistaken for links or headings.
237fn 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), // opening fence
251            (Some(open), Some(m)) if open == m => fence = None, // closing fence
252            (None, None) => {
253                out.push_str(line);
254                out.push('\n');
255            }
256            _ => {} // inside a fence (or a mismatched fence marker within one)
257        }
258    }
259    out
260}
261
262/// Extract anchor slugs for level 2-4 ATX headings, matching the ids the
263/// renderer injects (`dioxus_mdx`'s `extract_headers` + `slugify`). H1 is
264/// excluded (it is not linkable and is stripped as the duplicate page title).
265fn 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
279/// Extract non-image markdown link targets (`[text](target)`), stripping any
280/// `"title"` suffix and `<>` wrappers. Image links (`![...](...)`) are skipped.
281fn 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
313/// Returns true if `target` begins with a URL scheme (`https:`, `mailto:`, …).
314fn 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
331/// Strip a trailing slash and any `.mdx`/`.md` extension so a link target lines
332/// up with the extension-less nav page keys.
333fn 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
342/// Resolve a relative link target against the directory of `current_page`.
343/// Returns `None` if the path escapes the docs root.
344fn resolve_relative(current_page: &str, path: &str) -> Option<String> {
345    let mut base: Vec<&str> = current_page.split('/').collect();
346    base.pop(); // drop the current file component, keeping its directory
347    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
359/// Outcome of resolving an internal link target to a docs page.
360enum LinkResolution {
361    /// Resolved to a known page (carries its key for anchor validation).
362    Valid(String),
363    /// Clearly targets a docs page, but no page matches.
364    Broken,
365    /// Not validatable (external route, runtime-generated section, …).
366    Skip,
367}
368
369/// A group directory is "validatable" only if it holds at least two static nav
370/// pages. Single-page groups (e.g. an `api-reference` group whose remaining
371/// pages are generated at runtime from an OpenAPI spec) are skipped, since the
372/// build crate cannot know those slugs and would false-positive on them.
373fn 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
379/// Classify a root-absolute link (e.g. `/docs/guides/foo`). The consumer's base
380/// path (e.g. `/docs`) is unknown, so both the full path and the path with its
381/// first segment stripped are tried against the known pages.
382fn 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
410/// Classify a relative link (e.g. `../guides/foo`) resolved against the current
411/// file's directory.
412fn 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
435/// Validate a single link target found in `src`. Emits `cargo:warning` for
436/// broken page targets and missing anchors.
437fn 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    // Same-page anchor (`#heading`).
451    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    // Skip external links (`https:`, `mailto:`, protocol-relative `//host`).
459    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
484/// Validate that `fragment` matches a heading anchor in `page`. Skipped when the
485/// target page's headings are unknown (its file was not read).
486fn 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
505/// Validate docs frontmatter (build error on malformed) and internal markdown
506/// links (warnings only) for every existing nav page.
507fn validate_docs(manifest_dir: &str, docs_dir: &str, pages: &[String]) {
508    // Read each existing page once.
509    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    // Strip fenced code once and reuse for both headings and link scanning.
529    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
550/// A docs page's frontmatter block (if present) must parse as a YAML mapping.
551/// No particular fields are required for docs pages.
552fn 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    // No closing delimiter → not a frontmatter block (matches runtime behavior).
559    let Some(end) = after.find("\n---") else {
560        return;
561    };
562    let yaml = after[..end].trim();
563    if yaml.is_empty() {
564        return; // an empty frontmatter block is valid
565    }
566    match serde_yaml::from_str::<serde_yaml::Value>(yaml) {
567        Ok(serde_yaml::Value::Mapping(_)) => {}
568        // The runtime treats an unparseable leading block as page content and
569        // still renders the page (a `---`-fenced paragraph is legal markdown),
570        // so a hard build failure here would reject pages that work. Warn only.
571        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/// Blog frontmatter fields, mirroring
581/// `dioxus_docs_kit::blog::types::BlogFrontmatter` (which a build crate cannot
582/// depend on). ALL fields are mirrored, including optional ones: a
583/// present-but-wrong-typed optional field (e.g. `tags: rust` instead of a
584/// sequence) is a hard deserialize error at runtime that silently drops the
585/// post, so it must fail the build here too.
586#[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
604/// A blog post must have a valid frontmatter block carrying the required
605/// fields, or the build fails (the post would otherwise silently vanish from
606/// the site at runtime). The extraction mirrors `extract_blog_frontmatter`.
607fn 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    // ---- link validation ---------------------------------------------------
643
644    // Mirrors `dioxus_mdx`'s own `slugify` test cases.
645    #[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 &amp; Tricks"), "tips-tricks");
652        assert_eq!(slugify("Q&A"), "qa");
653        assert_eq!(slugify("a &lt; 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 ![alt](/img/logo.png) 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        // Escapes the docs root.
707        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        // Valid under a `/docs` base path.
745        assert!(matches!(
746            classify_root_absolute("docs/guides/basic-usage", &page_set, &group_counts),
747            LinkResolution::Valid(_)
748        ));
749        // Valid under an empty (`/`) base path.
750        assert!(matches!(
751            classify_root_absolute("getting-started/introduction", &page_set, &group_counts),
752            LinkResolution::Valid(_)
753        ));
754        // Broken: a dense group, but no such page.
755        assert!(matches!(
756            classify_root_absolute("docs/guides/nope", &page_set, &group_counts),
757            LinkResolution::Broken
758        ));
759        // Skip: api-reference has a single static page; the rest are runtime
760        // OpenAPI operations the build crate cannot see.
761        assert!(matches!(
762            classify_root_absolute("docs/api-reference/getUser", &page_set, &group_counts),
763            LinkResolution::Skip
764        ));
765        // Skip: non-docs routes.
766        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        // Relative link into the single-page (OpenAPI) group is skipped.
791        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    // ---- frontmatter validation --------------------------------------------
803
804    #[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        // The runtime renders these pages (the block is treated as content),
814        // so the build must not reject them — it only emits cargo:warning.
815        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        // No `date` field.
841        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        // `tags` must be a sequence; a scalar fails deserialization at runtime
854        // and would silently drop the post, so it must fail the build.
855        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}