國際化

在 config/i18n.ts 啟用語言,在 locales JSON 管理文案。依產品需求新增語言並完成翻譯後,即可切換,不必另外整合 i18n 框架。

Saavo 範本已內建多語言支援,預設提供英文、簡體中文與繁體中文。不同語言透過 URL 前綴區分,頁面文案則統一從對應的 Locale 檔案讀取。

開發自己的產品時,通常不必再引入其他 i18n 函式庫。只要在設定中啟用需要支援的語言,並補上對應翻譯,就能在頁面與伺服器端程式碼中,直接使用專案提供的 Localized、localized 與 serverLocalized 輸出多語言文案。

既有功能

範本初始化後,即可使用下列國際化功能:

功能預設值說明
啟用語言en、zh-Hans、zh-Hantconfig/i18n.ts
預設語言en不含語言前綴的路由
其他語言前綴/{code}/...例如 /zh-Hans/docs
文案檔案locales/en.json 等en.json 是鍵名與型別的依據
語言切換頁面頂端的語言切換器在已啟用的語言之間切換

頁面語言依 URL 路徑中的語言前綴決定,API 則透過 Accept-Language 請求標頭辨識語言。雖然配接器保留了 Locale Cookie 的設定名稱,目前的語言偵測流程並不會讀取 Cookie。也就是說,切換語言仍以 URL 與請求標頭為準,Cookie 不會影響語言切換。

除了頁面文案,網站名稱、方案名稱等設定內容也可以支援多語言。需要翻譯的設定可以使用 { value, key } 格式,value 是預設內容,key 則指向 Locale 檔案中的對應翻譯。

此外,瀏覽器端需要使用的設定,統一從 @/libs/config/client 讀取。deploy.ts、使用權益設定與各類金鑰等都屬於伺服器端資訊,不應直接匯入前端程式碼,以免被封裝到瀏覽器資源中。

先體驗語言切換

開啟首頁,使用語言切換器在英文與中文之間切換。預設語言的位址不含前綴:

/                  英文首頁
/zh-Hans           簡體中文首頁
/zh-Hant           繁體中文首頁
/docs              英文文件
/zh-Hans/docs      簡體中文文件

語言切換器

範本的註解中也預留了其他語言,包括使用 RTL 的 ar。這些語言預設未啟用,不會出現在語言清單中。

提示

修改品牌名稱時,需要同步更新所有已啟用語言的對應文案。如果只修改英文版,中文頁面仍可能顯示預設的「Saavo Starter」。

設定多語言

語言清單在 config/i18n.ts 設定,文案則統一儲存在 locales/ 目錄,每種語言對應一份獨立的 JSON 檔案。

新增語言

  1. 在 languages 取消註解,或新增 { code, name, direction }。
  2. 新增 locales/{code}.json,鍵名須與 locales/en.json 一致。
  3. 若有文件或部落格,補齊 content/docs/{code}/ 與對應的部落格目錄。
  4. 翻譯法律頁面、電子郵件,以及產品設定中使用的所有 key。
  5. RTL 語言請設定 direction: 'rtl'。

defaultLanguage 決定沒有語言前綴的路由使用哪種預設語言,因此不要任意修改。變更後,原本預設顯示英文的無前綴頁面,會改成新的預設語言,既有英文存取路徑也可能因此改變。

完整步驟請參考:

新增語言

修改品牌與介面文案

如果只需要修改某種語言的文案,直接編輯對應的 Locale JSON 即可。如果要同步調整品牌名稱等共用內容,則需要更新所有已啟用語言的翻譯檔案。例如,將產品名稱從「Saavo Starter」改為「My Product」時,除了 locales/en.json,也要同步修改 locales/zh-Hans.json 與 locales/zh-Hant.json。

設定中的 LocalizedText 可以同時包含 value 與 key。設定 key 後,系統會優先讀取 Locale 檔案的翻譯。如果目前語言缺少對應內容,就會使用 value 中的預設英文文案。

元件中提供給使用者的固定文案,也應統一放在 Locale 檔案管理,不要直接在 JSX 硬編碼中英文長句。例如,需要顯示「購買失敗,請重試」的錯誤提示時,應在 Locale 檔案定義 components.billing.purchaseFailed,不要直接在元件寫死「Purchase failed, please try again.」。

在業務程式碼中使用多語言

業務程式碼只使用專案提供的三個 API,全部從 @/components/server/Localized 匯入,不要直接讀取 Locale JSON。

路徑中的 server 只表示 <Localized /> 輸出純字串,不需要額外互動,頁面的用戶端與伺服器端都能使用,不是只能在伺服器端呼叫。localized() 也能在用戶端元件,以及點擊、提交等回呼中使用。只有 serverLocalized() 必須先取得 Ctx 或 WorkerCtx。

依文案出現的位置選擇 API:

位置API
JSX 中的可見文字<Localized />
aria-label、placeholder、alt、Toast、函式回傳值localized()
頁面 SEO、API 回應、電子郵件等已有請求執行脈絡的地方serverLocalized()

JSX 文字:

import { Localized } from '@/components/server/Localized';

<h2>
    <Localized
        id="components.profileSettings.title"
        default="Profile Settings"
    />
</h2>

屬性、Toast 等必須使用字串的位置:

import { localized } from '@/components/server/Localized';
import { toast } from 'react-toastify';

<img
    alt={localized({
        id: 'components.profileSettings.avatar.alt',
        default: 'User avatar',
    })}
/>

<input
    placeholder={localized({
        id: 'components.profileSettings.fullName.placeholder',
        default: 'Enter your full name',
    })}
/>

toast.error(localized({
    id: 'components.billing.purchaseFailed',
    default: 'Purchase failed, please refresh the page and try again.',
}));

伺服器端頁面、API 與電子郵件已持有 c 時:

import { serverLocalized } from '@/components/server/Localized';

const pageTitle = serverLocalized({
    c,
    id: 'pages.home.title',
    default: `${siteName} - A complete SaaS starter for Cloudflare`,
    values: { siteName },
});

id 必須是 locales/en.json 中已有的鍵名,default 則是缺少翻譯時使用的英文備用文案。電子郵件範本也使用 serverLocalized,例如 email.welcome.text。

需要插入姓名、數量等動態內容時,請在 Locale 中保留完整句子,使用 {name} 這類占位符,並在呼叫時透過 values 傳入。不要將一句話拆成多段翻譯後再組合。

localized({
    id: 'components.account.products.cadence.everyMonths',
    default: 'Every {count} months',
    values: { count },
});

日期、時間與金額不要直接寫入翻譯字串排版。先依目前語言格式化,再將結果放入 values。伺服器端使用 c.var.getLocale(),用戶端元件通常由頁面傳入 locale,金額則使用 formatMinorAmount。

import { formatMinorAmount } from '@/libs/utils/currency';

const date = new Intl.DateTimeFormat(locale, {
    year: 'numeric',
    month: 'short',
    day: 'numeric',
}).format(paidAt);

const amount = formatMinorAmount(totalMinor, currency, locale);

底層雖然使用 @intlify/core,業務程式碼不要直接呼叫它的 numberFormat / datetimeFormat,專案也沒有為這兩種格式另外設定對照表。具名插值使用上述 values,數字與日期則使用 Intl。

翻譯文案中的直線符號需要跳脫

目前 Saavo 的國際化功能使用 @intlify/core 處理翻譯文字。這個函式庫除了提供一般變數插值,也支援複數等訊息語法,其中 | 有特殊意義。

翻譯文字中未跳脫的 |,會被視為複數分支分隔符號,用來依數量選擇不同文案。例如:

{
    "itemCount": "One item | {count} items"
}

傳入不同數量時,會自動選擇對應內容:

// 輸出 One item
localized({
    id: 'itemCount',
    values: { count: 1 },
});

// 輸出 5 items
localized({
    id: 'itemCount',
    values: { count: 5 },
});

常見寫法有兩種:

文案形式分支意義
單數文案 | 複數文案數量為 1 時使用前者,其他情況使用後者
零個文案 | 單數文案 | 複數文案分別處理 0、1 與其他數量

例如:

{
    "itemCount": "No items | One item | {count} items"
}

因此,如果希望頁面實際顯示 | 字元,就不能直接寫在翻譯文字中。

一般文案較少遇到這個問題,但標題很容易出現,因為許多人習慣使用 | 分隔網站名稱與頁面標題。例如:

{
    "pages": {
        "home": {
            "title": "{siteName} | Product Overview"
        }
    }
}

這裡的 | 會被翻譯引擎辨識為複數分支分隔符號,最後可能只顯示其中一部分內容。

如果需要顯示實際的 |,應改為:

{
    "pages": {
        "home": {
            "title": "{siteName} {'|'} Product Overview"
        }
    }
}

這樣 | 就會以一般字元輸出,不再參與複數語法解析。

修改頁面標題時很容易遇到這個問題,使用 | 時請特別注意。

上線檢查

語言內容會直接呈現給使用者,上線前建議至少確認:

  • 所有已啟用的語言都能透過語言切換器存取,且 URL 前綴正確。
  • locales/en.json、zh-Hans.json、zh-Hant.json 中的品牌名稱與重要頁面文案,已換成自己的產品內容。
  • 新增語言的 JSON 鍵名與英文一致,沒有缺少 key。
  • 該語言有文件與部落格內容,或你可以接受缺少頁面。
  • 法律頁面、電子郵件主旨與內文已翻譯。
  • 未修改 defaultLanguage,除非已確認無前綴路由的新意義。

建議在每種啟用的語言下,都開啟首頁、登入頁、價格頁與一篇文件,不要只檢查英文。

常見問題

接下來

依接下來要開發的功能,可以繼續閱讀:

  • 想新增語言 → 新增語言
  • 想翻譯元件中硬編碼的文案 → 使用儲存庫的 i18n skill
  • 想撰寫多語言文件 → 文件系統
  • 想撰寫多語言部落格 → 部落格

大多數產品完成本章後,只需要維護 Locale 檔案與內容目錄。

新頁面從一開始就使用 Localized 輸出文案,避免事後再抽出字串。