use crate::plugin::{Plugin, PluginContext};
use anyhow::Result;
use serde::Serialize;
use std::fs;
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct AccessibilityIssue {
pub criterion: String,
pub severity: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct PageReport {
pub path: String,
pub issues: Vec<AccessibilityIssue>,
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct AccessibilityReport {
pub pages_scanned: usize,
pub total_issues: usize,
#[serde(default = "default_wcag_version")]
pub wcag_version: String,
pub pages: Vec<PageReport>,
}
fn default_wcag_version() -> String {
"2.2".to_string()
}
#[derive(Debug, Clone, Copy, Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum CriterionStatus {
Automated,
Runtime,
Manual,
NotApplicable,
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct CriterionEntry {
pub criterion: String,
pub level: String,
pub title: String,
pub status: CriterionStatus,
pub all_pages_pass: bool,
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct WcagComplianceReport {
pub wcag_version: String,
pub pages_scanned: usize,
pub criteria: Vec<CriterionEntry>,
}
#[derive(Debug, Clone, Copy)]
pub struct AccessibilityPlugin;
impl Plugin for AccessibilityPlugin {
fn name(&self) -> &'static str {
"accessibility"
}
fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
if !ctx.site_dir.exists() {
return Ok(());
}
let html_files = ctx.get_html_files();
let mut report = AccessibilityReport {
pages_scanned: html_files.len(),
total_issues: 0,
wcag_version: "2.2".to_string(),
pages: Vec::new(),
};
let mut failed_criteria: std::collections::HashSet<String> =
std::collections::HashSet::new();
for path in &html_files {
let html = fs::read_to_string(path)?;
let rel = path
.strip_prefix(&ctx.site_dir)
.unwrap_or(path)
.to_string_lossy()
.to_string();
let issues = check_page(&html);
if !issues.is_empty() {
for issue in &issues {
let _ = failed_criteria.insert(issue.criterion.clone());
log::warn!(
"[a11y] {} — [{}] {}",
rel,
issue.criterion,
issue.message
);
}
report.total_issues += issues.len();
report.pages.push(PageReport { path: rel, issues });
}
}
let report_path = ctx.site_dir.join("accessibility-report.json");
let json = serde_json::to_string_pretty(&report)?;
fs::write(&report_path, json)?;
let compliance =
build_compliance_report(html_files.len(), &failed_criteria);
let matrix_path = ctx.site_dir.join("wcag-compliance.json");
fs::write(&matrix_path, serde_json::to_string_pretty(&compliance)?)?;
if report.total_issues > 0 {
log::warn!(
"[a11y] {} issue(s) across {} page(s). Reports: {} + {}",
report.total_issues,
report.pages.len(),
report_path.display(),
matrix_path.display()
);
} else {
log::info!(
"[a11y] All {} page(s) passed checks. Reports: {} + {}",
report.pages_scanned,
report_path.display(),
matrix_path.display()
);
}
Ok(())
}
}
fn build_compliance_report(
pages_scanned: usize,
failed: &std::collections::HashSet<String>,
) -> WcagComplianceReport {
use CriterionStatus::{Automated, Manual, NotApplicable, Runtime};
let did_pass = |sc: &str| !failed.contains(sc);
let row = |sc: &str, level: &str, title: &str, status: CriterionStatus| {
CriterionEntry {
criterion: sc.to_string(),
level: level.to_string(),
title: title.to_string(),
status,
all_pages_pass: matches!(status, Automated) && did_pass(sc),
}
};
let criteria = vec![
row("1.1.1", "A", "Non-text Content", Automated),
row("1.3.1", "A", "Info and Relationships", Automated),
row("1.4.3", "AA", "Contrast (Minimum)", Runtime),
row("1.4.10", "AA", "Reflow", Runtime),
row("1.4.11", "AA", "Non-text Contrast", Runtime),
row("1.4.12", "AA", "Text Spacing", Runtime),
row("2.3.1", "A", "Three Flashes or Below Threshold", Automated),
row("2.4.4", "A", "Link Purpose (In Context)", Automated),
row("2.4.11", "AA", "Focus Not Obscured (Minimum)", Runtime),
row("2.4.13", "AAA", "Focus Appearance", Automated),
row("2.5.7", "AA", "Dragging Movements", Manual),
row("2.5.8", "AA", "Target Size (Minimum)", Automated),
row("3.1.1", "A", "Language of Page", Automated),
row("3.2.6", "A", "Consistent Help", Manual),
row("3.3.7", "A", "Redundant Entry", NotApplicable),
row(
"3.3.8",
"AA",
"Accessible Authentication (Minimum)",
NotApplicable,
),
row("4.1.3", "AA", "Status Messages", Runtime),
];
WcagComplianceReport {
wcag_version: "2.2".to_string(),
pages_scanned,
criteria,
}
}
fn check_page(html: &str) -> Vec<AccessibilityIssue> {
let mut issues = Vec::new();
check_img_alt(html, &mut issues);
check_html_lang(html, &mut issues);
check_link_text(html, &mut issues);
check_heading_hierarchy(html, &mut issues);
check_banned_elements(html, &mut issues);
check_aria_landmarks(html, &mut issues);
check_target_size(html, &mut issues);
check_focus_appearance(html, &mut issues);
let _ = check_consistent_help;
issues
}
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)"
),
});
}
}
}
}
}
}
fn selector_targets_interactive(selector: &str) -> bool {
selector.contains("button")
|| selector.contains("input")
|| selector.contains("[role=\"button\"]")
|| selector.contains("[role='button']")
|| selector.contains("[role=button]")
|| selector == "a"
|| selector.starts_with("a ")
|| selector.starts_with("a:")
|| selector.starts_with("a.")
|| selector.starts_with("a#")
|| selector.starts_with("a[")
}
fn first_px_value(css: &str, prop: &str) -> Option<u32> {
let pat = format!("{prop}:");
let start = css.find(&pat)?;
let after = &css[start + pat.len()..];
let value = after.split(';').next()?.trim();
let digits: String =
value.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
return None;
}
if value[digits.len()..].trim_start().starts_with("px") {
digits.parse().ok()
} else {
None
}
}
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(),
});
}
}
}
}
fn extract_all_style_blocks(html: &str) -> Vec<String> {
let mut blocks = Vec::new();
let lower = html.to_lowercase();
let mut cursor = 0;
while let Some(rel_open) = lower[cursor..].find("<style") {
let abs_open = cursor + rel_open;
let tag_end = find_tag_end(&lower, abs_open);
cursor = tag_end;
let Some(rel_close) = lower[cursor..].find("</style>") else {
break;
};
blocks.push(html[cursor..cursor + rel_close].to_string());
cursor += rel_close + "</style>".len();
}
blocks
}
fn preprocess_css(css: &str) -> String {
let lower = css.to_lowercase();
let no_comments = strip_css_comments(&lower);
strip_at_rules(&no_comments)
}
fn strip_css_comments(css: &str) -> String {
let mut out = String::with_capacity(css.len());
let bytes = css.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len() && &bytes[i..i + 2] == b"/*" {
i += 2;
while i + 1 < bytes.len() && &bytes[i..i + 2] != b"*/" {
i += 1;
}
i = (i + 2).min(bytes.len());
out.push(' ');
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
fn strip_at_rules(css: &str) -> String {
let mut out = String::with_capacity(css.len());
let bytes = css.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'@' {
let mut j = i;
while j < bytes.len() && bytes[j] != b'{' && bytes[j] != b';' {
j += 1;
}
if j >= bytes.len() {
break;
}
if bytes[j] == b';' {
i = j + 1;
continue;
}
let mut depth = 0_i32;
let mut k = j;
while k < bytes.len() {
match bytes[k] {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
k += 1;
break;
}
}
_ => {}
}
k += 1;
}
i = k;
continue;
}
out.push(bytes[i] as char);
i += 1;
}
out
}
fn parse_top_level_rules(css: &str) -> Vec<(String, String)> {
let mut rules = Vec::new();
let bytes = css.as_bytes();
let mut i = 0;
while i < bytes.len() {
let Some(open_rel) = css[i..].find('{') else {
break;
};
let open = i + open_rel;
let selector = css[i..open].trim().to_string();
if selector.is_empty() {
i = open + 1;
continue;
}
let mut depth = 1_i32;
let mut j = open + 1;
while j < bytes.len() {
match bytes[j] {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
j += 1;
}
let body = css[open + 1..j].to_string();
rules.push((selector, body));
i = j + 1;
}
rules
}
fn check_consistent_help(html: &str, issues: &mut Vec<AccessibilityIssue>) {
let lower = html.to_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(),
});
}
}
fn has_valid_alt(tag: &str) -> bool {
let has_alt_eq = tag.contains("alt=");
let has_alt_bare = !has_alt_eq
&& (tag.contains(" alt ")
|| tag.contains(" alt>")
|| tag.ends_with(" alt"));
has_alt_eq || has_alt_bare
}
fn has_empty_alt(tag: &str) -> bool {
let has_alt_eq = tag.contains("alt=");
let has_alt_bare = !has_alt_eq
&& (tag.contains(" alt ")
|| tag.contains(" alt>")
|| tag.ends_with(" alt"));
tag.contains("alt=\"\"")
|| tag.contains("alt=''")
|| has_alt_bare
|| (has_alt_eq && !tag.contains("alt=\"") && !tag.contains("alt='"))
}
fn is_decorative_img(tag: &str) -> bool {
tag.contains("role=\"presentation\"")
|| tag.contains("role=\"none\"")
|| tag.contains("role='presentation'")
|| tag.contains("role='none'")
|| tag.contains("role=presentation")
|| tag.contains("role=none")
}
fn find_tag_end(html: &str, tag_start: usize) -> usize {
let bytes = html.as_bytes();
let mut i = tag_start;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let b = bytes[i];
match quote {
Some(q) if b == q => quote = None,
Some(_) => {}
None => match b {
b'"' | b'\'' => quote = Some(b),
b'>' => return i + 1,
_ => {}
},
}
i += 1;
}
bytes.len()
}
fn check_img_alt(html: &str, issues: &mut Vec<AccessibilityIssue>) {
let lower = html.to_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;
}
}
fn check_html_lang(html: &str, issues: &mut Vec<AccessibilityIssue>) {
let lower = html.to_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(),
});
}
}
}
fn check_link_text(html: &str, issues: &mut Vec<AccessibilityIssue>) {
let lower = html.to_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);
}
}
fn check_heading_hierarchy(html: &str, issues: &mut Vec<AccessibilityIssue>) {
let lower = html.to_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;
}
}
}
fn check_banned_elements(html: &str, issues: &mut Vec<AccessibilityIssue>) {
let lower = html.to_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..]),
});
}
}
}
fn check_aria_landmarks(html: &str, issues: &mut Vec<AccessibilityIssue>) {
let lower = html.to_lowercase();
let main_count = lower.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) = lower[pos..].find("<nav") {
let abs = pos + start;
let tag_end = lower[abs..].find('>').map_or(lower.len(), |e| abs + e);
let tag = &lower[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;
}
}
fn extract_attr_value(tag: &str, attr: &str) -> Option<String> {
let lower = tag.to_lowercase();
let pattern = format!("{attr}=");
let start = lower.find(&pattern)?;
let after = &tag[start + pattern.len()..];
let trimmed = after.trim_start();
if let Some(inner) = trimmed.strip_prefix('"') {
let end = inner.find('"')?;
Some(inner[..end].to_string())
} else if let Some(inner) = trimmed.strip_prefix('\'') {
let end = inner.find('\'')?;
Some(inner[..end].to_string())
} else {
let end = trimmed
.find(|c: char| c.is_whitespace() || c == '>')
.unwrap_or(trimmed.len());
Some(trimmed[..end].to_string())
}
}
fn strip_tags_simple(html: &str) -> String {
let mut result = String::with_capacity(html.len());
let mut in_tag = false;
for ch in html.chars() {
if ch == '<' {
in_tag = true;
} else if ch == '>' {
in_tag = false;
} else if !in_tag {
result.push(ch);
}
}
result
}
#[cfg(test)]
fn collect_html_files(
dir: &std::path::Path,
) -> Result<Vec<std::path::PathBuf>> {
crate::walk::walk_files(dir, "html")
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::path::Path;
use tempfile::tempdir;
fn test_ctx(site_dir: &Path) -> PluginContext {
crate::test_support::init_logger();
PluginContext::new(
Path::new("content"),
Path::new("build"),
site_dir,
Path::new("templates"),
)
}
#[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"></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"></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></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 name_returns_static_accessibility_identifier() {
assert_eq!(AccessibilityPlugin.name(), "accessibility");
}
#[test]
fn after_compile_missing_site_dir_returns_ok_without_writing() {
let dir = tempdir().unwrap();
let missing = dir.path().join("missing");
let ctx = test_ctx(&missing);
AccessibilityPlugin.after_compile(&ctx).unwrap();
assert!(!missing.join("accessibility-report.json").exists());
}
#[test]
fn after_compile_clean_pages_logs_all_passed() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
fs::write(
site.join("index.html"),
r#"<html lang="en"><head></head><body>
<nav aria-label="Main"><a href="/">Home</a></nav>
<main><h1>T</h1><img src="a.jpg" alt="A"></main>
</body></html>"#,
)
.unwrap();
let ctx = test_ctx(&site);
AccessibilityPlugin.after_compile(&ctx).unwrap();
let report: AccessibilityReport = serde_json::from_str(
&fs::read_to_string(site.join("accessibility-report.json"))
.unwrap(),
)
.unwrap();
assert_eq!(report.total_issues, 0);
}
#[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>
</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>
</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 extract_attr_value_double_quoted() {
let result = extract_attr_value(r#"<a href="/foo">"#, "href");
assert_eq!(result, Some("/foo".to_string()));
}
#[test]
fn extract_attr_value_single_quoted() {
let result = extract_attr_value(r"<a href='/bar'>", "href");
assert_eq!(result, Some("/bar".to_string()));
}
#[test]
fn extract_attr_value_unquoted() {
let result = extract_attr_value(r"<a href=/baz>", "href");
assert_eq!(result, Some("/baz".to_string()));
}
#[test]
fn extract_attr_value_missing_attribute_returns_none() {
let result = extract_attr_value(r"<a>", "href");
assert!(result.is_none());
}
#[test]
fn strip_tags_simple_removes_html_tags_and_preserves_text() {
let result = strip_tags_simple("<p>hello <b>world</b>!</p>");
assert_eq!(result, "hello world!");
}
#[test]
fn strip_tags_simple_handles_empty_and_text_only() {
assert_eq!(strip_tags_simple(""), "");
assert_eq!(strip_tags_simple("plain text"), "plain text");
}
#[test]
fn collect_html_files_filters_non_html_extensions() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("a.html"), "").unwrap();
fs::write(dir.path().join("b.css"), "").unwrap();
let result = collect_html_files(dir.path()).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn collect_html_files_skips_non_directories_in_stack() {
let dir = tempdir().unwrap();
let result = collect_html_files(&dir.path().join("missing")).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_plugin_writes_report() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
fs::write(
site.join("index.html"),
r#"<html><head></head><body><main><img src="x.jpg"></main></body></html>"#,
)
.unwrap();
let ctx = test_ctx(&site);
AccessibilityPlugin.after_compile(&ctx).unwrap();
let report_path = site.join("accessibility-report.json");
assert!(report_path.exists());
let content = fs::read_to_string(&report_path).unwrap();
let report: AccessibilityReport =
serde_json::from_str(&content).unwrap();
assert_eq!(report.pages_scanned, 1);
assert!(report.total_issues > 0);
assert_eq!(report.wcag_version, "2.2");
}
#[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></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></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 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");
}
#[test]
fn test_compliance_matrix_emitted() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
fs::write(
site.join("index.html"),
r#"<html lang="en"><head></head><body><main>
<h1>OK</h1>
<a href="/contact">Contact</a>
</main></body></html>"#,
)
.unwrap();
let ctx = test_ctx(&site);
AccessibilityPlugin.after_compile(&ctx).unwrap();
let matrix_path = site.join("wcag-compliance.json");
assert!(matrix_path.exists());
let content = fs::read_to_string(&matrix_path).unwrap();
let matrix: WcagComplianceReport =
serde_json::from_str(&content).unwrap();
assert_eq!(matrix.wcag_version, "2.2");
assert_eq!(matrix.pages_scanned, 1);
let names: Vec<&str> = matrix
.criteria
.iter()
.map(|c| c.criterion.as_str())
.collect();
assert!(names.contains(&"2.4.13"));
assert!(names.contains(&"2.5.8"));
assert!(names.contains(&"3.2.6"));
}
#[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></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></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></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 parse_top_level_rules_skips_empty_selector() {
let rules = parse_top_level_rules("{ width: 10px; }");
assert!(rules.is_empty());
}
#[test]
fn strip_at_rules_handles_nested_media() {
let css = "a { color: red } @media print { a { color: blue } } b { color: green }";
let stripped = strip_at_rules(css);
assert!(stripped.contains("a { color: red }"));
assert!(stripped.contains("b { color: green }"));
assert!(!stripped.contains("@media"));
assert!(!stripped.contains("blue"));
}
#[test]
fn strip_css_comments_removes_block_comments() {
let css = "a { /* hidden */ color: red; }";
let stripped = strip_css_comments(css);
assert!(!stripped.contains("hidden"));
assert!(stripped.contains("color: red"));
}
#[test]
fn strip_css_comments_handles_unterminated_comment() {
let css = "a { /* never closes";
let _ = strip_css_comments(css);
}
#[test]
fn extract_all_style_blocks_returns_every_block() {
let html =
"<html><head><style>x{}</style><style>y{}</style></head></html>";
let blocks = extract_all_style_blocks(html);
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].trim(), "x{}");
assert_eq!(blocks[1].trim(), "y{}");
}
#[test]
fn extract_all_style_blocks_handles_attributes_with_quoted_gt() {
let html =
r#"<html><head><style data-tag="x>y">a{}</style></head></html>"#;
let blocks = extract_all_style_blocks(html);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].trim(), "a{}");
}
}