Migrate WordPress site to Hugo

Replaces the WordPress 7.0.2 / HitMag site with a static Hugo build,
deployed by Gitea Actions over rsync/SSH.

Content: 26 files (9 pages + 17 posts) as Markdown page bundles, plus
127 of 165 media attachments. The remaining 38 are media-library
leftovers that appear nowhere on the live site; they are listed in
MIGRATION.md.

The WXR export contains neither media binaries nor widgets, so both were
recovered from the live host before it is retired:
  - tools/fetch_media.py downloads all uploads, capping them at 2000px
  - tools/build_data.py scrapes the "Unsere Mitglieder" and
    "In Erinnerung" widgets into data/*.yaml

TablePress' six Termine tables became data/termine.yaml, rendered with
the newest year expanded and earlier years collapsed. Old permalinks are
not preserved (clean slugs, no redirects, as agreed).

Custom layouts, no third-party theme. Plain CSS, since the pinned Hugo
0.164.0 is the non-extended build and cannot compile Sass.

Verified: clean build with zero warnings, 718 internal links checked and
none broken, no surviving wp-content URLs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 19:20:11 +02:00
parent eccf30584a
commit bef5182545
188 changed files with 2770 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
"""Generate data/*.yaml.
Two sources:
* ``data/termine.yaml`` comes from the six TablePress tables in the WXR export.
* ``data/members.yaml`` and ``data/memoriam.yaml`` come from the *live* homepage.
Those two blocks were WordPress widgets, and WXR does not export widgets at all,
so scraping the rendered page is the only way to recover them.
Run once during the migration. After that the YAML files are the source of truth.
"""
from __future__ import annotations
import html
import json
import re
import sys
import urllib.request
from pathlib import Path
from wxr import REPO_ROOT, clean_media_name, load_items
LIVE_HOME = "https://motorradclub-giebelwald.de/"
USER_AGENT = "mcg-website-migration/1.0 (+https://mcg.luxick.de)"
DATA = REPO_ROOT / "data"
def yaml_str(value: str) -> str:
"""Always double-quote; the cells contain colons, umlauts and inline HTML."""
return '"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + '"'
# --------------------------------------------------------------------------- termine
def build_termine() -> None:
items = load_items()
tables = [i for i in items.values() if i.post_type == "tablepress_table"]
years: dict[int, list[dict[str, str]]] = {}
for table in tables:
match = re.search(r"(\d{4})", table.title)
if not match:
print(f"!! skipping table without a year in the title: {table.title}",
file=sys.stderr)
continue
year = int(match.group(1))
rows = []
for row in json.loads(table.content):
date = (row[0] if len(row) > 0 else "").strip()
desc = (row[1] if len(row) > 1 else "").strip()
if not date and not desc:
continue # TablePress spacer rows
rows.append({"date": date, "description": desc})
years[year] = rows
lines = [
"# Vereinstermine, ursprünglich TablePress-Tabellen aus WordPress.",
"# Neue Termine: einfach unter dem passenden Jahr ergänzen.",
"",
]
for year in sorted(years, reverse=True):
lines.append(f'"{year}":')
if not years[year]:
lines[-1] += " []"
continue
for row in years[year]:
lines.append(f" - date: {yaml_str(row['date'])}")
lines.append(f" description: {yaml_str(row['description'])}")
lines.append("")
(DATA / "termine.yaml").write_text("\n".join(lines).rstrip() + "\n",
encoding="utf-8")
total = sum(len(v) for v in years.values())
print(f"termine.yaml: {len(years)} Jahre, {total} Termine")
# ------------------------------------------------------------------- widget scraping
def live_home() -> str:
req = urllib.request.Request(LIVE_HOME, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=60) as resp:
return resp.read().decode("utf-8", errors="replace")
GALLERY_ITEM = re.compile(
r"<figure class='gallery-item'>.*?"
r"<a href='(?P<href>[^']+)'>.*?"
r"(?:<figcaption[^>]*>(?P<caption>.*?)</figcaption>)?"
r"</figure>",
re.S,
)
def gallery_entries(fragment: str) -> list[tuple[str, str]]:
out = []
for m in GALLERY_ITEM.finditer(fragment):
href = m.group("href")
caption = html.unescape(re.sub(r"<[^>]+>", "", m.group("caption") or "")).strip()
out.append((href, caption))
return out
def image_ref(url: str) -> str:
"""Map an uploads URL to the committed path under assets/img/."""
return "img/" + clean_media_name(url.rsplit("/", 1)[-1])
def build_widgets() -> None:
page = live_home()
# Each widget renders as <h4 class="widget-title">NAME</h4> followed by a WP
# gallery. Keep the trailing </figure> inside the group, otherwise the last
# entry of every gallery is silently dropped.
members_html = re.search(
r"Unsere Mitglieder</h4>(.*?</figure>)\s*</div>", page, re.S
)
memoriam_html = re.search(
r"In Erinnerung</h4>(.*?</figure>)\s*</div>", page, re.S
)
if not members_html or not memoriam_html:
raise SystemExit("could not locate the member/memoriam widgets on the live page")
members = gallery_entries(members_html.group(1))
memoriam = gallery_entries(memoriam_html.group(1))
# The WordPress gallery widget shuffles its order on every request, so the
# source order carries no meaning. Sort by name for a stable, readable list.
members.sort(key=lambda entry: entry[1].casefold())
lines = [
"# Mitglieder-Galerie der Startseite.",
"# Aus dem WordPress-Widget übernommen (Widgets sind nicht im XML-Export enthalten).",
"",
]
for url, name in members:
lines.append(f"- name: {yaml_str(name)}")
lines.append(f" image: {yaml_str(image_ref(url))}")
(DATA / "members.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"members.yaml: {len(members)} Mitglieder")
lines = [
"# \"In Erinnerung\" ebenfalls aus einem WordPress-Widget übernommen.",
"",
]
for url, caption in memoriam:
# Captions look like "Roland Groos ✝ 27.5.2020".
name, _, died = caption.partition("")
lines.append(f"- name: {yaml_str(name.strip())}")
lines.append(f" died: {yaml_str(died.strip())}")
lines.append(f" image: {yaml_str(image_ref(url))}")
(DATA / "memoriam.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"memoriam.yaml: {len(memoriam)} Einträge")
return [image_ref(u) for u, _ in members] + [image_ref(u) for u, _ in memoriam]
def main() -> int:
DATA.mkdir(parents=True, exist_ok=True)
build_termine()
build_widgets()
return 0
if __name__ == "__main__":
raise SystemExit(main())