Skip to content

Internationalization (i18n)

fitz-liveviews is i18n-agnostic: the framework never hard-codes a language, and the packaged UI components take a locale and translate through a dictionary you own. That keeps the library small — but it means the wiring lives in your app. This chapter is the official path, so you don't have to reverse-engineer it from the Admin ABM.

The whole pattern is five pieces:

  1. A language cookie the browser carries.
  2. Reading the locale in every @get and every @ws.
  3. <html lang> on the document.
  4. A /lang/{code} route that sets the cookie and redirects back.
  5. A t(locale, key) dictionary — your responsibility.

The one trap: the locale reaches a @ws socket through the handshake cookie, not __flv_init. __flv_init only carries the ws_path query string. Read the cookie at the handshake with @header(name="cookie") + locale_from_cookie(...) on the @ws handler (the WebSocket upgrade is an HTTP request) — see step 2.


Pick a cookie name and a default. Everything keys off it.

fn lang_cookie_name() -> Str => "flv_lang"

// Normalize whatever the cookie holds to a supported locale.
fn normalize_locale(raw: Str?) -> Str {
  return match raw {
    "en" => "en",
    _    => "es",     // default
  }
}

2. Reading the locale — in @get AND @ws

A live app renders twice — once over HTTP (the first paint) and then over the socket (every diff). Both have to know the locale, or the diffs come back in the wrong language and the page flickers between two. This is the most common i18n bug in LiveViews and the hardest to debug.

On @get / @post — read the cookie declaratively with @cookie(name="...") (Fitz core v0.49.0+): it binds the value (or null) to a handler param, no manual Cookie: parsing.

@cookie(name="flv_lang")
@get("/")
fn home(flv_lang: Str?) -> Response {
  let locale = normalize_locale(flv_lang)
  let opts = LayoutOpts { title: t(locale, "app.title"), lang: locale }
  return html_response(live_layout_with(opts, "/live/board", "board",
    board_render(board_state(locale))))
}

On @ws — read the raw handshake cookie with @header(name="cookie") and parse it. (@cookie is not yet wired into the @ws arity check, so use the header path here — the WebSocket upgrade carries the cookie in its request headers.) A tiny reusable parser:

fn locale_from_cookie(cookie: Str?) -> Str {
  let raw: Str = match cookie { null => return "es", c => c }
  for part in raw.split(";") {
    let kv = part.trim().split("=")
    if (kv.len() >= 2) {
      if (kv[0] == "flv_lang") { return normalize_locale(kv[1]) }
    }
  }
  return "es"
}

@header(name="cookie")
@ws("/live/board")
async fn board_socket(ws: WsConn<LiveFrame>, cookie: Str?) {
  let locale = locale_from_cookie(cookie)     // <-- the whole connection's language
  // ... render/dispatch using `locale`, so diffs come back translated ...
}

Put the cookie read (@cookie on HTTP, @header(name="cookie") on @ws) as the first line of every handler — it's the cheapest way to never ship a mixed-language page. This is exactly what examples/admin/src/i18n.fitz does.

3. <html lang>

Set lang on the document so screen readers and the browser pick the right language. live_layout_with (FLV-01) takes it in LayoutOpts:

let opts = LayoutOpts { title: t(locale, "app.title"), lang: locale }
return html_response(live_layout_with(opts, ws_path, root_id, initial))

For a full admin shell, app_shell(title, lang, ...) emits <html lang="{lang}"> the same way.

4. /lang/{code} — switch the language

A plain link (<a href="/lang/en">EN</a>) hits this route, which sets the cookie and redirects back to where the user was (via the referer header). With the Fitz core cookie API (v0.49.0), write it as a Cookie on the response — no hand-built Set-Cookie string:

@header(name="referer")
@get("/lang/{code}")
fn set_lang(code: Str, referer: Str?) -> Response {
  let loc = normalize_locale(code)
  let back = match referer { null => "/", r => r }
  return Response {
    status: 303,
    cookies: [ Cookie {
      name: lang_cookie_name(),
      value: loc,
      path: "/",
      max_age: 31536000,        // 1 year
      same_site: "Lax",
    } ],
    headers: { "Location": back },
  }
}

The 303 + Location sends the browser back to the page it came from, now with the new cookie, so the next render (HTTP and socket) is in the chosen language.

Pre-v0.49.0 / manual fallback. Build the header by hand: headers: { "Set-Cookie": "flv_lang=" + loc + "; Path=/; Max-Age=31536000; SameSite=Lax", "Location": back }.

5. t(locale, key) — the dictionary (your job)

The framework does not ship a translation table — that's your content. A dotted-key lookup with a self-returning fallback (so a missing key is visible but never crashes) is enough:

fn t(locale: Str, key: Str) -> Str {
  return match locale {
    "en" => t_en(key),
    _    => t_es(key),
  }
}

fn t_es(key: Str) -> Str {
  return match key {
    "app.title"     => "Mi App",
    "nav.home"      => "Inicio",
    "actions.save"  => "Guardar",
    _               => key,          // missing → show the key
  }
}

fn t_en(key: Str) -> Str {
  return match key {
    "app.title"     => "My App",
    "nav.home"      => "Home",
    "actions.save"  => "Save",
    _               => key,
  }
}

Then thread locale into every render and every component. The packaged UI components already take a locale-derived string — you pass t(locale, "…"), never a hard-coded literal:

button_render(button {
  label: t(locale, "actions.save"), variant: "primary", on_click: "save"
})

Interpolating counts / names into a translated string goes in the dictionary, not the template, so word order stays correct per language:

fn confirm_msg(locale: Str, n: Int) -> Str {
  return match locale {
    "en" => "Delete {n} item(s)?",
    _    => "¿Eliminar {n} elemento(s)?",
  }
}

Loading the catalogs from JSON files at boot (instead of compiling them in) is possible with the Fitz core fs module (fs.read + json.loads) — see the language guide's "Filesystem" chapter.


Checklist

  • [ ] One cookie name + a normalize_locale with a default.
  • [ ] @cookie(name="flv_lang") on every @get and @ws.
  • [ ] lang set on the document (LayoutOpts / app_shell).
  • [ ] A /lang/{code} route that sets the cookie + 303 back.
  • [ ] A t(locale, key) dictionary; no hard-coded strings in templates or components.
  • [ ] Count/name interpolation lives in the dictionary, not the template.

A working end-to-end example is the flagship Admin ABM (examples/admin/, ES/EN) — i18n.fitz (dictionary + reader), auth.fitz (/lang/{code}), and the @ws handlers reading the locale at the handshake.

See also: LiveViews · UI components.