use std::fs;
use std::path::{Path, PathBuf};
use ssg_core::{build_entry, CachePolicy, Manifest, ManifestEntry};
use crate::error::SsgError;
use crate::plugin::{Plugin, PluginContext};
pub const MANIFEST_RELATIVE_PATH: &str = ".ssg/manifest.json";
pub const CONTENT_RELATIVE_DIR: &str = ".ssg/content";
#[derive(Debug, Clone, Copy)]
pub struct IsrManifestPlugin;
impl IsrManifestPlugin {
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl Default for IsrManifestPlugin {
fn default() -> Self {
Self::new()
}
}
impl Plugin for IsrManifestPlugin {
fn name(&self) -> &'static str {
"isr-manifest"
}
fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
if ctx.dry_run {
return Ok(());
}
let manifest =
build_manifest(&ctx.content_dir, &ctx.template_dir, &ctx.site_dir)?;
write_manifest(&manifest, &ctx.site_dir)?;
copy_sources(
&ctx.content_dir,
&ctx.template_dir,
&ctx.site_dir,
&manifest,
)?;
Ok(())
}
}
pub fn build_manifest(
content_dir: &Path,
template_dir: &Path,
site_dir: &Path,
) -> Result<Manifest, SsgError> {
let mut manifest = Manifest::new(build_stamp());
let md_files = collect_md_files(content_dir)?;
for md_path in md_files {
let entry = build_entry_for_markdown(
&md_path,
content_dir,
template_dir,
site_dir,
)?;
let Some((url, entry)) = entry else { continue };
manifest.insert(url, entry);
}
Ok(manifest)
}
fn build_stamp() -> String {
let version = env!("CARGO_PKG_VERSION");
format!("ssg-{version}")
}
fn collect_md_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
let mut out = Vec::new();
if !dir.exists() {
return Ok(out);
}
visit(dir, &mut out)?;
out.sort();
Ok(out)
}
fn visit(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), SsgError> {
let entries = fs::read_dir(dir).map_err(|e| SsgError::Io {
path: dir.to_path_buf(),
source: e,
})?;
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.') {
continue;
}
if path.is_dir() {
visit(&path, out)?;
} else if path.extension().is_some_and(|e| e == "md") {
out.push(path);
}
}
Ok(())
}
fn build_entry_for_markdown(
md_path: &Path,
content_dir: &Path,
template_dir: &Path,
_site_dir: &Path,
) -> Result<Option<(String, ManifestEntry)>, SsgError> {
let bytes = fs::read(md_path).map_err(|e| SsgError::Io {
path: md_path.to_path_buf(),
source: e,
})?;
let rel = md_path
.strip_prefix(content_dir)
.unwrap_or(md_path)
.to_string_lossy()
.replace('\\', "/");
let url = derive_url(&rel);
let text = String::from_utf8_lossy(&bytes);
let cache = extract_isr_cache(&text);
let templates = collect_templates(template_dir);
let mut template_bytes_owned: Vec<Vec<u8>> =
Vec::with_capacity(templates.len());
let mut sources: Vec<String> = Vec::with_capacity(1 + templates.len());
sources.push(format!("content/{rel}"));
for (tpl_rel, tpl_path) in &templates {
let tb = fs::read(tpl_path).map_err(|e| SsgError::Io {
path: tpl_path.clone(),
source: e,
})?;
template_bytes_owned.push(tb);
sources.push(format!("templates/{tpl_rel}"));
}
let mut byte_refs: Vec<&[u8]> = Vec::with_capacity(sources.len());
byte_refs.push(&bytes);
for tb in &template_bytes_owned {
byte_refs.push(tb);
}
let entry = build_entry(sources, &byte_refs, cache);
Ok(Some((url, entry)))
}
fn collect_templates(template_dir: &Path) -> Vec<(String, PathBuf)> {
let mut out = Vec::new();
if !template_dir.exists() {
return out;
}
let candidates = ["index.html", "page.html"];
for name in candidates {
let p = template_dir.join(name);
if p.exists() {
out.push((name.to_string(), p));
}
}
out
}
fn derive_url(rel: &str) -> String {
let stripped = rel.strip_suffix(".md").unwrap_or(rel);
if stripped == "index" {
return "/index.html".to_string();
}
if let Some(trim) = stripped.strip_suffix("/index") {
return format!("/{trim}/index.html");
}
format!("/{stripped}/index.html")
}
fn extract_isr_cache(text: &str) -> Option<CachePolicy> {
let fm = extract_frontmatter_block(text)?;
let mut in_isr = false;
let mut s_maxage: Option<u32> = None;
let mut swr: Option<u32> = None;
for raw_line in fm.lines() {
let line = raw_line.trim_end();
if line.starts_with("isr:") {
in_isr = true;
continue;
}
if in_isr {
let trimmed = line.trim_start();
if line.starts_with(' ') || line.starts_with('\t') {
if let Some((k, v)) = trimmed.split_once(':') {
let k = k.trim();
let v = v.trim();
match k {
"s_maxage" | "s-maxage" => {
s_maxage = v.parse::<u32>().ok();
}
"swr" | "stale-while-revalidate" => {
swr = v.parse::<u32>().ok();
}
_ => {}
}
}
} else if !line.is_empty() {
in_isr = false;
}
}
}
if s_maxage.is_none() && swr.is_none() {
return None;
}
Some(CachePolicy {
s_maxage: s_maxage.unwrap_or(ssg_core::DEFAULT_S_MAXAGE),
swr: swr.unwrap_or(ssg_core::DEFAULT_SWR),
})
}
fn extract_frontmatter_block(text: &str) -> Option<&str> {
let trimmed = text.trim_start();
if let Some(after) = trimmed.strip_prefix("---") {
if let Some(end) = after.find("---") {
return Some(&after[..end]);
}
}
if let Some(after) = trimmed.strip_prefix("+++") {
if let Some(end) = after.find("+++") {
return Some(&after[..end]);
}
}
None
}
fn write_manifest(
manifest: &Manifest,
site_dir: &Path,
) -> Result<(), SsgError> {
let manifest_path = site_dir.join(MANIFEST_RELATIVE_PATH);
if let Some(parent) = manifest_path.parent() {
fs::create_dir_all(parent).map_err(|e| SsgError::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
let json = manifest.to_pretty_json().map_err(|e| SsgError::Io {
path: manifest_path.clone(),
source: std::io::Error::other(e),
})?;
fs::write(&manifest_path, json).map_err(|e| SsgError::Io {
path: manifest_path.clone(),
source: e,
})?;
Ok(())
}
fn copy_sources(
content_dir: &Path,
template_dir: &Path,
site_dir: &Path,
manifest: &Manifest,
) -> Result<(), SsgError> {
let content_out = site_dir.join(CONTENT_RELATIVE_DIR);
fs::create_dir_all(&content_out).map_err(|e| SsgError::Io {
path: content_out.clone(),
source: e,
})?;
let mut all_sources = std::collections::BTreeSet::new();
for entry in manifest.entries.values() {
for s in &entry.sources {
let _ = all_sources.insert(s.clone());
}
}
for source in all_sources {
let src_path = if let Some(rel) = source.strip_prefix("content/") {
content_dir.join(rel)
} else if let Some(rel) = source.strip_prefix("templates/") {
template_dir.join(rel)
} else {
continue;
};
if !src_path.exists() {
continue;
}
let dst_path = content_out.join(&source);
if let Some(parent) = dst_path.parent() {
fs::create_dir_all(parent).map_err(|e| SsgError::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
let _bytes_copied =
fs::copy(&src_path, &dst_path).map_err(|e| SsgError::Io {
path: dst_path.clone(),
source: e,
})?;
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn derive_url_index() {
assert_eq!(derive_url("index.md"), "/index.html");
}
#[test]
fn derive_url_post() {
assert_eq!(derive_url("posts/foo.md"), "/posts/foo/index.html");
}
#[test]
fn derive_url_already_index() {
assert_eq!(derive_url("about/index.md"), "/about/index.html");
}
#[test]
fn derive_url_nested() {
assert_eq!(derive_url("a/b/c.md"), "/a/b/c/index.html");
}
#[test]
fn derive_url_without_md_suffix_uses_input_verbatim() {
assert_eq!(derive_url("notes"), "/notes/index.html");
}
#[test]
fn extract_isr_cache_yaml() {
let text =
"---\ntitle: Foo\nisr:\n s_maxage: 600\n swr: 3600\n---\n# Body";
let c = extract_isr_cache(text).unwrap();
assert_eq!(c.s_maxage, 600);
assert_eq!(c.swr, 3600);
}
#[test]
fn extract_isr_cache_only_s_maxage() {
let text = "---\nisr:\n s_maxage: 30\n---\n";
let c = extract_isr_cache(text).unwrap();
assert_eq!(c.s_maxage, 30);
assert_eq!(c.swr, ssg_core::DEFAULT_SWR);
}
#[test]
fn extract_isr_cache_only_swr() {
let text = "---\nisr:\n swr: 7200\n---\n";
let c = extract_isr_cache(text).unwrap();
assert_eq!(c.s_maxage, ssg_core::DEFAULT_S_MAXAGE);
assert_eq!(c.swr, 7200);
}
#[test]
fn extract_isr_cache_none() {
let text = "---\ntitle: Foo\n---\n";
assert!(extract_isr_cache(text).is_none());
}
#[test]
fn extract_isr_cache_no_frontmatter() {
assert!(extract_isr_cache("# Hello").is_none());
}
#[test]
fn collect_md_files_recursive_and_sorted() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("b.md"), "b").unwrap();
fs::create_dir_all(dir.path().join("sub")).unwrap();
fs::write(dir.path().join("sub/a.md"), "a").unwrap();
fs::write(dir.path().join("ignore.txt"), "no").unwrap();
let files = collect_md_files(dir.path()).unwrap();
assert_eq!(files.len(), 2);
assert!(files[0].ends_with("b.md"));
assert!(files[1].ends_with("sub/a.md"));
}
#[test]
fn collect_md_files_skips_hidden_dirs() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join(".hidden")).unwrap();
fs::write(dir.path().join(".hidden/a.md"), "x").unwrap();
fs::write(dir.path().join("real.md"), "y").unwrap();
let files = collect_md_files(dir.path()).unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("real.md"));
}
#[test]
fn build_manifest_emits_entries() {
let dir = tempdir().unwrap();
let content_dir = dir.path().join("content");
let template_dir = dir.path().join("templates");
let site_dir = dir.path().join("public");
fs::create_dir_all(&content_dir).unwrap();
fs::create_dir_all(&template_dir).unwrap();
fs::create_dir_all(&site_dir).unwrap();
fs::write(content_dir.join("index.md"), "# Home").unwrap();
fs::write(
content_dir.join("post.md"),
"---\nisr:\n s_maxage: 30\n---\n# Post",
)
.unwrap();
fs::write(template_dir.join("index.html"), "<html/>").unwrap();
fs::write(template_dir.join("page.html"), "<page/>").unwrap();
let m = build_manifest(&content_dir, &template_dir, &site_dir).unwrap();
assert_eq!(m.len(), 2);
assert!(m.get("/index.html").is_some());
let post = m.get("/post/index.html").unwrap();
assert_eq!(post.cache.as_ref().unwrap().s_maxage, 30);
assert_eq!(post.sources[0], "content/post.md");
assert!(post.sources.iter().any(|s| s == "templates/index.html"));
assert!(post.sources.iter().any(|s| s == "templates/page.html"));
assert_eq!(post.hash.len(), 64);
}
#[test]
fn build_entry_for_markdown_falls_back_to_full_path_outside_content_dir() {
let dir = tempdir().unwrap();
let elsewhere = dir.path().join("elsewhere");
fs::create_dir_all(&elsewhere).unwrap();
let md_path = elsewhere.join("orphan.md");
fs::write(&md_path, "# Orphan").unwrap();
let content_dir = dir.path().join("content");
let template_dir = dir.path().join("templates");
let site_dir = dir.path().join("site");
fs::create_dir_all(&content_dir).unwrap();
fs::create_dir_all(&template_dir).unwrap();
fs::create_dir_all(&site_dir).unwrap();
let (url, entry) = build_entry_for_markdown(
&md_path,
&content_dir,
&template_dir,
&site_dir,
)
.unwrap()
.expect("entry is always produced");
assert!(
entry.sources[0].contains("orphan.md"),
"source should reference the full path: {:?}",
entry.sources
);
assert!(url.ends_with("/index.html"));
}
#[test]
fn write_manifest_creates_parent_dirs() {
let dir = tempdir().unwrap();
let m = Manifest::default();
write_manifest(&m, dir.path()).unwrap();
let p = dir.path().join(MANIFEST_RELATIVE_PATH);
assert!(p.exists());
let parsed: Manifest =
serde_json::from_str(&fs::read_to_string(&p).unwrap()).unwrap();
assert_eq!(parsed, m);
}
#[test]
fn plugin_after_compile_writes_manifest_and_copies_sources() {
let dir = tempdir().unwrap();
let content_dir = dir.path().join("content");
let template_dir = dir.path().join("templates");
let site_dir = dir.path().join("public");
fs::create_dir_all(&content_dir).unwrap();
fs::create_dir_all(&template_dir).unwrap();
fs::create_dir_all(&site_dir).unwrap();
fs::write(content_dir.join("a.md"), "# A").unwrap();
fs::write(template_dir.join("index.html"), "<x/>").unwrap();
let ctx = PluginContext {
content_dir: content_dir.clone(),
build_dir: site_dir.clone(),
site_dir: site_dir.clone(),
template_dir: template_dir.clone(),
config: None,
cache: None,
memory_budget: None,
html_files: None,
dep_graph: None,
dry_run: false,
};
IsrManifestPlugin.after_compile(&ctx).unwrap();
let manifest_path = site_dir.join(MANIFEST_RELATIVE_PATH);
assert!(manifest_path.exists());
let content_dst =
site_dir.join(CONTENT_RELATIVE_DIR).join("content/a.md");
assert!(content_dst.exists(), "raw markdown should be staged");
let template_dst = site_dir
.join(CONTENT_RELATIVE_DIR)
.join("templates/index.html");
assert!(template_dst.exists(), "template should be staged");
}
#[test]
fn plugin_after_compile_dry_run_writes_nothing() {
let dir = tempdir().unwrap();
let content_dir = dir.path().join("content");
let template_dir = dir.path().join("templates");
let site_dir = dir.path().join("public");
fs::create_dir_all(&content_dir).unwrap();
fs::create_dir_all(&template_dir).unwrap();
fs::create_dir_all(&site_dir).unwrap();
fs::write(content_dir.join("a.md"), "x").unwrap();
let ctx = PluginContext {
content_dir,
build_dir: site_dir.clone(),
site_dir: site_dir.clone(),
template_dir,
config: None,
cache: None,
memory_budget: None,
html_files: None,
dep_graph: None,
dry_run: true,
};
IsrManifestPlugin.after_compile(&ctx).unwrap();
assert!(!site_dir.join(MANIFEST_RELATIVE_PATH).exists());
}
#[test]
fn plugin_name() {
assert_eq!(IsrManifestPlugin.name(), "isr-manifest");
}
#[test]
fn plugin_default_constructs() {
let _p = <IsrManifestPlugin as Default>::default();
}
#[test]
fn plugin_after_compile_full_run_writes_manifest_and_copies_sources() {
let dir = tempdir().unwrap();
let content_dir = dir.path().join("content");
let template_dir = dir.path().join("templates");
let site_dir = dir.path().join("public");
fs::create_dir_all(&content_dir).unwrap();
fs::create_dir_all(&template_dir).unwrap();
fs::create_dir_all(&site_dir).unwrap();
fs::write(content_dir.join("hello.md"), "hello world").unwrap();
fs::write(template_dir.join("index.html"), "<html/>").unwrap();
let ctx = PluginContext {
content_dir: content_dir.clone(),
build_dir: site_dir.clone(),
site_dir: site_dir.clone(),
template_dir,
config: None,
cache: None,
memory_budget: None,
html_files: None,
dep_graph: None,
dry_run: false,
};
IsrManifestPlugin.after_compile(&ctx).unwrap();
assert!(site_dir.join(MANIFEST_RELATIVE_PATH).exists());
}
#[test]
fn collect_md_files_nonexistent_dir_returns_empty() {
let out = collect_md_files(Path::new("/nonexistent/xxx")).unwrap();
assert!(out.is_empty());
}
#[test]
fn collect_md_files_skips_hidden_dirs_v2() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join(".hidden")).unwrap();
fs::write(dir.path().join(".hidden/secret.md"), "x").unwrap();
fs::write(dir.path().join("visible.md"), "x").unwrap();
let out = collect_md_files(dir.path()).unwrap();
assert_eq!(out.len(), 1);
assert!(out[0].file_name().unwrap() == "visible.md");
}
#[test]
fn collect_md_files_walks_nested_v2() {
let dir = tempdir().unwrap();
let sub = dir.path().join("a/b/c");
fs::create_dir_all(&sub).unwrap();
fs::write(sub.join("deep.md"), "x").unwrap();
fs::write(dir.path().join("shallow.md"), "x").unwrap();
let out = collect_md_files(dir.path()).unwrap();
assert_eq!(out.len(), 2);
}
#[test]
fn collect_templates_nonexistent_dir_returns_empty() {
let out = collect_templates(Path::new("/nonexistent/yyy"));
assert!(out.is_empty());
}
#[test]
fn extract_isr_cache_yaml_both_keys() {
let text = "---\nisr:\n s_maxage: 600\n swr: 3600\n---\n";
let p = extract_isr_cache(text).unwrap();
assert_eq!(p.s_maxage, 600);
assert_eq!(p.swr, 3600);
}
#[test]
fn extract_isr_cache_yaml_dash_variants() {
let text =
"---\nisr:\n s-maxage: 42\n stale-while-revalidate: 99\n---\n";
let p = extract_isr_cache(text).unwrap();
assert_eq!(p.s_maxage, 42);
assert_eq!(p.swr, 99);
}
#[test]
fn extract_isr_cache_ignores_unknown_keys_in_isr_block() {
let text = "---\nisr:\n unknown_key: 5\n s_maxage: 7\n---\n";
let p = extract_isr_cache(text).unwrap();
assert_eq!(p.s_maxage, 7);
}
#[test]
fn extract_isr_cache_isr_block_exits_on_non_indented_line() {
let text = "---\nisr:\n s_maxage: 5\ntitle: Hi\nswr: 8\n---\n";
let p = extract_isr_cache(text).unwrap();
assert_eq!(p.s_maxage, 5);
assert_eq!(p.swr, ssg_core::DEFAULT_SWR);
}
#[test]
fn extract_isr_cache_no_frontmatter_returns_none() {
assert!(extract_isr_cache("just body text").is_none());
}
#[test]
fn extract_isr_cache_no_isr_block_returns_none() {
let text = "---\ntitle: Hi\n---\n";
assert!(extract_isr_cache(text).is_none());
}
#[test]
fn extract_frontmatter_block_toml_fences() {
let text = "+++\ntitle = \"X\"\n+++\nbody";
let body = extract_frontmatter_block(text).unwrap();
assert!(body.contains("title"));
}
#[test]
fn derive_url_index_md_maps_to_root_index_html() {
assert_eq!(derive_url("index.md"), "/index.html");
}
#[test]
fn derive_url_nested_index_maps_to_dir_slash_index_html() {
assert_eq!(derive_url("about/index.md"), "/about/index.html");
}
#[test]
fn derive_url_regular_md_maps_to_dir_slash_index_html() {
assert_eq!(derive_url("posts/hello.md"), "/posts/hello/index.html");
}
fn make_ctx(
content_dir: &Path,
template_dir: &Path,
site_dir: &Path,
) -> PluginContext {
PluginContext {
content_dir: content_dir.to_path_buf(),
build_dir: site_dir.to_path_buf(),
site_dir: site_dir.to_path_buf(),
template_dir: template_dir.to_path_buf(),
config: None,
cache: None,
memory_budget: None,
html_files: None,
dep_graph: None,
dry_run: false,
}
}
#[cfg(unix)]
fn deny_access(p: &Path) {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(p, fs::Permissions::from_mode(0o000)).unwrap();
}
#[cfg(unix)]
fn restore_access(p: &Path) {
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(p, fs::Permissions::from_mode(0o755));
}
#[test]
#[cfg(unix)]
fn after_compile_propagates_unreadable_subdir_error() {
let dir = tempdir().unwrap();
let content_dir = dir.path().join("content");
let template_dir = dir.path().join("templates");
let site_dir = dir.path().join("public");
fs::create_dir_all(content_dir.join("locked")).unwrap();
fs::create_dir_all(&template_dir).unwrap();
fs::create_dir_all(&site_dir).unwrap();
deny_access(&content_dir.join("locked"));
let ctx = make_ctx(&content_dir, &template_dir, &site_dir);
let res = IsrManifestPlugin.after_compile(&ctx);
restore_access(&content_dir.join("locked"));
if let Err(e) = res {
assert!(!format!("{e}").is_empty());
}
}
#[test]
#[cfg(unix)]
fn build_manifest_propagates_unreadable_md_error() {
let dir = tempdir().unwrap();
let content_dir = dir.path().join("content");
let template_dir = dir.path().join("templates");
let site_dir = dir.path().join("public");
fs::create_dir_all(&content_dir).unwrap();
fs::create_dir_all(&template_dir).unwrap();
fs::create_dir_all(&site_dir).unwrap();
let md = content_dir.join("locked.md");
fs::write(&md, "# locked").unwrap();
deny_access(&md);
let res = build_manifest(&content_dir, &template_dir, &site_dir);
restore_access(&md);
if let Err(e) = res {
assert!(!format!("{e}").is_empty());
}
}
#[test]
#[cfg(unix)]
fn build_manifest_propagates_unreadable_template_error() {
let dir = tempdir().unwrap();
let content_dir = dir.path().join("content");
let template_dir = dir.path().join("templates");
let site_dir = dir.path().join("public");
fs::create_dir_all(&content_dir).unwrap();
fs::create_dir_all(&template_dir).unwrap();
fs::create_dir_all(&site_dir).unwrap();
fs::write(content_dir.join("a.md"), "# a").unwrap();
let tpl = template_dir.join("index.html");
fs::write(&tpl, "<html/>").unwrap();
deny_access(&tpl);
let res = build_manifest(&content_dir, &template_dir, &site_dir);
restore_access(&tpl);
if let Err(e) = res {
assert!(!format!("{e}").is_empty());
}
}
#[test]
fn after_compile_fails_when_ssg_dir_is_a_file() {
let dir = tempdir().unwrap();
let content_dir = dir.path().join("content");
let template_dir = dir.path().join("templates");
let site_dir = dir.path().join("public");
fs::create_dir_all(&content_dir).unwrap();
fs::create_dir_all(&template_dir).unwrap();
fs::create_dir_all(&site_dir).unwrap();
fs::write(site_dir.join(".ssg"), "not a dir").unwrap();
let ctx = make_ctx(&content_dir, &template_dir, &site_dir);
let err = IsrManifestPlugin.after_compile(&ctx).unwrap_err();
assert!(!format!("{err}").is_empty());
}
#[test]
fn write_manifest_fails_when_manifest_path_is_a_dir() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join(MANIFEST_RELATIVE_PATH)).unwrap();
let err = write_manifest(&Manifest::default(), dir.path()).unwrap_err();
assert!(!format!("{err}").is_empty());
}
#[test]
fn after_compile_fails_when_content_out_is_a_file() {
let dir = tempdir().unwrap();
let content_dir = dir.path().join("content");
let template_dir = dir.path().join("templates");
let site_dir = dir.path().join("public");
fs::create_dir_all(&content_dir).unwrap();
fs::create_dir_all(&template_dir).unwrap();
fs::create_dir_all(site_dir.join(".ssg")).unwrap();
fs::write(site_dir.join(CONTENT_RELATIVE_DIR), "not a dir").unwrap();
let ctx = make_ctx(&content_dir, &template_dir, &site_dir);
let err = IsrManifestPlugin.after_compile(&ctx).unwrap_err();
assert!(!format!("{err}").is_empty());
}
fn manifest_with_sources(sources: Vec<String>) -> Manifest {
let byte_refs: Vec<&[u8]> = vec![b"x"; sources.len()];
let entry = build_entry(sources, &byte_refs, None);
let mut m = Manifest::new(build_stamp());
m.insert("/index.html".to_string(), entry);
m
}
#[test]
fn copy_sources_skips_unknown_prefix_and_missing_files() {
let dir = tempdir().unwrap();
let content_dir = dir.path().join("content");
let template_dir = dir.path().join("templates");
let site_dir = dir.path().join("public");
fs::create_dir_all(&content_dir).unwrap();
fs::create_dir_all(&template_dir).unwrap();
fs::create_dir_all(&site_dir).unwrap();
let m = manifest_with_sources(vec![
"bogus/thing".to_string(),
"content/missing.md".to_string(),
"templates/missing.html".to_string(),
]);
copy_sources(&content_dir, &template_dir, &site_dir, &m).unwrap();
let staged = site_dir.join(CONTENT_RELATIVE_DIR);
assert_eq!(fs::read_dir(staged).unwrap().count(), 0);
}
#[test]
fn copy_sources_fails_when_dst_parent_is_a_file() {
let dir = tempdir().unwrap();
let content_dir = dir.path().join("content");
let template_dir = dir.path().join("templates");
let site_dir = dir.path().join("public");
fs::create_dir_all(&content_dir).unwrap();
fs::create_dir_all(&template_dir).unwrap();
fs::write(content_dir.join("a.md"), "# a").unwrap();
let content_out = site_dir.join(CONTENT_RELATIVE_DIR);
fs::create_dir_all(&content_out).unwrap();
fs::write(content_out.join("content"), "not a dir").unwrap();
let m = manifest_with_sources(vec!["content/a.md".to_string()]);
let err = copy_sources(&content_dir, &template_dir, &site_dir, &m)
.unwrap_err();
assert!(!format!("{err}").is_empty());
}
#[test]
fn copy_sources_fails_when_dst_path_is_a_dir() {
let dir = tempdir().unwrap();
let content_dir = dir.path().join("content");
let template_dir = dir.path().join("templates");
let site_dir = dir.path().join("public");
fs::create_dir_all(&content_dir).unwrap();
fs::create_dir_all(&template_dir).unwrap();
fs::write(content_dir.join("a.md"), "# a").unwrap();
let dst = site_dir.join(CONTENT_RELATIVE_DIR).join("content/a.md");
fs::create_dir_all(&dst).unwrap();
let m = manifest_with_sources(vec!["content/a.md".to_string()]);
let err = copy_sources(&content_dir, &template_dir, &site_dir, &m)
.unwrap_err();
assert!(!format!("{err}").is_empty());
}
#[test]
fn extract_isr_cache_ignores_indented_line_without_colon() {
let text = "---\nisr:\n nocolonhere\n s_maxage: 3\n---\n";
let p = extract_isr_cache(text).unwrap();
assert_eq!(p.s_maxage, 3);
}
#[test]
fn extract_isr_cache_blank_line_keeps_isr_block_open() {
let text = "---\nisr:\n\n s_maxage: 4\n---\n";
let p = extract_isr_cache(text).unwrap();
assert_eq!(p.s_maxage, 4);
}
#[test]
fn extract_frontmatter_block_unclosed_yaml_returns_none() {
assert!(extract_frontmatter_block("---\nno closing fence").is_none());
}
#[test]
fn extract_frontmatter_block_unclosed_toml_returns_none() {
assert!(extract_frontmatter_block("+++\nno closing fence").is_none());
}
}