i18n-js 3.9.2 → 4.2.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (149) 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 +45 -561
  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 +191 -0
  18. data/README.md +425 -951
  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 +157 -0
  32. data/lib/i18n-js/cli/lint_translations_command.rb +155 -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 +152 -198
  49. data/.editorconfig +0 -24
  50. data/.github/workflows/tests.yaml +0 -106
  51. data/.npmignore +0 -27
  52. data/Appraisals +0 -52
  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_10.gemfile +0 -7
  64. data/gemfiles/i18n_1_2.gemfile +0 -7
  65. data/gemfiles/i18n_1_3.gemfile +0 -7
  66. data/gemfiles/i18n_1_4.gemfile +0 -7
  67. data/gemfiles/i18n_1_5.gemfile +0 -7
  68. data/gemfiles/i18n_1_6.gemfile +0 -7
  69. data/gemfiles/i18n_1_7.gemfile +0 -7
  70. data/gemfiles/i18n_1_8.gemfile +0 -7
  71. data/gemfiles/i18n_1_9.gemfile +0 -7
  72. data/i18njs.png +0 -0
  73. data/lib/i18n/js/dependencies.rb +0 -67
  74. data/lib/i18n/js/engine.rb +0 -87
  75. data/lib/i18n/js/fallback_locales.rb +0 -70
  76. data/lib/i18n/js/formatters/base.rb +0 -25
  77. data/lib/i18n/js/formatters/js.rb +0 -39
  78. data/lib/i18n/js/formatters/json.rb +0 -13
  79. data/lib/i18n/js/middleware.rb +0 -82
  80. data/lib/i18n/js/private/config_store.rb +0 -31
  81. data/lib/i18n/js/private/hash_with_symbol_keys.rb +0 -36
  82. data/lib/i18n/js/segment.rb +0 -81
  83. data/lib/i18n/js/utils.rb +0 -91
  84. data/lib/i18n/js/version.rb +0 -7
  85. data/lib/i18n/js.rb +0 -274
  86. data/lib/rails/generators/i18n/js/config/config_generator.rb +0 -19
  87. data/lib/rails/generators/i18n/js/config/templates/i18n-js.yml +0 -27
  88. data/lib/tasks/export.rake +0 -8
  89. data/spec/fixtures/custom_path.yml +0 -5
  90. data/spec/fixtures/default.yml +0 -5
  91. data/spec/fixtures/erb.yml +0 -5
  92. data/spec/fixtures/except_condition.yml +0 -7
  93. data/spec/fixtures/js_available_locales_custom.yml +0 -1
  94. data/spec/fixtures/js_export_dir_custom.yml +0 -7
  95. data/spec/fixtures/js_export_dir_none.yml +0 -6
  96. data/spec/fixtures/js_extend_parent.yml +0 -6
  97. data/spec/fixtures/js_extend_segment.yml +0 -6
  98. data/spec/fixtures/js_file_per_locale.yml +0 -7
  99. data/spec/fixtures/js_file_per_locale_with_fallbacks_as_default_locale_symbol.yml +0 -4
  100. data/spec/fixtures/js_file_per_locale_with_fallbacks_as_hash.yml +0 -6
  101. data/spec/fixtures/js_file_per_locale_with_fallbacks_as_locale.yml +0 -4
  102. data/spec/fixtures/js_file_per_locale_with_fallbacks_as_locale_without_fallback_translations.yml +0 -4
  103. data/spec/fixtures/js_file_per_locale_with_fallbacks_enabled.yml +0 -4
  104. data/spec/fixtures/js_file_per_locale_without_fallbacks.yml +0 -4
  105. data/spec/fixtures/js_file_with_namespace_prefix_and_pretty_print.yml +0 -9
  106. data/spec/fixtures/js_sort_translation_keys_false.yml +0 -6
  107. data/spec/fixtures/js_sort_translation_keys_true.yml +0 -6
  108. data/spec/fixtures/json_only.yml +0 -18
  109. data/spec/fixtures/locales.yml +0 -133
  110. data/spec/fixtures/merge_plurals.yml +0 -6
  111. data/spec/fixtures/merge_plurals_with_no_overrides.yml +0 -4
  112. data/spec/fixtures/merge_plurals_with_partial_overrides.yml +0 -4
  113. data/spec/fixtures/millions.yml +0 -4
  114. data/spec/fixtures/multiple_conditions.yml +0 -7
  115. data/spec/fixtures/multiple_conditions_per_locale.yml +0 -7
  116. data/spec/fixtures/multiple_files.yml +0 -7
  117. data/spec/fixtures/no_config.yml +0 -2
  118. data/spec/fixtures/no_scope.yml +0 -4
  119. data/spec/fixtures/simple_scope.yml +0 -5
  120. data/spec/js/currency.spec.js +0 -62
  121. data/spec/js/current_locale.spec.js +0 -19
  122. data/spec/js/dates.spec.js +0 -276
  123. data/spec/js/defaults.spec.js +0 -31
  124. data/spec/js/extend.spec.js +0 -110
  125. data/spec/js/interpolation.spec.js +0 -124
  126. data/spec/js/jasmine/MIT.LICENSE +0 -20
  127. data/spec/js/jasmine/jasmine-html.js +0 -190
  128. data/spec/js/jasmine/jasmine.css +0 -166
  129. data/spec/js/jasmine/jasmine.js +0 -2476
  130. data/spec/js/jasmine/jasmine_favicon.png +0 -0
  131. data/spec/js/json_parsable.spec.js +0 -14
  132. data/spec/js/locales.spec.js +0 -31
  133. data/spec/js/localization.spec.js +0 -78
  134. data/spec/js/numbers.spec.js +0 -174
  135. data/spec/js/placeholder.spec.js +0 -24
  136. data/spec/js/pluralization.spec.js +0 -228
  137. data/spec/js/prepare_options.spec.js +0 -41
  138. data/spec/js/require.js +0 -2083
  139. data/spec/js/specs.html +0 -49
  140. data/spec/js/specs_requirejs.html +0 -72
  141. data/spec/js/translate.spec.js +0 -304
  142. data/spec/js/translations.js +0 -188
  143. data/spec/js/utility_functions.spec.js +0 -20
  144. data/spec/ruby/i18n/js/fallback_locales_spec.rb +0 -84
  145. data/spec/ruby/i18n/js/segment_spec.rb +0 -286
  146. data/spec/ruby/i18n/js/utils_spec.rb +0 -138
  147. data/spec/ruby/i18n/js_spec.rb +0 -797
  148. data/spec/spec_helper.rb +0 -80
  149. data/yarn.lock +0 -138
@@ -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.2"
5
+ end