feat(i18n): translation foundation, isolated from the host app's I18n - #153
Open
AnjanJ wants to merge 1 commit into
Open
feat(i18n): translation foundation, isolated from the host app's I18n#153AnjanJ wants to merge 1 commit into
AnjanJ wants to merge 1 commit into
Conversation
RED had no i18n at all — no config/locales, no t() call anywhere. This adds the machinery to translate the dashboard without translating anything yet. Every string is still English and rendered HTML is byte-identical to main. The #148 fix added config.dashboard_locale and an around_action to keep Pagy's locale from leaking, and said it was groundwork "so the mechanism is in place for translating the rest of the dashboard." This is that mechanism. RED translates through its own I18n::Backend::Simple rather than the host's. Sharing the host's backend would hand it three ways to break the error dashboard — the page that has to work when everything else is broken: - raise_on_missing_translations turns a key we forgot into a 500 - enforce_available_locales raises when RED asks for its own locale - a custom exception_handler can raise on anything For the same reason the around_action does NOT call I18n.with_locale, which mutates host global state. RED's locale lives on its own CurrentAttributes and is cleared in the ensure, so it cannot outlive the request or strand on a recycled Puma thread. Pagy's locale and RED's resolve independently — they ship different dictionaries, and a locale RED can serve but Pagy cannot must still render, with English pagination. The helper is red_t, not t: overriding t risks colliding with host helpers and with Rails' lazy lookup, and an explicit name keeps every translated site greppable when the remaining ~1,600 strings get extracted. Two things only found by running it: - A raw backend lookup signals a miss by throwing :exception rather than returning; I18n.translate is what normally catches it. Uncaught, every missing key would be an UncaughtThrowError on the dashboard. - I18n::InvalidPluralizationData raises when a locale supplies only :other and the count needs :one — a live 500 risk for any translated locale. Both are caught and degraded to English. Nothing in I18nStore can raise. Date formats are now translation keys rather than literals: "%B %d, %Y" is a US ordering as much as it is English words. local_time_ago interpolates instead of concatenating " ago", which is wrong word order in several languages. Its server-rendered fallback still calls Rails' time_ago_in_words (host-translated), but the browser replaces that text on load — Phase 3 localizes the JS side. Full UI translation is deferred to v1.1+ (ROADMAP item AA). It is ~1,600 strings across 48 views, inline JS, mailers, and notification payloads — larger than any feature shipped so far — and it is reach rather than depth, which the roadmap already defers on the same grounds as APM. Shipping the foundation now makes config.dashboard_locale honest and lets pages be translated one release at a time instead of in a single push. Test plan: - RSpec: 3743 examples, 0 failures (+95), green on a second seed - RuboCop: 494 files, no offenses - Chaos tests: 1442/1442 assertions across all 5 apps, production mode - Rendered HTML diffed against main across 4 pages: identical after normalizing CSRF tokens, timestamps, and Faker data - Mutation-tested: deleting the ensure cleanup fails 3 specs, deleting the English fallback fails 1. An earlier version of the leak specs passed even with the cleanup removed, because CurrentAttributes resets between requests in the test harness and masked it; they now exercise the around_action directly - Verified the built gem contains config/locales/en.yml (no gemspec change needed — it already ships config/**/*) Refs #148 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
RED had no i18n at all — no
config/locales, not()call anywhere. This adds the machinery to translate the dashboard without translating anything yet. Every string is still English, and rendered HTML is byte-identical tomain.The #148 fix added
config.dashboard_localeand anaround_actionto stop Pagy's locale leaking, and described itself as groundwork "so the mechanism is in place for translating the rest of the dashboard." This is that mechanism.Why a private I18n backend
RED translates through its own
I18n::Backend::Simplerather than the host's. Sharing the host's backend would hand it three ways to break the error dashboard — the page that has to work when everything else is broken:raise_on_missing_translationsturns a key we forgot into a 500enforce_available_localesraises as soon as RED asks for its own localeexception_handlercan raise on anythingFor the same reason the
around_actiondoes not callI18n.with_locale, which mutates host global state. RED's locale lives on its ownCurrentAttributesand is cleared in theensure, so it cannot outlive the request or strand on a recycled Puma thread.Pagy's locale and RED's resolve independently — they ship different dictionaries, and a locale RED can serve but Pagy cannot must still render, with English pagination.
The helper is
red_t, nott: overridingtrisks colliding with host helpers and with Rails' lazy lookup, and an explicit name keeps every translated site greppable when the remaining ~1,600 strings get extracted.Trade-off accepted: hosts cannot override RED's strings with their own locale files. That is the right default for a self-hosted ops tool, and can be relaxed later without breaking anything.
Two bugs only found by running it
Both would have been 500s on the error dashboard:
throws:exceptionon a miss rather than returning —I18n.translateis what normally catches it. Uncaught, every missing key becomes anUncaughtThrowError.I18n::InvalidPluralizationDataraises when a locale supplies onlyotherand the count needsone. English has both forms, so this only bites once a real translation ships.Both are caught and degraded to English. Nothing in
I18nStorecan raise.Dates
Date formats are now translation keys rather than literals —
"%B %d, %Y"is a US ordering as much as it is English words.local_time_agointerpolates instead of concatenating" ago", which is the wrong word order in several languages.Known limitation:
local_time_ago's server-rendered fallback still calls Rails'time_ago_in_words, which translates through the host's I18n. The browser replaces that text on load, so it only shows for non-JS clients. Phase 3 localizes the JS side.Scope
Full UI translation is deferred to v1.1+ (ROADMAP item AA). It is ~1,600 strings across 48 views, inline JS, mailers, and notification payloads — larger than any feature shipped so far — and it is reach rather than depth, which the roadmap already defers on the same grounds as APM.
Shipping the foundation now makes
config.dashboard_localehonest (it previously promised more than it delivered) and lets pages be translated one release at a time instead of in a single push. No user has asked for this; the demand signal is zero i18n issues filed.Test plan
mainacross 4 pages — identical after normalizing CSRF tokens, timestamps, and Faker dataensurecleanup fails 3 specs; deleting the English fallback fails 1An earlier version of the leak specs passed even with the cleanup removed —
CurrentAttributesresets between requests in the test harness, so assertingCurrent.locale.nil?after agettested a framework guarantee rather than the code. Found by deliberately reverting the production change and watching them stay green. They now exercise thearound_actiondirectly.config/locales/en.yml— no gemspec change needed, it already shipsconfig/**/*Refs #148
🤖 Generated with Claude Code