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