diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f50de711..3f98f120 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -85,6 +85,9 @@ jobs: cache: "npm" - run: make install-js - run: make ci-js-lint + # Catches user-visible text rendered as a literal instead of t(keys.…). + # Shipping a locales/en.json never proved a page actually read it. + - run: make ci-check-untranslated js-typecheck: name: JS typecheck diff --git a/CLAUDE.md b/CLAUDE.md index 2c5b8f81..d6f98a37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,6 +99,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`** — enforced by `make ci-check-untranslated`, see § CI. 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 @@ -117,7 +120,11 @@ E2E tests live in `tests/e2e/` behind the `e2e` pytest marker and run against a ## CI -`.github/workflows/pr.yml` runs Python lint / typecheck / tests, JS lint / typecheck / tests, and the 300-line file-size check as parallel jobs; `make lint` locally runs the same checks serially. Branch protection requires the aggregate `pr-checks` job. +`.github/workflows/pr.yml` runs Python lint / typecheck / tests, JS lint / typecheck / tests, the 300-line file-size check, and the untranslated-string check as parallel jobs; `make lint` locally runs the same checks serially. Branch protection requires the aggregate `pr-checks` job. + +`make ci-check-untranslated` (`scripts/check_untranslated_strings.mjs`) parses every `.tsx` and fails on user-visible text rendered as a literal — JSX text, a `title`/`placeholder`/`aria-label`-style attribute, or a `toast.*()`/`confirm()` argument — including copy hidden in `cond ? 'A' : 'B'`. It parses with `@babel/parser` rather than grepping, because no regex over JSX can tell `

Save

` from `Promise`. It does **not** see strings passed through a variable or a config object (`const THEME = { mobileTitleLabel: 'Admin' }`), so those still need care. + +To exempt a genuinely technical literal: wrap it in ``/`
`, or mark the line `// i18n-exempt: `; `i18n-exempt-file: ` in a file's first lines skips the whole file.
 
 ## Authoritative references
 
diff --git a/Makefile b/Makefile
index c3485c55..4293c4d2 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-build-packages worker beat worker-docker
+.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-check-untranslated ci-build-packages worker beat worker-docker
 
 # Install
 install:
@@ -93,7 +93,7 @@ loadtest:                   ## Run locust against a server already on $(LOCUST_H
 loadtest-memray:            ## Start uvicorn under memray, load-test, emit flamegraph
 	scripts/loadtest_memray.sh $(LOCUST_ARGS)
 
-lint: ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings
+lint: ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-check-untranslated
 	uv run python scripts/check_metadata.py
 	uv run python scripts/check_readmes.py
 
@@ -135,6 +135,13 @@ ci-check-file-size:
 ci-check-hardcoded-strings:
 	uv run python scripts/check_hardcoded_strings.py
 
+# Fail when a .tsx renders user-visible text as a literal instead of t(keys.…).
+# Shipping locales/en.json never proved a page actually read it: SM013-SM016
+# only compare catalogs to each other, so with i18n_supported_locales=["en"]
+# they never fire and tsc is happy with hardcoded English.
+ci-check-untranslated:
+	node scripts/check_untranslated_strings.mjs
+
 # Dry-run the release build: build sdists + wheels for every workspace member
 # the same way release.yml does. Catches packaging regressions at PR time
 # (e.g. force-include paths that crash the sdist→wheel rebuild) instead of
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="