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 chapter
  • locales-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"]
}
FormatFeature
JSON (default)json_files
YAMLyaml_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 with t!(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.

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: obviously
  • set_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 if enable_cookie is false)
  • cookie_options: options for the cookie, the value is of type leptos_use::UseCookieOptions<Locale> (default to Default::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 locale
  • cookie_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.

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:

  1. A locale prefix is present in the URL pathname when using I18nRoute (e.g. /en/about)
  2. A cookie is present that contains a previously detected locale
  3. A locale can be matched based on the Accept-Language header in SSR
  4. A locale can be matched base on the navigator.languages API in CSR
  5. 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 DataKeys 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

  1. 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.

  2. 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

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 be flat ("a.b.c": "...") or nested ("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 to true then. If you use namespaces with i18n Ally I have not figured out (maybe you will ?) how to make the namespace::key syntax work for the macros, so just use namespace.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.