qa-consistency.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. #!/usr/bin/env python3
  2. """Site-wide consistency audit across all pages.
  3. Programmatic (not eyeball) checks:
  4. 1. masthead nav items and order identical across pages (paths normalised
  5. to site-root-relative so differing ../ depths compare equal)
  6. 2. footer columns / freedoms / licence line semantically identical
  7. (footer "Site" Home href also normalised)
  8. 3. aria-current="page" on exactly one masthead nav item, the RIGHT one
  9. (Home on /, Developer across the developer tree)
  10. 4. head meta set: charset, viewport, UNIQUE title, description,
  11. color-scheme, theme-color light+dark, favicon, og:title/description/type
  12. 5. pager chain across detail pages in catalogue order, ending at the
  13. developer index
  14. 6. root-relative link audit (site should deploy at any path)
  15. 7. breadcrumb trail on developer pages
  16. 8. one h1 per page, skip-link first focusable, images have alt
  17. Usage: python3 tools/qa-consistency.py [repo-root]
  18. """
  19. import pathlib
  20. import re
  21. import sys
  22. from html.parser import HTMLParser
  23. ROOT = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
  24. PAGES = ["index.html",
  25. "developer/index.html",
  26. "developer/invercargill/index.html",
  27. "developer/invercargill-json/index.html",
  28. "developer/inversion/index.html",
  29. "developer/invercargill-sql/index.html",
  30. "developer/invercargill-sql-inversion/index.html",
  31. "developer/astralis/index.html",
  32. "developer/statum/index.html",
  33. "developer/spry/index.html",
  34. "developer/locksmith/index.html",
  35. "developer/vallama/index.html",
  36. "developer/usm/index.html"]
  37. CATALOGUE = ["invercargill", "invercargill-json", "inversion",
  38. "invercargill-sql", "invercargill-sql-inversion",
  39. "astralis", "statum", "spry", "locksmith", "vallama", "usm"]
  40. errors = []
  41. def rootify(page_path, ref):
  42. """Normalise a page-relative URL to a site-root-relative path."""
  43. if ref.startswith(("http", "data:", "mailto:", "#", "//")):
  44. return ref
  45. if ref.startswith("/"):
  46. return ref # already root-relative
  47. base = pathlib.PurePosixPath(page_path).parent
  48. p = base / ref
  49. parts = []
  50. for seg in p.parts:
  51. if seg == "..":
  52. if parts:
  53. parts.pop()
  54. elif seg not in (".", ""):
  55. parts.append(seg)
  56. return "/".join(parts)
  57. def canon(page_path, ref):
  58. """Canonical form for comparing link targets: root-relative, with
  59. directory links and index.html pages normalised to the same key."""
  60. r = rootify(page_path, ref)
  61. if r.startswith(("#", "http", "data:", "mailto:")):
  62. return r
  63. if r.endswith("/index.html"):
  64. r = r[: -len("index.html")]
  65. elif r and not r.endswith("/"):
  66. r += "/"
  67. return r
  68. class PageParser(HTMLParser):
  69. def __init__(self, path):
  70. super().__init__(convert_charrefs=True)
  71. self.path = path
  72. self.metas = []
  73. self.favicon = None
  74. self.nav_links = [] # (label, href, aria_current)
  75. self.footer_html = []
  76. self.in_masthead = False
  77. self.in_footer = False
  78. self.h1s = 0
  79. self.imgs_no_alt = 0
  80. self.first_focusable = None
  81. self.breadcrumbs = [] # (label, href or None)
  82. self.in_breadcrumb = False
  83. self.pager = {} # 'prev'/'next' -> href
  84. self.root_relative = []
  85. self._pend_nav = None
  86. self._pend_bc = None
  87. # -- attribute-level harvesting (works for start and void tags) -----
  88. def _attrs(self, tag, attrs):
  89. d = dict(attrs)
  90. classes = set(d.get("class", "").split())
  91. if tag in ("a", "button", "input", "select", "textarea") and \
  92. self.first_focusable is None and d.get("tabindex") != "-1":
  93. self.first_focusable = (tag, d.get("class", "") or "",
  94. d.get("href", "") or "")
  95. if tag == "a":
  96. href = d.get("href", "")
  97. if href.startswith("/") and not href.startswith("//"):
  98. self.root_relative.append(href)
  99. if self.in_masthead:
  100. self._pend_nav = [href, d.get("aria-current"), ""]
  101. if self.in_breadcrumb:
  102. self._pend_bc = [href, ""]
  103. if "docs-pager__link--prev" in classes:
  104. self.pager["prev"] = href
  105. if "docs-pager__link--next" in classes:
  106. self.pager["next"] = href
  107. if tag == "img" and "alt" not in d:
  108. self.imgs_no_alt += 1
  109. if tag == "h1":
  110. self.h1s += 1
  111. if tag == "meta":
  112. if d.get("charset"):
  113. self.metas.append(("charset", "charset", d["charset"]))
  114. for k in ("name", "property"):
  115. if d.get(k):
  116. self.metas.append((k, d[k], d.get("content", "")))
  117. if tag == "link":
  118. rel = (d.get("rel") or "").lower()
  119. if "icon" in rel.split():
  120. self.favicon = d.get("href")
  121. elif "stylesheet" in rel.split():
  122. self.metas.append(("link", "stylesheet", d.get("href", "")))
  123. if tag == "header" and "masthead" in classes:
  124. self.in_masthead = True
  125. if tag == "footer" and "site-footer" in classes:
  126. self.in_footer = True
  127. if tag == "nav" and "breadcrumb" in classes:
  128. self.in_breadcrumb = True
  129. def handle_starttag(self, tag, attrs):
  130. self._attrs(tag, attrs)
  131. if self.in_footer:
  132. self.footer_html.append(self.get_starttag_text() or "")
  133. def handle_startendtag(self, tag, attrs):
  134. self._attrs(tag, attrs)
  135. if self.in_footer:
  136. self.footer_html.append(self.get_starttag_text() or "")
  137. def handle_endtag(self, tag):
  138. if tag == "header":
  139. self.in_masthead = False
  140. if tag == "footer":
  141. self.in_footer = False
  142. if tag == "nav":
  143. self.in_breadcrumb = False
  144. if self.in_footer:
  145. self.footer_html.append(f"</{tag}>")
  146. def handle_data(self, data):
  147. if self.in_footer:
  148. self.footer_html.append(data)
  149. t = data.strip()
  150. if not t:
  151. return
  152. if self._pend_nav is not None:
  153. self.nav_links.append((t, self._pend_nav[0], self._pend_nav[1]))
  154. self._pend_nav = None
  155. if self._pend_bc is not None:
  156. self.breadcrumbs.append((t, self._pend_bc[0]))
  157. self._pend_bc = None
  158. def normalise_footer(parts, page_path):
  159. """Serialise captured footer HTML with page-relative hrefs/srcs
  160. rewritten root-relative so footers from any depth compare equal."""
  161. html = re.sub(r"\s+", " ", "".join(parts)).strip()
  162. def sub(m):
  163. return f'{m.group(1)}="{rootify(page_path, m.group(2))}"'
  164. return re.sub(r'(href|src)="([^"]*)"', sub, html)
  165. def main():
  166. pages = {}
  167. for rel in PAGES:
  168. f = ROOT / rel
  169. if not f.exists():
  170. errors.append(f"missing page {rel}")
  171. continue
  172. text = f.read_text(encoding="utf-8")
  173. p = PageParser(rel)
  174. p.feed(text)
  175. pages[rel] = p
  176. # ---- 1 + 3: masthead nav identity + aria-current placement
  177. ref_nav = None
  178. for rel, p in pages.items():
  179. sig = [(lbl, canon(rel, href)) for lbl, href, _ in p.nav_links]
  180. if ref_nav is None:
  181. ref_nav = sig
  182. elif sig != ref_nav:
  183. errors.append(f"nav differs on {rel}:\n"
  184. f" got {sig}\n expected {ref_nav}")
  185. cur = [n for n in p.nav_links if n[2] == "page"]
  186. if len(cur) != 1:
  187. errors.append(f"{rel}: {len(cur)} aria-current=page in nav "
  188. f"(expected 1): {cur}")
  189. else:
  190. expect = "Home" if rel == "index.html" else "Developer"
  191. if cur[0][0] != expect:
  192. errors.append(f"{rel}: aria-current on '{cur[0][0]}', "
  193. f"expected '{expect}'")
  194. # ---- 2: footer identity
  195. ref_footer = None
  196. for rel, p in pages.items():
  197. if not p.footer_html:
  198. errors.append(f"{rel}: no footer captured")
  199. continue
  200. norm = normalise_footer(p.footer_html, rel)
  201. if ref_footer is None:
  202. ref_footer = norm
  203. elif norm != ref_footer:
  204. errors.append(f"footer differs on {rel}")
  205. # ---- 4: head meta
  206. titles = {}
  207. for rel, p in pages.items():
  208. m = {}
  209. for kind, key, content in p.metas:
  210. m.setdefault(f"{kind}:{key}", []).append(content)
  211. text = (ROOT / rel).read_text(encoding="utf-8")
  212. t = re.search(r"<title[^>]*>(.*?)</title>", text, re.S)
  213. if not t:
  214. errors.append(f"{rel}: no <title>")
  215. else:
  216. title = t.group(1).strip()
  217. if title in titles:
  218. errors.append(f"duplicate title '{title}' on "
  219. f"{titles[title]} and {rel}")
  220. titles[rel] = title
  221. if not any(k.startswith("charset:") for k in m):
  222. errors.append(f"{rel}: no charset meta")
  223. for k in ("name:viewport", "name:description", "name:color-scheme",
  224. "name:theme-color", "property:og:title",
  225. "property:og:description", "property:og:type"):
  226. if k not in m:
  227. errors.append(f"{rel}: missing {k}")
  228. tc = m.get("name:theme-color", [])
  229. if len(tc) != 2:
  230. errors.append(f"{rel}: expected 2 theme-color metas "
  231. f"(light+dark), got {len(tc)}")
  232. if p.favicon is None:
  233. errors.append(f"{rel}: no favicon link")
  234. # ---- 5: pager chain
  235. detail = [f"developer/{s}/index.html" for s in CATALOGUE]
  236. detail_keys = [canon(d, ".") for d in detail]
  237. order, seen = [], set()
  238. cur = detail[0]
  239. while cur in pages:
  240. if cur in seen:
  241. errors.append(f"pager cycle at {cur}")
  242. break
  243. seen.add(cur)
  244. order.append(cur)
  245. nxt = pages[cur].pager.get("next")
  246. if not nxt:
  247. break
  248. cur_key = canon(cur, nxt)
  249. match = [d for d in detail if canon(d, ".") == cur_key]
  250. cur = match[0] if match else cur_key
  251. if order != detail:
  252. errors.append(f"pager chain wrong:\n got {order}\n"
  253. f" expected {detail}")
  254. else:
  255. last_next = pages[order[-1]].pager.get("next")
  256. end = canon(order[-1], last_next) if last_next else None
  257. print(f"pager chain OK: 11 pages in catalogue order, "
  258. f"last next -> {end}")
  259. if end != canon("developer/index.html", "."):
  260. errors.append(f"pager chain does not end at developer index "
  261. f"(ends at {end})")
  262. for i, pg in enumerate(detail):
  263. if pg not in pages:
  264. continue
  265. got_prev = pages[pg].pager.get("prev")
  266. if i == 0:
  267. if got_prev is not None:
  268. errors.append(f"{pg}: unexpected prev link {got_prev}")
  269. elif not got_prev or canon(pg, got_prev) != detail_keys[i - 1]:
  270. errors.append(f"{pg}: prev is {got_prev}, "
  271. f"expected {detail[i-1]}")
  272. # ---- 6: root-relative links
  273. for rel, p in pages.items():
  274. for href in p.root_relative:
  275. errors.append(f"root-relative link {href} on {rel}")
  276. # ---- 7: breadcrumbs
  277. for rel, p in pages.items():
  278. if rel == "index.html":
  279. if p.breadcrumbs:
  280. errors.append(f"{rel}: unexpected breadcrumbs")
  281. continue
  282. if rel == "developer/index.html":
  283. exp = [("Astrologue", "..")]
  284. else:
  285. exp = [("Astrologue", "../.."), ("Developer", "..")]
  286. got = p.breadcrumbs
  287. if [g[0] for g in got] != [e[0] for e in exp]:
  288. errors.append(f"{rel}: breadcrumb labels "
  289. f"{[g[0] for g in got]} != {[e[0] for e in exp]}")
  290. else:
  291. for (gl, gh), (el, eh) in zip(got, exp):
  292. if rootify(rel, gh) != rootify(rel, eh + "/"):
  293. errors.append(f"{rel}: breadcrumb '{gl}' href {gh} "
  294. f"!= expected {eh}/")
  295. # ---- 8: a11y spot checks
  296. for rel, p in pages.items():
  297. if not p.first_focusable or \
  298. "skip-link" not in p.first_focusable[1]:
  299. errors.append(f"{rel}: first focusable is not the skip link: "
  300. f"{p.first_focusable}")
  301. if p.h1s != 1:
  302. errors.append(f"{rel}: {p.h1s} h1 elements (expected 1)")
  303. if p.imgs_no_alt:
  304. errors.append(f"{rel}: {p.imgs_no_alt} img without alt")
  305. if errors:
  306. print(f"\n{len(errors)} ERROR(S):")
  307. for e in errors:
  308. print(f" - {e}")
  309. sys.exit(1)
  310. print(f"OK: {len(pages)} pages consistent "
  311. f"(nav, footer, meta, aria-current, pager, breadcrumbs, "
  312. f"no root-relative links, a11y)")
  313. if __name__ == "__main__":
  314. main()