Minimal I18n with Rails 3.2

This guest post is by Fabio Akita, also known as akitaonrails. He is a known Brazilian Ruby Activist and has been the program chairman for Rubyconf Brazil 2012 for the last 5 years. He also co-founded Codeminer 42, a software boutique specialized in taking care of outsourced work from fledgling startups that need great Rails developers. Fabio has been publicly evangelizing Ruby, Rails and agile techniques since 2006 and has talked around 100 times in conferences around the globe.

Fabio Akita If you don't know me, I'm natural from Brazil where we speak Brazilian Portuguese. If you're from outside of the USA, it's likely that you bump into the same issues as I do when writing apps that wants to achieve worldwide repercussion: internationalization and localization. Problem is, most developers are careless about it and start writing code with English and Portuguese all mixed up. And when the time comes to explicitly support both, we have to go deep intervention in the code to extract all the particular language bits into manageable structures.

Even though both Ruby and Ruby on Rails have gone through lots of improvements in this regard, several developers are still uncertain on how to properly use those features. One thing in particular, when talking about multi-cultural apps, there is more to it than just translating strings. Bear in mind that there is both Localization (L10n) and Internationalization (I18n). I won't go too deep into the matters of L10n but if you're building the next multi-cultural app, keep that in mind.

I've posted all the code I'll use in this article to my Github account, you can check it out here and you can also see a live version at my Heroku free account here.

Let's start with the basics:

Database and string codification

I don't intend to repeat all that has been discussed in the past about encodings, unicode, UTF8 and everything that is now properly and fully supported on Ruby 1.9. If you didn't follow that thread, I highly recommend you start reading Yehuda Katz's great articles:

If you're from countries that have English as the natural language, keep in mind one thing about Unicode and Latin1 encodings – from Wikipedia:

To allow backward compatibility, the 128 ASCII and 256 ISO-8859-1 (Latin 1) characters are assigned Unicode/UCS code points that are the same as their codes in the earlier standards. Therefore, ASCII can be considered a 7-bit encoding scheme for a very small subset of Unicode/UCS, and, conversely, the UTF-8 encoding forms are binary-compatible with ASCII for code points below 128, meaning all ASCII is valid UTF-8. The other encoding forms resemble ASCII in how they represent the first 128 characters of Unicode, but use 16 or 32 bits per character, so they require conversion for compatibility (similarly UCS-2 is upwards compatible with UTF-16).

Bottomline is that if you forget to deal with UTF8 and fallback to Latin1, you won't notice for a long time. Most modern systems: databases, text editors, etc already default to UTF8, but some don't. First things first: make sure you're saving your source code files as UTF8. Second: make sure your database was created with UTF8 support. For example, if you create your Rails app databases using the standard rake db:create, you're safe to have it as UTF8 but if you create them manually using your database command line tool, enforce UTF8. On MySQL you must do:

```EATE DATABASE dbname
CHARACTER SET utf8
COLLATE utf8_general_ci;

On PostgreSQL you must do:

CREATE DATABASE dbname
WITH OWNER "postgres"
ENCODING 'UTF8'
LC_COLLATE = 'en_US.UTF-8'
LC_CTYPE = 'en_US.UTF-8';

Obviously, change dbname and postgres accordingly. Don't mix up! If you're dealing with text, make sure your code, Ruby gems you depend on, all use UTF8. It was a much harder experience 2 years ago, but now that the community has committed to Ruby 1.9, you won't notice it most of the time.

About the source code, even if you save your file as UTF8 you have to take one extra care. If you're writing text in languages that need special characters, you must start your file with one the following lines:

# encoding: UTF-8
# coding: UTF-8
# -*- coding: UTF-8 -*-
# -*- coding: utf-8 -*-

Choose one and use just one, they all work the same and they instruct the Ruby interpreter to properly handle the special characters. Ruby will warn you of that if you try to run source code with non-English characters in it.

But I might add that most of the time, in a Rails app, having to add one of these lines can be considered a “code smell”. That's because you should've extracted that non-English text into external i18n files and your Ruby code should be free of language-specific text. So use this is you must, but in everyday programming you should extract those strings.

And an extra recommendation: people sometimes discuss whether we should write the code itself in our native languages or default to English. I hardly recommend that you must default to English for things such as class names, methods names, variables names, even documentation in comments within the code. We live in a globalized world and the market has already defaulted to English, so keep the pseudo-patriotic discussions for other places. In code you write in English. You never know when a foreigner might join your team. You never know when you will have to join a foreign team. Do not limit neither your code nor yourself.

Starting a new Rails app

I'll assume you already at least the very basics on how to bootstrap a new Rails app. The official Rails support for I18n started at Rails 2.2, and the great Rails Guides has a very good introduction on Rails Internationalization API. I'll assume that you read and understood it all so not to repeat what's already nicely explained there. The idea is to enhance on some of the points that I feel people still have a hard time dealing with.

L10n wise, you should start customizing your app by modifying the config/application.rb, approximately around line 28 to become something like the following snippet:

# Set Time.zone default to the specified zone and make Active Record auto-convert to this zone.
# Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC.
config.time_zone = 'Brasilia'

# The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
# config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
config.i18n.available_locales = [:en, :"pt-BR"]
config.i18n.default_locale = :"pt-BR"

# Configure the default encoding used in templates for Ruby 1.9.
config.encoding = "utf-8"

Throughout this article, I'll use Brazilian Portuguese and Brazil as an example non-English language and culture. You have to change it accordingly to your country. Time zone is one point that always confuses everybody, but the bottom line is that your database should always record date and time in UTC, the Greenwich GMT-0. I live in the “Brazilia”, which is GMT-3. That means that while in Greenwhich it's noon, in Brazil it's 9 AM. And I have an extra problem: my country is big enough to have 3 different time zones and Daylight Savings. Rails' ActiveSupport already does a decent job overriding what it must in order for you to be able to operate on dates and times regardless of their time zones because all basic operations goes through UTC.

Take this code (running within Rails console to have ActiveSupport already activated):

>> Time.zone = 'Brasilia'
=> "Brasilia"
>> t1 = Time.zone.local(2012,7,13,12,0,0)
=> Fri, 13 Jul 2012 12:00:00 BRT -03:00

>> Time.zone = 'Tokyo'
=> "Tokyo"
>> t2 = Time.zone.local(2012,7,13,12,0,0)
=> Fri, 13 Jul 2012 12:00:00 JST +09:00

[21] pry(main)> t1 - t2
=> 43200.0
[25] pry(main)> (t1 - t2) / 1.day
=> 0.5

We are using the exact same input date and time, 7/13/2012 12:00:00 PM. But when we create 2 Time objects using different time zones, you can see that the subtraction of both objects gives a 12 hours difference (which is the actual time difference between Brazil and Japan). Now, you can have people on both countries write in their local times and have operations that respect that difference.

But I digress. Coming back to Rails i18n support, you will read in the guides that the default location for translated strings is within config/locales. And you can have 2 difference kinds of files: Ruby or YAML. I recommend using YAML files but this is more a personal taste. You can even mix locale files in YAML and Ruby.

Now, Rails itself is internationalized, defaulting to English. So all ActiveRecord's validation messages, for example, are already properly extracted. One Rubyist that have been pitching about i18n support a long time ago is Sven Fuchs and he maintains a repository of i18n goodies for you to explore called rails-i18n. There you will find the files needed to translate the Rails framework itself. And if your country/language is not there, please contribute back.

In my case, I'm interested in the Brazilian Portuguese translations, you can download it like this:

curl https://raw.github.com/svenfuchs/rails-i18n/master/rails/locale/pt-BR.yml > config/locales/rails.pt-BR.yml

Those locale files don't only add translated strings, it also starts the basics of L10n by properly adding data formats. Check out this example view template in my demonstration app.

Rails commands Output in English Output in Brazilian Portuguese
number_to_currency(123.56) $123.56 R$ 123,56
number_to_human(100_555_123.15) 101 Million 100 milhões
I18n.l(Time.current, format: :long) July 23, 2012 22:26 Segunda, 23 de Julho de 2012, 22:25 h
distance_of_time_in_words(1.hour + 20.minutes) about 1 hour aproximadamente 1 hora

You can see that Rails already does a lot of heavy lifting for you, so don't put all that effort to waste.

Devise

Most web apps that have user authentication use Devise. If you want to learn more check out Ryan Bates' awesome screencasts:

The same as Rails, Devise also has extracted its internal strings and is fully internationalizable. Check out it's Wiki about i18n for more details. But you can start by downloading your translated files from Christopher Dell's project, like this:

curl https://raw.github.com/tigrish/devise-i18n/master/locales/en-US.yml > config/locales/devise.en.yml
curl https://github.com/tigrish/devise-i18n/blob/master/locales/pt-BR.yml > config/locales/devise.pt-BR.yml

But if you want to have everything translated, you have to go the extra mile and actually use Devise's generator to clone its view templates within your Rails app by running rails g devise:views. This will copy the templates in app/views/devise. Keep the templates you want and translate all of them. As an example, take the resend confirmation template:

<h2>Resend confirmation instructions</h2>

<%= form_for(resource, :as => resource_name, :url => confirmation_path(resource_name), :html => { :method => :post }) do |f| %>
<%= devise_error_messages! %>

<div><%= f.label :email %><br />
<%= f.email_field :email %></div>

<div><%= f.submit "Resend confirmation instructions" %></div>
<% end %>

<%= render "devise/shared/links" %>

You have to extract them manually. In the case of Brazilian Portuguese I have already done the heavy lifting myself, you can download them from my demonstration project and replace the originals. Don't forget to also download the YAML file:

wget https://raw.github.com/akitaonrails/Rails-3-I18n-Demonstration/master/config/locales/devise.views.en.yml > config/locales/devise.views.en.yml
wget https://raw.github.com/akitaonrails/Rails-3-I18n-Demonstration/master/config/locales/devise.views.pt-BR.yml > config/locales/devise.views.pt-BR.yml

This should take care of the view templates, but you also have to take care of Rails' Form Helpers properly translating your model attributes. The Rails Guides quickly explain that, but in summary you have to have something similar to the following snippets in your config/locales file:

activemodel:
  errors:
    <<: *errors
activerecord:
  errors:
    <<: *errors
  models:
    user: "Usuário"
    article: "Artigo"
  attributes:
    user:
      email: "E-mail"
      password: "Senha"
      password_confirmation: "Confirmar Senha"
      current_password: "Senha Atual"
      remember_me: "Lembre-se de mim"
    article:
      title: "Título"
      body: "Conteúdo"
      body_html: "Conteúdo em HTML"

The User model is what Devise creates for you by default. As an added example, there is a Article model. The code should speak for itself. You translate the model class name in activerecord.models and the attributes in activerecord.attributes.[model].

The extra mile on database tables with Globalize 3

We took care of most of the structural translations already but you still have your user generated content. If you will have an application that users from around the world can use, maybe you may want to have content that reflects each user's language. The concept is quite simple: each content :has_many translations.

O conceito é simples: queremos um suporte que me permita utilizar os mesmos nomes de atributos mas que devolvam valores diferntes dependendo da localização escolhida atualmente. If we would add an Rspec spec to cover this behavior, it would look like this:

describe Article do
  before(:each) do
    I18n.locale = :en
    @article = Article.create title: "Hello World", body: "Test"
    I18n.locale = :"pt-BR"
    @article.update_attributes(title: "Ola Mundo", body: "Teste")
  end

  context "translations" do
    it "should read the correct translation" do
      @article = Article.last

      I18n.locale = :en
      @article.title.should == "Hello World"
      @article.body.should == "Test"

      I18n.locale = :"pt-BR"
      @article.title.should == "Ola Mundo"
      @article.body.should == "Teste"
    end
  end
end

I chose to use Sven Fuchs' Globalize 3 gem. Add that to your Gemfile as gem 'globalize3', run the bundle command and you're good to go.

If you already have a Article model in your app, you should add a new migration like this:

class CreateArticles < ActiveRecord::Migration
  def up
    create_table :articles do |t|
      t.string :slug, null: false
      t.timestamps
    end
    add_index :articles, :slug, unique: true

    Article.create_translation_table! :title => :string, :body => :text
  end

  def down
    drop_table :articles
    Article.drop_translation_table!
  end
end

Do not use Rails 3's new change migration method. After that just migrate your database and let's go back to the Article model:

class Article < ActiveRecord::Base
  attr_accessible :slug, :title, :body, :locale, :translations_attributes

  translates :title, :body
  accepts_nested_attributes_for :translations

  class Translation
    attr_accessible :locale, :title, :body
  end
end

Don't mind the table created in the migration, you will use the Article model as usual. It will detect the current I18n.locale and save the content in the proper fields. Changing the current locale makes it query the different translations.

Managing your Globalized content with ActiveAdmin

Whenever I need an administration section, my first choice is to use the formidable Active Admin, it has a clean neutral design that my clients enjoy, it's easy to use, and easily customizable. If you have a model associated with a CarrierWave uploader, for example, it will automatically show a file input attribute and that's because it's using Formtastic underneath to assemble the forms automatically. Read Active Admin's documentation to understand how to get started.

Now, to support a Globalize 3 extended model we will need some more tweaking. First of all let's add additional gems to the Gemfile to help:

...
group :assets do
  gem 'jquery-ui-rails'
  ...
end
...
gem 'jquery-rails'
gem 'activeadmin'
gem 'ActiveAdmin-Globalize3-inputs'
...

Now, we need to tell Active Admin to handle the Article model. We do that by creating a app/admin/article.rb file like this:

ActiveAdmin.register Article do
  index do
    column :id
    column :slug
    column :title
    default_actions
  end

  show do |article|
    attributes_table do
      row :slug
      I18n.available_locales.each do |locale|
        h3 I18n.t(locale, scope: ["translation"])
        div do
          h4 article.translations.where(locale: locale).first.title
        end
      end
    end
    active_admin_comments
  end
...
end

The index block is quite standard. Now the show block is interesting as we are accessing the translations association from the Article model directly. We iterate through each supported translation, as defined in config/application.rb.

I'm using ActiveAdmin-Globalize3-inputs, which is turn depends on JQuery UI to adapt the administration form to use tabs for each locale.

Then we take advantage of ActiveRecord's ability to handle mass assigned nested attributes through accepts_nested_attributes_for. To take advantage of this feature, we need to edit our Article model like this:

class Article < ActiveRecord::Base
  attr_accessible :body, :slug, :title, :locale, :translations_attributes
    ...
  translates :title, :body
  accepts_nested_attributes_for :translations
    ...
  class Translation
    attr_accessible :locale, :title, :body
  end

  def translations_attributes=(attributes)
    new_translations = attributes.values.reduce({}) do |new_values, translation|
      new_values.merge! translation.delete("locale") => translation
    end
    set_translations new_translations
  end
    ...
end

Now we need to make sure JQuery UI is available by modifying app/assets/stylesheets/active_admin.css like this:

// Active Admin CSS Styles
@import "active_admin/mixins";
@import "active_admin/base";
@import "jquery.ui.tabs";

And also modify the app/assets/javascripts/active_admin.js like this:

//= require active_admin/base
//= require jquery.ui.tabs

Finally, there is a last bit that we need to add to the end of the app/admin/articles.rb file:

ActiveAdmin.register Article do
  ...
form do |f|
    f.input :slug
    f.globalize_inputs :translations do |lf|
      lf.inputs do
        lf.input :title
        lf.input :body

        lf.input :locale, :as => :hidden
      end
    end

    f.buttons
  end
end

That will tap into Active Admin's internal Formtastic dependency and with the gem we added it will produce a screen like this:

By the way, sometimes people forget that in order for the Asset Pipeline to properly compile Active Admin's assets in production, you have to declare them in the config/application.rb file like this:

config.assets.precompile += %w(active_admin.js active_admin.css)

As a last tip, Active Admin interface itself is fully internationalizable. Read it's documentation and you will find the YAML files that you can use to translate it to your native language.

I18n Routes

Last, but not least, for SEO purposes it is a good idea to have all or at least most of your URLs fully translated to your native language. For instance, we would want to have the following routes pointing all to the same actions:

/users/sign_in
/en/users/sign_in
/pt-BR/usuarios/login

There are several gems that try to achieve this, but the best I found so far is rails-translate-routes. As usual, just add it to our Gemfile like this: gem 'rails-translate-routes' and run the bundle command. Then go edit your config/routes.rb file to looks like this:

I18nDemo::Application.routes.draw do
  # rotas para active admin
  ActiveAdmin.routes(self)
  devise_for :admin_users, ActiveAdmin::Devise.config

  # rotas de autenticação do Devise
  devise_for :users

  # rotas pra artigos
  resources :articles

  # pagina principal
  get "welcome/index", as: "welcome"
  root to: 'welcome#index'
end

We can translate just what we need, as an example let's say that we want our Article routes and Devise's routes to be translated but we don't care for Active Admin's routes. So we can organize the routes file like this:

I18nDemo::Application.routes.draw do
  devise_for :users
  resources :articles
  get "welcome/index", as: "welcome"
  root to: 'welcome#index'
end

ActionDispatch::Routing::Translator.translate_from_file(
  'config/locales/routes.yml', {
    prefix_on_default_locale: true,
    keep_untranslated_routes: true })

I18nDemo::Application.routes.draw do
  ActiveAdmin.routes(self)
  devise_for :admin_users, ActiveAdmin::Devise.config
end

Where we put the translate_from_file defines the separation between what's translated and what is not. Now it's just a matter of creating a file named config/locales/routes.yml with the following translations:

en:
  routes:
pt-BR:
  routes:
    welcome: bemvindo
    new: novo
    edit: editar
    destroy: destruir
    password: senha
    sign_in: login
    users: usuarios
    cancel: cancelar
    article: artigo
    articles: artigos

The en.routes block is empty because – as I recommended in the beginning of the article – all our code is in English, so Rails will just pick the classes' names and the entire app is in English by default. In the [your language].routes just make the translations for the words you want. After all that, when we run Rails' rake routes task, we will have an output that looks like this:

...
article_pt_br GET    /pt-BR/artigos/:id(.:format)    articles#show {:locale=>"pt-BR"}
   article_en GET    /en/articles/:id(.:format)      articles#show {:locale=>"en"}
              GET    /articles/:id(.:format)         articles#show
              PUT    /pt-BR/artigos/:id(.:format)    articles#update {:locale=>"pt-BR"}
              PUT    /en/articles/:id(.:format)      articles#update {:locale=>"en"}
              PUT    /articles/:id(.:format)         articles#update
              DELETE /pt-BR/artigos/:id(.:format)    articles#destroy {:locale=>"pt-BR"}
              DELETE /en/articles/:id(.:format)      articles#destroy {:locale=>"en"}
              DELETE /articles/:id(.:format)         articles#destroy
welcome_pt_br GET    /pt-BR/bemvindo/index(.:format) welcome#index {:locale=>"pt-BR"}
   welcome_en GET    /en/welcome/index(.:format)     welcome#index {:locale=>"en"}
              GET    /welcome/index(.:format)        welcome#index
   root_pt_br        /pt-BR                          welcome#index {:locale=>"pt-BR"}
      root_en        /en                             welcome#index {:locale=>"en"}

Have you ever questioned yourself on the usage of named routes such as new_article_path in your view templates when you could just easily write “/articles/new”? Now you know why: the same named route will obey the internal I18n.locale and output the correct translated route. Pro tip: always try to adhere to the conventions instead of trying to be too smart, in this case, having being smart will cost you a lot of time to reconvert every hard-coded route as a named route.

We now need the application to be able to detect the locale options within the params hash, so let's edit /app/controllers/application_controller.rb:

class ApplicationController < ActionController::Base
  protect_from_forgery

  before_filter :set_locale
  before_filter :set_locale_from_url

  private

  def set_locale
    if lang = request.env['HTTP_ACCEPT_LANGUAGE']
      lang = lang[/^[a-z]{2}/]
      lang = :"pt-BR" if lang == "pt"
    end
    I18n.locale = params[:locale] || lang || I18n.default_locale
  end
end

Now both http://localhost:3000/en/articles and http://localhost:3000/pt-BR/artigos will respond correctly. To create links in our pages to change the language, we can create a little helper to put in the view layout:

module ApplicationHelper
  def language_links
    links = []
    I18n.available_locales.each do |locale|
      locale_key = "translation.#{locale}"
      if locale == I18n.locale
        links << link_to(I18n.t(locale_key), "#", class: "btn disabled")
      else
        links << link_to(I18n.t(locale_key), url_for(locale: locale.to_s), class: "btn")
      end
    end
    links.join("\n").html_safe
  end
    ...
end

The url_for helper will create links that return to the current page in the browser, but with the translated route and proper locale parameter. Just add the helper somewhere in your layout view template:

...
<div class="form-actions">
  <%= language_links %>
</div>
</body>
</html>

There are several different techniques to detect the language. You can make Rails understand subdomains, user's authenticated session, browser default language, cookies, but I prefer simple URI sections like the above examples show.

Conclusion

As you can see, there are several things we can add to our applications to make them fully international. But even if you're not planning to add multiple languages, it doesn't hurt to follow a few simple rules:

  • Make sure your database and source files are all using UTF8. It's very common to find applications running under Latin1 and having lot's of pain to reconvert everything to UTF8.
  • Having language specific text within your Ruby source code or view templates has to be considered a "code smell". Rails already makes all the heavy lifting, so just create a simple config/locales/en.yml to start.
  • Adding something as Globalize 3, on the other hand, may not be necessary unless you're sure you will need it. It's not difficult to add it later.
  • Do not use methods such as strftime or other methods that hard code the format of data conversions. Use I18n.localize for formatting.
  • And study more about Time zones and Rails support, you never know when you're gonna be bitten by time related issues.

There is a lot more you can tweak in your Rails application but this covers what you will face most commonly in your next multi-cultural world-wide application.

I hope you found this article useful. Feel free to ask questions and give feedback in the comments section of this post. Thanks!

comments powered by Disqus