From bb34ba5887e61a71afc5d65833c07a8d76c3c29b Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sat, 23 Aug 2025 18:52:45 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20[html]=20Cambia=20tipo?= =?UTF-8?q?s=20`Option...`=20por=20`Attr...`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renombra los tipos para atributos HTML `Id`, `Name`, `Value` (`String`), `L10n` (`Translate`) y `Classes`. Y mueve `OptionComponent` al *core* de componentes como `TypedSlot`. --- .../component/after_render_component.rs | 4 +- .../component/before_render_component.rs | 4 +- src/base/action/component/is_renderable.rs | 4 +- src/core/component.rs | 3 + src/core/component/children.rs | 17 ++-- src/core/component/slot.rs | 64 +++++++++++++ src/html.rs | 90 +++++++++++++------ src/html/{opt_classes.rs => attr_classes.rs} | 14 +-- src/html/attr_id.rs | 63 +++++++++++++ src/html/{opt_translated.rs => attr_l10n.rs} | 16 ++-- src/html/attr_name.rs | 63 +++++++++++++ src/html/attr_value.rs | 65 ++++++++++++++ src/html/opt_component.rs | 68 -------------- src/html/opt_id.rs | 59 ------------ src/html/opt_name.rs | 59 ------------ src/html/opt_string.rs | 57 ------------ src/response/page.rs | 28 +++--- 17 files changed, 367 insertions(+), 311 deletions(-) create mode 100644 src/core/component/slot.rs rename src/html/{opt_classes.rs => attr_classes.rs} (90%) create mode 100644 src/html/attr_id.rs rename src/html/{opt_translated.rs => attr_l10n.rs} (79%) create mode 100644 src/html/attr_name.rs create mode 100644 src/html/attr_value.rs delete mode 100644 src/html/opt_component.rs delete mode 100644 src/html/opt_id.rs delete mode 100644 src/html/opt_name.rs delete mode 100644 src/html/opt_string.rs diff --git a/src/base/action/component/after_render_component.rs b/src/base/action/component/after_render_component.rs index 917f322..0cb0334 100644 --- a/src/base/action/component/after_render_component.rs +++ b/src/base/action/component/after_render_component.rs @@ -6,7 +6,7 @@ use crate::base::action::FnActionWithComponent; pub struct AfterRender { f: FnActionWithComponent, referer_type_id: Option, - referer_id: OptionId, + referer_id: AttrId, weight: Weight, } @@ -34,7 +34,7 @@ impl AfterRender { AfterRender { f, referer_type_id: Some(UniqueId::of::()), - referer_id: OptionId::default(), + referer_id: AttrId::default(), weight: 0, } } diff --git a/src/base/action/component/before_render_component.rs b/src/base/action/component/before_render_component.rs index 8c2e38d..46ff9aa 100644 --- a/src/base/action/component/before_render_component.rs +++ b/src/base/action/component/before_render_component.rs @@ -6,7 +6,7 @@ use crate::base::action::FnActionWithComponent; pub struct BeforeRender { f: FnActionWithComponent, referer_type_id: Option, - referer_id: OptionId, + referer_id: AttrId, weight: Weight, } @@ -34,7 +34,7 @@ impl BeforeRender { BeforeRender { f, referer_type_id: Some(UniqueId::of::()), - referer_id: OptionId::default(), + referer_id: AttrId::default(), weight: 0, } } diff --git a/src/base/action/component/is_renderable.rs b/src/base/action/component/is_renderable.rs index baa86f1..5a0e244 100644 --- a/src/base/action/component/is_renderable.rs +++ b/src/base/action/component/is_renderable.rs @@ -11,7 +11,7 @@ pub type FnIsRenderable = fn(component: &C, cx: &Context) -> bool; pub struct IsRenderable { f: FnIsRenderable, referer_type_id: Option, - referer_id: OptionId, + referer_id: AttrId, weight: Weight, } @@ -39,7 +39,7 @@ impl IsRenderable { IsRenderable { f, referer_type_id: Some(UniqueId::of::()), - referer_id: OptionId::default(), + referer_id: AttrId::default(), weight: 0, } } diff --git a/src/core/component.rs b/src/core/component.rs index 17b9b73..3691472 100644 --- a/src/core/component.rs +++ b/src/core/component.rs @@ -7,3 +7,6 @@ mod children; pub use children::Children; pub use children::{Child, ChildOp}; pub use children::{Typed, TypedOp}; + +mod slot; +pub use slot::TypedSlot; diff --git a/src/core/component/children.rs b/src/core/component/children.rs index fb85db7..cb112e1 100644 --- a/src/core/component/children.rs +++ b/src/core/component/children.rs @@ -9,13 +9,13 @@ use std::vec::IntoIter; /// Representa un componente encapsulado de forma segura y compartida. /// -/// Esta estructura permite manipular y renderizar cualquier tipo que implemente [`Component`], -/// garantizando acceso concurrente a través de [`Arc>`]. +/// Esta estructura permite manipular y renderizar un componente que implemente [`Component`], y +/// habilita acceso concurrente mediante [`Arc>`]. #[derive(Clone)] pub struct Child(Arc>); impl Child { - /// Crea un nuevo [`Child`] a partir de un componente. + /// Crea un nuevo `Child` a partir de un componente. pub fn with(component: impl Component) -> Self { Child(Arc::new(RwLock::new(component))) } @@ -46,7 +46,8 @@ impl Child { /// Variante tipada de [`Child`] para evitar conversiones durante el uso. /// -/// Facilita el acceso a componentes del mismo tipo sin necesidad de hacer `downcast`. +/// Esta estructura permite manipular y renderizar un componente concreto que implemente +/// [`Component`], y habilita acceso concurrente mediante [`Arc>`]. pub struct Typed(Arc>); impl Clone for Typed { @@ -56,7 +57,7 @@ impl Clone for Typed { } impl Typed { - /// Crea un nuevo [`Typed`] a partir de un componente. + /// Crea un nuevo `Typed` a partir de un componente. pub fn with(component: C) -> Self { Typed(Arc::new(RwLock::new(component))) } @@ -284,7 +285,7 @@ impl IntoIterator for Children { /// /// # Ejemplo de uso: /// - /// ```rust#ignore + /// ```rust,ignore /// let children = Children::new().with(child1).with(child2); /// for child in children { /// println!("{:?}", child.id()); @@ -303,7 +304,7 @@ impl<'a> IntoIterator for &'a Children { /// /// # Ejemplo de uso: /// - /// ```rust#ignore + /// ```rust,ignore /// let children = Children::new().with(child1).with(child2); /// for child in &children { /// println!("{:?}", child.id()); @@ -322,7 +323,7 @@ impl<'a> IntoIterator for &'a mut Children { /// /// # Ejemplo de uso: /// - /// ```rust#ignore + /// ```rust,ignore /// let mut children = Children::new().with(child1).with(child2); /// for child in &mut children { /// child.render(&mut context); diff --git a/src/core/component/slot.rs b/src/core/component/slot.rs new file mode 100644 index 0000000..19ed72a --- /dev/null +++ b/src/core/component/slot.rs @@ -0,0 +1,64 @@ +use crate::builder_fn; +use crate::core::component::{Component, Typed}; +use crate::html::{html, Context, Markup}; + +/// Contenedor para un componente [`Typed`] opcional. +/// +/// Un `TypedSlot` actúa como un contenedor dentro de otro componente para incluir o no un +/// subcomponente. Internamente encapsula `Option>`, pero proporciona una API más sencilla +/// para construir estructuras jerárquicas. +/// +/// # Ejemplo +/// +/// ```rust,ignore +/// use pagetop::prelude::*; +/// +/// let comp = MyComponent::new(); +/// let opt = TypedSlot::new(comp); +/// assert!(opt.get().is_some()); +/// ``` +pub struct TypedSlot(Option>); + +impl Default for TypedSlot { + fn default() -> Self { + TypedSlot(None) + } +} + +impl TypedSlot { + /// Crea un nuevo [`TypedSlot`]. + /// + /// El componente se envuelve automáticamente en un [`Typed`] y se almacena. + pub fn new(component: C) -> Self { + TypedSlot(Some(Typed::with(component))) + } + + // TypedSlot BUILDER ********************************************************************* + + /// Establece un componente nuevo, o lo vacía. + /// + /// Si se proporciona `Some(component)`, se guarda en [`Typed`]; y si es `None`, se limpia. + #[builder_fn] + pub fn with_value(mut self, component: Option) -> Self { + self.0 = component.map(Typed::with); + self + } + + // TypedSlot GETTERS ********************************************************************* + + /// Devuelve un clon (incrementa el contador `Arc`) de [`Typed`], si existe. + pub fn get(&self) -> Option> { + self.0.clone() + } + + // TypedSlot RENDER ************************************************************************ + + /// Renderiza el componente, si existe. + pub fn render(&self, cx: &mut Context) -> Markup { + if let Some(component) = &self.0 { + component.render(cx) + } else { + html! {} + } + } +} diff --git a/src/html.rs b/src/html.rs index 82fa906..784457e 100644 --- a/src/html.rs +++ b/src/html.rs @@ -3,52 +3,82 @@ mod maud; pub use maud::{display, html, html_private, Escaper, Markup, PreEscaped, Render, DOCTYPE}; +// HTML DOCUMENT ASSETS **************************************************************************** + mod assets; pub use assets::favicon::Favicon; pub use assets::javascript::JavaScript; pub use assets::stylesheet::{StyleSheet, TargetMedia}; pub(crate) use assets::Assets; +// HTML DOCUMENT CONTEXT *************************************************************************** + mod context; pub use context::{AssetsOp, Context, ErrorParam}; -mod opt_id; -pub use opt_id::OptionId; +// HTML ATTRIBUTES ********************************************************************************* -mod opt_name; -pub use opt_name::OptionName; +mod attr_id; +pub use attr_id::AttrId; +/// **Obsoleto desde la versión 0.4.0**: usar [`AttrId`] en su lugar. +#[deprecated(since = "0.4.0", note = "Use `AttrId` instead")] +pub type OptionId = AttrId; -mod opt_string; -pub use opt_string::OptionString; +mod attr_name; +pub use attr_name::AttrName; +/// **Obsoleto desde la versión 0.4.0**: usar [`AttrName`] en su lugar. +#[deprecated(since = "0.4.0", note = "Use `AttrName` instead")] +pub type OptionName = AttrName; -mod opt_translated; -pub use opt_translated::OptionTranslated; +mod attr_value; +pub use attr_value::AttrValue; +/// **Obsoleto desde la versión 0.4.0**: usar [`AttrValue`] en su lugar. +#[deprecated(since = "0.4.0", note = "Use `AttrValue` instead")] +pub type OptionString = AttrValue; -mod opt_classes; -pub use opt_classes::{ClassesOp, OptionClasses}; +mod attr_l10n; +pub use attr_l10n::AttrL10n; +/// **Obsoleto desde la versión 0.4.0**: usar [`AttrL10n`] en su lugar. +#[deprecated(since = "0.4.0", note = "Use `AttrL10n` instead")] +pub type OptionTranslated = AttrL10n; -mod opt_component; -pub use opt_component::OptionComponent; +mod attr_classes; +pub use attr_classes::{AttrClasses, ClassesOp}; +/// **Obsoleto desde la versión 0.4.0**: usar [`AttrClasses`] en su lugar. +#[deprecated(since = "0.4.0", note = "Use `AttrClasses` instead")] +pub type OptionClasses = AttrClasses; -use crate::AutoDefault; +use crate::{core, AutoDefault}; + +/// **Obsoleto desde la versión 0.4.0**: usar [`TypedSlot`](crate::core::component::TypedSlot) en su +/// lugar. +#[deprecated( + since = "0.4.0", + note = "Use `pagetop::core::component::TypedSlot` instead" +)] +#[allow(type_alias_bounds)] +pub type OptionComponent = core::component::TypedSlot; /// Prepara contenido HTML para su conversión a [`Markup`]. /// -/// Este tipo encapsula distintos orígenes de contenido HTML (texto plano, HTML escapado o marcado -/// ya procesado) para renderizar de forma homogénea en plantillas sin interferir con el uso -/// estándar de [`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::*; /// -/// let fragment = PrepareMarkup::Text(String::from("Hola mundo")); +/// // Texto normal, se escapa automáticamente para evitar inyección de HTML. +/// let fragment = PrepareMarkup::Escaped(String::from("Hola mundo")); /// assert_eq!(fragment.render().into_string(), "Hola <b>mundo</b>"); /// -/// let raw_html = PrepareMarkup::Escaped(String::from("negrita")); +/// // HTML literal, se inserta directamente, sin escapado adicional. +/// let raw_html = PrepareMarkup::Raw(String::from("negrita")); /// assert_eq!(raw_html.render().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." } @@ -60,14 +90,22 @@ use crate::AutoDefault; /// ``` #[derive(AutoDefault)] pub enum PrepareMarkup { - /// No se genera contenido HTML (devuelve `html! {}`). + /// No se genera contenido HTML (equivale a `html! {}`). #[default] None, - /// Texto estático que se escapará automáticamente para no ser interpretado como HTML. - Text(String), - /// Contenido sin escapado adicional, útil para HTML generado externamente. + /// 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), } @@ -76,8 +114,8 @@ impl PrepareMarkup { pub fn is_empty(&self) -> bool { match self { PrepareMarkup::None => true, - PrepareMarkup::Text(text) => text.is_empty(), - PrepareMarkup::Escaped(string) => string.is_empty(), + PrepareMarkup::Escaped(text) => text.is_empty(), + PrepareMarkup::Raw(string) => string.is_empty(), PrepareMarkup::With(markup) => markup.is_empty(), } } @@ -88,8 +126,8 @@ impl Render for PrepareMarkup { fn render(&self) -> Markup { match self { PrepareMarkup::None => html! {}, - PrepareMarkup::Text(text) => html! { (text) }, - PrepareMarkup::Escaped(string) => html! { (PreEscaped(string)) }, + PrepareMarkup::Escaped(text) => html! { (text) }, + PrepareMarkup::Raw(string) => html! { (PreEscaped(string)) }, PrepareMarkup::With(markup) => html! { (markup) }, } } diff --git a/src/html/opt_classes.rs b/src/html/attr_classes.rs similarity index 90% rename from src/html/opt_classes.rs rename to src/html/attr_classes.rs index a985762..92851aa 100644 --- a/src/html/opt_classes.rs +++ b/src/html/attr_classes.rs @@ -1,6 +1,6 @@ use crate::{builder_fn, AutoDefault}; -/// Operaciones disponibles sobre la lista de clases en [`OptionClasses`]. +/// Operaciones disponibles sobre la lista de clases en [`AttrClasses`]. pub enum ClassesOp { /// Añade al final (si no existe). Add, @@ -33,7 +33,7 @@ pub enum ClassesOp { /// ```rust /// use pagetop::prelude::*; /// -/// let classes = OptionClasses::new("Btn btn-primary") +/// let classes = AttrClasses::new("Btn btn-primary") /// .with_value(ClassesOp::Add, "Active") /// .with_value(ClassesOp::Remove, "btn-primary"); /// @@ -41,14 +41,14 @@ pub enum ClassesOp { /// assert!(classes.contains("active")); /// ``` #[derive(AutoDefault, Clone, Debug)] -pub struct OptionClasses(Vec); +pub struct AttrClasses(Vec); -impl OptionClasses { +impl AttrClasses { pub fn new(classes: impl AsRef) -> Self { - OptionClasses::default().with_value(ClassesOp::Prepend, classes) + AttrClasses::default().with_value(ClassesOp::Prepend, classes) } - // OptionClasses BUILDER *********************************************************************** + // AttrClasses BUILDER ************************************************************************* #[builder_fn] pub fn with_value(mut self, op: ClassesOp, classes: impl AsRef) -> Self { @@ -114,7 +114,7 @@ impl OptionClasses { } } - // OptionClasses GETTERS *********************************************************************** + // AttrClasses GETTERS ************************************************************************* /// Devuele la cadena de clases, si existe. pub fn get(&self) -> Option { diff --git a/src/html/attr_id.rs b/src/html/attr_id.rs new file mode 100644 index 0000000..8bb1d33 --- /dev/null +++ b/src/html/attr_id.rs @@ -0,0 +1,63 @@ +use crate::{builder_fn, AutoDefault}; + +/// Identificador normalizado para el atributo `id` o similar de HTML. +/// +/// Este tipo encapsula `Option` garantizando un valor normalizado para su uso: +/// +/// - Se eliminan los espacios al principio y al final. +/// - Se convierte a minúsculas. +/// - Se sustituyen los espacios intermedios por guiones bajos (`_`). +/// - Si el resultado es una cadena vacía, se guarda `None`. +/// +/// # Ejemplo +/// +/// ```rust +/// use pagetop::prelude::*; +/// +/// let id = AttrId::new(" main Section "); +/// assert_eq!(id.as_str(), Some("main_section")); +/// +/// let empty = AttrId::default(); +/// assert_eq!(empty.get(), None); +/// ``` +#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)] +pub struct AttrId(Option); + +impl AttrId { + /// Crea un nuevo `AttrId` normalizando el valor. + pub fn new(value: impl AsRef) -> Self { + AttrId::default().with_value(value) + } + + // AttrId BUILDER ****************************************************************************** + + /// Establece un identificador nuevo normalizando el valor. + #[builder_fn] + pub fn with_value(mut self, value: impl AsRef) -> Self { + let value = value.as_ref().trim().to_ascii_lowercase().replace(' ', "_"); + self.0 = if value.is_empty() { None } else { Some(value) }; + self + } + + // AttrId GETTERS ****************************************************************************** + + /// Devuelve el identificador normalizado, si existe. + pub fn get(&self) -> Option { + self.0.as_ref().cloned() + } + + /// Devuelve el identificador normalizado (sin clonar), si existe. + pub fn as_str(&self) -> Option<&str> { + self.0.as_deref() + } + + /// Devuelve el identificador normalizado (propiedad), si existe. + pub fn into_inner(self) -> Option { + self.0 + } + + /// `true` si no hay valor. + pub fn is_empty(&self) -> bool { + self.0.is_none() + } +} diff --git a/src/html/opt_translated.rs b/src/html/attr_l10n.rs similarity index 79% rename from src/html/opt_translated.rs rename to src/html/attr_l10n.rs index b15ea18..cd5b389 100644 --- a/src/html/opt_translated.rs +++ b/src/html/attr_l10n.rs @@ -2,7 +2,7 @@ use crate::html::Markup; use crate::locale::{L10n, LangId}; use crate::{builder_fn, AutoDefault}; -/// Cadena para traducir al renderizar ([`locale`](crate::locale)). +/// Texto para [traducir](crate::locale) en atributos HTML. /// /// Encapsula un tipo [`L10n`] para manejar traducciones de forma segura. /// @@ -12,7 +12,7 @@ use crate::{builder_fn, AutoDefault}; /// use pagetop::prelude::*; /// /// // Traducción por clave en las locales por defecto de PageTop. -/// let hello = OptionTranslated::new(L10n::l("test-hello-world")); +/// let hello = AttrL10n::new(L10n::l("test-hello-world")); /// /// // Español disponible. /// assert_eq!( @@ -31,15 +31,15 @@ use crate::{builder_fn, AutoDefault}; /// assert_eq!(markup.into_string(), "¡Hola mundo!"); /// ``` #[derive(AutoDefault, Clone, Debug)] -pub struct OptionTranslated(L10n); +pub struct AttrL10n(L10n); -impl OptionTranslated { - /// Crea una nueva instancia [`OptionTranslated`]. +impl AttrL10n { + /// Crea una nueva instancia `AttrL10n`. pub fn new(value: L10n) -> Self { - OptionTranslated(value) + AttrL10n(value) } - // OptionTranslated BUILDER ******************************************************************** + // AttrL10n BUILDER **************************************************************************** /// Establece una traducción nueva. #[builder_fn] @@ -48,7 +48,7 @@ impl OptionTranslated { self } - // OptionTranslated GETTERS ******************************************************************** + // AttrL10n GETTERS **************************************************************************** /// Devuelve la traducción para `language`, si existe. pub fn using(&self, language: &impl LangId) -> Option { diff --git a/src/html/attr_name.rs b/src/html/attr_name.rs new file mode 100644 index 0000000..928f841 --- /dev/null +++ b/src/html/attr_name.rs @@ -0,0 +1,63 @@ +use crate::{builder_fn, AutoDefault}; + +/// Nombre normalizado para el atributo `name` o similar de HTML. +/// +/// Este tipo encapsula `Option` garantizando un valor normalizado para su uso: +/// +/// - Se eliminan los espacios al principio y al final. +/// - Se convierte a minúsculas. +/// - Se sustituyen los espacios intermedios por guiones bajos (`_`). +/// - Si el resultado es una cadena vacía, se guarda `None`. +/// +/// # Ejemplo +/// +/// ```rust +/// use pagetop::prelude::*; +/// +/// let name = AttrName::new(" DISplay name "); +/// assert_eq!(name.as_str(), Some("display_name")); +/// +/// let empty = AttrName::default(); +/// assert_eq!(empty.get(), None); +/// ``` +#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)] +pub struct AttrName(Option); + +impl AttrName { + /// Crea un nuevo `AttrName` normalizando el valor. + pub fn new(value: impl AsRef) -> Self { + AttrName::default().with_value(value) + } + + // AttrName BUILDER **************************************************************************** + + /// Establece un nombre nuevo normalizando el valor. + #[builder_fn] + pub fn with_value(mut self, value: impl AsRef) -> Self { + let value = value.as_ref().trim().to_ascii_lowercase().replace(' ', "_"); + self.0 = if value.is_empty() { None } else { Some(value) }; + self + } + + // AttrName GETTERS **************************************************************************** + + /// Devuelve el nombre normalizado, si existe. + pub fn get(&self) -> Option { + self.0.as_ref().cloned() + } + + /// Devuelve el nombre normalizado (sin clonar), si existe. + pub fn as_str(&self) -> Option<&str> { + self.0.as_deref() + } + + /// Devuelve el nombre normalizado (propiedad), si existe. + pub fn into_inner(self) -> Option { + self.0 + } + + /// `true` si no hay valor. + pub fn is_empty(&self) -> bool { + self.0.is_none() + } +} diff --git a/src/html/attr_value.rs b/src/html/attr_value.rs new file mode 100644 index 0000000..c70229f --- /dev/null +++ b/src/html/attr_value.rs @@ -0,0 +1,65 @@ +use crate::{builder_fn, AutoDefault}; + +/// Cadena normalizada para renderizar en atributos HTML. +/// +/// Este tipo encapsula `Option` garantizando un valor normalizado para su uso: +/// +/// - Se eliminan los espacios al principio y al final. +/// - Si el resultado es una cadena vacía, se guarda `None`. +/// +/// # Ejemplo +/// +/// ```rust +/// use pagetop::prelude::*; +/// +/// let s = AttrValue::new(" a new string "); +/// assert_eq!(s.as_str(), Some("a new string")); +/// +/// let empty = AttrValue::default(); +/// assert_eq!(empty.get(), None); +/// ``` +#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)] +pub struct AttrValue(Option); + +impl AttrValue { + /// Crea un nuevo `AttrValue` normalizando el valor. + pub fn new(value: impl AsRef) -> Self { + AttrValue::default().with_value(value) + } + + // AttrValue BUILDER *************************************************************************** + + /// Establece una cadena nueva normalizando el valor. + #[builder_fn] + pub fn with_value(mut self, value: impl AsRef) -> Self { + let value = value.as_ref().trim(); + self.0 = if value.is_empty() { + None + } else { + Some(value.to_owned()) + }; + self + } + + // AttrValue GETTERS *************************************************************************** + + /// Devuelve la cadena normalizada, si existe. + pub fn get(&self) -> Option { + self.0.as_ref().cloned() + } + + /// Devuelve la cadena normalizada (sin clonar), si existe. + pub fn as_str(&self) -> Option<&str> { + self.0.as_deref() + } + + /// Devuelve la cadena normalizada (propiedad), si existe. + pub fn into_inner(self) -> Option { + self.0 + } + + /// `true` si no hay valor. + pub fn is_empty(&self) -> bool { + self.0.is_none() + } +} diff --git a/src/html/opt_component.rs b/src/html/opt_component.rs deleted file mode 100644 index 39106d9..0000000 --- a/src/html/opt_component.rs +++ /dev/null @@ -1,68 +0,0 @@ -use crate::builder_fn; -use crate::core::component::{Component, Typed}; -use crate::html::{html, Context, Markup}; - -/// Contenedor de componente para incluir en otros componentes. -/// -/// Este tipo encapsula `Option>` para incluir un componente de manera segura en otros -/// componentes, útil para representar estructuras complejas. -/// -/// # Ejemplo -/// -/// ```rust,ignore -/// use pagetop::prelude::*; -/// -/// let comp = MyComponent::new(); -/// let opt = OptionComponent::new(comp); -/// assert!(opt.get().is_some()); -/// ``` -pub struct OptionComponent(Option>); - -impl Default for OptionComponent { - fn default() -> Self { - OptionComponent(None) - } -} - -impl OptionComponent { - /// Crea un nuevo [`OptionComponent`]. - /// - /// El componente se envuelve automáticamente en un [`Typed`] y se almacena. - pub fn new(component: C) -> Self { - OptionComponent::default().with_value(Some(component)) - } - - // OptionComponent BUILDER ********************************************************************* - - /// Establece un componente nuevo, o lo vacía. - /// - /// Si se proporciona `Some(component)`, se guarda en [`Typed`]; y si es `None`, se limpia. - #[builder_fn] - pub fn with_value(mut self, component: Option) -> Self { - if let Some(component) = component { - self.0 = Some(Typed::with(component)); - } else { - self.0 = None; - } - self - } - - // OptionComponent GETTERS ********************************************************************* - - /// Devuelve el componente, si existe. - pub fn get(&self) -> Option> { - if let Some(value) = &self.0 { - return Some(value.clone()); - } - None - } - - /// Renderiza el componente, si existe. - pub fn render(&self, cx: &mut Context) -> Markup { - if let Some(component) = &self.0 { - component.render(cx) - } else { - html! {} - } - } -} diff --git a/src/html/opt_id.rs b/src/html/opt_id.rs deleted file mode 100644 index 139fdcd..0000000 --- a/src/html/opt_id.rs +++ /dev/null @@ -1,59 +0,0 @@ -use crate::{builder_fn, AutoDefault}; - -/// Identificador normalizado para el atributo `id` o similar de HTML. -/// -/// Este tipo encapsula `Option` garantizando un valor normalizado para su uso. -/// -/// # Normalización -/// -/// - Se eliminan los espacios al principio y al final. -/// - Se convierte a minúsculas. -/// - Se sustituyen los espacios intermedios por guiones bajos (`_`). -/// - Si el resultado es una cadena vacía, se guarda `None`. -/// -/// # Ejemplo -/// -/// ```rust -/// use pagetop::prelude::*; -/// -/// let id = OptionId::new(" main Section "); -/// assert_eq!(id.get(), Some(String::from("main_section"))); -/// -/// let empty = OptionId::default(); -/// assert_eq!(empty.get(), None); -/// ``` -#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)] -pub struct OptionId(Option); - -impl OptionId { - /// Crea un nuevo [`OptionId`]. - /// - /// El valor se normaliza automáticamente. - pub fn new(value: impl AsRef) -> Self { - OptionId::default().with_value(value) - } - - // OptionId BUILDER **************************************************************************** - - /// Establece un identificador nuevo. - /// - /// El valor se normaliza automáticamente. - #[builder_fn] - pub fn with_value(mut self, value: impl AsRef) -> Self { - let value = value.as_ref().trim().to_ascii_lowercase().replace(' ', "_"); - self.0 = (!value.is_empty()).then_some(value); - self - } - - // OptionId GETTERS **************************************************************************** - - /// Devuelve el identificador, si existe. - pub fn get(&self) -> Option { - if let Some(value) = &self.0 { - if !value.is_empty() { - return Some(value.to_owned()); - } - } - None - } -} diff --git a/src/html/opt_name.rs b/src/html/opt_name.rs deleted file mode 100644 index ffb0b98..0000000 --- a/src/html/opt_name.rs +++ /dev/null @@ -1,59 +0,0 @@ -use crate::{builder_fn, AutoDefault}; - -/// Nombre normalizado para el atributo `name` o similar de HTML. -/// -/// Este tipo encapsula `Option` garantizando un valor normalizado para su uso. -/// -/// # Normalización -/// -/// - Se eliminan los espacios al principio y al final. -/// - Se convierte a minúsculas. -/// - Se sustituyen los espacios intermedios por guiones bajos (`_`). -/// - Si el resultado es una cadena vacía, se guarda `None`. -/// -/// # Ejemplo -/// -/// ```rust -/// use pagetop::prelude::*; -/// -/// let name = OptionName::new(" DISplay name "); -/// assert_eq!(name.get(), Some(String::from("display_name"))); -/// -/// let empty = OptionName::default(); -/// assert_eq!(empty.get(), None); -/// ``` -#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)] -pub struct OptionName(Option); - -impl OptionName { - /// Crea un nuevo [`OptionName`]. - /// - /// El valor se normaliza automáticamente. - pub fn new(value: impl AsRef) -> Self { - OptionName::default().with_value(value) - } - - // OptionName BUILDER ************************************************************************** - - /// Establece un nombre nuevo. - /// - /// El valor se normaliza automáticamente. - #[builder_fn] - pub fn with_value(mut self, value: impl AsRef) -> Self { - let value = value.as_ref().trim().to_ascii_lowercase().replace(' ', "_"); - self.0 = (!value.is_empty()).then_some(value); - self - } - - // OptionName GETTERS ************************************************************************** - - /// Devuelve el nombre, si existe. - pub fn get(&self) -> Option { - if let Some(value) = &self.0 { - if !value.is_empty() { - return Some(value.to_owned()); - } - } - None - } -} diff --git a/src/html/opt_string.rs b/src/html/opt_string.rs deleted file mode 100644 index 5bfd9c7..0000000 --- a/src/html/opt_string.rs +++ /dev/null @@ -1,57 +0,0 @@ -use crate::{builder_fn, AutoDefault}; - -/// Cadena normalizada para renderizar en atributos HTML. -/// -/// Este tipo encapsula `Option` garantizando un valor normalizado para su uso. -/// -/// # Normalización -/// -/// - Se eliminan los espacios al principio y al final. -/// - Si el resultado es una cadena vacía, se guarda `None`. -/// -/// # Ejemplo -/// -/// ```rust -/// use pagetop::prelude::*; -/// -/// let s = OptionString::new(" a new string "); -/// assert_eq!(s.get(), Some(String::from("a new string"))); -/// -/// let empty = OptionString::default(); -/// assert_eq!(empty.get(), None); -/// ``` -#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)] -pub struct OptionString(Option); - -impl OptionString { - /// Crea un nuevo [`OptionString`]. - /// - /// El valor se normaliza automáticamente. - pub fn new(value: impl AsRef) -> Self { - OptionString::default().with_value(value) - } - - // OptionString BUILDER ************************************************************************ - - /// Establece una cadena nueva. - /// - /// El valor se normaliza automáticamente. - #[builder_fn] - pub fn with_value(mut self, value: impl AsRef) -> Self { - let value = value.as_ref().trim().to_owned(); - self.0 = (!value.is_empty()).then_some(value); - self - } - - // OptionString GETTERS ************************************************************************ - - /// Devuelve la cadena, si existe. - pub fn get(&self) -> Option { - if let Some(value) = &self.0 { - if !value.is_empty() { - return Some(value.to_owned()); - } - } - None - } -} diff --git a/src/response/page.rs b/src/response/page.rs index f30e299..ea88e84 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -7,8 +7,10 @@ use crate::base::action; use crate::builder_fn; use crate::core::component::{Child, ChildOp, Component}; use crate::core::theme::{ChildrenInRegions, ThemeRef, REGION_CONTENT}; -use crate::html::{html, AssetsOp, Context, Markup, DOCTYPE}; -use crate::html::{ClassesOp, OptionClasses, OptionId, OptionTranslated}; +use crate::html::{html, Markup, DOCTYPE}; +use crate::html::{AssetsOp, Context}; +use crate::html::{AttrClasses, ClassesOp}; +use crate::html::{AttrId, AttrL10n}; use crate::locale::{CharacterDirection, L10n, LangId, LanguageIdentifier}; use crate::service::HttpRequest; @@ -19,13 +21,13 @@ use crate::service::HttpRequest; /// renderizado. #[rustfmt::skip] pub struct Page { - title : OptionTranslated, - description : OptionTranslated, + title : AttrL10n, + description : AttrL10n, metadata : Vec<(&'static str, &'static str)>, properties : Vec<(&'static str, &'static str)>, context : Context, - body_id : OptionId, - body_classes: OptionClasses, + body_id : AttrId, + body_classes: AttrClasses, regions : ChildrenInRegions, } @@ -37,13 +39,13 @@ impl Page { #[rustfmt::skip] pub fn new(request: Option) -> Self { Page { - title : OptionTranslated::default(), - description : OptionTranslated::default(), + title : AttrL10n::default(), + description : AttrL10n::default(), metadata : Vec::default(), properties : Vec::default(), context : Context::new(request), - body_id : OptionId::default(), - body_classes: OptionClasses::default(), + body_id : AttrId::default(), + body_classes: AttrClasses::default(), regions : ChildrenInRegions::default(), } } @@ -113,7 +115,7 @@ impl Page { self } - /// Modifica las clases CSS del elemento `` con una operación sobre [`OptionClasses`]. + /// Modifica las clases CSS del elemento `` con una operación sobre [`AttrClasses`]. #[builder_fn] pub fn with_body_classes(mut self, op: ClassesOp, classes: impl AsRef) -> Self { self.body_classes.alter_value(op, classes); @@ -183,12 +185,12 @@ impl Page { } /// Devuelve el identificador del elemento ``. - pub fn body_id(&self) -> &OptionId { + pub fn body_id(&self) -> &AttrId { &self.body_id } /// Devuelve las clases CSS del elemento ``. - pub fn body_classes(&self) -> &OptionClasses { + pub fn body_classes(&self) -> &AttrClasses { &self.body_classes } From 7ebd7b0e4972c4a907c34ef2ffcbe97708fd0d13 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sat, 23 Aug 2025 19:34:26 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=85=20[tests]=20Ampl=C3=ADa=20pruebas?= =?UTF-8?q?=20para=20`PrepareMarkup'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/html.rs | 105 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 99 insertions(+), 6 deletions(-) diff --git a/tests/html.rs b/tests/html.rs index 315f74a..1499c70 100644 --- a/tests/html.rs +++ b/tests/html.rs @@ -1,17 +1,110 @@ use pagetop::prelude::*; #[pagetop::test] -async fn prepare_markup_is_empty() { - let _app = service::test::init_service(Application::new().test()).await; +async fn prepare_markup_render_none_is_empty_string() { + assert_eq!(render(&PrepareMarkup::None), ""); +} +#[pagetop::test] +async fn prepare_markup_render_escaped_escapes_html_and_ampersands() { + let pm = PrepareMarkup::Escaped(String::from("& \" ' ")); + assert_eq!(render(&pm), "<b>& " ' </b>"); +} + +#[pagetop::test] +async fn prepare_markup_render_raw_is_inserted_verbatim() { + let pm = PrepareMarkup::Raw(String::from("bold")); + assert_eq!(render(&pm), "bold"); +} + +#[pagetop::test] +async fn prepare_markup_render_with_keeps_structure() { + let pm = PrepareMarkup::With(html! { + h2 { "Sample title" } + p { "This is a paragraph." } + }); + assert_eq!( + render(&pm), + "

Sample title

This is a paragraph.

" + ); +} + +#[pagetop::test] +async fn prepare_markup_does_not_double_escape_when_wrapped_in_html_macro() { + // Escaped: dentro de `html!` no debe volver a escaparse. + let escaped = PrepareMarkup::Escaped("x".into()); + let wrapped_escaped = html! { div { (escaped) } }; + assert_eq!( + wrapped_escaped.into_string(), + "
<i>x</i>
" + ); + + // Raw: tampoco debe escaparse al integrarlo. + let raw = PrepareMarkup::Raw("x".into()); + let wrapped_raw = html! { div { (raw) } }; + assert_eq!(wrapped_raw.into_string(), "
x
"); + + // With: debe incrustar el Markup tal cual. + let with = PrepareMarkup::With(html! { span.title { "ok" } }); + let wrapped_with = html! { div { (with) } }; + assert_eq!( + wrapped_with.into_string(), + "
ok
" + ); +} + +#[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!(render(&esc), "Hello, tomorrow coffee ☕ & donuts!"); + + // Raw debe pasar íntegro. + let raw = PrepareMarkup::Raw("Title — section © 2025".into()); + assert_eq!(render(&raw), "Title — section © 2025"); +} + +#[pagetop::test] +async fn prepare_markup_is_empty_semantics() { assert!(PrepareMarkup::None.is_empty()); - assert!(PrepareMarkup::Text(String::from("")).is_empty()); - assert!(!PrepareMarkup::Text(String::from("x")).is_empty()); - assert!(PrepareMarkup::Escaped(String::new()).is_empty()); - assert!(!PrepareMarkup::Escaped("a".into()).is_empty()); + assert!(PrepareMarkup::Escaped(String::from("")).is_empty()); + assert!(!PrepareMarkup::Escaped(String::from("x")).is_empty()); + + assert!(PrepareMarkup::Raw(String::new()).is_empty()); + assert!(PrepareMarkup::Raw(String::from("")).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_equivalence_between_render_and_inline_in_html_macro() { + let cases = [ + PrepareMarkup::None, + PrepareMarkup::Escaped("x".into()), + PrepareMarkup::Raw("x".into()), + PrepareMarkup::With(html! { b { "x" } }), + ]; + + for pm in cases { + let rendered = render(&pm); + let in_macro = html! { (pm) }.into_string(); + assert_eq!( + rendered, in_macro, + "The output of Render and (pm) inside html! must match" + ); + } +} + +// HELPERS ***************************************************************************************** + +fn render(x: &impl Render) -> String { + x.render().into_string() }