Ruby on Rails and I18n

In the lifetime of most successfull Rails applications, comes a moment, when it will have to speak many languages. If a developer did not thought of such possibility at the very beginning of the project, that will cause some trouble and manual work. There are so many places where it was just easy and convenient to use static texts (most likely in English), that at this point this will imply digging through the source code and manually searching for strings. The most tedious part of whole translation process is ealing with dozens of views, controllers and mailers. That’s where most of the strings presented to the user live.

Ruby on Rails has a standard way of dealing with multilingual applications: the i18n gem. Using it is dead easy. When we want to have some text translated, we simply put a call to the I18n.translate method with corresponding parameters. We can also use shortened method alias: I18n.t, and inside views even same method name t without leading module I18n. The basic parameter which we will focus on, is a string key uniquely identifying given translation. This key can (or even should) be hierarchical, so constructing names like contacts.form.greeting is possible. Please also note that above key does not contain any identifier of the desired language itself.

In addition to that, a very important piece of translation system in Rails is underlying storage engine. By default, I18n comes with a Simple backend, that reads the translations from YAML files. It looks similiar to this:

en:
 contacts:
  form:
   greeting: "Greetings, stranger. Please fill in this form"

Those files are located in config/locales directory. For each language, we should at least consider creating a separate file (in this case en.yml). We can certainly break down the whole language translation tree into multiple files, or even keep all languages in one. It is also possible to use other storage engines, such as reading/writing translations in a database, however for the purposes of this article we will focus on the default one only.

Translating existing Rails application

We have a huge application with dozens of views, flash messages hidden somewhere in controller’s actions, e-mails subjects and contents, and afraid to think - JavaScript source files. The developer did not use Rails translation’s functionality at all. In this case there is a lot of tedious work that needs to be done. Manually going through all the places mentioned above, finding all translatable strings, replacing them with I18n.t function call, coming up with some key identifying given text and finally: inserting new key in YAML translations files with value originally copied from the source. For each desired language of course. Sounds like a nightmare.

Fortunately, i18n-tasks gem comes to the rescue (or at least part of spadework to do). Although at first glance it’s not quite suitable to do what we need:

“i18n-tasks helps you find and manage missing and unused translations”

The main thing it does, which in my opinion is extremely useful, is to go through the entire project structure, look for used I18n.t function calls and collect used keys names. Then it structurize found keys, presenting the results in a nice, colored form. Found ones, depending on the configuration, this gem task will add to YAML translations files. It will also alphabetically sort them at the same time, of course skipping existing ones so nothing will be overwritten. Furthermore, it can work in second mode, removing keys added to YAMLs, which hasn’t been found used in application code.

It is important also to specify which folders i18n-tasks should be scanning, where (and when) to add the default scope. To do so, we modify appropriate entries in the config/i18n-tasks.yml configuration file:

## Root for resolving relative keys (default)
relative_roots:
  - app/views
  - app/controllers

If given key is not yet added to any YAML file, appropriate entry will be made, and a human-readable representation of the key name will be inserted as the value of it. For example, if key is named greetings_stranger, then value for it will be “Greetings stranger”. In addition, it will be placed in appropriate place in the translation file hierarchy. To improve working with translations, i18n-tasks gem allows skipping key scope prefix, which identifies it uniquely. I.e. if the translation will be used in app/views/contacts/_form.html.erb view file, then function calls I18n.t("contacts.form.greetings_stranger") and I18n.t(".greetings_stranger") will be unambiguous. Then i18n-tasks command will use the abbreviated name of the key with the name and location of the file altogether to put it in the right place at YAML hierarchy. Pretty neat.

There are still two problems to solve:

  • in application code we have are no I18n.t calls
  • even if we had them, converting name of the key and using it as a value is inadequate for us

So let’s maybe start from solving the second one. If we read Rails internationalization documentation deeply, we can find information about optional parameters of a t function. Particularly useful would be for us default key of second, options parameter (Hash). Well, if the translation engine does not find in its backend desired key, specially prepared span tag will be rendered there:

<span class="translation_missing" title="missing: en.views.contacts.form.hello">Hello</span>

with the text content being human readable last element of the key name. By providing a value for default as follows, it will be used instead:

I18n.t(".hello", default: "Hello, world")

By adding a small, very naive workaround to i18n-tasks, it should fetch the value of default key (if given), and use in file translations file: commit. There is a little problem though. This piece of code can’t handle scenario, when there are two subsequent I18n.t calls with default value to extract in a single line of code. For my needs, it was good enough. In the end, we can run i18n-tasks add-missing command and enjoy correct strings pulled into YAML files.

Back to the first problem. As programmers are lazy creatures by nature, manually replacing every translatable string with specially prepared t function call is not an option. First thing that came to my mind was to write a script, which would scan through all files in given directory and perform a bit of gsub magic. I decided, however, that automatically detecting which strings responds to texts presented to the user, and which ones are just a piece of code (HTML, Ruby, …) would be too complicated and would not compensate for the time spent on my little adventure. I decided (unfortunately) to manually search for such places.

I wrote small plugin for my text editor (SublimeText), which overwrites selected text onto specially crafted call of I18n.t. Back to the point, the result was surprisingly simple:

import sublime
import sublime_plugin
import re
class TranslateCommand(sublime_plugin.TextCommand):
  def run(self, edit):
    for region in self.view.sel():
      if not region.empty():
        s = self.view.substr(region)
        key = re.sub('[^a-z0-9]+', '_', re.sub('(^[^a-z]|[^a-z0-9]$)', '', s[:30].lower()))
        self.view.replace(edit, region, "=t(\"{key}\", default: \"{s}\")".format(**locals()))

This is some python code particularly designed for use as a plugin in graphical editor SublimeText. From selected text, it creates a lower case snake_case key without any special characters and a maximum length of 30 characters. It also removes some characters, which are not allowed be at the beginning of generated key.

A similar macros for gEdit editor, written also in python, looks as follows. First snippet I used to translate some standard strings. Second one, when I had had to deal with texts, which only part of required translating (mostly strings in double quotation marks, with interpolation variables).

$1=t(".$< import re; return re.sub('[^a-z0-9]+', '_', re.sub('(^[^a-z]|[^a-z0-9]$)', '', $GEDIT_SELECTED_TEXT[:30].lower())) >", default: "$GEDIT_SELECTED_TEXT")

$1#{t('.$< import re; return re.sub('[^a-z0-9]+', '_', re.sub('(^[^a-z]|[^a-z0-9]$)', '', $GEDIT_SELECTED_TEXT[:30].lower())) >', default: '$GEDIT_SELECTED_TEXT')

Summary

In this manner, at a low effort I was able to relatively quickly and efficiently translate large existing Rails application. If you’re aware of any easier method, please do share in the comments. Macros for other popular editors are also welcome (vim users preferably).

PS. Yes, I deliberately omitted in this post translating texts built into JavaScript files. Because it sucks. There is of course i18n-js gem, which is well suited to the task, but still, painstaking work to replace texts with functions calls has to be done (unless you write a macro ). Therefore, for an occasional use, I recommend just simply pass translated strings via HTML elements data attributes.

Post by Kamil Dziemianowicz

Kamil, long-term employee of AmberBit (joined 2012), is a full stack developer (Ruby, JavaScript).