zfb
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

サンプル: Blog

Markdown/MDX の記事、3 種類の動的ルート、zudo-react のテーマ切り替えアイランド、手書き CSS のテーマを備えた純粋 SSG のブログ。アセットのみの Cloudflare Worker としてデプロイされています

このページで扱うこと

実際にデプロイされている zfb 製のブログです。Markdown と MDX の記事からなる blog コンテンツコレクション、3 種類の動的ルート、zudo-react の "use client" アイランド 1 つ、そして手書き CSS で作ったテーマで構成されています。完全な静的ビルドで、SSR も Cloudflare のバインディングもアダプタも使いません。コンテンツ駆動の zfb サイトとして、現実的な最小構成になっています。

ライブデモ: zfb-example-blog.takazudomodular.com

リポジトリ: Takazudo/zfb-example-blog

何を示すか

  • .md と .mdx の記事を 1 つのディレクトリに混在させた blog というコンテンツコレクション。

  • 3 つのルートファイルによる 3 種類の動的ルート — 記事個別ページ(pages/blog/[slug].tsx)、ページネーション付き一覧(pages/blog/page/[page].tsx)、タグ別ページ(pages/tags/[tag].tsx)。それぞれが独自の paths() を持ちます。

  • ソート済みの配列をそのまま paths() の戻り値に変換する paginate()。

  • entry.Content の components プロパティ経由でページに届く独自の MDX コンポーネント(<Note>) — MDX コンポーネントを参照。

  • 初回レンダリングが SSR セーフな zudo-react のアイランド 1 つ(ThemeToggle)と、誤ったテーマで描画されないようにするインラインの事前スクリプト。

  • prefers-color-scheme ではなく data-theme 属性で切り替える、styles/global.css にすべて記述したテーマ。

使用技術

項目zfb-example-blog
フレームワークzudo-react と zfb 3.1.0。テーマ切り替えには zudo-react のアイランドランタイムを使用
ランタイム@takazudo/zfb-runtime 3.1.0
スタイリングstyles/global.css の手書き CSS。wind.reset: "owned-v1" と preflight 互換の手書き @layer base ブロック
レンダリング純粋な SSG。export const prerender = false を持つルートはありません
コンテンツコレクション 1 つ — blog → content/blog(.md 4 本 + .mdx 1 本)
インタラクション"use client" アイランド 1 つを <Island when="idle"> でマウント
CloudflareWorkers Static Assets、アセットのみ — wrangler.toml に main キーはありません
バインディングなし
アダプタ@takazudo/zfb-adapter-cloudflare は依存関係に含まれていません
その他の依存wrangler 4.85.0 は devDependency。ほかにランタイム依存はありません

zfb build が出力する HTML は 14 ページです。内訳はトップ 1、記事 5、ページネーション一覧 2、タグページ 6。この数字の根拠は後述の「仕組み」で追いかけます。

必要な準備と設定

Cloudflare のアカウントがなくてもローカルで動きます。 pnpm install && pnpm build だけでサイト全体が生成され、pnpm preview で確認できます。用意すべきものは何もありません — バインディングも環境変数も Worker シークレットもマイグレーションもシードデータも不要です。content/blog/ の記事がそのままデータベースです。

Cloudflare が必要になるのはデプロイ時だけです。 リポジトリの GitHub Actions ワークフローが wrangler deploy を実行し、dist/ を Worker の静的アセットとしてアップロードしたうえで、wrangler.toml に宣言されたカスタムドメインを紐付けます。必要なリポジトリシークレットは CLOUDFLARE_API_TOKEN と CLOUDFLARE_ACCOUNT_ID の 2 つで、トークンがない環境では認証を伴うジョブが自動的にスキップされるため、フォークでも CI は緑のままです。

カスタムドメインにはゾーンスコープの権限が必要

wrangler.toml は custom_domain = true を宣言しているため、API トークンにはアカウントスコープの Workers Scripts · Edit に加えて、対象ゾーンの Zone · Workers Routes · Edit が必要です。これがないと wrangler deploy は Worker のアップロードまで成功したうえで、ルート作成の段階で失敗します。デプロイが中途半端に終わり、ドメインは解決されないままになります。トークンの詳しい作り方はリポジトリの docs/cloudflare-setup.md にあります。

ライブデモは公開された読み取り専用の静的サイトです。認証はなく、書き込める箇所も壊せる状態もありません。1 点だけ既知の粗があり、wrangler.toml にも記載されています。not_found_handling = "404-page" が設定されている一方で、このプロジェクトは dist/404.html を出力しないため、マッチしないパスは Cloudflare の素の 404 にフォールバックします。設定自体は 404 ページを追加したときのために正しく先回りしてある、という状態です。

仕組み

4 つのルートファイルから 14 ページ

ページ数はビルド設定ではなく、それぞれの paths() が返すものから決まります。追いかける価値のある部分です。

pages/index.tsx は静的ルートなので 1 ページ。pages/blog/[slug].tsx は記事 1 本につき 1 エントリを返すので、記事 5 本で 5 ページ。pages/tags/[tag].tsx は各記事の tags フロントマターを Map<string, BlogEntry[]> に集約し、キーごとに 1 ページを出力します。5 本の記事が言及しているタグは deploy、framework、intro、perf、ssr、tooling の 6 つなので 6 ページです。

残る 2 ページはページネーション付き一覧で、計算は paginate() が引き受けます。その戻り値がそのまま paths() の戻り値になり、間に変換処理を挟む必要はありません。

pages/blog/page/[page].tsx
import type { BlogEntry, BlogFrontmatter } from "../../../lib/types";

export async function paths() {
  const { getCollection } = await import("@takazudo/zfb/content");
  const { paginate } = await import("@takazudo/zfb/paginate");
  const posts = await getCollection<BlogFrontmatter>("blog");
  const sorted = [...posts].sort((a, b) => b.data.date.localeCompare(a.data.date));
  return paginate(sorted, { pageSize: 3, param: "page" });
}

記事 5 本に対する pageSize: 3 で /blog/page/1 と /blog/page/2 の 2 ページ。合計 1 + 5 + 6 + 2 = 14 ページです。

.sort() の前に [...posts] でコピーしている点にも注目してください。このサンプルはソートするすべてのルートで意図的にこうしています。getCollection() が返す配列は複数のルート間で共有される可能性があり、その場でソートすると他のルートの並び順まで黙って変えてしまうからです。

誤ったテーマでの一瞬の描画を避ける

テーマ切り替えはこのサイト唯一のインタラクティブな要素で、素直に実装すると 2 種類の問題が同時に起きるため、サンプルの中でもっとも読む価値のある部分です。

アイランドの初回レンダリングは、サーバー側とクライアントのハイドレーション時とで同一の HTML でなければなりません。ThemeToggle は signal<Theme> を決定的な "light" で初期化し、computed でボタンのテキストと ARIA ラベルを作るため、セットアップ中にブラウザーの状態を読みません。アイランドがサーバー HTML を採用した後、scope.onActivate が保存済みまたはシステムのテーマを読み、scope.effect が変更をドキュメントとストレージに反映します。ボタンは on:click でシグナルを更新します。

インラインスクリプトは、ページ全体の初回描画に関する問題を解決します。<head> でスタイルシートの解析前にテーマ属性を同期的に設定するため、アイランドの有効化を待たずに最初のフレームから選択した配色が使われます。

layouts/default.tsx
const THEME_BOOTSTRAP_SCRIPT = `(() => {
  try {
    var saved = localStorage.getItem("basic-blog:theme");
    var hasMM = typeof window !== "undefined" && typeof window.matchMedia === "function";
    var theme = saved === "light" || saved === "dark"
      ? saved
      : (hasMM && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
    document.documentElement.dataset.theme = theme;
  } catch (e) {
    document.documentElement.dataset.theme = "light";
  }
})();`;

スタイルシートも同じ属性を読みます。styles/global.css は :root にライトテーマの --blog-* カスタムプロパティを宣言し、[data-theme="dark"] で上書きします。レイアウト、コンポーネント、記事本文のスタイルはすべて手書きで、ユーティリティクラスは使いません。zfb.config.ts は zudo-wind の reset: "owned-v1" を設定しています。さらに手書きの @layer base ブロックで、リセットに含まれない preflight 互換ルールの一部を戻します。測定済みの比較はComing from Tailwindを参照してください。

レイアウト自体は素のサーバーコンポーネントのままで、JavaScript を送るのはトグルだけです。しかも <Island when="idle"> によって、ブラウザに余裕ができるまでその実行も遅らせています。

MDX の中の独自コンポーネント

content/blog/hello-zfb.mdx には <Note title="MDX in basic-blog"> と書かれています。これが解決できるのは、記事ページのルートが本文を描画する際に defaultComponents と一緒に Note を渡しているからです。

pages/blog/[slug].tsx
<post.Content components={{ ...defaultComponents, Note }} />

defaultComponents は HTML タグの上書き(<p>、<a>、見出し、リストなど)を提供します。先にスプレッドしているので、キーが衝突した場合は右側の個別指定が勝ちます。<Note> コンポーネント自体はあえて素朴な作りです。このサンプルでの役割は見栄えのする admonition になることではなく、受け渡しの仕組みが機能していることを示すことだからです。

ローカルで動かす

Node.js >= 22.12.0 と pnpm 10.x が必要です。pnpm install だけで zfb CLI も入るため、Rust ツールチェインも zfb 本体のチェックアウトも要りません。

pnpm install
pnpm dev        # zfb dev server with live reload
pnpm build      # static build into dist/ (14 pages)
pnpm preview    # serve the built dist/ locally
pnpm typecheck  # zfb check — collection validation + tsc --noEmit

純粋な SSG なので、Worker のリクエストスコープにまつわる制約はありません。zfb dev が配信するものは本番と同じです。細かい点が 2 つあります。predev が rm -rf dist .zfb .zfb-build を実行するため pnpm dev は毎回コールドビルドから始まること、そして pnpm typecheck の実体は zfb check で、tsc に加えてコレクションの検証も行うことです。

関連ページ

Revision History

作成更新