Software localization

Translating Ruby Applications with the R18n Ruby Gem

R18n is a ruby gem that lets you implement i18n for Ruby apps. This guide will walk you through how to use it for translating Ruby apps.
Software localization blog category featured image | Phrase

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

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

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

# ...

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.