i18n-embedded-references
Reference other translations from inside a translation with ${key}.
en:
company: Acme Corp
welcome: "Welcome to ${company}"
footer:
legal: "${company} is a registered trademark."I18n.t(:welcome) # => "Welcome to Acme Corp"
I18n.t(:'footer.legal') # => "Acme Corp is a registered trademark."Change company once and every translation that embeds it follows.
This gem is a maintained alternative to i18n-recursive-lookup,
which has been unmaintained since 2015. It keeps the ${} syntax, drops the
ActiveSupport dependency, resolves references recursively with cycle detection,
and is locale-aware when fallbacks are in play.
Installation
# Gemfile
gem 'i18n-embedded-references'Then include the module in your backend. In Rails, for example in
config/initializers/i18n.rb:
require 'i18n/embedded_references'
I18n::Backend::Simple.include(I18n::Backend::EmbeddedReferences)It works with I18n::Backend::Fallbacks wherever that is included: on the
same backend, or on a Chain above it (which is what Rails does with
config.i18n.fallbacks, so the initializer line above is all Rails needs).
Requires i18n >= 1.9.
Syntax
| Written | Result |
|---|---|
${some.key} |
the value of some.key
|
$${some.key} |
the literal text ${some.key}
|
"${some.hash}" |
the hash itself, when the string is only that ref |
"${some.array}" |
the array itself, when the string is only that ref |
The key is taken verbatim: ${ some.key } refers to " some.key ", not to
some.key. Only an exact "${some.hash}" returns the structure; with any
text around it, whitespace included, the value is spliced into a string.
References are resolved anywhere in the translation tree: in plain strings,
inside hashes returned by I18n.t(:some_scope), and inside arrays.
Semantics
Recursive. A referenced value may contain references of its own. A cycle
raises I18n::Backend::EmbeddedReferences::CircularReferenceError
with the full chain (en.a -> en.b -> en.a).
Resolved before interpolation and pluralization. Interpolation values and
count given to I18n.t are forwarded to referenced keys, so this works:
en:
body: "Dear %{name}"
letter: "${body}, see you soon"I18n.t(:letter, name: 'Ann') # => "Dear Ann, see you soon"Locale-aware. A reference is looked up with the locale that was originally
requested, not the locale where the referencing string was found. With
fallbacks en-CL -> en:
en:
name: notice
alert: "alert ${name}"
en-CL:
name: notice-clI18n.t(:alert, locale: :'en-CL') # => "alert notice-cl"alert comes from en via fallback, but ${name} still prefers en-CL.
Explicit locale: options and I18n::Backend::Chain are honoured the same way.
Missing references. A missing referenced key makes the referencing
string translation missing. Your usual raise:, throw: and exception handler
apply, and the message names the key that is actually absent
(Translation missing: en.company). Inside a hash or array (I18n.t(:scope))
each element is resolved on its own, so a broken reference in one value does
not take the whole structure down.
I18n.exists? does not resolve references. It tells whether the key is
defined, the same way it ignores %{} placeholders, so a translation with a
broken reference still exists. Use I18n.t or the checker below to find
broken references.
No write-back. Resolved values are never stored back into the translation
store, so store_translations at runtime is picked up on the next lookup and
the raw ${} source is preserved in the backend.
Checking references statically
The gem ships a checker that reads your locale files without booting the app and reports, per file and key:
- broken references: the target key does not exist in the locale or its fallbacks (at runtime this would surface as a missing translation)
-
circular references:
a -> b -> a(at runtime:CircularReferenceError) -
scope references embedded in text:
"See ${menu}"wheremenuis a hash or array
bundle exec i18n-embedded-references check config/locales packs/**/config/localesconfig/locales/en.yml
en.footer.legal
error: broken reference ${company_name} (not found in en)
en.nav.label
error: reference ${menu} is embedded in text but points to a hash or array
config/locales/es-CL.yml
es-CL.welcome
error: broken reference ${compnay} (not found in es-CL, es)
3 errors, 0 warnings, 142 references checked in 12 files (0.1s)
Exit status is 1 when there are errors, so it drops straight into CI.
Fallbacks default to the region-stripped parent (es-CL -> es). Anything
else, plus keys that another backend resolves at runtime, goes in
.i18n-embedded-references.yml (picked up automatically) or --config FILE:
paths:
- config/locales
- packs/**/config/locales
default_fallbacks: [es] # I18n.fallbacks = [:es]
fallbacks:
ca: [es] # extra per-locale chains
ignore_missing:
- 'custom_translations.*' # resolved by a database backend, not YAMLThe same options exist as flags: --fallback ca:es, --default-fallback es,
--ignore-missing 'custom_translations.*', --quiet.
In Rails, prefer the rake task: it boots the app and reads I18n.fallbacks,
so the checker follows the same fallback chains as runtime without repeating
them as flags:
bin/rake i18n:embedded_references:check \
PATHS="config/locales packs/**/config/locales" \
IGNORE_MISSING="custom_translations.*"Using fallbacks from I18n.fallbacks (4 locales, defaults: es)
3 errors, 0 warnings, 142 references checked in 12 files (1.8s)
Fallbacks are only taken from the app when its backend actually includes
I18n::Backend::Fallbacks (what config.i18n.fallbacks does). Each app chain
(I18n.fallbacks[locale]) is exactly what the backend walks at runtime, so it
is used verbatim: in its own order, without inserting the regional parent, and
without prepending the locale if the chain does not start with it (a custom
fallbacks object may return [:en] for :pt). Precedence is flags >
config file > app: a locale given in fallbacks (config or CONFIG=file)
ignores the app chain and the app defaults, and the app defaults only apply to
locales without an app chain. APP_FALLBACKS=false skips booting the app. Keys resolved by another backend still have to be listed in
IGNORE_MISSING (space separated globs): there is no way to infer them.
Outside Rails, add require 'i18n/embedded_references/tasks' to your Rakefile.
When the booted app does not use I18n::Backend::Fallbacks, the task checks
without fallbacks (no regional parent either, only the ones given by flags or
config) and says so:
App backend does not use I18n::Backend::Fallbacks (RAILS_ENV=test): checking without fallbacks
Mind the environment: Rails generates production.rb with
config.i18n.fallbacks = true, but development and test leave it off, and the
task usually runs in CI with RAILS_ENV=test. If production uses fallbacks and
test does not, the task reports references that production resolves. Align
config.i18n.fallbacks in test with production, or pass the fallbacks as
flags or config.
Differences from i18n-recursive-lookup
- No ActiveSupport dependency.
- References resolve with the requested locale (see above); the original used the fallback locale, which returned the wrong value for locale overrides.
- No compiled-value cache written into the store. The original cached under the fallback locale, leaking locale-specific values across locales.
- Cycle detection instead of a stack overflow.
- Missing references surface through the standard I18n missing-translation handling instead of being spliced in as text.
- Interpolation values and
countare forwarded to referenced keys.
Development
bundle install
bundle exec rake test
bundle exec rubocopTo release, bump VERSION in lib/i18n/embedded_references/version.rb, add
the entry to CHANGELOG.md, merge, and push a tag with the same version:
git tag v1.2.0 && git push origin v1.2.0The Release workflow checks that the tag matches the version, runs the
tests and publishes to RubyGems.org through trusted publishing.
License
MIT. See LICENSE.