Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Translations

Ocre translates an app Rails-style: strings live in locales/<code>.yml files compiled into the Worker, handlers take the I18n extractor for the request’s locale, and templates call i18n.t("key") with %{name} values and CLDR plural forms. This page covers ocre g locale, the locale file format, how the locale of a request is chosen, missing keys and defaults, scoped keys, HTML in translations, dates, numbers, model and attribute names, validation messages, the built-in translations, ocre i18n missing, and translations outside requests.

Before you start

  • An Ocre app created with ocre new. Translations work in full-stack and API-only apps; the template examples need a full-stack app.
  • Nothing on Cloudflare: translations use no binding, no D1, KV or other billed resource.
  • The examples use the Post model of the blog starter (ocre new <name> --starter blog) for a count.

Setting up translations

ocre g locale en fr
  create  locales/en.yml
  create  locales/fr.yml
  update  src/lib.rs

Next:
  add keys to the locale files; take `i18n: ocre::i18n::I18n` in a handler and call i18n.t("key")
  ocre i18n missing

The first code is the default locale. The first run adds two things to src/lib.rs: the LOCALES static at the end of the file and the layer as the last call of routes():

fn routes() -> Router<Ctx> {
    Router::new()
        .route("/", get(home))
        .route("/up", get(up))
        // ocre:routes
        .merge(posts::routes())
        .layer(ocre::i18n::layer(&LOCALES))
}

/// Translations in locales/<code>.yml, compiled into the Worker; the first code
/// is the default locale. `ocre g locale <code>` adds one.
static LOCALES: ocre::i18n::Locales = ocre::locales!("en", "fr");
  • ocre::locales!("en", "fr") includes locales/en.yml and locales/fr.yml (paths from the app root) with include_str!; a missing file is a compile error. The files are parsed once per Worker instance, on first use.
  • ocre::i18n::layer(&LOCALES) makes the catalog available to the I18n extractor. Keep it last in routes(), after the // ocre:routes marker, so it covers every route; generators insert new routes above it. Without it, I18n answers a 500 whose log names this fix.

Later runs add a locale (ocre g locale de creates locales/de.yml and appends "de" to the macro). Codes are a language, optionally with a region or script: en, fr, pt-BR, zh-Hant. Declaring a code twice fails:

error: locale `fr` is already declared in src/lib.rs
hint: edit locales/fr.yml; `ocre i18n missing` lists keys to translate

The generated files:

# Default locale (en). Nested keys, used as i18n.t("app.welcome") in handlers
# and {{ i18n.t("app.welcome") }} in templates. Quote values that start with
# `%` or contain `: `, e.g. "%{count} posts"; `%{name}` takes .arg("name", value).
# Plurals: a key with one/other children (plus few/many... for some languages),
# picked by .count(n). `ocre i18n missing` checks the other locales against this file.
en:
  app:
    welcome: "Welcome"
# Locale fr. Translate every key of locales/en.yml at the same path;
# `ocre i18n missing` lists the keys still to add.
fr:

Locale files

A locale file is a subset of YAML: nested keys and strings, the part Rails locale files use. Every file Ocre accepts is valid YAML with the same meaning. The examples on this page use these two files:

# locales/en.yml
en:
  app:
    welcome: "Welcome"
  hello:
    title: "Hello"
    greeting: "Hello %{name}!"
    posts:
      one: "%{count} post"
      other: "%{count} posts"
# locales/fr.yml
fr:
  app:
    welcome: "Bienvenue"
  hello:
    title: "Bonjour"
    greeting: "Bonjour %{name} !"
    posts:
      one: "%{count} article"
      other: "%{count} articles"

Keys are addressed with dots, without the locale: hello.greeting. The rules the parser enforces:

RuleDetail
One root keyThe file starts with its code on its own line (fr:), at column 0; everything else is indented under it
IndentationSpaces, not tabs; siblings use the same indentation (2 spaces by convention)
KeysASCII letters, digits, _ and -, followed by : or by : at the end of the line; no duplicate keys
ValuesStrings on the same line: double-quoted (escapes \n, \t, \", \\, \/, \uXXXX), single-quoted ('' for a quote), or plain
Plain valuesMust be quoted when they start with one of [, {, &, *, !, %, @, ,, ?, - or a backtick, contain : , end with :, or read as another YAML type (~, null, true, false, yes, no, on, off)
CommentsLines starting with #, and # ... after a value
Not supportedLists (- item), block scalars (|, >), flow collections, anchors; a key with neither a value nor children

When in doubt, double-quote every value. Values containing %{...} start with % often enough that the generated comment asks to quote them.

ocre dev and ocre deploy read the files with the same parser before building and refuse a file the Worker could not load, naming the line and the fix. With posts: %{count} articles on line 4 of locales/fr.yml:

ocre dev
error: invalid locale files:
  locales/fr.yml:4: quote values starting with `%`, e.g. "%{count} articles"
hint: fix each line named above (quote values with "..." when in doubt); `ocre i18n missing` checks them again

Translating in handlers and templates

Take i18n: ocre::i18n::I18n in a handler and pass it to the template struct (it is Copy). In templates, {{ i18n.t("key") }} writes the translation straight into the page, escaped by askama like any value; never mark it |safe.

  • i18n.t("hello.greeting").arg("name", value) fills %{name} (value is anything Display). A placeholder without a value stays as written.
  • i18n.t("hello.posts").count(n) picks the plural form for n and fills %{count}.
  • i18n.locale() is the code, for <html lang="{{ i18n.locale() }}">; i18n.codes() lists every code, default first, for a language switcher.
  • In Rust code, i18n.t("posts.created").to_string() gives a String (flash messages, JSON, emails).
  • i18n.path("/posts") is /fr/posts for French visitors: links that keep the locale (see Links that keep the locale).
// src/hello.rs
use askama::Template;
use axum::{Router, extract::State, response::Html, routing::get};
use ocre::{Ctx, Result, i18n::I18n, render};

use crate::models::post;

#[derive(Template)]
#[template(
    source = r#"<!doctype html>
<html lang="{{ i18n.locale() }}">
<title>{{ i18n.t("hello.title") }}</title>
<h1>{{ i18n.t("hello.greeting").arg("name", name) }}</h1>
<p>{{ i18n.t("hello.posts").count(posts) }}</p>
<nav>{% for code in i18n.codes() %}
  <form method="post" action="/locale/{{ code }}"><button>{{ code }}</button></form>
{%- endfor %}</nav>
</html>"#,
    ext = "html"
)]
struct HelloView {
    i18n: I18n,
    name: &'static str,
    posts: i64,
}

/// `/hello` picks the locale from the cookie or `Accept-Language`;
/// `/en/hello` and `/fr/hello` take it from the path.
pub fn routes() -> Router<Ctx> {
    Router::new()
        .route("/hello", get(hello))
        .nest("/{locale}", Router::new().route("/hello", get(hello)))
}

async fn hello(State(ctx): State<Ctx>, i18n: I18n) -> Result<Html<String>> {
    let posts = post::count(&ctx).await?;
    render(&HelloView { i18n, name: "Ada", posts })
}

Register it in src/lib.rs (mod hello; under // ocre:modules, .merge(hello::routes()) under // ocre:routes, above the layer). With ocre dev running and two posts:

curl -s http://localhost:8787/hello
<!doctype html>
<html lang="en">
<title>Hello</title>
<h1>Hello Ada!</h1>
<p>2 posts</p>
<nav>
  <form method="post" action="/locale/en"><button>en</button></form>
  <form method="post" action="/locale/fr"><button>fr</button></form></nav>
</html>

How the locale of a request is chosen

The I18n extractor takes the first of:

  1. The {locale} path segment, for routes nested with .nest("/{locale}", routes). An unknown code is a 404.
  2. The locale cookie, when it names a known locale.
  3. Accept-Language, by quality order: an exact code first, then the same language (fr-CH matches fr, pt matches pt-BR).
  4. The default locale, the first code in ocre::locales!.

Reading the locale only reads headers. Real runs against the handler above (showing lines 2 to 4 of each page):

curl -s -H 'Accept-Language: fr-CH,fr;q=0.9,en;q=0.8' http://localhost:8787/hello
<html lang="fr">
<title>Bonjour</title>
<h1>Bonjour Ada !</h1>

The path wins over Accept-Language:

curl -s -H 'Accept-Language: fr' http://localhost:8787/en/hello
<html lang="en">
<title>Hello</title>
<h1>Hello Ada!</h1>

The cookie wins over Accept-Language:

curl -s -H 'Cookie: locale=fr' -H 'Accept-Language: en' http://localhost:8787/hello
<html lang="fr">
<title>Bonjour</title>
<h1>Bonjour Ada !</h1>

An unknown code in the path:

curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8787/de/hello
404

Without any of them, the page is in the default locale (en above). In API-only apps, the extractor’s errors are JSON.

i18n.cookie() (or LOCALES.locale(code).cookie()) is the Set-Cookie value locale=<code>; Path=/; Max-Age=31536000; SameSite=Lax, remembered for a year. Send it when the visitor picks a language:

// src/locale.rs
use axum::{
    Router,
    extract::Path,
    http::header,
    response::{IntoResponse, Redirect},
    routing::post,
};
use ocre::{Ctx, Error, Result};

pub fn routes() -> Router<Ctx> {
    Router::new().route("/locale/{code}", post(switch))
}

/// Remembers the visitor's language for a year (`locale` cookie), then goes to /hello.
async fn switch(Path(code): Path<String>) -> Result<impl IntoResponse> {
    if !crate::LOCALES.codes().any(|known| known == code) {
        return Err(Error::NotFound);
    }
    let cookie = crate::LOCALES.locale(&code).cookie();
    Ok(([(header::SET_COOKIE, cookie)], Redirect::to("/hello")))
}

It is a POST, like any request that changes state (the forms of the /hello page above post to it; Ocre’s CSRF check covers them). Real runs:

curl -si -X POST http://localhost:8787/locale/fr
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8787/locale/de
HTTP/1.1 303 See Other
Location: /hello
Set-Cookie: locale=fr; Path=/; Max-Age=31536000; SameSite=Lax
...
404

LOCALES.locale(code) alone never fails: an unknown code gives the default locale, which is why the handler checks codes() first.

Plurals

A key is plural when every child is a CLDR category name: zero, one, two, few, many, other. .count(n) picks the form with the locale’s rule and fills %{count}; a zero form, when present, wins for 0; a missing form falls back to other. Negative numbers use their absolute value.

Languages (code)Forms ocre i18n missing requiresRule
English and every language not listed belowone, otherone for 1
French (fr), Portuguese (pt), Hindi (hi), Persian (fa), Bengali (bn)one, otherone for 0 and 1
Russian (ru), Ukrainian (uk), Belarusian (be)one, few, many, otherone for 1, 21, 31…; few for 2-4, 22-24…; many otherwise
Polish (pl)one, few, many, otherone for 1 only; few for 2-4, 22-24…; many otherwise
Czech (cs), Slovak (sk)one, few, otherone for 1, few for 2-4
Arabic (ar)zero, one, two, few, many, other0, 1, 2; few for 3-10 (mod 100); many for 11-99 (mod 100)
Hebrew (he, iw)one, two, other1, 2
Japanese (ja), Chinese (zh), Korean (ko), Vietnamese (vi), Thai (th), Indonesian (id), Malay (ms), Lao (lo), Burmese (my)otheralways other

The rule comes from the language part of the code (pt-BR uses Portuguese). So .count(0) gives “0 posts” in English and “0 article” in French; add a zero: form (“Aucun article”) to say it differently.

Missing keys

A key missing from the request’s locale behaves differently by build:

  • Debug builds (ocre dev, tests) show translation missing: fr.hello.posts in its place, so gaps are visible. Real output of /hello with Accept-Language: fr before hello.posts was added to fr.yml:

    <p>translation missing: fr.hello.posts</p>
    
  • Release builds (ocre deploy) fall back to the default locale’s text. A key missing from the default locale too shows translation missing: <code>.<key> in both builds.

ocre i18n missing

ocre i18n missing checks every declared locale against the default one: keys absent, plural forms the language needs, locale files not declared in src/lib.rs, declared files that do not exist, and syntax errors. It exits with 1 when there is any problem. With hello.posts missing from fr.yml:

ocre i18n missing
error: 2 locale problems:
  locales/fr.yml: missing hello.posts.one
  locales/fr.yml: missing hello.posts.other
hint: add each missing key at the same path as in locales/en.yml, translated (plural keys need the forms listed); then run `ocre i18n missing` again

After adding them:

  every locale (en, fr) has every key of locales/en.yml

With --json:

{"command":"i18n missing","ok":true,"ran":["every locale (en, fr) has every key of locales/en.yml"]}

Run it after adding keys to the default locale, and in CI. A plural key written as plain text in another locale counts as present. See CLI commands.

Defaults and alternative keys

Rails’ default: option is two methods of the translation:

  • .or_key("actions.save") tries another key when the first has no translation; chain several, tried in order.
  • .or("Save") is the text used when neither the key nor its alternatives have one. It replaces translation missing: ... in every build (in debug builds, a key missing from the request’s locale shows this text rather than the default locale’s). %{name} placeholders in it are filled like in a translation.

i18n.exists("posts.title") tells whether a key has a translation (in the locale, the default locale or the built-in translations), for example to show an optional help text.

// src/buttons.rs
use ocre::i18n::I18n;

/// The label of a form's submit button: the form's own, else the shared one, else English.
pub fn submit_label(i18n: I18n) -> String {
    i18n.t("posts.form.submit").or_key("actions.save").or("Save").to_string()
}

Scoped keys (lazy lookup)

Rails resolves t(".title") from the view’s path. In Ocre, the handler says which scope its template uses: i18n.scope("posts.index") returns the same I18n (still Copy) where keys starting with a dot are relative to that scope. Keys without a leading dot stay absolute.

// src/scoped.rs
use askama::Template;
use axum::response::Html;
use ocre::{Result, i18n::I18n, render};

#[derive(Template)]
#[template(
    source = r#"<h1>{{ i18n.t(".title") }}</h1>
<a href="{{ i18n.path("/posts/new") }}">{{ i18n.t(".new") }}</a>
<footer>{{ i18n.t("app.welcome") }}</footer>"#,
    ext = "html"
)]
struct IndexView {
    i18n: I18n,
}

/// `.title` is `posts.index.title`, `.new` is `posts.index.new`.
pub async fn index(i18n: I18n) -> Result<Html<String>> {
    render(&IndexView { i18n: i18n.scope("posts.index") })
}

A new scope(..) replaces the previous one. .or_key(".other"), exists(".key") and namespace(".group") resolve the dot the same way.

Namespaces

i18n.namespace("editor") returns every translation under a prefix as a BTreeMap from the relative key (bold, link.title) to the text, for example to hand a group of strings to JavaScript with Json(i18n.namespace("editor")). Plural keys give their other form. Only the app’s files are read; release builds add the default locale’s keys missing from the request’s locale, as t falls back to them.

HTML in translations

Rails marks keys ending in _html as safe. In Ocre, end the translation with .html(): the text of the translation is written as is, and every .arg(..) value is HTML-escaped, so user input stays text. askama writes the result without escaping it again.

en:
  signup:
    terms: "I accept the <a href=\"/terms\">terms of service</a>, %{name}."
<label>{{ i18n.t("signup.terms").arg("name", user.name).html() }}</label>

With user.name set to <b>Ada</b>, the page gets I accept the <a href="/terms">terms of service</a>, &lt;b&gt;Ada&lt;/b&gt;.. Use .html() only for keys whose text you wrote; plain {{ i18n.t(..) }} stays escaped, and never needs |safe.

Dates, times and numbers

I18n has the localized versions of the view helpers. They take what the helpers take: Unix seconds, or the text D1 stores (2026-09-29, 2026-09-29 14:05:00), read as UTC.

CallEnglishFrench
i18n.l("2026-09-29", "long")September 29, 202629 septembre 2026
i18n.l("2026-09-29 14:05:00", "short")29 Sep 14:0529 sept. 14h05
i18n.l(at, "%A %-d %B")Tuesday 29 Septembermardi 29 septembre
i18n.number(1234567.5)1,234,567.51 234 567,5
i18n.number_with_precision(3.14159, 2)3.143,14
i18n.currency(1234.5, "€")€1,234.501 234,50 €
i18n.time_ago_in_words(post.created_at)about 3 hoursenviron 3 heures
i18n.distance_of_time_in_words(from, to)2 days2 jours
  • l(value, format): a format name is looked up in date.formats.<name> for a date without time and in time.formats.<name> otherwise (built in: default, short, long); a format containing % is a pattern with the directives of strftime. Month and day names come from date.month_names, date.abbr_month_names, date.day_names (Sunday first) and date.abbr_day_names, written as comma-separated lists since locale files have no YAML lists; %p uses time.am/time.pm. An unknown format name shows translation missing: fr.date.formats.<name>; a value that is not a time is returned unchanged.
  • Numbers use number.format.delimiter and number.format.separator (French: a no-break space and ,); currency lays out the unit with number.currency.format.format (%u the unit, %n the number: %u%n in English, %n %u in French). For amounts in cents, divide first: i18n.currency(cents as f64 / 100.0, "€").
  • time_ago_in_words uses Rails’ datetime.distance_in_words.* keys (less_than_x_minutes, x_minutes, about_x_hours, x_days, about_x_months, x_months, about_x_years, over_x_years, almost_x_years, each with plural forms). Add “ago” with your own key: i18n.t("posts.ago").arg("time", i18n.time_ago_in_words(post.created_at)).

Any of these keys in a locale file overrides the built-in value:

fr:
  date:
    formats:
      short: "%-d %b"
    abbr_month_names: "janv,févr,mars,avr,mai,juin,juil,août,sept,oct,nov,déc"
  number:
    currency:
      format:
        format: "%n %u"

Model and attribute names

Rails’ Post.model_name.human and human_attribute_name are i18n.model_name("post", count) and i18n.attribute("post", "title"), for page titles, form labels and table headers:

fr:
  models:
    post:
      one: "Article"
      other: "Articles"
  attributes:
    created_at: "Créé le"   # every model
    post:
      title: "Titre"        # this model only
  • model_name("post", n) reads models.post, a text or plural forms. Without it, the humanized name (blog_post gives Blog post) for every count: Ocre has no runtime inflector, so plurals of other languages are translation keys.
  • attribute("post", "title") reads attributes.post.title, then attributes.title, then humanizes the field like FieldError::full_message (author_id gives Author).

Rails nests these under activerecord.; Ocre’s models are not Active Record classes, so the keys start at models. and attributes..

Validation messages

Every Validator check records Rails’ key of its message (FieldError::key): blank, too_long, too_short, wrong_length, greater_than, greater_than_or_equal_to, less_than, less_than_or_equal_to, other_than, inclusion, exclusion, confirmation, accepted, present (absence), invalid (format, email), not_a_number, plus Ocre’s not_json, not_a_date, not_a_datetime, not_a_time, not_a_uuid and not_a_decimal. i18n.error_message("post", &error) translates the message; i18n.full_message("post", &error) adds the translated field name with errors.format (default %{attribute} %{message}). The first key found wins:

  1. errors.models.post.attributes.title.blank
  2. errors.models.post.blank
  3. errors.attributes.title.blank
  4. errors.messages.blank

looked up in the request’s locale file, then in the built-in translations of its language, then in the default locale. %{count} is the check’s bound (plural forms work: too_long: {one: ..., other: ...}), %{attribute} the translated field (for confirmation, the confirmed one) and %{model} the model name. An error added with check(..) or reworded with .message(..) keeps its text, except the English messages above (has already been taken, the uniqueness message of generated models, is taken).

fr:
  errors:
    models:
      post:
        attributes:
          title:
            blank: "donnez un titre à l'article"

Generated forms show error.full_message(), in English. To translate them, give the form template i18n: I18n and write the errors with the model name:

// src/form_errors.rs
use askama::Template;
use ocre::{FieldError, i18n::I18n};

/// The errors of the post form, translated.
#[derive(Template)]
#[template(
    source = r#"<ul>{% for error in errors %}
  <li>{{ i18n.full_message("post", error) }}</li>
{%- endfor %}</ul>
<label for="title">{{ i18n.attribute("post", "title") }}</label>"#,
    ext = "html"
)]
pub struct PostFormErrors {
    pub i18n: I18n,
    pub errors: Vec<FieldError>,
}

For a JSON API, map the errors the same way before answering: errors.iter().map(|e| i18n.error_message("post", e)).

Built-in translations

Ocre compiles rails-i18n’s framework texts for English (en), French (fr), German (de), Spanish (es), Italian (it), Portuguese (pt, used by pt-BR) and Dutch (nl): the validation messages and errors.format, date.* and time.* names and formats, number.format.* and number.currency.format.format, and datetime.distance_in_words.*. They are Rust tables, not YAML: nothing is parsed, and they add no binding call. Every lookup (t included) goes through, in order: the locale’s file, the built-in translations of its language, the default locale’s file (release builds only for t), its built-in translations, then built-in English. So t("errors.messages.blank") works in French without a line of YAML, and any key in a locale file wins. Other languages get English until their files define the keys; ocre i18n missing does not require them.

Mailer subjects, form labels and buttons are generated code in the app, not framework strings: translate them with t as in Translating generated scaffolds.

Rails’ default_url_options and scope "(:locale)" keep the locale in every link. In Ocre, nest the localized routes under the {locale} segment and build links with i18n.path(..):

Router::new().nest("/{locale}", posts::routes()) // /en/posts, /fr/posts
<a href="{{ i18n.path("/posts") }}">{{ i18n.t("posts.index.title") }}</a>   <!-- /fr/posts -->

i18n.path("/") gives /fr. The I18n extractor of those routes reads the locale from the path (an unknown code is a 404), so the link and the page agree.

Search engines: canonical and hreflang

Each translated page tells search engines its canonical URL and where its other languages are. i18n.alternate_links(base_url, path) writes those <link> elements for the visitor’s locale: canonical (the page in this locale), one alternate per locale with its hreflang (the locale code: fr, zh-Hans), and x-default (the default locale). base_url is the app’s public origin (APP_URL, see ocre::mail::url); path is the page without its locale prefix.

<!-- templates/layout.html, in <head> -->
{{ i18n.alternate_links(base_url, page_path)|safe }}
<link rel="canonical" href="https://example.com/fr/pricing">
<link rel="alternate" hreflang="en" href="https://example.com/en/pricing">
<link rel="alternate" hreflang="fr" href="https://example.com/fr/pricing">
<link rel="alternate" hreflang="x-default" href="https://example.com/en/pricing">

i18n.alternates(base_url, path) gives the same URLs as (code, url) pairs, for a sitemap: ocre g seo writes src/seo.rs, whose /sitemap.xml lists every page of its PAGES list in every locale with these alternates, and whose /llms.txt lists them for language models (see Views: structured data, sitemap and llms.txt). Old URLs that carried the locale in the query (/pricing?locale=fr) move with a permanent redirect: a route on the old path answering Redirect::permanent(&LOCALES.locale(&query.locale).path("/pricing")) (a 308, which search engines treat like a 301).

Other locales, views and sources

  • Another locale than the request’s: i18n.in_locale("de") (Rails’ locale: option) returns the same I18n in German, keeping the scope; LOCALES.locale(code) does it without a request. The default locale is the first code of ocre::locales!; the available locales are the codes it lists (i18n.codes()), and every locale falls back to the default one, then to the built-in translations.
  • Localized views: Rails picks index.fr.html.erb by file name. In Ocre, a page whose layout differs by language (not just its strings) chooses the template struct in the handler: match i18n.locale() { "fr" => render(&IndexFr { .. }), _ => render(&Index { .. }) }.
  • Other sources: Catalog::load takes any (code, text) pairs of &'static str, so translations can come from generated Rust constants or other include_str! files as well as locales/*.yml. There is no swappable backend: translations stored in KV or D1 would cost a billed read per request (KV allows 100,000 reads a day on the free plan) and could not be checked by ocre i18n missing before deploying.

Translating generated scaffolds

Generated scaffolds, auth pages and mailers contain English strings. To translate one, for example the posts index:

  1. Move each string to locales/en.yml (and translate it in the other files):

    en:
      posts:
        index:
          title: "Posts"
          new: "New post"
        created: "Post was successfully created."
    
  2. Add i18n: I18n to the handler’s arguments and to its template struct (use ocre::i18n::I18n;):

    struct IndexView {
        flash: Flash,
        posts: Vec<Post>,
        i18n: I18n,
    }
    
    async fn index(State(ctx): State<Ctx>, flash: Flash, page: Page, i18n: I18n) -> Result<Html<String>> {
        render(&IndexView { flash, posts: post::all(&ctx, page).await?, i18n })
    }
  3. Replace the text in the template: <h1>{{ i18n.t("posts.index.title") }}</h1>, <a href="/posts/new">{{ i18n.t("posts.index.new") }}</a>.

  4. Flash messages are set in Rust: session.flash("notice", i18n.t("posts.created").to_string())?; (take i18n: I18n in create too).

  5. The generated templates/layout.html has <html lang="en">. To make it follow the request, every view that extends the layout needs an i18n field; then write <html lang="{{ i18n.locale() }}">.

  6. Run ocre i18n missing.

Validation messages come from Validator in English; i18n.full_message("post", &error) translates them (see Validation messages). Pages whose ETag must change with the language put i18n.locale() in it (see Caching).

Outside requests: mailers and jobs

Mailers, jobs and scheduled tasks have no request, so no I18n extractor. LOCALES.locale(code) gives the same I18n for a stored code, for example a locale column of the user. The match is exact and case-insensitive (fr-CH does not match fr here, unlike Accept-Language); an unknown code gives the default locale.

// src/mailers/welcome_localized.rs
use ocre::mail::Email;

/// The welcome email in the user's language; `locale` is their saved code ("fr").
pub fn welcome(to: &str, locale: &str) -> Email {
    let i18n = crate::LOCALES.locale(locale);
    let subject = i18n.t("mailers.welcome.subject").to_string();
    let text = i18n.t("mailers.welcome.body").arg("email", to).to_string();
    Email::new(to, subject, text)
}

LOCALES is the private static in src/lib.rs; child modules reach it as crate::LOCALES. In a job, store the code in the job’s arguments (or load it with the user) and call crate::LOCALES.locale(&code) in perform. See Email and Background jobs and schedules.

Cost

Translations cost no billed resource. The files are compiled into the binary; the first translation in a Worker instance parses them once with a small parser written for this subset (the README reports that the toml crate alone, parsing into a table, compiled to 205 KB of release WebAssembly, against about 420 KB for the whole blog app). Lookups are a BTreeMap search, and t(..) is written straight into the askama output without an intermediate String.

See also