postcrate_core/rendering/
a11y.rs1use 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 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 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 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 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 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 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; }
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}