1#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
2#![forbid(unsafe_code)]
3#![allow(clippy::redundant_pub_crate)]
12mod css;
53mod html;
54
55pub use html::find_tag_end;
64pub use html::img_missing_alt;
70mod report;
71mod rules;
72mod types;
73
74pub use report::build_compliance_report;
75pub use rules::check_consistent_help;
76pub use types::{
77 AccessibilityIssue, AccessibilityReport, CriterionEntry, CriterionStatus,
78 PageReport, WcagComplianceReport,
79};
80
81pub fn check_page(html: &str) -> Vec<AccessibilityIssue> {
89 let mut issues = Vec::new();
90
91 rules::check_img_alt(html, &mut issues);
93
94 rules::check_html_lang(html, &mut issues);
96
97 rules::check_link_text(html, &mut issues);
99
100 rules::check_heading_hierarchy(html, &mut issues);
102
103 rules::check_banned_elements(html, &mut issues);
105
106 rules::check_aria_landmarks(html, &mut issues);
108
109 rules::check_target_size(html, &mut issues);
114
115 rules::check_focus_appearance(html, &mut issues);
118
119 issues
127}
128
129#[cfg(test)]
130mod tests {
131
132 #[test]
147 fn check_page_survives_non_ascii_that_grows_when_lowercased() {
148 let html = "<!\u{130}html>\n<img src=\"a.png\" alt>";
151 let _ = check_page(html);
152
153 for probe in [
155 "\u{130}<img src=\"x.png\">",
156 "<img alt=\"\u{130}\" src=\"y.png\">",
157 "<p>\u{130}\u{130}\u{130}</p><img>",
158 "<img src=\"\u{130}.png\">",
159 ] {
160 let _ = check_page(probe);
161 }
162 }
163
164 #[test]
166 fn check_page_still_matches_uppercase_tags() {
167 let issues = check_page("<IMG SRC=\"a.png\">");
168 assert!(
169 issues.iter().any(|i| i.criterion == "1.1.1"),
170 "uppercase <IMG> without alt was not detected: {issues:?}"
171 );
172 }
173
174 use super::*;
175
176 #[test]
177 fn test_img_alt_missing() {
178 let html = r#"<html lang="en"><head></head><body><main><img src="photo.jpg"></main></body></html>"#;
179 let issues = check_page(html);
180 assert!(issues.iter().any(|i| i.criterion == "1.1.1"));
181 }
182
183 #[test]
184 fn test_img_alt_present() {
185 let html = r#"<html lang="en"><head></head><body><main><img src="photo.jpg" alt="A photo"><marquee>seed</marquee></main></body></html>"#;
186 let issues = check_page(html);
187 assert!(!issues.iter().any(|i| i.criterion == "1.1.1"));
190 }
191
192 #[test]
193 fn test_img_alt_with_inline_svg_data_url() {
194 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"><marquee>seed</marquee></main></body></html>"#;
198 let issues = check_page(html);
199 assert!(
200 !issues.iter().any(|i| i.criterion == "1.1.1"),
201 "SVG-data-url img with valid alt should not raise 1.1.1, got: {issues:?}"
202 );
203 }
204
205 #[test]
206 fn test_html_lang_missing() {
207 let html = "<html><head></head><body><main></main></body></html>";
208 let issues = check_page(html);
209 assert!(issues.iter().any(|i| i.criterion == "3.1.1"));
210 }
211
212 #[test]
213 fn test_heading_skip() {
214 let html = r#"<html lang="en"><head></head><body><main><h1>Title</h1><h3>Skip</h3></main></body></html>"#;
215 let issues = check_page(html);
216 assert!(issues.iter().any(|i| i.message.contains("skips")));
217 }
218
219 #[test]
220 fn test_banned_marquee() {
221 let html = r#"<html lang="en"><head></head><body><main><marquee>No</marquee></main></body></html>"#;
222 let issues = check_page(html);
223 assert!(issues.iter().any(|i| i.criterion == "2.3.1"));
224 }
225
226 #[test]
227 fn test_nav_without_label() {
228 let html = r#"<html lang="en"><head></head><body><nav></nav><main></main></body></html>"#;
229 let issues = check_page(html);
230 assert!(issues.iter().any(|i| i.message.contains("aria-label")));
231 }
232
233 #[test]
234 fn test_nav_with_label_passes() {
235 let html = r#"<html lang="en"><head></head><body><nav aria-label="Main"></nav><main><marquee>seed</marquee></main></body></html>"#;
236 let issues = check_page(html);
237 assert!(!issues.iter().any(|i| i.message.contains("aria-label")));
238 }
239
240 #[test]
241 fn test_clean_page_no_issues() {
242 let html = r#"<html lang="en"><head></head><body>
243 <nav aria-label="Main"><a href="/">Home</a></nav>
244 <main><h1>Title</h1><h2>Sub</h2>
245 <img src="x.jpg" alt="Photo"></main></body></html>"#;
246 let issues = check_page(html);
247 assert!(issues.is_empty(), "Expected no issues, got: {issues:?}");
248 }
249
250 #[test]
255 fn check_link_text_empty_anchor_reports_issue() {
256 let html = r#"<html lang="en"><head></head><body><main>
257 <a href="/page"></a>
258 </main></body></html>"#;
259 let issues = check_page(html);
260 assert!(issues.iter().any(|i| i.criterion == "2.4.4"));
261 }
262
263 #[test]
264 fn check_link_text_empty_anchor_with_aria_label_passes() {
265 let html = r#"<html lang="en"><head></head><body><main>
266 <a href="/page" aria-label="Read more"></a>
267 <marquee>seed</marquee>
268 </main></body></html>"#;
269 let issues = check_page(html);
270 assert!(!issues.iter().any(|i| i.criterion == "2.4.4"));
271 }
272
273 #[test]
274 fn check_link_text_empty_anchor_with_title_passes() {
275 let html = r#"<html lang="en"><head></head><body><main>
276 <a href="/page" title="Read more"></a>
277 <marquee>seed</marquee>
278 </main></body></html>"#;
279 let issues = check_page(html);
280 assert!(!issues.iter().any(|i| i.criterion == "2.4.4"));
281 }
282
283 #[test]
284 fn check_link_text_empty_anchor_with_no_href_reports_issue() {
285 let html = r#"<html lang="en"><head></head><body><main>
290 <a ></a>
291 </main></body></html>"#;
292 let _ = check_page(html);
293 }
294
295 #[test]
300 fn check_aria_landmarks_no_main_element_reports_issue() {
301 let html = r#"<html lang="en"><head></head><body>
302 <div>no main landmark here</div>
303 </body></html>"#;
304 let issues = check_page(html);
305 assert!(issues
306 .iter()
307 .any(|i| i.message.contains("no <main> landmark")));
308 }
309
310 #[test]
311 fn check_aria_landmarks_multiple_main_elements_reports_issue() {
312 let html = r#"<html lang="en"><head></head><body>
313 <main>first</main>
314 <main>second</main>
315 </body></html>"#;
316 let issues = check_page(html);
317 assert!(issues
318 .iter()
319 .any(|i| i.message.contains("2 <main> elements")));
320 }
321
322 #[test]
325 fn test_target_size_below_minimum_flagged() {
326 let html = r#"<html lang="en"><head><style>
327 button { width: 16px; height: 16px; }
328 </style></head><body><main></main></body></html>"#;
329 let issues = check_page(html);
330 assert!(
331 issues.iter().any(|i| i.criterion == "2.5.8"),
332 "expected 2.5.8 issue for 16px button, got {issues:?}"
333 );
334 }
335
336 #[test]
337 fn test_target_size_compliant_passes() {
338 let html = r#"<html lang="en"><head><style>
339 button { width: 32px; height: 32px; }
340 </style></head><body><main><marquee>seed</marquee></main></body></html>"#;
341 let issues: Vec<_> = check_page(html)
342 .into_iter()
343 .filter(|i| i.criterion == "2.5.8")
344 .collect();
345 assert!(
346 issues.is_empty(),
347 "32px button should not trigger 2.5.8, got {issues:?}"
348 );
349 }
350
351 #[test]
352 fn test_focus_appearance_outline_none_flagged() {
353 let html = r#"<html lang="en"><head><style>
354 a:focus { outline: none; }
355 </style></head><body><main></main></body></html>"#;
356 let issues = check_page(html);
357 assert!(
358 issues.iter().any(|i| i.criterion == "2.4.13"),
359 "expected 2.4.13 issue for bare outline:none, got {issues:?}"
360 );
361 }
362
363 #[test]
364 fn test_focus_appearance_with_box_shadow_passes() {
365 let html = r#"<html lang="en"><head><style>
366 a:focus { outline: none; box-shadow: 0 0 0 2px blue; }
367 </style></head><body><main><marquee>seed</marquee></main></body></html>"#;
368 let issues: Vec<_> = check_page(html)
369 .into_iter()
370 .filter(|i| i.criterion == "2.4.13")
371 .collect();
372 assert!(
373 issues.is_empty(),
374 "outline:none + box-shadow should pass 2.4.13, got {issues:?}"
375 );
376 }
377
378 #[test]
381 fn target_size_ignores_value_inside_css_comment() {
382 let html = r#"<html lang="en"><head><style>
385 button { /* width: 10px */ width: 32px; height: 32px; }
386 </style></head><body><main><marquee>seed</marquee></main></body></html>"#;
387 let issues = check_page(html);
388 assert!(
389 !issues.iter().any(|i| i.criterion == "2.5.8"),
390 "comment must not trigger 2.5.8, got {issues:?}"
391 );
392 }
393
394 #[test]
395 fn target_size_ignores_rule_inside_media_query() {
396 let html = r#"<html lang="en"><head><style>
399 @media print { button { width: 10px; height: 10px; } }
400 button { width: 32px; height: 32px; }
401 </style></head><body><main><marquee>seed</marquee></main></body></html>"#;
402 let issues = check_page(html);
403 assert!(
404 !issues.iter().any(|i| i.criterion == "2.5.8"),
405 "@media-nested 10px must not flag 2.5.8, got {issues:?}"
406 );
407 }
408
409 #[test]
410 fn target_size_scans_every_style_block() {
411 let html = r#"<html lang="en">
413 <head>
414 <style>p { color: red }</style>
415 <style>button { width: 8px; height: 8px; }</style>
416 </head>
417 <body><main></main></body>
418 </html>"#;
419 let issues = check_page(html);
420 assert!(
421 issues.iter().any(|i| i.criterion == "2.5.8"),
422 "second <style> block's button rule must be inspected, got {issues:?}"
423 );
424 }
425
426 #[test]
427 fn focus_appearance_ignores_outline_none_inside_supports() {
428 let html = r#"<html lang="en"><head><style>
431 @supports (display: grid) { a:focus { outline: none; } }
432 a:focus { outline: 2px solid blue; }
433 </style></head><body><main><marquee>seed</marquee></main></body></html>"#;
434 let issues = check_page(html);
435 assert!(
436 !issues.iter().any(|i| i.criterion == "2.4.13"),
437 "@supports-nested outline:none must not flag 2.4.13, got {issues:?}"
438 );
439 }
440
441 #[test]
444 fn empty_alt_without_decorative_role_is_flagged() {
445 let html = r#"<html lang="en"><body><main><img src="x.png" alt=""></main></body></html>"#;
446 let issues = check_page(html);
447 assert!(
448 issues.iter().any(|i| i.criterion == "1.1.1"),
449 "empty alt without decorative role must flag 1.1.1: {issues:?}"
450 );
451 }
452
453 #[test]
454 fn empty_alt_with_decorative_role_passes() {
455 let html = r#"<html lang="en"><body><main><img src="x.png" alt="" role="presentation"><marquee>seed</marquee></main></body></html>"#;
456 let issues = check_page(html);
457 assert!(
458 !issues.iter().any(|i| i.criterion == "1.1.1"),
459 "decorative empty-alt image must not flag 1.1.1: {issues:?}"
460 );
461 }
462
463 #[test]
464 fn empty_alt_with_aria_hidden_passes() {
465 let html = "<html lang=\"en\"><body><main>\
468 <img class=\"brand-mark\" src=\"/assets/logo.svg\"\n \
469 width=\"28\" height=\"28\" alt=\"\" aria-hidden=\"true\" />\
470 </main></body></html>";
471 let issues = check_page(html);
472 assert!(
473 !issues.iter().any(|i| i.criterion == "1.1.1"),
474 "alt=\"\" aria-hidden=\"true\" must not flag 1.1.1: {issues:?}"
475 );
476 }
477
478 #[test]
479 fn aria_hidden_without_alt_is_still_flagged() {
480 let html = r#"<html lang="en"><body><main><img src="x.png" aria-hidden="true"></main></body></html>"#;
481 let issues = check_page(html);
482 assert!(
483 issues.iter().any(|i| i.criterion == "1.1.1"),
484 "an <img> with no alt attribute must flag 1.1.1: {issues:?}"
485 );
486 }
487
488 #[test]
489 fn unquoted_alt_passes() {
490 let html = r#"<html lang="en"><body><main><img src=x.png alt=photo></main></body></html>"#;
491 let issues = check_page(html);
492 assert!(
493 !issues.iter().any(|i| i.criterion == "1.1.1"),
494 "unquoted alt=photo is alt text: {issues:?}"
495 );
496 }
497
498 #[test]
499 fn missing_alt_and_missing_src_reports_no_src_placeholder() {
500 let html = r#"<html lang="en"><body><main><img></main></body></html>"#;
501 let issues = check_page(html);
502 let issue = issues
503 .iter()
504 .find(|i| i.criterion == "1.1.1")
505 .expect("img without alt must be flagged");
506 assert!(
507 issue.message.contains("(no src)"),
508 "missing src should use the placeholder: {}",
509 issue.message
510 );
511 }
512}