From 208ad83bea2ecc2f7402a00abb0b25ea44bf3ad7 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Wed, 9 Jul 2025 20:39:39 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20A=C3=B1ade=20soporte=20para=20local?= =?UTF-8?q?izaci=C3=B3n=20y=20traducci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Incluye recursos Fluent básicos y pruebas asociadas. - Nueva variable de configuración global para definir el idioma predeterminado. --- Cargo.lock | 278 ++++++++++++++++++++++++- Cargo.toml | 5 +- src/app.rs | 5 +- src/global.rs | 3 + src/lib.rs | 4 + src/locale.rs | 364 +++++++++++++++++++++++++++++++++ src/locale/en-US/languages.ftl | 5 + src/locale/en-US/test.ftl | 11 + src/locale/es-ES/languages.ftl | 5 + src/locale/es-ES/test.ftl | 11 + src/prelude.rs | 8 + src/util.rs | 26 +++ tests/locale.rs | 58 ++++++ 13 files changed, 780 insertions(+), 3 deletions(-) create mode 100644 src/locale.rs create mode 100644 src/locale/en-US/languages.ftl create mode 100644 src/locale/en-US/test.ftl create mode 100644 src/locale/es-ES/languages.ftl create mode 100644 src/locale/es-ES/test.ftl create mode 100644 src/util.rs create mode 100644 tests/locale.rs diff --git a/Cargo.lock b/Cargo.lock index 3fe4d51..64073ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -287,6 +287,16 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bstr" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -384,6 +394,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -492,6 +521,81 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent-bundle" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 1.1.0", + "self_cell 0.10.3", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ad0989667548f06ccd0e306ed56b61bd4d35458d54df5ec7587c0e8ed5e94" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" +dependencies = [ + "thiserror", +] + +[[package]] +name = "fluent-template-macros" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ed02449601d0dacdc05cb5e13db5dab8f2b98d773aff5c53b62fad43a1b19a1" +dependencies = [ + "flume", + "ignore", + "proc-macro2", + "quote", + "syn", + "unic-langid", +] + +[[package]] +name = "fluent-templates" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69855a5fe87629495efca79aec72adfa97954f1006f928e3a2ec750cb3e85386" +dependencies = [ + "fluent-bundle", + "fluent-langneg", + "fluent-syntax", + "fluent-template-macros", + "flume", + "ignore", + "intl-memoizer", + "log", + "thiserror", + "unic-langid", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -571,6 +675,19 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "globset" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + [[package]] name = "h2" version = "0.3.26" @@ -726,6 +843,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata 0.4.9", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "impl-more" version = "0.1.9" @@ -742,6 +875,25 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + [[package]] name = "itoa" version = "1.0.15" @@ -918,12 +1070,13 @@ checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] name = "pagetop" -version = "0.0.4" +version = "0.0.5" dependencies = [ "actix-web", "colored", "config", "figlet-rs", + "fluent-templates", "itoa", "pagetop-macros", "serde", @@ -933,6 +1086,7 @@ dependencies = [ "tracing-actix-web", "tracing-appender", "tracing-subscriber", + "unic-langid", ] [[package]] @@ -1052,6 +1206,12 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + [[package]] name = "proc-macro2" version = "1.0.95" @@ -1182,6 +1342,18 @@ version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" version = "1.0.7" @@ -1207,12 +1379,36 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "self_cell" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" +dependencies = [ + "self_cell 1.2.0", +] + +[[package]] +name = "self_cell" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" + [[package]] name = "serde" version = "1.0.219" @@ -1323,6 +1519,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -1604,12 +1809,64 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.1", +] + [[package]] name = "typenum" version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", + "unic-langid-macros", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + +[[package]] +name = "unic-langid-macros" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25" +dependencies = [ + "proc-macro-hack", + "tinystr", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" +dependencies = [ + "proc-macro-hack", + "quote", + "syn", + "unic-langid-impl", +] + [[package]] name = "unicode-ident" version = "1.0.18" @@ -1662,6 +1919,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1751,6 +2018,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index b8b3f5f..bcf1fd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pagetop" -version = "0.0.4" +version = "0.0.5" edition = "2021" description = """\ @@ -28,6 +28,9 @@ tracing-appender = "0.2.3" tracing-subscriber = { version = "0.3.19", features = ["json", "env-filter"] } tracing-actix-web = "0.7.18" +fluent-templates = "0.13.0" +unic-langid = { version = "0.9.6", features = ["macros"] } + actix-web = "4.11.0" pagetop-macros.workspace = true diff --git a/src/app.rs b/src/app.rs index 082419a..b02b294 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,7 +2,7 @@ mod figfont; -use crate::{global, service, trace}; +use crate::{global, locale, service, trace}; use substring::Substring; @@ -26,6 +26,9 @@ impl Application { // Inicia gestión de trazas y registro de eventos (logging). LazyLock::force(&trace::TRACING); + // Valida el identificador de idioma por defecto. + LazyLock::force(&locale::DEFAULT_LANGID); + Self } diff --git a/src/global.rs b/src/global.rs index 8432032..a90aa86 100644 --- a/src/global.rs +++ b/src/global.rs @@ -8,6 +8,7 @@ include_config!(SETTINGS: Settings => [ // [app] "app.name" => "Sample", "app.description" => "Developed with the amazing PageTop framework.", + "app.language" => "en-US", "app.startup_banner" => "Slant", // [log] @@ -38,6 +39,8 @@ pub struct App { pub name: String, /// Breve descripción de la aplicación. pub description: String, + /// Idioma predeterminado (localización). + pub language: String, /// Banner ASCII mostrado al inicio: *"Off"* (desactivado), *"Slant"*, *"Small"*, *"Speed"* o /// *"Starwars"*. pub startup_banner: String, diff --git a/src/lib.rs b/src/lib.rs index b9b2a4b..07b9934 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,6 +36,8 @@ pub use pagetop_macros::{html, main, test, AutoDefault}; // API ********************************************************************************************* +// Funciones y macros útiles. +pub mod util; // Carga las opciones de configuración. pub mod config; // Opciones de configuración globales. @@ -44,6 +46,8 @@ pub mod global; pub mod trace; // HTML en código. pub mod html; +// Localización. +pub mod locale; // Gestión del servidor y servicios web. pub mod service; // Prepara y ejecuta la aplicación. diff --git a/src/locale.rs b/src/locale.rs new file mode 100644 index 0000000..064481f --- /dev/null +++ b/src/locale.rs @@ -0,0 +1,364 @@ +//! Localización (L10n). +//! +//! `PageTop` utiliza las especificaciones de [Fluent](https://www.projectfluent.org/) para la +//! localización de aplicaciones, y aprovecha [fluent-templates](https://docs.rs/fluent-templates/) +//! para integrar los recursos de traducción directamente en el binario de la aplicación. +//! +//! # Sintaxis Fluent (FTL) +//! +//! El formato empleado para describir los recursos de traducción se denomina +//! [FTL](https://www.projectfluent.org/fluent/guide/). Está diseñado para ser legible y expresivo, +//! permitiendo representar construcciones complejas del lenguaje natural como el género, el plural +//! o las conjugaciones verbales. +//! +//! # Recursos Fluent +//! +//! Por defecto, las traducciones se organizan en el directorio *src/locale*, con subdirectorios +//! para cada [Identificador de Idioma Unicode](https://docs.rs/unic-langid/) válido. Podríamos +//! tener una estructura como esta: +//! +//! ```text +//! src/locale/ +//! ├── common.ftl +//! ├── en-US/ +//! │ ├── default.ftl +//! │ └── main.ftl +//! ├── es-ES/ +//! │ ├── default.ftl +//! │ └── main.ftl +//! ├── es-MX/ +//! │ ├── default.ftl +//! │ └── main.ftl +//! └── fr/ +//! ├── default.ftl +//! └── main.ftl +//! ``` +//! +//! Ejemplo de un archivo *src/locale/en-US/main.ftl*: +//! +//! ```text +//! hello-world = Hello world! +//! hello-user = Hello, {$userName}! +//! shared-photos = +//! {$userName} {$photoCount -> +//! [one] added a new photo +//! *[other] added {$photoCount} new photos +//! } of {$userGender -> +//! [male] him and his family +//! [female] her and her family +//! *[other] the family +//! }. +//! ``` +//! +//! Y su archivo equivalente para español *src/locale/es-ES/main.ftl*: +//! +//! ```text +//! hello-world = Hola mundo! +//! hello-user = ¡Hola, {$userName}! +//! shared-photos = +//! {$userName} {$photoCount -> +//! [one] ha añadido una nueva foto +//! *[other] ha añadido {$photoCount} nuevas fotos +//! } de {$userGender -> +//! [male] él y su familia +//! [female] ella y su familia +//! *[other] la familia +//! }. +//! ``` +//! +//! +//! # Cómo aplicar la localización en tu código +//! +//! Una vez creado el directorio con los recursos FTL, basta con utilizar la macro +//! [`include_locales!`](crate::include_locales) para integrarlos en la aplicación. +//! +//! Si los recursos se encuentran en el directorio `"src/locale"`, sólo hay que declarar: +//! +//! ```rust +//! use pagetop::prelude::*; +//! +//! include_locales!(LOCALES_SAMPLE); +//! ``` +//! +//! Pero si están ubicados en otro directorio, entonces se pueden incluir usando: +//! +//! ```rust,ignore +//! include_locales!(LOCALES_SAMPLE from "ruta/a/las/traducciones"); +//! ``` + +use crate::html::{Markup, PreEscaped}; +use crate::{global, hm, AutoDefault}; + +pub use fluent_templates; +pub use unic_langid::{CharacterDirection, LanguageIdentifier}; + +use unic_langid::langid; + +use fluent_templates::Loader; +use fluent_templates::StaticLoader as Locales; + +use std::borrow::Cow; +use std::collections::HashMap; +use std::sync::LazyLock; + +use std::fmt; + +// Asocia cada código de idioma (como "en-US") con su respectivo [`LanguageIdentifier`] y la clave +// en *locale/.../languages.ftl* para obtener el nombre del idioma según la localización. +static LANGUAGES: LazyLock> = LazyLock::new(|| { + hm![ + "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" ), + ] +}); + +// Identificador del idioma de **respaldo** (predefinido a `en-US`). +// +// Se usa cuando el valor del código de idioma en las traducciones no corresponde con ningún idioma +// soportado por la aplicación. +static FALLBACK_LANGID: LazyLock = LazyLock::new(|| langid!("en-US")); + +// Identificador del idioma **por defecto** para la aplicación. +// +// Se resuelve a partir de [`global::SETTINGS.app.language`](global::SETTINGS). Si el código de +// idioma configurado no es válido o no está disponible entonces resuelve como [`FALLBACK_LANGID`]. +pub(crate) static DEFAULT_LANGID: LazyLock<&LanguageIdentifier> = + LazyLock::new(|| LangMatch::langid_or_fallback(&global::SETTINGS.app.language)); + +/// Comprueba si el idioma está soportado por `PageTop`. +/// +/// Útil para transformar un código de idioma en un [`LanguageIdentifier`] válido para `PageTop`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum LangMatch { + /// Cuando el código del idioma es una cadena vacía. + Empty, + /// Si encuentra un [`LanguageIdentifier`] que coincide exactamente o retrocediendo al idioma + /// base. + Found(&'static LanguageIdentifier), + /// Si el código del idioma no está entre los soportados por `PageTop`. + Unknown(String), +} + +impl LangMatch { + /// Resuelve `language` y devuelve el [`LangMatch`] apropiado. + pub fn resolve(language: impl AsRef) -> Self { + let language = language.as_ref().trim(); + + // Rechaza cadenas vacías. + if language.is_empty() { + return Self::Empty; + } + + // Intenta aplicar coincidencia exacta con el código completo (p.ej. "es-MX"). + if let Some(langid) = LANGUAGES.get(language).map(|(langid, _)| langid) { + return Self::Found(langid); + } + + // Si la variante regional no existe, retrocede al idioma base (p.ej. "es"). + if let Some((base_lang, _)) = language.split_once('-') { + if let Some(langid) = LANGUAGES.get(base_lang).map(|(langid, _)| langid) { + return Self::Found(langid); + } + } + + // Devuelve desconocido si el idioma no está soportado. + Self::Unknown(language.to_string()) + } + + /// Devuelve siempre un [`LanguageIdentifier`] válido. + /// + /// Si `language` está vacío o es desconocido, devuelve el idioma de respaldo ("en-US"). + #[inline] + pub fn langid_or_fallback(language: impl AsRef) -> &'static LanguageIdentifier { + match Self::resolve(language) { + Self::Found(l) => l, + _ => &FALLBACK_LANGID, + } + } + + /// Devuelve un [`LanguageIdentifier`] válido para la instancia. + /// + /// Si `language` está vacío o es desconocido, devuelve el idioma de respaldo ("en-US"). + #[inline] + pub fn as_langid(&self) -> &'static LanguageIdentifier { + match self { + LangMatch::Found(l) => l, + _ => &FALLBACK_LANGID, + } + } +} + +#[macro_export] +/// Define un conjunto de elementos de localización y textos de traducción local. +macro_rules! include_locales { + // Se eliminan las marcas de aislamiento Unicode en los argumentos para mejorar la legibilidad y + // la compatibilidad en ciertos contextos de renderizado. + ( $LOCALES:ident $(, $core_locales:literal)? ) => { + $crate::locale::fluent_templates::static_loader! { + static $LOCALES = { + locales: "src/locale", + $( core_locales: $core_locales, )? + fallback_language: "en-US", + // Elimina marcas de aislamiento Unicode en los argumentos. + customise: |bundle| bundle.set_use_isolating(false), + }; + } + }; + ( $LOCALES:ident from $dir_locales:literal $(, $core_locales:literal)? ) => { + $crate::locale::fluent_templates::static_loader! { + static $LOCALES = { + locales: $dir_locales, + $( core_locales: $core_locales, )? + fallback_language: "en-US", + // Elimina marcas de aislamiento Unicode en los argumentos. + customise: |bundle| bundle.set_use_isolating(false), + }; + } + }; +} + +include_locales!(LOCALES_PAGETOP); + +// Operación de localización a realizar. +// +// * `None` - No se aplica ninguna localización. +// * `Text` - Con una cadena literal que se devolverá tal cual. +// * `Translate` - Con la clave a resolver en el `Locales` indicado. +#[derive(AutoDefault)] +enum L10nOp { + #[default] + None, + Text(String), + Translate(String), +} + +/// Crea instancias para traducir textos localizados. +/// +/// Cada instancia puede representar: +/// +/// - Un texto puro (`n()`) que no requiere traducción. +/// - Una clave para traducir un texto de las traducciones por defecto de `PageTop` (`l()`). +/// - Una clave para traducir de un conjunto concreto de traducciones (`t()`). +/// +/// Los argumentos dinámicos se añaden mediante `with_arg()` o `with_args()`. +/// +/// ```rust +/// use pagetop::prelude::*; +/// +/// // Texto literal sin traducción. +/// let raw = L10n::n("© 2025 PageTop").get(); +/// +/// // Traducción simple con clave y argumentos. +/// let hello = L10n::l("greeting") +/// .with_arg("name", "Manuel") +/// .markup(); +/// ``` +/// +/// También para traducciones a idiomas concretos. +/// +/// ```rust,ignore +/// // Traducción con clave, conjunto de traducciones y código de idioma a usar. +/// let bye = L10n::t("goodbye", &LOCALES_CUSTOM).using(LangMatch::langid_or_fallback("it")); +/// ``` +#[derive(AutoDefault)] +pub struct L10n { + op: L10nOp, + #[default(&LOCALES_PAGETOP)] + locales: &'static Locales, + args: HashMap, +} + +impl L10n { + /// **n** = *“native”*. Crea una instancia con una cadena literal sin traducción. + pub fn n(text: impl Into) -> Self { + L10n { + op: L10nOp::Text(text.into()), + ..Default::default() + } + } + + /// **l** = *“lookup”*. Crea una instancia para traducir usando una clave de la tabla de + /// traducciones por defecto. + pub fn l(key: impl Into) -> Self { + L10n { + op: L10nOp::Translate(key.into()), + ..Default::default() + } + } + + /// **t** = *“translate”*. Crea una instancia para traducir usando una clave de una tabla de + /// traducciones específica. + pub fn t(key: impl Into, locales: &'static Locales) -> Self { + L10n { + op: L10nOp::Translate(key.into()), + locales, + ..Default::default() + } + } + + /// Añade un argumento `{$arg}` → `value` a la traducción. + pub fn with_arg(mut self, arg: impl Into, value: impl Into) -> Self { + self.args.insert(arg.into(), value.into()); + self + } + + /// Añade varios argumentos a la traducción de una sola vez (p.ej. usando la macro [`hm!`], + /// también vec![("k", "v")], incluso un array de duplas u otras colecciones). + pub fn with_args(mut self, args: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + self.args + .extend(args.into_iter().map(|(k, v)| (k.into(), v.into()))); + self + } + + /// Resuelve la traducción usando el idioma por defecto de la aplicación. Devuelve `None` si no + /// aplica o no encuentra una traducción. + pub fn get(&self) -> Option { + self.using(&DEFAULT_LANGID) + } + + /// Resuelve la traducción usando el [`LanguageIdentifier`] indicado. Devuelve `None` si no + /// aplica o no encuentra una traducción. + pub fn using(&self, langid: &LanguageIdentifier) -> Option { + match &self.op { + L10nOp::None => None, + L10nOp::Text(text) => Some(text.to_owned()), + L10nOp::Translate(key) => self.locales.try_lookup_with_args( + langid, + key, + &self.args.iter().fold(HashMap::new(), |mut arg, (k, v)| { + arg.insert(Cow::Owned(k.clone()), v.to_owned().into()); + arg + }), + ), + } + } + + /// Traduce y escapa con el idioma por defecto, devolviendo [`Markup`]. + pub fn markup(&self) -> Markup { + PreEscaped(self.get().unwrap_or_default()) + } + + /// Traduce y escapa con el [`LanguageIdentifier`] indicado, devolviendo [`Markup`]. + pub fn escaped(&self, langid: &LanguageIdentifier) -> Markup { + PreEscaped(self.using(langid).unwrap_or_default()) + } +} + +impl fmt::Display for L10n { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let content = match &self.op { + L10nOp::None => String::new(), + L10nOp::Text(text) => text.clone(), + L10nOp::Translate(key) => self.get().unwrap_or_else(|| format!("??<{}>", key)), + }; + write!(f, "{content}") + } +} diff --git a/src/locale/en-US/languages.ftl b/src/locale/en-US/languages.ftl new file mode 100644 index 0000000..1e81660 --- /dev/null +++ b/src/locale/en-US/languages.ftl @@ -0,0 +1,5 @@ +english = English +english_british = English (British) +english_united_states = English (United States) +spanish = Spanish +spanish_spain = Spanish (Spain) diff --git a/src/locale/en-US/test.ftl b/src/locale/en-US/test.ftl new file mode 100644 index 0000000..3c317fc --- /dev/null +++ b/src/locale/en-US/test.ftl @@ -0,0 +1,11 @@ +test-hello-world = Hello world! +test-hello-user = Hello, { $userName }! +test-shared-photos = + { $userName } { $photoCount -> + [one] added a new photo + *[other] added { $photoCount } new photos + } of { $userGender -> + [male] him and his family + [female] her and her family + *[other] their family + }. diff --git a/src/locale/es-ES/languages.ftl b/src/locale/es-ES/languages.ftl new file mode 100644 index 0000000..ee74ec2 --- /dev/null +++ b/src/locale/es-ES/languages.ftl @@ -0,0 +1,5 @@ +english = Inglés +english_british = Inglés (Gran Bretaña) +english_united_states = Inglés (Estados Unidos) +spanish = Español +spanish_spain = Español (España) diff --git a/src/locale/es-ES/test.ftl b/src/locale/es-ES/test.ftl new file mode 100644 index 0000000..02cd22e --- /dev/null +++ b/src/locale/es-ES/test.ftl @@ -0,0 +1,11 @@ +test-hello-world = ¡Hola mundo! +test-hello-user = ¡Hola, { $userName }! +test-shared-photos = + { $userName } { $photoCount -> + [one] ha añadido una nueva foto + *[other] ha añadido { $photoCount } nuevas fotos + } de { $userGender -> + [male] él y su familia + [female] ella y su familia + *[other] la familia + }. diff --git a/src/prelude.rs b/src/prelude.rs index 872447a..af29d37 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -8,17 +8,25 @@ pub use crate::AutoDefault; // MACROS. +// crate::util +pub use crate::hm; // crate::config pub use crate::include_config; +// crate::locale +pub use crate::include_locales; // API. +pub use crate::util; + pub use crate::global; pub use crate::trace; pub use crate::html::*; +pub use crate::locale::*; + pub use crate::service; pub use crate::app::Application; diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..07a6abd --- /dev/null +++ b/src/util.rs @@ -0,0 +1,26 @@ +//! Funciones y macros útiles. + +// MACROS ÚTILES *********************************************************************************** + +#[macro_export] +/// Macro para construir una colección de pares clave-valor. +/// +/// ```rust +/// use pagetop::hm; +/// use std::collections::HashMap; +/// +/// let args:HashMap<&str, String> = hm![ +/// "userName" => "Roberto", +/// "photoCount" => "3", +/// "userGender" => "male", +/// ]; +/// ``` +macro_rules! hm { + ( $($key:expr => $value:expr),* $(,)? ) => {{ + let mut a = std::collections::HashMap::new(); + $( + a.insert($key.into(), $value.into()); + )* + a + }}; +} diff --git a/tests/locale.rs b/tests/locale.rs new file mode 100644 index 0000000..c723cd4 --- /dev/null +++ b/tests/locale.rs @@ -0,0 +1,58 @@ +use pagetop::prelude::*; + +#[pagetop::test] +async fn literal_text() { + let _app = service::test::init_service(Application::new().test()).await; + + let l10n = L10n::n("© 2025 PageTop"); + assert_eq!(l10n.get(), Some("© 2025 PageTop".to_string())); +} + +#[pagetop::test] +async fn translation_without_args() { + let _app = service::test::init_service(Application::new().test()).await; + + let l10n = L10n::l("test-hello-world"); + let translation = l10n.using(LangMatch::langid_or_fallback("es-ES")); + assert_eq!(translation, Some("¡Hola mundo!".to_string())); +} + +#[pagetop::test] +async fn translation_with_args() { + let _app = service::test::init_service(Application::new().test()).await; + + let l10n = L10n::l("test-hello-user").with_arg("userName", "Manuel"); + let translation = l10n.using(LangMatch::langid_or_fallback("es-ES")); + assert_eq!(translation, Some("¡Hola, Manuel!".to_string())); +} + +#[pagetop::test] +async fn translation_with_plural_and_select() { + let _app = service::test::init_service(Application::new().test()).await; + + let l10n = L10n::l("test-shared-photos").with_args(vec![ + ("userName", "Roberto"), + ("photoCount", "3"), + ("userGender", "male"), + ]); + let translation = l10n.using(LangMatch::langid_or_fallback("es-ES")).unwrap(); + assert!(translation.contains("añadido 3 nuevas fotos de él")); +} + +#[pagetop::test] +async fn check_fallback_language() { + let _app = service::test::init_service(Application::new().test()).await; + + let l10n = L10n::l("test-hello-world"); + let translation = l10n.using(LangMatch::langid_or_fallback("xx-YY")); // Fallback a "en-US". + assert_eq!(translation, Some("Hello world!".to_string())); +} + +#[pagetop::test] +async fn check_unknown_key() { + let _app = service::test::init_service(Application::new().test()).await; + + let l10n = L10n::l("non-existent-key"); + let translation = l10n.using(LangMatch::langid_or_fallback("en-US")); + assert_eq!(translation, None); +}