Cookbook

Recipes for dxn_i18n - embedding translations, choosing the user's locale from a platform tag, checking translations in a test, sharing a translator between threads, and mapping names to translated labels.

Short, complete answers to common tasks. Each snippet is a function you can paste next to your app code.

#Ship translations inside the binary

include_str! puts the JSON in the executable, which works on every target including WebAssembly, where there is no file system. Keep one file per language in a locales/ directory beside src/.

use dxn_i18n::{Bundle, Translator};
use dxn_intl::parse_locale;

fn build_translator(user_tag: &str) -> Result<Translator, Box<dyn std::error::Error>> {
    let mut translator = Translator::new(parse_locale("en")?);
    for (tag, json) in [
        ("en", include_str!("../locales/en.json")),
        ("hr", include_str!("../locales/hr.json")),
    ] {
        let mut bundle = Bundle::new(parse_locale(tag)?);
        bundle.add_json(json)?;
        translator.add_bundle(bundle);
    }
    translator.set_locale(parse_locale(user_tag)?);
    Ok(translator)
}

To load from disk or the network instead, read the text yourself and call add_json with it. To add a language, drop in a file and one line in the list; a user whose locale has no bundle sees the default language.

#Take the user’s language from the platform

The library reads no system settings. Ask the platform for the user’s language tag (a BCP 47 tag, or a POSIX one like pt_BR.UTF-8) and fall back to the default when it is missing or unreadable:

use dxn_i18n::Translator;
use dxn_intl::{Locale, parse_locale};

/// `platform_tag` is what the OS or browser reported, if anything.
fn follow_the_system(translator: &mut Translator, platform_tag: Option<&str>) {
    let locale = platform_tag
        .and_then(|tag| parse_locale(tag).ok())
        .unwrap_or(Locale::UNKNOWN);
    translator.set_locale(locale);
}

Locale::UNKNOWN finds no bundle, so the lookup goes straight to the fallbacks and the default. Preferences such as the 24-hour clock arrive in the same tag (en-US-u-hc-h23); The Translator lists the extensions.

#Check translations in a test

The parser catches broken syntax when a resource loads, so loading every resource with ? in a test already fails on a typo. Two more checks are cheap and catch what reviewers miss: ids that a language lacks, and variables a translation uses that the default language does not provide (these show as {$name} on screen).

use std::collections::BTreeSet;
use dxn_i18n::syntax::{Body, Declaration, Expression, Operand, PatternPart};
use dxn_i18n::{Bundle, Message};
use dxn_intl::parse_locale;

fn expression_variables(expression: &Expression, out: &mut BTreeSet<String>) {
    if let Some(Operand::Variable(name)) = &expression.operand {
        out.insert(name.clone());
    }
    for option in expression.function.iter().flat_map(|function| &function.options) {
        if let Operand::Variable(name) = &option.value {
            out.insert(name.clone());
        }
    }
}

/// The arguments a message needs: variables it reads that no `.local` declares.
fn needed_arguments(message: &Message) -> BTreeSet<String> {
    let syntax = message.syntax();
    let mut used = BTreeSet::new();
    let mut locals = BTreeSet::new();
    for declaration in &syntax.declarations {
        expression_variables(declaration.expression(), &mut used);
        if let Declaration::Local { name, .. } = declaration {
            locals.insert(name.clone());
        }
    }
    let mut patterns = Vec::new();
    match &syntax.body {
        Body::Pattern(pattern) => patterns.push(pattern),
        Body::Matcher(matcher) => {
            used.extend(matcher.selectors.iter().cloned());
            patterns.extend(matcher.variants.iter().map(|variant| &variant.pattern));
        }
    }
    for part in patterns.into_iter().flatten() {
        match part {
            PatternPart::Expression(expression) => expression_variables(expression, &mut used),
            PatternPart::Markup(markup) => {
                for option in &markup.options {
                    if let Operand::Variable(name) = &option.value {
                        used.insert(name.clone());
                    }
                }
            }
            PatternPart::Text(_) => {}
        }
    }
    used.difference(&locals).cloned().collect()
}

/// Problems with `translated` compared to the `default` language's bundle.
fn compare(default: &Bundle, translated: &Bundle) -> Vec<String> {
    let mut problems = Vec::new();
    let mut ids: Vec<&str> = default.ids().collect();
    ids.sort();
    for id in ids {
        let (Some(original), Some(message)) = (default.get(id), translated.get(id)) else {
            problems.push(format!("{id}: not translated"));
            continue;
        };
        let known = needed_arguments(original);
        for name in needed_arguments(message).difference(&known) {
            problems.push(format!("{id}: uses ${name}, which the default does not pass"));
        }
    }
    problems
}

let mut en = Bundle::new(parse_locale("en")?);
en.add_json(r#"{ "greeting": "Hello, {$name}!", "bye": "Goodbye", "items": ".input {$n :number} .match $n one {{{$n} item}} * {{{$n} items}}" }"#)?;
let mut hr = Bundle::new(parse_locale("hr")?);
hr.add_json(r#"{ "greeting": "Bok, {$nam}!", "items": ".input {$n :number} .match $n one {{{$n} artikl}} few {{{$n} artikla}} * {{{$n} artikala}}" }"#)?;

assert_eq!(
    compare(&en, &hr),
    ["bye: not translated", "greeting: uses $nam, which the default does not pass"]
);

In a real test, run compare for each language and assert!(problems.is_empty(), "{problems:#?}"). Add a test that calls try_format on each default-language message with sample arguments to catch bad options (Errors and fallbacks).

#Share one translator between threads

Translator::format takes &self and the type is Send + Sync, so readers need no lock among themselves. Wrap it in an RwLock when something changes the language at runtime:

use std::sync::{Arc, RwLock};
use std::thread;
use dxn_i18n::{Args, Bundle, Translator};
use dxn_intl::parse_locale;

let mut bundle = Bundle::new(parse_locale("en")?);
bundle.add("hello", "Hello, {$name}!")?;
let mut translator = Translator::new(parse_locale("en")?);
translator.add_bundle(bundle);

let shared = Arc::new(RwLock::new(translator));

let reader = Arc::clone(&shared);
let worker = thread::spawn(move || {
    let translator = reader.read().expect("lock not poisoned");
    translator.format("hello", &Args::new().with("name", "worker"))
});
assert_eq!(worker.join().expect("worker finished"), "Hello, worker!");

shared.write().expect("lock not poisoned").set_locale(parse_locale("de")?);

#Turn a code or name into a translated label

Messages are looked up by id, so keep ids stable and derive them from your enum:

use dxn_i18n::{Args, Bundle, Translator};
use dxn_intl::parse_locale;

enum Status {
    Draft,
    Published,
}

impl Status {
    fn message_id(&self) -> &'static str {
        match self {
            Status::Draft => "status.draft",
            Status::Published => "status.published",
        }
    }
}

let mut en = Bundle::new(parse_locale("en")?);
en.add_json(r#"{ "status": { "draft": "Draft", "published": "Published" } }"#)?;
let mut translator = Translator::new(parse_locale("en")?);
translator.add_bundle(en);

assert_eq!(translator.format(Status::Published.message_id(), &Args::new()), "Published");
assert_eq!(translator.format(Status::Draft.message_id(), &Args::new()), "Draft");

A new variant without a message shows its id (status.archived), which is visible in development. For text that changes by value rather than by label, use a matcher on a fixed string.

#Other tasks

TaskPage
Bold text and links in a sentenceMarkup and parts
Show a Unix timestamp in the user’s zoneDates and times
“Ana and 4 others”Selecting variants
Prices that never touch a floatNumbers and money
A format MF2 does not haveCustom functions
Right-to-left textText direction