check.py 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. #!/usr/bin/env python3
  2. """Static-site sanity checker for the Astrologue website.
  3. Walks every .html file under the given root, then verifies:
  4. 1. every src/href asset reference resolves to a real file;
  5. 2. no external URLs are referenced (the site must be self-contained);
  6. 3. internal page anchors (#id and page#id) point at real ids;
  7. 4. every local page is reachable by some link (orphan check);
  8. 5. quick HTML well-formedness signals (basic tag balance).
  9. Exit code 1 on any failure. Usage: check.py [root]
  10. """
  11. import re
  12. import sys
  13. from pathlib import Path
  14. from urllib.parse import urlparse, unquote
  15. ROOT = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
  16. EXTERNAL = re.compile(r"^(https?|ftp|wss?|//):?")
  17. # Reference designs are not part of the deployable site.
  18. EXCLUDE_DIRS = {"Original", "Proposed", ".git"}
  19. # Site-root-relative pages that are linked ahead of their existence because a
  20. # later task in this goal builds them. Remove entries here once the page lands
  21. # so the checker enforces them again.
  22. UPCOMING_PAGES = {"developer/"}
  23. def check() -> int:
  24. errors: list[str] = []
  25. pages = sorted(p for p in ROOT.rglob("*.html")
  26. if not (set(p.relative_to(ROOT).parts[:-1]) & EXCLUDE_DIRS))
  27. if not pages:
  28. errors.append("no .html files found under root")
  29. ids_by_page: dict[Path, set[str]] = {}
  30. refs: list[tuple[Path, str, str]] = [] # page, attr value, kind
  31. linked_pages: set[Path] = set()
  32. for page in pages:
  33. text = page.read_text(encoding="utf-8")
  34. ids_by_page[page] = set(re.findall(r'id="([^"]+)"', text))
  35. # strip comments so commented-out template rows are not treated as live refs
  36. body = re.sub(r"<!--.*?-->", "", text, flags=re.S)
  37. for attr, val in re.findall(r'\b(src|href)="([^"]+)"', body):
  38. refs.append((page, val, attr))
  39. # naive tag-balance check for the elements we actually use
  40. for tag in ("html", "head", "body", "header", "nav", "main", "footer",
  41. "section", "article", "figure", "table", "ul", "ol", "div", "a", "p"):
  42. opens = len(re.findall(rf"<{tag}[\s>]", body))
  43. closes = len(re.findall(rf"</{tag}>", body))
  44. if opens != closes:
  45. errors.append(f"{page.name}: <{tag}> open/close mismatch ({opens}/{closes})")
  46. for page, val, _attr in refs:
  47. parsed = urlparse(val)
  48. if parsed.scheme or val.startswith("//"):
  49. if not (val.startswith("data:")):
  50. errors.append(f"{page.name}: EXTERNAL reference '{val}'")
  51. continue
  52. if parsed.fragment and not parsed.path:
  53. # same-page anchor
  54. if parsed.fragment not in ids_by_page[page]:
  55. errors.append(f"{page.name}: broken in-page anchor '#{parsed.fragment}'")
  56. continue
  57. target = (page.parent / unquote(parsed.path)).resolve()
  58. if not target.exists():
  59. rel = unquote(parsed.path).strip("/")
  60. if parsed.path.startswith("/") and rel in {p.strip("/") for p in UPCOMING_PAGES}:
  61. continue
  62. errors.append(f"{page.name}: missing asset '{val}'")
  63. continue
  64. if target.suffix == ".html" or target.name == "":
  65. # a page link; check its fragment if present
  66. linked_pages.add(target)
  67. if parsed.fragment:
  68. tp = target if target.suffix == ".html" else target / "index.html"
  69. if tp.is_file():
  70. if parsed.fragment not in ids_by_page.get(tp, set()):
  71. errors.append(f"{page.name}: broken anchor in {val}")
  72. # orphan check
  73. for page in pages:
  74. if page not in linked_pages and page != ROOT / "index.html":
  75. errors.append(f"orphan page (nothing links to it): {page.relative_to(ROOT)}")
  76. if errors:
  77. print(f"FAIL ({len(errors)} problems)")
  78. for e in errors:
  79. print(" -", e)
  80. return 1
  81. print(f"OK: {len(pages)} page(s), {len(refs)} reference(s), all local, all resolve")
  82. return 0
  83. if __name__ == "__main__":
  84. sys.exit(check())