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
extendedrecommandée) installé sur votre machine (brew install hugo). - Node.js pour exécuter l’outil d’extraction.
- Python 3 (avec le module
pyyamloptionnel 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 :
- Path to XML file: Indiquez le chemin vers votre fichier d’export (ex:
export.xml). - Path to output directory: Spécifiez le dossier de sortie (ex:
output). - 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 decontent/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).
- Ajout d’un thème (exemple avec PaperMod) via un sous-module Git :Bash
git submodule add https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod - 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.