i18n-embedded-references 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: db4b2544a71629739e076e47d845285f077a7e35343283debc38bd28a5eebede
4
+ data.tar.gz: 186b04b8c04e0d77def14c9fd3feab88c609690c7857973e0689aa1d339e86d6
5
+ SHA512:
6
+ metadata.gz: 10036be6315e7fd4bd7bbd49ab80a029b64a9d24e446da5ae4acfe641b474d2163562268699d71e0fa685095e8d1722ef243144712bd12ca0ed5c6c9e6f3ba34
7
+ data.tar.gz: 7ebb506a3da0dbfb4f473f90eee32e756f518f81256c47c944945a2da1a3108cac96bcfbc7379305e8b55f6196f85600becdc8cf0560bf8678aeb56404500c60
data/CHANGELOG.md ADDED
@@ -0,0 +1,57 @@
1
+ # Changelog
2
+
3
+ ## 1.2.0
4
+
5
+ - The requested locale is taken from the `fallback_original_locale` option
6
+ that `I18n::Backend::Fallbacks` passes down, so references resolve with the
7
+ right locale when Fallbacks is included above the backend (Rails includes
8
+ it into the root backend class, a Chain in many apps). Requires i18n >= 1.9.
9
+ - Every reference in a translation resolves with the requested locale. Before,
10
+ with an explicit `locale:` different from `I18n.locale`, only the first
11
+ `${ref}` did and the rest fell back to `I18n.locale`. Thanks to @seff73 for
12
+ reporting it and pinning the plain-backend case with a test (#3).
13
+ - Inside hashes and arrays a missing referenced key is handled per element
14
+ by the standard exception handler instead of making the whole structure
15
+ missing.
16
+ - `I18n.exists?` no longer resolves references, it only tells whether the
17
+ key is defined. Before, a broken reference raised `UncaughtThrowError` and
18
+ references resolved with `I18n.locale` instead of the given locale.
19
+ - `require 'i18n-embedded-references'` (Bundler's default autorequire for
20
+ the gem name) now works; `require 'i18n/embedded_references'` stays valid.
21
+ - `rake i18n:embedded_references:check` boots the app when an `:environment`
22
+ task exists and mirrors `I18n.fallbacks`, so Rails apps no longer need
23
+ `--default-fallback`/`--fallback`. App chains are used verbatim, in their
24
+ own order and without prepending the locale when the chain does not start
25
+ with it, since that is exactly what the backend walks at runtime.
26
+ Precedence is flags > config file > app: a locale configured there ignores
27
+ the app chain and defaults. `APP_FALLBACKS=false` disables it. When the
28
+ app's backend does not include `I18n::Backend::Fallbacks`, the task checks
29
+ without fallbacks (no regional parent) and prints a warning naming the
30
+ environment. New `IGNORE_MISSING` variable for the task.
31
+ - The checker and the backend share one grammar for `${key}`
32
+ (`I18n::EmbeddedReferences::Syntax`), so they no longer disagree: the
33
+ checker takes the key verbatim (`${ name }` is reported as broken, like the
34
+ runtime treats it), only an exact `${key}` counts as a whole-string
35
+ reference (`" ${menu}"` is a scope error), and references in hashes inside
36
+ arrays are checked too.
37
+
38
+ ## 1.1.0
39
+
40
+ - Static checker for locale files: `i18n-embedded-references check` (CLI),
41
+ `rake i18n:embedded_references:check` (Rails railtie or manual require).
42
+ Reports broken references honouring fallbacks, circular references and
43
+ hash/array targets embedded in text. Config via `.i18n-embedded-references.yml`
44
+ or flags (`--fallback`, `--default-fallback`, `--ignore-missing`, `--quiet`).
45
+
46
+ ## 1.0.0
47
+
48
+ Initial release. Successor of [i18n-recursive-lookup](https://github.com/annkissam/i18n-recursive-lookup),
49
+ rewritten without ActiveSupport.
50
+
51
+ - `${key}` references inside strings, hashes and arrays, resolved recursively.
52
+ - `$${key}` escapes to the literal `${key}`.
53
+ - References resolve with the originally requested locale, so fallbacks and
54
+ backend chains apply to the referenced key too.
55
+ - Circular references raise `I18n::Backend::EmbeddedReferences::CircularReferenceError`.
56
+ - `raise:` and `throw:` options propagate to referenced lookups.
57
+ - No write-back cache into the translation store.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Buk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,239 @@
1
+ # i18n-embedded-references
2
+
3
+ Reference other translations from inside a translation with `${key}`.
4
+
5
+ ```yaml
6
+ en:
7
+ company: Acme Corp
8
+ welcome: "Welcome to ${company}"
9
+ footer:
10
+ legal: "${company} is a registered trademark."
11
+ ```
12
+
13
+ ```ruby
14
+ I18n.t(:welcome) # => "Welcome to Acme Corp"
15
+ I18n.t(:'footer.legal') # => "Acme Corp is a registered trademark."
16
+ ```
17
+
18
+ Change `company` once and every translation that embeds it follows.
19
+
20
+ This gem is the successor of [i18n-recursive-lookup](https://github.com/annkissam/i18n-recursive-lookup),
21
+ which has been unmaintained since 2015. It keeps the `${}` syntax, drops the
22
+ ActiveSupport dependency, resolves references recursively with cycle detection,
23
+ and is locale-aware when fallbacks are in play.
24
+
25
+ ## Installation
26
+
27
+ ```ruby
28
+ # Gemfile
29
+ gem 'i18n-embedded-references'
30
+ ```
31
+
32
+ Then include the module in your backend. In Rails, for example in
33
+ `config/initializers/i18n.rb`:
34
+
35
+ ```ruby
36
+ require 'i18n/embedded_references'
37
+
38
+ I18n::Backend::Simple.include(I18n::Backend::EmbeddedReferences)
39
+ ```
40
+
41
+ It works with `I18n::Backend::Fallbacks` wherever that is included: on the
42
+ same backend, or on a `Chain` above it (which is what Rails does with
43
+ `config.i18n.fallbacks`, so the initializer line above is all Rails needs).
44
+ Requires i18n >= 1.9.
45
+
46
+ ## Syntax
47
+
48
+ | Written | Result |
49
+ |------------------------|----------------------------------------------------|
50
+ | `${some.key}` | the value of `some.key` |
51
+ | `$${some.key}` | the literal text `${some.key}` |
52
+ | `"${some.hash}"` | the hash itself, when the string is only that ref |
53
+ | `"${some.array}"` | the array itself, when the string is only that ref |
54
+
55
+ The key is taken verbatim: `${ some.key }` refers to `" some.key "`, not to
56
+ `some.key`. Only an exact `"${some.hash}"` returns the structure; with any
57
+ text around it, whitespace included, the value is spliced into a string.
58
+
59
+ References are resolved anywhere in the translation tree: in plain strings,
60
+ inside hashes returned by `I18n.t(:some_scope)`, and inside arrays.
61
+
62
+ ## Semantics
63
+
64
+ **Recursive.** A referenced value may contain references of its own. A cycle
65
+ raises `I18n::Backend::EmbeddedReferences::CircularReferenceError`
66
+ with the full chain (`en.a -> en.b -> en.a`).
67
+
68
+ **Resolved before interpolation and pluralization.** Interpolation values and
69
+ `count` given to `I18n.t` are forwarded to referenced keys, so this works:
70
+
71
+ ```yaml
72
+ en:
73
+ body: "Dear %{name}"
74
+ letter: "${body}, see you soon"
75
+ ```
76
+
77
+ ```ruby
78
+ I18n.t(:letter, name: 'Ann') # => "Dear Ann, see you soon"
79
+ ```
80
+
81
+ **Locale-aware.** A reference is looked up with the locale that was originally
82
+ requested, not the locale where the referencing string was found. With
83
+ fallbacks `en-CL -> en`:
84
+
85
+ ```yaml
86
+ en:
87
+ name: notice
88
+ alert: "alert ${name}"
89
+ en-CL:
90
+ name: notice-cl
91
+ ```
92
+
93
+ ```ruby
94
+ I18n.t(:alert, locale: :'en-CL') # => "alert notice-cl"
95
+ ```
96
+
97
+ `alert` comes from `en` via fallback, but `${name}` still prefers `en-CL`.
98
+ Explicit `locale:` options and `I18n::Backend::Chain` are honoured the same way.
99
+
100
+ **Missing references.** A missing referenced key makes the referencing
101
+ string translation missing. Your usual `raise:`, `throw:` and exception handler
102
+ apply, and the message names the key that is actually absent
103
+ (`Translation missing: en.company`). Inside a hash or array (`I18n.t(:scope)`)
104
+ each element is resolved on its own, so a broken reference in one value does
105
+ not take the whole structure down.
106
+
107
+ **`I18n.exists?` does not resolve references.** It tells whether the key is
108
+ defined, the same way it ignores `%{}` placeholders, so a translation with a
109
+ broken reference still exists. Use `I18n.t` or the checker below to find
110
+ broken references.
111
+
112
+ **No write-back.** Resolved values are never stored back into the translation
113
+ store, so `store_translations` at runtime is picked up on the next lookup and
114
+ the raw `${}` source is preserved in the backend.
115
+
116
+ ## Checking references statically
117
+
118
+ The gem ships a checker that reads your locale files without booting the app
119
+ and reports, per file and key:
120
+
121
+ - **broken references**: the target key does not exist in the locale or its
122
+ fallbacks (at runtime this would surface as a missing translation)
123
+ - **circular references**: `a -> b -> a` (at runtime: `CircularReferenceError`)
124
+ - **scope references embedded in text**: `"See ${menu}"` where `menu` is a
125
+ hash or array
126
+
127
+ ```bash
128
+ bundle exec i18n-embedded-references check config/locales packs/**/config/locales
129
+ ```
130
+
131
+ ```
132
+ config/locales/en.yml
133
+ en.footer.legal
134
+ error: broken reference ${company_name} (not found in en)
135
+ en.nav.label
136
+ error: reference ${menu} is embedded in text but points to a hash or array
137
+
138
+ config/locales/es-CL.yml
139
+ es-CL.welcome
140
+ error: broken reference ${compnay} (not found in es-CL, es)
141
+
142
+ 3 errors, 0 warnings, 142 references checked in 12 files (0.1s)
143
+ ```
144
+
145
+ Exit status is 1 when there are errors, so it drops straight into CI.
146
+
147
+ Fallbacks default to the region-stripped parent (`es-CL` -> `es`). Anything
148
+ else, plus keys that another backend resolves at runtime, goes in
149
+ `.i18n-embedded-references.yml` (picked up automatically) or `--config FILE`:
150
+
151
+ ```yaml
152
+ paths:
153
+ - config/locales
154
+ - packs/**/config/locales
155
+ default_fallbacks: [es] # I18n.fallbacks = [:es]
156
+ fallbacks:
157
+ ca: [es] # extra per-locale chains
158
+ ignore_missing:
159
+ - 'custom_translations.*' # resolved by a database backend, not YAML
160
+ ```
161
+
162
+ The same options exist as flags: `--fallback ca:es`, `--default-fallback es`,
163
+ `--ignore-missing 'custom_translations.*'`, `--quiet`.
164
+
165
+ In Rails, prefer the rake task: it boots the app and reads `I18n.fallbacks`,
166
+ so the checker follows the same fallback chains as runtime without repeating
167
+ them as flags:
168
+
169
+ ```bash
170
+ bin/rake i18n:embedded_references:check \
171
+ PATHS="config/locales packs/**/config/locales" \
172
+ IGNORE_MISSING="custom_translations.*"
173
+ ```
174
+
175
+ ```
176
+ Using fallbacks from I18n.fallbacks (4 locales, defaults: es)
177
+ 3 errors, 0 warnings, 142 references checked in 12 files (1.8s)
178
+ ```
179
+
180
+ Fallbacks are only taken from the app when its backend actually includes
181
+ `I18n::Backend::Fallbacks` (what `config.i18n.fallbacks` does). Each app chain
182
+ (`I18n.fallbacks[locale]`) is exactly what the backend walks at runtime, so it
183
+ is used verbatim: in its own order, without inserting the regional parent, and
184
+ without prepending the locale if the chain does not start with it (a custom
185
+ fallbacks object may return `[:en]` for `:pt`). Precedence is flags >
186
+ config file > app: a locale given in `fallbacks` (config or `CONFIG=file`)
187
+ ignores the app chain and the app defaults, and the app defaults only apply to
188
+ locales without an app chain. `APP_FALLBACKS=false` skips booting the app. Keys resolved by another backend still have to be listed in
189
+ `IGNORE_MISSING` (space separated globs): there is no way to infer them.
190
+ Outside Rails, add `require 'i18n/embedded_references/tasks'` to your Rakefile.
191
+
192
+ When the booted app does not use `I18n::Backend::Fallbacks`, the task checks
193
+ without fallbacks (no regional parent either, only the ones given by flags or
194
+ config) and says so:
195
+
196
+ ```
197
+ App backend does not use I18n::Backend::Fallbacks (RAILS_ENV=test): checking without fallbacks
198
+ ```
199
+
200
+ Mind the environment: Rails generates `production.rb` with
201
+ `config.i18n.fallbacks = true`, but development and test leave it off, and the
202
+ task usually runs in CI with `RAILS_ENV=test`. If production uses fallbacks and
203
+ test does not, the task reports references that production resolves. Align
204
+ `config.i18n.fallbacks` in test with production, or pass the fallbacks as
205
+ flags or config.
206
+
207
+ ## Differences from i18n-recursive-lookup
208
+
209
+ - No ActiveSupport dependency.
210
+ - References resolve with the requested locale (see above); the original used
211
+ the fallback locale, which returned the wrong value for locale overrides.
212
+ - No compiled-value cache written into the store. The original cached under the
213
+ fallback locale, leaking locale-specific values across locales.
214
+ - Cycle detection instead of a stack overflow.
215
+ - Missing references surface through the standard I18n missing-translation
216
+ handling instead of being spliced in as text.
217
+ - Interpolation values and `count` are forwarded to referenced keys.
218
+
219
+ ## Development
220
+
221
+ ```bash
222
+ bundle install
223
+ bundle exec rake test
224
+ bundle exec rubocop
225
+ ```
226
+
227
+ To release, bump `VERSION` in `lib/i18n/embedded_references/version.rb`, add
228
+ the entry to `CHANGELOG.md`, merge, and push a tag with the same version:
229
+
230
+ ```bash
231
+ git tag v1.2.0 && git push origin v1.2.0
232
+ ```
233
+
234
+ The `Release` workflow checks that the tag matches the version, runs the
235
+ tests and publishes to RubyGems.org through trusted publishing.
236
+
237
+ ## License
238
+
239
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'i18n/embedded_references/cli'
5
+
6
+ exit I18n::EmbeddedReferences::CLI.new.run(ARGV)
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n'
4
+ require_relative '../embedded_references/syntax'
5
+
6
+ module I18n
7
+ module Backend
8
+ # Resolves `${some.key}` markers found inside translations by looking up
9
+ # the referenced key and splicing its value in place. `$${some.key}`
10
+ # produces the literal text `${some.key}`.
11
+ #
12
+ # Include it in any backend that implements `lookup`:
13
+ #
14
+ # I18n::Backend::Simple.include(I18n::Backend::EmbeddedReferences)
15
+ #
16
+ # References are resolved through `I18n.translate`, with the locale that
17
+ # was originally requested, so fallback chains and backend chains are
18
+ # honoured for the referenced key as well as for the referencing one. The
19
+ # requested locale is taken from the `fallback_original_locale` option
20
+ # that `I18n::Backend::Fallbacks` passes down (i18n >= 1.9), so it works
21
+ # whether Fallbacks sits on this backend or on a Chain above it.
22
+ # Resolution happens before interpolation and pluralization, so a
23
+ # referenced value may itself contain `%{}` placeholders.
24
+ #
25
+ # A missing referenced key makes a string translation missing (the
26
+ # MissingTranslation names the absent key). Inside hashes and arrays the
27
+ # missing reference is handled per element by the standard exception
28
+ # handler, so one broken value does not take the whole structure down.
29
+ #
30
+ # `exists?` does not resolve references: it answers whether the key is
31
+ # defined, like I18n does for interpolations.
32
+ module EmbeddedReferences
33
+ class CircularReferenceError < I18n::ArgumentError
34
+ def initialize(chain)
35
+ super("circular embedded reference: #{chain.join(' -> ')}")
36
+ end
37
+ end
38
+
39
+ REQUESTED_LOCALE_KEY = :i18n_embedded_references_requested_locale
40
+ RESOLUTION_STACK_KEY = :i18n_embedded_references_stack
41
+ SKIP_RESOLUTION_KEY = :i18n_embedded_references_skip_resolution
42
+
43
+ # Options that only make sense for the referencing lookup and must not
44
+ # leak into the nested translate call. Everything else (interpolation
45
+ # values, `count`, `exception_handler`, ...) is forwarded so that a
46
+ # referenced value is interpolated and pluralized like the caller asked.
47
+ NOT_FORWARDED_OPTIONS = %i[
48
+ scope default separator locale object format resolve cascade deep_interpolation
49
+ fallback fallback_in_progress fallback_original_locale
50
+ ].freeze
51
+
52
+ def translate(locale, key, options = EMPTY_HASH)
53
+ with_requested_locale(options[:fallback_original_locale] || locale) { super }
54
+ end
55
+
56
+ # `Base#exists?` calls `lookup` outside `translate`, so resolving there
57
+ # would use `I18n.locale` instead of the given locale, and a broken
58
+ # reference would `throw` with nobody to catch it.
59
+ def exists?(locale, key, options = EMPTY_HASH)
60
+ previous = Thread.current[SKIP_RESOLUTION_KEY]
61
+ Thread.current[SKIP_RESOLUTION_KEY] = true
62
+ super
63
+ ensure
64
+ Thread.current[SKIP_RESOLUTION_KEY] = previous
65
+ end
66
+
67
+ protected
68
+
69
+ def lookup(locale, key, scope = [], options = EMPTY_HASH)
70
+ result = super
71
+ return result if Thread.current[SKIP_RESOLUTION_KEY]
72
+ return result unless result.is_a?(String) || result.is_a?(Hash) || result.is_a?(Array)
73
+
74
+ resolve_embedded(result, options, nested: false)
75
+ end
76
+
77
+ private
78
+
79
+ # Records the locale of the outermost translate call so that nested
80
+ # reference lookups use it instead of whatever fallback locale the
81
+ # referencing translation was found in. Only the call that set the
82
+ # locale clears it: nested calls re-enter with the same locale and must
83
+ # not wipe it before the remaining references are resolved.
84
+ def with_requested_locale(locale)
85
+ owner = Thread.current[REQUESTED_LOCALE_KEY].nil?
86
+ Thread.current[REQUESTED_LOCALE_KEY] = locale if owner
87
+ yield
88
+ ensure
89
+ Thread.current[REQUESTED_LOCALE_KEY] = nil if owner
90
+ end
91
+
92
+ def requested_locale
93
+ Thread.current[REQUESTED_LOCALE_KEY] || I18n.locale
94
+ end
95
+
96
+ # `nested` is true for values found inside a hash or array.
97
+ def resolve_embedded(subject, options, nested:)
98
+ case subject
99
+ when Hash
100
+ subject.each_with_object({}) { |(k, v), h| h[k] = resolve_embedded(v, options, nested: true) }
101
+ when Array
102
+ subject.map { |v| resolve_embedded(v, options, nested: true) }
103
+ when String
104
+ resolve_string(subject, options, nested: nested)
105
+ else
106
+ subject
107
+ end
108
+ end
109
+
110
+ def resolve_string(string, options, nested:)
111
+ return string unless I18n::EmbeddedReferences::Syntax.markers?(string)
112
+
113
+ tokens = I18n::EmbeddedReferences::Syntax.tokenize(string)
114
+ resolved = tokens.map do |token|
115
+ token.is_a?(String) ? token : resolve_reference(token.key, options, nested: nested)
116
+ end
117
+
118
+ # A translation consisting of a single reference keeps the referenced
119
+ # value as-is, so `${some.hash}` and `${some.array}` return structures.
120
+ I18n::EmbeddedReferences::Syntax.single_reference?(tokens) ? resolved.first : resolved.join
121
+ end
122
+
123
+ def resolve_reference(key, options, nested:)
124
+ locale = requested_locale
125
+ stack = (Thread.current[RESOLUTION_STACK_KEY] ||= [])
126
+ entry = "#{locale}.#{key}"
127
+ raise CircularReferenceError, stack + [entry] if stack.include?(entry)
128
+
129
+ stack.push(entry)
130
+ forwarded = options.reject { |k, _| NOT_FORWARDED_OPTIONS.include?(k) }
131
+ # For a string translation, `throw: true` hands the MissingTranslation
132
+ # of the referenced key to the outermost translate call, which applies
133
+ # the caller's raise/throw/exception handler. Inside a structure the
134
+ # element is resolved on its own so the rest of the hash or array
135
+ # survives a broken reference.
136
+ I18n.translate(key, **forwarded, locale: locale, throw: !nested)
137
+ ensure
138
+ stack.pop
139
+ Thread.current[RESOLUTION_STACK_KEY] = nil if stack.empty?
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18n
4
+ module EmbeddedReferences
5
+ # Reads the fallback configuration of the running app, so the checker
6
+ # resolves references the same way I18n does at runtime without having to
7
+ # repeat `config.i18n.fallbacks` as command line flags.
8
+ module AppFallbacks
9
+ module_function
10
+
11
+ # Returns `{ enabled: true, chains: { locale => [chain] }, default_fallbacks: [...] }`
12
+ # built from `I18n.fallbacks`. Each chain is the full runtime chain of the
13
+ # locale. Defaults are only read when the fallbacks object exposes them (a
14
+ # custom object may only implement `#[]`). When the backend does not use
15
+ # fallbacks, returns `{ enabled: false, chains: {}, default_fallbacks: [] }`:
16
+ # at runtime there is no fallback at all, not even the regional parent.
17
+ def from_i18n
18
+ return { enabled: false, chains: {}, default_fallbacks: [] } unless fallbacks_enabled?
19
+
20
+ fallbacks = I18n.fallbacks
21
+ chains = Array(I18n.available_locales).to_h do |locale|
22
+ [locale.to_s, Array(fallbacks[locale]).map(&:to_s)]
23
+ end
24
+ defaults = fallbacks.respond_to?(:defaults) ? Array(fallbacks.defaults).map(&:to_s) : []
25
+ { enabled: true, chains: chains, default_fallbacks: defaults }
26
+ end
27
+
28
+ # Fallbacks only apply at runtime when the backend class includes
29
+ # I18n::Backend::Fallbacks (Rails does it with `config.i18n.fallbacks`).
30
+ def fallbacks_enabled?
31
+ defined?(I18n::Backend::Fallbacks) &&
32
+ I18n.respond_to?(:fallbacks) &&
33
+ I18n.backend.class.include?(I18n::Backend::Fallbacks)
34
+ end
35
+
36
+ def describe(app)
37
+ unless app[:enabled]
38
+ return 'App backend does not use I18n::Backend::Fallbacks ' \
39
+ "(RAILS_ENV=#{environment}): checking without fallbacks"
40
+ end
41
+
42
+ defaults = app[:default_fallbacks].empty? ? 'none' : app[:default_fallbacks].join(', ')
43
+ "Using fallbacks from I18n.fallbacks (#{app[:chains].size} locales, defaults: #{defaults})"
44
+ end
45
+
46
+ def environment
47
+ return Rails.env if defined?(Rails) && Rails.respond_to?(:env)
48
+
49
+ ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'unknown'
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'set'
5
+ require 'date'
6
+ require_relative 'syntax'
7
+
8
+ module I18n
9
+ module EmbeddedReferences
10
+ # Static analysis of `${key}` references across locale YAML files.
11
+ #
12
+ # Loads the files without booting I18n or Rails, flattens them into
13
+ # `{ locale => { "dotted.key" => value } }` and verifies every reference:
14
+ #
15
+ # - broken: the target key does not exist in the locale or its fallbacks
16
+ # - cycle: following references leads back to the starting key
17
+ # - scope: a reference embedded in text points at a Hash/Array target
18
+ #
19
+ # Fallbacks default to the region-stripped parent (`es-CL` -> `es`), can be
20
+ # extended per locale, and `default_fallbacks` are appended to every chain
21
+ # (the equivalent of `I18n.fallbacks = [:es]`). `ignore_missing` takes key
22
+ # globs for keys resolved elsewhere (another backend, a database, ...).
23
+ #
24
+ # `app_chains` and `app_default_fallbacks` hold the fallbacks of the
25
+ # running app (see AppFallbacks). An app chain is exactly what the runtime
26
+ # walks, so it is used verbatim: no regional parent is inserted and the
27
+ # locale itself is not prepended when the chain omits it. A locale listed
28
+ # in `fallbacks` ignores the app entirely. When the app runs
29
+ # without I18n::Backend::Fallbacks, `app_fallbacks_disabled` drops the
30
+ # regional parent for the other locales: their chain is only the locale
31
+ # plus the `default_fallbacks` given by the user.
32
+ class Checker
33
+ Problem = Struct.new(:kind, :severity, :locale, :key, :file, :message, keyword_init: true) do
34
+ def error?
35
+ severity == :error
36
+ end
37
+ end
38
+
39
+ Report = Struct.new(:problems, :references, :files, :seconds, keyword_init: true) do
40
+ def errors
41
+ problems.select(&:error?)
42
+ end
43
+
44
+ def warnings
45
+ problems.reject(&:error?)
46
+ end
47
+
48
+ def ok?
49
+ errors.empty?
50
+ end
51
+ end
52
+
53
+ YAML_CLASSES = [Symbol, Date, Time].freeze
54
+ EMPTY_HASH = {}.freeze
55
+ EMPTY_SET = Set.new.freeze
56
+
57
+ attr_reader :fallbacks, :default_fallbacks, :ignore_missing, :app_chains, :app_default_fallbacks,
58
+ :app_fallbacks_disabled
59
+
60
+ # @param paths [Array<String>] files, directories or globs
61
+ # @param fallbacks [Hash{String => Array<String>}] extra fallbacks per locale
62
+ # @param default_fallbacks [Array<String>] locales appended to every chain
63
+ # @param ignore_missing [Array<String>] key globs whose absence is not an error
64
+ # @param app_chains [Hash{String => Array<String>}] runtime chain per locale, used verbatim
65
+ # @param app_default_fallbacks [Array<String>] app defaults for locales without a chain
66
+ # @param app_fallbacks_disabled [Boolean] the app runs without fallbacks
67
+ def initialize(paths, fallbacks: {}, default_fallbacks: [], ignore_missing: [], # rubocop:disable Metrics/ParameterLists
68
+ app_chains: {}, app_default_fallbacks: [], app_fallbacks_disabled: false)
69
+ @paths = Array(paths)
70
+ @fallbacks = string_lists(fallbacks)
71
+ @default_fallbacks = Array(default_fallbacks).map(&:to_s)
72
+ @ignore_missing = Array(ignore_missing).map(&:to_s)
73
+ @app_chains = string_lists(app_chains)
74
+ @app_default_fallbacks = Array(app_default_fallbacks).map(&:to_s)
75
+ @app_fallbacks_disabled = app_fallbacks_disabled
76
+ @translations = {} # locale => { key => value }
77
+ @origins = {} # locale => { key => file }
78
+ @scopes = {} # locale => Set of keys that are hashes or arrays
79
+ @problems = []
80
+ end
81
+
82
+ def run
83
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
84
+ files = expand_paths
85
+ files.each { |file| load_file(file) }
86
+ references = check_references
87
+ check_cycles
88
+ Report.new(
89
+ problems: @problems.sort_by { |p| [p.file.to_s, p.locale, p.key] },
90
+ references: references,
91
+ files: files.size,
92
+ seconds: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
93
+ )
94
+ end
95
+
96
+ # Fallbacks given for the locale (flags or config) win over the app; an
97
+ # app chain is the runtime chain itself (`I18n.fallbacks[locale]`), so it
98
+ # is used verbatim, without prepending the locale when the chain omits
99
+ # it; an app without fallbacks only keeps the user defaults; otherwise
100
+ # the regional parent and the defaults apply.
101
+ def chain(locale)
102
+ @chains ||= {}
103
+ @chains[locale] ||= build_chain(locale).uniq
104
+ end
105
+
106
+ private
107
+
108
+ def build_chain(locale)
109
+ if fallbacks.key?(locale)
110
+ [locale] + regional_parent(locale) + fallbacks[locale] + default_fallbacks
111
+ elsif app_fallbacks_disabled
112
+ [locale] + default_fallbacks
113
+ elsif app_chains.key?(locale)
114
+ app_chains[locale] + default_fallbacks
115
+ else
116
+ [locale] + regional_parent(locale) + default_fallbacks + app_default_fallbacks
117
+ end
118
+ end
119
+
120
+ def regional_parent(locale)
121
+ locale.include?('-') ? [locale.split('-').first] : []
122
+ end
123
+
124
+ def string_lists(hash)
125
+ hash.to_h { |k, v| [k.to_s, Array(v).map(&:to_s)] }
126
+ end
127
+
128
+ def expand_paths
129
+ @paths.flat_map { |path| expand_path(path) }.uniq.sort
130
+ end
131
+
132
+ # A path may be a file, a directory or a glob whose matches are themselves
133
+ # files or directories (e.g. `packs/**/config/locales`).
134
+ def expand_path(path)
135
+ if File.directory?(path)
136
+ Dir.glob(File.join(path, '**', '*.{yml,yaml}')).select { |f| File.file?(f) }
137
+ elsif File.file?(path)
138
+ [path]
139
+ else
140
+ Dir.glob(path).flat_map { |match| File.directory?(match) ? expand_path(match) : [match] }
141
+ end
142
+ end
143
+
144
+ def load_file(file)
145
+ data = YAML.safe_load(File.read(file), aliases: true, permitted_classes: YAML_CLASSES, filename: file)
146
+ return unless data.is_a?(Hash)
147
+
148
+ data.each do |locale, tree|
149
+ next unless tree.is_a?(Hash)
150
+
151
+ flatten(tree, [], locale.to_s, file)
152
+ end
153
+ rescue Psych::Exception, ArgumentError => e
154
+ problem(:parse, :warning, nil, nil, file, "could not parse: #{e.message.lines.first&.strip}")
155
+ end
156
+
157
+ def flatten(tree, path, locale, file)
158
+ tree.each do |k, v|
159
+ current = path + [k.to_s]
160
+ if v.is_a?(Hash)
161
+ scopes_of(locale) << current.join('.')
162
+ flatten(v, current, locale, file)
163
+ else
164
+ key = current.join('.')
165
+ scopes_of(locale) << key if v.is_a?(Array)
166
+ keys_of(locale)[key] = v
167
+ (@origins[locale] ||= {})[key] = file
168
+ end
169
+ end
170
+ end
171
+
172
+ # Read access never creates locales, so iterating @translations is safe.
173
+ def keys_of(locale, create: true)
174
+ create ? (@translations[locale] ||= {}) : @translations.fetch(locale, EMPTY_HASH)
175
+ end
176
+
177
+ def scopes_of(locale, create: true)
178
+ create ? (@scopes[locale] ||= Set.new) : @scopes.fetch(locale, EMPTY_SET)
179
+ end
180
+
181
+ def key_defined?(locale, key)
182
+ keys_of(locale, create: false).key?(key)
183
+ end
184
+
185
+ def scope?(locale, key)
186
+ scopes_of(locale, create: false).include?(key)
187
+ end
188
+
189
+ def check_references
190
+ count = 0
191
+ @translations.each do |locale, keys|
192
+ keys.each do |key, value|
193
+ each_string(value) do |string|
194
+ scan(string).each do |ref, whole|
195
+ count += 1
196
+ check_reference(locale, key, ref, whole)
197
+ end
198
+ end
199
+ end
200
+ end
201
+ count
202
+ end
203
+
204
+ def check_reference(locale, key, ref, whole)
205
+ file = @origins[locale][key]
206
+ found = chain(locale).find { |l| key_defined?(l, ref) || scope?(l, ref) }
207
+ if found.nil?
208
+ return if ignored?(ref)
209
+
210
+ problem(:broken, :error, locale, key, file,
211
+ "broken reference ${#{ref}} (not found in #{chain(locale).join(', ')})")
212
+ elsif !whole && scope?(found, ref)
213
+ problem(:scope, :error, locale, key, file,
214
+ "reference ${#{ref}} is embedded in text but points to a hash or array")
215
+ end
216
+ end
217
+
218
+ def check_cycles
219
+ @translations.each_key do |locale|
220
+ state = {}
221
+ @translations[locale].each_key do |key|
222
+ visit(locale, key, state, []) unless state[key]
223
+ end
224
+ end
225
+ end
226
+
227
+ # Iterative-friendly DFS with three states: nil (new), :active, :done.
228
+ def visit(locale, key, state, stack)
229
+ state[key] = :active
230
+ stack.push(key)
231
+ targets_of(locale, key).each do |ref|
232
+ case state[ref]
233
+ when :active
234
+ cycle = stack[stack.index(ref)..] + [ref]
235
+ problem(:cycle, :error, locale, cycle.first, @origins[locale][cycle.first],
236
+ "circular reference #{cycle.map { |k| "#{locale}.#{k}" }.join(' -> ')}")
237
+ when nil
238
+ visit(locale, ref, state, stack)
239
+ end
240
+ end
241
+ stack.pop
242
+ state[key] = :done
243
+ end
244
+
245
+ # Keys referenced from `key` as resolved from `locale` through its chain.
246
+ def targets_of(locale, key)
247
+ source = chain(locale).find { |l| key_defined?(l, key) }
248
+ return [] unless source
249
+
250
+ refs = []
251
+ each_string(@translations[source][key]) { |s| refs.concat(scan(s).map(&:first)) }
252
+ refs.select { |ref| chain(locale).any? { |l| key_defined?(l, ref) } }
253
+ end
254
+
255
+ def each_string(value, &block)
256
+ case value
257
+ when String then yield value
258
+ when Array then value.each { |v| each_string(v, &block) }
259
+ when Hash then value.each_value { |v| each_string(v, &block) }
260
+ end
261
+ end
262
+
263
+ # Returns [[key, whole_string?], ...] skipping escaped `$${}` markers.
264
+ def scan(string)
265
+ return [] unless Syntax.markers?(string)
266
+
267
+ tokens = Syntax.tokenize(string)
268
+ whole = Syntax.single_reference?(tokens)
269
+ tokens.grep(Syntax::Reference).map { |ref| [ref.key, whole] }
270
+ end
271
+
272
+ def ignored?(ref)
273
+ ignore_missing.any? { |glob| File.fnmatch?(glob, ref, File::FNM_EXTGLOB) }
274
+ end
275
+
276
+ def problem(kind, severity, locale, key, file, message)
277
+ @problems << Problem.new(kind: kind, severity: severity, locale: locale, key: key, file: file, message: message)
278
+ end
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'yaml'
5
+ require_relative 'checker'
6
+
7
+ module I18n
8
+ module EmbeddedReferences
9
+ # Command line front end for Checker.
10
+ #
11
+ # i18n-embedded-references check [paths...] [--config FILE]
12
+ # [--fallback LOCALE:PARENT[,PARENT]] [--default-fallback LOCALE]
13
+ # [--ignore-missing GLOB] [--quiet]
14
+ #
15
+ # Exit status: 0 clean, 1 errors found, 2 usage error.
16
+ #
17
+ # `app_fallbacks` (`{ enabled:, chains:, default_fallbacks: }`, see
18
+ # AppFallbacks) has the lowest precedence: a locale given in the config
19
+ # file or flags ignores the app chain and the app defaults. App chains are
20
+ # used as is. With `enabled: false` the app has no fallbacks, so the other
21
+ # locales get no regional parent either.
22
+ class CLI
23
+ CONFIG_FILE = '.i18n-embedded-references.yml'
24
+ DEFAULT_PATHS = ['config/locales'].freeze
25
+
26
+ def initialize(out: $stdout, err: $stderr, app_fallbacks: nil)
27
+ @out = out
28
+ @err = err
29
+ @app_fallbacks = app_fallbacks
30
+ end
31
+
32
+ def run(argv)
33
+ argv = argv.dup
34
+ command = argv.shift
35
+ return usage(2) unless command == 'check'
36
+
37
+ options = parse(argv)
38
+ return options if options.is_a?(Integer)
39
+
40
+ report = Checker.new(options[:paths], **checker_options(options)).run
41
+ print_report(report, quiet: options[:quiet])
42
+ report.ok? ? 0 : 1
43
+ rescue OptionParser::ParseError => e
44
+ @err.puts e.message
45
+ usage(2)
46
+ end
47
+
48
+ private
49
+
50
+ def checker_options(options)
51
+ app = @app_fallbacks || {}
52
+ {
53
+ fallbacks: options[:fallbacks], default_fallbacks: options[:default_fallbacks],
54
+ ignore_missing: options[:ignore_missing],
55
+ app_chains: app.fetch(:chains, {}), app_default_fallbacks: app.fetch(:default_fallbacks, []),
56
+ app_fallbacks_disabled: app[:enabled] == false
57
+ }
58
+ end
59
+
60
+ def parse(argv)
61
+ options = { fallbacks: {}, default_fallbacks: [], ignore_missing: [], quiet: false, config: nil }
62
+ parser = build_parser(options)
63
+ paths = parser.parse(argv)
64
+ return 0 if options[:help]
65
+
66
+ merge_config(options, paths)
67
+ end
68
+
69
+ def build_parser(options)
70
+ OptionParser.new do |o|
71
+ o.banner = 'Usage: i18n-embedded-references check [paths...] [options]'
72
+ o.on('-c', '--config FILE', "config file (default: #{CONFIG_FILE} if present)") { |f| options[:config] = f }
73
+ o.on('-f', '--fallback SPEC', 'LOCALE:PARENT[,PARENT] (repeatable)') do |spec|
74
+ locale, parents = spec.split(':', 2)
75
+ options[:fallbacks][locale] = parents.to_s.split(',')
76
+ end
77
+ o.on('-d', '--default-fallback LOCALE', 'fallback appended to every locale (repeatable)') do |l|
78
+ options[:default_fallbacks] << l
79
+ end
80
+ o.on('-i', '--ignore-missing GLOB', 'key glob whose absence is not an error (repeatable)') do |g|
81
+ options[:ignore_missing] << g
82
+ end
83
+ o.on('-q', '--quiet', 'only print the summary line') { options[:quiet] = true }
84
+ o.on('-h', '--help') do
85
+ @out.puts o
86
+ options[:help] = true
87
+ end
88
+ end
89
+ end
90
+
91
+ # Command line values win over the config file; lists are concatenated.
92
+ # The app fallbacks are not merged here: Checker applies them only to
93
+ # the locales that neither of them configures.
94
+ def merge_config(options, paths)
95
+ config = load_config(options[:config])
96
+ config.fetch('fallbacks', {}).each { |l, p| options[:fallbacks][l.to_s] ||= Array(p).map(&:to_s) }
97
+ %i[default_fallbacks ignore_missing].each { |k| options[k] = list(config, k.to_s) + options[k] }
98
+ options[:paths] = paths.empty? ? list(config, 'paths') : paths
99
+ options[:paths] = DEFAULT_PATHS if options[:paths].empty?
100
+ options
101
+ end
102
+
103
+ def load_config(path)
104
+ file = path || (File.exist?(CONFIG_FILE) ? CONFIG_FILE : nil)
105
+ return {} unless file
106
+
107
+ YAML.safe_load(File.read(file), filename: file) || {}
108
+ end
109
+
110
+ def list(config, key)
111
+ Array(config[key]).map(&:to_s)
112
+ end
113
+
114
+ def print_report(report, quiet:)
115
+ unless quiet
116
+ report.problems.group_by(&:file).each do |file, problems|
117
+ @out.puts file || '(no file)'
118
+ problems.each do |p|
119
+ @out.puts " #{[p.locale, p.key].compact.join('.')}" if p.key
120
+ @out.puts " #{p.severity}: #{p.message}"
121
+ end
122
+ @out.puts
123
+ end
124
+ end
125
+ @out.puts format('%<e>d errors, %<w>d warnings, %<r>d references checked in %<f>d files (%<s>.1fs)',
126
+ e: report.errors.size, w: report.warnings.size, r: report.references,
127
+ f: report.files, s: report.seconds)
128
+ end
129
+
130
+ def usage(status)
131
+ @err.puts 'Usage: i18n-embedded-references check [paths...] [options]'
132
+ status
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18n
4
+ module EmbeddedReferences
5
+ # Exposes `rake i18n:embedded_references:check` in Rails apps.
6
+ class Railtie < Rails::Railtie
7
+ rake_tasks do
8
+ load File.expand_path('tasks.rb', __dir__)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18n
4
+ module EmbeddedReferences
5
+ # The `${key}` grammar, shared by the backend and the checker so both
6
+ # agree on what a reference is.
7
+ #
8
+ # - `${some.key}` is a reference to `some.key`, taken verbatim (no strip)
9
+ # - `$${some.key}` is the literal text `${some.key}`
10
+ # - `${}` is not a reference
11
+ #
12
+ # A string that is exactly one reference (no surrounding text, not even
13
+ # whitespace) stands for the referenced value itself, which may be a hash
14
+ # or an array.
15
+ module Syntax
16
+ Reference = Struct.new(:key)
17
+
18
+ TOKENIZER = /(\$\$\{[^}]+\}|\$\{[^}]+\})/.freeze
19
+ TOKEN = /\A(\$)?\$\{([^}]+)\}\z/.freeze
20
+
21
+ module_function
22
+
23
+ # Whether the string may contain references or escapes at all.
24
+ def markers?(string)
25
+ string.include?('${')
26
+ end
27
+
28
+ # Splits a string into literal Strings and References, in order.
29
+ # Escapes come back as literal text.
30
+ def tokenize(string)
31
+ return [string] unless markers?(string)
32
+
33
+ string.split(TOKENIZER).reject(&:empty?).map do |token|
34
+ match = token.match(TOKEN)
35
+ next token unless match
36
+ next token[1..] if match[1] # `$${key}` escapes to the literal `${key}`
37
+
38
+ Reference.new(match[2])
39
+ end
40
+ end
41
+
42
+ def single_reference?(tokens)
43
+ tokens.size == 1 && tokens.first.is_a?(Reference)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake'
4
+ require_relative 'cli'
5
+ require_relative 'app_fallbacks'
6
+
7
+ namespace :i18n do
8
+ namespace :embedded_references do
9
+ desc 'Check ${} references in locale files ' \
10
+ '(PATHS="a b", IGNORE_MISSING="glob ...", CONFIG=file, APP_FALLBACKS=false)'
11
+ task :check do
12
+ # In a Rails app, boot it and mirror I18n.fallbacks so references are
13
+ # resolved like at runtime. APP_FALLBACKS=false skips booting the app.
14
+ app_fallbacks = nil
15
+ if ENV['APP_FALLBACKS'] != 'false' && Rake::Task.task_defined?(:environment)
16
+ Rake::Task[:environment].invoke
17
+ app_fallbacks = I18n::EmbeddedReferences::AppFallbacks.from_i18n
18
+ puts I18n::EmbeddedReferences::AppFallbacks.describe(app_fallbacks)
19
+ end
20
+
21
+ argv = ['check']
22
+ argv.concat(ENV['PATHS'].to_s.split) if ENV['PATHS']
23
+ argv.push('--config', ENV['CONFIG']) if ENV['CONFIG']
24
+ ENV['IGNORE_MISSING'].to_s.split.each { |glob| argv.push('--ignore-missing', glob) }
25
+ status = I18n::EmbeddedReferences::CLI.new(app_fallbacks: app_fallbacks).run(argv)
26
+ abort if status != 0
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18n
4
+ module EmbeddedReferences
5
+ VERSION = '1.2.0'
6
+ end
7
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n'
4
+ require_relative 'embedded_references/version'
5
+ require_relative 'backend/embedded_references'
6
+ require_relative 'embedded_references/railtie' if defined?(Rails::Railtie)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point matching the gem name, so `require "i18n-embedded-references"`
4
+ # (what Bundler tries by default) works alongside `require "i18n/embedded_references"`.
5
+ require_relative 'i18n/embedded_references'
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: i18n-embedded-references
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Jaime Arrieta
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-10-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: i18n
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '1.9'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '2'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '1.9'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '2'
33
+ description: |
34
+ An I18n backend extension that lets a translation embed other translations
35
+ using the `${some.key}` marker. References are resolved lazily on lookup,
36
+ recursively, with cycle detection and fallback-aware locale resolution.
37
+ Works with strings, hashes and arrays. Successor of i18n-recursive-lookup.
38
+ email:
39
+ - contacto@buk.cl
40
+ executables:
41
+ - i18n-embedded-references
42
+ extensions: []
43
+ extra_rdoc_files: []
44
+ files:
45
+ - CHANGELOG.md
46
+ - LICENSE
47
+ - README.md
48
+ - exe/i18n-embedded-references
49
+ - lib/i18n-embedded-references.rb
50
+ - lib/i18n/backend/embedded_references.rb
51
+ - lib/i18n/embedded_references.rb
52
+ - lib/i18n/embedded_references/app_fallbacks.rb
53
+ - lib/i18n/embedded_references/checker.rb
54
+ - lib/i18n/embedded_references/cli.rb
55
+ - lib/i18n/embedded_references/railtie.rb
56
+ - lib/i18n/embedded_references/syntax.rb
57
+ - lib/i18n/embedded_references/tasks.rb
58
+ - lib/i18n/embedded_references/version.rb
59
+ homepage: https://github.com/bukhr/i18n-embedded-references
60
+ licenses:
61
+ - MIT
62
+ metadata:
63
+ homepage_uri: https://github.com/bukhr/i18n-embedded-references
64
+ source_code_uri: https://github.com/bukhr/i18n-embedded-references
65
+ changelog_uri: https://github.com/bukhr/i18n-embedded-references/blob/main/CHANGELOG.md
66
+ rubygems_mfa_required: 'true'
67
+ post_install_message:
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '2.7'
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 3.5.22
83
+ signing_key:
84
+ specification_version: 4
85
+ summary: Reference other translations from inside a translation with ${key}.
86
+ test_files: []