I18n

The I18n capability is the shape bext uses to render localized strings. An I18nPlugin takes a key, a locale, and a bag of variables, and returns the translated text. Swap the plugin and the same i18n.t("hello", { name: "Ada" }) call reads from a JSON file, a Mozilla Fluent bundle, or an ICU MessageFormat catalog — your code does not change.

This page covers the trait shape, the typed I18nValue enum that lets plural selection work correctly across backends, how BCP 47 locale negotiation is handled as a default method, and what the two reference plugins — @bext/i18n-static and @bext/i18n-fluent — do behind the trait boundary.

The trait

The full shape lives in bext-plugin-api::i18n:

pub trait I18nPlugin: Send + Sync {
    fn name(&self) -> &str;

    fn translate(
        &self,
        key: &str,
        locale: &str,
        vars: &TransVars,
    ) -> Result<String, I18nError>;

    fn supported_locales(&self) -> Vec;
    fn fallback_locale(&self) -> String;

    fn has_locale(&self, locale: &str) -> bool { /* default */ }
    fn negotiate(&self, requested: &str) -> String { /* default */ }
}

Four required methods, two defaults. translate is the workhorse; supported_locales and fallback_locale answer config-time questions; has_locale and negotiate are the defaults that give every backend a working BCP 47 lookup for free.

TransVars is a HashMap<String, I18nValue> where I18nValue is a tagged enum over the four variable types every serious i18n library understands:

pub enum I18nValue {
    String(String),
    I64(i64),
    F64(f64),
    Bool(bool),
}

No dates, no nested objects, no custom types. A date in a translation is almost always a pre-formatted String or a Unix timestamp as I64, and a tagged union of four primitives is trivially portable across FFI.

Missing keys never error

The trait contract is deliberately asymmetric: translate returns Result<String, I18nError>, but the Ok path is a best-effort rendering. If the requested key is unknown, the backend returns Ok("key.that.was.missing") — the key itself, verbatim. If a {name} placeholder has no matching variable, the backend leaves {name} in the output. Neither case errors out, because a missing translation must never take down a UI.

Err(I18nError) is reserved for four situations every backend can hit but none of them is "the designer forgot a key":

- LocaleUnavailable — the requested locale was promised by supported_locales() but failed to load (corrupted file, parse error, missing bundle after a reload).

- FormatError — the locale loaded fine but the specific pattern failed (ICU placeholder did not match the variable bag, a Fluent function call errored).

- Unavailable — the backend is transiently unable to serve anything right now (reload in progress, cache eviction).

- Unknown — a classification the backend could not assign, to be treated like Unavailable.

The upshot is that caller code almost never branches on Err — it only surfaces when something genuinely needs operator attention. The common render path is a one-liner.

Why owned String, not Cow<'_, str>

The plan sketch in 02-capabilities.md uses Cow<'_, str> so that a static JSON backend could hand back a borrowed slice of an interned template when no interpolation happened. That borrow would have to live as long as the backend, which forces a lifetime onto the trait — which in turn rules out dispatching through dyn I18nPlugin, because trait-object methods cannot introduce new lifetime parameters cleanly.

Owned String costs one allocation per call and trades that for a trait that works through FFI, WASM, QuickJS, and async bridging with no special cases. For a capability called on the order of once per rendered string on a server handling thousands of requests per second, the allocation is invisible in profiles.

BCP 47 negotiation as a default method

fallback_locale() returns a single tag — not a chain — because the walk from a specific requested tag to a broader supported one is bog-standard RFC 4647 §3.4 Lookup, and every backend should get the same answer for the same inputs. So the trait provides it as a default:

fn negotiate(&self, requested: &str) -> String {
    let mut candidate = requested.to_owned();
    loop {
        if self.has_locale(&candidate) {
            return candidate;
        }
        match candidate.rfind('-') {
            Some(idx) => candidate.truncate(idx),
            None => return self.fallback_locale(),
        }
    }
}

Ask for fr-CA, get fr-CA if supported, else fr, else the fallback. The Fluent plugin overrides negotiate with a richer matcher that considers script subtags; the static plugin inherits the default. Either way, callers hand in whatever Accept-Language or session locale they have and get back a tag the plugin can actually serve.

The two reference plugins

@bext/i18n-static

crates/bext-impls/bext-i18n-static is the dev and small-project backend. It loads a directory of JSON files — one per locale — and does {name}-style interpolation:

{
  "hello": "Hello, {name}!",
  "items": "{count} items"
}

The only dependency beyond the trait crate is serde_json. load_dir("en", "./locales") reads en.json, fr.json, fr-CA.json, and so on — the filename minus the extension becomes the locale tag. Missing keys return the key itself. Missing {vars} leave the placeholder in place. The interpolator walks the template once, does not allocate unless it sees a placeholder, and treats a malformed { with no closing brace as a literal character.

@bext/i18n-fluent

crates/bext-impls/bext-i18n-fluent is the production backend for anything that cares about plurals and gender. It wraps the fluent crate, which is the same implementation Firefox uses, so any valid .ftl file works out of the box:

hello = Hello, { $name }!
items =
    { $count ->
        [one] 1 item
       *[other] { $count } items
    }

The plugin owns a HashMap<String, FluentBundle> — one bundle per locale — and translate looks up the message, builds a FluentArgs from the TransVars bag, and formats. CLDR plural selection, gender selectors, nested messages, per-locale grammar all work because the fluent crate handles them. This crate is the only place in the workspace that imports from fluent::*; every other caller sees I18nPlugin.

Fluent parse errors on add_bundle become LocaleUnavailable. Format errors at runtime (unknown variable type, selector arm mismatch) become FormatError.

Config

Picking a backend is one line in bext.config.toml:

[i18n]
provider = "static"
locales_dir = "./locales"
fallback_locale = "en"

[i18n.resource_attributes]
reload_on_change = true

Swap provider = "static" for provider = "fluent" and point bundles_dir at your .ftl files — the trait stays the same, the code that calls i18n.t(...) stays the same, the plural selection gets better.

Emitting a translation from a plugin

Plugins reach the active i18n backend through the host-function table (host.i18n()), which returns a &dyn I18nPlugin. A plugin that wants to render a greeting for the current request's locale looks like this:

use bext_plugin_api::i18n::{I18nValue, TransVars};

pub fn greet(host: &Host, name: &str) -> Result<String, String> {
    let i18n = host.i18n();
    let locale = i18n.negotiate(&host.request_locale());
    let mut vars = TransVars::new();
    vars.insert("name".into(), I18nValue::from(name));
    i18n.translate("hello", &locale, &vars)
        .map_err(|e| e.to_string())
}

negotiate walks the request's locale down to something the backend can serve. translate returns a best-effort string; the .map_err is defensive and only fires on real backend trouble.

Where to go next

- The capabilities overview covers the promotion ladder I18n will travel once it lands as first-party.

- Building a plugin walks through declaring requires_capabilities = ["i18n"] in the manifest and reaching the active backend from your plugin code.

- Configuration reference lists every [i18n] key the runtime understands.