app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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:
MCargo.lock | 306++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MCargo.toml | 7+++++++
Mcrates/launchers/desktop/Cargo.toml | 1+
Mcrates/launchers/desktop/src/app.rs | 3+++
Mcrates/shared/i18n/Cargo.toml | 13+++++++++++++
Acrates/shared/i18n/build.rs | 259+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/shared/i18n/src/keys.rs | 27+++++++++++++++++++++++++++
Mcrates/shared/i18n/src/lib.rs | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/shared/ui/src/placeholder.rs | 2+-
Ai18n/id_salt.txt | 1+
Ai18n/locales/en/messages.json | 6++++++
Ai18n/mf2-i18n.toml | 3+++
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"