#![forbid(unsafe_code)]
mod accept;
mod direction;
mod fixture;
mod fluent;
pub use accept::accept_language;
pub use direction::{Direction, direction};
pub use fixture::StaticCatalog;
pub use fluent::{CatalogError, FluentCatalog, FluentCatalogBuilder};
pub use unic_langid::LanguageIdentifier;
pub const TITLE: &str = "title";
pub const BODY: &str = "body";
pub const SUBJECT: &str = "subject";
pub type Args = serde_json::Map<String, serde_json::Value>;
pub trait Catalog: Send + Sync {
fn locales(&self) -> &[LanguageIdentifier];
fn default_locale(&self) -> &LanguageIdentifier;
fn render(
&self,
locale: &LanguageIdentifier,
key: &str,
attribute: &str,
args: &Args,
) -> Option<String>;
fn has(&self, locale: &LanguageIdentifier, key: &str, attribute: &str) -> bool {
self.render(locale, key, attribute, &Args::new()).is_some()
}
fn negotiate(&self, requested: &LanguageIdentifier) -> LanguageIdentifier {
let default = self.default_locale();
fluent_langneg::negotiate_languages(
std::slice::from_ref(requested),
self.locales(),
Some(default),
fluent_langneg::NegotiationStrategy::Filtering,
)
.first()
.map_or_else(|| default.clone(), |found| (*found).clone())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Localized {
pub text: String,
pub locale: LanguageIdentifier,
pub missing: bool,
}
#[must_use]
pub fn localize(
catalog: &dyn Catalog,
requested: &LanguageIdentifier,
key: &str,
attribute: &str,
args: &Args,
) -> Localized {
let negotiated = catalog.negotiate(requested);
if let Some(text) = catalog.render(&negotiated, key, attribute, args) {
return Localized {
text,
locale: negotiated,
missing: false,
};
}
let default = catalog.default_locale().clone();
if default != negotiated
&& let Some(text) = catalog.render(&default, key, attribute, args)
{
return Localized {
text,
locale: default,
missing: false,
};
}
Localized {
text: missing_text(key, attribute),
locale: default,
missing: true,
}
}
#[must_use]
pub fn missing_text(key: &str, attribute: &str) -> String {
format!("{key}.{attribute}")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Missing {
pub locale: LanguageIdentifier,
pub key: String,
pub attribute: String,
}
impl std::fmt::Display for Missing {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}.{}", self.locale, self.key, self.attribute)
}
}
#[must_use]
pub fn missing_messages(catalog: &dyn Catalog, keys: &[&str], attributes: &[&str]) -> Vec<Missing> {
let mut missing = Vec::new();
for locale in catalog.locales() {
for key in keys {
for attribute in attributes {
if !catalog.has(locale, key, attribute) {
missing.push(Missing {
locale: locale.clone(),
key: (*key).to_owned(),
attribute: (*attribute).to_owned(),
});
}
}
}
}
missing
}
#[must_use]
pub fn parse_locale(raw: &str) -> Option<LanguageIdentifier> {
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.len() > 64 {
return None;
}
trimmed.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn langid(tag: &str) -> LanguageIdentifier {
tag.parse().expect("a language tag")
}
fn catalog() -> StaticCatalog {
StaticCatalog::new("en")
.with("en", "booking-confirmed", TITLE, "Booking confirmed")
.with("en", "booking-confirmed", BODY, "See you on { $day }")
.with("en", "coach-notes", TITLE, "New notes")
.with("id", "booking-confirmed", TITLE, "Pesanan dikonfirmasi")
.with("id", "booking-confirmed", BODY, "Sampai jumpa { $day }")
}
#[test]
fn a_regional_tag_negotiates_down_to_the_language_it_is() {
let rendered = localize(
&catalog(),
&langid("id-ID"),
"booking-confirmed",
TITLE,
&Args::new(),
);
assert_eq!(rendered.text, "Pesanan dikonfirmasi");
assert_eq!(rendered.locale, langid("id"));
}
#[test]
fn a_locale_the_catalog_does_not_have_falls_back_to_the_default() {
let rendered = localize(
&catalog(),
&langid("fr-CA"),
"booking-confirmed",
TITLE,
&Args::new(),
);
assert_eq!(rendered.text, "Booking confirmed");
assert_eq!(
rendered.locale,
langid("en"),
"the locale reported must be the one it was rendered in, or the caller sets \
lang=\"fr\" on English text"
);
assert!(!rendered.missing);
}
#[test]
fn a_half_translated_locale_falls_back_per_message_not_per_catalog() {
let rendered = localize(
&catalog(),
&langid("id"),
"coach-notes",
TITLE,
&Args::new(),
);
assert_eq!(rendered.text, "New notes");
assert_eq!(rendered.locale, langid("en"));
assert!(!rendered.missing);
}
#[test]
fn a_key_no_locale_has_renders_as_the_key_and_says_so() {
let rendered = localize(
&catalog(),
&langid("id"),
"room-starting",
BODY,
&Args::new(),
);
assert!(rendered.missing, "a key nothing has must not pass silently");
assert_eq!(rendered.text, "room-starting.body");
assert!(
!rendered.text.is_empty(),
"an empty body looks delivered; the key is a bug report"
);
}
#[test]
fn garbage_is_refused_at_the_boundary_rather_than_stored() {
for raw in [
"",
" ",
"not a language",
"<script>alert(1)</script>",
"en; DROP TABLE notifications_subscriptions",
"e",
"../../etc/passwd",
] {
assert!(
parse_locale(raw).is_none(),
"{raw:?} is not a language tag and must not become one"
);
}
assert!(parse_locale(&"a".repeat(4096)).is_none());
}
#[test]
fn a_tag_that_parses_is_stored_canonicalised() {
let parsed = parse_locale("EN-us").expect("a valid tag");
assert_eq!(parsed.to_string(), "en-US");
assert_eq!(
parse_locale(" id-ID ").map(|l| l.to_string()).as_deref(),
Some("id-ID")
);
}
#[test]
fn the_completeness_check_lists_ids_and_never_a_rendered_string() {
let missing = missing_messages(
&catalog(),
&["booking-confirmed", "coach-notes"],
&[TITLE, BODY],
);
let listed: Vec<String> = missing.iter().map(ToString::to_string).collect();
assert_eq!(
listed,
[
"en: coach-notes.body",
"id: coach-notes.title",
"id: coach-notes.body",
],
"{listed:?}"
);
for entry in &missing {
assert!(
!entry.to_string().contains("See you"),
"the report is identifiers only: {entry}"
);
}
}
}