WordPressからEmDashへ 移行の記録(前編)

WordPressからEmDashへ移行する(前編)ローカル検証までの全記録

5年運用したWordPressブログをEmDashへ移行中。エクスポートXMLの制御文字、インポート後のデモ記事混入、ロケールのミラー行、localeを区別しない削除APIで起こした事故まで、詰まったところを全部書いた。

WordPressで5年運用してきたこのブログを、EmDashというCMSに移す作業をしている。この記事は「本番に出す前のローカル検証が終わるまで」の記録。詰まったところを、後から同じことをやる人(と、半年後の自分)のために全部書いておく。

EmDashとは

Astroの上に作られたCMS。Cloudflare Workers上で動く。特徴を先に挙げると:

  • 表側(訪問者が見るページ)はAstroのサーバーレンダリング。Reactを使わない
  • 管理画面だけがReact製
  • データはCloudflare D1(SQLite)、画像はR2

WordPressと構造を比べるとこうなる。

| | 表側 | 管理画面 |

|---|---|---|

| WordPress | PHP | ブロックエディタだけReact、他はPHP |

| EmDash | Astro | まるごとReact |

移行前にやったこと

バックアップを2種類とる

.wpress(All-in-One WP Migration)とWXR(WordPress標準のエクスポート)の両方を取った。役割が違う。

  • .wpress … サイト丸ごと。画像の実体はこっちにしか入っていない
  • WXR(XML) … 記事・タイトル・日付・カテゴリ・タグ・コメント。画像はURLの参照だけ

EmDashに読み込めるのはWXRの方。ただし画像を後から拾いに行くので、.wpressも保険として置いておく。

PHPの更新警告は無視した

管理画面に「PHP 7.4は古い」と出ていたが、更新しなかった。理由は2つ。

  1. 移行できればサーバーごと止めるので無駄になる
  2. PHP 7.4→8系はテーマやプラグインが対応していないと画面が真っ白になる。そうなると記事のエクスポートすらできず、移行そのものが詰む

移行を検討している人は、まず記事を吸い出してから環境をいじった方がいい。

詰まったところ

1. エクスポートしたXMLが壊れていた

WXRをダウンロードして中身を数えようとしたら、XMLパーサーがエラーを吐いた。

xml.etree.ElementTree.ParseError: not well-formed (invalid token): line 20522, column 260

該当行をcat -vで見たら、記事本文に制御文字0x08(バックスペース)が2個混ざっていた。XML 1.0では許されない文字。おそらく昔どこかからコピペしたときに紛れ込んだもの。

XML 1.0で不正な制御文字(タブ・改行・復帰以外の0x00〜0x1F と 0x7F)を落とせば直る。

raw = open('WordPress.xml','rb').read()
bad = bytes(b for b in range(0x20) if b not in (0x09,0x0A,0x0D)) + b'\x7f'
open('clean.xml','wb').write(raw.translate(None, bad))

2バイト消えただけで、以降は問題なく読めた。5年分の記事があるとこういう地雷が混ざる。

2. npm install が通らない

テンプレートのdevDependenciesが@cloudflare/workers-types@^4なのに、同梱のwranglerがpeerで^5を要求していて衝突した。^5に上げて解決。

3. CLIにimportコマンドはない

emdashにはCLIがあるが、importサブコマンドは存在しない。WordPressからの取り込みは管理画面からファイルをアップロードする方式のみ。ここは探して時間を溶かした。

インポートの結果

管理画面 /_emdash/admin/import/wordpress にXMLをアップロード。分析→著者マッピング→実行→メディア取得、の4ステップ。

| WordPress | → EmDash | 件数 |

|---|---|---|

| 記事 公開 | 公開 | 69 |

| 記事 非公開(private) | 下書き | 15 |

| 記事 下書き | 下書き | 34 |

| 固定ページ | 公開8/下書き1 | 9 |

| メディア | 取り込み | 408 |

| アイキャッチ | 紐づけ | 79 |

公開記事は69本すべてが公開のまま入った。 アイキャッチもWXR側の_thumbnail_idを持つ記事数(79)とDB側のfeatured_imageが入った記事数(79)が一致した。

いくつか注意点。

  • EmDashに「非公開(private)」がない。WordPressの非公開15本は下書きになる。消えてはいない
  • 「17件スキップ」と出るが、これはメニュー項目・カスタムCSS・テンプレートなど記事以外のデータ。欠落ではない
  • メディアエラー1件は、元サイトに実物がない画像(404)だった。DBにレコードだけ残っていたやつ

移行のせいだと勘違いしたもの

コードブロックが1行に潰れて表示される記事があった。移行の不具合かと思って元のWXRを開いたら、元記事が`<pre>`を使わず`<code>`だけで書かれていた。WordPressでも同じ表示だっただけ。忠実に移行できていた。

崩れを見つけたら、まず元データを見るべきだった。

インポート後の掃除

ここが一番ハマった。インポートしただけでは終わらない。

デモ記事が混ざる

EmDashのテンプレートには英語のデモ記事が8本ついてくる。インポートした69本と混在する。

削除すれば済む話ではない。EmDashはデプロイ時もDBが空ならseedを自動適用する。つまり本番に上げた瞬間、また8本入る。

根本的にはseed/seed.jsonからcontent.postsを空にする。スキーマ定義(コレクション・タクソノミー・メニュー)は残す必要があるので、中身だけ消す。

カテゴリ一覧に%e6%9c%aa...が出る

日本語カテゴリ「未分類」が、URLエンコードされた文字列のまま表示された。

DBを見たら、同じslugのタームが2行あった。

category|programming|プログラミング|en
category|programming|programming|ja   ← これ

インポータのソースを読んで分かった。mirrorTermsToLocalesという関数が、記事に複数のロケールが混ざっている場合に備えて各タームを他ロケールにミラーする。そのときラベルにslugをそのまま入れる(label: slug)。日本語カテゴリのslugはURLエンコードされているので、%e6%9c%aa...が表示されることになる。

今回は記事が全部locale=enなので、jaの行は完全に不要。消せばいい。

地雷:ターム削除APIはlocaleを区別しない

ここで事故を起こした。

管理画面のカテゴリ一覧に「Crypto」と「crypto」が並んでいたので、小文字の方(ゴミ)をUIから削除した。結果、残すべき「Crypto」の方が消えた。

APIを見たら理由が分かる。

DELETE /_emdash/api/taxonomies/{tax}/terms/{slug}

slugしか受け取らない。 同じslugでen/jaが並んでいると、どちらが消えるか指定できない。

復旧はできた。ja行のtranslation_groupが、消えたen行のIDを指していたので、そのIDでINSERTし直せば元通りになる。記事との紐づけを持つcontent_taxonomiesはterm idではなくtranslation_groupを持つ設計なので、記事側は壊れていなかった。

結論として、ja行の掃除はUIではなくSQLでやる。

DELETE FROM taxonomies WHERE locale='ja';

本番なら wrangler d1 execute <DB名> --remote --command "..."。

ローカル開発でハマった2つ

開発サーバーが遅いのは仕様

npm run devで立てたサイトが、1ページ3〜5秒かかる。リンクを押しても数秒くるくるする。

これはViteがアクセスのたびにコンパイルするため。本番と同じビルドで測ると0.01〜0.09秒だった。

npm run build
npx wrangler dev --port 8788 --local

中身の確認はこっちでやるべきだった。開発サーバーの遅さを本番の速度と勘違いしなくて済む。

管理画面だけが延々くるくる回る

半日ほど開発サーバーを起動しっぱなしにしていたら、管理画面が開かなくなった。ログにこれが出ていた。

Invalid hook call. Hooks can only be called inside of the body of a function component.
TypeError: Cannot read properties of null (reading 'useEffect')

Viteの依存キャッシュが壊れてReactが二重に読み込まれた状態。表側のページは無事で、管理画面だけ壊れるのは、EmDashが管理画面にだけReactを使っているから。

npx astro dev stop
rm -rf node_modules/.vite .astro
npm run dev

これで直った。

まだ終わっていないこと

ここまでは全部ローカルでの下見。本番に出すときに効いてくる前提を書いておく。

  • ローカルのD1/R2は本番に持ち上がらない。 wrangler deployはコードだけを配る。本番用のインポートはデプロイ後にもう一度やることになる
  • WordPressを止めるのは本番の画像取り込みが終わってから。 インポータは画像を元サイトの実URLから取りに行くので、先に止めると408枚が全部欠ける
  • 旧URLはルート直下の`/{slug}/`、EmDashは`/posts/{slug}`。 ドメインを切り替えるなら69本分の301リダイレクトがいる
  • 画像のaltが全部空。 WordPressのalt属性はWXRに乗らない。SEOと読み上げのために後から入れるかどうかは検討中

今のところの所感

インポート自体は素直に通った。69本が69本のまま、画像408枚と一緒に入る。ここは想像より遥かに楽だった。

きつかったのはインポートの後。デモコンテンツの混入、ロケールのミラー行、locale非対応の削除API。どれもドキュメントには書いていなくて、DBとインポータのソースを読んで初めて分かった。

WordPressからの移行を考えている人は、インポートの成否より、インポート後の掃除に時間を見た方がいい。そして掃除の手順は、必ず手順書として書き残すこと。本番でもう一度、同じことをやることになるから。

続きは本番デプロイのときに書く。