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

Closed
opened 2026-08-31 16:45:06 +00:00 by jcoffey-dev · 0 comments
Owner

#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.

Scope, measured

~1,000 user-facing strings across 56 files — 586 JSX text nodes, 353 translatable attributes, and 30 toast/dialog call sites outside JSX. Counted with the TypeScript parser, not grep.

The core (lib/i18n.ts, ~130 lines, no dependencies)

English is the key. t("Archive") returns the English when there's no translation. That buys three things worth more than tidy symbolic keys: no English catalogue to keep in step with the code; a missing translation degrading to readable English rather than mail.list.archive; and extraction being wrap the string rather than invent a name for it. Names are where extraction stalls, and 55 components is a lot of small naming arguments.

The cost — editing English copy orphans its translations — 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, not (one, other). Two forms is an English assumption that doesn't survive Phase 2: Russian and Ukrainian need three, and choosing between them isn't a question about the number 1. Intl.PluralRules picks; a category the catalogue lacks falls back to other rather than rendering undefined. Tested against real Russian forms.

Interpolation is named, not positional — German reorders a sentence and means the same thing.

Catalogues are dynamically imported, so an English reader never downloads one. applyLang sets lang before kicking the load: lang is what stops Chrome offering to translate and shouldn't wait on a network request to say something it already knows.

t() is a plain function, not a hook. 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 extraction from "wrap it" into "wrap it and add a hook", for an event that happens about once per account.

Reference extraction

NotificationsSettings.tsx end to end — it covers all four shapes: JSX text, translated attributes, a toast, and a sentence with a value interpolated into it. That's the pattern for the other 50 files.

Tracking the rest

npm run i18n:coverage — because 1,000 strings is too many to eyeball in review or carry in anyone's head.

i18n extraction: 20 wrapped, 925 remaining across 50 files  (2%)

    79 left   views/settings/GeneralSettings.tsx
    56 left   views/mail/MessageView.tsx
    56 left   views/calendar/EventEditor.tsx
    ...

It deliberately doesn't count punctuation and separators as untranslated — a floor no amount of work could reach would make the number useless. A progress report, not a gate; --check exits non-zero for once the number is low enough to mean something.

Roadmap

ROADMAP.md listed translations as "English-only for now" on a page whose stated purpose is things the answer is no to. It now says what's actually happening, carries the phase order (de/fr/nl/es/pt-BR, then ru/uk/zh-Hans/ja), 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 work is how an RTL build ends up unusable with nobody saying so.

Checks

443 web tests (+10) and 109 server tests pass; typecheck and build clean. Nothing user-visible changes: English remains the only language in the picker, so this is a no-op for anyone running it.

Rebuilt from: session transcript.

#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. ## Scope, measured **~1,000 user-facing strings across 56 files** — 586 JSX text nodes, 353 translatable attributes, and 30 toast/dialog call sites outside JSX. Counted with the TypeScript parser, not grep. ## The core (`lib/i18n.ts`, ~130 lines, no dependencies) **English is the key.** `t("Archive")` returns the English when there's no translation. That buys three things worth more than tidy symbolic keys: no English catalogue to keep in step with the code; a missing translation degrading to readable English rather than `mail.list.archive`; and extraction being *wrap the string* rather than *invent a name for it*. Names are where extraction stalls, and 55 components is a lot of small naming arguments. The cost — editing English copy orphans its translations — 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, not `(one, other)`.** Two forms is an English assumption that doesn't survive Phase 2: Russian and Ukrainian need three, and choosing between them isn't a question about the number 1. `Intl.PluralRules` picks; a category the catalogue lacks falls back to `other` rather than rendering `undefined`. Tested against real Russian forms. **Interpolation is named, not positional** — German reorders a sentence and means the same thing. **Catalogues are dynamically imported**, so an English reader never downloads one. `applyLang` sets `lang` *before* kicking the load: `lang` is what stops Chrome offering to translate and shouldn't wait on a network request to say something it already knows. **`t()` is a plain function, not a hook.** 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 extraction from "wrap it" into "wrap it and add a hook", for an event that happens about once per account. ## Reference extraction `NotificationsSettings.tsx` end to end — it covers all four shapes: JSX text, translated attributes, a toast, and a sentence with a value interpolated into it. That's the pattern for the other 50 files. ## Tracking the rest `npm run i18n:coverage` — because 1,000 strings is too many to eyeball in review or carry in anyone's head. ``` i18n extraction: 20 wrapped, 925 remaining across 50 files (2%) 79 left views/settings/GeneralSettings.tsx 56 left views/mail/MessageView.tsx 56 left views/calendar/EventEditor.tsx ... ``` It deliberately doesn't count punctuation and separators as untranslated — a floor no amount of work could reach would make the number useless. A progress report, not a gate; `--check` exits non-zero for once the number is low enough to mean something. ## Roadmap `ROADMAP.md` listed translations as "English-only for now" on a page whose stated purpose is things the answer is *no* to. It now says what's actually happening, carries the phase order (de/fr/nl/es/pt-BR, then ru/uk/zh-Hans/ja), 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 work is how an RTL build ends up unusable with nobody saying so. ## Checks 443 web tests (+10) and 109 server tests pass; typecheck and build clean. Nothing user-visible changes: English remains the only language in the picker, so this is a no-op for anyone running it. <sub>Rebuilt from: session transcript.</sub>
This repo is archived. You cannot comment on issues.