Skip to main content

ssg/plugins/
isr_manifest.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! ISR manifest emitter — `dist/.ssg/manifest.json` + raw content KV
5//! payloads under `dist/.ssg/content/`.
6//!
7//! Runs as an `after_compile` plugin, but ONLY when the build opted
8//! into ISR via `--isr` (issue #546 AC9 — without the flag the plugin
9//! is not registered and the build stays byte-identical to v0.0.43).
10//!
11//! Behaviour:
12//!
13//! 1. Walks the content directory for `.md` files.
14//! 2. For each markdown file, parses frontmatter to look for
15//!    `isr.s_maxage` / `isr.swr` overrides.
16//! 3. Derives the published URL using the existing slug rules.
17//! 4. Emits a `ManifestEntry` listing the markdown source + the
18//!    relevant templates as `sources`, with a sha256 over their bytes.
19//! 5. Writes `dist/.ssg/manifest.json` and copies the raw sources into
20//!    `dist/.ssg/content/` so the deploy step can upload to KV.
21
22use std::fs;
23use std::path::{Path, PathBuf};
24
25use ssg_core::{build_entry, CachePolicy, Manifest, ManifestEntry};
26
27use crate::error::SsgError;
28use crate::plugin::{Plugin, PluginContext};
29
30/// Subdirectory inside `dist/.ssg/` that holds the manifest.
31pub const MANIFEST_RELATIVE_PATH: &str = ".ssg/manifest.json";
32
33/// Subdirectory inside `dist/.ssg/` that holds raw source payloads
34/// destined for KV / Edge Config upload.
35pub const CONTENT_RELATIVE_DIR: &str = ".ssg/content";
36
37/// `after_compile` plugin that emits the ISR manifest + raw KV
38/// payloads. Off by default; enabled by the `--isr` flag.
39///
40/// # Examples
41///
42/// ```
43/// use ssg::plugin::Plugin;
44/// use ssg::isr_manifest::IsrManifestPlugin;
45/// assert_eq!(IsrManifestPlugin::new().name(), "isr-manifest");
46/// ```
47#[derive(Debug, Clone, Copy)]
48pub struct IsrManifestPlugin;
49
50impl IsrManifestPlugin {
51    /// Constructs a new instance.
52    ///
53    /// # Examples
54    ///
55    /// ```
56    /// use ssg::isr_manifest::IsrManifestPlugin;
57    /// let _plugin = IsrManifestPlugin::new();
58    /// ```
59    #[must_use]
60    pub const fn new() -> Self {
61        Self
62    }
63}
64
65impl Default for IsrManifestPlugin {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl Plugin for IsrManifestPlugin {
72    fn name(&self) -> &'static str {
73        "isr-manifest"
74    }
75
76    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
77        if ctx.dry_run {
78            return Ok(());
79        }
80
81        let manifest =
82            build_manifest(&ctx.content_dir, &ctx.template_dir, &ctx.site_dir)?;
83
84        write_manifest(&manifest, &ctx.site_dir)?;
85        copy_sources(
86            &ctx.content_dir,
87            &ctx.template_dir,
88            &ctx.site_dir,
89            &manifest,
90        )?;
91
92        Ok(())
93    }
94}
95
96// ---------------------------------------------------------------------------
97// build_manifest — walk content + derive entries
98// ---------------------------------------------------------------------------
99
100/// Builds a [`Manifest`] by walking `content_dir` for `.md` files and
101/// pairing each with the layout templates it would render against.
102///
103/// The layout selection mirrors what the staticdatagen pipeline would
104/// do — `templates/index.html` and `templates/page.html` cover the
105/// 95% case. A page can override the cache policy via
106/// `isr.s_maxage` / `isr.swr` in frontmatter.
107///
108/// # Errors
109///
110/// Returns [`SsgError::Io`] when the content/template directories cannot
111/// be walked or read.
112///
113/// # Examples
114///
115/// ```
116/// use ssg::isr_manifest::build_manifest;
117/// let tmp = tempfile::tempdir().unwrap();
118/// let content = tmp.path().join("content");
119/// let templates = tmp.path().join("templates");
120/// let site = tmp.path().join("site");
121/// std::fs::create_dir_all(&content).unwrap();
122/// std::fs::create_dir_all(&templates).unwrap();
123/// std::fs::create_dir_all(&site).unwrap();
124/// let m = build_manifest(&content, &templates, &site).unwrap();
125/// assert_eq!(m.len(), 0);
126/// ```
127pub fn build_manifest(
128    content_dir: &Path,
129    template_dir: &Path,
130    site_dir: &Path,
131) -> Result<Manifest, SsgError> {
132    let mut manifest = Manifest::new(build_stamp());
133
134    let md_files = collect_md_files(content_dir)?;
135    for md_path in md_files {
136        let entry = build_entry_for_markdown(
137            &md_path,
138            content_dir,
139            template_dir,
140            site_dir,
141        )?;
142        let Some((url, entry)) = entry else { continue };
143        manifest.insert(url, entry);
144    }
145
146    Ok(manifest)
147}
148
149/// Returns a stable per-build identifier. Uses the workspace package
150/// version when available — adapters compare this to detect a deploy.
151fn build_stamp() -> String {
152    let version = env!("CARGO_PKG_VERSION");
153    format!("ssg-{version}")
154}
155
156/// Walks `dir` recursively and returns every `.md` file. Skips hidden
157/// directories (`.git`, `.ssg`, etc.) and returns paths sorted
158/// lexicographically for determinism.
159fn collect_md_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
160    let mut out = Vec::new();
161    if !dir.exists() {
162        return Ok(out);
163    }
164    visit(dir, &mut out)?;
165    out.sort();
166    Ok(out)
167}
168
169fn visit(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), SsgError> {
170    let entries = fs::read_dir(dir).map_err(|e| SsgError::Io {
171        path: dir.to_path_buf(),
172        source: e,
173    })?;
174    for entry in entries.flatten() {
175        let path = entry.path();
176        let name = entry.file_name();
177        let name_str = name.to_string_lossy();
178        if name_str.starts_with('.') {
179            continue;
180        }
181        if path.is_dir() {
182            visit(&path, out)?;
183        } else if path.extension().is_some_and(|e| e == "md") {
184            out.push(path);
185        }
186    }
187    Ok(())
188}
189
190/// Builds a `(url, ManifestEntry)` pair for a single markdown file.
191fn build_entry_for_markdown(
192    md_path: &Path,
193    content_dir: &Path,
194    template_dir: &Path,
195    _site_dir: &Path,
196) -> Result<Option<(String, ManifestEntry)>, SsgError> {
197    let bytes = fs::read(md_path).map_err(|e| SsgError::Io {
198        path: md_path.to_path_buf(),
199        source: e,
200    })?;
201
202    let rel = md_path
203        .strip_prefix(content_dir)
204        .unwrap_or(md_path)
205        .to_string_lossy()
206        .replace('\\', "/");
207
208    // Derive published URL: content/posts/foo.md → /posts/foo/index.html
209    let url = derive_url(&rel);
210
211    // Parse frontmatter for ISR overrides.
212    let text = String::from_utf8_lossy(&bytes);
213    let cache = extract_isr_cache(&text);
214
215    // Templates that almost every page depends on. The static-data
216    // pipeline picks one of `index.html` / `page.html` per page — we
217    // include both so the Edge renderer can decide at fetch time.
218    let templates = collect_templates(template_dir);
219    let mut template_bytes_owned: Vec<Vec<u8>> =
220        Vec::with_capacity(templates.len());
221    let mut sources: Vec<String> = Vec::with_capacity(1 + templates.len());
222    sources.push(format!("content/{rel}"));
223
224    for (tpl_rel, tpl_path) in &templates {
225        let tb = fs::read(tpl_path).map_err(|e| SsgError::Io {
226            path: tpl_path.clone(),
227            source: e,
228        })?;
229        template_bytes_owned.push(tb);
230        sources.push(format!("templates/{tpl_rel}"));
231    }
232
233    let mut byte_refs: Vec<&[u8]> = Vec::with_capacity(sources.len());
234    byte_refs.push(&bytes);
235    for tb in &template_bytes_owned {
236        byte_refs.push(tb);
237    }
238
239    let entry = build_entry(sources, &byte_refs, cache);
240    Ok(Some((url, entry)))
241}
242
243/// Returns `[(relative_template_path, absolute_path)]` for layouts
244/// the Edge renderer might need. Returns an empty vec if the dir
245/// doesn't exist (e.g. minimal sites).
246fn collect_templates(template_dir: &Path) -> Vec<(String, PathBuf)> {
247    let mut out = Vec::new();
248    if !template_dir.exists() {
249        return out;
250    }
251    let candidates = ["index.html", "page.html"];
252    for name in candidates {
253        let p = template_dir.join(name);
254        if p.exists() {
255            out.push((name.to_string(), p));
256        }
257    }
258    out
259}
260
261/// Maps a content-relative path (`posts/foo.md`, `index.md`,
262/// `about/index.md`) to the published URL the static pipeline would
263/// emit (`/posts/foo/index.html`, `/index.html`, `/about/index.html`).
264fn derive_url(rel: &str) -> String {
265    let stripped = rel.strip_suffix(".md").unwrap_or(rel);
266    if stripped == "index" {
267        return "/index.html".to_string();
268    }
269    if let Some(trim) = stripped.strip_suffix("/index") {
270        return format!("/{trim}/index.html");
271    }
272    format!("/{stripped}/index.html")
273}
274
275/// Extracts `isr.s_maxage` and `isr.swr` from YAML/TOML/JSON
276/// frontmatter. Returns `None` if neither is present.
277///
278/// Frontmatter shape (YAML):
279///
280/// ```yaml
281/// isr:
282///   s_maxage: 600
283///   swr: 3600
284/// ```
285fn extract_isr_cache(text: &str) -> Option<CachePolicy> {
286    // Strip frontmatter block.
287    let fm = extract_frontmatter_block(text)?;
288
289    // Look for `isr:` block and the two numeric keys. We do a
290    // line-based scan so the parser stays deterministic and avoids
291    // pulling a YAML dep here — the canonical parser lives in
292    // staticdatagen / frontmatter-gen and runs upstream of us.
293    let mut in_isr = false;
294    let mut s_maxage: Option<u32> = None;
295    let mut swr: Option<u32> = None;
296
297    for raw_line in fm.lines() {
298        let line = raw_line.trim_end();
299        if line.starts_with("isr:") {
300            in_isr = true;
301            continue;
302        }
303        if in_isr {
304            let trimmed = line.trim_start();
305            // Indented child of `isr:`
306            if line.starts_with(' ') || line.starts_with('\t') {
307                if let Some((k, v)) = trimmed.split_once(':') {
308                    let k = k.trim();
309                    let v = v.trim();
310                    match k {
311                        "s_maxage" | "s-maxage" => {
312                            s_maxage = v.parse::<u32>().ok();
313                        }
314                        "swr" | "stale-while-revalidate" => {
315                            swr = v.parse::<u32>().ok();
316                        }
317                        _ => {}
318                    }
319                }
320            } else if !line.is_empty() {
321                in_isr = false;
322            }
323        }
324    }
325
326    if s_maxage.is_none() && swr.is_none() {
327        return None;
328    }
329    Some(CachePolicy {
330        s_maxage: s_maxage.unwrap_or(ssg_core::DEFAULT_S_MAXAGE),
331        swr: swr.unwrap_or(ssg_core::DEFAULT_SWR),
332    })
333}
334
335/// Extracts the raw YAML/TOML body of the frontmatter block. Supports
336/// `---`-fenced YAML and `+++`-fenced TOML.
337fn extract_frontmatter_block(text: &str) -> Option<&str> {
338    let trimmed = text.trim_start();
339    if let Some(after) = trimmed.strip_prefix("---") {
340        if let Some(end) = after.find("---") {
341            return Some(&after[..end]);
342        }
343    }
344    if let Some(after) = trimmed.strip_prefix("+++") {
345        if let Some(end) = after.find("+++") {
346            return Some(&after[..end]);
347        }
348    }
349    None
350}
351
352// ---------------------------------------------------------------------------
353// I/O: write manifest + copy raw sources
354// ---------------------------------------------------------------------------
355
356fn write_manifest(
357    manifest: &Manifest,
358    site_dir: &Path,
359) -> Result<(), SsgError> {
360    let manifest_path = site_dir.join(MANIFEST_RELATIVE_PATH);
361    if let Some(parent) = manifest_path.parent() {
362        fs::create_dir_all(parent).map_err(|e| SsgError::Io {
363            path: parent.to_path_buf(),
364            source: e,
365        })?;
366    }
367    let json = manifest.to_pretty_json().map_err(|e| SsgError::Io {
368        path: manifest_path.clone(),
369        source: std::io::Error::other(e),
370    })?;
371    fs::write(&manifest_path, json).map_err(|e| SsgError::Io {
372        path: manifest_path.clone(),
373        source: e,
374    })?;
375    Ok(())
376}
377
378fn copy_sources(
379    content_dir: &Path,
380    template_dir: &Path,
381    site_dir: &Path,
382    manifest: &Manifest,
383) -> Result<(), SsgError> {
384    let content_out = site_dir.join(CONTENT_RELATIVE_DIR);
385    fs::create_dir_all(&content_out).map_err(|e| SsgError::Io {
386        path: content_out.clone(),
387        source: e,
388    })?;
389
390    // Collect unique source keys from manifest.
391    let mut all_sources = std::collections::BTreeSet::new();
392    for entry in manifest.entries.values() {
393        for s in &entry.sources {
394            let _ = all_sources.insert(s.clone());
395        }
396    }
397
398    for source in all_sources {
399        let src_path = if let Some(rel) = source.strip_prefix("content/") {
400            content_dir.join(rel)
401        } else if let Some(rel) = source.strip_prefix("templates/") {
402            template_dir.join(rel)
403        } else {
404            continue;
405        };
406        if !src_path.exists() {
407            continue;
408        }
409
410        let dst_path = content_out.join(&source);
411        if let Some(parent) = dst_path.parent() {
412            fs::create_dir_all(parent).map_err(|e| SsgError::Io {
413                path: parent.to_path_buf(),
414                source: e,
415            })?;
416        }
417        let _bytes_copied =
418            fs::copy(&src_path, &dst_path).map_err(|e| SsgError::Io {
419                path: dst_path.clone(),
420                source: e,
421            })?;
422    }
423
424    Ok(())
425}
426
427// ---------------------------------------------------------------------------
428// Tests
429// ---------------------------------------------------------------------------
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use tempfile::tempdir;
435
436    #[test]
437    fn derive_url_index() {
438        assert_eq!(derive_url("index.md"), "/index.html");
439    }
440
441    #[test]
442    fn derive_url_post() {
443        assert_eq!(derive_url("posts/foo.md"), "/posts/foo/index.html");
444    }
445
446    #[test]
447    fn derive_url_already_index() {
448        assert_eq!(derive_url("about/index.md"), "/about/index.html");
449    }
450
451    #[test]
452    fn derive_url_nested() {
453        assert_eq!(derive_url("a/b/c.md"), "/a/b/c/index.html");
454    }
455
456    #[test]
457    fn derive_url_without_md_suffix_uses_input_verbatim() {
458        // `strip_suffix(".md")` fails when there's no `.md` extension,
459        // exercising the `unwrap_or(rel)` fallback before deriving the
460        // URL from the (unstripped) input.
461        assert_eq!(derive_url("notes"), "/notes/index.html");
462    }
463
464    #[test]
465    fn extract_isr_cache_yaml() {
466        let text =
467            "---\ntitle: Foo\nisr:\n  s_maxage: 600\n  swr: 3600\n---\n# Body";
468        let c = extract_isr_cache(text).unwrap();
469        assert_eq!(c.s_maxage, 600);
470        assert_eq!(c.swr, 3600);
471    }
472
473    #[test]
474    fn extract_isr_cache_only_s_maxage() {
475        let text = "---\nisr:\n  s_maxage: 30\n---\n";
476        let c = extract_isr_cache(text).unwrap();
477        assert_eq!(c.s_maxage, 30);
478        assert_eq!(c.swr, ssg_core::DEFAULT_SWR);
479    }
480
481    #[test]
482    fn extract_isr_cache_only_swr() {
483        let text = "---\nisr:\n  swr: 7200\n---\n";
484        let c = extract_isr_cache(text).unwrap();
485        assert_eq!(c.s_maxage, ssg_core::DEFAULT_S_MAXAGE);
486        assert_eq!(c.swr, 7200);
487    }
488
489    #[test]
490    fn extract_isr_cache_none() {
491        let text = "---\ntitle: Foo\n---\n";
492        assert!(extract_isr_cache(text).is_none());
493    }
494
495    #[test]
496    fn extract_isr_cache_no_frontmatter() {
497        assert!(extract_isr_cache("# Hello").is_none());
498    }
499
500    #[test]
501    fn collect_md_files_recursive_and_sorted() {
502        let dir = tempdir().unwrap();
503        fs::write(dir.path().join("b.md"), "b").unwrap();
504        fs::create_dir_all(dir.path().join("sub")).unwrap();
505        fs::write(dir.path().join("sub/a.md"), "a").unwrap();
506        fs::write(dir.path().join("ignore.txt"), "no").unwrap();
507
508        let files = collect_md_files(dir.path()).unwrap();
509        assert_eq!(files.len(), 2);
510        // Sorted lexicographically by full path: `b.md` < `sub/a.md`.
511        assert!(files[0].ends_with("b.md"));
512        assert!(files[1].ends_with("sub/a.md"));
513    }
514
515    #[test]
516    fn collect_md_files_skips_hidden_dirs() {
517        let dir = tempdir().unwrap();
518        fs::create_dir_all(dir.path().join(".hidden")).unwrap();
519        fs::write(dir.path().join(".hidden/a.md"), "x").unwrap();
520        fs::write(dir.path().join("real.md"), "y").unwrap();
521
522        let files = collect_md_files(dir.path()).unwrap();
523        assert_eq!(files.len(), 1);
524        assert!(files[0].ends_with("real.md"));
525    }
526
527    #[test]
528    fn build_manifest_emits_entries() {
529        let dir = tempdir().unwrap();
530        let content_dir = dir.path().join("content");
531        let template_dir = dir.path().join("templates");
532        let site_dir = dir.path().join("public");
533
534        fs::create_dir_all(&content_dir).unwrap();
535        fs::create_dir_all(&template_dir).unwrap();
536        fs::create_dir_all(&site_dir).unwrap();
537
538        fs::write(content_dir.join("index.md"), "# Home").unwrap();
539        fs::write(
540            content_dir.join("post.md"),
541            "---\nisr:\n  s_maxage: 30\n---\n# Post",
542        )
543        .unwrap();
544        fs::write(template_dir.join("index.html"), "<html/>").unwrap();
545        fs::write(template_dir.join("page.html"), "<page/>").unwrap();
546
547        let m = build_manifest(&content_dir, &template_dir, &site_dir).unwrap();
548        assert_eq!(m.len(), 2);
549        assert!(m.get("/index.html").is_some());
550        let post = m.get("/post/index.html").unwrap();
551        assert_eq!(post.cache.as_ref().unwrap().s_maxage, 30);
552        assert_eq!(post.sources[0], "content/post.md");
553        // Sources include the two templates.
554        assert!(post.sources.iter().any(|s| s == "templates/index.html"));
555        assert!(post.sources.iter().any(|s| s == "templates/page.html"));
556        assert_eq!(post.hash.len(), 64);
557    }
558
559    #[test]
560    fn build_entry_for_markdown_falls_back_to_full_path_outside_content_dir() {
561        // `md_path.strip_prefix(content_dir)` fails when the markdown
562        // file doesn't actually live under `content_dir`, exercising
563        // the `unwrap_or(md_path)` fallback that keeps the full path
564        // as `rel` instead of erroring out.
565        let dir = tempdir().unwrap();
566        let elsewhere = dir.path().join("elsewhere");
567        fs::create_dir_all(&elsewhere).unwrap();
568        let md_path = elsewhere.join("orphan.md");
569        fs::write(&md_path, "# Orphan").unwrap();
570
571        let content_dir = dir.path().join("content");
572        let template_dir = dir.path().join("templates");
573        let site_dir = dir.path().join("site");
574        fs::create_dir_all(&content_dir).unwrap();
575        fs::create_dir_all(&template_dir).unwrap();
576        fs::create_dir_all(&site_dir).unwrap();
577
578        let (url, entry) = build_entry_for_markdown(
579            &md_path,
580            &content_dir,
581            &template_dir,
582            &site_dir,
583        )
584        .unwrap()
585        .expect("entry is always produced");
586        assert!(
587            entry.sources[0].contains("orphan.md"),
588            "source should reference the full path: {:?}",
589            entry.sources
590        );
591        assert!(url.ends_with("/index.html"));
592    }
593
594    #[test]
595    fn write_manifest_creates_parent_dirs() {
596        let dir = tempdir().unwrap();
597        let m = Manifest::default();
598        write_manifest(&m, dir.path()).unwrap();
599        let p = dir.path().join(MANIFEST_RELATIVE_PATH);
600        assert!(p.exists());
601        let parsed: Manifest =
602            serde_json::from_str(&fs::read_to_string(&p).unwrap()).unwrap();
603        assert_eq!(parsed, m);
604    }
605
606    #[test]
607    fn plugin_after_compile_writes_manifest_and_copies_sources() {
608        let dir = tempdir().unwrap();
609        let content_dir = dir.path().join("content");
610        let template_dir = dir.path().join("templates");
611        let site_dir = dir.path().join("public");
612
613        fs::create_dir_all(&content_dir).unwrap();
614        fs::create_dir_all(&template_dir).unwrap();
615        fs::create_dir_all(&site_dir).unwrap();
616
617        fs::write(content_dir.join("a.md"), "# A").unwrap();
618        fs::write(template_dir.join("index.html"), "<x/>").unwrap();
619
620        let ctx = PluginContext {
621            content_dir: content_dir.clone(),
622            build_dir: site_dir.clone(),
623            site_dir: site_dir.clone(),
624            template_dir: template_dir.clone(),
625            config: None,
626            cache: None,
627            memory_budget: None,
628            html_files: None,
629            dep_graph: None,
630            dry_run: false,
631        };
632
633        IsrManifestPlugin.after_compile(&ctx).unwrap();
634
635        let manifest_path = site_dir.join(MANIFEST_RELATIVE_PATH);
636        assert!(manifest_path.exists());
637
638        let content_dst =
639            site_dir.join(CONTENT_RELATIVE_DIR).join("content/a.md");
640        assert!(content_dst.exists(), "raw markdown should be staged");
641
642        let template_dst = site_dir
643            .join(CONTENT_RELATIVE_DIR)
644            .join("templates/index.html");
645        assert!(template_dst.exists(), "template should be staged");
646    }
647
648    #[test]
649    fn plugin_after_compile_dry_run_writes_nothing() {
650        let dir = tempdir().unwrap();
651        let content_dir = dir.path().join("content");
652        let template_dir = dir.path().join("templates");
653        let site_dir = dir.path().join("public");
654        fs::create_dir_all(&content_dir).unwrap();
655        fs::create_dir_all(&template_dir).unwrap();
656        fs::create_dir_all(&site_dir).unwrap();
657        fs::write(content_dir.join("a.md"), "x").unwrap();
658
659        let ctx = PluginContext {
660            content_dir,
661            build_dir: site_dir.clone(),
662            site_dir: site_dir.clone(),
663            template_dir,
664            config: None,
665            cache: None,
666            memory_budget: None,
667            html_files: None,
668            dep_graph: None,
669            dry_run: true,
670        };
671
672        IsrManifestPlugin.after_compile(&ctx).unwrap();
673        assert!(!site_dir.join(MANIFEST_RELATIVE_PATH).exists());
674    }
675
676    #[test]
677    fn plugin_name() {
678        assert_eq!(IsrManifestPlugin.name(), "isr-manifest");
679    }
680
681    #[test]
682    fn plugin_default_constructs() {
683        let _p = <IsrManifestPlugin as Default>::default();
684    }
685
686    #[test]
687    fn plugin_after_compile_full_run_writes_manifest_and_copies_sources() {
688        // Covers after_compile's full happy path: build_manifest +
689        // write_manifest + copy_sources (the line-90 branch the dry-run
690        // test skips).
691        let dir = tempdir().unwrap();
692        let content_dir = dir.path().join("content");
693        let template_dir = dir.path().join("templates");
694        let site_dir = dir.path().join("public");
695        fs::create_dir_all(&content_dir).unwrap();
696        fs::create_dir_all(&template_dir).unwrap();
697        fs::create_dir_all(&site_dir).unwrap();
698        fs::write(content_dir.join("hello.md"), "hello world").unwrap();
699        fs::write(template_dir.join("index.html"), "<html/>").unwrap();
700
701        let ctx = PluginContext {
702            content_dir: content_dir.clone(),
703            build_dir: site_dir.clone(),
704            site_dir: site_dir.clone(),
705            template_dir,
706            config: None,
707            cache: None,
708            memory_budget: None,
709            html_files: None,
710            dep_graph: None,
711            dry_run: false,
712        };
713
714        IsrManifestPlugin.after_compile(&ctx).unwrap();
715        assert!(site_dir.join(MANIFEST_RELATIVE_PATH).exists());
716    }
717
718    #[test]
719    fn collect_md_files_nonexistent_dir_returns_empty() {
720        // Covers line ~162 `if !dir.exists() return Ok(vec![])`.
721        let out = collect_md_files(Path::new("/nonexistent/xxx")).unwrap();
722        assert!(out.is_empty());
723    }
724
725    #[test]
726    fn collect_md_files_skips_hidden_dirs_v2() {
727        // Differs from the existing same-named test by exercising
728        // the path-skip branch with a nested .md instead of relying
729        // on top-level filtering.
730        let dir = tempdir().unwrap();
731        fs::create_dir_all(dir.path().join(".hidden")).unwrap();
732        fs::write(dir.path().join(".hidden/secret.md"), "x").unwrap();
733        fs::write(dir.path().join("visible.md"), "x").unwrap();
734        let out = collect_md_files(dir.path()).unwrap();
735        assert_eq!(out.len(), 1);
736        assert_eq!(
737            out[0].file_name().unwrap(),
738            std::ffi::OsStr::new("visible.md")
739        );
740    }
741
742    #[test]
743    fn collect_md_files_walks_nested_v2() {
744        let dir = tempdir().unwrap();
745        let sub = dir.path().join("a/b/c");
746        fs::create_dir_all(&sub).unwrap();
747        fs::write(sub.join("deep.md"), "x").unwrap();
748        fs::write(dir.path().join("shallow.md"), "x").unwrap();
749        let out = collect_md_files(dir.path()).unwrap();
750        assert_eq!(out.len(), 2);
751    }
752
753    #[test]
754    fn collect_templates_nonexistent_dir_returns_empty() {
755        // Covers line ~249.
756        let out = collect_templates(Path::new("/nonexistent/yyy"));
757        assert!(out.is_empty());
758    }
759
760    #[test]
761    fn extract_isr_cache_yaml_both_keys() {
762        // Covers lines 312-315 (s_maxage + swr) and line 332 return.
763        let text = "---\nisr:\n  s_maxage: 600\n  swr: 3600\n---\n";
764        let p = extract_isr_cache(text).unwrap();
765        assert_eq!(p.s_maxage, 600);
766        assert_eq!(p.swr, 3600);
767    }
768
769    #[test]
770    fn extract_isr_cache_yaml_dash_variants() {
771        // Covers the s-maxage/stale-while-revalidate alt keys.
772        let text =
773            "---\nisr:\n  s-maxage: 42\n  stale-while-revalidate: 99\n---\n";
774        let p = extract_isr_cache(text).unwrap();
775        assert_eq!(p.s_maxage, 42);
776        assert_eq!(p.swr, 99);
777    }
778
779    #[test]
780    fn extract_isr_cache_ignores_unknown_keys_in_isr_block() {
781        // Covers line 317 `_ => {}` for unknown keys inside isr block.
782        let text = "---\nisr:\n  unknown_key: 5\n  s_maxage: 7\n---\n";
783        let p = extract_isr_cache(text).unwrap();
784        assert_eq!(p.s_maxage, 7);
785    }
786
787    #[test]
788    fn extract_isr_cache_isr_block_exits_on_non_indented_line() {
789        // Covers line 320-321 (line not empty AND not indented → exit).
790        let text = "---\nisr:\n  s_maxage: 5\ntitle: Hi\nswr: 8\n---\n";
791        let p = extract_isr_cache(text).unwrap();
792        // s_maxage in block was picked up; swr at top level was NOT.
793        assert_eq!(p.s_maxage, 5);
794        assert_eq!(p.swr, ssg_core::DEFAULT_SWR);
795    }
796
797    #[test]
798    fn extract_isr_cache_no_frontmatter_returns_none() {
799        assert!(extract_isr_cache("just body text").is_none());
800    }
801
802    #[test]
803    fn extract_isr_cache_no_isr_block_returns_none() {
804        let text = "---\ntitle: Hi\n---\n";
805        assert!(extract_isr_cache(text).is_none());
806    }
807
808    #[test]
809    fn extract_frontmatter_block_toml_fences() {
810        // Covers lines 344-347 (+++ TOML branch).
811        let text = "+++\ntitle = \"X\"\n+++\nbody";
812        let body = extract_frontmatter_block(text).unwrap();
813        assert!(body.contains("title"));
814    }
815
816    #[test]
817    fn derive_url_index_md_maps_to_root_index_html() {
818        assert_eq!(derive_url("index.md"), "/index.html");
819    }
820
821    #[test]
822    fn derive_url_nested_index_maps_to_dir_slash_index_html() {
823        assert_eq!(derive_url("about/index.md"), "/about/index.html");
824    }
825
826    #[test]
827    fn derive_url_regular_md_maps_to_dir_slash_index_html() {
828        assert_eq!(derive_url("posts/hello.md"), "/posts/hello/index.html");
829    }
830
831    /// Builds a non-dry-run [`PluginContext`] over the three dirs.
832    fn make_ctx(
833        content_dir: &Path,
834        template_dir: &Path,
835        site_dir: &Path,
836    ) -> PluginContext {
837        PluginContext {
838            content_dir: content_dir.to_path_buf(),
839            build_dir: site_dir.to_path_buf(),
840            site_dir: site_dir.to_path_buf(),
841            template_dir: template_dir.to_path_buf(),
842            config: None,
843            cache: None,
844            memory_budget: None,
845            html_files: None,
846            dep_graph: None,
847            dry_run: false,
848        }
849    }
850
851    #[cfg(unix)]
852    fn deny_access(p: &Path) {
853        use std::os::unix::fs::PermissionsExt;
854        fs::set_permissions(p, fs::Permissions::from_mode(0o000)).unwrap();
855    }
856
857    #[cfg(unix)]
858    fn restore_access(p: &Path) {
859        use std::os::unix::fs::PermissionsExt;
860        let _ = fs::set_permissions(p, fs::Permissions::from_mode(0o755));
861    }
862
863    #[test]
864    #[cfg(unix)]
865    fn after_compile_propagates_unreadable_subdir_error() {
866        // A chmod-000 subdir makes `visit`'s read_dir fail inside the
867        // recursion, exercising the Io closure and every `?` layer up
868        // through after_compile.
869        let dir = tempdir().unwrap();
870        let content_dir = dir.path().join("content");
871        let template_dir = dir.path().join("templates");
872        let site_dir = dir.path().join("public");
873        fs::create_dir_all(content_dir.join("locked")).unwrap();
874        fs::create_dir_all(&template_dir).unwrap();
875        fs::create_dir_all(&site_dir).unwrap();
876        deny_access(&content_dir.join("locked"));
877
878        let ctx = make_ctx(&content_dir, &template_dir, &site_dir);
879        let res = IsrManifestPlugin.after_compile(&ctx);
880
881        restore_access(&content_dir.join("locked"));
882        // Root CI runners bypass perms; only assert when it errored.
883        if let Err(e) = res {
884            assert!(!format!("{e}").is_empty());
885        }
886    }
887
888    #[test]
889    #[cfg(unix)]
890    fn build_manifest_propagates_unreadable_md_error() {
891        // A chmod-000 markdown file makes `fs::read` in
892        // build_entry_for_markdown fail.
893        let dir = tempdir().unwrap();
894        let content_dir = dir.path().join("content");
895        let template_dir = dir.path().join("templates");
896        let site_dir = dir.path().join("public");
897        fs::create_dir_all(&content_dir).unwrap();
898        fs::create_dir_all(&template_dir).unwrap();
899        fs::create_dir_all(&site_dir).unwrap();
900        let md = content_dir.join("locked.md");
901        fs::write(&md, "# locked").unwrap();
902        deny_access(&md);
903
904        let res = build_manifest(&content_dir, &template_dir, &site_dir);
905
906        restore_access(&md);
907        if let Err(e) = res {
908            assert!(!format!("{e}").is_empty());
909        }
910    }
911
912    #[test]
913    #[cfg(unix)]
914    fn build_manifest_propagates_unreadable_template_error() {
915        // A chmod-000 template makes the per-template `fs::read` fail.
916        let dir = tempdir().unwrap();
917        let content_dir = dir.path().join("content");
918        let template_dir = dir.path().join("templates");
919        let site_dir = dir.path().join("public");
920        fs::create_dir_all(&content_dir).unwrap();
921        fs::create_dir_all(&template_dir).unwrap();
922        fs::create_dir_all(&site_dir).unwrap();
923        fs::write(content_dir.join("a.md"), "# a").unwrap();
924        let tpl = template_dir.join("index.html");
925        fs::write(&tpl, "<html/>").unwrap();
926        deny_access(&tpl);
927
928        let res = build_manifest(&content_dir, &template_dir, &site_dir);
929
930        restore_access(&tpl);
931        if let Err(e) = res {
932            assert!(!format!("{e}").is_empty());
933        }
934    }
935
936    #[test]
937    fn after_compile_fails_when_ssg_dir_is_a_file() {
938        // `site/.ssg` existing as a *file* makes write_manifest's
939        // create_dir_all fail, covering its Io closure and the `?`
940        // propagation in after_compile.
941        let dir = tempdir().unwrap();
942        let content_dir = dir.path().join("content");
943        let template_dir = dir.path().join("templates");
944        let site_dir = dir.path().join("public");
945        fs::create_dir_all(&content_dir).unwrap();
946        fs::create_dir_all(&template_dir).unwrap();
947        fs::create_dir_all(&site_dir).unwrap();
948        fs::write(site_dir.join(".ssg"), "not a dir").unwrap();
949
950        let ctx = make_ctx(&content_dir, &template_dir, &site_dir);
951        let err = IsrManifestPlugin.after_compile(&ctx).unwrap_err();
952        assert!(!format!("{err}").is_empty());
953    }
954
955    #[test]
956    fn write_manifest_fails_when_manifest_path_is_a_dir() {
957        // A directory squatting on `.ssg/manifest.json` makes
958        // `fs::write` fail.
959        let dir = tempdir().unwrap();
960        fs::create_dir_all(dir.path().join(MANIFEST_RELATIVE_PATH)).unwrap();
961        let err = write_manifest(&Manifest::default(), dir.path()).unwrap_err();
962        assert!(!format!("{err}").is_empty());
963    }
964
965    #[test]
966    fn after_compile_fails_when_content_out_is_a_file() {
967        // write_manifest succeeds but copy_sources' create_dir_all
968        // fails because `.ssg/content` exists as a file.
969        let dir = tempdir().unwrap();
970        let content_dir = dir.path().join("content");
971        let template_dir = dir.path().join("templates");
972        let site_dir = dir.path().join("public");
973        fs::create_dir_all(&content_dir).unwrap();
974        fs::create_dir_all(&template_dir).unwrap();
975        fs::create_dir_all(site_dir.join(".ssg")).unwrap();
976        fs::write(site_dir.join(CONTENT_RELATIVE_DIR), "not a dir").unwrap();
977
978        let ctx = make_ctx(&content_dir, &template_dir, &site_dir);
979        let err = IsrManifestPlugin.after_compile(&ctx).unwrap_err();
980        assert!(!format!("{err}").is_empty());
981    }
982
983    /// Builds a one-entry manifest whose entry lists `sources`.
984    fn manifest_with_sources(sources: Vec<String>) -> Manifest {
985        let byte_refs: Vec<&[u8]> = vec![b"x"; sources.len()];
986        let entry = build_entry(sources, &byte_refs, None);
987        let mut m = Manifest::new(build_stamp());
988        m.insert("/index.html".to_string(), entry);
989        m
990    }
991
992    #[test]
993    fn copy_sources_skips_unknown_prefix_and_missing_files() {
994        let dir = tempdir().unwrap();
995        let content_dir = dir.path().join("content");
996        let template_dir = dir.path().join("templates");
997        let site_dir = dir.path().join("public");
998        fs::create_dir_all(&content_dir).unwrap();
999        fs::create_dir_all(&template_dir).unwrap();
1000        fs::create_dir_all(&site_dir).unwrap();
1001
1002        let m = manifest_with_sources(vec![
1003            "bogus/thing".to_string(),
1004            "content/missing.md".to_string(),
1005            "templates/missing.html".to_string(),
1006        ]);
1007        copy_sources(&content_dir, &template_dir, &site_dir, &m).unwrap();
1008        // Nothing staged: all sources skipped.
1009        let staged = site_dir.join(CONTENT_RELATIVE_DIR);
1010        assert_eq!(fs::read_dir(staged).unwrap().count(), 0);
1011    }
1012
1013    #[test]
1014    fn copy_sources_fails_when_dst_parent_is_a_file() {
1015        // `.ssg/content/content` exists as a file, so create_dir_all
1016        // for the destination parent fails.
1017        let dir = tempdir().unwrap();
1018        let content_dir = dir.path().join("content");
1019        let template_dir = dir.path().join("templates");
1020        let site_dir = dir.path().join("public");
1021        fs::create_dir_all(&content_dir).unwrap();
1022        fs::create_dir_all(&template_dir).unwrap();
1023        fs::write(content_dir.join("a.md"), "# a").unwrap();
1024        let content_out = site_dir.join(CONTENT_RELATIVE_DIR);
1025        fs::create_dir_all(&content_out).unwrap();
1026        fs::write(content_out.join("content"), "not a dir").unwrap();
1027
1028        let m = manifest_with_sources(vec!["content/a.md".to_string()]);
1029        let err = copy_sources(&content_dir, &template_dir, &site_dir, &m)
1030            .unwrap_err();
1031        assert!(!format!("{err}").is_empty());
1032    }
1033
1034    #[test]
1035    fn copy_sources_fails_when_dst_path_is_a_dir() {
1036        // The destination path itself is a directory, so fs::copy
1037        // fails after the parent create_dir_all succeeded.
1038        let dir = tempdir().unwrap();
1039        let content_dir = dir.path().join("content");
1040        let template_dir = dir.path().join("templates");
1041        let site_dir = dir.path().join("public");
1042        fs::create_dir_all(&content_dir).unwrap();
1043        fs::create_dir_all(&template_dir).unwrap();
1044        fs::write(content_dir.join("a.md"), "# a").unwrap();
1045        let dst = site_dir.join(CONTENT_RELATIVE_DIR).join("content/a.md");
1046        fs::create_dir_all(&dst).unwrap();
1047
1048        let m = manifest_with_sources(vec!["content/a.md".to_string()]);
1049        let err = copy_sources(&content_dir, &template_dir, &site_dir, &m)
1050            .unwrap_err();
1051        assert!(!format!("{err}").is_empty());
1052    }
1053
1054    #[test]
1055    fn extract_isr_cache_ignores_indented_line_without_colon() {
1056        let text = "---\nisr:\n  nocolonhere\n  s_maxage: 3\n---\n";
1057        let p = extract_isr_cache(text).unwrap();
1058        assert_eq!(p.s_maxage, 3);
1059    }
1060
1061    #[test]
1062    fn extract_isr_cache_blank_line_keeps_isr_block_open() {
1063        // An empty line inside the isr block is neither indented nor
1064        // non-empty, so the block stays open.
1065        let text = "---\nisr:\n\n  s_maxage: 4\n---\n";
1066        let p = extract_isr_cache(text).unwrap();
1067        assert_eq!(p.s_maxage, 4);
1068    }
1069
1070    #[test]
1071    fn extract_frontmatter_block_unclosed_yaml_returns_none() {
1072        assert!(extract_frontmatter_block("---\nno closing fence").is_none());
1073    }
1074
1075    #[test]
1076    fn extract_frontmatter_block_unclosed_toml_returns_none() {
1077        assert!(extract_frontmatter_block("+++\nno closing fence").is_none());
1078    }
1079}