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

Rust MF2

Unicode MessageFormat 2 (MF2) for Rust applications: web applications with Leptos, and native command-line and terminal applications, including Ratatui. You write translations in MF2, the build checks them, and tr!("id", name = value) shows a message in the reader’s language.

One crate

An application depends on mf2, and on mf2-build for its build script. mf2’s features choose the rest:

FeatureFor
leptos (or leptos-0-8) with ssr, hydrate or csra Leptos application, server-rendered, hydrated or client-only
axumthe server: each request’s language, and the catalogs’ routes
nativea command-line tool, in the system’s language
ratatuia Ratatui terminal UI, with markup as styles
fn-number, fn-datetime and a date backendnumbers and dates in each language’s own way

The mf2 command (cargo install mf2-cli) makes starters, checks translations and exchanges them with translators.

What happens underneath. The build turns each language into one small binary catalog. A browser downloads a language’s catalog when it needs it, switches language without a reload, and its wasm carries none of the text. A native application embeds its catalogs or ships them beside the executable. How the crates fit together has the rest.

Where to start

Every sample is compiled. Each rust, toml and mf2 block in this book names the file of a small application it belongs to, and cargo xtask docs builds those applications in CI, for the targets they run on. The exceptions are marked: the migration page’s leptos-fluent code, which its commands convert; the upgrade page’s 1.x excerpts; and the reference pages’ fragments, which a test parses instead.

Getting started

This page builds a small server-rendered Leptos application, hello, translated into English and French. The server renders each page in the reader’s language. The browser then hydrates it and switches language live, without a reload. That is the default way to use this library, and most Leptos applications are built this way. The other delivery modes (islands, client-only, lazy routes) are on their own page. Each of them starts from what you build here.

Every file below is complete.

Checked by CI. cargo xtask docs puts these same blocks together into this application and compiles it for the server and for the browser, so what you copy here is what CI builds.

What you need

  • Rust 1.88 or later (the 2024 edition), and the browser target:

    rustup target add wasm32-unknown-unknown
    
  • cargo-leptos, which builds the server and the client together and serves them:

    cargo install cargo-leptos
    
  • Optionally, the mf2 command, which checks translations and makes starters (The command line has all of it). Nothing on this page needs it:

    cargo install mf2-cli
    

    mf2 init --ssr writes this whole application in one step (Command line); this page writes it by hand, a file at a time, so that each line is explained.

  • For a client-only application only, Trunk (cargo install trunk), which builds it (Delivery modes).

Which version these pages show. The manifests on these pages name mf2 = "2" and mf2-build = "2": 2.0.0, on crates.io. cargo install mf2-cli installs the mf2 command.

The shape of an application

hello/
├── Cargo.toml        the application, and the `mf2` it builds with
├── build.rs          reads locales/, writes the catalogs and the module
├── locales/
│   ├── en/main.mf2
│   └── fr/main.mf2
└── src/
    ├── lib.rs        the page, and the browser's entry point
    └── main.rs       the server

One crate holds the application and its messages.

How it works. The build script reads locales/, checks every message, and writes three things: one binary catalog per language, a manifest describing all the messages, and a small Rust module, which the crate includes, holding the tr! macro and the Locale type. The browser downloads the catalog for one language when it needs it. No message text, message id, argument name or plural rule is compiled into the wasm. So changing a translation leaves the wasm byte-for-byte the same, and every reader’s cached copy stays valid.

The messages

Messages are Unicode MessageFormat 2 (MF2), in files under locales/<language>/. Each file starts by naming its language. Then comes one message per id = pattern, and a [section] prefixes the ids that follow it (language.label below). MF2 for developers teaches the language itself:

@locale en
---

app-title = Hello, MessageFormat 2
greeting = Hello, {$name}!

# A plural: the number picks the variant, by this language's rules.
visits =
  .input {$count :integer}
  .match $count
  one {{You have been here once.}}
  *   {{You have been here {$count} times.}}

visit-again = Visit again
not-found = There is nothing here.

[language]
label = Language
apply = Apply
en = English
fr = Français
@locale fr
---

app-title = Bonjour, MessageFormat 2
greeting = Bonjour, {$name} !

visits =
  .input {$count :integer}
  .match $count
  one  {{Vous êtes venu une fois.}}
  many {{Vous êtes venu {$count} fois.}}
  *    {{Vous êtes venu {$count} fois.}}

visit-again = Revenir
not-found = Il n’y a rien ici.

[language]
label = Langue
apply = Appliquer
en = English
fr = Français

{$name} is an argument that the code passes in. .match chooses a variant, and * is the one that matches anything else. Each language writes the variants its grammar needs. French has a many category (for numbers such as a million), and the build warns about a category that no variant names, so the French message names it too. The language names are messages too. language.fr is Français in every catalog, so each language is named in its own language, and the names are catalog data rather than text in the wasm.

mf2 check runs every check that the build runs, with the features cargo resolves for the build (or the ones --features names). It also works without cargo, so you can use it in a translator’s editor: it then checks as if every function were on, and says so in one line.

The manifest

[package]
name = "hello"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
leptos = { version = "0.9.0-beta", default-features = false }
leptos_meta = "0.9.0-beta"
leptos_router = "0.9.0-beta"
mf2 = { version = "2", features = ["leptos", "fn-number"] }

axum = { version = "0.8", optional = true }
console_error_panic_hook = { version = "0.1", optional = true }
leptos_axum = { version = "0.9.0-beta", optional = true }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"], optional = true }
wasm-bindgen = { version = "0.2", optional = true }

[build-dependencies]
mf2-build = "2"

[features]
hydrate = [
    "leptos/hydrate",
    "mf2/hydrate",
    "dep:console_error_panic_hook",
    "dep:wasm-bindgen",
]
ssr = [
    "leptos/ssr",
    "leptos_meta/ssr",
    "leptos_router/ssr",
    "mf2/ssr",
    "mf2/axum",
    "dep:axum",
    "dep:leptos_axum",
    "dep:tokio",
]

[package.metadata.leptos]
output-name = "hello"
site-root = "target/site"
site-pkg-dir = "pkg"
site-addr = "127.0.0.1:3000"
reload-port = 3001
bin-features = ["ssr"]
bin-default-features = false
lib-features = ["hydrate"]
lib-default-features = false
lib-profile-release = "wasm-release"
# `cargo leptos watch` watches the crate's sources only.
watch-additional-files = ["locales"]

[profile.wasm-release]
inherits = "release"
opt-level = "z"
lto = "fat"
codegen-units = 1
panic = "abort"
strip = true

The application forwards its own ssr or hydrate to mf2, just as it does to leptos, and the server’s build turns on mf2/axum beside it. watch-additional-files makes cargo leptos watch see an edit under locales/: without it, a translation edit is not seen until something else changes.

mf2’s other features decide which formatting functions exist, and they are the same for both builds, so the server and the browser format alike. A message can never add code to the wasm by itself: if a French translation uses :datetime and the crate was not built with fn-datetime, the build fails and names the message. With no features, a message can still use :string, :number, :integer and plural selection. Numbers then use neutral symbols (1234.5). fn-number gives each language’s own symbols and grouping (1 234,5 in French, grouped with a narrow no-break space, U+202F); dates take fn-datetime and one backend:

Feature of mf2Adds
fn-numbernumbers in each locale’s own symbols, :percent, :currency, :unit
datetime-icu (with features = ["icu-blob"] on mf2-build):datetime, :date, :time through ICU4X, on the server and in the browser alike; it turns on fn-datetime
datetime-intlthe same, through the browser’s own Intl.DateTimeFormat: a smaller wasm, and the browser’s formatting
intlnumbers and plural rules through the browser’s Intl too

A feature that is on but that no message uses adds nothing to the wasm.

The build script is three lines. It reads locales/, checks every message, and writes the catalogs, the manifest and the module to cargo’s OUT_DIR; a message with an error fails the build and is named:

fn main() {
    mf2_build::run();
}

An optional mf2.toml beside Cargo.toml configures the build: the source language (en unless it says otherwise), what the catalogs leave out, what a missing translation shows, and the lints. The defaults suit this page: a message not translated yet shows in the source language, and the catalogs the browser downloads carry no message ids and no comments. mf2.toml lists every key.

Leptos 0.9 or 0.8. mf2’s leptos feature is for Leptos 0.9. A requirement of "0.9.0-beta" takes every later 0.9.0-* pre-release and the 0.9 releases with an ordinary cargo update. An application that stays on Leptos 0.8 names the 0.8 crates and mf2’s leptos-0-8 in place of leptos; the rest of the manifest, and every source file, is unchanged:

[dependencies]
leptos = { version = "0.8", default-features = false }
leptos_meta = "0.8"
leptos_router = "0.8"
leptos_axum = { version = "0.8", optional = true }
mf2 = { version = "2", features = ["leptos-0-8", "fn-number"] }

Asking for both lines at once — leptos and leptos-0-8 — is a compile error, the only one, that says what to write.

The page

src/lib.rs includes what the build generated, and holds the document shell, the page, and the browser’s entry point. The shell does three things for translation:

use leptos::prelude::*;
use leptos_meta::{MetaTags, Title, provide_meta_context};
use leptos_router::components::{Route, Router, Routes};
use leptos_router::path;
use mf2::leptos::{CatalogLinks, CatalogPreload, LocaleSwitcher, html_lang};

// What the build script generated: `tr!`, `Locale`, `install()` and the
// rest, at this crate's root.
mf2::include_generated!();

/// The document. `lang` and `dir` are those of the language this request
/// is rendered in, so an Arabic page is right-to-left from its first byte.
pub fn shell(options: LeptosOptions) -> impl IntoView {
    let (lang, dir) = html_lang();
    view! {
        <!DOCTYPE html>
        <html lang=lang dir=dir>
            <head>
                <meta charset="utf-8" />
                <meta name="viewport" content="width=device-width, initial-scale=1" />
                <AutoReload options=options.clone() />
                <HydrationScripts options />
                <MetaTags />
                // This page's catalog, downloading in parallel with the wasm.
                <CatalogPreload />
                // Every language's catalog URL, so a switch needs no extra
                // round trip.
                <CatalogLinks />
            </head>
            <body>
                <App />
            </body>
        </html>
    }
}
  • html_lang() gives <html lang dir> for the language the server negotiated. This is how a screen reader picks its voice (WCAG 3.1.1), and how a right-to-left language lays out right-to-left.
  • <CatalogPreload/> writes a <link rel="preload"> for this page’s catalog, so it downloads while the wasm does.
  • <CatalogLinks/> lists every language’s catalog URL (this page’s included), so switching language fetches the catalog directly. Leave it out to keep pages a few bytes smaller. A switch then asks the server for the URL first, which costs one round trip.

How the client boots. The preload link is the client’s boot data: the client reads the catalog URL from it and the language from <html lang>. There is no inline script and no JSON.

The page itself uses tr! wherever it needs text:

#[component]
pub fn App() -> impl IntoView {
    provide_meta_context();
    view! {
        <Title text=tr!("app-title") />
        <Router>
            <header>
                <LocaleSwitcher label=tr!("language.label") button=tr!("language.apply") />
            </header>
            <main>
                <Routes fallback=|| view! { <p>{tr!("not-found")}</p> }>
                    <Route path=path!("/") view=Home />
                </Routes>
            </main>
        </Router>
    }
}

#[component]
fn Home() -> impl IntoView {
    let count = RwSignal::new(1);
    view! {
        <h1>{tr!("greeting", name = "Ada")}</h1>
        // A signal as an argument: the text follows the count and the
        // language, with no closure at the call site.
        <p role="status">{tr!("visits", count = count)}</p>
        <button on:click=move |_| *count.write() += 1>{tr!("visit-again")}</button>
    }
}

tr!("id", name = value) is checked at compile time against the messages. A misspelt id gets a suggestion, and a missing, unknown or duplicated argument is an error. One macro works in a text node, an attribute, a component prop and a String; Call sites covers every position.

What tr! returns. A small description of the message (its number and its arguments), not text. The text is made where the description is rendered, in whichever language is current, which is why one macro works in every position.

The switcher is a form: a labelled <select> and a button. Choosing a language does nothing until the button is pressed. Once the page has hydrated, pressing it switches the page live. Before that, or if the wasm never loads, the form submits ?lang=fr and the server renders the page in French. Switching language explains why, and how to build your own switcher.

The switcher lists every language, in the order of Locale::ALL, each named by its language.<tag> message in its own language: adding a language to locales/ adds it to the switcher, with no code.

The browser’s entry point installs what the build generated and hydrates:

/// The browser's entry point: install what the build generated, then
/// hydrate once this page's catalog has arrived.
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
    console_error_panic_hook::set_once();
    install();
    mf2::leptos::hydrate_body(App);
}

What hydrate_body does. It fetches the catalog named by the preload link (reusing that download), checks that it came from the same build as the wasm, installs it, and only then hydrates. If the catalog cannot be loaded, the page stays as the server rendered it: readable, not interactive, with one mf2: line in the console. A catalog from a different deploy makes the page reload, rather than be read wrongly.

The server

src/main.rs is an ordinary leptos_axum server with three additions:

#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    use axum::Router;
    use hello::{App, shell};
    use leptos::prelude::*;
    use leptos_axum::{LeptosRoutes, file_and_error_handler, generate_route_list};
    use mf2::axum::{Negotiator, catalog_routes};

    // 1. The catalogs this build embeds, each checked against the build
    //    once, here: a deploy that mixes builds fails at start-up rather
    //    than in a request.
    hello::install();

    let conf = get_configuration(None)?;
    let addr = conf.leptos_options.site_addr;
    let leptos_options = conf.leptos_options;
    let routes = generate_route_list(App);

    let app = Router::new()
        .leptos_routes(&leptos_options, routes, {
            let leptos_options = leptos_options.clone();
            move || shell(leptos_options.clone())
        })
        .fallback(file_and_error_handler(shell))
        // 2. Each request's language: `?lang=` (a link, or the switcher's
        //    form), then the cookie a choice leaves, then the browser's
        //    `Accept-Language`. The response gets `Content-Language`, `Vary`
        //    and the cookie.
        .layer(Negotiator::default())
        // 3. `/i18n/*`: the catalogs, served from the binary, each compressed
        //    once and kept, cached for a year (each file's name carries its
        //    content hash).
        .merge(catalog_routes())
        .with_state(leptos_options);

    let listener = tokio::net::TcpListener::bind(&addr).await?;
    println!("listening on http://{addr}");
    axum::serve(listener, app.into_make_service()).await?;
    Ok(())
}

#[cfg(not(feature = "ssr"))]
fn main() {}

The routes and the error handler are Leptos’s plain forms, with nothing to pass to each.

How the language reaches the page. The negotiator is a tower layer: every page the routes and the fallback render finds the request’s language through the request itself. The language is chosen once, on the server: the client reads it from the page and never negotiates again, so hydration cannot disagree with the server.

Run it

cargo leptos watch

Open http://127.0.0.1:3000. The page is in English or French, depending on your browser’s languages. Choose the other language and press the button: the page switches without reloading, <html lang> changes with it, and the count keeps its value. Reload, and the page is still in the language you chose, because the cookie remembers it. Edit a message in locales/fr/main.mf2: the watcher rebuilds, and the new text appears. The wasm stays byte-for-byte the same.

For a release build, run cargo leptos build --release.

Next

  • MF2 for developers: the message language, from placeholders to plurals, gender and markup.
  • Call sites: tr! in every position, arguments, markup as elements, and when a String needs to be plain.
  • Delivery modes: lazy routes, islands (the smallest download), and client-only applications.
  • Switching language: the switcher, negotiation, and your own controls.
  • Translating: translators, reviews, pseudo-locales and the checks for CI.
  • Accessibility: what the library does for WCAG 2.2 AA, and what the application still has to do.
  • Testing: the text in each language, pinned per test.
  • Troubleshooting: when tr! is not found, the text is empty, or the page stays in one language.

How the crates fit together

Rust MF2 is a family of crates for using Unicode MessageFormat 2 (MF2) from Rust, in Leptos web applications and in native command-line and terminal applications. An application names one of them, mf2, and turns on the integration for its kind as mf2’s features: leptos and axum on the web, native (and ratatui) natively. Its build script calls mf2-build, which checks the translation resources and writes the generated module the call sites use and one binary catalog (.mf2b) per language; at run time the runtime formats each call site against the catalog of the active locale.

build time:  .mf2 resources ──> mf2-build ──> generated module (tr!, ids)
                                          └─> one .mf2b catalog per language

run time:    application ──> call sites (tr!) ──> runtime ──> active catalog

The book explains how to use the system. Each crate’s rustdoc remains the reference for its exact API and feature flags.

Application-facing crates

CrateRole
mf2Application facade: the call-site API and the MF2 runtime, with features for the host and the formatting functions. With a Leptos line and mode, mf2::leptos: rendering, reactive arguments, markup, and switching language live. With axum, mf2::axum: each request’s language and the catalogs’ routes. With native, mf2::native: the catalogs embedded or shipped, and the system’s language. With ratatui, mf2::ratatui: messages as Ratatui text, markup as styles.
mf2-buildBuild-time validation and generation of manifests, catalogs, and the Rust module used by call sites.
mf2-cliThe mf2 command for making starters, checking resources, compiling catalogs, and converting or exchanging translations.

mf2 is the facade applications use to write and format messages; it is not just an index of package links. It defines the call-site types (Tr, TrArgs, TrRich, TrDyn, ArgValue, DateTimeValue, …), and each integration is a module of it built on those types and the runtime. Without a Leptos mode, mf2 compiles no Leptos code: no Leptos, no tachys, no reactive_graph. 1.x’s leptos-mf2, mf2-axum, mf2-native and mf2-ratatui are these features now (Upgrading from 1.x).

Supporting crates

These crates provide the implementation layers used by the application-facing crates. Most application code does not need to depend on them directly.

CrateRole
mf2-modelMF2 message data model and shared identifiers.
mf2-syntaxParsing, validation, analysis, and serialization of MF2 messages.
mf2-resourceReading and writing message resource files.
mf2-catalogBinary catalog format, reader, and writer.
mf2-runtimeMessage evaluation, formatting, and output sinks.
mf2-locale-dataLocale direction, plural rules, and compact locale data used during builds.
mf2-fn-numberLocalized number, currency, percent, and unit functions.
mf2-fn-datetimeDate and time functions with the selected backend.
mf2-host-stdNative host services for formatting.
mf2-host-webBrowser host services for formatting.
mf2-macrosProcedural macros used by generated translation modules and mf2.

Choose a path

MF2 for developers

This page is about the message language: how to write a message, and why it looks the way it does. Unicode MessageFormat 2 (MF2) is part of Unicode’s CLDR specification (UTS #35). It defines one message: its text, the values it takes, how it formats them, and how it chooses between wordings. The files that hold many messages follow a W3C draft, the Message Resource format, which is still being written; this library reads it as .mf2 files.

Every example here belongs to one small command-line application, guide, which prints each message in English and in French. cargo xtask docs builds it, so every message on this page is one the build accepts. Native applications explains the parts of such an application that this page only shows.

[package]
name = "guide"
version = "0.1.0"
edition = "2024"

[dependencies]
mf2 = { version = "2", features = ["native", "fn-number"] }

[build-dependencies]
mf2-build = "2"
fn main() {
    mf2_build::run();
}

The .mf2 file

Messages live in locales/<language>/<name>.mf2, one directory per language. A file has two parts, split by a line of three dashes:

  • Before ---, the file’s properties. @locale names the file’s language, and is the one property every file needs.
  • After ---, the messages: one id = message each. A message may continue on the following lines if they are indented; the indentation is removed.

A line that starts with # is a comment, and it belongs to the message (or section) right below it, unless an empty line comes between them. A line that starts with @ is a property of what follows. A [section] line puts its name, and a dot, in front of every id after it, up to the next section: below, total in section [order] is the message order.total. Sections do not nest, so a section names its full path ([settings.sound]).

@locale en
---

# The first line the application prints.
@param $name - the reader's name, as they wrote it
welcome = Welcome, {$name}!
braces = Write \{ and \} for a brace in a message.

tr! names a message by its id and passes its arguments by name:

//! Prints every message of the MF2 chapter, in each of its languages.

mf2::include_generated!();

fn main() {
    install();
    for locale in Locale::ALL {
        set_locale(locale);
        println!("{}", tr!("welcome", name = "Ada"));
        println!("{}", tr!("braces"));

Text and placeholders

Everything in a message is text, except what is in braces:

  • {$name} is a placeholder: the value of the argument name.
  • {|some text|} is a literal, and prints as it is. A literal rarely helps on its own, but it can be formatted, like any value.
  • \{, \}, \| and \\ write a brace, a bar or a backslash.

The spaces after = are not part of the message; every other space is, even at the end of a line.

Functions and options

A placeholder can name a function, which formats its value: `{$amount
number}. Options follow the function's name, as name=value`:
[order]

@param $amount - the order's total, in the shop's currency
total =
  .local $sum = {$amount :number minimumFractionDigits=2}
  {{Total: {$sum}}}

@param $share - the discount, as a fraction: 0.15 is 15%
discount = {$share :percent} off

The functions this library provides:

FunctionFormats
:stringa value as text; .match compares the text
:numbera number, in the reader’s language; .match uses exact values, then the plural category
:integera number, as a whole number; .match as :number
:percenta fraction, as a percentage (with fn-number)
:currency, :unitan amount of money, a measure (with fn-number)
:datetime, :date, :timea date and time (with fn-datetime)

Common :number options are minimumFractionDigits, maximumFractionDigits and useGrouping. select=ordinal makes a number select on its ordinal category (“1st”, “2nd”) instead of its plural one.

A message that starts with a declaration writes its text between double braces, {{…}}:

  • .local $sum = {…} gives a formatted value a name, used by the text below it (total above).
  • .input {$count :integer} gives an argument a function, used everywhere the message names the argument. It is how a message says what a selection is made on.

A translation may choose other options, or other functions, from the source. It is the translator’s message too.

Plurals

.match chooses among variants, one per wording. Each variant starts with its key; * matches any value, and every .match needs a * variant. A number matches an exact key first (0), then its plural category in the language: zero, one, two, few, many or other.

# Still in [order], so its id is order.items.
@param $count - how many items the basket holds; a whole number
items =
  .input {$count :integer}
  .match $count
  0   {{Your basket is empty.}}
  one {{One item in your basket.}}
  *   {{{$count} items in your basket.}}

The categories are the language’s own. English has one and other; French adds many (for a million and more), and its one also covers 0; Polish, Arabic and others have more. Each language’s file names the categories its grammar needs, and the build warns about a category the language has that no variant names.

Ordinals

select=ordinal chooses by position: “1st”, “2nd”, “3rd”, “4th”. English uses four ordinal categories for this, French two.

[race]

@param $place - the reader's place in the race: 1 is first
place =
  .input {$place :integer select=ordinal}
  .match $place
  one {{You finished {$place}st.}}
  two {{You finished {$place}nd.}}
  few {{You finished {$place}rd.}}
  *   {{You finished {$place}th.}}
        for count in [0, 1, 3] {
            println!("{}", tr!("order.items", count = count));
        }
        println!("{}", tr!("order.total", amount = 1234.5));
        println!("{}", tr!("order.discount", share = 0.15));
        for place in [1, 2, 3, 4, 11, 22] {
            println!("{}", tr!("race.place", place = place));
        }

Selecting on several values

.match can take several values, and each variant then has one key per value, in order. The usual case is a person’s grammatical gender and a count:

[social]

@param $name - who shared the photos
@param $gender - their gender, for the grammar: female, male or other
@param $count - how many photos they shared
shared =
  .input {$gender :string}
  .input {$count :integer}
  .match $gender $count
  female one {{{$name} added a photo to her album.}}
  female *   {{{$name} added {$count} photos to her album.}}
  male   one {{{$name} added a photo to his album.}}
  male   *   {{{$name} added {$count} photos to his album.}}
  *      one {{{$name} added a photo to their album.}}
  *      *   {{{$name} added {$count} photos to their album.}}

The order of the variants does not matter: the best match wins, and a key that names a value beats *. Every combination needs a variant that matches it, which is why the last one is * *.

:string selects on text, and that is the MF2 way to choose on a Rust enum: pass a key, and let each language’s message say what the key reads as.

[presence]

# The code passes one of: online, away, offline.
status =
  .input {$status :string}
  .match $status
  online {{Online}}
  away   {{Away}}
  *      {{Offline}}

Passing the enum itself, through Display, would put Display’s text in the message. That text comes from Rust code and is not translated: a French reader would see English. The argument is a key, so the words stay in the messages:

        for (name, gender) in [("Ada", "female"), ("Alan", "male"), ("Sam", "other")] {
            println!("{}", tr!("social.shared", name = name, gender = gender, count = 2));
        }
        for presence in [Presence::Online, Presence::Away, Presence::Offline] {
            println!("{}", tr!("presence.status", status = presence.key()));
        }

Markup

{#name}…{/name} marks a stretch of a message, and {#name/} stands on its own. The message says what a stretch is (a key to press, a link, something to stress); the code decides how it looks: an element on the web (Call sites), a style in a terminal (Native applications). As plain text, as here, the markup is left out.

[help]
save = Press {#key}Ctrl+S{/key} to save, or {#key}Esc{/key} to close.

Keep a sentence with styled words in one message. Splitting it into “Press”, “Ctrl+S” and “to save” fixes the English word order, and another language may need the key at the end or in the middle. With markup, the translator moves it. A translation that drops markup the source has is an error (dropped-markup, The command line).

Notes for translators

Translators read the files, not the code, so the files carry what they need:

  • a # comment above a message says where it appears or what it means;
  • @param $name - … says what an argument holds, so a translator can choose the right grammar;
  • @do-not-translate marks a message that stays as written: a brand, or a language’s name in that language. On a section, it covers every message in it; at the top of a file, the whole file.
# The product's name: the same in every language.
@do-not-translate
[brand]
name = Photon

mf2 export passes comments and @param notes to a translation tool, and mf2 check holds a translation to @do-not-translate. A language that leaves such a message out shows the source’s, as French does here.

        println!("{}", tr!("help.save"));
        println!("{}", tr!("brand.name"));
    }
}

/// Whether a reader is available. Its words are in the messages; the code
/// passes a key.
#[derive(Clone, Copy)]
enum Presence {
    Online,
    Away,
    Offline,
}

impl Presence {
    fn key(self) -> &'static str {
        match self {
            Self::Online => "online",
            Self::Away => "away",
            Self::Offline => "offline",
        }
    }
}

The French file

Each language writes its own variants. French names many; its ordinals have two forms; and its album is son album whatever the owner’s gender, so every variant has * for the gender: it matches on the count alone, but still names both values, as the source does.

@locale fr
---

welcome = Bienvenue, {$name} !
braces = Écrivez \{ et \} pour une accolade dans un message.

[order]

total =
  .local $sum = {$amount :number minimumFractionDigits=2}
  {{Total : {$sum}}}

discount = {$share :percent} de remise

items =
  .input {$count :integer}
  .match $count
  0    {{Votre panier est vide.}}
  one  {{Un article dans votre panier.}}
  many {{{$count} d’articles dans votre panier.}}
  *    {{{$count} articles dans votre panier.}}

[race]

place =
  .input {$place :integer select=ordinal}
  .match $place
  one {{Vous finissez {$place}er.}}
  *   {{Vous finissez {$place}e.}}

[social]

shared =
  .input {$gender :string}
  .input {$count :integer}
  .match $gender $count
  * one  {{{$name} a ajouté une photo à son album.}}
  * many {{{$name} a ajouté {$count} de photos à son album.}}
  * *    {{{$name} a ajouté {$count} photos à son album.}}

[presence]

status =
  .input {$status :string}
  .match $status
  online {{En ligne}}
  away   {{Absent}}
  *      {{Hors ligne}}

[help]
save = Appuyez sur {#key}Ctrl+S{/key} pour enregistrer, ou sur {#key}Échap{/key} pour fermer.

cargo run prints both languages.

Side by side with other formats

order.items, from Plurals, in each:

Fluent (.ftl):

order-items = { $count ->
    [0] Your basket is empty.
    [one] One item in your basket.
   *[other] { $count } items in your basket.
}

ICU MessageFormat 1 (Java, ICU4C, FormatJS):

{count, plural,
  =0 {Your basket is empty.}
  one {One item in your basket.}
  other {# items in your basket.}}

i18next (JSON, one key per plural form):

{
  "items_zero": "Your basket is empty.",
  "items_one": "One item in your basket.",
  "items_other": "{{count}} items in your basket."
}
MF2FluentICU MessageFormat 1i18next
Defined byUnicode (CLDR)Mozilla’s Project FluentICUthe i18next library
Several valuesone .match, a key per valueselectors nested inside variantsselect and plural nestedkey suffixes: a context, then the plural form
Formattingfunctions with options (:number)NUMBER(), DATETIME(){n, number, …} styles and skeletonsformatters named in the placeholder
Markuppart of the syntax, checkednone: text, or HTML the application parsesnone (some libraries add tags)through a component, in React
Reusenone: each message stands aloneterms (-brand) and message referencesnonenesting ($t(key))
Files.mf2, sections and properties.ftlwhatever holds the stringsJSON

Fluent users will miss terms. MF2 has no references between messages, so each message is complete and can be translated on its own. A brand is a @do-not-translate message the code shows, or text written in each message that needs it. mf2 convert --from fluent copies each term into the messages that use it (The command line, Migrating from leptos-fluent).

Why messages have ids

Some libraries extract messages from the code: the English text is the key, and a tool collects every string it finds. This library does the opposite. The messages are written in files, each under an id, and the code names the id. That choice buys several things:

  • The source language is a translation like the others. Fixing a typo in the English does not change a key, so no translation is lost.
  • One English word can be two messages. “Open” the verb and “open” the state get two ids, and languages that use two words can.
  • A message is more than a string. A plural, a gender, markup and options live in the message, where the translator can change them, instead of in code around it.
  • Mistakes are compile errors. tr! checks that the id exists (a misspelt one gets a suggestion) and that the arguments are the message’s; mf2 check finds missing translations and ids nothing uses.
  • The code carries no text. On the web, the browser downloads a language’s messages only when it needs them, and the wasm has none of them.
  • Translators never read Rust. They get the files, or an export, with every comment and note.

Call sites: tr! in every position

There is one macro. tr!("id", name = value, …) works in a text node, an attribute, a component prop, a const table, and a String, and it costs each call site about the same in the wasm wherever it is used. This page shows each position, with the messages it uses.

The samples on this page are components in one library, set up as the application in Getting started is: mf2 with the leptos feature, a build script, and messages under locales/. cargo xtask docs compiles them for the server and for the browser. They begin with:

use leptos::prelude::*;
use mf2::{ArgValue, DateTimeValue, Tr};

mf2::include_generated!();

and their messages begin with:

@locale en
---

welcome = Welcome back

What tr! returns

tr! does not produce text. It produces a description of a message: the message’s number and its arguments. The text is made where the description is used, in whichever language is current. This is why one macro works in every position, and why text rendered from a description follows a language switch without a closure at the call site.

The macro checks the call against the messages at compile time:

  • the id exists (if it is misspelt, the error suggests the closest one);
  • the arguments are exactly the message’s variables: none missing, none unknown, none given twice;
  • if markup handlers are given, there is one for every markup element of the message, and none for an element it does not have;
  • each argument’s type converts to a message argument.

A message with no arguments is a Tr: Copy, four bytes, and usable in a const. A message with arguments is a TrArgs. One with markup handlers is a TrRich. Nothing at the call site carries text, an id, or an argument name. Arguments are passed by position, and the names stay in the build.

Text

#[component]
pub fn Welcome() -> impl IntoView {
    view! { <h1>{tr!("welcome")}</h1> }
}

In the browser the text node joins the library’s registry of nodes. On a language switch, the registry rewrites every registered node directly. No effect runs per call site, and the node’s slot is freed when it unmounts.

Attributes

A description can be the value of an attribute — any but class and style, which Leptos types as class and style values rather than text, so class=tr!(…) does not compile:

[search]
placeholder = Search the catalog
label = Search
#[component]
pub fn Search() -> impl IntoView {
    view! {
        <input
            type="search"
            placeholder=tr!("search.placeholder")
            aria-label=tr!("search.label")
        />
    }
}

Bidi isolation is chosen by the attribute’s name. An argument whose direction could differ from the message’s — a string, typically — is wrapped in invisible Unicode isolates (U+2066–U+2069; a string gets U+2068 and U+2069). A formatted number takes the locale’s own direction and gets none. The MF2 specification makes this the default so that, for example, a Latin name in an Arabic sentence cannot reorder the sentence around it. Whether the marks belong in the text depends on who reads it. For a person they are right; for a program they are junk:

The attributeIts text
value, href, src, srcset, action, formaction, poster, cite, download, id, name, for, form, list, class, type, and every data-*plain: a program reads it (a form submission, a URL, a script)
every other name: title, alt, aria-*, placeholder, label, content, …isolated: a person reads it

The name match ignores ASCII case, and the rule is the same on the server and in the browser. prop:value= (a DOM property) is always plain:

[signature]
label = Signature
text = Signed, {$name}
#[component]
pub fn Signature(name: String) -> impl IntoView {
    view! {
        <label>
            {tr!("signature.label")}
            // Submitted with the form: no marks in `value`. Read by a person:
            // the name is isolated in `title`.
            <input
                name="signature"
                value=tr!("signature.text", name = name.clone())
                title=tr!("signature.text", name = name)
            />
        </label>
    }
}

Component props

A description converts into what a component takes text as: TextProp, Signal<String>, Oco<'static, str> and String. Prefer #[prop(into)] TextProp. It is derived, so the component re-reads it after a language switch:

[settings]
title = Settings
intro = Choose how the catalog looks to you.
#[component]
pub fn Card(#[prop(into)] title: TextProp, children: Children) -> impl IntoView {
    view! {
        <section>
            <h2>{move || title.get()}</h2>
            {children()}
        </section>
    }
}

#[component]
pub fn Settings() -> impl IntoView {
    view! {
        <Card title=tr!("settings.title")>
            <p>{tr!("settings.intro")}</p>
        </Card>
    }
}
A prop of typeAfter a language switch
TextProp, Signal<String>follows it (derived)
Oco<'static, str>, Stringkeeps the text it had: it is a value

On the server, a derived prop captures the request’s catalog when it is converted. Code that reads the prop after rendering, such as leptos_meta reading <Title text=…>, therefore still gets the request’s language.

Arguments

Arguments are named at the call site. They can be literals, variables or expressions:

[order]
summary = {$customer}: {$items :integer} items, {$total :currency currency=EUR}
exact = Exactly {$amount :number minimumFractionDigits=2}
#[component]
pub fn Order(customer: String, items: u32, total: f64) -> impl IntoView {
    view! {
        <p>{tr!("order.summary", customer = customer, items = items, total = total)}</p>
        // A decimal as its exact text, not rounded through f64.
        <p>{tr!("order.exact", amount = ArgValue::decimal("19.99"))}</p>
    }
}
FromIs
&str, String, &String, Arc<str>, char, Cow<'static, str>a string. A literal and a borrowed Cow are kept as they are, an Arc<str> is shared as it is, and other text is copied once into a shared string
i8…i128, u8…u128, isize, usize, and their NonZero formsan integer, exactly: past i64, its exact decimal
f32, f64a floating-point number
boolthe string true or false, which .match selects on
ArgValue::decimal("19.99")an exact decimal, as its text
DateTimeValue, SystemTimea date and time (below)
&Path, PathBuf, &OsStr, OsStringits text
a signal: Signal, ReadSignal, RwSignal, Memo (and their Arc forms)its value, read when the message is formatted (below)
&T, for any of these that is Copythe value
any other type with Display (an error, an address)its text, which is not translated

With native, jiff’s Timestamp, Zoned, civil::Date and civil::DateTime are dates too; a civil::Time is passed as its text. A Cow that borrows for less than 'static is refused by the borrow checker: pass &*cow.

Anything else is a compile error at the argument that says what an argument may be. A type of your own implements Display to pass its text, or mf2::IntoArg to pass it as a number or a date.

Dates

A date needs a date backend among mf2’s features: datetime-icu (which also needs mf2-build’s icu-blob, to put each language’s date data in its catalog), or datetime-intl for the browser’s own formatter:

[dependencies]
mf2 = { version = "2", features = ["leptos", "fn-number", "datetime-icu"] }

[build-dependencies]
mf2-build = { version = "2", features = ["icu-blob"] }
[post]
published = Published {$when :datetime dateLength=long}
#[component]
pub fn Published(epoch_ms: i64) -> impl IntoView {
    // `instant` is `None` past the years a date can hold.
    let when = DateTimeValue::instant(epoch_ms);
    view! { <p>{when.map(|when| tr!("post.published", when = when))}</p> }
}

The options are MessageFormat 2’s, not JavaScript’s: dateFields, dateLength and timePrecision on :datetime, fields and length on :date, precision on :time, and timeZoneStyle to show the zone. An option a function does not have is ignored when the message formats, so dateStyle=long gives the default length; mf2 check warns about it (unknown-option).

Dates are shown in the reader’s time zone, with no code in the application:

  • In the browser, the library asks for the reader’s zone (Intl.DateTimeFormat().resolvedOptions().timeZone).
  • A server cannot know it on a reader’s first visit, so that page is rendered in UTC — or in the zone setup().with_time_zone(…) names. When the page has hydrated, the library rewrites the dates that come out differently in the reader’s zone, and only those; the rest of the page is not touched. It then remembers the zone in a cookie, mf2_tz.
  • Every later page is rendered in the reader’s zone from the start: the server reads the cookie, and the page says which zone it was rendered in, so nothing changes after hydration. A zone the server’s time zone database does not know, or a malformed cookie, is ignored.
  • A client-only application renders in the reader’s zone from its first frame, and writes no cookie.

with_time_zone takes an mf2::TimeZone: TimeZone::UTC, TimeZone::offset(seconds), or TimeZone::named("Europe/Paris"), which returns an Option — None for a name the server’s time-zone database does not know — so the application decides what to fall back to: setup().with_time_zone(TimeZone::named("Europe/Paris").unwrap_or(TimeZone::UTC)).

So a reader on a first visit may see a date change once, just after the page becomes interactive. A message that must show one particular zone — an event’s local time, say — names it, and the reader’s zone does not apply:

[event]
starts = Doors open {$when :time timeZone=|Europe/Paris| timeZoneStyle=short}

The zone, in order: the one the message names (timeZone=input means the value’s own, and an instant’s own zone is UTC unless it was given one); else the reader’s, once known; else with_time_zone’s; else UTC. A floating value (DateTimeValue::floating) is a wall time with no zone and is shown as it is.

A value can carry a zone of its own, for timeZone=input to show: DateTimeValue::instant(t).with_zone("Europe/Paris") is the instant t, shown at Paris’s wall time; DateTimeValue::wall_time(date, time, "Europe/Paris") is that wall time in Paris, whatever instant it is.

Islands. Dates inside islands are corrected like any others. A date in a component that stays on the server (not an island) is not sent to the browser as code, so it cannot be corrected: on a reader’s first visit it stays in UTC (or with_time_zone’s zone) until the next page, which the cookie renders in the reader’s zone.

Arguments that change

Pass a signal and the text follows it. The call site has no closure: the library runs one effect for the node, and that effect reads the signal.

[cart]

items =
  .input {$count :integer}
  .match $count
  0   {{Your cart is empty}}
  one {{One item in your cart}}
  *   {{{$count} items in your cart}}

add = Add an item
#[component]
pub fn Cart() -> impl IntoView {
    let count = RwSignal::new(0u32);
    view! {
        <p role="status">{tr!("cart.items", count = count)}</p>
        <button on:click=move |_| *count.write() += 1>{tr!("cart.add")}</button>
    }
}

For a value computed from other signals, pass a Memo or a Signal::derive(…). A signal that has been disposed reads as unset. The message then reports an unresolved variable instead of panicking.

Choosing between messages

A closure can return a different message depending on state. Every branch must return the same type: Tr for messages without arguments, TrArgs for messages with them.

[status]
online = Online
offline = Offline
#[component]
pub fn Status(online: Signal<bool>) -> impl IntoView {
    view! {
        <p>
            {move || if online.get() { tr!("status.online") } else { tr!("status.offline") }}
        </p>
    }
}

Markup as elements

MF2 messages can contain markup: {#name}…{/name}. A call site turns each markup element into a real element with a closure. The message decides where the element goes, so a translation can move it to the place its grammar needs, and the view does not have to know the language’s word order:

[terms]
accept = By continuing you accept our {#link}terms of use{/link} and {#strong}our privacy policy{/strong}.
#[component]
pub fn Terms() -> impl IntoView {
    view! {
        <p>
            {tr!(
                "terms.accept",
                link = |children| view! { <a href="/terms">{children}</a> },
                strong = |children| view! { <strong>{children}</strong> },
            )}
        </p>
    }
}

Give a handler for every markup element of the message, or for none. Leaving one out is a compile error, because it is almost always an oversight. With none, the message renders as its text, the markup left out. The element structure comes from the catalog, so the page waits for the catalog before it hydrates. Every client entry point on the delivery modes page does this.

Strings

In code that needs text rather than a view, such as a toast, an error value, a server function argument or format!, turn the description into a String:

[file]
saved = Saved {$name}
default-name = Untitled {$n :integer}
/// Text a person will read: isolated, as the MF2 specification requires of
/// a message formatted to a single string.
pub fn saved_notice(name: &str) -> String {
    tr!("file.saved", name = name).to_string()
}

/// Text a program will read (a file name, a comparison, the clipboard):
/// no invisible bidi marks in it.
pub fn default_file_name(n: u32) -> String {
    tr!("file.default-name", n = n).to_plain_string()
}
MethodIsolated?For
to_string(), String::from(…)yestext a person reads
to_plain_string()notext a program reads

to_display_string() still exists as another name for to_string().

These read the catalog in force when they are called. In the browser, calling one inside a closure subscribes that closure to the language, so it re-runs after a switch. A text node is always isolated, because it has no attribute name to decide by. When a text node must be plain (for example the starting text of a <textarea>), use a closure:

[draft]
body = Dear {$name},
#[component]
pub fn Draft(name: String) -> impl IntoView {
    view! {
        <textarea name="body">
            {move || tr!("draft.body", name = name.clone()).to_plain_string()}
        </textarea>
    }
}

Such a closure costs more than a text node: in the churn benchmark (cargo xtask churn), a mounted row holding one uses about 546 bytes of heap, against about 113 for a row holding a text node. Use it only where plain text is needed.

Messages as data

A Tr is a constant, so a table of commands, menu entries or errors can hold messages as plain data and format them when they are shown:

[command]
pause = Pause
resume = Resume
pub struct Command {
    pub name: &'static str,
    pub label: Tr,
}

pub const COMMANDS: &[Command] = &[
    Command { name: "pause", label: tr!("command.pause") },
    Command { name: "resume", label: tr!("command.resume") },
];

#[component]
pub fn Commands() -> impl IntoView {
    view! {
        <ul>
            {COMMANDS
                .iter()
                .map(|command| view! { <li data-command=command.name>{command.label}</li> })
                .collect_view()}
        </ul>
    }
}

When the id is only known at run time

tr! needs the id and the argument names when it is compiled. A tool that formats messages chosen by data (a preview, a test fixture, a server formatting a message named in a request) can use msg_id!("id") and TrDyn::new. TrDyn carries argument names and matches them at run time: a name the message does not have is ignored, and a variable no name matches shows its fallback text. It is not for the browser: it puts names in the wasm, which is what tr! exists to avoid. So this sample is compiled for the server only:

/// A notice the server formats in the request's language, chosen by data.
#[cfg(feature = "ssr")]
pub fn notice(kind: &str, fields: Vec<(String, String)>) -> Option<String> {
    let id = match kind {
        "saved" => msg_id!("file.saved"),
        "signed" => msg_id!("signature.text"),
        _ => return None,
    };
    Some(mf2::TrDyn::new(id, fields).to_plain_string())
}

Delivery modes

Leptos can deliver an application in four ways, and this library supports all four. They differ in what runs in the browser and in how a language switch happens:

ModeThe browser getsA language switchStart from
SSR + hydrate (the default)the whole application as wasmlive, no reloadGetting started
…with lazy routesthe same, split into chunks fetched per routelive, no reloadbelow
Islandsonly the interactive partsthe form’s ?lang= and a new page, remembered in a cookiebelow
Client-onlythe whole application, no serverlive, remembered in the browserbelow

In every mode, the wasm contains no message text, ids, argument names or plural rules. A translation edit leaves it byte-for-byte the same, so every reader’s cached copy stays valid.

SSR + hydrate

This is Getting started: the server negotiates the language, renders the page in it, and embeds the catalogs. The browser loads the page’s catalog (preloaded by the page, in parallel with the wasm), hydrates, and switches language live. Use it unless you have a reason to use one of the others.

One crate is enough. The catalogs are embedded in the server binary only: the generated module’s list of catalog files is compiled only with ssr. So a translation edit changes the server and leaves the wasm alone, with no extra build step.

Lazy routes

With cargo leptos --split, a #[lazy_route] becomes a wasm chunk of its own, fetched the first time the route is matched. Descriptions inside the chunk read the catalog the main module installed, join the same node registry, and follow a switch like everything else. When the route unmounts, they free their registry slots.

Two things change from Getting started: the route, and the entry point, hydrate_lazy. The shell is unchanged:

use leptos::prelude::*;
use leptos_meta::{MetaTags, Title, provide_meta_context};
use leptos_router::components::{A, Route, Router, Routes};
use leptos_router::{Lazy, LazyRoute, lazy_route, path};
use mf2::leptos::{CatalogLinks, CatalogPreload, LocaleSwitcher, html_lang};

mf2::include_generated!();

pub fn shell(options: LeptosOptions) -> impl IntoView {
    let (lang, dir) = html_lang();
    view! {
        <!DOCTYPE html>
        <html lang=lang dir=dir>
            <head>
                <meta charset="utf-8" />
                <meta name="viewport" content="width=device-width, initial-scale=1" />
                <AutoReload options=options.clone() />
                <HydrationScripts options />
                <MetaTags />
                <CatalogPreload />
                <CatalogLinks />
            </head>
            <body>
                <App />
            </body>
        </html>
    }
}

The application has a second route, whose view is a LazyRoute:

#[component]
pub fn App() -> impl IntoView {
    provide_meta_context();
    view! {
        <Title text=tr!("app-title") />
        <Router>
            <header>
                <LocaleSwitcher label=tr!("language.label") button=tr!("language.apply") />
            </header>
            <nav>
                <A href="/">{tr!("app-title")}</A>
                " "
                <A href="/visits">{tr!("visit-again")}</A>
            </nav>
            <main>
                <Routes fallback=|| view! { <p>{tr!("not-found")}</p> }>
                    <Route path=path!("/") view=|| view! { <h1>{tr!("greeting", name = "Ada")}</h1> } />
                    // Under `--split`, this view is a wasm chunk of its own.
                    <Route path=path!("/visits") view={Lazy::<Visits>::new()} />
                </Routes>
            </main>
        </Router>
    }
}

pub struct Visits;

#[lazy_route]
impl LazyRoute for Visits {
    fn data() -> Self {
        Visits
    }

    fn view(this: Self) -> AnyView {
        let Visits = this;
        let count = RwSignal::new(1);
        view! {
            <p role="status">{tr!("visits", count = count)}</p>
            <button on:click=move |_| *count.write() += 1>{tr!("visit-again")}</button>
        }
        .into_any()
    }
}

hydrate_lazy replaces hydrate_body. It does the same thing, and in addition, when the page being loaded is a lazy route, it loads that route’s chunk before hydration reaches it:

#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
    console_error_panic_hook::set_once();
    install();
    mf2::leptos::hydrate_lazy(App);
}

Leptos hydrates a lazy route only with its lazy feature, so the client’s feature list gains leptos/lazy. Without it, the page panics when hydration reaches the route:

[features]
hydrate = [
    "leptos/hydrate",
    "leptos/lazy",
    "mf2/hydrate",
    "dep:console_error_panic_hook",
    "dep:wasm-bindgen",
]

Run it with cargo leptos watch --split, and build it with cargo leptos build --split. The flag is not optional here: with #[lazy_route] and leptos/lazy, a build without it leaves the JavaScript importing a placeholder (__wasm_split_placeholder__) that the browser cannot resolve, and nothing hydrates on any page. This is Leptos’s behaviour, not this library’s.

Islands

In an islands application, only the components marked #[island] are compiled to wasm. Everything else renders on the server and ships no code at all. A server-only component costs the wasm nothing, however many messages it uses. cargo xtask islands-zero measured this on the islands example (2026-09-28). It adds a server-only component with a call site in every position (text, attribute, argument, markup) and compares the client with and without it. The code section was 165,705 bytes with 864 functions both times, and the data section 23,446 bytes both times, so the wasm shipped 85,726 bytes gzipped both times. The two files are not identical byte for byte, but no section grew. (An earlier run, 2026-09-27, saw the data section grow by 8 bytes: a few bytes of data at most, and no code.)

Islands change the trade-off for switching. Most of the page has no client code, so it cannot follow a live switch. The documented default is therefore the static-locale feature: the switcher’s form submits ?lang=, and the server renders the whole page in the new language, server-only parts included, and remembers the choice in the cookie. Turn on islands in Leptos and static-locale in mf2:

[dependencies]
leptos = { version = "0.9.0-beta", default-features = false, features = ["islands"] }
mf2 = { version = "2", features = ["leptos", "fn-number", "static-locale"] }

static-locale applies to the server and the client alike. Nothing on the client registers to follow the language, except a node with a signal-valued argument (it still has to re-format when the signal changes).

The islands gate. A message with markup takes its element structure from the catalog, so an island that contains one must not hydrate before the catalog has arrived. Leptos hydrates islands one at a time, in page order, and waits for any island that returns a promise. <IslandsGate/> is an empty island, placed first in <body>, that waits for the catalog. Every island after it then hydrates against the page’s catalog. It costs no extra request, and a few bytes of page:

use leptos::prelude::*;
use leptos_meta::{MetaTags, Title, provide_meta_context};
use mf2::leptos::{CatalogPreload, IslandsGate, LocaleSwitcher, html_lang};

mf2::include_generated!();

pub fn shell(options: LeptosOptions) -> impl IntoView {
    let (lang, dir) = html_lang();
    view! {
        <!DOCTYPE html>
        <html lang=lang dir=dir>
            <head>
                <meta charset="utf-8" />
                <meta name="viewport" content="width=device-width, initial-scale=1" />
                <AutoReload options=options.clone() />
                <HydrationScripts options islands=true />
                <MetaTags />
                // No `<CatalogLinks/>`: a switch reloads, and the server
                // writes the new page's preload.
                <CatalogPreload />
            </head>
            <body>
                // First, and outside every island.
                <IslandsGate />
                <App />
            </body>
        </html>
    }
}

The page is an ordinary component, so it renders on the server only. The switcher is not an island either: under static-locale its form submits ?lang=…, and the server does the rest:

#[component]
pub fn App() -> impl IntoView {
    provide_meta_context();
    view! {
        <Title text=tr!("app-title") />
        <header>
            <LocaleSwitcher label=tr!("language.label") button=tr!("language.apply") />
        </header>
        <main>
            <h1>{tr!("greeting", name = "Ada")}</h1>
            <Visits />
        </main>
    }
}

/// The one part that runs in the browser.
#[island]
fn Visits() -> impl IntoView {
    let count = RwSignal::new(1);
    view! {
        <p role="status">{tr!("visits", count = count)}</p>
        <button on:click=move |_| *count.write() += 1>{tr!("visit-again")}</button>
    }
}

The client’s entry point installs what the build generated and starts loading the catalog (hydrate_islands). The application also has to export the gate’s island function itself, because mf2 forbids the unsafe code that a #[wasm_bindgen] export expands to. islands_gate!() writes it:

#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
    console_error_panic_hook::set_once();
    install();
    mf2::leptos::hydrate_islands();
}

// The island `<IslandsGate/>` renders.
mf2::leptos::islands_gate!();

The server is the same as in Getting started. Islands change the client, not the server.

Without static-locale. The islands then register their nodes to follow a live switch, but the switch does not become live: the switcher is not an island, so pressing it still submits ?lang= and loads a new page, and an island’s state (a counter, say) starts again. The registration costs wasm and memory for nothing, so keep static-locale on an islands application.

Client-only

A client-only application has no server. The browser does everything, and any static file host can serve the site. Three things are different:

  • The language comes from the browser: the one the reader chose last time (kept in localStorage), else the one that best serves navigator.languages (fr-CA finds fr), chosen as a server chooses from Accept-Language, else the source language. A switch is live, and is remembered. To choose as a server does, by CLDR’s language-matching data, the page needs the part of that data its languages need, which the build generates, and a client-only build’s generated setup() carries. A Setup built by hand without it (.with_language_matching(&LANGUAGE_MATCHING)) finds only a locale of the reader’s own language (fr-CA still finds fr).
  • The catalogs are published beside the wasm, by mf2 compile --site. There is no server to embed them in. With csr, the build script generates only the module, so the wasm names no catalog file.
  • The page finds them through a small index, i18n/index.json. The page preloads it, so it downloads in parallel with the wasm.

The application’s manifest turns on csr everywhere:

[package]
name = "hello-csr"
version = "0.1.0"
edition = "2024"

[dependencies]
console_error_panic_hook = "0.1"
leptos = { version = "0.9.0-beta", features = ["csr"] }
leptos_meta = "0.9.0-beta"
mf2 = { version = "2", features = ["leptos", "csr", "fn-number"] }

[build-dependencies]
mf2-build = "2"

[profile.release]
opt-level = "z"
lto = "fat"
codegen-units = 1
panic = "abort"
strip = true

The build script and the messages are Getting started’s, unchanged.

The application mounts through the same gate as hydration. mount_to_body loads the index, chooses the language from those it lists, loads that language’s catalog, sets <html lang dir>, and only then mounts. If the boot fails, it logs one mf2: line and mounts nothing:

use leptos::prelude::*;
use leptos_meta::{Title, provide_meta_context};
use mf2::leptos::LocaleSwitcher;

mf2::include_generated!();

#[component]
fn App() -> impl IntoView {
    provide_meta_context();
    let count = RwSignal::new(1);
    view! {
        <Title text=tr!("app-title") />
        <header>
            <LocaleSwitcher label=tr!("language.label") button=tr!("language.apply") />
        </header>
        <main>
            <h1>{tr!("greeting", name = "Ada")}</h1>
            <p role="status">{tr!("visits", count = count)}</p>
            <button on:click=move |_| *count.write() += 1>{tr!("visit-again")}</button>
        </main>
    }
}

fn main() {
    console_error_panic_hook::set_once();
    install();
    mf2::leptos::mount_to_body(App);
}

With Trunk, index.html preloads the index:

<!DOCTYPE html>
<!-- The boot sets `lang` and `dir` to the language it chooses. -->
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Hello, MessageFormat 2</title>
    <link rel="preload" as="fetch" crossorigin="anonymous" href="i18n/index.json" data-mf2-index />
    <link data-trunk rel="rust" data-bin="hello-csr" />
  </head>
  <body></body>
</html>

and a hook publishes the catalogs into the site after each build:

[build]
target = "index.html"
dist = "dist"

[[hooks]]
stage = "post_build"
command = "sh"
command_arguments = ["-c", "mf2 compile --site \"$TRUNK_STAGING_DIR/i18n\""]

mf2 compile --site writes each catalog (with .br and .gz versions), named by its content hash, and index.json. It builds the catalogs for mf2’s features in this crate as cargo resolves them, so the catalogs and the wasm always agree on which functions exist. If you pass a --features list that disagrees with cargo’s, it fails and prints both lists. Serve the catalogs with a long cache lifetime, since their names change when their content does, and serve index.json with no-cache.

Booting costs two requests one after the other: the index, then one catalog. SSR costs one. The index starts downloading with the wasm, not after it.

trunk serve runs it locally; trunk build --release writes the site to dist/.

Switching language

This page covers how a request’s language is chosen, the switcher the library provides, what a switch does, and how to build your own control. Its samples join the call sites library, with these messages:

[language]
label = Language
apply = Apply
en = English
fr = Français

How the server chooses

On a server, mf2::axum’s Negotiator chooses each request’s language. It tries an ordered list of sources, and the first one that names a language this build has wins (fr-CA finds fr). Sinks record the result in the response — CookieLocale as a sink writes the cookie only for an explicit choice, a language that came from ?lang= or the path, never for a guess from Accept-Language or the default:

SourceReads
QueryParam?lang=fr (the name can be changed): how a link, a test, or the switcher’s form chooses
CookieLocalethe mf2_locale cookie: a choice the reader made earlier
AcceptLanguagethe browser’s languages, in quality order
PathPrefix/fr/…, for a site whose languages have their own URLs

If no source matches, the source language is used, unless .default_locale(…) names another. The response carries Content-Language and a Vary that names each source’s header, and Cookie as well, because the server also reads the reader’s time zone from the mf2_tz cookie. The server is the only place a language is negotiated. The client reads it from the page (<html lang> and the preload link), so hydration cannot choose differently.

Negotiator::default(), which Getting started uses, reads the query, then the cookie, then Accept-Language. A site with a language in its URLs puts the path first. A ?lang= then cannot change the language of /en/…, so the switcher’s form, which submits one, needs the server to send it on to the other language’s URL: path_prefix_redirect answers /en/page?lang=fr with a redirect to /fr/page. It reads the query’s name from the negotiator, so it goes under it: add it to the router before the negotiator.

/// A site whose pages live under `/en/…` and `/fr/…`.
#[cfg(feature = "ssr")]
pub fn path_negotiator() -> mf2::axum::Negotiator {
    use mf2::axum::{AcceptLanguage, CookieLocale, Negotiator, PathPrefix, QueryParam};
    Negotiator::empty()
        .source(PathPrefix)
        .source(QueryParam::default())
        .source(CookieLocale::default())
        .source(AcceptLanguage)
        .sink(CookieLocale::default())
}

/// The redirect under the negotiator, as layers on the application's router.
#[cfg(feature = "ssr")]
pub fn with_path_redirect(router: axum::Router) -> axum::Router {
    router
        .layer(axum::middleware::from_fn(mf2::axum::path_prefix_redirect))
        .layer(path_negotiator())
}

Such a site should also tell search engines about the other languages’ URLs. <AlternateLinks/> in the <head> writes one <link rel="alternate" hreflang> per language, plus an x-default:

#[component]
pub fn Alternates() -> impl IntoView {
    view! {
        <mf2::leptos::AlternateLinks href_of=|tag| format!("https://example.com/{tag}/") />
    }
}

The server’s options

Negotiator::default() is what a site gets when it says nothing more: ?lang=, the cookie, then Accept-Language, with the cookie as its sink, Secure except in a debug build. A site that wants another order or another source starts from Negotiator::empty() and lists its sources. Each part can be changed:

  • QueryParam("hl") reads another parameter name than lang. QueryParam::default() is lang. <LocaleSwitcher>’s form submits under the name of the negotiator’s first QueryParam.
  • CookieLocale’s fields: name (mf2_locale), max_age (a year, in seconds), path (/), same_site (Lax) and secure (true). The client writes the cookie under mf2_locale on every switch, so a different name is for an extra source that reads a cookie another system wrote, listed after the default one. A Secure cookie is not stored from a page served over plain HTTP — browsers make an exception for 127.0.0.1 and localhost — so a development server reached without TLS at any other address (a phone on the local network, say) sets secure: false (Negotiator::default() ties it to debug_assertions).
  • .default_locale("fr") answers a request no source matched in French instead of the source language, if the build has French.
  • Negotiator::over(locales, default) starts from an explicit table of tags and directions instead of the build’s, for a site that offers fewer languages than it built, or a test. Negotiator::locales() (and, anywhere on the server or the client, mf2::leptos::locales()) returns the table in use: the tags and their directions, in build order — for a sitemap or a list of hreflang links.
/// `?hl=` first, a cookie an older version of the site wrote after this
/// library's own, and French for a request nothing matches.
#[cfg(feature = "ssr")]
pub fn custom_negotiator() -> mf2::axum::Negotiator {
    use mf2::axum::{AcceptLanguage, CookieLocale, Negotiator, QueryParam};
    Negotiator::empty()
        .source(QueryParam("hl"))
        .source(CookieLocale::default())
        .source(CookieLocale {
            name: "site_lang",
            ..CookieLocale::default()
        })
        .source(AcceptLanguage)
        .sink(CookieLocale {
            secure: !cfg!(debug_assertions),
            ..CookieLocale::default()
        })
        .default_locale("fr")
}

What was negotiated. provide_locale returns it, and mf2::axum::negotiated() gives it to any component that renders in the request: the tag, its direction, from (the source that matched, such as "query" or "cookie", or "default") and matched.

/// Says where the page's language came from, on the server.
#[cfg(feature = "ssr")]
#[component]
pub fn LanguageOrigin() -> impl IntoView {
    let from = mf2::axum::negotiated().map_or("default", |n| n.from);
    view! { <meta name="language-origin" content=from /> }
}

Your own sources and sinks. A source implements LocaleSource: a name, the request header it reads (for Vary), and the tags a request offers, best first. A sink implements LocaleSink: a name, and the header it adds to the response for what was negotiated. A subdomain as a source:

/// `fr.example.com` → `fr`.
#[cfg(feature = "ssr")]
#[derive(Debug)]
pub struct Subdomain;

#[cfg(feature = "ssr")]
impl mf2::axum::LocaleSource for Subdomain {
    fn name(&self) -> &'static str {
        "subdomain"
    }

    fn vary(&self) -> Option<axum::http::HeaderName> {
        Some(axum::http::header::HOST)
    }

    fn candidates<'r>(
        &self,
        parts: &'r axum::http::request::Parts,
        out: &mut Vec<std::borrow::Cow<'r, str>>,
    ) {
        let host = parts.headers.get(axum::http::header::HOST).and_then(|h| h.to_str().ok());
        if let Some((first, _)) = host.and_then(|h| h.split_once('.')) {
            out.push(std::borrow::Cow::Borrowed(first));
        }
    }
}

The switcher

<LocaleSwitcher> is the switcher the library provides. It is a <form method="get">. Inside it are a <select name="lang"> (named for the negotiator’s QueryParam) in its own <label>, and a submit button whose text you supply. With no children it offers every language the build has, each named by its language.<tag> message, so adding a language needs no code:

#[component]
pub fn Header() -> impl IntoView {
    view! {
        <header>
            <mf2::leptos::LocaleSwitcher label=tr!("language.label") button=tr!("language.apply") />
        </header>
    }
}

A language without a language.<tag> message is shown by its tag, and the build warns about it.

Nothing happens until the button is pressed. A <select> fires change on every arrow key, so a switcher that switched on change would change the page’s language at each keypress while a keyboard user moved through the list (WCAG 3.2.2). What pressing the button does depends on what the page runs:

The pagePressing the button
hydrated (hydrate) or client-only (csr)switches live, with focus left on the button
hydrated with static-localewrites the cookie and reloads in the new language
not yet hydrated, hydration failed, or no wasmsubmits ?lang=fr, which the server negotiates

The last row is why it is a form. The switcher works before the wasm arrives and when it never does, and on an islands page it needs no island: there the switcher is not an island, so no client code runs, and the form’s ?lang= and the server’s cookie are the switch.

On a site whose languages live in its URLs, give the switcher href_of, the URL of the current page in a language — the shape <AlternateLinks/> takes. Each option then carries its language’s URL, and pressing the button goes there instead of switching in place. Without the wasm, the form’s ?lang= goes to the server, and path_prefix_redirect (above) sends it on to the same URL. This sample also makes a list of its own: <LocaleOption> children, whose tag is the generated Locale or a tag as a string:

/// This page's URL in `tag`, on a site under `/en/…` and `/fr/…`.
fn account_href(tag: &str) -> String {
    format!("/{tag}/account")
}

#[component]
pub fn AccountHeader() -> impl IntoView {
    view! {
        <mf2::leptos::LocaleSwitcher
            label=tr!("language.label")
            button=tr!("language.apply")
            href_of=account_href
        >
            <mf2::leptos::LocaleOption tag=Locale::En>{Locale::En.name()}</mf2::leptos::LocaleOption>
            <mf2::leptos::LocaleOption tag="fr">{tr!("language.fr")}</mf2::leptos::LocaleOption>
        </mf2::leptos::LocaleSwitcher>
    }
}

The option for the page’s language is selected in the server’s HTML. Each option carries its own lang, so a screen reader reads “Français” with a French voice. Each option’s text is a message: language.fr is Français in every catalog, so each language is named in its own language, and none of the names is compiled into the wasm. The switcher has no fixed id, so a page can have two (a header and a footer).

The form has the class mf2-locale-switcher. A flexbox layout that works in both directions:

.mf2-locale-switcher {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}

.mf2-locale-switcher label {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

What a switch does

A live switch (set_locale(Locale::Fr), generated beside tr!) does this, in order:

  1. it finds the French catalog’s URL: from the page’s <CatalogLinks/> if the shell renders them, or else by asking GET /i18n/fr, which redirects to it (one extra round trip, only at switch time). A client-only application finds it in the site’s index.json, and has no /i18n/<tag> to ask;
  2. it fetches the catalog and checks it against the build. A catalog from another deploy (the server was redeployed since the page loaded) is never read: the choice is remembered as in step 6, and the page reloads into the new language, which the new deploy’s server renders;
  3. it installs the catalog and rewrites every registered text node, attribute and markup fragment directly, with no effect per call site;
  4. it notifies derived props (TextProp, Signal<String>) and closures that read a string;
  5. it sets <html lang dir>, so an Arabic page turns right-to-left from the dir attribute alone;
  6. it remembers the choice for the next visit: the cookie on a server-rendered page, localStorage in a client-only application.

If any other step before the install fails, the page stays as it was and set_locale logs one line, beginning mf2:, to the console.

Your own control

The simplest control that works everywhere is a link per language. It navigates, so the page reloads in the new language, and it needs no client code:

#[component]
pub fn LanguageLinks() -> impl IntoView {
    view! {
        <nav aria-label=tr!("language.label")>
            <ul class="languages">
                <li><a href="?lang=en" hreflang="en" lang="en">{tr!("language.en")}</a></li>
                <li><a href="?lang=fr" hreflang="fr" lang="fr">{tr!("language.fr")}</a></li>
            </ul>
        </nav>
    }
}

To switch live from your own control, call the generated set_locale. preload_locale fetches and checks a catalog without switching to it. Call it when the pointer or keyboard focus reaches a control, so that the switch itself is instant. Both are callable on the server too, where they do nothing, so a component needs no #[cfg]:

/// A button that switches live. It needs client code, so it does nothing
/// before hydration: prefer `LocaleSwitcher` unless the page cannot work
/// without the wasm anyway.
#[component]
pub fn SwitchButton(lang: Locale, children: Children) -> impl IntoView {
    view! {
        <button
            type="button"
            lang=lang.tag()
            on:click=move |_| set_locale(lang)
            on:pointerenter=move |_| preload_locale(lang)
            on:focus=move |_| preload_locale(lang)
        >
            {children()}
        </button>
    }
}

The current language

The generated current_locale() is the language the page is in. In the browser it is reactive: a view or an effect that reads it follows the next switch. On the server it is the request’s language. As a closure it keeps an attribute in step, content=move || current_locale().tag(); the accessibility page uses it for schema.org’s inLanguage.

Native CLI and Ratatui apps

The call sites tr! builds are plain values: a command-line tool or a terminal UI formats them as well as a web page does. A native application names two crates of this library, and no more:

  • mf2, with the native feature — or ratatui, which implies it — and the features of the functions its messages call (fn-number here);
  • mf2-build, in its build script, whose whole body is mf2_build::run().

The messages sit in locales/ beside the code that uses them: there is no separate translation crate, and no mf2.toml — its defaults (the source language is en; a message missing from a translation falls back to it) are what these applications want. Nothing passes a handle, a style map or a language tag around.

This page builds three applications, and cargo xtask docs compiles each:

  • count, a one-file command-line tool;
  • hops, a Ratatui terminal UI with a language menu and a live switch;
  • trace, a workspace in which a library owns the messages and a terminal UI draws them.

The first two are what mf2 init --cli and mf2 init --tui write (see the command line): the files below are theirs, and the book checks that they match. Write them by hand, or let init write them.

A command-line tool

count counts the files in a directory and says so in the reader’s language. Its manifest names mf2 with native and fn-number, and mf2-build for the build script:

[package]
name = "count"
version = "0.1.0"
edition = "2024"

[dependencies]
clap = { version = "4", features = ["derive"] }
mf2 = { version = "2", features = ["native", "fn-number"] }

[build-dependencies]
mf2-build = "2"

The rest of the manifest only makes rebuilding quicker: the build script compiles the messages again after every edit to them, and built optimized it does so faster. An application may leave it out.

# The build script compiles the messages again after every edit to
# them: built optimized, it does so faster.
[profile.dev.build-override]
opt-level = 2

The build script reads locales/, checks every message, and generates a module with the catalogs embedded in the executable:

fn main() {
    mf2_build::run();
}

Like cargo new, it keeps what the build writes out of version control:

/target

One file per language. A plural’s variants follow each language’s own rules: French has a many form that English has not.

@locale en
---

files =
  .input {$count :integer}
  .match $count
  0   {{{$dir} is empty.}}
  one {{{$dir} holds one file.}}
  *   {{{$dir} holds {$count} files.}}

unreadable = Cannot read {$dir}: {$error}
@locale fr
---

files =
  .input {$count :integer}
  .match $count
  0    {{{$dir} est vide.}}
  one  {{{$dir} contient {$count} fichier.}}
  many {{{$dir} contient {$count} de fichiers.}}
  *    {{{$dir} contient {$count} fichiers.}}

unreadable = Impossible de lire {$dir} : {$error}

mf2::include_generated!() brings in what the build script generated: tr!, the Locale enum with one variant per language, install() and set_locale.

//! Counts the files in a directory, and says so in the reader's language.

use std::path::PathBuf;
use std::process::ExitCode;

use clap::Parser;

mf2::include_generated!();

Locale parses from a string, so clap takes --lang as one. The parse goes through the same language matcher as the system’s languages do: --lang fr_CA.UTF-8 is French, and --lang de is refused with the languages the application has.

#[derive(Parser)]
struct Args {
    /// The directory to count.
    #[arg(default_value = ".")]
    dir: PathBuf,
    /// The language to answer in, instead of the system's.
    #[arg(long)]
    lang: Option<Locale>,
}

fn main() -> ExitCode {
    let args = Args::parse();
    install();
    if let Some(lang) = args.lang {
        set_locale(lang);
    }
    match std::fs::read_dir(&args.dir) {
        Ok(entries) => {
            println!("{}", tr!("files", dir = &args.dir, count = entries.count()));
            ExitCode::SUCCESS
        }
        Err(error) => {
            eprintln!("{}", tr!("unreadable", dir = &args.dir, error = error));
            ExitCode::FAILURE
        }
    }
}
  • install() loads the catalogs the executable embeds, once, and chooses the first of the system’s languages the application has. It cannot fail: catalogs from the same build always load. A process holds one message set: installing a second corpus panics, and mf2::native::Catalogs formats another (catalogs.format("fr", &message)).
  • set_locale overrides that choice, for every thread.
  • println!("{}", tr!(…)) formats the message in the language in force. tr!(…).to_string() gives the text as a String.
  • The arguments are Rust values: dir is a path and count a number, which :integer formats in the reader’s way. error is an io::Error, which has no typed form; its text is the argument.

cargo run -- --lang fr prints the French.

Two settings differ from the web

  • Bidi isolation is off. MF2 can wrap a placeholder whose direction could differ from the message’s in invisible isolation characters, which terminals and logs tend to show as stray characters. An application that shows right-to-left text in a terminal that honours them turns them on with mf2::native::set_bidi.
  • Dates use the system’s time zone — its IANA name when it has one, else a zone that follows the system’s daylight-saving rules, else UTC. mf2::native::set_time_zone changes it.

Catalogs outside the executable

A build script that asks mf2_build::Build for Emit::NativeFiles, in place of run(), embeds no catalog. The generated install_from_directory(dir) then loads the files, named <locale>.<hash>.mf2b, from a directory the application chooses. Each file is checked as it loads, its bytes against the hash in its name, so a catalog from another build is an error rather than wrong text. mf2 compile --out DIR, given the features the application builds with, writes the same files for a package to ship.

A terminal UI

hops draws a trace in the shape of a network-diagnostic tool: a bordered table, a language menu, a key-hint bar with styled keys, a status line with a plural and numbers. 1 and 2 switch the language while it runs. Its manifest turns on ratatui in place of native:

[package]
name = "hops"
version = "0.1.0"
edition = "2024"

[dependencies]
clap = { version = "4", features = ["derive"] }
mf2 = { version = "2", features = ["ratatui", "fn-number"] }
ratatui = "0.30"

[build-dependencies]
mf2-build = "2"

# The build script compiles the messages again after every edit to
# them: built optimized, it does so faster.
[profile.dev.build-override]
opt-level = 2

Its build script and .gitignore are the command-line tool’s:

fn main() {
    mf2_build::run();
}
/target

The messages mark up what a stretch of text is — a key, a host, a warning — and say nothing of how it looks. The translation decides where it goes:

@locale en
---

title = Trace to {#host}{$target}{/host}
languages = Language
hints = {#key}1{/key} {#key}2{/key} language · {#key}q{/key} quit

status =
  .input {$hops :integer}
  .match $hops
  one {{{$hops} hop · probes: {$sent :integer} · loss: {$loss :percent maximumFractionDigits=1}}}
  *   {{{$hops} hops · probes: {$sent :integer} · loss: {$loss :percent maximumFractionDigits=1}}}

[column]
hop = #
host = Host
loss = Loss
average = Avg (ms)

[cell]
no-reply = {#warn}no reply{/warn}
loss = {$share :percent maximumFractionDigits=1}
ms = {$ms :number minimumFractionDigits=1 maximumFractionDigits=1}

# Each language's name, in that language: the same in every file.
@do-not-translate
[language]
en = English
fr = Français
@locale fr
---

title = Trace vers {#host}{$target}{/host}
languages = Langue
hints = {#key}1{/key} {#key}2{/key} langue · {#key}q{/key} quitter

status =
  .input {$hops :integer}
  .match $hops
  one  {{{$hops} saut · sondes : {$sent :integer} · perte : {$loss :percent maximumFractionDigits=1}}}
  many {{{$hops} de sauts · sondes : {$sent :integer} · perte : {$loss :percent maximumFractionDigits=1}}}
  *    {{{$hops} sauts · sondes : {$sent :integer} · perte : {$loss :percent maximumFractionDigits=1}}}

[column]
hop = #
host = Hôte
loss = Perte
average = Moy. (ms)

[cell]
no-reply = {#warn}pas de réponse{/warn}
loss = {$share :percent maximumFractionDigits=1}
ms = {$ms :number minimumFractionDigits=1 maximumFractionDigits=1}

@do-not-translate
[language]
en = English
fr = Français

The language section names each language in itself. When every language has its language.<tag> message, the generated Locale has a name() that returns it, for a language menu.

main chooses the language as the command-line tool does, then sets the theme: how each markup name is drawn. markup::… holds a constant for every name the messages use, so a misspelt name does not compile.

//! A trippy-shaped terminal UI in the reader's language. `1` and `2` switch
//! the language live; `q` quits.

mod ui;

use clap::Parser;
use mf2::ratatui::{Theme, set_theme};
use ratatui::crossterm::event::{self, Event, KeyCode};
use ratatui::style::Style;

mf2::include_generated!();

#[derive(Parser)]
struct Args {
    /// The language to draw in, instead of the system's.
    #[arg(long)]
    lang: Option<Locale>,
}

fn main() -> std::io::Result<()> {
    let args = Args::parse();
    install();
    if let Some(lang) = args.lang {
        set_locale(lang);
    }
    // What each markup name looks like: a message says what a stretch is,
    // the theme how it is drawn. `markup::…` has a constant for every name
    // the messages use, so a misspelt name does not compile.
    set_theme(
        Theme::default()
            .style(markup::KEY, Style::new().bold().yellow())
            .style(markup::HOST, Style::new().underlined())
            .style(markup::WARN, Style::new().red()),
    );
    let trace = ui::Trace::sample();
    ratatui::run(|terminal| {
        loop {
            terminal.draw(|frame| ui::draw(frame, &trace))?;
            if let Event::Key(key) = event::read()? {
                match key.code {
                    // Every thread's next format is in the new language.
                    KeyCode::Char('1') => set_locale(Locale::En),
                    KeyCode::Char('2') => set_locale(Locale::Fr),
                    KeyCode::Char('q') => return Ok(()),
                    _ => {}
                }
            }
        }
    })
}
  • set_theme sets the app-wide theme, once; every thread’s next draw uses it. Theme::default() already draws b and strong bold, i and em italic, u underlined, s and del crossed out, and code and kbd reversed; .style(…) adds or replaces one name’s style. mf2::ratatui::with_theme sets one for a scope on this thread.
  • set_locale(Locale::Fr) is the live switch: the next frame is French, on every thread.
  • mod ui is declared before the include. It imports tr! with the crate’s prelude, use crate::prelude::*, which any module may do.
//! One frame: the hops in a bordered table, a language menu, a key-hint bar
//! and a status line. Nothing is passed for translation: the text is in the
//! current language, and the theme says how markup looks.

use ratatui::Frame;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::Stylize;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Cell, List, Row, Table};

use crate::prelude::*;

/// A trace in progress.
pub struct Trace {
    pub target: &'static str,
    pub sent: u64,
    pub hops: Vec<Hop>,
}

/// One hop: the host that answered, if one did; the share of probes lost;
/// the average round trip in milliseconds.
pub struct Hop {
    pub host: Option<&'static str>,
    pub loss: f64,
    pub average: f64,
}

impl Trace {
    /// A trace to draw.
    pub fn sample() -> Trace {
        let hop = |host, loss, average| Hop { host, loss, average };
        Trace {
            target: "example.org",
            sent: 1204,
            hops: vec![
                hop(Some("router.lan"), 0.0, 1.1),
                hop(None, 1.0, 0.0),
                hop(Some("example.org"), 0.021, 45.6),
            ],
        }
    }

    /// The share of probes lost over the whole path.
    fn loss(&self) -> f64 {
        self.hops.iter().map(|hop| hop.loss).sum::<f64>() / self.hops.len() as f64
    }
}

A description converts to Ratatui’s Span, Line and Text, so Ratatui takes it wherever it takes text: a Row of headers, a Cell, a block’s title, a Paragraph, a List.

/// Draws the frame.
pub fn draw(frame: &mut Frame, trace: &Trace) {
    let [main, hints, status] = Layout::vertical([
        Constraint::Fill(1),
        Constraint::Length(1),
        Constraint::Length(1),
    ])
    .areas(frame.area());
    let [hops, menu] =
        Layout::horizontal([Constraint::Fill(1), Constraint::Length(16)]).areas(main);
    // A bordered table: a title with markup, headers, and cells in the
    // reader's number format.
    let header = Row::new([
        tr!("column.hop"),
        tr!("column.host"),
        tr!("column.loss"),
        tr!("column.average"),
    ])
    .bold();
    let rows = trace.hops.iter().enumerate().map(|(i, hop)| {
        let host = match hop.host {
            Some(name) => Cell::from(name),
            None => Cell::from(tr!("cell.no-reply")),
        };
        Row::new([
            Cell::from((i + 1).to_string()),
            host,
            Cell::from(tr!("cell.loss", share = hop.loss)),
            Cell::from(tr!("cell.ms", ms = hop.average)),
        ])
    });
    let widths = [
        Constraint::Length(3),
        Constraint::Fill(1),
        Constraint::Length(8),
        Constraint::Length(10),
    ];
    let table = Table::new(rows, widths)
        .header(header)
        .block(Block::bordered().title(tr!("title", target = trace.target)));
    frame.render_widget(table, hops);

Markup is drawn by the theme: the host in the title is underlined, and “no reply” is red. The French title puts the host where French wants it, with no change to the code. Nested elements patch their styles in order; a name the theme lacks keeps the style around it; standalone markup ({#name/}) draws nothing. Ratatui’s own Stylize works on a description too: .bold() gives a Line that keeps the message’s styles over bold.

Locale::ALL, name() and current_locale() make the language menu:

    // The language menu: each language named in itself, the current one bold.
    let items = Locale::ALL.iter().enumerate().map(|(i, &lang)| {
        let item = Line::from(vec![Span::raw(format!("{} ", i + 1)), Span::from(lang.name())]);
        if lang == current_locale() {
            item.bold()
        } else {
            item
        }
    });
    let list = List::new(items).block(Block::bordered().title(tr!("languages")));
    frame.render_widget(list, menu);

The key-hint bar is one message, so that the translation places the keys:

    // The key-hint bar is one message, so a translation places the keys.
    frame.render_widget(Line::from(tr!("hints")).right_aligned(), hints);

    // The status line: a plural, a count and a percentage.
    let summary = tr!(
        "status",
        hops = trace.hops.len(),
        sent = trace.sent,
        loss = trace.loss(),
    );
    frame.render_widget(Line::from(summary), status);
}

Lines, spans and markup

  • A Text starts a new line at each line break in the message; a Line and a Span join the lines with a space.
  • A Span has one style, so it keeps none of the message’s markup. That has a trap: Ratatui collects anything that converts into a Span into a Line, so [tr!("a"), tr!("b")].into_iter().collect::<Line>() compiles, and loses both messages’ styles. To keep them, write one message for the styled line, as hints is — the translation then places the pieces — or extend a Line with each message’s Line::from(tr!(…)).spans.

A conversion borrows the catalog’s text from the executable: a message with no placeholders makes a Span without allocating, and each placeholder is one String.

A library and its terminal UI

trace is a workspace of two crates. The library, trace-core, owns the messages: build.rs and locales/ are its. The terminal UI uses the library’s tr!, Locale and install(); there is no third crate for the translations.

[workspace]
members = ["core", "tui"]
resolver = "3"

The library turns on native and the functions its messages call:

[package]
name = "trace-core"
version = "0.1.0"
edition = "2024"

[dependencies]
mf2 = { version = "2", features = ["native", "fn-number"] }

[build-dependencies]
mf2-build = "2"
fn main() {
    mf2_build::run();
}
@locale en
---

title = Trace to {#host}{$target}{/host}

summary =
  .input {$sent :integer}
  .match $sent
  one {{{$sent} probe sent · loss {$loss :percent maximumFractionDigits=1}}}
  *   {{{$sent} probes sent · loss {$loss :percent maximumFractionDigits=1}}}

[error]
resolve = Could not resolve {$host}.
permission = Tracing needs privileges: run as root, or use unprivileged mode.
@locale fr
---

title = Trace vers {#host}{$target}{/host}

summary =
  .input {$sent :integer}
  .match $sent
  one  {{{$sent} sonde envoyée · perte {$loss :percent maximumFractionDigits=1}}}
  many {{{$sent} de sondes envoyées · perte {$loss :percent maximumFractionDigits=1}}}
  *    {{{$sent} sondes envoyées · perte {$loss :percent maximumFractionDigits=1}}}

[error]
resolve = Impossible de résoudre {$host}.
permission = Le traçage demande des privilèges : lancez-le en root, ou en mode non privilégié.

The library returns descriptions — mf2::TrArgs, what tr! builds — rather than text, and formats its own errors. Neither takes a handle: an error prints in the language the application chose, because the process has one store of catalogs and one language.

//! What a trace knows, and every message the program shows. The terminal UI
//! uses this crate's `tr!`, `Locale` and `install()`.

use std::fmt;

mf2::include_generated!();

/// A trace in progress.
pub struct Trace {
    pub target: String,
    pub sent: u64,
    pub lost: u64,
}

impl Trace {
    /// The trace's title: its target, marked as a host.
    pub fn title(&self) -> mf2::TrArgs {
        tr!("title", target = &self.target)
    }

    /// One line about the trace so far.
    pub fn summary(&self) -> mf2::TrArgs {
        let loss = self.lost as f64 / self.sent.max(1) as f64;
        tr!("summary", sent = self.sent, loss = loss)
    }
}

/// Why a trace cannot start, in the reader's language.
#[derive(Debug)]
pub enum Error {
    Resolve { host: String },
    Permission,
}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Error::Resolve { host } => write!(f, "{}", tr!("error.resolve", host = host)),
            Error::Permission => write!(f, "{}", tr!("error.permission")),
        }
    }
}

impl std::error::Error for Error {}

The terminal UI names mf2 only for ratatui. Cargo unifies the two crates’ features in the workspace, and the library’s build script sees the unified set, so trace_core::markup exists when the terminal UI is built.

[package]
name = "trace-tui"
version = "0.1.0"
edition = "2024"

[dependencies]
clap = { version = "4", features = ["derive"] }
mf2 = { version = "2", features = ["ratatui"] }
ratatui = "0.30"
trace-core = { path = "../core" }
//! The terminal UI: the library's trace and messages, drawn with Ratatui.

use clap::Parser;
use mf2::ratatui::{Theme, set_theme};
use ratatui::crossterm::event::{self, Event, KeyCode};
use ratatui::style::Style;
use ratatui::widgets::{Block, Paragraph};
use trace_core::Trace;
use trace_core::prelude::*;

#[derive(Parser)]
struct Args {
    /// The host to trace.
    target: String,
    /// The language to draw in, instead of the system's.
    #[arg(long)]
    lang: Option<Locale>,
}

fn main() -> std::io::Result<()> {
    let args = Args::parse();
    trace_core::install();
    if let Some(lang) = args.lang {
        set_locale(lang);
    }
    set_theme(Theme::default().style(trace_core::markup::HOST, Style::new().underlined()));
    let trace = Trace {
        target: args.target,
        sent: 1204,
        lost: 25,
    };
    ratatui::run(|terminal| {
        loop {
            terminal.draw(|frame| {
                let block = Block::bordered().title(trace.title());
                frame.render_widget(Paragraph::new(trace.summary()).block(block), frame.area());
            })?;
            if let Event::Key(key) = event::read()? {
                match key.code {
                    KeyCode::Char('1') => set_locale(Locale::En),
                    KeyCode::Char('2') => set_locale(Locale::Fr),
                    KeyCode::Char('q') => return Ok(()),
                    _ => {}
                }
            }
        }
    })
}

Every module of either crate imports the library’s prelude, as ui.rs does in hops.

A library shared with a browser client must not turn on native: a browser build refuses it. Such a library returns descriptions and leaves each application to choose its mode. This one serves a terminal UI only.

The command line

mf2 is the command for an application’s messages: it makes a starter, checks the messages, formats their files, compiles its catalogs, and moves translations in and out. Install it once:

cargo install mf2-cli

Every command works on the crate that holds the messages (the directory with locales/, and mf2.toml if it has one), which is the current directory or the one -C DIR names. It needs no cargo and no build: a translator’s machine, a CI job or an editor can run it. On this page it runs in the application of Getting started, and cargo xtask docs runs each command and checks that it prints and writes what the page shows.

A command exits with 0 when it has nothing to report, and 1 when it reports an error (or a warning, under --deny-warnings) or could not run; a flag it does not know is refused before it starts. What it prints is for a person and may be worded differently in a later release: a script should read the exit status, or ask for --format json where a command offers it.

init: a starter

mf2 init --cli

makes a complete command-line application in the current directory, which must be empty (or name a new one: mf2 init --cli count). It counts the files in a directory and says so in English or French; cargo run -- --lang fr tries it. Native applications shows and explains every file it writes.

mf2 init --tui

makes a Ratatui terminal UI instead: a bordered table, a language menu, a key-hint bar and a status line, with 1 and 2 switching the language while it runs. Both are the shapes the native applications page explains.

Three more make a Leptos application, each in one crate, with its messages in locales/ beside its code:

mf2 init --ssr

makes a server-rendered application that hydrates in the browser, built with cargo-leptos: cargo leptos watch serves it on http://127.0.0.1:3000. Its page shows a greeting, a counter whose text is a plural, and a language switcher that changes the language live.

mf2 init --islands

makes the same page as an islands application: only the counter runs in the browser, and a switch loads the page again in the new language.

mf2 init --csr

makes a client-only application, built with Trunk: trunk serve builds it, and its Trunk.toml runs mf2 compile --site after each build, to publish the catalogs beside the wasm.

Every new application init makes has a .gitignore, as cargo new’s do, that keeps what the build writes out of version control: /target, and for the client-only one Trunk’s /dist too.

In a directory that holds a crate already, each mode adds translations to it instead: a build script, locales/en/main.mf2, and mf2 (with the mode’s features) and mf2-build through cargo add. It prints what is left to write, which it cannot write into an existing manifest or code: for a Leptos application, the lines that forward its ssr and hydrate features to mf2, the include, install() on each side and a first tr!; and, for the workspace’s root manifest, the optional [profile.dev.build-override] setting that makes rebuilding the messages quicker (Native CLI and Ratatui apps). --locale adds a language, --source-locale changes the source language from en, --no-messages leaves the messages out, and --force writes over files that are there.

Without a mode, init lists the five and changes nothing.

check: every check the build makes

mf2 check --features fn-number
mf2 check: 9 messages in 2 locales, nothing to report

check parses every file, checks every message against the source language’s, and runs the lints mf2.toml configures, exactly as the build script does. Which functions a message may use depends on mf2’s features in the crate, so check uses the features cargo resolves for the application’s build, from what cargo has already fetched for this machine (it never downloads). --features names them instead (as above). Where cargo cannot answer — no Cargo.toml, or dependencies not fetched yet — check says so in one line and checks as if every function were on, so that it reports none as missing that the build may have.

  • --deny-warnings makes a warning fail the command, as it fails a build whose lints are raised to errors.
  • --format json prints the findings as JSON, for an editor or CI.
  • --src DIR (repeatable) also reads the Rust sources in DIR, and reports a message no tr! uses (unused-id).

A finding names its file, line and column, the problem, and its code in brackets: missing-translation, for one, names the first ten missing ids of each language. mf2.toml’s [lints] raises or lowers a code; Lints explains each one, with an example and its fix.

A message the source marks @do-not-translate — a brand, or a language’s own name — needs no translation. A language that does not have it shows the source’s, and it is not counted as missing, here or in stats; one that copies it has to copy it exactly (do-not-translate). The mark on a [section], or at the top of a file, covers every message under it.

A translation that leaves out markup its source message has is an error, dropped-markup: Accept our {#link}terms{/link}. translated as Acceptez nos conditions. would take the link away from French readers. One variant of a message may leave the markup out as long as another keeps it, and a corpus that drops emphasis on purpose lowers the code with dropped-markup = "warn" under [lints].

fmt: one layout for every file

mf2 fmt --check
mf2 fmt: 0 of 2 file(s) would change

fmt writes every .mf2 file under locales/ (or the paths it is given) in one layout, so that a diff shows what changed and not how it was typed: a blank line after the --- that ends the file’s header, one around a message that starts on its own line (a .match), and none anywhere else; an entry’s value as the message’s own canonical form. --check changes nothing and exits with 1 if a file would change. The other commands that write .mf2 files (convert, import, pseudo) write this layout too.

compile: the catalogs

mf2 compile --features fn-number --out catalogs
mf2 compile: 9 messages, 2 locales to catalogs (8 file(s) changed)

writes what the build script writes — each language’s catalog (with .br and .gz versions), the manifest, and the generated module — into a directory of your choosing. It is what a client-only site publishes (with --site DIR, which also writes index.json and takes its features from cargo; see Delivery modes) and what a native application ships (Native apps). Give it the features the application builds the crate with: they decide what a catalog holds, and its content-hashed name. -v lists the files.

stats, dump: what is in a catalog

mf2 stats prints, for each language, how many of the messages that need translating it has and lacks (not those marked @do-not-translate), and its catalog’s size raw, gzipped and brotli-compressed; then the locale data each catalog carries, entry by entry. --format json for a dashboard. Like check, it uses the features cargo resolves for the application’s mf2, or those --features names.

mf2 dump <file.mf2b> prints a compiled catalog back as MF2 (or, with --format json, as data), all of it or one message (--id), reading ids from the manifest (--manifest) when the catalog was built without them.

export, import: translations in and out

Translating follows a team through a round of this, from export to the checks in CI.

A translation tool or a translator who does not work in the repository gets one language as a file, and gives it back. As JSON, one message per id:

mf2 export fr -o fr.json
{
  "app-title": "Bonjour, MessageFormat 2",
  "greeting": "Bonjour, {$name} !",
  "language.apply": "Appliquer",
  "language.en": "English",
  "language.fr": "Français",
  "language.label": "Langue",
  "not-found": "Il n’y a rien ici.",
  "visit-again": "Revenir",
  "visits": ".input {$count :integer}\n.match $count\none  {{Vous êtes venu une fois.}}\nmany {{Vous êtes venu {$count} fois.}}\n*    {{Vous êtes venu {$count} fois.}}"
}

or as XLIFF 2, which translation tools read, with the source text beside each translation, placeholders as <ph> elements the tool keeps intact, and a .match as a group of its variants:

mf2 export fr --format xliff -o fr.xlf

import reads either back (it tells them apart by their content) into the language’s .mf2 files as they stand: only the messages’ values change, and the files keep their sections, comments and properties. --dry-run says what would change and writes nothing:

mf2 import fr fr.json --dry-run
mf2 import: 0 message(s) would change in fr

Before it writes anything, import runs every check check makes on the files as they would be, with the same features (--features names them). A translation that would bring an error — a variable its source does not declare, a link left out — is reported as check reports it, and nothing is written; an error the files already had does not stop it. An XLIFF document also adds the messages the language does not have yet, in the file and section where the source has them. JSON changes only the messages the language has: it names the ones it leaves out, and exits with 1.

pseudo: find what is not translated

mf2 pseudo --dry-run
would write ./locales/en-XA/main.mf2
mf2 pseudo: en-XA — 9 message(s) in 1 file(s) (dry run)
would write ./locales/ar-XB/main.mf2
mf2 pseudo: ar-XB — 9 message(s) in 1 file(s) (dry run)

writes two pseudo-languages made from the source messages. en-XA is the text in brackets, accented letter for letter and about 30 % longer, so a string left untranslated, or a layout with no room for a longer language, stands out. ar-XB forces each run of text right to left, so a page that does not apply the language’s direction shows its text reversed. Placeholders and markup are left alone. --locale writes one of the two.

watch: rebuild on every edit

mf2 watch --out DIR compiles like compile (into dist if no --out is given), then again each time a file under locales/ changes, for a development server that serves the catalogs from DIR. A cargo leptos watch does not need it: its watch-additional-files rebuilds the application instead (Getting started).

convert: from Fluent

mf2 convert --from leptos-fluent converts a leptos-fluent application — its .ftl files and its call sites — and has its own page. --from fluent converts .ftl files only, for a project that does not use leptos-fluent:

mf2 convert --from fluent locales-ftl

locales-ftl holds one directory per language (locales-ftl/fr/*.ftl), and each .ftl file becomes a .mf2 file named after it, in the crate’s locales/. A second run over the same files writes nothing and says the files are unchanged; a file it would write that already holds other text stops it, so nothing is overwritten.

A message converts to the MF2 message that formats as fluent-bundle formatted it, for the same arguments. What cannot be converted that way is reported with the file, line and column, and a code; the conversion exits with 1 until none is an error:

CodeLevelWhat
fluent-junkerrortext Fluent itself could not parse
fluent-missing-referenceerrora reference to a message, attribute or term that does not exist
fluent-cyclic-referenceerrormessages or terms that refer to each other in a loop
fluent-unknown-functionerrora function other than NUMBER and DATETIME
fluent-number-operanderrorNUMBER of something that is not a variable or a number
fluent-currency-missingerrorNUMBER(…, style: "currency") with no currency
fluent-datetime-optionerrora DATETIME option MF2 has no counterpart for
fluent-date-selectorerrora date used to choose a variant: MF2 dates do not select
fluent-mixed-keyserrora select whose keys are both numbers or plural categories and plain words, so whether it selects on a number or a string is decided only at run time
fluent-variant-limiterrora message that would need more than 256 variants
fluent-duplicate-iderroran id defined twice in one language
fluent-file-collisionerrortwo files that would write one .mf2 file
fluent-localeerrora directory whose name is not a language tag
fluent-datetime-approximatewarningevery converted DATETIME: MF2’s nearest form, which may not be identical
fluent-number-optionwarninga NUMBER option Fluent ignored, dropped
fluent-unreachable-variantwarninga variant Fluent could never choose, kept
fluent-unbound-term-variablewarninga variable in a term that nothing sets: kept as the text Fluent printed
fluent-term-positionalwarninga positional argument to a term, which Fluent ignores

Fluent terms (-brand) have no MF2 counterpart: each is copied into every message that uses it, and the report counts where.

Every option

Every command takes -C DIR (--dir), the crate that holds the messages (the current directory by default); like git -C, it makes every relative path the command takes read in DIR. Every command also takes -h (--help). --features LIST names the features of mf2 the application builds with, separated by commas (fn-number,fn-datetime). --format json reports as JSON, for CI and editors.

CommandArguments and options
init [DIR]--cli, --tui, --ssr, --islands, --csr: the kind of application; --name NAME: the crate’s name (a new application’s is its directory’s); --source-locale TAG (default en); --locale TAG: another language, repeatable; --force: write over files that are there; --no-messages: no starter locales/<tag>/main.mf2, for messages that come from convert or import
check--features LIST; --format text|json; --deny-warnings: fail on a warning too; --src DIR: the Rust sources, for unused-id, repeatable
fmt [PATH]…the files or directories (default locales/); --check: write nothing, name the files that would change, exit 1 if any would
compile--features LIST; -o, --out DIR (default dist): the manifest, the catalogs and the generated module; --site DIR: instead, only what a static host serves, the catalogs and index.json, with the features cargo resolves; --facade PATH: the crate the generated module re-exports as __mf2 (default ::mf2); -v, --verbose: list what was written
stats--features LIST; --format text|json
dump FILE.mf2b--format mf2|json: MF2 source, one message per line (the default), or the data model as JSON; --manifest FILE.mf2m: the ids of a catalog built without them; --id ID: one message
pseudo--locale en-XA|ar-XB: one of the two (both by default); --dry-run: write nothing
export LOCALE-o, --out FILE (default: standard output); --format json|xliff (default json)
import LOCALE FILEFILE is flat JSON or XLIFF 2, told apart by content; --dry-run: write nothing; --features LIST
watch--features LIST; -o, --out DIR (default dist); --interval MS: how often to look (default 300); --max-rebuilds N: stop after N rebuilds (0, the default, never stops)
convert DIR--from fluent|leptos-fluent; --format text|json; --write (leptos-fluent): write the files, where without it the diff is shown; --locales DIR (leptos-fluent): the .ftl directory, if not the initializer’s locales: or APP_DIR/locales; --i18n-crate NAME (leptos-fluent): the crate the rewritten use names

The lints check reports are listed, each with an example and its fix, in Lints, and mf2.toml’s keys in mf2.toml.

Translating

This page follows one round of translation with the application of Getting started, which is in English and French. The team adds German through a translation tool, which reads XLIFF 2; a French reviewer, who works in a plain editor, gets JSON; pseudo-locales show whether the layout has room; mf2 stats shows what is still missing; and CI keeps it that way. Every command below runs in cargo xtask docs, which holds each output to what this page shows. The command line lists every option of these commands.

A new language

German needs two things. The source language names it, in the [language] section, so that the switcher can offer it:

@locale en
---

app-title = Hello, MessageFormat 2
greeting = Hello, {$name}!

# A plural: the number picks the variant, by this language's rules.
visits =
  .input {$count :integer}
  .match $count
  one {{You have been here once.}}
  *   {{You have been here {$count} times.}}

visit-again = Visit again
not-found = There is nothing here.

[language]
label = Language
apply = Apply

# Each language's name, in that language, the same in every catalog.
@do-not-translate
en = English
@do-not-translate
fr = Français
@do-not-translate
de = Deutsch

A language’s own name is marked @do-not-translate: it reads the same in every catalog, so no translator is asked for it, a language that leaves it out shows the source’s, and none counts as missing it. A language that copies it has to copy it exactly, as French does (do-not-translate).

And German gets a file, which starts with no messages:

@locale de
---

Export for a translation tool

mf2 export de --format xliff -o de.xlf
mf2 export: 10 messages of en with de's translations to de.xlf

de.xlf is an XLIFF 2 document, which translation tools read: each message of the source language is a unit, with the German translation as its target where German has one (none yet). A placeholder is a <ph> element, which a tool keeps intact and lets the translator move; a .match is a group of its variants, one unit each; a comment above a message is a note for the translator; and a message marked @do-not-translate is a unit the tool locks.

The translation comes back

The translator sends the document back with targets filled in, all but one: not-found is still in English.

<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.1" srcLang="en" trgLang="de" xml:space="preserve">
  <file id="f1" original="main.mf2" canResegment="no">
    <unit id="app-title" name="app-title">
      <segment>
        <source>Hello, MessageFormat 2</source>
        <target>Hallo, MessageFormat 2</target>
      </segment>
    </unit>
    <unit id="greeting" name="greeting">
      <originalData>
        <data id="d1">{$name}</data>
      </originalData>
      <segment>
        <source>Hello, <ph id="1" dataRef="d1" disp="{$name}"/>!</source>
        <target>Hallo, <ph id="1" dataRef="d1" disp="{$name}"/>!</target>
      </segment>
    </unit>
    <group id="visits" name="visits" type="mf2:select">
      <notes>
        <note category="comment">A plural: the number picks the variant, by this language's rules.</note>
      </notes>
      <unit id="visits:1" name="one">
        <segment>
          <source>You have been here once.</source>
          <target>Du warst einmal hier.</target>
        </segment>
      </unit>
      <unit id="visits:2" name="*">
        <originalData>
          <data id="d1">{$count}</data>
        </originalData>
        <segment>
          <source>You have been here <ph id="1" dataRef="d1" disp="{$count}"/> times.</source>
          <target>Du warst <ph id="1" dataRef="d1" disp="{$count}"/>-mal hier.</target>
        </segment>
      </unit>
    </group>
    <unit id="visit-again" name="visit-again">
      <segment>
        <source>Visit again</source>
        <target>Wieder besuchen</target>
      </segment>
    </unit>
    <unit id="not-found" name="not-found">
      <segment>
        <source>There is nothing here.</source>
      </segment>
    </unit>
    <group id="s:language" name="language" type="mf2:section">
      <unit id="language.label" name="language.label">
        <segment>
          <source>Language</source>
          <target>Sprache</target>
        </segment>
      </unit>
      <unit id="language.apply" name="language.apply">
        <segment>
          <source>Apply</source>
          <target>Übernehmen</target>
        </segment>
      </unit>
      <unit id="language.en" name="language.en" translate="no">
        <notes>
          <note category="comment">Each language's name, in that language, the same in every catalog.</note>
        </notes>
        <segment>
          <source>English</source>
        </segment>
      </unit>
      <unit id="language.fr" name="language.fr" translate="no">
        <segment>
          <source>Français</source>
        </segment>
      </unit>
      <unit id="language.de" name="language.de" translate="no">
        <segment>
          <source>Deutsch</source>
        </segment>
      </unit>
    </group>
  </file>
</xliff>

mf2 import reads it into German’s files:

mf2 import de from-translator/de.xlf
./locales/de/main.mf2:1:1: warn: 1 of 7 messages are missing here and fall back to en: not-found (locale de) [missing-translation]
mf2 import: 0 message(s) changed, 6 added, in de
@locale de
---

app-title = Hallo, MessageFormat 2
greeting = Hallo, {$name}!

visits =
  .input {$count :integer}
  .match $count
  one {{Du warst einmal hier.}}
  * {{Du warst {$count}-mal hier.}}

visit-again = Wieder besuchen

[language]
label = Sprache
apply = Übernehmen

An XLIFF document adds the messages the language does not have yet, in the file and section where the source has them, and it changes the ones it has; the files keep their comments and properties. The names are not there: German shows the source’s. The warning is the message the translator left: until it is translated, a German reader sees it in English (missing-translation).

A review, as JSON

A reviewer who works in a plain editor gets French as JSON, one entry per id:

mf2 export fr -o fr.json
mf2 export: 9 messages of fr to fr.json

The reviewer sends back the two entries they changed, and one has a mistake: the placeholder {$name} became {$nom}, which the code never passes.

{
  "greeting": "Salut, {$nom} !",
  "not-found": "Cette page n’existe pas."
}
mf2 import fr review/fr.json
./locales/fr/main.mf2:5:21: error: $nom is not an input of the source message; if this language needs it, the source has to declare it with .input (in greeting, locale fr) [undeclared-variable]
./locales/fr/main.mf2:5:12: warn: the source message shows $name, which this translation does not (in greeting, locale fr) [dropped-placeholder]
mf2 import: 1 error(s) that fr does not have now; nothing was written

Before it writes anything, import runs every check mf2 check makes on the files as they would be. A translation that would bring an error is reported with its file, line and code, nothing is written, and the command exits with 1; an error the files had already does not stop it. The same holds for markup: a translation that leaves out a link or emphasis its source has is an error (dropped-markup), because the reader would lose it.

The reviewer corrects the placeholder:

{
  "greeting": "Salut, {$name} !",
  "not-found": "Cette page n’existe pas."
}
mf2 import fr review/fr-fixed.json
mf2 import: 2 message(s) changed in fr

JSON changes only the messages the language has, which is what a review needs. An id the language does not have yet is left out, named, and the command exits with 1 and points to XLIFF, which knows where in the files a new message goes.

Pseudo-locales: room for longer text

Pseudo-locales show what a translation will do to the layout, before a translator starts:

mf2 pseudo
mf2 pseudo: en-XA — 7 message(s) in 1 file(s)
mf2 pseudo: ar-XB — 7 message(s) in 1 file(s)
@locale en-XA
---

app-title = [Ĥéļļö, ṀéššåĝéƑöŕɱåţ 2 one two]
greeting = [Ĥéļļö, {$name}! one]

# A plural: the number picks the variant, by this language's rules.
visits =
  .input {$count :integer}
  .match $count
  one {{[Ýöû ĥåṽé ƀééñ ĥéŕé öñçé. one two]}}
  * {{[Ýöû ĥåṽé ƀééñ ĥéŕé {$count} ţîɱéš. one two]}}

visit-again = [Ṽîšîţ åĝåîñ one]
not-found = [Ţĥéŕé îš ñöţĥîñĝ ĥéŕé. one two]

[language]
label = [Ļåñĝûåĝé one]
apply = [Åþþļý one]

# Each language's name, in that language, the same in every catalog.
@do-not-translate
en = English
@do-not-translate
fr = Français
@do-not-translate
de = Deutsch

en-XA is the source text accented, in brackets and about 30 % longer. Text that shows without brackets did not come from a catalog, and a label that overflows or is cut short has no room for a longer language. ar-XB forces each run of text right to left, so a page that does not apply the language’s direction shows it reversed. Placeholders, markup and the messages marked @do-not-translate are left as they are. Run the application (cargo leptos watch) and open http://127.0.0.1:3000/?lang=en-XA, then ?lang=ar-XB.

The pseudo-locales are for a development build, and a release does not ship them. They are generated, so keep them out of the repository:

# .gitignore
/locales/en-XA/
/locales/ar-XB/

While they are there, mf2 check reports a warning about ar-XB (the other is German’s gap):

mf2 check --features fn-number
./locales/ar-XB/main.mf2:9:3: warn: ar-XB has the plural categories zero, two, few, many, which no variant names; they all fall to the catch-all (in visits, locale ar-XB) [missing-plural-category]
./locales/de/main.mf2:1:1: warn: 1 of 7 messages are missing here and fall back to en: not-found (locale de) [missing-translation]
mf2 check: 0 error(s), 2 warning(s)

ar-XB has the variants of the English source, which is enough to test the layout. The pseudo-locales need no name messages: the build names each by its tag, so the switcher and Locale::name() show en-XA and ar-XB.

What is missing: mf2 stats

mf2 stats --features fn-number
corpus . — 10 messages (3 marked @do-not-translate), source locale en, manifest 0x6e06fdf7f6d6e715
CLDR 48.2.1 · MF2 spec 5c4ddb27 · catalog format v1

locale    coverage  missing       raw        gz        br  catalog
de           85.7%        1       425       328       271  de.dd01d173fa211668.mf2b
en          100.0%        0       394       306       225  en.1b178a31f1849357.mf2b
fr          100.0%        0       448       350       306  fr.6dea50f6252f5092.mf2b

locale data, entry by entry (raw bytes in the catalog):
  de           17 B  plural.cardinal 5 B, number.symbols 12 B
  en           17 B  plural.cardinal 5 B, number.symbols 12 B
  fr           30 B  plural.cardinal 16 B, number.symbols 14 B

0 error(s), 1 warning(s) — run `mf2 check`

For each language, coverage and missing count the messages that need translating: the three names marked @do-not-translate count in neither, here or in check. German lacks one of seven. The next columns are the language’s catalog, raw, gzipped and brotli-compressed, and the last section is the locale data each catalog carries, entry by entry. --format json prints the same for a dashboard. Like check, stats counts with the features cargo resolves for the application’s mf2; --features names them instead, as here.

The checks in CI

Two commands keep the messages in shape. mf2 fmt --check fails when a file is not in the one layout (import and pseudo write it; a hand edit may not):

mf2 fmt --check
mf2 fmt: 0 of 3 file(s) would change

mf2 check runs every check the build makes. Errors fail it always; --deny-warnings fails it on warnings too, and here the German gap does:

mf2 check --features fn-number --deny-warnings
./locales/de/main.mf2:1:1: warn: 1 of 7 messages are missing here and fall back to en: not-found (locale de) [missing-translation]
mf2 check: 0 error(s), 1 warning(s)

Which gaps fail a build is the team’s choice. Without --deny-warnings, a missing translation is reported and the fallback ships; mf2.toml can raise missing-translation alone to an error, or lower another code (Lints, mf2.toml).

A Forgejo workflow that runs them on every push and pull request, in the directory that holds locales/ (with -C DIR if the messages live in a crate of their own). The runner needs git and a Rust toolchain; check asks cargo which features the application turns on.

# .forgejo/workflows/messages.yml
on:
  push:
  pull_request:

jobs:
  messages:
    runs-on: rust # one of your runner's labels
    steps:
      - name: Fetch the sources
        run: |
          git init --quiet .
          git fetch --quiet --depth=1 "${{ github.server_url }}/${{ github.repository }}.git" "${{ github.sha }}"
          git checkout --quiet --detach FETCH_HEAD
      - name: Install mf2
        run: cargo install mf2-cli --locked
      - name: One layout
        run: mf2 fmt --check
      - name: Every check the build makes
        run: mf2 check --deny-warnings
      - name: What each language lacks
        run: mf2 stats

The job fetches the sources with git; a private repository adds its token to the fetch. mf2 check --format json prints the findings as data, for a job that comments on a pull request.

Testing

A test pins a language for the code it runs, checks the text in each language the application ships, and draws a terminal UI into a buffer to compare with what the reader should see. Pseudo-locales then show whether the layout has room for a longer language. This page adds tests to trace, the library and terminal UI from Native CLI and Ratatui apps; the docs build runs every one of them.

One language for one test: with_locale

The generated module has with_locale(Locale, body): it runs body with this thread formatting in that language, and puts the thread’s language back when body returns or panics. It needs no install(): the embedded catalogs are loaded on first use. Other threads are not affected, so tests pinned to different languages run in parallel, as cargo test runs them. set_locale is the wrong tool in a test: it changes the language of every thread, and the tests running beside it.

The library’s tests go in core/tests/:

//! The library's messages, as text, in each language it ships.

use trace_core::{Error, Locale, Trace, with_locale};

fn trace() -> Trace {
    Trace {
        target: String::from("example.org"),
        sent: 1204,
        lost: 25,
    }
}

#[test]
fn the_summary_in_english_and_in_french() {
    let trace = trace();
    let english = with_locale(Locale::En, || trace.summary().to_string());
    assert_eq!(english, "1,204 probes sent · loss 2.1%");
    let french = with_locale(Locale::Fr, || trace.summary().to_string());
    assert_eq!(french, "1\u{202f}204 sondes envoyées · perte 2,1\u{a0}%");
}

#[test]
fn one_probe_is_singular() {
    let trace = Trace {
        sent: 1,
        lost: 0,
        ..trace()
    };
    let english = with_locale(Locale::En, || trace.summary().to_string());
    assert_eq!(english, "1 probe sent · loss 0%");
}

#[test]
fn an_error_prints_in_the_chosen_language() {
    let error = Error::Resolve {
        host: String::from("example.invalid"),
    };
    let french = with_locale(Locale::Fr, || error.to_string());
    assert_eq!(french, "Impossible de résoudre example.invalid.");
}

#[test]
fn locale_format_is_one_message_in_one_language() {
    assert_eq!(
        Locale::Fr.format(&trace().title()),
        "Trace vers example.org"
    );
}
  • Compare text, not descriptions. tr! returns a description of the message, which is formatted when it is shown; .to_string() inside with_locale formats it in the test’s language. A description built inside with_locale and formatted after it is in the thread’s own language again.
  • Locale::format formats one message in one language, with no closure: the shortest way to check a single message.
  • French numbers group with a narrow no-break space (\u{202f}) and put a no-break space (\u{a0}) before %, as CLDR says French does. Write them as escapes, so that the expected text shows what is in it.
  • Asserting on a description itself, or calling unwrap() on a result that holds one, reaches its Debug. That costs nothing in a native test; in code a browser build ships it costs about 1 KB of wasm (Features).

A frame in several languages: TestBackend

Ratatui’s TestBackend draws into a buffer instead of a terminal, and assert_buffer_lines compares the buffer with the lines the reader should see: a snapshot of the frame, in each language. The terminal UI’s tests draw what its main draws; an application with a larger frame puts its drawing in a function that both call, as hops does with ui::draw.

//! The terminal UI's frame, drawn into a buffer in each language.

use mf2::ratatui::{Theme, with_theme};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::style::{Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::{Block, Paragraph};
use trace_core::{Locale, Trace, with_locale};

fn trace() -> Trace {
    Trace {
        target: String::from("example.org"),
        sent: 1204,
        lost: 25,
    }
}

/// The frame `main` draws, in `locale`, on a terminal `width` columns wide.
fn draw(locale: Locale, width: u16) -> Terminal<TestBackend> {
    let trace = trace();
    let mut terminal = Terminal::new(TestBackend::new(width, 3)).expect("a test terminal");
    with_locale(locale, || {
        terminal.draw(|frame| {
            let block = Block::bordered().title(trace.title());
            frame.render_widget(Paragraph::new(trace.summary()).block(block), frame.area());
        })
    })
    .expect("a frame");
    terminal
}

#[test]
fn the_frame_in_english() {
    draw(Locale::En, 40).backend().assert_buffer_lines([
        "┌Trace to example.org──────────────────┐",
        "│1,204 probes sent · loss 2.1%         │",
        "└──────────────────────────────────────┘",
    ]);
}

#[test]
fn the_frame_in_french() {
    draw(Locale::Fr, 40).backend().assert_buffer_lines([
        "┌Trace vers example.org────────────────┐",
        "│1\u{202f}204 sondes envoyées · perte 2,1\u{a0}%   │",
        "└──────────────────────────────────────┘",
    ]);
}

A test sets no theme, so markup draws with the style around it and the snapshot is plain text. To test the styles, give the test a theme with with_theme, which holds for its body on this thread only, and look at the cells:

#[test]
fn the_host_is_underlined_by_the_theme() {
    let theme = Theme::default().style(trace_core::markup::HOST, Style::new().underlined());
    let terminal = with_theme(&theme, || draw(Locale::Fr, 40));
    let buffer = terminal.backend().buffer();
    // "Trace vers " is 11 columns after the corner; the host follows.
    assert!(buffer[(12, 0)].modifier.contains(Modifier::UNDERLINED));
    assert!(!buffer[(11, 0)].modifier.contains(Modifier::UNDERLINED));
}

Room for a longer language: pseudo-locales

A translation is often longer than the source, and a terminal does not reflow. Pseudo-locales make one before any translator starts: en-XA is the source accented, in brackets and about a third longer, and ar-XB runs right to left. The library’s messages are in core, so the command runs there:

mf2 -C core pseudo
mf2 pseudo: en-XA — 4 message(s) in 1 file(s)
mf2 pseudo: ar-XB — 4 message(s) in 1 file(s)

With them written, Locale::ALL has EnXa and ArXb, and a test that walks Locale::ALL checks them with the rest. This one holds every language’s summary to the width the frame gives it:

/// Every language's summary fits inside the border of the narrowest
/// terminal the application supports.
#[test]
fn every_summary_fits_sixty_columns() {
    let trace = trace();
    for locale in Locale::ALL {
        let width = with_locale(locale, || Line::from(trace.summary()).width());
        assert!(width <= 58, "{} needs {width} columns", locale.tag());
    }
}

At the snapshots’ 40 columns this test would fail: en-XA’s summary needs 39 columns and the border leaves 38, which is what a longer translation would meet. A failure names the language and the columns it needs. The pseudo-locales stay out of the repository (Translating says why), so CI writes them before it runs the tests:

mf2 -C core pseudo
cargo test --workspace

Without them the test still runs, over the shipped languages; it names no pseudo-locale, so it compiles either way.

Troubleshooting

Each entry gives what you see, why, and what to change. The library says something wherever it used to fall back without a word: the build fails with a message, a native application panics, a server writes one line to standard error, or a browser debug build writes one line to the console. A server says each thing once per process (once per language, where it names one), not once per request.

tr! is not found

You see, in a module of the crate whose build script compiles the messages:

error: cannot find macro `tr` in this scope
   --> core/src/report.rs:2:5
    |
  2 |     tr!("title", target = "example.org")
    |     ^^
    |
help: consider importing this macro through its public re-export
    |
  1 + use crate::tr;
    |

(rustc may also offer r#try! as a similar name; it is not the fix.)

Why: the generated module makes tr an ordinary item of the crate’s root, so it is in scope at the root, after mf2::include_generated!(), and nowhere else. Every other module imports it, whether it is declared before the include or after it.

Fix: import it once per module:

  • in the crate that includes the generated module, use crate::prelude::*; (which also brings Locale, set_locale and the rest), use crate::tr;, or write crate::tr!(…);
  • in a crate that depends on it, use my_lib::prelude::*;, use my_lib::tr;, or my_lib::tr!(…).

Do not define a macro_rules! tr of your own beside the generated one: two items named tr make every call ambiguous.

A stale manifest

You see a tr! that fails to compile although its message exists, with this error (the path and the two hashes are your build’s):

the message manifest at …/out/manifest.mf2m is stale: it hashes to 0x…, but this `tr!` was generated for 0x…
the generated module and the manifest are from different builds — rebuild the i18n crate (in an editor: restart the proc-macro server)

or, when the manifest is missing:

cannot read the message manifest at …/out/manifest.mf2m: No such file or directory (os error 2)
the i18n crate's build script writes it — build that crate, or, if its target directory moved, rebuild it there

Why: tr! checks each call against the manifest the build script wrote, and the generated module records which manifest that was. An editor usually causes this: its proc-macro server keeps an older build’s module while cargo has written a newer manifest, or the target directory was moved or cleaned.

Fix: run cargo build for the crate that holds the messages. In an editor, restart its proc-macro server (in rust-analyzer: Restart server).

On the web, the same mismatch at run time is a deploy skew: a page from one build loaded a catalog from another. The client does not use the catalog; it writes mf2: this page's catalog is from another deploy; reloading. to the console and reloads. If it keeps happening, the server or a cache is serving the wasm and the catalogs from different deploys: publish them together.

Empty text

A native application panics at its first message:

mf2: no catalogs are installed: call install() at start-up

Why: nothing loaded the catalogs. Fix: call the generated install() first thing in main. A test needs no install(): with_locale loads the embedded catalogs itself.

A server renders the page with every message empty, and writes once:

mf2: a message was formatted with no catalogs installed, so it rendered as empty text; call the generated install() at start-up

Fix: call install() in the server’s main, before the router is built.

In the browser, text is empty when a component formats a message before any catalog is active. A debug build says so once in the console:

mf2: a message was formatted before any catalog was active, so it rendered as empty text; start the page through mf2::leptos (hydrate_body, mount_to_body), which loads the catalog first. (Debug builds only.)

A release build has no such code, so check a debug build first. Fix: start the client as Getting started does, through mf2::leptos’s hydrate_body or mount_to_body, which load the catalog before the first render.

Pages stuck in the default language

The page renders, always in the source language. The server says why, once:

  • No language for the request:

    mf2: a page rendered without the request's language, so it is in the source language, `en`; add mf2::axum's Negotiator layer to the router, or call provide_locale in the render
    

    A request’s render ran outside the negotiator (the route list the server builds at start-up never prints it). Fix: add the Negotiator layer to the router that serves the pages, as Getting started does, or call provide_locale in the render.

  • A language the build does not have:

    mf2: provide_locale("pt-BR"): this build has no catalog for that language, so the page renders in the source language, `en`
    

    Fix: add a catalog for it (locales/pt-BR/), or pass a tag the application ships; Locale::ALL lists them.

  • The reader’s languages match no catalog:

    mf2: no catalog matches the reader's languages (zh-TW, zh), so they are served the default language, `en`; a catalog for one of them would serve them
    

    This is a normal outcome, and the line tells you which catalog is missing. A Traditional Chinese reader is a common case: Traditional and Simplified Chinese do not fall back to each other, as CLDR’s data says, so a zh-Hans catalog does not serve zh-TW. Fix: add a zh-Hant catalog ([fallback] says how the chains are built).

  • The language is in the path, and the redirect sends everyone to ?lang:

    mf2: path_prefix_redirect ran on a request the Negotiator has not seen, so it reads the query parameter `lang`; add its .layer before the negotiator's, so that it runs under it
    

    Fix: in axum the last .layer runs first, so add the redirect’s .layer before the Negotiator’s (Switching language).

If the server says nothing and the page is still in the source language, check the switcher first: Switching language describes how the choice is remembered and sent.

A test sees the wrong language

A test that calls set_locale changes the language of every thread, so the tests that run beside it see it too. Use with_locale, which holds for one closure on one thread, and format inside the closure: a description made inside with_locale and turned into text after it is in the thread’s own language again.

mf2.toml

mf2.toml sits beside the Cargo.toml of the crate that holds the messages, next to locales/. It is optional: without it, every key has the default this page gives. The build script and every mf2 command read the same file, so a build and mf2 check always agree. A key the file does not know is refused, with its line and column, rather than ignored.

A file that sets every key:

source_locale = "en"

[fallback]
"es-MX" = ["es", "en"]

[catalog]
strip = ["cold", "ids"]
missing = "fallback"

[locale_data]
currencies = "used"
units = ["kilometer", "mile"]

[lints]
neutral-numbers = "allow"
dropped-markup = "warn"

[functions]
"app:emoji" = "my_app_i18n::functions::EMOJI"

Which functions a message may use is not configured here: it follows the features the application turns on for mf2 (Features of mf2).

source_locale

The language the messages are written in first, as a BCP 47 tag. Default "en". Every other language is checked against it: its ids are the application’s ids, its variables are the arguments a call site passes, and every lint compares a translation with it. It is also the last language every fallback chain ends at. An empty tag is refused.

[fallback]

For a language, the languages a missing message is taken from, in order:

[fallback]
"es-MX" = ["es-419", "es"]
"pt-BR" = ["pt"]

A language with no entry falls back to its parent tags, longest first (es-MX, then es), and every chain ends at the source language whether it names it or not. A language may not name itself. The chains are resolved when the catalogs are built, so the browser follows no chain at run time.

The parents are found by cutting the tag, never by relating scripts: Traditional and Simplified Chinese do not fall back to each other, as CLDR’s data says. A reader of Traditional Chinese whom the application serves in the source language needs a Traditional catalog (zh-Hant). On the web, the server says so once for each first language it could not match, naming the reader’s languages.

[catalog]

What each language’s catalog carries.

strip

The parts of a catalog left out, a list of:

  • "cold": the messages’ attributes and comments, which nothing formats;
  • "ids": the table of message ids. The generated code formats by number, so a catalog needs no ids to be used; mf2 dump --manifest reads them from the manifest instead.

Default ["cold", "ids"], which is what a browser should download. [] keeps both, for a catalog a tool reads.

missing

What a language that lacks a message gets in its catalog:

  • "fallback" (the default): the text of the first language in its fallback chain that has it. The message is marked as borrowed, and with mf2’s mark-fallback-lang feature the page wraps it in its own lang (Accessibility).
  • "id": the message’s id, so that a gap shows in the page while it is being translated.
  • "empty": nothing.

Each choice is reported by the missing-translation lint with the ids it applies to.

[locale_data]

How much of CLDR’s currency and unit data a catalog carries, when mf2’s fn-number feature is on (without it no number data is carried at all).

currencies

  • "used" (the default): the currencies the messages name, as in {$price :currency currency=EUR}.
  • "all": every currency CLDR has.
  • a list of ISO 4217 codes, ["USD", "EUR"]: those, as well as the ones the messages name.

A :currency whose currency option is a variable makes the catalog carry every currency under "used" or "all", and "used" raises dynamic-currency. A list says which codes the variable can hold: the catalog carries only those (and the ones the messages name), with no warning. A code outside the list formats with the code itself as its symbol and name, and two fraction digits.

units

The same for :unit and CLDR’s unit identifiers ("kilometer", "liter-per-100-kilometer"): "used", "all", or a list. A :unit whose unit option is a variable carries every unit under "used" (raising dynamic-unit) or "all", and only the listed ones under a list. A unit outside the list is an Unsupported Operation error when formatted, unless it is X-per-Y of two units the catalog has.

[lints]

A lint’s level, by its name: "allow" (say nothing), "warn" (report, and the build goes on) or "error" (report, and the build and mf2 check fail).

[lints]
missing-translation = "error"
unknown-option = "error"
dropped-markup = "warn"

Any lint can be raised. A lint that states a rule the build relies on cannot be lowered below "error", and one set lower is refused with the reason; Lints gives each lint’s default and the lowest level it takes.

[functions]

The application’s own MF2 functions: the name a message calls it by, and the Rust path of a &'static dyn mf2::Function that implements it.

[functions]
"app:emoji" = "my_app_i18n::functions::EMOJI"

A message may then write {$mood :app:emoji}, and the generated module registers the function. A name that is neither built in nor listed here is the unknown-function error. A function is linked only if some message uses it.

Lints

The build script and mf2 check run the same checks over the messages. Each has a name, which a report prints in brackets and mf2.toml’s [lints] uses to raise or lower it:

  • error: reported, and the build (and mf2 check) fails;
  • warn: reported, and the build goes on (mf2 check --deny-warnings fails on it);
  • allow: not reported.

Any lint can be raised. Some state a rule the rest of the build relies on and cannot be lowered: each section below gives the default and the lowest level mf2.toml may set.

The examples show a message in the source language (en) and its translation (fr), each as it stands in its locales/<tag>/*.mf2 file.

Errors by default

extra-id

Default error; the lowest mf2.toml may set it is error.

A translation has an id the source language does not. Nothing in the application can ask for it, and usually it is a message renamed or removed in the source and left behind in a translation.

en   (no message "mystery")
fr   mystery = Quoi ?

Fix: delete it from the translation, or add it to the source language if the application needs it.

undeclared-variable

Default error; the lowest mf2.toml may set it is error.

A translation uses a variable its source message does not have. A call site passes the source’s arguments, so the variable would never have a value.

en   greeting = Hello, {$name}!
fr   greeting = Bonjour, {$user} !

Fix: use the source’s variable ({$name}). A language that needs more input than the source shows, a grammatical gender for one, gets it by the source declaring it with .input, even where the source’s own text does not use it; the call site then passes it for every language.

undeclared-markup

Default error; the lowest mf2.toml may set it is error.

A translation uses markup its source message does not. The application maps only the source’s markup names to elements or styles.

en   close = Press {#kbd}Esc{/kbd} to close
fr   close = Appuyez sur {#kbd}{#b}Échap{/b}{/kbd} pour fermer

Fix: remove it from the translation, or add it to the source and the call site.

dropped-markup

Default error; the lowest mf2.toml may set it is allow.

A translation leaves out markup its source message has. A link that is gone from the French sentence is gone for French readers, and nothing at run time says so.

en   terms = Accept our {#link}terms{/link}.
fr   terms = Acceptez nos conditions.

Fix: keep the markup around the words that carry it (Acceptez nos {#link}conditions{/link}.). One variant of a .match may leave it out as long as another keeps it. A corpus that drops emphasis on purpose may lower it to warn.

dynamic-select

Default error; the lowest mf2.toml may set it is allow.

A number’s select option (plural, ordinal or exact) comes from a variable, so the build cannot tell which rules the message selects by. The catalog then has to carry both plural rule sets, and the message still reports a bad option when it runs.

en   place = .input {$n :integer select=$how} .match $n one {{…}} * {{…}}

Fix: write the value (select=ordinal), or make two messages, one for each kind of selection.

bad-option-value

Default error; the lowest mf2.toml may set it is allow.

An option that MF2 defines is given a literal value it cannot take. At run time the function would report a bad option and format without it.

en   price = It costs {$amount :currency currency=EUR minimumFractionDigits=lots}

Fix: a value the option takes (minimumFractionDigits=2).

gated-function

Default error; the lowest mf2.toml may set it is error.

A message calls a function whose feature of mf2 is off: :percent, :currency and :unit need fn-number; :datetime, :date and :time need fn-datetime. A translation can never add formatting code to the application by itself.

en   price = It costs {$amount :currency currency=EUR}

with mf2 = { …, features = ["ssr"] }. Fix: turn the feature on (features = ["ssr", "fn-number"], see Features of mf2), or leave the function out of the message. mf2 check reads the features cargo resolves, or --features when it is given.

do-not-translate

Default error; the lowest mf2.toml may set it is allow.

A message the source marks @do-not-translate, a brand or a language’s own name, differs in a translation.

en   @do-not-translate
     brand = Example
fr   brand = Exemple

Fix: delete it from the translation (the source’s text is shown), or copy it exactly.

duplicate-id

Default error; the lowest mf2.toml may set it is error.

One language defines an id twice, in one file or two, so one of them would be dropped without a word.

en   save = Save
     save = Store

Fix: rename or delete one.

locale-mismatch

Default error; the lowest mf2.toml may set it is allow.

A file’s @locale header names another language than the directory it sits in. It is nearly always a copy that was never finished.

locales/fr/main.mf2   @locale de

Fix: make the header name the directory’s language (@locale fr). A corpus that keeps one language’s files under another tag on purpose may lower it.

unknown-function

Default error; the lowest mf2.toml may set it is allow.

A message calls a function that is neither one of MF2’s nor listed under [functions].

en   mood = Today: {$mood :app:emoji}

Fix: correct the name if it is a typo, or register the application’s function under [functions]. Lowered, the call stays in the catalog and the message shows MF2’s fallback for an unknown function when it runs.

Warnings by default

missing-translation

Default warn; the lowest mf2.toml may set it is allow.

A language lacks an id the source language has. The report names the first ten ids of each language, and what the catalog carries in their place ([catalog] missing).

en   save = Save
fr   (no message "save")

Fix: translate it. mf2 stats counts what each language lacks, and mf2 pseudo shows untranslated text in the page. A message marked @do-not-translate is never missing. Raise it to error to keep a release from shipping with gaps.

neutral-numbers

Default warn; the lowest mf2.toml may set it is allow.

mf2’s fn-number feature is off and a placeholder can receive a number: the number would be written with neutral symbols (1234.5), not the language’s separators, grouping or digits. The build cannot see what a call site passes, so a placeholder that only ever receives text raises it too.

en   files = {$count} files

Fix: turn on fn-number; or, if these placeholders only receive text, neutral-numbers = "allow" under [lints].

unpaired-markup

Default warn; the lowest mf2.toml may set it is allow.

Markup opened and not closed, or closed and not opened.

en   close = Press {#kbd}Esc to close

Fix: close it ({#kbd}Esc{/kbd}), or remove the stray tag.

missing-plural-category

Default warn; the lowest mf2.toml may set it is allow.

A plural .match does not have a variant for every plural category of the translation’s own language. The catch-all * covers it, usually with the wrong grammar.

fr   visits = .input {$count :integer} .match $count one {{…}} * {{…}}

French also has many (for a million and more). Fix: add the variant (many {{…}}). A language’s categories are CLDR’s, and they differ from the source’s: Polish has one, few, many and other.

non-nfc-source

Default warn; the lowest mf2.toml may set it is allow.

Text that is not in Unicode Normalization Form C: an é typed as e followed by a combining accent, say. It looks the same, but compares, searches and sorts differently from the composed letter.

Fix: save the file normalized to NFC (most editors and translation tools have the setting).

dropped-placeholder

Default warn; the lowest mf2.toml may set it is allow.

A translation never uses a variable its source message shows.

en   greeting = Hello, {$name}!
fr   greeting = Bonjour !

Fix: put it back (Bonjour, {$name} !). A variable used anywhere in the translation counts, so a plural’s one variant may say “a message” without {$count} while another variant shows it.

unknown-option

Default warn; the lowest mf2.toml may set it is allow.

A built-in function is given an option it does not define. MF2 ignores unknown options, so the message formats as if it were not there.

en   due = Due {$when :datetime dateStyle=long}

dateStyle is the browser’s Intl name; MF2’s is dateLength. Fix: the function’s own option (dateLength=long).

unused-id

Default warn; the lowest mf2.toml may set it is allow.

A message that no tr! in the application’s sources names. It is raised only by mf2 check --src DIR, which reads the sources; the build script does not look.

en   old-banner = Welcome to the beta!

The ids the generated code uses itself count as used: the languages’ names, language.<tag>, which the locale switcher and Locale::name() show. The warning points at the line that defines the id.

Fix: for an id nothing uses, delete it from every language, once nothing will use it again.

suspicious-bidi

Default warn; the lowest mf2.toml may set it is allow.

A bidirectional isolate character (U+2066 to U+2068) opened in literal text and never closed with U+2069, or closed and never opened. The rest of the line, and sometimes of the page, is laid out in the wrong direction.

Fix: close it, or remove it: a placeholder is isolated when it is formatted, so a message rarely needs these characters by hand.

dynamic-currency

Default warn; the lowest mf2.toml may set it is allow.

A :currency whose currency option is a variable, and [locale_data] currencies is not a list, so the catalog carries the data of every currency CLDR has.

en   price = It costs {$amount :currency currency=$code}

Fix: list the codes the variable can hold under [locale_data] currencies (currencies = ["EUR", "USD"]): the catalog carries only those, and the warning stops. Or name the currency (currency=EUR), or accept the size and lower it to allow.

dynamic-unit

Default warn; the lowest mf2.toml may set it is allow.

The same for a :unit whose unit option is a variable, and [locale_data] units is not a list: the catalog carries every unit.

en   distance = {$value :unit unit=$how}

Fix: list the units the variable can hold under [locale_data] units, name the unit (unit=kilometer), or accept the size and lower it.

nonstandard-name

Default warn; the lowest mf2.toml may set it is allow.

A variable, option, function, markup or attribute name that is not an ordinary identifier: it uses a character the Unicode security guidelines advise against in identifiers, or mixes scripts. MF2 accepts it, but it can look identical to another name.

en   greeting = Hello, {$nаme}!

The а above is Cyrillic, so $nаme is not the $name a call site passes. Fix: spell the name in one script, with letters and digits.

Features of mf2

An application names mf2 once, with the features it needs; everything else is left out of the build. None is on by default. The applications mf2 init makes choose them for each kind of application; this page says what each one does.

A feature decides which functions a message may use, so the build script and mf2 check read the features cargo resolves for the crate, and a message that calls a function whose feature is off is the gated-function error.

The Leptos line

leptos

The Leptos layer renders with Leptos 0.9, the default line.

leptos-0-8

The same layer with Leptos 0.8, for an application that stays on it.

A Leptos mode needs one line, and both at once is a compile error that says what to write. The line goes on the mf2 dependency (features = ["leptos"]); the mode goes in the application’s own feature of the same name, beside Leptos’s.

A Leptos mode

Exactly one, in the application’s feature of the same name (ssr = ["leptos/ssr", "mf2/ssr"]). Each brings mf2::leptos: tr! in text, attributes and props, the catalog of the request or the page, the live switch and the page’s components; and each implies its host.

ssr

Rendered on the server. Implies host-std.

hydrate

The server-rendered page, hydrated in the browser. Implies host-web.

csr

Built and rendered in the browser alone. Implies host-web.

static-locale

No live switch: a language switch sets a cookie and loads the page again, and rendered text registers nothing to update. It suits islands.

mark-fallback-lang

Text borrowed from a fallback language (a message not translated yet) is wrapped in a <span lang> of its own language, and dir when its direction differs from the page’s, identically on the server and in the browser (WCAG 2.2’s Language of Parts). Only a borrowed message in the page’s text is wrapped, so a page with no missing translation is unchanged; an attribute or a string cannot carry a lang and stays unmarked.

A server

axum

mf2::axum, with or without Leptos: the reader’s language chosen from the request, the catalogs served from the server binary under /i18n/ (precompressed), and the generated Locale as an extractor, with Locale::format. Implies host-std.

A native application

native

mf2::native, for a command-line tool or a terminal UI: one corpus’s catalogs embedded in the executable or shipped beside it (checked against the content hash in their names), installed once for the process, in the system’s language and time zone. A description’s Display, to_string() and to_cow() then show its text. Implies host-std.

ratatui

mf2::ratatui: a message as Ratatui Text or Line, its markup ({#name}…{/name}) as styles the application maps by name. It adds ratatui-core alone, whose types ratatui re-exports. Implies native.

clap

The generated Locale gets a clap value parser, so that --lang is matched by the same rules as the system’s language (fr_CA.UTF-8 is French), and --help lists the languages.

native, ratatui and axum are never in a browser build: beside hydrate or csr, each is a compile error when compiling for wasm32. On the host they compile together, as a workspace’s cargo check unifies features.

Functions

fn-number

Numbers in the reader’s language: its decimal and grouping separators, digits and numbering system for :number, :integer and unannotated numbers; and :percent, :currency and :unit. Without it, a number is written with neutral symbols (1234.5), and the build says so (neutral-numbers).

fn-datetime

:datetime, :date and :time, and date and time values without a function. On its own it formats with a neutral stand-in; a date backend (below) gives it the reader’s language. With a Leptos mode, dates are shown in the reader’s time zone: the browser reports its zone, a page the server rendered in another zone is corrected after hydrating, and the mf2_tz cookie lets the server render the next page in it. Off, none of this is in the client.

datetime-icu

Dates formatted by ICU4X on the server and in the browser, with the data each language needs in its catalog (icu.blob). Implies fn-datetime.

datetime-intl

Dates formatted by the browser’s Intl.DateTimeFormat in a browser build, and by ICU4X with its compiled data everywhere else. Implies fn-datetime.

intl

In a browser build, numbers are formatted and plurals chosen by the browser’s Intl.NumberFormat and Intl.PluralRules (which needs a browser with Intl.NumberFormat v3), instead of Rust code in the wasm. Every other build keeps the Rust code.

Hosts and tools

A mode implies its host; an application rarely names one.

host-std

Formatting on a native target: servers, tests, wasm32-wasip1.

host-web

Formatting in the browser.

compile

mf2::compile_str: an ad-hoc message compiled into a one-message catalog, for a server or a test. Never in a client.

What a browser build pays for text

A description (what tr! returns) turned into a String in a browser build costs code in the wasm, and the ways differ: .to_string() is the leanest; format!("{}", …) adds a few dozen bytes; {:?} adds about 1 KB, and so does an unwrap() or an assert_eq! that involves a description, since each reaches its Debug. In a view, tr! renders without any of them.

Accessibility

WCAG 2.2 AA is a requirement of this library, not an option. Some of it the library does for you. The rest is what any page has to do, and the translated parts of a page make some of it easier to get wrong. This page lists both. Its samples join the call sites library.

What the library does

  • The page’s language (3.1.1). html_lang() gives the shell <html lang dir> for the language the page is rendered in, and every switch updates both attributes. dir comes from the catalog, so a right-to-left language lays out right-to-left without a second stylesheet, as long as the CSS uses flexbox and logical properties (below).
  • The language of each option (3.1.2). Each option of the switcher carries its own lang and names its language in that language, so a screen reader reads “Français” with a French voice.
  • No change of context on input (3.2.2). The switcher applies a choice when its button is pressed, never on the <select>’s change, which the keyboard fires at every arrow key. See Switching language.
  • A labelled control (1.3.1, 3.3.2, 4.1.2). The switcher’s <select> is inside a visible <label> whose text you supply. It has no fixed id, so a page can have two switchers without duplicate ids.
  • Focus stays put. A live switch rewrites text in place, so focus stays where it was, on the switcher’s button. The one exception is a message with markup, whose fragment is built again in the new language: an element inside it (a link, say) is a new element after a switch.
  • Bidirectional text. Arguments that need it (strings; not formatted numbers) are isolated in text a person reads, so a Latin name cannot scramble an Arabic sentence. The isolates are left out where a program reads the text. Call sites lists which is which.
  • It works without the wasm. A server-rendered page is complete, in the reader’s language, before any client code runs. If the catalog cannot be loaded, the page stays as the server sent it instead of going blank.

What the application does

  • Status messages (4.1.3). Text that changes without moving focus, such as a count or a result, needs role="status" (or aria-live) so that it is announced. A signal-valued argument changes the text in place, so the same holds for it:

    [results]
    
    count =
      .input {$n :integer}
      .match $n
      0   {{No results}}
      one {{One result}}
      *   {{{$n} results}}
    
    #[component]
    pub fn ResultCount(n: Signal<u32>) -> impl IntoView {
        view! { <p role="status">{tr!("results.count", n = n)}</p> }
    }
  • A title per page (2.4.2). Give each route its own <Title> from a message. A client-side navigation then changes the title too.

  • Landmarks (1.3.1, 2.4.1). Keep <header>, <nav>, <main> and <footer> as siblings, not nested inside <main>, so that “skip to main content” lands on the content.

  • Distinct labels (2.4.6). Two fields with the same label are two messages that happen to say the same thing in English. Give each field its own message, even if the English text is the same, because another language may need to tell them apart.

  • Layout in both directions (1.4.10). Lay out with flexbox, and use logical properties (margin-inline-start, padding-inline, text-align: start) instead of left and right. Then dir="rtl" mirrors the layout by itself. Leave room for text to grow: a German or Finnish label is often half as long again as the English one.

  • Contrast (1.4.3, 1.4.11). Placeholder text and control borders are where the browsers’ defaults fall below AA most often. Set them.

Language as data

A page can state its language as structured data as well as in <html lang>, for crawlers and for tools that read schema.org. Keep it in step with a switch by reading current_locale() (from Switching language) in a closure:

[article]
headline = How the catalog is built
#[component]
pub fn Article() -> impl IntoView {
    view! {
        <article itemscope itemtype="https://schema.org/Article">
            <meta itemprop="inLanguage" content=move || current_locale().tag() />
            <h2 itemprop="headline">{tr!("article.headline")}</h2>
        </article>
    }
}

Untranslated text

If a message has not been translated yet, missing = "fallback" (the default in mf2.toml) shows the source language’s text in its place. Other options are "id" (the message’s id, so the gap is visible) and "empty". That borrowed text is in a different language from the page, and WCAG 3.1.2 asks for such a passage to be marked with its own lang. Turn on mark-fallback-lang and the library does it:

[dependencies]
mf2 = { version = "2", features = ["leptos", "fn-number", "datetime-icu", "mark-fallback-lang"] }

A message the page’s catalog borrowed then renders inside a <span> naming the language it came from. In an Arabic page, an English sentence becomes <span lang="en" dir="ltr">…</span>: a screen reader reads it with an English voice, and it lays out left to right. dir is added only when the two languages’ directions differ. A translated message is still a bare text node, so the feature changes nothing on a page with no missing translation. The server writes the span, hydration keeps the one it wrote, and a live switch adds or removes it around the same text.

Some places cannot be marked, and stay as they are:

  • Attributes (title, aria-label, alt, placeholder). HTML gives an attribute a language only through its element’s lang, which would relabel the element’s content as well.
  • Strings (to_string(), String::from, TextProp, Signal<String>). A string has no markup to carry a lang.
  • Elements that hold only text (<title>, <textarea>, <option>, <script>, <style>). A span there would be shown as characters, so a borrowed message in one is written without it, on the server and in the browser.

For those, the answer is to translate the message. Either way:

  • mf2 check warns about missing translations (missing-translation), once per language, naming the first ten missing ids, and mf2 stats counts them per language;

  • to make a missing translation fail the build, raise the lint in mf2.toml, beside Cargo.toml:

    [lints]
    missing-translation = "error"
    

How the examples are checked

The examples in this repository (examples/demo-ssr, demo-islands, demo-csr) are audited against WCAG 2.2 AA in every language, right to left included. tools/e2e/checks/a11y.mjs runs axe-core over 28 pages in Chromium and Firefox and finds no violations. It also checks contrast, reflow at 320 CSS pixels, text spacing, landmarks, and the switcher from the keyboard. The audit and its findings are in plans/phase-7-results.md. It has not been done with a screen reader; the browsers’ accessibility tree stands in for one.

Migrating from leptos-fluent

mf2 convert --from leptos-fluent moves a leptos-fluent application to this library in one command. It converts the .ftl files to MF2 messages, and it rewrites every tr! and move_tr! in the Rust sources to this library’s tr!, where the rewrite is mechanical. Everything it cannot rewrite it reports, with the file, the line and the column. The rest of this page is that report: what each item means and how to finish it by hand.

The example is the application of Getting started, as it would have been written with leptos-fluent 0.3. That release is built on Leptos 0.8, so an application moving from it either moves to Leptos 0.9 at the same time or keeps 0.8 through this library’s opt-in (Getting started, “Leptos 0.9 or 0.8”). The before samples on this page are leptos-fluent code. They are not compiled, since this repository does not depend on leptos-fluent, but cargo xtask docs runs the commands below on them, and checks that the rewritten file is the one this page shows and that the finished application compiles.

Before

The messages, in leptos-fluent’s layout, one directory per language:

app-title = Hello, Fluent
greeting = Hello, { $name }!
visits =
    { $count ->
        [one] You have been here once.
       *[other] You have been here { $count } times.
    }
visit-again = Visit again
not-found = There is nothing here.
search = Search
    .placeholder = Search the site
language-label = Language
language-apply = Apply
language-en = English
language-fr = Français
app-title = Bonjour, Fluent
greeting = Bonjour, { $name } !
visits =
    { $count ->
        [one] Vous êtes venu une fois.
       *[other] Vous êtes venu { $count } fois.
    }
visit-again = Revenir
not-found = Il n’y a rien ici.
search = Rechercher
    .placeholder = Rechercher sur le site
language-label = Langue
language-apply = Appliquer
language-en = English
language-fr = Français

The page’s components, with tr! where a String is wanted and move_tr! where text must follow the language:

use leptos::prelude::*;
use leptos_fluent::{move_tr, tr};

#[component]
pub fn Home() -> impl IntoView {
    let count = RwSignal::new(1);
    view! {
        <h1>{move_tr!("greeting", { "name" => "Ada" })}</h1>
        <p role="status">{move_tr!("visits", { "count" => count.get() })}</p>
        <button on:click=move |_| *count.write() += 1>{move || tr!("visit-again")}</button>
        <input type="search" placeholder=move_tr!("search.placeholder") aria-label=move_tr!("search") />
    }
}

/// The text of a notice, for code that needs a `String`.
pub fn visits_notice(count: i32) -> String {
    tr!("visits", { "count" => count })
}

And the application: the leptos_fluent! initializer, the shell, and a language selector reading the I18n context:

pub mod components;

use components::Home;
use leptos::prelude::*;
use leptos_fluent::{I18n, leptos_fluent, move_tr};
use leptos_meta::{MetaTags, Title, provide_meta_context};
use leptos_router::components::{Route, Router, Routes};
use leptos_router::path;

#[component]
fn I18nProvider(children: Children) -> impl IntoView {
    leptos_fluent! {
        children: children(),
        locales: "./locales",
        default_language: "en",
        sync_html_tag_lang: true,
        cookie_name: "lang",
        initial_language_from_cookie: true,
        set_language_to_cookie: true,
        initial_language_from_accept_language_header: true,
    }
}

pub fn shell(options: LeptosOptions) -> impl IntoView {
    view! {
        <!DOCTYPE html>
        <html>
            <head>
                <meta charset="utf-8" />
                <meta name="viewport" content="width=device-width, initial-scale=1" />
                <AutoReload options=options.clone() />
                <HydrationScripts options />
                <MetaTags />
            </head>
            <body>
                <App />
            </body>
        </html>
    }
}

#[component]
pub fn App() -> impl IntoView {
    provide_meta_context();
    view! {
        <I18nProvider>
            <Title text=move_tr!("app-title") />
            <Router>
                <header>
                    <LanguageSelector />
                </header>
                <main>
                    <Routes fallback=|| view! { <p>{move_tr!("not-found")}</p> }>
                        <Route path=path!("/") view=Home />
                    </Routes>
                </main>
            </Router>
        </I18nProvider>
    }
}

#[component]
fn LanguageSelector() -> impl IntoView {
    let i18n = expect_context::<I18n>();
    view! {
        <fieldset>
            <legend>{move_tr!("language-label")}</legend>
            {move || {
                i18n.languages
                    .iter()
                    .map(|lang| {
                        view! {
                            <label>
                                <input
                                    type="radio"
                                    name="language"
                                    value=lang
                                    checked=&i18n.language.get() == lang
                                    on:click=move |_| i18n.language.set(lang)
                                />
                                {lang.name}
                            </label>
                        }
                    })
                    .collect::<Vec<_>>()
            }}
        </fieldset>
    }
}

#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
    console_error_panic_hook::set_once();
    leptos::mount::hydrate_body(App);
}

The conversion

First the crate gains what Getting started’s has for its messages, and nothing else: its manifest names mf2 (with the leptos feature) and mf2-build, and forwards ssr and hydrate to mf2, and it has the three-line build script. Here that is Getting started’s Cargo.toml and build.rs, unchanged. (mf2 init --ssr --no-messages, in the crate, adds the same.) Then the conversion. Without --write the command changes nothing: it prints a diff of every Rust file it would rewrite, the .mf2 files it would write, and its report. Read the diff, then run it again with --write:

mf2 convert --from leptos-fluent .
mf2 convert --from leptos-fluent . --write

. is the application’s crate. The .ftl directory is the initializer’s locales: (or --locales DIR, else locales/), and each .ftl file becomes a .mf2 file beside it. The messages are in the application’s own crate, so the rewritten use lines import crate::tr. (Messages kept in a crate of their own are written there with -C DIR, and the use lines name that crate, read from its Cargo.toml, or --i18n-crate.)

Both runs exit with status 1, because the report is not empty: a migration is finished when the report is empty. Everything else was written.

What the command rewrote

src/components.rs, rewritten:

use leptos::prelude::*;
use crate::tr;

#[component]
pub fn Home() -> impl IntoView {
    let count = RwSignal::new(1);
    view! {
        <h1>{tr!("greeting", name = "Ada")}</h1>
        <p role="status">{Signal::derive(move || tr!("visits", count = count.get()).to_string())}</p>
        <button on:click=move |_| *count.write() += 1>{tr!("visit-again")}</button>
        <input type="search" placeholder=tr!("search.placeholder") aria-label=tr!("search") />
    }
}

/// The text of a notice, for code that needs a `String`.
pub fn visits_notice(count: i32) -> String {
    tr!("visits", count = count).to_string()
}

Each call became tr!, with its arguments written name = value. What it became depends on where it stands:

Where the call standsleptos-fluentAfter
where Leptos renders it: a child, an attribute or prop value, with arguments that cannot change (literals, or none)tr!(…), move_tr!(…), move || tr!(…)tr!(…): the description renders itself and follows a language switch
the same, with an argument that may change, in a closuremove || tr!(…)the closure kept, returning tr!(…).to_string()
anywhere else: a function, a closure, a match arm, an if branchtr!(…)tr!(…).to_string(), the same String as before
move_tr! outside a view, or with an argument that may changemove_tr!(…)Signal::derive(move || tr!(…).to_string()), move_tr!’s own expansion: the same Signal<String>

The command keeps every type and every evaluation of an argument as it was. It cannot know that count.get() reads a signal, so the visit counter kept a closure. The idiomatic form passes the signal itself, and the text follows it without a closure at the call site (Call sites, “Arguments that change”). Change it by hand where you like:

// before
<p role="status">{Signal::derive(move || tr!("visits", count = count.get()).to_string())}</p>
// after
<p role="status">{tr!("visits", count = count)}</p>

The other rewrites:

  • tr!(i18n, "id", …): the context is dropped.
  • { "name" => value } becomes name = value. A name that is not a Rust identifier, such as $user-name, is written "user-name" = value.
  • A Fluent attribute keeps its id: search.placeholder is the message the conversion made from search’s .placeholder.
  • use leptos_fluent::{move_tr, tr}; becomes use crate::tr;.

Every call is also checked against the converted messages. An id that is not a message, or arguments that are not exactly the message’s variables, are reported, because tr! checks both when it compiles. leptos-fluent ignored an extra argument and printed {$name} for a missing one.

A file that does not name leptos_fluent is not touched. A second run therefore changes nothing: it exits as the first did, and reports the .mf2 files it would write as unchanged.

What is left to do by hand

For the application above, the report says:

./src/lib.rs:5:1: error: this `use leptos_fluent` is not rewritten: it names I18n, leptos_fluent, move_tr [leptos-fluent-import]
./src/lib.rs:12:5: error: `leptos_fluent!` initializes leptos-fluent: replace it with the generated `install()`, on the server and in the browser; its `lang` cookie is not read: the client always writes `mf2_locale`, so to keep the language readers chose before, add `CookieLocale { name: "lang", ..Default::default() }` to the server's `Negotiator` as an extra source, after `CookieLocale::default()` and before `AcceptLanguage` [leptos-fluent-initializer]
./src/lib.rs:64:33: error: the `leptos-fluent` context: its language and languages become `<LocaleSwitcher>` or the locale API, its `tr` / `tr_with_args` `msg_id!` and `TrDyn` [leptos-fluent-context]
mf2 convert: 22 entries in 2 locale(s), 2 .mf2 file(s) written; 2 Rust file(s) rewritten; 3 error(s), 0 warning(s)

The sample’s manifest does not name leptos-fluent; a real application’s does, and its report also has a leptos-fluent-dependency line for it. Each code, and how to finish it:

CodeWhatInstead
leptos-fluent-initializerleptos_fluent! { … } or static_loader! { … }the generated install(), called in the browser’s entry point and at the server’s start. The language negotiation options become mf2::axum’s Negotiator (Switching language)
leptos-fluent-contextthe I18n context: i18n.language, i18n.languages, i18n.tr(…)a language selector becomes <LocaleSwitcher>; a lookup by an id known only at run time becomes msg_id! and TrDyn (Call sites)
leptos-fluent-importa use leptos_fluent::… naming more than tr and move_trthe generated tr, at the crate’s root
leptos-fluent-dependencyleptos-fluent or fluent-templates in a Cargo.tomlmf2 (with leptos) and mf2-build, with ssr and hydrate forwarded to mf2 (Getting started)
leptos-fluent-dynamic-idan id that is not a string literala literal id, or msg_id! and TrDyn
leptos-fluent-if-formtr!(if c { "a" } else { "b" })if c { tr!("a") } else { tr!("b") }, each branch the same type
leptos-fluent-cfg#[cfg] inside a calla #[cfg] on a statement around it
leptos-fluent-argument-name, leptos-fluent-calla call in none of the documented formsby hand
leptos-fluent-unknown-id, leptos-fluent-argumentsthe call does not match the converted messagethe message or the call (rewritten already)
leptos-fluent-parsea file that does not tokenizefix it and run again

The finished src/lib.rs: the initializer and the selector are replaced, and the shell gains what Getting started explains (<html lang dir>, the catalog preload, the switcher):

pub mod components;

use components::Home;
use leptos::prelude::*;
use leptos_meta::{MetaTags, Title, provide_meta_context};
use leptos_router::components::{Route, Router, Routes};
use leptos_router::path;
use mf2::leptos::{CatalogLinks, CatalogPreload, LocaleOption, LocaleSwitcher, html_lang};

mf2::include_generated!();

pub fn shell(options: LeptosOptions) -> impl IntoView {
    let (lang, dir) = html_lang();
    view! {
        <!DOCTYPE html>
        <html lang=lang dir=dir>
            <head>
                <meta charset="utf-8" />
                <meta name="viewport" content="width=device-width, initial-scale=1" />
                <AutoReload options=options.clone() />
                <HydrationScripts options />
                <MetaTags />
                <CatalogPreload />
                <CatalogLinks />
            </head>
            <body>
                <App />
            </body>
        </html>
    }
}

#[component]
pub fn App() -> impl IntoView {
    provide_meta_context();
    view! {
        <Title text=tr!("app-title") />
        <Router>
            <header>
                <LocaleSwitcher label=tr!("language-label") button=tr!("language-apply")>
                    <LocaleOption tag="en">{tr!("language-en")}</LocaleOption>
                    <LocaleOption tag="fr">{tr!("language-fr")}</LocaleOption>
                </LocaleSwitcher>
            </header>
            <main>
                <Routes fallback=|| view! { <p>{tr!("not-found")}</p> }>
                    <Route path=path!("/") view=Home />
                </Routes>
            </main>
        </Router>
    }
}

#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
    console_error_panic_hook::set_once();
    install();
    mf2::leptos::hydrate_body(App);
}

The server is Getting started’s src/main.rs: it calls the generated install() and negotiates each request’s language. It reads this library’s cookie, mf2_locale, which the client writes on every switch — not the initializer’s lang. So that a reader’s earlier choice is kept after the migration, add a source for the old cookie after the default one and before AcceptLanguage, as the report says: .source(CookieLocale { name: "lang", ..CookieLocale::default() }). The manifest is Getting started’s too: leptos-fluent gives way to mf2 and mf2-build, and ssr and hydrate forward to mf2.

What changes for the reader

  • Text follows a language switch everywhere. A tr! in a view used to be a String fixed when the view was built. It is now a description that the library re-renders on a switch, the fix leptos-fluent asked for with move_tr! or a closure.
  • The browser downloads one language. leptos-fluent compiled every language’s .ftl into the wasm. Now each language is a catalog fetched when it is needed, and the wasm holds no message text.
  • Numbers are localized. fluent-bundle printed a number as Rust’s f64 does. :number groups digits and uses each language’s symbols (with the fn-number feature).
  • Bidi isolation follows the MF2 specification. A placeholder whose direction could differ from the message’s (a string) is isolated; a formatted number, in the locale’s own direction, is not. The characters are invisible, and attributes a program reads get none (Call sites).
  • Terms are copied. MF2 has no Fluent terms (-brand). The conversion copies a term into every message that uses it, and its report counts where. A later change to the brand name is a change to each message.

A decimal with more than three places now chooses its plural variant by the rounded number it shows (1.0004 is “one”), and plural rules are current CLDR’s. The conversion keeps everything else: for the same arguments a converted message chooses the variant Fluent chose.

Upgrading from 1.x

2.0 is not source-compatible with 1.x. Five crates became one, several paths moved, and code that 1.x asked the application to write is now generated. This page lists each change with its 1.x form and its 2.0 form, so an upgrade is a list of edits. The changelog has every change in detail.

Blocks marked 1.x show the old code for comparison and are not compiled. The 2.0 forms are compiled: the command-line tool below here, and each web file on the page this one links to, where it appears whole.

One crate

An application names mf2 and mf2-build, nothing else of this project. Each 1.x crate is a feature of mf2 now:

1.x crate2.0
leptos-mf2mf2’s leptos (Leptos 0.9) or leptos-0-8, and one mode: ssr, hydrate or csr
mf2-axummf2’s axum
mf2-native (1.1.0, never published)mf2’s native
mf2-ratatui (1.1.0, never published)mf2’s ratatui (which turns on native)
the translation crate (hello-i18n)gone: the application’s own crate holds locales/ and the build script

A mode turns the Leptos layer on; leptos alone chooses the line. Two modes, both lines, or a mode with no line is a compile error that says what to write. native or ratatui beside hydrate or csr is refused only in a browser build (wasm32), so a workspace holding a browser client and a terminal UI still checks as one.

1.x, a server-rendered application and its translation crate:

[workspace]
members = [".", "i18n"]

[dependencies]
hello-i18n = { path = "i18n", features = ["fn-number"] }
leptos-mf2 = "1"
mf2-axum = { version = "1", optional = true }

[features]
hydrate = ["leptos-mf2/hydrate", "hello-i18n/hydrate"]
ssr = ["leptos-mf2/ssr", "hello-i18n/ssr", "dep:mf2-axum"]

2.0, one crate. The formatting functions are mf2’s features, the same for both builds; the mode and axum are forwarded as leptos’s are:

[dependencies]
mf2 = { version = "2", features = ["leptos", "fn-number"] }

[build-dependencies]
mf2-build = "2"

[features]
hydrate = ["leptos/hydrate", "mf2/hydrate"]
ssr = ["leptos/ssr", "mf2/ssr", "mf2/axum"]

Move i18n/locales/ to locales/, i18n/mf2.toml (if any) beside Cargo.toml, and delete the translation crate. watch-additional-files becomes ["locales"]. Getting started shows the whole manifest.

The build script

1.x:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    mf2_build::Build::new()?
        .emit(mf2_build::Emit::Native)
        .emit_cargo(true)
        .run()?
        .into_result()?;
    Ok(())
}

2.0: one call. What the generated module holds follows the features mf2 is built with, so a native application no longer asks for Emit::Native:

fn main() {
    mf2_build::run();
}

mf2_build::Build remains for a build that needs more, such as Emit::NativeFiles for catalogs shipped beside the executable.

Paths

1.x2.0
leptos_mf2::…mf2::leptos::…
mf2_axum::…mf2::axum::…
mf2_native::NativeI18nthe generated install() and the locale functions; mf2::native::Catalogs for the explicit form
mf2_native::NativeErrormf2::native::Error
mf2_ratatui::…mf2::ratatui::…
hello_i18n::tr!tr!, at the crate’s root, where mf2::include_generated!() puts it

setup() and install()

A 1.x translation crate wrote setup() by hand, and the application installed it once on each side. 2.0 generates both setup() and install(). Delete the hand-written setup(): it would clash with the generated one.

1.x:

// i18n/src/lib.rs
mf2::include_generated!();

pub fn setup() -> mf2::leptos_mf2::Setup {
    mf2::leptos_mf2::Setup::new(registry(), &host::HOST, MANIFEST_HASH, SOURCE_LOCALE, LOCALES)
}

// the browser's entry point
leptos_mf2::install(hello_i18n::setup());
leptos_mf2::hydrate_body(App);

// the server's main
mf2_axum::install(hello_i18n::setup(), hello_i18n::CATALOGS)?;

2.0:

// src/lib.rs
mf2::include_generated!();

// the browser's entry point
install();
mf2::leptos::hydrate_body(App);

// the server's main
hello::install();

A client-only application’s generated setup() also carries the language-matching data its browser needs (below); a Setup built by hand with Setup::new has none.

The server: a tower layer

1.x negotiated inside the render: a closure called provide_locale, and every _with_context entry point had to be given it. A missed one rendered a page in the default language with no other symptom. In 2.0 the Negotiator is a tower layer, and the routes and the fallback are Leptos’s plain forms.

1.x:

let negotiator = Arc::new(
    Negotiator::empty()
        .source(QueryParam::default())
        .source(CookieLocale::default())
        .source(AcceptLanguage)
        .sink(CookieLocale { secure: !cfg!(debug_assertions), ..CookieLocale::default() }),
);
let context = {
    let negotiator = Arc::clone(&negotiator);
    move || {
        mf2_axum::provide_locale(&negotiator);
    }
};
let app = Router::new()
    .merge(mf2_axum::catalog_routes())
    .leptos_routes_with_context(&leptos_options, routes, context.clone(), {
        let leptos_options = leptos_options.clone();
        move || shell(leptos_options.clone())
    })
    .fallback(file_and_error_handler_with_context(context, shell));

2.0:

let app = Router::new()
    .leptos_routes(&leptos_options, routes, {
        let leptos_options = leptos_options.clone();
        move || shell(leptos_options.clone())
    })
    .fallback(file_and_error_handler(shell))
    .layer(Negotiator::default())
    .merge(catalog_routes());

Getting started shows the whole server.

The default order changed. 1.x’s Negotiator::default() read the cookie, then Accept-Language. 2.0’s reads ?lang=, then the cookie, then Accept-Language, and its cookie is Secure outside a debug build: the chain 1.x’s pages spelled out by hand. A negotiator built with Negotiator::empty() and .source(…) works as before; give it to .layer(…).

mf2::axum::path_prefix_redirect keeps its signature (axum::middleware::from_fn) and reads the query parameter’s name from the layer. The cookie is now written only for an explicit choice (?lang= or the path), not for a guess from Accept-Language.

The switcher

<LocaleSwitcher/> with no children lists every language, in the order of Locale::ALL, each named by its language.<tag> message. 1.x needed a <LocaleOption> per language; those children still work, for a list of your own. The helpers that take a language take the generated Locale: tag=Locale::Fr. Switching language has the details.

tr! arguments

An argument converts through mf2::IntoArg. Every type 1.x took still converts, and more do: every integer type exactly (u64 and u128 past i64 as their exact decimal), bool as true or false for .match, paths, SystemTime, jiff’s instants and civil dates with native, and any type with Display, as its text, which is not translated. A type that is none of these is a compile error at the argument, naming IntoArg. The table in Call sites lists them.

An application’s own type implemented From<T> for ArgValue in 1.x. That still works; IntoArg is the 2.0 form. 1.x:

impl From<Entries> for mf2::ArgValue {
    fn from(entries: Entries) -> mf2::ArgValue {
        mf2::ArgValue::from(entries.0 as i64)
    }
}

The 2.0 form is in the program below.

Native applications

1.1.0’s mf2-native, never published, kept the catalogs and the language in a NativeI18n value that every format went through. 2.0 installs them once for the process: a description shows its text wherever text is wanted, and the language is the generated Locale.

1.x:

let mut i18n = mf2_native::NativeI18n::embedded(&count_i18n::CORPUS)?;
if let Some(lang) = args.lang.as_deref() {
    i18n.set_locale(lang)?;
}
println!("{}", i18n.format(&count_i18n::tr!("files", dir = dir, count = n as i64)));

2.0, the whole program, on the messages of count:

//! Counts the files in a directory: `count`, as a 1.x tool reads after
//! the upgrade.

use std::path::PathBuf;
use std::process::ExitCode;

use clap::Parser;

mf2::include_generated!();

#[derive(Parser)]
struct Args {
    #[arg(default_value = ".")]
    dir: PathBuf,
    /// Was `Option<String>`, checked by `set_locale`. `Locale` parses
    /// through the language matcher and refuses a language the tool lacks.
    #[arg(long)]
    lang: Option<Locale>,
}

/// An application's own type as an argument: 1.x's `From<Entries> for
/// ArgValue` becomes `IntoArg`.
struct Entries(usize);

impl mf2::IntoArg for Entries {
    fn into_arg(self) -> mf2::ArgValue {
        mf2::IntoArg::into_arg(self.0)
    }
}

fn main() -> ExitCode {
    let args = Args::parse();
    // Was `NativeI18n::embedded(&CORPUS)?`: it cannot fail now.
    install();
    if let Some(lang) = args.lang {
        set_locale(lang);
    }
    match std::fs::read_dir(&args.dir) {
        Ok(entries) => {
            // Was `i18n.format(&tr!(…))`: a description is its own text.
            let count = Entries(entries.count());
            println!("{}", tr!("files", dir = &args.dir, count = count));
            ExitCode::SUCCESS
        }
        Err(error) => {
            // An `io::Error` is an argument as its text.
            eprintln!("{}", tr!("unreadable", dir = &args.dir, error = error));
            ExitCode::FAILURE
        }
    }
}

The rest of the native API, 1.x to 2.0:

1.x2.0
NativeI18n::from_directory(&CORPUS, dir)?install_from_directory(dir)?, which accepts a partial set: only the source language’s file is required
i18n.set_locale("fr")?set_locale(Locale::Fr), for every thread; with_locale(Locale::Fr, …) for one thread and one scope
i18n.locale(), i18n.locale_source()current_locale(), mf2::native::locale_source()
i18n.format(&message)message.to_string(), println!("{}", message), .to_plain_string() (never isolated), .to_cow() (borrowed when the message is plain text)
i18n.available_locales()Locale::ALL
several corpora, each with its own NativeI18none install per process (a second corpus panics); mf2::native::Catalogs::embedded(&CORPUS)? and catalogs.format("fr", &message) for another

Formatting before install() panics in a build whose only mode is native, and the message names install(). Beside a Leptos mode it never panics: a message shows no text until the page’s or the request’s catalog is there. Tests need no install: with_locale loads what it needs (Testing).

Ratatui

1.x’s mf2_ratatui::line, mf2_ratatui::text and MarkupStyles are gone. A description converts into a Span, Line or Text itself, and a Theme, set once, styles its markup.

1.x:

let styles = MarkupStyles::new()
    .with("ok", Style::new().fg(Color::Green).bold())
    .with("host", Style::new().underlined());
let title = mf2_ratatui::line(i18n, &native_demo_i18n::tr!("title"), &styles);
let status = mf2_ratatui::text(i18n, &native_demo_i18n::tr!("status", host = host, sent = sent), &styles);
Paragraph::new(status).block(Block::bordered().title(title))

2.0: the theme once, at start-up, with a generated constant per markup name; then each call site as Ratatui text:

set_theme(
    Theme::default()
        .style(markup::OK, Style::new().green().bold())
        .style(markup::HOST, Style::new().underlined()),
);

Paragraph::new(Text::from(tr!("status", host = host, sent = sent)))
    .block(Block::bordered().title(tr!("title")))

A description is Styled as a Line (tr!("quit").bold()) and a Widget. Keep a styled line in one message: collecting several into one Line flattens their markup. Native CLI and Ratatui apps shows a whole terminal UI.

Choosing a language

2.0 has one language matcher, built on CLDR’s language-matching data, and everything that chooses a language uses it: the server, a client-only application’s boot, install, set_locale, with_locale and parsing a Locale. Most choices stay the same; these differ from 1.x:

  • zh-Hant-TW and zh-Hant find an application’s zh-TW.
  • Traditional and Simplified Chinese no longer stand in for each other: CLDR has no rule between the two scripts, so a Traditional reader gets their next language, else the source language. An application that serves both needs a catalog for each (Troubleshooting).
  • Serbian’s Latin and Cyrillic are served for each other.
  • A language CLDR says a reader understands is served when theirs is missing: Breton readers get French, Catalan readers Spanish.
  • Of several regions, the closest wins: an Australian reader gets en-GB before en-US.
  • A reader’s list is weighed as a whole: de-AT first finds de before an exact match of the second language.

A client-only application carries the part of CLDR’s data its languages need, in the generated setup().

[locale_data]

In 1.x a list of codes under [locale_data] changed nothing when a message took its currency or unit from a variable: the catalog carried every code, and dynamic-currency or dynamic-unit warned. In 2.0 the list says which codes the variable can hold. The catalog carries those (and the ones the messages name), with no warning; a code outside the list formats with the code as its symbol. mf2.toml has the details.

Checks and commands

  • mf2 fmt’s layout changed: a blank line after the frontmatter’s --- and around a message laid out on lines of its own. Run mf2 fmt once; until then mf2 fmt --check reports the files.
  • dropped-markup is an error: a translation that leaves out the source’s markup fails the build. A corpus that drops markup on purpose sets it to "warn" under [lints] (Lints).
  • @do-not-translate messages are not missing, in missing-translation and in mf2 stats’s counts.
  • mf2 check uses the crate’s features as cargo resolves them, so it reports what the build reports.
  • mf2 import checks what it would write, and writes nothing if that brings an error. A JSON import that leaves out ids the language does not have yet exits 1: use XLIFF, which adds them.

Behaviour that changed without an edit

  • A language switch that meets another deploy’s catalog now reloads into the new language, rather than refusing the switch.
  • Under a Leptos mode, Tr, TrArgs, TrRich and TrDyn implement Display. .to_string() stays the leanest in a browser build; {} adds a few dozen bytes, and {:?} about 1 KB.
  • A native application’s dates follow the system’s daylight-saving rules, even when the system zone has no IANA name.

Versions and what 2.x promises

Every crate of Rust MF2 is released together, at one version: mf2 2.0.0 goes with mf2-build 2.0.0, and each crate asks for the others at exactly that version. An application names two: mf2, with the features it needs (a Leptos line and a mode, axum, native, ratatui, …), and mf2-build in its translation crate’s build; cargo update moves them together. mf2-cli is not a dependency: it is the mf2 command, installed with cargo install mf2-cli, at the same version as the rest. The other crates are what these are built on, and an application does not name them.

The version numbers follow Semantic Versioning: within 2.x, a patch release fixes things and a minor release adds them; neither breaks a program that 2.0 built. Anything that would is 3.0.

What 2.x promises

  • The public API of the published crates — every item their documentation on docs.rs shows, with the feature flags that turn items on. mf2‘s promise is per mode: what it offers depends on the mode an application turns on, and the Leptos modes exclude one another, so its API is listed once for each — the core (no mode), ssr, hydrate, csr, native, ratatui and axum. An item a mode has, that mode keeps within 2.x; a mode may gain items in a minor release. A type marked #[non_exhaustive] may gain a variant or a field in a minor release: match it with a _ arm, and build it with its constructor or from Default, not with a struct literal. Among them are the MF2 data model’s alternatives (a later MF2 may add structures), error types, the functions’ option values, mf2.toml’s Config, and what a build or a negotiation returns.
  • The forms of tr!: in text, attributes, props and strings, with arguments, signals and markup, as Call sites shows them. A call site that compiles under 2.0 compiles under every 2.x, and a message that is checked at compile time stays checked.
  • The generated module’s items — what mf2-build writes into the translation crate, as the book shows them: tr! and msg_id!, Locale (its variants, ALL, SOURCE, tag, dir, best_match, its parsing and Display, format with native, name when every language names itself), LOCALES, SOURCE_LOCALE, LANGUAGE_MATCHING, CATALOGS, CORPUS, registry(), the functions that install the catalogs and choose the language (setup, install, install_from_directory, set_locale, current_locale, preload_locale, with_locale), markup with ratatui, and the prelude. Each is there when the build has what it needs, as the modes decide; a name that begins with __ is not promised.
  • The mf2 command line: its commands (init, check, compile, fmt, stats, dump, pseudo, export, import, watch, convert) and their flags. A script that runs under 2.0 runs under 2.x.
  • The resource format as mf2 fmt writes it: a .mf2 file that 2.0 accepts, 2.x accepts, with the same meaning. The layout fmt gives it — where it leaves blank lines — may change in a minor release, so a project that runs mf2 fmt --check in CI runs mf2 fmt once after such an upgrade.

What it does not promise

  • Items hidden from the documentation (#[doc(hidden)]). The code that mf2-build generates, the tr! macro, the runtime and the mf2 command line share them; since the crates are released together and ask for one another at one exact version, they always agree. Do not call them yourself. They are:

    • what tr! and the generated module expand to (tr, tr_args0 … tr_args_n, tr_rich, tr_dyn, markup, ArgValue::str_static, the argument dispatch mf2::__arg, the proc-macros of mf2-macros), and the bits of a MsgId;
    • mf2-build’s pipeline — every module (config, loader, corpus, manifest, check, slice, catalog, codegen, pseudo, …) — and what the build hands the command line (Outcome::catalogs, Outcome::manifest, the loader’s records); what a build.rs uses (Build, Config, Features, Lint, Level, Report, Error) is promised;
    • the compiled catalog’s layout: in mf2-catalog everything but Catalog (loading it, its locale, direction, manifest hash, message count, lookup, its bytes), CldrVersion and the error types; the runtime’s access to it (FnContext::catalog, Formatter::simple_ref, StrRef, Sink::push_catalog_text, plural_category), and the manifest (Manifest, Compiled::manifest);
    • the locale matcher’s table and its entry points, which the generated module, the native module and the web server call (LanguageMatching::new, LanguageMatching::EMPTY, LanguageMatching::best_match, LanguageMatching::distance_of, LanguageMatching::cldr, Corpus::with_language_matching, mf2::leptos::best_locale); LanguageMatching itself, the generated LANGUAGE_MATCHING and Setup::with_language_matching are promised;
    • the switches and helpers the function crates and the build share (INTL_NUMBERS, Number::format_by_host, mf2_fn_datetime::icu::prime, literal_options, CldrVersion::to_u32 / from_u32, mf2_build::Error::io), and the WG test suite’s error names (ErrorKind::suite_name);
    • mf2-locale-data’s tables and entry builders — all but its errors, CLDR_VERSION and direction;
    • mf2-resource’s Rust API, which mirrors the draft resource format (below) — the format itself, as mf2 fmt writes it, is promised;
    • in mf2::leptos, what mf2::axum and the library’s own tests use: the rendering glue and its view states, the table of catalogs the server serves, the names the page and the server share (links), and live_nodes, installed.

    Each published crate commits the list of what it does promise as api.txt, and mf2 one list per mode in api/ (cargo xtask api); each list’s first line says it is what 2.x promises. A change to it fails the project’s CI until the list is updated with it — which is how a change to the promise is seen and reviewed. From the second release on, each release is also compared with the version before it on crates.io by cargo-semver-checks, and one that breaks it is refused (cargo xtask release). The check skips mf2-macros: a procedural-macro crate has no Rust API for the tool to read, and its promise is the macros’ names and the tr! forms, which the project’s tests hold.

  • The compiled catalog (.mf2b) and the manifest. They are what a build produces and its server and browser read, not a format to keep. A server and a client built together agree; the manifest’s hash is how each checks that. After an upgrade, rebuild both: a catalog from one version is not guaranteed to load in another.

  • The wording of reports — mf2 check’s messages, compile errors, statistics. They get clearer; match on exit status, not text.

  • Exact figures: sizes, timings and the like, which the project measures and budgets but which move with every dependency.

Leptos versions

mf2 supports two Leptos lines in 2.x, each a feature an application names beside its mode: leptos is Leptos 0.9, the default line, and leptos-0-8 is Leptos 0.8 (see Getting started). Each line has its helper crate of components, mf2-leptos-ui-0-9 and mf2-leptos-ui-0-8, which mf2 depends on; an application does not name them. Both lines, or a mode with neither, is a compile error that says what to write.

WhenWhat changesRelease
a new Leptos 0.9 pre-release, or 0.9’s releasetaken as it comes (2.0.0 is built on 0.9.0-beta)patch
a new Leptos line (0.10)added as a feature beside the others, with its helper crateminor
the leptos feature moves to another line, or a line is droppedan application’s build breaks3.0

The W3C Message Resource format

The .mf2 resource format follows a W3C draft that is not final. If the draft changes, 2.x follows it in a way that keeps your files working: mf2 fmt accepts the form 2.0 wrote and can rewrite it in the new one. A change that would make 2.x reject a file that 2.0 accepted waits for 3.0.

Minimum supported Rust version

Rust 1.88. Every crate states it as rust-version, and CI checks the published crates on exactly that release, natively and for wasm32-unknown-unknown, on both Leptos lines — and checks that 1.87 does not build them, so the figure is measured rather than assumed. Leptos 0.9 itself needs 1.88.

Raising the minimum Rust version is a minor release, and the changelog says so.

Where the releases stand

1.0.0 is on crates.io: all sixteen crates of the 1.0 family, on 26 September 2026. 1.1.0 was never published, and will not be: it was prepared as a minor release after 1.0.0, and its fixes and its native support are part of 2.0.0. 2.0.0 is the next release, not yet published: one crate, mf2, where 1.x had leptos-mf2 and mf2-axum beside it; how to move a 1.x application is in Upgrading from 1.x. See the changelog.