From 657be8dcb99f047bd425294529e2fa5eccc2c856 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 21 Aug 2026 13:51:30 +0200 Subject: [PATCH 1/3] i18n: give every module a complete `en` catalog and wire it up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ``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/` 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 --- CLAUDE.md | 3 + docs/framework-conventions.md | 16 + .../core/simple_module_core/audit_links.py | 5 + framework/core/simple_module_core/menu.py | 36 +- framework/core/tests/test_menu.py | 59 +++ .../simple_module_hosting/_inertia_shared.py | 26 ++ .../simple_module_hosting/app_builder.py | 8 +- .../simple_module_hosting/i18n_manifest.py | 18 + .../simple_module_hosting/middleware.py | 7 +- ...test_i18n_types_cover_installed_modules.py | 66 +++ .../tests/test_strict_discovery_wiring.py | 7 +- .../audit_log/audit_log/endpoints/views.py | 4 +- modules/audit_log/audit_log/locales/en.json | 3 + modules/audit_log/audit_log/module.py | 2 + modules/audit_log/audit_log/pages/Browse.tsx | 2 +- modules/audit_log/audit_log/resolve.py | 18 +- .../audit_log/tests/test_audit_log_resolve.py | 28 ++ .../background_tasks/locales/en.json | 55 ++- .../background_tasks/module.py | 3 + .../background_tasks/pages/Detail.tsx | 77 ++-- .../background_tasks/pages/Index.tsx | 57 ++- .../background_tasks/pages/Workers.tsx | 62 ++- .../pages/components/ExecutionRow.tsx | 18 +- .../pages/components/RetryConfirmDialog.tsx | 27 +- .../pages/components/StatusStrip.tsx | 9 +- .../background_tasks/pages/constants.ts | 17 + .../background_tasks/pages/retry.ts | 6 +- .../branding/components/BannerField.tsx | 6 +- modules/branding/branding/locales/en.json | 6 +- modules/branding/branding/module.py | 2 + modules/dashboard/dashboard/locales/en.json | 42 +- modules/dashboard/dashboard/module.py | 2 + modules/dashboard/dashboard/pages/Doctor.tsx | 68 ++- modules/dashboard/dashboard/pages/Home.tsx | 25 +- .../feature_flags/locales/en.json | 3 + modules/feature_flags/feature_flags/module.py | 2 + .../feature_flags/pages/Browse.tsx | 2 +- .../file_storage/file_storage/locales/en.json | 6 +- modules/file_storage/file_storage/module.py | 2 + .../file_storage/pages/Browse.tsx | 2 +- modules/keycloak/keycloak/locales/en.json | 6 +- modules/keycloak/keycloak/module.py | 1 + modules/keycloak/keycloak/pages/LoggedOut.tsx | 8 +- modules/keycloak/keycloak/pages/Login.tsx | 4 +- .../permissions/permissions/locales/en.json | 10 +- .../permissions/pages/RoleEdit.tsx | 6 +- .../permissions/pages/UserEdit.tsx | 4 +- modules/settings/settings/locales/en.json | 28 +- modules/settings/settings/module.py | 3 + modules/settings/settings/pages/Browse.tsx | 4 +- modules/settings/settings/pages/Create.tsx | 6 +- modules/settings/settings/pages/Edit.tsx | 6 +- .../settings/settings/pages/ModulesEdit.tsx | 2 +- .../settings/pages/components/FieldInput.tsx | 4 +- .../settings/pages/components/ModuleForm.tsx | 12 +- .../settings/pages/components/ValueInput.tsx | 4 +- .../users/admin/components/IndexFilters.tsx | 22 +- .../users/users/admin/components/RolesTab.tsx | 15 +- .../users/users/admin/components/UserRow.tsx | 16 +- modules/users/users/locales/en.json | 278 +++++++++++ modules/users/users/module.py | 5 + modules/users/users/pages/AcceptInvite.tsx | 35 +- modules/users/users/pages/ForgotPassword.tsx | 29 +- modules/users/users/pages/Login.tsx | 38 +- modules/users/users/pages/Profile.tsx | 41 +- modules/users/users/pages/Register.tsx | 50 +- modules/users/users/pages/ResetPassword.tsx | 28 +- modules/users/users/pages/Users/AddPeople.tsx | 46 +- modules/users/users/pages/Users/Edit.tsx | 104 ++--- modules/users/users/pages/Users/Index.tsx | 63 ++- .../Users/components/AccountStatusCard.tsx | 46 +- .../Users/components/CreateUserFields.tsx | 19 +- .../pages/Users/components/DangerZone.tsx | 37 +- .../pages/Users/components/DetailsCard.tsx | 10 +- .../pages/Users/components/InviteFields.tsx | 10 +- .../pages/Users/components/InviteResults.tsx | 19 +- .../pages/Users/components/MetadataCard.tsx | 37 +- .../pages/Users/components/RolePicker.tsx | 11 +- .../pages/Users/components/RolesCard.tsx | 6 +- .../pages/Users/components/UserStats.tsx | 30 ++ .../pages/Users/components/useUserActions.ts | 90 ++++ modules/users/users/pages/VerifyEmail.tsx | 48 +- packages/i18n/src/generated-resources.ts | 346 ++++++++++++++ packages/i18n/src/keys.generated.ts | 436 ++++++++++++++++++ packages/ui/locales/en.json | 12 + packages/ui/locales/es.json | 12 + packages/ui/src/lib/relative-time.test.ts | 26 +- packages/ui/src/lib/relative-time.ts | 34 +- 88 files changed, 2366 insertions(+), 548 deletions(-) create mode 100644 framework/hosting/tests/test_i18n_types_cover_installed_modules.py create mode 100644 modules/users/users/pages/Users/components/UserStats.tsx create mode 100644 modules/users/users/pages/Users/components/useUserActions.ts diff --git a/CLAUDE.md b/CLAUDE.md index ab872d90..7f9392f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,6 +97,9 @@ Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (b - **Framework vs plugin coupling**: `SM009` is an error if `framework/*` directly imports from a plugin module. Framework code must not reach into `modules/`. - **Zod schemas with translated messages** must be constructed inside a hook (`useT()`) — never at module scope, or they freeze against the first render's locale. - **Locales**: ship `/locales/.json` and declare in `ModuleBase.locale_dirs()` with the module's lowercase name as the namespace. `{"browse": {"title": "X"}}` flattens to `.browse.title`. Pluralize with CLDR suffixes (`_one`, `_other`, ...). +- **No user-visible string literals in `.tsx`.** Every rendered string — JSX text, `placeholder`, `aria-label`, ``, `toast.*()`, confirm text — goes through `t(keys..…)` from `@simple-module-py/i18n`. Exempt: shell commands, env-var names, and JSON examples shown as literal ``. In non-component modules (a `retry.ts` helper) import the non-hook `t` and call it *inside* the function, never at module scope, or it freezes against the boot locale. +- **Menu labels**: set `label_key`/`group_key` on `MenuItem` next to `label`/`group`; group headers use the shared `ui.nav_groups.*` keys. Menus are translated server-side in `MenuRegistry.get_for_user(translate=…)`, and an unresolved key falls back to the literal `label`. See [docs/framework-conventions.md](docs/framework-conventions.md) § Shared props. +- Regenerate `packages/i18n/src/{keys.generated,generated-resources}.ts` after touching any catalog — booting the host in development does it, and `t()` only accepts keys present there. - **Ty (type checker) false positives** from SQLModel: `unresolved-attribute`, `unsupported-operator`, `unknown-argument`, `no-matching-overload`, `invalid-argument-type` are all globally ignored in `pyproject.toml` because SQLModel declares fields with plain Python types while runtime instruments them as SQLAlchemy attributes. Do not re-enable these rules — real bugs surface in tests. ## Diagnostic codes diff --git a/docs/framework-conventions.md b/docs/framework-conventions.md index 9cf27e9b..1a83bbbd 100644 --- a/docs/framework-conventions.md +++ b/docs/framework-conventions.md @@ -232,6 +232,22 @@ way. Pick a band by audience, leave gaps of ~10 between siblings, and put module-specific user-dropdown items in the `900+` range (Profile=990, Logout=999). Sidebar items can also set `group="