これまで当サイトは、会社概要や事業内容などの主要ページは日本語・英語・簡体字中国語の3言語に対応していましたが、ブログだけは日本語のみで、英中ページからは日本語記事にそのままリンクしていました。今回、既存記事を含む全記事に英語・簡体字中国語版を用意し、ブログも3言語対応にしました。本記事では、その設計と実装、途中で踏んだ制約を整理します。
設計 — 記事の対応づけはファイル名で揃える
当サイトのブログはAstroのcontent collectionで管理しており、src/content/blog/*.md の1ファイルが1記事、ファイル名がそのままURLになります。翻訳記事を追加するにあたり、最初に決めたのは「日本語版とその翻訳版をどう対応づけるか」でした。
Astroのcontent collectionは、src/content.config.ts に複数定義できます。今回は既存の blog(日本語)に加えて blogEn・blogZh を追加する形にしました。三者とも同じ glob() ローダーを使い、参照するディレクトリだけを src/content/blog・src/content/blog-en・src/content/blog-zh と分けています。
glob() ローダーの挙動は公式ドキュメントに次のように説明されています。
When using the glob() loader with Markdown, MDX, Markdoc, JSON, or TOML files, every content entry id is automatically generated in an URL-friendly format based on the content filename.
(glob()ローダーをMarkdown・MDX・Markdoc・JSON・TOMLファイルで使う場合、各コンテンツエントリーのidは、ファイル名をもとにURLに使える形式で自動生成されます)
つまりファイル名がそのままそのコレクション内でのIDになります。ここから、3つのコレクションで同じファイル名(YYYY-MM-DD-slug.md)を使えば、言語をまたいだ「同じ記事」の対応づけをファイル名の一致だけで表現できる、という設計にしました。/blog/<slug>・/en/blog/<slug>・/zh/blog/<slug> は同じ <slug> を指すので、前後の記事ナビや言語切替、hreflangの実装も、slugをキーにした単純な集合演算(あるコレクションに同じidの記事が存在するか)だけで組み立てられます。
サムネイル画像も、翻訳記事側にはあえて thumbnail フィールドを持たせず、同じ <slug> を持つ日本語版のサムネイルを常に参照する形にしました。画像を複製する手間も、翻訳のたびに差し替える手間もなくなります。
ルーティング — ページごとにgetStaticPathsが要る
Astroは静的サイト出力(SSG)が既定の動作モードです。動的ルートについて、公式ドキュメントは次のように説明しています。
Because all routes must be determined at build time, a dynamic route must export a getStaticPaths() that returns an array of objects with a params property.
(すべてのルートはビルド時に確定している必要があるため、動的ルートはparamsプロパティを持つオブジェクトの配列を返すgetStaticPaths()をエクスポートしなければなりません)
このgetStaticPaths()は、ページファイル(src/pages/**/*.astro)自身に書く必要があり、他のAstroコンポーネントに委譲できません。当サイトのブログには一覧・記事詳細・タグ別一覧の3種類のページがあり、これを3言語ぶん用意すると、getStaticPaths()を持つページファイルだけで9個になります。
これをそのまま9つの独立した実装にすると、マークアップやCSS、JSの二重三重管理になります。そこで、ページファイル側は「対象コレクションを取得し、getStaticPathsでURLを確定させ、データを渡す」役割に絞り、実際の見た目・文言・スクリプトはBlogPostPage.astro・BlogListPage.astro・BlogTagPage.astroという共有コンポーネントに集約しました。会社概要・事業内容などの固定ページで既に使っていた「locale propを受け取るコンポーネント+薄いページラッパー」という構成を、動的ルートにも広げた形です。
src/pages/blog/[slug].astro // getStaticPaths(blogコレクション)+ <BlogPostPage locale="ja" .../>
src/pages/en/blog/[slug].astro // getStaticPaths(blogEnコレクション)+ <BlogPostPage locale="en" .../>
src/pages/zh/blog/[slug].astro // getStaticPaths(blogZhコレクション)+ <BlogPostPage locale="zh" .../>言語切替とhreflangも、この構成のおかげで単純になりました。各ページのgetStaticPathsで、同じslugが他の2コレクションにも存在するかを調べ、存在するロケールの一覧をtranslatedLocalespropとして共通レイアウトに渡すだけです。
ハマりどころ — 脚注の見出しラベルは1つしか持てない
当サイトの記事は、出典を### 参考という手動の見出し+リストか、Markdownの脚注記法([^1])のどちらかで書けるようにしています。脚注記法を使うと、Astroのmarkdown処理系が末尾に自動で見出しを生成する仕組みで、当サイトではastro.config.mjsでその見出しラベルを「参考文献」に固定しています。
markdown: {
processor: satteri({
features: {
gfm: {
footnotes: {
label: '参考文献',
backLabel: '本文の参照箇所{reference}に戻る',
},
},
},
}),
},このmarkdown処理系の設定はサイト全体で1つのインスタンスしか持てず、記事の言語ごとに切り替える仕組みはありません。つまり、英語記事で脚注記法を使っても、見出しは英語にならず「参考文献」のまま出てしまいます。
対処として、翻訳記事では脚注記法を使わず、日本語版で脚注を使っている記事も含めて、翻訳版では手動の見出し+リスト形式(英語版は### References、中国語版は### 参考资料)に変換する運用にしました。もともと「### 参考(または脚注[^n])でリスト化」という2つの書き方を許容していたので、翻訳版では前者に統一する、というだけで済みました。
まとめ
- Astroのcontent collectionは複数定義できる。
glob()ローダーはファイル名からidを自動生成するため、3つのコレクション(blog・blogEn・blogZh)で同じファイル名を使えば、言語をまたいだ記事の対応づけをファイル名の一致だけで表現できる - Astroの静的サイト出力では、動的ルートの
getStaticPaths()は各ページファイルに書く必要があり、他のコンポーネントに委譲できない。そのためページファイルはデータ取得に徹し、見た目・文言・スクリプトはlocalepropを受け取る共有コンポーネントに集約した - markdown処理系の設定(脚注の見出しラベルなど)はサイト全体で1つしか持てない。翻訳記事では脚注記法(
[^n])を使わず、手動の見出し+リスト形式に統一することで回避した - サムネイル画像は複製せず、翻訳記事は常に日本語版の画像を参照する
参考
- Content collections — Astro Docs(2026年7月25日確認)
- Routing — Astro Docs(2026年7月25日確認)