1use crate::error::{PathErrorExt, SsgError};
24use crate::plugin::{Plugin, PluginContext};
25use serde::Serialize;
26use std::fs;
27
28pub use ssg_a11y::{
32 AccessibilityIssue, AccessibilityReport, CriterionEntry, CriterionStatus,
33 PageReport, WcagComplianceReport,
34};
35
36#[derive(Debug, Clone, Copy)]
40pub struct AccessibilityPlugin;
41
42impl Plugin for AccessibilityPlugin {
43 fn name(&self) -> &'static str {
44 "accessibility"
45 }
46
47 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
48 if !ctx.site_dir.exists() {
49 return Ok(());
50 }
51
52 let html_files = ctx.get_html_files();
53 let mut report = AccessibilityReport {
54 pages_scanned: html_files.len(),
55 total_issues: 0,
56 wcag_version: "2.2".to_string(),
57 pages: Vec::new(),
58 };
59
60 let mut failed_criteria: std::collections::HashSet<String> =
62 std::collections::HashSet::new();
63
64 for path in &html_files {
65 let html = fs::read_to_string(path).with_path(path)?;
66 let rel = path
67 .strip_prefix(&ctx.site_dir)
68 .unwrap_or(path)
69 .to_string_lossy()
70 .to_string();
71
72 let issues = ssg_a11y::check_page(&html);
73 if !issues.is_empty() {
74 for issue in &issues {
75 let _ = failed_criteria.insert(issue.criterion.clone());
76 log::warn!(
77 "[a11y] {} — [{}] {}",
78 rel,
79 issue.criterion,
80 issue.message
81 );
82 }
83 report.total_issues += issues.len();
84 report.pages.push(PageReport { path: rel, issues });
85 }
86 }
87
88 let report_path = ctx.site_dir.join("accessibility-report.json");
90 let json = to_pretty_json(&report, &report_path)?;
91 fs::write(&report_path, json).with_path(&report_path)?;
92
93 let compliance = ssg_a11y::build_compliance_report(
95 html_files.len(),
96 &failed_criteria,
97 );
98 let matrix_path = ctx.site_dir.join("wcag-compliance.json");
99 let json_compliance = to_pretty_json(&compliance, &matrix_path)?;
100 fs::write(&matrix_path, json_compliance).with_path(&matrix_path)?;
101
102 if report.total_issues > 0 {
103 log::warn!(
104 "[a11y] {} issue(s) across {} page(s). Reports: {} + {}",
105 report.total_issues,
106 report.pages.len(),
107 report_path.display(),
108 matrix_path.display()
109 );
110 } else {
111 log::info!(
112 "[a11y] All {} page(s) passed checks. Reports: {} + {}",
113 report.pages_scanned,
114 report_path.display(),
115 matrix_path.display()
116 );
117 }
118
119 Ok(())
120 }
121}
122
123fn to_pretty_json<T: Serialize>(
127 value: &T,
128 path: &std::path::Path,
129) -> Result<String, SsgError> {
130 fail_point!("accessibility::to-json", |_| {
131 Err(SsgError::Io {
132 path: path.to_path_buf(),
133 source: std::io::Error::other("injected: accessibility::to-json"),
134 })
135 });
136 serde_json::to_string_pretty(value).map_err(|e| SsgError::Io {
137 path: path.to_path_buf(),
138 source: std::io::Error::other(e),
139 })
140}
141
142#[cfg(test)]
143fn collect_html_files(
144 dir: &std::path::Path,
145) -> Result<Vec<std::path::PathBuf>, SsgError> {
146 crate::walk::walk_files(dir, "html")
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use std::path::Path;
153 use tempfile::tempdir;
154
155 fn test_ctx(site_dir: &Path) -> PluginContext {
156 crate::test_support::init_logger();
157 PluginContext::new(
158 Path::new("content"),
159 Path::new("build"),
160 site_dir,
161 Path::new("templates"),
162 )
163 }
164
165 #[test]
170 fn name_returns_static_accessibility_identifier() {
171 assert_eq!(AccessibilityPlugin.name(), "accessibility");
172 }
173
174 #[test]
175 fn after_compile_missing_site_dir_returns_ok_without_writing() {
176 let dir = tempdir().unwrap();
178 let missing = dir.path().join("missing");
179 let ctx = test_ctx(&missing);
180 AccessibilityPlugin.after_compile(&ctx).unwrap();
181 assert!(!missing.join("accessibility-report.json").exists());
182 }
183
184 #[test]
185 #[serial_test::parallel(accessibility_failpoint)]
186 fn after_compile_clean_pages_logs_all_passed() {
187 let dir = tempdir().unwrap();
190 let site = dir.path().join("site");
191 fs::create_dir_all(&site).unwrap();
192 fs::write(
193 site.join("index.html"),
194 r#"<html lang="en"><head></head><body>
195 <nav aria-label="Main"><a href="/">Home</a></nav>
196 <main><h1>T</h1><img src="a.jpg" alt="A"></main>
197 </body></html>"#,
198 )
199 .unwrap();
200
201 let ctx = test_ctx(&site);
202 AccessibilityPlugin.after_compile(&ctx).unwrap();
203 let report: AccessibilityReport = serde_json::from_str(
205 &fs::read_to_string(site.join("accessibility-report.json"))
206 .unwrap(),
207 )
208 .unwrap();
209 assert_eq!(report.total_issues, 0);
210 }
211
212 #[test]
217 fn collect_html_files_filters_non_html_extensions() {
218 let dir = tempdir().unwrap();
219 fs::write(dir.path().join("a.html"), "").unwrap();
220 fs::write(dir.path().join("b.css"), "").unwrap();
221 let result = collect_html_files(dir.path()).unwrap();
222 assert_eq!(result.len(), 1);
223 }
224
225 #[test]
226 fn collect_html_files_skips_non_directories_in_stack() {
227 let dir = tempdir().unwrap();
229 let result = collect_html_files(&dir.path().join("missing")).unwrap();
230 assert!(result.is_empty());
231 }
232
233 #[test]
234 #[serial_test::parallel(accessibility_failpoint)]
235 fn test_plugin_writes_report() {
236 let dir = tempdir().unwrap();
237 let site = dir.path().join("site");
238 fs::create_dir_all(&site).unwrap();
239 fs::write(
240 site.join("index.html"),
241 r#"<html><head></head><body><main><img src="x.jpg"></main></body></html>"#,
242 )
243 .unwrap();
244
245 let ctx = test_ctx(&site);
246 AccessibilityPlugin.after_compile(&ctx).unwrap();
247
248 let report_path = site.join("accessibility-report.json");
249 assert!(report_path.exists());
250
251 let content = fs::read_to_string(&report_path).unwrap();
252 let report: AccessibilityReport =
253 serde_json::from_str(&content).unwrap();
254 assert_eq!(report.pages_scanned, 1);
255 assert!(report.total_issues > 0);
256 assert_eq!(report.wcag_version, "2.2");
257 }
258
259 #[test]
260 #[serial_test::parallel(accessibility_failpoint)]
261 fn test_compliance_matrix_emitted() {
262 let dir = tempdir().unwrap();
263 let site = dir.path().join("site");
264 fs::create_dir_all(&site).unwrap();
265 fs::write(
266 site.join("index.html"),
267 r#"<html lang="en"><head></head><body><main>
268 <h1>OK</h1>
269 <a href="/contact">Contact</a>
270 </main></body></html>"#,
271 )
272 .unwrap();
273
274 let ctx = test_ctx(&site);
275 AccessibilityPlugin.after_compile(&ctx).unwrap();
276
277 let matrix_path = site.join("wcag-compliance.json");
278 assert!(matrix_path.exists());
279
280 let content = fs::read_to_string(&matrix_path).unwrap();
281 let matrix: WcagComplianceReport =
282 serde_json::from_str(&content).unwrap();
283 assert_eq!(matrix.wcag_version, "2.2");
284 assert_eq!(matrix.pages_scanned, 1);
285 let names: Vec<&str> = matrix
288 .criteria
289 .iter()
290 .map(|c| c.criterion.as_str())
291 .collect();
292 assert!(names.contains(&"2.4.13"));
293 assert!(names.contains(&"2.5.8"));
294 assert!(names.contains(&"3.2.6"));
295 }
296
297 #[test]
298 #[serial_test::parallel(accessibility_failpoint)]
299 fn after_compile_write_failure_returns_io_error() {
300 let dir = tempdir().unwrap();
301
302 let file_path = dir.path().join("site");
304 fs::write(&file_path, "").unwrap();
305
306 let ctx = test_ctx(&file_path);
307 let res = AccessibilityPlugin.after_compile(&ctx);
308 assert!(res.is_err());
309 let err = res.unwrap_err();
310 assert!(
311 matches!(err, SsgError::Io { ref path, .. } if path == &file_path.join("accessibility-report.json"))
312 );
313 }
314
315 #[test]
318 #[serial_test::parallel(accessibility_failpoint)]
319 fn to_pretty_json_maps_serde_failure_to_io_error() {
320 let bad: std::collections::BTreeMap<(u8, u8), u8> =
323 std::iter::once(((1, 2), 3)).collect();
324 let err = to_pretty_json(&bad, Path::new("artifact.json"))
325 .expect_err("non-string map keys must fail serialisation");
326 assert!(
327 matches!(err, SsgError::Io { ref path, .. } if path == Path::new("artifact.json"))
328 );
329 }
330
331 #[test]
332 #[serial_test::parallel(accessibility_failpoint)]
333 fn after_compile_matrix_write_failure_returns_io_error() {
334 let dir = tempdir().unwrap();
337 fs::create_dir_all(dir.path().join("wcag-compliance.json")).unwrap();
338
339 let ctx = test_ctx(dir.path());
340 let err = AccessibilityPlugin.after_compile(&ctx).unwrap_err();
341 assert!(
342 matches!(err, SsgError::Io { ref path, .. } if path == &dir.path().join("wcag-compliance.json"))
343 );
344 assert!(
345 dir.path().join("accessibility-report.json").exists(),
346 "issue report must have been written before the failure"
347 );
348 }
349
350 #[cfg(unix)]
351 #[test]
352 #[serial_test::parallel(accessibility_failpoint)]
353 fn after_compile_unreadable_page_returns_io_error() {
354 use std::os::unix::fs::PermissionsExt;
355
356 let dir = tempdir().unwrap();
357 let page = dir.path().join("index.html");
358 fs::write(&page, "<html lang=\"en\"><body></body></html>").unwrap();
359 fs::set_permissions(&page, fs::Permissions::from_mode(0o000)).unwrap();
360
361 let ctx = test_ctx(dir.path());
362 let res = AccessibilityPlugin.after_compile(&ctx);
363
364 fs::set_permissions(&page, fs::Permissions::from_mode(0o644)).unwrap();
366
367 let err = res.expect_err("unreadable page must abort the scan");
368 assert!(matches!(err, SsgError::Io { ref path, .. } if path == &page));
369 }
370}
371
372#[cfg(all(test, feature = "test-fault-injection"))]
373mod fault_tests {
374 use super::*;
375 use std::path::Path;
376 use tempfile::tempdir;
377
378 struct FailGuard<'a>(&'a str);
380
381 impl Drop for FailGuard<'_> {
382 fn drop(&mut self) {
383 let _ = fail::cfg(self.0, "off");
384 }
385 }
386
387 fn ctx_for(dir: &Path) -> PluginContext {
388 PluginContext::new(
389 Path::new("content"),
390 Path::new("build"),
391 dir,
392 Path::new("templates"),
393 )
394 }
395
396 #[test]
397 #[serial_test::serial(accessibility_failpoint)]
398 fn report_serialisation_failure_aborts_after_compile() {
399 let _guard = FailGuard("accessibility::to-json");
400 fail::cfg("accessibility::to-json", "return").unwrap();
401
402 let dir = tempdir().unwrap();
403 let err = AccessibilityPlugin
404 .after_compile(&ctx_for(dir.path()))
405 .expect_err("first serialisation must fail");
406 assert!(err.to_string().contains("accessibility-report.json"));
407 }
408
409 #[test]
410 #[serial_test::serial(accessibility_failpoint)]
411 fn matrix_serialisation_failure_aborts_after_compile() {
412 let _guard = FailGuard("accessibility::to-json");
414 fail::cfg("accessibility::to-json", "1*off->1*return").unwrap();
415
416 let dir = tempdir().unwrap();
417 let err = AccessibilityPlugin
418 .after_compile(&ctx_for(dir.path()))
419 .expect_err("second serialisation must fail");
420 assert!(err.to_string().contains("wcag-compliance.json"));
421 }
422}