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