[CODE]

Astro i18n 雙語網站踩坑紀錄

Astro 內建的 i18n 功能看起來很簡單,直到你真的開始用。這篇記錄路由設定、各語言的 Content Collections、語言自動偵測,還有幾個讓我卡了一下的邊界情況。

2 min read
astro i18n typescript web

這個網站加上另外幾個專案都需要同時支援繁體中文和英文。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。

以個人網站的規模來說,這個基礎已經夠用了。