Menu
Public repository / master
AkurAI-Framework / crates/cli/src/i18n.rs
Latest change 68c23789cce8e807c988190dcb1e6a3699cd5ed6 - OLI-156: path-prefix locales, hreflang alternates, sitemap xhtml:link by Ólafur Búi Ólafsson
//! i18n wiring for the CLI: locale negotiation + render-context injection.
//!
//! [`I18nState`] is loaded once at `serve` / `dev` startup from
//! `backend/locales/` and held in the server closure. Each request calls
//! [`I18nState::extras`] to build the `__locale` + `__messages` context
//! extras that the template engine reads via `{{ t "key" }}`.
//!
//! When `backend/locales/` is absent the state is `None` and all render
//! paths behave exactly as before (zero behaviour change).
#![forbid(unsafe_code)]
use akurai_i18n::Catalogs;
use akurai_json::Value;
use std::collections::HashMap;
use std::path::Path;
// ── I18nState ──────────────────────────────────────────────────────────────
/// Loaded i18n state: a validated [`Catalogs`] (for default-locale detection
/// and fallback-aware translation) plus raw JSON message maps (to build the
/// `__messages` object for every key in the active locale).
pub struct I18nState {
/// Validated catalogs — `default_locale()` and `translate()` with fallback.
catalogs: Catalogs,
/// `locale → Value::Object(key → Str)` — raw parsed message maps.
raw: HashMap<String, Value>,
/// Sorted list of available locale codes (e.g. `["en", "is"]`).
locales: Vec<String>,
}
impl I18nState {
/// Load from `dir`, returning `None` if the directory does not exist, is
/// empty of `.json` files, or any file fails to parse.
///
/// Emits a startup warning to stderr on parse failure instead of aborting.
pub fn load(dir: &Path) -> Option<I18nState> {
if !dir.is_dir() {
return None;
}
// Delegate validation + default-locale logic to the i18n crate.
let catalogs = match Catalogs::load_dir(dir) {
Ok(c) => c,
Err(e) => {
eprintln!(" i18n: failed to load catalogs: {e}");
return None;
}
};
// Build raw message maps for __messages injection.
let mut raw: HashMap<String, Value> = HashMap::new();
let mut locales: Vec<String> = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return None;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Some(locale) = path
.file_stem()
.and_then(|s| s.to_str())
.map(str::to_string)
else {
continue;
};
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(value) = akurai_json::parse(&text) else {
continue;
};
locales.push(locale.clone());
raw.insert(locale, value);
}
locales.sort();
Some(I18nState {
catalogs,
raw,
locales,
})
}
/// Sorted available locale codes.
pub fn locales(&self) -> &[String] {
&self.locales
}
/// The catalog default locale (served at `/` in path-prefix mode).
pub fn default_locale(&self) -> &str {
self.catalogs.default_locale()
}
/// Build the `__locale` + `__messages` context extras for the given
/// `Accept-Language` header value (may be empty or the empty string for
/// default-locale rendering).
pub fn extras(&self, accept_language: &str) -> Vec<(String, Value)> {
let avail: Vec<&str> = self.locales.iter().map(String::as_str).collect();
let locale = negotiate_locale(accept_language, &avail, self.catalogs.default_locale());
let messages = self.messages_object(&locale);
vec![
("__locale".into(), Value::Str(locale)),
("__messages".into(), messages),
]
}
/// Build extras for an explicit language preference (the `akurai-lang`
/// cookie set by the language toggle). When `lang` is `None` or not an
/// available locale, the catalog's **default locale** is used — so the suite
/// defaults to its default language (Icelandic) regardless of the browser's
/// `Accept-Language`, and the user's toggle is the only override.
pub fn extras_for_lang(&self, lang: Option<&str>) -> Vec<(String, Value)> {
let locale = lang
.and_then(|l| {
self.locales
.iter()
.find(|a| a.eq_ignore_ascii_case(l))
.cloned()
})
.unwrap_or_else(|| self.catalogs.default_locale().to_string());
let messages = self.messages_object(&locale);
vec![
("__locale".into(), Value::Str(locale)),
("__messages".into(), messages),
]
}
/// Build a `Value::Object` from all known message keys for `locale`,
/// using the catalog's fallback chain (locale → default → key).
fn messages_object(&self, locale: &str) -> Value {
// Collect the union of all keys across all locale catalogs.
let mut all_keys: Vec<String> = Vec::new();
for v in self.raw.values() {
if let Value::Object(pairs) = v {
for (k, _) in pairs {
if !all_keys.contains(k) {
all_keys.push(k.clone());
}
}
}
}
all_keys.sort();
let pairs: Vec<(String, Value)> = all_keys
.into_iter()
.map(|key| {
let translated = self.catalogs.translate(locale, &key).to_string();
(key, Value::Str(translated))
})
.collect();
Value::Object(pairs)
}
}
// ── negotiate_locale ────────────────────────────────────────────────────────
/// Pick the best locale from an `Accept-Language` header.
///
/// # Algorithm
/// 1. Parse comma-separated language tags; extract `;q=` quality factors
/// (default `1.0`).
/// 2. Sort descending by quality (stable — ties preserve wire order).
/// 3. For each tag in order:
/// a. Try an exact case-insensitive match against `available`.
/// b. Try the primary subtag only (e.g. `is-IS` → `is`).
/// 4. Fall back to `default` if nothing matched or the header was empty.
///
/// Returns an owned `String` (clone of the matched entry or `default`).
pub fn negotiate_locale(accept_language: &str, available: &[&str], default: &str) -> String {
if accept_language.trim().is_empty() || available.is_empty() {
return default.to_string();
}
// Parse tags with quality weights.
let mut tags: Vec<(String, f32)> = accept_language
.split(',')
.filter_map(|part| {
let part = part.trim();
if part.is_empty() {
return None;
}
let (tag_raw, q) = match part.split_once(';') {
Some((t, params)) => {
let q_val = params
.split(';')
.find_map(|p| {
p.trim()
.strip_prefix("q=")
.and_then(|v| v.parse::<f32>().ok())
})
.unwrap_or(1.0_f32);
(t.trim(), q_val)
}
None => (part, 1.0_f32),
};
if tag_raw.is_empty() {
return None;
}
Some((tag_raw.to_ascii_lowercase(), q))
})
.collect();
// Stable sort descending by quality.
tags.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
// Match tags against available locales.
for (tag, _) in &tags {
// Exact match (case-insensitive).
if let Some(locale) = available.iter().find(|l| l.to_ascii_lowercase() == *tag) {
return locale.to_string();
}
// Primary subtag match: `is-IS` → try `is`.
let primary = tag.split('-').next().unwrap_or(tag.as_str());
if let Some(locale) = available.iter().find(|l| l.to_ascii_lowercase() == primary) {
return locale.to_string();
}
}
default.to_string()
}
// ── tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// ── negotiate_locale unit tests ────────────────────────────────────────
#[test]
fn empty_header_returns_default() {
assert_eq!(negotiate_locale("", &["en", "is"], "en"), "en");
}
#[test]
fn whitespace_only_returns_default() {
assert_eq!(negotiate_locale(" ", &["en", "is"], "en"), "en");
}
#[test]
fn simple_match() {
assert_eq!(negotiate_locale("is", &["en", "is"], "en"), "is");
}
#[test]
fn highest_quality_wins() {
// `is` at implicit q=1.0 > `en` at q=0.9
assert_eq!(negotiate_locale("is, en;q=0.9", &["en", "is"], "en"), "is");
}
#[test]
fn explicit_quality_ordering() {
// en;q=0.8 < is;q=0.9 even though en appears first
assert_eq!(
negotiate_locale("en;q=0.8, is;q=0.9", &["en", "is"], "en"),
"is"
);
}
#[test]
fn primary_subtag_match() {
// `is-IS` has no direct match, but primary `is` is available
assert_eq!(negotiate_locale("is-IS", &["en", "is"], "en"), "is");
}
#[test]
fn primary_subtag_with_quality() {
assert_eq!(
negotiate_locale("is-IS;q=0.9, en;q=0.5", &["en", "is"], "en"),
"is"
);
}
#[test]
fn no_match_returns_default() {
assert_eq!(negotiate_locale("fr, de", &["en", "is"], "en"), "en");
}
#[test]
fn case_insensitive_exact_match() {
assert_eq!(negotiate_locale("IS", &["en", "is"], "en"), "is");
assert_eq!(negotiate_locale("EN", &["en", "is"], "en"), "en");
}
#[test]
fn empty_available_returns_default() {
assert_eq!(negotiate_locale("is, en", &[], "en"), "en");
}
#[test]
fn wildcard_star_falls_through_to_default() {
// `*` is not in available, falls back to default
assert_eq!(negotiate_locale("*", &["en", "is"], "en"), "en");
}
#[test]
fn multiple_candidates_first_available_wins() {
// fr not available, de not available, en available
assert_eq!(
negotiate_locale("fr, de, en;q=0.5", &["en", "is"], "en"),
"en"
);
}
// ── I18nState integration tests ────────────────────────────────────────
/// Scratch base using CARGO_TARGET_TMPDIR when set, otherwise crate target/.
fn scratch_base() -> std::path::PathBuf {
match std::env::var_os("CARGO_TARGET_TMPDIR") {
Some(dir) => std::path::PathBuf::from(dir),
None => std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("i18n-test-tmp"),
}
}
fn make_locales_dir(name: &str, files: &[(&str, &str)]) -> std::path::PathBuf {
let dir = scratch_base().join(name);
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for (filename, content) in files {
std::fs::write(dir.join(filename), content).unwrap();
}
dir
}
#[test]
fn load_none_when_dir_absent() {
let dir = scratch_base().join("no-such-dir-i18n");
assert!(I18nState::load(&dir).is_none());
}
#[test]
fn load_and_extras_inject_locale_and_messages() {
let dir = make_locales_dir(
"i18n_inject",
&[
(
"en.json",
r#"{"footer.tagline":"A pure-Rust framework.","nav.docs":"Docs"}"#,
),
(
"is.json",
r#"{"footer.tagline":"Hrein Rust umgjörð.","nav.docs":"Skjöl"}"#,
),
],
);
let state = I18nState::load(&dir).expect("should load");
// Icelandic extras.
let extras = state.extras("is");
let locale = extras
.iter()
.find(|(k, _)| k == "__locale")
.map(|(_, v)| v.as_str().unwrap_or(""))
.unwrap_or("");
assert_eq!(locale, "is");
let messages = extras
.iter()
.find(|(k, _)| k == "__messages")
.map(|(_, v)| v)
.expect("__messages present");
let tagline = messages
.get("footer.tagline")
.and_then(|v| v.as_str())
.unwrap_or("");
assert_eq!(tagline, "Hrein Rust umgjörð.");
// English extras.
let extras_en = state.extras("en");
let messages_en = extras_en
.iter()
.find(|(k, _)| k == "__messages")
.map(|(_, v)| v)
.expect("__messages present");
let tagline_en = messages_en
.get("footer.tagline")
.and_then(|v| v.as_str())
.unwrap_or("");
assert_eq!(tagline_en, "A pure-Rust framework.");
}
#[test]
fn extras_negotiate_from_accept_language_header() {
let dir = make_locales_dir(
"i18n_negotiate_header",
&[
("en.json", r#"{"key":"hello"}"#),
("is.json", r#"{"key":"halló"}"#),
],
);
let state = I18nState::load(&dir).expect("should load");
// is-IS primary subtag → is
let extras = state.extras("is-IS, en;q=0.5");
let locale = extras
.iter()
.find(|(k, _)| k == "__locale")
.and_then(|(_, v)| v.as_str())
.unwrap_or("");
assert_eq!(locale, "is");
}
#[test]
fn missing_key_falls_back_to_key_text() {
let dir = make_locales_dir(
"i18n_fallback_key",
&[("en.json", r#"{"known":"Known value"}"#)],
);
let state = I18nState::load(&dir).expect("should load");
let extras = state.extras("");
let messages = extras
.iter()
.find(|(k, _)| k == "__messages")
.map(|(_, v)| v)
.expect("__messages");
// `known` is present
assert_eq!(
messages.get("known").and_then(|v| v.as_str()),
Some("Known value")
);
}
#[test]
fn template_render_with_injected_messages() {
use akurai_json::Value;
use akurai_template::Engine;
let dir = make_locales_dir(
"i18n_template_render",
&[
("en.json", r#"{"footer.tagline":"A pure-Rust framework."}"#),
("is.json", r#"{"footer.tagline":"Hrein Rust umgjörð."}"#),
],
);
let state = I18nState::load(&dir).expect("should load");
let mut engine = Engine::new();
engine
.register("footer", r#"<p>{{ t "footer.tagline" }}</p>"#)
.expect("register");
// Render with Icelandic extras injected into context.
let extras = state.extras("is");
let mut ctx_pairs: Vec<(String, Value)> = vec![];
ctx_pairs.extend(extras);
let ctx = Value::Object(ctx_pairs);
let html = engine.render("footer", &ctx).expect("render");
assert_eq!(html, "<p>Hrein Rust umgjörð.</p>");
// Render with English extras.
let extras_en = state.extras("en");
let ctx_en = Value::Object(extras_en);
let html_en = engine.render("footer", &ctx_en).expect("render");
assert_eq!(html_en, "<p>A pure-Rust framework.</p>");
}
}