i18n-js 4.0.0 → 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.
@@ -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));
@@ -7,7 +7,9 @@ module I18nJS
7
7
 
8
8
  def self.listen(
9
9
  config_file: Rails.root.join("config/i18n.yml"),
10
- locales_dir: Rails.root.join("config/locales")
10
+ locales_dir: Rails.root.join("config/locales"),
11
+ run_on_start: true,
12
+ options: {}
11
13
  )
12
14
  return unless Rails.env.development?
13
15
  return if started
@@ -18,13 +20,15 @@ module I18nJS
18
20
 
19
21
  self.started = true
20
22
 
23
+ locales_dirs = Array(locales_dir).map {|path| File.expand_path(path) }
24
+
21
25
  relative_paths =
22
- [config_file, locales_dir].map {|path| relative_path(path) }
26
+ [config_file, *locales_dirs].map {|path| relative_path(path) }
23
27
 
24
28
  debug("Watching #{relative_paths.inspect}")
25
29
 
26
- listener(config_file, locales_dir.to_s).start
27
- I18nJS.call(config_file: config_file)
30
+ listener(config_file, locales_dirs.map(&:to_s), options).start
31
+ I18nJS.call(config_file: config_file) if run_on_start
28
32
  end
29
33
 
30
34
  def self.relative_path(path)
@@ -43,12 +47,12 @@ module I18nJS
43
47
  @logger ||= ActiveSupport::TaggedLogging.new(Rails.logger)
44
48
  end
45
49
 
46
- def self.listener(config_file, locales_dir)
47
- paths = [File.dirname(config_file), locales_dir]
50
+ def self.listener(config_file, locales_dirs, options)
51
+ paths = [File.dirname(config_file), *locales_dirs]
48
52
 
49
- Listen.to(*paths) do |changed, added, removed|
53
+ Listen.to(*paths, options) do |changed, added, removed|
50
54
  changes = compute_changes(
51
- [config_file, locales_dir],
55
+ [config_file, *locales_dirs],
52
56
  changed,
53
57
  added,
54
58
  removed
@@ -58,11 +62,22 @@ module I18nJS
58
62
 
59
63
  debug(changes.map {|key, value| "#{key}=#{value.inspect}" }.join(", "))
60
64
 
61
- ::I18n.backend.reload!
62
- I18nJS.call(config_file: config_file)
65
+ capture do
66
+ system "i18n", "export", "--config", config_file.to_s
67
+ end
63
68
  end
64
69
  end
65
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
+
66
81
  def self.compute_changes(paths, changed, added, removed)
67
82
  paths = paths.map {|path| relative_path(path) }
68
83
 
@@ -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
@@ -4,14 +4,28 @@ module I18nJS
4
4
  class Schema
5
5
  InvalidError = Class.new(StandardError)
6
6
 
7
- ROOT_KEYS = %i[translations check].freeze
8
- REQUIRED_ROOT_KEYS = %i[translations].freeze
9
- REQUIRED_CHECK_KEYS = %i[ignore].freeze
7
+ REQUIRED_LINT_TRANSLATIONS_KEYS = %i[ignore].freeze
8
+ REQUIRED_LINT_SCRIPTS_KEYS = %i[ignore patterns].freeze
10
9
  REQUIRED_TRANSLATION_KEYS = %i[file patterns].freeze
11
10
  TRANSLATION_KEYS = %i[file patterns].freeze
12
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
+
13
25
  def self.validate!(target)
14
- new(target).validate!
26
+ schema = new(target)
27
+ schema.validate!
28
+ schema
15
29
  end
16
30
 
17
31
  attr_reader :target
@@ -21,41 +35,96 @@ module I18nJS
21
35
  end
22
36
 
23
37
  def validate!
24
- 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
+ )
25
49
 
26
- expect_required_keys(REQUIRED_ROOT_KEYS, target)
27
- reject_extraneous_keys(ROOT_KEYS, target)
28
50
  validate_translations
29
- validate_check
51
+ validate_lint_translations
52
+ validate_lint_scripts
53
+ validate_plugins
30
54
  end
31
55
 
32
- def validate_check
33
- return unless target.key?(:check)
56
+ def validate_plugins
57
+ I18nJS.plugins.each do |plugin|
58
+ next unless target.key?(plugin.config_key)
34
59
 
35
- check = target[:check]
60
+ expect_type(
61
+ path: [plugin.config_key, :enabled],
62
+ types: [TrueClass, FalseClass]
63
+ )
36
64
 
37
- expect_type(:check, check, Hash, target)
38
- expect_required_keys(REQUIRED_CHECK_KEYS, check)
39
- expect_type(:ignore, check[:ignore], Array, check)
65
+ plugin.validate_schema
66
+ end
40
67
  end
41
68
 
42
- def validate_translations
43
- translations = target[:translations]
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
44
95
 
45
- expect_type(:translations, translations, Array, target)
46
- expect_array_with_items(:translations, translations)
96
+ return unless target.key?(key)
47
97
 
48
- translations.each do |translation|
49
- validate_translation(translation)
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)
50
112
  end
51
113
  end
52
114
 
53
- def validate_translation(translation)
54
- expect_required_keys(REQUIRED_TRANSLATION_KEYS, translation)
55
- reject_extraneous_keys(TRANSLATION_KEYS, translation)
56
- expect_type(:file, translation[:file], String, translation)
57
- expect_type(:patterns, translation[:patterns], Array, translation)
58
- 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])
59
128
  end
60
129
 
61
130
  def reject(error_message, node = nil)
@@ -63,42 +132,85 @@ module I18nJS
63
132
  raise InvalidError, "#{error_message}#{node_json}"
64
133
  end
65
134
 
66
- def expect_type(attribute, value, expected_type, payload)
67
- return if value.is_a?(expected_type)
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) }
68
141
 
69
142
  actual_type = value.class
70
143
 
144
+ type_desc = if types.size == 1
145
+ types[0].to_s.inspect
146
+ else
147
+ "one of #{types.inspect}"
148
+ end
149
+
71
150
  message = [
72
- "Expected #{attribute.inspect} to be #{expected_type};",
151
+ "Expected #{path.join('.').inspect} to be #{type_desc};",
73
152
  "got #{actual_type} instead"
74
153
  ].join(" ")
75
154
 
76
- reject message, payload
155
+ reject message, target
77
156
  end
78
157
 
79
- 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
+
80
164
  return unless value.empty?
81
165
 
82
- 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
83
168
  end
84
169
 
85
- def expect_required_keys(required_keys, value)
86
- 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)
174
+
175
+ keys.each do |key|
176
+ next if actual_keys.include?(key)
87
177
 
88
- required_keys.each do |key|
89
- next if keys.include?(key)
178
+ path_desc = if path.empty?
179
+ key.to_s.inspect
180
+ else
181
+ (path + [key]).join(".").inspect
182
+ end
90
183
 
91
- reject "Expected #{key.inspect} to be defined", value
184
+ reject "Expected #{path_desc} to be defined", target
92
185
  end
93
186
  end
94
187
 
95
- def reject_extraneous_keys(allowed_keys, value)
96
- keys = value.keys.map(&:to_sym)
97
- extraneous = keys - allowed_keys
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
98
194
 
99
195
  return if extraneous.empty?
100
196
 
101
- 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)
102
214
  end
103
215
  end
104
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module I18nJS
4
- VERSION = "4.0.0"
4
+ VERSION = "4.2.3"
5
5
  end