#[cfg(feature = "image-optimization")]
use crate::error::{PathErrorExt, SsgError};
#[cfg(feature = "image-optimization")]
use crate::plugin::{Plugin, PluginContext};
#[cfg(feature = "image-optimization")]
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
};
#[cfg(feature = "image-optimization")]
const DEFAULT_BREAKPOINTS: &[u32] = &[320, 640, 1024, 1440];
#[cfg(feature = "image-optimization")]
const DEFAULT_QUALITY: u8 = 80;
#[cfg(feature = "image-optimization")]
const DEFAULT_AVIF_QUALITY: u8 = 70;
#[cfg(feature = "image-optimization")]
const AVIF_SPEED: u8 = 4;
#[cfg(feature = "image-optimization")]
#[derive(Debug, Clone)]
pub struct ImageOptimizationPlugin {
pub quality: u8,
pub avif_quality: u8,
pub lazy_avif: bool,
pub breakpoints: Vec<u32>,
}
#[cfg(feature = "image-optimization")]
impl Default for ImageOptimizationPlugin {
fn default() -> Self {
Self {
quality: DEFAULT_QUALITY,
avif_quality: DEFAULT_AVIF_QUALITY,
lazy_avif: false,
breakpoints: DEFAULT_BREAKPOINTS.to_vec(),
}
}
}
#[cfg(feature = "image-optimization")]
impl Plugin for ImageOptimizationPlugin {
fn name(&self) -> &'static str {
"image-optimization"
}
fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
if !ctx.site_dir.exists() {
return Ok(());
}
let images = collect_images(&ctx.site_dir)?;
if images.is_empty() {
return Ok(());
}
let optimized_dir = ctx.site_dir.join("optimized");
fs::create_dir_all(&optimized_dir).with_path(&optimized_dir)?;
let manifest = optimize_all_images(
&images,
&ctx.site_dir,
&optimized_dir,
&self.breakpoints,
self.quality,
self.avif_quality,
);
rewrite_html_img_tags(&ctx.site_dir, &manifest)?;
let webp_count: usize =
manifest.values().map(|m| m.webp_variants.len()).sum();
let avif_count: usize =
manifest.values().map(|m| m.avif_variants.len()).sum();
log::info!(
"[image] Optimised {} image(s); {webp_count} WebP, {avif_count} AVIF variant(s)",
manifest.len()
);
Ok(())
}
}
#[cfg(feature = "image-optimization")]
#[derive(Debug, Clone)]
struct ImageVariant {
rel_path: String,
width: u32,
}
#[cfg(feature = "image-optimization")]
#[derive(Debug, Clone)]
struct ImageManifest {
original_rel: String,
original_width: u32,
original_height: u32,
webp_variants: Vec<ImageVariant>,
avif_variants: Vec<ImageVariant>,
}
#[cfg(feature = "image-optimization")]
fn optimize_all_images(
images: &[PathBuf],
site_dir: &Path,
optimized_dir: &Path,
breakpoints: &[u32],
quality: u8,
avif_quality: u8,
) -> HashMap<String, ImageManifest> {
let mut manifest = HashMap::new();
for img_path in images {
match process_image(
img_path,
site_dir,
optimized_dir,
breakpoints,
quality,
avif_quality,
) {
Ok(entry) => {
let _ = manifest.insert(entry.original_rel.clone(), entry);
}
Err(e) => {
log::warn!(
"[image] Failed to process {}: {e}",
img_path.display()
);
}
}
}
manifest
}
#[cfg(feature = "image-optimization")]
fn rewrite_html_img_tags(
site_dir: &Path,
manifest: &HashMap<String, ImageManifest>,
) -> Result<(), SsgError> {
let html_files = collect_html_files(site_dir)?;
for html_path in &html_files {
let html = fs::read_to_string(html_path).with_path(html_path)?;
let rewritten = rewrite_img_tags(&html, manifest);
if rewritten != html {
fs::write(html_path, rewritten).with_path(html_path)?;
}
}
Ok(())
}
#[cfg(feature = "image-optimization")]
fn process_image(
img_path: &Path,
site_dir: &Path,
optimized_dir: &Path,
breakpoints: &[u32],
_quality: u8,
avif_quality: u8,
) -> Result<ImageManifest, SsgError> {
use rayon::prelude::*;
let img = image::open(img_path).map_err(|e| SsgError::io(e, img_path))?;
let (orig_w, orig_h) = (img.width(), img.height());
let rel = img_path
.strip_prefix(site_dir)
.unwrap_or(img_path)
.to_string_lossy()
.replace('\\', "/");
let stem = img_path.file_stem().unwrap_or_default().to_string_lossy();
let mut webp_variants = Vec::new();
let mut resized_variants: Vec<(u32, image::DynamicImage)> = Vec::new();
for &width in breakpoints {
if width >= orig_w {
continue; }
let ratio = f64::from(width) / f64::from(orig_w);
let height = (f64::from(orig_h) * ratio) as u32;
let resized = img.resize_exact(
width,
height,
image::imageops::FilterType::Lanczos3,
);
let variant_name = format!("{stem}-{width}w.webp");
let variant_path = optimized_dir.join(&variant_name);
resized
.save(&variant_path)
.map_err(|e| SsgError::io(e, &variant_path))?;
let variant_rel = format!("optimized/{variant_name}");
webp_variants.push(ImageVariant {
rel_path: variant_rel,
width,
});
resized_variants.push((width, resized));
}
let avif_results: Vec<Option<ImageVariant>> = resized_variants
.par_iter()
.map(|(width, resized)| {
let variant_name = format!("{stem}-{width}w.avif");
let variant_path = optimized_dir.join(&variant_name);
match encode_avif(resized, avif_quality) {
Ok(bytes) => match fs::write(&variant_path, &bytes) {
Ok(()) => Some(ImageVariant {
rel_path: format!("optimized/{variant_name}"),
width: *width,
}),
Err(e) => {
log::warn!(
"[image] AVIF write failed for {}: {e}",
variant_path.display()
);
None
}
},
Err(e) => {
log::warn!(
"[image] AVIF encode failed for {}-{width}w: {e}",
stem
);
None
}
}
})
.collect();
let avif_variants: Vec<ImageVariant> =
avif_results.into_iter().flatten().collect();
Ok(ImageManifest {
original_rel: rel,
original_width: orig_w,
original_height: orig_h,
webp_variants,
avif_variants,
})
}
#[cfg(feature = "image-optimization")]
pub fn encode_avif(
img: &image::DynamicImage,
quality: u8,
) -> Result<Vec<u8>, SsgError> {
use rgb::FromSlice;
let rgba = img.to_rgba8();
let width = rgba.width() as usize;
let height = rgba.height() as usize;
let raw = rgba.as_raw().as_rgba();
let quality_f = f32::from(quality.clamp(1, 100));
let encoded = ravif::Encoder::new()
.with_quality(quality_f)
.with_alpha_quality(quality_f)
.with_speed(AVIF_SPEED)
.encode_rgba(ravif::Img::new(raw, width, height))
.map_err(|e| {
SsgError::io(
std::io::Error::other(format!("ravif encode failed: {e}")),
"<avif-buffer>",
)
})?;
Ok(encoded.avif_file)
}
#[cfg(feature = "image-optimization")]
fn rewrite_img_tags(
html: &str,
manifest: &HashMap<String, ImageManifest>,
) -> String {
use crate::util::html_rewriter::rewrite_html;
use lol_html::element;
use std::cell::RefCell;
use std::rc::Rc;
let consumed: Rc<RefCell<std::collections::HashSet<String>>> =
Rc::new(RefCell::new(std::collections::HashSet::new()));
let manifest = manifest.clone();
let consumed_cb = Rc::clone(&consumed);
let handler = element!("img", move |el| {
let Some(src) = el.get_attribute("src") else {
return Ok(());
};
let key = src.strip_prefix('/').unwrap_or(&src).to_string();
let Some(entry) = manifest.get(&key) else {
return Ok(());
};
if entry.webp_variants.is_empty() && entry.avif_variants.is_empty() {
return Ok(());
}
let original_rel = &entry.original_rel;
{
let mut consumed = consumed_cb.borrow_mut();
if consumed.contains(original_rel) {
return Ok(());
}
let _ = consumed.insert(original_rel.clone());
}
let alt = el.get_attribute("alt").unwrap_or_default();
let fetchpriority = el.get_attribute("fetchpriority");
let loading = if fetchpriority.as_deref() == Some("high") {
"eager"
} else {
"lazy"
};
let width = el
.get_attribute("width")
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(entry.original_width);
let height = el
.get_attribute("height")
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(entry.original_height);
if el.has_attribute("srcset") {
log::info!(
"[image] replacing author-supplied srcset on '{src}' with responsive variants"
);
}
let webp_srcset: String = entry
.webp_variants
.iter()
.map(|v| format!("/{} {}w", v.rel_path, v.width))
.collect::<Vec<_>>()
.join(", ");
let avif_srcset: String = entry
.avif_variants
.iter()
.map(|v| format!("/{} {}w", v.rel_path, v.width))
.collect::<Vec<_>>()
.join(", ");
let sizes = "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw";
let mut picture = String::from("<picture>");
if !avif_srcset.is_empty() {
picture.push_str(&format!(
"<source type=\"image/avif\" srcset=\"{avif_srcset}\" sizes=\"{sizes}\">"
));
}
if !webp_srcset.is_empty() {
picture.push_str(&format!(
"<source type=\"image/webp\" srcset=\"{webp_srcset}\" sizes=\"{sizes}\">"
));
}
let fp_attr = match fetchpriority {
Some(ref fp) => format!(" fetchpriority=\"{fp}\""),
None => String::new(),
};
picture.push_str(&format!(
"<img src=\"/{original_rel}\" alt=\"{alt}\" \
width=\"{width}\" height=\"{height}\" \
loading=\"{loading}\" decoding=\"async\"{fp_attr}>",
));
picture.push_str("</picture>");
el.replace(&picture, lol_html::html_content::ContentType::Html);
Ok(())
});
match rewrite_html(html, vec![handler]) {
Ok(s) => s,
Err(e) => {
log::warn!(
"[image] HTML rewrite failed, leaving page unchanged: {e}"
);
html.to_string()
}
}
}
#[cfg(all(test, feature = "image-optimization"))]
fn extract_attr(tag: &str, attr: &str) -> Option<String> {
let pattern = format!("{attr}=\"");
let start = tag.find(&pattern)? + pattern.len();
let end = tag[start..].find('"')? + start;
Some(tag[start..end].to_string())
}
#[cfg(feature = "image-optimization")]
fn collect_images(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
let all = crate::walk::walk_files_multi(dir, &["jpg", "jpeg", "png"])?;
Ok(all
.into_iter()
.filter(|p| !p.components().any(|c| c.as_os_str() == "optimized"))
.collect())
}
#[cfg(feature = "image-optimization")]
fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
crate::walk::walk_files(dir, "html")
}
#[cfg(all(test, feature = "image-optimization"))]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use tempfile::tempdir;
fn write_test_jpeg(path: &Path, w: u32, h: u32) {
let buf = image::ImageBuffer::from_fn(w, h, |x, y| {
image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
});
image::DynamicImage::ImageRgb8(buf)
.save_with_format(path, image::ImageFormat::Jpeg)
.expect("write jpeg");
}
fn write_test_png(path: &Path, w: u32, h: u32) {
let buf = image::ImageBuffer::from_fn(w, h, |x, y| {
image::Rgba([(x % 256) as u8, (y % 256) as u8, 200, 255])
});
image::DynamicImage::ImageRgba8(buf)
.save_with_format(path, image::ImageFormat::Png)
.expect("write png");
}
fn manifest_with(
original_rel: &str,
width: u32,
height: u32,
variant_widths: &[u32],
) -> HashMap<String, ImageManifest> {
let stem = original_rel
.rsplit('/')
.next()
.unwrap_or(original_rel)
.rsplit('.')
.nth(1)
.unwrap_or("img");
let webp_variants = variant_widths
.iter()
.map(|&w| ImageVariant {
rel_path: format!("optimized/{stem}-{w}w.webp"),
width: w,
})
.collect();
let mut m = HashMap::new();
let _ = m.insert(
original_rel.to_string(),
ImageManifest {
original_rel: original_rel.to_string(),
original_width: width,
original_height: height,
webp_variants,
avif_variants: Vec::new(),
},
);
m
}
fn manifest_with_avif(
original_rel: &str,
width: u32,
height: u32,
variant_widths: &[u32],
) -> HashMap<String, ImageManifest> {
let stem = original_rel
.rsplit('/')
.next()
.unwrap_or(original_rel)
.rsplit('.')
.nth(1)
.unwrap_or("img");
let webp_variants = variant_widths
.iter()
.map(|&w| ImageVariant {
rel_path: format!("optimized/{stem}-{w}w.webp"),
width: w,
})
.collect();
let avif_variants = variant_widths
.iter()
.map(|&w| ImageVariant {
rel_path: format!("optimized/{stem}-{w}w.avif"),
width: w,
})
.collect();
let mut m = HashMap::new();
let _ = m.insert(
original_rel.to_string(),
ImageManifest {
original_rel: original_rel.to_string(),
original_width: width,
original_height: height,
webp_variants,
avif_variants,
},
);
m
}
#[test]
fn default_plugin_has_expected_quality_and_breakpoints() {
let plugin = ImageOptimizationPlugin::default();
assert_eq!(plugin.quality, 80);
assert_eq!(plugin.avif_quality, 70);
assert!(!plugin.lazy_avif);
assert_eq!(plugin.breakpoints, vec![320, 640, 1024, 1440]);
}
#[test]
fn plugin_allows_custom_quality_and_breakpoints() {
let plugin = ImageOptimizationPlugin {
quality: 90,
avif_quality: 60,
lazy_avif: true,
breakpoints: vec![480, 960],
};
assert_eq!(plugin.quality, 90);
assert_eq!(plugin.avif_quality, 60);
assert!(plugin.lazy_avif);
assert_eq!(plugin.breakpoints, vec![480, 960]);
}
#[test]
fn encode_avif_produces_valid_avif_bytes() {
let buf = image::ImageBuffer::from_fn(64, 64, |x, y| {
image::Rgb([(x * 4) as u8, (y * 4) as u8, 128])
});
let img = image::DynamicImage::ImageRgb8(buf);
let bytes = encode_avif(&img, 70).expect("encode_avif");
assert!(
bytes.len() > 12 && &bytes[4..12] == b"ftypavif",
"encoded bytes should be a valid ftypavif ISOBMFF file"
);
}
#[test]
fn encode_avif_lower_quality_yields_smaller_file() {
let buf = image::ImageBuffer::from_fn(96, 96, |x, y| {
let r = ((x * y) ^ (x + y)) as u8;
let g = (x.wrapping_mul(3).wrapping_add(y)) as u8;
let b = ((x ^ y) * 5) as u8;
image::Rgb([r, g, b])
});
let img = image::DynamicImage::ImageRgb8(buf);
let high = encode_avif(&img, 80).unwrap();
let low = encode_avif(&img, 30).unwrap();
assert!(
low.len() < high.len(),
"quality=30 ({} bytes) should be smaller than quality=80 ({} bytes)",
low.len(),
high.len()
);
}
#[test]
fn encode_avif_clamps_quality_to_valid_range() {
let buf = image::ImageBuffer::from_fn(32, 32, |_, _| {
image::Rgb([200_u8, 100, 50])
});
let img = image::DynamicImage::ImageRgb8(buf);
assert!(encode_avif(&img, 0).is_ok());
assert!(encode_avif(&img, 100).is_ok());
}
#[test]
fn name_returns_static_image_optimization_identifier() {
assert_eq!(
ImageOptimizationPlugin::default().name(),
"image-optimization"
);
}
#[test]
fn extract_attr_table_driven_inputs() {
let cases: &[(&str, &str, Option<&str>)] = &[
(r#"<img src="x.jpg" alt="Photo">"#, "alt", Some("Photo")),
(r#"<img src="x.jpg">"#, "alt", None),
(r#"<img alt="">"#, "alt", Some("")),
(
r#"<img alt="multi word value">"#,
"alt",
Some("multi word value"),
),
(r#"<img src="x.jpg" alt="P">"#, "src", Some("x.jpg")),
(r"<img>", "src", None),
(
r#"<img fetchpriority="high" src="x.jpg">"#,
"fetchpriority",
Some("high"),
),
];
for &(tag, attr, expected) in cases {
let actual = extract_attr(tag, attr);
assert_eq!(
actual.as_deref(),
expected,
"extract_attr({tag:?}, {attr:?}) should be {expected:?}"
);
}
}
#[test]
fn rewrite_img_tags_replaces_img_with_picture_element() {
let manifest =
manifest_with("images/photo.jpg", 2000, 1500, &[640, 1024]);
let html = r#"<img src="images/photo.jpg" alt="A photo">"#;
let result = rewrite_img_tags(html, &manifest);
assert!(result.contains("<picture>"));
assert!(result.contains("</picture>"));
assert!(result.contains(r#"type="image/webp""#));
assert!(result.contains("srcset="));
assert!(result.contains("640w"));
assert!(result.contains("1024w"));
assert!(result.contains(r#"loading="lazy""#));
assert!(result.contains(r#"decoding="async""#));
assert!(result.contains(r#"width="2000""#));
assert!(result.contains(r#"height="1500""#));
assert!(result.contains(r#"alt="A photo""#));
}
#[test]
fn rewrite_img_tags_preserves_alt_text() {
let manifest = manifest_with("a.jpg", 2000, 1000, &[640]);
let html = r#"<img src="a.jpg" alt="Important context">"#;
let result = rewrite_img_tags(html, &manifest);
assert!(result.contains(r#"alt="Important context""#));
}
#[test]
fn rewrite_img_tags_handles_missing_alt_with_empty_string() {
let manifest = manifest_with("a.jpg", 2000, 1000, &[640]);
let html = r#"<img src="a.jpg">"#;
let result = rewrite_img_tags(html, &manifest);
assert!(result.contains(r#"alt="""#));
}
#[test]
fn rewrite_img_tags_handles_absolute_src_path() {
let manifest = manifest_with("images/a.jpg", 2000, 1000, &[640]);
let html = r#"<img src="/images/a.jpg" alt="">"#;
let result = rewrite_img_tags(html, &manifest);
assert!(result.contains("<picture>"));
}
#[test]
fn rewrite_img_tags_no_match_returns_unchanged() {
let manifest = manifest_with("ghost.jpg", 100, 100, &[640]);
let html = r"<p>no images here</p>";
let result = rewrite_img_tags(html, &manifest);
assert_eq!(result, html);
}
#[test]
fn rewrite_img_tags_skips_entries_with_no_variants() {
let manifest = manifest_with("a.jpg", 2000, 1000, &[]);
let html = r#"<img src="a.jpg" alt="x">"#;
let result = rewrite_img_tags(html, &manifest);
assert_eq!(result, html, "no variants → no rewrite");
}
#[test]
fn rewrite_img_tags_builds_srcset_with_width_descriptors() {
let manifest =
manifest_with("a.jpg", 4000, 3000, &[320, 640, 1024, 1440]);
let html = r#"<img src="a.jpg" alt="">"#;
let result = rewrite_img_tags(html, &manifest);
for w in [320, 640, 1024, 1440] {
assert!(
result.contains(&format!("{w}w")),
"srcset should contain {w}w:\n{result}"
);
}
assert!(result.matches(", ").count() >= 3);
}
#[test]
fn rewrite_img_tags_srcset_paths_are_absolute() {
let manifest = manifest_with("a.jpg", 2000, 1000, &[640]);
let html = r#"<img src="a.jpg" alt="">"#;
let result = rewrite_img_tags(html, &manifest);
assert!(
result.contains("/optimized/a-640w.webp 640w"),
"srcset paths should be absolute: {result}"
);
}
#[test]
fn rewrite_img_tags_default_loading_is_lazy() {
let manifest = manifest_with("a.jpg", 2000, 1000, &[640]);
let html = r#"<img src="a.jpg" alt="">"#;
let result = rewrite_img_tags(html, &manifest);
assert!(result.contains(r#"loading="lazy""#));
assert!(result.contains(r#"decoding="async""#));
}
#[test]
fn rewrite_img_tags_fetchpriority_high_gets_eager_loading() {
let manifest = manifest_with("hero.jpg", 2000, 1000, &[640]);
let html = r#"<img src="hero.jpg" alt="Hero" fetchpriority="high">"#;
let result = rewrite_img_tags(html, &manifest);
assert!(
result.contains(r#"loading="eager""#),
"fetchpriority=high should produce loading=eager: {result}"
);
assert!(
result.contains(r#"fetchpriority="high""#),
"fetchpriority attribute should be preserved: {result}"
);
}
#[test]
fn rewrite_img_tags_fetchpriority_low_still_lazy() {
let manifest = manifest_with("bg.jpg", 2000, 1000, &[640]);
let html = r#"<img src="bg.jpg" alt="" fetchpriority="low">"#;
let result = rewrite_img_tags(html, &manifest);
assert!(result.contains(r#"loading="lazy""#));
}
#[test]
fn rewrite_img_tags_includes_avif_source_when_present() {
let manifest =
manifest_with_avif("photo.jpg", 2000, 1500, &[640, 1024]);
let html = r#"<img src="photo.jpg" alt="">"#;
let result = rewrite_img_tags(html, &manifest);
assert!(
result.contains(r#"type="image/avif""#),
"should have AVIF source: {result}"
);
assert!(
result.contains(r#"type="image/webp""#),
"should have WebP source: {result}"
);
let avif_pos = result.find("image/avif").expect("avif present");
let webp_pos = result.find("image/webp").expect("webp present");
assert!(
avif_pos < webp_pos,
"AVIF source should precede WebP source"
);
}
#[test]
fn rewrite_img_tags_avif_srcset_has_correct_format() {
let manifest = manifest_with_avif("photo.jpg", 2000, 1500, &[320, 640]);
let html = r#"<img src="photo.jpg" alt="">"#;
let result = rewrite_img_tags(html, &manifest);
assert!(
result.contains("/optimized/photo-320w.avif 320w"),
"AVIF srcset should have width descriptors: {result}"
);
assert!(
result.contains("/optimized/photo-640w.avif 640w"),
"AVIF srcset should have width descriptors: {result}"
);
}
#[test]
fn rewrite_img_tags_injects_dimensions_from_manifest() {
let manifest = manifest_with("a.jpg", 1920, 1080, &[640]);
let html = r#"<img src="a.jpg" alt="">"#;
let result = rewrite_img_tags(html, &manifest);
assert!(result.contains(r#"width="1920""#));
assert!(result.contains(r#"height="1080""#));
}
#[test]
fn rewrite_img_tags_preserves_explicit_width_height() {
let manifest = manifest_with("a.jpg", 1920, 1080, &[640]);
let html = r#"<img src="a.jpg" alt="" width="800" height="450">"#;
let result = rewrite_img_tags(html, &manifest);
assert!(
result.contains(r#"width="800""#),
"explicit width should be preserved: {result}"
);
assert!(
result.contains(r#"height="450""#),
"explicit height should be preserved: {result}"
);
}
#[test]
fn rewrite_img_tags_only_replaces_first_occurrence_per_image() {
let manifest = manifest_with("a.jpg", 2000, 1000, &[640]);
let html = r#"<img src="a.jpg"><img src="a.jpg">"#;
let result = rewrite_img_tags(html, &manifest);
assert_eq!(result.matches("<picture>").count(), 1);
}
#[test]
fn collect_images_skips_optimized_subdirectory() {
let dir = tempdir().expect("tempdir");
let site = dir.path().join("site");
let opt = site.join("optimized");
fs::create_dir_all(&opt).unwrap();
fs::write(site.join("photo.jpg"), [0xFF, 0xD8]).unwrap();
fs::write(opt.join("photo-640w.webp"), [0]).unwrap();
let images = collect_images(&site).unwrap();
assert_eq!(images.len(), 1);
assert!(images[0].ends_with("photo.jpg"));
}
#[test]
fn collect_images_filters_to_jpg_jpeg_png_only() {
let dir = tempdir().expect("tempdir");
for name in ["a.jpg", "b.jpeg", "c.png", "d.gif", "e.webp", "f.txt"] {
fs::write(dir.path().join(name), [0]).unwrap();
}
let images = collect_images(dir.path()).unwrap();
assert_eq!(images.len(), 3, "only jpg/jpeg/png should be collected");
}
#[test]
fn collect_images_extension_match_is_case_insensitive() {
let dir = tempdir().expect("tempdir");
for name in ["A.JPG", "B.PNG", "C.JPEG"] {
fs::write(dir.path().join(name), [0]).unwrap();
}
let images = collect_images(dir.path()).unwrap();
assert_eq!(images.len(), 3);
}
#[test]
fn collect_images_recurses_into_nested_subdirectories() {
let dir = tempdir().expect("tempdir");
let nested = dir.path().join("a").join("b");
fs::create_dir_all(&nested).unwrap();
fs::write(dir.path().join("top.jpg"), [0]).unwrap();
fs::write(nested.join("deep.png"), [0]).unwrap();
let images = collect_images(dir.path()).unwrap();
assert_eq!(images.len(), 2);
}
#[test]
fn collect_images_returns_empty_for_missing_directory() {
let dir = tempdir().expect("tempdir");
let result = collect_images(&dir.path().join("missing")).unwrap();
assert!(result.is_empty());
}
#[test]
fn collect_images_returns_results_sorted() {
let dir = tempdir().expect("tempdir");
for name in ["zebra.jpg", "apple.jpg", "mango.jpg"] {
fs::write(dir.path().join(name), [0]).unwrap();
}
let images = collect_images(dir.path()).unwrap();
let names: Vec<_> = images
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap())
.collect();
assert_eq!(names, vec!["apple.jpg", "mango.jpg", "zebra.jpg"]);
}
#[test]
fn collect_html_files_filters_non_html_extensions() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.html"), "").unwrap();
fs::write(dir.path().join("b.css"), "").unwrap();
let result = collect_html_files(dir.path()).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn collect_html_files_recurses_and_sorts() {
let dir = tempdir().expect("tempdir");
let nested = dir.path().join("blog");
fs::create_dir_all(&nested).unwrap();
fs::write(dir.path().join("index.html"), "").unwrap();
fs::write(nested.join("post.html"), "").unwrap();
let result = collect_html_files(dir.path()).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn after_compile_missing_site_dir_returns_ok() {
let dir = tempdir().expect("tempdir");
let missing = dir.path().join("missing");
let ctx =
PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
ImageOptimizationPlugin::default()
.after_compile(&ctx)
.expect("missing site is not an error");
assert!(!missing.exists());
}
#[test]
fn process_image_generates_webp_variants_below_original_width() {
let dir = tempdir().expect("tempdir");
let site = dir.path().join("site");
let opt = site.join("optimized");
fs::create_dir_all(&opt).unwrap();
let src = site.join("hero.jpg");
write_test_jpeg(&src, 2000, 1000);
let manifest = process_image(
&src,
&site,
&opt,
&[320, 640, 1024, 1440],
DEFAULT_QUALITY,
DEFAULT_AVIF_QUALITY,
)
.unwrap();
assert_eq!(manifest.original_width, 2000);
assert_eq!(manifest.original_height, 1000);
assert_eq!(manifest.original_rel, "hero.jpg");
assert_eq!(manifest.webp_variants.len(), 4);
for v in &manifest.webp_variants {
assert!(opt
.join(v.rel_path.trim_start_matches("optimized/"))
.exists());
assert!(v.width < 2000);
}
assert_eq!(manifest.avif_variants.len(), 4);
for v in &manifest.avif_variants {
let path = opt.join(v.rel_path.trim_start_matches("optimized/"));
assert!(path.exists(), "AVIF variant must exist on disk: {path:?}");
let bytes = fs::read(&path).unwrap();
assert!(
bytes.len() > 12 && &bytes[4..12] == b"ftypavif",
"AVIF file should start with ftypavif box: {path:?}"
);
}
}
#[test]
fn process_image_skips_widths_larger_than_original() {
let dir = tempdir().expect("tempdir");
let site = dir.path().join("site");
let opt = site.join("optimized");
fs::create_dir_all(&opt).unwrap();
let src = site.join("small.png");
write_test_png(&src, 500, 500);
let manifest = process_image(
&src,
&site,
&opt,
&[320, 640, 1024, 1440],
DEFAULT_QUALITY,
DEFAULT_AVIF_QUALITY,
)
.unwrap();
assert_eq!(manifest.webp_variants.len(), 1);
assert_eq!(manifest.webp_variants[0].width, 320);
assert_eq!(manifest.avif_variants.len(), 1);
assert_eq!(manifest.avif_variants[0].width, 320);
}
#[test]
fn process_image_uses_custom_breakpoints() {
let dir = tempdir().expect("tempdir");
let site = dir.path().join("site");
let opt = site.join("optimized");
fs::create_dir_all(&opt).unwrap();
let src = site.join("photo.jpg");
write_test_jpeg(&src, 2000, 1000);
let manifest = process_image(
&src,
&site,
&opt,
&[480, 960],
DEFAULT_QUALITY,
DEFAULT_AVIF_QUALITY,
)
.unwrap();
assert_eq!(manifest.webp_variants.len(), 2);
assert_eq!(manifest.webp_variants[0].width, 480);
assert_eq!(manifest.webp_variants[1].width, 960);
}
#[test]
fn process_image_rejects_unreadable_source_path() {
let dir = tempdir().expect("tempdir");
let opt = dir.path().join("opt");
fs::create_dir_all(&opt).unwrap();
let missing = dir.path().join("does-not-exist.jpg");
assert!(process_image(
&missing,
dir.path(),
&opt,
DEFAULT_BREAKPOINTS,
DEFAULT_QUALITY,
DEFAULT_AVIF_QUALITY
)
.is_err());
}
#[test]
fn after_compile_processes_real_images_and_rewrites_html() {
let dir = tempdir().expect("tempdir");
let site = dir.path().join("site");
let images = site.join("images");
fs::create_dir_all(&images).unwrap();
write_test_jpeg(&images.join("photo.jpg"), 2000, 1500);
fs::write(
site.join("index.html"),
r#"<html><head></head><body><img src="/images/photo.jpg" alt="Test"></body></html>"#,
)
.unwrap();
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
ImageOptimizationPlugin::default()
.after_compile(&ctx)
.unwrap();
assert!(images.join("photo.jpg").exists());
assert!(site.join("optimized").exists());
let html = fs::read_to_string(site.join("index.html")).unwrap();
assert!(html.contains("<picture>"));
assert!(html.contains("image/webp"));
assert!(html.contains(r#"alt="Test""#));
assert!(html.contains(r#"loading="lazy""#));
assert!(html.contains(r#"decoding="async""#));
}
#[test]
fn after_compile_failed_image_processing_logs_warn_and_continues() {
let dir = tempdir().expect("tempdir");
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
fs::write(site.join("broken.jpg"), b"this is not really a jpeg")
.unwrap();
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
ImageOptimizationPlugin::default()
.after_compile(&ctx)
.expect("broken image must not propagate");
}
#[test]
fn after_compile_html_without_image_refs_skips_rewrite() {
let dir = tempdir().expect("tempdir");
let site = dir.path().join("site");
let images = site.join("images");
fs::create_dir_all(&images).unwrap();
write_test_jpeg(&images.join("orphan.jpg"), 1000, 1000);
let original_html =
"<html><head></head><body><p>no images here</p></body></html>";
fs::write(site.join("index.html"), original_html).unwrap();
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
ImageOptimizationPlugin::default()
.after_compile(&ctx)
.unwrap();
let after = fs::read_to_string(site.join("index.html")).unwrap();
assert_eq!(
after, original_html,
"html with no image refs should not be rewritten"
);
}
#[test]
fn after_compile_no_images_short_circuits_without_creating_optimized_dir() {
let dir = tempdir().expect("tempdir");
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
fs::write(site.join("index.html"), "<p></p>").unwrap();
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
ImageOptimizationPlugin::default()
.after_compile(&ctx)
.unwrap();
assert!(!site.join("optimized").exists());
}
}