DEV Community

Stefan Wintermeyer
Stefan Wintermeyer

Posted on

I18n with Phoenix LiveView

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
Enter fullscreen mode Exit fullscreen mode

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

config :example, ExampleWeb.Gettext,
  default_locale: "en",
  locales: ~w(en de)
Enter fullscreen mode Exit fullscreen mode

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

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
Enter fullscreen mode Exit fullscreen mode

We have to include this plug in the pipeline:

lib/example_web/router.ex

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
  [...]
Enter fullscreen mode Exit fullscreen mode

The LiveView Hello World with I18n

A nice Hello World! example:

lib/example_web/live/page_live.ex

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!"
Enter fullscreen mode Exit fullscreen mode

Here's some more docu about using Gettext:

After all this you can fire up your application:

mix phx.server
Enter fullscreen mode Exit fullscreen mode

That's all!

Browser screenshot

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/

Top comments (0)