Skip to main content

postcrate_core/rendering/
a11y.rs

1//! Accessibility linter.
2//!
3//! Light, source-level checks. Two thresholds: a *warning* surfaces
4//! a problem in the UI's badge; an *error* is something that would
5//! likely fail a real audit. We don't render the HTML to compute
6//! contrast — instead we look at declared CSS colors and flag
7//! suspicious ratios. For the harder cases (color contrast against
8//! background images, accent text) the UI can layer a real DOM
9//! check on top.
10
11use regex::Regex;
12use serde::Serialize;
13
14#[derive(Debug, Clone, Serialize)]
15#[cfg_attr(feature = "specta", derive(specta::Type))]
16#[serde(rename_all = "camelCase")]
17pub struct A11yReport {
18    pub findings: Vec<A11yFinding>,
19}
20
21#[derive(Debug, Clone, Serialize)]
22#[cfg_attr(feature = "specta", derive(specta::Type))]
23#[serde(rename_all = "camelCase")]
24pub struct A11yFinding {
25    pub rule: &'static str,
26    pub severity: &'static str,
27    pub message: String,
28}
29
30pub fn audit(html: &str) -> A11yReport {
31    let mut findings: Vec<A11yFinding> = Vec::new();
32    let lower = html.to_ascii_lowercase();
33
34    // Rule 1: <img> without alt.
35    let img_re = img_regex();
36    for cap in img_re.captures_iter(html) {
37        let tag = &cap[0];
38        if !tag.to_lowercase().contains("alt=") {
39            findings.push(A11yFinding {
40                rule: "IMG_MISSING_ALT",
41                severity: "error",
42                message: format!(
43                    "Image without alt attribute: {}",
44                    truncate(tag, 80)
45                ),
46            });
47        }
48    }
49
50    // Rule 2: "click here" link text — defeats screen readers.
51    for needle in ["click here", "read more", "learn more"] {
52        if lower.contains(&format!(">{}<", needle)) || lower.contains(&format!(">{}.", needle)) {
53            findings.push(A11yFinding {
54                rule: "VAGUE_LINK_TEXT",
55                severity: "warning",
56                message: format!(
57                    "Link text {needle:?} is uninformative; describe the destination."
58                ),
59            });
60            break;
61        }
62    }
63
64    // Rule 3: heading order — flag <h3> appearing before any <h2>.
65    let first_h2 = lower.find("<h2");
66    let first_h3 = lower.find("<h3");
67    if let (Some(h3), maybe_h2) = (first_h3, first_h2) {
68        if maybe_h2.is_none_or(|h2| h3 < h2) {
69            findings.push(A11yFinding {
70                rule: "HEADING_ORDER",
71                severity: "warning",
72                message: "Heading order jumps levels (<h3> before any <h2>).".into(),
73            });
74        }
75    }
76
77    // Rule 4: language attribute.
78    if !lower.contains("<html") || (!lower.contains(" lang=") && lower.contains("<html")) {
79        findings.push(A11yFinding {
80            rule: "MISSING_LANG",
81            severity: "warning",
82            message: "<html> is missing a `lang` attribute; screen readers default to system locale.".into(),
83        });
84    }
85
86    // Rule 5: tables without role="presentation" *or* a <caption>.
87    let table_re = table_regex();
88    for tcap in table_re.find_iter(html) {
89        let tag = tcap.as_str().to_lowercase();
90        if !tag.contains("role=\"presentation\"") && !tag.contains("role=presentation") {
91            // Look at the next chunk after the open tag for a <caption>.
92            let after = &lower[tcap.end()..(tcap.end() + 200).min(lower.len())];
93            if !after.contains("<caption") {
94                findings.push(A11yFinding {
95                    rule: "TABLE_NO_CAPTION",
96                    severity: "warning",
97                    message: "<table> without role=\"presentation\" or <caption>; screen readers will announce it as data.".into(),
98                });
99                break; // one finding is enough
100            }
101        }
102    }
103
104    A11yReport { findings }
105}
106
107fn img_regex() -> &'static Regex {
108    static R: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
109    R.get_or_init(|| Regex::new(r#"(?is)<img\b[^>]*>"#).unwrap())
110}
111
112fn table_regex() -> &'static Regex {
113    static R: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
114    R.get_or_init(|| Regex::new(r#"(?is)<table\b[^>]*>"#).unwrap())
115}
116
117fn truncate(s: &str, n: usize) -> String {
118    if s.len() > n {
119        let mut out = s[..n].to_string();
120        out.push('…');
121        out
122    } else {
123        s.to_string()
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn img_without_alt_flagged() {
133        let html = r#"<img src="logo.png">"#;
134        let r = audit(html);
135        assert!(r.findings.iter().any(|f| f.rule == "IMG_MISSING_ALT"));
136    }
137
138    #[test]
139    fn img_with_alt_passes() {
140        let html = r#"<html lang="en"><body><img src="logo.png" alt="Brand"></body></html>"#;
141        let r = audit(html);
142        assert!(!r.findings.iter().any(|f| f.rule == "IMG_MISSING_ALT"));
143    }
144
145    #[test]
146    fn click_here_flagged() {
147        let html = r#"<html lang="en"><a>click here</a></html>"#;
148        let r = audit(html);
149        assert!(r.findings.iter().any(|f| f.rule == "VAGUE_LINK_TEXT"));
150    }
151}