refactor(site): type-safe i18n keys derived from en

This commit is contained in:
Christian Visintin
2026-06-07 21:27:43 +02:00
parent f544a63d19
commit f56dd60868
2 changed files with 30 additions and 9 deletions

View File

@@ -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",
);
});
});

View File

@@ -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<Locale, Record<string, string>> = {
en: en as Record<string, string>,
"zh-CN": zhCN as Record<string, string>,
it: it as Record<string, string>,
fr: fr as Record<string, string>,
es: es as Record<string, string>,
};
/** 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<Record<TranslationKey, string>>,
it: it as Partial<Record<TranslationKey, string>>,
fr: fr as Partial<Record<TranslationKey, string>>,
es: es as Partial<Record<TranslationKey, string>>,
} satisfies Record<Locale, Partial<Record<TranslationKey, string>>>;
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, string>): 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, string>,
): 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);