Thanks to the Internet, our big Earth has suddenly become a small place, in the sense that your customers on the other side of the world are now as close to you as your next door neighbor. However, the solution to the problem of distance is not everything. We have become closer, but we continue to communicate in different languages.
In addition to language barriers, there is also a cultural implication. We understand the same things in different ways; we have different systems of measurement and understanding of time. For example, in China, people understand 02.03.2021 as March 2nd, while in the USA, it is February 3rd. Also, Rails is very conventional about the app structure unless we are talking about translations, and we are going to fix this problem. Internationalization solves this problem, but what exactly is internationalization?
Internationalization is a development technique that simplifies the adaptation of a product to the linguistic and cultural differences of a region other than the one in which the product was developed. Simply put, it is an opportunity to stay on the same page with people of other languages and cultures.
Can Rails help us solve the problem of communicating with customers around the world? It sure can! Rails has a built-in library, I18n, which is a powerful internationalization tool. It allows translations to be organized, supports pluralization, and has good documentation. The problem is that when it comes to internationalization, Rails does not follow its "Convention over Configuration" principle, and the developer is left to solve the problem of organizing the code and choosing techniques for implementing internationalization.
We'll be using Rails 6+, Ruby 3+, and postgresql 10+ for this guide, although all the techniques you'll see should work with earlier versions just fine. We will create a simple multi-lingual app – a product catalog. It will have both public and admin parts. Let's start by initializing our app. Also, we will replace minitest with rspec, so add a -T
flag.
rails new global_market --database=postgresql -T
Next, we will add some configuration to increase our productivity. Just create i18n.rb
file in config/initializers
and add everything you need.
# config/initializers/i18n.rb
# With this line of code, we can use any folder structure for our
# translations in the config/locales directory
I18n.load_path += Dir[Rails.root.join("config", "locales", "**", "*.yml")]
# Locales are supported by our app
I18n.available_locales = %i[en es]
# Our default locale
I18n.default_locale = :en
An app can't exist without a welcome page and title, so we will create them now.
# config/routes.rb
root to: "site/welcome#index"
namespace :site, path: "/" do
root to: "welcome#index"
end
As you can see, we have extracted the public part of our app to the site
namespace. It will help us organize translations properly for the whole public part.
Now, we need a controller to render our welcome page. Following our route structure, we will create a separate folder for all site
related controllers.
# app/controllers/site/base_controller.rb
module Site
# All controllers in Site namespace should inherit
# from this BaseController
class BaseController < ApplicationController
layout "site"
end
end
# app/controllers/site/welcome_controller.rb
module Site
class WelcomeController < BaseController
def index; end
end
end
In Site::BaseController
there is an instruction to use site
layout in all controllers which inherits it. Thus, our Site::WelcomeController
uses the site
layout because it inherits from Site::BaseController
.
Implementing namespaces and base controllers is a good practice for not only organizing layouts and translations but also scoping business logic, authentication, and authorization in one place. You will see how it works with the admin's part.
Now, we need to create layout and welcome page.
<!-- app/views/layouts/site.html.erb -->
<!DOCTYPE html>
<html lang="<%= I18n.locale %>">
<head>
<title>Global market</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
</head>
<body>
<%= yield %>
</body>
</html>
The html
tag here has a very important attribute called lang
. You can find a more detailed description at w3.org, but in this post, we will focus on Rails. We set lang
to our current app locale.
<!-- app/views/site/welcome/index.html.erb -->
<h1>Global market</h1>
<p><%= t(".greet") %></p>
The t
helper here is an alias for translate
, which looks up the translation for a given path. We provide ".greet"
as an argument, and the leading dot here says that translation lookup should be relative. In our case, I18n parses it like it is site.welcome.index.greet
. Smart enough! All we need is to add translation files.
# config/locales/views/site/welcome/index/index.es.yml
en:
site:
welcome:
index:
greet: "Best prices and fast delivery"
# config/locales/views/site/welcome/index/index.es.yml
es:
site:
welcome:
index:
greet: "Mejores precios y entrega rápida."
Our current file structure is very convenient to work with.
\locales
\views
\site
\welcome
\index
index.en.yml
index.es.yml
Don't forget to restart your Rails server because translation files are loaded once during app initialization. If you need your Rails app to detect new locales/*.yml files automatically without restarting it, add simple script to your initializers.
I18n expects that the root of our translation file is a language identifier, and further hierarchy information is irrelevant. Here, we have a hierarchy that corresponds to our translation lookup path. Open your index page, and you should see this text: "Best prices and fast delivery".
Locale detection strategy
We have two languages now, but how do we show the appropriate one to our customers? There are lots of strategies, but for the public part (site), we will detect the user’s language from the URL, as Google recommends.
All our routes for the site
namespace will look like this:
/en
/en/products
/en/products/1
/es
/es/products
/es/products/1
Looks beautiful, eh? But, how do we inject the locale into all routes and links? Let's start with our routes; we will change them a little bit.
# config/routes.rb
root to: "site/welcome#index"
namespace :site, path: "/" do
# Now we have params[:locale] available
# in our controllers and views
scope ":locale"
root to: "welcome#index"
end
end
# app/controllers/site/base_controller.rb
module Site
class BaseController < ApplicationController
layout "site"
before_action :set_locale
private
def set_locale
if I18n.available_locales.include?(params[:locale]&.to_sym)
I18n.locale = params[:locale]
else
redirect_to "/#{I18n.default_locale}"
end
end
end
end
Now, if a user opens the index page, Rails redirects it to /en
, because we set English as our default locale. When users try to set a non-existing locale in the URL, Rails also redirects them to /en
. Such an approach is great for URL sharing and search engines and looks pretty. For completeness, let's add a language switch button in the site
layout.
<!-- app/views/layouts/site.html.erb -->
<!-- <head>...</head> -->
<body>
<ul>
<% I18n.available_locales.each do |locale| %>
<% if I18n.locale != locale %>
<li>
<%= link_to t(".lang.#{locale}"), url_for(locale: locale) %>
</li>
<% end %>
<% end %>
</ul>
<%= yield %>
</body>
How does it work? We iterate all available locales, and if it is not equal to the current locale, the link is rendered. The link preserves the current URL and only the locale changes.
Translation for static pages
Often, there are a lot of rich-text pages (e.g., About and Terms of Service pages), and using the translate
method everywhere to creating corresponding translation files is inconvenient. However, Rails has built-in functionality for such cases. Let's create an about
page to check it out. We need to add the route and action first.
# config/routes.rb
scope ":locale"
root to: "welcome#index"
get "about", to: "welcome#about" # add route to /about page
end
# app/controllers/site/welcome_controller.rb
module Site
class WelcomeController < BaseController
def index; end
def about; end # add action to render appropriate view
end
Next, add about.en.html.erb
and about.es.html.erb
files to views/site/welcome
. As you can see, there is a locale identifier in the file name. Each file can have its own text, styles, and photos. The key point here is that Rails renders views based on the user’s current locale.
<!-- app/views/site/welcome/about.en.html.erb -->
<%= image_tag("https://lipis.github.io/flag-icon-css/flags/4x3/gb.svg", width: "100px") %>
<h1>About the project</h1>
<!-- app/views/site/welcome/about.es.html.erb -->
<%= image_tag("https://lipis.github.io/flag-icon-css/flags/4x3/es.svg", width: "100px") %>
<h1>Sobre el proyecto</h1>
Now, when a user opens /en/about
or es/about
, Rails renders the appropriate view template. At this point, our site guests can visit the index and about pages and change the language. Next, let's add some dynamic data!
Admins
We need to create an /admins
part, where our admins will be able to add products to the catalogue. They might speak different languages, so it's a good idea to make the UI multi-lingual for them, too. While in the site
part, we use URL params to select the language, here, we have admin in the database, and it's a good idea to derive the locale from an admin entry in the DB. We will also add the devise
gem for authentication purposes.
# Gemfile
gem "devise"
Install the devise gem and set the initial configuration:
bundle instal
rails generate devise:install
Now, we are ready to create our Admin
model.
rails generate devise Admin
Open the migration file that Rails just created and add an additional field, locale
, to it. We will use it to select a language for the UI when an admin signs in.
# ...
t.string :email, null: false, default: ""
t.string :encrypted_password, null: false, default: ""
t.string :locale, null: false, default: "en" # add this line
# ...
Don't forget to run migrations!
rails db:migrate
Devise automatically injects devise routes for admin, but we also need an admins
namespace where all the authentication-check magic happens.
# config/routes.rb
devise_for :admins # this is added by Devise
namespace :admins do
root to: "welcome#index"
end
As you can see, in the admins
namespace, like in site
namespace, we have a reference to the welcome
controller. The cool thing here is that they do not conflict. For the site
namespace, we will create BaseController
and WelcomeController
.
# app/controllers/admins/base_controller.rb
module Admins
# All controllers in the Admins namespace should inherit
# from this BaseController
class BaseController < ApplicationController
layout "admins"
# Devise will ask for authentication for
# all inherited controllers
before_action :authenticate_admin!
before_action :set_locale
private
# When admins are authenticated, we can access their data
# via the current_admin helper and obtain their locale from DB
def set_locale
I18n.locale = current_admin.locale
end
end
end
# app/controllers/admins/welcome_controller.rb
module Admins
class WelcomeController < BaseController
def index; end # Only admin is able to visit this page
end
end
Now, we need corresponding views and translation files.
<!-- app/views/layouts/admins.html.erb -->
<!DOCTYPE html>
<html lang="<%= I18n.locale %>">
<head>
<title>Admins only</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
</head>
<body>
<%= yield %>
</body>
</html>
<!-- app/views/admins/welcome/index.html.erb -->
<h1><%= t(".languages", count: I18.available_locales.length)%></h1>
# config/locales/views/site/welcome/index/index.es.yml
en:
admins:
welcome:
index:
languages:
zero: "There are no languages available"
one: "Only one language is available"
other: "There are %{count} languages available"
# config/locales/views/site/welcome/index/index.es.yml
es:
admins:
welcome:
index:
languages:
zero: "No hay idiomas disponibles"
one: "Solo hay un idioma disponible"
other: "Hay %{count} idiomas disponibles"
Everything else is set up like site/welcome
, but here, we introduce pluralization for countable things. I18n is able to select the appropriate translation based on the variable passed to it. If you see that pluralization is not working well for your locale, it's a good idea to enhance your Rails app with the rails-i18n gem.
To see the result, we have to add an admin entity. Forms and views for this entity are outside the scope of this topic, so use the console to add an admin to the database.
rails c
Admin.create(email: "admin@dev.me", password: "12345678", locale: "en")
Now, when you visit the /admins
path, devise asks for your credentials. If the credentials are correct, you will gain access to the /admins
root path and translations based on the locale extracted from the database entry.
ActiveRecord translations
Finally, we are ready to add a Product
model with the ability to add dynamic translation. First, we will use the mobility gem. Mobility is a gem for storing and retrieving translations as attributes on a class. It has several strategies for storing translations and works perfectly with both ActiveRecord and Sequel.
# Gemfile
gem "mobility"
Don't forget to install and add migrations provided by the mobility gem. It will create two separate tables for storing string and text translations, respectively. It's a default translation storing strategy, but you are free to check out other options.
bundle install
rails generate mobility:install
rails db:migrate
Mobility created the initializer config/initializers/mobility.rb
. Go there and uncomment plugin locale_accessors
. More information about locale accessors is provided here.
Our product has a non-translatable title
and a translatable description
. Also, we want to add price
and sales_start_at
fields, which will be localized appropriately. Description should not be present in migration because it will be handled by mobility tables.
rails g model Product title:string price:integer sales_start_at:datetime
To make a translatable description
available for the model, we just need to connect the model to mobility and specify the field and type via a special DSL.
# models/product.rb
class Product < ApplicationRecord
extend Mobility
# We want description_en and description_es available to read and write
# and store it in text translations table
translates :description, type: :text, locale_accessors: I18n.available_locales
end
Let's continue with all the routes, controllers, and view routines.
# config/routes.rb
namespace :admins do
root to: "welcome#index"
resources :products # add routes for products
end
As you can see, controllers can also handle relative translation lookups. For our create action, we have two translations: when a product is created successfully or a failure occurs. For t(".success")
, i18n looks for admins.products.create.success
, and for t(".failure")
, the path will be admins.products.create.failure
.
# app/controllers/admins/products_controller.rb
module Admins
class ProductsController < BaseController
def index
@products = Product.all
end
def show
@product = Product.find(params[:id])
end
def new
@product = Product.new
end
def create
@product = Product.new(product_params)
if @product.save
flash[:success] = t(".success")
redirect_to [:admins, @product]
else
flash[:error] = t(".failure")
render :new
end
end
private
# gem `mobility` creates an accessor method for translatable fields, and now we have `description_en` and
# `description_es`. This is why we include them in the permitted list.
def product_params
params.require(:product).permit(:title, :description, :price, :sales_start_at)
end
end
end
Next, we will create corresponding translations for our controller. Pay attention to the locale files structure; the final one is available at the end of this post.
# config/locales/controllers/admins/products/products.en.yml
en:
admins:
products:
create:
success: "Product has been added successfully"
failure: "Check the form"
# config/locales/controllers/admins/products/products.es.yml
es:
admins:
products:
create:
success: "El producto se ha agregado con éxito"
failure: "Consultar el formulario"
Finally, we’ll add views to work with our products. I will not show translation files here because the logic is similar to the welcome page, as shown above, so give it a shot. You can read about Model.human_attribute_name(:attribute)
here.
<!-- app/views/admins/products/index.html.erb -->
<h1><%= t(".title") %></h1>
<table>
<thead>
<tr>
<th><%= Product.human_attribute_name(:title) %></th>
<th><%= Product.human_attribute_name(:price) %></th>
<th><%= Product.human_attribute_name(:sales_starts_at) %></th>
</tr>
</thead>
<% @products.each do |product| %>
<tr>
<td><%= link_to product.title, [:admins, product] %></td>
<td><%= product.price %></td>
<td><%= product.sales_starts_at %></td>
</tr>
<% end %>
</table>
<!-- app/views/admins/products/new.html.erb -->
<h1><%= t(".title") %></h1>
<%= form_for([:admins, @product]) do |f|>
= f.label :title
= f.text_field :title
<br>
= f.label :price
= f.number_field :price
<br>
= f.label :sales_starts_at
= f.date_time :sales_starts_at
<br>
<% I18n.available_locales.each do |locale| %>
= f.label "description_#{locale}"
= f.text_area "description_#{locale}"
<% end %>
<% end %>
Localize
To understand how to display language and culturally sensitive data, such as dates and numbers, let's finish our site index page. Rails I18n has the built-in helper l
(alias of localize
). It helps render DateTime properly, according to the current language.
<% @products.each do |product| %>
<tr>
<td><%= product.title %></td>
<td><%= product.price %></td>
<td><%= l(product.sales_starts_at, format: :short) %></td>
</tr>
<% end %>
If you need more precise control over the display of dates and times, I recommend delegating such functionality to JavaScript by adding the date-fns library. Just create a helper and bypass data to this library like we will do with number rendering.
Although localize
works pretty well with the date and time, there are lingual differences in how numbers (e.g., prices) are rendered. To resolve this problem, we will create a view helper.
Numbers
We need a little bit of JavaScript here. We need a built-in toLocaleDateString
method. I assume that you are using the newest Rails version with Webpacker installed and that ES6 is available for you.
Let's start with the view helper. Create a localize_helper.rb file and then add thelocalize_time
method.
# app/helpers/localize_helper.rb
module LocalizeHelper
def price(number)
# Render <span data-localize="price">199.95</span>
content_tag(:span, datetime, data: { localize: :price })
end
end
Hence, when you add <%= price(@product.price) %>
in views, Rails renders the span
tag with a data attribute called "localize". We will use this attribute to find a span on the page and display all data properly for a specific language.
// app/javascript/src/helpers/price.js
// Turbolinks are enabled by default in Rails,
// we need to process our script on every page load
// https://github.com/turbolinks/turbolinks#full-list-of-events
document.addEventListener("turbolinks:ready", () => {
// Get language from html tag
const lang = document.documentElement.lang;
// Find all span tags with data-localize="price"
const pricesOnPage = document.querySelectorAll("[data-localize=\"price\"]");
if (pricesOnPage.length > 0) {
// Iterate all price span tags
[...pricesOnPage].forEach(priceOnPage => {
// Modify text in span tag according to current language
priceOnPage.textContent = priceOnPage.textContent.toLocaleString(
lang, { style: "currency", currency: "USD" }
);
})
}
});
Now, we need to connect this script to our site
layout. We want to separate all concerns to keep the code clean. We don't need an existing application
JavaScript pack; we want the site
JavaScript pack. Just rename it.
mv app/javascript/packs/application.js app/javascript/packs/site.js
// app/javascript/packs/site.js
import Rails from "@rails/ujs"
import Turbolinks from "turbolinks"
import * as ActiveStorage from "@rails/activestorage"
import "channels"
// Add this line
import "../src/helpers/price";
Rails.start()
Turbolinks.start()
ActiveStorage.start()
The final step is to connect our site
pack to the layout. Just add one line to your site
layout in the <head>
tag.
<%= javascript_pack_tag 'site', 'data-turbolinks-track': 'reload' %>
Now, you are ready to use your helper, and it will handle prices properly:
<% @products.each do |product| %>
<tr>
<td><%= product.title %></td>
<td><%= price(product.price) %></td> <!-- Modify this line -->
<td><%= l(product.sales_starts_at, format: :short) %></td>
</tr>
<% end %>
Maintainable translations
It's very difficult to maintain translations, especially when there are a lot of languages. The must-have tool for every multi-lingual app is i18n-task. 18n-tasks helps you find and manage missing and unused translations. There are a lot of features, some of them are as follows:
- Find and report translations that are missing or unused.
- Automatic translation from Google Translate or other services.
- Integrate tasks with rspec to prevent translations problems on the production server.
Conclusion
Congratulations! We have built a simple catalogue that addresses many of the internationalization issues. We learned how to create a convenient file structure for translation files, figured out the translation of static pages, learned strategies for choosing a language, adopted a method to translate dynamic data (ActiveRecord), made our own helpers and, most importantly, understood how to build maintainable multilingual applications.
Final locales file structure
The idea is to follow I18n path logic. Also, it is a very good practice to duplicate the folder and file names in it. It will help to easily find locale files in your IDE/text-editor and edit files in one place.
\models
\product
product.en.yml
product.es.yml
\controllers
\admins
\products
products.en.yml
products.es.yml
\views
\layouts
\site
site.en.yml
site.es.yml
\admins
admins.en.yml
admins.es.yml
\site
\products
\show
show.en.yml
show.es.yml
\index
index.en.yml
index.es.yml