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 <the@jat.mn>
This commit is contained in:
YIU Tsun Wa
2026-10-25 12:15:42 -07:00
committed by GitHub
co-authored by jatmn
parent cdd6af609b
commit cdb28e0e3e
5 changed files with 276 additions and 9 deletions
+184
View File
@@ -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)
})
})
+2
View File
@@ -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<string, I18nDictionary> = {
en,
vi,
'zh-HK': zhHK,
}
export { detectLocale }
+53
View File
@@ -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..."
};
+36 -8
View File
@@ -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<string, Locale> = {
english: 'en',
en: 'en',
vietnamese: 'vi',
vi: 'vi',
const LANGUAGE_MAP: Map<string, Locale> = 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'
}
+1 -1
View File
@@ -1,4 +1,4 @@
export type Locale = 'en' | 'vi'
export type Locale = 'en' | 'vi' | 'zh-HK'
export type LocalizationKey = string