Internationalization with gettext

DANA 325 L W X A K S G P D Q C U V F E N R H I B M O T

Objectives

  • Your Language Toggle Button/Dropdown contains two flags and it is clear which language is currently activated by showing the correct flag. (1 Point)

  • Your lanugage button is in your navbar / layout and correctly flips and reloads the page in two lanugages. Alternatively you have implemented a fully functional language toggle dropdown. (2 Points)

  • Have at least 50 text snippets and 3 pages fully translated into at least one other language via Gettext. (3 points) (4 Points)

Please watch the videos under Resources before class.

Today we are going to work on supporting our website with a second language, whether we know one or don't.

Here is my new language toggle button in the finished product. Clicking it will change translated text snippets to German and back: Example Navbar English Example Navbar German

Displaying Language Toggle button with correct two flags (1 point)

Lets start with adding a language toggle button to our button.ex file:

@doc """
Creates a button to switch between locales.

  ## Examples
    <.language_toggle locale={@locale} />
"""
attr :locale, :string, required: true, values: ~w(de en)

def language_toggle(assigns) do
  ~H"""
  <.link
    :if={@locale == "en"}
    type="button"
    class="rounded-full size-6 block"
    href={~p"/locale/de"}
    method="PUT"
  >
    <span class="sr-only">Deutsch</span>
    <svg
      aria-hidden="true"
      class="size-6 rounded-full"
      xmlns:xlink="http://www.w3.org/1999/xlink"
      viewBox="0 0 3900 3900"
    >
      <path fill="#b22234" d="M0 0h7410v3900H0z" />
      <path
        d="M0 450h7410m0 600H0m0 600h7410m0 600H0m0 600h7410m0 600H0"
        stroke="#fff"
        stroke-width="300"
      />
      <path fill="#3c3b6e" d="M0 0h2964v2100H0z" />
      <g fill="#fff">
        <g id="d">
          <g id="c">
            <g id="e">
              <g id="b">
                <path id="a" d="M247 90l70.534 217.082-184.66-134.164h228.253L176.466 307.082z" />
                <use xlink:href="#a" y="420" />
                <use xlink:href="#a" y="840" />
                <use xlink:href="#a" y="1260" />
              </g>
              <use xlink:href="#a" y="1680" />
            </g>
            <use xlink:href="#b" x="247" y="210" />
          </g>
          <use xlink:href="#c" x="494" />
        </g>
        <use xlink:href="#d" x="988" />
        <use xlink:href="#c" x="1976" />
        <use xlink:href="#e" x="2470" />
      </g>
    </svg>
  </.link>

  <.link
    :if={@locale == "de"}
    class="rounded-full size-6 block"
    type="button"
    href={~p"/locale/en"}
    method="PUT"
  >
    <span class="sr-only">English (US)</span>
    <svg aria-hidden="true" class="size-6 rounded-full" viewBox="0 0 512 512">
      <path fill="#ffce00" d="M0 341.3h512V512H0z" />
      <path d="M0 0h512v170.7H0z" />
      <path fill="#d00" d="M0 170.7h512v170.6H0z" />
    </svg>
  </.link>
  """
end

This code contains something new that we have not seen before: an SVG image. Everything inside <svg></svg> is used to draw an image via code! We will have an entire lecture dedicated to svg images so we will just accept that one of the images is drawing the US flag, while the other one is drawing Germany's flag. In fact, the seatmap on our course website is an SVG image drawn with the exact same techniques.

Note shoud you choose to implement a language other than German (and you really should if you speak another language) you will need to search the internet for a language flag in form of an <svg> image and replace the image in the code above.

You will get a point if you language button is present in your main menu and contains the correct two flags of countries which are representative for the language.

Note that different scripts (such as traditional Chinese) should be natively supported as all our files use the "UTF-8" encoding.

Making the Button work (2 point)

Lets now discuss how the language button actually worked:

<.link
  :if={@locale == "en"}
  type="button"
  class="rounded-full size-6 block"
  href={~p"/locale/de"}
  method="PUT"
>

In the <.language_toggle> component there are two links, that are conditionally displayed depending on the state of the assign @locale.

If the locale is currently en (English) we want to display the US flag but with a link to /locale/de so we will switch to German upon clicking it and vise verse.

If we were to have more than two languages a dropdown would probably be more appropriate. Flowbite has a nice example component on which we can build upon. For this assignment, although encouraged, you are not required to create a language toggle dropdown. Conveniently last Friday's bonus lecture is about dropdowns so you will want to have a look at it if you try.

Since the href attribute redirects the page we will need to add a route to the router:

put "/locale/:locale", LocaleController, :update

Make sure that this route does NOT end up being protected by authentication or inside a live_session block.

Since we are inside the router we want add a custom plug that will automatically set the correct locale when starting a webpage request. Add a line to the browser pipeline:

import AppWeb.LocaleController, only: [put_locale: 2] # <- add this

pipeline :browser do
  plug :accepts, ["html"]
  plug :fetch_session
  plug :put_locale  # <- add this!
  plug :fetch_live_flash
  plug :put_root_layout, html: {AppWeb.Layouts, :root}
  plug :protect_from_forgery
  plug :put_secure_browser_headers
  plug :fetch_current_user
  plug :add_message_changeset
end

We also want to enable internationalization in all our live views which means adding an on_mount instruction to all our live_session scopes:

live_session :require_authenticated_user,
  on_mount: [
    {AppWeb.UserAuth, :put_locale},           # <- add this
    {AppWeb.UserAuth, :ensure_authenticated},
    {AppWeb.UserAuth, :add_message_changeset}
  ]
  # ...

Do the same thing for every other live_session scope you can find in your router.

Now we need to work on our LocaleController and our on_mount instruction. Lets do the latter first as it is a short function we add to our UserAuth module:

def on_mount(:put_locale, _params, session, socket) do
  Gettext.put_locale(session["locale"])
  socket = Phoenix.Component.assign_new(socket, :locale, fn -> session["locale"] end)
  {:cont, socket}
end

Make sure the function is grouped together with your other on_mount function clauses or you will get a compiler warning.

What does this function do? It wil first get the locale from the session and then:

  • instructs Gettext to switch to that locale for the current process (web request)
  • assign a :locale key to the socket's assigns

We are done here and can shift our focus towards completing the new LocaleController. Add the following module (we will discuss each of the parts afterwards):

defmodule AppWeb.LocaleController do
  use AppWeb, :controller
  use Gettext, backend: AppWeb.Gettext

  import Plug.Conn

  # Make the locale cookie valid for 60 days.
  @max_age 60 * 60 * 24 * 60
  @locale_cookie "_dsa_web_locale"
  @locale_options [max_age: @max_age, same_site: "Lax"]

  @doc """
  Plug to automatically set the locale based on (in this priority):
    1. Session Data
    2. Cookie Data
    3. Preferred language from header
    4. English

  Will also keep the preference alive, as long as the visitor visits within the 60 day duration.
  """
  def put_locale(conn, _opts) do
    locale =
      Map.get(conn.assigns, :locale) ||
        get_session(conn, :locale) ||
        fetch_cookies(conn).cookies[@locale_cookie] ||
        parse_accept_language_from_headers(conn)

    Gettext.put_locale(locale)

    conn
    |> assign(:locale, locale)
    |> put_session(:locale, locale)
    |> put_resp_cookie(@locale_cookie, locale, @locale_options)
  end

  # Helper to parse the Accept-Language header and extract the preferred language, defaults to en
  defp parse_accept_language_from_headers(conn) do
    case Enum.find(conn.req_headers, fn {k, _v} -> k == "accept-language" end) do
      {_, locale} ->
        locale =
          locale
          # Split on commas for multiple languages
          |> String.split(",")
          # Trim spaces
          |> Enum.map(&String.trim/1)
          # Get the first preferred language
          |> List.first()
          # Split language region (e.g., "en-US")
          |> String.split("-")
          # Extract the language (e.g., "en")
          |> List.first()

        cond do
          String.starts_with?(locale, "de-") || locale == "de" -> "de"
          true -> "en"
        end

      nil ->
        "en"
    end
  end

  @doc """
  Action that allows toggling between locales.
  It will update session, set the cookie, and redirect to the same url that refered it.
  """
  def update(conn, %{"locale" => locale}) when locale in ["de", "en"] do
    referer =
      Enum.find(conn.req_headers, fn {k, _v} -> k == "referer" end)
      |> case do
        {_, referer} -> referer
        nil -> "/"
      end

    conn
    |> put_session(:locale, locale)
    |> put_resp_cookie(@locale_cookie, locale, @locale_options)
    |> assign(:locale, locale)
    |> redirect(external: referer)
  end
end

There are two public functions in this module. put_locale/2 is being called on every page request because it was plugged into the browser pipeline. update/2 is the function that gets executed when we click the language toggle button.

Spend at least 5-10 minutes discussing the pieces of each function in detail with your partner to help you understand how to tweak it towards your own language. I have spend quite some time figuring out the logic and would hate to see you rushing through it.

Pay especially attention how the locale is determined in the put_locale function.

Answer with your parntner: What does it mean if the locale is coming from a session versus the cookie versus the request header?

Also do some research of what else gets transmitted via the request headers. In fact it would be best to use your Browser's Web Inspector to actually look at your request headers when opening the course website.

Finally try to guess why I chose to redirect via the :external flag versus just a regular path in the update/2 function.

Once you are done discussing this module you should be able to get your language token working.

Translations via Gettext (4 points)

Now its time to do some translations. Remember what you watched in the video, this is all going to be relevant from here onwards.

Remember the two commands to update template files (*.pot) and translation files (*.po).

mix gettext.extract
mix gettext.merge priv/gettext

The first time you run these you will want to add a flag for the locale to create the new files. To create german translation files you would use:

mix gettext.merge priv/gettext --locale de

On subsequent calls all existing locales will update so you should skip the --locale flag, otherwise you would only be updating that specific locale.

Run those once to get things setup.

Now its time to walk the long road and one-by-one replace as many static strings that you can find in your template files with gettext calls:

<span>Hello</span>

would become:

<span>{gettext("Hello")}</span>

Once you run the two mix gettext commands again you should see new entries in your po and pot files. We will now want to work on our *.po file for the translation:

#: lib/app_web/components/ui/navbar.ex:32
#, elixir-autogen, elixir-format
msgid "Hello"
msgstr "Hallo"

Fully translate at least 3 pages (such as the chat or your homepage, including your menu). Don't forget attachments such as forms and modals. Make sure you have at least 50 translation snippets over all your pages. If you miss a few words on a page then we won't deduct points here but if you pages are only halfway translated you won't get the points for this objective.

Test Module

For completeness I have also attached my test module here:

defmodule AppWeb.LocaleControllerTest do
  use AppWeb.ConnCase, async: true

  @locale_cookie "_app_web_locale"

  describe "PUT locale" do
    test "assigns locale from session when present", %{conn: conn} do
      # Simulate a connection with a locale in the session
      conn = get(conn, "/")

      # Check that the default locale from session is assigned
      assert get_session(conn, :locale) == "en"
      assert conn.assigns[:locale] == "en"

      # Check that the locale is also stored in the cookie
      assert conn.resp_cookies[@locale_cookie].value == "en"
    end

    test "assigns locale from cookie when present", %{conn: conn} do
      # Simulate a connection with a locale in the cookie
      conn =
        conn
        |> put_resp_cookie(@locale_cookie, "de")
        |> get("/")

      # Check that the locale from cookie is assigned
      assert get_session(conn, :locale) == "de"
      assert conn.assigns[:locale] == "de"
    end

    test "assigns locale from accept-language header", %{conn: conn} do
      # Simulate a connection with an Accept-Language header
      conn =
        conn
        |> put_req_header("accept-language", "de,en;q=0.8")
        |> get("/")

      # Check that the locale from the header is assigned
      assert get_session(conn, :locale) == "de"
      assert conn.assigns[:locale] == "de"
    end

    test "assigns default locale when none provided", %{conn: conn} do
      # Simulate a connection with no locale set in session, cookie, or header
      conn = get(conn, "/")

      # Check that the default locale is assigned
      assert get_session(conn, :locale) == "en"
      assert conn.assigns[:locale] == "en"
    end

    test "assigns locale from header but defaults to 'en' if unsupported", %{conn: conn} do
      # Simulate a connection with an unsupported language in the Accept-Language header
      conn =
        conn
        |> put_req_header("accept-language", "fr,es;q=0.8")
        |> get("/")

      # Check that it defaults to 'en'
      assert get_session(conn, :locale) == "en"
      assert conn.assigns[:locale] == "en"
    end
  end

  describe "update/2" do
    setup %{conn: conn} do
      # Set up the conn with a referer and a default locale (e.g., "en")
      conn =
        conn
        |> put_req_header("referer", "http://example.com")
        |> assign(:locale, "en")

      {:ok, conn: conn}
    end

    test "toggles locale from 'en' to 'de' and redirects to referer", %{conn: conn} do
      conn = put(conn, ~p"/locale/de")

      assert get_session(conn, :locale) == "de"
      assert fetch_cookies(conn).cookies[@locale_cookie] == "de"
      assert conn.assigns.locale == "de"
      assert redirected_to(conn) == "http://example.com"
    end

    test "toggles locale from 'de' to 'en' and redirects to referer", %{conn: conn} do
      # Setting initial locale to "de"
      conn = assign(conn, :locale, "de")
      conn = put(conn, ~p"/locale/en")

      assert get_session(conn, :locale) == "en"
      assert fetch_cookies(conn).cookies[@locale_cookie] == "en"
      assert conn.assigns.locale == "en"
      assert redirected_to(conn) == "http://example.com"
    end
  end
end
Copyright © 2025 Alexander Fuchsberger, Bucknell University. All rights reserved.