1use crate::plugin::{Plugin, PluginContext};
10use anyhow::Result;
11use std::fs;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19#[non_exhaustive]
20pub enum DeployTarget {
21 Netlify,
23 Vercel,
25 CloudflarePages,
27 GithubPages,
29}
30
31#[derive(Debug, Clone, Copy)]
33pub struct DeployPlugin {
34 target: DeployTarget,
35}
36
37impl DeployPlugin {
38 #[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
71const 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 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 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 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 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 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 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 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 #[test]
260 fn deploy_target_equality_reflexive_for_each_variant() {
261 let variants = [
263 DeployTarget::Netlify,
264 DeployTarget::Vercel,
265 DeployTarget::CloudflarePages,
266 DeployTarget::GithubPages,
267 ];
268
269 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 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 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 #[test]
310 fn new_constructs_plugin_for_every_target_variant() {
311 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 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 #[test]
348 fn after_compile_missing_site_dir_returns_ok_without_writing() {
349 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 assert!(!missing_site.exists());
367 assert!(!dir.path().join("_headers").exists());
368 assert!(!dir.path().join("netlify.toml").exists());
369 }
370
371 #[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 assert!(!site.join("_headers").exists());
430 assert!(!site.join("netlify.toml").exists());
431 assert!(!site.join("vercel.json").exists());
432 }
433
434 #[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 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 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 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 #[test]
560 fn after_compile_idempotent_for_every_target() {
561 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 #[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}