sweedworks

← all sources

checkstructure.py

Headings, labels and landmarks

101 lines. This is the file the build actually runs, copied verbatim at build time.

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