use std::path::{Path, PathBuf};
use crate::error::SsgError;
use staticdatagen::compile;
use crate::cmd::SsgConfig;
use crate::{
accessibility, ai, assets, content, csp, deploy, drafts, highlight, i18n,
islands, livereload, pagination, plugin, plugins as plugins_mod,
postprocess, search, seo, shortcodes, streaming, taxonomy, walk,
};
#[derive(Debug, Clone, serde::Serialize)]
#[allow(dead_code)]
pub struct BuildError {
pub file: Option<String>,
pub line: Option<usize>,
pub message: String,
}
impl BuildError {
#[must_use]
#[allow(dead_code)]
pub fn from_error(err: &SsgError) -> Self {
let message = format!("{err:#}");
let file = extract_file_from_error(&message);
Self {
file,
line: None,
message,
}
}
#[must_use]
#[allow(dead_code)]
pub fn to_ws_message(&self) -> String {
serde_json::json!({
"type": "error",
"file": self.file,
"line": self.line,
"message": self.message,
})
.to_string()
}
}
#[must_use]
#[allow(dead_code)]
pub fn clear_error_message() -> String {
r#"{"type":"clear-error"}"#.to_string()
}
#[allow(dead_code)]
fn extract_file_from_error(msg: &str) -> Option<String> {
for word in msg.split_whitespace() {
let trimmed = word.trim_matches(|c: char| {
!c.is_alphanumeric() && c != '/' && c != '.' && c != '_' && c != '-'
});
if trimmed.contains('/')
&& (trimmed.ends_with(".md")
|| trimmed.ends_with(".html")
|| trimmed.ends_with(".toml")
|| trimmed.ends_with(".yml")
|| trimmed.ends_with(".yaml"))
{
return Some(trimmed.to_string());
}
}
None
}
#[derive(Debug, Clone, Default)]
#[allow(clippy::struct_excessive_bools)]
pub struct RunOptions {
pub quiet: bool,
pub include_drafts: bool,
pub deploy_target: Option<String>,
pub validate_only: bool,
pub jobs: Option<usize>,
pub max_memory_mb: Option<usize>,
#[allow(dead_code)]
pub ai_fix: bool,
#[allow(dead_code)]
pub ai_fix_dry_run: bool,
pub incremental: bool,
pub no_llm_cache: bool,
pub isr: bool,
}
impl RunOptions {
pub fn from_matches(matches: &clap::ArgMatches) -> Self {
Self {
quiet: matches.get_flag("quiet"),
include_drafts: matches.get_flag("drafts"),
deploy_target: matches.get_one::<String>("deploy").cloned(),
validate_only: matches.get_flag("validate"),
jobs: matches.get_one::<usize>("jobs").copied(),
max_memory_mb: matches.get_one::<usize>("max-memory").copied(),
ai_fix: matches.get_flag("ai-fix"),
ai_fix_dry_run: matches.get_flag("ai-fix-dry-run"),
incremental: matches
.try_contains_id("incremental")
.unwrap_or(false)
&& matches.get_flag("incremental"),
no_llm_cache: matches
.try_contains_id("no-llm-cache")
.unwrap_or(false)
&& matches.get_flag("no-llm-cache"),
isr: matches.try_contains_id("isr").unwrap_or(false)
&& matches.get_flag("isr"),
}
}
pub fn from_subcommand_matches(sub_m: &clap::ArgMatches) -> Self {
let opt_flag = |name: &str| -> bool {
sub_m.try_contains_id(name).unwrap_or(false) && sub_m.get_flag(name)
};
let opt_one = |name: &str| -> Option<usize> {
if sub_m.try_contains_id(name).unwrap_or(false) {
sub_m.get_one::<usize>(name).copied()
} else {
None
}
};
let opt_str = |name: &str| -> Option<String> {
if sub_m.try_contains_id(name).unwrap_or(false) {
sub_m.get_one::<String>(name).cloned()
} else {
None
}
};
Self {
quiet: opt_flag("quiet"),
include_drafts: opt_flag("drafts"),
deploy_target: opt_str("target"),
validate_only: false,
jobs: opt_one("jobs"),
max_memory_mb: opt_one("max-memory"),
ai_fix: false,
ai_fix_dry_run: false,
incremental: opt_flag("incremental"),
no_llm_cache: opt_flag("no-llm-cache"),
isr: opt_flag("isr"),
}
}
}
pub fn resolve_build_and_site_dirs(config: &SsgConfig) -> (PathBuf, PathBuf) {
let site_dir = config
.serve_dir
.clone()
.unwrap_or_else(|| config.output_dir.clone());
let build_dir = if site_dir == config.output_dir {
config.output_dir.with_extension("build-tmp")
} else {
config.output_dir.clone()
};
(build_dir, site_dir)
}
pub fn build_pipeline(
config: &SsgConfig,
opts: &RunOptions,
) -> (
plugin::PluginManager,
plugin::PluginContext,
PathBuf,
PathBuf,
) {
let (build_dir, site_dir) = resolve_build_and_site_dirs(config);
if opts.no_llm_cache {
std::env::set_var("SSG_NO_LLM_CACHE", "1");
}
let mut ctx = plugin::PluginContext::with_config(
&config.content_dir,
&build_dir,
&site_dir,
&config.template_dir,
config.clone(),
);
if let Some(mb) = opts.max_memory_mb {
ctx.memory_budget = Some(streaming::MemoryBudget::from_mb(mb));
}
let mut plugins = plugin::PluginManager::new();
register_default_plugins(
&mut plugins,
config,
opts.include_drafts,
opts.deploy_target.as_deref(),
);
if opts.isr {
register_isr_plugins(&mut plugins);
}
(plugins, ctx, build_dir, site_dir)
}
pub fn register_isr_plugins(plugins: &mut plugin::PluginManager) {
plugins.register(crate::isr_manifest::IsrManifestPlugin::new());
plugins.register(crate::rpc_schema::RpcSchemaPlugin::new());
}
#[cfg_attr(
feature = "otel",
tracing::instrument(skip(plugins, ctx), fields(
content_dir = %content_dir.display(),
site_dir = %site_dir.display(),
quiet,
))
)]
pub fn execute_build_pipeline(
plugins: &plugin::PluginManager,
ctx: &plugin::PluginContext,
build_dir: &Path,
content_dir: &Path,
site_dir: &Path,
template_dir: &Path,
quiet: bool,
) -> Result<(), SsgError> {
execute_build_pipeline_with(
plugins,
ctx,
build_dir,
content_dir,
site_dir,
template_dir,
quiet,
false,
)
}
#[cfg_attr(
feature = "otel",
tracing::instrument(skip(plugins, ctx), fields(
content_dir = %content_dir.display(),
site_dir = %site_dir.display(),
quiet,
incremental,
))
)]
pub fn execute_build_pipeline_with(
plugins: &plugin::PluginManager,
ctx: &plugin::PluginContext,
build_dir: &Path,
content_dir: &Path,
site_dir: &Path,
template_dir: &Path,
quiet: bool,
incremental: bool,
) -> Result<(), SsgError> {
let start = std::time::Instant::now();
let cache_root = depgraph_cache_root(site_dir);
let plugin_cache = plugin::PluginCache::load(site_dir);
let prev_graph = crate::depgraph::DepGraph::load(&cache_root);
let mut ctx = ctx.clone();
ctx.cache = Some(plugin_cache);
ctx.dep_graph = Some(prev_graph.clone());
if incremental {
let current =
crate::depgraph::current_hashes(content_dir, template_dir)?;
let diff = prev_graph.diff(¤t);
if diff.is_empty() && prev_graph.page_count() > 0 && site_dir.exists() {
let elapsed = start.elapsed();
if !quiet {
println!(
"Site cached ({} pages, no changes) in {:.2}ms",
prev_graph.page_count(),
elapsed.as_secs_f64() * 1000.0,
);
}
return Ok(());
}
if !diff.deleted.is_empty() {
let stale_outputs = prev_graph.invalidated_outputs(&diff.deleted);
for out in &stale_outputs {
let _ = std::fs::remove_file(out);
}
}
}
plugins.run_before_compile(&ctx)?;
let budget = ctx
.memory_budget
.unwrap_or_else(streaming::MemoryBudget::default_budget);
let explicitly_set = ctx.memory_budget.is_some();
if streaming::should_stream(content_dir, &budget, explicitly_set) {
let batches = streaming::batched_content_files(content_dir, &budget)?;
for (i, batch) in batches.iter().enumerate() {
streaming::compile_batch(
batch,
content_dir,
build_dir,
site_dir,
template_dir,
i,
)?;
}
} else {
let base_url = ctx.config.as_ref().map(|c| c.base_url.clone());
compile_site_with_base_url(
build_dir,
content_dir,
site_dir,
template_dir,
base_url.as_deref(),
)?;
}
ctx.cache_html_files();
plugins.run_after_compile(&ctx)?;
plugins.run_fused_transforms(&ctx)?;
let mut new_graph = crate::depgraph::DepGraph::new();
if let Err(e) = crate::depgraph::populate(
&mut new_graph,
content_dir,
template_dir,
site_dir,
) {
log::warn!("Failed to populate dependency graph: {e}");
}
if let Err(e) = new_graph.save(&cache_root) {
log::warn!("Failed to save dependency graph: {e}");
}
if let Some(ref mut cache) = ctx.cache {
if let Ok(files) = walk::walk_files(site_dir, "html") {
for file in &files {
cache.update(file);
}
}
if let Err(e) = cache.save(site_dir) {
log::warn!("Failed to save plugin cache: {e}");
}
}
let elapsed = start.elapsed();
if !quiet {
println!(
"Site built in {:.2}s ({} plugin(s))",
elapsed.as_secs_f64(),
plugins.len()
);
}
Ok(())
}
#[must_use]
pub fn depgraph_cache_root(site_dir: &Path) -> PathBuf {
let target = Path::new("target");
if target.is_dir() {
target.join(crate::depgraph::CACHE_DIRNAME)
} else {
site_dir.join(".ssg-cache")
}
}
pub fn compile_site(
build_dir: &Path,
content_dir: &Path,
site_dir: &Path,
template_dir: &Path,
) -> Result<(), SsgError> {
compile_site_with_base_url(
build_dir,
content_dir,
site_dir,
template_dir,
None,
)
}
pub fn compile_site_with_base_url(
build_dir: &Path,
content_dir: &Path,
site_dir: &Path,
template_dir: &Path,
base_url: Option<&str>,
) -> Result<(), SsgError> {
let template_vars =
crate::content_stager::collect_template_vars(template_dir)
.map_err(|e| SsgError::io(e, template_dir))?;
let staged_content =
crate::content_stager::stage_content_with_site_defaults(
content_dir,
build_dir,
&template_vars,
base_url,
)
.map_err(|e| SsgError::io(e, content_dir))?;
compile(build_dir, &staged_content, site_dir, template_dir).map_err(|e| {
eprintln!(" Error compiling site: {e:?}");
SsgError::io(
std::io::Error::other(format!("Failed to compile site: {e:?}")),
build_dir,
)
})
}
pub fn register_default_plugins(
plugins: &mut plugin::PluginManager,
config: &SsgConfig,
include_drafts: bool,
deploy_target: Option<&str>,
) {
let base_url = config.base_url.clone();
plugins.register(content::ContentValidationPlugin);
plugins.register(drafts::DraftPlugin::new(include_drafts));
plugins.register(shortcodes::ShortcodePlugin);
#[cfg(feature = "templates")]
plugins.register(
crate::template_plugin::TemplatePlugin::from_template_dir(
&config.template_dir,
),
);
plugins.register(postprocess::SitemapFixPlugin);
plugins.register(postprocess::NewsSitemapFixPlugin);
plugins.register(postprocess::RssAggregatePlugin);
plugins.register(postprocess::AtomFeedPlugin);
plugins.register(postprocess::JsonFeedPlugin);
plugins.register(postprocess::ManifestFixPlugin);
plugins.register(postprocess::HtmlFixPlugin);
plugins.register(postprocess::SbomPlugin);
plugins.register(postprocess::AgenticDiscoveryPlugin);
plugins.register(highlight::HighlightPlugin::default());
plugins.register(seo::SeoPlugin);
plugins
.register(seo::JsonLdPlugin::from_site(&base_url, &config.site_name));
plugins.register(seo::CanonicalPlugin::new(base_url.clone()));
plugins.register(seo::RobotsPlugin::new(base_url));
plugins.register(ai::AiPlugin);
plugins.register(crate::agent_api::AgentApiPlugin::default());
plugins.register(taxonomy::TaxonomyPlugin);
plugins.register(pagination::PaginationPlugin::default());
plugins.register(search::SearchPlugin);
plugins.register(accessibility::AccessibilityPlugin);
#[cfg(feature = "image-optimization")]
plugins.register(crate::image_plugin::ImageOptimizationPlugin::default());
if let Some(ref i18n_cfg) = config.i18n {
if i18n_cfg.locales.len() > 1 {
plugins.register(i18n::I18nPlugin::new(i18n_cfg.clone()));
}
}
plugins.register(islands::IslandPlugin);
if crate::view_transitions::ViewTransitionsPlugin::enabled(config) {
plugins.register(crate::view_transitions::ViewTransitionsPlugin::new());
}
plugins.register(csp::CspPlugin);
plugins.register(crate::sbom::SbomPlugin);
plugins.register(assets::FingerprintPlugin);
plugins.register(plugins_mod::MinifyPlugin);
plugins.register(postprocess::EdgeHeadersPlugin);
if let Some(target) = deploy_target {
let dt = match target {
"netlify" => Some(deploy::DeployTarget::Netlify),
"vercel" => Some(deploy::DeployTarget::Vercel),
"cloudflare" => Some(deploy::DeployTarget::CloudflarePages),
"github" => Some(deploy::DeployTarget::GithubPages),
_ => {
log::warn!("Unknown deploy target: {target}");
None
}
};
if let Some(dt) = dt {
plugins.register(deploy::DeployPlugin::new(dt));
}
}
plugins.register(livereload::LiveReloadPlugin::default());
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_build_error_serialization() {
let err = BuildError {
file: Some("content/post.md".to_string()),
line: Some(42),
message: "unexpected token".to_string(),
};
let json = err.to_ws_message();
let parsed: serde_json::Value =
serde_json::from_str(&json).expect("valid JSON");
assert_eq!(parsed["type"], "error");
assert_eq!(parsed["file"], "content/post.md");
assert_eq!(parsed["line"], 42);
assert_eq!(parsed["message"], "unexpected token");
}
#[test]
fn test_clear_error_message() {
let msg = clear_error_message();
let parsed: serde_json::Value =
serde_json::from_str(&msg).expect("valid JSON");
assert_eq!(parsed["type"], "clear-error");
}
#[test]
fn test_extract_file_from_error_md() {
let msg = "cannot read content/posts/hello.md: permission denied";
assert_eq!(
extract_file_from_error(msg),
Some("content/posts/hello.md".to_string())
);
}
#[test]
fn test_extract_file_from_error_html() {
let msg = "template error in templates/base.html";
assert_eq!(
extract_file_from_error(msg),
Some("templates/base.html".to_string())
);
}
#[test]
fn test_extract_file_from_error_toml() {
let msg = "parse error in config/site.toml at line 5";
assert_eq!(
extract_file_from_error(msg),
Some("config/site.toml".to_string())
);
}
#[test]
fn test_extract_file_from_error_none() {
let msg = "something went wrong with no file path";
assert_eq!(extract_file_from_error(msg), None);
}
#[test]
fn test_build_error_from_error() {
let err = SsgError::Io {
path: PathBuf::from("output/index.html"),
source: std::io::Error::other("disk full"),
};
let be = BuildError::from_error(&err);
assert_eq!(be.file, Some("output/index.html".to_string()));
assert!(be.line.is_none());
assert!(be.message.contains("disk full"));
}
#[test]
fn test_build_error_no_file_no_line() {
let err = BuildError {
file: None,
line: None,
message: "something broke".to_string(),
};
let json = err.to_ws_message();
let parsed: serde_json::Value =
serde_json::from_str(&json).expect("valid JSON");
assert_eq!(parsed["type"], "error");
assert!(parsed["file"].is_null());
assert!(parsed["line"].is_null());
assert_eq!(parsed["message"], "something broke");
}
#[test]
fn test_build_error_clone() {
let err = BuildError {
file: Some("a/b.md".to_string()),
line: Some(10),
message: "oops".to_string(),
};
let cloned = err.clone();
assert_eq!(cloned.file, err.file);
assert_eq!(cloned.line, err.line);
assert_eq!(cloned.message, err.message);
}
#[test]
fn test_build_error_debug() {
let err = BuildError {
file: None,
line: None,
message: "debug test".to_string(),
};
let debug = format!("{err:?}");
assert!(debug.contains("BuildError"));
assert!(debug.contains("debug test"));
}
#[test]
fn test_build_error_from_error_no_file() {
let err = SsgError::Core(ssg_core::Error::FrontmatterParse {
syntax: "generic error without any file path".to_string(),
});
let be = BuildError::from_error(&err);
assert!(be.file.is_none());
assert!(be.message.contains("generic error"));
}
#[test]
fn test_build_error_from_error_yml_extension() {
let err = SsgError::Io {
path: PathBuf::from("config/site.yml"),
source: std::io::Error::other("parse error"),
};
let be = BuildError::from_error(&err);
assert_eq!(be.file, Some("config/site.yml".to_string()));
}
#[test]
fn test_build_error_from_error_yaml_extension() {
let err = SsgError::Io {
path: PathBuf::from("data/settings.yaml"),
source: std::io::Error::other("error at line 3"),
};
let be = BuildError::from_error(&err);
assert_eq!(be.file, Some("data/settings.yaml".to_string()));
}
#[test]
fn test_extract_file_with_punctuation_around_path() {
let msg = "error: 'templates/base.html' not found";
let result = extract_file_from_error(msg);
assert_eq!(result, Some("templates/base.html".to_string()));
}
#[test]
fn test_extract_file_no_slash_in_word() {
let msg = "file not found: base.html";
let result = extract_file_from_error(msg);
assert!(result.is_none(), "no slash means no file path extraction");
}
#[test]
fn test_extract_file_multiple_paths_returns_first() {
let msg = "failed to read src/a.md and src/b.html";
let result = extract_file_from_error(msg);
assert_eq!(result, Some("src/a.md".to_string()));
}
#[test]
fn test_extract_file_toml_with_trailing_colon() {
let msg = "invalid key in config/site.toml: 'foo'";
let result = extract_file_from_error(msg);
assert_eq!(result, Some("config/site.toml".to_string()));
}
#[test]
fn test_clear_error_message_is_valid_json() {
let msg = clear_error_message();
let parsed: serde_json::Value =
serde_json::from_str(&msg).expect("valid JSON");
assert_eq!(parsed["type"], "clear-error");
assert_eq!(parsed.as_object().unwrap().len(), 1);
}
#[test]
fn test_resolve_dirs_no_serve_dir() {
use crate::cmd::SsgConfig;
use std::path::PathBuf;
let mut config = SsgConfig::default();
config.output_dir = PathBuf::from("out");
config.serve_dir = None;
let (build, site) = resolve_build_and_site_dirs(&config);
assert_eq!(site, PathBuf::from("out"));
assert_ne!(build, site);
}
#[test]
fn test_resolve_dirs_serve_differs_from_output() {
use crate::cmd::SsgConfig;
use std::path::PathBuf;
let mut config = SsgConfig::default();
config.output_dir = PathBuf::from("build");
config.serve_dir = Some(PathBuf::from("public"));
let (build, site) = resolve_build_and_site_dirs(&config);
assert_eq!(build, PathBuf::from("build"));
assert_eq!(site, PathBuf::from("public"));
}
#[test]
fn test_resolve_dirs_serve_equals_output() {
use crate::cmd::SsgConfig;
use std::path::PathBuf;
let mut config = SsgConfig::default();
config.output_dir = PathBuf::from("dist");
config.serve_dir = Some(PathBuf::from("dist"));
let (build, site) = resolve_build_and_site_dirs(&config);
assert_eq!(site, PathBuf::from("dist"));
assert_ne!(build, site);
assert!(build.to_string_lossy().contains("build-tmp"));
}
#[test]
fn test_run_options_defaults() {
use crate::cmd::Cli;
let cli = Cli::build();
let matches = cli.try_get_matches_from(vec!["ssg"]).unwrap();
let opts = RunOptions::from_matches(&matches);
assert!(!opts.quiet);
assert!(!opts.include_drafts);
assert!(opts.deploy_target.is_none());
assert!(!opts.validate_only);
assert!(opts.jobs.is_none());
assert!(opts.max_memory_mb.is_none());
assert!(!opts.ai_fix);
assert!(!opts.ai_fix_dry_run);
}
#[test]
fn test_run_options_ai_fix_flags() {
use crate::cmd::Cli;
let cli = Cli::build();
let matches = cli
.try_get_matches_from(vec!["ssg", "--ai-fix", "--ai-fix-dry-run"])
.unwrap();
let opts = RunOptions::from_matches(&matches);
assert!(opts.ai_fix);
assert!(opts.ai_fix_dry_run);
}
#[test]
fn test_run_options_from_matches_incremental_no_llm_cache_isr_flags() {
use crate::cmd::Cli;
let cli = Cli::build();
let matches = cli
.try_get_matches_from(vec![
"ssg",
"--incremental",
"--no-llm-cache",
"--isr",
])
.unwrap();
let opts = RunOptions::from_matches(&matches);
assert!(opts.incremental);
assert!(opts.no_llm_cache);
assert!(opts.isr);
}
#[test]
fn test_run_options_debug() {
use crate::cmd::Cli;
let cli = Cli::build();
let matches = cli.try_get_matches_from(vec!["ssg"]).unwrap();
let opts = RunOptions::from_matches(&matches);
let debug = format!("{opts:?}");
assert!(debug.contains("RunOptions"));
assert!(debug.contains("quiet"));
}
#[test]
fn test_run_options_clone() {
use crate::cmd::Cli;
let cli = Cli::build();
let matches = cli
.try_get_matches_from(vec!["ssg", "--quiet", "--jobs", "2"])
.unwrap();
let opts = RunOptions::from_matches(&matches);
let cloned = opts.clone();
assert_eq!(cloned.quiet, opts.quiet);
assert_eq!(cloned.jobs, opts.jobs);
}
#[test]
fn test_register_default_plugins_minimum_count() {
use crate::cmd::SsgConfig;
use crate::plugin::PluginManager;
let config = SsgConfig::default();
let mut pm = PluginManager::new();
register_default_plugins(&mut pm, &config, false, None);
let count = pm.len();
assert!(
count >= 15,
"expected at least 15 default plugins, got {count}"
);
}
#[test]
fn test_register_default_plugins_includes_key_plugins() {
use crate::cmd::SsgConfig;
use crate::plugin::PluginManager;
let config = SsgConfig::default();
let mut pm = PluginManager::new();
register_default_plugins(&mut pm, &config, false, None);
let names = pm.names();
assert!(names.contains(&"content-validation"));
assert!(names.contains(&"drafts"));
assert!(names.contains(&"shortcodes"));
assert!(names.contains(&"seo"));
assert!(names.contains(&"search"));
assert!(names.contains(&"minify"));
assert!(names.contains(&"livereload"));
}
#[test]
fn test_register_default_plugins_with_deploy_adds_deploy_plugin() {
use crate::cmd::SsgConfig;
use crate::plugin::PluginManager;
let config = SsgConfig::default();
let mut pm_without = PluginManager::new();
register_default_plugins(&mut pm_without, &config, false, None);
let count_without = pm_without.len();
let mut pm_with = PluginManager::new();
register_default_plugins(&mut pm_with, &config, false, Some("netlify"));
assert_eq!(pm_with.len(), count_without + 1);
assert!(pm_with.names().contains(&"deploy"));
}
#[test]
fn test_register_default_plugins_unknown_deploy_skipped() {
use crate::cmd::SsgConfig;
use crate::plugin::PluginManager;
let config = SsgConfig::default();
let mut pm = PluginManager::new();
register_default_plugins(
&mut pm,
&config,
false,
Some("nonexistent-platform"),
);
assert!(
!pm.names().contains(&"deploy"),
"unknown deploy target should not register a deploy plugin"
);
}
#[test]
fn test_build_pipeline_returns_valid_dirs() {
use crate::cmd::SsgConfig;
let temp = tempfile::tempdir().unwrap();
let mut config = SsgConfig::default();
config.content_dir = temp.path().join("content");
config.output_dir = temp.path().join("public");
config.template_dir = temp.path().join("templates");
let opts = RunOptions {
quiet: true,
include_drafts: false,
deploy_target: None,
validate_only: false,
jobs: None,
max_memory_mb: None,
ai_fix: false,
ai_fix_dry_run: false,
incremental: false,
no_llm_cache: false,
isr: false,
};
let (plugins, ctx, build_dir, site_dir) =
build_pipeline(&config, &opts);
assert!(!plugins.is_empty());
assert_ne!(build_dir, site_dir);
assert_eq!(ctx.content_dir, temp.path().join("content"));
}
#[test]
fn test_run_options_from_subcommand_reads_max_memory() {
use crate::cmd::Cli;
let matches = Cli::subcommand_app().get_matches_from(vec![
"ssg",
"build",
"--max-memory",
"64",
]);
let sub_m = matches.subcommand_matches("build").unwrap();
let opts = RunOptions::from_subcommand_matches(sub_m);
assert_eq!(opts.max_memory_mb, Some(64));
}
#[test]
fn test_build_pipeline_no_llm_cache_exports_env_flag() {
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let prev = std::env::var("SSG_NO_LLM_CACHE").ok();
std::env::remove_var("SSG_NO_LLM_CACHE");
let config = SsgConfig::default();
let opts = RunOptions {
no_llm_cache: true,
..RunOptions::default()
};
let (plugins, _ctx, _build, _site) = build_pipeline(&config, &opts);
let seen = std::env::var("SSG_NO_LLM_CACHE").ok();
match prev {
Some(v) => std::env::set_var("SSG_NO_LLM_CACHE", v),
None => std::env::remove_var("SSG_NO_LLM_CACHE"),
}
assert_eq!(seen.as_deref(), Some("1"));
assert!(!plugins.is_empty());
}
#[test]
fn test_register_isr_plugins_appends_isr_pair() {
use crate::plugin::PluginManager;
let mut pm = PluginManager::new();
register_isr_plugins(&mut pm);
assert_eq!(pm.len(), 2, "ISR manifest + RPC schema plugins");
}
#[test]
fn test_build_pipeline_isr_flag_appends_plugins() {
let config = SsgConfig::default();
let base = build_pipeline(&config, &RunOptions::default()).0.len();
let opts = RunOptions {
isr: true,
..RunOptions::default()
};
let with_isr = build_pipeline(&config, &opts).0.len();
assert_eq!(with_isr, base + 2);
}
#[test]
fn test_register_default_plugins_multi_locale_adds_i18n() {
use crate::plugin::PluginManager;
let mut config = SsgConfig::default();
config.i18n = Some(i18n::I18nConfig {
default_locale: "en".to_string(),
locales: vec!["en".to_string(), "fr".to_string()],
url_prefix: Default::default(),
});
let mut pm = PluginManager::new();
register_default_plugins(&mut pm, &config, false, None);
assert!(
pm.names().contains(&"i18n"),
"two locales must register the i18n plugin: {:?}",
pm.names()
);
}
#[test]
fn test_register_default_plugins_single_locale_skips_i18n() {
use crate::plugin::PluginManager;
let mut config = SsgConfig::default();
config.i18n = Some(i18n::I18nConfig::default());
let mut pm = PluginManager::new();
register_default_plugins(&mut pm, &config, false, None);
assert!(!pm.names().contains(&"i18n"));
}
#[test]
fn test_register_default_plugins_transitions_opt_in() {
use crate::plugin::PluginManager;
let mut config = SsgConfig::default();
config.transitions = true;
let mut pm = PluginManager::new();
register_default_plugins(&mut pm, &config, false, None);
assert!(pm.names().contains(&"view-transitions"));
}
#[test]
#[serial_test::serial(cwd)]
fn test_depgraph_cache_root_falls_back_without_target_dir() {
let tmp = tempfile::tempdir().unwrap();
let prev = std::env::current_dir().expect("read current dir");
std::env::set_current_dir(tmp.path()).expect("pushd");
let root = depgraph_cache_root(Path::new("/tmp/site"));
std::env::set_current_dir(&prev).expect("popd");
assert_eq!(root, Path::new("/tmp/site").join(".ssg-cache"));
}
#[test]
#[cfg(unix)]
fn test_compile_maps_unreadable_template_dir_to_io_error() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let content = tmp.path().join("content");
let build = tmp.path().join("build");
let site = tmp.path().join("public");
let templates = tmp.path().join("templates");
std::fs::create_dir_all(&content).unwrap();
std::fs::create_dir_all(&templates).unwrap();
std::fs::set_permissions(
&templates,
std::fs::Permissions::from_mode(0o000),
)
.unwrap();
let res = compile_site_with_base_url(
&build, &content, &site, &templates, None,
);
let _ = std::fs::set_permissions(
&templates,
std::fs::Permissions::from_mode(0o755),
);
assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
}
fn build_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf, PathBuf)
{
crate::test_support::init_logger();
let tmp = tempfile::tempdir().expect("tempdir");
let content = tmp.path().join("content");
let build = tmp.path().join("build");
let site = tmp.path().join("public");
let templates = tmp.path().join("templates");
std::fs::create_dir_all(&content).expect("mkdir content");
std::fs::create_dir_all(&templates).expect("mkdir templates");
std::fs::create_dir_all(&build).expect("mkdir build");
std::fs::write(
content.join("index.md"),
"---\ntitle: \"Home\"\ndescription: \"home\"\n\
permalink: \"https://example.com/\"\n---\nhome body",
)
.expect("write index.md");
std::fs::write(
content.join("about.md"),
"---\ntitle: \"About\"\ndescription: \"about\"\n\
permalink: \"https://example.com/about/\"\n---\nabout body",
)
.expect("write about.md");
std::fs::write(
templates.join("page.html"),
"<!doctype html><html><body>{{ content }}</body></html>",
)
.expect("write template");
(tmp, content, build, site, templates)
}
#[derive(Debug)]
struct FailingPlugin {
phase: &'static str,
}
impl plugin::Plugin for FailingPlugin {
fn name(&self) -> &'static str {
"failing-test-plugin"
}
fn before_compile(
&self,
_ctx: &plugin::PluginContext,
) -> Result<(), SsgError> {
if self.phase == "before" {
return Err(SsgError::Validation {
field: "test".to_string(),
message: "injected before_compile failure".to_string(),
});
}
Ok(())
}
fn after_compile(
&self,
_ctx: &plugin::PluginContext,
) -> Result<(), SsgError> {
if self.phase == "after" {
return Err(SsgError::Validation {
field: "test".to_string(),
message: "injected after_compile failure".to_string(),
});
}
Ok(())
}
fn has_transform(&self) -> bool {
self.phase == "transform"
}
fn transform_html(
&self,
_html: &str,
_path: &Path,
_ctx: &plugin::PluginContext,
) -> Result<String, SsgError> {
Err(SsgError::Validation {
field: "test".to_string(),
message: "injected transform failure".to_string(),
})
}
}
#[derive(Debug)]
struct SabotagePlugin {
mode: &'static str,
}
impl plugin::Plugin for SabotagePlugin {
fn name(&self) -> &'static str {
"sabotage-test-plugin"
}
fn after_compile(
&self,
ctx: &plugin::PluginContext,
) -> Result<(), SsgError> {
if self.mode == "block-plugin-cache" {
let _ = std::fs::create_dir_all(
ctx.site_dir.join(".ssg-plugin-cache.json"),
);
}
#[cfg(unix)]
if self.mode == "lock-subdir" {
use std::os::unix::fs::PermissionsExt;
let locked = ctx.site_dir.join("locked");
let _ = std::fs::create_dir_all(&locked);
let _ = std::fs::set_permissions(
&locked,
std::fs::Permissions::from_mode(0o000),
);
}
Ok(())
}
}
fn run_fixture_with_plugins(
pm: &plugin::PluginManager,
incremental: bool,
) -> (tempfile::TempDir, PathBuf, Result<(), SsgError>) {
let (tmp, content, build, site, templates) = build_fixture();
let ctx =
plugin::PluginContext::new(&content, &build, &site, &templates);
let res = execute_build_pipeline_with(
pm,
&ctx,
&build,
&content,
&site,
&templates,
true,
incremental,
);
(tmp, site, res)
}
#[test]
fn test_pipeline_propagates_before_compile_failure() {
let mut pm = plugin::PluginManager::new();
pm.register(FailingPlugin { phase: "before" });
let (_tmp, _site, res) = run_fixture_with_plugins(&pm, false);
assert!(res.is_err());
}
#[test]
#[serial_test::parallel(stager_fp)]
fn test_pipeline_propagates_after_compile_failure() {
let mut pm = plugin::PluginManager::new();
pm.register(FailingPlugin { phase: "after" });
let (_tmp, _site, res) = run_fixture_with_plugins(&pm, false);
assert!(res.is_err());
}
#[test]
#[serial_test::parallel(stager_fp)]
fn test_pipeline_propagates_transform_failure() {
let mut pm = plugin::PluginManager::new();
pm.register(FailingPlugin { phase: "transform" });
let (_tmp, _site, res) = run_fixture_with_plugins(&pm, false);
assert!(res.is_err());
}
#[test]
#[serial_test::serial(ssg_cache, stager_fp)]
fn test_pipeline_streams_when_budget_explicitly_set() {
let (_tmp, content, build, site, templates) = build_fixture();
let mut ctx =
plugin::PluginContext::new(&content, &build, &site, &templates);
ctx.memory_budget = Some(streaming::MemoryBudget::from_mb(1));
let pm = plugin::PluginManager::new();
execute_build_pipeline_with(
&pm, &ctx, &build, &content, &site, &templates, true, false,
)
.expect("streamed build should succeed");
assert!(
site.join("about").join("index.html").exists(),
"batched compile must emit the page outputs"
);
}
#[test]
#[serial_test::serial(cwd, ssg_cache, stager_fp)]
fn test_pipeline_incremental_fast_path_and_delete_sweep() {
let (_tmp, content, build, site, templates) = build_fixture();
let ctx =
plugin::PluginContext::new(&content, &build, &site, &templates);
let pm = plugin::PluginManager::new();
let cache_root = depgraph_cache_root(&site);
let _ = std::fs::remove_file(
cache_root.join(crate::depgraph::DEP_GRAPH_FILE),
);
execute_build_pipeline_with(
&pm, &ctx, &build, &content, &site, &templates, false, true,
)
.expect("cold incremental build should succeed");
let about_out = site.join("about").join("index.html");
assert!(about_out.exists());
std::fs::write(&about_out, "MARKER").unwrap();
execute_build_pipeline_with(
&pm, &ctx, &build, &content, &site, &templates, false, true,
)
.expect("warm incremental build should succeed");
assert_eq!(
std::fs::read_to_string(&about_out).unwrap(),
"MARKER",
"fast path must not recompile unchanged sources"
);
std::fs::remove_file(content.join("about.md")).unwrap();
execute_build_pipeline_with(
&pm, &ctx, &build, &content, &site, &templates, false, true,
)
.expect("incremental rebuild after delete should succeed");
assert!(!about_out.exists(), "deleted source's output must be swept");
}
#[test]
#[cfg(unix)]
#[serial_test::serial(ssg_cache, stager_fp)]
fn test_pipeline_warns_but_succeeds_when_populate_fails() {
let (_tmp, content, build, site, templates) = build_fixture();
std::os::unix::fs::symlink(
content.join("nowhere.md"),
content.join("ghost.md"),
)
.unwrap();
let ctx =
plugin::PluginContext::new(&content, &build, &site, &templates);
let pm = plugin::PluginManager::new();
execute_build_pipeline_with(
&pm, &ctx, &build, &content, &site, &templates, true, false,
)
.expect("populate failure must be non-fatal");
}
#[test]
#[serial_test::serial(cwd, ssg_cache, stager_fp)]
fn test_pipeline_warns_but_succeeds_when_graph_save_fails() {
let (_tmp, content, build, site, templates) = build_fixture();
let ctx =
plugin::PluginContext::new(&content, &build, &site, &templates);
let pm = plugin::PluginManager::new();
let cache_root = depgraph_cache_root(&site);
let blocker =
cache_root.join(format!("{}.tmp", crate::depgraph::DEP_GRAPH_FILE));
std::fs::create_dir_all(&blocker).unwrap();
std::fs::write(blocker.join("keep.txt"), "x").unwrap();
let res = execute_build_pipeline_with(
&pm, &ctx, &build, &content, &site, &templates, true, false,
);
let blocked = blocker.is_dir();
let _ = std::fs::remove_dir_all(&blocker);
res.expect("graph-save failure must be non-fatal");
assert!(blocked, "blocker must have survived the build");
}
#[test]
#[serial_test::serial(ssg_cache, stager_fp)]
fn test_pipeline_warns_but_succeeds_when_plugin_cache_save_fails() {
let mut pm = plugin::PluginManager::new();
pm.register(SabotagePlugin {
mode: "block-plugin-cache",
});
let (_tmp, site, res) = run_fixture_with_plugins(&pm, false);
res.expect("plugin-cache save failure must be non-fatal");
assert!(
site.join(".ssg-plugin-cache.json").is_dir(),
"blocker must be present for the warn arm to have fired"
);
}
#[test]
#[serial_test::serial(ssg_cache, stager_fp)]
fn test_execute_build_pipeline_with_config_derives_base_url_for_non_streaming_compile(
) {
use crate::cmd::SsgConfig;
let (_tmp, content, build, site, templates) = build_fixture();
let config = SsgConfig {
base_url: "https://example.com".to_string(),
..SsgConfig::default()
};
let ctx = plugin::PluginContext::with_config(
&content, &build, &site, &templates, config,
);
let pm = plugin::PluginManager::new();
execute_build_pipeline_with(
&pm, &ctx, &build, &content, &site, &templates, true, false,
)
.expect("build with a configured base_url should succeed");
assert!(
site.join("about").join("index.html").exists(),
"compile must still emit page outputs when config carries a base_url"
);
}
#[test]
#[cfg(unix)]
#[serial_test::serial(cwd, ssg_cache, stager_fp)]
fn test_pipeline_incremental_propagates_current_hashes_failure() {
use std::os::unix::fs::PermissionsExt;
let (_tmp, content, build, site, templates) = build_fixture();
let ctx =
plugin::PluginContext::new(&content, &build, &site, &templates);
let pm = plugin::PluginManager::new();
std::fs::set_permissions(
&content,
std::fs::Permissions::from_mode(0o000),
)
.unwrap();
let res = execute_build_pipeline_with(
&pm, &ctx, &build, &content, &site, &templates, true, true,
);
let _ = std::fs::set_permissions(
&content,
std::fs::Permissions::from_mode(0o755),
);
assert!(
res.is_err(),
"unreadable content_dir must fail current_hashes and propagate"
);
}
#[test]
#[cfg(unix)]
#[serial_test::serial(ssg_cache, stager_fp)]
fn test_pipeline_tolerates_unwalkable_site_dir() {
use std::os::unix::fs::PermissionsExt;
let mut pm = plugin::PluginManager::new();
pm.register(SabotagePlugin {
mode: "lock-subdir",
});
let (_tmp, site, res) = run_fixture_with_plugins(&pm, false);
let locked = site.join("locked");
let was_locked = locked.is_dir();
let _ = std::fs::set_permissions(
&locked,
std::fs::Permissions::from_mode(0o755),
);
res.expect("unwalkable site dir must be non-fatal");
assert!(was_locked, "sabotage dir must have survived the build");
}
}