i18n on the web

This commit is contained in:
Priec
2026-08-14 20:35:18 +02:00
parent 3be6b63084
commit 2dc0d94ba0
12 changed files with 178 additions and 0 deletions

152
web/src/i18n/mod.rs Normal file
View File

@@ -0,0 +1,152 @@
//! Per-request Fluent internationalisation for the web UI.
//!
//! Catalogues live in `web/locales/<language>/main.ftl`. English is the source
//! and fallback language. Unlike the TUI client, the web server must not keep a
//! process-global current language: requests in different languages run in the
//! same process concurrently.
use axum::http::{HeaderMap, header};
use fluent_templates::{LanguageIdentifier, Loader, static_loader};
use std::{borrow::Cow, collections::HashMap};
static_loader! {
static LOCALES = {
locales: "./locales",
fallback_language: "en",
};
}
/// A language selected for one HTTP request.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum Locale {
#[default]
English,
Slovak,
Czech,
}
impl Locale {
/// Selects the best supported language from the browser's
/// `Accept-Language` header. Unsupported and missing languages use English.
pub(crate) fn from_headers(headers: &HeaderMap) -> Self {
headers
.get(header::ACCEPT_LANGUAGE)
.and_then(|value| value.to_str().ok())
.and_then(Self::from_accept_language)
.unwrap_or_default()
}
fn from_accept_language(header: &str) -> Option<Self> {
header
.split(',')
.filter_map(|entry| {
let mut parts = entry.trim().split(';');
let language = Self::from_language_tag(parts.next()?.trim())?;
let quality = parts.find_map(|parameter| {
parameter
.trim()
.strip_prefix("q=")
.and_then(|value| value.parse::<f32>().ok())
}).unwrap_or(1.0);
(quality > 0.0).then_some((language, quality))
})
// Keep the first language when qualities are equal: header order
// is the browser's preference order.
.reduce(|best, candidate| {
if candidate.1 > best.1 { candidate } else { best }
})
.map(|(language, _)| language)
}
fn from_language_tag(tag: &str) -> Option<Self> {
match tag.split(['-', '_']).next()?.to_ascii_lowercase().as_str() {
"en" => Some(Self::English),
"sk" => Some(Self::Slovak),
// `cs` is the ISO language code; `cz` is accepted as a convenient
// alias but is not used for the catalogue directory or HTML lang.
"cs" | "cz" => Some(Self::Czech),
_ => None,
}
}
pub(crate) const fn code(self) -> &'static str {
match self {
Self::English => "en",
Self::Slovak => "sk",
Self::Czech => "cs",
}
}
fn language_identifier(self) -> LanguageIdentifier {
self.code()
.parse()
.expect("supported locale codes are valid language identifiers")
}
/// Looks up a Fluent message, falling back to English. Unknown keys remain
/// visible during development instead of silently rendering an empty label.
pub(crate) fn lookup(self, key: &str) -> String {
LOCALES
.try_lookup(&self.language_identifier(), key)
.unwrap_or_else(|| format!("{key}"))
}
/// Looks up a message containing Fluent variables such as `{ $count }`.
#[allow(dead_code)]
pub(crate) fn lookup_args(
self,
key: &str,
args: &HashMap<Cow<'static, str>, fluent_value::FluentValue<'_>>,
) -> String {
LOCALES
.try_lookup_with_args(&self.language_identifier(), key, args)
.unwrap_or_else(|| format!("{key}"))
}
}
/// Per-request counterpart of the client crate's `tr!` macro.
///
/// The locale is explicit because one web process serves concurrent users in
/// different languages.
#[macro_export]
macro_rules! tr {
($locale:expr, $key:expr) => {
$locale.lookup($key)
};
($locale:expr, $key:expr, $($name:expr => $value:expr),+ $(,)?) => {{
let mut args: std::collections::HashMap<
std::borrow::Cow<'static, str>,
$crate::i18n::fluent_value::FluentValue,
> = std::collections::HashMap::new();
$(
args.insert(std::borrow::Cow::Borrowed($name), $value.into());
)+
$locale.lookup_args($key, &args)
}};
}
pub(crate) mod fluent_value {
pub use fluent_templates::fluent_bundle::FluentValue;
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
#[test]
fn browser_language_is_selected_by_quality() {
let mut headers = HeaderMap::new();
headers.insert(
header::ACCEPT_LANGUAGE,
HeaderValue::from_static("en;q=0.5, sk-SK;q=0.9, cs;q=0.8"),
);
assert_eq!(Locale::from_headers(&headers), Locale::Slovak);
}
#[test]
fn unknown_messages_are_visible_during_development() {
assert_eq!(Locale::Czech.lookup("missing-key"), "⟪missing-key⟫");
}
}

View File

@@ -12,6 +12,7 @@ mod schema;
mod services;
mod ui;
mod authz;
mod i18n;
// The server's system column vocabulary, read out of `common` the same way the
// generated protos are: this crate compiles that source tree directly instead
// of depending on the crate. Only `is_system_column` is used here.

View File

@@ -47,6 +47,7 @@ mod tests {
// The admin page is only reachable with a session, so the navbar
// shows the role badge and the log-out button.
let nav = Nav {
locale: Default::default(),
authenticated: true,
role: "admin".to_string(),
can_admin: true,

View File

@@ -42,6 +42,7 @@ mod tests {
fn page() -> GrantsPage {
GrantsPage {
nav: Nav {
locale: Default::default(),
authenticated: true,
role: "admin".to_string(),
can_admin: true,

View File

@@ -31,6 +31,7 @@ mod tests {
fn the_role_list_offers_creation_and_holds_back_deletion_of_a_role_in_use() {
let page = RolesPage {
nav: Nav {
locale: Default::default(),
authenticated: true,
role: "admin".to_string(),
can_admin: true,

View File

@@ -31,6 +31,7 @@ mod tests {
fn a_user_the_caller_does_not_outrank_gets_no_controls() {
let page = UsersPage {
nav: Nav {
locale: Default::default(),
authenticated: true,
role: "admin".to_string(),
can_admin: true,

View File

@@ -13,6 +13,10 @@ pub(crate) const SESSION_COOKIE: &str = "analytics_token";
/// `ui/base.html` renders `ui/navbar.html` unconditionally.
#[derive(Clone, Debug)]
pub(crate) struct Nav {
// Used by templates as they are migrated to Fluent. It is intentionally
// present before the first template string is converted.
#[allow(dead_code)]
pub locale: crate::i18n::Locale,
pub authenticated: bool,
pub role: String,
pub can_admin: bool,
@@ -29,6 +33,7 @@ impl Nav {
/// entries.
pub(crate) fn new(headers: &HeaderMap, active: &'static str) -> Self {
Self {
locale: crate::i18n::Locale::from_headers(headers),
authenticated: crate::cookie_value(headers, SESSION_COOKIE).is_some(),
role: String::new(),
can_admin: false,
@@ -40,6 +45,16 @@ impl Nav {
}
}
#[allow(dead_code)]
pub(crate) fn language(&self) -> &'static str {
self.locale.code()
}
#[allow(dead_code)]
pub(crate) fn tr(&self, key: &str) -> String {
self.locale.lookup(key)
}
pub(crate) fn with_authorization(
mut self,
authorization: &crate::auth::AuthorizationSnapshot,
@@ -64,6 +79,7 @@ impl Nav {
impl Default for Nav {
fn default() -> Self {
Self {
locale: crate::i18n::Locale::default(),
authenticated: false,
role: String::new(),
can_admin: false,