WordPress から Hugo へ移行する

Hugo Wordpress 覚書

WordPress で運用していたこのサイトを Hugo に移行してみることにした。

WordPress は管理画面から記事を書けるし、テーマやプラグインも多くて便利だった。ただ、長く運用していると、PHP、MySQL、プラグイン、テーマ、セキュリティ更新、バックアップなど、面倒を見るものが少しずつ増えていく。

このブログは基本的に調べたことを残しておく場所なので、動的な仕組みはあまり必要ない。だったら、Markdown と静的HTMLで管理できる Hugo に寄せたほうが自分には合いそうだと思った。

今回の移行作業では Codex も使った。WordPress のエクスポートXMLを読み込むスクリプトを書いたり、変換後の記事をHugoでビルド確認したり、表示崩れしそうなところを少しずつ直したりする作業を、Codex と相談しながら進めた。

移行方針

今回の方針は次のようにした。

  • WordPress のエクスポートXMLを元データにする
  • 記事と固定ページを Hugo の content 配下に変換する
  • 画像などのアップロードファイルは static/wp-content/uploads 配下に置く
  • 既存URLはできるだけ維持する
  • 変換後も必要に応じて手で直せる形にする
  • 変換スクリプトの作成や検証には Codex を使う

完全自動変換を目指すというより、まず Hugo で表示できる状態まで持っていって、細かいところはあとから直す、という進め方にした。

WordPress からエクスポートする

WordPress の管理画面からエクスポートファイルを作成する。

管理画面で次のメニューを開く。

ツール -> エクスポート

「すべてのコンテンツ」を選択して、WXR形式のXMLファイルをダウンロードする。

このXMLには記事、固定ページ、カテゴリ、タグ、本文、WordPress上のURLなどが含まれている。画像ファイルそのものは含まれないので、画像はあとで別途ダウンロードする。

Hugo プロジェクトへ記事を取り込む

このリポジトリでは、WordPress の WXR XML を読み込むために scripts/import-wordpress-wxr.ps1 を用意した。

たとえば wordpress.WordPress.xml を取り込む場合は、PowerShell で次のように実行する。

.\scripts\import-wordpress-wxr.ps1 -InputPath .\wordpress.WordPress.xml

公開済みの記事と固定ページが、それぞれ次の場所に出力される。

content/posts
content/pages

下書きや非公開記事も含めたい場合は、必要に応じてオプションを付ける。

.\scripts\import-wordpress-wxr.ps1 -InputPath .\wordpress.WordPress.xml -IncludeDrafts -IncludePrivate

変換後の Markdown ファイルには、WordPress 側の情報も残しておく。

wordpress_id = "123"
wordpress_url = "https://nosubject.io/example/"
wordpress_status = "publish"

あとから元記事と突き合わせたいときに、この情報があると助かる。

URL を維持する

既存の検索結果やブックマークから来た人が迷子にならないように、記事のフロントマターには url を入れるようにした。

url = "/wordpress-cocoon-edit-header-tag/"

Hugo ではファイル名やセクション構成を変えても、url を指定しておけば公開URLを固定できる。

WordPress からの移行では、ここを最初に決めておいたほうがよい。あとでURLが変わると、内部リンクや検索エンジン側の扱いを直すのが面倒になる。

画像ファイルを移行する

WordPress の本文中には、次のような画像URLが残っている。

https://nosubject.io/wp-content/uploads/2020/02/image.png

Hugo では static 配下に置いたファイルがそのまま公開されるので、WordPress と同じパスで参照できるようにした。

static/wp-content/uploads/2020/02/image.png

画像のダウンロードとURL置換には scripts/download-wordpress-media.ps1 を使う。

まずは確認だけする。

.\scripts\download-wordpress-media.ps1 -DryRun

問題なさそうなら実行する。

.\scripts\download-wordpress-media.ps1

これで取得できた画像については、記事内のURLも /wp-content/uploads/... 形式に置き換えられる。

WordPress ブロック由来のHTMLを整理する

WordPress のブロックエディタで書いた記事は、本文に wp-block-* 系のHTMLが残る。

Hugo 側では markup.goldmark.renderer.unsafe = true にしてHTMLをそのまま出せるようにしているが、そのままだと不要なクラスや埋め込み表現が多い。

そこで、記事取り込み後に scripts/convert-wordpress-content-for-hugo.ps1 を実行して、Hugo向けに少し整理する。

.\scripts\convert-wordpress-content-for-hugo.ps1 -DryRun

確認して問題なさそうなら実行する。

.\scripts\convert-wordpress-content-for-hugo.ps1

このスクリプトでは、たとえば次のような変換をしている。

  • WordPress の埋め込みURLを blogcard ショートコードにする
  • GitHub Gist の <script> を Hugo の gist ショートコードにする
  • サイト内リンクを相対パスに寄せる
  • 最初の画像を featured_imageimages に入れる
  • 一部の見出しやボタン用HTMLを整理する

Amazon リンクをショートコードにする

過去記事には Amazon アソシエイトの iframe が入っているものがある。

そのままHTMLとして残しても表示はできるが、記事内に長い iframe のHTMLが大量にあると編集しづらい。

そこで layouts/shortcodes/amazon-iframe.html を用意して、本文側では次のようなショートコードに変換する。

{{< amazon-iframe asin="B07GBD3JLR" tag="nosubjectio0d-22" >}}

この変換には scripts/convert-amazon-links.ps1 を使う。

.\scripts\convert-amazon-links.ps1 -DryRun

問題なさそうなら実行する。

.\scripts\convert-amazon-links.ps1

汎用版の PowerShell スクリプト例

自分の環境ではリポジトリ内の scripts 配下に用途別のスクリプトを置いた。

このあたりのスクリプトは、Codex に相談しながら作成した。最初に小さく変換して、Hugo でビルドして、エラーや表示の違和感が出たらスクリプトを直す、という流れで進めると作業しやすかった。

そのまま全部を載せると長くなるので、ここでは別のサイトでも流用しやすい形にした最小構成の例を置いておく。

WordPress の WXR XML を Hugo 記事へ変換する

import-wordpress-wxr.ps1 の汎用版。

WordPress のエクスポートXMLから postpage を読み取り、Hugo の content/postscontent/pages に Markdown ファイルとして出力する。

param(
    [Parameter(Mandatory = $true)]
    [string]$InputPath,

    [string]$OutputRoot = "content",

    [switch]$IncludeDrafts
)

$ErrorActionPreference = "Stop"
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)

function Get-NodeText {
    param(
        [System.Xml.XmlNode]$Node,
        [string]$XPath,
        [System.Xml.XmlNamespaceManager]$Namespaces
    )

    $found = $Node.SelectSingleNode($XPath, $Namespaces)
    if ($null -eq $found) {
        return ""
    }

    return $found.InnerText
}

function ConvertTo-TomlString {
    param([string]$Value)

    if ($null -eq $Value) {
        $Value = ""
    }

    $escaped = $Value.Replace("\", "\\").Replace('"', '\"')
    $escaped = $escaped.Replace("`r", "\r").Replace("`n", "\n")
    return '"' + $escaped + '"'
}

function ConvertTo-HugoDate {
    param([string]$Value)

    if ([string]::IsNullOrWhiteSpace($Value) -or $Value -eq "0000-00-00 00:00:00") {
        return $null
    }

    $date = [datetime]::Parse($Value, [System.Globalization.CultureInfo]::InvariantCulture)
    return $date.ToString("yyyy-MM-ddTHH:mm:ss") + "+09:00"
}

function ConvertTo-SafeFileName {
    param(
        [string]$Slug,
        [string]$PostId
    )

    $name = $Slug
    if ([string]::IsNullOrWhiteSpace($name)) {
        $name = "wordpress-$PostId"
    }

    $name = [regex]::Replace($name, '[<>:"/\\|?*\x00-\x1F]', "-")
    return [regex]::Replace($name, "-{2,}", "-").Trim(" ", ".", "-")
}

[xml]$xml = [System.IO.File]::ReadAllText((Resolve-Path -LiteralPath $InputPath), [System.Text.Encoding]::UTF8)

$ns = New-Object System.Xml.XmlNamespaceManager($xml.NameTable)
$ns.AddNamespace("content", "http://purl.org/rss/1.0/modules/content/")
$ns.AddNamespace("dc", "http://purl.org/dc/elements/1.1/")
$ns.AddNamespace("wp", "http://wordpress.org/export/1.2/")

$postsDir = Join-Path $OutputRoot "posts"
$pagesDir = Join-Path $OutputRoot "pages"
New-Item -ItemType Directory -Force -Path $postsDir | Out-Null
New-Item -ItemType Directory -Force -Path $pagesDir | Out-Null

$items = $xml.SelectNodes("//channel/item")

foreach ($item in $items) {
    $type = Get-NodeText $item "wp:post_type" $ns
    if ($type -ne "post" -and $type -ne "page") {
        continue
    }

    $status = Get-NodeText $item "wp:status" $ns
    if ($status -ne "publish" -and -not $IncludeDrafts) {
        continue
    }

    $postId = Get-NodeText $item "wp:post_id" $ns
    $slug = Get-NodeText $item "wp:post_name" $ns
    $title = $item.title.InnerText
    $link = $item.link
    $date = ConvertTo-HugoDate (Get-NodeText $item "wp:post_date" $ns)
    $lastmod = ConvertTo-HugoDate (Get-NodeText $item "wp:post_modified" $ns)
    $author = Get-NodeText $item "dc:creator" $ns
    $body = Get-NodeText $item "content:encoded" $ns

    $categories = @()
    $tags = @()
    foreach ($category in $item.SelectNodes("category")) {
        $domain = $category.GetAttribute("domain")
        if ($domain -eq "category") {
            $categories += $category.InnerText
        }
        elseif ($domain -eq "post_tag") {
            $tags += $category.InnerText
        }
    }

    $urlPath = if ([string]::IsNullOrWhiteSpace($slug)) { $null } else { "/" + $slug.Trim("/") + "/" }
    $draft = if ($status -eq "publish") { "false" } else { "true" }

    $frontMatter = @()
    $frontMatter += "+++"
    $frontMatter += "title = $(ConvertTo-TomlString $title)"
    if ($date) { $frontMatter += "date = $(ConvertTo-TomlString $date)" }
    if ($lastmod) { $frontMatter += "lastmod = $(ConvertTo-TomlString $lastmod)" }
    $frontMatter += "draft = $draft"
    if ($slug) { $frontMatter += "slug = $(ConvertTo-TomlString $slug)" }
    if ($urlPath) { $frontMatter += "url = $(ConvertTo-TomlString $urlPath)" }
    if ($author) { $frontMatter += "author = $(ConvertTo-TomlString $author)" }
    $frontMatter += "wordpress_id = $(ConvertTo-TomlString $postId)"
    $frontMatter += "wordpress_url = $(ConvertTo-TomlString $link)"
    $frontMatter += "wordpress_status = $(ConvertTo-TomlString $status)"

    if ($categories.Count -gt 0) {
        $frontMatter += "categories = [" + (($categories | Select-Object -Unique | ForEach-Object { ConvertTo-TomlString $_ }) -join ", ") + "]"
    }
    if ($tags.Count -gt 0) {
        $frontMatter += "tags = [" + (($tags | Select-Object -Unique | ForEach-Object { ConvertTo-TomlString $_ }) -join ", ") + "]"
    }

    $frontMatter += "+++"

    $targetDir = if ($type -eq "post") { $postsDir } else { $pagesDir }
    $fileName = ConvertTo-SafeFileName $slug $postId
    $targetPath = Join-Path $targetDir "$fileName.md"
    $output = ($frontMatter -join "`n") + "`n`n" + $body.Trim() + "`n"

    [System.IO.File]::WriteAllText((Join-Path (Get-Location) $targetPath), $output, $utf8NoBom)
}

記事内の画像URLを拾ってダウンロードする

download-wordpress-media.ps1 の汎用版。

本文中の /wp-content/uploads/ を含むURLを探して、Hugo の static/wp-content/uploads 配下に保存する。

param(
    [string]$ContentRoot = "content",
    [string]$StaticRoot = "static",
    [switch]$DryRun
)

$ErrorActionPreference = "Stop"
$urlPattern = 'https?://[^\s"''<>]+/wp-content/uploads/[^\s"''<>]+'
$files = Get-ChildItem -Recurse -File -Path $ContentRoot -Filter *.md
$media = [ordered]@{}

foreach ($file in $files) {
    $text = [System.IO.File]::ReadAllText($file.FullName, [System.Text.Encoding]::UTF8)
    foreach ($match in [regex]::Matches($text, $urlPattern)) {
        $rawUrl = [System.Net.WebUtility]::HtmlDecode($match.Value)
        try {
            $uri = [uri]$rawUrl
        }
        catch {
            continue
        }

        $marker = "/wp-content/uploads/"
        $index = $uri.AbsolutePath.IndexOf($marker, [System.StringComparison]::OrdinalIgnoreCase)
        if ($index -lt 0) {
            continue
        }

        $relative = [uri]::UnescapeDataString($uri.AbsolutePath.Substring($index + $marker.Length))
        $localUrl = "/wp-content/uploads/" + (($relative -split "/") | ForEach-Object { [uri]::EscapeDataString($_) }) -join "/"
        $localPath = Join-Path $StaticRoot ("wp-content/uploads/" + $relative).Replace("/", [System.IO.Path]::DirectorySeparatorChar)

        if (-not $media.Contains($rawUrl)) {
            $media[$rawUrl] = [pscustomobject]@{
                Url = $rawUrl
                LocalUrl = $localUrl
                LocalPath = $localPath
            }
        }
    }
}

foreach ($item in $media.Values) {
    if ($DryRun) {
        Write-Host "$($item.Url) -> $($item.LocalPath)"
        continue
    }

    $directory = Split-Path -Parent $item.LocalPath
    New-Item -ItemType Directory -Force -Path $directory | Out-Null

    if (-not (Test-Path -LiteralPath $item.LocalPath)) {
        Invoke-WebRequest -Uri $item.Url -OutFile $item.LocalPath -MaximumRedirection 10
    }
}

if (-not $DryRun) {
    foreach ($file in $files) {
        $text = [System.IO.File]::ReadAllText($file.FullName, [System.Text.Encoding]::UTF8)
        $updated = $text

        foreach ($item in $media.Values) {
            if (Test-Path -LiteralPath $item.LocalPath) {
                $updated = $updated.Replace($item.Url, $item.LocalUrl)
            }
        }

        if ($updated -ne $text) {
            [System.IO.File]::WriteAllText($file.FullName, $updated, [System.Text.Encoding]::UTF8)
        }
    }
}

WordPress 由来の本文を少し整理する

convert-wordpress-content-for-hugo.ps1 の汎用版。

WordPressブロックのコメントを消したり、サイト内リンクを相対パスに寄せたり、最初の画像を featured_image に入れたりする。

param(
    [string]$ContentRoot = "content",
    [string]$SiteHost = "example.com",
    [switch]$DryRun
)

$ErrorActionPreference = "Stop"
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)

function Get-FirstImage {
    param([string]$Body)

    $match = [regex]::Match($Body, '(?is)<img\b[^>]*\bsrc\s*=\s*([''"])(.*?)\1')
    if ($match.Success) {
        return $match.Groups[2].Value
    }

    return ""
}

function Update-FrontMatter {
    param(
        [string]$FrontMatter,
        [string]$Body
    )

    $image = Get-FirstImage $Body
    if ([string]::IsNullOrWhiteSpace($image)) {
        return $FrontMatter
    }

    $updated = $FrontMatter
    if ($updated -notmatch '(?m)^featured_image\s*=') {
        $updated = $updated.TrimEnd() + "`nfeatured_image = `"$image`""
    }
    if ($updated -notmatch '(?m)^images\s*=') {
        $updated = $updated.TrimEnd() + "`nimages = [`"$image`"]"
    }

    return $updated
}

$files = Get-ChildItem -Recurse -File -Path $ContentRoot -Filter *.md
$changed = 0

foreach ($file in $files) {
    $text = [System.IO.File]::ReadAllText($file.FullName, [System.Text.Encoding]::UTF8)
    $match = [regex]::Match($text, '(?s)\A\+\+\+\r?\n(.*?)\r?\n\+\+\+\r?\n(.*)\z')
    if (-not $match.Success) {
        continue
    }

    $frontMatter = $match.Groups[1].Value
    $body = $match.Groups[2].Value

    $updatedBody = $body
    $updatedBody = [regex]::Replace($updatedBody, '<!--\s*/?wp:[\s\S]*?-->\s*', "")
    $updatedBody = $updatedBody -replace "https?://(?:www\.)?$([regex]::Escape($SiteHost))(/[^`"'\s<)]*)", '$1'
    $updatedBody = [regex]::Replace($updatedBody, '(?is)<h1\b[^>]*>(.*?)</h1>', '<h2>$1</h2>')

    $updatedFrontMatter = Update-FrontMatter $frontMatter $updatedBody
    $updatedText = "+++`n" + $updatedFrontMatter.TrimEnd() + "`n+++`n" + $updatedBody.TrimStart()

    if ($updatedText -ne $text) {
        $changed += 1
        if (-not $DryRun) {
            [System.IO.File]::WriteAllText($file.FullName, $updatedText, $utf8NoBom)
        }
    }
}

[pscustomobject]@{
    Files = $files.Count
    ChangedFiles = $changed
    DryRun = [bool]$DryRun
} | Format-List

埋め込みURLを Hugo ショートコードにする

WordPress の埋め込みブロックを Hugo のショートコードへ寄せたい場合は、本文変換時に次のような置換を追加する。

Hugoの記事内でサンプルを表示する都合上、下の例では {{< を分けて文字列化している。

$shortcodeOpen = "{{" + "<"
$shortcodeClose = ">" + "}}"

$body = [regex]::Replace(
    $body,
    '(?is)<figure\b[^>]*class\s*=\s*"[^"]*wp-block-embed[^"]*"[^>]*>.*?(https?://[^<\s]+).*?</figure>',
    {
        param($match)
        $url = $match.Groups[1].Value.Replace('"', '&quot;')
        "$shortcodeOpen blogcard url=`"$url`" $shortcodeClose"
    }
)

このあたりはサイトごとの本文のクセが出やすいので、最初から完全変換を狙わず、-DryRun で確認しながら少しずつ増やしたほうがよい。

Hugo で表示確認する

記事と画像を移行したら、Hugo のローカルサーバーで確認する。

hugo server -D --disableFastRender

この環境では Hugo Extended を使っている。

hugo version

現在は次のバージョンで確認している。

hugo v0.164.0+extended windows/amd64

ローカルサーバーを起動したら、ブラウザで確認する。

http://localhost:1313/

移行してみて気をつけるところ

実際に移行してみると、単にHTMLへ変換できれば終わり、というわけではなかった。

特に確認が必要だったのはこのあたり。

  • 画像が存在するか
  • 内部リンクが古いドメインのまま残っていないか
  • WordPress ブロック由来のHTMLが表示崩れしていないか
  • カテゴリやタグが意図した通りに出ているか
  • featured_image が一覧ページで自然に表示されるか
  • 日本語記事と英語記事の切り替えが正しく動くか
  • 既存URLが維持されているか

記事数が多い場合は、完璧な変換スクリプトを作ろうとすると時間がかかる。

まず全体をHugoでビルドできる状態にして、目立つ崩れから順番に直していくほうがよさそうだ。

まとめ

WordPress から Hugo への移行は、次の順番で進めた。

  1. WordPress から WXR XML をエクスポートする
  2. import-wordpress-wxr.ps1 で記事と固定ページを取り込む
  3. download-wordpress-media.ps1 で画像を static/wp-content/uploads に保存する
  4. convert-wordpress-content-for-hugo.ps1 で本文を整理する
  5. 必要に応じて Amazon リンクなどをショートコード化する
  6. hugo server -D で表示確認する
  7. 気になる記事から手で直す

WordPress の管理画面から解放される代わりに、Hugo ではファイルとして記事を管理できる。

自分のように「調べたことを残す」用途なら、静的サイトのほうが扱いやすい場面は多そうだ。

移行作業はまだ微調整が残っているが、少なくとも記事、画像、URLを Hugo 側で扱えるところまでは持ってこられた。

Codex 向け移行用プロンプト

最後に、別の WordPress サイトを Hugo に移行するときに使えそうな Codex 向けプロンプトを置いておく。

最初から全部を一度に任せるより、まずは現状調査、次にインポートスクリプト、次に本文変換、最後に表示確認、というように段階を分けると進めやすい。

WordPress から Hugo への移行を手伝ってください。

目的:
- WordPress の WXR XML エクスポートファイルを Hugo の content 配下へ移行する
- 既存記事URLをできるだけ維持する
- 画像は Hugo の static/wp-content/uploads 配下へ保存する
- 変換後も手で修正しやすい Markdown / HTML 混在形式で残す
- まずは Hugo でビルドできる状態を優先する

前提:
- 作業ディレクトリは現在開いている Hugo プロジェクトです
- WordPress のエクスポートXMLはプロジェクト直下、または指定したパスにあります
- Hugo Extended がインストール済みです
- PowerShell で実行できる移行スクリプトを作成してください

やってほしいこと:
1. まずリポジトリ構成、hugo.toml、content、layouts、static、既存スクリプトの有無を確認してください
2. git status を確認し、既存の未コミット変更を勝手に戻さないでください
3. WordPress の WXR XML を読み込み、post は content/posts、page は content/pages に出力する ps1 を作成してください
4. フロントマターには title、date、lastmod、draft、slug、url、author、categories、tags を入れてください
5. 元記事との対応確認用に wordpress_id、wordpress_url、wordpress_status も残してください
6. 既存URLを維持するため、WordPress の slug から url = "/slug/" を設定してください
7. 本文内の WordPress ブロックコメントや不要な wp-block 系HTMLを、壊しすぎない範囲で整理してください
8. 本文内の /wp-content/uploads/ 画像URLを検出し、static/wp-content/uploads 配下へダウンロードする ps1 を作成してください
9. ダウンロード済み画像のURLは /wp-content/uploads/... のローカルURLへ置換してください
10. Gist、埋め込みリンク、Amazon iframe などは、必要に応じて Hugo shortcode へ変換してください
11. 変換スクリプトには -DryRun オプションを付け、いきなり大量変更しないでください
12. 変換後に hugo -D でビルド確認してください
13. エラーが出た場合は、テンプレート側を無理に変更する前に Hugo のバージョン、設定、本文変換結果を確認してください
14. 最後に、作成・変更したファイル、実行したコマンド、ビルド結果、残っている確認事項をまとめてください

注意:
- 既存ファイルを破壊的に上書きしないでください
- 既存URLを変えないでください
- WordPress 由来のHTMLを完全な Markdown に変換しようとしすぎないでください
- 画像や内部リンクの置換は、DryRun で確認してから実行してください
- 既存テーマやレイアウトの変更は最小限にしてください

もう少し小さく始めたい場合は、最初の依頼を次のようにしてもよい。

この Hugo プロジェクトを確認して、WordPress から移行するための作業計画を作ってください。
まだファイルは変更せず、hugo.toml、content、layouts、static、既存スクリプトの有無、Hugo のビルド可否を確認して、移行手順を提案してください。

実際に作業を進めるときは、次のように段階ごとに頼むと確認しやすい。

WordPress の WXR XML を Hugo の content/posts と content/pages に変換する PowerShell スクリプトを作成してください。
まずは公開済み記事だけを対象にし、既存URLを維持するため url フロントマターを設定してください。
作成後、DryRun または少数の記事で動作確認してください。

画像移行だけを頼む場合は、次のようにする。

content 配下の Markdown から WordPress の画像URLを抽出し、static/wp-content/uploads に保存する PowerShell スクリプトを作成してください。
-DryRun で対象URLと保存先を確認できるようにしてください。
保存できた画像だけ、本文内URLを /wp-content/uploads/... に置換してください。

本文整理だけを頼む場合は、次のようにする。

WordPress から移行した本文を Hugo 向けに整理する PowerShell スクリプトを作成してください。
WordPress ブロックコメントの削除、内部リンクの相対化、最初の画像から featured_image/images を設定する処理を入れてください。
ただし、HTMLを壊しすぎないようにし、-DryRun で変更件数を確認できるようにしてください。
Hugo WordPress 移行 静的サイト
スポンサーリンク
☆Geha☆をフォローする