Translations (i18n)

Answer each visitor in their language with message catalogs, Accept-Language negotiation, placeholders and plural forms, no extra dependencies.

The i18n module (since 18.16.0) holds your messages in one catalog per language, picks the language each request asks for in Accept-Language, and fills in placeholders and plural forms. It needs no feature flag and no extra dependency.

Catalogs

One file per language, named by its language tag: locales/en.toml, locales/uk.toml, locales/en-GB.toml. The format is a flat subset of TOML, so editors and translation tools open it as TOML:

# locales/en.toml
greeting    = "Hello, {name}!"
items.one   = "{count} item"
items.other = "{count} items"
# locales/uk.toml
greeting    = "Привіт, {name}!"
items.one   = "{count} річ"
items.few   = "{count} речі"
items.many  = "{count} речей"

Values are quoted: "…" takes \n, \t, \", \\ and é escapes, '…' is taken as written. {name} is a placeholder; {{ and }} are literal braces. Load them once at startup:

use rust_web_server::i18n::Catalogs;

let catalogs = Catalogs::from_dir("locales", "en")?; // "en": the default language
# Ok::<(), String>(())

Catalogs::new("en").add("uk", text)? builds them from strings instead (include_str! keeps them in the binary).

In a handler

use rust_web_server::app::App;
use rust_web_server::i18n::Catalogs;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};

let app = App::with_state(Catalogs::from_dir("locales", "en").unwrap())
    .get("/cart", |request, _params, _conn, catalogs| {
        let t = catalogs.for_request(request);            // reads Accept-Language
        let body = format!(
            "{}\n{}\n",
            t.t_with("greeting", &[("name", "Olena")]),
            t.plural("items", 3),                         // "3 речі" for uk, "3 items" for en
        );
        t.localize(Response::text(STATUS_CODE_REASON_PHRASE.n200_ok, &body))
    });
  • t(key) / t_with(key, &[("name", value)]): the message, placeholders filled. A key the language lacks falls back to its base language (en-GB → en), then the default language, then the key itself, so a missing translation shows up as its key rather than an error.
  • plural(key, count) / plural_with(…): key.one, key.few, key.many or key.other for that count in that language, with {count} filled; key.other when the category has no message.
  • localize(response) sets Content-Language and adds Vary: Accept-Language, so caches keep one copy per language.
  • for_language("uk") when the user picked a language (a cookie, a profile setting, a /uk/… path) rather than the browser's.

Choosing the language

AcceptLanguage (also an extractor) parses the header: ranges ordered by q and then by position, q=0 left out, at most 32 considered. negotiate follows RFC 4647 lookup: each range as sent, then with subtags cut from the end (uk-UA → uk), then a range's language matching a regional catalog (pt → pt-BR). Nothing matching, or only *, gives the default language.

use rust_web_server::i18n::AcceptLanguage;

let preferences = AcceptLanguage::parse("uk-UA, en;q=0.8");
assert_eq!(Some("uk"), preferences.negotiate(&["en", "uk"]));

Plural forms

plural_category(language, count) covers the common families:

Languages Categories
Ukrainian, Russian, Belarusian, Polish one (1, 21, 31…; Polish only 1), few (2–4, 22–24…), many (the rest)
Czech, Slovak one (1), few (2–4), other
French, Brazilian Portuguese one (0 and 1), other
Japanese, Chinese, Korean, Vietnamese, Thai, Indonesian, Malay, Turkish… other only
everything else one (1), other (English rules)

Caching

CacheLayer caches a response whose Vary names a header only when that header is part of its cache key. Add it with .vary_by_header("Accept-Language") to cache localized pages per language; otherwise they pass through uncached rather than being served in the wrong language.

Edit this page on GitHub