Skip to main content

ssg_a11y/
rules.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Individual WCAG 2.2 success-criterion checks, plus the ARIA landmark
5//! checks. Each `check_*` function scans a raw HTML document and appends
6//! any [`AccessibilityIssue`](crate::AccessibilityIssue)s it finds to the
7//! caller-supplied `Vec`.
8
9use crate::css::{
10    extract_all_style_blocks, first_px_value, parse_top_level_rules,
11    preprocess_css, selector_targets_interactive,
12};
13use crate::html::{
14    extract_attr_value, find_tag_end, img_missing_alt, strip_tags_simple,
15};
16use crate::types::AccessibilityIssue;
17
18/// WCAG 1.1.1: Every <img> must have a non-empty alt attribute.
19pub(crate) fn check_img_alt(html: &str, issues: &mut Vec<AccessibilityIssue>) {
20    // `to_ascii_lowercase` rather than `to_lowercase`: offsets computed on the
21    // lowercased copy are used to slice the ORIGINAL string, and Unicode
22    // lowercasing is not length-preserving. `İ` (U+0130) lowercases to two
23    // chars, shifting every subsequent byte offset and panicking on the next
24    // slice that lands mid-character. Tag and attribute names are ASCII, so an
25    // ASCII fold matches identically while leaving every byte offset intact.
26    let lower = html.to_ascii_lowercase();
27    let mut pos = 0;
28    while let Some(start) = lower[pos..].find("<img") {
29        let abs = pos + start;
30        let tag_end = find_tag_end(&lower, abs);
31        let tag = &lower[abs..tag_end];
32
33        if img_missing_alt(tag) {
34            let src = extract_attr_value(&html[abs..tag_end], "src")
35                .unwrap_or_default();
36            issues.push(AccessibilityIssue {
37                criterion: "1.1.1".to_string(),
38                severity: "error".to_string(),
39                message: format!(
40                    "<img> missing alt text: {}",
41                    if src.is_empty() { "(no src)" } else { &src }
42                ),
43            });
44        }
45
46        pos = tag_end;
47    }
48}
49
50/// WCAG 3.1.1: <html> element must have a lang attribute.
51pub(crate) fn check_html_lang(
52    html: &str,
53    issues: &mut Vec<AccessibilityIssue>,
54) {
55    let lower = html.to_ascii_lowercase();
56    if let Some(start) = lower.find("<html") {
57        let tag_end =
58            lower[start..].find('>').map_or(lower.len(), |e| start + e);
59        let tag = &lower[start..tag_end];
60        if !tag.contains("lang=") {
61            issues.push(AccessibilityIssue {
62                criterion: "3.1.1".to_string(),
63                severity: "error".to_string(),
64                message: "<html> missing lang attribute".to_string(),
65            });
66        }
67    }
68}
69
70/// WCAG 2.4.4: Links must have discernible text.
71pub(crate) fn check_link_text(
72    html: &str,
73    issues: &mut Vec<AccessibilityIssue>,
74) {
75    let lower = html.to_ascii_lowercase();
76    let mut pos = 0;
77    while let Some(start) = lower[pos..].find("<a ") {
78        let abs = pos + start;
79        let close = lower[abs..].find("</a>").unwrap_or(lower.len() - abs);
80        let full = &lower[abs..abs + close];
81
82        // Get inner content (between > and </a>)
83        if let Some(gt) = full.find('>') {
84            let inner = &full[gt + 1..];
85            let text = strip_tags_simple(inner);
86            let has_aria = full.contains("aria-label=");
87            let has_title = full.contains("title=");
88
89            if text.trim().is_empty() && !has_aria && !has_title {
90                let href = extract_attr_value(&html[abs..abs + close], "href")
91                    .unwrap_or_default();
92                issues.push(AccessibilityIssue {
93                    criterion: "2.4.4".to_string(),
94                    severity: "warning".to_string(),
95                    message: format!(
96                        "<a> has no discernible text: href={}",
97                        if href.is_empty() { "(none)" } else { &href }
98                    ),
99                });
100            }
101        }
102
103        pos = abs + close.max(1);
104    }
105}
106
107/// WCAG 1.3.1: Heading levels must not skip (e.g. h1 → h3).
108pub(crate) fn check_heading_hierarchy(
109    html: &str,
110    issues: &mut Vec<AccessibilityIssue>,
111) {
112    let lower = html.to_ascii_lowercase();
113    let mut last_level: u8 = 0;
114
115    for level in 1..=6u8 {
116        let tag = format!("<h{level}");
117        if lower.contains(&tag) {
118            if last_level > 0 && level > last_level + 1 {
119                issues.push(AccessibilityIssue {
120                    criterion: "1.3.1".to_string(),
121                    severity: "warning".to_string(),
122                    message: format!(
123                        "Heading hierarchy skips from h{last_level} to h{level}"
124                    ),
125                });
126            }
127            last_level = level;
128        }
129    }
130}
131
132/// WCAG 2.3.1: No <marquee> or <blink> elements.
133pub(crate) fn check_banned_elements(
134    html: &str,
135    issues: &mut Vec<AccessibilityIssue>,
136) {
137    let lower = html.to_ascii_lowercase();
138    for tag in &["<marquee", "<blink"] {
139        if lower.contains(tag) {
140            issues.push(AccessibilityIssue {
141                criterion: "2.3.1".to_string(),
142                severity: "error".to_string(),
143                message: format!("Banned element {} found", &tag[1..]),
144            });
145        }
146    }
147}
148
149/// Lowercases `html` and removes HTML comments, `<style>` blocks, and
150/// `<script>` blocks, so landmark counting isn't confused by markup that
151/// only appears inside those (e.g. a commented-out `<main>`).
152pub(crate) fn strip_non_content_blocks(html: &str) -> String {
153    let mut clean = html.to_ascii_lowercase();
154
155    // Remove HTML comments
156    while let Some(start) = clean.find("<!--") {
157        if let Some(end) = clean[start..].find("-->") {
158            clean.replace_range(start..start + end + 3, "");
159        } else {
160            break;
161        }
162    }
163
164    // Remove style blocks
165    while let Some(start) = clean.find("<style") {
166        if let Some(end) = clean[start..].find("</style>") {
167            clean.replace_range(start..start + end + 8, "");
168        } else {
169            break;
170        }
171    }
172
173    // Remove script blocks
174    while let Some(start) = clean.find("<script") {
175        if let Some(end) = clean[start..].find("</script>") {
176            clean.replace_range(start..start + end + 9, "");
177        } else {
178            break;
179        }
180    }
181
182    clean
183}
184
185/// ARIA landmark checks: one <main>, nav has aria-label.
186pub(crate) fn check_aria_landmarks(
187    html: &str,
188    issues: &mut Vec<AccessibilityIssue>,
189) {
190    let clean = strip_non_content_blocks(html);
191
192    // Count <main> elements
193    let main_count = clean.matches("<main").count();
194    if main_count == 0 {
195        issues.push(AccessibilityIssue {
196            criterion: "ARIA".to_string(),
197            severity: "warning".to_string(),
198            message: "Page has no <main> landmark".to_string(),
199        });
200    } else if main_count > 1 {
201        issues.push(AccessibilityIssue {
202            criterion: "ARIA".to_string(),
203            severity: "warning".to_string(),
204            message: format!(
205                "Page has {main_count} <main> elements (expected 1)"
206            ),
207        });
208    }
209
210    // Check <nav> elements have aria-label
211    let mut pos = 0;
212    while let Some(start) = clean[pos..].find("<nav") {
213        let abs = pos + start;
214        let tag_end = clean[abs..].find('>').map_or(clean.len(), |e| abs + e);
215        let tag = &clean[abs..tag_end];
216        if !tag.contains("aria-label") && !tag.contains("aria-labelledby") {
217            issues.push(AccessibilityIssue {
218                criterion: "ARIA".to_string(),
219                severity: "warning".to_string(),
220                message: "<nav> missing aria-label".to_string(),
221            });
222        }
223        pos = tag_end;
224    }
225}
226
227/// WCAG 2.2 — 2.5.8 Target Size (Minimum, AA).
228///
229/// Heuristic: scan every inline `<style>` block. Flag any declaration
230/// that sets `width` or `height` to a value smaller than 24 px on a
231/// selector that targets `button`, `a`, `input`, or `[role="button"]`.
232/// We can't fully verify rendered size at build time (that's a job for
233/// a runtime tool such as axe-core) but explicit sub-24 px declarations
234/// are unambiguous regressions.
235pub(crate) fn check_target_size(
236    html: &str,
237    issues: &mut Vec<AccessibilityIssue>,
238) {
239    for css in extract_all_style_blocks(html) {
240        let cleaned = preprocess_css(&css);
241        for (selector, body) in parse_top_level_rules(&cleaned) {
242            if !selector_targets_interactive(&selector) {
243                continue;
244            }
245            for prop in ["width", "height"] {
246                if let Some(px) = first_px_value(&body, prop) {
247                    if px > 0 && px < 24 {
248                        issues.push(AccessibilityIssue {
249                            criterion: "2.5.8".to_string(),
250                            severity: "warning".to_string(),
251                            message: format!(
252                                "Target size {prop}={px}px on `{selector}` \
253                                 is below the 24×24 minimum (WCAG 2.2 AA)"
254                            ),
255                        });
256                    }
257                }
258            }
259        }
260    }
261}
262
263/// WCAG 2.2 — 2.4.13 Focus Appearance (AAA).
264///
265/// Detects `:focus { outline: none }` (or `outline: 0`) without a
266/// compensating `outline-style`, `box-shadow`, or `border` declaration
267/// in the same rule.
268pub(crate) fn check_focus_appearance(
269    html: &str,
270    issues: &mut Vec<AccessibilityIssue>,
271) {
272    for css in extract_all_style_blocks(html) {
273        let cleaned = preprocess_css(&css);
274        for (selector, body) in parse_top_level_rules(&cleaned) {
275            if !selector.contains(":focus") {
276                continue;
277            }
278            let kills_outline = body.contains("outline:none")
279                || body.contains("outline: none")
280                || body.contains("outline:0")
281                || body.contains("outline: 0");
282            let has_replacement = body.contains("outline-style")
283                || body.contains("outline-color")
284                || body.contains("box-shadow")
285                || body.contains("border:");
286
287            if kills_outline && !has_replacement {
288                issues.push(AccessibilityIssue {
289                    criterion: "2.4.13".to_string(),
290                    severity: "warning".to_string(),
291                    message: "`:focus { outline: none }` without a \
292                         compensating outline-style/box-shadow/border \
293                         (WCAG 2.2 AAA — Focus Appearance)"
294                        .to_string(),
295                });
296            }
297        }
298    }
299}
300
301/// WCAG 2.2 — 3.2.6 Consistent Help (Level A).
302///
303/// Build-time verification is partial — full conformance requires
304/// cross-page comparison of help-mechanism placement. This check
305/// emits an *info* note when a page contains no detectable help link
306/// (anchor text matching `help`, `contact`, `support`, `faq`).
307/// Cross-page placement consistency is left to a runtime audit and
308/// human review — that's why [`crate::CriterionStatus::Manual`] is used
309/// for this criterion in the compliance matrix rather than wiring this
310/// helper into [`crate::check_page`] directly (it would be too noisy on
311/// every clean page that simply omits a help link). Callers that want
312/// cross-page analysis can run this themselves across every page's HTML
313/// and look for at least one match site-wide.
314pub fn check_consistent_help(html: &str, issues: &mut Vec<AccessibilityIssue>) {
315    let lower = html.to_ascii_lowercase();
316    let has_help_link = lower.contains(">help<")
317        || lower.contains(">contact<")
318        || lower.contains(">support<")
319        || lower.contains(">faq<")
320        || lower.contains("aria-label=\"help\"")
321        || lower.contains("aria-label=\"contact\"")
322        || lower.contains("aria-label=\"support\"");
323
324    if !has_help_link {
325        issues.push(AccessibilityIssue {
326            criterion: "3.2.6".to_string(),
327            severity: "info".to_string(),
328            message: "No detectable help/contact/support link on page; \
329                      verify that the site provides a consistent help \
330                      mechanism across pages (WCAG 2.2 A — Consistent \
331                      Help)"
332                .to_string(),
333        });
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn check_html_lang_skips_fragment_without_html_tag() {
343        let mut issues = Vec::new();
344        check_html_lang("<p>fragment only</p>", &mut issues);
345        assert!(issues.is_empty());
346    }
347
348    #[test]
349    fn check_link_text_tolerates_unterminated_anchor() {
350        // `<a ` with no `>` before EOF — inner content can't be found,
351        // so no issue is raised and the scanner terminates.
352        let mut issues = Vec::new();
353        check_link_text("<a href=/x", &mut issues);
354        assert!(issues.is_empty());
355    }
356
357    #[test]
358    fn strip_non_content_blocks_removes_html_comments() {
359        let out = strip_non_content_blocks("<body><!-- <main> --></body>");
360        assert!(!out.contains("<main>"));
361        assert!(out.contains("<body>"));
362    }
363
364    #[test]
365    fn strip_non_content_blocks_tolerates_unterminated_comment() {
366        let out = strip_non_content_blocks("<body><!-- no close");
367        assert!(out.contains("<!--"), "unterminated comment kept: {out}");
368    }
369
370    #[test]
371    fn strip_non_content_blocks_tolerates_unterminated_style() {
372        let out = strip_non_content_blocks("<body><style>a{}");
373        assert!(out.contains("<style>"), "unterminated style kept: {out}");
374    }
375
376    #[test]
377    fn strip_non_content_blocks_tolerates_unterminated_script() {
378        let out = strip_non_content_blocks("<body><script>let x=1;");
379        assert!(out.contains("<script>"), "unterminated script kept: {out}");
380    }
381
382    #[test]
383    fn test_consistent_help_helper_detects_link() {
384        // The cross-page checker isn't wired into per-page validation
385        // (it would be too noisy on every clean page that omits a
386        // help link), but the helper itself still works and we want
387        // it covered for future cross-page use.
388        let html_with = r#"<html lang="en"><body><a href="/contact">Contact</a></body></html>"#;
389        let html_without =
390            r#"<html lang="en"><body><p>nothing</p></body></html>"#;
391        let mut buf = Vec::new();
392        check_consistent_help(html_with, &mut buf);
393        assert!(buf.is_empty(), "with link, no issue");
394        check_consistent_help(html_without, &mut buf);
395        assert_eq!(buf.len(), 1);
396        assert_eq!(buf[0].criterion, "3.2.6");
397    }
398}