返回 CodeWhale
dictionaries.test.ts
根目录 / web / lib / i18n / dictionaries.test.ts
1 import { describe, expect, it } from "vitest";
2 import {
3 DICTIONARY_LOCALES,
4 EN_CHROME,
5 EN_HOME,
6 fill,
7 getChrome,
8 getHome,
9 splitToken,
10 } from "./dictionaries";
11 import { locales, partialLocales } from "./config";
12 import type { ChromeDict, HomeDict } from "./dictionaries/types";
13
14 /**
15 * Keys whose value is a mark, a proper noun, or a formatting tag rather
16 * than prose — a locale sharing English's value here is correct, not a
17 * missing translation.
18 */
19 const NON_PROSE_KEYS = new Set([
20 "wordmarkSeal",
21 "dateLocale",
22 "githubFallback",
23 "tickerLiveTag",
24 "sealDecides",
25 "sealWorkflow",
26 "sealStart",
27 "sealBoundaries",
28 "sealSurfaces",
29 "sealCommunity",
30 ]);
31
32 /** Chrome keys that are real sentences/labels and must be translated. */
33 const CHROME_PROSE_KEYS = [
34 "skipToContent",
35 "navDocs",
36 "navCommunity",
37 "navPrimaryAria",
38 "navHomeAria",
39 "wordmarkTag",
40 "starsAria",
41 "traceLabel",
42 "traceTabsAria",
43 "menuOpen",
44 "menuClose",
45 "themeAria",
46 "themeTitle",
47 "footerTagline",
48 "footerProduct",
49 "footerProject",
50 "footerGuide",
51 "footerCanonicalSource",
52 "footerReleasesLink",
53 "switcherLabel",
54 "switcherSwitchTo",
55 "partialBadge",
56 // Ticker chrome. The repository's own record (titles, handles, tags) stays
57 // verbatim, but the verbs the strip prints around it are copy.
58 "tickerMerged",
59 "tickerOpened",
60 "tickerClosed",
61 "tickerReleased",
62 "tickerFirstContribution",
63 "tickerBy",
64 "tickerAria",
65 ] as const satisfies readonly (keyof ChromeDict)[];
66
67 /** Home keys that are real sentences and must be translated. */
68 const HOME_PROSE_KEYS = [
69 "metaTitle",
70 "metaDescription",
71 "kicker",
72 "heroTitleA",
73 "heroTitleB",
74 "heroIntro",
75 "installEyebrow",
76 "installRequirement",
77 "installOtherWays",
78 "shotSession",
79 "screenshotAlt",
80 "figcaption",
81 "proofHeading",
82 "proofBody",
83 "decidesEyebrow",
84 "decidesHeading",
85 "decidesLede",
86 "workflowHeading",
87 "receiptAria",
88 "receiptInspect",
89 "receiptAct",
90 "receiptReport",
91 "startHeading",
92 "startLede",
93 "startGuideLink",
94 "startVocabularyLink",
95 "boundariesBody",
96 "hostedGatewayLocal",
97 "surfacesHeading",
98 "runtimeLink",
99 "installBandHeading",
100 "installGuideLink",
101 "communityHeading",
102 "communityBody",
103 "communityLinksAria",
104 ] as const satisfies readonly (keyof HomeDict)[];
105
106 function templateTokens(value: string): string[] {
107 return [...value.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort();
108 }
109
110 function flattenStrings(dict: object): Record<string, string> {
111 const out: Record<string, string> = {};
112 for (const [key, value] of Object.entries(dict)) {
113 if (typeof value === "string") {
114 out[key] = value;
115 } else if (Array.isArray(value)) {
116 value.forEach((pair, i) => {
117 out[`${key}[${i}][0]`] = pair[0];
118 out[`${key}[${i}][1]`] = pair[1];
119 });
120 }
121 }
122 return out;
123 }
124
125 describe("website dictionaries", () => {
126 it("cover every routed locale except the English reference", () => {
127 expect([...DICTIONARY_LOCALES].sort()).toEqual(
128 ["zh", "es", "id", "ja", "ko", "pt-BR", "ru", "uk", "vi"].sort(),
129 );
130 // Chinese is dictionary-backed like every other locale — no inline
131 // en/zh special case survives in the page/component sources (#4934).
132 expect(DICTIONARY_LOCALES).toContain("zh");
133 // Every routed locale either has its own dictionary or *is* English.
134 for (const locale of locales) {
135 expect(
136 locale === "en" || DICTIONARY_LOCALES.includes(locale),
137 `${locale} has no dictionary`,
138 ).toBe(true);
139 }
140 // Every partial locale is dictionary-backed, so the partial badge marks
141 // untranslated page bodies — never untranslated chrome.
142 for (const locale of partialLocales) {
143 expect(DICTIONARY_LOCALES, `${locale} partial pack`).toContain(locale);
144 }
145 });
146
147 it("holds every dictionary to exact key parity with the English reference", () => {
148 const enChromeKeys = Object.keys(EN_CHROME).sort();
149 const enHomeKeys = Object.keys(EN_HOME).sort();
150 for (const locale of DICTIONARY_LOCALES) {
151 expect(Object.keys(getChrome(locale)).sort(), `${locale} chrome keys`).toEqual(
152 enChromeKeys,
153 );
154 expect(Object.keys(getHome(locale)).sort(), `${locale} home keys`).toEqual(
155 enHomeKeys,
156 );
157 }
158 });
159
160 it("preserves {token} template placeholders through translation", () => {
161 const enChromeTokens = flattenStrings(EN_CHROME);
162 const enHomeTokens = flattenStrings(EN_HOME);
163 for (const locale of DICTIONARY_LOCALES) {
164 const chrome = flattenStrings(getChrome(locale));
165 const home = flattenStrings(getHome(locale));
166 for (const key of Object.keys(enChromeTokens)) {
167 expect(templateTokens(chrome[key]), `${locale} chrome ${key}`).toEqual(
168 templateTokens(enChromeTokens[key]),
169 );
170 }
171 for (const key of Object.keys(enHomeTokens)) {
172 expect(templateTokens(home[key]), `${locale} home ${key}`).toEqual(
173 templateTokens(enHomeTokens[key]),
174 );
175 }
176 }
177 });
178
179 it("keeps workflow and surface lists structurally aligned", () => {
180 for (const locale of DICTIONARY_LOCALES) {
181 const home = getHome(locale);
182 expect(home.workflow, `${locale} workflow`).toHaveLength(4);
183 expect(home.surfaces, `${locale} surfaces`).toHaveLength(5);
184 for (const pair of [...home.workflow, ...home.surfaces]) {
185 expect(pair[0].length, `${locale} empty title`).toBeGreaterThan(0);
186 expect(pair[1].length, `${locale} empty description`).toBeGreaterThan(0);
187 }
188 }
189 });
190
191 it("falls back to the English dictionary for unrouted locales — no missing markers", () => {
192 for (const key of Object.keys(EN_CHROME) as (keyof ChromeDict)[]) {
193 expect(getChrome("fr")[key]).toBe(EN_CHROME[key]);
194 expect(getChrome("en")[key]).toBe(EN_CHROME[key]);
195 }
196 for (const key of Object.keys(EN_HOME) as (keyof HomeDict)[]) {
197 expect(getHome("de")[key]).toEqual(EN_HOME[key]);
198 }
199 });
200
201 it("has no empty strings anywhere", () => {
202 for (const locale of ["en", ...DICTIONARY_LOCALES]) {
203 for (const [key, value] of Object.entries(flattenStrings(getChrome(locale)))) {
204 expect(value.trim().length, `${locale} chrome ${key}`).toBeGreaterThan(0);
205 }
206 for (const [key, value] of Object.entries(flattenStrings(getHome(locale)))) {
207 expect(value.trim().length, `${locale} home ${key}`).toBeGreaterThan(0);
208 }
209 }
210 });
211
212 it("keeps the Cyrillic packs script-pure (no cross-leakage, no mixed copy)", () => {
213 const cyrillic = /[Ѐ-ӿ]/;
214 for (const [key, value] of Object.entries(flattenStrings(getChrome("uk")))) {
215 expect(value, `uk chrome ${key}`).not.toMatch(/[ыэъЫЭЪ]/);
216 void cyrillic;
217 }
218 for (const [key, value] of Object.entries(flattenStrings(getHome("uk")))) {
219 expect(value, `uk home ${key}`).not.toMatch(/[ыэъЫЭЪ]/);
220 }
221 for (const [key, value] of Object.entries(flattenStrings(getChrome("ru")))) {
222 expect(value, `ru chrome ${key}`).not.toMatch(/[іІїЇєЄґҐ]/);
223 }
224 for (const [key, value] of Object.entries(flattenStrings(getHome("ru")))) {
225 expect(value, `ru home ${key}`).not.toMatch(/[іІїЇєЄґҐ]/);
226 }
227 // Prose values are actually translated, not English pass-through.
228 expect(getHome("ru").heroIntro).toMatch(cyrillic);
229 expect(getHome("uk").heroIntro).toMatch(cyrillic);
230 expect(getChrome("ru").navDocs).not.toBe(EN_CHROME.navDocs);
231 expect(getChrome("uk").navDocs).not.toBe(EN_CHROME.navDocs);
232 expect(getChrome("ru").navDocs).not.toBe(getChrome("uk").navDocs);
233 });
234
235 it("keeps the Chinese pack in Han script for prose (no English pass-through)", () => {
236 const han = /[一-鿿]/;
237 const chrome = getChrome("zh");
238 const home = getHome("zh");
239 for (const key of CHROME_PROSE_KEYS) {
240 expect(chrome[key], `zh chrome ${key}`).toMatch(han);
241 }
242 for (const key of HOME_PROSE_KEYS) {
243 expect(home[key], `zh home ${key}`).toMatch(han);
244 }
245 // Chinese resolves to its OWN dictionary, not the English reference.
246 expect(chrome.navDocs).not.toBe(EN_CHROME.navDocs);
247 expect(home.heroTitleA).not.toBe(EN_HOME.heroTitleA);
248 });
249
250 it("leaves no unmarked English prose in any non-English dictionary", () => {
251 for (const locale of DICTIONARY_LOCALES) {
252 const chrome = getChrome(locale);
253 const home = getHome(locale);
254 for (const key of CHROME_PROSE_KEYS) {
255 expect(chrome[key], `${locale} chrome ${key} is English pass-through`).not.toBe(
256 EN_CHROME[key],
257 );
258 }
259 for (const key of HOME_PROSE_KEYS) {
260 expect(home[key], `${locale} home ${key} is English pass-through`).not.toBe(
261 EN_HOME[key],
262 );
263 }
264 }
265 });
266
267 it("keeps marks, tags, and proper nouns out of the translated-prose rule", () => {
268 // Documents the deliberate exceptions so a future audit does not read a
269 // shared value here as a missing translation.
270 for (const key of NON_PROSE_KEYS) {
271 const inChrome = key in EN_CHROME;
272 const inHome = key in EN_HOME;
273 expect(inChrome || inHome, `${key} is not a real dictionary key`).toBe(true);
274 expect(CHROME_PROSE_KEYS as readonly string[]).not.toContain(key);
275 expect(HOME_PROSE_KEYS as readonly string[]).not.toContain(key);
276 }
277 });
278
279 it("carries the {brand} token through every hero lede for splitToken()", () => {
280 for (const locale of ["en", ...DICTIONARY_LOCALES]) {
281 const lede = getHome(locale).heroIntro;
282 expect(lede, `${locale} heroIntro`).toContain("{brand}");
283 const parts = splitToken(lede, "brand");
284 expect(parts.length, `${locale} heroIntro brand split`).toBe(2);
285 expect(parts.join("").includes("{brand}")).toBe(false);
286 }
287 });
288
289 it("carries the {handle} token through every ticker by-line", () => {
290 // components/ticker.tsx splits on the token so the handle is typeset in
291 // its own element. A locale that drops it would print a by-line with no
292 // contributor in it — the opposite of the point.
293 for (const locale of ["en", ...DICTIONARY_LOCALES]) {
294 const byLine = getChrome(locale).tickerBy;
295 expect(byLine, `${locale} tickerBy`).toContain("{handle}");
296 const parts = splitToken(byLine, "handle");
297 expect(parts.length, `${locale} tickerBy split`).toBe(2);
298 }
299 });
300
301 it("interpolates templates with fill() and leaves unknown tokens visible", () => {
302 expect(fill("Latest release {tag}", { tag: "v0.9.2" })).toBe("Latest release v0.9.2");
303 expect(fill("{count} provider routes", { count: 30 })).toBe("30 provider routes");
304 expect(fill("v{version} {state}", { version: "0.9.2" })).toBe("v0.9.2 {state}");
305 });
306 });
307
307 lines TYPESCRIPT