跳轉到

i18n(多語系實作)

對應 implementation flow:程式流程/i18n.md

本檔角色:i18n 的實作層 —— 字典載入 / flatten、I18nService 實作、Angular Pipe、ICU 複數、Intl 日期 / 數字 / 金額格式化、PWA 字典快取、schema 驗證、啟動 / 切換流程。 語系清單 / 品牌副標 / app.* keys 對照 / 社群 PR 設計見 語系清單.md;常數(DEFAULT_LANG / SUPPORTED_LANGS)見 程式參數.md。對應 src/i18n/

1. 字典結構

src/i18n/
├── zh-TW.json   ← 預設 + 完整翻譯
├── zh-CN.json
├── en.json      ← fallback
├── ja.json
└── index.ts     ← I18nService 實作

每檔 JSON 的現行 19 個 root 為 materialsappcommonracegaragesettingserrorssubmitchatthemesvalidationskillsnavsortstatsugcpartspagesseo;這份名錄以完整撰寫源 zh-TW.json 為機器權威, 其他語系必須具備相同 key tree。app.brand 跨語系不翻;validation 可帶 ICU 參數,頁面/SEO 文案分別放在 pagesseo。字典不作 /assets/i18n/ 靜態檔;由動態 import() 形成 lazy bundle chunk,PWA 快取見 §6

2. I18nService

type LangCode = 'zh-TW' | 'zh-CN' | 'en' | 'ja';   // = SUPPORTED_LANGS(system-constants/ui,程式參數/ui.md §15;純客戶端、非 protocol)

interface I18nService {
  getCurrentLang(): LangCode;
  setLang(lang: LangCode): Promise<void>;
  t(key: string, params?: Record<string, unknown>): string;
  getAvailableLangs(): LangCode[];
  detectLang(): LangCode;
}
@Injectable({ providedIn: 'root' })
export class I18nServiceImpl implements I18nService {
  private currentLang: LangCode = 'zh-TW';          // DEFAULT_LANG(語系清單 §1)
  private dict = new Map<string, string>();
  private fallbackDict = new Map<string, string>(); // 'en'

  async setLang(lang: LangCode): Promise<void> {
    this.dict = this.flatten(await this.loadDict(lang));
    this.currentLang = lang;
    this.persist.set(lang);                           // 寫 persist 埠(=settings 顯示語系值)——一經設定即「已選」
    if (lang !== 'en' && this.fallbackDict.size === 0)
      this.fallbackDict = this.flatten(await this.loadDict('en'));
  }

  t(key: string, params?: Record<string, unknown>): string {
    const template = this.dict.get(key) ?? this.fallbackDict.get(key);   // 缺 key → fallback en
    if (!template) { console.warn(`[i18n] missing key: ${key}`); return environment.production ? `[${key}]` : key; }
    return this.interpolate(template, params);
  }

  // 一般插值:{param} 直接替換(複數走 §3 ICU)
  private interpolate(template: string, params?: Record<string, unknown>): string {
    return params ? template.replace(/\{(\w+)\}/g, (_, k) => params[k] !== undefined ? String(params[k]) : `{${k}}`) : template;   // 缺 param 保留 {param} placeholder(除錯友善,語系清單 §13)
  }

  private loadDict(lang: LangCode): Promise<any> {
    // 動態 import=字典隨 bundle lazy chunk(§6);埠注入形——測試直塞字典物件、
    // server/prerender 同路(無獨立靜態檔可 fetch)
    switch (lang) { case 'zh-TW': return import('./zh-TW.json').then(m => m.default); /* …四語系同形 */ }
  }

  // 巢狀 JSON → 扁平 key("race.lap")
  private flatten(obj: any, prefix = ''): Map<string, string> {
    const out = new Map<string, string>();
    for (const [k, v] of Object.entries(obj)) {
      const fk = prefix ? `${prefix}.${k}` : k;
      if (typeof v === 'string') out.set(fk, v);
      else if (v && typeof v === 'object') for (const [ik, iv] of this.flatten(v, fk)) out.set(ik, iv);
    }
    return out;
  }

  detectLang(): LangCode {
    const urlLang = location.pathname.match(/^\/(zh-TW|zh-CN|en|ja)(\/|$)/)?.[1] as LangCode | undefined;
    if (urlLang) return urlLang;                       // SEO 登陸:URL /<lang>/ 前綴優先(程式架構.md §13 前綴變體)
    const stored = this.persist.get();                 // 讀 persist 埠(=settings 顯示語系值):未選=null(預設未設定)
    if (stored && this.getAvailableLangs().includes(stored)) return stored;   // 已選過才命中;未選 → 續走 navigator
    const b = navigator.language;
    if (b.startsWith('zh-Hant') || ['zh-TW', 'zh-HK', 'zh-MO'].some(t => b.startsWith(t))) return 'zh-TW';   // 繁體圈含港澳
    if (b.startsWith('zh')) return 'zh-CN';
    if (b.startsWith('ja')) return 'ja';
    return 'en';
  }

  getCurrentLang() { return this.currentLang; }
  getAvailableLangs(): LangCode[] { return ['zh-TW', 'zh-CN', 'en', 'ja']; }
}

語系持久化 =settings 顯示語系值persist 埠背後即 settings 的 DisplaySettings.language 欄位,非獨立 localStorage 鍵):其預設為 null= 尚未選擇,故首訪者 detectLang 的 persist 分支落空、續往 navigator.language 偵測;使用者(或偵測結果經 setLang)一旦定案即寫回該值持久化。偵測順序恆為 URL 前綴 → persist(settings 顯示語系)→ navigator → ensettings.md)。

3. Angular Pipe + ICU 複數

Pipe 名 i18n語系清單.md §5),impure(語系切換即重算):

@Pipe({ name: 'i18n', pure: false })
export class TranslatePipe implements PipeTransform {
  constructor(private i18n: I18nService) {}
  transform(key: string, params?: Record<string, unknown>): string { return this.i18n.t(key, params); }
}
<button>{{ 'common.confirm' | i18n }}</button>
<span>{{ 'race.lap' | i18n: { lap: 2, total: 3 } }}</span>

複數採 ICU MessageFormat 子集語系清單.md §8)——由本專案 formatIcu 解析 =Nzeroonetwofewmanyother,以 Intl.PluralRules 選分支、 Intl.NumberFormat 格式化 #,不依賴第三方 MessageFormat runtime:

// 字典:"items": "{count, plural, =0 {沒有項目} one {# 個項目} other {# 個項目}}"
const output = formatIcu(dictionary.items, { count }, currentLang);

4. 日期 / 數字 / 金額(Intl)

const formatDate   = (d: Date, lang: LangCode) => new Intl.DateTimeFormat(lang).format(d);
const formatNumber = (n: number, lang: LangCode) => new Intl.NumberFormat(lang).format(n);
// 金額:bigint minor unit → 整數除以 100 顯示([語系清單.md §8](../語系清單.md))
const formatMoney  = (minor: bigint, lang: LangCode) =>
  new Intl.NumberFormat(lang, { minimumFractionDigits: 2 }).format(Number(minor) / 100);

直接用瀏覽器 Intl,無額外資源。

5. 啟動 / 切換

async function bootstrapApp() {
  const i18n = new I18nServiceImpl();
  await i18n.setLang(i18n.detectLang());   // URL /<lang>/ 前綴 → persist → navigator.language → fallback en
  // ... 啟動 Angular
}
// server/prerender:persist 埠=no-op(不讀不寫)——settings 單例於同 worker 跨
// route 共享,讀寫 persist 會讓前一 route 的語系洩漏到無前綴頁(產物語系隨渲染序漂)

// 設定頁切換(語系清單 §9):只保存選擇並提示需重整,不在活的 component tree 換字典
async function chooseLanguage(newLang: LangCode): Promise<void> {
  await settings.update('display', { language: newLang });
  reloadHint.show('display.language');
}

6. PWA 字典快取

字典隨 bundle chunk 交付loadDict 動態 import()——四語系各為一個 lazy chunk、無獨立 /assets/i18n/ 靜態檔),離線能力由自寫 Service Worker 承載(非 Angular ngsw;versioning.md §5 / pwa-offline.md §2):

  • install 全量 precache 涵蓋全部 hash 檔名 JS(build 後 inject-precache 掃 dist 注入)= 四語系字典 chunk 首次安裝即入快取;
  • fetch 走靜態資源 cache-first 分路(pwa-offline.md §2);
  • 升版失效 = 新版 CACHE_NAME(嵌 client_version)整組換新(versioning.md §7)。

字典 chunk 命中本地快取 → 離線也能顯示;runtime 由 loadDict() 按需載入記憶體(§2)。

6.1 Locale-aware 應用字型

src/i18n/locale-fonts.ts 是應用語系字型的 runtime 權威。初始 CSS 只帶 Inter;bootstrap 選定 語系後,activateLocaleFontStylesheets() 先移除舊的 link[data-open4wd-locale-font],再只為 目前 CJK 語系載入自行託管的 400/700 stylesheet:zh-TW → noto-sans-tczh-CN → noto-sans-scja → noto-sans-jp;英文不加 CJK stylesheet。所有 face 使用 font-display: swap 並由一般 PWA 靜態資源 cache-first 處理。字型載入不是 /race route guard, 失敗只走系統 fallback chain,不得阻塞進賽。

7. 字典 Schema 驗證(CI)

// 以 zh-TW(撰寫源 = 預設 + 完整翻譯)為基準比對 key 一致性,CI 跑 pnpm run i18n:validate
async function validateDictAgainstReference(langDict: any, referenceLang: LangCode = 'zh-TW'): Promise<{ missing: string[]; extra: string[] }> {
  const refKeys = collectKeys(await loadDict(referenceLang));
  const langKeys = collectKeys(langDict);
  return { missing: refKeys.filter(k => !langKeys.includes(k)),   // 缺 key → 警告;en 缺 key → CI error(en 為 fallback 鏈前提)
           extra:   langKeys.filter(k => !refKeys.includes(k)) }; // 多 key → 警告(dead translation)
}

CI 另跑 pnpm run i18n:usage 的 strict 使用面檢查:掃描 production .ts.html 的靜態 key、 樣板字串前綴與字面加號前綴,並明確解析 src/seo/seo-routes.jsontitleKeydescriptionKeykeywordsKey;不得把任意 JSON 字串泛認成翻譯 key。檢查拒絕「產品會 emit、 字典不存在」與「字典存在、產品碼零引用」兩種漂移,並比對四語系 ICU 參數集合。由 registry/ 資料檔提供、無法從程式字面推得的動態前綴必須在 scanner 內明列;scanner fixture 同時覆蓋 靜態、兩種動態組法、具名 JSON 欄位與 ICU mismatch。

命名邊界不是把所有文字塞進同一個 root:頁面專屬內容使用 pages.<page>.*,跨頁共享的領域 詞彙可使用 domain root,共用控制項使用 commonvalidation 等 component root。唯一的 UI-facing error taxonomy 是 errors.<domain>.<reason>garage.reject.*spectator.reject.* 之類平行拒因 namespace 不允許。機器 reason code 可留在 domain 型別, 只有 adapter 映射成上述翻譯鍵。

社群新增語系 PR 流程見 語系清單.md §10(patch bump、不影響配對 / protocol)。

8. 跨模組對接

模組 對接
seo/seo.md MetadataServiceapp.* keys 生成 <head> / OG / hreflang
settings/ 設定頁語系切換 UI
pwa-offline/ Service Worker 字典快取
system-constants DEFAULT_LANG / SUPPORTED_LANGS(見 程式參數.md