1use crate::error::SsgError;
10use crate::plugin::{Plugin, PluginContext};
11use anyhow::Result;
12use std::fs;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum DeployTarget {
22 Netlify,
24 Vercel,
26 CloudflarePages,
28 GithubPages,
30}
31
32#[derive(Debug, Clone, Copy)]
34pub struct DeployPlugin {
35 target: DeployTarget,
36}
37
38impl DeployPlugin {
39 #[must_use]
51 pub const fn new(target: DeployTarget) -> Self {
52 Self { target }
53 }
54}
55
56impl Plugin for DeployPlugin {
57 fn name(&self) -> &'static str {
58 "deploy"
59 }
60
61 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
62 if !ctx.site_dir.exists() {
63 return Ok(());
64 }
65
66 match self.target {
67 DeployTarget::Netlify => generate_netlify(&ctx.site_dir)
68 .map_err(|e| SsgError::io(e, &ctx.site_dir))?,
69 DeployTarget::Vercel => generate_vercel(&ctx.site_dir)
70 .map_err(|e| SsgError::io(e, &ctx.site_dir))?,
71 DeployTarget::CloudflarePages => {
72 generate_cloudflare(&ctx.site_dir)
73 .map_err(|e| SsgError::io(e, &ctx.site_dir))?;
74 }
75 DeployTarget::GithubPages => {
76 generate_github_pages(&ctx.site_dir)
77 .map_err(|e| SsgError::io(e, &ctx.site_dir))?;
78 }
79 }
80
81 log::info!("[deploy] Generated {:?} config", self.target);
82 Ok(())
83 }
84}
85
86const fn security_headers() -> [(&'static str, &'static str); 7] {
94 [
95 ("X-Content-Type-Options", "nosniff"),
96 ("X-Frame-Options", "DENY"),
97 ("X-XSS-Protection", "1; mode=block"),
98 ("Referrer-Policy", "strict-origin-when-cross-origin"),
99 (
100 "Permissions-Policy",
101 "camera=(), microphone=(), geolocation=()",
102 ),
103 ("Content-Security-Policy", crate::csp::computed_policy()),
104 (
105 "Strict-Transport-Security",
106 "max-age=31536000; includeSubDomains",
107 ),
108 ]
109}
110
111fn generate_netlify(site_dir: &std::path::Path) -> Result<()> {
112 let mut headers = String::from("/*\n");
113 for (k, v) in security_headers() {
114 headers.push_str(&format!(" {k} = {v}\n"));
115 }
116 headers.push_str(
121 "\n/assets/*\n Cache-Control: public, max-age=31536000, immutable\n",
122 );
123 for ext in [
124 "css", "js", "mjs", "png", "jpg", "jpeg", "webp", "avif", "gif", "svg",
125 "woff", "woff2",
126 ] {
127 headers.push_str(&format!(
128 "\n/*.{ext}\n Cache-Control: public, max-age=31536000, immutable\n"
129 ));
130 }
131 headers.push_str("\n/*.html\n Cache-Control: no-cache, must-revalidate\n");
134 for path in [
135 "/sitemap.xml",
136 "/sitemap-news.xml",
137 "/atom.xml",
138 "/rss.xml",
139 "/manifest.json",
140 "/robots.txt",
141 "/search-index.json",
142 ] {
143 headers.push_str(&format!(
144 "\n{path}\n Cache-Control: no-cache, must-revalidate\n"
145 ));
146 }
147
148 fs::write(site_dir.join("_headers"), &headers)?;
149 fs::write(site_dir.join("_redirects"), "")?;
150
151 let toml = r#"[build]
152 publish = "public"
153 command = "cargo run -- -c content -o public -t templates"
154
155[[headers]]
156 for = "/assets/*"
157 [headers.values]
158 Cache-Control = "public, max-age=31536000, immutable"
159"#;
160 fs::write(site_dir.join("netlify.toml"), toml)?;
161 Ok(())
162}
163
164fn generate_vercel(site_dir: &std::path::Path) -> Result<()> {
165 let mut headers_arr = Vec::new();
166 for (k, v) in security_headers() {
167 headers_arr.push(serde_json::json!({"key": k, "value": v}));
168 }
169
170 let immutable = serde_json::json!([
174 {"key": "Cache-Control", "value": "public, max-age=31536000, immutable"}
175 ]);
176 let no_cache = serde_json::json!([
177 {"key": "Cache-Control", "value": "no-cache, must-revalidate"}
178 ]);
179
180 let config = serde_json::json!({
181 "headers": [
182 {"source": "/(.*)", "headers": headers_arr},
183 {"source": "/assets/(.*)", "headers": immutable},
184 {"source": "/(.*)\\.(css|js|mjs|png|jpg|jpeg|webp|avif|gif|svg|woff|woff2)",
185 "headers": immutable},
186 {"source": "/(.*)\\.html", "headers": no_cache},
187 {"source": "/(sitemap|sitemap-news|atom|rss)\\.xml",
188 "headers": no_cache},
189 {"source": "/(manifest|search-index)\\.json", "headers": no_cache},
190 {"source": "/robots\\.txt", "headers": no_cache}
191 ]
192 });
193
194 let json = serde_json::to_string_pretty(&config)?;
195 fs::write(site_dir.join("vercel.json"), json)?;
196 Ok(())
197}
198
199fn generate_cloudflare(site_dir: &std::path::Path) -> Result<()> {
200 let mut headers = String::from("/*\n");
201 for (k, v) in security_headers() {
202 headers.push_str(&format!(" {k} : {v}\n"));
203 }
204 headers.push_str(
206 "\n/assets/*\n Cache-Control: public, max-age=31536000, immutable\n",
207 );
208 for ext in [
209 "css", "js", "mjs", "png", "jpg", "jpeg", "webp", "avif", "gif", "svg",
210 "woff", "woff2",
211 ] {
212 headers.push_str(&format!(
213 "\n/*.{ext}\n Cache-Control: public, max-age=31536000, immutable\n"
214 ));
215 }
216 headers.push_str("\n/*.html\n Cache-Control: no-cache, must-revalidate\n");
217 for path in [
218 "/sitemap.xml",
219 "/atom.xml",
220 "/rss.xml",
221 "/manifest.json",
222 "/robots.txt",
223 ] {
224 headers.push_str(&format!(
225 "\n{path}\n Cache-Control: no-cache, must-revalidate\n"
226 ));
227 }
228
229 fs::write(site_dir.join("_headers"), &headers)?;
230 fs::write(site_dir.join("_redirects"), "")?;
231 Ok(())
232}
233
234fn generate_github_pages(site_dir: &std::path::Path) -> Result<()> {
235 fs::write(site_dir.join(".nojekyll"), "")?;
237 Ok(())
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use crate::test_support::init_logger;
244 use std::path::{Path, PathBuf};
245 use tempfile::{tempdir, TempDir};
246
247 fn make_ctx_with_site() -> (TempDir, PathBuf, PluginContext) {
254 init_logger();
255 let dir = tempdir().expect("create tempdir");
256 let site = dir.path().join("site");
257 fs::create_dir_all(&site).expect("create site dir");
258 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
259 (dir, site, ctx)
260 }
261
262 fn assert_all_security_headers_present(body: &str) {
265 for (k, v) in security_headers() {
266 assert!(
267 body.contains(k),
268 "missing header key `{k}` in body:\n{body}"
269 );
270 assert!(
271 body.contains(v),
272 "missing header value `{v}` in body:\n{body}"
273 );
274 }
275 }
276
277 #[test]
282 fn deploy_target_equality_reflexive_for_each_variant() {
283 let variants = [
285 DeployTarget::Netlify,
286 DeployTarget::Vercel,
287 DeployTarget::CloudflarePages,
288 DeployTarget::GithubPages,
289 ];
290
291 for v in variants {
293 assert_eq!(v, v, "{v:?} should equal itself");
294 }
295 }
296
297 #[test]
298 fn deploy_target_distinct_variants_are_not_equal() {
299 assert_ne!(DeployTarget::Netlify, DeployTarget::Vercel);
302 assert_ne!(DeployTarget::Vercel, DeployTarget::CloudflarePages);
303 assert_ne!(DeployTarget::CloudflarePages, DeployTarget::GithubPages);
304 assert_ne!(DeployTarget::GithubPages, DeployTarget::Netlify);
305 }
306
307 #[test]
308 fn deploy_target_every_unordered_pair_is_not_equal() {
309 let variants = [
315 DeployTarget::Netlify,
316 DeployTarget::Vercel,
317 DeployTarget::CloudflarePages,
318 DeployTarget::GithubPages,
319 ];
320 for (i, a) in variants.iter().enumerate() {
321 for b in &variants[i + 1..] {
322 assert_ne!(a, b, "{a:?} should not equal {b:?}");
323 }
324 }
325 }
326
327 #[test]
328 fn deploy_target_is_copy_after_move() {
329 let target = DeployTarget::Netlify;
332 let _copy = target;
333 assert_eq!(target, DeployTarget::Netlify);
334 }
335
336 #[test]
337 fn deploy_target_debug_format_contains_variant_name() {
338 assert!(format!("{:?}", DeployTarget::Netlify).contains("Netlify"));
339 assert!(format!("{:?}", DeployTarget::Vercel).contains("Vercel"));
340 assert!(format!("{:?}", DeployTarget::CloudflarePages)
341 .contains("CloudflarePages"));
342 assert!(
343 format!("{:?}", DeployTarget::GithubPages).contains("GithubPages")
344 );
345 }
346
347 #[test]
352 fn new_constructs_plugin_for_every_target_variant() {
353 let cases = [
356 DeployTarget::Netlify,
357 DeployTarget::Vercel,
358 DeployTarget::CloudflarePages,
359 DeployTarget::GithubPages,
360 ];
361 for target in cases {
362 let plugin = DeployPlugin::new(target);
363 assert_eq!(
364 plugin.target, target,
365 "constructor must store the supplied target"
366 );
367 }
368 }
369
370 #[test]
371 fn name_returns_static_deploy_identifier() {
372 let plugin = DeployPlugin::new(DeployTarget::Netlify);
375 assert_eq!(plugin.name(), "deploy");
376 }
377
378 #[test]
379 fn deploy_plugin_is_copy_after_move() {
380 let plugin = DeployPlugin::new(DeployTarget::Vercel);
381 let _copy = plugin;
382 assert_eq!(plugin.name(), "deploy");
383 }
384
385 #[test]
386 fn deploy_plugin_debug_format_contains_type_name() {
387 let plugin = DeployPlugin::new(DeployTarget::CloudflarePages);
390 let formatted = format!("{plugin:?}");
391 assert!(formatted.contains("DeployPlugin"), "got: {formatted}");
392 assert!(formatted.contains("CloudflarePages"), "got: {formatted}");
393 }
394
395 #[test]
400 fn after_compile_missing_site_dir_returns_ok_without_writing() {
401 let dir = tempdir().expect("tempdir");
404 let missing_site = dir.path().join("does-not-exist");
405 let ctx = PluginContext::new(
406 dir.path(),
407 dir.path(),
408 &missing_site,
409 dir.path(),
410 );
411
412 let plugin = DeployPlugin::new(DeployTarget::Netlify);
413 plugin
414 .after_compile(&ctx)
415 .expect("missing site_dir is not an error");
416
417 assert!(!missing_site.exists());
419 assert!(!dir.path().join("_headers").exists());
420 assert!(!dir.path().join("netlify.toml").exists());
421 }
422
423 #[test]
428 fn after_compile_netlify_writes_all_expected_artifacts() {
429 let (_tmp, site, ctx) = make_ctx_with_site();
430 DeployPlugin::new(DeployTarget::Netlify)
431 .after_compile(&ctx)
432 .expect("netlify after_compile");
433
434 for f in ["_headers", "_redirects", "netlify.toml"] {
435 assert!(
436 site.join(f).exists(),
437 "Netlify dispatch must produce `{f}`"
438 );
439 }
440 }
441
442 #[test]
443 fn after_compile_vercel_writes_well_formed_json() {
444 let (_tmp, site, ctx) = make_ctx_with_site();
445 DeployPlugin::new(DeployTarget::Vercel)
446 .after_compile(&ctx)
447 .expect("vercel after_compile");
448
449 let raw = fs::read_to_string(site.join("vercel.json"))
450 .expect("vercel.json should exist");
451 let parsed: serde_json::Value =
452 serde_json::from_str(&raw).expect("vercel.json must be valid JSON");
453 assert!(
454 parsed.get("headers").and_then(|v| v.as_array()).is_some(),
455 "vercel.json must have a top-level `headers` array"
456 );
457 }
458
459 #[test]
460 fn after_compile_cloudflare_writes_headers_and_redirects() {
461 let (_tmp, site, ctx) = make_ctx_with_site();
462 DeployPlugin::new(DeployTarget::CloudflarePages)
463 .after_compile(&ctx)
464 .expect("cloudflare after_compile");
465
466 assert!(site.join("_headers").exists());
467 assert!(site.join("_redirects").exists());
468 }
469
470 #[test]
471 fn after_compile_github_pages_writes_only_nojekyll() {
472 let (_tmp, site, ctx) = make_ctx_with_site();
473 DeployPlugin::new(DeployTarget::GithubPages)
474 .after_compile(&ctx)
475 .expect("github pages after_compile");
476
477 assert!(site.join(".nojekyll").exists());
478 assert!(!site.join("_headers").exists());
482 assert!(!site.join("netlify.toml").exists());
483 assert!(!site.join("vercel.json").exists());
484 }
485
486 #[test]
491 fn generate_netlify_headers_file_contains_every_security_header() {
492 let dir = tempdir().expect("tempdir");
493 generate_netlify(dir.path()).expect("generate netlify");
494
495 let body = fs::read_to_string(dir.path().join("_headers"))
496 .expect("read _headers");
497 assert_all_security_headers_present(&body);
498 }
499
500 #[test]
501 fn generate_netlify_headers_file_contains_cache_directives() {
502 let dir = tempdir().expect("tempdir");
503 generate_netlify(dir.path()).expect("generate netlify");
504
505 let body = fs::read_to_string(dir.path().join("_headers"))
506 .expect("read _headers");
507 assert!(body.contains("/assets/*"));
511 assert!(body.contains("max-age=31536000"));
512 assert!(body.contains("immutable"));
513 assert!(body.contains("/*.html"));
514 assert!(body.contains("no-cache"));
515 assert!(body.contains("/*.png"));
517 assert!(body.contains("/*.woff2"));
518 }
519
520 #[test]
521 fn generate_netlify_toml_contains_build_publish_directive() {
522 let dir = tempdir().expect("tempdir");
523 generate_netlify(dir.path()).expect("generate netlify");
524
525 let toml = fs::read_to_string(dir.path().join("netlify.toml"))
526 .expect("read netlify.toml");
527 assert!(toml.contains("[build]"));
528 assert!(toml.contains("publish"));
529 assert!(toml.contains("[[headers]]"));
530 }
531
532 #[test]
533 fn generate_netlify_creates_empty_redirects_file() {
534 let dir = tempdir().expect("tempdir");
535 generate_netlify(dir.path()).expect("generate netlify");
536
537 let redirects = fs::read_to_string(dir.path().join("_redirects"))
538 .expect("read _redirects");
539 assert!(redirects.is_empty(), "_redirects starts empty by design");
540 }
541
542 #[test]
543 fn generate_vercel_json_contains_every_security_header_value() {
544 let dir = tempdir().expect("tempdir");
545 generate_vercel(dir.path()).expect("generate vercel");
546
547 let json = fs::read_to_string(dir.path().join("vercel.json"))
548 .expect("read vercel.json");
549 assert_all_security_headers_present(&json);
550 }
551
552 #[test]
553 fn generate_vercel_json_has_asset_cache_route() {
554 let dir = tempdir().expect("tempdir");
555 generate_vercel(dir.path()).expect("generate vercel");
556
557 let raw = fs::read_to_string(dir.path().join("vercel.json"))
558 .expect("read vercel.json");
559 let parsed: serde_json::Value =
560 serde_json::from_str(&raw).expect("valid JSON");
561
562 let routes = parsed["headers"].as_array().expect("headers is an array");
563 let sources: Vec<&str> =
564 routes.iter().filter_map(|r| r["source"].as_str()).collect();
565 assert!(sources.iter().any(|s| s.contains("/assets/")));
566 assert!(sources.iter().any(|s| s.contains("/(.*)")));
567 }
568
569 #[test]
570 fn generate_cloudflare_headers_file_uses_colon_separator() {
571 let dir = tempdir().expect("tempdir");
575 generate_cloudflare(dir.path()).expect("generate cloudflare");
576
577 let body = fs::read_to_string(dir.path().join("_headers"))
578 .expect("read _headers");
579 assert!(body.contains("X-Content-Type-Options : nosniff"));
580 assert_all_security_headers_present(&body);
581 }
582
583 #[test]
584 fn generate_cloudflare_writes_empty_redirects_file() {
585 let dir = tempdir().expect("tempdir");
586 generate_cloudflare(dir.path()).expect("generate cloudflare");
587
588 let redirects = fs::read_to_string(dir.path().join("_redirects"))
589 .expect("read _redirects");
590 assert!(redirects.is_empty());
591 }
592
593 #[test]
594 fn generate_github_pages_writes_empty_nojekyll_marker() {
595 let dir = tempdir().expect("tempdir");
596 generate_github_pages(dir.path()).expect("generate github pages");
597
598 let nojekyll = dir.path().join(".nojekyll");
599 assert!(nojekyll.exists());
600 let contents = fs::read_to_string(&nojekyll).expect("read .nojekyll");
601 assert!(
602 contents.is_empty(),
603 ".nojekyll is a marker file and must be empty"
604 );
605 }
606
607 #[test]
612 fn after_compile_idempotent_for_every_target() {
613 for target in [
617 DeployTarget::Netlify,
618 DeployTarget::Vercel,
619 DeployTarget::CloudflarePages,
620 DeployTarget::GithubPages,
621 ] {
622 let (_tmp, _site, ctx) = make_ctx_with_site();
623 let plugin = DeployPlugin::new(target);
624 plugin
625 .after_compile(&ctx)
626 .expect("first after_compile for target should succeed");
627 plugin
628 .after_compile(&ctx)
629 .expect("second after_compile for target should succeed");
630 }
631 }
632
633 #[test]
638 fn generate_netlify_into_missing_parent_returns_err() {
639 let bogus = Path::new("/this/path/should/not/exist/ssg-test");
640 let result = generate_netlify(bogus);
641 assert!(
642 result.is_err(),
643 "writing into a non-existent parent must error"
644 );
645 }
646
647 #[test]
648 fn generate_vercel_into_missing_parent_returns_err() {
649 let bogus = Path::new("/this/path/should/not/exist/ssg-test");
650 assert!(generate_vercel(bogus).is_err());
651 }
652
653 #[test]
654 fn generate_cloudflare_into_missing_parent_returns_err() {
655 let bogus = Path::new("/this/path/should/not/exist/ssg-test");
656 assert!(generate_cloudflare(bogus).is_err());
657 }
658
659 #[test]
660 fn generate_github_pages_into_missing_parent_returns_err() {
661 let bogus = Path::new("/this/path/should/not/exist/ssg-test");
662 assert!(generate_github_pages(bogus).is_err());
663 }
664
665 #[test]
671 #[cfg(unix)]
672 fn after_compile_maps_generator_errors_for_every_target() {
673 use std::os::unix::fs::PermissionsExt;
674 for target in [
675 DeployTarget::Netlify,
676 DeployTarget::Vercel,
677 DeployTarget::CloudflarePages,
678 DeployTarget::GithubPages,
679 ] {
680 let (_tmp, site, ctx) = make_ctx_with_site();
681 fs::set_permissions(&site, fs::Permissions::from_mode(0o555))
685 .expect("chmod site dir");
686
687 let res = DeployPlugin::new(target).after_compile(&ctx);
688
689 let _ =
690 fs::set_permissions(&site, fs::Permissions::from_mode(0o755));
691 assert!(
695 res.err().is_none_or(|e| !format!("{e}").is_empty()),
696 "{target:?}"
697 );
698 }
699 }
700
701 #[test]
706 fn generate_netlify_redirects_write_failure_propagates() {
707 let dir = tempdir().expect("tempdir");
710 fs::create_dir_all(dir.path().join("_redirects")).unwrap();
711 assert!(generate_netlify(dir.path()).is_err());
712 }
713
714 #[test]
715 fn generate_netlify_toml_write_failure_propagates() {
716 let dir = tempdir().expect("tempdir");
719 fs::create_dir_all(dir.path().join("netlify.toml")).unwrap();
720 assert!(generate_netlify(dir.path()).is_err());
721 }
722
723 #[test]
724 fn generate_cloudflare_redirects_write_failure_propagates() {
725 let dir = tempdir().expect("tempdir");
727 fs::create_dir_all(dir.path().join("_redirects")).unwrap();
728 assert!(generate_cloudflare(dir.path()).is_err());
729 }
730}