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
.ftlresources 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.
| Need | Crate or command |
|---|---|
| Typed messages, labels, variants, and choices | es-fluent |
| General Rust, CLI, TUI, or desktop runtime | es-fluent-manager-embedded |
| Dioxus client or SSR runtime | es-fluent-manager-dioxus |
| Bevy ECS and UI runtime | es-fluent-manager-bevy |
| Typed locale enum and language labels | es-fluent-lang |
| Generate, check, sync, format, and inspect FTL | cargo es-fluent from es-fluent-cli |
| Track locale assets and compile-check fallback messages | es-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:
| Surface | Release line | Runtime compatibility |
|---|---|---|
es-fluent, CLI, embedded manager, and language enum | 0.18.x | General Rust |
es-fluent-manager-dioxus | 0.7.x | Dioxus 0.7.x |
es-fluent-manager-bevy | 0.19.x | Bevy 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-deriveandes-fluent-lang-macroimplement macros re-exported by the public facades.es-fluent-manager-coreexposes shared runtime contracts for custom manager integrations.es-fluent-manager-macrosexposes 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
| Field | Required | Meaning |
|---|---|---|
fallback_language | Yes | Canonical BCP-47 tag used for generated fallback resources. |
assets_dir | Yes | Locale asset directory, relative to and contained within the package root. |
fluent_feature | No | Cargo features enabled while the CLI collects derive inventory. |
namespaces | No | Allowlist for literal namespace = "..." values. |
domains | No | Additional FTL domains owned by this package. |
missing_message_policy | No | strict (default) requires fallback message values; fallback-str gives normal typed lookup a generated snake_case fallback. |
check_fallback_copies | No | Enables 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::Variant→my_enum-Variant). - Structs: The struct itself becomes the message ID (e.g.,
MyStruct→my_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.Someinserts the converted value;Nonestill inserts the argument asFluentValue::None. #[fluent(selector)]onOption<T>fields creates an optional selector argument.#[fluent(selector)]and#[fluent(value = ...)]are mutually exclusive on the same field. Explicit value attributes overrideOption<T>inference.
Message IDs and resources:
#[fluent(key = "...")]on an enum variant overrides that variant’s key suffix. On unit-onlyEsFluentenums, 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 ini18n.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,
checkvalidates the expected namespace file; a key in{crate}.ftlstill 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 output | Fallback source | Example |
|---|---|---|
EsFluent struct | Struct name | WelcomeMessage → welcome_message |
EsFluent enum message | Variant name | InvalidPassword → invalid_password |
EsFluentVariants message | Source field or variant name | display_name → display_name |
EsFluentLabel | Labeled type name | LoginForm → login_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 }
| Feature | Supported field types |
|---|---|
icu-datetime | std::time::SystemTime, std::time::Duration, plus ICU4X Date<Gregorian>, Time, DateTime<Gregorian>, and ZonedDateTime<Gregorian, TimeZoneInfo<AtTime>> |
chrono | NaiveDate, NaiveTime, NaiveDateTime, and DateTime<Tz> for any Tz: TimeZone |
jiff | civil::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
| Declaration | File path |
|---|---|
| No namespace | assets_dir/{locale}/{crate}.ftl |
| With namespace | assets_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
| Syntax | Example source file | Resulting namespace |
|---|---|---|
namespace = "name" | any | name |
namespace = file | src/ui/button.rs | button |
namespace = file_relative | src/ui/button.rs | ui/button |
namespace = folder | src/screens/ui/button.rs | ui |
namespace = folder_relative | src/screens/ui/button.rs | screens/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:
macrosis enabled by default and provides#[es_fluent_language].localized-langsformats 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:
| Trait | Description |
|---|---|
Default | Returns the variant matching fallback_language from i18n.toml |
FromStr | Parses "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 |
FluentMessage | Renders 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-langruntime hook. cargo es-fluent generatewill 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.
| Manager | Choose it for | Continue |
|---|---|---|
es-fluent-manager-embedded | CLIs, TUIs, desktop apps, services, and general Rust | Embedded manager |
es-fluent-manager-dioxus | Dioxus client rendering, SSR, or both | Dioxus manager |
es-fluent-manager-bevy | Bevy ECS, assets, and reactive UI text | Bevy manager |
All concrete managers follow the same application model:
- Put
define_i18n_module!()in a library-reachable module. - Keep derived message types reachable from a library target.
- Initialize or provide a manager context with a selected language.
- Call
localize_message(&message)or pass that context to typed label helpers. - 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
| Command | Use it to |
|---|---|
generate | Add or update fallback FTL entries from Rust derives. |
watch | Regenerate while Rust and configuration inputs change. |
check | Validate configuration, keys, variables, locales, and orphaned files. |
status | Preview pending generation, cleanup, formatting, sync, and validation work. |
doctor | Diagnose configuration, build wiring, managers, and fallback catalog readiness. |
fmt | Format selected FTL resources. |
sync | Copy missing fallback keys into existing target locales. |
add-locale | Create target locale directories and seed their FTL files. |
clean | Remove entries or files not represented by current derive inventory. |
tree | Inspect 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--ignorewith--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.