Software localization
Translating Ruby Applications with the R18n Ruby Gem
In the previous articles, we showed you how to translate Rails applications with I18n and listed some internationalization best practices. What if, however, you have a good old Ruby application that should be translated as well? Are there any solutions to solve this task? Yes, there are! Today I am going to present you R18n - a gem created by Andrey Sitnik that allows translating Ruby, Rails and Sinatra applications with ease. This gem has a somewhat different approach than I18n and getting started with some of its features can be a bit complex but, fear not, I am here to guide you. In this article you will learn:
- Basics of the R18n gem
- Usage of r18n-desktop module
- Loading translations and setting locale
- Translating strings and localizing date/time
- Using filters
The source code for the article is available on GitHub. For the purposes of this demo, I will be using Ruby 2.3.3 but R18n is tested against 2.2 and 2.4 as well.
Sample Application
To see R18n in action we indeed require a sample application. We won't create anything complex and will fully concentrate on the gem itself. I propose we craft a small module called Bank
that will have a main Account
class. This class will contain a bunch of methods allowing to create an account that has an owner's info and some budget. Also, it will be possible to add funds to this account, withdraw or send them to another person. Nothing really complex. Create a new bank directory with a lib folder inside. This lib folder will host our main account.rb file:
# bank/lib/account.rb module Bank class Account end end
The bank folder will also contain a bank.rb file with the following minimalist contents:
# bank/bank.rb require_relative 'lib/account' module Bank end
Now let's flesh out the Account
class. Upon the creation of the account I'd like to be able to set the balance, the owner's name and gender. The balance should be an optional argument with a default value of 0
.
# bank/lib/account.rb module Bank class Account attr_reader :owner, :balance, :gender def initialize(owner:, balance: 0, gender:) @owner = owner @balance = balance @gender = check_gender_validity_for gender end end end
Note that that I am using a new hash-style way of writing the method's arguments. You may, of course, stick to the old way as well. Another thing to notice is that the balance cannot be changed directly using balance=
- we'll have a separate method for that. check_gender_validity_for
is a private method that checks if the provided gender is correct. As you know, there are only two possible genders to choose from, so let's store their titles in a constant and craft the method itself:
module Bank class Account VALID_GENDER = %w(male female).freeze # ... private def check_gender_validity_for(gender) VALID_GENDER.include?(gender) ? gender : 'male' end end end
Next add a credit
and withdraw
methods:
module Bank class Account # ... def credit(amount) @balance += amount end def withdraw(amount) raise(WithdrawError, '[ERROR] This account does not have enough money to withdraw!') if balance < amount @balance -= amount end # ... end end
We need to check whether an account has enough money to withdraw because otherwise it means that anyone may take as much money as he wants. I mean, that's quite cool but definitely incorrect. A custom WithdrawError
class is being used here, therefore let's define it inside a separate file:
# bank/lib/errors.rb module Bank class WithdrawError < StandardError end end
Don't forget to require this file inside the bank.rb:
require_relative 'lib/errors' require_relative 'lib/account' # ...
You may also add additional checks to see if the amount, for example, is not negative. I will not do it in this article to keep things simple. Also, I would like to be able to transfer money between accounts. This process, basically, involves two steps: withdrawing money from one account and adding them to another one. We also need to rescue from the WithdrawError
:
module Bank class Account # ... def transfer_to(another_account, amount) puts "[#{Time.now}] Transaction started" begin withdraw(amount) another_account.credit amount rescue WithdrawError => e puts e else puts "#{owner} transferred $#{amount} to #{another_account.owner}" ensure puts "[#{Time.now}] Transaction ended" end end end end
In a real world this process will surely be wrapped in some transaction, so we are simulating it with informational messages. Lastly, it would be nice if we could see some information about the accounts. Let's create an info
method for that:
module Bank class Account # ... def info "Account's owner: #{owner} (#{gender}). Current balance: $#{balance}." end end end
I am not using puts
here because someone may want to, say, write this information to a file. Alright, the application is finally ready! To be able to see it in action, create a small runner.rb file outside the bank directory:
# runner.rb require_relative 'bank/bank' john_account = Bank::Account.new owner: 'John', balance: 20, gender: 'male' kate_account = Bank::Account.new owner: 'Kate', balance: 15, gender: 'female' puts john_account.info john_account.transfer_to(kate_account, 10) puts john_account.info puts kate_account.info
Now, the question is: how do we translate this application to other languages? For example, I like the user to be able to select his language upon the application's loading. All the messages should be probably translated, dates and numbers should be localized as well. It seems that the time has come to start integrating R18n!
Integrating R18n
So, the R18n library consists of the following modules:
- r18n-core that, as you've guessed, hosts all the main code
- r18n-rails - wrapper for Rails that adds some magic for routes and models
- r18n-sinatra - wrapper for Sinatra
- r18n-desktop - wrapper for desktop (shell) applications that we are going to utilize in this article
All in all, r18n-desktop is a small module that properly reads system locale on various systems and sets it as a default one. It also provide a from_env method to load translations from a specified directory. All other code comes from the core module. Get started by installing the gem on your PC:
gem install r18n-desktop
Then require it inside the bank/bank.rb file:
require 'r18n-desktop' # ...
Translations for R18n come in a form of YAML files, which is the same format that I18n uses. There is a difference though: initially all the parameters in your translations are not named but rather numbered:
some_translation: "The values are %1 and %2"
Wrapper for Rails does support named variables and you may include it as well, but I don't see any real need to do so. It is advised to store all translations inside a i18n folder with .yml files inside. Each file should have downcased language code as a name: en-us.yml, de.yml, ru.yml etc. R18n supports lots of languages out of the box and provides translations for date/time, some commonly used words as well as pluralization rules. In this article we will support English and Russian languages, but you may stick with any other languages you prefer. Create the en.yml and ru.yml files inside the bank/lib/i18n directory. Place our first messages there:
# bank/lib/i18n/en.yml account: info: "Account's owner: %1 (%2). Current balance: $%3."
# bank/lib/i18n/ru.yml account: info: "Владелец счёта: %1 (%2). Текущий баланс: $%3."
These messages have three parameters that we will need to provide later. Before doing that, however, let's allow users to choose a locale.
Switching Locale
To be able to switch a locale upon the application's boot, let's create a separate LocaleSettings
class:
# bank/lib/locale_settings.rb module Bank class LocaleSettings end end
Require this file inside the bank/bank.rb:
require 'r18n-desktop' require_relative 'lib/errors' require_relative 'lib/locale_settings' require_relative 'lib/account' module Bank LocaleSettings.new end
I am also instantiating the LocaleSettings
right inside the Bank
module but you may place this code inside the runner.rb file as well. Now we probably would like to present the user a list of available locales to choose. One option is to hard-code them, but that's not the best way because if a new locale is added then you will need to tweak the code accordingly. Instead, I propose to load the translations and then fetch the available locales with the help of the R18n
module:
module Bank class LocaleSettings def initialize puts "Select locale's code:" R18n.from_env 'bank/lib/i18n/' puts R18n.get.available_locales.map(&:code) R18n.get.available_locales.each do |locale| puts "#{locale.title} (#{locale.code})" end end end end
So, there are a couple of things going on here:
R18n.from_env 'bank/lib/i18n/'
loads all translations from the given directory. At this point all the messages are already available for use. Note that the system locale will be set as the default one, but you may control this behavior by setting a second optional parameter with a language's codeR18n.from_env 'path', 'en'
R18n.get
returns the R18n object for the current thread. Next we simply use theavailable_locales
method and display their titles and codes
The last step here is fetching the user's input and changing the locale accordingly (we also need to make sure that the chosen locale is actually supported):
module Bank class LocaleSettings def initialize # ... R18n.get.available_locales.each do |locale| puts "#{locale.title} (#{locale.code})" end change_locale_to gets.strip.downcase end private def change_locale_to(locale) locale = 'en' unless R18n.get.available_locales.map(&:code).include?(locale) R18n.from_env 'bank/lib/i18n/', locale end end end
Actually, there is a set
method available that changes the currently used locale, so employing from_env
again should not be required. Unfortunately, there is some odd bug with this method, so we have to use the suggested approach as a workaround. Great! The language is now set and we can perform the actual translations.
Performing Translations
R18n provides a method with a very short name t
that should be familiar to all Rails users. This method, however, has a somewhat different approach. In Rails, in order to fetch a translation under some key you would say:
t('account.info')
When using R18n, however, you should write
R18n.get.t.account.info
instead, because the t
method returns a list of translations for the currently used locale. But, what if the translation key has the same name as some existing Ruby method, like for example send
? Well, in this case, you can write the above code in a hash style using the []
method:
R18n.get.t['account.info']
If the requested translation is not found, the error is not raised. Instead, the requested key is being returned:
R18n.get.t.no.translation # => [no.translation]
You may easily provide the default value using the |
method (note that there is only one pipe, which corresponds to this generic method):
R18n.get.t.no.translation | 'no translation!'
The translation itself is not a string but an instance of the Translation
class. For example, you may do the following:
R18n.get.t.no.translation.translated? # => false
It is somewhat tedious to always write R18n.get.t
so the library provides a couple of helper methods for you:
r18n
is the same as writingR18n.get
t
is a shorthand forR18n.get.t
l
is used to localize date/time and is the same as writingR18n.get.l
Alright, now that we understand the basics let's apply the knowledge into practice. I would like to utilize R18n helper methods inside my Account
class so include the corresponding module now:
module Bank class Account include R18n::Helpers # ... end end
Let's translate the string inside the info
method by providing three parameters:
module Bank class Account # ... def info t.account.info(owner, gender, balance) end end end
Simple, isn't it? Now add translations for the error message:
errors: not_enough_money_for_withdrawal: '[ERROR] This account does not have enough money to withdraw!'
errors: not_enough_money_for_withdrawal: '[ОШИБКА] На счету недостаточно средств для снятия!'
Utilize it inside the withdraw
method:
module Bank class Account def withdraw(amount) raise(WithdrawError, t.errors.not_enough_money_for_withdrawal) if balance < amount @balance -= amount end end end
Now, what about the date and time inside the transfer_to
method? Of course, we can localize them as well, so let's do it in the next section.
Localizing Date, Time and Numbers
As you remember, we have two messages with timestamps inside the transfer_to
method that mimic a transaction. Different countries use different date and time formats, so it would be nice to localize the timestamps as well. There is an l
method for that:
l(Time.now)
This method accepts a second optional argument that can have three possible values: :standart
(the default one), :full
and :human
. When using :full
format l
, that obviously returns a full date and time, for example "1st of September, 2017 16:53". :human
tries to format the date to a human-friendly format:
l(Date.new(2017, 8, 30), :human) # => 2 days ago
The corresponding translations are available in R18n out of the box. The problem, however, is that there is no easy way to provide custom formatting options. This is because they are not listed in the YAML file, but rather in a separate .rb file. Luckily, the library has a custom version of the
strftime method (and a bunch of others like format_integer
) that properly translates months names. Therefore, let's employ this method now. Firstly, add translations:
transaction: started: "[%1] Transaction started" ended: "[%1] Transaction ended"
transaction: started: "[%1] Начало транзакции" ended: "[%1] Окончание транзакции"
Then simply provide localized datetime inside the transfer_to
method:
module Bank class Account def transfer_to(another_account, amount) puts t.transaction.started i18n.locale.strftime Time.now, '%d %B %Y %H:%M:%S' begin withdraw(amount) another_account.credit amount rescue WithdrawError => e puts e else puts "#{owner} transferred $#{i18n.locale.format_integer(amount)} to #{another_account.owner}" ensure puts t.transaction.ended i18n.locale.strftime Time.now, '%d %B %Y %H:%M:%S' end end end end
Here I've also used the format_integer
method. The only message that is not yet translated in our application is the one inside the else
branch of the transfer_to
method. But there is a small thing to remember: some languages (like Russian, for example), have different forms of verbs depending on the gender. Therefore, we must introduce a custom filter to take care of that.
Using Filters
Filters in R18n are used to do something with the translation based on the conditions or fetch the appropriate part of it. For instance, there is a count
filter available that utilizes predefined pluralization rules and returns the proper translation. To use this filter, do the following:
cookies: count: !!pl 1: You have one cookie n: You have %1 cookies. Wow!
!!pl
part here is the name of the filter defined in the library's core. There are some other filters available, including
escape_html and
markdown. In order to use this filter, simply perform a translation like we did previously:
t.cookies.count(5) # => You have 5 cookies. Wow!
Note that some languages (Slavic, for instance) have more complex pluralization rules, therefore you might need to provide more data like this:
cookies: count: !!pl 1: У вас одна печенька 2: У вас %1 печеньки n: У вас %1 печенек. Ух ты!
This feature is supported out of the box by the pluralize
method that is redefined for Russian, Polish and some other languages in the following way:
def pluralize(n) if 0 == n 0 elsif 1 == n % 10 and 11 != n % 100 1 elsif 2 <= n % 10 and 4 >= n % 10 and (10 > n % 100 or 20 <= n % 100) 2 else 'n' end end
Check the file that corresponds to your language for more details. In the next example we need to craft a custom filter that will add support for the gender information. First of all, provide translations. For the English language we don't really care about the owner's gender:
account: info: "Account's owner: %1 (%2). Current balance: $%3." transfer: !!gender base: "%2 transferred $%4 to %3."
But for Russian we do:
account: info: "Владелец счёта: %1 (%2). Текущий баланс: $%3." transfer: !!gender male: '%2 перевёл %3 $%4' female: '%2 перевела %3 $%4'
You may wonder why the parameters are numbered starting from 2
but I'll explain it in a moment. Next, employ the add
method inside the LocaleSettings
class:
module Bank class LocaleSettings def initialize # ... R18n::Filters.add('gender', :gender) do |translation, config, user| end # ... end end end
One important thing to remember is that the filter should be added before you load translations using from_env
method, otherwise it won't work. The add
method accepts two arguments: the name of the filter and its label (optional). It also requires a block to be passed which basically explains what this filter should do. The block has three local variables:
translation
contains the actual translation that was requested by the user. Note that this object is not an instance of theR18n::Translation
class, it is just a hash with contents like{'male' => '...', 'female' => '...'}
config
contains information about the currently chosen locale and the requested key:{:locale=>Locale en (English), :path=>"account.transfer"}
. The object under the:locale
key is an instance of theR18n::Locales::En
class (or a similar one)user
is a first parameter passed to the method that should perform the actual translation. In our case this method will be calledtransfer
:t.account.transfer(self)
Now let's code the block's body. There are a couple of approaches we can use here, but let's simply check if the translation has one or more keys. If there are two keys - we get the one that equals to the user's gender. Otherwise, get the string under the base
key:
# ... R18n::Filters.add('gender', :gender) do |translation, config, user| translation.length > 1 ? translation[user.gender] : translation['base'] end
We can utilize this filter inside the transfer_to
:
module Bank class Account def transfer_to(another_account, amount) puts t.transaction.started i18n.locale.strftime Time.now, '%d %B %Y %H:%M:%S' begin withdraw(amount) another_account.credit amount rescue WithdrawError => e puts e else puts t.account.transfer self, owner, another_account.owner, i18n.locale.format_integer(amount) # <==== ensure puts t.transaction.ended i18n.locale.strftime Time.now, '%d %B %Y %H:%M:%S' end end end end
self
will be assigned to the user
local variable that we've seen earlier. All other variables will be forwarded to the translation and used there as parameters. What's interesting though, is that the first argument self
will be also available for us as the first parameter, that's why there is no parameter %1
:
base: "%2 transferred $%4 to %3."
Another thing you may ask is why do we need the :gender
label when creating the filter? Well, actually we don't but sometimes it may come in handy. By using this label you can enable, disable, or remove the chosen filter completely:
R18n::Filters.off(:gender) R18n::Filters.on(:gender) R18n::Filters.delete(:gender)
So, that's it. We have fully translated our small application using the R18n gem and it seems to be working just fine!
Stick with Phrase
Working with translation files can be challenging, especially when your app is of bigger scope and supports many languages. You might easily miss some translations for a specific language, which can lead to confusion among users.
And so Phrase can make your life easier: Grab your 14-day trial today. Phrase supports many different languages and frameworks, including JavaScript of course. It allows you to easily import and export translation data. What’s even greater, you can quickly understand which translation keys are missing because it’s easy to lose track when working with many languages in big applications.
On top of that, you can collaborate with translators as it’s much better to have professionally done localization for your website. If you’d like to learn more about Phrase, refer to the Phrase Localization Platform.
Conclusion
In this article, we have seen R18n, a gem to translate Ruby, Rails, and Sinatra applications in practice. We have integrated it into the sample shell application, added support for two languages, allowed to choose the desired one, and translated all the textual messages. Also, we've created a custom filter that adds support for gender information.
All in all, we have covered all the major areas of the R18n gem, but there are a bunch of other features available so make sure to browse the gem's docs. While reading this article you had a chance to look at the application's translation process from a bit different angle, and I really hope it was interesting for you.