i18n on the web
This commit is contained in:
152
web/src/i18n/mod.rs
Normal file
152
web/src/i18n/mod.rs
Normal 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⟫");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -42,6 +42,7 @@ mod tests {
|
||||
fn page() -> GrantsPage {
|
||||
GrantsPage {
|
||||
nav: Nav {
|
||||
locale: Default::default(),
|
||||
authenticated: true,
|
||||
role: "admin".to_string(),
|
||||
can_admin: true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user