pagetop/src/core/component/context.rs
Manuel Cillero c7b680a7f7 (html): Añade Preload para precarga de recursos
Nuevo tipo `Preload` (junto a `StyleSheet` y `JavaScript`) que emite
`<link rel="preload">` con soporte para fuentes, estilos, scripts e
imágenes (WebP y AVIF incluidos). Bootsier lo usa para precargar las
fuentes Source Sans 3 y eliminar el FOUT en la carga inicial.
2026-07-07 00:03:36 +02:00

656 lines
24 KiB
Rust

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<Favicon>),
/// 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<C: Contextual>(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<HttpRequest>) -> 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<T: Send + Sync + 'static>(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 `<body>`.
#[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<ChildOp>) -> 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<ChildOp>) -> 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<Markup, ErrorPage> {
/// 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::<String>("user_id").is_err());
/// ```
fn param<T: 'static>(&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<T: Clone + Default + 'static>(&self, key: &'static str) -> T {
self.param::<T>(key).ok().cloned().unwrap_or_default()
}
/// Devuelve el parámetro clonado o un **valor por defecto** si no existe.
fn param_or<T: Clone + 'static>(&self, key: &'static str, default: T) -> T {
self.param::<T>(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: Clone + 'static, F: FnOnce() -> T>(&self, key: &'static str, f: F) -> T {
self.param::<T>(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<StyleSheet>;
/// Devuelve los scripts JavaScript de los recursos del contexto.
fn javascripts(&self) -> &Assets<JavaScript>;
/// Devuelve identificador, clases CSS y atributos HTML del elemento `<body>`.
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::<i32>("user_id").unwrap();
/// assert_eq!(id, 42);
///
/// // Genera un identificador para un componente de tipo `Menu`.
/// let unique_id = cx.build_id::<Menu>(1);
/// assert_eq!(unique_id, "menu-1"); // Si es el primero generado.
/// }
/// ```
#[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.
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.
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 {
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<HttpRequest>) -> 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::<Preload>::new(),
stylesheets: Assets::<StyleSheet>::new(),
javascripts: Assets::<JavaScript>::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::<CurrentUser>())
.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<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))
(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<CowStr>) -> 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<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::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<HttpRequest>) -> 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<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
}
#[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<ChildOp>) -> 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<ChildOp>) -> 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<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 body_props(&self) -> &Props {
&self.body_props
}
// **< Contextual HELPERS >*********************************************************************
fn remove_param(&mut self, key: &'static str) -> bool {
self.params.remove(key).is_some()
}
}