|
@@ -0,0 +1,350 @@
|
|
|
|
|
+#!/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"<title[^>]*>(.*?)</title>", text, re.S)
|
|
|
|
|
+ if not t:
|
|
|
|
|
+ errors.append(f"{rel}: no <title>")
|
|
|
|
|
+ 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()
|