Stacked on #146. 78% extracted — 515 wrapped, 143 remaining across 37 files.
Why a codemod
Wrapping ~1,000 strings by hand is a thousand chances to mistype the copy itself, and a parser doesn't 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.
What it refuses matters as much as what it does:
Text split around an interpolation arrives as separate fragments. Wrapping each on its own gives "Move " and " messages", which no translator can work with. Listed for a person to rebuild as whole sentences — that's most of the remaining 143.
Anything containing a double quote, which would end the literal.
Three things it had to be taught, each found by running it
<code>, <kbd>, <pre> are not prose. The first run wrapped label:name inside <code> — a search operator, where translating it breaks the thing it documents. Subtrees marked translate="no" are skipped for the same reason, which ties this to the boundaries set in #145.
t is a natural callback parameter name, and several files already use it ((t: SieveTest) =>, .map((t) =>). An import called t is shadowed inside those callbacks — silently, wherever the local happens to be callable. The name is now checked per file and aliased to translate where taken.
JSX decodes HTML entities; a JS string literal does not.Language & region moved into t("...") and rendered the entity on screen.
The bug worth dwelling on
That last one passed typecheck. It passed 443 tests. And the settings page said "Language & region" in plain sight.
It took looking at a screenshot to notice, and then a sweep of ten rendered views to find a second occurrence — in a sentence I'd written by hand earlier the same day, in #145. Nothing in the toolchain was ever going to catch it: 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.
Verification
443 web tests, 109 server tests, typecheck and build all clean.
Browser sweep across 10 views (mail, six settings pages, calendar, contacts, files) confirming no entity leaks, no emptied headings or labels, and no t( leaking into rendered text.
Spot-checked the diff on the heaviest files.
Nothing user-visible changes — English is still the only language in the picker.
What's left
143 strings, nearly all of them sentences split around an interpolation. Those need a person deciding where the placeholder goes, which is exactly the work t("Move {n} to {folder}") and plural() were designed for. Worst offenders: GeneralSettings (15), FiltersSettings (13), MessageView (12).
Stacked on #146. **78% extracted** — 515 wrapped, 143 remaining across 37 files.
## Why a codemod
Wrapping ~1,000 strings by hand is a thousand chances to mistype the copy itself, and a parser doesn't 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**.
What it refuses matters as much as what it does:
- **Text split around an interpolation** arrives as separate fragments. Wrapping each on its own gives `"Move "` and `" messages"`, which no translator can work with. Listed for a person to rebuild as whole sentences — that's most of the remaining 143.
- **Anything containing a double quote**, which would end the literal.
## Three things it had to be taught, each found by running it
**`<code>`, `<kbd>`, `<pre>` are not prose.** The first run wrapped `label:name` inside `<code>` — a search operator, where translating it breaks the thing it documents. Subtrees marked `translate="no"` are skipped for the same reason, which ties this to the boundaries set in #145.
**`t` is a natural callback parameter name**, and several files already use it (`(t: SieveTest) =>`, `.map((t) =>`). An import called `t` is shadowed inside those callbacks — silently, wherever the local happens to be callable. The name is now checked per file and aliased to `translate` where taken.
**JSX decodes HTML entities; a JS string literal does not.** `Language & region` moved into `t("...")` and rendered the entity on screen.
## The bug worth dwelling on
That last one passed typecheck. It passed 443 tests. And the settings page said **"Language & region"** in plain sight.
It took looking at a screenshot to notice, and then a sweep of ten rendered views to find a second occurrence — in a sentence I'd written by hand earlier the same day, in #145. Nothing in the toolchain was ever going to catch it: 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.
## Verification
- 443 web tests, 109 server tests, typecheck and build all clean.
- Browser sweep across 10 views (mail, six settings pages, calendar, contacts, files) confirming no entity leaks, no emptied headings or labels, and no `t(` leaking into rendered text.
- Spot-checked the diff on the heaviest files.
Nothing user-visible changes — English is still the only language in the picker.
## What's left
143 strings, nearly all of them sentences split around an interpolation. Those need a person deciding where the placeholder goes, which is exactly the work `t("Move {n} to {folder}")` and `plural()` were designed for. Worst offenders: `GeneralSettings` (15), `FiltersSettings` (13), `MessageView` (12).
**Merged** 2026-08-31 as coffey-labs/ihasmail@46c1dc28e36e
<sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Stacked on #146. 78% extracted — 515 wrapped, 143 remaining across 37 files.
Why a codemod
Wrapping ~1,000 strings by hand is a thousand chances to mistype the copy itself, and a parser doesn't get bored.
scripts/i18n-extract.mjsdoes the mechanical part — JSX text and the attributes a person actually reads — and refuses the rest rather than guessing.What it refuses matters as much as what it does:
"Move "and" messages", which no translator can work with. Listed for a person to rebuild as whole sentences — that's most of the remaining 143.Three things it had to be taught, each found by running it
<code>,<kbd>,<pre>are not prose. The first run wrappedlabel:nameinside<code>— a search operator, where translating it breaks the thing it documents. Subtrees markedtranslate="no"are skipped for the same reason, which ties this to the boundaries set in #145.tis a natural callback parameter name, and several files already use it ((t: SieveTest) =>,.map((t) =>). An import calledtis shadowed inside those callbacks — silently, wherever the local happens to be callable. The name is now checked per file and aliased totranslatewhere taken.JSX decodes HTML entities; a JS string literal does not.
Language & regionmoved intot("...")and rendered the entity on screen.The bug worth dwelling on
That last one passed typecheck. It passed 443 tests. And the settings page said "Language & region" in plain sight.
It took looking at a screenshot to notice, and then a sweep of ten rendered views to find a second occurrence — in a sentence I'd written by hand earlier the same day, in #145. Nothing in the toolchain was ever going to catch it: 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.
Verification
t(leaking into rendered text.Nothing user-visible changes — English is still the only language in the picker.
What's left
143 strings, nearly all of them sentences split around an interpolation. Those need a person deciding where the placeholder goes, which is exactly the work
t("Move {n} to {folder}")andplural()were designed for. Worst offenders:GeneralSettings(15),FiltersSettings(13),MessageView(12).Merged 2026-08-31 as coffey-labs/ihasmail@46c1dc28e3
Rebuilt from: git history, session transcript.