1use crate::plugin::{Plugin, PluginContext};
31use anyhow::Result;
32use serde::Serialize;
33use std::fs;
34
35#[derive(Debug, Clone, Serialize, serde::Deserialize)]
37pub struct AccessibilityIssue {
38 pub criterion: String,
40 pub severity: String,
42 pub message: String,
44}
45
46#[derive(Debug, Clone, Serialize, serde::Deserialize)]
48pub struct PageReport {
49 pub path: String,
51 pub issues: Vec<AccessibilityIssue>,
53}
54
55#[derive(Debug, Clone, Serialize, serde::Deserialize)]
57pub struct AccessibilityReport {
58 pub pages_scanned: usize,
60 pub total_issues: usize,
62 #[serde(default = "default_wcag_version")]
64 pub wcag_version: String,
65 pub pages: Vec<PageReport>,
67}
68
69fn default_wcag_version() -> String {
70 "2.2".to_string()
71}
72
73#[derive(Debug, Clone, Copy, Serialize, serde::Deserialize)]
75#[serde(rename_all = "kebab-case")]
76#[non_exhaustive]
77pub enum CriterionStatus {
78 Automated,
80 Runtime,
82 Manual,
84 NotApplicable,
86}
87
88#[derive(Debug, Clone, Serialize, serde::Deserialize)]
90pub struct CriterionEntry {
91 pub criterion: String,
93 pub level: String,
95 pub title: String,
97 pub status: CriterionStatus,
99 pub all_pages_pass: bool,
101}
102
103#[derive(Debug, Clone, Serialize, serde::Deserialize)]
105pub struct WcagComplianceReport {
106 pub wcag_version: String,
108 pub pages_scanned: usize,
110 pub criteria: Vec<CriterionEntry>,
112}
113
114#[derive(Debug, Clone, Copy)]
118pub struct AccessibilityPlugin;
119
120impl Plugin for AccessibilityPlugin {
121 fn name(&self) -> &'static str {
122 "accessibility"
123 }
124
125 fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
126 if !ctx.site_dir.exists() {
127 return Ok(());
128 }
129
130 let html_files = ctx.get_html_files();
131 let mut report = AccessibilityReport {
132 pages_scanned: html_files.len(),
133 total_issues: 0,
134 wcag_version: "2.2".to_string(),
135 pages: Vec::new(),
136 };
137
138 let mut failed_criteria: std::collections::HashSet<String> =
140 std::collections::HashSet::new();
141
142 for path in &html_files {
143 let html = fs::read_to_string(path)?;
144 let rel = path
145 .strip_prefix(&ctx.site_dir)
146 .unwrap_or(path)
147 .to_string_lossy()
148 .to_string();
149
150 let issues = check_page(&html);
151 if !issues.is_empty() {
152 for issue in &issues {
153 let _ = failed_criteria.insert(issue.criterion.clone());
154 log::warn!(
155 "[a11y] {} — [{}] {}",
156 rel,
157 issue.criterion,
158 issue.message
159 );
160 }
161 report.total_issues += issues.len();
162 report.pages.push(PageReport { path: rel, issues });
163 }
164 }
165
166 let report_path = ctx.site_dir.join("accessibility-report.json");
168 let json = serde_json::to_string_pretty(&report)?;
169 fs::write(&report_path, json)?;
170
171 let compliance =
173 build_compliance_report(html_files.len(), &failed_criteria);
174 let matrix_path = ctx.site_dir.join("wcag-compliance.json");
175 fs::write(&matrix_path, serde_json::to_string_pretty(&compliance)?)?;
176
177 if report.total_issues > 0 {
178 log::warn!(
179 "[a11y] {} issue(s) across {} page(s). Reports: {} + {}",
180 report.total_issues,
181 report.pages.len(),
182 report_path.display(),
183 matrix_path.display()
184 );
185 } else {
186 log::info!(
187 "[a11y] All {} page(s) passed checks. Reports: {} + {}",
188 report.pages_scanned,
189 report_path.display(),
190 matrix_path.display()
191 );
192 }
193
194 Ok(())
195 }
196}
197
198fn build_compliance_report(
201 pages_scanned: usize,
202 failed: &std::collections::HashSet<String>,
203) -> WcagComplianceReport {
204 use CriterionStatus::{Automated, Manual, NotApplicable, Runtime};
205 let did_pass = |sc: &str| !failed.contains(sc);
206 let row = |sc: &str, level: &str, title: &str, status: CriterionStatus| {
207 CriterionEntry {
208 criterion: sc.to_string(),
209 level: level.to_string(),
210 title: title.to_string(),
211 status,
212 all_pages_pass: matches!(status, Automated) && did_pass(sc),
213 }
214 };
215
216 let criteria = vec![
217 row("1.1.1", "A", "Non-text Content", Automated),
219 row("1.3.1", "A", "Info and Relationships", Automated),
220 row("1.4.3", "AA", "Contrast (Minimum)", Runtime),
221 row("1.4.10", "AA", "Reflow", Runtime),
222 row("1.4.11", "AA", "Non-text Contrast", Runtime),
223 row("1.4.12", "AA", "Text Spacing", Runtime),
224 row("2.3.1", "A", "Three Flashes or Below Threshold", Automated),
226 row("2.4.4", "A", "Link Purpose (In Context)", Automated),
227 row("2.4.11", "AA", "Focus Not Obscured (Minimum)", Runtime),
228 row("2.4.13", "AAA", "Focus Appearance", Automated),
229 row("2.5.7", "AA", "Dragging Movements", Manual),
230 row("2.5.8", "AA", "Target Size (Minimum)", Automated),
231 row("3.1.1", "A", "Language of Page", Automated),
233 row("3.2.6", "A", "Consistent Help", Manual),
236 row("3.3.7", "A", "Redundant Entry", NotApplicable),
237 row(
238 "3.3.8",
239 "AA",
240 "Accessible Authentication (Minimum)",
241 NotApplicable,
242 ),
243 row("4.1.3", "AA", "Status Messages", Runtime),
245 ];
246
247 WcagComplianceReport {
248 wcag_version: "2.2".to_string(),
249 pages_scanned,
250 criteria,
251 }
252}
253
254fn check_page(html: &str) -> Vec<AccessibilityIssue> {
256 let mut issues = Vec::new();
257
258 check_img_alt(html, &mut issues);
260
261 check_html_lang(html, &mut issues);
263
264 check_link_text(html, &mut issues);
266
267 check_heading_hierarchy(html, &mut issues);
269
270 check_banned_elements(html, &mut issues);
272
273 check_aria_landmarks(html, &mut issues);
275
276 check_target_size(html, &mut issues);
281
282 check_focus_appearance(html, &mut issues);
285
286 let _ = check_consistent_help; issues
293}
294
295fn check_target_size(html: &str, issues: &mut Vec<AccessibilityIssue>) {
304 for css in extract_all_style_blocks(html) {
305 let cleaned = preprocess_css(&css);
306 for (selector, body) in parse_top_level_rules(&cleaned) {
307 if !selector_targets_interactive(&selector) {
308 continue;
309 }
310 for prop in ["width", "height"] {
311 if let Some(px) = first_px_value(&body, prop) {
312 if px > 0 && px < 24 {
313 issues.push(AccessibilityIssue {
314 criterion: "2.5.8".to_string(),
315 severity: "warning".to_string(),
316 message: format!(
317 "Target size {prop}={px}px on `{selector}` \
318 is below the 24×24 minimum (WCAG 2.2 AA)"
319 ),
320 });
321 }
322 }
323 }
324 }
325 }
326}
327
328fn selector_targets_interactive(selector: &str) -> bool {
331 selector.contains("button")
332 || selector.contains("input")
333 || selector.contains("[role=\"button\"]")
334 || selector.contains("[role='button']")
335 || selector.contains("[role=button]")
336 || selector == "a"
338 || selector.starts_with("a ")
339 || selector.starts_with("a:")
340 || selector.starts_with("a.")
341 || selector.starts_with("a#")
342 || selector.starts_with("a[")
343}
344
345fn first_px_value(css: &str, prop: &str) -> Option<u32> {
347 let pat = format!("{prop}:");
348 let start = css.find(&pat)?;
349 let after = &css[start + pat.len()..];
350 let value = after.split(';').next()?.trim();
351 let digits: String =
352 value.chars().take_while(|c| c.is_ascii_digit()).collect();
353 if digits.is_empty() {
354 return None;
355 }
356 if value[digits.len()..].trim_start().starts_with("px") {
357 digits.parse().ok()
358 } else {
359 None
360 }
361}
362
363fn check_focus_appearance(html: &str, issues: &mut Vec<AccessibilityIssue>) {
369 for css in extract_all_style_blocks(html) {
370 let cleaned = preprocess_css(&css);
371 for (selector, body) in parse_top_level_rules(&cleaned) {
372 if !selector.contains(":focus") {
373 continue;
374 }
375 let kills_outline = body.contains("outline:none")
376 || body.contains("outline: none")
377 || body.contains("outline:0")
378 || body.contains("outline: 0");
379 let has_replacement = body.contains("outline-style")
380 || body.contains("outline-color")
381 || body.contains("box-shadow")
382 || body.contains("border:");
383
384 if kills_outline && !has_replacement {
385 issues.push(AccessibilityIssue {
386 criterion: "2.4.13".to_string(),
387 severity: "warning".to_string(),
388 message: "`:focus { outline: none }` without a \
389 compensating outline-style/box-shadow/border \
390 (WCAG 2.2 AAA — Focus Appearance)"
391 .to_string(),
392 });
393 }
394 }
395 }
396}
397
398fn extract_all_style_blocks(html: &str) -> Vec<String> {
420 let mut blocks = Vec::new();
421 let lower = html.to_lowercase();
422 let mut cursor = 0;
423
424 while let Some(rel_open) = lower[cursor..].find("<style") {
425 let abs_open = cursor + rel_open;
426 let tag_end = find_tag_end(&lower, abs_open);
427 cursor = tag_end;
428
429 let Some(rel_close) = lower[cursor..].find("</style>") else {
430 break;
431 };
432 blocks.push(html[cursor..cursor + rel_close].to_string());
436 cursor += rel_close + "</style>".len();
437 }
438
439 blocks
440}
441
442fn preprocess_css(css: &str) -> String {
452 let lower = css.to_lowercase();
453 let no_comments = strip_css_comments(&lower);
454 strip_at_rules(&no_comments)
455}
456
457fn strip_css_comments(css: &str) -> String {
458 let mut out = String::with_capacity(css.len());
459 let bytes = css.as_bytes();
460 let mut i = 0;
461 while i < bytes.len() {
462 if i + 1 < bytes.len() && &bytes[i..i + 2] == b"/*" {
463 i += 2;
465 while i + 1 < bytes.len() && &bytes[i..i + 2] != b"*/" {
466 i += 1;
467 }
468 i = (i + 2).min(bytes.len());
469 out.push(' ');
472 } else {
473 out.push(bytes[i] as char);
474 i += 1;
475 }
476 }
477 out
478}
479
480fn strip_at_rules(css: &str) -> String {
481 let mut out = String::with_capacity(css.len());
482 let bytes = css.as_bytes();
483 let mut i = 0;
484 while i < bytes.len() {
485 if bytes[i] == b'@' {
486 let mut j = i;
489 while j < bytes.len() && bytes[j] != b'{' && bytes[j] != b';' {
490 j += 1;
491 }
492 if j >= bytes.len() {
493 break;
494 }
495 if bytes[j] == b';' {
496 i = j + 1;
498 continue;
499 }
500 let mut depth = 0_i32;
502 let mut k = j;
503 while k < bytes.len() {
504 match bytes[k] {
505 b'{' => depth += 1,
506 b'}' => {
507 depth -= 1;
508 if depth == 0 {
509 k += 1;
510 break;
511 }
512 }
513 _ => {}
514 }
515 k += 1;
516 }
517 i = k;
518 continue;
519 }
520 out.push(bytes[i] as char);
521 i += 1;
522 }
523 out
524}
525
526fn parse_top_level_rules(css: &str) -> Vec<(String, String)> {
529 let mut rules = Vec::new();
530 let bytes = css.as_bytes();
531 let mut i = 0;
532 while i < bytes.len() {
533 let Some(open_rel) = css[i..].find('{') else {
534 break;
535 };
536 let open = i + open_rel;
537 let selector = css[i..open].trim().to_string();
538 if selector.is_empty() {
539 i = open + 1;
540 continue;
541 }
542 let mut depth = 1_i32;
545 let mut j = open + 1;
546 while j < bytes.len() {
547 match bytes[j] {
548 b'{' => depth += 1,
549 b'}' => {
550 depth -= 1;
551 if depth == 0 {
552 break;
553 }
554 }
555 _ => {}
556 }
557 j += 1;
558 }
559 let body = css[open + 1..j].to_string();
560 rules.push((selector, body));
561 i = j + 1;
562 }
563 rules
564}
565
566fn check_consistent_help(html: &str, issues: &mut Vec<AccessibilityIssue>) {
575 let lower = html.to_lowercase();
576 let has_help_link = lower.contains(">help<")
577 || lower.contains(">contact<")
578 || lower.contains(">support<")
579 || lower.contains(">faq<")
580 || lower.contains("aria-label=\"help\"")
581 || lower.contains("aria-label=\"contact\"")
582 || lower.contains("aria-label=\"support\"");
583
584 if !has_help_link {
585 issues.push(AccessibilityIssue {
586 criterion: "3.2.6".to_string(),
587 severity: "info".to_string(),
588 message: "No detectable help/contact/support link on page; \
589 verify that the site provides a consistent help \
590 mechanism across pages (WCAG 2.2 A — Consistent \
591 Help)"
592 .to_string(),
593 });
594 }
595}
596
597fn has_valid_alt(tag: &str) -> bool {
599 let has_alt_eq = tag.contains("alt=");
600 let has_alt_bare = !has_alt_eq
601 && (tag.contains(" alt ")
602 || tag.contains(" alt>")
603 || tag.ends_with(" alt"));
604 has_alt_eq || has_alt_bare
605}
606
607fn has_empty_alt(tag: &str) -> bool {
609 let has_alt_eq = tag.contains("alt=");
610 let has_alt_bare = !has_alt_eq
611 && (tag.contains(" alt ")
612 || tag.contains(" alt>")
613 || tag.ends_with(" alt"));
614 tag.contains("alt=\"\"")
615 || tag.contains("alt=''")
616 || has_alt_bare
617 || (has_alt_eq && !tag.contains("alt=\"") && !tag.contains("alt='"))
618}
619
620fn is_decorative_img(tag: &str) -> bool {
622 tag.contains("role=\"presentation\"")
623 || tag.contains("role=\"none\"")
624 || tag.contains("role='presentation'")
625 || tag.contains("role='none'")
626 || tag.contains("role=presentation")
627 || tag.contains("role=none")
628}
629
630fn find_tag_end(html: &str, tag_start: usize) -> usize {
635 let bytes = html.as_bytes();
636 let mut i = tag_start;
637 let mut quote: Option<u8> = None;
638 while i < bytes.len() {
639 let b = bytes[i];
640 match quote {
641 Some(q) if b == q => quote = None,
642 Some(_) => {}
643 None => match b {
644 b'"' | b'\'' => quote = Some(b),
645 b'>' => return i + 1,
646 _ => {}
647 },
648 }
649 i += 1;
650 }
651 bytes.len()
652}
653
654fn check_img_alt(html: &str, issues: &mut Vec<AccessibilityIssue>) {
656 let lower = html.to_lowercase();
657 let mut pos = 0;
658 while let Some(start) = lower[pos..].find("<img") {
659 let abs = pos + start;
660 let tag_end = find_tag_end(&lower, abs);
661 let tag = &lower[abs..tag_end];
662
663 if !has_valid_alt(tag)
664 || (has_empty_alt(tag) && !is_decorative_img(tag))
665 {
666 let src = extract_attr_value(&html[abs..tag_end], "src")
667 .unwrap_or_default();
668 issues.push(AccessibilityIssue {
669 criterion: "1.1.1".to_string(),
670 severity: "error".to_string(),
671 message: format!(
672 "<img> missing alt text: {}",
673 if src.is_empty() { "(no src)" } else { &src }
674 ),
675 });
676 }
677
678 pos = tag_end;
679 }
680}
681
682fn check_html_lang(html: &str, issues: &mut Vec<AccessibilityIssue>) {
684 let lower = html.to_lowercase();
685 if let Some(start) = lower.find("<html") {
686 let tag_end =
687 lower[start..].find('>').map_or(lower.len(), |e| start + e);
688 let tag = &lower[start..tag_end];
689 if !tag.contains("lang=") {
690 issues.push(AccessibilityIssue {
691 criterion: "3.1.1".to_string(),
692 severity: "error".to_string(),
693 message: "<html> missing lang attribute".to_string(),
694 });
695 }
696 }
697}
698
699fn check_link_text(html: &str, issues: &mut Vec<AccessibilityIssue>) {
701 let lower = html.to_lowercase();
702 let mut pos = 0;
703 while let Some(start) = lower[pos..].find("<a ") {
704 let abs = pos + start;
705 let close = lower[abs..].find("</a>").unwrap_or(lower.len() - abs);
706 let full = &lower[abs..abs + close];
707
708 if let Some(gt) = full.find('>') {
710 let inner = &full[gt + 1..];
711 let text = strip_tags_simple(inner);
712 let has_aria = full.contains("aria-label=");
713 let has_title = full.contains("title=");
714
715 if text.trim().is_empty() && !has_aria && !has_title {
716 let href = extract_attr_value(&html[abs..abs + close], "href")
717 .unwrap_or_default();
718 issues.push(AccessibilityIssue {
719 criterion: "2.4.4".to_string(),
720 severity: "warning".to_string(),
721 message: format!(
722 "<a> has no discernible text: href={}",
723 if href.is_empty() { "(none)" } else { &href }
724 ),
725 });
726 }
727 }
728
729 pos = abs + close.max(1);
730 }
731}
732
733fn check_heading_hierarchy(html: &str, issues: &mut Vec<AccessibilityIssue>) {
735 let lower = html.to_lowercase();
736 let mut last_level: u8 = 0;
737
738 for level in 1..=6u8 {
739 let tag = format!("<h{level}");
740 if lower.contains(&tag) {
741 if last_level > 0 && level > last_level + 1 {
742 issues.push(AccessibilityIssue {
743 criterion: "1.3.1".to_string(),
744 severity: "warning".to_string(),
745 message: format!(
746 "Heading hierarchy skips from h{last_level} to h{level}"
747 ),
748 });
749 }
750 last_level = level;
751 }
752 }
753}
754
755fn check_banned_elements(html: &str, issues: &mut Vec<AccessibilityIssue>) {
757 let lower = html.to_lowercase();
758 for tag in &["<marquee", "<blink"] {
759 if lower.contains(tag) {
760 issues.push(AccessibilityIssue {
761 criterion: "2.3.1".to_string(),
762 severity: "error".to_string(),
763 message: format!("Banned element {} found", &tag[1..]),
764 });
765 }
766 }
767}
768
769fn check_aria_landmarks(html: &str, issues: &mut Vec<AccessibilityIssue>) {
771 let lower = html.to_lowercase();
772
773 let main_count = lower.matches("<main").count();
775 if main_count == 0 {
776 issues.push(AccessibilityIssue {
777 criterion: "ARIA".to_string(),
778 severity: "warning".to_string(),
779 message: "Page has no <main> landmark".to_string(),
780 });
781 } else if main_count > 1 {
782 issues.push(AccessibilityIssue {
783 criterion: "ARIA".to_string(),
784 severity: "warning".to_string(),
785 message: format!(
786 "Page has {main_count} <main> elements (expected 1)"
787 ),
788 });
789 }
790
791 let mut pos = 0;
793 while let Some(start) = lower[pos..].find("<nav") {
794 let abs = pos + start;
795 let tag_end = lower[abs..].find('>').map_or(lower.len(), |e| abs + e);
796 let tag = &lower[abs..tag_end];
797 if !tag.contains("aria-label") && !tag.contains("aria-labelledby") {
798 issues.push(AccessibilityIssue {
799 criterion: "ARIA".to_string(),
800 severity: "warning".to_string(),
801 message: "<nav> missing aria-label".to_string(),
802 });
803 }
804 pos = tag_end;
805 }
806}
807
808fn extract_attr_value(tag: &str, attr: &str) -> Option<String> {
810 let lower = tag.to_lowercase();
811 let pattern = format!("{attr}=");
812 let start = lower.find(&pattern)?;
813 let after = &tag[start + pattern.len()..];
814 let trimmed = after.trim_start();
815 if let Some(inner) = trimmed.strip_prefix('"') {
816 let end = inner.find('"')?;
817 Some(inner[..end].to_string())
818 } else if let Some(inner) = trimmed.strip_prefix('\'') {
819 let end = inner.find('\'')?;
820 Some(inner[..end].to_string())
821 } else {
822 let end = trimmed
823 .find(|c: char| c.is_whitespace() || c == '>')
824 .unwrap_or(trimmed.len());
825 Some(trimmed[..end].to_string())
826 }
827}
828
829fn strip_tags_simple(html: &str) -> String {
831 let mut result = String::with_capacity(html.len());
832 let mut in_tag = false;
833 for ch in html.chars() {
834 if ch == '<' {
835 in_tag = true;
836 } else if ch == '>' {
837 in_tag = false;
838 } else if !in_tag {
839 result.push(ch);
840 }
841 }
842 result
843}
844
845#[cfg(test)]
846fn collect_html_files(
847 dir: &std::path::Path,
848) -> Result<Vec<std::path::PathBuf>> {
849 crate::walk::walk_files(dir, "html")
850}
851
852#[cfg(test)]
853#[allow(clippy::unwrap_used, clippy::expect_used)]
854mod tests {
855 use super::*;
856 use std::path::Path;
857 use tempfile::tempdir;
858
859 fn test_ctx(site_dir: &Path) -> PluginContext {
860 crate::test_support::init_logger();
861 PluginContext::new(
862 Path::new("content"),
863 Path::new("build"),
864 site_dir,
865 Path::new("templates"),
866 )
867 }
868
869 #[test]
870 fn test_img_alt_missing() {
871 let html = r#"<html lang="en"><head></head><body><main><img src="photo.jpg"></main></body></html>"#;
872 let issues = check_page(html);
873 assert!(issues.iter().any(|i| i.criterion == "1.1.1"));
874 }
875
876 #[test]
877 fn test_img_alt_present() {
878 let html = r#"<html lang="en"><head></head><body><main><img src="photo.jpg" alt="A photo"></main></body></html>"#;
879 let issues = check_page(html);
880 assert!(!issues.iter().any(|i| i.criterion == "1.1.1"));
881 }
882
883 #[test]
884 fn test_img_alt_with_inline_svg_data_url() {
885 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>"#;
889 let issues = check_page(html);
890 assert!(
891 !issues.iter().any(|i| i.criterion == "1.1.1"),
892 "SVG-data-url img with valid alt should not raise 1.1.1, got: {issues:?}"
893 );
894 }
895
896 #[test]
897 fn test_html_lang_missing() {
898 let html = "<html><head></head><body><main></main></body></html>";
899 let issues = check_page(html);
900 assert!(issues.iter().any(|i| i.criterion == "3.1.1"));
901 }
902
903 #[test]
904 fn test_heading_skip() {
905 let html = r#"<html lang="en"><head></head><body><main><h1>Title</h1><h3>Skip</h3></main></body></html>"#;
906 let issues = check_page(html);
907 assert!(issues.iter().any(|i| i.message.contains("skips")));
908 }
909
910 #[test]
911 fn test_banned_marquee() {
912 let html = r#"<html lang="en"><head></head><body><main><marquee>No</marquee></main></body></html>"#;
913 let issues = check_page(html);
914 assert!(issues.iter().any(|i| i.criterion == "2.3.1"));
915 }
916
917 #[test]
918 fn test_nav_without_label() {
919 let html = r#"<html lang="en"><head></head><body><nav></nav><main></main></body></html>"#;
920 let issues = check_page(html);
921 assert!(issues.iter().any(|i| i.message.contains("aria-label")));
922 }
923
924 #[test]
925 fn test_nav_with_label_passes() {
926 let html = r#"<html lang="en"><head></head><body><nav aria-label="Main"></nav><main></main></body></html>"#;
927 let issues = check_page(html);
928 assert!(!issues.iter().any(|i| i.message.contains("aria-label")));
929 }
930
931 #[test]
932 fn test_clean_page_no_issues() {
933 let html = r#"<html lang="en"><head></head><body>
934 <nav aria-label="Main"><a href="/">Home</a></nav>
935 <main><h1>Title</h1><h2>Sub</h2>
936 <img src="x.jpg" alt="Photo"></main></body></html>"#;
937 let issues = check_page(html);
938 assert!(issues.is_empty(), "Expected no issues, got: {issues:?}");
939 }
940
941 #[test]
946 fn name_returns_static_accessibility_identifier() {
947 assert_eq!(AccessibilityPlugin.name(), "accessibility");
948 }
949
950 #[test]
951 fn after_compile_missing_site_dir_returns_ok_without_writing() {
952 let dir = tempdir().unwrap();
954 let missing = dir.path().join("missing");
955 let ctx = test_ctx(&missing);
956 AccessibilityPlugin.after_compile(&ctx).unwrap();
957 assert!(!missing.join("accessibility-report.json").exists());
958 }
959
960 #[test]
961 fn after_compile_clean_pages_logs_all_passed() {
962 let dir = tempdir().unwrap();
965 let site = dir.path().join("site");
966 fs::create_dir_all(&site).unwrap();
967 fs::write(
968 site.join("index.html"),
969 r#"<html lang="en"><head></head><body>
970 <nav aria-label="Main"><a href="/">Home</a></nav>
971 <main><h1>T</h1><img src="a.jpg" alt="A"></main>
972 </body></html>"#,
973 )
974 .unwrap();
975
976 let ctx = test_ctx(&site);
977 AccessibilityPlugin.after_compile(&ctx).unwrap();
978 let report: AccessibilityReport = serde_json::from_str(
980 &fs::read_to_string(site.join("accessibility-report.json"))
981 .unwrap(),
982 )
983 .unwrap();
984 assert_eq!(report.total_issues, 0);
985 }
986
987 #[test]
992 fn check_link_text_empty_anchor_reports_issue() {
993 let html = r#"<html lang="en"><head></head><body><main>
996 <a href="/page"></a>
997 </main></body></html>"#;
998 let issues = check_page(html);
999 assert!(issues.iter().any(|i| i.criterion == "2.4.4"));
1000 }
1001
1002 #[test]
1003 fn check_link_text_empty_anchor_with_aria_label_passes() {
1004 let html = r#"<html lang="en"><head></head><body><main>
1005 <a href="/page" aria-label="Read more"></a>
1006 </main></body></html>"#;
1007 let issues = check_page(html);
1008 assert!(!issues.iter().any(|i| i.criterion == "2.4.4"));
1009 }
1010
1011 #[test]
1012 fn check_link_text_empty_anchor_with_title_passes() {
1013 let html = r#"<html lang="en"><head></head><body><main>
1014 <a href="/page" title="Read more"></a>
1015 </main></body></html>"#;
1016 let issues = check_page(html);
1017 assert!(!issues.iter().any(|i| i.criterion == "2.4.4"));
1018 }
1019
1020 #[test]
1021 fn check_link_text_empty_anchor_with_no_href_reports_issue() {
1022 let html = r#"<html lang="en"><head></head><body><main>
1027 <a ></a>
1028 </main></body></html>"#;
1029 let _ = check_page(html);
1030 }
1031
1032 #[test]
1037 fn check_aria_landmarks_no_main_element_reports_issue() {
1038 let html = r#"<html lang="en"><head></head><body>
1040 <div>no main landmark here</div>
1041 </body></html>"#;
1042 let issues = check_page(html);
1043 assert!(issues
1044 .iter()
1045 .any(|i| i.message.contains("no <main> landmark")));
1046 }
1047
1048 #[test]
1049 fn check_aria_landmarks_multiple_main_elements_reports_issue() {
1050 let html = r#"<html lang="en"><head></head><body>
1052 <main>first</main>
1053 <main>second</main>
1054 </body></html>"#;
1055 let issues = check_page(html);
1056 assert!(issues
1057 .iter()
1058 .any(|i| i.message.contains("2 <main> elements")));
1059 }
1060
1061 #[test]
1066 fn extract_attr_value_double_quoted() {
1067 let result = extract_attr_value(r#"<a href="/foo">"#, "href");
1068 assert_eq!(result, Some("/foo".to_string()));
1069 }
1070
1071 #[test]
1072 fn extract_attr_value_single_quoted() {
1073 let result = extract_attr_value(r"<a href='/bar'>", "href");
1075 assert_eq!(result, Some("/bar".to_string()));
1076 }
1077
1078 #[test]
1079 fn extract_attr_value_unquoted() {
1080 let result = extract_attr_value(r"<a href=/baz>", "href");
1083 assert_eq!(result, Some("/baz".to_string()));
1084 }
1085
1086 #[test]
1087 fn extract_attr_value_missing_attribute_returns_none() {
1088 let result = extract_attr_value(r"<a>", "href");
1089 assert!(result.is_none());
1090 }
1091
1092 #[test]
1097 fn strip_tags_simple_removes_html_tags_and_preserves_text() {
1098 let result = strip_tags_simple("<p>hello <b>world</b>!</p>");
1100 assert_eq!(result, "hello world!");
1101 }
1102
1103 #[test]
1104 fn strip_tags_simple_handles_empty_and_text_only() {
1105 assert_eq!(strip_tags_simple(""), "");
1106 assert_eq!(strip_tags_simple("plain text"), "plain text");
1107 }
1108
1109 #[test]
1114 fn collect_html_files_filters_non_html_extensions() {
1115 let dir = tempdir().unwrap();
1116 fs::write(dir.path().join("a.html"), "").unwrap();
1117 fs::write(dir.path().join("b.css"), "").unwrap();
1118 let result = collect_html_files(dir.path()).unwrap();
1119 assert_eq!(result.len(), 1);
1120 }
1121
1122 #[test]
1123 fn collect_html_files_skips_non_directories_in_stack() {
1124 let dir = tempdir().unwrap();
1127 let result = collect_html_files(&dir.path().join("missing")).unwrap();
1128 assert!(result.is_empty());
1129 }
1130
1131 #[test]
1132 fn test_plugin_writes_report() {
1133 let dir = tempdir().unwrap();
1134 let site = dir.path().join("site");
1135 fs::create_dir_all(&site).unwrap();
1136 fs::write(
1137 site.join("index.html"),
1138 r#"<html><head></head><body><main><img src="x.jpg"></main></body></html>"#,
1139 )
1140 .unwrap();
1141
1142 let ctx = test_ctx(&site);
1143 AccessibilityPlugin.after_compile(&ctx).unwrap();
1144
1145 let report_path = site.join("accessibility-report.json");
1146 assert!(report_path.exists());
1147
1148 let content = fs::read_to_string(&report_path).unwrap();
1149 let report: AccessibilityReport =
1150 serde_json::from_str(&content).unwrap();
1151 assert_eq!(report.pages_scanned, 1);
1152 assert!(report.total_issues > 0);
1153 assert_eq!(report.wcag_version, "2.2");
1154 }
1155
1156 #[test]
1159 fn test_target_size_below_minimum_flagged() {
1160 let html = r#"<html lang="en"><head><style>
1161 button { width: 16px; height: 16px; }
1162 </style></head><body><main></main></body></html>"#;
1163 let issues = check_page(html);
1164 assert!(
1165 issues.iter().any(|i| i.criterion == "2.5.8"),
1166 "expected 2.5.8 issue for 16px button, got {issues:?}"
1167 );
1168 }
1169
1170 #[test]
1171 fn test_target_size_compliant_passes() {
1172 let html = r#"<html lang="en"><head><style>
1173 button { width: 32px; height: 32px; }
1174 </style></head><body><main></main></body></html>"#;
1175 let issues: Vec<_> = check_page(html)
1176 .into_iter()
1177 .filter(|i| i.criterion == "2.5.8")
1178 .collect();
1179 assert!(
1180 issues.is_empty(),
1181 "32px button should not trigger 2.5.8, got {issues:?}"
1182 );
1183 }
1184
1185 #[test]
1186 fn test_focus_appearance_outline_none_flagged() {
1187 let html = r#"<html lang="en"><head><style>
1188 a:focus { outline: none; }
1189 </style></head><body><main></main></body></html>"#;
1190 let issues = check_page(html);
1191 assert!(
1192 issues.iter().any(|i| i.criterion == "2.4.13"),
1193 "expected 2.4.13 issue for bare outline:none, got {issues:?}"
1194 );
1195 }
1196
1197 #[test]
1198 fn test_focus_appearance_with_box_shadow_passes() {
1199 let html = r#"<html lang="en"><head><style>
1200 a:focus { outline: none; box-shadow: 0 0 0 2px blue; }
1201 </style></head><body><main></main></body></html>"#;
1202 let issues: Vec<_> = check_page(html)
1203 .into_iter()
1204 .filter(|i| i.criterion == "2.4.13")
1205 .collect();
1206 assert!(
1207 issues.is_empty(),
1208 "outline:none + box-shadow should pass 2.4.13, got {issues:?}"
1209 );
1210 }
1211
1212 #[test]
1213 fn test_consistent_help_helper_detects_link() {
1214 let html_with = r#"<html lang="en"><body><a href="/contact">Contact</a></body></html>"#;
1219 let html_without =
1220 r#"<html lang="en"><body><p>nothing</p></body></html>"#;
1221 let mut buf = Vec::new();
1222 check_consistent_help(html_with, &mut buf);
1223 assert!(buf.is_empty(), "with link, no issue");
1224 check_consistent_help(html_without, &mut buf);
1225 assert_eq!(buf.len(), 1);
1226 assert_eq!(buf[0].criterion, "3.2.6");
1227 }
1228
1229 #[test]
1230 fn test_compliance_matrix_emitted() {
1231 let dir = tempdir().unwrap();
1232 let site = dir.path().join("site");
1233 fs::create_dir_all(&site).unwrap();
1234 fs::write(
1235 site.join("index.html"),
1236 r#"<html lang="en"><head></head><body><main>
1237 <h1>OK</h1>
1238 <a href="/contact">Contact</a>
1239 </main></body></html>"#,
1240 )
1241 .unwrap();
1242
1243 let ctx = test_ctx(&site);
1244 AccessibilityPlugin.after_compile(&ctx).unwrap();
1245
1246 let matrix_path = site.join("wcag-compliance.json");
1247 assert!(matrix_path.exists());
1248
1249 let content = fs::read_to_string(&matrix_path).unwrap();
1250 let matrix: WcagComplianceReport =
1251 serde_json::from_str(&content).unwrap();
1252 assert_eq!(matrix.wcag_version, "2.2");
1253 assert_eq!(matrix.pages_scanned, 1);
1254 let names: Vec<&str> = matrix
1257 .criteria
1258 .iter()
1259 .map(|c| c.criterion.as_str())
1260 .collect();
1261 assert!(names.contains(&"2.4.13"));
1262 assert!(names.contains(&"2.5.8"));
1263 assert!(names.contains(&"3.2.6"));
1264 }
1265
1266 #[test]
1269 fn target_size_ignores_value_inside_css_comment() {
1270 let html = r#"<html lang="en"><head><style>
1273 button { /* width: 10px */ width: 32px; height: 32px; }
1274 </style></head><body><main></main></body></html>"#;
1275 let issues = check_page(html);
1276 assert!(
1277 !issues.iter().any(|i| i.criterion == "2.5.8"),
1278 "comment must not trigger 2.5.8, got {issues:?}"
1279 );
1280 }
1281
1282 #[test]
1283 fn target_size_ignores_rule_inside_media_query() {
1284 let html = r#"<html lang="en"><head><style>
1287 @media print { button { width: 10px; height: 10px; } }
1288 button { width: 32px; height: 32px; }
1289 </style></head><body><main></main></body></html>"#;
1290 let issues = check_page(html);
1291 assert!(
1292 !issues.iter().any(|i| i.criterion == "2.5.8"),
1293 "@media-nested 10px must not flag 2.5.8, got {issues:?}"
1294 );
1295 }
1296
1297 #[test]
1298 fn target_size_scans_every_style_block() {
1299 let html = r#"<html lang="en">
1301 <head>
1302 <style>p { color: red }</style>
1303 <style>button { width: 8px; height: 8px; }</style>
1304 </head>
1305 <body><main></main></body>
1306 </html>"#;
1307 let issues = check_page(html);
1308 assert!(
1309 issues.iter().any(|i| i.criterion == "2.5.8"),
1310 "second <style> block's button rule must be inspected, got {issues:?}"
1311 );
1312 }
1313
1314 #[test]
1315 fn focus_appearance_ignores_outline_none_inside_supports() {
1316 let html = r#"<html lang="en"><head><style>
1319 @supports (display: grid) { a:focus { outline: none; } }
1320 a:focus { outline: 2px solid blue; }
1321 </style></head><body><main></main></body></html>"#;
1322 let issues = check_page(html);
1323 assert!(
1324 !issues.iter().any(|i| i.criterion == "2.4.13"),
1325 "@supports-nested outline:none must not flag 2.4.13, got {issues:?}"
1326 );
1327 }
1328
1329 #[test]
1330 fn parse_top_level_rules_skips_empty_selector() {
1331 let rules = parse_top_level_rules("{ width: 10px; }");
1332 assert!(rules.is_empty());
1333 }
1334
1335 #[test]
1336 fn strip_at_rules_handles_nested_media() {
1337 let css = "a { color: red } @media print { a { color: blue } } b { color: green }";
1338 let stripped = strip_at_rules(css);
1339 assert!(stripped.contains("a { color: red }"));
1340 assert!(stripped.contains("b { color: green }"));
1341 assert!(!stripped.contains("@media"));
1342 assert!(!stripped.contains("blue"));
1344 }
1345
1346 #[test]
1347 fn strip_css_comments_removes_block_comments() {
1348 let css = "a { /* hidden */ color: red; }";
1349 let stripped = strip_css_comments(css);
1350 assert!(!stripped.contains("hidden"));
1351 assert!(stripped.contains("color: red"));
1352 }
1353
1354 #[test]
1355 fn strip_css_comments_handles_unterminated_comment() {
1356 let css = "a { /* never closes";
1358 let _ = strip_css_comments(css);
1359 }
1360
1361 #[test]
1362 fn extract_all_style_blocks_returns_every_block() {
1363 let html =
1364 "<html><head><style>x{}</style><style>y{}</style></head></html>";
1365 let blocks = extract_all_style_blocks(html);
1366 assert_eq!(blocks.len(), 2);
1367 assert_eq!(blocks[0].trim(), "x{}");
1368 assert_eq!(blocks[1].trim(), "y{}");
1369 }
1370
1371 #[test]
1372 fn extract_all_style_blocks_handles_attributes_with_quoted_gt() {
1373 let html =
1374 r#"<html><head><style data-tag="x>y">a{}</style></head></html>"#;
1375 let blocks = extract_all_style_blocks(html);
1376 assert_eq!(blocks.len(), 1);
1377 assert_eq!(blocks[0].trim(), "a{}");
1378 }
1379}