Compare commits
No commits in common. "7ebd7b0e4972c4a907c34ef2ffcbe97708fd0d13" and "75eec8bebc996d1e9fb8b256bee1a3ddd58f7977" have entirely different histories.
7ebd7b0e49
...
75eec8bebc
18 changed files with 317 additions and 466 deletions
|
@ -6,7 +6,7 @@ use crate::base::action::FnActionWithComponent;
|
|||
pub struct AfterRender<C: Component> {
|
||||
f: FnActionWithComponent<C>,
|
||||
referer_type_id: Option<UniqueId>,
|
||||
referer_id: AttrId,
|
||||
referer_id: OptionId,
|
||||
weight: Weight,
|
||||
}
|
||||
|
||||
|
@ -34,7 +34,7 @@ impl<C: Component> AfterRender<C> {
|
|||
AfterRender {
|
||||
f,
|
||||
referer_type_id: Some(UniqueId::of::<C>()),
|
||||
referer_id: AttrId::default(),
|
||||
referer_id: OptionId::default(),
|
||||
weight: 0,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ use crate::base::action::FnActionWithComponent;
|
|||
pub struct BeforeRender<C: Component> {
|
||||
f: FnActionWithComponent<C>,
|
||||
referer_type_id: Option<UniqueId>,
|
||||
referer_id: AttrId,
|
||||
referer_id: OptionId,
|
||||
weight: Weight,
|
||||
}
|
||||
|
||||
|
@ -34,7 +34,7 @@ impl<C: Component> BeforeRender<C> {
|
|||
BeforeRender {
|
||||
f,
|
||||
referer_type_id: Some(UniqueId::of::<C>()),
|
||||
referer_id: AttrId::default(),
|
||||
referer_id: OptionId::default(),
|
||||
weight: 0,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ pub type FnIsRenderable<C> = fn(component: &C, cx: &Context) -> bool;
|
|||
pub struct IsRenderable<C: Component> {
|
||||
f: FnIsRenderable<C>,
|
||||
referer_type_id: Option<UniqueId>,
|
||||
referer_id: AttrId,
|
||||
referer_id: OptionId,
|
||||
weight: Weight,
|
||||
}
|
||||
|
||||
|
@ -39,7 +39,7 @@ impl<C: Component> IsRenderable<C> {
|
|||
IsRenderable {
|
||||
f,
|
||||
referer_type_id: Some(UniqueId::of::<C>()),
|
||||
referer_id: AttrId::default(),
|
||||
referer_id: OptionId::default(),
|
||||
weight: 0,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,3 @@ mod children;
|
|||
pub use children::Children;
|
||||
pub use children::{Child, ChildOp};
|
||||
pub use children::{Typed, TypedOp};
|
||||
|
||||
mod slot;
|
||||
pub use slot::TypedSlot;
|
||||
|
|
|
@ -9,13 +9,13 @@ use std::vec::IntoIter;
|
|||
|
||||
/// Representa un componente encapsulado de forma segura y compartida.
|
||||
///
|
||||
/// Esta estructura permite manipular y renderizar un componente que implemente [`Component`], y
|
||||
/// habilita acceso concurrente mediante [`Arc<RwLock<_>>`].
|
||||
/// Esta estructura permite manipular y renderizar cualquier tipo que implemente [`Component`],
|
||||
/// garantizando acceso concurrente a través de [`Arc<RwLock<_>>`].
|
||||
#[derive(Clone)]
|
||||
pub struct Child(Arc<RwLock<dyn Component>>);
|
||||
|
||||
impl Child {
|
||||
/// Crea un nuevo `Child` a partir de un componente.
|
||||
/// Crea un nuevo [`Child`] a partir de un componente.
|
||||
pub fn with(component: impl Component) -> Self {
|
||||
Child(Arc::new(RwLock::new(component)))
|
||||
}
|
||||
|
@ -46,8 +46,7 @@ impl Child {
|
|||
|
||||
/// Variante tipada de [`Child`] para evitar conversiones durante el uso.
|
||||
///
|
||||
/// Esta estructura permite manipular y renderizar un componente concreto que implemente
|
||||
/// [`Component`], y habilita acceso concurrente mediante [`Arc<RwLock<_>>`].
|
||||
/// Facilita el acceso a componentes del mismo tipo sin necesidad de hacer `downcast`.
|
||||
pub struct Typed<C: Component>(Arc<RwLock<C>>);
|
||||
|
||||
impl<C: Component> Clone for Typed<C> {
|
||||
|
@ -57,7 +56,7 @@ impl<C: Component> Clone for Typed<C> {
|
|||
}
|
||||
|
||||
impl<C: Component> Typed<C> {
|
||||
/// Crea un nuevo `Typed` a partir de un componente.
|
||||
/// Crea un nuevo [`Typed`] a partir de un componente.
|
||||
pub fn with(component: C) -> Self {
|
||||
Typed(Arc::new(RwLock::new(component)))
|
||||
}
|
||||
|
@ -285,7 +284,7 @@ impl IntoIterator for Children {
|
|||
///
|
||||
/// # Ejemplo de uso:
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// ```rust#ignore
|
||||
/// let children = Children::new().with(child1).with(child2);
|
||||
/// for child in children {
|
||||
/// println!("{:?}", child.id());
|
||||
|
@ -304,7 +303,7 @@ impl<'a> IntoIterator for &'a Children {
|
|||
///
|
||||
/// # Ejemplo de uso:
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// ```rust#ignore
|
||||
/// let children = Children::new().with(child1).with(child2);
|
||||
/// for child in &children {
|
||||
/// println!("{:?}", child.id());
|
||||
|
@ -323,7 +322,7 @@ impl<'a> IntoIterator for &'a mut Children {
|
|||
///
|
||||
/// # Ejemplo de uso:
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// ```rust#ignore
|
||||
/// let mut children = Children::new().with(child1).with(child2);
|
||||
/// for child in &mut children {
|
||||
/// child.render(&mut context);
|
||||
|
|
|
@ -1,64 +0,0 @@
|
|||
use crate::builder_fn;
|
||||
use crate::core::component::{Component, Typed};
|
||||
use crate::html::{html, Context, Markup};
|
||||
|
||||
/// Contenedor para un componente [`Typed`] opcional.
|
||||
///
|
||||
/// Un `TypedSlot` actúa como un contenedor dentro de otro componente para incluir o no un
|
||||
/// subcomponente. Internamente encapsula `Option<Typed<C>>`, pero proporciona una API más sencilla
|
||||
/// para construir estructuras jerárquicas.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use pagetop::prelude::*;
|
||||
///
|
||||
/// let comp = MyComponent::new();
|
||||
/// let opt = TypedSlot::new(comp);
|
||||
/// assert!(opt.get().is_some());
|
||||
/// ```
|
||||
pub struct TypedSlot<C: Component>(Option<Typed<C>>);
|
||||
|
||||
impl<C: Component> Default for TypedSlot<C> {
|
||||
fn default() -> Self {
|
||||
TypedSlot(None)
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: Component> TypedSlot<C> {
|
||||
/// Crea un nuevo [`TypedSlot`].
|
||||
///
|
||||
/// El componente se envuelve automáticamente en un [`Typed`] y se almacena.
|
||||
pub fn new(component: C) -> Self {
|
||||
TypedSlot(Some(Typed::with(component)))
|
||||
}
|
||||
|
||||
// TypedSlot BUILDER *********************************************************************
|
||||
|
||||
/// Establece un componente nuevo, o lo vacía.
|
||||
///
|
||||
/// Si se proporciona `Some(component)`, se guarda en [`Typed`]; y si es `None`, se limpia.
|
||||
#[builder_fn]
|
||||
pub fn with_value(mut self, component: Option<C>) -> Self {
|
||||
self.0 = component.map(Typed::with);
|
||||
self
|
||||
}
|
||||
|
||||
// TypedSlot GETTERS *********************************************************************
|
||||
|
||||
/// Devuelve un clon (incrementa el contador `Arc`) de [`Typed<C>`], si existe.
|
||||
pub fn get(&self) -> Option<Typed<C>> {
|
||||
self.0.clone()
|
||||
}
|
||||
|
||||
// TypedSlot RENDER ************************************************************************
|
||||
|
||||
/// Renderiza el componente, si existe.
|
||||
pub fn render(&self, cx: &mut Context) -> Markup {
|
||||
if let Some(component) = &self.0 {
|
||||
component.render(cx)
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
}
|
90
src/html.rs
90
src/html.rs
|
@ -3,82 +3,52 @@
|
|||
mod maud;
|
||||
pub use maud::{display, html, html_private, Escaper, Markup, PreEscaped, Render, DOCTYPE};
|
||||
|
||||
// HTML DOCUMENT ASSETS ****************************************************************************
|
||||
|
||||
mod assets;
|
||||
pub use assets::favicon::Favicon;
|
||||
pub use assets::javascript::JavaScript;
|
||||
pub use assets::stylesheet::{StyleSheet, TargetMedia};
|
||||
pub(crate) use assets::Assets;
|
||||
|
||||
// HTML DOCUMENT CONTEXT ***************************************************************************
|
||||
|
||||
mod context;
|
||||
pub use context::{AssetsOp, Context, ErrorParam};
|
||||
|
||||
// HTML ATTRIBUTES *********************************************************************************
|
||||
mod opt_id;
|
||||
pub use opt_id::OptionId;
|
||||
|
||||
mod attr_id;
|
||||
pub use attr_id::AttrId;
|
||||
/// **Obsoleto desde la versión 0.4.0**: usar [`AttrId`] en su lugar.
|
||||
#[deprecated(since = "0.4.0", note = "Use `AttrId` instead")]
|
||||
pub type OptionId = AttrId;
|
||||
mod opt_name;
|
||||
pub use opt_name::OptionName;
|
||||
|
||||
mod attr_name;
|
||||
pub use attr_name::AttrName;
|
||||
/// **Obsoleto desde la versión 0.4.0**: usar [`AttrName`] en su lugar.
|
||||
#[deprecated(since = "0.4.0", note = "Use `AttrName` instead")]
|
||||
pub type OptionName = AttrName;
|
||||
mod opt_string;
|
||||
pub use opt_string::OptionString;
|
||||
|
||||
mod attr_value;
|
||||
pub use attr_value::AttrValue;
|
||||
/// **Obsoleto desde la versión 0.4.0**: usar [`AttrValue`] en su lugar.
|
||||
#[deprecated(since = "0.4.0", note = "Use `AttrValue` instead")]
|
||||
pub type OptionString = AttrValue;
|
||||
mod opt_translated;
|
||||
pub use opt_translated::OptionTranslated;
|
||||
|
||||
mod attr_l10n;
|
||||
pub use attr_l10n::AttrL10n;
|
||||
/// **Obsoleto desde la versión 0.4.0**: usar [`AttrL10n`] en su lugar.
|
||||
#[deprecated(since = "0.4.0", note = "Use `AttrL10n` instead")]
|
||||
pub type OptionTranslated = AttrL10n;
|
||||
mod opt_classes;
|
||||
pub use opt_classes::{ClassesOp, OptionClasses};
|
||||
|
||||
mod attr_classes;
|
||||
pub use attr_classes::{AttrClasses, ClassesOp};
|
||||
/// **Obsoleto desde la versión 0.4.0**: usar [`AttrClasses`] en su lugar.
|
||||
#[deprecated(since = "0.4.0", note = "Use `AttrClasses` instead")]
|
||||
pub type OptionClasses = AttrClasses;
|
||||
mod opt_component;
|
||||
pub use opt_component::OptionComponent;
|
||||
|
||||
use crate::{core, AutoDefault};
|
||||
|
||||
/// **Obsoleto desde la versión 0.4.0**: usar [`TypedSlot`](crate::core::component::TypedSlot) en su
|
||||
/// lugar.
|
||||
#[deprecated(
|
||||
since = "0.4.0",
|
||||
note = "Use `pagetop::core::component::TypedSlot` instead"
|
||||
)]
|
||||
#[allow(type_alias_bounds)]
|
||||
pub type OptionComponent<C: core::component::Component> = core::component::TypedSlot<C>;
|
||||
use crate::AutoDefault;
|
||||
|
||||
/// Prepara contenido HTML para su conversión a [`Markup`].
|
||||
///
|
||||
/// Este tipo encapsula distintos orígenes de contenido HTML (texto plano, HTML sin escapar o
|
||||
/// fragmentos ya procesados) para renderizarlos de forma homogénea en plantillas, sin interferir
|
||||
/// con el uso estándar de [`Markup`].
|
||||
/// Este tipo encapsula distintos orígenes de contenido HTML (texto plano, HTML escapado o marcado
|
||||
/// ya procesado) para renderizar de forma homogénea en plantillas sin interferir con el uso
|
||||
/// estándar de [`Markup`].
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust
|
||||
/// use pagetop::prelude::*;
|
||||
///
|
||||
/// // Texto normal, se escapa automáticamente para evitar inyección de HTML.
|
||||
/// let fragment = PrepareMarkup::Escaped(String::from("Hola <b>mundo</b>"));
|
||||
/// let fragment = PrepareMarkup::Text(String::from("Hola <b>mundo</b>"));
|
||||
/// assert_eq!(fragment.render().into_string(), "Hola <b>mundo</b>");
|
||||
///
|
||||
/// // HTML literal, se inserta directamente, sin escapado adicional.
|
||||
/// let raw_html = PrepareMarkup::Raw(String::from("<b>negrita</b>"));
|
||||
/// let raw_html = PrepareMarkup::Escaped(String::from("<b>negrita</b>"));
|
||||
/// assert_eq!(raw_html.render().into_string(), "<b>negrita</b>");
|
||||
///
|
||||
/// // Fragmento ya preparado con la macro `html!`.
|
||||
/// let prepared = PrepareMarkup::With(html! {
|
||||
/// h2 { "Título de ejemplo" }
|
||||
/// p { "Este es un párrafo con contenido dinámico." }
|
||||
|
@ -90,22 +60,14 @@ pub type OptionComponent<C: core::component::Component> = core::component::Typed
|
|||
/// ```
|
||||
#[derive(AutoDefault)]
|
||||
pub enum PrepareMarkup {
|
||||
/// No se genera contenido HTML (equivale a `html! {}`).
|
||||
/// No se genera contenido HTML (devuelve `html! {}`).
|
||||
#[default]
|
||||
None,
|
||||
/// Texto plano que se **escapará automáticamente** para que no sea interpretado como HTML.
|
||||
///
|
||||
/// Úsalo con textos que provengan de usuarios u otras fuentes externas para garantizar la
|
||||
/// seguridad contra inyección de código.
|
||||
/// Texto estático que se escapará automáticamente para no ser interpretado como HTML.
|
||||
Text(String),
|
||||
/// Contenido sin escapado adicional, útil para HTML generado externamente.
|
||||
Escaped(String),
|
||||
/// HTML literal que se inserta **sin escapado adicional**.
|
||||
///
|
||||
/// Úsalo únicamente para contenido generado de forma confiable o controlada, ya que cualquier
|
||||
/// etiqueta o script incluido será renderizado directamente en el documento.
|
||||
Raw(String),
|
||||
/// Fragmento HTML ya preparado como [`Markup`], listo para insertarse directamente.
|
||||
///
|
||||
/// Normalmente proviene de expresiones `html! { ... }`.
|
||||
With(Markup),
|
||||
}
|
||||
|
||||
|
@ -114,8 +76,8 @@ impl PrepareMarkup {
|
|||
pub fn is_empty(&self) -> bool {
|
||||
match self {
|
||||
PrepareMarkup::None => true,
|
||||
PrepareMarkup::Escaped(text) => text.is_empty(),
|
||||
PrepareMarkup::Raw(string) => string.is_empty(),
|
||||
PrepareMarkup::Text(text) => text.is_empty(),
|
||||
PrepareMarkup::Escaped(string) => string.is_empty(),
|
||||
PrepareMarkup::With(markup) => markup.is_empty(),
|
||||
}
|
||||
}
|
||||
|
@ -126,8 +88,8 @@ impl Render for PrepareMarkup {
|
|||
fn render(&self) -> Markup {
|
||||
match self {
|
||||
PrepareMarkup::None => html! {},
|
||||
PrepareMarkup::Escaped(text) => html! { (text) },
|
||||
PrepareMarkup::Raw(string) => html! { (PreEscaped(string)) },
|
||||
PrepareMarkup::Text(text) => html! { (text) },
|
||||
PrepareMarkup::Escaped(string) => html! { (PreEscaped(string)) },
|
||||
PrepareMarkup::With(markup) => html! { (markup) },
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
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:
|
||||
///
|
||||
/// - Se eliminan los espacios al principio y al final.
|
||||
/// - Se convierte a minúsculas.
|
||||
/// - 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 = AttrId::new(" main Section ");
|
||||
/// assert_eq!(id.as_str(), Some("main_section"));
|
||||
///
|
||||
/// let empty = AttrId::default();
|
||||
/// assert_eq!(empty.get(), None);
|
||||
/// ```
|
||||
#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)]
|
||||
pub struct AttrId(Option<String>);
|
||||
|
||||
impl AttrId {
|
||||
/// Crea un nuevo `AttrId` normalizando el valor.
|
||||
pub fn new(value: impl AsRef<str>) -> Self {
|
||||
AttrId::default().with_value(value)
|
||||
}
|
||||
|
||||
// AttrId BUILDER ******************************************************************************
|
||||
|
||||
/// Establece un identificador nuevo normalizando el valor.
|
||||
#[builder_fn]
|
||||
pub fn with_value(mut self, value: impl AsRef<str>) -> Self {
|
||||
let value = value.as_ref().trim().to_ascii_lowercase().replace(' ', "_");
|
||||
self.0 = if value.is_empty() { None } else { Some(value) };
|
||||
self
|
||||
}
|
||||
|
||||
// AttrId GETTERS ******************************************************************************
|
||||
|
||||
/// Devuelve el identificador normalizado, si existe.
|
||||
pub fn get(&self) -> Option<String> {
|
||||
self.0.as_ref().cloned()
|
||||
}
|
||||
|
||||
/// Devuelve el identificador normalizado (sin clonar), si existe.
|
||||
pub fn as_str(&self) -> Option<&str> {
|
||||
self.0.as_deref()
|
||||
}
|
||||
|
||||
/// Devuelve el identificador normalizado (propiedad), si existe.
|
||||
pub fn into_inner(self) -> Option<String> {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// `true` si no hay valor.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_none()
|
||||
}
|
||||
}
|
|
@ -1,63 +0,0 @@
|
|||
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:
|
||||
///
|
||||
/// - Se eliminan los espacios al principio y al final.
|
||||
/// - Se convierte a minúsculas.
|
||||
/// - 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 = AttrName::new(" DISplay name ");
|
||||
/// assert_eq!(name.as_str(), Some("display_name"));
|
||||
///
|
||||
/// let empty = AttrName::default();
|
||||
/// assert_eq!(empty.get(), None);
|
||||
/// ```
|
||||
#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)]
|
||||
pub struct AttrName(Option<String>);
|
||||
|
||||
impl AttrName {
|
||||
/// Crea un nuevo `AttrName` normalizando el valor.
|
||||
pub fn new(value: impl AsRef<str>) -> Self {
|
||||
AttrName::default().with_value(value)
|
||||
}
|
||||
|
||||
// AttrName BUILDER ****************************************************************************
|
||||
|
||||
/// Establece un nombre nuevo normalizando el valor.
|
||||
#[builder_fn]
|
||||
pub fn with_value(mut self, value: impl AsRef<str>) -> Self {
|
||||
let value = value.as_ref().trim().to_ascii_lowercase().replace(' ', "_");
|
||||
self.0 = if value.is_empty() { None } else { Some(value) };
|
||||
self
|
||||
}
|
||||
|
||||
// AttrName GETTERS ****************************************************************************
|
||||
|
||||
/// Devuelve el nombre normalizado, si existe.
|
||||
pub fn get(&self) -> Option<String> {
|
||||
self.0.as_ref().cloned()
|
||||
}
|
||||
|
||||
/// Devuelve el nombre normalizado (sin clonar), si existe.
|
||||
pub fn as_str(&self) -> Option<&str> {
|
||||
self.0.as_deref()
|
||||
}
|
||||
|
||||
/// Devuelve el nombre normalizado (propiedad), si existe.
|
||||
pub fn into_inner(self) -> Option<String> {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// `true` si no hay valor.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_none()
|
||||
}
|
||||
}
|
|
@ -1,65 +0,0 @@
|
|||
use crate::{builder_fn, AutoDefault};
|
||||
|
||||
/// Cadena normalizada para renderizar en atributos HTML.
|
||||
///
|
||||
/// Este tipo encapsula `Option<String>` garantizando un valor normalizado para su uso:
|
||||
///
|
||||
/// - 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 = AttrValue::new(" a new string ");
|
||||
/// assert_eq!(s.as_str(), Some("a new string"));
|
||||
///
|
||||
/// let empty = AttrValue::default();
|
||||
/// assert_eq!(empty.get(), None);
|
||||
/// ```
|
||||
#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)]
|
||||
pub struct AttrValue(Option<String>);
|
||||
|
||||
impl AttrValue {
|
||||
/// Crea un nuevo `AttrValue` normalizando el valor.
|
||||
pub fn new(value: impl AsRef<str>) -> Self {
|
||||
AttrValue::default().with_value(value)
|
||||
}
|
||||
|
||||
// AttrValue BUILDER ***************************************************************************
|
||||
|
||||
/// Establece una cadena nueva normalizando el valor.
|
||||
#[builder_fn]
|
||||
pub fn with_value(mut self, value: impl AsRef<str>) -> Self {
|
||||
let value = value.as_ref().trim();
|
||||
self.0 = if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value.to_owned())
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
// AttrValue GETTERS ***************************************************************************
|
||||
|
||||
/// Devuelve la cadena normalizada, si existe.
|
||||
pub fn get(&self) -> Option<String> {
|
||||
self.0.as_ref().cloned()
|
||||
}
|
||||
|
||||
/// Devuelve la cadena normalizada (sin clonar), si existe.
|
||||
pub fn as_str(&self) -> Option<&str> {
|
||||
self.0.as_deref()
|
||||
}
|
||||
|
||||
/// Devuelve la cadena normalizada (propiedad), si existe.
|
||||
pub fn into_inner(self) -> Option<String> {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// `true` si no hay valor.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_none()
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
use crate::{builder_fn, AutoDefault};
|
||||
|
||||
/// Operaciones disponibles sobre la lista de clases en [`AttrClasses`].
|
||||
/// Operaciones disponibles sobre la lista de clases en [`OptionClasses`].
|
||||
pub enum ClassesOp {
|
||||
/// Añade al final (si no existe).
|
||||
Add,
|
||||
|
@ -33,7 +33,7 @@ pub enum ClassesOp {
|
|||
/// ```rust
|
||||
/// use pagetop::prelude::*;
|
||||
///
|
||||
/// let classes = AttrClasses::new("Btn btn-primary")
|
||||
/// let classes = OptionClasses::new("Btn btn-primary")
|
||||
/// .with_value(ClassesOp::Add, "Active")
|
||||
/// .with_value(ClassesOp::Remove, "btn-primary");
|
||||
///
|
||||
|
@ -41,14 +41,14 @@ pub enum ClassesOp {
|
|||
/// assert!(classes.contains("active"));
|
||||
/// ```
|
||||
#[derive(AutoDefault, Clone, Debug)]
|
||||
pub struct AttrClasses(Vec<String>);
|
||||
pub struct OptionClasses(Vec<String>);
|
||||
|
||||
impl AttrClasses {
|
||||
impl OptionClasses {
|
||||
pub fn new(classes: impl AsRef<str>) -> Self {
|
||||
AttrClasses::default().with_value(ClassesOp::Prepend, classes)
|
||||
OptionClasses::default().with_value(ClassesOp::Prepend, classes)
|
||||
}
|
||||
|
||||
// AttrClasses BUILDER *************************************************************************
|
||||
// OptionClasses BUILDER ***********************************************************************
|
||||
|
||||
#[builder_fn]
|
||||
pub fn with_value(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
|
||||
|
@ -114,7 +114,7 @@ impl AttrClasses {
|
|||
}
|
||||
}
|
||||
|
||||
// AttrClasses GETTERS *************************************************************************
|
||||
// OptionClasses GETTERS ***********************************************************************
|
||||
|
||||
/// Devuele la cadena de clases, si existe.
|
||||
pub fn get(&self) -> Option<String> {
|
68
src/html/opt_component.rs
Normal file
68
src/html/opt_component.rs
Normal file
|
@ -0,0 +1,68 @@
|
|||
use crate::builder_fn;
|
||||
use crate::core::component::{Component, Typed};
|
||||
use crate::html::{html, Context, Markup};
|
||||
|
||||
/// Contenedor de componente para incluir en otros componentes.
|
||||
///
|
||||
/// Este tipo encapsula `Option<Typed<C>>` para incluir un componente de manera segura en otros
|
||||
/// componentes, útil para representar estructuras complejas.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use pagetop::prelude::*;
|
||||
///
|
||||
/// let comp = MyComponent::new();
|
||||
/// let opt = OptionComponent::new(comp);
|
||||
/// assert!(opt.get().is_some());
|
||||
/// ```
|
||||
pub struct OptionComponent<C: Component>(Option<Typed<C>>);
|
||||
|
||||
impl<C: Component> Default for OptionComponent<C> {
|
||||
fn default() -> Self {
|
||||
OptionComponent(None)
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: Component> OptionComponent<C> {
|
||||
/// Crea un nuevo [`OptionComponent`].
|
||||
///
|
||||
/// El componente se envuelve automáticamente en un [`Typed`] y se almacena.
|
||||
pub fn new(component: C) -> Self {
|
||||
OptionComponent::default().with_value(Some(component))
|
||||
}
|
||||
|
||||
// OptionComponent BUILDER *********************************************************************
|
||||
|
||||
/// Establece un componente nuevo, o lo vacía.
|
||||
///
|
||||
/// Si se proporciona `Some(component)`, se guarda en [`Typed`]; y si es `None`, se limpia.
|
||||
#[builder_fn]
|
||||
pub fn with_value(mut self, component: Option<C>) -> Self {
|
||||
if let Some(component) = component {
|
||||
self.0 = Some(Typed::with(component));
|
||||
} else {
|
||||
self.0 = None;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
// OptionComponent GETTERS *********************************************************************
|
||||
|
||||
/// Devuelve el componente, si existe.
|
||||
pub fn get(&self) -> Option<Typed<C>> {
|
||||
if let Some(value) = &self.0 {
|
||||
return Some(value.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Renderiza el componente, si existe.
|
||||
pub fn render(&self, cx: &mut Context) -> Markup {
|
||||
if let Some(component) = &self.0 {
|
||||
component.render(cx)
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
}
|
59
src/html/opt_id.rs
Normal file
59
src/html/opt_id.rs
Normal file
|
@ -0,0 +1,59 @@
|
|||
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 convierte a minúsculas.
|
||||
/// - 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().to_ascii_lowercase().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
|
||||
}
|
||||
}
|
59
src/html/opt_name.rs
Normal file
59
src/html/opt_name.rs
Normal file
|
@ -0,0 +1,59 @@
|
|||
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 convierte a minúsculas.
|
||||
/// - 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().to_ascii_lowercase().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
|
||||
}
|
||||
}
|
57
src/html/opt_string.rs
Normal file
57
src/html/opt_string.rs
Normal file
|
@ -0,0 +1,57 @@
|
|||
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
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@ use crate::html::Markup;
|
|||
use crate::locale::{L10n, LangId};
|
||||
use crate::{builder_fn, AutoDefault};
|
||||
|
||||
/// Texto para [traducir](crate::locale) en atributos HTML.
|
||||
/// Cadena para traducir al renderizar ([`locale`](crate::locale)).
|
||||
///
|
||||
/// Encapsula un tipo [`L10n`] para manejar traducciones de forma segura.
|
||||
///
|
||||
|
@ -12,7 +12,7 @@ use crate::{builder_fn, AutoDefault};
|
|||
/// use pagetop::prelude::*;
|
||||
///
|
||||
/// // Traducción por clave en las locales por defecto de PageTop.
|
||||
/// let hello = AttrL10n::new(L10n::l("test-hello-world"));
|
||||
/// let hello = OptionTranslated::new(L10n::l("test-hello-world"));
|
||||
///
|
||||
/// // Español disponible.
|
||||
/// assert_eq!(
|
||||
|
@ -31,15 +31,15 @@ use crate::{builder_fn, AutoDefault};
|
|||
/// assert_eq!(markup.into_string(), "¡Hola mundo!");
|
||||
/// ```
|
||||
#[derive(AutoDefault, Clone, Debug)]
|
||||
pub struct AttrL10n(L10n);
|
||||
pub struct OptionTranslated(L10n);
|
||||
|
||||
impl AttrL10n {
|
||||
/// Crea una nueva instancia `AttrL10n`.
|
||||
impl OptionTranslated {
|
||||
/// Crea una nueva instancia [`OptionTranslated`].
|
||||
pub fn new(value: L10n) -> Self {
|
||||
AttrL10n(value)
|
||||
OptionTranslated(value)
|
||||
}
|
||||
|
||||
// AttrL10n BUILDER ****************************************************************************
|
||||
// OptionTranslated BUILDER ********************************************************************
|
||||
|
||||
/// Establece una traducción nueva.
|
||||
#[builder_fn]
|
||||
|
@ -48,7 +48,7 @@ impl AttrL10n {
|
|||
self
|
||||
}
|
||||
|
||||
// AttrL10n GETTERS ****************************************************************************
|
||||
// OptionTranslated GETTERS ********************************************************************
|
||||
|
||||
/// Devuelve la traducción para `language`, si existe.
|
||||
pub fn using(&self, language: &impl LangId) -> Option<String> {
|
|
@ -7,10 +7,8 @@ use crate::base::action;
|
|||
use crate::builder_fn;
|
||||
use crate::core::component::{Child, ChildOp, Component};
|
||||
use crate::core::theme::{ChildrenInRegions, ThemeRef, REGION_CONTENT};
|
||||
use crate::html::{html, Markup, DOCTYPE};
|
||||
use crate::html::{AssetsOp, Context};
|
||||
use crate::html::{AttrClasses, ClassesOp};
|
||||
use crate::html::{AttrId, AttrL10n};
|
||||
use crate::html::{html, AssetsOp, Context, Markup, DOCTYPE};
|
||||
use crate::html::{ClassesOp, OptionClasses, OptionId, OptionTranslated};
|
||||
use crate::locale::{CharacterDirection, L10n, LangId, LanguageIdentifier};
|
||||
use crate::service::HttpRequest;
|
||||
|
||||
|
@ -21,13 +19,13 @@ use crate::service::HttpRequest;
|
|||
/// renderizado.
|
||||
#[rustfmt::skip]
|
||||
pub struct Page {
|
||||
title : AttrL10n,
|
||||
description : AttrL10n,
|
||||
title : OptionTranslated,
|
||||
description : OptionTranslated,
|
||||
metadata : Vec<(&'static str, &'static str)>,
|
||||
properties : Vec<(&'static str, &'static str)>,
|
||||
context : Context,
|
||||
body_id : AttrId,
|
||||
body_classes: AttrClasses,
|
||||
body_id : OptionId,
|
||||
body_classes: OptionClasses,
|
||||
regions : ChildrenInRegions,
|
||||
}
|
||||
|
||||
|
@ -39,13 +37,13 @@ impl Page {
|
|||
#[rustfmt::skip]
|
||||
pub fn new(request: Option<HttpRequest>) -> Self {
|
||||
Page {
|
||||
title : AttrL10n::default(),
|
||||
description : AttrL10n::default(),
|
||||
title : OptionTranslated::default(),
|
||||
description : OptionTranslated::default(),
|
||||
metadata : Vec::default(),
|
||||
properties : Vec::default(),
|
||||
context : Context::new(request),
|
||||
body_id : AttrId::default(),
|
||||
body_classes: AttrClasses::default(),
|
||||
body_id : OptionId::default(),
|
||||
body_classes: OptionClasses::default(),
|
||||
regions : ChildrenInRegions::default(),
|
||||
}
|
||||
}
|
||||
|
@ -115,7 +113,7 @@ impl Page {
|
|||
self
|
||||
}
|
||||
|
||||
/// Modifica las clases CSS del elemento `<body>` con una operación sobre [`AttrClasses`].
|
||||
/// Modifica las clases CSS del elemento `<body>` con una operación sobre [`OptionClasses`].
|
||||
#[builder_fn]
|
||||
pub fn with_body_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
|
||||
self.body_classes.alter_value(op, classes);
|
||||
|
@ -185,12 +183,12 @@ impl Page {
|
|||
}
|
||||
|
||||
/// Devuelve el identificador del elemento `<body>`.
|
||||
pub fn body_id(&self) -> &AttrId {
|
||||
pub fn body_id(&self) -> &OptionId {
|
||||
&self.body_id
|
||||
}
|
||||
|
||||
/// Devuelve las clases CSS del elemento `<body>`.
|
||||
pub fn body_classes(&self) -> &AttrClasses {
|
||||
pub fn body_classes(&self) -> &OptionClasses {
|
||||
&self.body_classes
|
||||
}
|
||||
|
||||
|
|
105
tests/html.rs
105
tests/html.rs
|
@ -1,110 +1,17 @@
|
|||
use pagetop::prelude::*;
|
||||
|
||||
#[pagetop::test]
|
||||
async fn prepare_markup_render_none_is_empty_string() {
|
||||
assert_eq!(render(&PrepareMarkup::None), "");
|
||||
}
|
||||
async fn prepare_markup_is_empty() {
|
||||
let _app = service::test::init_service(Application::new().test()).await;
|
||||
|
||||
#[pagetop::test]
|
||||
async fn prepare_markup_render_escaped_escapes_html_and_ampersands() {
|
||||
let pm = PrepareMarkup::Escaped(String::from("<b>& \" ' </b>"));
|
||||
assert_eq!(render(&pm), "<b>& " ' </b>");
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn prepare_markup_render_raw_is_inserted_verbatim() {
|
||||
let pm = PrepareMarkup::Raw(String::from("<b>bold</b><script>1<2</script>"));
|
||||
assert_eq!(render(&pm), "<b>bold</b><script>1<2</script>");
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn prepare_markup_render_with_keeps_structure() {
|
||||
let pm = PrepareMarkup::With(html! {
|
||||
h2 { "Sample title" }
|
||||
p { "This is a paragraph." }
|
||||
});
|
||||
assert_eq!(
|
||||
render(&pm),
|
||||
"<h2>Sample title</h2><p>This is a paragraph.</p>"
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn prepare_markup_does_not_double_escape_when_wrapped_in_html_macro() {
|
||||
// Escaped: dentro de `html!` no debe volver a escaparse.
|
||||
let escaped = PrepareMarkup::Escaped("<i>x</i>".into());
|
||||
let wrapped_escaped = html! { div { (escaped) } };
|
||||
assert_eq!(
|
||||
wrapped_escaped.into_string(),
|
||||
"<div><i>x</i></div>"
|
||||
);
|
||||
|
||||
// Raw: tampoco debe escaparse al integrarlo.
|
||||
let raw = PrepareMarkup::Raw("<i>x</i>".into());
|
||||
let wrapped_raw = html! { div { (raw) } };
|
||||
assert_eq!(wrapped_raw.into_string(), "<div><i>x</i></div>");
|
||||
|
||||
// With: debe incrustar el Markup tal cual.
|
||||
let with = PrepareMarkup::With(html! { span.title { "ok" } });
|
||||
let wrapped_with = html! { div { (with) } };
|
||||
assert_eq!(
|
||||
wrapped_with.into_string(),
|
||||
"<div><span class=\"title\">ok</span></div>"
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn prepare_markup_unicode_is_preserved() {
|
||||
// Texto con acentos y emojis debe conservarse (salvo el escape HTML de signos).
|
||||
let esc = PrepareMarkup::Escaped("Hello, tomorrow coffee ☕ & donuts!".into());
|
||||
assert_eq!(render(&esc), "Hello, tomorrow coffee ☕ & donuts!");
|
||||
|
||||
// Raw debe pasar íntegro.
|
||||
let raw = PrepareMarkup::Raw("Title — section © 2025".into());
|
||||
assert_eq!(render(&raw), "Title — section © 2025");
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn prepare_markup_is_empty_semantics() {
|
||||
assert!(PrepareMarkup::None.is_empty());
|
||||
|
||||
assert!(PrepareMarkup::Escaped(String::new()).is_empty());
|
||||
assert!(PrepareMarkup::Escaped(String::from("")).is_empty());
|
||||
assert!(!PrepareMarkup::Escaped(String::from("x")).is_empty());
|
||||
assert!(PrepareMarkup::Text(String::from("")).is_empty());
|
||||
assert!(!PrepareMarkup::Text(String::from("x")).is_empty());
|
||||
|
||||
assert!(PrepareMarkup::Raw(String::new()).is_empty());
|
||||
assert!(PrepareMarkup::Raw(String::from("")).is_empty());
|
||||
assert!(!PrepareMarkup::Raw("a".into()).is_empty());
|
||||
assert!(PrepareMarkup::Escaped(String::new()).is_empty());
|
||||
assert!(!PrepareMarkup::Escaped("a".into()).is_empty());
|
||||
|
||||
assert!(PrepareMarkup::With(html! {}).is_empty());
|
||||
assert!(!PrepareMarkup::With(html! { span { "!" } }).is_empty());
|
||||
|
||||
// Ojo: espacios NO deberían considerarse vacíos (comportamiento actual).
|
||||
assert!(!PrepareMarkup::Escaped(" ".into()).is_empty());
|
||||
assert!(!PrepareMarkup::Raw(" ".into()).is_empty());
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn prepare_markup_equivalence_between_render_and_inline_in_html_macro() {
|
||||
let cases = [
|
||||
PrepareMarkup::None,
|
||||
PrepareMarkup::Escaped("<b>x</b>".into()),
|
||||
PrepareMarkup::Raw("<b>x</b>".into()),
|
||||
PrepareMarkup::With(html! { b { "x" } }),
|
||||
];
|
||||
|
||||
for pm in cases {
|
||||
let rendered = render(&pm);
|
||||
let in_macro = html! { (pm) }.into_string();
|
||||
assert_eq!(
|
||||
rendered, in_macro,
|
||||
"The output of Render and (pm) inside html! must match"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// HELPERS *****************************************************************************************
|
||||
|
||||
fn render(x: &impl Render) -> String {
|
||||
x.render().into_string()
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue