[CODE]

Astro i18n:那兩個字我沒打,整個下午就沒了

Code & Cast 的 CI pipeline 要同時發布兩個語言版本的文章。這篇記錄 Astro i18n 層裡每個迫使你自己動手補的缺口:trailing slash 死結、content collection 不認識翻譯關係、entry ID 小寫化、以及用 TypeScript 在 build time 攔截漏掉的翻譯。

3 min read AI 生成
astro i18n typescript web

Code & Cast 的兩個 section 都靠 CI pipeline 供稿:CODE 那邊掃 GitHub commit 自動生成技術文章,CAST 那邊監控 Instagram 和 Medium 自動生成釣魚紀錄,分別寫進 src/content/blog/en/src/content/blog/zh-TW/。讀者不重疊——台灣工程師讀中文版,英語圈開發者讀英文版,兩群人幾乎不交叉。

這個架構讓 i18n 成為前提,不是加分項。Routing layer 壞掉,CI 寫的文章哪個語言的讀者都看不到。

Astro 的 i18n routing 文件完整,範例能跑。我還是卡了半個下午。Chrome network tab 全是紅的:/zh-TW/blog 無限循環 redirect。翻過 middleware、翻過 redirect 設定,找到的原因是一個根本還沒碰過的選項:

trailingSlash: 'ignore'

Astro 在 dev 環境會把 /zh-TW/blog redirect 到 /zh-TW/blog/。這個行為剛好撞上 i18n routing layer,兩邊互相等對方先處理,死結。'ignore' 讓兩層各走各的路,不再嘗試補 slash,循環消失。完整 config:

// astro.config.mjs
export default defineConfig({
  trailingSlash: 'ignore',
  i18n: {
    defaultLocale: 'en',
    locales: ['en', 'zh-TW'],
    routing: {
      prefixDefaultLocale: false,
    },
  },
})

prefixDefaultLocale: false 讓英文維持 /blog 而不是 /en/blog。預設語言加前綴,只是讓每條 URL 變長,沒有帶進任何新資訊。

13 個 page 檔,換掉 render time 的 branch

每個語言各一套 page 檔:

src/pages/
  index.astro          ← 英文
  blog/
    index.astro
    [slug].astro
  cast/
    index.astro
    [slug].astro
  about.astro
  404.astro
  zh-TW/
    index.astro        ← 中文
    blog/
      index.astro
      [slug].astro
    cast/
      index.astro
      [slug].astro
    about.astro

英文 7 個、中文 6 個(404 只有英文版),共 13 個 page 檔。替代方案是 7 個,每個在 render time 讀 Astro.currentLocale 然後走不同分支。

每個 page 檔一打開就知道自己的語言,直接呼叫對應的 query,不讀 Astro.currentLocale,不跑條件判斷。兩個語言各六頁,多出六個檔案,但這六個是純機械複製:照 pattern、改語言常數,沒有實質邏輯差異。什麼都沒壞的時候,13 個檔案和 7 個感覺沒差。出問題要 debug 的時候,翻一個明確的檔案清單,比追一棵 branch tree 快得多。

Astro 的 entry ID 會把資料夾名稱轉小寫

src/content/blog/
  en/
    hello-world.mdx    → ID: en/hello-world.mdx
  zh-TW/
    hello-world.mdx    → ID: zh-tw/hello-world.mdx  ← 小寫了

Astro 在生成 entry ID 時把資料夾名稱統一轉小寫。zh-TW/ 放進去,出來是 zh-tw/。Repository layer 在比對之前必須先正規化:

async getBySlug(slug: string, lang: Locale) {
  const entries = await getCollection('blog')
  const entry = entries.find((e) => {
    const parts = e.id.replace(/\.mdx?$/, '').split('/')
    const entryLang = parts[0] === 'zh-tw' ? 'zh-TW' : parts[0]
    return entryLang === lang && parts.slice(1).join('/') === slug
  })
  if (!entry) return undefined
  return toArticle(entry)
}

靜態網站,build time 跑一次,沒有 runtime 代價。少了 parts[0] === 'zh-tw' ? 'zh-TW' : parts[0] 這行,lang === 'zh-TW' 比對 'zh-tw' 永遠是 false,所有中文文章在 production 全 404。開發環境不會出現這個問題,因為 getCollection 還沒跑過真實的比對。這種 bug 你在本機看不到,部署完才發現。

Content collection 不知道翻譯是什麼

src/content/blog/
  en/hello-world.mdx
  zh-TW/hello-world.mdx

同樣的檔名,不同的語言資料夾。Astro 把這兩個 entry 當成完全無關的資料,框架裡沒有任何「翻譯對應關係」的概念。語言切換按鈕要怎麼從 /blog/hello-world 跳到 /zh-TW/blog/hello-world,是你自己的責任。唯一能依賴的是兩個檔案用同樣的名稱——但這個慣例是你跟自己的約定,Astro 不保證、不檢查,CI 剛發布一篇英文文章但中文版還沒跟上,Astro 不會提醒你。

漏掉的翻譯,要在 build time 就讓流程炸掉

14 個 UI 字串 key,全部放在同一個 ui.ts

export const ui = {
  'en': {
    'nav.blog': 'Engineering',
    'nav.cast': 'Fishing',
    'hero.cta.blog': 'Engineering Articles',
    // 11 個 key
  },
  'zh-TW': {
    'nav.blog': '技術',
    'nav.cast': '釣魚',
    'hero.cta.blog': '技術文章',
    // 11 個 key
  },
} 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。英文加了新字串但忘記補中文,type error,build 失敗,不會進到部署。整個專案跑下來沒有漏過任何翻譯,因為 runtime 根本沒有機會讓你發現有漏。

偵測只在根路徑,是有意設計

語言自動偵測放在 index.astro 的 inline script 裡:

if (window.location.pathname === '/') {
  const override = sessionStorage.getItem('lang-override')
  if (override) {
    sessionStorage.removeItem('lang-override')
  } else {
    const lang = navigator.language || 'en'
    if (lang.startsWith('zh')) {
      window.location.replace('/zh-TW/')
    }
  }
}

replace() 不留 history entry。sessionStoragelang-override key 是在使用者點語言切換時由 Nav.astro 寫進去的。同一個 session 裡,明確切換到英文後回首頁,不會被踢回中文。關掉 tab 再開,key 清掉,下一個 session 重新偵測——這是正確行為:同一個 session 不打擾你的選擇,新的 session 從瀏覽器語言重新判斷。

根路徑以外不跑偵測,因為根路徑以外的每個 URL 已經帶著 locale。用瀏覽器語言設定去覆蓋 URL 裡已經明確的決定,只是在製造邊緣案例,對讀者沒有任何好處。為語言偏好多存一份狀態,就是多養一類永遠要負責的 bug。

Astro 故意不幫你做的兩件事

中文版文章不存在時,/zh-TW/blog/that-slug 直接 404,Astro 不會自動 fallback 到英文版。要 404 還是要 fallback,是只有你能回答的產品決策——中文版不存在代表「這篇只有英文」還是代表「出錯了」?框架沒有立場替你選。

每個生成連結的地方都要帶著 locale,所以這件事收進一個 helper:

export function getLocalePath(locale: UILocale, path: string): string {
  if (locale === defaultLocale) return path
  return `/zh-TW${path}`
}

Nav.astro 裡每個導航連結都走 getLocalePath。少了這個,locale 前綴邏輯散進每個碰到 URL 的 component——分散、不一致,改了一個,不知道其他的有沒有跟上。結構有變動時只需要更新這一個函式。

這兩個缺口不是 Astro 的設計疏失。Astro 的責任是路由:哪些 locale 存在、怎麼對應到 URL。應用層的決策——fallback 策略、URL 建構、翻譯完整性——本來就屬於更上層的 code。

那個 redirect loop 問題其實有文件記錄。只是文件說的根本是另一件事。