✨ 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.
This commit is contained in:
parent
efc4839613
commit
208ad83bea
13 changed files with 780 additions and 3 deletions
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
364
src/locale.rs
Normal file
364
src/locale.rs
Normal 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}")
|
||||
}
|
||||
}
|
5
src/locale/en-US/languages.ftl
Normal file
5
src/locale/en-US/languages.ftl
Normal 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
11
src/locale/en-US/test.ftl
Normal 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
|
||||
}.
|
5
src/locale/es-ES/languages.ftl
Normal file
5
src/locale/es-ES/languages.ftl
Normal 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
11
src/locale/es-ES/test.ftl
Normal 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
|
||||
}.
|
|
@ -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;
|
||||
|
|
26
src/util.rs
Normal file
26
src/util.rs
Normal 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
|
||||
}};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue