From b3be00574f39ec8e7664f591b8e69083a0b26e10 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 14 Dec 2025 14:33:35 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20[locale]=20Refactoriza=20el=20siste?= =?UTF-8?q?ma=20de=20localizaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- examples/navbar-menus.rs | 37 +- .../pagetop-bootsier/src/theme/navbar.rs | 6 +- .../src/theme/navbar/brand.rs | 14 +- src/app.rs | 7 +- src/base/extension/welcome.rs | 25 +- src/core/component.rs | 33 +- src/core/component/context.rs | 51 ++- src/global.rs | 68 ++- src/html/route.rs | 4 +- src/locale.rs | 399 ++---------------- src/locale/definition.rs | 210 +++++++++ src/locale/l10n.rs | 194 +++++++++ src/locale/languages.rs | 27 ++ src/locale/request.rs | 178 ++++++++ src/response/page.rs | 1 + 15 files changed, 789 insertions(+), 465 deletions(-) create mode 100644 src/locale/definition.rs create mode 100644 src/locale/l10n.rs create mode 100644 src/locale/languages.rs create mode 100644 src/locale/request.rs diff --git a/examples/navbar-menus.rs b/examples/navbar-menus.rs index d534afb8..a2ae76d0 100644 --- a/examples/navbar-menus.rs +++ b/examples/navbar-menus.rs @@ -10,16 +10,13 @@ impl Extension for SuperMenu { } fn initialize(&self) { - let home_path = |cx: &Context| util::join!("/lang/", cx.langid().language.as_str()).into(); - - let navbar_menu = Navbar::brand_left(navbar::Brand::new().with_path(Some(home_path))) + let navbar_menu = Navbar::brand_left(navbar::Brand::new()) .with_expand(BreakPoint::LG) .add_item(navbar::Item::nav( Nav::new() - .add_item(nav::Item::link( - L10n::l("sample_menus_item_link"), - home_path, - )) + .add_item(nav::Item::link(L10n::l("sample_menus_item_link"), |cx| { + cx.route("/") + })) .add_item(nav::Item::link_blank( L10n::l("sample_menus_item_blank"), |_| "https://docs.rs/pagetop".into(), @@ -30,11 +27,11 @@ impl Extension for SuperMenu { .add_item(dropdown::Item::header(L10n::l("sample_menus_dev_header"))) .add_item(dropdown::Item::link( L10n::l("sample_menus_dev_getting_started"), - |_| "/dev/getting-started".into(), + |cx| cx.route("/dev/getting-started"), )) .add_item(dropdown::Item::link( L10n::l("sample_menus_dev_guides"), - |_| "/dev/guides".into(), + |cx| cx.route("/dev/guides"), )) .add_item(dropdown::Item::link_blank( L10n::l("sample_menus_dev_forum"), @@ -44,14 +41,14 @@ impl Extension for SuperMenu { .add_item(dropdown::Item::header(L10n::l("sample_menus_sdk_header"))) .add_item(dropdown::Item::link( L10n::l("sample_menus_sdk_rust"), - |_| "/dev/sdks/rust".into(), + |cx| cx.route("/dev/sdks/rust"), )) - .add_item(dropdown::Item::link(L10n::l("sample_menus_sdk_js"), |_| { - "/dev/sdks/js".into() + .add_item(dropdown::Item::link(L10n::l("sample_menus_sdk_js"), |cx| { + cx.route("/dev/sdks/js") })) .add_item(dropdown::Item::link( L10n::l("sample_menus_sdk_python"), - |_| "/dev/sdks/python".into(), + |cx| cx.route("/dev/sdks/python"), )) .add_item(dropdown::Item::divider()) .add_item(dropdown::Item::header(L10n::l( @@ -59,22 +56,22 @@ impl Extension for SuperMenu { ))) .add_item(dropdown::Item::link( L10n::l("sample_menus_plugin_auth"), - |_| "/dev/sdks/rust/plugins/auth".into(), + |cx| cx.route("/dev/sdks/rust/plugins/auth"), )) .add_item(dropdown::Item::link( L10n::l("sample_menus_plugin_cache"), - |_| "/dev/sdks/rust/plugins/cache".into(), + |cx| cx.route("/dev/sdks/rust/plugins/cache"), )) .add_item(dropdown::Item::divider()) .add_item(dropdown::Item::label(L10n::l("sample_menus_item_label"))) .add_item(dropdown::Item::link_disabled( L10n::l("sample_menus_item_disabled"), - |_| "#".into(), + |cx| cx.route("#"), )), )) .add_item(nav::Item::link_disabled( L10n::l("sample_menus_item_disabled"), - |_| "#".into(), + |cx| cx.route("#"), )), )) .add_item(navbar::Item::nav( @@ -85,10 +82,10 @@ impl Extension for SuperMenu { ) .add_item(nav::Item::link( L10n::l("sample_menus_item_sign_up"), - |_| "/auth/sign-up".into(), + |cx| cx.route("/auth/sign-up"), )) - .add_item(nav::Item::link(L10n::l("sample_menus_item_login"), |_| { - "/auth/login".into() + .add_item(nav::Item::link(L10n::l("sample_menus_item_login"), |cx| { + cx.route("/auth/login") })), )); diff --git a/extensions/pagetop-bootsier/src/theme/navbar.rs b/extensions/pagetop-bootsier/src/theme/navbar.rs index b293b614..717ec679 100644 --- a/extensions/pagetop-bootsier/src/theme/navbar.rs +++ b/extensions/pagetop-bootsier/src/theme/navbar.rs @@ -45,7 +45,7 @@ //! # use pagetop_bootsier::prelude::*; //! let brand = navbar::Brand::new() //! .with_title(L10n::n("PageTop")) -//! .with_path(Some(|_| "/".into())); +//! .with_route(Some(|cx| cx.route("/"))); //! //! let navbar = Navbar::brand_left(brand) //! .add_item(navbar::Item::nav( @@ -72,7 +72,7 @@ //! # use pagetop_bootsier::prelude::*; //! let brand = navbar::Brand::new() //! .with_title(L10n::n("Intranet")) -//! .with_path(Some(|_| "/".into())); +//! .with_route(Some(|cx| cx.route("/"))); //! //! let navbar = Navbar::brand_right(brand) //! .with_expand(BreakPoint::LG) @@ -115,7 +115,7 @@ //! # use pagetop_bootsier::prelude::*; //! let brand = navbar::Brand::new() //! .with_title(L10n::n("Main App")) -//! .with_path(Some(|_| "/".into())); +//! .with_route(Some(|cx| cx.route("/"))); //! //! let navbar = Navbar::brand_left(brand) //! .with_position(navbar::Position::FixedTop) diff --git a/extensions/pagetop-bootsier/src/theme/navbar/brand.rs b/extensions/pagetop-bootsier/src/theme/navbar/brand.rs index 2fc31ef7..2d4eef9e 100644 --- a/extensions/pagetop-bootsier/src/theme/navbar/brand.rs +++ b/extensions/pagetop-bootsier/src/theme/navbar/brand.rs @@ -6,7 +6,7 @@ use crate::prelude::*; /// /// Representa la identidad del sitio con una imagen, título y eslogan: /// -/// - Si hay URL ([`with_path()`](Self::with_path)), el bloque completo actúa como enlace. Por +/// - Si hay URL ([`with_route()`](Self::with_route)), el bloque completo actúa como enlace. Por /// defecto enlaza a la raíz del sitio (`/`). /// - Si no hay imagen ([`with_image()`](Self::with_image)) ni título /// ([`with_title()`](Self::with_title)), la marca de identidad no se renderiza. @@ -23,8 +23,8 @@ pub struct Brand { /// Devuelve el eslogan de la marca. slogan: L10n, /// Devuelve la función que resuelve la URL asociada a la marca (si existe). - #[default(_code = "Some(|_| \"/\".into())")] - path: Option, + #[default(_code = "Some(|cx| cx.route(\"/\"))")] + route: Option, } impl Component for Brand { @@ -44,8 +44,8 @@ impl Component for Brand { } let slogan = self.slogan().using(cx); PrepareMarkup::With(html! { - @if let Some(path) = self.path() { - a class="navbar-brand" href=(path(cx)) { (image) (title) (slogan) } + @if let Some(route) = self.route() { + a class="navbar-brand" href=(route(cx)) { (image) (title) (slogan) } } @else { span class="navbar-brand" { (image) (title) (slogan) } } @@ -86,8 +86,8 @@ impl Brand { /// Define la URL de destino. Si es `None`, la marca no será un enlace. #[builder_fn] - pub fn with_path(mut self, path: Option) -> Self { - self.path = path; + pub fn with_route(mut self, route: Option) -> Self { + self.route = route; self } } diff --git a/src/app.rs b/src/app.rs index 6ecff369..dd1f8780 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,9 +4,10 @@ mod figfont; use crate::core::{extension, extension::ExtensionRef}; use crate::html::Markup; +use crate::locale::Locale; use crate::response::page::{ErrorPage, ResultPage}; use crate::service::HttpRequest; -use crate::{global, locale, service, trace, PAGETOP_VERSION}; +use crate::{global, service, trace, PAGETOP_VERSION}; use actix_session::config::{BrowserSession, PersistentSession, SessionLifecycle}; use actix_session::storage::CookieSessionStore; @@ -57,8 +58,8 @@ 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); + // Inicializa el idioma predeterminado. + Locale::init(); // Registra las extensiones de la aplicación. extension::all::register_extensions(root_extension); diff --git a/src/base/extension/welcome.rs b/src/base/extension/welcome.rs index c4f8b07f..a3fc3777 100644 --- a/src/base/extension/welcome.rs +++ b/src/base/extension/welcome.rs @@ -3,8 +3,7 @@ use crate::prelude::*; /// Página de bienvenida de PageTop. /// /// Esta extensión se instala por defecto si el ajuste de configuración [`global::App::welcome`] es -/// `true`. Muestra una página de bienvenida de PageTop en la ruta raíz (`/`) o en `/lang/{lang}`, -/// siempre que `{lang}` sea un idioma soportado (si no, devuelve una página de error 404). +/// `true`. Muestra una página de bienvenida de PageTop en la ruta raíz (`/`). /// /// No obstante, cualquier extensión puede sobrescribir este comportamiento si utiliza estas mismas /// rutas. @@ -22,33 +21,15 @@ impl Extension for Welcome { } fn configure_service(&self, scfg: &mut service::web::ServiceConfig) { - scfg.route("/", service::web::get().to(home_page)) - .route("/lang/{lang}", service::web::get().to(home_lang)); + scfg.route("/", service::web::get().to(home)); } } -async fn home_page(request: HttpRequest) -> ResultPage { - let language = Locale::from_request(Some(&request)); - home(request, &language) -} - -async fn home_lang( - request: HttpRequest, - path: service::web::Path, -) -> ResultPage { - let language = Locale::resolve(path.into_inner()); - match language { - Locale::Found(_) => home(request, &language), - _ => Err(ErrorPage::NotFound(request)), - } -} - -fn home(request: HttpRequest, language: &impl LangId) -> ResultPage { +async fn home(request: HttpRequest) -> ResultPage { let app = &global::SETTINGS.app.name; Page::new(request) .with_title(L10n::l("welcome_title")) - .with_langid(language) .add_child( Intro::new() .add_child( diff --git a/src/core/component.rs b/src/core/component.rs index db959cea..ba35fe48 100644 --- a/src/core/component.rs +++ b/src/core/component.rs @@ -16,8 +16,8 @@ pub use context::{Context, ContextError, ContextOp, Contextual}; /// Alias de función (*callback*) para **determinar si un componente se renderiza o no**. /// /// Puede usarse para permitir que una instancia concreta de un tipo de componente dado decida -/// dinámicamente durante el proceso de renderizado ([`Component::is_renderable()`]) si se renderiza -/// o no. +/// dinámicamente durante el proceso de renderizado ([`Component::is_renderable()`]), si se +/// renderiza o no. /// /// # Ejemplo /// @@ -69,28 +69,37 @@ pub type FnIsRenderable = fn(cx: &Context) -> bool; /// Alias de función (*callback*) para **resolver una ruta URL** según el contexto de renderizado. /// /// Se usa para generar enlaces dinámicos en función del contexto (petición, idioma, parámetros, -/// etc.). El resultado se devuelve como una [`RoutePath`], que representa un *path* base junto con -/// una lista opcional de parámetros de consulta. +/// etc.). Devuelve una [`RoutePath`], que representa un *path* base junto con una lista opcional de +/// parámetros de consulta. /// -/// Gracias a la implementación de [`RoutePath`] puedes usar rutas estáticas sin asignaciones -/// adicionales: +/// El caso más común es construir rutas relativas dependientes del contexto, normalmente usando +/// [`Context::route`](crate::core::component::Context::route): /// /// ```rust /// # use pagetop::prelude::*; -/// # let static_path: FnPathByContext = -/// |_| "/path/to/resource".into() +/// # let relative_route: FnPathByContext = +/// |cx| cx.route("/path/to/page") /// # ; /// ``` /// -/// O construir rutas dinámicas en tiempo de ejecución: +/// También es posible usar rutas estáticas sin asignaciones adicionales: /// /// ```rust /// # use pagetop::prelude::*; -/// # let dynamic_path: FnPathByContext = +/// # let external_route: FnPathByContext = +/// |_| "https://www.example.com".into() +/// # ; +/// ``` +/// +/// O componer rutas dinámicas en tiempo de ejecución: +/// +/// ```rust +/// # use pagetop::prelude::*; +/// # let dynamic_route: FnPathByContext = /// |cx| RoutePath::new("/user").with_param("id", cx.param::("user_id").unwrap().to_string()) /// # ; /// ``` /// -/// El componente que reciba un [`FnPathByContext`] invocará esta función durante el renderizado -/// para obtener la URL final para asignarla al atributo HTML correspondiente. +/// Los componentes que acepten un [`FnPathByContext`] invocarán esta función durante el renderizado +/// para obtener la URL final que se asignará al atributo HTML correspondiente. pub type FnPathByContext = fn(cx: &Context) -> RoutePath; diff --git a/src/core/component/context.rs b/src/core/component/context.rs index 01b329c1..ec013c14 100644 --- a/src/core/component/context.rs +++ b/src/core/component/context.rs @@ -2,13 +2,14 @@ use crate::core::component::ChildOp; use crate::core::theme::all::DEFAULT_THEME; use crate::core::theme::{ChildrenInRegions, RegionRef, TemplateRef, ThemeRef}; use crate::core::TypeInfo; -use crate::html::{html, Markup}; +use crate::html::{html, Markup, RoutePath}; use crate::html::{Assets, Favicon, JavaScript, StyleSheet}; -use crate::locale::{LangId, LanguageIdentifier, Locale}; +use crate::locale::{LangId, LanguageIdentifier, RequestLocale}; use crate::service::HttpRequest; use crate::{builder_fn, util}; use std::any::Any; +use std::borrow::Cow; use std::collections::HashMap; /// Operaciones para modificar recursos asociados al [`Context`] de un documento. @@ -204,8 +205,8 @@ pub trait Contextual: LangId { /// ``` #[rustfmt::skip] pub struct Context { - request : Option, // Solicitud HTTP de origen. - langid : &'static LanguageIdentifier, // Identificador de idioma. + request : Option, // Petición HTTP de origen. + locale : RequestLocale, // Idioma asociado a la petición. theme : ThemeRef, // Referencia al tema usado para renderizar. template : TemplateRef, // Plantilla usada para renderizar. favicon : Option, // Favicon, si se ha definido. @@ -229,10 +230,10 @@ impl Context { /// recursos cargados. #[rustfmt::skip] pub fn new(request: Option) -> Self { - let langid = Locale::from_request(request.as_ref()).langid(); + let locale = RequestLocale::from_request(request.as_ref()); Context { request, - langid, + locale, theme : *DEFAULT_THEME, template : DEFAULT_THEME.default_template(), favicon : None, @@ -362,22 +363,40 @@ impl Context { pub fn remove_param(&mut self, key: &'static str) -> bool { self.params.remove(key).is_some() } + + // **< Context HELPERS >************************************************************************ + + /// Construye una ruta aplicada al contexto actual. + /// + /// La ruta resultante se envuelve en un [`RoutePath`], que permite añadir parámetros de + /// consulta de forma tipada. Si la política de negociación de idioma actual + /// [`LangNegotiation`](crate::global::LangNegotiation) indica que debe propagarse el idioma + /// para esta petición, se añade o actualiza el parámetro de *query* `lang=...` con el + /// identificador de idioma efectivo del contexto. + /// + /// Esto garantiza que los enlaces generados desde el contexto preservan la preferencia de + /// idioma del usuario cuando procede. + pub fn route(&self, path: impl Into>) -> RoutePath { + let mut route = RoutePath::new(path); + if self.locale.needs_lang_query() { + route.alter_param("lang", self.locale.langid().to_string()); + } + route + } } /// Permite a [`Context`](crate::core::component::Context) actuar como proveedor de idioma. /// -/// Devuelve un [`LanguageIdentifier`] siguiendo este orden de prioridad: +/// Internamente delega en [`RequestLocale`], que tiene en cuenta la petición HTTP, la configuración +/// global de idioma de la aplicación, la cabecera `Accept-Language` y/o el idioma de respaldo. /// -/// 1. Un idioma válido establecido explícitamente con [`Context::with_langid`]. -/// 2. El idioma por defecto configurado para la aplicación. -/// 3. Un idioma válido extraído de la cabecera `Accept-Language` del navegador. -/// 4. Y si ninguna de las opciones anteriores aplica, se usa el idioma de respaldo (`"en-US"`). -/// -/// Resulta útil para usar el [`Context`] como fuente de traducción en +/// Todo ello según la negociación indicada en [`global::SETTINGS.app.lang_negotiation`]. Esto +/// permite que el [`Context`] se use como fuente de idioma coherente en /// [`L10n::lookup()`](crate::locale::L10n::lookup) o [`L10n::using()`](crate::locale::L10n::using). impl LangId for Context { + #[inline] fn langid(&self) -> &'static LanguageIdentifier { - self.langid + self.locale.langid() } } @@ -387,12 +406,14 @@ impl Contextual for Context { #[builder_fn] fn with_request(mut self, request: Option) -> Self { self.request = request; + // Recalcula el locale según la nueva petición y la política de negociación configurada. + self.locale = RequestLocale::from_request(self.request.as_ref()); self } #[builder_fn] fn with_langid(mut self, language: &impl LangId) -> Self { - self.langid = language.langid(); + self.locale.with_langid(language); self } diff --git a/src/global.rs b/src/global.rs index a40669df..b484731b 100644 --- a/src/global.rs +++ b/src/global.rs @@ -1,14 +1,17 @@ //! Opciones de configuración globales. -use crate::include_config; +use crate::{include_config, AutoDefault}; use serde::Deserialize; +// **< SETTINGS >*********************************************************************************** + include_config!(SETTINGS: Settings => [ // [app] "app.name" => "PageTop App", "app.description" => "Developed with the amazing PageTop framework.", "app.theme" => "Basic", + "app.lang_negotiation" => "Full", "app.startup_banner" => "Slant", "app.welcome" => true, @@ -29,6 +32,37 @@ include_config!(SETTINGS: Settings => [ "server.session_lifetime" => 604_800, ]); +// **< LangNegotiation >**************************************************************************** + +/// Modos disponibles para negociar el idioma de una petición HTTP. +/// +/// El ajuste [`global::SETTINGS.app.lang_negotiation`](crate::global::App::lang_negotiation) +/// determina qué fuentes intervienen en la resolución del idioma efectivo utilizado por +/// [`RequestLocale`](crate::locale::RequestLocale) y en la generación de URLs mediante +/// [`Context::route()`](crate::core::component::Context::route). +#[derive(AutoDefault, Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +pub enum LangNegotiation { + /// Usa todas las fuentes disponibles para determinar el idioma, en este orden: comprueba el + /// parámetro `?lang` de la URL; si no está presente o no es válido, usa la cabecera HTTP + /// `Accept-Language`; si tampoco está disponible o no es válido, usa el idioma configurado en + /// [`global::SETTINGS.app.language`](crate::global::App::language) o, en su defecto, el idioma + /// de respaldo. Es el comportamiento por defecto. + #[default] + Full, + + /// Igual que `LangNegotiation::Full`, pero sin tener en cuenta el parámetro `?lang` de la URL. + /// El idioma depende únicamente de la cabecera `Accept-Language` del navegador y, en última + /// instancia, de la configuración o idioma de respaldo. + NoQuery, + + /// Usa sólo la configuración o, en su defecto, el idioma de respaldo; ignora la cabecera + /// `Accept-Language` y el parámetro de la URL. Este modo proporciona un comportamiento estable + /// con idioma fijo. + ConfigOnly, +} + +// **< Settings >*********************************************************************************** + #[derive(Debug, Deserialize)] /// Tipos para las secciones globales [`[app]`](App), [`[dev]`](Dev), [`[log]`](Log) y /// [`[server]`](Server) de [`SETTINGS`]. @@ -48,22 +82,30 @@ pub struct App { pub description: String, /// Tema predeterminado. pub theme: String, - /// Idioma predeterminado de la aplicación. + /// Idioma predeterminado de la aplicación (p. ej., *"es-ES"* o *"en-US"*). /// - /// Si queda en `None`, el idioma de renderizado se decide intentando usar el asignado con - /// [`Contextual::with_langid()`](crate::core::component::Contextual::with_langid) en el - /// contexto del documento. Si no se ha establecido, prueba el recibido en la cabecera - /// `Accept-Language` enviada por el navegador. Y si ninguno aplica, emplea el idioma de - /// respaldo (`"en-US"`). + /// Cuando tiene un valor validado por [`Locale`](crate::locale::Locale), se usa como candidato + /// para resolver el idioma efectivo de cada petición según la estrategia definida en + /// [`lang_negotiation`](Self::lang_negotiation) y aplicada por + /// [`RequestLocale`](crate::locale::RequestLocale). + /// + /// Si es `None` o no contiene un valor válido, la negociación del idioma pasa a depender de + /// otras fuentes como la cabecera `Accept-Language` de la petición o, en último término, del + /// idioma de respaldo configurado en el sistema. pub language: Option, + /// Estrategia para resolver el idioma usado en la petición: *"Full"*, *"NoQuery"* o + /// *"ConfigOnly"*. + /// + /// Define las fuentes que intervienen en la negociación del idioma para el renderizado de los + /// documentos y la generación de URLs. Ver [`LangNegotiation`] para los modos disponibles. + pub lang_negotiation: LangNegotiation, /// Banner ASCII mostrado al inicio: *"Off"* (desactivado), *"Slant"*, *"Small"*, *"Speed"* o /// *"Starwars"*. pub startup_banner: String, /// Activa la página de bienvenida de PageTop. /// /// Si está activada, se instala la extensión [`Welcome`](crate::base::extension::Welcome), que - /// ofrece una página de bienvenida predefinida en `"/"` y también en `"/lang/{lang}"`, para - /// mostrar el contenido en el idioma `{lang}`, siempre que esté soportado. + /// ofrece una página de bienvenida predefinida en `"/"`. pub welcome: bool, /// Modo de ejecución, dado por la variable de entorno `PAGETOP_RUN_MODE`, o *"default"* si no /// está definido. @@ -87,18 +129,18 @@ pub struct Dev { #[derive(Debug, Deserialize)] /// Sección `[log]` de la configuración. Forma parte de [`Settings`]. pub struct Log { - /// Gestión de trazas y registro de eventos activado (`true`) o desactivado (`false`). + /// Gestión de trazas y registro de eventos activada (*true*) o desactivada (*false*). pub enabled: bool, /// Opciones, o combinación de opciones separadas por comas, para filtrar las trazas: *"Error"*, /// *"Warn"*, *"Info"*, *"Debug"* o *"Trace"*. - /// Ejemplo: "Error,actix_server::builder=Info,tracing_actix_web=Debug". + /// Ejemplo: *"Error,actix_server::builder=Info,tracing_actix_web=Debug"*. pub tracing: String, /// Muestra los mensajes de traza en el terminal (*"Stdout"*) o los vuelca en archivos con /// rotación: *"Daily"*, *"Hourly"*, *"Minutely"* o *"Endless"*. pub rolling: String, - /// Directorio para los archivos de traza (si `rolling` ≠ *"Stdout"*). + /// Directorio para los archivos de traza (si [`rolling`](Self::rolling) ≠ *"Stdout"*). pub path: String, - /// Prefijo para los archivos de traza (si `rolling` ≠ *"Stdout"*). + /// Prefijo para los archivos de traza (si [`rolling`](Self::rolling) ≠ *"Stdout"*). pub prefix: String, /// Formato de salida de las trazas. Opciones: *"Full"*, *"Compact"*, *"Pretty"* o *"Json"*. pub format: String, diff --git a/src/html/route.rs b/src/html/route.rs index c7dac096..699706f3 100644 --- a/src/html/route.rs +++ b/src/html/route.rs @@ -1,4 +1,4 @@ -use crate::AutoDefault; +use crate::{builder_fn, AutoDefault}; use std::borrow::Cow; use std::fmt; @@ -56,12 +56,14 @@ impl RoutePath { } /// Añade o sustituye un parámetro `key=value`. Si la clave ya existe, el valor se sobrescribe. + #[builder_fn] pub fn with_param(mut self, key: impl Into, value: impl Into) -> Self { self.query.insert(key.into(), value.into()); self } /// Añade o sustituye un *flag* sin valor, por ejemplo `?debug`. + #[builder_fn] pub fn with_flag(mut self, flag: impl Into) -> Self { self.query.insert(flag.into(), String::new()); self diff --git a/src/locale.rs b/src/locale.rs index 1a644e66..742a639f 100644 --- a/src/locale.rs +++ b/src/locale.rs @@ -89,215 +89,57 @@ //! Y *voilà*, sólo queda operar con los idiomas soportados por PageTop usando [`Locale`] y traducir //! textos con [`L10n`]. -use crate::html::{Markup, PreEscaped}; -use crate::service::HttpRequest; -use crate::{global, util, 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; +mod languages; -use std::borrow::Cow; -use std::collections::HashMap; -use std::sync::LazyLock; +mod definition; +pub use definition::{LangId, Locale}; -use std::fmt; +mod request; +pub use request::RequestLocale; -// Asocia cada identificador 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(|| { - 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" ), - ] -}); +mod l10n; +pub use l10n::L10n; -// Identificador de idioma de **respaldo** (predefinido a `en-US`). -// -// Se usa cuando el valor del identificador 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 de idioma **por defecto** para la aplicación. -// -// Se resuelve a partir de [`global::SETTINGS.app.language`](global::SETTINGS). Si el identificador -// de idioma no es válido o no está disponible, se deja sin definir (`None`) y se delega en -// [`Locale::default()`] o [`LangId::langid()`] la aplicación del idioma de respaldo. -pub(crate) static DEFAULT_LANGID: LazyLock> = LazyLock::new(|| { - Locale::resolve(global::SETTINGS.app.language.as_deref().unwrap_or("")).as_option() -}); - -/// Representa la fuente de idioma (`LanguageIdentifier`) asociada a un recurso. +/// Incluye un conjunto de recursos **Fluent** con textos de traducción propios. /// -/// Este *trait* permite que distintas estructuras expongan su fuente de idioma de forma uniforme. -pub trait LangId { - /// Devuelve el identificador de idioma asociado al recurso. - fn langid(&self) -> &'static LanguageIdentifier; -} - -/// Operaciones con los idiomas soportados por PageTop. +/// Esta macro integra en el binario de la aplicación los archivos FTL ubicados en los siguientes +/// directorios opcionales de recursos Fluent: /// -/// Utiliza [`Locale`] para transformar un identificador de idioma en un [`LanguageIdentifier`] -/// soportado por PageTop. +/// - `$dir_locales`, con los subdirectorios de cada idioma. Por ejemplo, `"files/ftl"` o +/// `"assets/translations"`. Si no se indica, se usará el directorio por defecto `"src/locale"`. +/// - `$core_locales`, que añade un conjunto de traducciones que se cargan para **todos** los +/// idiomas. Sirve para definir textos comunes que no tienen por qué duplicarse en cada +/// subdirectorio de idioma. +/// +/// Cada extensión o tema puede definir sus propios recursos de traducción usando esta macro. Para +/// más detalles sobre el sistema de localización consulta el módulo [`locale`](crate::locale). /// /// # 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 caso, el de -/// respaldo (`"en-US"`): +/// Uso básico con el directorio por defecto `"src/locale"`: /// /// ```rust /// # use pagetop::prelude::*; -/// // Idioma por defecto o de respaldo si no resuelve. -/// let lang = Locale::resolve("it-IT"); -/// let langid = lang.langid(); +/// include_locales!(LOCALES_SAMPLE); /// ``` -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum Locale { - /// Cuando el identificador de idioma es una cadena vacía. - Unspecified, - /// Si 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"`). - Found(&'static LanguageIdentifier), - /// Si el identificador de idioma no está entre los soportados 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::Found(DEFAULT_LANGID.unwrap_or(&FALLBACK_LANGID)) - } -} - -impl Locale { - /// Resuelve `language` y devuelve la variante [`Locale`] apropiada. - pub fn resolve(language: impl AsRef) -> 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::Found(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::Found(langid); - } - } - - // En caso contrario, indica que el idioma no está soportado. - Self::Unsupported(language.to_string()) - } - - /// Crea un [`Locale`] a partir de una petición HTTP. - /// - /// El orden de resolución del idioma es el siguiente: - /// - /// 1. Idioma por defecto de la aplicación, si se ha definido en la configuración global - /// ([`global::SETTINGS.app.language`]). - /// 2. Si no hay idioma por defecto válido, se intenta extraer el idioma de la cabecera HTTP - /// `Accept-Language` usando [`Locale::resolve`]. - /// 3. Si no hay cabecera o el valor no es legible, se devuelve [`Locale::Unspecified`]. - /// - /// Este método **no aplica** idioma de respaldo. Para obtener siempre un [`LanguageIdentifier`] - /// válido (aplicando idioma por defecto y, en último término, el de respaldo), utiliza - /// [`LangId::langid()`] sobre el valor devuelto. - pub fn from_request(request: Option<&HttpRequest>) -> Self { - // 1) Se usa `DEFAULT_LANGID` si la aplicación tiene un idioma por defecto válido. - if let Some(default) = *DEFAULT_LANGID { - return Locale::Found(default); - } - // 2) Sin idioma por defecto, se evalúa la cabecera `Accept-Language` de la petición HTTP. - request - .and_then(|req| req.headers().get("Accept-Language")) - .and_then(|value| value.to_str().ok()) - // Aplica `resolve()` para devolver `Found`, `Unspecified` o `Unsupported`. - .map(Locale::resolve) - // 3) Si no hay cabecera o no puede leerse, se considera no especificado. - .unwrap_or(Locale::Unspecified) - } - - /// Devuelve el [`LanguageIdentifier`] si el idioma fue reconocido. - /// - /// Solo retorna `Some` si la variante es [`Locale::Found`]. 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::Found(l) => Some(l), - _ => None, - } - } -} - -/// Permite a [`Locale`] actuar como proveedor de idioma. /// -/// Devuelve el [`LanguageIdentifier`] si la variante es [`Locale::Found`]; en caso contrario, -/// devuelve el idioma por defecto de la aplicación y, si tampoco está disponible, el idioma de -/// respaldo ("en-US"). +/// Uso indicando recursos comunes (además de `"src/locale"`): /// -/// Resulta útil para usar un valor de [`Locale`] como fuente de traducción en [`L10n::lookup()`] -/// o [`L10n::using()`]. -impl LangId for Locale { - fn langid(&self) -> &'static LanguageIdentifier { - match self { - Locale::Found(l) => l, - _ => DEFAULT_LANGID.unwrap_or(&FALLBACK_LANGID), - } - } -} - +/// ```rust,ignore +/// include_locales!(LOCALES_SAMPLE, "src/core-locale"); +/// ``` +/// +/// Uso con un directorio de recursos Fluent alternativo: +/// +/// ```rust,ignore +/// include_locales!(LOCALES_SAMPLE from "ruta/a/las/traducciones"); +/// ``` #[macro_export] -/// Incluye un conjunto de recursos **Fluent** y textos de traducción propios. macro_rules! include_locales { // Se desactiva la inserción de marcas de aislamiento Unicode (FSI/PDI) en los argumentos para // mejorar la legibilidad y la compatibilidad en ciertos contextos de renderizado. @@ -324,184 +166,3 @@ macro_rules! include_locales { } }; } - -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, -} - -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 del conjunto de - /// traducciones predefinidas. - 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 un conjunto de - /// traducciones específico. - 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 vez (p. ej. usando la macro [`util::kv!`], - /// 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 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 { - 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 { - 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::>(), - ) - } - } - } - } - - /// 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", &"") - .finish() - } -} diff --git a/src/locale/definition.rs b/src/locale/definition.rs new file mode 100644 index 00000000..6349b460 --- /dev/null +++ b/src/locale/definition.rs @@ -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> = 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 = 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) -> 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(), + } + } +} diff --git a/src/locale/l10n.rs b/src/locale/l10n.rs new file mode 100644 index 00000000..94c309c9 --- /dev/null +++ b/src/locale/l10n.rs @@ -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, +} + +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 del conjunto de + /// traducciones predefinidas. + 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 un conjunto de + /// traducciones específico. + 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 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(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 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 { + 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 { + 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::>(), + ) + } + } + } + } + + /// 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", &"") + .finish() + } +} diff --git a/src/locale/languages.rs b/src/locale/languages.rs new file mode 100644 index 00000000..f1962a14 --- /dev/null +++ b/src/locale/languages.rs @@ -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> = + 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" ), + ] + }); diff --git a/src/locale/request.rs b/src/locale/request.rs new file mode 100644 index 00000000..6f3af13d --- /dev/null +++ b/src/locale/request.rs @@ -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 + } +} diff --git a/src/response/page.rs b/src/response/page.rs index 90ce84e1..41d0a125 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -294,6 +294,7 @@ impl Page { /// Resulta útil para usar [`Page`] directamente como fuente de traducción en [`L10n::lookup()`] o /// [`L10n::using()`]. impl LangId for Page { + #[inline] fn langid(&self) -> &'static LanguageIdentifier { self.context.langid() }