Compare commits

..

3 commits

Author SHA1 Message Date
0121fad94a ♻️ (html): Simplifica API de Classes y ClassesOp
Elimina `ClassesOp::Replace` (sustituible con `Remove`+`Add`), renombra
`Set` a `Reset` por claridad semántica, añade `Classes::is_empty()` y
mejora documentación de `ClassesOp` con nota sobre orden CSS.
2026-06-11 07:18:04 +02:00
35a5221c92 (macros): Permite (expr) como atributo en html!
Introduce `Attribute::Splice` en el AST de Maud, de modo que `(expr)` en
posición de atributo renderiza la expresión directamente sobre el buffer
de salida del elemento.
2026-06-11 06:46:16 +02:00
47b6553fe4 🐛 (build): Aísla dir temp. por nombre de destino 2026-06-11 06:21:54 +02:00
5 changed files with 66 additions and 81 deletions

View file

@ -203,9 +203,11 @@ impl StaticFilesBundle {
where where
P: AsRef<Path>, P: AsRef<Path>,
{ {
// Crea un directorio temporal para el archivo CSS. // Crea un directorio temporal único para el archivo CSS (basado en su nombre, para que
// varias llamadas a from_scss en el mismo build.rs no se pisen).
let out_dir = std::env::var("OUT_DIR").unwrap(); let out_dir = std::env::var("OUT_DIR").unwrap();
let temp_dir = Path::new(&out_dir).join("from_scss_files"); let safe_name = target_name.replace(['.', '-'], "_");
let temp_dir = Path::new(&out_dir).join(format!("from_scss_{safe_name}"));
// Limpia el directorio temporal de ejecuciones previas, si existe. // Limpia el directorio temporal de ejecuciones previas, si existe.
if temp_dir.exists() { if temp_dir.exists() {

View file

@ -212,6 +212,7 @@ impl DiagnosticParse for Element {
|| input.peek(Lit) || input.peek(Lit)
|| input.peek(Dot) || input.peek(Dot)
|| input.peek(Pound) || input.peek(Pound)
|| input.peek(Paren)
{ {
let attr = input.diagnostic_parse(diagnostics)?; let attr = input.diagnostic_parse(diagnostics)?;
@ -346,6 +347,10 @@ pub enum Attribute {
name: HtmlName, name: HtmlName,
attr_type: AttributeType, attr_type: AttributeType,
}, },
Splice {
paren_token: Paren,
expr: Expr,
},
} }
impl DiagnosticParse for Attribute { impl DiagnosticParse for Attribute {
@ -374,6 +379,12 @@ impl DiagnosticParse for Attribute {
pound_token: input.parse()?, pound_token: input.parse()?,
name: input.diagnostic_parse(diagnostics)?, name: input.diagnostic_parse(diagnostics)?,
}) })
} else if lookahead.peek(Paren) {
let content;
Ok(Self::Splice {
paren_token: parenthesized!(content in input),
expr: content.parse()?,
})
} else { } else {
let name = input.diagnostic_parse::<HtmlName>(diagnostics)?; let name = input.diagnostic_parse::<HtmlName>(diagnostics)?;
@ -424,6 +435,11 @@ impl ToTokens for Attribute {
name.to_tokens(tokens); name.to_tokens(tokens);
attr_type.to_tokens(tokens); attr_type.to_tokens(tokens);
} }
Self::Splice { paren_token, expr } => {
paren_token.surround(tokens, |tokens| {
expr.to_tokens(tokens);
});
}
} }
} }
} }

View file

@ -139,7 +139,7 @@ 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) = split_attrs(attrs); let (classes, id, named_attrs, spliced) = split_attrs(attrs);
if !classes.is_empty() { if !classes.is_empty() {
let mut toggle_class_exprs = vec![]; let mut toggle_class_exprs = vec![];
@ -184,6 +184,9 @@ impl Generator {
for (name, attr_type) in named_attrs { for (name, attr_type) in named_attrs {
self.attr(name, attr_type, build); self.attr(name, attr_type, build);
} }
for expr in spliced {
self.splice(expr, build);
}
} }
fn control_flow<E: Into<Element>>(&self, control_flow: ControlFlow<E>, build: &mut Builder) { fn control_flow<E: Into<Element>>(&self, control_flow: ControlFlow<E>, build: &mut Builder) {
@ -316,10 +319,12 @@ fn split_attrs(
Vec<(HtmlNameOrMarkup, Option<Expr>)>, Vec<(HtmlNameOrMarkup, Option<Expr>)>,
Option<HtmlNameOrMarkup>, Option<HtmlNameOrMarkup>,
Vec<(HtmlName, AttributeType)>, Vec<(HtmlName, AttributeType)>,
Vec<Expr>,
) { ) {
let mut classes = vec![]; let mut classes = vec![];
let mut id = None; let mut id = None;
let mut named_attrs = vec![]; let mut named_attrs = vec![];
let mut spliced = vec![];
for attr in attrs { for attr in attrs {
match attr { match attr {
@ -328,10 +333,11 @@ fn split_attrs(
} }
Attribute::Id { name, .. } => id = Some(name), Attribute::Id { name, .. } => id = Some(name),
Attribute::Named { name, attr_type } => named_attrs.push((name, attr_type)), Attribute::Named { name, attr_type } => named_attrs.push((name, attr_type)),
Attribute::Splice { expr, .. } => spliced.push(expr),
} }
} }
(classes, id, named_attrs) (classes, id, named_attrs, spliced)
} }
//////////////////////////////////////////////////////// ////////////////////////////////////////////////////////

View file

@ -1,4 +1,4 @@
use crate::{AutoDefault, CowStr, builder_fn, util}; use crate::{AutoDefault, builder_fn, util};
use std::collections::HashSet; use std::collections::HashSet;
@ -7,6 +7,27 @@ use std::collections::HashSet;
/// Cada variante opera sobre **una o más clases** proporcionadas como una cadena separada por /// Cada variante opera sobre **una o más clases** proporcionadas como una cadena separada por
/// espacios (p. ej. `"btn active"`), que se normalizan internamente a minúsculas en /// espacios (p. ej. `"btn active"`), que se normalizan internamente a minúsculas en
/// [`Classes::with_classes()`]. /// [`Classes::with_classes()`].
///
/// # Orden de las clases y CSS
///
/// El navegador aplica los estilos según la especificidad de los selectores y el orden en que las
/// reglas aparecen en la **hoja de estilos**, no por el orden de las clases en el atributo `class`.
/// Por tanto, `"btn active"` y `"active btn"` producen exactamente el mismo resultado visual.
///
/// Las operaciones [`Add`](Self::Add) y [`Prepend`](Self::Prepend) permiten controlar ese orden
/// únicamente por legibilidad o por convención de proyecto, no porque afecte al comportamiento
/// del navegador.
///
/// # Reemplazar una clase
///
/// Para sustituir una clase por otra encadena [`Remove`](Self::Remove) y [`Add`](Self::Add):
/// ```rust
/// # use pagetop::prelude::*;
/// let c = Classes::new("btn btn-primary active")
/// .with_classes(ClassesOp::Remove, "btn-primary")
/// .with_classes(ClassesOp::Add, "btn-secondary");
/// assert_eq!(c.get(), Some("btn active btn-secondary".to_string()));
/// ```
#[derive(AutoDefault, Clone, Debug, PartialEq)] #[derive(AutoDefault, Clone, Debug, PartialEq)]
pub enum ClassesOp { pub enum ClassesOp {
/// Añade las clases que no existan al final. /// Añade las clases que no existan al final.
@ -16,9 +37,6 @@ pub enum ClassesOp {
Prepend, Prepend,
/// Elimina las clases indicadas que existan. /// Elimina las clases indicadas que existan.
Remove, Remove,
/// Sustituye una o varias clases existentes (indicadas en la variante) por las clases
/// proporcionadas.
Replace(CowStr),
/// Alterna presencia/ausencia de una o más clases. /// Alterna presencia/ausencia de una o más clases.
/// ///
/// Si en una misma llamada se repite una clase (p. ej. `"a a"`) que ya existe, el resultado /// Si en una misma llamada se repite una clase (p. ej. `"a a"`) que ya existe, el resultado
@ -26,7 +44,7 @@ pub enum ClassesOp {
/// final). /// final).
Toggle, Toggle,
/// Sustituye la lista completa por las clases indicadas. /// Sustituye la lista completa por las clases indicadas.
Set, Reset,
} }
/// Lista de clases CSS normalizadas para el atributo `class` de HTML. /// Lista de clases CSS normalizadas para el atributo `class` de HTML.
@ -36,8 +54,8 @@ pub enum ClassesOp {
/// ///
/// # Normalización /// # Normalización
/// ///
/// - Aunque el orden de las clases en el atributo `class` no afecta al resultado en CSS, /// - El orden de las clases no afecta al resultado en CSS; las operaciones de ordenación
/// [`ClassesOp`] ofrece operaciones para controlar su orden de aparición por legibilidad. /// ([`Add`](ClassesOp::Add), [`Prepend`](ClassesOp::Prepend)) son puramente estéticas.
/// - Solo se acepta una lista de clases con caracteres ASCII. /// - Solo se acepta una lista de clases con caracteres ASCII.
/// - Las clases se almacenan en minúsculas. /// - Las clases se almacenan en minúsculas.
/// - No se permiten clases duplicadas tras la normalización (por ejemplo, `Btn` y `btn` se /// - No se permiten clases duplicadas tras la normalización (por ejemplo, `Btn` y `btn` se
@ -51,7 +69,8 @@ pub enum ClassesOp {
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// let classes = Classes::new("Btn btn-primary") /// let classes = Classes::new("Btn btn-primary")
/// .with_classes(ClassesOp::Add, "Active") /// .with_classes(ClassesOp::Add, "Active")
/// .with_classes(ClassesOp::Replace("active".into()), "Disabled") /// .with_classes(ClassesOp::Remove, "active")
/// .with_classes(ClassesOp::Add, "Disabled")
/// .with_classes(ClassesOp::Remove, "btn-primary"); /// .with_classes(ClassesOp::Remove, "btn-primary");
/// ///
/// assert_eq!(classes.get(), Some("btn disabled".to_string())); /// assert_eq!(classes.get(), Some("btn disabled".to_string()));
@ -109,26 +128,6 @@ impl Classes {
} }
self.0.retain(|c| !to_remove.contains(c.as_str())); self.0.retain(|c| !to_remove.contains(c.as_str()));
} }
ClassesOp::Replace(classes_to_replace) => {
let Some(classes_to_replace) = util::normalize_ascii_or_empty(
classes_to_replace.as_ref(),
"ClassesOp::Replace",
) else {
return self;
};
let mut pos = self.0.len();
let mut replaced = false;
for class in classes_to_replace.as_ref().split_ascii_whitespace() {
if let Some(replace_pos) = self.0.iter().position(|c| c == class) {
self.0.remove(replace_pos);
pos = pos.min(replace_pos);
replaced = true;
}
}
if replaced {
self.add(normalized.as_ref().split_ascii_whitespace(), pos);
}
}
ClassesOp::Toggle => { ClassesOp::Toggle => {
for class in normalized.as_ref().split_ascii_whitespace() { for class in normalized.as_ref().split_ascii_whitespace() {
if let Some(pos) = self.0.iter().position(|c| c == class) { if let Some(pos) = self.0.iter().position(|c| c == class) {
@ -138,7 +137,7 @@ impl Classes {
} }
} }
} }
ClassesOp::Set => { ClassesOp::Reset => {
self.0.clear(); self.0.clear();
self.add(normalized.as_ref().split_ascii_whitespace(), 0); self.add(normalized.as_ref().split_ascii_whitespace(), 0);
} }
@ -168,6 +167,11 @@ impl Classes {
// **< Classes GETTERS >************************************************************************ // **< Classes GETTERS >************************************************************************
/// Devuelve `true` si no hay clases.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// Devuelve la cadena de clases, si existe. /// Devuelve la cadena de clases, si existe.
pub fn get(&self) -> Option<String> { pub fn get(&self) -> Option<String> {
if self.0.is_empty() { if self.0.is_empty() {

View file

@ -79,19 +79,19 @@ async fn classes_prepend_ignores_empty_input() {
} }
#[pagetop::test] #[pagetop::test]
async fn classes_set_replaces_entire_list_and_dedups() { async fn classes_reset_replaces_entire_list_and_dedups() {
let c = Classes::new("a b c").with_classes(ClassesOp::Set, "X y y Z"); let c = Classes::new("a b c").with_classes(ClassesOp::Reset, "X y y Z");
assert_classes(&c, Some("x y z")); assert_classes(&c, Some("x y z"));
} }
#[pagetop::test] #[pagetop::test]
async fn classes_set_with_empty_input_clears() { async fn classes_reset_with_empty_input_clears() {
let base = Classes::new("a b"); let base = Classes::new("a b");
let c = base.with_classes(ClassesOp::Set, " \n "); let c = base.with_classes(ClassesOp::Reset, " \n ");
assert_classes(&c, None); assert_classes(&c, None);
} }
// **< Mutation operations (remove/toggle/replace) >************************************************ // **< Mutation operations (remove/toggle) >********************************************************
#[pagetop::test] #[pagetop::test]
async fn classes_remove_is_case_insensitive() { async fn classes_remove_is_case_insensitive() {
@ -138,49 +138,6 @@ async fn classes_toggle_duplicate_tokens_are_applied_sequentially() {
assert_classes(&c, Some("b a")); assert_classes(&c, Some("b a"));
} }
#[pagetop::test]
async fn classes_replace_removes_targets_and_inserts_new_at_min_position() {
let c = Classes::new("a b c d").with_classes(ClassesOp::Replace("c a".into()), "x y");
assert_classes(&c, Some("x y b d"));
}
#[pagetop::test]
async fn classes_replace_when_none_found_does_nothing() {
let c = Classes::new("a b").with_classes(ClassesOp::Replace("x y".into()), "c d");
assert_classes(&c, Some("a b"));
}
#[pagetop::test]
async fn classes_replace_is_case_insensitive_on_targets_and_new_values_are_normalized() {
let c = Classes::new("btn btn-primary active")
.with_classes(ClassesOp::Replace("BTN-PRIMARY".into()), "Btn-Secondary");
assert_classes(&c, Some("btn btn-secondary active"));
}
#[pagetop::test]
async fn classes_replace_with_empty_new_removes_only() {
let c = Classes::new("a b c").with_classes(ClassesOp::Replace("b".into()), " ");
assert_classes(&c, Some("a c"));
}
#[pagetop::test]
async fn classes_replace_dedups_against_existing_items() {
let c = Classes::new("a b c").with_classes(ClassesOp::Replace("b".into()), "c d");
assert_classes(&c, Some("a d c"));
}
#[pagetop::test]
async fn classes_replace_ignores_target_whitespace_and_repetition() {
let c = Classes::new("a b c").with_classes(ClassesOp::Replace(" b b ".into()), "x y");
assert_classes(&c, Some("a x y c"));
}
#[pagetop::test]
async fn classes_replace_rejects_non_ascii_targets_is_noop() {
let c = Classes::new("a b c").with_classes(ClassesOp::Replace("b ñ".into()), "x");
assert_classes(&c, Some("a b c"));
}
// **< Queries (contains) >************************************************************************* // **< Queries (contains) >*************************************************************************
#[pagetop::test] #[pagetop::test]