Translations (i18n)
One set of translation files for both halves of the app — Laravel's __() and
trans_choice() in Rust, $t / $tc in Svelte.
Files
One JSON file per locale, nested or flat:
// lang/en.json
{
"welcome": "Hello, :name!",
"files": "{0} No files|{1} One file|[2,*] :count files",
"nav": { "home": "Home", "settings": "Settings" }
}
// lang/nb.json
{
"welcome": "Hei, :name!",
"files": "{0} Ingen filer|{1} Én fil|[2,*] :count filer",
"nav": { "home": "Hjem", "settings": "Innstillinger" }
}
Embed them so they ship inside the binary:
#[derive(rust_embed::RustEmbed)]
#[folder = "lang/"]
struct Lang;
App::new().provider(I18nProvider::embedded::<Lang>().fallback("en"))
(I18nProvider::from_dir("lang") reads them from disk instead — handy while
editing them.)
In Rust
let t = ctx.get::<Translator>();
t.get("welcome", &[("name", "Ada")]); // "Hei, Ada!"
t.get("nav.home", &[]); // nested keys use dots
t.choice("files", 3, &[]); // "3 filer"
t.set_locale("nb");
In Svelte
<script>
import { t, tc, locale, setLocale } from "@elyra/runtime";
</script>
<h1>{$t("welcome", { name })}</h1>
<p>{$tc("files", count)}</p>
<button on:click={() => setLocale("nb")}>Norsk ({$locale})</button>
$t and $tc are stores: markup re-renders when the catalog loads and when the
locale changes — from setLocale, from Rust (Translator::set_locale), or in
another window. Outside a component, translate(key, params) and
choice(key, count, params) do the same once loadTranslations() has resolved.
Which locale
At startup: an explicit I18nProvider::locale("nb"), else the locale the user
last chose (set_locale saves it in the Store), else the OS
language (read properly on macOS and Windows — $LANG is unset for an app
launched from the Dock), else the fallback.
A lookup tries the exact locale, then its language, then the fallback:
nb-NO → nb → en. A key found nowhere returns the key itself, so a
missing translation shows up instead of rendering blank.
Placeholders
:name is replaced by the value; :Name capitalises it and :NAME upper-cases
it. Longer names are filled first, so :name never eats the start of
:namespace.
Plurals
Segments separated by |. A segment may start with an exact count {0} or a
range [1,19] / [2,*], and the first matching one wins. Otherwise the
locale's plural rule chooses among the segments — the same table Laravel uses:
two forms for English, Norwegian and most European languages, 0 counting as
singular in French, one form in Japanese or Chinese, three in Russian or Polish.
"apples": "apple|apples", // rule-picked
"files": "{0} No files|{1} One file|[2,*] :count files" // conditions
Mixing the two needs care, exactly as in Laravel: once no condition matches,
the rule counts every segment, so "{0} none|file|files" picks none for 1.
Give each segment its condition, or none.
The rules exist on both sides (Rust and the runtime) so $tc stays synchronous;
tests pin them to the same cases.
Testing
Swap in a hand-built translator and an in-memory store, so a test neither reads the OS language nor writes the real settings file:
let t = Translator::new("en").add_json("en", r#"{ "welcome": "Hello, :name!" }"#);
TestApp::new(
App::new()
.provider(I18nProvider::with_translator(t).locale("en"))
.swap(Store::fake()),
)