initial commit
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
/* ============================================================================
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user