/* ============================================================================ TRANSLATOR ---------------------------------------------------------------------------- ~2 KB of dependency-free lookup: a dotted key path into the JSON catalogue plus {placeholder} interpolation. No i18next, no ICU MessageFormat - the catalogues hold UI chrome only, page prose comes from Strapi. English is the reference catalogue: `Messages` is derived from en.json, and ar.json is typechecked against it, so a key added on one side and forgotten on the other is a compile error rather than a blank label in production. ========================================================================= */ import en from "./messages/en.json"; import ar from "./messages/ar.json"; import { DEFAULT_LOCALE, type Locale } from "./config"; /** The shape of every catalogue. Derived from the English source of truth. */ export type Messages = typeof en; /** Anything that may be interpolated into a message. */ export type TranslationParams = Readonly< Record >; /** * Every valid dotted key path in the catalogue, e.g. "nav.sections.about". * Keys that address a nested object rather than a string are not assignable, * so `t("nav")` does not typecheck. */ export type MessageKey = T extends string ? never : { [K in Extract]: T[K] extends string ? K : `${K}.${MessageKey}`; }[Extract]; export type TranslationKey = MessageKey; /** A bound translator. Stable per locale - see `useTranslation()`. */ export type Translator = ( key: TranslationKey, params?: TranslationParams, ) => string; /* The `Messages` annotation is the parity check: ar.json must supply every key en.json declares, with a string in every leaf position. */ const arabicMessages: Messages = ar; const CATALOGUES: Readonly> = { en, ar: arabicMessages, }; /** The whole catalogue for a locale - handy for spreading into a component. */ export function getMessages(locale: Locale): Messages { return CATALOGUES[locale] ?? CATALOGUES[DEFAULT_LOCALE]; } /** Walk a dotted path. Returns undefined for a missing or non-string leaf. */ function lookup(catalogue: Messages, key: string): string | undefined { let current: unknown = catalogue; for (const segment of key.split(".")) { if (typeof current !== "object" || current === null) return undefined; current = (current as Record)[segment]; } return typeof current === "string" ? current : undefined; } const PLACEHOLDER = /\{(\w+)\}/g; /** * Replace every `{name}` with `params.name`. An unknown or undefined * placeholder is left in place rather than replaced with "undefined", so a * missing value is visible in review instead of silently shipping. */ export function interpolate( template: string, params?: TranslationParams, ): string { if (!params) return template; return template.replace(PLACEHOLDER, (match, name: string) => { const value = params[name]; return value === undefined ? match : String(value); }); } /** * Translate one key. Falls back to the English string when a locale is * missing it, and finally to the key itself so a page never renders "". */ export function translate( locale: Locale, key: TranslationKey, params?: TranslationParams, ): string { const template = lookup(getMessages(locale), key) ?? lookup(CATALOGUES[DEFAULT_LOCALE], key); if (template === undefined) { if (import.meta.env?.DEV) { console.warn(`[i18n] missing translation for "${key}" (${locale})`); } return key; } return interpolate(template, params); } /** A translator bound to one locale. Cheap - safe to call in a `useMemo`. */ export function createTranslator(locale: Locale): Translator { return (key, params) => translate(locale, key, params); }