├── test ├── config │ ├── require_error.rb │ ├── everything.yml │ ├── digest.yml │ ├── locale_placeholder.yml │ ├── config.yml.erb │ ├── group.yml │ ├── locale_placeholder_dir.yml │ ├── specific.yml │ ├── everything.json │ ├── require.rb │ ├── multiple_files.yml │ ├── embed_fallback_translations.yml │ ├── check.yml │ ├── lint_translations.yml │ ├── export_files.yml │ └── lint_scripts.yml ├── fixtures │ ├── expected │ │ ├── clean_hash.json │ │ ├── group.json │ │ ├── multiple_files │ │ │ ├── pt.json │ │ │ ├── es.json │ │ │ └── en.json │ │ ├── specific.json │ │ ├── everything.json │ │ ├── transformed.json │ │ ├── export_files.ts │ │ ├── lint.txt │ │ └── embed_fallback_translations.json │ ├── yml │ │ ├── pt.yml │ │ ├── es.yml │ │ └── en.yml │ ├── export_files.erb │ ├── po │ │ ├── es.po │ │ ├── pt.po │ │ └── en.po │ └── embed │ │ └── translations.yml ├── support │ ├── backends.rb │ ├── helpers.rb │ └── minitest.rb ├── scripts │ └── lint │ │ └── file.js ├── i18n-js │ ├── clean_hash_test.rb │ ├── cli │ │ ├── root_command_test.rb │ │ ├── version_command_test.rb │ │ ├── ui_test.rb │ │ ├── init_command_test.rb │ │ ├── plugins_command_test.rb │ │ ├── lint_scripts_command_test.rb │ │ ├── export_command_test.rb │ │ ├── lint_translations_command_test.rb │ │ └── check_command_test.rb │ ├── sort_hash_test.rb │ ├── embed_fallback_translations_plugin_test.rb │ ├── export_files_plugin_test.rb │ ├── plugin_test.rb │ ├── schema_test.rb │ └── exporter_test.rb └── test_helper.rb ├── images └── i18njs.png ├── .github ├── FUNDING.yml ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md ├── dependabot.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── ruby-tests.yml ├── Gemfile ├── lib ├── i18n-js │ ├── version.rb │ ├── clean_hash.rb │ ├── sort_hash.rb │ ├── cli │ │ ├── version_command.rb │ │ ├── check_command.rb │ │ ├── init_command.rb │ │ ├── ui.rb │ │ ├── plugins_command.rb │ │ ├── command.rb │ │ ├── export_command.rb │ │ ├── lint_scripts_command.rb │ │ └── lint_translations_command.rb │ ├── cli.rb │ ├── embed_fallback_translations_plugin.rb │ ├── listen.rb │ ├── export_files_plugin.rb │ ├── plugin.rb │ ├── lint.ts │ └── schema.rb ├── guard │ ├── i18n-js │ │ ├── version.rb │ │ └── templates │ │ │ └── Guardfile │ └── i18n-js.rb └── i18n-js.rb ├── exe └── i18n ├── .gitignore ├── package.json ├── Rakefile ├── .rubocop.yml ├── LICENSE.md ├── i18n-js.gemspec ├── bin └── release ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── MIGRATING_FROM_V3_TO_V4.md └── README.md /test/config/require_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | raise "💣" 4 | -------------------------------------------------------------------------------- /images/i18njs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fnando/i18n-js/HEAD/images/i18njs.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | github: [fnando] 3 | custom: ["https://paypal.me/nandovieira/🍕"] 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /lib/i18n-js/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module I18nJS 4 | VERSION = "4.2.4" 5 | end 6 | -------------------------------------------------------------------------------- /test/config/everything.yml: -------------------------------------------------------------------------------- 1 | --- 2 | translations: 3 | - file: test/output/everything.json 4 | patterns: 5 | - "*" 6 | -------------------------------------------------------------------------------- /test/config/digest.yml: -------------------------------------------------------------------------------- 1 | --- 2 | translations: 3 | - file: test/output/:locale.:digest.json 4 | patterns: 5 | - "*" 6 | -------------------------------------------------------------------------------- /test/config/locale_placeholder.yml: -------------------------------------------------------------------------------- 1 | --- 2 | translations: 3 | - file: test/output/:locale.json 4 | patterns: 5 | - "*" 6 | -------------------------------------------------------------------------------- /test/fixtures/expected/clean_hash.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "a": 1, 4 | "b": { 5 | "d": 4 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/config/config.yml.erb: -------------------------------------------------------------------------------- 1 | --- 2 | translations: 3 | - file: <%= "test/output/everything.json" %> 4 | patterns: 5 | - "*" 6 | -------------------------------------------------------------------------------- /test/config/group.yml: -------------------------------------------------------------------------------- 1 | --- 2 | translations: 3 | - file: test/output/group.json 4 | patterns: 5 | - "{pt,en}.time for bed!" 6 | -------------------------------------------------------------------------------- /test/config/locale_placeholder_dir.yml: -------------------------------------------------------------------------------- 1 | --- 2 | translations: 3 | - file: test/output/:locale/translations.json 4 | patterns: 5 | - "*" 6 | -------------------------------------------------------------------------------- /test/fixtures/yml/pt.yml: -------------------------------------------------------------------------------- 1 | --- 2 | pt: 3 | bye: "tchau" 4 | bunny rabbit adventure: a aventura da coelhinha 5 | time for bed!: hora de dormir! 6 | -------------------------------------------------------------------------------- /test/config/specific.yml: -------------------------------------------------------------------------------- 1 | --- 2 | translations: 3 | - file: test/output/specific.json 4 | patterns: 5 | - "*.bye" 6 | - "*.time for bed!" 7 | -------------------------------------------------------------------------------- /test/fixtures/export_files.erb: -------------------------------------------------------------------------------- 1 | <%= banner %> 2 | 3 | import { i18n } from "config/i18n"; 4 | 5 | i18n.store(<%= JSON.pretty_generate(translations) %>); 6 | -------------------------------------------------------------------------------- /test/fixtures/yml/es.yml: -------------------------------------------------------------------------------- 1 | --- 2 | es: 3 | bye: "adios" 4 | bunny rabbit adventure: conejito conejo aventura 5 | time for bed!: hora de acostarse! 6 | -------------------------------------------------------------------------------- /test/support/backends.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class GettextBackend < I18n::Backend::Simple 4 | include I18n::Backend::Gettext 5 | end 6 | -------------------------------------------------------------------------------- /test/config/everything.json: -------------------------------------------------------------------------------- 1 | { 2 | "translations": [ 3 | { 4 | "file": "test/output/everything.json", 5 | "patterns": ["*"] 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /test/fixtures/yml/en.yml: -------------------------------------------------------------------------------- 1 | --- 2 | en: 3 | hello sunshine!: hello sunshine! 4 | bunny rabbit adventure: bunny rabbit adventure 5 | time for bed!: time for bed! 6 | -------------------------------------------------------------------------------- /test/fixtures/expected/group.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "time for bed!": "time for bed!" 4 | }, 5 | "pt": { 6 | "time for bed!": "hora de dormir!" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # You can read more about CODEOWNERS at 2 | # https://help.github.com/github/creating-cloning-and-archiving-repositories/about-code-owners 3 | 4 | * @fnando 5 | -------------------------------------------------------------------------------- /exe/i18n: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require_relative "../lib/i18n-js/cli" 5 | I18nJS::CLI.new(argv: ARGV, stdout: $stdout, stderr: $stderr).call 6 | -------------------------------------------------------------------------------- /test/config/require.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | I18n.available_locales = %i[en es pt] 4 | I18n.default_locale = :en 5 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"] 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /vendor/ 10 | *.log 11 | *.lock 12 | /lib/**/*.js 13 | /test/output 14 | -------------------------------------------------------------------------------- /test/config/multiple_files.yml: -------------------------------------------------------------------------------- 1 | --- 2 | translations: 3 | - file: test/output/es.json 4 | patterns: 5 | - "es.*" 6 | - file: test/output/pt.json 7 | patterns: 8 | - "pt.*" 9 | -------------------------------------------------------------------------------- /test/fixtures/po/es.po: -------------------------------------------------------------------------------- 1 | msgid "bye" 2 | msgstr "adios" 3 | 4 | msgid "bunny rabbit adventure" 5 | msgstr "conejito conejo aventura" 6 | 7 | msgid "time for bed!" 8 | msgstr "hora de acostarse!" 9 | -------------------------------------------------------------------------------- /test/fixtures/po/pt.po: -------------------------------------------------------------------------------- 1 | msgid "bye" 2 | msgstr "tchau" 3 | 4 | msgid "bunny rabbit adventure" 5 | msgstr "a aventura da coelhinha" 6 | 7 | msgid "time for bed!" 8 | msgstr "hora de dormir!" 9 | -------------------------------------------------------------------------------- /test/fixtures/expected/multiple_files/pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "pt": { 3 | "bunny rabbit adventure": "a aventura da coelhinha", 4 | "bye": "tchau", 5 | "time for bed!": "hora de dormir!" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | contact_links: 3 | - name: "🤨 Q&A" 4 | url: https://github.com/fnando/i18n-js/discussions/new?category=q-a 5 | about: Have a question? Ask it away here! 6 | -------------------------------------------------------------------------------- /test/fixtures/expected/multiple_files/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "es": { 3 | "bunny rabbit adventure": "conejito conejo aventura", 4 | "bye": "adios", 5 | "time for bed!": "hora de acostarse!" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/po/en.po: -------------------------------------------------------------------------------- 1 | msgid "hello sunshine!" 2 | msgstr "hello sunshine!" 3 | 4 | msgid "bunny rabbit adventure" 5 | msgstr "bunny rabbit adventure" 6 | 7 | msgid "time for bed!" 8 | msgstr "time for bed!" 9 | -------------------------------------------------------------------------------- /test/config/embed_fallback_translations.yml: -------------------------------------------------------------------------------- 1 | --- 2 | translations: 3 | - file: test/output/embed_fallback_translations.json 4 | patterns: 5 | - "*" 6 | 7 | embed_fallback_translations: 8 | enabled: true 9 | -------------------------------------------------------------------------------- /test/fixtures/expected/multiple_files/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "bunny rabbit adventure": "bunny rabbit adventure", 4 | "hello sunshine!": "hello sunshine!", 5 | "time for bed!": "time for bed!" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/embed/translations.yml: -------------------------------------------------------------------------------- 1 | --- 2 | en: 3 | words: 4 | bye: "Goodbye!" 5 | hello: "Hi!" 6 | 7 | counting: 8 | one: This is one! 9 | 10 | pt: 11 | words: 12 | bye: "Adeus!" 13 | counting: 14 | one: Esse é um! 15 | -------------------------------------------------------------------------------- /test/config/check.yml: -------------------------------------------------------------------------------- 1 | --- 2 | translations: 3 | - file: test/output/everything.json 4 | patterns: 5 | - "*" 6 | 7 | check: 8 | ignore: 9 | - "es.bye" 10 | - "es.hello sunshine!" 11 | - "pt.bye" 12 | - "pt.hello sunshine!" 13 | -------------------------------------------------------------------------------- /test/scripts/lint/file.js: -------------------------------------------------------------------------------- 1 | i18n.t("js.missing"); 2 | i18n.t("js.missing", { scope: "base" }); 3 | i18n.t("bunny rabbit adventure"); 4 | i18n.t("js.missing", { defaults: [{ scope: "bunny rabbit adventure" }] }); 5 | i18n.t("ignore_scope"); 6 | i18n.t("another_ignore_scope"); 7 | -------------------------------------------------------------------------------- /lib/guard/i18n-js/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | gem "guard" 4 | gem "guard-compat" 5 | require "guard/compat/plugin" 6 | 7 | require "i18n-js" 8 | 9 | module Guard 10 | class I18njsVersion < Plugin 11 | VERSION = I18nJS::VERSION 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/config/lint_translations.yml: -------------------------------------------------------------------------------- 1 | --- 2 | translations: 3 | - file: test/output/everything.json 4 | patterns: 5 | - "*" 6 | 7 | lint_translations: 8 | ignore: 9 | - "es.bye" 10 | - "es.hello sunshine!" 11 | - "pt.bye" 12 | - "pt.hello sunshine!" 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "esbuild": "^0.16.2", 4 | "glob": "^8.0.3", 5 | "typescript": "^4.9.4" 6 | }, 7 | "scripts": { 8 | "compile": "esbuild lib/i18n-js/lint.ts --bundle --platform=node --outfile=lib/i18n-js/lint.js --target=node16" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/config/export_files.yml: -------------------------------------------------------------------------------- 1 | --- 2 | translations: 3 | - file: test/output/export_files.json 4 | patterns: 5 | - "*" 6 | 7 | export_files: 8 | enabled: true 9 | files: 10 | - template: test/fixtures/export_files.erb 11 | output: "%{dir}/%{base_name}-%{digest}.ts" 12 | -------------------------------------------------------------------------------- /test/fixtures/expected/specific.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "time for bed!": "time for bed!" 4 | }, 5 | "es": { 6 | "bye": "adios", 7 | "time for bed!": "hora de acostarse!" 8 | }, 9 | "pt": { 10 | "bye": "tchau", 11 | "time for bed!": "hora de dormir!" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/support/helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | def reset_i18n 4 | I18n.available_locales = ["en"] 5 | I18n.locale = "en" 6 | I18n.default_locale = "en" 7 | I18n.load_path = [] 8 | I18n.backend = nil 9 | I18n.default_separator = nil 10 | I18n.enforce_available_locales = false 11 | end 12 | -------------------------------------------------------------------------------- /test/i18n-js/clean_hash_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class CleanHashTest < Minitest::Test 6 | test "removes non accepted values" do 7 | expected = {b: {d: 4}} 8 | 9 | assert_equal expected, I18nJS.clean_hash(a: -> { }, b: {c: -> { }, d: 4}) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/guard/i18n-js/templates/Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | guard(:"i18n-js", 4 | run_on_start: true, 5 | config_file: "./config/i18n.yml", 6 | require_file: "./config/environment.rb") do 7 | watch(%r{^(app|config)/locales/.+\.(yml|po)$}) 8 | watch(%r{^config/i18n.yml$}) 9 | watch("Gemfile") 10 | end 11 | -------------------------------------------------------------------------------- /lib/i18n-js/clean_hash.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module I18nJS 4 | def self.clean_hash(hash) 5 | hash.keys.each_with_object({}) do |key, buffer| 6 | value = hash[key] 7 | 8 | next if value.is_a?(Proc) 9 | 10 | buffer[key] = value.is_a?(Hash) ? clean_hash(value) : value 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | require "rubocop/rake_task" 6 | 7 | Rake::TestTask.new(:test) do |t| 8 | t.libs << "test" 9 | t.libs << "lib" 10 | t.test_files = FileList["test/**/*_test.rb"] 11 | end 12 | 13 | RuboCop::RakeTask.new 14 | 15 | task default: %i[test rubocop] 16 | -------------------------------------------------------------------------------- /lib/i18n-js/sort_hash.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module I18nJS 4 | def self.sort_hash(hash) 5 | return hash unless hash.is_a?(Hash) 6 | 7 | hash.keys.sort_by(&:to_s).each_with_object({}) do |key, seed| 8 | value = hash[key] 9 | seed[key] = value.is_a?(Hash) ? sort_hash(value) : value 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/config/lint_scripts.yml: -------------------------------------------------------------------------------- 1 | --- 2 | translations: 3 | - file: test/output/everything.json 4 | patterns: 5 | - "*" 6 | 7 | lint_scripts: 8 | patterns: 9 | - "!(node_modules)/**/*.js" 10 | - "!(node_modules)/**/*.ts" 11 | - "!(node_modules)/**/*.jsx" 12 | - "!(node_modules)/**/*.tsx" 13 | ignore: 14 | - ignore_scope 15 | - pt.another_ignore_scope 16 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV["TZ"] = "Etc/UTC" 4 | 5 | require "simplecov" 6 | SimpleCov.start do 7 | add_filter(/test/) 8 | end 9 | 10 | require "bundler/setup" 11 | require "i18n-js" 12 | require "i18n-js/cli" 13 | 14 | require "minitest/utils" 15 | require "minitest/autorun" 16 | 17 | Dir["./test/support/**/*.rb"].each do |file| 18 | require file 19 | end 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Documentation: 3 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 4 | 5 | version: 2 6 | updates: 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | 12 | - package-ecosystem: "bundler" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | -------------------------------------------------------------------------------- /lib/i18n-js/cli/version_command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module I18nJS 4 | class CLI 5 | class VersionCommand < Command 6 | command_name "version" 7 | description "Show package version" 8 | 9 | parse do |opts| 10 | opts.banner = "Usage: i18n #{name}" 11 | 12 | opts.on_tail do 13 | ui.exit_with("v#{I18nJS::VERSION}") 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/i18n-js/cli/root_command_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class RootCommandTest < Minitest::Test 6 | let(:stdout) { StringIO.new } 7 | let(:stderr) { StringIO.new } 8 | 9 | test "shows help on root" do 10 | cli = I18nJS::CLI.new(argv: [], stdout:, stderr:) 11 | 12 | assert_exit_code(1) { cli.call } 13 | assert_includes stderr.tap(&:rewind).read, "Usage: i18n COMMAND FLAGS" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/i18n-js/cli/version_command_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class VersionCommandTest < Minitest::Test 6 | let(:stdout) { StringIO.new } 7 | let(:stderr) { StringIO.new } 8 | 9 | test "displays version" do 10 | cli = I18nJS::CLI.new( 11 | argv: %w[version], 12 | stdout:, 13 | stderr: 14 | ) 15 | 16 | assert_exit_code(0) { cli.call } 17 | assert_stdout_includes "v#{I18nJS::VERSION}" 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | --- 2 | inherit_gem: 3 | rubocop-fnando: .rubocop.yml 4 | 5 | AllCops: 6 | TargetRubyVersion: 3.2 7 | NewCops: enable 8 | Exclude: 9 | - tmp/**/* 10 | - vendor/**/* 11 | - gemfiles/**/* 12 | 13 | Naming/FileName: 14 | Exclude: 15 | - lib/i18n-js.rb 16 | - lib/guard/i18n-js.rb 17 | 18 | Style/PerlBackrefs: 19 | Enabled: false 20 | 21 | Gemspec/DevelopmentDependencies: 22 | Enabled: false 23 | 24 | Gemspec/AttributeAssignment: 25 | Enabled: false 26 | -------------------------------------------------------------------------------- /test/fixtures/expected/everything.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "bunny rabbit adventure": "bunny rabbit adventure", 4 | "time for bed!": "time for bed!", 5 | "hello sunshine!": "hello sunshine!" 6 | }, 7 | "es": { 8 | "bunny rabbit adventure": "conejito conejo aventura", 9 | "bye": "adios", 10 | "time for bed!": "hora de acostarse!" 11 | }, 12 | "pt": { 13 | "bunny rabbit adventure": "a aventura da coelhinha", 14 | "bye": "tchau", 15 | "time for bed!": "hora de dormir!" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/i18n-js/sort_hash_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class SortHashTest < Minitest::Test 6 | test "returns non-hash objects" do 7 | assert_equal 1, I18nJS.sort_hash(1) 8 | end 9 | 10 | test "sorts shallow hash" do 11 | expected = {a: 1, b: 2, c: 3} 12 | 13 | assert_equal expected, I18nJS.sort_hash(c: 3, a: 1, b: 2) 14 | end 15 | 16 | test "sorts nested hash" do 17 | expected = {a: {b: 1, c: 2}, d: 3} 18 | 19 | assert_equal expected, I18nJS.sort_hash(d: 3, a: {c: 2, b: 1}) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/i18n-js/cli/check_command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module I18nJS 4 | class CLI 5 | class CheckCommand < LintTranslationsCommand 6 | command_name "check" 7 | description "Check for missing translations based on the default " \ 8 | "locale (DEPRECATED: Use `i18n lint:translations` instead)" 9 | 10 | def command 11 | ui.stderr_print "=> WARNING: `i18n check` has been deprecated in " \ 12 | "favor of `i18n lint:translations`" 13 | super 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/fixtures/expected/transformed.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "bunny rabbit adventure": "bunny rabbit adventure", 4 | "time for bed!": "time for bed!", 5 | "hello sunshine!": "hello sunshine!", 6 | "injected": "yes:en" 7 | }, 8 | "es": { 9 | "bunny rabbit adventure": "conejito conejo aventura", 10 | "bye": "adios", 11 | "time for bed!": "hora de acostarse!", 12 | "injected": "yes:es" 13 | }, 14 | "pt": { 15 | "bunny rabbit adventure": "a aventura da coelhinha", 16 | "bye": "tchau", 17 | "time for bed!": "hora de dormir!", 18 | "injected": "yes:pt" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/fixtures/expected/export_files.ts: -------------------------------------------------------------------------------- 1 | // File generated by i18n-js on 2022-12-10 15:37:00 +0000 2 | 3 | import { i18n } from "config/i18n"; 4 | 5 | i18n.store({ 6 | "en": { 7 | "bunny rabbit adventure": "bunny rabbit adventure", 8 | "hello sunshine!": "hello sunshine!", 9 | "time for bed!": "time for bed!" 10 | }, 11 | "es": { 12 | "bunny rabbit adventure": "conejito conejo aventura", 13 | "bye": "adios", 14 | "time for bed!": "hora de acostarse!" 15 | }, 16 | "pt": { 17 | "bunny rabbit adventure": "a aventura da coelhinha", 18 | "bye": "tchau", 19 | "time for bed!": "hora de dormir!" 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "💡 Feature request" 3 | about: Have an idea that may be useful? Make a suggestion! 4 | title: 'Feature Request: ' 5 | labels: 'Feature request' 6 | 7 | --- 8 | 9 | ## Description 10 | 11 | _A clear and concise description of what the problem is._ 12 | 13 | ## Describe the solution 14 | 15 | _A clear and concise description of what you want to happen._ 16 | 17 | ## Alternatives you considered 18 | 19 | _A clear and concise description of any alternative solutions or features you've considered._ 20 | 21 | ## Additional context 22 | 23 | _Add any other context, screenshots, links, etc about the feature request here._ 24 | -------------------------------------------------------------------------------- /test/i18n-js/embed_fallback_translations_plugin_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class EmbedFallbackTranslationsPluginTest < Minitest::Test 6 | test "embeds fallback translations" do 7 | require "i18n-js/embed_fallback_translations_plugin" 8 | I18nJS.register_plugin(I18nJS::EmbedFallbackTranslationsPlugin) 9 | 10 | I18n.load_path << Dir[ 11 | "./test/fixtures/yml/*.yml", 12 | "./test/fixtures/embed/*.yml" 13 | ] 14 | actual_files = 15 | I18nJS.call(config_file: "./test/config/embed_fallback_translations.yml") 16 | 17 | assert_exported_files ["test/output/embed_fallback_translations.json"], 18 | actual_files 19 | assert_json_file "test/fixtures/expected/embed_fallback_translations.json", 20 | "test/output/embed_fallback_translations.json" 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/i18n-js/export_files_plugin_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ExportScriptFilesPluginTest < Minitest::Test 6 | test "exports script files" do 7 | require "i18n-js/export_files_plugin" 8 | I18nJS.plugins.clear 9 | I18nJS.register_plugin(I18nJS::ExportFilesPlugin) 10 | I18n.load_path << Dir["./test/fixtures/yml/*.yml"] 11 | 12 | now = Time.parse("2022-12-10T15:37:00") 13 | Time.stubs(:now).returns(now) 14 | 15 | exported_file = 16 | "test/output/export_files-3d4fd73158044f2545580d5fd9d09c77.ts" 17 | 18 | actual_files = 19 | I18nJS.call(config_file: "./test/config/export_files.yml") 20 | 21 | assert_exported_files ["test/output/export_files.json"], actual_files 22 | assert_file exported_file 23 | assert_equal File.read("test/fixtures/expected/export_files.ts"), 24 | File.read(exported_file) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "🐛 Bug Report" 3 | about: Report a reproducible bug or regression. 4 | title: 'Bug: ' 5 | labels: 'Status: Unconfirmed' 6 | 7 | --- 8 | 9 | 15 | 16 | ## Description 17 | 18 | [Add bug description here] 19 | 20 | ## How to reproduce 21 | 22 | [Add steps on how to reproduce this issue] 23 | 24 | ## What do you expect 25 | 26 | [Describe what do you expect to happen] 27 | 28 | ## What happened instead 29 | 30 | [Describe the actual results] 31 | 32 | ## Software: 33 | 34 | - Gem version: [Add gem version here] 35 | - Ruby version: [Add version here] 36 | 37 | ## Full backtrace 38 | 39 | ```text 40 | [Paste full backtrace here] 41 | ``` 42 | -------------------------------------------------------------------------------- /test/fixtures/expected/lint.txt: -------------------------------------------------------------------------------- 1 | => Config file: "test/config/lint_scripts.yml" 2 | => Require file: "test/config/require.rb" 3 | => Node: "%{node}" 4 | => Available locales: [:en, :es, :pt] 5 | => Patterns: ["!(node_modules)/**/*.js", "!(node_modules)/**/*.ts", "!(node_modules)/**/*.jsx", "!(node_modules)/**/*.tsx"] 6 | => 9 translations, 11 missing, 4 ignored 7 | - test/scripts/lint/file.js:1:1: en.js.missing 8 | - test/scripts/lint/file.js:1:1: es.js.missing 9 | - test/scripts/lint/file.js:1:1: pt.js.missing 10 | - test/scripts/lint/file.js:2:8: en.base.js.missing 11 | - test/scripts/lint/file.js:2:8: es.base.js.missing 12 | - test/scripts/lint/file.js:2:8: pt.base.js.missing 13 | - test/scripts/lint/file.js:4:8: en.js.missing 14 | - test/scripts/lint/file.js:4:8: es.js.missing 15 | - test/scripts/lint/file.js:4:8: pt.js.missing 16 | - test/scripts/lint/file.js:6:1: en.another_ignore_scope 17 | - test/scripts/lint/file.js:6:1: es.another_ignore_scope 18 | -------------------------------------------------------------------------------- /test/fixtures/expected/embed_fallback_translations.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "bunny rabbit adventure": "bunny rabbit adventure", 4 | "counting": { 5 | "one": "This is one!" 6 | }, 7 | "hello sunshine!": "hello sunshine!", 8 | "time for bed!": "time for bed!", 9 | "words": { 10 | "bye": "Goodbye!", 11 | "hello": "Hi!" 12 | } 13 | }, 14 | "es": { 15 | "bunny rabbit adventure": "conejito conejo aventura", 16 | "counting": { 17 | "one": "This is one!" 18 | }, 19 | "hello sunshine!": "hello sunshine!", 20 | "time for bed!": "hora de acostarse!", 21 | "words": { 22 | "bye": "Goodbye!", 23 | "hello": "Hi!" 24 | }, 25 | "bye": "adios" 26 | }, 27 | "pt": { 28 | "bunny rabbit adventure": "a aventura da coelhinha", 29 | "counting": { 30 | "one": "Esse é um!" 31 | }, 32 | "hello sunshine!": "hello sunshine!", 33 | "time for bed!": "hora de dormir!", 34 | "words": { 35 | "bye": "Adeus!", 36 | "hello": "Hi!" 37 | }, 38 | "bye": "tchau" 39 | } 40 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Nando Vieira 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 | PR Checklist 10 | 11 | ### PR Structure 12 | 13 | - [ ] This PR has reasonably narrow scope (if not, break it down into smaller 14 | PRs). 15 | - [ ] This PR avoids mixing refactoring changes with feature changes (split into 16 | two PRs otherwise). 17 | - [ ] This PR's title starts is concise and descriptive. 18 | 19 | ### Thoroughness 20 | 21 | - [ ] This PR adds tests for the most critical parts of the new functionality or 22 | fixes. 23 | - [ ] I've updated any docs, `.md` files, etc… affected by this change. 24 | 25 |
26 | 27 | ### What 28 | 29 | [TODO: Short statement about what is changing.] 30 | 31 | ### Why 32 | 33 | [TODO: Why this change is being made. Include any context required to understand 34 | the why.] 35 | 36 | ### Known limitations 37 | 38 | [TODO or N/A] 39 | -------------------------------------------------------------------------------- /lib/i18n-js/cli/init_command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module I18nJS 4 | class CLI 5 | class InitCommand < Command 6 | command_name "init" 7 | description "Initialize a project" 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 generated" 16 | ) do |config_file| 17 | options[:config_file] = config_file 18 | end 19 | 20 | opts.on("-h", "--help", "Prints this help") do 21 | ui.exit_with opts.to_s 22 | end 23 | end 24 | 25 | command do 26 | file_path = File.expand_path( 27 | options.fetch(:config_file, "config/i18n.yml") 28 | ) 29 | 30 | if File.file?(file_path) 31 | ui.fail_with("ERROR: #{file_path} already exists!") 32 | end 33 | 34 | FileUtils.mkdir_p(File.dirname(file_path)) 35 | 36 | File.open(file_path, "w") do |file| 37 | file << <<~YAML 38 | --- 39 | translations: 40 | - file: app/javascript/locales.json 41 | patterns: 42 | - "*" 43 | - "!*.activerecord" 44 | - "!*.errors" 45 | - "!*.number.nth" 46 | 47 | YAML 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/i18n-js/cli/ui.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module I18nJS 4 | class CLI 5 | class UI 6 | attr_reader :stdout, :stderr 7 | attr_accessor :colored 8 | 9 | def initialize(stdout:, stderr:, colored: nil) 10 | @stdout = stdout 11 | @stderr = stderr 12 | @colored = colored 13 | end 14 | 15 | def stdout_print(*message) 16 | stdout << "#{message.join(' ')}\n" 17 | end 18 | 19 | def stderr_print(*message) 20 | stderr << "#{message.join(' ')}\n" 21 | end 22 | 23 | def fail_with(*message) 24 | stderr_print(message) 25 | exit(1) 26 | end 27 | 28 | def exit_with(*message) 29 | stdout_print(message) 30 | exit(0) 31 | end 32 | 33 | def yellow(text) 34 | ansi(text, 33) 35 | end 36 | 37 | def red(text) 38 | ansi(text, 31) 39 | end 40 | 41 | def colored? 42 | colored_output = if colored.nil? 43 | stdout.tty? 44 | else 45 | colored 46 | end 47 | 48 | colored_output && !no_color? 49 | end 50 | 51 | def ansi(text, code) 52 | if colored? 53 | "\e[#{code}m#{text}\e[0m" 54 | else 55 | text 56 | end 57 | end 58 | 59 | def no_color? 60 | !ENV["NO_COLOR"].nil? && ENV["NO_COLOR"] == "1" 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/i18n-js/cli/ui_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class UITest < Minitest::Test 6 | class FakeIO < StringIO 7 | attr_writer :tty 8 | 9 | def tty? 10 | @tty 11 | end 12 | end 13 | 14 | let(:io) { StringIO.new } 15 | 16 | teardown do 17 | ENV.delete("NO_COLOR") 18 | end 19 | 20 | test "returns ansi text when detecting tty" do 21 | io = FakeIO.new 22 | io.tty = true 23 | 24 | ui = I18nJS::CLI::UI.new(stdout: io, stderr: io) 25 | 26 | assert_equal "\e[33mhello\e[0m", ui.yellow("hello") 27 | end 28 | 29 | test "returns plain text when not detecting tty" do 30 | io = FakeIO.new 31 | io.tty = false 32 | 33 | ui = I18nJS::CLI::UI.new(stdout: io, stderr: io) 34 | 35 | assert_equal "hello", ui.yellow("hello") 36 | end 37 | 38 | test "returns plain text when detect tty but NO_COLOR is set" do 39 | ENV["NO_COLOR"] = "1" 40 | io = FakeIO.new 41 | io.tty = true 42 | 43 | ui = I18nJS::CLI::UI.new(stdout: io, stderr: io) 44 | 45 | assert_equal "hello", ui.yellow("hello") 46 | end 47 | 48 | test "returns ansi text when colored is set" do 49 | ui = I18nJS::CLI::UI.new(stdout: io, stderr: io, colored: true) 50 | 51 | assert_equal "\e[33mhello\e[0m", ui.yellow("hello") 52 | end 53 | 54 | test "returns plain text when colored is not set" do 55 | ui = I18nJS::CLI::UI.new(stdout: io, stderr: io, colored: false) 56 | 57 | assert_equal "hello", ui.yellow("hello") 58 | end 59 | 60 | test "returns plain text when colored is set but so is NO_COLOR" do 61 | ENV["NO_COLOR"] = "1" 62 | ui = I18nJS::CLI::UI.new(stdout: io, stderr: io, colored: true) 63 | 64 | assert_equal "hello", ui.yellow("hello") 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/i18n-js/cli.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../i18n-js" 4 | require_relative "cli/command" 5 | require_relative "cli/ui" 6 | require_relative "cli/init_command" 7 | require_relative "cli/version_command" 8 | require_relative "cli/export_command" 9 | require_relative "cli/plugins_command" 10 | require_relative "cli/lint_translations_command" 11 | require_relative "cli/lint_scripts_command" 12 | require_relative "cli/check_command" 13 | 14 | module I18nJS 15 | class CLI 16 | attr_reader :ui 17 | 18 | def initialize(argv:, stdout:, stderr:, colored: stdout.tty?) 19 | @argv = argv.dup 20 | @ui = UI.new(stdout:, stderr:, colored:) 21 | end 22 | 23 | def call 24 | command_name = @argv.shift 25 | command = commands.find {|cmd| cmd.name == command_name } 26 | 27 | ui.fail_with(root_help) unless command 28 | 29 | command.call 30 | end 31 | 32 | private def command_classes 33 | [ 34 | InitCommand, 35 | ExportCommand, 36 | VersionCommand, 37 | PluginsCommand, 38 | LintTranslationsCommand, 39 | LintScriptsCommand, 40 | CheckCommand 41 | ] 42 | end 43 | 44 | private def commands 45 | command_classes.map do |command_class| 46 | command_class.new(argv: @argv, ui:) 47 | end 48 | end 49 | 50 | private def root_help 51 | commands_list = commands 52 | .map {|cmd| "- #{cmd.name}: #{cmd.description}" } 53 | .join("\n") 54 | 55 | <<~TEXT 56 | Usage: i18n COMMAND FLAGS 57 | 58 | Commands: 59 | 60 | #{commands_list} 61 | 62 | Run `i18n COMMAND --help` for more information on specific commands. 63 | TEXT 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /test/i18n-js/cli/init_command_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class InitCommandTest < 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[init --help], 12 | stdout:, 13 | stderr: 14 | ) 15 | 16 | assert_exit_code(0) { cli.call } 17 | assert_stdout_includes "Usage: i18n init [options]" 18 | end 19 | 20 | test "initializes project with default config file" do 21 | FileUtils.mkdir_p("./test/output") 22 | 23 | Dir.chdir("test/output") do 24 | cli = I18nJS::CLI.new( 25 | argv: %w[init], 26 | stdout:, 27 | stderr: 28 | ) 29 | 30 | assert_exit_code(0) { cli.call } 31 | end 32 | 33 | assert_file "test/output/config/i18n.yml" 34 | assert_equal "", stdout_text 35 | end 36 | 37 | test "initializes project" do 38 | cli = I18nJS::CLI.new( 39 | argv: %w[init --config test/output/i18n.yml], 40 | stdout:, 41 | stderr: 42 | ) 43 | 44 | assert_exit_code(0) { cli.call } 45 | assert_file "test/output/i18n.yml" 46 | assert_equal "", stdout_text 47 | end 48 | 49 | test "rejects existing config file" do 50 | config_file = "test/output/i18n.yml" 51 | 52 | cli = I18nJS::CLI.new( 53 | argv: %W[init --config #{config_file}], 54 | stdout:, 55 | stderr: 56 | ) 57 | 58 | assert_exit_code(0) { cli.call } 59 | 60 | cli = I18nJS::CLI.new( 61 | argv: %W[init --config #{config_file}], 62 | stdout:, 63 | stderr: 64 | ) 65 | 66 | assert_exit_code(1) { cli.call } 67 | assert_stderr_includes \ 68 | "ERROR: #{File.expand_path(config_file)} already exists!" 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /i18n-js.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/i18n-js/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "i18n-js" 7 | spec.version = I18nJS::VERSION 8 | spec.authors = ["Nando Vieira"] 9 | spec.email = ["me@fnando.com"] 10 | 11 | spec.summary = "Export i18n translations and use them on JavaScript." 12 | spec.description = spec.summary 13 | spec.license = "MIT" 14 | spec.required_ruby_version = Gem::Requirement.new(">= 3.2.0") 15 | spec.metadata = {"rubygems_mfa_required" => "true"} 16 | 17 | github_url = "https://github.com/fnando/i18n-js" 18 | github_tree_url = "#{github_url}/tree/v#{spec.version}" 19 | 20 | spec.homepage = github_url 21 | spec.metadata["homepage_uri"] = spec.homepage 22 | spec.metadata["bug_tracker_uri"] = "#{github_url}/issues" 23 | spec.metadata["source_code_uri"] = github_tree_url 24 | spec.metadata["changelog_uri"] = "#{github_tree_url}/CHANGELOG.md" 25 | spec.metadata["documentation_uri"] = "#{github_tree_url}/README.md" 26 | spec.metadata["license_uri"] = "#{github_tree_url}/LICENSE.md" 27 | 28 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 29 | `git ls-files -z` 30 | .split("\x0") 31 | .reject {|f| f.match(%r{^(test|spec|features|images)/}) } 32 | end 33 | 34 | spec.files << "lib/i18n-js/lint.js" 35 | 36 | spec.bindir = "exe" 37 | spec.executables = spec.files.grep(%r{^exe/}) {|f| File.basename(f) } 38 | spec.require_paths = ["lib"] 39 | 40 | spec.add_dependency "glob", ">= 0.4.0" 41 | spec.add_dependency "i18n" 42 | 43 | spec.add_development_dependency "activesupport" 44 | spec.add_development_dependency "minitest" 45 | spec.add_development_dependency "minitest-utils" 46 | spec.add_development_dependency "mocha" 47 | spec.add_development_dependency "pry-meta" 48 | spec.add_development_dependency "rake" 49 | spec.add_development_dependency "rubocop" 50 | spec.add_development_dependency "rubocop-fnando" 51 | spec.add_development_dependency "simplecov" 52 | end 53 | -------------------------------------------------------------------------------- /.github/workflows/ruby-tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: ruby-tests 3 | 4 | on: 5 | pull_request_target: 6 | push: 7 | workflow_dispatch: 8 | inputs: {} 9 | 10 | jobs: 11 | build: 12 | name: 13 | Tests with Ruby ${{ matrix.ruby }}, Node ${{ matrix.node }} and ${{ 14 | matrix.gemfile }} 15 | runs-on: "ubuntu-latest" 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | ruby: ["3.3", "3.4"] 20 | node: ["22", "24"] 21 | gemfile: 22 | - Gemfile 23 | if: | 24 | github.actor == 'dependabot[bot]' && github.event_name == 'pull_request_target' || 25 | github.actor != 'dependabot[bot]' 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - uses: actions/cache@v4 31 | id: bundler-cache 32 | with: 33 | path: vendor/bundle 34 | key: > 35 | ${{ runner.os }}-${{ matrix.ruby }}-gems-${{ 36 | hashFiles(matrix.gemfile) }} 37 | 38 | - uses: actions/cache@v4 39 | id: npm-cache 40 | with: 41 | path: vendor/bundle 42 | key: > 43 | ${{ runner.os }}-${{ matrix.node }}-npm-${{ 44 | hashFiles('package.json') }} 45 | 46 | - name: Set up Node 47 | uses: actions/setup-node@v4.0.2 48 | with: 49 | node-version: ${{ matrix.node }} 50 | 51 | - name: Install npm dependencies 52 | run: | 53 | yarn install 54 | 55 | - name: Set up Ruby 56 | uses: ruby/setup-ruby@v1 57 | with: 58 | ruby-version: ${{ matrix.ruby }} 59 | 60 | - name: Install gem dependencies 61 | env: 62 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 63 | run: | 64 | gem install bundler 65 | bundle config path vendor/bundle 66 | bundle update --jobs 4 --retry 3 67 | 68 | - name: Run Tests 69 | env: 70 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 71 | run: | 72 | yarn compile 73 | bundle exec rake 74 | -------------------------------------------------------------------------------- /lib/i18n-js/cli/plugins_command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module I18nJS 4 | class CLI 5 | class PluginsCommand < Command 6 | command_name "plugins" 7 | description "List plugins that will be activated" 8 | 9 | parse do |opts| 10 | opts.banner = "Usage: i18n #{name} [options]" 11 | 12 | opts.on( 13 | "-rREQUIRE_FILE", 14 | "--require=REQUIRE_FILE", 15 | "A Ruby file that must be loaded" 16 | ) do |require_file| 17 | options[:require_file] = require_file 18 | end 19 | 20 | opts.on("-h", "--help", "Prints this help") do 21 | ui.exit_with opts.to_s 22 | end 23 | end 24 | 25 | command do 26 | set_defaults! 27 | ui.colored = options[:colored] 28 | 29 | if options[:require_file] 30 | ui.stdout_print("=> Require file:", options[:require_file].inspect) 31 | require_file = File.expand_path(options[:require_file]) 32 | end 33 | 34 | if require_file && !File.file?(require_file) 35 | ui.fail_with( 36 | "=> ERROR: require file doesn't exist at", 37 | require_file.inspect 38 | ) 39 | end 40 | 41 | load_require_file!(require_file) if require_file 42 | 43 | files = I18nJS.plugin_files 44 | 45 | if files.empty? 46 | ui.stdout_print("=> No plugins have been detected.") 47 | else 48 | ui.stdout_print("=> Plugins that will be activated:") 49 | 50 | files.each do |file| 51 | file = file.gsub("#{Dir.home}/", "~/") 52 | 53 | ui.stdout_print(" * #{file}") 54 | end 55 | end 56 | end 57 | 58 | private def set_defaults! 59 | config_file = "./config/i18n.yml" 60 | require_file = "./config/environment.rb" 61 | 62 | options[:config_file] ||= config_file if File.file?(config_file) 63 | options[:require_file] ||= require_file if File.file?(require_file) 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/i18n-js/cli/command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module I18nJS 4 | class CLI 5 | class Command 6 | attr_reader :ui, :argv 7 | 8 | def self.command_name(name) 9 | define_method(:name) { name } 10 | end 11 | 12 | def self.description(description) 13 | define_method(:description) { description } 14 | end 15 | 16 | def self.parse(&block) 17 | define_method(:parse) do 18 | OptionParser 19 | .new {|opts| instance_exec(opts, &block) } 20 | .parse!(argv) 21 | end 22 | end 23 | 24 | def self.command(&) 25 | define_method(:command, &) 26 | end 27 | 28 | def initialize(argv:, ui:) 29 | @argv = argv.dup 30 | @ui = ui 31 | end 32 | 33 | def call 34 | parse 35 | command 36 | end 37 | 38 | def options 39 | @options ||= {} 40 | end 41 | 42 | private def load_config_file(config_file) 43 | config = Glob::SymbolizeKeys.call(YAML.load_file(config_file)) 44 | 45 | if config.key?(:check) 46 | config[:lint_translations] ||= config.delete(:check) 47 | end 48 | 49 | config 50 | end 51 | 52 | private def load_require_file!(require_file) 53 | require_without_warnings(require_file) 54 | rescue Exception => error # rubocop:disable Lint/RescueException 55 | ui.stderr_print("=> ERROR: couldn't load", 56 | options[:require_file].inspect) 57 | ui.fail_with( 58 | "\n#{error_description(error)}\n#{error.backtrace.join("\n")}" 59 | ) 60 | end 61 | 62 | private def error_description(error) 63 | [ 64 | error.class.name, 65 | error.message 66 | ].reject(&:empty?).join(" => ") 67 | end 68 | 69 | private def require_without_warnings(path) 70 | old_verbose = $VERBOSE 71 | $VERBOSE = nil 72 | 73 | load path 74 | ensure 75 | $VERBOSE = old_verbose 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /bin/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "optparse" 5 | require_relative "../lib/i18n-js/version" 6 | 7 | def write_file(path, contents) 8 | File.open(File.expand_path(path), "w") do |io| 9 | io << contents 10 | end 11 | end 12 | 13 | changelog_path = "./CHANGELOG.md" 14 | version_path = "./lib/i18n-js/version.rb" 15 | 16 | version = nil 17 | segments = I18nJS::VERSION.split(".") 18 | major, minor, patch = *segments.take(3).map(&:to_i) 19 | pre = segments[4].to_s 20 | pre_version = pre.gsub(/[^\d]/m, "").to_i 21 | date = Time.now.strftime("%b %d, %Y") 22 | dry_run = false 23 | alpha = false 24 | 25 | OptionParser.new do |opts| 26 | opts.on("--major") do 27 | version = "#{major + 1}.0.0" 28 | end 29 | 30 | opts.on("--minor") do 31 | version = "#{major}.#{minor + 1}.0" 32 | end 33 | 34 | opts.on("--patch") do 35 | version = "#{major}.#{minor}.#{patch + 1}" 36 | end 37 | 38 | opts.on("--alpha") do 39 | alpha = true 40 | end 41 | 42 | opts.on("--dry-run") do 43 | dry_run = true 44 | end 45 | end.parse! 46 | 47 | version = "#{version}.alpha#{pre_version + 1}" if alpha 48 | 49 | unless version 50 | puts "ERROR: You need to use either one of: --major, --minor, --patch" 51 | exit 1 52 | end 53 | 54 | puts "=> Current version: #{I18nJS::VERSION}" 55 | puts "=> Next version: #{version}" 56 | 57 | system "yarn", "install" 58 | system "yarn", "compile" 59 | 60 | write_file changelog_path, 61 | File.read(changelog_path) 62 | .gsub("Unreleased", "v#{version} - #{date}") 63 | 64 | puts "=> Updated #{changelog_path}" 65 | 66 | write_file version_path, 67 | File.read(version_path) 68 | .gsub(/VERSION = ".*?"/, %[VERSION = "#{version}"]) 69 | 70 | puts "=> Updated #{version_path}" 71 | 72 | unless dry_run 73 | system "git", "add", changelog_path, version_path 74 | system "git", "commit", "-m", "Bump up version (v#{version})" 75 | system "rake", "release" 76 | end 77 | 78 | if dry_run 79 | system "rake", "build" 80 | system "git", "checkout", changelog_path, version_path 81 | end 82 | -------------------------------------------------------------------------------- /lib/guard/i18n-js.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | gem "guard" 4 | gem "guard-compat" 5 | require "guard/compat/plugin" 6 | 7 | require "i18n-js" 8 | 9 | module Guard 10 | class I18njs < Plugin 11 | attr_reader :config_file, :require_file, :current_thread 12 | 13 | def initialize(options = {}) 14 | @config_file = options.delete(:config_file) 15 | @require_file = options.delete(:require_file) 16 | super 17 | end 18 | 19 | def start 20 | export_files 21 | end 22 | 23 | def stop 24 | current_thread&.exit 25 | end 26 | 27 | def reload 28 | export_files 29 | end 30 | 31 | def run_all 32 | export_files 33 | end 34 | 35 | def run_on_additions(paths) 36 | export_files(paths) 37 | end 38 | 39 | def run_on_modifications(paths) 40 | export_files(paths) 41 | end 42 | 43 | def run_on_removals(paths) 44 | export_files(paths) 45 | end 46 | 47 | def export_files(changes = nil) 48 | return unless validate_file(:config_file, config_file) 49 | return unless validate_file(:require_file, require_file) 50 | 51 | current_thread&.exit 52 | 53 | info("Changes detected: #{changes.join(', ')}") if changes 54 | 55 | @current_thread = Thread.new do 56 | capture do 57 | system "i18n", 58 | "export", 59 | "--config", 60 | config_file.to_s, 61 | "--require", 62 | require_file.to_s, 63 | "--quiet" 64 | end 65 | end 66 | 67 | current_thread.join 68 | end 69 | 70 | def capture 71 | original = $stdout 72 | $stdout = StringIO.new 73 | yield 74 | rescue StandardError 75 | # noop 76 | ensure 77 | $stdout = original 78 | end 79 | 80 | def validate_file(key, file) # rubocop:disable Naming/PredicateMethod 81 | return true if file && File.file?(file) 82 | 83 | error("#{key.inspect} must be a file") 84 | false 85 | end 86 | 87 | def error(message) 88 | ::Guard::UI.error "[i18n-js] #{message}" 89 | end 90 | 91 | def info(message) 92 | ::Guard::UI.info "[i18n-js] #{message}" 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /test/i18n-js/cli/plugins_command_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class PluginsCommandTest < 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[plugins --help], 12 | stdout:, 13 | stderr: 14 | ) 15 | 16 | assert_exit_code(0) { cli.call } 17 | assert_stdout_includes "Usage: i18n plugins [options]" 18 | end 19 | 20 | test "with missing require file" do 21 | require_file = "missing/require.rb" 22 | path = File.expand_path(require_file) 23 | 24 | cli = I18nJS::CLI.new( 25 | argv: %W[ 26 | plugins 27 | --require #{require_file} 28 | ], 29 | stdout:, 30 | stderr: 31 | ) 32 | 33 | assert_exit_code(1) { cli.call } 34 | assert_stderr_includes %[ERROR: require file doesn't exist at "#{path}"] 35 | end 36 | 37 | test "with require file that fails to load" do 38 | cli = I18nJS::CLI.new( 39 | argv: %w[ 40 | plugins 41 | --require test/config/require_error.rb 42 | ], 43 | stdout:, 44 | stderr: 45 | ) 46 | 47 | assert_exit_code(1) { cli.call } 48 | 49 | assert_stderr_includes "RuntimeError => 💣" 50 | assert_stderr_includes \ 51 | %[ERROR: couldn't load "test/config/require_error.rb"] 52 | end 53 | 54 | test "returns message when no plugins have been found" do 55 | I18nJS.stubs(:plugin_files).returns([]) 56 | 57 | cli = I18nJS::CLI.new( 58 | argv: %w[ 59 | plugins 60 | --require test/config/require.rb 61 | ], 62 | stdout:, 63 | stderr: 64 | ) 65 | 66 | assert_exit_code(1) { cli.call } 67 | 68 | output = stdout.tap(&:rewind).read.chomp 69 | 70 | assert_includes output, "=> No plugins have been detected." 71 | end 72 | 73 | test "returns message for plugins that will be activated" do 74 | I18nJS.stubs(:plugin_files).returns(["/file.rb", "#{Dir.home}/another.rb"]) 75 | 76 | cli = I18nJS::CLI.new( 77 | argv: %w[ 78 | plugins 79 | --require test/config/require.rb 80 | ], 81 | stdout:, 82 | stderr: 83 | ) 84 | 85 | assert_exit_code(1) { cli.call } 86 | 87 | output = stdout.tap(&:rewind).read.chomp 88 | 89 | assert_includes output, " * /file" 90 | assert_includes output, " * ~/another.rb" 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/support/minitest.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "fileutils" 4 | 5 | module Minitest 6 | class Test 7 | setup do 8 | reset_i18n 9 | 10 | I18nJS.plugins.clear 11 | I18nJS.available_plugins.clear 12 | I18n.config.clear_available_locales_set 13 | I18n.backend = I18n::Backend::Simple.new 14 | I18nJS::Schema.root_keys.delete(:sample) 15 | FileUtils.rm_rf "./test/output" 16 | end 17 | 18 | teardown do 19 | FileUtils.rm_rf "./test/output" 20 | end 21 | 22 | private def assert_schema_error(message) 23 | error = nil 24 | 25 | begin 26 | yield 27 | rescue I18nJS::Schema::InvalidError => error 28 | # do nothing 29 | end 30 | 31 | assert error, "Expected block to have raised a schema error" 32 | assert_includes error.message, message 33 | end 34 | 35 | private def assert_file(path) 36 | path = File.expand_path(path) 37 | 38 | assert File.file?(path), "Expected #{path} to be a file" 39 | end 40 | 41 | private def assert_json_file(expected_file, actual_file) 42 | expected = ::JSON.parse(File.read(File.expand_path(expected_file))) 43 | actual = ::JSON.parse(File.read(File.expand_path(actual_file))) 44 | 45 | assert_equal expected, actual 46 | end 47 | 48 | private def assert_exported_files(expected_files, actual_files) 49 | expected_files = expected_files.map {|path| File.expand_path(path) }.sort 50 | 51 | assert_equal expected_files.size, 52 | actual_files.size, 53 | "Expected files to be equal in size " \ 54 | "(#{expected_files.size} != #{actual_files.size})" 55 | assert_equal expected_files, actual_files.sort 56 | 57 | actual_files.each do |path| 58 | assert_file(path) 59 | end 60 | end 61 | 62 | private def assert_exit_code(expected_code) 63 | yield 64 | rescue SystemExit => error 65 | assert_equal expected_code, error.exception.status 66 | end 67 | 68 | private def assert_stdout_includes(text) 69 | assert_includes stdout_text, text 70 | end 71 | 72 | private def assert_stderr_includes(text) 73 | assert_includes stderr_text, text 74 | end 75 | 76 | private def stdout_text 77 | stdout.tap(&:rewind).read 78 | end 79 | 80 | private def stderr_text 81 | stderr.tap(&:rewind).read 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/i18n-js/embed_fallback_translations_plugin.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module I18nJS 4 | require "i18n-js/plugin" 5 | 6 | class EmbedFallbackTranslationsPlugin < I18nJS::Plugin 7 | module Utils 8 | # Based on deep_merge by Stefan Rusterholz, see 9 | # . 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 | i18n.js 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 | Tests 22 | Gem 23 | Gem 24 | MIT License 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 | --------------------------------------------------------------------------------