msos/tools/build_content.py

275 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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)