Skip to content

Commit fffd1cc

Browse files
committed
feat(i18n): add zh-tw locale, UI fixes, OpenCode convergence
* feat(i18n): add Traditional Chinese (zh-tw) locale support - Add zh-tw.mjs generated via OpenCC + Taiwan vocab overrides - Add tools/generate-zh-tw.mjs conversion script - Register zh-tw in i18n.dict.mjs and LANGUAGE_META - Update t() fallback chain: zh-tw -> zh -> en - Add opencc-js as devDependency - Add zh-tw tests (key parity, placeholders, fallback chain) * fix(ui): narrow config overlay scope and remove sidebar version badge - Config write overlay: fixed -> absolute, scoped to panel body - Move overlay inside tool-config-write-body in codex/claude/opencode - Remove v{{appVersion}} from sidebar brand kicker * refactor(i18n): converge OpenCode tab copy and add lang.zh-tw keys - Streamline 7 OpenCode descriptions across zh/en/ja/vi/zh-tw - Add lang.zh-tw key to all locale files - Recompile web-ui-render.precompiled.js * chore(release): bump version to 0.0.47
1 parent 52f8976 commit fffd1cc

18 files changed

Lines changed: 1508 additions & 122 deletions

package-lock.json

Lines changed: 10 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codexmate",
3-
"version": "0.0.46",
3+
"version": "0.0.47",
44
"description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具",
55
"main": "cli.js",
66
"bin": {
@@ -72,6 +72,7 @@
7272
"license": "Apache-2.0",
7373
"devDependencies": {
7474
"@vue/compiler-dom": "^3.5.30",
75+
"opencc-js": "^1.3.1",
7576
"vitepress": "^1.6.4"
7677
}
7778
}

tests/unit/config-tabs-ui.test.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ test('config template keeps expected config tabs in top and side navigation', ()
134134
assert.doesNotMatch(sideGhostTab, /@click=/);
135135
assert.doesNotMatch(sideGhostTab, /@keydown/);
136136
assert.ok(html.indexOf('id="side-tab-trash"') < html.indexOf('id="side-tab-new"'), 'ghost side tab should remain after trash tab to reserve end scroll space');
137-
assert.match(html, /<div class="brand-kicker">Codex Mate<span v-if="appVersion" class="brand-version"> v\{\{ appVersion \}\}<\/span><\/div>/);
137+
assert.match(html, /<div class="brand-kicker">Codex Mate<\/div>/);
138138
assert.match(html, /v-if="isAppVersionStatusVisible\(\)"[\s\S]*side-update-notice--'[\s\S]*appVersionStatusKind\(\)[\s\S]*@click="handleAppVersionStatusClick"/);
139139
assert.match(html, /<span class="side-update-title">\{\{\s*appUpdateNoticeText\(\)\s*\}\}<\/span>/);
140140
assert.match(html, /<span class="side-update-meta">\{\{\s*appUpdateNoticeMeta\(\)\s*\}\}<\/span>/);

tests/unit/i18n-locales.test.mjs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const __filename = fileURLToPath(import.meta.url);
88
const __dirname = path.dirname(__filename);
99
const repoRoot = path.resolve(__dirname, '..', '..');
1010
const localeDir = path.join(repoRoot, 'web-ui', 'modules', 'i18n', 'locales');
11-
const expectedLocales = ['zh', 'en', 'ja', 'vi'];
11+
const expectedLocales = ['zh', 'zh-tw', 'en', 'ja', 'vi'];
1212

1313
function placeholders(value) {
1414
return [...String(value).matchAll(/\{(\w+)\}/g)]
@@ -131,3 +131,49 @@ test('builtin prompt templates re-localize when language changes', async () => {
131131
assert.strictEqual(jaDraft.name, DICT.ja['plugins.builtin.commentPolish.name']);
132132
assert(jaDraft.template.includes(DICT.ja['plugins.builtin.commentPolish.line1']));
133133
});
134+
135+
test('zh-tw has same keys as zh', () => {
136+
const zhKeys = Object.keys(DICT.zh).sort();
137+
const twKeys = Object.keys(DICT['zh-tw']).sort();
138+
assert.deepStrictEqual(twKeys, zhKeys, 'zh-tw must define exactly the same keys as zh');
139+
});
140+
141+
test('zh-tw preserves placeholders from zh', () => {
142+
for (const [key, value] of Object.entries(DICT['zh-tw'])) {
143+
const zhValue = DICT.zh[key];
144+
assert.deepStrictEqual(
145+
placeholders(value),
146+
placeholders(zhValue),
147+
`zh-tw placeholder mismatch for key: ${key}`
148+
);
149+
}
150+
});
151+
152+
test('zh-tw uses traditional Chinese characters', () => {
153+
const tw = DICT['zh-tw'];
154+
assert.strictEqual(tw['common.copy'], '複製');
155+
assert.strictEqual(tw['common.edit'], '編輯');
156+
assert.strictEqual(tw['common.delete'], '刪除');
157+
assert.strictEqual(tw['common.loading'], '載入中...');
158+
assert.strictEqual(tw['common.export'], '匯出');
159+
assert.strictEqual(tw['common.import'], '匯入');
160+
assert.strictEqual(tw['common.refresh'], '重新整理');
161+
assert.strictEqual(tw['common.save'], '保存');
162+
assert.strictEqual(tw['common.uninstall'], '解除安裝');
163+
assert.strictEqual(tw['settings.language.title'], '語言');
164+
assert.strictEqual(tw['lang.zh-tw'], '繁體中文');
165+
});
166+
167+
test('zh-tw fallback resolves through zh before en', () => {
168+
const table = DICT['zh-tw'] || DICT.zh;
169+
const fallbackZh = DICT.zh;
170+
const fallbackEn = DICT.en;
171+
// Simulate the i18n.mjs t() fallback chain
172+
const tFallback = (key) => {
173+
return (table && table[key]) || (fallbackZh && fallbackZh[key]) || (fallbackEn && fallbackEn[key]) || key;
174+
};
175+
// For keys present in zh-tw, it should use zh-tw value (which differs from zh for UI terms)
176+
assert.strictEqual(tFallback('common.copy'), DICT['zh-tw']['common.copy']);
177+
// For a hypothetical missing key, it would fall back to zh then en
178+
assert.strictEqual(tFallback('nonexistent.key.xyz'), 'nonexistent.key.xyz');
179+
});

tools/generate-zh-tw.mjs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { readFileSync, writeFileSync } from 'fs';
2+
const OpenCC = (await import('opencc-js')).default;
3+
const converter = OpenCC.Converter({ from: 'cn', to: 'tw' });
4+
5+
const src = readFileSync('web-ui/modules/i18n/locales/zh.mjs', 'utf8');
6+
7+
// Taiwan vocab overrides (applied AFTER OpenCC cn→tw conversion)
8+
// Keys must be Traditional forms (post-OpenCC output)
9+
const OVERRIDES = [
10+
['服務器', '伺服器'],
11+
['設置', '設定'],
12+
['默認', '預設'],
13+
['當前', '目前'],
14+
['加載', '載入'],
15+
['導出', '匯出'],
16+
['導入', '匯入'],
17+
['自定義', '自訂'],
18+
['信息', '資訊'],
19+
['數據', '資料'],
20+
['視頻', '影片'],
21+
['内存', '記憶體'],
22+
['登錄', '登入'],
23+
['注銷', '登出'],
24+
['文件夾', '資料夾'],
25+
['網絡', '網路'],
26+
['打印', '列印'],
27+
['鼠標', '滑鼠'],
28+
['剪切', '剪下'],
29+
['搜索', '搜尋'],
30+
['替換', '取代'],
31+
['菜單', '選單'],
32+
['圖標', '圖示'],
33+
['配置', '設定'],
34+
['禁用', '停用'],
35+
['卸載', '解除安裝'],
36+
['发送', '傳送'],
37+
['啓用', '啟用'],
38+
['啓動', '啟動'],
39+
['啓', '啟'],
40+
['開啓', '開啟'],
41+
['重啓', '重新啟動'],
42+
['刷新', '重新整理'],
43+
];
44+
45+
function postProcess(text) {
46+
let r = text;
47+
for (const [from, to] of OVERRIDES) {
48+
r = r.split(from).join(to);
49+
}
50+
return r;
51+
}
52+
53+
const converted = postProcess(converter(src));
54+
55+
// Fix export name: const zh → const zhTw
56+
const fixed = converted
57+
.replace(/^const zh = /m, 'const zhTw = ')
58+
.replace(/^export \{ zh \};/m, 'export { zhTw };')
59+
.replace(/^export default zh;/m, 'export default zhTw;');
60+
61+
writeFileSync('web-ui/modules/i18n/locales/zh-tw.mjs', fixed, 'utf8');
62+
console.log('zh-tw.mjs generated, lines:', fixed.split('\n').length);

web-ui/modules/i18n.dict.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { zh } from './i18n/locales/zh.mjs';
2+
import { zhTw } from './i18n/locales/zh-tw.mjs';
23
import { en } from './i18n/locales/en.mjs';
34
import { ja } from './i18n/locales/ja.mjs';
45
import { vi } from './i18n/locales/vi.mjs';
56

67
const DICT = Object.freeze({
78
zh,
9+
'zh-tw': zhTw,
810
en,
911
ja,
1012
vi

web-ui/modules/i18n.mjs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const I18N_STORAGE_KEY = 'codexmateLang';
44

55
const LANGUAGE_META = Object.freeze([
66
Object.freeze({ code: 'zh', nativeName: '中文', englishName: 'Chinese', htmlLang: 'zh-CN', dir: 'ltr' }),
7+
Object.freeze({ code: 'zh-tw', nativeName: '繁體中文', englishName: 'Chinese-TW', htmlLang: 'zh-TW', dir: 'ltr' }),
78
Object.freeze({ code: 'en', nativeName: 'English', englishName: 'English', htmlLang: 'en', dir: 'ltr' }),
89
Object.freeze({ code: 'ja', nativeName: '日本語', englishName: 'Japanese', htmlLang: 'ja', dir: 'ltr' }),
910
Object.freeze({ code: 'vi', nativeName: 'Tiếng Việt', englishName: 'Vietnamese', htmlLang: 'vi', dir: 'ltr' })
@@ -102,9 +103,9 @@ export function createI18nMethods() {
102103
t(key, params = null) {
103104
const lang = normalizeLang(this.lang);
104105
const table = DICT[lang] || DICT.zh;
105-
const fallbackEn = DICT.en;
106106
const fallbackZh = DICT.zh;
107-
const raw = (table && table[key]) || (fallbackEn && fallbackEn[key]) || (fallbackZh && fallbackZh[key]) || key;
107+
const fallbackEn = DICT.en;
108+
const raw = (table && table[key]) || (fallbackZh && fallbackZh[key]) || (fallbackEn && fallbackEn[key]) || key;
108109
return interpolate(raw, params);
109110
}
110111
};

web-ui/modules/i18n/locales/en.mjs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const en = Object.freeze({
22
// Global
33
'lang.zh': '中文',
4+
'lang.zh-tw': '繁體中文',
45
'lang.en': 'English',
56
'lang.vi': 'Vietnamese',
67
'lang.label': 'Language',
@@ -811,15 +812,15 @@ const en = Object.freeze({
811812
'toolConfig.claude.lockedDesc': 'Claude config will not be modified. Enable writes in this tab to add, apply, edit, or delete providers.',
812813
'toolConfig.claude.confirmMessage': 'After enabling this, apply actions in the Claude tab may write ~/.claude/settings.json and related Claude config.',
813814
'toolConfig.opencode.title': 'OpenCode config writes',
814-
'toolConfig.opencode.desc': 'OpenCode config is read-only by default; enable this before writing the XDG OpenCode config file (for example ~/.config/opencode/opencode.jsonc).',
815-
'toolConfig.opencode.lockedTitle': 'OpenCode config is read-only',
816-
'toolConfig.opencode.lockedDesc': 'OpenCode config will not be modified until writes are enabled for this tab.',
815+
'toolConfig.opencode.desc': 'Read-only by default; enable to write OpenCode config.',
816+
'toolConfig.opencode.lockedTitle': 'Read-only',
817+
'toolConfig.opencode.lockedDesc': 'Enable write access to save, import, or apply config.',
817818
'toolConfig.opencode.confirmMessage': 'After enabling this, actions in the OpenCode tab may write the XDG OpenCode config file (for example ~/.config/opencode/opencode.jsonc) or the file specified by OPENCODE_CONFIG.',
818819
'opencode.providerModel.title': 'OpenCode provider / model',
819820
'opencode.writeAria': 'OpenCode write access',
820821
'opencode.applySelection': 'Apply to OpenCode',
821822
'opencode.targetFile': 'Active OpenCode config: {path} · {status}',
822-
'opencode.providerStoreFile': 'CodexMate provider store: {path} (multiple providers are stored here; only the selected provider is projected to OpenCode)',
823+
'opencode.providerStoreFile': 'Provider draft store: {path} (written to OpenCode on apply)',
823824
'opencode.field.agent': 'Agent',
824825
'opencode.field.apiKeyKeep': 'API key (leave blank to keep current key)',
825826
'opencode.field.maxTokens': 'maxTokens (optional)',
@@ -828,15 +829,15 @@ const en = Object.freeze({
828829
'opencode.option.reasoningLow': 'low',
829830
'opencode.option.reasoningMedium': 'medium',
830831
'opencode.option.reasoningHigh': 'high',
831-
'opencode.applyToCoreAgents': 'Apply to build / plan / general / title / summary / compaction',
832+
'opencode.applyToCoreAgents': 'Apply to all core agents',
832833
'opencode.enableAutoCompaction': 'Enable compaction.auto',
833834
'opencode.disableProvider': 'Disable this provider',
834835
'opencode.configFile.title': 'OpenCode config file',
835836
'opencode.importParse': 'Import / parse file',
836837
'opencode.saveConfig': 'Save config',
837838
'opencode.parsedFile': 'Parsed: {file}',
838-
'opencode.textarea.placeholder': 'View/edit ~/.config/opencode/opencode.jsonc here',
839-
'opencode.configFile.hint': 'This is the active OpenCode config. CodexMate stores multiple provider drafts separately to avoid polluting native OpenCode config.',
839+
'opencode.textarea.placeholder': 'Edit OpenCode config',
840+
'opencode.configFile.hint': 'Active config preview; drafts stored in separate Provider store.',
840841
'opencode.summary.title': 'Parsed summary',
841842
'opencode.summary.noApiKey': 'No API key configured',
842843
'opencode.summary.noModel': 'No model set',

web-ui/modules/i18n/locales/ja.mjs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const ja = Object.freeze({
22

33
// Global
44
'lang.zh': '中国語',
5+
'lang.zh-tw': '繁體中文',
56
'lang.en': 'English',
67
'lang.vi': 'ベトナム語',
78
'lang.label': '言語',
@@ -800,15 +801,15 @@ const ja = Object.freeze({
800801
'toolConfig.claude.lockedDesc': 'Claude 設定には書き込みません。追加・適用・編集・削除するには、このタブの書き込みを有効化してください。',
801802
'toolConfig.claude.confirmMessage': '有効化すると、Claude タブ内の適用操作が ~/.claude/settings.json などの Claude 設定を書き込みます。',
802803
'toolConfig.opencode.title': 'OpenCode 設定の書き込み',
803-
'toolConfig.opencode.desc': '既定では OpenCode 設定を読み取り専用で表示します。有効化した場合のみ XDG OpenCode 設定ファイル(例: ~/.config/opencode/opencode.jsonc)に書き込みます。',
804-
'toolConfig.opencode.lockedTitle': 'OpenCode 設定は読み取り専用です',
805-
'toolConfig.opencode.lockedDesc': 'OpenCode 設定には書き込みません。保存・インポート・provider/model 適用には、このタブの書き込みを有効化してください。',
804+
'toolConfig.opencode.desc': 'デフォルトは読み取り専用。有効化すると OpenCode 設定ファイルに書き込みます。',
805+
'toolConfig.opencode.lockedTitle': '読み取り専用',
806+
'toolConfig.opencode.lockedDesc': '書き込み権限を有効化すると、保存・インポート・設定の適用ができます。',
806807
'toolConfig.opencode.confirmMessage': '有効化すると、OpenCode タブ内の操作が XDG OpenCode 設定ファイル(例: ~/.config/opencode/opencode.jsonc)または OPENCODE_CONFIG 指定ファイルを書き込みます。',
807808
'opencode.providerModel.title': 'OpenCode provider / model',
808809
'opencode.writeAria': 'OpenCode 書き込み',
809810
'opencode.applySelection': 'OpenCode に適用',
810811
'opencode.targetFile': '有効な OpenCode 設定: {path} · {status}',
811-
'opencode.providerStoreFile': 'CodexMate provider store: {path} (multiple providers are stored here; only the selected provider is projected to OpenCode)',
812+
'opencode.providerStoreFile': 'プロバイダ下書きストア:{path}(適用時に OpenCode へ書き込み)',
812813
'opencode.field.agent': 'Agent',
813814
'opencode.field.apiKeyKeep': 'API Key(空なら既存 key を保持)',
814815
'opencode.field.maxTokens': 'maxTokens(任意)',
@@ -817,15 +818,15 @@ const ja = Object.freeze({
817818
'opencode.option.reasoningLow': 'low',
818819
'opencode.option.reasoningMedium': 'medium',
819820
'opencode.option.reasoningHigh': 'high',
820-
'opencode.applyToCoreAgents': 'build / plan / general / title / summary / compaction に適用',
821+
'opencode.applyToCoreAgents': 'すべてのコア agent に適用',
821822
'opencode.enableAutoCompaction': 'compaction.auto を有効化',
822823
'opencode.disableProvider': 'この provider を無効化',
823824
'opencode.configFile.title': 'OpenCode 設定ファイル',
824825
'opencode.importParse': 'ファイルをインポート/解析',
825826
'opencode.saveConfig': '設定を保存',
826827
'opencode.parsedFile': '解析済み: {file}',
827-
'opencode.textarea.placeholder': 'ここで ~/.config/opencode/opencode.jsonc を表示/編集',
828-
'opencode.configFile.hint': 'This is the active OpenCode config. CodexMate stores multiple provider drafts separately to avoid polluting native OpenCode config.',
828+
'opencode.textarea.placeholder': 'OpenCode 設定を編集',
829+
'opencode.configFile.hint': '有効設定のプレビュー。下書きは個別のプロバイダストアに保存されます。',
829830
'opencode.summary.title': '解析サマリー',
830831
'opencode.summary.noApiKey': 'API Key 未設定',
831832
'opencode.summary.noModel': 'model 未設定',

web-ui/modules/i18n/locales/vi.mjs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const vi = Object.freeze({
1010
'plugins.builtin.ruleAck.line1': 'Hãy làm theo【{{rule}}】, nhận được thì phản hồi',
1111
// Global
1212
'lang.zh': 'Tiếng Trung',
13+
'lang.zh-tw': 'Tiếng Trung (Phồn thể)',
1314
'lang.en': 'Tiếng Anh',
1415
'lang.vi': 'Tiếng Việt',
1516
'lang.label': 'Ngôn ngữ',
@@ -163,15 +164,15 @@ const vi = Object.freeze({
163164
'toolConfig.claude.lockedDesc': 'Cấu hình Claude sẽ không bị sửa. Hãy bật ghi trong tab này để thêm, áp dụng, sửa hoặc xóa provider.',
164165
'toolConfig.claude.confirmMessage': 'Sau khi bật, thao tác áp dụng trong tab Claude có thể ghi ~/.claude/settings.json và cấu hình Claude liên quan.',
165166
'toolConfig.opencode.title': 'Ghi cấu hình OpenCode',
166-
'toolConfig.opencode.desc': 'Cấu hình OpenCode mặc định chỉ đọc; bật công tắc này trước khi ghi file cấu hình XDG OpenCode (ví dụ ~/.config/opencode/opencode.jsonc).',
167-
'toolConfig.opencode.lockedTitle': 'Cấu hình OpenCode đang chỉ đọc',
168-
'toolConfig.opencode.lockedDesc': 'Cấu hình OpenCode sẽ không bị sửa cho đến khi bật ghi trong tab này.',
167+
'toolConfig.opencode.desc': 'Mặc định chỉ đọc; bật để ghi cấu hình OpenCode.',
168+
'toolConfig.opencode.lockedTitle': 'Chỉ đọc',
169+
'toolConfig.opencode.lockedDesc': 'Bật quyền ghi để lưu, nhập hoặc áp dụng cấu hình.',
169170
'toolConfig.opencode.confirmMessage': 'Sau khi bật, thao tác trong tab OpenCode có thể ghi file cấu hình XDG OpenCode (ví dụ ~/.config/opencode/opencode.jsonc) hoặc file do OPENCODE_CONFIG chỉ định.',
170171
'opencode.providerModel.title': 'Provider / model OpenCode',
171172
'opencode.writeAria': 'Quyền ghi OpenCode',
172173
'opencode.applySelection': 'Áp dụng vào OpenCode',
173174
'opencode.targetFile': 'Cấu hình OpenCode đang có hiệu lực: {path} · {status}',
174-
'opencode.providerStoreFile': 'Kho provider của CodexMate: {path} (nhiều provider được lưu tại đây; chỉ provider đã chọn được ghi sang OpenCode)',
175+
'opencode.providerStoreFile': 'Kho nháp provider: {path} (ghi vào OpenCode khi áp dụng)',
175176
'opencode.field.agent': 'Agent',
176177
'opencode.field.apiKeyKeep': 'API Key (để trống để giữ key hiện tại)',
177178
'opencode.field.maxTokens': 'maxTokens (tùy chọn)',
@@ -180,15 +181,15 @@ const vi = Object.freeze({
180181
'opencode.option.reasoningLow': 'low',
181182
'opencode.option.reasoningMedium': 'medium',
182183
'opencode.option.reasoningHigh': 'high',
183-
'opencode.applyToCoreAgents': 'Áp dụng cho build / plan / general / title / summary / compaction',
184+
'opencode.applyToCoreAgents': 'Áp dụng cho tất cả agent cốt lõi',
184185
'opencode.enableAutoCompaction': 'Bật compaction.auto',
185186
'opencode.disableProvider': 'Tắt provider này',
186187
'opencode.configFile.title': 'File cấu hình OpenCode',
187188
'opencode.importParse': 'Nhập / phân tích file',
188189
'opencode.saveConfig': 'Lưu cấu hình',
189190
'opencode.parsedFile': 'Đã phân tích: {file}',
190-
'opencode.textarea.placeholder': 'Xem/sửa ~/.config/opencode/opencode.jsonc tại đây',
191-
'opencode.configFile.hint': 'Đây là cấu hình OpenCode đang hoạt động. CodexMate lưu riêng nhiều bản nháp theo từng provider để tránh ghi đè hoặc làm thay đổi cấu hình OpenCode gốc.',
191+
'opencode.textarea.placeholder': 'Chỉnh sửa cấu hình OpenCode',
192+
'opencode.configFile.hint': 'Xem trước cấu hình đang hoạt động; bản nháp lưu trong kho Provider riêng.',
192193
'opencode.summary.title': 'Tóm tắt đã phân tích',
193194
'opencode.summary.noApiKey': 'Chưa cấu hình API Key',
194195
'opencode.summary.noModel': 'Chưa đặt model',

0 commit comments

Comments
 (0)