返回 CodeWhale
dictionaries.test.ts
根目录 / web / lib / i18n / dictionaries.test.ts
1 import { existsSync } from "node:fs";
2 import { describe, expect, it } from "vitest";
3 import {
4 DICTIONARY_LOCALES,
5 EN_CHROME,
6 EN_DOCS_GUIDE,
7 EN_DOCS_HOOKS,
8 EN_DOCS_MCP,
9 EN_DOCS_RUNTIME_API,
10 EN_DOCS_SANDBOX,
11 EN_DOCS_SUBAGENTS,
12 EN_DOCS_WEB,
13 EN_DOCS_WORK,
14 EN_DOCS_COMPUTERS,
15 EN_DOCS_AUTH,
16 EN_DOCS_TRUST,
17 EN_DOCS_REVIEW,
18 EN_DOCS_VOCABULARY,
19 EN_COMPUTER_USE,
20 EN_CHANGELOG,
21 EN_DOCS_SHELL,
22 EN_DOCS_TROUBLESHOOTING,
23 EN_DIGEST,
24 EN_FEED,
25 EN_COMMUNITY,
26 EN_FAQ,
27 EN_HOME,
28 EN_LEGAL_PRIVACY,
29 EN_LEGAL_TERMS,
30 EN_ROADMAP,
31 EN_CONTRIBUTE,
32 EN_CONSTITUTION,
33 EN_RUNTIME,
34 fill,
35 getChrome,
36 getDocsGuide,
37 getDocsHooks,
38 getDocsMcp,
39 getDocsRuntimeApi,
40 getDocsSandbox,
41 getDocsSubagents,
42 getDocsWeb,
43 getDocsWork,
44 getDocsComputers,
45 getDocsAuth,
46 getDocsTrust,
47 getComputerUse,
48 getChangelog,
49 getDocsShell,
50 getDocsTroubleshooting,
51 getDocsConfiguration,
52 getDocsFleet,
53 getDocsModes,
54 getDocsReview,
55 getDocsVocabulary,
56 getDigest,
57 getFeed,
58 getCommunity,
59 getFaq,
60 getHome,
61 getLegalPrivacy,
62 getLegalTerms,
63 getRoadmap,
64 getContribute,
65 getConstitution,
66 getRuntime,
67 pickText,
68 pickTextLocale,
69 splitToken,
70 } from "./dictionaries";
71 import { locales, partialLocales } from "./config";
72 import type { ChromeDict, HomeDict } from "./dictionaries/types";
73
74 /**
75 * Keys whose value is a mark, a proper noun, or a formatting tag rather
76 * than prose — a locale sharing English's value here is correct, not a
77 * missing translation.
78 */
79 const NON_PROSE_KEYS = new Set([
80 "dateLocale",
81 ]);
82
83 /** Chrome keys that are real sentences/labels and must be translated. */
84 const CHROME_PROSE_KEYS = [
85 "skipToContent",
86 "navDocs",
87 "navProduct",
88 "navCommunity",
89 "navPrimaryAria",
90 "navHomeAria",
91 "menuOpen",
92 "menuClose",
93 "themeAria",
94 "themeTitle",
95 "footerTagline",
96 "footerProduct",
97 "footerProject",
98 "footerGuide",
99 "footerCanonicalSource",
100 "footerReleasesLink",
101 "switcherLabel",
102 "switcherSwitchTo",
103 "partialBadge",
104 ] as const satisfies readonly (keyof ChromeDict)[];
105
106 /**
107 * Locale/key pairs whose English-identical value is a native loanword in
108 * that locale, not a missing translation — asserted below so the equality
109 * is deliberate and visible. German "Community" matches the TUI pack
110 * (crates/tui/locales/de.json: "Community & Mitwirken").
111 */
112 const CHROME_LOANWORDS: Record<string, readonly string[]> = {
113 de: ["navCommunity"],
114 };
115
116 /** Home keys that are real sentences and must be translated. */
117 const HOME_PROSE_KEYS = [
118 "metaTitle",
119 "metaDescription",
120 "heroTitle",
121 "heroIntro",
122 "getCodewhale",
123 "heroInstallAria",
124 "exploreProduct",
125 "shotPreview",
126 "shotBuild",
127 "screenshotAlt",
128 "gainHeading",
129 "gainLede",
130 "modelsHeading",
131 "modelsBody",
132 "modelsLink",
133 "startHeading",
134 "startLede",
135 "startGuideLink",
136 "startVocabularyLink",
137 "availabilityHeading",
138 "availabilityLede",
139 "availabilityNote",
140 "accountLink",
141 "surfacesHeading",
142 "runtimeLink",
143 "installBandHeading",
144 "installGuideLink",
145 "communityHeading",
146 "communityBody",
147 "communityLinksAria",
148 ] as const satisfies readonly (keyof HomeDict)[];
149
150 function templateTokens(value: string): string[] {
151 return [...value.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort();
152 }
153
154 function flattenStrings(dict: object): Record<string, string> {
155 const out: Record<string, string> = {};
156 for (const [key, value] of Object.entries(dict)) {
157 if (typeof value === "string") {
158 out[key] = value;
159 } else if (Array.isArray(value)) {
160 value.forEach((row: string[], i: number) => {
161 row.forEach((cell, j) => {
162 out[`${key}[${i}][${j}]`] = cell;
163 });
164 });
165 }
166 }
167 return out;
168 }
169
170 describe("website dictionaries", () => {
171 it("cover every routed locale except the English reference", () => {
172 expect([...DICTIONARY_LOCALES].sort()).toEqual(
173 [
174 "zh", "es", "id", "ja", "ko", "pt-BR", "ru", "uk", "vi",
175 "fr", "de", "ca", "hi", "tr", "it", "pl", "ar",
176 ].sort(),
177 );
178 // Chinese is dictionary-backed like every other locale — no inline
179 // en/zh special case survives in the page/component sources (#4934).
180 expect(DICTIONARY_LOCALES).toContain("zh");
181 // Every routed locale either has its own dictionary or *is* English.
182 for (const locale of locales) {
183 expect(
184 locale === "en" || DICTIONARY_LOCALES.includes(locale),
185 `${locale} has no dictionary`,
186 ).toBe(true);
187 }
188 // Every partial locale is dictionary-backed, so the partial badge marks
189 // untranslated page bodies — never untranslated chrome.
190 for (const locale of partialLocales) {
191 expect(DICTIONARY_LOCALES, `${locale} partial pack`).toContain(locale);
192 }
193 });
194
195 it("holds every dictionary to exact key parity with the English reference", () => {
196 const enChromeKeys = Object.keys(EN_CHROME).sort();
197 const enHomeKeys = Object.keys(EN_HOME).sort();
198 for (const locale of DICTIONARY_LOCALES) {
199 expect(Object.keys(getChrome(locale)).sort(), `${locale} chrome keys`).toEqual(
200 enChromeKeys,
201 );
202 expect(Object.keys(getHome(locale)).sort(), `${locale} home keys`).toEqual(
203 enHomeKeys,
204 );
205 }
206 });
207
208 it("preserves {token} template placeholders through translation", () => {
209 const enChromeTokens = flattenStrings(EN_CHROME);
210 const enHomeTokens = flattenStrings(EN_HOME);
211 for (const locale of DICTIONARY_LOCALES) {
212 const chrome = flattenStrings(getChrome(locale));
213 const home = flattenStrings(getHome(locale));
214 for (const key of Object.keys(enChromeTokens)) {
215 expect(templateTokens(chrome[key]), `${locale} chrome ${key}`).toEqual(
216 templateTokens(enChromeTokens[key]),
217 );
218 }
219 for (const key of Object.keys(enHomeTokens)) {
220 expect(templateTokens(home[key]), `${locale} home ${key}`).toEqual(
221 templateTokens(enHomeTokens[key]),
222 );
223 }
224 }
225 });
226
227 it("holds every shipped page dictionary to key parity and English fallback (#5337)", () => {
228 const enKeys = Object.keys(EN_DOCS_GUIDE).sort();
229 for (const locale of [...DICTIONARY_LOCALES, "fr", "und"]) {
230 // Page dictionaries are optional per locale: whatever getDocsGuide
231 // resolves — the locale's own file or the English fallback — must
232 // carry the exact reference shape, so a page never sees a missing key.
233 expect(Object.keys(getDocsGuide(locale)).sort(), `${locale} docs-guide keys`).toEqual(
234 enKeys,
235 );
236 }
237 // zh ships a real translation, not an English pass-through.
238 expect(getDocsGuide("zh").overviewTitle).not.toBe(EN_DOCS_GUIDE.overviewTitle);
239 // The wave-2 locales ship docs-guide too, translated rather than passed through.
240 for (const locale of ["fr", "de", "ca", "hi", "tr", "it", "pl", "ar"]) {
241 expect(getDocsGuide(locale).overviewTitle, `${locale} docs-guide`).not.toBe(
242 EN_DOCS_GUIDE.overviewTitle,
243 );
244 }
245 // A locale without the file falls back to the English reference object.
246 expect(getDocsGuide("ja")).toBe(EN_DOCS_GUIDE);
247 });
248
249 it("holds the docs shell dictionary to the same contract (#5337)", () => {
250 const enKeys = Object.keys(EN_DOCS_SHELL).sort();
251 for (const locale of [...DICTIONARY_LOCALES, "fr", "und"]) {
252 expect(Object.keys(getDocsShell(locale)).sort(), `${locale} docs-shell keys`).toEqual(
253 enKeys,
254 );
255 }
256 // zh ships a real translation, not an English pass-through.
257 expect(getDocsShell("zh").heroTitle).not.toBe(EN_DOCS_SHELL.heroTitle);
258 // Every other locale renders the English shell today, exactly as the
259 // `isZh` ternaries in docs/layout.tsx did before the move.
260 for (const locale of ["ja", "fr", "ar", "und"]) {
261 expect(getDocsShell(locale), `${locale} docs-shell`).toBe(EN_DOCS_SHELL);
262 }
263 });
264
265 it("holds the docs page-body dictionaries to the same contract (#5337)", () => {
266 for (const [label, get, reference] of [
267 ["docs-hooks", getDocsHooks, EN_DOCS_HOOKS],
268 ["docs-troubleshooting", getDocsTroubleshooting, EN_DOCS_TROUBLESHOOTING],
269 ["docs-runtime-api", getDocsRuntimeApi, EN_DOCS_RUNTIME_API],
270 ["docs-sandbox", getDocsSandbox, EN_DOCS_SANDBOX],
271 ["docs-subagents", getDocsSubagents, EN_DOCS_SUBAGENTS],
272 ["docs-mcp", getDocsMcp, EN_DOCS_MCP],
273 ["docs-web", getDocsWeb, EN_DOCS_WEB],
274 ["docs-work", getDocsWork, EN_DOCS_WORK],
275 ["docs-computers", getDocsComputers, EN_DOCS_COMPUTERS],
276 ["docs-auth", getDocsAuth, EN_DOCS_AUTH],
277 ["docs-trust", getDocsTrust, EN_DOCS_TRUST],
278 ["docs-review", getDocsReview, EN_DOCS_REVIEW],
279 ["docs-vocabulary", getDocsVocabulary, EN_DOCS_VOCABULARY],
280 ["changelog", getChangelog, EN_CHANGELOG],
281 ["legal-terms", getLegalTerms, EN_LEGAL_TERMS],
282 ["legal-privacy", getLegalPrivacy, EN_LEGAL_PRIVACY],
283 ["digest", getDigest, EN_DIGEST],
284 ["feed", getFeed, EN_FEED],
285 ["community", getCommunity, EN_COMMUNITY],
286 ["faq", getFaq, EN_FAQ],
287 ["roadmap", getRoadmap, EN_ROADMAP],
288 ["contribute", getContribute, EN_CONTRIBUTE],
289 ["constitution", getConstitution, EN_CONSTITUTION],
290 ["runtime", getRuntime, EN_RUNTIME],
291 ] as const) {
292 const enKeys = Object.keys(reference).sort();
293 for (const locale of [...DICTIONARY_LOCALES, "fr", "und"]) {
294 expect(Object.keys(get(locale)).sort(), `${locale} ${label} keys`).toEqual(enKeys);
295 }
296 // zh ships a real translation, not an English pass-through. The probe is
297 // `metaTitle` rather than `overviewTitle` because docs/mcp's heading is
298 // the code-owned literal `MCP` and stays in the page.
299 expect(get("zh").metaTitle, `zh ${label}`).not.toBe(reference.metaTitle);
300 // Every other locale renders English today, exactly as the `isZh`
301 // ternaries in the page did before the move.
302 for (const locale of ["ja", "fr", "ar", "und"]) {
303 expect(get(locale), `${locale} ${label}`).toBe(reference);
304 }
305 }
306 });
307
308 it("ships the Computer Use page dictionary for every routed locale", () => {
309 const enKeys = Object.keys(EN_COMPUTER_USE).sort();
310 for (const locale of [...DICTIONARY_LOCALES, "und"]) {
311 expect(Object.keys(getComputerUse(locale)).sort(), `${locale} computer-use keys`).toEqual(enKeys);
312 expect(getComputerUse(locale).steps, `${locale} computer-use steps`).toHaveLength(4);
313 }
314 // The download page is translated for every routed locale, not passed
315 // through: each dictionary locale resolves its own object with its own
316 // primary-button label; only an unknown locale gets the English reference.
317 for (const locale of DICTIONARY_LOCALES) {
318 expect(getComputerUse(locale), `${locale} computer-use`).not.toBe(EN_COMPUTER_USE);
319 expect(getComputerUse(locale).download, `${locale} computer-use download`).not.toBe(EN_COMPUTER_USE.download);
320 }
321 expect(getComputerUse("und")).toBe(EN_COMPUTER_USE);
322 });
323
324 it("keeps every docs task page aligned between English and Chinese", () => {
325 const routeExists = (href: string) =>
326 existsSync(new URL(`../../app/[locale]${href.split("#")[0]}/page.tsx`, import.meta.url));
327 const internalLinks = (text: string) =>
328 [...text.matchAll(/\]\((\/[^)\s]*)\)/g)].map((m) => m[1]);
329 for (const [label, get] of [
330 ["docs-auth", getDocsAuth],
331 ["docs-computers", getDocsComputers],
332 ["docs-configuration", getDocsConfiguration],
333 ["docs-fleet", getDocsFleet],
334 ["docs-hooks", getDocsHooks],
335 ["docs-mcp", getDocsMcp],
336 ["docs-modes", getDocsModes],
337 ["docs-review", getDocsReview],
338 ["docs-runtime-api", getDocsRuntimeApi],
339 ["docs-sandbox", getDocsSandbox],
340 ["docs-subagents", getDocsSubagents],
341 ["docs-troubleshooting", getDocsTroubleshooting],
342 ["docs-trust", getDocsTrust],
343 ["docs-web", getDocsWeb],
344 ["docs-work", getDocsWork],
345 ] as const) {
346 const en = get("en");
347 const zh = get("zh");
348 expect(zh, label).not.toBe(en);
349 // Same sections, in the same order, with the same block shapes.
350 expect(zh.sections.map((x) => x.id), label).toEqual(en.sections.map((x) => x.id));
351 en.sections.forEach((section, i) => {
352 const other = zh.sections[i].blocks;
353 expect(other.map((b) => Object.keys(b)[0]), `${label}#${section.id}`).toEqual(
354 section.blocks.map((b) => Object.keys(b)[0]),
355 );
356 section.blocks.forEach((block, j) => {
357 const peer = other[j];
358 // Commands are shown verbatim: a translation never edits one.
359 if ("code" in block && "code" in peer) {
360 expect(peer.code, `${label}#${section.id} code`).toBe(block.code);
361 }
362 if ("rows" in block && "rows" in peer) {
363 expect(peer.rows.length, `${label}#${section.id} rows`).toBe(block.rows.length);
364 if (block.codeTerms) {
365 expect(peer.rows.map(([term]) => term)).toEqual(block.rows.map(([term]) => term));
366 }
367 }
368 if ("steps" in block && "steps" in peer) {
369 expect(peer.steps.length, `${label}#${section.id} steps`).toBe(block.steps.length);
370 }
371 if ("list" in block && "list" in peer) {
372 expect(peer.list.length, `${label}#${section.id} list`).toBe(block.list.length);
373 }
374 });
375 });
376 expect(zh.next.map((n) => n.href), label).toEqual(en.next.map((n) => n.href));
377 // Every internal link — inline or "next" — lands on a real page.
378 for (const dict of [en, zh]) {
379 const text = JSON.stringify(dict.sections);
380 for (const href of [...internalLinks(text), ...dict.next.map((n) => n.href)]) {
381 expect(routeExists(href), `${label}: ${href}`).toBe(true);
382 }
383 }
384 }
385 });
386
387 it("pickText selects the locale side of legacy { en, zh } pairs", () => {
388 const pair = { en: "English", zh: "中文" };
389 expect(pickText(pair, "zh")).toBe("中文");
390 expect(pickText(pair, "en")).toBe("English");
391 expect(pickText(pair, "ja"), "non-zh locales read the English side").toBe("English");
392 expect(pickTextLocale("zh")).toBe("zh");
393 for (const locale of ["en", "ja", "ar"]) expect(pickTextLocale(locale)).toBe("en");
394 });
395
396 it("keeps the gain, models, availability, and surface lists structurally aligned", () => {
397 for (const locale of DICTIONARY_LOCALES) {
398 const home = getHome(locale);
399 expect(home.gain, `${locale} gain`).toHaveLength(3);
400 expect(home.modelsFacts, `${locale} modelsFacts`).toHaveLength(3);
401 expect(home.availability, `${locale} availability`).toHaveLength(5);
402 expect(home.surfaces, `${locale} surfaces`).toHaveLength(5);
403 for (const row of [...home.gain, ...home.modelsFacts, ...home.availability, ...home.surfaces]) {
404 for (const cell of row) {
405 if (cell === undefined) continue;
406 expect(cell.length, `${locale} empty cell`).toBeGreaterThan(0);
407 }
408 }
409 }
410 });
411
412 it("links the community VS Code GUI to its Marketplace listing in every locale", () => {
413 const marketplace = "https://marketplace.visualstudio.com/items?itemName=HengQuWorld.brotherwhale-vscode";
414 for (const locale of ["en", ...DICTIONARY_LOCALES]) {
415 const row = getHome(locale).availability.find(([surface]) => surface.startsWith("CodeWhale GUI"));
416 expect(row?.[3], `${locale} GUI Marketplace link`).toBe(marketplace);
417 }
418 });
419
420 it("falls back to the English dictionary for unrouted locales — no missing markers", () => {
421 for (const key of Object.keys(EN_CHROME) as (keyof ChromeDict)[]) {
422 expect(getChrome("xx")[key]).toBe(EN_CHROME[key]);
423 expect(getChrome("en")[key]).toBe(EN_CHROME[key]);
424 }
425 for (const key of Object.keys(EN_HOME) as (keyof HomeDict)[]) {
426 expect(getHome("xx")[key]).toEqual(EN_HOME[key]);
427 }
428 });
429
430 it("has no empty strings anywhere", () => {
431 for (const locale of ["en", ...DICTIONARY_LOCALES]) {
432 for (const [key, value] of Object.entries(flattenStrings(getChrome(locale)))) {
433 expect(value.trim().length, `${locale} chrome ${key}`).toBeGreaterThan(0);
434 }
435 for (const [key, value] of Object.entries(flattenStrings(getHome(locale)))) {
436 expect(value.trim().length, `${locale} home ${key}`).toBeGreaterThan(0);
437 }
438 }
439 });
440
441 it("keeps the Cyrillic packs script-pure (no cross-leakage, no mixed copy)", () => {
442 const cyrillic = /[Ѐ-ӿ]/;
443 for (const [key, value] of Object.entries(flattenStrings(getChrome("uk")))) {
444 expect(value, `uk chrome ${key}`).not.toMatch(/[ыэъЫЭЪ]/);
445 void cyrillic;
446 }
447 for (const [key, value] of Object.entries(flattenStrings(getHome("uk")))) {
448 expect(value, `uk home ${key}`).not.toMatch(/[ыэъЫЭЪ]/);
449 }
450 for (const [key, value] of Object.entries(flattenStrings(getChrome("ru")))) {
451 expect(value, `ru chrome ${key}`).not.toMatch(/[іІїЇєЄґҐ]/);
452 }
453 for (const [key, value] of Object.entries(flattenStrings(getHome("ru")))) {
454 expect(value, `ru home ${key}`).not.toMatch(/[іІїЇєЄґҐ]/);
455 }
456 // Prose values are actually translated, not English pass-through.
457 expect(getHome("ru").heroIntro).toMatch(cyrillic);
458 expect(getHome("uk").heroIntro).toMatch(cyrillic);
459 expect(getChrome("ru").navDocs).not.toBe(EN_CHROME.navDocs);
460 expect(getChrome("uk").navDocs).not.toBe(EN_CHROME.navDocs);
461 expect(getChrome("ru").navDocs).not.toBe(getChrome("uk").navDocs);
462 });
463
464 it("keeps the Chinese pack in Han script for prose (no English pass-through)", () => {
465 const han = /[一-鿿]/;
466 const chrome = getChrome("zh");
467 const home = getHome("zh");
468 for (const key of CHROME_PROSE_KEYS) {
469 expect(chrome[key], `zh chrome ${key}`).toMatch(han);
470 }
471 for (const key of HOME_PROSE_KEYS) {
472 expect(home[key], `zh home ${key}`).toMatch(han);
473 }
474 // Chinese resolves to its OWN dictionary, not the English reference.
475 expect(chrome.navDocs).not.toBe(EN_CHROME.navDocs);
476 expect(home.heroTitle).not.toBe(EN_HOME.heroTitle);
477 });
478
479 it("leaves no unmarked English prose in any non-English dictionary", () => {
480 for (const locale of DICTIONARY_LOCALES) {
481 const chrome = getChrome(locale);
482 const home = getHome(locale);
483 const loanwords = new Set(CHROME_LOANWORDS[locale] ?? []);
484 for (const key of CHROME_PROSE_KEYS) {
485 if (loanwords.has(key)) {
486 // A documented loanword: the shared value IS the native word.
487 expect(chrome[key], `${locale} chrome ${key} loanword`).toBe(EN_CHROME[key]);
488 continue;
489 }
490 expect(chrome[key], `${locale} chrome ${key} is English pass-through`).not.toBe(
491 EN_CHROME[key],
492 );
493 }
494 for (const key of HOME_PROSE_KEYS) {
495 expect(home[key], `${locale} home ${key} is English pass-through`).not.toBe(
496 EN_HOME[key],
497 );
498 }
499 }
500 });
501
502 it("keeps marks, tags, and proper nouns out of the translated-prose rule", () => {
503 // Documents the deliberate exceptions so a future audit does not read a
504 // shared value here as a missing translation.
505 for (const key of NON_PROSE_KEYS) {
506 const inChrome = key in EN_CHROME;
507 const inHome = key in EN_HOME;
508 expect(inChrome || inHome, `${key} is not a real dictionary key`).toBe(true);
509 expect(CHROME_PROSE_KEYS as readonly string[]).not.toContain(key);
510 expect(HOME_PROSE_KEYS as readonly string[]).not.toContain(key);
511 }
512 });
513
514 it("carries the {brand} token through every hero lede for splitToken()", () => {
515 for (const locale of ["en", ...DICTIONARY_LOCALES]) {
516 const lede = getHome(locale).heroIntro;
517 expect(lede, `${locale} heroIntro`).toContain("{brand}");
518 const parts = splitToken(lede, "brand");
519 expect(parts.length, `${locale} heroIntro brand split`).toBe(2);
520 expect(parts.join("").includes("{brand}")).toBe(false);
521 }
522 });
523
524 it("carries {date} through both legal pages' effective-date line", () => {
525 // The date used to sit in the JSX between two translated fragments. Now
526 // it is a fill() token, so a translation that drops it would ship a legal
527 // page that no longer says when it took effect.
528 for (const locale of ["en", "zh"]) {
529 expect(getLegalTerms(locale).updated, `${locale} legal-terms`).toContain("{date}");
530 expect(getLegalPrivacy(locale).updated, `${locale} legal-privacy`).toContain("{date}");
531 }
532 });
533
534 it("interpolates templates with fill() and leaves unknown tokens visible", () => {
535 expect(fill("Latest release {tag}", { tag: "v0.9.2" })).toBe("Latest release v0.9.2");
536 expect(fill("{count} provider routes", { count: 30 })).toBe("30 provider routes");
537 expect(fill("v{version} {state}", { version: "0.9.2" })).toBe("v0.9.2 {state}");
538 });
539 });
540
540 lines TYPESCRIPT