feat(cms): U4 one-command site build + local preview
Add tools/build_site.py: runs the full pipeline in dependency order (build_content -> build_listings -> seo_inject --all --sitemap -> build_botlinks -> build_search_index), each as a subprocess so a failing step aborts the build before anything downstream runs (no partial deploy). --check verifies non-destructively; --images also runs optimize_images. This is what CI will run in U5. Fix a build_content idempotence bug found via U4: the hero and article-header regions were emitted with leading indentation while the regex left the template's pre-tag whitespace in place, so indentation compounded (+16 spaces) on every rebuild. First line is now unindented; the full build is verified idempotent (identical git tree across two runs). Added a fixpoint test (re-rendering a page's own output is byte-identical) so this can't regress. docs/local-preview.md: build + serve on :8000 in all three languages. tools/README.md: document build_site + build_content. 26 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8ff968739a
commit
2d535db7d5
5 files changed
+175
-2
No files matched your search
@@ -0,0 +1,86 @@
|
||||
#!/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"]),
|
||||
]
|
||||
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)
|
||||
Reference in new issue
Block a user