Skip to main content

I18n

Struct I18n 

Source
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:

§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

Source

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");
Source

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");
Source

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");
Source

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}€");
Source

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");
Source

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

Source

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");
Source

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");
Source

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)"]);
Source

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

Source

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");
Source

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"]);
Source

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.");
Source

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");
Source

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");
Source

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");
Source

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")]);
Source

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");
Source

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())]);

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\">"
);
Source

pub fn cookie(&self) -> String

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");

Trait Implementations§

Source§

impl Clone for I18n

Source§

fn clone(&self) -> I18n

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Copy for I18n

Source§

impl Debug for I18n

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<S: Send + Sync> FromRequestParts<S> for I18n

Failures render as HTML pages in full-stack apps and as JSON in API-only apps.

Source§

type Rejection = Error

If the extractor fails it’ll use this “rejection” type. A rejection is a kind of error that can be converted into a response.
Source§

async fn from_request_parts( parts: &mut Parts, state: &S, ) -> Result<Self, Self::Rejection>

Perform the extraction.

Auto Trait Implementations§

§

impl Freeze for I18n

§

impl RefUnwindSafe for I18n

§

impl Send for I18n

§

impl Sync for I18n

§

impl Unpin for I18n

§

impl UnsafeUnpin for I18n

§

impl UnwindSafe for I18n

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
where ST: ?Sized, DT: ?Sized,

§

impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
where ST: ?Sized, DT: ?Sized,

Source§

impl<T> CloneToUninit for T
where T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

§

impl<T> FromRef<T> for T
where T: Clone,

§

fn from_ref(input: &T) -> T

Converts to this type from a reference to the input type.
§

impl<S, T> FromRequest<S, ViaParts> for T
where S: Send + Sync, T: FromRequestParts<S>,

§

type Rejection = <T as FromRequestParts<S>>::Rejection

If the extractor fails it’ll use this “rejection” type. A rejection is a kind of error that can be converted into a response.
§

fn from_request( req: Request<Body>, state: &S, ) -> impl Future<Output = Result<T, <T as FromRequest<S, ViaParts>>::Rejection>>

Perform the extraction.
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

§

impl<T> Read<Exclusive, BecauseExclusive> for T
where T: ?Sized,

Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<S, T> Upcast<T> for S
where T: UpcastFrom<S> + ?Sized, S: ?Sized,

Source§

fn upcast(&self) -> &T
where Self: ErasableGeneric, T: Sized + ErasableGeneric<Repr = Self::Repr>,

Perform a zero-cost type-safe upcast to a wider ref type within the Wasm bindgen generics type system. Read more
Source§

fn upcast_into(self) -> T
where Self: Sized + ErasableGeneric, T: Sized + ErasableGeneric<Repr = Self::Repr>,

Perform a zero-cost type-safe upcast to a wider type within the Wasm bindgen generics type system. Read more
§

impl<V, T> VZip<V> for T
where V: MultiLane<T>,

§

fn vzip(self) -> V