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
Postmodel 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")includeslocales/en.ymlandlocales/fr.yml(paths from the app root) withinclude_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 theI18nextractor. Keep it last inroutes(), after the// ocre:routesmarker, so it covers every route; generators insert new routes above it. Without it,I18nanswers 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:
| Rule | Detail |
|---|---|
| One root key | The file starts with its code on its own line (fr:), at column 0; everything else is indented under it |
| Indentation | Spaces, not tabs; siblings use the same indentation (2 spaces by convention) |
| Keys | ASCII letters, digits, _ and -, followed by : or by : at the end of the line; no duplicate keys |
| Values | Strings on the same line: double-quoted (escapes \n, \t, \", \\, \/, \uXXXX), single-quoted ('' for a quote), or plain |
| Plain values | Must 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) |
| Comments | Lines starting with #, and # ... after a value |
| Not supported | Lists (- 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}(valueis anythingDisplay). A placeholder without a value stays as written.i18n.t("hello.posts").count(n)picks the plural form fornand 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 aString(flash messages, JSON, emails). i18n.path("/posts")is/fr/postsfor 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:
- The
{locale}path segment, for routes nested with.nest("/{locale}", routes). An unknown code is a 404. - The
localecookie, when it names a known locale. Accept-Language, by quality order: an exact code first, then the same language (fr-CHmatchesfr,ptmatchespt-BR).- 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.
Letting visitors choose: the locale cookie
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 requires | Rule |
|---|---|---|
| English and every language not listed below | one, other | one for 1 |
French (fr), Portuguese (pt), Hindi (hi), Persian (fa), Bengali (bn) | one, other | one for 0 and 1 |
Russian (ru), Ukrainian (uk), Belarusian (be) | one, few, many, other | one for 1, 21, 31…; few for 2-4, 22-24…; many otherwise |
Polish (pl) | one, few, many, other | one for 1 only; few for 2-4, 22-24…; many otherwise |
Czech (cs), Slovak (sk) | one, few, other | one for 1, few for 2-4 |
Arabic (ar) | zero, one, two, few, many, other | 0, 1, 2; few for 3-10 (mod 100); many for 11-99 (mod 100) |
Hebrew (he, iw) | one, two, other | 1, 2 |
Japanese (ja), Chinese (zh), Korean (ko), Vietnamese (vi), Thai (th), Indonesian (id), Malay (ms), Lao (lo), Burmese (my) | other | always 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) showtranslation missing: fr.hello.postsin its place, so gaps are visible. Real output of/hellowithAccept-Language: frbeforehello.postswas added tofr.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 showstranslation 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 replacestranslation 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>, <b>Ada</b>.. 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.
| Call | English | French |
|---|---|---|
i18n.l("2026-09-29", "long") | September 29, 2026 | 29 septembre 2026 |
i18n.l("2026-09-29 14:05:00", "short") | 29 Sep 14:05 | 29 sept. 14h05 |
i18n.l(at, "%A %-d %B") | Tuesday 29 September | mardi 29 septembre |
i18n.number(1234567.5) | 1,234,567.5 | 1 234 567,5 |
i18n.number_with_precision(3.14159, 2) | 3.14 | 3,14 |
i18n.currency(1234.5, "€") | €1,234.50 | 1 234,50 € |
i18n.time_ago_in_words(post.created_at) | about 3 hours | environ 3 heures |
i18n.distance_of_time_in_words(from, to) | 2 days | 2 jours |
l(value, format): a format name is looked up indate.formats.<name>for a date without time and intime.formats.<name>otherwise (built in:default,short,long); a format containing%is a pattern with the directives ofstrftime. Month and day names come fromdate.month_names,date.abbr_month_names,date.day_names(Sunday first) anddate.abbr_day_names, written as comma-separated lists since locale files have no YAML lists;%pusestime.am/time.pm. An unknown format name showstranslation missing: fr.date.formats.<name>; a value that is not a time is returned unchanged.- Numbers use
number.format.delimiterandnumber.format.separator(French: a no-break space and,);currencylays out the unit withnumber.currency.format.format(%uthe unit,%nthe number:%u%nin English,%n %uin French). For amounts in cents, divide first:i18n.currency(cents as f64 / 100.0, "€"). time_ago_in_wordsuses 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)readsmodels.post, a text or plural forms. Without it, the humanized name (blog_postgivesBlog post) for every count: Ocre has no runtime inflector, so plurals of other languages are translation keys.attribute("post", "title")readsattributes.post.title, thenattributes.title, then humanizes the field likeFieldError::full_message(author_idgivesAuthor).
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:
errors.models.post.attributes.title.blankerrors.models.post.blankerrors.attributes.title.blankerrors.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.
Links that keep the locale
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 sameI18nin German, keeping the scope;LOCALES.locale(code)does it without a request. The default locale is the first code ofocre::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.erbby 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::loadtakes any(code, text)pairs of&'static str, so translations can come from generated Rust constants or otherinclude_str!files as well aslocales/*.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 byocre i18n missingbefore deploying.
Translating generated scaffolds
Generated scaffolds, auth pages and mailers contain English strings. To translate one, for example the posts index:
-
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." -
Add
i18n: I18nto 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 }) } -
Replace the text in the template:
<h1>{{ i18n.t("posts.index.title") }}</h1>,<a href="/posts/new">{{ i18n.t("posts.index.new") }}</a>. -
Flash messages are set in Rust:
session.flash("notice", i18n.t("posts.created").to_string())?;(takei18n: I18nincreatetoo). -
The generated
templates/layout.htmlhas<html lang="en">. To make it follow the request, every view that extends the layout needs ani18nfield; then write<html lang="{{ i18n.locale() }}">. -
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
- Generators:
ocre g locale. - CLI commands:
ocre i18n missing, andocre dev, which checks the files first. - Controllers and routing and Views, helpers and forms: handlers, nested routes and templates.
- Caching: the locale in ETags and
CacheControl::public. - Email and Background jobs and schedules.
ocre::i18nrustdoc.