#!/usr/bin/env python3 """ migrate_pages_to_content.py -- one-shot back-fill of the content/ source tree from the existing hand-authored detail pages. Implements U1 of docs/plans/2026-08-08-001-feat-no-code-cms-plan.md. See docs/content-model.md for the front-matter schema this produces. It walks en|mk|si/{blog,news,projects}//index.html, extracts the structured header fields (title, subtitle, category, date, hero image + caption) and preserves the article body verbatim as an HTML fragment (body_format: html), then writes content///.md. This is a SCAFFOLD a human proofs, not a guaranteed lossless transform. Pages it cannot parse cleanly are reported and skipped, never emitted half-formed. Usage: python tools/migrate_pages_to_content.py # dry run: report only python tools/migrate_pages_to_content.py --write # write content/ files """ import argparse import os import re import sys from bs4 import BeautifulSoup import yaml sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import seo_inject # noqa: E402 (central ARTICLE_DATES + date helpers) ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) LANGS = ("en", "mk", "si") # source folder -> content type FOLDER_TO_TYPE = {"blog": "blog", "news": "news", "projects": "event"} # content type -> content/ subfolder TYPE_TO_DIR = {"blog": "blog", "news": "news", "event": "events"} def _rootrel_img(src): """Normalise an image src to a root-relative /images/... path.""" if not src: return src m = re.search(r"(images/.*)$", src) return "/" + m.group(1) if m else src def _inner_html(node): """Verbatim inner HTML of a bs4 node (children concatenated).""" return "".join(str(c) for c in node.contents).strip() def _body_fragment(body): """Verbatim markup of the body element PLUS every following sibling within its container, with wrappers intact. Event pages keep the photo gallery (`section.event-gallery`) as a sibling that follows `.article-body`, so inner-HTML alone would drop it. Walking from the body element to the end of the container preserves the body wrapper and any trailing sections losslessly. """ parts, node = [], body while node is not None: parts.append(str(node)) node = node.next_sibling return "".join(parts).strip() def _text(node): return node.get_text(" ", strip=True) if node else "" def _iso_date(source_folder, slug, en_html=None): """(iso, kind) for an entry: prefer the central ARTICLE_DATES registry (preserving whether the date is a 'published' or 'modified' one), then fall back to reading it off the English page (mirrors build_listings).""" key = f"{source_folder}/{slug}" rec = seo_inject.ARTICLE_DATES.get(key, {}) if rec.get("published"): return rec["published"], "published" if rec.get("modified"): return rec["modified"], "modified" if en_html is not None: try: iso = seo_inject.extract_publish_date(en_html) if iso: return iso, "published" except Exception: pass return None, None def _parse_hero(figure): """Extract hero image + caption from a
node.""" if not figure: return None img = figure.find("img") hero = {} if img: hero["src"] = _rootrel_img(img.get("src")) if img.get("alt"): hero["alt"] = img.get("alt") if img.get("width"): hero["width"] = str(img.get("width")) if img.get("height"): hero["height"] = str(img.get("height")) cap = figure.find("figcaption") if cap: caption = _inner_html(cap) if caption: hero["caption_html"] = caption return hero or None def parse_page(html, source_folder, slug, lang, en_html=None): """Parse one detail page into a content record dict, or return None with a reason if the page shape is not recognised. Returns (record, None) on success or (None, reason) on failure. """ soup = BeautifulSoup(html, "html.parser") ctype = FOLDER_TO_TYPE[source_folder] h1 = soup.find("h1") if not h1: return None, "no

found" title = _text(h1) record = { "type": ctype, "slug": slug, "lang": lang, "source_folder": source_folder, "title": title, } if source_folder == "news": body = soup.select_one(".news-article-body") hero = _parse_hero(soup.select_one("figure.news-article-hero")) date_line = _text(soup.select_one(".news-article-date")) else: # blog + projects share the article-* structure body = soup.select_one(".article-body") hero = _parse_hero(soup.select_one("figure.article-main-image")) date_line = _text(soup.select_one(".article-publish-date")) subtitle = _text(soup.select_one(".article-subtitle")) if subtitle: record["subtitle"] = subtitle if body is None: return None, "no article body element found" # Best-effort category: blog date line is "Category · Last checked …". if date_line and "·" in date_line: cat, _sep, _rest = date_line.partition("·") cat = cat.strip() # Only treat as a category if it doesn't itself look like a date phrase. if cat and not re.search(r"\bpublished\b|\d{4}", cat, re.I): record["category"] = cat iso, kind = _iso_date(source_folder, slug, en_html=en_html) if not iso: return None, "no date (missing from ARTICLE_DATES and unparseable)" record["date"] = iso if kind == "modified": # 'published' is the implicit default; only record the exception. record["date_type"] = "modified" if date_line: record["date_line"] = date_line if hero: record["hero"] = hero record["body_format"] = "html" record["_body"] = _body_fragment(body) if not record["_body"]: return None, "article body is empty" return record, None # Deterministic front-matter key order for readable, stable output. _KEY_ORDER = [ "type", "slug", "lang", "source_folder", "title", "subtitle", "category", "date", "date_type", "date_line", "hero", "body_format", ] def to_file_text(record): """Serialise a content record to front-matter + body text.""" body = record.get("_body", "") meta = {k: record[k] for k in _KEY_ORDER if k in record} front = yaml.safe_dump( meta, allow_unicode=True, sort_keys=False, default_flow_style=False, width=1000 ) return f"---\n{front}---\n{body}\n" def iter_pages(): """Yield (lang, source_folder, slug, page_path) for every detail page.""" for lang in LANGS: for folder in ("blog", "news", "projects"): base = os.path.join(ROOT, lang, folder) if not os.path.isdir(base): continue for slug in sorted(os.listdir(base)): page = os.path.join(base, slug, "index.html") if os.path.exists(page): yield lang, folder, slug, page def _read(path): with open(path, encoding="utf-8") as f: return f.read() def run(write=False): written, skipped = [], [] # Cache English pages so non-en pages can fall back to the en date. en_cache = {} for lang, folder, slug, page in iter_pages(): if lang == "en": en_cache[(folder, slug)] = _read(page) for lang, folder, slug, page in iter_pages(): html = _read(page) en_html = en_cache.get((folder, slug)) record, reason = parse_page(html, folder, slug, lang, en_html=en_html) if record is None: skipped.append((f"{lang}/{folder}/{slug}", reason)) continue out_dir = os.path.join(ROOT, "content", TYPE_TO_DIR[record["type"]], slug) out_path = os.path.join(out_dir, f"{lang}.md") rel = os.path.relpath(out_path, ROOT).replace(os.sep, "/") if write: os.makedirs(out_dir, exist_ok=True) with open(out_path, "w", encoding="utf-8", newline="\n") as f: f.write(to_file_text(record)) written.append(rel) verb = "Wrote" if write else "Would write" print(f"{verb} {len(written)} content files:") for rel in written: print(f" + {rel}") if skipped: print(f"\nSkipped {len(skipped)} page(s) that need manual attention:") for name, reason in skipped: print(f" ! {name}: {reason}") if not write: print("\n(dry run — re-run with --write to create the files)") return written, skipped if __name__ == "__main__": ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--write", action="store_true", help="write content/ files") args = ap.parse_args() run(write=args.write)