♻️ (pagetop): Refactorización de Contextual

Los métodos `required_id()` y `push_message()` son operaciones de tiempo
de renderizado, no de construcción. Se trasladan como métodos inherentes
de `Context`. También se ajustan los métodos asociados a parámetros.
This commit is contained in:
Manuel Cillero 2026-04-12 13:43:32 +02:00
parent 318d7de2b6
commit 0419658192
7 changed files with 121 additions and 189 deletions

View file

@ -36,10 +36,10 @@ impl Component for Block {
return Ok(html! {});
}
let id = cx.required_id::<Block>(self.id());
let id = cx.required_id::<Self>(self.id(), 1);
Ok(html! {
div id=(id) class=[self.classes().get()] {
div id=(&id) class=[self.classes().get()] {
@if let Some(title) = self.title().lookup(cx) {
h2 class="block__title" { span { (title) } }
}

View file

@ -26,7 +26,7 @@ use std::sync::Arc;
/// ```rust
/// # use pagetop::prelude::*;
/// let component = Html::with(|cx| {
/// let user = cx.param::<String>("username").cloned().unwrap_or("visitor".to_string());
/// let user = cx.param_or("username", "visitor".to_string());
/// html! {
/// h1 { "Hello, " (user) }
/// }

View file

@ -63,7 +63,7 @@ pub use context::{AssetsOp, Context, ContextError, Contextual};
///
/// // Se instancia un componente que sólo se renderiza si `user_logged_in` es `true`.
/// let mut component = SampleComponent::new().with_renderable(Some(|cx: &Context| {
/// cx.param::<bool>("user_logged_in").copied().unwrap_or(false)
/// cx.param_or_default::<bool>("user_logged_in")
/// }));
///
/// // Aquí simplemente se comprueba que compila y se puede invocar.

View file

@ -1,4 +1,4 @@
use crate::core::component::{ChildOp, MessageLevel, StatusMessage};
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::core::TypeInfo;
@ -77,7 +77,6 @@ impl std::error::Error for ContextError {}
/// - 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.
/// - Generar **identificadores únicos** por tipo de componente.
///
/// Lo implementan, típicamente, estructuras que manejan el contexto de renderizado, como
/// [`Context`](crate::core::component::Context) o [`Page`](crate::response::page::Page).
@ -94,7 +93,7 @@ impl std::error::Error for ContextError {}
/// .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("usuario_id", 42_i32)
/// .with_param("user_id", 42_i32)
/// }
/// ```
pub trait Contextual: LangId {
@ -118,16 +117,17 @@ pub trait Contextual: LangId {
/// Añade o modifica un parámetro dinámico del contexto.
///
/// El valor se guardará conservando el *nombre del tipo* real para mejorar los mensajes de
/// error posteriores.
/// 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.
///
/// # Ejemplos
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// let cx = Context::new(None)
/// .with_param("usuario_id", 42_i32)
/// .with_param("titulo", "Hola".to_string())
/// .with_param("user_id", 42_i32)
/// .with_param("title", "Hello".to_string())
/// .with_param("flags", vec!["a", "b"]);
/// ```
#[builder_fn]
@ -158,49 +158,43 @@ pub trait Contextual: LangId {
/// Devuelve la plantilla configurada para renderizar el documento.
fn template(&self) -> TemplateRef;
/// Recupera un parámetro como [`Option`], simplificando el acceso.
/// Recupera una *referencia tipada* al parámetro solicitado.
///
/// A diferencia de [`get_param`](Context::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.
/// Devuelve:
///
/// 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.
/// - `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("username", "Alice".to_string());
/// let cx = Context::new(None)
/// .with_param("user_id", 42_i32)
/// .with_param("title", "Hello".to_string());
///
/// // Devuelve Some(&String) si existe y coincide el tipo.
/// assert_eq!(cx.param::<String>("username").map(|s| s.as_str()), Some("Alice"));
/// let id: i32 = *cx.param("user_id").unwrap();
/// let title: &String = cx.param("title").unwrap();
///
/// // Devuelve None si no existe o si el tipo no coincide.
/// assert!(cx.param::<i32>("username").is_none());
/// assert!(cx.param::<String>("missing").is_none());
///
/// // Acceso con valor por defecto.
/// let user = cx.param::<String>("missing")
/// .cloned()
/// .unwrap_or_else(|| "visitor".to_string());
/// assert_eq!(user, "visitor");
/// // Error de tipo:
/// assert!(cx.param::<String>("user_id").is_err());
/// ```
fn param<T: 'static>(&self, key: &'static str) -> Option<&T>;
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: Default + Clone + 'static>(&self, key: &'static str) -> T {
self.param::<T>(key).cloned().unwrap_or_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).cloned().unwrap_or(default)
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).cloned().unwrap_or_else(f)
self.param::<T>(key).ok().cloned().unwrap_or_else(f)
}
/// Devuelve el Favicon de los recursos del contexto.
@ -214,27 +208,17 @@ pub trait Contextual: LangId {
// **< Contextual HELPERS >*********************************************************************
/// Devuelve el `id` proporcionado tal cual, o genera uno único para el tipo `T` si no se
/// proporciona ninguno.
///
/// Si `id` es `None`, construye un identificador en la forma `<tipo>-<n>`, donde `<tipo>` es el
/// nombre corto del tipo en minúsculas y `<n>` un contador incremental interno del contexto. Es
/// útil para asignar identificadores HTML predecibles cuando el componente no recibe uno
/// explícito.
fn required_id<T>(&self, id: Option<String>) -> String;
/// Acumula un [`StatusMessage`] en el contexto para notificar al visitante.
///
/// Pueden generarse en cualquier punto del ciclo de una petición web (manejadores, renderizado,
/// lógica de negocio, etc.) que tengan acceso al contexto, y mostrarlos luego, por ejemplo, en
/// la página final devuelta al usuario.
/// Elimina un parámetro del contexto. Devuelve `true` si la clave existía y se eliminó.
///
/// # Ejemplo
///
/// ```rust,ignore
/// cx.push_message(MessageLevel::Warning, L10n::l("session-not-valid"));
/// ```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 push_message(&mut self, level: MessageLevel, text: L10n);
fn remove_param(&mut self, key: &'static str) -> bool;
}
/// Implementa un **contexto de renderizado** para un documento HTML.
@ -264,7 +248,7 @@ pub trait Contextual: LangId {
/// // 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("usuario_id", 42)
/// .with_param("user_id", 42)
/// }
/// ```
///
@ -272,34 +256,38 @@ pub trait Contextual: LangId {
///
/// ```rust
/// # 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.get_param::<i32>("usuario_id").unwrap();
/// let id: i32 = *cx.param::<i32>("user_id").unwrap();
/// assert_eq!(id, 42);
///
/// // Genera un identificador para un componente de tipo `Menu`.
/// struct Menu;
/// let unique_id = cx.required_id::<Menu>(None);
/// let unique_id = cx.required_id::<Menu>(None, 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.
theme : ThemeRef, // Referencia al tema usado para renderizar.
template : TemplateRef, // Plantilla usada para renderizar.
favicon : Option<Favicon>, // Favicon, si se ha definido.
stylesheets: Assets<StyleSheet>, // Hojas de estilo CSS.
javascripts: Assets<JavaScript>, // Scripts JavaScript.
regions : ChildrenInRegions, // Regiones de componentes para renderizar.
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.
stylesheets: Assets<StyleSheet>, // Hojas de estilo CSS.
javascripts: Assets<JavaScript>, // Scripts JavaScript.
regions : ChildrenInRegions, // Regiones de componentes para renderizar.
params : HashMap<&'static str, (Box<dyn Any>, &'static str)>, // Parámetros en ejecución.
id_counter : Cell<usize>, // Cell permite incrementarlo desde &self en required_id().
messages : Vec<StatusMessage>, // Mensajes de usuario acumulados.
id_counter : Cell<usize>, // Cell permite incrementar desde &self en required_id().
messages : Vec<StatusMessage>, // Mensajes de usuario acumulados.
}
impl Default for Context {
@ -366,90 +354,6 @@ impl Context {
.render(self)
}
// **< Context PARAMS >*************************************************************************
/// 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.
///
/// # 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::<String>("usuario_id").is_err());
/// ```
pub fn get_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,
})
}
/// Recupera el parámetro solicitado y lo elimina del contexto.
///
/// Devuelve:
///
/// - `Ok(T)` si la clave existía y el tipo coincide.
/// - `Err(ContextError::ParamNotFound)` si la clave no existe.
/// - `Err(ContextError::ParamTypeMismatch)` 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::<i32>("contador").is_err()); // ya no está
///
/// // Error de tipo:
/// assert!(cx.take_param::<i32>("titulo").is_err());
/// ```
pub fn take_param<T: 'static>(&mut self, key: &'static str) -> Result<T, ContextError> {
let (boxed, saved) = self.params.remove(key).ok_or(ContextError::ParamNotFound)?;
boxed
.downcast::<T>()
.map(|b| *b)
.map_err(|_| ContextError::ParamTypeMismatch {
key,
expected: TypeInfo::FullName.of::<T>(),
saved,
})
}
/// Elimina un parámetro del contexto. Devuelve `true` si la clave existía y se eliminó.
///
/// Devuelve `false` en caso contrario. Usar cuando sólo 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()
}
// **< Context HELPERS >************************************************************************
/// Construye una ruta aplicada al contexto actual.
@ -470,6 +374,51 @@ impl Context {
route
}
/// Garantiza un identificador único para un componente `C`, generándolo si no se proporciona
/// ninguno.
///
/// Si `id` es `None`, crea un identificador usando los últimos segmentos del *path* completo
/// del tipo `C`, separados por `-` y en minúsculas, seguidos de un contador incremental interno
/// del contexto. Por ejemplo, para un componente `MyApp::ui::Menu` con `parts = 2` podría
/// devolver un identificador como `ui-menu-1` si ha sido el primero en generarse.
///
/// Con `parts = 1` se usa el nombre corto del tipo. Si `parts` es `0` o supera el número de
/// segmentos del *path*, entonces se usará el *path* completo.
///
/// Es útil para asignar identificadores HTML predecibles cuando el componente no recibe uno
/// explícito.
pub fn required_id<C: Component>(&self, id: Option<String>, parts: usize) -> String {
if let Some(id) = id {
return id;
}
let segments: Vec<&str> = TypeInfo::FullName.of::<C>().split("::").collect();
let parts = if parts == 0 || parts >= segments.len() {
segments.len()
} else {
parts
};
self.id_counter.set(self.id_counter.get() + 1);
let prefix = segments[segments.len() - parts..].join("-").to_lowercase();
util::join!(prefix, "-", self.id_counter.get().to_string())
}
/// Acumula un [`StatusMessage`] en el contexto para notificar al visitante.
///
/// Pueden generarse en cualquier punto del ciclo de una petición web (manejadores, 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
/// # 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
@ -589,8 +538,14 @@ impl Contextual for Context {
self.template
}
fn param<T: 'static>(&self, key: &'static str) -> Option<&T> {
self.get_param::<T>(key).ok()
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> {
@ -607,26 +562,7 @@ impl Contextual for Context {
// **< Contextual HELPERS >*********************************************************************
fn required_id<T>(&self, id: Option<String>) -> String {
if let Some(id) = id {
id
} else {
let prefix = TypeInfo::ShortName
.of::<T>()
.trim()
.replace(' ', "_")
.to_lowercase();
let prefix = if prefix.is_empty() {
"prefix".to_string()
} else {
prefix
};
self.id_counter.set(self.id_counter.get() + 1);
util::join!(prefix, "-", self.id_counter.get().to_string())
}
}
fn push_message(&mut self, level: MessageLevel, text: L10n) {
self.messages.push(StatusMessage::new(level, text));
fn remove_param(&mut self, key: &'static str) -> bool {
self.params.remove(key).is_some()
}
}

View file

@ -19,7 +19,7 @@ pub use error::ErrorPage;
pub use actix_web::Result as ResultPage;
use crate::base::action;
use crate::core::component::{AssetsOp, ChildOp, Context, Contextual};
use crate::core::component::{AssetsOp, ChildOp, Context, ContextError, Contextual};
use crate::core::theme::{DefaultRegion, Region, RegionRef, TemplateRef, ThemeRef};
use crate::html::{html, Markup, DOCTYPE};
use crate::html::{Assets, Favicon, JavaScript, StyleSheet};
@ -349,7 +349,7 @@ impl Contextual for Page {
self.context.template()
}
fn param<T: 'static>(&self, key: &'static str) -> Option<&T> {
fn param<T: 'static>(&self, key: &'static str) -> Result<&T, ContextError> {
self.context.param(key)
}
@ -367,11 +367,7 @@ impl Contextual for Page {
// **< Contextual HELPERS >*********************************************************************
fn required_id<T>(&self, id: Option<String>) -> String {
self.context.required_id::<T>(id)
}
fn push_message(&mut self, level: crate::prelude::MessageLevel, text: L10n) {
self.context.push_message(level, text);
fn remove_param(&mut self, key: &'static str) -> bool {
self.context.remove_param(key)
}
}