From ac4c4bd2673548b9c110a389161f666240d7e5be Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 14 Sep 2026 08:03:12 -0700 Subject: [PATCH] Check component props for untranslated literals, and fail on a finding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit i18n-literals checked title, aria-label, placeholder and alt on elements, but not the same English passed to a component, so a MenuItem label or a Popover ariaLabel written as a literal went through. It also excused a literal that happened to be a catalogue key. That exemption is meant for English held in a constant and translated where it renders, and a literal written straight into a JSX attribute has no such render site. No component passes its props through t(). And neither half of i18n:check was run with --check, so a finding printed and the script still exited 0. Component props are checked now, a key no longer excuses a literal in an attribute, and both scripts run with --check. That found 28 strings rendering English in every language: 19 already had keys and are wrapped, and 9 are new keys in all nine catalogues. The contact editor's Save and Saving… buttons are wrapped as well, on the same line. --- CONTRIBUTING.md | 2 +- KNOWN-ISSUES.md | 2 +- package.json | 2 +- scripts/i18n-literals.mjs | 21 +++++++++++++++++---- web/src/locales/de.ts | 9 +++++++++ web/src/locales/es.ts | 9 +++++++++ web/src/locales/fr.ts | 9 +++++++++ web/src/locales/ja.ts | 9 +++++++++ web/src/locales/nl.ts | 9 +++++++++ web/src/locales/pt-BR.ts | 9 +++++++++ web/src/locales/ru.ts | 9 +++++++++ web/src/locales/uk.ts | 9 +++++++++ web/src/locales/zh-Hans.ts | 9 +++++++++ web/src/ui/datefield.tsx | 4 ++-- web/src/views/compose/Composer.tsx | 4 ++-- web/src/views/contacts/ContactEditor.tsx | 2 +- web/src/views/mail/MailboxTree.tsx | 2 +- web/src/views/mail/MessageList.tsx | 8 ++++---- web/src/views/mail/MessageView.tsx | 4 ++-- web/src/views/mail/ThreadView.tsx | 8 ++++---- 20 files changed, 117 insertions(+), 23 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4d63912..e3315e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,7 +92,7 @@ in all nine languages. Nothing errors. The only signal is the fallback count going up, so read it: ```sh -npm run i18n:check # literals wrapped, and catalogue health +npm run i18n:check # literals wrapped, and catalogue health; exits 1 on a finding node scripts/i18n-catalog-check.mjs # per-language: translated / used / falling back ``` diff --git a/KNOWN-ISSUES.md b/KNOWN-ISSUES.md index 33ee6e1..eb97029 100644 --- a/KNOWN-ISSUES.md +++ b/KNOWN-ISSUES.md @@ -62,7 +62,7 @@ works the same way — and dropped where 0.15 was the whole subject. Support for - **All nine translations have never been read by anybody who speaks them.** They were produced by AI against standard dictionaries on 2026-08-31 — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, which with English makes ten languages in the picker — and every one of the nine is marked **Beta** in the picker, with that stated in Settings beside a link for reporting anything that reads wrongly. This is the entry that matters most on this page, because it is the one thing here that cannot be closed by testing: a translation can be complete, consistent, pass every check, and still read like a machine wrote it, and nobody on this project can tell which. What *is* verified is the machinery around them. A missing key renders its English source, so a bad line can simply be deleted; a stale key — one whose English no longer exists — is caught by `npm run i18n:check` rather than sitting in the file looking correct and never being looked up. Plurals are asked of `Intl.PluralRules` rather than assumed, which is why Russian and Ukrainian carry three forms and Japanese and Chinese carry one; supplying `one` for Japanese would have been filling in a distinction the language does not draw. Confirmed live on the deployed instance (2026-08-31) against a 6,289-message mailbox: role folders localise and the ~20 custom folders keep the names their owner gave them, dates and the calendar follow the language, and 6,289 renders as *6289 листувань* — the genitive plural a number ending in nine takes, which is the first time the plural machinery ran on anything but a hand-picked value. -- **`npm run i18n:coverage` reported 100% while about two hundred strings rendered English in every language.** It reads JSX text, and it was not wrong about what it measured — none of them were JSX text. They were `toast.error(...)` arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and `aria-label=` attributes, and template literals: every one built from an expression a codemod cannot read. The calendar's own view switcher was the clearest case, spelling its labels `v[0].toUpperCase() + v.slice(1)` — correct English, untranslatable anywhere else, and galling because **Day**, **Week**, **Month** and **Agenda** were already in all nine catalogues and the buttons simply never asked for them. Reported from production, where the switcher stayed English in a Japanese interface. All of them are now wrapped, and `npm run i18n:check` grew a second half (`scripts/i18n-literals.mjs`) that accepts a string wrapped where it is written *or* present as a catalogue key — the constant-table convention, where `SECTIONS` holds `label: "About"` and the render site calls `t(s.label)` — and refuses one that is neither, because that is a string no catalogue can translate however many languages ship. It found twenty more than a hand sweep had. Worth recording as a general lesson rather than an i18n one: a coverage number measures the thing it can see, and the strings it cannot see are exactly the ones nobody is checking. +- **`npm run i18n:coverage` reported 100% while about two hundred strings rendered English in every language.** It reads JSX text, and it was not wrong about what it measured — none of them were JSX text. They were `toast.error(...)` arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and `aria-label=` attributes, and template literals: every one built from an expression a codemod cannot read. The calendar's own view switcher was the clearest case, spelling its labels `v[0].toUpperCase() + v.slice(1)` — correct English, untranslatable anywhere else, and galling because **Day**, **Week**, **Month** and **Agenda** were already in all nine catalogues and the buttons simply never asked for them. Reported from production, where the switcher stayed English in a Japanese interface. All of them are now wrapped, and `npm run i18n:check` grew a second half (`scripts/i18n-literals.mjs`) that accepts a string wrapped where it is written *or* present as a catalogue key — the constant-table convention, where `SECTIONS` holds `label: "About"` and the render site calls `t(s.label)` — and refuses one that is neither, because that is a string no catalogue can translate however many languages ship. It found twenty more than a hand sweep had. Worth recording as a general lesson rather than an i18n one: a coverage number measures the thing it can see, and the strings it cannot see are exactly the ones nobody is checking. **The check had the same blind spot one level down (2026-09-14).** It looked at `title=`, `aria-label=`, `placeholder=` and `alt=` on elements, but not at props passed to components, so `` passed. It also accepted a JSX literal that was a catalogue key, although no component here runs its props through `t()`, so 19 strings with translations in every catalogue (Report spam, Mark as read, Add star, Save…) still rendered in English. And the script only exited non-zero with `--check`, which `npm run i18n:check` never passed, so it could print a finding without failing. Component props are checked now, a key no longer excuses a literal in an attribute, and both halves run with `--check`. That turned up 28 strings, all fixed: 19 wrapped, and 9 that needed new keys in all nine catalogues. Still not covered: English built with a template literal inside an attribute, such as ``aria-label={`Remove ${email}`}``. Such a string can't be a catalogue key as written, and a handful remain in the calendar and contacts sidebars, the recipient chips, the address menu, attachment titles and the contact editor's "Edit …" title. - **A compressing hop in front of Stalwart truncated every blob download, and nothing said so.** Node decompresses a gzip response before the code ever sees the body, but leaves the `content-length` header describing the *compressed* bytes. The blob proxy copied that header onto the longer body it forwarded, so the browser stopped reading exactly that many bytes in and called the download complete. Reported on [#76](https://github.com/Coffey-Labs/ihasmail/issues/76) against a Coolify deployment, where Traefik's compress middleware only engages above 1 KiB: filter rules one and two were fine and the third pushed the script past the threshold, after which it came back cut off mid-rule — 384 bytes of a 1.3 KB script. The size threshold is what made it look like a race. This is the *second* cause behind that issue, and the first fix did not touch it: a truncated script is neither unknown nor empty, so the "refuse to save from a baseline we could not read" guard never fired — the script parsed, just with rules missing, and the next save wrote the short version back over the real one. Every blob download shared the fault, not just Sieve: message source, vCards, signature HTML, attachments being forwarded, and the `settings.json` sync. Settings degraded honestly by luck rather than design — a truncated file fails `JSON.parse`, which is caught and leaves the local cache in charge — so it stopped syncing between devices instead of being overwritten. The proxy now asks upstream for `identity` and, for a hop that compresses anyway, forwards no length at all rather than one describing different bytes. The image proxy is unaffected: it uses `node:http` directly, sends no `accept-encoding`, and never decompresses. The save path no longer trusts the transport either: a script is now checked for completeness against the shape the generator emits — every `# rule:` comment parses, every enabled rule has an `if` and a closed body below it, every block ends with a blank line — and saving refuses on anything short, as does the rule editor, which reports the script as unreadable rather than showing the rules that happened to parse. The check is structural rather than a re-serialize-and-compare, so a script written by an older version with a different serializer is still editable; refusing over a changed byte would be the worse bug. It catches a cut at every offset except the end of a complete rule block, which is a legitimately shorter script and indistinguishable from one in the bytes alone — that residual is what the proxy fix covers. diff --git a/package.json b/package.json index d8b71b7..02e2713 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "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\"", "i18n:coverage": "node scripts/i18n-coverage.mjs", - "i18n:check": "node scripts/i18n-catalog-check.mjs && node scripts/i18n-literals.mjs", + "i18n:check": "node scripts/i18n-catalog-check.mjs --check && node scripts/i18n-literals.mjs --check", "dev:mock:no-keyword-sort": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-keyword-sort -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"" }, "devDependencies": { diff --git a/scripts/i18n-literals.mjs b/scripts/i18n-literals.mjs index b26e506..c2f50f4 100644 --- a/scripts/i18n-literals.mjs +++ b/scripts/i18n-literals.mjs @@ -40,7 +40,13 @@ const UI_PROPS = new Set([ "title", "message", "label", "confirmLabel", "cancelLabel", "ariaLabel", "placeholder", "hint", "occurrenceLabel", "occurrenceHint", "seriesLabel", "seriesHint", ]); -const UI_ATTRS = new Set(["title", "aria-label", "placeholder", "alt"]); +/* + * A JSX attribute is shown whether it lands on an element or on a component: + * `` renders its label as given, exactly as + * ` {anchor && ( - { setAnchor(null); inputRef.current?.focus(); }} role="dialog" className="dp-pop" closeOnClick={false} ariaLabel="Choose a date"> + { setAnchor(null); inputRef.current?.focus(); }} role="dialog" className="dp-pop" closeOnClick={false} ariaLabel={translate("Choose a date")}> { setAnchor(null); inputRef.current?.focus(); }} @@ -335,7 +335,7 @@ export function DateTimeField({ value, onChange, className, disabled, required, {anchor && ( - +
- } label={d.sendAt !== null ? "Send now instead" : "Send"} kbd={d.sendAt !== null ? undefined : "Ctrl+↵"} onClick={() => { if (d.sendAt !== null) patch({ sendAt: null }); sendMenu.close(); void doSend(); }} /> + } label={d.sendAt !== null ? translate("Send now instead") : translate("Send")} kbd={d.sendAt !== null ? undefined : "Ctrl+↵"} onClick={() => { if (d.sendAt !== null) patch({ sendAt: null }); sendMenu.close(); void doSend(); }} /> } label={translate("Undo window: {seconds}s", { seconds: settings.undoSendSeconds })} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} /> {canSchedule && { sendMenu.close(); setScheduleOpen(true); }} />} @@ -323,7 +323,7 @@ export function Composer({ draft }: { draft: Draft }) { - } label={d.format === "html" ? "Switch to plain text" : "Switch to rich text"} onClick={toggleFormat} /> + } label={d.format === "html" ? translate("Switch to plain text") : translate("Switch to rich text")} onClick={toggleFormat} /> } label={translate("Request read receipt")} checked={d.requestReceipt} onClick={() => patch({ requestReceipt: !d.requestReceipt })} /> {translate("Priority")} diff --git a/web/src/views/contacts/ContactEditor.tsx b/web/src/views/contacts/ContactEditor.tsx index a8161d9..dd61392 100644 --- a/web/src/views/contacts/ContactEditor.tsx +++ b/web/src/views/contacts/ContactEditor.tsx @@ -156,7 +156,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props) const photoSrc = photo?.dataUrl ?? (!removePhoto && existingPhoto ? (existingPhoto.uri?.startsWith("data:") ? existingPhoto.uri : existingPhoto.blobId ? client.downloadUrl(contacts.accountId!, existingPhoto.blobId, "photo", existingPhoto.mediaType ?? "image/jpeg", true) : null) : null); return ( - }> + }>