Software localization

A Better Way to Set and Manage Locale Data in Your Phoenix Apps

Learn how to craft a custom module plug that allows to set and persist the chosen locale. Our solution supports GET param, cookie and HTTP header.
Software localization blog category featured image | Phrase

In our previous article, we've explained how to internationalize Phoenix applications with the help of Gettext. We have introduced support for two languages, covered the process of extracting translations, adding pluralizations and some other topics. Also, we have briefly talked about switching between locales by utilizing a third-party plug called set_locale. This plug is really convenient and easy to use but it appears that a similar solution can be coded from scratch quite easily. After all, it is much better to code some features all by yourself to understand how exactly it works. Also, this way you have total control over how everything ties up together.

So, today I'd like to show you how to set and manage locale data in the Phoenix applications with the help of a module plug. Our solution is going to support three sources of locale data:

  • GET param
  • Cookie
  • HTTP header

This way once a user has chosen some locale setting, it will be persisted and utilized on subsequent visits without the need to adjust this setting again.

We will continue working on the demo application created in the previous article. If you'd like to follow along, simply clone this repo by running:

git clone git@github.com/phrase/PhraseAppPhoenixI18n

The final version of the application is available at the same repo, under the locale branch. All committed changes can be found on this page. Also note that in order to run the application you'll require:

Some Cleanup

Before proceeding to the main part, let's do some cleanup. As long as we are not going to employ the set_locale plug anymore, the following line can be removed from the mix.exs file:

  defp deps do

    [

      # ...

      {:set_locale, "~> 0.2.1"} # <===

    ]

  end

Also, remove set_locale from the application (inside the same file):

 def application do

    [

      mod: {Demo.Application, []},

      extra_applications: [

        :logger,

        :runtime_tools,

        :set_locale # <===

      ]

    ]

  end

Next, tweak the lib/demo_web/router.ex file by removing the third-party plug:

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

and keeping only the following scope:

  scope "/", DemoWeb do

    pipe_through :browser # Use the default browser stack

    get "/", PageController, :index

  end

This way we have got rid of the set_locale plug and may proceed to crafting our own solution.

Creating a Custom Locale Plug

So, we are going to create our own custom plug called simply Locale. Its behaviour will be somewhat similar to the set_locale plug used in the previous article, but with some differences. Here are the key points:

  • The locale should be initially set based on the value of the locale GET param. So, if I visit http://localhost:4000?locale=ru, Russian locale should be utilized.
  • If this GET param is not present, try to use the value from a cookie called locale.
  • If the cookie is not set as well, check the Accept-Language HTTP header.
  • Lastly, if the header is not present, fallback to a default locale. The same applies to scenarios when the requested locale is not supported.
  • As long as the default locale is already set in the config/config.exs (line config :demo, DemoWeb.Gettext, default_locale: "ru", locales: ~w(en ru)), there is no need to pass the default value to the plug again as it was done with the set_locale.
  • After the locale was successfully set, its value should be saved under the locale cookie.

All in all, nothing complex. Alright, start by hooking up a new plug by modifying the router.ex file:

  pipeline :browser do

    # ...

    plug DemoWeb.Plugs.Locale

  end

Next, create a new lib/demo_web/plugs/locale_plug.ex file which is going to contain the actual plug:

defmodule DemoWeb.Plugs.Locale do

  import Plug.Conn

end

So, this plug allows us to transform the Connection object somehow. As explained by the documentation, it should define two callbacks:

  • init/1 that initializes options to be passed to call/2. It may return simply nil though.
  • call/2 which performs the actual transformation. It accepts and must return the connection object.

Here is the first draft for these two callbacks:

  def init(_opts), do: nil

  def call(%Plug.Conn{params: %{"locale" => locale}} = conn, _opts) do

  end

init/1 does not need to initialize any options, so it simply returns nil. If, for example, you want it to accept a default locale, change it to something like:

def init(default_locale), do: default_locale # here you may also want

# to check if the default_locale actually supported by the app

The plug will then accept a default value in the route.ex file like this:

plug DemoWeb.Plugs.Locale, "en" # default locale set to "en"

Now let's talk about the call/2 callback. The part %Plug.Conn{params: %{"locale" => locale}} = conn allows us to fetch the locale param and assign it to the locale variable. _opts has the value of nil (because that's what the init/1 callback returns) and we are not going to use it.

The problem is that the requested locale may not be supported at all, so we should check for such cases. This can be done inside the call function itself, or by using guard clauses:

  def call(%Plug.Conn{params: %{"locale" => locale}} = conn, _opts) when locale in @locales do

  end

  def call(conn, _opts), do: conn

when locale in @locales is our guard clause that checks whether the requested locale is present inside the @locales list (which will be defined in a moment). If it does present, the function will be executed, otherwise we proceed to the def call(conn, _opts), do: conn line and simply return the connection back without doing anything else.

Now all we need to do is define the @locales list:

@locales Gettext.known_locales(DemoWeb.Gettext)

Note that you cannot employ know_locales directly in the guard clause as you'll end with an error:

** (ArgumentError) invalid args for operator "in", it expects a compile-time list or compile-time range on the right side when used in guard expressions

Setting Locale

The next step to do is to actually set the locale by calling the put_locale/2 function that accepts a Gettext backend and the language's code:

  def call(%Plug.Conn{params: %{"locale" => locale}} = conn, _opts) when locale in @locales do

    Gettext.put_locale(DemoWeb.Gettext, locale) # <===

  end

Also, don't forget to return the conn itself:

  def call(%Plug.Conn{params: %{"locale" => locale}} = conn, _opts) when locale in @locales do

    Gettext.put_locale(DemoWeb.Gettext, locale)

    conn # <===

  end

Great! The first iteration is nearly finished and you may boot the server by running:

mix phx.server

Navigate to the http://127.0.0.1:4000/ and make sure that the default locale (Russian, in my case) is used. Next try switching it by going to http://127.0.0.1:4000?locale=en — all text should be in English. Note that if you try to open http://127.0.0.1:4000/ again, the text will still be in English. If, however, you reboot the server, this setting will be lost and the default language will be utilized again. We'll deal with this problem later.

UI Changes

Before we proceed to the next iteration, let's also present two links to switch between locales for our own convenience. First of all, introduce a new helper inside the views/layout_view.ex file:

  def switch_locale_path(conn, locale, language) do

    "<a href=\"#{page_path(conn, :index, locale: :en)}\">#{language}</a>" |> raw

  end

raw/1 function should be called here because otherwise the HTML will be rendered as plain text, whereas we want this string to turn into a hyperlink.

Next, simply utilize this helper inside the templates/layout/app.html.eex by modifying the default navigation block:

      <header class="header">

        <nav role="navigation">

          <ul class="nav nav-pills pull-right">

            <li><%= switch_locale_path @conn, :en, "English" %></li>

            <li><%= switch_locale_path @conn, :ru, "Russian" %></li>

          </ul>

        </nav>

        <span class="logo"></span>

      </header>

Great! Now you may switch between locales by simply clicking on one of these links.

Persisting Locale Data

Now that we have coded some very basic version of the plug, let's try making it a bit more complex. What I want to do is store the chosen locale in a cookie named, quite unsuprisingly, locale:

  def call(%Plug.Conn{params: %{"locale" => locale}} = conn, _opts) when locale in @locales do

    Gettext.put_locale(DemoWeb.Gettext, locale)

    conn = put_resp_cookie conn, "locale", locale, max_age: 10*24*60*60 # <===

    conn

  end

The cookie is set using the put_resp_cookie/4 function. "locale" is the key, whereas locale is the value that should be stored under this key. Also, I've set the max_age option to 10 days, but you may provide a much greater value so that the cookie becomes virtually permanent. Note that you must assign the result of calling put_resp_cookie/4 to the conn, otherwise the data won't be persisted.

Next, let's make sure that the cookie actually has the correct data by printing out its contents inside the lib/demo_web/controllers/page_controller.ex:

  def index(conn, _params) do

    conn.cookies["locale"] |> IO.inspect # <===

    render conn, "index.html"

  end

Visit the http://127.0.0.1:4000/?locale=en URL and make sure that the console has the following output:

[info] GET /

[debug] Processing with DemoWeb.PageController.index/2

  Parameters: %{"locale" => "en"}

  Pipelines: [:browser]

"en"

[info] Sent 200 in 0ms

Brilliant!

Note that the same result may be achieved by storing locale inside the session, not cookie. To save some data inside the session, utilize the put_session/3 function:

conn = conn |> put_session(:locale, locale)

:locale here is a key (which can also be represented as a string), whereas locale is a value. The data can be then read with the help of get_session/2 function:

get_session(conn, :locale)

Fetching Locale Data

The chosen locale is now persisted inside the cookie, but it needs to be properly read. On top of that, we have to make sure that the language is supported. Guard clause is not very suitable for this scenario because we need to perform too many actions. Instead, let's stick with the case macro:

  def call(conn, _opts) do

    case locale_from_params(conn) || locale_from_cookies(conn) do

      nil     -> conn

      locale  ->

        Gettext.put_locale(DemoWeb.Gettext, locale)

        conn = put_resp_cookie conn, "locale", locale, max_age: 10 * 24 * 60 * 60

        conn

    end

  end

Here we are using two new functions that will be defined later: locale_from_params/1 and locale_from_cookies/1. These functions return either the locale itself or nil if the chosen locale is not supported or not provided. If nil was returned by both functions, call/2 simply returns conn and nothing else happens. Otherwise, we perform the same actions as before: set the locale and persist it inside the cookie.

Now let's code the two new functions that will be marked as private:

  defp locale_from_params(conn) do

    conn.params["locale"] |> validate_locale

  end

  defp locale_from_cookies(conn) do

    conn.cookies["locale"] |> validate_locale

  end

Nothing fancy is going on here. We simply fetch params or cookies and then validate the value. validate_locale/1 is yet another private function:

  defp validate_locale(locale) when locale in @locales, do: locale

  defp validate_locale(_locale), do: nil

This is where we are using our old guard clause that makes sure the locale is actually supported.

One thing I don't like about the call/2 function is that we are persisting the locale under any circumstances, even if the same value is already stored. Let's change this behaviour by utilizing a new function:

  def call(conn, _opts) do

    case locale_from_params(conn) || locale_from_cookies(conn) do

      nil     -> conn

      locale  ->

        Gettext.put_locale(DemoWeb.Gettext, locale)

        conn = conn |> persist_locale(locale) # <===

        conn

    end

  end

Here is the function itself:

  defp persist_locale(conn, new_locale) do

    if conn.cookies["locale"] != new_locale do

      conn |> put_resp_cookie("locale", new_locale, max_age: 10 * 24 * 60 * 60)

    else

      conn

    end

  end

Now if the cookie's value does not match the newly chosen locale we overwrite it. Unfortunately, we cannot access conn.cookies in the guard clause, so I had to stick with the if macro instead.

Fetching From HTTP Header

At this point we are trying to fetch locale data from the GET param and from the cookie. Why don't we also take the Accept-Locale HTTP header into consideration? To do that, we can utilize the functions already introduced in the set_locale plug:

  # Taken from set_locale plug written by Gerard de Brieder

  # https://github.com/smeevil/set_locale/blob/fd35624e25d79d61e70742e42ade955e5ff857b8/lib/headers.ex

  defp locale_from_header(conn) do

    conn

    |> extract_accept_language

    |> Enum.find(nil, fn accepted_locale -> Enum.member?(@locales, accepted_locale) end)

  end

  def extract_accept_language(conn) do

    case Plug.Conn.get_req_header(conn, "accept-language") do

      [value | _] ->

        value

        |> String.split(",")

        |> Enum.map(&parse_language_option/1)

        |> Enum.sort(&(&1.quality > &2.quality))

        |> Enum.map(&(&1.tag))

        |> Enum.reject(&is_nil/1)

        |> ensure_language_fallbacks()

        _ ->

        []

      end

    end

    defp parse_language_option(string) do

      captures = Regex.named_captures(~r/^\s?(?<tag>[\w\-]+)(?:;q=(?<quality>[\d\.]+))?$/i, string)

      quality = case Float.parse(captures["quality"] || "1.0") do

        {val, _} -> val

        _ -> 1.0

      end

      %{tag: captures["tag"], quality: quality}

    end

    defp ensure_language_fallbacks(tags) do

      Enum.flat_map tags, fn tag ->

        [language | _] = String.split(tag, "-")

        if Enum.member?(tags, language), do: [tag], else: [tag, language]

      end

    end

These functions, basically, parse the HTTP header and make sure that the language is present in the list of allowed locales.

Now we may simply the locale_from_header/1 function:

  def call(conn, _opts) do

    case locale_from_params(conn) || locale_from_cookies(conn) || locale_from_header(conn) do # <===

      nil     -> conn

      locale  ->

        Gettext.put_locale(DemoWeb.Gettext, locale)

        conn = conn |> persist_locale(locale)

        conn

    end

  end

And, this is it! You may now play with the application by switching between locales or trying to provide some non-supported language. Everything should work properly which is really cool.

Stick with Phrase

Working with translation files can be challenging, especially when your app is of bigger scope and supports many languages. You might easily miss some translations for a specific language, which can lead to confusion among users.

And so Phrase can make your life easier: Grab your 14-day trial today. Phrase supports many different languages and frameworks, including JavaScript of course. It allows you to easily import and export translation data. What’s even greater, you can quickly understand which translation keys are missing because it’s easy to lose track when working with many languages in big applications.

On top of that, you can collaborate with translators as it’s much better to have professionally done localization for your website. If you’d like to learn more about Phrase, refer to the Phrase Localization Suite.

Conclusion

This is all for today! In this article we have seen how to set and manage locale data in Phoenix applications. You have seen how to create a module plug that tries to fetch locale from the GET param, cookie and HTTP header, which is quite flexible. The resulting functionality is somewhat similar to the set_locale plug, but now you have full control on how everything works and (hopefully!) understand the logic behind all this code.

I hope this tutorial was useful for you. As always, thanks for staying with me and see you in the next articles!