🎨 Protege el uso de render en PrepareMarkup

This commit is contained in:
Manuel Cillero 2025-11-17 22:47:47 +01:00
parent 6091f451ac
commit 682ed7cc45
4 changed files with 112 additions and 90 deletions

View file

@ -104,11 +104,11 @@ pub use unit::UnitValue;
/// # use pagetop::prelude::*;
/// // Texto normal, se escapa automáticamente para evitar inyección de HTML.
/// let fragment = PrepareMarkup::Escaped("Hola <b>mundo</b>".to_string());
/// assert_eq!(fragment.render().into_string(), "Hola &lt;b&gt;mundo&lt;/b&gt;");
/// assert_eq!(fragment.into_string(), "Hola &lt;b&gt;mundo&lt;/b&gt;");
///
/// // HTML literal, se inserta directamente, sin escapado adicional.
/// let raw_html = PrepareMarkup::Raw("<b>negrita</b>".to_string());
/// assert_eq!(raw_html.render().into_string(), "<b>negrita</b>");
/// assert_eq!(raw_html.into_string(), "<b>negrita</b>");
///
/// // Fragmento ya preparado con la macro `html!`.
/// let prepared = PrepareMarkup::With(html! {
@ -116,11 +116,11 @@ pub use unit::UnitValue;
/// p { "Este es un párrafo con contenido dinámico." }
/// });
/// assert_eq!(
/// prepared.render().into_string(),
/// prepared.into_string(),
/// "<h2>Título de ejemplo</h2><p>Este es un párrafo con contenido dinámico.</p>"
/// );
/// ```
#[derive(AutoDefault)]
#[derive(AutoDefault, Clone)]
pub enum PrepareMarkup {
/// No se genera contenido HTML (equivale a `html! {}`).
#[default]
@ -152,8 +152,13 @@ impl PrepareMarkup {
}
}
/// Integra el renderizado fácilmente en la macro [`html!`].
pub fn render(&self) -> Markup {
/// Convierte el contenido en una cadena HTML renderizada. Usar sólo para pruebas o depuración.
pub fn into_string(&self) -> String {
self.render().into_string()
}
// Integra el renderizado fácilmente en la macro [`html!`].
pub(crate) fn render(&self) -> Markup {
match self {
PrepareMarkup::None => html! {},
PrepareMarkup::Escaped(text) => html! { (text) },

View file

@ -2,32 +2,28 @@ use pagetop::prelude::*;
#[pagetop::test]
async fn component_html_renders_static_markup() {
let component = Html::with(|_| {
let mut component = Html::with(|_| {
html! {
p { "Test" }
}
});
let markup = component
.prepare_component(&mut Context::new(None))
.render();
let markup = component.render(&mut Context::default());
assert_eq!(markup.0, "<p>Test</p>");
}
#[pagetop::test]
async fn component_html_renders_using_context_param() {
let mut cx = Context::new(None).with_param("username", "Alice".to_string());
let mut cx = Context::default().with_param("username", "Alice".to_string());
let component = Html::with(|cx| {
let mut component = Html::with(|cx| {
let name = cx.param::<String>("username").cloned().unwrap_or_default();
html! {
span { (name) }
}
});
let markup = component.prepare_component(&mut cx).render();
let markup = component.render(&mut cx);
assert_eq!(markup.0, "<span>Alice</span>");
}
@ -37,21 +33,15 @@ async fn component_html_allows_replacing_render_function() {
component.alter_fn(|_| html! { div { "Modified" } });
let markup = component
.prepare_component(&mut Context::new(None))
.render();
let markup = component.render(&mut Context::default());
assert_eq!(markup.0, "<div>Modified</div>");
}
#[pagetop::test]
async fn component_html_default_renders_empty_markup() {
let component = Html::default();
let markup = component
.prepare_component(&mut Context::new(None))
.render();
let mut component = Html::default();
let markup = component.render(&mut Context::default());
assert_eq!(markup.0, "");
}
@ -60,7 +50,7 @@ async fn component_html_can_access_http_method() {
let req = service::test::TestRequest::with_uri("/").to_http_request();
let mut cx = Context::new(Some(req));
let component = Html::with(|cx| {
let mut component = Html::with(|cx| {
let method = cx
.request()
.map(|r| r.method().to_string())
@ -68,7 +58,6 @@ async fn component_html_can_access_http_method() {
html! { span { (method) } }
});
let markup = component.prepare_component(&mut cx).render();
let markup = component.render(&mut cx);
assert_eq!(markup.0, "<span>GET</span>");
}

View file

@ -4,8 +4,8 @@ use pagetop::prelude::*;
async fn poweredby_default_shows_only_pagetop_recognition() {
let _app = service::test::init_service(Application::new().test()).await;
let p = PoweredBy::default();
let html = render_component(&p);
let mut p = PoweredBy::default();
let html = p.render(&mut Context::default());
// Debe mostrar el bloque de reconocimiento a PageTop.
assert!(html.as_str().contains("poweredby__pagetop"));
@ -18,8 +18,8 @@ async fn poweredby_default_shows_only_pagetop_recognition() {
async fn poweredby_new_includes_current_year_and_app_name() {
let _app = service::test::init_service(Application::new().test()).await;
let p = PoweredBy::new();
let html = render_component(&p);
let mut p = PoweredBy::new();
let html = p.render(&mut Context::default());
let year = Utc::now().format("%Y").to_string();
assert!(
@ -43,8 +43,8 @@ async fn poweredby_with_copyright_overrides_text() {
let _app = service::test::init_service(Application::new().test()).await;
let custom = "2001 © FooBar Inc.";
let p = PoweredBy::default().with_copyright(Some(custom));
let html = render_component(&p);
let mut p = PoweredBy::default().with_copyright(Some(custom));
let html = p.render(&mut Context::default());
assert!(html.as_str().contains(custom));
assert!(html.as_str().contains("poweredby__copyright"));
@ -54,8 +54,8 @@ async fn poweredby_with_copyright_overrides_text() {
async fn poweredby_with_copyright_none_hides_text() {
let _app = service::test::init_service(Application::new().test()).await;
let p = PoweredBy::new().with_copyright(None::<String>);
let html = render_component(&p);
let mut p = PoweredBy::new().with_copyright(None::<String>);
let html = p.render(&mut Context::default());
assert!(!html.as_str().contains("poweredby__copyright"));
// El reconocimiento a PageTop siempre debe aparecer.
@ -66,8 +66,8 @@ async fn poweredby_with_copyright_none_hides_text() {
async fn poweredby_link_points_to_crates_io() {
let _app = service::test::init_service(Application::new().test()).await;
let p = PoweredBy::default();
let html = render_component(&p);
let mut p = PoweredBy::default();
let html = p.render(&mut Context::default());
assert!(
html.as_str().contains("https://pagetop.cillero.es"),
@ -89,11 +89,3 @@ async fn poweredby_getter_reflects_internal_state() {
assert!(c1.contains(&Utc::now().format("%Y").to_string()));
assert!(c1.contains(&global::SETTINGS.app.name));
}
// **< HELPERS >************************************************************************************
fn render_component<C: Component>(c: &C) -> Markup {
let mut cx = Context::default();
let pm = c.prepare_component(&mut cx);
pm.render()
}

View file

@ -1,70 +1,69 @@
use pagetop::prelude::*;
#[pagetop::test]
async fn prepare_markup_render_none_is_empty_string() {
assert_eq!(PrepareMarkup::None.render().as_str(), "");
/// Componente mínimo para probar `PrepareMarkup` pasando por el ciclo real
/// de renderizado de componentes (`ComponentRender`).
#[derive(AutoDefault)]
struct TestPrepareComponent {
pm: PrepareMarkup,
}
impl Component for TestPrepareComponent {
fn new() -> Self {
Self {
pm: PrepareMarkup::None,
}
}
fn prepare_component(&self, _cx: &mut Context) -> PrepareMarkup {
self.pm.clone()
}
}
impl TestPrepareComponent {
fn render_pm(pm: PrepareMarkup) -> String {
let mut c = TestPrepareComponent { pm };
c.render(&mut Context::default()).into_string()
}
}
#[pagetop::test]
async fn prepare_markup_render_escaped_escapes_html_and_ampersands() {
async fn prepare_markup_none_is_empty_string() {
assert_eq!(PrepareMarkup::None.into_string(), "");
}
#[pagetop::test]
async fn prepare_markup_escaped_escapes_html_and_ampersands() {
let pm = PrepareMarkup::Escaped("<b>& \" ' </b>".to_string());
assert_eq!(pm.render().as_str(), "&lt;b&gt;&amp; &quot; ' &lt;/b&gt;");
assert_eq!(pm.into_string(), "&lt;b&gt;&amp; &quot; ' &lt;/b&gt;");
}
#[pagetop::test]
async fn prepare_markup_render_raw_is_inserted_verbatim() {
async fn prepare_markup_raw_is_inserted_verbatim() {
let pm = PrepareMarkup::Raw("<b>bold</b><script>1<2</script>".to_string());
assert_eq!(pm.render().as_str(), "<b>bold</b><script>1<2</script>");
assert_eq!(pm.into_string(), "<b>bold</b><script>1<2</script>");
}
#[pagetop::test]
async fn prepare_markup_render_with_keeps_structure() {
async fn prepare_markup_with_keeps_structure() {
let pm = PrepareMarkup::With(html! {
h2 { "Sample title" }
p { "This is a paragraph." }
});
assert_eq!(
pm.render().as_str(),
pm.into_string(),
"<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.render()) } };
assert_eq!(
wrapped_escaped.into_string(),
"<div>&lt;i&gt;x&lt;/i&gt;</div>"
);
// Raw: tampoco debe escaparse al integrarlo.
let raw = PrepareMarkup::Raw("<i>x</i>".into());
let wrapped_raw = html! { div { (raw.render()) } };
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.render()) } };
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!(
esc.render().as_str(),
"Hello, tomorrow coffee ☕ &amp; donuts!"
);
assert_eq!(esc.into_string(), "Hello, tomorrow coffee ☕ &amp; donuts!");
// Raw debe pasar íntegro.
let raw = PrepareMarkup::Raw("Title — section © 2025".into());
assert_eq!(raw.render().as_str(), "Title — section © 2025");
assert_eq!(raw.into_string(), "Title — section © 2025");
}
#[pagetop::test]
@ -88,7 +87,36 @@ async fn prepare_markup_is_empty_semantics() {
}
#[pagetop::test]
async fn prepare_markup_equivalence_between_render_and_inline_in_html_macro() {
async fn prepare_markup_does_not_double_escape_when_markup_is_reinjected_in_html_macro() {
let mut cx = Context::default();
// Escaped: dentro de `html!` no debe volver a escaparse.
let mut comp = TestPrepareComponent {
pm: PrepareMarkup::Escaped("<i>x</i>".into()),
};
let markup = comp.render(&mut cx); // Markup
let wrapped_escaped = html! { div { (markup) } }.into_string();
assert_eq!(wrapped_escaped, "<div>&lt;i&gt;x&lt;/i&gt;</div>");
// Raw: tampoco debe escaparse al integrarlo.
let mut comp = TestPrepareComponent {
pm: PrepareMarkup::Raw("<i>x</i>".into()),
};
let markup = comp.render(&mut cx);
let wrapped_raw = html! { div { (markup) } }.into_string();
assert_eq!(wrapped_raw, "<div><i>x</i></div>");
// With: debe incrustar el Markup tal cual.
let mut comp = TestPrepareComponent {
pm: PrepareMarkup::With(html! { span.title { "ok" } }),
};
let markup = comp.render(&mut cx);
let wrapped_with = html! { div { (markup) } }.into_string();
assert_eq!(wrapped_with, "<div><span class=\"title\">ok</span></div>");
}
#[pagetop::test]
async fn prepare_markup_equivalence_between_component_render_and_markup_reinjected_in_html_macro() {
let cases = [
PrepareMarkup::None,
PrepareMarkup::Escaped("<b>x</b>".into()),
@ -97,12 +125,20 @@ async fn prepare_markup_equivalence_between_render_and_inline_in_html_macro() {
];
for pm in cases {
let rendered = pm.render();
let in_macro = html! { (rendered) }.into_string();
// Vía 1: renderizamos y obtenemos directamente el String.
let via_component = TestPrepareComponent::render_pm(pm.clone());
// Vía 2: renderizamos, reinyectamos el Markup en `html!` y volvemos a obtener String.
let via_macro = {
let mut cx = Context::default();
let mut comp = TestPrepareComponent { pm };
let markup = comp.render(&mut cx);
html! { (markup) }.into_string()
};
assert_eq!(
rendered.as_str(),
in_macro,
"The output of Render and (pm) inside html! must match"
via_component, via_macro,
"The output of component render and (Markup) inside html! must match"
);
}
}