#!/usr/bin/env python3 """Site-wide consistency audit across all pages. Programmatic (not eyeball) checks: 1. masthead nav items and order identical across pages (paths normalised to site-root-relative so differing ../ depths compare equal) 2. footer columns / freedoms / licence line semantically identical (footer "Site" Home href also normalised) 3. aria-current="page" on exactly one masthead nav item, the RIGHT one (Home on /, Developer across the developer tree) 4. head meta set: charset, viewport, UNIQUE title, description, color-scheme, theme-color light+dark, favicon, og:title/description/type 5. pager chain across detail pages in catalogue order, ending at the developer index 6. root-relative link audit (site should deploy at any path) 7. breadcrumb trail on developer pages 8. one h1 per page, skip-link first focusable, images have alt Usage: python3 tools/qa-consistency.py [repo-root] """ import pathlib import re import sys from html.parser import HTMLParser ROOT = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve() PAGES = ["index.html", "developer/index.html", "developer/invercargill/index.html", "developer/invercargill-json/index.html", "developer/inversion/index.html", "developer/invercargill-sql/index.html", "developer/invercargill-sql-inversion/index.html", "developer/astralis/index.html", "developer/statum/index.html", "developer/spry/index.html", "developer/locksmith/index.html", "developer/vallama/index.html", "developer/usm/index.html"] CATALOGUE = ["invercargill", "invercargill-json", "inversion", "invercargill-sql", "invercargill-sql-inversion", "astralis", "statum", "spry", "locksmith", "vallama", "usm"] errors = [] def rootify(page_path, ref): """Normalise a page-relative URL to a site-root-relative path.""" if ref.startswith(("http", "data:", "mailto:", "#", "//")): return ref if ref.startswith("/"): return ref # already root-relative base = pathlib.PurePosixPath(page_path).parent p = base / ref parts = [] for seg in p.parts: if seg == "..": if parts: parts.pop() elif seg not in (".", ""): parts.append(seg) return "/".join(parts) def canon(page_path, ref): """Canonical form for comparing link targets: root-relative, with directory links and index.html pages normalised to the same key.""" r = rootify(page_path, ref) if r.startswith(("#", "http", "data:", "mailto:")): return r if r.endswith("/index.html"): r = r[: -len("index.html")] elif r and not r.endswith("/"): r += "/" return r class PageParser(HTMLParser): def __init__(self, path): super().__init__(convert_charrefs=True) self.path = path self.metas = [] self.favicon = None self.nav_links = [] # (label, href, aria_current) self.footer_html = [] self.in_masthead = False self.in_footer = False self.h1s = 0 self.imgs_no_alt = 0 self.first_focusable = None self.breadcrumbs = [] # (label, href or None) self.in_breadcrumb = False self.pager = {} # 'prev'/'next' -> href self.root_relative = [] self._pend_nav = None self._pend_bc = None # -- attribute-level harvesting (works for start and void tags) ----- def _attrs(self, tag, attrs): d = dict(attrs) classes = set(d.get("class", "").split()) if tag in ("a", "button", "input", "select", "textarea") and \ self.first_focusable is None and d.get("tabindex") != "-1": self.first_focusable = (tag, d.get("class", "") or "", d.get("href", "") or "") if tag == "a": href = d.get("href", "") if href.startswith("/") and not href.startswith("//"): self.root_relative.append(href) if self.in_masthead: self._pend_nav = [href, d.get("aria-current"), ""] if self.in_breadcrumb: self._pend_bc = [href, ""] if "docs-pager__link--prev" in classes: self.pager["prev"] = href if "docs-pager__link--next" in classes: self.pager["next"] = href if tag == "img" and "alt" not in d: self.imgs_no_alt += 1 if tag == "h1": self.h1s += 1 if tag == "meta": if d.get("charset"): self.metas.append(("charset", "charset", d["charset"])) for k in ("name", "property"): if d.get(k): self.metas.append((k, d[k], d.get("content", ""))) if tag == "link": rel = (d.get("rel") or "").lower() if "icon" in rel.split(): self.favicon = d.get("href") elif "stylesheet" in rel.split(): self.metas.append(("link", "stylesheet", d.get("href", ""))) if tag == "header" and "masthead" in classes: self.in_masthead = True if tag == "footer" and "site-footer" in classes: self.in_footer = True if tag == "nav" and "breadcrumb" in classes: self.in_breadcrumb = True def handle_starttag(self, tag, attrs): self._attrs(tag, attrs) if self.in_footer: self.footer_html.append(self.get_starttag_text() or "") def handle_startendtag(self, tag, attrs): self._attrs(tag, attrs) if self.in_footer: self.footer_html.append(self.get_starttag_text() or "") def handle_endtag(self, tag): if tag == "header": self.in_masthead = False if tag == "footer": self.in_footer = False if tag == "nav": self.in_breadcrumb = False if self.in_footer: self.footer_html.append(f"") def handle_data(self, data): if self.in_footer: self.footer_html.append(data) t = data.strip() if not t: return if self._pend_nav is not None: self.nav_links.append((t, self._pend_nav[0], self._pend_nav[1])) self._pend_nav = None if self._pend_bc is not None: self.breadcrumbs.append((t, self._pend_bc[0])) self._pend_bc = None def normalise_footer(parts, page_path): """Serialise captured footer HTML with page-relative hrefs/srcs rewritten root-relative so footers from any depth compare equal.""" html = re.sub(r"\s+", " ", "".join(parts)).strip() def sub(m): return f'{m.group(1)}="{rootify(page_path, m.group(2))}"' return re.sub(r'(href|src)="([^"]*)"', sub, html) def main(): pages = {} for rel in PAGES: f = ROOT / rel if not f.exists(): errors.append(f"missing page {rel}") continue text = f.read_text(encoding="utf-8") p = PageParser(rel) p.feed(text) pages[rel] = p # ---- 1 + 3: masthead nav identity + aria-current placement ref_nav = None for rel, p in pages.items(): sig = [(lbl, canon(rel, href)) for lbl, href, _ in p.nav_links] if ref_nav is None: ref_nav = sig elif sig != ref_nav: errors.append(f"nav differs on {rel}:\n" f" got {sig}\n expected {ref_nav}") cur = [n for n in p.nav_links if n[2] == "page"] if len(cur) != 1: errors.append(f"{rel}: {len(cur)} aria-current=page in nav " f"(expected 1): {cur}") else: expect = "Home" if rel == "index.html" else "Developer" if cur[0][0] != expect: errors.append(f"{rel}: aria-current on '{cur[0][0]}', " f"expected '{expect}'") # ---- 2: footer identity ref_footer = None for rel, p in pages.items(): if not p.footer_html: errors.append(f"{rel}: no footer captured") continue norm = normalise_footer(p.footer_html, rel) if ref_footer is None: ref_footer = norm elif norm != ref_footer: errors.append(f"footer differs on {rel}") # ---- 4: head meta titles = {} for rel, p in pages.items(): m = {} for kind, key, content in p.metas: m.setdefault(f"{kind}:{key}", []).append(content) text = (ROOT / rel).read_text(encoding="utf-8") t = re.search(r"]*>(.*?)", text, re.S) if not t: errors.append(f"{rel}: no ") else: title = t.group(1).strip() if title in titles: errors.append(f"duplicate title '{title}' on " f"{titles[title]} and {rel}") titles[rel] = title if not any(k.startswith("charset:") for k in m): errors.append(f"{rel}: no charset meta") for k in ("name:viewport", "name:description", "name:color-scheme", "name:theme-color", "property:og:title", "property:og:description", "property:og:type"): if k not in m: errors.append(f"{rel}: missing {k}") tc = m.get("name:theme-color", []) if len(tc) != 2: errors.append(f"{rel}: expected 2 theme-color metas " f"(light+dark), got {len(tc)}") if p.favicon is None: errors.append(f"{rel}: no favicon link") # ---- 5: pager chain detail = [f"developer/{s}/index.html" for s in CATALOGUE] detail_keys = [canon(d, ".") for d in detail] order, seen = [], set() cur = detail[0] while cur in pages: if cur in seen: errors.append(f"pager cycle at {cur}") break seen.add(cur) order.append(cur) nxt = pages[cur].pager.get("next") if not nxt: break cur_key = canon(cur, nxt) match = [d for d in detail if canon(d, ".") == cur_key] cur = match[0] if match else cur_key if order != detail: errors.append(f"pager chain wrong:\n got {order}\n" f" expected {detail}") else: last_next = pages[order[-1]].pager.get("next") end = canon(order[-1], last_next) if last_next else None print(f"pager chain OK: 11 pages in catalogue order, " f"last next -> {end}") if end != canon("developer/index.html", "."): errors.append(f"pager chain does not end at developer index " f"(ends at {end})") for i, pg in enumerate(detail): if pg not in pages: continue got_prev = pages[pg].pager.get("prev") if i == 0: if got_prev is not None: errors.append(f"{pg}: unexpected prev link {got_prev}") elif not got_prev or canon(pg, got_prev) != detail_keys[i - 1]: errors.append(f"{pg}: prev is {got_prev}, " f"expected {detail[i-1]}") # ---- 6: root-relative links for rel, p in pages.items(): for href in p.root_relative: errors.append(f"root-relative link {href} on {rel}") # ---- 7: breadcrumbs for rel, p in pages.items(): if rel == "index.html": if p.breadcrumbs: errors.append(f"{rel}: unexpected breadcrumbs") continue if rel == "developer/index.html": exp = [("Astrologue", "..")] else: exp = [("Astrologue", "../.."), ("Developer", "..")] got = p.breadcrumbs if [g[0] for g in got] != [e[0] for e in exp]: errors.append(f"{rel}: breadcrumb labels " f"{[g[0] for g in got]} != {[e[0] for e in exp]}") else: for (gl, gh), (el, eh) in zip(got, exp): if rootify(rel, gh) != rootify(rel, eh + "/"): errors.append(f"{rel}: breadcrumb '{gl}' href {gh} " f"!= expected {eh}/") # ---- 8: a11y spot checks for rel, p in pages.items(): if not p.first_focusable or \ "skip-link" not in p.first_focusable[1]: errors.append(f"{rel}: first focusable is not the skip link: " f"{p.first_focusable}") if p.h1s != 1: errors.append(f"{rel}: {p.h1s} h1 elements (expected 1)") if p.imgs_no_alt: errors.append(f"{rel}: {p.imgs_no_alt} img without alt") if errors: print(f"\n{len(errors)} ERROR(S):") for e in errors: print(f" - {e}") sys.exit(1) print(f"OK: {len(pages)} pages consistent " f"(nav, footer, meta, aria-current, pager, breadcrumbs, " f"no root-relative links, a11y)") if __name__ == "__main__": main()