/** * 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; export type Vars = Record; /** * 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(); const numberFormats = new Map(); 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); }