From f56dd608688740e14f0b67782682e917f7559baa Mon Sep 17 00:00:00 2001 From: Christian Visintin Date: Sun, 7 Jun 2026 21:27:43 +0200 Subject: [PATCH] refactor(site): type-safe i18n keys derived from en --- site/src/i18n/ui.test.ts | 7 +++++++ site/src/i18n/ui.ts | 32 +++++++++++++++++++++++--------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/site/src/i18n/ui.test.ts b/site/src/i18n/ui.test.ts index 1836602..e112367 100644 --- a/site/src/i18n/ui.test.ts +++ b/site/src/i18n/ui.test.ts @@ -26,4 +26,11 @@ describe("i18n", () => { const t = useTranslations("en"); expect(t("hero.tabs", { host: "1.2.3.4" })).toContain("1.2.3.4"); }); + + it("interpolates the full string exactly", () => { + const t = useTranslations("en"); + expect(t("hero.tabs", { host: "10.0.0.1" })).toBe( + "termscp — 10.0.0.1 (sftp) connected", + ); + }); }); diff --git a/site/src/i18n/ui.ts b/site/src/i18n/ui.ts index 9c3219b..4f00424 100644 --- a/site/src/i18n/ui.ts +++ b/site/src/i18n/ui.ts @@ -8,13 +8,18 @@ export const locales = ["en", "zh-CN", "it", "fr", "es"] as const; export type Locale = (typeof locales)[number]; export const defaultLocale: Locale = "en"; -const dictionaries: Record> = { - en: en as Record, - "zh-CN": zhCN as Record, - it: it as Record, - fr: fr as Record, - es: es as Record, -}; +/** Authoritative set of translation keys, derived from the en dictionary. */ +export type TranslationKey = keyof typeof en; + +// `en` is left uncast so it remains the source of truth for `TranslationKey`. +// Other locales may omit keys, so they are typed as partial dictionaries. +const dictionaries = { + en, + "zh-CN": zhCN as Partial>, + it: it as Partial>, + fr: fr as Partial>, + es: es as Partial>, +} satisfies Record>>; export function isLocale(value: string): value is Locale { return (locales as readonly string[]).includes(value); @@ -23,8 +28,17 @@ export function isLocale(value: string): value is Locale { /** Resolve a translator for `locale`, falling back to en, then to the key. */ export function useTranslations(locale: Locale) { const dict = dictionaries[locale] ?? dictionaries[defaultLocale]; - return (key: string, vars?: Record): string => { - let value = dict[key] ?? dictionaries[defaultLocale][key] ?? key; + // `TranslationKey | (string & {})` keeps autocomplete for known keys while + // still accepting arbitrary strings, preserving the "return key if missing" + // contract at runtime. + return ( + key: TranslationKey | (string & {}), + vars?: Record, + ): string => { + let value = + dict[key as TranslationKey] ?? + dictionaries[defaultLocale][key as TranslationKey] ?? + key; if (vars) { for (const [k, v] of Object.entries(vars)) { value = value.replaceAll(`{${k}}`, v);