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(¤t) {
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(¤t.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}