Software localization

Translating Phoenix Applications with GNU gettext

Phoenix i18n - Learn how to add support for i18n in Phoenix applications with Gettext & how to support multiple locales, pluralization rules, and domains.
Software localization blog category featured image | Phrase

Phoenix is a fast and reliable MVC framework written in the language Elixir (which, in turn, relies on Erlang). It has many features that should be familiar to developers who come from the Rails or Django world, but, at the same time, it may seem a bit complex at first due to Elixir's functional nature.

In this article, you will learn about Phoenix i18n. I'll walk you through how to add support for i18n in Phoenix applications with the help of gettext (which is a default dependency). You will learn what gettext is, what PO and POT files are, how to generate them, and easily extract translations from your views. I will also talk about supporting multiple locales, pluralization rules, and domains. If you would like to run the code samples presented in this article locally, you'll need to install OTP (at least 18), Elixir (at least 1.4) and, of course, the Phoenix framework itself (version 1.3 will be used in this tutorial).

The source code for this article can be found on GitHub.

What is GNU gettext?

So, gettext is a complex open-source solution created by GNU (initially it was introduced by Sun Microsystems in the middle 90s). It is used to create multilingual systems (not only web applications) by many developers and companies, so you may find lots of materials about it on the net. Discussing all features of gettext is outside of the scope of this article, but you may find full documentation online. We will be mainly interested in how gettext files should be named and organized, and what they are used for.

GNU gettext instructs us to create a folder named after the locales they are going to support. For example, en, ru, de etc. Inside, there should be a folder called LC_MESSAGES with one or multiple .po files. PO means "portable object" and these files contain strings to be translated as well as the actual translations. So, the file structure should look like this:

  • en
    • LC_MESSAGES
      • default.po
      • other.po
  • ru
    • LC_MESSAGES
      • default.po
      • other.po

default and other are names of the domains (or scopes) and I will cover them later in this article.

Apart from PO files, there are also POT files (which stands for "portable object template"). They are used as a base to create PO files and soon you will see why it matters. I think we can already proceed to the main part of the article because it will be much easier to understand everything in practice.

Simple translations

Make sure you have Erlang, Elixir, and Phoenix installed on your machine and create a new application by running:

mix phx.new demo --no-ecto

We are using a --no-ecto flag here because the database won't be needed for the purposes of this article. The installer will ask whether you'd like to install dependencies, so type Y and hit Enter. After a couple of minutes, your new shiny application will be prepared!

Open the demo/lib/demo_web/templates/page/index.html.eex file. This is a default starting page for the application. Remove everything except for this block:

<div class="jumbotron">

  <h2><%= gettext "Welcome to %{name}!", name: "Phoenix" %></h2>

</div>

It is a welcoming message, but note that it utilizes a gettext function — this is exactly how we are going to display translated texts to our users. So what is going on here? gettext accepts at least one argument: that is, a string that we would like to translate. You may call it a "key" for now, but strictly speaking, it is more than just a key. You see, when adding support for i18nin frameworks like Ruby on Rails we usually have to provide keys in a format of top_scope.other_scope.my_key. For example, in Rails that would be:

I18n.translate 'users.messages.welcome'

Next, in a separate file, we would need to provide a translation for this key. If the translation is missing, the key itself would be rendered on the screen in a more or less prettified way. GNU gettext uses a different approach where the strings to localize act like keys themselves. So, even if the translation cannot be found, the string can be printed on the screen. This is also convenient because if you need to internationalize an existing application, the strings can be simply passed to the gettext function (of course, in some cases you will need to do more work).

The second argument passed to gettext, as you've probably guessed, contains parameters (or bindings) that we want to interpolate into the translation. In our case, we have one parameter called name. Note that all the parameters must be wrapped with %{}.

To make sure that the string actually does work, cd into the folder containing your project, boot the server

mix phx.server

and navigate to http://localhost:4000. You will see the "Welcome to Phoenix!" phrase which means everything is working as expected!

Adding a new translation

For demonstration purposes let's also add a subtitle to our root page:

<div class="jumbotron">

  <h2><%= gettext "Welcome to %{name}!", name: "Phoenix" %></h2>

  <h3><%= gettext "This tutorial is brought to you by %{company}",

  company: "PhraseApp" %></h3>

</div>

Now, where can we add a translation for this new string? Well, all translations are stored under the priv/gettext folder that already has some default files inside. We can, of course, create all the necessary files manually, but that would be too tedious. Instead, run the following command:

mix gettext.extract

It is going to scan the project's files and check if GNU gettext is used anywhere. A new priv/gettext/default.pot file will be created for you. As already mentioned above, .pot means "portable object template" and so this file is used as a template to generate translations for other languages. Note that this file should not be modified directly. If you open it now, you'll see something like that:

## 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.

msgid ""

msgstr ""

#: lib/demo_web/templates/page/index.html.eex:3

msgid "This tutorial is brought to you by %{company}"

msgstr ""

#: lib/demo_web/templates/page/index.html.eex:2

msgid "Welcome to %{name}!"

msgstr ""

Note that our messages were added here automatically and you can even see the lines where they are located. msgid is the "key" and msgstr contains the translation. Really cool!

Now we need to generate a PO translation file for the English language, so run another command:

mix gettext.merge priv/gettext

This command, basically, utilizes the default.pot template and creates a default.po file in the priv/gettext/en/LC_MESSAGES folder. You may also run:

mix gettext.extract --merge

to create (or update) the template and .po files in one go.

The default PO file has the following contents:

## `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"

#: lib/demo_web/templates/page/index.html.eex:3

msgid "This tutorial is brought to you by %{company}"

msgstr ""

#: lib/demo_web/templates/page/index.html.eex:2

msgid "Welcome to %{name}!"

msgstr ""

Here you may translate the given strings as needed. Of course, this makes little sense to do so because the messages are already in English, but let's do the following tweak to make sure everything is still working fine:

msgid "Welcome to %{name}!"

msgstr "Howdy from %{name}!"

If you visit the root page of the website now, you should see "Howdy from Phoenix!" which means our translation was used properly. Good job!

Multiple Locales

So, the default locale for Phoenix apps is en (English). You may change this setting by tweaking the config/config.exs file:

config :demo, DemoWeb.Gettext, default_locale: "ru"

Now the default locale is Russian, but you may specify anything else. Let's also provide the list of supported locales for our application:

config :demo, DemoWeb.Gettext, default_locale: "ru", locales: ~w(en ru)

By the way, you may get a list of all known locales in the following way:

Gettext.known_locales(MyApp.Gettext) #=> ["en", "ru"]

Next, we need to generate PO files for the Russian locale, so run:

mix gettext.merge priv/gettext --locale ru

This is going to generate a priv/gettext/ru/LC_MESSAGES folder with .po files inside. Tweak the default.po by adding some translations:

#: lib/demo_web/templates/page/index.html.eex:3

msgid "This tutorial is brought to you by %{company}"

msgstr "Руководство от компании %{company}"

#: lib/demo_web/templates/page/index.html.eex:2

msgid "Welcome to %{name}!"

msgstr "Добро пожаловать в приложение %{name}!"

Now, depending on the chosen locale, Phoenix will render either English or Russian translations. If you need to enforce a locale, you may use the with_locale function:

Gettext.with_locale DemoWeb.Gettext, "en", fn ->

  MyApp.DemoWeb.gettext("test string")

end

Switching between locales

Alright, the translations are added, but how do we understand which locale the user would like to utilize? One of the easiest ways to achieve this task is by using the set_locale plug that extracts the desired locale from URLs or Accept-Language HTTP header. To specify a locale in the URL, one would type http://localhost:4000/en/some_path. If the locale is not specified (or if an unsupported language was requested), one of two things will happen:

  • If the request contains an Accept-Language HTTP header and this locale is supported, the user will be redirected to a page with the corresponding locale.
  • Otherwise, the user will be automatically redirected to a URL that contains the code of the default locale (Russian, in our case).

The plug is really easy to work with. Open the  mix.exs file and add set_locale to the deps function:

  defp deps do

    [

      # ...

      {:set_locale, "~> 0.2.1"}

    ]

  end

Also, add it to the application:

  def application do

    [

      mod: {Demo.Application, []},

      extra_applications: [:logger, :runtime_tools, :set_locale]

    ]

  end

Next run:

mix deps.get

to install everything.

The lib/demo_web/router.ex needs some changes as well. Add a new plug to the :browser pipeline:

  pipeline :browser do

    # ...

    plug SetLocale, gettext: DemoWeb.Gettext, default_locale: "ru"

  end

Lastly, create a new scope:

  scope "/:locale", DemoWeb do

    pipe_through :browser

    get "/", PageController, :index

  end

This is it. Now boot the server and try navigating to http://localhost:4000/ru and http://localhost:4000/en. You should see that the messages are translated properly which is exactly what we need!

Pluralization

Suppose we'd like to say how many messages the user has. It does not really matter what messages we are talking about or where they come from — what matters is the fact that there can be 1 or more messages. It means we need to introduce a pluralization rule. Luckily, the GNU gettext plug already takes care of that for us by introducing the ngettext function. This function accepts at least 3 arguments: a string in singular form, a string in plural form, and count. You may also provide so-called bindings (other parameters that should be interpolated into the translation).

So, let's utilize this function now:

<p>

  <%= ngettext "You have one message", "You have %{count} messages", 2 %>

</p>

%{count} here is an interpolation that will be replaced with some number (2 in this case). Now update our template and PO files:

mix gettext.extract --merge

A new entry will be added to the default.po files:

msgid "You have one message"

msgid_plural "You have %{count} messages"

msgstr[0] ""

msgstr[1] ""

msgstr[0] should contain the text that will be displayed when there is only 1 message. msgstr[1], of course, stores the text for the case when there are multiple messages. This is totally okay for the English locale, but not enough for Russian. This language has more complex pluralization rules, but most existing languages are already supported by the Gettext.Plural behavior. So, let's cover all the possible cases for the Russian locale:

msgid "You have one message"

msgid_plural "You have %{count} messages"

msgstr[0] "У вас одно новое сообщение"

msgstr[1] "У вас %{count} новых сообщения"

msgstr[2] "У вас %{count} новых сообщений"

Now, for 1 message we'll use case 0, for zero or few messages — case 1, and case 2 in all other situations.

Working With Domains

All translations added so far were placed to the default.po files. default here is the name of the domain that acts as a scope or a namespace. Having only one scope may be quite okay for a small application, but this is not very convenient for large systems that have hundreds of translations. GNU gettext supports multiple domains and so let's try to introduce one by using the dgettext function:

<p>

  <%= dgettext "system", "Some system output: %{msg}", msg: "debug goes here" %>

</p>

This function is very similar to gettext, but it accepts the domain name as the first argument. In our case, we've specified system to pretend it is going to contain some service messages. Now run:

mix gettext.extract --merge

You will note that system.pot and two system.po files will be created. So, these files should be named after the domain and contain only the messages that belong to this domain. You may add translation inside the priv/ru/LC_MESSAGES/system.po:

msgid "Some system output: %{msg}"

msgstr "Системное сообщение: %{msg}"

Note that if you'd like to grab a pluralized translation from a specific domain, the ngettext function should be used:

dgettext "domain", "Singular %{msg}", "Plural %{msg}", 4, msg: "demo"

Stick with Phrase

Writing code to localize your application is one task, but working with translations is something else. Having plenty of translations for multiple languages may quickly overwhelm you which will lead to the user’s confusion. Luckily, Phrase can make your life as a developer a lot easier!

Feel free to grab your 14-day trial now. Phrase supports all your familiar formats, including PO. It allows to easily import and export translation data and search for any missing translations, which is really convenient. On top of that, you can collaborate with translators as it is much better to have professionally done localization for your website.

Conclusion

In this article, we have seen how to translate a Phoenix application with the help of GNU gettext. Apart from getting to know GNU gettext a bit closer, we have also learned how to generate PO and POT files and extract all the necessary strings from a Phoenix application. You have learned how to add support for multiple locales and set the proper locale based on the user's preferences. Also, we have talked about utilizing pluralization rules and working with domains.

If you would like to learn more about GNU gettext in the Phoenix framework, you may refer to this official guide that provides useful examples and an API reference for all the available functions. I hope this article was useful and interesting! Thanks for staying with me today and until the next time.