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