このサイトにブログ機能を追加しました。 CMSは使わず、content/blog/.mdxファイルを置き、ビルド時にNext.jsで読み込みます。 記事を追加するときに必要なのは、ファイルを書いてコミットすることだけです。

なぜCMSを使わなかったか

理由は単純で、記事を書く頻度がまだ読めないからです。 月に1本書くかもしれませんし、しばらく何も書かないかもしれません。 その段階でCMSを選定し、設定し、運用するほどではないと判断しました。

エディタで記事を書き、コミットして、デプロイする。 まずはこの単純な構成を優先することにしました。

static exportとMDXレンダリングの組み合わせ

このサイトはnext.config.tsoutput: "export"を指定し、Cloudflare Pagesへ静的ファイルとしてデプロイしています。 ここで考える必要があったのが、MDXをどのタイミングでHTMLへ変換するかでした。

MDXのレンダリングには、App Router向けのnext-mdx-remote/rscを使っています。 generateStaticParams()で記事のslugを列挙すると、各記事ページがビルド時に生成されます。 そのServer Component内でMDXRemoteへ本文を渡し、MDXをレンダリングする構成です。

このサイトではISRや動的なAPIルートを使わず、すべての記事をビルド時に生成するため、output: "export"とも無理なく組み合わせられました。

frontmatterのパースと、MDX本文のレンダリングは分けています。 frontmatterはgray-matterで取り出し、記事情報を扱う処理はsrc/lib/posts.tsへ集約しました。 本文は<MDXRemote source={post.content} />へ渡します。

const { data, content } = matter(rawMdxFile);
 
// data.title、data.publishedAtなどがfrontmatter
// contentがMDX本文

draft記事を静的ファイルに含めない

frontmatterにdraft: trueを書くと、一覧、詳細、トップページのどこにも表示されないようにしています。 仕組み自体は、getPublishedPosts()でdraft記事を除外しているだけです。

ここで重要なのは、記事一覧だけでなく、詳細ページのgenerateStaticParams()からもdraft記事を除外することです。 一覧に表示されなくても、詳細ページが生成されていればURLからアクセスできてしまいます。 generateStaticParams()へ渡さなければ、draft記事のHTMLは静的成果物に含まれません。

認証による非公開ではありませんが、公開サイトへ記事を出さない方法として、静的サイトでは単純な構成です。

コピーボタンとシンタックスハイライト

コードブロックには、先にコピーボタンを実装しました。 MDXのpre要素をClient Componentへ差し替え、コードのtextContentをクリップボードへ書き込む小さな実装です。 技術記事ではコードをコピーする機会が多いため、記事を書き始める前に用意しておきました。

シンタックスハイライトにはrehype-pretty-codeを使っています。 Shikiによるハイライトをビルド時に適用できるため、シンタックスハイライトのためのクライアントJSを追加せず、output: "export"と組み合わせられます。

配色にはPanda Syntaxを使いたかったのですが、そのまま使えるテーマが見つかりませんでした。 そこで、AIに既存テーマの配色を調べてもらい、Shikiで読み込めるpanda-syntax-theme.jsonを作りました。

ハイライトを追加したあとも、コピーボタン側の実装はほとんど変えていません。 コピーボタンとpre要素を包む構造はそのままで、コードブロックの内部にハイライト済みのHTMLが入る形になっています。

.mdxを書いて、コミットして、デプロイする。 今のところ、このブログに必要なのはそれだけです。