252 lines
9.7 KiB
TypeScript
252 lines
9.7 KiB
TypeScript
import { createElement, Fragment, useSyncExternalStore, type ReactNode } from "react";
|
|
import { DEFAULT_UI_LANGUAGE, resolveUiLanguage } from "@/lib/languages";
|
|
|
|
/**
|
|
* Translation, in about as little machinery as the job takes.
|
|
*
|
|
* The English text is the key. `t("Archive")` looks "Archive" up in whatever
|
|
* catalog is loaded and returns the English if it is not there, which buys
|
|
* three things worth more than tidy symbolic keys: there is no English
|
|
* catalog to keep in step with the code, a missing translation degrades to
|
|
* readable English rather than to `mail.list.archive`, and extracting a string
|
|
* is wrapping it rather than inventing a name for it. Names are where
|
|
* extraction stalls -- 55 components is a lot of small naming arguments.
|
|
*
|
|
* The cost is that changing English copy orphans its translations. That is the
|
|
* right trade here: the copy is the product, and a stale translation should
|
|
* fall back to the new English rather than keep showing the old sentence in
|
|
* German.
|
|
*/
|
|
|
|
export type Vars = Record<string, string | number>;
|
|
|
|
/** One entry per plural category the language actually uses. */
|
|
export type PluralForms = Partial<Record<Intl.LDMLPluralRule, string>> & { other: string };
|
|
|
|
export interface Catalog {
|
|
/** English source → translation. */
|
|
strings: Record<string, string>;
|
|
/** English `other` form → the forms this language needs. */
|
|
plurals: Record<string, PluralForms>;
|
|
}
|
|
|
|
const EMPTY: Catalog = { strings: {}, plurals: {} };
|
|
|
|
let current: Catalog = EMPTY;
|
|
let currentTag: string = DEFAULT_UI_LANGUAGE;
|
|
let version = 0;
|
|
const listeners = new Set<() => void>();
|
|
|
|
function publish(): void {
|
|
version += 1;
|
|
for (const fn of listeners) fn();
|
|
}
|
|
|
|
/**
|
|
* Fill in `{name}` placeholders.
|
|
*
|
|
* Named rather than positional, because a translator reorders a sentence and
|
|
* positional arguments do not survive that -- German puts the verb last, and
|
|
* "{0} of {1}" becomes a different order with the same meaning.
|
|
*/
|
|
export function interpolate(template: string, vars?: Vars): string {
|
|
if (!vars) return template;
|
|
return template.replace(/\{(\w+)\}/g, (whole, key: string) =>
|
|
Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : whole,
|
|
);
|
|
}
|
|
|
|
/** Translate, falling back to the English that was passed in. */
|
|
export function t(source: string, vars?: Vars): string {
|
|
return interpolate(current.strings[source] ?? source, vars);
|
|
}
|
|
|
|
/**
|
|
* Translate where the English word is doing two jobs.
|
|
*
|
|
* English-as-key has one real weakness and this is it: "Archive" is the button
|
|
* that archives a message and the folder the message lands in, and German
|
|
* needs "Archivieren" for the first and "Archiv" for the second. One key
|
|
* cannot hold both. "Important" is the same — a priority tag and a folder.
|
|
*
|
|
* So a context can be given, and the lookup becomes context + source while the
|
|
* fallback stays the plain English. A translator sees the context and knows
|
|
* which sense to render; a catalog that has not got round to it still
|
|
* renders the English word, which was right in English all along.
|
|
*
|
|
* The separator is a control character rather than a punctuation mark, which
|
|
* is the gettext convention and for the same reason: no English string can
|
|
* contain it by accident.
|
|
*/
|
|
export const CONTEXT_SEPARATOR = "\u0004";
|
|
|
|
export function tc(context: string, source: string, vars?: Vars): string {
|
|
const keyed = current.strings[`${context}${CONTEXT_SEPARATOR}${source}`];
|
|
return interpolate(keyed ?? current.strings[source] ?? source, vars);
|
|
}
|
|
|
|
/**
|
|
* Translate a counted thing.
|
|
*
|
|
* Two forms is an English assumption and does not survive the second phase of
|
|
* this: Russian and Ukrainian use three, and picking between them is not
|
|
* `n === 1`. `Intl.PluralRules` knows the rule for every language the browser
|
|
* knows, so the catalog supplies the forms and the runtime picks.
|
|
*
|
|
* The English `other` form is the key, so a call site reads as the sentence it
|
|
* produces and needs no invented name.
|
|
*/
|
|
export function plural(n: number, forms: PluralForms, vars?: Vars): string {
|
|
const entry = current.plurals[forms.other] ?? forms;
|
|
let category: Intl.LDMLPluralRule = "other";
|
|
try {
|
|
category = new Intl.PluralRules(currentTag).select(n);
|
|
} catch {
|
|
/* an unknown tag: "other" is the safe form and English's only plural */
|
|
}
|
|
return interpolate(entry[category] ?? entry.other, { n, ...vars });
|
|
}
|
|
|
|
/**
|
|
* A translated sentence with elements inside it.
|
|
*
|
|
* Some sentences have a `<code>` or a `<kbd>` in the middle of them, and the
|
|
* two obvious approaches are both wrong. Splitting the sentence into two `t()`
|
|
* calls hands a translator "This browser cannot register apps for" and "links,
|
|
* in particular…", which are not sentences and cannot be reordered into a
|
|
* language that puts the verb somewhere else. Dropping the element and
|
|
* interpolating plain text keeps the sentence whole but loses the monospace
|
|
* that told the reader it was a literal.
|
|
*
|
|
* So the sentence stays whole and the elements are placeholders in it:
|
|
*
|
|
* tNode("Open {scheme} links in ihasmail.", { scheme: <code>mailto:</code> })
|
|
*
|
|
* A translator sees one sentence with a named hole and can put the hole
|
|
* wherever their language wants it.
|
|
*/
|
|
export function tNode(source: string, parts: Record<string, ReactNode>, vars?: Vars): ReactNode {
|
|
const translated = interpolate(current.strings[source] ?? source, vars);
|
|
const out: ReactNode[] = [];
|
|
let last = 0;
|
|
const re = /\{(\w+)\}/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(translated))) {
|
|
if (!Object.prototype.hasOwnProperty.call(parts, m[1]!)) continue;
|
|
if (m.index > last) out.push(translated.slice(last, m.index));
|
|
// Keyed, because this is an array and React asks; the index is stable for
|
|
// a given rendering of a given sentence.
|
|
out.push(createElement(Fragment, { key: `${m[1]}-${m.index}` }, parts[m[1]!]));
|
|
last = m.index + m[0].length;
|
|
}
|
|
if (last < translated.length) out.push(translated.slice(last));
|
|
return out;
|
|
}
|
|
|
|
/** Subscribe to catalog changes without React. Used by the tests. */
|
|
export function subscribeForTest(fn: () => void): () => void {
|
|
listeners.add(fn);
|
|
return () => void listeners.delete(fn);
|
|
}
|
|
|
|
/** The language in force, for anything that needs the tag itself. */
|
|
export function currentLanguage(): string {
|
|
return currentTag;
|
|
}
|
|
|
|
/**
|
|
* Put a catalog in force.
|
|
*
|
|
* Exported for tests and for the loader; nothing else should call it, because
|
|
* the tag and the catalog have to move together or `plural` selects with one
|
|
* language's rules against another's forms.
|
|
*/
|
|
export function setCatalog(tag: string, catalog: Catalog): void {
|
|
/*
|
|
* Publishing only when something actually changed is not an optimization
|
|
* here, it is the thing that stops an infinite loop.
|
|
*
|
|
* The root keys its tree on the language version, so a publish remounts
|
|
* everything. Remounting re-runs the effect that fetches the account's
|
|
* settings file, which calls `hydrate`, which calls `applyLang`, which lands
|
|
* back here -- with the identical tag and the identical catalog. Publishing
|
|
* that non-change bumped the version again and went round for ever: the
|
|
* message list refetched on every pass, which is what it looked like from
|
|
* the outside.
|
|
*
|
|
* Reference equality is enough. `EMPTY` is a module constant and a
|
|
* dynamically imported catalog is cached, so the same language really does
|
|
* hand back the same object.
|
|
*/
|
|
if (currentTag === tag && current === catalog) return;
|
|
currentTag = tag;
|
|
current = catalog;
|
|
publish();
|
|
}
|
|
|
|
/**
|
|
* Load and apply a language.
|
|
*
|
|
* English is the built-in: it is the source text, so there is nothing to fetch
|
|
* and no chance of a missing catalog leaving the app blank. Everything else
|
|
* is a dynamic import, so a reader who never leaves English never downloads a
|
|
* catalog -- which matters, because the main bundle is already large enough
|
|
* to warn about.
|
|
*/
|
|
/**
|
|
* The catalog load that is in flight, so the first paint can wait for it.
|
|
*
|
|
* Without this, a cold load paints before the catalog lands. Components
|
|
* recover -- the tree is rebuilt when the catalog arrives -- but a string
|
|
* computed in an effect does not: a toast fired in that window is emitted in
|
|
* English and stays English, in an interface that is otherwise German.
|
|
* Reported as a stale-folder toast that ignored the language setting.
|
|
*/
|
|
let inFlight: Promise<void> = Promise.resolve();
|
|
|
|
/** Resolves once the chosen language is in force. English resolves at once. */
|
|
export function whenLanguageReady(): Promise<void> {
|
|
return inFlight;
|
|
}
|
|
|
|
export async function loadLanguage(tag: string): Promise<void> {
|
|
inFlight = loadLanguageNow(tag);
|
|
return inFlight;
|
|
}
|
|
|
|
async function loadLanguageNow(tag: string): Promise<void> {
|
|
const resolved = resolveUiLanguage(tag);
|
|
if (resolved === DEFAULT_UI_LANGUAGE) {
|
|
setCatalog(DEFAULT_UI_LANGUAGE, EMPTY);
|
|
return;
|
|
}
|
|
try {
|
|
const mod = (await import(`../locales/${resolved}.ts`)) as { catalog: Catalog };
|
|
setCatalog(resolved, mod.catalog);
|
|
} catch {
|
|
// A catalog that will not load leaves English in force rather than a
|
|
// half-rendered page. `resolveUiLanguage` should already have prevented
|
|
// this; it being reachable at all is why it is caught.
|
|
setCatalog(DEFAULT_UI_LANGUAGE, EMPTY);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Re-render when the language changes.
|
|
*
|
|
* Used once, at the root, to key the tree — rather than at each of the
|
|
* thousand call sites, which would make `t()` a hook and extraction far more
|
|
* invasive than wrapping a string. Language changes are rare enough that
|
|
* re-rendering everything is the cheaper design.
|
|
*/
|
|
export function useLanguageVersion(): number {
|
|
return useSyncExternalStore(
|
|
(fn) => {
|
|
listeners.add(fn);
|
|
return () => listeners.delete(fn);
|
|
},
|
|
() => version,
|
|
() => version,
|
|
);
|
|
}
|