英訳がまだ無くても壊れない日英ブログの作り方

公開:

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本目を出す前から「英訳どうしよう」で手が止まりかけた——完全にいつものやつ。

直したのは根性じゃなくて設計のほう。「片方だけでも壊れない」をコード(準備中フォールバック)に逃がした瞬間、英訳は「あとで足せるオプション」に降格して、一気に気が楽になった。ルールを意志力じゃなくコードに持たせると、守らなくても事故らない。 この感覚、今後の自動化でもそのまま効きそうです。

今日はここまで。次は別の自動化ネタにいくか、この記事の英訳を足す回をやるか——出してから決めます

← 記事一覧へ