228 lines
7.7 KiB
Rust
228 lines
7.7 KiB
Rust
//! 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",
|
|
// Fluent wraps every interpolated value in U+2068/U+2069 by default, so
|
|
// that a right-to-left name cannot reorder the sentence around it. All
|
|
// three supported languages are left-to-right, and the marks are not
|
|
// free: they reach the page as invisible characters inside table and
|
|
// column names, which then fail to match anything a user copies out.
|
|
customise: |bundle| bundle.set_use_isolating(false),
|
|
};
|
|
}
|
|
|
|
/// 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())?;
|
|
// An entry with no `q=` at all is weight 1.0. An entry that
|
|
// *has* one the parser cannot read is unacceptable, not
|
|
// most-preferred (RFC 9110 §12.4.2) — otherwise a junk qvalue
|
|
// outranks every honest preference in the header.
|
|
let quality = match parts
|
|
.find_map(|parameter| parameter.trim().strip_prefix("q="))
|
|
{
|
|
Some(value) => value
|
|
.parse::<f32>()
|
|
.ok()
|
|
// A qvalue is a number in 0..=1; anything outside that
|
|
// is as malformed as text is.
|
|
.filter(|quality| (0.0..=1.0).contains(quality))
|
|
.unwrap_or(0.0),
|
|
None => 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.
|
|
pub(crate) fn lookup(self, key: &str) -> String {
|
|
LOCALES
|
|
.try_lookup(&self.language_identifier(), key)
|
|
.unwrap_or_else(|| missing(key))
|
|
}
|
|
|
|
/// Looks up a message containing Fluent variables such as `{ $count }`.
|
|
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(|| missing(key))
|
|
}
|
|
}
|
|
|
|
/// What an unknown key renders as.
|
|
///
|
|
/// In development it is loud on purpose, so a key that no catalogue defines is
|
|
/// caught by looking at the page. A release build must not put that on a user's
|
|
/// screen, so it degrades to the key's own words: `td-pick-table-delete` reads
|
|
/// as "td pick table delete" — wrong, but a label rather than debug output.
|
|
fn missing(key: &str) -> String {
|
|
if cfg!(debug_assertions) {
|
|
format!("⟪{key}⟫")
|
|
} else {
|
|
key.replace(['-', '_'], " ")
|
|
}
|
|
}
|
|
|
|
/// 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 catalogue;
|
|
|
|
#[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);
|
|
}
|
|
|
|
/// A `q=` the parser cannot read means "not acceptable", so it must not
|
|
/// beat a language the browser actually asked for.
|
|
#[test]
|
|
fn a_malformed_qvalue_does_not_outrank_a_real_preference() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
header::ACCEPT_LANGUAGE,
|
|
HeaderValue::from_static("cs;q=banana, sk;q=0.4"),
|
|
);
|
|
|
|
assert_eq!(Locale::from_headers(&headers), Locale::Slovak);
|
|
}
|
|
|
|
#[test]
|
|
fn an_out_of_range_qvalue_is_rejected_rather_than_trusted() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
header::ACCEPT_LANGUAGE,
|
|
HeaderValue::from_static("cs;q=999, sk;q=0.4"),
|
|
);
|
|
|
|
assert_eq!(Locale::from_headers(&headers), Locale::Slovak);
|
|
}
|
|
|
|
/// No `q=` at all still means "most preferred".
|
|
#[test]
|
|
fn a_missing_qvalue_is_full_weight() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
header::ACCEPT_LANGUAGE,
|
|
HeaderValue::from_static("sk;q=0.9, cs"),
|
|
);
|
|
|
|
assert_eq!(Locale::from_headers(&headers), Locale::Czech);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_messages_are_visible_during_development() {
|
|
let rendered = Locale::Czech.lookup("missing-key");
|
|
|
|
if cfg!(debug_assertions) {
|
|
assert_eq!(rendered, "⟪missing-key⟫");
|
|
} else {
|
|
// Loud in development, but never on a user's screen in a release
|
|
// build.
|
|
assert_eq!(rendered, "missing key");
|
|
}
|
|
}
|
|
}
|