Autumn ships an opt-in i18n module that gives Spring Boot / Rails / Phoenix migrants the localization story they expect, with convention over configuration and a small, well-defined surface.

The module is gated behind the i18n feature flag, so apps that don't enable it pay zero compile cost and incur zero runtime cost. It is built around three ideas:

  1. Translations live at i18n/<locale>.ftl — a Project Fluent file per locale, discovered from the project root at startup.
  2. A request-scoped Locale extractor resolves the active locale from the request, in a stable documented order.
  3. A t!() macro performs the actual key lookup with automatic fallback to the default locale and a rate-limited tracing::warn! on misses.

Status: ships in v0.4.x as opt-in via the i18n Cargo feature.


Quick start

1. Enable the feature flag

TOML
# Cargo.toml
[dependencies]
autumn-web = { version = "0.4", features = ["i18n"] }

2. Configure supported locales

TOML
# autumn.toml
[i18n]
default_locale = "en"
supported_locales = ["en", "es"]
# Optional — when omitted, falls back to [default_locale].
fallback_chain = ["en"]
# Optional — defaults to "i18n".
dir = "i18n"

3. Author a translation file per locale

Text
# i18n/en.ftl
welcome.title = Welcome to my blog
welcome.greeting = Hello, { $name }!
Text
# i18n/es.ftl
welcome.title = Bienvenido a mi blog
welcome.greeting = ¡Hola, { $name }!

4. Auto-load at app startup

Rust,ignore
use autumn_web::prelude::*;

#[get("/")]
async fn index(locale: Locale) -> Markup {
    html! {
        h1 { (t!(locale, "welcome.title")) }
        p { (t!(locale, "welcome.greeting", name = "Ada")) }
    }
}

#[autumn_web::main]
async fn main() {
    autumn_web::app()
        .i18n_auto() // discovers `i18n/` dir from the [i18n] block
        .routes(routes![index])
        .run()
        .await;
}

That's the whole flow. Open the app at /?locale=es to see Spanish, or set Accept-Language: es in your browser.


File convention

Every .ftl file inside the configured dir (default: i18n/) is loaded at startup, keyed by its filename stem. So i18n/en.ftl becomes the en locale, i18n/pt-BR.ftl becomes pt-BR, etc.

The default locale's file is mandatory: if it's missing, Bundle::load_from_dir returns LoadError::MissingDefaultLocale and .i18n_auto() panics with the typed error message. This is the spec's "fail fast" rule — a half-localized app is worse than a clearly broken one.

Files that are not the default locale are optional; missing locales just won't be served, and the negotiation step will fall through to the next-best match.


Resolution order

The Locale extractor walks the request in this order, returning the first locale that matches the configured supported_locales:

  1. ?locale=xx query parameter (explicit override; useful for testing and for "language switcher" links).
  2. Signed session cookie — the autumn_locale key inside the framework's HMAC-signed session, set via autumn_web::i18n::set_locale_in_session(session, locale).await. This is the recommended way to persist a switcher choice.
  3. Plain autumn_locale cookie — unsigned, set via autumn_web::i18n::set_locale_cookie(locale). Useful when sessions are not enabled.
  4. Accept-Language request header, with full RFC 7231 q-value negotiation (e.g. es-MX,es;q=0.9,en;q=0.7 matches es if only ["en", "es"] are supported).
  5. The configured default_locale.

The order is stable. Applications can rely on it. If steps 1–4 produce a locale that is not in the supported list, the extractor falls through to the next step rather than serving an unsupported locale.

Implementing a locale switcher

The simplest switcher is a pair of ?locale= links:

Rust,ignore
html! {
    a href="?locale=en" { "English" }
    a href="?locale=es" { "Español" }
}

The recommended way to persist the choice across navigations is the framework's signed session cookie via set_locale_in_session. The session cookie is HMAC-signed by the framework, so a hostile client cannot forge it:

Rust,ignore
use autumn_web::i18n::set_locale_in_session;

#[post("/locale/{locale}")]
async fn switch(session: Session, Path(locale): Path<String>) -> impl IntoResponse {
    set_locale_in_session(&session, &locale).await;
    Redirect::to("/")
}

For apps that don't use the session subsystem, the unsigned autumn_locale cookie via set_locale_cookie(locale) is the fallback — note it lives after the session in the resolution order, so a session-set locale always wins.


The t!() macro

t! is a proc-macro (lives in autumn_macros, re-exported as autumn_web::t and from autumn_web::prelude when the i18n feature is on). Two forms:

Rust,ignore
// Without args:
t!(locale, "welcome.title")

// With named args (Project Fluent's `{ $name }` placeable syntax):
t!(locale, "welcome.greeting", name = "Ada")

Compile-time key validation

At expansion time the proc-macro reads $CARGO_MANIFEST_DIR/i18n/<default_locale>.ftl (where <default_locale> is the value of the AUTUMN_I18N_DEFAULT_LOCALE env var, defaulting to "en"; AUTUMN_I18N_FILE overrides the path). If the requested key is not present, the build fails:

Text
error: i18n key `welcome.tite` is not defined in the default locale bundle
         hint: did you mean `welcome.title`?
   --> src/routes/index.rs:12:24
    |
 12 |     t!(locale, "welcome.tite")
    |                ^^^^^^^^^^^^^^

If the file does not exist (e.g. a brand-new app that just enabled the feature flag), the macro falls back to a runtime-only call — the build succeeds and the runtime {$key} marker surfaces the missing key. As soon as you author i18n/en.ftl, the next build picks up the compile-time check automatically.

Runtime behaviour

  • If the key is missing in the requested locale, the bundle walks the configured fallback_chain and returns the first hit.
  • If no fallback hits, it returns {$key} so the missing key is visible at render time, and a tracing::warn! (rate-limited per (locale, key) pair) is emitted.
  • Unknown placeables ({ $name } with no matching arg) are left literally in the output for the same reason: silent empty strings hide bugs.

Localizing validator error messages

This is a documented pattern, not new public API. Map a validator::ValidationErrors to a localized error map by using the field name plus a convention like validation.<field>.<code>:

Text
# i18n/en.ftl
validation.email.email = Please enter a valid email address.
validation.password.length = Password must be at least 8 characters.
Rust,ignore
use validator::ValidationErrors;

fn localize_errors(errors: &ValidationErrors, locale: &Locale) -> Vec<(String, String)> {
    let mut out = Vec::new();
    for (field, field_errors) in errors.field_errors() {
        for err in field_errors {
            let key = format!("validation.{field}.{}", err.code);
            out.push((field.to_string(), t!(locale, &key)));
        }
    }
    out
}

If a code is missing from the .ftl, the {$key} marker makes the omission obvious during testing. We deliberately do not fork or replace validator — its API is the API.


Scaffolding a new project

autumn new can scaffold the i18n module for you with a flag — it is off by default so the baseline new-project experience stays minimal:

Shell
autumn new my-app --with-i18n

This creates i18n/en.ftl with a stub welcome.title / welcome.greeting pair, adds the [i18n] block to autumn.toml, enables the i18n feature on the autumn-web dependency, and wires .i18n_auto() into main.rs. Drop additional i18n/<locale>.ftl files at your leisure.

Migrating from monolingual

The migration is bounded by the size of your translation file, not by the number of files you have to touch:

  1. Add the feature flag to Cargo.toml and the [i18n] block to autumn.toml.
  2. Move every literal string in your templates into i18n/en.ftl under a stable key (we recommend <page>.<element>). This is a pure edit pass — no logic changes.
  3. Replace each literal in your handlers / templates with t!(locale, "key"). Any handler that needs translations adds a Locale parameter.
  4. Author additional locales (i18n/es.ftl, etc.) at your leisure. Missing keys fall back to default_locale so partial coverage works from day one.
  5. Add a locale switcher somewhere visible.

There is no schema change, no per-handler wiring. The shape of the translation file is the only authored surface.


Out of scope

The framework deliberately does not ship:

  • Translation management tooling integrations (Crowdin, Lokalise, Phrase). Author .ftl files in your editor.
  • ICU MessageFormat. Use Fluent's NUMBER and DATETIME built-ins for number / date formatting.
  • Right-to-left layout helpers in autumn-web/ui. The CSS / layout side stays the user's problem; the framework only owns text resolution.
  • A localized version of every framework-emitted error page. Track separately if demand emerges.
  • A reimplementation of validator's message system. We document the integration pattern above; we do not fork or replace validator.
  • Hot-reloading translation files in autumn dev. Nice-to-have, not blocking.

If your app needs richer plural / gender rules, author them in Fluent itself — the format already supports them.


See also