| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129 |
- #!/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 (site-root-
- relative paths such as /developer/ resolve against the site root);
- 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.
- # TODO(A3/A4/A5): the eleven project detail pages land in waves 3-4; strip
- # these entries as each wave merges.
- UPCOMING_PAGES = {
- "developer/invercargill/",
- "developer/invercargill-json/",
- "developer/invercargill-sql/",
- "developer/invercargill-sql-inversion/",
- "developer/inversion/",
- "developer/astralis/",
- "developer/statum/",
- "developer/spry/",
- "developer/locksmith/",
- "developer/vallama/",
- "developer/usm/",
- }
- 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"</{tag}>", 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 parsed.path.startswith("/"):
- # site-root-relative: resolve against the site root, not /
- target = (ROOT / unquote(parsed.path).lstrip("/")).resolve()
- if not target.exists():
- # allowlist upcoming pages no matter whether they are linked
- # root-relative (/developer/spry/) or relatively (spry/)
- try:
- rel = target.relative_to(ROOT).as_posix().strip("/") + "/"
- except ValueError:
- rel = None
- if rel is not None and rel in UPCOMING_PAGES:
- continue
- errors.append(f"{page.name}: missing asset '{val}'")
- continue
- if target.suffix == ".html" or target.is_dir():
- # a page link; check its fragment if present
- linked_pages.add(target / "index.html" if target.is_dir() else 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())
|