diff --git a/src/base/action/component/after_render_component.rs b/src/base/action/component/after_render_component.rs index c7da161c..3a9901fc 100644 --- a/src/base/action/component/after_render_component.rs +++ b/src/base/action/component/after_render_component.rs @@ -39,8 +39,7 @@ impl AfterRender { /// Afina el registro para ejecutar la acción [`FnActionWithComponent`] sólo para el componente /// `C` con identificador `id`. pub fn filter_by_referer_id(mut self, id: impl AsRef) -> Self { - let id = id.as_ref().trim().to_ascii_lowercase().replace(' ', "_"); - self.referer_id = if id.is_empty() { None } else { Some(id) }; + self.referer_id = util::normalize_token(id); self } diff --git a/src/base/action/component/before_render_component.rs b/src/base/action/component/before_render_component.rs index 2443f05d..08cd46d4 100644 --- a/src/base/action/component/before_render_component.rs +++ b/src/base/action/component/before_render_component.rs @@ -39,8 +39,7 @@ impl BeforeRender { /// Afina el registro para ejecutar la acción [`FnActionWithComponent`] sólo para el componente /// `C` con identificador `id`. pub fn filter_by_referer_id(mut self, id: impl AsRef) -> Self { - let id = id.as_ref().trim().to_ascii_lowercase().replace(' ', "_"); - self.referer_id = if id.is_empty() { None } else { Some(id) }; + self.referer_id = util::normalize_token(id); self } diff --git a/src/base/action/component/transform_markup_component.rs b/src/base/action/component/transform_markup_component.rs index ab4e2f77..64a4a507 100644 --- a/src/base/action/component/transform_markup_component.rs +++ b/src/base/action/component/transform_markup_component.rs @@ -39,8 +39,7 @@ impl TransformMarkup { /// Afina el registro para ejecutar la acción [`FnActionTransformMarkup`] sólo para el /// componente `C` con identificador `id`. pub fn filter_by_referer_id(mut self, id: impl AsRef) -> Self { - let id = id.as_ref().trim().to_ascii_lowercase().replace(' ', "_"); - self.referer_id = if id.is_empty() { None } else { Some(id) }; + self.referer_id = util::normalize_token(id); self } diff --git a/src/html/attr.rs b/src/html/attr.rs index 07fe52f5..9a3551bc 100644 --- a/src/html/attr.rs +++ b/src/html/attr.rs @@ -1,5 +1,5 @@ use crate::locale::{L10n, LangId}; -use crate::{AutoDefault, builder_fn}; +use crate::{AutoDefault, builder_fn, util}; /// Valor opcional para atributos HTML. /// @@ -163,12 +163,10 @@ impl AttrName { /// Establece un nombre nuevo normalizando el valor. #[builder_fn] pub fn with_name(mut self, name: impl AsRef) -> Self { - let name = name.as_ref().trim(); - if name.is_empty() { - self.0 = Attr::default(); - } else { - self.0 = Attr::some(name.to_ascii_lowercase().replace(' ', "_")); - } + self.0 = match util::normalize_token(name) { + Some(name) => Attr::some(name), + None => Attr::default(), + }; self } @@ -228,12 +226,10 @@ impl AttrValue { /// Establece una cadena nueva normalizando el valor. #[builder_fn] pub fn with_str(mut self, value: impl AsRef) -> Self { - let value = value.as_ref().trim(); - if value.is_empty() { - self.0 = Attr::default(); - } else { - self.0 = Attr::some(value.to_string()); - } + self.0 = match util::non_blank(value.as_ref()) { + Some(value) => Attr::some(value.to_string()), + None => Attr::default(), + }; self } diff --git a/src/html/props.rs b/src/html/props.rs index 2bc9104d..a87dccdd 100644 --- a/src/html/props.rs +++ b/src/html/props.rs @@ -734,12 +734,7 @@ impl Props { // **< Props PRIVATE >************************************************************************** fn apply_id(&mut self, id: &str) { - let id = id.trim(); - self.id = if id.is_empty() { - None - } else { - Some(id.to_ascii_lowercase().replace(' ', "_")) - }; + self.id = util::normalize_token(id); } fn insert_classes<'a, I>(&mut self, classes: I, mut pos: usize) diff --git a/src/html/unit.rs b/src/html/unit.rs index 3df35612..9eba992f 100644 --- a/src/html/unit.rs +++ b/src/html/unit.rs @@ -1,4 +1,4 @@ -use crate::AutoDefault; +use crate::{AutoDefault, util}; use serde::{Deserialize, Deserializer}; @@ -201,10 +201,9 @@ impl FromStr for UnitValue { type Err = String; fn from_str(input: &str) -> Result { - let s = input.trim(); - if s.is_empty() { + let Some(s) = util::non_blank(input) else { return Ok(UnitValue::None); - } + }; if s.eq_ignore_ascii_case("auto") { return Ok(UnitValue::Auto); } diff --git a/src/locale/definition.rs b/src/locale/definition.rs index 1b754e13..40767003 100644 --- a/src/locale/definition.rs +++ b/src/locale/definition.rs @@ -1,4 +1,4 @@ -use crate::{global, trace}; +use crate::{global, trace, util}; use super::languages::LANGUAGES; use super::{LanguageIdentifier, langid}; @@ -92,12 +92,9 @@ impl Locale { /// [`Locale::Resolved`]. /// - En caso contrario, devuelve [`Locale::Unsupported`] con la cadena original. pub fn resolve(language: impl AsRef) -> Self { - let language = language.as_ref().trim(); - - // Rechaza cadenas vacías. - if language.is_empty() { + let Some(language) = util::non_blank(language.as_ref()) else { return Self::Unspecified; - } + }; // Intenta aplicar coincidencia exacta con el código completo (p. ej. "es-MX"). let lang = language.to_ascii_lowercase(); @@ -149,8 +146,13 @@ impl Locale { /// Debe llamarse durante la inicialización para indicar si el idioma por defecto procede de la /// configuración, de una configuración no válida o del idioma de respaldo. pub(crate) fn init() { - match global::SETTINGS.app.language.as_deref() { - Some(raw) if !raw.trim().is_empty() => { + match global::SETTINGS + .app + .language + .as_deref() + .and_then(util::non_blank) + { + Some(raw) => { if let Some(langid) = *CONFIG_LANGID { trace::debug!("Default language \"{langid}\" (from config: \"{raw}\")"); } else { diff --git a/src/util.rs b/src/util.rs index 4a0df3b5..0c0ebf22 100644 --- a/src/util.rs +++ b/src/util.rs @@ -50,6 +50,25 @@ pub enum NormalizeAsciiError { NonAscii, } +/// Recorta espacios y convierte una cadena vacía en `None`. +/// +/// Pensada para campos de formulario opcionales, de tal manera que si una cadena queda vacía tras +/// recortar, entonces se trata como "no proporcionado" en lugar de como un valor válido. +/// +/// # Ejemplo +/// +/// ```rust +/// # use pagetop::util; +/// assert_eq!(util::non_blank(" hello "), Some("hello")); +/// assert_eq!(util::non_blank(" "), None); +/// assert_eq!(util::non_blank(""), None); +/// ``` +#[inline] +pub fn non_blank(s: &str) -> Option<&str> { + let s = s.trim(); + if s.is_empty() { None } else { Some(s) } +} + /// Normaliza una cadena ASCII con uno o varios tokens separados. /// /// Los *separadores* son caracteres `is_ascii_whitespace()` como `' '`, `'\t'`, `'\n'` o `'\r'`. @@ -168,6 +187,32 @@ pub fn normalize_ascii_or_empty<'a>(input: &'a str, target: &'static str) -> Opt } } +/// Recorta espacios, convierte una cadena vacía en `None` y normaliza el resto. +/// +/// Convierte en un único token: en minúsculas y con cada espacio en blanco sustituido por `_`. +/// +/// Al contrario que [`normalize_ascii()`], no colapsa las secuencias de varios espacios en blanco +/// seguidos, sino que cada uno se convierte en su propio `_` (p. ej. dos espacios seguidos generan +/// `__`). +/// +/// Está pensada para identificadores internos (p. ej. el `id` HTML de un componente, o la clave +/// `referer_id` de una acción) construidos a partir de texto libre. +/// +/// # Ejemplo +/// +/// ```rust +/// # use pagetop::util; +/// assert_eq!(util::normalize_token(" My Id "), Some("my_id".to_string())); +/// assert_eq!(util::normalize_token("AÑO\tNuevo"), Some("año_nuevo".to_string())); +/// assert_eq!(util::normalize_token("a b"), Some("a___b".to_string())); +/// assert_eq!(util::normalize_token(" "), None); +/// assert_eq!(util::normalize_token(""), None); +/// ``` +#[inline] +pub fn normalize_token(s: impl AsRef) -> Option { + non_blank(s.as_ref()).map(|s| s.to_lowercase().replace(char::is_whitespace, "_")) +} + /// Indica si una URL **parece** externa por su prefijo. /// /// No es una validación de la URL; sólo mira el inicio del texto: `//` (relativa al protocolo),