From 3e1bc0fb0e767d0da61a1e0877f526792cedf150 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Tue, 17 Mar 2026 20:04:26 +0100 Subject: [PATCH 1/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Cambia=20en=20prepare?= =?UTF-8?q?=5Fcomponent()=20el=20tipo=20devuelto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elimina `PrepareMarkup` como tipo de retorno de prepare_component() y de `FnPrepareRender`, sustituyéndolo directamente por `Markup`. Se elimina una capa innecesaria, ya que html! {} y html! { ... } cubren todos los casos que ofrecía `PrepareMarkup`. --- .gitignore | 2 + .../src/theme/container/component.rs | 28 ++-- .../src/theme/dropdown/component.rs | 8 +- .../src/theme/dropdown/item.rs | 24 +-- .../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 | 12 +- .../src/theme/image/component.rs | 10 +- .../src/theme/nav/component.rs | 8 +- .../pagetop-bootsier/src/theme/nav/item.rs | 24 +-- .../src/theme/navbar/brand.rs | 8 +- .../src/theme/navbar/component.rs | 8 +- .../pagetop-bootsier/src/theme/navbar/item.rs | 18 +-- .../src/theme/offcanvas/component.rs | 4 +- src/base/action/theme/prepare_render.rs | 6 +- 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/component.rs | 4 +- src/core/component/context.rs | 21 +++ src/core/component/definition.rs | 10 +- src/html.rs | 80 ---------- src/html/logo.rs | 6 +- tests/html_markup.rs | 107 +++++++++++++ tests/html_pm.rs | 144 ------------------ 28 files changed, 241 insertions(+), 335 deletions(-) create mode 100644 tests/html_markup.rs delete mode 100644 tests/html_pm.rs diff --git a/.gitignore b/.gitignore index 65db440e..ab19156b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ **/local.*.toml **/local.toml .env +.cargo +.vscode diff --git a/extensions/pagetop-bootsier/src/theme/container/component.rs b/extensions/pagetop-bootsier/src/theme/container/component.rs index b105abb1..38fc9e1f 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) -> PrepareMarkup { + fn prepare_component(&self, cx: &mut Context) -> Markup { let output = self.children().render(cx); if output.is_empty() { - return PrepareMarkup::None; + return html! {}; } let style = match self.container_width() { container::Width::FluidMax(w) if w.is_measurable() => { @@ -45,36 +45,36 @@ impl Component for Container { _ => None, }; match self.container_kind() { - container::Kind::Default => PrepareMarkup::With(html! { + container::Kind::Default => html! { div id=[self.id()] class=[self.classes().get()] style=[style] { (output) } - }), - container::Kind::Main => PrepareMarkup::With(html! { + }, + container::Kind::Main => html! { main id=[self.id()] class=[self.classes().get()] style=[style] { (output) } - }), - container::Kind::Header => PrepareMarkup::With(html! { + }, + container::Kind::Header => html! { header id=[self.id()] class=[self.classes().get()] style=[style] { (output) } - }), - container::Kind::Footer => PrepareMarkup::With(html! { + }, + container::Kind::Footer => html! { footer id=[self.id()] class=[self.classes().get()] style=[style] { (output) } - }), - container::Kind::Section => PrepareMarkup::With(html! { + }, + container::Kind::Section => html! { section id=[self.id()] class=[self.classes().get()] style=[style] { (output) } - }), - container::Kind::Article => PrepareMarkup::With(html! { + }, + container::Kind::Article => html! { article id=[self.id()] class=[self.classes().get()] style=[style] { (output) } - }), + }, } } } diff --git a/extensions/pagetop-bootsier/src/theme/dropdown/component.rs b/extensions/pagetop-bootsier/src/theme/dropdown/component.rs index 3e683f18..a5f17298 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) -> PrepareMarkup { + fn prepare_component(&self, cx: &mut Context) -> Markup { // Si no hay elementos en el menú, no se prepara. let items = self.items().render(cx); if items.is_empty() { - return PrepareMarkup::None; + return html! {}; } // Título opcional para el menú desplegable. let title = self.title().using(cx); - PrepareMarkup::With(html! { + 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 91570636..2bc3bf02 100644 --- a/extensions/pagetop-bootsier/src/theme/dropdown/item.rs +++ b/extensions/pagetop-bootsier/src/theme/dropdown/item.rs @@ -62,17 +62,17 @@ impl Component for Item { self.id.get() } - fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { + fn prepare_component(&self, cx: &mut Context) -> Markup { match self.item_kind() { - ItemKind::Void => PrepareMarkup::None, + ItemKind::Void => html! {}, - ItemKind::Label(label) => PrepareMarkup::With(html! { + ItemKind::Label(label) => html! { li id=[self.id()] class=[self.classes().get()] { span class="dropdown-item-text" { (label.using(cx)) } } - }), + }, ItemKind::Link { label, @@ -100,7 +100,7 @@ impl Component for Item { let aria_disabled = disabled.then_some("true"); let tabindex = disabled.then_some("-1"); - PrepareMarkup::With(html! { + html! { li id=[self.id()] class=[self.classes().get()] { a class=(classes) @@ -114,7 +114,7 @@ impl Component for Item { (label.using(cx)) } } - }) + } } ItemKind::Button { label, disabled } => { @@ -126,7 +126,7 @@ impl Component for Item { let aria_disabled = disabled.then_some("true"); let disabled_attr = disabled.then_some("disabled"); - PrepareMarkup::With(html! { + html! { li id=[self.id()] class=[self.classes().get()] { button class=(classes) @@ -137,20 +137,20 @@ impl Component for Item { (label.using(cx)) } } - }) + } } - ItemKind::Header(label) => PrepareMarkup::With(html! { + ItemKind::Header(label) => html! { li id=[self.id()] class=[self.classes().get()] { h6 class="dropdown-header" { (label.using(cx)) } } - }), + }, - ItemKind::Divider => PrepareMarkup::With(html! { + 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 2da46e3f..b3a814a1 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) -> PrepareMarkup { + fn prepare_component(&self, cx: &mut Context) -> Markup { let method = match self.method() { form::Method::Post => Some("post"), form::Method::Get => None, }; - PrepareMarkup::With(html! { + 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 36092ca2..f731e566 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) -> PrepareMarkup { - PrepareMarkup::With(html! { + fn prepare_component(&self, cx: &mut Context) -> Markup { + 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 bf76e082..019c98c0 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) -> PrepareMarkup { + fn prepare_component(&self, cx: &mut Context) -> Markup { let id = self.name().get().map(|name| util::join!("edit-", name)); - PrepareMarkup::With(html! { + 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 96de2049..dfce3aa9 100644 --- a/extensions/pagetop-bootsier/src/theme/icon.rs +++ b/extensions/pagetop-bootsier/src/theme/icon.rs @@ -35,26 +35,26 @@ impl Component for Icon { } } - fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { + fn prepare_component(&self, cx: &mut Context) -> Markup { match self.icon_kind() { - IconKind::None => PrepareMarkup::None, + IconKind::None => html! {}, IconKind::Font(_) => { let aria_label = self.aria_label().lookup(cx); let has_label = aria_label.is_some(); - PrepareMarkup::With(html! { + html! { i class=[self.classes().get()] role=[has_label.then_some("img")] aria-label=[aria_label] aria-hidden=[(!has_label).then_some("true")] {} - }) + } } IconKind::Svg { shapes, viewbox } => { let aria_label = self.aria_label().lookup(cx); let has_label = aria_label.is_some(); let viewbox = viewbox.get().unwrap_or_else(|| DEFAULT_VIEWBOX.to_string()); - PrepareMarkup::With(html! { + html! { svg xmlns="http://www.w3.org/2000/svg" viewBox=(viewbox) @@ -67,7 +67,7 @@ impl Component for Icon { { (shapes) } - }) + } } } } diff --git a/extensions/pagetop-bootsier/src/theme/image/component.rs b/extensions/pagetop-bootsier/src/theme/image/component.rs index 4362a25f..de2866cc 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) -> PrepareMarkup { + fn prepare_component(&self, cx: &mut Context) -> Markup { 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 PrepareMarkup::With(html! { + return 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), }; - PrepareMarkup::With(html! { + 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 00703c79..48cef0a3 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) -> PrepareMarkup { + fn prepare_component(&self, cx: &mut Context) -> Markup { let items = self.items().render(cx); if items.is_empty() { - return PrepareMarkup::None; + return html! {}; } - PrepareMarkup::With(html! { + 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 06bb7353..be9b2932 100644 --- a/extensions/pagetop-bootsier/src/theme/nav/item.rs +++ b/extensions/pagetop-bootsier/src/theme/nav/item.rs @@ -99,17 +99,17 @@ impl Component for Item { self.alter_classes(ClassesOp::Prepend, self.item_kind().to_class()); } - fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { + fn prepare_component(&self, cx: &mut Context) -> Markup { match self.item_kind() { - ItemKind::Void => PrepareMarkup::None, + ItemKind::Void => html! {}, - ItemKind::Label(label) => PrepareMarkup::With(html! { + ItemKind::Label(label) => html! { li id=[self.id()] class=[self.classes().get()] { span class="nav-link disabled" aria-disabled="true" { (label.using(cx)) } } - }), + }, ItemKind::Link { label, @@ -136,7 +136,7 @@ impl Component for Item { let aria_current = (href.is_some() && is_current).then_some("page"); let aria_disabled = (*disabled).then_some("true"); - PrepareMarkup::With(html! { + html! { li id=[self.id()] class=[self.classes().get()] { a class=(classes) @@ -149,27 +149,27 @@ impl Component for Item { (label.using(cx)) } } - }) + } } - ItemKind::Html(html) => PrepareMarkup::With(html! { + ItemKind::Html(html) => html! { li id=[self.id()] class=[self.classes().get()] { (html.render(cx)) } - }), + }, ItemKind::Dropdown(menu) => { if let Some(dd) = menu.borrow() { let items = dd.items().render(cx); if items.is_empty() { - return PrepareMarkup::None; + return html! {}; } let title = dd.title().lookup(cx).unwrap_or_else(|| { L10n::t("dropdown", &LOCALES_BOOTSIER) .lookup(cx) .unwrap_or_else(|| "Dropdown".to_string()) }); - PrepareMarkup::With(html! { + html! { li id=[self.id()] class=[self.classes().get()] { a class="nav-link dropdown-toggle" @@ -184,9 +184,9 @@ impl Component for Item { (items) } } - }) + } } else { - PrepareMarkup::None + html! {} } } } diff --git a/extensions/pagetop-bootsier/src/theme/navbar/brand.rs b/extensions/pagetop-bootsier/src/theme/navbar/brand.rs index b39f8032..15a7c57f 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) -> PrepareMarkup { + fn prepare_component(&self, cx: &mut Context) -> Markup { let image = self.image().render(cx); let title = self.title().using(cx); if title.is_empty() && image.is_empty() { - return PrepareMarkup::None; + return html! {}; } let slogan = self.slogan().using(cx); - PrepareMarkup::With(html! { + 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 77aa5e57..36ce7961 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) -> PrepareMarkup { + fn prepare_component(&self, cx: &mut Context) -> Markup { // 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 PrepareMarkup::None; + return html! {}; } // Asegura que la barra tiene un `id` para poder asociarlo al colapso/offcanvas. let id = cx.required_id::(self.id()); - PrepareMarkup::With(html! { + 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 376ba6db..89b7c45e 100644 --- a/extensions/pagetop-bootsier/src/theme/navbar/item.rs +++ b/extensions/pagetop-bootsier/src/theme/navbar/item.rs @@ -46,30 +46,30 @@ impl Component for Item { } } - fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { + fn prepare_component(&self, cx: &mut Context) -> Markup { match self { - Self::Void => PrepareMarkup::None, - Self::Brand(brand) => PrepareMarkup::With(html! { (brand.render(cx)) }), + 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 PrepareMarkup::None; + return html! {}; } - PrepareMarkup::With(html! { + html! { ul id=[nav.id()] class=[nav.classes().get()] { (items) } - }) + } } else { - PrepareMarkup::None + html! {} } } - Self::Text(text) => PrepareMarkup::With(html! { + Self::Text(text) => html! { span class="navbar-text" { (text.using(cx)) } - }), + }, } } } diff --git a/extensions/pagetop-bootsier/src/theme/offcanvas/component.rs b/extensions/pagetop-bootsier/src/theme/offcanvas/component.rs index fe990681..2e35f96d 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) -> PrepareMarkup { - PrepareMarkup::With(self.render_offcanvas(cx, None)) + fn prepare_component(&self, cx: &mut Context) -> Markup { + self.render_offcanvas(cx, None) } } diff --git a/src/base/action/theme/prepare_render.rs b/src/base/action/theme/prepare_render.rs index 8e46e8cc..8cc362be 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) -> PrepareMarkup; +pub type FnPrepareRender = fn(component: &C, cx: &mut Context) -> Markup; /// Ejecuta [`FnPrepareRender`] para preparar el renderizado de un componente. /// @@ -43,8 +43,8 @@ impl PrepareRender { /// Despacha las acciones. Se detiene en cuanto una renderiza. #[inline] - pub(crate) fn dispatch(component: &C, cx: &mut Context) -> PrepareMarkup { - let mut render_component = PrepareMarkup::None; + pub(crate) fn dispatch(component: &C, cx: &mut Context) -> Markup { + let mut render_component = html! {}; dispatch_actions( &ActionKey::new( UniqueId::of::(), diff --git a/src/base/component/block.rs b/src/base/component/block.rs index 77eacfec..c10c6fa2 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) -> PrepareMarkup { + fn prepare_component(&self, cx: &mut Context) -> Markup { let block_body = self.children().render(cx); if block_body.is_empty() { - return PrepareMarkup::None; + return html! {}; } let id = cx.required_id::(self.id()); - PrepareMarkup::With(html! { + 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 ae8e1a33..58d16857 100644 --- a/src/base/component/html.rs +++ b/src/base/component/html.rs @@ -42,8 +42,8 @@ impl Component for Html { Self::default() } - fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { - PrepareMarkup::With(self.html(cx)) + fn prepare_component(&self, cx: &mut Context) -> Markup { + self.html(cx) } } diff --git a/src/base/component/intro.rs b/src/base/component/intro.rs index 715d4839..793ad8b7 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) -> PrepareMarkup { + fn prepare_component(&self, cx: &mut Context) -> Markup { if *self.opening() == IntroOpening::PageTop { cx.alter_assets(ContextOp::AddJavaScript(JavaScript::on_load_async("intro-js", |cx| util::indoc!(r#" @@ -135,7 +135,7 @@ impl Component for Intro { ))); } - PrepareMarkup::With(html! { + 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 e90263c9..61f7ab84 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) -> PrepareMarkup { - PrepareMarkup::With(html! { + fn prepare_component(&self, cx: &mut Context) -> Markup { + 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 9f81cd52..da7fb803 100644 --- a/src/core/action.rs +++ b/src/core/action.rs @@ -35,7 +35,7 @@ pub use all::dispatch_actions; /// impl Theme for MyTheme {} /// /// fn before_render_button(c: &mut Button, cx: &mut Context) { todo!() } -/// fn render_error404(c: &Error404, cx: &mut Context) -> PrepareMarkup { todo!() } +/// fn render_error404(c: &Error404, cx: &mut Context) -> Markup { todo!() } /// ``` #[macro_export] macro_rules! actions_boxed { diff --git a/src/core/component.rs b/src/core/component.rs index ba35fe48..0eb347e5 100644 --- a/src/core/component.rs +++ b/src/core/component.rs @@ -38,8 +38,8 @@ pub use context::{Context, ContextError, ContextOp, Contextual}; /// self.renderable.map_or(true, |f| f(cx)) /// } /// -/// fn prepare_component(&self, _cx: &mut Context) -> PrepareMarkup { -/// PrepareMarkup::Escaped("Visible component".into()) +/// fn prepare_component(&self, _cx: &mut Context) -> Markup { +/// html! { "Visible component" } /// } /// } /// diff --git a/src/core/component/context.rs b/src/core/component/context.rs index 5ac1ebe8..3e159c26 100644 --- a/src/core/component/context.rs +++ b/src/core/component/context.rs @@ -10,6 +10,7 @@ use crate::{builder_fn, util, CowStr}; use std::any::Any; use std::collections::HashMap; +use std::fmt; /// Operaciones para modificar recursos asociados al [`Context`] de un documento. pub enum ContextOp { @@ -45,6 +46,26 @@ pub enum ContextError { }, } +impl fmt::Display for ContextError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ContextError::ParamNotFound => { + write!(f, "parameter not found") + } + ContextError::ParamTypeMismatch { + key, + expected, + saved, + } => write!( + f, + "type mismatch for parameter \"{key}\": expected \"{expected}\", found \"{saved}\"" + ), + } + } +} + +impl std::error::Error for ContextError {} + /// Interfaz para gestionar el **contexto de renderizado** de un documento HTML. /// /// `Contextual` extiende [`LangId`] para establecer el idioma del documento y añade métodos para: diff --git a/src/core/component/definition.rs b/src/core/component/definition.rs index 13b03851..1992c2e7 100644 --- a/src/core/component/definition.rs +++ b/src/core/component/definition.rs @@ -1,7 +1,7 @@ use crate::base::action; use crate::core::component::Context; use crate::core::{AnyInfo, TypeInfo}; -use crate::html::{html, Markup, PrepareMarkup}; +use crate::html::{html, Markup}; /// Define la función de renderizado para todos los componentes. /// @@ -77,10 +77,10 @@ pub trait Component: AnyInfo + ComponentRender + Send + Sync { /// 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. /// - /// Por defecto, devuelve [`PrepareMarkup::None`]. + /// Por defecto, devuelve un [`Markup`] vacío (`html! {}`). #[allow(unused_variables)] - fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { - PrepareMarkup::None + fn prepare_component(&self, cx: &mut Context) -> Markup { + html! {} } } @@ -137,6 +137,6 @@ impl ComponentRender for C { action::component::AfterRender::dispatch(self, cx); // Devuelve el marcado final. - prepare.render() + prepare } } diff --git a/src/html.rs b/src/html.rs index ea718a34..21809c08 100644 --- a/src/html.rs +++ b/src/html.rs @@ -1,7 +1,5 @@ //! HTML en código. -use crate::AutoDefault; - mod maud; pub use maud::{display, html, html_private, Escaper, Markup, PreEscaped, DOCTYPE}; @@ -29,81 +27,3 @@ pub use classes::{Classes, ClassesOp}; mod unit; pub use unit::UnitValue; - -// **< HTML PrepareMarkup >************************************************************************* - -/// Prepara contenido HTML para su conversión a [`Markup`]. -/// -/// Este tipo encapsula distintos orígenes de contenido HTML (texto plano, HTML sin escapar o -/// fragmentos ya procesados) para renderizarlos de forma homogénea en plantillas, sin interferir -/// con el uso estándar de [`Markup`]. -/// -/// # Ejemplo -/// -/// ```rust -/// # use pagetop::prelude::*; -/// // Texto normal, se escapa automáticamente para evitar inyección de HTML. -/// let fragment = PrepareMarkup::Escaped("Hola mundo".to_string()); -/// assert_eq!(fragment.into_string(), "Hola <b>mundo</b>"); -/// -/// // HTML literal, se inserta directamente, sin escapado adicional. -/// let raw_html = PrepareMarkup::Raw("negrita".to_string()); -/// assert_eq!(raw_html.into_string(), "negrita"); -/// -/// // Fragmento ya preparado con la macro `html!`. -/// let prepared = PrepareMarkup::With(html! { -/// h2 { "Título de ejemplo" } -/// p { "Este es un párrafo con contenido dinámico." } -/// }); -/// assert_eq!( -/// prepared.into_string(), -/// "

Título de ejemplo

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

" -/// ); -/// ``` -#[derive(AutoDefault, Clone)] -pub enum PrepareMarkup { - /// No se genera contenido HTML (equivale a `html! {}`). - #[default] - None, - /// Texto plano que se **escapará automáticamente** para que no sea interpretado como HTML. - /// - /// Úsalo con textos que provengan de usuarios u otras fuentes externas para garantizar la - /// seguridad contra inyección de código. - Escaped(String), - /// HTML literal que se inserta **sin escapado adicional**. - /// - /// Úsalo únicamente para contenido generado de forma confiable o controlada, ya que cualquier - /// etiqueta o script incluido será renderizado directamente en el documento. - Raw(String), - /// Fragmento HTML ya preparado como [`Markup`], listo para insertarse directamente. - /// - /// Normalmente proviene de expresiones `html! { ... }`. - With(Markup), -} - -impl PrepareMarkup { - /// Devuelve `true` si el contenido está vacío y no generará HTML al renderizar. - pub fn is_empty(&self) -> bool { - match self { - PrepareMarkup::None => true, - PrepareMarkup::Escaped(text) => text.is_empty(), - PrepareMarkup::Raw(string) => string.is_empty(), - PrepareMarkup::With(markup) => markup.is_empty(), - } - } - - /// Convierte el contenido en una cadena HTML renderizada. Usar sólo para pruebas o depuración. - pub fn into_string(&self) -> String { - self.render().into_string() - } - - /// Integra el renderizado fácilmente en la macro [`html!`]. - pub(crate) fn render(&self) -> Markup { - match self { - PrepareMarkup::None => html! {}, - PrepareMarkup::Escaped(text) => html! { (text) }, - PrepareMarkup::Raw(string) => html! { (PreEscaped(string)) }, - PrepareMarkup::With(markup) => html! { (markup) }, - } - } -} diff --git a/src/html/logo.rs b/src/html/logo.rs index fd604414..d5dcaa0b 100644 --- a/src/html/logo.rs +++ b/src/html/logo.rs @@ -9,8 +9,8 @@ use crate::AutoDefault; /// /// ```rust /// # use pagetop::prelude::*; -/// fn render_logo(cx: &mut Context) -> PrepareMarkup { -/// PrepareMarkup::With(html! { +/// fn render_logo(cx: &mut Context) -> Markup { +/// html! { /// div class="logo_color" { /// (PageTopSvg::Color.render(cx)) /// } @@ -23,7 +23,7 @@ use crate::AutoDefault; /// div class="line_red" { /// (PageTopSvg::LineRGB(255, 0, 0).render(cx)) /// } -/// }) +/// } /// }; /// ``` diff --git a/tests/html_markup.rs b/tests/html_markup.rs new file mode 100644 index 00000000..ed8d7d73 --- /dev/null +++ b/tests/html_markup.rs @@ -0,0 +1,107 @@ +use pagetop::prelude::*; + +/// Componente mínimo para probar `Markup` pasando por el ciclo real de renderizado de componentes +/// (`ComponentRender`). El parámetro de contexto `"renderable"` se usará para controlar si el +/// componente se renderiza (`true` por defecto). +#[derive(AutoDefault)] +struct TestMarkupComponent { + markup: Markup, +} + +impl Component for TestMarkupComponent { + fn new() -> Self { + TestMarkupComponent::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() + } +} + +// **< Comportamiento de Markup >******************************************************************* + +#[pagetop::test] +async fn string_in_html_macro_escapes_html_entities() { + let markup = html! { ("& \" ' ") }; + assert_eq!(markup.into_string(), "<b>& " ' </b>"); +} + +#[pagetop::test] +async fn preescaped_in_html_macro_is_inserted_verbatim() { + let markup = html! { (PreEscaped("bold")) }; + assert_eq!(markup.into_string(), "bold"); +} + +#[pagetop::test] +async fn unicode_is_preserved_in_markup() { + // Texto con acentos y emojis: sólo se escapan los signos HTML. + let esc = html! { ("Hello, tomorrow coffee ☕ & donuts!") }; + assert_eq!(esc.into_string(), "Hello, tomorrow coffee ☕ & donuts!"); + + // PreEscaped debe pasar íntegro. + let raw = html! { (PreEscaped("Title — section © 2025")) }; + assert_eq!(raw.into_string(), "Title — section © 2025"); +} + +#[pagetop::test] +async fn markup_is_empty_semantics() { + assert!(html! {}.is_empty()); + + assert!(html! { ("") }.is_empty()); + assert!(!html! { ("x") }.is_empty()); + + assert!(html! { (PreEscaped(String::new())) }.is_empty()); + assert!(!html! { (PreEscaped("a")) }.is_empty()); + + assert!(html! { (String::new()) }.is_empty()); + + assert!(!html! { span { "!" } }.is_empty()); + + // Espacios NO se consideran vacíos. + assert!(!html! { (" ") }.is_empty()); + assert!(!html! { (PreEscaped(" ")) }.is_empty()); +} + +// **< Markup a través del ciclo de componente >**************************************************** + +#[pagetop::test] +async fn non_renderable_component_produces_empty_markup() { + let mut cx = Context::default().with_param("renderable", false); + let mut comp = TestMarkupComponent { + markup: html! { p { "Should never be rendered" } }, + }; + assert_eq!(comp.render(&mut cx).into_string(), ""); +} + +#[pagetop::test] +async fn markup_from_component_equals_markup_reinjected_in_html_macro() { + let cases = [ + html! {}, + html! { ("x") }, + html! { (PreEscaped("x")) }, + html! { b { "x" } }, + ]; + + for markup in cases { + // Vía 1: renderizamos a través del ciclo de componente. + let via_component = { + let mut cx = Context::default(); + let mut comp = TestMarkupComponent { + markup: markup.clone(), + }; + comp.render(&mut cx).into_string() + }; + + // Vía 2: reinyectamos el Markup en `html!` directamente. + let via_macro = html! { (markup) }.into_string(); + + assert_eq!( + via_component, via_macro, + "The output of component render and (Markup) inside html! must match" + ); + } +} diff --git a/tests/html_pm.rs b/tests/html_pm.rs deleted file mode 100644 index 615ea470..00000000 --- a/tests/html_pm.rs +++ /dev/null @@ -1,144 +0,0 @@ -use pagetop::prelude::*; - -/// Componente mínimo para probar `PrepareMarkup` pasando por el ciclo real -/// de renderizado de componentes (`ComponentRender`). -#[derive(AutoDefault)] -struct TestPrepareComponent { - pm: PrepareMarkup, -} - -impl Component for TestPrepareComponent { - fn new() -> Self { - Self { - pm: PrepareMarkup::None, - } - } - - fn prepare_component(&self, _cx: &mut Context) -> PrepareMarkup { - self.pm.clone() - } -} - -impl TestPrepareComponent { - fn render_pm(pm: PrepareMarkup) -> String { - let mut c = TestPrepareComponent { pm }; - c.render(&mut Context::default()).into_string() - } -} - -#[pagetop::test] -async fn prepare_markup_none_is_empty_string() { - assert_eq!(PrepareMarkup::None.into_string(), ""); -} - -#[pagetop::test] -async fn prepare_markup_escaped_escapes_html_and_ampersands() { - let pm = PrepareMarkup::Escaped("& \" ' ".to_string()); - assert_eq!(pm.into_string(), "<b>& " ' </b>"); -} - -#[pagetop::test] -async fn prepare_markup_raw_is_inserted_verbatim() { - let pm = PrepareMarkup::Raw("bold".to_string()); - assert_eq!(pm.into_string(), "bold"); -} - -#[pagetop::test] -async fn prepare_markup_with_keeps_structure() { - let pm = PrepareMarkup::With(html! { - h2 { "Sample title" } - p { "This is a paragraph." } - }); - assert_eq!( - pm.into_string(), - "

Sample title

This is a paragraph.

" - ); -} - -#[pagetop::test] -async fn prepare_markup_unicode_is_preserved() { - // Texto con acentos y emojis debe conservarse (salvo el escape HTML de signos). - let esc = PrepareMarkup::Escaped("Hello, tomorrow coffee ☕ & donuts!".into()); - assert_eq!(esc.into_string(), "Hello, tomorrow coffee ☕ & donuts!"); - - // Raw debe pasar íntegro. - let raw = PrepareMarkup::Raw("Title — section © 2025".into()); - assert_eq!(raw.into_string(), "Title — section © 2025"); -} - -#[pagetop::test] -async fn prepare_markup_is_empty_semantics() { - assert!(PrepareMarkup::None.is_empty()); - - assert!(PrepareMarkup::Escaped(String::new()).is_empty()); - assert!(PrepareMarkup::Escaped("".to_string()).is_empty()); - assert!(!PrepareMarkup::Escaped("x".to_string()).is_empty()); - - assert!(PrepareMarkup::Raw(String::new()).is_empty()); - assert!(PrepareMarkup::Raw("".to_string()).is_empty()); - assert!(!PrepareMarkup::Raw("a".into()).is_empty()); - - assert!(PrepareMarkup::With(html! {}).is_empty()); - assert!(!PrepareMarkup::With(html! { span { "!" } }).is_empty()); - - // Ojo: espacios NO deberían considerarse vacíos (comportamiento actual). - assert!(!PrepareMarkup::Escaped(" ".into()).is_empty()); - assert!(!PrepareMarkup::Raw(" ".into()).is_empty()); -} - -#[pagetop::test] -async fn prepare_markup_does_not_double_escape_when_markup_is_reinjected_in_html_macro() { - let mut cx = Context::default(); - - // Escaped: dentro de `html!` no debe volver a escaparse. - let mut comp = TestPrepareComponent { - pm: PrepareMarkup::Escaped("x".into()), - }; - let markup = comp.render(&mut cx); // Markup - let wrapped_escaped = html! { div { (markup) } }.into_string(); - assert_eq!(wrapped_escaped, "
<i>x</i>
"); - - // Raw: tampoco debe escaparse al integrarlo. - let mut comp = TestPrepareComponent { - pm: PrepareMarkup::Raw("x".into()), - }; - let markup = comp.render(&mut cx); - let wrapped_raw = html! { div { (markup) } }.into_string(); - assert_eq!(wrapped_raw, "
x
"); - - // With: debe incrustar el Markup tal cual. - let mut comp = TestPrepareComponent { - pm: PrepareMarkup::With(html! { span.title { "ok" } }), - }; - let markup = comp.render(&mut cx); - let wrapped_with = html! { div { (markup) } }.into_string(); - assert_eq!(wrapped_with, "
ok
"); -} - -#[pagetop::test] -async fn prepare_markup_equivalence_between_component_render_and_markup_reinjected_in_html_macro() { - let cases = [ - PrepareMarkup::None, - PrepareMarkup::Escaped("x".into()), - PrepareMarkup::Raw("x".into()), - PrepareMarkup::With(html! { b { "x" } }), - ]; - - for pm in cases { - // Vía 1: renderizamos y obtenemos directamente el String. - let via_component = TestPrepareComponent::render_pm(pm.clone()); - - // Vía 2: renderizamos, reinyectamos el Markup en `html!` y volvemos a obtener String. - let via_macro = { - let mut cx = Context::default(); - let mut comp = TestPrepareComponent { pm }; - let markup = comp.render(&mut cx); - html! { (markup) }.into_string() - }; - - assert_eq!( - via_component, via_macro, - "The output of component render and (Markup) inside html! must match" - ); - } -} From c3feff9efd5c5d01ce0845325a1266e89c7a2b9c Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sat, 21 Mar 2026 11:12:46 +0100 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=9A=9A=20Renombra=20`ContextOp`=20a?= =?UTF-8?q?=20`AssetsOp`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El nombre anterior era ambiguo (podría referirse a cualquier operación del contexto); `AssetsOp` describe exactamente lo que hace: operar sobre los recursos del documento. --- extensions/pagetop-aliner/src/lib.rs | 6 ++--- extensions/pagetop-bootsier/src/lib.rs | 4 +-- src/base/component/intro.rs | 4 +-- src/base/theme/basic.rs | 4 +-- src/core/component.rs | 2 +- src/core/component/context.rs | 34 +++++++++++++------------- src/lib.rs | 6 ++--- src/response/page.rs | 4 +-- 8 files changed, 32 insertions(+), 32 deletions(-) diff --git a/extensions/pagetop-aliner/src/lib.rs b/extensions/pagetop-aliner/src/lib.rs index 04b5ad1a..30621b21 100644 --- a/extensions/pagetop-aliner/src/lib.rs +++ b/extensions/pagetop-aliner/src/lib.rs @@ -105,17 +105,17 @@ impl Extension for Aliner { impl Theme for Aliner { fn before_render_page_body(&self, page: &mut Page) { - page.alter_assets(ContextOp::AddStyleSheet( + page.alter_assets(AssetsOp::AddStyleSheet( StyleSheet::from("/css/normalize.css") .with_version("8.0.1") .with_weight(-99), )) - .alter_assets(ContextOp::AddStyleSheet( + .alter_assets(AssetsOp::AddStyleSheet( StyleSheet::from("/css/basic.css") .with_version(PAGETOP_VERSION) .with_weight(-99), )) - .alter_assets(ContextOp::AddStyleSheet( + .alter_assets(AssetsOp::AddStyleSheet( StyleSheet::from("/aliner/css/styles.css") .with_version(env!("CARGO_PKG_VERSION")) .with_weight(-99), diff --git a/extensions/pagetop-bootsier/src/lib.rs b/extensions/pagetop-bootsier/src/lib.rs index 5c88959a..0281fe7a 100644 --- a/extensions/pagetop-bootsier/src/lib.rs +++ b/extensions/pagetop-bootsier/src/lib.rs @@ -151,12 +151,12 @@ impl Theme for Bootsier { } fn before_render_page_body(&self, page: &mut Page) { - page.alter_assets(ContextOp::AddStyleSheet( + page.alter_assets(AssetsOp::AddStyleSheet( StyleSheet::from("/bootsier/bs/bootstrap.min.css") .with_version(BOOTSTRAP_VERSION) .with_weight(-90), )) - .alter_assets(ContextOp::AddJavaScript( + .alter_assets(AssetsOp::AddJavaScript( JavaScript::defer("/bootsier/js/bootstrap.bundle.min.js") .with_version(BOOTSTRAP_VERSION) .with_weight(-90), diff --git a/src/base/component/intro.rs b/src/base/component/intro.rs index 793ad8b7..fd68d2bc 100644 --- a/src/base/component/intro.rs +++ b/src/base/component/intro.rs @@ -110,14 +110,14 @@ impl Component for Intro { } fn setup_before_prepare(&mut self, cx: &mut Context) { - cx.alter_assets(ContextOp::AddStyleSheet( + cx.alter_assets(AssetsOp::AddStyleSheet( StyleSheet::from("/css/intro.css").with_version(PAGETOP_VERSION), )); } fn prepare_component(&self, cx: &mut Context) -> Markup { if *self.opening() == IntroOpening::PageTop { - cx.alter_assets(ContextOp::AddJavaScript(JavaScript::on_load_async("intro-js", |cx| + cx.alter_assets(AssetsOp::AddJavaScript(JavaScript::on_load_async("intro-js", |cx| util::indoc!(r#" try { const resp = await fetch("https://crates.io/api/v1/crates/pagetop"); diff --git a/src/base/theme/basic.rs b/src/base/theme/basic.rs index ca3c4a82..858f5d2b 100644 --- a/src/base/theme/basic.rs +++ b/src/base/theme/basic.rs @@ -12,12 +12,12 @@ impl Extension for Basic { impl Theme for Basic { fn before_render_page_body(&self, page: &mut Page) { - page.alter_assets(ContextOp::AddStyleSheet( + page.alter_assets(AssetsOp::AddStyleSheet( StyleSheet::from("/css/normalize.css") .with_version("8.0.1") .with_weight(-99), )) - .alter_assets(ContextOp::AddStyleSheet( + .alter_assets(AssetsOp::AddStyleSheet( StyleSheet::from("/css/basic.css") .with_version(PAGETOP_VERSION) .with_weight(-99), diff --git a/src/core/component.rs b/src/core/component.rs index 0eb347e5..79d524cc 100644 --- a/src/core/component.rs +++ b/src/core/component.rs @@ -11,7 +11,7 @@ pub use children::{Child, ChildOp}; pub use children::{Typed, TypedOp}; mod context; -pub use context::{Context, ContextError, ContextOp, Contextual}; +pub use context::{AssetsOp, Context, ContextError, Contextual}; /// Alias de función (*callback*) para **determinar si un componente se renderiza o no**. /// diff --git a/src/core/component/context.rs b/src/core/component/context.rs index 3e159c26..e2a220b7 100644 --- a/src/core/component/context.rs +++ b/src/core/component/context.rs @@ -13,7 +13,7 @@ use std::collections::HashMap; use std::fmt; /// Operaciones para modificar recursos asociados al [`Context`] de un documento. -pub enum ContextOp { +pub enum AssetsOp { /// Define el *favicon* del documento. Sobrescribe cualquier valor anterior. SetFavicon(Option), /// Define el *favicon* solo si no se ha establecido previamente. @@ -73,7 +73,7 @@ impl std::error::Error for ContextError {} /// - Almacenar la **petición HTTP** de origen. /// - Seleccionar el **tema** y la **plantilla** de renderizado. /// - Administrar **recursos** del documento como el icono [`Favicon`], las hojas de estilo -/// [`StyleSheet`] o los scripts [`JavaScript`] mediante [`ContextOp`]. +/// [`StyleSheet`] o los scripts [`JavaScript`] mediante [`AssetsOp`]. /// - Leer y mantener **parámetros dinámicos tipados** de contexto. /// - Generar **identificadores únicos** por tipo de componente. /// @@ -89,9 +89,9 @@ impl std::error::Error for ContextError {} /// cx.with_langid(&Locale::resolve("es-ES")) /// .with_theme(&Aliner) /// .with_template(&DefaultTemplate::Standard) -/// .with_assets(ContextOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) -/// .with_assets(ContextOp::AddStyleSheet(StyleSheet::from("/css/app.css"))) -/// .with_assets(ContextOp::AddJavaScript(JavaScript::defer("/js/app.js"))) +/// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) +/// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/app.css"))) +/// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/app.js"))) /// .with_param("usuario_id", 42_i32) /// } /// ``` @@ -118,9 +118,9 @@ pub trait Contextual: LangId { #[builder_fn] fn with_param(self, key: &'static str, value: T) -> Self; - /// Define los recursos del contexto usando [`ContextOp`]. + /// Define los recursos del contexto usando [`AssetsOp`]. #[builder_fn] - fn with_assets(self, op: ContextOp) -> Self; + fn with_assets(self, op: AssetsOp) -> Self; /// Opera con [`ChildOp`] en una región del documento. #[builder_fn] @@ -194,11 +194,11 @@ pub trait Contextual: LangId { /// // Establece el tema para renderizar. /// .with_theme(&Aliner) /// // Asigna un favicon. -/// .with_assets(ContextOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) +/// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) /// // Añade una hoja de estilo externa. -/// .with_assets(ContextOp::AddStyleSheet(StyleSheet::from("/css/style.css"))) +/// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/style.css"))) /// // Añade un script JavaScript. -/// .with_assets(ContextOp::AddJavaScript(JavaScript::defer("/js/main.js"))) +/// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/main.js"))) /// // Añade un parámetro dinámico al contexto. /// .with_param("usuario_id", 42) /// } @@ -471,29 +471,29 @@ impl Contextual for Context { } #[builder_fn] - fn with_assets(mut self, op: ContextOp) -> Self { + fn with_assets(mut self, op: AssetsOp) -> Self { match op { // Favicon. - ContextOp::SetFavicon(favicon) => { + AssetsOp::SetFavicon(favicon) => { self.favicon = favicon; } - ContextOp::SetFaviconIfNone(icon) => { + AssetsOp::SetFaviconIfNone(icon) => { if self.favicon.is_none() { self.favicon = Some(icon); } } // Stylesheets. - ContextOp::AddStyleSheet(css) => { + AssetsOp::AddStyleSheet(css) => { self.stylesheets.add(css); } - ContextOp::RemoveStyleSheet(path) => { + AssetsOp::RemoveStyleSheet(path) => { self.stylesheets.remove(path); } // Scripts JavaScript. - ContextOp::AddJavaScript(js) => { + AssetsOp::AddJavaScript(js) => { self.javascripts.add(js); } - ContextOp::RemoveJavaScript(path) => { + AssetsOp::RemoveJavaScript(path) => { self.javascripts.remove(path); } } diff --git a/src/lib.rs b/src/lib.rs index 6369df88..6ee31d60 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -117,13 +117,13 @@ use std::ops::Deref; /// impl Theme for MyTheme { /// fn before_render_page_body(&self, page: &mut Page) { /// page -/// .alter_assets(ContextOp::AddStyleSheet( +/// .alter_assets(AssetsOp::AddStyleSheet( /// StyleSheet::from("/css/normalize.css").with_version("8.0.1"), /// )) -/// .alter_assets(ContextOp::AddStyleSheet( +/// .alter_assets(AssetsOp::AddStyleSheet( /// StyleSheet::from("/css/basic.css").with_version(PAGETOP_VERSION), /// )) -/// .alter_assets(ContextOp::AddStyleSheet( +/// .alter_assets(AssetsOp::AddStyleSheet( /// StyleSheet::from("/mytheme/styles.css").with_version(env!("CARGO_PKG_VERSION")), /// )); /// } diff --git a/src/response/page.rs b/src/response/page.rs index 83fe00ba..5af063e4 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -19,8 +19,8 @@ pub use error::ErrorPage; pub use actix_web::Result as ResultPage; use crate::base::action; +use crate::core::component::{AssetsOp, Context, Contextual}; use crate::core::component::{Child, ChildOp, Component}; -use crate::core::component::{Context, ContextOp, Contextual}; use crate::core::theme::{DefaultRegion, Region, RegionRef, TemplateRef, ThemeRef}; use crate::html::{html, Markup, DOCTYPE}; use crate::html::{Assets, Favicon, JavaScript, StyleSheet}; @@ -334,7 +334,7 @@ impl Contextual for Page { } #[builder_fn] - fn with_assets(mut self, op: ContextOp) -> Self { + fn with_assets(mut self, op: AssetsOp) -> Self { self.context.alter_assets(op); self } From a0b14aec36d9128b4185c11c50b656a58c9036ba Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sat, 21 Mar 2026 11:26:02 +0100 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9C=A8=20Implementa=20`Debug`=20en=20com?= =?UTF-8?q?p./tipos=20principales?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/theme/container/component.rs | 2 +- .../src/theme/dropdown/component.rs | 2 +- .../src/theme/dropdown/item.rs | 4 ++-- .../src/theme/form/component.rs | 2 +- .../src/theme/form/fieldset.rs | 2 +- .../pagetop-bootsier/src/theme/form/input.rs | 2 +- extensions/pagetop-bootsier/src/theme/icon.rs | 2 +- .../src/theme/image/component.rs | 2 +- .../src/theme/nav/component.rs | 2 +- .../pagetop-bootsier/src/theme/nav/item.rs | 4 ++-- .../src/theme/navbar/brand.rs | 2 +- .../src/theme/navbar/component.rs | 2 +- .../pagetop-bootsier/src/theme/navbar/item.rs | 2 +- .../src/theme/navbar/props.rs | 2 +- .../src/theme/offcanvas/component.rs | 2 +- src/base/component/block.rs | 2 +- src/base/component/html.rs | 10 +++++++++ src/base/component/intro.rs | 2 +- src/base/component/poweredby.rs | 2 +- src/core/component/children.rs | 21 +++++++++++++++++- src/locale/l10n.rs | 22 +++++++++---------- 21 files changed, 61 insertions(+), 32 deletions(-) diff --git a/extensions/pagetop-bootsier/src/theme/container/component.rs b/extensions/pagetop-bootsier/src/theme/container/component.rs index 38fc9e1f..5578765f 100644 --- a/extensions/pagetop-bootsier/src/theme/container/component.rs +++ b/extensions/pagetop-bootsier/src/theme/container/component.rs @@ -6,7 +6,7 @@ use crate::prelude::*; /// /// Envuelve un contenido con la etiqueta HTML indicada por [`container::Kind`]. Sólo se renderiza /// si existen componentes hijos (*children*). -#[derive(AutoDefault, Getters)] +#[derive(AutoDefault, Debug, Getters)] pub struct Container { #[getters(skip)] id: AttrId, diff --git a/extensions/pagetop-bootsier/src/theme/dropdown/component.rs b/extensions/pagetop-bootsier/src/theme/dropdown/component.rs index a5f17298..046f6ec1 100644 --- a/extensions/pagetop-bootsier/src/theme/dropdown/component.rs +++ b/extensions/pagetop-bootsier/src/theme/dropdown/component.rs @@ -19,7 +19,7 @@ use crate::LOCALES_BOOTSIER; /// /// Ver ejemplo en el módulo [`dropdown`]. /// Si no contiene elementos, el componente **no se renderiza**. -#[derive(AutoDefault, Getters)] +#[derive(AutoDefault, Debug, Getters)] pub struct Dropdown { #[getters(skip)] id: AttrId, diff --git a/extensions/pagetop-bootsier/src/theme/dropdown/item.rs b/extensions/pagetop-bootsier/src/theme/dropdown/item.rs index 2bc3bf02..2b94a8f1 100644 --- a/extensions/pagetop-bootsier/src/theme/dropdown/item.rs +++ b/extensions/pagetop-bootsier/src/theme/dropdown/item.rs @@ -7,7 +7,7 @@ use pagetop::prelude::*; /// /// Define internamente la naturaleza del elemento y su comportamiento al mostrarse o interactuar /// con él. -#[derive(AutoDefault)] +#[derive(AutoDefault, Debug)] pub enum ItemKind { /// Elemento vacío, no produce salida. #[default] @@ -43,7 +43,7 @@ pub enum ItemKind { /// /// Permite definir el identificador, las clases de estilo adicionales y el tipo de interacción /// asociada, manteniendo una interfaz común para renderizar todos los elementos del menú. -#[derive(AutoDefault, Getters)] +#[derive(AutoDefault, Debug, Getters)] pub struct Item { #[getters(skip)] id: AttrId, diff --git a/extensions/pagetop-bootsier/src/theme/form/component.rs b/extensions/pagetop-bootsier/src/theme/form/component.rs index b3a814a1..5212554b 100644 --- a/extensions/pagetop-bootsier/src/theme/form/component.rs +++ b/extensions/pagetop-bootsier/src/theme/form/component.rs @@ -27,7 +27,7 @@ use crate::theme::form; /// .with_classes(ClassesOp::Add, "mb-3") /// .add_child(Input::new().with_name("q")); /// ``` -#[derive(AutoDefault, Getters)] +#[derive(AutoDefault, Debug, Getters)] pub struct Form { #[getters(skip)] id: AttrId, diff --git a/extensions/pagetop-bootsier/src/theme/form/fieldset.rs b/extensions/pagetop-bootsier/src/theme/form/fieldset.rs index f731e566..6d6a08cb 100644 --- a/extensions/pagetop-bootsier/src/theme/form/fieldset.rs +++ b/extensions/pagetop-bootsier/src/theme/form/fieldset.rs @@ -3,7 +3,7 @@ use pagetop::prelude::*; /// Agrupa controles relacionados de un formulario (`
`). /// /// Se usa para mejorar la accesibilidad cuando se acompaña de una leyenda que encabeza el grupo. -#[derive(AutoDefault, Getters)] +#[derive(AutoDefault, Debug, Getters)] pub struct Fieldset { #[getters(skip)] id: AttrId, diff --git a/extensions/pagetop-bootsier/src/theme/form/input.rs b/extensions/pagetop-bootsier/src/theme/form/input.rs index 019c98c0..bb37681b 100644 --- a/extensions/pagetop-bootsier/src/theme/form/input.rs +++ b/extensions/pagetop-bootsier/src/theme/form/input.rs @@ -3,7 +3,7 @@ use pagetop::prelude::*; use crate::theme::form; use crate::LOCALES_BOOTSIER; -#[derive(AutoDefault, Getters)] +#[derive(AutoDefault, Debug, Getters)] pub struct Input { classes: Classes, input_type: form::InputType, diff --git a/extensions/pagetop-bootsier/src/theme/icon.rs b/extensions/pagetop-bootsier/src/theme/icon.rs index dfce3aa9..4a2abde2 100644 --- a/extensions/pagetop-bootsier/src/theme/icon.rs +++ b/extensions/pagetop-bootsier/src/theme/icon.rs @@ -13,7 +13,7 @@ pub enum IconKind { }, } -#[derive(AutoDefault, Getters)] +#[derive(AutoDefault, Debug, Getters)] pub struct Icon { /// Devuelve las clases CSS asociadas al icono. classes: Classes, diff --git a/extensions/pagetop-bootsier/src/theme/image/component.rs b/extensions/pagetop-bootsier/src/theme/image/component.rs index de2866cc..597446c7 100644 --- a/extensions/pagetop-bootsier/src/theme/image/component.rs +++ b/extensions/pagetop-bootsier/src/theme/image/component.rs @@ -9,7 +9,7 @@ use crate::prelude::*; /// ([`classes::Border`](crate::theme::classes::Border)) y **redondeo de esquinas** /// ([`classes::Rounded`](crate::theme::classes::Rounded)). /// - Resuelve el texto alternativo `alt` con **localización** mediante [`L10n`]. -#[derive(AutoDefault, Getters)] +#[derive(AutoDefault, Debug, Getters)] pub struct Image { #[getters(skip)] id: AttrId, diff --git a/extensions/pagetop-bootsier/src/theme/nav/component.rs b/extensions/pagetop-bootsier/src/theme/nav/component.rs index 48cef0a3..d01fd1d7 100644 --- a/extensions/pagetop-bootsier/src/theme/nav/component.rs +++ b/extensions/pagetop-bootsier/src/theme/nav/component.rs @@ -10,7 +10,7 @@ use crate::prelude::*; /// /// Ver ejemplo en el módulo [`nav`]. /// Si no contiene elementos, el componente **no se renderiza**. -#[derive(AutoDefault, Getters)] +#[derive(AutoDefault, Debug, Getters)] pub struct Nav { #[getters(skip)] id: AttrId, diff --git a/extensions/pagetop-bootsier/src/theme/nav/item.rs b/extensions/pagetop-bootsier/src/theme/nav/item.rs index be9b2932..5db8864e 100644 --- a/extensions/pagetop-bootsier/src/theme/nav/item.rs +++ b/extensions/pagetop-bootsier/src/theme/nav/item.rs @@ -10,7 +10,7 @@ use crate::LOCALES_BOOTSIER; /// /// Define internamente la naturaleza del elemento y su comportamiento al mostrarse o interactuar /// con él. -#[derive(AutoDefault)] +#[derive(AutoDefault, Debug)] pub enum ItemKind { /// Elemento vacío, no produce salida. #[default] @@ -76,7 +76,7 @@ impl ItemKind { /// /// Permite definir el identificador, las clases de estilo adicionales y el tipo de interacción /// asociada, manteniendo una interfaz común para renderizar todos los elementos del menú. -#[derive(AutoDefault, Getters)] +#[derive(AutoDefault, Debug, Getters)] pub struct Item { #[getters(skip)] id: AttrId, diff --git a/extensions/pagetop-bootsier/src/theme/navbar/brand.rs b/extensions/pagetop-bootsier/src/theme/navbar/brand.rs index 15a7c57f..7dd43ef5 100644 --- a/extensions/pagetop-bootsier/src/theme/navbar/brand.rs +++ b/extensions/pagetop-bootsier/src/theme/navbar/brand.rs @@ -11,7 +11,7 @@ use crate::prelude::*; /// - Si no hay imagen ([`with_image()`](Self::with_image)) ni título /// ([`with_title()`](Self::with_title)), la marca de identidad no se renderiza. /// - El eslogan ([`with_slogan()`](Self::with_slogan)) es opcional; por defecto no tiene contenido. -#[derive(AutoDefault, Getters)] +#[derive(AutoDefault, Debug, Getters)] pub struct Brand { #[getters(skip)] id: AttrId, diff --git a/extensions/pagetop-bootsier/src/theme/navbar/component.rs b/extensions/pagetop-bootsier/src/theme/navbar/component.rs index 36ce7961..7b56f2a2 100644 --- a/extensions/pagetop-bootsier/src/theme/navbar/component.rs +++ b/extensions/pagetop-bootsier/src/theme/navbar/component.rs @@ -14,7 +14,7 @@ const TOGGLE_OFFCANVAS: &str = "offcanvas"; /// /// Ver ejemplos en el módulo [`navbar`]. /// Si no contiene elementos, el componente **no se renderiza**. -#[derive(AutoDefault, Getters)] +#[derive(AutoDefault, Debug, Getters)] pub struct Navbar { #[getters(skip)] id: AttrId, diff --git a/extensions/pagetop-bootsier/src/theme/navbar/item.rs b/extensions/pagetop-bootsier/src/theme/navbar/item.rs index 89b7c45e..25ad3a0e 100644 --- a/extensions/pagetop-bootsier/src/theme/navbar/item.rs +++ b/extensions/pagetop-bootsier/src/theme/navbar/item.rs @@ -7,7 +7,7 @@ use crate::prelude::*; /// Cada variante determina qué se renderiza y cómo. Estos elementos se colocan **dentro del /// contenido** de la barra (la parte colapsable, el *offcanvas* o el bloque simple), por lo que son /// independientes de la marca o del botón que ya pueda definir el propio [`navbar::Layout`]. -#[derive(AutoDefault)] +#[derive(AutoDefault, Debug)] pub enum Item { /// Sin contenido, no produce salida. #[default] diff --git a/extensions/pagetop-bootsier/src/theme/navbar/props.rs b/extensions/pagetop-bootsier/src/theme/navbar/props.rs index 59189946..1d88aab7 100644 --- a/extensions/pagetop-bootsier/src/theme/navbar/props.rs +++ b/extensions/pagetop-bootsier/src/theme/navbar/props.rs @@ -5,7 +5,7 @@ use crate::prelude::*; // **< Layout >************************************************************************************* /// Representa los diferentes tipos de presentación de una barra de navegación [`Navbar`]. -#[derive(AutoDefault)] +#[derive(AutoDefault, Debug)] pub enum Layout { /// Barra simple, sin marca de identidad y sin botón de despliegue. /// diff --git a/extensions/pagetop-bootsier/src/theme/offcanvas/component.rs b/extensions/pagetop-bootsier/src/theme/offcanvas/component.rs index 2e35f96d..ee175547 100644 --- a/extensions/pagetop-bootsier/src/theme/offcanvas/component.rs +++ b/extensions/pagetop-bootsier/src/theme/offcanvas/component.rs @@ -21,7 +21,7 @@ use crate::LOCALES_BOOTSIER; /// /// Ver ejemplo en el módulo [`offcanvas`]. /// Si no contiene elementos, el componente **no se renderiza**. -#[derive(AutoDefault, Getters)] +#[derive(AutoDefault, Debug, Getters)] pub struct Offcanvas { #[getters(skip)] id: AttrId, diff --git a/src/base/component/block.rs b/src/base/component/block.rs index c10c6fa2..1e075e2e 100644 --- a/src/base/component/block.rs +++ b/src/base/component/block.rs @@ -4,7 +4,7 @@ use crate::prelude::*; /// /// Los bloques se utilizan como contenedores de otros componentes o contenidos, con un título /// opcional y un cuerpo que sólo se renderiza si existen componentes hijos (*children*). -#[derive(AutoDefault, Getters)] +#[derive(AutoDefault, Debug, Getters)] pub struct Block { #[getters(skip)] id: AttrId, diff --git a/src/base/component/html.rs b/src/base/component/html.rs index 58d16857..ac0edb2f 100644 --- a/src/base/component/html.rs +++ b/src/base/component/html.rs @@ -1,5 +1,7 @@ use crate::prelude::*; +use std::fmt; + /// Componente básico que renderiza dinámicamente código HTML según el contexto. /// /// Este componente permite generar contenido HTML arbitrario, usando la macro `html!` y accediendo @@ -31,6 +33,14 @@ use crate::prelude::*; /// ``` pub struct Html(Box Markup + Send + Sync>); +impl fmt::Debug for Html { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("Html") + .field(&"Fn(&mut Context) -> Markup") + .finish() + } +} + impl Default for Html { fn default() -> Self { Self::with(|_| html! {}) diff --git a/src/base/component/intro.rs b/src/base/component/intro.rs index fd68d2bc..378ca42a 100644 --- a/src/base/component/intro.rs +++ b/src/base/component/intro.rs @@ -76,7 +76,7 @@ pub enum IntroOpening { /// })), /// ); /// ``` -#[derive(Getters)] +#[derive(Debug, Getters)] pub struct Intro { /// Devuelve el título de entrada. title: L10n, diff --git a/src/base/component/poweredby.rs b/src/base/component/poweredby.rs index 61f7ab84..b9ffe61f 100644 --- a/src/base/component/poweredby.rs +++ b/src/base/component/poweredby.rs @@ -8,7 +8,7 @@ const LINK: &str = ", diff --git a/src/core/component/children.rs b/src/core/component/children.rs index 1c424870..7aa9b053 100644 --- a/src/core/component/children.rs +++ b/src/core/component/children.rs @@ -7,6 +7,7 @@ use parking_lot::RwLock; pub use parking_lot::RwLockReadGuard as ComponentReadGuard; pub use parking_lot::RwLockWriteGuard as ComponentWriteGuard; +use std::fmt; use std::sync::Arc; use std::vec::IntoIter; @@ -17,6 +18,15 @@ use std::vec::IntoIter; #[derive(AutoDefault, Clone)] pub struct Child(Option>>); +impl fmt::Debug for Child { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.0 { + None => write!(f, "Child(None)"), + Some(c) => write!(f, "Child({})", c.read().name()), + } + } +} + impl Child { /// Crea un nuevo `Child` a partir de un componente. pub fn with(component: impl Component) -> Self { @@ -71,6 +81,15 @@ impl Child { #[derive(AutoDefault, Clone)] pub struct Typed(Option>>); +impl fmt::Debug for Typed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.0 { + None => write!(f, "Typed(None)"), + Some(c) => write!(f, "Typed({})", c.read().name()), + } + } +} + impl Typed { /// Crea un nuevo `Typed` a partir de un componente. pub fn with(component: C) -> Self { @@ -202,7 +221,7 @@ pub enum TypedOp { /// Esta lista permite añadir, modificar, renderizar y consultar componentes hijo en orden de /// inserción, soportando operaciones avanzadas como inserción relativa o reemplazo por /// identificador. -#[derive(AutoDefault, Clone)] +#[derive(AutoDefault, Clone, Debug)] pub struct Children(Vec); impl Children { diff --git a/src/locale/l10n.rs b/src/locale/l10n.rs index 7b096d9f..af5e9535 100644 --- a/src/locale/l10n.rs +++ b/src/locale/l10n.rs @@ -62,6 +62,17 @@ pub struct L10n { args: Vec<(CowStr, CowStr)>, } +impl fmt::Debug for L10n { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("L10n") + .field("op", &self.op) + .field("args", &self.args) + // No se puede mostrar `locales`; se representa con un texto fijo. + .field("locales", &"") + .finish() + } +} + impl L10n { /// **n** = *“native”*. Crea una instancia con una cadena literal sin traducción. pub fn n(text: impl Into) -> Self { @@ -177,14 +188,3 @@ impl L10n { PreEscaped(self.lookup(language).unwrap_or_default()) } } - -impl fmt::Debug for L10n { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("L10n") - .field("op", &self.op) - .field("args", &self.args) - // No se puede mostrar `locales`; se representa con un texto fijo. - .field("locales", &"") - .finish() - } -} From 34aeeab2d70a5059d7a98e165b57df038899ab0c Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sat, 21 Mar 2026 13:15:39 +0100 Subject: [PATCH 4/6] =?UTF-8?q?=E2=9C=A8=20A=C3=B1ade=20`ComponentError`?= =?UTF-8?q?=20con=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()) } } From 04dbbc8858e9e202709763636c02b6016610a4d8 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sat, 21 Mar 2026 13:24:42 +0100 Subject: [PATCH 5/6] =?UTF-8?q?=E2=9C=A8=20A=C3=B1ade=20`StatusMessage`/`M?= =?UTF-8?q?essageLevel`=20al=20contexto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/component.rs | 3 + src/core/component/context.rs | 131 ++++++++++++++++++++-------------- src/core/component/message.rs | 49 +++++++++++++ src/response/page.rs | 4 ++ 4 files changed, 134 insertions(+), 53 deletions(-) create mode 100644 src/core/component/message.rs diff --git a/src/core/component.rs b/src/core/component.rs index ecd6eda6..50c43c21 100644 --- a/src/core/component.rs +++ b/src/core/component.rs @@ -13,6 +13,9 @@ pub use children::Children; pub use children::{Child, ChildOp}; pub use children::{Typed, TypedOp}; +mod message; +pub use message::{MessageLevel, StatusMessage}; + mod context; pub use context::{AssetsOp, Context, ContextError, Contextual}; diff --git a/src/core/component/context.rs b/src/core/component/context.rs index e2a220b7..c1bea79f 100644 --- a/src/core/component/context.rs +++ b/src/core/component/context.rs @@ -1,9 +1,10 @@ -use crate::core::component::ChildOp; +use crate::core::component::{ChildOp, MessageLevel, StatusMessage}; use crate::core::theme::all::DEFAULT_THEME; use crate::core::theme::{ChildrenInRegions, RegionRef, TemplateRef, ThemeRef}; use crate::core::TypeInfo; use crate::html::{html, Markup, RoutePath}; use crate::html::{Assets, Favicon, JavaScript, StyleSheet}; +use crate::locale::L10n; use crate::locale::{LangId, LanguageIdentifier, RequestLocale}; use crate::service::HttpRequest; use crate::{builder_fn, util, CowStr}; @@ -115,6 +116,19 @@ pub trait Contextual: LangId { fn with_template(self, template: TemplateRef) -> Self; /// Añade o modifica un parámetro dinámico del contexto. + /// + /// El valor se guardará conservando el *nombre del tipo* real para mejorar los mensajes de + /// error posteriores. + /// + /// # Ejemplos + /// + /// ```rust + /// # use pagetop::prelude::*; + /// let cx = Context::new(None) + /// .with_param("usuario_id", 42_i32) + /// .with_param("titulo", "Hola".to_string()) + /// .with_param("flags", vec!["a", "b"]); + /// ``` #[builder_fn] fn with_param(self, key: &'static str, value: T) -> Self; @@ -137,7 +151,34 @@ pub trait Contextual: LangId { /// Devuelve la plantilla configurada para renderizar el documento. fn template(&self) -> TemplateRef; - /// Recupera un parámetro como [`Option`]. + /// Recupera un parámetro como [`Option`], simplificando el acceso. + /// + /// A diferencia de [`get_param`](Context::get_param), que devuelve un [`Result`] con + /// información detallada de error, este método devuelve `None` tanto si la clave no existe como + /// si el valor guardado no coincide con el tipo solicitado. + /// + /// Resulta útil en escenarios donde sólo interesa saber si el valor existe y es del tipo + /// correcto, sin necesidad de diferenciar entre error de ausencia o de tipo. + /// + /// # Ejemplo + /// + /// ```rust + /// # use pagetop::prelude::*; + /// let cx = Context::new(None).with_param("username", "Alice".to_string()); + /// + /// // Devuelve Some(&String) si existe y coincide el tipo. + /// assert_eq!(cx.param::("username").map(|s| s.as_str()), Some("Alice")); + /// + /// // Devuelve None si no existe o si el tipo no coincide. + /// assert!(cx.param::("username").is_none()); + /// assert!(cx.param::("missing").is_none()); + /// + /// // Acceso con valor por defecto. + /// let user = cx.param::("missing") + /// .cloned() + /// .unwrap_or_else(|| "visitor".to_string()); + /// assert_eq!(user, "visitor"); + /// ``` fn param(&self, key: &'static str) -> Option<&T>; /// Devuelve el parámetro clonado o el **valor por defecto del tipo** (`T::default()`). @@ -166,11 +207,27 @@ pub trait Contextual: LangId { // **< Contextual HELPERS >********************************************************************* - /// Genera un identificador único por tipo (`-`) cuando no se aporta uno explícito. + /// Devuelve el `id` proporcionado tal cual, o genera uno único para el tipo `T` si no se + /// proporciona ninguno. /// - /// Es útil para componentes u otros elementos HTML que necesitan un identificador predecible si - /// no se proporciona ninguno. + /// Si `id` es `None`, construye un identificador en la forma `-`, donde `` es el + /// nombre corto del tipo en minúsculas y `` un contador incremental interno del contexto. Es + /// útil para asignar identificadores HTML predecibles cuando el componente no recibe uno + /// explícito. fn required_id(&mut self, id: Option) -> String; + + /// Acumula un [`StatusMessage`] en el contexto para notificar al visitante. + /// + /// Pueden generarse en cualquier punto del ciclo de una petición web (manejadores, renderizado, + /// lógica de negocio, etc.) que tengan acceso al contexto, y mostrarlos luego, por ejemplo, en + /// la página final devuelta al usuario. + /// + /// # Ejemplo + /// + /// ```rust,ignore + /// cx.push_message(MessageLevel::Warning, L10n::l("session-not-valid")); + /// ``` + fn push_message(&mut self, level: MessageLevel, text: L10n); } /// Implementa un **contexto de renderizado** para un documento HTML. @@ -235,6 +292,7 @@ pub struct Context { regions : ChildrenInRegions, // Regiones de componentes para renderizar. params : HashMap<&'static str, (Box, &'static str)>, // Parámetros en ejecución. id_counter : usize, // Contador para generar identificadores únicos. + messages : Vec, // Mensajes de usuario acumulados. } impl Default for Context { @@ -262,6 +320,7 @@ impl Context { regions : ChildrenInRegions::default(), params : HashMap::default(), id_counter : 0, + messages : Vec::new(), } } @@ -403,6 +462,16 @@ impl Context { } route } + + /// Devuelve todos los mensajes de usuario acumulados. + pub fn messages(&self) -> &[StatusMessage] { + &self.messages + } + + /// Indica si hay mensajes de usuario acumulados. + pub fn has_messages(&self) -> bool { + !self.messages.is_empty() + } } /// Permite a [`Context`](crate::core::component::Context) actuar como proveedor de idioma. @@ -449,20 +518,6 @@ impl Contextual for Context { self } - /// Añade o modifica un parámetro dinámico del contexto. - /// - /// El valor se guarda conservando el *nombre del tipo* real para mejorar los mensajes de error - /// posteriores. - /// - /// # Ejemplos - /// - /// ```rust - /// # use pagetop::prelude::*; - /// let cx = Context::new(None) - /// .with_param("usuario_id", 42_i32) - /// .with_param("titulo", "Hola".to_string()) - /// .with_param("flags", vec!["a", "b"]); - /// ``` #[builder_fn] fn with_param(mut self, key: &'static str, value: T) -> Self { let type_name = TypeInfo::FullName.of::(); @@ -520,34 +575,6 @@ impl Contextual for Context { self.template } - /// Recupera un parámetro como [`Option`], simplificando el acceso. - /// - /// A diferencia de [`get_param`](Self::get_param), que devuelve un [`Result`] con información - /// detallada de error, este método devuelve `None` tanto si la clave no existe como si el valor - /// guardado no coincide con el tipo solicitado. - /// - /// Resulta útil en escenarios donde sólo interesa saber si el valor existe y es del tipo - /// correcto, sin necesidad de diferenciar entre error de ausencia o de tipo. - /// - /// # Ejemplo - /// - /// ```rust - /// # use pagetop::prelude::*; - /// let cx = Context::new(None).with_param("username", "Alice".to_string()); - /// - /// // Devuelve Some(&String) si existe y coincide el tipo. - /// assert_eq!(cx.param::("username").map(|s| s.as_str()), Some("Alice")); - /// - /// // Devuelve None si no existe o si el tipo no coincide. - /// assert!(cx.param::("username").is_none()); - /// assert!(cx.param::("missing").is_none()); - /// - /// // Acceso con valor por defecto. - /// let user = cx.param::("missing") - /// .cloned() - /// .unwrap_or_else(|| "visitor".to_string()); - /// assert_eq!(user, "visitor"); - /// ``` fn param(&self, key: &'static str) -> Option<&T> { self.get_param::(key).ok() } @@ -566,12 +593,6 @@ impl Contextual for Context { // **< Contextual HELPERS >********************************************************************* - /// Devuelve un identificador único dentro del contexto para el tipo `T`, si no se proporciona - /// un `id` explícito. - /// - /// Si no se proporciona un `id`, se genera un identificador único en la forma `-` - /// donde `` es el nombre corto del tipo en minúsculas (sin espacios) y `` es un - /// contador interno incremental. fn required_id(&mut self, id: Option) -> String { if let Some(id) = id { id @@ -590,4 +611,8 @@ impl Contextual for Context { util::join!(prefix, "-", self.id_counter.to_string()) } } + + fn push_message(&mut self, level: MessageLevel, text: L10n) { + self.messages.push(StatusMessage::new(level, text)); + } } diff --git a/src/core/component/message.rs b/src/core/component/message.rs new file mode 100644 index 00000000..d0f9d77b --- /dev/null +++ b/src/core/component/message.rs @@ -0,0 +1,49 @@ +use crate::locale::L10n; +use crate::{AutoDefault, Getters}; + +/// Nivel de severidad de un [`StatusMessage`]. +#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] +pub enum MessageLevel { + /// Mensaje informativo para el usuario. + #[default] + Info, + /// Aviso o advertencia para el usuario. + Warning, + /// Error comunicado al usuario. + Error, +} + +/// Notificación amigable para el usuario generada al procesar una petición web. +/// +/// Representa un mensaje con carácter informativo, una advertencia o un error. A diferencia de +/// [`ComponentError`](super::ComponentError), no está ligado a un fallo interno de renderizado, +/// puede generarse en cualquier punto del procesamiento de una petición web (manejadores, +/// renderizado, lógica de negocio, etc.). +/// +/// El texto se almacena como [`L10n`] para resolverse con el idioma del contexto en el momento de +/// la visualización. +/// +/// # Ejemplo +/// +/// ```rust +/// # use pagetop::prelude::*; +/// // Mensaje informativo con clave traducible. +/// let info = StatusMessage::new(MessageLevel::Info, L10n::l("saved-successfully")); +/// +/// // Aviso con texto literal sin traducción. +/// let warn = StatusMessage::new(MessageLevel::Warning, L10n::n("Formulario incompleto.")); +/// ``` +#[derive(Debug, Getters)] +pub struct StatusMessage { + /// Nivel de severidad del mensaje. + level: MessageLevel, + /// Texto del mensaje. + text: L10n, +} + +impl StatusMessage { + /// Crea un nuevo mensaje de usuario con el nivel y texto indicados. + pub fn new(level: MessageLevel, text: L10n) -> Self { + StatusMessage { level, text } + } +} diff --git a/src/response/page.rs b/src/response/page.rs index 5af063e4..a30c2324 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -380,4 +380,8 @@ impl Contextual for Page { fn required_id(&mut self, id: Option) -> String { self.context.required_id::(id) } + + fn push_message(&mut self, level: crate::prelude::MessageLevel, text: L10n) { + self.context.push_message(level, text); + } } From af309930f76fadf722595f25f034db5f839f4b38 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sat, 21 Mar 2026 13:26:11 +0100 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=A9=B9=20Mensajes=20de=20error=20en?= =?UTF-8?q?=20min=C3=BAscula=20y=20corrige=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/html/unit.rs | 10 +++++----- src/util.rs | 2 +- tests/component_poweredby.rs | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/html/unit.rs b/src/html/unit.rs index 5096ad4f..af452598 100644 --- a/src/html/unit.rs +++ b/src/html/unit.rs @@ -213,12 +213,12 @@ impl FromStr for UnitValue { None => { let n: f32 = s .parse() - .map_err(|e| format!("Invalid number `{s}`: {e}"))?; + .map_err(|e| format!("invalid number `{s}`: {e}"))?; if n == 0.0 { Ok(UnitValue::Zero) } else { Err( - "Missing unit (expected one of cm,in,mm,pc,pt,px,em,rem,vh,vw, or %)" + "missing unit (expected one of cm,in,mm,pc,pt,px,em,rem,vh,vw, or %)" .to_string(), ) } @@ -230,11 +230,11 @@ impl FromStr for UnitValue { let parse_abs = |n_s: &str| -> Result { n_s.parse::() - .map_err(|e| format!("Invalid integer `{n_s}`: {e}")) + .map_err(|e| format!("invalid integer `{n_s}`: {e}")) }; let parse_rel = |n_s: &str| -> Result { n_s.parse::() - .map_err(|e| format!("Invalid float `{n_s}`: {e}")) + .map_err(|e| format!("invalid float `{n_s}`: {e}")) }; match u.to_ascii_lowercase().as_str() { @@ -253,7 +253,7 @@ impl FromStr for UnitValue { // Porcentaje como unidad. "%" => Ok(UnitValue::RelPct(parse_rel(n)?)), // Unidad desconocida. - _ => Err(format!("Unknown unit: `{u}`")), + _ => Err(format!("unknown unit: `{u}`")), } } } diff --git a/src/util.rs b/src/util.rs index de275efa..6ea4b70d 100644 --- a/src/util.rs +++ b/src/util.rs @@ -192,7 +192,7 @@ pub fn resolve_absolute_dir>(path: P) -> io::Result { Ok(absolute_dir) } else { Err({ - let msg = format!("Path \"{}\" is not a directory", absolute_dir.display()); + let msg = format!("path \"{}\" is not a directory", absolute_dir.display()); trace::warn!(msg); io::Error::new(io::ErrorKind::InvalidInput, msg) }) diff --git a/tests/component_poweredby.rs b/tests/component_poweredby.rs index 7e5a062c..7dca895d 100644 --- a/tests/component_poweredby.rs +++ b/tests/component_poweredby.rs @@ -85,7 +85,7 @@ async fn poweredby_getter_reflects_internal_state() { // Y `new()` lo inicializa con año + nombre de app. let p1 = PoweredBy::new(); - let c1 = p1.copyright().expect("Expected copyright to exis"); + let c1 = p1.copyright().expect("Expected copyright to exist"); assert!(c1.contains(&Utc::now().format("%Y").to_string())); assert!(c1.contains(&global::SETTINGS.app.name)); }