Compare commits

...

2 commits

Author SHA1 Message Date
208ad83bea Añade soporte para localización y traducción
- Incluye recursos Fluent básicos y pruebas asociadas.
- Nueva variable de configuración global para definir el idioma
  predeterminado.
2025-07-09 20:39:39 +02:00
efc4839613 🚨 Aplica retoques por ejecución de clippy 2025-07-08 20:57:39 +02:00
15 changed files with 792 additions and 11 deletions

278
Cargo.lock generated
View file

@ -287,6 +287,16 @@ dependencies = [
"alloc-stdlib", "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]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.19.0" version = "3.19.0"
@ -384,6 +394,25 @@ dependencies = [
"crossbeam-utils", "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]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.21" version = "0.8.21"
@ -492,6 +521,81 @@ dependencies = [
"miniz_oxide", "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]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@ -571,6 +675,19 @@ version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 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]] [[package]]
name = "h2" name = "h2"
version = "0.3.26" version = "0.3.26"
@ -726,6 +843,22 @@ dependencies = [
"icu_properties", "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]] [[package]]
name = "impl-more" name = "impl-more"
version = "0.1.9" version = "0.1.9"
@ -742,6 +875,25 @@ dependencies = [
"hashbrown", "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]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.15" version = "1.0.15"
@ -918,12 +1070,13 @@ checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]] [[package]]
name = "pagetop" name = "pagetop"
version = "0.0.4" version = "0.0.5"
dependencies = [ dependencies = [
"actix-web", "actix-web",
"colored", "colored",
"config", "config",
"figlet-rs", "figlet-rs",
"fluent-templates",
"itoa", "itoa",
"pagetop-macros", "pagetop-macros",
"serde", "serde",
@ -933,6 +1086,7 @@ dependencies = [
"tracing-actix-web", "tracing-actix-web",
"tracing-appender", "tracing-appender",
"tracing-subscriber", "tracing-subscriber",
"unic-langid",
] ]
[[package]] [[package]]
@ -1052,6 +1206,12 @@ dependencies = [
"toml_edit", "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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.95" version = "1.0.95"
@ -1182,6 +1342,18 @@ version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" 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]] [[package]]
name = "rustix" name = "rustix"
version = "1.0.7" version = "1.0.7"
@ -1207,12 +1379,36 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 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]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 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]] [[package]]
name = "serde" name = "serde"
version = "1.0.219" version = "1.0.219"
@ -1323,6 +1519,15 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.0" version = "1.2.0"
@ -1604,12 +1809,64 @@ dependencies = [
"tracing-serde", "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]] [[package]]
name = "typenum" name = "typenum"
version = "1.18.0" version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" 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]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.18" version = "1.0.18"
@ -1662,6 +1919,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 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]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.1+wasi-snapshot-preview1" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 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]] [[package]]
name = "winapi-x86_64-pc-windows-gnu" name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0" version = "0.4.0"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "pagetop" name = "pagetop"
version = "0.0.4" version = "0.0.5"
edition = "2021" edition = "2021"
description = """\ description = """\
@ -28,6 +28,9 @@ tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.19", features = ["json", "env-filter"] } tracing-subscriber = { version = "0.3.19", features = ["json", "env-filter"] }
tracing-actix-web = "0.7.18" tracing-actix-web = "0.7.18"
fluent-templates = "0.13.0"
unic-langid = { version = "0.9.6", features = ["macros"] }
actix-web = "4.11.0" actix-web = "4.11.0"
pagetop-macros.workspace = true pagetop-macros.workspace = true

View file

@ -2,7 +2,7 @@
mod figfont; mod figfont;
use crate::{global, service, trace}; use crate::{global, locale, service, trace};
use substring::Substring; use substring::Substring;
@ -11,6 +11,12 @@ use std::sync::LazyLock;
pub struct Application; pub struct Application;
impl Default for Application {
fn default() -> Self {
Self::new()
}
}
impl Application { impl Application {
/// Crea una instancia de la aplicación. /// Crea una instancia de la aplicación.
pub fn new() -> Self { pub fn new() -> Self {
@ -20,6 +26,9 @@ impl Application {
// Inicia gestión de trazas y registro de eventos (logging). // Inicia gestión de trazas y registro de eventos (logging).
LazyLock::force(&trace::TRACING); LazyLock::force(&trace::TRACING);
// Valida el identificador de idioma por defecto.
LazyLock::force(&locale::DEFAULT_LANGID);
Self Self
} }

View file

@ -23,7 +23,7 @@
//! * Si `PAGETOP_RUN_MODE` no está definida, se asume el valor `default`, y `PageTop` intentará //! * Si `PAGETOP_RUN_MODE` no está definida, se asume el valor `default`, y `PageTop` intentará
//! cargar *config/default.toml* si el archivo existe. //! cargar *config/default.toml* si el archivo existe.
//! //!
//! * Útil para definir configuraciones específicas por entorno, garantizando que cada uno (p.e. //! * Útil para definir configuraciones específicas por entorno, garantizando que cada uno (p.ej.
//! *dev*, *staging* o *production*) disponga de sus propias opciones, como claves de API, //! *dev*, *staging* o *production*) disponga de sus propias opciones, como claves de API,
//! URLs o ajustes de rendimiento, sin afectar a los demás. //! URLs o ajustes de rendimiento, sin afectar a los demás.
//! //!
@ -126,7 +126,7 @@ const DEFAULT_RUN_MODE: &str = "default";
/// Valores originales cargados desde los archivos de configuración como pares `clave = valor`. /// Valores originales cargados desde los archivos de configuración como pares `clave = valor`.
pub static CONFIG_VALUES: LazyLock<ConfigBuilder<DefaultState>> = LazyLock::new(|| { pub static CONFIG_VALUES: LazyLock<ConfigBuilder<DefaultState>> = LazyLock::new(|| {
// Determina el directorio de configuración: // Determina el directorio de configuración:
// - Usa CONFIG_DIR si está definido en el entorno (p.e.: CONFIG_DIR=/etc/myapp ./myapp). // - Usa CONFIG_DIR si está definido en el entorno (p.ej.: CONFIG_DIR=/etc/myapp ./myapp).
// - Si no, intenta DEFAULT_CONFIG_DIR dentro del proyecto (en CARGO_MANIFEST_DIR). // - Si no, intenta DEFAULT_CONFIG_DIR dentro del proyecto (en CARGO_MANIFEST_DIR).
// - Si nada de esto aplica, entonces usa DEFAULT_CONFIG_DIR relativo al ejecutable. // - Si nada de esto aplica, entonces usa DEFAULT_CONFIG_DIR relativo al ejecutable.
let config_dir: PathBuf = if let Ok(env_dir) = env::var("CONFIG_DIR") { let config_dir: PathBuf = if let Ok(env_dir) = env::var("CONFIG_DIR") {
@ -143,15 +143,15 @@ pub static CONFIG_VALUES: LazyLock<ConfigBuilder<DefaultState>> = LazyLock::new(
}; };
// Determina el modo de ejecución según la variable de entorno PAGETOP_RUN_MODE. Por defecto usa // Determina el modo de ejecución según la variable de entorno PAGETOP_RUN_MODE. Por defecto usa
// DEFAULT_RUN_MODE si no está definida (p.e.: PAGETOP_RUN_MODE=production ./myapp). // DEFAULT_RUN_MODE si no está definida (p.ej.: PAGETOP_RUN_MODE=production ./myapp).
let rm = env::var("PAGETOP_RUN_MODE").unwrap_or_else(|_| DEFAULT_RUN_MODE.into()); let rm = env::var("PAGETOP_RUN_MODE").unwrap_or_else(|_| DEFAULT_RUN_MODE.into());
Config::builder() Config::builder()
// 1. Configuración común para todos los entornos (common.toml). // 1. Configuración común para todos los entornos (common.toml).
.add_source(File::from(config_dir.join("common.toml")).required(false)) .add_source(File::from(config_dir.join("common.toml")).required(false))
// 2. Configuración específica del entorno (p.e.: default.toml, production.toml). // 2. Configuración específica del entorno (p.ej.: default.toml, production.toml).
.add_source(File::from(config_dir.join(format!("{rm}.toml"))).required(false)) .add_source(File::from(config_dir.join(format!("{rm}.toml"))).required(false))
// 3. Configuración local reservada para cada entorno (p.e.: local.default.toml). // 3. Configuración local reservada para cada entorno (p.ej.: local.default.toml).
.add_source(File::from(config_dir.join(format!("local.{rm}.toml"))).required(false)) .add_source(File::from(config_dir.join(format!("local.{rm}.toml"))).required(false))
// 4. Configuración local común (local.toml). // 4. Configuración local común (local.toml).
.add_source(File::from(config_dir.join("local.toml")).required(false)) .add_source(File::from(config_dir.join("local.toml")).required(false))
@ -217,7 +217,7 @@ pub static CONFIG_VALUES: LazyLock<ConfigBuilder<DefaultState>> = LazyLock::new(
/// * **Valores por defecto**. Declara un valor por defecto para cada clave obligatoria. Las claves /// * **Valores por defecto**. Declara un valor por defecto para cada clave obligatoria. Las claves
/// opcionales pueden ser `Option<T>`. /// opcionales pueden ser `Option<T>`.
/// ///
/// * **Secciones únicas**. Agrupa tus claves dentro de una sección exclusiva (p.e. `[blog]`) para /// * **Secciones únicas**. Agrupa tus claves dentro de una sección exclusiva (p.ej. `[blog]`) para
/// evitar colisiones con otras librerías. /// evitar colisiones con otras librerías.
/// ///
/// * **Solo lectura**. La variable generada es inmutable durante toda la vida del programa. Para /// * **Solo lectura**. La variable generada es inmutable durante toda la vida del programa. Para
@ -236,7 +236,6 @@ pub static CONFIG_VALUES: LazyLock<ConfigBuilder<DefaultState>> = LazyLock::new(
/// [dependencies] /// [dependencies]
/// serde = { version = "1.0", features = ["derive"] } /// serde = { version = "1.0", features = ["derive"] }
/// ``` /// ```
#[macro_export] #[macro_export]
macro_rules! include_config { macro_rules! include_config {
( $SETTINGS_NAME:ident : $Settings_Type:ty => [ $( $k:literal => $v:expr ),* $(,)? ] ) => { ( $SETTINGS_NAME:ident : $Settings_Type:ty => [ $( $k:literal => $v:expr ),* $(,)? ] ) => {

View file

@ -8,6 +8,7 @@ include_config!(SETTINGS: Settings => [
// [app] // [app]
"app.name" => "Sample", "app.name" => "Sample",
"app.description" => "Developed with the amazing PageTop framework.", "app.description" => "Developed with the amazing PageTop framework.",
"app.language" => "en-US",
"app.startup_banner" => "Slant", "app.startup_banner" => "Slant",
// [log] // [log]
@ -38,6 +39,8 @@ pub struct App {
pub name: String, pub name: String,
/// Breve descripción de la aplicación. /// Breve descripción de la aplicación.
pub description: String, pub description: String,
/// Idioma predeterminado (localización).
pub language: String,
/// Banner ASCII mostrado al inicio: *"Off"* (desactivado), *"Slant"*, *"Small"*, *"Speed"* o /// Banner ASCII mostrado al inicio: *"Off"* (desactivado), *"Slant"*, *"Small"*, *"Speed"* o
/// *"Starwars"*. /// *"Starwars"*.
pub startup_banner: String, pub startup_banner: String,

View file

@ -36,6 +36,8 @@ pub use pagetop_macros::{html, main, test, AutoDefault};
// API ********************************************************************************************* // API *********************************************************************************************
// Funciones y macros útiles.
pub mod util;
// Carga las opciones de configuración. // Carga las opciones de configuración.
pub mod config; pub mod config;
// Opciones de configuración globales. // Opciones de configuración globales.
@ -44,6 +46,8 @@ pub mod global;
pub mod trace; pub mod trace;
// HTML en código. // HTML en código.
pub mod html; pub mod html;
// Localización.
pub mod locale;
// Gestión del servidor y servicios web. // Gestión del servidor y servicios web.
pub mod service; pub mod service;
// Prepara y ejecuta la aplicación. // Prepara y ejecuta la aplicación.

364
src/locale.rs Normal file
View file

@ -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<HashMap<&str, (LanguageIdentifier, &str)>> = 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<LanguageIdentifier> = 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<str>) -> 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<str>) -> &'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<String, String>,
}
impl L10n {
/// **n** = *“native”*. Crea una instancia con una cadena literal sin traducción.
pub fn n(text: impl Into<String>) -> 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<String>) -> 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<String>, 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<String>, value: impl Into<String>) -> 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<I, K, V>(mut self, args: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
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<String> {
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<String> {
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}")
}
}

View file

@ -0,0 +1,5 @@
english = English
english_british = English (British)
english_united_states = English (United States)
spanish = Spanish
spanish_spain = Spanish (Spain)

11
src/locale/en-US/test.ftl Normal file
View file

@ -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
}.

View file

@ -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)

11
src/locale/es-ES/test.ftl Normal file
View file

@ -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
}.

View file

@ -8,17 +8,25 @@ pub use crate::AutoDefault;
// MACROS. // MACROS.
// crate::util
pub use crate::hm;
// crate::config // crate::config
pub use crate::include_config; pub use crate::include_config;
// crate::locale
pub use crate::include_locales;
// API. // API.
pub use crate::util;
pub use crate::global; pub use crate::global;
pub use crate::trace; pub use crate::trace;
pub use crate::html::*; pub use crate::html::*;
pub use crate::locale::*;
pub use crate::service; pub use crate::service;
pub use crate::app::Application; pub use crate::app::Application;

View file

@ -33,7 +33,6 @@ use std::sync::LazyLock;
/// Dado que las trazas o eventos registrados poco antes de un fallo suelen ser cruciales para /// Dado que las trazas o eventos registrados poco antes de un fallo suelen ser cruciales para
/// diagnosticar la causa, `Lazy<WorkerGuard>` garantiza que todos los registros almacenados se /// diagnosticar la causa, `Lazy<WorkerGuard>` garantiza que todos los registros almacenados se
/// envíen antes de finalizar la ejecución. /// envíen antes de finalizar la ejecución.
#[rustfmt::skip] #[rustfmt::skip]
pub(crate) static TRACING: LazyLock<WorkerGuard> = LazyLock::new(|| { pub(crate) static TRACING: LazyLock<WorkerGuard> = LazyLock::new(|| {
let env_filter = EnvFilter::try_new(&global::SETTINGS.log.tracing) let env_filter = EnvFilter::try_new(&global::SETTINGS.log.tracing)

26
src/util.rs Normal file
View file

@ -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
}};
}

58
tests/locale.rs Normal file
View file

@ -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);
}