SuperAPI AI Directory

wjs-converting-wp-to-hugo

Frontend & websites

View repository

Updated 10-15 23:32

About this skill

wjs-converting-wp-to-hugo

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:

  1. WXR export — from the WordPress admin 工具 → 导出 → 所有内容 obtained *.xml(contains the HTML body of all posts/pages/categories/tags).
  2. uploads/ folder — the site's wp-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 .xml and uploads/(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:

  1. 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 to draft。 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).
  2. WordPress scaffold pages(sample-page、login/register/findpassword and 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 &#038; 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 .xml containsthe body of password-protected posts(exactly what you excluded from the site) andauthor emails。
  • uploads/wordpress_db.sql is a full database dump (users, password hashes). The converter already skips it when copying .sql/.DS_Store。
  • assets/gitignore useroot-anchored /uploads/(do not write uploads/, otherwise static/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:
    git checkout --orphan _clean && git add -A && git commit -m "..." && git branch -D main && git branch -m main
    
    Confirm git 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 at public/ (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