Añade tipos para renderizar atributos HTML

This commit is contained in:
Manuel Cillero 2025-07-21 20:52:45 +02:00
parent 613ab5243c
commit f88513d67f
7 changed files with 393 additions and 2 deletions

View file

@ -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`].

130
src/html/opt_classes.rs Normal file
View file

@ -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<String>);
impl OptionClasses {
pub fn new(classes: impl AsRef<str>) -> Self {
OptionClasses::default().with_value(ClassesOp::Prepend, classes)
}
// OptionClasses BUILDER ***********************************************************************
#[builder_fn]
pub fn with_value(mut self, op: ClassesOp, classes: impl AsRef<str>) -> 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<String> {
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<str>) -> bool {
let class = class.as_ref();
self.0.iter().any(|c| c == class)
}
}

57
src/html/opt_id.rs Normal file
View file

@ -0,0 +1,57 @@
use crate::{builder_fn, AutoDefault};
/// Identificador normalizado para el atributo `id` o similar de HTML.
///
/// Este tipo encapsula `Option<String>` 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<String>);
impl OptionId {
/// Crea un nuevo [`OptionId`].
///
/// El valor se normaliza automáticamente.
pub fn new(value: impl AsRef<str>) -> 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<str>) -> 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<String> {
if let Some(value) = &self.0 {
if !value.is_empty() {
return Some(value.to_owned());
}
}
None
}
}

57
src/html/opt_name.rs Normal file
View file

@ -0,0 +1,57 @@
use crate::{builder_fn, AutoDefault};
/// Nombre normalizado para el atributo `name` o similar de HTML.
///
/// Este tipo encapsula `Option<String>` 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<String>);
impl OptionName {
/// Crea un nuevo [`OptionName`].
///
/// El valor se normaliza automáticamente.
pub fn new(value: impl AsRef<str>) -> 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<str>) -> 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<String> {
if let Some(value) = &self.0 {
if !value.is_empty() {
return Some(value.to_owned());
}
}
None
}
}

56
src/html/opt_string.rs Normal file
View file

@ -0,0 +1,56 @@
use crate::{builder_fn, AutoDefault};
/// Cadena normalizada para renderizar en atributos HTML.
///
/// Este tipo encapsula `Option<String>` 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<String>);
impl OptionString {
/// Crea un nuevo [`OptionString`].
///
/// El valor se normaliza automáticamente.
pub fn new(value: impl AsRef<str>) -> 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<str>) -> 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<String> {
if let Some(value) = &self.0 {
if !value.is_empty() {
return Some(value.to_owned());
}
}
None
}
}

View file

@ -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<String> {
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)
}
}

View file

@ -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", &"<StaticLoader>")
.finish()
}
}
impl fmt::Display for L10n {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let content = match &self.op {