diff --git a/Cargo.lock b/Cargo.lock index 01051020..c659e0da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9101,6 +9101,7 @@ dependencies = [ "askama", "axum", "axum-extra", + "fluent-templates", "isocountry", "jiff", "prost", diff --git a/web/Cargo.toml b/web/Cargo.toml index ccaf6bb2..bd3bfdc7 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -9,6 +9,7 @@ axum = "0.8" # `axum::Form` (serde_urlencoded) cannot decode repeated keys into a `Vec`, and # the table builder posts one set of fields per already-added column. axum-extra = { version = "0.10", features = ["form"] } +fluent-templates = "0.14.0" rusty-money = "0.5.0" # The register form offers the same timezone and country suggestions the TUI # client does, and reads them from the same two crates. diff --git a/web/locales/cs/main.ftl b/web/locales/cs/main.ftl new file mode 100644 index 00000000..3cf678eb --- /dev/null +++ b/web/locales/cs/main.ftl @@ -0,0 +1 @@ +# Czech translations. Missing messages fall back to English. diff --git a/web/locales/en/main.ftl b/web/locales/en/main.ftl new file mode 100644 index 00000000..175cb538 --- /dev/null +++ b/web/locales/en/main.ftl @@ -0,0 +1 @@ +# English is the source locale. Add every new key here first. diff --git a/web/locales/sk/main.ftl b/web/locales/sk/main.ftl new file mode 100644 index 00000000..c79fd50c --- /dev/null +++ b/web/locales/sk/main.ftl @@ -0,0 +1 @@ +# Slovak translations. Missing messages fall back to English. diff --git a/web/src/i18n/mod.rs b/web/src/i18n/mod.rs new file mode 100644 index 00000000..a544157f --- /dev/null +++ b/web/src/i18n/mod.rs @@ -0,0 +1,152 @@ +//! Per-request Fluent internationalisation for the web UI. +//! +//! Catalogues live in `web/locales//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 { + 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::().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 { + 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, 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⟫"); + } +} diff --git a/web/src/lib.rs b/web/src/lib.rs index 447d91b8..b4601811 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -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. diff --git a/web/src/pages/admin/admin/ui.rs b/web/src/pages/admin/admin/ui.rs index 44ea6aaf..dc9eb102 100644 --- a/web/src/pages/admin/admin/ui.rs +++ b/web/src/pages/admin/admin/ui.rs @@ -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, diff --git a/web/src/pages/permissions/grants/ui.rs b/web/src/pages/permissions/grants/ui.rs index 3472e072..d859f885 100644 --- a/web/src/pages/permissions/grants/ui.rs +++ b/web/src/pages/permissions/grants/ui.rs @@ -42,6 +42,7 @@ mod tests { fn page() -> GrantsPage { GrantsPage { nav: Nav { + locale: Default::default(), authenticated: true, role: "admin".to_string(), can_admin: true, diff --git a/web/src/pages/permissions/roles/ui.rs b/web/src/pages/permissions/roles/ui.rs index 00059a43..87190f82 100644 --- a/web/src/pages/permissions/roles/ui.rs +++ b/web/src/pages/permissions/roles/ui.rs @@ -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, diff --git a/web/src/pages/permissions/users/ui.rs b/web/src/pages/permissions/users/ui.rs index d253df95..ba7ee3d1 100644 --- a/web/src/pages/permissions/users/ui.rs +++ b/web/src/pages/permissions/users/ui.rs @@ -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, diff --git a/web/src/ui/mod.rs b/web/src/ui/mod.rs index b33570c2..973b91a7 100644 --- a/web/src/ui/mod.rs +++ b/web/src/ui/mod.rs @@ -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,