Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Internationalization

The Tee system supports multiple languages. This is implemented in the interface layer and uses Fluent files stored in the repository.

Key files

  • docs/SPECS/SPEC-04-I18N.md
  • src/interface/i18n.rs
  • locales/<locale>/main.ftl

How locale is chosen

The locale is resolved in this order:

  1. User preference (if available).
  2. locale cookie.
  3. Accept-Language header.
  4. Default locale (usually en).

This logic is implemented in src/interface/i18n.rs.

Translator and templates

The Translator loads Fluent bundles at startup and provides a text method. Handlers call the translator and pass localized strings into templates. Templates do not perform translation lookups.

This keeps templates simple and avoids I/O during rendering.

If a key is missing, the translator falls back to the default locale and can return the key name as a last resort. This avoids crashing a page because of a missing translation.

Code example: loading bundles

From src/interface/i18n.rs:

#![allow(unused)]
fn main() {
pub fn load_from_disk<P: AsRef<Path>>(
    root: P,
    default_locale: &str,
    required_locales: &[&str],
) -> Result<Self, anyhow::Error> {
    let root = root.as_ref();
    let mut bundles = HashMap::new();

    for entry in fs::read_dir(root).context("reading locales directory")? {
        let entry = entry?;
        if !entry.file_type()?.is_dir() {
            continue;
        }
        let locale_code = entry.file_name().to_string_lossy().to_string();
        let Some(langid) = parse_locale(&locale_code) else {
            warn!(code = %locale_code, "skipping invalid locale directory name");
            continue;
        };
        let path = entry.path().join("main.ftl");
        if !path.exists() {
            continue;
        }
        let bundle = load_bundle(&langid, &path)?;
        bundles.insert(langid, bundle);
    }
}

Code example: passing localized strings to templates

From src/interface/routes_web.rs:

#![allow(unused)]
fn main() {
fn build_layout_texts(translator: &Translator, locale: &Locale) -> LayoutTexts {
    LayoutTexts {
        brand: translator.text(&locale.0, "layout-brand", None),
        nav_tasks: translator.text(&locale.0, "layout-nav-tasks", None),
        theme_label: translator.text(&locale.0, "layout-theme-label", None),
        theme_light: translator.text(&locale.0, "layout-theme-light", None),
        theme_dark: translator.text(&locale.0, "layout-theme-dark", None),
        theme_system: translator.text(&locale.0, "layout-theme-system", None),
        locale_label: translator.text(&locale.0, "layout-locale-label", None),
    }
}
}

Date and time formatting

The interface layer formats dates with helper functions like format_date and format_datetime. These functions apply locale-aware formatting before values reach the template.

Adding a new locale

Steps:

  1. Create a new folder under locales/<lang>.
  2. Add a main.ftl file with the required keys.
  3. Restart the service to reload bundles.

Exercise

  • Add a new translation key to all locale files.
  • Remove the key from one locale and observe the fallback behavior.

Next: Theming and UI Assets.