Skip to main content

ssg/
accessibility.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Automated WCAG accessibility checker and ARIA validation plugin.
5//!
6//! Validates generated HTML against a subset of WCAG 2.2 Level AA
7//! success criteria and checks ARIA landmark correctness. Produces
8//! two artifacts in the site directory:
9//!
10//! - `accessibility-report.json` — issue list per page (existing format).
11//! - `wcag-compliance.json` — compliance matrix mapping each WCAG 2.2
12//!   criterion to its automation status (automated / runtime-only /
13//!   manual / not-applicable) plus a per-page pass/fail summary.
14//!
15//! Build-time checks:
16//! - 1.1.1 Non-text content (`<img alt>`)
17//! - 1.3.1 Heading hierarchy (no skipped levels)
18//! - 2.3.1 Banned elements (`<marquee>`, `<blink>`)
19//! - 2.4.4 Link purpose (discernible text or `aria-label`)
20//! - 2.4.13 Focus appearance — `:focus { outline: none }` without
21//!   compensating style is flagged (WCAG 2.2 addition)
22//! - 2.5.8 Target size minimum — explicit `width`/`height` < 24 px on
23//!   interactive selectors flagged (WCAG 2.2 addition)
24//! - 3.1.1 Page language (`<html lang>`)
25//! - 3.2.6 Consistent help — informational note when help link absent
26//!   from page (WCAG 2.2 addition; full check requires cross-page
27//!   analysis, see runtime axe-core gate)
28//! - ARIA landmarks (single `<main>`, `<nav aria-label>`)
29
30use crate::plugin::{Plugin, PluginContext};
31use anyhow::Result;
32use serde::Serialize;
33use std::fs;
34
35/// An individual accessibility issue found in a page.
36#[derive(Debug, Clone, Serialize, serde::Deserialize)]
37pub struct AccessibilityIssue {
38    /// WCAG success criterion (e.g. "1.1.1").
39    pub criterion: String,
40    /// Severity: "error" or "warning".
41    pub severity: String,
42    /// Human-readable description.
43    pub message: String,
44}
45
46/// Accessibility report for a single page.
47#[derive(Debug, Clone, Serialize, serde::Deserialize)]
48pub struct PageReport {
49    /// Relative path of the HTML file.
50    pub path: String,
51    /// Issues found.
52    pub issues: Vec<AccessibilityIssue>,
53}
54
55/// Full accessibility report.
56#[derive(Debug, Clone, Serialize, serde::Deserialize)]
57pub struct AccessibilityReport {
58    /// Total pages scanned.
59    pub pages_scanned: usize,
60    /// Total issues found.
61    pub total_issues: usize,
62    /// WCAG version this report is asserted against.
63    #[serde(default = "default_wcag_version")]
64    pub wcag_version: String,
65    /// Per-page reports (only pages with issues).
66    pub pages: Vec<PageReport>,
67}
68
69fn default_wcag_version() -> String {
70    "2.2".to_string()
71}
72
73/// How a single WCAG criterion is verified.
74#[derive(Debug, Clone, Copy, Serialize, serde::Deserialize)]
75#[serde(rename_all = "kebab-case")]
76#[non_exhaustive]
77pub enum CriterionStatus {
78    /// SSG verifies this criterion at build time.
79    Automated,
80    /// Verified at runtime by axe-core in CI (`visual.yml`).
81    Runtime,
82    /// Requires human review (e.g. cognitive accessibility).
83    Manual,
84    /// Does not apply to static content (e.g. forms-only criteria).
85    NotApplicable,
86}
87
88/// One row of the WCAG 2.2 compliance matrix.
89#[derive(Debug, Clone, Serialize, serde::Deserialize)]
90pub struct CriterionEntry {
91    /// SC identifier (e.g. "1.1.1", "2.5.8").
92    pub criterion: String,
93    /// Conformance level: A, AA, AAA.
94    pub level: String,
95    /// Short title of the criterion.
96    pub title: String,
97    /// Verification status.
98    pub status: CriterionStatus,
99    /// True if every scanned page passed (only meaningful for `Automated`).
100    pub all_pages_pass: bool,
101}
102
103/// WCAG 2.2 compliance matrix written alongside `accessibility-report.json`.
104#[derive(Debug, Clone, Serialize, serde::Deserialize)]
105pub struct WcagComplianceReport {
106    /// Spec version this matrix is asserted against.
107    pub wcag_version: String,
108    /// Total pages scanned.
109    pub pages_scanned: usize,
110    /// Per-criterion compliance entries.
111    pub criteria: Vec<CriterionEntry>,
112}
113
114/// Plugin that checks generated HTML for WCAG compliance.
115///
116/// Runs in `after_compile`. Non-blocking by default (logs warnings).
117#[derive(Debug, Clone, Copy)]
118pub struct AccessibilityPlugin;
119
120impl Plugin for AccessibilityPlugin {
121    fn name(&self) -> &'static str {
122        "accessibility"
123    }
124
125    fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
126        if !ctx.site_dir.exists() {
127            return Ok(());
128        }
129
130        let html_files = ctx.get_html_files();
131        let mut report = AccessibilityReport {
132            pages_scanned: html_files.len(),
133            total_issues: 0,
134            wcag_version: "2.2".to_string(),
135            pages: Vec::new(),
136        };
137
138        // Per-criterion fail set, used to populate the compliance matrix.
139        let mut failed_criteria: std::collections::HashSet<String> =
140            std::collections::HashSet::new();
141
142        for path in &html_files {
143            let html = fs::read_to_string(path)?;
144            let rel = path
145                .strip_prefix(&ctx.site_dir)
146                .unwrap_or(path)
147                .to_string_lossy()
148                .to_string();
149
150            let issues = check_page(&html);
151            if !issues.is_empty() {
152                for issue in &issues {
153                    let _ = failed_criteria.insert(issue.criterion.clone());
154                    log::warn!(
155                        "[a11y] {} — [{}] {}",
156                        rel,
157                        issue.criterion,
158                        issue.message
159                    );
160                }
161                report.total_issues += issues.len();
162                report.pages.push(PageReport { path: rel, issues });
163            }
164        }
165
166        // Write the per-page issue report.
167        let report_path = ctx.site_dir.join("accessibility-report.json");
168        let json = serde_json::to_string_pretty(&report)?;
169        fs::write(&report_path, json)?;
170
171        // Write the WCAG 2.2 compliance matrix.
172        let compliance =
173            build_compliance_report(html_files.len(), &failed_criteria);
174        let matrix_path = ctx.site_dir.join("wcag-compliance.json");
175        fs::write(&matrix_path, serde_json::to_string_pretty(&compliance)?)?;
176
177        if report.total_issues > 0 {
178            log::warn!(
179                "[a11y] {} issue(s) across {} page(s). Reports: {} + {}",
180                report.total_issues,
181                report.pages.len(),
182                report_path.display(),
183                matrix_path.display()
184            );
185        } else {
186            log::info!(
187                "[a11y] All {} page(s) passed checks. Reports: {} + {}",
188                report.pages_scanned,
189                report_path.display(),
190                matrix_path.display()
191            );
192        }
193
194        Ok(())
195    }
196}
197
198/// Constructs the WCAG 2.2 compliance matrix. Marks `all_pages_pass=false`
199/// for any criterion that produced at least one issue across the scan.
200fn build_compliance_report(
201    pages_scanned: usize,
202    failed: &std::collections::HashSet<String>,
203) -> WcagComplianceReport {
204    use CriterionStatus::{Automated, Manual, NotApplicable, Runtime};
205    let did_pass = |sc: &str| !failed.contains(sc);
206    let row = |sc: &str, level: &str, title: &str, status: CriterionStatus| {
207        CriterionEntry {
208            criterion: sc.to_string(),
209            level: level.to_string(),
210            title: title.to_string(),
211            status,
212            all_pages_pass: matches!(status, Automated) && did_pass(sc),
213        }
214    };
215
216    let criteria = vec![
217        // Perceivable
218        row("1.1.1", "A", "Non-text Content", Automated),
219        row("1.3.1", "A", "Info and Relationships", Automated),
220        row("1.4.3", "AA", "Contrast (Minimum)", Runtime),
221        row("1.4.10", "AA", "Reflow", Runtime),
222        row("1.4.11", "AA", "Non-text Contrast", Runtime),
223        row("1.4.12", "AA", "Text Spacing", Runtime),
224        // Operable
225        row("2.3.1", "A", "Three Flashes or Below Threshold", Automated),
226        row("2.4.4", "A", "Link Purpose (In Context)", Automated),
227        row("2.4.11", "AA", "Focus Not Obscured (Minimum)", Runtime),
228        row("2.4.13", "AAA", "Focus Appearance", Automated),
229        row("2.5.7", "AA", "Dragging Movements", Manual),
230        row("2.5.8", "AA", "Target Size (Minimum)", Automated),
231        // Understandable
232        row("3.1.1", "A", "Language of Page", Automated),
233        // 3.2.6 requires cross-page analysis (consistent placement of
234        // a help mechanism); the per-page validator can't decide it.
235        row("3.2.6", "A", "Consistent Help", Manual),
236        row("3.3.7", "A", "Redundant Entry", NotApplicable),
237        row(
238            "3.3.8",
239            "AA",
240            "Accessible Authentication (Minimum)",
241            NotApplicable,
242        ),
243        // Robust
244        row("4.1.3", "AA", "Status Messages", Runtime),
245    ];
246
247    WcagComplianceReport {
248        wcag_version: "2.2".to_string(),
249        pages_scanned,
250        criteria,
251    }
252}
253
254/// Runs all WCAG checks on a single HTML page.
255fn check_page(html: &str) -> Vec<AccessibilityIssue> {
256    let mut issues = Vec::new();
257
258    // WCAG 1.1.1: Non-text Content — all <img> must have alt
259    check_img_alt(html, &mut issues);
260
261    // WCAG 3.1.1: Language of Page — <html> must have lang
262    check_html_lang(html, &mut issues);
263
264    // WCAG 2.4.4: Link Purpose — all <a> must have discernible text
265    check_link_text(html, &mut issues);
266
267    // WCAG 1.3.1: Heading hierarchy — no skipped levels
268    check_heading_hierarchy(html, &mut issues);
269
270    // WCAG 2.3.1: No flashing — no <marquee> or <blink>
271    check_banned_elements(html, &mut issues);
272
273    // ARIA: exactly one <main>, nav elements have aria-label
274    check_aria_landmarks(html, &mut issues);
275
276    // WCAG 2.2 additions ----------------------------------------------
277
278    // 2.5.8 Target Size (Minimum) — interactive selectors with
279    // explicit width/height < 24px in inline CSS.
280    check_target_size(html, &mut issues);
281
282    // 2.4.13 Focus Appearance — `outline: none` on :focus without a
283    // compensating outline-style/box-shadow/border declaration.
284    check_focus_appearance(html, &mut issues);
285
286    // 3.2.6 Consistent Help is not checked per-page — it requires
287    // cross-page comparison of help-mechanism placement, which is
288    // beyond the per-page scan. The compliance matrix marks it as
289    // `manual` so reviewers know to verify it during release sign-off.
290    let _ = check_consistent_help; // keep the helper for tests + future cross-page work
291
292    issues
293}
294
295/// WCAG 2.2 — 2.5.8 Target Size (Minimum, AA).
296///
297/// Heuristic: scan the first inline `<style>` block. Flag any
298/// declaration that sets `width` or `height` to a value smaller than
299/// 24 px on a selector that targets `button`, `a`, `input`, or
300/// `[role="button"]`. We can't fully verify rendered size at build
301/// time (that's the runtime axe-core gate's job) but explicit
302/// sub-24 px declarations are unambiguous regressions.
303fn check_target_size(html: &str, issues: &mut Vec<AccessibilityIssue>) {
304    for css in extract_all_style_blocks(html) {
305        let cleaned = preprocess_css(&css);
306        for (selector, body) in parse_top_level_rules(&cleaned) {
307            if !selector_targets_interactive(&selector) {
308                continue;
309            }
310            for prop in ["width", "height"] {
311                if let Some(px) = first_px_value(&body, prop) {
312                    if px > 0 && px < 24 {
313                        issues.push(AccessibilityIssue {
314                            criterion: "2.5.8".to_string(),
315                            severity: "warning".to_string(),
316                            message: format!(
317                                "Target size {prop}={px}px on `{selector}` \
318                                 is below the 24×24 minimum (WCAG 2.2 AA)"
319                            ),
320                        });
321                    }
322                }
323            }
324        }
325    }
326}
327
328/// Returns `true` if `selector` (already lowercased + trimmed)
329/// targets an interactive element class for WCAG 2.5.8.
330fn selector_targets_interactive(selector: &str) -> bool {
331    selector.contains("button")
332        || selector.contains("input")
333        || selector.contains("[role=\"button\"]")
334        || selector.contains("[role='button']")
335        || selector.contains("[role=button]")
336        // Bare `a` or `a ...` selector. Excludes `area`, `aside`, etc.
337        || selector == "a"
338        || selector.starts_with("a ")
339        || selector.starts_with("a:")
340        || selector.starts_with("a.")
341        || selector.starts_with("a#")
342        || selector.starts_with("a[")
343}
344
345/// Returns the first `<prop>: NNpx` value (in pixels) inside `css`.
346fn first_px_value(css: &str, prop: &str) -> Option<u32> {
347    let pat = format!("{prop}:");
348    let start = css.find(&pat)?;
349    let after = &css[start + pat.len()..];
350    let value = after.split(';').next()?.trim();
351    let digits: String =
352        value.chars().take_while(|c| c.is_ascii_digit()).collect();
353    if digits.is_empty() {
354        return None;
355    }
356    if value[digits.len()..].trim_start().starts_with("px") {
357        digits.parse().ok()
358    } else {
359        None
360    }
361}
362
363/// WCAG 2.2 — 2.4.13 Focus Appearance (AAA).
364///
365/// Detects `:focus { outline: none }` (or `outline: 0`) without a
366/// compensating `outline-style`, `box-shadow`, or `border` declaration
367/// in the same rule.
368fn check_focus_appearance(html: &str, issues: &mut Vec<AccessibilityIssue>) {
369    for css in extract_all_style_blocks(html) {
370        let cleaned = preprocess_css(&css);
371        for (selector, body) in parse_top_level_rules(&cleaned) {
372            if !selector.contains(":focus") {
373                continue;
374            }
375            let kills_outline = body.contains("outline:none")
376                || body.contains("outline: none")
377                || body.contains("outline:0")
378                || body.contains("outline: 0");
379            let has_replacement = body.contains("outline-style")
380                || body.contains("outline-color")
381                || body.contains("box-shadow")
382                || body.contains("border:");
383
384            if kills_outline && !has_replacement {
385                issues.push(AccessibilityIssue {
386                    criterion: "2.4.13".to_string(),
387                    severity: "warning".to_string(),
388                    message: "`:focus { outline: none }` without a \
389                         compensating outline-style/box-shadow/border \
390                         (WCAG 2.2 AAA — Focus Appearance)"
391                        .to_string(),
392                });
393            }
394        }
395    }
396}
397
398// =====================================================================
399// CSS preprocessor (audit fix for items #3)
400// =====================================================================
401//
402// The previous WCAG 2.2 checks parsed only the *first* `<style>`
403// block, did not strip `/* ... */` comments, and did not skip rules
404// nested inside `@media` / `@supports`. Common false positives:
405//
406//   /* width: 10px */     — flagged as a 2.5.8 violation
407//   @media print { button { width: 10px } }
408//                         — flagged as a 2.5.8 violation though
409//                           the rule only applies on print
410//
411// `extract_all_style_blocks` + `preprocess_css` + `parse_top_level_rules`
412// fix all three. They lowercase as they go so downstream checks
413// continue to do case-insensitive matching.
414
415/// Returns the inner CSS of every `<style>...</style>` block in the
416/// document. Tolerant of `<style data-foo="bar">` and other
417/// attribute-bearing forms (uses `find_tag_end` so quoted `>` inside
418/// attribute values doesn't truncate the open tag).
419fn extract_all_style_blocks(html: &str) -> Vec<String> {
420    let mut blocks = Vec::new();
421    let lower = html.to_lowercase();
422    let mut cursor = 0;
423
424    while let Some(rel_open) = lower[cursor..].find("<style") {
425        let abs_open = cursor + rel_open;
426        let tag_end = find_tag_end(&lower, abs_open);
427        cursor = tag_end;
428
429        let Some(rel_close) = lower[cursor..].find("</style>") else {
430            break;
431        };
432        // Use the original-case slice (we lowercase later in
433        // `preprocess_css` so case-sensitive selectors don't get
434        // inadvertently normalised before extraction).
435        blocks.push(html[cursor..cursor + rel_close].to_string());
436        cursor += rel_close + "</style>".len();
437    }
438
439    blocks
440}
441
442/// CSS preprocessor: lowercases, strips `/* ... */` comments, and
443/// removes the body of any `@media` / `@supports` / `@keyframes`
444/// at-rule. The returned string contains only the top-level rules
445/// that apply unconditionally to every viewport.
446///
447/// At-rule body removal is brace-balanced — it correctly skips over
448/// nested blocks inside `@supports (display: grid) { @media (prefers
449/// ...) { ... } }`. The at-rule's own preamble (the `@supports (...)`
450/// part) is dropped along with its body.
451fn preprocess_css(css: &str) -> String {
452    let lower = css.to_lowercase();
453    let no_comments = strip_css_comments(&lower);
454    strip_at_rules(&no_comments)
455}
456
457fn strip_css_comments(css: &str) -> String {
458    let mut out = String::with_capacity(css.len());
459    let bytes = css.as_bytes();
460    let mut i = 0;
461    while i < bytes.len() {
462        if i + 1 < bytes.len() && &bytes[i..i + 2] == b"/*" {
463            // Skip until the matching `*/`.
464            i += 2;
465            while i + 1 < bytes.len() && &bytes[i..i + 2] != b"*/" {
466                i += 1;
467            }
468            i = (i + 2).min(bytes.len());
469            // Replace the comment with a single space so adjacent
470            // tokens don't accidentally merge (`/*x*/y` → ` y`).
471            out.push(' ');
472        } else {
473            out.push(bytes[i] as char);
474            i += 1;
475        }
476    }
477    out
478}
479
480fn strip_at_rules(css: &str) -> String {
481    let mut out = String::with_capacity(css.len());
482    let bytes = css.as_bytes();
483    let mut i = 0;
484    while i < bytes.len() {
485        if bytes[i] == b'@' {
486            // Skip the at-rule preamble until either `;` (rule
487            // terminator, e.g. `@import`) or `{` (block start).
488            let mut j = i;
489            while j < bytes.len() && bytes[j] != b'{' && bytes[j] != b';' {
490                j += 1;
491            }
492            if j >= bytes.len() {
493                break;
494            }
495            if bytes[j] == b';' {
496                // Bare at-rule with no block — skip including the `;`.
497                i = j + 1;
498                continue;
499            }
500            // Brace-balanced skip of the at-rule body.
501            let mut depth = 0_i32;
502            let mut k = j;
503            while k < bytes.len() {
504                match bytes[k] {
505                    b'{' => depth += 1,
506                    b'}' => {
507                        depth -= 1;
508                        if depth == 0 {
509                            k += 1;
510                            break;
511                        }
512                    }
513                    _ => {}
514                }
515                k += 1;
516            }
517            i = k;
518            continue;
519        }
520        out.push(bytes[i] as char);
521        i += 1;
522    }
523    out
524}
525
526/// Splits CSS into `(selector, body)` pairs at the top level. Comments
527/// and at-rules must already be removed.
528fn parse_top_level_rules(css: &str) -> Vec<(String, String)> {
529    let mut rules = Vec::new();
530    let bytes = css.as_bytes();
531    let mut i = 0;
532    while i < bytes.len() {
533        let Some(open_rel) = css[i..].find('{') else {
534            break;
535        };
536        let open = i + open_rel;
537        let selector = css[i..open].trim().to_string();
538        if selector.is_empty() {
539            i = open + 1;
540            continue;
541        }
542        // Brace-balanced body extraction (handles nested `{}` even
543        // though plain CSS doesn't use them — defensive).
544        let mut depth = 1_i32;
545        let mut j = open + 1;
546        while j < bytes.len() {
547            match bytes[j] {
548                b'{' => depth += 1,
549                b'}' => {
550                    depth -= 1;
551                    if depth == 0 {
552                        break;
553                    }
554                }
555                _ => {}
556            }
557            j += 1;
558        }
559        let body = css[open + 1..j].to_string();
560        rules.push((selector, body));
561        i = j + 1;
562    }
563    rules
564}
565
566/// WCAG 2.2 — 3.2.6 Consistent Help (Level A).
567///
568/// Build-time verification is partial — full conformance requires
569/// cross-page comparison of help-mechanism placement. This check
570/// emits an *info* note when a page contains no detectable help link
571/// (anchor text matching `help`, `contact`, `support`, `faq`).
572/// Cross-page placement consistency is left to the runtime axe-core
573/// audit and human review.
574fn check_consistent_help(html: &str, issues: &mut Vec<AccessibilityIssue>) {
575    let lower = html.to_lowercase();
576    let has_help_link = lower.contains(">help<")
577        || lower.contains(">contact<")
578        || lower.contains(">support<")
579        || lower.contains(">faq<")
580        || lower.contains("aria-label=\"help\"")
581        || lower.contains("aria-label=\"contact\"")
582        || lower.contains("aria-label=\"support\"");
583
584    if !has_help_link {
585        issues.push(AccessibilityIssue {
586            criterion: "3.2.6".to_string(),
587            severity: "info".to_string(),
588            message: "No detectable help/contact/support link on page; \
589                      verify that the site provides a consistent help \
590                      mechanism across pages (WCAG 2.2 A — Consistent \
591                      Help)"
592                .to_string(),
593        });
594    }
595}
596
597/// Returns `true` if the `<img>` tag has any form of `alt` attribute.
598fn has_valid_alt(tag: &str) -> bool {
599    let has_alt_eq = tag.contains("alt=");
600    let has_alt_bare = !has_alt_eq
601        && (tag.contains(" alt ")
602            || tag.contains(" alt>")
603            || tag.ends_with(" alt"));
604    has_alt_eq || has_alt_bare
605}
606
607/// Returns `true` if the `<img>` tag has an empty or missing-value alt.
608fn has_empty_alt(tag: &str) -> bool {
609    let has_alt_eq = tag.contains("alt=");
610    let has_alt_bare = !has_alt_eq
611        && (tag.contains(" alt ")
612            || tag.contains(" alt>")
613            || tag.ends_with(" alt"));
614    tag.contains("alt=\"\"")
615        || tag.contains("alt=''")
616        || has_alt_bare
617        || (has_alt_eq && !tag.contains("alt=\"") && !tag.contains("alt='"))
618}
619
620/// Returns `true` if the `<img>` tag is marked as decorative via ARIA roles.
621fn is_decorative_img(tag: &str) -> bool {
622    tag.contains("role=\"presentation\"")
623        || tag.contains("role=\"none\"")
624        || tag.contains("role='presentation'")
625        || tag.contains("role='none'")
626        || tag.contains("role=presentation")
627        || tag.contains("role=none")
628}
629
630/// Returns the absolute end index (one past the closing `>`) of the HTML
631/// tag that starts at `tag_start`. Skips `>` characters that occur inside
632/// double- or single-quoted attribute values so that inline SVG `data:`
633/// URLs in `src` attributes don't truncate the tag prematurely.
634fn find_tag_end(html: &str, tag_start: usize) -> usize {
635    let bytes = html.as_bytes();
636    let mut i = tag_start;
637    let mut quote: Option<u8> = None;
638    while i < bytes.len() {
639        let b = bytes[i];
640        match quote {
641            Some(q) if b == q => quote = None,
642            Some(_) => {}
643            None => match b {
644                b'"' | b'\'' => quote = Some(b),
645                b'>' => return i + 1,
646                _ => {}
647            },
648        }
649        i += 1;
650    }
651    bytes.len()
652}
653
654/// WCAG 1.1.1: Every <img> must have a non-empty alt attribute.
655fn check_img_alt(html: &str, issues: &mut Vec<AccessibilityIssue>) {
656    let lower = html.to_lowercase();
657    let mut pos = 0;
658    while let Some(start) = lower[pos..].find("<img") {
659        let abs = pos + start;
660        let tag_end = find_tag_end(&lower, abs);
661        let tag = &lower[abs..tag_end];
662
663        if !has_valid_alt(tag)
664            || (has_empty_alt(tag) && !is_decorative_img(tag))
665        {
666            let src = extract_attr_value(&html[abs..tag_end], "src")
667                .unwrap_or_default();
668            issues.push(AccessibilityIssue {
669                criterion: "1.1.1".to_string(),
670                severity: "error".to_string(),
671                message: format!(
672                    "<img> missing alt text: {}",
673                    if src.is_empty() { "(no src)" } else { &src }
674                ),
675            });
676        }
677
678        pos = tag_end;
679    }
680}
681
682/// WCAG 3.1.1: <html> element must have a lang attribute.
683fn check_html_lang(html: &str, issues: &mut Vec<AccessibilityIssue>) {
684    let lower = html.to_lowercase();
685    if let Some(start) = lower.find("<html") {
686        let tag_end =
687            lower[start..].find('>').map_or(lower.len(), |e| start + e);
688        let tag = &lower[start..tag_end];
689        if !tag.contains("lang=") {
690            issues.push(AccessibilityIssue {
691                criterion: "3.1.1".to_string(),
692                severity: "error".to_string(),
693                message: "<html> missing lang attribute".to_string(),
694            });
695        }
696    }
697}
698
699/// WCAG 2.4.4: Links must have discernible text.
700fn check_link_text(html: &str, issues: &mut Vec<AccessibilityIssue>) {
701    let lower = html.to_lowercase();
702    let mut pos = 0;
703    while let Some(start) = lower[pos..].find("<a ") {
704        let abs = pos + start;
705        let close = lower[abs..].find("</a>").unwrap_or(lower.len() - abs);
706        let full = &lower[abs..abs + close];
707
708        // Get inner content (between > and </a>)
709        if let Some(gt) = full.find('>') {
710            let inner = &full[gt + 1..];
711            let text = strip_tags_simple(inner);
712            let has_aria = full.contains("aria-label=");
713            let has_title = full.contains("title=");
714
715            if text.trim().is_empty() && !has_aria && !has_title {
716                let href = extract_attr_value(&html[abs..abs + close], "href")
717                    .unwrap_or_default();
718                issues.push(AccessibilityIssue {
719                    criterion: "2.4.4".to_string(),
720                    severity: "warning".to_string(),
721                    message: format!(
722                        "<a> has no discernible text: href={}",
723                        if href.is_empty() { "(none)" } else { &href }
724                    ),
725                });
726            }
727        }
728
729        pos = abs + close.max(1);
730    }
731}
732
733/// WCAG 1.3.1: Heading levels must not skip (e.g. h1 → h3).
734fn check_heading_hierarchy(html: &str, issues: &mut Vec<AccessibilityIssue>) {
735    let lower = html.to_lowercase();
736    let mut last_level: u8 = 0;
737
738    for level in 1..=6u8 {
739        let tag = format!("<h{level}");
740        if lower.contains(&tag) {
741            if last_level > 0 && level > last_level + 1 {
742                issues.push(AccessibilityIssue {
743                    criterion: "1.3.1".to_string(),
744                    severity: "warning".to_string(),
745                    message: format!(
746                        "Heading hierarchy skips from h{last_level} to h{level}"
747                    ),
748                });
749            }
750            last_level = level;
751        }
752    }
753}
754
755/// WCAG 2.3.1: No <marquee> or <blink> elements.
756fn check_banned_elements(html: &str, issues: &mut Vec<AccessibilityIssue>) {
757    let lower = html.to_lowercase();
758    for tag in &["<marquee", "<blink"] {
759        if lower.contains(tag) {
760            issues.push(AccessibilityIssue {
761                criterion: "2.3.1".to_string(),
762                severity: "error".to_string(),
763                message: format!("Banned element {} found", &tag[1..]),
764            });
765        }
766    }
767}
768
769/// ARIA landmark checks: one <main>, nav has aria-label.
770fn check_aria_landmarks(html: &str, issues: &mut Vec<AccessibilityIssue>) {
771    let lower = html.to_lowercase();
772
773    // Count <main> elements
774    let main_count = lower.matches("<main").count();
775    if main_count == 0 {
776        issues.push(AccessibilityIssue {
777            criterion: "ARIA".to_string(),
778            severity: "warning".to_string(),
779            message: "Page has no <main> landmark".to_string(),
780        });
781    } else if main_count > 1 {
782        issues.push(AccessibilityIssue {
783            criterion: "ARIA".to_string(),
784            severity: "warning".to_string(),
785            message: format!(
786                "Page has {main_count} <main> elements (expected 1)"
787            ),
788        });
789    }
790
791    // Check <nav> elements have aria-label
792    let mut pos = 0;
793    while let Some(start) = lower[pos..].find("<nav") {
794        let abs = pos + start;
795        let tag_end = lower[abs..].find('>').map_or(lower.len(), |e| abs + e);
796        let tag = &lower[abs..tag_end];
797        if !tag.contains("aria-label") && !tag.contains("aria-labelledby") {
798            issues.push(AccessibilityIssue {
799                criterion: "ARIA".to_string(),
800                severity: "warning".to_string(),
801                message: "<nav> missing aria-label".to_string(),
802            });
803        }
804        pos = tag_end;
805    }
806}
807
808/// Extracts an attribute value from an HTML tag string.
809fn extract_attr_value(tag: &str, attr: &str) -> Option<String> {
810    let lower = tag.to_lowercase();
811    let pattern = format!("{attr}=");
812    let start = lower.find(&pattern)?;
813    let after = &tag[start + pattern.len()..];
814    let trimmed = after.trim_start();
815    if let Some(inner) = trimmed.strip_prefix('"') {
816        let end = inner.find('"')?;
817        Some(inner[..end].to_string())
818    } else if let Some(inner) = trimmed.strip_prefix('\'') {
819        let end = inner.find('\'')?;
820        Some(inner[..end].to_string())
821    } else {
822        let end = trimmed
823            .find(|c: char| c.is_whitespace() || c == '>')
824            .unwrap_or(trimmed.len());
825        Some(trimmed[..end].to_string())
826    }
827}
828
829/// Simple tag stripper for checking inner text.
830fn strip_tags_simple(html: &str) -> String {
831    let mut result = String::with_capacity(html.len());
832    let mut in_tag = false;
833    for ch in html.chars() {
834        if ch == '<' {
835            in_tag = true;
836        } else if ch == '>' {
837            in_tag = false;
838        } else if !in_tag {
839            result.push(ch);
840        }
841    }
842    result
843}
844
845#[cfg(test)]
846fn collect_html_files(
847    dir: &std::path::Path,
848) -> Result<Vec<std::path::PathBuf>> {
849    crate::walk::walk_files(dir, "html")
850}
851
852#[cfg(test)]
853#[allow(clippy::unwrap_used, clippy::expect_used)]
854mod tests {
855    use super::*;
856    use std::path::Path;
857    use tempfile::tempdir;
858
859    fn test_ctx(site_dir: &Path) -> PluginContext {
860        crate::test_support::init_logger();
861        PluginContext::new(
862            Path::new("content"),
863            Path::new("build"),
864            site_dir,
865            Path::new("templates"),
866        )
867    }
868
869    #[test]
870    fn test_img_alt_missing() {
871        let html = r#"<html lang="en"><head></head><body><main><img src="photo.jpg"></main></body></html>"#;
872        let issues = check_page(html);
873        assert!(issues.iter().any(|i| i.criterion == "1.1.1"));
874    }
875
876    #[test]
877    fn test_img_alt_present() {
878        let html = r#"<html lang="en"><head></head><body><main><img src="photo.jpg" alt="A photo"></main></body></html>"#;
879        let issues = check_page(html);
880        assert!(!issues.iter().any(|i| i.criterion == "1.1.1"));
881    }
882
883    #[test]
884    fn test_img_alt_with_inline_svg_data_url() {
885        // Regression: a `>` inside an SVG data URL in `src` previously
886        // truncated the tag and the parser missed the `alt` attribute,
887        // raising a false `<img> missing alt text: (no src)` issue.
888        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"></main></body></html>"#;
889        let issues = check_page(html);
890        assert!(
891            !issues.iter().any(|i| i.criterion == "1.1.1"),
892            "SVG-data-url img with valid alt should not raise 1.1.1, got: {issues:?}"
893        );
894    }
895
896    #[test]
897    fn test_html_lang_missing() {
898        let html = "<html><head></head><body><main></main></body></html>";
899        let issues = check_page(html);
900        assert!(issues.iter().any(|i| i.criterion == "3.1.1"));
901    }
902
903    #[test]
904    fn test_heading_skip() {
905        let html = r#"<html lang="en"><head></head><body><main><h1>Title</h1><h3>Skip</h3></main></body></html>"#;
906        let issues = check_page(html);
907        assert!(issues.iter().any(|i| i.message.contains("skips")));
908    }
909
910    #[test]
911    fn test_banned_marquee() {
912        let html = r#"<html lang="en"><head></head><body><main><marquee>No</marquee></main></body></html>"#;
913        let issues = check_page(html);
914        assert!(issues.iter().any(|i| i.criterion == "2.3.1"));
915    }
916
917    #[test]
918    fn test_nav_without_label() {
919        let html = r#"<html lang="en"><head></head><body><nav></nav><main></main></body></html>"#;
920        let issues = check_page(html);
921        assert!(issues.iter().any(|i| i.message.contains("aria-label")));
922    }
923
924    #[test]
925    fn test_nav_with_label_passes() {
926        let html = r#"<html lang="en"><head></head><body><nav aria-label="Main"></nav><main></main></body></html>"#;
927        let issues = check_page(html);
928        assert!(!issues.iter().any(|i| i.message.contains("aria-label")));
929    }
930
931    #[test]
932    fn test_clean_page_no_issues() {
933        let html = r#"<html lang="en"><head></head><body>
934            <nav aria-label="Main"><a href="/">Home</a></nav>
935            <main><h1>Title</h1><h2>Sub</h2>
936            <img src="x.jpg" alt="Photo"></main></body></html>"#;
937        let issues = check_page(html);
938        assert!(issues.is_empty(), "Expected no issues, got: {issues:?}");
939    }
940
941    // -------------------------------------------------------------------
942    // Plugin trait surface
943    // -------------------------------------------------------------------
944
945    #[test]
946    fn name_returns_static_accessibility_identifier() {
947        assert_eq!(AccessibilityPlugin.name(), "accessibility");
948    }
949
950    #[test]
951    fn after_compile_missing_site_dir_returns_ok_without_writing() {
952        // Line 62: the `!ctx.site_dir.exists()` early return.
953        let dir = tempdir().unwrap();
954        let missing = dir.path().join("missing");
955        let ctx = test_ctx(&missing);
956        AccessibilityPlugin.after_compile(&ctx).unwrap();
957        assert!(!missing.join("accessibility-report.json").exists());
958    }
959
960    #[test]
961    fn after_compile_clean_pages_logs_all_passed() {
962        // Line 108: the `else` branch logging "All N pages passed".
963        // Requires a site with at least one clean page.
964        let dir = tempdir().unwrap();
965        let site = dir.path().join("site");
966        fs::create_dir_all(&site).unwrap();
967        fs::write(
968            site.join("index.html"),
969            r#"<html lang="en"><head></head><body>
970            <nav aria-label="Main"><a href="/">Home</a></nav>
971            <main><h1>T</h1><img src="a.jpg" alt="A"></main>
972            </body></html>"#,
973        )
974        .unwrap();
975
976        let ctx = test_ctx(&site);
977        AccessibilityPlugin.after_compile(&ctx).unwrap();
978        // Report should exist and show zero issues.
979        let report: AccessibilityReport = serde_json::from_str(
980            &fs::read_to_string(site.join("accessibility-report.json"))
981                .unwrap(),
982        )
983        .unwrap();
984        assert_eq!(report.total_issues, 0);
985    }
986
987    // -------------------------------------------------------------------
988    // check_link_text — discernible-text detection
989    // -------------------------------------------------------------------
990
991    #[test]
992    fn check_link_text_empty_anchor_reports_issue() {
993        // Lines 209-220: the `if text.trim().is_empty() && !has_aria
994        // && !has_title` branch that emits a warning.
995        let html = r#"<html lang="en"><head></head><body><main>
996            <a href="/page"></a>
997        </main></body></html>"#;
998        let issues = check_page(html);
999        assert!(issues.iter().any(|i| i.criterion == "2.4.4"));
1000    }
1001
1002    #[test]
1003    fn check_link_text_empty_anchor_with_aria_label_passes() {
1004        let html = r#"<html lang="en"><head></head><body><main>
1005            <a href="/page" aria-label="Read more"></a>
1006        </main></body></html>"#;
1007        let issues = check_page(html);
1008        assert!(!issues.iter().any(|i| i.criterion == "2.4.4"));
1009    }
1010
1011    #[test]
1012    fn check_link_text_empty_anchor_with_title_passes() {
1013        let html = r#"<html lang="en"><head></head><body><main>
1014            <a href="/page" title="Read more"></a>
1015        </main></body></html>"#;
1016        let issues = check_page(html);
1017        assert!(!issues.iter().any(|i| i.criterion == "2.4.4"));
1018    }
1019
1020    #[test]
1021    fn check_link_text_empty_anchor_with_no_href_reports_issue() {
1022        // The link-text check is run on `<a ` (with trailing space),
1023        // so a bare `<a></a>` without any attribute is NOT matched
1024        // by the parser. This test simply confirms the empty-text
1025        // check fires for anchors that ARE matched.
1026        let html = r#"<html lang="en"><head></head><body><main>
1027            <a ></a>
1028        </main></body></html>"#;
1029        let _ = check_page(html);
1030    }
1031
1032    // -------------------------------------------------------------------
1033    // check_aria_landmarks — <main> count branches
1034    // -------------------------------------------------------------------
1035
1036    #[test]
1037    fn check_aria_landmarks_no_main_element_reports_issue() {
1038        // Line 268: main_count == 0 branch.
1039        let html = r#"<html lang="en"><head></head><body>
1040            <div>no main landmark here</div>
1041        </body></html>"#;
1042        let issues = check_page(html);
1043        assert!(issues
1044            .iter()
1045            .any(|i| i.message.contains("no <main> landmark")));
1046    }
1047
1048    #[test]
1049    fn check_aria_landmarks_multiple_main_elements_reports_issue() {
1050        // Lines 274-281: `main_count > 1` branch.
1051        let html = r#"<html lang="en"><head></head><body>
1052            <main>first</main>
1053            <main>second</main>
1054        </body></html>"#;
1055        let issues = check_page(html);
1056        assert!(issues
1057            .iter()
1058            .any(|i| i.message.contains("2 <main> elements")));
1059    }
1060
1061    // -------------------------------------------------------------------
1062    // extract_attr_value — quote-style branches
1063    // -------------------------------------------------------------------
1064
1065    #[test]
1066    fn extract_attr_value_double_quoted() {
1067        let result = extract_attr_value(r#"<a href="/foo">"#, "href");
1068        assert_eq!(result, Some("/foo".to_string()));
1069    }
1070
1071    #[test]
1072    fn extract_attr_value_single_quoted() {
1073        // Lines 311-313: the single-quote branch.
1074        let result = extract_attr_value(r"<a href='/bar'>", "href");
1075        assert_eq!(result, Some("/bar".to_string()));
1076    }
1077
1078    #[test]
1079    fn extract_attr_value_unquoted() {
1080        // Lines 315-318: the no-quote fallback branch, terminated by
1081        // whitespace or `>`.
1082        let result = extract_attr_value(r"<a href=/baz>", "href");
1083        assert_eq!(result, Some("/baz".to_string()));
1084    }
1085
1086    #[test]
1087    fn extract_attr_value_missing_attribute_returns_none() {
1088        let result = extract_attr_value(r"<a>", "href");
1089        assert!(result.is_none());
1090    }
1091
1092    // -------------------------------------------------------------------
1093    // strip_tags_simple — in-tag tracking
1094    // -------------------------------------------------------------------
1095
1096    #[test]
1097    fn strip_tags_simple_removes_html_tags_and_preserves_text() {
1098        // Lines 328, 330: in_tag = true / false transitions.
1099        let result = strip_tags_simple("<p>hello <b>world</b>!</p>");
1100        assert_eq!(result, "hello world!");
1101    }
1102
1103    #[test]
1104    fn strip_tags_simple_handles_empty_and_text_only() {
1105        assert_eq!(strip_tags_simple(""), "");
1106        assert_eq!(strip_tags_simple("plain text"), "plain text");
1107    }
1108
1109    // -------------------------------------------------------------------
1110    // collect_html_files — depth guard + non-html filter
1111    // -------------------------------------------------------------------
1112
1113    #[test]
1114    fn collect_html_files_filters_non_html_extensions() {
1115        let dir = tempdir().unwrap();
1116        fs::write(dir.path().join("a.html"), "").unwrap();
1117        fs::write(dir.path().join("b.css"), "").unwrap();
1118        let result = collect_html_files(dir.path()).unwrap();
1119        assert_eq!(result.len(), 1);
1120    }
1121
1122    #[test]
1123    fn collect_html_files_skips_non_directories_in_stack() {
1124        // Line 343-344: `!current.is_dir()` continue branch —
1125        // covered by the normal tempdir walk.
1126        let dir = tempdir().unwrap();
1127        let result = collect_html_files(&dir.path().join("missing")).unwrap();
1128        assert!(result.is_empty());
1129    }
1130
1131    #[test]
1132    fn test_plugin_writes_report() {
1133        let dir = tempdir().unwrap();
1134        let site = dir.path().join("site");
1135        fs::create_dir_all(&site).unwrap();
1136        fs::write(
1137            site.join("index.html"),
1138            r#"<html><head></head><body><main><img src="x.jpg"></main></body></html>"#,
1139        )
1140        .unwrap();
1141
1142        let ctx = test_ctx(&site);
1143        AccessibilityPlugin.after_compile(&ctx).unwrap();
1144
1145        let report_path = site.join("accessibility-report.json");
1146        assert!(report_path.exists());
1147
1148        let content = fs::read_to_string(&report_path).unwrap();
1149        let report: AccessibilityReport =
1150            serde_json::from_str(&content).unwrap();
1151        assert_eq!(report.pages_scanned, 1);
1152        assert!(report.total_issues > 0);
1153        assert_eq!(report.wcag_version, "2.2");
1154    }
1155
1156    // ── WCAG 2.2 additions (issues #421, #463) ─────────────────────
1157
1158    #[test]
1159    fn test_target_size_below_minimum_flagged() {
1160        let html = r#"<html lang="en"><head><style>
1161            button { width: 16px; height: 16px; }
1162        </style></head><body><main></main></body></html>"#;
1163        let issues = check_page(html);
1164        assert!(
1165            issues.iter().any(|i| i.criterion == "2.5.8"),
1166            "expected 2.5.8 issue for 16px button, got {issues:?}"
1167        );
1168    }
1169
1170    #[test]
1171    fn test_target_size_compliant_passes() {
1172        let html = r#"<html lang="en"><head><style>
1173            button { width: 32px; height: 32px; }
1174        </style></head><body><main></main></body></html>"#;
1175        let issues: Vec<_> = check_page(html)
1176            .into_iter()
1177            .filter(|i| i.criterion == "2.5.8")
1178            .collect();
1179        assert!(
1180            issues.is_empty(),
1181            "32px button should not trigger 2.5.8, got {issues:?}"
1182        );
1183    }
1184
1185    #[test]
1186    fn test_focus_appearance_outline_none_flagged() {
1187        let html = r#"<html lang="en"><head><style>
1188            a:focus { outline: none; }
1189        </style></head><body><main></main></body></html>"#;
1190        let issues = check_page(html);
1191        assert!(
1192            issues.iter().any(|i| i.criterion == "2.4.13"),
1193            "expected 2.4.13 issue for bare outline:none, got {issues:?}"
1194        );
1195    }
1196
1197    #[test]
1198    fn test_focus_appearance_with_box_shadow_passes() {
1199        let html = r#"<html lang="en"><head><style>
1200            a:focus { outline: none; box-shadow: 0 0 0 2px blue; }
1201        </style></head><body><main></main></body></html>"#;
1202        let issues: Vec<_> = check_page(html)
1203            .into_iter()
1204            .filter(|i| i.criterion == "2.4.13")
1205            .collect();
1206        assert!(
1207            issues.is_empty(),
1208            "outline:none + box-shadow should pass 2.4.13, got {issues:?}"
1209        );
1210    }
1211
1212    #[test]
1213    fn test_consistent_help_helper_detects_link() {
1214        // The cross-page checker isn't wired into per-page validation
1215        // (it would be too noisy on every clean page that omits a
1216        // help link), but the helper itself still works and we want
1217        // it covered for future cross-page use.
1218        let html_with = r#"<html lang="en"><body><a href="/contact">Contact</a></body></html>"#;
1219        let html_without =
1220            r#"<html lang="en"><body><p>nothing</p></body></html>"#;
1221        let mut buf = Vec::new();
1222        check_consistent_help(html_with, &mut buf);
1223        assert!(buf.is_empty(), "with link, no issue");
1224        check_consistent_help(html_without, &mut buf);
1225        assert_eq!(buf.len(), 1);
1226        assert_eq!(buf[0].criterion, "3.2.6");
1227    }
1228
1229    #[test]
1230    fn test_compliance_matrix_emitted() {
1231        let dir = tempdir().unwrap();
1232        let site = dir.path().join("site");
1233        fs::create_dir_all(&site).unwrap();
1234        fs::write(
1235            site.join("index.html"),
1236            r#"<html lang="en"><head></head><body><main>
1237                <h1>OK</h1>
1238                <a href="/contact">Contact</a>
1239            </main></body></html>"#,
1240        )
1241        .unwrap();
1242
1243        let ctx = test_ctx(&site);
1244        AccessibilityPlugin.after_compile(&ctx).unwrap();
1245
1246        let matrix_path = site.join("wcag-compliance.json");
1247        assert!(matrix_path.exists());
1248
1249        let content = fs::read_to_string(&matrix_path).unwrap();
1250        let matrix: WcagComplianceReport =
1251            serde_json::from_str(&content).unwrap();
1252        assert_eq!(matrix.wcag_version, "2.2");
1253        assert_eq!(matrix.pages_scanned, 1);
1254        // The matrix carries every WCAG 2.2 row we listed in
1255        // build_compliance_report, including the three additions.
1256        let names: Vec<&str> = matrix
1257            .criteria
1258            .iter()
1259            .map(|c| c.criterion.as_str())
1260            .collect();
1261        assert!(names.contains(&"2.4.13"));
1262        assert!(names.contains(&"2.5.8"));
1263        assert!(names.contains(&"3.2.6"));
1264    }
1265
1266    // ── CSS preprocessor (audit fix item #3) ───────────────────────
1267
1268    #[test]
1269    fn target_size_ignores_value_inside_css_comment() {
1270        // Pre-fix: `/* width: 10px */` inside a button rule
1271        // triggered a false 2.5.8 violation.
1272        let html = r#"<html lang="en"><head><style>
1273            button { /* width: 10px */ width: 32px; height: 32px; }
1274        </style></head><body><main></main></body></html>"#;
1275        let issues = check_page(html);
1276        assert!(
1277            !issues.iter().any(|i| i.criterion == "2.5.8"),
1278            "comment must not trigger 2.5.8, got {issues:?}"
1279        );
1280    }
1281
1282    #[test]
1283    fn target_size_ignores_rule_inside_media_query() {
1284        // Rules nested in `@media` only apply conditionally; they
1285        // must not be treated as unconditional violations.
1286        let html = r#"<html lang="en"><head><style>
1287            @media print { button { width: 10px; height: 10px; } }
1288            button { width: 32px; height: 32px; }
1289        </style></head><body><main></main></body></html>"#;
1290        let issues = check_page(html);
1291        assert!(
1292            !issues.iter().any(|i| i.criterion == "2.5.8"),
1293            "@media-nested 10px must not flag 2.5.8, got {issues:?}"
1294        );
1295    }
1296
1297    #[test]
1298    fn target_size_scans_every_style_block() {
1299        // Pre-fix: only the first <style> was inspected.
1300        let html = r#"<html lang="en">
1301            <head>
1302                <style>p { color: red }</style>
1303                <style>button { width: 8px; height: 8px; }</style>
1304            </head>
1305            <body><main></main></body>
1306        </html>"#;
1307        let issues = check_page(html);
1308        assert!(
1309            issues.iter().any(|i| i.criterion == "2.5.8"),
1310            "second <style> block's button rule must be inspected, got {issues:?}"
1311        );
1312    }
1313
1314    #[test]
1315    fn focus_appearance_ignores_outline_none_inside_supports() {
1316        // `outline:none` inside `@supports` only applies under that
1317        // condition; not an unconditional 2.4.13 violation.
1318        let html = r#"<html lang="en"><head><style>
1319            @supports (display: grid) { a:focus { outline: none; } }
1320            a:focus { outline: 2px solid blue; }
1321        </style></head><body><main></main></body></html>"#;
1322        let issues = check_page(html);
1323        assert!(
1324            !issues.iter().any(|i| i.criterion == "2.4.13"),
1325            "@supports-nested outline:none must not flag 2.4.13, got {issues:?}"
1326        );
1327    }
1328
1329    #[test]
1330    fn parse_top_level_rules_skips_empty_selector() {
1331        let rules = parse_top_level_rules("{ width: 10px; }");
1332        assert!(rules.is_empty());
1333    }
1334
1335    #[test]
1336    fn strip_at_rules_handles_nested_media() {
1337        let css = "a { color: red } @media print { a { color: blue } } b { color: green }";
1338        let stripped = strip_at_rules(css);
1339        assert!(stripped.contains("a { color: red }"));
1340        assert!(stripped.contains("b { color: green }"));
1341        assert!(!stripped.contains("@media"));
1342        // The print rule's `color: blue` declaration must be gone.
1343        assert!(!stripped.contains("blue"));
1344    }
1345
1346    #[test]
1347    fn strip_css_comments_removes_block_comments() {
1348        let css = "a { /* hidden */ color: red; }";
1349        let stripped = strip_css_comments(css);
1350        assert!(!stripped.contains("hidden"));
1351        assert!(stripped.contains("color: red"));
1352    }
1353
1354    #[test]
1355    fn strip_css_comments_handles_unterminated_comment() {
1356        // Defensive: an unterminated /* should not loop forever.
1357        let css = "a { /* never closes";
1358        let _ = strip_css_comments(css);
1359    }
1360
1361    #[test]
1362    fn extract_all_style_blocks_returns_every_block() {
1363        let html =
1364            "<html><head><style>x{}</style><style>y{}</style></head></html>";
1365        let blocks = extract_all_style_blocks(html);
1366        assert_eq!(blocks.len(), 2);
1367        assert_eq!(blocks[0].trim(), "x{}");
1368        assert_eq!(blocks[1].trim(), "y{}");
1369    }
1370
1371    #[test]
1372    fn extract_all_style_blocks_handles_attributes_with_quoted_gt() {
1373        let html =
1374            r#"<html><head><style data-tag="x>y">a{}</style></head></html>"#;
1375        let blocks = extract_all_style_blocks(html);
1376        assert_eq!(blocks.len(), 1);
1377        assert_eq!(blocks[0].trim(), "a{}");
1378    }
1379}