├── .github └── workflows │ └── ruby.yml ├── .gitignore ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── VERSION ├── i18n-backend-side_by_side.gemspec ├── lib └── i18n │ └── backend │ └── side_by_side.rb └── test ├── locales ├── novel.yml ├── oldschool.de.yml └── oldschool.en.yml └── test.rb /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Ruby 9 | 10 | on: 11 | push: 12 | branches: [ master ] 13 | pull_request: 14 | branches: [ master ] 15 | 16 | jobs: 17 | test: 18 | 19 | runs-on: ubuntu-latest 20 | 21 | strategy: 22 | matrix: 23 | ruby-version: ['3.2', '3.1', '3.0', '2.7'] 24 | 25 | steps: 26 | - uses: actions/checkout@v3 27 | - name: Set up Ruby ${{ matrix.ruby-version }} 28 | uses: ruby/setup-ruby@ec02537da5712d66d4d50a0f33b7eb52773b5ed1 29 | with: 30 | ruby-version: ${{ matrix.ruby-version }} 31 | - name: Install dependencies 32 | run: bundle install 33 | - name: Run tests 34 | run: bundle exec rake 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.bundle 11 | *.so 12 | *.o 13 | *.a 14 | mkmf.log 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## v1.3.0 4 | 5 | - Add Ruby 3.1 and 3.2 to CI 6 | - Support translations keys without specified language #8 7 | 8 | ## v1.2.0 9 | 10 | - Support i18n 1.10.0 11 | - Drop support for Ruby < 2.7 12 | 13 | ## v1.1.0 14 | 15 | - Remove upper bound on i18n dependency 16 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 ElectricFeel Mobility Systems GmbH 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # i18n-backend-side\_by\_side 2 | 3 | [![Tests Status](https://github.com/electric-feel/i18n-backend-side_by_side/workflows/Ruby/badge.svg)](https://github.com/electric-feel/i18n-backend-side_by_side/actions?query=workflow%3ARuby) 4 | [![Gem Version](https://badge.fury.io/rb/i18n-backend-side_by_side.svg)](https://rubygems.org/gems/i18n-backend-side_by_side) 5 | 6 | **Tired of jumping between language files when translating keys? Stop jumping 7 | and have all the languages side by side.** 8 | 9 | This gem is a subclass of the default `I18n::Backend::Simple` backend. It 10 | changes the way translations get loaded from files in order to support 11 | specifying the language code anywhere along the key path as opposed to the root 12 | key. This allows for all languages being defined next to each other, 13 | making it easier to translate between them. 14 | 15 | ## How It Works 16 | 17 | Let's assume your app supports English, Spanish, and German. You probably have 18 | one file per language: 19 | 20 | ``` 21 | locales/ 22 | view.en.yml 23 | view.es.yml 24 | view.de.yml 25 | ``` 26 | 27 | The files might look something like this (e.g. `locales/en.yml`): 28 | 29 | ```yaml 30 | en: 31 | view: 32 | title: Welcome 33 | inbox: 34 | zero: You have no messages 35 | one: You have one message 36 | other: 'You have %{count} messages' 37 | ``` 38 | 39 | Whenever you have to add or modify a key, you have to edit all files. Also, you 40 | can't see all translations at once for a specific key. With this gem you can 41 | merge all the files and specify the translation for a key at that key: 42 | 43 | ```yaml 44 | _: 45 | view: 46 | title: 47 | _en: Welcome 48 | _es: Bienvenido 49 | _de: Willkommen 50 | common_message: This string is available for all languages 51 | inbox: 52 | _en: 53 | zero: You have no messages 54 | one: You have one message 55 | other: 'You have %{count} messages' 56 | _es: 57 | zero: No tiene mensajes 58 | one: Tienes un mensaje 59 | other: 'Tienes %{count} messajes' 60 | _de: 61 | zero: Du hast keine Nachrichten 62 | one: Du hast eine Nachricht 63 | other: 'Du hast %{count} Nachrichten' 64 | ``` 65 | 66 | Two things to note here: 67 | 68 | 1. The root key is an underscore. Omitting it results in the file being 69 | processed as a regular translation file, without support for side-by-side 70 | translations. 71 | 72 | 2. The language codes are prefixed with an underscore. This is needed in order 73 | to distinguish a language code key from a normal key. This also means that 74 | regular keys can't start with an underscore. 75 | 76 | When the files get loaded, they're transformed on the fly to the original format 77 | by moving the language code to the beginning of the key path: 78 | 79 | ``` 80 | _.foo.bar._en => en.foo.bar 81 | _.foo.bar._en-UK.abc.xyz => en-UK.foo.bar.abc.xyz 82 | ``` 83 | 84 | ## Installation 85 | 86 | Add this line to your application's Gemfile: 87 | 88 | ```ruby 89 | gem 'i18n-backend-side_by_side' 90 | ``` 91 | 92 | Set up `I18n` to use an instance of this backend: 93 | 94 | ```ruby 95 | I18n.backend = I18n::Backend::SideBySide.new 96 | ``` 97 | 98 | That's it. Continue using `I18n` as you're used to. Happy translating! 99 | 100 | ## Authors 101 | 102 | - Pratik Mukerji ([pmukerji](https://github.com/pmukerji)) 103 | - Philipe Fatio ([fphilipe](https://github.com/fphilipe)) 104 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rake/testtask' 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << 'test' 6 | end 7 | 8 | task :default => :test 9 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 1.3.0 2 | -------------------------------------------------------------------------------- /i18n-backend-side_by_side.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'i18n-backend-side_by_side' 7 | spec.version = File.read('VERSION').strip 8 | spec.authors = ['Philipe Fatio', 'Pratik Mukerji'] 9 | spec.email = ['me@phili.pe', 'pratik@electricfeel.com'] 10 | spec.summary = %q{Tired of jumping between language files when translating keys? Stop jumping and have all the languages side by side.} 11 | spec.description = spec.summary 12 | spec.homepage = 'https://github.com/electric-feel/i18n-backend-side_by_side' 13 | spec.license = 'MIT' 14 | 15 | spec.files = `git ls-files -z`.split("\x0") 16 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 17 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 18 | spec.require_paths = ['lib'] 19 | spec.required_ruby_version = '>= 2.7.0' 20 | 21 | spec.add_development_dependency 'rake' 22 | spec.add_development_dependency 'minitest' 23 | spec.add_dependency 'i18n', '>= 1.10.0' 24 | spec.add_dependency 'activesupport', '>= 6.0.0' 25 | end 26 | -------------------------------------------------------------------------------- /lib/i18n/backend/side_by_side.rb: -------------------------------------------------------------------------------- 1 | require 'i18n' 2 | require 'active_support/core_ext/hash/keys' 3 | 4 | module I18n 5 | module Backend 6 | class SideBySide < Simple 7 | VERSION = File.read(File.expand_path('../../../../VERSION', __FILE__)) 8 | LOCALE_PREFIX = '_' 9 | 10 | protected 11 | 12 | def load_file(filename) 13 | type = File.extname(filename).tr('.', '').downcase 14 | raise UnknownFileType.new(type, filename) unless respond_to?(:"load_#{type}", true) 15 | data = send(:"load_#{type}", filename).first 16 | unless data.is_a?(Hash) 17 | raise InvalidLocaleData.new(filename, 'expects it to return a hash, but does not') 18 | end 19 | 20 | if data.first.first.to_s == LOCALE_PREFIX 21 | _process([], data.deep_symbolize_keys[LOCALE_PREFIX.to_sym]) 22 | else 23 | data.each { |locale, d| store_translations(locale, d || {}) } 24 | end 25 | end 26 | 27 | private 28 | 29 | def _process(path, hash) 30 | if !hash.is_a?(Hash) 31 | ### TRANSLATION KEY WITHOUT SPECIFIED LANGUAGE - WILL BE APPLIED TO ALL LANGUAGES 32 | value = hash 33 | translations.keys.each do |locale| 34 | _store([locale] + path, value) 35 | end 36 | elsif _contains_locales?(hash) 37 | hash.each do |locale, value| 38 | _store([_strip_locale_prefix(locale)] + path, value) 39 | end 40 | else 41 | hash.each { |key, value| _process(path + [key], value) } 42 | end 43 | end 44 | 45 | def _store(path, value) 46 | *keys, last_key = path 47 | target = keys.inject(translations) do |hash, key| 48 | hash[key] ||= {} 49 | hash[key] 50 | end 51 | target[last_key] = value 52 | end 53 | 54 | def _contains_locales?(hash) 55 | hash.first.first[0] == LOCALE_PREFIX 56 | end 57 | 58 | def _strip_locale_prefix(locale) 59 | locale[LOCALE_PREFIX.length..-1].to_sym 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/locales/novel.yml: -------------------------------------------------------------------------------- 1 | _: 2 | foo: 3 | bar: 4 | _en: Hi 5 | _de: Hallo 6 | hash: 7 | key: 8 | _en: value 9 | _de: Wert 10 | count: 11 | _en: 12 | one: one 13 | zero: none 14 | other: many 15 | _de: 16 | one: ein 17 | zero: keine 18 | other: mehrere 19 | only_en: 20 | some_key: 21 | _en: 'some value' 22 | without_language: "Without Language" 23 | -------------------------------------------------------------------------------- /test/locales/oldschool.de.yml: -------------------------------------------------------------------------------- 1 | de: 2 | oldschool: Immer noch hier 3 | -------------------------------------------------------------------------------- /test/locales/oldschool.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | oldschool: Still here 3 | -------------------------------------------------------------------------------- /test/test.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'i18n/backend/side_by_side' 3 | require 'minitest/autorun' 4 | 5 | I18n.load_path = Dir['test/locales/*.yml'] 6 | I18n.backend = I18n::Backend::SideBySide.new 7 | 8 | class Test < Minitest::Test 9 | def test_oldschool 10 | assert_equal('Still here', I18n.t('oldschool', locale: :en)) 11 | assert_equal('Immer noch hier', I18n.t('oldschool', locale: :de)) 12 | end 13 | 14 | def test_simple 15 | assert_equal('Hi', I18n.t('foo.bar', locale: :en)) 16 | assert_equal('Hallo', I18n.t('foo.bar', locale: :de)) 17 | end 18 | 19 | def test_hash 20 | assert_equal({ key: 'value' }, I18n.t('foo.hash', locale: :en)) 21 | assert_equal({ key: 'Wert' }, I18n.t('foo.hash', locale: :de)) 22 | end 23 | 24 | def test_count 25 | assert_equal('none', I18n.t('foo.count', count: 0, locale: :en)) 26 | assert_equal('keine', I18n.t('foo.count', count: 0, locale: :de)) 27 | end 28 | 29 | def test_partially_missing 30 | assert_equal('some value', I18n.t('foo.only_en.some_key', locale: :en)) 31 | assert_equal({ some_key: 'some value' }, I18n.t('foo.only_en', locale: :en)) 32 | assert_raises { I18n.t('foo.only_en.some_key', locale: :de, raise: true) } 33 | assert_raises { I18n.t('foo.only_en', locale: :de, raise: true) } 34 | end 35 | 36 | def test_without_language 37 | assert_equal('Without Language', I18n.t('foo.without_language', language: :en)) 38 | assert_equal('Without Language', I18n.t('foo.without_language', language: :de)) 39 | end 40 | end 41 | --------------------------------------------------------------------------------