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;
64/// Whether an `<img>` tag lacks a text alternative (WCAG 1.1.1).
65///
66/// Exported so the host crate's other alt-text checks agree with
67/// [`check_page`] on what counts as missing, rather than keeping their
68/// own copy of the rule. Additive.
69pub use html::img_missing_alt;
70mod report;
71mod rules;
72mod types;
73
74pub use report::build_compliance_report;
75pub use rules::check_consistent_help;
76pub use types::{
77    AccessibilityIssue, AccessibilityReport, CriterionEntry, CriterionStatus,
78    PageReport, WcagComplianceReport,
79};
80
81/// Runs all WCAG checks on a single HTML page.
82///
83/// This is the crate's main entry point: pass it the full text of one
84/// rendered HTML page and it returns every issue found. It performs no
85/// I/O — callers are responsible for reading the file (or otherwise
86/// obtaining the HTML string) and for aggregating per-page results into
87/// a report (see [`AccessibilityReport`]) if desired.
88pub fn check_page(html: &str) -> Vec<AccessibilityIssue> {
89    let mut issues = Vec::new();
90
91    // WCAG 1.1.1: Non-text Content — all <img> must have alt
92    rules::check_img_alt(html, &mut issues);
93
94    // WCAG 3.1.1: Language of Page — <html> must have lang
95    rules::check_html_lang(html, &mut issues);
96
97    // WCAG 2.4.4: Link Purpose — all <a> must have discernible text
98    rules::check_link_text(html, &mut issues);
99
100    // WCAG 1.3.1: Heading hierarchy — no skipped levels
101    rules::check_heading_hierarchy(html, &mut issues);
102
103    // WCAG 2.3.1: No flashing — no <marquee> or <blink>
104    rules::check_banned_elements(html, &mut issues);
105
106    // ARIA: exactly one <main>, nav elements have aria-label
107    rules::check_aria_landmarks(html, &mut issues);
108
109    // WCAG 2.2 additions ----------------------------------------------
110
111    // 2.5.8 Target Size (Minimum) — interactive selectors with
112    // explicit width/height < 24px in inline CSS.
113    rules::check_target_size(html, &mut issues);
114
115    // 2.4.13 Focus Appearance — `outline: none` on :focus without a
116    // compensating outline-style/box-shadow/border declaration.
117    rules::check_focus_appearance(html, &mut issues);
118
119    // 3.2.6 Consistent Help is not checked per-page — it requires
120    // cross-page comparison of help-mechanism placement, which is
121    // beyond the per-page scan. [`rules::check_consistent_help`] is kept
122    // as a standalone helper for callers that want to run it themselves
123    // across a whole site; see [`build_compliance_report`], which marks
124    // this criterion `manual` in the matrix.
125
126    issues
127}
128
129#[cfg(test)]
130mod tests {
131
132    /// Regression: `check_page` panicked on non-ASCII input whose lowercase
133    /// form is longer than the original.
134    ///
135    /// Found by `fuzz_a11y` within seconds of the target being added. The
136    /// rules lowercased the page to search it case-insensitively, then used
137    /// the resulting offsets to slice the *original* string. `to_lowercase`
138    /// is not length-preserving — `İ` (U+0130) becomes two chars — so every
139    /// offset past it was wrong and the next slice landed mid-character:
140    ///
141    ///     thread panicked at crates/ssg-a11y/src/rules.rs:31:47
142    ///     byte index is not a char boundary
143    ///
144    /// This runs inside `ssg build`, so the input that triggers it is
145    /// someone's own content and the symptom is a crashed build.
146    #[test]
147    fn check_page_survives_non_ascii_that_grows_when_lowercased() {
148        // The reduced crash input from the fuzzer: U+0130 before a tag whose
149        // attributes are then sliced out of the original string.
150        let html = "<!\u{130}html>\n<img src=\"a.png\" alt>";
151        let _ = check_page(html);
152
153        // A few more shapes with the same hazard.
154        for probe in [
155            "\u{130}<img src=\"x.png\">",
156            "<img alt=\"\u{130}\" src=\"y.png\">",
157            "<p>\u{130}\u{130}\u{130}</p><img>",
158            "<img src=\"\u{130}.png\">",
159        ] {
160            let _ = check_page(probe);
161        }
162    }
163
164    /// The ASCII fold must still match tags written in any case.
165    #[test]
166    fn check_page_still_matches_uppercase_tags() {
167        let issues = check_page("<IMG SRC=\"a.png\">");
168        assert!(
169            issues.iter().any(|i| i.criterion == "1.1.1"),
170            "uppercase <IMG> without alt was not detected: {issues:?}"
171        );
172    }
173
174    use super::*;
175
176    #[test]
177    fn test_img_alt_missing() {
178        let html = r#"<html lang="en"><head></head><body><main><img src="photo.jpg"></main></body></html>"#;
179        let issues = check_page(html);
180        assert!(issues.iter().any(|i| i.criterion == "1.1.1"));
181    }
182
183    #[test]
184    fn test_img_alt_present() {
185        let html = r#"<html lang="en"><head></head><body><main><img src="photo.jpg" alt="A photo"><marquee>seed</marquee></main></body></html>"#;
186        let issues = check_page(html);
187        // The seeded <marquee> (2.3.1) keeps the issue list non-empty
188        // so the criterion predicate actually executes.
189        assert!(!issues.iter().any(|i| i.criterion == "1.1.1"));
190    }
191
192    #[test]
193    fn test_img_alt_with_inline_svg_data_url() {
194        // Regression: a `>` inside an SVG data URL in `src` previously
195        // truncated the tag and the parser missed the `alt` attribute,
196        // raising a false `<img> missing alt text: (no src)` issue.
197        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>"#;
198        let issues = check_page(html);
199        assert!(
200            !issues.iter().any(|i| i.criterion == "1.1.1"),
201            "SVG-data-url img with valid alt should not raise 1.1.1, got: {issues:?}"
202        );
203    }
204
205    #[test]
206    fn test_html_lang_missing() {
207        let html = "<html><head></head><body><main></main></body></html>";
208        let issues = check_page(html);
209        assert!(issues.iter().any(|i| i.criterion == "3.1.1"));
210    }
211
212    #[test]
213    fn test_heading_skip() {
214        let html = r#"<html lang="en"><head></head><body><main><h1>Title</h1><h3>Skip</h3></main></body></html>"#;
215        let issues = check_page(html);
216        assert!(issues.iter().any(|i| i.message.contains("skips")));
217    }
218
219    #[test]
220    fn test_banned_marquee() {
221        let html = r#"<html lang="en"><head></head><body><main><marquee>No</marquee></main></body></html>"#;
222        let issues = check_page(html);
223        assert!(issues.iter().any(|i| i.criterion == "2.3.1"));
224    }
225
226    #[test]
227    fn test_nav_without_label() {
228        let html = r#"<html lang="en"><head></head><body><nav></nav><main></main></body></html>"#;
229        let issues = check_page(html);
230        assert!(issues.iter().any(|i| i.message.contains("aria-label")));
231    }
232
233    #[test]
234    fn test_nav_with_label_passes() {
235        let html = r#"<html lang="en"><head></head><body><nav aria-label="Main"></nav><main><marquee>seed</marquee></main></body></html>"#;
236        let issues = check_page(html);
237        assert!(!issues.iter().any(|i| i.message.contains("aria-label")));
238    }
239
240    #[test]
241    fn test_clean_page_no_issues() {
242        let html = r#"<html lang="en"><head></head><body>
243            <nav aria-label="Main"><a href="/">Home</a></nav>
244            <main><h1>Title</h1><h2>Sub</h2>
245            <img src="x.jpg" alt="Photo"></main></body></html>"#;
246        let issues = check_page(html);
247        assert!(issues.is_empty(), "Expected no issues, got: {issues:?}");
248    }
249
250    // -------------------------------------------------------------------
251    // check_link_text — discernible-text detection
252    // -------------------------------------------------------------------
253
254    #[test]
255    fn check_link_text_empty_anchor_reports_issue() {
256        let html = r#"<html lang="en"><head></head><body><main>
257            <a href="/page"></a>
258        </main></body></html>"#;
259        let issues = check_page(html);
260        assert!(issues.iter().any(|i| i.criterion == "2.4.4"));
261    }
262
263    #[test]
264    fn check_link_text_empty_anchor_with_aria_label_passes() {
265        let html = r#"<html lang="en"><head></head><body><main>
266            <a href="/page" aria-label="Read more"></a>
267            <marquee>seed</marquee>
268        </main></body></html>"#;
269        let issues = check_page(html);
270        assert!(!issues.iter().any(|i| i.criterion == "2.4.4"));
271    }
272
273    #[test]
274    fn check_link_text_empty_anchor_with_title_passes() {
275        let html = r#"<html lang="en"><head></head><body><main>
276            <a href="/page" title="Read more"></a>
277            <marquee>seed</marquee>
278        </main></body></html>"#;
279        let issues = check_page(html);
280        assert!(!issues.iter().any(|i| i.criterion == "2.4.4"));
281    }
282
283    #[test]
284    fn check_link_text_empty_anchor_with_no_href_reports_issue() {
285        // The link-text check is run on `<a ` (with trailing space),
286        // so a bare `<a></a>` without any attribute is NOT matched
287        // by the parser. This test simply confirms the empty-text
288        // check fires for anchors that ARE matched.
289        let html = r#"<html lang="en"><head></head><body><main>
290            <a ></a>
291        </main></body></html>"#;
292        let _ = check_page(html);
293    }
294
295    // -------------------------------------------------------------------
296    // check_aria_landmarks — <main> count branches
297    // -------------------------------------------------------------------
298
299    #[test]
300    fn check_aria_landmarks_no_main_element_reports_issue() {
301        let html = r#"<html lang="en"><head></head><body>
302            <div>no main landmark here</div>
303        </body></html>"#;
304        let issues = check_page(html);
305        assert!(issues
306            .iter()
307            .any(|i| i.message.contains("no <main> landmark")));
308    }
309
310    #[test]
311    fn check_aria_landmarks_multiple_main_elements_reports_issue() {
312        let html = r#"<html lang="en"><head></head><body>
313            <main>first</main>
314            <main>second</main>
315        </body></html>"#;
316        let issues = check_page(html);
317        assert!(issues
318            .iter()
319            .any(|i| i.message.contains("2 <main> elements")));
320    }
321
322    // ── WCAG 2.2 additions ──────────────────────────────────────────
323
324    #[test]
325    fn test_target_size_below_minimum_flagged() {
326        let html = r#"<html lang="en"><head><style>
327            button { width: 16px; height: 16px; }
328        </style></head><body><main></main></body></html>"#;
329        let issues = check_page(html);
330        assert!(
331            issues.iter().any(|i| i.criterion == "2.5.8"),
332            "expected 2.5.8 issue for 16px button, got {issues:?}"
333        );
334    }
335
336    #[test]
337    fn test_target_size_compliant_passes() {
338        let html = r#"<html lang="en"><head><style>
339            button { width: 32px; height: 32px; }
340        </style></head><body><main><marquee>seed</marquee></main></body></html>"#;
341        let issues: Vec<_> = check_page(html)
342            .into_iter()
343            .filter(|i| i.criterion == "2.5.8")
344            .collect();
345        assert!(
346            issues.is_empty(),
347            "32px button should not trigger 2.5.8, got {issues:?}"
348        );
349    }
350
351    #[test]
352    fn test_focus_appearance_outline_none_flagged() {
353        let html = r#"<html lang="en"><head><style>
354            a:focus { outline: none; }
355        </style></head><body><main></main></body></html>"#;
356        let issues = check_page(html);
357        assert!(
358            issues.iter().any(|i| i.criterion == "2.4.13"),
359            "expected 2.4.13 issue for bare outline:none, got {issues:?}"
360        );
361    }
362
363    #[test]
364    fn test_focus_appearance_with_box_shadow_passes() {
365        let html = r#"<html lang="en"><head><style>
366            a:focus { outline: none; box-shadow: 0 0 0 2px blue; }
367        </style></head><body><main><marquee>seed</marquee></main></body></html>"#;
368        let issues: Vec<_> = check_page(html)
369            .into_iter()
370            .filter(|i| i.criterion == "2.4.13")
371            .collect();
372        assert!(
373            issues.is_empty(),
374            "outline:none + box-shadow should pass 2.4.13, got {issues:?}"
375        );
376    }
377
378    // ── CSS preprocessor ────────────────────────────────────────────
379
380    #[test]
381    fn target_size_ignores_value_inside_css_comment() {
382        // Pre-fix: `/* width: 10px */` inside a button rule
383        // triggered a false 2.5.8 violation.
384        let html = r#"<html lang="en"><head><style>
385            button { /* width: 10px */ width: 32px; height: 32px; }
386        </style></head><body><main><marquee>seed</marquee></main></body></html>"#;
387        let issues = check_page(html);
388        assert!(
389            !issues.iter().any(|i| i.criterion == "2.5.8"),
390            "comment must not trigger 2.5.8, got {issues:?}"
391        );
392    }
393
394    #[test]
395    fn target_size_ignores_rule_inside_media_query() {
396        // Rules nested in `@media` only apply conditionally; they
397        // must not be treated as unconditional violations.
398        let html = r#"<html lang="en"><head><style>
399            @media print { button { width: 10px; height: 10px; } }
400            button { width: 32px; height: 32px; }
401        </style></head><body><main><marquee>seed</marquee></main></body></html>"#;
402        let issues = check_page(html);
403        assert!(
404            !issues.iter().any(|i| i.criterion == "2.5.8"),
405            "@media-nested 10px must not flag 2.5.8, got {issues:?}"
406        );
407    }
408
409    #[test]
410    fn target_size_scans_every_style_block() {
411        // Pre-fix: only the first <style> was inspected.
412        let html = r#"<html lang="en">
413            <head>
414                <style>p { color: red }</style>
415                <style>button { width: 8px; height: 8px; }</style>
416            </head>
417            <body><main></main></body>
418        </html>"#;
419        let issues = check_page(html);
420        assert!(
421            issues.iter().any(|i| i.criterion == "2.5.8"),
422            "second <style> block's button rule must be inspected, got {issues:?}"
423        );
424    }
425
426    #[test]
427    fn focus_appearance_ignores_outline_none_inside_supports() {
428        // `outline:none` inside `@supports` only applies under that
429        // condition; not an unconditional 2.4.13 violation.
430        let html = r#"<html lang="en"><head><style>
431            @supports (display: grid) { a:focus { outline: none; } }
432            a:focus { outline: 2px solid blue; }
433        </style></head><body><main><marquee>seed</marquee></main></body></html>"#;
434        let issues = check_page(html);
435        assert!(
436            !issues.iter().any(|i| i.criterion == "2.4.13"),
437            "@supports-nested outline:none must not flag 2.4.13, got {issues:?}"
438        );
439    }
440
441    // ── check_img_alt edge shapes ─────────────────────────────────────
442
443    #[test]
444    fn empty_alt_without_decorative_role_is_flagged() {
445        let html = r#"<html lang="en"><body><main><img src="x.png" alt=""></main></body></html>"#;
446        let issues = check_page(html);
447        assert!(
448            issues.iter().any(|i| i.criterion == "1.1.1"),
449            "empty alt without decorative role must flag 1.1.1: {issues:?}"
450        );
451    }
452
453    #[test]
454    fn empty_alt_with_decorative_role_passes() {
455        let html = r#"<html lang="en"><body><main><img src="x.png" alt="" role="presentation"><marquee>seed</marquee></main></body></html>"#;
456        let issues = check_page(html);
457        assert!(
458            !issues.iter().any(|i| i.criterion == "1.1.1"),
459            "decorative empty-alt image must not flag 1.1.1: {issues:?}"
460        );
461    }
462
463    #[test]
464    fn empty_alt_with_aria_hidden_passes() {
465        // The masthead logo that produced the false positive: one tag
466        // across two lines, alt="" and aria-hidden="true".
467        let html = "<html lang=\"en\"><body><main>\
468            <img class=\"brand-mark\" src=\"/assets/logo.svg\"\n     \
469            width=\"28\" height=\"28\" alt=\"\" aria-hidden=\"true\" />\
470            </main></body></html>";
471        let issues = check_page(html);
472        assert!(
473            !issues.iter().any(|i| i.criterion == "1.1.1"),
474            "alt=\"\" aria-hidden=\"true\" must not flag 1.1.1: {issues:?}"
475        );
476    }
477
478    #[test]
479    fn aria_hidden_without_alt_is_still_flagged() {
480        let html = r#"<html lang="en"><body><main><img src="x.png" aria-hidden="true"></main></body></html>"#;
481        let issues = check_page(html);
482        assert!(
483            issues.iter().any(|i| i.criterion == "1.1.1"),
484            "an <img> with no alt attribute must flag 1.1.1: {issues:?}"
485        );
486    }
487
488    #[test]
489    fn unquoted_alt_passes() {
490        let html = r#"<html lang="en"><body><main><img src=x.png alt=photo></main></body></html>"#;
491        let issues = check_page(html);
492        assert!(
493            !issues.iter().any(|i| i.criterion == "1.1.1"),
494            "unquoted alt=photo is alt text: {issues:?}"
495        );
496    }
497
498    #[test]
499    fn missing_alt_and_missing_src_reports_no_src_placeholder() {
500        let html = r#"<html lang="en"><body><main><img></main></body></html>"#;
501        let issues = check_page(html);
502        let issue = issues
503            .iter()
504            .find(|i| i.criterion == "1.1.1")
505            .expect("img without alt must be flagged");
506        assert!(
507            issue.message.contains("(no src)"),
508            "missing src should use the placeholder: {}",
509            issue.message
510        );
511    }
512}