Skip to main content

ssg/core/
content_stager.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Content staging — residual upstream-gap workarounds for the
5//! `staticdatagen` → `staticweaver` → `metadata-gen` pipeline.
6//!
7//! ## v0.0.46 residual scope
8//!
9//! Most of v0.0.45's shim layer was retired in v0.0.46 by upstream
10//! fixes:
11//!
12//! - `staticdatagen 0.0.10` (closes upstream `#67`, `#68`, `#69`,
13//!   `#70`, `#71`) handles missing `layout:` keys, absent
14//!   `main.js`/`sw.js`, absent tags-page templates, nested-locale
15//!   walk (`_posts/<lang>/`), and success-log ordering natively.
16//! - `staticweaver 0.0.3` (closes upstream `#28`) added the
17//!   `Engine::with_lax_undefined(true)` opt-in.
18//! - `staticweaver 0.0.3` also made `escape_html_into` idempotent
19//!   (closes [ssg #589](https://github.com/sebastienrousseau/static-site-generator/issues/589)).
20//! - `rss-gen 0.0.6` (closes upstream `#34`) prefixed validation
21//!   errors with `channel.` / `item.` context and accepts
22//!   relative URLs at item level.
23//! - `metadata-gen 0.0.5` (closes upstream `#20`) collapses
24//!   multi-line double-quoted YAML scalars internally.
25//!
26//! What remains here are **two** narrow gaps the upstreams haven't
27//! closed yet:
28//!
29//! 1. **Template-default injection.** `staticdatagen 0.0.10` doesn't
30//!    yet opt the staticweaver Engine into `lax_undefined` (tracked at
31//!    [staticdatagen #99](https://github.com/sebastienrousseau/staticdatagen/issues/99)),
32//!    so unresolved `{{ var }}` tags still abort the build.
33//!    [`collect_template_vars`] + [`stage_content_with_template_defaults`]
34//!    pre-fill an empty `var: ""` for every key the templates reference
35//!    but the content omits.
36//!
37//! 2. **Multi-line quoted-scalar collapse.** `staticdatagen 0.0.10`
38//!    pins `metadata-gen = "0.0.4"` (the pre-#20 release; tracked at
39//!    [staticdatagen #100](https://github.com/sebastienrousseau/staticdatagen/issues/100)).
40//!    `copy_tree` (the per-file staging helper) applies the same collapse pass that's now upstream
41//!    in `metadata-gen 0.0.5`, so the user's content sees consistent
42//!    behaviour regardless of which `metadata-gen` is transitively
43//!    resolved.
44//!
45//! Both shims auto-retire when the corresponding staticdatagen follow-up
46//! releases — the residual module shrinks to nothing.
47//!
48//! ## Permalink derivation (spec A2/B1, plan §2 item 1.2, issue #586)
49//!
50//! `staticdatagen`'s RSS generator hard-fails the whole build when a
51//! post lacks `permalink:` front matter (`rss-gen`: "channel.link is
52//! missing"). Because every page passes through this stager before
53//! `staticdatagen::compile`, the stager can make that failure
54//! unreachable: when a staged `.md` file's frontmatter carries
55//! neither `permalink` nor `url`, [`stage_content_with_site_defaults`]
56//! injects `permalink: "{base_url}/{relative_output_path}"` derived
57//! via [`crate::urls::derive_permalink`]. Author-specified permalinks
58//! always win — files that already declare `permalink` or `url` pass
59//! through verbatim. Only YAML `---` fenced frontmatter flows through
60//! this stager (the template-default shim shares the same
61//! constraint); files without a frontmatter block are left untouched.
62//!
63//! ## Why staging instead of editing in-place?
64//!
65//! The user's checkout is sacred:
66//!
67//! - the build runs from a CI checkout the user expects to be read-only;
68//! - reruns of the build would re-inject defaults, doubling lines.
69//!
70//! Instead we operate on a fresh directory under
71//! [`std::env::temp_dir()`] (FNV-1a-keyed by `build_dir` + pid) that's
72//! recreated on every build.
73
74use std::fs;
75use std::io;
76use std::path::{Path, PathBuf};
77
78/// Stages a copy of `content_dir` and injects empty defaults for every
79/// `{{ var }}` reference the templates make.
80///
81/// Works around the staticweaver "Unresolved template tag" crash that
82/// fires when user content omits a key their template references —
83/// `staticdatagen 0.0.10` still calls `Engine::new(...)` without
84/// `.with_lax_undefined(true)`. Tracked at [staticdatagen #99].
85///
86/// `template_var_keys` is the result of [`collect_template_vars`] run
87/// over the user's template directory.
88///
89/// Convenience wrapper over [`stage_content_with_site_defaults`] with
90/// no base URL — no `permalink:` derivation happens on staged content.
91///
92/// [staticdatagen #99]: https://github.com/sebastienrousseau/staticdatagen/issues/99
93///
94/// # Errors
95///
96/// Returns [`io::Error`] when the staging directory cannot be created
97/// or a source file cannot be read or written.
98pub fn stage_content_with_template_defaults(
99    content_dir: &Path,
100    build_dir: &Path,
101    template_var_keys: &[String],
102) -> Result<PathBuf, io::Error> {
103    stage_content_with_site_defaults(
104        content_dir,
105        build_dir,
106        template_var_keys,
107        None,
108        &[],
109    )
110}
111
112/// Like [`stage_content_with_template_defaults`] but also derives a
113/// `permalink:` for staged `.md` files that don't declare one.
114///
115/// Injection targets every staged `.md` file whose frontmatter
116/// carries neither `permalink` nor `url` (spec A2/B1, plan §2 item
117/// 1.2, issue #586). The derived value comes from
118/// [`crate::urls::derive_permalink`] applied to `base_url` and the
119/// file's content-relative path — i.e.
120/// `{base_url}/{relative_output_path}` under the compiler's
121/// `foo.md → foo/index.html` output convention, published as a pretty
122/// directory URL (`{base_url}/foo/`). This guarantees `staticdatagen`
123/// / `rss-gen` always see a channel/item link and can never abort the
124/// build with "channel.link is missing".
125///
126/// Passing `base_url: None` (or an empty/whitespace-only base URL)
127/// disables permalink injection and behaves exactly like
128/// [`stage_content_with_template_defaults`] — an rss-gen-valid
129/// permalink must be an *absolute* URL, so there is nothing useful to
130/// derive without a base.
131///
132/// Author-specified front matter always wins: files that already
133/// declare `permalink` or `url` pass through verbatim.
134///
135/// # Errors
136///
137/// Returns [`io::Error`] when the staging directory cannot be created
138/// or a source file cannot be read or written.
139///
140/// # Examples
141///
142/// ```rust
143/// use ssg::content_stager::stage_content_with_site_defaults;
144/// use std::fs;
145///
146/// let tmp = tempfile::tempdir().unwrap();
147/// let src = tmp.path().join("content");
148/// let build = tmp.path().join("build");
149/// fs::create_dir_all(&src).unwrap();
150/// fs::write(src.join("post.md"), "---\ntitle: A\n---\nbody").unwrap();
151///
152/// let staged = stage_content_with_site_defaults(
153///     &src, &build, &[], Some("https://example.com"), &[],
154/// ).unwrap();
155/// let out = fs::read_to_string(staged.join("post.md")).unwrap();
156/// assert!(out.contains("permalink: \"https://example.com/post/\""));
157/// ```
158pub fn stage_content_with_site_defaults(
159    content_dir: &Path,
160    build_dir: &Path,
161    template_var_keys: &[String],
162    base_url: Option<&str>,
163    locales: &[String],
164) -> Result<PathBuf, io::Error> {
165    // An empty base URL can't produce the absolute permalink rss-gen
166    // validates for — treat it as "no base URL, skip injection".
167    let base_url = base_url.map(str::trim).filter(|b| !b.is_empty());
168
169    let staging_dir = staging_root_for("content", build_dir);
170    recreate_staging_dir(&staging_dir)?;
171
172    copy_tree(content_dir, &staging_dir, base_url)?;
173
174    // staticdatagen 0.0.10 closes upstream #69 — the tags-page generator
175    // is now a no-op when no `tags.md` / `tags/index.md` template is
176    // present. The v0.0.45 `ensure_tags_stub` shim was retired in this
177    // release.
178
179    if !template_var_keys.is_empty() {
180        inject_template_defaults_recursive(
181            &staging_dir,
182            template_var_keys,
183            base_url,
184            locales,
185        )?;
186    }
187
188    Ok(staging_dir)
189}
190
191/// Front-matter keys derived from `base_url` and the page's own location.
192///
193/// Two scopes, named so the call site says which one it means:
194///
195/// | Key | Value for `fr/a-propos.md` under `https://example.com/atlas` |
196/// | --- | --- |
197/// | `site_path`   | `/atlas/` |
198/// | `site_url`    | `https://example.com/atlas/` |
199/// | `locale_path` | `/atlas/fr/` |
200/// | `locale_url`  | `https://example.com/atlas/fr/` |
201///
202/// `site_*` addresses the site root, where assets, feeds, the manifest and
203/// the favicon are published — there is exactly one copy of each per site,
204/// regardless of locale. `locale_*` addresses the current locale's root,
205/// where page-to-page links live.
206///
207/// Conflating the two is not hypothetical: the previous hand-maintained
208/// `base_path` / `asset_path` pair carried no scope in either name, and a
209/// French page duly requested `/atlas/fr/styles.css`, which is never
210/// written. `{{site_path}}styles.css` and `{{locale_path}}articles/` both
211/// read correctly at the call site, and `{{locale_path}}styles.css` reads
212/// visibly wrong.
213///
214/// Every value carries a trailing slash so templates concatenate without a
215/// separator. On a single-locale site `locale_*` equals `site_*`, so a theme
216/// can use the locale forms throughout and gain locales later without
217/// editing content.
218///
219/// Author front matter always wins — these are only injected when absent.
220pub(crate) const DERIVED_PATH_KEYS: [&str; 4] =
221    ["site_path", "site_url", "locale_path", "locale_url"];
222
223/// Computes [`DERIVED_PATH_KEYS`] for one staged file.
224///
225/// `staged_rel` is the file's path relative to the staging root, e.g.
226/// `fr/a-propos.md`. A locale is recognised either as a leading directory
227/// (`fr/a-propos.md`) or, for a locale home page, as the whole stem
228/// (`fr.md` — which the nested-index flattening produces and which compiles
229/// to `fr/index.html`).
230fn derive_path_globals(
231    base_url: Option<&str>,
232    staged_rel: &Path,
233    locales: &[String],
234) -> Vec<(String, String)> {
235    let site_url = base_url.map_or_else(
236        || "/".to_string(),
237        |b| format!("{}/", b.trim_end_matches('/')),
238    );
239    let site_path = url_path_component(&site_url);
240
241    let locale = detect_locale(staged_rel, locales);
242    let (locale_url, locale_path) = match locale {
243        Some(l) => (format!("{site_url}{l}/"), format!("{site_path}{l}/")),
244        None => (site_url.clone(), site_path.clone()),
245    };
246
247    // Zipped against the const so the documented key list and the values
248    // actually injected cannot drift apart.
249    DERIVED_PATH_KEYS
250        .into_iter()
251        .map(str::to_string)
252        .zip([site_path, site_url, locale_path, locale_url])
253        .collect()
254}
255
256/// Returns the path component of an absolute URL, with a trailing slash.
257///
258/// `https://example.com/atlas/` yields `/atlas/`; a bare origin, or a value
259/// that is already a path, yields `/`.
260fn url_path_component(url: &str) -> String {
261    let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
262    let path = if url.starts_with('/') {
263        url
264    } else {
265        after_scheme.find('/').map_or("/", |i| &after_scheme[i..])
266    };
267    let trimmed = path.trim_matches('/');
268    if trimmed.is_empty() {
269        "/".to_string()
270    } else {
271        format!("/{trimmed}/")
272    }
273}
274
275/// Identifies which configured locale a staged file belongs to.
276fn detect_locale(staged_rel: &Path, locales: &[String]) -> Option<String> {
277    if locales.len() < 2 {
278        return None;
279    }
280    let mut comps = staged_rel.components();
281    let first = comps.next()?.as_os_str().to_string_lossy().into_owned();
282
283    // `fr/a-propos.md` — locale as a directory.
284    if comps.next().is_some() && locales.contains(&first) {
285        return Some(first);
286    }
287    // `fr.md` — a locale home page, flattened from `fr/index.md`.
288    let stem = Path::new(&first)
289        .file_stem()
290        .map(|s| s.to_string_lossy().into_owned())?;
291    locales.contains(&stem).then_some(stem)
292}
293
294/// Recreates the staging directory from scratch so a previous build's
295/// layout injection doesn't leak into this build's inputs.
296fn recreate_staging_dir(staging_dir: &Path) -> Result<(), io::Error> {
297    if staging_dir.exists() {
298        fs::remove_dir_all(staging_dir)?;
299    }
300    fs::create_dir_all(staging_dir)?;
301    Ok(())
302}
303
304/// Picks a staging-directory location *outside* `build_dir` so the
305/// staged tree never gets swept into staticdatagen's output.
306///
307/// Key derivation includes:
308/// - the OS temp dir (`std::env::temp_dir()`)
309/// - the process id (disambiguates concurrent `ssg` invocations)
310/// - a stable FNV-1a hash of `build_dir`'s absolute path
311///   (disambiguates concurrent in-process callers — load-bearing for
312///   the parallel test runner, where every test shares the same pid)
313/// - a per-purpose suffix (`content` / `templates`)
314///
315/// The hash is FNV-1a 64-bit rather than `DefaultHasher` to keep the
316/// path deterministic across runs (good for debugging) while staying
317/// well-distributed across the `usize` space.
318fn staging_root_for(suffix: &str, build_dir: &Path) -> PathBuf {
319    // FNV-1a 64-bit over the build_dir bytes — deterministic and
320    // adequate for path disambiguation.
321    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
322    for b in build_dir.as_os_str().as_encoded_bytes() {
323        hash ^= u64::from(*b);
324        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
325    }
326    std::env::temp_dir().join(format!(
327        "ssg-staging-{}-{hash:016x}-{suffix}",
328        std::process::id()
329    ))
330}
331
332/// Recursively mirrors `src` into `dst`, applying
333/// [`collapse_multiline_quoted_scalars`] to `.md` files (and, when
334/// `base_url` is present, [`inject_permalink_if_missing`] — spec
335/// A2/B1, plan §2 item 1.2, issue #586) and copying everything else
336/// verbatim. Per-file work runs in parallel via Rayon
337/// — `read + transform + write` is the hot path that determined
338/// whether the staging shim added meaningful build-time overhead.
339/// With ~100 pages the parallel pass keeps the staging cost under
340/// ~50 ms on a 4-core runner, well inside the perf-budget gate.
341fn copy_tree(
342    src: &Path,
343    dst: &Path,
344    base_url: Option<&str>,
345) -> Result<(), io::Error> {
346    use rayon::iter::IntoParallelIterator;
347    use rayon::iter::ParallelIterator;
348
349    // Collect entries up front so the directory-creation step is
350    // serial (cheap) and the per-file work is parallel.
351    let mut files: Vec<(PathBuf, PathBuf, bool)> = Vec::new();
352    let mut dirs: Vec<(PathBuf, PathBuf)> = Vec::new();
353    collect_entries(src, dst, &mut files, &mut dirs)?;
354    flatten_nested_index_pages(dst, &mut files);
355
356    // Create every directory serially (cheap; preserves order so
357    // children can be written without races).
358    for (_src_dir, dst_dir) in &dirs {
359        fs::create_dir_all(dst_dir)?;
360    }
361
362    let errors: Vec<io::Error> = files
363        .into_par_iter()
364        .filter_map(|(src_path, dst_path, is_md)| {
365            let r = if is_md {
366                // staticdatagen 0.0.10 closes upstream #67 — missing
367                // `layout:` keys default to "page" inside the compiler
368                // itself. Per-file work is now ONLY the multi-line
369                // quoted-scalar collapse (still needed until
370                // staticdatagen bumps `metadata-gen` to 0.0.5;
371                // tracked: staticdatagen#100).
372                fs::read_to_string(&src_path).and_then(|body| {
373                    let mut staged = collapse_multiline_quoted_scalars(&body);
374                    // Guarantee a permalink so rss-gen's
375                    // "channel.link is missing" hard-fail is
376                    // unreachable (spec A2/B1, plan §2 item 1.2,
377                    // issue #586). Author-specified `permalink`/`url`
378                    // keys always win — see
379                    // `inject_permalink_if_missing`.
380                    if let Some(base) = base_url {
381                        // `strip_prefix(src)` cannot fail here: every
382                        // `src_path` in `files` was built by
383                        // `collect_entries` descending from this same
384                        // `src` via repeated `.join()` calls, so it is
385                        // always literally prefixed by `src`. The `Err`
386                        // arm is unreachable through the public API and
387                        // is kept only as defensive protection against a
388                        // future refactor of `collect_entries` /
389                        // `copy_tree`'s call graph — not covered by
390                        // tests for that reason (100% coverage
391                        // verification, v0.0.47).
392                        if let Ok(rel) = src_path.strip_prefix(src) {
393                            let rel = rel.to_string_lossy();
394                            let permalink =
395                                crate::urls::derive_permalink(base, &rel);
396                            staged = inject_permalink_if_missing(
397                                &staged, &permalink,
398                            );
399                        }
400                    }
401                    fs::write(&dst_path, staged)
402                })
403            } else {
404                fs::copy(&src_path, &dst_path).map(|_| ())
405            };
406            r.err()
407        })
408        .collect();
409
410    if let Some(e) = errors.into_iter().next() {
411        return Err(e);
412    }
413    Ok(())
414}
415
416/// Re-targets a staged nested `index.md` onto its parent directory's
417/// name so `staticdatagen` writes it to `<parent>/index.html` instead
418/// of `<parent>/index/index.html`.
419///
420/// ## Why this exists
421///
422/// [`crate::urls::derive_output_rel_path`] documents the compiler's
423/// output convention as `about/index.md → about/index.html`, and the
424/// ISR manifest's `derive_url` repeats it. `staticdatagen 0.0.11`
425/// does not honour it:
426/// `utilities::write::write_files_to_build_directory` compares the
427/// *whole* processed name against `"index"`, so only a content-root
428/// `index.md` reaches the root-index branch. Anything nested —
429/// `fr/index.md` — falls through to `write_content_files`, which
430/// creates a directory named after the full stem (`fr/index/`) and
431/// writes `index.html` inside it. Every per-locale home page is
432/// affected.
433///
434/// `staticdatagen` is a published external dependency, so the mapping
435/// cannot be corrected in-repo. It can be side-stepped: the compiler
436/// writes `foo.md` to `foo/index.html`, so staging `fr/index.md` under
437/// the name `fr.md` produces exactly the documented output path.
438///
439/// ## Collisions
440///
441/// If the content tree already carries a file that would stage to the
442/// same destination (both `fr.md` and `fr/index.md` authored), the
443/// nested file keeps its original staged path. Flattening would
444/// silently drop one of the two pages, which is worse than the
445/// directory-level bug.
446///
447/// Directory names containing dots are appended to textually rather
448/// than through `Path::set_extension`, which would truncate `v1.2`
449/// to `v1`.
450fn flatten_nested_index_pages(
451    dst_root: &Path,
452    files: &mut [(PathBuf, PathBuf, bool)],
453) {
454    let occupied: std::collections::HashSet<PathBuf> =
455        files.iter().map(|(_, dst, _)| dst.clone()).collect();
456
457    for (_src_path, dst_path, is_md) in files.iter_mut() {
458        if !*is_md {
459            continue;
460        }
461        if dst_path.file_stem().and_then(|s| s.to_str()) != Some("index") {
462            continue;
463        }
464        // A content-root `index.md` already compiles to the site
465        // root's `index.html` — only nested ones are wrong.
466        let Some(parent) = dst_path.parent() else {
467            continue;
468        };
469        if parent == dst_root {
470            continue;
471        }
472        let (Some(grandparent), Some(dir_name)) =
473            (parent.parent(), parent.file_name())
474        else {
475            continue;
476        };
477        let ext = dst_path.extension().unwrap_or_default().to_string_lossy();
478        let renamed =
479            grandparent.join(format!("{}.{ext}", dir_name.to_string_lossy()));
480        if occupied.contains(&renamed) {
481            continue;
482        }
483        *dst_path = renamed;
484    }
485}
486
487/// Build-time control files that live *in* the content directory but are
488/// inputs to `ssg` itself, not pages to compile.
489///
490/// `content.schema.toml` is the documented location for typed front-matter
491/// schemas (see the "Content schema validation" section of the README).
492/// It is read by [`crate::core_group::content`] before the compile, and it
493/// must not then be handed to `staticdatagen`, which treats every staged
494/// file as a page and aborts the whole build with
495/// `Failed to extract metadata: No valid front matter found`.
496const CONTENT_CONTROL_FILES: &[&str] = &["content.schema.toml"];
497
498/// Walks `src` and partitions entries into directories (to create
499/// before parallel writes) and files (with their destination path
500/// and a markdown flag).
501///
502/// Entries named in [`CONTENT_CONTROL_FILES`] are skipped: they configure
503/// the build rather than describing a page.
504fn collect_entries(
505    src: &Path,
506    dst: &Path,
507    files: &mut Vec<(PathBuf, PathBuf, bool)>,
508    dirs: &mut Vec<(PathBuf, PathBuf)>,
509) -> Result<(), io::Error> {
510    for entry in fs::read_dir(src)? {
511        let entry = entry?;
512        let file_name = entry.file_name();
513        let src_path = entry.path();
514        let dst_path = dst.join(&file_name);
515        let file_type = entry.file_type()?;
516        if file_type.is_dir() {
517            dirs.push((src_path.clone(), dst_path.clone()));
518            collect_entries(&src_path, &dst_path, files, dirs)?;
519        } else if file_type.is_file() {
520            if CONTENT_CONTROL_FILES
521                .iter()
522                .any(|name| file_name.as_encoded_bytes() == name.as_bytes())
523            {
524                continue;
525            }
526            let is_md = is_markdown(&src_path);
527            files.push((src_path, dst_path, is_md));
528        }
529        // Symlinks and other special files are skipped — staticdatagen
530        // wouldn't follow them safely anyway.
531    }
532    Ok(())
533}
534
535/// Walks `template_dir` recursively and returns the sorted, deduped
536/// set of every `{{ <var> }}` reference found in the template files.
537///
538/// Filtered references (`{{ var | filter }}`), dotted paths (`{{ a.b }}`),
539/// helpers (`{{#each ...}}`), and the `{{!...}}` raw-emit form are all
540/// skipped — only bare top-level keys end up in the result, because
541/// those are the ones staticweaver actually looks up against the
542/// frontmatter `metadata` `HashMap`.
543///
544/// Returns an empty Vec if `template_dir` doesn't exist.
545///
546/// # Errors
547///
548/// Returns [`io::Error`] only for unexpected failures reading the
549/// template tree; a missing directory is treated as "no templates",
550/// not as an error.
551///
552/// # Examples
553///
554/// ```rust
555/// use ssg::content_stager::collect_template_vars;
556/// use std::fs;
557///
558/// let tmp = tempfile::tempdir().unwrap();
559/// let t = tmp.path().join("t");
560/// fs::create_dir_all(&t).unwrap();
561/// fs::write(t.join("page.html"), "<title>{{ title }}</title>{{ author }}").unwrap();
562///
563/// let vars = collect_template_vars(&t).unwrap();
564/// assert!(vars.contains(&"title".to_string()));
565/// assert!(vars.contains(&"author".to_string()));
566/// ```
567pub fn collect_template_vars(
568    template_dir: &Path,
569) -> Result<Vec<String>, io::Error> {
570    let mut out = std::collections::BTreeSet::new();
571    if !template_dir.exists() {
572        return Ok(Vec::new());
573    }
574    walk_collect_vars(template_dir, &mut out)?;
575    Ok(out.into_iter().collect())
576}
577
578fn walk_collect_vars(
579    dir: &Path,
580    out: &mut std::collections::BTreeSet<String>,
581) -> Result<(), io::Error> {
582    for entry in fs::read_dir(dir)? {
583        let entry = entry?;
584        let p = entry.path();
585        let ft = entry.file_type()?;
586        if ft.is_dir() {
587            walk_collect_vars(&p, out)?;
588        } else if ft.is_file() {
589            // Only scan templating-eligible files; ignore .js, .css,
590            // etc. that happen to live under template_dir.
591            let is_template = matches!(
592                p.extension().and_then(|s| s.to_str()),
593                Some("html" | "htm" | "xml" | "txt" | "rss")
594            );
595            if is_template {
596                if let Ok(body) = fs::read_to_string(&p) {
597                    extract_simple_vars(&body, out);
598                }
599            }
600        }
601    }
602    Ok(())
603}
604
605/// Extracts bare `{{ key }}` references from `body` into `out`.
606/// Skips refs that include filters (`|`), dotted paths (`.`), or
607/// staticweaver helpers (`#`, `/`, `!`, `>`).
608fn extract_simple_vars(
609    body: &str,
610    out: &mut std::collections::BTreeSet<String>,
611) {
612    let bytes = body.as_bytes();
613    let mut i = 0;
614    while i + 1 < bytes.len() {
615        if bytes[i] == b'{' && bytes[i + 1] == b'{' {
616            // Walk to the matching `}}`.
617            if let Some(end) = find_closing_braces(&body[i + 2..]) {
618                let inner = body[i + 2..i + 2 + end].trim();
619                if let Some(name) = simple_var_name(inner) {
620                    let _ = out.insert(name.to_string());
621                }
622                i += 2 + end + 2;
623                continue;
624            }
625        }
626        i += 1;
627    }
628}
629
630const fn find_closing_braces(s: &str) -> Option<usize> {
631    let bytes = s.as_bytes();
632    let mut j = 0;
633    while j + 1 < bytes.len() {
634        if bytes[j] == b'}' && bytes[j + 1] == b'}' {
635            return Some(j);
636        }
637        j += 1;
638    }
639    None
640}
641
642/// Returns Some(name) when `inner` is a simple `<key>` lookup —
643/// no filter (`|`), no dotted path (`.`), no helper prefix
644/// (`#`, `/`, `!`, `>`), no whitespace inside the name.
645fn simple_var_name(inner: &str) -> Option<&str> {
646    let s = inner.trim();
647    if s.is_empty() {
648        return None;
649    }
650    let first = s.as_bytes()[0];
651    if matches!(first, b'#' | b'/' | b'!' | b'>') {
652        return None;
653    }
654    if s.contains('|') || s.contains('.') {
655        return None;
656    }
657    if s.bytes().any(|b| b.is_ascii_whitespace()) {
658        return None;
659    }
660    Some(s)
661}
662
663/// Walks every `.md` file under `dir` and injects empty defaults for
664/// every key in `keys` not already present in that file's frontmatter.
665/// Per-file work is parallelised via Rayon so the staging cost on a
666/// 100-page corpus stays inside the perf-budget gate.
667fn inject_template_defaults_recursive(
668    dir: &Path,
669    keys: &[String],
670    base_url: Option<&str>,
671    locales: &[String],
672) -> Result<(), io::Error> {
673    use rayon::iter::IntoParallelIterator;
674    use rayon::iter::ParallelIterator;
675
676    fail_point!("content_stager::inject-defaults", |_| {
677        Err(io::Error::other(
678            "injected: content_stager::inject-defaults",
679        ))
680    });
681
682    let mut md_files: Vec<PathBuf> = Vec::new();
683    collect_markdown_files(dir, &mut md_files)?;
684
685    let errors: Vec<io::Error> = md_files
686        .into_par_iter()
687        .filter_map(|p| {
688            let body = match fs::read_to_string(&p) {
689                Ok(b) => b,
690                Err(e) => return Some(e),
691            };
692            // Only derive what the templates actually reference: a theme
693            // that never writes `{{site_path}}` pays nothing, and the
694            // skip-write path below stays reachable.
695            let rel = p.strip_prefix(dir).unwrap_or(&p);
696            let derived: Vec<(String, String)> =
697                derive_path_globals(base_url, rel, locales)
698                    .into_iter()
699                    .filter(|(k, _)| keys.iter().any(|want| want == k))
700                    .collect();
701            let staged = inject_missing_keys_with_values(&body, keys, &derived);
702            if staged == body {
703                return None;
704            }
705            fs::write(&p, staged).err()
706        })
707        .collect();
708    if let Some(e) = errors.into_iter().next() {
709        return Err(e);
710    }
711    Ok(())
712}
713
714fn collect_markdown_files(
715    dir: &Path,
716    out: &mut Vec<PathBuf>,
717) -> Result<(), io::Error> {
718    for entry in fs::read_dir(dir)? {
719        let entry = entry?;
720        let p = entry.path();
721        let ft = entry.file_type()?;
722        if ft.is_dir() {
723            collect_markdown_files(&p, out)?;
724        } else if ft.is_file() && is_markdown(&p) {
725            out.push(p);
726        }
727    }
728    Ok(())
729}
730
731/// Injects empty `key: ""` entries into the frontmatter block for any
732/// `key` not already present. No-op for files without a frontmatter
733/// block.
734#[must_use]
735pub fn inject_missing_keys(body: &str, keys: &[String]) -> String {
736    inject_missing_keys_with_values(body, keys, &[])
737}
738
739/// As [`inject_missing_keys`], but `derived` supplies real values for the
740/// keys it names instead of the empty-string placeholder.
741///
742/// Author front matter wins: a key already present in the block is left
743/// exactly as written, so a theme can override any derived value — a locale
744/// tree served from a different origin, say — without fighting the default.
745pub fn inject_missing_keys_with_values(
746    body: &str,
747    keys: &[String],
748    derived: &[(String, String)],
749) -> String {
750    let trimmed = body.trim_start_matches('\u{FEFF}');
751    let Some((_lead, after_open)) = find_opening_fence(trimmed) else {
752        return body.to_string();
753    };
754    let Some(close_rel) = find_closing_fence(after_open) else {
755        return body.to_string();
756    };
757    let block = &after_open[..close_rel];
758    let after_block = &after_open[close_rel..];
759
760    let derived_keys: Vec<String> =
761        derived.iter().map(|(k, _)| k.clone()).collect();
762    let missing: Vec<&String> = keys
763        .iter()
764        .chain(derived_keys.iter())
765        .filter(|k| !frontmatter_has_key(block, k))
766        .collect();
767    if missing.is_empty() {
768        return body.to_string();
769    }
770
771    let mut additions = String::with_capacity(missing.len() * 24);
772    let mut seen: Vec<&str> = Vec::with_capacity(missing.len());
773    for k in missing {
774        if seen.contains(&k.as_str()) {
775            continue;
776        }
777        seen.push(k.as_str());
778        let value = derived
779            .iter()
780            .find(|(dk, _)| dk == k)
781            .map_or("", |(_, v)| v.as_str());
782        additions.push_str(&format!("{k}: \"{value}\"\n"));
783    }
784
785    let mut out = String::with_capacity(body.len() + additions.len());
786    out.push_str(&trimmed[..trimmed.len() - after_open.len()]);
787    out.push_str(&additions);
788    out.push_str(block);
789    out.push_str(after_block);
790    if body.starts_with('\u{FEFF}') {
791        return format!("\u{FEFF}{out}");
792    }
793    out
794}
795
796/// Injects `permalink: "<permalink>"` as the first key of the YAML
797/// frontmatter block when the block exists and declares *neither*
798/// `permalink` nor `url` (spec A2/B1, plan §2 item 1.2, issue #586).
799///
800/// Author-specified values always win: a file with either key passes
801/// through byte-for-byte. Files without a frontmatter fence are left
802/// untouched — the stager's structural line-scan (shared with the
803/// template-default shim) only operates on YAML `---` fenced blocks,
804/// and `staticdatagen` extracts no metadata from fence-less files
805/// anyway.
806///
807/// Idempotent: a second pass over previously-staged content returns
808/// the input unchanged (the injected `permalink:` is detected as an
809/// existing key).
810///
811/// # Examples
812///
813/// ```rust
814/// use ssg::content_stager::inject_permalink_if_missing;
815///
816/// // Missing both keys — derived permalink lands as the first key.
817/// let out = inject_permalink_if_missing(
818///     "---\ntitle: T\n---\nbody",
819///     "https://example.com/t/",
820/// );
821/// assert!(out.contains("permalink: \"https://example.com/t/\""));
822///
823/// // Author-specified permalink wins verbatim.
824/// let with_permalink = "---\npermalink: /mine/\ntitle: T\n---\nbody";
825/// assert_eq!(
826///     inject_permalink_if_missing(with_permalink, "https://x/"),
827///     with_permalink
828/// );
829///
830/// // `url` counts as author-specified too.
831/// let with_url = "---\nurl: /u/\ntitle: T\n---\nbody";
832/// assert_eq!(inject_permalink_if_missing(with_url, "https://x/"), with_url);
833/// ```
834#[must_use]
835pub fn inject_permalink_if_missing(body: &str, permalink: &str) -> String {
836    let trimmed = body.trim_start_matches('\u{FEFF}');
837    let Some((_lead, after_open)) = find_opening_fence(trimmed) else {
838        return body.to_string();
839    };
840    let Some(close_rel) = find_closing_fence(after_open) else {
841        return body.to_string();
842    };
843    let block = &after_open[..close_rel];
844    let after_block = &after_open[close_rel..];
845
846    // Author-specified link keys always win (spec B1): `permalink`
847    // is what staticdatagen reads; `url` is the common alias authors
848    // migrating from other generators carry.
849    if frontmatter_has_key(block, "permalink")
850        || frontmatter_has_key(block, "url")
851    {
852        return body.to_string();
853    }
854
855    let mut out = String::with_capacity(body.len() + permalink.len() + 16);
856    out.push_str(&trimmed[..trimmed.len() - after_open.len()]);
857    out.push_str(&format!("permalink: \"{permalink}\"\n"));
858    out.push_str(block);
859    out.push_str(after_block);
860    if body.starts_with('\u{FEFF}') {
861        return format!("\u{FEFF}{out}");
862    }
863    out
864}
865
866/// Returns `true` if the frontmatter block declares `key` (any
867/// quoting style, comments skipped).
868fn frontmatter_has_key(block: &str, key: &str) -> bool {
869    for raw in block.lines() {
870        let line = raw.trim_start();
871        if line.starts_with('#') {
872            continue;
873        }
874        let prefixes = [
875            format!("{key}:"),
876            format!("{key} :"),
877            format!("\"{key}\":"),
878            format!("'{key}':"),
879        ];
880        if prefixes.iter().any(|p| line.starts_with(p)) {
881            return true;
882        }
883    }
884    false
885}
886
887fn is_markdown(p: &Path) -> bool {
888    matches!(
889        p.extension().and_then(|s| s.to_str()),
890        Some("md" | "markdown")
891    )
892}
893
894/// Collapses YAML-spec-compliant multi-line quoted scalars onto a
895/// single line so noyalib's stricter parser inside `metadata-gen`
896/// can read them.
897///
898/// Pattern handled (the common one in the wild):
899///
900/// ```yaml
901/// key: "
902/// value-on-next-line"
903/// ```
904///
905/// becomes
906///
907/// ```yaml
908/// key: "value-on-next-line"
909/// ```
910///
911/// The collapse joins lines until the matching closing quote is seen
912/// on a subsequent line, replacing intervening newlines with a single
913/// space (the YAML semantic for folded line breaks inside double-
914/// quoted scalars).
915fn collapse_multiline_quoted_scalars(block: &str) -> String {
916    let mut out = String::with_capacity(block.len());
917    let lines: Vec<&str> = block.lines().collect();
918    let mut i = 0;
919    while i < lines.len() {
920        let line = lines[i];
921        // Detect `key: "` (opening quote, nothing after).
922        if let Some(eq_pos) = line.find(": \"") {
923            let after_quote = &line[eq_pos + 3..];
924            // The quote sits at the very end of the line (only
925            // whitespace allowed after) AND no closing `"` on this
926            // line — multi-line case.
927            if after_quote.trim().is_empty() {
928                // Walk forward joining lines until we find the
929                // closing `"`.
930                let mut joined = String::from(&line[..eq_pos + 3]);
931                let mut closed = false;
932                i += 1;
933                while i < lines.len() {
934                    let next = lines[i];
935                    if let Some(close) = next.find('"') {
936                        joined.push_str(next[..close].trim_start());
937                        joined.push_str(&next[close..]);
938                        out.push_str(&joined);
939                        out.push('\n');
940                        i += 1;
941                        closed = true;
942                        break;
943                    }
944                    joined.push_str(next.trim_start());
945                    joined.push(' ');
946                    i += 1;
947                }
948                // Pathological case — no closing quote in the file.
949                // Emit what we've accumulated so the downstream
950                // extractor sees the same broken content rather than
951                // silently swallowing it.
952                if !closed {
953                    out.push_str(joined.trim_end());
954                    out.push('\n');
955                }
956                continue;
957            }
958        }
959        out.push_str(line);
960        out.push('\n');
961        i += 1;
962    }
963    out
964}
965
966/// Returns `(prefix, after_open_fence)` where `prefix` is the bytes
967/// before the first `---\n` (i.e. leading blank lines) and
968/// `after_open_fence` starts at the first character past the fence.
969fn find_opening_fence(s: &str) -> Option<(&str, &str)> {
970    // Walk lines until we see a non-empty one. If it's exactly `---`
971    // (with optional CR), the fence opens. Anything else means no
972    // frontmatter.
973    let mut byte_pos = 0;
974    for line in s.split_inclusive('\n') {
975        let bare = line.trim_end_matches('\n').trim_end_matches('\r');
976        if bare.trim().is_empty() {
977            byte_pos += line.len();
978            continue;
979        }
980        if bare == "---" {
981            let lead = &s[..byte_pos];
982            let after = &s[byte_pos + line.len()..];
983            return Some((lead, after));
984        }
985        return None;
986    }
987    None
988}
989
990/// Returns the byte offset within `after_open` at which the closing
991/// `---` fence begins (the offset is the start of the closing fence
992/// line, not past it).
993fn find_closing_fence(after_open: &str) -> Option<usize> {
994    let mut byte_pos = 0;
995    for line in after_open.split_inclusive('\n') {
996        let bare = line.trim_end_matches('\n').trim_end_matches('\r');
997        if bare.trim() == "---" {
998            return Some(byte_pos);
999        }
1000        byte_pos += line.len();
1001    }
1002    None
1003}
1004
1005// ---------------------------------------------------------------------
1006// Template staging
1007// ---------------------------------------------------------------------
1008
1009#[cfg(test)]
1010mod tests {
1011    use super::*;
1012
1013    #[test]
1014    fn inject_missing_keys_no_frontmatter_passthrough() {
1015        // Covers `let Some(...) = find_opening_fence(...) else { return body.to_string(); }`.
1016        let body = "no frontmatter here\nplain markdown";
1017        let out = inject_missing_keys(body, &["x".to_string()]);
1018        assert_eq!(out, body);
1019    }
1020
1021    #[test]
1022    fn inject_missing_keys_unterminated_frontmatter_passthrough() {
1023        // Covers `let Some(...) = find_closing_fence(...) else { return body.to_string(); }`.
1024        let body = "---\ntitle: T\n# never closes";
1025        let out = inject_missing_keys(body, &["x".to_string()]);
1026        assert_eq!(out, body);
1027    }
1028
1029    #[test]
1030    fn inject_missing_keys_with_bom_preserves_bom() {
1031        // Covers the `if body.starts_with('\u{FEFF}') { format!("\u{FEFF}{out}") }`
1032        // branch inside `inject_missing_keys` — distinct from the
1033        // existing `bom_is_preserved` test which only exercises the
1034        // sibling layout-injection function.
1035        let body = "\u{FEFF}---\ntitle: T\n---\nbody";
1036        let out = inject_missing_keys(body, &["author".to_string()]);
1037        assert!(out.starts_with('\u{FEFF}'));
1038        assert!(out.contains("author: \"\""));
1039    }
1040
1041    #[test]
1042    fn inject_missing_keys_returns_body_unchanged_when_all_keys_present() {
1043        // Covers the `if missing.is_empty() { return body.to_string(); }`
1044        // arm — distinct from the no-frontmatter and unterminated-fence
1045        // passthroughs above, which never reach this check at all.
1046        let body = "---\ntitle: T\nauthor: A\n---\nbody";
1047        let out = inject_missing_keys(
1048            body,
1049            &["title".to_string(), "author".to_string()],
1050        );
1051        assert_eq!(out, body);
1052    }
1053
1054    #[test]
1055    fn find_closing_braces_returns_none_when_unterminated() {
1056        // Covers `find_closing_braces` walking past the end without
1057        // seeing `}}` — happens for malformed templates.
1058        let mut out = std::collections::BTreeSet::new();
1059        extract_simple_vars("{{ never_closes", &mut out);
1060        // No vars extracted because the closing braces weren't found.
1061        assert!(out.is_empty());
1062    }
1063
1064    #[test]
1065    fn collapse_multiline_quoted_scalar_collapses_two_line() {
1066        let input = "url: \"\nhttps://example.com/x\"\n";
1067        let out = collapse_multiline_quoted_scalars(input);
1068        assert!(out.contains("url: \"https://example.com/x\""));
1069        assert!(!out.contains("\nhttps"));
1070    }
1071
1072    #[test]
1073    fn collapse_multiline_quoted_scalar_preserves_single_line() {
1074        let input = "title: \"On one line\"\nauthor: \"Jane\"\n";
1075        let out = collapse_multiline_quoted_scalars(input);
1076        assert_eq!(out, input);
1077    }
1078
1079    #[test]
1080    fn collapse_multiline_quoted_scalar_collapses_three_line() {
1081        let input = "blurb: \"\nline one\nline two\"\n";
1082        let out = collapse_multiline_quoted_scalars(input);
1083        assert!(out.contains("blurb: \"line one line two\""));
1084    }
1085
1086    #[test]
1087    fn collapse_handles_unterminated_quote_gracefully() {
1088        // Pathological case — never close. We should not panic; we
1089        // just emit what we have so the downstream extractor surfaces
1090        // a clean error.
1091        let input = "x: \"\nstill open\n";
1092        let out = collapse_multiline_quoted_scalars(input);
1093        assert!(out.contains("still open"));
1094    }
1095
1096    #[test]
1097    fn user_real_world_twitter_url_multiline_collapses() {
1098        // Exact shape from
1099        // _posts/2026-04-11-quantum-thresholds-are-moving-again.md
1100        // — the file that exposed the noyalib brittleness.
1101        let input = "twitter_url: \"\nhttps://sebastienrousseau.com/2026-04-11-quantum-thresholds-are-moving-again\"\n";
1102        let out = collapse_multiline_quoted_scalars(input);
1103        assert!(out.contains("twitter_url: \"https://"));
1104        assert_eq!(out.lines().count(), 1);
1105    }
1106
1107    // ---------------------------------------------------------------
1108    // Permalink derivation (spec A2/B1, plan §2 item 1.2, issue #586)
1109    // ---------------------------------------------------------------
1110
1111    #[test]
1112    fn inject_permalink_adds_key_when_missing() {
1113        let out = inject_permalink_if_missing(
1114            "---\ntitle: T\n---\nbody",
1115            "https://example.com/t/",
1116        );
1117        assert!(out.contains("permalink: \"https://example.com/t/\""));
1118        assert!(out.contains("title: T"));
1119        assert!(out.contains("body"));
1120    }
1121
1122    #[test]
1123    fn inject_permalink_preserves_author_permalink_verbatim() {
1124        let input = "---\npermalink: /custom/place/\ntitle: T\n---\nbody";
1125        assert_eq!(
1126            inject_permalink_if_missing(input, "https://example.com/t/"),
1127            input
1128        );
1129    }
1130
1131    #[test]
1132    fn inject_permalink_treats_url_key_as_author_specified() {
1133        let input = "---\nurl: https://elsewhere.example/\ntitle: T\n---\nb";
1134        assert_eq!(
1135            inject_permalink_if_missing(input, "https://example.com/t/"),
1136            input
1137        );
1138    }
1139
1140    #[test]
1141    fn inject_permalink_no_frontmatter_passthrough() {
1142        let input = "# Heading\n\nBody.";
1143        assert_eq!(
1144            inject_permalink_if_missing(input, "https://example.com/"),
1145            input
1146        );
1147    }
1148
1149    #[test]
1150    fn inject_permalink_unterminated_fence_passthrough() {
1151        let input = "---\ntitle: T\n# never closes";
1152        assert_eq!(
1153            inject_permalink_if_missing(input, "https://example.com/"),
1154            input
1155        );
1156    }
1157
1158    #[test]
1159    fn inject_permalink_preserves_bom() {
1160        let input = "\u{FEFF}---\ntitle: T\n---\nbody";
1161        let out = inject_permalink_if_missing(input, "https://example.com/t/");
1162        assert!(out.starts_with('\u{FEFF}'));
1163        assert!(out.contains("permalink: \"https://example.com/t/\""));
1164    }
1165
1166    #[test]
1167    fn inject_permalink_is_idempotent() {
1168        let input = "---\ntitle: T\n---\nbody";
1169        let once = inject_permalink_if_missing(input, "https://example.com/t/");
1170        let twice =
1171            inject_permalink_if_missing(&once, "https://example.com/t/");
1172        assert_eq!(once, twice);
1173    }
1174
1175    #[test]
1176    #[serial_test::parallel(stager_fp)]
1177    fn stage_with_site_defaults_derives_permalinks_for_all_pages() {
1178        // Plan §2 1.2 acceptance shape: a 3-page fixture where ZERO
1179        // pages carry `permalink:` must stage with derived permalinks
1180        // matching `{base_url}/{output_path}` under the compiler's
1181        // `foo.md → foo/index.html` pretty-URL convention.
1182        let tmp = tempfile::tempdir().unwrap();
1183        let src = tmp.path().join("content");
1184        let build = tmp.path().join("build");
1185        fs::create_dir_all(src.join("posts")).unwrap();
1186        fs::write(src.join("index.md"), "---\ntitle: Home\n---\nhome").unwrap();
1187        fs::write(src.join("about.md"), "---\ntitle: About\n---\nab").unwrap();
1188        fs::write(src.join("posts/first.md"), "---\ntitle: First\n---\npost")
1189            .unwrap();
1190
1191        let staged = stage_content_with_site_defaults(
1192            &src,
1193            &build,
1194            &[],
1195            Some("https://example.com"),
1196            &[],
1197        )
1198        .unwrap();
1199
1200        let home = fs::read_to_string(staged.join("index.md")).unwrap();
1201        assert!(
1202            home.contains("permalink: \"https://example.com/\""),
1203            "index.md must map to the site root URL: {home}"
1204        );
1205        let about = fs::read_to_string(staged.join("about.md")).unwrap();
1206        assert!(
1207            about.contains("permalink: \"https://example.com/about/\""),
1208            "about.md must map to a pretty directory URL: {about}"
1209        );
1210        let post = fs::read_to_string(staged.join("posts/first.md")).unwrap();
1211        assert!(
1212            post.contains("permalink: \"https://example.com/posts/first/\""),
1213            "nested page must include its directory path: {post}"
1214        );
1215    }
1216
1217    #[test]
1218    #[serial_test::parallel(stager_fp)]
1219    fn stage_with_site_defaults_keeps_author_permalink_verbatim() {
1220        let tmp = tempfile::tempdir().unwrap();
1221        let src = tmp.path().join("content");
1222        let build = tmp.path().join("build");
1223        fs::create_dir_all(&src).unwrap();
1224        fs::write(
1225            src.join("custom.md"),
1226            "---\nlayout: post\npermalink: \"https://example.com/my-spot/\"\ntitle: C\n---\nbody",
1227        )
1228        .unwrap();
1229
1230        let staged = stage_content_with_site_defaults(
1231            &src,
1232            &build,
1233            &[],
1234            Some("https://example.com"),
1235            &[],
1236        )
1237        .unwrap();
1238        let body = fs::read_to_string(staged.join("custom.md")).unwrap();
1239        assert!(body.contains("permalink: \"https://example.com/my-spot/\""));
1240        // Exactly one permalink key — no derived duplicate.
1241        assert_eq!(body.matches("permalink:").count(), 1);
1242    }
1243
1244    #[test]
1245    #[serial_test::parallel(stager_fp)]
1246    fn stage_with_site_defaults_handles_nested_index_md() {
1247        // `about/index.md` publishes at `about/index.html` →
1248        // permalink `{base}/about/` (index.html collapses to the
1249        // directory URL, matching the Atom feed convention).
1250        //
1251        // The staged file is named `about.md`, not `about/index.md` —
1252        // see `flatten_nested_index_pages`. The permalink is derived
1253        // from the AUTHORED path, so the value is unaffected.
1254        let tmp = tempfile::tempdir().unwrap();
1255        let src = tmp.path().join("content");
1256        let build = tmp.path().join("build");
1257        fs::create_dir_all(src.join("about")).unwrap();
1258        fs::write(src.join("about/index.md"), "---\ntitle: About\n---\nbody")
1259            .unwrap();
1260
1261        let staged = stage_content_with_site_defaults(
1262            &src,
1263            &build,
1264            &[],
1265            Some("https://example.com/"),
1266            &[],
1267        )
1268        .unwrap();
1269        let body = fs::read_to_string(staged.join("about.md")).unwrap();
1270        assert!(
1271            body.contains("permalink: \"https://example.com/about/\""),
1272            "trailing-slash base + nested index.md: {body}"
1273        );
1274    }
1275
1276    // -----------------------------------------------------------------
1277    // Nested `index.md` flattening (staticdatagen output-path gap)
1278    // -----------------------------------------------------------------
1279
1280    #[test]
1281    #[serial_test::parallel(stager_fp)]
1282    fn stage_flattens_nested_index_md_to_parent_named_file() {
1283        // `crate::urls::derive_output_rel_path` documents (and asserts)
1284        // `about/index.md → about/index.html`, but
1285        // `staticdatagen::utilities::write::write_files_to_build_directory`
1286        // only special-cases the exact processed name `"index"`, so a
1287        // nested `fr/index.md` lands at `fr/index/index.html` and every
1288        // locale home page gains a directory level.
1289        //
1290        // Staging the file as `fr.md` restores the documented mapping:
1291        // the compiler writes `<build>/fr/index.html` for it.
1292        let tmp = tempfile::tempdir().unwrap();
1293        let src = tmp.path().join("content");
1294        let build = tmp.path().join("build");
1295        fs::create_dir_all(src.join("fr")).unwrap();
1296        fs::create_dir_all(src.join("fr/blog")).unwrap();
1297        fs::write(src.join("index.md"), "---\ntitle: Home\n---\nen").unwrap();
1298        fs::write(src.join("fr/index.md"), "---\ntitle: Accueil\n---\nfr")
1299            .unwrap();
1300        fs::write(src.join("fr/blog/index.md"), "---\ntitle: Blog\n---\nb")
1301            .unwrap();
1302        fs::write(src.join("fr/a-propos.md"), "---\ntitle: A\n---\nap")
1303            .unwrap();
1304
1305        let staged =
1306            stage_content_with_template_defaults(&src, &build, &[]).unwrap();
1307
1308        assert!(
1309            staged.join("index.md").exists(),
1310            "root index.md is already correct and must stay put"
1311        );
1312        assert!(
1313            staged.join("fr.md").exists(),
1314            "fr/index.md must stage as fr.md so it compiles to fr/index.html"
1315        );
1316        assert!(
1317            !staged.join("fr/index.md").exists(),
1318            "the nested original must not also be staged"
1319        );
1320        assert!(
1321            staged.join("fr/blog.md").exists(),
1322            "deeper nesting flattens one level too"
1323        );
1324        assert!(
1325            !staged.join("fr/blog/index.md").exists(),
1326            "the nested original must not also be staged"
1327        );
1328        assert!(
1329            staged.join("fr/a-propos.md").exists(),
1330            "non-index siblings are untouched"
1331        );
1332    }
1333
1334    #[test]
1335    #[serial_test::parallel(stager_fp)]
1336    fn stage_keeps_nested_index_md_when_parent_named_file_exists() {
1337        // `fr.md` and `fr/index.md` both authored: flattening would
1338        // silently drop one page, so the nested file keeps its path
1339        // and the pre-existing (wrong but non-destructive) layout.
1340        let tmp = tempfile::tempdir().unwrap();
1341        let src = tmp.path().join("content");
1342        let build = tmp.path().join("build");
1343        fs::create_dir_all(src.join("fr")).unwrap();
1344        fs::write(src.join("fr.md"), "---\ntitle: FR\n---\nsection").unwrap();
1345        fs::write(src.join("fr/index.md"), "---\ntitle: Accueil\n---\nfr")
1346            .unwrap();
1347
1348        let staged =
1349            stage_content_with_template_defaults(&src, &build, &[]).unwrap();
1350
1351        assert!(staged.join("fr.md").exists());
1352        assert!(
1353            staged.join("fr/index.md").exists(),
1354            "collision must not clobber the authored fr.md"
1355        );
1356        assert!(
1357            fs::read_to_string(staged.join("fr.md"))
1358                .unwrap()
1359                .contains("title: FR"),
1360            "the authored fr.md must survive verbatim"
1361        );
1362    }
1363
1364    #[test]
1365    fn flatten_nested_index_pages_keeps_dotted_directory_names_intact() {
1366        // `set_extension` on `v1.2` would truncate it to `v1.md`;
1367        // the implementation appends the extension textually instead.
1368        let dst = Path::new("/staged");
1369        let mut files = vec![(
1370            PathBuf::from("/src/v1.2/index.md"),
1371            PathBuf::from("/staged/v1.2/index.md"),
1372            true,
1373        )];
1374        flatten_nested_index_pages(dst, &mut files);
1375        assert_eq!(files[0].1, PathBuf::from("/staged/v1.2.md"));
1376    }
1377
1378    #[test]
1379    fn flatten_nested_index_pages_ignores_non_markdown_and_root_files() {
1380        let dst = Path::new("/staged");
1381        let mut files = vec![
1382            // Non-markdown `index.html` asset — not a compiled page.
1383            (
1384                PathBuf::from("/src/fr/index.html"),
1385                PathBuf::from("/staged/fr/index.html"),
1386                false,
1387            ),
1388            // Root index.md — already compiles to the site root.
1389            (
1390                PathBuf::from("/src/index.md"),
1391                PathBuf::from("/staged/index.md"),
1392                true,
1393            ),
1394            // Nested non-index page — untouched.
1395            (
1396                PathBuf::from("/src/fr/about.md"),
1397                PathBuf::from("/staged/fr/about.md"),
1398                true,
1399            ),
1400        ];
1401        let before = files.clone();
1402        flatten_nested_index_pages(dst, &mut files);
1403        assert_eq!(files, before);
1404    }
1405
1406    #[test]
1407    #[serial_test::parallel(stager_fp)]
1408    fn stage_without_base_url_injects_no_permalink() {
1409        let tmp = tempfile::tempdir().unwrap();
1410        let src = tmp.path().join("content");
1411        let build = tmp.path().join("build");
1412        fs::create_dir_all(&src).unwrap();
1413        fs::write(src.join("a.md"), "---\ntitle: A\n---\nbody").unwrap();
1414
1415        // Legacy entry point — must stay permalink-free.
1416        let staged =
1417            stage_content_with_template_defaults(&src, &build, &[]).unwrap();
1418        let body = fs::read_to_string(staged.join("a.md")).unwrap();
1419        assert!(!body.contains("permalink:"));
1420
1421        // Empty / whitespace-only base URL disables injection too —
1422        // rss-gen only accepts absolute URLs, so there is nothing
1423        // valid to derive.
1424        let staged = stage_content_with_site_defaults(
1425            &src,
1426            &build,
1427            &[],
1428            Some("   "),
1429            &[],
1430        )
1431        .unwrap();
1432        let body = fs::read_to_string(staged.join("a.md")).unwrap();
1433        assert!(!body.contains("permalink:"));
1434    }
1435
1436    #[test]
1437    #[serial_test::parallel(stager_fp)]
1438    fn stage_with_site_defaults_is_idempotent_across_runs() {
1439        // v0.0.46 retired the layout-injection shim (staticdatagen
1440        // 0.0.10 defaults missing `layout:` natively), so the staged
1441        // output must carry exactly one derived permalink and no
1442        // injected layout key — across repeated staging runs.
1443        let tmp = tempfile::tempdir().unwrap();
1444        let src = tmp.path().join("content");
1445        let build = tmp.path().join("build");
1446        fs::create_dir_all(&src).unwrap();
1447        fs::write(src.join("a.md"), "---\ntitle: A\n---\nbody").unwrap();
1448
1449        let _first = stage_content_with_site_defaults(
1450            &src,
1451            &build,
1452            &[],
1453            Some("https://example.com"),
1454            &[],
1455        )
1456        .unwrap();
1457        let staged = stage_content_with_site_defaults(
1458            &src,
1459            &build,
1460            &[],
1461            Some("https://example.com"),
1462            &[],
1463        )
1464        .unwrap();
1465        let body = fs::read_to_string(staged.join("a.md")).unwrap();
1466        assert_eq!(body.matches("permalink:").count(), 1);
1467        assert_eq!(body.matches("layout:").count(), 0);
1468    }
1469
1470    #[test]
1471    #[serial_test::parallel(stager_fp)]
1472    fn stage_content_with_template_defaults_injects_defaults_end_to_end() {
1473        // Every other test that reaches `stage_content_with_site_defaults`
1474        // / `stage_content_with_template_defaults` passes an EMPTY
1475        // `template_var_keys` slice, so the `if
1476        // !template_var_keys.is_empty() { inject_template_defaults_recursive(...) }`
1477        // branch (and everything it calls) is only ever unit-tested via
1478        // a direct call to `inject_template_defaults_recursive`, never
1479        // through the public staging entry point. Drive it end to end.
1480        let tmp = tempfile::tempdir().unwrap();
1481        let src = tmp.path().join("content");
1482        let build = tmp.path().join("build");
1483        fs::create_dir_all(&src).unwrap();
1484        fs::write(src.join("a.md"), "---\ntitle: A\n---\nbody").unwrap();
1485
1486        let staged = stage_content_with_template_defaults(
1487            &src,
1488            &build,
1489            &["author".to_string()],
1490        )
1491        .unwrap();
1492
1493        let body = fs::read_to_string(staged.join("a.md")).unwrap();
1494        assert!(
1495            body.contains("author: \"\""),
1496            "missing template var must be injected: {body}"
1497        );
1498    }
1499
1500    // ── derived path globals ─────────────────────────────────────────
1501
1502    fn locales() -> Vec<String> {
1503        vec!["en".to_string(), "fr".to_string()]
1504    }
1505
1506    fn derived_for(rel: &str, base: Option<&str>) -> Vec<(String, String)> {
1507        derive_path_globals(base, Path::new(rel), &locales())
1508    }
1509
1510    fn value_of(pairs: &[(String, String)], key: &str) -> String {
1511        pairs
1512            .iter()
1513            .find(|(k, _)| k == key)
1514            .map(|(_, v)| v.clone())
1515            .unwrap_or_default()
1516    }
1517
1518    /// The distinction the old `base_path` / `asset_path` pair failed to
1519    /// carry: assets live at the site root regardless of locale, pages do
1520    /// not. A French page asking for `/atlas/fr/styles.css` gets a 404,
1521    /// because that file is only ever written once, at the site root.
1522    #[test]
1523    fn derived_paths_separate_site_scope_from_locale_scope() {
1524        let d =
1525            derived_for("fr/a-propos.md", Some("https://example.com/atlas"));
1526
1527        assert_eq!(value_of(&d, "site_path"), "/atlas/");
1528        assert_eq!(value_of(&d, "site_url"), "https://example.com/atlas/");
1529        assert_eq!(value_of(&d, "locale_path"), "/atlas/fr/");
1530        assert_eq!(value_of(&d, "locale_url"), "https://example.com/atlas/fr/");
1531    }
1532
1533    /// A locale home page is staged as `fr.md` by the nested-index
1534    /// flattening, and still belongs to `fr`.
1535    #[test]
1536    fn derived_paths_recognise_a_flattened_locale_home_page() {
1537        let d = derived_for("fr.md", Some("https://example.com/atlas"));
1538        assert_eq!(value_of(&d, "locale_path"), "/atlas/fr/");
1539    }
1540
1541    /// Default-locale pages live at the site root, so the two scopes
1542    /// coincide — a theme can use the locale forms throughout.
1543    #[test]
1544    fn derived_paths_collapse_for_the_root_hosted_default_locale() {
1545        let d = derived_for("about.md", Some("https://example.com/atlas"));
1546        assert_eq!(value_of(&d, "locale_path"), value_of(&d, "site_path"));
1547        assert_eq!(value_of(&d, "locale_url"), value_of(&d, "site_url"));
1548    }
1549
1550    /// A single-locale site never sees a locale segment, even if a
1551    /// directory happens to share a locale's name.
1552    #[test]
1553    fn derived_paths_ignore_locales_when_only_one_is_configured() {
1554        let d = derive_path_globals(
1555            Some("https://example.com"),
1556            Path::new("fr/a-propos.md"),
1557            &["en".to_string()],
1558        );
1559        assert_eq!(value_of(&d, "locale_path"), "/");
1560    }
1561
1562    /// A site at the domain root, and a build with no `base_url` at all,
1563    /// both yield usable root-relative values rather than `//`.
1564    #[test]
1565    fn derived_paths_handle_the_domain_root_and_a_missing_base_url() {
1566        let root = derived_for("about.md", Some("https://example.com"));
1567        assert_eq!(value_of(&root, "site_path"), "/");
1568        assert_eq!(value_of(&root, "site_url"), "https://example.com/");
1569
1570        let none = derived_for("about.md", None);
1571        assert_eq!(value_of(&none, "site_path"), "/");
1572        assert_eq!(value_of(&none, "site_url"), "/");
1573    }
1574
1575    /// Trailing slashes are guaranteed so templates concatenate directly.
1576    #[test]
1577    fn derived_paths_always_end_in_a_slash() {
1578        for base in [
1579            Some("https://example.com/atlas/"),
1580            Some("https://example.com/atlas"),
1581        ] {
1582            for (key, value) in derived_for("fr/x.md", base) {
1583                assert!(value.ends_with('/'), "{key} = {value:?}");
1584            }
1585        }
1586    }
1587
1588    /// Author front matter wins: a page that declares its own value keeps
1589    /// it, so a locale served from another origin stays overridable.
1590    #[test]
1591    fn author_front_matter_overrides_a_derived_value() {
1592        let body = "---\nlocale_path: \"/custom/\"\n---\nbody\n";
1593        let out = inject_missing_keys_with_values(
1594            body,
1595            &["locale_path".to_string()],
1596            &[("locale_path".to_string(), "/atlas/fr/".to_string())],
1597        );
1598        assert!(out.contains("/custom/"), "{out}");
1599        assert!(
1600            !out.contains("/atlas/fr/"),
1601            "derived value overrode the author: {out}"
1602        );
1603    }
1604
1605    #[test]
1606    fn inject_template_defaults_recursive_skips_write_when_no_keys_missing() {
1607        // Covers the `if staged == body { return None; }` skip-write
1608        // arm inside the parallel closure — every other
1609        // `inject_template_defaults_recursive` test supplies a key
1610        // that's actually missing, so the write always happens there.
1611        let tmp = tempfile::tempdir().unwrap();
1612        let dir = tmp.path().join("staged");
1613        fs::create_dir_all(&dir).unwrap();
1614        let path = dir.join("a.md");
1615        let original = "---\ntitle: T\nauthor: A\n---\nbody";
1616        fs::write(&path, original).unwrap();
1617        let before = fs::metadata(&path).unwrap().modified().unwrap();
1618
1619        inject_template_defaults_recursive(
1620            &dir,
1621            &["title".to_string(), "author".to_string()],
1622            None,
1623            &[],
1624        )
1625        .unwrap();
1626
1627        let after_body = fs::read_to_string(&path).unwrap();
1628        assert_eq!(after_body, original, "no-op write must not alter content");
1629        let after = fs::metadata(&path).unwrap().modified().unwrap();
1630        assert_eq!(before, after, "file must not be rewritten when unchanged");
1631    }
1632
1633    // -----------------------------------------------------------------
1634    // recreate_staging_dir — happy path and both failure arms
1635    // -----------------------------------------------------------------
1636
1637    #[test]
1638    fn recreate_staging_dir_wipes_previous_contents() {
1639        let tmp = tempfile::tempdir().unwrap();
1640        let staging = tmp.path().join("staging");
1641        fs::create_dir_all(&staging).unwrap();
1642        fs::write(staging.join("stale.md"), "old").unwrap();
1643
1644        recreate_staging_dir(&staging).unwrap();
1645
1646        assert!(staging.is_dir());
1647        assert!(!staging.join("stale.md").exists());
1648    }
1649
1650    #[test]
1651    fn recreate_staging_dir_fails_when_path_is_a_file() {
1652        // `remove_dir_all` on a regular file fails — the first `?`.
1653        let tmp = tempfile::tempdir().unwrap();
1654        let blocked = tmp.path().join("staging");
1655        fs::write(&blocked, "not a dir").unwrap();
1656
1657        assert!(recreate_staging_dir(&blocked).is_err());
1658    }
1659
1660    #[test]
1661    #[cfg(unix)]
1662    fn recreate_staging_dir_fails_when_parent_is_read_only() {
1663        // `create_dir_all` under a read-only parent fails — the
1664        // second `?`.
1665        use std::os::unix::fs::PermissionsExt;
1666        let tmp = tempfile::tempdir().unwrap();
1667        let parent = tmp.path().join("ro");
1668        fs::create_dir_all(&parent).unwrap();
1669        fs::set_permissions(&parent, fs::Permissions::from_mode(0o555))
1670            .unwrap();
1671
1672        let res = recreate_staging_dir(&parent.join("staging"));
1673
1674        let _ = fs::set_permissions(&parent, fs::Permissions::from_mode(0o755));
1675        // Root bypasses permissions on some CI runners, so tolerate Ok.
1676        assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1677    }
1678
1679    #[test]
1680    #[serial_test::parallel(stager_fp)]
1681    fn stage_fails_when_staging_root_is_blocked_by_a_file() {
1682        // Drives the `recreate_staging_dir(..)?` edge inside
1683        // `stage_content_with_site_defaults`.
1684        let tmp = tempfile::tempdir().unwrap();
1685        let src = tmp.path().join("content");
1686        let build = tmp.path().join("build");
1687        fs::create_dir_all(&src).unwrap();
1688
1689        let staging = staging_root_for("content", &build);
1690        fs::write(&staging, "blocker").unwrap();
1691
1692        let res =
1693            stage_content_with_site_defaults(&src, &build, &[], None, &[]);
1694        let _ = fs::remove_file(&staging);
1695        assert!(res.is_err());
1696    }
1697
1698    // -----------------------------------------------------------------
1699    // copy_tree / collect_entries — error and skip arms
1700    // -----------------------------------------------------------------
1701
1702    #[test]
1703    fn copy_tree_fails_when_destination_subdir_is_blocked() {
1704        // A file squatting on a destination directory name makes the
1705        // serial `create_dir_all` fail.
1706        let tmp = tempfile::tempdir().unwrap();
1707        let src = tmp.path().join("src");
1708        let dst = tmp.path().join("dst");
1709        fs::create_dir_all(src.join("sub")).unwrap();
1710        fs::write(src.join("sub/a.md"), "---\nt: a\n---\nx").unwrap();
1711        fs::create_dir_all(&dst).unwrap();
1712        fs::write(dst.join("sub"), "file, not dir").unwrap();
1713
1714        assert!(copy_tree(&src, &dst, None).is_err());
1715    }
1716
1717    #[test]
1718    fn copy_tree_copies_non_markdown_files_verbatim() {
1719        let tmp = tempfile::tempdir().unwrap();
1720        let src = tmp.path().join("src");
1721        let dst = tmp.path().join("dst");
1722        fs::create_dir_all(&src).unwrap();
1723        fs::create_dir_all(&dst).unwrap();
1724        fs::write(src.join("style.css"), "body{}").unwrap();
1725
1726        copy_tree(&src, &dst, None).unwrap();
1727        assert_eq!(
1728            fs::read_to_string(dst.join("style.css")).unwrap(),
1729            "body{}"
1730        );
1731    }
1732
1733    #[test]
1734    fn copy_tree_reports_first_per_file_error() {
1735        // A non-UTF-8 .md file fails `read_to_string` inside the
1736        // parallel pass; the first collected error is returned.
1737        let tmp = tempfile::tempdir().unwrap();
1738        let src = tmp.path().join("src");
1739        let dst = tmp.path().join("dst");
1740        fs::create_dir_all(&src).unwrap();
1741        fs::create_dir_all(&dst).unwrap();
1742        fs::write(src.join("bad.md"), [0xFF, 0xFE, 0x00]).unwrap();
1743
1744        assert!(copy_tree(&src, &dst, None).is_err());
1745    }
1746
1747    #[test]
1748    fn copy_tree_reports_error_copying_non_markdown_file() {
1749        // The `fs::copy(&src_path, &dst_path).map(|_| ())` arm for
1750        // non-markdown files is only exercised on the success path
1751        // elsewhere (`copy_tree_copies_non_markdown_files_verbatim`).
1752        // Make the destination path itself an existing directory so
1753        // `fs::copy` fails for the non-md file specifically (distinct
1754        // from `copy_tree_fails_when_destination_subdir_is_blocked`,
1755        // which fails earlier at the serial `create_dir_all` step).
1756        let tmp = tempfile::tempdir().unwrap();
1757        let src = tmp.path().join("src");
1758        let dst = tmp.path().join("dst");
1759        fs::create_dir_all(&src).unwrap();
1760        fs::create_dir_all(&dst).unwrap();
1761        fs::write(src.join("logo.png"), b"not really a png").unwrap();
1762        // Destination already occupied by a directory named like the file.
1763        fs::create_dir_all(dst.join("logo.png")).unwrap();
1764
1765        assert!(copy_tree(&src, &dst, None).is_err());
1766    }
1767
1768    #[test]
1769    #[cfg(unix)]
1770    fn copy_tree_propagates_unreadable_source_subdir() {
1771        // The recursive collect_entries call fails when a nested
1772        // source directory can't be listed.
1773        use std::os::unix::fs::PermissionsExt;
1774        let tmp = tempfile::tempdir().unwrap();
1775        let src = tmp.path().join("src");
1776        let dst = tmp.path().join("dst");
1777        let locked = src.join("locked");
1778        fs::create_dir_all(&locked).unwrap();
1779        fs::create_dir_all(&dst).unwrap();
1780        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1781            .unwrap();
1782
1783        let res = copy_tree(&src, &dst, None);
1784
1785        let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o755));
1786        // Root bypasses permissions on some CI runners, so tolerate Ok.
1787        assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1788    }
1789
1790    #[test]
1791    #[cfg(unix)]
1792    fn collect_entries_skips_symlinks_and_special_files() {
1793        let tmp = tempfile::tempdir().unwrap();
1794        let src = tmp.path().join("src");
1795        let dst = tmp.path().join("dst");
1796        fs::create_dir_all(&src).unwrap();
1797        fs::create_dir_all(&dst).unwrap();
1798        fs::write(src.join("real.md"), "---\nt: a\n---\nx").unwrap();
1799        std::os::unix::fs::symlink(src.join("nowhere.md"), src.join("link.md"))
1800            .unwrap();
1801
1802        copy_tree(&src, &dst, None).unwrap();
1803        assert!(dst.join("real.md").exists());
1804        assert!(!dst.join("link.md").exists(), "symlinks must be skipped");
1805    }
1806
1807    // -----------------------------------------------------------------
1808    // collect_template_vars / extract_simple_vars — recursion, errors,
1809    // and every reject shape
1810    // -----------------------------------------------------------------
1811
1812    #[test]
1813    fn collect_template_vars_recurses_into_subdirectories() {
1814        let tmp = tempfile::tempdir().unwrap();
1815        let t = tmp.path().join("templates");
1816        fs::create_dir_all(t.join("partials")).unwrap();
1817        fs::write(t.join("page.html"), "{{ title }}").unwrap();
1818        fs::write(t.join("partials/nav.html"), "{{ nav_label }}").unwrap();
1819
1820        let vars = collect_template_vars(&t).unwrap();
1821        assert!(vars.contains(&"title".to_string()));
1822        assert!(vars.contains(&"nav_label".to_string()));
1823    }
1824
1825    #[test]
1826    #[cfg(unix)]
1827    fn collect_template_vars_propagates_unreadable_subdir() {
1828        use std::os::unix::fs::PermissionsExt;
1829        let tmp = tempfile::tempdir().unwrap();
1830        let t = tmp.path().join("templates");
1831        let sub = t.join("locked");
1832        fs::create_dir_all(&sub).unwrap();
1833        fs::set_permissions(&sub, fs::Permissions::from_mode(0o000)).unwrap();
1834
1835        let res = collect_template_vars(&t);
1836
1837        let _ = fs::set_permissions(&sub, fs::Permissions::from_mode(0o755));
1838        // Root bypasses permissions on some CI runners, so tolerate Ok.
1839        assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1840    }
1841
1842    #[test]
1843    fn extract_simple_vars_rejects_every_non_simple_shape() {
1844        let tmp = tempfile::tempdir().unwrap();
1845        let t = tmp.path().join("templates");
1846        fs::create_dir_all(&t).unwrap();
1847        fs::write(
1848            t.join("page.html"),
1849            // empty ref, helper, closing tag, raw-emit, partial,
1850            // filtered, dotted, spaced, unclosed — none are simple.
1851            "{{  }}{{#each xs}}{{/each}}{{!raw}}{{>part}}\
1852             {{ a | upper }}{{ a.b }}{{ a b }}{{ good }}{{ broken",
1853        )
1854        .unwrap();
1855
1856        let vars = collect_template_vars(&t).unwrap();
1857        assert_eq!(vars, vec!["good".to_string()]);
1858    }
1859
1860    #[test]
1861    fn walk_collect_vars_skips_unreadable_and_non_template_files() {
1862        let tmp = tempfile::tempdir().unwrap();
1863        let t = tmp.path().join("templates");
1864        fs::create_dir_all(&t).unwrap();
1865        // Non-UTF-8 template file: read_to_string fails, silently
1866        // skipped.
1867        fs::write(t.join("binary.html"), [0xFF, 0xFE, 0x00]).unwrap();
1868        // Non-templating extension: never scanned.
1869        fs::write(t.join("style.css"), "{{ not_a_var }}").unwrap();
1870        fs::write(t.join("page.html"), "{{ real_var }}").unwrap();
1871
1872        let vars = collect_template_vars(&t).unwrap();
1873        assert_eq!(vars, vec!["real_var".to_string()]);
1874    }
1875
1876    // -----------------------------------------------------------------
1877    // inject_template_defaults_recursive — direct error/skip arms
1878    // -----------------------------------------------------------------
1879
1880    #[test]
1881    fn inject_defaults_recurses_and_injects_in_nested_dirs() {
1882        let tmp = tempfile::tempdir().unwrap();
1883        let dir = tmp.path().join("staged");
1884        fs::create_dir_all(dir.join("blog")).unwrap();
1885        fs::write(dir.join("blog/a.md"), "---\ntitle: A\n---\nx").unwrap();
1886
1887        inject_template_defaults_recursive(
1888            &dir,
1889            &["author".to_string()],
1890            None,
1891            &[],
1892        )
1893        .unwrap();
1894
1895        let body = fs::read_to_string(dir.join("blog/a.md")).unwrap();
1896        assert!(body.contains("author:"));
1897    }
1898
1899    #[test]
1900    fn inject_defaults_reports_unreadable_markdown() {
1901        // Non-UTF-8 bytes fail the read inside the parallel pass.
1902        let tmp = tempfile::tempdir().unwrap();
1903        let dir = tmp.path().join("staged");
1904        fs::create_dir_all(&dir).unwrap();
1905        fs::write(dir.join("bad.md"), [0xFF, 0xFE]).unwrap();
1906
1907        let res = inject_template_defaults_recursive(
1908            &dir,
1909            &["k".to_string()],
1910            None,
1911            &[],
1912        );
1913        assert!(res.is_err());
1914    }
1915
1916    #[test]
1917    #[cfg(unix)]
1918    fn inject_defaults_propagates_unreadable_subdir() {
1919        use std::os::unix::fs::PermissionsExt;
1920        let tmp = tempfile::tempdir().unwrap();
1921        let dir = tmp.path().join("staged");
1922        let sub = dir.join("locked");
1923        fs::create_dir_all(&sub).unwrap();
1924        fs::set_permissions(&sub, fs::Permissions::from_mode(0o000)).unwrap();
1925
1926        let res = inject_template_defaults_recursive(
1927            &dir,
1928            &["k".to_string()],
1929            None,
1930            &[],
1931        );
1932
1933        let _ = fs::set_permissions(&sub, fs::Permissions::from_mode(0o755));
1934        assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1935    }
1936
1937    #[test]
1938    #[cfg(unix)]
1939    fn collect_markdown_files_skips_symlinks() {
1940        let tmp = tempfile::tempdir().unwrap();
1941        let dir = tmp.path().join("staged");
1942        fs::create_dir_all(&dir).unwrap();
1943        fs::write(dir.join("real.md"), "---\nt: a\n---\nx").unwrap();
1944        std::os::unix::fs::symlink(dir.join("nowhere.md"), dir.join("link.md"))
1945            .unwrap();
1946
1947        let mut found = Vec::new();
1948        collect_markdown_files(&dir, &mut found).unwrap();
1949        assert_eq!(found.len(), 1);
1950    }
1951
1952    #[test]
1953    fn collect_markdown_files_skips_non_markdown_regular_files() {
1954        // The `else if ft.is_file() && is_markdown(&p)` arm where
1955        // `is_file()` is true but `is_markdown()` is false is never
1956        // exercised elsewhere — every fixture used with
1957        // `collect_markdown_files` / `inject_template_defaults_recursive`
1958        // only ever contains `.md` files.
1959        let tmp = tempfile::tempdir().unwrap();
1960        let dir = tmp.path().join("staged");
1961        fs::create_dir_all(&dir).unwrap();
1962        fs::write(dir.join("real.md"), "---\nt: a\n---\nx").unwrap();
1963        fs::write(dir.join("notes.txt"), "not markdown").unwrap();
1964        fs::write(dir.join("style.css"), "body{}").unwrap();
1965
1966        let mut found = Vec::new();
1967        collect_markdown_files(&dir, &mut found).unwrap();
1968        assert_eq!(found, vec![dir.join("real.md")]);
1969    }
1970
1971    // -----------------------------------------------------------------
1972    // is_markdown / find_opening_fence — remaining shapes
1973    // -----------------------------------------------------------------
1974
1975    #[test]
1976    fn is_markdown_accepts_both_extensions() {
1977        assert!(is_markdown(Path::new("a.md")));
1978        assert!(is_markdown(Path::new("a.markdown")));
1979        assert!(!is_markdown(Path::new("a.html")));
1980    }
1981
1982    #[test]
1983    fn find_opening_fence_skips_leading_blank_lines() {
1984        let (lead, after) =
1985            find_opening_fence("\n  \n---\ntitle: x\n---\nbody").unwrap();
1986        assert_eq!(lead, "\n  \n");
1987        assert!(after.starts_with("title: x"));
1988    }
1989
1990    #[test]
1991    fn find_opening_fence_returns_none_for_blank_only_input() {
1992        assert!(find_opening_fence("\n\n  \n").is_none());
1993        assert!(find_opening_fence("").is_none());
1994    }
1995
1996    // -----------------------------------------------------------------
1997    // Fault injection — inject_template_defaults_recursive failpoint
1998    // -----------------------------------------------------------------
1999
2000    #[cfg(feature = "test-fault-injection")]
2001    #[test]
2002    #[serial_test::serial(stager_fp)]
2003    fn stage_fault_inject_defaults_returns_err() {
2004        // RAII guard so a panicking assertion still deactivates the
2005        // failpoint (mirrors tests/fault_injection.rs).
2006        struct FailGuard(&'static str);
2007        impl Drop for FailGuard {
2008            fn drop(&mut self) {
2009                let _ = fail::cfg(self.0, "off");
2010            }
2011        }
2012        let _guard = FailGuard("content_stager::inject-defaults");
2013        fail::cfg("content_stager::inject-defaults", "return")
2014            .expect("activate failpoint");
2015
2016        let tmp = tempfile::tempdir().unwrap();
2017        let src = tmp.path().join("content");
2018        let build = tmp.path().join("build");
2019        fs::create_dir_all(&src).unwrap();
2020        fs::write(src.join("a.md"), "---\ntitle: A\n---\nbody").unwrap();
2021
2022        let err = stage_content_with_site_defaults(
2023            &src,
2024            &build,
2025            &["title".to_string()],
2026            None,
2027            &[],
2028        )
2029        .expect_err("failpoint must abort the staging pass");
2030        assert!(format!("{err}").contains("inject-defaults"));
2031    }
2032}