405 lines
12 KiB
Rust
405 lines
12 KiB
Rust
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<FluentResource>;
|
|
|
|
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<String> {
|
|
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<String, String> {
|
|
&RENDER_MAPS[locale_index(normalize_language(language))]
|
|
}
|
|
|
|
static RENDER_KEYS: LazyLock<Vec<String>> = 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<String, String>; 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::<std::collections::HashSet<_>>();
|
|
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}");
|
|
}
|
|
}
|
|
}
|