#[cfg(feature = "image-optimization")]
use crate::plugin::{Plugin, PluginContext};
#[cfg(feature = "image-optimization")]
use anyhow::{Context, Result};
#[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")]
#[derive(Debug, Clone)]
pub struct ImageOptimizationPlugin {
pub quality: u8,
pub breakpoints: Vec<u32>,
}
#[cfg(feature = "image-optimization")]
impl Default for ImageOptimizationPlugin {
fn default() -> Self {
Self {
quality: DEFAULT_QUALITY,
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<()> {
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)?;
let manifest = optimize_all_images(
&images,
&ctx.site_dir,
&optimized_dir,
&self.breakpoints,
self.quality,
);
rewrite_html_img_tags(&ctx.site_dir, &manifest)?;
log::info!(
"[image] Optimised {} image(s), {} variant(s) generated",
manifest.len(),
manifest
.values()
.map(|m| m.webp_variants.len())
.sum::<usize>()
);
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,
) -> HashMap<String, ImageManifest> {
let mut manifest = HashMap::new();
for img_path in images {
match process_image(
img_path,
site_dir,
optimized_dir,
breakpoints,
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<()> {
let html_files = collect_html_files(site_dir)?;
for html_path in &html_files {
let html = fs::read_to_string(html_path)?;
let rewritten = rewrite_img_tags(&html, manifest);
if rewritten != html {
fs::write(html_path, rewritten)?;
}
}
Ok(())
}
#[cfg(feature = "image-optimization")]
fn process_image(
img_path: &Path,
site_dir: &Path,
optimized_dir: &Path,
breakpoints: &[u32],
_quality: u8,
) -> Result<ImageManifest> {
let img = image::open(img_path)
.with_context(|| format!("Failed to open {}", img_path.display()))?;
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 avif_variants = 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).with_context(|| {
format!("Failed to save {}", variant_path.display())
})?;
let variant_rel = format!("optimized/{variant_name}");
webp_variants.push(ImageVariant {
rel_path: variant_rel,
width,
});
}
Ok(ImageManifest {
original_rel: rel,
original_width: orig_w,
original_height: orig_h,
webp_variants,
avif_variants,
})
}
#[cfg(feature = "image-optimization")]
fn rewrite_img_tags(
html: &str,
manifest: &HashMap<String, ImageManifest>,
) -> String {
let mut result = html.to_string();
for (original_rel, entry) in manifest {
if entry.webp_variants.is_empty() && entry.avif_variants.is_empty() {
continue;
}
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 patterns = [
format!("\"{original_rel}\""),
format!("\"/{original_rel}\""),
];
for pattern in &patterns {
if let Some(img_start) = result.find(pattern) {
let search_back = &result[..img_start + pattern.len()];
if let Some(tag_start) = search_back.rfind("<img") {
let tag_end = result[tag_start..]
.find('>')
.map_or(result.len(), |e| tag_start + e + 1);
let old_tag = &result[tag_start..tag_end];
let alt = extract_attr(old_tag, "alt").unwrap_or_default();
let fetchpriority = extract_attr(old_tag, "fetchpriority");
let loading = if fetchpriority.as_deref() == Some("high") {
"eager"
} else {
"lazy"
};
let width = extract_attr(old_tag, "width")
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(entry.original_width);
let height = extract_attr(old_tag, "height")
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(entry.original_height);
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}\">"
));
}
picture.push_str(&format!(
"<img src=\"/{original_rel}\" alt=\"{alt}\" \
width=\"{width}\" height=\"{height}\" \
loading=\"{loading}\" decoding=\"async\">"
));
if let Some(ref fp) = fetchpriority {
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}\">"
));
}
picture.push_str(&format!(
"<img src=\"/{original_rel}\" alt=\"{alt}\" \
width=\"{width}\" height=\"{height}\" \
loading=\"{loading}\" decoding=\"async\" \
fetchpriority=\"{fp}\">"
));
}
picture.push_str("</picture>");
result = format!(
"{}{}{}",
&result[..tag_start],
picture,
&result[tag_end..]
);
break; }
}
}
}
result
}
#[cfg(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>> {
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>> {
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.breakpoints, vec![320, 640, 1024, 1440]);
}
#[test]
fn plugin_allows_custom_quality_and_breakpoints() {
let plugin = ImageOptimizationPlugin {
quality: 90,
breakpoints: vec![480, 960],
};
assert_eq!(plugin.quality, 90);
assert_eq!(plugin.breakpoints, vec![480, 960]);
}
#[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,
)
.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);
}
}
#[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,
)
.unwrap();
assert_eq!(manifest.webp_variants.len(), 1);
assert_eq!(manifest.webp_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)
.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
)
.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());
}
}