i18n-js 4.1.0 → 4.2.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 60f1ad0e9477d8e6570573f64ac2a55136653da8e4791c6fc32e69c9547e197b
4
- data.tar.gz: d46a3d8b8a25020635d9c108b2cb2caf3a256e419112a21a0d986f94e1e19c37
3
+ metadata.gz: 0545bfe4fee26d8a5aed4aec6262e8a3db733ac951dbb0111f05e8fb2488e516
4
+ data.tar.gz: 50dd6e5e4e019f01e7b72fcfacb71faf2faa73ed7157082c647432b4ef96f263
5
5
  SHA512:
6
- metadata.gz: a1d63d094866a836a69a9b3fc8f2989608a1b794daf5d5c82062e17b3bcb07ab7fb0ec8f2c05aa27a2585236cff33d974c313ca81be96de129340ecce8f78490
7
- data.tar.gz: 7ec126b41663f765a421950c1ddcb215d460681355088fc77731673c44f3baf590cc58c6e5841bc9dda6f58732249488cdad3b020287b57d7d1de70cf8e95716
6
+ metadata.gz: b5366522c08e868ce0b555a4d0c0d10029bbee9270cb9602600f1ca4f2e89473f02baa577644dd5ae9a38046c3c03abf6c66029d79bac7d4ca41f50ed6ea4b86
7
+ data.tar.gz: c3a0670b398c51c9bd1e01b163c46b2e69fe8f4ee06a099d8d46dc78ee57da1e78bff51e35db98ddb15b91fe2f422c8a8584fadb0b4066dc191a01eab65adac4
@@ -44,7 +44,7 @@ jobs:
44
44
  hashFiles('package.json') }}
45
45
 
46
46
  - name: Set up Node
47
- uses: actions/setup-node@v2-beta
47
+ uses: actions/setup-node@v3.5.1
48
48
  with:
49
49
  node-version: ${{ matrix.node }}
50
50
 
data/CHANGELOG.md CHANGED
@@ -11,6 +11,22 @@ Prefix your message with one of the following:
11
11
  - [Security] in case of vulnerabilities.
12
12
  -->
13
13
 
14
+ ## v4.2.1 - Dec 25, 2022
15
+
16
+ - [Changed] Change plugin api to be based on instance methods. This avoids
17
+ having to pass in the config for each and every method. It also allows us
18
+ adding helper methods to the base class.
19
+ - [Fixed] Fix performance issues with embed fallback translations' initial
20
+ implementation.
21
+
22
+ ## v4.2.0 - Dec 10, 2022
23
+
24
+ - [Added] Add `I18nJS::Plugin.after_export(files:, config:)` method, that's
25
+ called whenever whenever I18nJS finishes exporting files. You can use it to
26
+ further process files, or generate new files based on the exported files.
27
+ - [Added] Bult-in plugin `I18nJS::ExportFilesPlugin`, which allows exporting
28
+ files out of the translations file by using a custom template.
29
+
14
30
  ## v4.1.0 - Dec 09, 2022
15
31
 
16
32
  - [Added] Parse configuration files as erb.
@@ -18,6 +34,8 @@ Prefix your message with one of the following:
18
34
  exported during `I18n.listen`'s boot. The default value is `true`.
19
35
  - [Added] Now it's possible to transform translations before exporting them
20
36
  using a stable plugin api.
37
+ - [Added] Built-in plugin `I18nJS::EmbedFallbackTranslationsPlugin`, which
38
+ allows embedding missing translations on exported files.
21
39
  - [Deprecated] The `i18n check` has been deprecated. Use
22
40
  `i18n lint:translations` instead.
23
41
  - [Added] Use `i18n lint:scripts` to lint JavaScript/TypeScript.
@@ -47,8 +47,8 @@ JSON
47
47
  ```
48
48
 
49
49
  You can also use guard. Make sure you have both
50
- [guard](https://rubygems.org/packages/guard) and
51
- [guard-compat](https://rubygems.org/packages/guard-compat) installed and use
50
+ [guard](https://rubygems.org/gems/guard) and
51
+ [guard-compat](https://rubygems.org/gems/guard-compat) installed and use
52
52
  Guardfile file with the following contents:
53
53
 
54
54
  ```ruby
@@ -174,8 +174,8 @@ translations:
174
174
 
175
175
  Other configuration options:
176
176
 
177
- - `export_i18n_js`: removed without an equivalent
178
- - `fallbacks`: removed without an equivalent
177
+ - `export_i18n_js`: replaced by [export_files plugin](https://github.com/fnando/i18n-js#export_files)
178
+ - `fallbacks`: replaced by [embed_fallback_translations plugin](https://github.com/fnando/i18n-js#embed_fallback_translations)
179
179
  - `js_available_locales`: removed (on v4 you can use groups, like in
180
180
  `{pt-BR,en}.*`)
181
181
  - `namespace`: removed without an equivalent
data/README.md CHANGED
@@ -140,30 +140,91 @@ embed_fallback_translations:
140
140
  enabled: true
141
141
  ```
142
142
 
143
+ ##### `export_files`:
144
+
145
+ By default, i18n-js will export only JSON files out of your translations. This
146
+ plugin allows exporting other file formats. To use it, add the following to your
147
+ configuration file:
148
+
149
+ ```yaml
150
+ export_files:
151
+ enabled: true
152
+ files:
153
+ - template: path/to/template.erb
154
+ output: "%{dir}/%{base_name}.ts"
155
+ ```
156
+
157
+ You can export multiple files by define more entries.
158
+
159
+ The output name can use the following placeholders:
160
+
161
+ - `%{dir}`: the directory where the translation file is.
162
+ - `%{name}`: file name with extension.
163
+ - `%{base_name}`: file name without extension.
164
+ - `%{digest}`: MD5 hexdigest from the generated file.
165
+
166
+ The template file must be a valid eRB template. You can execute arbitrary Ruby
167
+ code, so be careful. An example of how you can generate a file can be seen
168
+ below:
169
+
170
+ ```erb
171
+ /* eslint-disable */
172
+ <%= banner %>
173
+
174
+ import { i18n } from "config/i18n";
175
+
176
+ i18n.store(<%= JSON.pretty_generate(translations) %>);
177
+ ```
178
+
179
+ This template is loading the instance from `config/i18n` and storing the
180
+ translations that have been loaded. The
181
+ `banner(comment: "// ", include_time: true)` method is built-in. The generated
182
+ file will look something like this:
183
+
184
+ ```typescript
185
+ /* eslint-disable */
186
+ // File generated by i18n-js on 2022-12-10 15:37:00 +0000
187
+
188
+ import { i18n } from "config/i18n";
189
+
190
+ i18n.store({
191
+ en: {
192
+ "bunny rabbit adventure": "bunny rabbit adventure",
193
+ "hello sunshine!": "hello sunshine!",
194
+ "time for bed!": "time for bed!",
195
+ },
196
+ es: {
197
+ "bunny rabbit adventure": "conejito conejo aventura",
198
+ bye: "adios",
199
+ "time for bed!": "hora de acostarse!",
200
+ },
201
+ pt: {
202
+ "bunny rabbit adventure": "a aventura da coelhinha",
203
+ bye: "tchau",
204
+ "time for bed!": "hora de dormir!",
205
+ },
206
+ });
207
+ ```
208
+
143
209
  #### Plugin API
144
210
 
145
211
  You can transform the exported translations by adding plugins. A plugin must
146
- inherit from `I18nJS::Plugin` and can have 3 class methods. The following
147
- example shows how the built-in `embed_fallback_translations` plugin is
148
- implemented.
212
+ inherit from `I18nJS::Plugin` and can have 4 class methods (they're all optional
213
+ and will default to a noop implementation). For real examples, see [lib/i18n-js/embed_fallback_translations_plugin.rb](https://github.com/fnando/i18n-js/blob/main/lib/i18n-js/embed_fallback_translations_plugin.rb) and [lib/i18n-js/export_files_plugin.rb](https://github.com/fnando/i18n-js/blob/main/lib/i18n-js/export_files_plugin.rb)
149
214
 
150
215
  ```ruby
151
216
  # frozen_string_literal: true
152
217
 
153
218
  module I18nJS
154
- require "i18n-js/plugin"
155
-
156
- class EmbedFallbackTranslationsPlugin < I18nJS::Plugin
157
- CONFIG_KEY = :embed_fallback_translations
219
+ class SamplePlugin < I18nJS::Plugin
220
+ # This method is responsible for transforming the translations. The
221
+ # translations you'll receive may be already be filtered by other plugins
222
+ # and by the default filtering itself. If you need to access the original
223
+ # translations, use `I18nJS.translations`.
224
+ def transform(translations:)
225
+ # transform `translations` here…
158
226
 
159
- # This method must set up the basic plugin configuration, like adding the
160
- # config's root key in case your plugin accepts configuration (defined via
161
- # the config file).
162
- #
163
- # If you don't add this key, the linter will prevent non-default keys from
164
- # being added to the configuration file.
165
- def self.setup
166
- I18nJS::Schema.root_keys << CONFIG_KEY
227
+ translations
167
228
  end
168
229
 
169
230
  # In case your plugin accepts configuration, this is where you must validate
@@ -171,63 +232,44 @@ module I18nJS
171
232
  # If the configuration contains invalid data, then you must raise an
172
233
  # exception using something like
173
234
  # `raise I18nJS::Schema::InvalidError, error_message`.
174
- def self.validate_schema(config:)
175
- return unless config.key?(CONFIG_KEY)
176
-
177
- plugin_config = config[CONFIG_KEY]
178
- valid_keys = %i[enabled]
179
- schema = I18nJS::Schema.new(config)
180
-
181
- schema.expect_required_keys(valid_keys, plugin_config)
182
- schema.reject_extraneous_keys(valid_keys, plugin_config)
183
- schema.expect_enabled_config(CONFIG_KEY, plugin_config[:enabled])
235
+ #
236
+ # Notice the validation will only happen when the plugin configuration is
237
+ # set (i.e. the configuration contains your config key).
238
+ def validate_schema
239
+ # validate plugin schema here…
184
240
  end
185
241
 
186
- # This method is responsible for transforming the translations. The
187
- # translations you'll receive may be already be filtered by other plugins
188
- # and by the default filtering itself. If you need to access the original
189
- # translations, use `I18nJS.translations`.
242
+ # This method must set up the basic plugin configuration, like adding the
243
+ # config's root key in case your plugin accepts configuration (defined via
244
+ # the config file).
190
245
  #
191
- # Make sure you always check whether your plugin is active before
192
- # transforming translations; otherwise, opting out transformation won't be
193
- # possible.
194
- def self.transform(translations:, config:)
195
- return translations unless config.dig(CONFIG_KEY, :enabled)
196
-
197
- translations_glob = Glob.new(translations)
198
- translations_glob << "*"
199
-
200
- mapping = translations.keys.each_with_object({}) do |locale, buffer|
201
- buffer[locale] = Glob.new(translations[locale]).tap do |glob|
202
- glob << "*"
203
- end
204
- end
205
-
206
- default_locale = I18n.default_locale
207
- default_locale_glob = mapping.delete(default_locale)
208
- default_locale_paths = default_locale_glob.paths
209
-
210
- mapping.each do |locale, glob|
211
- missing_keys = default_locale_paths - glob.paths
212
-
213
- missing_keys.each do |key|
214
- components = key.split(".").map(&:to_sym)
215
- fallback_translation = translations.dig(default_locale, *components)
216
-
217
- next unless fallback_translation
218
-
219
- translations_glob.set([locale, key].join("."), fallback_translation)
220
- end
221
- end
246
+ # If you don't add this key, the linter will prevent non-default keys from
247
+ # being added to the configuration file.
248
+ def setup
249
+ # If you plugin has configuration, uncomment the line below
250
+ # I18nJS::Schema.root_keys << config_key
251
+ end
222
252
 
223
- translations_glob.to_h
253
+ # This method is called whenever `I18nJS.call(**kwargs)` finishes exporting
254
+ # JSON files based on your configuration.
255
+ #
256
+ # You can use it to further process exported files, or generate new files
257
+ # based on the translations that have been exported.
258
+ def after_export(files:)
259
+ # process exported files here…
224
260
  end
225
261
  end
226
-
227
- I18nJS.register_plugin(EmbedFallbackTranslationsPlugin)
228
262
  end
229
263
  ```
230
264
 
265
+ The class `I18nJS::Plugin` implements some helper methods that you can use:
266
+
267
+ - `I18nJS::Plugin#config_key`: the configuration key that was inferred out of
268
+ your plugin's class name.
269
+ - `I18nJS::Plugin#config`: the plugin configuration.
270
+ - `I18nJS::Plugin#enabled?`: whether the plugin is enabled or not based on the
271
+ plugin's configuration.
272
+
231
273
  To distribute this plugin, you need to create a gem package that matches the
232
274
  pattern `i18n-js/*_plugin.rb`. You can test whether your plugin will be found by
233
275
  installing your gem, opening a iRB session and running
@@ -242,7 +284,20 @@ To list missing and extraneous translations, you can use
242
284
  how `i18n export` does, but will output the list of keys that don't have a
243
285
  matching translation against the default locale. Here's an example:
244
286
 
245
- ![`i18n lint:translations` command in action](https://github.com/fnando/i18n-js/raw/main/images/i18njs-check.gif)
287
+ ```console
288
+ $ i18n lint:translations
289
+ => Config file: "./config/i18n.yml"
290
+ => Require file: "./config/environment.rb"
291
+ => Check "./config/i18n.yml" for ignored keys.
292
+ => en: 232 translations
293
+ => pt-BR: 5 missing, 1 extraneous, 1 ignored
294
+ - pt-BR.actors.github.metrics (missing)
295
+ - pt-BR.actors.github.metrics_hint (missing)
296
+ - pt-BR.actors.github.repo_metrics (missing)
297
+ - pt-BR.actors.github.repository (missing)
298
+ - pt-BR.actors.github.user_metrics (missing)
299
+ - pt-BR.github.repository (extraneous)
300
+ ```
246
301
 
247
302
  This command will exist with status 1 whenever there are missing translations.
248
303
  This way you can use it as a CI linting.
@@ -300,9 +355,9 @@ you're using dynamic scoping through variables (e.g.
300
355
  `const scope = "message"; i18n.t(scope)`), they will be skipped.
301
356
 
302
357
  ```console
303
- $ i18n lint:scripts --config test/config/lint.yml --require test/config/require.rb
304
- => Config file: "test/config/lint.yml"
305
- => Require file: "test/config/require.rb"
358
+ $ i18n lint:scripts
359
+ => Config file: "./config/i18n.yml"
360
+ => Require file: "./config/environment.rb"
306
361
  => Node: "/Users/fnando/.asdf/shims/node"
307
362
  => Available locales: [:en, :es, :pt]
308
363
  => Patterns: ["!(node_modules)/**/*.js", "!(node_modules)/**/*.ts", "!(node_modules)/**/*.jsx", "!(node_modules)/**/*.tsx"]
@@ -464,7 +519,7 @@ that loads all the exported translation.
464
519
 
465
520
  [There's a document](https://github.com/fnando/i18n-js/tree/main/MIGRATING_FROM_V3_TO_V4.md)
466
521
  outlining some of the things you need to do to migrate from v3 to v4. It may not
467
- be as complete as we'd like it to be, so let's know if you face any issues
522
+ be as complete as we'd like it to be, so let us know if you face any issues
468
523
  during the migration is not outline is that document.
469
524
 
470
525
  #### How can I export translations without having a database around?
@@ -19,7 +19,7 @@ major, minor, patch = *segments.take(3).map(&:to_i)
19
19
  pre = segments[4].to_s
20
20
  pre_version = pre.gsub(/[^\d]/m, "").to_i
21
21
  date = Time.now.strftime("%b %d, %Y")
22
- release = false
22
+ dry_run = false
23
23
  alpha = false
24
24
 
25
25
  OptionParser.new do |opts|
@@ -39,8 +39,8 @@ OptionParser.new do |opts|
39
39
  alpha = true
40
40
  end
41
41
 
42
- opts.on("--release") do
43
- release = true
42
+ opts.on("--dry-run") do
43
+ dry_run = true
44
44
  end
45
45
  end.parse!
46
46
 
@@ -69,11 +69,13 @@ write_file version_path,
69
69
 
70
70
  puts "=> Updated #{version_path}"
71
71
 
72
- if release
72
+ unless dry_run
73
73
  system "git", "add", changelog_path, version_path
74
74
  system "git", "commit", "-m", "Bump up version (v#{version})"
75
- system "git", "tag", "v#{version}"
75
+ system "rake", "release"
76
76
  end
77
77
 
78
- system "rake", "build"
79
- system "git", "checkout", changelog_path, version_path unless release
78
+ if dry_run
79
+ system "rake", "build"
80
+ system "git", "checkout", changelog_path, version_path
81
+ end
@@ -4,73 +4,65 @@ module I18nJS
4
4
  require "i18n-js/plugin"
5
5
 
6
6
  class EmbedFallbackTranslationsPlugin < I18nJS::Plugin
7
- CONFIG_KEY = :embed_fallback_translations
7
+ module Utils
8
+ # Based on deep_merge by Stefan Rusterholz, see
9
+ # <https://www.ruby-forum.com/topic/142809>.
10
+ # This method is used to handle I18n fallbacks. Given two equivalent path
11
+ # nodes in two locale trees:
12
+ # 1. If the node in the current locale appears to be an I18n pluralization
13
+ # (:one, :other, etc.), use the node, but merge in any missing/non-nil
14
+ # keys from the fallback (default) locale.
15
+ # 2. Else if both nodes are Hashes, combine (merge) the key-value pairs of
16
+ # the two nodes into one, prioritizing the current locale.
17
+ # 3. Else if either node is nil, use the other node.
18
+
19
+ PLURAL_KEYS = %i[zero one two few many other].freeze
20
+ PLURAL_MERGER = proc {|_key, v1, v2| v1 || v2 }
21
+ MERGER = proc do |_key, v1, v2|
22
+ if v1.is_a?(Hash) && v2.is_a?(Hash)
23
+ if (v2.keys - PLURAL_KEYS).empty?
24
+ v2.merge(v1, &PLURAL_MERGER).slice(*v2.keys)
25
+ else
26
+ v1.merge(v2, &MERGER)
27
+ end
28
+ else
29
+ v2 || v1
30
+ end
31
+ end
8
32
 
9
- # This method must set up the basic plugin configuration, like adding the
10
- # config's root key in case your plugin accepts configuration (defined via
11
- # the config file).
12
- #
13
- # If you don't add this key, the linter will prevent non-default keys from
14
- # being added to the configuration file.
15
- def self.setup
16
- I18nJS::Schema.root_keys << CONFIG_KEY
33
+ def self.deep_merge(target_hash, hash)
34
+ target_hash.merge(hash, &MERGER)
35
+ end
17
36
  end
18
37
 
19
- # In case your plugin accepts configuration, this is where you must validate
20
- # the configuration, making sure only valid keys and type is provided.
21
- # If the configuration contains invalid data, then you must raise an
22
- # exception using something like
23
- # `raise I18nJS::Schema::InvalidError, error_message`.
24
- def self.validate_schema(config:)
25
- return unless config.key?(CONFIG_KEY)
38
+ def setup
39
+ I18nJS::Schema.root_keys << config_key
40
+ end
26
41
 
27
- plugin_config = config[CONFIG_KEY]
42
+ def validate_schema
28
43
  valid_keys = %i[enabled]
29
- schema = I18nJS::Schema.new(config)
30
44
 
31
- schema.expect_required_keys(valid_keys, plugin_config)
32
- schema.reject_extraneous_keys(valid_keys, plugin_config)
33
- schema.expect_enabled_config(CONFIG_KEY, plugin_config[:enabled])
45
+ schema.expect_required_keys(keys: valid_keys, path: [config_key])
46
+ schema.reject_extraneous_keys(keys: valid_keys, path: [config_key])
34
47
  end
35
48
 
36
- # This method is responsible for transforming the translations. The
37
- # translations you'll receive may be already be filtered by other plugins
38
- # and by the default filtering itself. If you need to access the original
39
- # translations, use `I18nJS.translations`.
40
- #
41
- # Make sure you always check whether your plugin is active before
42
- # transforming translations; otherwise, opting out transformation won't be
43
- # possible.
44
- def self.transform(translations:, config:)
45
- return translations unless config.dig(CONFIG_KEY, :enabled)
49
+ def transform(translations:)
50
+ return translations unless enabled?
46
51
 
47
- translations_glob = Glob.new(translations)
48
- translations_glob << "*"
52
+ fallback_locale = I18n.default_locale.to_sym
53
+ locales_to_fallback = translations.keys - [fallback_locale]
49
54
 
50
- mapping = translations.keys.each_with_object({}) do |locale, buffer|
51
- buffer[locale] = Glob.new(translations[locale]).tap do |glob|
52
- glob << "*"
53
- end
54
- end
55
+ translations_with_fallback = {}
56
+ translations_with_fallback[fallback_locale] =
57
+ translations[fallback_locale]
55
58
 
56
- default_locale = I18n.default_locale
57
- default_locale_glob = mapping.delete(default_locale)
58
- default_locale_paths = default_locale_glob.paths
59
-
60
- mapping.each do |locale, glob|
61
- missing_keys = default_locale_paths - glob.paths
62
-
63
- missing_keys.each do |key|
64
- components = key.split(".").map(&:to_sym)
65
- fallback_translation = translations.dig(default_locale, *components)
66
-
67
- next unless fallback_translation
68
-
69
- translations_glob.set([locale, key].join("."), fallback_translation)
70
- end
59
+ locales_to_fallback.each do |locale|
60
+ translations_with_fallback[locale] = Utils.deep_merge(
61
+ translations[fallback_locale], translations[locale]
62
+ )
71
63
  end
72
64
 
73
- translations_glob.to_h
65
+ translations_with_fallback
74
66
  end
75
67
  end
76
68
 
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nJS
4
+ require "i18n-js/plugin"
5
+
6
+ class ExportFilesPlugin < I18nJS::Plugin
7
+ def setup
8
+ I18nJS::Schema.root_keys << config_key
9
+ end
10
+
11
+ def validate_schema
12
+ valid_keys = %i[enabled files]
13
+
14
+ schema.expect_required_keys(keys: valid_keys, path: [config_key])
15
+ schema.reject_extraneous_keys(keys: valid_keys, path: [config_key])
16
+ schema.expect_array_with_items(path: [config_key, :files])
17
+
18
+ config[:files].each_with_index do |_exports, index|
19
+ export_keys = %i[template output]
20
+
21
+ schema.expect_required_keys(
22
+ keys: export_keys,
23
+ path: [config_key, :files, index]
24
+ )
25
+
26
+ schema.reject_extraneous_keys(
27
+ keys: export_keys,
28
+ path: [config_key, :files, index]
29
+ )
30
+
31
+ schema.expect_type(
32
+ path: [config_key, :files, index, :template],
33
+ types: String
34
+ )
35
+
36
+ schema.expect_type(
37
+ path: [config_key, :files, index, :output],
38
+ types: String
39
+ )
40
+ end
41
+ end
42
+
43
+ def after_export(files:)
44
+ require "erb"
45
+ require "digest/md5"
46
+ require "json"
47
+
48
+ files.each do |file|
49
+ dir = File.dirname(file)
50
+ name = File.basename(file)
51
+ extension = File.extname(name)
52
+ base_name = File.basename(file, extension)
53
+
54
+ config[:files].each do |export|
55
+ translations = JSON.load_file(file)
56
+ template = Template.new(
57
+ file: file,
58
+ translations: translations,
59
+ template: export[:template]
60
+ )
61
+
62
+ contents = template.render
63
+
64
+ output = format(
65
+ export[:output],
66
+ dir: dir,
67
+ name: name,
68
+ extension: extension,
69
+ digest: Digest::MD5.hexdigest(contents),
70
+ base_name: base_name
71
+ )
72
+
73
+ File.open(output, "w") do |io|
74
+ io << contents
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ class Template
81
+ attr_accessor :file, :translations, :template
82
+
83
+ def initialize(**kwargs)
84
+ kwargs.each do |key, value|
85
+ public_send("#{key}=", value)
86
+ end
87
+ end
88
+
89
+ def banner(comment: "// ", include_time: true)
90
+ [
91
+ "#{comment}File generated by i18n-js",
92
+ include_time ? " on #{Time.now}" : nil
93
+ ].compact.join
94
+ end
95
+
96
+ def render
97
+ ERB.new(File.read(template)).result(binding)
98
+ end
99
+ end
100
+ end
101
+
102
+ I18nJS.register_plugin(ExportFilesPlugin)
103
+ end
@@ -3,13 +3,16 @@
3
3
  require_relative "schema"
4
4
 
5
5
  module I18nJS
6
+ def self.available_plugins
7
+ @available_plugins ||= Set.new
8
+ end
9
+
6
10
  def self.plugins
7
11
  @plugins ||= []
8
12
  end
9
13
 
10
14
  def self.register_plugin(plugin)
11
- plugins << plugin
12
- plugin.setup
15
+ available_plugins << plugin
13
16
  end
14
17
 
15
18
  def self.plugin_files
@@ -22,17 +25,79 @@ module I18nJS
22
25
  end
23
26
  end
24
27
 
28
+ def self.initialize_plugins!(config:)
29
+ @plugins = available_plugins.map do |plugin|
30
+ plugin.new(config: config).tap(&:setup)
31
+ end
32
+ end
33
+
25
34
  class Plugin
26
- def self.transform(translations:, config:) # rubocop:disable Lint/UnusedMethodArgument
35
+ # The configuration that's being used to export translations.
36
+ attr_reader :main_config
37
+
38
+ # The `I18nJS::Schema` instance that can be used to validate your plugin's
39
+ # configuration.
40
+ attr_reader :schema
41
+
42
+ def initialize(config:)
43
+ @main_config = config
44
+ @schema = I18nJS::Schema.new(@main_config)
45
+ end
46
+
47
+ # Infer the config key name out of the class.
48
+ # If you plugin is called `MySamplePlugin`, the key will be `my_sample`.
49
+ def config_key
50
+ self.class.name.split("::").last
51
+ .gsub(/Plugin$/, "")
52
+ .gsub(/^([A-Z]+)([A-Z])/) { "#{$1.downcase}#{$2}" }
53
+ .gsub(/^([A-Z]+)/) { $1.downcase }
54
+ .gsub(/([A-Z]+)/m) { "_#{$1.downcase}" }
55
+ .downcase
56
+ .to_sym
57
+ end
58
+
59
+ # Return the plugin configuration
60
+ def config
61
+ main_config[config_key] || {}
62
+ end
63
+
64
+ # Check whether plugin is enabled or not.
65
+ # A plugin is enabled when the plugin configuration has `enabled: true`.
66
+ def enabled?
67
+ config[:enabled]
68
+ end
69
+
70
+ # This method is responsible for transforming the translations. The
71
+ # translations you'll receive may be already be filtered by other plugins
72
+ # and by the default filtering itself. If you need to access the original
73
+ # translations, use `I18nJS.translations`.
74
+ def transform(translations:)
27
75
  translations
28
76
  end
29
77
 
30
- # Must raise I18nJS::SchemaInvalidError with the error message if schema
31
- # validation has failed.
32
- def self.validate_schema(config:)
78
+ # In case your plugin accepts configuration, this is where you must validate
79
+ # the configuration, making sure only valid keys and type is provided.
80
+ # If the configuration contains invalid data, then you must raise an
81
+ # exception using something like
82
+ # `raise I18nJS::Schema::InvalidError, error_message`.
83
+ def validate_schema
84
+ end
85
+
86
+ # This method must set up the basic plugin configuration, like adding the
87
+ # config's root key in case your plugin accepts configuration (defined via
88
+ # the config file).
89
+ #
90
+ # If you don't add this key, the linter will prevent non-default keys from
91
+ # being added to the configuration file.
92
+ def setup
33
93
  end
34
94
 
35
- def self.setup
95
+ # This method is called whenever `I18nJS.call(**kwargs)` finishes exporting
96
+ # JSON files based on your configuration.
97
+ #
98
+ # You can use it to further process exported files, or generate new files
99
+ # based on the translations that have been exported.
100
+ def after_export(files:)
36
101
  end
37
102
  end
38
103
  end
@@ -25,7 +25,7 @@ module I18nJS
25
25
  def self.validate!(target)
26
26
  schema = new(target)
27
27
  schema.validate!
28
- I18nJS.plugins.each {|plugin| plugin.validate_schema(config: target) }
28
+ schema
29
29
  end
30
30
 
31
31
  attr_reader :target
@@ -35,13 +35,44 @@ module I18nJS
35
35
  end
36
36
 
37
37
  def validate!
38
- expect_type(:root, target, Hash, target)
38
+ validate_root
39
+
40
+ expect_required_keys(
41
+ keys: self.class.required_root_keys,
42
+ path: nil
43
+ )
44
+
45
+ reject_extraneous_keys(
46
+ keys: self.class.root_keys,
47
+ path: nil
48
+ )
39
49
 
40
- expect_required_keys(self.class.required_root_keys, target)
41
- reject_extraneous_keys(self.class.root_keys, target)
42
50
  validate_translations
43
51
  validate_lint_translations
44
52
  validate_lint_scripts
53
+ validate_plugins
54
+ end
55
+
56
+ def validate_plugins
57
+ I18nJS.plugins.each do |plugin|
58
+ next unless target.key?(plugin.config_key)
59
+
60
+ expect_type(
61
+ path: [plugin.config_key, :enabled],
62
+ types: [TrueClass, FalseClass]
63
+ )
64
+
65
+ plugin.validate_schema
66
+ end
67
+ end
68
+
69
+ def validate_root
70
+ return if target.is_a?(Hash)
71
+
72
+ message = "Expected config to be \"Hash\"; " \
73
+ "got #{target.class} instead"
74
+
75
+ reject message, target
45
76
  end
46
77
 
47
78
  def validate_lint_translations
@@ -49,11 +80,14 @@ module I18nJS
49
80
 
50
81
  return unless target.key?(key)
51
82
 
52
- config = target[key]
83
+ expect_type(path: [key], types: Hash)
84
+
85
+ expect_required_keys(
86
+ keys: REQUIRED_LINT_TRANSLATIONS_KEYS,
87
+ path: [key]
88
+ )
53
89
 
54
- expect_type(key, config, Hash, target)
55
- expect_required_keys(REQUIRED_LINT_TRANSLATIONS_KEYS, config)
56
- expect_type(:ignore, config[:ignore], Array, config)
90
+ expect_type(path: [key, :ignore], types: Array)
57
91
  end
58
92
 
59
93
  def validate_lint_scripts
@@ -61,31 +95,36 @@ module I18nJS
61
95
 
62
96
  return unless target.key?(key)
63
97
 
64
- config = target[key]
65
-
66
- expect_type(key, config, Hash, target)
67
- expect_required_keys(REQUIRED_LINT_SCRIPTS_KEYS, config)
68
- expect_type(:ignore, config[:ignore], Array, config)
69
- expect_type(:patterns, config[:patterns], Array, config)
98
+ expect_type(path: [key], types: Hash)
99
+ expect_required_keys(
100
+ keys: REQUIRED_LINT_SCRIPTS_KEYS,
101
+ path: [key]
102
+ )
103
+ expect_type(path: [key, :ignore], types: Array)
104
+ expect_type(path: [key, :patterns], types: Array)
70
105
  end
71
106
 
72
107
  def validate_translations
73
- translations = target[:translations]
74
-
75
- expect_type(:translations, translations, Array, target)
76
- expect_array_with_items(:translations, translations)
108
+ expect_array_with_items(path: [:translations])
77
109
 
78
- translations.each do |translation|
79
- validate_translation(translation)
110
+ target[:translations].each_with_index do |translation, index|
111
+ validate_translation(translation, index)
80
112
  end
81
113
  end
82
114
 
83
- def validate_translation(translation)
84
- expect_required_keys(REQUIRED_TRANSLATION_KEYS, translation)
85
- reject_extraneous_keys(TRANSLATION_KEYS, translation)
86
- expect_type(:file, translation[:file], String, translation)
87
- expect_type(:patterns, translation[:patterns], Array, translation)
88
- expect_array_with_items(:patterns, translation[:patterns], translation)
115
+ def validate_translation(_translation, index)
116
+ expect_required_keys(
117
+ path: [:translations, index],
118
+ keys: REQUIRED_TRANSLATION_KEYS
119
+ )
120
+
121
+ reject_extraneous_keys(
122
+ keys: TRANSLATION_KEYS,
123
+ path: [:translations, index]
124
+ )
125
+
126
+ expect_type(path: [:translations, index, :file], types: String)
127
+ expect_array_with_items(path: [:translations, index, :patterns])
89
128
  end
90
129
 
91
130
  def reject(error_message, node = nil)
@@ -93,51 +132,85 @@ module I18nJS
93
132
  raise InvalidError, "#{error_message}#{node_json}"
94
133
  end
95
134
 
96
- def expect_enabled_config(config_key, value)
97
- return if [TrueClass, FalseClass].include?(value.class)
98
-
99
- actual_type = value.class
100
-
101
- reject "Expected #{config_key}.enabled to be a boolean; " \
102
- "got #{actual_type} instead"
103
- end
135
+ def expect_type(path:, types:)
136
+ path = prepare_path(path: path)
137
+ value = value_for(path: path)
138
+ types = Array(types)
104
139
 
105
- def expect_type(attribute, value, expected_type, payload)
106
- return if value.is_a?(expected_type)
140
+ return if types.any? {|type| value.is_a?(type) }
107
141
 
108
142
  actual_type = value.class
109
143
 
144
+ type_desc = if types.size == 1
145
+ types[0].to_s.inspect
146
+ else
147
+ "one of #{types.inspect}"
148
+ end
149
+
110
150
  message = [
111
- "Expected #{attribute.inspect} to be #{expected_type};",
151
+ "Expected #{path.join('.').inspect} to be #{type_desc};",
112
152
  "got #{actual_type} instead"
113
153
  ].join(" ")
114
154
 
115
- reject message, payload
155
+ reject message, target
116
156
  end
117
157
 
118
- def expect_array_with_items(attribute, value, payload = value)
158
+ def expect_array_with_items(path:)
159
+ expect_type(path: path, types: Array)
160
+
161
+ path = prepare_path(path: path)
162
+ value = value_for(path: path)
163
+
119
164
  return unless value.empty?
120
165
 
121
- reject "Expected #{attribute.inspect} to have at least one item", payload
166
+ reject "Expected #{path.join('.').inspect} to have at least one item",
167
+ target
122
168
  end
123
169
 
124
- def expect_required_keys(required_keys, value)
125
- keys = value.keys.map(&:to_sym)
170
+ def expect_required_keys(keys:, path:)
171
+ path = prepare_path(path: path)
172
+ value = value_for(path: path)
173
+ actual_keys = value.keys.map(&:to_sym)
126
174
 
127
- required_keys.each do |key|
128
- next if keys.include?(key)
175
+ keys.each do |key|
176
+ next if actual_keys.include?(key)
129
177
 
130
- reject "Expected #{key.inspect} to be defined", value
178
+ path_desc = if path.empty?
179
+ key.to_s.inspect
180
+ else
181
+ (path + [key]).join(".").inspect
182
+ end
183
+
184
+ reject "Expected #{path_desc} to be defined", target
131
185
  end
132
186
  end
133
187
 
134
- def reject_extraneous_keys(allowed_keys, value)
135
- keys = value.keys.map(&:to_sym)
136
- extraneous = keys.to_a - allowed_keys.to_a
188
+ def reject_extraneous_keys(keys:, path:)
189
+ path = prepare_path(path: path)
190
+ value = value_for(path: path)
191
+
192
+ actual_keys = value.keys.map(&:to_sym)
193
+ extraneous = actual_keys.to_a - keys.to_a
137
194
 
138
195
  return if extraneous.empty?
139
196
 
140
- reject "Unexpected keys: #{extraneous.join(', ')}", value
197
+ path_desc = if path.empty?
198
+ "config"
199
+ else
200
+ path.join(".").inspect
201
+ end
202
+
203
+ reject "#{path_desc} has unexpected keys: #{extraneous.inspect}",
204
+ target
205
+ end
206
+
207
+ def prepare_path(path:)
208
+ path = path.to_s.split(".").map(&:to_sym) unless path.is_a?(Array)
209
+ path
210
+ end
211
+
212
+ def value_for(path:)
213
+ path.empty? ? target : target.dig(*path)
141
214
  end
142
215
  end
143
216
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module I18nJS
4
- VERSION = "4.1.0"
4
+ VERSION = "4.2.1"
5
5
  end
data/lib/i18n-js.rb CHANGED
@@ -23,25 +23,32 @@ module I18nJS
23
23
  "you must set either `config_file` or `config`"
24
24
  end
25
25
 
26
- load_plugins!
27
-
28
26
  config = Glob::SymbolizeKeys.call(config || load_config_file(config_file))
29
27
 
28
+ load_plugins!
29
+ initialize_plugins!(config: config)
30
30
  Schema.validate!(config)
31
+
31
32
  exported_files = []
32
33
 
33
- config[:translations].each do |group|
34
- exported_files += export_group(group, config)
34
+ config[:translations].each {|group| exported_files += export_group(group) }
35
+
36
+ plugins.each do |plugin|
37
+ plugin.after_export(files: exported_files.dup) if plugin.enabled?
35
38
  end
36
39
 
37
40
  exported_files
38
41
  end
39
42
 
40
- def self.export_group(group, config)
43
+ def self.export_group(group)
41
44
  filtered_translations = Glob.filter(translations, group[:patterns])
42
45
  filtered_translations =
43
46
  plugins.reduce(filtered_translations) do |buffer, plugin|
44
- plugin.transform(translations: buffer, config: config)
47
+ if plugin.enabled?
48
+ plugin.transform(translations: buffer)
49
+ else
50
+ buffer
51
+ end
45
52
  end
46
53
 
47
54
  output_file_path = File.expand_path(group[:file])
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: i18n-js
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.1.0
4
+ version: 4.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nando Vieira
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-12-09 00:00:00.000000000 Z
11
+ date: 2022-12-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: glob
@@ -190,7 +190,7 @@ files:
190
190
  - MIGRATING_FROM_V3_TO_V4.md
191
191
  - README.md
192
192
  - Rakefile
193
- - bin/pack
193
+ - bin/release
194
194
  - exe/i18n
195
195
  - i18n-js.gemspec
196
196
  - lib/guard/i18n-js.rb
@@ -208,6 +208,7 @@ files:
208
208
  - lib/i18n-js/cli/ui.rb
209
209
  - lib/i18n-js/cli/version_command.rb
210
210
  - lib/i18n-js/embed_fallback_translations_plugin.rb
211
+ - lib/i18n-js/export_files_plugin.rb
211
212
  - lib/i18n-js/lint.js
212
213
  - lib/i18n-js/lint.ts
213
214
  - lib/i18n-js/listen.rb
@@ -222,10 +223,10 @@ metadata:
222
223
  rubygems_mfa_required: 'true'
223
224
  homepage_uri: https://github.com/fnando/i18n-js
224
225
  bug_tracker_uri: https://github.com/fnando/i18n-js/issues
225
- source_code_uri: https://github.com/fnando/i18n-js/tree/v4.1.0
226
- changelog_uri: https://github.com/fnando/i18n-js/tree/v4.1.0/CHANGELOG.md
227
- documentation_uri: https://github.com/fnando/i18n-js/tree/v4.1.0/README.md
228
- license_uri: https://github.com/fnando/i18n-js/tree/v4.1.0/LICENSE.md
226
+ source_code_uri: https://github.com/fnando/i18n-js/tree/v4.2.1
227
+ changelog_uri: https://github.com/fnando/i18n-js/tree/v4.2.1/CHANGELOG.md
228
+ documentation_uri: https://github.com/fnando/i18n-js/tree/v4.2.1/README.md
229
+ license_uri: https://github.com/fnando/i18n-js/tree/v4.2.1/LICENSE.md
229
230
  post_install_message:
230
231
  rdoc_options: []
231
232
  require_paths:
@@ -241,7 +242,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
241
242
  - !ruby/object:Gem::Version
242
243
  version: '0'
243
244
  requirements: []
244
- rubygems_version: 3.3.26
245
+ rubygems_version: 3.3.7
245
246
  signing_key:
246
247
  specification_version: 4
247
248
  summary: Export i18n translations and use them on JavaScript.