commit 6fc713dff551151fc696d129312a990deac24860
parent 13d1a073c1f0e2ac4c9fc42daef44bfd9c035816
Author: triesap <tyson@radroots.org>
Date: Fri, 17 Apr 2026 16:55:04 +0000
i18n: add typed shell catalog
- compile the app locale catalog through the shared mf2 build and runtime crates
- add typed keys and english messages for app, home, and settings shell text
- resolve the launch locale from host locale inputs before the desktop window opens
- cover key registry, catalog completeness, and locale negotiation with crate tests
Diffstat:
12 files changed, 751 insertions(+), 9 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -16,7 +16,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
- "cpufeatures",
+ "cpufeatures 0.2.17",
"zeroize",
]
@@ -61,6 +61,15 @@ dependencies = [
]
[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -477,6 +486,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
name = "bindgen"
version = "0.71.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -597,6 +612,20 @@ dependencies = [
]
[[package]]
+name = "blake3"
+version = "1.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "cc",
+ "cfg-if",
+ "constant_time_eq",
+ "cpufeatures 0.3.0",
+]
+
+[[package]]
name = "block"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -798,6 +827,20 @@ dependencies = [
]
[[package]]
+name = "chrono"
+version = "0.4.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "pure-rust-locales",
+ "wasm-bindgen",
+ "windows-link 0.2.1",
+]
+
+[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -934,6 +977,12 @@ dependencies = [
]
[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -954,6 +1003,12 @@ dependencies = [
]
[[package]]
+name = "constant_time_eq"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
+
+[[package]]
name = "convert_case"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1127,6 +1182,15 @@ dependencies = [
]
[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1203,6 +1267,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "curve25519-dalek-derive",
+ "digest",
+ "fiat-crypto",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1215,6 +1306,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "zeroize",
+]
+
+[[package]]
name = "derive_more"
version = "0.99.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1361,6 +1462,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "pkcs8",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "serde",
+ "sha2",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1569,6 +1694,12 @@ dependencies = [
]
[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
+[[package]]
name = "filedescriptor"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2492,6 +2623,30 @@ dependencies = [
]
[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core 0.61.2",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
name = "icu_collections"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2689,6 +2844,15 @@ dependencies = [
]
[[package]]
+name = "intl_pluralrules"
+version = "7.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972"
+dependencies = [
+ "unic-langid",
+]
+
+[[package]]
name = "inventory"
version = "0.3.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3069,6 +3233,69 @@ dependencies = [
]
[[package]]
+name = "mf2-i18n-build"
+version = "0.1.0"
+dependencies = [
+ "blake3",
+ "hex",
+ "mf2-i18n-core",
+ "serde",
+ "serde_json",
+ "sha2",
+ "thiserror 1.0.69",
+ "toml 0.8.23",
+]
+
+[[package]]
+name = "mf2-i18n-core"
+version = "0.1.0"
+
+[[package]]
+name = "mf2-i18n-embedded"
+version = "0.1.0"
+dependencies = [
+ "mf2-i18n-core",
+]
+
+[[package]]
+name = "mf2-i18n-native"
+version = "0.1.0"
+dependencies = [
+ "mf2-i18n-core",
+ "mf2-i18n-embedded",
+ "mf2-i18n-runtime",
+ "mf2-i18n-std",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "mf2-i18n-runtime"
+version = "0.1.0"
+dependencies = [
+ "ed25519-dalek",
+ "hex",
+ "mf2-i18n-core",
+ "mf2-i18n-std",
+ "serde",
+ "serde_json",
+ "sha2",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "mf2-i18n-std"
+version = "0.1.0"
+dependencies = [
+ "chrono",
+ "intl_pluralrules",
+ "mf2-i18n-core",
+ "num-format",
+ "pure-rust-locales",
+ "thiserror 1.0.69",
+ "unic-langid",
+]
+
+[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3296,6 +3523,16 @@ dependencies = [
]
[[package]]
+name = "num-format"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3"
+dependencies = [
+ "arrayvec",
+ "itoa",
+]
+
+[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3688,6 +3925,16 @@ dependencies = [
]
[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3860,6 +4107,12 @@ dependencies = [
]
[[package]]
+name = "pure-rust-locales"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "869675ad2d7541aea90c6d88c81f46a7f4ea9af8cd0395d38f11a95126998a0d"
+
+[[package]]
name = "pxfm"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3980,6 +4233,7 @@ version = "0.1.0"
dependencies = [
"gpui",
"radroots_app_core",
+ "radroots_app_i18n",
"radroots_app_ui",
]
@@ -3990,6 +4244,15 @@ version = "0.1.0"
[[package]]
name = "radroots_app_i18n"
version = "0.1.0"
+dependencies = [
+ "hex",
+ "mf2-i18n-build",
+ "mf2-i18n-core",
+ "mf2-i18n-native",
+ "serde",
+ "serde_json",
+ "toml 0.8.23",
+]
[[package]]
name = "radroots_app_ui"
@@ -4728,7 +4991,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
- "cpufeatures",
+ "cpufeatures 0.2.17",
"digest",
]
@@ -4749,6 +5012,15 @@ dependencies = [
]
[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "rand_core 0.6.4",
+]
+
+[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4861,6 +5133,16 @@ dependencies = [
]
[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5314,6 +5596,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
+ "serde_core",
"zerovec",
]
@@ -5591,6 +5874,24 @@ dependencies = [
]
[[package]]
+name = "unic-langid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05"
+dependencies = [
+ "unic-langid-impl",
+]
+
+[[package]]
+name = "unic-langid-impl"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658"
+dependencies = [
+ "tinystr",
+]
+
+[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7142,6 +7443,7 @@ version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
+ "serde",
"yoke",
"zerofrom",
"zerovec-derive",
diff --git a/Cargo.toml b/Cargo.toml
@@ -19,9 +19,16 @@ readme = "README.md"
[workspace.dependencies]
gpui = "0.2.2"
+hex = "0.4"
+mf2-i18n-build = { path = "../../../../vendor/triesap/mf2-i18n/crates/mf2-i18n-build", version = "0.1.0" }
+mf2-i18n-core = { path = "../../../../vendor/triesap/mf2-i18n/crates/mf2-i18n-core", version = "0.1.0" }
+mf2-i18n-native = { path = "../../../../vendor/triesap/mf2-i18n/crates/mf2-i18n-native", version = "0.1.0" }
radroots_app_core = { path = "crates/shared/core", version = "0.1.0" }
radroots_app_i18n = { path = "crates/shared/i18n", version = "0.1.0" }
radroots_app_ui = { path = "crates/shared/ui", version = "0.1.0" }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+toml = "0.8"
[workspace.lints.rust]
unsafe_code = "forbid"
diff --git a/crates/launchers/desktop/Cargo.toml b/crates/launchers/desktop/Cargo.toml
@@ -10,6 +10,7 @@ publish = false
[dependencies]
gpui.workspace = true
radroots_app_core.workspace = true
+radroots_app_i18n.workspace = true
radroots_app_ui.workspace = true
[lints]
diff --git a/crates/launchers/desktop/src/app.rs b/crates/launchers/desktop/src/app.rs
@@ -1,5 +1,6 @@
use gpui::{AppContext, Application, WindowOptions, px, size};
use radroots_app_core::APP_ID;
+use radroots_app_i18n::select_locale_for_process;
use radroots_app_ui::{APP_UI_THEME, PlaceholderView};
fn titlebar_options() -> gpui::TitlebarOptions {
@@ -14,6 +15,8 @@ pub fn launch() {
let app = Application::new();
app.run(|cx| {
+ select_locale_for_process();
+
cx.on_window_closed(|cx| {
if cx.windows().is_empty() {
cx.quit();
diff --git a/crates/shared/i18n/Cargo.toml b/crates/shared/i18n/Cargo.toml
@@ -6,6 +6,19 @@ authors.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
+build = "build.rs"
+
+[dependencies]
+mf2-i18n-core.workspace = true
+mf2-i18n-native.workspace = true
+
+[build-dependencies]
+hex.workspace = true
+mf2-i18n-build.workspace = true
+mf2-i18n-core.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+toml.workspace = true
[lints]
workspace = true
diff --git a/crates/shared/i18n/build.rs b/crates/shared/i18n/build.rs
@@ -0,0 +1,259 @@
+use std::collections::BTreeMap;
+use std::env;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use mf2_i18n_build::compiler::compile_message;
+use mf2_i18n_build::id_map::{IdMap, build_id_map};
+use mf2_i18n_build::pack_encode::{PackBuildInput, encode_pack};
+use mf2_i18n_build::parser::parse_message;
+use mf2_i18n_core::PackKind;
+use serde::Deserialize;
+
+type Catalog = BTreeMap<String, String>;
+
+#[derive(Debug, Deserialize)]
+struct ProjectConfig {
+ default_locale: String,
+ source_dirs: Vec<PathBuf>,
+ project_salt_path: PathBuf,
+}
+
+fn main() {
+ let manifest_dir =
+ PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("cargo manifest dir should exist"));
+ let app_root = manifest_dir
+ .parent()
+ .and_then(Path::parent)
+ .and_then(Path::parent)
+ .expect("app root should be discoverable from shared i18n crate");
+ let i18n_root = app_root.join("i18n");
+ let config_path = i18n_root.join("mf2-i18n.toml");
+ println!("cargo:rerun-if-changed={}", config_path.display());
+
+ let config = load_project_config(&config_path);
+ let salt_path = i18n_root.join(&config.project_salt_path);
+ println!("cargo:rerun-if-changed={}", salt_path.display());
+ let id_salt = load_id_salt(&salt_path);
+
+ let catalogs = load_catalogs(&i18n_root, &config.source_dirs);
+ let default_catalog = catalogs
+ .get(&config.default_locale)
+ .unwrap_or_else(|| {
+ panic!(
+ "default locale {} catalog should exist",
+ config.default_locale
+ )
+ })
+ .clone();
+ ensure_catalog_keys_match(&catalogs);
+
+ let id_map = build_id_map(
+ default_catalog.keys().cloned().collect::<Vec<_>>(),
+ &id_salt,
+ )
+ .expect("id map should build");
+
+ let out_dir =
+ PathBuf::from(env::var("OUT_DIR").expect("out dir should exist")).join("app_i18n");
+ fs::create_dir_all(&out_dir).expect("i18n out dir should be created");
+
+ write_id_map(&out_dir, &id_map);
+ for (locale, catalog) in &catalogs {
+ write_pack(&out_dir, locale, catalog, &id_map);
+ }
+
+ let locale_ids = catalogs.keys().cloned().collect::<Vec<_>>();
+ let default_catalog_keys = default_catalog.keys().cloned().collect::<Vec<_>>();
+ write_generated_runtime(
+ &out_dir,
+ &config.default_locale,
+ &locale_ids,
+ &default_catalog_keys,
+ );
+}
+
+fn load_project_config(path: &Path) -> ProjectConfig {
+ let raw = fs::read_to_string(path).unwrap_or_else(|error| {
+ panic!(
+ "failed to read i18n project config {}: {error}",
+ path.display()
+ )
+ });
+ toml::from_str(&raw).unwrap_or_else(|error| {
+ panic!(
+ "failed to parse i18n project config {}: {error}",
+ path.display()
+ )
+ })
+}
+
+fn load_id_salt(path: &Path) -> Vec<u8> {
+ let raw = fs::read_to_string(path)
+ .unwrap_or_else(|error| panic!("failed to read id salt {}: {error}", path.display()));
+ let salt = raw.trim();
+ assert!(
+ !salt.is_empty(),
+ "i18n id salt {} must not be empty",
+ path.display()
+ );
+ salt.as_bytes().to_vec()
+}
+
+fn load_catalogs(i18n_root: &Path, source_dirs: &[PathBuf]) -> BTreeMap<String, Catalog> {
+ assert!(
+ !source_dirs.is_empty(),
+ "i18n project config must declare at least one source dir"
+ );
+
+ let mut catalogs = BTreeMap::<String, Catalog>::new();
+ for source_dir in source_dirs {
+ let source_root = i18n_root.join(source_dir);
+ println!("cargo:rerun-if-changed={}", source_root.display());
+ let entries = fs::read_dir(&source_root).unwrap_or_else(|error| {
+ panic!(
+ "failed to read source dir {}: {error}",
+ source_root.display()
+ )
+ });
+
+ for entry in entries {
+ let entry = entry.unwrap_or_else(|error| {
+ panic!(
+ "failed to read source dir entry under {}: {error}",
+ source_root.display()
+ )
+ });
+ let path = entry.path();
+ if !path.is_dir() {
+ continue;
+ }
+
+ let locale = entry.file_name().to_string_lossy().into_owned();
+ let messages_path = path.join("messages.json");
+ if !messages_path.is_file() {
+ continue;
+ }
+ println!("cargo:rerun-if-changed={}", messages_path.display());
+
+ let catalog = load_catalog(&messages_path);
+ let merged = catalogs.entry(locale.clone()).or_default();
+ for (key, value) in catalog {
+ assert!(
+ merged.insert(key.clone(), value).is_none(),
+ "duplicate i18n message key {key} in locale {locale} from {}",
+ messages_path.display()
+ );
+ }
+ }
+ }
+
+ assert!(
+ !catalogs.is_empty(),
+ "at least one locale catalog is required"
+ );
+ catalogs
+}
+
+fn load_catalog(path: &Path) -> Catalog {
+ let raw = fs::read_to_string(path)
+ .unwrap_or_else(|error| panic!("failed to read i18n catalog {}: {error}", path.display()));
+ serde_json::from_str(&raw)
+ .unwrap_or_else(|error| panic!("failed to parse i18n catalog {}: {error}", path.display()))
+}
+
+fn ensure_catalog_keys_match(catalogs: &BTreeMap<String, Catalog>) {
+ let Some((reference_locale, reference_catalog)) = catalogs.iter().next() else {
+ panic!("at least one i18n catalog is required");
+ };
+
+ let reference_keys = reference_catalog.keys().cloned().collect::<Vec<_>>();
+ for (locale, catalog) in catalogs.iter().skip(1) {
+ let keys = catalog.keys().cloned().collect::<Vec<_>>();
+ assert_eq!(
+ keys, reference_keys,
+ "i18n catalog keys for locale {locale} do not match reference locale {reference_locale}"
+ );
+ }
+}
+
+fn write_id_map(out_dir: &Path, id_map: &IdMap) {
+ let entries = id_map
+ .entries()
+ .map(|(key, id)| (key.to_owned(), u32::from(id)))
+ .collect::<BTreeMap<_, _>>();
+ let id_map_json = serde_json::to_vec_pretty(&entries).expect("id map json should serialize");
+ fs::write(out_dir.join("id-map.json"), id_map_json).expect("id map json should write");
+
+ let hash = id_map.hash().expect("id map hash should build");
+ let hash_text = format!("sha256:{}\n", hex::encode(hash));
+ fs::write(out_dir.join("id-map.sha256"), hash_text).expect("id map hash should write");
+}
+
+fn write_pack(out_dir: &Path, locale: &str, catalog: &Catalog, id_map: &IdMap) {
+ let id_map_hash = id_map.hash().expect("id map hash should build");
+ let mut messages = BTreeMap::new();
+
+ for (key, source) in catalog {
+ let parsed = parse_message(source).unwrap_or_else(|error| {
+ panic!("failed to parse i18n message for locale {locale} key {key}: {error:?}")
+ });
+ let compiled = compile_message(&parsed).unwrap_or_else(|error| {
+ panic!("failed to compile i18n message for locale {locale} key {key}: {error}")
+ });
+ let message_id = id_map
+ .get(key)
+ .unwrap_or_else(|| panic!("missing message id for locale {locale} key {key}"));
+ messages.insert(message_id, compiled.program);
+ }
+
+ let pack_bytes = encode_pack(&PackBuildInput {
+ pack_kind: PackKind::Base,
+ id_map_hash,
+ locale_tag: locale.to_owned(),
+ parent_tag: None,
+ build_epoch_ms: 0,
+ messages,
+ });
+
+ fs::write(out_dir.join(format!("{locale}.mf2pack")), pack_bytes)
+ .unwrap_or_else(|error| panic!("failed to write i18n pack for locale {locale}: {error}"));
+}
+
+fn write_generated_runtime(
+ out_dir: &Path,
+ default_locale: &str,
+ locale_ids: &[String],
+ default_catalog_keys: &[String],
+) {
+ let packs_source = locale_ids
+ .iter()
+ .map(|locale| {
+ format!(
+ " ({locale:?}, include_bytes!(concat!(env!(\"OUT_DIR\"), \"/app_i18n/{locale}.mf2pack\"))),"
+ )
+ })
+ .collect::<Vec<_>>()
+ .join("\n");
+ let runtime_source = format!(
+ "mod generated {{\n mf2_i18n_native::define_i18n_module! {{\n init_policy: strict,\n default_locale: {default_locale:?},\n id_map_json: include_bytes!(concat!(env!(\"OUT_DIR\"), \"/app_i18n/id-map.json\")),\n id_map_hash: include_bytes!(concat!(env!(\"OUT_DIR\"), \"/app_i18n/id-map.sha256\")),\n packs: [\n{packs_source}\n ],\n }}\n}}\n"
+ );
+ fs::write(out_dir.join("generated_module.rs"), runtime_source)
+ .expect("generated runtime module should write");
+
+ let supported_locale_values = locale_ids
+ .iter()
+ .map(|locale| format!("{locale:?}"))
+ .collect::<Vec<_>>()
+ .join(", ");
+ let key_values = default_catalog_keys
+ .iter()
+ .map(|key| format!("{key:?}"))
+ .collect::<Vec<_>>()
+ .join(", ");
+ let catalog_source = format!(
+ "const DEFAULT_LOCALE_ID: &str = {default_locale:?};\nconst SUPPORTED_LOCALE_IDS: &[&str] = &[{supported_locale_values}];\n#[cfg(test)] const DEFAULT_CATALOG_KEY_IDS: &[&str] = &[{key_values}];\n"
+ );
+ fs::write(out_dir.join("generated_catalog.rs"), catalog_source)
+ .expect("generated catalog metadata should write");
+}
diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs
@@ -0,0 +1,27 @@
+macro_rules! define_app_text_keys {
+ ($($variant:ident => $id:literal,)+) => {
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
+ pub enum AppTextKey {
+ $($variant,)+
+ }
+
+ impl AppTextKey {
+ pub const ALL: &'static [Self] = &[
+ $(Self::$variant,)+
+ ];
+
+ pub const fn id(self) -> &'static str {
+ match self {
+ $(Self::$variant => $id,)+
+ }
+ }
+ }
+ };
+}
+
+define_app_text_keys! {
+ AppName => "app.name",
+ HomeBrand => "home.brand",
+ HomeTitle => "home.title",
+ SettingsTitle => "settings.title",
+}
diff --git a/crates/shared/i18n/src/lib.rs b/crates/shared/i18n/src/lib.rs
@@ -1,12 +1,132 @@
#![forbid(unsafe_code)]
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum AppTextKey {
- Brand,
+use mf2_i18n_core::{LanguageTag, negotiate_lookup};
+
+mod keys;
+
+pub use keys::AppTextKey;
+
+include!(concat!(env!("OUT_DIR"), "/app_i18n/generated_module.rs"));
+include!(concat!(env!("OUT_DIR"), "/app_i18n/generated_catalog.rs"));
+
+pub fn app_text(key: AppTextKey) -> String {
+ generated::tr(key.id())
+ .unwrap_or_else(|error| panic!("missing localized app text for key {}: {error}", key.id()))
+}
+
+pub fn default_locale() -> &'static str {
+ DEFAULT_LOCALE_ID
+}
+
+pub fn supported_locales() -> &'static [&'static str] {
+ SUPPORTED_LOCALE_IDS
+}
+
+pub fn resolve_locale_from_host(host_locale: &str) -> String {
+ let normalized = normalize_host_locale(host_locale);
+ let requested_locale = match LanguageTag::parse(&normalized) {
+ Ok(locale) => locale,
+ Err(_) => return DEFAULT_LOCALE_ID.to_owned(),
+ };
+ let supported = SUPPORTED_LOCALE_IDS
+ .iter()
+ .map(|locale| LanguageTag::parse(locale).expect("supported locale should parse"))
+ .collect::<Vec<_>>();
+ let default_locale =
+ LanguageTag::parse(DEFAULT_LOCALE_ID).expect("default locale should parse");
+
+ negotiate_lookup(&[requested_locale], &supported, &default_locale)
+ .selected
+ .normalized()
+ .to_owned()
+}
+
+pub fn select_locale_from_host(host_locale: &str) -> String {
+ let locale = resolve_locale_from_host(host_locale);
+ generated::set_locale(&locale).unwrap_or_else(|_| DEFAULT_LOCALE_ID.to_owned())
+}
+
+pub fn select_locale_for_process() -> String {
+ let host_locale = [
+ std::env::var("LC_ALL").ok(),
+ std::env::var("LC_MESSAGES").ok(),
+ std::env::var("LANGUAGE").ok(),
+ std::env::var("LANG").ok(),
+ ]
+ .into_iter()
+ .flatten()
+ .find(|value| !value.trim().is_empty())
+ .unwrap_or_else(|| DEFAULT_LOCALE_ID.to_owned());
+
+ select_locale_from_host(&host_locale)
+}
+
+fn normalize_host_locale(host_locale: &str) -> String {
+ let trimmed = host_locale.trim();
+ if trimmed.is_empty() {
+ return DEFAULT_LOCALE_ID.to_owned();
+ }
+
+ let without_fallbacks = trimmed.split(':').next().unwrap_or(DEFAULT_LOCALE_ID);
+ let without_encoding = without_fallbacks
+ .split('.')
+ .next()
+ .unwrap_or(DEFAULT_LOCALE_ID)
+ .split('@')
+ .next()
+ .unwrap_or(DEFAULT_LOCALE_ID)
+ .trim();
+ if without_encoding.is_empty() {
+ return DEFAULT_LOCALE_ID.to_owned();
+ }
+
+ without_encoding.replace('_', "-")
}
-pub fn app_text(key: AppTextKey) -> &'static str {
- match key {
- AppTextKey::Brand => "radroots",
+#[cfg(test)]
+mod tests {
+ use std::collections::BTreeSet;
+
+ use super::{
+ AppTextKey, app_text, default_locale, resolve_locale_from_host, supported_locales,
+ };
+
+ #[test]
+ fn generated_catalog_matches_typed_key_registry() {
+ let catalog_keys = super::DEFAULT_CATALOG_KEY_IDS
+ .iter()
+ .copied()
+ .collect::<BTreeSet<_>>();
+ let typed_keys = AppTextKey::ALL
+ .iter()
+ .map(|key| key.id())
+ .collect::<BTreeSet<_>>();
+
+ assert_eq!(typed_keys, catalog_keys);
+ }
+
+ #[test]
+ fn english_catalog_covers_all_defined_text_keys() {
+ assert_eq!(super::generated::default_locale(), default_locale());
+ assert_eq!(
+ super::generated::supported_locales(),
+ supported_locales()
+ .iter()
+ .map(|locale| (*locale).to_owned())
+ .collect::<Vec<_>>()
+ );
+
+ for key in AppTextKey::ALL {
+ assert!(!app_text(*key).trim().is_empty());
+ }
+ }
+
+ #[test]
+ fn host_locale_negotiation_reduces_to_supported_base_locale() {
+ assert_eq!(resolve_locale_from_host("en_US.UTF-8"), "en");
+ assert_eq!(resolve_locale_from_host("en-GB"), "en");
+ assert_eq!(resolve_locale_from_host("en:fr"), "en");
+ assert_eq!(resolve_locale_from_host(""), "en");
+ assert_eq!(resolve_locale_from_host("C.UTF-8"), "en");
}
}
diff --git a/crates/shared/ui/src/placeholder.rs b/crates/shared/ui/src/placeholder.rs
@@ -14,7 +14,7 @@ impl Render for PlaceholderView {
.text_size(px(APP_UI_THEME.typography.brand_text_px))
.font_weight(FontWeight::SEMIBOLD)
.text_color(rgb(APP_UI_THEME.text.primary))
- .child(app_text(AppTextKey::Brand)),
+ .child(app_text(AppTextKey::HomeBrand)),
),
)
}
diff --git a/i18n/id_salt.txt b/i18n/id_salt.txt
@@ -0,0 +1 @@
+radroots-app-i18n-2026-04-17
diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json
@@ -0,0 +1,6 @@
+{
+ "app.name": "radroots",
+ "home.brand": "radroots",
+ "home.title": "home",
+ "settings.title": "settings"
+}
diff --git a/i18n/mf2-i18n.toml b/i18n/mf2-i18n.toml
@@ -0,0 +1,3 @@
+default_locale = "en"
+source_dirs = ["locales"]
+project_salt_path = "id_salt.txt"