diff --git a/src/html/classes.rs b/src/html/classes.rs
index 806a00bb..3d6e06b7 100644
--- a/src/html/classes.rs
+++ b/src/html/classes.rs
@@ -1,4 +1,4 @@
-use crate::{builder_fn, trace, util, AutoDefault};
+use crate::{builder_fn, util, AutoDefault};
use std::borrow::Cow;
use std::collections::HashSet;
@@ -74,16 +74,10 @@ impl Classes {
/// lista de clases actual.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef) -> Self {
- let normalized = match util::normalize_ascii(classes.as_ref()) {
- Ok(c) => c,
- Err(util::NormalizeAsciiError::NonAscii) => {
- trace::debug!(
- classes = %classes.as_ref().escape_default(),
- "Classes::with_classes: Ignoring classes due to non-ASCII chars"
- );
- return self;
- }
- _ => Cow::Borrowed(""),
+ let Some(normalized) =
+ util::normalize_ascii_or_empty(classes.as_ref(), "Classes::with_classes")
+ else {
+ return self;
};
match op {
ClassesOp::Add => {
@@ -116,25 +110,24 @@ impl Classes {
self.0.retain(|c| !to_remove.contains(c.as_str()));
}
ClassesOp::Replace(classes_to_replace) => {
- let mut pos = self.0.len();
- let classes_to_replace = match util::normalize_ascii(classes_to_replace.as_ref()) {
- Ok(c) => c,
- Err(util::NormalizeAsciiError::NonAscii) => {
- trace::debug!(
- classes = %classes_to_replace.as_ref().escape_default(),
- "Classes::with_classes: Invalid replace classes due to non-ASCII chars"
- );
- return self;
- }
- _ => Cow::Borrowed(""),
+ 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;
}
}
- self.add(normalized.as_ref().split_ascii_whitespace(), pos);
+ if replaced {
+ self.add(normalized.as_ref().split_ascii_whitespace(), pos);
+ }
}
ClassesOp::Toggle => {
for class in normalized.as_ref().split_ascii_whitespace() {
diff --git a/src/util.rs b/src/util.rs
index 45073a61..de275efa 100644
--- a/src/util.rs
+++ b/src/util.rs
@@ -128,6 +128,30 @@ pub fn normalize_ascii<'a>(input: &'a str) -> Result, NormalizeAsci
Ok(Cow::Owned(output))
}
+/// Normaliza una cadena ASCII, opcionalmente vacía, con uno o varios tokens separados.
+///
+/// - Devuelve `Some(Cow)` si la entrada es válida ASCII (normalizada a minúsculas).
+/// - Devuelve `Some(Cow::Borrowed(""))` si la entrada es `""` o queda vacía tras recortar.
+/// - Devuelve `None` si la entrada contiene bytes non-ASCII; y emite un `trace::debug!` con el
+/// campo `target`.
+#[inline]
+pub fn normalize_ascii_or_empty<'a>(input: &'a str, target: &'static str) -> Option> {
+ match normalize_ascii(input) {
+ Ok(s) => Some(s),
+ Err(NormalizeAsciiError::NonAscii) => {
+ trace::debug!(
+ target = %target,
+ input = %input.escape_default(),
+ "Ignoring due to non-ASCII chars"
+ );
+ None
+ }
+ Err(NormalizeAsciiError::IsEmpty | NormalizeAsciiError::EmptyAfterTrimming) => {
+ Some(Cow::Borrowed(""))
+ }
+ }
+}
+
/// Resuelve y valida la ruta de un directorio existente, devolviendo una ruta absoluta.
///
/// - Si la ruta es relativa, se resuelve respecto al directorio del proyecto según la variable de
diff --git a/tests/html_classes.rs b/tests/html_classes.rs
index 4b2326dd..91eaaad7 100644
--- a/tests/html_classes.rs
+++ b/tests/html_classes.rs
@@ -54,6 +54,12 @@ async fn classes_add_same_tokens() {
assert_classes(&c, Some("a b"));
}
+#[pagetop::test]
+async fn classes_add_rejects_non_ascii_is_noop() {
+ let c = Classes::new("a b").with_classes(ClassesOp::Add, "c ñ d");
+ assert_classes(&c, Some("a b"));
+}
+
#[pagetop::test]
async fn classes_prepend_inserts_at_front_preserving_new_order() {
let c = Classes::new("c d").with_classes(ClassesOp::Prepend, "A b");
@@ -139,9 +145,9 @@ async fn classes_replace_removes_targets_and_inserts_new_at_min_position() {
}
#[pagetop::test]
-async fn classes_replace_when_none_found_appends_at_end() {
+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 c d"));
+ assert_classes(&c, Some("a b"));
}
#[pagetop::test]
@@ -169,6 +175,12 @@ async fn classes_replace_ignores_target_whitespace_and_repetition() {
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]
@@ -192,6 +204,22 @@ async fn classes_contains_all_and_any() {
assert!(!c.contains_any("missing other"));
}
+#[pagetop::test]
+async fn classes_contains_empty_and_whitespace_is_false() {
+ let c = Classes::new("a b");
+ assert!(!c.contains(""));
+ assert!(!c.contains(" \t"));
+ assert!(!c.contains_any(""));
+ assert!(!c.contains_any(" \n "));
+}
+
+#[pagetop::test]
+async fn classes_contains_non_ascii_is_false() {
+ let c = Classes::new("a b");
+ assert!(!c.contains("ñ"));
+ assert!(!c.contains_any("a ñ"));
+}
+
// **< Properties / regression (combined sequences, ordering) >*************************************
#[pagetop::test]