# es-fluent
[](https://github.com/stayhydated/es-fluent/actions/workflows/ci.yml)
[](https://docs.rs/es-fluent/)
[](https://crates.io/crates/es-fluent)
Derive macros and utilities for authoring strongly-typed messages with [Project Fluent](https://projectfluent.org/).
This crate gives you:
- Derives to turn enums/structs into Fluent message IDs and arguments.
- Integration via a embedded singleton manager (`es-fluent-manager-embedded`) or for Bevy (`es-fluent-manager-bevy`).
## Examples
- [bevy](https://github.com/stayhydated/es-fluent/tree/master/examples/bevy-example)
- [gpui](https://github.com/stayhydated/es-fluent/tree/master/examples/gpui-example)
- [cosmic](https://github.com/stayhydated/es-fluent/tree/master/examples/cosmic-example)
- [iced](https://github.com/stayhydated/es-fluent/tree/master/examples/iced-example)
## Installation
Add the crate with the `derive` feature to access the procedural macros:
```toml
[dependencies]
es-fluent = { version = "*", features = ["derive"] }
unic-langid = "*"
# If you want to register modules with the embedded singleton and localize at runtime:
es-fluent-manager-embedded = "*"
# For Bevy integration: replace `es-fluent-manager-embedded` with `es-fluent-manager-bevy`
es-fluent-manager-bevy = "*"
```
To bootstrap `.ftl` files from your Rust types, add the build helper:
```toml
[build-dependencies]
es-fluent-build = "*"
```
And create a `build.rs`:
```rs
// build.rs
use es_fluent_build::FluentParseMode;
fn main() {
if let Err(e) = es_fluent_build::FluentBuilder::new()
.mode(FluentParseMode::Conservative)
.build()
{
eprintln!("Error building FTL files: {e}");
}
}
```
## Project configuration
Create an `i18n.toml` next to your `Cargo.toml`:
```toml
# i18n.toml
assets_dir = "i18n" # where your localized files live
fallback_language = "en"
```
When you run a build, the builder will:
- Discover your crate name,
- Parse Rust sources under `src/`,
- Generate or update a base FTL file at `{assets_dir}/{fallback_language}/{crate_name}.ftl`.
For example, with `assets_dir = "../i18n"` and `fallback_language = "en"`, the file would be `../i18n/en/{crate_name}.ftl`.
## Core derives
### `#[derive(EsFluent)]` on enums
Annotate an enum or a struct to generate message IDs and implement `es_fluent::FluentDisplay`.
```rs
use es_fluent::EsFluent;
#[derive(EsFluent)]
pub struct HelloUser<'a>(&'a str);
impl<'a> HelloUser<'a> {
pub fn new(user_name: &'a str) -> Self {
Self(user_name)
}
}
```
Fields become Fluent arguments. The derive generates stable keys and formatting logic for you.
### Choices with `EsFluentChoice`
When a message needs to match on an enum (a Fluent select expression), implement `EsFluentChoice`. You can then mark a field with `#[fluent(choice)]` to pass its choice value instead of formatting it as a nested message.
```rs
use es_fluent::{EsFluent, EsFluentChoice};
#[derive(EsFluent, EsFluentChoice)]
#[fluent_choice(serialize_all = "snake_case")]
pub enum Gender {
Male,
Female,
Other,
}
#[derive(EsFluent)]
pub enum Shared<'a> {
Photos {
user_name: &'a str,
photo_count: &'a u32,
#[fluent(choice)]
user_gender: &'a Gender,
},
}
```
A prototyping build will write skeleton FTL like:
```ftl
## Gender
gender-Male = Male
gender-Female = Female
gender-Other = Other
## Hello
hello-User = User { $user_name }
## Shared
shared-Photos = Photos { $user_name } { $photo_count } { $user_gender }
```
You can then edit it into a real copy, e.g.:
```ftl
## Gender
gender-Female = Female
gender-Helicopter = Helicopter
gender-Male = Male
gender-Other = Other
## Hello
hello-User = Hello, {$user_name}!
## Shared
shared-Photos =
{$user_name} {$photo_count ->
[one] added a new photo
*[other] added {$photo_count} new photos
} to {$user_gender ->
[male] his stream
[female] her stream
*[other] their stream
}.
```
### `#[derive(EsFluent)]` on structs (keys and “this”)
You can derive on structs to produce key enums (labels, descriptions, etc.). For example:
```rs
use es_fluent::EsFluent;
#[derive(EsFluent)]
#[fluent(this)] // generates `Address::this_ftl()`
#[fluent(keys = ["description", "label"])]
pub struct Address {
pub street: String,
pub postal_code: String,
}
```
This expands to enums like `AddressLabelFtl` and `AddressDescriptionFtl` with variants for each field (`Street`, `PostalCode`). `this` adds a helper `Address::this_ftl()` that returns the ID of the parent.
### `#[derive(EsFluentKv)]` on structs
For key-value generation from structs, you can use `EsFluentKv`. This derive is specialized for generating keys for struct fields, often used for UI elements like labels and descriptions.
Here is an example of a `User` struct with various fields:
```rs
use es_fluent::{EsFluent, EsFluentKv};
use rust_decimal::Decimal;
use strum::EnumIter;
#[derive(Clone, Debug, Default, EnumIter, EsFluent, PartialEq)]
pub enum PreferedLanguage {
#[default]
English,
French,
Chinese,
}
#[derive(Clone, Debug, Default, EnumIter, EsFluent, PartialEq)]
pub enum EnumCountry {
#[default]
UnitedStates,
France,
China,
}
#[derive(Clone, Debug, Default, EsFluentKv)]
#[fluent_kv(this, keys_this, keys = ["description", "label"])]
pub struct User {
pub username: Option<String>,
pub email: String,
pub age: Option<u32>,
pub balance: Decimal,
pub subscribe_newsletter: bool,
pub enable_notifications: bool,
pub preferred: PreferedLanguage,
pub country: Option<EnumCountry>,
pub birth_date: Option<chrono::NaiveDate>,
pub skip_me: bool,
}
```
The `#[fluent_kv(this, keys_this, keys = ["description", "label"])]` attribute instructs the derive to generate enums `UserDescriptionFtl` and `UserLabelFtl`. The `this` argument also generates a message ID for the struct itself and `keys_this` generates a message ID for the generated enums from the keys.
This will generate the following FTL entries:
```ftl
## EnumCountry
enum_country-China = China
enum_country-France = France
enum_country-UnitedStates = United States
## PreferedLanguage
prefered_language-Chinese = Chinese
prefered_language-English = English
prefered_language-French = French
## User
user = User
## UserDescriptionFtl
user_description_kv_ftl = User Description Ftl
user_description_kv_ftl-age = Age
user_description_kv_ftl-balance = Balance
user_description_kv_ftl-birth_date = Birth Date
user_description_kv_ftl-country = Country
user_description_kv_ftl-email = Email
user_description_kv_ftl-enable_notifications = Enable Notifications
user_description_kv_ftl-preferred = Preferred
user_description_kv_ftl-skip_me = Skip Me
user_description_kv_ftl-subscribe_newsletter = Subscribe Newsletter
user_description_kv_ftl-username = Username
## UserLabelFtl
user_label_kv_ftl = User Label Ftl
user_label_kv_ftl-age = Age
user_label_kv_ftl-balance = Balance
user_label_kv_ftl-birth_date = Birth Date
user_label_kv_ftl-country = Country
user_label_kv_ftl-email = Email
user_label_kv_ftl-enable_notifications = Enable Notifications
user_label_kv_ftl-preferred = Preferred
user_label_kv_ftl-skip_me = Skip Me
user_label_kv_ftl-subscribe_newsletter = Subscribe Newsletter
user_label_kv_ftl-username = Username
```
## Derive Macro Supported kinds
### Enums
- enum_unit
- enum_named
- enum_tuple
### Structs
- struct_named
- struct_tuple
### Generics
Generic parameters must convert into Fluent values when used as arguments:
```rs
use es_fluent::EsFluent;
use fluent_bundle::FluentValue;
#[derive(EsFluent)]
pub enum GenericFluentDisplay<T>
where
for<'a> &'a T: Into<FluentValue<'a>>,
{
A(T),
B { c: T },
D,
}
```
## Language Enum Generation
### #[es_fluent_language]
This macro reads your crate's `i18n.toml`, finds all available languages in your `assets_dir`, and generates an enum with a variant for each one. It also implements `Default` (using your `fallback_language`) and conversions to/from `unic_langid::LanguageIdentifier`.
### Usage
Add the dependencies:
```toml
[dependencies]
es-fluent-lang = "*"
unic-langid = "*"
```
Then, apply the macro to an empty enum:
```rs
use es_fluent::EsFluent;
use es_fluent_lang_macro::es_fluent_language;
use strum::EnumIter;
#[es_fluent_language]
#[derive(Clone, Copy, Debug, EnumIter, EsFluent, PartialEq)]
pub enum Languages {}
// The macro generates variants from your i18n asset folders.
// If you have 'en' and 'fr-CA', it generates:
// enum Language { En, FrCA }
```