The groundwork in #145 gave the app a language to serve. This gives it something to serve, and a way to measure the distance to the languages actually planned. The English text is the key. `t("Archive")` looks "Archive" up and returns the English when it is not there, which buys three things worth more than tidy symbolic keys: no English catalogue to keep in step with the code, a missing translation that degrades to readable English rather than to `mail.list.archive`, and an extraction step that is wrapping a string rather than inventing a name for it. Names are where extraction stalls, and 55 components is a lot of small naming arguments. The cost is that editing English copy orphans its translations, which is the right way round: the copy is the product, and a stale German sentence should fall back to the new English. `plural()` takes forms rather than (one, other), because two forms is an English assumption that does not survive phase two of the plan. Russian and Ukrainian need three, and choosing between them is not a question about the number 1. Intl.PluralRules knows the rule for every language the browser knows, so the catalogue supplies the forms and the runtime picks; a category the catalogue does not carry falls back to `other` rather than rendering undefined. Interpolation is named rather than positional for the same reason -- German moves the parts of a sentence around and means the same thing. Catalogues are dynamically imported, so a reader who never leaves English never downloads one, and English needs no fetch at all. `applyLang` sets the lang attribute before kicking the load, deliberately: lang is what stops Chrome offering to translate and should not wait on a network request to say something it already knows. `t()` is a plain function, not a hook, so the tree is keyed on a language version at the root and thrown away when the catalogue changes. Making every call site a subscriber would turn extracting a string from "wrap it" into "wrap it and add a hook", for an event that happens about once per account. NotificationsSettings is extracted end to end as the reference -- it covers all four shapes, being JSX text, translated attributes, a toast, and a sentence with a value interpolated into it. scripts/i18n-coverage.mjs counts what is left, because ~1,000 strings across 56 files is too many to eyeball in review or carry in anyone's head. It reports 20 wrapped and 925 remaining, and it deliberately does not count punctuation and separators as untranslated -- a floor no amount of work could reach would make the number useless. A progress report rather than a gate: --check exits non-zero, for once the number is low enough for that to mean something. ROADMAP.md said translations were "English-only for now" on a page whose stated purpose is things the answer is "no" to. It now says what is actually happening, carries the phase order, and says why Arabic, Hebrew and Persian are on neither list: RTL is a layout and bidi problem rather than a longer catalogue, and shipping it as though it were the same kind of work is how an RTL build ends up unusable with nobody saying so.
148 lines
5.2 KiB
TypeScript
148 lines
5.2 KiB
TypeScript
import { useSyncExternalStore } 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
|
|
* catalogue 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
|
|
* catalogue 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 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 catalogue 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 });
|
|
}
|
|
|
|
/** The language in force, for anything that needs the tag itself. */
|
|
export function currentLanguage(): string {
|
|
return currentTag;
|
|
}
|
|
|
|
/**
|
|
* Put a catalogue in force.
|
|
*
|
|
* Exported for tests and for the loader; nothing else should call it, because
|
|
* the tag and the catalogue have to move together or `plural` selects with one
|
|
* language's rules against another's forms.
|
|
*/
|
|
export function setCatalog(tag: string, catalog: Catalog): void {
|
|
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 catalogue leaving the app blank. Everything else
|
|
* is a dynamic import, so a reader who never leaves English never downloads a
|
|
* catalogue -- which matters, because the main bundle is already large enough
|
|
* to warn about.
|
|
*/
|
|
export async function loadLanguage(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 catalogue 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,
|
|
);
|
|
}
|