Skip to main content

lumen_runtime/run/
i18n.rs

1//! Locale wiring: locale resolution, catalogue loading, the app's base
2//! writing direction, and the script-side translator and formatter hooks.
3//!
4//! An app's translations live in `<app_dir>/locale/<lang>.ftl`, one Fluent
5//! catalogue per locale, which `lumenc i18n extract` writes and translators
6//! edit. At startup every catalogue is loaded and the locale the app
7//! starts in comes from `[app] locale`, else the locale the OS reports,
8//! else `en-US`. A script changes it later with `set_locale`, and every
9//! catalogue is already in memory, so the switch needs no file read. A key
10//! the active locale lacks falls through to the locale the
11//! app's source strings are written in, which is `[app] fallback_locale`,
12//! else `en-US`; naming the active locale there leaves the miss resolving
13//! to the text the author wrote.
14//!
15//! Two readers share one registry: markup (`translatable="key"`, resolved
16//! in `crate::spawn`) reads [`lumen_core::i18n::AppI18n`] off the world,
17//! and scripts (`t("key")`) reach the same catalogue through the
18//! process-wide [`lumen_core::i18n`] hook installed here. Both see a
19//! catalogue reload the moment it lands.
20//!
21//! Locale-aware formatting is split the same way and over the same pair of
22//! seams: markup (`format="currency:EUR"`) reads the world resource and
23//! scripts (`format_currency(...)`) reach the app's ICU4X formatters
24//! through the formatting hook installed here. Reading the per-app
25//! resource for markup is what keeps a process hosting two Lumen apps from
26//! rendering one app's text in the other's locale.
27//!
28//! The resolved locale also sets the app's base writing direction, through
29//! [`lumen_core::components::DefaultLayoutDirection`]: an app running in
30//! Arabic or Hebrew is mirrored with nothing in its markup saying so, which
31//! is what the web target already does from the `<html>` tag. A `dir`
32//! attribute overrides it for the element it is on and everything under it.
33//! Switching locale moves the direction with the catalogue, so picking
34//! Arabic from a language menu mirrors the tree that is already on screen.
35
36use super::*;
37
38use lumen_core::i18n::AppI18n;
39use lumen_i18n::{I18nPlugin, SharedI18n};
40
41/// Directory holding an app's `.ftl` catalogues.
42pub fn locale_dir(app_dir: &Path) -> PathBuf {
43    app_dir.join("locale")
44}
45
46/// The byte-read seam catalogue loads go through: the app's asset source
47/// chain when the asset server is up (so a source overlaying a catalogue
48/// path wins, like every other asset read), else the plain filesystem.
49fn catalogue_reader(world: &World) -> impl Fn(&Path) -> std::io::Result<Vec<u8>> + use<> {
50    let reader = world
51        .get_resource::<lumen_assets::AssetServer>()
52        .map(|s| s.source_reader());
53    move |path: &Path| match &reader {
54        Some(reader) => reader.read(path),
55        None => std::fs::read(path),
56    }
57}
58
59/// Resolve the locale the app starts in, install [`SharedI18n`] +
60/// [`AppI18n`], set the base writing direction from the locale, load every
61/// catalogue under `<dir>/locale`, and publish the translator every script
62/// host's `t()` builtin calls, the formatter its `format_*` builtins call,
63/// and the tag its `locale()` builtin reads.
64pub(crate) fn register_i18n(
65    app: &mut App,
66    dir: &Path,
67    cfg: &crate::config::LumenToml,
68) -> Result<(), RunError> {
69    let mut plugin = I18nPlugin::default();
70    if let Some(locale) = &cfg.app.locale {
71        plugin = plugin.with_locale(locale.clone());
72    }
73    if let Some(fallback) = &cfg.app.fallback_locale {
74        plugin = plugin.with_fallback_locale(fallback.clone());
75    }
76    let current = plugin.install(&mut app.world);
77    // The locale carries the base writing direction, the same way the web
78    // target reads it off the tag: an app running in Arabic is mirrored
79    // without the author asking. A `dir` in the markup still wins, on the
80    // root and on any subtree, through the existing cascade.
81    let direction = if lumen_i18n::is_rtl(&current) {
82        LayoutDirection::Rtl
83    } else {
84        LayoutDirection::Ltr
85    };
86    app.world.insert_resource(DefaultLayoutDirection(direction));
87    let shared = app.world.resource::<SharedI18n>().clone();
88    let fallback = shared
89        .read()
90        .fallback_chain
91        .iter()
92        .map(ToString::to_string)
93        .collect::<Vec<_>>()
94        .join(",");
95    let loaded = shared
96        .write()
97        .load_dir(&locale_dir(dir), catalogue_reader(&app.world))
98        .map_err(|e| RunError::I18n(e.to_string()))?;
99    tracing::debug!(locale = %current, fallback = %fallback, catalogues = loaded.len(), "i18n ready");
100
101    let for_scripts = app.world.resource::<AppI18n>().clone();
102    let formatting = for_scripts.clone();
103    lumen_core::i18n::set_translator(move |key| for_scripts.try_translate(key));
104    lumen_core::i18n::set_formatter(move |spec, value| formatting.format(spec, value));
105    // What a script's `locale()` answers before anything switches. The
106    // starting locale can come from the OS, so no script knows it otherwise,
107    // and a language menu needs it to mark the entry it is already on.
108    lumen_core::i18n::set_active_locale(&current.to_string());
109    Ok(())
110}
111
112/// Load the catalogues a compiled app carries for every locale that has no
113/// loose file under `locale/`.
114///
115/// A loose file wins: [`register_i18n`] has already loaded every one, and a
116/// locale it loaded is passed over here. That keeps the loose-files rule for
117/// an app's data, so a translator edits the file beside an app and the app
118/// reads the edit, while an app shipped with no `locale/` directory still
119/// reads in every language it was built with. The same goes for the fallback
120/// chain: `[app] fallback_locale` in `lumen.toml` wins over the one the
121/// artifact recorded.
122pub(crate) fn add_compiled_catalogues(
123    world: &mut World,
124    cfg: &crate::config::LumenToml,
125    compiled: &lumen_ir::artifact::CompiledI18n,
126) -> Result<(), RunError> {
127    if compiled.catalogues.is_empty() && compiled.fallback.is_empty() {
128        return Ok(());
129    }
130    let Some(shared) = world.get_resource::<SharedI18n>().cloned() else {
131        return Ok(());
132    };
133    let mut i18n = shared.write();
134    for (tag, source) in &compiled.catalogues {
135        let lang: lumen_i18n::LanguageIdentifier = lumen_i18n::Lang::try_from(tag.as_str())
136            .map_err(|e| RunError::I18n(e.to_string()))?
137            .into();
138        if i18n.bundles.contains_key(&lang) {
139            continue;
140        }
141        i18n.load_ftl(lang, source)
142            .map_err(|e| RunError::I18n(format!("the compiled `{tag}` catalogue: {e}")))?;
143    }
144    if cfg.app.fallback_locale.is_none() && !compiled.fallback.is_empty() {
145        i18n.fallback_chain = compiled
146            .fallback
147            .iter()
148            .map(|tag| lumen_i18n::Lang::try_from(tag.as_str()).map(Into::into))
149            .collect::<Result<_, _>>()
150            .map_err(|e| RunError::I18n(e.to_string()))?;
151    }
152    Ok(())
153}
154
155/// Re-read every catalogue into the live registry. Called by hot reload
156/// after an edit under `locale/`; `load_ftl` replaces a locale's bundle, so
157/// the running app picks the new strings up without a restart.
158#[cfg(feature = "runtime-parse")]
159pub(crate) fn reload_catalogues(world: &mut World, dir: &Path) {
160    let Some(shared) = world.get_resource::<SharedI18n>().cloned() else {
161        return;
162    };
163    if let Err(e) = shared
164        .write()
165        .load_dir(&locale_dir(dir), catalogue_reader(world))
166    {
167        eprintln!("lumenc hot-reload: locale catalogue failed to load: {e}");
168    }
169}
170
171/// Path + mtime of every `.ftl` file under `<dir>/locale`, sorted by path.
172/// The hot-reload sweep compares whole stamp lists rather than a fixed set
173/// of paths, so adding or deleting a catalogue registers as a change too.
174#[cfg(feature = "runtime-parse")]
175pub(crate) fn locale_stamps(dir: &Path) -> Vec<(PathBuf, Option<SystemTime>)> {
176    let Ok(entries) = std::fs::read_dir(locale_dir(dir)) else {
177        return Vec::new();
178    };
179    let mut out: Vec<(PathBuf, Option<SystemTime>)> = entries
180        .flatten()
181        .map(|e| e.path())
182        .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("ftl"))
183        .map(|p| {
184            let m = mtime(&p);
185            (p, m)
186        })
187        .collect();
188    out.sort();
189    out
190}