Translate 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 byAndrey 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!
Writing code to localize your application is one task, but working with translations is a totally different story. Having many translations for multiple languages may quickly overwhelm you which will lead to the user’s confusion. But Phrase can make your life as a developer easier! Grab your 14-day trial now. Phrase supports many different languages and frameworks, including Ruby and Rails. It allows to easily import and export translations data and search for any missing translations, which is really convenient. On top of that, you can collaborate with translators as it is much better to have professionally done localization for your website. If you’d like to learn more about Phrase, refer to the Getting Started guide.
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! Do you like the approach that R18n suggests? What features do you find useful and which ones need improvement? Share your experience in the comments. As always, I thank you for staying with me and happy coding!
Originally published on The Phrase Blog.