diff --git a/docs/content-model.md b/docs/content-model.md
index 72a3de40..b7abef95 100644
--- a/docs/content-model.md
+++ b/docs/content-model.md
@@ -91,6 +91,18 @@ The scaffold is a starting point a human proofs, **not** a guaranteed lossless
transform — pages the extractor cannot parse cleanly are reported, not
silently emitted.
+## Deferred refinements
+
+- **`toc` (blog/event scrollspy labels).** The sidebar labels are often
+ hand-shortened relative to the `
` headings (e.g. heading "How an idea for
+ socializing became the start of a movement" → sidebar "Idea to Movement"). To
+ avoid regressing that editorial curation, U2 **preserves an existing populated
+ scrollspy** and only auto-generates one for a page that has none. A future
+ optional `toc:` front-matter list (ordered `{id, label}` pairs) would let the
+ CMS edit those short labels directly. Not needed to reproduce today's pages.
+- **`mobile_title`.** Some blog pages use a shortened mobile header title; U2
+ preserves the existing one and does not overwrite it.
+
## Non-goals for U1
- No CMS wiring (U7), no build/render of pages (U2), no listings/date refactor
diff --git a/tools/build_content.py b/tools/build_content.py
new file mode 100644
index 00000000..73d6eb57
--- /dev/null
+++ b/tools/build_content.py
@@ -0,0 +1,274 @@
+#!/usr/bin/env python3
+"""
+build_content.py -- render content/ source files into the site's detail pages.
+
+Implements U2 of docs/plans/2026-08-08-001-feat-no-code-cms-plan.md. Reads the
+front-matter + body produced by U1 (see docs/content-model.md) and writes the
+blog / news / event detail pages under /{blog,news,projects}//.
+
+Design: clone a template page of the SAME language + type (the page's own
+current file when it exists — "self-template" — so all chrome, scripts, nav and
+footer are preserved byte-for-byte), then replace only the regions that are
+derived from content:
+ -
+ - article header (date line, H1, subtitle) [article-page: blog + event]
+ - hero
+ - scrollspy sidebar (regenerated from the body's
headings) [blog+event]
+ - the article body (verbatim for body_format: html; converted for markdown)
+ - news pages use the simpler .news-article-* structure (no header/scrollspy)
+
+For body_format: html (what migration produces), the stored body fragment is
+injected verbatim, so guide-boxes, inline figures and event galleries render
+exactly as before. body_format: markdown (new CMS posts) is converted via
+build_blog's Markdown helpers.
+
+Usage:
+ python tools/build_content.py --check # build in memory, diff vs current pages
+ python tools/build_content.py --write # write the pages
+"""
+import argparse
+import html as html_mod
+import os
+import re
+import sys
+
+import yaml
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import build_blog # markdown -> sections/body for body_format: markdown # noqa: E402
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+LANGS = ("en", "mk", "si")
+TYPE_TO_DIR = {"blog": "blog", "news": "news", "events": "events"}
+# content type -> output url folder (events render to the existing projects/ tree)
+TYPE_TO_OUT_FOLDER = {"blog": "blog", "news": "news", "event": "projects"}
+DEPTH_PREFIX = "../../../" # every detail page sits at ///
+
+
+# --------------------------------------------------------------------------- IO
+
+def load_entry(path):
+ """Parse a content file into (meta_dict, body_str)."""
+ with open(path, encoding="utf-8") as f:
+ text = f.read()
+ if not text.startswith("---\n"):
+ raise ValueError(f"{path}: missing front-matter fence")
+ _, front, body = text.split("---\n", 2)
+ meta = yaml.safe_load(front) or {}
+ return meta, body.strip("\n")
+
+
+def iter_entries():
+ """Yield (meta, body, content_path) for every content/ file."""
+ for ctype_dir in TYPE_TO_DIR.values():
+ base = os.path.join(ROOT, "content", ctype_dir)
+ if not os.path.isdir(base):
+ continue
+ for slug in sorted(os.listdir(base)):
+ for lang in LANGS:
+ path = os.path.join(base, slug, f"{lang}.md")
+ if os.path.exists(path):
+ meta, body = load_entry(path)
+ yield meta, body, path
+
+
+def output_path(meta):
+ folder = TYPE_TO_OUT_FOLDER[meta["type"]]
+ return os.path.join(ROOT, meta["lang"], folder, meta["slug"], "index.html")
+
+
+# ------------------------------------------------------------------- rendering
+
+def _rel_img(src):
+ """Rewrite a root-relative /images/... path to the page's ../../../ depth."""
+ if src and src.startswith("/images/"):
+ return DEPTH_PREFIX + src.lstrip("/")
+ return src
+
+
+def render_body(meta, body):
+ """Return the body region markup (from
to just before
+ the container's )."""
+ if meta.get("body_format", "html") == "html":
+ return body # verbatim fragment (already includes the body wrapper)
+ # markdown -> article-body via build_blog's converter
+ _t, _s, sections = build_blog.parse_markdown("# x\n\n" + body)
+ inner = build_blog.render_body(sections, meta.get("category", ""), "", "")
+ return '
\n' + inner + '\n
'
+
+
+def _scrollspy_is_populated(base):
+ m = re.search(r'