i18n-js 4.0.0 → 4.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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