Skip to content

Commit 2ee1e8d

Browse files
authored
feat(l10n): add Korean, Malay, Russian, and Hebrew language support (#584)
* fix(l10n): set deleteButton to blank for icon-only button * feat(l10n): add he, ko, ms, ru to language registry * chore(l10n): update scripts and fix ko.json placeholders * feat(l10n): update unit tests for 8 languages * feat(l10n): update E2E test and fix formatting
1 parent 77aa399 commit 2ee1e8d

8 files changed

Lines changed: 165 additions & 131 deletions

File tree

e2e/specs/features/language.spec.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ const LANGUAGE_ASSERTIONS: Record<
3333
screenTitle: 'Settings',
3434
firstCardTitle: 'Model Initialization Settings',
3535
},
36+
he: {
37+
screenTitle: 'הגדרות',
38+
firstCardTitle: 'הגדרות איתחול מודל',
39+
},
3640
id: {
3741
screenTitle: 'Pengaturan',
3842
firstCardTitle: 'Pengaturan Inisialisasi Model',
@@ -41,14 +45,26 @@ const LANGUAGE_ASSERTIONS: Record<
4145
screenTitle: '設定',
4246
firstCardTitle: 'モデル初期化設定',
4347
},
48+
ko: {
49+
screenTitle: '설정',
50+
firstCardTitle: '모델 초기화 설정',
51+
},
52+
ms: {
53+
screenTitle: 'Tetapan',
54+
firstCardTitle: 'Tetapan Permulaan Model',
55+
},
56+
ru: {
57+
screenTitle: 'Настройки',
58+
firstCardTitle: 'Настройки инициализации модели',
59+
},
4460
zh: {
4561
screenTitle: '设置',
4662
firstCardTitle: '模型初始化设置',
4763
},
4864
};
4965

5066
// Order: start with non-English, end with English to restore default state
51-
const LANGUAGE_ORDER = ['id', 'ja', 'zh', 'en'];
67+
const LANGUAGE_ORDER = ['he', 'id', 'ja', 'ko', 'ms', 'ru', 'zh', 'en'];
5268

5369
describe('Language Switching', () => {
5470
let chatPage: ChatPage;

scripts/__tests__/validate-l10n.test.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,16 @@ function runWithLocales(overrides = {}) {
2121

2222
try {
2323
// Copy original locale files to temp dir
24-
for (const filename of ['en.json', 'id.json', 'ja.json', 'zh.json']) {
24+
for (const filename of [
25+
'en.json',
26+
'he.json',
27+
'id.json',
28+
'ja.json',
29+
'ko.json',
30+
'ms.json',
31+
'ru.json',
32+
'zh.json',
33+
]) {
2534
const src = path.join(LOCALES_DIR, filename);
2635
const dest = path.join(tmpLocalesDir, filename);
2736
fs.copyFileSync(src, dest);
@@ -70,8 +79,12 @@ describe('validate-l10n.js', () => {
7079
const result = runWithLocales();
7180
expect(result.exitCode).toBe(0);
7281
expect(result.output).toContain('en.json: valid JSON');
82+
expect(result.output).toContain('he.json: valid JSON');
7383
expect(result.output).toContain('id.json: valid JSON');
7484
expect(result.output).toContain('ja.json: valid JSON');
85+
expect(result.output).toContain('ko.json: valid JSON');
86+
expect(result.output).toContain('ms.json: valid JSON');
87+
expect(result.output).toContain('ru.json: valid JSON');
7588
expect(result.output).toContain('zh.json: valid JSON');
7689
expect(result.output).toContain('All l10n files valid');
7790
});

scripts/sync-weblate.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ async function uploadSourceFile() {
5353
}
5454

5555
async function downloadTranslations() {
56-
const languages = ['id', 'ja', 'zh'];
56+
const languages = ['he', 'id', 'ja', 'ko', 'ms', 'ru', 'zh'];
5757

5858
for (const lang of languages) {
5959
try {

src/locales/__tests__/locales.test.ts

Lines changed: 87 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -34,81 +34,59 @@ const EXPECTED_SECTIONS = [
3434
'simulator',
3535
];
3636

37+
const ALL_LANGUAGES: AvailableLanguage[] = [
38+
'en',
39+
'he',
40+
'id',
41+
'ja',
42+
'ko',
43+
'ms',
44+
'ru',
45+
'zh',
46+
];
47+
3748
describe('l10n object', () => {
3849
it('supports all expected languages', () => {
39-
expect(supportedLanguages).toEqual(['en', 'id', 'ja', 'zh']);
40-
expect(Object.keys(l10n)).toEqual(['en', 'id', 'ja', 'zh']);
50+
expect(supportedLanguages).toEqual(ALL_LANGUAGES);
51+
expect(Object.keys(l10n)).toEqual(ALL_LANGUAGES);
4152
});
4253

4354
it('l10n.en is eagerly loaded and equals raw enData', () => {
4455
expect(l10n.en).toBe(enData); // same reference, not a copy
4556
});
4657

47-
it('l10n.en has all 19 expected top-level sections', () => {
48-
const enSections = Object.keys(l10n.en);
49-
for (const section of EXPECTED_SECTIONS) {
50-
expect(enSections).toContain(section);
51-
}
52-
expect(enSections).toHaveLength(EXPECTED_SECTIONS.length);
53-
});
54-
55-
it('l10n.ja has all 19 expected top-level sections', () => {
56-
const jaSections = Object.keys(l10n.ja);
57-
for (const section of EXPECTED_SECTIONS) {
58-
expect(jaSections).toContain(section);
59-
}
60-
expect(jaSections).toHaveLength(EXPECTED_SECTIONS.length);
61-
});
62-
63-
it('l10n.id has all 19 expected top-level sections', () => {
64-
const idSections = Object.keys(l10n.id);
65-
for (const section of EXPECTED_SECTIONS) {
66-
expect(idSections).toContain(section);
67-
}
68-
expect(idSections).toHaveLength(EXPECTED_SECTIONS.length);
69-
});
70-
71-
it('l10n.zh has all 19 expected top-level sections', () => {
72-
const zhSections = Object.keys(l10n.zh);
73-
for (const section of EXPECTED_SECTIONS) {
74-
expect(zhSections).toContain(section);
75-
}
76-
expect(zhSections).toHaveLength(EXPECTED_SECTIONS.length);
77-
});
58+
it.each(ALL_LANGUAGES)(
59+
'l10n.%s has all 19 expected top-level sections',
60+
lang => {
61+
const sections = Object.keys(l10n[lang]);
62+
for (const section of EXPECTED_SECTIONS) {
63+
expect(sections).toContain(section);
64+
}
65+
expect(sections).toHaveLength(EXPECTED_SECTIONS.length);
66+
},
67+
);
7868

7969
it('l10n.en matches the raw en.json data', () => {
8070
expect(l10n.en).toEqual(enData);
8171
});
8272

83-
it('l10n.id contains Indonesian translations where they exist', () => {
84-
const idData = require('../id.json');
85-
expect(l10n.id.common.cancel).toBe(idData.common.cancel);
86-
expect(l10n.id.common.cancel).not.toBe(l10n.en.common.cancel);
87-
});
88-
89-
it('returns cached result on repeated access for id', () => {
90-
const first = l10n.id;
91-
const second = l10n.id;
92-
expect(first).toBe(second); // same reference = cached
93-
});
94-
95-
it('returns cached result on repeated access for zh', () => {
96-
const first = l10n.zh;
97-
const second = l10n.zh;
98-
expect(first).toBe(second); // same reference = cached
99-
});
100-
101-
it('l10n.ja contains Japanese translations where they exist', () => {
102-
const jaData = require('../ja.json');
103-
expect(l10n.ja.common.cancel).toBe(jaData.common.cancel);
104-
expect(l10n.ja.common.cancel).not.toBe(l10n.en.common.cancel);
105-
});
106-
107-
it('l10n.zh contains Chinese translations where they exist', () => {
108-
const zhData = require('../zh.json');
109-
expect(l10n.zh.common.cancel).toBe(zhData.common.cancel);
110-
expect(l10n.zh.common.cancel).not.toBe(l10n.en.common.cancel);
111-
});
73+
it.each(['he', 'id', 'ja', 'ko', 'ms', 'ru', 'zh'] as AvailableLanguage[])(
74+
'l10n.%s contains translations where they exist',
75+
lang => {
76+
const langData = require(`../${lang}.json`);
77+
expect(l10n[lang].common.cancel).toBe(langData.common.cancel);
78+
expect(l10n[lang].common.cancel).not.toBe(l10n.en.common.cancel);
79+
},
80+
);
81+
82+
it.each(['he', 'id', 'ja', 'ko', 'ms', 'ru', 'zh'] as AvailableLanguage[])(
83+
'returns cached result on repeated access for %s',
84+
lang => {
85+
const first = l10n[lang];
86+
const second = l10n[lang];
87+
expect(first).toBe(second); // same reference = cached
88+
},
89+
);
11290

11391
it('l10n.ja falls back to English for missing keys', () => {
11492
// Verify the merge mechanism by building a partial ja
@@ -138,26 +116,24 @@ describe('l10n object', () => {
138116
expect(enData).toEqual(enClone);
139117
});
140118

141-
it('returns cached result on repeated access', () => {
142-
const first = l10n.ja;
143-
const second = l10n.ja;
144-
expect(first).toBe(second); // same reference = cached
145-
});
146-
147119
it('supports in operator for all languages', () => {
148120
expect('en' in l10n).toBe(true);
121+
expect('he' in l10n).toBe(true);
149122
expect('id' in l10n).toBe(true);
150123
expect('ja' in l10n).toBe(true);
124+
expect('ko' in l10n).toBe(true);
125+
expect('ms' in l10n).toBe(true);
126+
expect('ru' in l10n).toBe(true);
151127
expect('zh' in l10n).toBe(true);
152128
expect('xx' in l10n).toBe(false);
153129
expect('fr' in l10n).toBe(false);
154130
});
155131

156132
it('returns undefined for unsupported language key', () => {
157133
// Access a property that does not exist on the l10n object
158-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
134+
159135
expect((l10n as any).xx).toBeUndefined();
160-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
136+
161137
expect((l10n as any).fr).toBeUndefined();
162138
});
163139

@@ -170,7 +146,7 @@ describe('l10n object', () => {
170146

171147
// Object.keys should enumerate property names without invoking getters
172148
const keys = Object.keys(freshModule.l10n);
173-
expect(keys).toEqual(['en', 'id', 'ja', 'zh']);
149+
expect(keys).toEqual(ALL_LANGUAGES);
174150

175151
// Cache should still only have en -- getters were not called
176152
expect(freshModule._testGetCacheKeys()).toEqual(['en']);
@@ -192,7 +168,7 @@ describe('l10n object', () => {
192168

193169
describe('exports', () => {
194170
it('supportedLanguages matches expected array', () => {
195-
expect(supportedLanguages).toEqual(['en', 'id', 'ja', 'zh']);
171+
expect(supportedLanguages).toEqual(ALL_LANGUAGES);
196172
});
197173

198174
it('languageDisplayNames has entries for all supported languages', () => {
@@ -204,9 +180,15 @@ describe('exports', () => {
204180

205181
it('languageDisplayNames contains expected values', () => {
206182
expect(languageDisplayNames.en).toBe('English (EN)');
183+
expect(languageDisplayNames.he).toBe('\u05E2\u05D1\u05E8\u05D9\u05EA (HE)');
184+
expect(languageDisplayNames.id).toBe('Indonesia (ID)');
207185
expect(languageDisplayNames.ja).toBe('\u65E5\u672C\u8A9E (JA)');
186+
expect(languageDisplayNames.ko).toBe('\uD55C\uAD6D\uC5B4 (KO)');
187+
expect(languageDisplayNames.ms).toBe('Melayu (MS)');
188+
expect(languageDisplayNames.ru).toBe(
189+
'\u0420\u0443\u0441\u0441\u043A\u0438\u0439 (RU)',
190+
);
208191
expect(languageDisplayNames.zh).toBe('\u4E2D\u6587 (ZH)');
209-
expect(languageDisplayNames.id).toBe('Indonesia (ID)');
210192
});
211193

212194
it('languageDisplayNames has exactly the same keys as supportedLanguages', () => {
@@ -235,64 +217,37 @@ describe('lazy loading', () => {
235217
});
236218
});
237219

238-
it('accessing a language populates the cache', () => {
239-
jest.isolateModules(() => {
240-
const freshModule = require('../index');
241-
242-
// Before access: only en
243-
expect(freshModule._testGetCacheKeys()).toEqual(['en']);
220+
it.each(['he', 'id', 'ja', 'ko', 'ms', 'ru', 'zh'] as AvailableLanguage[])(
221+
'accessing %s populates the cache',
222+
lang => {
223+
jest.isolateModules(() => {
224+
const freshModule = require('../index');
225+
expect(freshModule._testGetCacheKeys()).toEqual(['en']);
244226

245-
// Access ja
246-
const _ja = freshModule.l10n.ja;
247-
expect(_ja).toBeDefined();
248-
249-
// After access: en and ja
250-
expect(freshModule._testGetCacheKeys()).toContain('ja');
251-
});
252-
});
253-
254-
it('accessing id populates the cache', () => {
255-
jest.isolateModules(() => {
256-
const freshModule = require('../index');
257-
expect(freshModule._testGetCacheKeys()).toEqual(['en']);
258-
259-
const _id = freshModule.l10n.id;
260-
expect(_id).toBeDefined();
261-
expect(freshModule._testGetCacheKeys()).toContain('id');
262-
});
263-
});
264-
265-
it('accessing zh populates the cache', () => {
266-
jest.isolateModules(() => {
267-
const freshModule = require('../index');
268-
expect(freshModule._testGetCacheKeys()).toEqual(['en']);
269-
270-
const _zh = freshModule.l10n.zh;
271-
expect(_zh).toBeDefined();
272-
expect(freshModule._testGetCacheKeys()).toContain('zh');
273-
});
274-
});
227+
const _data = freshModule.l10n[lang];
228+
expect(_data).toBeDefined();
229+
expect(freshModule._testGetCacheKeys()).toContain(lang);
230+
});
231+
},
232+
);
275233

276234
it('accessing all languages populates the full cache', () => {
277235
jest.isolateModules(() => {
278236
const freshModule = require('../index');
279237
expect(freshModule._testGetCacheKeys()).toEqual(['en']);
280238

281239
// Access each non-en language
282-
const _id = freshModule.l10n.id;
283-
const _ja = freshModule.l10n.ja;
284-
const _zh = freshModule.l10n.zh;
285-
286-
expect(_id).toBeDefined();
287-
expect(_ja).toBeDefined();
288-
expect(_zh).toBeDefined();
240+
const nonEn = ALL_LANGUAGES.filter(l => l !== 'en');
241+
for (const lang of nonEn) {
242+
const _data = freshModule.l10n[lang];
243+
expect(_data).toBeDefined();
244+
}
289245

290246
const cacheKeys = freshModule._testGetCacheKeys();
291-
expect(cacheKeys).toContain('en');
292-
expect(cacheKeys).toContain('id');
293-
expect(cacheKeys).toContain('ja');
294-
expect(cacheKeys).toContain('zh');
295-
expect(cacheKeys).toHaveLength(4);
247+
for (const lang of ALL_LANGUAGES) {
248+
expect(cacheKeys).toContain(lang);
249+
}
250+
expect(cacheKeys).toHaveLength(ALL_LANGUAGES.length);
296251
});
297252
});
298253

@@ -305,13 +260,22 @@ describe('lazy loading', () => {
305260

306261
describe('type safety', () => {
307262
it('AvailableLanguage matches supported languages', () => {
308-
const keys: AvailableLanguage[] = ['en', 'id', 'ja', 'zh'];
263+
const keys: AvailableLanguage[] = ALL_LANGUAGES;
309264
expect(keys).toEqual(supportedLanguages);
310265
});
311266

312267
it('keyof typeof l10n resolves to literal union', () => {
313268
// At runtime we verify the keys match
314-
const keys: Array<keyof typeof l10n> = ['en', 'id', 'ja', 'zh'];
269+
const keys: Array<keyof typeof l10n> = [
270+
'en',
271+
'he',
272+
'id',
273+
'ja',
274+
'ko',
275+
'ms',
276+
'ru',
277+
'zh',
278+
];
315279
expect(Object.keys(l10n).sort()).toEqual(keys.sort());
316280

317281
// This would cause a compile error if the type were wrong:

src/locales/en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1104,7 +1104,7 @@
11041104
"tokensPerSecond": "t/s"
11051105
},
11061106
"actions": {
1107-
"deleteButton": "Delete",
1107+
"deleteButton": " ",
11081108
"submittedText": "✓ Shared to",
11091109
"leaderboardLink": "AI Phone Leaderboard ↗",
11101110
"cannotShare": "Cannot share",

0 commit comments

Comments
 (0)