#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
#![forbid(unsafe_code)]
#![allow(clippy::redundant_pub_crate)]
mod css;
mod html;
pub use html::find_tag_end;
mod report;
mod rules;
mod types;
pub use report::build_compliance_report;
pub use rules::check_consistent_help;
pub use types::{
AccessibilityIssue, AccessibilityReport, CriterionEntry, CriterionStatus,
PageReport, WcagComplianceReport,
};
pub fn check_page(html: &str) -> Vec<AccessibilityIssue> {
let mut issues = Vec::new();
rules::check_img_alt(html, &mut issues);
rules::check_html_lang(html, &mut issues);
rules::check_link_text(html, &mut issues);
rules::check_heading_hierarchy(html, &mut issues);
rules::check_banned_elements(html, &mut issues);
rules::check_aria_landmarks(html, &mut issues);
rules::check_target_size(html, &mut issues);
rules::check_focus_appearance(html, &mut issues);
issues
}
#[cfg(test)]
mod tests {
#[test]
fn check_page_survives_non_ascii_that_grows_when_lowercased() {
let html = "<!\u{130}html>\n<img src=\"a.png\" alt>";
let _ = check_page(html);
for probe in [
"\u{130}<img src=\"x.png\">",
"<img alt=\"\u{130}\" src=\"y.png\">",
"<p>\u{130}\u{130}\u{130}</p><img>",
"<img src=\"\u{130}.png\">",
] {
let _ = check_page(probe);
}
}
#[test]
fn check_page_still_matches_uppercase_tags() {
let issues = check_page("<IMG SRC=\"a.png\">");
assert!(
issues.iter().any(|i| i.criterion == "1.1.1"),
"uppercase <IMG> without alt was not detected: {issues:?}"
);
}
use super::*;
#[test]
fn test_img_alt_missing() {
let html = r#"<html lang="en"><head></head><body><main><img src="photo.jpg"></main></body></html>"#;
let issues = check_page(html);
assert!(issues.iter().any(|i| i.criterion == "1.1.1"));
}
#[test]
fn test_img_alt_present() {
let html = r#"<html lang="en"><head></head><body><main><img src="photo.jpg" alt="A photo"><marquee>seed</marquee></main></body></html>"#;
let issues = check_page(html);
assert!(!issues.iter().any(|i| i.criterion == "1.1.1"));
}
#[test]
fn test_img_alt_with_inline_svg_data_url() {
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>"#;
let issues = check_page(html);
assert!(
!issues.iter().any(|i| i.criterion == "1.1.1"),
"SVG-data-url img with valid alt should not raise 1.1.1, got: {issues:?}"
);
}
#[test]
fn test_html_lang_missing() {
let html = "<html><head></head><body><main></main></body></html>";
let issues = check_page(html);
assert!(issues.iter().any(|i| i.criterion == "3.1.1"));
}
#[test]
fn test_heading_skip() {
let html = r#"<html lang="en"><head></head><body><main><h1>Title</h1><h3>Skip</h3></main></body></html>"#;
let issues = check_page(html);
assert!(issues.iter().any(|i| i.message.contains("skips")));
}
#[test]
fn test_banned_marquee() {
let html = r#"<html lang="en"><head></head><body><main><marquee>No</marquee></main></body></html>"#;
let issues = check_page(html);
assert!(issues.iter().any(|i| i.criterion == "2.3.1"));
}
#[test]
fn test_nav_without_label() {
let html = r#"<html lang="en"><head></head><body><nav></nav><main></main></body></html>"#;
let issues = check_page(html);
assert!(issues.iter().any(|i| i.message.contains("aria-label")));
}
#[test]
fn test_nav_with_label_passes() {
let html = r#"<html lang="en"><head></head><body><nav aria-label="Main"></nav><main><marquee>seed</marquee></main></body></html>"#;
let issues = check_page(html);
assert!(!issues.iter().any(|i| i.message.contains("aria-label")));
}
#[test]
fn test_clean_page_no_issues() {
let html = r#"<html lang="en"><head></head><body>
<nav aria-label="Main"><a href="/">Home</a></nav>
<main><h1>Title</h1><h2>Sub</h2>
<img src="x.jpg" alt="Photo"></main></body></html>"#;
let issues = check_page(html);
assert!(issues.is_empty(), "Expected no issues, got: {issues:?}");
}
#[test]
fn check_link_text_empty_anchor_reports_issue() {
let html = r#"<html lang="en"><head></head><body><main>
<a href="/page"></a>
</main></body></html>"#;
let issues = check_page(html);
assert!(issues.iter().any(|i| i.criterion == "2.4.4"));
}
#[test]
fn check_link_text_empty_anchor_with_aria_label_passes() {
let html = r#"<html lang="en"><head></head><body><main>
<a href="/page" aria-label="Read more"></a>
<marquee>seed</marquee>
</main></body></html>"#;
let issues = check_page(html);
assert!(!issues.iter().any(|i| i.criterion == "2.4.4"));
}
#[test]
fn check_link_text_empty_anchor_with_title_passes() {
let html = r#"<html lang="en"><head></head><body><main>
<a href="/page" title="Read more"></a>
<marquee>seed</marquee>
</main></body></html>"#;
let issues = check_page(html);
assert!(!issues.iter().any(|i| i.criterion == "2.4.4"));
}
#[test]
fn check_link_text_empty_anchor_with_no_href_reports_issue() {
let html = r#"<html lang="en"><head></head><body><main>
<a ></a>
</main></body></html>"#;
let _ = check_page(html);
}
#[test]
fn check_aria_landmarks_no_main_element_reports_issue() {
let html = r#"<html lang="en"><head></head><body>
<div>no main landmark here</div>
</body></html>"#;
let issues = check_page(html);
assert!(issues
.iter()
.any(|i| i.message.contains("no <main> landmark")));
}
#[test]
fn check_aria_landmarks_multiple_main_elements_reports_issue() {
let html = r#"<html lang="en"><head></head><body>
<main>first</main>
<main>second</main>
</body></html>"#;
let issues = check_page(html);
assert!(issues
.iter()
.any(|i| i.message.contains("2 <main> elements")));
}
#[test]
fn test_target_size_below_minimum_flagged() {
let html = r#"<html lang="en"><head><style>
button { width: 16px; height: 16px; }
</style></head><body><main></main></body></html>"#;
let issues = check_page(html);
assert!(
issues.iter().any(|i| i.criterion == "2.5.8"),
"expected 2.5.8 issue for 16px button, got {issues:?}"
);
}
#[test]
fn test_target_size_compliant_passes() {
let html = r#"<html lang="en"><head><style>
button { width: 32px; height: 32px; }
</style></head><body><main><marquee>seed</marquee></main></body></html>"#;
let issues: Vec<_> = check_page(html)
.into_iter()
.filter(|i| i.criterion == "2.5.8")
.collect();
assert!(
issues.is_empty(),
"32px button should not trigger 2.5.8, got {issues:?}"
);
}
#[test]
fn test_focus_appearance_outline_none_flagged() {
let html = r#"<html lang="en"><head><style>
a:focus { outline: none; }
</style></head><body><main></main></body></html>"#;
let issues = check_page(html);
assert!(
issues.iter().any(|i| i.criterion == "2.4.13"),
"expected 2.4.13 issue for bare outline:none, got {issues:?}"
);
}
#[test]
fn test_focus_appearance_with_box_shadow_passes() {
let html = r#"<html lang="en"><head><style>
a:focus { outline: none; box-shadow: 0 0 0 2px blue; }
</style></head><body><main><marquee>seed</marquee></main></body></html>"#;
let issues: Vec<_> = check_page(html)
.into_iter()
.filter(|i| i.criterion == "2.4.13")
.collect();
assert!(
issues.is_empty(),
"outline:none + box-shadow should pass 2.4.13, got {issues:?}"
);
}
#[test]
fn target_size_ignores_value_inside_css_comment() {
let html = r#"<html lang="en"><head><style>
button { /* width: 10px */ width: 32px; height: 32px; }
</style></head><body><main><marquee>seed</marquee></main></body></html>"#;
let issues = check_page(html);
assert!(
!issues.iter().any(|i| i.criterion == "2.5.8"),
"comment must not trigger 2.5.8, got {issues:?}"
);
}
#[test]
fn target_size_ignores_rule_inside_media_query() {
let html = r#"<html lang="en"><head><style>
@media print { button { width: 10px; height: 10px; } }
button { width: 32px; height: 32px; }
</style></head><body><main><marquee>seed</marquee></main></body></html>"#;
let issues = check_page(html);
assert!(
!issues.iter().any(|i| i.criterion == "2.5.8"),
"@media-nested 10px must not flag 2.5.8, got {issues:?}"
);
}
#[test]
fn target_size_scans_every_style_block() {
let html = r#"<html lang="en">
<head>
<style>p { color: red }</style>
<style>button { width: 8px; height: 8px; }</style>
</head>
<body><main></main></body>
</html>"#;
let issues = check_page(html);
assert!(
issues.iter().any(|i| i.criterion == "2.5.8"),
"second <style> block's button rule must be inspected, got {issues:?}"
);
}
#[test]
fn focus_appearance_ignores_outline_none_inside_supports() {
let html = r#"<html lang="en"><head><style>
@supports (display: grid) { a:focus { outline: none; } }
a:focus { outline: 2px solid blue; }
</style></head><body><main><marquee>seed</marquee></main></body></html>"#;
let issues = check_page(html);
assert!(
!issues.iter().any(|i| i.criterion == "2.4.13"),
"@supports-nested outline:none must not flag 2.4.13, got {issues:?}"
);
}
#[test]
fn empty_alt_without_decorative_role_is_flagged() {
let html = r#"<html lang="en"><body><main><img src="x.png" alt=""></main></body></html>"#;
let issues = check_page(html);
assert!(
issues.iter().any(|i| i.criterion == "1.1.1"),
"empty alt without decorative role must flag 1.1.1: {issues:?}"
);
}
#[test]
fn empty_alt_with_decorative_role_passes() {
let html = r#"<html lang="en"><body><main><img src="x.png" alt="" role="presentation"><marquee>seed</marquee></main></body></html>"#;
let issues = check_page(html);
assert!(
!issues.iter().any(|i| i.criterion == "1.1.1"),
"decorative empty-alt image must not flag 1.1.1: {issues:?}"
);
}
#[test]
fn missing_alt_and_missing_src_reports_no_src_placeholder() {
let html = r#"<html lang="en"><body><main><img></main></body></html>"#;
let issues = check_page(html);
let issue = issues
.iter()
.find(|i| i.criterion == "1.1.1")
.expect("img without alt must be flagged");
assert!(
issue.message.contains("(no src)"),
"missing src should use the placeholder: {}",
issue.message
);
}
}