use std::collections::HashMap;
use std::path::Path;
use tracing::warn;
use issuerd_core::{Realm, User};
pub use issuerd_core::i18n::SHIPPED_LOCALES;
pub const DEFAULT_LOCALE: &str = "en";
mod bundles {
pub const EN: &str = include_str!("../i18n/messages_en.json");
pub const DE: &str = include_str!("../i18n/messages_de.json");
}
pub type MessageBundle = HashMap<String, String>;
fn parse_bundle(json: &str) -> MessageBundle {
serde_json::from_str(json).unwrap_or_default()
}
fn builtin_bundle(locale: &str) -> Option<MessageBundle> {
match locale {
"en" => Some(parse_bundle(bundles::EN)),
"de" => Some(parse_bundle(bundles::DE)),
_ => None,
}
}
fn language_subtag(tag: &str) -> &str {
tag.split(['-', '_']).next().unwrap_or(tag)
}
pub fn parse_accept_language(header: &str) -> Vec<String> {
let mut parsed: Vec<(f32, usize, String)> = header
.split(',')
.enumerate()
.filter_map(|(idx, part)| {
let part = part.trim();
if part.is_empty() {
return None;
}
let (tag, q) = match part.split_once(';') {
Some((tag, params)) => {
let q = params
.trim()
.strip_prefix("q=")
.and_then(|v| v.parse::<f32>().ok())
.unwrap_or(1.0);
(tag.trim(), q)
}
None => (part, 1.0),
};
if tag.is_empty() {
return None;
}
Some((q, idx, tag.to_string()))
})
.collect();
parsed.sort_by(|a, b| {
b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal).then(a.1.cmp(&b.1))
});
parsed.into_iter().map(|(_, _, tag)| tag).collect()
}
pub fn resolve_locale(
realm: &Realm,
ui_locales: &[String],
accept_language: Option<&str>,
) -> String {
if !realm.internationalization_enabled {
return DEFAULT_LOCALE.to_string();
}
let allowed = |tag: &str| {
realm.supported_locales.is_empty()
|| realm
.supported_locales
.iter()
.any(|s| language_subtag(s) == language_subtag(tag) || s == tag)
};
for candidate in ui_locales {
if allowed(candidate) {
return candidate.clone();
}
}
if let Some(header) = accept_language {
for candidate in parse_accept_language(header) {
if candidate == "*" {
continue;
}
if allowed(&candidate) {
return candidate;
}
}
}
realm.default_locale.clone().unwrap_or_else(|| DEFAULT_LOCALE.to_string())
}
pub fn user_locale(realm: &Realm, user: &User) -> String {
if !realm.internationalization_enabled {
return DEFAULT_LOCALE.to_string();
}
if let Some(loc) = user
.attributes
.get("locale")
.and_then(|v| v.first())
.map(String::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
{
let allowed = realm.supported_locales.is_empty()
|| realm
.supported_locales
.iter()
.any(|s| language_subtag(s) == language_subtag(loc) || s == loc);
if allowed {
return loc.to_string();
}
}
realm.default_locale.clone().unwrap_or_else(|| DEFAULT_LOCALE.to_string())
}
pub fn message_bundle(
themes_dir: Option<&Path>,
theme: Option<&str>,
locale: &str,
) -> MessageBundle {
let mut bundle = builtin_bundle(DEFAULT_LOCALE).unwrap_or_default();
let lang = language_subtag(locale);
if lang != DEFAULT_LOCALE {
if let Some(overlay) = builtin_bundle(lang) {
bundle.extend(overlay);
}
}
if let (Some(dir), Some(theme_name)) = (themes_dir, theme) {
for layer in [DEFAULT_LOCALE, lang] {
let path = dir.join(theme_name).join(format!("messages_{layer}.json"));
if let Ok(content) = std::fs::read_to_string(&path) {
match serde_json::from_str::<MessageBundle>(&content) {
Ok(overlay) => bundle.extend(overlay),
Err(e) => {
warn!(path = %path.display(), error = %e, "ignoring malformed theme message bundle")
}
}
}
}
}
bundle
}
pub fn msg<'a>(bundle: &'a MessageBundle, key: &str, default: &'a str) -> String {
bundle.get(key).cloned().unwrap_or_else(|| default.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn realm_with(i18n: bool, supported: &[&str], default: Option<&str>) -> Realm {
Realm {
internationalization_enabled: i18n,
supported_locales: supported.iter().map(|s| s.to_string()).collect(),
default_locale: default.map(|s| s.to_string()),
..Realm::default()
}
}
#[test]
fn accept_language_parsing_orders_by_quality() {
assert_eq!(parse_accept_language("de, en-US;q=0.9, en;q=0.8"), vec!["de", "en-US", "en"]);
assert_eq!(parse_accept_language("en-US"), vec!["en-US"]);
assert!(parse_accept_language("").is_empty());
assert_eq!(parse_accept_language("en;q=0.5, de;q=0.9"), vec!["de", "en"]);
}
#[test]
fn i18n_disabled_always_resolves_en() {
let realm = realm_with(false, &["de"], Some("de"));
assert_eq!(resolve_locale(&realm, &["de".to_string()], Some("de")), "en");
}
#[test]
fn ui_locales_win_over_header_and_default() {
let realm = realm_with(true, &["en", "de"], Some("en"));
assert_eq!(resolve_locale(&realm, &["de".to_string()], Some("fr"),), "de");
}
#[test]
fn accept_language_beats_realm_default() {
let realm = realm_with(true, &["en", "de"], Some("de"));
assert_eq!(resolve_locale(&realm, &[], Some("en-US, en;q=0.9")), "en-US");
}
#[test]
fn unsupported_candidates_fall_through_to_default() {
let realm = realm_with(true, &["en"], Some("en"));
assert_eq!(resolve_locale(&realm, &["de".to_string()], Some("de")), "en");
}
#[test]
fn no_candidates_uses_realm_default_then_en() {
let with_default = realm_with(true, &["de"], Some("de"));
assert_eq!(resolve_locale(&with_default, &[], None), "de");
let without_default = realm_with(true, &[], None);
assert_eq!(resolve_locale(&without_default, &[], None), "en");
}
#[test]
fn region_subtag_matches_supported_language() {
let realm = realm_with(true, &["de"], None);
assert_eq!(resolve_locale(&realm, &["de-DE".to_string()], None), "de-DE");
}
#[test]
fn builtin_bundles_cover_same_keys() {
let en = builtin_bundle("en").unwrap();
let de = builtin_bundle("de").unwrap();
let missing: Vec<_> = en.keys().filter(|k| !de.contains_key(*k)).collect();
assert!(missing.is_empty(), "de bundle missing keys: {missing:?}");
}
#[test]
fn unknown_locale_falls_back_to_english_bundle() {
let bundle = message_bundle(None, None, "fr-FR");
assert_eq!(bundle.get("login.title").map(String::as_str), Some("Sign In"));
}
#[test]
fn german_bundle_overrides_english_keys() {
let bundle = message_bundle(None, None, "de-DE");
let title = bundle.get("login.title").unwrap();
assert_ne!(title, "Sign In");
assert!(bundle.contains_key("consent.allow"));
}
}