2024-06-09

ブログのホスティングをVercelからCloudflare Pagesに移した ContentLayer編

5月のブログを書きそびれました。2024あけおめ記事で「1ヶ月に一回ブログを書く」と啖呵切っていたのにわずか4ヶ月で潰えてしまうとは・・・根性が足りないですね。

昨日そろそろブログ記事を書いてやるかとなったんですが、ブログをCloudflareに移管してEdge Runtimeで"本物のWeb"をやって約束された地に行きたいなとなって、コードがちゃがちゃ弄ってたら移管成功したので、今回の記事ではその過程をメモがてら残そうという試みです。思い出しながらなので記憶定かじゃないところあるかも。(本当は最近作ったスマートスピーカーの記事とか書きたかったんですがまた今度にします)

今回はcontentlayerを導入した部分について話そうと思います。

課題#

Next.jsはEdge Runtimeを使うことによってCloudflare Pagesにデプロイすることが可能になります。
Edge RuntimeではWeb標準をベースとしたAPIが使用可能で、一方でfspathなどといったNode.jsの一部のAPIを使用するパッケージを使用することができません。しかしこのブログではfs/promisesreaddir()や、path.join()などのNode.jsのAPIに依存していて、このままでは移行できませんでした。

例えば、記事一覧の取得には以下のようなコードがありました。

// readdir, pathのimport
import { readdir } from "fs/promises"
import path from "path"
 
// 記事一覧を取得する関数
// これを記事一覧ページで使っていた
export const getArticles = async () => {
  // .mdxファイルがあるディレクトリからファイル一覧を取得
  const postDirPath = path.join(process.cwd(), "src/markdown/posts")
  const postFilePaths = await readdir(postDirPath)
 
  const postMetas = await Promise.all(
    // ファイルパス一覧をmapで処理していく
    postFilePath
      .map(async (filePath) => {
        // .mdxファイルから`meta`を一度インポートする。
        // `meta`にはタイトルの情報と公開日の情報が含まれている
        const { meta } = (await import(`@/markdown/posts/${filePath}`)) as {
          meta: { title: string; publishedAt: string }
        }
 
        // metaと記事のidを生成して返す
        return { ...meta, id: filePath.replace(/\.mdx$/, "") }
      })
      // ソートして降順にする
      .sort()
      .reverse(),
  )
  return postMetas
}

残念ながらreaddirpath.joinを代替できるものはedge runtimeにはありません。なのでこのケースでは「ファイルシステムから情報を取得する」という方法を根本から見直す必要がありました。

解決#

そもそも記事一覧の情報などはビルド時に確定しているので、動的に生成する必要がありません。なのでビルド時に記事一覧が生成できればいいわけです。
なにかいいツールないかなと思って見つけたのがcontentlayerというツールでした。

contentlayerは.mdファイルなどといった、いわゆるコンテンツファイルの情報をjsonに変換し、typescript上のアプリケーションに簡単にimportできるようにするためのツールです。

.mdファイルからjsonへの変換はビルド時に一回走るのみだったり、ファイルの一覧を取得するためのallArticles(Articleの命名は設定に依ります)のようなオブジェクトを生やしてくれたり、そのうえ生成物に型をつけてくれたり等々・・・先の目標に耐えうるし、上記の利点も享受できそうなので今回は他と比較することもなくcontentlayerを採用することにしました。

contentlayerを知るきっかけになった記事は以下でした。

個人ブログ開発でとても便利な Contentlayer を導入してみた | stin's Blog

セットアップは公式のGetting Startedに解説されていて、手順通りに進めていくことで特に詰まることなく導入できました。

セットアップの中でcontentlayer.config.tsという、contentlayerの設定ファイルを編集していく必要がありました。そのうち以下のようなmdファイルの定義を書く必要があったので説明コメントともに示しておきます。

 
// 記事のmarkdownファイルの定義
export const Article = defineDocumentType(() => ({
  // ここで指定した名前が生成される型の名前になります
  name: "Article",
  // どのようなフォルダに入っているか指定します
  // ここではフルパスは指定しません
  // 下記のmakeSourceに渡すパスの以下をここに記述すればよいです
  filePathPattern: `**/posts/*.md`,
  // contentType: "mdx",
 
  // フィールドの設定です
  // フィールドではmarkdownのyaml headerに書かれたmetadataを定義しておきます
  fields: {
    title: { type: "string", required: true },
    publishedAt: { type: "date", required: true },
  },
  // computedFieldsは生成時に自動で計算されるメタデータです
  // 自分は記事のidを.mdファイルのファイル名にしたかったのでその定義を行いました
  computedFields: {
    id: {
      type: "string",
      resolve: (doc) => doc._raw.sourceFileName.replace(/\.md$/, ""),
    },
  },
}))
 
// about.mdの設定
export const About = ...
 

上記のように設定すると、記事一覧を取得する部分は以下のインポートだけで済みます。 注: contentlayer/generatedが自動生成されたjsonファイルのあるpathです。

import { allArticles } from "contentlayer/generated"

記事ページの本文その他情報の取得も以前は↓

const getContent = async (slug: string) => {
  const { default: Content, meta } = (await import(
    `@/markdown/posts/${slug}.mdx`
  )) as { default: React.FC; meta: { title: string; publishedAt: string } }
 
  return { Content, meta }
}

contentlayer導入後は以下のようになりました。

import { allArticles } from "contentlayer/generated"
const post = allArticles.find((post) => post.id === slug)

まとめ#

contentlayerを導入したことでファイルシステムに依存していたコードを廃することができました。うれしい。

ここまでで既にEdge Runtimeには対応できているのですが、Cloudflare Pagesでcontentlayerを動かすためにはmdxを廃する必要がありました。理由はcontentlayerのmdx対応はevalを用いて実現されているところにあります。Cloudflare Pages(Workers)ではevalを用いたコードは許容されていないからです。(EvalError: Code generation from strings disallowed for this context - Developers / Cloudflare Pages - Cloudflare Community)

JavaScript and web standards · Cloudflare Workers docs

For security reasons, the following are not allowed:

  • eval()
  • new Function
  • WebAssembly.compile
  • WebAssembly.compileStreaming
  • WebAssembly.instantiate with a buffer parameter
  • WebAssembly.instantiateStreaming

自分は現状mdx固有の機能を使っていなかったので、特に問題なく通常のmdファイルへ移行できました。