forked from ChelseaKR/nearmiss
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy patha11y_check.py
More file actions
161 lines (143 loc) · 5.82 KB
/
Copy patha11y_check.py
File metadata and controls
161 lines (143 loc) · 5.82 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
#!/usr/bin/env python3
"""A small, dependency-free accessibility gate for the static web UI.
This is the fast, merge-blocking structural check that runs in `make accessibility`
and on every CI run: it verifies the foundations that automated and manual audits
build on — a language, a title, landmarks and a heading, labeled data tables, a
skip link, and image alternatives. It is intentionally NOT a substitute for the
deeper axe run and the manual NVDA/VoiceOver review described in
docs/ACCESSIBILITY.md; it is the floor those sit on.
Usage: python tools/a11y_check.py web/index.html
Exit: 0 if all checks pass, 1 otherwise (with a list of failures).
"""
from __future__ import annotations
import sys
from html.parser import HTMLParser
from pathlib import Path
class _Audit(HTMLParser):
def __init__(self) -> None:
super().__init__()
self.html_lang = False
self.has_title = False
self.has_main = False
self.has_h1 = False
self.has_skip_link = False
self.tables = 0
self.tables_with_caption = 0
self.tables_with_th_scope = 0
self.img_total = 0
self.img_with_alt = 0
self.buttons_with_text = 0
self.buttons_total = 0
self._in_title = False
self._title_text = ""
self._in_table = False
self._cur_caption = False
self._cur_scope = False
self._first_anchor_href = ""
self._capture_text: list[str] = []
def handle_starttag(self, tag: str, attrs_list: list[tuple[str, str | None]]) -> None:
attrs = {k: (v or "") for k, v in attrs_list}
if self._handle_landmark_tag(tag, attrs):
return
self._handle_content_tag(tag, attrs)
def _handle_landmark_tag(self, tag: str, attrs: dict[str, str]) -> bool:
"""Handle page-landmark start tags (lang, title, main, h1, skip link).
Returns True if `tag` was one of these (so the caller can skip the
content-tag dispatch below).
"""
if tag == "html" and attrs.get("lang", "").strip():
self.html_lang = True
elif tag == "title":
self._in_title = True
elif tag == "main":
self.has_main = True
elif tag == "h1":
self.has_h1 = True
elif tag == "a" and attrs.get("href", "") == "#main":
self.has_skip_link = True
else:
return False
return True
def _handle_content_tag(self, tag: str, attrs: dict[str, str]) -> None:
"""Handle content start tags: table/caption/th scoping, img alt, button text capture."""
if tag == "table":
self._in_table = True
self.tables += 1
self._cur_caption = False
self._cur_scope = False
elif tag == "caption" and self._in_table:
self._cur_caption = True
elif tag == "th" and attrs.get("scope", "").strip():
self._cur_scope = True
elif tag == "img":
self.img_total += 1
if "alt" in attrs:
self.img_with_alt += 1
elif tag == "button":
self.buttons_total += 1
self._capture_text = []
def handle_endtag(self, tag: str) -> None:
if tag == "title":
self._in_title = False
elif tag == "table" and self._in_table:
self._in_table = False
if self._cur_caption:
self.tables_with_caption += 1
if self._cur_scope:
self.tables_with_th_scope += 1
elif tag == "button" and "".join(self._capture_text).strip():
self.buttons_with_text += 1
def handle_data(self, data: str) -> None:
if self._in_title:
self._title_text += data
if data.strip():
self.has_title = True
if self.buttons_total and self._capture_text is not None:
self._capture_text.append(data)
def audit(html: str) -> list[str]:
a = _Audit()
a.feed(html)
problems: list[str] = []
if not a.html_lang:
problems.append("<html> is missing a non-empty lang attribute (WCAG 3.1.1)")
if not a.has_title:
problems.append("missing a non-empty <title> (WCAG 2.4.2)")
if not a.has_main:
problems.append("missing a <main> landmark (WCAG 1.3.1 / 2.4.1)")
if not a.has_h1:
problems.append("missing an <h1> heading (WCAG 1.3.1 / 2.4.6)")
if not a.has_skip_link:
problems.append('missing a skip link (<a href="#main">) (WCAG 2.4.1)')
if a.tables and a.tables_with_caption < a.tables:
problems.append("a data <table> is missing a <caption> (WCAG 1.3.1)")
if a.tables and a.tables_with_th_scope < a.tables:
problems.append("a data <table> is missing <th scope> headers (WCAG 1.3.1)")
if a.img_total != a.img_with_alt:
problems.append("an <img> is missing an alt attribute (WCAG 1.1.1)")
if a.buttons_total and a.buttons_with_text < a.buttons_total:
problems.append("a <button> has no accessible text (WCAG 4.1.2)")
return problems
def main(argv: list[str]) -> int:
targets = argv[1:] or ["web/index.html"]
failures = 0
for target in targets:
path = Path(target)
if not path.is_file():
print(f"a11y: FAIL {target}: file not found")
failures += 1
continue
problems = audit(path.read_text(encoding="utf-8"))
if problems:
failures += 1
print(f"a11y: FAIL {target}")
for p in problems:
print(f" - {p}")
else:
print(f"a11y: PASS {target} (structural checks)")
if failures:
print(f"\na11y: {failures} file(s) failed structural checks.")
return 1
print("\na11y: structural checks passed. Run axe + manual SR review for full conformance.")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))