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:
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'sassigns
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