use fluent_bundle::concurrent::FluentBundle; use fluent_bundle::{FluentArgs, FluentResource}; use std::collections::HashMap; use std::sync::LazyLock; /// Supported UI locales, matching the i18n.allium SupportedLanguage spec. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[repr(usize)] pub enum UiLocale { En = 0, De = 1, Fr = 2, It = 3, Es = 4, } impl UiLocale { /// BCP-47 base code for this locale. pub fn code(self) -> &'static str { match self { Self::En => "en", Self::De => "de", Self::Fr => "fr", Self::It => "it", Self::Es => "es", } } /// All supported locales. pub fn all() -> &'static [UiLocale] { &[Self::En, Self::De, Self::Fr, Self::It, Self::Es] } /// Unicode regional-indicator flag emoji for this locale. pub fn flag_emoji(self) -> &'static str { match self { Self::En => "\u{1F1EC}\u{1F1E7}", // 🇬🇧 Self::De => "\u{1F1E9}\u{1F1EA}", // 🇩🇪 Self::Fr => "\u{1F1EB}\u{1F1F7}", // 🇫🇷 Self::It => "\u{1F1EE}\u{1F1F9}", // 🇮🇹 Self::Es => "\u{1F1EA}\u{1F1F8}", // 🇪🇸 } } } impl std::fmt::Display for UiLocale { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.code()) } } /// Normalize a language code to a supported UiLocale. /// /// Strips region suffix ("en-US" → "en"), lowercases, and falls back to English /// for unrecognized codes. This implements the LanguageNormalization invariant /// from i18n.allium. pub fn normalize_language(code: &str) -> UiLocale { let base = code.split(['-', '_']).next().unwrap_or("en").to_lowercase(); match base.as_str() { "en" => UiLocale::En, "de" => UiLocale::De, "fr" => UiLocale::Fr, "it" => UiLocale::It, "es" => UiLocale::Es, _ => UiLocale::En, } } /// Detect the OS locale and return the closest supported UiLocale. /// /// Uses sys-locale to query the system. Falls back to English if detection /// fails or the detected locale is not supported. pub fn detect_os_locale() -> UiLocale { match sys_locale::get_locale() { Some(tag) => normalize_language(&tag), None => UiLocale::En, } } type Bundle = FluentBundle; const UI_EN: &str = include_str!("../../../../locales/ui/en.ftl"); const RENDER_EN: &str = include_str!("../../../../locales/render/en.ftl"); fn resource(source: &str) -> FluentResource { FluentResource::try_new(source.to_owned()) .unwrap_or_else(|(_, errors)| panic!("invalid Fluent catalog: {errors:?}")) } fn bundle(locale: UiLocale, english: &str, localized: &str) -> Bundle { let mut bundle = FluentBundle::new_concurrent(vec![ locale.code().parse().expect("supported locale is valid"), ]); bundle.set_use_isolating(false); bundle .add_resource(resource(english)) .expect("English Fluent catalog has unique keys"); if locale != UiLocale::En { bundle.add_resource_overriding(resource(localized)); } bundle } static UI_CATALOGS: LazyLock<[Bundle; 5]> = LazyLock::new(|| { [ bundle(UiLocale::En, UI_EN, UI_EN), bundle( UiLocale::De, UI_EN, include_str!("../../../../locales/ui/de.ftl"), ), bundle( UiLocale::Fr, UI_EN, include_str!("../../../../locales/ui/fr.ftl"), ), bundle( UiLocale::It, UI_EN, include_str!("../../../../locales/ui/it.ftl"), ), bundle( UiLocale::Es, UI_EN, include_str!("../../../../locales/ui/es.ftl"), ), ] }); static RENDER_CATALOGS: LazyLock<[Bundle; 5]> = LazyLock::new(|| { [ bundle(UiLocale::En, RENDER_EN, RENDER_EN), bundle( UiLocale::De, RENDER_EN, include_str!("../../../../locales/render/de.ftl"), ), bundle( UiLocale::Fr, RENDER_EN, include_str!("../../../../locales/render/fr.ftl"), ), bundle( UiLocale::It, RENDER_EN, include_str!("../../../../locales/render/it.ftl"), ), bundle( UiLocale::Es, RENDER_EN, include_str!("../../../../locales/render/es.ftl"), ), ] }); fn locale_index(locale: UiLocale) -> usize { locale as usize } fn format(bundle: &Bundle, key: &str, params: &[(&str, &str)]) -> Option { let id = key.replace('.', "-"); let pattern = bundle.get_message(&id)?.value()?; let mut args = FluentArgs::new(); for (name, value) in params { args.set(*name, *value); } let mut errors = Vec::new(); let value = bundle .format_pattern(pattern, Some(&args), &mut errors) .into_owned(); Some(value) } fn ui_catalog_for(locale: UiLocale) -> &'static Bundle { &UI_CATALOGS[locale_index(locale)] } fn render_catalog_for(language: &str) -> &'static Bundle { &RENDER_CATALOGS[locale_index(normalize_language(language))] } /// Look up a translation key for the given locale. /// /// Fallback chain: requested locale → English → key itself. /// This implements the MenuTranslations invariant from i18n.allium. pub fn translate(locale: UiLocale, key: &str) -> String { format(ui_catalog_for(locale), key, &[]).unwrap_or_else(|| key.to_owned()) } /// Look up a translation key and substitute `{param}` placeholders. /// /// Parameters are provided as `&[("param_name", "value")]`. pub fn translate_with(locale: UiLocale, key: &str, params: &[(&str, &str)]) -> String { format(ui_catalog_for(locale), key, params).unwrap_or_else(|| key.to_owned()) } /// Look up a render/template translation key by content language code. /// /// This is independent of the UI locale — it uses the project's content language. /// Fallback chain: requested language → English → key itself. /// Implements the RenderTranslations invariant from i18n.allium. pub fn translate_render(language: &str, key: &str) -> String { format(render_catalog_for(language), key, &[]).unwrap_or_else(|| key.to_owned()) } /// Return the entire render translation map for a language. /// /// Used to inject as `translations` into the Liquid template context. pub fn get_render_translations(language: &str) -> &'static HashMap { &RENDER_MAPS[locale_index(normalize_language(language))] } static RENDER_KEYS: LazyLock> = LazyLock::new(|| { resource(RENDER_EN) .entries() .filter_map(|entry| match entry { fluent_syntax::ast::Entry::Message(message) => Some(message.id.name.replace('-', ".")), _ => None, }) .collect() }); static RENDER_MAPS: LazyLock<[HashMap; 5]> = LazyLock::new(|| { std::array::from_fn(|index| { let locale = UiLocale::all()[index]; RENDER_KEYS .iter() .map(|key| (key.clone(), translate_render(locale.code(), key))) .collect() }) }); #[cfg(test)] mod tests { use super::*; #[test] fn engine_progress_keys_exist_in_every_ui_catalog() { let keys = [ "engine-progress-scanningPublishedPosts", "engine-progress-translatingPost", "engine-progress-translationBatchComplete", "engine-progress-loadingProjectMetadata", "engine-progress-scanningPosts", "engine-progress-postItem", "engine-progress-scanningMedia", "engine-progress-mediaItem", "engine-progress-rebuildingTemplates", "engine-progress-rebuildingScripts", "engine-progress-importingTags", "engine-progress-refreshingSemanticIndex", "engine-progress-rebuildComplete", ]; let catalogs = [ ("en", UI_EN), ("de", include_str!("../../../../locales/ui/de.ftl")), ("fr", include_str!("../../../../locales/ui/fr.ftl")), ("it", include_str!("../../../../locales/ui/it.ftl")), ("es", include_str!("../../../../locales/ui/es.ftl")), ]; for (language, source) in catalogs { let present = resource(source) .entries() .filter_map(|entry| match entry { fluent_syntax::ast::Entry::Message(message) => { Some(message.id.name.to_string()) } _ => None, }) .collect::>(); for key in keys { assert!(present.contains(key), "{language} is missing {key}"); } } } // LanguageNormalization invariant: base-extract and fallback #[test] fn normalize_strips_region() { assert_eq!(normalize_language("en-US"), UiLocale::En); assert_eq!(normalize_language("de-AT"), UiLocale::De); assert_eq!(normalize_language("fr-CA"), UiLocale::Fr); assert_eq!(normalize_language("it-CH"), UiLocale::It); assert_eq!(normalize_language("es-MX"), UiLocale::Es); } #[test] fn normalize_underscore_separator() { assert_eq!(normalize_language("de_DE"), UiLocale::De); } #[test] fn normalize_unknown_falls_back_to_en() { assert_eq!(normalize_language("xx"), UiLocale::En); assert_eq!(normalize_language("ja"), UiLocale::En); assert_eq!(normalize_language(""), UiLocale::En); } #[test] fn normalize_case_insensitive() { assert_eq!(normalize_language("DE"), UiLocale::De); assert_eq!(normalize_language("FR-fr"), UiLocale::Fr); } // MenuTranslations invariant: menu labels come from locale catalog #[test] fn translate_menu_labels() { let label = translate(UiLocale::De, "menu.group.file"); assert_eq!(label, "Datei"); let label = translate(UiLocale::Fr, "menu.item.save"); assert_eq!(label, "Enregistrer"); } #[test] fn translate_missing_key_returns_key() { let result = translate(UiLocale::En, "nonexistent.key.xyz"); assert_eq!(result, "nonexistent.key.xyz"); } #[test] fn translate_with_interpolation() { let result = translate_with( UiLocale::En, "tasks.triggerTitle", &[("running", "2"), ("pending", "3")], ); assert_eq!(result, "2 running, 3 pending"); } #[test] fn translate_with_interpolation_german() { let result = translate_with( UiLocale::De, "tasks.triggerTitle", &[("running", "1"), ("pending", "5")], ); assert_eq!(result, "1 laufend, 5 ausstehend"); } #[test] fn all_locales_have_menu_keys() { let key = "menu.group.file"; for locale in UiLocale::all() { let val = translate(*locale, key); assert_ne!(val, key, "missing {key} for locale {locale}"); } } #[test] fn locale_code_roundtrip() { for locale in UiLocale::all() { assert_eq!(normalize_language(locale.code()), *locale); } } #[test] fn detect_os_locale_does_not_panic() { let _ = detect_os_locale(); } // RenderTranslations invariant: separate catalog for content/template locale #[test] fn translate_render_english() { assert_eq!(translate_render("en", "render.archive"), "Archive"); assert_eq!(translate_render("en", "render.month.1"), "January"); } #[test] fn translate_render_german() { assert_eq!(translate_render("de", "render.archive"), "Archiv"); assert_eq!(translate_render("de", "render.month.1"), "Januar"); } #[test] fn translate_render_falls_back_to_english() { assert_eq!(translate_render("ja", "render.archive"), "Archive"); } #[test] fn translate_render_missing_key_returns_key() { assert_eq!( translate_render("en", "render.nonexistent"), "render.nonexistent" ); } #[test] fn get_render_translations_not_empty() { let map = get_render_translations("en"); assert!(map.contains_key("render.archive")); assert!(map.contains_key("render.month.12")); assert!(map.len() >= 34); } #[test] fn all_render_locales_have_archive_key() { for code in &["en", "de", "fr", "it", "es"] { let val = translate_render(code, "render.archive"); assert_ne!(val, "render.archive", "missing render.archive for {code}"); } } }