#!/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"{tag}>") 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"