這個網站加上另外幾個專案都需要同時支援繁體中文和英文。Astro 現在有內建的 i18n,我以為很快就能搞定。大部分確實快,但有幾個地方讓我卡了半天才弄清楚。
這篇是實際做了什麼、踩了哪些坑的紀錄。
路由設定
Config 本身很直觀:
// astro.config.mjs
export default defineConfig({
trailingSlash: 'ignore',
i18n: {
defaultLocale: 'en',
locales: ['en', 'zh-TW'],
routing: {
prefixDefaultLocale: false,
},
},
})
prefixDefaultLocale: false 讓英文版住在 /blog 而不是 /en/blog,中文版則是 /zh-TW/blog。對個人作品集來說這樣比較乾淨,預設語言的 URL 不需要多一層前綴。
trailingSlash: 'ignore' 這行花了我一點時間才加上去。沒有它的話,Astro 的 trailing slash redirect 有時候會跟 i18n 路由層撞在一起,在開發環境產生 redirect loop。設成 'ignore' 就直接繞過這個問題了。
用重複頁面而非魔法路由
我選擇把每個語言的頁面分開放,而不是試圖讓同一個檔案處理兩個 locale:
src/pages/
blog/
index.astro ← 英文
[slug].astro
zh-TW/
blog/
index.astro ← 中文
[slug].astro
檔案多了一倍,但好處是每個頁面一開始就知道自己的 locale 是什麼,直接呼叫對應的 query 就行。不用在 render 時從 URL 解析 locale、也不用條件判斷,思路很清晰。
Content Collections 依 Locale 分資料夾
實際的 MDX 內容放在各語言的子資料夾底下:
src/content/blog/
en/
hello-world.mdx
zh-TW/
hello-world.mdx ← 相同 slug,不同語言
跨語言的 slug 對應是手動維護的——同一篇文章用同樣的檔名。Astro 的 content collections 本身沒有「這兩個檔案互為翻譯版本」的概念。如果你想做語言切換按鈕(點了切換到同一篇的另一個語言版本),這部分要自己實作。
過濾 locale 的邏輯放在 repository 層。Astro content collection 裡每個 entry 的 ID 長這樣:en/hello-world.mdx,切開就能拿到 locale 和 slug:
async getBySlug(slug: string, lang: Locale) {
const entries = await getCollection('blog')
const entry = entries.find((e) => {
const parts = e.id.replace(/\.mdx?$/, '').split('/')
return parts[0] === lang && parts.slice(1).join('/') === slug
})
if (!entry) return undefined
return toArticle(entry)
}
每次 slug 頁面 render 都會跑一遍這個邏輯,效率不是最好,但 static site 在 build time 跑,所以根本無所謂。
語言自動偵測
自動偵測只在根路徑 / 觸發。做法是在頁面 <head> 裡塞一段 inline script:
<Fragment slot="head">
<script is:inline>
if (window.location.pathname === '/') {
const lang = navigator.language || 'en'
if (lang.startsWith('zh')) {
window.location.replace('/zh-TW/')
}
}
</script>
</Fragment>
故意做得很陽春。在 client 端檢查 navigator.language,用 replace() 跳轉(不是 href =,那樣會留下 history record)。沒有 localStorage、沒有 cookie,如果你直接進 /blog 就是英文,想看中文就點語言切換按鈕。
翻譯字串的模式
所有 UI 字串都集中在一個 ui.ts:
export const ui = {
'en': {
'nav.blog': 'Engineering',
'nav.cast': 'Fishing',
'hero.cta.blog': 'Engineering Articles',
// ...
},
'zh-TW': {
'nav.blog': '技術',
'nav.cast': '釣魚',
'hero.cta.blog': '技術文章',
// ...
},
} as const
type TranslationKeys = keyof typeof ui['en']
export function t(locale: UILocale, key: TranslationKeys): string {
return (ui[locale]?.[key] ?? ui['en'][key]) as string
}
as const 讓 TypeScript 有足夠的型別資訊在 compile time 抓到遺漏的 key。如果你在 'en' 加了一個 key 但忘了加 'zh-TW',t() 會 fallback 到英文——對預設語言是英文的網站來說這樣很合理。
這個模式在 5 個以上的頁面和一堆 component 裡用下來都沒問題。
Astro i18n 沒幫你做的事
幾個我得自己補的地方:
沒有 404 fallback 到預設語言。 如果某篇中文文章還沒寫,/zh-TW/blog/some-slug 就是 404。Astro 不會自動 serve 英文版。要這個行為得自己寫。
沒有翻譯版本的關聯。 Content collection 不知道哪個 MDX 是哪個的中文版。靠檔名一致來對應,這是唯一的 out-of-the-box 工具。
getLocalePath 要自己寫。 每個會產生連結的 component 都要知道當前 locale 並加對應的前綴。我把它抽成 ui.ts 裡的一個小 helper:
export function getLocalePath(locale: UILocale, path: string): string {
if (locale === defaultLocale) return path
return `/zh-TW${path}`
}
之後所有 <a href> 都過這個 getLocalePath(locale, '/blog'),不直接寫死 /zh-TW/blog。少了這個,locale 相關的字串會散落在各個 component 裡面,很難維護。
值得用內建 i18n 嗎?
我覺得值得。以前 Astro 沒有內建 i18n 的時候,路由邏輯得全部自己來。現在光是 config 宣告 locale 和路由策略,URL 結構就處理好了。缺口——fallback 頁面、語言版本之間的關聯、路徑生成——是真實存在的,但填起來也沒幾行 code。
以個人網站的規模來說,這個基礎已經夠用了。