WIP: Simplifica gestión de regiones y plantillas en los temas #9

Draft
manuelcillero wants to merge 3 commits from improve-region-management into add-menu-component
4 changed files with 112 additions and 90 deletions
Showing only changes of commit 682ed7cc45 - Show all commits

View file

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

View file

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

View file

@ -4,8 +4,8 @@ use pagetop::prelude::*;
async fn poweredby_default_shows_only_pagetop_recognition() { async fn poweredby_default_shows_only_pagetop_recognition() {
let _app = service::test::init_service(Application::new().test()).await; let _app = service::test::init_service(Application::new().test()).await;
let p = PoweredBy::default(); let mut p = PoweredBy::default();
let html = render_component(&p); let html = p.render(&mut Context::default());
// Debe mostrar el bloque de reconocimiento a PageTop. // Debe mostrar el bloque de reconocimiento a PageTop.
assert!(html.as_str().contains("poweredby__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() { async fn poweredby_new_includes_current_year_and_app_name() {
let _app = service::test::init_service(Application::new().test()).await; let _app = service::test::init_service(Application::new().test()).await;
let p = PoweredBy::new(); let mut p = PoweredBy::new();
let html = render_component(&p); let html = p.render(&mut Context::default());
let year = Utc::now().format("%Y").to_string(); let year = Utc::now().format("%Y").to_string();
assert!( assert!(
@ -43,8 +43,8 @@ async fn poweredby_with_copyright_overrides_text() {
let _app = service::test::init_service(Application::new().test()).await; let _app = service::test::init_service(Application::new().test()).await;
let custom = "2001 © FooBar Inc."; let custom = "2001 © FooBar Inc.";
let p = PoweredBy::default().with_copyright(Some(custom)); let mut p = PoweredBy::default().with_copyright(Some(custom));
let html = render_component(&p); let html = p.render(&mut Context::default());
assert!(html.as_str().contains(custom)); assert!(html.as_str().contains(custom));
assert!(html.as_str().contains("poweredby__copyright")); 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() { async fn poweredby_with_copyright_none_hides_text() {
let _app = service::test::init_service(Application::new().test()).await; let _app = service::test::init_service(Application::new().test()).await;
let p = PoweredBy::new().with_copyright(None::<String>); let mut p = PoweredBy::new().with_copyright(None::<String>);
let html = render_component(&p); let html = p.render(&mut Context::default());
assert!(!html.as_str().contains("poweredby__copyright")); assert!(!html.as_str().contains("poweredby__copyright"));
// El reconocimiento a PageTop siempre debe aparecer. // 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() { async fn poweredby_link_points_to_crates_io() {
let _app = service::test::init_service(Application::new().test()).await; let _app = service::test::init_service(Application::new().test()).await;
let p = PoweredBy::default(); let mut p = PoweredBy::default();
let html = render_component(&p); let html = p.render(&mut Context::default());
assert!( assert!(
html.as_str().contains("https://pagetop.cillero.es"), 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(&Utc::now().format("%Y").to_string()));
assert!(c1.contains(&global::SETTINGS.app.name)); 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::*; use pagetop::prelude::*;
#[pagetop::test] /// Componente mínimo para probar `PrepareMarkup` pasando por el ciclo real
async fn prepare_markup_render_none_is_empty_string() { /// de renderizado de componentes (`ComponentRender`).
assert_eq!(PrepareMarkup::None.render().as_str(), ""); #[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] #[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()); 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] #[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()); 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] #[pagetop::test]
async fn prepare_markup_render_with_keeps_structure() { async fn prepare_markup_with_keeps_structure() {
let pm = PrepareMarkup::With(html! { let pm = PrepareMarkup::With(html! {
h2 { "Sample title" } h2 { "Sample title" }
p { "This is a paragraph." } p { "This is a paragraph." }
}); });
assert_eq!( assert_eq!(
pm.render().as_str(), pm.into_string(),
"<h2>Sample title</h2><p>This is a paragraph.</p>" "<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] #[pagetop::test]
async fn prepare_markup_unicode_is_preserved() { async fn prepare_markup_unicode_is_preserved() {
// Texto con acentos y emojis debe conservarse (salvo el escape HTML de signos). // Texto con acentos y emojis debe conservarse (salvo el escape HTML de signos).
let esc = PrepareMarkup::Escaped("Hello, tomorrow coffee ☕ & donuts!".into()); let esc = PrepareMarkup::Escaped("Hello, tomorrow coffee ☕ & donuts!".into());
assert_eq!( assert_eq!(esc.into_string(), "Hello, tomorrow coffee ☕ &amp; donuts!");
esc.render().as_str(),
"Hello, tomorrow coffee ☕ &amp; donuts!"
);
// Raw debe pasar íntegro. // Raw debe pasar íntegro.
let raw = PrepareMarkup::Raw("Title — section © 2025".into()); 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] #[pagetop::test]
@ -88,7 +87,36 @@ async fn prepare_markup_is_empty_semantics() {
} }
#[pagetop::test] #[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 = [ let cases = [
PrepareMarkup::None, PrepareMarkup::None,
PrepareMarkup::Escaped("<b>x</b>".into()), 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 { for pm in cases {
let rendered = pm.render(); // Vía 1: renderizamos y obtenemos directamente el String.
let in_macro = html! { (rendered) }.into_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!( assert_eq!(
rendered.as_str(), via_component, via_macro,
in_macro, "The output of component render and (Markup) inside html! must match"
"The output of Render and (pm) inside html! must match"
); );
} }
} }