Skip to main content

ssg/audit/gates/
images.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Image optimisation + alt-text gate.
5//!
6//! Per page:
7//! - Every `<img>` has an `alt` attribute (error).
8//! - Every `<img>` has explicit `width` + `height` (warn — required
9//!   to avoid CLS).
10//! - Each referenced image file has a sibling `.webp` or `.avif` (warn).
11//! - Each referenced image file is under the configured size budget
12//!   (warn).
13
14use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
15use super::{find_tag_end, hreflang_attr};
16
17const NAME: &str = "images";
18
19/// Image optimisation + alt-text gate.
20///
21/// # Examples
22///
23/// ```
24/// use ssg::audit::AuditGate;
25/// use ssg::audit::gates::images::ImagesGate;
26/// assert_eq!(ImagesGate.name(), "images");
27/// ```
28#[derive(Debug, Clone, Copy)]
29pub struct ImagesGate;
30
31impl AuditGate for ImagesGate {
32    fn name(&self) -> &'static str {
33        NAME
34    }
35
36    fn explain(&self) -> &'static str {
37        "Per <img>: asserts alt text (error), explicit width + height \
38         (warn), and that the referenced file has a sibling .webp or \
39         .avif source (warn — image-optimization plugin emits both). \
40         Files larger than `image_budget` raise a warn so authors \
41         catch unoptimised originals."
42    }
43
44    fn run(&self, site: &Site, opts: &AuditOptions) -> Vec<Finding> {
45        let mut findings = Vec::new();
46        for path in &site.html_files {
47            let Ok(html) = site.read(path) else { continue };
48            let rel = site.rel(path);
49            for img in extract_imgs(&html) {
50                if img.alt.is_none() {
51                    findings.push(
52                        Finding::new(
53                            NAME,
54                            Severity::Error,
55                            format!("<img src=\"{}\"> missing alt", img.src),
56                        )
57                        .with_code("IMG-ALT")
58                        .with_path(rel.clone()),
59                    );
60                }
61                if img.width.is_none() || img.height.is_none() {
62                    findings.push(
63                        Finding::new(
64                            NAME,
65                            Severity::Warn,
66                            format!(
67                                "<img src=\"{}\"> missing explicit width/height (CLS risk)",
68                                img.src
69                            ),
70                        )
71                        .with_code("IMG-DIMS")
72                        .with_path(rel.clone()),
73                    );
74                }
75                if img.src.starts_with("http://")
76                    || img.src.starts_with("https://")
77                    || img.src.starts_with("//")
78                    || img.src.starts_with("data:")
79                {
80                    continue;
81                }
82                let candidate = resolve_img_candidate(site, path, &img.src);
83                if let Ok(meta) = std::fs::metadata(&candidate) {
84                    if meta.len() as usize > opts.image_budget {
85                        findings.push(
86                            Finding::new(
87                                NAME,
88                                Severity::Warn,
89                                format!(
90                                    "{} weighs {} bytes (budget {})",
91                                    img.src,
92                                    meta.len(),
93                                    opts.image_budget
94                                ),
95                            )
96                            .with_code("IMG-OVER-BUDGET")
97                            .with_path(rel.clone()),
98                        );
99                    }
100                    // A vector source is already the resolution-independent
101                    // format; a raster sibling would be a downgrade, so asking
102                    // for one produces a warning nothing can ever clear.
103                    let is_vector = candidate
104                        .extension()
105                        .and_then(|e| e.to_str())
106                        .is_some_and(|e| e.eq_ignore_ascii_case("svg"));
107                    // Check for sibling .webp / .avif
108                    let stem = candidate.with_extension("");
109                    let has_webp = stem.with_extension("webp").exists();
110                    let has_avif = stem.with_extension("avif").exists();
111                    if !is_vector && !has_webp && !has_avif {
112                        findings.push(
113                            Finding::new(
114                                NAME,
115                                Severity::Warn,
116                                format!(
117                                    "{} has no sibling .webp or .avif source",
118                                    img.src
119                                ),
120                            )
121                            .with_code("IMG-NO-MODERN")
122                            .with_path(rel.clone()),
123                        );
124                    }
125                }
126            }
127        }
128        findings
129    }
130}
131
132/// Resolves an `<img src>` value to the on-disk file it references:
133/// site-absolute paths anchor at the site root, relative paths at the
134/// containing page's directory (falling back to the root when the page
135/// path has no parent component).
136fn resolve_img_candidate(
137    site: &Site,
138    page: &std::path::Path,
139    src: &str,
140) -> std::path::PathBuf {
141    if let Some(s) = src.strip_prefix('/') {
142        site.root.join(s)
143    } else if let Some(parent) = page.parent() {
144        parent.join(src)
145    } else {
146        site.root.join(src)
147    }
148}
149
150struct ImgRef {
151    src: String,
152    alt: Option<String>,
153    width: Option<String>,
154    height: Option<String>,
155}
156
157fn extract_imgs(html: &str) -> Vec<ImgRef> {
158    let mut out = Vec::new();
159    let lower = html.to_ascii_lowercase();
160    let mut cursor = 0;
161    while let Some(rel) = lower[cursor..].find("<img") {
162        let abs = cursor + rel;
163        // Quote-aware end detection: SVG data-URIs in `src` carry raw
164        // `>` characters that a naive find('>') truncates on.
165        let end = find_tag_end(html, abs);
166        let tag = &html[abs..end];
167        cursor = end;
168        let src = hreflang_attr(tag, "src").unwrap_or_default();
169        let alt = hreflang_attr(tag, "alt");
170        let width = hreflang_attr(tag, "width");
171        let height = hreflang_attr(tag, "height");
172        out.push(ImgRef {
173            src,
174            alt,
175            width,
176            height,
177        });
178    }
179    out
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    fn site_with(html: &str, image_bytes: usize) -> Site {
187        let tmp = tempfile::tempdir().unwrap();
188        let root = tmp.path().to_path_buf();
189        let p = root.join("page.html");
190        std::fs::write(&p, html).unwrap();
191        std::fs::write(root.join("a.jpg"), vec![0u8; image_bytes]).unwrap();
192        std::fs::write(root.join("a.webp"), vec![0u8; 10]).unwrap();
193        std::mem::forget(tmp);
194        Site {
195            root,
196            html_files: vec![p],
197        }
198    }
199
200    #[test]
201    fn passing_image_is_clean() {
202        let html = r#"<html><body><img src="a.jpg" alt="a" width="10" height="10"></body></html>"#;
203        let f = ImagesGate.run(&site_with(html, 10), &AuditOptions::default());
204        assert!(f.is_empty(), "got {f:?}");
205    }
206
207    #[test]
208    fn missing_alt_flags_error() {
209        let html = r#"<html><body><img src="a.jpg" width="10" height="10"></body></html>"#;
210        let f = ImagesGate.run(&site_with(html, 10), &AuditOptions::default());
211        assert!(f.iter().any(|x| x.code.as_deref() == Some("IMG-ALT")));
212    }
213
214    #[test]
215    fn over_budget_image_flagged() {
216        let html = r#"<html><body><img src="a.jpg" alt="a" width="10" height="10"></body></html>"#;
217        let f = ImagesGate.run(
218            &site_with(html, 5000),
219            &AuditOptions {
220                image_budget: 100,
221                ..AuditOptions::default()
222            },
223        );
224        assert!(f
225            .iter()
226            .any(|x| x.code.as_deref() == Some("IMG-OVER-BUDGET")));
227    }
228
229    #[test]
230    fn missing_width_height_warns_with_dims_code() {
231        let html = r#"<html><body><img src="a.jpg" alt="a"></body></html>"#;
232        let f = ImagesGate.run(&site_with(html, 10), &AuditOptions::default());
233        let dims = f
234            .iter()
235            .find(|x| x.code.as_deref() == Some("IMG-DIMS"))
236            .expect("dims finding");
237        assert_eq!(dims.severity, Severity::Warn);
238    }
239
240    #[test]
241    fn missing_only_height_still_flags_dims() {
242        let html =
243            r#"<html><body><img src="a.jpg" alt="a" width="10"></body></html>"#;
244        let f = ImagesGate.run(&site_with(html, 10), &AuditOptions::default());
245        assert!(f.iter().any(|x| x.code.as_deref() == Some("IMG-DIMS")));
246    }
247
248    #[test]
249    fn no_modern_sibling_warns() {
250        let tmp = tempfile::tempdir().unwrap();
251        let root = tmp.path().to_path_buf();
252        std::fs::write(root.join("plain.png"), vec![0u8; 10]).unwrap();
253        let html_path = root.join("page.html");
254        std::fs::write(
255            &html_path,
256            r#"<html><body><img src="plain.png" alt="p" width="1" height="1"></body></html>"#,
257        )
258        .unwrap();
259        std::mem::forget(tmp);
260        let s = Site {
261            root,
262            html_files: vec![html_path],
263        };
264        let f = ImagesGate.run(&s, &AuditOptions::default());
265        assert!(f.iter().any(|x| x.code.as_deref() == Some("IMG-NO-MODERN")));
266    }
267
268    #[test]
269    fn svg_is_not_asked_for_a_raster_sibling() {
270        // An SVG is already resolution-independent; a .webp or .avif sibling
271        // would be a downgrade, not a modern format. Asking for one made the
272        // theme suite's logo a permanent warning nothing could clear.
273        let tmp = tempfile::tempdir().unwrap();
274        let root = tmp.path().to_path_buf();
275        std::fs::write(
276            root.join("logo.svg"),
277            b"<svg xmlns='http://www.w3.org/2000/svg'/>",
278        )
279        .unwrap();
280        let html_path = root.join("page.html");
281        std::fs::write(
282            &html_path,
283            r#"<html><body><img src="logo.svg" alt="l" width="1" height="1"></body></html>"#,
284        )
285        .unwrap();
286        std::mem::forget(tmp);
287        let s = Site {
288            root,
289            html_files: vec![html_path],
290        };
291        let f = ImagesGate.run(&s, &AuditOptions::default());
292        assert!(
293            f.iter().all(|x| x.code.as_deref() != Some("IMG-NO-MODERN")),
294            "an SVG should not be asked for a raster sibling: {f:?}"
295        );
296    }
297
298    #[test]
299    fn avif_sibling_suppresses_no_modern() {
300        let tmp = tempfile::tempdir().unwrap();
301        let root = tmp.path().to_path_buf();
302        std::fs::write(root.join("hero.png"), vec![0u8; 10]).unwrap();
303        std::fs::write(root.join("hero.avif"), vec![0u8; 5]).unwrap();
304        let html_path = root.join("page.html");
305        // Second <img> lacks height: the IMG-DIMS finding keeps `f`
306        // non-empty so the suppression predicate actually evaluates.
307        std::fs::write(
308            &html_path,
309            r#"<html><body><img src="hero.png" alt="h" width="1" height="1"><img src="hero.png" alt="h2" width="1"></body></html>"#,
310        )
311        .unwrap();
312        std::mem::forget(tmp);
313        let s = Site {
314            root,
315            html_files: vec![html_path],
316        };
317        let f = ImagesGate.run(&s, &AuditOptions::default());
318        assert!(f.iter().all(|x| x.code.as_deref() != Some("IMG-NO-MODERN")));
319    }
320
321    #[test]
322    fn external_image_src_is_skipped() {
323        // Height intentionally missing: the IMG-DIMS finding keeps `f`
324        // non-empty so the not-probed predicate actually evaluates.
325        let html = r#"<html><body><img src="https://cdn.example/a.jpg" alt="x" width="1"></body></html>"#;
326        let f = ImagesGate.run(&site_with(html, 10), &AuditOptions::default());
327        assert!(f.iter().any(|x| x.code.as_deref() == Some("IMG-DIMS")));
328        assert!(
329            f.iter()
330                .all(|x| x.code.as_deref() != Some("IMG-OVER-BUDGET")
331                    && x.code.as_deref() != Some("IMG-NO-MODERN")),
332            "external imgs should not be probed; got {f:?}"
333        );
334    }
335
336    #[test]
337    fn data_uri_image_src_is_skipped() {
338        // Height intentionally missing: IMG-DIMS keeps `f` non-empty.
339        let html = r#"<html><body><img src="data:image/png;base64,iVBOR" alt="x" width="1"></body></html>"#;
340        let f = ImagesGate.run(&site_with(html, 10), &AuditOptions::default());
341        assert!(f
342            .iter()
343            .all(|x| x.code.as_deref() != Some("IMG-OVER-BUDGET")));
344    }
345
346    #[test]
347    fn protocol_relative_image_src_is_skipped() {
348        // Height intentionally missing: IMG-DIMS keeps `f` non-empty.
349        let html = r#"<html><body><img src="//cdn.example/a.jpg" alt="x" width="1"></body></html>"#;
350        let f = ImagesGate.run(&site_with(html, 10), &AuditOptions::default());
351        assert!(f.iter().all(|x| x.code.as_deref() != Some("IMG-NO-MODERN")));
352    }
353
354    #[test]
355    fn svg_data_uri_with_raw_gt_does_not_truncate_tag() {
356        // Regression: naive find('>') cut the tag at the first `>`
357        // inside the data-URI, losing alt/width/height that follow.
358        // The second <img> yields IMG-NO-MODERN so `f` is non-empty
359        // and the exemption predicate actually evaluates.
360        let html = "<html><body><img alt=\"Banner\" \
361            src=\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg'>\
362            <rect width='1' height='1'/></svg>\" \
363            width=\"1440\" height=\"398\">\
364            <img src=\"plain.png\" alt=\"p\" width=\"1\" height=\"1\">\
365            </body></html>";
366        let s = site_with(html, 10);
367        std::fs::write(s.root.join("plain.png"), vec![0u8; 10]).unwrap();
368        let f = ImagesGate.run(&s, &AuditOptions::default());
369        assert!(f.iter().any(|x| x.code.as_deref() == Some("IMG-NO-MODERN")));
370        assert!(
371            f.iter().all(|x| x.code.as_deref() != Some("IMG-ALT")
372                && x.code.as_deref() != Some("IMG-DIMS")),
373            "attributes after a data-URI must be seen: {f:?}"
374        );
375    }
376
377    #[test]
378    fn minified_valueless_alt_counts_as_alt() {
379        // Minifiers collapse alt="" to bare `alt` on decorative images.
380        // The plain.png <img> yields IMG-NO-MODERN so the exemption
381        // predicate below actually evaluates per finding.
382        let html = "<html><body>\
383            <img alt height=33 role=presentation src=a.jpg width=100>\
384            <img src=plain.png alt=p width=1 height=1>\
385            </body></html>";
386        let s = site_with(html, 10);
387        std::fs::write(s.root.join("plain.png"), vec![0u8; 10]).unwrap();
388        let f = ImagesGate.run(&s, &AuditOptions::default());
389        assert!(f.iter().any(|x| x.code.as_deref() == Some("IMG-NO-MODERN")));
390        assert!(
391            f.iter().all(|x| x.code.as_deref() != Some("IMG-ALT")
392                && x.code.as_deref() != Some("IMG-DIMS")),
393            "bare `alt` + unquoted dims must count: {f:?}"
394        );
395    }
396
397    #[test]
398    fn truly_missing_alt_still_flagged_on_minified_tag() {
399        // True positive preserved on unquoted minified markup.
400        let html = "<html><body>\
401            <img height=33 src=a.jpg width=100>\
402            </body></html>";
403        let f = ImagesGate.run(&site_with(html, 10), &AuditOptions::default());
404        assert!(
405            f.iter().any(|x| x.code.as_deref() == Some("IMG-ALT")),
406            "missing alt must still flag: {f:?}"
407        );
408    }
409
410    #[test]
411    fn unreadable_html_skipped_no_panic() {
412        let tmp = tempfile::tempdir().unwrap();
413        let bogus = tmp.path().join("ghost.html");
414        let s = Site {
415            root: tmp.path().to_path_buf(),
416            html_files: vec![bogus],
417        };
418        std::mem::forget(tmp);
419        let f = ImagesGate.run(&s, &AuditOptions::default());
420        assert!(f.is_empty());
421    }
422
423    #[test]
424    fn site_absolute_src_resolves_from_root() {
425        let html = r#"<html><body><img src="/a.jpg" alt="a" width="10" height="10"></body></html>"#;
426        let f = ImagesGate.run(&site_with(html, 10), &AuditOptions::default());
427        assert!(
428            f.is_empty(),
429            "leading-slash src must anchor at site root: {f:?}"
430        );
431    }
432
433    #[test]
434    fn missing_local_image_file_is_not_probed() {
435        // fs::metadata fails, so budget/modern-sibling checks skip.
436        let html = r#"<html><body><img src="ghost.png" alt="g" width="1" height="1"></body></html>"#;
437        let f = ImagesGate.run(&site_with(html, 10), &AuditOptions::default());
438        assert!(f.is_empty(), "nonexistent file must be skipped: {f:?}");
439    }
440
441    #[test]
442    fn candidate_for_parentless_page_falls_back_to_root() {
443        let s = site_with("<html></html>", 10);
444        let got =
445            resolve_img_candidate(&s, std::path::Path::new(""), "pic.jpg");
446        assert_eq!(got, s.root.join("pic.jpg"));
447    }
448
449    #[test]
450    fn metadata_methods_exposed() {
451        let g = ImagesGate;
452        assert_eq!(g.name(), "images");
453        assert!(g.explain().contains("alt"));
454        let _copy: ImagesGate = g;
455        let _clone = g;
456        assert!(format!("{g:?}").contains("ImagesGate"));
457    }
458}