use crate::css::{
extract_all_style_blocks, first_px_value, parse_top_level_rules,
preprocess_css, selector_targets_interactive,
};
use crate::html::{
extract_attr_value, find_tag_end, has_empty_alt, has_valid_alt,
is_decorative_img, strip_tags_simple,
};
use crate::types::AccessibilityIssue;
pub(crate) fn check_img_alt(html: &str, issues: &mut Vec<AccessibilityIssue>) {
let lower = html.to_ascii_lowercase();
let mut pos = 0;
while let Some(start) = lower[pos..].find("<img") {
let abs = pos + start;
let tag_end = find_tag_end(&lower, abs);
let tag = &lower[abs..tag_end];
if !has_valid_alt(tag)
|| (has_empty_alt(tag) && !is_decorative_img(tag))
{
let src = extract_attr_value(&html[abs..tag_end], "src")
.unwrap_or_default();
issues.push(AccessibilityIssue {
criterion: "1.1.1".to_string(),
severity: "error".to_string(),
message: format!(
"<img> missing alt text: {}",
if src.is_empty() { "(no src)" } else { &src }
),
});
}
pos = tag_end;
}
}
pub(crate) fn check_html_lang(
html: &str,
issues: &mut Vec<AccessibilityIssue>,
) {
let lower = html.to_ascii_lowercase();
if let Some(start) = lower.find("<html") {
let tag_end =
lower[start..].find('>').map_or(lower.len(), |e| start + e);
let tag = &lower[start..tag_end];
if !tag.contains("lang=") {
issues.push(AccessibilityIssue {
criterion: "3.1.1".to_string(),
severity: "error".to_string(),
message: "<html> missing lang attribute".to_string(),
});
}
}
}
pub(crate) fn check_link_text(
html: &str,
issues: &mut Vec<AccessibilityIssue>,
) {
let lower = html.to_ascii_lowercase();
let mut pos = 0;
while let Some(start) = lower[pos..].find("<a ") {
let abs = pos + start;
let close = lower[abs..].find("</a>").unwrap_or(lower.len() - abs);
let full = &lower[abs..abs + close];
if let Some(gt) = full.find('>') {
let inner = &full[gt + 1..];
let text = strip_tags_simple(inner);
let has_aria = full.contains("aria-label=");
let has_title = full.contains("title=");
if text.trim().is_empty() && !has_aria && !has_title {
let href = extract_attr_value(&html[abs..abs + close], "href")
.unwrap_or_default();
issues.push(AccessibilityIssue {
criterion: "2.4.4".to_string(),
severity: "warning".to_string(),
message: format!(
"<a> has no discernible text: href={}",
if href.is_empty() { "(none)" } else { &href }
),
});
}
}
pos = abs + close.max(1);
}
}
pub(crate) fn check_heading_hierarchy(
html: &str,
issues: &mut Vec<AccessibilityIssue>,
) {
let lower = html.to_ascii_lowercase();
let mut last_level: u8 = 0;
for level in 1..=6u8 {
let tag = format!("<h{level}");
if lower.contains(&tag) {
if last_level > 0 && level > last_level + 1 {
issues.push(AccessibilityIssue {
criterion: "1.3.1".to_string(),
severity: "warning".to_string(),
message: format!(
"Heading hierarchy skips from h{last_level} to h{level}"
),
});
}
last_level = level;
}
}
}
pub(crate) fn check_banned_elements(
html: &str,
issues: &mut Vec<AccessibilityIssue>,
) {
let lower = html.to_ascii_lowercase();
for tag in &["<marquee", "<blink"] {
if lower.contains(tag) {
issues.push(AccessibilityIssue {
criterion: "2.3.1".to_string(),
severity: "error".to_string(),
message: format!("Banned element {} found", &tag[1..]),
});
}
}
}
pub(crate) fn strip_non_content_blocks(html: &str) -> String {
let mut clean = html.to_ascii_lowercase();
while let Some(start) = clean.find("<!--") {
if let Some(end) = clean[start..].find("-->") {
clean.replace_range(start..start + end + 3, "");
} else {
break;
}
}
while let Some(start) = clean.find("<style") {
if let Some(end) = clean[start..].find("</style>") {
clean.replace_range(start..start + end + 8, "");
} else {
break;
}
}
while let Some(start) = clean.find("<script") {
if let Some(end) = clean[start..].find("</script>") {
clean.replace_range(start..start + end + 9, "");
} else {
break;
}
}
clean
}
pub(crate) fn check_aria_landmarks(
html: &str,
issues: &mut Vec<AccessibilityIssue>,
) {
let clean = strip_non_content_blocks(html);
let main_count = clean.matches("<main").count();
if main_count == 0 {
issues.push(AccessibilityIssue {
criterion: "ARIA".to_string(),
severity: "warning".to_string(),
message: "Page has no <main> landmark".to_string(),
});
} else if main_count > 1 {
issues.push(AccessibilityIssue {
criterion: "ARIA".to_string(),
severity: "warning".to_string(),
message: format!(
"Page has {main_count} <main> elements (expected 1)"
),
});
}
let mut pos = 0;
while let Some(start) = clean[pos..].find("<nav") {
let abs = pos + start;
let tag_end = clean[abs..].find('>').map_or(clean.len(), |e| abs + e);
let tag = &clean[abs..tag_end];
if !tag.contains("aria-label") && !tag.contains("aria-labelledby") {
issues.push(AccessibilityIssue {
criterion: "ARIA".to_string(),
severity: "warning".to_string(),
message: "<nav> missing aria-label".to_string(),
});
}
pos = tag_end;
}
}
pub(crate) fn check_target_size(
html: &str,
issues: &mut Vec<AccessibilityIssue>,
) {
for css in extract_all_style_blocks(html) {
let cleaned = preprocess_css(&css);
for (selector, body) in parse_top_level_rules(&cleaned) {
if !selector_targets_interactive(&selector) {
continue;
}
for prop in ["width", "height"] {
if let Some(px) = first_px_value(&body, prop) {
if px > 0 && px < 24 {
issues.push(AccessibilityIssue {
criterion: "2.5.8".to_string(),
severity: "warning".to_string(),
message: format!(
"Target size {prop}={px}px on `{selector}` \
is below the 24×24 minimum (WCAG 2.2 AA)"
),
});
}
}
}
}
}
}
pub(crate) fn check_focus_appearance(
html: &str,
issues: &mut Vec<AccessibilityIssue>,
) {
for css in extract_all_style_blocks(html) {
let cleaned = preprocess_css(&css);
for (selector, body) in parse_top_level_rules(&cleaned) {
if !selector.contains(":focus") {
continue;
}
let kills_outline = body.contains("outline:none")
|| body.contains("outline: none")
|| body.contains("outline:0")
|| body.contains("outline: 0");
let has_replacement = body.contains("outline-style")
|| body.contains("outline-color")
|| body.contains("box-shadow")
|| body.contains("border:");
if kills_outline && !has_replacement {
issues.push(AccessibilityIssue {
criterion: "2.4.13".to_string(),
severity: "warning".to_string(),
message: "`:focus { outline: none }` without a \
compensating outline-style/box-shadow/border \
(WCAG 2.2 AAA — Focus Appearance)"
.to_string(),
});
}
}
}
}
pub fn check_consistent_help(html: &str, issues: &mut Vec<AccessibilityIssue>) {
let lower = html.to_ascii_lowercase();
let has_help_link = lower.contains(">help<")
|| lower.contains(">contact<")
|| lower.contains(">support<")
|| lower.contains(">faq<")
|| lower.contains("aria-label=\"help\"")
|| lower.contains("aria-label=\"contact\"")
|| lower.contains("aria-label=\"support\"");
if !has_help_link {
issues.push(AccessibilityIssue {
criterion: "3.2.6".to_string(),
severity: "info".to_string(),
message: "No detectable help/contact/support link on page; \
verify that the site provides a consistent help \
mechanism across pages (WCAG 2.2 A — Consistent \
Help)"
.to_string(),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_html_lang_skips_fragment_without_html_tag() {
let mut issues = Vec::new();
check_html_lang("<p>fragment only</p>", &mut issues);
assert!(issues.is_empty());
}
#[test]
fn check_link_text_tolerates_unterminated_anchor() {
let mut issues = Vec::new();
check_link_text("<a href=/x", &mut issues);
assert!(issues.is_empty());
}
#[test]
fn strip_non_content_blocks_removes_html_comments() {
let out = strip_non_content_blocks("<body><!-- <main> --></body>");
assert!(!out.contains("<main>"));
assert!(out.contains("<body>"));
}
#[test]
fn strip_non_content_blocks_tolerates_unterminated_comment() {
let out = strip_non_content_blocks("<body><!-- no close");
assert!(out.contains("<!--"), "unterminated comment kept: {out}");
}
#[test]
fn strip_non_content_blocks_tolerates_unterminated_style() {
let out = strip_non_content_blocks("<body><style>a{}");
assert!(out.contains("<style>"), "unterminated style kept: {out}");
}
#[test]
fn strip_non_content_blocks_tolerates_unterminated_script() {
let out = strip_non_content_blocks("<body><script>let x=1;");
assert!(out.contains("<script>"), "unterminated script kept: {out}");
}
#[test]
fn test_consistent_help_helper_detects_link() {
let html_with = r#"<html lang="en"><body><a href="/contact">Contact</a></body></html>"#;
let html_without =
r#"<html lang="en"><body><p>nothing</p></body></html>"#;
let mut buf = Vec::new();
check_consistent_help(html_with, &mut buf);
assert!(buf.is_empty(), "with link, no issue");
check_consistent_help(html_without, &mut buf);
assert_eq!(buf.len(), 1);
assert_eq!(buf[0].criterion, "3.2.6");
}
}