use crate::plugin::{Plugin, PluginContext};
use anyhow::{Context, Result};
use std::fs;
#[derive(Debug, Copy, Clone)]
pub struct MinifyPlugin;
impl Plugin for MinifyPlugin {
fn name(&self) -> &str {
"minify"
}
fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
if !ctx.site_dir.exists() {
return Ok(());
}
let mut count = 0usize;
for entry in fs::read_dir(&ctx.site_dir)? {
let path = entry?.path();
if path.extension().map_or(false, |e| e == "html") {
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let minified = minify_html(&content);
fs::write(&path, &minified)
.with_context(|| format!("Failed to write {}", path.display()))?;
count += 1;
}
}
if count > 0 {
println!("[minify] Processed {} HTML files", count);
}
Ok(())
}
}
fn minify_html(html: &str) -> String {
let mut result = String::with_capacity(html.len());
let mut in_whitespace = false;
let in_pre = false;
for ch in html.chars() {
if html.contains("<pre") {
return html.to_string();
}
if ch.is_whitespace() {
if !in_whitespace && !in_pre {
result.push(' ');
in_whitespace = true;
} else if in_pre {
result.push(ch);
}
} else {
in_whitespace = false;
result.push(ch);
}
}
let _ = in_pre; result
}
#[derive(Debug, Copy, Clone)]
pub struct ImageOptiPlugin;
impl Plugin for ImageOptiPlugin {
fn name(&self) -> &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 {
pub fn new(target: &str) -> Self {
Self {
target: target.to_string(),
}
}
}
impl Plugin for DeployPlugin {
fn name(&self) -> &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)]
mod tests {
use super::*;
use crate::plugin::PluginContext;
use std::path::Path;
use tempfile::tempdir;
fn test_ctx_with(site_dir: &Path) -> PluginContext {
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(())
}
}