diff --git a/.gitignore b/.gitignore index ab19156b..65db440e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,3 @@ **/local.*.toml **/local.toml .env -.cargo -.vscode diff --git a/extensions/pagetop-aliner/src/lib.rs b/extensions/pagetop-aliner/src/lib.rs index 30621b21..04b5ad1a 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(AssetsOp::AddStyleSheet( + page.alter_assets(ContextOp::AddStyleSheet( StyleSheet::from("/css/normalize.css") .with_version("8.0.1") .with_weight(-99), )) - .alter_assets(AssetsOp::AddStyleSheet( + .alter_assets(ContextOp::AddStyleSheet( StyleSheet::from("/css/basic.css") .with_version(PAGETOP_VERSION) .with_weight(-99), )) - .alter_assets(AssetsOp::AddStyleSheet( + .alter_assets(ContextOp::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 0281fe7a..5c88959a 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(AssetsOp::AddStyleSheet( + page.alter_assets(ContextOp::AddStyleSheet( StyleSheet::from("/bootsier/bs/bootstrap.min.css") .with_version(BOOTSTRAP_VERSION) .with_weight(-90), )) - .alter_assets(AssetsOp::AddJavaScript( + .alter_assets(ContextOp::AddJavaScript( JavaScript::defer("/bootsier/js/bootstrap.bundle.min.js") .with_version(BOOTSTRAP_VERSION) .with_weight(-90), diff --git a/extensions/pagetop-bootsier/src/theme/container/component.rs b/extensions/pagetop-bootsier/src/theme/container/component.rs index ad828c48..b105abb1 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, Debug, Getters)] +#[derive(AutoDefault, Getters)] pub struct Container { #[getters(skip)] id: AttrId, @@ -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) -> Result { + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { let output = self.children().render(cx); if output.is_empty() { - return Ok(html! {}); + return PrepareMarkup::None; } let style = match self.container_width() { container::Width::FluidMax(w) if w.is_measurable() => { @@ -44,38 +44,38 @@ impl Component for Container { } _ => None, }; - Ok(match self.container_kind() { - container::Kind::Default => html! { + match self.container_kind() { + container::Kind::Default => PrepareMarkup::With(html! { div id=[self.id()] class=[self.classes().get()] style=[style] { (output) } - }, - container::Kind::Main => html! { + }), + container::Kind::Main => PrepareMarkup::With(html! { main id=[self.id()] class=[self.classes().get()] style=[style] { (output) } - }, - container::Kind::Header => html! { + }), + container::Kind::Header => PrepareMarkup::With(html! { header id=[self.id()] class=[self.classes().get()] style=[style] { (output) } - }, - container::Kind::Footer => html! { + }), + container::Kind::Footer => PrepareMarkup::With(html! { footer id=[self.id()] class=[self.classes().get()] style=[style] { (output) } - }, - container::Kind::Section => html! { + }), + container::Kind::Section => PrepareMarkup::With(html! { section id=[self.id()] class=[self.classes().get()] style=[style] { (output) } - }, - container::Kind::Article => html! { + }), + container::Kind::Article => PrepareMarkup::With(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 cb721fca..3e683f18 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, Debug, Getters)] +#[derive(AutoDefault, Getters)] pub struct Dropdown { #[getters(skip)] id: AttrId, @@ -63,17 +63,17 @@ impl Component for Dropdown { ); } - fn prepare_component(&self, cx: &mut Context) -> Result { + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { // Si no hay elementos en el menú, no se prepara. let items = self.items().render(cx); if items.is_empty() { - return Ok(html! {}); + return PrepareMarkup::None; } // Título opcional para el menú desplegable. let title = self.title().using(cx); - Ok(html! { + PrepareMarkup::With(html! { div id=[self.id()] class=[self.classes().get()] { @if !title.is_empty() { @let mut btn_classes = Classes::new({ diff --git a/extensions/pagetop-bootsier/src/theme/dropdown/item.rs b/extensions/pagetop-bootsier/src/theme/dropdown/item.rs index ac252d1b..91570636 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, Debug)] +#[derive(AutoDefault)] 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, Debug, Getters)] +#[derive(AutoDefault, Getters)] pub struct Item { #[getters(skip)] id: AttrId, @@ -62,17 +62,17 @@ impl Component for Item { self.id.get() } - fn prepare_component(&self, cx: &mut Context) -> Result { - Ok(match self.item_kind() { - ItemKind::Void => html! {}, + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { + match self.item_kind() { + ItemKind::Void => PrepareMarkup::None, - ItemKind::Label(label) => html! { + ItemKind::Label(label) => PrepareMarkup::With(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"); - html! { + PrepareMarkup::With(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"); - html! { + PrepareMarkup::With(html! { li id=[self.id()] class=[self.classes().get()] { button class=(classes) @@ -137,21 +137,21 @@ impl Component for Item { (label.using(cx)) } } - } + }) } - ItemKind::Header(label) => html! { + ItemKind::Header(label) => PrepareMarkup::With(html! { li id=[self.id()] class=[self.classes().get()] { h6 class="dropdown-header" { (label.using(cx)) } } - }, + }), - ItemKind::Divider => html! { + ItemKind::Divider => PrepareMarkup::With(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 6ed05938..2da46e3f 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, Debug, Getters)] +#[derive(AutoDefault, Getters)] pub struct Form { #[getters(skip)] id: AttrId, @@ -52,12 +52,12 @@ impl Component for Form { self.alter_classes(ClassesOp::Prepend, "form"); } - fn prepare_component(&self, cx: &mut Context) -> Result { + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { let method = match self.method() { form::Method::Post => Some("post"), form::Method::Get => None, }; - Ok(html! { + PrepareMarkup::With(html! { form id=[self.id()] class=[self.classes().get()] diff --git a/extensions/pagetop-bootsier/src/theme/form/fieldset.rs b/extensions/pagetop-bootsier/src/theme/form/fieldset.rs index 536218e9..36092ca2 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, Debug, Getters)] +#[derive(AutoDefault, Getters)] pub struct Fieldset { #[getters(skip)] id: AttrId, @@ -22,8 +22,8 @@ impl Component for Fieldset { self.id.get() } - fn prepare_component(&self, cx: &mut Context) -> Result { - Ok(html! { + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { + PrepareMarkup::With(html! { fieldset id=[self.id()] class=[self.classes().get()] disabled[*self.disabled()] { @if let Some(legend) = self.legend().lookup(cx) { legend { (legend) } diff --git a/extensions/pagetop-bootsier/src/theme/form/input.rs b/extensions/pagetop-bootsier/src/theme/form/input.rs index 1872f806..bf76e082 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, Debug, Getters)] +#[derive(AutoDefault, Getters)] pub struct Input { classes: Classes, input_type: form::InputType, @@ -36,9 +36,9 @@ impl Component for Input { ); } - fn prepare_component(&self, cx: &mut Context) -> Result { + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { let id = self.name().get().map(|name| util::join!("edit-", name)); - Ok(html! { + PrepareMarkup::With(html! { div class=[self.classes().get()] { @if let Some(label) = self.label().lookup(cx) { label for=[&id] class="form-label" { diff --git a/extensions/pagetop-bootsier/src/theme/icon.rs b/extensions/pagetop-bootsier/src/theme/icon.rs index e3c0fe56..96de2049 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, Debug, Getters)] +#[derive(AutoDefault, Getters)] pub struct Icon { /// Devuelve las clases CSS asociadas al icono. classes: Classes, @@ -35,26 +35,26 @@ impl Component for Icon { } } - fn prepare_component(&self, cx: &mut Context) -> Result { - Ok(match self.icon_kind() { - IconKind::None => html! {}, + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { + match self.icon_kind() { + IconKind::None => PrepareMarkup::None, IconKind::Font(_) => { let aria_label = self.aria_label().lookup(cx); let has_label = aria_label.is_some(); - html! { + PrepareMarkup::With(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()); - html! { + PrepareMarkup::With(html! { svg xmlns="http://www.w3.org/2000/svg" viewBox=(viewbox) @@ -67,9 +67,9 @@ 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 f9f04d26..4362a25f 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, Debug, Getters)] +#[derive(AutoDefault, Getters)] pub struct Image { #[getters(skip)] id: AttrId, @@ -36,13 +36,13 @@ impl Component for Image { self.alter_classes(ClassesOp::Prepend, self.source().to_class()); } - fn prepare_component(&self, cx: &mut Context) -> Result { + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { 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 Ok(html! { + return PrepareMarkup::With(html! { span id=[self.id()] class=[self.classes().get()] @@ -59,7 +59,7 @@ impl Component for Image { image::Source::Thumbnail(source) => Some(source), image::Source::Plain(source) => Some(source), }; - Ok(html! { + PrepareMarkup::With(html! { img src=[source] alt=(alt_text) diff --git a/extensions/pagetop-bootsier/src/theme/nav/component.rs b/extensions/pagetop-bootsier/src/theme/nav/component.rs index fd849a9c..00703c79 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, Debug, Getters)] +#[derive(AutoDefault, Getters)] pub struct Nav { #[getters(skip)] id: AttrId, @@ -42,13 +42,13 @@ impl Component for Nav { }); } - fn prepare_component(&self, cx: &mut Context) -> Result { + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { let items = self.items().render(cx); if items.is_empty() { - return Ok(html! {}); + return PrepareMarkup::None; } - Ok(html! { + PrepareMarkup::With(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 45418021..06bb7353 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, Debug)] +#[derive(AutoDefault)] 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, Debug, Getters)] +#[derive(AutoDefault, Getters)] pub struct Item { #[getters(skip)] id: AttrId, @@ -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) -> Result { - Ok(match self.item_kind() { - ItemKind::Void => html! {}, + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { + match self.item_kind() { + ItemKind::Void => PrepareMarkup::None, - ItemKind::Label(label) => html! { + ItemKind::Label(label) => PrepareMarkup::With(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"); - html! { + PrepareMarkup::With(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) => html! { + ItemKind::Html(html) => PrepareMarkup::With(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 Ok(html! {}); + return PrepareMarkup::None; } let title = dd.title().lookup(cx).unwrap_or_else(|| { L10n::t("dropdown", &LOCALES_BOOTSIER) .lookup(cx) .unwrap_or_else(|| "Dropdown".to_string()) }); - html! { + PrepareMarkup::With(html! { li id=[self.id()] class=[self.classes().get()] { a class="nav-link dropdown-toggle" @@ -184,12 +184,12 @@ impl Component for Item { (items) } } - } + }) } else { - html! {} + PrepareMarkup::None } } - }) + } } } diff --git a/extensions/pagetop-bootsier/src/theme/navbar/brand.rs b/extensions/pagetop-bootsier/src/theme/navbar/brand.rs index 5c22195a..b39f8032 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, Debug, Getters)] +#[derive(AutoDefault, Getters)] pub struct Brand { #[getters(skip)] id: AttrId, @@ -36,14 +36,14 @@ impl Component for Brand { self.id.get() } - fn prepare_component(&self, cx: &mut Context) -> Result { + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { let image = self.image().render(cx); let title = self.title().using(cx); if title.is_empty() && image.is_empty() { - return Ok(html! {}); + return PrepareMarkup::None; } let slogan = self.slogan().using(cx); - Ok(html! { + PrepareMarkup::With(html! { @if let Some(route) = self.route() { a class="navbar-brand" href=(route(cx)) { (image) (title) (slogan) } } @else { diff --git a/extensions/pagetop-bootsier/src/theme/navbar/component.rs b/extensions/pagetop-bootsier/src/theme/navbar/component.rs index 6f0ecf54..77aa5e57 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, Debug, Getters)] +#[derive(AutoDefault, Getters)] pub struct Navbar { #[getters(skip)] id: AttrId, @@ -48,7 +48,7 @@ impl Component for Navbar { }); } - fn prepare_component(&self, cx: &mut Context) -> Result { + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { // 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 Ok(html! {}); + return PrepareMarkup::None; } // Asegura que la barra tiene un `id` para poder asociarlo al colapso/offcanvas. let id = cx.required_id::(self.id()); - Ok(html! { + PrepareMarkup::With(html! { nav id=(id) class=[self.classes().get()] { div class="container-fluid" { @match self.layout() { diff --git a/extensions/pagetop-bootsier/src/theme/navbar/item.rs b/extensions/pagetop-bootsier/src/theme/navbar/item.rs index 28e74cae..376ba6db 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, Debug)] +#[derive(AutoDefault)] pub enum Item { /// Sin contenido, no produce salida. #[default] @@ -46,31 +46,31 @@ impl Component for Item { } } - fn prepare_component(&self, cx: &mut Context) -> Result { - Ok(match self { - Self::Void => html! {}, - Self::Brand(brand) => html! { (brand.render(cx)) }, + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { + match self { + Self::Void => PrepareMarkup::None, + Self::Brand(brand) => PrepareMarkup::With(html! { (brand.render(cx)) }), Self::Nav(nav) => { if let Some(nav) = nav.borrow() { let items = nav.items().render(cx); if items.is_empty() { - return Ok(html! {}); + return PrepareMarkup::None; } - html! { + PrepareMarkup::With(html! { ul id=[nav.id()] class=[nav.classes().get()] { (items) } - } + }) } else { - html! {} + PrepareMarkup::None } } - Self::Text(text) => html! { + Self::Text(text) => PrepareMarkup::With(html! { span class="navbar-text" { (text.using(cx)) } - }, - }) + }), + } } } diff --git a/extensions/pagetop-bootsier/src/theme/navbar/props.rs b/extensions/pagetop-bootsier/src/theme/navbar/props.rs index 1d88aab7..59189946 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, Debug)] +#[derive(AutoDefault)] 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 233a9821..fe990681 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, Debug, Getters)] +#[derive(AutoDefault, Getters)] pub struct Offcanvas { #[getters(skip)] id: AttrId, @@ -62,8 +62,8 @@ impl Component for Offcanvas { }); } - fn prepare_component(&self, cx: &mut Context) -> Result { - Ok(self.render_offcanvas(cx, None)) + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { + PrepareMarkup::With(self.render_offcanvas(cx, None)) } } diff --git a/src/base/action/theme/prepare_render.rs b/src/base/action/theme/prepare_render.rs index 108fd4b8..8e46e8cc 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) -> Result; +pub type FnPrepareRender = fn(component: &C, cx: &mut Context) -> PrepareMarkup; /// Ejecuta [`FnPrepareRender`] para preparar el renderizado de un componente. /// @@ -41,25 +41,23 @@ impl PrepareRender { } } - /// Despacha las acciones. Se detiene en cuanto una renderiza o produce un error. + /// Despacha las acciones. Se detiene en cuanto una renderiza. #[inline] - pub(crate) fn dispatch(component: &C, cx: &mut Context) -> Result { - let mut render_result: Result = Ok(html! {}); - dispatch_actions_until( + pub(crate) fn dispatch(component: &C, cx: &mut Context) -> PrepareMarkup { + let mut render_component = PrepareMarkup::None; + dispatch_actions( &ActionKey::new( UniqueId::of::(), Some(cx.theme().type_id()), Some(UniqueId::of::()), None, ), - |action: &Self| match &render_result { - Ok(markup) if markup.is_empty() => { - render_result = (action.f)(component, cx); - std::ops::ControlFlow::Continue(()) + |action: &Self| { + if render_component.is_empty() { + render_component = (action.f)(component, cx); } - _ => std::ops::ControlFlow::Break(()), }, ); - render_result + render_component } } diff --git a/src/base/component/block.rs b/src/base/component/block.rs index 3faf6636..77eacfec 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, Debug, Getters)] +#[derive(AutoDefault, Getters)] pub struct Block { #[getters(skip)] id: AttrId, @@ -29,16 +29,16 @@ impl Component for Block { self.alter_classes(ClassesOp::Prepend, "block"); } - fn prepare_component(&self, cx: &mut Context) -> Result { + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { let block_body = self.children().render(cx); if block_body.is_empty() { - return Ok(html! {}); + return PrepareMarkup::None; } let id = cx.required_id::(self.id()); - Ok(html! { + PrepareMarkup::With(html! { div id=(id) class=[self.classes().get()] { @if let Some(title) = self.title().lookup(cx) { h2 class="block__title" { span { (title) } } diff --git a/src/base/component/html.rs b/src/base/component/html.rs index 3a55bd6e..ae8e1a33 100644 --- a/src/base/component/html.rs +++ b/src/base/component/html.rs @@ -1,7 +1,5 @@ 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 @@ -33,14 +31,6 @@ use std::fmt; /// ``` 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! {}) @@ -52,8 +42,8 @@ impl Component for Html { Self::default() } - fn prepare_component(&self, cx: &mut Context) -> Result { - Ok(self.html(cx)) + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { + PrepareMarkup::With(self.html(cx)) } } diff --git a/src/base/component/intro.rs b/src/base/component/intro.rs index 1fd44e25..715d4839 100644 --- a/src/base/component/intro.rs +++ b/src/base/component/intro.rs @@ -76,7 +76,7 @@ pub enum IntroOpening { /// })), /// ); /// ``` -#[derive(Debug, Getters)] +#[derive(Getters)] pub struct Intro { /// Devuelve el título de entrada. title: L10n, @@ -110,14 +110,14 @@ impl Component for Intro { } fn setup_before_prepare(&mut self, cx: &mut Context) { - cx.alter_assets(AssetsOp::AddStyleSheet( + cx.alter_assets(ContextOp::AddStyleSheet( StyleSheet::from("/css/intro.css").with_version(PAGETOP_VERSION), )); } - fn prepare_component(&self, cx: &mut Context) -> Result { + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { if *self.opening() == IntroOpening::PageTop { - cx.alter_assets(AssetsOp::AddJavaScript(JavaScript::on_load_async("intro-js", |cx| + cx.alter_assets(ContextOp::AddJavaScript(JavaScript::on_load_async("intro-js", |cx| util::indoc!(r#" try { const resp = await fetch("https://crates.io/api/v1/crates/pagetop"); @@ -135,7 +135,7 @@ impl Component for Intro { ))); } - Ok(html! { + PrepareMarkup::With(html! { div class="intro" { div class="intro-header" { section class="intro-header__body" { diff --git a/src/base/component/poweredby.rs b/src/base/component/poweredby.rs index 1b9ffd66..e90263c9 100644 --- a/src/base/component/poweredby.rs +++ b/src/base/component/poweredby.rs @@ -8,7 +8,7 @@ const LINK: &str = ", @@ -25,8 +25,8 @@ impl Component for PoweredBy { PoweredBy { copyright: Some(c) } } - fn prepare_component(&self, cx: &mut Context) -> Result { - Ok(html! { + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { + PrepareMarkup::With(html! { div id=[self.id()] class="poweredby" { @if let Some(c) = self.copyright() { span class="poweredby__copyright" { (c) "." } " " diff --git a/src/base/theme/basic.rs b/src/base/theme/basic.rs index 858f5d2b..ca3c4a82 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(AssetsOp::AddStyleSheet( + page.alter_assets(ContextOp::AddStyleSheet( StyleSheet::from("/css/normalize.css") .with_version("8.0.1") .with_weight(-99), )) - .alter_assets(AssetsOp::AddStyleSheet( + .alter_assets(ContextOp::AddStyleSheet( StyleSheet::from("/css/basic.css") .with_version(PAGETOP_VERSION) .with_weight(-99), diff --git a/src/core/action.rs b/src/core/action.rs index 8eb724dd..9f81cd52 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, dispatch_actions_until}; +pub use all::dispatch_actions; /// Facilita la implementación del método [`actions()`](crate::core::extension::Extension::actions). /// @@ -35,7 +35,7 @@ pub use all::{dispatch_actions, dispatch_actions_until}; /// impl Theme for MyTheme {} /// /// fn before_render_button(c: &mut Button, cx: &mut Context) { todo!() } -/// fn render_error404(c: &Error404, cx: &mut Context) -> Markup { todo!() } +/// fn render_error404(c: &Error404, cx: &mut Context) -> PrepareMarkup { todo!() } /// ``` #[macro_export] macro_rules! actions_boxed { diff --git a/src/core/action/all.rs b/src/core/action/all.rs index 513b1140..7e7305b1 100644 --- a/src/core/action/all.rs +++ b/src/core/action/all.rs @@ -72,18 +72,3 @@ 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 b8abff80..d60129c1 100644 --- a/src/core/action/list.rs +++ b/src/core/action/list.rs @@ -39,21 +39,4 @@ 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 50c43c21..ba35fe48 100644 --- a/src/core/component.rs +++ b/src/core/component.rs @@ -2,9 +2,6 @@ use crate::html::RoutePath; -mod error; -pub use error::ComponentError; - mod definition; pub use definition::{Component, ComponentRender}; @@ -13,11 +10,8 @@ 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}; +pub use context::{Context, ContextError, ContextOp, Contextual}; /// Alias de función (*callback*) para **determinar si un componente se renderiza o no**. /// @@ -44,8 +38,8 @@ pub use context::{AssetsOp, Context, ContextError, Contextual}; /// self.renderable.map_or(true, |f| f(cx)) /// } /// -/// fn prepare_component(&self, _cx: &mut Context) -> Result { -/// Ok(html! { "Visible component" }) +/// fn prepare_component(&self, _cx: &mut Context) -> PrepareMarkup { +/// PrepareMarkup::Escaped("Visible component".into()) /// } /// } /// diff --git a/src/core/component/children.rs b/src/core/component/children.rs index 7aa9b053..1c424870 100644 --- a/src/core/component/children.rs +++ b/src/core/component/children.rs @@ -7,7 +7,6 @@ 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; @@ -18,15 +17,6 @@ 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 { @@ -81,15 +71,6 @@ 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 { @@ -221,7 +202,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, Debug)] +#[derive(AutoDefault, Clone)] pub struct Children(Vec); impl Children { diff --git a/src/core/component/context.rs b/src/core/component/context.rs index c1bea79f..5ac1ebe8 100644 --- a/src/core/component/context.rs +++ b/src/core/component/context.rs @@ -1,20 +1,18 @@ -use crate::core::component::{ChildOp, MessageLevel, StatusMessage}; +use crate::core::component::ChildOp; 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}; use std::any::Any; use std::collections::HashMap; -use std::fmt; /// Operaciones para modificar recursos asociados al [`Context`] de un documento. -pub enum AssetsOp { +pub enum ContextOp { /// Define el *favicon* del documento. Sobrescribe cualquier valor anterior. SetFavicon(Option), /// Define el *favicon* solo si no se ha establecido previamente. @@ -47,26 +45,6 @@ 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: @@ -74,7 +52,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 [`AssetsOp`]. +/// [`StyleSheet`] o los scripts [`JavaScript`] mediante [`ContextOp`]. /// - Leer y mantener **parámetros dinámicos tipados** de contexto. /// - Generar **identificadores únicos** por tipo de componente. /// @@ -90,9 +68,9 @@ impl std::error::Error for ContextError {} /// cx.with_langid(&Locale::resolve("es-ES")) /// .with_theme(&Aliner) /// .with_template(&DefaultTemplate::Standard) -/// .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_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_param("usuario_id", 42_i32) /// } /// ``` @@ -116,25 +94,12 @@ 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; - /// Define los recursos del contexto usando [`AssetsOp`]. + /// Define los recursos del contexto usando [`ContextOp`]. #[builder_fn] - fn with_assets(self, op: AssetsOp) -> Self; + fn with_assets(self, op: ContextOp) -> Self; /// Opera con [`ChildOp`] en una región del documento. #[builder_fn] @@ -151,34 +116,7 @@ pub trait Contextual: LangId { /// Devuelve la plantilla configurada para renderizar el documento. fn template(&self) -> TemplateRef; - /// 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"); - /// ``` + /// Recupera un parámetro como [`Option`]. fn param(&self, key: &'static str) -> Option<&T>; /// Devuelve el parámetro clonado o el **valor por defecto del tipo** (`T::default()`). @@ -207,27 +145,11 @@ pub trait Contextual: LangId { // **< Contextual HELPERS >********************************************************************* - /// Devuelve el `id` proporcionado tal cual, o genera uno único para el tipo `T` si no se - /// proporciona ninguno. + /// Genera un identificador único por tipo (`-`) cuando no se aporta uno explícito. /// - /// 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. + /// Es útil para componentes u otros elementos HTML que necesitan un identificador predecible si + /// no se proporciona ninguno. 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. @@ -251,11 +173,11 @@ pub trait Contextual: LangId { /// // Establece el tema para renderizar. /// .with_theme(&Aliner) /// // Asigna un favicon. -/// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) +/// .with_assets(ContextOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) /// // Añade una hoja de estilo externa. -/// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/style.css"))) +/// .with_assets(ContextOp::AddStyleSheet(StyleSheet::from("/css/style.css"))) /// // Añade un script JavaScript. -/// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/main.js"))) +/// .with_assets(ContextOp::AddJavaScript(JavaScript::defer("/js/main.js"))) /// // Añade un parámetro dinámico al contexto. /// .with_param("usuario_id", 42) /// } @@ -292,7 +214,6 @@ 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 { @@ -320,7 +241,6 @@ impl Context { regions : ChildrenInRegions::default(), params : HashMap::default(), id_counter : 0, - messages : Vec::new(), } } @@ -462,16 +382,6 @@ 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. @@ -518,6 +428,20 @@ 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::(); @@ -526,29 +450,29 @@ impl Contextual for Context { } #[builder_fn] - fn with_assets(mut self, op: AssetsOp) -> Self { + fn with_assets(mut self, op: ContextOp) -> Self { match op { // Favicon. - AssetsOp::SetFavicon(favicon) => { + ContextOp::SetFavicon(favicon) => { self.favicon = favicon; } - AssetsOp::SetFaviconIfNone(icon) => { + ContextOp::SetFaviconIfNone(icon) => { if self.favicon.is_none() { self.favicon = Some(icon); } } // Stylesheets. - AssetsOp::AddStyleSheet(css) => { + ContextOp::AddStyleSheet(css) => { self.stylesheets.add(css); } - AssetsOp::RemoveStyleSheet(path) => { + ContextOp::RemoveStyleSheet(path) => { self.stylesheets.remove(path); } // Scripts JavaScript. - AssetsOp::AddJavaScript(js) => { + ContextOp::AddJavaScript(js) => { self.javascripts.add(js); } - AssetsOp::RemoveJavaScript(path) => { + ContextOp::RemoveJavaScript(path) => { self.javascripts.remove(path); } } @@ -575,6 +499,34 @@ 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() } @@ -593,6 +545,12 @@ 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 @@ -611,8 +569,4 @@ 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/definition.rs b/src/core/component/definition.rs index 611e9125..13b03851 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::{ComponentError, Context, Contextual}; +use crate::core::component::Context; use crate::core::{AnyInfo, TypeInfo}; -use crate::html::{html, Markup}; +use crate::html::{html, Markup, PrepareMarkup}; /// Define la función de renderizado para todos los componentes. /// @@ -14,15 +14,11 @@ pub trait ComponentRender { /// Interfaz común que debe implementar un componente renderizable en PageTop. /// -/// 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. +/// 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. 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; @@ -55,9 +51,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 asignarse una función [`FnIsRenderable`](super::FnIsRenderable) a un campo del - /// componente para permitir que instancias concretas del mismo puedan decidir dinámicamente si - /// se renderizan o no. + /// También puede 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. #[allow(unused_variables)] fn is_renderable(&self, cx: &mut Context) -> bool { true @@ -66,28 +62,25 @@ 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 renderizarlo. Por defecto no hace nada. + /// contexto antes de preparar la renderización del componente. Por defecto no hace nada. #[allow(unused_variables)] fn setup_before_prepare(&mut self, cx: &mut Context) {} - /// Devuelve el marcado HTML del componente usando el contexto proporcionado. + /// Devuelve una representación renderizada del componente. /// /// 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. 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). + /// durante el proceso de construcción del documento. Puede sobrescribirse para generar + /// dinámicamente el contenido HTML con acceso al contexto de renderizado. /// - /// 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. + /// 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. /// - /// 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. + /// Por defecto, devuelve [`PrepareMarkup::None`]. #[allow(unused_variables)] - fn prepare_component(&self, cx: &mut Context) -> Result { - Ok(html! {}) + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { + PrepareMarkup::None } } @@ -130,33 +123,11 @@ impl ComponentRender for C { action::component::BeforeRender::dispatch(self, cx); // Prepara el renderizado del componente. - 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() - } + let prepare = action::theme::PrepareRender::dispatch(self, cx); + let prepare = if prepare.is_empty() { + self.prepare_component(cx) + } else { + prepare }; // Acciones específicas del tema después de renderizar el componente. @@ -166,6 +137,6 @@ impl ComponentRender for C { action::component::AfterRender::dispatch(self, cx); // Devuelve el marcado final. - prepare + prepare.render() } } diff --git a/src/core/component/error.rs b/src/core/component/error.rs deleted file mode 100644 index 50c042d0..00000000 --- a/src/core/component/error.rs +++ /dev/null @@ -1,66 +0,0 @@ -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/src/core/component/message.rs b/src/core/component/message.rs deleted file mode 100644 index d0f9d77b..00000000 --- a/src/core/component/message.rs +++ /dev/null @@ -1,49 +0,0 @@ -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/html.rs b/src/html.rs index 21809c08..ea718a34 100644 --- a/src/html.rs +++ b/src/html.rs @@ -1,5 +1,7 @@ //! HTML en código. +use crate::AutoDefault; + mod maud; pub use maud::{display, html, html_private, Escaper, Markup, PreEscaped, DOCTYPE}; @@ -27,3 +29,81 @@ 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 d5dcaa0b..fd604414 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) -> Markup { -/// html! { +/// fn render_logo(cx: &mut Context) -> PrepareMarkup { +/// PrepareMarkup::With(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/src/html/unit.rs b/src/html/unit.rs index af452598..5096ad4f 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/lib.rs b/src/lib.rs index 6ee31d60..6369df88 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(AssetsOp::AddStyleSheet( +/// .alter_assets(ContextOp::AddStyleSheet( /// StyleSheet::from("/css/normalize.css").with_version("8.0.1"), /// )) -/// .alter_assets(AssetsOp::AddStyleSheet( +/// .alter_assets(ContextOp::AddStyleSheet( /// StyleSheet::from("/css/basic.css").with_version(PAGETOP_VERSION), /// )) -/// .alter_assets(AssetsOp::AddStyleSheet( +/// .alter_assets(ContextOp::AddStyleSheet( /// StyleSheet::from("/mytheme/styles.css").with_version(env!("CARGO_PKG_VERSION")), /// )); /// } diff --git a/src/locale/l10n.rs b/src/locale/l10n.rs index af5e9535..7b096d9f 100644 --- a/src/locale/l10n.rs +++ b/src/locale/l10n.rs @@ -62,17 +62,6 @@ 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 { @@ -188,3 +177,14 @@ 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() + } +} diff --git a/src/response/page.rs b/src/response/page.rs index a30c2324..83fe00ba 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: AssetsOp) -> Self { + fn with_assets(mut self, op: ContextOp) -> Self { self.context.alter_assets(op); self } @@ -380,8 +380,4 @@ 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); - } } diff --git a/src/util.rs b/src/util.rs index 6ea4b70d..de275efa 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 7dca895d..7e5a062c 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 exist"); + let c1 = p1.copyright().expect("Expected copyright to exis"); assert!(c1.contains(&Utc::now().format("%Y").to_string())); assert!(c1.contains(&global::SETTINGS.app.name)); } diff --git a/tests/html_markup.rs b/tests/html_markup.rs deleted file mode 100644 index 4dae8b2c..00000000 --- a/tests/html_markup.rs +++ /dev/null @@ -1,107 +0,0 @@ -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 { - Self::default() - } - - fn is_renderable(&self, cx: &mut Context) -> bool { - cx.param_or::("renderable", true) - } - - fn prepare_component(&self, _cx: &mut Context) -> Result { - Ok(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 new file mode 100644 index 00000000..615ea470 --- /dev/null +++ b/tests/html_pm.rs @@ -0,0 +1,144 @@ +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" + ); + } +}