Skip to content

Commit 90121a7

Browse files
jonfroehlichclaude
andcommitted
Keep check_a11y.py as a deprecated local-only a11y spot-check
Document that it is NOT a CI gate and must not be wired into content-lint.yml; accessibility enforcement is moving to off-the-shelf pa11y-ci (axe-core). Kept only as a fast dependency-free local audit; deletable once pa11y-ci lands (#110). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cf2ed25 commit 90121a7

2 files changed

Lines changed: 200 additions & 0 deletions

File tree

scripts/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Python utilities that bulk-edit or generate content across the textbook. They ar
2727
| [`fix_embedded_media.py`](fix_embedded_media.py) | Normalize `<video>` inline styles and wrap bare YouTube iframes responsively. | `--run` |
2828
| [`update_lesson_nav.py`](update_lesson_nav.py) | Migrate old `.btn` lesson nav to card-style `<nav class="lesson-nav">` (rewrites `.md``.html`). | `--run` |
2929
| [`fix_arduino_urls.py`](fix_arduino_urls.py) | Migrate old `arduino.cc` URLs to `docs.arduino.cc`. **Untested/brittle — use with care.** | `--apply` |
30+
| [`check_a11y.py`](check_a11y.py) | **Deprecated** local-only a11y spot-check (iframe `title`, video `aria-label`, image alt). **Not a CI gate** — superseded by pa11y-ci. Read-only. | _(none)_ |
3031

3132
## Details
3233

@@ -88,6 +89,24 @@ python scripts/update_lesson_nav.py # dry run
8889
python scripts/update_lesson_nav.py --run # apply
8990
```
9091

92+
### `check_a11y.py` (deprecated)
93+
94+
A fast, dependency-free local spot-check for three source-level a11y patterns:
95+
YouTube `<iframe>`s missing `title=`, `<video>`s missing `aria-label`, and images
96+
with empty alt (`![](...)`). Same published-page scope and draft/contributor
97+
exemptions as `check_seo_frontmatter.py`.
98+
99+
> **Not a CI gate.** Accessibility enforcement is moving to off-the-shelf
100+
> [`pa11y-ci`](https://github.com/pa11y/pa11y-ci) (axe-core, run against the built
101+
> site), which covers far more of WCAG. Do **not** wire this script into
102+
> `content-lint.yml`; once pa11y-ci is in place it can be deleted (see issue #110).
103+
104+
```bash
105+
python scripts/check_a11y.py # full report, grouped by module
106+
python scripts/check_a11y.py --summary # per-module counts only
107+
python scripts/check_a11y.py --ci # exit 1 if any issue (local spot-check)
108+
```
109+
91110
### `fix_arduino_urls.py`
92111

93112
Sweeps `.md` files for old `arduino.cc` URLs and migrates them to `docs.arduino.cc`

scripts/check_a11y.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"""
2+
check_a11y.py — quick local accessibility audit for embedded media and images.
3+
4+
DEPRECATED — NOT a CI gate. We are standardizing on off-the-shelf pa11y-ci
5+
(axe-core, run against the built site) for accessibility enforcement, since
6+
it covers far more of WCAG (contrast, heading order, ARIA, ...) and matches
7+
the Makeability Lab website's tooling. This bespoke checker is kept only as a
8+
fast, dependency-free local spot-check for the three source-level patterns
9+
below. Do NOT wire it back into `.github/workflows/content-lint.yml`; once
10+
pa11y-ci is in place and proven, this script can be deleted. See issue #110.
11+
12+
Scans published .md pages for three common, mechanically-detectable a11y gaps:
13+
14+
1. YouTube <iframe> without a `title=` attribute (screen readers announce a
15+
generic "iframe" with no context).
16+
2. <video> without an `aria-label` attribute (same problem for video heroes).
17+
3. Markdown images with empty or missing alt text — `![](...)` — on a
18+
published page.
19+
20+
Detection is intentionally conservative (only the patterns above, only on
21+
published pages) so it can run as a non-flaky CI gate alongside
22+
check_seo_frontmatter.py. Drafts (`nav_exclude`/`search_exclude`) and
23+
contributor/deprecated docs are exempt, mirroring the SEO gate.
24+
25+
Modes:
26+
python scripts/check_a11y.py # full report, grouped by module (exit 0)
27+
python scripts/check_a11y.py --summary # per-module counts only
28+
python scripts/check_a11y.py --ci # exit 1 if any issue found (local spot-check only)
29+
30+
Prints ASCII only (avoids cp1252 crashes on Windows consoles).
31+
"""
32+
33+
import re
34+
import sys
35+
from collections import defaultdict
36+
from pathlib import Path
37+
38+
DOCS_DIR = "."
39+
SKIP_DIRS = {"_site", ".git", "node_modules", "vendor", ".jekyll-cache",
40+
"scripts", "_includes", "_layouts", "_data", "_sass", "assets"}
41+
42+
# Pages exempt from the a11y requirement (contributor docs, deprecated).
43+
IGNORE = {
44+
"website-dev.md", "website-install.md", "teaching-notes.md",
45+
"website-content-ideas.md", "README.md", "LICENSE.md", "CLAUDE.md",
46+
"404.md", "arduino/potentiometers-old.md",
47+
}
48+
49+
FRONT_MATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
50+
HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
51+
52+
# A full <iframe ...> ... </iframe> (or self-contained opening tag) — captured so
53+
# we can test individual attributes. Non-greedy up to the first '>'.
54+
IFRAME_OPEN_RE = re.compile(r"<iframe\b[^>]*>", re.IGNORECASE)
55+
VIDEO_OPEN_RE = re.compile(r"<video\b[^>]*>", re.IGNORECASE)
56+
57+
YOUTUBE_RE = re.compile(r"youtube\.com|youtu\.be", re.IGNORECASE)
58+
TITLE_ATTR_RE = re.compile(r'\btitle\s*=\s*"[^"]*"', re.IGNORECASE)
59+
ARIA_LABEL_RE = re.compile(r'\baria-label\s*=\s*"[^"]*"', re.IGNORECASE)
60+
61+
# Markdown image with empty alt: ![](...) — allow whitespace inside the brackets.
62+
EMPTY_ALT_RE = re.compile(r"!\[\s*\]\(")
63+
64+
65+
def front_matter(content):
66+
m = FRONT_MATTER_RE.match(content)
67+
return (m.group(1), content[m.end():]) if m else (None, content)
68+
69+
70+
def fm_true(fm, key):
71+
return bool(re.search(rf"^{key}:\s*true\s*$", fm, re.MULTILINE | re.IGNORECASE))
72+
73+
74+
def line_of(body, idx):
75+
"""1-based line number of character offset idx within body."""
76+
return body.count("\n", 0, idx) + 1
77+
78+
79+
def scan_body(body):
80+
"""Return a list of (line_no, kind, snippet) issues for one page body."""
81+
visible = HTML_COMMENT_RE.sub(lambda m: "\n" * m.group(0).count("\n"), body)
82+
issues = []
83+
84+
for m in IFRAME_OPEN_RE.finditer(visible):
85+
tag = m.group(0)
86+
if YOUTUBE_RE.search(tag) and not TITLE_ATTR_RE.search(tag):
87+
issues.append((line_of(visible, m.start()), "iframe-no-title",
88+
tag[:80]))
89+
90+
for m in VIDEO_OPEN_RE.finditer(visible):
91+
tag = m.group(0)
92+
if not ARIA_LABEL_RE.search(tag):
93+
issues.append((line_of(visible, m.start()), "video-no-aria-label",
94+
tag[:80]))
95+
96+
for m in EMPTY_ALT_RE.finditer(visible):
97+
issues.append((line_of(visible, m.start()), "empty-alt",
98+
visible[m.start():m.start() + 80].replace("\n", " ")))
99+
100+
return sorted(issues)
101+
102+
103+
def rel(p):
104+
return str(p).replace("\\", "/").lstrip("./")
105+
106+
107+
def module_of(relpath):
108+
return relpath.split("/")[0] if "/" in relpath else "(root)"
109+
110+
111+
def collect():
112+
"""Return {relpath: [issues]} for all published pages with issues."""
113+
results = {}
114+
checked = 0
115+
for path in sorted(Path(DOCS_DIR).rglob("*.md")):
116+
if any(part in SKIP_DIRS for part in path.parts):
117+
continue
118+
relpath = rel(path)
119+
if relpath in IGNORE:
120+
continue
121+
fm, body = front_matter(path.read_text(encoding="utf-8"))
122+
if fm is None or not re.search(r"^layout:", fm, re.MULTILINE):
123+
continue
124+
if fm_true(fm, "nav_exclude") or fm_true(fm, "search_exclude"):
125+
continue
126+
checked += 1
127+
issues = scan_body(body)
128+
if issues:
129+
results[relpath] = issues
130+
return results, checked
131+
132+
133+
KINDS = ("iframe-no-title", "video-no-aria-label", "empty-alt")
134+
135+
136+
def main():
137+
summary_only = "--summary" in sys.argv
138+
ci = "--ci" in sys.argv
139+
140+
results, checked = collect()
141+
total = sum(len(v) for v in results.values())
142+
143+
# Per-module tallies.
144+
by_module = defaultdict(lambda: defaultdict(int))
145+
for relpath, issues in results.items():
146+
mod = module_of(relpath)
147+
for _, kind, _ in issues:
148+
by_module[mod][kind] += 1
149+
150+
print(f"Checked {checked} published page(s); "
151+
f"{total} a11y issue(s) in {len(results)} file(s).\n")
152+
153+
print(f"{'module':<16}{'iframe':>9}{'video':>9}{'alt':>9}{'total':>9}")
154+
print("-" * 52)
155+
for mod in sorted(by_module, key=lambda m: -sum(by_module[m].values())):
156+
c = by_module[mod]
157+
tot = sum(c.values())
158+
print(f"{mod:<16}{c['iframe-no-title']:>9}"
159+
f"{c['video-no-aria-label']:>9}{c['empty-alt']:>9}{tot:>9}")
160+
print("-" * 52)
161+
gt = {k: sum(by_module[m][k] for m in by_module) for k in KINDS}
162+
print(f"{'TOTAL':<16}{gt['iframe-no-title']:>9}"
163+
f"{gt['video-no-aria-label']:>9}{gt['empty-alt']:>9}{total:>9}")
164+
165+
if not summary_only:
166+
print()
167+
for relpath in sorted(results, key=lambda p: (module_of(p), p)):
168+
print(f"\n{relpath}")
169+
for line_no, kind, snippet in results[relpath]:
170+
print(f" L{line_no:<5} {kind:<20} {snippet}")
171+
172+
if ci and total:
173+
print(f"\nERROR: {total} accessibility issue(s) found on published pages.")
174+
print("Add title= to YouTube iframes, aria-label to <video>, and "
175+
"descriptive alt text to images.")
176+
return 1
177+
return 0
178+
179+
180+
if __name__ == "__main__":
181+
sys.exit(main())

0 commit comments

Comments
 (0)