Tableau Localize
View SourceIntroduction
You add locales to a Tableau site that already follows Tableau. Each locale gets its own permalink at build time (/ for the default, /<locale>/... for others). Gettext drives copy; locale.js persists the visitor's choice and syncs the language <.select>.
For Phoenix apps with localize_web and router scopes, see Localize.
Before you start
| Requirement | Notes |
|---|---|
| Tableau | Design assets, ESM Esbuild, use Corex, LiveSocket |
{:gettext, "~> 1.0"} and {:localize, "~> 0.26} | CLDR display names and RTL |
select.css | For the language switcher |
localize_web is not required for Tableau static sites.
How it works
- Gettext catalogs hold translated strings;
gettext/1in templates reads the active locale. - Permalinks encode locale in the URL path at compile time.
locale.jsreadsdata-locale*on<html>, updateslang/dir, and syncs the select.<.select redirect>navigates to the target path;corex:set-localeupdates storage for cross-tab UX.
Dependencies
def deps do
[
{:gettext, "~> 1.0"},
{:localize, "~> 0.26"}
]
endmix deps.get
mix localize.download_locales en ar
Run mix localize.download_locales again when you add locales.
Gettext
lib/my_app/gettext.ex:
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__)
endconfig/config.exs:
config :phoenix,
gettext_backend: MyApp.Gettext,
json_library: Jason
config :localize,
supported_locales: ~w(en ar)aLocale module
Merge site_name with Tableau Theming if you use themes.
lib/my_app/config.ex:
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()
endlib/my_app/locale.ex:
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)]
endLayout
In RootLayout, add use Gettext, backend: MyApp.Gettext next to use Corex.
In template/1, before ~H:
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)On <html> (combine with data-theme / data-mode when you use 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)}
>For GitHub Pages project sites, set data-public-path-prefix to the pathname prefix without a trailing slash; locale.js strips it before reading the first path segment.
Tableau Theming and Tableau Mode head scripts stay in <head> as documented there.
locale.js
Create assets/js/locale.js:
;(() => {
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()
})
})()site.js
import "./locale.js"Register Select in hooks if not already present.
Pages
One shared template; emit 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.
Language select
redirect navigates to item.to. The URL is authoritative; corex:set-locale updates localStorage for other tabs.
<.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>Extract and merge catalogs:
mix gettext.extract
mix gettext.merge priv/gettext
Fill priv/gettext/<locale>/LC_MESSAGES/default.po.
Related
- Tableau — baseline setup
- Localize — Phoenix
localize_weband router scopes - Tableau Theming, Tableau Mode — combine when needed