/`.
+
+## 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.
diff --git a/tools/README.md b/tools/README.md
index f4b4db54..cb45858b 100644
--- a/tools/README.md
+++ b/tools/README.md
@@ -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
`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
Renders the `content/` source files into the blog/news/event detail pages
diff --git a/tools/build_content.py b/tools/build_content.py
index 73d6eb57..dda1217d 100644
--- a/tools/build_content.py
+++ b/tools/build_content.py
@@ -118,7 +118,9 @@ def render_article_header(meta):
f' {html_mod.escape(meta["title"])}
']
if meta.get("subtitle"):
lines.append(f' {html_mod.escape(meta["subtitle"])}
')
- 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):
@@ -138,7 +140,9 @@ def render_hero(hero, css_class):
if hero.get("caption_html"):
fig.append(f' {hero["caption_html"]}')
fig.append(' ')
- 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
diff --git a/tools/build_site.py b/tools/build_site.py
new file mode 100644
index 00000000..44e5df82
--- /dev/null
+++ b/tools/build_site.py
@@ -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)
diff --git a/tools/tests/test_build_content.py b/tools/tests/test_build_content.py
index 420098b1..50322d6d 100644
--- a/tools/tests/test_build_content.py
+++ b/tools/tests/test_build_content.py
@@ -42,6 +42,20 @@ class ReproducesEverySite(unittest.TestCase):
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):
def test_cyrillic_suffix_preserved(self):
# MK pages use ' - МСОС' (Cyrillic); it must survive, not be dropped.