use crate::core::theme::all::{theme_by_short_name, DEFAULT_THEME}; use crate::core::theme::ThemeRef; use crate::core::TypeInfo; use crate::html::{html, Markup}; use crate::html::{Assets, Favicon, JavaScript, StyleSheet}; use crate::locale::{LangId, LangMatch, LanguageIdentifier, DEFAULT_LANGID, FALLBACK_LANGID}; use crate::service::HttpRequest; use crate::{builder_fn, join}; use std::any::Any; use std::collections::HashMap; /// Operaciones para modificar recursos asociados al contexto ([`Context`]) de un documento. pub enum ContextOp { // Favicon. /// Define el *favicon* del documento. Sobrescribe cualquier valor anterior. SetFavicon(Option), /// Define el *favicon* solo si no se ha establecido previamente. SetFaviconIfNone(Favicon), // Stylesheets. /// Añade una hoja de estilos CSS al documento. AddStyleSheet(StyleSheet), /// Elimina una hoja de estilos por su ruta o identificador. RemoveStyleSheet(&'static str), // JavaScripts. /// 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. /// /// - [`ErrorParam::NotFound`]: la clave no existe. /// - [`ErrorParam::TypeMismatch`]: 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 ErrorParam { NotFound, TypeMismatch { key: &'static str, expected: &'static str, saved: &'static str, }, } /// Interfaz para gestionar el **contexto de renderizado** de un documento HTML. /// /// `Contextual` extiende [`LangId`] y define los métodos para: /// /// - Establecer el **idioma** del documento. /// - Almacenar la **solicitud HTTP** de origen. /// - Seleccionar **tema** y **composición** (*layout*) de renderizado. /// - Administrar **recursos** del documento como el icono [`Favicon`], las hojas de estilo /// [`StyleSheet`] o los scripts [`JavaScript`] mediante [`ContextOp`]. /// - Leer y mantener **parámetros dinámicos tipados** de contexto. /// - Generar **identificadores únicos** por tipo de componente. /// /// Lo implementan, típicamente, estructuras que representan el contexto de renderizado, como /// [`Context`](crate::core::component::Context) o [`Page`](crate::response::page::Page). /// /// # Ejemplo /// /// ```rust /// use pagetop::prelude::*; /// /// fn prepare_context(cx: C) -> C { /// cx.with_langid(&LangMatch::resolve("es-ES")) /// .with_theme("aliner") /// .with_layout("default") /// .with_assets(ContextOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) /// .with_assets(ContextOp::AddStyleSheet(StyleSheet::from("/css/app.css"))) /// .with_assets(ContextOp::AddJavaScript(JavaScript::defer("/js/app.js"))) /// .with_param("usuario_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 solicitud 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_name: &'static str) -> Self; /// Especifica la composición para renderizar el documento. #[builder_fn] fn with_layout(self, layout_name: &'static str) -> Self; /// Añade o modifica un parámetro dinámico del contexto. #[builder_fn] fn with_param(self, key: &'static str, value: T) -> Self; /// Define los recursos del contexto usando [`ContextOp`]. #[builder_fn] fn with_assets(self, op: ContextOp) -> Self; // **< Contextual GETTERS >********************************************************************* /// Devuelve una referencia a la solicitud HTTP asociada, si existe. fn request(&self) -> Option<&HttpRequest>; /// Devuelve el tema que se usará para renderizar el documento. fn theme(&self) -> ThemeRef; /// Devuelve la composición para renderizar el documento. Por defecto es `"default"`. fn layout(&self) -> &str; /// Recupera un parámetro como [`Option`]. fn param(&self, key: &'static str) -> Option<&T>; /// 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).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).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).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; // **< Contextual HELPERS >********************************************************************* /// Genera un identificador único por tipo (`-`) cuando no se aporta uno explícito. /// /// Es útil para componentes u otros elementos HTML que necesitan un identificador predecible si /// no se proporciona ninguno. fn required_id(&mut self, id: Option) -> String; } /// 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 solicitud HTTP: /// /// ```rust /// use pagetop::prelude::*; /// /// fn new_context(request: HttpRequest) -> Context { /// Context::new(Some(request)) /// // Establece el idioma del documento a español. /// .with_langid(&LangMatch::resolve("es-ES")) /// // Selecciona un tema (por su nombre corto). /// .with_theme("aliner") /// // Asigna un favicon. /// .with_assets(ContextOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) /// // Añade una hoja de estilo externa. /// .with_assets(ContextOp::AddStyleSheet(StyleSheet::from("/css/style.css"))) /// // Añade un script JavaScript. /// .with_assets(ContextOp::AddJavaScript(JavaScript::defer("/js/main.js"))) /// // Añade un parámetro dinámico al contexto. /// .with_param("usuario_id", 42) /// } /// ``` /// /// Y hace operaciones con un contexto dado: /// /// ```rust /// use pagetop::prelude::*; /// /// 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.get_param::("usuario_id").unwrap(); /// assert_eq!(id, 42); /// /// // Genera un identificador para un componente de tipo `Menu`. /// struct Menu; /// let unique_id = cx.required_id::(None); /// assert_eq!(unique_id, "menu-1"); // Si es el primero generado. /// } /// ``` #[rustfmt::skip] pub struct Context { request : Option, // Solicitud HTTP de origen. langid : &'static LanguageIdentifier, // Identificador de idioma. theme : ThemeRef, // Referencia al tema para renderizar. layout : &'static str, // Composición del documento para renderizar. favicon : Option, // Favicon, si se ha definido. stylesheets: Assets, // Hojas de estilo CSS. javascripts: Assets, // Scripts JavaScript. params : HashMap<&'static str, (Box, &'static str)>, // Parámetros en ejecución. id_counter : usize, // Contador para generar identificadores únicos. } impl Default for Context { fn default() -> Self { Context::new(None) } } impl Context { /// Crea un nuevo contexto asociado a una solicitud HTTP. /// /// El contexto inicializa el idioma, tema y composición por defecto, sin favicon ni recursos /// cargados. #[rustfmt::skip] pub fn new(request: Option) -> Self { // Se intenta DEFAULT_LANGID. let langid = DEFAULT_LANGID // Si es None evalúa la cadena de extracción desde la cabecera HTTP. .or_else(|| { request // Se usa `as_ref()` sobre `Option` para no mover el valor. .as_ref() .and_then(|req| req.headers().get("Accept-Language")) .and_then(|value| value.to_str().ok()) .and_then(|language| LangMatch::resolve(language).as_option()) }) // Si todo falla, se recurre a &FALLBACK_LANGID. .unwrap_or(&FALLBACK_LANGID); Context { request, langid, theme : *DEFAULT_THEME, layout : "default", favicon : None, stylesheets: Assets::::new(), javascripts: Assets::::new(), params : HashMap::default(), id_counter : 0, } } // **< 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 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)) } (stylesheets.render(self)) (javascripts.render(self)) }; // Restaura los campos tal y como estaban. self.favicon = favicon; self.stylesheets = stylesheets; self.javascripts = javascripts; markup } // **< Context PARAMS >************************************************************************* /// Recupera una *referencia tipada* al parámetro solicitado. /// /// Devuelve: /// /// - `Ok(&T)` si la clave existe y el tipo coincide. /// - `Err(ErrorParam::NotFound)` si la clave no existe. /// - `Err(ErrorParam::TypeMismatch)` si la clave existe pero el tipo no coincide. /// /// # Ejemplos /// /// ```rust /// use pagetop::prelude::*; /// /// let cx = Context::new(None) /// .with_param("usuario_id", 42_i32) /// .with_param("titulo", "Hola".to_string()); /// /// let id: &i32 = cx.get_param("usuario_id").unwrap(); /// let titulo: &String = cx.get_param("titulo").unwrap(); /// /// // Error de tipo: /// assert!(cx.get_param::("usuario_id").is_err()); /// ``` pub fn get_param(&self, key: &'static str) -> Result<&T, ErrorParam> { let (any, type_name) = self.params.get(key).ok_or(ErrorParam::NotFound)?; any.downcast_ref::() .ok_or_else(|| ErrorParam::TypeMismatch { key, expected: TypeInfo::FullName.of::(), saved: type_name, }) } /// Recupera el parámetro solicitado y lo elimina del contexto. /// /// Devuelve: /// /// - `Ok(T)` si la clave existía y el tipo coincide. /// - `Err(ErrorParam::NotFound)` si la clave no existe. /// - `Err(ErrorParam::TypeMismatch)` si el tipo no coincide. /// /// # Ejemplos /// /// ```rust /// use pagetop::prelude::*; /// /// let mut cx = Context::new(None) /// .with_param("contador", 7_i32) /// .with_param("titulo", "Hola".to_string()); /// /// let n: i32 = cx.take_param("contador").unwrap(); /// assert!(cx.get_param::("contador").is_err()); // ya no está /// /// // Error de tipo: /// assert!(cx.take_param::("titulo").is_err()); /// ``` pub fn take_param(&mut self, key: &'static str) -> Result { let (boxed, saved) = self.params.remove(key).ok_or(ErrorParam::NotFound)?; boxed .downcast::() .map(|b| *b) .map_err(|_| ErrorParam::TypeMismatch { key, expected: TypeInfo::FullName.of::(), saved, }) } /// Elimina un parámetro del contexto. Devuelve `true` si la clave existía y se eliminó. /// /// Devuelve `false` en caso contrario. Usar cuando solo interesa borrar la entrada. /// /// # Ejemplos /// /// ```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 /// ``` pub fn remove_param(&mut self, key: &'static str) -> bool { self.params.remove(key).is_some() } } /// Permite a [`Context`](crate::core::component::Context) actuar como proveedor de idioma. /// /// Devuelve un [`LanguageIdentifier`] siguiendo este orden de prioridad: /// /// 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 un contexto ([`Context`]) como fuente de traducción en /// [`L10n::lookup()`](crate::locale::L10n::lookup) o [`L10n::using()`](crate::locale::L10n::using). impl LangId for Context { fn langid(&self) -> &'static LanguageIdentifier { self.langid } } impl Contextual for Context { // **< Contextual BUILDER >********************************************************************* #[builder_fn] fn with_request(mut self, request: Option) -> Self { self.request = request; self } #[builder_fn] fn with_langid(mut self, language: &impl LangId) -> Self { self.langid = language.langid(); self } /// Asigna el tema para renderizar el documento. /// /// Localiza el tema por su [`short_name()`](crate::core::AnyInfo::short_name), y si no aplica /// ninguno entonces usará el tema por defecto. #[builder_fn] fn with_theme(mut self, theme_name: &'static str) -> Self { self.theme = theme_by_short_name(theme_name).unwrap_or(*DEFAULT_THEME); self } #[builder_fn] fn with_layout(mut self, layout_name: &'static str) -> Self { self.layout = layout_name; self } /// Añade o modifica un parámetro dinámico del contexto. /// /// El valor se guarda conservando el *nombre del tipo* real para mejorar los mensajes de error /// posteriores. /// /// # Ejemplos /// /// ```rust /// use pagetop::prelude::*; /// /// let cx = Context::new(None) /// .with_param("usuario_id", 42_i32) /// .with_param("titulo", "Hola".to_string()) /// .with_param("flags", vec!["a", "b"]); /// ``` #[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: ContextOp) -> Self { match op { // Favicon. ContextOp::SetFavicon(favicon) => { self.favicon = favicon; } ContextOp::SetFaviconIfNone(icon) => { if self.favicon.is_none() { self.favicon = Some(icon); } } // Stylesheets. ContextOp::AddStyleSheet(css) => { self.stylesheets.add(css); } ContextOp::RemoveStyleSheet(path) => { self.stylesheets.remove(path); } // JavaScripts. ContextOp::AddJavaScript(js) => { self.javascripts.add(js); } ContextOp::RemoveJavaScript(path) => { self.javascripts.remove(path); } } self } // **< Contextual GETTERS >********************************************************************* fn request(&self) -> Option<&HttpRequest> { self.request.as_ref() } fn theme(&self) -> ThemeRef { self.theme } fn layout(&self) -> &str { self.layout } /// Recupera un parámetro como [`Option`], simplificando el acceso. /// /// A diferencia de [`get_param`](Self::get_param), que devuelve un [`Result`] con información /// detallada de error, este método devuelve `None` tanto si la clave no existe como si el valor /// guardado no coincide con el tipo solicitado. /// /// Resulta útil en escenarios donde sólo interesa saber si el valor existe y es del tipo /// correcto, sin necesidad de diferenciar entre error de ausencia o de tipo. /// /// # Ejemplo /// /// ```rust /// use pagetop::prelude::*; /// /// let cx = Context::new(None).with_param("username", "Alice".to_string()); /// /// // Devuelve Some(&String) si existe y coincide el tipo. /// assert_eq!(cx.param::("username").map(|s| s.as_str()), Some("Alice")); /// /// // Devuelve None si no existe o si el tipo no coincide. /// assert!(cx.param::("username").is_none()); /// assert!(cx.param::("missing").is_none()); /// /// // Acceso con valor por defecto. /// let user = cx.param::("missing") /// .cloned() /// .unwrap_or_else(|| "visitor".to_string()); /// assert_eq!(user, "visitor"); /// ``` fn param(&self, key: &'static str) -> Option<&T> { self.get_param::(key).ok() } fn favicon(&self) -> Option<&Favicon> { self.favicon.as_ref() } fn stylesheets(&self) -> &Assets { &self.stylesheets } fn javascripts(&self) -> &Assets { &self.javascripts } // **< Contextual HELPERS >********************************************************************* /// Devuelve un identificador único dentro del contexto para el tipo `T`, si no se proporciona /// un `id` explícito. /// /// Si no se proporciona un `id`, se genera un identificador único en la forma `-` /// donde `` es el nombre corto del tipo en minúsculas (sin espacios) y `` es un /// contador interno incremental. fn required_id(&mut self, id: Option) -> String { if let Some(id) = id { id } else { let prefix = TypeInfo::ShortName .of::() .trim() .replace(' ', "_") .to_lowercase(); let prefix = if prefix.is_empty() { "prefix".to_string() } else { prefix }; self.id_counter += 1; join!(prefix, "-", self.id_counter.to_string()) } } }