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
Or by adding this line to your Cargo.toml
under [dependencies]
:
leptos_i18n = "0.4"
actix-web
Backend
When compiling for the backend using actix-web
, enable the actix
feature:
# Cargo.toml
[features]
ssr = [
"leptos_i18n/actix",
]
axum
Backend
When compiling for the backend using axum
, enable the axum
feature:
# Cargo.toml
[features]
ssr = [
"leptos_i18n/axum",
]
Hydrate
When compiling for the client, enable the hydrate
feature:
# Cargo.toml
[features]
hydrate = [
"leptos_i18n/hydrate",
]
Client Side Rendering
When compiling for the client, enable the csr
feature:
# Cargo.toml
[dependencies.leptos_i18n]
features = ["csr"]
You can find examples using CSR on the github repo
Setting Up
This first section will introduce you to the configuration you need to use leptos_i18n
. By the end of this section, you should be able to
set up the basics to start using translations in your Leptos
application.
Configuration
This crate in basically entirely based around one macro: the load_locales!
macro. 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 [package.metadata.leptos-i18n]
section in your Cargo.toml
.
To declare en
and fr
as locales, with en
being the default you would write:
[package.metadata.leptos-i18n]
default = "en"
locales = ["en", "fr"]
There is 2 more optional values you can supply:
namespaces
: This is to split your translations in multiple files, we will cover it in a later chapterlocales-dir
: This is to have a custom path to the directory containing the locales files, it defaults to"./locales"
.
Once this configuration is done, you can start writing your translations.
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-dir
field in the configuration, for example
[package.metadata.leptos-i18n]
default = "en"
locales = ["en", "fr"]
locales-dir = "./path/to/mylocales
will look for
./path
└── to
└── mylocales
├── en.json
└── fr.json
Other Formats
JSON being the default, you can change that by first removing the defaults features, and enabling the feature for the format you need:
# Cargo.toml
[dependencies]
leptos_i18n = {
default-features = false,
features = ["yaml_files"]
}
Format | Feature |
---|---|
JSON (default) | json_files |
YAML | yaml_files |
Other formats may be supported later.
Namespaces
Translations files can grow quite rapidly and becomes very big, and avoiding key collisions can be hard without avoiding long names. To avoid this situation you can declare namespaces in the configuration:
[package.metadata.leptos-i18n]
default = "en"
locales = ["en", "fr"]
namespaces = ["common", "home"]
Then your file structures 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 sections of the website for example.
This also allows the common
namespace to use keys that the home
namespace also uses, without colliding.
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, you declare your translations as key-value pairs:
{
"hello_world": "Hello World!"
}
But there are additional rules you must follow in addition to those of the format you use.
Keys
Key names must be valid Rust identifier, with the exception of -
that would be converted to _
.
Same keys across files
The keys must be the same across all files, else the load_locales!
macro will emit warnings. The 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 default it's value to the default locale one and emit a warning that a key is missing in that locale.
If you want to explicitly state that this value take the value of the default locale, you can declare it as null
:
{
"take_value_of_default": null
}
This will no longer trigger a warning for that key.
Surplus key
If a key is present in another locale but not in the default locale, this key will be ignored and a warning will be emitted.
Value Kinds
You can specify multiple kinds of values:
- Literals (String, Numbers, Boolean)
- Interpolated String
- Ranges
- Plurals
The next chapters of this section will cover them, apart for literals, those are self explanatory.
Interpolation
Interpolate Values
There may be situations where you must interpolate a value inside your translations, for example a dynamic number. You could declare 2 translations and use them around that number, but this is not an elegant solution.
To declare a value that will be interpolated in your translations, simply give it a name surrounded by {{ }}
:
{
"click_count": "You clicked {{ count }} times"
}
Interpolate Components
There may also be situations where you want to wrap part of your translation into a component, for example to highlight it.
You can declare a component with html-like syntax:
{
"highlight_me": "highlight <b>me</b>"
}
Use both
You can mix them both without problem:
{
"click_count": "You clicked <b>{{ count }}</b> times"
}
Names
Just like keys, names of variable/components can be anything as long as it is a valid Rust identifier, apart from -
which will be converted to _
.
Plurals
What are plurals ?
Plurals are a standardized way to deal with numbers, for example the English language deal with 2 plurals: "one" (1) and "other" (0, 2, 3, ..).
If you were to have
{
"items": "{{ count }} items"
}
this would produce "1 items", which is not good English.
This can be solved by defining 2 plural forms:
{
"items_one": "{{ count }} item",
"items_other": "{{ count }} items"
}
Providing the count to the t!
macro, this will result in:
#![allow(unused)] fn main() { 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" }
{{ count }}
is a special variable when using plurals, even if you don't interpolate it you ust supply it:
{
"items_one": "one item",
"items_other": "some items"
}
This will still need you to supply the count
variable: t!(i18n, items, count = ...)
.
Why bother ?
Why bother and not just do
#![allow(unused)] fn main() { if item_count == 1 { t!(i18n, items_one) } else { t!(i18n, items_other, count = move || item_count) } }
Because all languages don't use the same plurals!
For example in French, 0 is considered singular, so this could produce "0 choses" instead of "0 chose", which is bad French (except in certain conditions, because French, exceptions are everywhere).
Ordinal plurals
What I describe above are "Cardinal" plurals, but they don't work with like "1st place", "2nd place", ect..
The English language use 4 ordinal plurals, and French 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 them 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
_ordinal
suffix is removed, in this example you access it witht!(i18n, key, count = ..)
How to know which to use:
There are resources online to help you find what you should 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
that you can read about in a next chapter.
Activate the feature
To use plurals in your translations, enable the "plurals" feature.
Ranges
We just talked about plurals, which are standardized, but we have a little unorthodox features that I called ranges.
They are based around a count and display different translations based on this count.
To declare them the key takes a sequence where each element is a sequence with the first element being the value, and the other element the count to match against:
{
"click_count": [
["You have not clicked yet", 0],
["You clicked once", 1],
["You clicked {{ count }} times", "_"]
]
}
Multiple exact values
You can declare multiple counts to match against:
{
"click_count": [
["0 or 5", 0, 5],
["1, 2, 3 or 4", 1, 2, 3, 4],
["You clicked {{ count }} times", "_"]
]
}
Ranges
You can also declare a range where the translations is used:
{
"click_count": [
["0 or 5", 0, 5],
["1, 2, 3 or 4", "1..=4"],
["You clicked {{ count }} times", "_"]
]
}
You can use all Rust ranges syntax: s..e
, ..e
, s..
, s..=e
, ..=e
or even ..
( ..
will be considered fallback _
)
Number type
By default the count is expected to be an i32
, but you can change that by specifying the type as the first element of the sequence:
{
"click_count": [
"u32",
["You have not clicked yet", 0],
["You clicked once", 1],
["You clicked {{ count }} times", "_"]
]
}
Now you only have to cover the u32
range.
The supported types are i8
, i16
, i32
, i64
, u8
, u16
, u32
, u64
, f32
and f64
.
Fallback
If all the given counts don't fill the range of the number type, you can use a fallback ("_"
or ".."
) as seen above, but it can be completely omitted on the last element of the sequence:
{
"click_count": [
["You have not clicked yet", 0],
["You clicked once", 1],
["You clicked {{ count }} times"]
]
}
Fallbacks are not required if you already cover the full number range:
{
"click_count": [
"u8",
["You have not clicked yet", 0],
["1 to 254", "1..=254"],
["255", 255]
]
}
Fallbacks are always required for f32
and f64
.
Order
The order of the ranges matter, for example:
{
"click_count": [
["first", "0..5"],
["second", "0..=5"],
["You clicked {{ count }} times"]
]
}
Here "second" will only be printed if count is 5, if 0 <= count < 5
then "first" will be printed.
Mix ranges with exact values
You can totally mix them, this is valid:
{
"click_count": [
["first", 0, "3..5", "10..=56"],
["second", "0..3", "..78"],
["You clicked {{ count }} times"]
]
}
Use interpolation
The "You clicked {{ count }} times" kind of gave it away, but you can use interpolation in your ranges, this is valid:
{
"click_count": [
["<b>first</b>", 0, "3..5", "10..=56"],
["<i>second</i>", "0..3", "..78"],
["You clicked {{ count }} times and have {{ banana_count }} bananas"]
]
}
With ranges, {{ count }}
is a special variable that refers to the count provided to the range, so you don't need to also provide it:
{
"click_count": [
["You have not clicked yet", 0],
["You clicked once", 1],
["You clicked {{ count }} times"]
]
}
#![allow(unused)] fn main() { t!(i18n, click_count, count = || 0); }
Will result in "You have not clicked yet"
and
#![allow(unused)] fn main() { t!(i18n, click_count, count = || 5); }
Will result in "You clicked 5 times"
.
Providing count
will create an error:
#![allow(unused)] fn main() { t!(i18n, click_count, count = 12, count = || 5); // compilation error }
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
that you can read about in a future chapter.
Subkeys
You can declare subkeys by just giving 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"
}
}
}
#![allow(unused)] fn main() { 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)
by the value of the key hello_world
, making reuse
equal to "message: Hello World!"
.
You can point to any key other than ranges and keys containing subkeys.
To point to subkeys you give the path by separating the key by .
: $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 to clicked_twice
to have the value "You clicked two times"
.
Arguments must be string, delimited by either single quotes or double quotes.
Note: Any argument with no matching variable are 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)\"})"
}
#![allow(unused)] fn main() { 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/ranges
If you have a plural like
{
"key_one": "one item",
"key_other": "{{ count }} items"
}
You can supply the count as a foreign key in 2 ways, as a variable:
{
"new_key": "$t(key, {\"count\": \"{{ new_count }}\"})"
}
This will just rename the key:
#![allow(unused)] fn main() { t!(i18n, new_key, new_count = move || 1); // -> "one item" t!(i18n, new_key, new_count = move || 2); // -> "2 items" }
note: for the
count
arg to plurals/ranges, the value provided must be single variable (whitespaces around are supported though).
Or by an actual value:
{
"singular_key": "$t(key, {\"count\": 1})",
"multiple_key": "$t(key, {\"count\": 6})"
}
#![allow(unused)] fn main() { t!(i18n, singular_key); // -> "one item" t!(i18n, multiple_key); // -> "6 items" }
note: while floats are supported, they don't carry all the informations once deserialized such as leading 0, so some truncation may occur.
Multiple counts ranges or plurals
If you need multiple counts for a plural or a range, for example:
{
"key": "{{ boys_count }} boys and {{ girls_count }} girls"
}
You can use Foreign keys
to construct a single key from multiple plurals/ranges by overriding there "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"
}
#![allow(unused)] fn main() { 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 ranges 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 mix are subkeys. If a key has subkeys in one locale, the key must have subkeys in all locales.
Formatters
For interpolation, every variables (other than count
for ranges) are expected to be of type impl IntoView + Clone + 'static
.
But some values have different ways to be represented based on the locale:
- Number
- 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 suits the format you need:
{
"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 is all the formatters:
Number
{
"number_formatter": "{{ num, number }}"
}
Will format the number based on the locale.
This make the variable needed to be impl leptos_i18n::formatting::NumberFormatterInputFn
, which is auto 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::FixedDecimal
which is a type used by icu
to format numbers. That trait is currently implemented for:
- FixedDecimal
- usize
- u8
- u16
- u32
- u64
- u128
- isize
- i8
- i16
- i32
- i64
- i128
- f32 *
- f64 *
* Is implemented for convenience, but uses
FixedDecimal::try_from_f64
with the floating precision, you may want to use your own.
The formatter itself does'nt provide formatting options such as maximum significant digits, but those can be customize through FixedDecimal
before being passed to the formatter.
Enable the "format_nums" feature to use the number formatter.
Arguments
There are no arguments for this formatter at the moment.
Example
#![allow(unused)] fn main() { use crate::i18n::*; let i18n = use_i18n(); let num = move || 100_000; t!(i18n, number_formatter, num); }
Date
{
"date_formatter": "{{ date_var, date }}"
}
Will format the date based on the locale.
This make the variable needed to be impl leptos_i18n::formatting::DateFormatterInputFn
, which is auto 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::DateInput
which is a trait used by icu
to format dates. The IntoIcuDate
trait is currently implemented for T: DateInput<Calendar = AnyCalendar>
.
You can use icu::datetime::{Date, DateTime}
, or implement that trait for anything you want.
Enable the "format_datetime" feature to use the date formatter.
Arguments
There is one argument at the moment for the date formatter: date_length
, which is based on icu::datetime::options::length::Date
, that can take 4 values:
- full
- long
- medium (default)
- short
{
"short_date_formatter": "{{ date_var, date(date_length: short) }}"
}
Example
#![allow(unused)] fn main() { use crate::i18n::*; use leptos_i18n::reexports::icu::calendar::Date; let i18n = use_i18n(); let date_var = move || Date::try_new_iso_date(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 make the variable needed to be impl leptos_i18n::formatting::TimeFormatterInputFn
, which is auto 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::TimeInput
which is a trait used by icu
to format time. The IntoIcuTime
trait is currently implemented for T: IsoTimeInput
.
You can use icu::datetime::{Time, DateTime}
, or implement that trait for anything you want.
Enable the "format_datetime" feature to use the time formatter.
Arguments
There is one argument at the moment for the time formatter: time_length
, which is based on icu::datetime::options::length::Time
, that can take 4 values:
- full
- long
- medium
- short (default)
{
"full_time_formatter": "{{ time_var, time(time_length: full) }}"
}
Example
#![allow(unused)] fn main() { use crate::i18n::*; use leptos_i18n::reexports::icu::calendar::Date; 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 make the variable needed to be impl leptos_i18n::formatting::DateTimeFormatterInputFn
, which is auto 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::DateTimeInput
which is a trait used by icu
to format datetimes. The IntoIcuDateTime
trait is currently implemented for T: DateTimeInput<Calendar = AnyCalendar>
.
You can use icu::datetime::DateTime
, or implement that trait for anything you want.
Enable the "format_datetime" feature to use the datetime formatter.
Arguments
There is two arguments at the moment for the datetime formatter: date_length
and time_length
that behave exactly the same at the one above.
{
"short_date_long_time_formatter": "{{ datetime_var, datetime(date_length: short; time_length: full) }}"
}
Example
#![allow(unused)] fn main() { use crate::i18n::*; use leptos_i18n::reexports::icu::calendar::DateTime; let i18n = use_i18n(); let datetime_var = move || { let date = Date::try_new_iso_date(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 make the variable needed to be impl leptos_i18n::formatting::ListFormatterInputFn
, which is auto implemented for impl Fn() -> T + Clone + 'static where T: leptos_i18n::formatting::WriteableList
.
WriteableList
is a trait to turn a value into a impl Iterator<Item = impl writeable::Writeable>
.
Enable the "format_list" feature to use the list formatter.
Arguments
There is 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 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
#![allow(unused)] fn main() { use crate::i18n::*; let i18n = use_i18n(); let list_var = move || ["A", "B", "C"]; t!(i18n, list_formatter, list_var); }
How to use in code
Now that we know how to declare our translations, we can incorporate them in the code, and this is what this chapter cover.
Load The Translations
Loading all those translations is the role of the load_locales!
macro, just call this macro anywhere in your codebase and it will generate the code needed to use your translations.
#![allow(unused)] fn main() { // lib.rs/main.rs leptos_i18n::load_locales!(); }
The i18n
module
The macro will generate a module called i18n
, this module contain everything you need you use your translations.
The Locale
enum
You can find the enum Locale
in this module, it represent all the locales you declared, for example this configuration:
[package.metadata.leptos-i18n]
default = "en"
locales = ["en", "fr"]
Generate this enum:
#![allow(unused)] fn main() { #[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, Default)] #[allow(non_camel_case_types)] pub enum Locale { #[default] en, fr } }
The I18nKeys
struct
This generated struct represent the structure of your translations, with each translation key being a key in this struct.
It contain an associated constant for each locale, where every field is populated with the values for the locale.
en.json
{
"hello_world": "Hello World!"
}
fr.json
{
"hello_world": "Bonjour le Monde!"
}
This will generate this struct:
#![allow(unused)] fn main() { pub struct I18nKeys { pub hello_world: &'static str, } impl I18nKeys { const en: Self = I18nKeys { hello_world: "Hello World!" }; const fr: Self = I18nKeys { hello_world: "Bonjour le Monde!" }; } ```rust leptos_i18n::load_locales!(); assert_eq!(i18n::I18nKeys::en.hello_world, "Hello World!"); assert_eq!(i18n::I18nKeys::fr.hello_world, "Bonjour le Monde!"); }
This way of accessing the values is possible but it's not practical and most importantly not reactive, we will cover the t!
macro later,
which let you access the values based on the context:
#![allow(unused)] fn main() { t!(i18n, hello_world) }
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 load_locales!
macro generates the I18nContextProvider
component in the i18n
module,
you can use this component to make the context accessible to all child components.
#![allow(unused)] fn main() { 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.
#![allow(unused)] fn main() { 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:
#![allow(unused)] fn main() { 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
exist: 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:
#![allow(unused)] fn main() { 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
exist: 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 accept multiple props, all optionnal (except children)
children
: obviouslyset_lang_attr_on_html
: should or not set the "lang" attribute on the root<html>
element (default to true)set_dir_attr_on_html
: should or not 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 page reload (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_cookie
is 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 experimental-islands
feature from Leptos the I18nContextProvider
loose 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:
#![allow(unused)] fn main() { 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 a 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
implement Directive
from leptos to set the "lang" attribute, so you can just do
#![allow(unused)] fn main() { 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 you application to use the translations but be isolated from the "main" locale, this is what sub-context are for.
Why not just use I18nContextProvider
?
I18nContextProvider
does not shadow any context if one already exist,
this is because it should only be one "main" context, or they will battle for the cookie, the "lang" attribute, the routing, ect..
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 makes it so the locale of the sub-context is always the opposite of the "main" one:
#![allow(unused)] fn main() { 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 exist (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 a information point, although is does what you thing.
Shadowing correctly
Shadowing a context is not as easy as it sounds:
#![allow(unused)] fn main() { 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 there docs, the best approach is to use a provider:
#![allow(unused)] fn main() { #[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:
#![allow(unused)] fn main() { 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 has 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 options a cookie,
that function is useless without the cookie
feature.
cookie_name
is an option to a name for a cookie to be set to keep state of the chosen localecookie_options
is an option to some options for a cookie.
The t!
Macro
To access your translations the t!
macro is used, you can access a string with a simple t!(i18n, $key)
:
#![allow(unused)] fn main() { 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:
#![allow(unused)] fn main() { 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 value, you can pass it directly:
#![allow(unused)] fn main() { 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 implement IntoView + Clone + 'static
, you can pass a view if you want:
#![allow(unused)] fn main() { 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:
#![allow(unused)] fn main() { 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" */} <p>{t!(i18n, click_count, count, <b> = |children| view!{ <b>{children}</b> })}</p> } } }
If your variable as the same name as the component, you can pass it directly:
#![allow(unused)] fn main() { 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" */} <p>{t!(i18n, click_count, count, <b>)}</p> } } }
You can pass anything that implement Fn(leptos::ChildrenFn) -> V + Clone + 'static
where V: IntoView
.
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 is wrapped by a single component:
#![allow(unused)] fn main() { // key = "<b>{{ count }}</b>" t!(i18n, key, <b> = <span />, count = 32); }
This will render <span>32</span>
.
You can set attributes, event handlers, props ect:
#![allow(unused)] fn main() { t!(i18n, key, <b> = <span attr:id="my_id" on:click=|_| { /* do stuff */} />, count = 0); }
Basically <name .../>
expand to move |children| view! { <name ...>{children}</name> }
Ranges
Ranges expect a variable count
that implement Fn() -> N + Clone + 'static
where N
is the specified type of the range (default is i32
).
#![allow(unused)] fn main() { t!(i18n, key_to_range, count = count); }
Plurals
Plurals expect a variable count
that implement Fn() -> N + Clone + 'static
where N
implement Into<icu_plurals::PluralsOperands>
(PluralsOperands
). Integers and unsigned primitives implements it, along with FixedDecimal
.
#![allow(unused)] fn main() { t!(i18n, key_to_plurals, count = count); }
Access subkeys
You can access subkeys by simply separating the path with .
:
#![allow(unused)] fn main() { 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:
#![allow(unused)] fn main() { 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:
#![allow(unused)] fn main() { 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 it first argument, it takes the desired locale:
#![allow(unused)] fn main() { 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:
#![allow(unused)] fn main() { 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 has
#![allow(unused)] fn main() { 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 scale better.
The td_string!
Macro
The td_string!
macro is to use interpolations outside the context of rendering views, it let you give a different kind of values and return a &'static str
or a String
depending on the value of the key.
If the value is a plain string or a boolean it returns a &'static str
, if it's an interpolations or a number it returns a String
.
This requires the interpolate_display
feature to be enabled to work with interpolations.
It enable you to do this:
#![allow(unused)] fn main() { // 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 implement Display
.
If the key use ranges it expect the type of the count, if you set the type to f32
, it expect a f32
.
Components expect a value that implement leptos_i18::display::DisplayComponent
, you can find some type made to help the formatting in the display
module,
such as DisplayComp
.
String
and &str
implement this trait such that
#![allow(unused)] fn main() { // 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 let you pass leptos attributes:
#![allow(unused)] fn main() { let attrs = [("id", leptos::Attribute::String("my_id".into()))]; 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:
#![allow(unused)] fn main() { Fn(&mut core::fmt::Formatter, &dyn Fn(&mut core::fmt::Formatter) -> core::fmt::Result) -> core::fmt::Result }
which basically let you do this:
#![allow(unused)] fn main() { use core::fmt::{Formatter, Result}; fn render_b(f: &mut Formatter, child: &dyn Fn(&mut Formatter) -> Result) -> Result { write!(f, "<div id=\"some_id\">")?; child(f)?; // format the children write!(f, "</div>") } // hello_world = "Hello <b>World</b> !" let hw = td_string!(Locale::en, hello_world, <b> = render_b); assert_eq!(hw, "Hello <div id=\"some_id\">World</div> !"); }
If you look closely, there is no Clone
or 'static
bounds for any arguments, but they are captured by the value returned by the macro,
so the returned value as a lifetime bound to the "smallest" lifetime of the arguments.
The td_display!
Macro
Just like the td_string!
macro but return 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
.
#![allow(unused)] fn main() { 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 return 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 impl `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
access a signal reactively.
I18nRoute
The i18n
module generated from the load_locales!()
macro export the I18nRoute
component,
this component act exactly like a leptos_router::Route
and take the same args, except for the path.
What it does is manage a prefix on the URL such that
#![allow(unused)] fn main() { use crate::i18n::I18nRoute; use leptos::prelude::*; use leptos_router::*; view! { <Router> <Routes fallback=||"Page not found"> <I18nRoute view=Outlet> <Route path=path!("") view=Home /> <Route path=path!("counter") view=Counter /> </I18nRoute> </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 provide the I18nContext
if not already provided, and set 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 cookie, 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
keep 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 set 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 inside the i18nRoute
the i18n_path!
to create localized path segments:
#![allow(unused)] fn main() { use leptos_i18n::i18n_path; <I18nRoute view=Outlet> <Route path=i18n_path!(Locale, |locale| td_string(locale, segment_path_name)) view={/* */} /> </I18nRoute> }
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 a i18nRoute
it will default to the default locale.
Caveat
If you have a layout like this:
#![allow(unused)] fn main() { view! { <I18nContextProvider> <Menu /> <Router> <Routes fallback=||"Page not found"> <I18nRoute view=Outlet> <Route path=path!("") view=Home /> </I18nRoute> </Routes> </Router> </I18nContextProvider> } }
And the Menu
component use localization, you could be suprised to see that sometimes there is a mismatch beetween 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 update it.
You have multiple solutions, you can either use the Menu
component inside the i18nRoute
:
#![allow(unused)] fn main() { view! { <I18nContextProvider> <Router> <Routes fallback=||"Page not found"> <I18nRoute view=|| view! { <Menu /> <Outlet /> }> <Route path=path!("") view=Home /> </I18nRoute> </Routes> </Router> </I18nContextProvider> } }
Or you can use the parse_locale_from_path
option on the I18nContextProvider
:
#![allow(unused)] fn main() { view! { <I18nContextProvider parse_locale_from_path=""> <Menu /> <Router> <Routes fallback=||"Page not found"> <I18nRoute view=Outlet> <Route path=path!("") view=Home /> </I18nRoute> </Routes> </Router> </I18nContextProvider> } }
This option force the context to initialize itself with the locale from the URL. It is not enabled by default because the only time it is neededis this particular case.
It expect the base_path argument you would pass to the I18nRoute
.
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 explain how to do it!
The scope_i18n!
macro
Using namespaces and subkeys can make things quite cumbersome very fast, imagine you have this:
#![allow(unused)] fn main() { 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 use namespace.subkeys.*
but we have to repeat it everywhere,
well here comes the scope_i18n!
macro, you can rewrite to:
#![allow(unused)] fn main() { 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:
#![allow(unused)] fn main() { 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
On the above example we do let i18n = use_i18n();
but only access the context to scope it afterward, we could do
#![allow(unused)] fn main() { let i18n = scope_i18n!(use_i18n(), namespace.subkeys); }
Well this is what the use_i18n_scoped!
macro is for:
#![allow(unused)] fn main() { 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:
#![allow(unused)] fn main() { 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:
#![allow(unused)] fn main() { 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:
#![allow(unused)] fn main() { 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 use a typestate pattern, but some of the types are hard to access as a user as they 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 to write 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:
#![allow(unused)] fn main() { let i18n = { leptos_i18n::__private::scope_ctx_util(use_i18n(), |_k| &_k.$keys) }; }
Hummm, 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
tho, 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 result in the same code path. And with how aggressive Rust is with inlining small functions, it probably compile to the exact same thing. So no runtime performance loss! Yeaah!
t_format!
You may want to use the formatting capability without the need to create an entry in you translations, you can use the t_format!
macro for that:
#![allow(unused)] fn main() { 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
#![allow(unused)] fn main() { let date = move || Date::try_new_iso_date(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: full)); assert_eq!(fr, "vendredi 2 janvier 1970"); }
t_plural!
You can use the t_plural!
macro to match on the plural form of a given count:
#![allow(unused)] fn main() { 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 locale "fr" but "other" with locale "en".
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.
More Information
This chapter covers more information that detail some behavior that are expected, such as how the 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 multiple 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-Language
header in SSR - A locale can be matched base on the
navigator.languages
API 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 do not tries to compute a locale when loading, the only locale changes that can happen is by explicitly setting it in the context.
How To Reduce Binary Size
This chapter is about the few options you have to reduce the binary footprint of this library, other than compiler options such as opt-level = "z"
and other things that are common for every builds.
ICU4X Datagen
This library use ICU4X as a backend for formatters and plurals, and the default baked data provider can take quite a lot of space as it contains informations for every possible locale. So if you use only a few this is a complete waste.
Disable compiled data
The first step to remove those excess informations is to disable the default data provider, it is activated by the "icu_compiled_data"
feature that 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 informations we have 0 informations. You will now need to bring your own data provider. For that you will need multiple things.
1. Datagen
First generate the informations, you can use icu_datagen
for that, either as a CLI of 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
#![allow(unused)] fn main() { include!(concat!(env!("OUT_DIR"), "/baked_data/mod.rs")); pub struct MyDataProvider; impl_data_provider!(MyDataProvider); }
This is explained 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 impl IcuDataProvider
for you provider, you can do it manually as it is straight forward, but the lib comes with a derive macro:
#![allow(unused)] fn main() { 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 start,
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:
#![allow(unused)] fn main() { #[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 straight forward. Your build.rs can look like this:
use icu_datagen::baked_exporter::*; use icu_datagen::prelude::*; 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, Default::default()).unwrap(); DatagenDriver::new() // Keys needed for plurals .with_keys(icu_datagen::keys(&[ "plurals/cardinal@1", "plurals/ordinal@1", ])) // Used locales, no fallback needed .with_locales_no_fallback([langid!("en"), langid!("fr")], Default::default()) .export(&DatagenProvider::new_latest_tested(), exporter) .unwrap(); }
Here we are generating the informations 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 above build.rs
is that it can go out of sync with your translations,
when all informations are already in the translations.
# Cargo.toml
[build-dependencies]
leptos_i18n_build = "0.5.0-gamma2"
use leptos_i18n_build::TranslationsInfos; 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 translations_infos = TranslationsInfos::parse().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 informations gained when parsing the translations. This will trigger a rerun if the config or translations changed and be kept in sync. If your code use plurals, it will build with informations for plurals. If it use a formatter it will build with the informations for that formatter.
If you use more data someway, like for example using t*_format!
with a formatter not used in the translations, there is functions to either supply additionnal options or keys:
#![allow(unused)] fn main() { use leptos_i18n_build::Options; translations_infos.generate_data_with_options(mod_directory, [Options::FormatDateTime]).unwrap(); }
This will inject the ICU DataKey
s needed for the date
, time
and datetime
formatters.
#![allow(unused)] fn main() { use leptos_i18n_build::Options; translations_infos.generate_data_with_data_keys( mod_directory, icu_datagen::keys(&["plurals/cardinal@1", "plurals/ordinal@1"]) ).unwrap(); }
This will inject the keys for cardinal and ordinal plurals.
If you need both, Options
can be turned into the need keys:
#![allow(unused)] fn main() { use leptos_i18n_build::Options; let mut keys = icu_datagen::keys(&["plurals/cardinal@1", "plurals/ordinal@1"]) let keys.extend(Options::FormatDateTime.into_data_keys()) // keys now contains the `DataKey`s needed for plurals and for the `time`, `date` and `datetime` formatters. translations_infos.generate_data_with_data_keys(mod_directory, keys).unwrap(); }
Is it worth the trouble ?
YES. With opt-level = "z"
and lto = true
, the plurals example is at 394ko (at the time of writing), now by just providing a custom provider tailored to the used locales ("en" and "fr"), it shrinks down to 248ko! It almost cutted in half the binary size!
I highly suggest to take the time to implement this.
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 are small and the values are not long, but when supporting a high number of locales and with a lot of keys, binary sizes start to highly increase.
The "dynamic_load"
feature reduce this binary size increase by removing the baked translations in the binary, and lazy load 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, one unit per locale per namespaces if you use them.
How it works
When using SSR, the server will register every units used for a given request and bake only the used one in the sent HTML, they are then parsed when the client hydrate, so no request for translations is done on page load. When the client need access to an unloaded unit, it will request it to the server and will update the view when received.
What changes ?
Async accessors
For obvious reason, 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::new_unsync
)
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 bloc.
Server Fn
If you use a backend that need to manually register server functions,
you can use the ServerFn
associated type on the Locale
trait implemented by the generated Locale
enum:
#![allow(unused)] fn main() { use i18n::Locale; use leptos_i18n::Locale as LocaleTrait; register_server_fn::<<Locale as LocaleTrait>::ServerFn>(); }
Final note
Other than that, this is mostly a drop in feature and do not require much from the user.
Disclaimers
-
There is a chance that enabling this feature actually increase binary sizes if there isn't much 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 the trade will be beneficial. So do some testing.
-
Only the raw strings are removed from the binary, the code to display each keys 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 actix backend
ssr
This feature must be enabled when building the server. It is auto 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.
experimental-islands
This feature is, as it's name says, experimental.
This make this lib somewhat usable when using islands
with Leptos.
serde
This feature implement Serialize
and Deserialize
for the Locale
enum
interpolate_display
This feature generate extra code for each interpolation to allow rendering them as a string instead of a View
.
show_keys_only
This feature makes every translations to only display it's corresponding key, this is useful to track untranslated strings in you application.
suppress_key_warnings
This features disable the warnings when a key is missing or in surplus, we discourage its usage and highly encourage the use of explicit defaults, but if its what's you want, we won't stop you.
json_files
(Default)
To enable when you use JSON files for your locales
yaml_files
To enable when you use YAML files for your locales
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.
track_locale_files
Allow tracking of locale files as dependencies for rebuilds in stable.
The load_locales!()
macro using external dependencies the build system is not aware that the macro should be rerun when those files changes,
you may have noticed that if you use cargo-leptos
with watch-additional-files = ["locales"]
and running cargo leptos watch
, even if the file changes and cargo-leptos triggers a rebuild nothing changes.
This feature use a "trick" by using include_bytes!()
to declare the use of a file, but I'm a bit sceptical of the impact on build time using this.
I've already checked and it does not include the bytes in the final binary, even in debug, but it may slow down compilation time.
If you use the nightly
feature it use the path tracking API so no trick using include_bytes!
and the possible slowdown in compile times coming with it.
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 locales. This is great when starting up a project without knowing exactly what you need, this is why it is enabled by default, so things works right out of the box. But those baked data can take quite a lot of space in the final binary as it brings informations for all possible locales, so if you want to reduce this footprint you can disable this feature and provide you own data with selected informations. See the datagen section in the reduce binary size chapter for more informations.
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.
The i18n Ally
VS Code extension
The i18n Ally
extension is an extension
that have a bunch of features for managing, structuring and even automate translations, with the most notable one being an overlay over translations 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 reason this lib is not supported by i18n Ally
(one day maybe ?), but the awesome people working on that extension
gave us 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 are for, in order, t!
, td!
, td_string!
and td_display!
. If you don't use all translations macro you can remove/comment out the regex for that macro. Those regex are not perfect, and I'm no expert so there maybe is some better/faster ones, and if you encounter a problem with them feel free to open an issue/discussion on github about it.
monopoly
is to disable all supported frameworks, if you use any other frameworks supported by the extension in your project set it to false
.
Common settings
There is multiple settings for the extension that you can set in .vscode/settings.json
, those are all optional, here is non exhaustive list with their (default):
-
i18n-ally.keystyle
(auto): this one can beflat
("a.b.c": "..."
) ornested
("a": { "b": { "c": "..." } }
), this is irrelevant to you if you don't use subkeys, but if you do, set it to"nested"
as this is the style that this lib support. -
i18n-ally.localesPaths
(auto): this is the path to your locales, it can be a path or a list of path, by default set it to"locales"
, but if you either have a custom locales path or use a cargo workspaces you will have to supply the path here. -
i18n-ally.namespace
(false): this is is if you use namespaces, set it totrue
then. If you use namespaces withi18n Ally
I have not figured out (maybe you will ?) how to make thenamespace::key
syntax work for the macros, so just usenamespace.key
. -
i18n-ally.sourceLanguage
(en): The primary key of the project, so I suggest putting the default locale for the value. -
i18n-ally.displayLanguage
(en): The locale that the overlay use.
You can find other settings that could interest you in the official doc, with more information about the settings mentioned above, with their default value.
Other features
This extension offer some other interesting features that could interest you, I would suggest you to take a look at their wiki for more information.