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;
64mod report;
65mod rules;
66mod types;
67
68pub use report::build_compliance_report;
69pub use rules::check_consistent_help;
70pub use types::{
71 AccessibilityIssue, AccessibilityReport, CriterionEntry, CriterionStatus,
72 PageReport, WcagComplianceReport,
73};
74
75pub fn check_page(html: &str) -> Vec<AccessibilityIssue> {
83 let mut issues = Vec::new();
84
85 rules::check_img_alt(html, &mut issues);
87
88 rules::check_html_lang(html, &mut issues);
90
91 rules::check_link_text(html, &mut issues);
93
94 rules::check_heading_hierarchy(html, &mut issues);
96
97 rules::check_banned_elements(html, &mut issues);
99
100 rules::check_aria_landmarks(html, &mut issues);
102
103 rules::check_target_size(html, &mut issues);
108
109 rules::check_focus_appearance(html, &mut issues);
112
113 issues
121}
122
123#[cfg(test)]
124mod tests {
125
126 #[test]
141 fn check_page_survives_non_ascii_that_grows_when_lowercased() {
142 let html = "<!\u{130}html>\n<img src=\"a.png\" alt>";
145 let _ = check_page(html);
146
147 for probe in [
149 "\u{130}<img src=\"x.png\">",
150 "<img alt=\"\u{130}\" src=\"y.png\">",
151 "<p>\u{130}\u{130}\u{130}</p><img>",
152 "<img src=\"\u{130}.png\">",
153 ] {
154 let _ = check_page(probe);
155 }
156 }
157
158 #[test]
160 fn check_page_still_matches_uppercase_tags() {
161 let issues = check_page("<IMG SRC=\"a.png\">");
162 assert!(
163 issues.iter().any(|i| i.criterion == "1.1.1"),
164 "uppercase <IMG> without alt was not detected: {issues:?}"
165 );
166 }
167
168 use super::*;
169
170 #[test]
171 fn test_img_alt_missing() {
172 let html = r#"<html lang="en"><head></head><body><main><img src="photo.jpg"></main></body></html>"#;
173 let issues = check_page(html);
174 assert!(issues.iter().any(|i| i.criterion == "1.1.1"));
175 }
176
177 #[test]
178 fn test_img_alt_present() {
179 let html = r#"<html lang="en"><head></head><body><main><img src="photo.jpg" alt="A photo"><marquee>seed</marquee></main></body></html>"#;
180 let issues = check_page(html);
181 assert!(!issues.iter().any(|i| i.criterion == "1.1.1"));
184 }
185
186 #[test]
187 fn test_img_alt_with_inline_svg_data_url() {
188 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>"#;
192 let issues = check_page(html);
193 assert!(
194 !issues.iter().any(|i| i.criterion == "1.1.1"),
195 "SVG-data-url img with valid alt should not raise 1.1.1, got: {issues:?}"
196 );
197 }
198
199 #[test]
200 fn test_html_lang_missing() {
201 let html = "<html><head></head><body><main></main></body></html>";
202 let issues = check_page(html);
203 assert!(issues.iter().any(|i| i.criterion == "3.1.1"));
204 }
205
206 #[test]
207 fn test_heading_skip() {
208 let html = r#"<html lang="en"><head></head><body><main><h1>Title</h1><h3>Skip</h3></main></body></html>"#;
209 let issues = check_page(html);
210 assert!(issues.iter().any(|i| i.message.contains("skips")));
211 }
212
213 #[test]
214 fn test_banned_marquee() {
215 let html = r#"<html lang="en"><head></head><body><main><marquee>No</marquee></main></body></html>"#;
216 let issues = check_page(html);
217 assert!(issues.iter().any(|i| i.criterion == "2.3.1"));
218 }
219
220 #[test]
221 fn test_nav_without_label() {
222 let html = r#"<html lang="en"><head></head><body><nav></nav><main></main></body></html>"#;
223 let issues = check_page(html);
224 assert!(issues.iter().any(|i| i.message.contains("aria-label")));
225 }
226
227 #[test]
228 fn test_nav_with_label_passes() {
229 let html = r#"<html lang="en"><head></head><body><nav aria-label="Main"></nav><main><marquee>seed</marquee></main></body></html>"#;
230 let issues = check_page(html);
231 assert!(!issues.iter().any(|i| i.message.contains("aria-label")));
232 }
233
234 #[test]
235 fn test_clean_page_no_issues() {
236 let html = r#"<html lang="en"><head></head><body>
237 <nav aria-label="Main"><a href="/">Home</a></nav>
238 <main><h1>Title</h1><h2>Sub</h2>
239 <img src="x.jpg" alt="Photo"></main></body></html>"#;
240 let issues = check_page(html);
241 assert!(issues.is_empty(), "Expected no issues, got: {issues:?}");
242 }
243
244 #[test]
249 fn check_link_text_empty_anchor_reports_issue() {
250 let html = r#"<html lang="en"><head></head><body><main>
251 <a href="/page"></a>
252 </main></body></html>"#;
253 let issues = check_page(html);
254 assert!(issues.iter().any(|i| i.criterion == "2.4.4"));
255 }
256
257 #[test]
258 fn check_link_text_empty_anchor_with_aria_label_passes() {
259 let html = r#"<html lang="en"><head></head><body><main>
260 <a href="/page" aria-label="Read more"></a>
261 <marquee>seed</marquee>
262 </main></body></html>"#;
263 let issues = check_page(html);
264 assert!(!issues.iter().any(|i| i.criterion == "2.4.4"));
265 }
266
267 #[test]
268 fn check_link_text_empty_anchor_with_title_passes() {
269 let html = r#"<html lang="en"><head></head><body><main>
270 <a href="/page" title="Read more"></a>
271 <marquee>seed</marquee>
272 </main></body></html>"#;
273 let issues = check_page(html);
274 assert!(!issues.iter().any(|i| i.criterion == "2.4.4"));
275 }
276
277 #[test]
278 fn check_link_text_empty_anchor_with_no_href_reports_issue() {
279 let html = r#"<html lang="en"><head></head><body><main>
284 <a ></a>
285 </main></body></html>"#;
286 let _ = check_page(html);
287 }
288
289 #[test]
294 fn check_aria_landmarks_no_main_element_reports_issue() {
295 let html = r#"<html lang="en"><head></head><body>
296 <div>no main landmark here</div>
297 </body></html>"#;
298 let issues = check_page(html);
299 assert!(issues
300 .iter()
301 .any(|i| i.message.contains("no <main> landmark")));
302 }
303
304 #[test]
305 fn check_aria_landmarks_multiple_main_elements_reports_issue() {
306 let html = r#"<html lang="en"><head></head><body>
307 <main>first</main>
308 <main>second</main>
309 </body></html>"#;
310 let issues = check_page(html);
311 assert!(issues
312 .iter()
313 .any(|i| i.message.contains("2 <main> elements")));
314 }
315
316 #[test]
319 fn test_target_size_below_minimum_flagged() {
320 let html = r#"<html lang="en"><head><style>
321 button { width: 16px; height: 16px; }
322 </style></head><body><main></main></body></html>"#;
323 let issues = check_page(html);
324 assert!(
325 issues.iter().any(|i| i.criterion == "2.5.8"),
326 "expected 2.5.8 issue for 16px button, got {issues:?}"
327 );
328 }
329
330 #[test]
331 fn test_target_size_compliant_passes() {
332 let html = r#"<html lang="en"><head><style>
333 button { width: 32px; height: 32px; }
334 </style></head><body><main><marquee>seed</marquee></main></body></html>"#;
335 let issues: Vec<_> = check_page(html)
336 .into_iter()
337 .filter(|i| i.criterion == "2.5.8")
338 .collect();
339 assert!(
340 issues.is_empty(),
341 "32px button should not trigger 2.5.8, got {issues:?}"
342 );
343 }
344
345 #[test]
346 fn test_focus_appearance_outline_none_flagged() {
347 let html = r#"<html lang="en"><head><style>
348 a:focus { outline: none; }
349 </style></head><body><main></main></body></html>"#;
350 let issues = check_page(html);
351 assert!(
352 issues.iter().any(|i| i.criterion == "2.4.13"),
353 "expected 2.4.13 issue for bare outline:none, got {issues:?}"
354 );
355 }
356
357 #[test]
358 fn test_focus_appearance_with_box_shadow_passes() {
359 let html = r#"<html lang="en"><head><style>
360 a:focus { outline: none; box-shadow: 0 0 0 2px blue; }
361 </style></head><body><main><marquee>seed</marquee></main></body></html>"#;
362 let issues: Vec<_> = check_page(html)
363 .into_iter()
364 .filter(|i| i.criterion == "2.4.13")
365 .collect();
366 assert!(
367 issues.is_empty(),
368 "outline:none + box-shadow should pass 2.4.13, got {issues:?}"
369 );
370 }
371
372 #[test]
375 fn target_size_ignores_value_inside_css_comment() {
376 let html = r#"<html lang="en"><head><style>
379 button { /* width: 10px */ width: 32px; height: 32px; }
380 </style></head><body><main><marquee>seed</marquee></main></body></html>"#;
381 let issues = check_page(html);
382 assert!(
383 !issues.iter().any(|i| i.criterion == "2.5.8"),
384 "comment must not trigger 2.5.8, got {issues:?}"
385 );
386 }
387
388 #[test]
389 fn target_size_ignores_rule_inside_media_query() {
390 let html = r#"<html lang="en"><head><style>
393 @media print { button { width: 10px; height: 10px; } }
394 button { width: 32px; height: 32px; }
395 </style></head><body><main><marquee>seed</marquee></main></body></html>"#;
396 let issues = check_page(html);
397 assert!(
398 !issues.iter().any(|i| i.criterion == "2.5.8"),
399 "@media-nested 10px must not flag 2.5.8, got {issues:?}"
400 );
401 }
402
403 #[test]
404 fn target_size_scans_every_style_block() {
405 let html = r#"<html lang="en">
407 <head>
408 <style>p { color: red }</style>
409 <style>button { width: 8px; height: 8px; }</style>
410 </head>
411 <body><main></main></body>
412 </html>"#;
413 let issues = check_page(html);
414 assert!(
415 issues.iter().any(|i| i.criterion == "2.5.8"),
416 "second <style> block's button rule must be inspected, got {issues:?}"
417 );
418 }
419
420 #[test]
421 fn focus_appearance_ignores_outline_none_inside_supports() {
422 let html = r#"<html lang="en"><head><style>
425 @supports (display: grid) { a:focus { outline: none; } }
426 a:focus { outline: 2px solid blue; }
427 </style></head><body><main><marquee>seed</marquee></main></body></html>"#;
428 let issues = check_page(html);
429 assert!(
430 !issues.iter().any(|i| i.criterion == "2.4.13"),
431 "@supports-nested outline:none must not flag 2.4.13, got {issues:?}"
432 );
433 }
434
435 #[test]
438 fn empty_alt_without_decorative_role_is_flagged() {
439 let html = r#"<html lang="en"><body><main><img src="x.png" alt=""></main></body></html>"#;
440 let issues = check_page(html);
441 assert!(
442 issues.iter().any(|i| i.criterion == "1.1.1"),
443 "empty alt without decorative role must flag 1.1.1: {issues:?}"
444 );
445 }
446
447 #[test]
448 fn empty_alt_with_decorative_role_passes() {
449 let html = r#"<html lang="en"><body><main><img src="x.png" alt="" role="presentation"><marquee>seed</marquee></main></body></html>"#;
450 let issues = check_page(html);
451 assert!(
452 !issues.iter().any(|i| i.criterion == "1.1.1"),
453 "decorative empty-alt image must not flag 1.1.1: {issues:?}"
454 );
455 }
456
457 #[test]
458 fn missing_alt_and_missing_src_reports_no_src_placeholder() {
459 let html = r#"<html lang="en"><body><main><img></main></body></html>"#;
460 let issues = check_page(html);
461 let issue = issues
462 .iter()
463 .find(|i| i.criterion == "1.1.1")
464 .expect("img without alt must be flagged");
465 assert!(
466 issue.message.contains("(no src)"),
467 "missing src should use the placeholder: {}",
468 issue.message
469 );
470 }
471}