i18n on the web
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -9101,6 +9101,7 @@ dependencies = [
|
|||||||
"askama",
|
"askama",
|
||||||
"axum",
|
"axum",
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
|
"fluent-templates",
|
||||||
"isocountry",
|
"isocountry",
|
||||||
"jiff",
|
"jiff",
|
||||||
"prost",
|
"prost",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ axum = "0.8"
|
|||||||
# `axum::Form` (serde_urlencoded) cannot decode repeated keys into a `Vec`, and
|
# `axum::Form` (serde_urlencoded) cannot decode repeated keys into a `Vec`, and
|
||||||
# the table builder posts one set of fields per already-added column.
|
# the table builder posts one set of fields per already-added column.
|
||||||
axum-extra = { version = "0.10", features = ["form"] }
|
axum-extra = { version = "0.10", features = ["form"] }
|
||||||
|
fluent-templates = "0.14.0"
|
||||||
rusty-money = "0.5.0"
|
rusty-money = "0.5.0"
|
||||||
# The register form offers the same timezone and country suggestions the TUI
|
# The register form offers the same timezone and country suggestions the TUI
|
||||||
# client does, and reads them from the same two crates.
|
# client does, and reads them from the same two crates.
|
||||||
|
|||||||
1
web/locales/cs/main.ftl
Normal file
1
web/locales/cs/main.ftl
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Czech translations. Missing messages fall back to English.
|
||||||
1
web/locales/en/main.ftl
Normal file
1
web/locales/en/main.ftl
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# English is the source locale. Add every new key here first.
|
||||||
1
web/locales/sk/main.ftl
Normal file
1
web/locales/sk/main.ftl
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Slovak translations. Missing messages fall back to English.
|
||||||
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 services;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod authz;
|
mod authz;
|
||||||
|
mod i18n;
|
||||||
// The server's system column vocabulary, read out of `common` the same way the
|
// 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
|
// generated protos are: this crate compiles that source tree directly instead
|
||||||
// of depending on the crate. Only `is_system_column` is used here.
|
// 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
|
// The admin page is only reachable with a session, so the navbar
|
||||||
// shows the role badge and the log-out button.
|
// shows the role badge and the log-out button.
|
||||||
let nav = Nav {
|
let nav = Nav {
|
||||||
|
locale: Default::default(),
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
role: "admin".to_string(),
|
role: "admin".to_string(),
|
||||||
can_admin: true,
|
can_admin: true,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ mod tests {
|
|||||||
fn page() -> GrantsPage {
|
fn page() -> GrantsPage {
|
||||||
GrantsPage {
|
GrantsPage {
|
||||||
nav: Nav {
|
nav: Nav {
|
||||||
|
locale: Default::default(),
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
role: "admin".to_string(),
|
role: "admin".to_string(),
|
||||||
can_admin: true,
|
can_admin: true,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ mod tests {
|
|||||||
fn the_role_list_offers_creation_and_holds_back_deletion_of_a_role_in_use() {
|
fn the_role_list_offers_creation_and_holds_back_deletion_of_a_role_in_use() {
|
||||||
let page = RolesPage {
|
let page = RolesPage {
|
||||||
nav: Nav {
|
nav: Nav {
|
||||||
|
locale: Default::default(),
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
role: "admin".to_string(),
|
role: "admin".to_string(),
|
||||||
can_admin: true,
|
can_admin: true,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ mod tests {
|
|||||||
fn a_user_the_caller_does_not_outrank_gets_no_controls() {
|
fn a_user_the_caller_does_not_outrank_gets_no_controls() {
|
||||||
let page = UsersPage {
|
let page = UsersPage {
|
||||||
nav: Nav {
|
nav: Nav {
|
||||||
|
locale: Default::default(),
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
role: "admin".to_string(),
|
role: "admin".to_string(),
|
||||||
can_admin: true,
|
can_admin: true,
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ pub(crate) const SESSION_COOKIE: &str = "analytics_token";
|
|||||||
/// `ui/base.html` renders `ui/navbar.html` unconditionally.
|
/// `ui/base.html` renders `ui/navbar.html` unconditionally.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct Nav {
|
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 authenticated: bool,
|
||||||
pub role: String,
|
pub role: String,
|
||||||
pub can_admin: bool,
|
pub can_admin: bool,
|
||||||
@@ -29,6 +33,7 @@ impl Nav {
|
|||||||
/// entries.
|
/// entries.
|
||||||
pub(crate) fn new(headers: &HeaderMap, active: &'static str) -> Self {
|
pub(crate) fn new(headers: &HeaderMap, active: &'static str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
locale: crate::i18n::Locale::from_headers(headers),
|
||||||
authenticated: crate::cookie_value(headers, SESSION_COOKIE).is_some(),
|
authenticated: crate::cookie_value(headers, SESSION_COOKIE).is_some(),
|
||||||
role: String::new(),
|
role: String::new(),
|
||||||
can_admin: false,
|
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(
|
pub(crate) fn with_authorization(
|
||||||
mut self,
|
mut self,
|
||||||
authorization: &crate::auth::AuthorizationSnapshot,
|
authorization: &crate::auth::AuthorizationSnapshot,
|
||||||
@@ -64,6 +79,7 @@ impl Nav {
|
|||||||
impl Default for Nav {
|
impl Default for Nav {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
locale: crate::i18n::Locale::default(),
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
role: String::new(),
|
role: String::new(),
|
||||||
can_admin: false,
|
can_admin: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user