Migrate any WordPress site to a Hugo + Markdown + git static site, deploy to GitHub Pages. Only two inputs are needed,fully offline, zero third-party dependencies:
- WXR export — from the WordPress admin
工具 → 导出 → 所有内容obtained*.xml(contains the HTML body of all posts/pages/categories/tags). uploads/folder — the site'swp-content/uploads/(images and attachments organized into directories by年/月).
Output:content/*.md + static/wp-content/uploads/ + hand-written minimal theme, built with Hugo, published via GitHub Actions.
Site-wide URLs keep the /archives/<数字>/ unchanged, old links never break 100%.
When to use
- The user has a WordPress site and wants to remove dynamic features/comments/database, and switch to git + Markdown maintenance.
- The user provided the WXR
.xmlanduploads/(or can obtain them). - Old links must be preserved (SEO / external references).
When NOT to use
- No WXR, only an online site → first export from the WP admin, or pull JSON via the REST API (this skill uses WXR, which is more portable).
- Need to keep dynamic features such as comments/membership/search → a static site can't do it, not applicable.
- The site is tiny (just a few posts) → copying by hand is faster.
Core principle
WXR is the single source of truth,uploads/ used directly as static assets.
Images are not downloaded or renamed: copy uploads/ into static/wp-content/uploads/, change the image URLs in the body to root-relative /wp-content/uploads/... and they resolve in place. Post URLs from <link> are preserved as-is. The converter ispure functions + unit tests, test first then write.
Pipeline
WXR .xml + uploads/ → wxr_to_hugo.py → content/*.md + static/wp-content/uploads/ → hugo build → GitHub Actions → Pages
Two decisions you MUST ask the user (do not silently decide)
There are two types of content in WordPress that a static site can't handle,you must ask the user, don't publish on your own:
- Password-protected posts(
<wp:post_password>is non-empty). A static site has no password gate → publishing means making it public. Options:exclude(default, safest, URLs will 404) / publish publicly / convert todraft。 When verifying counts, always use ElementTree (i.e.parse_items), don't use bare grep:<wp:post_password>values are CDATA-wrapped (<![CDATA[secret]]>),grep '<wp:post_password>[^<]*</...>'will treat every entry as empty → falsely report "0 password posts", missing posts that really are password-protected (learned in practice with maggiacito.com, almost missed publishing 1 post). - WordPress scaffold pages(
sample-page、login/register/findpasswordand other plugin shortcode pages, empty pages, login designer preview pages). Defaultexclude— they are not content.is_real_page()already filters by "empty body / single shortcode / default slug blacklist".
The converter already implements exclusion for both types; use AskUserQuestion to confirm before running the full batch.
Steps
1. Place the inputs, create the project
mkdir -p ~/code/<site> && cd ~/code/<site> && git init
# 把 WXR 拷进来(注意:WXR 含密码文章正文 + 作者邮箱,勿提交!见「安全」)
cp /path/to/<site>.WordPress.*.xml .
# uploads/ 放到工程根(含子目录 年/月)。注意它可能含 wordpress_db.sql —— 勿提交!
cp -R /path/to/uploads ./uploads
mkdir -p scripts tests content/posts layouts/_default layouts/partials static
Copy in this skill's assets (keep the directory correspondence):
SK="$HOME/.claude/skills/wjs-converting-wp-to-hugo"
cp "$SK"/scripts/*.py scripts/ # wxr_to_hugo.py, verify_build.py
cp "$SK"/tests/test_wxr.py tests/ # 单元测试(须放 tests/,与 scripts/ 同级)
cp -R "$SK"/assets/layouts/. layouts/ # 手写主题
cp "$SK"/assets/hugo.toml . # 改 title / baseURL / 菜单
mkdir -p .github/workflows && cp "$SK"/assets/workflow-hugo.yml .github/workflows/hugo.yml
cp "$SK"/assets/gitignore .gitignore
printf '%s' '<你的域名,如 huixianju.cn>' > static/CNAME # 自定义域名
2. Run the tests first (the converter is TDD)
python3 tests/test_wxr.py # 期望 ALL PASS;改任何转换逻辑都先加失败测试
3. Confirm the counts + run the full conversion
python3 scripts/wxr_to_hugo.py <site>.WordPress.*.xml
Print the report:posts / pages / images / uploads_copied / warnings. Verify the post count matches the WP admin.
warnings will list: empty-body posts, skipped scaffold pages, external images.
4. Build and assert all old links resolve
hugo --gc --minify # 没装:brew install hugo(要 extended)
python3 scripts/verify_build.py <site>.WordPress.*.xml # checked N posts, missing 0
5. Visually verify locally
hugo server -p 1313
Spot-check 5 posts against the live site (including 1 image post, 1 multi-link post): verify titles, lists, links, images, and videos are correct.
Key: Links should be page-relative (../../...), images load from local /wp-content/uploads/ , not pulled from online.
6. Push to GitHub (for public repos see "Security")
gh repo create <site> --public --source=. --remote=origin
git push -u origin main
7. Enable Pages → Actions,enable first, then run
gh api -X POST repos/<owner>/<site>/pages -f build_type=workflow
Pitfall: If you push before Pages is enabled, the first Action will fail with a 404 at configure-pages . After enabling Pages,re-run:
gh workflow run "Deploy Hugo site to Pages" --repo <owner>/<site>
gh run watch <run-id> --repo <owner>/<site> --exit-status
Verify the temporary address https://<owner>.github.io/<site>/: home / a post / categories / index.xml / an image all return 200.
(Images may briefly return 301 right after deployment; that's CDN warm-up, follow the redirect and it eventually returns 200.)
8. DNS switch (manual by operator, do it only after verification passes)
First confirm the temporary address is correct site-wide,WP is still online, zero risk. Then at your DNS provider (e.g. Cloudflare) point the domain to Pages:
- apex: A record →
185.199.108.153 / 109.153 / 110.153 / 111.153, or CNAME →<owner>.github.io。 - When using Cloudflare orange-cloud proxy, set SSL/TLS to Full; for the first certificate issuance you can temporarily use grey cloud (DNS only).
- After DNS takes effect, check Enforce HTTPS。
- Take the old WP offline only after it has been stable online for several days (shut it down first and keep a backup; delete it completely only after confirming no rollback is needed).
Pitfalls the converter has hit (already fixed in wxr_to_hugo.py, do not revert)
| Pitfall | Symptom | Fix |
|---|---|---|
| Hyperlink loses href | <a href> only text remains, URL is lost |
<a> accumulate text inside, output on close [文字](href) |
Gallery has extra -(figure version) |
An empty list marker before each image in an image post | figure stack identifies wp-block-gallery, inside a gallery <li> is not output - |
Gallery has extra -(ul version) |
Early Gutenberg put wp-block-gallery in <ul>(without <figure> wrapper), the figure check on the previous line missed it, leaving an isolated - |
_ul_stack also identifies <ul class=wp-block-gallery>, inside a gallery <li> is not output - (maggiacito.com in practice) |
| CJK permalink encoding | The permalink is URL-encoded Chinese (/sculpting-in-time/%e4%ba%8c…/). Keeping it as-is makes Hugo create a literal %e4%.. directory, and the server decodes the %xx in the request and it doesn't match → all old links break |
_norm_url() use unquote() to decode the path into Chinese, Hugo creates a UTF-8 directory; static hosts decode the inbound %xx and it matches,both encoded/decoded old links stay alive. Numeric /archives/<id>/ is unaffected (maggiacito.com in practice) |
| Images load from online | Body images are absolute https://站点/wp-content/... |
_root_relative() change self-hosted images to /wp-content/...; external images stay absolute |
| Video/embed lost | <video>/<iframe> body becomes empty |
Pass through as-is as HTML (hugo.toml open goldmark unsafe=true) |
| lastmod empty | Some WXR has no wp:post_modified |
When missing, fall back to wp:post_date |
| Classic editor soft line breaks | Bare \n in the body gets eaten |
handle_data Preserve raw text line breaks |
| Entities not decoded | In titles & |
html.unescape(); quotes inside titles replaced with single quotes |
| New post number skipping | Manually assigning URL numbers easily collides | next_archive_id() scan existing max number +1 |
Link portability
hugo.toml Set relativeURLs = true + canonifyURLs = false, Hugo outputs all links aspage-relative
(../../archives/123/). This way public/ works under file://, subpaths (github.io/<repo>/), and custom domains.
Image URLs have been changed to root-relative in the converter, and Hugo makes them relative again, so they resolve both locally and online.
Security (required reading for public repos)
WXR and raw uploads/ contain sensitive data,never enter git:
- WXR
.xmlcontainsthe body of password-protected posts(exactly what you excluded from the site) andauthor emails。 uploads/wordpress_db.sqlis a full database dump (users, password hashes). The converter already skips it when copying.sql/.DS_Store。assets/gitignoreuseroot-anchored/uploads/(do not writeuploads/, otherwisestatic/wp-content/uploads/will be ignored together with it, and images will not upload). It also ignores*.WordPress.*.xml、public/、resources/。- If the WXR was previouslyalready committed: gitignore has no effect on tracked files, so first
git rm --cached <xml>。 - Before pushing to a public repo, if history contains WXR or design documents with infrastructure details (instance ID / IP / cloud commands) → rebuild clean history:
Confirmgit checkout --orphan _clean && git add -A && git commit -m "..." && git branch -D main && git branch -m maingit diff --cached --name-only | grep -iE 'WordPress.*xml|wordpress_db|\.sql$'is empty before pushing.
Theme
assets/layouts/ isa hand-written minimalist theme, with zero external dependencies (no submodule / PaperMod, avoiding foreign build code and permission hassles).
CJK-friendly, home post list, category pages, archive by year, previous/next on post pages,/feed/ → /index.xml old RSS compatible.
Change static/CNAME to the target domain.
Writing new posts later
ID=$(python3 -c "import sys;sys.path.insert(0,'scripts');import wxr_to_hugo;print(wxr_to_hugo.next_archive_id('content'))")
# 建 content/posts/$ID.md,front matter 写 url: /archives/$ID/,与老文章同一号段
git push # Action 自动构建上线
Acceptance
- post count matches WP admin; every
/archives/<id>/hits atpublic/(verify_build.py) - all images localized, no external links
wp-content; videos/iframes pass through and can play - password posts and scaffold pages handled as the user decided
- links are page-relative,
file:///subpath/domain all clickable - WXR / db.sql / raw uploads not in git; public repo history has no sensitive data
- temporary Pages URL returns 200 site-wide, old links return 200 after DNS switch