feat: port el-ui vessels — rename crates→vessels, add El source + manifests
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
/// LocaleBundle — loads and caches translation strings.
|
||||
///
|
||||
/// A bundle holds all translation strings for one locale. Strings are
|
||||
/// keyed by dot-delimited paths (e.g. "profile.followers"). The bundle
|
||||
/// supports both flat strings and plural forms.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use crate::locale::Locale;
|
||||
use crate::plural::{plural_form, PluralForm};
|
||||
|
||||
/// A single translation value — either a simple string or a plural map.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TranslationValue {
|
||||
/// A simple translated string. May contain `{key}` interpolation placeholders.
|
||||
Simple(String),
|
||||
/// A plural-form map. Keys are form names: "zero", "one", "two", "few", "many", "other".
|
||||
Plural(HashMap<String, String>),
|
||||
}
|
||||
|
||||
/// A bundle of translations for a single locale.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocaleBundle {
|
||||
pub locale: Locale,
|
||||
translations: HashMap<String, TranslationValue>,
|
||||
}
|
||||
|
||||
impl LocaleBundle {
|
||||
/// Create an empty bundle for a locale.
|
||||
pub fn new(locale: Locale) -> Self {
|
||||
Self {
|
||||
locale,
|
||||
translations: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a simple translation.
|
||||
pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
|
||||
self.translations.insert(
|
||||
key.into(),
|
||||
TranslationValue::Simple(value.into()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Insert a plural translation.
|
||||
pub fn insert_plural(
|
||||
&mut self,
|
||||
key: impl Into<String>,
|
||||
forms: HashMap<String, String>,
|
||||
) {
|
||||
self.translations
|
||||
.insert(key.into(), TranslationValue::Plural(forms));
|
||||
}
|
||||
|
||||
/// Look up a key and return the simple string (no interpolation).
|
||||
pub fn get_raw(&self, key: &str) -> Option<&str> {
|
||||
match self.translations.get(key)? {
|
||||
TranslationValue::Simple(s) => Some(s.as_str()),
|
||||
TranslationValue::Plural(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a key with variable interpolation.
|
||||
///
|
||||
/// Replaces `{name}` placeholders with values from `vars`.
|
||||
pub fn translate(&self, key: &str, vars: &HashMap<&str, String>) -> Option<String> {
|
||||
let raw = match self.translations.get(key)? {
|
||||
TranslationValue::Simple(s) => s.clone(),
|
||||
TranslationValue::Plural(forms) => {
|
||||
// For translate(), use "other" as default
|
||||
forms.get("other")?.clone()
|
||||
}
|
||||
};
|
||||
Some(interpolate(&raw, vars))
|
||||
}
|
||||
|
||||
/// Look up a plural key with a count.
|
||||
///
|
||||
/// Selects the correct plural form for the locale's language and count,
|
||||
/// then interpolates `{n}` and any other `vars`.
|
||||
pub fn translate_plural(
|
||||
&self,
|
||||
key: &str,
|
||||
count: i64,
|
||||
vars: &HashMap<&str, String>,
|
||||
) -> Option<String> {
|
||||
let forms = match self.translations.get(key)? {
|
||||
TranslationValue::Plural(f) => f,
|
||||
TranslationValue::Simple(s) => {
|
||||
// Fall through: treat the simple string as "other"
|
||||
let mut result_vars = vars.clone();
|
||||
result_vars.insert("n", count.to_string());
|
||||
return Some(interpolate(s, &result_vars));
|
||||
}
|
||||
};
|
||||
|
||||
let form = plural_form(&self.locale.language, count);
|
||||
let form_key = match form {
|
||||
PluralForm::Zero => "zero",
|
||||
PluralForm::One => "one",
|
||||
PluralForm::Two => "two",
|
||||
PluralForm::Few => "few",
|
||||
PluralForm::Many => "many",
|
||||
PluralForm::Other => "other",
|
||||
};
|
||||
|
||||
let template = forms
|
||||
.get(form_key)
|
||||
.or_else(|| forms.get("other"))?;
|
||||
|
||||
let mut result_vars = vars.clone();
|
||||
result_vars.insert("n", count.to_string());
|
||||
Some(interpolate(template, &result_vars))
|
||||
}
|
||||
|
||||
/// Number of translations loaded.
|
||||
pub fn len(&self) -> usize {
|
||||
self.translations.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.translations.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace `{key}` placeholders in `template` with values from `vars`.
|
||||
fn interpolate(template: &str, vars: &HashMap<&str, String>) -> String {
|
||||
let mut result = template.to_string();
|
||||
for (key, value) in vars {
|
||||
result = result.replace(&format!("{{{}}}", key), value);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Load a bundle from a TOML string.
|
||||
///
|
||||
/// Format:
|
||||
/// ```toml
|
||||
/// [profile]
|
||||
/// follow = "Follow"
|
||||
/// followers = { one = "{n} Follower", other = "{n} Followers" }
|
||||
/// ```
|
||||
pub fn load_toml(locale: Locale, toml_str: &str) -> Result<LocaleBundle, String> {
|
||||
let value: toml::Value = toml::from_str(toml_str)
|
||||
.map_err(|e| format!("TOML parse error: {}", e))?;
|
||||
|
||||
let mut bundle = LocaleBundle::new(locale);
|
||||
|
||||
if let toml::Value::Table(table) = value {
|
||||
load_table(&mut bundle, &table, "");
|
||||
}
|
||||
|
||||
Ok(bundle)
|
||||
}
|
||||
|
||||
fn load_table(bundle: &mut LocaleBundle, table: &toml::value::Table, prefix: &str) {
|
||||
for (key, value) in table {
|
||||
let full_key = if prefix.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
format!("{}.{}", prefix, key)
|
||||
};
|
||||
|
||||
match value {
|
||||
toml::Value::String(s) => {
|
||||
bundle.insert(full_key, s.clone());
|
||||
}
|
||||
toml::Value::Table(inner) => {
|
||||
// Check if it's a plural table (has "one", "other", etc.)
|
||||
let is_plural = inner.contains_key("one")
|
||||
|| inner.contains_key("other")
|
||||
|| inner.contains_key("zero")
|
||||
|| inner.contains_key("few")
|
||||
|| inner.contains_key("many");
|
||||
|
||||
if is_plural {
|
||||
let mut forms = HashMap::new();
|
||||
for (form, form_val) in inner {
|
||||
if let toml::Value::String(s) = form_val {
|
||||
forms.insert(form.clone(), s.clone());
|
||||
}
|
||||
}
|
||||
bundle.insert_plural(full_key, forms);
|
||||
} else {
|
||||
// Nested namespace
|
||||
load_table(bundle, inner, &full_key);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn en_bundle() -> LocaleBundle {
|
||||
let mut b = LocaleBundle::new(Locale::en_us());
|
||||
b.insert("profile.follow", "Follow");
|
||||
b.insert("profile.bio", "Bio");
|
||||
let mut forms = HashMap::new();
|
||||
forms.insert("one".to_string(), "{n} Follower".to_string());
|
||||
forms.insert("other".to_string(), "{n} Followers".to_string());
|
||||
b.insert_plural("profile.followers", forms);
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_raw_simple() {
|
||||
let b = en_bundle();
|
||||
assert_eq!(b.get_raw("profile.follow"), Some("Follow"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_raw_missing() {
|
||||
let b = en_bundle();
|
||||
assert_eq!(b.get_raw("nonexistent.key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_simple() {
|
||||
let b = en_bundle();
|
||||
let vars = HashMap::new();
|
||||
assert_eq!(
|
||||
b.translate("profile.follow", &vars),
|
||||
Some("Follow".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_plural_one() {
|
||||
let b = en_bundle();
|
||||
let vars = HashMap::new();
|
||||
assert_eq!(
|
||||
b.translate_plural("profile.followers", 1, &vars),
|
||||
Some("1 Follower".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_plural_many() {
|
||||
let b = en_bundle();
|
||||
let vars = HashMap::new();
|
||||
assert_eq!(
|
||||
b.translate_plural("profile.followers", 42, &vars),
|
||||
Some("42 Followers".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolation_replaces_placeholder() {
|
||||
let mut b = LocaleBundle::new(Locale::en_us());
|
||||
b.insert("greeting", "Hello, {name}!");
|
||||
let mut vars = HashMap::new();
|
||||
vars.insert("name", "Alice".to_string());
|
||||
assert_eq!(
|
||||
b.translate("greeting", &vars),
|
||||
Some("Hello, Alice!".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_len() {
|
||||
let b = en_bundle();
|
||||
assert_eq!(b.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_toml_simple() {
|
||||
let toml = r#"
|
||||
[profile]
|
||||
follow = "Follow"
|
||||
bio = "Bio"
|
||||
"#;
|
||||
let bundle = load_toml(Locale::en_us(), toml).unwrap();
|
||||
assert_eq!(bundle.get_raw("profile.follow"), Some("Follow"));
|
||||
assert_eq!(bundle.get_raw("profile.bio"), Some("Bio"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_toml_plural() {
|
||||
let toml = r#"
|
||||
[profile]
|
||||
followers = { one = "{n} Follower", other = "{n} Followers" }
|
||||
"#;
|
||||
let bundle = load_toml(Locale::en_us(), toml).unwrap();
|
||||
let vars = HashMap::new();
|
||||
assert_eq!(
|
||||
bundle.translate_plural("profile.followers", 1, &vars),
|
||||
Some("1 Follower".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_toml_invalid() {
|
||||
let result = load_toml(Locale::en_us(), "not valid toml %%%");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/// Number, date, and currency formatting per locale.
|
||||
///
|
||||
/// Formatting is locale-sensitive: number grouping, decimal separators,
|
||||
/// currency symbol placement, and date ordering all vary by locale.
|
||||
/// Use these formatters rather than hardcoding formatting logic.
|
||||
|
||||
use crate::locale::Locale;
|
||||
|
||||
/// Format a number with locale-appropriate grouping and decimals.
|
||||
///
|
||||
/// Examples:
|
||||
/// - en-US: 1,234,567.89
|
||||
/// - de-DE: 1.234.567,89
|
||||
/// - fr-FR: 1 234 567,89
|
||||
pub fn format_number(value: f64, locale: &Locale, decimal_places: usize) -> String {
|
||||
let (group_sep, decimal_sep) = separators_for_locale(locale);
|
||||
|
||||
let rounded = round_to(value, decimal_places);
|
||||
let is_negative = rounded < 0.0;
|
||||
let abs_value = rounded.abs();
|
||||
|
||||
let int_part = abs_value.trunc() as u64;
|
||||
let frac_part = ((abs_value.fract() * 10f64.powi(decimal_places as i32)).round()) as u64;
|
||||
|
||||
let int_str = format_integer_with_grouping(int_part, group_sep);
|
||||
|
||||
let result = if decimal_places > 0 {
|
||||
format!(
|
||||
"{}{}{}",
|
||||
int_str,
|
||||
decimal_sep,
|
||||
format!("{:0>width$}", frac_part, width = decimal_places)
|
||||
)
|
||||
} else {
|
||||
int_str
|
||||
};
|
||||
|
||||
if is_negative {
|
||||
format!("-{}", result)
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a currency value with locale-appropriate symbol and placement.
|
||||
///
|
||||
/// Examples:
|
||||
/// - en-US / USD: $1,234.56
|
||||
/// - de-DE / EUR: 1.234,56 €
|
||||
/// - ja / JPY: ¥1,235
|
||||
pub fn format_currency(value: f64, locale: &Locale, currency_code: &str) -> String {
|
||||
let (symbol, prefix, decimals) = currency_info(currency_code);
|
||||
let formatted = format_number(value, locale, decimals);
|
||||
|
||||
if prefix {
|
||||
format!("{}{}", symbol, formatted)
|
||||
} else {
|
||||
format!("{} {}", formatted, symbol)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format an integer with locale-appropriate grouping separators.
|
||||
pub fn format_integer(value: i64, locale: &Locale) -> String {
|
||||
let (group_sep, _) = separators_for_locale(locale);
|
||||
let is_negative = value < 0;
|
||||
let abs_val = value.unsigned_abs();
|
||||
let grouped = format_integer_with_grouping(abs_val, group_sep);
|
||||
if is_negative {
|
||||
format!("-{}", grouped)
|
||||
} else {
|
||||
grouped
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a percentage (0.85 → "85%", locale-aware).
|
||||
pub fn format_percent(value: f64, locale: &Locale, decimal_places: usize) -> String {
|
||||
let pct = value * 100.0;
|
||||
let (_, decimal_sep) = separators_for_locale(locale);
|
||||
let int_part = pct.trunc() as u64;
|
||||
let frac = ((pct.fract() * 10f64.powi(decimal_places as i32)).round()) as u64;
|
||||
|
||||
if decimal_places > 0 {
|
||||
format!(
|
||||
"{}{}{}%",
|
||||
int_part,
|
||||
decimal_sep,
|
||||
format!("{:0>width$}", frac, width = decimal_places)
|
||||
)
|
||||
} else {
|
||||
format!("{}%", int_part)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
fn separators_for_locale(locale: &Locale) -> (char, char) {
|
||||
match locale.language.as_str() {
|
||||
// Comma grouping, period decimal (en-US style)
|
||||
"en" | "ja" | "ko" | "zh" | "th" => (',', '.'),
|
||||
// Period grouping, comma decimal (European style)
|
||||
"de" | "nl" | "it" | "pt" | "es" | "tr" | "pl" | "ru" | "uk" | "el" => ('.', ','),
|
||||
// Thin space grouping, comma decimal (French style)
|
||||
"fr" | "sv" | "no" | "nb" | "da" | "fi" => ('\u{202F}', ','),
|
||||
// Default: comma grouping, period decimal
|
||||
_ => (',', '.'),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_integer_with_grouping(value: u64, sep: char) -> String {
|
||||
let s = value.to_string();
|
||||
if s.len() <= 3 {
|
||||
return s;
|
||||
}
|
||||
let mut result = String::new();
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let len = chars.len();
|
||||
for (i, &ch) in chars.iter().enumerate() {
|
||||
if i > 0 && (len - i) % 3 == 0 {
|
||||
result.push(sep);
|
||||
}
|
||||
result.push(ch);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn round_to(value: f64, places: usize) -> f64 {
|
||||
let factor = 10f64.powi(places as i32);
|
||||
(value * factor).round() / factor
|
||||
}
|
||||
|
||||
fn currency_info(code: &str) -> (&'static str, bool, usize) {
|
||||
// (symbol, prefix, decimal_places)
|
||||
match code.to_uppercase().as_str() {
|
||||
"USD" => ("$", true, 2),
|
||||
"EUR" => ("€", false, 2),
|
||||
"GBP" => ("£", true, 2),
|
||||
"JPY" => ("¥", true, 0),
|
||||
"CNY" => ("¥", true, 2),
|
||||
"KRW" => ("₩", true, 0),
|
||||
"INR" => ("₹", true, 2),
|
||||
"CHF" => ("CHF", true, 2),
|
||||
"CAD" => ("CA$", true, 2),
|
||||
"AUD" => ("A$", true, 2),
|
||||
"BRL" => ("R$", true, 2),
|
||||
"MXN" => ("MX$", true, 2),
|
||||
"RUB" => ("₽", false, 2),
|
||||
"SEK" => ("kr", false, 2),
|
||||
"NOK" => ("kr", false, 2),
|
||||
"DKK" => ("kr", false, 2),
|
||||
"PLN" => ("zł", false, 2),
|
||||
"TRY" => ("₺", true, 2),
|
||||
"SAR" => ("﷼", false, 2),
|
||||
"AED" => ("د.إ", false, 2),
|
||||
_ => ("¤", true, 2), // generic currency sign for unknown codes
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn format_number_en_us() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_number(1234567.89, &locale, 2), "1,234,567.89");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_number_de() {
|
||||
let locale = Locale::new("de-DE");
|
||||
assert_eq!(format_number(1234.56, &locale, 2), "1.234,56");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_number_no_decimals() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_number(42.0, &locale, 0), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_number_negative() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_number(-1000.0, &locale, 2), "-1,000.00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_currency_usd() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_currency(1234.56, &locale, "USD"), "$1,234.56");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_currency_jpy_no_decimals() {
|
||||
let locale = Locale::ja();
|
||||
assert_eq!(format_currency(1234.0, &locale, "JPY"), "¥1,234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_integer_groups() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_integer(1000000, &locale), "1,000,000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_integer_small() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_integer(42, &locale), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_percent_whole() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_percent(0.85, &locale, 0), "85%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_percent_with_decimal() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_percent(0.856, &locale, 1), "85.6%");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! el-i18n — Localization for el-ui.
|
||||
//!
|
||||
//! RTL-aware, plural forms, CLDR-based number/currency formatting.
|
||||
//!
|
||||
//! ## Quick start
|
||||
//!
|
||||
//! ```
|
||||
//! use el_i18n::prelude::*;
|
||||
//! use std::collections::HashMap;
|
||||
//!
|
||||
//! // Build a bundle
|
||||
//! let mut bundle = LocaleBundle::new(Locale::en_us());
|
||||
//! bundle.insert("profile.follow", "Follow");
|
||||
//! let mut forms = HashMap::new();
|
||||
//! forms.insert("one".to_string(), "{n} Follower".to_string());
|
||||
//! forms.insert("other".to_string(), "{n} Followers".to_string());
|
||||
//! bundle.insert_plural("profile.followers", forms);
|
||||
//!
|
||||
//! // Create a context
|
||||
//! let ctx = LocaleContext::new(Locale::en_us(), bundle);
|
||||
//!
|
||||
//! // Translate
|
||||
//! assert_eq!(ctx.t("profile.follow"), "Follow");
|
||||
//! assert_eq!(ctx.t_plural("profile.followers", 1), "1 Follower");
|
||||
//! assert_eq!(ctx.t_plural("profile.followers", 42), "42 Followers");
|
||||
//! ```
|
||||
|
||||
#![deny(warnings)]
|
||||
|
||||
pub mod bundle;
|
||||
pub mod format;
|
||||
pub mod locale;
|
||||
pub mod plural;
|
||||
pub mod t;
|
||||
|
||||
pub mod prelude {
|
||||
pub use crate::bundle::{load_toml, LocaleBundle, TranslationValue};
|
||||
pub use crate::format::{format_currency, format_integer, format_number, format_percent};
|
||||
pub use crate::locale::{Locale, TextDirection};
|
||||
pub use crate::plural::{plural_form, PluralForm};
|
||||
pub use crate::t::LocaleContext;
|
||||
}
|
||||
|
||||
pub use prelude::*;
|
||||
@@ -0,0 +1,182 @@
|
||||
/// Locale — language + optional region + directionality.
|
||||
///
|
||||
/// Locale identifies both the language for translation lookup and the
|
||||
/// region for number/date/currency formatting.
|
||||
|
||||
/// Text direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TextDirection {
|
||||
LeftToRight,
|
||||
RightToLeft,
|
||||
}
|
||||
|
||||
/// A locale identifier.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Locale {
|
||||
/// BCP 47 language tag (e.g. "en", "ar", "zh-Hant").
|
||||
pub language: String,
|
||||
/// Optional region (e.g. "US", "GB", "TW").
|
||||
pub region: Option<String>,
|
||||
}
|
||||
|
||||
impl Locale {
|
||||
/// Create from a BCP 47 tag like "en-US" or "ar".
|
||||
pub fn new(tag: impl Into<String>) -> Self {
|
||||
let tag = tag.into();
|
||||
if let Some(idx) = tag.find('-') {
|
||||
let (lang, rest) = tag.split_at(idx);
|
||||
let region = rest.trim_start_matches('-');
|
||||
Self {
|
||||
language: lang.to_lowercase(),
|
||||
region: if region.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(region.to_uppercase())
|
||||
},
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
language: tag.to_lowercase(),
|
||||
region: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The full BCP 47 tag (e.g. "en-US").
|
||||
pub fn tag(&self) -> String {
|
||||
match &self.region {
|
||||
Some(r) => format!("{}-{}", self.language, r),
|
||||
None => self.language.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The text direction for this locale.
|
||||
pub fn direction(&self) -> TextDirection {
|
||||
if self.is_rtl() {
|
||||
TextDirection::RightToLeft
|
||||
} else {
|
||||
TextDirection::LeftToRight
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this locale uses right-to-left script.
|
||||
pub fn is_rtl(&self) -> bool {
|
||||
// RTL language codes per Unicode CLDR
|
||||
matches!(
|
||||
self.language.as_str(),
|
||||
"ar" // Arabic
|
||||
| "he" | "iw" // Hebrew
|
||||
| "fa" | "per" // Persian/Farsi
|
||||
| "ur" // Urdu
|
||||
| "ps" // Pashto
|
||||
| "ug" // Uyghur
|
||||
| "yi" // Yiddish
|
||||
| "dv" // Maldivian/Dhivehi
|
||||
| "ku" // Kurdish (some scripts)
|
||||
| "sd" // Sindhi
|
||||
)
|
||||
}
|
||||
|
||||
/// English (US).
|
||||
pub fn en_us() -> Self {
|
||||
Self::new("en-US")
|
||||
}
|
||||
|
||||
/// English (GB).
|
||||
pub fn en_gb() -> Self {
|
||||
Self::new("en-GB")
|
||||
}
|
||||
|
||||
/// Arabic (a common RTL locale).
|
||||
pub fn ar() -> Self {
|
||||
Self::new("ar")
|
||||
}
|
||||
|
||||
/// Arabic (Saudi Arabia).
|
||||
pub fn ar_sa() -> Self {
|
||||
Self::new("ar-SA")
|
||||
}
|
||||
|
||||
/// Spanish (Spain).
|
||||
pub fn es_es() -> Self {
|
||||
Self::new("es-ES")
|
||||
}
|
||||
|
||||
/// French (France).
|
||||
pub fn fr_fr() -> Self {
|
||||
Self::new("fr-FR")
|
||||
}
|
||||
|
||||
/// Japanese.
|
||||
pub fn ja() -> Self {
|
||||
Self::new("ja")
|
||||
}
|
||||
|
||||
/// Chinese (Traditional).
|
||||
pub fn zh_hant() -> Self {
|
||||
Self::new("zh-Hant")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Locale {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.tag())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn locale_parse_with_region() {
|
||||
let l = Locale::new("en-US");
|
||||
assert_eq!(l.language, "en");
|
||||
assert_eq!(l.region, Some("US".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_parse_without_region() {
|
||||
let l = Locale::new("ja");
|
||||
assert_eq!(l.language, "ja");
|
||||
assert_eq!(l.region, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_tag_roundtrip() {
|
||||
let l = Locale::new("fr-FR");
|
||||
assert_eq!(l.tag(), "fr-FR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_is_rtl() {
|
||||
assert!(Locale::new("ar").is_rtl());
|
||||
assert!(Locale::new("ar-SA").is_rtl());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hebrew_is_rtl() {
|
||||
assert!(Locale::new("he").is_rtl());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persian_is_rtl() {
|
||||
assert!(Locale::new("fa").is_rtl());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_is_ltr() {
|
||||
assert!(!Locale::new("en").is_rtl());
|
||||
assert_eq!(Locale::new("en-US").direction(), TextDirection::LeftToRight);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_direction() {
|
||||
assert_eq!(Locale::ar().direction(), TextDirection::RightToLeft);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_display() {
|
||||
assert_eq!(format!("{}", Locale::en_us()), "en-US");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// el-i18n — Localization for el-ui.
|
||||
//
|
||||
// Two-letter language tag + optional region: en, en-US, ar-EG, zh-Hant.
|
||||
// Bundles map keys to either a plain string or a plural-form map.
|
||||
// `t(key)` and `t_plural(key, count)` are the surface API.
|
||||
|
||||
// ── Locale ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let DIR_LTR: String = "ltr"
|
||||
let DIR_RTL: String = "rtl"
|
||||
|
||||
type Locale {
|
||||
language: String // "en"
|
||||
region: String // "US" (may be empty)
|
||||
direction: String // "ltr" | "rtl"
|
||||
}
|
||||
|
||||
fn locale_new(language: String, region: String) -> Locale {
|
||||
let dir: String = "ltr"
|
||||
if str_eq(language, "ar") { let dir = "rtl" }
|
||||
if str_eq(language, "he") { let dir = "rtl" }
|
||||
if str_eq(language, "fa") { let dir = "rtl" }
|
||||
if str_eq(language, "ur") { let dir = "rtl" }
|
||||
{ "language": language, "region": region, "direction": dir }
|
||||
}
|
||||
|
||||
fn locale_en_us() -> Locale { locale_new("en", "US") }
|
||||
fn locale_es_es() -> Locale { locale_new("es", "ES") }
|
||||
fn locale_zh_cn() -> Locale { locale_new("zh", "CN") }
|
||||
fn locale_ar_eg() -> Locale { locale_new("ar", "EG") }
|
||||
fn locale_ja_jp() -> Locale { locale_new("ja", "JP") }
|
||||
|
||||
fn locale_tag(loc: Locale) -> String {
|
||||
if str_eq(loc.region, "") { return loc.language }
|
||||
loc.language + "-" + loc.region
|
||||
}
|
||||
|
||||
fn is_rtl(loc: Locale) -> Bool {
|
||||
str_eq(loc.direction, "rtl")
|
||||
}
|
||||
|
||||
// ── Plural forms (CLDR cardinal categories) ──────────────────────────────────
|
||||
//
|
||||
// Categories: zero, one, two, few, many, other.
|
||||
// Most languages only use one + other; Arabic uses all six.
|
||||
|
||||
let PLURAL_ZERO: String = "zero"
|
||||
let PLURAL_ONE: String = "one"
|
||||
let PLURAL_TWO: String = "two"
|
||||
let PLURAL_FEW: String = "few"
|
||||
let PLURAL_MANY: String = "many"
|
||||
let PLURAL_OTHER: String = "other"
|
||||
|
||||
fn plural_form(loc: Locale, n: Int) -> String {
|
||||
if str_eq(loc.language, "ar") { return plural_form_arabic(n) }
|
||||
if str_eq(loc.language, "ru") { return plural_form_russian(n) }
|
||||
if str_eq(loc.language, "pl") { return plural_form_polish(n) }
|
||||
if str_eq(loc.language, "ja") { return PLURAL_OTHER }
|
||||
if str_eq(loc.language, "zh") { return PLURAL_OTHER }
|
||||
if str_eq(loc.language, "ko") { return PLURAL_OTHER }
|
||||
// Default English-like rule
|
||||
if n == 1 { return PLURAL_ONE }
|
||||
PLURAL_OTHER
|
||||
}
|
||||
|
||||
fn plural_form_arabic(n: Int) -> String {
|
||||
if n == 0 { return PLURAL_ZERO }
|
||||
if n == 1 { return PLURAL_ONE }
|
||||
if n == 2 { return PLURAL_TWO }
|
||||
let mod100: Int = n - ((n / 100) * 100)
|
||||
if mod100 >= 3 {
|
||||
if mod100 <= 10 { return PLURAL_FEW }
|
||||
}
|
||||
if mod100 >= 11 {
|
||||
if mod100 <= 99 { return PLURAL_MANY }
|
||||
}
|
||||
PLURAL_OTHER
|
||||
}
|
||||
|
||||
fn plural_form_russian(n: Int) -> String {
|
||||
let mod10: Int = n - ((n / 10) * 10)
|
||||
let mod100: Int = n - ((n / 100) * 100)
|
||||
if mod10 == 1 {
|
||||
if mod100 == 11 { return PLURAL_MANY }
|
||||
return PLURAL_ONE
|
||||
}
|
||||
if mod10 >= 2 {
|
||||
if mod10 <= 4 {
|
||||
if mod100 >= 12 {
|
||||
if mod100 <= 14 { return PLURAL_MANY }
|
||||
}
|
||||
return PLURAL_FEW
|
||||
}
|
||||
}
|
||||
PLURAL_MANY
|
||||
}
|
||||
|
||||
fn plural_form_polish(n: Int) -> String {
|
||||
if n == 1 { return PLURAL_ONE }
|
||||
let mod10: Int = n - ((n / 10) * 10)
|
||||
let mod100: Int = n - ((n / 100) * 100)
|
||||
if mod10 >= 2 {
|
||||
if mod10 <= 4 {
|
||||
if mod100 >= 12 {
|
||||
if mod100 <= 14 { return PLURAL_MANY }
|
||||
}
|
||||
return PLURAL_FEW
|
||||
}
|
||||
}
|
||||
PLURAL_MANY
|
||||
}
|
||||
|
||||
// ── Translation bundle ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Stored as a JSON map: key -> value | { one: "...", other: "..." }
|
||||
// `bundle_load_toml` parses a TOML file at load time (planned runtime fn).
|
||||
|
||||
fn bundle_new() -> String {
|
||||
"{}"
|
||||
}
|
||||
|
||||
fn bundle_insert(bundle: String, key: String, value: String) -> String {
|
||||
json_set(bundle, key, "\"" + value + "\"")
|
||||
}
|
||||
|
||||
fn bundle_insert_plural(bundle: String, key: String, plural_map_json: String) -> String {
|
||||
json_set(bundle, key, plural_map_json)
|
||||
}
|
||||
|
||||
fn bundle_load_toml(path: String) -> String {
|
||||
let raw: String = fs_read(path)
|
||||
toml_to_json(raw)
|
||||
}
|
||||
|
||||
// ── LocaleContext + t/t_plural ──────────────────────────────────────────────
|
||||
|
||||
type LocaleContext {
|
||||
locale: Locale
|
||||
bundle: String // JSON
|
||||
fallback_bundle: String
|
||||
}
|
||||
|
||||
fn locale_context_new(loc: Locale, bundle: String) -> LocaleContext {
|
||||
{ "locale": loc, "bundle": bundle, "fallback_bundle": "{}" }
|
||||
}
|
||||
|
||||
fn t(ctx: LocaleContext, key: String) -> String {
|
||||
let v: String = json_get(ctx.bundle, key)
|
||||
if str_eq(v, "") { let v = json_get(ctx.fallback_bundle, key) }
|
||||
if str_eq(v, "") { return key }
|
||||
v
|
||||
}
|
||||
|
||||
fn t_plural(ctx: LocaleContext, key: String, n: Int) -> String {
|
||||
let entry: String = json_get(ctx.bundle, key)
|
||||
if str_eq(entry, "") { return key }
|
||||
let form: String = plural_form(ctx.locale, n)
|
||||
let template: String = json_get(entry, form)
|
||||
if str_eq(template, "") { let template = json_get(entry, PLURAL_OTHER) }
|
||||
if str_eq(template, "") { return key }
|
||||
str_replace(template, "{n}", int_to_str(n))
|
||||
}
|
||||
|
||||
// ── Number / currency formatting ─────────────────────────────────────────────
|
||||
|
||||
fn format_integer(loc: Locale, n: Int) -> String {
|
||||
// Group thousands by the locale's separator. Stub: en uses ',', most EU uses '.'.
|
||||
let sep: String = ","
|
||||
if str_eq(loc.language, "es") { let sep = "." }
|
||||
if str_eq(loc.language, "de") { let sep = "." }
|
||||
if str_eq(loc.language, "fr") { let sep = " " }
|
||||
int_with_separator(n, sep)
|
||||
}
|
||||
|
||||
fn format_number(loc: Locale, n: Int, fraction_digits: Int) -> String {
|
||||
let dec_sep: String = "."
|
||||
if str_eq(loc.language, "es") { let dec_sep = "," }
|
||||
if str_eq(loc.language, "de") { let dec_sep = "," }
|
||||
if str_eq(loc.language, "fr") { let dec_sep = "," }
|
||||
format_integer(loc, n) + dec_sep + repeat_str("0", fraction_digits)
|
||||
}
|
||||
|
||||
fn format_percent(loc: Locale, value_x100: Int) -> String {
|
||||
let body: String = int_to_str(value_x100 / 100) + "."
|
||||
+ int_to_str(value_x100 - ((value_x100 / 100) * 100))
|
||||
if str_eq(loc.language, "fr") { return body + " %" }
|
||||
body + "%"
|
||||
}
|
||||
|
||||
fn format_currency(loc: Locale, amount_minor: Int, iso: String) -> String {
|
||||
// amount_minor is in the smallest unit (cents). Stub formatting only.
|
||||
let major: Int = amount_minor / 100
|
||||
let minor: Int = amount_minor - (major * 100)
|
||||
let body: String = int_to_str(major) + "." + int_to_str(minor)
|
||||
if str_eq(iso, "USD") { return "$" + body }
|
||||
if str_eq(iso, "EUR") { return body + " €" }
|
||||
if str_eq(iso, "GBP") { return "£" + body }
|
||||
if str_eq(iso, "JPY") { return "¥" + int_to_str(amount_minor) }
|
||||
body + " " + iso
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ──────────────────────────────────────────────────────
|
||||
|
||||
let loc: Locale = locale_en_us()
|
||||
let bundle: String = bundle_new()
|
||||
let bundle = bundle_insert(bundle, "profile.follow", "Follow")
|
||||
let ctx: LocaleContext = locale_context_new(loc, bundle)
|
||||
println("[el-i18n] " + t(ctx, "profile.follow"))
|
||||
@@ -0,0 +1,189 @@
|
||||
/// Plural forms — handles language-specific plurality rules.
|
||||
///
|
||||
/// Different languages have very different plural forms. English has two
|
||||
/// (one / other). Russian has four. Arabic has six. This module maps counts
|
||||
/// to the correct form for a given locale.
|
||||
|
||||
/// Named plural categories per Unicode CLDR.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PluralForm {
|
||||
/// Exactly zero (some languages have a special zero form).
|
||||
Zero,
|
||||
/// Exactly one.
|
||||
One,
|
||||
/// Small numbers (e.g. 2–4 in Slavic languages).
|
||||
Two,
|
||||
/// Few (language-specific).
|
||||
Few,
|
||||
/// Many (language-specific).
|
||||
Many,
|
||||
/// The catch-all / default.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Determine the plural form for a count in a given language.
|
||||
///
|
||||
/// Rules are a simplified implementation of CLDR plural rules for the most
|
||||
/// common languages. The full CLDR spec covers hundreds of languages; these
|
||||
/// cover the major cases.
|
||||
pub fn plural_form(language: &str, count: i64) -> PluralForm {
|
||||
let n = count.unsigned_abs(); // absolute value for matching
|
||||
|
||||
match language {
|
||||
// English and most Western European languages: one / other
|
||||
"en" | "de" | "nl" | "sv" | "da" | "no" | "nb" | "nn" | "fi"
|
||||
| "et" | "hu" | "tr" | "pt" | "it" | "es" | "ca" | "el" | "id"
|
||||
| "ms" | "th" | "zh" | "ja" | "ko" | "vi" | "ur" => {
|
||||
if n == 1 { PluralForm::One } else { PluralForm::Other }
|
||||
}
|
||||
|
||||
// French: one for 0 and 1, other for rest
|
||||
"fr" => {
|
||||
if n <= 1 { PluralForm::One } else { PluralForm::Other }
|
||||
}
|
||||
|
||||
// Russian, Ukrainian, Belarusian: complex Slavic rules
|
||||
"ru" | "uk" | "be" => {
|
||||
let n10 = n % 10;
|
||||
let n100 = n % 100;
|
||||
if n10 == 1 && n100 != 11 {
|
||||
PluralForm::One
|
||||
} else if (2..=4).contains(&n10) && !(12..=14).contains(&n100) {
|
||||
PluralForm::Few
|
||||
} else {
|
||||
PluralForm::Many
|
||||
}
|
||||
}
|
||||
|
||||
// Polish: similar Slavic rules
|
||||
"pl" => {
|
||||
let n10 = n % 10;
|
||||
let n100 = n % 100;
|
||||
if n == 1 {
|
||||
PluralForm::One
|
||||
} else if (2..=4).contains(&n10) && !(12..=14).contains(&n100) {
|
||||
PluralForm::Few
|
||||
} else {
|
||||
PluralForm::Many
|
||||
}
|
||||
}
|
||||
|
||||
// Czech, Slovak
|
||||
"cs" | "sk" => {
|
||||
if n == 1 {
|
||||
PluralForm::One
|
||||
} else if (2..=4).contains(&n) {
|
||||
PluralForm::Few
|
||||
} else {
|
||||
PluralForm::Other
|
||||
}
|
||||
}
|
||||
|
||||
// Arabic: 6 forms
|
||||
"ar" => {
|
||||
let n100 = n % 100;
|
||||
if n == 0 {
|
||||
PluralForm::Zero
|
||||
} else if n == 1 {
|
||||
PluralForm::One
|
||||
} else if n == 2 {
|
||||
PluralForm::Two
|
||||
} else if (3..=10).contains(&n100) {
|
||||
PluralForm::Few
|
||||
} else if (11..=99).contains(&n100) {
|
||||
PluralForm::Many
|
||||
} else {
|
||||
PluralForm::Other
|
||||
}
|
||||
}
|
||||
|
||||
// Hebrew
|
||||
"he" | "iw" => {
|
||||
if n == 1 {
|
||||
PluralForm::One
|
||||
} else if n == 2 {
|
||||
PluralForm::Two
|
||||
} else if n >= 11 && n % 10 == 0 {
|
||||
PluralForm::Many
|
||||
} else {
|
||||
PluralForm::Other
|
||||
}
|
||||
}
|
||||
|
||||
// Default: one / other
|
||||
_ => {
|
||||
if n == 1 { PluralForm::One } else { PluralForm::Other }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn english_one() {
|
||||
assert_eq!(plural_form("en", 1), PluralForm::One);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_other() {
|
||||
assert_eq!(plural_form("en", 0), PluralForm::Other);
|
||||
assert_eq!(plural_form("en", 2), PluralForm::Other);
|
||||
assert_eq!(plural_form("en", 100), PluralForm::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn french_zero_is_one() {
|
||||
assert_eq!(plural_form("fr", 0), PluralForm::One);
|
||||
assert_eq!(plural_form("fr", 1), PluralForm::One);
|
||||
assert_eq!(plural_form("fr", 2), PluralForm::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_one() {
|
||||
assert_eq!(plural_form("ru", 1), PluralForm::One);
|
||||
assert_eq!(plural_form("ru", 21), PluralForm::One);
|
||||
assert_eq!(plural_form("ru", 101), PluralForm::One);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_few() {
|
||||
assert_eq!(plural_form("ru", 2), PluralForm::Few);
|
||||
assert_eq!(plural_form("ru", 3), PluralForm::Few);
|
||||
assert_eq!(plural_form("ru", 22), PluralForm::Few);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_many() {
|
||||
assert_eq!(plural_form("ru", 5), PluralForm::Many);
|
||||
assert_eq!(plural_form("ru", 11), PluralForm::Many);
|
||||
assert_eq!(plural_form("ru", 20), PluralForm::Many);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_zero() {
|
||||
assert_eq!(plural_form("ar", 0), PluralForm::Zero);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_two() {
|
||||
assert_eq!(plural_form("ar", 2), PluralForm::Two);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_few() {
|
||||
assert_eq!(plural_form("ar", 5), PluralForm::Few);
|
||||
assert_eq!(plural_form("ar", 10), PluralForm::Few);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_many() {
|
||||
assert_eq!(plural_form("ar", 15), PluralForm::Many);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hebrew_two() {
|
||||
assert_eq!(plural_form("he", 2), PluralForm::Two);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/// The t() translation function and LocaleContext.
|
||||
///
|
||||
/// Components call `ctx.t("key")` or `ctx.t_plural("key", count)`.
|
||||
/// The context flows down from the experience root and carries the active bundle.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use crate::bundle::LocaleBundle;
|
||||
use crate::locale::Locale;
|
||||
|
||||
/// The active localization context.
|
||||
///
|
||||
/// Holds the current locale and its translation bundle. Passed down
|
||||
/// through the component tree. When the locale changes, the context
|
||||
/// is updated and all components that used it re-render.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocaleContext {
|
||||
pub locale: Locale,
|
||||
bundle: LocaleBundle,
|
||||
/// Fallback bundle (typically English) used when a key is missing.
|
||||
fallback: Option<LocaleBundle>,
|
||||
}
|
||||
|
||||
impl LocaleContext {
|
||||
/// Create a context with a locale and its bundle.
|
||||
pub fn new(locale: Locale, bundle: LocaleBundle) -> Self {
|
||||
Self {
|
||||
locale,
|
||||
bundle,
|
||||
fallback: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a fallback bundle for missing keys.
|
||||
pub fn with_fallback(mut self, fallback: LocaleBundle) -> Self {
|
||||
self.fallback = Some(fallback);
|
||||
self
|
||||
}
|
||||
|
||||
/// Translate a key with optional variable interpolation.
|
||||
///
|
||||
/// Returns the key itself if not found (never panics).
|
||||
pub fn t(&self, key: &str) -> String {
|
||||
self.t_vars(key, &HashMap::new())
|
||||
}
|
||||
|
||||
/// Translate a key with variables.
|
||||
pub fn t_vars(&self, key: &str, vars: &HashMap<&str, String>) -> String {
|
||||
// Try primary bundle
|
||||
if let Some(s) = self.bundle.translate(key, vars) {
|
||||
return s;
|
||||
}
|
||||
// Try fallback bundle
|
||||
if let Some(ref fb) = self.fallback {
|
||||
if let Some(s) = fb.translate(key, vars) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
// Return the key itself — visible but never panics
|
||||
key.to_string()
|
||||
}
|
||||
|
||||
/// Translate a plural key with a count.
|
||||
pub fn t_plural(&self, key: &str, count: i64) -> String {
|
||||
self.t_plural_vars(key, count, &HashMap::new())
|
||||
}
|
||||
|
||||
/// Translate a plural key with a count and extra variables.
|
||||
pub fn t_plural_vars(
|
||||
&self,
|
||||
key: &str,
|
||||
count: i64,
|
||||
vars: &HashMap<&str, String>,
|
||||
) -> String {
|
||||
if let Some(s) = self.bundle.translate_plural(key, count, vars) {
|
||||
return s;
|
||||
}
|
||||
if let Some(ref fb) = self.fallback {
|
||||
if let Some(s) = fb.translate_plural(key, count, vars) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
key.to_string()
|
||||
}
|
||||
|
||||
/// The current locale tag (e.g. "en-US").
|
||||
pub fn locale_tag(&self) -> String {
|
||||
self.locale.tag()
|
||||
}
|
||||
|
||||
/// Whether the current locale is RTL.
|
||||
pub fn is_rtl(&self) -> bool {
|
||||
self.locale.is_rtl()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience macro for translation calls.
|
||||
///
|
||||
/// ```ignore
|
||||
/// // Simple
|
||||
/// let text = t!(ctx, "profile.follow");
|
||||
///
|
||||
/// // With variables
|
||||
/// let text = t!(ctx, "greeting", name => "Alice");
|
||||
///
|
||||
/// // Plural
|
||||
/// let text = t_n!(ctx, "profile.followers", count);
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! t {
|
||||
($ctx:expr, $key:expr) => {
|
||||
$ctx.t($key)
|
||||
};
|
||||
($ctx:expr, $key:expr, $($var:ident => $val:expr),+) => {{
|
||||
let mut vars = std::collections::HashMap::new();
|
||||
$(vars.insert(stringify!($var), $val.to_string());)+
|
||||
$ctx.t_vars($key, &vars)
|
||||
}};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! t_n {
|
||||
($ctx:expr, $key:expr, $count:expr) => {
|
||||
$ctx.t_plural($key, $count as i64)
|
||||
};
|
||||
($ctx:expr, $key:expr, $count:expr, $($var:ident => $val:expr),+) => {{
|
||||
let mut vars = std::collections::HashMap::new();
|
||||
$(vars.insert(stringify!($var), $val.to_string());)+
|
||||
$ctx.t_plural_vars($key, $count as i64, &vars)
|
||||
}};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::bundle::LocaleBundle;
|
||||
|
||||
fn make_ctx() -> LocaleContext {
|
||||
let mut bundle = LocaleBundle::new(Locale::en_us());
|
||||
bundle.insert("profile.follow", "Follow");
|
||||
bundle.insert("greeting", "Hello, {name}!");
|
||||
let mut forms = HashMap::new();
|
||||
forms.insert("one".to_string(), "{n} Follower".to_string());
|
||||
forms.insert("other".to_string(), "{n} Followers".to_string());
|
||||
bundle.insert_plural("profile.followers", forms);
|
||||
LocaleContext::new(Locale::en_us(), bundle)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_simple() {
|
||||
let ctx = make_ctx();
|
||||
assert_eq!(ctx.t("profile.follow"), "Follow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_missing_returns_key() {
|
||||
let ctx = make_ctx();
|
||||
assert_eq!(ctx.t("no.such.key"), "no.such.key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_vars_interpolation() {
|
||||
let ctx = make_ctx();
|
||||
let mut vars = HashMap::new();
|
||||
vars.insert("name", "Bob".to_string());
|
||||
assert_eq!(ctx.t_vars("greeting", &vars), "Hello, Bob!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_plural_one() {
|
||||
let ctx = make_ctx();
|
||||
assert_eq!(ctx.t_plural("profile.followers", 1), "1 Follower");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_plural_many() {
|
||||
let ctx = make_ctx();
|
||||
assert_eq!(ctx.t_plural("profile.followers", 42), "42 Followers");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_rtl_english() {
|
||||
let ctx = make_ctx();
|
||||
assert!(!ctx.is_rtl());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_rtl_arabic() {
|
||||
let bundle = LocaleBundle::new(Locale::ar());
|
||||
let ctx = LocaleContext::new(Locale::ar(), bundle);
|
||||
assert!(ctx.is_rtl());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_bundle_used() {
|
||||
let primary_bundle = LocaleBundle::new(Locale::new("fr"));
|
||||
// Key not in French bundle
|
||||
let mut fallback = LocaleBundle::new(Locale::en_us());
|
||||
fallback.insert("app.name", "My App");
|
||||
let ctx = LocaleContext::new(Locale::new("fr"), primary_bundle)
|
||||
.with_fallback(fallback);
|
||||
assert_eq!(ctx.t("app.name"), "My App");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_tag() {
|
||||
let ctx = make_ctx();
|
||||
assert_eq!(ctx.locale_tag(), "en-US");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user