1#[cfg(feature = "image-optimization")]
26use crate::plugin::{Plugin, PluginContext};
27#[cfg(feature = "image-optimization")]
28use anyhow::{Context, Result};
29#[cfg(feature = "image-optimization")]
30use std::{
31 collections::HashMap,
32 fs,
33 path::{Path, PathBuf},
34};
35
36#[cfg(feature = "image-optimization")]
38const DEFAULT_BREAKPOINTS: &[u32] = &[320, 640, 1024, 1440];
39
40#[cfg(feature = "image-optimization")]
42const DEFAULT_QUALITY: u8 = 80;
43
44#[cfg(feature = "image-optimization")]
54#[derive(Debug, Clone)]
55pub struct ImageOptimizationPlugin {
56 pub quality: u8,
58 pub breakpoints: Vec<u32>,
60}
61
62#[cfg(feature = "image-optimization")]
63impl Default for ImageOptimizationPlugin {
64 fn default() -> Self {
65 Self {
66 quality: DEFAULT_QUALITY,
67 breakpoints: DEFAULT_BREAKPOINTS.to_vec(),
68 }
69 }
70}
71
72#[cfg(feature = "image-optimization")]
73impl Plugin for ImageOptimizationPlugin {
74 fn name(&self) -> &'static str {
75 "image-optimization"
76 }
77
78 fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
79 if !ctx.site_dir.exists() {
80 return Ok(());
81 }
82
83 let images = collect_images(&ctx.site_dir)?;
84 if images.is_empty() {
85 return Ok(());
86 }
87
88 let optimized_dir = ctx.site_dir.join("optimized");
89 fs::create_dir_all(&optimized_dir)?;
90
91 let manifest = optimize_all_images(
92 &images,
93 &ctx.site_dir,
94 &optimized_dir,
95 &self.breakpoints,
96 self.quality,
97 );
98
99 rewrite_html_img_tags(&ctx.site_dir, &manifest)?;
100
101 log::info!(
102 "[image] Optimised {} image(s), {} variant(s) generated",
103 manifest.len(),
104 manifest
105 .values()
106 .map(|m| m.webp_variants.len())
107 .sum::<usize>()
108 );
109 Ok(())
110 }
111}
112
113#[cfg(feature = "image-optimization")]
114#[derive(Debug, Clone)]
115struct ImageVariant {
116 rel_path: String,
117 width: u32,
118}
119
120#[cfg(feature = "image-optimization")]
121#[derive(Debug, Clone)]
122struct ImageManifest {
123 original_rel: String,
124 original_width: u32,
125 original_height: u32,
126 webp_variants: Vec<ImageVariant>,
127 avif_variants: Vec<ImageVariant>,
128}
129
130#[cfg(feature = "image-optimization")]
132fn optimize_all_images(
133 images: &[PathBuf],
134 site_dir: &Path,
135 optimized_dir: &Path,
136 breakpoints: &[u32],
137 quality: u8,
138) -> HashMap<String, ImageManifest> {
139 let mut manifest = HashMap::new();
140 for img_path in images {
141 match process_image(
142 img_path,
143 site_dir,
144 optimized_dir,
145 breakpoints,
146 quality,
147 ) {
148 Ok(entry) => {
149 let _ = manifest.insert(entry.original_rel.clone(), entry);
150 }
151 Err(e) => {
152 log::warn!(
153 "[image] Failed to process {}: {e}",
154 img_path.display()
155 );
156 }
157 }
158 }
159 manifest
160}
161
162#[cfg(feature = "image-optimization")]
164fn rewrite_html_img_tags(
165 site_dir: &Path,
166 manifest: &HashMap<String, ImageManifest>,
167) -> Result<()> {
168 let html_files = collect_html_files(site_dir)?;
169 for html_path in &html_files {
170 let html = fs::read_to_string(html_path)?;
171 let rewritten = rewrite_img_tags(&html, manifest);
172 if rewritten != html {
173 fs::write(html_path, rewritten)?;
174 }
175 }
176 Ok(())
177}
178
179#[cfg(feature = "image-optimization")]
182fn process_image(
183 img_path: &Path,
184 site_dir: &Path,
185 optimized_dir: &Path,
186 breakpoints: &[u32],
187 _quality: u8,
188) -> Result<ImageManifest> {
189 let img = image::open(img_path)
190 .with_context(|| format!("Failed to open {}", img_path.display()))?;
191
192 let (orig_w, orig_h) = (img.width(), img.height());
193 let rel = img_path
194 .strip_prefix(site_dir)
195 .unwrap_or(img_path)
196 .to_string_lossy()
197 .replace('\\', "/");
198
199 let stem = img_path.file_stem().unwrap_or_default().to_string_lossy();
200
201 let mut webp_variants = Vec::new();
202 let avif_variants = Vec::new();
203
204 for &width in breakpoints {
205 if width >= orig_w {
206 continue; }
208
209 let ratio = f64::from(width) / f64::from(orig_w);
210 let height = (f64::from(orig_h) * ratio) as u32;
211 let resized = img.resize_exact(
212 width,
213 height,
214 image::imageops::FilterType::Lanczos3,
215 );
216
217 let variant_name = format!("{stem}-{width}w.webp");
219 let variant_path = optimized_dir.join(&variant_name);
220 resized.save(&variant_path).with_context(|| {
221 format!("Failed to save {}", variant_path.display())
222 })?;
223
224 let variant_rel = format!("optimized/{variant_name}");
225 webp_variants.push(ImageVariant {
226 rel_path: variant_rel,
227 width,
228 });
229
230 }
236
237 Ok(ImageManifest {
238 original_rel: rel,
239 original_width: orig_w,
240 original_height: orig_h,
241 webp_variants,
242 avif_variants,
243 })
244}
245
246#[cfg(feature = "image-optimization")]
257fn rewrite_img_tags(
258 html: &str,
259 manifest: &HashMap<String, ImageManifest>,
260) -> String {
261 let mut result = html.to_string();
262
263 for (original_rel, entry) in manifest {
264 if entry.webp_variants.is_empty() && entry.avif_variants.is_empty() {
265 continue;
266 }
267
268 let webp_srcset: String = entry
270 .webp_variants
271 .iter()
272 .map(|v| format!("/{} {}w", v.rel_path, v.width))
273 .collect::<Vec<_>>()
274 .join(", ");
275
276 let avif_srcset: String = entry
278 .avif_variants
279 .iter()
280 .map(|v| format!("/{} {}w", v.rel_path, v.width))
281 .collect::<Vec<_>>()
282 .join(", ");
283
284 let patterns = [
286 format!("\"{original_rel}\""),
287 format!("\"/{original_rel}\""),
288 ];
289
290 for pattern in &patterns {
291 if let Some(img_start) = result.find(pattern) {
292 let search_back = &result[..img_start + pattern.len()];
294 if let Some(tag_start) = search_back.rfind("<img") {
295 let tag_end = result[tag_start..]
296 .find('>')
297 .map_or(result.len(), |e| tag_start + e + 1);
298
299 let old_tag = &result[tag_start..tag_end];
300
301 let alt = extract_attr(old_tag, "alt").unwrap_or_default();
303 let fetchpriority = extract_attr(old_tag, "fetchpriority");
304
305 let loading = if fetchpriority.as_deref() == Some("high") {
307 "eager"
308 } else {
309 "lazy"
310 };
311
312 let width = extract_attr(old_tag, "width")
314 .and_then(|v| v.parse::<u32>().ok())
315 .unwrap_or(entry.original_width);
316 let height = extract_attr(old_tag, "height")
317 .and_then(|v| v.parse::<u32>().ok())
318 .unwrap_or(entry.original_height);
319
320 let sizes = "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw";
321
322 let mut picture = String::from("<picture>");
324
325 if !avif_srcset.is_empty() {
327 picture.push_str(&format!(
328 "<source type=\"image/avif\" srcset=\"{avif_srcset}\" sizes=\"{sizes}\">"
329 ));
330 }
331
332 if !webp_srcset.is_empty() {
334 picture.push_str(&format!(
335 "<source type=\"image/webp\" srcset=\"{webp_srcset}\" sizes=\"{sizes}\">"
336 ));
337 }
338
339 picture.push_str(&format!(
341 "<img src=\"/{original_rel}\" alt=\"{alt}\" \
342 width=\"{width}\" height=\"{height}\" \
343 loading=\"{loading}\" decoding=\"async\">"
344 ));
345
346 if let Some(ref fp) = fetchpriority {
348 picture = String::from("<picture>");
351 if !avif_srcset.is_empty() {
352 picture.push_str(&format!(
353 "<source type=\"image/avif\" srcset=\"{avif_srcset}\" sizes=\"{sizes}\">"
354 ));
355 }
356 if !webp_srcset.is_empty() {
357 picture.push_str(&format!(
358 "<source type=\"image/webp\" srcset=\"{webp_srcset}\" sizes=\"{sizes}\">"
359 ));
360 }
361 picture.push_str(&format!(
362 "<img src=\"/{original_rel}\" alt=\"{alt}\" \
363 width=\"{width}\" height=\"{height}\" \
364 loading=\"{loading}\" decoding=\"async\" \
365 fetchpriority=\"{fp}\">"
366 ));
367 }
368
369 picture.push_str("</picture>");
370
371 result = format!(
372 "{}{}{}",
373 &result[..tag_start],
374 picture,
375 &result[tag_end..]
376 );
377 break; }
379 }
380 }
381 }
382
383 result
384}
385
386#[cfg(feature = "image-optimization")]
387fn extract_attr(tag: &str, attr: &str) -> Option<String> {
388 let pattern = format!("{attr}=\"");
389 let start = tag.find(&pattern)? + pattern.len();
390 let end = tag[start..].find('"')? + start;
391 Some(tag[start..end].to_string())
392}
393
394#[cfg(feature = "image-optimization")]
398fn collect_images(dir: &Path) -> Result<Vec<PathBuf>> {
399 let all = crate::walk::walk_files_multi(dir, &["jpg", "jpeg", "png"])?;
400 Ok(all
401 .into_iter()
402 .filter(|p| !p.components().any(|c| c.as_os_str() == "optimized"))
403 .collect())
404}
405
406#[cfg(feature = "image-optimization")]
407fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>> {
408 crate::walk::walk_files(dir, "html")
409}
410
411#[cfg(all(test, feature = "image-optimization"))]
412#[allow(clippy::unwrap_used, clippy::expect_used)]
413mod tests {
414 use super::*;
415 use tempfile::tempdir;
416
417 fn write_test_jpeg(path: &Path, w: u32, h: u32) {
423 let buf = image::ImageBuffer::from_fn(w, h, |x, y| {
424 image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
425 });
426 image::DynamicImage::ImageRgb8(buf)
427 .save_with_format(path, image::ImageFormat::Jpeg)
428 .expect("write jpeg");
429 }
430
431 fn write_test_png(path: &Path, w: u32, h: u32) {
433 let buf = image::ImageBuffer::from_fn(w, h, |x, y| {
434 image::Rgba([(x % 256) as u8, (y % 256) as u8, 200, 255])
435 });
436 image::DynamicImage::ImageRgba8(buf)
437 .save_with_format(path, image::ImageFormat::Png)
438 .expect("write png");
439 }
440
441 fn manifest_with(
443 original_rel: &str,
444 width: u32,
445 height: u32,
446 variant_widths: &[u32],
447 ) -> HashMap<String, ImageManifest> {
448 let stem = original_rel
449 .rsplit('/')
450 .next()
451 .unwrap_or(original_rel)
452 .rsplit('.')
453 .nth(1)
454 .unwrap_or("img");
455 let webp_variants = variant_widths
456 .iter()
457 .map(|&w| ImageVariant {
458 rel_path: format!("optimized/{stem}-{w}w.webp"),
459 width: w,
460 })
461 .collect();
462 let mut m = HashMap::new();
463 let _ = m.insert(
464 original_rel.to_string(),
465 ImageManifest {
466 original_rel: original_rel.to_string(),
467 original_width: width,
468 original_height: height,
469 webp_variants,
470 avif_variants: Vec::new(),
471 },
472 );
473 m
474 }
475
476 fn manifest_with_avif(
478 original_rel: &str,
479 width: u32,
480 height: u32,
481 variant_widths: &[u32],
482 ) -> HashMap<String, ImageManifest> {
483 let stem = original_rel
484 .rsplit('/')
485 .next()
486 .unwrap_or(original_rel)
487 .rsplit('.')
488 .nth(1)
489 .unwrap_or("img");
490 let webp_variants = variant_widths
491 .iter()
492 .map(|&w| ImageVariant {
493 rel_path: format!("optimized/{stem}-{w}w.webp"),
494 width: w,
495 })
496 .collect();
497 let avif_variants = variant_widths
498 .iter()
499 .map(|&w| ImageVariant {
500 rel_path: format!("optimized/{stem}-{w}w.avif"),
501 width: w,
502 })
503 .collect();
504 let mut m = HashMap::new();
505 let _ = m.insert(
506 original_rel.to_string(),
507 ImageManifest {
508 original_rel: original_rel.to_string(),
509 original_width: width,
510 original_height: height,
511 webp_variants,
512 avif_variants,
513 },
514 );
515 m
516 }
517
518 #[test]
523 fn default_plugin_has_expected_quality_and_breakpoints() {
524 let plugin = ImageOptimizationPlugin::default();
525 assert_eq!(plugin.quality, 80);
526 assert_eq!(plugin.breakpoints, vec![320, 640, 1024, 1440]);
527 }
528
529 #[test]
530 fn plugin_allows_custom_quality_and_breakpoints() {
531 let plugin = ImageOptimizationPlugin {
532 quality: 90,
533 breakpoints: vec![480, 960],
534 };
535 assert_eq!(plugin.quality, 90);
536 assert_eq!(plugin.breakpoints, vec![480, 960]);
537 }
538
539 #[test]
540 fn name_returns_static_image_optimization_identifier() {
541 assert_eq!(
542 ImageOptimizationPlugin::default().name(),
543 "image-optimization"
544 );
545 }
546
547 #[test]
552 fn extract_attr_table_driven_inputs() {
553 let cases: &[(&str, &str, Option<&str>)] = &[
554 (r#"<img src="x.jpg" alt="Photo">"#, "alt", Some("Photo")),
555 (r#"<img src="x.jpg">"#, "alt", None),
556 (r#"<img alt="">"#, "alt", Some("")),
557 (
558 r#"<img alt="multi word value">"#,
559 "alt",
560 Some("multi word value"),
561 ),
562 (r#"<img src="x.jpg" alt="P">"#, "src", Some("x.jpg")),
563 (r"<img>", "src", None),
564 (
565 r#"<img fetchpriority="high" src="x.jpg">"#,
566 "fetchpriority",
567 Some("high"),
568 ),
569 ];
570 for &(tag, attr, expected) in cases {
571 let actual = extract_attr(tag, attr);
572 assert_eq!(
573 actual.as_deref(),
574 expected,
575 "extract_attr({tag:?}, {attr:?}) should be {expected:?}"
576 );
577 }
578 }
579
580 #[test]
585 fn rewrite_img_tags_replaces_img_with_picture_element() {
586 let manifest =
587 manifest_with("images/photo.jpg", 2000, 1500, &[640, 1024]);
588 let html = r#"<img src="images/photo.jpg" alt="A photo">"#;
589
590 let result = rewrite_img_tags(html, &manifest);
591
592 assert!(result.contains("<picture>"));
593 assert!(result.contains("</picture>"));
594 assert!(result.contains(r#"type="image/webp""#));
595 assert!(result.contains("srcset="));
596 assert!(result.contains("640w"));
597 assert!(result.contains("1024w"));
598 assert!(result.contains(r#"loading="lazy""#));
599 assert!(result.contains(r#"decoding="async""#));
600 assert!(result.contains(r#"width="2000""#));
601 assert!(result.contains(r#"height="1500""#));
602 assert!(result.contains(r#"alt="A photo""#));
603 }
604
605 #[test]
606 fn rewrite_img_tags_preserves_alt_text() {
607 let manifest = manifest_with("a.jpg", 2000, 1000, &[640]);
608 let html = r#"<img src="a.jpg" alt="Important context">"#;
609 let result = rewrite_img_tags(html, &manifest);
610 assert!(result.contains(r#"alt="Important context""#));
611 }
612
613 #[test]
614 fn rewrite_img_tags_handles_missing_alt_with_empty_string() {
615 let manifest = manifest_with("a.jpg", 2000, 1000, &[640]);
616 let html = r#"<img src="a.jpg">"#;
617 let result = rewrite_img_tags(html, &manifest);
618 assert!(result.contains(r#"alt="""#));
619 }
620
621 #[test]
622 fn rewrite_img_tags_handles_absolute_src_path() {
623 let manifest = manifest_with("images/a.jpg", 2000, 1000, &[640]);
624 let html = r#"<img src="/images/a.jpg" alt="">"#;
625 let result = rewrite_img_tags(html, &manifest);
626 assert!(result.contains("<picture>"));
627 }
628
629 #[test]
630 fn rewrite_img_tags_no_match_returns_unchanged() {
631 let manifest = manifest_with("ghost.jpg", 100, 100, &[640]);
632 let html = r"<p>no images here</p>";
633 let result = rewrite_img_tags(html, &manifest);
634 assert_eq!(result, html);
635 }
636
637 #[test]
638 fn rewrite_img_tags_skips_entries_with_no_variants() {
639 let manifest = manifest_with("a.jpg", 2000, 1000, &[]);
640 let html = r#"<img src="a.jpg" alt="x">"#;
641 let result = rewrite_img_tags(html, &manifest);
642 assert_eq!(result, html, "no variants → no rewrite");
643 }
644
645 #[test]
650 fn rewrite_img_tags_builds_srcset_with_width_descriptors() {
651 let manifest =
652 manifest_with("a.jpg", 4000, 3000, &[320, 640, 1024, 1440]);
653 let html = r#"<img src="a.jpg" alt="">"#;
654 let result = rewrite_img_tags(html, &manifest);
655 for w in [320, 640, 1024, 1440] {
656 assert!(
657 result.contains(&format!("{w}w")),
658 "srcset should contain {w}w:\n{result}"
659 );
660 }
661 assert!(result.matches(", ").count() >= 3);
662 }
663
664 #[test]
665 fn rewrite_img_tags_srcset_paths_are_absolute() {
666 let manifest = manifest_with("a.jpg", 2000, 1000, &[640]);
667 let html = r#"<img src="a.jpg" alt="">"#;
668 let result = rewrite_img_tags(html, &manifest);
669 assert!(
670 result.contains("/optimized/a-640w.webp 640w"),
671 "srcset paths should be absolute: {result}"
672 );
673 }
674
675 #[test]
680 fn rewrite_img_tags_default_loading_is_lazy() {
681 let manifest = manifest_with("a.jpg", 2000, 1000, &[640]);
682 let html = r#"<img src="a.jpg" alt="">"#;
683 let result = rewrite_img_tags(html, &manifest);
684 assert!(result.contains(r#"loading="lazy""#));
685 assert!(result.contains(r#"decoding="async""#));
686 }
687
688 #[test]
689 fn rewrite_img_tags_fetchpriority_high_gets_eager_loading() {
690 let manifest = manifest_with("hero.jpg", 2000, 1000, &[640]);
691 let html = r#"<img src="hero.jpg" alt="Hero" fetchpriority="high">"#;
692 let result = rewrite_img_tags(html, &manifest);
693 assert!(
694 result.contains(r#"loading="eager""#),
695 "fetchpriority=high should produce loading=eager: {result}"
696 );
697 assert!(
698 result.contains(r#"fetchpriority="high""#),
699 "fetchpriority attribute should be preserved: {result}"
700 );
701 }
702
703 #[test]
704 fn rewrite_img_tags_fetchpriority_low_still_lazy() {
705 let manifest = manifest_with("bg.jpg", 2000, 1000, &[640]);
706 let html = r#"<img src="bg.jpg" alt="" fetchpriority="low">"#;
707 let result = rewrite_img_tags(html, &manifest);
708 assert!(result.contains(r#"loading="lazy""#));
709 }
710
711 #[test]
716 fn rewrite_img_tags_includes_avif_source_when_present() {
717 let manifest =
718 manifest_with_avif("photo.jpg", 2000, 1500, &[640, 1024]);
719 let html = r#"<img src="photo.jpg" alt="">"#;
720 let result = rewrite_img_tags(html, &manifest);
721
722 assert!(
723 result.contains(r#"type="image/avif""#),
724 "should have AVIF source: {result}"
725 );
726 assert!(
727 result.contains(r#"type="image/webp""#),
728 "should have WebP source: {result}"
729 );
730
731 let avif_pos = result.find("image/avif").expect("avif present");
733 let webp_pos = result.find("image/webp").expect("webp present");
734 assert!(
735 avif_pos < webp_pos,
736 "AVIF source should precede WebP source"
737 );
738 }
739
740 #[test]
741 fn rewrite_img_tags_avif_srcset_has_correct_format() {
742 let manifest = manifest_with_avif("photo.jpg", 2000, 1500, &[320, 640]);
743 let html = r#"<img src="photo.jpg" alt="">"#;
744 let result = rewrite_img_tags(html, &manifest);
745
746 assert!(
747 result.contains("/optimized/photo-320w.avif 320w"),
748 "AVIF srcset should have width descriptors: {result}"
749 );
750 assert!(
751 result.contains("/optimized/photo-640w.avif 640w"),
752 "AVIF srcset should have width descriptors: {result}"
753 );
754 }
755
756 #[test]
761 fn rewrite_img_tags_injects_dimensions_from_manifest() {
762 let manifest = manifest_with("a.jpg", 1920, 1080, &[640]);
763 let html = r#"<img src="a.jpg" alt="">"#;
764 let result = rewrite_img_tags(html, &manifest);
765 assert!(result.contains(r#"width="1920""#));
766 assert!(result.contains(r#"height="1080""#));
767 }
768
769 #[test]
770 fn rewrite_img_tags_preserves_explicit_width_height() {
771 let manifest = manifest_with("a.jpg", 1920, 1080, &[640]);
772 let html = r#"<img src="a.jpg" alt="" width="800" height="450">"#;
773 let result = rewrite_img_tags(html, &manifest);
774 assert!(
775 result.contains(r#"width="800""#),
776 "explicit width should be preserved: {result}"
777 );
778 assert!(
779 result.contains(r#"height="450""#),
780 "explicit height should be preserved: {result}"
781 );
782 }
783
784 #[test]
785 fn rewrite_img_tags_only_replaces_first_occurrence_per_image() {
786 let manifest = manifest_with("a.jpg", 2000, 1000, &[640]);
787 let html = r#"<img src="a.jpg"><img src="a.jpg">"#;
788 let result = rewrite_img_tags(html, &manifest);
789 assert_eq!(result.matches("<picture>").count(), 1);
790 }
791
792 #[test]
797 fn collect_images_skips_optimized_subdirectory() {
798 let dir = tempdir().expect("tempdir");
799 let site = dir.path().join("site");
800 let opt = site.join("optimized");
801 fs::create_dir_all(&opt).unwrap();
802
803 fs::write(site.join("photo.jpg"), [0xFF, 0xD8]).unwrap();
804 fs::write(opt.join("photo-640w.webp"), [0]).unwrap();
805
806 let images = collect_images(&site).unwrap();
807 assert_eq!(images.len(), 1);
808 assert!(images[0].ends_with("photo.jpg"));
809 }
810
811 #[test]
812 fn collect_images_filters_to_jpg_jpeg_png_only() {
813 let dir = tempdir().expect("tempdir");
814 for name in ["a.jpg", "b.jpeg", "c.png", "d.gif", "e.webp", "f.txt"] {
815 fs::write(dir.path().join(name), [0]).unwrap();
816 }
817 let images = collect_images(dir.path()).unwrap();
818 assert_eq!(images.len(), 3, "only jpg/jpeg/png should be collected");
819 }
820
821 #[test]
822 fn collect_images_extension_match_is_case_insensitive() {
823 let dir = tempdir().expect("tempdir");
824 for name in ["A.JPG", "B.PNG", "C.JPEG"] {
825 fs::write(dir.path().join(name), [0]).unwrap();
826 }
827 let images = collect_images(dir.path()).unwrap();
828 assert_eq!(images.len(), 3);
829 }
830
831 #[test]
832 fn collect_images_recurses_into_nested_subdirectories() {
833 let dir = tempdir().expect("tempdir");
834 let nested = dir.path().join("a").join("b");
835 fs::create_dir_all(&nested).unwrap();
836 fs::write(dir.path().join("top.jpg"), [0]).unwrap();
837 fs::write(nested.join("deep.png"), [0]).unwrap();
838
839 let images = collect_images(dir.path()).unwrap();
840 assert_eq!(images.len(), 2);
841 }
842
843 #[test]
844 fn collect_images_returns_empty_for_missing_directory() {
845 let dir = tempdir().expect("tempdir");
846 let result = collect_images(&dir.path().join("missing")).unwrap();
847 assert!(result.is_empty());
848 }
849
850 #[test]
851 fn collect_images_returns_results_sorted() {
852 let dir = tempdir().expect("tempdir");
853 for name in ["zebra.jpg", "apple.jpg", "mango.jpg"] {
854 fs::write(dir.path().join(name), [0]).unwrap();
855 }
856 let images = collect_images(dir.path()).unwrap();
857 let names: Vec<_> = images
858 .iter()
859 .map(|p| p.file_name().unwrap().to_str().unwrap())
860 .collect();
861 assert_eq!(names, vec!["apple.jpg", "mango.jpg", "zebra.jpg"]);
862 }
863
864 #[test]
869 fn collect_html_files_filters_non_html_extensions() {
870 let dir = tempdir().expect("tempdir");
871 fs::write(dir.path().join("a.html"), "").unwrap();
872 fs::write(dir.path().join("b.css"), "").unwrap();
873
874 let result = collect_html_files(dir.path()).unwrap();
875 assert_eq!(result.len(), 1);
876 }
877
878 #[test]
879 fn collect_html_files_recurses_and_sorts() {
880 let dir = tempdir().expect("tempdir");
881 let nested = dir.path().join("blog");
882 fs::create_dir_all(&nested).unwrap();
883 fs::write(dir.path().join("index.html"), "").unwrap();
884 fs::write(nested.join("post.html"), "").unwrap();
885
886 let result = collect_html_files(dir.path()).unwrap();
887 assert_eq!(result.len(), 2);
888 }
889
890 #[test]
895 fn after_compile_missing_site_dir_returns_ok() {
896 let dir = tempdir().expect("tempdir");
897 let missing = dir.path().join("missing");
898 let ctx =
899 PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
900 ImageOptimizationPlugin::default()
901 .after_compile(&ctx)
902 .expect("missing site is not an error");
903 assert!(!missing.exists());
904 }
905
906 #[test]
911 fn process_image_generates_webp_variants_below_original_width() {
912 let dir = tempdir().expect("tempdir");
913 let site = dir.path().join("site");
914 let opt = site.join("optimized");
915 fs::create_dir_all(&opt).unwrap();
916
917 let src = site.join("hero.jpg");
918 write_test_jpeg(&src, 2000, 1000);
919
920 let manifest = process_image(
921 &src,
922 &site,
923 &opt,
924 &[320, 640, 1024, 1440],
925 DEFAULT_QUALITY,
926 )
927 .unwrap();
928 assert_eq!(manifest.original_width, 2000);
929 assert_eq!(manifest.original_height, 1000);
930 assert_eq!(manifest.original_rel, "hero.jpg");
931
932 assert_eq!(manifest.webp_variants.len(), 4);
934 for v in &manifest.webp_variants {
935 assert!(opt
936 .join(v.rel_path.trim_start_matches("optimized/"))
937 .exists());
938 assert!(v.width < 2000);
939 }
940 }
941
942 #[test]
943 fn process_image_skips_widths_larger_than_original() {
944 let dir = tempdir().expect("tempdir");
945 let site = dir.path().join("site");
946 let opt = site.join("optimized");
947 fs::create_dir_all(&opt).unwrap();
948
949 let src = site.join("small.png");
950 write_test_png(&src, 500, 500);
951
952 let manifest = process_image(
953 &src,
954 &site,
955 &opt,
956 &[320, 640, 1024, 1440],
957 DEFAULT_QUALITY,
958 )
959 .unwrap();
960 assert_eq!(manifest.webp_variants.len(), 1);
962 assert_eq!(manifest.webp_variants[0].width, 320);
963 }
964
965 #[test]
966 fn process_image_uses_custom_breakpoints() {
967 let dir = tempdir().expect("tempdir");
968 let site = dir.path().join("site");
969 let opt = site.join("optimized");
970 fs::create_dir_all(&opt).unwrap();
971
972 let src = site.join("photo.jpg");
973 write_test_jpeg(&src, 2000, 1000);
974
975 let manifest =
976 process_image(&src, &site, &opt, &[480, 960], DEFAULT_QUALITY)
977 .unwrap();
978 assert_eq!(manifest.webp_variants.len(), 2);
979 assert_eq!(manifest.webp_variants[0].width, 480);
980 assert_eq!(manifest.webp_variants[1].width, 960);
981 }
982
983 #[test]
984 fn process_image_rejects_unreadable_source_path() {
985 let dir = tempdir().expect("tempdir");
986 let opt = dir.path().join("opt");
987 fs::create_dir_all(&opt).unwrap();
988 let missing = dir.path().join("does-not-exist.jpg");
989 assert!(process_image(
990 &missing,
991 dir.path(),
992 &opt,
993 DEFAULT_BREAKPOINTS,
994 DEFAULT_QUALITY
995 )
996 .is_err());
997 }
998
999 #[test]
1004 fn after_compile_processes_real_images_and_rewrites_html() {
1005 let dir = tempdir().expect("tempdir");
1006 let site = dir.path().join("site");
1007 let images = site.join("images");
1008 fs::create_dir_all(&images).unwrap();
1009
1010 write_test_jpeg(&images.join("photo.jpg"), 2000, 1500);
1011 fs::write(
1012 site.join("index.html"),
1013 r#"<html><head></head><body><img src="/images/photo.jpg" alt="Test"></body></html>"#,
1014 )
1015 .unwrap();
1016
1017 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1018 ImageOptimizationPlugin::default()
1019 .after_compile(&ctx)
1020 .unwrap();
1021
1022 assert!(images.join("photo.jpg").exists());
1024 assert!(site.join("optimized").exists());
1026 let html = fs::read_to_string(site.join("index.html")).unwrap();
1028 assert!(html.contains("<picture>"));
1029 assert!(html.contains("image/webp"));
1030 assert!(html.contains(r#"alt="Test""#));
1031 assert!(html.contains(r#"loading="lazy""#));
1032 assert!(html.contains(r#"decoding="async""#));
1033 }
1034
1035 #[test]
1036 fn after_compile_failed_image_processing_logs_warn_and_continues() {
1037 let dir = tempdir().expect("tempdir");
1038 let site = dir.path().join("site");
1039 fs::create_dir_all(&site).unwrap();
1040
1041 fs::write(site.join("broken.jpg"), b"this is not really a jpeg")
1042 .unwrap();
1043
1044 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1045 ImageOptimizationPlugin::default()
1046 .after_compile(&ctx)
1047 .expect("broken image must not propagate");
1048 }
1049
1050 #[test]
1051 fn after_compile_html_without_image_refs_skips_rewrite() {
1052 let dir = tempdir().expect("tempdir");
1053 let site = dir.path().join("site");
1054 let images = site.join("images");
1055 fs::create_dir_all(&images).unwrap();
1056
1057 write_test_jpeg(&images.join("orphan.jpg"), 1000, 1000);
1058 let original_html =
1059 "<html><head></head><body><p>no images here</p></body></html>";
1060 fs::write(site.join("index.html"), original_html).unwrap();
1061
1062 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1063 ImageOptimizationPlugin::default()
1064 .after_compile(&ctx)
1065 .unwrap();
1066
1067 let after = fs::read_to_string(site.join("index.html")).unwrap();
1068 assert_eq!(
1069 after, original_html,
1070 "html with no image refs should not be rewritten"
1071 );
1072 }
1073
1074 #[test]
1075 fn after_compile_no_images_short_circuits_without_creating_optimized_dir() {
1076 let dir = tempdir().expect("tempdir");
1077 let site = dir.path().join("site");
1078 fs::create_dir_all(&site).unwrap();
1079 fs::write(site.join("index.html"), "<p></p>").unwrap();
1080
1081 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1082 ImageOptimizationPlugin::default()
1083 .after_compile(&ctx)
1084 .unwrap();
1085 assert!(!site.join("optimized").exists());
1086 }
1087}