#!/usr/bin/env python3 """ build_site.py -- one command to build the whole site from content/. Implements U4 of docs/plans/2026-08-08-001-feat-no-code-cms-plan.md. Runs the existing tools in dependency order so a single content edit propagates to every derived page. This is what CI runs before deploying (U5). Order matters: 1. build_content content/ front-matter + body -> detail pages 2. build_listings detail pages -> News hub + homepage "Latest news" 3. seo_inject inject SEO block + rebuild sitemap.xml / robots.txt 4. build_botlinks regenerate /links-for-bots/ + llms.txt from the pages 5. build_search_index rebuild the offline assistant index Each step is a separate process (build_search_index chdir's at import, and this keeps a failing step from corrupting later ones). Any non-zero exit aborts the build so a broken step never reaches deploy. Usage: python tools/build_site.py # full build (writes pages) python tools/build_site.py --images # also optimise images -> WebP first python tools/build_site.py --check # non-destructive: verify pages match content/ """ import argparse import os import subprocess import sys import time ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # (description, argv relative to ROOT) BUILD_STEPS = [ ("Render detail pages from content/", ["tools/build_content.py", "--write"]), ("Rebuild News hub + homepage listings", ["tools/build_listings.py"]), ("Inject SEO + rebuild sitemap/robots", ["tools/seo_inject.py", "--all", "--sitemap"]), ("Rebuild bot/AI discovery files", ["tools/build_botlinks.py"]), ("Rebuild offline assistant search index", ["tools/build_search_index.py"]), # Last: stamp a content-hash ?v= on local CSS/JS refs so deploys aren't # served stale from cache. Must run after every step that writes pages. ("Cache-bust CSS/JS references", ["tools/cache_bust.py"]), ] IMAGES_STEP = ("Optimise images to WebP + update refs", ["optimize_images.py"]) CHECK_STEP = ("Verify rendered pages match content/", ["tools/build_content.py", "--check"]) def _run(desc, argv): print(f"\n>>> {desc}\n $ python {' '.join(argv)}") env = {**os.environ, "PYTHONIOENCODING": "utf-8"} t0 = time.time() result = subprocess.run([sys.executable, *argv], cwd=ROOT, env=env) dt = time.time() - t0 if result.returncode != 0: raise SystemExit( f"\n!!! Step failed ({result.returncode}): {desc}\n" f" Build aborted — nothing downstream ran, so no partial output is deployed.") print(f" done ({dt:.1f}s)") def build(images=False): steps = list(BUILD_STEPS) if images: steps.insert(1, IMAGES_STEP) # after pages exist, before refs are consumed print(f"Building site from content/ ({len(steps)} steps)") t0 = time.time() for desc, argv in steps: _run(desc, argv) print(f"\nBuild complete in {time.time() - t0:.1f}s. " f"Preview locally with: python -m http.server 8000 (see docs/local-preview.md)") def check(): print("Verifying the site matches content/ (no files written)...") _run(*CHECK_STEP) if __name__ == "__main__": ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--images", action="store_true", help="also run optimize_images.py (WebP conversion + ref rewrite)") ap.add_argument("--check", action="store_true", help="verify rendered pages match content/ without writing") args = ap.parse_args() if args.check: check() else: build(images=args.images)