diff --git a/config/common.toml b/config/common.toml index 900872d6..d6b30e57 100644 --- a/config/common.toml +++ b/config/common.toml @@ -1,5 +1,6 @@ [app] name = "Samples" +#language = "es-ES" [log] tracing = "Debug" diff --git a/src/lib.rs b/src/lib.rs index ad7b24f7..9797b84b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -81,20 +81,12 @@ pub use paste::paste; pub use pagetop_macros::{fn_builder, main, test, AutoDefault, ComponentClasses}; -// ************************************************************************************************* -// GLOBAL. -// ************************************************************************************************* - -pub use static_files::Resource as StaticResource; - -pub type HashMapResources = std::collections::HashMap<&'static str, StaticResource>; +pub type HashMapResources = std::collections::HashMap<&'static str, static_files::Resource>; pub use std::any::TypeId; pub type Weight = i8; -include_locales!(LOCALES_PAGETOP); - // API ********************************************************************************************* // Useful functions and macros. diff --git a/src/locale.rs b/src/locale.rs index 513c7f70..71d0aa2f 100644 --- a/src/locale.rs +++ b/src/locale.rs @@ -1,21 +1,19 @@ //! Localization (L10n). //! -//! PageTop uses the [Fluent](https://www.projectfluent.org/) set of specifications for application -//! localization. +//! PageTop uses the [Fluent](https://www.projectfluent.org/) specifications for application +//! localization, leveraging the [fluent-templates](https://docs.rs/fluent-templates/) crate to +//! integrate translation resources directly into the application binary. //! //! # Fluent Syntax (FTL) //! //! The format used to describe the translation resources used by Fluent is called -//! [FTL](https://www.projectfluent.org/fluent/guide/). FTL is designed to be easy to read while -//! simultaneously allowing the representation of complex natural language concepts to address -//! gender, plurals, conjugations, and others. +//! [FTL](https://www.projectfluent.org/fluent/guide/). FTL is designed to be both readable and +//! expressive, enabling complex natural language constructs like gender, plurals, and conjugations. //! //! # Fluent Resources //! -//! PageTop utilizes [fluent-templates](https://docs.rs/fluent-templates/) to integrate localization -//! resources into the application binary. The following example groups files and subfolders from -//! *src/locale* that have a valid [Unicode Language Identifier](https://docs.rs/unic-langid/) and -//! assigns them to their corresponding identifier: +//! Localization resources are organized in the *src/locale* directory, with subdirectories for +//! each valid [Unicode Language Identifier](https://docs.rs/unic-langid/): //! //! ```text //! src/locale/ @@ -69,13 +67,13 @@ //! # How to apply localization in your code //! //! Once you have created your FTL resource directory, use the -//! [`static_locales!`](crate::static_locales) macro to integrate them into your module or +//! [`include_locales!`](crate::include_locales) macro to integrate them into your module or //! application. If your resources are located in the `"src/locale"` directory, simply declare: //! //! ``` //! use pagetop::prelude::*; //! -//! static_locales!(LOCALES_SAMPLE); +//! include_locales!(LOCALES_SAMPLE); //! ``` //! //! But if they are in another directory, then you can use: @@ -83,14 +81,14 @@ //! ``` //! use pagetop::prelude::*; //! -//! static_locales!(LOCALES_SAMPLE in "path/to/locale"); +//! include_locales!(LOCALES_SAMPLE from "path/to/locale"); //! ``` use crate::html::{Markup, PreEscaped}; -use crate::{global, kv, AutoDefault, LOCALES_PAGETOP}; +use crate::{global, kv, AutoDefault}; pub use fluent_templates; -pub use unic_langid::LanguageIdentifier; +pub use unic_langid::{CharacterDirection, LanguageIdentifier}; use fluent_templates::Loader; use fluent_templates::StaticLoader as Locales; @@ -102,45 +100,61 @@ use std::sync::LazyLock; use std::fmt; -const LANGUAGE_SET_FAILURE: &str = "language_set_failure"; - +/// A mapping between language codes (e.g., "en-US") and their corresponding [`LanguageIdentifier`] +/// and locale key names. static LANGUAGES: LazyLock> = LazyLock::new(|| { kv![ - "en" => (langid!("en-US"), "English"), - "en-GB" => (langid!("en-GB"), "English (British)"), - "en-US" => (langid!("en-US"), "English (United States)"), - "es" => (langid!("es-ES"), "Spanish"), - "es-ES" => (langid!("es-ES"), "Spanish (Spain)"), + "en" => ( langid!("en-US"), "english" ), + "en-GB" => ( langid!("en-GB"), "english_british" ), + "en-US" => ( langid!("en-US"), "english_united_states" ), + "es" => ( langid!("es-ES"), "spanish" ), + "es-ES" => ( langid!("es-ES"), "spanish_spain" ), ] }); -static FALLBACK_LANGID: LazyLock = LazyLock::new(|| langid!("en-US")); +static FALLBACK: LazyLock = LazyLock::new(|| langid!("en-US")); /// Sets the application's default /// [Unicode Language Identifier](https://unicode.org/reports/tr35/tr35.html#Unicode_language_identifier) /// through `SETTINGS.app.language`. -pub static DEFAULT_LANGID: LazyLock<&LanguageIdentifier> = LazyLock::new(|| { - langid_for(global::SETTINGS.app.language.as_str()).unwrap_or(&FALLBACK_LANGID) -}); +pub static DEFAULT_LANGID: LazyLock<&LanguageIdentifier> = + LazyLock::new(|| langid_for(&global::SETTINGS.app.language).unwrap_or(&FALLBACK)); -pub fn langid_for(language: impl Into) -> Result<&'static LanguageIdentifier, String> { - let language = language.into(); - match LANGUAGES.get(language.as_str()) { - Some((langid, _)) => Ok(langid), - None => { - if language.is_empty() { - Ok(&FALLBACK_LANGID) - } else { - Err(format!( - "No langid for Unicode Language Identifier \"{language}\".", - )) - } +pub enum LangError { + EmptyLang, + UnknownLang(String), +} + +impl fmt::Display for LangError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LangError::EmptyLang => write!(f, "The language identifier is empty."), + LangError::UnknownLang(lang) => write!(f, "Unknown language identifier: {lang}"), } } } +pub fn langid_for(language: impl Into) -> Result<&'static LanguageIdentifier, LangError> { + let language = language.into(); + if language.is_empty() { + return Err(LangError::EmptyLang); + } + // Attempt to match the full language code (e.g., "es-MX"). + if let Some(langid) = LANGUAGES.get(&language).map(|(langid, _)| langid) { + return Ok(langid); + } + // Fallback to the base language if no sublocale is found (e.g., "es"). + if let Some((base_lang, _)) = language.split_once('-') { + if let Some(langid) = LANGUAGES.get(base_lang).map(|(langid, _)| langid) { + return Ok(langid); + } + } + Err(LangError::UnknownLang(language)) +} + #[macro_export] -/// Defines a set of localization elements and local translation texts. +/// Defines a set of localization elements and local translation texts, removing Unicode isolating +/// marks around arguments to improve readability and compatibility in certain rendering contexts. macro_rules! include_locales { ( $LOCALES:ident $(, $core_locales:literal)? ) => { $crate::locale::fluent_templates::static_loader! { @@ -153,7 +167,7 @@ macro_rules! include_locales { }; } }; - ( $LOCALES:ident in $dir_locales:literal $(, $core_locales:literal)? ) => { + ( $LOCALES:ident from $dir_locales:literal $(, $core_locales:literal)? ) => { $crate::locale::fluent_templates::static_loader! { static $LOCALES = { locales: $dir_locales, @@ -166,6 +180,8 @@ macro_rules! include_locales { }; } +include_locales!(LOCALES_PAGETOP); + #[derive(AutoDefault)] enum L10nOp { #[default] @@ -177,7 +193,8 @@ enum L10nOp { #[derive(AutoDefault)] pub struct L10n { op: L10nOp, - locales: Option<&'static Locales>, + #[default(&LOCALES_PAGETOP)] + locales: &'static Locales, args: HashMap, } @@ -192,7 +209,6 @@ impl L10n { pub fn l(key: impl Into) -> Self { L10n { op: L10nOp::Translate(key.into()), - locales: Some(&LOCALES_PAGETOP), ..Default::default() } } @@ -200,7 +216,7 @@ impl L10n { pub fn t(key: impl Into, locales: &'static Locales) -> Self { L10n { op: L10nOp::Translate(key.into()), - locales: Some(locales), + locales, ..Default::default() } } @@ -210,37 +226,44 @@ impl L10n { self } + pub fn with_args(mut self, args: HashMap) -> Self { + for (k, v) in args { + self.args.insert(k, v); + } + self + } + + pub fn get(&self) -> Option { + self.using(&DEFAULT_LANGID) + } + pub fn using(&self, langid: &LanguageIdentifier) -> Option { match &self.op { L10nOp::None => None, L10nOp::Text(text) => Some(text.to_owned()), - L10nOp::Translate(key) => match self.locales { - Some(locales) => { - if self.args.is_empty() { - locales.try_lookup(langid, key) - } else { - locales.try_lookup_with_args( - langid, - key, - &self - .args - .iter() - .fold(HashMap::new(), |mut args, (key, value)| { - args.insert(key.to_string(), value.to_owned().into()); - args - }), - ) - } + L10nOp::Translate(key) => { + if self.args.is_empty() { + self.locales.try_lookup(langid, key) + } else { + self.locales.try_lookup_with_args( + langid, + key, + &self.args.iter().fold(HashMap::new(), |mut args, (k, v)| { + args.insert(k.to_string(), v.to_owned().into()); + args + }), + ) } - None => None, - }, + } } } + /// Escapes translated text using the default language identifier. pub fn markup(&self) -> Markup { - PreEscaped(self.using(&DEFAULT_LANGID).unwrap_or_default()) + PreEscaped(self.get().unwrap_or_default()) } + /// Escapes translated text using the specified language identifier. pub fn escaped(&self, langid: &LanguageIdentifier) -> Markup { PreEscaped(self.using(langid).unwrap_or_default()) } @@ -248,43 +271,11 @@ impl L10n { impl fmt::Display for L10n { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match &self.op { - L10nOp::None => write!(f, ""), - L10nOp::Text(text) => write!(f, "{text}"), - L10nOp::Translate(key) => { - if let Some(locales) = self.locales { - write!( - f, - "{}", - if self.args.is_empty() { - locales.lookup( - match key.as_str() { - LANGUAGE_SET_FAILURE => &FALLBACK_LANGID, - _ => &DEFAULT_LANGID, - }, - key, - ) - } else { - locales.lookup_with_args( - match key.as_str() { - LANGUAGE_SET_FAILURE => &FALLBACK_LANGID, - _ => &DEFAULT_LANGID, - }, - key, - &self - .args - .iter() - .fold(HashMap::new(), |mut args, (key, value)| { - args.insert(key.to_string(), value.to_owned().into()); - args - }), - ) - } - ) - } else { - write!(f, "Unknown localization {key}") - } - } - } + let content = match &self.op { + L10nOp::None => "".to_string(), + L10nOp::Text(text) => text.clone(), + L10nOp::Translate(key) => self.get().unwrap_or_else(|| format!("No <{}>", key)), + }; + write!(f, "{content}") } }