Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com> Reviewed-on: #14
243 lines
6.9 KiB
Rust
243 lines
6.9 KiB
Rust
use std::collections::HashMap;
|
|
use std::sync::LazyLock;
|
|
|
|
use serde_json::Value;
|
|
|
|
/// Supported application locales.
|
|
///
|
|
/// Each variant maps to an ISO 639-1 code and a human-readable label
|
|
/// displayed in the language picker.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum Locale {
|
|
#[default]
|
|
En,
|
|
De,
|
|
Fr,
|
|
Es,
|
|
Pt,
|
|
}
|
|
|
|
impl Locale {
|
|
/// ISO 639-1 language code.
|
|
pub fn code(self) -> &'static str {
|
|
match self {
|
|
Locale::En => "en",
|
|
Locale::De => "de",
|
|
Locale::Fr => "fr",
|
|
Locale::Es => "es",
|
|
Locale::Pt => "pt",
|
|
}
|
|
}
|
|
|
|
/// Human-readable label in the locale's own language.
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Locale::En => "English",
|
|
Locale::De => "Deutsch",
|
|
Locale::Fr => "Francais",
|
|
Locale::Es => "Espanol",
|
|
Locale::Pt => "Portugues",
|
|
}
|
|
}
|
|
|
|
/// All available locales.
|
|
pub fn all() -> &'static [Locale] {
|
|
&[Locale::En, Locale::De, Locale::Fr, Locale::Es, Locale::Pt]
|
|
}
|
|
|
|
/// Parse a locale from its ISO 639-1 code.
|
|
///
|
|
/// Returns `Locale::En` for unrecognized codes.
|
|
pub fn from_code(code: &str) -> Self {
|
|
match code {
|
|
"de" => Locale::De,
|
|
"fr" => Locale::Fr,
|
|
"es" => Locale::Es,
|
|
"pt" => Locale::Pt,
|
|
_ => Locale::En,
|
|
}
|
|
}
|
|
}
|
|
|
|
type TranslationMap = HashMap<String, String>;
|
|
|
|
/// All translations loaded at compile time and parsed lazily on first access.
|
|
///
|
|
/// Uses `LazyLock` (stable since Rust 1.80) to avoid runtime file I/O.
|
|
/// Each locale's JSON is embedded via `include_str!` and flattened into
|
|
/// dot-separated keys (e.g. `"nav.dashboard"` -> `"Dashboard"`).
|
|
static TRANSLATIONS: LazyLock<HashMap<&'static str, TranslationMap>> = LazyLock::new(|| {
|
|
let mut map = HashMap::with_capacity(5);
|
|
map.insert(
|
|
"en",
|
|
parse_translations(include_str!("../../assets/i18n/en.json")),
|
|
);
|
|
map.insert(
|
|
"de",
|
|
parse_translations(include_str!("../../assets/i18n/de.json")),
|
|
);
|
|
map.insert(
|
|
"fr",
|
|
parse_translations(include_str!("../../assets/i18n/fr.json")),
|
|
);
|
|
map.insert(
|
|
"es",
|
|
parse_translations(include_str!("../../assets/i18n/es.json")),
|
|
);
|
|
map.insert(
|
|
"pt",
|
|
parse_translations(include_str!("../../assets/i18n/pt.json")),
|
|
);
|
|
map
|
|
});
|
|
|
|
/// Parse a JSON string into a flat `key -> value` map.
|
|
///
|
|
/// Nested objects are flattened with dot separators:
|
|
/// `{ "nav": { "home": "Home" } }` becomes `"nav.home" -> "Home"`.
|
|
fn parse_translations(json: &str) -> TranslationMap {
|
|
// SAFETY: translation JSON files are bundled at compile time and are
|
|
// validated during development. A malformed file will panic here during
|
|
// the first access, which surfaces immediately in testing.
|
|
let value: Value = serde_json::from_str(json).unwrap_or(Value::Object(Default::default()));
|
|
let mut map = TranslationMap::new();
|
|
flatten_json("", &value, &mut map);
|
|
map
|
|
}
|
|
|
|
/// Recursively flatten a JSON value into dot-separated keys.
|
|
fn flatten_json(prefix: &str, value: &Value, map: &mut TranslationMap) {
|
|
match value {
|
|
Value::Object(obj) => {
|
|
for (key, val) in obj {
|
|
let new_prefix = if prefix.is_empty() {
|
|
key.clone()
|
|
} else {
|
|
format!("{prefix}.{key}")
|
|
};
|
|
flatten_json(&new_prefix, val, map);
|
|
}
|
|
}
|
|
Value::String(s) => {
|
|
map.insert(prefix.to_string(), s.clone());
|
|
}
|
|
// Non-string leaf values are skipped (numbers, bools, nulls)
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Look up a translation for the given locale and key.
|
|
///
|
|
/// Falls back to English if the key is missing in the target locale.
|
|
/// Returns the raw key if not found in any locale (useful for debugging
|
|
/// missing translations).
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `locale` - The target locale
|
|
/// * `key` - Dot-separated translation key (e.g. `"nav.dashboard"`)
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// The translated string, or the key itself as a fallback.
|
|
pub fn t(locale: Locale, key: &str) -> String {
|
|
TRANSLATIONS
|
|
.get(locale.code())
|
|
.and_then(|map| map.get(key))
|
|
.cloned()
|
|
.unwrap_or_else(|| {
|
|
// Fallback to English
|
|
TRANSLATIONS
|
|
.get("en")
|
|
.and_then(|map| map.get(key))
|
|
.cloned()
|
|
.unwrap_or_else(|| key.to_string())
|
|
})
|
|
}
|
|
|
|
/// Look up a translation and substitute variables.
|
|
///
|
|
/// Variables in the translation string use `{name}` syntax.
|
|
/// Each `(name, value)` pair in `vars` replaces `{name}` with `value`.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `locale` - The target locale
|
|
/// * `key` - Dot-separated translation key
|
|
/// * `vars` - Slice of `(name, value)` pairs for substitution
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// The translated string with all variables substituted.
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```
|
|
/// use dashboard::i18n::{tw, Locale};
|
|
/// let text = tw(Locale::En, "common.up_to_seats", &[("n", "5")]);
|
|
/// assert_eq!(text, "Up to 5 seats");
|
|
/// ```
|
|
pub fn tw(locale: Locale, key: &str, vars: &[(&str, &str)]) -> String {
|
|
let mut result = t(locale, key);
|
|
for (name, value) in vars {
|
|
result = result.replace(&format!("{{{name}}}"), value);
|
|
}
|
|
result
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn english_lookup() {
|
|
let result = t(Locale::En, "nav.dashboard");
|
|
assert_eq!(result, "Dashboard");
|
|
}
|
|
|
|
#[test]
|
|
fn german_lookup() {
|
|
let result = t(Locale::De, "nav.dashboard");
|
|
assert_eq!(result, "Dashboard");
|
|
}
|
|
|
|
#[test]
|
|
fn fallback_to_english() {
|
|
// If a key exists in English but not in another locale, English is returned
|
|
let en = t(Locale::En, "common.loading");
|
|
let result = t(Locale::De, "common.loading");
|
|
// German should have its own translation, but if missing, falls back to EN
|
|
assert!(!result.is_empty());
|
|
// Just verify it doesn't return the key itself
|
|
assert_ne!(result, "common.loading");
|
|
let _ = en; // suppress unused warning
|
|
}
|
|
|
|
#[test]
|
|
fn missing_key_returns_key() {
|
|
let result = t(Locale::En, "nonexistent.key");
|
|
assert_eq!(result, "nonexistent.key");
|
|
}
|
|
|
|
#[test]
|
|
fn variable_substitution() {
|
|
let result = tw(Locale::En, "common.up_to_seats", &[("n", "5")]);
|
|
assert_eq!(result, "Up to 5 seats");
|
|
}
|
|
|
|
#[test]
|
|
fn locale_from_code() {
|
|
assert_eq!(Locale::from_code("de"), Locale::De);
|
|
assert_eq!(Locale::from_code("fr"), Locale::Fr);
|
|
assert_eq!(Locale::from_code("unknown"), Locale::En);
|
|
}
|
|
|
|
#[test]
|
|
fn all_locales_loaded() {
|
|
for locale in Locale::all() {
|
|
let result = t(*locale, "nav.dashboard");
|
|
assert!(!result.is_empty());
|
|
}
|
|
}
|
|
}
|