英訳がまだ無くても壊れない日英ブログの作り方
1本目の最後で「次はこの日英バイリンガル構成を Claude Code でどう組んだかを書く」と予告した。その回収です。
…で、いきなり白状すると——この記事、英訳をまだ書いていません。日本語だけで公開しています。なのにサイトは壊れないし、言語トグルも事故らない。なぜそれが成立するのか、が今日いちばんおいしいところ。
要件:ペアが理想、でも片方だけでも壊れないこと
やりたかったのはシンプルで、日本語を起点に英語へ展開し、記事ごとに言語トグルで行き来できること。
ただ、「全記事を必ず日英ペアで」を厳格な公開条件にすると、英訳が公開のブロッカーになる。自分の場合これは致命的で、「英訳どうしよう」で手が止まる未来がはっきり見える(いつものエタるパターンの入り口)。
なので要件をこう決めました。
日英ペアが理想。ただし片方だけでも公開できて、サイトが壊れないこと。
ここで「自動で英訳する機能を組む」手もありました。でも今回はあえて付けない。翻訳を自動化するより、翻訳が無くても困らない状態を先に作る。そっちのほうが自分には効く、と判断しました。
ポイントは、この「寝かせない」を気合いじゃなくコードに持たせること。
記事の置き方:<locale>/<slug>
ディレクトリ構成はこれだけ。
src/content/blog/
ja/
making-this-blog-a-test-subject.md
bilingual-astro-setup.md ← この記事
en/
making-this-blog-a-test-subject.md
(bilingual-astro-setup.md はまだ無い)
Astro のコンテンツコレクションで読み込むと、各記事の id が ja/bilingual-astro-setup のように <locale>/<slug> になります。スキーマはこれだけ(抜粋)。
// src/content.config.ts
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string().default(''),
pubDate: z.coerce.date(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
id の先頭セグメントがロケール、残りがスラッグ。取り出すのは小さなヘルパー2つだけ。
// src/utils/posts.ts(要点・型は省略)
export function localeOf(post) {
return post.id.split('/')[0]; // "ja"
}
export function slugOf(post) {
return post.id.split('/').slice(1).join('/'); // "bilingual-astro-setup"
}
スラッグを揃えると言語トグルが自動でペアになる
ルーティングは Astro の i18n に任せています。
// astro.config.mjs
i18n: {
defaultLocale: 'ja',
locales: ['ja', 'en'],
routing: { prefixDefaultLocale: false }, // 日本語は `/`、英語は `/en/`
}
日本語記事は /blog/<slug>/、英語記事は /en/blog/<slug>/ に出ます。スラッグが同じだから、言語トグルは「今のパスの先頭に /en を足し引きするだけ」で相手にたどり着ける。
// src/utils/i18n.ts(要点)
localizedPath('/blog/bilingual-astro-setup/', 'en');
// → '/en/blog/bilingual-astro-setup/'
つまり「スラッグを英小文字・ハイフン区切りで揃える」というルール1個だけで、ペアリングが自動になる。記事側に「相手の記事はこれ」みたいなメタデータを書く必要がない。手で管理する情報は少ないほど壊れません。
「寝かせない」をコードに持たせる:準備中フォールバック
ここが今日の主役。スラッグを足し引きするだけだと、英訳が存在しない記事で英語トグルを押すと 404 になります。それだと「英訳できるまで日本語版も出せない」に逆戻り。
そこで言語トグル側に保険を入れました。記事ページのときだけ、相手言語に同じスラッグの記事が実在するか確認し、無ければリンクにせず「準備中」表示にする。
// src/components/LanguagePicker.astro(要点)
const blogMatch = pathname.match(/^\/(?:en\/)?blog\/(.+?)\/?$/);
const currentSlug = blogMatch?.[1];
let availableLocales = null;
if (currentSlug) {
const [ja, en] = await Promise.all([getPosts('ja'), getPosts('en')]);
availableLocales = new Set();
if (ja.some((p) => slugOf(p) === currentSlug)) availableLocales.add('ja');
if (en.some((p) => slugOf(p) === currentSlug)) availableLocales.add('en');
}
// 相手が無ければ <a> ではなく、薄い「準備中」ラベルにする
これで日本語だけ先に出してもサイトは壊れない。あとで英訳を en/<同じスラッグ>.md に足すと、トグルは何もいじらず勝手にペアに戻る。記事側のフロントマターも一切触らない。
そして——この記事がまさにその状態です。いまこのページの英語トグルは「準備中」になっているはず。仕組みを説明する記事自体が、その仕組みのデモになっている。
公開とドラフトの線引き
本番ビルドでは下書きを除外しています。getPosts が本番(PROD)のときだけ draft !== true を通す。
// src/utils/posts.ts
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.PROD ? data.draft !== true : true,
);
そして npm run build が型・コンテンツ検証込みのゲート。これが通れば「少なくとも形は壊れていない」が毎回機械的に保証される。中身の良し悪しは別問題だけど、事故ってはいない、を自分の目視じゃなくビルドに見てもらえるのは大きい。
正直なところ(つまずき)
最初、自分に「毎回ちゃんと英訳してからペアで出す」を課そうとしました。で、それが普通に重かった。1本目を出す前から「英訳どうしよう」で手が止まりかけた——完全にいつものやつ。
直したのは根性じゃなくて設計のほう。「片方だけでも壊れない」をコード(準備中フォールバック)に逃がした瞬間、英訳は「あとで足せるオプション」に降格して、一気に気が楽になった。ルールを意志力じゃなくコードに持たせると、守らなくても事故らない。 この感覚、今後の自動化でもそのまま効きそうです。
次
- 英訳を後追いで足す運用を、まさにこの記事で一度通してみる。
- 英訳の自動生成は、あえてやらないままにする。「翻訳が無くても壊れない」を作った時点で、急いで機能化する理由が消えた。英訳は手で、出したくなったときだけ足す。
今日はここまで。次は別の自動化ネタにいくか、この記事の英訳を足す回をやるか——出してから決めます。