#!/usr/bin/env python3 """Static-site sanity checker for the Astrologue website. Walks every .html file under the given root, then verifies: 1. every src/href asset reference resolves to a real file; 2. no external URLs are referenced (the site must be self-contained); 3. internal page anchors (#id and page#id) point at real ids; 4. every local page is reachable by some link (orphan check); 5. quick HTML well-formedness signals (basic tag balance). Exit code 1 on any failure. Usage: check.py [root] """ import re import sys from pathlib import Path from urllib.parse import urlparse, unquote ROOT = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve() EXTERNAL = re.compile(r"^(https?|ftp|wss?|//):?") # Reference designs are not part of the deployable site. EXCLUDE_DIRS = {"Original", "Proposed", ".git"} # Site-root-relative pages that are linked ahead of their existence because a # later task in this goal builds them. Remove entries here once the page lands # so the checker enforces them again. UPCOMING_PAGES = {"developer/"} def check() -> int: errors: list[str] = [] pages = sorted(p for p in ROOT.rglob("*.html") if not (set(p.relative_to(ROOT).parts[:-1]) & EXCLUDE_DIRS)) if not pages: errors.append("no .html files found under root") ids_by_page: dict[Path, set[str]] = {} refs: list[tuple[Path, str, str]] = [] # page, attr value, kind linked_pages: set[Path] = set() for page in pages: text = page.read_text(encoding="utf-8") ids_by_page[page] = set(re.findall(r'id="([^"]+)"', text)) # strip comments so commented-out template rows are not treated as live refs body = re.sub(r"", "", text, flags=re.S) for attr, val in re.findall(r'\b(src|href)="([^"]+)"', body): refs.append((page, val, attr)) # naive tag-balance check for the elements we actually use for tag in ("html", "head", "body", "header", "nav", "main", "footer", "section", "article", "figure", "table", "ul", "ol", "div", "a", "p"): opens = len(re.findall(rf"<{tag}[\s>]", body)) closes = len(re.findall(rf"", body)) if opens != closes: errors.append(f"{page.name}: <{tag}> open/close mismatch ({opens}/{closes})") for page, val, _attr in refs: parsed = urlparse(val) if parsed.scheme or val.startswith("//"): if not (val.startswith("data:")): errors.append(f"{page.name}: EXTERNAL reference '{val}'") continue if parsed.fragment and not parsed.path: # same-page anchor if parsed.fragment not in ids_by_page[page]: errors.append(f"{page.name}: broken in-page anchor '#{parsed.fragment}'") continue target = (page.parent / unquote(parsed.path)).resolve() if not target.exists(): rel = unquote(parsed.path).strip("/") if parsed.path.startswith("/") and rel in {p.strip("/") for p in UPCOMING_PAGES}: continue errors.append(f"{page.name}: missing asset '{val}'") continue if target.suffix == ".html" or target.name == "": # a page link; check its fragment if present linked_pages.add(target) if parsed.fragment: tp = target if target.suffix == ".html" else target / "index.html" if tp.is_file(): if parsed.fragment not in ids_by_page.get(tp, set()): errors.append(f"{page.name}: broken anchor in {val}") # orphan check for page in pages: if page not in linked_pages and page != ROOT / "index.html": errors.append(f"orphan page (nothing links to it): {page.relative_to(ROOT)}") if errors: print(f"FAIL ({len(errors)} problems)") for e in errors: print(" -", e) return 1 print(f"OK: {len(pages)} page(s), {len(refs)} reference(s), all local, all resolve") return 0 if __name__ == "__main__": sys.exit(check())