Skip to main content

ssg/plugins/
accessibility.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Automated WCAG accessibility checker and ARIA validation plugin.
5//!
6//! This is a thin SSG-side wrapper around the standalone
7//! [`ssg-a11y`](https://docs.rs/ssg-a11y) crate, which contains all of
8//! the actual WCAG 2.2 rule-checking logic (that crate has zero
9//! dependency on SSG's [`Plugin`] trait, [`PluginContext`], or
10//! [`SsgError`], so it can be reused by other Rust web frameworks). This
11//! module's job is purely the SSG integration: walking the compiled
12//! site's HTML files, calling into `ssg_a11y::check_page` for each one,
13//! and writing the two build artifacts:
14//!
15//! - `accessibility-report.json` — issue list per page (existing format).
16//! - `wcag-compliance.json` — compliance matrix mapping each WCAG 2.2
17//!   criterion to its automation status (automated / runtime-only /
18//!   manual / not-applicable) plus a per-page pass/fail summary.
19//!
20//! See the [`ssg_a11y`] crate docs for the full list of WCAG criteria
21//! checked.
22
23use crate::error::{PathErrorExt, SsgError};
24use crate::plugin::{Plugin, PluginContext};
25use serde::Serialize;
26use std::fs;
27
28// Re-exported so `ssg::accessibility::{AccessibilityReport, ...}` keeps
29// working unchanged for existing consumers — the types themselves now
30// live in the standalone `ssg-a11y` crate.
31pub use ssg_a11y::{
32    AccessibilityIssue, AccessibilityReport, CriterionEntry, CriterionStatus,
33    PageReport, WcagComplianceReport,
34};
35
36/// Plugin that checks generated HTML for WCAG compliance.
37///
38/// Runs in `after_compile`. Non-blocking by default (logs warnings).
39#[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        // Per-criterion fail set, used to populate the compliance matrix.
61        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        // Write the per-page issue report.
89        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        // Write the WCAG 2.2 compliance matrix.
94        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
123/// Serialises a report artifact as pretty-printed JSON, mapping any
124/// serialisation failure onto [`SsgError::Io`] keyed by the artifact
125/// path it was destined for.
126fn 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    // -------------------------------------------------------------------
166    // Plugin trait surface
167    // -------------------------------------------------------------------
168
169    #[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        // The `!ctx.site_dir.exists()` early return.
177        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        // The `else` branch logging "All N pages passed".
188        // Requires a site with at least one clean page.
189        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        // Report should exist and show zero issues.
204        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    // -------------------------------------------------------------------
213    // collect_html_files — depth guard + non-html filter
214    // -------------------------------------------------------------------
215
216    #[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        // Covered by the normal tempdir walk.
228        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        // The matrix carries every WCAG 2.2 row we listed in
286        // build_compliance_report, including the three additions.
287        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        // Create a file where it expects the site directory to be.
303        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    // ── to_pretty_json ──────────────────────────────────────────────
316
317    #[test]
318    #[serial_test::parallel(accessibility_failpoint)]
319    fn to_pretty_json_maps_serde_failure_to_io_error() {
320        // JSON object keys must be strings — a tuple-keyed map makes
321        // `serde_json::to_string_pretty` fail, driving the error arm.
322        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        // The issue report writes fine, but `wcag-compliance.json`
335        // already exists as a *directory* so the second write fails.
336        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        // Restore permissions before asserting so cleanup always works.
365        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    /// RAII guard that disables a failpoint on drop.
379    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        // First call (issue report) succeeds, second (matrix) fails.
413        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}