//! 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", // 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 { 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::() .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 { 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, 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"); } } }