pub struct I18n { /* private fields */ }Expand description
Axum extractor giving translations in the request’s locale, like Rails’ I18n.t with a per-request locale.
The locale comes from the {locale} path segment (routes nested with
.nest("/{locale}", ..), see LOCALE_PARAM), else the locale cookie
(LOCALE_COOKIE), else Accept-Language (by quality; fr-CH matches
fr, pt matches pt-BR), else the default locale. It is Copy: pass
it by value to template structs, where {{ i18n.t("posts.title") }}
writes the text. Selecting the locale reads headers only; no billed
resource is used.
Rejections render as HTML pages in full-stack apps and as JSON in API-only apps:
Error::NotFound(404) when the{locale}path segment is not one of the app’s locales;Error::Internal(500) whenlayeris missing; the log line names the fix.
§Examples
use axum::{Router, routing::get};
use ocre::{Ctx, i18n::I18n};
async fn title(i18n: I18n) -> String {
i18n.t("posts.title").to_string()
}
// `/en/posts`, `/fr/posts`; `/de/posts` is a 404 unless the app has German.
fn routes() -> Router<Ctx> {
Router::new().nest("/{locale}", Router::new().route("/posts", get(title)))
}Implementations§
Source§impl I18n
impl I18n
Sourcepub fn l(&self, value: impl Display, format: &str) -> String
pub fn l(&self, value: impl Display, format: &str) -> String
Formats a date or time with a format of the locale, like Rails’ l(value, format: :short).
value is what helpers take: Unix seconds or the
text D1 stores (2026-09-29, 2026-09-29 14:05:00…), in UTC.
format is a name looked up in date.formats.<name> for a date
without time (2026-09-29) and in time.formats.<name> otherwise
(built-in names: default, short, long), or a pattern when it
contains % (the directives of strftime).
Month and day names (%B %b %A %a) and %p come from
date.month_names, date.abbr_month_names, date.day_names
(Sunday first), date.abbr_day_names (comma-separated lists) and
time.am/time.pm. Built in for English, French, German, Spanish,
Italian, Portuguese and Dutch; locale files override any of them.
A value that is not a time is returned unchanged; an unknown format
name gives translation missing: fr.date.formats.<name>.
§Examples
static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
ocre::i18n::Catalog::load(&[("en", "en:\n"), ("fr", "fr:\n date:\n formats:\n short: \"%-d %b\"\n")])
});
let fr = LOCALES.locale("fr");
assert_eq!(fr.l("2026-09-29", "long"), "29 septembre 2026");
assert_eq!(fr.l("2026-09-29", "short"), "29 sept.");
assert_eq!(fr.l("2026-09-29 14:05:00", "%A %-d %B à %Hh%M"), "mardi 29 septembre à 14h05");
assert_eq!(LOCALES.locale("en").l("2026-09-29", "long"), "September 29, 2026");Sourcepub fn number(&self, value: impl Display) -> String
pub fn number(&self, value: impl Display) -> String
Groups thousands and writes the decimal separator of the locale: 1 234 567,5 in French.
Rails’ number_with_delimiter with number.format.delimiter and
number.format.separator (built in: , and . in English, a
no-break space and , in French, . and , in German, Spanish,
Italian, Portuguese and Dutch). Text that is not a number is returned
unchanged.
§Examples
static LOCALES: ocre::i18n::Locales =
LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n"), ("de", "de:\n")]));
assert_eq!(LOCALES.locale("en").number(1234567.5), "1,234,567.5");
assert_eq!(LOCALES.locale("de").number(1234567.5), "1.234.567,5");
assert_eq!(LOCALES.locale("de").number("n/a"), "n/a");Sourcepub fn number_with_precision(
&self,
value: impl Display,
precision: usize,
) -> String
pub fn number_with_precision( &self, value: impl Display, precision: usize, ) -> String
Rounds to precision decimals with the locale’s decimal separator: 3,14 in French.
Like Rails’ number_with_precision: no thousands delimiter. Text
that is not a number is returned unchanged.
§Examples
static LOCALES: ocre::i18n::Locales = LazyLock::new(|| ocre::i18n::Catalog::load(&[("fr", "fr:\n")]));
assert_eq!(LOCALES.locale("fr").number_with_precision(3.14159, 2), "3,14");Sourcepub fn currency(&self, value: impl Display, unit: &str) -> String
pub fn currency(&self, value: impl Display, unit: &str) -> String
Formats an amount with two decimals and unit placed as the locale does: 1 234,50 € in French.
The layout is number.currency.format.format (%u the unit, %n
the number; built in: %u%n in English, %n %u in French, German,
Spanish and Italian, %u %n in Portuguese and Dutch, with a no-break
space), and the number uses number’s separators. A
negative amount starts with -. Text that is not a number is returned
unchanged. For cents, divide first: i18n.currency(cents as f64 / 100.0, "€").
§Examples
static LOCALES: ocre::i18n::Locales =
LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n"), ("fr", "fr:\n")]));
assert_eq!(LOCALES.locale("en").currency(-1234.5, "$"), "-$1,234.50");
assert_eq!(LOCALES.locale("fr").currency(1234.5, "€"), "1\u{a0}234,50\u{a0}€");Sourcepub fn time_ago_in_words(&self, value: impl Display) -> String
pub fn time_ago_in_words(&self, value: impl Display) -> String
The time from value to now in words, in the locale: environ 3 heures.
Rails’ time_ago_in_words with the 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 and %{count}); the ranges
are those of helpers::time_ago_in_words.
value is Unix seconds or D1 text; anything else is returned
unchanged. Add “ago” with a translation: t("posts.ago").arg("time", ..).
§Examples
static LOCALES: ocre::i18n::Locales = LazyLock::new(|| ocre::i18n::Catalog::load(&[("fr", "fr:\n")]));
assert_eq!(LOCALES.locale("fr").time_ago_in_words(ocre::now() - 3 * 3600), "environ 3 heures");Sourcepub fn distance_of_time_in_words(
&self,
from: impl Display,
to: impl Display,
) -> String
pub fn distance_of_time_in_words( &self, from: impl Display, to: impl Display, ) -> String
The time between two times in words, in the locale (Rails’ distance_of_time_in_words).
The order does not matter; see time_ago_in_words.
§Examples
static LOCALES: ocre::i18n::Locales = LazyLock::new(|| ocre::i18n::Catalog::load(&[("de", "de:\n")]));
let de = LOCALES.locale("de");
assert_eq!(de.distance_of_time_in_words("2026-01-01 10:00:00", "2026-01-03 09:00:00"), "2 Tage");Source§impl I18n
impl I18n
Sourcepub fn model_name(&self, model: &str, count: impl Count) -> String
pub fn model_name(&self, model: &str, count: impl Count) -> String
The name of a model for count items, from models.<model> (Rails’ Post.model_name.human(count:)).
model is the snake_case name (blog_post). models.<model> is a
text or plural forms (one/other…). Without a translation, the
humanized name (Blog post) for every count: add an other form for
plurals, since Ocre has no runtime inflector.
§Examples
static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
ocre::i18n::Catalog::load(&[("fr", "fr:\n models:\n post:\n one: Article\n other: Articles\n")])
});
let fr = LOCALES.locale("fr");
assert_eq!(fr.model_name("post", 1), "Article");
assert_eq!(fr.model_name("post", 3), "Articles");
assert_eq!(fr.model_name("blog_post", 1), "Blog post");Sourcepub fn attribute(&self, model: &str, field: &str) -> String
pub fn attribute(&self, model: &str, field: &str) -> String
The name of a model’s field, from attributes.<model>.<field>, then attributes.<field> (Rails’ human_attribute_name).
Without a translation, the humanized field (published_at gives
Published at, author_id gives Author), as
FieldError::full_message writes it. Use it for form labels and
table headers.
§Examples
static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
ocre::i18n::Catalog::load(&[(
"fr",
"fr:\n attributes:\n created_at: Créé le\n post:\n title: Titre\n",
)])
});
let fr = LOCALES.locale("fr");
assert_eq!(fr.attribute("post", "title"), "Titre");
assert_eq!(fr.attribute("post", "created_at"), "Créé le");
assert_eq!(fr.attribute("post", "author_id"), "Author");Sourcepub fn error_message(&self, model: &str, error: &FieldError) -> String
pub fn error_message(&self, model: &str, error: &FieldError) -> String
The message of a validation error in the locale, without the field name (Rails’ errors.messages).
Each Validator check records Rails’ key
(FieldError::key: blank, too_long, inclusion…), looked up
in this order, the first found winning:
errors.models.<model>.attributes.<field>.<key>,
errors.models.<model>.<key>, errors.attributes.<field>.<key>,
errors.messages.<key>; first in the locale’s file, then in the
built-in translations of its language (English, French, German,
Spanish, Italian, Portuguese, Dutch), then in the default locale.
%{count} is the check’s bound, %{attribute} the translated field
(the confirmed one for confirmation) and %{model} the model name.
An error without a key (a custom check
or message) keeps its message, unless
that message is one of the English built-in ones (has already been taken is taken).
§Examples
use ocre::{Error, Validator};
static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
ocre::i18n::Catalog::load(&[
("en", "en:\n"),
("fr", "fr:\n errors:\n models:\n post:\n attributes:\n title:\n blank: \"donnez un titre\"\n"),
])
});
let mut v = Validator::new();
v.required("title", "").required("body", "").max_length("slug", "far-too-long", 5);
let Err(Error::Invalid(errors)) = v.finish() else { unreachable!() };
let fr = LOCALES.locale("fr");
let messages: Vec<String> = errors.iter().map(|error| fr.error_message("post", error)).collect();
assert_eq!(messages, ["donnez un titre", "doit être rempli(e)", "est trop long (pas plus de 5 caractères)"]);Sourcepub fn full_message(&self, model: &str, error: &FieldError) -> String
pub fn full_message(&self, model: &str, error: &FieldError) -> String
The translated field name and message of a validation error, as errors.format lays them out (Rails’ full_message).
errors.format defaults to %{attribute} %{message}; the attribute
is attribute and the message
error_message.
§Examples
static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
ocre::i18n::Catalog::load(&[("de", "de:\n attributes:\n post:\n title: Titel\n")])
});
let mut v = ocre::Validator::new();
v.required("title", "");
let de = LOCALES.locale("de");
assert_eq!(de.full_message("post", &v.errors()[0]), "Titel muss ausgefüllt werden");Source§impl I18n
impl I18n
Sourcepub fn locale(&self) -> &'static str
pub fn locale(&self) -> &'static str
Returns the locale code, e.g. for <html lang="{{ i18n.locale() }}">.
§Examples
static LOCALES: ocre::i18n::Locales = LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n")]));
assert_eq!(LOCALES.locale("en").locale(), "en");Sourcepub fn codes(&self) -> impl Iterator<Item = &'static str> + 'static
pub fn codes(&self) -> impl Iterator<Item = &'static str> + 'static
Returns every locale code of the app, default first, e.g. for a language switcher.
§Examples
static LOCALES: ocre::i18n::Locales =
LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n"), ("fr", "fr:\n")]));
assert_eq!(LOCALES.locale("fr").codes().collect::<Vec<_>>(), ["en", "fr"]);Sourcepub fn t<'a>(&self, key: &'a str) -> Translation<'a>
pub fn t<'a>(&self, key: &'a str) -> Translation<'a>
Starts the translation of a dotted key, like Rails’ t("posts.created").
The returned Translation is a Display value:
askama writes it (escaped) into the page without an extra String,
.to_string() gives one (for flashes, emails). Add %{name} values
with Translation::arg and the plural count with
Translation::count. The lookup happens when it is displayed.
§Examples
static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
ocre::i18n::Catalog::load(&[("en", "en:\n posts:\n created: Post was successfully created.\n")])
});
let i18n = LOCALES.locale("en");
assert_eq!(i18n.t("posts.created").to_string(), "Post was successfully created.");Sourcepub fn scope(&self, scope: &'static str) -> Self
pub fn scope(&self, scope: &'static str) -> Self
Returns the same translations with scope as the prefix of keys starting with . (Rails’ lazy lookup).
i18n.scope("posts.index").t(".title") looks up posts.index.title;
keys without a leading dot stay absolute. Rails derives the scope from
the view’s path; in Ocre, the handler sets it before passing i18n to
its template. A scope replaces the previous one.
§Examples
static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
ocre::i18n::Catalog::load(&[("en", "en:\n app:\n name: Blog\n posts:\n index:\n title: Posts\n")])
});
let i18n = LOCALES.locale("en").scope("posts.index");
assert_eq!(i18n.t(".title").to_string(), "Posts");
assert_eq!(i18n.t("app.name").to_string(), "Blog");Sourcepub fn in_locale(&self, code: &str) -> Self
pub fn in_locale(&self, code: &str) -> Self
Returns the same translations in locale code (Rails’ locale: option), keeping the scope.
code matches like Catalog::locale: exactly, case-insensitively,
and an unknown code gives the default locale.
§Examples
static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
ocre::i18n::Catalog::load(&[("en", "en:\n hello: Hello\n"), ("fr", "fr:\n hello: Bonjour\n")])
});
let i18n = LOCALES.locale("en");
assert_eq!(i18n.in_locale("fr").t("hello").to_string(), "Bonjour");
assert_eq!(i18n.in_locale("de").locale(), "en");Sourcepub fn exists(&self, key: &str) -> bool
pub fn exists(&self, key: &str) -> bool
Whether key has a translation: in the locale, the default locale or the built-in translations.
Keys starting with . are relative to the scope.
§Examples
static LOCALES: ocre::i18n::Locales =
LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n posts:\n title: Posts\n")]));
let i18n = LOCALES.locale("en");
assert!(i18n.exists("posts.title"));
assert!(i18n.exists("errors.messages.blank"), "built in");
assert!(!i18n.exists("posts"), "a namespace is not a translation");Sourcepub fn namespace(&self, prefix: &str) -> BTreeMap<&'static str, &'static str>
pub fn namespace(&self, prefix: &str) -> BTreeMap<&'static str, &'static str>
Returns every translation under prefix, by key relative to it (Rails’ namespace lookup).
Nested keys keep their dots (form.submit); plural keys give their
other form. Only the app’s files are read. Release builds add the
default locale’s keys missing from the locale, as t falls
back to them. Keys starting with . are relative to the
scope. Handy to hand a group of texts to JavaScript:
Json(i18n.namespace("editor")).
§Examples
static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
ocre::i18n::Catalog::load(&[("en", "en:\n editor:\n bold: Bold\n link:\n title: Link\n other: x\n")])
});
let texts = LOCALES.locale("en").namespace("editor");
assert_eq!(texts.into_iter().collect::<Vec<_>>(), [("bold", "Bold"), ("link.title", "Link")]);Sourcepub fn path(&self, path: &str) -> String
pub fn path(&self, path: &str) -> String
Prefixes path with the locale, for routes nested under /{locale} (Rails’ default_url_options).
i18n.path("/posts") is /fr/posts for French visitors, so links
keep the locale; "/" gives /fr. Use it in templates:
<a href="{{ i18n.path("/posts") }}">.
§Examples
static LOCALES: ocre::i18n::Locales =
LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n"), ("fr", "fr:\n")]));
let i18n = LOCALES.locale("fr");
assert_eq!(i18n.path("/posts/3"), "/fr/posts/3");
assert_eq!(i18n.path("/"), "/fr");Sourcepub fn alternates(
&self,
base_url: &str,
path: &str,
) -> Vec<(&'static str, String)>
pub fn alternates( &self, base_url: &str, path: &str, ) -> Vec<(&'static str, String)>
The absolute URL of path in every locale, the default first:
(code, base_url + "/<code>" + path), for a sitemap’s alternates
(Sitemap::add_localized).
§Examples
static LOCALES: ocre::i18n::Locales =
LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n"), ("zh-Hans", "zh-Hans:\n")]));
let urls = LOCALES.locale("zh-Hans").alternates("https://ex.com/", "/pricing");
assert_eq!(urls, [("en", "https://ex.com/en/pricing".to_owned()), ("zh-Hans", "https://ex.com/zh-Hans/pricing".to_owned())]);Sourcepub fn alternate_links(&self, base_url: &str, path: &str) -> String
pub fn alternate_links(&self, base_url: &str, path: &str) -> String
The <link> elements a localized page’s <head> needs: canonical
(this locale’s URL), one alternate per locale with its hreflang,
and x-default (the default locale’s URL). base_url is the app’s
public origin (APP_URL, ocre::mail::url),
path the page without its locale prefix. Values are escaped; in
askama: {{ i18n.alternate_links(base_url, "/pricing")|safe }}.
§Examples
static LOCALES: ocre::i18n::Locales =
LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n"), ("fr", "fr:\n")]));
assert_eq!(
LOCALES.locale("fr").alternate_links("https://ex.com", "/"),
"<link rel=\"canonical\" href=\"https://ex.com/fr\">\n\
<link rel=\"alternate\" hreflang=\"en\" href=\"https://ex.com/en\">\n\
<link rel=\"alternate\" hreflang=\"fr\" href=\"https://ex.com/fr\">\n\
<link rel=\"alternate\" hreflang=\"x-default\" href=\"https://ex.com/en\">"
);Returns a Set-Cookie value remembering this locale for a year.
The cookie is locale=<code>; Path=/; Max-Age=31536000; SameSite=Lax;
later requests without a {locale} path segment use it. Send it
when the visitor picks a language.
§Examples
use std::sync::LazyLock;
use axum::{extract::Path, http::header, response::{IntoResponse, Redirect}};
static LOCALES: ocre::i18n::Locales =
LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n"), ("fr", "fr:\n")]));
// POST /locale/{code}
async fn switch(Path(code): Path<String>) -> impl IntoResponse {
let cookie = LOCALES.locale(&code).cookie(); // unknown codes give the default
([(header::SET_COOKIE, cookie)], Redirect::to("/"))
}
assert_eq!(LOCALES.locale("fr").cookie(), "locale=fr; Path=/; Max-Age=31536000; SameSite=Lax");