# es-fluent > es-fluent is a Rust localization ecosystem built on Project Fluent focused on type safety, ergonomics, and developer experience. It uses derive macros and tooling to keep Rust types and FTL messages in sync. Key features: - Derive macros (`EsFluent`, `EsFluentChoice`, `EsFluentVariants`, `EsFluentLabel`) for strongly typed message keys and arguments - `cargo es-fluent` CLI support for checking and generating FTL skeletons - Compile-time namespace and argument validation aligned with `i18n.toml` - Language enum generation from locale assets via `es-fluent-lang-macro` - Runtime integrations via `es-fluent-manager-embedded` and `es-fluent-manager-bevy` ## Docs # Introduction `es-fluent` is a localization (i18n) ecosystem for Rust built on top of [Project Fluent](https://projectfluent.org/). 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**](workspace_map.md) — Which crates you depend on directly and which support crates you usually won't need directly. 2. [**Getting Started**](getting_started.md) — Installation, configuration, and a working end-to-end example. 3. [**Deriving Messages**](deriving_messages.md) — Mapping structs and enums to FTL message keys using `EsFluent`, `EsFluentChoice`, `EsFluentVariants`, and `EsFluentLabel`. 4. [**Namespaces & File Splitting**](namespaces.md) — Organizing translations into multiple FTL files. 5. [**Language Enum**](language_enum.md) — Auto-generating a type-safe `Languages` enum from your locale folders. 6. [**Runtime Managers**](managers.md) — Loading and resolving translations at runtime with the embedded, Dioxus, or Bevy manager. 7. [**CLI Tooling**](cli.md) — Generating, validating, syncing, cleaning, formatting, and inspecting FTL files from the command line. 8. [**Incremental Builds**](incremental_builds.md) — 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. ```toml [dependencies] es-fluent = "0.16" es-fluent-manager-embedded = "0.16" es-fluent-lang = "0.16" ``` Install the CLI separately: ```sh 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](getting_started.md), [Deriving Messages](deriving_messages.md), [Namespaces & File Splitting](namespaces.md) | | `es-fluent-manager-embedded` | Embedded-runtime apps, CLIs, TUIs, desktop apps | [Runtime Managers](managers.md) | | `es-fluent-manager-dioxus` | Dioxus apps using provider/hook-based client locale state or request-scoped SSR | [Runtime Managers](managers.md) | | `es-fluent-manager-bevy` | Bevy integration, reactive localized UI, asset loading | [Runtime Managers](managers.md) | | `es-fluent-lang` | Type-safe locale enum generation and localized language names | [Language Enum](language_enum.md) | | `es-fluent-cli` | Generating, checking, cleaning, syncing, formatting, and inspecting FTL files | [CLI Tooling](cli.md) | ## 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: ```toml [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: ```sh 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: ```toml # 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 ```rust // src/lib.rs use es_fluent::EsFluent; #[derive(EsFluent)] pub enum LoginError { InvalidPassword, UserNotFound { username: String }, } ``` ### 2. Generate the FTL file ```sh cargo es-fluent generate ``` This creates `assets/locales/en/{your_crate}.ftl` with a skeleton: ```ftl ## 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 ```rust // src/main.rs use my_crate::i18n::I18n; use unic_langid::langid; fn main() -> Result<(), Box> { 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: ```sh 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. ```rust 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: ```ftl ## 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: ```rust 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` fields, including paths like `std::option::Option`. Type aliases to `Option` 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. ```rust 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)); ``` ```ftl ## 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. ```rust 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: ```ftl greeting = { $gender -> [male] Welcome Mr. { $name } [female] Welcome Ms. { $name } *[other] Welcome { $name } } ``` ```rust 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. ```rust 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: ```ftl ## 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 ``` ```rust 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: ```rust use es_fluent::EsFluentVariants; #[derive(EsFluentVariants)] pub enum SettingsTab { General, Notifications, Privacy, } ``` ```ftl ## SettingsTabVariants settings_tab_variants-General = General settings_tab_variants-Notifications = Notifications settings_tab_variants-Privacy = Privacy ``` ```rust 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`. ```rust use es_fluent::EsFluentLabel; #[derive(EsFluentLabel)] pub enum GenderLabelOnly { Male, Female, Other, } ``` ```ftl gender_label_only_label = Gender Label Only ``` ```rust 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: ```rust 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, } ``` ```ftl login_form_combined_label_variants_label = Login Form Combined Label Variants login_form_combined_description_variants_label = Login Form Combined Description Variants ``` ```rust 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. ```rust 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. ```rust 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. ```rust 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. ```rust 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. ```rust 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: ```toml [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]`: ```rust 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: ```rust 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` | Converts from an owned `unic-langid` identifier | | `Into` | 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: ```rust use es_fluent_manager_embedded as manager; let i18n = manager::EmbeddedI18n::try_new_with_language(Languages::En)?; ``` Since it implements `Into`, 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: ```rust 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: ```rust 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**: ```rust #[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. | 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: ```rust // 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: ```rust 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> { 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(...)`: ```rust use es_fluent::FluentLabel as _; let title = MyMessage::localize_label(&i18n); ``` If you have a [Language Enum](language_enum.md), you can pass it directly since it implements `Into`: ```rust 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(...)`: ```rust 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: ```toml [dependencies] es-fluent-manager-core = "0.16" ``` ```rust 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: ```rust 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: ```toml # 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: ```rust // 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 ```rust 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 ```rust 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> { 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](https://bevyengine.org/) 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` for UI text, and derive `BevyFluentText` on message types used with `FluentText` 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: ```rust // 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`: ```rust 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: ```rust 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: ```rust 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](language_enum.md)), 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`. ```rust 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). ```rust 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: ```rust app.register_fluent_text::(); ``` If the type needs locale refresh, implement `RefreshForLocale` and use the locale-aware registration function: ```rust 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::(); ``` #### Do Nested Types Need `BevyFluentText`? Only the **component type** wrapped by `FluentText` 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` 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](deriving_messages.md)), and manages the corresponding FTL translation files for you. ## Installation ```sh cargo install es-fluent-cli --locked ``` ## Configuration The CLI reads your `i18n.toml` (see [Getting Started](getting_started.md)) to locate FTL assets. Make sure it exists in your crate root: ```toml # 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 `/`-p ` to choose a crate or workspace root instead of the current directory, and `--package `/`-P ` to process one package from a workspace. ## Commands ### Init For a new crate, scaffold the standard files first: ```sh 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 `: choose the fallback locale directory and config value. - `--locales `: create additional locale directories, repeatable or comma-separated. - `--assets-dir `: choose the locale asset directory relative to the crate root. - `--namespaces `: write a namespace allowlist into `i18n.toml`, repeatable or comma-separated. - `--dioxus-runtime `: 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: ```sh cargo es-fluent generate ``` This will: 1. Collect derive inventory registrations from workspace library targets. 1. Update `assets_dir/en/{your_crate}.ftl` (and `assets_dir/en/{your_crate}/{namespace}.ftl` for [namespaced](namespaces.md) 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: ```sh 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: ```sh cargo es-fluent check ``` Use `--all` to check all locales, not just the fallback language. Use `--ignore ` 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: ```sh 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): ```sh 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: ```sh 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: ```sh cargo es-fluent sync --all ``` Use `--locale ` 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 ` to create missing locale directories before seeding them from the fallback locale. The `sync` command properly handles [namespaced](namespaces.md) 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: ```sh cargo es-fluent add-locale fr-FR zh-CN ``` This is equivalent to `sync --create --locale ` 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: ```sh 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: ```sh 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: ```sh 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: ```sh 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 ```yaml 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., `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**: ```toml [build-dependencies] es-fluent-build = "0.16" ``` Call the tracking helper from `build.rs`: ```rust,no_run // 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. ---