From 95dcb960867f212f1f0e3fffeef1f9d7e5175edd Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 31 Aug 2026 09:44:49 -0700 Subject: [PATCH 1/2] 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. --- ROADMAP.md | 4 +- package.json | 3 +- scripts/i18n-coverage.mjs | 55 +++++++ web/src/App.tsx | 17 +- web/src/lib/__tests__/i18n.test.ts | 79 ++++++++++ web/src/lib/i18n.ts | 147 ++++++++++++++++++ web/src/store/settings.ts | 11 +- .../views/settings/NotificationsSettings.tsx | 25 +-- 8 files changed, 324 insertions(+), 17 deletions(-) create mode 100755 scripts/i18n-coverage.mjs create mode 100644 web/src/lib/__tests__/i18n.test.ts create mode 100644 web/src/lib/i18n.ts diff --git a/ROADMAP.md b/ROADMAP.md index 8c0ef12..1a0b1e4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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, `` 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. diff --git a/package.json b/package.json index cc1ef60..df65d48 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/i18n-coverage.mjs b/scripts/i18n-coverage.mjs new file mode 100755 index 0000000..fc289f4 --- /dev/null +++ b/scripts/i18n-coverage.mjs @@ -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); diff --git a/web/src/App.tsx b/web/src/App.tsx index 25d1c16..79070ea 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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" ? : } + {status === "anonymous" ? : } diff --git a/web/src/lib/__tests__/i18n.test.ts b/web/src/lib/__tests__/i18n.test.ts new file mode 100644 index 0000000..7a68b5d --- /dev/null +++ b/web/src/lib/__tests__/i18n.test.ts @@ -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"); + }); +}); diff --git a/web/src/lib/i18n.ts b/web/src/lib/i18n.ts new file mode 100644 index 0000000..c33a899 --- /dev/null +++ b/web/src/lib/i18n.ts @@ -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; + +/** One entry per plural category the language actually uses. */ +export type PluralForms = Partial> & { other: string }; + +export interface Catalog { + /** English source → translation. */ + strings: Record; + /** English `other` form → the forms this language needs. */ + plurals: Record; +} + +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 { + 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, + ); +} diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index cdcc691..8c767bb 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -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`). */ diff --git a/web/src/views/settings/NotificationsSettings.tsx b/web/src/views/settings/NotificationsSettings.tsx index eeb794c..07b7711 100644 --- a/web/src/views/settings/NotificationsSettings.tsx +++ b/web/src/views/settings/NotificationsSettings.tsx @@ -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 (
-

Notifications

-

{`Live updates are delivered via JMAP push (${pushConnected ? "connected" : "reconnecting…"}).`}

+

{t("Notifications")}

+

{t("Live updates are delivered via JMAP push ({state}).", { state: pushConnected ? t("connected") : t("reconnecting…") })}

{ @@ -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.") } /> - update({ notificationSound: v })} label="Play a sound for new mail" /> + update({ notificationSound: v })} label={t("Play a sound for new mail")} />
- +
-

The tab title and favicon always show your unread Inbox count.

+

{t("The tab title and favicon always show your unread Inbox count.")}

); } From 8ea611f7f7e65adae07cca5133eabb2e34db50f6 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 31 Aug 2026 09:58:33 -0700 Subject: [PATCH 2/2] Extract 515 strings by codemod, and the two bugs only a screenshot caught Wrapping ~1,000 strings by hand is a thousand chances to mistype the copy itself, and a parser does not get bored. scripts/i18n-extract.mjs does the mechanical part -- JSX text and the attributes a person actually reads -- and refuses the rest rather than guessing. 78% now: 515 wrapped, 143 left. What it refuses matters as much as what it does. Text split around an interpolation arrives as separate fragments, and wrapping each on its own produces "Move " and " messages", which no translator can do anything with; those are listed for a person to rebuild as sentences. So is anything containing a double quote, which would end the literal. Three things it had to be taught, each found by running it: - , and
 are not prose. The first run wrapped `label:name`
  inside  -- a search operator, where translating it breaks the thing it
  documents. Subtrees marked translate="no" are skipped for the same reason.
- `t` is a natural name for a callback parameter and several files already use
  it, so an import called `t` is shadowed inside those callbacks -- silently,
  wherever the local happens to be callable. The name is checked per file now
  and aliased to `translate` where it is taken.
- JSX decodes HTML entities and a JS string literal does not, so
  `Language & region` moved into t("...") and rendered the entity on screen.

That last one is the one worth remembering. Typecheck passed, 443 tests
passed, and the page said "Language & region" in plain sight. It took
looking at a screenshot, and then a sweep of ten views to find the second
occurrence in a sentence I had written by hand earlier the same day. Nothing
in the toolchain was ever going to catch it: it is valid TypeScript rendering
valid text that happens to be wrong.

The codemod decodes entities now, and checks for a quote after decoding rather
than before.
---
 scripts/i18n-extract.mjs                      | 130 ++++++++++++++++++
 web/src/ui/datefield.tsx                      |  15 +-
 web/src/ui/dialog.tsx                         |   3 +-
 web/src/ui/misc.tsx                           |   3 +-
 web/src/ui/toast.tsx                          |   3 +-
 web/src/views/AppShell.tsx                    |  35 ++---
 web/src/views/Login.tsx                       |  13 +-
 web/src/views/SearchBar.tsx                   |  27 ++--
 web/src/views/Shortcuts.tsx                   |   3 +-
 .../views/calendar/CalendarContextMenu.tsx    |  21 +--
 web/src/views/calendar/CalendarDialog.tsx     |  19 +--
 web/src/views/calendar/CalendarSidebar.tsx    |  35 ++---
 web/src/views/calendar/CalendarView.tsx       |  17 +--
 web/src/views/calendar/EventEditor.tsx        |  67 ++++-----
 web/src/views/calendar/EventPopover.tsx       |  15 +-
 web/src/views/compose/Composer.tsx            |  79 +++++------
 web/src/views/compose/FilePicker.tsx          |   9 +-
 web/src/views/compose/RecipientPicker.tsx     |  19 +--
 web/src/views/compose/RichEditor.tsx          |  65 ++++-----
 web/src/views/compose/SchedulePicker.tsx      |  15 +-
 web/src/views/contacts/ContactEditor.tsx      |  67 ++++-----
 web/src/views/contacts/ContactsSidebar.tsx    |  37 ++---
 web/src/views/contacts/ContactsView.tsx       |  33 ++---
 web/src/views/files/FilesTree.tsx             |  21 +--
 web/src/views/files/FilesView.tsx             |  29 ++--
 web/src/views/mail/AddressMenu.tsx            |  11 +-
 web/src/views/mail/FilterFromMessage.tsx      |  15 +-
 web/src/views/mail/InviteCard.tsx             |   5 +-
 web/src/views/mail/LabelPicker.tsx            |   5 +-
 web/src/views/mail/MailboxPicker.tsx          |   5 +-
 web/src/views/mail/MailboxTree.tsx            |  25 ++--
 web/src/views/mail/MessageList.tsx            |  74 +++++-----
 web/src/views/mail/MessageView.tsx            |  88 ++++++------
 web/src/views/mail/ThreadView.tsx             |  25 ++--
 web/src/views/mail/VCardCard.tsx              |   3 +-
 web/src/views/settings/AboutSettings.tsx      |  25 ++--
 web/src/views/settings/AppearanceSettings.tsx |  65 +++++----
 web/src/views/settings/CalendarSettings.tsx   |  69 +++++-----
 web/src/views/settings/FiltersSettings.tsx    |  49 +++----
 web/src/views/settings/FoldersSettings.tsx    |  13 +-
 web/src/views/settings/GeneralSettings.tsx    | 127 ++++++++---------
 web/src/views/settings/IdentitiesSettings.tsx |  27 ++--
 web/src/views/settings/LabelsSettings.tsx     |   7 +-
 web/src/views/settings/RuleDialog.tsx         |  59 ++++----
 web/src/views/settings/SecuritySettings.tsx   |  70 +++++-----
 web/src/views/settings/SettingsView.tsx       |   9 +-
 web/src/views/settings/ShareDialog.tsx        |  16 ++-
 web/src/views/settings/ShortcutsSettings.tsx  |   5 +-
 web/src/views/settings/TemplatesSettings.tsx  |  17 +--
 web/src/views/settings/VacationSettings.tsx   |  19 +--
 50 files changed, 900 insertions(+), 713 deletions(-)
 create mode 100644 scripts/i18n-extract.mjs

diff --git a/scripts/i18n-extract.mjs b/scripts/i18n-extract.mjs
new file mode 100644
index 0000000..5985ff4
--- /dev/null
+++ b/scripts/i18n-extract.mjs
@@ -0,0 +1,130 @@
+#!/usr/bin/env node
+/*
+ * Wrap the strings a codemod can safely wrap, and report the ones it cannot.
+ *
+ * Roughly 1,000 strings is too many to hand-edit without introducing typos
+ * into the copy itself, and a parser does not get bored. But it must not be
+ * trusted with everything: text that is split around an interpolation arrives
+ * as separate fragments, and wrapping each fragment on its own produces
+ * "Move " and " messages", which no translator can do anything with. Those are
+ * left alone and listed, because they need a sentence built by hand.
+ *
+ *   node scripts/i18n-extract.mjs    rewrite in place
+ *   node scripts/i18n-extract.mjs --dry 
+ */
+import ts from "typescript";
+import { readFileSync, writeFileSync } from "node:fs";
+
+const ATTRS = new Set(["title", "aria-label", "placeholder", "alt", "label", "hint", "confirmLabel", "description"]);
+const NOT_PROSE = /^[\s·—–\-:;,.()[\]{}/|+×✓~<>#*@0-9]*$/u;
+/*
+ * Elements whose text is not prose however much it looks like it. `label:name`
+ * inside  is a search operator: translating it breaks the thing it
+ * documents. The first run of this wrapped exactly that, which is why the list
+ * exists.
+ */
+const CODE_TAGS = new Set(["code", "kbd", "pre", "samp", "var"]);
+/*
+ * JSX decodes HTML entities in text; a JS string literal does not. Moving
+ * `Language & region` into t("...") without decoding renders the entity
+ * literally on screen -- which the first run of this did, and which no
+ * typecheck or test noticed. It took looking at the page.
+ */
+const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\u00a0", mdash: "—", ndash: "–", hellip: "…", times: "×", middot: "·" };
+const decode = (s) => s.replace(/&(\w+);/g, (whole, name) => ENTITIES[name] ?? whole)
+  .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)));
+const tagOf = (node, src) => (ts.isJsxElement(node) ? node.openingElement.tagName.getText(src) : "");
+const optedOut = (node, src) => {
+  const opening = ts.isJsxElement(node) ? node.openingElement : ts.isJsxSelfClosingElement(node) ? node : null;
+  return Boolean(opening?.attributes.properties.some((a) =>
+    ts.isJsxAttribute(a) && a.name.getText(src) === "translate" &&
+    a.initializer && ts.isStringLiteral(a.initializer) && a.initializer.text === "no"));
+};
+
+const dry = process.argv.includes("--dry");
+const files = process.argv.slice(2).filter((a) => !a.startsWith("--"));
+let wrapped = 0;
+const skipped = [];
+
+for (const file of files) {
+  const text = readFileSync(file, "utf8");
+  const src = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
+  /*
+   * `t` is a natural name for a callback parameter, and several files already
+   * use it -- `(t: SieveTest) => ...`, `.map((t) => ...)`. An import called
+   * `t` is shadowed inside those callbacks, silently where the local happens
+   * to be callable. So the name is checked first and aliased where it is
+   * taken, per file, rather than assumed to be free.
+   */
+  let bound = false;
+  const scan = (n) => {
+    if ((ts.isParameter(n) || ts.isVariableDeclaration(n) || ts.isBindingElement(n)) && n.name && ts.isIdentifier(n.name) && n.name.text === "t") bound = true;
+    ts.forEachChild(n, scan);
+  };
+  scan(src);
+  const T = bound ? "translate" : "t";
+  /** [start, end, replacement] — applied back-to-front so offsets hold. */
+  const edits = [];
+
+  const visit = (node) => {
+    if (ts.isJsxElement(node) || ts.isJsxFragment(node)) {
+      if (CODE_TAGS.has(tagOf(node, src).toLowerCase()) || optedOut(node, src)) return;   // and not its children
+      const kids = node.children;
+      const meaningful = kids.filter((c) => !(ts.isJsxText(c) && !c.text.trim()));
+      for (const c of kids) {
+        if (!ts.isJsxText(c)) continue;
+        const raw = c.text;
+        const body = raw.trim();
+        if (body.length < 2 || NOT_PROSE.test(body)) continue;
+        // Split around an interpolation: the fragments are not sentences.
+        if (meaningful.length > 1) {
+          const { line } = src.getLineAndCharacterOfPosition(c.getStart(src));
+          skipped.push({ file, line: line + 1, why: "text split around an expression", text: body.slice(0, 52) });
+          continue;
+        }
+        if (decode(body).includes('"')) {
+          const { line } = src.getLineAndCharacterOfPosition(c.getStart(src));
+          skipped.push({ file, line: line + 1, why: "contains a quote", text: body.slice(0, 52) });
+          continue;
+        }
+        // Keep the original leading/trailing whitespace: JSX collapses it, and
+        // reflowing here would change the rendered spacing.
+        const lead = raw.slice(0, raw.indexOf(body[0]));
+        const tail = raw.slice(raw.lastIndexOf(body[body.length - 1]) + 1);
+        edits.push([c.getStart(src), c.getEnd(), `${lead}{${T}("${decode(body.replace(/\s+/g, " "))}")}${tail}`]);
+        wrapped++;
+      }
+    }
+    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 && !NOT_PROSE.test(lit.text)) {
+        if (decode(lit.text).includes('"')) {
+          const { line } = src.getLineAndCharacterOfPosition(lit.getStart(src));
+          skipped.push({ file, line: line + 1, why: "contains a quote", text: lit.text.slice(0, 52) });
+        } else {
+          edits.push([i.getStart(src), i.getEnd(), `{${T}("${decode(lit.text)}")}`]);
+          wrapped++;
+        }
+      }
+    }
+    ts.forEachChild(node, visit);
+  };
+  visit(src);
+  if (!edits.length) continue;
+
+  let out = text;
+  for (const [start, end, rep] of edits.sort((a, b) => b[0] - a[0])) out = out.slice(0, start) + rep + out.slice(end);
+  if (!/from "@\/lib\/i18n"/.test(out)) {
+    const lastImport = [...out.matchAll(/^import .*?;$/gm)].pop();
+    const decl = bound ? 'import { t as translate } from "@/lib/i18n";' : 'import { t } from "@/lib/i18n";';
+    if (lastImport) out = out.slice(0, lastImport.index + lastImport[0].length) + "\n" + decl + out.slice(lastImport.index + lastImport[0].length);
+  }
+  if (!dry) writeFileSync(file, out);
+}
+
+console.log(`${dry ? "would wrap" : "wrapped"} ${wrapped} strings across ${files.length} files`);
+if (skipped.length) {
+  console.log(`\n${skipped.length} left for a person:`);
+  for (const s of skipped) console.log(`  ${s.file.replace("web/src/", "")}:${s.line}  (${s.why})  ${s.text}`);
+}
diff --git a/web/src/ui/datefield.tsx b/web/src/ui/datefield.tsx
index 1e11b8b..d00b27b 100644
--- a/web/src/ui/datefield.tsx
+++ b/web/src/ui/datefield.tsx
@@ -14,6 +14,7 @@ import {
 } from "@/lib/datetime";
 import { dateTimeKey, useSettings } from "@/store/settings";
 import { anchorFromEl, Popover, type Anchor } from "./popover";
+import { t as translate } from "@/lib/i18n";
 
 /*
  * Date and time fields that follow the user's configured format.
@@ -74,9 +75,9 @@ function CalendarGrid({ selected, onPick, onClose }: { selected: Date | null; on
   return (
     
- + {formatMonthYear(anchor)} - +
@@ -99,8 +100,8 @@ function CalendarGrid({ selected, onPick, onClose }: { selected: Date | null; on })}
- - + +
); @@ -127,7 +128,7 @@ function TimeList({ selected, onPick }: { selected: Date | null; onPick: (hours: }, []); return ( -
+
{slots.map((t, i) => ( {anchor && ( @@ -311,7 +312,7 @@ export function DateTimeField({ value, onChange, className, disabled, required, if (e.key === "ArrowDown" && !anchor) { e.preventDefault(); open(); } }} /> - diff --git a/web/src/ui/dialog.tsx b/web/src/ui/dialog.tsx index c323ca6..8931a5b 100644 --- a/web/src/ui/dialog.tsx +++ b/web/src/ui/dialog.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { X } from "lucide-react"; import { create } from "zustand"; +import { t } from "@/lib/i18n"; interface DialogProps { open: boolean; @@ -71,7 +72,7 @@ export function Dialog({ open, onClose, title, children, footer, size = "md", cl {title !== undefined && (

{title}

-
diff --git a/web/src/ui/misc.tsx b/web/src/ui/misc.tsx index 60456fb..17c3c0d 100644 --- a/web/src/ui/misc.tsx +++ b/web/src/ui/misc.tsx @@ -3,6 +3,7 @@ import type { EmailAddress } from "@/jmap/types"; import { avatarColor, initials } from "@/lib/address"; import { useContacts } from "@/store/contacts"; import { contactPhoto } from "@/lib/contacts"; +import { t } from "@/lib/i18n"; export function Avatar({ who, size, className }: { who: EmailAddress | { name?: string | null; email?: string } | string | null | undefined; size?: "sm" | "lg" | "xl"; className?: string }) { const email = typeof who === "string" ? who : (who?.email ?? ""); @@ -82,7 +83,7 @@ export function Kbd({ keys }: { keys: string }) { {keys.split(" ").map((k, i) => ( - {i > 0 && then} + {i > 0 && {t("then")}} {k.split("+").map((p, j) => ( {p === "mod" ? (navigator.platform.includes("Mac") ? "⌘" : "Ctrl") : p === "shift" ? "⇧" : p === "enter" ? "↵" : p === "esc" ? "Esc" : p} diff --git a/web/src/ui/toast.tsx b/web/src/ui/toast.tsx index ecdc81c..a39087e 100644 --- a/web/src/ui/toast.tsx +++ b/web/src/ui/toast.tsx @@ -1,5 +1,6 @@ import { create } from "zustand"; import { X } from "lucide-react"; +import { t as translate } from "@/lib/i18n"; export interface Toast { id: number; @@ -73,7 +74,7 @@ export function ToastHost() { {t.action.label} )} - {t.progress && t.duration > 0 && } diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index 07a0b16..42c1243 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -15,6 +15,7 @@ import { CalendarSidebar } from "./calendar/CalendarSidebar"; import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts"; import { formatSize } from "@/lib/format"; import { TranslateBoundary } from "@/ui/TranslateBoundary"; +import { t } from "@/lib/i18n"; const PUSH_LABEL = { connected: "Live updates connected", @@ -67,7 +68,7 @@ export function AppShell({ children }: { children: ReactNode }) { return (
- @@ -83,14 +84,14 @@ export function AppShell({ children }: { children: ReactNode }) { - - + - @@ -104,14 +105,14 @@ export function AppShell({ children }: { children: ReactNode }) {
- } label="Documentation" href="https://docs.ihasmail.org" external /> + } label={t("Documentation")} href="https://docs.ihasmail.org" external /> {/* The project site. It is linked from the login screen footer, which is a page a signed-in user never sees again -- so from inside the app there was no way back to it. */} - } label="About ihasmail" href="https://ihasmail.org" external /> - } label="Settings" onClick={() => navigate("/settings")} /> - } label="Refresh" onClick={() => window.location.reload()} /> - } label="Sign out" onClick={() => void logout()} /> + } label={t("About ihasmail")} href="https://ihasmail.org" external /> + } label={t("Settings")} onClick={() => navigate("/settings")} /> + } label={t("Refresh")} onClick={() => window.location.reload()} /> + } label={t("Sign out")} onClick={() => void logout()} />
@@ -138,14 +139,14 @@ export function AppShell({ children }: { children: ReactNode }) { {section === "calendar" && } {section === "contacts" && } {section === "files" && } - {section === "settings" &&
Settings
} + {section === "settings" &&
{t("Settings")}
} {(section === "mail" || section === "search") && } -