From 9354894b3ae073c4c3fb511626b2eeb3f90b6c3b Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Mon, 6 Jul 2026 00:48:41 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20Convierte=20el=20ciclo=20de=20re?= =?UTF-8?q?nderizado=20en=20async?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prepare()` de `Component`, `render()` de `Region` y `Template`, y la implementación de `ComponentRender` pasan a ser funciones async. Se actualizan todos los componentes base, helpers, tests y ejemplos. --- README.md | 8 +- examples/form-controls.rs | 2 + examples/hello-name.rs | 2 + examples/hello-world.rs | 2 + examples/intro-colors.rs | 2 + extensions/pagetop-aliner/README.md | 3 +- extensions/pagetop-aliner/src/lib.rs | 5 +- helpers/pagetop-build/README.md | 1 + helpers/pagetop-build/src/lib.rs | 1 + src/app.rs | 7 +- src/auth.rs | 3 +- src/base/component/block.rs | 5 +- src/base/component/button.rs | 3 +- src/base/component/form/check.rs | 3 +- src/base/component/form/checkbox.rs | 3 +- src/base/component/form/component.rs | 5 +- src/base/component/form/fieldset.rs | 5 +- src/base/component/form/hidden.rs | 3 +- src/base/component/form/input.rs | 3 +- src/base/component/form/radio.rs | 3 +- src/base/component/form/range.rs | 3 +- src/base/component/form/select.rs | 3 +- src/base/component/form/textarea.rs | 3 +- src/base/component/html.rs | 3 +- src/base/component/intro.rs | 9 +- src/base/component/poweredby.rs | 3 +- src/base/extension/welcome.rs | 2 + src/base/theme/basic.rs | 2 + src/core/action.rs | 1 + src/core/component.rs | 4 +- src/core/component/children.rs | 169 ++++++++++++++------------- src/core/component/context.rs | 44 +++---- src/core/component/definition.rs | 46 +++++--- src/core/component/error.rs | 3 +- src/core/extension/definition.rs | 14 ++- src/core/theme.rs | 23 ++-- src/core/theme/definition.rs | 16 ++- src/core/theme/regions.rs | 32 +++-- src/html/logo.rs | 12 +- src/html/props.rs | 3 +- src/lib.rs | 5 +- src/response/page.rs | 13 ++- src/response/page/error.rs | 122 +++++++++---------- src/web.rs | 1 + tests/component_children.rs | 136 ++++++++++----------- tests/component_html.rs | 20 ++-- tests/component_poweredby.rs | 33 +++--- tests/html_markup.rs | 7 +- 48 files changed, 436 insertions(+), 365 deletions(-) diff --git a/README.md b/README.md index 674a308c..53a68c9a 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ use pagetop::prelude::*; struct HelloWorld; +#[async_trait] impl Extension for HelloWorld { fn configure_router(&self, router: Router) -> Router { router.route("/", web::get(hello_world)) @@ -60,7 +61,7 @@ impl Extension for HelloWorld { async fn hello_world(request: HttpRequest) -> Result { Page::new(request) .with_child(Html::with(|_| html! { h1 { "Hello World!" } })) - .render() + .render().await } #[pagetop::main] @@ -72,7 +73,6 @@ async fn main() -> std::io::Result<()> { Este programa implementa una extensión llamada `HelloWorld` que sirve una página web en la ruta raíz (`/`) mostrando el texto "Hello World!" dentro de un elemento HTML `

`. - ## Proyecto El código se organiza en un *workspace* donde actualmente se incluyen los siguientes subproyectos: @@ -111,7 +111,6 @@ El código se organiza en un *workspace* donde actualmente se incluyen los sigui * **[pagetop-seaorm](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/extensions/pagetop-seaorm)**, integra [SeaORM](https://www.sea-ql.org/SeaORM) para acceder a bases de datos relacionales. - ## Pruebas Para simplificar el flujo de trabajo, el repositorio incluye varios **alias de Cargo** declarados en @@ -132,14 +131,12 @@ Para simplificar el flujo de trabajo, el repositorio incluye varios **alias de C > * Los alias suprimen las trazas del registro de eventos. Para activarlas usa directamente > `cargo test`. - ## Advertencia **PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos hasta que se libere la versión **1.0.0**. - ## Licencia El código está disponible bajo una doble licencia: @@ -153,7 +150,6 @@ El código está disponible bajo una doble licencia: Puedes elegir la licencia que prefieras. Este enfoque de doble licencia es el estándar de facto en el ecosistema Rust. - ## Contribuir PageTop mantiene **un único repositorio oficial**: diff --git a/examples/form-controls.rs b/examples/form-controls.rs index 4679039a..b88bcfa4 100644 --- a/examples/form-controls.rs +++ b/examples/form-controls.rs @@ -5,6 +5,7 @@ include_locales!(LOC from "examples/locale"); struct FormControls; +#[async_trait] impl Extension for FormControls { fn dependencies(&self) -> Vec { vec![&pagetop_aliner::Aliner, &pagetop_bootsier::Bootsier] @@ -297,6 +298,7 @@ async fn form_controls(request: HttpRequest) -> Result { ), ) .render() + .await } fn form_lists() -> Form { diff --git a/examples/hello-name.rs b/examples/hello-name.rs index 3e9de96b..a37051c3 100644 --- a/examples/hello-name.rs +++ b/examples/hello-name.rs @@ -2,6 +2,7 @@ use pagetop::prelude::*; struct HelloName; +#[async_trait] impl Extension for HelloName { fn configure_router(&self, router: Router) -> Router { router.route("/hello/{name}", web::get(hello_name)) @@ -19,6 +20,7 @@ async fn hello_name( } })) .render() + .await } #[pagetop::main] diff --git a/examples/hello-world.rs b/examples/hello-world.rs index 287eb6eb..bbbc3994 100644 --- a/examples/hello-world.rs +++ b/examples/hello-world.rs @@ -2,6 +2,7 @@ use pagetop::prelude::*; struct HelloWorld; +#[async_trait] impl Extension for HelloWorld { fn configure_router(&self, router: Router) -> Router { router.route("/", web::get(hello_world)) @@ -16,6 +17,7 @@ async fn hello_world(request: HttpRequest) -> Result { } })) .render() + .await } #[pagetop::main] diff --git a/examples/intro-colors.rs b/examples/intro-colors.rs index 693a259f..cde6deed 100644 --- a/examples/intro-colors.rs +++ b/examples/intro-colors.rs @@ -4,6 +4,7 @@ include_locales!(LOC from "examples/locale"); struct IntroColors; +#[async_trait] impl Extension for IntroColors { fn configure_router(&self, router: Router) -> Router { router.route("/", web::get(intro_colors)) @@ -74,6 +75,7 @@ async fn intro_colors(request: HttpRequest) -> Result { ), ) .render() + .await } #[pagetop::main] diff --git a/extensions/pagetop-aliner/README.md b/extensions/pagetop-aliner/README.md index ded3aa6f..8ba40b25 100644 --- a/extensions/pagetop-aliner/README.md +++ b/extensions/pagetop-aliner/README.md @@ -34,6 +34,7 @@ use pagetop::prelude::*; struct MyApp; +#[async_trait] impl Extension for MyApp { fn dependencies(&self) -> Vec { vec![ @@ -68,7 +69,7 @@ async fn homepage(request: HttpRequest) -> Result { p { (L10n::l("sample_content").using(cx)) } })), ) - .render() + .render().await } ``` diff --git a/extensions/pagetop-aliner/src/lib.rs b/extensions/pagetop-aliner/src/lib.rs index 33f32677..d037828a 100644 --- a/extensions/pagetop-aliner/src/lib.rs +++ b/extensions/pagetop-aliner/src/lib.rs @@ -35,6 +35,7 @@ use pagetop::prelude::*; struct MyApp; +#[async_trait] impl Extension for MyApp { fn dependencies(&self) -> Vec { vec![ @@ -69,7 +70,7 @@ async fn homepage(request: HttpRequest) -> Result { p { (L10n::l("sample_content").using(cx)) } })), ) - .render() + .render().await } ``` */ @@ -89,6 +90,7 @@ include_locales!(LOCALES_ALINER); /// - Preparar ejemplos y documentación, sin dependencias visuales (CSS/JS) innecesarias. pub struct Aliner; +#[async_trait] impl Extension for Aliner { fn name(&self) -> L10n { L10n::t("extension_name", &LOCALES_ALINER) @@ -108,6 +110,7 @@ impl Extension for Aliner { } } +#[async_trait] impl Theme for Aliner { fn before_render_page_body(&self, page: &mut Page) { page.alter_assets(AssetsOp::AddStyleSheet( diff --git a/helpers/pagetop-build/README.md b/helpers/pagetop-build/README.md index e33bf8f0..b84430eb 100644 --- a/helpers/pagetop-build/README.md +++ b/helpers/pagetop-build/README.md @@ -96,6 +96,7 @@ use pagetop::prelude::*; pub struct MyExtension; +#[async_trait] impl Extension for MyExtension { fn configure_router(&self, mut router: Router) -> Router { serve_static_files!(router, ["./static/css", app_css] => "/public/css"); diff --git a/helpers/pagetop-build/src/lib.rs b/helpers/pagetop-build/src/lib.rs index 5a9d24bb..8e06ae33 100644 --- a/helpers/pagetop-build/src/lib.rs +++ b/helpers/pagetop-build/src/lib.rs @@ -97,6 +97,7 @@ use pagetop::prelude::*; pub struct MyExtension; +#[async_trait] impl Extension for MyExtension { fn configure_router(&self, mut router: Router) -> Router { serve_static_files!(router, ["./static/css", app_css] => "/public/css"); diff --git a/src/app.rs b/src/app.rs index 6949c50f..55f256b1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,7 +5,7 @@ mod figfont; use crate::core::{extension, extension::ExtensionRef}; use crate::html::Markup; use crate::locale::Locale; -use crate::response::page::ErrorPage; +use crate::response::page::{ErrorPage, render_error_pages}; use crate::web::{HttpRequest, Router}; use crate::{PAGETOP_VERSION, global, trace}; @@ -109,7 +109,9 @@ impl Application { fn build_router() -> Router { let router = extension::all::configure_routes(Router::new()); let router = extension::all::configure_middleware(router); - router.fallback(route_not_found) + router + .fallback(route_not_found) + .layer(axum::middleware::from_fn(render_error_pages)) } /// Arranca el servidor web de la aplicación. @@ -123,6 +125,7 @@ impl Application { /// /// struct MyApp; /// + /// #[async_trait] /// impl Extension for MyApp {} /// /// #[pagetop::main] diff --git a/src/auth.rs b/src/auth.rs index 8fd10ea0..a59e23d9 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -94,6 +94,7 @@ pub type FnCheckPermission = fn(cx: &Context, key: &str, granted: &mut bool); /// /// pub struct MyAuth; /// +/// #[async_trait] /// impl Extension for MyAuth { /// fn actions(&self) -> Vec { /// actions![CheckPermission::new(check_my_permissions)] @@ -162,7 +163,7 @@ impl CheckPermission { /// if !has_permission(page.context(), "myapp.edit") { /// return Err(ErrorPage::NotFound(request)); /// } -/// page.render() +/// page.render().await /// } /// ``` pub fn has_permission(cx: &Context, key: &str) -> bool { diff --git a/src/base/component/block.rs b/src/base/component/block.rs index 2e56c5b0..62c4c817 100644 --- a/src/base/component/block.rs +++ b/src/base/component/block.rs @@ -14,6 +14,7 @@ pub struct Block { children: Children, } +#[async_trait] impl Component for Block { fn new() -> Self { Self::default() @@ -31,8 +32,8 @@ impl Component for Block { self.alter_prop(PropsOp::prepend_classes("block")); } - fn prepare(&self, cx: &mut Context) -> Result { - let block_body = self.children().render(cx); + async fn prepare(&self, cx: &mut Context) -> Result { + let block_body = self.children().render(cx).await; if block_body.is_empty() { return Ok(html! {}); diff --git a/src/base/component/button.rs b/src/base/component/button.rs index 8d2f0fa7..e5028ebb 100644 --- a/src/base/component/button.rs +++ b/src/base/component/button.rs @@ -81,6 +81,7 @@ pub struct Button { disabled: bool, } +#[async_trait] impl Component for Button { fn new() -> Self { Self::default() @@ -94,7 +95,7 @@ impl Component for Button { self.alter_prop(PropsOp::prepend_classes("button")); } - fn prepare(&self, cx: &mut Context) -> Result { + async fn prepare(&self, cx: &mut Context) -> Result { Ok(html! { button type=(self.kind()) diff --git a/src/base/component/form/check.rs b/src/base/component/form/check.rs index 2bbc1091..9d2438b1 100644 --- a/src/base/component/form/check.rs +++ b/src/base/component/form/check.rs @@ -121,6 +121,7 @@ pub struct Field { inline: bool, } +#[async_trait] impl Component for Field { fn new() -> Self { Self::default() @@ -145,7 +146,7 @@ impl Component for Field { self.alter_prop(PropsOp::prepend_classes("form-field form-field-checkboxes")); } - fn prepare(&self, cx: &mut Context) -> Result { + async fn prepare(&self, cx: &mut Context) -> Result { // En `setup()` se garantiza que `name` e `id` están definidos antes del renderizado. let name = self.name().get().unwrap(); let container_id = self.id().unwrap(); diff --git a/src/base/component/form/checkbox.rs b/src/base/component/form/checkbox.rs index cf382e27..568299d4 100644 --- a/src/base/component/form/checkbox.rs +++ b/src/base/component/form/checkbox.rs @@ -61,6 +61,7 @@ pub struct Checkbox { reverse: bool, } +#[async_trait] impl Component for Checkbox { fn new() -> Self { Self::default() @@ -95,7 +96,7 @@ impl Component for Checkbox { self.alter_prop(PropsOp::prepend_classes(classes)); } - fn prepare(&self, cx: &mut Context) -> Result { + async fn prepare(&self, cx: &mut Context) -> Result { // En `setup()` se garantiza que `name` e `id` están definidos antes del renderizado. let name = self.name().get().unwrap(); let container_id = self.id().unwrap(); diff --git a/src/base/component/form/component.rs b/src/base/component/form/component.rs index dc222ee8..7dfd72fe 100644 --- a/src/base/component/form/component.rs +++ b/src/base/component/form/component.rs @@ -57,6 +57,7 @@ pub struct Form { children: Children, } +#[async_trait] impl Component for Form { fn new() -> Self { Self::default() @@ -70,7 +71,7 @@ impl Component for Form { self.alter_prop(PropsOp::prepend_classes("form")); } - fn prepare(&self, cx: &mut Context) -> Result { + async fn prepare(&self, cx: &mut Context) -> Result { let method = match self.method() { form::Method::Post => Some("post"), form::Method::Get => None, @@ -82,7 +83,7 @@ impl Component for Form { method=[method] accept-charset=[self.charset().get()] { - (self.children().render(cx)) + (self.children().render(cx).await) } }) } diff --git a/src/base/component/form/fieldset.rs b/src/base/component/form/fieldset.rs index 1e420480..45a792fc 100644 --- a/src/base/component/form/fieldset.rs +++ b/src/base/component/form/fieldset.rs @@ -36,6 +36,7 @@ pub struct Fieldset { children: Children, } +#[async_trait] impl Component for Fieldset { fn new() -> Self { Self::default() @@ -45,8 +46,8 @@ impl Component for Fieldset { self.props.get_id() } - fn prepare(&self, cx: &mut Context) -> Result { - let children = self.children().render(cx); + async fn prepare(&self, cx: &mut Context) -> Result { + let children = self.children().render(cx).await; if children.is_empty() { return Ok(html! {}); diff --git a/src/base/component/form/hidden.rs b/src/base/component/form/hidden.rs index 5ad9e3ed..4f3ae6dc 100644 --- a/src/base/component/form/hidden.rs +++ b/src/base/component/form/hidden.rs @@ -35,12 +35,13 @@ pub struct Hidden { value: AttrValue, } +#[async_trait] impl Component for Hidden { fn new() -> Self { Self::default() } - fn prepare(&self, _cx: &mut Context) -> Result { + async fn prepare(&self, _cx: &mut Context) -> Result { Ok(html! { input type="hidden" diff --git a/src/base/component/form/input.rs b/src/base/component/form/input.rs index 7b57c648..33ebb9c3 100644 --- a/src/base/component/form/input.rs +++ b/src/base/component/form/input.rs @@ -157,6 +157,7 @@ pub struct Field { inputmode: Attr, } +#[async_trait] impl Component for Field { fn new() -> Self { Self::default() @@ -181,7 +182,7 @@ impl Component for Field { ))); } - fn prepare(&self, cx: &mut Context) -> Result { + async fn prepare(&self, cx: &mut Context) -> Result { let container_id = self.id(); let input_id = container_id.as_deref().map(|id| util::join!(id, "-input")); let input_class = if *self.plaintext() { diff --git a/src/base/component/form/radio.rs b/src/base/component/form/radio.rs index f06dd387..b045f112 100644 --- a/src/base/component/form/radio.rs +++ b/src/base/component/form/radio.rs @@ -112,6 +112,7 @@ pub struct Field { inline: bool, } +#[async_trait] impl Component for Field { fn new() -> Self { Self::default() @@ -136,7 +137,7 @@ impl Component for Field { self.alter_prop(PropsOp::prepend_classes("form-field form-field-radios")); } - fn prepare(&self, cx: &mut Context) -> Result { + async fn prepare(&self, cx: &mut Context) -> Result { // En `setup()` se garantiza que `name` e `id` están definidos antes del renderizado. let name = self.name().get().unwrap(); let container_id = self.id().unwrap(); diff --git a/src/base/component/form/range.rs b/src/base/component/form/range.rs index 386768d7..183e0a76 100644 --- a/src/base/component/form/range.rs +++ b/src/base/component/form/range.rs @@ -53,6 +53,7 @@ pub struct Range { disabled: bool, } +#[async_trait] impl Component for Range { fn new() -> Self { Self::default() @@ -74,7 +75,7 @@ impl Component for Range { self.alter_prop(PropsOp::prepend_classes("form-field form-field-range")); } - fn prepare(&self, cx: &mut Context) -> Result { + async fn prepare(&self, cx: &mut Context) -> Result { let container_id = self.id(); let range_id = container_id.as_deref().map(|id| util::join!(id, "-range")); Ok(html! { diff --git a/src/base/component/form/select.rs b/src/base/component/form/select.rs index fc94d06c..b9ebfc12 100644 --- a/src/base/component/form/select.rs +++ b/src/base/component/form/select.rs @@ -212,6 +212,7 @@ pub struct Field { disabled: bool, } +#[async_trait] impl Component for Field { fn new() -> Self { Self::default() @@ -233,7 +234,7 @@ impl Component for Field { self.alter_prop(PropsOp::prepend_classes("form-field form-field-select")); } - fn prepare(&self, cx: &mut Context) -> Result { + async fn prepare(&self, cx: &mut Context) -> Result { let container_id = self.id(); let select_id = container_id.as_deref().map(|id| util::join!(id, "-select")); diff --git a/src/base/component/form/textarea.rs b/src/base/component/form/textarea.rs index 5b4cc1ff..3b2b5fb5 100644 --- a/src/base/component/form/textarea.rs +++ b/src/base/component/form/textarea.rs @@ -63,6 +63,7 @@ pub struct Textarea { disabled: bool, } +#[async_trait] impl Component for Textarea { fn new() -> Self { Self::default() @@ -84,7 +85,7 @@ impl Component for Textarea { self.alter_prop(PropsOp::prepend_classes("form-field form-field-textarea")); } - fn prepare(&self, cx: &mut Context) -> Result { + async fn prepare(&self, cx: &mut Context) -> Result { let container_id = self.id(); let textarea_id = container_id .as_deref() diff --git a/src/base/component/html.rs b/src/base/component/html.rs index e0c9e519..9cbe8729 100644 --- a/src/base/component/html.rs +++ b/src/base/component/html.rs @@ -49,12 +49,13 @@ impl Default for Html { } } +#[async_trait] impl Component for Html { fn new() -> Self { Self::default() } - fn prepare(&self, cx: &mut Context) -> Result { + async fn prepare(&self, cx: &mut Context) -> Result { Ok(self.html(cx)) } } diff --git a/src/base/component/intro.rs b/src/base/component/intro.rs index 1ecd6318..f7589c26 100644 --- a/src/base/component/intro.rs +++ b/src/base/component/intro.rs @@ -107,12 +107,13 @@ impl Default for Intro { } } +#[async_trait] impl Component for Intro { fn new() -> Self { Self::default() } - fn prepare(&self, cx: &mut Context) -> Result { + async fn prepare(&self, cx: &mut Context) -> Result { cx.alter_assets(AssetsOp::AddStyleSheet( StyleSheet::from("/pagetop/css/intro.css").with_version(PAGETOP_VERSION), )); @@ -146,7 +147,7 @@ impl Component for Intro { } aside class="intro-header-img" aria-hidden="true" { div class="intro-header-mascot" { - (PageTopSvg::Color.render(cx)) + (PageTopSvg::Color.markup(cx)) } } } @@ -191,7 +192,7 @@ impl Component for Intro { (L10n::l("intro_text2").using(cx)) } } - (self.children().render(cx)) + (self.children().render(cx).await) } } } @@ -199,7 +200,7 @@ impl Component for Intro { div class="intro-footer" { section class="intro-footer-body" { div class="intro-footer-logo" { - (PageTopSvg::LineLight.render(cx)) + (PageTopSvg::LineLight.markup(cx)) } div class="intro-footer-links" { a href="https://crates.io/crates/pagetop" target="_blank" rel="noopener noreferrer" { ("Crates.io") } diff --git a/src/base/component/poweredby.rs b/src/base/component/poweredby.rs index 3e4792b8..41f93bd2 100644 --- a/src/base/component/poweredby.rs +++ b/src/base/component/poweredby.rs @@ -14,6 +14,7 @@ pub struct PoweredBy { copyright: Option, } +#[async_trait] impl Component for PoweredBy { /// Crea una nueva instancia de `PoweredBy`. /// @@ -25,7 +26,7 @@ impl Component for PoweredBy { PoweredBy { copyright: Some(c) } } - fn prepare(&self, cx: &mut Context) -> Result { + async fn prepare(&self, cx: &mut Context) -> Result { Ok(html! { div id=[self.id()] class="poweredby" { @if let Some(c) = self.copyright() { diff --git a/src/base/extension/welcome.rs b/src/base/extension/welcome.rs index 34040f40..e5262e82 100644 --- a/src/base/extension/welcome.rs +++ b/src/base/extension/welcome.rs @@ -11,6 +11,7 @@ use crate::prelude::*; /// Resulta útil en demos o para comprobar rápidamente que el servidor ha arrancado correctamente. pub struct Welcome; +#[async_trait] impl Extension for Welcome { fn name(&self) -> L10n { L10n::l("welcome_extension_name") @@ -62,4 +63,5 @@ async fn home(request: HttpRequest) -> Result { ), ) .render() + .await } diff --git a/src/base/theme/basic.rs b/src/base/theme/basic.rs index 3e6e99e9..26090da3 100644 --- a/src/base/theme/basic.rs +++ b/src/base/theme/basic.rs @@ -4,12 +4,14 @@ use crate::prelude::*; /// Tema básico por defecto que extiende el funcionamiento predeterminado de [`Theme`]. pub struct Basic; +#[async_trait] impl Extension for Basic { fn theme(&self) -> Option { Some(&Self) } } +#[async_trait] impl Theme for Basic { fn before_render_page_body(&self, page: &mut Page) { page.alter_assets(AssetsOp::AddStyleSheet( diff --git a/src/core/action.rs b/src/core/action.rs index 5a10662f..3b168e21 100644 --- a/src/core/action.rs +++ b/src/core/action.rs @@ -26,6 +26,7 @@ pub use all::{dispatch_actions, try_dispatch_actions}; /// Extensión que ajusta un botón antes de renderizarlo y transforma su HTML final: /// /// ```rust,ignore +/// #[async_trait] /// impl Extension for MyExtension { /// fn actions(&self) -> Vec { /// actions![ diff --git a/src/core/component.rs b/src/core/component.rs index 54a00b36..740d7d26 100644 --- a/src/core/component.rs +++ b/src/core/component.rs @@ -10,7 +10,6 @@ pub use definition::{Component, ComponentClone, ComponentRender}; mod children; pub use children::Children; -pub use children::ComponentGuard; pub use children::{Child, ChildOp, Embed}; mod message; @@ -34,6 +33,7 @@ pub use context::{AssetsOp, Context, ContextError, Contextual}; /// renderable: Option, /// } /// +/// #[async_trait] /// impl Component for SampleComponent { /// fn new() -> Self { /// Self::default() @@ -44,7 +44,7 @@ pub use context::{AssetsOp, Context, ContextError, Contextual}; /// self.renderable.map_or(true, |f| f(cx)) /// } /// -/// fn prepare(&self, _cx: &mut Context) -> Result { +/// async fn prepare(&self, _cx: &mut Context) -> Result { /// Ok(html! { "Visible component" }) /// } /// } diff --git a/src/core/component/children.rs b/src/core/component/children.rs index 480170f4..6d40352c 100644 --- a/src/core/component/children.rs +++ b/src/core/component/children.rs @@ -2,28 +2,23 @@ use crate::core::component::{Component, Context}; use crate::html::{Markup, html}; use crate::{AutoDefault, UniqueId, builder_fn}; -use parking_lot::Mutex; - -pub use parking_lot::MutexGuard as ComponentGuard; +use parking_lot::RwLock; use std::fmt; +use std::sync::Arc; use std::vec::IntoIter; -/// Representa un componente hijo encapsulado para su uso en una lista [`Children`]. -#[derive(AutoDefault)] -pub struct Child(Option>>); +// **< Child >************************************************************************************** -impl Clone for Child { - fn clone(&self) -> Self { - Child(self.0.as_ref().map(|m| Mutex::new(m.lock().clone_box()))) - } -} +/// Representa un componente hijo encapsulado para su uso en una lista [`Children`]. +#[derive(AutoDefault, Clone)] +pub struct Child(Option>>>); impl fmt::Debug for Child { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self.0 { None => write!(f, "Child(None)"), - Some(c) => write!(f, "Child({})", c.lock().name()), + Some(c) => write!(f, "Child({})", c.read().name()), } } } @@ -31,7 +26,7 @@ impl fmt::Debug for Child { impl Child { /// Crea un nuevo `Child` a partir de un componente. pub fn with(component: impl Component) -> Self { - Child(Some(Mutex::new(Box::new(component)))) + Child(Some(Arc::new(RwLock::new(Box::new(component))))) } // **< Child BUILDER >************************************************************************** @@ -41,7 +36,7 @@ impl Child { /// Si se proporciona `Some(component)`, se encapsula como [`Child`]; y si es `None`, se limpia. #[builder_fn] pub fn with_component(mut self, component: Option) -> Self { - self.0 = component.map(|c| Mutex::new(Box::new(c) as Box)); + self.0 = component.map(|c| Arc::new(RwLock::new(Box::new(c) as Box))); self } @@ -50,22 +45,28 @@ impl Child { /// Devuelve el identificador del componente, si existe y está definido. #[inline] pub fn id(&self) -> Option { - self.0.as_ref().and_then(|c| c.lock().id()) + self.0.as_ref().and_then(|c| c.read().id()) } // **< Child RENDER >*************************************************************************** /// Renderiza el componente con el contexto proporcionado. - pub fn render(&self, cx: &mut Context) -> Markup { - self.0.as_ref().map_or(html! {}, |c| c.lock().render(cx)) + pub async fn render(&self, cx: &mut Context) -> Markup { + match &self.0 { + None => html! {}, + Some(m) => { + let mut component = m.read().clone_box(); + component.render(cx).await + } + } } // **< Child HELPERS >************************************************************************** - /// Devuelve el [`UniqueId`] del tipo del componente, si existe. + // Devuelve el [`UniqueId`] del tipo del componente, si el Child no está vacío. #[inline] fn type_id(&self) -> Option { - self.0.as_ref().map(|c| c.lock().type_id()) + self.0.as_ref().map(|c| c.read().type_id()) } } @@ -80,12 +81,12 @@ impl From> for Child { /// children.with_child(my_embed.into()); /// ``` fn from(embed: Embed) -> Self { - if let Some(m) = embed.0 { - Child(Some(Mutex::new( - Box::new(m.into_inner()) as Box - ))) - } else { - Child(None) + match embed.0 { + None => Child(None), + Some(arc) => Child(Some(Arc::new(RwLock::new(match Arc::try_unwrap(arc) { + Ok(c) => Box::new(c) as Box, + Err(arc) => arc.clone_box(), + })))), } } } @@ -114,7 +115,7 @@ impl From for ChildOp { } } -// ************************************************************************************************* +// **< Embed >************************************************************************************** /// Contenedor tipado para un *único* componente de un tipo concreto conocido. /// @@ -125,11 +126,12 @@ impl From for ChildOp { /// Se usa habitualmente para incrustar un componente dentro de otro cuando no se necesita una lista /// completa de hijos ([`Children`]), sino un único componente tipado en un campo concreto. #[derive(AutoDefault)] -pub struct Embed(Option>); +pub struct Embed(Option>); -impl Clone for Embed { +// Arc: Clone no requiere C: Clone, pero #[derive(Clone)] añadiría ese bound innecesariamente. +impl Clone for Embed { fn clone(&self) -> Self { - Embed(self.0.as_ref().map(|m| Mutex::new(m.lock().clone()))) + Embed(self.0.clone()) } } @@ -137,7 +139,7 @@ impl fmt::Debug for Embed { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self.0 { None => write!(f, "Embed(None)"), - Some(c) => write!(f, "Embed({})", c.lock().name()), + Some(c) => write!(f, "Embed({})", c.name()), } } } @@ -145,7 +147,7 @@ impl fmt::Debug for Embed { impl Embed { /// Crea un nuevo `Embed` a partir de un componente. pub fn with(component: C) -> Self { - Embed(Some(Mutex::new(component))) + Embed(Some(Arc::new(component))) } // **< Embed BUILDER >************************************************************************** @@ -155,7 +157,7 @@ impl Embed { /// Si se proporciona `Some(component)`, se encapsula como [`Embed`]; y si es `None`, se limpia. #[builder_fn] pub fn with_component(mut self, component: Option) -> Self { - self.0 = component.map(Mutex::new); + self.0 = component.map(Arc::new); self } @@ -164,55 +166,69 @@ impl Embed { /// Devuelve el identificador del componente, si existe y está definido. #[inline] pub fn id(&self) -> Option { - self.0.as_ref().and_then(|c| c.lock().id()) + self.0.as_deref().and_then(|c| c.id()) } - /// Devuelve un acceso al componente incrustado. + /// Devuelve una referencia inmutable al componente incrustado, si existe. /// - /// - Devuelve `Some(ComponentGuard)` si existe el componente, o `None` si está vacío. - /// - El acceso es **exclusivo**: mientras el *guard* esté activo, no habrá otros accesos. - /// - Se recomienda mantener el *guard* **el menor tiempo posible** para evitar bloqueos - /// innecesarios. - /// - Para modificar el componente, declara el *guard* como `mut`: - /// `if let Some(mut c) = embed.get() { c.alter_title(...); }`. + /// Para acceso mutable, usa [`get_mut`](Embed::get_mut). /// /// # Ejemplo /// /// ```rust,no_run /// # use pagetop::prelude::*; /// let embed = Embed::with(Html::with(|_| html! { "Prueba" })); - /// { - /// if let Some(component) = embed.get() { - /// assert_eq!(component.name(), "Html"); - /// } - /// }; // El *guard* se libera aquí, antes del *drop* de `embed`. - /// - /// let embed = Embed::with(Block::new().with_title(L10n::n("Title"))); - /// { - /// if let Some(mut component) = embed.get() { - /// component.alter_title(L10n::n("New Title")); - /// } - /// }; // El *guard* se libera aquí, antes del *drop* de `embed`. + /// if let Some(component) = embed.get() { + /// assert_eq!(component.name(), "Html"); + /// } /// ``` - pub fn get(&self) -> Option> { - self.0.as_ref().map(|m| m.lock()) + pub fn get(&self) -> Option<&C> { + self.0.as_deref() + } + + /// Devuelve una referencia mutable al componente incrustado, si existe. + /// + /// Si el [`Arc`] interno es compartido (por ejemplo, justo después de clonar el componente + /// padre), aplica *copy-on-write*: clona `C` antes de devolver la referencia mutable. El + /// prototipo almacenado queda intacto. + /// + /// # Ejemplo + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// let mut embed = Embed::with(Block::new().with_title(L10n::n("Title"))); + /// if let Some(component) = embed.get_mut() { + /// component.alter_title(L10n::n("New Title")); + /// } + /// ``` + pub fn get_mut(&mut self) -> Option<&mut C> + where + C: Clone, + { + self.0.as_mut().map(Arc::make_mut) } // **< Embed RENDER >*************************************************************************** /// Renderiza el componente con el contexto proporcionado. - pub fn render(&self, cx: &mut Context) -> Markup { - self.0.as_ref().map_or(html! {}, |c| c.lock().render(cx)) + pub async fn render(&self, cx: &mut Context) -> Markup { + match &self.0 { + None => html! {}, + Some(m) => { + let mut component = m.clone_box(); + component.render(cx).await + } + } } } -// ************************************************************************************************* +// **< Children >*********************************************************************************** /// Operaciones para componentes hijo [`Child`] en una lista [`Children`]. pub enum ChildOp { /// Añade un hijo al final de la lista. Add(Child), - /// Añade un hijo solo si la lista está vacía. + /// Añade un hijo sólo si la lista está vacía. AddIfEmpty(Child), /// Añade varios hijos al final de la lista, en el orden recibido. AddMany(Vec), @@ -243,13 +259,10 @@ pub enum ChildOp { /// - [`Child`]: representa un componente hijo encapsulado dentro de la lista. Almacena cualquier /// componente sin necesidad de conocer su tipo concreto. /// - [`Embed`]: contenedor tipado para un *único* componente de tipo `C`. Preferible a -/// `Children` cuando el padre solo necesita un componente y quiere acceso directo a los métodos +/// `Children` cuando el padre sólo necesita un componente y quiere acceso directo a los métodos /// de `C`. /// - [`ChildOp`]: operaciones disponibles sobre la lista. Cuando se necesita algo más que añadir al /// final, se construye la variante adecuada y se pasa a [`with_child`](Self::with_child). -/// - [`ComponentGuard`]: devuelto por [`Embed::get`] para garantizar acceso exclusivo al componente -/// tipado. Mientras está activo bloquea cualquier otro acceso por lo que conviene liberarlo -/// cuanto antes. /// /// # Conversiones implícitas /// @@ -297,14 +310,14 @@ impl Children { } } - /// Añade un componente hijo al final de la lista. + // Añade un componente hijo al final de la lista. #[inline] pub(crate) fn add(&mut self, child: Child) -> &mut Self { self.0.push(child); self } - /// Añade un componente hijo en la lista sólo si está vacía. + // Añade un componente hijo en la lista sólo si está vacía. #[inline] pub(crate) fn add_if_empty(&mut self, child: Child) -> &mut Self { if self.0.is_empty() { @@ -345,17 +358,17 @@ impl Children { // **< Children RENDER >************************************************************************ /// Renderiza todos los componentes hijo, en orden. - pub fn render(&self, cx: &mut Context) -> Markup { + pub async fn render(&self, cx: &mut Context) -> Markup { html! { @for c in &self.0 { - (c.render(cx)) + (c.render(cx).await) } } } // **< Children HELPERS >*********************************************************************** - /// Añade más de un componente hijo al final de la lista (en el orden recibido). + // Añade más de un componente hijo al final de la lista (en el orden recibido). #[inline] fn add_many(&mut self, iter: I) -> &mut Self where @@ -365,7 +378,7 @@ impl Children { self } - /// Inserta un hijo después del componente con el `id` dado, o al final si no se encuentra. + // Inserta un hijo después del componente con el `id` dado, o al final si no se encuentra. #[inline] fn insert_after_id(&mut self, id: impl AsRef, child: Child) -> &mut Self { let id = Some(id.as_ref()); @@ -376,7 +389,7 @@ impl Children { self } - /// Inserta un hijo antes del componente con el `id` dado, o al principio si no se encuentra. + // Inserta un hijo antes del componente con el `id` dado, o al principio si no se encuentra. #[inline] fn insert_before_id(&mut self, id: impl AsRef, child: Child) -> &mut Self { let id = Some(id.as_ref()); @@ -387,14 +400,14 @@ impl Children { self } - /// Inserta un hijo al principio de la lista. + // Inserta un hijo al principio de la lista. #[inline] fn prepend(&mut self, child: Child) -> &mut Self { self.0.insert(0, child); self } - /// Inserta más de un componente hijo al principio de la lista (manteniendo el orden recibido). + // Inserta más de un componente hijo al principio de la lista (manteniendo el orden recibido). #[inline] fn prepend_many(&mut self, iter: I) -> &mut Self where @@ -405,7 +418,7 @@ impl Children { self } - /// Elimina el primer hijo con el `id` dado. + // Elimina el primer hijo con el `id` dado. #[inline] fn remove_by_id(&mut self, id: impl AsRef) -> &mut Self { let id = Some(id.as_ref()); @@ -415,7 +428,7 @@ impl Children { self } - /// Sustituye el primer hijo con el `id` dado por otro componente. + // Sustituye el primer hijo con el `id` dado por otro componente. #[inline] fn replace_by_id(&mut self, id: impl AsRef, child: Child) -> &mut Self { let id = Some(id.as_ref()); @@ -428,7 +441,7 @@ impl Children { self } - /// Elimina todos los componentes hijo de la lista. + // Elimina todos los componentes hijo de la lista. #[inline] fn reset(&mut self) -> &mut Self { self.0.clear(); @@ -445,7 +458,7 @@ impl IntoIterator for Children { /// # Ejemplo /// /// ```rust,ignore - /// let children = Children::new().with(child1).with(child2); + /// let children = Children::new().with_child(child1).with_child(child2); /// for child in children { /// println!("{:?}", child.id()); /// } @@ -464,7 +477,7 @@ impl<'a> IntoIterator for &'a Children { /// # Ejemplo /// /// ```rust,ignore - /// let children = Children::new().with(child1).with(child2); + /// let children = Children::new().with_child(child1).with_child(child2); /// for child in &children { /// println!("{:?}", child.id()); /// } @@ -483,9 +496,9 @@ impl<'a> IntoIterator for &'a mut Children { /// # Ejemplo /// /// ```rust,ignore - /// let mut children = Children::new().with(child1).with(child2); + /// let mut children = Children::new().with_child(child1).with_child(child2); /// for child in &mut children { - /// child.render(&mut context); + /// child.render(&mut context).await; /// } /// ``` fn into_iter(self) -> Self::IntoIter { diff --git a/src/core/component/context.rs b/src/core/component/context.rs index ad98810b..78bc66e6 100644 --- a/src/core/component/context.rs +++ b/src/core/component/context.rs @@ -10,8 +10,9 @@ 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::cell::RefCell; use std::collections::HashMap; use std::fmt; @@ -132,7 +133,7 @@ pub trait Contextual: LangId { /// .with_param("flags", vec!["a", "b"]); /// ``` #[builder_fn] - fn with_param(self, key: &'static str, value: T) -> Self; + fn with_param(self, key: &'static str, value: T) -> Self; /// Define los recursos del contexto usando [`AssetsOp`]. #[builder_fn] @@ -172,7 +173,7 @@ pub trait Contextual: LangId { /// if page.current_user().is_authenticated() { /// // Personalizar la página para el usuario autenticado. /// } - /// page.render() + /// page.render().await /// } /// ``` fn current_user(&self) -> &CurrentUser; @@ -305,19 +306,19 @@ pub trait Contextual: LangId { /// ``` #[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. - stylesheets : Assets, // Hojas de estilo CSS. - javascripts : Assets, // Scripts JavaScript. - body_props : Props, // Identificador, clases CSS y atributos del . - regions : ChildrenInRegions, // Regiones de componentes para renderizar. - params : HashMap<&'static str, (Box, &'static str)>, // Parámetros en ejecución. - id_counters : RefCell>, // RefCell permite mutar desde build_id(&self). - messages : Vec, // Mensajes de usuario acumulados. + 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. + 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 { @@ -347,7 +348,7 @@ impl Context { body_props : Props::default(), regions : ChildrenInRegions::default(), params : HashMap::default(), - id_counters: RefCell::new(HashMap::new()), + id_counters: Mutex::new(HashMap::new()), messages : Vec::new(), } } @@ -390,10 +391,11 @@ impl Context { } /// Renderiza los componentes de una región. - pub fn render_region(&mut self, region_ref: RegionRef) -> Markup { + pub async fn render_region_named(&mut self, region_name: &str) -> Markup { self.regions - .children_for(self.theme, region_ref) + .assemble_region(self.theme, region_name) .render(self) + .await } // **< Context HELPERS >************************************************************************ @@ -436,7 +438,7 @@ impl Context { segments }; let count = { - let mut map = self.id_counters.borrow_mut(); + let mut map = self.id_counters.lock(); let n = map.entry(TypeId::of::()).or_insert(0); *n += 1; *n @@ -529,7 +531,7 @@ impl Contextual for Context { } #[builder_fn] - fn with_param(mut self, key: &'static str, value: T) -> Self { + 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 diff --git a/src/core/component/definition.rs b/src/core/component/definition.rs index 0970c58b..ae2340c6 100644 --- a/src/core/component/definition.rs +++ b/src/core/component/definition.rs @@ -1,3 +1,4 @@ +use crate::async_trait; use crate::base::action; use crate::core::component::{ComponentError, Context, Contextual}; use crate::core::theme::ThemeRef; @@ -7,8 +8,9 @@ use crate::html::{Markup, html}; /// Habilita el clonado de componentes. /// /// Se implementa automáticamente para todo tipo que implemente [`Component`] y [`Clone`]. El método -/// [`clone_box`](Self::clone_box) devuelve una copia en la *pila* del componente original, lo que -/// permite clonar componentes sin conocer su tipo concreto en tiempo de compilación. +/// [`clone_box`](Self::clone_box) devuelve un clon del componente original encapsulado en un +/// `Box`, lo que permite clonar componentes sin conocer su tipo concreto en tiempo +/// de compilación. pub trait ComponentClone { /// Devuelve un clon del componente encapsulado en un [`Box`]. fn clone_box(&self) -> Box; @@ -18,9 +20,10 @@ pub trait ComponentClone { /// /// Este *trait* se implementa automáticamente en cualquier tipo (componente) que implemente /// [`Component`], por lo que no requiere ninguna codificación manual. +#[async_trait] pub trait ComponentRender { /// Renderiza el componente usando el contexto proporcionado. - fn render(&mut self, cx: &mut Context) -> Markup; + async fn render(&mut self, cx: &mut Context) -> Markup; } /// Interfaz común que debe implementar un componente renderizable en PageTop. @@ -35,9 +38,10 @@ pub trait ComponentRender { /// /// Todo tipo que implemente `Component` **debe** derivar también [`Clone`]. Aunque el compilador /// no lo exige directamente (hacerlo rompería la seguridad de objeto de `dyn Component`), -/// [`ComponentClone`] se implementa automáticamente mediante una *impl* blanket solo para los tipos +/// [`ComponentClone`] se implementa automáticamente mediante una *impl blanket* sólo para los tipos /// que sean `Component + Clone + 'static`. Sin `Clone`, habría que implementar [`ComponentClone`] a /// mano, y el componente no podría registrarse en [`InRegion`](crate::core::theme::InRegion). +#[async_trait] pub trait Component: AnyInfo + ComponentClone + ComponentRender + Send + Sync { /// Crea una nueva instancia del componente. /// @@ -72,12 +76,12 @@ pub trait Component: AnyInfo + ComponentClone + ComponentRender + Send + Sync { /// /// 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. Recibe solo una referencia + /// renderizan o no en función del contexto de renderizado. Recibe sólo una referencia /// compartida al contexto porque su único propósito es consultar datos, no modificarlos. /// - /// También puede asignarse una función [`FnIsRenderable`](super::FnIsRenderable) a un campo del - /// componente para permitir que instancias concretas del mismo puedan decidir dinámicamente si - /// se renderizan o no. + /// También **puede asignarse una función [`FnIsRenderable`](super::FnIsRenderable) a un campo + /// del componente** para permitir que instancias concretas del mismo puedan decidir + /// dinámicamente si se renderizan o no. #[allow(unused_variables)] fn is_renderable(&self, cx: &Context) -> bool { true @@ -88,8 +92,19 @@ pub trait Component: AnyInfo + ComponentClone + ComponentRender + Send + Sync { /// Segundo paso del [ciclo de renderizado](ComponentRender): se ejecuta tras comprobar /// [`is_renderable()`](Self::is_renderable) y antes de la acción /// [`BeforeRender`](crate::base::action::component::BeforeRender) y de - /// [`prepare()`](Self::prepare). Recibe solo una referencia compartida al contexto porque su + /// [`prepare()`](Self::prepare). Recibe sólo una referencia compartida al contexto porque su /// propósito es mutar el propio componente, no el contexto. Por defecto no hace nada. + /// + /// Está pensado para **normalizar el estado interno** del componente antes de renderizarlo. Por + /// ejemplo, calcular clases CSS, ajustar valores de campos, derivar atributos a partir del + /// contexto, etc. Se desaconseja utilizar para operaciones de E/S o consultas a base de datos; + /// es intencionadamente síncrono. + /// + /// La carga y el acceso a datos en general corresponden a [`prepare()`](Self::prepare), que es + /// `async` precisamente para ello. + /// + /// La separación entre `setup()` (mutación de estado) y [`prepare()`](Self::prepare) + /// (generación de HTML) es deliberada y no debe fusionarse. #[allow(unused_variables)] fn setup(&mut self, cx: &Context) {} @@ -97,8 +112,8 @@ pub trait Component: AnyInfo + ComponentClone + ComponentRender + Send + Sync { /// /// Cuarto paso del [ciclo de renderizado](ComponentRender): se invoca tras /// [`setup()`](Self::setup) y la acción - /// [`BeforeRender`](crate::base::action::component::BeforeRender), pero solo si ningún tema - /// en la cadena devuelve `Some` en + /// [`BeforeRender`](crate::base::action::component::BeforeRender), pero solamente si ningún + /// tema en la cadena devuelve `Some` en /// [`Theme::handle_component()`](crate::core::theme::Theme::handle_component). /// /// Se recomienda obtener los datos del componente a través de sus propios métodos para que los @@ -107,7 +122,7 @@ pub trait Component: AnyInfo + ComponentClone + ComponentRender + Send + Sync { /// Por defecto, devuelve un [`Markup`] vacío (`Ok(html! {})`). En caso de error, devuelve un /// [`ComponentError`] que puede incluir un marcado alternativo (*fallback*). #[allow(unused_variables)] - fn prepare(&self, cx: &mut Context) -> Result { + async fn prepare(&self, cx: &mut Context) -> Result { Ok(html! {}) } } @@ -143,8 +158,9 @@ impl ComponentClone for T { /// para que las extensiones puedan trabajar sobre el HTML final para modificarlo antes de /// devolverlo. /// 7. Devuelve el [`Markup`] resultante. +#[async_trait] impl ComponentRender for C { - fn render(&mut self, cx: &mut Context) -> Markup { + async fn render(&mut self, cx: &mut Context) -> Markup { // Si no es renderizable, devuelve un bloque HTML vacío. if !self.is_renderable(cx) { return html! {}; @@ -160,12 +176,12 @@ impl ComponentRender for C { let result = 'resolve: { let mut t: Option = Some(cx.theme()); while let Some(theme) = t { - if let Some(r) = theme.handle_component(self, cx) { + if let Some(r) = theme.handle_component(self, cx).await { break 'resolve r; } t = theme.parent(); } - self.prepare(cx) + self.prepare(cx).await }; let prepare = match result { Ok(markup) => markup, diff --git a/src/core/component/error.rs b/src/core/component/error.rs index 9fa34d29..5b548c62 100644 --- a/src/core/component/error.rs +++ b/src/core/component/error.rs @@ -13,9 +13,10 @@ use crate::{AutoDefault, Getters}; /// # use pagetop::prelude::*; /// # #[derive(Clone)] /// # struct MyComponent; +/// # #[async_trait] /// # impl Component for MyComponent { /// # fn new() -> Self { MyComponent } -/// fn prepare(&self, _cx: &mut Context) -> Result { +/// async fn prepare(&self, _cx: &mut Context) -> Result { /// Err(ComponentError::new("Database connection failed") /// .with_fallback(html! { p class="error" { "Content temporarily unavailable." } })) /// } diff --git a/src/core/extension/definition.rs b/src/core/extension/definition.rs index d4025d0a..4bbf9c4c 100644 --- a/src/core/extension/definition.rs +++ b/src/core/extension/definition.rs @@ -15,6 +15,7 @@ use crate::web::Router; /// # use pagetop::prelude::*; /// pub struct MyExtension; /// +/// #[async_trait] /// impl Extension for MyExtension { /// fn name(&self) -> L10n { /// L10n::n("My Extension") @@ -54,12 +55,14 @@ pub trait Extension: AnyInfo + Send + Sync { /// # use pagetop::prelude::*; /// pub struct MyTheme; /// + /// #[async_trait] /// impl Extension for MyTheme { /// fn theme(&self) -> Option { /// Some(&Self) /// } /// } /// + /// #[async_trait] /// impl Theme for MyTheme {} /// ``` fn theme(&self) -> Option { @@ -81,9 +84,11 @@ pub trait Extension: AnyInfo + Send + Sync { /// ```rust,no_run /// # use pagetop::prelude::*; /// # pub struct Database; + /// # #[async_trait] /// # impl Extension for Database {} /// pub struct MyApp; /// + /// #[async_trait] /// impl Extension for MyApp { /// fn dependencies(&self) -> Vec { /// vec![&Database] @@ -116,8 +121,6 @@ pub trait Extension: AnyInfo + Send + Sync { /// lógica de inicialización**. PageTop lo invoca una sola vez, después de que todas las /// dependencias se han inicializado y antes de aceptar cualquier petición HTTP. /// - /// En ese caso, el bloque `impl Extension` debe llevar `#[async_trait]`: - /// /// ```rust,no_run /// use pagetop::prelude::*; /// @@ -130,8 +133,6 @@ pub trait Extension: AnyInfo + Send + Sync { /// } /// } /// ``` - /// - /// Las extensiones que no sobrescriben `initialize()` no necesitan `#[async_trait]`. async fn initialize(&self) {} /// Registra rutas, servicios y capas de la extensión en el servidor web de la aplicación. @@ -160,6 +161,7 @@ pub trait Extension: AnyInfo + Send + Sync { /// # async fn create_post() -> &'static str { "" } /// pub struct Blog; /// + /// #[async_trait] /// impl Extension for Blog { /// fn configure_router(&self, router: Router) -> Router { /// router @@ -178,6 +180,7 @@ pub trait Extension: AnyInfo + Send + Sync { /// # async fn list_users() -> &'static str { "" } /// pub struct Admin; /// + /// #[async_trait] /// impl Extension for Admin { /// fn configure_router(&self, router: Router) -> Router { /// let admin = Router::new() @@ -204,6 +207,7 @@ pub trait Extension: AnyInfo + Send + Sync { /// # use pagetop::prelude::*; /// pub struct Api; /// + /// #[async_trait] /// impl Extension for Api { /// fn configure_router(&self, router: Router) -> Router { /// let api = Router::new() @@ -226,6 +230,7 @@ pub trait Extension: AnyInfo + Send + Sync { /// # use pagetop::prelude::*; /// pub struct MyExtension; /// + /// #[async_trait] /// impl Extension for MyExtension { /// fn configure_router(&self, router: Router) -> Router { /// serve_static_files!(router, [assets] => "/static"); @@ -257,6 +262,7 @@ pub trait Extension: AnyInfo + Send + Sync { /// # ) -> axum::response::Response { next.run(req).await } /// pub struct MyAuth; /// + /// #[async_trait] /// impl Extension for MyAuth { /// fn configure_middleware(&self, router: Router) -> Router { /// router.layer(middleware::from_fn(session_middleware)) diff --git a/src/core/theme.rs b/src/core/theme.rs index 9f358c41..46b5d086 100644 --- a/src/core/theme.rs +++ b/src/core/theme.rs @@ -27,6 +27,7 @@ //! Los temas pueden definir sus propias implementaciones de [`Template`] y [`Region`] (por ejemplo, //! mediante *enums* adicionales) para añadir nuevas plantillas o exponer regiones específicas. +use crate::async_trait; use crate::core::component::Context; use crate::html::{Markup, html}; use crate::locale::L10n; @@ -49,7 +50,8 @@ use crate::{AutoDefault, util}; /// /// El tema decide qué regiones mostrar en el cuerpo del documento, normalmente usando una plantilla /// ([`Template`]) al renderizar la página ([`Page`](crate::response::page::Page)). -pub trait Region { +#[async_trait] +pub trait Region: Send + Sync { /// Devuelve el nombre de la región. /// /// Este nombre es el identificador lógico de la región y se usa como clave en el [`Context`] @@ -81,12 +83,9 @@ pub trait Region { /// /// Se puede sobrescribir este método para modificar la estructura del contenedor, las clases /// utilizadas o la semántica del marcado generado para cada región. - fn render(&'static self, cx: &mut Context) -> Markup - where - Self: Sized, - { + async fn render(&self, cx: &mut Context) -> Markup { html! { - @let region = cx.render_region(self); + @let region = cx.render_region_named(self.name()).await; @if !region.is_empty() { div class=(util::join!("region region-", self.name())) @@ -157,7 +156,8 @@ impl Region for DefaultRegion { /// Una `Template` puede proporcionar una o más variantes para decidir la composición del `` /// de una página ([`Page`](crate::response::page::Page)). El tema utiliza esta información para /// determinar qué regiones ([`Region`]) deben renderizarse y en qué orden. -pub trait Template { +#[async_trait] +pub trait Template: Send + Sync { /// Renderiza el contenido de la plantilla. /// /// Por defecto, renderiza las regiones básicas de [`DefaultRegion`] en este orden: @@ -173,11 +173,11 @@ pub trait Template { /// Este método se invoca normalmente desde [`Theme::render_page_body()`] para generar el /// contenido del `` de una página según la plantilla devuelta por el contexto de la /// propia página ([`Contextual::template()`](crate::core::component::Contextual::template())). - fn render(&'static self, cx: &mut Context) -> Markup { + async fn render(&self, cx: &mut Context) -> Markup { html! { - (DefaultRegion::Header.render(cx)) - (DefaultRegion::Content.render(cx)) - (DefaultRegion::Footer.render(cx)) + (DefaultRegion::Header.render(cx).await) + (DefaultRegion::Content.render(cx).await) + (DefaultRegion::Footer.render(cx).await) } } } @@ -204,6 +204,7 @@ pub enum DefaultTemplate { Error, } +#[async_trait] impl Template for DefaultTemplate {} // **< render_component! >************************************************************************** diff --git a/src/core/theme/definition.rs b/src/core/theme/definition.rs index 0e9e85e6..720d128c 100644 --- a/src/core/theme/definition.rs +++ b/src/core/theme/definition.rs @@ -1,3 +1,4 @@ +use crate::async_trait; use crate::base::component::{Html, Intro, IntroOpening}; use crate::core::component::{ChildOp, Component, ComponentError, Context, Contextual}; use crate::core::extension::Extension; @@ -28,6 +29,7 @@ use crate::web::http::StatusCode; /// # use pagetop::prelude::*; /// pub struct MyTheme; /// +/// #[async_trait] /// impl Extension for MyTheme { /// fn name(&self) -> L10n { /// L10n::n("My theme") @@ -42,8 +44,10 @@ use crate::web::http::StatusCode; /// } /// } /// +/// #[async_trait] /// impl Theme for MyTheme {} /// ``` +#[async_trait] pub trait Theme: Extension + Send + Sync { /// Devuelve el tema padre del que hereda este tema, si existe. /// @@ -108,11 +112,11 @@ pub trait Theme: Extension + Send + Sync { /// - Envolver el contenido en contenedores adicionales. /// - Implementar lógicas de composición alternativas. #[inline] - fn render_page_body(&self, page: &mut Page) -> Markup { + async fn render_page_body(&self, page: &mut Page) -> Markup { if let Some(parent) = self.parent() { - parent.render_page_body(page) + parent.render_page_body(page).await } else { - page.template().render(page.context()) + page.template().render(page.context()).await } } @@ -152,9 +156,9 @@ pub trait Theme: Extension + Send + Sync { /// /// Los temas pueden sobrescribir este método para añadir etiquetas adicionales (por ejemplo, /// *favicons* personalizados, manifest, etiquetas de analítica, etc.). - fn render_page_head(&self, page: &mut Page) -> Markup { + async fn render_page_head(&self, page: &mut Page) -> Markup { if let Some(parent) = self.parent() { - return parent.render_page_head(page); + return parent.render_page_head(page).await; } let viewport = "width=device-width, initial-scale=1, shrink-to-fit=no"; html! { @@ -222,7 +226,7 @@ pub trait Theme: Extension + Send + Sync { /// } /// ``` #[allow(unused_variables)] - fn handle_component( + async fn handle_component( &self, component: &mut dyn Component, cx: &mut Context, diff --git a/src/core/theme/regions.rs b/src/core/theme/regions.rs index bbfce795..8ac9ce55 100644 --- a/src/core/theme/regions.rs +++ b/src/core/theme/regions.rs @@ -61,39 +61,37 @@ impl ChildrenInRegions { self } - /// Construye una lista de componentes frescos para la región indicada. + /// Ensambla los componentes frescos de la región indicada. /// - /// El orden es: prototipos globales comunes → children propios de la página → - /// prototipos específicos del tema activo. + /// Se recogen desde tres fuentes disponibles, en el siguiente orden: /// - /// Los prototipos globales se clonan en cada llamada (clon profundo gracias a - /// [`ComponentClone`]), garantizando que `setup()` siempre parte del estado - /// inicial. Los children propios de la página se mueven (son por petición y no necesitan - /// clonarse). - /// - /// [`ComponentClone`]: crate::core::component::ComponentClone - pub fn children_for(&mut self, theme_ref: ThemeRef, region_ref: RegionRef) -> Children { - let name = region_ref.name(); + /// 1. Prototipos globales comunes, disponibles en cualquier tema. Se clonan en cada petición + /// para que `setup()` parta siempre de un estado inicial limpio. + /// 2. Componentes propios de la página, registrados para esta petición concreta. Se mueven en + /// lugar de clonarse, ya que son de un único uso. + /// 3. Prototipos del tema activo, exclusivos del tema en curso. También se clonan para asegurar + /// que llegan a `setup()` con el mismo estado inicial. + pub fn assemble_region(&mut self, theme_ref: ThemeRef, region_name: &str) -> Children { let common = COMMON_REGIONS.read(); let themed = THEME_REGIONS.read(); let mut result = Children::new(); - // 1. Prototipos globales comunes - clon fresco por cada página. - if let Some(protos) = common.get(name) { + // 1. Prototipos globales comunes. + if let Some(protos) = common.get(region_name) { for proto in protos { result.add(proto.as_child()); } } - // 2. Children propios de la página - se mueven (son por petición, no requieren clonado). - if let Some(page_children) = self.0.remove(name) { + // 2. Componentes propios de la página: se mueven, no se clonan. + if let Some(page_children) = self.0.remove(region_name) { for child in page_children { result.add(child); } } - // 3. Prototipos del tema activo - clon fresco por cada página. + // 3. Prototipos del tema activo. if let Some(theme_map) = themed.get(&theme_ref.type_id()) { - if let Some(protos) = theme_map.get(name) { + if let Some(protos) = theme_map.get(region_name) { for proto in protos { result.add(proto.as_child()); } diff --git a/src/html/logo.rs b/src/html/logo.rs index 2e6711e5..19bf4357 100644 --- a/src/html/logo.rs +++ b/src/html/logo.rs @@ -12,16 +12,16 @@ use crate::locale::L10n; /// fn render_logo(cx: &mut Context) -> Markup { /// html! { /// div class="logo_color" { -/// (PageTopSvg::Color.render(cx)) +/// (PageTopSvg::Color.markup(cx)) /// } /// div class="line_dark" { -/// (PageTopSvg::LineDark.render(cx)) +/// (PageTopSvg::LineDark.markup(cx)) /// } /// div class="line_light" { -/// (PageTopSvg::LineLight.render(cx)) +/// (PageTopSvg::LineLight.markup(cx)) /// } /// div class="line_red" { -/// (PageTopSvg::LineRGB(255, 0, 0).render(cx)) +/// (PageTopSvg::LineRGB(255, 0, 0).markup(cx)) /// } /// } /// }; @@ -41,8 +41,8 @@ pub enum PageTopSvg { } impl PageTopSvg { - /// Renderiza el SVG del logotipo según la variante elegida. - pub fn render(&self, cx: &Context) -> Markup { + /// Devuelve el marcado SVG del logotipo según la variante elegida. + pub fn markup(&self, cx: &Context) -> Markup { let path_fills = match self { Self::Color => self.logo_color(), Self::LineDark => self.logo_line(10, 11, 9), diff --git a/src/html/props.rs b/src/html/props.rs index 223924af..b11347b6 100644 --- a/src/html/props.rs +++ b/src/html/props.rs @@ -162,10 +162,11 @@ impl PropsOp { /// props: Props, /// } /// +/// #[async_trait] /// impl Component for MyButton { /// fn new() -> Self { Self::default() } /// -/// fn prepare(&self, cx: &mut Context) -> Result { +/// async fn prepare(&self, cx: &mut Context) -> Result { /// Ok(html! { /// button (self.props()) { /// (self.label().using(cx)) diff --git a/src/lib.rs b/src/lib.rs index c069150a..6b63b33f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -52,6 +52,7 @@ use pagetop::prelude::*; struct HelloWorld; +#[async_trait] impl Extension for HelloWorld { fn configure_router(&self, router: Router) -> Router { router.route("/", web::get(hello_world)) @@ -61,7 +62,7 @@ impl Extension for HelloWorld { async fn hello_world(request: HttpRequest) -> Result { Page::new(request) .with_child(Html::with(|_| html! { h1 { "Hello World!" } })) - .render() + .render().await } #[pagetop::main] @@ -97,12 +98,14 @@ use std::ops::Deref; /// /// pub struct MyTheme; /// +/// #[async_trait] /// impl Extension for MyTheme { /// fn theme(&self) -> Option { /// Some(&Self) /// } /// } /// +/// #[async_trait] /// impl Theme for MyTheme { /// fn before_render_page_body(&self, page: &mut Page) { /// page diff --git a/src/response/page.rs b/src/response/page.rs index d6c0f42c..8aeb7605 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -15,6 +15,7 @@ mod error; pub use error::ErrorPage; +pub(crate) use error::render_error_pages; use crate::auth::CurrentUser; use crate::base::action; @@ -200,7 +201,7 @@ impl Page { /// `lang` y `dir` en la etiqueta ``. /// 8. Compone el documento HTML completo (``, ``, ``, ``) y /// devuelve un [`Result`] con el [`Markup`] final. - pub fn render(&mut self) -> Result { + pub async fn render(&mut self) -> Result { // Acciones específicas del tema antes de renderizar el . self.context.theme().before_render_page_body(self); @@ -209,9 +210,9 @@ impl Page { // Renderiza el . let body = html! { - (ReservedRegion::PageTop.render(&mut self.context)) - (self.context.theme().render_page_body(self)) - (ReservedRegion::PageBottom.render(&mut self.context)) + (ReservedRegion::PageTop.render(&mut self.context).await) + (self.context.theme().render_page_body(self).await) + (ReservedRegion::PageBottom.render(&mut self.context).await) }; // Acciones específicas del tema después de renderizar el . @@ -221,7 +222,7 @@ impl Page { action::page::AfterRenderBody::dispatch(self); // Renderiza el . - let head = self.context.theme().render_page_head(self); + let head = self.context.theme().render_page_head(self).await; // Compone la página incluyendo los atributos de idioma y dirección del texto. let lang = &self.context.langid().language; @@ -283,7 +284,7 @@ impl Contextual for Page { } #[builder_fn] - fn with_param(mut self, key: &'static str, value: T) -> Self { + fn with_param(mut self, key: &'static str, value: T) -> Self { self.context.alter_param(key, value); self } diff --git a/src/response/page/error.rs b/src/response/page/error.rs index 2c92d02e..62b3840c 100644 --- a/src/response/page/error.rs +++ b/src/response/page/error.rs @@ -1,3 +1,6 @@ +use axum::extract::Request; +use axum::middleware::Next; + use crate::core::component::Contextual; use crate::locale::L10n; use crate::util; @@ -5,20 +8,18 @@ use crate::web::{HttpRequest, IntoResponse, Response, http}; use super::Page; -use std::fmt; - /// Página de error asociada a un código de estado HTTP. /// /// Este enumerado agrupa tipos esenciales de error que pueden devolverse como página HTML completa. /// Cada variante encapsula la solicitud original ([`HttpRequest`]) y se corresponde con un código /// de estado concreto. /// -/// Para cada error se construye una [`Page`] usando el tema activo, lo que permite personalizar -/// la plantilla y el contenido del mensaje mediante los métodos específicos del tema -/// (por ejemplo, [`Theme::error_403()`](crate::core::theme::Theme::error_403), +/// Para cada error se construye una [`Page`] usando el tema activo, lo que permite personalizar la +/// plantilla y el contenido del mensaje mediante los métodos específicos del tema (por ejemplo, +/// [`Theme::error_403()`](crate::core::theme::Theme::error_403), /// [`Theme::error_404()`](crate::core::theme::Theme::error_404) o /// [`Theme::error_fatal()`](crate::core::theme::Theme::error_fatal)). -#[derive(Debug)] +#[derive(Clone, Debug)] pub enum ErrorPage { BadRequest(HttpRequest), AccessDenied(HttpRequest), @@ -29,26 +30,6 @@ pub enum ErrorPage { } impl ErrorPage { - // Renderiza una página de error genérica usando el tema activo. Deriva las claves de - // localización del código de estado (`error_title`, `_alert`, `_help`). Si el - // renderizado falla, escribe el texto plano del código de estado. - fn display_error_page(&self, f: &mut fmt::Formatter<'_>, request: &HttpRequest) -> fmt::Result { - let mut page = Page::new(request.clone()); - let code = self.status_code(); - page.theme().error_fatal( - &mut page, - code, - L10n::l(util::join!("error", code.as_str(), "_title")), - L10n::l(util::join!("error", code.as_str(), "_alert")), - L10n::l(util::join!("error", code.as_str(), "_help")), - ); - if let Ok(rendered) = page.render() { - write!(f, "{}", rendered.into_string()) - } else { - f.write_str(code.as_str()) - } - } - /// Devuelve el código de estado HTTP asociado a la variante de error. pub fn status_code(&self) -> http::StatusCode { match self { @@ -60,59 +41,68 @@ impl ErrorPage { ErrorPage::GatewayTimeout(_) => http::StatusCode::GATEWAY_TIMEOUT, } } -} -impl fmt::Display for ErrorPage { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - // Error 400. - Self::BadRequest(request) => self.display_error_page(f, request), - - // Error 403. + // Renderiza la página de error y construye la respuesta HTTP completa con el HTML generado. Si + // el renderizado falla, devuelve sólo el código de estado sin cuerpo. + async fn render_html(self) -> Response { + let status = self.status_code(); + let mut page = match self { Self::AccessDenied(request) => { - let mut page = Page::new(request.clone()); + let mut page = Page::new(request); page.theme().error_403(&mut page); - if let Ok(rendered) = page.render() { - write!(f, "{}", rendered.into_string()) - } else { - f.write_str(self.status_code().as_str()) - } + page } - - // Error 404. Self::NotFound(request) => { - let mut page = Page::new(request.clone()); + let mut page = Page::new(request); page.theme().error_404(&mut page); - if let Ok(rendered) = page.render() { - write!(f, "{}", rendered.into_string()) - } else { - f.write_str(self.status_code().as_str()) - } + page } - - // Error 500. - Self::InternalError(request) => self.display_error_page(f, request), - - // Error 503. - Self::ServiceUnavailable(request) => self.display_error_page(f, request), - - // Error 504. - Self::GatewayTimeout(request) => self.display_error_page(f, request), + Self::BadRequest(request) + | Self::InternalError(request) + | Self::ServiceUnavailable(request) + | Self::GatewayTimeout(request) => { + let mut page = Page::new(request); + page.theme().error_fatal( + &mut page, + status, + L10n::l(util::join!("error", status.as_str(), "_title")), + L10n::l(util::join!("error", status.as_str(), "_alert")), + L10n::l(util::join!("error", status.as_str(), "_help")), + ); + page + } + }; + match page.render().await { + Ok(rendered) => ( + status, + [(http::header::CONTENT_TYPE, "text/html; charset=utf-8")], + rendered.into_string(), + ) + .into_response(), + Err(_) => status.into_response(), } } } -/// Convierte un [`ErrorPage`] en una respuesta HTTP con el código de estado adecuado y el cuerpo -/// HTML generado por el tema activo. +/// Convierte un [`ErrorPage`] en una respuesta HTTP que luego se convertirá en una página HTML +/// completa usando el tema activo. impl IntoResponse for ErrorPage { fn into_response(self) -> Response { let status = self.status_code(); - let body = self.to_string(); - ( - status, - [(http::header::CONTENT_TYPE, "text/html; charset=utf-8")], - body, - ) - .into_response() + let mut response = status.into_response(); + response.extensions_mut().insert(self); + response } } + +// Intercepta respuestas con un [`ErrorPage`] pendiente y las convierte en páginas HTML completas +// usando el tema activo. +// +// Se registra globalmente sobre el router principal desde [`crate::app`]. +pub(crate) async fn render_error_pages(req: Request, next: Next) -> Response { + let mut response = next.run(req).await; + if let Some(error_page) = response.extensions_mut().remove::() { + return error_page.render_html().await; + } + response +} diff --git a/src/web.rs b/src/web.rs index 19f62476..137a23b3 100644 --- a/src/web.rs +++ b/src/web.rs @@ -242,6 +242,7 @@ impl tower::Service> for ServeEmbedded { /// # use pagetop::prelude::*; /// pub struct MyExtension; /// +/// #[async_trait] /// impl Extension for MyExtension { /// fn configure_router(&self, router: Router) -> Router { /// // Forma 1) Sistema de ficheros o embebido. diff --git a/tests/component_children.rs b/tests/component_children.rs index dc5eb5cc..9340a508 100644 --- a/tests/component_children.rs +++ b/tests/component_children.rs @@ -11,6 +11,7 @@ struct TestComp { text: String, } +#[async_trait] impl Component for TestComp { fn new() -> Self { Self::default() @@ -20,7 +21,7 @@ impl Component for TestComp { self.props.get_id() } - fn prepare(&self, _cx: &mut Context) -> Result { + async fn prepare(&self, _cx: &mut Context) -> Result { Ok(html! { (self.text) }) } } @@ -48,27 +49,30 @@ impl TestComp { async fn child_default_is_empty() { let child = Child::default(); assert!(child.id().is_none()); - assert!(child.render(&mut Context::default()).is_empty()); + assert!(child.render(&mut Context::default()).await.is_empty()); } #[pagetop::test] async fn child_with_stores_component_and_renders_it() { - let child = Child::with(TestComp::text("hola")); - assert_eq!(child.render(&mut Context::default()).into_string(), "hola"); + let child = Child::with(TestComp::text("hello")); + assert_eq!( + child.render(&mut Context::default()).await.into_string(), + "hello" + ); } #[pagetop::test] async fn child_id_returns_component_id() { - let child = Child::with(TestComp::tagged("my-id", "texto")); + let child = Child::with(TestComp::tagged("my-id", "text")); assert_eq!(child.id(), Some("my-id".to_string())); } #[pagetop::test] async fn child_from_component_is_equivalent_to_with() { - let child: Child = TestComp::text("desde from").into(); + let child: Child = TestComp::text("from-trait").into(); assert_eq!( - child.render(&mut Context::default()).into_string(), - "desde from" + child.render(&mut Context::default()).await.into_string(), + "from-trait" ); } @@ -78,11 +82,11 @@ async fn child_clone_is_deep() { let original = Child::with(TestComp::text("original")); let clone = original.clone(); assert_eq!( - original.render(&mut Context::default()).into_string(), + original.render(&mut Context::default()).await.into_string(), "original" ); assert_eq!( - clone.render(&mut Context::default()).into_string(), + clone.render(&mut Context::default()).await.into_string(), "original" ); } @@ -103,7 +107,7 @@ async fn children_add_appends_in_order() { .with_child(TestComp::text("b")) .with_child(TestComp::text("c")); assert_eq!(c.len(), 3); - assert_eq!(c.render(&mut Context::default()).into_string(), "abc"); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "abc"); } #[pagetop::test] @@ -111,13 +115,13 @@ async fn children_add_if_empty_only_adds_when_list_is_empty() { let mut cx = Context::default(); // Se añade porque la lista está vacía. - let c = Children::new().with_child(ChildOp::AddIfEmpty(TestComp::text("primero").into())); + let c = Children::new().with_child(ChildOp::AddIfEmpty(TestComp::text("first").into())); assert_eq!(c.len(), 1); // No se añade porque ya hay un elemento. - let c = c.with_child(ChildOp::AddIfEmpty(TestComp::text("segundo").into())); + let c = c.with_child(ChildOp::AddIfEmpty(TestComp::text("second").into())); assert_eq!(c.len(), 1); - assert_eq!(c.render(&mut cx).into_string(), "primero"); + assert_eq!(c.render(&mut cx).await.into_string(), "first"); } #[pagetop::test] @@ -128,7 +132,7 @@ async fn children_add_many_appends_all_in_order() { TestComp::text("z").into(), ])); assert_eq!(c.len(), 3); - assert_eq!(c.render(&mut Context::default()).into_string(), "xyz"); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "xyz"); } #[pagetop::test] @@ -136,7 +140,7 @@ async fn children_prepend_inserts_at_start() { let c = Children::new() .with_child(TestComp::text("b")) .with_child(ChildOp::Prepend(TestComp::text("a").into())); - assert_eq!(c.render(&mut Context::default()).into_string(), "ab"); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "ab"); } #[pagetop::test] @@ -147,7 +151,7 @@ async fn children_prepend_many_inserts_all_at_start_maintaining_order() { TestComp::text("a").into(), TestComp::text("b").into(), ])); - assert_eq!(c.render(&mut Context::default()).into_string(), "abc"); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "abc"); } #[pagetop::test] @@ -156,7 +160,7 @@ async fn children_insert_after_id_inserts_after_matching_element() { .with_child(TestComp::tagged("first", "a")) .with_child(TestComp::text("c")) .with_child(ChildOp::InsertAfterId("first", TestComp::text("b").into())); - assert_eq!(c.render(&mut Context::default()).into_string(), "abc"); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "abc"); } #[pagetop::test] @@ -164,10 +168,10 @@ async fn children_insert_after_id_appends_when_id_not_found() { let c = Children::new() .with_child(TestComp::text("a")) .with_child(ChildOp::InsertAfterId( - "no-existe", + "not-found", TestComp::text("b").into(), )); - assert_eq!(c.render(&mut Context::default()).into_string(), "ab"); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "ab"); } #[pagetop::test] @@ -176,7 +180,7 @@ async fn children_insert_before_id_inserts_before_matching_element() { .with_child(TestComp::text("a")) .with_child(TestComp::tagged("last", "c")) .with_child(ChildOp::InsertBeforeId("last", TestComp::text("b").into())); - assert_eq!(c.render(&mut Context::default()).into_string(), "abc"); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "abc"); } #[pagetop::test] @@ -184,10 +188,10 @@ async fn children_insert_before_id_prepends_when_id_not_found() { let c = Children::new() .with_child(TestComp::text("b")) .with_child(ChildOp::InsertBeforeId( - "no-existe", + "not-found", TestComp::text("a").into(), )); - assert_eq!(c.render(&mut Context::default()).into_string(), "ab"); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "ab"); } #[pagetop::test] @@ -198,28 +202,28 @@ async fn children_remove_by_id_removes_first_matching_element() { .with_child(TestComp::text("c")) .with_child(ChildOp::RemoveById("drop")); assert_eq!(c.len(), 2); - assert_eq!(c.render(&mut Context::default()).into_string(), "ac"); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "ac"); } #[pagetop::test] async fn children_remove_by_id_does_nothing_when_id_not_found() { let c = Children::new() .with_child(TestComp::text("a")) - .with_child(ChildOp::RemoveById("no-existe")); + .with_child(ChildOp::RemoveById("not-found")); assert_eq!(c.len(), 1); } #[pagetop::test] async fn children_replace_by_id_replaces_first_matching_element() { let c = Children::new() - .with_child(TestComp::tagged("target", "viejo")) + .with_child(TestComp::tagged("target", "old")) .with_child(TestComp::text("b")) - .with_child(ChildOp::ReplaceById( - "target", - TestComp::text("nuevo").into(), - )); + .with_child(ChildOp::ReplaceById("target", TestComp::text("new").into())); assert_eq!(c.len(), 2); - assert_eq!(c.render(&mut Context::default()).into_string(), "nuevob"); + assert_eq!( + c.render(&mut Context::default()).await.into_string(), + "newb" + ); } #[pagetop::test] @@ -234,11 +238,11 @@ async fn children_reset_clears_all_elements() { #[pagetop::test] async fn children_get_by_id_returns_first_matching_child() { let c = Children::new() - .with_child(TestComp::tagged("uno", "a")) - .with_child(TestComp::tagged("dos", "b")); - assert!(c.get_by_id("uno").is_some()); - assert!(c.get_by_id("dos").is_some()); - assert!(c.get_by_id("tres").is_none()); + .with_child(TestComp::tagged("one", "a")) + .with_child(TestComp::tagged("two", "b")); + assert!(c.get_by_id("one").is_some()); + assert!(c.get_by_id("two").is_some()); + assert!(c.get_by_id("three").is_none()); } #[pagetop::test] @@ -246,21 +250,21 @@ async fn children_iter_by_id_yields_all_matching_children() { let c = Children::new() .with_child(TestComp::tagged("rep", "a")) .with_child(TestComp::tagged("rep", "b")) - .with_child(TestComp::tagged("otro", "c")); + .with_child(TestComp::tagged("other", "c")); assert_eq!(c.iter_by_id("rep").count(), 2); - assert_eq!(c.iter_by_id("otro").count(), 1); - assert_eq!(c.iter_by_id("ninguno").count(), 0); + assert_eq!(c.iter_by_id("other").count(), 1); + assert_eq!(c.iter_by_id("none").count(), 0); } #[pagetop::test] async fn children_render_concatenates_all_outputs_in_order() { let c = Children::new() - .with_child(TestComp::text("uno ")) - .with_child(TestComp::text("dos ")) - .with_child(TestComp::text("tres")); + .with_child(TestComp::text("one ")) + .with_child(TestComp::text("two ")) + .with_child(TestComp::text("three")); assert_eq!( - c.render(&mut Context::default()).into_string(), - "uno dos tres" + c.render(&mut Context::default()).await.into_string(), + "one two three" ); } @@ -270,29 +274,29 @@ async fn children_render_concatenates_all_outputs_in_order() { async fn embed_default_is_empty() { let embed: Embed = Embed::default(); assert!(embed.id().is_none()); - assert!(embed.render(&mut Context::default()).is_empty()); + assert!(embed.render(&mut Context::default()).await.is_empty()); assert!(embed.get().is_none()); } #[pagetop::test] async fn embed_with_stores_component() { - let embed = Embed::with(TestComp::text("contenido")); + let embed = Embed::with(TestComp::text("content")); assert!(embed.get().is_some()); assert_eq!( - embed.render(&mut Context::default()).into_string(), - "contenido" + embed.render(&mut Context::default()).await.into_string(), + "content" ); } #[pagetop::test] async fn embed_id_returns_component_id() { - let embed = Embed::with(TestComp::tagged("embed-id", "texto")); + let embed = Embed::with(TestComp::tagged("embed-id", "text")); assert_eq!(embed.id(), Some("embed-id".to_string())); } #[pagetop::test] async fn embed_get_is_some_when_component_present() { - let embed = Embed::with(TestComp::tagged("abc", "hola")); + let embed = Embed::with(TestComp::tagged("abc", "hello")); // `get()` devuelve Some; la lectura del id verifica que accede al componente correctamente. assert!(embed.get().is_some()); assert_eq!(embed.id(), Some("abc".to_string())); @@ -300,38 +304,36 @@ async fn embed_get_is_some_when_component_present() { #[pagetop::test] async fn embed_get_allows_mutating_component() { - let embed = Embed::with(TestComp::tagged("orig", "texto")); - // El `;` final convierte el `if let` en sentencia y libera el guard antes que `embed`. - if let Some(mut comp) = embed.get() { + let mut embed = Embed::with(TestComp::tagged("orig", "text")); + if let Some(comp) = embed.get_mut() { comp.props - .alter_prop(PropsOp::set_id("modificado".to_string())); + .alter_prop(PropsOp::set_id("modified".to_string())); }; - assert_eq!(embed.id(), Some("modificado".to_string())); + assert_eq!(embed.id(), Some("modified".to_string())); } #[pagetop::test] async fn embed_with_component_replaces_content() { - let embed = - Embed::with(TestComp::text("primero")).with_component(Some(TestComp::text("segundo"))); + let embed = Embed::with(TestComp::text("first")).with_component(Some(TestComp::text("second"))); assert_eq!( - embed.render(&mut Context::default()).into_string(), - "segundo" + embed.render(&mut Context::default()).await.into_string(), + "second" ); } #[pagetop::test] async fn embed_with_component_none_empties_embed() { - let embed = Embed::with(TestComp::text("algo")).with_component(None); + let embed = Embed::with(TestComp::text("something")).with_component(None); assert!(embed.get().is_none()); - assert!(embed.render(&mut Context::default()).is_empty()); + assert!(embed.render(&mut Context::default()).await.is_empty()); } #[pagetop::test] async fn embed_clone_is_deep() { - let original = Embed::with(TestComp::tagged("orig", "texto")); - let clone = original.clone(); + let original = Embed::with(TestComp::tagged("orig", "text")); + let mut clone = original.clone(); // Mutar el clon no debe afectar al original. - if let Some(mut comp) = clone.get() { + if let Some(comp) = clone.get_mut() { comp.props .alter_prop(PropsOp::set_id("clone-id".to_string())); } @@ -341,10 +343,10 @@ async fn embed_clone_is_deep() { #[pagetop::test] async fn embed_converts_into_child() { - let embed = Embed::with(TestComp::text("desde embed")); + let embed = Embed::with(TestComp::text("from embed")); let child = Child::from(embed); assert_eq!( - child.render(&mut Context::default()).into_string(), - "desde embed" + child.render(&mut Context::default()).await.into_string(), + "from embed" ); } diff --git a/tests/component_html.rs b/tests/component_html.rs index d805d079..1eb6213e 100644 --- a/tests/component_html.rs +++ b/tests/component_html.rs @@ -8,8 +8,8 @@ async fn component_html_renders_static_markup() { } }); - let markup = component.render(&mut Context::default()); - assert_eq!(markup.0, "

Test

"); + let markup = component.render(&mut Context::default()).await; + assert_eq!(markup.into_string(), "

Test

"); } #[pagetop::test] @@ -23,8 +23,8 @@ async fn component_html_renders_using_context_param() { } }); - let markup = component.render(&mut cx); - assert_eq!(markup.0, "Alice"); + let markup = component.render(&mut cx).await; + assert_eq!(markup.into_string(), "Alice"); } #[pagetop::test] @@ -33,16 +33,16 @@ async fn component_html_allows_replacing_render_function() { component.alter_fn(|_| html! { div { "Modified" } }); - let markup = component.render(&mut Context::default()); - assert_eq!(markup.0, "
Modified
"); + let markup = component.render(&mut Context::default()).await; + assert_eq!(markup.into_string(), "
Modified
"); } #[pagetop::test] async fn component_html_default_renders_empty_markup() { let mut component = Html::default(); - let markup = component.render(&mut Context::default()); - assert_eq!(markup.0, ""); + let markup = component.render(&mut Context::default()).await; + assert_eq!(markup.into_string(), ""); } #[pagetop::test] @@ -60,6 +60,6 @@ async fn component_html_can_access_request_path() { html! { span { (path) } } }); - let markup = component.render(&mut cx); - assert_eq!(markup.0, "/hello/world"); + let markup = component.render(&mut cx).await; + assert_eq!(markup.into_string(), "/hello/world"); } diff --git a/tests/component_poweredby.rs b/tests/component_poweredby.rs index 2a82b84f..a0d383a4 100644 --- a/tests/component_poweredby.rs +++ b/tests/component_poweredby.rs @@ -13,13 +13,13 @@ async fn poweredby_default_shows_only_pagetop_recognition() { setup().await; let mut p = PoweredBy::default(); - let html = p.render(&mut Context::default()); + let html = p.render(&mut Context::default()).await.into_string(); // Debe mostrar el bloque de reconocimiento a PageTop. - assert!(html.as_str().contains("poweredby__pagetop")); + assert!(html.contains("poweredby__pagetop")); // Y NO debe mostrar el bloque de copyright. - assert!(!html.as_str().contains("poweredby__copyright")); + assert!(!html.contains("poweredby__copyright")); } #[pagetop::test] @@ -27,23 +27,20 @@ async fn poweredby_new_includes_current_year_and_app_name() { setup().await; let mut p = PoweredBy::new(); - let html = p.render(&mut Context::default()); + let html = p.render(&mut Context::default()).await.into_string(); let year = Utc::now().format("%Y").to_string(); - assert!( - html.as_str().contains(&year), - "HTML should include the current year" - ); + assert!(html.contains(&year), "HTML should include the current year"); // El nombre de la app proviene de `global::SETTINGS.app.name`. let app_name = &global::SETTINGS.app.name; assert!( - html.as_str().contains(app_name), + html.contains(app_name), "HTML should include the application name" ); // Debe existir el span de copyright. - assert!(html.as_str().contains("poweredby__copyright")); + assert!(html.contains("poweredby__copyright")); } #[pagetop::test] @@ -52,10 +49,10 @@ async fn poweredby_with_copyright_overrides_text() { let custom = "2001 © FooBar Inc."; let mut p = PoweredBy::default().with_copyright(Some(custom)); - let html = p.render(&mut Context::default()); + let html = p.render(&mut Context::default()).await.into_string(); - assert!(html.as_str().contains(custom)); - assert!(html.as_str().contains("poweredby__copyright")); + assert!(html.contains(custom)); + assert!(html.contains("poweredby__copyright")); } #[pagetop::test] @@ -63,11 +60,11 @@ async fn poweredby_with_copyright_none_hides_text() { setup().await; let mut p = PoweredBy::new().with_copyright(None::); - let html = p.render(&mut Context::default()); + let html = p.render(&mut Context::default()).await.into_string(); - assert!(!html.as_str().contains("poweredby__copyright")); + assert!(!html.contains("poweredby__copyright")); // El reconocimiento a PageTop siempre debe aparecer. - assert!(html.as_str().contains("poweredby__pagetop")); + assert!(html.contains("poweredby__pagetop")); } #[pagetop::test] @@ -75,10 +72,10 @@ async fn poweredby_link_points_to_crates_io() { setup().await; let mut p = PoweredBy::default(); - let html = p.render(&mut Context::default()); + let html = p.render(&mut Context::default()).await.into_string(); assert!( - html.as_str().contains("https://pagetop.cillero.es"), + html.contains("https://pagetop.cillero.es"), "Link should point to pagetop.cillero.es" ); } diff --git a/tests/html_markup.rs b/tests/html_markup.rs index e7348d9d..bdf2ff79 100644 --- a/tests/html_markup.rs +++ b/tests/html_markup.rs @@ -8,6 +8,7 @@ struct TestMarkupComponent { markup: Markup, } +#[async_trait] impl Component for TestMarkupComponent { fn new() -> Self { Self::default() @@ -17,7 +18,7 @@ impl Component for TestMarkupComponent { cx.param_or::("renderable", true) } - fn prepare(&self, _cx: &mut Context) -> Result { + async fn prepare(&self, _cx: &mut Context) -> Result { Ok(self.markup.clone()) } } @@ -74,7 +75,7 @@ async fn non_renderable_component_produces_empty_markup() { let mut comp = TestMarkupComponent { markup: html! { p { "Should never be rendered" } }, }; - assert_eq!(comp.render(&mut cx).into_string(), ""); + assert_eq!(comp.render(&mut cx).await.into_string(), ""); } #[pagetop::test] @@ -93,7 +94,7 @@ async fn markup_from_component_equals_markup_reinjected_in_html_macro() { let mut comp = TestMarkupComponent { markup: markup.clone(), }; - comp.render(&mut cx).into_string() + comp.render(&mut cx).await.into_string() }; // Vía 2: reinyectamos el Markup en `html!` directamente.