i18n-js 3.8.2 → 4.2.3

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.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. data/.github/CODEOWNERS +4 -0
  3. data/.github/FUNDING.yml +1 -1
  4. data/.github/ISSUE_TEMPLATE/bug_report.md +41 -0
  5. data/.github/ISSUE_TEMPLATE/config.yml +5 -0
  6. data/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
  7. data/.github/PULL_REQUEST_TEMPLATE.md +38 -0
  8. data/.github/dependabot.yml +15 -0
  9. data/.github/workflows/ruby-tests.yml +73 -0
  10. data/.gitignore +13 -7
  11. data/.rubocop.yml +19 -0
  12. data/CHANGELOG.md +47 -512
  13. data/CODE_OF_CONDUCT.md +74 -0
  14. data/CONTRIBUTING.md +79 -0
  15. data/Gemfile +3 -0
  16. data/LICENSE.md +20 -0
  17. data/MIGRATING_FROM_V3_TO_V4.md +192 -0
  18. data/README.md +425 -921
  19. data/Rakefile +10 -20
  20. data/bin/release +81 -0
  21. data/exe/i18n +5 -0
  22. data/i18n-js.gemspec +51 -29
  23. data/lib/guard/i18n-js/templates/Guardfile +10 -0
  24. data/lib/guard/i18n-js/version.rb +13 -0
  25. data/lib/guard/i18n-js.rb +95 -0
  26. data/lib/i18n-js/clean_hash.rb +13 -0
  27. data/lib/i18n-js/cli/check_command.rb +17 -0
  28. data/lib/i18n-js/cli/command.rb +79 -0
  29. data/lib/i18n-js/cli/export_command.rb +95 -0
  30. data/lib/i18n-js/cli/init_command.rb +52 -0
  31. data/lib/i18n-js/cli/lint_scripts_command.rb +159 -0
  32. data/lib/i18n-js/cli/lint_translations_command.rb +157 -0
  33. data/lib/i18n-js/cli/plugins_command.rb +67 -0
  34. data/lib/i18n-js/cli/ui.rb +64 -0
  35. data/lib/i18n-js/cli/version_command.rb +18 -0
  36. data/lib/i18n-js/cli.rb +66 -0
  37. data/lib/i18n-js/embed_fallback_translations_plugin.rb +70 -0
  38. data/lib/i18n-js/export_files_plugin.rb +103 -0
  39. data/lib/i18n-js/lint.js +150645 -0
  40. data/lib/i18n-js/lint.ts +196 -0
  41. data/lib/i18n-js/listen.rb +96 -0
  42. data/lib/i18n-js/plugin.rb +103 -0
  43. data/lib/i18n-js/schema.rb +216 -0
  44. data/lib/i18n-js/sort_hash.rb +12 -0
  45. data/lib/i18n-js/version.rb +5 -0
  46. data/lib/i18n-js.rb +107 -1
  47. data/package.json +5 -20
  48. metadata +149 -189
  49. data/.editorconfig +0 -24
  50. data/.github/workflows/tests.yaml +0 -100
  51. data/.npmignore +0 -27
  52. data/Appraisals +0 -44
  53. data/app/assets/javascripts/i18n/filtered.js.erb +0 -23
  54. data/app/assets/javascripts/i18n/shims.js +0 -240
  55. data/app/assets/javascripts/i18n/translations.js +0 -3
  56. data/app/assets/javascripts/i18n.js +0 -1095
  57. data/gemfiles/i18n_0_6.gemfile +0 -7
  58. data/gemfiles/i18n_0_7.gemfile +0 -7
  59. data/gemfiles/i18n_0_8.gemfile +0 -7
  60. data/gemfiles/i18n_0_9.gemfile +0 -7
  61. data/gemfiles/i18n_1_0.gemfile +0 -7
  62. data/gemfiles/i18n_1_1.gemfile +0 -7
  63. data/gemfiles/i18n_1_2.gemfile +0 -7
  64. data/gemfiles/i18n_1_3.gemfile +0 -7
  65. data/gemfiles/i18n_1_4.gemfile +0 -7
  66. data/gemfiles/i18n_1_5.gemfile +0 -7
  67. data/gemfiles/i18n_1_6.gemfile +0 -7
  68. data/gemfiles/i18n_1_7.gemfile +0 -7
  69. data/gemfiles/i18n_1_8.gemfile +0 -7
  70. data/i18njs.png +0 -0
  71. data/lib/i18n/js/dependencies.rb +0 -63
  72. data/lib/i18n/js/engine.rb +0 -87
  73. data/lib/i18n/js/fallback_locales.rb +0 -70
  74. data/lib/i18n/js/formatters/base.rb +0 -25
  75. data/lib/i18n/js/formatters/js.rb +0 -32
  76. data/lib/i18n/js/formatters/json.rb +0 -13
  77. data/lib/i18n/js/middleware.rb +0 -82
  78. data/lib/i18n/js/private/config_store.rb +0 -31
  79. data/lib/i18n/js/private/hash_with_symbol_keys.rb +0 -36
  80. data/lib/i18n/js/segment.rb +0 -80
  81. data/lib/i18n/js/utils.rb +0 -78
  82. data/lib/i18n/js/version.rb +0 -7
  83. data/lib/i18n/js.rb +0 -264
  84. data/lib/rails/generators/i18n/js/config/config_generator.rb +0 -19
  85. data/lib/rails/generators/i18n/js/config/templates/i18n-js.yml +0 -27
  86. data/lib/tasks/export.rake +0 -8
  87. data/spec/fixtures/custom_path.yml +0 -5
  88. data/spec/fixtures/default.yml +0 -5
  89. data/spec/fixtures/erb.yml +0 -5
  90. data/spec/fixtures/except_condition.yml +0 -7
  91. data/spec/fixtures/js_export_dir_custom.yml +0 -7
  92. data/spec/fixtures/js_export_dir_none.yml +0 -6
  93. data/spec/fixtures/js_extend_parent.yml +0 -6
  94. data/spec/fixtures/js_extend_segment.yml +0 -6
  95. data/spec/fixtures/js_file_per_locale.yml +0 -7
  96. data/spec/fixtures/js_file_per_locale_with_fallbacks_as_default_locale_symbol.yml +0 -4
  97. data/spec/fixtures/js_file_per_locale_with_fallbacks_as_hash.yml +0 -6
  98. data/spec/fixtures/js_file_per_locale_with_fallbacks_as_locale.yml +0 -4
  99. data/spec/fixtures/js_file_per_locale_with_fallbacks_as_locale_without_fallback_translations.yml +0 -4
  100. data/spec/fixtures/js_file_per_locale_with_fallbacks_enabled.yml +0 -4
  101. data/spec/fixtures/js_file_per_locale_without_fallbacks.yml +0 -4
  102. data/spec/fixtures/js_file_with_namespace_prefix_and_pretty_print.yml +0 -9
  103. data/spec/fixtures/js_sort_translation_keys_false.yml +0 -6
  104. data/spec/fixtures/js_sort_translation_keys_true.yml +0 -6
  105. data/spec/fixtures/json_only.yml +0 -18
  106. data/spec/fixtures/locales.yml +0 -133
  107. data/spec/fixtures/merge_plurals.yml +0 -6
  108. data/spec/fixtures/merge_plurals_with_no_overrides.yml +0 -4
  109. data/spec/fixtures/merge_plurals_with_partial_overrides.yml +0 -4
  110. data/spec/fixtures/millions.yml +0 -4
  111. data/spec/fixtures/multiple_conditions.yml +0 -7
  112. data/spec/fixtures/multiple_conditions_per_locale.yml +0 -7
  113. data/spec/fixtures/multiple_files.yml +0 -7
  114. data/spec/fixtures/no_config.yml +0 -2
  115. data/spec/fixtures/no_scope.yml +0 -4
  116. data/spec/fixtures/simple_scope.yml +0 -5
  117. data/spec/js/currency.spec.js +0 -62
  118. data/spec/js/current_locale.spec.js +0 -19
  119. data/spec/js/dates.spec.js +0 -276
  120. data/spec/js/defaults.spec.js +0 -31
  121. data/spec/js/extend.spec.js +0 -110
  122. data/spec/js/interpolation.spec.js +0 -124
  123. data/spec/js/jasmine/MIT.LICENSE +0 -20
  124. data/spec/js/jasmine/jasmine-html.js +0 -190
  125. data/spec/js/jasmine/jasmine.css +0 -166
  126. data/spec/js/jasmine/jasmine.js +0 -2476
  127. data/spec/js/jasmine/jasmine_favicon.png +0 -0
  128. data/spec/js/locales.spec.js +0 -31
  129. data/spec/js/localization.spec.js +0 -78
  130. data/spec/js/numbers.spec.js +0 -174
  131. data/spec/js/placeholder.spec.js +0 -24
  132. data/spec/js/pluralization.spec.js +0 -219
  133. data/spec/js/prepare_options.spec.js +0 -41
  134. data/spec/js/require.js +0 -2083
  135. data/spec/js/specs.html +0 -49
  136. data/spec/js/specs_requirejs.html +0 -72
  137. data/spec/js/translate.spec.js +0 -304
  138. data/spec/js/translations.js +0 -188
  139. data/spec/js/utility_functions.spec.js +0 -20
  140. data/spec/ruby/i18n/js/fallback_locales_spec.rb +0 -84
  141. data/spec/ruby/i18n/js/segment_spec.rb +0 -219
  142. data/spec/ruby/i18n/js/utils_spec.rb +0 -106
  143. data/spec/ruby/i18n/js_spec.rb +0 -748
  144. data/spec/spec_helper.rb +0 -80
  145. data/yarn.lock +0 -131
@@ -0,0 +1,196 @@
1
+ import { readFileSync, statSync } from "fs";
2
+ import * as ts from "typescript";
3
+ import { glob } from "glob";
4
+
5
+ type ScopeInfo = {
6
+ type: "default" | "scope" | "base";
7
+ location: string;
8
+ base: string | null;
9
+ full: string;
10
+ scope: string;
11
+ };
12
+
13
+ function location(node: ts.Node, append: string = ":") {
14
+ const sourceFile = node.getSourceFile();
15
+ let { line, character } = sourceFile.getLineAndCharacterOfPosition(
16
+ node.getStart(sourceFile)
17
+ );
18
+
19
+ line += 1;
20
+ character += 1;
21
+ const file = sourceFile.fileName;
22
+ const location = `${file}:${line}:${character}`;
23
+
24
+ return `${location}${append}`;
25
+ }
26
+
27
+ const callExpressions = ["t", "i18n.t", "i18n.translate"];
28
+
29
+ function tsKind(node: ts.Node) {
30
+ const keys = Object.keys(ts.SyntaxKind);
31
+ const values = Object.values(ts.SyntaxKind);
32
+
33
+ return keys[values.indexOf(node.kind)];
34
+ }
35
+
36
+ function getTranslationScopesFromFile(filePath: string) {
37
+ const scopes: ScopeInfo[] = [];
38
+
39
+ const sourceFile = ts.createSourceFile(
40
+ filePath,
41
+ readFileSync(filePath).toString(),
42
+ ts.ScriptTarget.ES2015,
43
+ true
44
+ );
45
+
46
+ inspect(sourceFile);
47
+
48
+ return scopes;
49
+
50
+ function inspect(node: ts.Node) {
51
+ const next = () => {
52
+ ts.forEachChild(node, inspect);
53
+ };
54
+
55
+ if (node.kind !== ts.SyntaxKind.CallExpression) {
56
+ return next();
57
+ }
58
+
59
+ const expr = node.getChildAt(0).getText();
60
+ const text = JSON.stringify(node.getText(sourceFile));
61
+
62
+ if (!callExpressions.includes(expr)) {
63
+ return next();
64
+ }
65
+
66
+ const syntaxList = node.getChildAt(2);
67
+
68
+ if (!syntaxList.getText().trim()) {
69
+ return next();
70
+ }
71
+
72
+ const scopeNode = syntaxList.getChildAt(0) as ts.StringLiteral;
73
+ const optionsNode = syntaxList.getChildAt(2) as ts.ObjectLiteralExpression;
74
+
75
+ if (scopeNode.kind !== ts.SyntaxKind.StringLiteral) {
76
+ return next();
77
+ }
78
+
79
+ if (
80
+ optionsNode &&
81
+ optionsNode.kind !== ts.SyntaxKind.ObjectLiteralExpression
82
+ ) {
83
+ return next();
84
+ }
85
+
86
+ if (!optionsNode) {
87
+ scopes.push({
88
+ type: "scope",
89
+ scope: scopeNode.text,
90
+ base: null,
91
+ full: scopeNode.text,
92
+ location: location(node, ""),
93
+ });
94
+ return next();
95
+ }
96
+
97
+ scopes.push(...getScopes(scopeNode, optionsNode));
98
+ }
99
+
100
+ function mapProperties(node: ts.ObjectLiteralExpression): {
101
+ name: string;
102
+ value: ts.Node;
103
+ }[] {
104
+ return node.properties.map((p) => ({
105
+ name: (p.name as ts.Identifier).escapedText.toString(),
106
+ value: p.getChildAt(2),
107
+ }));
108
+ }
109
+
110
+ function getScopes(
111
+ scopeNode: ts.StringLiteral,
112
+ node: ts.ObjectLiteralExpression
113
+ ): ScopeInfo[] {
114
+ const suffix = scopeNode.text;
115
+
116
+ const result: ScopeInfo[] = [];
117
+ const properties = mapProperties(node);
118
+
119
+ if (
120
+ properties.length === 0 ||
121
+ !properties.some((p) => p.name === "scope")
122
+ ) {
123
+ result.push({
124
+ type: "scope",
125
+ scope: suffix,
126
+ base: null,
127
+ full: suffix,
128
+ location: location(scopeNode, ""),
129
+ });
130
+ }
131
+
132
+ properties.forEach((property) => {
133
+ if (
134
+ property.name === "scope" &&
135
+ property.value.kind === ts.SyntaxKind.StringLiteral
136
+ ) {
137
+ const base = (property.value as ts.StringLiteral).text;
138
+
139
+ result.push({
140
+ type: "base",
141
+ scope: suffix,
142
+ base,
143
+ full: `${base}.${suffix}`,
144
+ location: location(scopeNode, ""),
145
+ });
146
+ }
147
+
148
+ if (
149
+ property.name === "defaults" &&
150
+ property.value.kind === ts.SyntaxKind.ArrayLiteralExpression
151
+ ) {
152
+ const op = property.value as ts.ArrayLiteralExpression;
153
+ const values = op.getChildAt(1);
154
+ const objects = (
155
+ values
156
+ .getChildren()
157
+ .filter(
158
+ (n) => n.kind === ts.SyntaxKind.ObjectLiteralExpression
159
+ ) as ts.ObjectLiteralExpression[]
160
+ ).map(mapProperties);
161
+
162
+ objects.forEach((object) => {
163
+ object.forEach((prop) => {
164
+ if (
165
+ prop.name === "scope" &&
166
+ prop.value.kind === ts.SyntaxKind.StringLiteral
167
+ ) {
168
+ const text = (prop.value as ts.StringLiteral).text;
169
+
170
+ result.push({
171
+ type: "default",
172
+ scope: text,
173
+ base: null,
174
+ full: text,
175
+ location: location(prop.value, ""),
176
+ });
177
+ }
178
+ });
179
+ });
180
+ }
181
+ });
182
+
183
+ return result;
184
+ }
185
+ }
186
+
187
+ const patterns = (
188
+ process.argv[2] ??
189
+ "!(node_modules)/**/*.js:!(node_modules)/**/*.ts:!(node_modules)/**/*.jsx:!(node_modules)/**/*.tsx"
190
+ ).split(":");
191
+ const files = patterns.flatMap((pattern) => glob.sync(pattern));
192
+ const scopes = files
193
+ .filter((filePath) => statSync(filePath).isFile())
194
+ .flatMap((path) => getTranslationScopesFromFile(path));
195
+
196
+ console.log(JSON.stringify(scopes, null, 2));
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nJS
4
+ class << self
5
+ attr_accessor :started
6
+ end
7
+
8
+ def self.listen(
9
+ config_file: Rails.root.join("config/i18n.yml"),
10
+ locales_dir: Rails.root.join("config/locales"),
11
+ run_on_start: true,
12
+ options: {}
13
+ )
14
+ return unless Rails.env.development?
15
+ return if started
16
+
17
+ gem "listen"
18
+ require "listen"
19
+ require "i18n-js"
20
+
21
+ self.started = true
22
+
23
+ locales_dirs = Array(locales_dir).map {|path| File.expand_path(path) }
24
+
25
+ relative_paths =
26
+ [config_file, *locales_dirs].map {|path| relative_path(path) }
27
+
28
+ debug("Watching #{relative_paths.inspect}")
29
+
30
+ listener(config_file, locales_dirs.map(&:to_s), options).start
31
+ I18nJS.call(config_file: config_file) if run_on_start
32
+ end
33
+
34
+ def self.relative_path(path)
35
+ Pathname.new(path).relative_path_from(Rails.root).to_s
36
+ end
37
+
38
+ def self.relative_path_list(paths)
39
+ paths.map {|path| relative_path(path) }
40
+ end
41
+
42
+ def self.debug(message)
43
+ logger.tagged("i18n-js") { logger.debug(message) }
44
+ end
45
+
46
+ def self.logger
47
+ @logger ||= ActiveSupport::TaggedLogging.new(Rails.logger)
48
+ end
49
+
50
+ def self.listener(config_file, locales_dirs, options)
51
+ paths = [File.dirname(config_file), *locales_dirs]
52
+
53
+ Listen.to(*paths, options) do |changed, added, removed|
54
+ changes = compute_changes(
55
+ [config_file, *locales_dirs],
56
+ changed,
57
+ added,
58
+ removed
59
+ )
60
+
61
+ next unless changes.any?
62
+
63
+ debug(changes.map {|key, value| "#{key}=#{value.inspect}" }.join(", "))
64
+
65
+ capture do
66
+ system "i18n", "export", "--config", config_file.to_s
67
+ end
68
+ end
69
+ end
70
+
71
+ def self.capture
72
+ original = $stdout
73
+ $stdout = StringIO.new
74
+ yield
75
+ rescue StandardError
76
+ # noop
77
+ ensure
78
+ $stdout = original
79
+ end
80
+
81
+ def self.compute_changes(paths, changed, added, removed)
82
+ paths = paths.map {|path| relative_path(path) }
83
+
84
+ {
85
+ changed: included_on_watched_paths(paths, changed),
86
+ added: included_on_watched_paths(paths, added),
87
+ removed: included_on_watched_paths(paths, removed)
88
+ }.select {|_k, v| v.any? }
89
+ end
90
+
91
+ def self.included_on_watched_paths(paths, changes)
92
+ changes.map {|change| relative_path(change) }.select do |change|
93
+ paths.any? {|path| change.start_with?(path) }
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "schema"
4
+
5
+ module I18nJS
6
+ def self.available_plugins
7
+ @available_plugins ||= Set.new
8
+ end
9
+
10
+ def self.plugins
11
+ @plugins ||= []
12
+ end
13
+
14
+ def self.register_plugin(plugin)
15
+ available_plugins << plugin
16
+ end
17
+
18
+ def self.plugin_files
19
+ Gem.find_files("i18n-js/*_plugin.rb")
20
+ end
21
+
22
+ def self.load_plugins!
23
+ plugin_files.each do |path|
24
+ require path
25
+ end
26
+ end
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
+
34
+ class Plugin
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:)
75
+ translations
76
+ end
77
+
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
93
+ end
94
+
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:)
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nJS
4
+ class Schema
5
+ InvalidError = Class.new(StandardError)
6
+
7
+ REQUIRED_LINT_TRANSLATIONS_KEYS = %i[ignore].freeze
8
+ REQUIRED_LINT_SCRIPTS_KEYS = %i[ignore patterns].freeze
9
+ REQUIRED_TRANSLATION_KEYS = %i[file patterns].freeze
10
+ TRANSLATION_KEYS = %i[file patterns].freeze
11
+
12
+ def self.root_keys
13
+ @root_keys ||= Set.new(%i[
14
+ translations
15
+ lint_translations
16
+ lint_scripts
17
+ check
18
+ ])
19
+ end
20
+
21
+ def self.required_root_keys
22
+ @required_root_keys ||= Set.new(%i[translations])
23
+ end
24
+
25
+ def self.validate!(target)
26
+ schema = new(target)
27
+ schema.validate!
28
+ schema
29
+ end
30
+
31
+ attr_reader :target
32
+
33
+ def initialize(target)
34
+ @target = target
35
+ end
36
+
37
+ def validate!
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
+ )
49
+
50
+ validate_translations
51
+ validate_lint_translations
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
76
+ end
77
+
78
+ def validate_lint_translations
79
+ key = :lint_translations
80
+
81
+ return unless target.key?(key)
82
+
83
+ expect_type(path: [key], types: Hash)
84
+
85
+ expect_required_keys(
86
+ keys: REQUIRED_LINT_TRANSLATIONS_KEYS,
87
+ path: [key]
88
+ )
89
+
90
+ expect_type(path: [key, :ignore], types: Array)
91
+ end
92
+
93
+ def validate_lint_scripts
94
+ key = :lint_scripts
95
+
96
+ return unless target.key?(key)
97
+
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)
105
+ end
106
+
107
+ def validate_translations
108
+ expect_array_with_items(path: [:translations])
109
+
110
+ target[:translations].each_with_index do |translation, index|
111
+ validate_translation(translation, index)
112
+ end
113
+ end
114
+
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])
128
+ end
129
+
130
+ def reject(error_message, node = nil)
131
+ node_json = "\n#{JSON.pretty_generate(node)}" if node
132
+ raise InvalidError, "#{error_message}#{node_json}"
133
+ end
134
+
135
+ def expect_type(path:, types:)
136
+ path = prepare_path(path: path)
137
+ value = value_for(path: path)
138
+ types = Array(types)
139
+
140
+ return if types.any? {|type| value.is_a?(type) }
141
+
142
+ actual_type = value.class
143
+
144
+ type_desc = if types.size == 1
145
+ types[0].to_s.inspect
146
+ else
147
+ "one of #{types.inspect}"
148
+ end
149
+
150
+ message = [
151
+ "Expected #{path.join('.').inspect} to be #{type_desc};",
152
+ "got #{actual_type} instead"
153
+ ].join(" ")
154
+
155
+ reject message, target
156
+ end
157
+
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
+
164
+ return unless value.empty?
165
+
166
+ reject "Expected #{path.join('.').inspect} to have at least one item",
167
+ target
168
+ end
169
+
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)
174
+
175
+ keys.each do |key|
176
+ next if actual_keys.include?(key)
177
+
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
185
+ end
186
+ end
187
+
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
194
+
195
+ return if extraneous.empty?
196
+
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)
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nJS
4
+ def self.sort_hash(hash)
5
+ return hash unless hash.is_a?(Hash)
6
+
7
+ hash.keys.sort_by(&:to_s).each_with_object({}) do |key, seed|
8
+ value = hash[key]
9
+ seed[key] = value.is_a?(Hash) ? sort_hash(value) : value
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nJS
4
+ VERSION = "4.2.3"
5
+ end