Ruby
Article

Go Global with Rails and I18n

By Ilya Bodrov-Krukowski

bola del mundo

You have probably heard of the Tower of Babel. This is a Biblical legend that speaks of a time, hundreds of years ago, where all people spoke the same language. However, when they started to build the Tower of Babel in an attempt to reach the skies, the Lord confound their language so that they could no longer understand each other and finish the job.

True or not, people today speak different languages. One day, you may need to enable support for different languages on your site. How would you do that? What if there is user-generated content that needs to be translated as well? Read on and find out!

The following topics will be covered in this article:

  • Rails internationalization API (I18n)
  • Switching languages on the website
  • Automatically setting language for the user based on his location (Geocoder gem)
  • Storing different versions of the user-generated content in the database (Globalize gem)

The source code is available on GitHub and a working demo can be found at http://sitepoint-i18n.herokuapp.com.

Ground Work

For this demo I will be using Rails 4.1.5 but the same solution can be implemented with Rails 3.

For today’s purposes, let’s create a simple educational website called Educator. First of all, we have to lay down some ground work . Fear not – this iteration will be short.

Create a new Rails app without the default testing suite:

$ rails new educator -T

Our site will have posts published by tutors. We are not going to implement an authentication and authorization system – only a model, controller, view, and a couple of routes. Here is the list of fields that will be present in the articles table (alongside with id, created_at, updated_at):

  • title (string)
  • body (text)

Create and apply the required migration:

$ rails g model Article title:string body:string
$ rake db:migrate

Now add the following routes to your routes.rb file:

config/routes.rb

[...]
resources :articles, only: [:index, :new, :create, :edit, :update]
root to: 'articles#index'
[...]

Next, create the articles_controller.rb file and paste in the following code:

articles_controller.rb

class ArticlesController < ApplicationController
  def index
    @articles = Article.order('created_at DESC')
  end

  def new
    @article = Article.new
  end

  def create
    @article = Article.new(article_params)
    if @article.save
      flash[:success] = "The article was published successfully!"
      redirect_to articles_path
    else
      render 'new'
    end
  end

  def edit
    @article = Article.find(params[:id])
  end

  def update
    @article = Article.find(params[:id])
    if @article.update_attributes(article_params)
      flash[:success] = "The article was updated successfully!"
      redirect_to articles_path
    else
      render 'edit'
    end
  end

  private

  def article_params
    params.require(:article).permit(:body, :title)
  end
end

There is nothing special here – some basic methods to display, create, and edit articles.

If you are on Rails 3 and not using strong_params, replace the article_params method with params[:article] and add the following line to the model:

models/article.rb

[...]
attr_accessible :body, :title
[...]

Proceed to the view. To help with styling, let’s use Twitter Bootstrap:

Gemfile

[...]
gem 'bootstrap-sass'
[...]

bundle install

stylesheets/application.css.scss

@import 'bootstrap';
@import 'bootstrap/theme';

javascripts/application.js

[...]
//= require bootstrap

Now, we can take advantage of Bootstrap’s power:

layouts/application.html.erb

[...]
<body>
  <div class="navbar navbar-default navbar-static-top">
    <div class="container">
      <div class="navbar-header">
        <%= link_to 'Educator', root_path, class: 'navbar-brand' %>
      </div>

      <ul class="nav navbar-nav navbar-left">
        <li><%= link_to 'Add new article', new_article_path %></li>
      </ul>
    </div>
  </div>

  <div class="container">
    <% flash.each do |key, value| %>
      <div class="alert alert-<%= key %>">
        <%= value %>
      </div>
    <% end %>

    <div class="page-header">
      <h1><%= yield :page_header %></h1>
    </div>

    <%= yield %>
  </div>
</body>
[...]

Note that I am using yield :page_header here so that different page headers may be provided easily when rendering different views.

Create the actual view:

articles/index.html.erb

<% content_for(:page_header) {"List of articles"} %>

<%= render @articles %>

content_for here is used to provide a page title for the yield block that I talked about above. We also have to create an _article.html.erb partial that will be rendered for each article in the @articles array:

articles/_article.html.erb

<h2><%= article.title %></h2>

<small class="text-muted"><%= article.created_at.strftime('%-d %B %Y %H:%M:%S') %></small>

<p><%= article.body %></p>

<p><%= link_to 'Edit', edit_article_path(article), class: 'btn btn-default' %></p>
<hr/>

strftime displays the creation date in the following format: “1 January 2014 00:00:00”. Read more about supported flags for strftime here.

Views for the new and edit actions are even simpler:

articles/new.html.erb

<% content_for(:page_header) {"New article"} %>

<%= render 'form' %>

articles/edit.html.erb

<% content_for(:page_header) {"Edit article"} %>

<%= render 'form' %>

Lastly, the _form.html.erb partial:

articles/_form.html.erb

<%= form_for @article do |f| %>
  <div class="form-group">
    <%= f.label :title %>
    <%= f.text_field :title, class: 'form-control', required: true %>
  </div>

  <div class="form-group">
    <%= f.label :body %>
    <%= f.text_area :body, rows: 3, class: 'form-control', required: true %>
  </div>

  <%= f.submit 'Submit', class: 'btn btn-primary btn-lg' %>
<% end %>

Fire up the server and create a couple of articles to check that everything is working. Great, now we can move on to the next iteration!

That Long Word “Internationalization”

Suppose our educational website is gaining popularity and we want to encourage even more users to visit. One way to do this is by making the site multilingual. Students from different countries could choose an appropriate language and use our resources comfortably.

For now we will present users with two languages to choose from: English and Russian. Why Russian? Well, it is one of the most popular languages in the world, spoken by 254 millions of people and… well, it is my native language. Of course, you might choose any language (choosing a non-widespread language may require you to do some extra work, which I discuss in a bit.)

Drop in a new gem into your Gemfile:

Gemfile

[...]
gem 'rails-i18n', '~> 4.0.0' # for Rails 4
# OR
gem 'rails-i18n', '~> 3.0.0' # for Rails 3
[...]

Don’t forget to run

$ bundle install

rails-i18n is a repository for collecting locale data for Ruby on Rails I18n. Okay, what the heck is I18n? Actually, this is nothing more that a shortened version of the word “internationalization” – there are exactly 18 letter between the first “i” and the last “n”. Silly, isn’t it?

This gem provides basic locale data for different languages, like translation for the month names, days, validation messages, some pluralization rules, etc. The full list of supported languages can be found here. If your language is not in the list you will need to add translations yourself.

We need to provide an array of languages that will be available in our app:

config/application.rb

[...]
I18n.available_locales = [:en, :ru]
[...]

By the way, you can also adjust the default time zone (“Central Time (US & Canada)” is used initially) and default locale (:en is used initially, which, as you’ve guessed, means “English”):

config.time_zone = 'Moscow' # set default time zone to "Moscow" (UTC +4)
config.i18n.default_locale = :ru # set default locale to Russian

Each locale requires a file to store translations for the headers, buttons, labels, and other elements of our site. These translation files reside inside the the config/locales directory with the extension of .yml (YAML – Yet Another Markup Language).

By default there is only one file, en.yml, with some demo content and quick explanations. Replace this file with the following content:

config/locales/en.yml

en:
  activerecord:
    attributes:
      article:
        title: "Title"
        body: "Body"
  forms:
    messages:
      success: "Operation succeeded!"
    buttons:
      edit: "Edit"
      submit: "Submit"
  menu:
    new_article: "Add an article"
  articles:
    index:
      title: "List of articles"
    edit:
      title: "Add article"
    new:
      title: "New article"

Some things to note here. First of all, we have to provide the locale of this file, which is done with the root key, en. All other translation data in nested inside the locale key. To store translations for Active Record attributes,use the structure activerecord - attributes - *model_name*.

After that, it’s really up to the developer to organize the keys based on their own personal preference. I’ve used a forms key to contain translations for form elements.

We will skip discussing the articles block for now. For now, let’s use some pretty bizzare cyrillic letters to store Russian translations:

config/locales/ru.yml

ru:
  activerecord:
   attributes:
     article:
       title: "Название"
       body: "Текст"
  forms:
    messages:
      success: "Операция была успешно выполнена!"
    buttons:
     edit: "Редактировать"
     submit: "Отправить"
  menu:
    new_article: "Добавить статью"
  articles:
    index:
      title: "Список статей"
    edit:
      title: "Редактировать статью"
    new:
      title: "Добавить статью"

There is a joke about a guy who showed some “tricks” using latin letters like these: “R –> Я”, “N –> И”. Actually, he was just using cyrillic letters. Anyway, we can use this translation data in our views.

There is a method called translate that accepts at least one argument and does a lookup in the translation file with regards to the currently set locale of the app. Read more here. If you are new to I18n, I encourage you to fully read that document.

In the views, the translate method can be called with an alias, t:

t('menu.new_article')

Or:

t(:new_article, scope: :menu) # This uses a scope to explain where to find the required key

OK, let’s try this out in the layout file:

layouts/application.html.erb

<body>
  <div class="navbar navbar-default navbar-static-top">
    <div class="container">
      <div class="navbar-header">
        <%= link_to 'Educator', root_path, class: 'navbar-brand' %>
      </div>

      <ul class="nav navbar-nav navbar-left">
        <li><%= link_to t('menu.new_article'), new_article_path %></li>
      </ul>
    </div>
  </div>

  <div class="container">
    <% flash.each do |key, value| %>
      <div class="alert alert-<%= key %>">
        <%= value %>
      </div>
    <% end %>

    <div class="page-header">
      <h1><%= yield :page_header %></h1>
    </div>

    <%= yield %>
  </div>
</body>

I’ve replaced the < %= link_to 'Add new article', new_article_path %> to < %= link_to t('menu.new_article'), new_article_path %>. Now, depending on the current locale, this link will contain different text. This is a very convenient way to work with different locales. If, sometime in the future, you need to add support for German on your site, just create another locale file and add German to the array of available languages.

Let’s modify index.html.erb view now:

<% content_for(:page_header) {t('.title')} %>
<%= render @articles %>

Something different is going on here. I am just using t('.title'), but the title for the index page is stored inside the articles.index scope. Why is it working? To make our life a bit easier, Rails supports “Lazy” lookup. If we have an index.html.erb view that is stored inside the articles directory and the following structure inside the translation file:

articles:
  index:
    title: "List of articles"

Inside the index.html.erb we may lookup the title key by simply issuing t('.title'). The same solution can be implemented for the other views:

articles/new.html.erb

<% content_for(:page_header) {t('.title')} %>
<%= render 'form' %>

articles/edit.html.erb

<% content_for(:page_header) {t('.title')} %>
<%= render 'form' %>

Move on to the _article.html.erb partial:

articles/_article.html.erb

<h2><%= article.title %></h2>

<small class="text-muted"><%= l(article.created_at, format: '%-d %B %Y %H:%M:%S') %></small>

<p><%= article.body %></p>

<p><%= link_to t('forms.buttons.edit'), edit_article_path(article), class: 'btn btn-default' %></p>
<hr/>

Note that the strftime method is replaced by l, an alias for localize. This method takes the provided date and/or time and formats it in an appropriate way by using the corresponding translation data (presented by the rails-i18n gem).

Don’t forget about the form:

articles/_form.html.erb

<%= form_for @article do |f| %>
  <div class="form-group">
    <%= f.label :title %>
    <%= f.text_field :title, class: 'form-control', required: true %>
  </div>

  <div class="form-group">
    <%= f.label :body %>
    <%= f.text_area :body, rows: 3, class: 'form-control', required: true %>
  </div>

  <%= f.submit t('forms.buttons.submit'), class: 'btn btn-primary btn-lg' %>
<% end %>

As long as we’ve specified translations for Article‘s attributes, they will be put into the corresponding labels.

Lastly, translate the text for the flash messages:

articles_controller.rb

[...]

def create
  @article = Article.new(article_params)
  if @article.save
    flash[:success] = t('forms.messages.success')
    redirect_to articles_path
  else
    render 'new'
  end
end

[...]

def update
  @article = Article.find(params[:id])
  if @article.update_attributes(article_params)
    flash[:success] = t('forms.messages.success')
    redirect_to articles_path
  else
    render 'edit'
  end
end

[...]

Let’s discuss one more thing in this iteration. Suppose you want to show how many educational articles there are in the database. To count all articles, you may use something like @articles.length, but what about the pluralization? In English, the pluralization rules are simple: “0 articles”, “1 articles”, “2 articles” etc. In Russian, however, things start to get a bit complicated because pluralization rules are not that straightforward. It appears that I18n module is clever enough to work with pluralization rules.

locales/en.yml

[...]
articles:
  index:
    count:
      one: "%{count} article"
      other: "%{count} articles"
[...]

locales/ru.yml

articles:
  index:
    count:
      zero: "%{count} статей"
      one: "%{count} статья"
      few: "%{count} статьи"
      many: "%{count} статей"
      other: "%{count} статьи"

Here I am simply adding which variant to use in which case. Also, note the %{count} – this is interpolation.

Now we can add this line to the view:

articles/index.html.erb

<small><%= t('.count', :count => @articles.length) %></small>

I am passing a hash as the second argument to the t method. It uses this count attribute to choose the required translation and interpolates its value into the string. Cool, isn’t it?

Reload the server and observe the results. As long as the default locale is set to English you will not
spot any major differences.

Let’s give our users a way to change the language of the website.

Changing Language

Let’s add anguage switching controls to the top menu. With the help of Bootstrap’s styles and scripts, we can easily implement a dropdown menu like this:

<div class="navbar navbar-default navbar-static-top">
  <div class="container">
    [...]
    <ul class="nav navbar-nav navbar-right">
      <li class="dropdown">
        <a class="dropdown-toggle" data-toggle="dropdown" href="#">
          <%= t('menu.languages.lang') %>
          <span class="caret"></span>
        </a>
        <ul class="dropdown-menu" role="menu">
          <li>
            <%= link_to t('menu.languages.en'), change_locale_path(:en) %>
          </li>
          <li>
            <%= link_to t('menu.languages.ru'), change_locale_path(:ru) %>
          </li>
        </ul>
      </li>
    </ul>
    [...]

Add the new translation data:

locales/en.yml

[...]
menu:
  new_article: "Add an article"
  languages:
    lang: "Language"
    ru: "Russian"
    en: "English"
[...]

locales/ru.yml

[...]
menu:
  new_article: "Добавить статью"
  languages:
    lang: "Язык"
    ru: "Русский"
    en: "Английский"
[...]

We need a new route for changing the locale. For simplicity’s sake, I will create a new controller called SettingsController and add a change_locale method there (to follow REST principles, you might create a separate controller to manage locales and employ an update method):

config/routes.rb

[...]
get '/change_locale/:locale', to: 'settings#change_locale', as: :change_locale
[...]

The actual method:

settings_controller.rb

class SettingsController < ApplicationController
  def change_locale
    l = params[:locale].to_s.strip.to_sym
    l = I18n.default_locale unless I18n.available_locales.include?(l)
    cookies.permanent[:educator_locale] = l
    redirect_to request.referer || root_url
  end
end

Check which locale was passed and if it is a valid one. We don’t want to set the locale to some gibberish. Then, set a permanent cookie (which is not as permanent as you might think. It will expire in 20 years) to store the selected locale and redirect the user back.

Great. The last thing we need to do is check the cookie’s contents and adjust the locale accordingly. We want this to happen on every page, so use the ApplicationController:

application_controller.rb

[...]
before_action :set_locale

def set_locale
  if cookies[:educator_locale] && I18n.available_locales.include?(cookies[:educator_locale].to_sym)
    l = cookies[:educator_locale].to_sym
  else
    l = I18n.default_locale
    cookies.permanent[:educator_locale] = l
  end
  I18n.locale = l
end
[...]

Check if the cookie is set and the language provided is in the list of available locales. If yes – fetch its contents and set locale accordingly. Otherwise, set the default locale.

Reload the server and try changing the locale. You will notice that all the headers, menus, buttons, and labels
change their content accordingly. But can we do better? Can we check user’s country by IP and set the most suitable locale? The answer is, yes we can!

Setting Locale According to User’s Country

To fetch the user’s country based on the IP address we’ll use the Geocoder gem by Alex Reisner.

Gemfile

[...]
gem 'geocoder'
[...]

Don’t forget to run

$ bundle install

To check user’s country, use the following line:

request.location.country_code

It returns a string like “RU”, “CN”, “DE”, etc. As long as we provide support for Russian, we’ll set it for every users from the CIS countries and set English for all others:

application_controller.rb

def set_locale
  if cookies[:educator_locale] && I18n.available_locales.include?(cookies[:educator_locale].to_sym)
    l = cookies[:educator_locale].to_sym
  else
    begin
      country_code = request.location.country_code
      if country_code
        country_code = country_code.downcase.to_sym
        # use russian for CIS countries, english for others
        [:ru, :kz, :ua, :by, :tj, :uz, :md, :az, :am, :kg, :tm].include?(country_code) ? l = :ru : l = :en
      else
        l = I18n.default_locale # use default locale if cannot retrieve this info
      end
    rescue
      l = I18n.default_locale
    ensure
      cookies.permanent[:educator_locale] = l
    end
  end
  I18n.locale = l
end

If the cookie with the preferred locale was already, use it. Otherwise, try to fetch user’s location. If that cannot be done, use the default locale. If the country’s ISO code is in the list, use Russian, otherwise set locale to English.

FYI, there are other ways to initially set the language of the site (like domain name, user-agent, and others – read more here).

Storing Translations for Articles

At this point users may change the language of the site and all links, buttons, labels, and menu items will be translated. However, articles will not. Articles will only be displayed in the language in which they were penned, of course. How can we store translations for user-generated content?

The easiest way to do this is by using the Globalize gem created by Sven Fuchs and other folks. Add it to your Gemfile:

Gemfile

[...]
gem 'globalize', '~> 4.0.2' # For Rails 4
# Or
gem 'globalize', '~> 3.1.0' # For Rails 3

and run

$ bundle install

This gem uses a separate translations table. For our demo, it will be called article_translations and contain the following fields:

  • id
  • article_id
  • locale
  • created_at – this is the creation date of the translation itself, not the original article
  • updated_at
  • title
  • body

We are storing translated versions of the articles for each language. Before creating the translations table, modify our model to mark the fields that should be translated (for example, we do not need to translate fields like id):

models/article.rb

[...]
translates :title, :body
[...]

Now create the appropriate migration:

$ rails g migration create_translation_for_articles

Modify the migration like this:

xxx_create_translation_for_articles.rb

class CreateTranslationForArticles < ActiveRecord::Migration
  def up
    Article.create_translation_table!({
                                          title: :string,
                                          body: :text}, {migrate_data: true})
  end

  def down
    Article.drop_translation_table! migrate_data: true
  end
end

First, note that we have to use up and down methods. Globalize cannot work with the change method. Second, the migrate_data: true option is needed because our articles table already contains some data and we want to migrate it (it will be migrated to the default locale).

Run the migration

$ rake db:migrate

and fire up your server. If you want to create a Russian version for any article, open it for editing, change the site’s locale to Russian, and enter new contents. Globalize will automatically store it in the translations table with the correct locale. For articles that are yet not translated, the content from the default locale will be used by default (read more here).

Globalize also supports versioning with PaperTrail when you use the globalize-versioning gem.

If you do not know what PaperTrail is or how to integrate versioning into your Rails app, you may be interested in my Versioning with PaperTrail article

Browse through alternative solutions on the Globalize’s page, but most of them are quite outdated.

Conclusion

We’ve built a fully international website in no time. Support for more languages may added quickly and easily – the hardest part is the actual translation of all the required text.

Have you ever implemented similar functionality in your projects? Which tools did you use? Have you ever needed to store translations for user-generated content? Share your experience in the comments!

  • The Phantom

    Thou shalt not determine the user’s locale by their IP-determined geographic location.

    No, really. Some countries have several populations which speak different languages. Some countries have weirdos that want to view websites in other languages. For instance, I live in Moscow, Russia; I use an IPv6 tunnel, so my IPv6 address geolocates to Warsaw, Poland; yet I prefer to view websites in English.

    Seriously, the Accept-Language: HTTP header is there for a reason.

    • Ilya Bodrov

      Obsiously this is only one of the possible solutions and as I stated in the article there are many other ways.

  • lesuk

    Thank you for writing good article !)
    Your last articles includes good knowledges, can you write in the future about subdomain and their
    settings on the hosting? Thank you.

    • Ilya Bodrov

      Hello, I am glad that my articles are useful (and it is cool that some readers provide feedback and report about mistakes)!

      Could you please be more specific on your request?

      I do plan to cover some server-related stuff but that would be about deploying Ruby apps (a screencast is coming, hopefully soon).

  • K B

    I really appreciate how thorough this is. It’s such a great article. I had no idea where to start with i18n, but you cleared up all of my questions. Thank you so much!

    • Ilya Bodrov

      You are welcome!

  • sakthivel

    Great tutorial

    • Ilya Bodrov

      My thanks :)

  • Travis

    Hi Ilya, great article. Our tool Phraseapp.com has an in-context editor, which works with Rails pretty smooth. Saves a lot of time during translation process makes creating keys really simple with the in-context editor: https://phraseapp.com/demo

    Regards

    • Ilya Bodrov

      Thanks, I’ll give it a try and consider writing an article about it!

      • Travis

        Awesome! Let me know in case you have any questions!

  • makina

    Great tutorial Ilya. I was working on an app with three locales and was wondering how you might handle the application_controller’s set_locale method in this case?

  • Anthony Candaele

    Nice article. I followed along and got it working.

    I found this typo:

    for the _articles partial view you give the wrong path name: articles/new.html.erb instead of articles/_article.html.erb

    Also for my Rails 4.2 app I needed a more recent version of the Globalize gem, I use version 5.0.0

    • Ilya Bodrov

      My thanks, it will be fixed asap :) Well, this article is pretty old, so Globalize 5 wasn’t released yet ;)

  • greengiant

    Hi Ilya,
    Your tutorial here uses cookies to store the user’s preferred locale. I was wondering then what you think would be best practices for filling in in the header to help with SEO.

    • Ilya Bodrov

      I don’t really think there are many best practices here :) Placing I18n.locale there would be enough.

  • Idriss SACKO

    thank you for your clear explanation.
    I use both French and Arabic. my problem is that I want the menu location changes in language chagean.

    • Ilya Bodrov

      Got it. This has nothing to do with Globalize. This can be done by using CSS classes that are set based on what language is currently used. In the simplest case:

      “`
      <body class="”>
      “`

      `I18n.locale` returns something like `fr`, `en`, `es` etc.

      Then in CSS

      “`
      .fr menu {float: left;}
      “`

      And the same for another language, but `float: right`, for example. Of course, you may need to provide more classes/styles, but all in all this should help.

  • https://skyfeedback.com Boris Tkachev

    Ilya, thanks for tutorial, it works, how would you switch language based on your user settings, involving devise. Even though i am set language on one computer, the other devise, such as phone dosnt know this, or if my application sends out emails it defaults to english language. Is there a tutorial for that too?

    • Ilya Bodrov

      Hello! Well, there might be a tutorial on that but I haven’t written any. HTTP headers provide information about user system’s language, so you may utilize them I presume.

  • Ilya Bodrov

    Sure, why not. Create some method that gets called after a user logs in..

  • Evgeniy

    Sounds great, but do you know something about store of translations and original content in the same table? I suggest, it can be implemented by using trees and relation parent -> children, but I can’t find answer. Any thoughts?

    • Ilya Bodrov

      I have not tried that. Not sure if it would be convenient…

Recommended

Learn Coding Online
Learn Web Development

Start learning web development and design for free with SitePoint Premium!

Get the latest in Ruby, once a week, for free.