fix: route server-composed and split-key strings through the i18n catalogs - #312
Open
antosubash wants to merge 1 commit into
Open
fix: route server-composed and split-key strings through the i18n catalogs#312antosubash wants to merge 1 commit into
antosubash wants to merge 1 commit into
Conversation
…alogs
The `hardcoded-strings` and `untranslated` checks both parse `.tsx`. A sentence
assembled in Python arrives at the page already rendered, and a sentence spliced
from two catalog keys around a value is two fragments a translator cannot
reorder — neither is visible to either check, so both drifted untranslated.
- Users' "Recent activity" card composed English in Python
(`f"{verb} {kind} {label}"` from an English verb table). It now emits a
catalog key plus its interpolation arguments and resolves them against the
request's `Translator`, the same shape menus use via `label_key` +
`MenuRegistry.get_for_user(translate=…)`. The row's *kind* goes through the
owning module's `AuditLink.label_key` rather than its English `label`.
A unit test translates with a stub that echoes keys, so an f-string here
fails loudly rather than silently shipping English.
- `permissions.edit.granted_summary` was the fragment "of {total} granted" with
the count rendered beside it, and the 403 page spliced
`error.forbidden_permission_prefix` + the permission + `…_suffix`. Both are
now single keys with an ordinary `{granted}` / `{permission}` placeholder,
rendered by a new shared `InterpolatedText`, which puts the styled value at
the placeholder wherever the translator moved it. `feature_flags`' ScopeCard
had solved this locally by splitting on a Word Joiner; it now uses the shared
component, which also handles a translation that dropped or repeated the
placeholder instead of silently truncating.
- A 413 answered "File exceeds the maximum allowed size", which tells someone
holding a 40 MB file nothing they can act on. The message now names the limit
(interpolated by the catalog) and the response carries `max_bytes` alongside.
`format_bytes` mirrors `pages/format.ts` — including its half-up rounding, so
one limit is not spelled two ways on one screen.
- `host/locales/es.json` shipped 25 `setup.*` keys still in English; translated.
- Nothing was checking locale parity: SM013–SM016 only ran when
`SM_I18N_SUPPORTED_LOCALES` was set, and it is set in neither `.env` nor
`.env.example`, so `make doctor` was green while drift accumulated. The
diagnostic now falls back to the locale files each namespace actually ships
when no set is declared — SM014/SM015 hold every translation to the default
catalog, while SM013 stays quiet, since a locale nothing promised cannot be
missing. That immediately surfaced four orphan `relative_time.*_ago_{one,other}`
keys in `packages/ui/locales/es.json`; they are declared in `en.json` rather
than deleted, because i18next resolves them when `count` is passed and
removing them would regress "hace 1 mes" to "hace 1 meses".
`SM_I18N_SUPPORTED_LOCALES` is documented in `.env.example` but left commented:
like every `SM_` variable it wins over the value edited at /admin/settings, and
the parity check no longer needs it.
Closes #296
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Deploying simple-module-python with
|
| Latest commit: |
c5ec235
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://ecfaddef.simple-module-python.pages.dev |
| Branch Preview URL: | https://fix-i18n-composed-strings.simple-module-python.pages.dev |
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 free
to 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.
Closes #296
Both lint checks parse
.tsx. A sentence assembled in Python reaches the page already rendered, and a sentence spliced from two catalog keys around a value is two fragments a translator cannot reorder — neither is visible to either check, so both drifted untranslated with nothing to flag them.What changed
Server-composed sentences. The users "Recent activity" card built English in Python (
f"{verb} {kind} {label}"from an English verb table), so the list read as English in every locale. It now emits a catalog key plus its interpolation arguments and resolves them against the request'sTranslator— the same shape menus use (label_key+MenuRegistry.get_for_user(translate=…)). The row's kind goes through the owning module'sAuditLink.label_keyrather than its Englishlabel. A unit test translates with a stub that echoeskey(args), so an f-string reintroduced here fails loudly instead of shipping English.Sentence fragments as keys.
permissions.edit.granted_summarywas"of {total} granted"with the count rendered beside it; the 403 page splicederror.forbidden_permission_prefix+ the permission +…_suffix. Both are now single keys with an ordinary{granted}/{permission}placeholder.A shared way to put markup inside a translated sentence. New
InterpolatedText(packages/ui) renders one key and drops the styled value at the placeholder, wherever the translation moved it.feature_flags'ScopeCardhad solved this locally by interpolating a Word Joiner and splitting on it; it now uses the shared component. The sentinel never reaches a catalog either way (the translator only ever sees{tenant_id}), but the shared version also handles a translation that dropped the placeholder — it appends the value rather than silently deleting it — and one that repeated it, keeping every word of the copy.Upload failure copy. A 413 answered
"File exceeds the maximum allowed size", which tells someone holding a 40 MB file nothing actionable. The message names the limit (interpolated by the catalog), and the response carriesmax_bytesalongside for callers composing their own copy.format_bytesmirrorspages/format.ts, including its half-up rounding, so one limit is never spelled two ways on one screen.host/locales/es.jsonshipped 25setup.*keys still in English — translated.Nothing was checking locale parity. SM013–SM016 only ran when
SM_I18N_SUPPORTED_LOCALESwas set, and it is set in neither.envnor.env.example, somake doctorwas green throughout. Rather than only documenting the variable, the diagnostic now falls back to the locale files each namespace actually ships when no set is declared: SM014/SM015 hold every translation to the default catalog's key set, while SM013 stays quiet, because a locale nothing promised cannot be missing. Declaring the set still turns SM013 back on.That fallback immediately reported the issue's fourth bullet: four orphan
relative_time.{months,years}_ago_{one,other}keys inpackages/ui/locales/es.json.Two deliberate departures from the issue text
The orphan
uikeys are declared inen.json, not deleted fromes.json. They are not dead:useRelativeTimecallst(key, { count }), and i18next resolves_one/_otherbefore falling back to the stem. Deleting them would regress "hace 1 mes" to "hace 1 meses". Adding the English forms clears SM015 and keeps the Spanish singular.SM_I18N_SUPPORTED_LOCALESis documented in.env.examplebut left commented out. Setting it live would make a copied.envoverride the value edited at /admin/settings — the precedence trap CLAUDE.md calls out fori18n_*. The auto-detect fallback getsmake doctorchecking parity on a stock checkout with no configuration at all, which is what the bullet was after.The issue also says the Word Joiner "is an invisible character a translator can delete or duplicate with no feedback". That part is not accurate — it is passed as the value of an ordinary
{tenant_id}placeholder and never appears in a catalog. The real fragility was the silent truncation on a mis-translated placeholder, which the shared component now handles.Verification
All run in the worktree after
uv sync --all-packages+npm install, exit codes read directly (nothing piped throughtailthat would mask them).uv run pytest -q(full suite)uv run ruff format --check .uv run ruff check .uv run ty checkuv run python scripts/check_file_size.pynpx biome check .make ci-js-typecheck(host + every module/package tsconfig)npx vitest run(full suite)node scripts/check_untranslated_strings.mjsuv run python scripts/check_hardcoded_strings.pyuv run python scripts/check_metadata.py/check_readmes.pymake doctormake doctorwas additionally probed by injecting a bogus key intopackages/ui/locales/es.json; it reportedSM015 … has keys not in default: __probe__and went quiet again when reverted — so the new fallback is genuinely running, not merely silent.Not done
_kind_ofstill lowercases the translated kind label for mid-sentence use. Correct for English and Spanish, wrong for a language that capitalises common nouns; documented in the docstring, which points such a locale at rephrasing the wholesummary.*clause instead.scripts/check_untranslated_strings.mjsstill cannot see strings behind a variable or a config object. Widening it is a separate change.