Extract 515 strings by codemod #147

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

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 &amp; 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@46c1dc28e3

Rebuilt from: git history, session transcript.

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 &amp; 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 &amp; 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.