L10n Best Practices in Rails

We’ve been doing Ruby on Rails projects here at Agency Fusion for a year now. I’m somewhat of a stickler for doing things The Right Way™. Luckily, the Rails framework has a pretty nice method for translating strings throughout the site. In this post I’m going to outline some of the different ways that you can implement localized strings and the advantages and disadvantages of each.

First, in Rails there are two methods used for localization: translate (aliased t) and localize (aliased l). Pretty straightforward, right? See the ActionView::Helpers::TranslationHelper docs for more info. Here are some quick examples of them being used in the wild:

/ app/views/example/index.html.slim
/ In slim
h1 =t 'This is the header!'

/ Localized date (slim only)
span.date =l @user.created_at
<!-- app/views/example/index.html.erb -->
<!-- in erb -->
<h1><%=t 'This is the header!' %></h1>

I like to put the t next to the = because it looks nice and concise, but the following is the exact same thing:

/ app/views/example/index.html.slim %>
/ In slim
h1 = t('This is the header!')
/ Localized date
span.date = l(@user.created_at)
<!-- In erb -->
<h1><%= t('This is the header!') %></h1>

The yaml file with translated strings would look like this:

# config/ko.yml The Korean translation.
ko:
  "This is the header!": "이것은 헤더입니다!"

Okay, now let’s discuss the above strategy.

I18n gettext style

The above type of translation is a lot like the gettext style of translating strings. You provide the full English translation, and that translation is used as a sort of key. The problem with doing it this way in Rails, though, is that Rails doesn’t have a full implementation of gettext, so it doesn’t allow you to attach a domain and/or context to a string. What this means is that all instances of 'This is the header!' will be translated exactly the same across all uses, even if it makes sense to translate one instance a different way for some languages. To sum up:

  • Advantages:
    • Easy and straightforward.
    • If there are no translations for that key in the appropriate language, it will just use the key, which is great for English.
  • Disadvantages:
    • There is no way to contextualize the translation.

Symbolized Keys

Another method that has similar strengths and weaknesses is to you use symbols as translation keys:

/ app/views/example/index.html.slim
h1 =t :this_is_the_header
# config/ko.yml
ko:
  this_is_the_header: "이것은 헤더입니다!"

This is nice, mostly because symbols are such a beautiful feature of Ruby syntax. They have the same advantages and disadvantages of using a full-text key, except that when there isn’t a translation available in the yaml file, it will return a capitalized version of the string:

This Is The Header

This is okay in a lot of instances, but again, it doesn’t provide the contextualization that you often need with translations.

A contextualization pattern for Rails i18n

To include context, you can namespace your keys. This is a little bit of a combination between the first two approaches with a little twist:

/ app/views/example/index.html.slim
h1 =t '.this_is_the_header'

The reason I gave the full relative path to the view was so that we could see what controller and action the view belonged to. The above example would be the view for the ExampleController’s index action. This is important because the . before this_is_the_header in the translation key automagically provides controller/action context to the key. The yaml file then looks like this:

# config/ko.yml
ko:
  example:
    index:
      this_is_the_header: "이것은 헤더입니다."

I love this way of doing things, but it is also not without disadvantages. The main disadvantage is that you have to now have a translation of the same basic key (this_is_the_header) for every view that uses it. This can suck for some strings that you use throughout the site.

One method to overcome this, though, is to use the :default option as an option argument to the t method:

/ app/views/example/index.html.slim
h1 =t '.this_is_the_header', default: t('common.this_is_the_header')
# config/ko.yml
ko:
  common:
    this_is_the_header: "이것은 헤더입니다!"

Now the key has a default translation, but can be overridden per context if needed. So if there is another view that you decide to override for Korean, you can do so without affecting all other instances of the translated key:

/ app/views/example/other.html.slim
h1 =t '.this_is_the_header', default: t('common.this_is_the_header')
# config/en.yaml
en:
  common:
    this_is_the_header: "This is the header!"
# config/ko.yaml
ko:
  common:
    this_is_the_header: "이것은 헤더입니다!"
  example:
    other: "이게 헤더잖아!"

The Korean translation above defaults in all instances except the other action to the common.this_is_the_header translation, but the English is the same across the board.

This method, unfortunately still has one ugly disadvantage, and that is that translating strings can sometimes make for really long lines in your views when you provide interpolated elements to your translations:

# config/ko.yml
ko:
  common:
    this_is_the_header: "%{name}, 이것은 헤더입니다!"
/ app/views/example/index.html.slim
h1 =t '.this_is_the_header', name: @user.name, \
  default: t('common.this_is_the_header', name: @user.name)

Maybe there’s a better way to deal with this, but I am as yet unaware of what it might be. Leave any ideas in the comments, thanks!