Translating models, views and routes using Rails I18n API

As I've recently finished localizing this very website, I've had my chances to play with Rails i18n API. Now it's time to draw some conclusions about what's hot and what's not about it.

I find the Rails 3 I18n documentation lacking, so I've decided to write this article - maybe it'll help fellow developers a little to get a grip on this aspect of Rails programming. The localization I've performed consisted of few major steps, which allowed to localize every bit of this website. I'll describe them one by one below, explaining why bother with each step and how to implement it.

Preparing the application-wide logic

Why?

App needs to somehow determine and set current locale upon every request. This is the foundation for all steps that will follow. Some nice language-switching button will be also useful, both for translation during development and for users themselves.

How?

There's a good overview on the subject in the official Rails I18n API guide. I choose to implement the method that embeds locale just after root path in URL, e.g. www.cloudlessstudio.com/en/.... I've forced it even for default language. This way, URLs always point to specific language and return the exact same page, which is so RESTful. Although I've implemented it myself, the rails-translate-routes gem (described few steps later) took care of it and I ended up removing my own application-wide before_filters.

Localizing Rails itself

Why?

Rails comes with full English translation for validation messages, button captions, pluralization rules, time ago helpers or other date helpers - basically, everything you read in your unlocalized app right now and you didn't write yourself. No support for other languages is included.

How?

The bad news is that Rails internal i18n is almost undocumented. You have no means of knowing which keys need to be included in locale files - other than waiting for your app to scream that some specific key is missing. For example, I've tried to translate the time_ago_in_words helper and didn't manage to complete it. Not cool!
The good news is there is rails-i18n gem which contains pre-made translations for said elements. So I've bundled it and voila! You can optionally pick required locales by putting the following line into any of environment configs:

config.i18n.available_locales = [:en, :pl]

I can only regret that I haven't found this gem at the beginning. In my opinion it should be more highlighted in the official guide and not just mentioned between the lines in the middle of it, like it currently is.

Translating views

Why?

The best option, in my opinion, is inline i18n of views, especially those with small to moderate amount of text and simple tagging inside of translated elements. This allows to keep easily-maintainable pattern of one view for each action. Changing layout in the future doesn't force you to meddle with multiple files so it's DRY and Railish(R). Even more so, as having dictionary for phrases or words repeated in multiple places in an app allows to translate them just once and reuse them everywhere you like.

How?

This is the core of i18n API. Replace every word, phrase or chunk of text that needs translation with t '.phrase' and create the following structure in each language's YAML:

en:
  controller_name:
    action_name:
      phrase: "Some text"

This is called "lazy namespacing" and lets you maintain clean structure of locale files. You can also get to other controller/action keys using t 'controller_name.action_name.phrase'.
If translated text contains HTML tags, suffix the key name with _html. And if you need interpolation, use t '.phrase', :var => articles_path and %{var} in the localized text. I've used it mostly to send paths generated by Rails (as shown above), in order not to hard-code them. That's all for most of views.

One-shot translation of specific views

Why?

Translating whole views is the best way if they contain massive amounts of text and/or multiple HTML tags. This allows to use HAML goodness and avoid packing unreadable HTML in locale files. I've used it for the Help view, which contains lots of text and it felt cumbersome and ugly to push all the heavily-tagged text into locale files.

How?

Just duplicate the view file and add locale suffixes at the end of file names but before extension, resulting with names like help.en.html.haml and help.pl.html.haml.

Translating models and DB entries

Why?

Allows storing multi-lang versions of blog or app entries in the DB and fetching appropriate ones to users according on their locale. Automatic migration takes care of creating required tables in database and updating existing entries into multi-lang versions. Enhanced forms in views are all that will be needed for editorial tasks.

How?

The globalize3 gem takes care of creating translation tables, maintaining them and fetching appropriate data according to current locale. You won't even need to know the structure of DB elements specific to localized data. All that needs to be done is adding translates :title, :intro to models and writing appropriate migrations, which are described well on gem's github page.

Creating form logic is more tricky as, by default, globalize3 operates only on current locale's data. I wished for using single form for whole model including translations. This way each locale's fields can reside next to each other allowing for easy translation and all data can be sent with single submit.
The batch_translations gem is supposed to do just that but it's no longer maintained and doesn't work properly, generating the infamous WARNING: can't mass-assign protected attributes or just not working regardless of accepts_nested_attributes_for or attr_accessible settings. I've fixed it using custom helper in create and update methods. It takes care of translation data simply by switching between locales and using update_attributes with appropriate data extracted from nested params. Here's how it looks like:

def translation_update(obj, par)
  curr_locale = I18n.locale
  I18n.locale = I18n.default_locale
  if obj.update_attributes(params[par].merge({:translations_attributes => {}}))
    params[par][:translations_attributes].each do |num, trans|
      I18n.locale = trans[:locale]
      trans.delete :locale
      trans.delete :id
      unless obj.update_attributes(trans) 
        flash.now[:notice] = "Translation update failed for locale: #{I18n.locale.to_s.upcase}"
        I18n.locale = curr_locale
        return false
      end
    end
    I18n.locale = curr_locale
    return true
  else
    I18n.locale = curr_locale
    return false
  end
end

It's invoked in create/update in place of stock update_attributes, e.g. if translation_update(@article, :article) {..}.

Translating routes

Why?

Localizing routes is recommended in order to be SEO-friendly and make it clear to users, which languages they're using.

How?

I've tried the i18n-routing gem but it didn't work for me. The problem was I didn't want to change path names in whole app code and this gem expects you to use localized paths only, like articles_en_path/articles_pl_path. I want my old articles_path to just point to current locale.
Thankfully, there's also the rails-translate-routes gem, which is more customizable and so it happens that it allows for just that. It works by implementing its own locale setting logic, relieving you from coding default_url_options or set_locale. The gem is well documented and works as expected. The only downside is that it forces you to put route localization into separate YAML and I've also had to put mine outside of config/locales as it was colliding with other locale files - but it's not a party killer by any means.

The bottom line

Rails I18n API indeed allows for quite painless localization process, even with apps which weren't coded with multi-language support from the beginning. Too bad the documentation is lacking and it's hard to figure out scope/key names required for translation. There are gems which try to come with aid, but I still don't like the way it is.

Gems mentioned above, used for translating routes and models, are great assets and take care of trickiest parts of i18n which are not built into Rails. The necessity of writing custom helper to replace broken model forms gem once again taught me that it usually takes less time to implement something manually instead of relying on some half-baked gems.

In the end, it's great that after initiating i18n in an app, adding more languages is just a matter of bundling more locale files with it, with almost no code work required. But I've yet to try that - two locales are more than enough for now.

Comments

This post has not been commented by anyone yet.

Your five cents