Files

119 lines
3.9 KiB
TypeScript
Raw Permalink Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
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<string, string | number | undefined>
>;
/**
* 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> = T extends string
? never
: {
[K in Extract<keyof T, string>]: T[K] extends string
? K
: `${K}.${MessageKey<T[K]>}`;
}[Extract<keyof T, string>];
export type TranslationKey = MessageKey<Messages>;
/** 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<Record<Locale, Messages>> = {
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<string, unknown>)[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);
}