diff --git a/extensions/pagetop-bootsier/src/lib.rs b/extensions/pagetop-bootsier/src/lib.rs index 7b83bde2..f2838d7a 100644 --- a/extensions/pagetop-bootsier/src/lib.rs +++ b/extensions/pagetop-bootsier/src/lib.rs @@ -145,7 +145,6 @@ impl Theme for Bootsier { setup_component!(component, { Button => |c| theme::bs::button::setup(c), Container => |c| theme::bs::container::setup(c), - Image => |c| theme::bs::image::setup(c), form::input::Field => |c| theme::bs::form::input::setup(c), form::select::Field => |c| theme::bs::form::select::setup(c), form::Textarea => |c| theme::bs::form::textarea::setup(c), diff --git a/extensions/pagetop-bootsier/src/theme/bs/image.rs b/extensions/pagetop-bootsier/src/theme/bs/image.rs index e5a1f649..837d25a1 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/image.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/image.rs @@ -1,24 +1,7 @@ //! Definiciones para renderizar imágenes ([`Image`]). -use pagetop::prelude::*; +mod props; +pub use props::{Size, Source}; -pub use pagetop::base::component::image::{Image, Size, Source}; - -// **< Image SETUP >******************************************************************************** - -pub(crate) fn setup(image: &mut Image) { - match image.source() { - Source::Logo(_) | Source::Responsive(_) => { - image.alter_prop(PropsOp::replace_classes("image image-fluid", "img-fluid")); - } - Source::Thumbnail(_) => { - image.alter_prop(PropsOp::replace_classes( - "image image-thumbnail", - "img-thumbnail", - )); - } - Source::Plain(_) => { - image.alter_prop(PropsOp::remove_classes("image")); - } - } -} +mod component; +pub use component::Image; diff --git a/src/base/component/image/component.rs b/extensions/pagetop-bootsier/src/theme/bs/image/component.rs similarity index 56% rename from src/base/component/image/component.rs rename to extensions/pagetop-bootsier/src/theme/bs/image/component.rs index ed799886..37bc1371 100644 --- a/src/base/component/image/component.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/image/component.rs @@ -1,33 +1,25 @@ -use crate::prelude::*; +use pagetop::prelude::*; + +use crate::theme::*; /// Componente para renderizar una **imagen**. /// /// A una imagen se le puede: /// -/// - Establecer su contenido a partir del origen definido en [`image::Source`]. -/// - Configurar sus **dimensiones** ([`with_size()`](Self::with_size)). +/// - Establecer su contenido a partir del origen definido en +/// [`image::Source`](crate::theme::bs::image::Source). +/// - Configurar sus **dimensiones** ([`with_size()`](Self::with_size)), **borde** +/// ([`Border`](crate::theme::class::Border)) y **redondeo de esquinas** +/// ([`Rounded`](crate::theme::class::Rounded)). /// - Aplicar el texto alternativo `alt` con **localización** mediante [`Lc`]. -/// -/// # Ejemplo -/// -/// ```rust,no_run -/// use pagetop::prelude::*; -/// -/// let logo = Image::with(image::Source::logo(PageTopSvg::Color)) -/// .with_alternative(Lc::n("PageTop")); -/// -/// let photo = Image::with(image::Source::responsive("/files/photo.jpg")) -/// .with_size(image::Size::Width(UnitValue::Px(320))) -/// .with_alternative(Lc::n("Team photo")); -/// ``` #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Image { /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, /// Devuelve las dimensiones de la imagen. - size: image::Size, + size: bs::image::Size, /// Devuelve el origen de la imagen. - source: image::Source, + source: bs::image::Source, /// Devuelve el texto alternativo localizado. alternative: Attr, } @@ -43,40 +35,20 @@ impl Component for Image { } fn setup(&mut self, _cx: &Context) { - self.alter_prop(PropsOp::prepend_classes(match self.source() { - image::Source::Logo(_) => "image image-fluid", - image::Source::Responsive(_) => "image image-fluid", - image::Source::Thumbnail(_) => "image image-thumbnail", - image::Source::Plain(_) => "image", - })); - // El tamaño se aplica como declaraciones `style` individuales sobre `Props`. - match *self.size() { - image::Size::Auto => {} - image::Size::Dimensions(w, h) => { - self.alter_prop(PropsOp::add_style("width", w.to_string())); - self.alter_prop(PropsOp::add_style("height", h.to_string())); - } - image::Size::Width(w) => { - self.alter_prop(PropsOp::add_style("width", w.to_string())); - } - image::Size::Height(h) => { - self.alter_prop(PropsOp::add_style("height", h.to_string())); - } - image::Size::Both(v) => { - self.alter_prop(PropsOp::add_style("width", v.to_string())); - self.alter_prop(PropsOp::add_style("height", v.to_string())); - } - } + // Clases CSS por defecto para la imagen, según el origen seleccionado. + self.alter_prop(PropsOp::prepend_classes(self.source().to_class())); } async fn prepare(&self, cx: &mut Context) -> Result { + let dimensions = self.size().to_style(); let alt_text = self.alternative().lookup(cx).unwrap_or_default(); + let is_decorative = alt_text.is_empty(); let source = match self.source() { - image::Source::Logo(logo) => { - let is_decorative = alt_text.is_empty(); + bs::image::Source::Logo(logo) => { return Ok(html! { span (self.props()) + style=[dimensions] role=[(!is_decorative).then_some("img")] aria-label=[(!is_decorative).then_some(alt_text)] aria-hidden=[is_decorative.then_some("true")] @@ -85,22 +57,23 @@ impl Component for Image { } }); } - image::Source::Responsive(source) => Some(source), - image::Source::Thumbnail(source) => Some(source), - image::Source::Plain(source) => Some(source), + bs::image::Source::Responsive(source) => Some(source), + bs::image::Source::Thumbnail(source) => Some(source), + bs::image::Source::Plain(source) => Some(source), }; Ok(html! { img src=[source] alt=(alt_text) - (self.props()) {} + (self.props()) + style=[dimensions] {} }) } } impl Image { /// Crea rápidamente una imagen especificando su origen. - pub fn with(source: image::Source) -> Self { + pub fn with(source: bs::image::Source) -> Self { Self::default().with_source(source) } @@ -114,6 +87,11 @@ impl Image { } /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. + /// + /// También acepta clases predefinidas para: + /// + /// - Establecer bordes ([`Border`]). + /// - Redondear las esquinas ([`Rounded`]). #[builder_fn] pub fn with_prop(mut self, op: PropsOp) -> Self { self.props.alter_prop(op); @@ -122,14 +100,14 @@ impl Image { /// Define las dimensiones de la imagen (auto, ancho/alto, ambos). #[builder_fn] - pub fn with_size(mut self, size: image::Size) -> Self { + pub fn with_size(mut self, size: bs::image::Size) -> Self { self.size = size; self } /// Establece el origen de la imagen, influyendo en su disposición en el contenido. #[builder_fn] - pub fn with_source(mut self, source: image::Source) -> Self { + pub fn with_source(mut self, source: bs::image::Source) -> Self { self.source = source; self } diff --git a/src/base/component/image/props.rs b/extensions/pagetop-bootsier/src/theme/bs/image/props.rs similarity index 54% rename from src/base/component/image/props.rs rename to extensions/pagetop-bootsier/src/theme/bs/image/props.rs index d84d6380..89c117f3 100644 --- a/src/base/component/image/props.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/image/props.rs @@ -1,8 +1,8 @@ -use crate::prelude::*; +use pagetop::prelude::*; // **< Size >*************************************************************************************** -/// Define las **dimensiones** de una imagen ([`Image`](super::Image)). +/// Define las **dimensiones** de una imagen ([`Image`](crate::theme::bs::Image)). #[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] pub enum Size { /// Ajuste automático por defecto. @@ -30,13 +30,23 @@ pub enum Size { Both(UnitValue), } +impl Size { + /// Devuelve el valor del atributo `style` en función del tamaño, o `None` si no aplica. + #[inline] + pub fn to_style(self) -> Option { + match self { + Self::Auto => None, + Self::Dimensions(w, h) => Some(format!("width: {w}; height: {h};")), + Self::Width(w) => Some(format!("width: {w};")), + Self::Height(h) => Some(format!("height: {h};")), + Self::Both(v) => Some(format!("width: {v}; height: {v};")), + } + } +} + // **< Source >************************************************************************************* -/// Especifica la **fuente** para publicar una imagen ([`Image`](super::Image)). -/// -/// Las variantes son puramente semánticas. El componente aplica una clase CSS base según la -/// variante en su propio `setup()`; los temas pueden sobrescribirla interceptando el renderizado -/// del componente. +/// Especifica la **fuente** para publicar una imagen ([`Image`](crate::theme::bs::Image)). #[derive(AutoDefault, Clone, Debug, PartialEq)] pub enum Source { /// Imagen con el logotipo de PageTop. @@ -46,39 +56,71 @@ pub enum Source { /// /// Lleva asociada la URL (o ruta) de la imagen. Responsive(CowStr), - /// Imagen que aplica un estilo de miniatura. + /// Imagen que aplica el estilo **miniatura** de Bootstrap. /// /// Lleva asociada la URL (o ruta) de la imagen. Thumbnail(CowStr), - /// Imagen sin modificadores adicionales de estilo, útil para controlar la apariencia con CSS - /// propio. + /// Imagen sin clases específicas de Bootstrap, útil para controlar con CSS propio. /// /// Lleva asociada la URL (o ruta) de la imagen. Plain(CowStr), } impl Source { + const IMG_FLUID: &str = "img-fluid"; + const IMG_THUMBNAIL: &str = "img-thumbnail"; + /// Imagen con el logotipo de PageTop. #[inline] pub fn logo(svg: PageTopSvg) -> Self { Self::Logo(svg) } - /// Imagen responsive. + /// Imagen responsive (`img-fluid`). #[inline] pub fn responsive(url: impl Into) -> Self { Self::Responsive(url.into()) } - /// Imagen miniatura. + /// Imagen miniatura (`img-thumbnail`). #[inline] pub fn thumbnail(url: impl Into) -> Self { Self::Thumbnail(url.into()) } - /// Imagen sin modificadores adicionales de estilo. + /// Imagen sin clases adicionales. #[inline] pub fn plain(url: impl Into) -> Self { Self::Plain(url.into()) } + + /// Devuelve la clase base asociada a la imagen según la fuente. + #[inline] + pub const fn as_str(&self) -> &'static str { + match self { + Source::Logo(_) | Source::Responsive(_) => Self::IMG_FLUID, + Source::Thumbnail(_) => Self::IMG_THUMBNAIL, + Source::Plain(_) => "", + } + } + + /// Añade la clase asociada al tipo de imagen a la cadena de clases. + #[inline] + pub fn push_to(&self, classes: &mut String) { + let s = self.as_str(); + if s.is_empty() { + return; + } + if !classes.is_empty() { + classes.push(' '); + } + classes.push_str(s); + } + + /// Devuelve la clase asociada al tipo de imagen. + pub fn to_class(&self) -> String { + let mut class = String::new(); + self.push_to(&mut class); + class + } } diff --git a/helpers/pagetop-macros/src/maud/ast.rs b/helpers/pagetop-macros/src/maud/ast.rs index 0b36d64b..c8309ef5 100644 --- a/helpers/pagetop-macros/src/maud/ast.rs +++ b/helpers/pagetop-macros/src/maud/ast.rs @@ -206,7 +206,6 @@ impl DiagnosticParse for Element { }, attrs: { let mut id_pushed = false; - let mut splice_pushed = false; let mut attrs = Vec::new(); while input.peek(Ident::peek_any) @@ -227,16 +226,6 @@ impl DiagnosticParse for Element { id_pushed = true; } - if let Attribute::Splice { .. } = attr { - if splice_pushed { - return Err(Error::new_spanned( - attr, - "only one spliced attribute value is allowed per element", - )); - } - splice_pushed = true; - } - attrs.push(attr); } diff --git a/helpers/pagetop-macros/src/maud/generate.rs b/helpers/pagetop-macros/src/maud/generate.rs index 6e4649ba..ed2fa214 100644 --- a/helpers/pagetop-macros/src/maud/generate.rs +++ b/helpers/pagetop-macros/src/maud/generate.rs @@ -1,6 +1,6 @@ use proc_macro2::{Ident, Span, TokenStream}; use quote::{ToTokens, quote}; -use syn::{Expr, LitStr, Local, parse_quote, token::Brace}; +use syn::{Expr, Local, parse_quote, token::Brace}; use crate::maud::{ast::*, escape}; @@ -71,17 +71,6 @@ impl Generator { ); } - fn splice_attrs(&self, expr: Expr, exclude: &[LitStr], build: &mut Builder) { - let output_ident = &self.output_ident; - build.push_tokens(quote!( - pagetop::html::html_private::render_attrs_to!( - &(#expr), - &[#(#exclude),*], - &mut #output_ident - ); - )); - } - fn element(&self, element: Element, build: &mut Builder) { let element_name = element.name.clone().unwrap_or_else(|| parse_quote!(div)); build.push_str("<"); @@ -152,21 +141,6 @@ impl Generator { fn attrs(&self, attrs: Vec, build: &mut Builder) { let (classes, id, named_attrs, spliced) = split_attrs(attrs); - // Must run before `classes`/`id`/`named_attrs` are consumed below. - let literal_attr_names: Vec = { - let mut names = Vec::new(); - if !classes.is_empty() { - names.push(LitStr::new("class", Span::call_site())); - } - if id.is_some() { - names.push(LitStr::new("id", Span::call_site())); - } - for (name, _) in &named_attrs { - names.push(LitStr::new(&name.to_string(), Span::call_site())); - } - names - }; - if !classes.is_empty() { let mut toggle_class_exprs = vec![]; @@ -211,7 +185,7 @@ impl Generator { self.attr(name, attr_type, build); } for expr in spliced { - self.splice_attrs(expr, &literal_attr_names, build); + self.splice(expr, build); } } diff --git a/src/base/component.rs b/src/base/component.rs index 28fcf30a..52809f46 100644 --- a/src/base/component.rs +++ b/src/base/component.rs @@ -30,10 +30,6 @@ pub use form::Form; mod html; pub use html::Html; -pub mod image; -#[doc(inline)] -pub use image::Image; - mod intro; pub use intro::{Intro, IntroOpening}; diff --git a/src/base/component/image.rs b/src/base/component/image.rs deleted file mode 100644 index 837d25a1..00000000 --- a/src/base/component/image.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Definiciones para renderizar imágenes ([`Image`]). - -mod props; -pub use props::{Size, Source}; - -mod component; -pub use component::Image; diff --git a/src/html.rs b/src/html.rs index a2d4d2d0..1f8e346e 100644 --- a/src/html.rs +++ b/src/html.rs @@ -1,9 +1,7 @@ //! HTML en código. pub(crate) mod maud; -pub use maud::DOCTYPE; -pub use maud::{Escaper, Markup, PreEscaped, Render, RenderAttrs}; -pub use maud::{display, html, html_private}; +pub use maud::{DOCTYPE, Escaper, Markup, PreEscaped, Render, display, html, html_private}; mod route_path; pub use route_path::RoutePath; diff --git a/src/html/maud.rs b/src/html/maud.rs index 858ed25a..cfc35105 100644 --- a/src/html/maud.rs +++ b/src/html/maud.rs @@ -2,8 +2,8 @@ //! A macro for writing HTML templates. //! -//! This documentation only describes the runtime API. For a general guide, check out the [book] -//! instead. +//! This documentation only describes the runtime API. For a general +//! guide, check out the [book] instead. //! //! [book]: https://maud.lambda.xyz/ @@ -29,7 +29,8 @@ mod escape; /// /// All other characters are passed through unchanged. /// -/// **Note:** In versions prior to 0.13, the single quote (`'`) was escaped as well. +/// **Note:** In versions prior to 0.13, the single quote (`'`) was +/// escaped as well. /// /// # Example /// @@ -58,14 +59,15 @@ impl fmt::Write for Escaper<'_> { /// Representa un tipo que puede renderizarse como HTML. /// -/// To implement this for your own type, override either the `.render()` or `.render_to()` methods; -/// since each is defined in terms of the other, you only need to implement one of them. See the -/// example below. +/// To implement this for your own type, override either the `.render()` +/// or `.render_to()` methods; since each is defined in terms of the +/// other, you only need to implement one of them. See the example below. /// /// # Minimal implementation /// -/// An implementation of this trait must override at least one of `.render()` or `.render_to()`. -/// Since the default definitions of these methods call each other, not doing this will result in +/// An implementation of this trait must override at least one of +/// `.render()` or `.render_to()`. Since the default definitions of +/// these methods call each other, not doing this will result in /// infinite recursion. pub trait Render { /// Renders `self` as a block of `Markup`. @@ -77,12 +79,13 @@ pub trait Render { /// Appends a representation of `self` to the given buffer. /// - /// Its default implementation just calls `.render()`, but you may override it with something - /// more efficient. + /// Its default implementation just calls `.render()`, but you may + /// override it with something more efficient. /// - /// Note that no further escaping is performed on data written to the buffer. If you override - /// this method, you must make sure that any data written is properly escaped, whether by hand - /// or using the [`Escaper`](struct.Escaper.html) wrapper struct. + /// Note that no further escaping is performed on data written to + /// the buffer. If you override this method, you must make sure that + /// any data written is properly escaped, whether by hand or using + /// the [`Escaper`](struct.Escaper.html) wrapper struct. fn render_to(&self, buffer: &mut String) { buffer.push_str(&self.render().into_string()); } @@ -136,27 +139,6 @@ impl Render for Arc { } } -/// Representa un tipo que puede renderizarse como los atributos de un elemento HTML. -/// -/// Exists so that a single value "spliced" into the attribute position of an element can avoid -/// duplicating an attribute the element already writes literally. The [`html!`](crate::html::html) -/// macro automatically computes the names of the element's literal attributes and passes them here; -/// no action is required from the programmer. -/// -/// [`Props`](crate::html::Props) is the only implementation in PageTop. -pub trait RenderAttrs { - /// Same as [`Render::render_to()`], but omitting any attribute whose name is in `exclude`. - #[track_caller] - fn render_attrs_to(&self, buffer: &mut String, exclude: &[&str]); -} - -impl RenderAttrs for &T { - #[track_caller] - fn render_attrs_to(&self, buffer: &mut String, exclude: &[&str]) { - T::render_attrs_to(self, buffer, exclude); - } -} - macro_rules! impl_render_with_display { ($($ty:ty)*) => { $( @@ -304,7 +286,7 @@ mod axum_support { pub mod html_private { extern crate alloc; - use super::{Render, RenderAttrs, display}; + use super::{Render, display}; use alloc::string::String; use core::fmt::Display; @@ -351,77 +333,4 @@ pub mod html_private { display(value).render_to(buffer); } } - - #[doc(hidden)] - #[macro_export] - macro_rules! render_attrs_to { - ($x:expr, $exclude:expr, $buffer:expr) => {{ - use $crate::html::html_private::*; - match ChooseAttrsRenderOrDisplay($x) { - x => (&&&x) - .implements_attrs_render_or_display() - .render_to(x.0, $exclude, $buffer), - } - }}; - } - - pub use render_attrs_to; - - pub struct ChooseAttrsRenderOrDisplay(pub T); - - pub struct ViaAttrsTag; - pub struct ViaAttrsRenderTag; - pub struct ViaAttrsDisplayTag; - - pub trait ViaAttrs { - fn implements_attrs_render_or_display(&self) -> ViaAttrsTag { - ViaAttrsTag - } - } - pub trait ViaAttrsRender { - fn implements_attrs_render_or_display(&self) -> ViaAttrsRenderTag { - ViaAttrsRenderTag - } - } - pub trait ViaAttrsDisplay { - fn implements_attrs_render_or_display(&self) -> ViaAttrsDisplayTag { - ViaAttrsDisplayTag - } - } - - impl ViaAttrs for &&ChooseAttrsRenderOrDisplay {} - impl ViaAttrsRender for &ChooseAttrsRenderOrDisplay {} - impl ViaAttrsDisplay for ChooseAttrsRenderOrDisplay {} - - impl ViaAttrsTag { - #[track_caller] - pub fn render_to( - self, - value: &T, - exclude: &[&str], - buffer: &mut String, - ) { - value.render_attrs_to(buffer, exclude); - } - } - impl ViaAttrsRenderTag { - pub fn render_to( - self, - value: &T, - _exclude: &[&str], - buffer: &mut String, - ) { - value.render_to(buffer); - } - } - impl ViaAttrsDisplayTag { - pub fn render_to( - self, - value: &T, - _exclude: &[&str], - buffer: &mut String, - ) { - display(value).render_to(buffer); - } - } } diff --git a/src/html/props.rs b/src/html/props.rs index 04c35fe7..d120282f 100644 --- a/src/html/props.rs +++ b/src/html/props.rs @@ -1,5 +1,5 @@ use crate::core::TypeInfo; -use crate::html::maud::{Escaper, RenderAttrs}; +use crate::html::maud::{Escaper, Render}; use crate::{AutoDefault, CowStr, builder_fn, trace, util}; use thiserror::Error; @@ -7,7 +7,6 @@ use thiserror::Error; use std::any::Any; use std::collections::HashMap; use std::fmt::{self, Write}; -use std::panic::Location; use std::sync::Arc; // **< PropsExtra >********************************************************************************* @@ -100,19 +99,11 @@ pub enum PropsOp { /// Añade la clase o clases que no existan al principio de la lista. La operación se ignora si /// el valor contiene caracteres no ASCII. PrependClasses(CowStr), - /// Sustituye **una o más** clases del primer valor por las clases indicadas en el segundo - /// valor, insertando las nuevas en la posición de la primera clase a sustituir encontrada, con - /// independencia del orden en que aparecen en el primer valor. Las que no existan se ignoran. - /// Si **ninguna** de las clases a sustituir existe, la operación no tiene efecto y no se - /// inserta nada. Se ignora si alguno de los dos valores contiene caracteres no ASCII. + /// Sustituye una o varias clases existentes (primer valor) por las clases indicadas (segundo + /// valor), insertando las nuevas en la posición de la primera clase sustituida encontrada. Si + /// ninguna de las clases a sustituir existe, la operación no tiene efecto. Se ignora si alguno + /// de los dos valores contiene caracteres no ASCII. ReplaceClasses(CowStr, CowStr), - /// A diferencia de [`ReplaceClasses`](Self::ReplaceClasses), exige que **todas** las clases del - /// primer valor estén presentes, independientemente de su orden; si falta una sola, la - /// operación no tiene efecto: ninguna clase se elimina ni se inserta. Si todas están presentes, - /// las sustituye por las clases indicadas en el segundo valor, insertando las nuevas en la - /// posición de la primera clase a sustituir encontrada. Se ignora si alguno de los dos valores - /// contiene caracteres no ASCII. - ReplaceAllClasses(CowStr, CowStr), /// Elimina la clase o clases indicadas de la lista. La operación se ignora si el valor contiene /// caracteres no ASCII. RemoveClasses(CowStr), @@ -178,34 +169,11 @@ impl PropsOp { /// let props = Props::classes("button primary") /// .with_prop(PropsOp::replace_classes("button", "btn")); /// assert_eq!(props.get_classes(), Some("btn primary".to_string())); - /// - /// // Basta con que exista alguna clase de `old` para aplicar el reemplazo. - /// let props = Props::classes("btn primary") - /// .with_prop(PropsOp::replace_classes("primary secondary", "danger")); - /// assert_eq!(props.get_classes(), Some("btn danger".to_string())); /// ``` pub fn replace_classes(old: impl Into, new: impl Into) -> Self { Self::ReplaceClasses(old.into(), new.into()) } - /// Crea la variante [`ReplaceAllClasses`](Self::ReplaceAllClasses) con las clases a sustituir - /// (`old`) y las nuevas clases (`new`). - /// - /// ```rust - /// # use pagetop::prelude::*; - /// let props = Props::classes("btn primary") - /// .with_prop(PropsOp::replace_all_classes("btn primary", "btn danger")); - /// assert_eq!(props.get_classes(), Some("btn danger".to_string())); - /// - /// // Si falta una sola clase de `old`, no hay reemplazo. - /// let props = Props::classes("btn primary") - /// .with_prop(PropsOp::replace_all_classes("primary secondary", "danger")); - /// assert_eq!(props.get_classes(), Some("btn primary".to_string())); - /// ``` - pub fn replace_all_classes(old: impl Into, new: impl Into) -> Self { - Self::ReplaceAllClasses(old.into(), new.into()) - } - /// Crea la variante [`RemoveClasses`](Self::RemoveClasses) con la clase o clases indicadas. pub fn remove_classes(classes: impl Into) -> Self { Self::RemoveClasses(classes.into()) @@ -351,24 +319,6 @@ impl PropsOp { /// assert_eq!(markup.into_string(), r#""#); /// ``` /// -/// # Atributos duplicados junto a `Props` -/// -/// Cuando el componente combina `(self.props())` con un atributo literal del mismo nombre en el -/// mismo elemento (una clase, un `#id`, o `nombre=valor`), la macro [`html!`](crate::html::html) -/// evita automáticamente la duplicación. Recopila en tiempo de compilación los nombres de los -/// atributos del elemento y al renderizar se omiten los duplicados en tiempo de ejecución. No -/// depende del orden en que se escriban ni requiere ninguna acción del desarrollador. -/// -/// ```rust -/// # use pagetop::prelude::*; -/// let props = Props::default().with_prop(PropsOp::set("title", "de Props")); -/// -/// let markup = html! { span title="literal" (props) { "OK" } }; -/// -/// // El atributo literal prevalece; `Props` omite su propio "title" en vez de duplicarlo. -/// assert_eq!(markup.into_string(), r#"OK"#); -/// ``` -/// /// # Valores extra /// /// Las variantes [`SetExtra`](PropsOp::SetExtra) y [`RemoveExtra`](PropsOp::RemoveExtra), usando @@ -512,27 +462,6 @@ impl Props { self.insert_classes(new.as_ref().split_ascii_whitespace(), pos); } } - PropsOp::ReplaceAllClasses(old, new) => { - let Some(old) = util::normalize_ascii_or_empty(old.as_ref(), "Props::with_prop") - else { - return self; - }; - let Some(new) = util::normalize_ascii_or_empty(new.as_ref(), "Props::with_prop") - else { - return self; - }; - if !self.has_all_classes(old.as_ref()) { - return self; - } - let mut pos = self.classes.len(); - for class in old.as_ref().split_ascii_whitespace() { - if let Some(replace_pos) = self.classes.iter().position(|c| c == class) { - self.classes.remove(replace_pos); - pos = pos.min(replace_pos); - } - } - self.insert_classes(new.as_ref().split_ascii_whitespace(), pos); - } PropsOp::RemoveClasses(classes) => { let Some(normalized) = util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop") @@ -687,19 +616,8 @@ impl Props { && self.attrs.is_empty() } - /// Devuelve `true` si la clase o **alguna** de las clases indicadas está presente. - pub fn has_classes(&self, classes: impl AsRef) -> bool { - let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else { - return false; - }; - normalized - .as_ref() - .split_ascii_whitespace() - .any(|class| self.classes.iter().any(|c| c == class)) - } - /// Devuelve `true` si la clase o **todas** las clases indicadas están presentes. - pub fn has_all_classes(&self, classes: impl AsRef) -> bool { + pub fn has_class(&self, classes: impl AsRef) -> bool { let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else { return false; }; @@ -709,6 +627,17 @@ impl Props { .all(|class| self.classes.iter().any(|c| c == class)) } + /// Devuelve `true` si la clase o **alguna** de las clases indicadas está presente. + pub fn has_any_class(&self, classes: impl AsRef) -> bool { + let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else { + return false; + }; + normalized + .as_ref() + .split_ascii_whitespace() + .any(|class| self.classes.iter().any(|c| c == class)) + } + /// Recupera una referencia tipada al valor extra asociado a la clave `key`. /// /// Devuelve un [`Result`] que indica si la clave existe y si el tipo coincide: @@ -900,82 +829,32 @@ impl Props { } #[doc(hidden)] -impl RenderAttrs for Props { - // Omite cualquier atributo que esté en `exclude` (recopilados por `html!` a partir de los - // atributos literales del elemento). Registra un `trace::debug!` por cada atributo duplicado, - // con la posición exacta del `html!` que lo produjo (propagado gracias a `#[track_caller]`) - // para facilitar la localización del problema. - #[track_caller] - fn render_attrs_to(&self, w: &mut String, exclude: &[&str]) { +impl Render for Props { + fn render_to(&self, w: &mut String) { if let Some(id) = self.id.as_deref() { - if exclude.contains(&"id") { - trace::debug!( - caller = %Location::caller(), - attribute = "id", - discarded = %id, - "Ignoring Props attribute already set as a literal on the same element" - ); - } else { - w.push_str(" id=\""); - let _ = write!(Escaper::new(w), "{}", id); - w.push('"'); - } + w.push_str(" id=\""); + let _ = write!(Escaper::new(w), "{}", id); + w.push('"'); } if let Some((first, rest)) = self.classes.split_first() { - if exclude.contains(&"class") { - trace::debug!( - caller = %Location::caller(), - attribute = "class", - discarded = %self.classes.join(" "), - id = %self.id.as_deref().unwrap_or(""), - "Ignoring Props attribute already set as a literal on the same element" - ); - } else { - w.push_str(" class=\""); - let _ = write!(Escaper::new(w), "{}", first); - for class in rest { - w.push(' '); - let _ = write!(Escaper::new(w), "{}", class); - } - w.push('"'); + w.push_str(" class=\""); + let _ = write!(Escaper::new(w), "{}", first); + for class in rest { + w.push(' '); + let _ = write!(Escaper::new(w), "{}", class); } + w.push('"'); } if let Some((first, rest)) = self.styles.split_first() { - if exclude.contains(&"style") { - let discarded = self - .styles - .iter() - .map(|(property, value)| format!("{property}: {value}")) - .collect::>() - .join("; "); - trace::debug!( - caller = %Location::caller(), - attribute = "style", - discarded = %discarded, - id = %self.id.as_deref().unwrap_or(""), - "Ignoring Props attribute already set as a literal on the same element" - ); - } else { - w.push_str(" style=\""); - let _ = write!(Escaper::new(w), "{}: {}", first.0, first.1); - for (property, value) in rest { - w.push_str("; "); - let _ = write!(Escaper::new(w), "{}: {}", property, value); - } - w.push('"'); + w.push_str(" style=\""); + let _ = write!(Escaper::new(w), "{}: {}", first.0, first.1); + for (property, value) in rest { + w.push_str("; "); + let _ = write!(Escaper::new(w), "{}: {}", property, value); } + w.push('"'); } for (name, value) in &self.attrs { - if exclude.contains(&name.as_ref()) { - trace::debug!( - caller = %Location::caller(), - attribute = %name, - discarded = %value, - id = %self.id.as_deref().unwrap_or(""), - "Ignoring Props attribute already set as a literal on the same element" - ); - continue; - } w.push(' '); let _ = write!(Escaper::new(w), "{}", name); w.push_str("=\""); diff --git a/tests/html_props.rs b/tests/html_props.rs index afcfe929..6b5f751e 100644 --- a/tests/html_props.rs +++ b/tests/html_props.rs @@ -157,13 +157,11 @@ async fn props_alongside_named_attr_renders_after_it() { } #[pagetop::test] -async fn props_combined_via_chaining_instead_of_multiple_splices() { - // An element accepts only a single attribute splice (a second `(props)` on the same element - // is a compile error); values from separate sources are combined by chaining `with_prop()` - // on one `Props`, not by splicing two of them. - let p = Props::new("hx-get", "/api").with_prop(PropsOp::set("hx-swap", "outerHTML")); +async fn props_multiple_splices_in_same_element() { + let p1 = Props::new("hx-get", "/api"); + let p2 = Props::new("hx-swap", "outerHTML"); assert_eq!( - html! { button (p) {} }.into_string(), + html! { button (p1) (p2) {} }.into_string(), r#""# ); } @@ -195,48 +193,6 @@ async fn props_splice_empty_string_emits_nothing() { assert_eq!(html! { span ("") { "x" } }.into_string(), "x"); } -// **< RenderAttrs: literal attribute collisions >************************************************** - -#[pagetop::test] -async fn props_id_collision_with_literal_omits_props_id() { - // A literal `#id` on the element takes precedence; `Props`'s own id is silently omitted instead - // of producing a duplicate `id` attribute. - let p = Props::default().with_id("from-props"); - assert_eq!( - html! { div #fixed (p) {} }.into_string(), - r#"
"# - ); -} - -#[pagetop::test] -async fn props_class_collision_with_literal_omits_props_classes() { - let p = Props::classes("from-props-a from-props-b"); - assert_eq!( - html! { div.fixed (p) {} }.into_string(), - r#"
"# - ); -} - -#[pagetop::test] -async fn props_style_collision_with_literal_omits_props_styles() { - let p = Props::default() - .with_prop(PropsOp::add_style("color", "red")) - .with_prop(PropsOp::add_style("font-weight", "bold")); - assert_eq!( - html! { div style="color: blue" (p) {} }.into_string(), - r#"
"# - ); -} - -#[pagetop::test] -async fn props_named_attr_collision_with_literal_omits_props_value() { - let p = Props::default().with_prop(PropsOp::set("title", "from-props")); - assert_eq!( - html! { span title="literal" (p) {} }.into_string(), - r#""# - ); -} - // **< is_attrs_empty / is_empty >****************************************************************** #[pagetop::test] diff --git a/tests/html_props_classes.rs b/tests/html_props_classes.rs index cf06484a..ededb8b2 100644 --- a/tests/html_props_classes.rs +++ b/tests/html_props_classes.rs @@ -24,8 +24,8 @@ async fn classes_new_empty_and_whitespace_is_empty() { async fn classes_new_normalizes_and_dedups_and_preserves_first_occurrence_order() { let p = Props::classes("Btn btn BTN btn-primary BTN-PRIMARY"); assert_classes(&p, Some("btn btn-primary")); - assert!(p.has_all_classes("BTN")); - assert!(p.has_all_classes("btn-primary")); + assert!(p.has_class("BTN")); + assert!(p.has_class("btn-primary")); } // **< PropsOp::add_classes >*********************************************************************** @@ -119,57 +119,6 @@ async fn classes_replace_rejects_non_ascii_targets_is_noop() { assert_classes(&p, Some("a b c")); } -// **< PropsOp::replace_all_classes >*************************************************************** - -#[pagetop::test] -async fn classes_replace_all_removes_targets_and_inserts_new_at_min_position() { - let p = Props::classes("a b c d").with_prop(PropsOp::replace_all_classes("c a", "x y")); - assert_classes(&p, Some("x y b d")); -} - -#[pagetop::test] -async fn classes_replace_all_when_missing_one_does_nothing_even_to_existing_one() { - let p = Props::classes("a b").with_prop(PropsOp::replace_all_classes("a x", "c d")); - assert_classes(&p, Some("a b")); -} - -#[pagetop::test] -async fn classes_replace_all_when_none_found_does_nothing() { - let p = Props::classes("a b").with_prop(PropsOp::replace_all_classes("x y", "c d")); - assert_classes(&p, Some("a b")); -} - -#[pagetop::test] -async fn classes_replace_all_is_case_insensitive_on_targets_and_new_values_are_normalized() { - let p = Props::classes("btn btn-primary active") - .with_prop(PropsOp::replace_all_classes("BTN ACTIVE", "Btn-Secondary")); - assert_classes(&p, Some("btn-secondary btn-primary")); -} - -#[pagetop::test] -async fn classes_replace_all_with_empty_new_removes_only() { - let p = Props::classes("a b c").with_prop(PropsOp::replace_all_classes("a b", " ")); - assert_classes(&p, Some("c")); -} - -#[pagetop::test] -async fn classes_replace_all_dedups_against_existing_items() { - let p = Props::classes("a b c").with_prop(PropsOp::replace_all_classes("a b", "c d")); - assert_classes(&p, Some("d c")); -} - -#[pagetop::test] -async fn classes_replace_all_ignores_target_whitespace_and_repetition() { - let p = Props::classes("a b c").with_prop(PropsOp::replace_all_classes(" b b ", "x y")); - assert_classes(&p, Some("a x y c")); -} - -#[pagetop::test] -async fn classes_replace_all_rejects_non_ascii_targets_is_noop() { - let p = Props::classes("a b c").with_prop(PropsOp::replace_all_classes("b ñ", "x")); - assert_classes(&p, Some("a b c")); -} - // **< PropsOp::set / remove ("class") >************************************************************ #[pagetop::test] @@ -216,41 +165,41 @@ async fn classes_remove_with_extra_whitespace() { assert_classes(&p, Some("a c")); } -// **< has_classes / has_all_classes >************************************************************** +// **< has_class / has_any_class >****************************************************************** #[pagetop::test] async fn classes_contains_single() { let p = Props::classes("btn btn-primary"); - assert!(p.has_all_classes("btn")); - assert!(p.has_all_classes("BTN")); - assert!(!p.has_all_classes("missing")); + assert!(p.has_class("btn")); + assert!(p.has_class("BTN")); + assert!(!p.has_class("missing")); } #[pagetop::test] async fn classes_contains_all_and_any() { let p = Props::classes("btn btn-primary active"); - assert!(p.has_classes("missing active")); - assert!(p.has_classes("BTN-PRIMARY missing")); - assert!(!p.has_classes("missing other")); - assert!(p.has_all_classes("btn active")); - assert!(p.has_all_classes("BTN BTN-PRIMARY")); - assert!(!p.has_all_classes("btn missing")); + assert!(p.has_class("btn active")); + assert!(p.has_class("BTN BTN-PRIMARY")); + assert!(!p.has_class("btn missing")); + assert!(p.has_any_class("missing active")); + assert!(p.has_any_class("BTN-PRIMARY missing")); + assert!(!p.has_any_class("missing other")); } #[pagetop::test] async fn classes_contains_empty_and_whitespace_is_false() { let p = Props::classes("a b"); - assert!(!p.has_classes("")); - assert!(!p.has_classes(" \n ")); - assert!(!p.has_all_classes("")); - assert!(!p.has_all_classes(" \t")); + assert!(!p.has_class("")); + assert!(!p.has_class(" \t")); + assert!(!p.has_any_class("")); + assert!(!p.has_any_class(" \n ")); } #[pagetop::test] async fn classes_contains_non_ascii_is_false() { let p = Props::classes("a b"); - assert!(!p.has_classes("a ñ")); - assert!(!p.has_all_classes("ñ")); + assert!(!p.has_class("ñ")); + assert!(!p.has_any_class("a ñ")); } // **< is_classes_empty >***************************************************************************