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 {