Ruby
Article
By Ilya Bodrov-Krukowski

Go Global with Rails and I18n

By Ilya Bodrov-Krukowski
Help us help you! You'll get a... FREE 6-Month Subscription to SitePoint Premium Plus you'll go in the draw to WIN a new Macbook SitePoint 2017 Survey Yes, let's Do this It only takes 5 min

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 to . 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!

--ADVERTISEMENT--

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!

Login or Create Account to Comment
Login Create Account
Recommended
Sponsors
Get the most important and interesting stories in tech. Straight to your inbox, daily.Is it good?