(html): Evita duplicar atributos de Props

`html!` recopila los atributos literales de cada elemento y evita que
`Props` los duplique al renderizar.
This commit is contained in:
Manuel Cillero 2026-08-17 00:02:20 +02:00
parent 05187c9343
commit 745d492d5b
5 changed files with 274 additions and 42 deletions

View file

@ -1,6 +1,6 @@
use proc_macro2::{Ident, Span, TokenStream}; use proc_macro2::{Ident, Span, TokenStream};
use quote::{ToTokens, quote}; 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}; 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) { fn element(&self, element: Element, build: &mut Builder) {
let element_name = element.name.clone().unwrap_or_else(|| parse_quote!(div)); let element_name = element.name.clone().unwrap_or_else(|| parse_quote!(div));
build.push_str("<"); build.push_str("<");
@ -141,6 +152,21 @@ impl Generator {
fn attrs(&self, attrs: Vec<Attribute>, build: &mut Builder) { fn attrs(&self, attrs: Vec<Attribute>, build: &mut Builder) {
let (classes, id, named_attrs, spliced) = split_attrs(attrs); let (classes, id, named_attrs, spliced) = split_attrs(attrs);
// Must run before `classes`/`id`/`named_attrs` are consumed below.
let literal_attr_names: Vec<LitStr> = {
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() { if !classes.is_empty() {
let mut toggle_class_exprs = vec![]; let mut toggle_class_exprs = vec![];
@ -185,7 +211,7 @@ impl Generator {
self.attr(name, attr_type, build); self.attr(name, attr_type, build);
} }
for expr in spliced { for expr in spliced {
self.splice(expr, build); self.splice_attrs(expr, &literal_attr_names, build);
} }
} }

View file

@ -1,7 +1,9 @@
//! HTML en código. //! HTML en código.
pub(crate) mod maud; 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; mod route_path;
pub use route_path::RoutePath; pub use route_path::RoutePath;

View file

@ -2,8 +2,8 @@
//! A macro for writing HTML templates. //! A macro for writing HTML templates.
//! //!
//! This documentation only describes the runtime API. For a general //! This documentation only describes the runtime API. For a general guide, check out the [book]
//! guide, check out the [book] instead. //! instead.
//! //!
//! [book]: https://maud.lambda.xyz/ //! [book]: https://maud.lambda.xyz/
@ -29,8 +29,7 @@ mod escape;
/// ///
/// All other characters are passed through unchanged. /// All other characters are passed through unchanged.
/// ///
/// **Note:** In versions prior to 0.13, the single quote (`'`) was /// **Note:** In versions prior to 0.13, the single quote (`'`) was escaped as well.
/// escaped as well.
/// ///
/// # Example /// # Example
/// ///
@ -59,15 +58,14 @@ impl fmt::Write for Escaper<'_> {
/// Representa un tipo que puede renderizarse como HTML. /// Representa un tipo que puede renderizarse como HTML.
/// ///
/// To implement this for your own type, override either the `.render()` /// To implement this for your own type, override either the `.render()` or `.render_to()` methods;
/// or `.render_to()` methods; since each is defined in terms of the /// since each is defined in terms of the other, you only need to implement one of them. See the
/// other, you only need to implement one of them. See the example below. /// example below.
/// ///
/// # Minimal implementation /// # Minimal implementation
/// ///
/// An implementation of this trait must override at least one of /// An implementation of this trait must override at least one of `.render()` or `.render_to()`.
/// `.render()` or `.render_to()`. Since the default definitions of /// Since the default definitions of these methods call each other, not doing this will result in
/// these methods call each other, not doing this will result in
/// infinite recursion. /// infinite recursion.
pub trait Render { pub trait Render {
/// Renders `self` as a block of `Markup`. /// Renders `self` as a block of `Markup`.
@ -79,13 +77,12 @@ pub trait Render {
/// Appends a representation of `self` to the given buffer. /// Appends a representation of `self` to the given buffer.
/// ///
/// Its default implementation just calls `.render()`, but you may /// Its default implementation just calls `.render()`, but you may override it with something
/// override it with something more efficient. /// more efficient.
/// ///
/// Note that no further escaping is performed on data written to /// Note that no further escaping is performed on data written to the buffer. If you override
/// the buffer. If you override this method, you must make sure that /// this method, you must make sure that any data written is properly escaped, whether by hand
/// any data written is properly escaped, whether by hand or using /// or using the [`Escaper`](struct.Escaper.html) wrapper struct.
/// the [`Escaper`](struct.Escaper.html) wrapper struct.
fn render_to(&self, buffer: &mut String) { fn render_to(&self, buffer: &mut String) {
buffer.push_str(&self.render().into_string()); buffer.push_str(&self.render().into_string());
} }
@ -139,6 +136,27 @@ impl<T: Render + ?Sized> Render for Arc<T> {
} }
} }
/// 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<T: RenderAttrs + ?Sized> 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 { macro_rules! impl_render_with_display {
($($ty:ty)*) => { ($($ty:ty)*) => {
$( $(
@ -286,7 +304,7 @@ mod axum_support {
pub mod html_private { pub mod html_private {
extern crate alloc; extern crate alloc;
use super::{Render, display}; use super::{Render, RenderAttrs, display};
use alloc::string::String; use alloc::string::String;
use core::fmt::Display; use core::fmt::Display;
@ -333,4 +351,77 @@ pub mod html_private {
display(value).render_to(buffer); 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<T>(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<T: RenderAttrs> ViaAttrs for &&ChooseAttrsRenderOrDisplay<T> {}
impl<T: Render> ViaAttrsRender for &ChooseAttrsRenderOrDisplay<T> {}
impl<T: Display> ViaAttrsDisplay for ChooseAttrsRenderOrDisplay<T> {}
impl ViaAttrsTag {
#[track_caller]
pub fn render_to<T: RenderAttrs + ?Sized>(
self,
value: &T,
exclude: &[&str],
buffer: &mut String,
) {
value.render_attrs_to(buffer, exclude);
}
}
impl ViaAttrsRenderTag {
pub fn render_to<T: Render + ?Sized>(
self,
value: &T,
_exclude: &[&str],
buffer: &mut String,
) {
value.render_to(buffer);
}
}
impl ViaAttrsDisplayTag {
pub fn render_to<T: Display + ?Sized>(
self,
value: &T,
_exclude: &[&str],
buffer: &mut String,
) {
display(value).render_to(buffer);
}
}
} }

View file

@ -1,5 +1,5 @@
use crate::core::TypeInfo; 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 crate::{AutoDefault, CowStr, builder_fn, trace, util};
use thiserror::Error; use thiserror::Error;
@ -7,6 +7,7 @@ use thiserror::Error;
use std::any::Any; use std::any::Any;
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt::{self, Write}; use std::fmt::{self, Write};
use std::panic::Location;
use std::sync::Arc; use std::sync::Arc;
// **< PropsExtra >********************************************************************************* // **< PropsExtra >*********************************************************************************
@ -350,6 +351,24 @@ impl PropsOp {
/// assert_eq!(markup.into_string(), r#"<button style="color: blue">OK</button>"#); /// assert_eq!(markup.into_string(), r#"<button style="color: blue">OK</button>"#);
/// ``` /// ```
/// ///
/// # 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#"<span title="literal">OK</span>"#);
/// ```
///
/// # Valores extra /// # Valores extra
/// ///
/// Las variantes [`SetExtra`](PropsOp::SetExtra) y [`RemoveExtra`](PropsOp::RemoveExtra), usando /// Las variantes [`SetExtra`](PropsOp::SetExtra) y [`RemoveExtra`](PropsOp::RemoveExtra), usando
@ -881,14 +900,37 @@ impl Props {
} }
#[doc(hidden)] #[doc(hidden)]
impl Render for Props { impl RenderAttrs for Props {
fn render_to(&self, w: &mut String) { // 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() { 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=\""); w.push_str(" id=\"");
let _ = write!(Escaper::new(w), "{}", id); let _ = write!(Escaper::new(w), "{}", id);
w.push('"'); w.push('"');
} }
}
if let Some((first, rest)) = self.classes.split_first() { 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("<none>"),
"Ignoring Props attribute already set as a literal on the same element"
);
} else {
w.push_str(" class=\""); w.push_str(" class=\"");
let _ = write!(Escaper::new(w), "{}", first); let _ = write!(Escaper::new(w), "{}", first);
for class in rest { for class in rest {
@ -897,7 +939,23 @@ impl Render for Props {
} }
w.push('"'); w.push('"');
} }
}
if let Some((first, rest)) = self.styles.split_first() { 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::<Vec<_>>()
.join("; ");
trace::debug!(
caller = %Location::caller(),
attribute = "style",
discarded = %discarded,
id = %self.id.as_deref().unwrap_or("<none>"),
"Ignoring Props attribute already set as a literal on the same element"
);
} else {
w.push_str(" style=\""); w.push_str(" style=\"");
let _ = write!(Escaper::new(w), "{}: {}", first.0, first.1); let _ = write!(Escaper::new(w), "{}: {}", first.0, first.1);
for (property, value) in rest { for (property, value) in rest {
@ -906,7 +964,18 @@ impl Render for Props {
} }
w.push('"'); w.push('"');
} }
}
for (name, value) in &self.attrs { 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("<none>"),
"Ignoring Props attribute already set as a literal on the same element"
);
continue;
}
w.push(' '); w.push(' ');
let _ = write!(Escaper::new(w), "{}", name); let _ = write!(Escaper::new(w), "{}", name);
w.push_str("=\""); w.push_str("=\"");

View file

@ -157,11 +157,13 @@ async fn props_alongside_named_attr_renders_after_it() {
} }
#[pagetop::test] #[pagetop::test]
async fn props_multiple_splices_in_same_element() { async fn props_combined_via_chaining_instead_of_multiple_splices() {
let p1 = Props::new("hx-get", "/api"); // An element accepts only a single attribute splice (a second `(props)` on the same element
let p2 = Props::new("hx-swap", "outerHTML"); // 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!( assert_eq!(
html! { button (p1) (p2) {} }.into_string(), html! { button (p) {} }.into_string(),
r#"<button hx-get="/api" hx-swap="outerHTML"></button>"# r#"<button hx-get="/api" hx-swap="outerHTML"></button>"#
); );
} }
@ -193,6 +195,48 @@ async fn props_splice_empty_string_emits_nothing() {
assert_eq!(html! { span ("") { "x" } }.into_string(), "<span>x</span>"); assert_eq!(html! { span ("") { "x" } }.into_string(), "<span>x</span>");
} }
// **< 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#"<div id="fixed"></div>"#
);
}
#[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#"<div class="fixed"></div>"#
);
}
#[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#"<div style="color: blue"></div>"#
);
}
#[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#"<span title="literal"></span>"#
);
}
// **< is_attrs_empty / is_empty >****************************************************************** // **< is_attrs_empty / is_empty >******************************************************************
#[pagetop::test] #[pagetop::test]