diff --git a/extensions/pagetop-aliner/src/lib.rs b/extensions/pagetop-aliner/src/lib.rs index cbc0f52..edbb504 100644 --- a/extensions/pagetop-aliner/src/lib.rs +++ b/extensions/pagetop-aliner/src/lib.rs @@ -82,13 +82,13 @@ async fn homepage(request: HttpRequest) -> ResultPage { use pagetop::prelude::*; -/// El tema usa las mismas regiones predefinidas por [`DefaultRegions`]. -pub type AlinerRegions = DefaultRegions; +/// El tema usa las mismas regiones predefinidas por [`ThemeRegion`]. +pub type AlinerRegion = ThemeRegion; /// Implementa el tema para usar en pruebas que muestran el esquema de páginas HTML. /// /// Tema mínimo ideal para **pruebas y demos** que renderiza el **esqueleto HTML** con las mismas -/// regiones básicas definidas por [`DefaultRegions`]. No pretende ser un tema para producción, está +/// regiones básicas definidas por [`ThemeRegion`]. No pretende ser un tema para producción, está /// pensado para: /// /// - Verificar integración de componentes y composiciones (*layouts*) sin estilos complejos. diff --git a/extensions/pagetop-bootsier/src/lib.rs b/extensions/pagetop-bootsier/src/lib.rs index 76f9d1e..4cb35a2 100644 --- a/extensions/pagetop-bootsier/src/lib.rs +++ b/extensions/pagetop-bootsier/src/lib.rs @@ -101,8 +101,8 @@ pub mod prelude { pub use crate::theme::*; } -/// El tema usa las mismas regiones predefinidas por [`DefaultRegions`]. -pub type BootsierRegions = DefaultRegions; +/// El tema usa las mismas regiones predefinidas por [`ThemeRegion`]. +pub type BootsierRegion = ThemeRegion; /// Implementa el tema. pub struct Bootsier; diff --git a/src/base/action/component.rs b/src/base/action/component.rs index 30c7ba4..aaef1ce 100644 --- a/src/base/action/component.rs +++ b/src/base/action/component.rs @@ -1,5 +1,8 @@ //! Acciones que operan sobre componentes. +mod is_renderable; +pub use is_renderable::*; + mod before_render_component; pub use before_render_component::*; diff --git a/src/base/action/component/is_renderable.rs b/src/base/action/component/is_renderable.rs new file mode 100644 index 0000000..5a0e244 --- /dev/null +++ b/src/base/action/component/is_renderable.rs @@ -0,0 +1,96 @@ +use crate::prelude::*; + +/// Tipo de función para determinar si un componente se renderiza o no. +/// +/// Se usa en la acción [`IsRenderable`] para controlar dinámicamente la visibilidad del componente +/// `component` según el contexto `cx`. El componente **no se renderiza** en cuanto una de las +/// funciones devuelva `false`. +pub type FnIsRenderable = fn(component: &C, cx: &Context) -> bool; + +/// Con la función [`FnIsRenderable`] se puede decidir si se renderiza o no un componente. +pub struct IsRenderable { + f: FnIsRenderable, + referer_type_id: Option, + referer_id: AttrId, + weight: Weight, +} + +/// Filtro para despachar [`FnIsRenderable`] para decidir si se renderiza o no un componente `C`. +impl ActionDispatcher for IsRenderable { + /// Devuelve el identificador de tipo ([`UniqueId`]) del componente `C`. + fn referer_type_id(&self) -> Option { + self.referer_type_id + } + + /// Devuelve el identificador del componente. + fn referer_id(&self) -> Option { + self.referer_id.get() + } + + /// Devuelve el peso para definir el orden de ejecución. + fn weight(&self) -> Weight { + self.weight + } +} + +impl IsRenderable { + /// Permite [registrar](Extension::actions) una nueva acción [`FnIsRenderable`]. + pub fn new(f: FnIsRenderable) -> Self { + IsRenderable { + f, + referer_type_id: Some(UniqueId::of::()), + referer_id: AttrId::default(), + weight: 0, + } + } + + /// Afina el registro para ejecutar la acción [`FnIsRenderable`] sólo para el componente `C` + /// con identificador `id`. + pub fn filter_by_referer_id(mut self, id: impl AsRef) -> Self { + self.referer_id.alter_value(id); + self + } + + /// Opcional. Acciones con pesos más bajos se aplican antes. Se pueden usar valores negativos. + pub fn with_weight(mut self, value: Weight) -> Self { + self.weight = value; + self + } + + // Despacha las acciones. Se detiene en cuanto una [`FnIsRenderable`] devuelve `false`. + #[inline] + pub(crate) fn dispatch(component: &C, cx: &mut Context) -> bool { + let mut renderable = true; + dispatch_actions( + &ActionKey::new( + UniqueId::of::(), + None, + Some(UniqueId::of::()), + None, + ), + |action: &Self| { + if renderable && !(action.f)(component, cx) { + renderable = false; + } + }, + ); + if renderable { + if let Some(id) = component.id() { + dispatch_actions( + &ActionKey::new( + UniqueId::of::(), + None, + Some(UniqueId::of::()), + Some(id), + ), + |action: &Self| { + if renderable && !(action.f)(component, cx) { + renderable = false; + } + }, + ); + } + } + renderable + } +} diff --git a/src/base/theme.rs b/src/base/theme.rs index 1e5b1a8..a4b2df5 100644 --- a/src/base/theme.rs +++ b/src/base/theme.rs @@ -1,4 +1,4 @@ //! Temas básicos soportados por PageTop. mod basic; -pub use basic::{Basic, BasicRegions}; +pub use basic::{Basic, BasicRegion}; diff --git a/src/base/theme/basic.rs b/src/base/theme/basic.rs index 2d713e3..a671185 100644 --- a/src/base/theme/basic.rs +++ b/src/base/theme/basic.rs @@ -1,8 +1,8 @@ /// Es el tema básico que incluye PageTop por defecto. use crate::prelude::*; -/// El tema básico usa las mismas regiones predefinidas por [`DefaultRegions`]. -pub type BasicRegions = DefaultRegions; +/// El tema básico usa las mismas regiones predefinidas por [`ThemeRegion`]. +pub type BasicRegion = ThemeRegion; /// Tema básico por defecto que extiende el funcionamiento predeterminado de [`Theme`]. pub struct Basic; diff --git a/src/core/component.rs b/src/core/component.rs index 9c9ade2..a7faa2f 100644 --- a/src/core/component.rs +++ b/src/core/component.rs @@ -11,59 +11,6 @@ pub use children::{Typed, TypedOp}; mod context; pub use context::{Context, ContextError, ContextOp, Contextual}; -/// Alias de función (*callback*) para **determinar si un componente se renderiza o no**. -/// -/// Puede usarse para permitir que una instancia concreta de un tipo de componente dado decida -/// dinámicamente durante el proceso de renderizado ([`Component::is_renderable()`]) si se renderiza -/// o no. -/// -/// # Ejemplo -/// -/// ```rust -/// # use pagetop::prelude::*; -/// #[derive(AutoDefault)] -/// struct SampleComponent { -/// renderable: Option, -/// } -/// -/// impl Component for SampleComponent { -/// fn new() -> Self { -/// Self::default() -/// } -/// -/// fn is_renderable(&self, cx: &mut Context) -> bool { -/// // Si hay callback, se usa; en caso contrario, se renderiza por defecto. -/// self.renderable.map_or(true, |f| f(cx)) -/// } -/// -/// fn prepare_component(&self, _cx: &mut Context) -> PrepareMarkup { -/// PrepareMarkup::Escaped("Visible component".into()) -/// } -/// } -/// -/// impl SampleComponent { -/// /// Asigna una función que decidirá si el componente se renderiza o no. -/// #[builder_fn] -/// pub fn with_renderable(mut self, f: Option) -> Self { -/// self.renderable = f; -/// self -/// } -/// } -/// -/// fn sample() { -/// let mut cx = Context::default().with_param("user_logged_in", true); -/// -/// // 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::("user_logged_in").copied().unwrap_or(false) -/// })); -/// -/// // Aquí simplemente se comprueba que compila y se puede invocar. -/// let _markup = component.render(&mut cx); -/// } -/// ``` -pub type FnIsRenderable = fn(cx: &Context) -> bool; - /// Alias de función (*callback*) para **resolver una URL** según el contexto de renderizado. /// /// Se usa para generar enlaces dinámicos en función del contexto (petición, idioma, etc.). Debe diff --git a/src/core/component/definition.rs b/src/core/component/definition.rs index 13b0385..c0573b4 100644 --- a/src/core/component/definition.rs +++ b/src/core/component/definition.rs @@ -45,20 +45,6 @@ pub trait Component: AnyInfo + ComponentRender + Send + Sync { None } - /// Indica si el componente es renderizable. - /// - /// Por defecto, todos los componentes son renderizables (`true`). Sin embargo, este método - /// puede sobrescribirse para decidir dinámicamente si los componentes de este tipo se - /// renderizan o no en función del contexto de renderizado. - /// - /// También puede usarse junto con un alias de función como - /// ([`FnIsRenderable`](crate::core::component::FnIsRenderable)) para permitir que instancias - /// concretas del componente decidan si se renderizan o no. - #[allow(unused_variables)] - fn is_renderable(&self, cx: &mut Context) -> bool { - true - } - /// Configura el componente justo antes de preparar el renderizado. /// /// Este método puede sobrescribirse para modificar la estructura interna del componente o el @@ -86,30 +72,30 @@ pub trait Component: AnyInfo + ComponentRender + Send + Sync { /// Implementa [`render()`](ComponentRender::render) para todos los componentes. /// -/// El proceso de renderizado de cada componente sigue esta secuencia: +/// Y para cada componente ejecuta la siguiente secuencia: /// -/// 1. Ejecuta [`is_renderable()`](Component::is_renderable) para ver si puede renderizarse en el -/// contexto actual. Si no es así, devuelve un [`Markup`] vacío. +/// 1. Despacha [`action::component::IsRenderable`](crate::base::action::component::IsRenderable) +/// para ver si se puede renderizar. Si no es así, devuelve un [`Markup`] vacío. /// 2. Ejecuta [`setup_before_prepare()`](Component::setup_before_prepare) para que el componente /// pueda ajustar su estructura interna o modificar el contexto. /// 3. Despacha [`action::theme::BeforeRender`](crate::base::action::theme::BeforeRender) para -/// permitir que el tema realice ajustes previos. +/// que el tema pueda hacer ajustes en el componente o el contexto. /// 4. Despacha [`action::component::BeforeRender`](crate::base::action::component::BeforeRender) -/// para que otras extensiones puedan también hacer ajustes previos. +/// para que otras extensiones puedan hacer ajustes. /// 5. **Prepara el renderizado del componente**: /// - Despacha [`action::theme::PrepareRender`](crate::base::action::theme::PrepareRender) -/// para permitir al tema generar un renderizado alternativo. -/// - Si el tema no lo modifica, llama a [`prepare_component()`](Component::prepare_component) -/// para obtener el renderizado por defecto del componente. +/// para permitir al tema preparar un renderizado diferente al predefinido. +/// - Si no es así, ejecuta [`prepare_component()`](Component::prepare_component) para preparar +/// el renderizado predefinido del componente. /// 6. Despacha [`action::theme::AfterRender`](crate::base::action::theme::AfterRender) para -/// que el tema pueda aplicar ajustes finales. +/// que el tema pueda hacer sus últimos ajustes. /// 7. Despacha [`action::component::AfterRender`](crate::base::action::component::AfterRender) /// para que otras extensiones puedan hacer sus últimos ajustes. -/// 8. Devuelve el [`Markup`] generado en el paso 5. +/// 8. Finalmente devuelve un [`Markup`] del renderizado preparado en el paso 5. impl ComponentRender for C { fn render(&mut self, cx: &mut Context) -> Markup { // Si no es renderizable, devuelve un bloque HTML vacío. - if !self.is_renderable(cx) { + if !action::component::IsRenderable::dispatch(self, cx) { return html! {}; } diff --git a/src/core/theme.rs b/src/core/theme.rs index 28638ba..64f40f3 100644 --- a/src/core/theme.rs +++ b/src/core/theme.rs @@ -9,14 +9,13 @@ //! tipografías, espaciados y cualquier otro detalle visual o de comportamiento (como animaciones, //! scripts de interfaz, etc.). //! -//! Los temas son extensiones que implementan [`Extension`](crate::core::extension::Extension), por -//! lo que se instancian, declaran dependencias y se inician igual que cualquier otra extensión. -//! También deben implementar [`Theme`] y sobrescribir el método -//! [`Extension::theme()`](crate::core::extension::Extension::theme) para que PageTop pueda -//! registrarlos como temas +//! Los temas son extensiones que implementan [`Extension`](crate::core::extension::Extension); por +//! lo que se instancian, declaran sus dependencias y se inician igual que el resto de extensiones; +//! pero serán temas si además implementan [`theme()`](crate::core::extension::Extension::theme) y +//! [`Theme`]. mod definition; -pub use definition::{Theme, ThemePage, ThemeRef, DefaultRegions}; +pub use definition::{Theme, ThemePage, ThemeRef, ThemeRegion}; mod regions; pub(crate) use regions::{ChildrenInRegions, REGION_CONTENT}; diff --git a/src/core/theme/definition.rs b/src/core/theme/definition.rs index 7d21c14..2a20c07 100644 --- a/src/core/theme/definition.rs +++ b/src/core/theme/definition.rs @@ -4,7 +4,7 @@ use crate::core::theme::{Region, RegionRef, REGION_CONTENT}; use crate::html::{html, Markup, StyleSheet}; use crate::locale::L10n; use crate::response::page::Page; -use crate::{global, join, AutoDefault}; +use crate::{global, join}; use std::sync::LazyLock; @@ -14,17 +14,16 @@ use std::sync::LazyLock; /// implementen [`Theme`] y, a su vez, [`Extension`]. pub type ThemeRef = &'static dyn Theme; -/// Conjunto de regiones predefinidas que los temas pueden exponer para el renderizado. +/// Conjunto de regiones que los temas pueden exponer para el renderizado. /// -/// `DefaultRegions` define un conjunto de regiones predefinidas para estructurar un documento HTML. +/// `ThemeRegion` define un conjunto de regiones predefinidas para estructurar un documento HTML. /// Proporciona **identificadores estables** (vía [`Region::key()`]) y **etiquetas localizables** /// (vía [`Region::label()`]) a las regiones donde se añadirán los componentes. /// /// Se usa por defecto en [`Theme::page_regions()`](crate::core::theme::Theme::page_regions) y sus /// variantes representan el conjunto mínimo recomendado para cualquier tema. Sin embargo, cada tema /// podría exponer su propio conjunto de regiones. -#[derive(AutoDefault)] -pub enum DefaultRegions { +pub enum ThemeRegion { /// Cabecera de la página. /// /// Clave: `"header"`. Suele contener *branding*, navegación principal o avisos globales. @@ -33,7 +32,6 @@ pub enum DefaultRegions { /// Contenido principal de la página (**obligatoria**). /// /// Clave: `"content"`. Es el destino por defecto para insertar componentes a nivel de página. - #[default] Content, /// Pie de página. @@ -42,12 +40,12 @@ pub enum DefaultRegions { Footer, } -impl Region for DefaultRegions { +impl Region for ThemeRegion { fn key(&self) -> &str { match self { - Self::Header => "header", - Self::Content => REGION_CONTENT, - Self::Footer => "footer", + ThemeRegion::Header => "header", + ThemeRegion::Content => REGION_CONTENT, + ThemeRegion::Footer => "footer", } } @@ -62,17 +60,16 @@ impl Region for DefaultRegions { /// implementa automáticamente para cualquier tipo que implemente [`Theme`], por lo que normalmente /// no requiere implementación explícita. /// -/// Si un tema **sobrescribe** uno o más de los siguientes métodos de [`Theme`]: +/// Si un tema **sobrescribe** uno o más de estos métodos de [`Theme`]: /// /// - [`render_page_region()`](Theme::render_page_region), /// - [`render_page_head()`](Theme::render_page_head), o /// - [`render_page_body()`](Theme::render_page_body); /// -/// puede volver al comportamiento por defecto con una llamada FQS (*Fully Qualified Syntax*) a: +/// es posible volver al comportamiento por defecto usando FQS (*Fully Qualified Syntax*): /// -/// - `::render_region(self, page, region)`, -/// - `::render_body(self, page, self.page_regions())`, o -/// - `::render_head(self, page)`. +/// - `::render_body(self, page, self.page_regions())` +/// - `::render_head(self, page)` pub trait ThemePage { /// Renderiza el **contenedor** de una región concreta del `` de la página. /// @@ -209,9 +206,9 @@ pub trait Theme: Extension + ThemePage + Send + Sync { /// fn page_regions(&self) -> &'static [RegionRef] { /// static REGIONS: LazyLock<[RegionRef; 4]> = LazyLock::new(|| { /// [ - /// &DefaultRegions::Header, - /// &DefaultRegions::Content, - /// &DefaultRegions::Footer, + /// &ThemeRegion::Header, + /// &ThemeRegion::Content, + /// &ThemeRegion::Footer, /// ] /// }); /// &*REGIONS @@ -220,9 +217,9 @@ pub trait Theme: Extension + ThemePage + Send + Sync { fn page_regions(&self) -> &'static [RegionRef] { static REGIONS: LazyLock<[RegionRef; 3]> = LazyLock::new(|| { [ - &DefaultRegions::Header, - &DefaultRegions::Content, - &DefaultRegions::Footer, + &ThemeRegion::Header, + &ThemeRegion::Content, + &ThemeRegion::Footer, ] }); &*REGIONS diff --git a/src/core/theme/regions.rs b/src/core/theme/regions.rs index 17e1543..8e386f5 100644 --- a/src/core/theme/regions.rs +++ b/src/core/theme/regions.rs @@ -31,25 +31,25 @@ pub const REGION_CONTENT: &str = "content"; /// `aria-label` o en descripciones semánticas del contenedor). /// /// Las implementaciones típicas son *enumeraciones estáticas* declaradas por cada tema (ver como -/// ejemplo [`DefaultRegions`](crate::core::theme::DefaultRegions)), de modo que las claves y -/// etiquetas permanecen inmutables y fácilmente referenciables. +/// ejemplo [`ThemeRegion`](crate::core::theme::ThemeRegion)), de modo que las claves y etiquetas +/// permanecen inmutables y fácilmente referenciables. /// /// # Ejemplo /// /// ```rust /// # use pagetop::prelude::*; -/// pub enum MyThemeRegions { +/// pub enum MyThemeRegion { /// Header, /// Content, /// Footer, /// } /// -/// impl Region for MyThemeRegions { +/// impl Region for MyThemeRegion { /// fn key(&self) -> &str { /// match self { -/// Self::Header => "header", -/// Self::Content => "content", -/// Self::Footer => "footer", +/// MyThemeRegion::Header => "header", +/// MyThemeRegion::Content => "content", +/// MyThemeRegion::Footer => "footer", /// } /// } /// @@ -111,7 +111,7 @@ impl ChildrenInRegions { } } -/// Permite añadir componentes a regiones globales o específicas de un tema. +/// Punto de acceso para añadir componentes a regiones globales o específicas de un tema. /// /// Según la variante, se pueden añadir componentes ([`add()`](Self::add)) que permanecerán /// disponibles durante toda la ejecución. diff --git a/src/html.rs b/src/html.rs index 82fdcd7..5f5b833 100644 --- a/src/html.rs +++ b/src/html.rs @@ -104,11 +104,11 @@ pub use unit::UnitValue; /// # use pagetop::prelude::*; /// // Texto normal, se escapa automáticamente para evitar inyección de HTML. /// let fragment = PrepareMarkup::Escaped("Hola mundo".to_string()); -/// assert_eq!(fragment.into_string(), "Hola <b>mundo</b>"); +/// assert_eq!(fragment.render().into_string(), "Hola <b>mundo</b>"); /// /// // HTML literal, se inserta directamente, sin escapado adicional. /// let raw_html = PrepareMarkup::Raw("negrita".to_string()); -/// assert_eq!(raw_html.into_string(), "negrita"); +/// assert_eq!(raw_html.render().into_string(), "negrita"); /// /// // Fragmento ya preparado con la macro `html!`. /// let prepared = PrepareMarkup::With(html! { @@ -116,11 +116,11 @@ pub use unit::UnitValue; /// p { "Este es un párrafo con contenido dinámico." } /// }); /// assert_eq!( -/// prepared.into_string(), +/// prepared.render().into_string(), /// "

Título de ejemplo

Este es un párrafo con contenido dinámico.

" /// ); /// ``` -#[derive(AutoDefault, Clone)] +#[derive(AutoDefault)] pub enum PrepareMarkup { /// No se genera contenido HTML (equivale a `html! {}`). #[default] @@ -152,13 +152,8 @@ impl PrepareMarkup { } } - /// Convierte el contenido en una cadena HTML renderizada. Usar sólo para pruebas o depuración. - pub fn into_string(&self) -> String { - self.render().into_string() - } - - // Integra el renderizado fácilmente en la macro [`html!`]. - pub(crate) fn render(&self) -> Markup { + /// Integra el renderizado fácilmente en la macro [`html!`]. + pub fn render(&self) -> Markup { match self { PrepareMarkup::None => html! {}, PrepareMarkup::Escaped(text) => html! { (text) }, diff --git a/tests/component_html.rs b/tests/component_html.rs index 06d77ec..851315a 100644 --- a/tests/component_html.rs +++ b/tests/component_html.rs @@ -2,28 +2,32 @@ use pagetop::prelude::*; #[pagetop::test] async fn component_html_renders_static_markup() { - let mut component = Html::with(|_| { + let component = Html::with(|_| { html! { p { "Test" } } }); - let markup = component.render(&mut Context::default()); + let markup = component + .prepare_component(&mut Context::new(None)) + .render(); + assert_eq!(markup.0, "

Test

"); } #[pagetop::test] async fn component_html_renders_using_context_param() { - let mut cx = Context::default().with_param("username", "Alice".to_string()); + let mut cx = Context::new(None).with_param("username", "Alice".to_string()); - let mut component = Html::with(|cx| { + let component = Html::with(|cx| { let name = cx.param::("username").cloned().unwrap_or_default(); html! { span { (name) } } }); - let markup = component.render(&mut cx); + let markup = component.prepare_component(&mut cx).render(); + assert_eq!(markup.0, "Alice"); } @@ -33,15 +37,21 @@ async fn component_html_allows_replacing_render_function() { component.alter_fn(|_| html! { div { "Modified" } }); - let markup = component.render(&mut Context::default()); + let markup = component + .prepare_component(&mut Context::new(None)) + .render(); + assert_eq!(markup.0, "
Modified
"); } #[pagetop::test] async fn component_html_default_renders_empty_markup() { - let mut component = Html::default(); + let component = Html::default(); + + let markup = component + .prepare_component(&mut Context::new(None)) + .render(); - let markup = component.render(&mut Context::default()); assert_eq!(markup.0, ""); } @@ -50,7 +60,7 @@ async fn component_html_can_access_http_method() { let req = service::test::TestRequest::with_uri("/").to_http_request(); let mut cx = Context::new(Some(req)); - let mut component = Html::with(|cx| { + let component = Html::with(|cx| { let method = cx .request() .map(|r| r.method().to_string()) @@ -58,6 +68,7 @@ async fn component_html_can_access_http_method() { html! { span { (method) } } }); - let markup = component.render(&mut cx); + let markup = component.prepare_component(&mut cx).render(); + assert_eq!(markup.0, "GET"); } diff --git a/tests/component_poweredby.rs b/tests/component_poweredby.rs index 7e5a062..27683d9 100644 --- a/tests/component_poweredby.rs +++ b/tests/component_poweredby.rs @@ -4,8 +4,8 @@ use pagetop::prelude::*; async fn poweredby_default_shows_only_pagetop_recognition() { let _app = service::test::init_service(Application::new().test()).await; - let mut p = PoweredBy::default(); - let html = p.render(&mut Context::default()); + let p = PoweredBy::default(); + let html = render_component(&p); // Debe mostrar el bloque de reconocimiento a PageTop. assert!(html.as_str().contains("poweredby__pagetop")); @@ -18,8 +18,8 @@ async fn poweredby_default_shows_only_pagetop_recognition() { async fn poweredby_new_includes_current_year_and_app_name() { let _app = service::test::init_service(Application::new().test()).await; - let mut p = PoweredBy::new(); - let html = p.render(&mut Context::default()); + let p = PoweredBy::new(); + let html = render_component(&p); let year = Utc::now().format("%Y").to_string(); assert!( @@ -43,8 +43,8 @@ async fn poweredby_with_copyright_overrides_text() { let _app = service::test::init_service(Application::new().test()).await; let custom = "2001 © FooBar Inc."; - let mut p = PoweredBy::default().with_copyright(Some(custom)); - let html = p.render(&mut Context::default()); + let p = PoweredBy::default().with_copyright(Some(custom)); + let html = render_component(&p); assert!(html.as_str().contains(custom)); assert!(html.as_str().contains("poweredby__copyright")); @@ -54,8 +54,8 @@ async fn poweredby_with_copyright_overrides_text() { async fn poweredby_with_copyright_none_hides_text() { let _app = service::test::init_service(Application::new().test()).await; - let mut p = PoweredBy::new().with_copyright(None::); - let html = p.render(&mut Context::default()); + let p = PoweredBy::new().with_copyright(None::); + let html = render_component(&p); assert!(!html.as_str().contains("poweredby__copyright")); // El reconocimiento a PageTop siempre debe aparecer. @@ -66,8 +66,8 @@ async fn poweredby_with_copyright_none_hides_text() { async fn poweredby_link_points_to_crates_io() { let _app = service::test::init_service(Application::new().test()).await; - let mut p = PoweredBy::default(); - let html = p.render(&mut Context::default()); + let p = PoweredBy::default(); + let html = render_component(&p); assert!( html.as_str().contains("https://pagetop.cillero.es"), @@ -89,3 +89,11 @@ async fn poweredby_getter_reflects_internal_state() { assert!(c1.contains(&Utc::now().format("%Y").to_string())); assert!(c1.contains(&global::SETTINGS.app.name)); } + +// **< HELPERS >************************************************************************************ + +fn render_component(c: &C) -> Markup { + let mut cx = Context::default(); + let pm = c.prepare_component(&mut cx); + pm.render() +} diff --git a/tests/html_pm.rs b/tests/html_pm.rs index 615ea47..ae4517b 100644 --- a/tests/html_pm.rs +++ b/tests/html_pm.rs @@ -1,69 +1,70 @@ use pagetop::prelude::*; -/// Componente mínimo para probar `PrepareMarkup` pasando por el ciclo real -/// de renderizado de componentes (`ComponentRender`). -#[derive(AutoDefault)] -struct TestPrepareComponent { - pm: PrepareMarkup, -} - -impl Component for TestPrepareComponent { - fn new() -> Self { - Self { - pm: PrepareMarkup::None, - } - } - - fn prepare_component(&self, _cx: &mut Context) -> PrepareMarkup { - self.pm.clone() - } -} - -impl TestPrepareComponent { - fn render_pm(pm: PrepareMarkup) -> String { - let mut c = TestPrepareComponent { pm }; - c.render(&mut Context::default()).into_string() - } +#[pagetop::test] +async fn prepare_markup_render_none_is_empty_string() { + assert_eq!(PrepareMarkup::None.render().as_str(), ""); } #[pagetop::test] -async fn prepare_markup_none_is_empty_string() { - assert_eq!(PrepareMarkup::None.into_string(), ""); -} - -#[pagetop::test] -async fn prepare_markup_escaped_escapes_html_and_ampersands() { +async fn prepare_markup_render_escaped_escapes_html_and_ampersands() { let pm = PrepareMarkup::Escaped("& \" ' ".to_string()); - assert_eq!(pm.into_string(), "<b>& " ' </b>"); + assert_eq!(pm.render().as_str(), "<b>& " ' </b>"); } #[pagetop::test] -async fn prepare_markup_raw_is_inserted_verbatim() { +async fn prepare_markup_render_raw_is_inserted_verbatim() { let pm = PrepareMarkup::Raw("bold".to_string()); - assert_eq!(pm.into_string(), "bold"); + assert_eq!(pm.render().as_str(), "bold"); } #[pagetop::test] -async fn prepare_markup_with_keeps_structure() { +async fn prepare_markup_render_with_keeps_structure() { let pm = PrepareMarkup::With(html! { h2 { "Sample title" } - p { "This is a paragraph." } + p { "This is a paragraph." } }); assert_eq!( - pm.into_string(), + pm.render().as_str(), "

Sample title

This is a paragraph.

" ); } +#[pagetop::test] +async fn prepare_markup_does_not_double_escape_when_wrapped_in_html_macro() { + // Escaped: dentro de `html!` no debe volver a escaparse. + let escaped = PrepareMarkup::Escaped("x".into()); + let wrapped_escaped = html! { div { (escaped.render()) } }; + assert_eq!( + wrapped_escaped.into_string(), + "
<i>x</i>
" + ); + + // Raw: tampoco debe escaparse al integrarlo. + let raw = PrepareMarkup::Raw("x".into()); + let wrapped_raw = html! { div { (raw.render()) } }; + assert_eq!(wrapped_raw.into_string(), "
x
"); + + // With: debe incrustar el Markup tal cual. + let with = PrepareMarkup::With(html! { span.title { "ok" } }); + let wrapped_with = html! { div { (with.render()) } }; + assert_eq!( + wrapped_with.into_string(), + "
ok
" + ); +} + #[pagetop::test] async fn prepare_markup_unicode_is_preserved() { // Texto con acentos y emojis debe conservarse (salvo el escape HTML de signos). let esc = PrepareMarkup::Escaped("Hello, tomorrow coffee ☕ & donuts!".into()); - assert_eq!(esc.into_string(), "Hello, tomorrow coffee ☕ & donuts!"); + assert_eq!( + esc.render().as_str(), + "Hello, tomorrow coffee ☕ & donuts!" + ); // Raw debe pasar íntegro. let raw = PrepareMarkup::Raw("Title — section © 2025".into()); - assert_eq!(raw.into_string(), "Title — section © 2025"); + assert_eq!(raw.render().as_str(), "Title — section © 2025"); } #[pagetop::test] @@ -87,36 +88,7 @@ async fn prepare_markup_is_empty_semantics() { } #[pagetop::test] -async fn prepare_markup_does_not_double_escape_when_markup_is_reinjected_in_html_macro() { - let mut cx = Context::default(); - - // Escaped: dentro de `html!` no debe volver a escaparse. - let mut comp = TestPrepareComponent { - pm: PrepareMarkup::Escaped("x".into()), - }; - let markup = comp.render(&mut cx); // Markup - let wrapped_escaped = html! { div { (markup) } }.into_string(); - assert_eq!(wrapped_escaped, "
<i>x</i>
"); - - // Raw: tampoco debe escaparse al integrarlo. - let mut comp = TestPrepareComponent { - pm: PrepareMarkup::Raw("x".into()), - }; - let markup = comp.render(&mut cx); - let wrapped_raw = html! { div { (markup) } }.into_string(); - assert_eq!(wrapped_raw, "
x
"); - - // With: debe incrustar el Markup tal cual. - let mut comp = TestPrepareComponent { - pm: PrepareMarkup::With(html! { span.title { "ok" } }), - }; - let markup = comp.render(&mut cx); - let wrapped_with = html! { div { (markup) } }.into_string(); - assert_eq!(wrapped_with, "
ok
"); -} - -#[pagetop::test] -async fn prepare_markup_equivalence_between_component_render_and_markup_reinjected_in_html_macro() { +async fn prepare_markup_equivalence_between_render_and_inline_in_html_macro() { let cases = [ PrepareMarkup::None, PrepareMarkup::Escaped("x".into()), @@ -125,20 +97,12 @@ async fn prepare_markup_equivalence_between_component_render_and_markup_reinject ]; for pm in cases { - // Vía 1: renderizamos y obtenemos directamente el String. - let via_component = TestPrepareComponent::render_pm(pm.clone()); - - // Vía 2: renderizamos, reinyectamos el Markup en `html!` y volvemos a obtener String. - let via_macro = { - let mut cx = Context::default(); - let mut comp = TestPrepareComponent { pm }; - let markup = comp.render(&mut cx); - html! { (markup) }.into_string() - }; - + let rendered = pm.render(); + let in_macro = html! { (rendered) }.into_string(); assert_eq!( - via_component, via_macro, - "The output of component render and (Markup) inside html! must match" + rendered.as_str(), + in_macro, + "The output of Render and (pm) inside html! must match" ); } }