diff --git a/helpers/pagetop-build/src/lib.rs b/helpers/pagetop-build/src/lib.rs index eacb6180..f8390ee6 100644 --- a/helpers/pagetop-build/src/lib.rs +++ b/helpers/pagetop-build/src/lib.rs @@ -203,11 +203,9 @@ impl StaticFilesBundle { where P: AsRef, { - // 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). + // Crea un directorio temporal para el archivo CSS. let out_dir = std::env::var("OUT_DIR").unwrap(); - let safe_name = target_name.replace(['.', '-'], "_"); - let temp_dir = Path::new(&out_dir).join(format!("from_scss_{safe_name}")); + let temp_dir = Path::new(&out_dir).join("from_scss_files"); // 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 c8309ef5..cdda2331 100644 --- a/helpers/pagetop-macros/src/maud/ast.rs +++ b/helpers/pagetop-macros/src/maud/ast.rs @@ -212,7 +212,6 @@ impl DiagnosticParse for Element { || input.peek(Lit) || input.peek(Dot) || input.peek(Pound) - || input.peek(Paren) { let attr = input.diagnostic_parse(diagnostics)?; @@ -347,10 +346,6 @@ pub enum Attribute { name: HtmlName, attr_type: AttributeType, }, - Splice { - paren_token: Paren, - expr: Expr, - }, } impl DiagnosticParse for Attribute { @@ -379,12 +374,6 @@ 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)?; @@ -435,11 +424,6 @@ 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 ed2fa214..a3dfb36e 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, spliced) = split_attrs(attrs); + let (classes, id, named_attrs) = split_attrs(attrs); if !classes.is_empty() { let mut toggle_class_exprs = vec![]; @@ -184,9 +184,6 @@ 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) { @@ -319,12 +316,10 @@ 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 { @@ -333,11 +328,10 @@ 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, spliced) + (classes, id, named_attrs) } //////////////////////////////////////////////////////// diff --git a/src/html/classes.rs b/src/html/classes.rs index 903475ec..2f665c19 100644 --- a/src/html/classes.rs +++ b/src/html/classes.rs @@ -1,4 +1,4 @@ -use crate::{AutoDefault, builder_fn, util}; +use crate::{AutoDefault, CowStr, builder_fn, util}; use std::collections::HashSet; @@ -7,27 +7,6 @@ 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. @@ -37,6 +16,9 @@ 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 @@ -44,7 +26,7 @@ pub enum ClassesOp { /// final). Toggle, /// Sustituye la lista completa por las clases indicadas. - Reset, + Set, } /// Lista de clases CSS normalizadas para el atributo `class` de HTML. @@ -54,8 +36,8 @@ pub enum ClassesOp { /// /// # Normalización /// -/// - 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. +/// - 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. /// - 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 @@ -69,8 +51,7 @@ pub enum ClassesOp { /// # use pagetop::prelude::*; /// let classes = Classes::new("Btn btn-primary") /// .with_classes(ClassesOp::Add, "Active") -/// .with_classes(ClassesOp::Remove, "active") -/// .with_classes(ClassesOp::Add, "Disabled") +/// .with_classes(ClassesOp::Replace("active".into()), "Disabled") /// .with_classes(ClassesOp::Remove, "btn-primary"); /// /// assert_eq!(classes.get(), Some("btn disabled".to_string())); @@ -128,6 +109,26 @@ 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) { @@ -137,7 +138,7 @@ impl Classes { } } } - ClassesOp::Reset => { + ClassesOp::Set => { self.0.clear(); self.add(normalized.as_ref().split_ascii_whitespace(), 0); } @@ -167,11 +168,6 @@ 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 e2198335..91eaaad7 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_reset_replaces_entire_list_and_dedups() { - let c = Classes::new("a b c").with_classes(ClassesOp::Reset, "X y y Z"); +async fn classes_set_replaces_entire_list_and_dedups() { + let c = Classes::new("a b c").with_classes(ClassesOp::Set, "X y y Z"); assert_classes(&c, Some("x y z")); } #[pagetop::test] -async fn classes_reset_with_empty_input_clears() { +async fn classes_set_with_empty_input_clears() { let base = Classes::new("a b"); - let c = base.with_classes(ClassesOp::Reset, " \n "); + let c = base.with_classes(ClassesOp::Set, " \n "); assert_classes(&c, None); } -// **< Mutation operations (remove/toggle) >******************************************************** +// **< Mutation operations (remove/toggle/replace) >************************************************ #[pagetop::test] async fn classes_remove_is_case_insensitive() { @@ -138,6 +138,49 @@ 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]