Introduction
es-fluent is a localization (i18n) ecosystem for Rust built on top of Project Fluent. It provides type-safe, ergonomic derive macros to link your Rust types directly to Fluent .ftl translation files.
The core philosophy:
- Type Safety: Your code and translation files stay in sync — mismatches are caught at compile time.
- Ergonomics: A single
#[derive(EsFluent)]on a struct or enum is all you need. - Developer Experience: A CLI generates FTL file skeletons, validates keys, and keeps everything consistent.
What This Book Covers
- Workspace Crates — Which crates you depend on directly and which support crates you usually won’t need directly.
- Getting Started — Installation, configuration, and a working end-to-end example.
- Deriving Messages — Mapping structs and enums to FTL message keys using
EsFluent,EsFluentChoice,EsFluentVariants, andEsFluentLabel. - Namespaces & File Splitting — Organizing translations into multiple FTL files.
- Language Enum — Auto-generating a type-safe
Languagesenum from your locale folders. - Runtime Managers — Loading and resolving translations at runtime with the embedded, Dioxus, or Bevy manager.
- CLI Tooling — Generating, validating, syncing, cleaning, formatting, and inspecting FTL files from the command line.
- Incremental Builds — Ensuring Cargo rebuilds when locale files change.
Workspace Crates
Start with the high-level crates. A typical application needs the facade crate, one runtime manager, and optionally the language-enum helper.
[dependencies]
es-fluent = "0.16"
es-fluent-manager-embedded = "0.16"
es-fluent-lang = "0.16"
Install the CLI separately:
cargo install es-fluent-cli --locked
Swap es-fluent-manager-embedded for es-fluent-manager-dioxus in Dioxus
apps by enabling client, ssr, or both surfaces as needed. Use
es-fluent-manager-bevy in Bevy apps.
Crates You Usually Use
| Crate | Use it for | Covered in this book |
|---|---|---|
es-fluent | Derives, traits, and the public localization facade | Getting Started, Deriving Messages, Namespaces & File Splitting |
es-fluent-manager-embedded | Embedded-runtime apps, CLIs, TUIs, desktop apps | Runtime Managers |
es-fluent-manager-dioxus | Dioxus apps using provider/hook-based client locale state or request-scoped SSR | Runtime Managers |
es-fluent-manager-bevy | Bevy integration, reactive localized UI, asset loading | Runtime Managers |
es-fluent-lang | Type-safe locale enum generation and localized language names | Language Enum |
es-fluent-cli | Generating, checking, cleaning, syncing, formatting, and inspecting FTL files | CLI Tooling |
Public Support Crates
| Crate | Role |
|---|---|
es-fluent-derive | Proc-macro implementation re-exported by es-fluent |
es-fluent-lang-macro | Implementation crate behind #[es_fluent_language] |
es-fluent-build | Build-script helper for locale asset rebuild tracking |
es-fluent-manager-core | Shared runtime traits, module registration, fallback logic |
es-fluent-manager-macros | Compile-time module registration and BevyFluentText derive |
Internal Workspace Crates
| Crate | Responsibility |
|---|---|
es-fluent-shared | Runtime-safe metadata, naming, namespace, and path helpers |
es-fluent-derive-core | Build-time option parsing and validation for derives |
es-fluent-toml | i18n.toml parsing, path resolution, and locale discovery |
es-fluent-generate | FTL AST generation, merging, cleaning, and formatting |
es-fluent-cli-helpers | Runtime logic executed inside the generated runner binary |
es-fluent-runner | Shared runner protocol types and .es-fluent/metadata path helpers |
xtask | Repository maintenance tasks such as rebuilding the book and language-name data |
Getting Started
Dependencies
Add es-fluent and a runtime manager. Derive macros are enabled by default:
[dependencies]
es-fluent = "0.16"
unic-langid = "0.9"
# For simple apps and CLIs:
es-fluent-manager-embedded = "0.16"
# For Dioxus apps, enable only the runtime surface you use.
# es-fluent-manager-dioxus = { version = "0.7", features = ["client"] }
# es-fluent-manager-dioxus = { version = "0.7", features = ["ssr"] }
# Browser WASM debug builds that use define_i18n_module! should add "debug-embed".
# es-fluent-manager-dioxus = { version = "0.7", features = ["client", "debug-embed"] }
Project Configuration
For a new crate, let the CLI create the standard config and module scaffold:
cargo es-fluent init
This creates i18n.toml, assets/locales/en/, src/i18n.rs, and a
pub mod i18n; declaration in src/lib.rs. Pass --manager dioxus or
--manager bevy for framework-specific scaffolding, and pass --build-rs
when you want Cargo to rebuild automatically after locale file changes.
Use --locales fr-FR,zh-CN to create additional locale directories up front,
--namespaces ui,errors to write a namespace allowlist, and
--update-cargo-toml to add the matching dependencies. When --build-rs is
also passed, the manifest update includes es-fluent-build under
[build-dependencies]. For Dioxus manifests, --dioxus-runtime client,
--dioxus-runtime ssr, or --dioxus-runtime client,ssr selects the generated
manager features; omitting it enables both.
Before writing anything, init checks generated-file conflicts, directory
targets, and Cargo.toml parseability when manifest updates are requested.
init creates a library target because CLI inventory collection reads library
targets. Put derived message types in src/lib.rs or another library crate;
binary-only derived types in src/main.rs are not discovered by generate.
Or create an i18n.toml next to your crate’s Cargo.toml manually:
# Default fallback language (required)
fallback_language = "en"
# Path to FTL assets relative to the config file (required)
assets_dir = "assets/locales"
# Features to enable if the crate's es-fluent derives are gated behind a feature (optional)
fluent_feature = ["my-feature"]
# Optional allowlist of namespace values for FTL file splitting
namespaces = ["ui", "errors", "messages"]
The CLI and build tools use this file as the single source of truth for locating .ftl files and validating keys.
Locale directory names use canonical BCP-47 tags. The executable README example
ships en, fr-FR, and zh-CN, with en as the fallback locale.
End-to-End Example
Here’s a minimal project that defines a localizable enum, generates the FTL skeleton, and prints a translated message.
1. Define a type
// src/lib.rs
use es_fluent::EsFluent;
#[derive(EsFluent)]
pub enum LoginError {
InvalidPassword,
UserNotFound { username: String },
}
2. Generate the FTL file
cargo es-fluent generate
This creates assets/locales/en/{your_crate}.ftl with a skeleton:
## LoginError
login_error-InvalidPassword = Invalid Password
login_error-UserNotFound = User Not Found { $username }
Edit the values to your liking — the CLI will preserve your changes on subsequent runs.
3. Initialize and localize
// src/main.rs
use my_crate::i18n::I18n;
use unic_langid::langid;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let i18n = I18n::try_new_with_language(langid!("en"))?;
let err = my_crate::LoginError::UserNotFound {
username: "alice".to_string(),
};
println!("{}", i18n.localize_message(&err));
// → "User Not Found alice"
Ok(())
}
Workflow Summary
A typical es-fluent workflow:
- Configure — Create
i18n.tomlwith your fallback language and asset path. - Derive — Annotate structs and enums with
#[derive(EsFluent)]. - Generate — Run
cargo es-fluent generateto create FTL file skeletons. - Translate — Edit the generated
.ftlfiles with real translations. - Use — Call
i18n.localize_message(&value)at runtime through an explicit manager.
When adding a new target language later, seed it from the fallback locale:
cargo es-fluent add-locale fr-FR
Before committing, cargo es-fluent status --all gives a read-only summary of
generation, formatting, sync, orphan-cleanup, and validation work that remains.
The following chapters break down each of these steps in detail.
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_name = "input")] String,
#[fluent(arg_name = "expected")] String,
#[fluent(arg_name = "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);
Common derive attributes:
arg_name = "..."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 = "...")]or#[fluent(value(...))]transforms a field before inserting it as a Fluent argument.#[fluent(key = "...")]on an enum variant overrides that variant’s key suffix.#[fluent(resource = "...")]on an enum overrides the base key,domain = "..."routes lookup to a specific manager domain, andskip_inventorysuppresses CLI inventory registration.domain = "..."is enum-only. Struct messages resolve in the current crate’s domain.- Optional-argument omission is generated for direct
Option<T>fields, including paths likestd::option::Option<T>. Type aliases toOption<T>are treated like ordinary field types. #[fluent_variants(skip)]omits a struct field or enum variant from generated variant enums;keys = [...]values must be lowercase snake_case.
Skipped single-field enum 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, FluentMessage};
#[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
Using Choices
Choices allow an enum to be used inside another message as a Fluent selector (e.g., for gender or category). Derive EsFluentChoice alongside EsFluent on the selector enum.
use es_fluent::{EsFluent, EsFluentChoice};
#[derive(EsFluent, EsFluentChoice)]
#[fluent_choice(serialize_all = "snake_case")]
pub enum GenderChoice {
Male,
Female,
Other,
}
#[derive(EsFluent)]
pub struct Greeting<'a> {
pub name: &'a str,
#[fluent(choice)] // Matches $gender -> [male]...
pub gender: &'a GenderChoice,
}
In the FTL file, the choice field can drive a selector:
greeting = { $gender ->
[male] Welcome Mr. { $name }
[female] Welcome Ms. { $name }
*[other] Welcome { $name }
}
use es_fluent::FluentMessage;
let greeting = Greeting { name: "John", gender: &GenderChoice::Male };
let _ = i18n.localize_message(&greeting);
Generating 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::EsFluentVariants;
#[derive(EsFluentVariants)]
#[fluent_variants(keys = ["label", "description"])]
pub struct LoginFormVariants {
pub username: String,
pub password: String,
}
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
use es_fluent::FluentMessage;
let _ = i18n.localize_message(&LoginFormVariantsLabelVariants::Username);
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
use es_fluent::FluentMessage;
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. Use derive(Debug, Clone) inside #[fluent_variants(...)] to
add derives to the generated enums.
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.
Origin Only
origin is enabled by default, so #[derive(EsFluentLabel)] creates a single
key for the type. #[fluent_label(origin)] is equivalent; use
#[fluent_label(origin = false)] when deriving only variant labels through
EsFluentVariants.
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);
Combined with Variants
#[fluent_label(variants)] can be combined with EsFluentVariants to generate type-level keys for each generated variant enum:
use es_fluent::{EsFluentLabel, EsFluentVariants};
#[derive(EsFluentLabel, EsFluentVariants)]
#[fluent_label(origin, variants)]
#[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 & File Splitting
By default, all your FTL keys land in a single {crate}.ftl file per locale. As a project grows, this gets unwieldy. Namespaces let you route specific types into separate .ftl files. Every derive macro (EsFluent, EsFluentLabel, EsFluentVariants) supports the same namespace attribute.
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.
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_label(origin)]
#[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/user/profile.rs
#[derive(EsFluentLabel)]
#[fluent_label(origin)]
#[fluent(namespace(folder(relative)))]
pub struct FolderUserProfile;
A type in src/user/profile.rs maps to namespace 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/ui/button.rs | ui |
namespace(folder(relative)) | src/ui/button.rs | 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
Most apps need to know which languages are available — for a settings screen, a language picker, or to select the right locale at startup. The #[es_fluent_language] macro generates a strongly-typed enum from the folders in your assets_dir, so you never hardcode locale strings.
Setup
Add the es-fluent-lang crate:
[dependencies]
es-fluent-lang = "0.16"
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.bevyis retained for compatibility with existing Bevy projects. Thewasm32force-link keepalive is emitted for default generated language enums across managers.
Usage
Define an empty enum and annotate it with #[es_fluent_language]:
use es_fluent_lang::es_fluent_language;
use es_fluent::EsFluent;
use strum::EnumIter;
#[es_fluent_language]
#[derive(Debug, Clone, Copy, PartialEq, EsFluent, EnumIter)]
pub enum Languages {}
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 |
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.
Using 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.
Language Name Labels
By deriving EsFluent alongside #[es_fluent_language], each variant can be rendered through an explicit manager with i18n.localize_message(&language). The crate formats those labels directly from ICU4X display-name data, so a language picker works out of the box:
use es_fluent::FluentMessage;
// 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 es_fluent::FluentMessage as _;
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 and skips inventory registration. 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(Debug, Clone, Copy, EsFluent, EnumIter)]
pub enum Languages {}
In custom mode:
- The macro stops injecting the built-in
es-fluent-langresource attributes. - When you also derive
EsFluent,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.
Runtime Managers
es-fluent is agnostic about how you load translations at runtime. The
ecosystem provides three ready-made manager crates so you don’t have to build
your own asset pipeline.
| Manager | Best for | How it works |
|---|---|---|
es-fluent-manager-embedded | CLIs, TUIs, desktop apps | Compiles FTL files into the binary |
es-fluent-manager-dioxus | Dioxus apps | Uses embedded assets plus Dioxus hooks or request-scoped SSR |
es-fluent-manager-bevy | Bevy games and apps | Loads FTL files via Bevy’s AssetServer |
Embedded Manager (es-fluent-manager-embedded)
Bundles your translations directly into the binary and returns an explicit manager handle. No external files needed at runtime.
Features
- Embedded Assets: Compiles your FTL files into the binary.
- Explicit Context: Keep the manager handle in application state and pass it to code that localizes messages.
- Thread Safe: Safe to use from multiple threads after initialization.
Enable the debug-embed Cargo feature for debug targets that cannot read
locale files from the filesystem. It forwards rust-embed’s debug embedding
mode through the manager crate.
Quick Start
1. Define the Module
Prefer a library-reachable module, usually src/i18n.rs declared from
src/lib.rs, so cargo es-fluent generate can discover localizable types from
the library target:
// a i18n.toml file must exist in the root of the crate
es_fluent_manager_embedded::define_i18n_module!();
Putting the module macro only in src/main.rs is runtime-only. It is safe only
when derived message types are still reachable from a library target, or when
you accept that binary-only derived types are not discovered by the CLI.
2. Initialize & Use
In your application entry point:
use es_fluent::{EsFluent, EsFluentLabel};
use es_fluent_manager_embedded::EmbeddedI18n;
use unic_langid::langid;
#[derive(EsFluent, EsFluentLabel)]
#[fluent_label(origin)]
enum MyMessage {
Hello { name: String },
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let i18n = EmbeddedI18n::try_new_with_language(langid!("en"))?;
let msg = MyMessage::Hello { name: "World".to_string() };
println!("{}", i18n.localize_message(&msg));
Ok(())
}
For types that derive EsFluentLabel, pass the same explicit context to
localize_label(...):
use es_fluent::FluentLabel as _;
let title = MyMessage::localize_label(&i18n);
If you have a Language Enum, you can pass it directly since it implements Into<LanguageIdentifier>:
let i18n = es_fluent_manager_embedded::EmbeddedI18n::try_new_with_language(Languages::En)?;
If the language is not known during startup, create the context first and switch
later with select_language(...):
let i18n = es_fluent_manager_embedded::EmbeddedI18n::try_new()?;
i18n.select_language(Languages::FrFr)?;
Before a language is selected, raw lookup returns None. Typed
localize_message(...) uses its message ID fallback and returns the message ID for
missing messages until select_language(...) succeeds.
select_language(...) returns an error if no discovered module can serve the
requested locale, or if a supported locale’s resources would build a broken
Fluent bundle. When some modules support the requested locale and others do
not, the default switch keeps the supporting modules active. Failed switches
keep the previous ready locale active.
When a locale has only some of a module’s files, the available files can still activate and missing messages fall back through the ICU4X locale fallback chain. Utility modules such as localized language-name display follow successful switches but do not make an otherwise unsupported locale count as supported.
Use try_new_with_language_strict(...) during startup or
select_language_strict(...) at runtime when every discovered module must
support the requested locale for selection to succeed.
EmbeddedI18n clones are cheap shared handles. Calling
select_language(...) through one clone changes the active language observed
by the other clones. Construct a separate EmbeddedI18n value when you need
isolated language state.
EmbeddedI18n intentionally exposes enum-first localize_message(...) for application lookup. It also implements FluentLocalizer so generated labels and integration code can resolve through the same explicit context.
For custom runtime integrations, es-fluent-manager-core exposes the same
strict discovery behavior through FluentManager. Construction does not select
a language, so select the initial language before handing the manager to
integration code:
[dependencies]
es-fluent-manager-core = "0.16"
use es_fluent_manager_core::FluentManager;
use unic_langid::langid;
let manager = FluentManager::try_new_with_discovered_modules()?;
manager.select_language(&langid!("en"))?;
// Use concrete manager crates for application-facing typed lookup.
Most applications should prefer a concrete manager crate instead of wiring a raw
FluentManager into application state manually. FluentManager remains a
low-level integration point; import es_fluent::FluentLocalizerExt as _ if
custom integration code needs generic localize_message(...) or fallible
try_localize_message(...) on a raw manager. Typed rendering uses a
render-scoped lookup, so nested message arguments and the outer message use the
same active localizer set during a concurrent language switch. Most application
code should stay on derived messages and concrete manager handles.
The embedded manager also uses strict discovery and returns initialization errors before the manager is returned:
es_fluent_manager_embedded::EmbeddedI18n::try_new_with_language(Languages::FrFr)
.expect("embedded i18n manager should initialize");
try_new_with_language(...) only returns the embedded context after the
requested language has been selected successfully.
Dioxus Manager (es-fluent-manager-dioxus)
Dioxus integration for es-fluent.
Enable the runtime surface your crate uses:
# Client apps
es-fluent-manager-dioxus = { version = "0.7", features = ["client"] }
# SSR
es-fluent-manager-dioxus = { version = "0.7", features = ["ssr"] }
# Browser WASM debug builds that use define_i18n_module!
es-fluent-manager-dioxus = { version = "0.7", features = ["client", "debug-embed"] }
The crate has no default runtime feature. The define_i18n_module! macro is always available.
client: Dioxus provider, hook/context runtime, and signal-backed locale state for interactive rendering.debug-embed: embeds macro-discovered FTL files even in debug builds, which browser WASM clients need because they cannot use filesystem fallback lookup.ssr: request-scoped Dioxus SSR runtime with cached module discovery.
Define the Module
Prefer a library-reachable module, usually src/i18n.rs declared from
src/lib.rs, so cargo es-fluent generate can discover localizable types from
the library target:
// a i18n.toml file must exist in the root of the crate
es_fluent_manager_dioxus::define_i18n_module!();
Putting the module macro only in src/main.rs is runtime-only. It is safe only
when derived message types are still reachable from a library target, or when
you accept that binary-only derived types are not discovered by the CLI.
Client Quick Start
use dioxus::prelude::*;
use es_fluent::{EsFluent, EsFluentLabel, FluentLabel as _};
use es_fluent_manager_dioxus::{I18nProvider, use_i18n};
use unic_langid::langid;
fn app() -> Element {
rsx! {
I18nProvider {
initial_language: langid!("en"),
LocaleButton {}
}
}
}
#[derive(Clone, Copy, EsFluent, EsFluentLabel)]
#[fluent(namespace = "ui")]
#[fluent_label(origin)]
enum UiMessage {
Hello,
}
#[component]
fn LocaleButton() -> Element {
let i18n = match use_i18n() {
Ok(i18n) => i18n,
Err(error) => return rsx! { "Failed to initialize i18n: {error}" },
};
let label = i18n.localize_message(&UiMessage::Hello);
let title = UiMessage::localize_label(&i18n);
rsx! {
button {
onclick: move |_| {
if let Err(error) = i18n.select_language(langid!("fr-FR")) {
eprintln!("locale switch failed: {error}");
}
},
"{title}: {label}"
}
}
}
Client apps should localize through the DioxusI18n context provided by I18nProvider, use_init_i18n(...), use_init_i18n_strict(...), or use_provide_i18n(...). Those hooks initialize once; changing the initial language, selection policy, or provided manager after the first render does not replace the installed context. Use localize_message(...) for typed context-bound lookup. DioxusI18n implements FluentLocalizer, so #[derive(EsFluentLabel)] values can call MyType::localize_label(&i18n) in client components. Raw string-ID lookup is not exposed as a client convenience API; keep application code on derived messages and labels. Startup selection defaults to best effort; pass selection_policy: LanguageSelectionPolicy::Strict, call use_init_i18n_with_policy(..., LanguageSelectionPolicy::Strict), or call use_init_i18n_strict(...) when every discovered module must support the startup locale. Locale switches use fallible select_language(...) or select_language_strict(...); after a manager is handed to the Dioxus provider, route language changes through those DioxusI18n methods so the Dioxus signal stays aligned with manager state. ManagedI18n clones are shared handles; language selection and requested-language updates are serialized, while localization reads use render-scoped manager snapshots so independent typed renders can run concurrently. requested_language() tracks the requested locale, while peek_requested_language() reads it without subscribing.
Dioxus localizes through explicit component or request context. Keeping lookup context-bound avoids cross-root, hot-reload, test, and SSR request leakage.
If initialization cannot complete, the hook still provides a failed context to keep hook order stable for callers that inspect the returned Result directly. I18nProvider logs that failure once per provider instance and renders fallback when one is supplied; without a fallback it renders children with a failed i18n context, so descendants that call use_i18n() receive the same initialization error. I18nProviderStrict is the fail-closed rendering variant: it renders fallback when one is supplied and otherwise renders an empty vnode. Strictness in the component name refers to rendering behavior; use selection_policy: LanguageSelectionPolicy::Strict for strict startup locale selection. Descendants can call try_use_i18n() to distinguish a missing provider from a failed provider. Event handlers and async tasks can call consume_i18n() or try_consume_i18n() while the Dioxus runtime is active.
SSR Quick Start
use dioxus::prelude::*;
use es_fluent::EsFluent;
use es_fluent_manager_dioxus::ssr::{SsrI18n, SsrI18nRuntime};
use unic_langid::langid;
#[derive(Clone, Copy, EsFluent)]
#[fluent(namespace = "site")]
enum SiteMessage {
Title,
}
#[component]
fn App(i18n: SsrI18n) -> Element {
let title = i18n.localize_message(&SiteMessage::Title);
rsx! { div { "{title}" } }
}
fn render(runtime: &SsrI18nRuntime) -> Result<String, Box<dyn std::error::Error>> {
let i18n = runtime.request(langid!("en"))?;
let mut dom = VirtualDom::new_with_props(
App,
AppProps {
i18n: i18n.clone(),
},
);
Ok(i18n.rebuild_and_render(&mut dom))
}
Create one SsrI18nRuntime during startup, then create one SsrI18n per request. The runtime caches the first validated module-discovery result for its lifetime, including discovery or validation failures; construct a new runtime to retry after a failed discovery. Each request creates fresh manager/localizer state so request languages remain isolated. request(...) uses best-effort initial language selection; use request_strict(...) when every discovered module must support the request locale.
The render helpers do not install context automatically; pass SsrI18n as a prop or call provide_context() from a component when using hook-based lookup.
SSR components should receive a cloned SsrI18n as a prop or through app-owned context and call localize_message(...) or MyType::localize_label(&i18n). If SSR components use the Dioxus hook API, enable both ssr and client features because SsrI18n::provide_context(...) is compiled behind client, then call i18n.provide_context()? from an app-owned provider component.
Bevy Manager (es-fluent-manager-bevy)
Seamless Bevy integration for es-fluent. This plugin connects type-safe localization with Bevy’s ECS and Asset system. Use #[derive(EsFluent)] for typed messages, wrap them in FluentText<T> for UI text, and derive BevyFluentText on message types used with FluentText<T> to register automatic updates when the language changes.
Features
- Asset Loading: Loads
.ftlfiles via Bevy’sAssetServer. - Hot Reloading: Supports hot-reloading of translations during development.
- Reactive UI: The
FluentTextcomponent automatically refreshes text when the locale changes. - Bevy-native Context: Systems can request
BevyI18nas aSystemParamfor direct localization. - Explicit Context: Localization comes from Bevy resources instead of a context-free bridge.
Quick Start
1. Define the Module
Prefer a library-reachable module, usually src/i18n.rs declared from
src/lib.rs, so cargo es-fluent generate can discover localizable types from
the library target:
// a i18n.toml file must exist in the root of the crate
es_fluent_manager_bevy::define_i18n_module!();
Putting the module macro only in src/main.rs is runtime-only. It is safe only
when derived message types are still reachable from a library target, or when
you accept that binary-only derived types are not discovered by the CLI.
2. Initialize & Use
Add the plugin to your App:
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();
}
By default, I18nPlugin loads locales from locales relative to Bevy’s asset
root, matching assets_dir = "assets/locales" in i18n.toml. If your Bevy
asset root is assets but translations live in assets/i18n, configure the
path explicitly:
use es_fluent_manager_bevy::{I18nPlugin, I18nPluginConfig};
app.add_plugins(I18nPlugin::with_config(
I18nPluginConfig::new(langid!("en")).with_asset_path("i18n"),
));
I18nPlugin localizes FluentText components through Bevy resources and does
not install a process-wide localization hook.
Advanced behavior
Plugin startup also uses strict module discovery, so invalid or duplicate i18n
module registrations are reported through I18nPluginStartupError instead of
being normalized silently. When setup fails, the plugin skips localization
runtime setup and leaves the error resource in the app world for diagnostics.
Failed hot reloads or locale switches keep the last accepted locale active
instead of publishing a broken update. A failed hot reload records diagnostics
but keeps the previous ready cache selectable until a later rebuild succeeds.
Generated message lookup is domain-scoped. If separate domains define the same message ID, Bevy keeps typed domain-scoped lookup available and leaves raw unscoped lookup unavailable for the ambiguous merged locale.
Locales with only optional resources, or with missing optional resources, are still treated as ready. They publish an empty Bevy cache instead of remaining pending indefinitely.
Use RequestedLanguageId to read the latest user intent and ActiveLanguageId
to read the currently published locale. LocaleChangedEvent refers to
ActiveLanguageId, not merely the latest request. When a requested locale
falls back to a resolved locale, Bevy publishes the requested locale for change
events and ECS resources while using the resolved locale for ready bundle
lookup. Runtime fallback managers are best-effort: Bevy asks them to select the
requested locale first, then the resolved locale, but rejection does not block
Bevy asset-backed locale publication. Only metadata-only Bevy registrations
create Bevy asset availability; runtime localizer registrations are reserved
for the fallback manager and do not make a locale wait on Bevy asset bundles.
When attached, runtime fallback selection tells FluentManager that Bevy assets
have already proved application locale support, so follower-only utility modules
such as es-fluent-lang can be committed without making runtime-only locales
selectable. Generated embedded localizers are fallback-aware, while custom
runtime localizers should implement parent-locale fallback in
select_language(...) when they need it. Runtime fallback managers are attached
whenever runtime modules are discovered, even if they reject the startup locale.
A startup rejection leaves runtime localizers unselected until a later accepted
locale switch. Runtime fallback managers are used only after Bevy resolves a
locale through asset or ready-bundle availability during startup or a later
LocaleChangeEvent; runtime-only locales do not by themselves make a Bevy
locale switch selectable.
For direct localization inside a system, request BevyI18n like any other
Bevy system parameter:
use es_fluent::FluentLabel as _;
use es_fluent_manager_bevy::BevyI18n;
fn update_title(i18n: BevyI18n) {
let title = i18n.localize_message(&UiMessage::Settings);
// `SettingsPanel` is any type that derives `EsFluentLabel`.
let section_title = SettingsPanel::localize_label(&i18n);
// apply `title` to your Bevy UI, window, or gameplay state
// use `section_title` for an `EsFluentLabel` type label
}
3. Define Localizable Components (Recommended)
Prefer the BevyFluentText derive macro. It auto-registers your type with I18nPlugin via inventory, so you don’t have to call any registration functions manually.
If a field depends on the active locale (like the Languages enum from Language Enum), mark it with #[locale]. The macro will generate RefreshForLocale and register the locale-aware systems for you.
#[locale] is supported on named struct fields and named enum variant fields, and you can mark more than one named field in the same variant when they all need refresh behavior.
RefreshForLocale receives the originally requested locale, not the fallback resource locale. For example, if en-GB falls back to en assets, locale-aware fields still refresh with en-GB.
use bevy::prelude::Component;
use es_fluent::EsFluent;
use es_fluent_manager_bevy::BevyFluentText;
#[derive(BevyFluentText, Clone, Component, EsFluent)]
pub enum UiMessage {
StartGame,
Settings,
LanguageHint {
#[locale]
current_language: Languages,
},
}
4. Using in UI
Use the FluentText component wrapper for any type that implements FluentMessage (which #[derive(EsFluent)] provides).
use es_fluent_manager_bevy::FluentText;
fn spawn_menu(mut commands: Commands) {
commands.spawn((
// This text will automatically update if language changes
FluentText::new(UiMessage::StartGame),
Text::new(""),
));
}
Manual Registration (Fallback)
If you cannot derive BevyFluentText (e.g., external types), you can still register manually:
app.register_fluent_text::<UiMessage>();
If the type needs locale refresh, implement RefreshForLocale and use the locale-aware registration function:
use es_fluent_manager_bevy::RefreshForLocale;
#[derive(EsFluent, Clone, Component)]
pub enum UiMessage {
LanguageHint { current_language: Languages },
}
impl RefreshForLocale for UiMessage {
fn refresh_for_locale(&mut self, lang: &unic_langid::LanguageIdentifier) {
match self {
UiMessage::LanguageHint { current_language } => {
if let Ok(value) = Languages::try_from(lang) {
*current_language = value;
}
}
}
}
}
app.register_fluent_text_from_locale::<UiMessage>();
Do Nested Types Need BevyFluentText?
Only the component type wrapped by FluentText<T> needs registration. If a nested field (like KbKeys) is only used inside a registered component, it does not need BevyFluentText. When the parent component re-renders, its EsFluent implementation formats all fields using the current locale.
You only need BevyFluentText for a nested type if you plan to use it directly as FluentText<ThatType> or otherwise register it as its own component.
CLI Tooling
The es-fluent-cli is the command-line companion for es-fluent. It builds a
temporary runner over your workspace library crates, reads the derive inventory
emitted by localizable types (see Deriving Messages),
and manages the corresponding FTL translation files for you.
Installation
cargo install es-fluent-cli --locked
Configuration
The CLI reads your i18n.toml (see Getting Started) to locate FTL assets. Make sure it exists in your crate root:
# Default fallback language (required)
fallback_language = "en"
# Path to FTL assets relative to the config file (required)
assets_dir = "assets/locales"
# Features to enable if the crate's es-fluent derives are gated behind a feature (optional)
fluent_feature = ["my-feature"]
# Optional allowlist of namespace values for FTL file splitting
namespaces = ["ui", "errors", "messages"]
Common Workspace Options
Every command accepts --path <PATH>/-p <PATH> to choose a crate or workspace
root instead of the current directory, and --package <NAME>/-P <NAME> to
process one package from a workspace.
Commands
Init
For a new crate, scaffold the standard files first:
cargo es-fluent init
This creates i18n.toml, assets/locales/en/, src/i18n.rs, and a
pub mod i18n; declaration in src/lib.rs. Use --manager dioxus or
--manager bevy to scaffold those manager imports instead of the embedded
manager. Use --build-rs when the crate uses manager macros and should rebuild
when locale files are added, removed, or renamed. For Dioxus manifests,
--dioxus-runtime client, --dioxus-runtime ssr, or
--dioxus-runtime client,ssr selects which manager features are added by
--update-cargo-toml; omitting it enables both. When --build-rs and
--update-cargo-toml are used together, init also adds es-fluent-build
under [build-dependencies].
init creates a library target because CLI inventory collection reads library
targets. Put derived message types in src/lib.rs or another library crate;
binary-only derived types in src/main.rs are not discovered by generate.
Useful options:
--fallback-language <LANG>: choose the fallback locale directory and config value.--locales <LANG>: create additional locale directories, repeatable or comma-separated.--assets-dir <PATH>: choose the locale asset directory relative to the crate root.--namespaces <NAME>: write a namespace allowlist intoi18n.toml, repeatable or comma-separated.--dioxus-runtime <client|ssr>: choose Dioxus manager features, repeatable or comma-separated.--update-cargo-toml: add the matchinges-fluent, manager, andunic-langiddependencies.--dry-run: preview files and manifest updates without writing them.--force: overwrite generated files that already exist.
Before writing anything, init checks generated-file conflicts, directory
targets, and Cargo.toml parseability when manifest updates are requested.
Generate
When you add new #[derive(EsFluent)], #[derive(EsFluentVariants)], or #[derive(EsFluentLabel)] types to your code, run:
cargo es-fluent generate
This will:
- Collect derive inventory registrations from workspace library targets.
- Update
assets_dir/en/{your_crate}.ftl(andassets_dir/en/{your_crate}/{namespace}.ftlfor namespaced types).- New items: Added as new messages.
- Changed items: Variables updated (e.g. if you added a field).
- Existing translations: Preserved untouched.
Use --mode conservative to merge generated keys while preserving manual-only
entries and existing translations. This is the default. Use --mode aggressive
when you want generated files rebuilt from the current Rust inventory.
Use --dry-run to preview changes without writing them. Use --force-run to bypass the staleness cache and run the generated runner through Cargo.
Literal string namespaces are checked as safe relative namespace paths at compile time. If you configure namespaces = [...] in i18n.toml, string-based namespaces are validated against the allowlist by both the compiler and the CLI during generate and watch.
Watch
Same as generate, but runs in watch mode — updating FTL files as you type:
cargo es-fluent watch
watch accepts the same --mode conservative|aggressive option as
generate.
Check
Verify that all your translations are valid and no keys are missing:
cargo es-fluent check
Use --all to check all locales, not just the fallback language. Use
--ignore <CRATE> to skip specific crates; it can be repeated or passed as a
comma-separated list. Use --force-run to bypass the staleness cache.
FTL variables that are not declared by Rust code are reported as errors.
Rust-declared variables omitted by a translation are reported as warnings; any
reported validation issue makes check exit non-zero for CI enforcement.
Clean
Remove orphan keys and groups that are no longer present in your source code:
cargo es-fluent clean
Use --dry-run to preview changes without writing them. Use --all to clean all locales. Use --force-run to bypass the staleness cache.
clean --all only targets canonical locale directories from the configured
assets_dir; invalid or non-canonical locale directory names are reported
instead of cleaned.
When the main crate file no longer has any non-namespaced registered Rust
types, clean deletes that stale main file. When a namespaced file no longer
has any registered Rust types, clean also removes that stale namespace file in
the locale being cleaned so discovery metadata stays in sync with code.
Clean Orphaned Files
Remove FTL files that are no longer tied to any registered types (e.g., when all items are now namespaced or the crate was deleted):
cargo es-fluent clean --orphaned --all
This compares files in non-fallback locales against the configured fallback locale (en in the executable README example). Files that exist in non-fallback locales but have no corresponding file in the fallback locale are considered orphaned and will be removed. The fallback locale itself is never modified.
Use --dry-run to preview which files would be removed without actually deleting them.
Fmt
Standardize the formatting of your FTL files using fluent-syntax rules:
cargo es-fluent fmt
Use --dry-run to preview changes and print diffs without writing them. Use
--all to format all locales.
Sync
Propagate keys from your fallback language to other languages (e.g., from en to fr-FR and zh-CN), creating placeholders for missing translations:
cargo es-fluent sync --all
Use --locale <LANG> to sync a specific locale, or --all to sync all
non-fallback locales. Running sync without either option exits non-zero with an actionable message. Use --dry-run to preview changes and print diffs without
writing them. Use --create with --locale <LANG> to create missing locale
directories before seeding them from the fallback locale.
The sync command properly handles namespaced FTL files, creating matching subdirectories in target locales when syncing from the fallback locale.
Add Locale
Create one or more locale directories and seed them from the fallback locale:
cargo es-fluent add-locale fr-FR zh-CN
This is equivalent to sync --create --locale <LANG> for each requested
locale. Use --dry-run to preview the files that would be created.
Tree
Inspect the discovered FTL file layout and message IDs for a crate:
cargo es-fluent tree
Use --all to show all locales instead of just the fallback language. Use
--attributes to include message and term attributes, and --variables to
list the Fluent variables referenced by each entry.
Doctor
Diagnose setup issues without changing files:
cargo es-fluent doctor
doctor checks discovered i18n crates for common setup problems such as missing
library targets, unreadable locale assets, manager dependency mismatches, and
missing build-script asset tracking.
Status
Run a read-only workflow summary before committing or in CI:
cargo es-fluent status --all
status reports whether generation would change fallback files, formatting is
needed, non-fallback locales need synced keys, orphaned files exist, or
validation would fail. It exits non-zero when attention is required.
Structured Output
Machine-readable output is available for commands intended for CI and editor integrations:
cargo es-fluent check --all --output json
cargo es-fluent status --all --output json
--output json is supported by check, fmt, sync, tree, doctor,
and status.
CI/CD Integration
GitHub Actions
name: es-fluent
on: [pull_request]
jobs:
es-fluent:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Check FTL files
uses: stayhydated/es-fluent/crates/es-fluent-cli@v0.16.0
with:
path: .
all: true
Inputs:
version: Optionales-fluent-cliversion to install from crates.io. If omitted, the action installs the CLI from the pinned action ref. Uselatestto install the newest crates.io release.path: Path to the crate or workspace root (passed as--path). Default:..package: Package name filter for workspaces (passed as--package). Default: empty.all: Check all locales, not just the fallback language. Default:false.ignore: Crates to skip during validation (comma-separated). Default: empty.force_run: Run the generated runner through Cargo, ignoring the staleness cache. Default:false.toolchain: Rust toolchain to install for the action. Default:stable.
This action always runs cargo es-fluent check. Pin the uses ref to a release
tag or commit SHA for reproducible builds. Omit version to run the CLI from
that ref, or set version when you intentionally want a crates.io release.
Limitations
The CLI runner links workspace crates as library targets only. If you define #[derive(EsFluent*)] types exclusively in a binary target, they won’t be registered in the inventory, and commands like generate or clean may miss or remove their keys.
Workarounds:
- Add a
lib.rstarget and move derived types into it. - Move shared localization types into a small library crate and depend on it from your binary.
Incremental Builds
If your crate uses the embedded, Dioxus, or Bevy manager macros, they discover locales at compile time by scanning your assets_dir. By default, Cargo doesn’t know about these files, so changes like renaming a locale folder (e.g., fr → fr-FR) won’t trigger a rebuild.
The es-fluent-build crate provides a build.rs helper that emits cargo:rerun-if-changed directives for your locale assets, ensuring Cargo rebuilds when translations change. Crates that only use the derive macros do not need this setup.
Setup
Add es-fluent-build to your build dependencies:
[build-dependencies]
es-fluent-build = "0.16"
Call the tracking helper from build.rs:
// build.rs
fn main() {
es_fluent_build::track_i18n_assets();
}
This guarantees your project recompiles whenever locale files or folders are added, removed, or renamed.