このサイトの記事は、Markdownファイルとして Git で管理しています。一方で、公開と非公開は管理画面のチェックボックスで切り替えられます。

CMSは導入していません。本文はGit、公開状態はD1と役割を分け、公開状態が変わったらサイトを再ビルドする構成です。この記事では、その設計と実装のポイントを紹介します。

実現したかったこと

  • 記事本文と画像は、コードと同じリポジトリで管理する
  • 記事を書き終えても、公開は別のタイミングで判断する
  • 公開ごとにコミットしない
  • 複数の記事をまとめて公開する
  • 公開中の記事が0件なら、LPに記事へのリンクを出さない
  • 配信は静的HTMLのままにする

公開の反映に数分かかっても問題がなかったので、リクエストのたびにデータベースを読むのではなく、ビルド時に公開状態を取り込む方式を選びました。

全体の構成

処理の流れは次のとおりです。

  1. 管理画面で記事を選び、公開または非公開を選ぶ
  2. Pages Functions が D1 の公開状態を更新する
  3. Deploy Hook を呼び出し、再ビルドを開始する
  4. ビルド時に公開APIを呼び、公開中の記事の一覧を取得する
  5. 公開中の記事だけをHTMLとして出力する

管理画面からPages Functions、Deploy Hookを経て再ビルドし、GitとD1の情報から静的HTMLを出力する流れの図

未公開の記事はビルド結果に含まれないので、URLを直接開いても404になります。

D1には公開状態だけを持つ

テーブルには、公開中の記事のスラッグと公開日だけを保存します。

CREATE TABLE article_publications (
  slug TEXT PRIMARY KEY,
  published_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);

行があれば公開中、なければ未公開です。真偽値の列を持たないので、状態の組み合わせを考える必要がありません。タイトルや本文はGit側にあり、D1に重複して保存しないようにしています。

未公開は行なし、公開中は行ありの2つの状態で、公開で行を追加し、非公開で行を削除し、再度公開しても公開日は変わらないことを示す図

まとめて公開しても再デプロイは1回

公開APIは、スラッグの配列を受け取ります。更新は batch でまとめて実行し、その後で Deploy Hook を1回だけ呼びます。

const statement = env.DB.prepare(
  body.published
    ? "INSERT INTO article_publications (slug) VALUES (?1) ON CONFLICT (slug) DO NOTHING"
    : "DELETE FROM article_publications WHERE slug = ?1",
);
await env.DB.batch(body.slugs.map((slug) => statement.bind(slug)));

return json({ ok: true, deployed: await triggerDeploy(env) });

ON CONFLICT DO NOTHING にしているので、公開中の記事を含めて再度公開しても、最初の公開日は変わりません。

Deploy Hookの失敗に備える

Deploy Hook は外部へのHTTPリクエストなので、失敗する場合があります。ただし、この時点で公開状態の保存は完了しています。

そこで、再デプロイの失敗をエラーとして扱うのではなく、結果を画面に返すようにしました。

export async function triggerDeploy(env: Env): Promise<boolean> {
  if (!env.PAGES_DEPLOY_HOOK_URL) {
    return false;
  }
  try {
    const response = await fetch(env.PAGES_DEPLOY_HOOK_URL, { method: "POST" });
    return response.ok;
  } catch (error) {
    console.error("deploy hook failed", (error as Error).message);
    return false;
  }
}

失敗したときは、管理画面の再反映ボタンから再実行できます。Deploy Hook のURLを知っていればビルドを起動できるので、URLはシークレットとして登録し、リポジトリには含めません。

取得に失敗したらビルドを止める

ビルド時に公開APIを取得できなかった場合の扱いは重要です。一時的な障害で公開記事が0件と判定されると、すべての記事がサイトから消えてしまいます。

そのため、取得に失敗したらビルドを失敗させます。

if (!response.ok) {
  throw new Error(`公開記事を取得できません: HTTP ${response.status}`);
}

Cloudflare Pages では、ビルドが失敗しても公開中のデプロイはそのまま残ります。記事が消えるより、更新が遅れるほうが安全だと判断しました。

例外は404です。APIを初めてデプロイするときは、公開中のサイトにAPIがまだ存在しないので、このときだけ記事なしでビルドを続けます。

公開APIの取得に成功すれば公開記事を出力し、404なら記事なしで続行し、それ以外の失敗ではビルドを止めて公開中のサイトを残すことを示す図

ローカルでは未公開の記事も確認する

未公開の記事を確認するため、環境変数で全件を出力できるようにしました。

if (process.env.ARTICLES_PREVIEW === "all") {
  return sources.map(({ slug, updatedAt }) => ({ slug, published_at: updatedAt }));
}

また、記事の frontmatter は、未公開の記事も含めてビルド時に検証します。公開するタイミングで初めて不備に気づくことを防ぐためです。

この方式が向いている場合

向いている場合 向いていない場合
書き手がGitを扱える 非エンジニアが本文を編集する
反映に数分かかっても問題がない 公開を即時に反映する必要がある
記事の本数や更新頻度が多くない 予約公開や編集の承認が多数必要になる

逆に、本文の編集者が複数いる場合や、公開の予約が必要な場合は、ヘッドレスCMSの導入も検討する価値があります。

まとめ

本文をGit、公開状態をD1に分けることで、静的サイトの配信方式を保ったまま、管理画面から記事を公開できるようになりました。全体の仕組みが小さくなり、記事が0件のときの表示も、ビルド結果だけで制御できます。