feat(i18n): add i18n on server side

- remove the hardcoded list of locales in favor of a discovry on start time
    - server will stop on badly formatted locale .json
    - add server.* entries for serer side translation

    server side translation will be used for templating and email
    note: no json in some embded html (like in /magic), amount of work was similar
This commit is contained in:
Edouard Vanbelle
2026-06-03 13:18:05 +02:00
parent e0f59aa942
commit 044bd76738
32 changed files with 1520 additions and 149 deletions
+159
View File
@@ -0,0 +1,159 @@
//! Locale negotiation for anonymous HTTP requests.
//!
//! Resolves the request's preferred locale, in priority order:
//!
//! 1. `?lang=fr` query parameter — explicit override, used by manual
//! testing and any future "language switcher" link on a public
//! page. Must match the registry; unknown values fall through.
//! 2. `Accept-Language` header — RFC 9110 quality-weighted list, the
//! standard browser-driven signal.
//! 3. The configured server default (`OXICLOUD_DEFAULT_LOCALE`), which
//! is always present in the registry by construction.
//!
//! Wire it as a regular Axum extractor on a handler that needs the
//! caller's locale: the `AppState` carries the [`LocaleRegistry`], so
//! handlers don't have to plumb anything else through.
//!
//! Authenticated requests should NOT use this extractor — their locale
//! comes from `user.preferred_locale` resolved at the service layer.
//! This extractor is for anonymous surfaces (magic-link landing pages,
//! the public login page) where no user row is available yet.
use std::sync::Arc;
use axum::extract::{FromRequestParts, Query};
use axum::http::request::Parts;
use serde::Deserialize;
use crate::common::di::AppState;
use crate::common::locale::Locale;
/// Negotiated locale for the current request.
#[derive(Debug, Clone)]
pub struct RequestLocale(pub Locale);
#[derive(Debug, Deserialize)]
struct LangOverride {
lang: Option<String>,
}
impl FromRequestParts<Arc<AppState>> for RequestLocale {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
let registry = &state.locale_registry;
// Priority 1 — explicit `?lang=` override. Parse failures or
// missing values just fall through to the next signal.
if let Ok(Query(LangOverride { lang: Some(code) })) =
Query::<LangOverride>::try_from_uri(&parts.uri)
&& let Some(locale) = registry.parse(&code)
{
return Ok(RequestLocale(locale));
}
// Priority 2 — Accept-Language. Use the `accept-language` crate
// for RFC-9110 q-value parsing; we pass the registry's codes
// as the supported list, so the crate hands us back the
// strongest match. The empty-list case falls through.
if let Some(header_value) = parts
.headers
.get(axum::http::header::ACCEPT_LANGUAGE)
.and_then(|v| v.to_str().ok())
{
let supported_owned: Vec<String> =
registry.iter().map(|l| l.as_str().to_string()).collect();
let supported: Vec<&str> = supported_owned.iter().map(String::as_str).collect();
if let Some(matched) = accept_language::intersection(header_value, &supported).first()
&& let Some(locale) = registry.parse(matched)
{
return Ok(RequestLocale(locale));
}
// Some browsers send only a primary tag (`fr`) when the
// user is on `fr-FR`; `intersection` is exact-tag, so a
// server that ships `fr-FR.json` but not `fr.json` (or
// vice-versa) needs a fallback. Walk the parsed list once
// more, this time stripping the subtag.
for raw in accept_language::parse(header_value) {
let primary = raw.split('-').next().unwrap_or(&raw);
if let Some(locale) = registry.parse(primary) {
return Ok(RequestLocale(locale));
}
}
}
// Priority 3 — configured default. Guaranteed to be in the
// registry (validated at startup).
Ok(RequestLocale(registry.default_locale().clone()))
}
}
impl RequestLocale {
/// Borrow the resolved locale.
pub fn locale(&self) -> &Locale {
&self.0
}
/// Move the resolved locale out of the extractor.
pub fn into_inner(self) -> Locale {
self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::locale::LocaleRegistry;
use std::fs;
use std::io::Write;
/// Smoke test for the priority logic, exercised against the
/// registry's `parse` directly so we don't need a full `AppState`
/// to assert behaviour. The extractor's prose above describes the
/// negotiation order; this test just locks in the building blocks.
fn registry_with(codes: &[&str], default: &str) -> Arc<LocaleRegistry> {
let dir = tempfile::tempdir().expect("tempdir");
for code in codes {
let path = dir.path().join(format!("{}.json", code));
let mut f = fs::File::create(&path).expect("create");
f.write_all(b"{}").expect("write");
}
let reg = LocaleRegistry::discover(dir.path(), default).expect("registry");
// Leak the tempdir for the lifetime of the test — `discover`
// already finished its filesystem work, so we just need the
// registry to outlive the call.
std::mem::forget(dir);
Arc::new(reg)
}
#[test]
fn registry_supplies_supported_list_for_intersection() {
let reg = registry_with(&["en", "fr", "de"], "en");
let owned: Vec<String> = reg.iter().map(|l| l.as_str().to_string()).collect();
let supported: Vec<&str> = owned.iter().map(String::as_str).collect();
let pick = accept_language::intersection("de, fr;q=0.9", &supported);
assert_eq!(pick.first().map(String::as_str), Some("de"));
}
#[test]
fn primary_tag_fallback_when_subtag_missing() {
let reg = registry_with(&["en", "fr"], "en");
let owned: Vec<String> = reg.iter().map(|l| l.as_str().to_string()).collect();
let supported: Vec<&str> = owned.iter().map(String::as_str).collect();
// Exact `fr-FR` is not in the registry; intersection returns
// empty, but the primary-tag walk hits `fr`.
let pick = accept_language::intersection("fr-FR", &supported);
assert!(pick.is_empty());
for raw in accept_language::parse("fr-FR") {
let primary = raw.split('-').next().unwrap_or(&raw);
if reg.parse(primary).is_some() {
return; // hit the fallback
}
}
panic!("primary-tag fallback did not match `fr`");
}
}
+1
View File
@@ -1,6 +1,7 @@
pub mod admin;
pub mod auth;
pub mod csrf;
pub mod locale;
pub mod rate_limit;
pub mod trace_span;
pub mod trusted_proxy;