From cdb28e0e3e464917880282885bb977c4d79ba0cf Mon Sep 17 00:00:00 2001 From: YIU Tsun Wa Date: Mon, 26 Oct 2026 03:15:42 +0800 Subject: [PATCH] feat(i18n): add Traditional Chinese (zh-HK) locale support (#2193) * ' * feat(i18n): add zh-HK locale and fix extensionless imports * fix(i18n): restore original en/vi dictionaries and fix zh-HK exports * fix(i18n): resolve import paths, file duplication, and locale matching * fix(i18n): restore working import paths, localize() API, and session-based locale detection for zh-HK * fix(i18n): use Map for locale lookup to avoid prototype-key collisions * fix(i18n): restore working import paths, localize() API, exports, and session-based locale detection * fix(i18n): correct onboard-github key mismatch in zh-HK dictionary * test(i18n): clear locale env vars in beforeEach to isolate settings-based fallback tests * fix(i18n): preserve command meaning and POSIX locale normalization --------- Co-authored-by: jatmn --- src/i18n.locale.test.ts | 184 ++++++++++++++++++++++++++++++++++++ src/i18n/index.ts | 2 + src/i18n/languages/zh-HK.ts | 53 +++++++++++ src/i18n/locale.ts | 44 +++++++-- src/i18n/types.ts | 2 +- 5 files changed, 276 insertions(+), 9 deletions(-) create mode 100644 src/i18n.locale.test.ts create mode 100644 src/i18n/languages/zh-HK.ts diff --git a/src/i18n.locale.test.ts b/src/i18n.locale.test.ts new file mode 100644 index 000000000..aa183b2dc --- /dev/null +++ b/src/i18n.locale.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' + +import { formatDescriptionWithSource } from './commands.js' +import onboardGithub from './commands/onboard-github/index.js' +import usage from './commands/usage/index.js' +import { getOpenClaudeCommandDescriptionKey } from './i18n/index.js' +import { detectLocale } from './i18n/locale.js' +import { snapshotEnv } from './test/envSnapshot.js' +import { + resetSettingsCache, + setSessionSettingsCache, +} from './utils/settings/settingsCache.js' + +let restoreEnv: () => void + +beforeEach(() => { + restoreEnv = snapshotEnv(['LC_ALL', 'LC_MESSAGES', 'LANG'], { clear: true }) +}) + +afterEach(() => { + resetSettingsCache() + restoreEnv() +}) + +// --------------------------------------------------------------------------- +// Settings-based detection +// --------------------------------------------------------------------------- + +describe('detectLocale – settings', () => { + test('returns zh-HK when settings.language is "zh-HK"', () => { + setSessionSettingsCache({ + settings: { language: 'zh-HK' }, + errors: [], + }) + expect(detectLocale()).toBe('zh-HK') + }) + + test('returns zh-HK for case-insensitive "zh-hk"', () => { + setSessionSettingsCache({ + settings: { language: 'zh-hk' }, + errors: [], + }) + expect(detectLocale()).toBe('zh-HK') + }) + + test('returns en for "en"', () => { + setSessionSettingsCache({ + settings: { language: 'en' }, + errors: [], + }) + expect(detectLocale()).toBe('en') + }) + + test('returns vi for "vi"', () => { + setSessionSettingsCache({ + settings: { language: 'vi' }, + errors: [], + }) + expect(detectLocale()).toBe('vi') + }) + + test('does NOT match zh_CN to zh-HK', () => { + setSessionSettingsCache({ + settings: { language: 'zh_CN' }, + errors: [], + }) + // zh_CN is not in the language map → falls through to env, then 'en' + expect(detectLocale()).toBe('en') + }) + + test('does NOT match en_HK to zh-HK', () => { + setSessionSettingsCache({ + settings: { language: 'en_HK' }, + errors: [], + }) + expect(detectLocale()).toBe('en') + }) +}) + +// --------------------------------------------------------------------------- +// Environment-based detection (no settings cache) +// --------------------------------------------------------------------------- + +describe('detectLocale – environment variables', () => { + /** Clear settings cache and all locale env vars so env path is exercised. */ + function clearAll() { + resetSettingsCache() + setSessionSettingsCache({ settings: {}, errors: [] }) + delete process.env.LC_ALL + delete process.env.LC_MESSAGES + delete process.env.LANG + } + + test('LC_ALL=zh_HK.UTF-8 → zh-HK', () => { + clearAll() + process.env.LC_ALL = 'zh_HK.UTF-8' + expect(detectLocale()).toBe('zh-HK') + }) + + test('LC_MESSAGES=vi_VN.UTF-8 → vi', () => { + clearAll() + process.env.LC_MESSAGES = 'vi_VN.UTF-8' + expect(detectLocale()).toBe('vi') + }) + + test('LANG=en_US.UTF-8 → en', () => { + clearAll() + process.env.LANG = 'en_US.UTF-8' + expect(detectLocale()).toBe('en') + }) + + test('LC_ALL=zh_CN.UTF-8 does NOT match zh-HK', () => { + clearAll() + process.env.LC_ALL = 'zh_CN.UTF-8' + expect(detectLocale()).toBe('en') + }) + + test('LANG=en_HK.UTF-8 does NOT match zh-HK', () => { + clearAll() + process.env.LANG = 'en_HK.UTF-8' + expect(detectLocale()).toBe('en') + }) + + test.each([ + ['en_US.UTF-8', 'zh_HK.UTF-8', 'vi_VN.UTF-8', 'en'], + ['', 'zh_HK.UTF-8', 'vi_VN.UTF-8', 'zh-HK'], + ['', '', 'vi_VN.UTF-8', 'vi'], + ['C', 'zh_HK.UTF-8', 'vi_VN.UTF-8', 'en'], + ] as const)('prioritizes LC_ALL=%s, LC_MESSAGES=%s, LANG=%s → %s', + (lcAll, lcMessages, lang, expected) => { + clearAll() + process.env.LC_ALL = lcAll + process.env.LC_MESSAGES = lcMessages + process.env.LANG = lang + expect(detectLocale()).toBe(expected) + }, + ) + + test('a supported settings language overrides all locale variables', () => { + setSessionSettingsCache({ settings: { language: 'en' }, errors: [] }) + process.env.LC_ALL = 'zh_HK.UTF-8' + process.env.LC_MESSAGES = 'vi_VN.UTF-8' + process.env.LANG = 'zh_HK.UTF-8' + expect(detectLocale()).toBe('en') + }) + + test.each([ + ['zh_HK@traditional', 'zh-HK'], + ['zh_HK.UTF-8@traditional', 'zh-HK'], + ['zh_CN@traditional', 'en'], + ['en_HK@traditional', 'en'], + ] as const)('LANG=%s → %s', (lang, expected) => { + clearAll() + process.env.LANG = lang + expect(detectLocale()).toBe(expected) + }) + + test('falls back to en when no env vars are set', () => { + clearAll() + expect(detectLocale()).toBe('en') + }) +}) + +// --------------------------------------------------------------------------- +// Command-description consumer resolution +// --------------------------------------------------------------------------- + +describe('zh-HK command descriptions', () => { + test.each([ + [usage.name, usage, '顯示計劃用量上限'], + [onboardGithub.name, onboardGithub, '設定 GitHub Copilot 認證資訊'], + ] as const)('preserves the purpose of /%s', (_name, command, expected) => { + setSessionSettingsCache({ settings: { language: 'zh-HK' }, errors: [] }) + const localizationKey = getOpenClaudeCommandDescriptionKey(command.name) + expect(localizationKey).toBeDefined() + + const localizedCommand = { ...command, localizationKey } + expect(formatDescriptionWithSource(localizedCommand)).toBe(expected) + + // The same real command must retain its canonical description in English. + setSessionSettingsCache({ settings: { language: 'en' }, errors: [] }) + expect(formatDescriptionWithSource(localizedCommand)).toBe(command.description) + }) +}) diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 7beb10a22..302cd12d5 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,6 +1,7 @@ import { detectLocale } from './locale.js' import { en } from './languages/en.js' import { vi } from './languages/vi.js' +import zhHK from './languages/zh-HK.js' import type { I18nDictionary, InterpolationValues, @@ -10,6 +11,7 @@ import type { const dictionaries: Record = { en, vi, + 'zh-HK': zhHK, } export { detectLocale } diff --git a/src/i18n/languages/zh-HK.ts b/src/i18n/languages/zh-HK.ts new file mode 100644 index 000000000..b83d6aeb7 --- /dev/null +++ b/src/i18n/languages/zh-HK.ts @@ -0,0 +1,53 @@ +export default { + "common.ok": "確定", + "common.cancel": "取消", + "common.back": "返回", + "common.continue": "繼續", + "common.save": "儲存", + "common.delete": "刪除", + "common.edit": "編輯", + "common.loading": "載入中...", + "common.success": "成功", + "common.failed": "失敗", + "common.yes": "是", + "common.no": "否", + "common.options": "選項", + "common.shortcut_hint": "按 Enter 繼續,Esc 返回", + + "commands.provider.description": "開啟服務商設定選單與切換設定檔", + "commands.model.description": "切換當前對話的 AI 模型", + "commands.onboard-github.description": "設定 GitHub Copilot 認證資訊", + "commands.buddy.description": "喚醒或設定 Terminal Pixel Buddy", + "commands.repomap.description": "檢視專案結構地圖與權重排名", + "commands.btw.description": "傳送旁白提問而不中斷主對話 Context", + "commands.usage.description": "顯示計劃用量上限", + "commands.help.description": "顯示 OpenClaude 指令與快捷鍵指南", + + "provider.title": "設定服務商設定檔", + "provider.select_method": "選擇登入或連接方式:", + "provider.options.subscription": "Claude 訂閱 (Pro / Team / Enterprise)", + "provider.options.console": "Anthropic Console API 帳號", + "provider.options.third_party": "第三方平台 (OpenAI, Gemini, DeepSeek, Ollama 等)", + "provider.presets.title": "選擇預設服務商:", + "provider.presets.gitlawb": "Gitlawb Opengateway (推薦)", + "provider.presets.deepseek": "DeepSeek Native API", + "provider.presets.aimlapi": "AI/ML API", + "provider.presets.ollama": "Ollama 本地服務", + "provider.enter_key": "請輸入 API Key:", + "provider.invalid_key": "API Key 無效,請重新輸入。", + "provider.saved": "服務商設定已儲存至 ~/.openclaude-profile.json", + + "model.current_model": "當前模型:", + "model.select_prompt": "輸入或選擇 Model ID:", + "model.custom_input": "自訂 Model ID", + "model.switched_success": "已成功切換模型至 {model}", + + "errors.node_not_found": "未找到 Node.js 執行檔。", + "errors.unsupported_region": "當前地區限制直接連線至 Anthropic。", + "errors.api_401": "API 認證失敗 (401)。", + "errors.api_429": "已超出請求速率限制 (429)。", + + "prompts.input_placeholder": "輸入提示詞或指令...", + "prompts.bash_mode": "[Bash 模式] 輸入 Shell 指令...", + "prompts.side_question": "[/btw 旁白提問] 不會影響當前會話 Context..." +}; diff --git a/src/i18n/locale.ts b/src/i18n/locale.ts index f00f91afa..561a17988 100644 --- a/src/i18n/locale.ts +++ b/src/i18n/locale.ts @@ -2,18 +2,46 @@ import { getInitialSettings } from '../utils/settings/settings.js' import { getSessionSettingsCache } from '../utils/settings/settingsCache.js' import type { Locale } from './types.js' -const LANGUAGE_MAP: Record = { - english: 'en', - en: 'en', - vietnamese: 'vi', - vi: 'vi', +const LANGUAGE_MAP: Map = new Map([ + ['english', 'en'], + ['en', 'en'], + ['vietnamese', 'vi'], + ['vi', 'vi'], + ['zh-hk', 'zh-HK'], + ['zh_hk', 'zh-HK'], + ['traditional chinese', 'zh-HK'], + ['chinese (hong kong)', 'zh-HK'], +]) + +function localeFromEnv(): Locale | undefined { + const raw = ( + process.env.LC_ALL || + process.env.LC_MESSAGES || + process.env.LANG || + '' + ).toLowerCase() + + if (!raw) return undefined + + // POSIX locales can carry a codeset and/or a modifier after the region. + const [langRegion] = raw.split(/[.@]/) + const [lang, region] = langRegion.split(/[-_]/) + + if (lang === 'zh' && region === 'hk') return 'zh-HK' + if (lang === 'vi') return 'vi' + if (lang === 'en') return 'en' + + return undefined } export function detectLocale(): Locale { const settings = getSessionSettingsCache()?.settings ?? getInitialSettings() const lang = settings.language - if (typeof lang !== 'string') { - return 'en' + + if (typeof lang === 'string') { + const mapped = LANGUAGE_MAP.get(lang.toLowerCase()) + if (mapped) return mapped } - return LANGUAGE_MAP[lang.toLowerCase()] ?? 'en' + + return localeFromEnv() ?? 'en' } diff --git a/src/i18n/types.ts b/src/i18n/types.ts index fc65e4a8b..d7c2c31fb 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -1,4 +1,4 @@ -export type Locale = 'en' | 'vi' +export type Locale = 'en' | 'vi' | 'zh-HK' export type LocalizationKey = string