Tableau static + Corex: localize
View SourceThis guide is for a Tableau site that already follows Tableau through design assets, ESM Esbuild, root layout, and LiveSocket hooks.
The Phoenix setup in Localize resolves locale in the router and wraps routes in localize do … end. Static Tableau builds locale into permalinks at compile time: / for the default locale, /<locale>/... elsewhere. locale.js persists the chosen locale for cross-tab UX and hydrates the language select; Gettext still drives gettext/1** in templates.
Dependencies for locale.js data-rtl-locales: derive RTL locale codes from the same source as MyApp.Locale.dir/1 (template below filters locales where CLDR layout is :rtl).
1. Dependencies
def deps do
[
{:gettext, "~> 1.0"},
{:localize, "~> 0.26"}
]
endmix deps.get
mix localize.download_locales en ar
mix localize.download_locales fills the on-disk CLDR cache for Localize.Locale.get/3 and Localize.Locale.display_name/2. Run it when the supported list changes; consider mix setup.
localize_webis not required in a Tableau site — its plugs andLocalize.Routesare Phoenix-router only. Add it only if you want its HTML helpers.
2. Gettext backend and config
defmodule MyApp.Gettext do
use Gettext.Backend,
otp_app: :my_app,
default_locale: "en",
allowed_locales: ~w(en ar)
def default_locale, do: __gettext__(:default_locale)
def locales, do: Gettext.known_locales(__MODULE__)
endIn config/config.exs (use literals; config runs before your modules compile):
config :phoenix,
gettext_backend: MyApp.Gettext,
json_library: Jason
config :localize,
supported_locales: ~w(en ar)aconfig :phoenix, :gettext_backend lets Corex components read default labels from your catalog.
3. MyApp.Config (locale slice)
Merge with Tableau static + Corex: theming themes / site_name if you use that guide too.
defmodule MyApp.Config do
@app :my_app
def site_name, do: Application.get_env(@app, :site_name, "MyApp")
def default_locale, do: MyApp.Gettext.default_locale()
def locales, do: MyApp.Gettext.locales()
end4. MyApp.Locale
defmodule MyApp.Locale do
def locales, do: MyApp.Config.locales()
def default_locale_string, do: MyApp.Config.default_locale()
def current(page) when is_map(page) do
perm = Map.get(page, :permalink) || Map.get(page, "permalink") || "/"
case String.split(perm, "/", trim: true) do
[first | _] -> if first in locales(), do: first, else: default_locale_string()
[] -> default_locale_string()
end
end
def lang(locale) when is_binary(locale), do: locale
def lang(_), do: default_locale_string()
def dir(locale) do
loc = lang(locale)
case Localize.Locale.get(loc, [:layout, :character_order], fallback: true) do
{:ok, :rtl} -> "rtl"
{:ok, :ltr} -> "ltr"
_ -> if loc == "ar", do: "rtl", else: "ltr"
end
end
def label(loc) when is_atom(loc), do: label(Atom.to_string(loc))
def label(loc) when is_binary(loc) do
case Localize.Locale.display_name(loc, locale: loc) do
{:ok, name} -> name
_ -> String.upcase(loc)
end
end
def swap_path(request_path, target_locale) do
target = to_string(target_locale)
supported = locales()
default = default_locale_string()
rest =
case String.split(request_path, "/", trim: true) do
[first | tail] -> if first in supported, do: tail, else: [first | tail]
[] -> []
end
cond do
target == default and rest == [] -> "/"
target == default -> "/" <> Enum.join(rest, "/")
rest == [] -> "/" <> target <> "/"
true -> "/" <> Enum.join([target | rest], "/")
end
end
def current_path(%{permalink: perm}) when is_binary(perm) do
if String.starts_with?(perm, "/"), do: perm, else: "/" <> perm
end
def current_path(_), do: "/"
def selected_path(page, locale), do: page |> current_path() |> swap_path(locale)
def language_select_items(current_path) do
locales()
|> Enum.map(fn loc ->
dest = swap_path(current_path, loc)
Corex.List.Item.new(%{
value: dest,
to: dest,
label: label(loc)
})
end)
|> Corex.List.new()
end
def language_select_value(current_path, locale), do: [swap_path(current_path, locale)]
end5. Root layout additions
In template/1, add use Gettext, backend: MyApp.Gettext next to use Corex. Resolve locale from assigns.page, apply Gettext.put_locale/2 before ~H, and enrich assigns (rtl_locales must agree with MyApp.Locale.dir/1 for each listed code):
def template(assigns) do
locale = MyApp.Locale.current(assigns.page)
Gettext.put_locale(MyApp.Gettext, MyApp.Locale.lang(locale))
rtl_locales =
MyApp.Locale.locales()
|> Enum.filter(&(MyApp.Locale.dir(&1) == "rtl"))
|> Enum.join(",")
assigns =
assigns
|> Map.put(:locale, locale)
|> Map.put(:rtl_locales, rtl_locales)Minimal <html> deltas (combine with data-theme / data-mode when you follow those guides):
<html
lang={MyApp.Locale.lang(@locale)}
dir={MyApp.Locale.dir(@locale)}
data-locale={@locale}
data-locales={Enum.join(MyApp.Config.locales(), ",")}
data-rtl-locales={@rtl_locales}
data-locale-selected-path={MyApp.Locale.selected_path(assigns.page, @locale)}
>If you publish under a subpath (GitHub Pages project sites), also set data-public-path-prefix to the pathname prefix (without trailing slash); locale.js strips it before reading the first segment. Omit both when serving from the domain root.
Theming and mode <head> scripts stay outside this guide; see Tableau static + Corex: theming and Tableau static + Corex: mode.
6. assets/js/locale.js
Self-contained (parseList, whenControlReady, firstDetailValue inlined):
;(() => {
const html = () => document.documentElement
const parseList = (attr) =>
(html().getAttribute(attr) || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
const whenControlReady = (id, run) => {
const iv = window.setInterval(() => {
const root = document.getElementById(id)
if (root && !root.hasAttribute("data-loading")) {
window.clearInterval(iv)
run()
}
}, 10)
window.setTimeout(() => window.clearInterval(iv), 10_000)
}
const firstDetailValue = (e) => {
const value = e.detail?.value
return Array.isArray(value) && value[0] ? value[0] : null
}
const validLocales = () => parseList("data-locales")
const rtlLocales = () => new Set(parseList("data-rtl-locales"))
const directionForLocale = (loc) =>
loc && rtlLocales().has(loc) ? "rtl" : "ltr"
const resolvedLocale = () => {
const pathLoc = localeFromPathname()
if (pathLoc) return pathLoc
const docLoc = html().getAttribute("data-locale")
if (docLoc && validLocales().includes(docLoc)) return docLoc
const stored = localStorage.getItem("data-locale")
if (stored && validLocales().includes(stored)) return stored
return ""
}
const syncDocumentLocale = () => {
const loc = resolvedLocale()
if (!loc || !validLocales().includes(loc)) return
html().setAttribute("lang", loc)
html().setAttribute("data-locale", loc)
html().setAttribute("dir", directionForLocale(loc))
}
const setLocale = (loc) => {
const allowed = validLocales()
if (!loc || !allowed.includes(loc)) return
localStorage.setItem("data-locale", loc)
}
const syncLangSelect = (path) => {
const root = document.getElementById("corex-language-switch")
if (!root || !path) return
root.dispatchEvent(
new CustomEvent("corex:select:set-value", { detail: { value: [path] } }),
)
}
const publicPathPrefix = () => {
const raw = html().getAttribute("data-public-path-prefix") || ""
return raw.replace(/\/+$/, "")
}
const localeFromPathname = () => {
let pathname = window.location.pathname
const pre = publicPathPrefix()
if (pre && pathname.startsWith(pre)) {
pathname = pathname.slice(pre.length) || "/"
}
const segs = pathname.split("/").filter(Boolean)
const first = segs[0] || ""
return validLocales().includes(first) ? first : ""
}
const syncLangFromDocument = () => {
const path = html().getAttribute("data-locale-selected-path")
syncLangSelect(path)
}
const pathLocale = localeFromPathname()
if (pathLocale) setLocale(pathLocale)
syncDocumentLocale()
whenControlReady("corex-language-switch", () => {
syncDocumentLocale()
syncLangFromDocument()
})
window.addEventListener("storage", (e) => {
if (e.key === "data-locale" && e.newValue) {
setLocale(e.newValue)
syncDocumentLocale()
}
})
window.addEventListener("corex:set-locale", (e) => {
const raw = firstDetailValue(e)
const s = raw != null ? String(raw) : ""
const seg = s.replace(/^\/+|\/+$/g, "").split("/")[0] || ""
const allowed = validLocales()
if (allowed.includes(seg)) {
setLocale(seg)
html().setAttribute("lang", seg)
html().setAttribute("data-locale", seg)
html().setAttribute("dir", directionForLocale(seg))
}
})
window.addEventListener("pageshow", () => {
syncDocumentLocale()
syncLangFromDocument()
})
})()7. assets/js/site.js additions
import "./locale.js"Register Select if not already present:
Select: () => import("corex/select"),8. Per-locale page modules
One template module; Module.create/3 emits one Tableau.Page per locale:
defmodule MyApp.HomePage do
use Phoenix.Component
use Corex
use Gettext, backend: MyApp.Gettext
def template(assigns) do
~H"""
<article class="layout__article" aria-labelledby="home-headline">
<h1 id="home-headline">{gettext("Welcome")}</h1>
<p>{gettext("Hello from %{name}.", name: MyApp.Config.site_name())}</p>
</article>
"""
end
end
for locale <- MyApp.Config.locales() do
mod = Module.concat(MyApp.HomePage, String.upcase(locale))
permalink =
if locale == MyApp.Config.default_locale(), do: "/", else: "/#{locale}/"
Module.create(
mod,
quote do
use Tableau.Page,
layout: MyApp.RootLayout,
permalink: unquote(permalink),
title: unquote(MyApp.Config.site_name()),
page_kind: :home
def template(assigns), do: MyApp.HomePage.template(assigns)
end,
__ENV__
)
endRepeat the for block for each page that should exist under every locale.
9. Language switcher HEEx
redirect navigates to item.to; on_value_change_client="corex:set-locale" updates locale.js / localStorage. The authoritative locale remains the URL.
<.select
id="corex-language-switch"
class="select select--sm max-w-6xs"
dir={MyApp.Locale.dir(@locale)}
items={MyApp.Locale.language_select_items(MyApp.Locale.current_path(@page))}
value={MyApp.Locale.language_select_value(MyApp.Locale.current_path(@page), @locale)}
redirect
on_value_change_client="corex:set-locale"
translation={%Corex.Select.Translation{placeholder: gettext("Language")}}
positioning={%Corex.Positioning{same_width: true}}
>
<:label class="sr-only">{gettext("Language")}</:label>
<:item :let={item}>{item.label}</:item>
<:trigger>
<.heroicon name="hero-language" class="icon" />
</:trigger>
<:item_indicator>
<.heroicon name="hero-check" class="icon" />
</:item_indicator>
</.select>10. Translate strings
mix gettext.extract
mix gettext.merge priv/gettext
Fill priv/gettext/<locale>/LC_MESSAGES/default.po.
Related
- Tableau — baseline Tableau + Corex setup.
- Localize — Phoenix
localize_webandlocalize do. - Tableau static + Corex: theming / Tableau static + Corex: mode — combine when you want theme or mode alongside locales.