119 lines
3.9 KiB
TypeScript
119 lines
3.9 KiB
TypeScript
/* ============================================================================
|
|
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.
|
|
|
|
Arabic is the reference catalogue: `Messages` is derived from ar.json, and
|
|
en.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 Arabic source of truth. */
|
|
export type Messages = typeof ar;
|
|
|
|
/** 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: en.json must supply every
|
|
key ar.json declares, with a string in every leaf position. */
|
|
const englishMessages: Messages = en;
|
|
|
|
const CATALOGUES: Readonly<Record<Locale, Messages>> = {
|
|
en: englishMessages,
|
|
ar,
|
|
};
|
|
|
|
/** 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 default-locale 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);
|
|
}
|