Translations (i18n)
With the i18n feature, an app can speak several languages. Put one TOML file per language in locales/, register them, and take a Locale in handlers:
# locales/en.toml
hello = "Hello, {name}!"
[inbox]
count.zero = "No messages"
count.one = "{count} message"
count.other = "{count} messages"
# locales/fr.toml
hello = "Bonjour, {name} !"
inbox.count.one = "{count} message"
inbox.count.other = "{count} messages"
#![allow(unused)] fn main() { use zapline::i18n::{I18n, Locale}; #[get("/hello/{name}")] async fn hello(t: Locale, name: Path<String>) -> String { t.t_with("hello", &[("name", &name.0)]) } App::new() .i18n(I18n::load("locales")?) .mount(hello) }
The file name is the language tag (en, fr, pt-BR), and nested tables become dotted keys (inbox.count.one). I18n::messages(lang, toml) adds a language from a string instead, for example one embedded with include_str!.
Under zapline dev, translations loaded with I18n::load are read again when a file in the directory changes, and open pages reload: no restart. A file with a syntax error keeps the last good messages (the error goes to stderr). Outside zapline dev (no ZAPLINE_DEV=1) they are loaded once.
Which language
For each request, Locale is the first available language from:
?lang=frin the URL. The app then remembers it in alangcookie (one year,SameSite=Lax), so a language switcher is just links:<a href="?lang=fr">Français</a>.- The
langcookie. - The
Accept-Languageheader, by its weights. - The default:
enif there is anen.toml, otherwise the first file alphabetically, or the one set with.default_lang("de").
Tags match exactly (ignoring case) or by their main part: pt-BR uses pt.toml when there is no pt-BR.toml, and de-AT uses de.toml.
Messages
| Call | Result |
|---|---|
t.t("key") | The message, as written |
t.t_with("hello", &[("name", &user.name)]) | {name} filled in; any Display value works. {{ is a literal { |
t.plural("inbox.count", n, &[]) | The plural form for n, with {count} filled in |
t.lang() | The language tag, for <html lang> or formatting |
A message missing in the visitor's language falls back to the default language, then to the key itself, so a gap shows on the page rather than failing it. I18n::missing() lists the gaps, which makes a good test:
#![allow(unused)] fn main() { #[test] fn every_language_is_complete() { let i18n = I18n::load("locales").unwrap(); assert_eq!(i18n.missing(), []); } }
The dev console shows each language with its message count and missing keys.
Plurals. plural picks key.one, key.few, key.many, key.other and so on by the language's rules (CLDR rules for integers, built in for English, French, Portuguese, Spanish, German, Russian, Ukrainian, Polish, Czech, Arabic, Japanese, Chinese, Korean and the many languages that follow one of their patterns). A key.zero message, when the language has one, is used for 0 in any language. All forms come from one language: if the visitor's language lacks the key, the default language's forms and rules are used.
In templates
With the views feature, pass the Locale into the context and call it:
#![allow(unused)] fn main() { #[get("/")] async fn index(views: Views, t: Locale) -> Result<Html<String>> { views.render("index.html", context! { t, unread: 3 }) } }
<html lang="{{ t.lang }}">
<h1>{{ t("hello", name=user.name) }}</h1>
<p>{{ t.plural("inbox.count", unread) }}</p>
Translated text is escaped like any other value.
If you cache pages with ResponseCache, the language is part of the page: cache only pages that don't depend on it, or vary the cache by language.