Publish CMS foundation + 2025–2026 events/content (excludes MSS 2026) #18
|
|
@ -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 `<h2>` 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
|
||||
|
|
|
|||
|
|
@ -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 <lang>/{blog,news,projects}/<slug>/.
|
||||
|
||||
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:
|
||||
- <title>
|
||||
- article header (date line, H1, subtitle) [article-page: blog + event]
|
||||
- hero <figure>
|
||||
- scrollspy sidebar (regenerated from the body's <h2 id> 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 <lang>/<folder>/<slug>/
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- 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 <div class=...-body> to just before
|
||||
the container's </article>)."""
|
||||
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 '<div class="article-body">\n' + inner + '\n </div>'
|
||||
|
||||
|
||||
def _scrollspy_is_populated(base):
|
||||
m = re.search(r'<nav class="scrollspy-nav">\s*<ul>(.*?)</ul>', base, re.DOTALL)
|
||||
return bool(m and "<li" in m.group(1))
|
||||
|
||||
|
||||
def render_scrollspy_from_body(body_html):
|
||||
"""Regenerate the scrollspy <li> list from the body's <h2 id=...> headings."""
|
||||
items, first = [], True
|
||||
for m in re.finditer(r'<h2 id="([^"]+)">(.*?)</h2>', body_html, re.DOTALL):
|
||||
hid, label = m.group(1), re.sub(r"<[^>]+>", "", m.group(2)).strip()
|
||||
cls = ' class="active"' if first else ""
|
||||
items.append(f' <li><a href="#{hid}"{cls}>{label}</a></li>')
|
||||
first = False
|
||||
return "\n".join(items)
|
||||
|
||||
|
||||
def render_article_header(meta):
|
||||
lines = [f' <p class="article-publish-date">{meta.get("date_line", "")}</p>',
|
||||
f' <h1>{html_mod.escape(meta["title"])}</h1>']
|
||||
if meta.get("subtitle"):
|
||||
lines.append(f' <p class="article-subtitle">{html_mod.escape(meta["subtitle"])}</p>')
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_hero(hero, css_class):
|
||||
"""Rebuild a hero <figure> (article-main-image or news-article-hero)."""
|
||||
if not hero:
|
||||
return None
|
||||
attrs = []
|
||||
if hero.get("width"):
|
||||
attrs.append(f'width="{hero["width"]}"')
|
||||
if hero.get("height"):
|
||||
attrs.append(f'height="{hero["height"]}"')
|
||||
attrs.append('fetchpriority="high"')
|
||||
attrs.append(f'src="{_rel_img(hero.get("src", ""))}"')
|
||||
attrs.append(f'alt="{html_mod.escape(hero.get("alt", ""))}"')
|
||||
fig = [f' <figure class="{css_class}">',
|
||||
f' <img {" ".join(attrs)}>']
|
||||
if hero.get("caption_html"):
|
||||
fig.append(f' <figcaption>{hero["caption_html"]}</figcaption>')
|
||||
fig.append(' </figure>')
|
||||
return "\n".join(fig)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- page assembly
|
||||
|
||||
def _title_suffix(base, escaped_title):
|
||||
"""Preserve each page's existing <title> suffix verbatim (EN/SI use
|
||||
' - MSOS', MK uses the Cyrillic ' - МСОС', some events use none) by taking
|
||||
whatever trails the title base in the current page. Language-agnostic."""
|
||||
m = re.search(r"<title>(.*?)</title>", base, re.DOTALL)
|
||||
if not m:
|
||||
return ""
|
||||
cur = m.group(1)
|
||||
return cur[len(escaped_title):] if cur.startswith(escaped_title) else ""
|
||||
|
||||
|
||||
def _sub1(pattern, repl, text, what):
|
||||
new, n = re.subn(pattern, lambda m: repl, text, count=1, flags=re.DOTALL)
|
||||
if n != 1:
|
||||
raise ValueError(f"region not found: {what}")
|
||||
return new
|
||||
|
||||
|
||||
def build_article_page(meta, body, base):
|
||||
"""blog + event pages (main.article-page)."""
|
||||
title = html_mod.escape(meta["title"])
|
||||
base = _sub1(r"<title>.*?</title>", f"<title>{title}{_title_suffix(base, title)}</title>", base, "title")
|
||||
base = _sub1(
|
||||
r'<p class="article-publish-date">.*?</p>\s*<h1>.*?</h1>(?:\s*<p class="article-subtitle">.*?</p>)?',
|
||||
render_article_header(meta), base, "article-header",
|
||||
)
|
||||
hero = render_hero(meta.get("hero"), "article-main-image")
|
||||
if hero:
|
||||
base = _sub1(r'<figure class="article-main-image">.*?</figure>', hero, base, "hero")
|
||||
# Scrollspy labels are hand-curated (often shorter than the H2 headings), so
|
||||
# a populated sidebar is preserved as-is. Only generate one when the template
|
||||
# has none yet (a brand-new page). See docs/content-model.md (`toc`).
|
||||
if not _scrollspy_is_populated(base):
|
||||
nav = render_scrollspy_from_body(body)
|
||||
if nav:
|
||||
base = re.sub(r'(<nav class="scrollspy-nav">\s*<ul>).*?(</ul>)',
|
||||
lambda m: m.group(1) + "\n" + nav + "\n " + m.group(2),
|
||||
base, count=1, flags=re.DOTALL)
|
||||
base = _sub1(r'<div class="article-body">.*?</article>',
|
||||
body + "\n </article>", base, "article-body")
|
||||
return base
|
||||
|
||||
|
||||
def build_news_page(meta, body, base):
|
||||
"""news pages (main.news-article)."""
|
||||
title = html_mod.escape(meta["title"])
|
||||
base = _sub1(r"<title>.*?</title>", f"<title>{title}{_title_suffix(base, title)}</title>", base, "title")
|
||||
base = _sub1(r"<h1>.*?</h1>", f"<h1>{title}</h1>", base, "news h1")
|
||||
base = _sub1(r'<p class="news-article-date">.*?</p>',
|
||||
f'<p class="news-article-date">{meta.get("date_line", "")}</p>', base, "news date")
|
||||
hero = render_hero(meta.get("hero"), "news-article-hero")
|
||||
if hero:
|
||||
base = _sub1(r'<figure class="news-article-hero">.*?</figure>', hero, base, "news hero")
|
||||
base = _sub1(r'<div class="news-article-body">.*?</article>',
|
||||
body + "\n </article>", base, "news body")
|
||||
return base
|
||||
|
||||
|
||||
def build_entry(meta, body, base_html):
|
||||
if meta["type"] == "news":
|
||||
return build_news_page(meta, body, base_html)
|
||||
return build_article_page(meta, body, base_html)
|
||||
|
||||
|
||||
def _template_html(meta):
|
||||
"""Self-template: use the page's own current file as the chrome skeleton."""
|
||||
out = output_path(meta)
|
||||
if not os.path.exists(out):
|
||||
raise FileNotFoundError(
|
||||
f"no template page for {meta['lang']}/{meta['type']}/{meta['slug']} "
|
||||
f"(new-page templates are handled in a later unit)")
|
||||
with open(out, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def run(write=False, check=False):
|
||||
built = 0
|
||||
mismatches = []
|
||||
for meta, body, path in iter_entries():
|
||||
base = _template_html(meta)
|
||||
rendered = build_entry(meta, body, base)
|
||||
out = output_path(meta)
|
||||
if check:
|
||||
with open(out, encoding="utf-8") as f:
|
||||
current = f.read()
|
||||
if _normalize(rendered) != _normalize(current):
|
||||
mismatches.append(os.path.relpath(out, ROOT).replace(os.sep, "/"))
|
||||
if write:
|
||||
with open(out, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(rendered)
|
||||
built += 1
|
||||
|
||||
print(f"Rendered {built} pages from content/.")
|
||||
if check:
|
||||
if mismatches:
|
||||
print(f"\n{len(mismatches)} page(s) differ from the current site "
|
||||
f"(semantic comparison):")
|
||||
for m in mismatches:
|
||||
print(f" ~ {m}")
|
||||
else:
|
||||
print("All rendered pages are equivalent to the current site. [OK]")
|
||||
return built, mismatches
|
||||
|
||||
|
||||
# ----------------------------------------------------- normalization (for --check)
|
||||
|
||||
def _normalize(html_text):
|
||||
"""Collapse insignificant whitespace and canonicalise attribute order so the
|
||||
comparison ignores formatting-only differences (per plan R5)."""
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(html_text, "html.parser")
|
||||
for tag in soup.find_all(True):
|
||||
if tag.attrs:
|
||||
tag.attrs = {k: tag.attrs[k] for k in sorted(tag.attrs)}
|
||||
text = str(soup)
|
||||
text = re.sub(r">\s+<", "><", text) # drop whitespace between tags
|
||||
text = re.sub(r"\s+", " ", text) # collapse runs of whitespace
|
||||
return text.strip()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--write", action="store_true", help="write the detail pages")
|
||||
ap.add_argument("--check", action="store_true",
|
||||
help="compare rendered output to the current pages (no write)")
|
||||
args = ap.parse_args()
|
||||
if not (args.write or args.check):
|
||||
args.check = True
|
||||
run(write=args.write, check=args.check)
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Characterization tests for tools/build_content.py (U2).
|
||||
|
||||
The core guarantee (plan R5): rendering every content/ entry reproduces the
|
||||
CURRENT live detail page, compared semantically (whitespace- and
|
||||
attribute-order-insensitive). This is what protects against SEO/layout
|
||||
regressions when the build takes over page generation.
|
||||
|
||||
Run directly: python tools/tests/test_build_content.py
|
||||
Or with pytest: pytest tools/tests/test_build_content.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
ROOT = os.path.dirname(TOOLS)
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
import build_content as bc # noqa: E402
|
||||
|
||||
|
||||
def _render(out_rel):
|
||||
"""Render the content entry whose output path ends with out_rel."""
|
||||
for meta, body, _path in bc.iter_entries():
|
||||
if bc.output_path(meta).replace(os.sep, "/").endswith(out_rel):
|
||||
base = bc._template_html(meta)
|
||||
return meta, bc.build_entry(meta, body, base)
|
||||
raise AssertionError(f"no content entry maps to {out_rel}")
|
||||
|
||||
|
||||
def _current(out_rel):
|
||||
with open(os.path.join(ROOT, out_rel), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
class ReproducesEverySite(unittest.TestCase):
|
||||
def test_all_pages_semantically_equivalent(self):
|
||||
"""Every rendered page must equal the current page (semantic compare)."""
|
||||
_built, mismatches = bc.run(check=True, write=False)
|
||||
self.assertEqual(mismatches, [], f"pages diverged from the live site: {mismatches}")
|
||||
|
||||
|
||||
class TitleSuffixFaithful(unittest.TestCase):
|
||||
def test_cyrillic_suffix_preserved(self):
|
||||
# MK pages use ' - МСОС' (Cyrillic); it must survive, not be dropped.
|
||||
meta, rendered = _render("mk/projects/macedonian-student-night-in-ljubljana/index.html")
|
||||
self.assertIn("- МСОС</title>", rendered) # - МСОС
|
||||
|
||||
def test_no_suffix_stays_absent(self):
|
||||
# Some event pages have no ' - MSOS' suffix; we must not invent one.
|
||||
_meta, rendered = _render("en/projects/morning-coffee-in-front-of-ctk/index.html")
|
||||
self.assertNotIn("- MSOS</title>", rendered)
|
||||
|
||||
|
||||
class RichContentSurvives(unittest.TestCase):
|
||||
def test_event_gallery_and_lightbox_render(self):
|
||||
_meta, rendered = _render("en/projects/paint-and-wine-at-sunset/index.html")
|
||||
self.assertIn("event-gallery", rendered)
|
||||
self.assertIn("eg-thumb", rendered)
|
||||
|
||||
def test_blog_guidebox_and_scrollspy_render(self):
|
||||
_meta, rendered = _render("en/blog/how-to-open-slovenian-bank-account/index.html")
|
||||
self.assertIn("guide-box", rendered)
|
||||
self.assertIn('class="scrollspy-nav"', rendered)
|
||||
|
||||
|
||||
class ScrollspyPreservesCuratedLabels(unittest.TestCase):
|
||||
def test_hand_shortened_labels_not_overwritten(self):
|
||||
# The body H2 is long; the sidebar label was shortened by hand. The
|
||||
# rendered page must keep the curated short label, not the H2 text.
|
||||
_meta, rendered = _render("en/projects/macedonian-student-night-in-ljubljana/index.html")
|
||||
self.assertIn(">Idea to Movement</a>", rendered)
|
||||
|
||||
|
||||
class HeroPathDepth(unittest.TestCase):
|
||||
def test_hero_src_uses_relative_depth(self):
|
||||
_meta, rendered = _render("en/blog/how-to-open-slovenian-bank-account/index.html")
|
||||
# Hero must resolve from the page's depth, not a root-absolute path.
|
||||
self.assertIn('src="../../../images/', rendered)
|
||||
self.assertNotIn('src="/images/', rendered)
|
||||
|
||||
|
||||
class NewsStructure(unittest.TestCase):
|
||||
def test_news_uses_news_article_body(self):
|
||||
_meta, rendered = _render("en/news/proof-of-means-updated-2026/index.html")
|
||||
self.assertIn('class="news-article-body"', rendered)
|
||||
self.assertIn('class="news-article-date"', rendered)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Loading…
Reference in New Issue