1use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
15use super::{find_tag_end, hreflang_attr};
16
17const NAME: &str = "images";
18
19#[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 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 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
132fn 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 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 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 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 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 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 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 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 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 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 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}