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
+133
View File
@@ -0,0 +1,133 @@
"""Post-migration checks. Run after `hugo --gc --minify`.
1. Inventory - every published WordPress page/post has a content file.
2. Media - every attachment is either placed in the site or explicitly
accounted for as an unused media-library leftover.
3. Links - no internal href/src in public/ points at a missing file, and no
absolute wp-content URL survived the import.
Exits non-zero if any check fails.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
from urllib.parse import unquote, urlsplit
from wp2hugo import PAGE_MAP, SKIP, SLUG_FIXES
from wxr import REPO_ROOT, clean_media_name, load_items, slugify
PUBLIC = REPO_ROOT / "public"
CONTENT = REPO_ROOT / "content"
failures: list[str] = []
notes: list[str] = []
def check_inventory(items) -> None:
expected = 0
for item in items.values():
if item.post_id in SKIP or item.status != "publish":
continue
if item.post_type == "page":
mapping = PAGE_MAP.get(item.post_id)
if not mapping:
failures.append(f"page {item.post_id} ({item.slug}) is unmapped")
continue
path = CONTENT / mapping[0]
elif item.post_type == "post":
path = CONTENT / "aktuelles" / SLUG_FIXES.get(
item.slug, slugify(item.slug)) / "index.md"
else:
continue
expected += 1
if not path.exists():
failures.append(f"missing content file for {item.post_type} "
f"{item.post_id} ({item.slug}): {path}")
notes.append(f"inventory: {expected} published pages/posts accounted for")
for post_id, reason in SKIP.items():
notes.append(f" skipped {post_id}: {reason}")
def check_media(items) -> None:
placed = {p.name for p in CONTENT.rglob("*") if p.is_file()
and p.suffix.lower() != ".md"}
placed |= {p.name for p in (REPO_ROOT / "assets" / "img").rglob("*")
if p.is_file()}
placed |= {p.name for p in (REPO_ROOT / "static").rglob("*") if p.is_file()}
# assets/img/logo.png and banner.jpg are renamed copies.
placed |= {"Emblem_transparent.png", "cropped-Banner_small_web.jpg"}
unused = []
for item in items.values():
if item.post_type != "attachment" or not item.attachment_url:
continue
original = item.attachment_url.rsplit("/", 1)[-1]
if original in placed or clean_media_name(original) in placed:
continue
unused.append(item.attachment_url)
total = sum(1 for i in items.values() if i.post_type == "attachment")
notes.append(f"media: {total - len(unused)}/{total} attachments placed")
if unused:
notes.append(f" {len(unused)} media-library leftovers not shown on the "
f"live site (see MIGRATION.md)")
HREF = re.compile(r'(?:href|src)=["\']?([^"\'\s>]+)', re.I)
def check_links() -> None:
if not PUBLIC.exists():
failures.append("public/ does not exist - run `hugo` first")
return
checked = broken = 0
for html in PUBLIC.rglob("*.html"):
text = html.read_text(encoding="utf-8", errors="replace")
for stale in re.findall(r'https?://motorradclub-giebelwald\.de/wp-content/\S*',
text):
failures.append(f"{html.relative_to(PUBLIC)}: stale WordPress URL {stale}")
for raw in HREF.findall(text):
url = urlsplit(raw)
if url.scheme or url.netloc or not url.path:
continue # external, mailto:, protocol-relative
if raw.startswith("#"):
continue
checked += 1
path = unquote(url.path)
target = (PUBLIC / path.lstrip("/") if path.startswith("/")
else html.parent / path)
if target.is_dir():
target = target / "index.html"
if not target.exists():
broken += 1
failures.append(f"{html.relative_to(PUBLIC)}: broken link -> {raw}")
notes.append(f"links: {checked} internal references checked, {broken} broken")
def main() -> int:
items = load_items()
check_inventory(items)
check_media(items)
check_links()
for note in notes:
print(note)
if failures:
print(f"\n{len(failures)} FAILURE(S):", file=sys.stderr)
for f in failures:
print(f" - {f}", file=sys.stderr)
return 1
print("\nall checks passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())