diff --git a/helpers/pagetop-build/src/lib.rs b/helpers/pagetop-build/src/lib.rs index f8390ee6..eacb6180 100644 --- a/helpers/pagetop-build/src/lib.rs +++ b/helpers/pagetop-build/src/lib.rs @@ -203,9 +203,11 @@ impl StaticFilesBundle { where P: AsRef, { - // 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 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. if temp_dir.exists() { diff --git a/helpers/pagetop-macros/src/maud/ast.rs b/helpers/pagetop-macros/src/maud/ast.rs index cdda2331..c8309ef5 100644 --- a/helpers/pagetop-macros/src/maud/ast.rs +++ b/helpers/pagetop-macros/src/maud/ast.rs @@ -212,6 +212,7 @@ impl DiagnosticParse for Element { || input.peek(Lit) || input.peek(Dot) || input.peek(Pound) + || input.peek(Paren) { let attr = input.diagnostic_parse(diagnostics)?; @@ -346,6 +347,10 @@ pub enum Attribute { name: HtmlName, attr_type: AttributeType, }, + Splice { + paren_token: Paren, + expr: Expr, + }, } impl DiagnosticParse for Attribute { @@ -374,6 +379,12 @@ impl DiagnosticParse for Attribute { pound_token: input.parse()?, 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 { let name = input.diagnostic_parse::(diagnostics)?; @@ -424,6 +435,11 @@ impl ToTokens for Attribute { name.to_tokens(tokens); attr_type.to_tokens(tokens); } + Self::Splice { paren_token, expr } => { + paren_token.surround(tokens, |tokens| { + expr.to_tokens(tokens); + }); + } } } } diff --git a/helpers/pagetop-macros/src/maud/generate.rs b/helpers/pagetop-macros/src/maud/generate.rs index a3dfb36e..ed2fa214 100644 --- a/helpers/pagetop-macros/src/maud/generate.rs +++ b/helpers/pagetop-macros/src/maud/generate.rs @@ -139,7 +139,7 @@ impl Generator { } fn attrs(&self, attrs: Vec, build: &mut Builder) { - let (classes, id, named_attrs) = split_attrs(attrs); + let (classes, id, named_attrs, spliced) = split_attrs(attrs); if !classes.is_empty() { let mut toggle_class_exprs = vec![]; @@ -184,6 +184,9 @@ impl Generator { for (name, attr_type) in named_attrs { self.attr(name, attr_type, build); } + for expr in spliced { + self.splice(expr, build); + } } fn control_flow>(&self, control_flow: ControlFlow, build: &mut Builder) { @@ -316,10 +319,12 @@ fn split_attrs( Vec<(HtmlNameOrMarkup, Option)>, Option, Vec<(HtmlName, AttributeType)>, + Vec, ) { let mut classes = vec![]; let mut id = None; let mut named_attrs = vec![]; + let mut spliced = vec![]; for attr in attrs { match attr { @@ -328,10 +333,11 @@ fn split_attrs( } Attribute::Id { name, .. } => id = Some(name), 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) } //////////////////////////////////////////////////////// diff --git a/src/html/classes.rs b/src/html/classes.rs index 2f665c19..903475ec 100644 --- a/src/html/classes.rs +++ b/src/html/classes.rs @@ -1,4 +1,4 @@ -use crate::{AutoDefault, CowStr, builder_fn, util}; +use crate::{AutoDefault, builder_fn, util}; 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 /// espacios (p. ej. `"btn active"`), que se normalizan internamente a minúsculas en /// [`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)] pub enum ClassesOp { /// Añade las clases que no existan al final. @@ -16,9 +37,6 @@ pub enum ClassesOp { Prepend, /// Elimina las clases indicadas que existan. 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. /// /// 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). Toggle, /// Sustituye la lista completa por las clases indicadas. - Set, + Reset, } /// Lista de clases CSS normalizadas para el atributo `class` de HTML. @@ -36,8 +54,8 @@ pub enum ClassesOp { /// /// # Normalización /// -/// - Aunque el orden de las clases en el atributo `class` no afecta al resultado en CSS, -/// [`ClassesOp`] ofrece operaciones para controlar su orden de aparición por legibilidad. +/// - El orden de las clases no afecta al resultado en CSS; las operaciones de ordenación +/// ([`Add`](ClassesOp::Add), [`Prepend`](ClassesOp::Prepend)) son puramente estéticas. /// - Solo se acepta una lista de clases con caracteres ASCII. /// - Las clases se almacenan en minúsculas. /// - 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::*; /// let classes = Classes::new("Btn btn-primary") /// .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"); /// /// 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())); } - 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 => { for class in normalized.as_ref().split_ascii_whitespace() { 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.add(normalized.as_ref().split_ascii_whitespace(), 0); } @@ -168,6 +167,11 @@ impl Classes { // **< 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. pub fn get(&self) -> Option { if self.0.is_empty() { diff --git a/tests/html_classes.rs b/tests/html_classes.rs index 91eaaad7..e2198335 100644 --- a/tests/html_classes.rs +++ b/tests/html_classes.rs @@ -79,19 +79,19 @@ async fn classes_prepend_ignores_empty_input() { } #[pagetop::test] -async fn classes_set_replaces_entire_list_and_dedups() { - let c = Classes::new("a b c").with_classes(ClassesOp::Set, "X y y Z"); +async fn classes_reset_replaces_entire_list_and_dedups() { + let c = Classes::new("a b c").with_classes(ClassesOp::Reset, "X y y Z"); assert_classes(&c, Some("x y z")); } #[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 c = base.with_classes(ClassesOp::Set, " \n "); + let c = base.with_classes(ClassesOp::Reset, " \n "); assert_classes(&c, None); } -// **< Mutation operations (remove/toggle/replace) >************************************************ +// **< Mutation operations (remove/toggle) >******************************************************** #[pagetop::test] 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")); } -#[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) >************************************************************************* #[pagetop::test]