1use crate::plugin::{Plugin, PluginContext};
13use anyhow::{Context, Result};
14use rayon::prelude::*;
15use std::fs;
16use std::sync::atomic::{AtomicUsize, Ordering};
17
18#[derive(Debug, Copy, Clone)]
33pub struct MinifyPlugin;
34
35impl Plugin for MinifyPlugin {
36 fn name(&self) -> &'static str {
37 "minify"
38 }
39
40 fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
41 if !ctx.site_dir.exists() {
42 return Ok(());
43 }
44
45 let cache = ctx.cache.as_ref();
46
47 let html_files: Vec<_> = fs::read_dir(&ctx.site_dir)?
49 .filter_map(std::result::Result::ok)
50 .map(|e| e.path())
51 .filter(|p| p.extension().is_some_and(|e| e == "html"))
52 .filter(|p| cache.is_none_or(|c| c.has_changed(p)))
53 .collect();
54
55 let count = AtomicUsize::new(0);
56
57 html_files.par_iter().try_for_each(|path| -> Result<()> {
58 fail_point!("plugins::minify-read", |_| {
59 anyhow::bail!("injected: plugins::minify-read")
60 });
61 let content = fs::read_to_string(path).with_context(|| {
62 format!("Failed to read {}", path.display())
63 })?;
64 let minified = minify_html(&content);
65 fail_point!("plugins::minify-write", |_| {
66 anyhow::bail!("injected: plugins::minify-write")
67 });
68 fs::write(path, &minified).with_context(|| {
69 format!("Failed to write {}", path.display())
70 })?;
71 let _ = count.fetch_add(1, Ordering::Relaxed);
72 Ok(())
73 })?;
74
75 let total = count.load(Ordering::Relaxed);
76 if total > 0 {
77 println!("[minify] Processed {total} HTML files");
78 }
79 Ok(())
80 }
81}
82
83fn minify_html(html: &str) -> String {
88 if html.contains("<pre") {
90 return html.to_string();
91 }
92
93 let mut result = String::with_capacity(html.len());
94 let mut in_whitespace = false;
95 for ch in html.chars() {
96 if ch.is_whitespace() {
97 if !in_whitespace {
98 result.push(' ');
99 in_whitespace = true;
100 }
101 } else {
102 in_whitespace = false;
103 result.push(ch);
104 }
105 }
106 result
107}
108
109#[derive(Debug, Copy, Clone)]
124pub struct ImageOptiPlugin;
125
126impl Plugin for ImageOptiPlugin {
127 fn name(&self) -> &'static str {
128 "image-opti"
129 }
130
131 fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
132 if !ctx.site_dir.exists() {
133 return Ok(());
134 }
135 let mut images = Vec::new();
136 for entry in fs::read_dir(&ctx.site_dir)? {
137 let path = entry?.path();
138 if let Some(ext) = path.extension() {
139 let ext = ext.to_string_lossy().to_lowercase();
140 if matches!(
141 ext.as_str(),
142 "png" | "jpg" | "jpeg" | "gif" | "bmp"
143 ) {
144 images.push(path);
145 }
146 }
147 }
148 if !images.is_empty() {
149 println!(
150 "[image-opti] Found {} images for optimization",
151 images.len()
152 );
153 }
154 Ok(())
155 }
156}
157
158#[derive(Debug)]
173pub struct DeployPlugin {
174 target: String,
175}
176
177impl DeployPlugin {
178 #[must_use]
180 pub fn new(target: &str) -> Self {
181 Self {
182 target: target.to_string(),
183 }
184 }
185}
186
187impl Plugin for DeployPlugin {
188 fn name(&self) -> &'static str {
189 "deploy"
190 }
191
192 fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
193 println!(
194 "[deploy] Site at {} ready for deployment to '{}'",
195 ctx.site_dir.display(),
196 self.target
197 );
198 Ok(())
199 }
200}
201
202#[cfg(test)]
203#[allow(clippy::unwrap_used, clippy::expect_used)]
204mod tests {
205 use super::*;
206 use crate::plugin::PluginContext;
207 use crate::test_support::init_logger;
208 use std::path::Path;
209 use tempfile::tempdir;
210
211 fn test_ctx_with(site_dir: &Path) -> PluginContext {
212 init_logger();
213 PluginContext::new(
214 Path::new("content"),
215 Path::new("build"),
216 site_dir,
217 Path::new("templates"),
218 )
219 }
220
221 #[test]
222 fn test_minify_plugin_name() {
223 assert_eq!(MinifyPlugin.name(), "minify");
224 }
225
226 #[test]
227 fn test_minify_plugin_empty_dir() -> Result<()> {
228 let temp = tempdir()?;
229 let ctx = test_ctx_with(temp.path());
230 MinifyPlugin.after_compile(&ctx)?;
231 Ok(())
232 }
233
234 #[test]
235 fn test_minify_plugin_processes_html() -> Result<()> {
236 let temp = tempdir()?;
237 let html_path = temp.path().join("index.html");
238 fs::write(&html_path, "<h1> Hello World </h1>")?;
239
240 let ctx = test_ctx_with(temp.path());
241 MinifyPlugin.after_compile(&ctx)?;
242
243 let content = fs::read_to_string(&html_path)?;
244 assert!(!content.contains(" "));
245 Ok(())
246 }
247
248 #[test]
249 fn test_minify_plugin_skips_non_html() -> Result<()> {
250 let temp = tempdir()?;
251 let css_path = temp.path().join("style.css");
252 fs::write(&css_path, "body { color: red; }")?;
253
254 let ctx = test_ctx_with(temp.path());
255 MinifyPlugin.after_compile(&ctx)?;
256
257 let content = fs::read_to_string(&css_path)?;
259 assert!(content.contains(" "));
260 Ok(())
261 }
262
263 #[test]
264 fn test_minify_plugin_nonexistent_dir() -> Result<()> {
265 let ctx = test_ctx_with(Path::new("/nonexistent"));
266 MinifyPlugin.after_compile(&ctx)?;
267 Ok(())
268 }
269
270 #[test]
271 fn test_minify_html_collapses_whitespace() {
272 let result = minify_html("<p> Hello World </p>");
273 assert_eq!(result, "<p> Hello World </p>");
274 }
275
276 #[test]
277 fn test_minify_html_preserves_pre() {
278 let input = "<pre> keep spaces </pre>";
279 let result = minify_html(input);
280 assert_eq!(result, input);
281 }
282
283 #[test]
284 fn test_image_opti_plugin_name() {
285 assert_eq!(ImageOptiPlugin.name(), "image-opti");
286 }
287
288 #[test]
289 fn test_image_opti_plugin_finds_images() -> Result<()> {
290 let temp = tempdir()?;
291 fs::write(temp.path().join("photo.png"), "PNG")?;
292 fs::write(temp.path().join("logo.jpg"), "JPG")?;
293 fs::write(temp.path().join("style.css"), "CSS")?;
294
295 let ctx = test_ctx_with(temp.path());
296 ImageOptiPlugin.after_compile(&ctx)?;
297 Ok(())
298 }
299
300 #[test]
301 fn test_image_opti_plugin_nonexistent_dir() -> Result<()> {
302 let ctx = test_ctx_with(Path::new("/nonexistent"));
303 ImageOptiPlugin.after_compile(&ctx)?;
304 Ok(())
305 }
306
307 #[test]
308 fn test_deploy_plugin_name() {
309 let p = DeployPlugin::new("staging");
310 assert_eq!(p.name(), "deploy");
311 }
312
313 #[test]
314 fn test_deploy_plugin_prints_target() -> Result<()> {
315 let temp = tempdir()?;
316 let ctx = test_ctx_with(temp.path());
317 let p = DeployPlugin::new("production");
318 p.after_compile(&ctx)?;
319 Ok(())
320 }
321
322 #[test]
323 fn test_all_plugins_register() {
324 use crate::plugin::PluginManager;
325 let mut pm = PluginManager::new();
326 pm.register(MinifyPlugin);
327 pm.register(ImageOptiPlugin);
328 pm.register(DeployPlugin::new("test"));
329 assert_eq!(pm.len(), 3);
330 assert_eq!(pm.names(), vec!["minify", "image-opti", "deploy"]);
331 }
332
333 #[test]
334 fn minify_plugin_preserves_pre_blocks() {
335 let input = "<pre> code with spaces </pre><p> other </p>";
337
338 let result = minify_html(input);
340
341 assert_eq!(result, input);
343 }
344
345 #[test]
346 fn minify_plugin_handles_nested_html() {
347 let input = "<div> <section> <article> <p> deep </p> </article> </section> </div>";
349
350 let result = minify_html(input);
352
353 assert!(!result.contains(" "));
355 assert!(result.contains("<div>"));
356 assert!(result.contains("</div>"));
357 assert!(result.contains("deep"));
358 }
359
360 #[test]
361 fn minify_plugin_empty_html_file() -> Result<()> {
362 let temp = tempdir()?;
364 let html_path = temp.path().join("empty.html");
365 fs::write(&html_path, "")?;
366
367 let ctx = test_ctx_with(temp.path());
369 MinifyPlugin.after_compile(&ctx)?;
370
371 let content = fs::read_to_string(&html_path)?;
373 assert!(content.is_empty());
374 Ok(())
375 }
376
377 #[test]
378 fn image_opti_plugin_finds_jpeg_variants() -> Result<()> {
379 let temp = tempdir()?;
381 fs::write(temp.path().join("photo.jpg"), "JPG")?;
382 fs::write(temp.path().join("banner.jpeg"), "JPEG")?;
383 fs::write(temp.path().join("readme.txt"), "text")?;
384
385 let ctx = test_ctx_with(temp.path());
387 ImageOptiPlugin.after_compile(&ctx)?;
388
389 let mut found = Vec::new();
392 for entry in fs::read_dir(temp.path())? {
393 let path = entry?.path();
394 if let Some(ext) = path.extension() {
395 let ext = ext.to_string_lossy().to_lowercase();
396 if matches!(ext.as_str(), "jpg" | "jpeg") {
397 found.push(path);
398 }
399 }
400 }
401 assert_eq!(found.len(), 2);
402 Ok(())
403 }
404
405 #[test]
406 fn image_opti_plugin_nested_directories() -> Result<()> {
407 let temp = tempdir()?;
409 let subdir = temp.path().join("subdir");
410 fs::create_dir(&subdir)?;
411 fs::write(subdir.join("deep.png"), "PNG")?;
412 fs::write(temp.path().join("top.png"), "PNG")?;
413
414 let ctx = test_ctx_with(temp.path());
416 ImageOptiPlugin.after_compile(&ctx)?;
417
418 Ok(())
421 }
422
423 #[test]
424 fn deploy_plugin_custom_target() -> Result<()> {
425 let temp = tempdir()?;
427 let ctx = test_ctx_with(temp.path());
428 let target_name = "staging-eu-west-1";
429 let plugin = DeployPlugin::new(target_name);
430
431 plugin.after_compile(&ctx)?;
433
434 assert_eq!(plugin.target, target_name);
436 Ok(())
437 }
438
439 #[test]
440 fn minify_plugin_nonexistent_dir_returns_ok() -> Result<()> {
441 let ctx = test_ctx_with(Path::new("/this/path/does/not/exist/at/all"));
443
444 assert!(MinifyPlugin.after_compile(&ctx).is_ok());
446 Ok(())
447 }
448
449 #[test]
454 fn minify_html_empty_string() {
455 let result = minify_html("");
456 assert_eq!(result, "");
457 }
458
459 #[test]
460 fn minify_html_whitespace_only() {
461 let result = minify_html(" \n\t \n ");
462 assert_eq!(result, " ");
463 }
464
465 #[test]
466 fn minify_html_no_whitespace() {
467 let input = "<p>hello</p>";
468 let result = minify_html(input);
469 assert_eq!(result, input);
470 }
471
472 #[test]
473 fn minify_html_preserves_pre_with_class() {
474 let input = "<pre class=\"lang-rust\"> fn main() { } </pre>";
475 let result = minify_html(input);
476 assert_eq!(result, input);
477 }
478
479 #[test]
480 fn minify_html_tabs_and_newlines() {
481 let input = "<div>\n\t<p>\n\t\tHello\n\t</p>\n</div>";
482 let result = minify_html(input);
483 assert_eq!(result, "<div> <p> Hello </p> </div>");
484 }
485
486 #[test]
487 fn minify_html_mixed_whitespace_types() {
488 let input = "<span> \t\n word \t\n </span>";
489 let result = minify_html(input);
490 assert_eq!(result, "<span> word </span>");
491 }
492
493 #[test]
494 fn minify_html_single_char() {
495 assert_eq!(minify_html("a"), "a");
496 assert_eq!(minify_html(" "), " ");
497 }
498
499 #[test]
500 fn minify_html_multiple_pre_tags() {
501 let input = "<pre>a</pre><pre>b</pre>";
502 let result = minify_html(input);
503 assert_eq!(result, input);
504 }
505
506 #[test]
511 fn minify_plugin_processes_multiple_html_files() -> Result<()> {
512 let temp = tempdir()?;
513 fs::write(temp.path().join("a.html"), "<p> hello </p>")?;
514 fs::write(temp.path().join("b.html"), "<div> world </div>")?;
515 fs::write(temp.path().join("c.txt"), " not html ")?;
516
517 let ctx = test_ctx_with(temp.path());
518 MinifyPlugin.after_compile(&ctx)?;
519
520 let a = fs::read_to_string(temp.path().join("a.html"))?;
521 let b = fs::read_to_string(temp.path().join("b.html"))?;
522 let c = fs::read_to_string(temp.path().join("c.txt"))?;
523
524 assert!(!a.contains(" "), "a.html should be minified");
525 assert!(!b.contains(" "), "b.html should be minified");
526 assert!(c.contains(" "), "c.txt should not be minified");
527 Ok(())
528 }
529
530 #[test]
531 fn minify_plugin_whitespace_only_html_file() -> Result<()> {
532 let temp = tempdir()?;
533 fs::write(temp.path().join("ws.html"), " \n\t \n ")?;
534
535 let ctx = test_ctx_with(temp.path());
536 MinifyPlugin.after_compile(&ctx)?;
537
538 let content = fs::read_to_string(temp.path().join("ws.html"))?;
539 assert_eq!(content, " ");
540 Ok(())
541 }
542
543 #[test]
544 fn minify_plugin_html_with_pre_block_not_modified() -> Result<()> {
545 let temp = tempdir()?;
546 let original =
547 "<html><pre> keep spaces </pre><p> other </p></html>";
548 fs::write(temp.path().join("pre.html"), original)?;
549
550 let ctx = test_ctx_with(temp.path());
551 MinifyPlugin.after_compile(&ctx)?;
552
553 let content = fs::read_to_string(temp.path().join("pre.html"))?;
554 assert_eq!(content, original);
555 Ok(())
556 }
557
558 #[test]
563 fn image_opti_plugin_finds_gif_and_bmp() -> Result<()> {
564 let temp = tempdir()?;
565 fs::write(temp.path().join("anim.gif"), "GIF")?;
566 fs::write(temp.path().join("icon.bmp"), "BMP")?;
567 fs::write(temp.path().join("doc.pdf"), "PDF")?;
568
569 let ctx = test_ctx_with(temp.path());
570 ImageOptiPlugin.after_compile(&ctx)?;
571
572 let mut count = 0;
576 for entry in fs::read_dir(temp.path())? {
577 let path = entry?.path();
578 if let Some(ext) = path.extension() {
579 let ext = ext.to_string_lossy().to_lowercase();
580 if matches!(ext.as_str(), "gif" | "bmp") {
581 count += 1;
582 }
583 }
584 }
585 assert_eq!(count, 2);
586 Ok(())
587 }
588
589 #[test]
590 fn image_opti_plugin_empty_dir_no_crash() -> Result<()> {
591 let temp = tempdir()?;
592 let ctx = test_ctx_with(temp.path());
593 ImageOptiPlugin.after_compile(&ctx)?;
594 Ok(())
595 }
596
597 #[test]
598 fn image_opti_plugin_no_images() -> Result<()> {
599 let temp = tempdir()?;
600 fs::write(temp.path().join("readme.txt"), "text")?;
601 fs::write(temp.path().join("style.css"), "css")?;
602
603 let ctx = test_ctx_with(temp.path());
604 ImageOptiPlugin.after_compile(&ctx)?;
605 Ok(())
606 }
607
608 #[test]
609 fn image_opti_plugin_files_without_extension() -> Result<()> {
610 let temp = tempdir()?;
611 fs::write(temp.path().join("Makefile"), "all:")?;
612 fs::write(temp.path().join("LICENSE"), "MIT")?;
613
614 let ctx = test_ctx_with(temp.path());
615 ImageOptiPlugin.after_compile(&ctx)?;
616 Ok(())
617 }
618
619 #[test]
624 fn deploy_plugin_empty_target() -> Result<()> {
625 let temp = tempdir()?;
626 let ctx = test_ctx_with(temp.path());
627 let plugin = DeployPlugin::new("");
628 plugin.after_compile(&ctx)?;
629 assert_eq!(plugin.target, "");
630 Ok(())
631 }
632
633 #[test]
634 fn deploy_plugin_various_targets() -> Result<()> {
635 let temp = tempdir()?;
636 let ctx = test_ctx_with(temp.path());
637
638 for target in ["staging", "production", "preview", "canary"] {
639 let plugin = DeployPlugin::new(target);
640 assert_eq!(plugin.name(), "deploy");
641 assert_eq!(plugin.target, target);
642 plugin.after_compile(&ctx)?;
643 }
644 Ok(())
645 }
646
647 #[test]
648 fn deploy_plugin_debug_format() {
649 let plugin = DeployPlugin::new("prod");
650 let debug = format!("{plugin:?}");
651 assert!(debug.contains("prod"));
652 }
653
654 #[test]
659 fn minify_plugin_copy_clone() {
660 let a = MinifyPlugin;
661 let b = a;
662 #[allow(clippy::clone_on_copy)]
663 let c = a.clone();
664 assert_eq!(a.name(), b.name());
665 assert_eq!(a.name(), c.name());
666 }
667
668 #[test]
669 fn minify_plugin_debug_format() {
670 let debug = format!("{:?}", MinifyPlugin);
671 assert!(debug.contains("MinifyPlugin"));
672 }
673
674 #[test]
675 fn image_opti_plugin_copy_clone() {
676 let a = ImageOptiPlugin;
677 let b = a;
678 #[allow(clippy::clone_on_copy)]
679 let c = a.clone();
680 assert_eq!(a.name(), b.name());
681 assert_eq!(a.name(), c.name());
682 }
683
684 #[test]
685 fn image_opti_plugin_debug_format() {
686 let debug = format!("{:?}", ImageOptiPlugin);
687 assert!(debug.contains("ImageOptiPlugin"));
688 }
689}