Skip to main content

ssg_a11y/
lib.rs

1#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
2#![forbid(unsafe_code)]
3// `pub(crate)` on cross-module helpers (e.g. `rules::check_img_alt` used
4// from `lib.rs`) is flagged by `redundant_pub_crate` because the private
5// `mod rules;`/`mod css;`/`mod html;` declarations already cap external
6// reachability — but `unreachable_pub` (rustc) wants exactly `pub(crate)`
7// for the same items, since they aren't part of the crate's public API.
8// These two lints are mutually exclusive for internal helpers split
9// across private submodules; `pub(crate)` is the more accurate signal
10// of intent (crate-internal, not public API), so silence the other.
11#![allow(clippy::redundant_pub_crate)]
12// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
13// SPDX-License-Identifier: Apache-2.0 OR MIT
14
15//! # ssg-a11y — Standalone WCAG 2.2 AA accessibility checker
16//!
17//! Framework-agnostic, build-time HTML validation against a subset of
18//! WCAG 2.2 Level AA success criteria, plus ARIA landmark checks. This
19//! crate has **no dependency on any web framework** — it operates purely
20//! on `&str` HTML in, [`AccessibilityIssue`] data out — so it can be
21//! embedded in the build pipeline of any Rust site/app generator
22//! (static site generators, Leptos, Dioxus, Yew, etc).
23//!
24//! ```
25//! let html = r#"<html><head></head><body><main><img src="a.jpg"></main></body></html>"#;
26//! let issues = ssg_a11y::check_page(html);
27//! assert!(issues.iter().any(|i| i.criterion == "1.1.1"));
28//! ```
29//!
30//! ## Checks performed
31//!
32//! - 1.1.1 Non-text content (`<img alt>`)
33//! - 1.3.1 Heading hierarchy (no skipped levels)
34//! - 2.3.1 Banned elements (`<marquee>`, `<blink>`)
35//! - 2.4.4 Link purpose (discernible text or `aria-label`)
36//! - 2.4.13 Focus appearance — `:focus { outline: none }` without a
37//!   compensating style is flagged (WCAG 2.2 addition)
38//! - 2.5.8 Target size minimum — explicit `width`/`height` < 24 px on
39//!   interactive selectors flagged (WCAG 2.2 addition)
40//! - 3.1.1 Page language (`<html lang>`)
41//! - 3.2.6 Consistent help — a standalone helper is provided
42//!   ([`check_page`] does not call it directly; see its docs) for
43//!   informational cross-page analysis (WCAG 2.2 addition)
44//! - ARIA landmarks (single `<main>`, `<nav aria-label>`)
45//!
46//! [`build_compliance_report`] additionally produces a full WCAG 2.2
47//! compliance matrix ([`WcagComplianceReport`]) mapping every criterion
48//! in the spec to its automation status (automated / runtime-only /
49//! manual / not-applicable), so a consumer can report on and track
50//! conformance beyond what this crate can check automatically.
51
52mod css;
53mod html;
54
55/// Scan to the end of an HTML tag.
56///
57/// Skips `>` characters inside quoted attribute values, so an inline
58/// `data:` URL in a `src` attribute cannot truncate the tag early.
59///
60/// Exported because a standalone HTML checker is where this belongs: the
61/// host crate had grown three separate copies of it (ssg#711). Additive —
62/// nothing existing changes shape.
63pub use html::find_tag_end;
64mod report;
65mod rules;
66mod types;
67
68pub use report::build_compliance_report;
69pub use rules::check_consistent_help;
70pub use types::{
71    AccessibilityIssue, AccessibilityReport, CriterionEntry, CriterionStatus,
72    PageReport, WcagComplianceReport,
73};
74
75/// Runs all WCAG checks on a single HTML page.
76///
77/// This is the crate's main entry point: pass it the full text of one
78/// rendered HTML page and it returns every issue found. It performs no
79/// I/O — callers are responsible for reading the file (or otherwise
80/// obtaining the HTML string) and for aggregating per-page results into
81/// a report (see [`AccessibilityReport`]) if desired.
82pub fn check_page(html: &str) -> Vec<AccessibilityIssue> {
83    let mut issues = Vec::new();
84
85    // WCAG 1.1.1: Non-text Content — all <img> must have alt
86    rules::check_img_alt(html, &mut issues);
87
88    // WCAG 3.1.1: Language of Page — <html> must have lang
89    rules::check_html_lang(html, &mut issues);
90
91    // WCAG 2.4.4: Link Purpose — all <a> must have discernible text
92    rules::check_link_text(html, &mut issues);
93
94    // WCAG 1.3.1: Heading hierarchy — no skipped levels
95    rules::check_heading_hierarchy(html, &mut issues);
96
97    // WCAG 2.3.1: No flashing — no <marquee> or <blink>
98    rules::check_banned_elements(html, &mut issues);
99
100    // ARIA: exactly one <main>, nav elements have aria-label
101    rules::check_aria_landmarks(html, &mut issues);
102
103    // WCAG 2.2 additions ----------------------------------------------
104
105    // 2.5.8 Target Size (Minimum) — interactive selectors with
106    // explicit width/height < 24px in inline CSS.
107    rules::check_target_size(html, &mut issues);
108
109    // 2.4.13 Focus Appearance — `outline: none` on :focus without a
110    // compensating outline-style/box-shadow/border declaration.
111    rules::check_focus_appearance(html, &mut issues);
112
113    // 3.2.6 Consistent Help is not checked per-page — it requires
114    // cross-page comparison of help-mechanism placement, which is
115    // beyond the per-page scan. [`rules::check_consistent_help`] is kept
116    // as a standalone helper for callers that want to run it themselves
117    // across a whole site; see [`build_compliance_report`], which marks
118    // this criterion `manual` in the matrix.
119
120    issues
121}
122
123#[cfg(test)]
124mod tests {
125
126    /// Regression: `check_page` panicked on non-ASCII input whose lowercase
127    /// form is longer than the original.
128    ///
129    /// Found by `fuzz_a11y` within seconds of the target being added. The
130    /// rules lowercased the page to search it case-insensitively, then used
131    /// the resulting offsets to slice the *original* string. `to_lowercase`
132    /// is not length-preserving — `İ` (U+0130) becomes two chars — so every
133    /// offset past it was wrong and the next slice landed mid-character:
134    ///
135    ///     thread panicked at crates/ssg-a11y/src/rules.rs:31:47
136    ///     byte index is not a char boundary
137    ///
138    /// This runs inside `ssg build`, so the input that triggers it is
139    /// someone's own content and the symptom is a crashed build.
140    #[test]
141    fn check_page_survives_non_ascii_that_grows_when_lowercased() {
142        // The reduced crash input from the fuzzer: U+0130 before a tag whose
143        // attributes are then sliced out of the original string.
144        let html = "<!\u{130}html>\n<img src=\"a.png\" alt>";
145        let _ = check_page(html);
146
147        // A few more shapes with the same hazard.
148        for probe in [
149            "\u{130}<img src=\"x.png\">",
150            "<img alt=\"\u{130}\" src=\"y.png\">",
151            "<p>\u{130}\u{130}\u{130}</p><img>",
152            "<img src=\"\u{130}.png\">",
153        ] {
154            let _ = check_page(probe);
155        }
156    }
157
158    /// The ASCII fold must still match tags written in any case.
159    #[test]
160    fn check_page_still_matches_uppercase_tags() {
161        let issues = check_page("<IMG SRC=\"a.png\">");
162        assert!(
163            issues.iter().any(|i| i.criterion == "1.1.1"),
164            "uppercase <IMG> without alt was not detected: {issues:?}"
165        );
166    }
167
168    use super::*;
169
170    #[test]
171    fn test_img_alt_missing() {
172        let html = r#"<html lang="en"><head></head><body><main><img src="photo.jpg"></main></body></html>"#;
173        let issues = check_page(html);
174        assert!(issues.iter().any(|i| i.criterion == "1.1.1"));
175    }
176
177    #[test]
178    fn test_img_alt_present() {
179        let html = r#"<html lang="en"><head></head><body><main><img src="photo.jpg" alt="A photo"><marquee>seed</marquee></main></body></html>"#;
180        let issues = check_page(html);
181        // The seeded <marquee> (2.3.1) keeps the issue list non-empty
182        // so the criterion predicate actually executes.
183        assert!(!issues.iter().any(|i| i.criterion == "1.1.1"));
184    }
185
186    #[test]
187    fn test_img_alt_with_inline_svg_data_url() {
188        // Regression: a `>` inside an SVG data URL in `src` previously
189        // truncated the tag and the parser missed the `alt` attribute,
190        // raising a false `<img> missing alt text: (no src)` issue.
191        let html = r#"<html lang="en"><head></head><body><main><img src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'><rect width='10' height='10'/></svg>" alt="Banner" width="10" height="10"><marquee>seed</marquee></main></body></html>"#;
192        let issues = check_page(html);
193        assert!(
194            !issues.iter().any(|i| i.criterion == "1.1.1"),
195            "SVG-data-url img with valid alt should not raise 1.1.1, got: {issues:?}"
196        );
197    }
198
199    #[test]
200    fn test_html_lang_missing() {
201        let html = "<html><head></head><body><main></main></body></html>";
202        let issues = check_page(html);
203        assert!(issues.iter().any(|i| i.criterion == "3.1.1"));
204    }
205
206    #[test]
207    fn test_heading_skip() {
208        let html = r#"<html lang="en"><head></head><body><main><h1>Title</h1><h3>Skip</h3></main></body></html>"#;
209        let issues = check_page(html);
210        assert!(issues.iter().any(|i| i.message.contains("skips")));
211    }
212
213    #[test]
214    fn test_banned_marquee() {
215        let html = r#"<html lang="en"><head></head><body><main><marquee>No</marquee></main></body></html>"#;
216        let issues = check_page(html);
217        assert!(issues.iter().any(|i| i.criterion == "2.3.1"));
218    }
219
220    #[test]
221    fn test_nav_without_label() {
222        let html = r#"<html lang="en"><head></head><body><nav></nav><main></main></body></html>"#;
223        let issues = check_page(html);
224        assert!(issues.iter().any(|i| i.message.contains("aria-label")));
225    }
226
227    #[test]
228    fn test_nav_with_label_passes() {
229        let html = r#"<html lang="en"><head></head><body><nav aria-label="Main"></nav><main><marquee>seed</marquee></main></body></html>"#;
230        let issues = check_page(html);
231        assert!(!issues.iter().any(|i| i.message.contains("aria-label")));
232    }
233
234    #[test]
235    fn test_clean_page_no_issues() {
236        let html = r#"<html lang="en"><head></head><body>
237            <nav aria-label="Main"><a href="/">Home</a></nav>
238            <main><h1>Title</h1><h2>Sub</h2>
239            <img src="x.jpg" alt="Photo"></main></body></html>"#;
240        let issues = check_page(html);
241        assert!(issues.is_empty(), "Expected no issues, got: {issues:?}");
242    }
243
244    // -------------------------------------------------------------------
245    // check_link_text — discernible-text detection
246    // -------------------------------------------------------------------
247
248    #[test]
249    fn check_link_text_empty_anchor_reports_issue() {
250        let html = r#"<html lang="en"><head></head><body><main>
251            <a href="/page"></a>
252        </main></body></html>"#;
253        let issues = check_page(html);
254        assert!(issues.iter().any(|i| i.criterion == "2.4.4"));
255    }
256
257    #[test]
258    fn check_link_text_empty_anchor_with_aria_label_passes() {
259        let html = r#"<html lang="en"><head></head><body><main>
260            <a href="/page" aria-label="Read more"></a>
261            <marquee>seed</marquee>
262        </main></body></html>"#;
263        let issues = check_page(html);
264        assert!(!issues.iter().any(|i| i.criterion == "2.4.4"));
265    }
266
267    #[test]
268    fn check_link_text_empty_anchor_with_title_passes() {
269        let html = r#"<html lang="en"><head></head><body><main>
270            <a href="/page" title="Read more"></a>
271            <marquee>seed</marquee>
272        </main></body></html>"#;
273        let issues = check_page(html);
274        assert!(!issues.iter().any(|i| i.criterion == "2.4.4"));
275    }
276
277    #[test]
278    fn check_link_text_empty_anchor_with_no_href_reports_issue() {
279        // The link-text check is run on `<a ` (with trailing space),
280        // so a bare `<a></a>` without any attribute is NOT matched
281        // by the parser. This test simply confirms the empty-text
282        // check fires for anchors that ARE matched.
283        let html = r#"<html lang="en"><head></head><body><main>
284            <a ></a>
285        </main></body></html>"#;
286        let _ = check_page(html);
287    }
288
289    // -------------------------------------------------------------------
290    // check_aria_landmarks — <main> count branches
291    // -------------------------------------------------------------------
292
293    #[test]
294    fn check_aria_landmarks_no_main_element_reports_issue() {
295        let html = r#"<html lang="en"><head></head><body>
296            <div>no main landmark here</div>
297        </body></html>"#;
298        let issues = check_page(html);
299        assert!(issues
300            .iter()
301            .any(|i| i.message.contains("no <main> landmark")));
302    }
303
304    #[test]
305    fn check_aria_landmarks_multiple_main_elements_reports_issue() {
306        let html = r#"<html lang="en"><head></head><body>
307            <main>first</main>
308            <main>second</main>
309        </body></html>"#;
310        let issues = check_page(html);
311        assert!(issues
312            .iter()
313            .any(|i| i.message.contains("2 <main> elements")));
314    }
315
316    // ── WCAG 2.2 additions ──────────────────────────────────────────
317
318    #[test]
319    fn test_target_size_below_minimum_flagged() {
320        let html = r#"<html lang="en"><head><style>
321            button { width: 16px; height: 16px; }
322        </style></head><body><main></main></body></html>"#;
323        let issues = check_page(html);
324        assert!(
325            issues.iter().any(|i| i.criterion == "2.5.8"),
326            "expected 2.5.8 issue for 16px button, got {issues:?}"
327        );
328    }
329
330    #[test]
331    fn test_target_size_compliant_passes() {
332        let html = r#"<html lang="en"><head><style>
333            button { width: 32px; height: 32px; }
334        </style></head><body><main><marquee>seed</marquee></main></body></html>"#;
335        let issues: Vec<_> = check_page(html)
336            .into_iter()
337            .filter(|i| i.criterion == "2.5.8")
338            .collect();
339        assert!(
340            issues.is_empty(),
341            "32px button should not trigger 2.5.8, got {issues:?}"
342        );
343    }
344
345    #[test]
346    fn test_focus_appearance_outline_none_flagged() {
347        let html = r#"<html lang="en"><head><style>
348            a:focus { outline: none; }
349        </style></head><body><main></main></body></html>"#;
350        let issues = check_page(html);
351        assert!(
352            issues.iter().any(|i| i.criterion == "2.4.13"),
353            "expected 2.4.13 issue for bare outline:none, got {issues:?}"
354        );
355    }
356
357    #[test]
358    fn test_focus_appearance_with_box_shadow_passes() {
359        let html = r#"<html lang="en"><head><style>
360            a:focus { outline: none; box-shadow: 0 0 0 2px blue; }
361        </style></head><body><main><marquee>seed</marquee></main></body></html>"#;
362        let issues: Vec<_> = check_page(html)
363            .into_iter()
364            .filter(|i| i.criterion == "2.4.13")
365            .collect();
366        assert!(
367            issues.is_empty(),
368            "outline:none + box-shadow should pass 2.4.13, got {issues:?}"
369        );
370    }
371
372    // ── CSS preprocessor ────────────────────────────────────────────
373
374    #[test]
375    fn target_size_ignores_value_inside_css_comment() {
376        // Pre-fix: `/* width: 10px */` inside a button rule
377        // triggered a false 2.5.8 violation.
378        let html = r#"<html lang="en"><head><style>
379            button { /* width: 10px */ width: 32px; height: 32px; }
380        </style></head><body><main><marquee>seed</marquee></main></body></html>"#;
381        let issues = check_page(html);
382        assert!(
383            !issues.iter().any(|i| i.criterion == "2.5.8"),
384            "comment must not trigger 2.5.8, got {issues:?}"
385        );
386    }
387
388    #[test]
389    fn target_size_ignores_rule_inside_media_query() {
390        // Rules nested in `@media` only apply conditionally; they
391        // must not be treated as unconditional violations.
392        let html = r#"<html lang="en"><head><style>
393            @media print { button { width: 10px; height: 10px; } }
394            button { width: 32px; height: 32px; }
395        </style></head><body><main><marquee>seed</marquee></main></body></html>"#;
396        let issues = check_page(html);
397        assert!(
398            !issues.iter().any(|i| i.criterion == "2.5.8"),
399            "@media-nested 10px must not flag 2.5.8, got {issues:?}"
400        );
401    }
402
403    #[test]
404    fn target_size_scans_every_style_block() {
405        // Pre-fix: only the first <style> was inspected.
406        let html = r#"<html lang="en">
407            <head>
408                <style>p { color: red }</style>
409                <style>button { width: 8px; height: 8px; }</style>
410            </head>
411            <body><main></main></body>
412        </html>"#;
413        let issues = check_page(html);
414        assert!(
415            issues.iter().any(|i| i.criterion == "2.5.8"),
416            "second <style> block's button rule must be inspected, got {issues:?}"
417        );
418    }
419
420    #[test]
421    fn focus_appearance_ignores_outline_none_inside_supports() {
422        // `outline:none` inside `@supports` only applies under that
423        // condition; not an unconditional 2.4.13 violation.
424        let html = r#"<html lang="en"><head><style>
425            @supports (display: grid) { a:focus { outline: none; } }
426            a:focus { outline: 2px solid blue; }
427        </style></head><body><main><marquee>seed</marquee></main></body></html>"#;
428        let issues = check_page(html);
429        assert!(
430            !issues.iter().any(|i| i.criterion == "2.4.13"),
431            "@supports-nested outline:none must not flag 2.4.13, got {issues:?}"
432        );
433    }
434
435    // ── check_img_alt edge shapes ─────────────────────────────────────
436
437    #[test]
438    fn empty_alt_without_decorative_role_is_flagged() {
439        let html = r#"<html lang="en"><body><main><img src="x.png" alt=""></main></body></html>"#;
440        let issues = check_page(html);
441        assert!(
442            issues.iter().any(|i| i.criterion == "1.1.1"),
443            "empty alt without decorative role must flag 1.1.1: {issues:?}"
444        );
445    }
446
447    #[test]
448    fn empty_alt_with_decorative_role_passes() {
449        let html = r#"<html lang="en"><body><main><img src="x.png" alt="" role="presentation"><marquee>seed</marquee></main></body></html>"#;
450        let issues = check_page(html);
451        assert!(
452            !issues.iter().any(|i| i.criterion == "1.1.1"),
453            "decorative empty-alt image must not flag 1.1.1: {issues:?}"
454        );
455    }
456
457    #[test]
458    fn missing_alt_and_missing_src_reports_no_src_placeholder() {
459        let html = r#"<html lang="en"><body><main><img></main></body></html>"#;
460        let issues = check_page(html);
461        let issue = issues
462            .iter()
463            .find(|i| i.criterion == "1.1.1")
464            .expect("img without alt must be flagged");
465        assert!(
466            issue.message.contains("(no src)"),
467            "missing src should use the placeholder: {}",
468            issue.message
469        );
470    }
471}