@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user