Translate Ruby Applications with the R18n ruby gem

All Rails developers are familiar with the build-in I18n library that allows easy translation of the application. What if you need to introduce internationalization for the pure Ruby app? R18n is a gem that can help you with that and in this article, you will learn how to work with it.

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.

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:

1

2

3

4

5

6

# bank/lib/account.rb

moduleBank

classAccount

end

end

The bank folder will also contain a bank.rb file with the following minimalist contents:

1

2

3

4

5

6

# 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.

1

2

3

4

5

6

7

8

9

10

11

12

13

# bank/lib/account.rb

moduleBank

classAccount

attr_reader:owner,:balance,:gender

definitialize(owner:,balance:0,gender:)

@owner=owner

@balance=balance

@gender=check_gender_validity_forgender

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:

1

2

3

4

5

6

7

8

9

10

11

moduleBank

classAccount

VALID_GENDER=%w(male female).freeze

# ...

private

defcheck_gender_validity_for(gender)

VALID_GENDER.include?(gender)?gender:'male'

end

end

end

Next add a credit and withdraw methods:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

moduleBank

classAccount

# ...

defcredit(amount)

@balance+=amount

end

defwithdraw(amount)

raise(WithdrawError,'[ERROR] This account does not have enough money to withdraw!')ifbalance<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:

1

2

3

4

5

6

# bank/lib/errors.rb

moduleBank

classWithdrawError<StandardError

end

end

Don’t forget to require this file inside the bank.rb:

1

2

3

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:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

moduleBank

classAccount

# ...

deftransfer_to(another_account,amount)

puts"[#{Time.now}] Transaction started"

begin

withdraw(amount)

another_account.creditamount

rescueWithdrawError=>e

putse

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:

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:

1

2

3

4

5

6

7

8

9

10

11

12

# runner.rb

require_relative'bank/bank'

john_account=Bank::Account.newowner:'John',balance:20,gender:'male'

kate_account=Bank::Account.newowner:'Kate',balance:15,gender:'female'

putsjohn_account.info

john_account.transfer_to(kate_account,10)

putsjohn_account.info

putskate_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!

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:

1

gem install r18n-desktop

Then require it inside the bank/bank.rb file:

1

2

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:

1

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:

1

2

3

4

# bank/lib/i18n/en.yml

account:

info: "Account's owner: %1 (%2). Current balance: $%3."

1

2

3

4

# 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:

1

2

3

4

5

6

# bank/lib/locale_settings.rb

moduleBank

classLocaleSettings

end

end

Require this file inside the bank/bank.rb:

1

2

3

4

5

6

7

8

require'r18n-desktop'

require_relative'lib/errors'

require_relative'lib/locale_settings'

require_relative'lib/account'

moduleBank

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:

1

2

3

4

5

6

7

8

9

10

11

12

moduleBank

classLocaleSettings

definitialize

puts"Select locale's code:"

R18n.from_env'bank/lib/i18n/'

putsR18n.get.available_locales.map(&:code)

R18n.get.available_locales.eachdo|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 code R18n.from_env 'path', 'en'

R18n.get returns the R18n object for the current thread. Next we simply use the available_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):

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:

1

t('account.info')

When using R18n, however, you should write

1

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:

1

R18n.get.t['account.info']

If the requested translation is not found, the error is not raised. Instead, the requested key is being returned:

1

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):

1

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:

1

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 writing R18n.get

t is a shorthand for R18n.get.t

l is used to localize date/time and is the same as writing R18n.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:

1

2

3

4

5

6

moduleBank

classAccount

includeR18n::Helpers

# ...

end

end

Let’s translate the string inside the info method by providing three parameters:

1

2

3

4

5

6

7

8

moduleBank

classAccount

# ...

definfo

t.account.info(owner,gender,balance)

end

end

end

Simple, isn’t it?

Now add translations for the error message:

1

2

errors:

not_enough_money_for_withdrawal: '[ERROR] This account does not have enough money to withdraw!'

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:

1

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:

1

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.

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:

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:

1

2

3

4

account:

info: "Account's owner: %1 (%2). Current balance: $%3."

transfer: !!gender

base: "%2 transferred $%4 to %3."

But for Russian we do:

1

2

3

4

5

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:

1

2

3

4

5

6

7

8

9

10

module Bank

classLocaleSettings

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 the R18n::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 the R18n::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 called transfer: 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:

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:

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:

1

2

3

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 PhraseApp!

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 PhraseApp can make your life as a developer easier!

Grab your 14-days trial now. PhraseApp 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 PhraseApp, 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.