Wednesday, 10 February 2016

Internationalization and Locale-Based URLs with Ruby on Rails 4.x

In this post we will learn how to produce the following locale-based URLs:

Deutsch: http://example.com/de/books
English: http://example.com/en/books

Introduction

No matter how small your application is, it is never too early to think of internationalization, even if you have no immediate plans to broadcast your website in multiple languages. Planning for internationalization early on will save countless hours and headache in the future. Even if your website is only using one language, the process of internationalization will force you to refactor all of your user-specific messages into one place so that they are easy to find and maintain.
This post, however, is not about the benefits of internationalization (you can find many yourself with a quick Google search) or how to display your website in multiple languages. I assume that you already know how to do that and will instead delve deeper into how to serve language-based content to your users with intuitive and user-friendly URLs.


Different locale-based URL options

Although I am only discussing one specific method of producing locale-based URLs, it is good to know other alternatives.
Locale as a URL variable: http://example.com/books?locale=en
With this method every URL with have the locale=en segment to indicate the language. While, it is clear what language is being used, having the same parameter in every URL is a little verbose, not to mention aesthetically unappealing (if you happen to be a pedant). Another disadvantage of this method is that search engines may not realize that a page uses a different language and, as a result, might not index it correctly.
Locale as a subdomain: http://en.example.com/books
This URL certainly looks much more pleasing to the eye and addresses the search engine shortcoming addressed above; however, this method requires extra configuration for every different language. That is, you will have manually add every subdomain to your website and point it correctly to your website, and, depending on how you configure these subdomains, you may have to repoint them all if the IP address of your website happens to change (which, of course, can be addresses by using canonical names). Another possible problem arises from using third-party hosting services, which may not allow you to add subdomains for free.
Locale as a host suffix: http://example.com/en/books
This, in my opinion, is the absolute winner, as it is trivial to configure, does not require any extra configuration if another language needs to be added in the future. Search engines are well aware of this technique, and your URLs stay short and easy to understand.
Session-based locale: http://example.com/books
In this case, there is no way to tell from the URL what language is being used. This method is typically reserved for websites where users have to log in and can then change their language of preference in website settings. Since a user specifically opts for a particular language, there is no reason to make URLs locale-based, and, therefore, this method certainly has its place and should not be discounted.


Creating a small application and setting up some translations

To learn to how to create pretty locale-based URLs, we will build together a tiny book inventory application. Follow my lead.

Let's create our application and a books resource with a title, one author and a price.

rails new bookapp && cd bookapp
rails generate scaffold book title author price:decimal
rake db:migrate

If we start our application with rails server and navigate to http://localhost:3000 we should be able to see an empty list of books. Before we start customizing URLs, let's add some translations to the application so that when the URLs are ready, we will be able to tell the difference.
Create the following internationalization files in /config/locales:
de.yml

de:
  button:
    back: 'Zurück'
    destroy: 'Zerstören'
    edit: 'Bearbeiten'
    show: 'Zeigen'

  listing: "%{model}liste"
  editing: "%{model} bearbeiten"

  helpers:
    submit:
      create: "%{model} erstellen"
      submit: "%{model} speichern"
      update: "%{model} aktualisieren"

  activerecord:
    models:
      book:
        one: 'Buch'
        other: 'Bücher'
        new: "Neues Buch"
    attributes:
      book:
        title: 'Titel'
        author: 'Autor'
        price: 'Preis'

en.yml

en:
  button:
    back: 'Back'
    destroy: 'Destroy'
    edit: 'Edit'
    show: 'Show'

  listing: "Listing %{model}"
  editing: "Editing %{model}"

  helpers:
    submit:
      create: "Create %{model}"
      submit: "Save %{model}"
      update: "Update %{model}"

  activerecord:
    models:
      book:
        one: 'Book'
        other: 'Books'
        new: "New Book"
    attributes:
      book:
        title: 'Title'
        author: 'Author'
        price: 'Price'
Modify the following Book views as follows:
_form.html.erb

<%= form_for(@book) do |f| %>
  <div class="field">
    <%= f.label :title, Book.human_attribute_name(:title) %><br>
    <%= f.text_field :title %>
  </div>
  <div class="field">
    <%= f.label :author, Book.human_attribute_name(:author) %><br>
    <%= f.text_field :author %>
  </div>
  <div class="field">
    <%= f.label :price, Book.human_attribute_name(:price) %><br>
    <%= f.text_field :price %>
  </div>
  <div class="actions">
    <%= f.submit class: 'button' %>
  </div>
<% end %>

edit.html.erb

<h1><%= t('editing', model: Book.model_name.human) %></h1>

<%= render 'form' %>

<%= link_to t('button.back'), books_path, class: 'hollow secondary button' %>
<%= link_to t('button.show'), @book, class: 'button' %>

index.html.erb

<h1><%= t('listing', model: Book.model_name.human(:count => 2)) %></h1>

<table>
  <thead>
    <tr>
      <th><%= Book.human_attribute_name(:title) %></th>
      <th><%= Book.human_attribute_name(:author) %></th>
      <th><%= Book.human_attribute_name(:price) %></th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <% @books.each do |book| %>
      <tr>
        <td><%= book.title %></td>
        <td><%= book.author %></td>
        <td><%= book.price %></td>
        <td><%= link_to t('button.show'), book %></td>
        <td><%= link_to t('button.edit'), edit_book_path(book) %></td>
        <td><%= link_to t('button.destroy'), book, method: :delete %></td>
      </tr>
    <% end %>
  </tbody>
</table>

<%= link_to t('activerecord.models.book.new'), new_book_path, class: 'button' %>

new.html.erb

<h1><%= t('activerecord.models.book.new') %></h1>

<%= render 'form' %>

<%= link_to t('button.back'), books_path, class: 'hollow secondary button' %>

show.html.erb

<p>
  <strong><%= Book.human_attribute_name(:title) %>:</strong>
  <%= @book.title %>
</p>

<p>
  <strong><%= Book.human_attribute_name(:author) %>:</strong>
  <%= @book.author %>
</p>

<p>
  <strong><%= Book.human_attribute_name(:price) %>:</strong>
  <%= @book.price %>
</p>

<%= link_to t('button.back'), books_path, class: 'hollow secondary button' %>
<%= link_to t('button.edit'), edit_book_path(@book), class: 'button' %>

Now that we've created some translations, we can change the default locale to German in /config/application.rb by uncommenting the line:

config.i18n.default_locale = :de

If we navigate to http://localhost:3000/books, we will see the list of books in German (remember to restart the application to see the changes).


Now we're finally ready to add locales to our URLs.


Setting up locale-based URLs

The first we need to do is enclose all routes that should have the locale prefix inside scope inside routes.rb.

Rails.application.routes.draw do
  scope '(:locale)', locale: Rails.configuration.x.locale do
    resources :books
    # other locale-based routes defined here
  end
  # non-locale-based routes defined here
end

The variable Rails.configuration.x.locale is currently undefined, but we will use it to keep a list of all supported locales. The best place to define it is in application.rb:

class Application < Rails::Application
  ...
  config.x.locale = /de|en/
  ...
end

Now we need to create a before_action in application_controller.rb. We also need to override the default default_url_options method so that all URLs always contain the locale (note that this will not affect any non-locale-based routes in case you defined some).

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception

  before_action :set_locale

  private

  def default_url_options
    {locale: params[:locale]}
  end

  def set_locale
    I18n.locale = params[:locale] || I18n.default_locale
  end
end

Now we can navigate to http://localhost:3000/de/books and http://localhost:3000/en/books and see the list of books in German and English, respectively.


Detecting default browser language

An even better idea is to serve website content in a user's preferred language by default instead of making users change the language to the one they like best. To achieve this goal, we need only change the default application locale to the user's default browser locale. The resulting application_controller.rb is given below:

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception

  before_action :set_locale

  private

  def default_url_options
    {locale: params[:locale]}
  end

  def preferred_locale
    request.env['HTTP_ACCEPT_LANGUAGE'].scan(/^[a-z]{2}/).first if request.env['HTTP_ACCEPT_LANGUAGE'].present?
  end

  def set_locale
    new_locale = params[:locale] || preferred_locale
    I18n.locale = if I18n.locale_available? new_locale
                    new_locale
                  else
                    I18n.default_locale
                  end
  end
end

To test whether this worked, change your default browser language from English to German or vice versa, restart the browser and navigate to http://localhost:3000/books. You should now see content in two different languages. Note that we've also added a check that verifies that the preferred user language is indeed among the languages available in the application. If that is not the case, we fall back to the default locale.


Setting page language in HTML

Search engines and web crawlers need to know which language is used by a particular page of your website. To do that simply make sure that the lang attribute on the <html> tag is set to change dynamically:

<html lang="<%= I18n.locale %>">


Creating buttons that change the locale

The only thing left to do is create links that change the language of a website. Fortunately, that is the easiest part.

<%= link_to 'Deutsch', params.permit(:locale).merge(locale: :de) %>
<%= link_to 'English', params.permit(:locale).merge(locale: :en) %>

These links can be placed on any page of the website (and should probably be present on all pages). They will redirect users to the same page that they are currently on, but the language will be changed.


Conclusion

You are now well on your way to creating user-friendly locale-based URLs that are generic and easily maintainable. The only thing to remember is that when adding new languages it is necessary to add the new locale in the application.rb and restart the application. Everything else should work as is.

Happy internationalizing!

No comments:

Post a comment