I18n with Phoenix LiveView
Tags: elixir, phoenix, liveview
Published: 2021-03-12 12:38:57

You want to use I81n in Phoenix LiveView? So did I. But one can't just use `gettext/1` in Phoenix LiveView out of the box. It took me a couple of hours to find out how to do it. Here is the recipe. You can copy and paste all of it. Just make sure to search and replace for `example` and `Example`.

## Setup

    mix phx.new example --live
    cd example

My example application features English (en) and German (de) and defaults to English. So we have to add this at the end of `config/config.ex`

```elixir
config :example, ExampleWeb.Gettext,
  default_locale: "en",
  locales: ~w(en de)
```

## Ask the browser

There are a couple of different ways of deciding which language to use. In my opinion the `"accept-language"` header from the browser is a good way to go. If you want to go a different direction this plug is a good starting point. To use `"accept-language"` and set the used language we create a new plug:

**lib/example_web/plug/local_plug.ex**

```elixir
defmodule ExampleWeb.Plugs.Locale do
  import Plug.Conn

  def init(_opts), do: nil

  def call(conn, _opts) do
    accepted_languages = extract_accept_language(conn)
    known_locales = Gettext.known_locales(ExampleWeb.Gettext)

    accepted_languages =
      known_locales --
        (known_locales -- accepted_languages)

    case accepted_languages do
      [locale | _] ->
        Gettext.put_locale(ExampleWeb.Gettext, locale)

        conn
        |> put_session(:locale, locale)

      _ ->
        conn
    end
  end

  # Copied from
  # https://raw.githubusercontent.com/smeevil/set_locale/fd35624e25d79d61e70742e42ade955e5ff857b8/lib/headers.ex
  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 ->
      case String.split(tag, "-") do
        [language, _country_variant] ->
          if Enum.member?(tags, language), do: [tag], else: [tag, language]

        [_language] ->
          [tag]
      end
    end)
  end
end
```

We have to include this plug in the pipeline:

**lib/example_web/router.ex**

```elixir
defmodule ExampleWeb.Router do
  use ExampleWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, {ExampleWeb.LayoutView, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug ExampleWeb.Plugs.Locale # <-----
  end
  [...]
```

## The LiveView Hello World with I18n

A nice Hello World! example:

**lib/example_web/live/page_live.ex**

```elixir
defmodule ExampleWeb.PageLive do
  use ExampleWeb, :live_view
  import Gettext, only: [with_locale: 2]

  @impl true
  def mount(_params, session, socket) do
    locale = case session do
      %{"locale" => locale} -> locale
      _ -> "en"
    end

    socket =
      socket
      |> assign(locale: locale)

    {:ok, socket}
  end

  def render(assigns) do
    ~L"""
    <%= with_locale(@locale, fn -> %>
      <%= gettext "Hello World!" %></h2>
    <% end) %>
    """
  end
end
```

## The Translations

We need to generate the config files for the `gettext` translations:

- `mix gettext.extract --merge`
- `mix gettext.merge priv/gettext --locale de`

Here are the files which need to be used for the translations:

    priv/gettext
    ├── de
    │   └── LC_MESSAGES
    │       ├── default.po
    │       └── errors.po
    ├── default.pot
    └── errors.pot

And here's the content of `priv/gettext/de/LC_MESSAGES/default.po` (the German translation):
    
    ## "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: de\n"
    "Plural-Forms: nplurals=2\n"

    #, elixir-format
    #: lib/example_web/live/page_live.ex:17
    msgid "Hello World!"
    msgstr "Hallo Welt!"

Here's some more docu about using Gettext:

- https://phrase.com/blog/posts/i18n-for-phoenix-applications-with-gettext/
- http://blog.plataformatec.com.br/2016/03/using-gettext-to-internationalize-a-phoenix-application/

After all this you can fire up your application:

    mix phx.server

That's all! 

![Browser screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uuof44ngu5vm62gpbglt.png)

My Twitter account: https://twitter.com/wintermeyer

PS: In case you want to tackle LiveView components have a look at https://www.paulfioravanti.com/blog/internationalisation-phoenix-live-components/https://www.paulfioravanti.com/blog/internationalisation-phoenix-live-components/