To translate or not to translate? We have been asking ourselves the same question in one of our latest Phoenix projects. Even though internationalizing our application is planned a bit ahead in our roadmap, we have decided to do an initial evaluation of the translation tools in the Elixir ecosystem, and we were pleasantly surprised by what it has to offer.
The Phoenix framework ships with internationalization (i18n) and localization (l10n) system called Gettext since version 1.1. Gettext is a tool for writing multilingual programs used as a standard by many communities, meaning there is a great set of tooling for developers and translators.
Let’s start!
The idea behind Gettext is that it translates messages based on the string itself and not on “keys”. For example, instead of specifying translations as keys, as in translate "view.welcome"
, we simply use the gettext "Hello there!"
function for every string in the app.
We have noticed this approach comes with two large benefits:
- When using “view.welcome” strings, it always required two steps from developers. The first was to use “view.welcome” in our templates and then add the translated string to a configuration file. Using
gettext
is a single step as it will use the given string if no translation is available; -
We can translate our applications without losing context since we keep the original text. We maintained applications in the past that relied heavily on “custom.message” strings and the extra level of indirection was always hard to work with.
Given those benefits, we have decided to tag already our strings with gettext
calls, saving us from future work without a loss in productivity or maintainability.
In this post, we will show you how we did that and how to translate effectively your app when the time comes in the future by running two tasks. Before, let’s take a look at the Gettext structure when we start a new Phoenix project.
Structure
Gettext uses two kinds of files: *.po
and *.pot
. These files are stored in priv/gettext/
. For now, we have only the file errors that are used by Ecto. The structure is:
priv/gettext
└─ en_US
| └─ LC_MESSAGES
| └─ errors.po
└─ errors.pot
POT file
POT means Portable Object Template and these files are generated automatically by:
mix gettext.extract
A new Phoenix project already has a gettext
call in the web/template/pages/index.html.eex
with <h2><%= gettext "Welcome to %{name}", name: "Phoenix!" %></h2>
.
The first time you run the task above, it will create a default.pot
file with the text provided:
priv/gettext
└─ en_US
| └─ LC_MESSAGES
| └─ errors.po
└─ default.pot
└─ errors.pot
The default.pot
file is similar to:
## This file is a PO Template file. `msgid`s here are often extracted from
## source code; add new translations manually only if they're dynamic
## translations that can't be statically extracted. Run `mix
## gettext.extract` to bring this file up to date. Leave `msgstr`s empty as
## changing them here as no effect; edit them in PO (`.po`) files instead.
#: web/templates/page/index.html.eex:2
msgid "Welcome to %{name}"
msgstr ""
These files should not be modified manually because this will cause them to be used as a reference for your next files and languages. (well, kind of, as there is a special case that will be covered in another blog post).
PO file
PO means Portable Object and the files are based on the POT files. These are the files that you will use to add your translations. First, we need to generate them with the task:
mix gettext.merge priv/gettext
Now, we have a new PO file in our structure:
priv/gettext
└─ en_US
| └─ LC_MESSAGES
| └─ default.po
| └─ errors.po
└─ default.pot
└─ errors.pot
Let’s take a look at the new file:
## `msgid`s in this file come from POT (.pot) files. Do not add, change, or
## remove `msgid`s manually here as they're tied to the ones in the
## corresponding POT file (with the same domain). Use `mix gettext.extract
## --merge` or `mix gettext.merge` to merge POT files into PO files.
msgid ""
msgstr ""
"Language: en\n"
#: web/templates/page/index.html.eex:2
msgid "Welcome to %{name}"
msgstr ""
Gettext uses English by default. The msgid
is the text provided in the gettext
function and msgstr
is the translated value for it.
Why does my app work when I use Gettext
without the translation files? By default, the gettext
function will return the string passed in. With this approach, you can add gettext
calls whenever you start thinking about localizing.
Adding new translations in our template
Every time that we add a new call to gettext
in the templates, we need to update our POT and PO files.
mix gettext.extract
mix gettext.merge priv/gettext
After this, the new msgid
will be available in your PO files. You could also run both tasks with a single one:
mix gettext.extract --merge
Adding new languages for our app
Now I want to translate the app to pt_BR
. The next step is running:
mix gettext.merge priv/gettext --locale pt_BR
This task will use the template files to generate the .PO
files. Let’s see our structure after we ran the task:
priv/gettext
└─ en_US
| └─ LC_MESSAGES
| └─ errors.po
| └─ default.po
└─ pt_BR
| └─ LC_MESSAGES
| └─ errors.po
| └─ default.po
└─ default.pot
└─ errors.pot
What happens if I change my locale to pt_BR
and I didn’t translate the msgid
? The Gettext will fall back to the default language and your app will not crash.
You can do more with Gettext
.
Pluralize
In addition to the simple translation, if you use gettext "Here is one string to translate"
, you can pluralize. Example:
# Plural translation
number_of_apples = 4
ngettext "The apple is ripe",
"The apples are ripe",
number_of_apples
Your PO file will be generated like:
msgid "The apple is ripe"
msgid_plural "The apples are ripe"
msgstr[0] ""
msgstr[1] ""
Interpolation
Interpolation keys can be placed in msgid
s or msgid_plural
s by enclosing them in %{
and }
, like this: Example: gettext("My name is %{name}", name: @user.name)
. The PO file will be like:
msgid "My name is %{name}"
msgstr ""
Domains
You can create domains to scope assess your translations. All your translations will be stored in the default domain. Ecto uses a custom domain called errors
for validations. To use a custom domain, use: dgettext "errors", "Here is an error message to translate"
. The tasks will be responsible for creating the files for this domain.
Locale per-process
One more thing, Gettext stores the locale per-process (in the process dictionary) and per Gettext module. This means that you can use Gettext.put_locale/2
in a new process in order to change the locale for that process. You can change the default value in your config/config.exs
with config :playfair, Playfair.Gettext, default_locale: "pt_BR"
.
Even after adding gettext
functions, you probably need to work in some assets, currency, dates, and so on. But, the hard work, scanning all your files to add the translation, has already been done.
You can find more information in the Gettext documentation. Do you believe that the Gettext
approach could have helped you?