i18n: complete every module's en catalog, and gate it in CI - #280
Merged
Conversation
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_01CHkFTspSPjV9qYTkf1MXQUFound 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_01CHkFTspSPjV9qYTkf1MXQUmain 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_01CHkFTspSPjV9qYTkf1MXQUDeploying simple-module-python with |
| 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 |
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 completeencatalog, wires it throught(), and adds the gate that would have caught the gap in the first place.The bug
Shipping
locales/en.jsonproved nothing about whether a page used it:scripts/check_hardcoded_strings.pyonly guards Python magic strings (permissions, role names, Inertia page ids).SM013–SM016only compare catalogs against each other, so withi18n_supported_locales = ["en"]they never fire.tscis perfectly happy with hardcoded English.So a module could carry a full catalog no page ever read.
usershad 24 pages and 7 keys;keycloakhad a complete catalog that zero pages touched.What changed
1.
encatalogs completed and wired (657be8d)userst(), 7 keysbackground_taskst()settingst()dashboardt()keycloakt()(catalog existed, unused)permissions,branding,file_storage,audit_log,feature_flags<Head>titles, placeholders leakingThree surfaces needed more than a
t()call:MenuItemgains optionallabel_key/group_key, resolved inMenuRegistry.get_for_user(translate=…)— server-side, so the payload carries finished text and the sidebar, topbar and command palette keep readingitem.labeluntouched. It also sidesteps the catalog audience split. An unresolved key falls back tolabel, so a missing translation degrades to English rather than a raw dotted key; modules that set no keys are unaffected.AuditLink.label_keydoes the same for audit-log entity labels.relativeAgeLabelreturned 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 lintrunstsc -p modules/<name>for each module in the workspace, including the auth provider this host did not activate — with a filtered union, translatingkeycloak'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-untranslatedparses every.tsxand fails on user-visible text rendered as a literal: JSX text, an allowlisted text attribute (title,placeholder,aria-label,label,description, …), atoast.*/confirmargument, and copy hidden incond ? '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/uiandhostkeep completeesalongsideen.Notes for review
@babel/parser(one transitive dep). Parsing, not grepping: no regex over JSX distinguishes<p>Save</p>from thePromise<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 justversion), and Biome 2.5.7 GritQL plugins do matchjsx_text()but// biome-ignore plugin:does not suppress plugin diagnostics, leaving no way to exempt a legitimate literal.const THEME = { mobileTitleLabel: 'Admin' }) is invisible without taint analysis.DemoPlaceholdersfixture (i18n-exempt-file).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.esis still dormant and unchanged in scope:i18n_supported_localesdefaults to["en"], soes.jsonis 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.relativeAgeLabel→relativeAgeis 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 newci-check-untranslatedmake test→ 2064 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 unrelatedSM003warning)main; I checked the merge result rather than assuming — it merges cleanly, the gate reports 0 findings across 99.tsxin the merged tree, and all 769 merged locale keys are present in the merged key unionhttps://claude.ai/code/session_01CHkFTspSPjV9qYTkf1MXQU