mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-11-06 20:58:14 -04:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
export type Locale = 'en' | 'vi'
|
||||
export type Locale = 'en' | 'vi' | 'zh-HK'
|
||||
|
||||
export type LocalizationKey = string
|
||||
|
||||
|
||||
Reference in New Issue
Block a user