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 為 materials、app、common、race、garage、
settings、errors、submit、chat、themes、validation、skills、nav、sort、
stats、ugc、parts、pages、seo;這份名錄以完整撰寫源 zh-TW.json 為機器權威,
其他語系必須具備相同 key tree。app.brand 跨語系不翻;validation 可帶 ICU 參數,頁面/SEO
文案分別放在 pages/seo。字典不作 /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 → en(settings.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
解析 =N/zero/one/two/few/many/other,以 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-tc、
zh-CN → noto-sans-sc、ja → 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.json 的 titleKey/
descriptionKey/keywordsKey;不得把任意 JSON 字串泛認成翻譯 key。檢查拒絕「產品會 emit、
字典不存在」與「字典存在、產品碼零引用」兩種漂移,並比對四語系 ICU 參數集合。由 registry/
資料檔提供、無法從程式字面推得的動態前綴必須在 scanner 內明列;scanner fixture 同時覆蓋
靜態、兩種動態組法、具名 JSON 欄位與 ICU mismatch。
命名邊界不是把所有文字塞進同一個 root:頁面專屬內容使用 pages.<page>.*,跨頁共享的領域
詞彙可使用 domain root,共用控制項使用 common/validation 等 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) |
MetadataService 讀 app.* keys 生成 <head> / OG / hreflang |
settings/ |
設定頁語系切換 UI |
pwa-offline/ |
Service Worker 字典快取 |
system-constants |
DEFAULT_LANG / SUPPORTED_LANGS(見 程式參數.md) |