Skip to main content

ssg/
deploy.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Deployment adapter generation.
5//!
6//! Generates platform-specific configuration files for common hosting
7//! providers, including cache headers and security headers.
8
9use crate::plugin::{Plugin, PluginContext};
10use anyhow::Result;
11use std::fs;
12
13/// Supported deployment targets.
14///
15/// Marked `#[non_exhaustive]` so future targets (AWS, Azure Static Web
16/// Apps, Cloudflare R2 sites) can be added without a major version bump.
17/// Downstream consumers must use a wildcard arm in `match` expressions.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19#[non_exhaustive]
20pub enum DeployTarget {
21    /// Netlify (`netlify.toml`).
22    Netlify,
23    /// Vercel (`vercel.json`).
24    Vercel,
25    /// Cloudflare Pages (`_headers`, `_redirects`).
26    CloudflarePages,
27    /// GitHub Pages (`.nojekyll`, `CNAME`).
28    GithubPages,
29}
30
31/// Plugin that generates deployment configuration files.
32#[derive(Debug, Clone, Copy)]
33pub struct DeployPlugin {
34    target: DeployTarget,
35}
36
37impl DeployPlugin {
38    /// Creates a new `DeployPlugin` for the given target.
39    #[must_use]
40    pub const fn new(target: DeployTarget) -> Self {
41        Self { target }
42    }
43}
44
45impl Plugin for DeployPlugin {
46    fn name(&self) -> &'static str {
47        "deploy"
48    }
49
50    fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
51        if !ctx.site_dir.exists() {
52            return Ok(());
53        }
54
55        match self.target {
56            DeployTarget::Netlify => generate_netlify(&ctx.site_dir)?,
57            DeployTarget::Vercel => generate_vercel(&ctx.site_dir)?,
58            DeployTarget::CloudflarePages => {
59                generate_cloudflare(&ctx.site_dir)?;
60            }
61            DeployTarget::GithubPages => {
62                generate_github_pages(&ctx.site_dir)?;
63            }
64        }
65
66        log::info!("[deploy] Generated {:?} config", self.target);
67        Ok(())
68    }
69}
70
71/// Security headers shared across all platforms.
72const SECURITY_HEADERS: &[(&str, &str)] = &[
73    ("X-Content-Type-Options", "nosniff"),
74    ("X-Frame-Options", "DENY"),
75    ("X-XSS-Protection", "1; mode=block"),
76    ("Referrer-Policy", "strict-origin-when-cross-origin"),
77    (
78        "Permissions-Policy",
79        "camera=(), microphone=(), geolocation=()",
80    ),
81    (
82        "Content-Security-Policy",
83        "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' https: data:; font-src 'self' https:; connect-src 'self'; frame-ancestors 'none'",
84    ),
85    ("Strict-Transport-Security", "max-age=31536000; includeSubDomains"),
86];
87
88fn generate_netlify(site_dir: &std::path::Path) -> Result<()> {
89    let mut headers = String::from("/*\n");
90    for (k, v) in SECURITY_HEADERS {
91        headers.push_str(&format!("  {k} = {v}\n"));
92    }
93    // Content-addressable assets (CSS/JS/images/fonts) — see issue
94    // #468. The fingerprint plugin renames every *.{css,js,png,jpg,
95    // webp,svg,woff2,...} to *.<hash>.<ext>, so each emit is
96    // intrinsically immutable.
97    headers.push_str(
98        "\n/assets/*\n  Cache-Control: public, max-age=31536000, immutable\n",
99    );
100    for ext in [
101        "css", "js", "mjs", "png", "jpg", "jpeg", "webp", "avif", "gif", "svg",
102        "woff", "woff2",
103    ] {
104        headers.push_str(&format!(
105            "\n/*.{ext}\n  Cache-Control: public, max-age=31536000, immutable\n"
106        ));
107    }
108    // HTML and feed/index files always revalidate so content updates
109    // are visible immediately.
110    headers.push_str("\n/*.html\n  Cache-Control: no-cache, must-revalidate\n");
111    for path in [
112        "/sitemap.xml",
113        "/sitemap-news.xml",
114        "/atom.xml",
115        "/rss.xml",
116        "/manifest.json",
117        "/robots.txt",
118        "/search-index.json",
119    ] {
120        headers.push_str(&format!(
121            "\n{path}\n  Cache-Control: no-cache, must-revalidate\n"
122        ));
123    }
124
125    fs::write(site_dir.join("_headers"), &headers)?;
126    fs::write(site_dir.join("_redirects"), "")?;
127
128    let toml = r#"[build]
129  publish = "public"
130  command = "cargo run -- -c content -o public -t templates"
131
132[[headers]]
133  for = "/assets/*"
134  [headers.values]
135    Cache-Control = "public, max-age=31536000, immutable"
136"#;
137    fs::write(site_dir.join("netlify.toml"), toml)?;
138    Ok(())
139}
140
141fn generate_vercel(site_dir: &std::path::Path) -> Result<()> {
142    let mut headers_arr = Vec::new();
143    for (k, v) in SECURITY_HEADERS {
144        headers_arr.push(serde_json::json!({"key": k, "value": v}));
145    }
146
147    // Per-extension immutable headers for content-addressable assets
148    // (#468). The fingerprint plugin renames these to name.hash.ext
149    // so the file body is bound to its name.
150    let immutable = serde_json::json!([
151        {"key": "Cache-Control", "value": "public, max-age=31536000, immutable"}
152    ]);
153    let no_cache = serde_json::json!([
154        {"key": "Cache-Control", "value": "no-cache, must-revalidate"}
155    ]);
156
157    let config = serde_json::json!({
158        "headers": [
159            {"source": "/(.*)", "headers": headers_arr},
160            {"source": "/assets/(.*)", "headers": immutable},
161            {"source": "/(.*)\\.(css|js|mjs|png|jpg|jpeg|webp|avif|gif|svg|woff|woff2)",
162             "headers": immutable},
163            {"source": "/(.*)\\.html", "headers": no_cache},
164            {"source": "/(sitemap|sitemap-news|atom|rss)\\.xml",
165             "headers": no_cache},
166            {"source": "/(manifest|search-index)\\.json", "headers": no_cache},
167            {"source": "/robots\\.txt", "headers": no_cache}
168        ]
169    });
170
171    let json = serde_json::to_string_pretty(&config)?;
172    fs::write(site_dir.join("vercel.json"), json)?;
173    Ok(())
174}
175
176fn generate_cloudflare(site_dir: &std::path::Path) -> Result<()> {
177    let mut headers = String::from("/*\n");
178    for (k, v) in SECURITY_HEADERS {
179        headers.push_str(&format!("  {k} : {v}\n"));
180    }
181    // Content-addressable assets (#468) — same set as Netlify.
182    headers.push_str(
183        "\n/assets/*\n  Cache-Control: public, max-age=31536000, immutable\n",
184    );
185    for ext in [
186        "css", "js", "mjs", "png", "jpg", "jpeg", "webp", "avif", "gif", "svg",
187        "woff", "woff2",
188    ] {
189        headers.push_str(&format!(
190            "\n/*.{ext}\n  Cache-Control: public, max-age=31536000, immutable\n"
191        ));
192    }
193    headers.push_str("\n/*.html\n  Cache-Control: no-cache, must-revalidate\n");
194    for path in [
195        "/sitemap.xml",
196        "/atom.xml",
197        "/rss.xml",
198        "/manifest.json",
199        "/robots.txt",
200    ] {
201        headers.push_str(&format!(
202            "\n{path}\n  Cache-Control: no-cache, must-revalidate\n"
203        ));
204    }
205
206    fs::write(site_dir.join("_headers"), &headers)?;
207    fs::write(site_dir.join("_redirects"), "")?;
208    Ok(())
209}
210
211fn generate_github_pages(site_dir: &std::path::Path) -> Result<()> {
212    // .nojekyll prevents GitHub Pages from processing with Jekyll
213    fs::write(site_dir.join(".nojekyll"), "")?;
214    Ok(())
215}
216
217#[cfg(test)]
218#[allow(clippy::unwrap_used, clippy::expect_used)]
219mod tests {
220    use super::*;
221    use crate::test_support::init_logger;
222    use std::path::{Path, PathBuf};
223    use tempfile::{tempdir, TempDir};
224
225    // -------------------------------------------------------------------
226    // Test fixtures
227    // -------------------------------------------------------------------
228
229    /// Builds a fresh temp dir containing a `site/` subdirectory and a
230    /// `PluginContext` pointing at it. Used by every plugin-trait test.
231    fn make_ctx_with_site() -> (TempDir, PathBuf, PluginContext) {
232        init_logger();
233        let dir = tempdir().expect("create tempdir");
234        let site = dir.path().join("site");
235        fs::create_dir_all(&site).expect("create site dir");
236        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
237        (dir, site, ctx)
238    }
239
240    /// Asserts that *every* security header documented in
241    /// `SECURITY_HEADERS` appears verbatim (key + value) inside `body`.
242    fn assert_all_security_headers_present(body: &str) {
243        for (k, v) in SECURITY_HEADERS {
244            assert!(
245                body.contains(k),
246                "missing header key `{k}` in body:\n{body}"
247            );
248            assert!(
249                body.contains(v),
250                "missing header value `{v}` in body:\n{body}"
251            );
252        }
253    }
254
255    // -------------------------------------------------------------------
256    // DeployTarget — derives, equality, copy semantics
257    // -------------------------------------------------------------------
258
259    #[test]
260    fn deploy_target_equality_reflexive_for_each_variant() {
261        // Arrange
262        let variants = [
263            DeployTarget::Netlify,
264            DeployTarget::Vercel,
265            DeployTarget::CloudflarePages,
266            DeployTarget::GithubPages,
267        ];
268
269        // Act + Assert: every variant is equal to itself.
270        for v in variants {
271            assert_eq!(v, v, "{v:?} should equal itself");
272        }
273    }
274
275    #[test]
276    fn deploy_target_distinct_variants_are_not_equal() {
277        // Distinct variants must compare unequal — guards against
278        // accidental duplicate discriminants if the enum is reordered.
279        assert_ne!(DeployTarget::Netlify, DeployTarget::Vercel);
280        assert_ne!(DeployTarget::Vercel, DeployTarget::CloudflarePages);
281        assert_ne!(DeployTarget::CloudflarePages, DeployTarget::GithubPages);
282        assert_ne!(DeployTarget::GithubPages, DeployTarget::Netlify);
283    }
284
285    #[test]
286    fn deploy_target_is_copy_after_move() {
287        // Verifies the `Copy` derive is in effect: the binding remains
288        // usable after being passed by value.
289        let target = DeployTarget::Netlify;
290        let _copy = target;
291        assert_eq!(target, DeployTarget::Netlify);
292    }
293
294    #[test]
295    fn deploy_target_debug_format_contains_variant_name() {
296        assert!(format!("{:?}", DeployTarget::Netlify).contains("Netlify"));
297        assert!(format!("{:?}", DeployTarget::Vercel).contains("Vercel"));
298        assert!(format!("{:?}", DeployTarget::CloudflarePages)
299            .contains("CloudflarePages"));
300        assert!(
301            format!("{:?}", DeployTarget::GithubPages).contains("GithubPages")
302        );
303    }
304
305    // -------------------------------------------------------------------
306    // DeployPlugin — constructor & trait surface
307    // -------------------------------------------------------------------
308
309    #[test]
310    fn new_constructs_plugin_for_every_target_variant() {
311        // Table-driven: every DeployTarget must be a valid argument to
312        // `DeployPlugin::new` and must round-trip through the field.
313        let cases = [
314            DeployTarget::Netlify,
315            DeployTarget::Vercel,
316            DeployTarget::CloudflarePages,
317            DeployTarget::GithubPages,
318        ];
319        for target in cases {
320            let plugin = DeployPlugin::new(target);
321            assert_eq!(
322                plugin.target, target,
323                "constructor must store the supplied target"
324            );
325        }
326    }
327
328    #[test]
329    fn name_returns_static_deploy_identifier() {
330        // The plugin name is part of the public contract — registries
331        // and log lines key off it, so it must be stable.
332        let plugin = DeployPlugin::new(DeployTarget::Netlify);
333        assert_eq!(plugin.name(), "deploy");
334    }
335
336    #[test]
337    fn deploy_plugin_is_copy_after_move() {
338        let plugin = DeployPlugin::new(DeployTarget::Vercel);
339        let _copy = plugin;
340        assert_eq!(plugin.name(), "deploy");
341    }
342
343    // -------------------------------------------------------------------
344    // after_compile — short-circuit on missing site directory
345    // -------------------------------------------------------------------
346
347    #[test]
348    fn after_compile_missing_site_dir_returns_ok_without_writing() {
349        // The hook must be a no-op when the build hasn't produced a
350        // site directory yet. This guards the early-return at line 46.
351        let dir = tempdir().expect("tempdir");
352        let missing_site = dir.path().join("does-not-exist");
353        let ctx = PluginContext::new(
354            dir.path(),
355            dir.path(),
356            &missing_site,
357            dir.path(),
358        );
359
360        let plugin = DeployPlugin::new(DeployTarget::Netlify);
361        plugin
362            .after_compile(&ctx)
363            .expect("missing site_dir is not an error");
364
365        // Nothing should have been created.
366        assert!(!missing_site.exists());
367        assert!(!dir.path().join("_headers").exists());
368        assert!(!dir.path().join("netlify.toml").exists());
369    }
370
371    // -------------------------------------------------------------------
372    // after_compile — full trait dispatch for every target
373    // -------------------------------------------------------------------
374
375    #[test]
376    fn after_compile_netlify_writes_all_expected_artifacts() {
377        let (_tmp, site, ctx) = make_ctx_with_site();
378        DeployPlugin::new(DeployTarget::Netlify)
379            .after_compile(&ctx)
380            .expect("netlify after_compile");
381
382        for f in ["_headers", "_redirects", "netlify.toml"] {
383            assert!(
384                site.join(f).exists(),
385                "Netlify dispatch must produce `{f}`"
386            );
387        }
388    }
389
390    #[test]
391    fn after_compile_vercel_writes_well_formed_json() {
392        let (_tmp, site, ctx) = make_ctx_with_site();
393        DeployPlugin::new(DeployTarget::Vercel)
394            .after_compile(&ctx)
395            .expect("vercel after_compile");
396
397        let raw = fs::read_to_string(site.join("vercel.json"))
398            .expect("vercel.json should exist");
399        let parsed: serde_json::Value =
400            serde_json::from_str(&raw).expect("vercel.json must be valid JSON");
401        assert!(
402            parsed.get("headers").and_then(|v| v.as_array()).is_some(),
403            "vercel.json must have a top-level `headers` array"
404        );
405    }
406
407    #[test]
408    fn after_compile_cloudflare_writes_headers_and_redirects() {
409        let (_tmp, site, ctx) = make_ctx_with_site();
410        DeployPlugin::new(DeployTarget::CloudflarePages)
411            .after_compile(&ctx)
412            .expect("cloudflare after_compile");
413
414        assert!(site.join("_headers").exists());
415        assert!(site.join("_redirects").exists());
416    }
417
418    #[test]
419    fn after_compile_github_pages_writes_only_nojekyll() {
420        let (_tmp, site, ctx) = make_ctx_with_site();
421        DeployPlugin::new(DeployTarget::GithubPages)
422            .after_compile(&ctx)
423            .expect("github pages after_compile");
424
425        assert!(site.join(".nojekyll").exists());
426        // GitHub Pages dispatch should NOT touch the Netlify/Vercel/CF
427        // artifacts — guard against cross-contamination if a future
428        // refactor accidentally calls multiple generators.
429        assert!(!site.join("_headers").exists());
430        assert!(!site.join("netlify.toml").exists());
431        assert!(!site.join("vercel.json").exists());
432    }
433
434    // -------------------------------------------------------------------
435    // Generators — header content completeness
436    // -------------------------------------------------------------------
437
438    #[test]
439    fn generate_netlify_headers_file_contains_every_security_header() {
440        let dir = tempdir().expect("tempdir");
441        generate_netlify(dir.path()).expect("generate netlify");
442
443        let body = fs::read_to_string(dir.path().join("_headers"))
444            .expect("read _headers");
445        assert_all_security_headers_present(&body);
446    }
447
448    #[test]
449    fn generate_netlify_headers_file_contains_cache_directives() {
450        let dir = tempdir().expect("tempdir");
451        generate_netlify(dir.path()).expect("generate netlify");
452
453        let body = fs::read_to_string(dir.path().join("_headers"))
454            .expect("read _headers");
455        // Issue #468: long-lived immutable for content-addressable
456        // assets (CSS/JS/images/fonts) + always-revalidate for HTML
457        // and feeds.
458        assert!(body.contains("/assets/*"));
459        assert!(body.contains("max-age=31536000"));
460        assert!(body.contains("immutable"));
461        assert!(body.contains("/*.html"));
462        assert!(body.contains("no-cache"));
463        // Per-extension immutable rules now exist for the wider asset set.
464        assert!(body.contains("/*.png"));
465        assert!(body.contains("/*.woff2"));
466    }
467
468    #[test]
469    fn generate_netlify_toml_contains_build_publish_directive() {
470        let dir = tempdir().expect("tempdir");
471        generate_netlify(dir.path()).expect("generate netlify");
472
473        let toml = fs::read_to_string(dir.path().join("netlify.toml"))
474            .expect("read netlify.toml");
475        assert!(toml.contains("[build]"));
476        assert!(toml.contains("publish"));
477        assert!(toml.contains("[[headers]]"));
478    }
479
480    #[test]
481    fn generate_netlify_creates_empty_redirects_file() {
482        let dir = tempdir().expect("tempdir");
483        generate_netlify(dir.path()).expect("generate netlify");
484
485        let redirects = fs::read_to_string(dir.path().join("_redirects"))
486            .expect("read _redirects");
487        assert!(redirects.is_empty(), "_redirects starts empty by design");
488    }
489
490    #[test]
491    fn generate_vercel_json_contains_every_security_header_value() {
492        let dir = tempdir().expect("tempdir");
493        generate_vercel(dir.path()).expect("generate vercel");
494
495        let json = fs::read_to_string(dir.path().join("vercel.json"))
496            .expect("read vercel.json");
497        assert_all_security_headers_present(&json);
498    }
499
500    #[test]
501    fn generate_vercel_json_has_asset_cache_route() {
502        let dir = tempdir().expect("tempdir");
503        generate_vercel(dir.path()).expect("generate vercel");
504
505        let raw = fs::read_to_string(dir.path().join("vercel.json"))
506            .expect("read vercel.json");
507        let parsed: serde_json::Value =
508            serde_json::from_str(&raw).expect("valid JSON");
509
510        let routes = parsed["headers"].as_array().expect("headers is an array");
511        let sources: Vec<&str> =
512            routes.iter().filter_map(|r| r["source"].as_str()).collect();
513        assert!(sources.iter().any(|s| s.contains("/assets/")));
514        assert!(sources.iter().any(|s| s.contains("/(.*)")));
515    }
516
517    #[test]
518    fn generate_cloudflare_headers_file_uses_colon_separator() {
519        // Cloudflare's _headers syntax differs from Netlify's: it
520        // uses `Key: Value` rather than `Key = Value`. Guard the
521        // separator to prevent silent regressions.
522        let dir = tempdir().expect("tempdir");
523        generate_cloudflare(dir.path()).expect("generate cloudflare");
524
525        let body = fs::read_to_string(dir.path().join("_headers"))
526            .expect("read _headers");
527        assert!(body.contains("X-Content-Type-Options : nosniff"));
528        assert_all_security_headers_present(&body);
529    }
530
531    #[test]
532    fn generate_cloudflare_writes_empty_redirects_file() {
533        let dir = tempdir().expect("tempdir");
534        generate_cloudflare(dir.path()).expect("generate cloudflare");
535
536        let redirects = fs::read_to_string(dir.path().join("_redirects"))
537            .expect("read _redirects");
538        assert!(redirects.is_empty());
539    }
540
541    #[test]
542    fn generate_github_pages_writes_empty_nojekyll_marker() {
543        let dir = tempdir().expect("tempdir");
544        generate_github_pages(dir.path()).expect("generate github pages");
545
546        let nojekyll = dir.path().join(".nojekyll");
547        assert!(nojekyll.exists());
548        let contents = fs::read_to_string(&nojekyll).expect("read .nojekyll");
549        assert!(
550            contents.is_empty(),
551            ".nojekyll is a marker file and must be empty"
552        );
553    }
554
555    // -------------------------------------------------------------------
556    // Idempotency — running the plugin twice must succeed
557    // -------------------------------------------------------------------
558
559    #[test]
560    fn after_compile_idempotent_for_every_target() {
561        // Re-running after_compile must not fail (file overwrite, not
562        // append). Guards against any future use of `OpenOptions::new
563        // ().create_new(true)` that would break re-builds.
564        for target in [
565            DeployTarget::Netlify,
566            DeployTarget::Vercel,
567            DeployTarget::CloudflarePages,
568            DeployTarget::GithubPages,
569        ] {
570            let (_tmp, _site, ctx) = make_ctx_with_site();
571            let plugin = DeployPlugin::new(target);
572            plugin
573                .after_compile(&ctx)
574                .unwrap_or_else(|e| panic!("first {target:?}: {e}"));
575            plugin
576                .after_compile(&ctx)
577                .unwrap_or_else(|e| panic!("second {target:?}: {e}"));
578        }
579    }
580
581    // -------------------------------------------------------------------
582    // Generator error paths — writing into a non-existent parent dir
583    // -------------------------------------------------------------------
584
585    #[test]
586    fn generate_netlify_into_missing_parent_returns_err() {
587        let bogus = Path::new("/this/path/should/not/exist/ssg-test");
588        let result = generate_netlify(bogus);
589        assert!(
590            result.is_err(),
591            "writing into a non-existent parent must error"
592        );
593    }
594
595    #[test]
596    fn generate_vercel_into_missing_parent_returns_err() {
597        let bogus = Path::new("/this/path/should/not/exist/ssg-test");
598        assert!(generate_vercel(bogus).is_err());
599    }
600
601    #[test]
602    fn generate_cloudflare_into_missing_parent_returns_err() {
603        let bogus = Path::new("/this/path/should/not/exist/ssg-test");
604        assert!(generate_cloudflare(bogus).is_err());
605    }
606
607    #[test]
608    fn generate_github_pages_into_missing_parent_returns_err() {
609        let bogus = Path::new("/this/path/should/not/exist/ssg-test");
610        assert!(generate_github_pages(bogus).is_err());
611    }
612}