diff --git a/docs/i18n.md b/docs/i18n.md index f4071b9c56..1e756511cb 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -282,7 +282,19 @@ That is why module-scope config objects survived the sweep, and why the new-code ratchet inherits the same hole. `check-module-scope-t.mjs` catches the adjacent case (a module-scope `t()` call, frozen at the boot locale) but not module-scope literal copy. Reviewing a new `const TABLE = [...]` of labels by eye -is still required. +is still required — and note the shape one step out from a config table is just +as invisible: **a plain function returning object literals**, e.g. a +`buildRows()` that returns `[{ label: "Disk usage" }]`, is never inspected either. + +**Walk the hooks your routes call, not just the components.** A closure derived +from component imports misses them, and they hold no JSX so the string count +reports them as zero. Settings → Agents shipped that way and needed a follow-up +for a thrown `Error` and a `setError` fallback in two `hooks/domains/settings/` +modules; Settings → System → Storage caught ten toast titles in +`use-storage-maintenance.ts` only because it re-derived its closure instead of +trusting the count. Install/update flows and any maintenance action are the +toast-heavy cases. A count from `pnpm run lint:i18n` is a floor on the visible +half, never a work-list. `externalize-strings.mjs` covers those shapes — it walks `.ts` as well as `.tsx` and handles template @@ -391,11 +403,67 @@ through `t()` — that is the acceptance check for a migrated screen, and the on one that sees the guard's blind spots. Interpolation placeholders and `` tag markers are preserved by the generator. +Two qualifications, both below: an **interpolated API or network payload** stays +English by design, so it is not a miss even though it renders unaccented; and +plain English is not a *complete* signal, because a payload inside a translated +frame renders accented and reads as done. + It has one blind spot of its own: **module-scope `t()`**. That copy *is* translated, just frozen at the boot locale, so it renders accented under pseudo and looks correct while never responding to a switch. `check-module-scope-t.mjs` is what catches it. +### …and it is weakest at an interpolated value + +Plain-English-on-screen is not a complete signal. When an un-migrated value is +interpolated into a **migrated** frame, the frame renders accented and the +payload reads as part of the sentence: + +```text +agents:unableToStartUpdate = "Unable to start update: {{error}}" +→ Ữńàƀĺē ţō śţàŕţ ūþďàţē: Agent installation is already in progress. +``` + +That scans as done. Both instances found this way survived a twelve-screen pseudo +walk of the migrated surface before anyone noticed them. + +So when a message interpolates a value, ask **where the value originates**. That +distinction is the whole check, and it is a two-minute grep over the paths you +migrated: + +```bash +cd apps/web +grep -rnE 't\("[^"]+", \{ (error|message)' components app hooks | grep -v '\.test\.' +``` + +- **An API or network error payload stays English by design.** `String(err)` from + a failed request, or a `result.error` the backend returned, is a diagnostic — + see [Backend](#what-the-backend-deliberately-does-not-translate) for why the + ~1,100 server error strings are out of scope. Around 37 catalog keys already + interpolate one; none of them is a miss. Where such a call needs a fallback for + a missing payload, the *fallback* is copy and is translated + (`t("linear:testFailed", { error: result.error || t("linear:unknownError") })`) + while the payload itself is not. +- **A client-side literal reaching a translated frame is a miss.** A string + thrown, `setError`'d, or assigned in one of your own hooks or helpers is copy + you wrote, and neither the lint rule nor the pseudo-locale can see it. + +**A translated toast followed by an English `throw` is correct, not a miss.** +This shape recurs across the migrated GitHub and Jira settings and should not be +churned: + +```tsx +} catch { + toast({ description: t("github:failedToSaveQuickActions"), variant: "error" }); + // Signals failure to the settings save coordinator, which only records the + // contributor id — this message never reaches a user, so it is not copy. + throw new Error("Failed to save quick actions"); +} +``` + +The user already saw translated copy; the throw is control flow for +`useSettingsSaveContributor`. Translating it would change nothing on screen. + While the migration is in flight, an unmigrated screen is expected to be full of plain English — pseudo only proves anything about screens whose components are on the `i18nGuardFiles` allowlist.