0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Astro + Sveltia CMS + Cloudflare Workers で日英バイリンガルブログを自作した — Pages を使わなかった理由

0
Posted at

このブログ(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つ。

  1. 日本語・英語の2言語で発信できる
  2. コンテンツを Git で管理する(履歴が残る、ロックインしない、エディタでもブラウザでも書ける)
  3. 運用コストを限りなくゼロに近づける(サーバレス・無料枠中心)
  4. 公開したら外部プラットフォーム(Qiita / DEV.to)へ自動で配信される
  5. 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-LanguageCache-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 でこれを取得し、新規記事を検出すると、syndicationenabled になっているプラットフォームへ投稿する。自動投稿できるのは 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 コーディングエージェントを操縦するためのハーネス — については、稿を改めて書く。

英語版(この記事の「対」になる記事)も近く公開する。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?