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:
+126
@@ -0,0 +1,126 @@
|
||||
"""Shared helpers for reading the WordPress WXR export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
NS = {
|
||||
"wp": "http://wordpress.org/export/1.2/",
|
||||
"content": "http://purl.org/rss/1.0/modules/content/",
|
||||
"excerpt": "http://wordpress.org/export/1.2/excerpt/",
|
||||
"dc": "http://purl.org/dc/elements/1.1/",
|
||||
}
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
EXPORT = REPO_ROOT / "wordpress-export" / "motorradclubgiebelwaldev.WordPress.2026-07-28.xml"
|
||||
CACHE = REPO_ROOT / ".cache" / "uploads"
|
||||
|
||||
# German transliteration first, so "Jubiläum" becomes "jubilaeum" and not "jubilaum".
|
||||
UMLAUTS = {
|
||||
"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss",
|
||||
"Ä": "Ae", "Ö": "Oe", "Ü": "Ue",
|
||||
}
|
||||
|
||||
|
||||
def slugify(value: str, *, keep_dots: bool = False) -> str:
|
||||
for src, dst in UMLAUTS.items():
|
||||
value = value.replace(src, dst)
|
||||
value = unicodedata.normalize("NFKD", value)
|
||||
value = "".join(c for c in value if not unicodedata.combining(c))
|
||||
allowed = r"[^a-z0-9._-]+" if keep_dots else r"[^a-z0-9-]+"
|
||||
value = re.sub(allowed, "-", value.lower())
|
||||
return re.sub(r"-{2,}", "-", value).strip("-")
|
||||
|
||||
|
||||
def slugify_filename(name: str) -> str:
|
||||
stem, _, ext = name.rpartition(".")
|
||||
return f"{slugify(stem, keep_dots=False)}.{ext.lower()}"
|
||||
|
||||
|
||||
def clean_media_name(name: str) -> str:
|
||||
"""Strip the bookkeeping suffixes WordPress bakes into upload filenames.
|
||||
|
||||
``Harry_2-e1654849333614.jpg`` -> ``harry-2.jpg``
|
||||
``IMG_20220527_121440-small-scaled.jpg`` -> ``img-20220527-121440-small.jpg``
|
||||
"""
|
||||
stem, _, ext = slugify_filename(name).rpartition(".")
|
||||
stem = re.sub(r"-e\d{9,}$", "", stem) # post-crop revision id
|
||||
stem = re.sub(r"-\d+x\d+$", "", stem) # generated size variant
|
||||
stem = re.sub(r"-scaled$", "", stem) # WP "big image" downscale
|
||||
return f"{stem or 'bild'}.{ext}"
|
||||
|
||||
|
||||
def text(node: ET.Element | None, path: str, default: str = "") -> str:
|
||||
if node is None:
|
||||
return default
|
||||
found = node.find(path, NS)
|
||||
if found is None or found.text is None:
|
||||
return default
|
||||
return found.text
|
||||
|
||||
|
||||
@dataclass
|
||||
class Item:
|
||||
element: ET.Element
|
||||
post_id: str
|
||||
post_type: str
|
||||
status: str
|
||||
title: str
|
||||
slug: str
|
||||
link: str
|
||||
parent: str
|
||||
date_gmt: str
|
||||
modified_gmt: str
|
||||
content: str
|
||||
excerpt: str
|
||||
attachment_url: str
|
||||
meta: dict[str, str] = field(default_factory=dict)
|
||||
categories: list[tuple[str, str]] = field(default_factory=list)
|
||||
|
||||
|
||||
def load_items() -> dict[str, Item]:
|
||||
channel = ET.parse(EXPORT).getroot().find("channel")
|
||||
items: dict[str, Item] = {}
|
||||
for el in channel.findall("item"):
|
||||
meta = {}
|
||||
for m in el.findall("wp:postmeta", NS):
|
||||
key = text(m, "wp:meta_key")
|
||||
meta[key] = text(m, "wp:meta_value")
|
||||
cats = [
|
||||
(c.get("domain", ""), c.get("nicename", ""))
|
||||
for c in el.findall("category")
|
||||
]
|
||||
item = Item(
|
||||
element=el,
|
||||
post_id=text(el, "wp:post_id"),
|
||||
post_type=text(el, "wp:post_type"),
|
||||
status=text(el, "wp:status"),
|
||||
title=(el.findtext("title") or "").strip(),
|
||||
slug=text(el, "wp:post_name"),
|
||||
link=(el.findtext("link") or "").strip(),
|
||||
parent=text(el, "wp:post_parent"),
|
||||
date_gmt=text(el, "wp:post_date_gmt"),
|
||||
modified_gmt=text(el, "wp:post_modified_gmt"),
|
||||
content=text(el, "content:encoded"),
|
||||
excerpt=text(el, "excerpt:encoded"),
|
||||
attachment_url=text(el, "wp:attachment_url"),
|
||||
meta=meta,
|
||||
categories=cats,
|
||||
)
|
||||
items[item.post_id] = item
|
||||
return items
|
||||
|
||||
|
||||
def attachments(items: dict[str, Item]) -> dict[str, Item]:
|
||||
return {k: v for k, v in items.items() if v.post_type == "attachment"}
|
||||
|
||||
|
||||
def cache_path(url: str) -> Path:
|
||||
"""Local cache location mirroring the wp-content/uploads/YYYY/MM layout."""
|
||||
rel = url.split("/wp-content/uploads/", 1)[-1]
|
||||
parts = rel.split("/")
|
||||
return CACHE.joinpath(*parts[:-1], slugify_filename(parts[-1]))
|
||||
Reference in New Issue
Block a user