diff --git a/extensions/pagetop-bootsier/src/lib.rs b/extensions/pagetop-bootsier/src/lib.rs index f2838d7a..7b83bde2 100644 --- a/extensions/pagetop-bootsier/src/lib.rs +++ b/extensions/pagetop-bootsier/src/lib.rs @@ -145,6 +145,7 @@ 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 837d25a1..e5a1f649 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/image.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/image.rs @@ -1,7 +1,24 @@ //! Definiciones para renderizar imágenes ([`Image`]). -mod props; -pub use props::{Size, Source}; +use pagetop::prelude::*; -mod component; -pub use component::Image; +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")); + } + } +} diff --git a/helpers/pagetop-macros/src/maud/ast.rs b/helpers/pagetop-macros/src/maud/ast.rs index c8309ef5..0b36d64b 100644 --- a/helpers/pagetop-macros/src/maud/ast.rs +++ b/helpers/pagetop-macros/src/maud/ast.rs @@ -206,6 +206,7 @@ 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) @@ -226,6 +227,16 @@ 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 ed2fa214..6e4649ba 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, Local, parse_quote, token::Brace}; +use syn::{Expr, LitStr, Local, parse_quote, token::Brace}; use crate::maud::{ast::*, escape}; @@ -71,6 +71,17 @@ 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("<"); @@ -141,6 +152,21 @@ 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![]; @@ -185,7 +211,7 @@ impl Generator { self.attr(name, attr_type, build); } for expr in spliced { - self.splice(expr, build); + self.splice_attrs(expr, &literal_attr_names, build); } } diff --git a/src/base/component.rs b/src/base/component.rs index 52809f46..28fcf30a 100644 --- a/src/base/component.rs +++ b/src/base/component.rs @@ -30,6 +30,10 @@ 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 new file mode 100644 index 00000000..837d25a1 --- /dev/null +++ b/src/base/component/image.rs @@ -0,0 +1,7 @@ +//! Definiciones para renderizar imágenes ([`Image`]). + +mod props; +pub use props::{Size, Source}; + +mod component; +pub use component::Image; diff --git a/extensions/pagetop-bootsier/src/theme/bs/image/component.rs b/src/base/component/image/component.rs similarity index 56% rename from extensions/pagetop-bootsier/src/theme/bs/image/component.rs rename to src/base/component/image/component.rs index 37bc1371..ed799886 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/image/component.rs +++ b/src/base/component/image/component.rs @@ -1,25 +1,33 @@ -use pagetop::prelude::*; - -use crate::theme::*; +use crate::prelude::*; /// Componente para renderizar una **imagen**. /// /// A una imagen se le puede: /// -/// - 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)). +/// - Establecer su contenido a partir del origen definido en [`image::Source`]. +/// - Configurar sus **dimensiones** ([`with_size()`](Self::with_size)). /// - 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: bs::image::Size, + size: image::Size, /// Devuelve el origen de la imagen. - source: bs::image::Source, + source: image::Source, /// Devuelve el texto alternativo localizado. alternative: Attr, } @@ -35,20 +43,40 @@ impl Component for Image { } fn setup(&mut self, _cx: &Context) { - // Clases CSS por defecto para la imagen, según el origen seleccionado. - self.alter_prop(PropsOp::prepend_classes(self.source().to_class())); + 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())); + } + } } 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() { - bs::image::Source::Logo(logo) => { + image::Source::Logo(logo) => { + let is_decorative = alt_text.is_empty(); 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")] @@ -57,23 +85,22 @@ impl Component for Image { } }); } - bs::image::Source::Responsive(source) => Some(source), - bs::image::Source::Thumbnail(source) => Some(source), - bs::image::Source::Plain(source) => Some(source), + image::Source::Responsive(source) => Some(source), + image::Source::Thumbnail(source) => Some(source), + image::Source::Plain(source) => Some(source), }; Ok(html! { img src=[source] alt=(alt_text) - (self.props()) - style=[dimensions] {} + (self.props()) {} }) } } impl Image { /// Crea rápidamente una imagen especificando su origen. - pub fn with(source: bs::image::Source) -> Self { + pub fn with(source: image::Source) -> Self { Self::default().with_source(source) } @@ -87,11 +114,6 @@ 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); @@ -100,14 +122,14 @@ impl Image { /// Define las dimensiones de la imagen (auto, ancho/alto, ambos). #[builder_fn] - pub fn with_size(mut self, size: bs::image::Size) -> Self { + pub fn with_size(mut self, size: 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: bs::image::Source) -> Self { + pub fn with_source(mut self, source: image::Source) -> Self { self.source = source; self } diff --git a/extensions/pagetop-bootsier/src/theme/bs/image/props.rs b/src/base/component/image/props.rs similarity index 54% rename from extensions/pagetop-bootsier/src/theme/bs/image/props.rs rename to src/base/component/image/props.rs index 89c117f3..d84d6380 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/image/props.rs +++ b/src/base/component/image/props.rs @@ -1,8 +1,8 @@ -use pagetop::prelude::*; +use crate::prelude::*; // **< Size >*************************************************************************************** -/// Define las **dimensiones** de una imagen ([`Image`](crate::theme::bs::Image)). +/// Define las **dimensiones** de una imagen ([`Image`](super::Image)). #[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] pub enum Size { /// Ajuste automático por defecto. @@ -30,23 +30,13 @@ 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`](crate::theme::bs::Image)). +/// 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. #[derive(AutoDefault, Clone, Debug, PartialEq)] pub enum Source { /// Imagen con el logotipo de PageTop. @@ -56,71 +46,39 @@ pub enum Source { /// /// Lleva asociada la URL (o ruta) de la imagen. Responsive(CowStr), - /// Imagen que aplica el estilo **miniatura** de Bootstrap. + /// Imagen que aplica un estilo de miniatura. /// /// Lleva asociada la URL (o ruta) de la imagen. Thumbnail(CowStr), - /// Imagen sin clases específicas de Bootstrap, útil para controlar con CSS propio. + /// Imagen sin modificadores adicionales de estilo, útil para controlar la apariencia 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 (`img-fluid`). + /// Imagen responsive. #[inline] pub fn responsive(url: impl Into) -> Self { Self::Responsive(url.into()) } - /// Imagen miniatura (`img-thumbnail`). + /// Imagen miniatura. #[inline] pub fn thumbnail(url: impl Into) -> Self { Self::Thumbnail(url.into()) } - /// Imagen sin clases adicionales. + /// Imagen sin modificadores adicionales de estilo. #[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/src/html.rs b/src/html.rs index 1f8e346e..a2d4d2d0 100644 --- a/src/html.rs +++ b/src/html.rs @@ -1,7 +1,9 @@ //! HTML en código. pub(crate) mod maud; -pub use maud::{DOCTYPE, Escaper, Markup, PreEscaped, Render, display, html, html_private}; +pub use maud::DOCTYPE; +pub use maud::{Escaper, Markup, PreEscaped, Render, RenderAttrs}; +pub use maud::{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 cfc35105..858ed25a 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,8 +29,7 @@ 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 /// @@ -59,15 +58,14 @@ 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`. @@ -79,13 +77,12 @@ 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()); } @@ -139,6 +136,27 @@ 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)*) => { $( @@ -286,7 +304,7 @@ mod axum_support { pub mod html_private { extern crate alloc; - use super::{Render, display}; + use super::{Render, RenderAttrs, display}; use alloc::string::String; use core::fmt::Display; @@ -333,4 +351,77 @@ 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 d120282f..04c35fe7 100644 --- a/src/html/props.rs +++ b/src/html/props.rs @@ -1,5 +1,5 @@ use crate::core::TypeInfo; -use crate::html::maud::{Escaper, Render}; +use crate::html::maud::{Escaper, RenderAttrs}; use crate::{AutoDefault, CowStr, builder_fn, trace, util}; use thiserror::Error; @@ -7,6 +7,7 @@ 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 >********************************************************************************* @@ -99,11 +100,19 @@ 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 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. + /// 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. 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), @@ -169,11 +178,34 @@ 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()) @@ -319,6 +351,24 @@ 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 @@ -462,6 +512,27 @@ 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") @@ -616,19 +687,8 @@ impl Props { && self.attrs.is_empty() } - /// Devuelve `true` si la clase o **todas** las clases indicadas están presentes. - pub fn has_class(&self, classes: impl AsRef) -> bool { - let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else { - return false; - }; - normalized - .as_ref() - .split_ascii_whitespace() - .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 { + pub fn has_classes(&self, classes: impl AsRef) -> bool { let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else { return false; }; @@ -638,6 +698,17 @@ impl Props { .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 { + let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else { + return false; + }; + normalized + .as_ref() + .split_ascii_whitespace() + .all(|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: @@ -829,32 +900,82 @@ impl Props { } #[doc(hidden)] -impl Render for Props { - fn render_to(&self, w: &mut String) { +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]) { if let Some(id) = self.id.as_deref() { - w.push_str(" id=\""); - let _ = write!(Escaper::new(w), "{}", id); - w.push('"'); + 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('"'); + } } if let Some((first, rest)) = self.classes.split_first() { - w.push_str(" class=\""); - let _ = write!(Escaper::new(w), "{}", first); - for class in rest { - w.push(' '); - let _ = write!(Escaper::new(w), "{}", class); + 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('"'); } if let Some((first, rest)) = self.styles.split_first() { - 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); + 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('"'); } 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 6b5f751e..afcfe929 100644 --- a/tests/html_props.rs +++ b/tests/html_props.rs @@ -157,11 +157,13 @@ async fn props_alongside_named_attr_renders_after_it() { } #[pagetop::test] -async fn props_multiple_splices_in_same_element() { - let p1 = Props::new("hx-get", "/api"); - let p2 = Props::new("hx-swap", "outerHTML"); +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")); assert_eq!( - html! { button (p1) (p2) {} }.into_string(), + html! { button (p) {} }.into_string(), r#""# ); } @@ -193,6 +195,48 @@ 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 ededb8b2..cf06484a 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_class("BTN")); - assert!(p.has_class("btn-primary")); + assert!(p.has_all_classes("BTN")); + assert!(p.has_all_classes("btn-primary")); } // **< PropsOp::add_classes >*********************************************************************** @@ -119,6 +119,57 @@ 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] @@ -165,41 +216,41 @@ async fn classes_remove_with_extra_whitespace() { assert_classes(&p, Some("a c")); } -// **< has_class / has_any_class >****************************************************************** +// **< has_classes / has_all_classes >************************************************************** #[pagetop::test] async fn classes_contains_single() { let p = Props::classes("btn btn-primary"); - assert!(p.has_class("btn")); - assert!(p.has_class("BTN")); - assert!(!p.has_class("missing")); + assert!(p.has_all_classes("btn")); + assert!(p.has_all_classes("BTN")); + assert!(!p.has_all_classes("missing")); } #[pagetop::test] async fn classes_contains_all_and_any() { let p = Props::classes("btn btn-primary active"); - 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")); + 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")); } #[pagetop::test] async fn classes_contains_empty_and_whitespace_is_false() { let p = Props::classes("a b"); - assert!(!p.has_class("")); - assert!(!p.has_class(" \t")); - assert!(!p.has_any_class("")); - assert!(!p.has_any_class(" \n ")); + assert!(!p.has_classes("")); + assert!(!p.has_classes(" \n ")); + assert!(!p.has_all_classes("")); + assert!(!p.has_all_classes(" \t")); } #[pagetop::test] async fn classes_contains_non_ascii_is_false() { let p = Props::classes("a b"); - assert!(!p.has_class("ñ")); - assert!(!p.has_any_class("a ñ")); + assert!(!p.has_classes("a ñ")); + assert!(!p.has_all_classes("ñ")); } // **< is_classes_empty >***************************************************************************