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⟫");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user