From 34aeeab2d70a5059d7a98e165b57df038899ab0c Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sat, 21 Mar 2026 13:15:39 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20A=C3=B1ade=20`ComponentError`=20con?= =?UTF-8?q?=20HTML=20alternativo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prepare_component()` ahora devuelve `Result` en lugar de `Markup`, para que los componentes señalen fallos durante el renderizado de forma explícita. `ComponentError` encapsula un mensaje de error y un marcado HTML alternativo opcional (`fallback`). Si se produce un error, el ciclo de renderizado registra la traza y muestra el `fallback` en lugar del componente fallido, sin interrumpir el resto de la página. Lo mismo aplica a los errores devueltos por la acción `PrepareRender` de los temas, que siguen el mismo mecanismo. --- .../src/theme/container/component.rs | 8 +- .../src/theme/dropdown/component.rs | 8 +- .../src/theme/dropdown/item.rs | 6 +- .../src/theme/form/component.rs | 6 +- .../src/theme/form/fieldset.rs | 6 +- .../pagetop-bootsier/src/theme/form/input.rs | 6 +- extensions/pagetop-bootsier/src/theme/icon.rs | 6 +- .../src/theme/image/component.rs | 10 +-- .../src/theme/nav/component.rs | 8 +- .../pagetop-bootsier/src/theme/nav/item.rs | 8 +- .../src/theme/navbar/brand.rs | 8 +- .../src/theme/navbar/component.rs | 8 +- .../pagetop-bootsier/src/theme/navbar/item.rs | 8 +- .../src/theme/offcanvas/component.rs | 4 +- src/base/action/theme/prepare_render.rs | 20 ++--- src/base/component/block.rs | 8 +- src/base/component/html.rs | 4 +- src/base/component/intro.rs | 6 +- src/base/component/poweredby.rs | 6 +- src/core/action.rs | 2 +- src/core/action/all.rs | 15 ++++ src/core/action/list.rs | 17 +++++ src/core/component.rs | 7 +- src/core/component/definition.rs | 75 +++++++++++++------ src/core/component/error.rs | 66 ++++++++++++++++ tests/html_markup.rs | 6 +- 26 files changed, 232 insertions(+), 100 deletions(-) create mode 100644 src/core/component/error.rs diff --git a/extensions/pagetop-bootsier/src/theme/container/component.rs b/extensions/pagetop-bootsier/src/theme/container/component.rs index 5578765f..ad828c48 100644 --- a/extensions/pagetop-bootsier/src/theme/container/component.rs +++ b/extensions/pagetop-bootsier/src/theme/container/component.rs @@ -33,10 +33,10 @@ impl Component for Container { self.alter_classes(ClassesOp::Prepend, self.container_width().to_class()); } - fn prepare_component(&self, cx: &mut Context) -> Markup { + fn prepare_component(&self, cx: &mut Context) -> Result { let output = self.children().render(cx); if output.is_empty() { - return html! {}; + return Ok(html! {}); } let style = match self.container_width() { container::Width::FluidMax(w) if w.is_measurable() => { @@ -44,7 +44,7 @@ impl Component for Container { } _ => None, }; - match self.container_kind() { + Ok(match self.container_kind() { container::Kind::Default => html! { div id=[self.id()] class=[self.classes().get()] style=[style] { (output) @@ -75,7 +75,7 @@ impl Component for Container { (output) } }, - } + }) } } diff --git a/extensions/pagetop-bootsier/src/theme/dropdown/component.rs b/extensions/pagetop-bootsier/src/theme/dropdown/component.rs index 046f6ec1..cb721fca 100644 --- a/extensions/pagetop-bootsier/src/theme/dropdown/component.rs +++ b/extensions/pagetop-bootsier/src/theme/dropdown/component.rs @@ -63,17 +63,17 @@ impl Component for Dropdown { ); } - fn prepare_component(&self, cx: &mut Context) -> Markup { + fn prepare_component(&self, cx: &mut Context) -> Result { // Si no hay elementos en el menú, no se prepara. let items = self.items().render(cx); if items.is_empty() { - return html! {}; + return Ok(html! {}); } // Título opcional para el menú desplegable. let title = self.title().using(cx); - html! { + Ok(html! { div id=[self.id()] class=[self.classes().get()] { @if !title.is_empty() { @let mut btn_classes = Classes::new({ @@ -156,7 +156,7 @@ impl Component for Dropdown { ul class="dropdown-menu" { (items) } } } - } + }) } } diff --git a/extensions/pagetop-bootsier/src/theme/dropdown/item.rs b/extensions/pagetop-bootsier/src/theme/dropdown/item.rs index 2b94a8f1..ac252d1b 100644 --- a/extensions/pagetop-bootsier/src/theme/dropdown/item.rs +++ b/extensions/pagetop-bootsier/src/theme/dropdown/item.rs @@ -62,8 +62,8 @@ impl Component for Item { self.id.get() } - fn prepare_component(&self, cx: &mut Context) -> Markup { - match self.item_kind() { + fn prepare_component(&self, cx: &mut Context) -> Result { + Ok(match self.item_kind() { ItemKind::Void => html! {}, ItemKind::Label(label) => html! { @@ -151,7 +151,7 @@ impl Component for Item { ItemKind::Divider => html! { li id=[self.id()] class=[self.classes().get()] { hr class="dropdown-divider" {} } }, - } + }) } } diff --git a/extensions/pagetop-bootsier/src/theme/form/component.rs b/extensions/pagetop-bootsier/src/theme/form/component.rs index 5212554b..6ed05938 100644 --- a/extensions/pagetop-bootsier/src/theme/form/component.rs +++ b/extensions/pagetop-bootsier/src/theme/form/component.rs @@ -52,12 +52,12 @@ impl Component for Form { self.alter_classes(ClassesOp::Prepend, "form"); } - fn prepare_component(&self, cx: &mut Context) -> Markup { + fn prepare_component(&self, cx: &mut Context) -> Result { let method = match self.method() { form::Method::Post => Some("post"), form::Method::Get => None, }; - html! { + Ok(html! { form id=[self.id()] class=[self.classes().get()] @@ -67,7 +67,7 @@ impl Component for Form { { (self.children().render(cx)) } - } + }) } } diff --git a/extensions/pagetop-bootsier/src/theme/form/fieldset.rs b/extensions/pagetop-bootsier/src/theme/form/fieldset.rs index 6d6a08cb..536218e9 100644 --- a/extensions/pagetop-bootsier/src/theme/form/fieldset.rs +++ b/extensions/pagetop-bootsier/src/theme/form/fieldset.rs @@ -22,15 +22,15 @@ impl Component for Fieldset { self.id.get() } - fn prepare_component(&self, cx: &mut Context) -> Markup { - html! { + fn prepare_component(&self, cx: &mut Context) -> Result { + Ok(html! { fieldset id=[self.id()] class=[self.classes().get()] disabled[*self.disabled()] { @if let Some(legend) = self.legend().lookup(cx) { legend { (legend) } } (self.children().render(cx)) } - } + }) } } diff --git a/extensions/pagetop-bootsier/src/theme/form/input.rs b/extensions/pagetop-bootsier/src/theme/form/input.rs index bb37681b..1872f806 100644 --- a/extensions/pagetop-bootsier/src/theme/form/input.rs +++ b/extensions/pagetop-bootsier/src/theme/form/input.rs @@ -36,9 +36,9 @@ impl Component for Input { ); } - fn prepare_component(&self, cx: &mut Context) -> Markup { + fn prepare_component(&self, cx: &mut Context) -> Result { let id = self.name().get().map(|name| util::join!("edit-", name)); - html! { + Ok(html! { div class=[self.classes().get()] { @if let Some(label) = self.label().lookup(cx) { label for=[&id] class="form-label" { @@ -72,7 +72,7 @@ impl Component for Input { div class="form-text" { (description) } } } - } + }) } } diff --git a/extensions/pagetop-bootsier/src/theme/icon.rs b/extensions/pagetop-bootsier/src/theme/icon.rs index 4a2abde2..e3c0fe56 100644 --- a/extensions/pagetop-bootsier/src/theme/icon.rs +++ b/extensions/pagetop-bootsier/src/theme/icon.rs @@ -35,8 +35,8 @@ impl Component for Icon { } } - fn prepare_component(&self, cx: &mut Context) -> Markup { - match self.icon_kind() { + fn prepare_component(&self, cx: &mut Context) -> Result { + Ok(match self.icon_kind() { IconKind::None => html! {}, IconKind::Font(_) => { let aria_label = self.aria_label().lookup(cx); @@ -69,7 +69,7 @@ impl Component for Icon { } } } - } + }) } } diff --git a/extensions/pagetop-bootsier/src/theme/image/component.rs b/extensions/pagetop-bootsier/src/theme/image/component.rs index 597446c7..f9f04d26 100644 --- a/extensions/pagetop-bootsier/src/theme/image/component.rs +++ b/extensions/pagetop-bootsier/src/theme/image/component.rs @@ -36,13 +36,13 @@ impl Component for Image { self.alter_classes(ClassesOp::Prepend, self.source().to_class()); } - fn prepare_component(&self, cx: &mut Context) -> Markup { + fn prepare_component(&self, cx: &mut Context) -> Result { let dimensions = self.size().to_style(); let alt_text = self.alternative().lookup(cx).unwrap_or_default(); let is_decorative = alt_text.is_empty(); let source = match self.source() { image::Source::Logo(logo) => { - return html! { + return Ok(html! { span id=[self.id()] class=[self.classes().get()] @@ -53,20 +53,20 @@ impl Component for Image { { (logo.render(cx)) } - } + }) } image::Source::Responsive(source) => Some(source), image::Source::Thumbnail(source) => Some(source), image::Source::Plain(source) => Some(source), }; - html! { + Ok(html! { img src=[source] alt=(alt_text) id=[self.id()] class=[self.classes().get()] style=[dimensions] {} - } + }) } } diff --git a/extensions/pagetop-bootsier/src/theme/nav/component.rs b/extensions/pagetop-bootsier/src/theme/nav/component.rs index d01fd1d7..fd849a9c 100644 --- a/extensions/pagetop-bootsier/src/theme/nav/component.rs +++ b/extensions/pagetop-bootsier/src/theme/nav/component.rs @@ -42,17 +42,17 @@ impl Component for Nav { }); } - fn prepare_component(&self, cx: &mut Context) -> Markup { + fn prepare_component(&self, cx: &mut Context) -> Result { let items = self.items().render(cx); if items.is_empty() { - return html! {}; + return Ok(html! {}); } - html! { + Ok(html! { ul id=[self.id()] class=[self.classes().get()] { (items) } - } + }) } } diff --git a/extensions/pagetop-bootsier/src/theme/nav/item.rs b/extensions/pagetop-bootsier/src/theme/nav/item.rs index 5db8864e..45418021 100644 --- a/extensions/pagetop-bootsier/src/theme/nav/item.rs +++ b/extensions/pagetop-bootsier/src/theme/nav/item.rs @@ -99,8 +99,8 @@ impl Component for Item { self.alter_classes(ClassesOp::Prepend, self.item_kind().to_class()); } - fn prepare_component(&self, cx: &mut Context) -> Markup { - match self.item_kind() { + fn prepare_component(&self, cx: &mut Context) -> Result { + Ok(match self.item_kind() { ItemKind::Void => html! {}, ItemKind::Label(label) => html! { @@ -162,7 +162,7 @@ impl Component for Item { if let Some(dd) = menu.borrow() { let items = dd.items().render(cx); if items.is_empty() { - return html! {}; + return Ok(html! {}); } let title = dd.title().lookup(cx).unwrap_or_else(|| { L10n::t("dropdown", &LOCALES_BOOTSIER) @@ -189,7 +189,7 @@ impl Component for Item { html! {} } } - } + }) } } diff --git a/extensions/pagetop-bootsier/src/theme/navbar/brand.rs b/extensions/pagetop-bootsier/src/theme/navbar/brand.rs index 7dd43ef5..5c22195a 100644 --- a/extensions/pagetop-bootsier/src/theme/navbar/brand.rs +++ b/extensions/pagetop-bootsier/src/theme/navbar/brand.rs @@ -36,20 +36,20 @@ impl Component for Brand { self.id.get() } - fn prepare_component(&self, cx: &mut Context) -> Markup { + fn prepare_component(&self, cx: &mut Context) -> Result { let image = self.image().render(cx); let title = self.title().using(cx); if title.is_empty() && image.is_empty() { - return html! {}; + return Ok(html! {}); } let slogan = self.slogan().using(cx); - html! { + Ok(html! { @if let Some(route) = self.route() { a class="navbar-brand" href=(route(cx)) { (image) (title) (slogan) } } @else { span class="navbar-brand" { (image) (title) (slogan) } } - } + }) } } diff --git a/extensions/pagetop-bootsier/src/theme/navbar/component.rs b/extensions/pagetop-bootsier/src/theme/navbar/component.rs index 7b56f2a2..6f0ecf54 100644 --- a/extensions/pagetop-bootsier/src/theme/navbar/component.rs +++ b/extensions/pagetop-bootsier/src/theme/navbar/component.rs @@ -48,7 +48,7 @@ impl Component for Navbar { }); } - fn prepare_component(&self, cx: &mut Context) -> Markup { + fn prepare_component(&self, cx: &mut Context) -> Result { // Botón de despliegue (colapso u offcanvas) para la barra. fn button(cx: &mut Context, data_bs_toggle: &str, id_content: &str) -> Markup { let id_content_target = util::join!("#", id_content); @@ -75,13 +75,13 @@ impl Component for Navbar { // Si no hay contenidos, no tiene sentido mostrar una barra vacía. let items = self.items().render(cx); if items.is_empty() { - return html! {}; + return Ok(html! {}); } // Asegura que la barra tiene un `id` para poder asociarlo al colapso/offcanvas. let id = cx.required_id::(self.id()); - html! { + Ok(html! { nav id=(id) class=[self.classes().get()] { div class="container-fluid" { @match self.layout() { @@ -162,7 +162,7 @@ impl Component for Navbar { } } } - } + }) } } diff --git a/extensions/pagetop-bootsier/src/theme/navbar/item.rs b/extensions/pagetop-bootsier/src/theme/navbar/item.rs index 25ad3a0e..28e74cae 100644 --- a/extensions/pagetop-bootsier/src/theme/navbar/item.rs +++ b/extensions/pagetop-bootsier/src/theme/navbar/item.rs @@ -46,15 +46,15 @@ impl Component for Item { } } - fn prepare_component(&self, cx: &mut Context) -> Markup { - match self { + fn prepare_component(&self, cx: &mut Context) -> Result { + Ok(match self { Self::Void => html! {}, Self::Brand(brand) => html! { (brand.render(cx)) }, Self::Nav(nav) => { if let Some(nav) = nav.borrow() { let items = nav.items().render(cx); if items.is_empty() { - return html! {}; + return Ok(html! {}); } html! { ul id=[nav.id()] class=[nav.classes().get()] { @@ -70,7 +70,7 @@ impl Component for Item { (text.using(cx)) } }, - } + }) } } diff --git a/extensions/pagetop-bootsier/src/theme/offcanvas/component.rs b/extensions/pagetop-bootsier/src/theme/offcanvas/component.rs index ee175547..233a9821 100644 --- a/extensions/pagetop-bootsier/src/theme/offcanvas/component.rs +++ b/extensions/pagetop-bootsier/src/theme/offcanvas/component.rs @@ -62,8 +62,8 @@ impl Component for Offcanvas { }); } - fn prepare_component(&self, cx: &mut Context) -> Markup { - self.render_offcanvas(cx, None) + fn prepare_component(&self, cx: &mut Context) -> Result { + Ok(self.render_offcanvas(cx, None)) } } diff --git a/src/base/action/theme/prepare_render.rs b/src/base/action/theme/prepare_render.rs index 8cc362be..108fd4b8 100644 --- a/src/base/action/theme/prepare_render.rs +++ b/src/base/action/theme/prepare_render.rs @@ -6,7 +6,7 @@ use crate::prelude::*; /// los componentes. /// /// Recibe una referencia al componente `component` y una referencia mutable al contexto `cx`. -pub type FnPrepareRender = fn(component: &C, cx: &mut Context) -> Markup; +pub type FnPrepareRender = fn(component: &C, cx: &mut Context) -> Result; /// Ejecuta [`FnPrepareRender`] para preparar el renderizado de un componente. /// @@ -41,23 +41,25 @@ impl PrepareRender { } } - /// Despacha las acciones. Se detiene en cuanto una renderiza. + /// Despacha las acciones. Se detiene en cuanto una renderiza o produce un error. #[inline] - pub(crate) fn dispatch(component: &C, cx: &mut Context) -> Markup { - let mut render_component = html! {}; - dispatch_actions( + pub(crate) fn dispatch(component: &C, cx: &mut Context) -> Result { + let mut render_result: Result = Ok(html! {}); + dispatch_actions_until( &ActionKey::new( UniqueId::of::(), Some(cx.theme().type_id()), Some(UniqueId::of::()), None, ), - |action: &Self| { - if render_component.is_empty() { - render_component = (action.f)(component, cx); + |action: &Self| match &render_result { + Ok(markup) if markup.is_empty() => { + render_result = (action.f)(component, cx); + std::ops::ControlFlow::Continue(()) } + _ => std::ops::ControlFlow::Break(()), }, ); - render_component + render_result } } diff --git a/src/base/component/block.rs b/src/base/component/block.rs index 1e075e2e..3faf6636 100644 --- a/src/base/component/block.rs +++ b/src/base/component/block.rs @@ -29,23 +29,23 @@ impl Component for Block { self.alter_classes(ClassesOp::Prepend, "block"); } - fn prepare_component(&self, cx: &mut Context) -> Markup { + fn prepare_component(&self, cx: &mut Context) -> Result { let block_body = self.children().render(cx); if block_body.is_empty() { - return html! {}; + return Ok(html! {}); } let id = cx.required_id::(self.id()); - html! { + Ok(html! { div id=(id) class=[self.classes().get()] { @if let Some(title) = self.title().lookup(cx) { h2 class="block__title" { span { (title) } } } div class="block__body" { (block_body) } } - } + }) } } diff --git a/src/base/component/html.rs b/src/base/component/html.rs index ac0edb2f..3a55bd6e 100644 --- a/src/base/component/html.rs +++ b/src/base/component/html.rs @@ -52,8 +52,8 @@ impl Component for Html { Self::default() } - fn prepare_component(&self, cx: &mut Context) -> Markup { - self.html(cx) + fn prepare_component(&self, cx: &mut Context) -> Result { + Ok(self.html(cx)) } } diff --git a/src/base/component/intro.rs b/src/base/component/intro.rs index 378ca42a..1fd44e25 100644 --- a/src/base/component/intro.rs +++ b/src/base/component/intro.rs @@ -115,7 +115,7 @@ impl Component for Intro { )); } - fn prepare_component(&self, cx: &mut Context) -> Markup { + fn prepare_component(&self, cx: &mut Context) -> Result { if *self.opening() == IntroOpening::PageTop { cx.alter_assets(AssetsOp::AddJavaScript(JavaScript::on_load_async("intro-js", |cx| util::indoc!(r#" @@ -135,7 +135,7 @@ impl Component for Intro { ))); } - html! { + Ok(html! { div class="intro" { div class="intro-header" { section class="intro-header__body" { @@ -206,7 +206,7 @@ impl Component for Intro { } } } - } + }) } } diff --git a/src/base/component/poweredby.rs b/src/base/component/poweredby.rs index b9ffe61f..1b9ffd66 100644 --- a/src/base/component/poweredby.rs +++ b/src/base/component/poweredby.rs @@ -25,8 +25,8 @@ impl Component for PoweredBy { PoweredBy { copyright: Some(c) } } - fn prepare_component(&self, cx: &mut Context) -> Markup { - html! { + fn prepare_component(&self, cx: &mut Context) -> Result { + Ok(html! { div id=[self.id()] class="poweredby" { @if let Some(c) = self.copyright() { span class="poweredby__copyright" { (c) "." } " " @@ -35,7 +35,7 @@ impl Component for PoweredBy { (L10n::l("poweredby_pagetop").with_arg("pagetop_link", LINK).using(cx)) } } - } + }) } } diff --git a/src/core/action.rs b/src/core/action.rs index da7fb803..8eb724dd 100644 --- a/src/core/action.rs +++ b/src/core/action.rs @@ -12,7 +12,7 @@ use list::ActionsList; mod all; pub(crate) use all::add_action; -pub use all::dispatch_actions; +pub use all::{dispatch_actions, dispatch_actions_until}; /// Facilita la implementación del método [`actions()`](crate::core::extension::Extension::actions). /// diff --git a/src/core/action/all.rs b/src/core/action/all.rs index 7e7305b1..513b1140 100644 --- a/src/core/action/all.rs +++ b/src/core/action/all.rs @@ -72,3 +72,18 @@ where list.iter_map(f); } } + +/// Despacha las funciones asociadas a una [`ActionKey`] con posible salida anticipada. +/// +/// Funciona igual que [`dispatch_actions`], pero el *closure* puede devolver +/// [`std::ops::ControlFlow::Continue`] para continuar ejecutando la siguiente acción; o +/// [`std::ops::ControlFlow::Break`] para detener la iteración inmediatamente. +pub fn dispatch_actions_until(key: &ActionKey, f: F) +where + A: ActionDispatcher, + F: FnMut(&A) -> std::ops::ControlFlow<()>, +{ + if let Some(list) = ACTIONS.read().get(key) { + list.iter_try_map(f); + } +} diff --git a/src/core/action/list.rs b/src/core/action/list.rs index d60129c1..b8abff80 100644 --- a/src/core/action/list.rs +++ b/src/core/action/list.rs @@ -39,4 +39,21 @@ impl ActionsList { }) .collect(); } + + pub fn iter_try_map(&self, mut f: F) + where + A: ActionDispatcher, + F: FnMut(&A) -> std::ops::ControlFlow<()>, + { + let list = self.0.read(); + for a in list.iter().rev() { + if let Some(action) = (**a).downcast_ref::() { + if f(action).is_break() { + break; + } + } else { + trace::error!("Failed to downcast action of type {}", (**a).type_name()); + } + } + } } diff --git a/src/core/component.rs b/src/core/component.rs index 79d524cc..ecd6eda6 100644 --- a/src/core/component.rs +++ b/src/core/component.rs @@ -2,6 +2,9 @@ use crate::html::RoutePath; +mod error; +pub use error::ComponentError; + mod definition; pub use definition::{Component, ComponentRender}; @@ -38,8 +41,8 @@ pub use context::{AssetsOp, Context, ContextError, Contextual}; /// self.renderable.map_or(true, |f| f(cx)) /// } /// -/// fn prepare_component(&self, _cx: &mut Context) -> Markup { -/// html! { "Visible component" } +/// fn prepare_component(&self, _cx: &mut Context) -> Result { +/// Ok(html! { "Visible component" }) /// } /// } /// diff --git a/src/core/component/definition.rs b/src/core/component/definition.rs index 1992c2e7..611e9125 100644 --- a/src/core/component/definition.rs +++ b/src/core/component/definition.rs @@ -1,5 +1,5 @@ use crate::base::action; -use crate::core::component::Context; +use crate::core::component::{ComponentError, Context, Contextual}; use crate::core::{AnyInfo, TypeInfo}; use crate::html::{html, Markup}; @@ -14,11 +14,15 @@ pub trait ComponentRender { /// Interfaz común que debe implementar un componente renderizable en PageTop. /// -/// Se recomienda que los componentes deriven [`AutoDefault`](crate::AutoDefault). También deben -/// implementar explícitamente el método [`new()`](Self::new) y pueden sobrescribir los otros -/// métodos para personalizar su comportamiento. +/// Se recomienda que los componentes declaren sus campos como privados, que deriven +/// [`AutoDefault`](crate::AutoDefault) o implementen [`Default`] para inicializarlos por defecto, y +/// [`Getters`](crate::Getters) para acceder a sus datos. Deberán implementar explícitamente el +/// método [`new()`](Self::new) y podrán sobrescribir los demás métodos para personalizar su +/// comportamiento. pub trait Component: AnyInfo + ComponentRender + Send + Sync { /// Crea una nueva instancia del componente. + /// + /// Por convención suele devolver `Self::default()`. fn new() -> Self where Self: Sized; @@ -51,9 +55,9 @@ pub trait Component: AnyInfo + ComponentRender + Send + Sync { /// puede sobrescribirse para decidir dinámicamente si los componentes de este tipo se /// renderizan o no en función del contexto de renderizado. /// - /// También puede usarse junto con un alias de función como - /// ([`FnIsRenderable`](crate::core::component::FnIsRenderable)) para permitir que instancias - /// concretas del componente decidan si se renderizan o no. + /// 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: &mut Context) -> bool { true @@ -62,25 +66,28 @@ pub trait Component: AnyInfo + ComponentRender + Send + Sync { /// Configura el componente justo antes de preparar el renderizado. /// /// Este método puede sobrescribirse para modificar la estructura interna del componente o el - /// contexto antes de preparar la renderización del componente. Por defecto no hace nada. + /// contexto antes de renderizarlo. Por defecto no hace nada. #[allow(unused_variables)] fn setup_before_prepare(&mut self, cx: &mut Context) {} - /// Devuelve una representación renderizada del componente. + /// Devuelve el marcado HTML del componente usando el contexto proporcionado. /// /// Este método forma parte del ciclo de vida de los componentes y se invoca automáticamente - /// durante el proceso de construcción del documento. Puede sobrescribirse para generar - /// dinámicamente el contenido HTML con acceso al contexto de renderizado. + /// durante el proceso de construcción del documento. Cada componente lo implementa para generar + /// su propio contenido HTML. Los temas hijo pueden sobrescribir opcionalmente su resultado + /// mediante la acción [`PrepareRender`](crate::base::action::theme::PrepareRender). /// - /// Este método debe ser capaz de preparar el renderizado del componente con los métodos del - /// propio componente y el contexto proporcionado, no debería hacerlo accediendo directamente a - /// los campos de la estructura del componente. Es una forma de garantizar que los programadores - /// podrán sobrescribir este método sin preocuparse por los detalles internos del componente. + /// Se recomienda obtener los datos del componente a través de sus propios métodos para que los + /// temas hijo que implementen dicha acción puedan generar el nuevo HTML sin depender de los + /// detalles internos del componente. /// - /// Por defecto, devuelve un [`Markup`] vacío (`html! {}`). + /// Por defecto, devuelve un [`Markup`] vacío (`Ok(html! {})`). + /// + /// En caso de error, devuelve un [`ComponentError`] que puede incluir un marcado alternativo + /// (*fallback*) para sustituir al componente fallido. #[allow(unused_variables)] - fn prepare_component(&self, cx: &mut Context) -> Markup { - html! {} + fn prepare_component(&self, cx: &mut Context) -> Result { + Ok(html! {}) } } @@ -123,11 +130,33 @@ impl ComponentRender for C { action::component::BeforeRender::dispatch(self, cx); // Prepara el renderizado del componente. - let prepare = action::theme::PrepareRender::dispatch(self, cx); - let prepare = if prepare.is_empty() { - self.prepare_component(cx) - } else { - prepare + let prepare = match action::theme::PrepareRender::dispatch(self, cx) { + Ok(markup) if !markup.is_empty() => markup, + Ok(_) => match self.prepare_component(cx) { + Ok(markup) => markup, + Err(error) => { + crate::trace::error!( + path = cx.request().map(|r| r.path()).unwrap_or(""), + component = self.name(), + id = self.id().as_deref().unwrap_or(""), + source = "prepare_component", + "render failed, using fallback: {}", + error.message() + ); + error.into_fallback() + } + }, + Err(error) => { + crate::trace::error!( + path = cx.request().map(|r| r.path()).unwrap_or(""), + component = self.name(), + id = self.id().as_deref().unwrap_or(""), + source = "PrepareRender", + "render failed, using fallback: {}", + error.message() + ); + error.into_fallback() + } }; // Acciones específicas del tema después de renderizar el componente. diff --git a/src/core/component/error.rs b/src/core/component/error.rs new file mode 100644 index 00000000..50c042d0 --- /dev/null +++ b/src/core/component/error.rs @@ -0,0 +1,66 @@ +use crate::html::{html, Markup}; +use crate::{AutoDefault, Getters}; + +/// Error producido durante el renderizado de un componente. +/// +/// Se usa en [`Component::prepare_component()`](super::Component::prepare_component) para devolver +/// un [`Err`]. Puede incluir un marcado HTML alternativo para renderizar el componente de manera +/// diferente en caso de error. +/// +/// # Ejemplo +/// +/// ```rust +/// # use pagetop::prelude::*; +/// # struct MyComponent; +/// # impl Component for MyComponent { +/// # fn new() -> Self { MyComponent } +/// fn prepare_component(&self, _cx: &mut Context) -> Result { +/// Err(ComponentError::new("Database connection failed") +/// .with_fallback(html! { p class="error" { "Content temporarily unavailable." } })) +/// } +/// # } +/// ``` +#[derive(AutoDefault, Debug, Getters)] +pub struct ComponentError { + /// Mensaje descriptivo del error. + message: String, + /// Marcado HTML alternativo para mostrar si el componente falla. + fallback: Markup, +} + +impl ComponentError { + /// Crea un nuevo error para un componente con un marcado alternativo vacío. + pub fn new(message: impl Into) -> Self { + ComponentError { + message: message.into(), + fallback: html! {}, + } + } + + // **< ComponentError BUILDER >***************************************************************** + + /// Asigna el marcado HTML alternativo (*fallback*) que se mostrará si el componente falla. + /// + /// Si no se proporciona, no se renderizará nada del componente. + pub fn with_fallback(mut self, fallback: Markup) -> Self { + self.fallback = fallback; + self + } + + // **< ComponentError GETTERS >***************************************************************** + + /// Consume el error y devuelve su marcado alternativo. + /// + /// Se invoca internamente en [`ComponentRender`](crate::core::component::ComponentRender). + pub(crate) fn into_fallback(self) -> Markup { + self.fallback + } +} + +impl std::fmt::Display for ComponentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for ComponentError {} diff --git a/tests/html_markup.rs b/tests/html_markup.rs index ed8d7d73..4dae8b2c 100644 --- a/tests/html_markup.rs +++ b/tests/html_markup.rs @@ -10,15 +10,15 @@ struct TestMarkupComponent { impl Component for TestMarkupComponent { fn new() -> Self { - TestMarkupComponent::default() + Self::default() } fn is_renderable(&self, cx: &mut Context) -> bool { cx.param_or::("renderable", true) } - fn prepare_component(&self, _cx: &mut Context) -> Markup { - self.markup.clone() + fn prepare_component(&self, _cx: &mut Context) -> Result { + Ok(self.markup.clone()) } }