use crate::error::{PathErrorExt, SsgError};
use crate::plugin::{Plugin, PluginContext};
use serde::Serialize;
use std::fs;
pub use ssg_a11y::{
AccessibilityIssue, AccessibilityReport, CriterionEntry, CriterionStatus,
PageReport, WcagComplianceReport,
};
#[derive(Debug, Clone, Copy)]
pub struct AccessibilityPlugin;
impl Plugin for AccessibilityPlugin {
fn name(&self) -> &'static str {
"accessibility"
}
fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
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).with_path(path)?;
let rel = path
.strip_prefix(&ctx.site_dir)
.unwrap_or(path)
.to_string_lossy()
.to_string();
let issues = ssg_a11y::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 = to_pretty_json(&report, &report_path)?;
fs::write(&report_path, json).with_path(&report_path)?;
let compliance = ssg_a11y::build_compliance_report(
html_files.len(),
&failed_criteria,
);
let matrix_path = ctx.site_dir.join("wcag-compliance.json");
let json_compliance = to_pretty_json(&compliance, &matrix_path)?;
fs::write(&matrix_path, json_compliance).with_path(&matrix_path)?;
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 to_pretty_json<T: Serialize>(
value: &T,
path: &std::path::Path,
) -> Result<String, SsgError> {
fail_point!("accessibility::to-json", |_| {
Err(SsgError::Io {
path: path.to_path_buf(),
source: std::io::Error::other("injected: accessibility::to-json"),
})
});
serde_json::to_string_pretty(value).map_err(|e| SsgError::Io {
path: path.to_path_buf(),
source: std::io::Error::other(e),
})
}
#[cfg(test)]
fn collect_html_files(
dir: &std::path::Path,
) -> Result<Vec<std::path::PathBuf>, SsgError> {
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 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]
#[serial_test::parallel(accessibility_failpoint)]
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 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]
#[serial_test::parallel(accessibility_failpoint)]
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]
#[serial_test::parallel(accessibility_failpoint)]
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]
#[serial_test::parallel(accessibility_failpoint)]
fn after_compile_write_failure_returns_io_error() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("site");
fs::write(&file_path, "").unwrap();
let ctx = test_ctx(&file_path);
let res = AccessibilityPlugin.after_compile(&ctx);
assert!(res.is_err());
let err = res.unwrap_err();
assert!(
matches!(err, SsgError::Io { ref path, .. } if path == &file_path.join("accessibility-report.json"))
);
}
#[test]
#[serial_test::parallel(accessibility_failpoint)]
fn to_pretty_json_maps_serde_failure_to_io_error() {
let bad: std::collections::BTreeMap<(u8, u8), u8> =
std::iter::once(((1, 2), 3)).collect();
let err = to_pretty_json(&bad, Path::new("artifact.json"))
.expect_err("non-string map keys must fail serialisation");
assert!(
matches!(err, SsgError::Io { ref path, .. } if path == Path::new("artifact.json"))
);
}
#[test]
#[serial_test::parallel(accessibility_failpoint)]
fn after_compile_matrix_write_failure_returns_io_error() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("wcag-compliance.json")).unwrap();
let ctx = test_ctx(dir.path());
let err = AccessibilityPlugin.after_compile(&ctx).unwrap_err();
assert!(
matches!(err, SsgError::Io { ref path, .. } if path == &dir.path().join("wcag-compliance.json"))
);
assert!(
dir.path().join("accessibility-report.json").exists(),
"issue report must have been written before the failure"
);
}
#[cfg(unix)]
#[test]
#[serial_test::parallel(accessibility_failpoint)]
fn after_compile_unreadable_page_returns_io_error() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let page = dir.path().join("index.html");
fs::write(&page, "<html lang=\"en\"><body></body></html>").unwrap();
fs::set_permissions(&page, fs::Permissions::from_mode(0o000)).unwrap();
let ctx = test_ctx(dir.path());
let res = AccessibilityPlugin.after_compile(&ctx);
fs::set_permissions(&page, fs::Permissions::from_mode(0o644)).unwrap();
let err = res.expect_err("unreadable page must abort the scan");
assert!(matches!(err, SsgError::Io { ref path, .. } if path == &page));
}
}
#[cfg(all(test, feature = "test-fault-injection"))]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod fault_tests {
use super::*;
use std::path::Path;
use tempfile::tempdir;
struct FailGuard<'a>(&'a str);
impl Drop for FailGuard<'_> {
fn drop(&mut self) {
let _ = fail::cfg(self.0, "off");
}
}
fn ctx_for(dir: &Path) -> PluginContext {
PluginContext::new(
Path::new("content"),
Path::new("build"),
dir,
Path::new("templates"),
)
}
#[test]
#[serial_test::serial(accessibility_failpoint)]
fn report_serialisation_failure_aborts_after_compile() {
let _guard = FailGuard("accessibility::to-json");
fail::cfg("accessibility::to-json", "return").unwrap();
let dir = tempdir().unwrap();
let err = AccessibilityPlugin
.after_compile(&ctx_for(dir.path()))
.expect_err("first serialisation must fail");
assert!(err.to_string().contains("accessibility-report.json"));
}
#[test]
#[serial_test::serial(accessibility_failpoint)]
fn matrix_serialisation_failure_aborts_after_compile() {
let _guard = FailGuard("accessibility::to-json");
fail::cfg("accessibility::to-json", "1*off->1*return").unwrap();
let dir = tempdir().unwrap();
let err = AccessibilityPlugin
.after_compile(&ctx_for(dir.path()))
.expect_err("second serialisation must fail");
assert!(err.to_string().contains("wcag-compliance.json"));
}
}