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") && } -