Expand description
§Fluent-Typed
Your Fluent translations as typed Rust functions. A misspelled key, a missing translation, or the wrong argument type becomes a compile error — and a translation you no longer use becomes a build warning.
Write a message in an .ftl file:
# locales/en/main.ftl
# $name (String) - the user's name
hello = Hello { $name }…and fluent-typed generates a typed accessor for it. Calling it wrong no longer fails
silently at runtime — it fails the build:
let strs = L10n::En.load();
strs.msg_hello("Sam"); // ✓ compiles
strs.msg_hello(42); // ✗ wrong argument type — caught at compile time
strs.msg_helo("Sam"); // ✗ misspelled key — caught at compile time§Why fluent-typed
- Typed accessors, no boilerplate. One
build.rscall turns every message into a typed function. Argument types are inferred from comments,NUMBER()calls and plural selectors. - Dead translations become warnings. An unused message function raises a
cargowarning, so stale keys don’t quietly accumulate. - Cross-locale safety. An accessor is generated only for a message present in every
locale with a matching set of variables — the rest are skipped with a warning naming
the
.ftlfile and line. - A linter for comment mistakes. Typo’d type keywords, annotations of variables that
don’t exist, comments detached from their message — reported at
Warn,DenyorStrictlevels. - Embed or load on demand. Bake every locale into the binary for server-side use, or load one language at a time on the client.
- Automatic language negotiation. With the
langnegfeature,L10n::langneg("en-US")resolves a user’s preferred language to the closest available locale, falling back to your configured default. - Bidi-safe by default. Interpolated values are wrapped in Unicode isolation marks so right-to-left text can’t corrupt the surrounding message.
- A free language menu. Name a message
language-nameand the generatedL10nenum hands you human-readable names for a language picker.
To get the unused-message warnings, generate the file in the crate that uses the accessors, and keep the generated
L10n/L10nLanguagetypes out of that crate’s public API — otherwise every message looks “used”.
§Usage
Translations live in a locales/ folder, with one subfolder per language
(locales/en/, locales/fr/, …) holding .ftl
(Fluent) files:
# locales/en/main.ftl
language-name = English
greeting = Welcome!
# $name (String) - the user's name
hello = Hello { $name }
unread-messages = { $count ->
[one] You have one message
*[other] You have { $count } messages
}
login = Log in
.placeholder = Enter your emailfluent-typed turns each message into a typed accessor on L10nLanguage,
inferring argument types from comments, NUMBER() calls and plural selectors:
strs.msg_greeting(); // plain message -> String
strs.msg_hello("Sam"); // string arg (typed via the comment)
strs.msg_unread_messages(3); // number arg (typed via the plural selector)
strs.msg_login_placeholder(); // a message attribute
L10n::En.language_name(); // language name, for a language menu# in Cargo.toml
[dependencies]
fluent-typed = "0.6"
[build-dependencies]
fluent-typed = { version = "0.6", features = ["build"] }use fluent_typed::{build_from_locales_folder, BuildOptions};
// in build.rs
fn main() -> std::process::ExitCode {
// Build with the default settings, which means to
// generate the src/l10n.rs file from the fluent
// translations found in the `locales/` folder,
// prefix the generated functions with "msg_" and
// indent the code with 4 spaces.It also generates a
// single ftl file with all the languages, which is
// embedded in the binary. See the BuildOptions and
// FtlOutputOptions for all the configuration options.
//
// This function returns an ExitCode.
build_from_locales_folder(BuildOptions::default())
// Note: there are also `try_build_from_locales_folder`
// which returns a Result
}// in lib.rs or main.rs
mod l10n;
use l10n::L10n;
// Load English translations into an L10nLanguage struct.
// It provides safe functions for accessing all messages.
let strs: L10nLanguage = L10n::EnGb.load();
// With the feature "langneg" enabled you can do automatic language
// negotiation, which falls back on the default language as
// configured in the BuildOptions in build.rs when generating.
let found_lang: L10nLanguage = L10n::langneg("en");
// In Dioxus/Leptos/Silkenweb etc the L10nLanguage struct is typically
// used inside of a Signal or other reactive construct, so that all
// translations are automatically updated when the struct is changed.
// A message without arguments.
assert_eq!("Welcome!", strs.msg_greeting());
// A message with a string argument (AsRef<str>).
let hello: String = strs.msg_hello("world");
// A message with a number argument (Into<FluentNumber>).
let unread: String = strs.msg_unread_messages(2);
// Note: interpolated values are wrapped in Unicode bidi isolation
// marks by default, so `hello` is "Hello \u{2068}world\u{2069}".
// See "Bidi isolation" below.
// The list of translated, human-readable language names.
let language_names: Vec<&str>
= L10n::iter().map(|lang| lang.language_name()).collect();
// Server-side, you typically load all the languages once.
let languages = L10n::load_all();
// `get` returns the lower-level `L10nBundle`; access messages by id:
let greeting = languages.get(L10n::En).msg("greeting", None).unwrap();§Type deduction
Since the fluent syntax doesn’t explicitly specify the type of the translation variables, this project uses the following rules to infer the type of the translation variables.
Types are read from the default locale only (set with
BuildOptions::with_default_language, en by default). A type comment in any
other locale has no effect — the linter flags it. Translators never
need to maintain type metadata.
The rules:
- String:
- If a variable’s comment contains
(String), as in# $name (String) - The name.
- If a variable’s comment contains
- Number:
your-rank = { NUMBER($pos, type: "ordinal") ->
[1] You finished first!
[one] You finished {$pos}st
[two] You finished {$pos}nd
[few] You finished {$pos}rd
*[other] You finished {$pos}th
}§Linting
Because argument types come from message comments, a mistake in a comment would
otherwise silently leave a variable untyped. The linter catches the common ones
and reports the .ftl file and line of each:
- a typo’d keyword —
(Numbr)instead of(Number); - an annotation of a variable the message doesn’t have —
# $nmevs$name; - a type-annotation comment detached from its message by a blank line;
- a type annotation in a non-default locale, where it has no effect.
BuildOptions::with_lint_level controls how strict the check is:
LintLevel::Off— no lint diagnostics.LintLevel::Warn(the default) — problems are reported ascargo::warning=lines; the build still succeeds.LintLevel::Deny— comment mistakes in the default locale become hard build errors. An untyped variable is still allowed.LintLevel::Strict— likeDeny, and additionally every variable of every generated message must resolve to a concrete type (via a(String)/(Number)comment, aNUMBER()call or a plural selector). An untyped variable fails the build.
// in build.rs
let options = BuildOptions::default().with_lint_level(LintLevel::Strict);Diagnostics about non-default locales stay warnings even under Strict — they
concern translator-owned files and must never block a build.
§Structured messages
Some translations need the app to inject a UI element mid-sentence — an icon, a
link, a button — without splitting the translated string on a placeholder token.
Annotate a variable or term with (Element) and fluent-typed generates a struct
of resolved text segments split at those points:
# $count (Number) - How many unread.
# $icon (Element) - An icon injected by the app.
# -privacy-link (Element) - Link text the app wraps in an <a>.
notice = { $icon } You have { $count } unread, see { -privacy-link }.
-privacy-link = our privacy policyAn (Element) variable ($icon) is a pure positional gap — the app fills it
with its own element. An (Element) term (-privacy-link) carries
translatable text the app wraps. The generated accessor returns a struct whose
fields are the resolved text runs and the element slots, in render order:
pub struct Notice {
pub s0: String, // text before $icon
pub icon: ElementGap, // $icon slot — filled by the app
pub s1: String, // text between the elements
pub privacy_link: String, // resolved -privacy-link text
pub s2: String, // text after -privacy-link
}
// $icon / -privacy-link are not parameters; only real arguments are:
let n: Notice = strs.notice(3);
// `Notice` also implements `Display`, joining the text for plain-text use.Selectors and ordinary variables inside each segment are fully resolved, so the
app never re-implements plural logic. When rendering, wrap each field and each
injected element in an isolated bidi run (an HTML <bdi>, or
unicode-bidi: isolate) — see “Bidi isolation”.
A term and a message cannot share a bare name — -foo and foo collide inside
fluent-bundle, which keys both under foo. fluent-typed catches this at build
time and reports the two source locations so it can be fixed before it becomes
a runtime crash.
§Bidi isolation
By default, the generated accessors wrap every interpolated variable in Unicode
bidi isolation marks (FSI U+2068 … PDI U+2069). This is the safe default for
text rendered in a bidi-aware context such as a web UI: it keeps an interpolated
value’s text direction from corrupting the surrounding message, which matters
whenever a right-to-left locale is used or user-provided text is interpolated.
// strs.msg_hello("world") == "Hello \u{2068}world\u{2069}"If the generated strings are never rendered in a bidi-aware context — and you do not use right-to-left locales or interpolate user-provided text — you can turn the marks off:
// in build.rs
let options = BuildOptions::default().without_bidi_isolation();Modules§
Structs§
Enums§
- Build
Error - FtlOutput
Options - The ftl output options for the build command. This allows you to configure how the output ftl files are generated, and also what type of access code is generated.
- Lint
Level - How strictly fluent-typed checks the comments in your
.ftlfiles.
Functions§
- build_
from_ locales_ folder - Generate rust code and ftl files from locales folder, which contains
<lang-id>/<resource-name>.ftlfiles. - try_
build_ from_ locales_ folder - Same as build_from_locales_folder, but returns result instead of an ExitCode.