Files
CashuMints.space/web/src/i18n/translate.ts
T
2026-08-20 22:41:25 +02:00

139 lines
5.4 KiB
TypeScript

/**
* The `t()` core: one lookup, one interpolation, one plural rule.
*
* Deliberately tiny and dependency free, because it runs in two places with very
* different budgets: in .astro frontmatter at build time, where nothing it does costs a
* visitor anything, and inside an island, where every byte ships. There is no ICU
* parser here. What ICU is actually needed for on this site is plurals, and
* `Intl.PluralRules` is already in every browser and in node.
*/
import type { Locale } from './config.js';
import { DEFAULT_LOCALE, intlTag } from './config.js';
/** A catalog is flat: dotted keys to strings. No nesting, so no path walking. */
export type Catalog = Record<string, string>;
export type Vars = Record<string, string | number>;
/**
* The interpolation placeholder. `{name}` and nothing else: no formats, no nested
* selects. A string that needs more than a value dropped into it is two strings.
*/
const PLACEHOLDER = /\{(\w+)\}/g;
/** Cached per locale, because constructing one of these is not free. */
const pluralRules = new Map<string, Intl.PluralRules>();
const numberFormats = new Map<string, Intl.NumberFormat>();
function pluralRule(locale: Locale): Intl.PluralRules {
const tag = intlTag(locale);
let rule = pluralRules.get(tag);
if (!rule) {
rule = new Intl.PluralRules(tag);
pluralRules.set(tag, rule);
}
return rule;
}
function numberFormat(locale: Locale): Intl.NumberFormat {
const tag = intlTag(locale);
let format = numberFormats.get(tag);
if (!format) {
format = new Intl.NumberFormat(tag);
numberFormats.set(tag, format);
}
return format;
}
/**
* The plural category for a count, as a catalog key suffix.
*
* English, Spanish and Dutch share one rule (`1` is singular, everything else is not),
* so this looks like overkill for the three launch languages. It is not: it is what
* makes language four a one-file job even when that language is Polish, which has three
* categories, or Arabic, which has six. A catalog that only ships `.one` and `.other`
* still resolves, because a missing category falls back to `.other`.
*/
function pluralKey(locale: Locale, key: string, n: number, catalog: Catalog): string | null {
const category = pluralRule(locale).select(n);
if (catalog[`${key}.${category}`] !== undefined) return `${key}.${category}`;
if (catalog[`${key}.other`] !== undefined) return `${key}.other`;
return null;
}
/**
* Substitute `{name}` placeholders.
*
* Numbers are formatted for the locale on the way in, so `{n}` in "1,000,000 sats"
* comes out as `1.000.000` in Spanish and Dutch without every caller remembering to
* format first. Pass a string when a raw value is wanted (a NUT number, a year, a
* rating already fixed to one decimal).
*/
function interpolate(template: string, vars: Vars | undefined, locale: Locale): string {
if (!vars) return template;
return template.replace(PLACEHOLDER, (whole, name: string) => {
const value = vars[name];
if (value === undefined) return whole;
return typeof value === 'number' ? numberFormat(locale).format(value) : value;
});
}
export interface Translator {
(key: string, vars?: Vars): string;
locale: Locale;
/** True when this key resolves in this locale without falling back. */
has: (key: string) => boolean;
}
/**
* Build a translator over one locale's catalog, with English behind it.
*
* A missing key renders the English string rather than the key itself: a visitor
* reading a Spanish page should meet an English sentence at worst, never
* `mint.warnings.meltOnly`. The build is what complains about the gap, loudly and by
* name, in `scripts/check-i18n.mjs`.
*/
export function createTranslator(locale: Locale, catalog: Catalog, fallback: Catalog): Translator {
const lookup = (key: string, n: number | undefined): string | undefined => {
if (n !== undefined) {
const plural = pluralKey(locale, key, n, catalog);
if (plural) return catalog[plural];
// The locale has no plural forms for this key. Take English's, picked with
// English's own rule, rather than dropping to the bare key.
const englishPlural = pluralKey(DEFAULT_LOCALE, key, n, fallback);
if (englishPlural) return fallback[englishPlural];
}
return catalog[key] ?? fallback[key];
};
const t = ((key: string, vars?: Vars): string => {
const n = typeof vars?.['n'] === 'number' ? (vars['n'] as number) : undefined;
const found = lookup(key, n);
// Nothing anywhere. Render the last segment of the key as words rather than the
// key: "meltOnly" is a bad string, "mint.warnings.meltOnly" is a bug on screen.
if (found === undefined) return humanise(key);
return interpolate(found, vars, locale);
}) as Translator;
t.locale = locale;
t.has = (key: string): boolean =>
catalog[key] !== undefined ||
catalog[`${key}.other`] !== undefined ||
catalog[`${key}.one`] !== undefined;
return t;
}
/**
* The last resort, for a key that exists in no catalog at all.
*
* This should be unreachable: `check-i18n` fails the build on a key that is used and
* not defined in English. It is here because "unreachable" and "never renders" are
* different claims, and the difference is what a visitor sees.
*/
function humanise(key: string): string {
const last = key.split('.').pop() ?? key;
const spaced = last.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[-_]+/g, ' ');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}