Migration d’un blog WordPress vers Hugo (via npx et Python)

Ce guide détaille la procédure complète pour exporter le contenu d’un site WordPress, le convertir en fichiers Markdown pour Hugo, restaurer les commentaires originaux et appliquer un thème.

Prerequisites / Prérequis

  • Hugo (version extended recommandée) installé sur votre machine (brew install hugo).
  • Node.js pour exécuter l’outil d’extraction.
  • Python 3 (avec le module pyyaml optionnel mais recommandé : pip install pyyaml).
  • Un export XML officiel de votre site WordPress (WordPress > Outils > Exporter > Tout le contenu).

1. Structure de dossiers Hugo

À la racine de votre projet Hugo, organisez la structure comme suit :

mon-site-hugo/
├── content/
│   ├── posts/      # Vos articles Markdown (+ dossiers images locaux)
│   └── pages/      # Vos pages statiques Markdown
├── static/         # Ads.txt, robots.txt, assets statiques
├── themes/         # Dossier des thèmes Hugo
└── hugo.toml       # Fichier de configuration Hugo

2. Conversion du fichier XML en Markdown avec npx

Utilisez le package wordpress-export-to-markdown via npx (exécuter la commande sans argument) :

npx wordpress-export-to-markdown

L’outil interactif va vous poser une série de questions dans le terminal :

  1. Path to XML file: Indiquez le chemin vers votre fichier d’export (ex: export.xml).
  2. Path to output directory: Spécifiez le dossier de sortie (ex: output).
  3. Validez les options par défaut pour le téléchargement automatique des images et la gestion des dates.

Une fois l’export terminé, déplacez les contenus générés :

Bash

cp -r output/posts/* content/posts/
cp -r output/pages/* content/pages/

Note sur les images : L’utilitaire place directement les images téléchargées dans un sous-dossier images/ à l’intérieur de content/posts/. Hugo sert automatiquement ces fichiers lors de la compilation sans aucune manipulation supplémentaire.

3. Installation et configuration d’un thème (Obligatoire)

Contrairement à WordPress, Hugo n’inclut aucun thème par défaut. Sans thème, la compilation générera des pages vides (erreur 404).

  1. Ajout d’un thème (exemple avec PaperMod) via un sous-module Git :Bashgit submodule add https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod
  2. Configuration de hugo.toml :Ini, TOMLbaseURL = 'https://votre-domaine.com/' languageCode = 'fr-fr' title = 'Mon Blog Statique' theme = 'PaperMod' [permalinks] page = "/:slug/" pages = "/:slug/" post = "/:slug/" posts = "/:slug/"

4. Injection des commentaires WordPress via Script Python

L’utilitaire npx ne rapatriant pas les commentaires, vous pouvez utiliser le script Python ci-dessous (scripts/inject_wp_comments.py). Il analyse le fichier XML de WordPress et réinjecte la liste des commentaires approuvés directement dans le Front Matter YAML de chaque fichier .md.

Fichier scripts/inject_wp_comments.py :

#!/usr/bin/env python3
"""
Inject WordPress comments from a WXR export into Hugo markdown front matter.

Usage:
  python3 scripts/inject_wp_comments.py \
    --xml /path/to/export.xml \
    --content content/posts \
    [--dry-run]
"""

from __future__ import annotations

import argparse
import html
import re
import sys
import unicodedata
from pathlib import Path
from urllib.parse import unquote

try:
    import yaml
except ImportError:
    yaml = None


ITEM_RE = re.compile(r"<item>(.*?)</item>", re.S | re.I)
COMMENT_RE = re.compile(r"<wp:comment>(.*?)</wp:comment>", re.S | re.I)


def cdata(tag: str, block: str) -> str:
    m = re.search(rf"<{tag}><!\[CDATA\[(.*?)\]\]></{tag}>", block, re.S | re.I)
    if m:
        return m.group(1)
    m = re.search(rf"<{tag}>(.*?)</{tag}>", block, re.S | re.I)
    if not m:
        return ""
    return html.unescape(m.group(1).strip())


def norm_slug(s: str) -> str:
    s = unquote(s or "")
    s = unicodedata.normalize("NFKD", s)
    s = "".join(c for c in s if not unicodedata.combining(c))
    s = s.lower().replace("’", "'").replace("'", "")
    s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
    return s


def parse_comments(item_block: str) -> list[dict]:
    out: list[dict] = []
    for cblock in COMMENT_RE.findall(item_block):
        approved = cdata("wp:comment_approved", cblock).strip().lower()
        if approved not in {"1", "true", "yes", "approve", "approved"}:
            continue
        ctype = cdata("wp:comment_type", cblock).strip().lower()
        if ctype and ctype not in {"comment"}:
            continue

        cid = cdata("wp:comment_id", cblock).strip()
        parent = cdata("wp:comment_parent", cblock).strip() or "0"
        author = cdata("wp:comment_author", cblock).strip() or "Anonyme"
        author_email = cdata("wp:comment_author_email", cblock).strip()
        author_url = cdata("wp:comment_author_url", cblock).strip()
        date = cdata("wp:comment_date", cblock).strip()
        if not date:
            date = cdata("wp:comment_date_gmt", cblock).strip()
        content = cdata("wp:comment_content", cblock)

        if date and "T" not in date:
            date = date.replace(" ", "T", 1)

        content_html = content
        if content and not re.search(r"</?[a-z][\s\S]*>", content, re.I):
            paras = re.split(r"\n\s*\n", content.strip())
            content_html = "".join(f"<p>{html.escape(p).replace(chr(10), '<br>')}</p>" for p in paras if p.strip())

        item = {
            "id": int(cid) if cid.isdigit() else cid,
            "parent": int(parent) if str(parent).isdigit() else parent,
            "author": author,
            "date": date,
            "content": content_html,
        }
        if author_url:
            item["authorUrl"] = author_url
        if author_email:
            item["authorEmail"] = author_email
        out.append(item)

    out.sort(key=lambda x: (str(x.get("date") or ""), str(x.get("id") or "")))
    return out


def parse_wxr(xml_path: Path) -> dict[str, dict]:
    text = xml_path.read_text(encoding="utf-8", errors="replace")
    by_slug: dict[str, dict] = {}
    for block in ITEM_RE.findall(text):
        ptype = cdata("wp:post_type", block)
        status = cdata("wp:status", block)
        if ptype not in {"post", "page"}:
            continue
        if status not in {"publish", "draft", "private", "pending", "future"}:
            continue
        comments = parse_comments(block)
        if not comments:
            continue
        slug = cdata("wp:post_name", block).strip()
        title = cdata("title", block).strip()
        if not slug:
            link = cdata("link", block)
            slug = link.rstrip("/").split("/")[-1] if link else ""
        by_slug[slug] = {
            "title": title,
            "comments": comments,
            "status": status,
            "post_type": ptype,
            "slug": slug,
            "slug_norm": norm_slug(slug),
            "title_norm": norm_slug(title),
        }
    return by_slug


def index_markdown(content_dir: Path) -> list[dict]:
    files = []
    for path in sorted(content_dir.rglob("*.md")):
        if path.name.startswith("."):
            continue
        stem = path.stem
        if stem == "index":
            stem = path.parent.name
        files.append(
            {
                "path": path,
                "stem": stem,
                "stem_norm": norm_slug(stem),
            }
        )
    return files


def match_post(wp: dict, md_files: list[dict]) -> Path | None:
    slug = wp["slug"]
    sn = wp["slug_norm"]
    tn = wp["title_norm"]

    for f in md_files:
        if f["stem"] == slug:
            return f["path"]
    for f in md_files:
        if f["stem_norm"] == sn:
            return f["path"]
    decoded = unquote(slug)
    for f in md_files:
        if f["stem"] == decoded or f["stem_norm"] == norm_slug(decoded):
            return f["path"]
    if tn:
        for f in md_files:
            if f["stem_norm"] == tn:
                return f["path"]
    return None


def split_front_matter(text: str) -> tuple[str, str, str] | None:
    if not text.startswith("---"):
        return None
    m = re.match(r"^---\r?\n(.*?)\r?\n---\r?\n?", text, re.S)
    if not m:
        return None
    fm = m.group(1)
    body = text[m.end() :]
    return "---\n", fm, body


def dump_comments_yaml(comments: list[dict]) -> str:
    if yaml is not None:
        dumped = yaml.safe_dump(
            {"comments": comments},
            allow_unicode=True,
            default_flow_style=False,
            sort_keys=False,
            width=1000,
        )
        return dumped.rstrip() + "\n"

    def esc(s: str) -> str:
        if s is None:
            return '""'
        s = str(s)
        if re.search(r'[:#\[\]{},&*!|>\'"%@`\n]', s) or s.strip() != s:
            return '"' + s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + '"'
        return s

    lines = ["comments:"]
    for c in comments:
        lines.append(f"  - id: {c['id']}")
        lines.append(f"    parent: {c['parent']}")
        lines.append(f"    author: {esc(c['author'])}")
        if c.get("authorUrl"):
            lines.append(f"    authorUrl: {esc(c['authorUrl'])}")
        if c.get("authorEmail"):
            lines.append(f"    authorEmail: {esc(c['authorEmail'])}")
        lines.append(f"    date: {esc(c['date'])}")
        content = c.get("content") or ""
        lines.append("    content: |")
        for line in content.splitlines() or [""]:
            lines.append(f"      {line}")
    return "\n".join(lines) + "\n"


def inject_comments(path: Path, comments: list[dict], dry_run: bool = False) -> str:
    text = path.read_text(encoding="utf-8")
    parts = split_front_matter(text)
    if not parts:
        return "no-front-matter"
    _, fm, body = parts

    if yaml is not None:
        try:
            data = yaml.safe_load(fm) or {}
            if not isinstance(data, dict):
                return "bad-front-matter"
            data["comments"] = comments
            new_fm = yaml.safe_dump(
                data,
                allow_unicode=True,
                default_flow_style=False,
                sort_keys=False,
                width=1000,
            ).rstrip() + "\n"
            new_text = f"---\n{new_fm}---\n{body}"
            if not dry_run:
                path.write_text(new_text, encoding="utf-8")
            return "updated"
        except Exception as exc:
            return f"yaml-error:{exc}"

    fm2 = re.sub(r"(?m)^comments:\n(?:[ \t]+.*\n)*", "", fm)
    fm2 = re.sub(r"(?m)^wp_comments:\n(?:[ \t]+.*\n)*", "", fm2)
    fm2 = fm2.rstrip() + "\n" + dump_comments_yaml(comments)
    new_text = f"---\n{fm2}---\n{body}"
    if not dry_run:
        path.write_text(new_text, encoding="utf-8")
    return "updated"


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--xml", required=True, type=Path)
    ap.add_argument("--content", default=Path("content/posts"), type=Path)
    ap.add_argument("--dry-run", action="store_true")
    args = ap.parse_args()

    if not args.xml.is_file():
        print(f"XML not found: {args.xml}", file=sys.stderr)
        return 1
    if not args.content.is_dir():
        print(f"Content dir not found: {args.content}", file=sys.stderr)
        return 1

    wp_posts = parse_wxr(args.xml)
    md_files = index_markdown(args.content)

    matched = 0
    updated = 0
    errors = []

    for slug, wp in sorted(wp_posts.items(), key=lambda x: x[0]):
        path = match_post(wp, md_files)
        if not path:
            continue
        matched += 1
        status = inject_comments(path, wp["comments"], dry_run=args.dry_run)
        if status == "updated":
            updated += 1

    print(f"Commentaires injectés dans {updated} articles.")
    return 0 if not errors else 2


if __name__ == "__main__":
    raise SystemExit(main())

Exécution du script :

python3 scripts/inject_wp_comments.py --xml export.xml --content content/posts

5. Affichage des commentaires statiques dans le template Hugo

Pour restituer les commentaires réinjectés en bas de vos articles, ajoutez ce bloc dans le fichier de layout de votre thème (ex: layouts/_default/single.html) :

{{ if .Params.comments }}
<section class="comments-static">
  <h3>Commentaires ({{ len .Params.comments }})</h3>
  {{ range .Params.comments }}
    <div class="comment" style="margin-bottom: 1rem; border-bottom: 1px solid #eee;">
      <strong>{{ .author }}</strong> <small>le {{ .date }}</small>
      <div>{{ .content | safeHTML }}</div>
    </div>
  {{ end }}
</section>
{{ end }}

6. Test et prévisualisation

Lancez le serveur local pour valider le rendu du thème, des images et des commentaires :

Bash

hugo server -D

Accédez ensuite à votre site sur http://localhost:1313/.

7. Résolution des problèmes fréquents

Images non affichées dans les articles : Si l’URL relative dans le fichier Markdown ne charge pas l’image, assurez-vous que le dossier images/ se trouve bien dans le même dossier que le fichier .md (dans content/posts/images/).

Page d’accueil vide ou erreur 404 sur les sous-pages : Vérifiez que vous avez bien configuré la propriété theme dans votre fichier hugo.toml et que le dossier themes/PaperMod (ou tout autre thème choisi) n’est pas vide.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur la façon dont les données de vos commentaires sont traitées.