diff --git a/docs/local-preview.md b/docs/local-preview.md new file mode 100644 index 00000000..2dfcd7fe --- /dev/null +++ b/docs/local-preview.md @@ -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: +- Македонски: +- Slovenščina: + +Blog / news / event pages live under, e.g., +`http://localhost:8000/en/blog//`. + +## 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.