Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Introduction

es-fluent connects Rust types to Project Fluent messages. Derive macros define typed message IDs and arguments, cargo es-fluent maintains Fluent translation files, and runtime managers resolve those messages in embedded, Dioxus, or Bevy applications.

This book is for Rust application developers who want to:

  • generate .ftl resources from structs and enums;
  • keep fallback and translated resources aligned;
  • switch locales through an explicit runtime context;
  • build typed language pickers; and
  • validate localization in local development and CI.

Start with Choose crates, then follow Getting started for a working embedded example. The remaining chapters cover configuration, derive behavior, resource layout, runtime integrations, and the CLI in more depth.

The examples assume familiarity with Cargo and basic Rust application structure. Familiarity with Fluent syntax is useful when replacing generated fallback text with production copy.

Choose crates

Most applications need the es-fluent facade, one runtime manager, and the CLI during development.

NeedCrate or command
Typed messages, labels, variants, and choiceses-fluent
General Rust, CLI, TUI, or desktop runtimees-fluent-manager-embedded
Dioxus client or SSR runtimees-fluent-manager-dioxus
Bevy ECS and UI runtimees-fluent-manager-bevy
Typed locale enum and language labelses-fluent-lang
Generate, check, sync, format, and inspect FTLcargo es-fluent from es-fluent-cli
Track locale assets and compile-check fallback messageses-fluent-build under [build-dependencies]

A general Rust application can start with:

[dependencies]
es-fluent = "0.18"
es-fluent-manager-embedded = "0.18"
unic-langid = "0.9"

[build-dependencies]
es-fluent-build = "0.18"

Install the CLI separately:

cargo install es-fluent-cli --locked

Compatible release lines

The framework-specific managers follow their framework version:

SurfaceRelease lineRuntime compatibility
es-fluent, CLI, embedded manager, and language enum0.18.xGeneral Rust
es-fluent-manager-dioxus0.7.xDioxus 0.7.x
es-fluent-manager-bevy0.19.xBevy 0.19.x

Supporting crates

Application code normally uses the facade and a concrete manager. The following crates are intended for narrower integration work:

  • es-fluent-derive and es-fluent-lang-macro implement macros re-exported by the public facades.
  • es-fluent-manager-core exposes shared runtime contracts for custom manager integrations.
  • es-fluent-manager-macros exposes the manager module and Bevy text macros re-exported by concrete managers.

Continue with Getting started, or choose a manager in Runtime managers.

Getting started

This tutorial creates a Rust binary that generates a fallback Fluent resource and prints a typed localized message. Run the commands from a Cargo package with both src/lib.rs and src/main.rs; the CLI discovers localizable types through library targets.

Install dependencies

Add the facade, embedded manager, locale identifier, and build helper:

[dependencies]
es-fluent = "0.18"
es-fluent-manager-embedded = "0.18"
unic-langid = "0.9"

[build-dependencies]
es-fluent-build = "0.18"

Install the Cargo subcommand:

cargo install es-fluent-cli --locked

Configure locale assets

Create i18n.toml next to Cargo.toml:

fallback_language = "en"
assets_dir = "assets/locales"

Create the fallback locale directory:

mkdir -p assets/locales/en

Create Cargo’s default custom-build target to track locale changes and write the strict fallback-message catalog:

// build.rs
fn main() {
    es_fluent_build::track_i18n_assets();
}

See Configure a project for the package-local missing-message policy, feature-gated derives, namespace allowlists, additional domains, and validation settings.

Define the runtime module

Create a library-reachable manager module:

// src/i18n.rs
pub use es_fluent_manager_embedded::{
    EmbeddedI18n as I18n, EmbeddedInitError, LocalizationError,
};

es_fluent_manager_embedded::define_i18n_module!();

Define a typed message in the library target:

// src/lib.rs
pub mod i18n;

use es_fluent::EsFluent;

#[derive(EsFluent)]
pub struct Greeting<'a> {
    pub name: &'a str,
}

Generate fallback FTL

Verify the setup, then generate:

cargo es-fluent doctor
cargo es-fluent generate

For a package named my-crate, generation creates assets/locales/en/my-crate.ftl with an entry like:

## Greeting

greeting = Greeting { $name }

Replace the generated value with fallback-language copy:

## Greeting

greeting = Hello, { $name }!

Conservative generation preserves edited values on later runs.

Localize the message

A package named my-crate is imported as my_crate from its binary target:

// src/main.rs
use my_crate::{Greeting, i18n::I18n};
use unic_langid::langid;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let i18n = I18n::try_new_with_language(langid!("en"))?;
    println!("{}", i18n.localize_message(&Greeting { name: "Ada" }));
    Ok(())
}

Run the program:

cargo run

It prints:

Hello, Ada!

Continue the workflow

After adding or changing localizable types, use:

cargo es-fluent generate
cargo es-fluent status --all-locales

Add a translated locale with:

cargo es-fluent add-locale fr-FR

Then edit the seeded FTL and run cargo es-fluent check --all-locales. See CLI reference for command behavior and Runtime managers for Dioxus and Bevy setup.

Configure a project

Each package that owns localizable types uses an i18n.toml beside its Cargo.toml. The configuration identifies the fallback locale, the locale asset root, and optional generation rules.

fallback_language = "en"
assets_dir = "assets/locales"

# Optional Cargo features needed to compile localizable library types.
# fluent_feature = ["my-feature"]

# Optional allowlist for literal namespace values.
# namespaces = ["ui", "errors"]

# Optional package-local missing-message policy. The default is "strict".
# missing_message_policy = "fallback-str"

# Optional additional package-local FTL resources.
# domains = ["emails"]

# Optional: disable warnings for translated values that match fallback text.
# check_fallback_copies = false

Fields

FieldRequiredMeaning
fallback_languageYesCanonical BCP-47 tag used for generated fallback resources.
assets_dirYesLocale asset directory, relative to and contained within the package root.
fluent_featureNoCargo features enabled while the CLI collects derive inventory.
namespacesNoAllowlist for literal namespace = "..." values.
domainsNoAdditional FTL domains owned by this package.
missing_message_policyNostrict (default) requires fallback message values; fallback-str gives normal typed lookup a generated snake_case fallback.
check_fallback_copiesNoEnables or disables all-locale warnings for unchanged fallback text.

Locale directory names and CLI locale arguments must use canonical BCP-47 tags, such as en, fr-FR, and zh-CN. Use canonical replacements for deprecated aliases.

The configured asset path must stay inside the package. Existing path components, locale directories, and discovered FTL paths must have the expected file type and must not be symlinks. These checks prevent commands from reading or writing outside the configured locale tree.

Missing-message policy

The default strict policy validates every generated key against the package’s fallback catalog. Missing and attribute-only fallback messages produce source-spanned compile errors.

Set the policy for a package that must keep normal typed rendering available after locale and Fluent fallback are exhausted:

missing_message_policy = "fallback-str"

Normal localize_message(...) and localize_label(...) calls then return the generated snake_case source name for a missing value. Fallible try_localize_message(...) and try_localize_label(...) calls still return None. The build helper continues to parse the fallback catalog, so malformed FTL and duplicate IDs remain errors.

Resource layout

The Cargo package name is the default FTL domain:

assets/locales/
├── en/
│   └── my-package.ftl
└── fr-FR/
    └── my-package.ftl

A custom [lib] name or renamed dependency does not change that default domain.

Namespaces split a domain into nested files:

assets/locales/en/my-package/ui.ftl

See Namespaces and file splitting for the supported namespace rules.

Additional domains create sibling resources:

domains = ["emails"]

A type annotated with #[fluent(domain = "emails")] keeps its generated message ID and writes to emails.ftl. Do not list the Cargo package name in domains; the default domain is implicit. Domains belong to the package that declares them and do not reference another crate.

Workspaces

Give every package that owns localizable types its own configuration, fallback resources, and library-reachable manager module. From the workspace root, run:

cargo es-fluent generate --path .
cargo es-fluent status --path . --all-locales
cargo es-fluent check --path . --all-locales

Each selected package is validated against its own domains, IDs, and missing-message policy. Strict and fallback-string packages can coexist in one workspace build. Different packages may reuse a domain name or generated ID without colliding.

Feature-gated messages

When derives are behind Cargo features, list those features in fluent_feature so CLI inventory matches the application build:

fluent_feature = ["admin-ui", "reports"]

Keep the list package-local. Package-filtered CLI runs compile only the selected package and its required dependencies.

Deriving messages

The EsFluent derive macro turns a struct or enum into a localizable message. Each type maps to one or more keys in your .ftl files, and fields become Fluent arguments.

  • Enums: Each variant becomes a message ID (e.g., MyEnum::Variantmy_enum-Variant).
  • Structs: The struct itself becomes the message ID (e.g., MyStructmy_struct).
  • Fields: Fields are automatically exposed as arguments to the Fluent message.
use es_fluent::EsFluent;

#[derive(EsFluent)]
pub enum LoginError {
    InvalidPassword,                   // no params
    UserNotFound { username: String }, // exposed as $username in the ftl file
    Something(String, String, String), // exposed as $f0, $f1, $f2 in the ftl file
    SomethingArgNamed(
        #[fluent(arg = "input")] String,
        #[fluent(arg = "expected")] String,
        #[fluent(arg = "details")] String,
    ), // exposed as $input, $expected, $details
}

#[derive(EsFluent)]
pub struct WelcomeMessage<'a> {
    pub name: &'a str, // exposed as $name in the ftl file
    pub count: i32,    // exposed as $count in the ftl file
}

The CLI generates the following FTL entries for these types:

## LoginError

login_error-InvalidPassword = Invalid Password
login_error-Something = Something { $f0 } { $f1 } { $f2 }
login_error-SomethingArgNamed = Something Arg Named { $input } { $expected } { $details }
login_error-UserNotFound = User Not Found { $username }

## WelcomeMessage

welcome_message = Welcome Message { $name } { $count }

At runtime, call i18n.localize_message(&value) on an explicit manager to resolve translations:

let _ = i18n.localize_message(&LoginError::InvalidPassword);
let _ = i18n.localize_message(&LoginError::UserNotFound { username: "john".to_string() });
let _ = i18n.localize_message(&LoginError::Something("a".to_string(), "b".to_string(), "c".to_string()));
let _ = i18n.localize_message(&LoginError::SomethingArgNamed("a".to_string(), "b".to_string(), "c".to_string()));

let welcome = WelcomeMessage { name: "John", count: 5 };
let _ = i18n.localize_message(&welcome);

Field arguments:

  • arg = "..." on a field renames that exposed Fluent argument (works on struct fields, enum named fields, and enum tuple fields).
  • #[fluent(skip)] on a field excludes that field from generated arguments.
  • #[fluent(value = |x: &String| x.len())] transforms a field before inserting it as a Fluent argument.
  • Plain Option<T> fields are inferred as optional Fluent arguments. Some inserts the converted value; None still inserts the argument as FluentValue::None.
  • #[fluent(selector)] on Option<T> fields creates an optional selector argument.
  • #[fluent(selector)] and #[fluent(value = ...)] are mutually exclusive on the same field. Explicit value attributes override Option<T> inference.

Message IDs and resources:

  • #[fluent(key = "...")] on an enum variant overrides that variant’s key suffix. On unit-only EsFluent enums, it also overrides the inferred selector value.
  • #[fluent(skip)] and #[fluent(key = "...")] cannot be combined on the same enum variant.
  • #[fluent(id = "...")] on an enum overrides the generated base key. Reserve it for a fixed external FTL contract; the generated name is normally clearer.
  • #[fluent(domain = "...")] on an enum or struct routes generated FTL to an additional package-local domain declared in i18n.toml.
  • Generated FTL IDs must be unique within one package-local domain, including across its namespace files. The same ID may be reused in another domain or package.
  • For namespaced types, check validates the expected namespace file; a key in {crate}.ftl still counts as missing if the Rust type belongs in {crate}/{namespace}.ftl.

Generated variants:

#[fluent_variants(skip)] omits a struct field or enum variant from generated variant enums; keys = [...] values must be lowercase snake_case.

Choose the missing-message policy

A configured crate uses the strict package-local policy by default and validates every derived message and label against a resolvable value in its fallback locale during compilation. Add es-fluent-build and call track_i18n_assets() from Cargo’s selected custom-build target; see Incremental builds. Missing keys and messages that have attributes but no value fail at the declaring Rust item with the domain, fallback root, and recovery command. Missing build-helper wiring receives a separate setup diagnostic that points to cargo es-fluent doctor.

Set fallback-str in the owning package’s i18n.toml when rendering should continue if the active locale, Fluent locale fallback, and the configured fallback resource cannot resolve a value:

missing_message_policy = "fallback-str"

Strict and fallback-string packages can coexist in one workspace build.

Normal localize_message(...) and localize_label(...) calls then return these snake_case values:

Derived outputFallback sourceExample
EsFluent structStruct nameWelcomeMessagewelcome_message
EsFluent enum messageVariant nameInvalidPasswordinvalid_password
EsFluentVariants messageSource field or variant namedisplay_namedisplay_name
EsFluentLabelLabeled type nameLoginFormlogin_form

The concrete embedded, Dioxus, and Bevy managers all use this shared behavior. try_localize_message(...) and try_localize_label(...) remain fallible and return None instead of applying the string fallback. The policy does not permit malformed FTL or duplicate message/term IDs.

Localized temporal arguments

Enable the feature for the date/time library used by your message fields:

[dependencies]
es-fluent = { version = "0.18", features = ["icu-datetime"] }

Temporal fields work like other derived arguments, including borrowed fields, Option<T>, and values returned by #[fluent(value = ...)]:

use es_fluent::EsFluent;
use std::time::{Duration, SystemTime};

#[derive(EsFluent)]
pub struct EventStartsAt {
    pub starts_at: SystemTime,
}

#[derive(EsFluent)]
pub struct OperationElapsed {
    pub elapsed: Duration,
}

The generated argument can be interpolated directly in FTL:

event_starts_at = Starts { $starts_at }
operation_elapsed = Completed in { $elapsed }
FeatureSupported field types
icu-datetimestd::time::SystemTime, std::time::Duration, plus ICU4X Date<Gregorian>, Time, DateTime<Gregorian>, and ZonedDateTime<Gregorian, TimeZoneInfo<AtTime>>
chronoNaiveDate, NaiveTime, NaiveDateTime, and DateTime<Tz> for any Tz: TimeZone
jiffcivil::Date, civil::Time, civil::DateTime, Timestamp, Zoned, Span, and SignedDuration

Calendar, time, instant, and zoned values use ICU4X’s medium localized formats for the manager’s active Fluent locale. Zoned values include a localized short UTC offset. SystemTime is treated as a UTC instant on either side of the Unix epoch and converted with millisecond precision. Duration is balanced through hours, minutes, seconds, and subsecond units, then rendered with ICU4X’s locale-aware short duration format. Jiff Timestamp values are rendered in UTC. Jiff Span and SignedDuration arguments use Jiff’s friendly duration format.

Delegate skipped wrapper variants

#[fluent(skip)] on a single-field enum variant suppresses that variant’s own key and delegates context-bound rendering to the wrapped value. This is useful for transparent wrapper enums.

use es_fluent::EsFluent;

#[derive(EsFluent)]
pub enum NetworkError {
    ApiUnavailable,
}

#[derive(EsFluent)]
pub enum TransactionError {
    #[fluent(skip)]
    Network(NetworkError),
}

let _ = i18n.localize_message(&TransactionError::Network(NetworkError::ApiUnavailable));
## NetworkError

network_error-ApiUnavailable = API is unavailable

Use choices

Choices allow an enum to be used inside another message as a Fluent selector (e.g., for gender or category). Unit-only enums that derive EsFluent infer EsFluentChoice automatically. Variants serialize as kebab-case by default, so GenderChoice::Male becomes male and a compound variant like VeryFriendly becomes very-friendly. Derived choice values are emitted as validated StaticFluentVariantKey values. Use #[fluent_choice(rename_all = "...")] on the same enum to change selector casing. Styles that generate invalid selector values, such as values containing spaces, are rejected at compile time. Use standalone #[derive(EsFluentChoice)] only for selector enums that should not also be registered as messages.

use es_fluent::EsFluent;

#[derive(EsFluent)]
pub enum GenderChoice {
    Male,
    Female,
    Other,
}

#[derive(EsFluent)]
pub struct Greeting<'a> {
    pub name: &'a str,
    #[fluent(selector)] // Matches $gender -> [male]...
    pub gender: Option<&'a GenderChoice>,
}

In the FTL file, the selector field can drive a selector:

greeting = { $gender ->
    [male] Welcome Mr. { $name }
    [female] Welcome Ms. { $name }
   *[other] Welcome { $name }
}
let greeting = Greeting { name: "John", gender: Some(&GenderChoice::Male) };
let _ = i18n.localize_message(&greeting);

Generate variants

EsFluentVariants generates key-value pair enums for struct fields or enum variants. This is useful for generating UI labels, placeholders, or descriptions for a form object, and it can also expose enum variants as localizable keys.

use es_fluent::{EsFluent, EsFluentVariants};

#[derive(EsFluentVariants)]
#[fluent_variants(keys = ["label", "description"])]
pub struct LoginFormVariants {
    pub username: String,
    pub password: String,
}

#[derive(EsFluent)]
pub struct ActiveFormField {
    #[fluent(selector)]
    pub field: LoginFormVariantsLabelVariants,
}

This generates two enums with corresponding FTL entries:

## LoginFormVariantsLabelVariants

login_form_variants_label_variants-password = Password
login_form_variants_label_variants-username = Username

## LoginFormVariantsDescriptionVariants

login_form_variants_description_variants-password = Password
login_form_variants_description_variants-username = Username
let _ = i18n.localize_message(&LoginFormVariantsLabelVariants::Username);
let _ = i18n.localize_message(&ActiveFormField {
    field: LoginFormVariantsLabelVariants::Username,
});

Generated variant enums also implement EsFluentChoice, so they can drive selector fields:

active_form_field =
    { $field ->
        [username] Editing username
       *[password] Editing password
    }

Enums are supported too. In that case, the derive generates a single ...Variants enum over the original variants:

use es_fluent::EsFluentVariants;

#[derive(EsFluentVariants)]
pub enum SettingsTab {
    General,
    Notifications,
    Privacy,
}
## SettingsTabVariants

settings_tab_variants-General = General
settings_tab_variants-Notifications = Notifications
settings_tab_variants-Privacy = Privacy
let _ = i18n.localize_message(&SettingsTabVariants::Notifications);

keys = [...] values must be lowercase snake_case. Use #[fluent_variants(skip)] to omit a struct field or enum variant from the generated enums. Generated enums derive Clone, Copy, Debug, Eq, Hash, and PartialEq automatically and implement EsFluentChoice, so they can be used directly in #[fluent(selector)] fields. Use derive(...) inside #[fluent_variants(...)] for additional traits; EsFluentChoice is already inferred.

Type-level labels

EsFluentLabel generates a FluentLabel implementation that registers the type’s name as a key. Where EsFluentVariants registers individual fields, EsFluentLabel registers the parent type itself.

Type label

#[derive(EsFluentLabel)] creates a single key for the type. The derive is enough to register the type label; no additional #[fluent_label(...)] flag is needed for the parent type itself.

use es_fluent::EsFluentLabel;

#[derive(EsFluentLabel)]
pub enum GenderLabelOnly {
    Male,
    Female,
    Other,
}
gender_label_only_label = Gender Label Only
use es_fluent::FluentLabel;
let _ = GenderLabelOnly::localize_label(&i18n);
let _ = GenderLabelOnly::try_localize_label(&i18n);
let _ = GenderLabelOnly::fluent_label_key();

Combine labels with generated variants

#[derive(EsFluentVariants)] also gives each generated variant enum a type-level label key inferred from the generated enum name:

use es_fluent::{EsFluentLabel, EsFluentVariants};

#[derive(EsFluentLabel, EsFluentVariants)]
#[fluent_variants(keys = ["label", "description"])]
pub struct LoginFormCombined {
    pub username: String,
    pub password: String,
}
login_form_combined_label_variants_label = Login Form Combined Label Variants
login_form_combined_description_variants_label = Login Form Combined Description Variants
use es_fluent::FluentLabel;
let _ = LoginFormCombinedDescriptionVariants::localize_label(&i18n);

Namespaces and file splitting

Namespaces route selected types into separate .ftl files instead of the default {crate}.ftl resource. EsFluent, EsFluentLabel, and EsFluentVariants support the same namespace modes.

Use exactly one namespace source for each generated output. When multiple derives are combined on one type, either inherit a shared namespace from #[fluent(namespace = ...)] or set one on the specific #[fluent_label(...)] / #[fluent_variants(...)] output, but do not combine those namespace sources.

Output layout

DeclarationFile path
No namespaceassets_dir/{locale}/{crate}.ftl
With namespaceassets_dir/{locale}/{crate}/{namespace}.ftl

When namespaces are enabled through the manager macros, the configured namespace files are the canonical per-locale resources. {crate}.ftl remains an optional mixed-mode resource for non-namespaced messages when it exists.

Namespace modes

Explicit string

namespace = "name" sets an explicit string namespace. Literal namespaces must be safe locale-relative paths: no empty segments, ./.., backslashes, absolute paths, surrounding whitespace, or .ftl suffix.

use es_fluent::EsFluent;

#[derive(EsFluent)]
#[fluent(namespace = "ui")]
pub struct Button<'a>(pub &'a str);

This writes the key to assets_dir/{locale}/{crate}/ui.ftl.

File stem

namespace = file uses the source file’s stem as the namespace.

use es_fluent::EsFluent;

// In src/components/dialog.rs
#[derive(EsFluent)]
#[fluent(namespace = file)]
pub struct Dialog {
    pub title: String,
}

A type in src/components/dialog.rs maps to namespace dialog.

File relative

namespace = file_relative uses the file path relative to the crate root, strips src/, and removes the extension.

use es_fluent::EsFluent;

// In src/ui/button.rs
#[derive(EsFluent)]
#[fluent(namespace = file_relative)]
pub enum Gender {
    Male,
    Female,
    Other(String),
}

A type in src/ui/button.rs maps to namespace ui/button.

Folder

namespace = folder uses the source file’s parent folder.

use es_fluent::EsFluentLabel;

// In src/user/profile.rs
#[derive(EsFluentLabel)]
#[fluent(namespace = folder)]
pub enum FolderStatus {
    Active,
    Inactive,
}

A type in src/user/profile.rs maps to namespace user.

Folder relative

namespace = folder_relative uses the parent folder path relative to the crate root, stripping src/ when nested and keeping src for root module files.

use es_fluent::EsFluentLabel;

// In src/screens/user/profile.rs
#[derive(EsFluentLabel)]
#[fluent(namespace = folder_relative)]
pub struct FolderUserProfile;

A type in src/screens/user/profile.rs maps to namespace screens/user. With namespace = folder, the same file would map only to user.

Quick reference

SyntaxExample source fileResulting namespace
namespace = "name"anyname
namespace = filesrc/ui/button.rsbutton
namespace = file_relativesrc/ui/button.rsui/button
namespace = foldersrc/screens/ui/button.rsui
namespace = folder_relativesrc/screens/ui/button.rsscreens/ui

Validation

Literal string namespaces are validated at compile time as safe relative namespace paths. If namespaces = [...] is set in your i18n.toml, both the compiler and the CLI validate that explicit string-based namespaces used by your code match the provided allowlist. File-based and folder-based namespaces bypass allowlist validation because they’re derived automatically from the source tree.

Language enum

The #[es_fluent_language] macro generates a typed enum from the locale directories in assets_dir. Use it to initialize a manager, switch locales, or build a language picker without maintaining a second list of locale strings.

Setup

Add the es-fluent-lang crate:

[dependencies]
es-fluent-lang = "0.18"

# Add this when the application iterates the generated enum.
strum = { version = "0.28", features = ["derive"] }

Feature flags:

  • macros is enabled by default and provides #[es_fluent_language].
  • localized-langs formats language names in the currently selected UI language instead of as autonyms.

For wasm32 builds, default generated language enums emit the force-link keepalive across managers, including Dioxus and Bevy.

Usage

Define an empty enum and annotate it with #[es_fluent_language]:

use es_fluent_lang::es_fluent_language;
use strum::EnumIter;

#[es_fluent_language]
#[derive(EnumIter)]
pub enum Languages {}

The macro derives Clone, Copy, Debug, Eq, Hash, and PartialEq automatically. Add derives such as EnumIter only when your application needs them.

If your assets_dir contains the same locales as the executable README example (en, fr-FR, and zh-CN), the macro expands this into:

pub enum Languages {
    En,
    FrFr,
    ZhCn,
}

The macro also generates these trait implementations:

TraitDescription
DefaultReturns the variant matching fallback_language from i18n.toml
FromStrParses "en", "fr-FR", or "zh-CN" into the matching variant
TryFrom<&LanguageIdentifier>Converts from a borrowed unic-langid identifier
TryFrom<LanguageIdentifier>Converts from an owned unic-langid identifier
Into<LanguageIdentifier>Converts back to a unic-langid identifier
FluentMessageRenders language labels through a manager

If the configured fallback language is not present as a locale directory, the macro still adds it to the enum so Default always has a valid variant.

Use the enum with managers

The Languages enum plugs directly into manager initialization:

use es_fluent_manager_embedded as manager;

let i18n = manager::EmbeddedI18n::try_new_with_language(Languages::En)?;

Since it implements Into<LanguageIdentifier>, you can pass variants anywhere a LanguageIdentifier is expected.

Render language-name labels

Each variant can be rendered through an explicit manager with i18n.localize_message(&language). The macro implements FluentMessage directly, and the crate formats those labels from ICU4X display-name data, so a language picker can display localized names:

// Prints the language name in its native script
println!("{}", i18n.localize_message(&Languages::FrFr)); // → "français"

By default, names are autonyms: FrFr renders as français and ZhCn renders as 中文. With the localized-langs feature, the same ICU4X data is formatted in the currently selected UI language instead, so an English UI can render French and Chinese.

For a language picker, iterate your generated enum, render each label through the active manager, and pass the selected variant back to the manager:

use strum::IntoEnumIterator as _;

for language in Languages::iter() {
    let label = i18n.localize_message(&language);
    println!("{language:?}: {label}");
}

i18n.select_language(Languages::FrFr)?;

The runtime uses the shared ICU4X/CLDR fallback chain when exact display-name data is missing. Use custom mode when you need project-specific labels or fully custom names for unsupported locale tags.

The built-in language-name module follows successful manager locale switches but does not count as application content support. A manager still reports an unsupported locale when no application translation module can serve it.

Custom mode

By default, the macro links to the built-in es-fluent-lang runtime without registering another translation module. If you want to provide your own translations for language names (for example, project-specific labels or exact wording control), use custom mode:

#[es_fluent_language(custom)]
#[derive(EnumIter)]
pub enum Languages {}

In custom mode:

  • The macro skips the built-in es-fluent-lang runtime hook.
  • cargo es-fluent generate will create keys for the enum in your FTL files.
  • You provide your own translations instead of using ICU4X-backed labels.
  • Use this when your app ships custom language-name translations for project-specific or otherwise unsupported locale tags.

Choose a runtime manager

A runtime manager loads FTL resources, selects a locale, and resolves typed messages through an explicit application context. Choose one manager for the application runtime.

ManagerChoose it forContinue
es-fluent-manager-embeddedCLIs, TUIs, desktop apps, services, and general RustEmbedded manager
es-fluent-manager-dioxusDioxus client rendering, SSR, or bothDioxus manager
es-fluent-manager-bevyBevy ECS, assets, and reactive UI textBevy manager

All concrete managers follow the same application model:

  1. Put define_i18n_module!() in a library-reachable module.
  2. Keep derived message types reachable from a library target.
  3. Initialize or provide a manager context with a selected language.
  4. Call localize_message(&message) or pass that context to typed label helpers.
  5. Use fallible lookup only where the caller intentionally handles a missing translation.

Manager macros scan configured locale assets at compile time. Add es-fluent-build to track those assets and produce the fallback-message catalog used by strict derive validation; see Incremental builds.

All managers route typed output through the owning package’s missing-message policy. The default strict policy rejects missing fallback message values. Set missing_message_policy = "fallback-str" in i18n.toml so normal message and label lookup returns a snake_case Rust source name after locale fallback is exhausted; fallible lookup still returns None.

Use es-fluent-manager-core directly only when building a custom runtime integration. Concrete managers provide the intended application-facing APIs.

Embedded manager

Use es-fluent-manager-embedded for general Rust applications, including CLIs, TUIs, desktop apps, and services. It compiles configured FTL resources into the binary and returns a cloneable EmbeddedI18n handle.

Add the dependency

[dependencies]
es-fluent = "0.18"
es-fluent-manager-embedded = "0.18"
unic-langid = "0.9"

Register package resources

Call the module macro from a library-reachable module:

// src/i18n.rs
pub use es_fluent_manager_embedded::EmbeddedI18n as I18n;

es_fluent_manager_embedded::define_i18n_module!();
// src/lib.rs
pub mod i18n;

In a workspace, call the macro in every package that owns FTL resources and link those owner crates into the host. One manager discovers the linked registrations; the host does not copy dependency FTL.

Initialize and localize

use es_fluent::EsFluent;
use es_fluent_manager_embedded::EmbeddedI18n;
use unic_langid::langid;

#[derive(EsFluent)]
struct Greeting<'a> {
    name: &'a str,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let i18n = EmbeddedI18n::try_new_with_language(langid!("en"))?;
    let text = i18n.localize_message(&Greeting { name: "Ada" });
    println!("{text}");
    Ok(())
}

When the initial language is not known at construction time, call EmbeddedI18n::try_new(), then select_language(...) before typed lookup.

Typed localize_message(...) and localize_label(...) treat a missing registered resource as a configuration error. The fallback locale is compile-time checked when es-fluent-build produces its catalog. Set missing_message_policy = "fallback-str" in the owning package’s i18n.toml to return snake_case source names from normal typed lookup instead. Import es_fluent::FluentLocalizerExt as _ and use try_localize_message(...) only at a boundary that handles the missing state. Labels provide a matching try_localize_label(...).

Select languages

select_language(...) succeeds when at least one application module can serve the requested locale and keeps supported modules active. Use select_language_strict(...) or try_new_with_language_strict(...) when every discovered application module must support the locale.

A failed switch keeps the previous ready locale. Cloned EmbeddedI18n handles share language state; construct a separate manager when independent locale state is required.

WASM debug builds embed locale assets automatically. For other debug targets that cannot read assets from the filesystem, enable the manager’s debug-embed feature.

Dioxus manager

Use es-fluent-manager-dioxus for Dioxus client rendering, request-scoped SSR, or applications that use both.

Choose features

[dependencies]
dioxus = "0.7"
es-fluent = "0.18"

# Client rendering:
es-fluent-manager-dioxus = { version = "0.7", features = ["client"] }

# SSR only:
# es-fluent-manager-dioxus = { version = "0.7", features = ["ssr"] }

# Client and SSR:
# es-fluent-manager-dioxus = { version = "0.7", features = ["client", "ssr"] }

The crate has no default runtime feature. The module macro remains available for all feature combinations. Set missing_message_policy = "fallback-str" in the owning package’s i18n.toml when client and request-scoped SSR lookup should render snake_case source names after locale fallback is exhausted. Fallback-locale values are compile-time checked by default through es-fluent-build.

Register Dioxus assets

Call the macro from a library-reachable module. The configured assets_dir must be inside the package root because Dioxus asset! owns the resource loading.

// src/i18n.rs
es_fluent_manager_dioxus::define_i18n_module!();

Provide client context

use dioxus::prelude::*;
use es_fluent::EsFluent;
use es_fluent_manager_dioxus::{DioxusAssetI18nProvider, use_i18n};
use unic_langid::langid;

#[derive(Clone, Copy, EsFluent)]
enum UiMessage {
    Hello,
}

fn app() -> Element {
    rsx! {
        DioxusAssetI18nProvider {
            initial_language: langid!("en"),
            Greeting {}
        }
    }
}

#[component]
fn Greeting() -> Element {
    let i18n = match use_i18n() {
        Ok(i18n) => i18n,
        Err(error) => return rsx! { "Missing i18n context: {error}" },
    };

    rsx! { p { "{i18n.localize_message(&UiMessage::Hello)}" } }
}

The provider loads discovered asset modules asynchronously, owns its loading and failure UI, and publishes a signal-backed context after loading. Descendant components use use_i18n(); event handlers can switch locales through the returned handle.

During debug WASM runs served by dx serve, Dioxus asset hot reload updates subscribed components when generated FTL changes.

Create SSR request state

Create one runtime, then request one locale context per render:

use es_fluent_manager_dioxus::ssr::SsrI18nRuntime;
use unic_langid::langid;

async fn request_i18n() -> Result<(), Box<dyn std::error::Error>> {
    let runtime = SsrI18nRuntime::discovered();
    let i18n = runtime.request(langid!("en")).await?;
    // Pass the i18n value into the request's component tree.
    let _ = i18n;
    Ok(())
}

request(...) and request_strict(...) are asynchronous because asset reads are asynchronous. Blocking variants are available for static generation. Render helpers do not install context automatically; pass SsrI18n as a prop or provide it from the request’s component tree.

Enable both client and ssr if SSR components use the Dioxus hook API. Use an explicit module set only when the application should load a subset of discovered translations.

Bevy manager

Use es-fluent-manager-bevy to connect typed messages to Bevy ECS, assets, and reactive UI text.

Add the manager

[dependencies]
bevy = "0.19"
es-fluent = "0.18"
es-fluent-manager-bevy = "0.19"
unic-langid = "0.9"

Register package resources from a library-reachable module:

// src/i18n.rs
es_fluent_manager_bevy::define_i18n_module!();

Install the plugin

use bevy::prelude::*;
use es_fluent_manager_bevy::I18nPlugin;
use unic_langid::langid;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(I18nPlugin::with_language(langid!("en")))
        .run();
}

Generated manager modules contribute their configured resources. Link every owner library in a multi-crate application; the host does not copy dependency FTL.

Fallback-locale message values are compile-time checked through es-fluent-build. Set missing_message_policy = "fallback-str" in the owning package’s i18n.toml when BevyI18n and FluentText<T> should render snake_case source names after locale fallback is exhausted.

Localize UI text

Derive BevyFluentText for component types that should refresh when the locale changes, then wrap values in FluentText<T>:

use bevy::prelude::*;
use es_fluent::EsFluent;
use es_fluent_manager_bevy::{BevyFluentText, FluentText};

#[derive(BevyFluentText, Clone, Component, EsFluent)]
enum UiMessage {
    StartGame,
    Settings,
}

fn spawn_menu(mut commands: Commands) {
    commands.spawn((
        FluentText::new(UiMessage::StartGame),
        Text::new(""),
    ));
}

Only a type used directly as FluentText<T> needs registration. Nested message fields are formatted when the parent value refreshes.

If a named struct field or named enum variant field depends on the requested locale, mark it with #[locale]. Its type must implement TryFrom<&LanguageIdentifier>. The derive generates locale refresh behavior and registration.

For an external type that cannot derive BevyFluentText, register it manually with register_fluent_text::<T>().

Localize in systems

Request BevyI18n as a system parameter:

use es_fluent_manager_bevy::BevyI18n;

fn update_title(i18n: BevyI18n) {
    let title = i18n.localize_message(&UiMessage::Settings);
    // Apply the title to application state.
    let _ = title;
}

Use RequestedLanguageId for the latest user request and ActiveLanguageId for the published locale. Failed asset reloads or locale switches keep the last accepted locale active.

Order application systems

The plugin labels localization phases with I18nSet. Use Bevy’s .before(...) and .after(...) APIs when an application system must run around locale synchronization or text refresh:

use bevy::prelude::*;
use es_fluent_manager_bevy::I18nSet;

fn persist_locale() {}
fn update_window_title() {}

app.add_systems(Update, persist_locale.after(I18nSet::LocaleSync));
app.add_systems(PostUpdate, update_window_title.after(I18nSet::TextUpdate));

CLI reference

es-fluent-cli maintains the FTL resources for a crate or Cargo workspace. It can generate fallback entries, validate translations, synchronize locales, format files, inspect resource trees, and clean stale output.

Commands that inspect derived messages collect inventory from library targets. Keep localizable types reachable from src/lib.rs or another library module.

Install the CLI

cargo install es-fluent-cli --locked

Examples use Cargo’s subcommand form:

cargo es-fluent --help
cargo es-fluent generate --help

The installed cargo-es-fluent binary accepts the same commands directly.

Before running commands, create i18n.toml and the configured fallback locale directory.

Command overview

CommandUse it to
generateAdd or update fallback FTL entries from Rust derives.
watchRegenerate while Rust and configuration inputs change.
checkValidate configuration, keys, variables, locales, and orphaned files.
statusPreview pending generation, cleanup, formatting, sync, and validation work.
doctorDiagnose configuration, build wiring, managers, and fallback catalog readiness.
fmtFormat selected FTL resources.
syncCopy missing fallback keys into existing target locales.
add-localeCreate target locale directories and seed their FTL files.
cleanRemove entries or files not represented by current derive inventory.
treeInspect discovered resources, entries, attributes, and variables.

Run cargo es-fluent <COMMAND> --help for the complete option set.

Select crates and workspaces

Commands use the current directory by default.

  • --path <PATH> or -P <PATH> selects a crate, workspace, manifest, or path inside a member.
  • A workspace-root path selects every configured package.
  • A member path selects that member.
  • --package <NAME> or -p <NAME> selects one configured package from the workspace.
  • check --ignore <NAME> excludes configured packages. Do not combine --ignore with --package.

Package-filtered commands avoid unrelated package configuration and compilation. A filter that selects no configured package exits non-zero.

Diagnose setup

Run the read-only setup doctor before generation or when compiler diagnostics report missing catalog wiring:

cargo es-fluent doctor
cargo es-fluent doctor --output json

doctor checks i18n.toml, fallback locale and FTL catalog inputs, Cargo’s selected library and custom-build targets, the es-fluent-build build dependency and track_i18n_assets() call, concrete manager declarations and features, define_i18n_module!() registration, and the package-local strict or fallback-str policy. It parses the local module graphs rooted at the selected targets, so comments, strings, unreferenced files, and an unused root build.rs do not count as integration evidence. Errors produce a non-zero exit code. Warnings identify cases where static inspection cannot prove the integration and request manual verification.

Generate fallback resources

cargo es-fluent generate

Conservative mode is the default: it adds derived entries, updates their declared variables, and preserves existing translations and manual-only entries. Use aggressive mode only when generated resources should be rebuilt from current derive inventory:

cargo es-fluent generate --mode aggressive --dry-run

--dry-run previews changes without writing them. --force-run refreshes cached derive inventory. Generation may compile selected library targets. A hidden inventory mode defers strict missing-key coverage only for that temporary build, so new keys can be collected without changing the package’s configured runtime policy. Catalog parsing is deferred to the requested operation so its normal validation and transaction diagnostics remain authoritative.

watch runs the same generation flow when Rust, manifest, build script, configuration, or workspace lockfile inputs change. Applicable .cargo/config.toml and .cargo/config files in the workspace hierarchy and Cargo home, their recursive includes, and configured lockfile paths invalidate both watch fingerprints and cached derive inventory. Press q or Ctrl-C to stop after active work and any already queued rerun finish. Transient Cargo metadata errors keep the previous build-source graph and watches active; saving a corrected manifest retries metadata discovery.

Validate before committing

Check all locale directories:

cargo es-fluent check --all-locales
cargo es-fluent status --all-locales

check exits non-zero for setup or validation issues. It verifies derived keys and arguments, canonical locale names, package-local ID uniqueness, non-fallback coverage, and orphaned non-fallback files.

When translated text intentionally matches the fallback value, place this marker before the message:

# es-fluent: same-as-fallback
product-name = es-fluent

Alternatively, set check_fallback_copies = false for that package.

status does not edit project or locale files. It reports whether generation, cleanup, formatting, synchronization, or validation needs attention, making it the useful pre-commit summary.

Format and manage locales

Format fallback resources or every discovered locale:

cargo es-fluent fmt
cargo es-fluent fmt --all-locales

Seed a new locale:

cargo es-fluent add-locale fr-FR

Synchronize existing locale directories:

cargo es-fluent sync --all-locales
cargo es-fluent sync --locale fr-FR --dry-run

Use sync --create --locale <LANG> when scripts need explicit locale creation, including JSON output. --all-locales processes existing locale directories and cannot be combined with --create.

Commands that write locale files plan the selected workspace change before committing it. A failed write restores earlier changes from that command.

Clean stale resources

clean treats current derive inventory as the source of truth for selected package and domain resources. It can remove manual-only entries and empty package-owned files, so preview it first:

cargo es-fluent clean --dry-run
cargo es-fluent clean --all-locales --dry-run

Add --orphaned to find non-fallback FTL files that have no matching fallback resource:

cargo es-fluent clean --orphaned --dry-run

Remove --dry-run only after reviewing the planned deletions.

Inspect resources

cargo es-fluent tree
cargo es-fluent tree --all-locales
cargo es-fluent tree --output json

Text output can link message rows to Rust or FTL source locations. Use --link-mode ftl for file-only inspection that does not compile a library target. JSON output is file-oriented and does not accept --link-mode.

Structured output

check, fmt, sync, tree, and status support --output json. After successful argument parsing, JSON mode writes the report to stdout. Use both the process exit status and documented report fields when automation distinguishes errors, warnings, or pending dry-run work.

GitHub Actions

The repository publishes an action that runs cargo es-fluent check:

name: es-fluent
on: [pull_request]

jobs:
  localization:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - name: Check Fluent resources
        uses: stayhydated/es-fluent/crates/es-fluent-cli@<TAG_OR_SHA>
        with:
          path: .
          all_locales: true
          no_fallback_copy_check: false

Pin the action to a release tag or commit SHA for reproducible builds. Set no_fallback_copy_check to true only when all-locale validation should allow translations that match the fallback text.

Incremental builds

Configured crates discover locale assets and validate derived messages against the fallback locale at compile time. Cargo also needs explicit asset tracking so locale changes, additions, renames, and deletions trigger that work again.

The es-fluent-build helper emits the rebuild directives and writes a catalog of resolvable fallback messages. Derive output uses that catalog to make a missing fallback message value a compile-time error.

Setup

Add es-fluent-build to your build dependencies:

[build-dependencies]
es-fluent-build = "0.18"

Call the tracking helper from Cargo’s selected custom-build target. The default path is build.rs:

// build.rs
fn main() {
    es_fluent_build::track_i18n_assets();
}

A custom [package] build = "support/i18n.rs" path uses the same helper call. This guarantees your project recompiles whenever locale files or folders are added, removed, or renamed. Run cargo es-fluent doctor to verify the helper through Cargo’s selected target and its local module graph. A warning means static inspection could not prove the integration and requires manual verification.