Skip to main content

ssg/audit/gates/
wcag.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! WCAG 2.2 AAA accessibility gate.
5//!
6//! Delegates to the existing build-time accessibility validator
7//! (`crate::accessibility::check_page_html`) so the gate's heuristics
8//! stay in lock-step with the `AccessibilityPlugin` validations.
9//! Findings map directly to WCAG success-criteria codes.
10
11use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
12use super::util;
13
14const NAME: &str = "wcag";
15
16/// WCAG 2.2 AAA accessibility gate.
17///
18/// # Examples
19///
20/// ```
21/// use ssg::audit::AuditGate;
22/// use ssg::audit::gates::wcag::WcagGate;
23/// assert_eq!(WcagGate.name(), "wcag");
24/// ```
25#[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
64// Re-export of the existing AccessibilityIssue scanner. We don't
65// import `crate::accessibility::check_page` directly because that
66// function is private to the plugin; instead we re-implement the
67// minimal surface needed by this gate — the same heuristics, kept in
68// sync by the shared test corpus (`tests/audit_gates.rs`).
69struct Issue {
70    criterion: String,
71    severity: String,
72    message: String,
73}
74
75/// Maps a scanner severity string to the audit [`Severity`] scale;
76/// unknown labels degrade to `Info`.
77fn 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        // Attribute-based lookup: minifiers collapse `alt=""` to a bare
112        // valueless `alt`, and unquoted `alt=x` is legal — both count.
113        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        // Leak the tempdir so the path stays alive for the test.
214        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        // Minifiers collapse alt="" to a bare `alt` on decorative
251        // images — that still satisfies WCAG 1.1.1.
252        // The empty <a> yields a 2.4.4 warning, keeping `f` non-empty
253        // so the no-1.1.1 predicate actually evaluates.
254        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        // The alt-less <img> yields a 1.1.1 error, keeping `f`
323        // non-empty so the no-2.4.4 predicate actually evaluates.
324        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        // Inner text (including nested markup) drives the character
364        // filter in check_link_text with both kept and dropped chars.
365        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        // `h1` followed directly by `h2` is a valid, non-skipping
409        // hierarchy: `last > 0 && level > last + 1` must evaluate to
410        // false (not just short-circuit on `last > 0`), which no
411        // existing single-heading fixture exercises.
412        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        // No `>` anywhere after `<html`, driving the `map_or` default
423        // (`lower.len()`) arm in `check_html_lang` instead of the
424        // `Some(gt)` arm every other fixture takes.
425        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        // `title=` alone (no `aria-label`, no inner text) must also
438        // satisfy 2.4.4 — no existing fixture isolates the `has_title`
439        // half of `!has_aria && !has_title`.
440        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}