checkstructure.py
Headings, labels and landmarks
101 lines. This is the file the build actually runs, copied verbatim at build time.
- 1
"""Structural accessibility: headings, labels, and names. - 2
- 3
checka11y.py measures colour, which is the easy half. This is the other half: - 4
a screen reader navigates by headings and needs every control to have a name. - 5
Neither is visible in a screenshot, so neither has ever been checked here. - 6
- 7
* exactly one <h1>, and no skipped heading levels (h2 -> h4) - 8
* every input and textarea has a label, aria-label or aria-labelledby - 9
* every button has text or an aria-label - 10
* every page has a <main> and the skip link points at it - 11
""" - 12
- 13
import os - 14
import re - 15
import sys - 16
- 17
HERE = os.path.dirname(os.path.abspath(__file__)) - 18
ROOT = os.path.abspath(os.path.join(HERE, "..")) - 19
- 20
PAGES = ["index.html", "tokens/index.html", "vocabulary/index.html", - 21
"predict/index.html", "cost/index.html", "learn/index.html", - 22
"attention/index.html", "about/index.html", "changes/index.html", - 23
"checking/index.html", "404.html"] - 24
- 25
HEADING_RE = re.compile(r"<h([1-6])\b[^>]*>(.*?)</h\1>", re.S | re.I) - 26
CONTROL_RE = re.compile(r"<(input|textarea|select)\b([^>]*)>", re.I) - 27
BUTTON_RE = re.compile(r"<button\b([^>]*)>(.*?)</button>", re.S | re.I) - 28
LABEL_FOR_RE = re.compile(r'<label[^>]*\bfor="([^"]+)"', re.I) - 29
ATTR_RE = re.compile(r'(\w[\w-]*)="([^"]*)"') - 30
TAG_RE = re.compile(r"<[^>]+>") - 31
- 32
- 33
def attrs(chunk): - 34
return dict(ATTR_RE.findall(chunk or "")) - 35
- 36
- 37
def main(): - 38
problems = [] - 39
- 40
for page in PAGES: - 41
path = os.path.join(ROOT, page) - 42
if not os.path.exists(path): - 43
problems.append(f"{page}: missing") - 44
continue - 45
src = open(path, encoding="utf-8").read() - 46
- 47
# --- headings --- - 48
levels = [int(m.group(1)) for m in HEADING_RE.finditer(src)] - 49
if levels.count(1) != 1: - 50
problems.append(f"{page}: {levels.count(1)} <h1> elements, want 1") - 51
previous = None - 52
for lvl in levels: - 53
if previous is not None and lvl > previous + 1: - 54
problems.append( - 55
f"{page}: heading jumps h{previous} -> h{lvl}, which leaves " - 56
f"a gap for anyone navigating by headings") - 57
break - 58
previous = lvl - 59
- 60
# --- controls need names --- - 61
labelled = set(LABEL_FOR_RE.findall(src)) - 62
for m in CONTROL_RE.finditer(src): - 63
a = attrs(m.group(2)) - 64
if a.get("type") in {"hidden", "submit", "button"}: - 65
continue - 66
ident = a.get("id", "") - 67
named = (ident in labelled or "aria-label" in a - 68
or "aria-labelledby" in a or "title" in a) - 69
if not named: - 70
problems.append( - 71
f"{page}: <{m.group(1)} id=\"{ident or '?'}\"> has no label") - 72
- 73
# --- buttons need names --- - 74
for m in BUTTON_RE.finditer(src): - 75
a = attrs(m.group(1)) - 76
text = TAG_RE.sub("", m.group(2)).strip() - 77
if not text and "aria-label" not in a: - 78
problems.append( - 79
f"{page}: a <button> has neither text nor aria-label") - 80
- 81
# --- landmarks --- - 82
if "<main" not in src: - 83
problems.append(f"{page}: no <main> landmark") - 84
if 'href="#main"' in src and 'id="main"' not in src: - 85
problems.append(f"{page}: skip link points at #main, which is absent") - 86
- 87
print(f"checked {len(PAGES)} pages") - 88
print() - 89
if problems: - 90
print(f"{len(problems)} problem(s):") - 91
for p in problems: - 92
print(f" - {p}") - 93
return 1 - 94
print("Headings nest without gaps, every control has a name, every page " - 95
"has a main landmark.") - 96
return 0 - 97
- 98
- 99
if __name__ == "__main__": - 100
sys.exit(main())