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                    && 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
312/// Returns true if `target` begins with a URL scheme (`https:`, `mailto:`, …).
313fn 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
330/// Strip a trailing slash and any `.mdx`/`.md` extension so a link target lines
331/// up with the extension-less nav page keys.
332fn 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
341/// Resolve a relative link target against the directory of `current_page`.
342/// Returns `None` if the path escapes the docs root.
343fn resolve_relative(current_page: &str, path: &str) -> Option<String> {
344    let mut base: Vec<&str> = current_page.split('/').collect();
345    base.pop(); // drop the current file component, keeping its directory
346    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
358/// Outcome of resolving an internal link target to a docs page.
359enum LinkResolution {
360    /// Resolved to a known page (carries its key for anchor validation).
361    Valid(String),
362    /// Clearly targets a docs page, but no page matches.
363    Broken,
364    /// Not validatable (external route, runtime-generated section, …).
365    Skip,
366}
367
368/// A group directory is "validatable" only if it holds at least two static nav
369/// pages. Single-page groups (e.g. an `api-reference` group whose remaining
370/// pages are generated at runtime from an OpenAPI spec) are skipped, since the
371/// build crate cannot know those slugs and would false-positive on them.
372fn 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
378/// Classify a root-absolute link (e.g. `/docs/guides/foo`). The consumer's base
379/// path (e.g. `/docs`) is unknown, so both the full path and the path with its
380/// first segment stripped are tried against the known pages.
381fn 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
409/// Classify a relative link (e.g. `../guides/foo`) resolved against the current
410/// file's directory.
411fn 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
434/// Validate a single link target found in `src`. Emits `cargo:warning` for
435/// broken page targets and missing anchors.
436fn 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    // Same-page anchor (`#heading`).
450    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    // Skip external links (`https:`, `mailto:`, protocol-relative `//host`).
458    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
483/// Validate that `fragment` matches a heading anchor in `page`. Skipped when the
484/// target page's headings are unknown (its file was not read).
485fn 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
504/// Validate docs frontmatter (build error on malformed) and internal markdown
505/// links (warnings only) for every existing nav page.
506fn validate_docs(manifest_dir: &str, docs_dir: &str, pages: &[String]) {
507    // Read each existing page once.
508    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    // Strip fenced code once and reuse for both headings and link scanning.
528    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
549/// A docs page's frontmatter block (if present) must parse as a YAML mapping.
550/// No particular fields are required for docs pages.
551fn 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    // No closing delimiter → not a frontmatter block (matches runtime behavior).
558    let Some(end) = after.find("\n---") else {
559        return;
560    };
561    let yaml = after[..end].trim();
562    if yaml.is_empty() {
563        return; // an empty frontmatter block is valid
564    }
565    match serde_yaml::from_str::<serde_yaml::Value>(yaml) {
566        Ok(serde_yaml::Value::Mapping(_)) => {}
567        // The runtime treats an unparseable leading block as page content and
568        // still renders the page (a `---`-fenced paragraph is legal markdown),
569        // so a hard build failure here would reject pages that work. Warn only.
570        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/// Blog frontmatter fields, mirroring
580/// `dioxus_docs_kit::blog::types::BlogFrontmatter` (which a build crate cannot
581/// depend on). ALL fields are mirrored, including optional ones: a
582/// present-but-wrong-typed optional field (e.g. `tags: rust` instead of a
583/// sequence) is a hard deserialize error at runtime that silently drops the
584/// post, so it must fail the build here too.
585#[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
603/// A blog post must have a valid frontmatter block carrying the required
604/// fields, or the build fails (the post would otherwise silently vanish from
605/// the site at runtime). The extraction mirrors `extract_blog_frontmatter`.
606fn 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    // ---- link validation ---------------------------------------------------
642
643    // Mirrors `dioxus_mdx`'s own `slugify` test cases.
644    #[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 &amp; Tricks"), "tips-tricks");
651        assert_eq!(slugify("Q&A"), "qa");
652        assert_eq!(slugify("a &lt; 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 ![alt](/img/logo.png) 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        // Escapes the docs root.
706        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        // Valid under a `/docs` base path.
744        assert!(matches!(
745            classify_root_absolute("docs/guides/basic-usage", &page_set, &group_counts),
746            LinkResolution::Valid(_)
747        ));
748        // Valid under an empty (`/`) base path.
749        assert!(matches!(
750            classify_root_absolute("getting-started/introduction", &page_set, &group_counts),
751            LinkResolution::Valid(_)
752        ));
753        // Broken: a dense group, but no such page.
754        assert!(matches!(
755            classify_root_absolute("docs/guides/nope", &page_set, &group_counts),
756            LinkResolution::Broken
757        ));
758        // Skip: api-reference has a single static page; the rest are runtime
759        // OpenAPI operations the build crate cannot see.
760        assert!(matches!(
761            classify_root_absolute("docs/api-reference/getUser", &page_set, &group_counts),
762            LinkResolution::Skip
763        ));
764        // Skip: non-docs routes.
765        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        // Relative link into the single-page (OpenAPI) group is skipped.
790        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    // ---- frontmatter validation --------------------------------------------
802
803    #[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        // The runtime renders these pages (the block is treated as content),
813        // so the build must not reject them — it only emits cargo:warning.
814        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        // No `date` field.
840        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        // `tags` must be a sequence; a scalar fails deserialization at runtime
853        // and would silently drop the post, so it must fail the build.
854        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}