Skip to main content

ssg/
plugins.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! # Built-in plugins
5//!
6//! Ready-to-use plugins for common static site generation tasks.
7//!
8//! - `MinifyPlugin` — Minifies HTML files in the site output directory.
9//! - `ImageOptiPlugin` — Logs image files for optimization (stub for external tooling).
10//! - `DeployPlugin` — Logs deployment target after build (stub for CI integration).
11
12use crate::plugin::{Plugin, PluginContext};
13use anyhow::{Context, Result};
14use rayon::prelude::*;
15use std::fs;
16use std::sync::atomic::{AtomicUsize, Ordering};
17
18/// Minifies HTML files by removing unnecessary whitespace.
19///
20/// Runs during the `after_compile` hook. Processes all `.html` files
21/// in the site directory.
22///
23/// # Example
24///
25/// ```rust
26/// use ssg::plugin::PluginManager;
27/// use ssg::plugins::MinifyPlugin;
28///
29/// let mut pm = PluginManager::new();
30/// pm.register(MinifyPlugin);
31/// ```
32#[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        // Collect HTML files (top-level only, matching previous behaviour).
48        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
83/// Minimal HTML minification: collapse whitespace runs into single spaces.
84///
85/// `<pre>` blocks short-circuit and return the input unchanged. This
86/// is intentionally simplistic; a real minifier lives in `minify-html`.
87fn minify_html(html: &str) -> String {
88    // Fast path: any `<pre` anywhere disables minification entirely.
89    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/// Image optimization plugin stub.
110///
111/// Scans the site directory for image files and logs them.
112/// Actual optimization requires external tools (e.g., `cwebp`, `avifenc`).
113///
114/// # Example
115///
116/// ```rust
117/// use ssg::plugin::PluginManager;
118/// use ssg::plugins::ImageOptiPlugin;
119///
120/// let mut pm = PluginManager::new();
121/// pm.register(ImageOptiPlugin);
122/// ```
123#[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/// Deployment plugin stub.
159///
160/// Logs the deployment target after a successful build.
161/// Extend with actual deployment logic for Vercel, Netlify, or Cloudflare.
162///
163/// # Example
164///
165/// ```rust
166/// use ssg::plugin::PluginManager;
167/// use ssg::plugins::DeployPlugin;
168///
169/// let mut pm = PluginManager::new();
170/// pm.register(DeployPlugin::new("production"));
171/// ```
172#[derive(Debug)]
173pub struct DeployPlugin {
174    target: String,
175}
176
177impl DeployPlugin {
178    /// Creates a new deployment plugin for the given target environment.
179    #[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        // CSS should be unchanged
258        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        // Arrange
336        let input = "<pre>  code   with   spaces  </pre><p>  other  </p>";
337
338        // Act
339        let result = minify_html(input);
340
341        // Assert — content with <pre> is returned verbatim
342        assert_eq!(result, input);
343    }
344
345    #[test]
346    fn minify_plugin_handles_nested_html() {
347        // Arrange
348        let input = "<div>  <section>  <article>  <p>  deep  </p>  </article>  </section>  </div>";
349
350        // Act
351        let result = minify_html(input);
352
353        // Assert — runs of whitespace collapsed to single spaces
354        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        // Arrange
363        let temp = tempdir()?;
364        let html_path = temp.path().join("empty.html");
365        fs::write(&html_path, "")?;
366
367        // Act
368        let ctx = test_ctx_with(temp.path());
369        MinifyPlugin.after_compile(&ctx)?;
370
371        // Assert — file exists, no crash
372        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        // Arrange
380        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        // Act
386        let ctx = test_ctx_with(temp.path());
387        ImageOptiPlugin.after_compile(&ctx)?;
388
389        // Assert — plugin runs without error (it only logs; we verify no crash)
390        // Also verify both extensions are recognized by the match arm
391        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        // Arrange — ImageOptiPlugin only reads top-level (read_dir, not recursive)
408        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        // Act
415        let ctx = test_ctx_with(temp.path());
416        ImageOptiPlugin.after_compile(&ctx)?;
417
418        // Assert — plugin completes without error; subdir images are not
419        // discovered since read_dir is non-recursive
420        Ok(())
421    }
422
423    #[test]
424    fn deploy_plugin_custom_target() -> Result<()> {
425        // Arrange
426        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        // Act — after_compile prints the target
432        plugin.after_compile(&ctx)?;
433
434        // Assert — the stored target matches what was provided
435        assert_eq!(plugin.target, target_name);
436        Ok(())
437    }
438
439    #[test]
440    fn minify_plugin_nonexistent_dir_returns_ok() -> Result<()> {
441        // Arrange
442        let ctx = test_ctx_with(Path::new("/this/path/does/not/exist/at/all"));
443
444        // Act & Assert — returns Ok without error
445        assert!(MinifyPlugin.after_compile(&ctx).is_ok());
446        Ok(())
447    }
448
449    // -----------------------------------------------------------------
450    // minify_html — additional edge cases
451    // -----------------------------------------------------------------
452
453    #[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    // -----------------------------------------------------------------
507    // MinifyPlugin — multiple HTML files
508    // -----------------------------------------------------------------
509
510    #[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    // -----------------------------------------------------------------
559    // ImageOptiPlugin — additional file types
560    // -----------------------------------------------------------------
561
562    #[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        // Verify the plugin ran without error. The plugin only logs —
573        // we verify it recognizes gif/bmp by not crashing and check
574        // file counts manually.
575        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    // -----------------------------------------------------------------
620    // DeployPlugin — additional targets
621    // -----------------------------------------------------------------
622
623    #[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    // -----------------------------------------------------------------
655    // MinifyPlugin / ImageOptiPlugin — trait object coverage
656    // -----------------------------------------------------------------
657
658    #[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}