✨ (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:
parent
9297f51b42
commit
7b340a19f3
15 changed files with 789 additions and 465 deletions
|
|
@ -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")
|
||||
})),
|
||||
));
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<FnPathByContext>,
|
||||
#[default(_code = "Some(|cx| cx.route(\"/\"))")]
|
||||
route: Option<FnPathByContext>,
|
||||
}
|
||||
|
||||
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<FnPathByContext>) -> Self {
|
||||
self.path = path;
|
||||
pub fn with_route(mut self, route: Option<FnPathByContext>) -> Self {
|
||||
self.route = route;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<Markup, ErrorPage> {
|
||||
let language = Locale::from_request(Some(&request));
|
||||
home(request, &language)
|
||||
}
|
||||
|
||||
async fn home_lang(
|
||||
request: HttpRequest,
|
||||
path: service::web::Path<String>,
|
||||
) -> ResultPage<Markup, ErrorPage> {
|
||||
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<Markup, ErrorPage> {
|
||||
async fn home(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
|
||||
let app = &global::SETTINGS.app.name;
|
||||
|
||||
Page::new(request)
|
||||
.with_title(L10n::l("welcome_title"))
|
||||
.with_langid(language)
|
||||
.add_child(
|
||||
Intro::new()
|
||||
.add_child(
|
||||
|
|
|
|||
|
|
@ -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::<u64>("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;
|
||||
|
|
|
|||
|
|
@ -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<HttpRequest>, // Solicitud HTTP de origen.
|
||||
langid : &'static LanguageIdentifier, // Identificador de idioma.
|
||||
request : Option<HttpRequest>, // 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>, // Favicon, si se ha definido.
|
||||
|
|
@ -229,10 +230,10 @@ impl Context {
|
|||
/// recursos cargados.
|
||||
#[rustfmt::skip]
|
||||
pub fn new(request: Option<HttpRequest>) -> 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<Cow<'static, str>>) -> 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<HttpRequest>) -> 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// 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,
|
||||
|
|
|
|||
|
|
@ -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<String>, value: impl Into<String>) -> 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<String>) -> Self {
|
||||
self.query.insert(flag.into(), String::new());
|
||||
self
|
||||
|
|
|
|||
399
src/locale.rs
399
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<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" ),
|
||||
]
|
||||
});
|
||||
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<LanguageIdentifier> = 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<Option<&LanguageIdentifier>> = 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<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::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<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!`],
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
210
src/locale/definition.rs
Normal file
210
src/locale/definition.rs
Normal 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
194
src/locale/l10n.rs
Normal 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
27
src/locale/languages.rs
Normal 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
178
src/locale/request.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue