WordPressからContentfulへの移行メモ

WordPressからAstro+Contentful+Cloudflareに移行したのでその作業メモ。

なぜ移行したの?

構成

Astroで始める爆速個人サイト開発 - Speaker Deck を参考に、

を選択しました。何もわからないので猿真似です。ブログということもあり、動的な処理は必要ないため静的サイトとして構築します。

作業

基本はGetting Started 🚀 Astro Documentationを参考に進めます。

プロジェクトの作成

とりあえずJavaScriptパッケージマネージャーが必要らしいのでインストールします。最近は npm よりも pnpm がよいとのことなので pnpm を入れてみます。

$ brew instal pnpm
$ pnpm create astro@latest

上のコードを実行すると、 pnpm がインストールされた後、以下のように対話プロンプトが実行されるので適当にプロジェクト名などを設定します。 今回は ./ki_chi_blog をプロジェクト名として新規作成しました。

../Library/pnpm/store/v3/tmp/dlx-22412   | +141 ++++++++++++++
Packages are hard linked from the content-addressable store to the virtual store.
  Content-addressable store is at: /Users/kiichi/Library/pnpm/store/v3
  Virtual store is at:             ../Library/pnpm/store/v3/tmp/dlx-22412/node_modules/.pnpm
../Library/pnpm/store/v3/tmp/dlx-22412   | Progress: resolved 141, reused 0, downloaded 141, added 141, done

╭─────╮  Houston:
│ ◠ ◡ ◠  Let's make the web a better place!
╰─────╯

 astro   v2.0.10 Launch sequence initiated.

   dir   Where should we create your new project?
         ./ki_chi_blog

  tmpl   How would you like to start your new project?
         Include sample files
      ✔  Template copied

  deps   Install dependencies?
         Yes
      ✔  Dependencies installed

   git   Initialize a new git repository?
         Yes
      ✔  Git initialized

    ts   Do you plan to write TypeScript?
         Yes

   use   How strict should TypeScript be?
         Strict
      ✔  TypeScript customized

  next   Liftoff confirmed. Explore your project!

         Enter your project directory using cd ./ki_chi_blog
         Run pnpm dev to start the dev server. CTRL+C to stop.
         Add frameworks like react or tailwind using astro add.

         Stuck? Join us at https://astro.build/chat

╭─────╮  Houston:
│ ◠ ◡ ◠  Good luck out there, astronaut! 🚀
╰─────╯

以下のコマンドを実行すると、ローカルサーバーが立ち上がり、 localhost:3000 で接続できるようになります。

$ pnpm run dev

> [email protected] dev /Users/kiichi/projects/ki_chi_blog
> astro dev

  🚀  astro  v2.0.10 started in 23ms

  ┃ Local    http://localhost:3000/
  ┃ Network  use --host to expose

エディタ設定 (Emacs)

エディタの設定ですが、Emacsで拡張子 .astro のファイルを web-mode で開けるようにしておけば良さそうです。LSPも動きます。 ついでにTypeScriptの設定もしておきます。 私は leaf.el を使っているので、以下を init.el に追記しました。

;; TypeScript
(leaf typescript-mode
  :ensure t
  :custom ((typescript-indent-level . 2))
  :mode "\\.ts\\'"
  :hook ((typescript-mode-hook . lsp-deferred))
  )

;; Astro
(leaf web-mode
  :ensure t
  :mode "\\.astro\\'"
  :custom ((web-mode-markup-indent-offset . 2)
           (web-mode-css-indent-offset . 2)
           (web-mode-code-indent-offset . 2))
  :hook ((web-mode-hook . lsp-deferred))
  )

最初にAstroファイルを開いた時にLSPで以下のようなwarningが出ました。

Unable to find typescript server path for astro-ls. Guessed: [node-module以下の長いパス]

この場合は typescript をインストールしてあげればOKのようです。

$ pnpm install -D typescript

テンプレートの編集

初期テンプレートが src ディレクトリ以下にあるのでここを参考にしつついじっていきます。

ContentfulでMarkdownで入力した結果をHTMLに変換

Contentfulの記事はリッチテキストではなくMarkdownで書きたいので、Contentfulから受け取ったMarkdown文字列をHTMLに変換します。 また remark-prism を使ってコードのハイライトをしたり、 rehype-rewrite で画像の最適化のためのタグの置換も行います。 これらを可能にする remarkrehype パッケージ群についても今回初めて知りました。以下サイトが参考になりました。

例えば、以下の関数で引数 markdown で与えられる文字列をHTML文字列にします。

export async function processMdImg(markdown) {
  const result = await unified()
    .use(remarkParse)
    .use(remarkGfm)
    .use(remarkMath)
    .use(remarkPrism)                                                     
    .use(remarkRehype)
    .use(rehypeKatex)
    .use(rehypeSlug)
    .use(rehypeRewrite, {
      selector: "img",
      rewrite: (node, index, parent) => {
        const imgWidths = [640, 800, 1400];
        if(node.type == 'element') {
          const imgSrc = node.properties.src; 
          const imgAlt = node.properties.alt;
          const imgUrl = new URL("https:" + imgSrc);
          if(imgUrl.hostname == 'images.ctfassets.net') {
            // Rewrite node
            node.tagName = "picture";
            node.properties = {};
            node.children = [

              // webp
              {
                type: "element",
                tagName: "source",
                properties: {
                  type: "image/webp",
                  srcset: imgWidths.map(w => imgSrc + "?w=" + w + "&fm=webp " + w + "w").join(","),
                  sizes: "(max-width: 768px) calc(100vw - 3em), (max-width: 1376px) calc(75vw - 8em), 800px",
                },
              },

              // fallback
              {
                type: "element",
                tagName: "img",
                properties: {
                  srcset: imgWidths.map(w => imgSrc + "?w=" + w + "&fm=jpg&fl=progressive " + w + "w").join(","),
                  sizes: "(max-width: 768px) calc(100vw - 3em), (max-width: 1376px) calc(75vw - 8em), 800px",
                  src: imgSrc,
                  alt: imgAlt,
                  loading: "lazy",
                  decoding: "async"
                },
              }
            ];

            // link to original image
            parent.children = [{type: 'element', tagName: 'a', properties: {href: imgSrc}, children: parent.children}]
          }
        }
      }
    })
    .use(rehypeFormat)                                                     
    .use(rehypeStringify)
    .process(markdown)

  return result.toString()
}

上記関数によって、Contentfulから供給されたMarkdown形式の文字列をHTMLに変換します。

個人的に嬉しいのは rehypeKatex によって数式を事前にタイプセットできるという点です。 今までWordpressでもJaveScriptライブラリの MathJax を読み込めばLaTeX形式で書かれた数式を表示させることはできたのですが、 これだとページ表示時にそこそこ大きいJavaScriptをロードする必要があり、遅延が気になっていました。 rehypeKatex はビルド時に数式もタイプセットしてくれるのでそのストレスが無くなります。

また、上のコードでそこそこ記述が多いのは画像に関する処理です。Contentfulの画像はURLにクエリを与えることでサイズ変更ができるので、以下のページを参考に画像をレスポンシブ対応にしました。

コンポーネントの作成

Astroはコンポーネントベースのフレームワークらしいので、各コンポーネントを作成していきます。 正直コンポーネント志向がよくわかっていないので、これでいいのか全く自信ありませんが、例えば以下のコード PostsList.astro は記事のリストを生成するコンポーネントとなります:

---
import { contentfulClient } from "@lib/contentful";
import type { blogPost } from "@lib/contentful";

const entries = await contentfulClient.getEntries<blogPost>({content_type: "blogPost"});

const posts = entries.items.map((item) => {
  const { title, category, tags, content, publishedAt, slug, fromWordPress, wordPressPermalink } = item.fields;

  const regex = /(^|\s).+?()/;
  const firstSentence = content.match(regex)?.[0] || "";

  return {
    title,
    category,
    tags,
    content,
    firstSentence,
    publishedAt: new Date(publishedAt).toISOString().split('T')[0], // YYYY-MM-DD
    slug,
    fromWordPress,
    wordPressPermalink
  };
});

const sortedPosts = posts.sort((a, b) => {
  return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime();
});

// トップページに表示する記事の数
const numberOfShownPosts = 10;

---
<ul>
    {sortedPosts.slice(0, numberOfShownPosts).map((post) => (
    <li>
      <time>{post.publishedAt}</time>
      <h2><a href={`/posts/${post.slug}/`}>{post.title}</a></h2>
      <div><p>{post.firstSentence}</p></div>
    </li>
  ))}
</ul>

<style>
 ul {
     list-style: none;
 }

 li {
     padding: 1.5rem 1rem 2rem;
     border-top: 1px solid #ddd;
 }

 li:last-child {
     border-bottom: 1px solid #ddd;
 }

 li time {
     display: block;
     font-size: 1.4rem;
     color: #666;
 }

 li h2 {
     margin-top: 0.5em;
     margin-bottom: 0.5em;
 }

 li h2 a {
     font-size: 1.4rem;
     font-weight: bold;
 }

 li div {
     word-break: break-all;
 }

 li div p {
     color: #666;
 }
</style>

上で定義された <PostsList> を他のAstroファイルから呼ぶことでコンポーネントとして扱うことができます。本ブログのトップページの記事一覧をこれで作成しています。 各コンポーネントやレイアウトの中で定義される <style> はそれぞれがCSS Moduleとして独立した名前空間で定義されます。 そのため、コンポーネントAを修飾するためのCSSとコンポーネントBを就職するためのCSSが両方のコンポーネントを呼び出す際に衝突することを避けられます。

データの移行

過去のWordPressで作ったデータをContentfulに移行します。 jonashcroft/wp-to-contentful: Script to Migrate WordPress Posts to Contentfulを使いました。基本的にはREADMEの通りで動きます。 (一部、リージョンが en-GB 固定になっている部分があったので、Contentfulのモデルに合わせて en-USja-JP に変えました)

git clone して、内部を適当に書き換えて実行するだけでHTML形式の記事がちゃんとMarkdown形式でContentfulに登録されていました。

Cloudflareにデプロイ

デプロイ先はCloudflare Pagesにしました。GitHubと簡単に接続できるので、上記コードを入れたレポジトリを指定してあげるだけでデプロイはOKです。 デフォルトの設定で master (or main)ブランチにpushされる度にデプロイされるようになっています。

Cloudflare Pages Functionを使ったリダイレクトの設定

今回からブログのURLを https://ki-chi.jp/posts/hogehoge/ のような形式、つまり /posts 以下へパーマリンクをつける形式に変更しました。 今までは各投稿のURLは https://ki-chi.jp/?p=3 というWordPressデフォルトの /?=(ページID) の形式でした。

URLの形式を単純に変更してしまうと過去のリンクがリンク切れになってしまうため、以前の形式のURLに対するアクセスを新形式のURLへリダイレクトする必要があります。 例えば上の https://ki-chi.jp/?p=3 へアクセスした場合あれば、新しいブログの https://ki-chi.jp/posts/wp3/ へリダイレクトされてほしいわけです。

まず初めにこちらの記事を参考に redirects ファイルに記述するやり方を試してみました。 ただリダイレクト元(例: /?p=3 )にクエリ文字列の"?"が含まれていたせいか、なぜか動かず。次善の策として、Cloudflare Pages FunctionとCloudflare Workers KVを使ってリダイレクトを実装しました。

具体的には、JavaScriptファイルを _workers.js としてルートディレクトリにデプロイしました。これでCloudflare Pages Functionとして認識されるようです。 中身は下記の通りで、リクエストURLのクエリ文字列がKVに入っているkeyに一致すれば、それに対応したvalueに指定されたパスへ301で転送するというシンプルなものです。 Cloudflare Pages Functionの詳しい使い方はこちらのドキュメントを参考にしました。また、下記のJavaScriptはこちらの記事を参考にしました。

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const origin = url.origin; // "http(s)://example.com"
    const pathname = url.pathname; // "/"

    // "?" 以降を取り除いた文字列を得る
    const queryString = url.search; // "?p=x"
    const path = pathname + queryString; // "/?p=x"

    const redirect_path = await env.REDIRECT_LIST.get(path); // "/posts/wpX/"

    if (redirect_path) {
      return new Response(null, {
	'status': 301,
	'headers': {
	  'Location': origin + redirect_path,
	}
      });
    }
    // Otherwise, serve the static assets.
    // Without this, the Worker will error and no assets will be served.
    return env.ASSETS.fetch(request);
  },
};

Cloudflare Workers KVへのデータ登録

なお、今回は100記事程度のリダイレクト定義があり、KVの管理画面から手作業で打ち込むには厳しい量だったので、Cloudflare用のCLIツール wrangler を使ってJSONファイルをKVに一括登録しました。

$ wrangler login
$ wrangler kv:bulk put redirects.json --namespace-id [KVのNamespace ID]

この redirects.json は下記フォーマットで記述されるJSONファイルです。

[
  {"key": "/?p=3", "value": "/posts/wp3/"},
  {"key": "/?p=15", "value": "/posts/wp15/"},
  ...
  {"key": "/?p=1119", "value": "/posts/wp1119/"}
]

上記のJSONファイルをKVに登録後、無事リダイレクトが動作するようになりました。

その他

最後に細かい設定をいくつか行いました。

やりのこし

上記で一通りの移行は終わりました。今回の移行はだいぶJavaScript周りの勉強になりました。

以下は手が空いた時に順次対応していこうと思います:

その他・参考