Start extraction: an i18n core, and a way to see how far it has got

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.
This commit is contained in:
2026-08-31 09:44:49 -07:00
parent 90ed579876
commit 95dcb96086
8 changed files with 324 additions and 17 deletions
+3 -1
View File
@@ -10,5 +10,7 @@ See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about
- **Sharing a mail folder.** Stalwart stores the share and never delivers it; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Withdrawn until the server does something with it. Sharing files, calendars and address books is unaffected and works.
- Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away)
- Translations (strings are English-only for now)
- **Translations.** In progress, and the only entry here that is "not yet" rather than "no". The groundwork shipped in [#145](https://github.com/Coffey-Labs/ihasmail/pull/145): an interface-language setting separate from the date-and-time locale, `<html lang>` served from it, and the structural work that keeps a browser's own translator from rewriting the page underneath React. What is left is the part that was always the hard part — extracting every user-facing string, and having each catalogue read by somebody who speaks the language. A language is offered in Settings only once its catalogue is complete, so a half-translated build shows English and nothing else; the picker is the gate, not the calendar.
Planned order, and it is an order rather than a wish list: **German, French, Dutch, Spanish, Portuguese (Brazil)** first, then **Russian, Ukrainian, Chinese (Simplified), Japanese**. Arabic, Hebrew and Persian are deliberately not on either list. They are right-to-left, and that is a layout and bidi problem rather than a longer catalogue — shipping them as though they were the same kind of work is how an RTL build ends up unusable and nobody says so.
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Came out of [#75](https://github.com/Coffey-Labs/ihasmail/issues/75), which is closed: what was reported there was a sign-in refused with nothing but "Invalid credentials", and that was fixed by saying what is actually happening and pointing at app passwords. The OAuth work it uncovered is tracked here rather than as an open issue, so there is no ticket to watch for it.
+2 -1
View File
@@ -21,7 +21,8 @@
"lint": "npm run typecheck",
"mock": "npm run mock -w server",
"dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
"dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\""
"dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
"i18n:coverage": "node scripts/i18n-coverage.mjs"
},
"devDependencies": {
"concurrently": "^9.1.2",
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env node
/*
* How much of the interface is extracted, and what is left.
*
* Extraction is ~1,000 strings across ~56 files, which is far too many to
* carry in anyone's head or to eyeball in review. This counts what is still
* hardcoded so the work can be done a file at a time and the remainder is
* always a number rather than a feeling.
*
* It is a progress report, not a gate: run it, do a file, run it again. It
* exits non-zero only with --check, so CI can be told to fail on regressions
* later, once the number is low enough for that to mean something.
*/
import ts from "typescript";
import { readFileSync, globSync } from "node:fs";
/** Attributes a person reads. `className` and `key` are not among them. */
const ATTRS = new Set(["title", "aria-label", "placeholder", "alt", "label", "hint", "confirmLabel", "message", "description"]);
/* Text that is not prose: punctuation, separators, and the single glyphs used
as dividers. Counting these as untranslated would put a floor under the
number that no amount of work could reach. */
const NOT_PROSE = /^[\s·—–\-—:;,.()[\]{}/|+×✓~<>#*@0-9]*$/u;
const files = globSync("web/src/**/*.tsx").filter((f) => !f.includes("__tests__"));
const rows = [];
let done = 0, todo = 0;
for (const file of files) {
const text = readFileSync(file, "utf8");
const src = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
let left = 0;
const wrapped = (text.match(/\bt\(\s*["'`]/g) || []).length + (text.match(/\bplural\(/g) || []).length;
const visit = (node) => {
if (ts.isJsxText(node) && node.text.trim().length > 1 && !NOT_PROSE.test(node.text.trim())) left++;
if (ts.isJsxAttribute(node) && ATTRS.has(node.name.getText(src))) {
const i = node.initializer;
const lit = i && (ts.isStringLiteral(i) ? i : ts.isJsxExpression(i) && i.expression && ts.isStringLiteral(i.expression) ? i.expression : null);
if (lit && lit.text.trim().length > 1) left++;
}
ts.forEachChild(node, visit);
};
visit(src);
done += wrapped;
todo += left;
if (left) rows.push([file.replace("web/src/", ""), left, wrapped]);
}
rows.sort((a, b) => b[1] - a[1]);
const pct = done + todo === 0 ? 100 : Math.round((done / (done + todo)) * 100);
console.log(`i18n extraction: ${done} wrapped, ${todo} remaining across ${rows.length} files (${pct}%)\n`);
for (const [f, left, w] of rows.slice(0, Number(process.argv.find((a) => a.startsWith("--top="))?.slice(6) ?? 15))) {
console.log(` ${String(left).padStart(4)} left${w ? `, ${w} done` : " "} ${f}`);
}
if (rows.length > 15 && !process.argv.includes("--all")) console.log(`\n …and ${rows.length - 15} more (--all, or --top=N)`);
if (process.argv.includes("--check") && todo > 0) process.exit(1);
+15 -2
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect } from "react";
import { Fragment, lazy, Suspense, useEffect } from "react";
import { Route, Switch, Redirect, useLocation } from "wouter";
import { useSession } from "@/store/session";
import { useMail } from "@/store/mail";
@@ -20,6 +20,7 @@ import { setUnreadBadge } from "@/lib/notify";
import { useSettings, syncedPart } from "@/store/settings";
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
import { listenForVerification, renewWebPush } from "@/lib/webpushEnable";
import { useLanguageVersion } from "@/lib/i18n";
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
@@ -29,6 +30,18 @@ const SettingsView = lazy(() => import("@/views/settings/SettingsView").then((m)
export function App() {
const status = useSession((s) => s.status);
const bootstrap = useSession((s) => s.bootstrap);
/*
* Subscribed once, here, and used as a key below.
*
* `t()` is a plain function rather than a hook, so a component has no way of
* knowing its strings just changed. Rather than make every one of the
* thousand call sites a subscriber -- which would turn extracting a string
* from "wrap it" into "wrap it and add a hook" -- the whole tree is thrown
* away and rebuilt when the catalogue changes. Picking a language is a
* once-in-an-account event; paying for it there is far cheaper than paying
* for it on every render everywhere.
*/
const languageVersion = useLanguageVersion();
useEffect(() => {
void bootstrap();
}, [bootstrap]);
@@ -42,7 +55,7 @@ export function App() {
}
return (
<>
{status === "anonymous" ? <LoginPage /> : <AuthedApp />}
<Fragment key={languageVersion}>{status === "anonymous" ? <LoginPage /> : <AuthedApp />}</Fragment>
<ToastHost />
<ConfirmHost />
</>
+79
View File
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it } from "vitest";
import { currentLanguage, interpolate, plural, setCatalog, t, type Catalog } from "@/lib/i18n";
const de: Catalog = {
strings: { "Archive": "Archivieren", "Move {n} to {folder}": "{n} nach {folder} verschieben" },
plurals: { "{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" } },
};
/* Russian is the reason plural() does not take (one, other): it needs three
forms, and which one applies is not a question about the number 1. */
const ru: Catalog = {
strings: {},
plurals: { "{n} messages": { one: "{n} сообщение", few: "{n} сообщения", many: "{n} сообщений", other: "{n} сообщения" } },
};
afterEach(() => setCatalog("en", { strings: {}, plurals: {} }));
describe("t", () => {
it("returns the English it was given when nothing is loaded", () => {
// The whole point of English-as-key: a missing translation degrades to
// readable English rather than to a symbolic name leaking into the UI.
expect(t("Archive")).toBe("Archive");
expect(currentLanguage()).toBe("en");
});
it("translates once a catalogue is in force", () => {
setCatalog("de", de);
expect(t("Archive")).toBe("Archivieren");
});
it("falls back per string, not per catalogue", () => {
setCatalog("de", de);
expect(t("Report spam")).toBe("Report spam");
});
});
describe("interpolation", () => {
it("fills named placeholders", () => {
expect(interpolate("Move {n} to {folder}", { n: 3, folder: "Archive" })).toBe("Move 3 to Archive");
});
it("survives a translator reordering the sentence", () => {
// Positional arguments would not: German moves the parts around and means
// the same thing.
setCatalog("de", de);
expect(t("Move {n} to {folder}", { n: 3, folder: "Archiv" })).toBe("3 nach Archiv verschieben");
});
it("leaves an unknown placeholder alone rather than printing undefined", () => {
expect(interpolate("Hello {who}", {})).toBe("Hello {who}");
});
});
describe("plural", () => {
const FORMS = { one: "{n} message", other: "{n} messages" };
it("picks the English form without a catalogue", () => {
expect(plural(1, FORMS)).toBe("1 message");
expect(plural(0, FORMS)).toBe("0 messages");
expect(plural(5, FORMS)).toBe("5 messages");
});
it("uses the target language's own rule, not English's", () => {
setCatalog("ru", ru);
expect(plural(1, FORMS)).toBe("1 сообщение"); // one
expect(plural(3, FORMS)).toBe("3 сообщения"); // few
expect(plural(7, FORMS)).toBe("7 сообщений"); // many
});
it("falls back to `other` when the catalogue lacks the category", () => {
setCatalog("de", de);
// German has no "few"; asking for 3 must not render undefined.
expect(plural(3, FORMS)).toBe("3 Nachrichten");
});
it("takes extra variables alongside the count", () => {
expect(plural(2, { one: "{n} message in {folder}", other: "{n} messages in {folder}" }, { folder: "Inbox" }))
.toBe("2 messages in Inbox");
});
});
+147
View File
@@ -0,0 +1,147 @@
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,
);
}
+10 -1
View File
@@ -5,6 +5,7 @@ import { queueSettingsPush } from "@/lib/settingsSync";
import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime";
import type { SwipeAction } from "@/lib/swipe";
import { resolveUiLanguage } from "@/lib/languages";
import { loadLanguage } from "@/lib/i18n";
/**
* "ihasmail" is a dark theme carrying the palette from ihasmail.org. It is a
@@ -368,7 +369,15 @@ function applyDateTimePrefs(s: Settings): void {
* load, which is the thing the whole design avoids.
*/
export function applyLang(s: Settings = useSettings.getState().settings): void {
document.documentElement.lang = resolveUiLanguage(s.uiLanguage);
const tag = resolveUiLanguage(s.uiLanguage);
document.documentElement.lang = tag;
/*
* The catalogue is fetched, so it lands a beat after the attribute. That
* order is deliberate: `lang` is what stops Chrome offering to translate,
* and it should not wait on a network request to say something it already
* knows. English needs no fetch at all and resolves immediately.
*/
void loadLanguage(tag);
}
/** Background of each theme, for the browser chrome (`theme-color`). */
@@ -6,6 +6,7 @@ import { useSession } from "@/store/session";
import { disableWebPush, enableWebPush, webPushActive } from "@/lib/webpushEnable";
import { supportsEmailPush, webPushAvailable } from "@/lib/webpush";
import { toast } from "@/ui/toast";
import { t } from "@/lib/i18n";
export function NotificationsSettings() {
const s = useSettings((st) => st.settings);
@@ -23,8 +24,8 @@ export function NotificationsSettings() {
}, [s.desktopNotifications]);
return (
<div>
<h1>Notifications</h1>
<p className="lead">{`Live updates are delivered via JMAP push (${pushConnected ? "connected" : "reconnecting…"}).`}</p>
<h1>{t("Notifications")}</h1>
<p className="lead">{t("Live updates are delivered via JMAP push ({state}).", { state: pushConnected ? t("connected") : t("reconnecting…") })}</p>
<Switch
checked={s.desktopNotifications}
onChange={async (v) => {
@@ -35,8 +36,8 @@ export function NotificationsSettings() {
}
update({ desktopNotifications: v });
}}
label="Desktop notifications while ihasmail is open"
hint={perm === "denied" ? "Notifications are blocked in your browser settings." : perm === "unsupported" ? "Not supported in this browser." : "Shows a system notification when new mail arrives in your Inbox while the tab is in the background."}
label={t("Desktop notifications while ihasmail is open")}
hint={perm === "denied" ? t("Notifications are blocked in your browser settings.") : perm === "unsupported" ? t("Not supported in this browser.") : t("Shows a system notification when new mail arrives in your Inbox while the tab is in the background.")}
disabled={perm === "denied" || perm === "unsupported"}
/>
{/*
@@ -57,7 +58,7 @@ export function NotificationsSettings() {
const res = await enableWebPush();
if (!res.ok) { toast.error(res.reason); return; }
setBackground(true);
toast.success("Background notifications are on");
toast.success(t("Background notifications are on"));
} else {
await disableWebPush();
setBackground(false);
@@ -66,20 +67,20 @@ export function NotificationsSettings() {
setBusy(false);
}
}}
label="Notify me even when ihasmail is closed"
label={t("Notify me even when ihasmail is closed")}
hint={
!canBackground
? "Needs a browser with the Push API and a mail server that publishes a push key."
? t("Needs a browser with the Push API and a mail server that publishes a push key.")
: supportsEmailPush()
? "Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again."
: "Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running."
? t("Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.")
: t("Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.")
}
/>
<Switch checked={s.notificationSound} onChange={(v) => update({ notificationSound: v })} label="Play a sound for new mail" />
<Switch checked={s.notificationSound} onChange={(v) => update({ notificationSound: v })} label={t("Play a sound for new mail")} />
<div className="row mt-16">
<button className="btn" onClick={() => { showNotification("ihasmail test", { body: "This is what a new-mail notification looks like." }); playNewMailSound(); }}>Test notification</button>
<button className="btn" onClick={() => { showNotification(t("ihasmail test"), { body: t("This is what a new-mail notification looks like.") }); playNewMailSound(); }}>{t("Test notification")}</button>
</div>
<p className="hint mt-8">The tab title and favicon always show your unread Inbox count.</p>
<p className="hint mt-8">{t("The tab title and favicon always show your unread Inbox count.")}</p>
</div>
);
}