Introduction
This book is intended as an introduction to the Leptos_i18n crate.
This crate is made to simplify Internationalization in a Leptos application that loads locales at compile time and provides compile-time checks for translation keys, interpolation keys, and the selected locale.
This guide does assume you know some basics about Leptos, but the majority of the guide is about declaring the translations and how to use them. You can find the Leptos book here.
The source code for the book is available here. PRs for typos or clarification are always welcome.
Getting started
First thing we need is a Leptos project, you can find documentation on how to set one up in the Leptos book.
Once you have set one up, you can add this crate to your project with
cargo add leptos_i18n leptos_i18n_build
Or by adding this line to your Cargo.toml:
[dependencies]
leptos_i18n = "0.6"
[build-dependencies]
leptos_i18n_build = "0.6"
We actually need 2 crates, we will talk about the second one later
actix-web Backend
When compiling for the backend using actix-web, enable the actix feature for the leptos_i18n crate:
# Cargo.toml
[features]
ssr = [
"leptos_i18n/actix",
"leptos_i18n_build/ssr",
]
axum Backend
When compiling for the backend using axum, enable the axum feature for the leptos_i18n crate:
# Cargo.toml
[features]
ssr = [
"leptos_i18n/axum",
"leptos_i18n_build/ssr",
]
Hydrate
When compiling for the client, enable the hydrate feature:
# Cargo.toml
[features]
hydrate = [
"leptos_i18n/hydrate",
"leptos_i18n_build/hydrate",
]
Client Side Rendering
When compiling for the client, enable the csr feature:
# Cargo.toml
[dependencies.leptos_i18n]
features = ["csr"]
[build-dependencies.leptos_i18n_build]
features = ["csr"]
You can find examples using CSR on the github repo
Setting Up
his first section introduces the configuration you need to use leptos_i18n. By the end of this section, you will be able to set up the basics to start using translations in your Leptos application.
Load The Translations
To load the translations we need codegen, and for that you can use the leptos_i18n_build package.
You use it with a build.rs file to generate the code to properly use you translations:
// build.rs
use leptos_i18n_build::{TranslationsInfos, Config};
use std::path::PathBuf;
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
println!("cargo::rerun-if-changed=build.rs");
println!("cargo::rerun-if-changed=Cargo.toml");
// where to generate the translations
let i18n_mod_directory = PathBuf::from(std::env::var_os("OUT_DIR").unwrap()).join("i18n");
let cfg = Config::new("en")?.add_locale("fr")?; // "en" is the default locale, "fr" is another locale.
let translations_infos = TranslationsInfos::parse(cfg)?;
// emit the errors and warnings found during parsing
translations_infos.emit_diagnostics();
// emit "cargo::rerun-if-changed" for every translation file
translations_infos.rerun_if_locales_changed();
// codegen
translations_infos.generate_i18n_module(i18n_mod_directory)?;
Ok(())
}
The i18n Module
You can then import the generated code with:
include!(concat!(env!("OUT_DIR"), "/i18n/mod.rs"));
This will include a module called i18n. This module contains everything you need to use your translations.
include!(concat!(env!("OUT_DIR"), "/i18n/mod.rs"));
use i18n::*;
Configuration
This crate is basically entirely based around the code generated in a build script. We will cover it in a later chapter, but for now just know that it looks at your translation files and generates code for them.
To load those translations it first needs to know what to look for, so you need to declare what locales you are supporting and which one is the default.
To do that you use the Config builder:
To declare en and fr as locales, with en being the default you would write:
#![allow(unused)] fn main() { let cfg = Config::new("en")?.add_locale("fr")?; }
There are more optional values you can supply:
add_namespace: This is to split your translations into multiple files, we will cover it in a later chapterlocales_path: This is to have a custom path to the directory containing the locales files, it defaults to"./locales".translations_uri: Used in a CSR application with thedynamic_loadfeature, more information in a later chapter.extend_locale: Allows you to describe the inheritance structure for locales, covered in a later chapter.parse_options: Parsing options, covered in the next segment
Once this configuration is done, you can start writing your translations.
Parsing Options
Config can take some options as an argument, for now we use the default but you can import the ParseOptions struct to tell the parser what to expect and produce, here we change the file format to yaml:
use leptos_i18n_build::{FileFormat, ParseOptions, TranslationsInfos};
use std::path::PathBuf;
use std::error::Error;
fn main() -> Resul<(), Box<dyn Error>> {
println!("cargo::rerun-if-changed=build.rs");
println!("cargo::rerun-if-changed=Cargo.toml");
let i18n_mod_directory = PathBuf::from(std::env::var_os("OUT_DIR").unwrap()).join("i18n");
let options = ParseOptions::default().file_format(FileFormat::Yaml);
let cfg = Config::new("en")?.add_locale("fr")?.parse_options(options);
let translations_infos = TranslationsInfos::parse(cfg)?;
translations_infos.emit_diagnostics();
translations_infos.rerun_if_locales_changed();
translations_infos.generate_i18n_module(i18n_mod_directory)?;
Ok(())
}
There are other options:
suppress_key_warnings: remove warnings emitted by missing keys or surplus keysinterpolate_display: generates extra code for each interpolation to allow rendering them as a string instead of aViewshow_keys_only: This feature makes every translation display only its corresponding key; this is useful for tracking untranslated strings in your application.
example:
let options = ParseOptions::default()
.file_format(FileFormat::Json5)
.suppress_key_warnings(true)
.interpolate_display(true)
.show_keys_only(true);
There is also a way to inject your own formatter, this needs its own chapter, which you can find in an appendix.
Codegen Options
TranslationsInfos::generate_i18n_module_with_options can take a CodegenOptions argument that let you:
- Add some top level attributes for the generated module
- Customize the name of the generated file
example:
use leptos_i18n_build::CodegenOptions;
let attributes = "#![allow(missing_docs)]".parse()?;
let options = CodegenOptions::default()
.top_level_attributes(Some(attributes))
.module_file_name("i18n.rs"); // "mod.rs" by default
translations_infos.generate_i18n_module_with_options(options)?;
File Structure
Now that you have configured your locales, you can start writing your translations. This chapter covers where to put your files. We will cover how to write them in another section.
By default, you must put your files in the ./locales directory, and each file must be {locale}.json:
./locales
├── en.json
└── fr.json
Custom Directory
You can change the path to the directory containing the files with the locales_path method on the config builder, for example:
#![allow(unused)] fn main() { let cfg = Config::new("en")?.add_locale("fr")?.locales_path("./path/to/mylocales"); }
will look for:
./path
└── to
└── mylocales
├── en.json
└── fr.json
Other Formats
JSON is the default format, but other formats are supported. We will see how to change that later. Here is a list of supported formats:
| Format |
|---|
| JSON (default) |
| JSON5 |
| YAML |
| TOML |
Additional formats may be supported in the future.
Namespaces
Translation files can grow quite rapidly and become very large. Avoiding key collisions can be difficult without the use of long names. To avoid this situation, you can declare namespaces in the configuration:
#![allow(unused)] fn main() { let cfg = Config::new("en")?.add_locale("fr")?.add_namespaces(["common", "home"]); }
Then your file structure must look like this in the /locales directory:
./locales
├── en
│ ├── common.json
│ └── home.json
└── fr
├── common.json
└── home.json
You can now make smaller files, with one for each section of the website, for example.
This also allows the common namespace to use keys that the home namespace also uses, without colliding.
Locale Inheritance
Locale inheritance allows you to create specialized locales that build upon more general ones, reducing duplication and making maintenance easier. Instead of defining every key for each locale, you can override only the keys that differ while inheriting the rest from a parent locale.
What is Locale Inheritance?
Imagine you have an English locale (en) with all your application's text. Now you want to create an American English locale (en-US) that uses most of the same text but changes a few specific terms (like "colour" to "color").
With inheritance, the en-US locale will:
- Use its own keys when they exist
- Fall back to the
enlocale's keys when they don't exist
This means you only need to define the differences in en-US, not duplicate everything.
How Inheritance Works
There are two types of inheritance in this crate:
1. Implicit Inheritance (Automatic)
The crate automatically creates inheritance relationships based on locale structure. It follows a simple rule: more specific locales inherit from more general ones.
Matching Pattern: language[-region][-anything-else] inherits from language[-region]
Example Inheritance Tree
Given these locales:
en(default)en-USen-Latnen-Valenciaen-Latn-US-Valenciaen-Latn-US-Valencia-u-ca-buddhisten-Latn-US-u-ca-buddhistfrfr-FRfr-FR-u-ca-buddhistfr-u-ca-buddhist
The automatic inheritance tree becomes:
en (default)
├── en-US
│ ├── en-Latn-US-Valencia
│ ├── en-Latn-US-Valencia-u-ca-buddhist
│ └── en-Latn-US-u-ca-buddhist
├── en-Valencia
├── en-Latn
└── fr
├── fr-FR
│ └── fr-FR-u-ca-buddhist
└── fr-u-ca-buddhist
Important Note: Scripts, variants, and extensions are ignored in automatic matching. For example, en-Latn-US-Valencia-u-ca-buddhist inherits from en-US (not en-Latn-US-Valencia) because the system only considers the language (en) and region (US) parts.
2. Explicit Inheritance (Manual)
When automatic inheritance is not sufficient, you can manually specify inheritance relationships using the inherits configuration.
Configuration
Add inheritance rules with the config builder:
#![allow(unused)] fn main() { let cfg = cfg.extend_locale("child-locale", "parent-locale")?; }
Example
To make en-Latn-US-Valencia-u-ca-buddhist inherit from en-Latn-US-Valencia instead of en-US:
#![allow(unused)] fn main() { let cfg = cfg.extend_locale("en-Latn-US-Valencia-u-ca-buddhist", "en-Latn-US-Valencia")?; }
This changes the inheritance tree to:
en (default)
├── en-US
│ ├── en-Latn-US-Valencia
│ │ └── en-Latn-US-Valencia-u-ca-buddhist
│ └── en-Latn-US-u-ca-buddhist
├── en-Valencia
├── en-Latn
└── fr
├── fr-FR
│ └── fr-FR-u-ca-buddhist
└── fr-u-ca-buddhist
Missing Key Warnings
The inheritance system affects how missing key warnings are handled.
When Warnings Are Suppressed
- Child locales: If locale A inherits from locale B, no missing key warnings are emitted for locale A
- Reason: Missing keys are expected to be provided by the parent locale
When Warnings Are Emitted
- Root locales: Locales that don't inherit from others (except the default) will show warnings for missing keys
- Example: In the tree above,
frwill show warnings for keys present inenbut missing infr
Suppressing Warnings
You can suppress missing key warnings for a locale by explicitly setting it to inherit from the default locale:
#![allow(unused)] fn main() { let cfg = Config::new("en")? .add_locale("fr")? .add_locale("it")? .extend_locale("it", "en")?; }
Result:
itlocale: No missing key warnings (explicitly inherits fromen)frlocale: Will show missing key warnings (doesn't explicitly inherit)
Important Rules and Limitations
Default Locale Cannot Inherit
The default locale is the root of the inheritance tree and cannot inherit from other locales.
This will cause an error:
#![allow(unused)] fn main() { let cfg = Config::new("en")? .add_locale("fr")? .extend_locale("en", "fr")?; // ❌ Error: default locale cannot inherit }
Inheritance vs. Defaulting
There's a distinction between:
- Inheritance: Explicit parent-child relationships between related locales
- Defaulting: Falling back to the default locale when no other option exists
For example, while fr technically falls back to en (the default), this is considered defaulting, not inheritance. Therefore, fr can still generate missing key warnings.
This inheritance system provides flexibility while maintaining simplicity for common use cases.
Declare translations
Now that we covered the configuration and where to put each file, we can now start writing the translations.
This chapter covers this topic only for the JSON format.
Key-Value Pairs
As expected, translations are declared as key-value pairs:
{
"hello_world": "Hello World!"
}
However, there are additional rules you must follow beyond those of the format you use.
Keys
Key names must be valid Rust identifiers, with the exception that - will be converted to _, and do not support strict or reserved keywords.
Same Keys Across Files
The keys must be the same across all files; otherwise, the codegen will emit warnings. Any difference in keys is based on the default locale.
Missing Key
If a key is present in the default locale but not in another locale, the other locale will use the value from the default locale and emit a warning that a key is missing in that locale.
If you want to explicitly indicate that this value should use the value from the default locale, you can declare it as null:
{
"take_value_of_default": null
}
This will prevent a warning from being triggered for that key.
Surplus Key
If a key is present in another locale but not in the default locale, the key will be ignored and a warning will be emitted.
Value Kinds
You can specify several kinds of values:
- Literals (String, Numbers, Boolean)
- Interpolated String
- Plurals
The next chapters of this section will cover them (apart from literals, which are self-explanatory).
Interpolation
Interpolate Values
There may be situations where you need to interpolate a value in your translations, for example, a dynamic number. You could declare 2 translations and use them with that number, but this is not an elegant solution.
To declare a value that will be interpolated in your translations, simply place its name inside {{ }}:
{
"click_count": "You clicked {{ count }} times"
}
Interpolate Components
There may also be situations where you want to wrap part of your translation in a component, for example, to highlight it.
You can declare a component with HTML-like syntax:
{
"highlight_me": "highlight <b>me</b>"
}
Or use self-closing components:
{
"with_break": "some line <br /> some other line"
}
Use Both
You can use both interpolated values and interpolated components together:
{
"click_count": "You clicked <b>{{ count }}</b> times"
}
Components Attributes
You can pass attributes to the components:
{
"highlight_me": "highlight <b id=\"john\">me</b>"
}
The values the attributes accept are:
- strings
- booleans
- numbers (signed, unsigned, floats),
- variables
The syntax for using variables:
{
"with_break": "some line <br id={{ id }} /> some other line"
}
Values Names
Value names must follow the same rules as keys.
Plurals
What Are Plurals?
Plurals are a standardized way to deal with quantities. For example, English uses with 2 plurals: "one" (1) and "other" (0, 2, 3, ..).
If you have
{
"items": "{{ count }} items"
}
this would produce "1 items", which is incorrect English.
This can be solved by defining 2 plural forms:
{
"items_one": "{{ count }} item",
"items_other": "{{ count }} items"
}
When providing the count to the t! macro, this will result in:
let i18n = use_i18n();
t!(i18n, items, count = || 0) // -> "0 items"
t!(i18n, items, count = || 1) // -> "1 item"
t!(i18n, items, count = || 4) // -> "4 items"
All
items_*are merged into the single keyitems.
{{ count }} is a special variable when using plurals. Even if you don't interpolate it, you must supply it:
{
"items_one": "one item",
"items_other": "some items"
}
This will still require you to supply the count variable: t!(i18n, items, count = ...).
Why Bother?
Why bother, instead of just doing:
if item_count == 1 {
t!(i18n, items_one)
} else {
t!(i18n, items_other, count = move || item_count)
}
Because not all languages use the same plural rules.
For example, in French, 0 is considered singular, so this could produce "0 choses" instead of "0 chose", which is incorrect in French (with some exceptions — French has many of them).
Ordinal Plurals
What I described above are "cardinal" plurals, but they don’t work for cases like "1st place", "2nd place", etc.
The English language uses 4 ordinal plural forms, while French uses 2:
- one: "1st place", "21st place"
- two: "2nd place", "22nd place"
- few: "3rd place", "33rd place"
- other: "4th place", "5th place", "7th place"
And French:
- one: "1ère place"
- other: "2ème place", "21ème place"
You can use ordinal plurals by using the _ordinal suffix:
{
"key_ordinal_one": "{{ count }}st place",
"key_ordinal_two": "{{ count }}nd place",
"key_ordinal_few": "{{ count }}rd place",
"key_ordinal_other": "{{ count }}th place"
}
The
_ordinalsuffix is removed, in this example you access it witht!(i18n, key, count = ..)
How to Know Which to Use
There are online resources that help determine which plural rules to use, my personal favorite is the Unicode CLDR Charts.
What if I Need Multiple Counts?
If you need multiple counts, for example:
{
"key": "{{ boys_count }} boys and {{ girls_count }} girls"
}
There isn't a way to represent this in a single key, you will need Foreign keys, which you can read about in a later chapter.
Activate the Feature
To use plurals in your translations, enable the "plurals" feature.
Subkeys
You can declare subkeys by assigning a map to the key:
{
"subkeys": {
"subkey_1": "This is subkey_1",
"subkey_n": "This is subkey <b>{{ n }}</b>",
"nested_subkeys": {
"nested_subkey_1": "you can nest subkeys"
}
}
}
t!(i18n, subkeys.subkey_1); // -> "This is subkey_1"
t!(i18n, subkeys.nested_subkeys.nested_subkey_1); // -> "you can nest subkeys"
Foreign Keys
Foreign keys let you re-use already declared translations:
{
"hello_world": "Hello World!",
"reuse": "message: $t(hello_world)"
}
This will replace $t(hello_world) with the value of the key hello_world, making reuse equal to "message: Hello World!".
You can point to any key other than keys containing subkeys.
To point to subkeys, you give the path by separating the keys with .: $t(key.subkey.subsubkey).
When using namespaces, you must specify the namespace of the key you are looking for, using :: $t(namespace:key).
You can point to explicitly defaulted keys, but not implicitly defaulted ones.
Supply Arguments
You can also supply arguments to fill variables of the pointed key:
{
"click_count": "You clicked {{ count }} times",
"clicked_twice": "$t(click_count, {\"count\": \"two\"})"
}
This will result in clicked_twice having the value "You clicked two times".
Arguments must be strings, delimited by double quotes. JSON only supports double quotes.
Note: Any argument with no matching variable is just discarded; they will not emit any warning/error.
Arguments can be anything that could be parsed as a normal key-value:
{
"key": "{{ arg }}",
"string_arg": "$t(key, {\"arg\": \"str\"})",
"boolean_arg": "$t(key, {\"arg\": true})",
"number_arg": "$t(key, {\"arg\": 56})",
"interpolated_arg": "$t(key, {\"arg\": \"value: {{ new_arg }}\"})",
"foreign_key_arg": "$t(key, {\"arg\": \"value: $t(interpolated_arg)\"})"
}
t!(i18n, string_arg); // -> "str"
t!(i18n, boolean_arg); // -> "true"
t!(i18n, number_arg); // -> "56"
t!(i18n, interpolated_arg, new_arg = "a value"); // -> "value: a value"
t!(i18n, foreign_key_arg, new_arg = "a value"); // -> "value: value: a value"
"count" Arg for Plurals
If you have a plural like
{
"key_one": "one item",
"key_other": "{{ count }} items"
}
You can supply the count as a foreign key in two ways, the first as a variable:
{
"new_key": "$t(key, {\"count\": \"{{ new_count }}\"})"
}
This will just rename the key.
t!(i18n, new_key, new_count = move || 1); // -> "one item"
t!(i18n, new_key, new_count = move || 2); // -> "2 items"
note: For the
countarg to plurals, the value provided must be a single variable (whitespaces around it is supported).
Or by an actual value:
{
"singular_key": "$t(key, {\"count\": 1})",
"multiple_key": "$t(key, {\"count\": 6})"
}
t!(i18n, singular_key); // -> "one item"
t!(i18n, multiple_key); // -> "6 items"
note: While floats are supported, they don't carry all the information once deserialized (such as leading zeros), so some truncation may occur.
Multi Counts Plurals
If you need multiple counts for a plural, like for example:
{
"key": "{{ boys_count }} boys and {{ girls_count }} girls"
}
You can use Foreign keys to construct a single key from multiple plurals by overriding their "count" variable:
{
"key": "$t(key_boys, {\"count\": \"{{ boys_count }}\"}) and $t(key_girls, {\"count\": \"{{ girls_count }}\"})",
"key_boys_one": "{{ count }} boy",
"key_boys_other": "{{ count }} boys",
"key_girls_one": "{{ count }} girl",
"key_girls_other": "{{ count }} girls"
}
t!(i18n, key, boys_count = move || 0, girls_count = move || 0); // -> "0 boys and 0 girls"
t!(i18n, key, boys_count = move || 0, girls_count = move || 1); // -> "0 boys and 1 girl"
t!(i18n, key, boys_count = move || 1, girls_count = move || 0); // -> "1 boy and 0 girls"
t!(i18n, key, boys_count = move || 56, girls_count = move || 39); // -> "56 boys and 39 girls"
Mixing Kinds
What happens if for a key you declare plurals in one locale, interpolation in another, and a simple string in a third?
Well, this is totally allowed, but you will still need to supply all values/components of every locale combined when using the translation, regardless of what the current locale is.
What is not allowed to be mixed are subkeys. If a key has subkeys in one locale, it must have subkeys in all locales.
Formatters
For interpolation, every variable (other than count for plurals) is expected to be of type impl IntoView + Clone + 'static.
However, some values can be represented differently depending on the locale:
- Number
- Currency
- Date
- Time
- List
You can specify the kind of value you are going to supply like this:
{
"key": "{{ var, formatter }}"
}
Some of the formatters can take arguments to better suit your formatting needs:
{
"key": "{{ var, formatter(arg_name: value; arg_name2: value; ...) }}"
}
If an argument has a default value, not supplying that argument will make that arg take the default value.
Here are all the formatters:
Number
{
"number_formatter": "{{ num, number }}"
}
Will format the number based on the locale.
This means the variable must be impl leptos_i18n::formatting::NumberFormatterInputFn, which is automatically implemented for impl Fn() -> T + Clone + 'static where T: leptos_i18n::formatting::IntoFixedDecimal.
IntoFixedDecimal is a trait to turn a value into a fixed_decimal::Decimal, which is a type used by icu to format numbers. That trait is currently implemented for:
- Decimal
- UnsignedDecimal
- Unsigned
- usize
- u8
- u16
- u32
- u64
- u128
- isize
- i8
- i16
- i32
- i64
- i128
- f32 *
- f64 *
* Is implemented for convenience, but uses
Decimal::try_from_f64with the floating precision; you may want to use your own.
The formatter itself doesn’t provide formatting options such as maximum significant digits, but those can be customized through Decimal before being passed to the formatter.
Enable the "format_nums" feature to use the number formatter.
Arguments
There is one argument at the moment for the number formatter: grouping_strategy, which is based on icu::decimal::options::GroupingStrategy, that can take 4 values:
- auto (default)
- never
- always
- min2
Example
use crate::i18n::*;
let i18n = use_i18n();
let num = move || 100_000;
t!(i18n, number_formatter, num);
Currency (Experimental)
{
"currency_formatter": "{{ num, currency }}"
}
Will format the currency based on the locale. The variable should be the same as number.
Enable the "format_currency" feature to use the currency formatter.
Arguments
There are two arguments at the moment for the currency formatter: width and currency_code, which are based on icu::experimental::dimension::currency::options::Width and icu::experimental::dimension::currency::CurrencyCode.
width values:
- short (default)
- narrow
currency_code value should be a currency code, such as USD or EUR. USD is the default value.
Example
use crate::i18n::*;
let i18n = use_i18n();
let num = move || 100_000;
t!(i18n, currency_formatter, num);
Date
{
"date_formatter": "{{ date_var, date }}"
}
Will format the date based on the locale.
This means the variable must be impl leptos_i18n::formatting::DateFormatterInputFn, which is automatically implemented for impl Fn() -> T + Clone + 'static where T: leptos_i18n::formatting::IntoIcuDate.
IntoIcuDate is a trait to turn a value into a impl icu::datetime::input::Date, which is a trait used by icu to format dates. The IntoIcuDate trait is currently implemented for T: ConvertCalendar<Converted<'a> = Date<Ref<'a, AnyCalendar>>>.
You can use icu::datetime::input::{Date, DateTime}, or implement that trait for anything you want.
Enable the "format_datetime" feature to use the date formatter.
Arguments
length, which is based on icu::datetime::options::Length, that can take 3 values:
- long
- medium (default)
- short
alignment, which is based on icu::datetime::options::Alignment, that can take 2 values:
- auto (default)
- column
time_precision, which is based on icu::datetime::options::TimePrecision, that can take 13 values:
- hour
- minute
- second (default)
- subsecond_s1,
- subsecond_s2,
- subsecond_s3,
- subsecond_s4,
- subsecond_s5,
- subsecond_s6,
- subsecond_s7,
- subsecond_s8,
- subsecond_s9,
- minute_optional,
year_style, which is based on icu::datetime::options::YearStyle, that can take 3 values:
- auto
- full
- with_era
{
"short_date_formatter": "{{ date_var, date(length: short) }}"
}
Example
use crate::i18n::*;
use leptos_i18n::reexports::icu::datetime::input::Date;
let i18n = use_i18n();
let date_var = move || Date::try_new_iso(1970, 1, 2).unwrap().to_any();
t!(i18n, date_formatter, date_var);
Time
{
"time_formatter": "{{ time_var, time }}"
}
Will format the time based on the locale.
This means the variable must be impl leptos_i18n::formatting::TimeFormatterInputFn, which is automatically implemented for impl Fn() -> T + Clone + 'static where T: leptos_i18n::formatting::IntoIcuTime.
IntoIcuTime is a trait to turn a value into a impl icu::datetime::input::Time, which is a trait used by icu to format time. The IntoIcuTime trait is currently implemented for T: ConvertCalendar<Converted<'a> = Time> + InFixedCalendar<()> + AllInputMarkers<fieldsets::T>.
You can use icu::datetime::input::{Time, DateTime}, or implement that trait for anything you want.
Enable the "format_datetime" feature to use the time formatter.
Arguments
length, which is based on icu::datetime::options::Length, that can take 3 values:
- long
- medium (default)
- short
alignment, which is based on icu::datetime::options::Alignment, that can take 2 values:
- auto (default)
- column
time_precision, which is based on icu::datetime::options::TimePrecision, that can take 13 values:
- hour
- minute
- second (default)
- subsecond_s1,
- subsecond_s2,
- subsecond_s3,
- subsecond_s4,
- subsecond_s5,
- subsecond_s6,
- subsecond_s7,
- subsecond_s8,
- subsecond_s9,
- minute_optional,
{
"full_time_formatter": "{{ time_var, time(length: long) }}"
}
Example
use crate::i18n::*;
use leptos_i18n::reexports::icu::datetime::input::Time;
let i18n = use_i18n();
let time_var = move || Time::try_new(14, 34, 28, 0).unwrap();
t!(i18n, time_formatter, time_var);
DateTime
{
"datetime_formatter": "{{ datetime_var, datetime }}"
}
Will format the datetime based on the locale.
This means the variable must be impl leptos_i18n::formatting::DateTimeFormatterInputFn, which is automatically implemented for impl Fn() -> T + Clone + 'static where T: leptos_i18n::formatting::IntoIcuDateTime.
IntoIcuDateTime is a trait to turn a value into a impl icu::datetime::input::DateTime which is a trait used by icu to format datetimes. The IntoIcuDateTime trait is currently implemented for T: ConvertCalendar<Converted<'a> = DateTime<Ref<'a, AnyCalendar>>>.
You can use icu::datetime::input::DateTime, or implement that trait for anything you want.
Enable the "format_datetime" feature to use the datetime formatter.
Arguments
There are four arguments at the moment for the datetime formatter: length, alignment, time_precision and year_style, which behave exactly the same as the ones above.
{
"short_date_long_time_formatter": "{{ datetime_var, datetime(length: short; time_precision: minute) }}"
}
Example
use crate::i18n::*;
use leptos_i18n::reexports::icu::datetime::input::{Date, DateTime, Time};
let i18n = use_i18n();
let datetime_var = move || {
let date = Date::try_new_iso(1970, 1, 2).unwrap().to_any();
let time = Time::try_new(14, 34, 28, 0).unwrap();
DateTime::new(date, time)
};
t!(i18n, datetime_formatter, datetime_var);
List
{
"list_formatter": "{{ list_var, list }}"
}
Will format the list based on the locale.
This means the variable must be impl leptos_i18n::formatting::ListFormatterInputFn, which is automatically implemented for impl Fn() -> T + Clone + 'static where T: leptos_i18n::formatting::WriteableList.
WriteableList is a trait to turn a value into an impl Iterator<Item = impl writeable::Writeable>.
Enable the "format_list" feature to use the list formatter.
Arguments
There are two arguments at the moment for the list formatter: list_type and list_length.
list_type takes 3 possible values:
- and
- or
- unit (default)
list_length takes 3 possible values:
- wide (default)
- short
- narrow
See the Intl.ListFormat documentation. icu is used to do the formatting, but I found the Mozilla doc to have more details.
{
"short_and_list_formatter": "{{ list_var, list(list_type: and; list_length: short) }}"
}
Example
use crate::i18n::*;
let i18n = use_i18n();
let list_var = move || ["A", "B", "C"];
t!(i18n, list_formatter, list_var);
Notes
Formatters cannot be used inside component attributes, this is NOT allowed:
{
"highlight_me": "highlight <b id={{ id, number }}>me</b>"
}
How to Use in Code
Now that we know how to declare our translations, we can incorporate them into the code. This chapter covers how to do that.
I18nContext
The I18nContext type is here to make all your application reactive to the change of the locale. You will use it to access the current locale or change it.
The context is a wrapper around a RwSignal of the current locale. Every getter/setter must be used with the same reasoning as signals.
Provide the Context
The generated code contain the I18nContextProvider component in the i18n module,
you can use this component to make the context accessible to all child components.
use crate::i18n::*;
use leptos::prelude::*;
// root of the application
#[component]
pub fn App() -> impl IntoView {
view! {
<I18nContextProvider>
/* */
</I18nContextProvider>
}
}
Access the Context
Once provided, you can access it with the use_i18n function, also generated in the i18n module.
use crate::i18n::*;
use leptos::prelude::*;
// somewhere else in the application
#[component]
pub fn Foo() -> impl IntoView {
let i18n = use_i18n();
view! {
/* */
}
}
Access the Current Locale
With the context, you can access the current locale with the get_locale method:
use crate::i18n::*;
use leptos::prelude::*;
#[component]
pub fn Foo() -> impl IntoView {
let i18n = use_i18n();
create_effect(|_| {
let locale = i18n.get_locale();
match locale {
Locale::en => {
log!("locale en");
},
Locale::fr => {
log!("locale fr");
}
}
})
view! {
/* */
}
}
If you enable the nightly feature, you can directly call the context: let locale = i18n();.
A non-reactive counterpart to get_locale exists: get_locale_untracked.
Change the Locale
With the context, you can change the current locale with the set_locale method. For example, this component will switch between en and fr with a button:
use crate::i18n::*;
use leptos::prelude::*;
#[component]
pub fn Foo() -> impl IntoView {
let i18n = use_i18n();
let on_switch = move |_| {
let new_locale = match i18n.get_locale() {
Locale::en => Locale::fr,
Locale::fr => Locale::en,
};
i18n.set_locale(new_locale);
};
view! {
<button on:click=on_switch>{t!(i18n, click_to_change_lang)}</button>
}
}
If you enable the nightly feature, you can directly call the contexti18n(new_locale);.
A non-reactive counterpart to set_locale exists: set_locale_untracked.
cookie Feature
When using the cookie feature, the context will set a cookie whenever the locale changes,
this cookie will be used to decide what locale to use on the page load in CSR,
and on request to the server in SSR by looking at the request headers.
Context Options
The I18nContextProvider component accepts multiple props, all optional (except children):
children: obviouslyset_lang_attr_on_html: whether to set the "lang" attribute on the root<html>element (default to true)set_dir_attr_on_html: whether to set the "dir" attribute on the root<html>element (default to true)enable_cookie: should set a cookie to keep track of the locale when the page reloads (default to true) (do nothing without the "cookie" feature)cookie_name: give a custom name to the cookie (default to the crate default value) (do nothing without the "cookie" feature or ifenable_cookieis false)cookie_options: options for the cookie, the value is of typeleptos_use::UseCookieOptions<Locale>(default toDefault::default)
Note on Island
If you use the islands feature from Leptos, the I18nContextProvider loses two props: cookie_options and ssr_lang_header_getter, because they are not serializable. If you need them, you can use the init_context_with_options function and provide the context yourself:
use leptos_i18n::init_i18n_context_with_options;
use leptos_i18n::context::{CookieOptions, UseLocalesOptions};
use leptos_meta::Html;
use leptos::prelude::*;
use crate::i18n::*;
#[island]
fn MyI18nProvider(
enable_cookie: Option<bool>,
cookie_name: Option<&str>,
children: Children
) -> impl IntoView {
let my_cookie_options: CookieOptions<Locale> = /* create your options here */;
let ssr_lang_header_getter: UseLocalesOptions = /* create your options here */;
let i18n = init_i18n_context_with_options::<Locale>(
enable_cookie,
cookie_name,
Some(my_cookie_options),
Some(ssr_lang_header_getter)
);
provide_context(i18n);
let lang = move || i18n.get_locale().as_str();
let dir = move || i18n.get_locale().direction().as_str();
view! {
<Html
attr:lang=lang
attr:dir=dir
/>
{children}
}
}
"lang" and "dir" HTML Attributes
You may want to add a "lang" or/and "dir" attribute on an HTML element such that
<div lang="fr"></div>
You could do it yourself by tracking the locale and setting the attribute yourself, but there is a simpler way:
The I18nContext implements Directive from Leptos to set the "lang" attribute, so you can just do
let i18n = use_i18n();
view! {
<div use:i18n />
}
And it will set the "lang" and "dir" attributes for you on the <div> element !
Note: Use directives don't work on the server, so don't rely on this for server-side rendering.
Sub Context
You may want to have sections of your application use translations but be isolated from the "main" locale; this is what sub-contexts are for.
Why Not Just Use I18nContextProvider ?
I18nContextProvider does not shadow any context if one already exists,
this is because there should only be one "main" context, or they will conflict over the cookie, the "lang" attribute, the routing, etc.
init_i18n_subcontext_* functions create a context that does not battle with the main context and makes it more obvious that a sub-context is created, improving code clarity.
Initialize a Sub-context
leptos_i18n::context::init_i18n_subcontext takes an initial_locale: Option<Signal<L>> argument, this is so you can control the sub-context locale outside of it, you can for example make it so the locale of the sub-context is always the opposite of the "main" one:
fn neg_locale(locale: Locale) -> Locale {
match locale {
Locale::en => Locale::fr,
Locale::fr => Locale::en
}
}
fn neg_i18n_signal(i18n: I18nContext<Locale>) -> Signal<Locale> {
Signal::derive(move || neg_locale(i18n.get()))
}
fn opposite_context() {
let i18n = use_i18n();
let ctx = init_i18n_subcontext(Some(neg_i18n_signal(i18n)));
// ..
}
If it is not supplied, it takes the parent context locale as a default, and if no parent context exists (yes, you can use sub-context as a "main" context if you want), it uses the same locale resolution as the normal context.
Providing a Sub-context
There is no provide_i18n_subcontext. It does exist but is marked as deprecated; it is not actually deprecated, it is only there as an information point, although it does what you think.
Shadowing Correctly
Shadowing a context is not as easy as it sounds:
use crate::i18n::*;
use leptos::prelude::*;
use leptos_i18n::context::provide_i18n_subcontext;
#[component]
fn Foo() -> impl IntoView {
view! {
<I18nContextProvider>
<Sub />
<Home />
</I18nContextProvider>
}
}
#[component]
fn Sub() -> impl IntoView {
let i18n = provide_i18n_subcontext();
view! {
<p>{t!(i18n, sub)}</p>
}
}
#[component]
fn Home() -> impl IntoView {
let i18n = use_i18n();
view! {
<p>{t!(i18n, home)}</p>
}
}
This will actually make the sub-context provided in the <Sub /> component replace the parent context and leak into the <Home /> component.
leptos::provide_context has a section about shadowing in their docs. The best approach is to use a provider:
#[component]
fn Sub() -> impl IntoView {
let i18n = init_i18n_subcontext();
view! {
<Provider value=i18n>
<p>{t!(i18n, sub)}</p>
</Provider>
}
}
So this crate has a I18nSubContextProvider generated in the i18n module:
use crate::i18n::*;
use leptos::prelude::*;
#[component]
fn Foo() -> impl IntoView {
view! {
<I18nContextProvider>
<I18nSubContextProvider>
<Sub />
</I18nSubContextProvider>
<Home />
</I18nContextProvider>
}
}
#[component]
fn Sub() -> impl IntoView {
let i18n = use_i18n();
view! {
<p>{t!(i18n, sub)}</p>
}
}
Options
Same as with the normal context, sub-contexts have behavior control options; they all take the initial_locale: Option<Signal<L>> as their first argument.
init_i18n_subcontext_with_options takes cookie options;
that function is useless without the cookie feature.
cookie_nameis an option for a cookie name to be set to keep the state of the chosen locale.cookie_optionsis an option for cookie options.
The t! Macro
To access your translations, use the t! macro. You can access a string with a simple t!(i18n, $key):
use crate::i18n::*;
use leptos::prelude::*;
#[component]
pub fn Foo() -> impl IntoView {
let i18n = use_i18n();
view! {
{/* "hello_world": "Hello World!" */}
<p>{t!(i18n, hello_world)}</p>
}
}
Interpolate Values
If some variables are declared for this key, you can pass them like this:
use crate::i18n::*;
use leptos::prelude::*;
#[component]
pub fn Foo() -> impl IntoView {
let i18n = use_i18n();
let (counter, _set_counter) = signal(0);
view! {
{/* "click_count": "you clicked {{ count }} times" */}
<p>{t!(i18n, click_count, count = move || counter.get())}</p>
}
}
If your variable has the same name as the placeholder, you can pass it directly:
use crate::i18n::*;
use leptos::prelude::*;
#[component]
pub fn Foo() -> impl IntoView {
let i18n = use_i18n();
let (counter, _set_counter) = signal(0);
let count = move || counter.get();
view! {
{/* "click_count": "you clicked {{ count }} times" */}
<p>{t!(i18n, click_count, count)}</p>
}
}
You can pass anything that implements IntoView + Clone + 'static, you can pass a view if you want:
use crate::i18n::*;
use leptos::prelude::*;
#[component]
pub fn Foo() -> impl IntoView {
let i18n = use_i18n();
let (counter, _set_counter) = signal(0);
let count = view!{
<b>
{ move || counter.get() }
</b>
};
view! {
{/* "click_count": "you clicked {{ count }} times" */}
<p>{t!(i18n, click_count, count)}</p>
}
}
Any missing values will generate an error.
Interpolate Components
If some components are declared for this key, you can pass them like this:
use crate::i18n::*;
use leptos::prelude::*;
#[component]
pub fn Foo() -> impl IntoView {
let i18n = use_i18n();
let (counter, _set_counter) = signal(0);
let count = move || counter.get();
view! {
{/* "click_count": "you clicked <b>{{ count }}</b> times<br/>Keep going!" */}
<p>{t!(i18n, click_count, count, <br/> = || view! { <br/> }, <b> = |children| view!{ <b>{children}</b> })}</p>
}
}
Please note usage of self-closing components.
If your variable has the same name as the component, you can pass it directly:
use crate::i18n::*;
use leptos::prelude::*;
#[component]
pub fn Foo() -> impl IntoView {
let i18n = use_i18n();
let (counter, _set_counter) = signal(0);
let count = move || counter.get();
let b = |children| view!{ <b>{children}</b> };
view! {
{/* "click_count": "you clicked <b>{{ count }}</b> times<br/>Keep going!" */}
<p>{t!(i18n, click_count, count, <b>, <br/> = <br/>)}</p>
}
}
You can pass anything that implements Fn(leptos::ChildrenFn) -> V + Clone + 'static where V: IntoView for normal components or Fn() -> V + Clone + 'static where V: IntoView for self-closed components.
Any missing components will generate an error.
|children| view! { <b>{children}</b> } can be verbose for simple components; you can use this syntax when the children are wrapped by a single component:
// key = "<b>{{ count }}</b>"
t!(i18n, key, <b> = <span />, count = 32);
This will render <span>32</span>.
You can set attributes, event handlers, props, etc.:
t!(i18n, key, <b> = <span attr:id="my_id" on:click=|_| { /* do stuff */} />, count = 0);
Basically <name .../> expands to move |children| view! { <name ...>{children}</name> }
Components Attributes
If you declared attributes with your components
{
"highlight_me": "highlight <b id={{ id }}>me</b>"
}
You can either retrieve them with a closure:
#![allow(unused)] fn main() { use leptos::children::ChildrenFn; use leptos::attr::any_attribute::AnyAttribute; let b = |children: ChildrenFn, attr: Vec<AnyAttribute>| view!{ <b {..attr} >{children}</b> } t!(i18n, highlight_me, id = "my_id", <b>) }
Or they will be passed to direct components alongside code defined attributes:
#![allow(unused)] fn main() { // this will spread the attributes into `b` alongside the given attributes t!(i18n, highlight_me, id = "my_id", <b> = <b attr:foo="bar" />) }
The same works for self-closing components; for the closure syntax you can take the attributes as the only argument:
{
"foo": "before<br id={{ id }} />after"
}
#![allow(unused)] fn main() { let br = |attr: Vec<AnyAttribute>| view!{ <br {..attr} /> } t!(i18n, highlight_me, id = "my_id", <br>) }
note: variables to attributes expect the value to implement
leptos::attr::AttributeValue.
Components with children can accept Fn(ChildrenFn, Vec<AnyAttribute>) or Fn(ChildrenFn),
and self-closing components can accept Fn() or Fn(Vec<AnyAttribute>).
Plurals
Plurals expect a variable count that implements Fn() -> N + Clone + 'static where N implements Into<icu_plurals::PluralsOperands> (PluralsOperands). Integers and unsigned primitives implement it, along with FixedDecimal.
t!(i18n, key_to_plurals, count = count);
Access Subkeys
You can access subkeys by simply separating the path with .:
use crate::i18n::*;
use leptos::prelude::*;
#[component]
pub fn Foo() -> impl IntoView {
let i18n = use_i18n();
view! {
{/*
"subkeys": {
"subkey_1": "This is subkeys.subkey_1"
}
*/}
<p>{t!(i18n, subkeys.subkey_1)}</p>
}
}
Access Namespaces
Namespaces are implemented as subkeys. You first access the namespace, then the keys in that namespace:
use crate::i18n::*;
use leptos::prelude::*;
#[component]
pub fn Foo() -> impl IntoView {
let i18n = use_i18n();
view! {
<p>{t!(i18n, my_namespace.hello_world)}</p>
}
}
To avoid confusion with subkeys, you can use :: to separate the namespace name from the rest of the path:
t!(i18n, my_namespace::hello_world)
tu!
The tu! macro is the same as t! but untracked.
The td! Macro
The td! macro works just like the t! macro but instead of taking the context as its first argument, it takes the desired locale:
td!(Locale::fr, hello_world)
This is useful if, for example, you want the buttons to switch locale to always be in the language they switch to:
use crate::i18n::*;
use leptos::prelude::*;
#[component]
pub fn Foo() -> impl IntoView {
let i18n = use_i18n();
view! {
<For
each = Locale::get_all
key = |locale| **locale
let:locale
>
<button on:click = move|_| i18n.set_locale(*locale)>
{td!(*locale, set_locale)}
</button>
</For>
}
}
This could just be written as
use crate::i18n::*;
use leptos::prelude::*;
#[component]
pub fn Foo() -> impl IntoView {
let i18n = use_i18n();
view! {
<button on:click = move|_| i18n.set_locale(Locale::en)>
{td!(Locale::en, set_locale)}
</button>
<button on:click = move|_| i18n.set_locale(Locale::fr)>
{td!(Locale::fr, set_locale)}
</button>
}
}
But the above approach scales better.
The td_string! Macro
The td_string! macro is used for interpolations outside the context of rendering views. It lets you provide different kinds of values and returns either a &'static str or a String depending on the value of the key.
If the value is a plain string or boolean, it returns a &'static str. If it's an interpolation or a number, it returns a String.
This requires the interpolate_display feature to be enabled to work with interpolations.
It enables you to do this:
// click_count = "You clicked {{ count }} times"
assert_eq!(
td_string!(Locale::en, click_count, count = 10),
"You clicked 10 times"
)
assert_eq!(
td_string!(Locale::en, click_count, count = "a lot of"),
"You clicked a lot of times"
)
Expected Values
Variables expect anything that implements Display.
If the key uses plurals, it expects the type of the count. If you set the type to f32, it expects a f32.
Components expect a value that implements leptos_i18::display::DisplayComponent<M>. You can find some types made to help with formatting in the display module,
such as DisplayComp. (M is a marker for Fn trait shenanigans, if you implement the trait yourself you can set it to ().)
str, String, and references to them implement this trait such that
// hello_world = "Hello <b>World</b> !"
let hw = td_string!(Locale::en, hello_world, <b> = "span");
assert_eq!(hw, "Hello <span>World</span> !");
The DisplayComp struct lets you pass attributes:
let attrs = [("id", "my_id")];
let b = DisplayComp::new("div", &attrs);
let hw = td_string!(Locale::en, hello_world, <b>);
assert_eq!(hw, "Hello <div id=\"my_id\">World</div> !");
If you want finer control over the formatting, you can create your own types implementing the DisplayComponent trait, or you can pass this abomination of a function:
Fn(&mut core::fmt::Formatter, leptos_i18n::display::Children, leptos_i18n::display::Attributes) -> core::fmt::Result
which basically lets you do this:
use core::fmt::{Formatter, Result};
use leptos_i18n::display::{Attributes, Children};
fn render_b(f: &mut Formatter, child: Children, attrs: Attributes) -> Result {
write!(f, "<div{attrs} id=\"some_id\">{child}</div>")
}
// hello_world = "Hello <b foo={{ foo }}>World</b> !"
let hw = td_string!(Locale::en, hello_world, foo = "bar", <b> = render_b);
assert_eq!(hw, "Hello <div foo=\"bar\" id=\"some_id\">World</div> !");
note: values for attributes must implement the
leptos_i18n::display::AttributeValuetrait, already implemented for numbers (u*, i*, f* and NonZero), str, bools and Option<impl AttributeValue>
If you look closely, there are no Clone or 'static bounds for any arguments, but they are captured by the value returned by the macro,
so the returned value has a lifetime bound to the "smallest" lifetime of the arguments.
Components with children can accept Fn(&mut Formatter, Children, Attributes) or Fn(&mut Formatter, Children),
and self-closing components can accept Fn(&mut Formatter, Attributes) or Fn(&mut Formatter).
The td_display! Macro
Just like the td_string! macro but returns either a struct implementing Display or a &'static str instead of a Cow<'static, str>.
This is useful if you will print the value or use it in any formatting operation, as it will avoid a temporary String.
use crate::i18n::Locale;
use leptos_i18n::td_display;
// click_count = "You clicked {{ count }} times"
let t = td_display!(Locale::en, click_count, count = 10); // this only returns the builder, no work has been done.
assert_eq!(format!("before {t} after"), "before You clicked 10 times after");
let t_str = t.to_string(); // can call `to_string` as the value implements `Display`
assert_eq!(t_str, "You clicked 10 times");
t_string, t_display, tu_string and tu_display
They also exist, td_string was used here for easier demonstration. Remember that t_string accesses a signal reactively.
I18nRoute
You can use the leptos_i18n_router crate that exports the I18nRoute component.
This component acts exactly like a leptos_router::Route and takes the same args, except for the path.
What it does is manage a prefix on the URL such that
use crate::i18n::Locale;
use leptos_i18n_router::I18nRoute;
use leptos::prelude::*;
use leptos_router::*;
view! {
<Router>
<Routes fallback=||"Page not found">
<I18nRoute<Locale, _, _> view=Outlet>
<Route path=path!("") view=Home />
<Route path=path!("counter") view=Counter />
</I18nRoute<Locale, _, _>>
</Routes>
</Router>
}
Produce default routes "/" and "/counter", but also ":locale/" and ":locale/counter" for each locale.
If you have en and fr as your routes, the generated routes will be: /, /counter, /en, /en/counter, /fr and /fr/counter.
This component provides the I18nContext if not already provided, and sets the locale accordingly.
Locale Resolution
The locale prefix in the URL is considered to have the biggest priority. When accessing "/en/*“, the locale will be set to en no matter what.
But accessing without a locale prefix such as "/counter“, the locale will be resolved based on other factors like cookies, request Accept-Language header, or navigator API.
See the Locale Resolution section.
Redirection
If a locale is found those ways and it is not the default locale, this will trigger a navigation to the correct locale prefix.
This means if you access "/counter" with the cookie set to fr (default being en), then you will be redirected to "/fr/counter".
Switching Locale
Switching locale updates the prefix accordingly. Switching from en to fr will set the prefix to fr, but switching to the default locale will remove the locale prefix entirely.
State Keeping
Switching locale will trigger a navigation, update the Location returned by use_location, but will not refresh the component tree.
This means that if Counter keeps a count as a state, and you switch locale from fr to en, this will trigger a navigation from "/fr/counter" to "/counter",
but the component will not be rerendered and the count state will be preserved.
Navigation
With the way the default route is handled, if you have a <A href=.. /> link in your application or use leptos_router::use_navigate,
you don't have to worry about removing the locale prefix as this will trigger a redirection to the correct locale.
This redirection also sets NavigateOptions.replace to true so the intermediate location will not show in the history.
Basically, if you are at "/fr/counter" and trigger a redirection to "/", this will trigger another redirection to "/fr"
and the history will look like you directly navigated from "/fr/counter" to "/fr".
Localized Path Segments
You can use the i18n_path! macro inside the I18nRoute to create localized path segments:
use leptos_i18n_router::i18n_path;
<I18nRoute<Locale, _, _> view=Outlet>
<Route path=i18n_path!(Locale, |locale| td_string(locale, segment_path_name)) view={/* */} />
</I18nRoute<Locale, _, _>>
If you have segment_path_name = "search" for English, and segment_path_name = "rechercher" for French, the I18nRoute will produce 3 paths:
- "/search" (if default = "en")
- "/en/search"
- "/fr/rechercher"
It can be used at any depth, and if not used inside an i18nRoute it will default to the default locale.
Caveat
If you have a layout like this:
view! {
<I18nContextProvider>
<Menu />
<Router>
<Routes fallback=||"Page not found">
<I18nRoute<Locale, _, _> view=Outlet>
<Route path=path!("") view=Home />
</I18nRoute<Locale, _, _>>
</Routes>
</Router>
</I18nContextProvider>
}
And the Menu component uses localization, you could be surprised to see that sometimes there is a mismatch between the locale used by the Menu and the one inside the router.
This is due to the locale being read from the URL only when the i18nRoute is rendered. So the context may be initialized with another locale, and then hit the router that updates it.
One solution would be to use the Menu component inside the i18nRoute:
view! {
<I18nContextProvider>
<Router>
<Routes fallback=||"Page not found">
<I18nRoute<Locale, _, _> view=|| view! {
<Menu />
<Outlet />
}>
<Route path=path!("") view=Home />
</I18nRoute<Locale, _, _>>
</Routes>
</Router>
</I18nContextProvider>
}
Scoping
If you are using subkeys or namespaces, access keys can get pretty big and repetitive, wouldn't it be nice to scope a context to a namespace or subkeys?
Well, this page explains how to do it!
The scope_i18n! Macro
Using namespaces and subkeys can make things quite cumbersome very fast. Imagine you have this:
let i18n = use_i18n();
t!(i18n, namespace.subkeys.value);
t!(i18n, namespace.subkeys.more_subkeys.subvalue);
t!(i18n, namespace.subkeys.more_subkeys.another_subvalue);
This only uses namespace.subkeys.*, but we have to repeat it everywhere.
Here comes the scope_i18n! macro. You can rewrite it to:
let i18n = use_i18n();
let i18n = scope_i18n!(i18n, namespace.subkeys);
t!(i18n, value);
t!(i18n, more_subkeys.subvalue);
t!(i18n, more_subkeys.another_subvalue);
This macro can be chained:
let i18n = use_i18n();
let i18n = scope_i18n!(i18n, namespace);
let i18n = scope_i18n!(i18n, subkeys);
t!(i18n, value);
let i18n = scope_i18n!(i18n, more_subkeys);
t!(i18n, subvalue);
t!(i18n, another_subvalue);
The use_i18n_scoped! Macro
In the above example, we do let i18n = use_i18n(); but only access the context to scope it afterward. We could do
let i18n = scope_i18n!(use_i18n(), namespace.subkeys);
Well, this is what the use_i18n_scoped! macro is for:
let i18n = use_i18n_scoped!(namespace.subkeys);
t!(i18n, value);
t!(i18n, more_subkeys.subvalue);
t!(i18n, more_subkeys.another_subvalue);
The scope_locale! Macro
The above examples are to scope a context, but maybe you use td! a lot and run into the same problems:
fn foo(locale: Locale) {
td!(locale, namespace.subkeys.value);
td!(locale, namespace.subkeys.more_subkeys.subvalue);
td!(locale, namespace.subkeys.more_subkeys.another_subvalue);
}
You can use the scope_locale! macro here:
fn foo(locale: Locale) {
let locale = scope_locale!(locale, namespace.subkeys);
td!(locale, value);
td!(locale, more_subkeys.subvalue);
td!(locale, more_subkeys.another_subvalue);
}
And again, it is chainable:
fn foo(locale: Locale) {
let locale = scope_locale!(locale, namespace.subkeys);
td!(locale, value);
let locale = scope_locale!(locale, more_subkeys);
td!(locale, subvalue);
td!(locale, another_subvalue);
}
Caveat
Unfortunately, it looks too good to be true... What's the catch? Where is the tradeoff?
To make this possible, it uses a typestate pattern, but some of the types are hard to access as a user as they are defined deep in the generated i18n module.
This makes it difficult to write the type of a scoped context or a scoped locale.
By default, I18nContext<L, S> is only generic over L because the S scope is the "default" one provided by L, so you can easily write I18nContext<Locale>.
But once you scope it, the S parameters will look like i18n::namespaces::ns_namespace::subkeys::sk_subkeys::subkeys_subkeys.
Yes. This is the path to the struct holding the keys of namespace.subkeys.
This makes it difficult to pass a scoped type around, as it would require writing I18nContext<Locale, i18n::namespaces::ns_namespace::subkeys::sk_subkeys::subkeys_subkeys>.
Maybe in the future there will be a macro to write this horrible path for you, but I don't think it is really needed for now.
If you look at the generated code you will see this:
let i18n = { leptos_i18n::__private::scope_ctx_util(use_i18n(), |_k| &_k.$keys) };
Hmm, what is this closure for? It’s just here for type inference and key checking! The function parameter is even _:fn(&OS) -> &NS, it's never used.
The function is even const (not for scope_locale though, the only one that could really benefit from it lol, because trait functions can't be const...).
But being a typestate using it or not actually results in the same code path. And with how aggressive Rust is with inlining small functions, it probably compiles to the exact same thing. So no runtime performance loss! Yeah!
t_format!
You may want to use the formatting capability without needing to create an entry in your translations.
You can use the t_format! macro for that:
use crate::i18n::*;
use leptos_i18n::formatting::t_format;
let i18n = use_i18n();
let num = move || 100_000;
t_format!(i18n, num, formatter: number);
There are 9 variants, just like the t! macro, td_format!, tu_format!, *_format_string, and *_format_display.
Example
let date = move || Date::try_new_iso(1970, 1, 2).unwrap().to_any();
let en = td_format_string!(Locale::en, date, formatter: date);
assert_eq!(en, "Jan 2, 1970");
let fr = td_format_string!(Locale::fr, date, formatter: date(date_length: long));
assert_eq!(fr, "2 janvier 1970");
t_plural!
You can use the t_plural! macro to match on the plural form of a given count:
let i18n = use_i18n();
let form = t_plural! {
i18n,
count = || 0,
one => "one",
_ => "other"
};
Effect::new(|| {
let s = form();
log!("{}", s);
})
This will print "one" with the "fr" locale but "other" with the "en" locale.
Accepted forms are: zero, one, two, few, many, other, and _.
This macro is for cardinal plurals; if you want to match against ordinal plurals, use the t_plural_ordinal! macro.
Access Translations in a Const Context
You can access the translations in a const context if you have those things:
- Constant Locale
- No arguments
- No using the "dynamic_load" feature
If you have
{
"subkeys": {
"key": "my value"
}
}
You can do
use crate::i18n::*;
const MY_VALUE: &str = Locale::en.get_keys_const().subkeys().key().inner();
If you want a macro:
macro_rules! td_const {
($locale:expr, $first_key:ident $(.$key:ident)*) => {
($locale).get_keys_const()
.$first_key()
$(.$key())*
.inner()
};
}
const MY_VALUE: &str = td_const(Locale::en, subkeys.key);
Server Functions
There is no context in server functions, so you can't call use_i18n. You could provide a context if you want,
and it would work as expected, but if you just want to access the user's locale, you can use the resolve_locale function:
#![allow(unused)] fn main() { #[server] async fn get_locale() -> Result<Locale, ServerFnError> { let locale: Locale = leptos_i18n::locale::resolve_locale(); Ok(locale) } }
More Information
This chapter covers more details about expected behaviors, such as how locale resolution is done.
Locale Resolution
This library handles the detection of what locale to use for you, but it can be done in a multitude of ways.
Here is the list of detection methods, sorted in priorities:
- A locale prefix is present in the URL pathname when using
I18nRoute(e.g./en/about) - A cookie is present that contains a previously detected locale
- A locale can be matched based on the
Accept-Languageheader in SSR - A locale can be matched based on the
navigator.languagesAPI in CSR - As a last resort, the default locale is used.
In SSR, it is always the server that resolves what locale to use; the client does not try to compute a locale when loading; the only locale changes that can happen are by explicitly setting it in the context.
note: The URL pathname locale has a behavior that can be unexpected; it only resolves when the I18nRoute component starts rendering, so if anything relied on the resolved locale before that,
it may have used a different locale than it should. You can learn more on the caveat section of the router chapter.
How To Reduce Binary Size
This chapter covers the options you have to reduce the binary footprint of this library, aside from compiler options such as opt-level = "z" and other settings that are common to all builds.
ICU4X Datagen
This library uses ICU4X as a backend for formatters and plurals, and the default baked data provider can take quite a lot of space as it contains information for every possible locale. So if you use only a few, this is a complete waste.
Disable Compiled Data
The first step to remove this excess information is to disable the default data provider. It is activated by the "icu_compiled_data" feature, which is enabled by default. So turn off default features or remove this feature.
Custom Provider
Great, we lost a lot of size, but now instead of having too much information, we have 0 information. You will now need to bring your own data provider. For that, you will need multiple things.
1. Datagen
First, generate the information; you can use icu_datagen for that, either as a CLI or with a build.rs (we will come back to it later).
2. Load
Then you need to load those informations; this is as simple as
include!(concat!(env!("OUT_DIR"), "/baked_data/mod.rs"));
pub struct MyDataProvider;
impl_data_provider!(MyDataProvider);
you will also need some dependencies:
[dependencies]
# "default-features = false" to turn off compiled_data
icu_provider_baked = "2.0.0" # for databake
icu_provider = "2.0.0" # for databake
zerovec = "0.11" # for databake
This is explained more in depth in the icu_datagen doc.
3. Supply to leptos_i18n the Provider.
You now just need to tell leptos_i18n what provider to use. For that, you first need to implement IcuDataProvider for your provider. You can do it manually as it is straightforward, but the lib comes with a derive macro:
include!(concat!(env!("OUT_DIR"), "/baked_data/mod.rs"));
#[derive(leptos_i18n::custom_provider::IcuDataProvider)]
pub struct MyDataProvider;
impl_data_provider!(MyDataProvider);
And then pass it to the set_icu_data_provider function when the program starts,
so for CSR apps in the main function:
fn main() {
leptos_i18n::custom_provider::set_icu_data_provider(MyDataProvider);
console_error_panic_hook::set_once();
leptos::mount::mount_to_body(|| leptos::view! { <App /> })
}
and for SSR apps in both on hydrate and on server startup:
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
leptos_i18n::custom_provider::set_icu_data_provider(MyDataProvider);
console_error_panic_hook::set_once();
leptos::mount::hydrate_body(App);
}
// example for actix
#[actix_web::main]
async fn main() -> std::io::Result<()> {
leptos_i18n::custom_provider::set_icu_data_provider(MyDataProvider);
// ..
}
Build.rs Datagen
The doc for ICU4X datagen can be quite intimidating, but it is actually quite straightforward. Your build.rs can look like this:
use icu_provider_export::{
baked_exporter::{self, BakedExporter},
DataLocaleFamily, DeduplicationStrategy, ExportDriver, ExportMetadata,
};
use std::path::PathBuf;
fn main() {
println!("cargo::rerun-if-changed=build.rs");
let mod_directory = PathBuf::from(std::env::var_os("OUT_DIR").unwrap()).join("baked_data");
let exporter = BakedExporter::new(mod_directory, {
let mut options = baked_exporter::Options::default();
options.overwrite = true;
options.use_internal_fallback = false;
options
})
.unwrap();
ExportDriver::new(
&[locale!("en"), locale!("fr")],
DeduplicationStrategy::None.into(),
LocaleFallbacker::new_without_data(),
)
.with_markers(&[icu::plurals::provider::MARKERS]);
}
Here we are generating the information for locales "en" and "fr", with the data needed for plurals.
Using leptos_i18n_build Crate
You can use the leptos_i18n_build crate that contains utils for the datagen.
The problem with the above build.rs is that it can go out of sync with your translations,
when all information is already in the translations.
# Cargo.toml
[build-dependencies]
leptos_i18n_build = "0.6.0"
use leptos_i18n_build::{TranslationsInfos, Config};
use std::path::PathBuf;
fn main() {
println!("cargo::rerun-if-changed=build.rs");
println!("cargo::rerun-if-changed=Cargo.toml");
let mod_directory = PathBuf::from(std::env::var_os("OUT_DIR").unwrap()).join("baked_data");
let cfg: Config = // ...;
let translations_infos = TranslationsInfos::parse(cfg).unwrap();
translations_infos.rerun_if_locales_changed();
translations_infos.generate_data(mod_directory).unwrap();
}
This will parse the config and the translations and generate the data for you using the information gained when parsing the translations. This will trigger a rerun if the config or translations changed and be kept in sync. If your code uses plurals, it will build with information for plurals. If it uses a formatter, it will build with the information for that formatter.
If you use more data somehow, like for example using t*_format! with a formatter not used in the translations, there are functions to either supply additional options or keys:
use leptos_i18n_build::Options;
translations_infos.generate_data_with_options(mod_directory, [Options::FormatDateTime]).unwrap();
This will inject the ICU DataMarkers needed for the date, time, and datetime formatters.
use leptos_i18n_build::Options;
translations_infos.generate_data_with_data_markers(
mod_directory,
&[icu::plurals::provider::MARKERS]
).unwrap();
This will inject the keys for cardinal and ordinal plurals.
If you need both, Options can be turned into the needed keys:
use leptos_i18n_build::Options;
let mut markers = &[icu::plurals::provider::MARKERS];
let markers.extend(Options::FormatDateTime.into_data_markers());
// markers now contains the `DataMarker`s needed for plurals and for the `time`, `date` and `datetime` formatters.
translations_infos.generate_data_with_data_markers(mod_directory, markers).unwrap();
Is It Worth the Trouble?
YES. With opt-level = "z" and lto = true, the plurals example is at 394 kB (at the time of writing). Now, by just providing a custom provider tailored to the used locales ("en" and "fr"), it shrinks down to 248 kB! It almost cut in half the binary size!
I highly suggest taking the time to implement this.
Experimental Features
When using experimental features, such as "format_currency", if you follow the step above you will probably have some compilation error in the impl_data_provider! macro.
To solve them you will need those few things:
Enable Experimental Feature
Enable the "experimental" feature for icu:
# Cargo.toml
[dependencies]
icu = {
version = "1.5.0",
default-features = false,
features = [ "experimental"]
}
Import icu_pattern
# Cargo.toml
[dependencies]
icu_pattern = "0.2.0" # for databake
Import the alloc Crate
The macro directly uses the alloc crate instead of std, so you must bring it into scope:
extern crate alloc;
include!(concat!(env!("OUT_DIR"), "/baked_data/mod.rs"));
pub struct MyDataProvider;
impl_data_provider!(MyDataProvider);
Example
You can take a look at the counter_icu_datagen example. This is a copy of the counter_plurals example but with a custom provider.
Dynamic Loading of Translations
Why Use It?
By default, the translations are loaded at compile time and are baked into the binary. This has some performance advantages but comes at a cost: binary size. This is fine when the number of keys and locales is small and the values are not long, but when supporting many locales and a lot of keys, binary size can increase significantly.
The "dynamic_load" feature reduces this binary size increase by removing the baked translations in the binary and lazy loading them on the client.
The way it does that is by using a server function to request the translations in a given "translation unit".
What I call "translation unit" is a group of translations; they are either one unit per locale or one unit per locale per namespaces if you use them.
How It Works
When using SSR, the server will register every unit used for a given request and bake only the used one in the sent HTML. They are then parsed when the client hydrates, so no request for translations is done on page load. When the client needs access to an unloaded unit, it will request it from the server and will update the view when received.
What Changes?
Async Accessors
For obvious reasons, with the "dynamic_load" accessing a value is now async, t!, td! and tu! still return impl Fn() -> impl IntoView, as the async part is handled inside of it with some optimizations, but the *_display! and *_string! variants now return a future and need to be awaited.
You can turn them into some kind of Signal<Option<String>> using leptos AsyncDerived:
let i18n = use_i18n();
let translation = AsyncDerived::new(move || t_string!(i18n, key)); // .get() will return an `Option<&'static str>`
Feel free to make yourself a macro to wrap them:
macro_rules! t_string_async {
($($tt:tt),*) => {
leptos::prelude::AsyncDerived::new(move || leptos_i18n::t_string!($($tt),*))
}
}
This could have been the design by default, but there are multiple ways to handle it so I decided to leave the choice to the user.
note: They are technically not needed to be async on the server, as translations are still baked in for them, but for the API to be the same on the client and the server they return the value wrapped in an async block.
Server Fn
If you use a backend that needs to manually register server functions,
you can use the ServerFn associated type on the Locale trait implemented by the generated Locale enum:
use i18n::Locale;
use leptos_i18n::Locale as LocaleTrait;
register_server_fn::<<Locale as LocaleTrait>::ServerFn>();
CSR
With SSR the translations are served by a server functions, but they don't exist with CSR, so you will need to create a static JSON containing them, so a bit more work is needed.
To do that you can use a build script and use the leptos_i18n_build crate:
# Cargo.toml
[build-dependencies]
leptos_i18n_build = "0.6.0"
use leptos_i18n_build::TranslationsInfos; fn main() { println!("cargo::rerun-if-changed=build.rs"); println!("cargo::rerun-if-changed=Cargo.toml"); let translations_infos = TranslationsInfos::parse(Default::default()).unwrap(); translations_infos.rerun_if_locales_changed(); translations_infos .get_translations() .write_to_dir("path/to/dir") .unwrap(); }
This will generate the necessary JSON files in the given directory. For example, you could generate them in target/i18n, resulting in this file structure:
./target
└── i18n
├── locale1.json
└── locale2.json
If you are using namespaces it would have this one:
./target
└── i18n
└── namespace1
├── locale1.json
└── locale2.json
└── namespace2
├── locale1.json
└── locale2.json
Then if you are using Trunk you just have to add the directory to the build pipeline:
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<link data-trunk rel="copy-dir" href="./target/i18n" />
</head>
<body></body>
</html>
Now the translations will be available at i18n/{locale}.json.
To inform leptos_i18n where to find those translations, you need to supply the translations_uri to the config builder:
#![allow(unused)] fn main() { let cfg = cfg.translations_uri("i18n/{locale}.json"); // or "i18n/{namespace}/{locale}.json" when using namespaces }
And this is it!
Disclaimers
-
There is a chance that enabling this feature actually increases binary sizes if there aren’t many translations, as there is additional code being generated to request, parse, and load the translations. But this is mostly a fixed cost, so with enough translations, so with enough translations, the trade-off will be beneficial. So do some testing.
-
Only the raw strings are removed from the binary; the code to render each key is still baked in it, whatever the locale or the namespace.
Features
You can find here all the available features of the crate.
actix
This feature must be enabled when building the server with the actix backend.
axum
This feature must be enabled when building the server with the axum backend.
ssr
This feature must be enabled when building the server. It is automatically enabled by the actix or axum features, but if you use another backend, you can use this feature and provide custom functions to get access to the request headers.
hydrate
This feature must be enabled when building the client in ssr mode.
csr
This feature must be enabled when building the client in csr mode.
cookie (Default)
Set a cookie to remember the last chosen locale.
islands
This feature is, as its name says, experimental.
This makes this lib somewhat usable when using islands with Leptos.
nightly
Enable the use of some nightly features, like directly calling the context to get/set the current locale
and allow the load_locale! macro to emit better warnings.
icu_compiled_data (Default)
ICU4X is used as a backend for formatting and plurals. They bring their own data to know what to do for each locale. This is great when starting up a project without knowing exactly what you need. This is why it is enabled by default, so things work right out of the box. But those baked data can take quite a lot of space in the final binary as they bring information for all possible locales, so if you want to reduce this footprint, you can disable this feature and provide your own data with selected information. See the datagen section in the reduce binary size chapter for more information.
plurals
Allow the use of plurals in translations.
format_datetime
Allow the use of the date, time, and datetime formatters.
format_list
Allow the use of the list formatter.
format_nums
Allow the use of the number formatter.
format_currency
Allow the use of the currency formatter.
The i18n Ally VS Code Extension
The i18n Ally extension includes many features for managing, structuring, and automating translations, with the most notable being an overlay on translation keys in the code, displaying the corresponding translations.
This is very helpful, and this section is a guide for a minimal setup to make this extension work with Leptos i18n.
Custom Framework Setup
For obvious reasons, this lib is not supported by i18n Ally (one day maybe?), but the developers of that extension have provided a way to make it work with custom frameworks.
You will need to first create a file in your .vscode folder named i18n-ally-custom-framework.yml and put this in it:
languageIds:
- rust
usageMatchRegex:
- "[^\\w\\d]t!\\(\\s*[\\w.:]*,\\s*([\\w.]*)"
- "[^\\w\\d]td!\\(\\s*[\\w.:]*,\\s*([\\w.]*)"
- "[^\\w\\d]td_string!\\(\\s*[\\w.:]*,\\s*([\\w.]*)"
- "[^\\w\\d]td_display!\\(\\s*[\\w.:]*,\\s*([\\w.]*)"
monopoly: true
languageIds is the language you are using in your project. I'm no expert, but this is probably for a VSC API to know what files to check.
usageMatchRegex is the regex to use to find the translations keys; the above regex patterns are, in order: for t!, td!, td_string! and td_display!. If you don't use all translations macros, you can remove/comment out the regex for that macro. These regexes may not be perfect, and I am no expert, so there may be better or faster alternatives. If you encounter a problem with them, feel free to open an issue or discussion on GitHub.
monopoly disables all supported frameworks; if you use any other frameworks supported by the extension in your project, set it to false.
Common Settings
There are multiple settings for the extension that you can set in .vscode/settings.json; those are all optional. Here is a non-exhaustive list with their defaults in parentheses:
-
i18n-ally.keystyle(auto): This option can beflat("a.b.c": "...") ornested("a": { "b": { "c": "..." } }). This is irrelevant if you don’t use subkeys, but if you do, set it to"nested"as this is the style that this library supports. -
i18n-ally.localesPaths(auto): This is the path to your locales; it can be a path or a list of paths. By default, it is set to"locales", but if you use a custom locales path or a cargo workspace, you will have to supply the path here. -
i18n-ally.namespace(false): Set this totrueif you use namespaces. If you use namespaces withi18n Ally, I have not figured out (maybe you will?) how to make thenamespace::keysyntax work for the macros, so just usenamespace.key. -
i18n-ally.sourceLanguage(en): The primary language of the project; I suggest setting this to your default locale. -
i18n-ally.displayLanguage(en): The locale that the overlay uses.
You can find other settings that may interest you in the official documentation, with more information about the settings mentioned above, along with their default values.
Other Features
This extension offers other interesting features. I suggest you take a look at their wiki for more information.