チュートリアル
Astro を2言語にする:プラグイン無し、content collections だけ
· 読了まで約12分
目次
このブログは先ごろ2言語になりました。すべての記事が英語では /blog/<slug>/、日本語では /ja/blog/<slug>/ に存在し、hreflang の注釈、言語切替、ロケール別の RSS、ロケール別の OG 画像も揃っています。同じことを計画している人に効きそうなのはここです。i18n プラグインもライブラリも使っていません。使ったのは content collections と、レストパラメータのルート1本と、自分で全部読める200行ほどの素の TypeScript だけです。
プラグインが元を取るのは、ロケールが何十もある場合や、実行時に言語ネゴシエーションが要る場合です。よくあるのはもっと地味な多言語でしょう。静的サイトで、言語は2〜3、既存の英語 URL は動かせない。この条件なら Astro 自身のプリミティブで足ります。プリミティブに留まれば、翻訳レイヤーそのものが存在しません。意図しない言語で描画されたときも、デバッグ対象は自分のコードだけです。以下が設計の全部です。
設計を決めた3つの制約
どれもよくある要件です。
- 既存の URL は1つも変えない。このサイトがそれまでに公開した URL はすべて接頭辞無しの英語
/blog/<slug>/で、検索エンジンにはとっくにインデックスされていました。英語を/en/の下に移すなら全部リダイレクトすることになり、コストだけかかって見返りがありません。だから既定ロケールは接頭辞無しのまま、接頭辞が付くのは翻訳だけ(/ja/blog/<slug>/)にしました。 - 翻訳の対応づけに帳簿を作らない。フロントマターの
translationKeyも、古びていく中央のマッピングファイルも要りません。何が何の翻訳なのかは、ファイルの置き方そのものに語らせます。 - 翻訳漏れは黙って通さない。未訳の UI 文字列が日本語ページでこっそり英語に落ちるのは避けたい。欲しいのは型エラーです。
1コレクション、ロケール別フォルダ
コンテンツは1つの blog コレクションに置き、その下をロケールで分けます。
src/content/blog/
├── en/
│ ├── deploy-astro-to-cloudflare-workers.mdx
│ └── astro-og-images-satori.mdx
└── ja/
├── deploy-astro-to-cloudflare-workers.mdx
└── astro-og-images-satori.mdx
glob ローダーの根が src/content/blog なので、各エントリの id は <locale>/<slug> の形で届きます。ロケールの情報はデータ側に含まれているので、フロントマターに書く必要はありません。分解はヘルパー1つです。
// src/posts.ts
export function splitId(id: string): { locale: Locale; slug: string } {
const [first, ...rest] = id.split('/');
return isLocale(first) && rest.length > 0
? { locale: first, slug: rest.join('/') }
: { locale: DEFAULT_LOCALE, slug: id };
}
export async function getPosts(locale: Locale): Promise<Post[]> {
const posts = await getCollection('blog', publishedOnly);
return posts
.filter((post) => splitId(post.id).locale === locale)
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
}
そしてこの置き方こそが対応づけの仕組みです。ロケールフォルダをまたいで同じ slug を持つ2本は、互いの翻訳。上の2つの deploy-astro-to-cloudflare-workers.mdx を結びつけているのはファイル名だけで、hreflang も言語切替もそこから組み立てています。日本語だけの記事は en/ に無い slug を使えばよく、あとは自動的に縮退します。hreflang の alternate は消え、言語切替はもう一方の言語のトップページに落ちます。
ルートファイル1枚で両言語
URL は [...locale] のレストパラメータから来るので、ルートファイル1本がすべての言語を描画します。仕掛けは、param が undefined のレストセグメントを Astro が落とすことです。接頭辞無しの既定ロケールには、この挙動がそのまま使えます。
// src/pages/[...locale]/blog/[slug].astro
export async function getStaticPaths() {
const paths = [];
for (const locale of await activeLocales()) {
for (const post of await getPosts(locale)) {
paths.push({
params: {
locale: locale === DEFAULT_LOCALE ? undefined : locale,
slug: splitId(post.id).slug,
},
// 実物はこの slug が存在するロケール一覧も渡している。
// 後述の hreflang はそれを材料にしている。
props: { post },
});
}
}
return paths;
}
英語のページは /blog/<slug>/、日本語は /ja/blog/<slug>/ に、同じテンプレートから生成されます。既存の英語 URL は1つも動いていません。冒頭の制約1は、このレストパラメータ1つで片付きました。一覧・タグページ・RSS エンドポイントも同じパターンで、ルーティング層はこの発想を数回使い回しただけです。
Astro には組み込みの i18n ルーティング設定(i18n.locales、prefixDefaultLocale など)もあり、悪いものではありません。ただそれが主に面倒を見るのはルーティングで、content collections を使っている限り上のルーティングはすでに些細です。設定を入れてもこの記事のコードが減ることはなかったので、見送りました。サーバー側で自動リダイレクトや言語ネゴシエーションが要るなら、そこは検討に値します。
hreflang は slug のペアから作る
2つのページが同じ記事の別言語版であることを検索エンジンに伝えないと、無関係なページとして(悪くすると競合として)扱われます。各ページの head には、その slug が存在するロケールを全部と、英語版を指す x-default を並べます。
<link rel="alternate" hreflang="en" href="https://astro.p4ni.com/blog/astro-og-images-satori/" />
<link rel="alternate" hreflang="ja" href="https://astro.p4ni.com/ja/blog/astro-og-images-satori/" />
<link rel="alternate" hreflang="x-default" href="https://astro.p4ni.com/blog/astro-og-images-satori/" />
hreflang は壊れても黙っているので、効いてくる規則を押さえておきます。
- 注釈は相互でなければならない。英語ページが日本語を挙げ、日本語ページが英語を挙げる。片方向の注釈は無視されます。
- 各ページは自分自身も挙げる。alternate だけではありません。
- URL は絶対で、canonical と完全に一致させる。末尾スラッシュもホストも同じ形に。
- 存在する alternate だけを出す。これは slug のペアリングから自動的に決まります。hreflang のリンク集合が、その slug を含むロケールフォルダの集合そのものだからです。
同じペアリングは目に見える言語切替も動かしています。hreflang が厳密さを求められ、未訳の記事では黙って消えるのに対して、切替のほうは役に立ち続けます。指す先の翻訳が無ければ、404 ではなくもう一方の言語のトップページへリンクします。どちらの挙動も同じ slug グルーピング関数を呼んでいるので、両者がずれることはありません。
型付きの UI 辞書
テンプレートには翻訳された枠組みが要ります。ナビゲーション、日付、フッター、「更新」ラベル。全部を1ファイルに置き、完全性は型システムに守らせます。
// src/i18n.ts
const en = {
'nav.articles': 'Articles',
'post.updated': 'Updated',
// …サイト上のすべての UI 文字列
} as const;
export type UiKey = keyof typeof en;
// en のキーで型付けする。1つ忘れれば型エラーになる。
const ja: Record<UiKey, string> = {
'nav.articles': '記事',
'post.updated': '更新',
// …
};
const ui = { en, ja };
export function useTranslations(locale: Locale) {
return (key: UiKey, vars?: Record<string, string | number>) =>
interpolate(ui[locale][key], vars);
}
冒頭の制約3、つまり翻訳漏れを黙って通さないという要件は、Record<UiKey, string> の1行にすべて集約されています。en に文字列を足せば、ja の抜けはその場で型エラーになる。エディタがその場で赤線を引きますし、CI で astro check を回していればそこでも落ちます(astro build だけでは型検査が走らないので、回す価値はあります)。i18n ライブラリを売り込む決め手はたいていこの要件ですが、ここでは型注釈1つです。
コンポーネント側は文言を直書きせず、URL から読んだロケールを添えて t('nav.articles') を呼びます。文字列はすべて辞書に、例外なし。この規律を守っている限り、2つ目の言語は長期的に完全なまま保てます。完全性を人のレビューに頼らず型検査に任せているので、そこは時間が経っても劣化しません。
空のロケールを世に出さない
バックログを翻訳していた数週間、日本語セクションは本番にまったく存在していませんでした。クローラーが見つける空の /ja/blog/ 一覧も、空洞のセクションを指す言語切替もありません。ロケールのルートを、公開済みの記事が実際にある言語にしか生成しないようにしてあるからです。
export async function activeLocales(): Promise<Locale[]> {
const posts = await getCollection('blog', publishedOnly);
const withPosts = new Set(posts.map((post) => splitId(post.id).locale));
return LOCALES.filter((l) => l === DEFAULT_LOCALE || withPosts.has(l));
}
ロケールを起動する操作は、最初の日本語記事をコミットする1つだけです。ルートも切替も hreflang の注釈も、全部この関数を呼んでいます。予約公開と組み合わせれば、日本語版の立ち上げ作業はこうなります。訳して、pubDate を決めて、あとは日次ビルドが一斉に公開してくれる。
残りの細部
- ロケール別の RSS を
/rss.xmlと/ja/rss.xmlに、それぞれ正しい<language>タグ付きで。ページと同じレストパラメータのパターンです。 langとog:localeは小さな対応表(en/ja、en_US/ja_JP)から引き、URL の先頭セグメントで決めます。- llms.txt も2言語化しました。各ロケールのインデックスが、そのロケールのページをその言語で説明します。生成元は同じ
getPostsの呼び出しです。 - sitemap は両ロケールを自動で含みます。どれも静的パスにすぎないからです。
私が売っているテーマ Almanac は当面は英語のみです。ただ、買った人から要望が出たら組み込むのはこのパターンだと思っています。依存を1つも増やさずに済むからです。顧客が自分で保守するコードベースでは、依存の少なさがそのまま効いてきます。
200行と聞くと npm install より手間に見えますが、その1行1行は変哲もない Astro です。すでに使っている getStaticPaths と getCollection にすぎません。言語切替がおかしなものを表示したとき、デバッグ対象になるのはプラグインのルーティングミドルウェアではなく、自分で書いた10行の関数です。2ロケールなら、私は迷わず同じ判断をもう一度します。ロケールが5つ6つに増えたときに最初に軋むのは、おそらく手で書いている UI 辞書のほうで、ルーティングではありません。そのときはじめて、プラグインの出番を考えます。