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:
parent
8ff968739a
commit
2d535db7d5
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Local preview
|
||||||
|
|
||||||
|
How to build the site from `content/` and preview it on your machine, so you
|
||||||
|
can see blog / news / event changes before they go live.
|
||||||
|
|
||||||
|
## 1. Build
|
||||||
|
|
||||||
|
From the repo root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python tools/build_site.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs the whole pipeline in order (renders detail pages from `content/`,
|
||||||
|
rebuilds the News hub + homepage, injects SEO + sitemap, regenerates the
|
||||||
|
bot-discovery files, and rebuilds the offline search index). Any failing step
|
||||||
|
aborts the build, so a broken step never produces half-built output.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python tools/build_site.py --check # verify pages match content/, write nothing
|
||||||
|
python tools/build_site.py --images # also convert new images to WebP + fix refs
|
||||||
|
```
|
||||||
|
|
||||||
|
The build is **idempotent** — running it again with no content change produces
|
||||||
|
no file changes.
|
||||||
|
|
||||||
|
## 2. Preview
|
||||||
|
|
||||||
|
Serve the repo root over HTTP (opening the files directly with `file://` breaks
|
||||||
|
absolute paths and the language switcher):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m http.server 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open:
|
||||||
|
|
||||||
|
- English: <http://localhost:8000/en/>
|
||||||
|
- Македонски: <http://localhost:8000/mk/>
|
||||||
|
- Slovenščina: <http://localhost:8000/si/>
|
||||||
|
|
||||||
|
Blog / news / event pages live under, e.g.,
|
||||||
|
`http://localhost:8000/en/blog/<slug>/`.
|
||||||
|
|
||||||
|
## 3. Requirements
|
||||||
|
|
||||||
|
Python 3 with `beautifulsoup4`, `pyyaml`, and `Pillow` (only needed for
|
||||||
|
`--images`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m pip install beautifulsoup4 pyyaml pillow
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `content/` is the **source of truth** for blog/news/events; the detail pages
|
||||||
|
under `en|mk|si/{blog,news,projects}/` are build output. Edit content in
|
||||||
|
`content/` (or, once it is wired up, the CMS), not the generated pages.
|
||||||
|
- See `docs/content-model.md` for the front-matter schema and
|
||||||
|
`docs/plans/2026-08-08-001-feat-no-code-cms-plan.md` for the overall design.
|
||||||
|
|
@ -8,6 +8,13 @@ so **adding a post no longer needs a manual `ARTICLE_DATES` edit** — the date
|
||||||
lives in the post's front-matter (`date` / `date_type`). See
|
lives in the post's front-matter (`date` / `date_type`). See
|
||||||
`docs/content-model.md`.
|
`docs/content-model.md`.
|
||||||
|
|
||||||
|
## build_site.py
|
||||||
|
|
||||||
|
One command to build the whole site from `content/`, in dependency order
|
||||||
|
(content pages -> listings -> SEO/sitemap -> bot links -> search index). This is
|
||||||
|
what CI runs before deploying. `--check` verifies without writing; `--images`
|
||||||
|
also runs image optimisation. Idempotent. See `docs/local-preview.md`.
|
||||||
|
|
||||||
## build_content.py
|
## build_content.py
|
||||||
|
|
||||||
Renders the `content/` source files into the blog/news/event detail pages
|
Renders the `content/` source files into the blog/news/event detail pages
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,9 @@ def render_article_header(meta):
|
||||||
f' <h1>{html_mod.escape(meta["title"])}</h1>']
|
f' <h1>{html_mod.escape(meta["title"])}</h1>']
|
||||||
if meta.get("subtitle"):
|
if meta.get("subtitle"):
|
||||||
lines.append(f' <p class="article-subtitle">{html_mod.escape(meta["subtitle"])}</p>')
|
lines.append(f' <p class="article-subtitle">{html_mod.escape(meta["subtitle"])}</p>')
|
||||||
return "\n".join(lines)
|
# First line carries no indent: the template's pre-tag whitespace positions
|
||||||
|
# it. Adding our own would compound on every rebuild (non-idempotent).
|
||||||
|
return "\n".join(lines).lstrip()
|
||||||
|
|
||||||
|
|
||||||
def render_hero(hero, css_class):
|
def render_hero(hero, css_class):
|
||||||
|
|
@ -138,7 +140,9 @@ def render_hero(hero, css_class):
|
||||||
if hero.get("caption_html"):
|
if hero.get("caption_html"):
|
||||||
fig.append(f' <figcaption>{hero["caption_html"]}</figcaption>')
|
fig.append(f' <figcaption>{hero["caption_html"]}</figcaption>')
|
||||||
fig.append(' </figure>')
|
fig.append(' </figure>')
|
||||||
return "\n".join(fig)
|
# First line unindented — the template's pre-tag whitespace positions it
|
||||||
|
# (otherwise indentation compounds on every rebuild).
|
||||||
|
return "\n".join(fig).lstrip()
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------- page assembly
|
# ------------------------------------------------------------- page assembly
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -42,6 +42,20 @@ class ReproducesEverySite(unittest.TestCase):
|
||||||
self.assertEqual(mismatches, [], f"pages diverged from the live site: {mismatches}")
|
self.assertEqual(mismatches, [], f"pages diverged from the live site: {mismatches}")
|
||||||
|
|
||||||
|
|
||||||
|
class RenderIsIdempotent(unittest.TestCase):
|
||||||
|
def test_rendering_output_again_is_a_fixpoint(self):
|
||||||
|
"""Re-rendering a page using its own rendered output as the template
|
||||||
|
must be byte-identical — otherwise the full build churns every run
|
||||||
|
(e.g. compounding indentation)."""
|
||||||
|
drift = []
|
||||||
|
for meta, body, _path in bc.iter_entries():
|
||||||
|
r1 = bc.build_entry(meta, body, bc._template_html(meta))
|
||||||
|
r2 = bc.build_entry(meta, body, r1)
|
||||||
|
if r1 != r2:
|
||||||
|
drift.append(bc.output_path(meta).replace(os.sep, "/"))
|
||||||
|
self.assertEqual(drift, [], f"non-idempotent renders: {drift}")
|
||||||
|
|
||||||
|
|
||||||
class TitleSuffixFaithful(unittest.TestCase):
|
class TitleSuffixFaithful(unittest.TestCase):
|
||||||
def test_cyrillic_suffix_preserved(self):
|
def test_cyrillic_suffix_preserved(self):
|
||||||
# MK pages use ' - МСОС' (Cyrillic); it must survive, not be dropped.
|
# MK pages use ' - МСОС' (Cyrillic); it must survive, not be dropped.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue