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 +7 -0
- data/CHANGELOG.md +57 -0
- data/LICENSE +21 -0
- data/README.md +239 -0
- data/exe/i18n-embedded-references +6 -0
- data/lib/i18n/backend/embedded_references.rb +143 -0
- data/lib/i18n/embedded_references/app_fallbacks.rb +53 -0
- data/lib/i18n/embedded_references/checker.rb +281 -0
- data/lib/i18n/embedded_references/cli.rb +136 -0
- data/lib/i18n/embedded_references/railtie.rb +12 -0
- data/lib/i18n/embedded_references/syntax.rb +47 -0
- data/lib/i18n/embedded_references/tasks.rb +29 -0
- data/lib/i18n/embedded_references/version.rb +7 -0
- data/lib/i18n/embedded_references.rb +6 -0
- data/lib/i18n-embedded-references.rb +5 -0
- metadata +86 -0
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,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
|
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: []
|