use crate::plugin::{Plugin, PluginContext};
use anyhow::Result;
use std::fs;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeployTarget {
Netlify,
Vercel,
CloudflarePages,
GithubPages,
}
#[derive(Debug, Clone, Copy)]
pub struct DeployPlugin {
target: DeployTarget,
}
impl DeployPlugin {
#[must_use]
pub const fn new(target: DeployTarget) -> Self {
Self { target }
}
}
impl Plugin for DeployPlugin {
fn name(&self) -> &'static str {
"deploy"
}
fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
if !ctx.site_dir.exists() {
return Ok(());
}
match self.target {
DeployTarget::Netlify => generate_netlify(&ctx.site_dir)?,
DeployTarget::Vercel => generate_vercel(&ctx.site_dir)?,
DeployTarget::CloudflarePages => {
generate_cloudflare(&ctx.site_dir)?;
}
DeployTarget::GithubPages => {
generate_github_pages(&ctx.site_dir)?;
}
}
log::info!("[deploy] Generated {:?} config", self.target);
Ok(())
}
}
const SECURITY_HEADERS: &[(&str, &str)] = &[
("X-Content-Type-Options", "nosniff"),
("X-Frame-Options", "DENY"),
("X-XSS-Protection", "1; mode=block"),
("Referrer-Policy", "strict-origin-when-cross-origin"),
(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=()",
),
(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' https: data:; font-src 'self' https:; connect-src 'self'; frame-ancestors 'none'",
),
("Strict-Transport-Security", "max-age=31536000; includeSubDomains"),
];
fn generate_netlify(site_dir: &std::path::Path) -> Result<()> {
let mut headers = String::from("/*\n");
for (k, v) in SECURITY_HEADERS {
headers.push_str(&format!(" {k} = {v}\n"));
}
headers.push_str(
"\n/assets/*\n Cache-Control: public, max-age=31536000, immutable\n",
);
headers.push_str("\n/*.html\n Cache-Control: public, max-age=3600\n");
fs::write(site_dir.join("_headers"), &headers)?;
fs::write(site_dir.join("_redirects"), "")?;
let toml = r#"[build]
publish = "public"
command = "cargo run -- -c content -o public -t templates"
[[headers]]
for = "/assets/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
"#;
fs::write(site_dir.join("netlify.toml"), toml)?;
Ok(())
}
fn generate_vercel(site_dir: &std::path::Path) -> Result<()> {
let mut headers_arr = Vec::new();
for (k, v) in SECURITY_HEADERS {
headers_arr.push(serde_json::json!({"key": k, "value": v}));
}
let config = serde_json::json!({
"headers": [
{
"source": "/(.*)",
"headers": headers_arr
},
{
"source": "/assets/(.*)",
"headers": [{"key": "Cache-Control", "value": "public, max-age=31536000, immutable"}]
}
]
});
let json = serde_json::to_string_pretty(&config)?;
fs::write(site_dir.join("vercel.json"), json)?;
Ok(())
}
fn generate_cloudflare(site_dir: &std::path::Path) -> Result<()> {
let mut headers = String::from("/*\n");
for (k, v) in SECURITY_HEADERS {
headers.push_str(&format!(" {k} : {v}\n"));
}
headers.push_str(
"\n/assets/*\n Cache-Control: public, max-age=31536000, immutable\n",
);
fs::write(site_dir.join("_headers"), &headers)?;
fs::write(site_dir.join("_redirects"), "")?;
Ok(())
}
fn generate_github_pages(site_dir: &std::path::Path) -> Result<()> {
fs::write(site_dir.join(".nojekyll"), "")?;
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::test_support::init_logger;
use std::path::{Path, PathBuf};
use tempfile::{tempdir, TempDir};
fn make_ctx_with_site() -> (TempDir, PathBuf, PluginContext) {
init_logger();
let dir = tempdir().expect("create tempdir");
let site = dir.path().join("site");
fs::create_dir_all(&site).expect("create site dir");
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
(dir, site, ctx)
}
fn assert_all_security_headers_present(body: &str) {
for (k, v) in SECURITY_HEADERS {
assert!(
body.contains(k),
"missing header key `{k}` in body:\n{body}"
);
assert!(
body.contains(v),
"missing header value `{v}` in body:\n{body}"
);
}
}
#[test]
fn deploy_target_equality_reflexive_for_each_variant() {
let variants = [
DeployTarget::Netlify,
DeployTarget::Vercel,
DeployTarget::CloudflarePages,
DeployTarget::GithubPages,
];
for v in variants {
assert_eq!(v, v, "{v:?} should equal itself");
}
}
#[test]
fn deploy_target_distinct_variants_are_not_equal() {
assert_ne!(DeployTarget::Netlify, DeployTarget::Vercel);
assert_ne!(DeployTarget::Vercel, DeployTarget::CloudflarePages);
assert_ne!(DeployTarget::CloudflarePages, DeployTarget::GithubPages);
assert_ne!(DeployTarget::GithubPages, DeployTarget::Netlify);
}
#[test]
fn deploy_target_is_copy_after_move() {
let target = DeployTarget::Netlify;
let _copy = target;
assert_eq!(target, DeployTarget::Netlify);
}
#[test]
fn deploy_target_debug_format_contains_variant_name() {
assert!(format!("{:?}", DeployTarget::Netlify).contains("Netlify"));
assert!(format!("{:?}", DeployTarget::Vercel).contains("Vercel"));
assert!(format!("{:?}", DeployTarget::CloudflarePages)
.contains("CloudflarePages"));
assert!(
format!("{:?}", DeployTarget::GithubPages).contains("GithubPages")
);
}
#[test]
fn new_constructs_plugin_for_every_target_variant() {
let cases = [
DeployTarget::Netlify,
DeployTarget::Vercel,
DeployTarget::CloudflarePages,
DeployTarget::GithubPages,
];
for target in cases {
let plugin = DeployPlugin::new(target);
assert_eq!(
plugin.target, target,
"constructor must store the supplied target"
);
}
}
#[test]
fn name_returns_static_deploy_identifier() {
let plugin = DeployPlugin::new(DeployTarget::Netlify);
assert_eq!(plugin.name(), "deploy");
}
#[test]
fn deploy_plugin_is_copy_after_move() {
let plugin = DeployPlugin::new(DeployTarget::Vercel);
let _copy = plugin;
assert_eq!(plugin.name(), "deploy");
}
#[test]
fn after_compile_missing_site_dir_returns_ok_without_writing() {
let dir = tempdir().expect("tempdir");
let missing_site = dir.path().join("does-not-exist");
let ctx = PluginContext::new(
dir.path(),
dir.path(),
&missing_site,
dir.path(),
);
let plugin = DeployPlugin::new(DeployTarget::Netlify);
plugin
.after_compile(&ctx)
.expect("missing site_dir is not an error");
assert!(!missing_site.exists());
assert!(!dir.path().join("_headers").exists());
assert!(!dir.path().join("netlify.toml").exists());
}
#[test]
fn after_compile_netlify_writes_all_expected_artifacts() {
let (_tmp, site, ctx) = make_ctx_with_site();
DeployPlugin::new(DeployTarget::Netlify)
.after_compile(&ctx)
.expect("netlify after_compile");
for f in ["_headers", "_redirects", "netlify.toml"] {
assert!(
site.join(f).exists(),
"Netlify dispatch must produce `{f}`"
);
}
}
#[test]
fn after_compile_vercel_writes_well_formed_json() {
let (_tmp, site, ctx) = make_ctx_with_site();
DeployPlugin::new(DeployTarget::Vercel)
.after_compile(&ctx)
.expect("vercel after_compile");
let raw = fs::read_to_string(site.join("vercel.json"))
.expect("vercel.json should exist");
let parsed: serde_json::Value =
serde_json::from_str(&raw).expect("vercel.json must be valid JSON");
assert!(
parsed.get("headers").and_then(|v| v.as_array()).is_some(),
"vercel.json must have a top-level `headers` array"
);
}
#[test]
fn after_compile_cloudflare_writes_headers_and_redirects() {
let (_tmp, site, ctx) = make_ctx_with_site();
DeployPlugin::new(DeployTarget::CloudflarePages)
.after_compile(&ctx)
.expect("cloudflare after_compile");
assert!(site.join("_headers").exists());
assert!(site.join("_redirects").exists());
}
#[test]
fn after_compile_github_pages_writes_only_nojekyll() {
let (_tmp, site, ctx) = make_ctx_with_site();
DeployPlugin::new(DeployTarget::GithubPages)
.after_compile(&ctx)
.expect("github pages after_compile");
assert!(site.join(".nojekyll").exists());
assert!(!site.join("_headers").exists());
assert!(!site.join("netlify.toml").exists());
assert!(!site.join("vercel.json").exists());
}
#[test]
fn generate_netlify_headers_file_contains_every_security_header() {
let dir = tempdir().expect("tempdir");
generate_netlify(dir.path()).expect("generate netlify");
let body = fs::read_to_string(dir.path().join("_headers"))
.expect("read _headers");
assert_all_security_headers_present(&body);
}
#[test]
fn generate_netlify_headers_file_contains_cache_directives() {
let dir = tempdir().expect("tempdir");
generate_netlify(dir.path()).expect("generate netlify");
let body = fs::read_to_string(dir.path().join("_headers"))
.expect("read _headers");
assert!(body.contains("/assets/*"));
assert!(body.contains("max-age=31536000"));
assert!(body.contains("immutable"));
assert!(body.contains("/*.html"));
assert!(body.contains("max-age=3600"));
}
#[test]
fn generate_netlify_toml_contains_build_publish_directive() {
let dir = tempdir().expect("tempdir");
generate_netlify(dir.path()).expect("generate netlify");
let toml = fs::read_to_string(dir.path().join("netlify.toml"))
.expect("read netlify.toml");
assert!(toml.contains("[build]"));
assert!(toml.contains("publish"));
assert!(toml.contains("[[headers]]"));
}
#[test]
fn generate_netlify_creates_empty_redirects_file() {
let dir = tempdir().expect("tempdir");
generate_netlify(dir.path()).expect("generate netlify");
let redirects = fs::read_to_string(dir.path().join("_redirects"))
.expect("read _redirects");
assert!(redirects.is_empty(), "_redirects starts empty by design");
}
#[test]
fn generate_vercel_json_contains_every_security_header_value() {
let dir = tempdir().expect("tempdir");
generate_vercel(dir.path()).expect("generate vercel");
let json = fs::read_to_string(dir.path().join("vercel.json"))
.expect("read vercel.json");
assert_all_security_headers_present(&json);
}
#[test]
fn generate_vercel_json_has_asset_cache_route() {
let dir = tempdir().expect("tempdir");
generate_vercel(dir.path()).expect("generate vercel");
let raw = fs::read_to_string(dir.path().join("vercel.json"))
.expect("read vercel.json");
let parsed: serde_json::Value =
serde_json::from_str(&raw).expect("valid JSON");
let routes = parsed["headers"].as_array().expect("headers is an array");
let sources: Vec<&str> =
routes.iter().filter_map(|r| r["source"].as_str()).collect();
assert!(sources.iter().any(|s| s.contains("/assets/")));
assert!(sources.iter().any(|s| s.contains("/(.*)")));
}
#[test]
fn generate_cloudflare_headers_file_uses_colon_separator() {
let dir = tempdir().expect("tempdir");
generate_cloudflare(dir.path()).expect("generate cloudflare");
let body = fs::read_to_string(dir.path().join("_headers"))
.expect("read _headers");
assert!(body.contains("X-Content-Type-Options : nosniff"));
assert_all_security_headers_present(&body);
}
#[test]
fn generate_cloudflare_writes_empty_redirects_file() {
let dir = tempdir().expect("tempdir");
generate_cloudflare(dir.path()).expect("generate cloudflare");
let redirects = fs::read_to_string(dir.path().join("_redirects"))
.expect("read _redirects");
assert!(redirects.is_empty());
}
#[test]
fn generate_github_pages_writes_empty_nojekyll_marker() {
let dir = tempdir().expect("tempdir");
generate_github_pages(dir.path()).expect("generate github pages");
let nojekyll = dir.path().join(".nojekyll");
assert!(nojekyll.exists());
let contents = fs::read_to_string(&nojekyll).expect("read .nojekyll");
assert!(
contents.is_empty(),
".nojekyll is a marker file and must be empty"
);
}
#[test]
fn after_compile_idempotent_for_every_target() {
for target in [
DeployTarget::Netlify,
DeployTarget::Vercel,
DeployTarget::CloudflarePages,
DeployTarget::GithubPages,
] {
let (_tmp, _site, ctx) = make_ctx_with_site();
let plugin = DeployPlugin::new(target);
plugin
.after_compile(&ctx)
.unwrap_or_else(|e| panic!("first {target:?}: {e}"));
plugin
.after_compile(&ctx)
.unwrap_or_else(|e| panic!("second {target:?}: {e}"));
}
}
#[test]
fn generate_netlify_into_missing_parent_returns_err() {
let bogus = Path::new("/this/path/should/not/exist/ssg-test");
let result = generate_netlify(bogus);
assert!(
result.is_err(),
"writing into a non-existent parent must error"
);
}
#[test]
fn generate_vercel_into_missing_parent_returns_err() {
let bogus = Path::new("/this/path/should/not/exist/ssg-test");
assert!(generate_vercel(bogus).is_err());
}
#[test]
fn generate_cloudflare_into_missing_parent_returns_err() {
let bogus = Path::new("/this/path/should/not/exist/ssg-test");
assert!(generate_cloudflare(bogus).is_err());
}
#[test]
fn generate_github_pages_into_missing_parent_returns_err() {
let bogus = Path::new("/this/path/should/not/exist/ssg-test");
assert!(generate_github_pages(bogus).is_err());
}
}