(locale): Refactoriza el sistema de localización

- Modulariza la lógica de localización.
- Actualiza la estructura de `Locale` para mejorar la resolución y
  gestión de idiomas.
- Introduce `RequestLocale` para manejar la negociación de idioma basada
  en las peticiones HTTP.
- Mejora `L10n` para ofrecer una gestión más flexible de traducciones
  con argumentos dinámicos.
- Actualiza la implementación de `LangId` en `Page` para garantizar una
  identificación de idioma coherente.
- Elimina código obsoleto y simplifica la gestión de identificadores de
  idioma.
This commit is contained in:
Manuel Cillero 2025-12-14 14:33:35 +01:00
parent 9297f51b42
commit 7b340a19f3
15 changed files with 789 additions and 465 deletions

210
src/locale/definition.rs Normal file
View file

@ -0,0 +1,210 @@
use crate::{global, trace};
use super::languages::LANGUAGES;
use super::{langid, LanguageIdentifier};
use std::sync::LazyLock;
// Identificador del idioma configurado para la aplicación, si es válido.
static CONFIG_LANGID: LazyLock<Option<&'static LanguageIdentifier>> = LazyLock::new(|| {
Locale::resolve(global::SETTINGS.app.language.as_deref().unwrap_or("")).as_option()
});
// Identificador del idioma de respaldo (predefinido a `"en-US"`).
static FALLBACK_LANGID: LazyLock<LanguageIdentifier> = LazyLock::new(|| langid!("en-US"));
/// Representa el identificador de idioma [`LanguageIdentifier`] asociado a un recurso.
///
/// Este *trait* permite que distintas estructuras expongan su idioma de forma uniforme. Las
/// implementaciones deben garantizar que siempre se devuelve un identificador de idioma válido. Si
/// el recurso no tiene uno asignado, se puede devolver, si procede, el identificador de idioma por
/// defecto de la aplicación ([`Locale::default_langid()`]).
pub trait LangId {
/// Devuelve el identificador de idioma asociado al recurso.
fn langid(&self) -> &'static LanguageIdentifier;
}
/// Resultado de resolver un identificador de idioma.
///
/// Utiliza [`Locale::resolve()`] para transformar una cadena de idioma en un [`LanguageIdentifier`]
/// soportado por PageTop.
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop::prelude::*;
/// // Coincidencia exacta.
/// let lang = Locale::resolve("es-ES");
/// assert_eq!(lang.langid().to_string(), "es-ES");
///
/// // Coincidencia parcial (retrocede al idioma base si no hay variante regional).
/// let lang = Locale::resolve("es-EC");
/// assert_eq!(lang.langid().to_string(), "es-ES"); // Porque "es-EC" no está soportado.
///
/// // Idioma no especificado.
/// let lang = Locale::resolve("");
/// assert_eq!(lang, Locale::Unspecified);
///
/// // Idioma no soportado.
/// let lang = Locale::resolve("ja-JP");
/// assert_eq!(lang, Locale::Unsupported("ja-JP".to_string()));
/// ```
///
/// Con la siguiente instrucción siempre se obtiene un [`LanguageIdentifier`] válido, ya sea porque
/// resuelve un idioma soportado o porque se aplica el idioma por defecto o, en último término, el
/// de respaldo (`"en-US"`):
///
/// ```rust
/// # use pagetop::prelude::*;
/// // Idioma por defecto si no resuelve.
/// let lang = Locale::resolve("it-IT");
/// let langid = lang.langid();
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Locale {
/// No se ha especificado ningún identificador de idioma.
///
/// Se usa cuando la cadena de idioma está vacía o no se puede obtener un idioma válido de la
/// petición HTTP.
Unspecified,
/// El identificador se ha resuelto a un idioma soportado por PageTop.
///
/// Se utiliza cuando se encuentra un [`LanguageIdentifier`] en la lista de idiomas soportados
/// por PageTop que coincide exactamente con el identificador de idioma (p. ej. `"es-ES"`) o
/// con el identificador del idioma base (p. ej. `"es"`).
Resolved(&'static LanguageIdentifier),
/// El identificador de idioma no está soportado por PageTop.
Unsupported(String),
}
impl Default for Locale {
/// Resuelve al idioma por defecto y, si no está disponible, al idioma de respaldo (`"en-US"`).
fn default() -> Self {
Locale::Resolved(Locale::default_langid())
}
}
impl Locale {
/// Resuelve `language` y devuelve la variante [`Locale`] apropiada.
///
/// - Si la cadena está vacía o contiene solo espacios, devuelve [`Locale::Unspecified`].
/// - Si el idioma se reconoce (ya sea como código completo o como idioma base), devuelve
/// [`Locale::Resolved`].
/// - En caso contrario, devuelve [`Locale::Unsupported`] con la cadena original.
pub fn resolve(language: impl AsRef<str>) -> Self {
let language = language.as_ref().trim();
// Rechaza cadenas vacías.
if language.is_empty() {
return Self::Unspecified;
}
// Intenta aplicar coincidencia exacta con el código completo (p. ej. "es-MX").
let lang = language.to_ascii_lowercase();
if let Some(langid) = LANGUAGES.get(lang.as_str()).map(|(langid, _)| langid) {
return Self::Resolved(langid);
}
// Si la variante regional no existe, retrocede al idioma base (p. ej. "es").
if let Some((base_lang, _)) = lang.split_once('-') {
if let Some(langid) = LANGUAGES.get(base_lang).map(|(langid, _)| langid) {
return Self::Resolved(langid);
}
}
// En caso contrario, indica que el idioma no está soportado.
Self::Unsupported(language.to_string())
}
/// Devuelve el [`LanguageIdentifier`] si el idioma fue reconocido.
///
/// Solo retorna `Some` si la variante es [`Locale::Resolved`]. En cualquier otro caso (por
/// ejemplo, si el identificador es vacío o no está soportado), devuelve `None`.
///
/// Este método es útil cuando se desea acceder directamente al idioma reconocido sin aplicar el
/// idioma por defecto ni el de respaldo.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// let lang = Locale::resolve("es-ES").as_option();
/// assert_eq!(lang.unwrap().to_string(), "es-ES");
///
/// let lang = Locale::resolve("ja-JP").as_option();
/// assert!(lang.is_none());
/// ```
#[inline]
pub fn as_option(&self) -> Option<&'static LanguageIdentifier> {
match self {
Locale::Resolved(l) => Some(l),
_ => None,
}
}
// **< Locale HELPERS >*************************************************************************
/// Inicializa el idioma por defecto que utilizará la aplicación.
///
/// Debe llamarse durante la inicialización para indicar si el idioma por defecto procede de la
/// configuración, de una configuración no válida o del idioma de respaldo.
pub(crate) fn init() {
match global::SETTINGS.app.language.as_deref() {
Some(raw) if !raw.trim().is_empty() => {
if let Some(langid) = *CONFIG_LANGID {
trace::debug!("Default language \"{langid}\" (from config: \"{raw}\")");
} else {
trace::debug!(
"Default language \"{}\" (fallback, invalid config: \"{raw}\")",
*FALLBACK_LANGID
);
}
}
_ => trace::debug!(
"Default language \"{}\" (fallback, no config)",
*FALLBACK_LANGID
),
}
}
/// Devuelve el identificador de idioma configurado explícitamente, si es válido.
///
/// Si no se ha configurado un idioma por defecto o el valor no es válido, devuelve `None`.
pub fn configured_langid() -> Option<&'static LanguageIdentifier> {
*CONFIG_LANGID
}
/// Devuelve siempre el identificador de idioma de respaldo (`"en-US"`).
///
/// Es el idioma garantizado incluso cuando no haya configuración de la aplicación o cuando
/// el valor configurado no sea válido.
pub fn fallback_langid() -> &'static LanguageIdentifier {
&*FALLBACK_LANGID
}
/// Devuelve el identificador de idioma configurado o, en su defecto, el de respaldo.
///
/// Este es el idioma que utiliza internamente [`Locale::default()`] y resulta útil como idioma
/// base cuando no se dispone de un contexto más específico.
pub fn default_langid() -> &'static LanguageIdentifier {
(*CONFIG_LANGID).unwrap_or(&*FALLBACK_LANGID)
}
}
/// Permite a [`Locale`] actuar como proveedor de idioma.
///
/// Devuelve el [`LanguageIdentifier`] si la variante es [`Locale::Resolved`]; en caso contrario,
/// devuelve el idioma por defecto de la aplicación y, si tampoco está disponible, el idioma de
/// respaldo (`"en-US"`).
///
/// Resulta útil para usar un valor de [`Locale`] como fuente de traducción en
/// [`L10n::lookup()`](crate::locale::L10n::lookup) o [`L10n::using()`](crate::locale::L10n::using).
impl LangId for Locale {
#[inline]
fn langid(&self) -> &'static LanguageIdentifier {
match self {
Locale::Resolved(l) => l,
_ => Locale::default_langid(),
}
}
}

194
src/locale/l10n.rs Normal file
View file

@ -0,0 +1,194 @@
use crate::html::{Markup, PreEscaped};
use crate::{include_locales, AutoDefault};
use super::{LangId, Locale};
use fluent_templates::Loader;
use fluent_templates::StaticLoader as Locales;
use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt;
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, Clone, Debug)]
enum L10nOp {
#[default]
None,
Text(Cow<'static, str>),
Translate(Cow<'static, str>),
}
/// 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 predefinidas de PageTop (`l()`).
/// - Una clave para traducir de un conjunto concreto de traducciones (`t()`).
///
/// # Ejemplo
///
/// Los argumentos dinámicos se añaden con `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")
/// .get();
/// ```
///
/// También sirve para traducciones contra un conjunto de recursos concreto.
///
/// ```rust,ignore
/// // Traducción con clave, conjunto de traducciones y fuente de idioma.
/// let bye = L10n::t("goodbye", &LOCALES_CUSTOM).lookup(&Locale::resolve("it"));
/// ```
#[derive(AutoDefault, Clone)]
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<Cow<'static, str>>) -> Self {
L10n {
op: L10nOp::Text(text.into()),
..Default::default()
}
}
/// **l** = *“lookup”*. Crea una instancia para traducir usando una clave del conjunto de
/// traducciones predefinidas.
pub fn l(key: impl Into<Cow<'static, str>>) -> Self {
L10n {
op: L10nOp::Translate(key.into()),
..Default::default()
}
}
/// **t** = *“translate”*. Crea una instancia para traducir usando una clave de un conjunto de
/// traducciones específico.
pub fn t(key: impl Into<Cow<'static, str>>, 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 vez (p. ej. usando la macro
/// [`util::kv!`](crate::util::kv) o 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 o, si no procede, el de respaldo de la
/// aplicación.
///
/// Devuelve `None` si no aplica o no encuentra una traducción válida.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// let text = L10n::l("greeting").with_arg("name", "Manuel").get();
/// ```
pub fn get(&self) -> Option<String> {
self.lookup(&Locale::default())
}
/// Resuelve la traducción usando la fuente de idioma proporcionada.
///
/// Devuelve `None` si no aplica o no encuentra una traducción válida.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// struct ResourceLang;
///
/// impl LangId for ResourceLang {
/// fn langid(&self) -> &'static LanguageIdentifier {
/// Locale::resolve("es-MX").langid()
/// }
/// }
///
/// let r = ResourceLang;
/// let text = L10n::l("greeting").with_arg("name", "Usuario").lookup(&r);
/// ```
pub fn lookup(&self, language: &impl LangId) -> Option<String> {
match &self.op {
L10nOp::None => None,
L10nOp::Text(text) => Some(text.clone().into_owned()),
L10nOp::Translate(key) => {
if self.args.is_empty() {
self.locales.try_lookup(language.langid(), key.as_ref())
} else {
self.locales.try_lookup_with_args(
language.langid(),
key.as_ref(),
&self
.args
.iter()
.map(|(k, v)| (Cow::Owned(k.clone()), v.clone().into()))
.collect::<HashMap<_, _>>(),
)
}
}
}
}
/// Traduce el texto y lo devuelve como [`Markup`] usando la fuente de idioma proporcionada.
///
/// Si no se encuentra una traducción válida, devuelve una cadena vacía.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// let html = L10n::l("welcome.message").using(&Locale::resolve("es"));
/// ```
pub fn using(&self, language: &impl LangId) -> Markup {
PreEscaped(self.lookup(language).unwrap_or_default())
}
}
impl fmt::Debug for L10n {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("L10n")
.field("op", &self.op)
.field("args", &self.args)
// No se puede mostrar `locales`; se representa con un texto fijo.
.field("locales", &"<StaticLoader>")
.finish()
}
}

27
src/locale/languages.rs Normal file
View file

@ -0,0 +1,27 @@
use crate::util;
use super::{langid, LanguageIdentifier};
use std::collections::HashMap;
use std::sync::LazyLock;
/// Tabla de idiomas soportados por PageTop.
///
/// Cada entrada asocia un código de idioma en minúsculas (por ejemplo, `"en"` o `"es-es"`) con:
///
/// - Su [`LanguageIdentifier`] canónico.
/// - La clave de traducción definida en `src/locale/{lang}/languages.ftl` para mostrar su nombre en
/// el idioma activo.
///
/// Esto permite admitir alias de idioma como `"en"` o `"es"` y, al mismo tiempo, mantener un
/// identificador de idioma canónico (por ejemplo, `langid!("en-US")` o `langid!("es-ES")`).
pub(crate) static LANGUAGES: LazyLock<HashMap<&str, (LanguageIdentifier, &str)>> =
LazyLock::new(|| {
util::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" ),
]
});

178
src/locale/request.rs Normal file
View file

@ -0,0 +1,178 @@
use crate::global;
use crate::service::HttpRequest;
use super::{LangId, LanguageIdentifier, Locale};
/// Representa el idioma asociado a una petición HTTP.
///
/// Determina qué idioma se usará para renderizar la respuesta asociada a una petición. También
/// indica si es necesario propagar ese idioma en los enlaces usando el parámetro de *query*
/// `?lang=...`. El comportamiento concreto depende de la política global
/// [`LangNegotiation`](crate::global::LangNegotiation) configurada en la aplicación.
///
/// El idioma resultante se expone a través del *trait* [`LangId`], de modo que pueda usarse
/// [`RequestLocale`] como cualquier otra fuente de idioma en PageTop.
pub struct RequestLocale {
// Idioma elegido por la aplicación para esta petición, combinando la configuración, la cabecera
// `Accept-Language` y/o el idioma de respaldo.
base: &'static LanguageIdentifier,
// Idioma finalmente aplicado a la petición (puede coincidir con `base` o no).
effective: &'static LanguageIdentifier,
}
impl RequestLocale {
/// Construye un `RequestLocale` a partir de una petición HTTP.
///
/// El idioma de la petición se decide según la estrategia definida por
/// [`LangNegotiation`](crate::global::LangNegotiation):
///
/// - [`LangNegotiation::Full`](crate::global::LangNegotiation::Full) determina el idioma en
/// este orden:
/// 1. Parámetro de *query* `?lang=...`, si existe y corresponde a un idioma soportado.
/// 2. [`Locale::configured_langid()`], si la aplicación tiene un idioma por defecto válido.
/// 3. Cabecera `Accept-Language`, si puede resolverse con [`Locale::resolve()`].
/// 4. Idioma de respaldo.
///
/// - [`LangNegotiation::NoQuery`](crate::global::LangNegotiation::NoQuery) descarta el uso del
/// parámetro `?lang=...` y determina el idioma en este orden:
/// 1. [`Locale::configured_langid()`], si la aplicación tiene un idioma por defecto válido.
/// 2. Cabecera `Accept-Language`, si puede resolverse con [`Locale::resolve()`].
/// 3. Idioma de respaldo.
///
/// - [`LangNegotiation::ConfigOnly`](crate::global::LangNegotiation::ConfigOnly) sólo usa la
/// configuración de la aplicación mediante [`Locale::default_langid()`], sin consultar la
/// cabecera `Accept-Language` ni el parámetro `?lang`. Este modo también aplica el idioma de
/// respaldo si es necesario.
///
/// En todos los casos, el idioma resultante es siempre un [`LanguageIdentifier`] soportado por
/// la aplicación y será el que PageTop utilice para renderizar la respuesta de la petición.
pub fn from_request(request: Option<&HttpRequest>) -> Self {
let mode = global::SETTINGS.app.lang_negotiation;
// Idioma elegido por la aplicación para esta petición, antes de considerar ajustes por URL.
let base: &'static LanguageIdentifier = match mode {
global::LangNegotiation::ConfigOnly => {
// Sólo configuración o, en su defecto, idioma de respaldo.
Locale::default_langid()
}
global::LangNegotiation::Full | global::LangNegotiation::NoQuery => {
if let Some(default) = Locale::configured_langid() {
default
} else {
// Sin idioma por defecto, se evalúa la cabecera `Accept-Language`.
request
.and_then(|req| req.headers().get("Accept-Language"))
.and_then(|value| value.to_str().ok())
.and_then(|header| {
// Puede tener varios idiomas, p. ej. "es-ES,es;q=0.9,en;q=0.8".
//
// Y cada idioma puede aplicar un factor de calidad. Actualmente se
// aplica una estrategia sencilla: usar sólo el primer idioma declarado
// antes de la primera coma e ignorar el resto de entradas y sus
// factores de calidad (`q=...`).
let first = header.split(',').next()?.trim();
// En este primer elemento también puede aparecer `;q=...`, así que se
// extrae únicamente la etiqueta de idioma: "es-ES;q=0.9" -> "es-ES".
let tag = first.split(';').next()?.trim();
// TODO: Mejorar el soporte de `Accept-Language` en el futuro:
//
// - Parsear todos los idiomas con sus factores de calidad (`q`).
// - Ordenar por `q` descendente y por aparición en caso de empate.
// - Ignorar o tratar explícitamente el comodín `*`.
// - Tener en cuenta rangos de idioma (`es`, `en`, etc.) y variantes
// regionales.
// - Añadir tests unitarios para distintas combinaciones de cabecera.
if tag.is_empty() {
None
} else if let Locale::Resolved(langid) = Locale::resolve(tag) {
Some(langid)
} else {
None
}
})
// Si no hay cabecera o no puede resolverse, se usa el idioma de respaldo.
.unwrap_or(Locale::fallback_langid())
}
}
};
// Idioma aplicado a la petición tras considerar la *query* `?lang=...`.
let effective: &'static LanguageIdentifier = match mode {
global::LangNegotiation::ConfigOnly | global::LangNegotiation::NoQuery => {
// En estos modos no se permite que la URL modifique el idioma.
base
}
global::LangNegotiation::Full => {
request
// Se obtiene el valor de `lang` de la petición, si existe.
.and_then(|req| {
req.query_string().split('&').find_map(|pair| {
let mut param = pair.splitn(2, '=');
match (param.next(), param.next()) {
(Some("lang"), Some(value)) if !value.is_empty() => Some(value),
_ => None,
}
})
})
// Se comprueba si es un idioma soportado.
.and_then(|language| {
if let Locale::Resolved(langid) = Locale::resolve(language) {
Some(langid)
} else {
None
}
})
// Si no hay `lang` o no es válido, se usa `base`.
.unwrap_or(base)
}
};
RequestLocale { base, effective }
}
/// Fuerza el idioma que se utilizará para las traducciones de esta petición.
///
/// Este método permite sustituir el idioma calculado (por configuración, cabecera, `?lang`,
/// etc.) por otro idioma. Normalmente se usa cuando quieres que toda la respuesta se genere en
/// un idioma concreto, independientemente de cómo se haya llegado a él.
#[inline]
pub fn with_langid(&mut self, language: &impl LangId) -> &mut Self {
self.effective = language.langid();
self
}
/// Indica si conviene propagar `lang=...` en los enlaces generados.
///
/// El comportamiento depende de la estrategia configurada en
/// [`LangNegotiation`](crate::global::LangNegotiation):
///
/// - En modo [`LangNegotiation::Full`](crate::global::LangNegotiation::Full) devuelve `true`
/// cuando la respuesta se está generando en un idioma distinto del que la aplicación habría
/// elegido automáticamente a partir de la configuración, el navegador y el idioma de
/// respaldo. En la práctica suele significar que el usuario ha pedido expresamente otro
/// idioma (por ejemplo, con `?lang=...`) o que se ha forzado con
/// [`with_langid()`](Self::with_langid), y por tanto es recomendable propagar `lang=...` en
/// los enlaces para mantener esa preferencia mientras se navega.
///
/// - En modos [`LangNegotiation::NoQuery`](crate::global::LangNegotiation::NoQuery) y
/// [`LangNegotiation::ConfigOnly`](crate::global::LangNegotiation::ConfigOnly) siempre
/// devuelve `false`, ya que en estas estrategias la aplicación no utiliza el parámetro
/// `?lang=...` para seleccionar ni para propagar el idioma.
#[inline]
pub(crate) fn needs_lang_query(&self) -> bool {
match global::SETTINGS.app.lang_negotiation {
global::LangNegotiation::Full => self.base != self.effective,
global::LangNegotiation::NoQuery | global::LangNegotiation::ConfigOnly => false,
}
}
}
/// Permite a [`RequestLocale`] actuar como proveedor de idioma.
impl LangId for RequestLocale {
#[inline]
fn langid(&self) -> &'static LanguageIdentifier {
self.effective
}
}