Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

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

  1. Workspace Crates — Which crates you depend on directly and which support crates you usually won’t need directly.
  2. Getting Started — Installation, configuration, and a working end-to-end example.
  3. Deriving Messages — Mapping structs and enums to FTL message keys using EsFluent, EsFluentChoice, EsFluentVariants, and EsFluentLabel.
  4. Namespaces & File Splitting — Organizing translations into multiple FTL files.
  5. Language Enum — Auto-generating a type-safe Languages enum from your locale folders.
  6. Runtime Managers — Loading and resolving translations at runtime with the embedded, Dioxus, or Bevy manager.
  7. CLI Tooling — Generating, validating, syncing, cleaning, formatting, and inspecting FTL files from the command line.
  8. 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

CrateUse it forCovered in this book
es-fluentDerives, traits, and the public localization facadeGetting Started, Deriving Messages, Namespaces & File Splitting
es-fluent-manager-embeddedEmbedded-runtime apps, CLIs, TUIs, desktop appsRuntime Managers
es-fluent-manager-dioxusDioxus apps using provider/hook-based client locale state or request-scoped SSRRuntime Managers
es-fluent-manager-bevyBevy integration, reactive localized UI, asset loadingRuntime Managers
es-fluent-langType-safe locale enum generation and localized language namesLanguage Enum
es-fluent-cliGenerating, checking, cleaning, syncing, formatting, and inspecting FTL filesCLI Tooling

Public Support Crates

CrateRole
es-fluent-deriveProc-macro implementation re-exported by es-fluent
es-fluent-lang-macroImplementation crate behind #[es_fluent_language]
es-fluent-buildBuild-script helper for locale asset rebuild tracking
es-fluent-manager-coreShared runtime traits, module registration, fallback logic
es-fluent-manager-macrosCompile-time module registration and BevyFluentText derive

Internal Workspace Crates

CrateResponsibility
es-fluent-sharedRuntime-safe metadata, naming, namespace, and path helpers
es-fluent-derive-coreBuild-time option parsing and validation for derives
es-fluent-tomli18n.toml parsing, path resolution, and locale discovery
es-fluent-generateFTL AST generation, merging, cleaning, and formatting
es-fluent-cli-helpersRuntime logic executed inside the generated runner binary
es-fluent-runnerShared runner protocol types and .es-fluent/metadata path helpers
xtaskRepository 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:

  1. Configure — Create i18n.toml with your fallback language and asset path.
  2. Derive — Annotate structs and enums with #[derive(EsFluent)].
  3. Generate — Run cargo es-fluent generate to create FTL file skeletons.
  4. Translate — Edit the generated .ftl files with real translations.
  5. 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::Variantmy_enum-Variant).
  • Structs: The struct itself becomes the message ID (e.g., MyStructmy_struct).
  • Fields: Fields are automatically exposed as arguments to the Fluent message.
use es_fluent::EsFluent;

#[derive(EsFluent)]
pub enum LoginError {
    InvalidPassword,                   // no params
    UserNotFound { username: String }, // exposed as $username in the ftl file
    Something(String, String, String), // exposed as $f0, $f1, $f2 in the ftl file
    SomethingArgNamed(
        #[fluent(arg_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, and skip_inventory suppresses 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 like std::option::Option<T>. Type aliases to Option<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

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

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

Namespace Modes

Explicit String

namespace = "name" sets an explicit string namespace.

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

SyntaxExample source fileResulting namespace
namespace = "name"anyname
namespace = filesrc/ui/button.rsbutton
namespace(file(relative))src/ui/button.rsui/button
namespace = foldersrc/ui/button.rsui
namespace(folder(relative))src/ui/button.rsui

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:

  • macros is enabled by default and provides #[es_fluent_language].
  • localized-langs formats language names in the currently selected UI language instead of as autonyms.
  • bevy is retained for compatibility with existing Bevy projects. The wasm32 force-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:

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

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-lang resource attributes.
  • When you also derive EsFluent, cargo es-fluent generate will create keys for the enum in your FTL files.
  • You provide your own translations instead of using ICU4X-backed labels.
  • Use this when your app ships custom language-name translations for project-specific or otherwise unsupported locale tags.

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.

ManagerBest forHow it works
es-fluent-manager-embeddedCLIs, TUIs, desktop appsCompiles FTL files into the binary
es-fluent-manager-dioxusDioxus appsUses embedded assets plus Dioxus hooks or request-scoped SSR
es-fluent-manager-bevyBevy games and appsLoads 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 .ftl files via Bevy’s AssetServer.
  • Hot Reloading: Supports hot-reloading of translations during development.
  • Reactive UI: The FluentText component automatically refreshes text when the locale changes.
  • Bevy-native Context: Systems can request BevyI18n as a SystemParam for 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
}

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 into i18n.toml, repeatable or comma-separated.
  • --dioxus-runtime <client|ssr>: choose Dioxus manager features, repeatable or comma-separated.
  • --update-cargo-toml: add the matching es-fluent, manager, and unic-langid dependencies.
  • --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:

  1. Collect derive inventory registrations from workspace library targets.
  2. Update assets_dir/en/{your_crate}.ftl (and assets_dir/en/{your_crate}/{namespace}.ftl for 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: Optional es-fluent-cli version to install from crates.io. If omitted, the action installs the CLI from the pinned action ref. Use latest to 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.rs target 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., frfr-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.