i18n-js 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "schema"
4
+
5
+ module I18nJS
6
+ def self.plugins
7
+ @plugins ||= []
8
+ end
9
+
10
+ def self.register_plugin(plugin)
11
+ plugins << plugin
12
+ plugin.setup
13
+ end
14
+
15
+ def self.plugin_files
16
+ Gem.find_files("i18n-js/*_plugin.rb")
17
+ end
18
+
19
+ def self.load_plugins!
20
+ plugin_files.each do |path|
21
+ require path
22
+ end
23
+ end
24
+
25
+ class Plugin
26
+ def self.transform(translations:, config:) # rubocop:disable Lint/UnusedMethodArgument
27
+ translations
28
+ end
29
+
30
+ # Must raise I18nJS::SchemaInvalidError with the error message if schema
31
+ # validation has failed.
32
+ def self.validate_schema(config:)
33
+ end
34
+
35
+ def self.setup
36
+ end
37
+ end
38
+ 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
+ I18nJS.plugins.each {|plugin| plugin.validate_schema(config: target) }
15
29
  end
16
30
 
17
31
  attr_reader :target
@@ -23,20 +37,36 @@ module I18nJS
23
37
  def validate!
24
38
  expect_type(:root, target, Hash, target)
25
39
 
26
- expect_required_keys(REQUIRED_ROOT_KEYS, target)
27
- reject_extraneous_keys(ROOT_KEYS, target)
40
+ expect_required_keys(self.class.required_root_keys, target)
41
+ reject_extraneous_keys(self.class.root_keys, target)
28
42
  validate_translations
29
- validate_check
43
+ validate_lint_translations
44
+ validate_lint_scripts
30
45
  end
31
46
 
32
- def validate_check
33
- return unless target.key?(:check)
47
+ def validate_lint_translations
48
+ key = :lint_translations
34
49
 
35
- check = target[:check]
50
+ return unless target.key?(key)
36
51
 
37
- expect_type(:check, check, Hash, target)
38
- expect_required_keys(REQUIRED_CHECK_KEYS, check)
39
- expect_type(:ignore, check[:ignore], Array, check)
52
+ config = target[key]
53
+
54
+ expect_type(key, config, Hash, target)
55
+ expect_required_keys(REQUIRED_LINT_TRANSLATIONS_KEYS, config)
56
+ expect_type(:ignore, config[:ignore], Array, config)
57
+ end
58
+
59
+ def validate_lint_scripts
60
+ key = :lint_scripts
61
+
62
+ return unless target.key?(key)
63
+
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)
40
70
  end
41
71
 
42
72
  def validate_translations
@@ -63,6 +93,15 @@ module I18nJS
63
93
  raise InvalidError, "#{error_message}#{node_json}"
64
94
  end
65
95
 
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
104
+
66
105
  def expect_type(attribute, value, expected_type, payload)
67
106
  return if value.is_a?(expected_type)
68
107
 
@@ -94,7 +133,7 @@ module I18nJS
94
133
 
95
134
  def reject_extraneous_keys(allowed_keys, value)
96
135
  keys = value.keys.map(&:to_sym)
97
- extraneous = keys - allowed_keys
136
+ extraneous = keys.to_a - allowed_keys.to_a
98
137
 
99
138
  return if extraneous.empty?
100
139
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module I18nJS
4
- VERSION = "4.0.0"
4
+ VERSION = "4.1.0"
5
5
  end
data/lib/i18n-js.rb CHANGED
@@ -6,9 +6,13 @@ require "yaml"
6
6
  require "glob"
7
7
  require "fileutils"
8
8
  require "optparse"
9
+ require "erb"
10
+ require "set"
11
+ require "digest/md5"
9
12
 
10
13
  require_relative "i18n-js/schema"
11
14
  require_relative "i18n-js/version"
15
+ require_relative "i18n-js/plugin"
12
16
 
13
17
  module I18nJS
14
18
  MissingConfigError = Class.new(StandardError)
@@ -19,19 +23,27 @@ module I18nJS
19
23
  "you must set either `config_file` or `config`"
20
24
  end
21
25
 
22
- config = Glob::SymbolizeKeys.call(config || YAML.load_file(config_file))
26
+ load_plugins!
27
+
28
+ config = Glob::SymbolizeKeys.call(config || load_config_file(config_file))
29
+
23
30
  Schema.validate!(config)
24
31
  exported_files = []
25
32
 
26
33
  config[:translations].each do |group|
27
- exported_files += export_group(group)
34
+ exported_files += export_group(group, config)
28
35
  end
29
36
 
30
37
  exported_files
31
38
  end
32
39
 
33
- def self.export_group(group)
40
+ def self.export_group(group, config)
34
41
  filtered_translations = Glob.filter(translations, group[:patterns])
42
+ filtered_translations =
43
+ plugins.reduce(filtered_translations) do |buffer, plugin|
44
+ plugin.transform(translations: buffer, config: config)
45
+ end
46
+
35
47
  output_file_path = File.expand_path(group[:file])
36
48
  exported_files = []
37
49
 
@@ -70,4 +82,9 @@ module I18nJS
70
82
  translations
71
83
  end
72
84
  end
85
+
86
+ def self.load_config_file(config_file)
87
+ erb = ERB.new(File.read(config_file))
88
+ YAML.safe_load(erb.result(binding))
89
+ end
73
90
  end
data/package.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "dependencies": {
3
+ "esbuild": "^0.16.2",
4
+ "glob": "^8.0.3",
5
+ "typescript": "^4.9.4"
6
+ },
7
+ "scripts": {
8
+ "compile": "esbuild lib/i18n-js/lint.ts --bundle --platform=node --outfile=lib/i18n-js/lint.js --target=node16"
9
+ }
10
+ }
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.0.0
4
+ version: 4.1.0
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-07-29 00:00:00.000000000 Z
11
+ date: 2022-12-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: glob
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '0'
19
+ version: 0.4.0
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '0'
26
+ version: 0.4.0
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: i18n
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -80,6 +80,20 @@ dependencies:
80
80
  - - ">="
81
81
  - !ruby/object:Gem::Version
82
82
  version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: mocha
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
83
97
  - !ruby/object:Gem::Dependency
84
98
  name: pry-meta
85
99
  requirement: !ruby/object:Gem::Requirement
@@ -173,12 +187,12 @@ files:
173
187
  - CONTRIBUTING.md
174
188
  - Gemfile
175
189
  - LICENSE.md
190
+ - MIGRATING_FROM_V3_TO_V4.md
176
191
  - README.md
177
192
  - Rakefile
193
+ - bin/pack
178
194
  - exe/i18n
179
195
  - i18n-js.gemspec
180
- - i18njs.png
181
- - images/i18njs-check.gif
182
196
  - lib/guard/i18n-js.rb
183
197
  - lib/guard/i18n-js/templates/Guardfile
184
198
  - lib/guard/i18n-js/version.rb
@@ -188,11 +202,19 @@ files:
188
202
  - lib/i18n-js/cli/command.rb
189
203
  - lib/i18n-js/cli/export_command.rb
190
204
  - lib/i18n-js/cli/init_command.rb
205
+ - lib/i18n-js/cli/lint_scripts_command.rb
206
+ - lib/i18n-js/cli/lint_translations_command.rb
207
+ - lib/i18n-js/cli/plugins_command.rb
191
208
  - lib/i18n-js/cli/ui.rb
192
209
  - lib/i18n-js/cli/version_command.rb
210
+ - lib/i18n-js/embed_fallback_translations_plugin.rb
211
+ - lib/i18n-js/lint.js
212
+ - lib/i18n-js/lint.ts
193
213
  - lib/i18n-js/listen.rb
214
+ - lib/i18n-js/plugin.rb
194
215
  - lib/i18n-js/schema.rb
195
216
  - lib/i18n-js/version.rb
217
+ - package.json
196
218
  homepage: https://github.com/fnando/i18n-js
197
219
  licenses:
198
220
  - MIT
@@ -200,10 +222,10 @@ metadata:
200
222
  rubygems_mfa_required: 'true'
201
223
  homepage_uri: https://github.com/fnando/i18n-js
202
224
  bug_tracker_uri: https://github.com/fnando/i18n-js/issues
203
- source_code_uri: https://github.com/fnando/i18n-js/tree/v4.0.0
204
- changelog_uri: https://github.com/fnando/i18n-js/tree/v4.0.0/CHANGELOG.md
205
- documentation_uri: https://github.com/fnando/i18n-js/tree/v4.0.0/README.md
206
- license_uri: https://github.com/fnando/i18n-js/tree/v4.0.0/LICENSE.md
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
207
229
  post_install_message:
208
230
  rdoc_options: []
209
231
  require_paths:
@@ -219,7 +241,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
219
241
  - !ruby/object:Gem::Version
220
242
  version: '0'
221
243
  requirements: []
222
- rubygems_version: 3.3.7
244
+ rubygems_version: 3.3.26
223
245
  signing_key:
224
246
  specification_version: 4
225
247
  summary: Export i18n translations and use them on JavaScript.
data/i18njs.png DELETED
Binary file
Binary file