From 8ea611f7f7e65adae07cca5133eabb2e34db50f6 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 31 Aug 2026 09:58:33 -0700 Subject: [PATCH] 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") && } -