.
10 | # This method is used to handle I18n fallbacks. Given two equivalent path
11 | # nodes in two locale trees:
12 | # 1. If the node in the current locale appears to be an I18n pluralization
13 | # (:one, :other, etc.), use the node, but merge in any missing/non-nil
14 | # keys from the fallback (default) locale.
15 | # 2. Else if both nodes are Hashes, combine (merge) the key-value pairs of
16 | # the two nodes into one, prioritizing the current locale.
17 | # 3. Else if either node is nil, use the other node.
18 |
19 | PLURAL_KEYS = %i[zero one two few many other].freeze
20 | PLURAL_MERGER = proc {|_key, v1, v2| v1 || v2 }
21 | MERGER = proc do |_key, v1, v2|
22 | if v1.is_a?(Hash) && v2.is_a?(Hash)
23 | if (v2.keys - PLURAL_KEYS).empty?
24 | v2.merge(v1, &PLURAL_MERGER).slice(*v2.keys)
25 | else
26 | v1.merge(v2, &MERGER)
27 | end
28 | else
29 | v2 || v1
30 | end
31 | end
32 |
33 | def self.deep_merge(target_hash, hash)
34 | target_hash.merge(hash, &MERGER)
35 | end
36 | end
37 |
38 | def setup
39 | I18nJS::Schema.root_keys << config_key
40 | end
41 |
42 | def validate_schema
43 | valid_keys = %i[enabled]
44 |
45 | schema.expect_required_keys(keys: valid_keys, path: [config_key])
46 | schema.reject_extraneous_keys(keys: valid_keys, path: [config_key])
47 | end
48 |
49 | def transform(translations:)
50 | return translations unless enabled?
51 |
52 | fallback_locale = I18n.default_locale.to_sym
53 | locales_to_fallback = translations.keys - [fallback_locale]
54 |
55 | translations_with_fallback = {}
56 | translations_with_fallback[fallback_locale] =
57 | translations[fallback_locale]
58 |
59 | locales_to_fallback.each do |locale|
60 | translations_with_fallback[locale] = Utils.deep_merge(
61 | translations[fallback_locale], translations[locale]
62 | )
63 | end
64 |
65 | translations_with_fallback
66 | end
67 | end
68 |
69 | I18nJS.register_plugin(EmbedFallbackTranslationsPlugin)
70 | end
71 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 |
13 |
14 | ## v4.2.3 - Mar 29, 2023
15 |
16 | - [Fixed] Load plugins when running `i18n lint:*` commands.
17 |
18 | ## v4.2.2 - Dec 30, 2022
19 |
20 | - [Changed] Do not re-export files whose contents haven't changed.
21 | - [Changed] Translations will always be deep sorted.
22 | - [Fixed] Remove procs from translations before exporting files.
23 |
24 | ## v4.2.1 - Dec 25, 2022
25 |
26 | - [Changed] Change plugin api to be based on instance methods. This avoids
27 | having to pass in the config for each and every method. It also allows us
28 | adding helper methods to the base class.
29 | - [Fixed] Fix performance issues with embed fallback translations' initial
30 | implementation.
31 |
32 | ## v4.2.0 - Dec 10, 2022
33 |
34 | - [Added] Add `I18nJS::Plugin.after_export(files:, config:)` method, that's
35 | called whenever whenever I18nJS finishes exporting files. You can use it to
36 | further process files, or generate new files based on the exported files.
37 | - [Added] Bult-in plugin `I18nJS::ExportFilesPlugin`, which allows exporting
38 | files out of the translations file by using a custom template.
39 |
40 | ## v4.1.0 - Dec 09, 2022
41 |
42 | - [Added] Parse configuration files as erb.
43 | - [Changed] `I18n.listen(run_on_start:)` was added to control if files should be
44 | exported during `I18n.listen`'s boot. The default value is `true`.
45 | - [Added] Now it's possible to transform translations before exporting them
46 | using a stable plugin api.
47 | - [Added] Built-in plugin `I18nJS::EmbedFallbackTranslationsPlugin`, which
48 | allows embedding missing translations on exported files.
49 | - [Deprecated] The `i18n check` has been deprecated. Use
50 | `i18n lint:translations` instead.
51 | - [Added] Use `i18n lint:scripts` to lint JavaScript/TypeScript.
52 | - [Fixed] Expand paths passed to `I18nJS.listen(locales_dir:)`.
53 |
54 | ## v4.0.1 - Aug 25, 2022
55 |
56 | - [Fixed] Shell out export to avoid handling I18n reloading heuristics.
57 | - [Changed] `I18nJS.listen` now accepts a directories list to watch.
58 | - [Changed] `I18nJS.listen` now accepts
59 | [listen](https://rubygems.org/gems/listen) options via `:options`.
60 |
61 | ## v4.0.0 - Jul 29, 2022
62 |
63 | - Official release of i18n-js v4.0.0.
64 |
--------------------------------------------------------------------------------
/lib/i18n-js/listen.rb:
--------------------------------------------------------------------------------
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:) 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
97 |
--------------------------------------------------------------------------------
/lib/i18n-js/export_files_plugin.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module I18nJS
4 | require "i18n-js/plugin"
5 |
6 | class ExportFilesPlugin < I18nJS::Plugin
7 | def setup
8 | I18nJS::Schema.root_keys << config_key
9 | end
10 |
11 | def validate_schema
12 | valid_keys = %i[enabled files]
13 |
14 | schema.expect_required_keys(keys: valid_keys, path: [config_key])
15 | schema.reject_extraneous_keys(keys: valid_keys, path: [config_key])
16 | schema.expect_array_with_items(path: [config_key, :files])
17 |
18 | config[:files].each_with_index do |_exports, index|
19 | export_keys = %i[template output]
20 |
21 | schema.expect_required_keys(
22 | keys: export_keys,
23 | path: [config_key, :files, index]
24 | )
25 |
26 | schema.reject_extraneous_keys(
27 | keys: export_keys,
28 | path: [config_key, :files, index]
29 | )
30 |
31 | schema.expect_type(
32 | path: [config_key, :files, index, :template],
33 | types: String
34 | )
35 |
36 | schema.expect_type(
37 | path: [config_key, :files, index, :output],
38 | types: String
39 | )
40 | end
41 | end
42 |
43 | def after_export(files:)
44 | require "erb"
45 | require "digest/md5"
46 | require "json"
47 |
48 | files.each do |file|
49 | dir = File.dirname(file)
50 | name = File.basename(file)
51 | extension = File.extname(name)
52 | base_name = File.basename(file, extension)
53 |
54 | config[:files].each do |export|
55 | translations = JSON.load_file(file)
56 | template = Template.new(
57 | file:,
58 | translations:,
59 | template: export[:template]
60 | )
61 |
62 | contents = template.render
63 |
64 | output = format(
65 | export[:output],
66 | dir:,
67 | name:,
68 | extension:,
69 | digest: Digest::MD5.hexdigest(contents),
70 | base_name:
71 | )
72 |
73 | File.open(output, "w") do |io|
74 | io << contents
75 | end
76 | end
77 | end
78 | end
79 |
80 | class Template
81 | attr_accessor :file, :translations, :template
82 |
83 | def initialize(**kwargs)
84 | kwargs.each do |key, value|
85 | public_send(:"#{key}=", value)
86 | end
87 | end
88 |
89 | def banner(comment: "// ", include_time: true)
90 | [
91 | "#{comment}File generated by i18n-js",
92 | include_time ? " on #{Time.now}" : nil
93 | ].compact.join
94 | end
95 |
96 | def render
97 | ERB.new(File.read(template)).result(binding)
98 | end
99 | end
100 | end
101 |
102 | I18nJS.register_plugin(ExportFilesPlugin)
103 | end
104 |
--------------------------------------------------------------------------------
/lib/i18n-js/cli/export_command.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module I18nJS
4 | class CLI
5 | class ExportCommand < Command
6 | command_name "export"
7 | description "Export translations as JSON files"
8 |
9 | parse do |opts|
10 | opts.banner = "Usage: i18n #{name} [options]"
11 |
12 | opts.on(
13 | "-cCONFIG_FILE",
14 | "--config=CONFIG_FILE",
15 | "The configuration file that will be used"
16 | ) do |config_file|
17 | options[:config_file] = config_file
18 | end
19 |
20 | opts.on(
21 | "-rREQUIRE_FILE",
22 | "--require=REQUIRE_FILE",
23 | "A Ruby file that must be loaded"
24 | ) do |require_file|
25 | options[:require_file] = require_file
26 | end
27 |
28 | opts.on(
29 | "-q",
30 | "--quiet",
31 | "Suppress non-error output"
32 | ) do |quiet|
33 | options[:quiet] = quiet
34 | end
35 |
36 | opts.on("-h", "--help", "Prints this help") do
37 | ui.exit_with opts.to_s
38 | end
39 | end
40 |
41 | command do
42 | set_defaults!
43 |
44 | unless options[:config_file]
45 | ui.fail_with("=> ERROR: you need to specify the config file")
46 | end
47 |
48 | log("=> Config file:", options[:config_file].inspect)
49 | config_file = File.expand_path(options[:config_file])
50 |
51 | if options[:require_file]
52 | log("=> Require file:", options[:require_file].inspect)
53 | require_file = File.expand_path(options[:require_file])
54 | end
55 |
56 | unless File.file?(config_file)
57 | ui.fail_with(
58 | "=> ERROR: config file doesn't exist at",
59 | config_file.inspect
60 | )
61 | end
62 |
63 | if require_file && !File.file?(require_file)
64 | ui.fail_with(
65 | "=> ERROR: require file doesn't exist at",
66 | require_file.inspect
67 | )
68 | end
69 |
70 | time = benchmark_realtime do
71 | load_require_file!(require_file) if require_file
72 | I18nJS.call(config_file:)
73 | end
74 |
75 | log("=> Done in #{time.round(2)}s")
76 | end
77 |
78 | private def log(*)
79 | return if options[:quiet]
80 |
81 | ui.stdout_print(*)
82 | end
83 |
84 | private def set_defaults!
85 | config_file = "./config/i18n.yml"
86 | require_file = "./config/environment.rb"
87 |
88 | options[:config_file] ||= config_file if File.file?(config_file)
89 | options[:require_file] ||= require_file if File.file?(require_file)
90 | end
91 |
92 | private def benchmark_realtime
93 | start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
94 | yield
95 | Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
96 | end
97 | end
98 | end
99 | end
100 |
--------------------------------------------------------------------------------
/lib/i18n-js.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "i18n"
4 | require "json"
5 | require "yaml"
6 | require "glob"
7 | require "fileutils"
8 | require "optparse"
9 | require "erb"
10 | require "digest/md5"
11 |
12 | require_relative "i18n-js/schema"
13 | require_relative "i18n-js/version"
14 | require_relative "i18n-js/plugin"
15 | require_relative "i18n-js/sort_hash"
16 | require_relative "i18n-js/clean_hash"
17 |
18 | module I18nJS
19 | MissingConfigError = Class.new(StandardError)
20 |
21 | def self.call(config_file: nil, config: nil)
22 | if !config_file && !config
23 | raise MissingConfigError,
24 | "you must set either `config_file` or `config`"
25 | end
26 |
27 | config = Glob::SymbolizeKeys.call(config || load_config_file(config_file))
28 |
29 | load_plugins!
30 | initialize_plugins!(config:)
31 | Schema.validate!(config)
32 |
33 | exported_files = []
34 |
35 | config[:translations].each {|group| exported_files += export_group(group) }
36 |
37 | plugins.each do |plugin|
38 | plugin.after_export(files: exported_files.dup) if plugin.enabled?
39 | end
40 |
41 | exported_files
42 | end
43 |
44 | def self.export_group(group)
45 | filtered_translations = Glob.filter(translations, group[:patterns])
46 | filtered_translations =
47 | plugins.reduce(filtered_translations) do |buffer, plugin|
48 | if plugin.enabled?
49 | plugin.transform(translations: buffer)
50 | else
51 | buffer
52 | end
53 | end
54 |
55 | filtered_translations = sort_hash(clean_hash(filtered_translations))
56 | output_file_path = File.expand_path(group[:file])
57 | exported_files = []
58 |
59 | if output_file_path.include?(":locale")
60 | filtered_translations.each_key do |locale|
61 | locale_file_path = output_file_path.gsub(":locale", locale.to_s)
62 | exported_files << write_file(locale_file_path,
63 | locale => filtered_translations[locale])
64 | end
65 | else
66 | exported_files << write_file(output_file_path, filtered_translations)
67 | end
68 |
69 | exported_files
70 | end
71 |
72 | def self.write_file(file_path, translations)
73 | FileUtils.mkdir_p(File.dirname(file_path))
74 |
75 | contents = ::JSON.pretty_generate(translations)
76 | digest = Digest::MD5.hexdigest(contents)
77 | file_path = file_path.gsub(":digest", digest)
78 |
79 | # Don't rewrite the file if it already exists and has the same content.
80 | # It helps the asset pipeline or webpack understand that file wasn't
81 | # changed.
82 | if File.exist?(file_path) && File.read(file_path) == contents
83 | return file_path
84 | end
85 |
86 | File.open(file_path, "w") do |file|
87 | file << contents
88 | end
89 |
90 | file_path
91 | end
92 |
93 | def self.translations
94 | ::I18n.backend.instance_eval do
95 | has_been_initialized_before =
96 | respond_to?(:initialized?, true) && initialized?
97 | init_translations unless has_been_initialized_before
98 | translations
99 | end
100 | end
101 |
102 | def self.load_config_file(config_file)
103 | erb = ERB.new(File.read(config_file))
104 | YAML.safe_load(erb.result(binding))
105 | end
106 | end
107 |
--------------------------------------------------------------------------------
/test/i18n-js/cli/lint_scripts_command_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | class LintCommandTest < Minitest::Test
6 | let(:stdout) { StringIO.new }
7 | let(:stderr) { StringIO.new }
8 |
9 | test "displays help" do
10 | cli = I18nJS::CLI.new(
11 | argv: %w[lint:scripts --help],
12 | stdout:,
13 | stderr:
14 | )
15 |
16 | assert_exit_code(0) { cli.call }
17 | assert_stdout_includes "Usage: i18n lint:scripts [options]"
18 | end
19 |
20 | test "without a config file" do
21 | cli = I18nJS::CLI.new(
22 | argv: %w[lint:scripts],
23 | stdout:,
24 | stderr:
25 | )
26 |
27 | assert_exit_code(1) { cli.call }
28 | assert_stderr_includes "ERROR: you need to specify the config file"
29 | end
30 |
31 | test "with missing config file" do
32 | config_file = "missing/i18n.yml"
33 | path = File.expand_path(config_file)
34 |
35 | cli = I18nJS::CLI.new(
36 | argv: %W[lint:scripts --config #{config_file}],
37 | stdout:,
38 | stderr:
39 | )
40 |
41 | assert_exit_code(1) { cli.call }
42 | assert_stderr_includes %[ERROR: config file doesn't exist at "#{path}"]
43 | end
44 |
45 | test "with missing require file" do
46 | require_file = "missing/require.rb"
47 | path = File.expand_path(require_file)
48 |
49 | cli = I18nJS::CLI.new(
50 | argv: %W[
51 | lint:scripts
52 | --config test/config/lint_scripts.yml
53 | --require #{require_file}
54 | ],
55 | stdout:,
56 | stderr:
57 | )
58 |
59 | assert_exit_code(1) { cli.call }
60 | assert_stderr_includes %[ERROR: require file doesn't exist at "#{path}"]
61 | end
62 |
63 | test "with missing node bin" do
64 | cli = I18nJS::CLI.new(
65 | argv: %w[
66 | lint:scripts
67 | --config test/config/lint_scripts.yml
68 | --require test/config/require.rb
69 | --node-path /invalid/path/to/node
70 | ],
71 | stdout:,
72 | stderr:
73 | )
74 |
75 | assert_exit_code(1) { cli.call }
76 | assert_stderr_includes "=> ERROR: node.js couldn't be found " \
77 | "(path: /invalid/path/to/node)"
78 | end
79 |
80 | test "with require file that fails to load" do
81 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"]
82 |
83 | cli = I18nJS::CLI.new(
84 | argv: %w[
85 | lint:scripts
86 | --config test/config/lint_scripts.yml
87 | --require test/config/require_error.rb
88 | ],
89 | stdout:,
90 | stderr:
91 | )
92 |
93 | assert_exit_code(1) { cli.call }
94 |
95 | assert_stderr_includes "RuntimeError => 💣"
96 | assert_stderr_includes \
97 | %[ERROR: couldn't load "test/config/require_error.rb"]
98 | end
99 |
100 | test "lints files" do
101 | cli = I18nJS::CLI.new(
102 | argv: %w[
103 | lint:scripts
104 | --config test/config/lint_scripts.yml
105 | --require test/config/require.rb
106 | ],
107 | stdout:,
108 | stderr:
109 | )
110 |
111 | assert_exit_code(8) { cli.call }
112 |
113 | output = format(
114 | File.read("./test/fixtures/expected/lint.txt"),
115 | node: `which node`.chomp
116 | )
117 |
118 | assert_stdout_includes(output)
119 | end
120 | end
121 |
--------------------------------------------------------------------------------
/lib/i18n-js/plugin.rb:
--------------------------------------------------------------------------------
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:).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
104 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, gender identity and expression, level of experience,
9 | nationality, personal appearance, race, religion, or sexual identity and
10 | orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at me@fnando.com. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at [https://contributor-covenant.org/version/1/4][version]
72 |
73 | [homepage]: https://contributor-covenant.org
74 | [version]: https://contributor-covenant.org/version/1/4/
75 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to i18n-js
2 |
3 | 👍🎉 First off, thanks for taking the time to contribute! 🎉👍
4 |
5 | The following is a set of guidelines for contributing to this project. These are
6 | mostly guidelines, not rules. Use your best judgment, and feel free to propose
7 | changes to this document in a pull request.
8 |
9 | ## Code of Conduct
10 |
11 | Everyone interacting in this project's codebases, issue trackers, chat rooms and
12 | mailing lists is expected to follow the [code of conduct](https://github.com/fnando/i18n-js/blob/main/CODE_OF_CONDUCT.md).
13 |
14 | ## Reporting bugs
15 |
16 | This section guides you through submitting a bug report. Following these
17 | guidelines helps maintainers and the community understand your report, reproduce
18 | the behavior, and find related reports.
19 |
20 | - Before creating bug reports, please check the open issues; somebody may
21 | already have submitted something similar, and you may not need to create a new
22 | one.
23 | - When you are creating a bug report, please include as many details as
24 | possible, with an example reproducing the issue.
25 |
26 | ## Contributing with code
27 |
28 | Before making any radicals changes, please make sure you discuss your intention
29 | by [opening an issue on Github](https://github.com/fnando/i18n-js/issues).
30 |
31 | When you're ready to make your pull request, follow checklist below to make sure
32 | your contribution is according to how this project works.
33 |
34 | 1. [Fork](https://help.github.com/forking/) i18n-js
35 | 2. Create a topic branch - `git checkout -b my_branch`
36 | 3. Make your changes using [descriptive commit messages](#commit-messages)
37 | 4. Update CHANGELOG.md describing your changes by adding an entry to the
38 | "Unreleased" section. If this section is not available, create one right
39 | before the last version.
40 | 5. Push to your branch - `git push origin my_branch`
41 | 6. [Create a pull request](https://help.github.com/articles/creating-a-pull-request)
42 | 7. That's it!
43 |
44 | ## Styleguides
45 |
46 | ### Commit messages
47 |
48 | - Use the present tense ("Add feature" not "Added feature")
49 | - Use the imperative mood ("Move cursor to..." not "Moves cursor to...")
50 | - Limit the first line to 72 characters or less
51 | - Reference issues and pull requests liberally after the first line
52 |
53 | ### Changelog
54 |
55 | - Add a message describing your changes to the "Unreleased" section. The
56 | changelog message should follow the same style as the commit message.
57 | - Prefix your message with one of the following:
58 | - `[Added]` for new features.
59 | - `[Changed]` for changes in existing functionality.
60 | - `[Deprecated]` for soon-to-be removed features.
61 | - `[Removed]` for now removed features.
62 | - `[Fixed]` for any bug fixes.
63 | - `[Security]` in case of vulnerabilities.
64 |
65 | ### Ruby code
66 |
67 | - This project uses [Rubocop](https://rubocop.org) to enforce code style. Before
68 | submitting your changes, make sure your tests are passing and code conforms to
69 | the expected style by running `rake`.
70 | - Do not change the library version. This will be done by the maintainer
71 | whenever a new version is about to be released.
72 |
73 | ### JavaScript code
74 |
75 | - This project uses [ESLint](https://eslint.org) to enforce code style. Before
76 | submitting your changes, make sure your tests are passing and code conforms to
77 | the expected style by running `yarn test:ci`.
78 | - Do not change the library version. This will be done by the maintainer
79 | whenever a new version is about to be released.
80 |
--------------------------------------------------------------------------------
/test/i18n-js/cli/export_command_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | class ExportCommandTest < Minitest::Test
6 | let(:stdout) { StringIO.new }
7 | let(:stderr) { StringIO.new }
8 |
9 | test "displays help" do
10 | cli = I18nJS::CLI.new(
11 | argv: %w[export --help],
12 | stdout:,
13 | stderr:
14 | )
15 |
16 | assert_exit_code(0) { cli.call }
17 | assert_stdout_includes "Usage: i18n export [options]"
18 | end
19 |
20 | test "without a config file" do
21 | cli = I18nJS::CLI.new(
22 | argv: %w[export],
23 | stdout:,
24 | stderr:
25 | )
26 |
27 | assert_exit_code(1) { cli.call }
28 | assert_stderr_includes "ERROR: you need to specify the config file"
29 | end
30 |
31 | test "with missing file" do
32 | config_file = "missing/i18n.yml"
33 | path = File.expand_path(config_file)
34 |
35 | cli = I18nJS::CLI.new(
36 | argv: %W[export --config #{config_file}],
37 | stdout:,
38 | stderr:
39 | )
40 |
41 | assert_exit_code(1) { cli.call }
42 | assert_stderr_includes %[ERROR: config file doesn't exist at "#{path}"]
43 | end
44 |
45 | test "with missing require file" do
46 | require_file = "missing/require.rb"
47 | path = File.expand_path(require_file)
48 |
49 | cli = I18nJS::CLI.new(
50 | argv: %W[
51 | export
52 | --config test/config/everything.yml
53 | --require #{require_file}
54 | ],
55 | stdout:,
56 | stderr:
57 | )
58 |
59 | assert_exit_code(1) { cli.call }
60 | assert_stderr_includes %[ERROR: require file doesn't exist at "#{path}"]
61 | end
62 |
63 | test "with existing file" do
64 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"]
65 |
66 | cli = I18nJS::CLI.new(
67 | argv: %w[export --config test/config/everything.yml],
68 | stdout:,
69 | stderr:
70 | )
71 |
72 | assert_exit_code(0) { cli.call }
73 |
74 | assert_file "test/output/everything.json"
75 | assert_json_file "test/fixtures/expected/everything.json",
76 | "test/output/everything.json"
77 | end
78 |
79 | test "with require file that fails to load" do
80 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"]
81 |
82 | cli = I18nJS::CLI.new(
83 | argv: %w[
84 | export
85 | --config test/config/everything.yml
86 | --require test/config/require_error.rb
87 | ],
88 | stdout:,
89 | stderr:
90 | )
91 |
92 | assert_exit_code(1) { cli.call }
93 |
94 | assert_stderr_includes "RuntimeError => 💣"
95 | assert_stderr_includes \
96 | %[ERROR: couldn't load "test/config/require_error.rb"]
97 | end
98 |
99 | test "requires file" do
100 | cli = I18nJS::CLI.new(
101 | argv: %w[
102 | export
103 | --config test/config/everything.yml
104 | --require test/config/require.rb
105 | ],
106 | stdout:,
107 | stderr:
108 | )
109 |
110 | assert_exit_code(0) { cli.call }
111 |
112 | assert_file "test/output/everything.json"
113 | assert_json_file "test/fixtures/expected/everything.json",
114 | "test/output/everything.json"
115 | end
116 |
117 | test "exports using quiet mode" do
118 | cli = I18nJS::CLI.new(
119 | argv: %w[
120 | export
121 | --config test/config/everything.yml
122 | --require test/config/require.rb
123 | --quiet
124 | ],
125 | stdout:,
126 | stderr:
127 | )
128 |
129 | assert_exit_code(0) { cli.call }
130 |
131 | assert_file "test/output/everything.json"
132 | assert_json_file "test/fixtures/expected/everything.json",
133 | "test/output/everything.json"
134 |
135 | assert_equal "", stdout.tap(&:rewind).read
136 | assert_equal "", stderr.tap(&:rewind).read
137 | end
138 | end
139 |
--------------------------------------------------------------------------------
/test/i18n-js/plugin_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | class PluginTest < Minitest::Test
6 | def create_plugin(&block)
7 | Class.new(I18nJS::Plugin) do
8 | def self.name
9 | "SamplePlugin"
10 | end
11 |
12 | def self.calls
13 | @calls ||= []
14 | end
15 |
16 | class_eval(&block) if block
17 | end
18 | end
19 |
20 | test "implements default transform method" do
21 | plugin_class = create_plugin
22 | plugin = plugin_class.new(config: {})
23 | translations = {}
24 |
25 | assert_same translations,
26 | plugin.transform(translations:)
27 | end
28 |
29 | test "registers plugin" do
30 | plugin_class = create_plugin
31 | I18nJS.register_plugin(plugin_class)
32 |
33 | assert_includes I18nJS.available_plugins, plugin_class
34 | end
35 |
36 | test "setups plugin" do
37 | plugin_class = create_plugin do
38 | def setup
39 | I18nJS::Schema.root_keys << :sample
40 | end
41 | end
42 |
43 | I18nJS.register_plugin(plugin_class)
44 | I18nJS.initialize_plugins!(config: {})
45 |
46 | assert_includes I18nJS::Schema.root_keys, :sample
47 | end
48 |
49 | test "validates schema" do
50 | config = {
51 | translations: [
52 | {
53 | file: "app/frontend/locales/en.json",
54 | patterns: [
55 | "*"
56 | ]
57 | }
58 | ],
59 | sample: {
60 | enabled: true
61 | }
62 | }
63 |
64 | plugin_class = create_plugin do
65 | def setup
66 | I18nJS::Schema.root_keys << config_key
67 | end
68 |
69 | def validate_schema
70 | self.class.calls << :validated_schema
71 | end
72 | end
73 |
74 | I18nJS.register_plugin(plugin_class)
75 | I18nJS.initialize_plugins!(config:)
76 | I18nJS::Schema.validate!(config)
77 |
78 | assert_equal 1, plugin_class.calls.size
79 | assert_includes plugin_class.calls, :validated_schema
80 | end
81 |
82 | test "runs after_export event" do
83 | config = Glob::SymbolizeKeys.call(
84 | I18nJS.load_config_file("./test/config/locale_placeholder.yml")
85 | .merge(sample: {enabled: true})
86 | )
87 |
88 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"]
89 | expected_files = [
90 | "test/output/en.json",
91 | "test/output/es.json",
92 | "test/output/pt.json"
93 | ]
94 |
95 | plugin_class = create_plugin do
96 | class << self
97 | attr_accessor :received_config, :received_files
98 | end
99 |
100 | def setup
101 | I18nJS::Schema.root_keys << :sample
102 | end
103 |
104 | def after_export(files:)
105 | self.class.received_files = files
106 | end
107 | end
108 |
109 | I18nJS.register_plugin(plugin_class)
110 |
111 | actual_files =
112 | I18nJS.call(config:)
113 |
114 | assert_exported_files expected_files, actual_files
115 | assert_exported_files expected_files, plugin_class.received_files
116 | end
117 |
118 | test "loads plugins using rubygems" do
119 | Gem
120 | .expects(:find_files)
121 | .with("i18n-js/*_plugin.rb")
122 | .returns(["/path/to/i18n-js/fallback_plugin.rb"])
123 |
124 | I18nJS.expects(:require).with("/path/to/i18n-js/fallback_plugin.rb")
125 |
126 | I18nJS.load_plugins!
127 | end
128 |
129 | test "infers config key out of class name" do
130 | {
131 | "SamplePlugin" => :sample,
132 | "EmbedFallbackTranslationsPlugin" => :embed_fallback_translations,
133 | "ExportFilesPlugin" => :export_files,
134 | "FetchFromHTTPPlugin" => :fetch_from_http,
135 | "HTTPClientPlugin" => :http_client
136 | }.each do |class_name, key|
137 | plugin_class = Class.new(I18nJS::Plugin)
138 | plugin_class.stubs(:name).returns(class_name)
139 | plugin = plugin_class.new(config: {})
140 |
141 | assert_equal key, plugin.config_key
142 | end
143 | end
144 | end
145 |
--------------------------------------------------------------------------------
/test/i18n-js/cli/lint_translations_command_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | class LintTranslationsCommandTest < Minitest::Test
6 | let(:stdout) { StringIO.new }
7 | let(:stderr) { StringIO.new }
8 |
9 | test "displays help" do
10 | cli = I18nJS::CLI.new(
11 | argv: %w[lint:translations --help],
12 | stdout:,
13 | stderr:
14 | )
15 |
16 | assert_exit_code(0) { cli.call }
17 | assert_stdout_includes "Usage: i18n lint:translations [options]"
18 | end
19 |
20 | test "without a config file" do
21 | cli = I18nJS::CLI.new(
22 | argv: %w[lint:translations],
23 | stdout:,
24 | stderr:
25 | )
26 |
27 | assert_exit_code(1) { cli.call }
28 | assert_stderr_includes "ERROR: you need to specify the config file"
29 | end
30 |
31 | test "with missing file" do
32 | config_file = "missing/i18n.yml"
33 | path = File.expand_path(config_file)
34 |
35 | cli = I18nJS::CLI.new(
36 | argv: %W[lint:translations --config #{config_file}],
37 | stdout:,
38 | stderr:
39 | )
40 |
41 | assert_exit_code(1) { cli.call }
42 | assert_stderr_includes %[ERROR: config file doesn't exist at "#{path}"]
43 | end
44 |
45 | test "with missing require file" do
46 | require_file = "missing/require.rb"
47 | path = File.expand_path(require_file)
48 |
49 | cli = I18nJS::CLI.new(
50 | argv: %W[
51 | lint:translations
52 | --config test/config/everything.yml
53 | --require #{require_file}
54 | ],
55 | stdout:,
56 | stderr:
57 | )
58 |
59 | assert_exit_code(1) { cli.call }
60 | assert_stderr_includes %[ERROR: require file doesn't exist at "#{path}"]
61 | end
62 |
63 | test "with existing file" do
64 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"]
65 |
66 | cli = I18nJS::CLI.new(
67 | argv: %w[lint:translations --config test/config/everything.yml],
68 | stdout:,
69 | stderr:
70 | )
71 |
72 | assert_exit_code(0) { cli.call }
73 | end
74 |
75 | test "with require file that fails to load" do
76 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"]
77 |
78 | cli = I18nJS::CLI.new(
79 | argv: %w[
80 | lint:translations
81 | --config test/config/everything.yml
82 | --require test/config/require_error.rb
83 | ],
84 | stdout:,
85 | stderr:
86 | )
87 |
88 | assert_exit_code(1) { cli.call }
89 |
90 | assert_stderr_includes "RuntimeError => 💣"
91 | assert_stderr_includes \
92 | %[ERROR: couldn't load "test/config/require_error.rb"]
93 | end
94 |
95 | test "forces colored output" do
96 | cli = I18nJS::CLI.new(
97 | argv: %w[
98 | lint:translations
99 | --config test/config/everything.yml
100 | --require test/config/require.rb
101 | --color
102 | ],
103 | stdout:,
104 | stderr:
105 | )
106 |
107 | assert_exit_code(1) { cli.call }
108 |
109 | output = stdout.tap(&:rewind).read.chomp
110 |
111 | assert_includes output, "\e[31mmissing\e[0m"
112 | assert_includes output, "\e[33mextraneous\e[0m"
113 | end
114 |
115 | test "checks loaded translations" do
116 | cli = I18nJS::CLI.new(
117 | argv: %w[
118 | lint:translations
119 | --config test/config/everything.yml
120 | --require test/config/require.rb
121 | ],
122 | stdout:,
123 | stderr:
124 | )
125 |
126 | assert_exit_code(1) { cli.call }
127 |
128 | output = stdout.tap(&:rewind).read.chomp
129 |
130 | assert_includes output, "=> en: 3 translations"
131 | assert_includes output, "=> es: 1 missing, 1 extraneous"
132 | assert_includes output, "- es.bye (extraneous)"
133 | assert_includes output, "- es.hello sunshine! (missing)"
134 | assert_includes output, "=> pt: 1 missing, 1 extraneous"
135 | assert_includes output, "- pt.bye (extraneous)"
136 | assert_includes output, "- pt.hello sunshine! (missing)"
137 | end
138 |
139 | test "ignores translations" do
140 | cli = I18nJS::CLI.new(
141 | argv: %w[
142 | lint:translations
143 | --config test/config/lint_translations.yml
144 | --require test/config/require.rb
145 | ],
146 | stdout:,
147 | stderr:
148 | )
149 |
150 | assert_exit_code(0) { cli.call }
151 |
152 | output = stdout.tap(&:rewind).read.chomp
153 |
154 | assert_includes output, "=> en: 3 translations"
155 | assert_includes output, "=> es: 0 missing, 0 extraneous, 2 ignored"
156 | assert_includes output, "=> pt: 0 missing, 0 extraneous, 2 ignored"
157 | end
158 | end
159 |
--------------------------------------------------------------------------------
/test/i18n-js/cli/check_command_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | class CheckCommandTest < Minitest::Test
6 | let(:stdout) { StringIO.new }
7 | let(:stderr) { StringIO.new }
8 |
9 | test "displays help" do
10 | cli = I18nJS::CLI.new(
11 | argv: %w[check --help],
12 | stdout:,
13 | stderr:
14 | )
15 |
16 | assert_exit_code(0) { cli.call }
17 | assert_stdout_includes "Usage: i18n check [options]"
18 | end
19 |
20 | test "without a config file" do
21 | cli = I18nJS::CLI.new(
22 | argv: %w[check],
23 | stdout:,
24 | stderr:
25 | )
26 |
27 | assert_exit_code(1) { cli.call }
28 | assert_command_deprecation_message
29 | assert_stderr_includes "ERROR: you need to specify the config file"
30 | end
31 |
32 | test "with missing file" do
33 | config_file = "missing/i18n.yml"
34 | path = File.expand_path(config_file)
35 |
36 | cli = I18nJS::CLI.new(
37 | argv: %W[check --config #{config_file}],
38 | stdout:,
39 | stderr:
40 | )
41 |
42 | assert_exit_code(1) { cli.call }
43 | assert_command_deprecation_message
44 | assert_stderr_includes %[ERROR: config file doesn't exist at "#{path}"]
45 | end
46 |
47 | test "with missing require file" do
48 | require_file = "missing/require.rb"
49 | path = File.expand_path(require_file)
50 |
51 | cli = I18nJS::CLI.new(
52 | argv: %W[
53 | check
54 | --config test/config/everything.yml
55 | --require #{require_file}
56 | ],
57 | stdout:,
58 | stderr:
59 | )
60 |
61 | assert_exit_code(1) { cli.call }
62 | assert_command_deprecation_message
63 | assert_stderr_includes %[ERROR: require file doesn't exist at "#{path}"]
64 | end
65 |
66 | test "with existing file" do
67 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"]
68 |
69 | cli = I18nJS::CLI.new(
70 | argv: %w[check --config test/config/everything.yml],
71 | stdout:,
72 | stderr:
73 | )
74 |
75 | assert_exit_code(0) { cli.call }
76 | assert_command_deprecation_message
77 | end
78 |
79 | test "with require file that fails to load" do
80 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"]
81 |
82 | cli = I18nJS::CLI.new(
83 | argv: %w[
84 | check
85 | --config test/config/everything.yml
86 | --require test/config/require_error.rb
87 | ],
88 | stdout:,
89 | stderr:
90 | )
91 |
92 | assert_exit_code(1) { cli.call }
93 |
94 | assert_command_deprecation_message
95 | assert_stderr_includes "RuntimeError => 💣"
96 | assert_stderr_includes \
97 | %[ERROR: couldn't load "test/config/require_error.rb"]
98 | end
99 |
100 | test "forces colored output" do
101 | cli = I18nJS::CLI.new(
102 | argv: %w[
103 | check
104 | --config test/config/everything.yml
105 | --require test/config/require.rb
106 | --color
107 | ],
108 | stdout:,
109 | stderr:
110 | )
111 |
112 | assert_exit_code(1) { cli.call }
113 |
114 | output = stdout.tap(&:rewind).read.chomp
115 |
116 | assert_command_deprecation_message
117 | assert_includes output, "\e[31mmissing\e[0m"
118 | assert_includes output, "\e[33mextraneous\e[0m"
119 | end
120 |
121 | test "checks loaded translations" do
122 | cli = I18nJS::CLI.new(
123 | argv: %w[
124 | check
125 | --config test/config/everything.yml
126 | --require test/config/require.rb
127 | ],
128 | stdout:,
129 | stderr:
130 | )
131 |
132 | assert_exit_code(1) { cli.call }
133 |
134 | output = stdout.tap(&:rewind).read.chomp
135 |
136 | assert_command_deprecation_message
137 | assert_includes output, "=> en: 3 translations"
138 | assert_includes output, "=> es: 1 missing, 1 extraneous"
139 | assert_includes output, "- es.bye (extraneous)"
140 | assert_includes output, "- es.hello sunshine! (missing)"
141 | assert_includes output, "=> pt: 1 missing, 1 extraneous"
142 | assert_includes output, "- pt.bye (extraneous)"
143 | assert_includes output, "- pt.hello sunshine! (missing)"
144 | end
145 |
146 | test "ignores translations" do
147 | cli = I18nJS::CLI.new(
148 | argv: %w[
149 | check
150 | --config test/config/check.yml
151 | --require test/config/require.rb
152 | ],
153 | stdout:,
154 | stderr:
155 | )
156 |
157 | assert_exit_code(0) { cli.call }
158 |
159 | output = stdout.tap(&:rewind).read.chomp
160 |
161 | assert_command_deprecation_message
162 | assert_includes output, "=> en: 3 translations"
163 | assert_includes output, "=> es: 0 missing, 0 extraneous, 2 ignored"
164 | assert_includes output, "=> pt: 0 missing, 0 extraneous, 2 ignored"
165 | end
166 |
167 | private def assert_command_deprecation_message
168 | assert_stderr_includes "WARNING: `i18n check` has been deprecated in " \
169 | "favor of `i18n lint:translations`"
170 | end
171 | end
172 |
--------------------------------------------------------------------------------
/lib/i18n-js/cli/lint_scripts_command.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module I18nJS
4 | class CLI
5 | class LintScriptsCommand < Command
6 | command_name "lint:scripts"
7 | description "Lint files using TypeScript"
8 |
9 | parse do |opts|
10 | opts.banner = "Usage: i18n #{name} [options]"
11 |
12 | opts.on(
13 | "-cCONFIG_FILE",
14 | "--config=CONFIG_FILE",
15 | "The configuration file that will be used"
16 | ) do |config_file|
17 | options[:config_file] = config_file
18 | end
19 |
20 | opts.on(
21 | "-rREQUIRE_FILE",
22 | "--require=REQUIRE_FILE",
23 | "A Ruby file that must be loaded"
24 | ) do |require_file|
25 | options[:require_file] = require_file
26 | end
27 |
28 | opts.on(
29 | "-nNODE_PATH",
30 | "--node-path=NODE_PATH",
31 | "Set node.js path"
32 | ) do |node_path|
33 | options[:node_path] = node_path
34 | end
35 |
36 | opts.on("-h", "--help", "Prints this help") do
37 | ui.exit_with opts.to_s
38 | end
39 | end
40 |
41 | command do
42 | set_defaults!
43 | ui.colored = options[:colored]
44 |
45 | unless options[:config_file]
46 | ui.fail_with("=> ERROR: you need to specify the config file")
47 | end
48 |
49 | ui.stdout_print("=> Config file:", options[:config_file].inspect)
50 | config_file = File.expand_path(options[:config_file])
51 |
52 | if options[:require_file]
53 | ui.stdout_print("=> Require file:", options[:require_file].inspect)
54 | require_file = File.expand_path(options[:require_file])
55 | end
56 |
57 | node_path = options[:node_path] || find_node
58 | ui.stdout_print("=> Node:", node_path.inspect)
59 |
60 | unless File.file?(config_file)
61 | ui.fail_with(
62 | "=> ERROR: config file doesn't exist at",
63 | config_file.inspect
64 | )
65 | end
66 |
67 | if require_file && !File.file?(require_file)
68 | ui.fail_with(
69 | "=> ERROR: require file doesn't exist at",
70 | require_file.inspect
71 | )
72 | end
73 |
74 | found_node = node_path && File.executable?(File.expand_path(node_path))
75 |
76 | unless found_node
77 | ui.fail_with(
78 | "=> ERROR: node.js couldn't be found (path: #{node_path})"
79 | )
80 | end
81 |
82 | config = load_config_file(config_file)
83 | I18nJS.load_plugins!
84 | I18nJS.initialize_plugins!(config:)
85 | Schema.validate!(config)
86 |
87 | load_require_file!(require_file) if require_file
88 |
89 | available_locales = I18n.available_locales
90 | ignored_keys = config.dig(:lint_scripts, :ignore) || []
91 |
92 | ui.stdout_print "=> Available locales: #{available_locales.inspect}"
93 |
94 | exported_files = I18nJS.call(config_file:)
95 | data = exported_files.each_with_object({}) do |file, buffer|
96 | buffer.merge!(JSON.load_file(file, symbolize_names: true))
97 | end
98 |
99 | lint_file = File.expand_path(File.join(__dir__, "../lint.js"))
100 | patterns = config.dig(:lint_scripts, :patterns) || %w[
101 | !(node_modules)/**/*.js
102 | !(node_modules)/**/*.ts
103 | !(node_modules)/**/*.jsx
104 | !(node_modules)/**/*.tsx
105 | ]
106 |
107 | ui.stdout_print "=> Patterns: #{patterns.inspect}"
108 |
109 | out = IO.popen([node_path, lint_file, patterns.join(":")]).read
110 | scopes = JSON.parse(out, symbolize_names: true)
111 | map = Glob::Map.call(data)
112 | missing_count = 0
113 | ignored_count = 0
114 |
115 | messages = []
116 |
117 | available_locales.each do |locale|
118 | scopes.each do |scope|
119 | scope_with_locale = "#{locale}.#{scope[:full]}"
120 |
121 | ignored = ignored_keys.include?(scope[:full]) ||
122 | ignored_keys.include?(scope_with_locale)
123 |
124 | if ignored
125 | ignored_count += 1
126 | next
127 | end
128 |
129 | next if map.include?(scope_with_locale)
130 |
131 | missing_count += 1
132 | messages << " - #{scope[:location]}: #{scope_with_locale}"
133 | end
134 | end
135 |
136 | ui.stdout_print "=> #{map.size} translations, #{missing_count} " \
137 | "missing, #{ignored_count} ignored"
138 | ui.stdout_print messages.sort.join("\n")
139 |
140 | exit(missing_count.size)
141 | end
142 |
143 | private def set_defaults!
144 | config_file = "./config/i18n.yml"
145 | require_file = "./config/environment.rb"
146 |
147 | options[:config_file] ||= config_file if File.file?(config_file)
148 | options[:require_file] ||= require_file if File.file?(require_file)
149 | end
150 |
151 | private def find_node
152 | ENV["PATH"]
153 | .split(File::PATH_SEPARATOR)
154 | .map {|dir| File.join(dir, "node") }
155 | .find {|bin| File.executable?(bin) }
156 | end
157 | end
158 | end
159 | end
160 |
--------------------------------------------------------------------------------
/lib/i18n-js/cli/lint_translations_command.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module I18nJS
4 | class CLI
5 | class LintTranslationsCommand < Command
6 | command_name "lint:translations"
7 | description "Check for missing translations based on the default locale"
8 |
9 | parse do |opts|
10 | opts.banner = "Usage: i18n #{name} [options]"
11 |
12 | opts.on(
13 | "-cCONFIG_FILE",
14 | "--config=CONFIG_FILE",
15 | "The configuration file that will be used"
16 | ) do |config_file|
17 | options[:config_file] = config_file
18 | end
19 |
20 | opts.on(
21 | "-rREQUIRE_FILE",
22 | "--require=REQUIRE_FILE",
23 | "A Ruby file that must be loaded"
24 | ) do |require_file|
25 | options[:require_file] = require_file
26 | end
27 |
28 | opts.on(
29 | "--[no-]color",
30 | "Force colored output"
31 | ) do |colored|
32 | options[:colored] = colored
33 | end
34 |
35 | opts.on("-h", "--help", "Prints this help") do
36 | ui.exit_with opts.to_s
37 | end
38 | end
39 |
40 | command do
41 | set_defaults!
42 | ui.colored = options[:colored]
43 |
44 | unless options[:config_file]
45 | ui.fail_with("=> ERROR: you need to specify the config file")
46 | end
47 |
48 | ui.stdout_print("=> Config file:", options[:config_file].inspect)
49 | config_file = File.expand_path(options[:config_file])
50 |
51 | if options[:require_file]
52 | ui.stdout_print("=> Require file:", options[:require_file].inspect)
53 | require_file = File.expand_path(options[:require_file])
54 | end
55 |
56 | unless File.file?(config_file)
57 | ui.fail_with(
58 | "=> ERROR: config file doesn't exist at",
59 | config_file.inspect
60 | )
61 | end
62 |
63 | if require_file && !File.file?(require_file)
64 | ui.fail_with(
65 | "=> ERROR: require file doesn't exist at",
66 | require_file.inspect
67 | )
68 | end
69 |
70 | config = load_config_file(config_file)
71 | I18nJS.load_plugins!
72 | I18nJS.initialize_plugins!(config:)
73 | Schema.validate!(config)
74 |
75 | load_require_file!(require_file) if require_file
76 |
77 | default_locale = I18n.default_locale
78 | available_locales = I18n.available_locales
79 | ignored_keys = config.dig(:lint_translations, :ignore) || []
80 |
81 | mapping = available_locales.each_with_object({}) do |locale, buffer|
82 | buffer[locale] =
83 | Glob::Map.call(Glob.filter(I18nJS.translations, ["#{locale}.*"]))
84 | .map {|key| key.gsub(/^.*?\./, "") }
85 | end
86 |
87 | default_locale_keys = mapping.delete(default_locale) || mapping
88 |
89 | if ignored_keys.any?
90 | ui.stdout_print "=> Check #{options[:config_file].inspect} for " \
91 | "ignored keys."
92 | end
93 |
94 | ui.stdout_print "=> #{default_locale}: #{default_locale_keys.size} " \
95 | "translations"
96 |
97 | total_missing_count = 0
98 |
99 | mapping.each do |locale, partial_keys|
100 | ignored_count = 0
101 |
102 | # Compute list of filtered keys (i.e. keys not ignored)
103 | filtered_keys = partial_keys.reject do |key|
104 | key = "#{locale}.#{key}"
105 |
106 | ignored = ignored_keys.include?(key)
107 | ignored_count += 1 if ignored
108 | ignored
109 | end
110 |
111 | extraneous = (partial_keys - default_locale_keys).reject do |key|
112 | key = "#{locale}.#{key}"
113 | ignored = ignored_keys.include?(key)
114 | ignored_count += 1 if ignored
115 | ignored
116 | end
117 |
118 | missing = (default_locale_keys - (filtered_keys - extraneous))
119 | .reject {|key| ignored_keys.include?("#{locale}.#{key}") }
120 |
121 | ignored_count += extraneous.size
122 | total_missing_count += missing.size
123 |
124 | ui.stdout_print "=> #{locale}: #{missing.size} missing, " \
125 | "#{extraneous.size} extraneous, " \
126 | "#{ignored_count} ignored"
127 |
128 | all_keys = (default_locale_keys + extraneous + missing).uniq.sort
129 |
130 | all_keys.each do |key|
131 | next if ignored_keys.include?("#{locale}.#{key}")
132 |
133 | label = if extraneous.include?(key)
134 | ui.yellow("extraneous")
135 | elsif missing.include?(key)
136 | ui.red("missing")
137 | else
138 | next
139 | end
140 |
141 | ui.stdout_print(" - #{locale}.#{key} (#{label})")
142 | end
143 | end
144 |
145 | exit(1) if total_missing_count.nonzero?
146 | end
147 |
148 | private def set_defaults!
149 | config_file = "./config/i18n.yml"
150 | require_file = "./config/environment.rb"
151 |
152 | options[:config_file] ||= config_file if File.file?(config_file)
153 | options[:require_file] ||= require_file if File.file?(require_file)
154 | end
155 | end
156 | end
157 | end
158 |
--------------------------------------------------------------------------------
/lib/i18n-js/lint.ts:
--------------------------------------------------------------------------------
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));
197 |
--------------------------------------------------------------------------------
/lib/i18n-js/schema.rb:
--------------------------------------------------------------------------------
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:)
137 | value = value_for(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:, types: Array)
160 |
161 | path = prepare_path(path:)
162 | value = value_for(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:)
172 | value = value_for(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:)
190 | value = value_for(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
217 |
--------------------------------------------------------------------------------
/test/i18n-js/schema_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | class SchemaTest < Minitest::Test
6 | test "requires root to be a hash" do
7 | error_message = %[Expected config to be "Hash"; got NilClass instead]
8 |
9 | assert_schema_error(error_message) do
10 | I18nJS::Schema.validate!(nil)
11 | end
12 | end
13 |
14 | test "accepts valid root keys" do
15 | I18nJS::Schema.validate!(
16 | translations: [
17 | {
18 | file: "app/frontend/locales/en.json",
19 | patterns: [
20 | "*"
21 | ]
22 | }
23 | ]
24 | )
25 | end
26 |
27 | test "requires translations key" do
28 | assert_schema_error("Expected \"translations\" to be defined") do
29 | I18nJS::Schema.validate!({})
30 | end
31 | end
32 |
33 | test "rejects extraneous keys on root" do
34 | assert_schema_error("config has unexpected keys: [:foo]") do
35 | I18nJS::Schema.validate!(
36 | translations: [{file: "file.json", patterns: ["*"]}],
37 | foo: 1
38 | )
39 | end
40 | end
41 |
42 | test "requires translations key to be an array" do
43 | assert_schema_error(
44 | %[Expected "translations" to be "Array"; got Hash instead]
45 | ) do
46 | I18nJS::Schema.validate!(translations: {})
47 | end
48 | end
49 |
50 | test "requires at least one translation config" do
51 | error_message = "Expected \"translations\" to have at least one item"
52 |
53 | assert_schema_error(error_message) do
54 | I18nJS::Schema.validate!(
55 | translations: []
56 | )
57 | end
58 | end
59 |
60 | test "requires translation to have :file key defined" do
61 | error_message = "Expected \"translations.0.file\" to be defined"
62 |
63 | assert_schema_error(error_message) do
64 | I18nJS::Schema.validate!(
65 | translations: [{patterns: "*"}]
66 | )
67 | end
68 | end
69 |
70 | test "requires translation's :file to be a string" do
71 | error_message = "Expected \"translations.0.file\" to be \"String\"; " \
72 | "got NilClass instead"
73 |
74 | assert_schema_error(error_message) do
75 | I18nJS::Schema.validate!(
76 | translations: [{file: nil, patterns: "*"}]
77 | )
78 | end
79 | end
80 |
81 | test "requires translation to have :patterns key defined" do
82 | error_message = "Expected \"translations.0.patterns\" to be defined"
83 |
84 | assert_schema_error(error_message) do
85 | I18nJS::Schema.validate!(
86 | translations: [{file: "some/file.json"}]
87 | )
88 | end
89 | end
90 |
91 | test "rejects extraneous keys on translation" do
92 | assert_schema_error("\"translations.0\" has unexpected keys: [:foo]") do
93 | I18nJS::Schema.validate!(
94 | translations: [{foo: 1, file: "some/file.json", patterns: ["*"]}]
95 | )
96 | end
97 | end
98 |
99 | test "requires :patterns to be an array" do
100 | error_message = "Expected \"translations.0.patterns\" to be \"Array\"; " \
101 | "got NilClass instead"
102 |
103 | assert_schema_error(error_message) do
104 | I18nJS::Schema.validate!(
105 | translations: [{patterns: nil, file: "some/file.json"}]
106 | )
107 | end
108 | end
109 |
110 | test "requires translation's :patterns to have at least one item" do
111 | error_message =
112 | "Expected \"translations.0.patterns\" to have at least one item"
113 |
114 | assert_schema_error(error_message) do
115 | I18nJS::Schema.validate!(
116 | translations: [{file: "some/file.json", patterns: []}]
117 | )
118 | end
119 | end
120 |
121 | test "requires lint_translations to be a hash" do
122 | error_message =
123 | %[Expected "lint_translations" to be "Hash"; got NilClass instead]
124 |
125 | assert_schema_error(error_message) do
126 | I18nJS::Schema.validate!(
127 | translations: [
128 | {
129 | file: "some/file.json",
130 | patterns: ["*"]
131 | }
132 | ],
133 | lint_translations: nil
134 | )
135 | end
136 | end
137 |
138 | test "requires lint_translations' :ignore to have :ignore" do
139 | error_message = "Expected \"lint_translations.ignore\" to be defined"
140 |
141 | assert_schema_error(error_message) do
142 | I18nJS::Schema.validate!(
143 | translations: [
144 | {
145 | file: "some/file.json",
146 | patterns: ["*"]
147 | }
148 | ],
149 | lint_translations: {}
150 | )
151 | end
152 | end
153 |
154 | test "requires lint_translations' :ignore to be an array" do
155 | error_message = "Expected \"lint_translations.ignore\" to be \"Array\"; " \
156 | "got Hash instead"
157 |
158 | assert_schema_error(error_message) do
159 | I18nJS::Schema.validate!(
160 | translations: [
161 | {
162 | file: "some/file.json",
163 | patterns: ["*"]
164 | }
165 | ],
166 | lint_translations: {
167 | ignore: {}
168 | }
169 | )
170 | end
171 | end
172 |
173 | test "requires plugin's enabled type to be boolean" do
174 | config = {
175 | translations: [
176 | {
177 | file: "app/frontend/locales/en.json",
178 | patterns: [
179 | "*"
180 | ]
181 | }
182 | ],
183 | sample: {
184 | enabled: nil
185 | }
186 | }
187 |
188 | plugin_class = Class.new(I18nJS::Plugin) do
189 | def self.name
190 | "SamplePlugin"
191 | end
192 |
193 | def setup
194 | I18nJS::Schema.root_keys << :sample
195 | end
196 | end
197 |
198 | I18nJS.register_plugin(plugin_class)
199 | I18nJS.initialize_plugins!(config:)
200 |
201 | error_message =
202 | "Expected \"sample.enabled\" to be one of [TrueClass, FalseClass]; " \
203 | "got NilClass instead"
204 |
205 | assert_schema_error(error_message) do
206 | I18nJS::Schema.validate!(config)
207 | end
208 | end
209 | end
210 |
--------------------------------------------------------------------------------
/MIGRATING_FROM_V3_TO_V4.md:
--------------------------------------------------------------------------------
1 | # Migrating from v3 to v4
2 |
3 | I18n-js v4 is a breaking change release and diverges quite a lot from how the
4 | previous version worked. This guides summarizes the process of upgrading an app
5 | that uses i18n-js v3 to v4.
6 |
7 | ## Development
8 |
9 | Previously, you could use a middleware to export translations (some people even
10 | used this in production 😬). In development, you can now use whatever your want,
11 | because i18n-js doesn't make any assumptions. All you need to do is running
12 | `i18n export`, either manually or by using something that listens to file
13 | changes.
14 |
15 | If you like watchman, you can use something like this:
16 |
17 | ```bash
18 | #!/usr/bin/env bash
19 |
20 | root=`pwd`
21 |
22 | watchman watch-del "$root"
23 | watchman watch-project "$root"
24 | watchman trigger-del "$root" i18n
25 |
26 | watchman -j <<-JSON
27 | [
28 | "trigger",
29 | "$root",
30 | {
31 | "name": "i18n",
32 | "expression": [
33 | "anyof",
34 | ["match", "config/locales/**/*.yml", "wholename"],
35 | ["match", "config/locales/**/*.po", "wholename"],
36 | ["match", "config/i18n.yml", "wholename"]
37 | ],
38 | "command": ["i18n", "export"]
39 | }
40 | ]
41 | JSON
42 |
43 | # If you're running this through Foreman, then uncomment the following lines:
44 | # while true; do
45 | # sleep 1
46 | # done
47 | ```
48 |
49 | You can also use guard. Make sure you have both
50 | [guard](https://rubygems.org/gems/guard) and
51 | [guard-compat](https://rubygems.org/gems/guard-compat) installed and use
52 | Guardfile file with the following contents:
53 |
54 | ```ruby
55 | guard(:"i18n-js",
56 | run_on_start: true,
57 | config_file: "./config/i18n.yml",
58 | require_file: "./config/environment.rb") do
59 | watch(%r{^config/locales/.+\.(yml|po)$})
60 | watch(%r{^config/i18n.yml$})
61 | watch("Gemfile")
62 | end
63 | ```
64 |
65 | To run guard, use `guard start -i`.
66 |
67 | Finally, you can use [listen](https://rubygems.org/gems/listen). Create the file
68 | `config/initializers/i18n.rb` with the following content:
69 |
70 | ```ruby
71 | Rails.application.config.after_initialize do
72 | require "i18n-js/listen"
73 | # This will only run in development.
74 | I18nJS.listen
75 | end
76 | ```
77 |
78 | > **Warning**:
79 | >
80 | > No matter which approach you choose, the idea is that you _precompile_ your
81 | > translations when going to production. DO NOT RUN any of the above in
82 | > production.
83 |
84 | ## Exporting translations
85 |
86 | The build process for i18n now relies on an external CLI called `i18n`. All you
87 | need to do is executing `i18n export` in your build step to generate the json
88 | files for your translations.
89 |
90 | ## Using your translations
91 |
92 | The JavaScript package is now a separate thing and need to be installed using
93 | your favorite tooling (e.g. yarn, npm, pnpm, etc).
94 |
95 | ```console
96 | $ yarn add i18n-js@latest
97 | $ npm i --save-dev i18n-js@latest
98 | ```
99 |
100 | From now on, the way you load translations and set up I18n-js is totally up to
101 | you, but means you need to load the json files and attach to the I18n-js
102 | instance. This is how I do it in a project I'm doing right now (Rails 7 +
103 | esbuild + TypeScript). First, we need to load the I18n-js configuration from the
104 | main JavaScript file:
105 |
106 | ```typescript
107 | // app/javascript/application.ts
108 | import { i18n } from "./config/i18n";
109 | ```
110 |
111 | Then we need to load our translations and instantiate the I18n-js class.
112 |
113 | ```typescript
114 | // app/javascript/config/i18n.ts
115 | import { I18n } from "i18n-js";
116 | import translations from "translations.json";
117 |
118 | // Fetch user locale from html#lang.
119 | // This value is being set on `app/views/layouts/application.html.erb` and
120 | // is inferred from `ACCEPT-LANGUAGE` header.
121 | const userLocale = document.documentElement.lang;
122 |
123 | export const i18n = new I18n();
124 | i18n.store(translations);
125 | i18n.defaultLocale = "en";
126 | i18n.enableFallback = true;
127 | i18n.locale = userLocale;
128 | ```
129 |
130 | The best thing about the above is that it is a pretty straightforward pattern in
131 | the JavaScript community. It doesn't rely on specific parts from Sprockets (I'm
132 | not even using it on my projects) or eRb files.
133 |
134 | ## Ruby on Rails
135 |
136 | ### Upgrading the configuration file
137 |
138 | The configuration file loaded from `config/i18n.yml` has changed. Given the v3
139 | configuration below
140 |
141 | ```yaml
142 | ---
143 | translations:
144 | - file: "app/assets/javascripts/date_formats.js"
145 | only: "*.date.formats"
146 | - file: "app/assets/javascripts/other.js"
147 | only: ["*.activerecord", "*.admin.*.title"]
148 | - file: "app/assets/javascripts/everything_else.js"
149 | except:
150 | - "*.activerecord"
151 | - "*.admin.*.title"
152 | - "*.date.formats"
153 | ```
154 |
155 | the equivalent configuration file for v4 would be
156 |
157 | ```yaml
158 | ---
159 | translations:
160 | - file: "app/assets/javascripts/date_formats.js"
161 | patterns:
162 | - "*.date.formats"
163 | - file: "app/assets/javascripts/other.js"
164 | patterns:
165 | - "*.activerecord"
166 | - "*.admin.*.title"
167 | - file: "app/assets/javascripts/everything_else.js"
168 | patterns:
169 | # Notice the exclamation mark.
170 | - "*"
171 | - "!*.activerecord"
172 | - "!*.admin.*.title"
173 | - "!*.date.formats"
174 | ```
175 |
176 | Other configuration options:
177 |
178 | - `export_i18n_js`: replaced by [export_files plugin](https://github.com/fnando/i18n-js#export_files)
179 | - `fallbacks`: replaced by [embed_fallback_translations plugin](https://github.com/fnando/i18n-js#embed_fallback_translations)
180 | - `js_available_locales`: removed (on v4 you can use groups, like in
181 | `{pt-BR,en}.*`)
182 | - `namespace`: removed without an equivalent
183 | - `sort_translation_keys`: removed (on v4 keys will always be sorted)
184 | - `translations[].prefix`: removed without an equivalent
185 | - `translations[].pretty_print`: removed (on v4 files will always be exported in
186 | a readable format)
187 |
188 | ### Placeholders
189 |
190 | Previously, v3 had the `%{locale}` placeholder, which can be used as part of the
191 | directory and/or file name. Now, the syntax is just `:locale`. Additionally, you
192 | can also use `:digest`, which uses a MD5 hex digest of the exported file.
193 |
--------------------------------------------------------------------------------
/test/i18n-js/exporter_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | class ExporterTest < Minitest::Test
6 | test "fails when neither config_file nor config is set" do
7 | assert_raises(I18nJS::MissingConfigError) do
8 | I18nJS.call(config_file: nil, config: nil)
9 | end
10 | end
11 |
12 | test "exports all translations" do
13 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"]
14 | actual_files = I18nJS.call(config_file: "./test/config/everything.yml")
15 |
16 | assert_exported_files ["test/output/everything.json"], actual_files
17 | assert_json_file "test/fixtures/expected/everything.json",
18 | "test/output/everything.json"
19 | end
20 |
21 | test "exports all translations (json config)" do
22 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"]
23 | actual_files = I18nJS.call(config_file: "./test/config/everything.json")
24 |
25 | assert_exported_files ["test/output/everything.json"], actual_files
26 | assert_json_file "test/fixtures/expected/everything.json",
27 | "test/output/everything.json"
28 | end
29 |
30 | test "exports all translations using config object" do
31 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"]
32 | actual_files = I18nJS.call(
33 | config: {
34 | translations: [
35 | {
36 | file: "test/output/everything.json",
37 | patterns: ["*"]
38 | }
39 | ]
40 | }
41 | )
42 |
43 | assert_exported_files ["test/output/everything.json"], actual_files
44 | assert_json_file "test/fixtures/expected/everything.json",
45 | "test/output/everything.json"
46 | end
47 |
48 | test "exports all translations using gettext backend" do
49 | I18n.backend = GettextBackend.new
50 | I18n.load_path << Dir["./test/fixtures/po/*.po"]
51 | actual_files = I18nJS.call(config_file: "./test/config/everything.yml")
52 |
53 | assert_exported_files ["test/output/everything.json"], actual_files
54 | assert_json_file "test/fixtures/expected/everything.json",
55 | "test/output/everything.json"
56 | end
57 |
58 | test "exports specific paths" do
59 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"]
60 | actual_files = I18nJS.call(config_file: "./test/config/specific.yml")
61 |
62 | assert_exported_files ["test/output/specific.json"], actual_files
63 | assert_json_file "test/fixtures/expected/specific.json",
64 | "test/output/specific.json"
65 | end
66 |
67 | test "exports multiple files" do
68 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"]
69 | actual_files =
70 | I18nJS.call(config_file: "./test/config/multiple_files.yml")
71 |
72 | assert_exported_files ["test/output/es.json", "test/output/pt.json"],
73 | actual_files
74 | assert_json_file "test/fixtures/expected/multiple_files/es.json",
75 | "test/output/es.json"
76 | assert_json_file "test/fixtures/expected/multiple_files/pt.json",
77 | "test/output/pt.json"
78 | end
79 |
80 | test "exports multiple files using :locale" do
81 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"]
82 | actual_files =
83 | I18nJS.call(config_file: "./test/config/locale_placeholder.yml")
84 |
85 | expected_files = [
86 | "test/output/en.json",
87 | "test/output/es.json",
88 | "test/output/pt.json"
89 | ]
90 |
91 | assert_exported_files expected_files,
92 | actual_files
93 | assert_json_file "test/fixtures/expected/multiple_files/es.json",
94 | "test/output/es.json"
95 | assert_json_file "test/fixtures/expected/multiple_files/pt.json",
96 | "test/output/pt.json"
97 | end
98 |
99 | test "exports multiple files using :locale as dirname" do
100 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"]
101 | actual_files =
102 | I18nJS.call(config_file: "./test/config/locale_placeholder_dir.yml")
103 |
104 | expected_files = [
105 | "test/output/en/translations.json",
106 | "test/output/es/translations.json",
107 | "test/output/pt/translations.json"
108 | ]
109 |
110 | assert_exported_files expected_files,
111 | actual_files
112 | assert_json_file "test/fixtures/expected/multiple_files/es.json",
113 | "test/output/es/translations.json"
114 | assert_json_file "test/fixtures/expected/multiple_files/pt.json",
115 | "test/output/pt/translations.json"
116 | end
117 |
118 | test "exports files using :digest" do
119 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"]
120 | actual_files = I18nJS.call(config_file: "./test/config/digest.yml")
121 |
122 | expected_files = [
123 | "test/output/en.677728247a2f2111271f43d6a9c07d1a.json",
124 | "test/output/es.d69fc73259977c7d14254b019ff85ec5.json",
125 | "test/output/pt.c7ff3b8cc02447b25a1375854ea718f5.json"
126 | ]
127 |
128 | assert_exported_files expected_files, actual_files
129 | assert_json_file "test/fixtures/expected/multiple_files/en.json",
130 | "test/output/en.677728247a2f2111271f43d6a9c07d1a.json"
131 |
132 | assert_json_file "test/fixtures/expected/multiple_files/es.json",
133 | "test/output/es.d69fc73259977c7d14254b019ff85ec5.json"
134 |
135 | assert_json_file "test/fixtures/expected/multiple_files/pt.json",
136 | "test/output/pt.c7ff3b8cc02447b25a1375854ea718f5.json"
137 | end
138 |
139 | test "exports files using groups" do
140 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"]
141 | actual_files = I18nJS.call(config_file: "./test/config/group.yml")
142 |
143 | expected_files = ["test/output/group.json"]
144 |
145 | assert_exported_files expected_files, actual_files
146 | assert_json_file "test/fixtures/expected/group.json",
147 | "test/output/group.json"
148 | end
149 |
150 | test "exports files using erb" do
151 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"]
152 | actual_files = I18nJS.call(config_file: "./test/config/config.yml.erb")
153 |
154 | expected_files = ["test/output/everything.json"]
155 |
156 | assert_exported_files expected_files, actual_files
157 | assert_json_file "test/fixtures/expected/everything.json",
158 | "test/output/everything.json"
159 | end
160 |
161 | test "exports files piping translation through plugins" do
162 | plugin_class = Class.new(I18nJS::Plugin) do
163 | def self.name
164 | "SamplePlugin"
165 | end
166 |
167 | def setup
168 | I18nJS::Schema.root_keys << config_key
169 | end
170 |
171 | def transform(translations:)
172 | translations.each_key do |locale|
173 | translations[locale][:injected] = "yes:#{locale}"
174 | end
175 |
176 | translations
177 | end
178 | end
179 |
180 | config = Glob::SymbolizeKeys.call(
181 | I18nJS.load_config_file("./test/config/everything.yml")
182 | .merge(sample: {enabled: true})
183 | )
184 | I18nJS.register_plugin(plugin_class)
185 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"]
186 | I18nJS.call(config:)
187 |
188 | assert_json_file "test/fixtures/expected/transformed.json",
189 | "test/output/everything.json"
190 | end
191 |
192 | test "does not overwrite exported files if identical" do
193 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"]
194 | exported_file_path = "test/output/everything.json"
195 |
196 | # First run
197 | actual_files = I18nJS.call(config_file: "./test/config/everything.yml")
198 |
199 | assert_exported_files [exported_file_path], actual_files
200 | exported_file_mtime = File.mtime(exported_file_path)
201 |
202 | sleep 0.1
203 |
204 | # Second run
205 | I18nJS.call(config_file: "./test/config/everything.yml")
206 |
207 | # mtime should be the same
208 | assert_equal exported_file_mtime, File.mtime(exported_file_path)
209 | end
210 |
211 | test "overwrites exported files if not identical" do
212 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"]
213 | exported_file_path = "test/output/everything.json"
214 |
215 | # First run
216 | actual_files = I18nJS.call(config_file: "./test/config/everything.yml")
217 |
218 | assert_exported_files [exported_file_path], actual_files
219 |
220 | # Change content of existed exported file (add space to the end of file).
221 | File.open(exported_file_path, "a") {|f| f << " " }
222 | exported_file_mtime = File.mtime(exported_file_path)
223 |
224 | sleep 0.1
225 |
226 | # Second run
227 | I18nJS.call(config_file: "./test/config/everything.yml")
228 |
229 | # File should overwritten to the correct one.
230 | assert_json_file "test/fixtures/expected/everything.json",
231 | exported_file_path
232 |
233 | # mtime should be newer
234 | assert_operator File.mtime(exported_file_path), :>, exported_file_mtime
235 | end
236 |
237 | test "cleans hash when exporting files" do
238 | I18n.backend.store_translations(:en, {a: 1, b: {c: -> { }, d: 4}})
239 |
240 | actual_files = I18nJS.call(config_file: "./test/config/everything.yml")
241 |
242 | assert_exported_files ["test/output/everything.json"], actual_files
243 | assert_json_file "test/fixtures/expected/clean_hash.json",
244 | "test/output/everything.json"
245 | end
246 | end
247 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Export i18n translations to JSON.
7 |
8 | A perfect fit if you want to export translations to JavaScript.
9 |
10 |
11 |
12 |
13 | Oh, you don't use Ruby? No problem! You can still use i18n-js
14 |
15 | and the
16 | companion JavaScript package.
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | ## Installation
28 |
29 | ```bash
30 | gem install i18n-js
31 | ```
32 |
33 | Or add the following line to your project's Gemfile:
34 |
35 | ```ruby
36 | gem "i18n-js"
37 | ```
38 |
39 | Create a default configuration file in ./config/i18n.yml
40 |
41 | ```bash
42 | i18n init
43 | ```
44 |
45 | ## Usage
46 |
47 | About patterns:
48 |
49 | - Patterns can use `*` as a wildcard and can appear more than once.
50 | - `*` will include everything
51 | - `*.messages.*`
52 | - Patterns starting with `!` are excluded.
53 | - `!*.activerecord.*` will exclude all ActiveRecord translations.
54 | - You can use groups:
55 | - `{pt-BR,en}.js.*` will include only `pt-BR` and `en` translations, even if
56 | more languages are available.
57 |
58 | > **Note**:
59 | >
60 | > Patterns use [glob](https://rubygems.org/gems/glob), so check it out for the
61 | > most up-to-date documentation about what's available.
62 |
63 | The config file:
64 |
65 | ```yml
66 | ---
67 | translations:
68 | - file: app/frontend/locales/en.json
69 | patterns:
70 | - "*"
71 | - "!*.activerecord"
72 | - "!*.errors"
73 | - "!*.number.nth"
74 |
75 | - file: app/frontend/locales/:locale.:digest.json
76 | patterns:
77 | - "*"
78 | ```
79 |
80 | The output path can use the following placeholders:
81 |
82 | - `:locale` - the language that's being exported.
83 | - `:digest` - the MD5 hex digest of the exported file.
84 |
85 | The example above could generate a file named
86 | `app/frontend/locales/en.7bdc958e33231eafb96b81e3d108eff3.json`.
87 |
88 | The config file is processed as erb, so you can have dynamic content on it if
89 | you want. The following example shows how to use groups from a variable.
90 |
91 | ```yml
92 | ---
93 | <% group = "{en,pt}" %>
94 |
95 | translations:
96 | - file: app/frontend/translations.json
97 | patterns:
98 | - "<%= group %>.*"
99 | - "!<%= group %>.activerecord"
100 | - "!<%= group %>.errors"
101 | - "!<%= group %>.number.nth"
102 | ```
103 |
104 | ### Exporting locale.yml to locale.json
105 |
106 | Your i18n yaml file can be exported to JSON using the Ruby API or the command
107 | line utility. Examples of both approaches are provided below:
108 |
109 | The Ruby API:
110 |
111 | ```ruby
112 | require "i18n-js"
113 |
114 | # The following call performs the same task as the CLI `i18n export` command
115 | I18nJS.call(config_file: "config/i18n.yml")
116 |
117 | # You can provide the config directly using the following
118 | config = {
119 | "translations"=>[
120 | {"file"=>"app/javascript/locales/:locale.json", "patterns"=>["*"]}
121 | ]
122 | }
123 |
124 | I18nJS.call(config: config)
125 | #=> ["app/javascript/locales/de.json", "app/javascript/locales/en.json"]
126 | ```
127 |
128 | The CLI API:
129 |
130 | ```console
131 | $ i18n --help
132 | Usage: i18n COMMAND FLAGS
133 |
134 | Commands:
135 |
136 | - init: Initialize a project
137 | - export: Export translations as JSON files
138 | - version: Show package version
139 | - plugins: List plugins that will be activated
140 | - lint:translations: Check for missing translations
141 | - lint:scripts: Lint files using TypeScript
142 |
143 | Run `i18n COMMAND --help` for more information on specific commands.
144 | ```
145 |
146 | By default, `i18n` will use `config/i18n.yml` and `config/environment.rb` as the
147 | configuration files. If you don't have these files, then you'll need to specify
148 | both `--config` and `--require`.
149 |
150 | ### Plugins
151 |
152 | #### Built-in plugins:
153 |
154 | ##### `embed_fallback_translations`:
155 |
156 | Embed fallback translations inferred from the default locale. This can be useful
157 | in cases where you have multiple large translation files and don't want to load
158 | the default locale together with the target locale.
159 |
160 | To use it, add the following to your configuration file:
161 |
162 | ```yaml
163 | ---
164 | embed_fallback_translations:
165 | enabled: true
166 | ```
167 |
168 | ##### `export_files`:
169 |
170 | By default, i18n-js will export only JSON files out of your translations. This
171 | plugin allows exporting other file formats. To use it, add the following to your
172 | configuration file:
173 |
174 | ```yaml
175 | ---
176 | export_files:
177 | enabled: true
178 | files:
179 | - template: path/to/template.erb
180 | output: "%{dir}/%{base_name}.ts"
181 | ```
182 |
183 | You can export multiple files by defining more entries.
184 |
185 | The output name can use the following placeholders:
186 |
187 | - `%{dir}`: the directory where the translation file is.
188 | - `%{name}`: file name with extension.
189 | - `%{base_name}`: file name without extension.
190 | - `%{digest}`: MD5 hexdigest from the generated file.
191 |
192 | The template file must be a valid eRB template. You can execute arbitrary Ruby
193 | code, so be careful. An example of how you can generate a file can be seen
194 | below:
195 |
196 | ```erb
197 | /* eslint-disable */
198 | <%= banner %>
199 |
200 | import { i18n } from "config/i18n";
201 |
202 | i18n.store(<%= JSON.pretty_generate(translations) %>);
203 | ```
204 |
205 | This template is loading the instance from `config/i18n` and storing the
206 | translations that have been loaded. The
207 | `banner(comment: "// ", include_time: true)` method is built-in. The generated
208 | file will look something like this:
209 |
210 | ```typescript
211 | /* eslint-disable */
212 | // File generated by i18n-js on 2022-12-10 15:37:00 +0000
213 |
214 | import { i18n } from "config/i18n";
215 |
216 | i18n.store({
217 | en: {
218 | "bunny rabbit adventure": "bunny rabbit adventure",
219 | "hello sunshine!": "hello sunshine!",
220 | "time for bed!": "time for bed!",
221 | },
222 | es: {
223 | "bunny rabbit adventure": "conejito conejo aventura",
224 | bye: "adios",
225 | "time for bed!": "hora de acostarse!",
226 | },
227 | pt: {
228 | "bunny rabbit adventure": "a aventura da coelhinha",
229 | bye: "tchau",
230 | "time for bed!": "hora de dormir!",
231 | },
232 | });
233 | ```
234 |
235 | #### Plugin API
236 |
237 | You can transform the exported translations by adding plugins. A plugin must
238 | inherit from `I18nJS::Plugin` and can have 4 class methods (they're all optional
239 | and will default to a noop implementation). For real examples, see
240 | [lib/i18n-js/embed_fallback_translations_plugin.rb](https://github.com/fnando/i18n-js/blob/main/lib/i18n-js/embed_fallback_translations_plugin.rb)
241 | and
242 | [lib/i18n-js/export_files_plugin.rb](https://github.com/fnando/i18n-js/blob/main/lib/i18n-js/export_files_plugin.rb)
243 |
244 | ```ruby
245 | # frozen_string_literal: true
246 |
247 | module I18nJS
248 | class SamplePlugin < I18nJS::Plugin
249 | # This method is responsible for transforming the translations. The
250 | # translations you'll receive may be already be filtered by other plugins
251 | # and by the default filtering itself. If you need to access the original
252 | # translations, use `I18nJS.translations`.
253 | def transform(translations:)
254 | # transform `translations` here…
255 |
256 | translations
257 | end
258 |
259 | # In case your plugin accepts configuration, this is where you must validate
260 | # the configuration, making sure only valid keys and type is provided.
261 | # If the configuration contains invalid data, then you must raise an
262 | # exception using something like
263 | # `raise I18nJS::Schema::InvalidError, error_message`.
264 | #
265 | # Notice the validation will only happen when the plugin configuration is
266 | # set (i.e. the configuration contains your config key).
267 | def validate_schema
268 | # validate plugin schema here…
269 | end
270 |
271 | # This method must set up the basic plugin configuration, like adding the
272 | # config's root key in case your plugin accepts configuration (defined via
273 | # the config file).
274 | #
275 | # If you don't add this key, the linter will prevent non-default keys from
276 | # being added to the configuration file.
277 | def setup
278 | # If you plugin has configuration, uncomment the line below
279 | # I18nJS::Schema.root_keys << config_key
280 | end
281 |
282 | # This method is called whenever `I18nJS.call(**kwargs)` finishes exporting
283 | # JSON files based on your configuration.
284 | #
285 | # You can use it to further process exported files, or generate new files
286 | # based on the translations that have been exported.
287 | def after_export(files:)
288 | # process exported files here…
289 | end
290 | end
291 | end
292 | ```
293 |
294 | The class `I18nJS::Plugin` implements some helper methods that you can use:
295 |
296 | - `I18nJS::Plugin#config_key`: the configuration key that was inferred out of
297 | your plugin's class name.
298 | - `I18nJS::Plugin#config`: the plugin configuration.
299 | - `I18nJS::Plugin#enabled?`: whether the plugin is enabled or not based on the
300 | plugin's configuration.
301 |
302 | To distribute this plugin, you need to create a gem package that matches the
303 | pattern `i18n-js/*_plugin.rb`. You can test whether your plugin will be found by
304 | installing your gem, opening a iRB session and running
305 | `Gem.find_files("i18n-js/*_plugin.rb")`. If your plugin is not listed, then you
306 | need to double check your gem load path and see why the file is not being
307 | loaded.
308 |
309 | ### Listing missing translations
310 |
311 | To list missing and extraneous translations, you can use
312 | `i18n lint:translations`. This command will load your translations similarly to
313 | how `i18n export` does, but will output the list of keys that don't have a
314 | matching translation against the default locale. Here's an example:
315 |
316 | ```console
317 | $ i18n lint:translations
318 | => Config file: "./config/i18n.yml"
319 | => Require file: "./config/environment.rb"
320 | => Check "./config/i18n.yml" for ignored keys.
321 | => en: 232 translations
322 | => pt-BR: 5 missing, 1 extraneous, 1 ignored
323 | - pt-BR.actors.github.metrics (missing)
324 | - pt-BR.actors.github.metrics_hint (missing)
325 | - pt-BR.actors.github.repo_metrics (missing)
326 | - pt-BR.actors.github.repository (missing)
327 | - pt-BR.actors.github.user_metrics (missing)
328 | - pt-BR.github.repository (extraneous)
329 | ```
330 |
331 | This command will exit with status 1 whenever there are missing translations.
332 | This way you can use it as a CI linting tool.
333 |
334 | You can ignore keys by adding a list to the config file:
335 |
336 | ```yml
337 | ---
338 | translations:
339 | - file: app/frontend/locales/en.json
340 | patterns:
341 | - "*"
342 | - "!*.activerecord"
343 | - "!*.errors"
344 | - "!*.number.nth"
345 |
346 | - file: app/frontend/locales/:locale.:digest.json
347 | patterns:
348 | - "*"
349 |
350 | lint_translations:
351 | ignore:
352 | - en.mailer.login.subject
353 | - en.mailer.login.body
354 | ```
355 |
356 | > **Note**:
357 | >
358 | > In order to avoid mistakenly ignoring keys, this configuration option only
359 | > accepts the full translation scope, rather than accepting a pattern like
360 | > `pt.ignored.scope.*`.
361 |
362 | ### Linting your JavaScript/TypeScript files
363 |
364 | To lint your script files and check for missing translations (which can signal
365 | that you're either using wrong scopes or forgot to add the translation), use
366 | `i18n lint:scripts`. This command will parse your JavaScript/TypeScript files
367 | and extract all scopes being used. This command requires a Node.js runtime. You
368 | can either specify one via `--node-path`, or let the plugin infer a binary from
369 | your `$PATH`.
370 |
371 | The comparison will be made against the export JSON files, which means it'll
372 | consider transformations performed by plugins (e.g. the output files may be
373 | affected by `embed_fallback_translations` plugin).
374 |
375 | The translations that will be extract must be called as one of the following
376 | ways:
377 |
378 | - `i18n.t(scope, options)`
379 | - `i18n.translate(scope, options)`
380 | - `t(scope, options)`
381 |
382 | Notice that only literal strings can be used, as in `i18n.t("message")`. If
383 | you're using dynamic scoping through variables (e.g.
384 | `const scope = "message"; i18n.t(scope)`), they will be skipped.
385 |
386 | ```console
387 | $ i18n lint:scripts
388 | => Config file: "./config/i18n.yml"
389 | => Require file: "./config/environment.rb"
390 | => Node: "/Users/fnando/.asdf/shims/node"
391 | => Available locales: [:en, :es, :pt]
392 | => Patterns: ["!(node_modules)/**/*.js", "!(node_modules)/**/*.ts", "!(node_modules)/**/*.jsx", "!(node_modules)/**/*.tsx"]
393 | => 9 translations, 11 missing, 4 ignored
394 | - test/scripts/lint/file.js:1:1: en.js.missing
395 | - test/scripts/lint/file.js:1:1: es.js.missing
396 | - test/scripts/lint/file.js:1:1: pt.js.missing
397 | - test/scripts/lint/file.js:2:8: en.base.js.missing
398 | - test/scripts/lint/file.js:2:8: es.base.js.missing
399 | - test/scripts/lint/file.js:2:8: pt.base.js.missing
400 | - test/scripts/lint/file.js:4:8: en.js.missing
401 | - test/scripts/lint/file.js:4:8: es.js.missing
402 | - test/scripts/lint/file.js:4:8: pt.js.missing
403 | - test/scripts/lint/file.js:6:1: en.another_ignore_scope
404 | - test/scripts/lint/file.js:6:1: es.another_ignore_scope
405 | ```
406 |
407 | This command will list all locales and their missing translations. To avoid
408 | listing a particular translation, you can set `lint_scripts.ignore` or
409 | `lint_translations.ignore` in your config file.
410 |
411 | ```yaml
412 | ---
413 | translations:
414 | - file: app/frontend/translations.json
415 | patterns:
416 | - "*"
417 |
418 | lint_scripts:
419 | ignore:
420 | - ignore_scope # will ignore this scope on all languages
421 | - pt.another_ignore_scope # will ignore this scope only on `pt`
422 | ```
423 |
424 | You can also set the patterns that will be looked up. By default, it scans all
425 | JavaScript and TypeScript files that don't live on `node_modules`.
426 |
427 | ```yaml
428 | ---
429 | translations:
430 | - file: app/frontend/translations.json
431 | patterns:
432 | - "*"
433 |
434 | lint_scripts:
435 | patterns:
436 | - "app/assets/**/*.ts"
437 | ```
438 |
439 | ## Automatically export translations
440 |
441 | ### Using [watchman](https://facebook.github.io/watchman/)
442 |
443 | Create a script at `bin/i18n-watch`.
444 |
445 | ```bash
446 | #!/usr/bin/env bash
447 |
448 | root=`pwd`
449 |
450 | watchman watch-del "$root"
451 | watchman watch-project "$root"
452 | watchman trigger-del "$root" i18n
453 |
454 | watchman -j <<-JSON
455 | [
456 | "trigger",
457 | "$root",
458 | {
459 | "name": "i18n",
460 | "expression": [
461 | "anyof",
462 | ["match", "config/locales/**/*.yml", "wholename"],
463 | ["match", "config/i18n.yml", "wholename"]
464 | ],
465 | "command": ["i18n", "export"]
466 | }
467 | ]
468 | JSON
469 |
470 | # If you're running this through Foreman,
471 | # then uncomment the following lines:
472 | # while true; do
473 | # sleep 1
474 | # done
475 | ```
476 |
477 | Make it executable with `chmod +x bin/i18n-watch`. To watch for changes, run
478 | `./bin/i18n-watch`. If you're using Foreman, make sure you uncommented the lines
479 | that keep the process running (`while..`), and add something like the following
480 | line to your Procfile:
481 |
482 | ```
483 | i18n: ./bin/i18n-watch
484 | ```
485 |
486 | ### Using [guard](https://rubygems.org/gems/guard)
487 |
488 | Install [guard](https://rubygems.org/gems/guard) and
489 | [guard-compat](https://rubygems.org/gems/guard-compat). Then create a Guardfile
490 | with the following configuration:
491 |
492 | ```ruby
493 | guard(:"i18n-js",
494 | run_on_start: true,
495 | config_file: "./config/i18n.yml",
496 | require_file: "./config/environment.rb") do
497 | watch(%r{^(app|config)/locales/.+\.(yml|po)$})
498 | watch(%r{^config/i18n.yml$})
499 | watch("Gemfile")
500 | end
501 | ```
502 |
503 | If your files are located in a different path, remember to configure file paths
504 | accordingly.
505 |
506 | Now you can run `guard start -i`.
507 |
508 | ### Using [listen](https://rubygems.org/gems/listen)
509 |
510 | Create a file under `config/initializers/i18n.rb` with the following content:
511 |
512 | ```ruby
513 | Rails.application.config.after_initialize do
514 | require "i18n-js/listen"
515 | I18nJS.listen
516 | end
517 | ```
518 |
519 | The code above will watch for changes based on `config/i18n.yml` and
520 | `config/locales`. You can customize these options:
521 |
522 | - `config_file` - i18n-js configuration file
523 | - `locales_dir` - one or multiple directories to watch for locales changes
524 | - `options` - passed directly to
525 | [listen](https://github.com/guard/listen/#options)
526 | - `run_on_start` - export files on start. Defaults to `true`. When disabled,
527 | files will be exported only when there are file changes.
528 |
529 | Example:
530 |
531 | ```ruby
532 | I18nJS.listen(
533 | config_file: "config/i18n.yml",
534 | locales_dir: ["config/locales", "app/views"],
535 | options: {only: %r{.yml$}},
536 | run_on_start: false
537 | )
538 | ```
539 |
540 | ### Integrating with your frontend
541 |
542 | You're done exporting files, now what? Well, go to
543 | [i18n](https://github.com/fnando/i18n) to discover how to use the NPM package
544 | that loads all the exported translation.
545 |
546 | ### FAQ
547 |
548 | #### I'm running v3. Is there a migration plan?
549 |
550 | [There's a document](https://github.com/fnando/i18n-js/tree/main/MIGRATING_FROM_V3_TO_V4.md)
551 | outlining some of the things you need to do to migrate from v3 to v4. It may not
552 | be as complete as we'd like it to be, so let us know if you face any issues
553 | during the migration that is not outlined in that document.
554 |
555 | #### How can I export translations without having a database around?
556 |
557 | Some people may have a build process using something like Docker that don't
558 | necessarily have a database available. In this case, you may define your own
559 | loading file by using something like
560 | `i18n export --require ./config/i18n_export.rb`, where `i18n_export.rb` may look
561 | like this:
562 |
563 | ```ruby
564 | # frozen_string_literal: true
565 |
566 | require "bundler/setup"
567 | require "rails"
568 | require "active_support/railtie"
569 | require "action_view/railtie"
570 |
571 | I18n.load_path += Dir["./config/locales/**/*.yml"]
572 | ```
573 |
574 | > **Note**:
575 | >
576 | > You may not need to load the ActiveSupport and ActionView lines, or you may
577 | > need to add additional requires for other libs. With this approach you have
578 | > full control on what's going to be loaded.
579 |
580 | ## Maintainer
581 |
582 | - [Nando Vieira](https://github.com/fnando)
583 |
584 | ## Contributors
585 |
586 | - https://github.com/fnando/i18n-js/contributors
587 |
588 | ## Contributing
589 |
590 | For more details about how to contribute, please read
591 | https://github.com/fnando/i18n-js/blob/main/CONTRIBUTING.md.
592 |
593 | ## License
594 |
595 | The gem is available as open source under the terms of the
596 | [MIT License](https://opensource.org/licenses/MIT). A copy of the license can be
597 | found at https://github.com/fnando/i18n-js/blob/main/LICENSE.md.
598 |
599 | ## Code of Conduct
600 |
601 | Everyone interacting in the i18n-js project's codebases, issue trackers, chat
602 | rooms and mailing lists is expected to follow the
603 | [code of conduct](https://github.com/fnando/i18n-js/blob/main/CODE_OF_CONDUCT.md).
604 |
--------------------------------------------------------------------------------