Skip to content

feat(i18n): translation foundation, isolated from the host app's I18n - #153

Open
AnjanJ wants to merge 1 commit into
mainfrom
feat/i18n-foundation
Open

feat(i18n): translation foundation, isolated from the host app's I18n#153
AnjanJ wants to merge 1 commit into
mainfrom
feat/i18n-foundation

Conversation

@AnjanJ

@AnjanJ AnjanJ commented Aug 13, 2026

Copy link
Copy Markdown
Owner

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 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::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 as soon as 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.

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:

  1. A raw backend lookup throws :exception on a miss rather than returning — I18n.translate is what normally catches it. Uncaught, every missing key becomes an UncaughtThrowError.
  2. I18n::InvalidPluralizationData raises when a locale supplies only other and the count needs one. English has both forms, so this only bites once a real translation ships.

Both are caught and degraded to English. Nothing in I18nStore can 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_ago interpolates 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_locale honest (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

  • 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 removedCurrentAttributes resets between requests in the test harness, so asserting Current.locale.nil? after a get tested a framework guarantee rather than the code. Found by deliberately reverting the production change and watching them stay green. 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

🤖 Generated with Claude Code

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant