use crate::plugin::{Plugin, PluginContext};
use anyhow::{Context, Result};
use rayon::prelude::*;
use std::fs;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Debug, Copy, Clone)]
pub struct MinifyPlugin;
impl Plugin for MinifyPlugin {
fn name(&self) -> &'static str {
"minify"
}
fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
if !ctx.site_dir.exists() {
return Ok(());
}
let cache = ctx.cache.as_ref();
let html_files: Vec<_> = fs::read_dir(&ctx.site_dir)?
.filter_map(std::result::Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "html"))
.filter(|p| cache.is_none_or(|c| c.has_changed(p)))
.collect();
let count = AtomicUsize::new(0);
html_files.par_iter().try_for_each(|path| -> Result<()> {
fail_point!("plugins::minify-read", |_| {
anyhow::bail!("injected: plugins::minify-read")
});
let content = fs::read_to_string(path).with_context(|| {
format!("Failed to read {}", path.display())
})?;
let minified = minify_html(&content);
fail_point!("plugins::minify-write", |_| {
anyhow::bail!("injected: plugins::minify-write")
});
fs::write(path, &minified).with_context(|| {
format!("Failed to write {}", path.display())
})?;
let _ = count.fetch_add(1, Ordering::Relaxed);
Ok(())
})?;
let total = count.load(Ordering::Relaxed);
if total > 0 {
println!("[minify] Processed {total} HTML files");
}
Ok(())
}
}
fn minify_html(html: &str) -> String {
if html.contains("<pre") {
return html.to_string();
}
let mut result = String::with_capacity(html.len());
let mut in_whitespace = false;
for ch in html.chars() {
if ch.is_whitespace() {
if !in_whitespace {
result.push(' ');
in_whitespace = true;
}
} else {
in_whitespace = false;
result.push(ch);
}
}
result
}
#[derive(Debug, Copy, Clone)]
pub struct ImageOptiPlugin;
impl Plugin for ImageOptiPlugin {
fn name(&self) -> &'static str {
"image-opti"
}
fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
if !ctx.site_dir.exists() {
return Ok(());
}
let mut images = Vec::new();
for entry in fs::read_dir(&ctx.site_dir)? {
let path = entry?.path();
if let Some(ext) = path.extension() {
let ext = ext.to_string_lossy().to_lowercase();
if matches!(
ext.as_str(),
"png" | "jpg" | "jpeg" | "gif" | "bmp"
) {
images.push(path);
}
}
}
if !images.is_empty() {
println!(
"[image-opti] Found {} images for optimization",
images.len()
);
}
Ok(())
}
}
#[derive(Debug)]
pub struct DeployPlugin {
target: String,
}
impl DeployPlugin {
#[must_use]
pub fn new(target: &str) -> Self {
Self {
target: target.to_string(),
}
}
}
impl Plugin for DeployPlugin {
fn name(&self) -> &'static str {
"deploy"
}
fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
println!(
"[deploy] Site at {} ready for deployment to '{}'",
ctx.site_dir.display(),
self.target
);
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::plugin::PluginContext;
use crate::test_support::init_logger;
use std::path::Path;
use tempfile::tempdir;
fn test_ctx_with(site_dir: &Path) -> PluginContext {
init_logger();
PluginContext::new(
Path::new("content"),
Path::new("build"),
site_dir,
Path::new("templates"),
)
}
#[test]
fn test_minify_plugin_name() {
assert_eq!(MinifyPlugin.name(), "minify");
}
#[test]
fn test_minify_plugin_empty_dir() -> Result<()> {
let temp = tempdir()?;
let ctx = test_ctx_with(temp.path());
MinifyPlugin.after_compile(&ctx)?;
Ok(())
}
#[test]
fn test_minify_plugin_processes_html() -> Result<()> {
let temp = tempdir()?;
let html_path = temp.path().join("index.html");
fs::write(&html_path, "<h1> Hello World </h1>")?;
let ctx = test_ctx_with(temp.path());
MinifyPlugin.after_compile(&ctx)?;
let content = fs::read_to_string(&html_path)?;
assert!(!content.contains(" "));
Ok(())
}
#[test]
fn test_minify_plugin_skips_non_html() -> Result<()> {
let temp = tempdir()?;
let css_path = temp.path().join("style.css");
fs::write(&css_path, "body { color: red; }")?;
let ctx = test_ctx_with(temp.path());
MinifyPlugin.after_compile(&ctx)?;
let content = fs::read_to_string(&css_path)?;
assert!(content.contains(" "));
Ok(())
}
#[test]
fn test_minify_plugin_nonexistent_dir() -> Result<()> {
let ctx = test_ctx_with(Path::new("/nonexistent"));
MinifyPlugin.after_compile(&ctx)?;
Ok(())
}
#[test]
fn test_minify_html_collapses_whitespace() {
let result = minify_html("<p> Hello World </p>");
assert_eq!(result, "<p> Hello World </p>");
}
#[test]
fn test_minify_html_preserves_pre() {
let input = "<pre> keep spaces </pre>";
let result = minify_html(input);
assert_eq!(result, input);
}
#[test]
fn test_image_opti_plugin_name() {
assert_eq!(ImageOptiPlugin.name(), "image-opti");
}
#[test]
fn test_image_opti_plugin_finds_images() -> Result<()> {
let temp = tempdir()?;
fs::write(temp.path().join("photo.png"), "PNG")?;
fs::write(temp.path().join("logo.jpg"), "JPG")?;
fs::write(temp.path().join("style.css"), "CSS")?;
let ctx = test_ctx_with(temp.path());
ImageOptiPlugin.after_compile(&ctx)?;
Ok(())
}
#[test]
fn test_image_opti_plugin_nonexistent_dir() -> Result<()> {
let ctx = test_ctx_with(Path::new("/nonexistent"));
ImageOptiPlugin.after_compile(&ctx)?;
Ok(())
}
#[test]
fn test_deploy_plugin_name() {
let p = DeployPlugin::new("staging");
assert_eq!(p.name(), "deploy");
}
#[test]
fn test_deploy_plugin_prints_target() -> Result<()> {
let temp = tempdir()?;
let ctx = test_ctx_with(temp.path());
let p = DeployPlugin::new("production");
p.after_compile(&ctx)?;
Ok(())
}
#[test]
fn test_all_plugins_register() {
use crate::plugin::PluginManager;
let mut pm = PluginManager::new();
pm.register(MinifyPlugin);
pm.register(ImageOptiPlugin);
pm.register(DeployPlugin::new("test"));
assert_eq!(pm.len(), 3);
assert_eq!(pm.names(), vec!["minify", "image-opti", "deploy"]);
}
#[test]
fn minify_plugin_preserves_pre_blocks() {
let input = "<pre> code with spaces </pre><p> other </p>";
let result = minify_html(input);
assert_eq!(result, input);
}
#[test]
fn minify_plugin_handles_nested_html() {
let input = "<div> <section> <article> <p> deep </p> </article> </section> </div>";
let result = minify_html(input);
assert!(!result.contains(" "));
assert!(result.contains("<div>"));
assert!(result.contains("</div>"));
assert!(result.contains("deep"));
}
#[test]
fn minify_plugin_empty_html_file() -> Result<()> {
let temp = tempdir()?;
let html_path = temp.path().join("empty.html");
fs::write(&html_path, "")?;
let ctx = test_ctx_with(temp.path());
MinifyPlugin.after_compile(&ctx)?;
let content = fs::read_to_string(&html_path)?;
assert!(content.is_empty());
Ok(())
}
#[test]
fn image_opti_plugin_finds_jpeg_variants() -> Result<()> {
let temp = tempdir()?;
fs::write(temp.path().join("photo.jpg"), "JPG")?;
fs::write(temp.path().join("banner.jpeg"), "JPEG")?;
fs::write(temp.path().join("readme.txt"), "text")?;
let ctx = test_ctx_with(temp.path());
ImageOptiPlugin.after_compile(&ctx)?;
let mut found = Vec::new();
for entry in fs::read_dir(temp.path())? {
let path = entry?.path();
if let Some(ext) = path.extension() {
let ext = ext.to_string_lossy().to_lowercase();
if matches!(ext.as_str(), "jpg" | "jpeg") {
found.push(path);
}
}
}
assert_eq!(found.len(), 2);
Ok(())
}
#[test]
fn image_opti_plugin_nested_directories() -> Result<()> {
let temp = tempdir()?;
let subdir = temp.path().join("subdir");
fs::create_dir(&subdir)?;
fs::write(subdir.join("deep.png"), "PNG")?;
fs::write(temp.path().join("top.png"), "PNG")?;
let ctx = test_ctx_with(temp.path());
ImageOptiPlugin.after_compile(&ctx)?;
Ok(())
}
#[test]
fn deploy_plugin_custom_target() -> Result<()> {
let temp = tempdir()?;
let ctx = test_ctx_with(temp.path());
let target_name = "staging-eu-west-1";
let plugin = DeployPlugin::new(target_name);
plugin.after_compile(&ctx)?;
assert_eq!(plugin.target, target_name);
Ok(())
}
#[test]
fn minify_plugin_nonexistent_dir_returns_ok() -> Result<()> {
let ctx = test_ctx_with(Path::new("/this/path/does/not/exist/at/all"));
assert!(MinifyPlugin.after_compile(&ctx).is_ok());
Ok(())
}
#[test]
fn minify_html_empty_string() {
let result = minify_html("");
assert_eq!(result, "");
}
#[test]
fn minify_html_whitespace_only() {
let result = minify_html(" \n\t \n ");
assert_eq!(result, " ");
}
#[test]
fn minify_html_no_whitespace() {
let input = "<p>hello</p>";
let result = minify_html(input);
assert_eq!(result, input);
}
#[test]
fn minify_html_preserves_pre_with_class() {
let input = "<pre class=\"lang-rust\"> fn main() { } </pre>";
let result = minify_html(input);
assert_eq!(result, input);
}
#[test]
fn minify_html_tabs_and_newlines() {
let input = "<div>\n\t<p>\n\t\tHello\n\t</p>\n</div>";
let result = minify_html(input);
assert_eq!(result, "<div> <p> Hello </p> </div>");
}
#[test]
fn minify_html_mixed_whitespace_types() {
let input = "<span> \t\n word \t\n </span>";
let result = minify_html(input);
assert_eq!(result, "<span> word </span>");
}
#[test]
fn minify_html_single_char() {
assert_eq!(minify_html("a"), "a");
assert_eq!(minify_html(" "), " ");
}
#[test]
fn minify_html_multiple_pre_tags() {
let input = "<pre>a</pre><pre>b</pre>";
let result = minify_html(input);
assert_eq!(result, input);
}
#[test]
fn minify_plugin_processes_multiple_html_files() -> Result<()> {
let temp = tempdir()?;
fs::write(temp.path().join("a.html"), "<p> hello </p>")?;
fs::write(temp.path().join("b.html"), "<div> world </div>")?;
fs::write(temp.path().join("c.txt"), " not html ")?;
let ctx = test_ctx_with(temp.path());
MinifyPlugin.after_compile(&ctx)?;
let a = fs::read_to_string(temp.path().join("a.html"))?;
let b = fs::read_to_string(temp.path().join("b.html"))?;
let c = fs::read_to_string(temp.path().join("c.txt"))?;
assert!(!a.contains(" "), "a.html should be minified");
assert!(!b.contains(" "), "b.html should be minified");
assert!(c.contains(" "), "c.txt should not be minified");
Ok(())
}
#[test]
fn minify_plugin_whitespace_only_html_file() -> Result<()> {
let temp = tempdir()?;
fs::write(temp.path().join("ws.html"), " \n\t \n ")?;
let ctx = test_ctx_with(temp.path());
MinifyPlugin.after_compile(&ctx)?;
let content = fs::read_to_string(temp.path().join("ws.html"))?;
assert_eq!(content, " ");
Ok(())
}
#[test]
fn minify_plugin_html_with_pre_block_not_modified() -> Result<()> {
let temp = tempdir()?;
let original =
"<html><pre> keep spaces </pre><p> other </p></html>";
fs::write(temp.path().join("pre.html"), original)?;
let ctx = test_ctx_with(temp.path());
MinifyPlugin.after_compile(&ctx)?;
let content = fs::read_to_string(temp.path().join("pre.html"))?;
assert_eq!(content, original);
Ok(())
}
#[test]
fn image_opti_plugin_finds_gif_and_bmp() -> Result<()> {
let temp = tempdir()?;
fs::write(temp.path().join("anim.gif"), "GIF")?;
fs::write(temp.path().join("icon.bmp"), "BMP")?;
fs::write(temp.path().join("doc.pdf"), "PDF")?;
let ctx = test_ctx_with(temp.path());
ImageOptiPlugin.after_compile(&ctx)?;
let mut count = 0;
for entry in fs::read_dir(temp.path())? {
let path = entry?.path();
if let Some(ext) = path.extension() {
let ext = ext.to_string_lossy().to_lowercase();
if matches!(ext.as_str(), "gif" | "bmp") {
count += 1;
}
}
}
assert_eq!(count, 2);
Ok(())
}
#[test]
fn image_opti_plugin_empty_dir_no_crash() -> Result<()> {
let temp = tempdir()?;
let ctx = test_ctx_with(temp.path());
ImageOptiPlugin.after_compile(&ctx)?;
Ok(())
}
#[test]
fn image_opti_plugin_no_images() -> Result<()> {
let temp = tempdir()?;
fs::write(temp.path().join("readme.txt"), "text")?;
fs::write(temp.path().join("style.css"), "css")?;
let ctx = test_ctx_with(temp.path());
ImageOptiPlugin.after_compile(&ctx)?;
Ok(())
}
#[test]
fn image_opti_plugin_files_without_extension() -> Result<()> {
let temp = tempdir()?;
fs::write(temp.path().join("Makefile"), "all:")?;
fs::write(temp.path().join("LICENSE"), "MIT")?;
let ctx = test_ctx_with(temp.path());
ImageOptiPlugin.after_compile(&ctx)?;
Ok(())
}
#[test]
fn deploy_plugin_empty_target() -> Result<()> {
let temp = tempdir()?;
let ctx = test_ctx_with(temp.path());
let plugin = DeployPlugin::new("");
plugin.after_compile(&ctx)?;
assert_eq!(plugin.target, "");
Ok(())
}
#[test]
fn deploy_plugin_various_targets() -> Result<()> {
let temp = tempdir()?;
let ctx = test_ctx_with(temp.path());
for target in ["staging", "production", "preview", "canary"] {
let plugin = DeployPlugin::new(target);
assert_eq!(plugin.name(), "deploy");
assert_eq!(plugin.target, target);
plugin.after_compile(&ctx)?;
}
Ok(())
}
#[test]
fn deploy_plugin_debug_format() {
let plugin = DeployPlugin::new("prod");
let debug = format!("{plugin:?}");
assert!(debug.contains("prod"));
}
#[test]
fn minify_plugin_copy_clone() {
let a = MinifyPlugin;
let b = a;
#[allow(clippy::clone_on_copy)]
let c = a.clone();
assert_eq!(a.name(), b.name());
assert_eq!(a.name(), c.name());
}
#[test]
fn minify_plugin_debug_format() {
let debug = format!("{:?}", MinifyPlugin);
assert!(debug.contains("MinifyPlugin"));
}
#[test]
fn image_opti_plugin_copy_clone() {
let a = ImageOptiPlugin;
let b = a;
#[allow(clippy::clone_on_copy)]
let c = a.clone();
assert_eq!(a.name(), b.name());
assert_eq!(a.name(), c.name());
}
#[test]
fn image_opti_plugin_debug_format() {
let debug = format!("{:?}", ImageOptiPlugin);
assert!(debug.contains("ImageOptiPlugin"));
}
}