From f88513d67f55a6735fb684e0d86f97c6092ced63 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Mon, 21 Jul 2025 20:52:45 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20A=C3=B1ade=20tipos=20para=20renderi?= =?UTF-8?q?zar=20atributos=20HTML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/html.rs | 15 +++++ src/html/opt_classes.rs | 130 +++++++++++++++++++++++++++++++++++++ src/html/opt_id.rs | 57 ++++++++++++++++ src/html/opt_name.rs | 57 ++++++++++++++++ src/html/opt_string.rs | 56 ++++++++++++++++ src/html/opt_translated.rs | 65 +++++++++++++++++++ src/locale.rs | 15 ++++- 7 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 src/html/opt_classes.rs create mode 100644 src/html/opt_id.rs create mode 100644 src/html/opt_name.rs create mode 100644 src/html/opt_string.rs create mode 100644 src/html/opt_translated.rs diff --git a/src/html.rs b/src/html.rs index 14a89ed..4fc9b3e 100644 --- a/src/html.rs +++ b/src/html.rs @@ -12,6 +12,21 @@ pub(crate) use assets::Assets; mod context; pub use context::{Context, ErrorParam}; +mod opt_id; +pub use opt_id::OptionId; + +mod opt_name; +pub use opt_name::OptionName; + +mod opt_string; +pub use opt_string::OptionString; + +mod opt_translated; +pub use opt_translated::OptionTranslated; + +mod opt_classes; +pub use opt_classes::{ClassesOp, OptionClasses}; + use crate::AutoDefault; /// Prepara contenido HTML para su conversión a [`Markup`]. diff --git a/src/html/opt_classes.rs b/src/html/opt_classes.rs new file mode 100644 index 0000000..bcec937 --- /dev/null +++ b/src/html/opt_classes.rs @@ -0,0 +1,130 @@ +use crate::{builder_fn, AutoDefault}; + +/// Operaciones disponibles sobre la lista de clases en [`OptionClasses`]. +pub enum ClassesOp { + /// Añade al final (si no existe). + Add, + /// Añade al principio. + Prepend, + /// Elimina coincidencias. + Remove, + /// Sustituye una o varias por las nuevas (`Replace("old other")`). + Replace(String), + /// Alterna presencia/ausencia. + Toggle, + /// Sustituye toda la lista. + Set, +} + +/// Cadena de clases CSS normalizadas para el atributo `class` de HTML. +/// +/// Permite construir y modificar dinámicamente con [`ClassesOp`] una lista de clases CSS +/// normalizadas. +/// +/// ### Normalización +/// - El [orden de las clases no es relevante](https://stackoverflow.com/a/1321712) en CSS. +/// - No se permiten clases duplicadas. +/// - Las clases vacías se ignoran. +/// +/// # Ejemplo +/// ```rust +/// use pagetop::prelude::*; +/// +/// let classes = OptionClasses::new("btn btn-primary") +/// .with_value(ClassesOp::Add, "active") +/// .with_value(ClassesOp::Remove, "btn-primary"); +/// +/// assert_eq!(classes.get(), Some(String::from("btn active"))); +/// assert!(classes.contains("active")); +/// ``` +#[derive(AutoDefault, Clone, Debug)] +pub struct OptionClasses(Vec); + +impl OptionClasses { + pub fn new(classes: impl AsRef) -> Self { + OptionClasses::default().with_value(ClassesOp::Prepend, classes) + } + + // OptionClasses BUILDER *********************************************************************** + + #[builder_fn] + pub fn with_value(mut self, op: ClassesOp, classes: impl AsRef) -> Self { + let classes: &str = classes.as_ref(); + let classes: Vec<&str> = classes.split_ascii_whitespace().collect(); + + if classes.is_empty() { + return self; + } + + match op { + ClassesOp::Add => { + self.add(&classes, self.0.len()); + } + ClassesOp::Prepend => { + self.add(&classes, 0); + } + ClassesOp::Remove => { + for class in classes { + self.0.retain(|c| c.ne(&class.to_string())); + } + } + ClassesOp::Replace(classes_to_replace) => { + let mut pos = self.0.len(); + let replace: Vec<&str> = classes_to_replace.split_ascii_whitespace().collect(); + for class in replace { + if let Some(replace_pos) = self.0.iter().position(|c| c.eq(class)) { + self.0.remove(replace_pos); + if pos > replace_pos { + pos = replace_pos; + } + } + } + self.add(&classes, pos); + } + ClassesOp::Toggle => { + for class in classes { + if !class.is_empty() { + if let Some(pos) = self.0.iter().position(|c| c.eq(class)) { + self.0.remove(pos); + } else { + self.0.push(class.to_string()); + } + } + } + } + ClassesOp::Set => { + self.0.clear(); + self.add(&classes, 0); + } + } + + self + } + + #[inline] + fn add(&mut self, classes: &[&str], mut pos: usize) { + for &class in classes { + if !class.is_empty() && !self.0.iter().any(|c| c == class) { + self.0.insert(pos, class.to_string()); + pos += 1; + } + } + } + + // OptionClasses GETTERS *********************************************************************** + + /// Devuele la cadena de clases, si existe. + pub fn get(&self) -> Option { + if self.0.is_empty() { + None + } else { + Some(self.0.join(" ")) + } + } + + /// Devuelve `true` si la clase está presente. + pub fn contains(&self, class: impl AsRef) -> bool { + let class = class.as_ref(); + self.0.iter().any(|c| c == class) + } +} diff --git a/src/html/opt_id.rs b/src/html/opt_id.rs new file mode 100644 index 0000000..6f166b9 --- /dev/null +++ b/src/html/opt_id.rs @@ -0,0 +1,57 @@ +use crate::{builder_fn, AutoDefault}; + +/// Identificador normalizado para el atributo `id` o similar de HTML. +/// +/// Este tipo encapsula `Option` garantizando un valor normalizado para su uso. +/// +/// # Normalización +/// - Se eliminan los espacios al principio y al final. +/// - Se sustituyen los espacios intermedios por guiones bajos (`_`). +/// - Si el resultado es una cadena vacía, se guarda `None`. +/// +/// # Ejemplo +/// +/// ```rust +/// use pagetop::prelude::*; +/// +/// let id = OptionId::new("main section"); +/// assert_eq!(id.get(), Some(String::from("main_section"))); +/// +/// let empty = OptionId::default(); +/// assert_eq!(empty.get(), None); +/// ``` +#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)] +pub struct OptionId(Option); + +impl OptionId { + /// Crea un nuevo [`OptionId`]. + /// + /// El valor se normaliza automáticamente. + pub fn new(value: impl AsRef) -> Self { + OptionId::default().with_value(value) + } + + // OptionId BUILDER **************************************************************************** + + /// Establece un identificador nuevo. + /// + /// El valor se normaliza automáticamente. + #[builder_fn] + pub fn with_value(mut self, value: impl AsRef) -> Self { + let value = value.as_ref().trim().replace(' ', "_"); + self.0 = (!value.is_empty()).then_some(value); + self + } + + // OptionId GETTERS **************************************************************************** + + /// Devuelve el identificador, si existe. + pub fn get(&self) -> Option { + if let Some(value) = &self.0 { + if !value.is_empty() { + return Some(value.to_owned()); + } + } + None + } +} diff --git a/src/html/opt_name.rs b/src/html/opt_name.rs new file mode 100644 index 0000000..c4e2e2e --- /dev/null +++ b/src/html/opt_name.rs @@ -0,0 +1,57 @@ +use crate::{builder_fn, AutoDefault}; + +/// Nombre normalizado para el atributo `name` o similar de HTML. +/// +/// Este tipo encapsula `Option` garantizando un valor normalizado para su uso. +/// +/// # Normalización +/// - Se eliminan los espacios al principio y al final. +/// - Se sustituyen los espacios intermedios por guiones bajos (`_`). +/// - Si el resultado es una cadena vacía, se guarda `None`. +/// +/// ## Ejemplo +/// +/// ```rust +/// use pagetop::prelude::*; +/// +/// let name = OptionName::new(" display name "); +/// assert_eq!(name.get(), Some(String::from("display_name"))); +/// +/// let empty = OptionName::default(); +/// assert_eq!(empty.get(), None); +/// ``` +#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)] +pub struct OptionName(Option); + +impl OptionName { + /// Crea un nuevo [`OptionName`]. + /// + /// El valor se normaliza automáticamente. + pub fn new(value: impl AsRef) -> Self { + OptionName::default().with_value(value) + } + + // OptionName BUILDER ************************************************************************** + + /// Establece un nombre nuevo. + /// + /// El valor se normaliza automáticamente. + #[builder_fn] + pub fn with_value(mut self, value: impl AsRef) -> Self { + let value = value.as_ref().trim().replace(' ', "_"); + self.0 = (!value.is_empty()).then_some(value); + self + } + + // OptionName GETTERS ************************************************************************** + + /// Devuelve el nombre, si existe. + pub fn get(&self) -> Option { + if let Some(value) = &self.0 { + if !value.is_empty() { + return Some(value.to_owned()); + } + } + None + } +} diff --git a/src/html/opt_string.rs b/src/html/opt_string.rs new file mode 100644 index 0000000..5379597 --- /dev/null +++ b/src/html/opt_string.rs @@ -0,0 +1,56 @@ +use crate::{builder_fn, AutoDefault}; + +/// Cadena normalizada para renderizar en atributos HTML. +/// +/// Este tipo encapsula `Option` garantizando un valor normalizado para su uso. +/// +/// # Normalización +/// - Se eliminan los espacios al principio y al final. +/// - Si el resultado es una cadena vacía, se guarda `None`. +/// +/// # Ejemplo +/// +/// ```rust +/// use pagetop::prelude::*; +/// +/// let s = OptionString::new(" a new string "); +/// assert_eq!(s.get(), Some(String::from("a new string"))); +/// +/// let empty = OptionString::default(); +/// assert_eq!(empty.get(), None); +/// ``` +#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)] +pub struct OptionString(Option); + +impl OptionString { + /// Crea un nuevo [`OptionString`]. + /// + /// El valor se normaliza automáticamente. + pub fn new(value: impl AsRef) -> Self { + OptionString::default().with_value(value) + } + + // OptionString BUILDER ************************************************************************ + + /// Establece una cadena nueva. + /// + /// El valor se normaliza automáticamente. + #[builder_fn] + pub fn with_value(mut self, value: impl AsRef) -> Self { + let value = value.as_ref().trim().to_owned(); + self.0 = (!value.is_empty()).then_some(value); + self + } + + // OptionString GETTERS ************************************************************************ + + /// Devuelve la cadena, si existe. + pub fn get(&self) -> Option { + if let Some(value) = &self.0 { + if !value.is_empty() { + return Some(value.to_owned()); + } + } + None + } +} diff --git a/src/html/opt_translated.rs b/src/html/opt_translated.rs new file mode 100644 index 0000000..ba60a0f --- /dev/null +++ b/src/html/opt_translated.rs @@ -0,0 +1,65 @@ +use crate::html::Markup; +use crate::locale::{L10n, LanguageIdentifier}; +use crate::{builder_fn, AutoDefault}; + +/// Cadena para traducir al renderizar ([`locale`](crate::locale)). +/// +/// Encapsula un tipo [`L10n`] para manejar traducciones de forma segura. +/// +/// # Ejemplo +/// +/// ```rust +/// use pagetop::prelude::*; +/// +/// // Traducción por clave en las locales por defecto de PageTop. +/// let hello = OptionTranslated::new(L10n::l("test-hello-world")); +/// +/// // Español disponible. +/// assert_eq!( +/// hello.using(LangMatch::langid_or_default("es-ES")), +/// Some(String::from("¡Hola mundo!")) +/// ); +/// +/// // Japonés no disponible, traduce al idioma de respaldo ("en-US"). +/// assert_eq!( +/// hello.using(LangMatch::langid_or_fallback("ja-JP")), +/// Some(String::from("Hello world!")) +/// ); +/// +/// // Para incrustar en HTML escapado: +/// let markup = hello.escaped(LangMatch::langid_or_default("es-ES")); +/// assert_eq!(markup.into_string(), "¡Hola mundo!"); +/// ``` +#[derive(AutoDefault, Clone, Debug)] +pub struct OptionTranslated(L10n); + +impl OptionTranslated { + /// Crea una nueva instancia [`OptionTranslated`]. + pub fn new(value: L10n) -> Self { + OptionTranslated(value) + } + + // OptionTranslated BUILDER ******************************************************************** + + /// Establece una traducción nueva. + #[builder_fn] + pub fn with_value(mut self, value: L10n) -> Self { + self.0 = value; + self + } + + // OptionTranslated GETTERS ******************************************************************** + + /// Devuelve la traducción para `langid`, si existe. + pub fn using(&self, langid: &LanguageIdentifier) -> Option { + self.0.using(langid) + } + + /// Devuelve la traducción *escapada* como [`Markup`] para `langid`, si existe. + /// + /// Útil para incrustar el texto directamente en plantillas HTML sin riesgo de inyección de + /// contenido. + pub fn escaped(&self, langid: &LanguageIdentifier) -> Markup { + self.0.escaped(langid) + } +} diff --git a/src/locale.rs b/src/locale.rs index 2dcf27e..9af83f5 100644 --- a/src/locale.rs +++ b/src/locale.rs @@ -284,7 +284,7 @@ include_locales!(LOCALES_PAGETOP); // * `None` - No se aplica ninguna localización. // * `Text` - Con una cadena literal que se devolverá tal cual. // * `Translate` - Con la clave a resolver en el `Locales` indicado. -#[derive(AutoDefault)] +#[derive(AutoDefault, Clone, Debug)] enum L10nOp { #[default] None, @@ -322,7 +322,7 @@ enum L10nOp { /// // Traducción con clave, conjunto de traducciones e identificador de idioma a usar. /// let bye = L10n::t("goodbye", &LOCALES_CUSTOM).using(LangMatch::langid_or_default("it")); /// ``` -#[derive(AutoDefault)] +#[derive(AutoDefault, Clone)] pub struct L10n { op: L10nOp, #[default(&LOCALES_PAGETOP)] @@ -411,6 +411,17 @@ impl L10n { } } +impl fmt::Debug for L10n { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("L10n") + .field("op", &self.op) + .field("args", &self.args) + // No se puede mostrar `locales`. Se representa con un texto fijo. + .field("locales", &"") + .finish() + } +} + impl fmt::Display for L10n { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let content = match &self.op {