このブログ(blog.tak3.jp)は、Astro でビルドした静的サイトを Cloudflare Workers の静的アセットで配信し、Sveltia CMS で編集し、公開すると Qiita / DEV.to へ自動配信される構成で動いている。日本語と英語の2言語構成で、main ブランチへの push がそのまま本番デプロイになる。
いまこの記事を Qiita で読んでいる方へ。正直に言うと、これは自動ではなく手動で貼っている。本来この日本語版は後述のパイプラインで Qiita へ自動投稿されるはずだったが、まさにその Qiita 側のアカウント制限に阻まれ、現在は手動運用に切り替えている。英語版が届いている DEV.to では、パイプラインは設計どおり全文を自動で配信している。この記事自体が、この記事で説明する経路で(Qiita だけは手動で)届いているわけだ。
本稿は「どう作るか」の手順書ではなく「なぜこの構成にしたか」の設計記だ。特に、Cloudflare Pages を使わなかった理由と、translationKey による日英2言語設計の2点に紙幅を割く。
前提: 要件と技術選定
作りたかったのは「ブログサイト」ではなく、自分のブログを一次情報源とする発信基盤だ。要件は次の5つ。
- 日本語・英語の2言語で発信できる
- コンテンツを Git で管理する(履歴が残る、ロックインしない、エディタでもブラウザでも書ける)
- 運用コストを限りなくゼロに近づける(サーバレス・無料枠中心)
- 公開したら外部プラットフォーム(Qiita / DEV.to)へ自動で配信される
- AI エージェントが実装する前提で、仕様書を単一の情報源にする
採用したのは次の構成。
| 領域 | 採用 |
|---|---|
| SSG | Astro + TypeScript(Content Layer API) |
| コンテンツ | Markdown / MDX + YAML frontmatter |
| CMS | Sveltia CMS(Git ベース、管理画面 /admin/) |
| ホスティング | Cloudflare Workers 静的アセット + 言語振分け用の薄い main Worker |
| 外部配信 | 別 Worker(Cron + D1 + KV)による配信サブシステム |
| RSS / sitemap |
@astrojs/rss / @astrojs/sitemap
|
全体像は1枚にするとこうなる。
CMS に Decap(旧 Netlify CMS)ではなく Sveltia を選んだのは、Decap 互換の config を保ちながら実装が新しく軽量で、開発が活発だからだ。Git ベース CMS は「WordPress の代替」ではなく「Git 上の構造化コンテンツを編集するための UI」と割り切って使っている。
Cloudflare Pages は「廃止」されたのか
本題。まず事実の整理から。2026年7月時点で、Pages に正式な廃止(EOL)の発表はない。 既存プロジェクトはサポートされ、新規作成も今のところ可能だ。
ただし実態は「新規非推奨」に近い。公式ドキュメントには Pages → Workers の移行ガイドが用意され、「今後の開発の焦点は Workers に置くため、新規プロジェクトには Workers を推奨する」と明言されている。機能面でも Workers は Pages でできることのほぼすべてをカバーしたうえで、Durable Objects や Cron Triggers、より充実した Observability など Pages にない機能を持つ(公式の互換性マトリクスに残る差分は、ブランチビルドの設定自由度など少数だ)。
紛らわしい点をひとつ。正式に deprecated なのは Workers Sites という、Pages とは別物の旧機能だ(こちらは Workers Static Assets への移行が明言されている)。「Pages 廃止」という言説の何割かは、この混同から来ていると思う。
というわけで、答えは「廃止になったから」ではない。このブログが Workers 静的アセットを選んだ理由は、消極的なもの1つと積極的なもの2つだ。
消極的な理由: いま新規に Pages を選ぶと、将来の移行コストを最初から抱え込むことになる。
積極的な理由①: ルーティングをコードで持てる。 このサイトは /ja/ と /en/ が並列で、ルート / に実体ページがない。静的ホスティングは閲覧者の言語を読めないが、静的アセットの前段に薄い main Worker を置けば、ルート / だけ言語を判定して 302 を返せる。
// wrangler.jsonc(抜粋)
{
"name": "takashimatsuyama-blog",
"compatibility_date": "2026-05-01",
"main": "worker/index.ts",
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"run_worker_first": ["/"] // ルート / だけ Worker が先行。他は静的アセットを素通し
}
}
言語判定は3段構えにした。①クエリ ?hl=ja|en(言語スイッチャー経由の明示切替。cookie に記録)、②cookie(過去の明示選択。ブラウザ設定より優先)、③初回は Accept-Language の最上位が en 系なら /en/、それ以外は /ja/(JA-primary サイトなので既定は ja)。302 には Vary: Accept-Language と Cache-Control: private, no-store を付け、言語判定の結果が共有キャッシュに乗らないようにしている。
// worker/index.ts(抜粋・簡略化)
const isRootDocument =
url.pathname === '/' && (request.method === 'GET' || request.method === 'HEAD');
// 言語別ページ・資産・404 はすべて静的アセットへ素通し
if (!isRootDocument) return env.ASSETS.fetch(request);
// 1. 明示切替 → 2. cookie → 3. Accept-Language(既定 ja)
const hl = url.searchParams.get('hl');
if (hl === 'ja' || hl === 'en') return redirectToLocale(url, hl, true);
const cookie = readCookie(request, LANG_COOKIE);
if (cookie === 'ja' || cookie === 'en') return redirectToLocale(url, cookie, false);
return redirectToLocale(url, preferredLocale(request.headers.get('Accept-Language')), true);
積極的な理由②: デプロイの一本化。 このリポジトリにはサイト本体のほかに、外部配信用の Worker(Cron / D1 / KV)が同居している。Pages と Workers の2系統で管理するより、どちらも wrangler deploy に揃えたほうが構成も CI も単純になる。
トレードオフも書いておく。Pages が無料で面倒を見てくれたビルドとプレビューの体験は、自前で構成することになる。このサイトでは main への push をトリガーに wrangler deploy が走るようにし、プレビューは wrangler versions upload が発行する URL で代替している。
多言語設計 — 「対訳」ではなく「対」で設計する
URL は /ja/... と /en/... のプレフィックス方式。hreflang の x-default は /ja/ に向けている。日本語を一次言語とするサイトで、言語不明の訪問者にどちらを見せるかを曖昧にしないためだ。
設計判断が必要だったのは Content Collections の持ち方で、選択肢は2つあった。
| A: 言語別に collection を分割 | B: 単一 collection + lang フィルタ |
|
|---|---|---|
| クエリ |
getCollection('blog_ja') を言語別に呼ぶ |
統一クエリで書ける |
| CMS との対応 | CMS collection と 1:1 | CMS の UI 上で言語を間違えうる |
| 事故りやすさ | 低い |
lang のフィルタ漏れが silent に起きうる |
採用は A(blog_ja / blog_en / notes_ja / notes_en)。決め手は「編集者と AI エージェントが迷わない」ことだ。Sveltia の collection と Astro の collection が 1:1 で対応し、lang は CMS 上 hidden で固定される。フィルタ漏れという silent failure の芽も消える。
日英の紐付けは frontmatter の translationKey で行う。
// content.config.ts(抜粋・簡略化)
const blogSchema = z.object({
title: z.string(),
pubDate: z.coerce.date(),
lang: z.enum(['ja', 'en']),
slug: z.string(),
translationKey: z.string(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
syndication: z.record(z.string()).default({}),
});
const blog_ja = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog_ja' }),
schema: blogSchema,
});
ポイントは、slug を translationKey から独立させたことだ。日英で slug が違ってよいので、英語版は直訳に縛られず、タイトルも構成も英語圏の読者向けに作り直せる。作っているのは「翻訳」ではなく「同じ主題の、対になる記事」だ。
translationKey は手入力なので、打ち間違いは「言語切替リンクが出ない」という silent failure になる。これはビルド時チェックを二段階にして潰した。
- 片側の言語にしか存在しない translationKey → 警告(「翻訳未対応」は正常なので許容する)
- 同一言語内で重複する translationKey → エラー(確実に入力ミス)
「翻訳がまだ無い」と「打ち間違えた」は機械的に区別できない。だからこそ、正常でありうる方を警告に、ありえない方をエラーに振り分ける。
draft は全経路から除外する — 誤公開が「外に波及する」構成だから
draft: true の記事は、次のすべてから除外している。
| 出力経路 | draft の扱い |
|---|---|
| トップ / 一覧 / 詳細 / タグ | 除外 |
| RSS / JSON Feed | 除外 |
| sitemap | 除外 |
| 言語切替リンク・関連記事 | 除外 |
| 外部配信(Worker) | 除外 |
単体の静的ブログなら、誤公開しても直して再デプロイすれば済む。しかしこの構成では、公開が Qiita / DEV.to への自動配信を引き起こす。自サイトから消しても、外に出た下書きは戻らない。だから CMS で作る新規記事は draft: true が既定で、除外は HTML だけでなく feed・sitemap・配信まで、すべての出力経路で徹底する。配信の自動化と draft の規律は、セットでしか成立しない。
Sveltia CMS は OAuth プロキシなしの PAT 最小構成で
Git ベース CMS を GitHub と組み合わせる場合、通常は OAuth 認証用のプロキシ(Auth Worker)を立てる。このサイトでは立てていない。編集者が自分ひとりで、対象リポジトリも1つだからだ。Sveltia には「Sign in with Token」があり、GitHub の fine-grained PAT を貼るだけでログインできる。
# public/admin/config.yml(抜粋)
backend:
name: github
repo: your-account/your-blog-repo # 実際は対象リポジトリを指定
branch: main
publish_mode: simple
media_folder: public/images/uploads
public_folder: /images/uploads
PAT は次の形まで絞っている。
- 対象をこのリポジトリ1つに限定した fine-grained PAT
- 権限は Contents: Read and write(+ 必須の Metadata: Read)のみ
- 有効期限付き。
publish_mode: simple(main へ直接 commit)なので PR 系の権限も不要
リスクも正直に書いておく。トークンはブラウザの localStorage に保存され、GitHub API へ直送される。自前インフラに秘匿情報を置かずに済む代わりに、「共有端末では使わない」「期限で回す」という運用前提が付く。ひとり運用の個人ブログなら、OAuth プロキシという可動部品を1つ減らす価値のほうが大きい、というのがここでの判断だ。
公開すると外部へ配信される — 配信パイプラインの概観
冒頭の「この記事自体が」の種明かしをしておく。
サイトは /feed.json(JSON Feed)を出力しており、各記事に _meta として配信用の情報 — GitHub 上の sourcePath / gitRef / sha、canonical_url、frontmatter の syndication 設定 — を持たせている。別の Worker が Cron でこれを取得し、新規記事を検出すると、syndication で enabled になっているプラットフォームへ投稿する。自動投稿できるのは Qiita と DEV.to だけの許可リスト制で、それ以外は手動。配信本文は記事ごとに「要約+本文への導線」か「全文」を選べ(frontmatter の syndicationBodyMode で指定)、この記事自体は全文で配信している。canonical はどちらの場合も自ブログに保持する。
ただし正直に補足すると、この記事の公開時点で Qiita への自動投稿は 403(Forbidden)で止まっている。トークンのスコープ不足でもレート制限でもなく、アカウント単位で API 経由の記事作成が拒否されている状態だ(同じアカウントで数日前までは自動投稿できていた)。原因を切り分けたうえで、Qiita だけは当面手動投稿に切り替えている。許可リストの設計はそのままだが、実運用は「DEV.to は全文を自動・Qiita は手動」だ。配信の自動化を解説する記事が、まさに配信先の制限に足を掬われた恰好で、これも含めて正直に書いておく。
二重投稿の防止は D1 の unique 制約による single-flight で行い、すべての配信は dry-run モード(実 HTTP もストレージ書き込みも発生しない)で検証してから本番化した。リトライ・レート制限・partial success の扱いまで含めた設計は、それだけで1本書ける分量なので続編に譲る。
ハマりどころ
compatibility_date に未来の日付は書けない。 main Worker を追加した途端、deploy がランタイム検証で弾かれた。原因は wrangler 設定の compatibility_date に Cloudflare 未リリースの将来日付を書いていたこと。静的アセットのみの構成では通っていたので、Worker を足すまで気づかなかった。
固定ページを単一 collection にすると id が衝突する。 glob loader は frontmatter の slug を id に使うため、/ja/about と /en/about を1つの pages collection に同居させると、同一 slug で id が衝突する。固定ページも記事と同じく言語別 collection(pages_ja / pages_en)に分割して解決した。
CMS の CDN URL はバージョン固定にする。 Sveltia CMS を CDN の latest で読み込むと、CMS 側の更新で管理画面の挙動が予告なく変わりうる。/admin/index.html の script タグはバージョン固定にして、更新は自分のタイミングで行う。
まとめ
- Workers 静的アセットの価値は「ルーティングをコードで持てる静的ホスティング」であること。「Pages が廃止されたから」ではなく、前段の薄い main Worker とデプロイ一本化という積極的な理由で選べる
- 多言語は「対訳」ではなく「対」で設計する。translationKey と slug の分離、警告とエラーを使い分けた二段階の整合性チェックがその実装
- 外部配信を自動化するなら、draft の規律は HTML 以外も含めた全出力経路で徹底する。この2つはセットでしか成立しない
このリポジトリの実装は、仕様書を単一の情報源として AI エージェント主体で行った。その開発スタイルの話と、いま公開している OSS — AI コーディングエージェントを操縦するためのハーネス — については、稿を改めて書く。
英語版(この記事の「対」になる記事)も近く公開する。