| 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 |