1use crate::css::{
10 extract_all_style_blocks, first_px_value, parse_top_level_rules,
11 preprocess_css, selector_targets_interactive,
12};
13use crate::html::{
14 extract_attr_value, find_tag_end, img_missing_alt, strip_tags_simple,
15};
16use crate::types::AccessibilityIssue;
17
18pub(crate) fn check_img_alt(html: &str, issues: &mut Vec<AccessibilityIssue>) {
20 let lower = html.to_ascii_lowercase();
27 let mut pos = 0;
28 while let Some(start) = lower[pos..].find("<img") {
29 let abs = pos + start;
30 let tag_end = find_tag_end(&lower, abs);
31 let tag = &lower[abs..tag_end];
32
33 if img_missing_alt(tag) {
34 let src = extract_attr_value(&html[abs..tag_end], "src")
35 .unwrap_or_default();
36 issues.push(AccessibilityIssue {
37 criterion: "1.1.1".to_string(),
38 severity: "error".to_string(),
39 message: format!(
40 "<img> missing alt text: {}",
41 if src.is_empty() { "(no src)" } else { &src }
42 ),
43 });
44 }
45
46 pos = tag_end;
47 }
48}
49
50pub(crate) fn check_html_lang(
52 html: &str,
53 issues: &mut Vec<AccessibilityIssue>,
54) {
55 let lower = html.to_ascii_lowercase();
56 if let Some(start) = lower.find("<html") {
57 let tag_end =
58 lower[start..].find('>').map_or(lower.len(), |e| start + e);
59 let tag = &lower[start..tag_end];
60 if !tag.contains("lang=") {
61 issues.push(AccessibilityIssue {
62 criterion: "3.1.1".to_string(),
63 severity: "error".to_string(),
64 message: "<html> missing lang attribute".to_string(),
65 });
66 }
67 }
68}
69
70pub(crate) fn check_link_text(
72 html: &str,
73 issues: &mut Vec<AccessibilityIssue>,
74) {
75 let lower = html.to_ascii_lowercase();
76 let mut pos = 0;
77 while let Some(start) = lower[pos..].find("<a ") {
78 let abs = pos + start;
79 let close = lower[abs..].find("</a>").unwrap_or(lower.len() - abs);
80 let full = &lower[abs..abs + close];
81
82 if let Some(gt) = full.find('>') {
84 let inner = &full[gt + 1..];
85 let text = strip_tags_simple(inner);
86 let has_aria = full.contains("aria-label=");
87 let has_title = full.contains("title=");
88
89 if text.trim().is_empty() && !has_aria && !has_title {
90 let href = extract_attr_value(&html[abs..abs + close], "href")
91 .unwrap_or_default();
92 issues.push(AccessibilityIssue {
93 criterion: "2.4.4".to_string(),
94 severity: "warning".to_string(),
95 message: format!(
96 "<a> has no discernible text: href={}",
97 if href.is_empty() { "(none)" } else { &href }
98 ),
99 });
100 }
101 }
102
103 pos = abs + close.max(1);
104 }
105}
106
107pub(crate) fn check_heading_hierarchy(
109 html: &str,
110 issues: &mut Vec<AccessibilityIssue>,
111) {
112 let lower = html.to_ascii_lowercase();
113 let mut last_level: u8 = 0;
114
115 for level in 1..=6u8 {
116 let tag = format!("<h{level}");
117 if lower.contains(&tag) {
118 if last_level > 0 && level > last_level + 1 {
119 issues.push(AccessibilityIssue {
120 criterion: "1.3.1".to_string(),
121 severity: "warning".to_string(),
122 message: format!(
123 "Heading hierarchy skips from h{last_level} to h{level}"
124 ),
125 });
126 }
127 last_level = level;
128 }
129 }
130}
131
132pub(crate) fn check_banned_elements(
134 html: &str,
135 issues: &mut Vec<AccessibilityIssue>,
136) {
137 let lower = html.to_ascii_lowercase();
138 for tag in &["<marquee", "<blink"] {
139 if lower.contains(tag) {
140 issues.push(AccessibilityIssue {
141 criterion: "2.3.1".to_string(),
142 severity: "error".to_string(),
143 message: format!("Banned element {} found", &tag[1..]),
144 });
145 }
146 }
147}
148
149pub(crate) fn strip_non_content_blocks(html: &str) -> String {
153 let mut clean = html.to_ascii_lowercase();
154
155 while let Some(start) = clean.find("<!--") {
157 if let Some(end) = clean[start..].find("-->") {
158 clean.replace_range(start..start + end + 3, "");
159 } else {
160 break;
161 }
162 }
163
164 while let Some(start) = clean.find("<style") {
166 if let Some(end) = clean[start..].find("</style>") {
167 clean.replace_range(start..start + end + 8, "");
168 } else {
169 break;
170 }
171 }
172
173 while let Some(start) = clean.find("<script") {
175 if let Some(end) = clean[start..].find("</script>") {
176 clean.replace_range(start..start + end + 9, "");
177 } else {
178 break;
179 }
180 }
181
182 clean
183}
184
185pub(crate) fn check_aria_landmarks(
187 html: &str,
188 issues: &mut Vec<AccessibilityIssue>,
189) {
190 let clean = strip_non_content_blocks(html);
191
192 let main_count = clean.matches("<main").count();
194 if main_count == 0 {
195 issues.push(AccessibilityIssue {
196 criterion: "ARIA".to_string(),
197 severity: "warning".to_string(),
198 message: "Page has no <main> landmark".to_string(),
199 });
200 } else if main_count > 1 {
201 issues.push(AccessibilityIssue {
202 criterion: "ARIA".to_string(),
203 severity: "warning".to_string(),
204 message: format!(
205 "Page has {main_count} <main> elements (expected 1)"
206 ),
207 });
208 }
209
210 let mut pos = 0;
212 while let Some(start) = clean[pos..].find("<nav") {
213 let abs = pos + start;
214 let tag_end = clean[abs..].find('>').map_or(clean.len(), |e| abs + e);
215 let tag = &clean[abs..tag_end];
216 if !tag.contains("aria-label") && !tag.contains("aria-labelledby") {
217 issues.push(AccessibilityIssue {
218 criterion: "ARIA".to_string(),
219 severity: "warning".to_string(),
220 message: "<nav> missing aria-label".to_string(),
221 });
222 }
223 pos = tag_end;
224 }
225}
226
227pub(crate) fn check_target_size(
236 html: &str,
237 issues: &mut Vec<AccessibilityIssue>,
238) {
239 for css in extract_all_style_blocks(html) {
240 let cleaned = preprocess_css(&css);
241 for (selector, body) in parse_top_level_rules(&cleaned) {
242 if !selector_targets_interactive(&selector) {
243 continue;
244 }
245 for prop in ["width", "height"] {
246 if let Some(px) = first_px_value(&body, prop) {
247 if px > 0 && px < 24 {
248 issues.push(AccessibilityIssue {
249 criterion: "2.5.8".to_string(),
250 severity: "warning".to_string(),
251 message: format!(
252 "Target size {prop}={px}px on `{selector}` \
253 is below the 24×24 minimum (WCAG 2.2 AA)"
254 ),
255 });
256 }
257 }
258 }
259 }
260 }
261}
262
263pub(crate) fn check_focus_appearance(
269 html: &str,
270 issues: &mut Vec<AccessibilityIssue>,
271) {
272 for css in extract_all_style_blocks(html) {
273 let cleaned = preprocess_css(&css);
274 for (selector, body) in parse_top_level_rules(&cleaned) {
275 if !selector.contains(":focus") {
276 continue;
277 }
278 let kills_outline = body.contains("outline:none")
279 || body.contains("outline: none")
280 || body.contains("outline:0")
281 || body.contains("outline: 0");
282 let has_replacement = body.contains("outline-style")
283 || body.contains("outline-color")
284 || body.contains("box-shadow")
285 || body.contains("border:");
286
287 if kills_outline && !has_replacement {
288 issues.push(AccessibilityIssue {
289 criterion: "2.4.13".to_string(),
290 severity: "warning".to_string(),
291 message: "`:focus { outline: none }` without a \
292 compensating outline-style/box-shadow/border \
293 (WCAG 2.2 AAA — Focus Appearance)"
294 .to_string(),
295 });
296 }
297 }
298 }
299}
300
301pub fn check_consistent_help(html: &str, issues: &mut Vec<AccessibilityIssue>) {
315 let lower = html.to_ascii_lowercase();
316 let has_help_link = lower.contains(">help<")
317 || lower.contains(">contact<")
318 || lower.contains(">support<")
319 || lower.contains(">faq<")
320 || lower.contains("aria-label=\"help\"")
321 || lower.contains("aria-label=\"contact\"")
322 || lower.contains("aria-label=\"support\"");
323
324 if !has_help_link {
325 issues.push(AccessibilityIssue {
326 criterion: "3.2.6".to_string(),
327 severity: "info".to_string(),
328 message: "No detectable help/contact/support link on page; \
329 verify that the site provides a consistent help \
330 mechanism across pages (WCAG 2.2 A — Consistent \
331 Help)"
332 .to_string(),
333 });
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 #[test]
342 fn check_html_lang_skips_fragment_without_html_tag() {
343 let mut issues = Vec::new();
344 check_html_lang("<p>fragment only</p>", &mut issues);
345 assert!(issues.is_empty());
346 }
347
348 #[test]
349 fn check_link_text_tolerates_unterminated_anchor() {
350 let mut issues = Vec::new();
353 check_link_text("<a href=/x", &mut issues);
354 assert!(issues.is_empty());
355 }
356
357 #[test]
358 fn strip_non_content_blocks_removes_html_comments() {
359 let out = strip_non_content_blocks("<body><!-- <main> --></body>");
360 assert!(!out.contains("<main>"));
361 assert!(out.contains("<body>"));
362 }
363
364 #[test]
365 fn strip_non_content_blocks_tolerates_unterminated_comment() {
366 let out = strip_non_content_blocks("<body><!-- no close");
367 assert!(out.contains("<!--"), "unterminated comment kept: {out}");
368 }
369
370 #[test]
371 fn strip_non_content_blocks_tolerates_unterminated_style() {
372 let out = strip_non_content_blocks("<body><style>a{}");
373 assert!(out.contains("<style>"), "unterminated style kept: {out}");
374 }
375
376 #[test]
377 fn strip_non_content_blocks_tolerates_unterminated_script() {
378 let out = strip_non_content_blocks("<body><script>let x=1;");
379 assert!(out.contains("<script>"), "unterminated script kept: {out}");
380 }
381
382 #[test]
383 fn test_consistent_help_helper_detects_link() {
384 let html_with = r#"<html lang="en"><body><a href="/contact">Contact</a></body></html>"#;
389 let html_without =
390 r#"<html lang="en"><body><p>nothing</p></body></html>"#;
391 let mut buf = Vec::new();
392 check_consistent_help(html_with, &mut buf);
393 assert!(buf.is_empty(), "with link, no issue");
394 check_consistent_help(html_without, &mut buf);
395 assert_eq!(buf.len(), 1);
396 assert_eq!(buf[0].criterion, "3.2.6");
397 }
398}