Skip to content

i18n: complete every module's en catalog, and gate it in CI - #280

Merged
antosubash merged 4 commits into
mainfrom
worktree-module-translations
Aug 21, 2026
Merged

i18n: complete every module's en catalog, and gate it in CI#280
antosubash merged 4 commits into
mainfrom
worktree-module-translations

Conversation

@antosubash

Copy link
Copy Markdown
Owner

Every module shipped a locales/ directory, but little of the UI actually read it — and nothing in CI could tell. This gives every module a complete en catalog, wires it through t(), and adds the gate that would have caught the gap in the first place.

The bug

Shipping locales/en.json proved nothing about whether a page used it:

  • scripts/check_hardcoded_strings.py only guards Python magic strings (permissions, role names, Inertia page ids).
  • SM013SM016 only compare catalogs against each other, so with i18n_supported_locales = ["en"] they never fire.
  • tsc is perfectly happy with hardcoded English.

So a module could carry a full catalog no page ever read. users had 24 pages and 7 keys; keycloak had a complete catalog that zero pages touched.

What changed

1. en catalogs completed and wired (657be8d)

ModuleBeforeAfter
users24 pages, 1 using t(), 7 keys24/24, 235 keys
background_tasks8 pages, 2 using t()8/8, 99 keys
settings10 pages, 8 using t()10/10, 76 keys
dashboard4 pages, 1 using t()chrome fully translated, 49 keys
keycloak0 using t() (catalog existed, unused)2/2, 9 keys
permissions, branding, file_storage, audit_log, feature_flags<Head> titles, placeholders leakingclosed

Three surfaces needed more than a t() call:

  • Menu labels were English in Python and render on every page. MenuItem gains optional label_key/group_key, resolved in MenuRegistry.get_for_user(translate=…)server-side, so the payload carries finished text and the sidebar, topbar and command palette keep reading item.label untouched. It also sidesteps the catalog audience split. An unresolved key falls back to label, so a missing translation degrades to English rather than a raw dotted key; modules that set no keys are unaffected.
  • AuditLink.label_key does the same for audit-log entity labels.
  • relativeAgeLabel returned hardcoded English; it now returns a key + count for the caller to translate, which also makes bucketing the thing its test asserts.

Type generation now runs over every installed module rather than the booted ones. make lint runs tsc -p modules/<name> for each module in the workspace, including the auth provider this host did not activate — with a filtered union, translating keycloak's pages would have broken its build on the next regeneration while the app itself ran fine. The runtime registry stays filtered: inactive modules' strings are typed, never served.

2. The CI gate (887af06)

make ci-check-untranslated parses every .tsx and fails on user-visible text rendered as a literal: JSX text, an allowlisted text attribute (title, placeholder, aria-label, label, description, …), a toast.*/confirm argument, and copy hidden in cond ? 'A' : 'B'.

Turning it on found 45 strings the first pass missed — that pass only scanned modules/, so it never looked at the shared chrome:

  • packages/ui — sidebar "Admin Panel"/"Back to App", the whole public nav (Docs, Modules, Log in, Sign up), the ⌘K command palette, sidebar aria-labels. On screen for every user.
  • host — Landing headings, CTA copy, the module-description list.
  • permissions"{n} of {total} granted", split across three JSX fragments.

All fixed. packages/ui and host keep complete es alongside en.

Notes for review

  • New devDependency: @babel/parser (one transitive dep). Parsing, not grepping: no regex over JSX distinguishes <p>Save</p> from the Promise<void> in a type annotation, and one that tries flags both. Two dependency-free routes were tried first and rejected — TypeScript 7 exposes no stable parse API (its default export is just version), and Biome 2.5.7 GritQL plugins do match jsx_text() but // biome-ignore plugin: does not suppress plugin diagnostics, leaving no way to exempt a legitimate literal.
  • Known blind spot, documented rather than papered over: a string reaching the screen through a variable or config object (const THEME = { mobileTitleLabel: 'Admin' }) is invisible without taint analysis.
  • Exemptions used, each with a reason in-line: a JSON example placeholder, a truncated token echo, a fake terminal titlebar, and the dev-only DemoPlaceholders fixture (i18n-exempt-file).
  • Left untranslated on purpose:site_lock's unlock gate — its middleware runs beforeLocaleMiddleware, so there is no resolved locale to translate against. Fixing it would mean reordering security middleware, which does not belong in this PR.
  • es is still dormant and unchanged in scope: i18n_supported_locales defaults to ["en"], so es.json is never loaded and the switcher hides itself. Enabling Spanish would need ~688 more strings (users 234, background_tasks 98, settings 75, …). I deliberately did not machine-generate those next to the existing hand-written Spanish.
  • relativeAgeLabelrelativeAge is a breaking rename for any external caller of @simple-module-py/ui; the only call site in this repo is updated.

Verification

  • make lint → clean, including the new ci-check-untranslated
  • make test2064 Python passed, 100 JS passed (up from 88 — 12 new tests for the checker itself, so a later "fix" to a heuristic cannot quietly stop it detecting anything)
  • make doctor → 0 errors (1 pre-existing unrelated SM003 warning)
  • Gate verified end-to-end against a reintroduced regression, and the type-union guard verified by stripping a namespace
  • Branch is behind main; I checked the merge result rather than assuming — it merges cleanly, the gate reports 0 findings across 99 .tsx in the merged tree, and all 769 merged locale keys are present in the merged key union

https://claude.ai/code/session_01CHkFTspSPjV9qYTkf1MXQU

Modules shipped locale files but largely did not use them: `users` had 24
pages and 7 keys, `keycloak` had a full catalog no page read, and even
"translated" modules leaked `<Head title>`s and placeholders. Anything a
module rendered as a literal was untranslatable no matter what the catalog
said.
Every user-visible string in module `.tsx` now goes through `t()`:
users 1/24 -> 24/24 pages, 7 -> 235 keys
background_tasks 2/8 -> 8/8, +37 keys
settings 8/10 -> 10/10, +11 keys
dashboard chrome fully translated
keycloak 0/2 -> 2/2 (catalog existed, unused)
permissions, branding, file_storage, audit_log, feature_flags: leaks closed
Three surfaces needed more than a `t()` call:
* Menu labels were English in Python and render on every page. `MenuItem`
gains optional `label_key`/`group_key`, resolved in
`MenuRegistry.get_for_user(translate=…)` — server-side, so the payload
carries finished text and the sidebar, topbar and command palette keep
reading `item.label` untouched. It also sidesteps the catalog audience
split. An unresolved key falls back to `label`, so a missing translation
degrades to English rather than to a raw dotted key on screen; modules
that set no keys are unaffected.
* `AuditLink.label_key` does the same for audit-log entity labels.
* `relativeAgeLabel` returned hardcoded English. It now returns a key and a
count for the caller to translate, which also makes the bucketing the
thing its test asserts.
Type generation now runs over every *installed* module rather than the
booted ones. `make lint` runs `tsc -p modules/<name>` for each module in the
workspace, including the auth provider this host did not activate — with a
filtered union, translating keycloak's pages would have broken its build on
the next regeneration while the app itself ran fine. The runtime registry
stays filtered: inactive modules' strings are typed, never served. Covered
by a test that fails if a namespace goes missing.
Left untranslated on purpose: `site_lock`'s gate page, whose middleware runs
before `LocaleMiddleware` and so has no locale to translate against, and
`DemoPlaceholders.tsx`, dev-only fixture content behind `import.meta.env.DEV`.
`es` remains partial and is not served — `i18n_supported_locales` defaults to
`["en"]`, so the switcher hides itself and `es.json` is never loaded.
Enabling it needs ~684 strings, mostly users (234) and background_tasks (98).
Claude-Session: https://claude.ai/code/session_01CHkFTspSPjV9qYTkf1MXQU
Nothing could catch the bug the previous commit fixed.
`check_hardcoded_strings.py` only guards Python magic strings; `SM013`-`SM016`
only compare catalogs against each other, so with `i18n_supported_locales =
["en"]` they never fire; and `tsc` is perfectly happy with hardcoded English.
A module could ship a full catalog that no page ever read, and CI stayed green.
`make ci-check-untranslated` now parses every `.tsx` and fails on user-visible
text rendered as a literal: JSX text, an allowlisted text attribute (`title`,
`placeholder`, `aria-label`, `label`, `description`, …), a `toast.*`/`confirm`
argument, and copy hidden in `cond ? 'A' : 'B'`.
Turned on, it found 45 strings the previous commit missed — that pass only
scanned `modules/`, so it never looked at the shared chrome:
packages/ui sidebar "Admin Panel"/"Back to App", the whole public nav
(Docs, Modules, Log in, Sign up), the ⌘K command palette,
sidebar aria-labels — on screen for every user
host Landing headings, CTA copy, the module-description list
permissions "{n} of {total} granted", split across three JSX fragments
All fixed. `packages/ui` and `host` keep complete `es` alongside `en`.
Parsing, not grepping: no regex over JSX distinguishes `<p>Save</p>` from the
`Promise<void>` in a type annotation, and one that tries flags both. That costs
one devDependency (`@babel/parser`, one transitive dep). Two dependency-free
routes were tried first and rejected — TypeScript 7 exposes no stable parse API
(its default export is just `version`), and Biome 2.5.7 GritQL plugins do match
`jsx_text()` but `// biome-ignore plugin:` does not suppress plugin
diagnostics, leaving no way to exempt a legitimate literal.
Exempt via `<code>`/`<pre>`, `// i18n-exempt: <reason>`, or a file-level
`i18n-exempt-file: <reason>` — used here for a JSON example placeholder, a
truncated token echo, a fake terminal titlebar, and the dev-only
DemoPlaceholders fixture.
Known blind spot, documented rather than papered over: a string reaching the
screen through a variable or config object (`const THEME = { mobileTitleLabel:
'Admin' }`) is invisible without taint analysis.
Detection lives in `scripts/lib/untranslated-strings.mjs` behind 12 unit tests,
so a later "fix" to one of its heuristics cannot quietly stop it detecting
anything; vitest's include grew to cover `scripts/**/*.test.mts`. Verified
end-to-end that the gate fails on a reintroduced regression.
Claude-Session: https://claude.ai/code/session_01CHkFTspSPjV9qYTkf1MXQU
Found by rendering the app with `es` switched on. The dashboard put
`dashboard.home.health_all_good` and `dashboard.home.system_meta` on screen as
raw dotted keys.
`messages_snapshot()` shipped only the active locale's entries, and the client
initialises i18next with `fallbackLng` equal to that same locale — so there is
no cross-locale fallback in the browser and a missing key renders as the key.
The server-side `Translator` has always fallen back to the default locale,
which is why menu labels rendered English while the page body showed keys: the
two paths disagreed.
Each non-default locale snapshot now layers over the default locale's, so an
untranslated key reads as the default language. Partial translation becomes a
safe, incremental state instead of a way to put dotted keys in front of users.
The previous commit made this worse before it made it better: adding ~600 `en`
keys to namespaces whose `es` file is absent or partial widened the blast
radius from "a few English strings" to "most of the admin UI in raw keys" the
moment anyone set `i18n_supported_locales = ["en", "es"]`. Nothing shipped
broken — `es` is not served by default — but it was a trap waiting for whoever
turned Spanish on first.
Verified in the browser: with `es` enabled the dashboard now reads Spanish
where translated ("Panel", "Resumen de tu aplicación", "MÓDULOS", "SISTEMA")
and English everywhere else ("all good", "Python 3.12.3 · 11 modules"), with no
raw keys anywhere.
Claude-Session: https://claude.ai/code/session_01CHkFTspSPjV9qYTkf1MXQU
main moved the admin surfaces under /admin, renamed the sidebar groups, and
switched those pages to AdminLayout. Resolution rule throughout: main owns the
structure and the routes, this branch owns the translations.
Edit / DangerZone / RolesTab main's /admin/... routes + the t() calls
Index / Workers main's AdminLayout; UserStats and relativeAge kept
AdminLayout main made the badge a link to /admin; kept that
and made it a component so its label can use t()
host/locales/{en,es} both sides appended a section (error vs
offline/admin) at the same spot, so git read
them as alternatives — merged as JSON, all four
sections present
Error.tsx main's per-status copy.title beats the generic
host.error.head_title, which is now removed
packages/i18n/*.generated.ts regenerated, not hand-merged
main renamed the sidebar groups, so the shared vocabulary follows: ui.nav_groups
is now access / appearance / content / system, with Spanish for each. The keys
are shared precisely so one module's "System" cannot drift from another's.
The untranslated-string gate then failed on code main had just added:
modules/users/users/admin/components/UserRow.tsx
88: [attr:aria-label] Edit
`aria-label={`Edit ${user.email}`}` — deliberately per-row so a screen-reader
user can tell rows apart, and real copy with an interpolated value. Kept the
intent, moved the string to users.user_row.edit_aria.
Verified against the merged tree: 2148 Python + 122 JS tests pass, `make lint`
clean (gate included), `make doctor` 0 errors, and all 23 pages re-rendered in a
browser on the new /admin routes with no i18n key reaching the screen.
Claude-Session: https://claude.ai/code/session_01CHkFTspSPjV9qYTkf1MXQU
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying simple-module-python with Cloudflare Pages Cloudflare Pages

Latest commit:69dc0e6
Status: ✅ Deploy successful!
Preview URL:https://d0904717.simple-module-python.pages.dev
Branch Preview URL:https://worktree-module-translations.simple-module-python.pages.dev

View logs

@antosubash
antosubash merged commit 9ad996d into mainAug 21, 2026
13 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@antosubash