🎨 Convierte el ciclo de renderizado en async

`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.
This commit is contained in:
Manuel Cillero 2026-07-06 00:48:41 +02:00
parent 9acd8cc51a
commit 9354894b3a
48 changed files with 436 additions and 365 deletions

View file

@ -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<Markup, ErrorPage> {
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 `<h1>`.
## 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**:

View file

@ -5,6 +5,7 @@ include_locales!(LOC from "examples/locale");
struct FormControls;
#[async_trait]
impl Extension for FormControls {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![&pagetop_aliner::Aliner, &pagetop_bootsier::Bootsier]
@ -297,6 +298,7 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
),
)
.render()
.await
}
fn form_lists() -> Form {

View file

@ -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]

View file

@ -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<Markup, ErrorPage> {
}
}))
.render()
.await
}
#[pagetop::main]

View file

@ -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<Markup, ErrorPage> {
),
)
.render()
.await
}
#[pagetop::main]

View file

@ -34,6 +34,7 @@ use pagetop::prelude::*;
struct MyApp;
#[async_trait]
impl Extension for MyApp {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![
@ -68,7 +69,7 @@ async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
p { (L10n::l("sample_content").using(cx)) }
})),
)
.render()
.render().await
}
```

View file

@ -35,6 +35,7 @@ use pagetop::prelude::*;
struct MyApp;
#[async_trait]
impl Extension for MyApp {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![
@ -69,7 +70,7 @@ async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
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(

View file

@ -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");

View file

@ -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");

View file

@ -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]

View file

@ -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<ActionBox> {
/// 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 {

View file

@ -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<Markup, ComponentError> {
let block_body = self.children().render(cx);
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let block_body = self.children().render(cx).await;
if block_body.is_empty() {
return Ok(html! {});

View file

@ -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<Markup, ComponentError> {
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(html! {
button
type=(self.kind())

View file

@ -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<Markup, ComponentError> {
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
// 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();

View file

@ -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<Markup, ComponentError> {
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
// 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();

View file

@ -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<Markup, ComponentError> {
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
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)
}
})
}

View file

@ -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<Markup, ComponentError> {
let children = self.children().render(cx);
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let children = self.children().render(cx).await;
if children.is_empty() {
return Ok(html! {});

View file

@ -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<Markup, ComponentError> {
async fn prepare(&self, _cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(html! {
input
type="hidden"

View file

@ -157,6 +157,7 @@ pub struct Field {
inputmode: Attr<Mode>,
}
#[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<Markup, ComponentError> {
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let container_id = self.id();
let input_id = container_id.as_deref().map(|id| util::join!(id, "-input"));
let input_class = if *self.plaintext() {

View file

@ -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<Markup, ComponentError> {
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
// 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();

View file

@ -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<Markup, ComponentError> {
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let container_id = self.id();
let range_id = container_id.as_deref().map(|id| util::join!(id, "-range"));
Ok(html! {

View file

@ -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<Markup, ComponentError> {
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let container_id = self.id();
let select_id = container_id.as_deref().map(|id| util::join!(id, "-select"));

View file

@ -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<Markup, ComponentError> {
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let container_id = self.id();
let textarea_id = container_id
.as_deref()

View file

@ -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<Markup, ComponentError> {
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(self.html(cx))
}
}

View file

@ -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<Markup, ComponentError> {
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
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") }

View file

@ -14,6 +14,7 @@ pub struct PoweredBy {
copyright: Option<String>,
}
#[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<Markup, ComponentError> {
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(html! {
div id=[self.id()] class="poweredby" {
@if let Some(c) = self.copyright() {

View file

@ -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<Markup, ErrorPage> {
),
)
.render()
.await
}

View file

@ -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<ThemeRef> {
Some(&Self)
}
}
#[async_trait]
impl Theme for Basic {
fn before_render_page_body(&self, page: &mut Page) {
page.alter_assets(AssetsOp::AddStyleSheet(

View file

@ -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<ActionBox> {
/// actions![

View file

@ -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<FnIsRenderable>,
/// }
///
/// #[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<Markup, ComponentError> {
/// async fn prepare(&self, _cx: &mut Context) -> Result<Markup, ComponentError> {
/// Ok(html! { "Visible component" })
/// }
/// }

View file

@ -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<Mutex<Box<dyn Component>>>);
// **< 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<Arc<RwLock<Box<dyn Component>>>>);
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<C: Component>(mut self, component: Option<C>) -> Self {
self.0 = component.map(|c| Mutex::new(Box::new(c) as Box<dyn Component>));
self.0 = component.map(|c| Arc::new(RwLock::new(Box::new(c) as Box<dyn Component>)));
self
}
@ -50,22 +45,28 @@ impl Child {
/// Devuelve el identificador del componente, si existe y está definido.
#[inline]
pub fn id(&self) -> Option<String> {
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<UniqueId> {
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<C: Component + 'static> From<Embed<C>> for Child {
/// children.with_child(my_embed.into());
/// ```
fn from(embed: Embed<C>) -> Self {
if let Some(m) = embed.0 {
Child(Some(Mutex::new(
Box::new(m.into_inner()) as Box<dyn Component>
)))
} 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<dyn Component>,
Err(arc) => arc.clone_box(),
})))),
}
}
}
@ -114,7 +115,7 @@ impl From<Child> for ChildOp {
}
}
// *************************************************************************************************
// **< Embed >**************************************************************************************
/// Contenedor tipado para un *único* componente de un tipo concreto conocido.
///
@ -125,11 +126,12 @@ impl From<Child> 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<C: Component>(Option<Mutex<C>>);
pub struct Embed<C: Component>(Option<Arc<C>>);
impl<C: Component + Clone> Clone for Embed<C> {
// Arc<C>: Clone no requiere C: Clone, pero #[derive(Clone)] añadiría ese bound innecesariamente.
impl<C: Component> Clone for Embed<C> {
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<C: Component> fmt::Debug for Embed<C> {
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<C: Component> fmt::Debug for Embed<C> {
impl<C: Component> Embed<C> {
/// 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<C: Component> Embed<C> {
/// 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<C>) -> Self {
self.0 = component.map(Mutex::new);
self.0 = component.map(Arc::new);
self
}
@ -164,55 +166,69 @@ impl<C: Component> Embed<C> {
/// Devuelve el identificador del componente, si existe y está definido.
#[inline]
pub fn id(&self) -> Option<String> {
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<C>)` 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<ComponentGuard<'_, C>> {
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<Child>),
@ -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<C>`]: 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<I>(&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<str>, 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<str>, 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<I>(&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<str>) -> &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<str>, 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 {

View file

@ -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<T: 'static>(self, key: &'static str, value: T) -> Self;
fn with_param<T: Send + Sync + 'static>(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<HttpRequest>, // Petición HTTP de origen.
locale : RequestLocale, // Idioma asociado a la petición.
current_user: CurrentUser, // Identidad del usuario actual.
theme : ThemeRef, // Referencia al tema usado para renderizar.
template : TemplateRef, // Plantilla usada para renderizar.
favicon : Option<Favicon>, // Favicon, si se ha definido.
stylesheets : Assets<StyleSheet>, // Hojas de estilo CSS.
javascripts : Assets<JavaScript>, // Scripts JavaScript.
body_props : Props, // Identificador, clases CSS y atributos del <body>.
regions : ChildrenInRegions, // Regiones de componentes para renderizar.
params : HashMap<&'static str, (Box<dyn Any>, &'static str)>, // Parámetros en ejecución.
id_counters : RefCell<HashMap<TypeId, usize>>, // RefCell permite mutar desde build_id(&self).
messages : Vec<StatusMessage>, // Mensajes de usuario acumulados.
request : Option<HttpRequest>, // Petición HTTP de origen.
locale : RequestLocale, // Idioma asociado a la petición.
current_user: CurrentUser, // Identidad del usuario actual.
theme : ThemeRef, // Referencia al tema usado para renderizar.
template : TemplateRef, // Plantilla usada para renderizar.
favicon : Option<Favicon>, // Favicon, si se ha definido.
stylesheets : Assets<StyleSheet>, // Hojas de estilo CSS.
javascripts : Assets<JavaScript>, // Scripts JavaScript.
body_props : Props, // Id, clases CSS y atributos del <body>.
regions : ChildrenInRegions, // Regiones de componentes para renderizar.
params : HashMap<&'static str, (Box<dyn Any + Send + Sync>, &'static str)>, // Parámetros.
id_counters : Mutex<HashMap<TypeId, usize>>, // Mutex permite mutar desde build_id(&self).
messages : Vec<StatusMessage>, // Mensajes de usuario acumulados.
}
impl Default for Context {
@ -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::<C>()).or_insert(0);
*n += 1;
*n
@ -529,7 +531,7 @@ impl Contextual for Context {
}
#[builder_fn]
fn with_param<T: 'static>(mut self, key: &'static str, value: T) -> Self {
fn with_param<T: Send + Sync + 'static>(mut self, key: &'static str, value: T) -> Self {
let type_name = TypeInfo::FullName.of::<T>();
self.params.insert(key, (Box::new(value), type_name));
self

View file

@ -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<dyn Component>`, 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<dyn Component>`].
fn clone_box(&self) -> Box<dyn Component>;
@ -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<Markup, ComponentError> {
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(html! {})
}
}
@ -143,8 +158,9 @@ impl<T: Component + Clone + 'static> 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<C: Component> 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<C: Component> ComponentRender for C {
let result = 'resolve: {
let mut t: Option<ThemeRef> = 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,

View file

@ -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<Markup, ComponentError> {
/// async fn prepare(&self, _cx: &mut Context) -> Result<Markup, ComponentError> {
/// Err(ComponentError::new("Database connection failed")
/// .with_fallback(html! { p class="error" { "Content temporarily unavailable." } }))
/// }

View file

@ -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<ThemeRef> {
/// Some(&Self)
/// }
/// }
///
/// #[async_trait]
/// impl Theme for MyTheme {}
/// ```
fn theme(&self) -> Option<ThemeRef> {
@ -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<ExtensionRef> {
/// 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))

View file

@ -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 `<body>`
/// 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 `<body>` 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! >**************************************************************************

View file

@ -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,

View file

@ -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());
}

View file

@ -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),

View file

@ -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<Markup, ComponentError> {
/// async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
/// Ok(html! {
/// button (self.props()) {
/// (self.label().using(cx))

View file

@ -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<Markup, ErrorPage> {
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<ThemeRef> {
/// Some(&Self)
/// }
/// }
///
/// #[async_trait]
/// impl Theme for MyTheme {
/// fn before_render_page_body(&self, page: &mut Page) {
/// page

View file

@ -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 `<html>`.
/// 8. Compone el documento HTML completo (`<!DOCTYPE html>`, `<html>`, `<head>`, `<body>`) y
/// devuelve un [`Result`] con el [`Markup`] final.
pub fn render(&mut self) -> Result<Markup, ErrorPage> {
pub async fn render(&mut self) -> Result<Markup, ErrorPage> {
// Acciones específicas del tema antes de renderizar el <body>.
self.context.theme().before_render_page_body(self);
@ -209,9 +210,9 @@ impl Page {
// Renderiza el <body>.
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 <body>.
@ -221,7 +222,7 @@ impl Page {
action::page::AfterRenderBody::dispatch(self);
// Renderiza el <head>.
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<T: 'static>(mut self, key: &'static str, value: T) -> Self {
fn with_param<T: Send + Sync + 'static>(mut self, key: &'static str, value: T) -> Self {
self.context.alter_param(key, value);
self
}

View file

@ -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<code>_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::<ErrorPage>() {
return error_page.render_html().await;
}
response
}

View file

@ -242,6 +242,7 @@ impl tower::Service<http::Request<Body>> 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.

View file

@ -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<Markup, ComponentError> {
async fn prepare(&self, _cx: &mut Context) -> Result<Markup, ComponentError> {
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<TestComp> = 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"
);
}

View file

@ -8,8 +8,8 @@ async fn component_html_renders_static_markup() {
}
});
let markup = component.render(&mut Context::default());
assert_eq!(markup.0, "<p>Test</p>");
let markup = component.render(&mut Context::default()).await;
assert_eq!(markup.into_string(), "<p>Test</p>");
}
#[pagetop::test]
@ -23,8 +23,8 @@ async fn component_html_renders_using_context_param() {
}
});
let markup = component.render(&mut cx);
assert_eq!(markup.0, "<span>Alice</span>");
let markup = component.render(&mut cx).await;
assert_eq!(markup.into_string(), "<span>Alice</span>");
}
#[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, "<div>Modified</div>");
let markup = component.render(&mut Context::default()).await;
assert_eq!(markup.into_string(), "<div>Modified</div>");
}
#[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, "<span>/hello/world</span>");
let markup = component.render(&mut cx).await;
assert_eq!(markup.into_string(), "<span>/hello/world</span>");
}

View file

@ -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::<String>);
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"
);
}

View file

@ -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::<bool>("renderable", true)
}
fn prepare(&self, _cx: &mut Context) -> Result<Markup, ComponentError> {
async fn prepare(&self, _cx: &mut Context) -> Result<Markup, ComponentError> {
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.