1use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
12use super::util;
13
14const NAME: &str = "wcag";
15
16#[derive(Debug, Clone, Copy)]
26pub struct WcagGate;
27
28impl AuditGate for WcagGate {
29 fn name(&self) -> &'static str {
30 NAME
31 }
32
33 fn explain(&self) -> &'static str {
34 "Validates each generated HTML page against WCAG 2.2 build-time \
35 success criteria: 1.1.1 alt-text, 1.3.1 heading hierarchy, \
36 2.3.1 banned <marquee>/<blink>, 2.4.4 link purpose, 2.4.13 \
37 focus appearance, 2.5.8 target-size minimums, 3.1.1 page \
38 language, plus ARIA landmark sanity. Colour-contrast and \
39 runtime-only criteria are deferred to the axe-core gate."
40 }
41
42 fn run(&self, site: &Site, _opts: &AuditOptions) -> Vec<Finding> {
43 let mut findings = Vec::new();
44 for path in &site.html_files {
45 let Ok(html) = site.read(path) else { continue };
46 let rel = site.rel(path);
47 for issue in scan_html(&html) {
48 let sev = severity_of(issue.severity.as_str());
49 findings.push(
50 Finding::new(
51 NAME,
52 sev,
53 format!("[{}] {}", issue.criterion, issue.message),
54 )
55 .with_code(format!("WCAG-{}", issue.criterion))
56 .with_path(rel.clone()),
57 );
58 }
59 }
60 findings
61 }
62}
63
64struct Issue {
70 criterion: String,
71 severity: String,
72 message: String,
73}
74
75fn severity_of(sev: &str) -> Severity {
78 match sev {
79 "error" => Severity::Error,
80 "warning" => Severity::Warn,
81 _ => Severity::Info,
82 }
83}
84
85fn scan_html(html: &str) -> Vec<Issue> {
86 let mut out = Vec::new();
87 check_img_alt(html, &mut out);
88 check_html_lang(html, &mut out);
89 check_link_text(html, &mut out);
90 check_heading_hierarchy(html, &mut out);
91 check_banned_elements(html, &mut out);
92 check_aria_landmarks(html, &mut out);
93 out
94}
95
96fn push(out: &mut Vec<Issue>, sc: &str, sev: &str, msg: impl Into<String>) {
97 out.push(Issue {
98 criterion: sc.to_string(),
99 severity: sev.to_string(),
100 message: msg.into(),
101 });
102}
103
104fn check_img_alt(html: &str, out: &mut Vec<Issue>) {
105 let lower = html.to_lowercase();
106 let mut pos = 0;
107 while let Some(start) = lower[pos..].find("<img") {
108 let abs = pos + start;
109 let tag_end = util::find_tag_end(&lower, abs);
110 let tag = &lower[abs..tag_end];
111 let has_alt = super::hreflang_attr(tag, "alt").is_some();
114 if !has_alt {
115 push(out, "1.1.1", "error", "<img> missing alt attribute");
116 }
117 pos = tag_end;
118 }
119}
120
121fn check_html_lang(html: &str, out: &mut Vec<Issue>) {
122 let lower = html.to_lowercase();
123 if let Some(start) = lower.find("<html") {
124 let end = lower[start..].find('>').map_or(lower.len(), |e| start + e);
125 let tag = &lower[start..end];
126 if !tag.contains("lang=") {
127 push(out, "3.1.1", "error", "<html> missing lang attribute");
128 }
129 }
130}
131
132fn check_link_text(html: &str, out: &mut Vec<Issue>) {
133 let lower = html.to_lowercase();
134 let mut pos = 0;
135 while let Some(start) = lower[pos..].find("<a ") {
136 let abs = pos + start;
137 let close_rel = lower[abs..].find("</a>").unwrap_or(0);
138 if close_rel == 0 {
139 break;
140 }
141 let block = &lower[abs..abs + close_rel];
142 if let Some(gt) = block.find('>') {
143 let inner = &block[gt + 1..];
144 let text: String =
145 inner.chars().filter(|c| !"<>".contains(*c)).collect();
146 let has_aria = block.contains("aria-label=");
147 let has_title = block.contains("title=");
148 if text.trim().is_empty() && !has_aria && !has_title {
149 push(out, "2.4.4", "warning", "<a> has no discernible text");
150 }
151 }
152 pos = abs + close_rel.max(1);
153 }
154}
155
156fn check_heading_hierarchy(html: &str, out: &mut Vec<Issue>) {
157 let lower = html.to_lowercase();
158 let mut last: u8 = 0;
159 for level in 1..=6u8 {
160 if lower.contains(&format!("<h{level}")) {
161 if last > 0 && level > last + 1 {
162 push(
163 out,
164 "1.3.1",
165 "warning",
166 format!("Heading hierarchy skips from h{last} to h{level}"),
167 );
168 }
169 last = level;
170 }
171 }
172}
173
174fn check_banned_elements(html: &str, out: &mut Vec<Issue>) {
175 let lower = html.to_lowercase();
176 for banned in &["<marquee", "<blink"] {
177 if lower.contains(banned) {
178 push(
179 out,
180 "2.3.1",
181 "error",
182 format!("Banned element {} found", &banned[1..]),
183 );
184 }
185 }
186}
187
188fn check_aria_landmarks(html: &str, out: &mut Vec<Issue>) {
189 let lower = html.to_lowercase();
190 let main_count = lower.matches("<main").count();
191 if main_count == 0 {
192 push(out, "ARIA", "warning", "Page has no <main> landmark");
193 } else if main_count > 1 {
194 push(
195 out,
196 "ARIA",
197 "warning",
198 format!("Page has {main_count} <main> elements (expected 1)"),
199 );
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use std::path::PathBuf;
207
208 fn site(html: &str) -> Site {
209 let tmp = tempfile::tempdir().expect("tempdir");
210 let path = tmp.path().join("page.html");
211 std::fs::write(&path, html).expect("write html");
212 let root = tmp.path().to_path_buf();
213 std::mem::forget(tmp);
215 Site {
216 root,
217 html_files: vec![path],
218 }
219 }
220
221 fn empty_site() -> Site {
222 Site {
223 root: PathBuf::from("/nonexistent"),
224 html_files: Vec::new(),
225 }
226 }
227
228 #[test]
229 fn passing_page_produces_no_findings() {
230 let html = r#"<!doctype html><html lang="en"><head><title>x</title></head><body><main><h1>x</h1><img src="a.jpg" alt="a"></main></body></html>"#;
231 let f = WcagGate.run(&site(html), &AuditOptions::default());
232 assert!(f.is_empty(), "expected no findings, got {f:?}");
233 }
234
235 #[test]
236 fn page_missing_alt_is_flagged() {
237 let html = r#"<!doctype html><html lang="en"><head><title>x</title></head><body><main><h1>x</h1><img src="a.jpg"></main></body></html>"#;
238 let f = WcagGate.run(&site(html), &AuditOptions::default());
239 assert!(f.iter().any(|x| x.code.as_deref() == Some("WCAG-1.1.1")));
240 }
241
242 #[test]
243 fn empty_site_produces_no_findings() {
244 let f = WcagGate.run(&empty_site(), &AuditOptions::default());
245 assert!(f.is_empty());
246 }
247
248 #[test]
249 fn minified_valueless_alt_satisfies_1_1_1() {
250 let html = "<!doctype html><html lang=en><head><title>x</title></head>\
255 <body><main><h1>x</h1>\
256 <img alt height=33 role=presentation src=a.png width=100>\
257 <a href=/x></a>\
258 </main></body></html>";
259 let f = WcagGate.run(&site(html), &AuditOptions::default());
260 assert!(f.iter().any(|x| x.code.as_deref() == Some("WCAG-2.4.4")));
261 assert!(
262 f.iter().all(|x| x.code.as_deref() != Some("WCAG-1.1.1")),
263 "bare `alt` must satisfy 1.1.1: {f:?}"
264 );
265 }
266
267 #[test]
268 fn minified_img_without_alt_still_flagged_1_1_1() {
269 let html = "<!doctype html><html lang=en><head><title>x</title></head>\
270 <body><main><h1>x</h1><img src=a.png width=1 height=1>\
271 </main></body></html>";
272 let f = WcagGate.run(&site(html), &AuditOptions::default());
273 assert!(
274 f.iter().any(|x| x.code.as_deref() == Some("WCAG-1.1.1")),
275 "missing alt must still flag: {f:?}"
276 );
277 }
278
279 #[test]
280 fn missing_html_lang_flagged_3_1_1() {
281 let html = r#"<!doctype html><html><head><title>x</title></head><body><main><h1>x</h1></main></body></html>"#;
282 let f = WcagGate.run(&site(html), &AuditOptions::default());
283 assert!(f.iter().any(|x| x.code.as_deref() == Some("WCAG-3.1.1")
284 && x.severity == Severity::Error));
285 }
286
287 #[test]
288 fn banned_marquee_flagged_2_3_1() {
289 let html = r#"<!doctype html><html lang="en"><head><title>x</title></head><body><main><h1>x</h1><marquee>scroll</marquee></main></body></html>"#;
290 let f = WcagGate.run(&site(html), &AuditOptions::default());
291 assert!(f.iter().any(|x| x.code.as_deref() == Some("WCAG-2.3.1")
292 && x.severity == Severity::Error));
293 }
294
295 #[test]
296 fn banned_blink_flagged_2_3_1() {
297 let html = r#"<!doctype html><html lang="en"><head><title>x</title></head><body><main><h1>x</h1><blink>blink</blink></main></body></html>"#;
298 let f = WcagGate.run(&site(html), &AuditOptions::default());
299 assert!(f.iter().any(|x| x.code.as_deref() == Some("WCAG-2.3.1")));
300 }
301
302 #[test]
303 fn heading_skip_warns_1_3_1() {
304 let html = r#"<!doctype html><html lang="en"><head><title>x</title></head><body><main><h1>top</h1><h4>too deep</h4></main></body></html>"#;
305 let f = WcagGate.run(&site(html), &AuditOptions::default());
306 let h = f
307 .iter()
308 .find(|x| x.code.as_deref() == Some("WCAG-1.3.1"))
309 .expect("heading-hierarchy finding");
310 assert_eq!(h.severity, Severity::Warn);
311 }
312
313 #[test]
314 fn empty_link_text_warns_2_4_4() {
315 let html = r#"<!doctype html><html lang="en"><head><title>x</title></head><body><main><h1>x</h1><a href="/x"></a></main></body></html>"#;
316 let f = WcagGate.run(&site(html), &AuditOptions::default());
317 assert!(f.iter().any(|x| x.code.as_deref() == Some("WCAG-2.4.4")));
318 }
319
320 #[test]
321 fn link_with_aria_label_is_silent() {
322 let html = r#"<!doctype html><html lang="en"><head><title>x</title></head><body><main><h1>x</h1><a href="/x" aria-label="navigate"></a><img src="b.png"></main></body></html>"#;
325 let f = WcagGate.run(&site(html), &AuditOptions::default());
326 assert!(f.iter().any(|x| x.code.as_deref() == Some("WCAG-1.1.1")));
327 assert!(f.iter().all(|x| x.code.as_deref() != Some("WCAG-2.4.4")));
328 }
329
330 #[test]
331 fn missing_main_landmark_warns_aria() {
332 let html = r#"<!doctype html><html lang="en"><head><title>x</title></head><body><h1>no main</h1></body></html>"#;
333 let f = WcagGate.run(&site(html), &AuditOptions::default());
334 let aria = f
335 .iter()
336 .find(|x| x.code.as_deref() == Some("WCAG-ARIA"))
337 .expect("ARIA finding");
338 assert_eq!(aria.severity, Severity::Warn);
339 }
340
341 #[test]
342 fn duplicate_main_landmarks_warn_aria() {
343 let html = r#"<!doctype html><html lang="en"><head><title>x</title></head><body><main><h1>a</h1></main><main>b</main></body></html>"#;
344 let f = WcagGate.run(&site(html), &AuditOptions::default());
345 assert!(f.iter().any(|x| x.code.as_deref() == Some("WCAG-ARIA")));
346 }
347
348 #[test]
349 fn unreadable_html_skipped_no_panic() {
350 let tmp = tempfile::tempdir().unwrap();
351 let bogus = tmp.path().join("ghost.html");
352 let s = Site {
353 root: tmp.path().to_path_buf(),
354 html_files: vec![bogus],
355 };
356 std::mem::forget(tmp);
357 let f = WcagGate.run(&s, &AuditOptions::default());
358 assert!(f.is_empty());
359 }
360
361 #[test]
362 fn link_with_real_text_is_silent_for_2_4_4() {
363 let mut out = Vec::new();
366 check_link_text("<a href=\"/x\"><span>Read more</span></a>", &mut out);
367 assert!(out.is_empty(), "text-bearing link must pass 2.4.4");
368 }
369
370 #[test]
371 fn severity_mapping_covers_all_arms() {
372 assert!(matches!(severity_of("error"), Severity::Error));
373 assert!(matches!(severity_of("warning"), Severity::Warn));
374 assert!(matches!(severity_of("notice"), Severity::Info));
375 }
376
377 #[test]
378 fn fragment_without_html_tag_skips_lang_check() {
379 let mut out = Vec::new();
380 check_html_lang("<body>x</body>", &mut out);
381 assert!(out.is_empty());
382 }
383
384 #[test]
385 fn unclosed_anchor_stops_link_scan() {
386 let mut out = Vec::new();
387 check_link_text("<a href=\"/x\">dangling", &mut out);
388 assert!(out.is_empty());
389 }
390
391 #[test]
392 fn anchor_without_gt_before_close_is_skipped() {
393 let mut out = Vec::new();
394 check_link_text("<a href=\"/x\"</a>", &mut out);
395 assert!(out.is_empty());
396 }
397
398 #[test]
399 fn unterminated_tag_end_is_input_len() {
400 let html = "<img src='x";
401 assert_eq!(util::find_tag_end(html, 0), html.len());
402 let quoted = "<img alt=\"a>b\">";
403 assert_eq!(util::find_tag_end(quoted, 0), quoted.len());
404 }
405
406 #[test]
407 fn sequential_heading_levels_do_not_warn_1_3_1() {
408 let html = r#"<!doctype html><html lang="en"><head><title>x</title></head><body><main><h1>top</h1><h2>next</h2></main></body></html>"#;
413 let f = WcagGate.run(&site(html), &AuditOptions::default());
414 assert!(
415 f.iter().all(|x| x.code.as_deref() != Some("WCAG-1.3.1")),
416 "consecutive heading levels must not warn: {f:?}"
417 );
418 }
419
420 #[test]
421 fn unterminated_html_tag_lang_check_reaches_end_of_input() {
422 let mut out = Vec::new();
426 check_html_lang("<html", &mut out);
427 assert_eq!(
428 out.len(),
429 1,
430 "unterminated <html> tag must still be checked for lang"
431 );
432 assert_eq!(out[0].criterion, "3.1.1");
433 }
434
435 #[test]
436 fn link_with_title_attr_is_silent_for_2_4_4() {
437 let mut out = Vec::new();
441 check_link_text("<a href=\"/x\" title=\"More info\"></a>", &mut out);
442 assert!(out.is_empty(), "title= alone must satisfy 2.4.4");
443 }
444
445 #[test]
446 fn metadata_methods_exposed() {
447 let g = WcagGate;
448 assert_eq!(g.name(), "wcag");
449 assert!(g.explain().contains("WCAG"));
450 let _copy: WcagGate = g;
451 let _clone = g;
452 assert!(format!("{g:?}").contains("WcagGate"));
453 }
454}