Skip to main content

ssg/
image_plugin.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Image optimization plugin.
5//!
6//! Processes images to generate WebP variants and responsive `<picture>`
7//! elements with `srcset`, `loading="lazy"`, and `decoding="async"`.
8//!
9//! ## Responsive pipeline
10//!
11//! For each `<img>` in compiled HTML the plugin emits:
12//!
13//! ```html
14//! <picture>
15//!   <source type="image/avif" srcset="…-320w.avif 320w, …"> <!-- if avif feature -->
16//!   <source type="image/webp" srcset="…-320w.webp 320w, …">
17//!   <img src="/original.jpg" alt="…" width="…" height="…"
18//!        loading="lazy" decoding="async">
19//! </picture>
20//! ```
21//!
22//! Images tagged with `fetchpriority="high"` receive `loading="eager"`
23//! instead, so the browser fetches them immediately.
24
25#[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/// Default responsive breakpoints (px).
37#[cfg(feature = "image-optimization")]
38const DEFAULT_BREAKPOINTS: &[u32] = &[320, 640, 1024, 1440];
39
40/// Default WebP encoding quality (1–100).
41#[cfg(feature = "image-optimization")]
42const DEFAULT_QUALITY: u8 = 80;
43
44/// Plugin that optimises images and rewrites HTML with `<picture>` tags.
45///
46/// Runs in `after_compile`:
47/// 1. Scans `site_dir` for JPEG/PNG images
48/// 2. Generates WebP variants at responsive widths
49/// 3. Rewrites `<img>` tags to `<picture>` with `srcset`
50/// 4. Adds `loading="lazy"`, `decoding="async"`, `width`, `height`
51///
52/// The quality and breakpoints are configurable via the struct fields.
53#[cfg(feature = "image-optimization")]
54#[derive(Debug, Clone)]
55pub struct ImageOptimizationPlugin {
56    /// WebP encoding quality (1–100). Defaults to 80.
57    pub quality: u8,
58    /// Responsive width breakpoints in pixels. Defaults to `[320, 640, 1024, 1440]`.
59    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/// Optimizes all images and builds the manifest, logging warnings for failures.
131#[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/// Rewrites HTML files to use `<picture>` tags for optimized images.
163#[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/// Processes a single image: resize + encode to WebP (and AVIF if available)
180/// at responsive widths.
181#[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; // Skip sizes larger than original
207        }
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        // Save WebP variant
218        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        // AVIF encoding would go here if the `image` crate is built
231        // with AVIF support (requires the `avif` feature). Since AVIF
232        // encoding pulls in heavy C dependencies (libdav1d, rav1e) it
233        // is left opt-in and the pipeline gracefully degrades to
234        // WebP + original.
235    }
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/// Rewrites `<img src="...">` tags to `<picture>` with srcset.
247///
248/// For each image in the manifest that has variants, the original
249/// `<img>` tag is wrapped in a `<picture>` element with:
250/// - `<source type="image/avif" srcset="...">` (if AVIF variants exist)
251/// - `<source type="image/webp" srcset="...">`
252/// - `<img>` fallback with `loading`, `decoding`, `width`, `height`
253///
254/// Images with `fetchpriority="high"` get `loading="eager"` instead of
255/// `loading="lazy"`.
256#[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        // Build WebP srcset
269        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        // Build AVIF srcset
277        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        // Find and replace <img src="...original_rel...">
285        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                // Find the <img that contains this src
293                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                    // Extract existing attributes
302                    let alt = extract_attr(old_tag, "alt").unwrap_or_default();
303                    let fetchpriority = extract_attr(old_tag, "fetchpriority");
304
305                    // Determine loading strategy
306                    let loading = if fetchpriority.as_deref() == Some("high") {
307                        "eager"
308                    } else {
309                        "lazy"
310                    };
311
312                    // Preserve original width/height if present, else use source dimensions
313                    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                    // Build <picture> element
323                    let mut picture = String::from("<picture>");
324
325                    // AVIF source (if variants exist)
326                    if !avif_srcset.is_empty() {
327                        picture.push_str(&format!(
328                            "<source type=\"image/avif\" srcset=\"{avif_srcset}\" sizes=\"{sizes}\">"
329                        ));
330                    }
331
332                    // WebP source
333                    if !webp_srcset.is_empty() {
334                        picture.push_str(&format!(
335                            "<source type=\"image/webp\" srcset=\"{webp_srcset}\" sizes=\"{sizes}\">"
336                        ));
337                    }
338
339                    // Fallback <img>
340                    picture.push_str(&format!(
341                        "<img src=\"/{original_rel}\" alt=\"{alt}\" \
342                         width=\"{width}\" height=\"{height}\" \
343                         loading=\"{loading}\" decoding=\"async\">"
344                    ));
345
346                    // fetchpriority on the img if present
347                    if let Some(ref fp) = fetchpriority {
348                        // Re-build: remove the closing > we just added, insert fetchpriority
349                        // Actually, let's build it properly from scratch
350                        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; // Only replace first occurrence per image
378                }
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/// Collect all `.jpg`/`.jpeg`/`.png` files under `dir`, skipping any
395/// that live inside an `optimized/` subdirectory (the plugin's own
396/// output directory — must not be re-processed).
397#[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    // -------------------------------------------------------------------
418    // Test fixtures
419    // -------------------------------------------------------------------
420
421    /// Writes a tiny programmatically-generated JPEG to the given path.
422    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    /// Writes a tiny programmatically-generated PNG to the given path.
432    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    /// Builds an in-memory `ImageManifest` with the supplied WebP variants.
442    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    /// Builds a manifest with both WebP and AVIF variants.
477    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    // -------------------------------------------------------------------
519    // ImageOptimizationPlugin — struct configuration
520    // -------------------------------------------------------------------
521
522    #[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    // -------------------------------------------------------------------
548    // extract_attr — table-driven over the success / failure paths
549    // -------------------------------------------------------------------
550
551    #[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    // -------------------------------------------------------------------
581    // rewrite_img_tags — picture element generation
582    // -------------------------------------------------------------------
583
584    #[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    // -------------------------------------------------------------------
646    // rewrite_img_tags — srcset format
647    // -------------------------------------------------------------------
648
649    #[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    // -------------------------------------------------------------------
676    // rewrite_img_tags — lazy loading defaults
677    // -------------------------------------------------------------------
678
679    #[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    // -------------------------------------------------------------------
712    // rewrite_img_tags — AVIF + WebP picture element
713    // -------------------------------------------------------------------
714
715    #[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        // AVIF should come before WebP (browser picks first match)
732        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    // -------------------------------------------------------------------
757    // rewrite_img_tags — width/height preservation
758    // -------------------------------------------------------------------
759
760    #[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    // -------------------------------------------------------------------
793    // collect_images — extension filter + optimized-dir skip
794    // -------------------------------------------------------------------
795
796    #[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    // -------------------------------------------------------------------
865    // collect_html_files — recursion + filtering
866    // -------------------------------------------------------------------
867
868    #[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    // -------------------------------------------------------------------
891    // after_compile — short-circuit paths (no real images)
892    // -------------------------------------------------------------------
893
894    #[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    // -------------------------------------------------------------------
907    // process_image — real JPEG/PNG round-trip
908    // -------------------------------------------------------------------
909
910    #[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        // Every breakpoint strictly less than 2000 must produce a variant.
933        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        // Only 320 should survive (320 < 500).
961        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    // -------------------------------------------------------------------
1000    // after_compile — end-to-end on real images
1001    // -------------------------------------------------------------------
1002
1003    #[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        // Original file preserved.
1023        assert!(images.join("photo.jpg").exists());
1024        // Optimized directory populated.
1025        assert!(site.join("optimized").exists());
1026        // HTML rewritten to <picture>.
1027        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}