the2g

WordPressからNext.js+Contentful+Vercelへの移行

目次
Next.js

ブログをWordPressからNext.js+Contentful+Vercelへ移行しました。他者が見て参考になるかはわかりませんが、頭に残ってることを書いときます。WordPressは長年プラグインにほぼ頼らず細かい部分も自分でコードを書いていたのですが、それらのメンテにギブしたというのが移行の背景です。単純に使いづらくなったのもありますが。

Next.js

SSGをしたかったので、この分野で強いGatsbyと悩みました。Next.jsは頻繁に仕様が変わるのがネックなのですが、それでも後方互換を大事にしているのとサードライブラリの充実度があり今回採用に至りました。

Theme UI

スタイル設定にはTheme UIを使用しました。Theme UIはEmotion+MDX+Typography.jsを組み合わせたミニフレームワークです。外部から読み込むmarkdownへのスタイル設定時に、グローバルスコープの汚染を回避できるのが好みです。

Next.js 10ではJSX Pragmaが従来の方法では動作しないため、以下に書き直す必要がありました。利用しているバージョンは、0.4.0-rc.14です。

/** @jsxRuntime classic */
/** @jsx jsx *

また、Firefoxでは開発時にのみ"Warning: Prop className did not match."がコンソールに表示されます。Next.jsのSSGは開発時はSSRになるため、そのCSSが挿入されていないことが原因と思われます。Material UIはServerStyleSheetsでこの問題に対処できるようになっています。Theme UIが利用しているEmotionもSSR時のソリューションを用意していますが、自分の環境ではうまくいきませんでした。開発時のみ出る警告のため、現状は放置することにしました。

Contentful

ContentfulはAPIベースのヘッドレスCMSです。日本語未対応ですが、稼働実績と無料枠があるので選択しました。ContentfulのAPIはWebアプリケーション内で利用するContent Delivery API - CDAとコンテンツ管理を行うContent Management API - CMAがあります。どちらのAPIもプログラム内からだけではなくcurlでの呼び出しもできます。CDAの利用方法は以下のようなインスタンスを作成し操作を行います。

const client = require("contentful").createClient({
  space: process.env.CONTENTFUL_SPACE_ID,
  accessToken: process.env.CONTENTFUL_ACCESS_TOKEN,
});

コンテンツを取得するときには主にgetEntries()というメソッドを使います。オプションで取得件数やソートなどが制御できます。件数はlimitキーで指定できますが、このメソッドは一度に最大1000件までしか取得できないという点に注意が必要です。ソート順はorderキーで制御でき、デフォルトは昇順。降順にするには、値の先頭にハイフンをつける。例えばorder: "-fields.publishedDate"のような感じ。

各コンテンツの作成日付はsys.createdAtに存在していますが、これはあくまでContentful内で作成された日付であるため、前述のようなpublishedDateのようなフィールドをContent Model内に作成するのをお勧めします。特に既存のコンテンツをContentfulにインポートするようなケースの場合は、このような独自フィールドがないとインポートした日付すべてがsys.createdAt便りになるため、ソートなどで不便です。

一方、CMAは外部からContenfulのコンテンツの書き込みや読み込みができるため、後述するWordPressの移行時に利用しました。こちらのOAuth tokenは発行時しか確認することができないので取扱いに注意。

WordPressから記事のインポート

Contentfulの記事本文はmarkdown形式を採用したいため、既存のWordPressの記事本文をHTMLからmarkdownに変換する必要がありました。まずWordPress標準のエクスポートでexport.xmlを書き出しておき、wordpress-export-to-markdownを使ってローカルに記事を一括DLします。このライブラリはnpx wordpress-export-to-markdownで対話形式で進行します。例えば以下のような構造で書き出しができます。

.
├── /2018
│   ├── /article-1
│   │   ├── index.md
│   │   └── /images
│   │       ├── android.jpg
│   │       └── ios.jpg
│   ├── /article-2
│   │   └── index.md
│   └── /article-3
│       ├── index.md
│       └── /images
│           └── linux.png
├── /2019
└── /2020

年の下に各記事のディレクトリを作成し、その中に記事がindex.mdで書き出されます。記事中に画像がある場合は、imagesというフォルダが生成されて中に画像が配置されます。マークダウンファイルの画像パスはこのローカル画像フォルダを参照します。

記事のタイトルはフォルダ名になるのですが、フォルダ名として利用できない記号は省略されます。また、フォルダ名はURLエンコードされているため、日本語などの2バイト文字が含まれる場合は別途デコードが必要でした。自分はnode環境で反復させてdecodeURI(dirname)しました。ただし、記事のタイトル自体はindex.md内のFront-matterに投稿日と一緒に記載されているため、これをgray-matterなどを使って参照できます。

title: "Next.js 9.3の変更点"
date: "2020-03-14"
------------------
ここに本文

GatsbyやMDXで利用する場合はこのまま必要なメタデータを使えば移行完了になります。今回は更にContentfulにエクスポートが必要なので、ここでCMAを利用しました。CMAはCDAとは別のアクセストークンが必要になるため、Contentfulでトークンを作成しておきます。

外部から記事の投稿に使うメソッドはcreateEntryです。Contentful modelにもよりますが、例えば1件挿入する場合は以下のようになります。自分の場合は6つのフィールドがあり、CategoryのみReference型になっています。Reference型の場合はtypeをlinkにして参照するコンテンツのIDを指定します。

client.getSpace("<your-spaceid>").then((space) => {
  space
    .getEnvironment("master")
    .then((enviroment) => {
      enviroment.createEntry("blogPost", {
        fields: {
          title: { "ja-JP": "test-title" },
          slug: { "ja-JP": "test-slug" },
          description: { "ja-JP": "test-description" },
          body: { "ja-JP": "test-body" },
          publishedDate: { "ja-JP": "2020-12-01" },
          category: {
            "ja-JP": {
            	sys: {
              	type: "link",
              	linkType: "Entry",
              	id: "<your-category-id>",
            	},
            },
          },
        },
      });
    })
    .then((entry) => console.log(entry))
    .catch(console.error);
});

上記は値がハードコーティングされていますが、実際は先に取り出した記事の本文などの情報を配列に入れ、Promise.all()の中でインポートしました。尚、CMAは1秒あたり10件、1時間あたり36000件といったレートリミットが設けられているため、setTimeoutで遅延させるなどの工夫が必要です。この部分はwp-to-contentfulを参考にしました。面倒なら参照先のコードに全部任せて移行しても問題ないと思います。

同様に画像アセットもインポートできます。1件だと以下のような感じです。

async function uploadFile(title, file, description ="demo") {
  var fileData = {
    fields: {
      title: { "ja-JP": title, },
      description: { "ja-JP": description },
      file: {
        "ja-JP": {
          contentType: file.contentType,
          fileName: file.fileName,
          upload: file.url,
        },
      },
    },
  };

  const fileobj = await client
    .getSpace("<your-space-id>")
    .then((space) => space.getEnvironment("master"))
    .then((enviroment) => enviroment.createAsset(fileData))
    .then((asset) => asset.processForAllLocales())
    .then((asset) => asset.publish())
    .then((asset) => asset.fields.file)
    .catch(console.error);

  return fileobj;
}

インラインSVG

Next.jsでインラインSVGを取り扱う1番簡単な方法は、airbnbのプラグインです。まずyarn add --dev babel-plugin-inline-react-svgでインストールします。そのあと.babelrcを編集します。

{
  "presets": ["next/babel"],
  // 以下を追加
  "plugins": ["inline-react-svg"]
}

Next.js v10はインラインSVGで絶対パスは解決できないようです。babel-plugin-module-resolverというライブラリを入れれば解決できるそうですが、こだわりがなければ参照パスで十分です。ちなみにNext.jsのbaseUrlの設定も機能しませんでした。

import Profile from "../assets/profile.svg";

<Profile width={48} height={48} />

サイトマップ

ホスト先はVercelを利用しています。GitHubリポジトリとプロジェクトを接続することで、Vercelはプロジェクトで使用されているフレームワークを判別して規定のコマンドを実行してくれます。規定コマンドを上書きするにはVercelのBuild & Development Settingsで変更できます。ここにpostbuildはありませんが、package.jsonに記述したpostbuildはmainブランチにマージしたときに行われる自動ビルド時でも実行してくれるようです。SSG後にサイトマップを出力するにはnext-sitemapが便利で、前述のpostbuildを利用して書き出します。

"scripts": {
	"postbuild": "next-sitemap"
}

next-sitemap.jsを配置するのを忘れないように。

module.exports = {
  siteUrl: "https://www.the2g.com",
  generateRobotsTxt: true,
};

ちなみに生成されるsitemap.xmlはgitignoreに入れておきます。

環境変数

Vercelの独自環境変数

Vercelはデプロイ時にランダムなURLを生成して割り振ります。例えば"my-site-xxxxx.vercel.app"のような形式。このURLはデプロイ後に生成されますが、この値をコード内で動的に取得したい場合はVERCEL_URLという環境変数を使うとアクセスできます。ただし、環境変数はデフォルトでは無効になっていて、有効化するにはプロジェクトのSettingsからEnviroment VariablesのAutomatically expose System Enviroment Variablesという項目にチェックを入れます。これでコードではprocess.env.VERCEL_URLという環境変数で動的なURLを取得できます。注意点は、プロジェクトに独自ドメインを割り当てた場合でも、VERCEL_URLで取得されるURLは自動生成される方のURLであり、独自ドメインははないという点です。

その他にも色々な環境変数が用意されています。

Next.jsの環境変数

Next.js 9.4からenvファイルを利用した環境変数がサポートされています。本番や開発環境に応じて環境変数を使い分けることができます。これを利用したときにリポジトリに含めることのできない環境変数(Contentfulのシークレットなど)はenv.localのようなlocal付きenvファイルに記述します。

本番用にはVercelのSettingsにあるEnviroment Variablesに同名の名前で定義します。ここで定義しておくと、GitHubと連携後にコードをプッシュして自動ビルドしてくれるときもこの環境変数を利用してくれます。

RSS

RSSフィードはfeedを使い生成しました。指定フォーマットに従ってURLなどの各種値を設定するだけなので定義は簡単です。このライブラリは1つのインスタンスでXML2種とJSON1種のファイルを作成できます。Next.jsの場合はpublic/rssのようなフォルダに書き出すようにしておけば、ビルド後はルートにrss/feed.xmlといった感じで配置されます。フィードの生成タイミングはindexページのgetStaticprops()内で行いましたが、フィードの生成をAPIルートに置いてビルドフックのようなもので呼び出すのが本来スマートなのかなと思います。

一般的なRSS Readerは記事の取得タイミングが数十分から一時間以上と思ったより長く、さらには開発環境では取得の動作確認ができないなど、中々面倒でした。

ドメイン移行

ドメインは引き続き現状のものを使っていきたいので移行手続きを行いました。Vercelのプロジェクトの設定のDomainsを選択して既存のドメインを入力すると、A RecordとNameserversが表示されるのでメモしておきます。

自分の場合、ドメインはスタードメインで取得しています。管理ページからネームサーバーとDNSレコードを編集できるフォームが用意されているため、上記でメモした値に書き換えれば問題なく動作しました。

リダイレクト

旧ブログの記事URLとインポートした記事のURLが異なるためリダイレクトさせます。Vercel or Next.jsどちらからでも設定ができるようです。Vercel側で設定すると、フレームワーク(今回の場合Next.js)を問わずリダイレクトさせることができますが、開発環境ではyarn devでリダイレクトは動作しません。この場合はvercel devを使えば動作します。Next.js側でリダイレクト設定をすれば前者でも動作しますが、今回はVercel側でリダイレクトの設定をすることにしました。

まず、古いURLと新URLの一覧を次のようなフォーマットでGoogle SpreadSheetsに作成します。これはCMAで記事を書き出すときにマッピングしておいたデータを加工して作りました。

これをExport Sheet Dataというアドオンを使い以下のようなJSONで書き出します。nameというキーがありますが、この値は実際は使っていません。単に後から確認がしやすいためです。

[
  {
    "name": "Spotify Web Player not working in Vivaldi",
    "source": "/2479",
    "destination": "/spotify-web-player-not-working-in-vivaldi"
  },
  {
    "name": "PhotoShopで全ピクセルをガイドで囲む",
    "source": "/2503",
    "destination": "/surround-every-pixel-with-a-guide-using-photoshop"
  },
  ...
]

これを使ってリダイレクト用の設定ファイルを書き出すスクリプトを作成します。

const fs = require("fs");
const data = require("./data/route-data.json");

// source: 移転前のパス
// destination: 宛先
// permanent: 永続(308)はtrue, 一時的(307)はfalse
let routes = data.map((item) => {
  return {
    source: item.source,
    destination: `/post${item.destination}`,
    permanent: true,
  };
});

let redirects = {
  redirects: routes,
};

let newdata = JSON.stringify(redirects, null, 2);
fs.writeFileSync("vercel.json", newdata);

これでvercel.jsonが書き出せるので、プロジェクトルートに配置すれば完成です。

{
  "redirects": [
    {
      "source": "/2479",
      "destination": "/post/spotify-web-player-not-working-in-vivaldi",
      "permanent": true
    },
    {
      "source": "/2503",
      "destination": "/post/surround-every-pixel-with-a-guide-using-photoshop",
      "permanent": true
    },
    ...
  ]
}

とりあえず記事の移行と投稿ができるところまでが目先の目標だったので、最適化はまだしていません。時間ができたときにでも修正していこうかなと思ってはいます。

Share article