use crate::auth::CurrentUser; use crate::core::TypeInfo; use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage}; use crate::core::theme::all::DEFAULT_THEME; use crate::core::theme::{ChildrenInRegions, DefaultRegion, RegionRef, TemplateRef, ThemeRef}; use crate::html::{Assets, Favicon, JavaScript, Preload, StyleSheet}; use crate::html::{Markup, Props, PropsOp, RoutePath, html}; use crate::locale::L10n; use crate::locale::{LangId, LanguageIdentifier, RequestLocale}; use crate::web::HttpRequest; use crate::{CowStr, builder_fn, util}; use parking_lot::Mutex; use std::any::{Any, TypeId}; use std::collections::HashMap; use std::fmt; /// Operaciones para modificar recursos asociados al [`Context`] de un documento. pub enum AssetsOp { /// Define el *favicon* del documento. Sobrescribe cualquier valor anterior. SetFavicon(Option), /// Define el *favicon* solo si no se ha establecido previamente. SetFaviconIfNone(Favicon), /// Añade un recurso para precarga al documento. AddPreload(Preload), /// Elimina un recurso para precarga por su ruta. RemovePreload(&'static str), /// Añade una hoja de estilos CSS al documento. AddStyleSheet(StyleSheet), /// Elimina una hoja de estilos por su ruta o identificador. RemoveStyleSheet(&'static str), /// Añade un script JavaScript al documento. AddJavaScript(JavaScript), /// Elimina un script por su ruta o identificador. RemoveJavaScript(&'static str), } /// Errores de acceso a parámetros dinámicos del contexto. /// /// - [`ContextError::ParamNotFound`]: la clave no existe. /// - [`ContextError::ParamTypeMismatch`]: la clave existe, pero el valor guardado no coincide con /// el tipo solicitado. Incluye nombre de la clave (`key`), tipo esperado (`expected`) y tipo /// realmente guardado (`saved`) para facilitar el diagnóstico. #[derive(Debug)] pub enum ContextError { ParamNotFound, ParamTypeMismatch { key: &'static str, expected: &'static str, saved: &'static str, }, } impl fmt::Display for ContextError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { ContextError::ParamNotFound => { write!(f, "parameter not found") } ContextError::ParamTypeMismatch { key, expected, saved, } => write!( f, "type mismatch for parameter \"{key}\": expected \"{expected}\", found \"{saved}\"" ), } } } impl std::error::Error for ContextError {} /// Interfaz para gestionar el **contexto de renderizado** de un documento HTML. /// /// `Contextual` extiende [`LangId`] para establecer el idioma del documento y añade métodos para: /// /// - Almacenar la **petición HTTP** de origen. /// - Seleccionar el **tema** y la **plantilla** de renderizado. /// - Administrar **recursos** del documento como el icono [`Favicon`], las hojas de estilo /// [`StyleSheet`] o los scripts [`JavaScript`] mediante [`AssetsOp`]. /// - Leer y mantener **parámetros dinámicos tipados** de contexto. /// /// Lo implementan, típicamente, estructuras que manejan el contexto de renderizado, como /// [`Context`](crate::core::component::Context) o [`Page`](crate::response::page::Page). /// /// # Ejemplo /// /// ```rust,no_run /// # use pagetop::prelude::*; /// # use pagetop_aliner::Aliner; /// fn prepare_context(cx: C) -> C { /// cx.with_langid(&Locale::resolve("es-ES")) /// .with_theme(&Aliner) /// .with_template(&DefaultTemplate::Standard) /// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) /// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/app.css"))) /// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/app.js"))) /// .with_param("user_id", 42_i32) /// } /// ``` pub trait Contextual: LangId { // **< Contextual BUILDER >********************************************************************* /// Establece el idioma del documento. #[builder_fn] fn with_langid(self, language: &impl LangId) -> Self; /// Almacena la petición HTTP de origen en el contexto. #[builder_fn] fn with_request(self, request: Option) -> Self; /// Especifica el tema para renderizar el documento. #[builder_fn] fn with_theme(self, theme: ThemeRef) -> Self; /// Especifica la plantilla para renderizar el documento. #[builder_fn] fn with_template(self, template: TemplateRef) -> Self; /// Añade o modifica un parámetro dinámico del contexto. /// /// El valor se almacena junto con el nombre de su tipo, lo que permite generar mensajes de /// error precisos al recuperarlo con [`param`](Contextual::param) si el tipo solicitado no /// coincide. /// /// # Ejemplo /// /// ```rust,no_run /// # use pagetop::prelude::*; /// let cx = Context::new(None) /// .with_param("user_id", 42_i32) /// .with_param("title", "Hello".to_string()) /// .with_param("flags", vec!["a", "b"]); /// ``` #[builder_fn] fn with_param(self, key: &'static str, value: T) -> Self; /// Define los recursos del contexto usando [`AssetsOp`]. #[builder_fn] fn with_assets(self, op: AssetsOp) -> Self; /// Modifica identificador, clases CSS o atributos HTML del elemento ``. #[builder_fn] fn with_body_props(self, op: PropsOp) -> Self; /// Añade un componente o aplica una operación [`ChildOp`] en la región por defecto del /// documento. #[builder_fn] fn with_child(self, op: impl Into) -> Self; /// Añade un componente o aplica una operación [`ChildOp`] en una región específica del /// documento. #[builder_fn] fn with_child_in(self, region_ref: RegionRef, op: impl Into) -> Self; // **< Contextual GETTERS >********************************************************************* /// Devuelve una referencia a la petición HTTP asociada, si existe. fn request(&self) -> Option<&HttpRequest>; /// Devuelve la identidad del usuario actual. /// /// Si ninguna extensión de autenticación ha inyectado un /// [`CurrentUser`](crate::auth::CurrentUser) en las extensiones de la petición HTTP, devuelve /// `&CurrentUser::Anonymous`. /// /// # Ejemplo /// /// ```rust,no_run /// # use pagetop::prelude::*; /// async fn greet(request: HttpRequest) -> Result { /// let mut page = Page::new(request); /// if page.current_user().is_authenticated() { /// // Personalizar la página para el usuario autenticado. /// } /// page.render().await /// } /// ``` fn current_user(&self) -> &CurrentUser; /// Devuelve el tema que se usará para renderizar el documento. fn theme(&self) -> ThemeRef; /// Devuelve la plantilla configurada para renderizar el documento. fn template(&self) -> TemplateRef; /// Recupera una *referencia tipada* al parámetro solicitado. /// /// Devuelve: /// /// - `Ok(&T)` si la clave existe y el tipo coincide. /// - `Err(ContextError::ParamNotFound)` si la clave no existe. /// - `Err(ContextError::ParamTypeMismatch)` si la clave existe pero el tipo no coincide. /// /// # Ejemplo /// /// ```rust /// # use pagetop::prelude::*; /// let cx = Context::new(None) /// .with_param("user_id", 42_i32) /// .with_param("title", "Hello".to_string()); /// /// let id: i32 = *cx.param("user_id").unwrap(); /// let title: &String = cx.param("title").unwrap(); /// /// // Error de tipo: /// assert!(cx.param::("user_id").is_err()); /// ``` fn param(&self, key: &'static str) -> Result<&T, ContextError>; /// Devuelve el parámetro clonado o el **valor por defecto del tipo** (`T::default()`). fn param_or_default(&self, key: &'static str) -> T { self.param::(key).ok().cloned().unwrap_or_default() } /// Devuelve el parámetro clonado o un **valor por defecto** si no existe. fn param_or(&self, key: &'static str, default: T) -> T { self.param::(key).ok().cloned().unwrap_or(default) } /// Devuelve el parámetro clonado o el **valor evaluado** por la función `f` si no existe. fn param_or_else T>(&self, key: &'static str, f: F) -> T { self.param::(key).ok().cloned().unwrap_or_else(f) } /// Devuelve el Favicon de los recursos del contexto. fn favicon(&self) -> Option<&Favicon>; /// Devuelve las hojas de estilo de los recursos del contexto. fn stylesheets(&self) -> &Assets; /// Devuelve los scripts JavaScript de los recursos del contexto. fn javascripts(&self) -> &Assets; /// Devuelve identificador, clases CSS y atributos HTML del elemento ``. fn body_props(&self) -> &Props; // **< Contextual HELPERS >********************************************************************* /// Elimina un parámetro del contexto. Devuelve `true` si la clave existía y se eliminó. /// /// # Ejemplo /// /// ```rust /// # use pagetop::prelude::*; /// let mut cx = Context::new(None).with_param("temp", 1u8); /// assert!(cx.remove_param("temp")); /// assert!(!cx.remove_param("temp")); // ya no existe /// ``` fn remove_param(&mut self, key: &'static str) -> bool; } /// Implementa un **contexto de renderizado** para un documento HTML. /// /// Extiende [`Contextual`] con métodos para **instanciar** y configurar un nuevo contexto, /// **renderizar los recursos** del documento (incluyendo el [`Favicon`], las hojas de estilo /// [`StyleSheet`] y los scripts [`JavaScript`]), o extender el uso de **parámetros dinámicos /// tipados** con nuevos métodos. /// /// # 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 { /// Context::new(Some(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(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) /// // Añade una hoja de estilo externa. /// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/style.css"))) /// // Añade un script JavaScript. /// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/main.js"))) /// // Añade un parámetro dinámico al contexto. /// .with_param("user_id", 42) /// } /// ``` /// /// 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 active_theme = cx.theme(); /// assert_eq!(active_theme.short_name(), "aliner"); /// /// // Recupera el parámetro a su tipo original. /// let id: i32 = *cx.param::("user_id").unwrap(); /// assert_eq!(id, 42); /// /// // Genera un identificador para un componente de tipo `Menu`. /// let unique_id = cx.build_id::(1); /// assert_eq!(unique_id, "menu-1"); // Si es el primero generado. /// } /// ``` #[rustfmt::skip] pub struct Context { request : Option, // Petición HTTP de origen. locale : RequestLocale, // Idioma asociado a la petición. current_user: CurrentUser, // Identidad del usuario actual. theme : ThemeRef, // Referencia al tema usado para renderizar. template : TemplateRef, // Plantilla usada para renderizar. favicon : Option, // Favicon, si se ha definido. preloads : Assets, // Recursos para precarga. stylesheets : Assets, // Hojas de estilo CSS. javascripts : Assets, // Scripts JavaScript. body_props : Props, // Id, clases CSS y atributos del . regions : ChildrenInRegions, // Regiones de componentes para renderizar. params : HashMap<&'static str, (Box, &'static str)>, // Parámetros. id_counters : Mutex>, // Mutex permite mutar desde build_id(&self). messages : Vec, // Mensajes de usuario acumulados. } impl Default for Context { fn default() -> Self { Context::new(None) } } impl Context { /// 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. #[rustfmt::skip] pub fn new(request: Option) -> Self { let locale = RequestLocale::from_request(request.as_ref()); let current_user = Self::resolve_current_user(request.as_ref()); Context { request, locale, current_user, theme : *DEFAULT_THEME, template : DEFAULT_THEME.default_template(), favicon : None, preloads : Assets::::new(), stylesheets: Assets::::new(), javascripts: Assets::::new(), body_props : Props::default(), regions : ChildrenInRegions::default(), params : HashMap::default(), id_counters: Mutex::new(HashMap::new()), messages : Vec::new(), } } // Extrae el `CurrentUser` inyectado por *middleware* en las extensiones de la petición, o // `CurrentUser::Anonymous` 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::()) .cloned() .unwrap_or(CurrentUser::Anonymous) } // **< 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::default() en self. let stylesheets = mem_take(&mut self.stylesheets); // Assets::default() en self. let javascripts = mem_take(&mut self.javascripts); // Assets::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)) (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. pub async fn render_region_named(&mut self, region_name: &str) -> Markup { self.regions .assemble_region(self.theme, region_name) .render(self) .await } // **< 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 } /// 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(&self, segments: usize) -> String { let path: Vec<&str> = TypeInfo::FullName.of::().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::()).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(&self, id: Option, segments: usize) -> String { match id { Some(id) => id, None => self.build_id::(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::new(None); /// cx.push_message(MessageLevel::Warning, L10n::n("Session is not valid")); /// ``` pub fn push_message(&mut self, level: MessageLevel, text: L10n) { 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`](crate::core::component::Context) actuar como proveedor de idioma. /// /// 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. /// /// 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.locale.langid() } } impl Contextual for Context { // **< Contextual BUILDER >********************************************************************* #[builder_fn] fn with_request(mut self, request: Option) -> Self { self.request = request; // Recalcula el *locale* y el usuario actual 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 } #[builder_fn] fn with_langid(mut self, language: &impl LangId) -> Self { self.locale.with_langid(language); self } #[builder_fn] fn with_theme(mut self, theme: ThemeRef) -> Self { self.theme = theme; self } #[builder_fn] fn with_template(mut self, template: TemplateRef) -> Self { self.template = template; self } #[builder_fn] fn with_param(mut self, key: &'static str, value: T) -> Self { let type_name = TypeInfo::FullName.of::(); self.params.insert(key, (Box::new(value), type_name)); self } #[builder_fn] fn with_assets(mut self, op: AssetsOp) -> Self { match op { // 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); } } self } #[builder_fn] fn with_body_props(mut self, op: PropsOp) -> Self { self.body_props.alter_prop(op); self } #[builder_fn] fn with_child(mut self, op: impl Into) -> Self { self.regions .alter_child_in(&DefaultRegion::Content, op.into()); self } #[builder_fn] fn with_child_in(mut self, region_ref: RegionRef, op: impl Into) -> Self { self.regions.alter_child_in(region_ref, op.into()); self } // **< Contextual GETTERS >********************************************************************* fn request(&self) -> Option<&HttpRequest> { self.request.as_ref() } fn current_user(&self) -> &CurrentUser { &self.current_user } fn theme(&self) -> ThemeRef { self.theme } fn template(&self) -> TemplateRef { self.template } fn param(&self, key: &'static str) -> Result<&T, ContextError> { let (any, type_name) = self.params.get(key).ok_or(ContextError::ParamNotFound)?; any.downcast_ref::() .ok_or_else(|| ContextError::ParamTypeMismatch { key, expected: TypeInfo::FullName.of::(), saved: type_name, }) } fn favicon(&self) -> Option<&Favicon> { self.favicon.as_ref() } fn stylesheets(&self) -> &Assets { &self.stylesheets } fn javascripts(&self) -> &Assets { &self.javascripts } 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() } }