- CurrentUser pasa de enum a struct con `id` opcional (anónimo = None) y preferencias de idioma, zona horaria y tema validadas en `with_*()`. - `RequestLocale` tiene en cuenta el idioma preferido del usuario, y Context usa su tema si lo tiene (`default_theme()` en otro caso). - Nuevos componentes `form::SelectLanguage`, `form::SelectTheme` y `form::SelectTimezone`, con `Timezone::supported_by_region()` para listar y validar las zonas IANA ofrecidas.
500 lines
20 KiB
Rust
500 lines
20 KiB
Rust
use crate::auth::CurrentUser;
|
|
use crate::core::TypeInfo;
|
|
use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage};
|
|
use crate::core::theme::{ChildrenInRegions, CoreRegions, CoreTemplates, default_theme};
|
|
use crate::core::theme::{RegionRef, TemplateRef, ThemeRef};
|
|
use crate::datetime::Tz;
|
|
use crate::html::{Assets, Favicon, JavaScript, Preload, ResponsiveStyles, StyleSheet};
|
|
use crate::html::{Markup, Props, PropsOp, RoutePath, html};
|
|
use crate::locale::Lc;
|
|
use crate::locale::{LangId, LanguageIdentifier, RequestLocale};
|
|
use crate::web::HttpRequest;
|
|
use crate::{builder_impl, util};
|
|
|
|
use parking_lot::Mutex;
|
|
|
|
use std::any::{Any, TypeId};
|
|
use std::collections::HashMap;
|
|
|
|
mod assets_op;
|
|
pub use assets_op::AssetsOp;
|
|
|
|
mod error;
|
|
pub use error::ContextError;
|
|
|
|
mod contextual;
|
|
pub use contextual::Contextual;
|
|
|
|
/// Implementa un **contexto de renderizado** para un documento HTML.
|
|
///
|
|
/// Se crea una sola vez por petición usando [`Context::new()`] (típicamente a través de
|
|
/// [`Page::new()`](crate::response::Page::new) o [`Page::admin()`](crate::response::Page::admin)),
|
|
/// y es la única vía por la que un componente, una acción o el tema activo conocen: la petición
|
|
/// HTTP de origen, el idioma negociado y la zona horaria efectiva, el usuario autenticado
|
|
/// ([`current_user()`](Contextual::current_user)), el tema y la plantilla en uso, y los recursos
|
|
/// (favicon, hojas de estilo, scripts) acumulados hasta ese momento. Otros datos que los
|
|
/// componentes necesiten durante el renderizado pueden ser parámetros dinámicos tipados con
|
|
/// [`with_param()`](Contextual::with_param)/[`param()`](Contextual::param).
|
|
///
|
|
/// La implementación extiende [`Contextual`], que aporta los métodos *builder* (`with_*`) y los
|
|
/// *getters* comunes a cualquier estructura que gestione un contexto de renderizado (también los
|
|
/// implementa [`Page`](crate::response::Page)). Además, `Context` añade:
|
|
///
|
|
/// - [`route()`](Self::route) para construir URLs que preserven `?lang=...` cuando corresponda.
|
|
/// - [`build_id()`](Self::build_id)/[`required_id()`](Self::required_id) para generar
|
|
/// identificadores HTML únicos por tipo de componente.
|
|
/// - [`push_message()`](Self::push_message)/[`messages()`](Self::messages) para acumular
|
|
/// [`StatusMessage`] que mostrar en algún momento del renderizado.
|
|
/// - [`render_assets()`](Self::render_assets)/[`render_region()`](Self::render_region), usados
|
|
/// internamente por [`Page`](crate::response::Page) para producir el HTML final del documento.
|
|
///
|
|
/// # Ejemplos
|
|
///
|
|
/// Crea un nuevo contexto asociado a una petición HTTP:
|
|
///
|
|
/// ```rust,no_run
|
|
/// # use pagetop::prelude::*;
|
|
/// # use pagetop_aliner::Aliner;
|
|
/// # fn new_context(request: HttpRequest) -> Context {
|
|
/// let cx = Context::new(request)
|
|
/// // Establece el idioma del documento a español.
|
|
/// .with_langid(&Locale::resolve("es-ES"))
|
|
/// // Establece el tema para renderizar.
|
|
/// .with_theme(&Aliner)
|
|
/// // Asigna un favicon.
|
|
/// .with_assets(Favicon::new().with_icon("/favicon.ico"))
|
|
/// // Añade una hoja de estilo externa.
|
|
/// .with_assets(StyleSheet::from("/css/style.css"))
|
|
/// // Añade un script JavaScript.
|
|
/// .with_assets(JavaScript::defer("/js/main.js"))
|
|
/// // Añade un parámetro dinámico al contexto.
|
|
/// .with_param("user_id", 42);
|
|
/// # cx }
|
|
/// ```
|
|
///
|
|
/// Y hace operaciones con un contexto dado:
|
|
///
|
|
/// ```rust,no_run
|
|
/// # use pagetop::prelude::*;
|
|
/// # #[derive(AutoDefault, Clone, Debug)]
|
|
/// # struct Menu;
|
|
/// # impl Component for Menu {
|
|
/// # fn new() -> Self { Self::default() }
|
|
/// # }
|
|
/// fn use_context(cx: &mut Context) {
|
|
/// // Recupera el tema seleccionado.
|
|
/// let _theme = cx.theme(); // short_name() => "basic" por defecto
|
|
///
|
|
/// // Recupera el parámetro a su tipo original.
|
|
/// let _id: i32 = *cx.param::<i32>("user_id").unwrap(); // => 42
|
|
///
|
|
/// // Genera un identificador para un componente de tipo `Menu`.
|
|
/// let _unique_id = cx.build_id::<Menu>(1); // => "menu-1" si es el primero
|
|
/// }
|
|
/// ```
|
|
#[rustfmt::skip]
|
|
pub struct Context {
|
|
request : Option<HttpRequest>, // Petición HTTP de origen.
|
|
locale : RequestLocale, // Idioma asociado a la petición.
|
|
current_user: CurrentUser, // Identidad del usuario actual.
|
|
timezone : Tz, // Zona horaria efectiva del documento.
|
|
theme : ThemeRef, // Referencia al tema usado para renderizar.
|
|
template : TemplateRef, // Plantilla usada para renderizar.
|
|
favicon : Option<Favicon>, // Favicon, si se ha definido.
|
|
preloads : Assets<Preload>, // Recursos para precarga.
|
|
stylesheets : Assets<StyleSheet>, // Hojas de estilo CSS.
|
|
javascripts : Assets<JavaScript>, // Scripts JavaScript.
|
|
responsives : ResponsiveStyles, // Estilos *responsive*.
|
|
body_props : Props, // Id, clases CSS y atributos del <body>.
|
|
regions : ChildrenInRegions, // Regiones de componentes para renderizar.
|
|
params : HashMap<&'static str, (Box<dyn Any + Send + Sync>, &'static str)>, // Parámetros.
|
|
id_counters : Mutex<HashMap<TypeId, usize>>, // Mutex permite mutar desde build_id(&self).
|
|
messages : Vec<StatusMessage>, // Mensajes de usuario acumulados.
|
|
}
|
|
|
|
impl Default for Context {
|
|
fn default() -> Self {
|
|
Self::base(None, &CoreTemplates::Standard)
|
|
}
|
|
}
|
|
|
|
impl Context {
|
|
// Construye el `Context` compartido por `new()`, `admin()` y `Default::default()`, evitando
|
|
// duplicar la lista de campos entre ambos (y la recursión que tendría `new()` llamando a
|
|
// `Default::default()`, o viceversa). Recibe la plantilla para que `admin()` no tenga que
|
|
// construir con la plantilla estándar y sobrescribirla después.
|
|
#[rustfmt::skip]
|
|
fn base(request: Option<HttpRequest>, template: TemplateRef) -> Self {
|
|
let locale = RequestLocale::from_request(request.as_ref());
|
|
let current_user = Self::resolve_current_user(request.as_ref());
|
|
let timezone = current_user.timezone();
|
|
let theme = current_user.theme().unwrap_or_else(default_theme);
|
|
Context {
|
|
request,
|
|
locale,
|
|
current_user,
|
|
timezone,
|
|
theme,
|
|
template,
|
|
favicon : None,
|
|
preloads : Assets::<Preload>::new(),
|
|
stylesheets: Assets::<StyleSheet>::new(),
|
|
javascripts: Assets::<JavaScript>::new(),
|
|
responsives: ResponsiveStyles::new(),
|
|
body_props : Props::default(),
|
|
regions : ChildrenInRegions::default(),
|
|
params : HashMap::default(),
|
|
id_counters: Mutex::new(HashMap::new()),
|
|
messages : Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Crea un nuevo contexto asociado a una petición HTTP.
|
|
///
|
|
/// El contexto inicializa el idioma, el tema y la plantilla por defecto, sin favicon ni otros
|
|
/// recursos cargados.
|
|
///
|
|
/// Para un contexto sin petición (renderizar un componente de forma aislada, en tests o fuera
|
|
/// del ciclo de una petición web), usa [`Context::default()`].
|
|
pub fn new(request: HttpRequest) -> Self {
|
|
Self::base(Some(request), &CoreTemplates::Standard)
|
|
}
|
|
|
|
/// Crea un nuevo contexto asociado a una petición HTTP, con la plantilla de administración.
|
|
///
|
|
/// El contexto inicializa el idioma, el tema y la plantilla [`CoreTemplates::Admin`], sin
|
|
/// favicon ni otros recursos cargados.
|
|
pub fn admin(request: HttpRequest) -> Self {
|
|
Self::base(Some(request), &CoreTemplates::Admin)
|
|
}
|
|
|
|
// Extrae el `CurrentUser` inyectado por middleware en las extensiones de la petición, o
|
|
// un usuario anónimo si no hay petición o ninguna extensión de autenticación está activa.
|
|
fn resolve_current_user(request: Option<&HttpRequest>) -> CurrentUser {
|
|
request
|
|
.and_then(|r| r.extension::<CurrentUser>())
|
|
.cloned()
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
// **< Context RENDER >*************************************************************************
|
|
|
|
/// Renderiza los recursos del contexto.
|
|
pub fn render_assets(&mut self) -> Markup {
|
|
use std::mem::take as mem_take;
|
|
|
|
// Extrae temporalmente los recursos.
|
|
let favicon = mem_take(&mut self.favicon); // Deja valor por defecto (None) en self.
|
|
let preloads = mem_take(&mut self.preloads); // Assets<Preload>::default() en self.
|
|
let stylesheets = mem_take(&mut self.stylesheets); // Assets<StyleSheet>::default() en self.
|
|
let javascripts = mem_take(&mut self.javascripts); // Assets<JavaScript>::default() en self.
|
|
|
|
// Renderiza con `&mut self` como contexto.
|
|
let markup = html! {
|
|
@if let Some(fi) = &favicon {
|
|
(fi.render(self))
|
|
}
|
|
// Primero los recursos para precarga para iniciar las descargas inmediatamente.
|
|
(preloads.render(self))
|
|
(stylesheets.render(self))
|
|
// Después los estilos *responsive*, para poder sobrescribir sus clases.
|
|
@if !self.responsives.is_empty() {
|
|
style { (self.responsives.render(self)) }
|
|
}
|
|
(javascripts.render(self))
|
|
};
|
|
|
|
// Restaura los campos tal y como estaban.
|
|
self.favicon = favicon;
|
|
self.preloads = preloads;
|
|
self.stylesheets = stylesheets;
|
|
self.javascripts = javascripts;
|
|
|
|
markup
|
|
}
|
|
|
|
/// Renderiza los componentes de una región.
|
|
///
|
|
/// Combina los componentes registrados para esta región en la petición actual con los
|
|
/// prototipos globales añadidos vía [`InRegion`](crate::core::theme::InRegion) (comunes o
|
|
/// específicos del tema activo).
|
|
pub async fn render_region(&mut self, region: RegionRef) -> Markup {
|
|
self.regions
|
|
.assemble_region(self.theme, region)
|
|
.render(self)
|
|
.await
|
|
}
|
|
|
|
// **< Context HELPERS >************************************************************************
|
|
|
|
/// Construye una ruta aplicada al contexto actual.
|
|
///
|
|
/// Acepta cualquier tipo convertible a [`RoutePath`] (un literal, un `String`, un `&str` de
|
|
/// cualquier vida, o un [`RoutePath`] ya construido con sus propios parámetros). Si la política
|
|
/// de negociación del idioma ([`LangNegotiation`](crate::global::LangNegotiation)) indica que
|
|
/// debe propagarse el idioma para esta petición, se añade o actualiza automáticamente el
|
|
/// parámetro de *query* `lang=...` con el identificador de idioma definido en el contexto.
|
|
///
|
|
/// Esto garantiza que los enlaces generados desde el contexto preservan la preferencia de
|
|
/// idioma del usuario durante la navegación. Si `path` **parece** una URL externa (ver
|
|
/// [`util::url_looks_external()`](crate::util::url_looks_external)), nunca se le añade `lang`.
|
|
///
|
|
/// Este método asume que ya tienes `cx` a mano en el momento de construir la ruta (dentro de
|
|
/// `prepare()`, un *handler* HTTP, etc.). Si lo que estás definiendo es un campo de componente
|
|
/// que se construye una sola vez y se reutiliza en peticiones futuras (un menú, un botón,
|
|
/// etc.), usa [`Route`](crate::core::component::Route) en su lugar (su documentación explica el
|
|
/// criterio completo para elegir entre ambos).
|
|
pub fn route(&self, path: impl Into<RoutePath>) -> RoutePath {
|
|
let mut route = path.into();
|
|
if !route.is_external() && self.locale.needs_lang_query() {
|
|
route.alter_param("lang", self.locale.lang_query_value());
|
|
}
|
|
route
|
|
}
|
|
|
|
/// Construye un identificador HTML único para el tipo de componente `C`.
|
|
///
|
|
/// Toma los `segments` finales del *path* completo del tipo, los une con `-` y los convierte a
|
|
/// minúsculas, y añade un contador independiente por tipo. Por ejemplo, para `MyApp::ui::Menu`
|
|
/// con `segments = 2` devuelve `ui-menu-1` la primera vez que se invoca para ese tipo,
|
|
/// `ui-menu-2` la segunda, etc.
|
|
///
|
|
/// Con `segments = 1` se usa sólo el nombre corto del tipo. Si `segments` es `0` o supera el
|
|
/// número de segmentos del *path*, se usan todos.
|
|
///
|
|
/// Es útil para asignar identificadores cuando el componente no recibe uno explícito. El
|
|
/// contador es local a este contexto y se reinicia para cada nueva petición.
|
|
pub fn build_id<C: Component>(&self, segments: usize) -> String {
|
|
let path: Vec<&str> = TypeInfo::FullName.of::<C>().split("::").collect();
|
|
let segments = if segments == 0 || segments >= path.len() {
|
|
path.len()
|
|
} else {
|
|
segments
|
|
};
|
|
let count = {
|
|
let mut map = self.id_counters.lock();
|
|
let n = map.entry(TypeId::of::<C>()).or_insert(0);
|
|
*n += 1;
|
|
*n
|
|
};
|
|
let prefix = path[path.len() - segments..].join("-").to_lowercase();
|
|
util::join!(prefix, "-", count.to_string())
|
|
}
|
|
|
|
/// Devuelve `id` si contiene un valor, o genera uno único con [`build_id`](Self::build_id)
|
|
/// si es `None`.
|
|
pub fn required_id<C: Component>(&self, id: Option<String>, segments: usize) -> String {
|
|
match id {
|
|
Some(id) => id,
|
|
None => self.build_id::<C>(segments),
|
|
}
|
|
}
|
|
|
|
/// Acumula un [`StatusMessage`] en el contexto para notificar al usuario.
|
|
///
|
|
/// Pueden generarse en cualquier punto del ciclo de una petición web (handlers, renderizado,
|
|
/// lógica de negocio, etc.) que tengan acceso al contexto, y mostrarlos luego, por ejemplo, en
|
|
/// la página final devuelta al usuario.
|
|
///
|
|
/// # Ejemplo
|
|
///
|
|
/// ```rust,no_run
|
|
/// # use pagetop::prelude::*;
|
|
/// # let mut cx = Context::default();
|
|
/// cx.push_message(MessageLevel::Warning, Lc::n("Session is not valid"));
|
|
/// ```
|
|
pub fn push_message(&mut self, level: MessageLevel, text: Lc) {
|
|
self.messages.push(StatusMessage::new(level, text));
|
|
}
|
|
|
|
/// Devuelve todos los mensajes de usuario acumulados.
|
|
pub fn messages(&self) -> &[StatusMessage] {
|
|
&self.messages
|
|
}
|
|
|
|
/// Indica si hay mensajes de usuario acumulados.
|
|
pub fn has_messages(&self) -> bool {
|
|
!self.messages.is_empty()
|
|
}
|
|
}
|
|
|
|
/// Permite a [`Context`] actuar como proveedor de idioma.
|
|
///
|
|
/// Internamente delega en [`RequestLocale`], que tiene en cuenta la petición HTTP (parámetro
|
|
/// `?lang` e idioma preferido del usuario), la configuración global de idioma de la aplicación, la
|
|
/// cabecera `Accept-Language` y/o el idioma de respaldo.
|
|
///
|
|
/// 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 [`Lc::lookup()`] o
|
|
/// [`Lc::using()`].
|
|
///
|
|
/// [`Context`]: crate::core::component::Context
|
|
/// [`global::SETTINGS.app.lang_negotiation`]: crate::global::App::lang_negotiation
|
|
/// [`Lc::lookup()`]: crate::locale::Lc::lookup
|
|
/// [`Lc::using()`]: crate::locale::Lc::using
|
|
impl LangId for Context {
|
|
#[inline]
|
|
fn langid(&self) -> &'static LanguageIdentifier {
|
|
self.locale.langid()
|
|
}
|
|
}
|
|
|
|
#[builder_impl]
|
|
impl Contextual for Context {
|
|
// **< Contextual BUILDER >*********************************************************************
|
|
|
|
fn with_request(mut self, request: Option<HttpRequest>) -> Self {
|
|
self.request = request;
|
|
// Recalcula el *locale*, el usuario actual, la zona horaria y el tema según la nueva
|
|
// petición y la política de negociación configurada.
|
|
self.locale = RequestLocale::from_request(self.request.as_ref());
|
|
self.current_user = Self::resolve_current_user(self.request.as_ref());
|
|
self.timezone = self.current_user.timezone();
|
|
self.theme = self.current_user.theme().unwrap_or_else(default_theme);
|
|
self
|
|
}
|
|
|
|
fn with_langid(mut self, language: &impl LangId) -> Self {
|
|
self.locale.with_langid(language);
|
|
self
|
|
}
|
|
|
|
fn with_timezone(mut self, tz: Tz) -> Self {
|
|
self.timezone = tz;
|
|
self
|
|
}
|
|
|
|
fn with_theme(mut self, theme: ThemeRef) -> Self {
|
|
self.theme = theme;
|
|
self
|
|
}
|
|
|
|
fn with_template(mut self, template: TemplateRef) -> Self {
|
|
self.template = template;
|
|
self
|
|
}
|
|
|
|
fn with_param<T: Send + Sync + 'static>(mut self, key: &'static str, value: T) -> Self {
|
|
let type_name = TypeInfo::FullName.of::<T>();
|
|
self.params.insert(key, (Box::new(value), type_name));
|
|
self
|
|
}
|
|
|
|
fn with_assets(mut self, op: impl Into<AssetsOp>) -> Self {
|
|
match op.into() {
|
|
// Favicon.
|
|
AssetsOp::SetFavicon(favicon) => {
|
|
self.favicon = favicon;
|
|
}
|
|
AssetsOp::SetFaviconIfNone(icon) => {
|
|
if self.favicon.is_none() {
|
|
self.favicon = Some(icon);
|
|
}
|
|
}
|
|
// Preloads.
|
|
AssetsOp::AddPreload(preload) => {
|
|
self.preloads.add(preload);
|
|
}
|
|
AssetsOp::RemovePreload(path) => {
|
|
self.preloads.remove(path);
|
|
}
|
|
// Stylesheets.
|
|
AssetsOp::AddStyleSheet(css) => {
|
|
self.stylesheets.add(css);
|
|
}
|
|
AssetsOp::RemoveStyleSheet(path) => {
|
|
self.stylesheets.remove(path);
|
|
}
|
|
// Scripts JavaScript.
|
|
AssetsOp::AddJavaScript(js) => {
|
|
self.javascripts.add(js);
|
|
}
|
|
AssetsOp::RemoveJavaScript(path) => {
|
|
self.javascripts.remove(path);
|
|
}
|
|
// Estilos responsive.
|
|
AssetsOp::AddResponsiveStyle(breakpoint, classes, property, value) => {
|
|
self.responsives
|
|
.add_style(breakpoint, classes, property, value);
|
|
}
|
|
AssetsOp::AddResponsiveStyles(breakpoint, classes, styles) => {
|
|
self.responsives.add_styles(breakpoint, classes, styles);
|
|
}
|
|
}
|
|
self
|
|
}
|
|
|
|
fn with_body_props(mut self, op: PropsOp) -> Self {
|
|
self.body_props.alter_prop(op);
|
|
self
|
|
}
|
|
|
|
fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
|
|
self.regions
|
|
.alter_child_in(&CoreRegions::Content, op.into());
|
|
self
|
|
}
|
|
|
|
fn with_child_in(mut self, region: RegionRef, op: impl Into<ChildOp>) -> Self {
|
|
self.regions.alter_child_in(region, op.into());
|
|
self
|
|
}
|
|
|
|
// **< Contextual GETTERS >*********************************************************************
|
|
|
|
fn request(&self) -> Option<&HttpRequest> {
|
|
self.request.as_ref()
|
|
}
|
|
|
|
fn current_user(&self) -> &CurrentUser {
|
|
&self.current_user
|
|
}
|
|
|
|
fn timezone(&self) -> Tz {
|
|
self.timezone
|
|
}
|
|
|
|
fn theme(&self) -> ThemeRef {
|
|
self.theme
|
|
}
|
|
|
|
fn template(&self) -> TemplateRef {
|
|
self.template
|
|
}
|
|
|
|
fn param<T: 'static>(&self, key: &'static str) -> Result<&T, ContextError> {
|
|
let (any, type_name) = self.params.get(key).ok_or(ContextError::ParamNotFound)?;
|
|
any.downcast_ref::<T>()
|
|
.ok_or_else(|| ContextError::ParamTypeMismatch {
|
|
key,
|
|
expected: TypeInfo::FullName.of::<T>(),
|
|
saved: type_name,
|
|
})
|
|
}
|
|
|
|
fn favicon(&self) -> Option<&Favicon> {
|
|
self.favicon.as_ref()
|
|
}
|
|
|
|
fn stylesheets(&self) -> &Assets<StyleSheet> {
|
|
&self.stylesheets
|
|
}
|
|
|
|
fn javascripts(&self) -> &Assets<JavaScript> {
|
|
&self.javascripts
|
|
}
|
|
|
|
fn responsive_styles(&self) -> &ResponsiveStyles {
|
|
&self.responsives
|
|
}
|
|
|
|
fn body_props(&self) -> &Props {
|
|
&self.body_props
|
|
}
|
|
|
|
// **< Contextual HELPERS >*********************************************************************
|
|
|
|
fn remove_param(&mut self, key: &'static str) -> bool {
|
|
self.params.remove(key).is_some()
|
|
}
|
|
}
|