| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350 |
- #!/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()
|