translations fixed to be safe
This commit is contained in:
@@ -13,6 +13,12 @@ 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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,12 +48,22 @@ impl Locale {
|
||||
.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);
|
||||
// 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
|
||||
@@ -83,16 +99,14 @@ impl Locale {
|
||||
.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.
|
||||
/// 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(|| format!("⟪{key}⟫"))
|
||||
.unwrap_or_else(|| missing(key))
|
||||
}
|
||||
|
||||
/// Looks up a message containing Fluent variables such as `{ $count }`.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn lookup_args(
|
||||
self,
|
||||
key: &str,
|
||||
@@ -100,7 +114,21 @@ impl Locale {
|
||||
) -> String {
|
||||
LOCALES
|
||||
.try_lookup_with_args(&self.language_identifier(), key, args)
|
||||
.unwrap_or_else(|| format!("⟪{key}⟫"))
|
||||
.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(['-', '_'], " ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +157,9 @@ pub(crate) mod fluent_value {
|
||||
pub use fluent_templates::fluent_bundle::FluentValue;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod catalogue;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -145,8 +176,52 @@ mod tests {
|
||||
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() {
|
||||
assert_eq!(Locale::Czech.lookup("missing-key"), "⟪missing-key⟫");
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user