Using Gettext to internationalize a Phoenix application

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>&lt;%= gettext "Welcome to %{name}", name: "Phoenix!" %&gt;</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 msgids or msgid_plurals 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?


Subscribe to Elixir Radar

Comments are closed.