Skip to content

Instantly share code, notes, and snippets.

@rhcarvalho
Created December 25, 2024 14:51
Show Gist options
  • Save rhcarvalho/e2e74a945fba71cdb6dfa95b3819120d to your computer and use it in GitHub Desktop.
Save rhcarvalho/e2e74a945fba71cdb6dfa95b3819120d to your computer and use it in GitHub Desktop.
Phoenix LiveView Multiple Locales

Phoenix LiveView Multiple Locales

This gist was extracted from a Phoenix LiveView project where I needed to support multiple languages. Translations are managed using GetText and the standard commands available in projects generated with Phoenix 1.7.

The code in this gist demonstrates how to use a Plug to store the locale in the session and a LiveView on_mount hook to set the current locale in LiveView assigns.

The Plug integrates with the rest of the project through a router pipeline, and the on_mount hook is attached to every LiveView by updating the generated ...Web module.

<!DOCTYPE html>
<html lang={@locale |> String.replace("_", "-")}>
<!-- ... -->
<.link
:for={
{locale, description, language} <- [
{"pt_BR", "Mudar idioma para", "Português"},
{"en", "Change language to", "English"},
{"de_AT", "Sprache ändern zu", "Deutsch"}
]
}
:if={@locale != locale}
href={~p"/?lang=#{locale}"}
class="px-2 text-zinc-600 hover:text-zinc-700"
>
<span class="sr-only"><%= description %></span> <%= language %>
</.link>
<!-- ... -->
</html>
defmodule HelloWeb.Plugs.SetLocale do
@moduledoc """
This plug is responsible for setting the locale in the session based on the
`lang` query parameter, existing session locale or `accept-language` HTTP
header.
## Behavior
- If the `lang` query parameter is present, it overwrites any existing locale
in the session and updates the session with the new locale.
- If the `lang` query parameter is not set, the plug tries to determine the
locale in the following order:
1. It reads the locale from the session state, if it exists.
2. If the locale is not found in the session state, it infers the locale
from the `accept-language` header.
3. If the `accept-language` header is not present or does not match any
supported locales, it defaults to English ("en").
"""
@behaviour Plug
import Plug.Conn
@default_locale "en"
# language => gettext_locale
@supported_locales %{
"en" => "en",
"pt" => "pt_BR",
"de" => "de_AT"
}
@impl true
def init(opts), do: opts
@impl true
def call(conn, opts) do
locale =
Keyword.get(opts, :force_locale) ||
get_params_locale(conn) ||
get_session_locale(conn) ||
get_browser_locale(conn) ||
@default_locale
Gettext.put_locale(HelloWeb.Gettext, locale)
conn
|> put_session(:locale, locale)
|> assign(:locale, locale)
end
@doc false
def get_params_locale(conn) do
conn.params["lang"] |> parse_language() |> locale_for_language()
end
@doc false
def get_session_locale(conn) do
get_session(conn, :locale) |> parse_language() |> locale_for_language()
end
@doc false
def get_browser_locale(conn) do
accept_language = Plug.Conn.get_req_header(conn, "accept-language")
case accept_language do
[header | _] -> parse_accept_language(header)
_ -> nil
end
end
defp parse_accept_language(header) do
header
|> String.split(",")
|> Enum.map(fn language ->
# Split the language by semicolon to handle q-values (ignored).
# For simplicity, we ignore the q-values and assume the languages are
# sorted in preferred order. This is what most browsers do anyway.
# https://developer.mozilla.org/en-US/docs/Glossary/Quality_values
[language | _] = String.split(language, ";")
parse_language(language)
end)
|> Enum.find_value(&locale_for_language/1)
end
defp parse_language(nil), do: nil
defp parse_language(language) when is_binary(language) do
String.split(language, ~r/[-_]/, parts: 2) |> List.first() |> String.downcase()
end
defp locale_for_language(language) do
@supported_locales[language]
end
end
defmodule HelloWeb.RestoreLocale do
def on_mount(arg, params, session, socket)
def on_mount(:default, params, session, socket) when map_size(session) == 0 do
# empty session when loading embedded iframe, force locale to pt_BR.
on_mount(:default, params, %{"locale" => "pt_BR"}, socket)
end
def on_mount(:default, _params, %{"locale" => locale}, socket) do
Gettext.put_locale(HelloWeb.Gettext, locale)
socket = Phoenix.Component.assign(socket, :locale, locale)
{:cont, socket}
end
def on_mount(:default, _params, _session, socket), do: {:cont, socket}
end
defmodule HelloWeb.Router do
use HelloWeb, :router
pipeline :browser do
plug :accepts, ["html"]
# ...
plug HelloWeb.Plugs.SetLocale
end
# ...
end
defmodule HelloWeb do
# ...
def live_view do
quote do
use Phoenix.LiveView,
layout: {HelloWeb.Layouts, :app}
on_mount HelloWeb.RestoreLocale
# ...
end
end
# ...
end
defmodule HelloWeb.Plugs.SetLocaleTest do
use ExUnit.Case, async: true
use Plug.Test
alias HelloWeb.Plugs.SetLocale
defp call(conn) do
SetLocale.call(conn, SetLocale.init([]))
end
test "sets the locale from params if present" do
conn =
conn(:get, "/?lang=pt_BR")
|> fetch_query_params()
|> init_test_session(%{})
|> call()
assert get_session(conn, :locale) == "pt_BR"
assert conn.assigns[:locale] == "pt_BR"
end
test "sets the locale from session if present" do
conn =
conn(:get, "/")
|> fetch_query_params()
|> init_test_session(%{})
|> put_session(:locale, "pt_BR")
|> call()
assert get_session(conn, :locale) == "pt_BR"
assert conn.assigns[:locale] == "pt_BR"
end
test "sets the locale from accept-language header if present" do
conn =
conn(:get, "/")
|> fetch_query_params()
|> init_test_session(%{})
|> put_req_header("accept-language", "pt-PT,pt;q=0.9,en-US;q=0.8,en;q=0.7")
|> call()
assert get_session(conn, :locale) == "pt_BR"
assert conn.assigns[:locale] == "pt_BR"
end
test "uses the default locale if no locale in params or session" do
conn =
conn(:get, "/")
|> fetch_query_params()
|> init_test_session(%{})
|> call()
assert get_session(conn, :locale) == "en"
assert conn.assigns[:locale] == "en"
end
test "uses the default locale if language from params is unsupported" do
conn =
conn(:get, "/?lang=pl")
|> fetch_query_params()
|> init_test_session(%{})
|> call()
assert get_session(conn, :locale) == "en"
assert conn.assigns[:locale] == "en"
end
test "uses the first supported locale from accept-language header" do
conn =
conn(:get, "/")
|> fetch_query_params()
|> init_test_session(%{})
|> put_req_header("accept-language", "pl-PL,pl;q=0.9,pt-BR;q=0.8,en-US;q=0.7,en;q=0.6")
|> call()
assert get_session(conn, :locale) == "pt_BR"
assert conn.assigns[:locale] == "pt_BR"
end
test "uses the default locale if none of the accept-language header locales are supported" do
conn =
conn(:get, "/")
|> fetch_query_params()
|> init_test_session(%{})
|> put_req_header("accept-language", "pl-PL,pl;q=0.9")
|> call()
assert get_session(conn, :locale) == "en"
assert conn.assigns[:locale] == "en"
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment