Compare commits

..

No commits in common. "b9d9cdf6016738024d50b31321c8761bcd913e51" and "55159f6d8fa7aa10d31b27d229100eafaf8aeb94" have entirely different histories.

33 changed files with 442 additions and 1756 deletions

View file

@ -102,7 +102,7 @@ impl Extension for SuperMenu {
)), )),
)); ));
InRegion::Global(&CoreRegion::Header).add( InRegion::Global(&DefaultRegions::Header).add(
bs::Container::new() bs::Container::new()
.with_width(bs::container::Width::FluidMax(UnitValue::RelRem(75.0))) .with_width(bs::container::Width::FluidMax(UnitValue::RelRem(75.0)))
.with_child(navbar_menu), .with_child(navbar_menu),

View file

@ -16,7 +16,6 @@ authors.workspace = true
[dependencies] [dependencies]
pagetop.workspace = true pagetop.workspace = true
serde_json.workspace = true
[build-dependencies] [build-dependencies]
pagetop-build.workspace = true pagetop-build.workspace = true

View file

@ -64,27 +64,6 @@ async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
} }
``` ```
Cuando los valores se construyen en tiempo de ejecución o quieres que una extensión aplique estos
atributos sin que el componente dependa de HTMX, usa `Props` junto con las constantes de `hx` en
lugar de escribirlos como literales en `html!`:
```rust
use pagetop::prelude::*;
use pagetop_htmx::prelude::*;
async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
let props = Props::new(hx::GET, "/api/hello")
.with_prop(PropsOp::set(hx::TARGET, "#result"));
Page::new(request)
.with_child(Html::with(move |_| html! {
button (props) { "Say hello" }
div #result {}
}))
.render().await
}
```
## Créditos ## Créditos
Este *crate* integra la biblioteca [HTMX 2.0.10](https://htmx.org), distribuida bajo licencia Este *crate* integra la biblioteca [HTMX 2.0.10](https://htmx.org), distribuida bajo licencia

View file

@ -24,9 +24,11 @@
//! //!
//! ```rust,no_run //! ```rust,no_run
//! use pagetop::prelude::*; //! use pagetop::prelude::*;
//! use pagetop_htmx::prelude::*; //! use pagetop_htmx::hx;
//! //!
//! let props = Props::new(hx::GET, "/api/items") //! let endpoint = "/api/items"; // Calculado en tiempo de ejecución.
//!
//! let props = Props::new(hx::GET, endpoint)
//! .with_prop(PropsOp::set(hx::TARGET, "#list")) //! .with_prop(PropsOp::set(hx::TARGET, "#list"))
//! .with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML)); //! .with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML));
//! //!
@ -43,7 +45,7 @@
//! //!
//! ```rust,no_run //! ```rust,no_run
//! use pagetop::prelude::*; //! use pagetop::prelude::*;
//! use pagetop_htmx::prelude::*; //! use pagetop_htmx::hx;
//! //!
//! #[derive(AutoDefault, Getters)] //! #[derive(AutoDefault, Getters)]
//! pub struct MyButton { //! pub struct MyButton {
@ -73,7 +75,7 @@
//! //!
//! ```rust,no_run //! ```rust,no_run
//! use pagetop::prelude::*; //! use pagetop::prelude::*;
//! use pagetop_htmx::prelude::*; //! use pagetop_htmx::hx;
//! //!
//! // Evento nativo del DOM: hx-on:click="..." //! // Evento nativo del DOM: hx-on:click="..."
//! // Evento propio de HTMX: hx-on::after-swap="..." //! // Evento propio de HTMX: hx-on::after-swap="..."
@ -89,7 +91,7 @@
/// ///
/// ```rust,no_run /// ```rust,no_run
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// # use pagetop_htmx::prelude::*; /// # use pagetop_htmx::hx;
/// let props = Props::new(hx::GET, "/api/search") /// let props = Props::new(hx::GET, "/api/search")
/// .with_prop(PropsOp::set(hx::TARGET, "#results")); /// .with_prop(PropsOp::set(hx::TARGET, "#results"));
/// ``` /// ```
@ -111,7 +113,7 @@ pub const PATCH: &str = "hx-patch";
/// ///
/// ```rust,no_run /// ```rust,no_run
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// # use pagetop_htmx::prelude::*; /// # use pagetop_htmx::hx;
/// // Al eliminar un elemento, reemplazarlo con respuesta vacía borra el nodo del DOM. /// // Al eliminar un elemento, reemplazarlo con respuesta vacía borra el nodo del DOM.
/// let props = Props::new(hx::DELETE, "/api/item/42") /// let props = Props::new(hx::DELETE, "/api/item/42")
/// .with_prop(PropsOp::set(hx::TARGET, "closest li")) /// .with_prop(PropsOp::set(hx::TARGET, "closest li"))
@ -128,7 +130,7 @@ pub const DELETE: &str = "hx-delete";
/// ///
/// ```rust,no_run /// ```rust,no_run
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// # use pagetop_htmx::prelude::*; /// # use pagetop_htmx::hx;
/// let props = Props::new(hx::GET, "/api/detalles") /// let props = Props::new(hx::GET, "/api/detalles")
/// .with_prop(PropsOp::set(hx::TARGET, "closest article")); /// .with_prop(PropsOp::set(hx::TARGET, "closest article"));
/// ``` /// ```
@ -142,7 +144,7 @@ pub const TARGET: &str = "hx-target";
/// ///
/// ```rust,no_run /// ```rust,no_run
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// # use pagetop_htmx::prelude::*; /// # use pagetop_htmx::hx;
/// // Reemplaza el elemento completo con una transición de 300 ms. /// // Reemplaza el elemento completo con una transición de 300 ms.
/// let props = Props::new(hx::SWAP, "outerHTML swap:300ms"); /// let props = Props::new(hx::SWAP, "outerHTML swap:300ms");
/// // O usando la constante tipada más los modificadores: /// // O usando la constante tipada más los modificadores:
@ -174,7 +176,7 @@ pub const SELECT_OOB: &str = "hx-select-oob";
/// ///
/// ```rust,no_run /// ```rust,no_run
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// # use pagetop_htmx::prelude::*; /// # use pagetop_htmx::hx;
/// // Buscar mientras se escribe, con 400 ms de espera y sólo si el valor cambia: /// // Buscar mientras se escribe, con 400 ms de espera y sólo si el valor cambia:
/// let props = Props::new(hx::GET, "/api/search") /// let props = Props::new(hx::GET, "/api/search")
/// .with_prop(PropsOp::set(hx::TRIGGER, "keyup changed delay:400ms")) /// .with_prop(PropsOp::set(hx::TRIGGER, "keyup changed delay:400ms"))
@ -292,11 +294,6 @@ pub const PRESERVE: &str = "hx-preserve";
/// - `"sse"` - soporte Server-Sent Events. /// - `"sse"` - soporte Server-Sent Events.
/// - `"json-enc"` - codifica la petición como JSON en lugar de form-urlencoded. /// - `"json-enc"` - codifica la petición como JSON en lugar de form-urlencoded.
/// - `"loading-states"` - gestión avanzada de estados de carga. /// - `"loading-states"` - gestión avanzada de estados de carga.
///
/// `pagetop-htmx` sólo integra el *core* de HTMX: usar cualquiera de estas extensiones (ver el
/// [catálogo oficial](https://htmx.org/extensions/)) requiere añadir su script correspondiente por
/// separado, por ejemplo con [`JavaScript::defer()`](pagetop::html::JavaScript::defer) en
/// [`dependencies()`](pagetop::core::extension::Extension::dependencies).
pub const EXT: &str = "hx-ext"; pub const EXT: &str = "hx-ext";
/// Atributos HTMX que los elementos descendientes NO heredarán de este elemento. /// Atributos HTMX que los elementos descendientes NO heredarán de este elemento.
@ -351,7 +348,7 @@ pub const DISABLE: &str = "hx-disable";
/// ///
/// ```rust,no_run /// ```rust,no_run
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// # use pagetop_htmx::prelude::*; /// # use pagetop_htmx::hx;
/// let props = Props::new(hx::on("click"), "this.classList.toggle('active')") /// let props = Props::new(hx::on("click"), "this.classList.toggle('active')")
/// .with_prop(PropsOp::set(hx::on("mouseenter"), "this.style.opacity='0.8'")); /// .with_prop(PropsOp::set(hx::on("mouseenter"), "this.style.opacity='0.8'"));
/// ``` /// ```
@ -367,7 +364,7 @@ pub fn on(event: &str) -> String {
/// ///
/// ```rust,no_run /// ```rust,no_run
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// # use pagetop_htmx::prelude::*; /// # use pagetop_htmx::hx;
/// let props = Props::new(hx::on_htmx("before-request"), "console.log('enviando...')") /// let props = Props::new(hx::on_htmx("before-request"), "console.log('enviando...')")
/// .with_prop(PropsOp::set(hx::on_htmx("after-swap"), "initTooltips()")); /// .with_prop(PropsOp::set(hx::on_htmx("after-swap"), "initTooltips()"));
/// ``` /// ```
@ -385,7 +382,7 @@ pub fn on_htmx(event: &str) -> String {
/// ///
/// ```rust,no_run /// ```rust,no_run
/// use pagetop::prelude::*; /// use pagetop::prelude::*;
/// use pagetop_htmx::prelude::*; /// use pagetop_htmx::hx;
/// ///
/// async fn handler(request: HttpRequest) { /// async fn handler(request: HttpRequest) {
/// if let Some(target) = request.headers().get(hx::request::TARGET) { /// if let Some(target) = request.headers().get(hx::request::TARGET) {
@ -420,19 +417,18 @@ pub mod request {
/// manualmente, aunque lo habitual es usar el constructor [`HtmxResponse`](crate::HtmxResponse). /// manualmente, aunque lo habitual es usar el constructor [`HtmxResponse`](crate::HtmxResponse).
/// ///
/// ```rust,no_run /// ```rust,no_run
/// use pagetop::prelude::*; /// use pagetop_htmx::hx;
/// use pagetop_htmx::prelude::*; /// use pagetop::web::http::{HeaderMap, HeaderName, HeaderValue};
/// ///
/// let mut headers = web::http::HeaderMap::new(); /// let mut headers = HeaderMap::new();
/// headers.insert( /// headers.insert(
/// hx::response::TRIGGER.parse::<web::http::HeaderName>().unwrap(), /// hx::response::TRIGGER.parse::<HeaderName>().unwrap(),
/// web::http::HeaderValue::from_static("itemAdded"), /// HeaderValue::from_static("itemAdded"),
/// ); /// );
/// ``` /// ```
pub mod response { pub mod response {
/// Redirige mediante AJAX a la URL o configuración JSON indicada. Ver /// Redirige mediante AJAX a la URL o configuración JSON indicada. Ver
/// [`HtmxResponse::location()`](crate::HtmxResponse::location) y /// [`HtmxResponse::location()`](crate::HtmxResponse::location).
/// [`HtmxResponse::location_json()`](crate::HtmxResponse::location_json).
pub const LOCATION: &str = "HX-Location"; pub const LOCATION: &str = "HX-Location";
/// Empuja la URL indicada al historial del navegador. Ver /// Empuja la URL indicada al historial del navegador. Ver
/// [`HtmxResponse::push_url()`](crate::HtmxResponse::push_url). /// [`HtmxResponse::push_url()`](crate::HtmxResponse::push_url).
@ -479,7 +475,7 @@ pub mod response {
/// ///
/// ```rust,no_run /// ```rust,no_run
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// # use pagetop_htmx::prelude::*; /// # use pagetop_htmx::hx;
/// // Reemplaza el elemento con una transición de 200 ms y desplaza al inicio: /// // Reemplaza el elemento con una transición de 200 ms y desplaza al inicio:
/// let props = Props::new(hx::SWAP, format!("{} swap:200ms scroll:top", hx::swap::OUTER_HTML)); /// let props = Props::new(hx::SWAP, format!("{} swap:200ms scroll:top", hx::swap::OUTER_HTML));
/// ``` /// ```
@ -519,7 +515,7 @@ pub mod swap {
/// ///
/// ```rust,no_run /// ```rust,no_run
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// # use pagetop_htmx::prelude::*; /// # use pagetop_htmx::hx;
/// // Búsqueda progresiva: petición 400 ms después de que el usuario deje de escribir. /// // Búsqueda progresiva: petición 400 ms después de que el usuario deje de escribir.
/// let search = Props::new(hx::TRIGGER, "keyup changed delay:400ms"); /// let search = Props::new(hx::TRIGGER, "keyup changed delay:400ms");
/// ///

View file

@ -1,74 +0,0 @@
//! Soporte HTMX al componente [`Table`].
use pagetop::prelude::*;
use crate::hx;
// **< sort_link() >********************************************************************************
/// Construye un [`SortLink`](pagetop::base::component::table::SortLink) para actualizar el orden de
/// la tabla sin recargar la página.
///
/// [`Table`] y `SortLink` no requieren HTMX. Cada extensión que quiera aplicar una navegación sin
/// recarga debe añadir sus propios atributos `hx-*` usando
/// [`SortLink::with_prop()`](pagetop::base::component::table::SortLink::with_prop). Como esos
/// cuatro atributos son siempre los mismos para cualquier cabecera ordenable (`hx-get` igual al
/// `href`, `hx-swap="outerHTML"` y `hx-push-url="true"`, y sólo `hx-target` cambia según la tabla),
/// [`sort_link()`] evita reescribirlos en cada columna de cada listado.
///
/// El enlace resultante funciona igual con o sin HTMX: `href` es siempre la URL real del nuevo
/// estado de orden, así que navega correctamente aunque HTMX no esté disponible en el cliente.
///
/// # Argumentos
///
/// - `href`: URL completa hacia el nuevo estado de orden, reflejando ya el campo y la dirección
/// que resultarán de pulsar esta cabecera. Acepta cualquier tipo convertible a [`RoutePath`],
/// normalmente el resultado de [`Context::route()`](pagetop::core::component::Context::route),
/// para que el enlace preserve el parámetro `lang` cuando corresponda.
/// - `target`: selector CSS del elemento que HTMX debe reemplazar (`hx-target`), típicamente el
/// contenedor que envuelve la tabla completa.
/// - `dir`: dirección de orden vigente de esta columna, o `None` si la tabla está ordenada
/// actualmente por otra columna. Se traslada tal cual a
/// [`SortLink::with_dir()`](pagetop::base::component::table::SortLink::with_dir).
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::prelude::*;
///
/// # fn build_column(cx: &Context) -> table::Column {
/// let current_field = "username"; // Estado vigente de la tabla.
/// let current_dir = html::SortDir::Asc; // Ordenada por "username" en ascendente.
///
/// let field = "username"; // Cabecera de la propia columna "username".
/// let is_active = field == current_field; // En el ejemplo, coincide con el campo vigente.
/// let active = is_active.then_some(current_dir); // `Some` sólo si esta columna ordena ahora.
/// let next_dir = html::SortDir::next_for(active); // El siguiente clic alterna la dirección.
///
/// // `cx` es el `Context` de la petición en curso.
/// let href = cx
/// .route("/admin/users")
/// .with_param("sort", field)
/// .with_param("dir", next_dir);
///
/// table::Column::new(L10n::n("User"))
/// .with_sort(hx_table::sort_link(href, "#user-table-wrapper", active))
/// # }
/// ```
pub fn sort_link(
href: impl Into<RoutePath>,
target: impl AsRef<str>,
dir: impl Into<Option<SortDir>>,
) -> table::SortLink {
// Se materializa como `String` propio porque el mismo valor sirve para dos llamadas: como
// `RoutePath` en `SortLink::new()` (vía `href.as_str()`) y como `CowStr` en `PropsOp::set()`.
let href = href.into().to_string();
let target = target.as_ref().to_owned();
table::SortLink::new(href.as_ref())
.with_dir(dir)
.with_prop(PropsOp::set(hx::GET, href))
.with_prop(PropsOp::set(hx::TARGET, target))
.with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML))
.with_prop(PropsOp::set(hx::PUSH_URL, "true"))
}

View file

@ -64,35 +64,11 @@ async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
.render().await .render().await
} }
``` ```
Cuando los valores se construyen en tiempo de ejecución o quieres que una extensión aplique estos
atributos sin que el componente dependa de HTMX, usa `Props` junto con las constantes de `hx` en
lugar de escribirlos como literales en `html!`:
```rust
use pagetop::prelude::*;
use pagetop_htmx::prelude::*;
async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
let props = Props::new(hx::GET, "/api/hello")
.with_prop(PropsOp::set(hx::TARGET, "#result"));
Page::new(request)
.with_child(Html::with(move |_| html! {
button (props) { "Say hello" }
div #result {}
}))
.render().await
}
```
*/ */
use pagetop::prelude::*; use pagetop::prelude::*;
include_locales!(LOCALES_HTMX);
pub mod hx; pub mod hx;
pub mod hx_table;
mod request; mod request;
pub use request::HtmxRequestExt; pub use request::HtmxRequestExt;
@ -100,14 +76,7 @@ pub use request::HtmxRequestExt;
mod response; mod response;
pub use response::HtmxResponse; pub use response::HtmxResponse;
/// Prelude de `pagetop-htmx`. include_locales!(LOCALES_HTMX);
pub mod prelude {
pub use crate::hx;
pub use crate::hx_table;
pub use crate::request::HtmxRequestExt;
pub use crate::response::HtmxResponse;
}
/// Integra HTMX 2 en cualquier aplicación PageTop. /// Integra HTMX 2 en cualquier aplicación PageTop.
/// ///

View file

@ -16,7 +16,7 @@ use pagetop::prelude::*;
/// ///
/// ```rust,no_run /// ```rust,no_run
/// use pagetop::prelude::*; /// use pagetop::prelude::*;
/// use pagetop_htmx::prelude::*; /// use pagetop_htmx::{HtmxRequestExt, HtmxResponse};
/// ///
/// async fn list_items(request: HttpRequest) -> Response { /// async fn list_items(request: HttpRequest) -> Response {
/// if request.is_htmx() { /// if request.is_htmx() {

View file

@ -17,7 +17,7 @@ use pagetop::prelude::*;
/// ///
/// ```rust,no_run /// ```rust,no_run
/// use pagetop::prelude::*; /// use pagetop::prelude::*;
/// use pagetop_htmx::prelude::*; /// use pagetop_htmx::{HtmxResponse, hx};
/// ///
/// async fn add_item(request: HttpRequest) -> impl IntoResponse { /// async fn add_item(request: HttpRequest) -> impl IntoResponse {
/// let new_item = html! { li #item-42 { "New item" } }; /// let new_item = html! { li #item-42 { "New item" } };
@ -37,7 +37,7 @@ use pagetop::prelude::*;
/// ///
/// ```rust,no_run /// ```rust,no_run
/// use pagetop::prelude::*; /// use pagetop::prelude::*;
/// use pagetop_htmx::prelude::*; /// use pagetop_htmx::HtmxResponse;
/// ///
/// async fn delete_item() -> impl IntoResponse { /// async fn delete_item() -> impl IntoResponse {
/// HtmxResponse::empty().redirect("/items") /// HtmxResponse::empty().redirect("/items")
@ -59,7 +59,7 @@ use pagetop::prelude::*;
/// ///
/// ```rust,no_run /// ```rust,no_run
/// use pagetop::prelude::*; /// use pagetop::prelude::*;
/// use pagetop_htmx::prelude::*; /// use pagetop_htmx::HtmxResponse;
/// ///
/// // Dos eventos sin datos: /// // Dos eventos sin datos:
/// HtmxResponse::empty().trigger("itemAdded, listUpdated"); /// HtmxResponse::empty().trigger("itemAdded, listUpdated");
@ -67,7 +67,6 @@ use pagetop::prelude::*;
/// // Evento con datos en JSON: /// // Evento con datos en JSON:
/// HtmxResponse::empty().trigger(r#"{"itemAdded": {"id": 42}}"#); /// HtmxResponse::empty().trigger(r#"{"itemAdded": {"id": 42}}"#);
/// ``` /// ```
#[must_use]
pub struct HtmxResponse { pub struct HtmxResponse {
markup: Markup, markup: Markup,
headers: web::http::HeaderMap, headers: web::http::HeaderMap,
@ -92,123 +91,45 @@ impl HtmxResponse {
/// Hace que HTMX realice una navegación AJAX a la URL indicada sin recargar la página. /// Hace que HTMX realice una navegación AJAX a la URL indicada sin recargar la página.
/// ///
/// A diferencia de [`redirect()`](Self::redirect), la navegación usa HTMX y actualiza sólo el /// A diferencia de [`redirect()`](Self::redirect), la navegación usa HTMX y actualiza sólo el
/// objetivo definido por el destino. Para personalizar `target`, `swap`, `select` o `values`, /// objetivo definido por el destino. Acepta una URL o un objeto JSON con claves `path`,
/// usa [`location_json()`](Self::location_json). /// `target`, `swap`, `select` y `values` para personalizar la navegación:
///
/// Usa [`Context::route()`](pagetop::core::component::Context::route) en lugar de un literal
/// para que la URL preserve el parámetro `lang` cuando corresponda:
/// ///
/// ```rust,no_run /// ```rust,no_run
/// use pagetop::prelude::*; /// use pagetop::prelude::*;
/// use pagetop_htmx::prelude::*; /// use pagetop_htmx::HtmxResponse;
/// ///
/// # fn build_response(cx: &Context) -> HtmxResponse { /// // Navegación simple:
/// HtmxResponse::empty().location(cx.route("/items")) /// HtmxResponse::empty().location("/items");
/// # }
/// ```
pub fn location(self, url: impl Into<RoutePath>) -> Self {
self.set_header(b"hx-location", url.into().to_string())
}
/// Hace que HTMX realice una navegación AJAX personalizada, con un objeto JSON de configuración
/// en lugar de una URL simple.
///
/// Acepta un objeto JSON con las claves `path`, `target`, `swap`, `select` y `values` (ver la
/// [documentación de HTMX](https://htmx.org/reference/#response_headers) para el detalle de
/// cada una). Al no ser una URL, no admite `Context::route()`: si `path` necesita el parámetro
/// `lang`, hay que componerlo a mano antes de construir el JSON. Para una navegación simple sin
/// estas opciones, usa [`location()`](Self::location).
///
/// Si `json` no es sintácticamente válido, la cabecera se descarta y se registra un aviso; el
/// resto de la respuesta no se ve afectado. Esta comprobación sólo valida la sintaxis JSON, no
/// que las claves sean las que espera HTMX.
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::prelude::*;
/// ///
/// // Navegación con destino personalizado:
/// HtmxResponse::empty() /// HtmxResponse::empty()
/// .location_json(r##"{"path": "/items", "target": "#content"}"##); /// .location(r##"{"path": "/items", "target": "#content"}"##);
/// ``` /// ```
/// pub fn location(self, url: impl Into<String>) -> Self {
/// Si algún valor se calcula en tiempo de ejecución, constrúyelo con [`serde_json::json!`] en self.set_header(b"hx-location", url)
/// lugar de interpolarlo a mano con `format!()`: evita comillas u otros caracteres sin escapar
/// que romperían la estructura del JSON.
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::prelude::*;
///
/// let item_name = "Alice's item"; // Contiene una comilla: no es seguro interpolarlo a mano.
///
/// let json = serde_json::json!({
/// "path": "/items",
/// "values": { "name": item_name },
/// })
/// .to_string();
///
/// HtmxResponse::empty().location_json(json);
/// ```
pub fn location_json(self, json: impl Into<String>) -> Self {
let json = json.into();
if let Err(error) = serde_json::from_str::<serde_json::Value>(&json) {
trace::warn!(
json = %json,
%error,
"HtmxResponse: invalid JSON in location_json(), header discarded",
);
return self;
}
self.set_header(b"hx-location", json)
} }
/// Empuja la URL indicada al historial del navegador. /// Empuja la URL indicada al historial del navegador.
/// ///
/// El usuario podrá navegar hacia atrás hasta esa URL. Usar `"false"` para desactivar el empuje /// El usuario podrá navegar hacia atrás hasta esa URL. Usar `"false"` para desactivar el empuje
/// aunque esté habilitado por el atributo `hx-push-url` del elemento. /// aunque esté habilitado por el atributo `hx-push-url` del elemento.
/// pub fn push_url(self, url: impl Into<String>) -> Self {
/// Usa [`Context::route()`](pagetop::core::component::Context::route) en lugar de un literal self.set_header(b"hx-push-url", url)
/// para que la URL preserve el parámetro `lang` cuando corresponda:
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::prelude::*;
///
/// # fn build_response(cx: &Context) -> HtmxResponse {
/// HtmxResponse::empty().push_url(cx.route("/items"))
/// # }
/// ```
pub fn push_url(self, url: impl Into<RoutePath>) -> Self {
self.set_header(b"hx-push-url", url.into().to_string())
} }
/// Reemplaza la URL actual en el historial sin añadir una nueva entrada. /// Reemplaza la URL actual en el historial sin añadir una nueva entrada.
/// ///
/// Usar `"false"` para desactivar el reemplazo. Usa /// Usar `"false"` para desactivar el reemplazo.
/// [`Context::route()`](pagetop::core::component::Context::route) en lugar de un literal para pub fn replace_url(self, url: impl Into<String>) -> Self {
/// que la URL preserve el parámetro `lang` cuando corresponda. self.set_header(b"hx-replace-url", url)
pub fn replace_url(self, url: impl Into<RoutePath>) -> Self {
self.set_header(b"hx-replace-url", url.into().to_string())
} }
/// Provoca una redirección completa del navegador a la URL indicada. /// Provoca una redirección completa del navegador a la URL indicada.
/// ///
/// A diferencia de [`location()`](Self::location), esta redirección recarga la página por /// A diferencia de [`location()`](Self::location), esta redirección recarga la página por
/// completo, como un `window.location.href = url` en JavaScript. /// completo, como un `window.location.href = url` en JavaScript.
/// pub fn redirect(self, url: impl Into<String>) -> Self {
/// Usa [`Context::route()`](pagetop::core::component::Context::route) en lugar de un literal self.set_header(b"hx-redirect", url)
/// para que la URL preserve el parámetro `lang` cuando corresponda:
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::prelude::*;
///
/// # fn build_response(cx: &Context) -> HtmxResponse {
/// HtmxResponse::empty().redirect(cx.route("/items"))
/// # }
/// ```
pub fn redirect(self, url: impl Into<RoutePath>) -> Self {
self.set_header(b"hx-redirect", url.into().to_string())
} }
/// Provoca una recarga completa de la página actual. /// Provoca una recarga completa de la página actual.
@ -249,7 +170,7 @@ impl HtmxResponse {
/// ///
/// ```rust,no_run /// ```rust,no_run
/// use pagetop::prelude::*; /// use pagetop::prelude::*;
/// use pagetop_htmx::prelude::*; /// use pagetop_htmx::HtmxResponse;
/// ///
/// // Evento simple: /// // Evento simple:
/// HtmxResponse::empty().trigger("itemAdded"); /// HtmxResponse::empty().trigger("itemAdded");
@ -260,21 +181,6 @@ impl HtmxResponse {
/// // Evento con datos en JSON: /// // Evento con datos en JSON:
/// HtmxResponse::empty().trigger(r#"{"itemAdded": {"id": 42, "name": "Example"}}"#); /// HtmxResponse::empty().trigger(r#"{"itemAdded": {"id": 42, "name": "Example"}}"#);
/// ``` /// ```
///
/// Si el dato del evento se calcula en tiempo de ejecución, constrúyelo con
/// [`serde_json::json!`] en lugar de interpolarlo a mano con `format!()`, para evitar comillas
/// u otros caracteres sin escapar que romperían la estructura del JSON:
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::prelude::*;
///
/// let item_name = "Alice's item"; // Contiene una comilla: no es seguro interpolarlo a mano.
///
/// let json = serde_json::json!({ "itemAdded": { "name": item_name } }).to_string();
///
/// HtmxResponse::empty().trigger(json);
/// ```
pub fn trigger(self, event: impl Into<String>) -> Self { pub fn trigger(self, event: impl Into<String>) -> Self {
self.set_header(b"hx-trigger", event) self.set_header(b"hx-trigger", event)
} }

View file

@ -1,62 +0,0 @@
use pagetop::prelude::*;
use pagetop_htmx::Htmx;
struct TestApp;
#[async_trait]
impl Extension for TestApp {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![&Htmx]
}
fn configure_router(&self, router: Router) -> Router {
router.route("/page", web::get(render_page))
}
}
async fn render_page(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_child(Html::with(|_| html! { p { "hello" } }))
.render()
.await
}
// All tests in this file share the same root extension (`TestApp`), since `EXTENSIONS` is a global
// `OnceLock` initialized only once per test binary (see `core/extension/all.rs`).
// **< Static assets >******************************************************************************
#[pagetop::test]
async fn htmx_script_is_served_at_the_expected_static_path() {
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
let req = web::test::TestRequest::get()
.uri("/htmx/js/htmx.min.js")
.to_request();
let resp = web::test::send_request(&app, req).await;
assert_eq!(resp.status(), web::http::StatusCode::OK);
let body = web::test::read_body_text(resp).await;
assert!(!body.is_empty());
assert!(body.contains("htmx"));
}
// **< Automatic script injection (BeforeRenderBody) >***********************************************
#[pagetop::test]
async fn rendered_pages_automatically_include_the_pinned_htmx_script_tag() {
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
let req = web::test::TestRequest::get().uri("/page").to_request();
let resp = web::test::send_request(&app, req).await;
assert_eq!(resp.status(), web::http::StatusCode::OK);
let body = web::test::read_body_text(resp).await;
// The version must stay in sync with the bundled `assets/js/htmx.min.js`; a mismatch here
// would mean the browser caches a stale script under a version tag that no longer matches it.
assert!(body.contains(r#"src="/htmx/js/htmx.min.js?v=2.0.10""#));
assert!(body.contains("defer"));
assert!(body.contains("<p>hello</p>"));
}

View file

@ -1,160 +0,0 @@
use pagetop_htmx::prelude::*;
// **< HTTP Methods >*******************************************************************************
#[pagetop::test]
async fn http_method_constants_match_the_htmx_attribute_names() {
assert_eq!(hx::GET, "hx-get");
assert_eq!(hx::POST, "hx-post");
assert_eq!(hx::PUT, "hx-put");
assert_eq!(hx::PATCH, "hx-patch");
assert_eq!(hx::DELETE, "hx-delete");
}
// **< Target and Swap >****************************************************************************
#[pagetop::test]
async fn target_and_swap_constants_match_the_htmx_attribute_names() {
assert_eq!(hx::TARGET, "hx-target");
assert_eq!(hx::SWAP, "hx-swap");
assert_eq!(hx::SWAP_OOB, "hx-swap-oob");
assert_eq!(hx::SELECT, "hx-select");
assert_eq!(hx::SELECT_OOB, "hx-select-oob");
}
// **< Trigger >************************************************************************************
#[pagetop::test]
async fn trigger_related_constants_match_the_htmx_attribute_names() {
assert_eq!(hx::TRIGGER, "hx-trigger");
assert_eq!(hx::BOOST, "hx-boost");
assert_eq!(hx::PUSH_URL, "hx-push-url");
assert_eq!(hx::REPLACE_URL, "hx-replace-url");
assert_eq!(hx::SYNC, "hx-sync");
}
// **< Request Data >*******************************************************************************
#[pagetop::test]
async fn request_data_constants_match_the_htmx_attribute_names() {
assert_eq!(hx::INCLUDE, "hx-include");
assert_eq!(hx::PARAMS, "hx-params");
assert_eq!(hx::VALS, "hx-vals");
assert_eq!(hx::HEADERS, "hx-headers");
assert_eq!(hx::ENCODING, "hx-encoding");
}
// **< Element Behavior >***************************************************************************
#[pagetop::test]
async fn element_behavior_constants_match_the_htmx_attribute_names() {
assert_eq!(hx::INDICATOR, "hx-indicator");
assert_eq!(hx::DISABLED_ELT, "hx-disabled-elt");
assert_eq!(hx::CONFIRM, "hx-confirm");
assert_eq!(hx::PROMPT, "hx-prompt");
assert_eq!(hx::VALIDATE, "hx-validate");
assert_eq!(hx::PRESERVE, "hx-preserve");
}
// **< Config and Extensions >**********************************************************************
#[pagetop::test]
async fn config_and_extension_constants_match_the_htmx_attribute_names() {
assert_eq!(hx::EXT, "hx-ext");
assert_eq!(hx::DISINHERIT, "hx-disinherit");
assert_eq!(hx::INHERIT, "hx-inherit");
assert_eq!(hx::REQUEST, "hx-request");
assert_eq!(hx::HISTORY, "hx-history");
assert_eq!(hx::HISTORY_ELT, "hx-history-elt");
assert_eq!(hx::DISABLE, "hx-disable");
}
// **< Inline Events (hx-on) >**********************************************************************
#[pagetop::test]
async fn on_builds_the_dom_event_attribute_name() {
assert_eq!(hx::on("click"), "hx-on:click");
assert_eq!(hx::on("mouseenter"), "hx-on:mouseenter");
}
#[pagetop::test]
async fn on_htmx_builds_the_htmx_lifecycle_event_attribute_name() {
assert_eq!(hx::on_htmx("before-request"), "hx-on::before-request");
assert_eq!(hx::on_htmx("after-swap"), "hx-on::after-swap");
}
#[pagetop::test]
async fn on_and_on_htmx_use_a_different_separator_for_the_same_event_name() {
// The single/double colon is the only thing that distinguishes a native DOM event from an
// HTMX lifecycle event with the same name; a typo here would silently listen to the wrong one.
let event = "after-swap";
assert_ne!(hx::on(event), hx::on_htmx(event));
assert_eq!(hx::on(event), "hx-on:after-swap");
assert_eq!(hx::on_htmx(event), "hx-on::after-swap");
}
// **< HTMX Request Headers (hx::request) >*********************************************************
#[pagetop::test]
async fn request_header_constants_match_the_lowercase_htmx_header_names() {
assert_eq!(hx::request::REQUEST, "hx-request");
assert_eq!(hx::request::BOOSTED, "hx-boosted");
assert_eq!(hx::request::CURRENT_URL, "hx-current-url");
assert_eq!(
hx::request::HISTORY_RESTORE_REQUEST,
"hx-history-restore-request"
);
assert_eq!(hx::request::PROMPT, "hx-prompt");
assert_eq!(hx::request::TARGET, "hx-target");
assert_eq!(hx::request::TRIGGER, "hx-trigger");
assert_eq!(hx::request::TRIGGER_NAME, "hx-trigger-name");
}
// **< HTMX Response Headers (hx::response) >*******************************************************
#[pagetop::test]
async fn response_header_constants_match_the_capitalized_htmx_header_names() {
// Unlike the request headers, HTMX documents the response headers in their canonical
// capitalized form (`HX-Location`, not `hx-location`); the constants mirror that on purpose.
assert_eq!(hx::response::LOCATION, "HX-Location");
assert_eq!(hx::response::PUSH_URL, "HX-Push-Url");
assert_eq!(hx::response::REDIRECT, "HX-Redirect");
assert_eq!(hx::response::REFRESH, "HX-Refresh");
assert_eq!(hx::response::REPLACE_URL, "HX-Replace-Url");
assert_eq!(hx::response::RESWAP, "HX-Reswap");
assert_eq!(hx::response::RETARGET, "HX-Retarget");
assert_eq!(hx::response::RESELECT, "HX-Reselect");
assert_eq!(hx::response::TRIGGER, "HX-Trigger");
assert_eq!(
hx::response::TRIGGER_AFTER_SETTLE,
"HX-Trigger-After-Settle"
);
assert_eq!(hx::response::TRIGGER_AFTER_SWAP, "HX-Trigger-After-Swap");
}
// **< hx-swap Values (hx::swap) >******************************************************************
#[pagetop::test]
async fn swap_value_constants_match_the_htmx_swap_strategies() {
assert_eq!(hx::swap::INNER_HTML, "innerHTML");
assert_eq!(hx::swap::OUTER_HTML, "outerHTML");
assert_eq!(hx::swap::BEFORE_BEGIN, "beforebegin");
assert_eq!(hx::swap::AFTER_BEGIN, "afterbegin");
assert_eq!(hx::swap::BEFORE_END, "beforeend");
assert_eq!(hx::swap::AFTER_END, "afterend");
assert_eq!(hx::swap::DELETE, "delete");
assert_eq!(hx::swap::NONE, "none");
}
// **< hx-trigger Values (hx::trigger) >************************************************************
#[pagetop::test]
async fn trigger_value_constants_match_the_htmx_event_names() {
assert_eq!(hx::trigger::CLICK, "click");
assert_eq!(hx::trigger::CHANGE, "change");
assert_eq!(hx::trigger::SUBMIT, "submit");
assert_eq!(hx::trigger::KEYUP, "keyup");
assert_eq!(hx::trigger::LOAD, "load");
assert_eq!(hx::trigger::REVEALED, "revealed");
assert_eq!(hx::trigger::INTERSECT, "intersect");
}

View file

@ -1,162 +0,0 @@
use pagetop::prelude::*;
use pagetop_htmx::prelude::*;
// Forces an effective language different from the default negotiated one (en-US, with no `?lang` in
// the request), so that `Context::route()` decides to propagate `?lang=...` in local routes.
fn cx_with_lang(lang: &str) -> Context {
Context::new(None).with_langid(&Locale::resolve(lang))
}
async fn render_column(column: table::Column) -> String {
let mut table = Table::new().with_column(column);
table.render(&mut Context::default()).await.into_string()
}
// **< sort_link() - htmx attributes >**************************************************************
#[pagetop::test]
async fn sort_link_sets_the_four_fixed_htmx_attributes() {
let column = table::Column::new(L10n::n("User")).with_sort(hx_table::sort_link(
"/admin/users",
"#user-table",
None,
));
let html = render_column(column).await;
assert!(html.contains(r#"hx-get="/admin/users""#));
assert!(html.contains(r##"hx-target="#user-table""##));
assert!(html.contains(r#"hx-swap="outerHTML""#));
assert!(html.contains(r#"hx-push-url="true""#));
}
#[pagetop::test]
async fn sort_link_href_matches_the_hx_get_value() {
// The link must work with or without HTMX: `href` is the real destination, and `hx-get` must
// request that very same URL so both navigation paths land on the same state.
let column = table::Column::new(L10n::n("User")).with_sort(hx_table::sort_link(
"/admin/users?sort=username",
"#user-table",
None,
));
let html = render_column(column).await;
assert!(html.contains(r#"href="/admin/users?sort=username""#));
assert!(html.contains(r#"hx-get="/admin/users?sort=username""#));
}
#[pagetop::test]
async fn sort_link_target_is_configurable_per_table() {
let column = table::Column::new(L10n::n("Email")).with_sort(hx_table::sort_link(
"/admin/users",
"#other-wrapper",
None,
));
let html = render_column(column).await;
assert!(html.contains(r##"hx-target="#other-wrapper""##));
}
// **< sort_link() - sort direction propagation >***************************************************
#[pagetop::test]
async fn sort_link_without_active_direction_marks_aria_sort_none() {
let column = table::Column::new(L10n::n("User")).with_sort(hx_table::sort_link(
"/admin/users",
"#user-table",
None,
));
let html = render_column(column).await;
assert!(html.contains(r#"aria-sort="none""#));
}
#[pagetop::test]
async fn sort_link_with_active_direction_marks_aria_sort_and_css_class() {
let column = table::Column::new(L10n::n("User")).with_sort(hx_table::sort_link(
"/admin/users",
"#user-table",
SortDir::Desc,
));
let html = render_column(column).await;
assert!(html.contains(r#"aria-sort="descending""#));
assert!(html.contains("table-sort table-sort-desc"));
}
// **< sort_link() - RoutePath / Context::route() integration >*************************************
#[pagetop::test]
async fn sort_link_with_a_bare_literal_href_never_adds_lang() {
// `sort_link()` does not receive `cx`, so it cannot add `lang` on its own: passing a raw
// literal must leave both `href` and `hx-get` exactly as given.
let column = table::Column::new(L10n::n("User")).with_sort(hx_table::sort_link(
"/admin/users",
"#user-table",
None,
));
let html = render_column(column).await;
assert!(html.contains(r#"href="/admin/users""#));
assert!(!html.contains("lang="));
}
#[pagetop::test]
async fn sort_link_carries_through_a_lang_aware_href_unchanged() {
// The caller is expected to resolve `href` with `cx.route(...)` beforehand (see the type's own
// doc example); `sort_link()` must not re-encode or otherwise alter what it receives.
let cx = cx_with_lang("es-ES");
let href = cx.route("/admin/users");
let column = table::Column::new(L10n::n("User")).with_sort(hx_table::sort_link(
href,
"#user-table",
None,
));
let html = render_column(column).await;
assert!(html.contains(r#"href="/admin/users?lang=es-ES""#));
assert!(html.contains(r#"hx-get="/admin/users?lang=es-ES""#));
}
#[pagetop::test]
async fn sort_link_carries_through_extra_query_params_in_order() {
let cx = cx_with_lang("es-ES");
let href = cx
.route("/admin/users")
.with_param("sort", "username")
.with_param("dir", "desc");
let column = table::Column::new(L10n::n("User")).with_sort(hx_table::sort_link(
href,
"#user-table",
SortDir::Desc,
));
let html = render_column(column).await;
// `&` is escaped to `&amp;` because this ends up inside an HTML attribute value.
assert!(html.contains(r#"href="/admin/users?lang=es-ES&amp;sort=username&amp;dir=desc""#));
}
#[pagetop::test]
async fn sort_link_with_an_external_href_is_left_untouched() {
// `Context::route()` never adds `lang` to a URL that looks external; `sort_link()` must not
// reintroduce it either, since it only forwards whatever `RoutePath` it receives.
let cx = cx_with_lang("es-ES");
let href = cx.route("https://example.com/export");
let column =
table::Column::new(L10n::n("Export")).with_sort(hx_table::sort_link(href, "#table", None));
let html = render_column(column).await;
assert!(html.contains(r#"href="https://example.com/export""#));
assert!(!html.contains("lang="));
}

View file

@ -1,169 +0,0 @@
use pagetop::prelude::*;
use pagetop_htmx::HtmxRequestExt;
struct TestApp;
#[async_trait]
impl Extension for TestApp {
fn configure_router(&self, router: Router) -> Router {
router.route("/echo", web::get(echo_request))
}
}
// Reports every `HtmxRequestExt` value as JSON, so a single route can back every test in this file
// without needing a dedicated handler per header.
async fn echo_request(request: HttpRequest) -> String {
serde_json::json!({
"is_htmx": request.is_htmx(),
"is_boosted": request.is_boosted(),
"is_history_restore": request.is_history_restore(),
"current_url": request.hx_current_url(),
"target": request.hx_target(),
"trigger_id": request.hx_trigger_id(),
"trigger_name": request.hx_trigger_name(),
"prompt": request.hx_prompt(),
})
.to_string()
}
async fn echo(app: &Router, headers: &[(&str, &str)]) -> serde_json::Value {
let mut req = web::test::TestRequest::get().uri("/echo");
for (name, value) in headers {
req = req.header(*name, *value);
}
let resp = web::test::send_request(app, req.to_request()).await;
let body = web::test::read_body_text(resp).await;
serde_json::from_str(&body).unwrap()
}
// All tests in this file share the same root extension (`TestApp`), since `EXTENSIONS` is a global
// `OnceLock` initialized only once per test binary (see `core/extension/all.rs`).
// **< is_htmx() >**********************************************************************************
#[pagetop::test]
async fn is_htmx_is_true_only_when_hx_request_is_exactly_true() {
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
let with_header = echo(&app, &[("hx-request", "true")]).await;
assert_eq!(with_header["is_htmx"], true);
let without_header = echo(&app, &[]).await;
assert_eq!(without_header["is_htmx"], false);
// A stray/incorrect value must not be treated as a truthy HTMX request.
let wrong_value = echo(&app, &[("hx-request", "false")]).await;
assert_eq!(wrong_value["is_htmx"], false);
}
// **< is_boosted() >*******************************************************************************
#[pagetop::test]
async fn is_boosted_reflects_the_hx_boosted_header() {
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
let boosted = echo(&app, &[("hx-boosted", "true")]).await;
assert_eq!(boosted["is_boosted"], true);
let not_boosted = echo(&app, &[]).await;
assert_eq!(not_boosted["is_boosted"], false);
}
// **< is_history_restore() >***********************************************************************
#[pagetop::test]
async fn is_history_restore_reflects_the_hx_history_restore_request_header() {
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
let restoring = echo(&app, &[("hx-history-restore-request", "true")]).await;
assert_eq!(restoring["is_history_restore"], true);
let not_restoring = echo(&app, &[]).await;
assert_eq!(not_restoring["is_history_restore"], false);
}
// **< hx_current_url() / hx_target() / hx_trigger_id() / hx_trigger_name() / hx_prompt() >*********
#[pagetop::test]
async fn hx_current_url_reads_the_hx_current_url_header_when_present() {
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
let with_url = echo(&app, &[("hx-current-url", "/admin/users?page=2")]).await;
assert_eq!(with_url["current_url"], "/admin/users?page=2");
let without_url = echo(&app, &[]).await;
assert!(without_url["current_url"].is_null());
}
#[pagetop::test]
async fn hx_target_reads_the_hx_target_header_when_present() {
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
let with_target = echo(&app, &[("hx-target", "user-table")]).await;
assert_eq!(with_target["target"], "user-table");
let without_target = echo(&app, &[]).await;
assert!(without_target["target"].is_null());
}
#[pagetop::test]
async fn hx_trigger_id_reads_the_hx_trigger_header_when_present() {
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
let with_trigger = echo(&app, &[("hx-trigger", "save-button")]).await;
assert_eq!(with_trigger["trigger_id"], "save-button");
let without_trigger = echo(&app, &[]).await;
assert!(without_trigger["trigger_id"].is_null());
}
#[pagetop::test]
async fn hx_trigger_name_reads_the_hx_trigger_name_header_when_present() {
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
let with_name = echo(&app, &[("hx-trigger-name", "email")]).await;
assert_eq!(with_name["trigger_name"], "email");
let without_name = echo(&app, &[]).await;
assert!(without_name["trigger_name"].is_null());
}
#[pagetop::test]
async fn hx_prompt_reads_the_hx_prompt_header_when_present() {
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
let with_prompt = echo(&app, &[("hx-prompt", "Are you sure?")]).await;
assert_eq!(with_prompt["prompt"], "Are you sure?");
let without_prompt = echo(&app, &[]).await;
assert!(without_prompt["prompt"].is_null());
}
// **< A realistic combined request >***************************************************************
#[pagetop::test]
async fn a_realistic_htmx_request_reports_all_fields_consistently() {
// Simulates a table sort click: a boosted-free HTMX request triggered by a link with an `id`,
// targeting the table wrapper.
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
let result = echo(
&app,
&[
("hx-request", "true"),
("hx-target", "user-table"),
("hx-trigger", "sort-username"),
("hx-current-url", "/admin/users"),
],
)
.await;
assert_eq!(result["is_htmx"], true);
assert_eq!(result["is_boosted"], false);
assert_eq!(result["is_history_restore"], false);
assert_eq!(result["target"], "user-table");
assert_eq!(result["trigger_id"], "sort-username");
assert_eq!(result["current_url"], "/admin/users");
assert!(result["trigger_name"].is_null());
assert!(result["prompt"].is_null());
}

View file

@ -1,233 +0,0 @@
use pagetop::prelude::*;
use pagetop_htmx::prelude::*;
// Forces an effective language different from the default negotiated one (en-US, with no `?lang` in
// the request), so that `Context::route()` decides to propagate `?lang=...` in local routes.
fn cx_with_lang(lang: &str) -> Context {
Context::new(None).with_langid(&Locale::resolve(lang))
}
fn header<'a>(response: &'a web::Response, name: &str) -> Option<&'a str> {
response.headers().get(name)?.to_str().ok()
}
// **< HtmxResponse::new() / empty() >**************************************************************
#[pagetop::test]
async fn new_renders_the_given_markup_with_an_html_content_type() {
let response = HtmxResponse::new(html! { li #item-42 { "New item" } }).into_response();
assert_eq!(
header(&response, "content-type"),
Some("text/html; charset=utf-8")
);
let body = web::test::read_body_text(response).await;
assert_eq!(body, r#"<li id="item-42">New item</li>"#);
}
#[pagetop::test]
async fn empty_has_no_body_but_keeps_the_html_content_type() {
let response = HtmxResponse::empty().into_response();
assert_eq!(
header(&response, "content-type"),
Some("text/html; charset=utf-8")
);
let body = web::test::read_body_text(response).await;
assert_eq!(body, "");
}
// **< location() / location_json() >***************************************************************
#[pagetop::test]
async fn location_sets_hx_location_from_a_route_path() {
let response = HtmxResponse::empty().location("/items").into_response();
assert_eq!(header(&response, "hx-location"), Some("/items"));
}
#[pagetop::test]
async fn location_preserves_lang_when_built_from_context_route() {
let cx = cx_with_lang("es-ES");
let response = HtmxResponse::empty()
.location(cx.route("/items"))
.into_response();
assert_eq!(header(&response, "hx-location"), Some("/items?lang=es-ES"));
}
#[pagetop::test]
async fn location_json_sets_hx_location_when_the_json_is_syntactically_valid() {
let json = r##"{"path": "/items", "target": "#content"}"##;
let response = HtmxResponse::empty().location_json(json).into_response();
assert_eq!(header(&response, "hx-location"), Some(json));
}
#[pagetop::test]
async fn location_json_discards_the_header_when_the_json_is_malformed() {
// Missing closing brace: invalid JSON. The header must be silently dropped rather than sending
// a broken payload to the client.
let response = HtmxResponse::empty()
.location_json(r##"{"path": "/items""##)
.into_response();
assert_eq!(header(&response, "hx-location"), None);
}
#[pagetop::test]
async fn location_json_only_validates_syntax_not_the_expected_keys() {
// A key HTMX does not recognize (`"tagret"` instead of `"target"`) is still valid JSON, so it
// passes this check; the mistake would only surface client-side. This documents that limit.
let json = r##"{"path": "/items", "tagret": "#content"}"##;
let response = HtmxResponse::empty().location_json(json).into_response();
assert_eq!(header(&response, "hx-location"), Some(json));
}
// **< push_url() / replace_url() / redirect() >****************************************************
#[pagetop::test]
async fn push_url_sets_hx_push_url_from_a_route_path() {
let cx = cx_with_lang("es-ES");
let response = HtmxResponse::empty()
.push_url(cx.route("/items"))
.into_response();
assert_eq!(header(&response, "hx-push-url"), Some("/items?lang=es-ES"));
}
#[pagetop::test]
async fn push_url_accepts_the_false_sentinel_to_disable_pushing() {
let response = HtmxResponse::empty().push_url("false").into_response();
assert_eq!(header(&response, "hx-push-url"), Some("false"));
}
#[pagetop::test]
async fn replace_url_sets_hx_replace_url_from_a_route_path() {
let response = HtmxResponse::empty()
.replace_url("/items/42")
.into_response();
assert_eq!(header(&response, "hx-replace-url"), Some("/items/42"));
}
#[pagetop::test]
async fn redirect_sets_hx_redirect_from_a_route_path() {
let cx = cx_with_lang("es-ES");
let response = HtmxResponse::empty()
.redirect(cx.route("/items"))
.into_response();
assert_eq!(header(&response, "hx-redirect"), Some("/items?lang=es-ES"));
}
// **< refresh() / retarget() / reswap() / reselect() >*********************************************
#[pagetop::test]
async fn refresh_sets_hx_refresh_to_true() {
let response = HtmxResponse::empty().refresh().into_response();
assert_eq!(header(&response, "hx-refresh"), Some("true"));
}
#[pagetop::test]
async fn retarget_reswap_and_reselect_set_the_expected_headers() {
let response = HtmxResponse::empty()
.retarget("#message")
.reswap(hx::swap::BEFORE_END)
.reselect("#fragment")
.into_response();
assert_eq!(header(&response, "hx-retarget"), Some("#message"));
assert_eq!(header(&response, "hx-reswap"), Some("beforeend"));
assert_eq!(header(&response, "hx-reselect"), Some("#fragment"));
}
// **< trigger() / trigger_after_settle() / trigger_after_swap() >**********************************
#[pagetop::test]
async fn trigger_accepts_a_single_event_name() {
let response = HtmxResponse::empty().trigger("itemAdded").into_response();
assert_eq!(header(&response, "hx-trigger"), Some("itemAdded"));
}
#[pagetop::test]
async fn trigger_accepts_multiple_comma_separated_events() {
let response = HtmxResponse::empty()
.trigger("itemAdded, listUpdated")
.into_response();
assert_eq!(
header(&response, "hx-trigger"),
Some("itemAdded, listUpdated")
);
}
#[pagetop::test]
async fn trigger_accepts_a_json_payload_with_event_data() {
let json = r#"{"itemAdded": {"id": 42, "name": "Example"}}"#;
let response = HtmxResponse::empty().trigger(json).into_response();
assert_eq!(header(&response, "hx-trigger"), Some(json));
}
#[pagetop::test]
async fn trigger_after_settle_and_trigger_after_swap_use_their_own_headers() {
let response = HtmxResponse::empty()
.trigger_after_settle("settled")
.trigger_after_swap("swapped")
.into_response();
assert_eq!(
header(&response, "hx-trigger-after-settle"),
Some("settled")
);
assert_eq!(header(&response, "hx-trigger-after-swap"), Some("swapped"));
}
// **< Builder chaining behavior >******************************************************************
#[pagetop::test]
async fn chaining_several_methods_sets_all_their_headers_at_once() {
let response = HtmxResponse::new(html! { ul { li { "Item 1" } li { "Item 2" } } })
.retarget("#list")
.reswap(hx::swap::BEFORE_END)
.push_url("/items")
.trigger("itemAdded")
.into_response();
assert_eq!(header(&response, "hx-retarget"), Some("#list"));
assert_eq!(header(&response, "hx-reswap"), Some("beforeend"));
assert_eq!(header(&response, "hx-push-url"), Some("/items"));
assert_eq!(header(&response, "hx-trigger"), Some("itemAdded"));
}
#[pagetop::test]
async fn calling_the_same_method_twice_the_last_call_wins() {
let response = HtmxResponse::empty()
.trigger("first")
.trigger("second")
.into_response();
assert_eq!(header(&response, "hx-trigger"), Some("second"));
}
#[pagetop::test]
async fn a_header_value_with_control_characters_is_silently_discarded() {
// `\n` is forbidden in an HTTP header value; `set_header()` must drop it rather than panicking
// or producing a malformed response.
let response = HtmxResponse::empty().retarget("foo\nbar").into_response();
assert_eq!(header(&response, "hx-retarget"), None);
}

View file

@ -1,7 +1,5 @@
//! Componentes nativos proporcionados por PageTop. //! Componentes nativos proporcionados por PageTop.
pub mod layout;
mod block; mod block;
pub use block::Block; pub use block::Block;

View file

@ -1,7 +0,0 @@
//! Definiciones para la composición de documentos ([`Region`] y [`Template`]).
mod region;
pub use region::Region;
mod template;
pub use template::Template;

View file

@ -1,111 +0,0 @@
use crate::prelude::*;
use std::fmt;
/// Componente que renderiza una región del `<body>`.
///
/// No recibe ningún contenido de quien lo construye. Lo obtiene directamente del [`Context`] en el
/// momento de renderizarse (ver [`Context::render_region()`]). Si la región no tiene contenido, no
/// se renderiza nada.
///
/// Si un tema necesita maquetar una región determinada de forma distinta, puede capturar este
/// componente en [`Theme::handle_component()`](crate::core::theme::Theme::handle_component) y hacer
/// [`downcast_ref()`](crate::core::AnyCast::downcast_ref) sobre el [`RegionRef`] que devuelve
/// [`Self::region()`], para compararlo con la variante deseada.
///
/// Como cualquier otro componente, participa también en el despacho de las
/// [acciones de componentes](crate::base::action::component) para que otras extensiones puedan
/// intervenir en su renderizado.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// struct Sidebar;
///
/// impl RegionName for Sidebar {
/// fn name(&self) -> &'static str {
/// "sidebar"
/// }
///
/// fn label(&self) -> L10n {
/// L10n::n("Sidebar")
/// }
/// }
///
/// let header = layout::Region::header();
/// let sidebar = layout::Region::of(&Sidebar);
/// ```
#[derive(Clone, Getters)]
pub struct Region {
/// Devuelve la región subyacente.
#[getters(copy)]
region: RegionRef,
}
impl fmt::Debug for Region {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Region")
.field("region", &self.region().name())
.finish()
}
}
impl Default for Region {
fn default() -> Self {
Region {
region: &CoreRegion::Content,
}
}
}
#[async_trait]
impl Component for Region {
fn new() -> Self {
Self::default()
}
/// Devuelve el nombre de la región subyacente como identificador del componente.
fn id(&self) -> Option<String> {
Some(self.region().name().to_owned())
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let name = self.region().name();
let content = cx.render_region(self.region()).await;
Ok(html! {
@if !content.is_empty() {
div
id=[self.id()]
class=(util::join!("region region-", name))
role="region"
aria-label=[self.region().label().lookup(cx)]
{
(content)
}
}
})
}
}
impl Region {
/// Define el componente que renderizará [`CoreRegion::Header`].
pub fn header() -> Self {
Region {
region: &CoreRegion::Header,
}
}
/// Define el componente que renderizará [`CoreRegion::Footer`].
pub fn footer() -> Self {
Region {
region: &CoreRegion::Footer,
}
}
/// Define el componente que renderizará la región indicada.
pub fn of(region: RegionRef) -> Self {
Region { region }
}
}

View file

@ -1,81 +0,0 @@
use crate::prelude::*;
use std::fmt;
/// Componente que renderiza el cuerpo de una plantilla de regiones.
///
/// La composición por defecto usa el componente [`Region`](crate::base::component::layout::Region)
/// para mostrar, en este orden, las regiones [`CoreRegion::Header`], [`CoreRegion::Content`] y
/// [`CoreRegion::Footer`].
///
/// No incluye las regiones reservadas
/// [`ReservedRegion::PageTop`](crate::response::ReservedRegion::PageTop) y
/// [`ReservedRegion::PageBottom`](crate::response::ReservedRegion::PageBottom) porque el propio
/// [`Page::render()`](crate::response::Page::render) las añade antes y después del resultado de
/// [`Theme::render_page_body()`](crate::core::theme::Theme::render_page_body) para que se
/// rendericen siempre, independientemente de la plantilla que se use.
///
/// Si un tema necesita maquetar una plantilla determinada de forma distinta, puede capturar este
/// componente en [`Theme::handle_component()`](crate::core::theme::Theme::handle_component) y hacer
/// [`downcast_ref()`](crate::core::AnyCast::downcast_ref) sobre el [`TemplateRef`] que devuelve
/// [`Self::template()`], para compararlo con la variante deseada.
///
/// Como cualquier otro componente, participa también en el despacho de las
/// [acciones de componentes](crate::base::action::component) para que otras extensiones puedan
/// intervenir en su renderizado.
#[derive(Clone, Getters)]
pub struct Template {
/// Devuelve la plantilla subyacente.
#[getters(copy)]
template: TemplateRef,
}
impl fmt::Debug for Template {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Template")
.field("template", &self.template().name())
.finish()
}
}
impl Default for Template {
fn default() -> Self {
Template {
template: &CoreTemplate::Standard,
}
}
}
#[async_trait]
impl Component for Template {
fn new() -> Self {
Self::default()
}
/// Devuelve el nombre de la plantilla subyacente como identificador del componente.
fn id(&self) -> Option<String> {
Some(self.template().name().to_owned())
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(html! {
(layout::Region::header().render(cx).await)
(layout::Region::default().render(cx).await)
(layout::Region::footer().render(cx).await)
})
}
}
impl Template {
/// Define el componente que renderizará [`CoreTemplate::Admin`].
pub fn admin() -> Self {
Template {
template: &CoreTemplate::Admin,
}
}
/// Define el componente que renderizará la plantilla indicada.
pub fn of(template: TemplateRef) -> Self {
Template { template }
}
}

View file

@ -25,7 +25,7 @@ impl Theme for Basic {
.with_weight(-99), .with_weight(-99),
)) ))
.alter_child_in( .alter_child_in(
&CoreRegion::Footer, &DefaultRegions::Footer,
ChildOp::AddIfEmpty(PoweredBy::new().into()), ChildOp::AddIfEmpty(PoweredBy::new().into()),
); );
} }

View file

@ -2,8 +2,7 @@ use crate::auth::CurrentUser;
use crate::core::TypeInfo; use crate::core::TypeInfo;
use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage}; use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage};
use crate::core::theme::all::DEFAULT_THEME; use crate::core::theme::all::DEFAULT_THEME;
use crate::core::theme::{ChildrenInRegions, CoreRegion, CoreTemplate}; use crate::core::theme::{ChildrenInRegions, DefaultRegions, RegionRef, TemplateRef, ThemeRef};
use crate::core::theme::{RegionRef, TemplateRef, ThemeRef};
use crate::html::{Assets, Favicon, JavaScript, Preload, StyleSheet}; use crate::html::{Assets, Favicon, JavaScript, Preload, StyleSheet};
use crate::html::{Markup, Props, PropsOp, RoutePath, html}; use crate::html::{Markup, Props, PropsOp, RoutePath, html};
use crate::locale::L10n; use crate::locale::L10n;
@ -78,7 +77,7 @@ pub enum ContextError {
/// fn prepare_context<C: Contextual>(cx: C) -> C { /// fn prepare_context<C: Contextual>(cx: C) -> C {
/// cx.with_langid(&Locale::resolve("es-ES")) /// cx.with_langid(&Locale::resolve("es-ES"))
/// .with_theme(&Aliner) /// .with_theme(&Aliner)
/// .with_template(&CoreTemplate::Standard) /// .with_template(&DefaultTemplates::Standard)
/// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) /// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico"))))
/// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/app.css"))) /// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/app.css")))
/// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/app.js"))) /// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/app.js")))
@ -138,7 +137,7 @@ pub trait Contextual: LangId {
/// Añade un componente o aplica una operación [`ChildOp`] en una región específica del /// Añade un componente o aplica una operación [`ChildOp`] en una región específica del
/// documento. /// documento.
#[builder_fn] #[builder_fn]
fn with_child_in(self, region: RegionRef, op: impl Into<ChildOp>) -> Self; fn with_child_in(self, region_ref: RegionRef, op: impl Into<ChildOp>) -> Self;
// **< Contextual GETTERS >********************************************************************* // **< Contextual GETTERS >*********************************************************************
@ -237,6 +236,28 @@ pub trait Contextual: LangId {
fn remove_param(&mut self, key: &'static str) -> bool; fn remove_param(&mut self, key: &'static str) -> bool;
} }
// Cómo obtener la plantilla activa del contexto: la del tema (por defecto o de administración), o
// una fijada explícitamente. Se resuelve contra el tema activo al leer `Context::template()`, no al
// asignarla, para que un cambio de tema posterior con `with_theme()` se refleje automáticamente.
enum TemplateSource {
// Plantilla por defecto.
Default,
// Plantilla de administración del tema activo.
Admin,
// Plantilla fijada explícitamente con `with_template()`.
Explicit(TemplateRef),
}
impl TemplateSource {
fn resolve(&self, theme: ThemeRef) -> TemplateRef {
match self {
Self::Default => theme.default_template(),
Self::Admin => theme.admin_template(),
Self::Explicit(template) => *template,
}
}
}
/// Implementa un **contexto de renderizado** para un documento HTML. /// Implementa un **contexto de renderizado** para un documento HTML.
/// ///
/// Se crea una sola vez por petición usando [`Context::new()`] (típicamente a través de /// Se crea una sola vez por petición usando [`Context::new()`] (típicamente a través de
@ -257,8 +278,9 @@ pub trait Contextual: LangId {
/// identificadores HTML únicos por tipo de componente. /// identificadores HTML únicos por tipo de componente.
/// - [`push_message()`](Self::push_message)/[`messages()`](Self::messages) para acumular /// - [`push_message()`](Self::push_message)/[`messages()`](Self::messages) para acumular
/// [`StatusMessage`] que mostrar en algún momento del renderizado. /// [`StatusMessage`] que mostrar en algún momento del renderizado.
/// - [`render_assets()`](Self::render_assets)/[`render_region()`](Self::render_region), usados /// - [`render_assets()`](Self::render_assets)/[`render_region_named()`](Self::render_region_named),
/// internamente por [`Page`](crate::response::Page) para producir el HTML final del documento. /// usados internamente por [`Page`](crate::response::Page) para producir el HTML final del
/// documento.
/// ///
/// # Ejemplos /// # Ejemplos
/// ///
@ -310,7 +332,7 @@ pub struct Context {
locale : RequestLocale, // Idioma asociado a la petición. locale : RequestLocale, // Idioma asociado a la petición.
current_user: CurrentUser, // Identidad del usuario actual. current_user: CurrentUser, // Identidad del usuario actual.
theme : ThemeRef, // Referencia al tema usado para renderizar. theme : ThemeRef, // Referencia al tema usado para renderizar.
template : TemplateRef, // Plantilla usada para renderizar. template : TemplateSource, // Plantilla usada para renderizar.
favicon : Option<Favicon>, // Favicon, si se ha definido. favicon : Option<Favicon>, // Favicon, si se ha definido.
preloads : Assets<Preload>, // Recursos para precarga. preloads : Assets<Preload>, // Recursos para precarga.
stylesheets : Assets<StyleSheet>, // Hojas de estilo CSS. stylesheets : Assets<StyleSheet>, // Hojas de estilo CSS.
@ -342,7 +364,7 @@ impl Context {
locale, locale,
current_user, current_user,
theme : *DEFAULT_THEME, theme : *DEFAULT_THEME,
template : &CoreTemplate::Standard, template : TemplateSource::Default,
favicon : None, favicon : None,
preloads : Assets::<Preload>::new(), preloads : Assets::<Preload>::new(),
stylesheets: Assets::<StyleSheet>::new(), stylesheets: Assets::<StyleSheet>::new(),
@ -364,6 +386,13 @@ impl Context {
.unwrap_or(CurrentUser::Anonymous) .unwrap_or(CurrentUser::Anonymous)
} }
// Fuerza la plantilla de administración del tema activo (usada por `Page::admin()`). Se
// resuelve dinámicamente contra `self.theme`, igual que `TemplateSource::Default`, así que
// sigue reflejando cualquier cambio de tema posterior con `with_theme()`.
pub(crate) fn use_admin_template(&mut self) {
self.template = TemplateSource::Admin;
}
// **< Context RENDER >************************************************************************* // **< Context RENDER >*************************************************************************
/// Renderiza los recursos del contexto. /// Renderiza los recursos del contexto.
@ -397,13 +426,9 @@ impl Context {
} }
/// Renderiza los componentes de una región. /// Renderiza los componentes de una región.
/// pub async fn render_region_named(&mut self, region_name: &str) -> Markup {
/// Combina los componentes registrados para esta región en la petición actual con los
/// prototipos globales añadidos vía [`InRegion`](crate::core::theme::InRegion) (comunes o
/// específicos del tema activo).
pub async fn render_region(&mut self, region: RegionRef) -> Markup {
self.regions self.regions
.assemble_region(self.theme, region) .assemble_region(self.theme, region_name)
.render(self) .render(self)
.await .await
} }
@ -543,7 +568,7 @@ impl Contextual for Context {
#[builder_fn] #[builder_fn]
fn with_template(mut self, template: TemplateRef) -> Self { fn with_template(mut self, template: TemplateRef) -> Self {
self.template = template; self.template = TemplateSource::Explicit(template);
self self
} }
@ -599,13 +624,14 @@ impl Contextual for Context {
#[builder_fn] #[builder_fn]
fn with_child(mut self, op: impl Into<ChildOp>) -> Self { fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
self.regions.alter_child_in(&CoreRegion::Content, op.into()); self.regions
.alter_child_in(&DefaultRegions::Content, op.into());
self self
} }
#[builder_fn] #[builder_fn]
fn with_child_in(mut self, region: RegionRef, op: impl Into<ChildOp>) -> Self { fn with_child_in(mut self, region_ref: RegionRef, op: impl Into<ChildOp>) -> Self {
self.regions.alter_child_in(region, op.into()); self.regions.alter_child_in(region_ref, op.into());
self self
} }
@ -624,7 +650,7 @@ impl Contextual for Context {
} }
fn template(&self) -> TemplateRef { fn template(&self) -> TemplateRef {
self.template self.template.resolve(self.theme)
} }
fn param<T: 'static>(&self, key: &'static str) -> Result<&T, ContextError> { fn param<T: 'static>(&self, key: &'static str) -> Result<&T, ContextError> {

View file

@ -144,15 +144,6 @@ impl Route {
pub fn resolve(&self, cx: &Context) -> RoutePath { pub fn resolve(&self, cx: &Context) -> RoutePath {
(self.0)(cx) (self.0)(cx)
} }
/// Como [`resolve()`](Self::resolve), pero devuelve `None` cuando la ruta calculada está vacía.
///
/// Útil para atributos HTML opcionales (por ejemplo `href`) que no deben renderizarse si la
/// ruta resultante no tiene contenido.
pub fn try_resolve(&self, cx: &Context) -> Option<RoutePath> {
let route = self.resolve(cx);
(!route.is_empty()).then_some(route)
}
} }
impl fmt::Debug for Route { impl fmt::Debug for Route {

View file

@ -1,15 +1,14 @@
//! API para añadir y gestionar nuevos temas. //! API para añadir y gestionar nuevos temas.
//! //!
//! Un tema es la *piel* de la aplicación: define estilos, tipografías, espaciados o comportamientos //! Un tema es la *piel* de la aplicación: define estilos, tipografías, espaciados o comportamientos
//! interactivos. Usa plantillas ([`Template`](crate::base::component::layout::Template)) para //! interactivos. Para ello utiliza plantillas ([`Template`]) que describen cómo maquetar el cuerpo
//! maquetar los contenidos en base a regiones ([`Region`](crate::base::component::layout::Region)). //! del documento a partir de regiones ([`Region`]). Cada región es un contenedor lógico
//! Cada región es un contenedor lógico identificado por un nombre para agrupar y renderizar //! identificado por un nombre para agrupar y renderizar componentes.
//! componentes.
//! //!
//! Una página ([`Page`](crate::response::Page)) es un documento HTML completo. Implementa //! Una página ([`Page`](crate::response::Page)) es un documento HTML completo. Implementa
//! [`Contextual`](crate::core::component::Contextual) para gestionar su propio //! [`Contextual`](crate::core::component::Contextual) para gestionar su propio [`Context`], donde
//! [`Context`](crate::core::component::Context), donde mantiene el tema activo, la plantilla //! mantiene el tema activo, la plantilla seleccionada y los componentes asociados a cada región a
//! seleccionada y los componentes asociados a cada región a renderizar. //! renderizar.
//! //!
//! Además, PageTop permite crear **temas hijo** que refinan el comportamiento de su tema padre. Un //! Además, PageTop permite crear **temas hijo** que refinan el comportamiento de su tema padre. Un
//! tema hijo hereda automáticamente todos los métodos del padre y puede sobrescribirlos //! tema hijo hereda automáticamente todos los métodos del padre y puede sobrescribirlos
@ -27,34 +26,24 @@
//! Un tema puede personalizarse en tres pasos, cada uno necesario sólo si lo que ofrece PageTop por //! Un tema puede personalizarse en tres pasos, cada uno necesario sólo si lo que ofrece PageTop por
//! defecto no basta: //! defecto no basta:
//! //!
//! 1. **Definir regiones nuevas**. Por defecto, PageTop define [`CoreRegion`] (`Header`, `Content`, //! 1. **Definir regiones propias**. Por defecto PageTop define [`DefaultRegions`], con tres
//! `Footer`) como las regiones de plantilla que se asumen siempre disponibles, y //! regiones (`Header`, `Content` y `Footer`) que usan la implementación por defecto de
//! [`ReservedRegion`](crate::response::ReservedRegion) (`PageTop`, `PageBottom`) como las //! [`Region::render()`]. Un tema puede definir un *enum* propio que implemente [`Region`] para
//! regiones reservadas que se renderizan al margen de cualquier plantilla. Un tema puede definir //! exponer sus propias regiones (por ejemplo, una barra lateral) o para cambiar cómo se muestra
//! su propio *enum* que implemente [`RegionName`] para **añadir** nuevas regiones que PageTop no //! el contenido de una región ya existente (identificada por su nombre).
//! ofrece (por ejemplo, una barra lateral). No es necesario redefinir las de [`CoreRegion`] ni //! 2. **Definir plantillas propias**. Por defecto existe [`DefaultTemplates`], con dos plantillas
//! las de [`ReservedRegion`](crate::response::ReservedRegion), que ya existen y se asume que //! (`Standard` y `Admin`) que usan la implementación por defecto de [`Template::render()`] para
//! cualquier tema respeta. //! renderizar [`DefaultRegions::Header`], [`DefaultRegions::Content`] y
//! 2. **Definir plantillas nuevas**. Por defecto existe [`CoreTemplate`], con las plantillas //! [`DefaultRegions::Footer`], en este orden. Un tema puede definir un *enum* propio que
//! `Standard` y `Admin` que usan `Page::new()` y `Page::admin()` respectivamente, y que son //! implemente [`Template`] para crear nuevas plantillas, maquetar las regiones de otra forma,
//! siempre las mismas: no hay un método de `Theme` para elegir una plantilla predeterminada //! cambiar su orden o envolverlas en contenedores adicionales.
//! distinta. Un tema puede definir su propio *enum* que implemente [`TemplateName`] para //! 3. **Elegir las plantillas predeterminadas**. Por un lado, la plantilla por defecto vía
//! **añadir** plantillas que PageTop no ofrece, y no para redefinir `Standard`/`Admin`. //! [`Theme::default_template()`] y, por otro, la plantilla para las páginas de administración,
//! 3. **Cambiar cómo se renderiza** una región, una plantilla o un componente ya existente, se hace //! [`Theme::admin_template()`]. De esta forma, las páginas creadas con `Page::new()` usarán
//! capturando el componente ([`Region`](crate::base::component::layout::Region) o //! automáticamente la plantilla `default_template()` del tema activo, y las páginas creadas con
//! [`Template`](crate::base::component::layout::Template), o el componente que sea) en //! `Page::admin()` usarán la de `admin_template()`, sin tener que llamar manualmente a
//! [`Theme::handle_component()`]. En el caso de regiones y plantillas, para distinguir *qué* //! [`with_template()`](crate::core::component::Contextual::with_template). Un tema que no
//! región o plantilla concreta envuelve el componente, sin comparar cadenas, basta con encadenar //! sobrescriba estos métodos sigue usando las plantillas por defecto de PageTop.
//! el *getter* correspondiente
//! ([`Region::region()`](crate::base::component::layout::Region::region) o
//! [`Template::template()`](crate::base::component::layout::Template::template)) con
//! [`AnyCast::downcast_ref()`](crate::core::AnyCast::downcast_ref) hacia el tipo concreto (por
//! ejemplo, [`CoreTemplate`] o el propio *enum* del tema). `pagetop-bootsier` hace exactamente
//! esto para maquetar `Standard` y `Admin` de forma distinta, sin necesitar sus propias
//! variantes de plantilla.
//!
//! Para forzar una plantilla completamente distinta en una página concreta, se puede llamar
//! manualmente a [`with_template()`](crate::core::component::Contextual::with_template).
//! //!
//! Las páginas de error (403, 404, y otros errores fatales) no tienen una plantilla propia: se //! Las páginas de error (403, 404, y otros errores fatales) no tienen una plantilla propia: se
//! renderizan con la plantilla ya activa en la página, para que el usuario no pierda el contexto de //! renderizan con la plantilla ya activa en la página, para que el usuario no pierda el contexto de
@ -62,8 +51,9 @@
//! [`Theme::error_403()`], [`Theme::error_404()`] o [`Theme::error_fatal()`], sin necesidad de una //! [`Theme::error_403()`], [`Theme::error_404()`] o [`Theme::error_fatal()`], sin necesidad de una
//! plantilla distinta. //! plantilla distinta.
//! //!
//! El resto del comportamiento de un tema (por ejemplo, el renderizado del `<head>`) se sobrescribe //! El resto del comportamiento de un tema (renderizado del `<head>`, o intervención en el
//! de forma independiente de estos tres pasos y no es necesario para tener un tema funcional. //! renderizado de componentes concretos con [`Theme::handle_component()`]) se sobrescribe de forma
//! independiente de estos tres pasos y no es necesario para tener un tema funcional.
//! //!
//! # Componentes que se procesan en todas las páginas //! # Componentes que se procesan en todas las páginas
//! //!
@ -79,7 +69,7 @@
//! //!
//! ```rust,no_run //! ```rust,no_run
//! # use pagetop::prelude::*; //! # use pagetop::prelude::*;
//! InRegion::Global(&CoreRegion::Footer).add(PoweredBy::new()); //! InRegion::Global(&DefaultRegions::Footer).add(PoweredBy::new());
//! ``` //! ```
//! //!
//! El componente se guarda como **prototipo**: cada página recibe un clon fresco en el momento del //! El componente se guarda como **prototipo**: cada página recibe un clon fresco en el momento del
@ -92,66 +82,90 @@
//! sola vez y que decida por sí mismo cuándo mostrarse, por ejemplo según la ruta de la petición o //! sola vez y que decida por sí mismo cuándo mostrarse, por ejemplo según la ruta de la petición o
//! si el usuario actual está autenticado. //! si el usuario actual está autenticado.
use crate::AutoDefault; use crate::async_trait;
use crate::core::AnyInfo; use crate::core::component::Context;
use crate::html::{Markup, html};
use crate::locale::L10n; use crate::locale::L10n;
use crate::{AutoDefault, util};
// **< RegionName >********************************************************************************* // **< Region >*************************************************************************************
/// Interfaz común para las regiones lógicas del `<body>`. /// Interfaz común para las regiones lógicas de un documento.
/// ///
/// Una `RegionName` representa un contenedor lógico identificado por un nombre de región. Su /// Una `Region` representa un contenedor lógico identificado por un nombre de región. Su contenido
/// contenido se obtiene del [`Context`](crate::core::component::Context), donde los componentes /// se obtiene del [`Context`], donde los componentes suelen registrarse usando implementaciones de
/// suelen registrarse usando implementaciones de métodos como /// métodos como [`Contextual::with_child_in()`](crate::core::component::Contextual::with_child_in).
/// [`Contextual::with_child_in()`](crate::core::component::Contextual::with_child_in).
/// ///
/// El contenido de una región viene determinado únicamente por su nombre, no por su tipo. Distintas /// El contenido de una región viene determinado únicamente por su nombre, no por su tipo. Distintas
/// implementaciones de [`RegionName`] que devuelvan el mismo nombre comparten el mismo conjunto de /// implementaciones de [`Region`] que devuelvan el mismo nombre compartirán el mismo conjunto de
/// componentes registrados en el [`Context`](crate::core::component::Context). Un *enum* propio que /// componentes registrados en el [`Context`], aunque cada región puede renderizar ese contenido de
/// implemente [`RegionName`] está pensado para **añadir** regiones que PageTop no ofrece (con un /// forma diferente. Por ejemplo, [`DefaultRegions::Header`] y `BootsierRegions::Header` mostrarían
/// nombre propio que no colisione con los de [`CoreRegion`] o /// los mismos componentes si ambas devuelven el nombre `"header"`, pero podrían maquetarse de
/// [`ReservedRegion`](crate::response::ReservedRegion)). /// manera distinta.
/// ///
/// El tema decide qué regiones mostrar en el `<body>`, normalmente usando una plantilla /// El tema decide qué regiones mostrar en el cuerpo del documento, normalmente usando una plantilla
/// ([`TemplateName`]) al renderizar la página ([`Page`](crate::response::Page)). /// ([`Template`]) al renderizar la página ([`Page`](crate::response::Page)).
/// #[async_trait]
/// Requiere [`AnyInfo`] para que un [`RegionRef`] pueda recuperarse mediante pub trait Region: Send + Sync {
/// [`AnyCast::downcast_ref()`](crate::core::AnyCast::downcast_ref) hacia su tipo concreto (por
/// ejemplo, para que un tema distinga en
/// [`Theme::handle_component()`](crate::core::theme::Theme::handle_component) qué variante
/// concreta está renderizando el componente [`Region`](crate::base::component::layout::Region)).
pub trait RegionName: Send + Sync + AnyInfo {
/// Devuelve el nombre de la región. /// Devuelve el nombre de la región.
/// ///
/// Este nombre es el identificador lógico de la región y se usa como clave en el /// Este nombre es el identificador lógico de la región y se usa como clave en el [`Context`]
/// [`Context`](crate::core::component::Context) para recuperar y renderizar el contenido /// para recuperar y renderizar el contenido registrado bajo ese nombre. Cualquier
/// registrado bajo ese nombre. Cualquier implementación de [`RegionName`] que devuelva el mismo /// implementación de [`Region`] que devuelva el mismo nombre compartirá el mismo conjunto de
/// nombre compartirá el mismo conjunto de componentes. /// componentes.
///
/// En la implementación predeterminada de [`Self::render()`] también se utiliza para construir
/// las clases del contenedor de la región (`"region region-<name>"`).
fn name(&self) -> &'static str; fn name(&self) -> &'static str;
/// Devuelve un *texto localizado* como etiqueta de accesibilidad asociada a la región. /// Devuelve un *texto localizado* como etiqueta de accesibilidad asociada a la región.
/// ///
/// En la implementación predeterminada de [`Region`](crate::base::component::layout::Region), /// En la implementación predeterminada de [`Self::render()`], este valor se usa como
/// este valor se usa como `aria-label` del contenedor de la región. /// `aria-label` del contenedor de la región.
fn label(&self) -> L10n; fn label(&self) -> L10n;
/// Renderiza el contenedor de la región.
///
/// Por defecto, recupera del [`Context`] el contenido de la región y, si no está vacío, lo
/// envuelve en un `<div>` con clases `"region region-<name>"` y un `aria-label` basado en el
/// *texto localizado* de la etiqueta asociada a la región:
///
/// ```html
/// <div class="region region-<name>" role="region" aria-label="<label>">
/// <!-- Componentes de la región "name" -->
/// </div>
/// ```
///
/// Se puede sobrescribir este método para modificar la estructura del contenedor, las clases
/// utilizadas o la semántica del marcado generado para cada región.
async fn render(&self, cx: &mut Context) -> Markup {
html! {
@let region = cx.render_region_named(self.name()).await;
@if !region.is_empty() {
div
class=(util::join!("region region-", self.name()))
role="region"
aria-label=[self.label().lookup(cx)]
{
(region)
}
}
}
}
} }
/// Referencia estática a una región. /// Referencia estática a una región.
pub type RegionRef = &'static dyn RegionName; pub type RegionRef = &'static dyn Region;
// **< CoreRegion >********************************************************************************* // **< DefaultRegions >*****************************************************************************
/// Regiones básicas que PageTop proporciona por defecto. /// Regiones básicas que PageTop proporciona por defecto.
/// ///
/// Comparten sus nombres (`"header"`, `"content"`, `"footer"`) con otras regiones que implementen /// Estas regiones comparten sus nombres (`"header"`, `"content"`, `"footer"`) con cualquier región
/// [`RegionName`], por lo que comparten también el contenido registrado bajo esos nombres. Por /// equivalente definida por otros temas, por lo que comparten también el contenido registrado bajo
/// defecto, son las regiones usadas por [`Template`](crate::base::component::layout::Template). /// esos nombres.
///
/// A estas regiones hay que sumar también las regiones internas reservadas por
/// [`ReservedRegion`](crate::response::ReservedRegion) (`"page-top"` y `"page-bottom"`), que
/// [`Page::render()`](crate::response::Page::render) renderiza en cualquier caso.
#[derive(AutoDefault)] #[derive(AutoDefault)]
pub enum CoreRegion { pub enum DefaultRegions {
/// Región estándar para la **cabecera** del documento, de nombre `"header"`. /// Región estándar para la **cabecera** del documento, de nombre `"header"`.
/// ///
/// Suele emplearse para mostrar un logotipo, navegación principal, barras superiores, etc. /// Suele emplearse para mostrar un logotipo, navegación principal, barras superiores, etc.
@ -170,7 +184,7 @@ pub enum CoreRegion {
Footer, Footer,
} }
impl RegionName for CoreRegion { impl Region for DefaultRegions {
#[inline] #[inline]
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
match self { match self {
@ -190,64 +204,63 @@ impl RegionName for CoreRegion {
} }
} }
// **< TemplateName >******************************************************************************* // **< Template >***********************************************************************************
/// Interfaz común para las plantillas lógicas de una página. /// Interfaz común para definir plantillas de contenido.
/// ///
/// Representa una variante identificada por un nombre. Un tema puede usar este nombre para decidir /// Una `Template` puede proporcionar una o más variantes para decidir la composición del `<body>`
/// la composición del cuerpo de una página ([`Page`](crate::response::Page)), es decir, qué /// de una página ([`Page`](crate::response::Page)). El tema utiliza esta información para
/// regiones ([`RegionName`]) renderizar y en qué orden. /// determinar qué regiones ([`Region`]) deben renderizarse y en qué orden.
/// #[async_trait]
/// Requiere [`AnyInfo`] por el mismo motivo que [`RegionName`], para que un [`TemplateRef`] pueda pub trait Template: Send + Sync {
/// recuperarse mediante [`AnyCast::downcast_ref()`](crate::core::AnyCast::downcast_ref) hacia su /// Renderiza el contenido de la plantilla.
/// tipo concreto (por ejemplo, para que un tema distinga en ///
/// [`Theme::handle_component()`](crate::core::theme::Theme::handle_component) qué variante concreta /// Por defecto, renderiza las regiones básicas de [`DefaultRegions`] en este orden:
/// está renderizando el componente [`Template`](crate::base::component::layout::Template)). /// [`DefaultRegions::Header`], [`DefaultRegions::Content`] y [`DefaultRegions::Footer`].
pub trait TemplateName: Send + Sync + AnyInfo { ///
/// Devuelve el nombre de la plantilla. /// Se puede sobrescribir este método para:
fn name(&self) -> &'static str; ///
/// - Cambiar el conjunto de regiones que se renderizan según variantes de la plantilla.
/// Devuelve un *texto localizado* como etiqueta descriptiva de la plantilla. /// - Alterar el orden de dichas regiones.
fn label(&self) -> L10n; /// - Envolver las regiones en contenedores adicionales.
/// - Implementar distribuciones específicas (por ejemplo, con barras laterales).
///
/// Este método se invoca normalmente desde [`Theme::render_page_body()`] para generar el
/// contenido del `<body>` de una página según la plantilla devuelta por el contexto de la
/// propia página ([`Contextual::template()`](crate::core::component::Contextual::template())).
async fn render(&self, cx: &mut Context) -> Markup {
html! {
(DefaultRegions::Header.render(cx).await)
(DefaultRegions::Content.render(cx).await)
(DefaultRegions::Footer.render(cx).await)
}
}
} }
/// Referencia estática a una plantilla. /// Referencia estática a una plantilla.
pub type TemplateRef = &'static dyn TemplateName; pub type TemplateRef = &'static dyn Template;
// **< CoreTemplate >******************************************************************************* // **< DefaultTemplates >***************************************************************************
/// Plantillas que PageTop proporciona por defecto. /// Plantillas que PageTop proporciona por defecto.
#[derive(AutoDefault)] #[derive(AutoDefault)]
pub enum CoreTemplate { pub enum DefaultTemplates {
/// Plantilla predeterminada, de nombre `"standard"`. /// Plantilla predeterminada.
/// ///
/// Se emplea cuando no se selecciona ninguna otra plantilla explícitamente. /// Utiliza la implementación por defecto de [`Template::render()`] y se emplea cuando no se
/// selecciona ninguna otra plantilla explícitamente.
#[default] #[default]
Standard, Standard,
/// Plantilla para la **interfaz de administración**, de nombre `"admin"`. /// Plantilla para la **interfaz de administración**.
/// ///
/// Se utiliza para páginas de administración o paneles de control. /// Se utiliza para páginas de administración o paneles de control. Por defecto utiliza la misma
/// implementación de [`Template::render()`] que [`Self::Standard`].
Admin, Admin,
} }
impl TemplateName for CoreTemplate { #[async_trait]
#[inline] impl Template for DefaultTemplates {}
fn name(&self) -> &'static str {
match self {
Self::Standard => "standard",
Self::Admin => "admin",
}
}
#[inline]
fn label(&self) -> L10n {
match self {
Self::Standard => L10n::l("template-standard"),
Self::Admin => L10n::l("template-admin"),
}
}
}
// **< render_component! >************************************************************************** // **< render_component! >**************************************************************************

View file

@ -1,9 +1,8 @@
use crate::async_trait; use crate::async_trait;
use crate::base::component::{Html, Intro, IntroOpening, layout}; use crate::base::component::{Html, Intro, IntroOpening};
use crate::core::component::{ChildOp, Component, ComponentError, ComponentRender}; use crate::core::component::{ChildOp, Component, ComponentError, Context, Contextual};
use crate::core::component::{Context, Contextual};
use crate::core::extension::Extension; use crate::core::extension::Extension;
use crate::core::theme::CoreRegion; use crate::core::theme::{DefaultRegions, DefaultTemplates, TemplateRef};
use crate::global; use crate::global;
use crate::html::{Markup, html}; use crate::html::{Markup, html};
use crate::locale::L10n; use crate::locale::L10n;
@ -14,10 +13,10 @@ use crate::web::http::StatusCode;
/// ///
/// Un tema es una [`Extension`](crate::core::extension::Extension) que define el aspecto general de /// Un tema es una [`Extension`](crate::core::extension::Extension) que define el aspecto general de
/// las páginas: cómo se renderiza el `<head>`, cómo se presenta el `<body>` usando plantillas /// las páginas: cómo se renderiza el `<head>`, cómo se presenta el `<body>` usando plantillas
/// ([`TemplateName`](crate::core::theme::TemplateName)) que maquetan regiones /// ([`Template`](crate::core::theme::Template)) que maquetan regiones
/// ([`RegionName`](crate::core::theme::RegionName)) y qué contenido mostrar en las páginas de /// ([`Region`](crate::core::theme::Region)) y qué contenido mostrar en las páginas de error. El
/// error. El contenido de cada región depende del [`Context`](crate::core::component::Context) y de /// contenido de cada región depende del [`Context`](crate::core::component::Context) y de su nombre
/// su nombre lógico. /// lógico.
/// ///
/// Todos los métodos de este *trait* tienen una implementación por defecto, por lo que pueden /// Todos los métodos de este *trait* tienen una implementación por defecto, por lo que pueden
/// sobrescribirse selectivamente para crear nuevos temas con comportamientos distintos a los /// sobrescribirse selectivamente para crear nuevos temas con comportamientos distintos a los
@ -60,6 +59,34 @@ pub trait Theme: Extension + Send + Sync {
None None
} }
/// Devuelve la plantilla ([`Template`](crate::core::theme::Template)) que el propio tema
/// propone como predeterminada.
///
/// Se utiliza al inicializar un [`Context`](crate::core::component::Context) o una página
/// ([`Page`](crate::response::Page)) por si no se elige ninguna otra plantilla con
/// [`Contextual::with_template()`](crate::core::component::Contextual::with_template).
///
/// La implementación por defecto devuelve la plantilla [`DefaultTemplates::Standard`] con una
/// estructura básica para la página. Los temas pueden sobrescribir este método para seleccionar
/// otra plantilla predeterminada o una plantilla propia.
#[inline]
fn default_template(&self) -> TemplateRef {
self.parent()
.map_or(&DefaultTemplates::Standard, |p| p.default_template())
}
/// Devuelve la plantilla ([`Template`](crate::core::theme::Template)) que el tema propone para
/// la interfaz de administración.
///
/// La implementación por defecto devuelve la plantilla [`DefaultTemplates::Admin`] con una
/// estructura básica para la interfaz de administración. Los temas pueden sobrescribir este
/// método para seleccionar otra plantilla predeterminada o una plantilla propia.
#[inline]
fn admin_template(&self) -> TemplateRef {
self.parent()
.map_or(&DefaultTemplates::Admin, |p| p.admin_template())
}
/// Acciones específicas del tema antes de renderizar el `<body>` de la página. /// Acciones específicas del tema antes de renderizar el `<body>` de la página.
/// ///
/// Es un buen lugar para inicializar o ajustar recursos en función del contexto de la página, /// Es un buen lugar para inicializar o ajustar recursos en función del contexto de la página,
@ -80,13 +107,14 @@ pub trait Theme: Extension + Send + Sync {
/// Renderiza el contenido del `<body>` de la página. /// Renderiza el contenido del `<body>` de la página.
/// ///
/// La implementación predeterminada delega en la plantilla asociada a la página, obtenida desde /// La implementación predeterminada delega en la plantilla asociada a la página, obtenida desde
/// su [`Context`](crate::core::component::Context), para componer el `<body>` a partir de las /// su [`Context`](crate::core::component::Context), y llama a
/// regiones. /// [`Template::render()`](crate::core::theme::Template::render) para componer el `<body>` a
/// partir de las regiones.
/// ///
/// Con la configuración por defecto, la plantilla estándar utiliza las regiones /// Con la configuración por defecto, la plantilla estándar utiliza las regiones
/// [`CoreRegion::Header`](crate::core::theme::CoreRegion::Header), /// [`DefaultRegions::Header`](crate::core::theme::DefaultRegions::Header),
/// [`CoreRegion::Content`](crate::core::theme::CoreRegion::Content) y /// [`DefaultRegions::Content`](crate::core::theme::DefaultRegions::Content) y
/// [`CoreRegion::Footer`](crate::core::theme::CoreRegion::Footer) en ese orden. /// [`DefaultRegions::Footer`](crate::core::theme::DefaultRegions::Footer) en ese orden.
/// ///
/// Los temas pueden sobrescribir este método para: /// Los temas pueden sobrescribir este método para:
/// ///
@ -99,8 +127,7 @@ pub trait Theme: Extension + Send + Sync {
if let Some(parent) = self.parent() { if let Some(parent) = self.parent() {
parent.render_page_body(page).await parent.render_page_body(page).await
} else { } else {
let template = page.template(); page.template().render(page.context()).await
layout::Template::of(template).render(page.context()).await
} }
} }
@ -220,16 +247,16 @@ pub trait Theme: Extension + Send + Sync {
/// Contenido predefinido para la página de error "*403 - Forbidden*" (acceso denegado). /// Contenido predefinido para la página de error "*403 - Forbidden*" (acceso denegado).
/// ///
/// Normalmente se renderiza con la plantilla ya activa en la página (por ejemplo /// Normalmente se renderiza con la plantilla predeterminada del tema (por defecto suele ser
/// [`CoreTemplate::Standard`](crate::core::theme::CoreTemplate::Standard)), para que el usuario /// [`DefaultTemplates::Standard`]), para que el usuario no pierda el contexto de navegación del
/// no pierda el contexto de navegación del sitio. Los temas pueden sobrescribir este método /// sitio. Los temas pueden sobrescribir este método para personalizar completamente el diseño y
/// para personalizar completamente el diseño y el contenido de la página de error. /// el contenido de la página de error.
fn error_403(&self, page: &mut Page) { fn error_403(&self, page: &mut Page) {
if let Some(parent) = self.parent() { if let Some(parent) = self.parent() {
return parent.error_403(page); return parent.error_403(page);
} }
page.alter_title(L10n::l("error403_title")).alter_child_in( page.alter_title(L10n::l("error403_title")).alter_child_in(
&CoreRegion::Content, &DefaultRegions::Content,
ChildOp::Prepend( ChildOp::Prepend(
Html::with(move |cx| { Html::with(move |cx| {
html! { html! {
@ -246,16 +273,15 @@ pub trait Theme: Extension + Send + Sync {
/// Contenido predefinido para la página de error "*404 - Not Found*" (recurso no encontrado). /// Contenido predefinido para la página de error "*404 - Not Found*" (recurso no encontrado).
/// ///
/// Normalmente se renderiza con la plantilla ya activa en la página (por ejemplo /// Normalmente se renderiza con la plantilla predeterminada del tema (por defecto suele ser
/// [`CoreTemplate::Standard`](crate::core::theme::CoreTemplate::Standard)). Los temas pueden /// [`DefaultTemplates::Standard`]). Los temas pueden sobrescribir este método para personalizar
/// sobrescribir este método para personalizar completamente el diseño y el contenido de la /// completamente el diseño y el contenido de la página de error.
/// página de error.
fn error_404(&self, page: &mut Page) { fn error_404(&self, page: &mut Page) {
if let Some(parent) = self.parent() { if let Some(parent) = self.parent() {
return parent.error_404(page); return parent.error_404(page);
} }
page.alter_title(L10n::l("error404_title")).alter_child_in( page.alter_title(L10n::l("error404_title")).alter_child_in(
&CoreRegion::Content, &DefaultRegions::Content,
ChildOp::Prepend( ChildOp::Prepend(
Html::with(move |cx| { Html::with(move |cx| {
html! { html! {
@ -281,10 +307,9 @@ pub trait Theme: Extension + Send + Sync {
/// funcionan con normalidad. /// funcionan con normalidad.
/// ///
/// Por defecto, asigna el título al documento (`title`), se renderiza con la plantilla ya /// Por defecto, asigna el título al documento (`title`), se renderiza con la plantilla ya
/// activa en la página (normalmente /// activa en la página (normalmente [`DefaultTemplates::Standard`]) y muestra un componente
/// [`CoreTemplate::Standard`](crate::core::theme::CoreTemplate::Standard)) y muestra un /// [`Intro`] con el código HTTP del error (`code`) y los mensajes proporcionados (`alert` y
/// componente [`Intro`] con el código HTTP del error (`code`) y los mensajes proporcionados /// `help`) como descripción del error.
/// (`alert` y `help`) como descripción del error.
/// ///
/// Este método no se utiliza en las implementaciones predefinidas de [`Self::error_403()`] ni /// Este método no se utiliza en las implementaciones predefinidas de [`Self::error_403()`] ni
/// [`Self::error_404()`], que definen su propio contenido específico. /// [`Self::error_404()`], que definen su propio contenido específico.
@ -301,7 +326,7 @@ pub trait Theme: Extension + Send + Sync {
return parent.error_fatal(page, code, title, alert, help); return parent.error_fatal(page, code, title, alert, help);
} }
page.alter_title(title).alter_child_in( page.alter_title(title).alter_child_in(
&CoreRegion::Content, &DefaultRegions::Content,
ChildOp::Prepend( ChildOp::Prepend(
Intro::new() Intro::new()
.with_title(L10n::l("error_code").with_arg("code", code.to_string())) .with_title(L10n::l("error_code").with_arg("code", code.to_string()))

View file

@ -1,5 +1,5 @@
use crate::core::component::{Child, ChildOp, Children, Component}; use crate::core::component::{Child, ChildOp, Children, Component};
use crate::core::theme::{CoreRegion, RegionRef, ThemeRef}; use crate::core::theme::{DefaultRegions, RegionRef, ThemeRef};
use crate::{AutoDefault, UniqueId, builder_fn}; use crate::{AutoDefault, UniqueId, builder_fn};
use parking_lot::RwLock; use parking_lot::RwLock;
@ -43,19 +43,20 @@ static COMMON_REGIONS: LazyLock<RwLock<RegionComponents>> =
pub(crate) struct ChildrenInRegions(HashMap<String, Children>); pub(crate) struct ChildrenInRegions(HashMap<String, Children>);
impl ChildrenInRegions { impl ChildrenInRegions {
pub fn with(region: RegionRef, child: Child) -> Self { pub fn with(region_ref: RegionRef, child: Child) -> Self {
Self::default().with_child_in(region, child) Self::default().with_child_in(region_ref, child)
} }
#[builder_fn] #[builder_fn]
pub fn with_child_in(mut self, region: RegionRef, op: impl Into<ChildOp>) -> Self { pub fn with_child_in(mut self, region_ref: RegionRef, op: impl Into<ChildOp>) -> Self {
let child = op.into(); let child = op.into();
let region_name = region.name(); if let Some(region) = self.0.get_mut(region_ref.name()) {
if let Some(region) = self.0.get_mut(region_name) {
region.alter_child(child); region.alter_child(child);
} else { } else {
let children = Children::new().with_child(child); self.0.insert(
self.0.insert(region_name.to_owned(), children); region_ref.name().to_owned(),
Children::new().with_child(child),
);
} }
self self
} }
@ -70,8 +71,7 @@ impl ChildrenInRegions {
/// lugar de clonarse, ya que son de un único uso. /// lugar de clonarse, ya que son de un único uso.
/// 3. Prototipos del tema activo, exclusivos del tema en curso. También se clonan para asegurar /// 3. Prototipos del tema activo, exclusivos del tema en curso. También se clonan para asegurar
/// que llegan a `setup()` con el mismo estado inicial. /// que llegan a `setup()` con el mismo estado inicial.
pub fn assemble_region(&mut self, theme: ThemeRef, region: RegionRef) -> Children { pub fn assemble_region(&mut self, theme_ref: ThemeRef, region_name: &str) -> Children {
let region_name = region.name();
let common = COMMON_REGIONS.read(); let common = COMMON_REGIONS.read();
let themed = THEME_REGIONS.read(); let themed = THEME_REGIONS.read();
@ -90,7 +90,7 @@ impl ChildrenInRegions {
} }
} }
// 3. Prototipos del tema activo. // 3. Prototipos del tema activo.
if let Some(theme_map) = themed.get(&theme.type_id()) { if let Some(theme_map) = themed.get(&theme_ref.type_id()) {
if let Some(protos) = theme_map.get(region_name) { if let Some(protos) = theme_map.get(region_name) {
for proto in protos { for proto in protos {
result.add(proto.as_child()); result.add(proto.as_child());
@ -118,27 +118,31 @@ impl ChildrenInRegions {
/// InRegion::Content.add(Html::with(|_| html! { "🎉 ¡Bienvenido!" })); /// InRegion::Content.add(Html::with(|_| html! { "🎉 ¡Bienvenido!" }));
/// ///
/// // Texto en la cabecera, visible en todos los temas. /// // Texto en la cabecera, visible en todos los temas.
/// InRegion::Global(&CoreRegion::Header).add(Html::with(|_| html! { "Publicidad" })); /// InRegion::Global(&DefaultRegions::Header).add(Html::with(|_| html! { "Publicidad" }));
/// ``` /// ```
pub enum InRegion { pub enum InRegion {
/// Región principal de **contenido** por defecto. /// Región principal de **contenido** por defecto.
/// ///
/// Añade el componente a la región lógica de contenido principal de la aplicación. Internamente /// Añade el componente a la región lógica de contenido principal de la aplicación. Por
/// equivale a `InRegion::Global(&CoreRegion::Content)`. /// convención, esta región corresponde a [`DefaultRegions::Content`], cuyo nombre es
/// `"content"`. Cualquier tema que renderice esa misma región de contenido, ya sea usando
/// directamente [`DefaultRegions::Content`] o cualquier otra implementación de
/// [`Region`](crate::core::theme::Region) que devuelva ese mismo nombre, mostrará los
/// componentes registrados aquí, aunque lo harán según su propio método de renderizado
/// ([`Region::render()`](crate::core::theme::Region::render)).
Content, Content,
/// Región global compartida por todos los temas. /// Región global compartida por todos los temas.
/// ///
/// Los componentes añadidos aquí se asocian al nombre de la región indicado por [`RegionRef`], /// Los componentes añadidos aquí se asocian al nombre de la región indicado por [`RegionRef`],
/// es decir, al valor devuelto por /// es decir, al valor devuelto por [`Region::name()`](crate::core::theme::Region::name) para
/// [`RegionName::name()`](crate::core::theme::RegionName::name) para esa región. Se mostrarán /// esa región. Se mostrarán en cualquier tema cuya plantilla renderice una región que devuelva
/// en cualquier tema que renderice la región que devuelva ese nombre. /// ese mismo nombre.
Global(RegionRef), Global(RegionRef),
/// Región asociada a un tema concreto. /// Región asociada a un tema concreto.
/// ///
/// Los componentes sólo se renderizarán cuando el documento se procese exactamente con el tema /// Los componentes sólo se renderizarán cuando el documento se procese con el tema indicado y
/// indicado (no sirve un tema hijo que lo herede), y se utilice la región referenciada. A /// se utilice la región referenciada. Resulta útil para añadir contenido específico en un tema
/// diferencia del resto de comportamiento de `Theme`, este registro no sigue la cadena /// sin afectar a otros.
/// `parent()`. Resulta útil para añadir contenido específico en un tema sin afectar a otros.
ForTheme(ThemeRef, RegionRef), ForTheme(ThemeRef, RegionRef),
} }
@ -159,26 +163,26 @@ impl InRegion {
/// })); /// }));
/// ///
/// // Texto en la cabecera. /// // Texto en la cabecera.
/// InRegion::Global(&CoreRegion::Header).add(Html::with(|_| { /// InRegion::Global(&DefaultRegions::Header).add(Html::with(|_| {
/// html! { "Publicidad" } /// html! { "Publicidad" }
/// })); /// }));
/// ///
/// // Contenido sólo para la región del pie de página en un tema concreto. /// // Contenido sólo para la región del pie de página en un tema concreto.
/// InRegion::ForTheme(&theme::Basic, &CoreRegion::Footer).add(Html::with(|_| { /// InRegion::ForTheme(&theme::Basic, &DefaultRegions::Footer).add(Html::with(|_| {
/// html! { "Aviso legal" } /// html! { "Aviso legal" }
/// })); /// }));
/// ``` /// ```
pub fn add(&self, component: impl Component + Clone + 'static) -> &Self { pub fn add(&self, component: impl Component + Clone + 'static) -> &Self {
let proto: Arc<dyn ComponentGlobal> = Arc::new(component); let proto: Arc<dyn ComponentGlobal> = Arc::new(component);
match self { match self {
InRegion::Content => Self::add_to_common(&CoreRegion::Content, proto), InRegion::Content => Self::add_to_common(&DefaultRegions::Content, proto),
InRegion::Global(region) => Self::add_to_common(*region, proto), InRegion::Global(region_ref) => Self::add_to_common(*region_ref, proto),
InRegion::ForTheme(theme, region) => { InRegion::ForTheme(theme_ref, region_ref) => {
THEME_REGIONS THEME_REGIONS
.write() .write()
.entry(theme.type_id()) .entry(theme_ref.type_id())
.or_default() .or_default()
.entry((*region).name().to_owned()) .entry((*region_ref).name().to_owned())
.or_default() .or_default()
.push(proto); .push(proto);
} }
@ -187,10 +191,10 @@ impl InRegion {
} }
#[inline] #[inline]
fn add_to_common(region: RegionRef, proto: Arc<dyn ComponentGlobal>) { fn add_to_common(region_ref: RegionRef, proto: Arc<dyn ComponentGlobal>) {
COMMON_REGIONS COMMON_REGIONS
.write() .write()
.entry(region.name().to_owned()) .entry(region_ref.name().to_owned())
.or_default() .or_default()
.push(proto); .push(proto);
} }

View file

@ -6,9 +6,6 @@ pub use maud::{DOCTYPE, Escaper, Markup, PreEscaped, Render, display, html, html
mod route_path; mod route_path;
pub use route_path::RoutePath; pub use route_path::RoutePath;
mod sort_dir;
pub use sort_dir::SortDir;
// **< HTML DOCUMENT ASSETS >*********************************************************************** // **< HTML DOCUMENT ASSETS >***********************************************************************
mod assets; mod assets;

View file

@ -19,7 +19,7 @@ enum Source {
Inline(CowStr, Box<dyn Fn(&mut Context) -> String + Send + Sync>), Inline(CowStr, Box<dyn Fn(&mut Context) -> String + Send + Sync>),
} }
/// Define el medio objetivo para una hoja de estilos. /// Define el medio objetivo para la hoja de estilos.
/// ///
/// Permite especificar en qué contexto se aplica el CSS, adaptándose a diferentes dispositivos o /// Permite especificar en qué contexto se aplica el CSS, adaptándose a diferentes dispositivos o
/// situaciones de impresión. /// situaciones de impresión.

View file

@ -97,12 +97,6 @@ impl RoutePath {
crate::util::url_looks_external(&self.path) crate::util::url_looks_external(&self.path)
} }
/// Indica si la ruta no tiene *path* ni parámetros, es decir, si su representación textual
/// sería una cadena vacía.
pub fn is_empty(&self) -> bool {
self.path.is_empty() && self.query.is_empty()
}
// **< RoutePath HELPERS >********************************************************************** // **< RoutePath HELPERS >**********************************************************************
// Codifica un valor para su uso seguro como parte de una *query string* según RFC 3986: los // Codifica un valor para su uso seguro como parte de una *query string* según RFC 3986: los

View file

@ -1,116 +0,0 @@
use crate::AutoDefault;
/// Representa una dirección de ordenación (ascendente o descendente).
///
/// Es un tipo definido exclusivamente para trabajar con datos ordenables. No depende de ningún
/// componente. Sirve tanto para representar el estado de un listado ordenable en la capa de
/// servicio (interpretando el valor de la *query string* con [`from_query()`](Self::from_query) y
/// serializándolo con [`as_str()`](Self::as_str)), como para calcular la dirección que debe llevar
/// un enlace de ordenación de la interfaz si se vuelve a pulsar ([`toggled()`](Self::toggled),
/// [`next_for()`](Self::next_for)).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum SortDir {
/// Orden ascendente. Es la dirección por defecto.
#[default]
Asc,
/// Orden descendente.
Desc,
}
impl SortDir {
/// Interpreta el valor del parámetro de ordenación procedente de una *query string*
/// (`"asc"`/`"desc"`).
///
/// Cualquier valor distinto de `"desc"` (incluido `None`, una cadena vacía o un valor no
/// reconocido) se interpreta como [`Asc`](Self::Asc).
///
/// ```rust
/// use pagetop::html::SortDir;
///
/// assert_eq!(SortDir::from_query(Some("desc")), SortDir::Desc);
/// assert_eq!(SortDir::from_query(Some("asc")), SortDir::Asc);
/// assert_eq!(SortDir::from_query(Some("")), SortDir::Asc);
/// assert_eq!(SortDir::from_query(None), SortDir::Asc);
/// ```
#[inline]
pub fn from_query(value: Option<&str>) -> Self {
match value {
Some("desc") => SortDir::Desc,
_ => SortDir::Asc,
}
}
/// Devuelve el valor de esta dirección tal como se representa en una *query string*
/// (`"asc"`/`"desc"`).
///
/// ```rust
/// use pagetop::html::SortDir;
///
/// assert_eq!(SortDir::Asc.as_str(), "asc");
/// assert_eq!(SortDir::Desc.as_str(), "desc");
/// ```
#[inline]
pub const fn as_str(self) -> &'static str {
match self {
SortDir::Asc => "asc",
SortDir::Desc => "desc",
}
}
/// Devuelve la dirección contraria a esta.
///
/// ```rust
/// use pagetop::html::SortDir;
///
/// assert_eq!(SortDir::Asc.toggled(), SortDir::Desc);
/// assert_eq!(SortDir::Desc.toggled(), SortDir::Asc);
/// ```
#[inline]
pub const fn toggled(self) -> Self {
match self {
SortDir::Asc => SortDir::Desc,
SortDir::Desc => SortDir::Asc,
}
}
/// Calcula la dirección que debe llevar un enlace de ordenación si se vuelve a pulsar, dado su
/// estado actual (`current`) que puede ser `Some(dir)` si el listado ya está ordenado por este
/// enlace, o `None` si el orden vigente lo determina otro campo.
///
/// Si el enlace ya ordena, [alterna la dirección](Self::toggled); si no, siempre empieza en
/// [`Asc`](Self::Asc), sea cual sea la dirección vigente del listado en conjunto.
///
/// ```rust
/// use pagetop::html::SortDir;
///
/// // Otro campo determina el orden vigente: el próximo clic aquí empieza en ascendente.
/// assert_eq!(SortDir::next_for(None), SortDir::Asc);
///
/// // Este mismo campo ya ordena en ascendente: el próximo clic debería invertirlo.
/// assert_eq!(SortDir::next_for(Some(SortDir::Asc)), SortDir::Desc);
/// assert_eq!(SortDir::next_for(Some(SortDir::Desc)), SortDir::Asc);
/// ```
#[inline]
pub const fn next_for(current: Option<SortDir>) -> SortDir {
match current {
Some(dir) => dir.toggled(),
None => SortDir::Asc,
}
}
}
/// Permite pasar un [`SortDir`] allí donde se espere `impl Into<String>`, por ejemplo, en
/// [`RoutePath::with_param()`](crate::html::RoutePath::with_param) o su equivalente
/// `alter_param()`, sin tener que escribir `as_str().to_owned()` a mano.
///
/// ```rust
/// use pagetop::html::SortDir;
///
/// assert_eq!(String::from(SortDir::Asc), "asc");
/// assert_eq!(String::from(SortDir::Desc), "desc");
/// ```
impl From<SortDir> for String {
fn from(dir: SortDir) -> Self {
dir.as_str().to_owned()
}
}

View file

@ -170,7 +170,7 @@ impl Locale {
/// Devuelve el identificador de idioma configurado explícitamente, si es válido. /// Devuelve el identificador de idioma configurado explícitamente, si es válido.
/// ///
/// Si no se ha configurado un idioma por defecto o el valor no es válido, devuelve `None`. /// Si no se ha configurado un idioma por defecto o el valor no es válido, devuelve `None`.
pub fn try_langid() -> Option<&'static LanguageIdentifier> { pub fn configured_langid() -> Option<&'static LanguageIdentifier> {
*CONFIG_LANGID *CONFIG_LANGID
} }

View file

@ -3,8 +3,6 @@ use crate::{AutoDefault, CowStr, include_locales};
use super::{LangId, Locale}; use super::{LangId, Locale};
include_locales!(LOCALES_PAGETOP);
use fluent_templates::Loader; use fluent_templates::Loader;
use fluent_templates::StaticLoader as Locales; use fluent_templates::StaticLoader as Locales;
@ -12,6 +10,8 @@ use std::collections::HashMap;
use std::fmt; use std::fmt;
include_locales!(LOCALES_PAGETOP);
/// Operación de localización a realizar. /// Operación de localización a realizar.
/// ///
/// * `None` - No se aplica ninguna localización. /// * `None` - No se aplica ninguna localización.

View file

@ -29,13 +29,13 @@ impl RequestLocale {
/// - [`LangNegotiation::Full`](crate::global::LangNegotiation::Full) determina el idioma en /// - [`LangNegotiation::Full`](crate::global::LangNegotiation::Full) determina el idioma en
/// este orden: /// este orden:
/// 1. Parámetro de *query* `?lang=...`, si existe y corresponde a un idioma soportado. /// 1. Parámetro de *query* `?lang=...`, si existe y corresponde a un idioma soportado.
/// 2. [`Locale::try_langid()`], si la aplicación tiene un idioma por defecto válido. /// 2. [`Locale::configured_langid()`], si la aplicación tiene un idioma por defecto válido.
/// 3. Cabecera `Accept-Language`, si puede resolverse con [`Locale::resolve()`]. /// 3. Cabecera `Accept-Language`, si puede resolverse con [`Locale::resolve()`].
/// 4. Idioma de respaldo. /// 4. Idioma de respaldo.
/// ///
/// - [`LangNegotiation::NoQuery`](crate::global::LangNegotiation::NoQuery) descarta el uso del /// - [`LangNegotiation::NoQuery`](crate::global::LangNegotiation::NoQuery) descarta el uso del
/// parámetro `?lang=...` y determina el idioma en este orden: /// parámetro `?lang=...` y determina el idioma en este orden:
/// 1. [`Locale::try_langid()`], si la aplicación tiene un idioma por defecto válido. /// 1. [`Locale::configured_langid()`], si la aplicación tiene un idioma por defecto válido.
/// 2. Cabecera `Accept-Language`, si puede resolverse con [`Locale::resolve()`]. /// 2. Cabecera `Accept-Language`, si puede resolverse con [`Locale::resolve()`].
/// 3. Idioma de respaldo. /// 3. Idioma de respaldo.
/// ///
@ -56,7 +56,7 @@ impl RequestLocale {
Locale::default_langid() Locale::default_langid()
} }
global::LangNegotiation::Full | global::LangNegotiation::NoQuery => { global::LangNegotiation::Full | global::LangNegotiation::NoQuery => {
if let Some(default) = Locale::try_langid() { if let Some(default) = Locale::configured_langid() {
default default
} else { } else {
// Sin idioma por defecto, se evalúa la cabecera `Accept-Language`. // Sin idioma por defecto, se evalúa la cabecera `Accept-Language`.

View file

@ -2,17 +2,16 @@
//! //!
//! Este módulo define [`Page`], que representa una página HTML lista para renderizar. Cada página //! Este módulo define [`Page`], que representa una página HTML lista para renderizar. Cada página
//! se construye a partir de un [`Context`] propio, donde se registran el tema activo, la plantilla //! se construye a partir de un [`Context`] propio, donde se registran el tema activo, la plantilla
//! ([`TemplateName`](crate::core::theme::TemplateName)) que define la disposición de las regiones //! ([`Template`](crate::core::theme::Template)) que define la disposición de las regiones
//! ([`RegionName`]), los componentes asociados y los recursos adicionales (hojas de estilo, //! ([`Region`]), los componentes asociados y los recursos adicionales (hojas de estilo, scripts,
//! scripts, *favicon*, etc.). //! *favicon*, etc.).
//! //!
//! El renderizado ([`Page::render()`]) delega en el tema ([`Theme`](crate::core::theme::Theme)) la //! El renderizado ([`Page::render()`]) delega en el tema ([`Theme`](crate::core::theme::Theme)) la
//! composición del `<head>` y del `<body>`, y se ejecutan las acciones registradas por las //! composición del `<head>` y del `<body>`, y se ejecutan las acciones registradas por las
//! extensiones antes y después de generar los contenidos. //! extensiones antes y después de generar los contenidos.
//! //!
//! También define las regiones internas reservadas ([`ReservedRegion`]) que actúan como puntos de //! También introduce regiones internas reservadas ([`ReservedRegion`]) que actúan como puntos de
//! anclaje globales al inicio y al final del `<body>`, fuera de las regiones que maqueta la //! anclaje globales al inicio y al final del documento.
//! plantilla activa.
mod error; mod error;
pub use error::ErrorPage; pub use error::ErrorPage;
@ -20,10 +19,8 @@ pub(crate) use error::{render_error_pages, response_for_panic, route_not_found};
use crate::auth::CurrentUser; use crate::auth::CurrentUser;
use crate::base::action; use crate::base::action;
use crate::base::component::layout; use crate::core::component::{AssetsOp, ChildOp, Context, ContextError, Contextual};
use crate::core::component::{AssetsOp, ChildOp, ComponentRender}; use crate::core::theme::{DefaultRegions, Region, RegionRef, TemplateRef, ThemeRef};
use crate::core::component::{Context, ContextError, Contextual};
use crate::core::theme::{CoreRegion, CoreTemplate, RegionName, RegionRef, TemplateRef, ThemeRef};
use crate::html::{Assets, Favicon, JavaScript, StyleSheet}; use crate::html::{Assets, Favicon, JavaScript, StyleSheet};
use crate::html::{Attr, Props, PropsOp}; use crate::html::{Attr, Props, PropsOp};
use crate::html::{DOCTYPE, Markup, html}; use crate::html::{DOCTYPE, Markup, html};
@ -35,35 +32,37 @@ use crate::{AutoDefault, builder_fn};
/// Regiones internas reservadas como puntos de anclaje globales. /// Regiones internas reservadas como puntos de anclaje globales.
/// ///
/// Representan contenedores especiales situados al inicio y al final del `<body>`, fuera de las /// Representan contenedores especiales situados al inicio y al final de un documento. Están
/// regiones que maqueta la plantilla activa. Las renderiza directamente [`Page::render()`], /// pensadas para proporcionar regiones donde inyectar contenido global o técnico. No suelen usarse
/// envolviendo el resultado de /// como regiones visibles en los temas.
/// [`Theme::render_page_body()`](crate::core::theme::Theme::render_page_body). **No suelen usarse
/// como regiones "visibles" en los temas**, sino para inyectar contenido global o técnico.
#[derive(AutoDefault)]
pub enum ReservedRegion { pub enum ReservedRegion {
/// Región interna situada al **inicio del `<body>`**, de nombre `"page-top"`. /// Región interna situada al **inicio del documento**.
/// ///
/// Proporciona un contenedor donde las extensiones puedan inyectar elementos auxiliares antes /// Su función es proporcionar un contenedor donde las extensiones puedan inyectar contenido
/// del resto de regiones (cabecera, contenido, etc.), como marcadores técnicos, inicializadores /// global antes del resto de regiones principales (cabecera, contenido, etc.).
/// o contenido de depuración. ///
/// No suele utilizarse en los temas como una región “visible” dentro del maquetado habitual,
/// sino como punto de anclaje para elementos auxiliares, marcadores técnicos, inicializadores o
/// contenido de depuración que deban situarse en la parte superior del documento.
/// ///
/// Se considera una región **reservada** para este tipo de usos globales. /// Se considera una región **reservada** para este tipo de usos globales.
#[default]
PageTop, PageTop,
/// Región interna situada al **final del `<body>`**, de nombre `"page-bottom"`. /// Región interna situada al **final del documento**.
/// ///
/// Proporciona un contenedor donde las extensiones puedan inyectar contenido global después del /// Pensada para proporcionar un contenedor donde las extensiones puedan inyectar contenido
/// resto de regiones (cabecera, contenido, etc.), como elementos auxiliares asociados a /// global después del resto de regiones principales (cabecera, contenido, etc.).
/// comportamientos dinámicos. ///
/// No suele utilizarse en los temas como una región “visible” dentro del maquetado habitual,
/// sino como punto de anclaje para elementos auxiliares asociados a comportamientos dinámicos
/// que deban situarse en la parte inferior del documento.
/// ///
/// Igual que [`Self::PageTop`], se considera una región **reservada** para este tipo de usos /// Igual que [`Self::PageTop`], se considera una región **reservada** para este tipo de usos
/// globales. /// globales.
PageBottom, PageBottom,
} }
impl RegionName for ReservedRegion { impl Region for ReservedRegion {
#[inline] #[inline]
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
match self { match self {
@ -102,23 +101,22 @@ impl Page {
/// [`CurrentUser`] inyectado por middleware en sus extensiones (ver /// [`CurrentUser`] inyectado por middleware en sus extensiones (ver
/// [`Context::new`](crate::core::component::Context::new)). Cualquier handler tiene acceso al /// [`Context::new`](crate::core::component::Context::new)). Cualquier handler tiene acceso al
/// usuario actual desde el momento en que se crea la página, sin llamadas adicionales. /// usuario actual desde el momento en que se crea la página, sin llamadas adicionales.
#[rustfmt::skip]
pub fn new(request: HttpRequest) -> Self { pub fn new(request: HttpRequest) -> Self {
Page { Page {
context: Context::new(Some(request)), title : Attr::<L10n>::default(),
..Default::default() description : Attr::<L10n>::default(),
metadata : Vec::default(),
properties : Vec::default(),
context : Context::new(Some(request)),
} }
} }
/// Crea una nueva instancia de página con la plantilla [`CoreTemplate::Admin`]. /// Crea una nueva instancia de página con la plantilla de administración del tema activo.
///
/// Cada tema puede maquetarla de forma distinta capturando
/// [`Template`](crate::base::component::layout::Template) en `handle_component()`, pero la
/// plantilla en sí es la misma constante para cualquier tema.
pub fn admin(request: HttpRequest) -> Self { pub fn admin(request: HttpRequest) -> Self {
Page { let mut page = Page::new(request);
context: Context::new(Some(request)).with_template(&CoreTemplate::Admin), page.context().use_admin_template();
..Default::default() page
}
} }
// **< Page BUILDER >*************************************************************************** // **< Page BUILDER >***************************************************************************
@ -219,9 +217,9 @@ impl Page {
// Renderiza el <body>. // Renderiza el <body>.
let body = html! { let body = html! {
(layout::Region::of(&ReservedRegion::PageTop).render(&mut self.context).await) (ReservedRegion::PageTop.render(&mut self.context).await)
(self.context.theme().render_page_body(self).await) (self.context.theme().render_page_body(self).await)
(layout::Region::of(&ReservedRegion::PageBottom).render(&mut self.context).await) (ReservedRegion::PageBottom.render(&mut self.context).await)
}; };
// Acciones específicas del tema después de renderizar el <body>. // Acciones específicas del tema después de renderizar el <body>.
@ -312,13 +310,14 @@ impl Contextual for Page {
#[builder_fn] #[builder_fn]
fn with_child(mut self, op: impl Into<ChildOp>) -> Self { fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
self.context.alter_child_in(&CoreRegion::Content, op.into()); self.context
.alter_child_in(&DefaultRegions::Content, op.into());
self self
} }
#[builder_fn] #[builder_fn]
fn with_child_in(mut self, region: RegionRef, op: impl Into<ChildOp>) -> Self { fn with_child_in(mut self, region_ref: RegionRef, op: impl Into<ChildOp>) -> Self {
self.context.alter_child_in(region, op.into()); self.context.alter_child_in(region_ref, op.into());
self self
} }

View file

@ -1,118 +0,0 @@
use pagetop::prelude::*;
/// Initializes PageTop (locale, extensions...) once for the whole suite.
///
/// Rendering a `Region`/`Template` looks up localized labels (`aria-label`, etc.), so tests that
/// render them need the localization subsystem loaded.
async fn setup() {
Application::new().await;
}
// **< A theme that intercepts the `Template` component >*******************************************
/// Replaces the default `Template` composition (`Header` + `Content` + `Footer`) with a fixed
/// marker string, for both `CoreTemplate::Standard` and `CoreTemplate::Admin`. Mirrors how
/// a real theme (e.g. `pagetop-bootsier`) tells its own layout apart from PageTop's default: by
/// intercepting the `Template` component in `handle_component()`, not by swapping which
/// `TemplateRef` gets resolved.
struct MarkerTheme;
#[async_trait]
impl Extension for MarkerTheme {
fn theme(&self) -> Option<ThemeRef> {
Some(&Self)
}
}
#[async_trait]
impl Theme for MarkerTheme {
async fn handle_component(
&self,
component: &mut dyn Component,
_cx: &mut Context,
) -> Option<Result<Markup, ComponentError>> {
let template = (&*component).downcast_ref::<layout::Template>()?;
template.template().downcast_ref::<CoreTemplate>()?;
Some(Ok(html! { "marker-template-output" }))
}
}
// **< Default/Admin template identity is independent of the active theme >*************************
//
// `Theme::default_template()`/`admin_template()` were removed: `Context::template()` always
// resolves `Default`/`Admin` to the core `CoreTemplate::Standard`/`Admin` identity, regardless
// of which theme is active. Themes customize the actual rendering by intercepting the `Template`
// component in `handle_component()` instead (see the tests further below).
#[pagetop::test]
async fn default_template_identity_is_independent_of_theme() {
let cx = Context::new(None);
assert_eq!(cx.template().name(), "standard");
let cx = Context::new(None).with_theme(&MarkerTheme);
assert_eq!(cx.template().name(), "standard");
}
#[pagetop::test]
async fn admin_template_identity_is_independent_of_theme() {
let mut page = Page::admin(web::test::TestRequest::get().to_http_request());
assert_eq!(page.context().template().name(), "admin");
let mut page =
Page::admin(web::test::TestRequest::get().to_http_request()).with_theme(&MarkerTheme);
assert_eq!(page.context().template().name(), "admin");
}
#[pagetop::test]
async fn explicit_template_is_not_overridden_by_a_later_with_theme() {
// A template explicitly set with `with_template()` prevails even if `with_theme()` is called
// afterwards.
let cx = Context::new(None)
.with_template(&CoreTemplate::Admin)
.with_theme(&pagetop::base::theme::Basic);
assert_eq!(cx.template().name(), "admin");
}
// **< A theme customizes rendering via `handle_component()` >**************************************
#[pagetop::test]
async fn without_a_matching_theme_the_default_composition_is_used() {
setup().await;
// With no theme intercepting it, and no content registered in any region, the default
// composition (Header + Content + Footer) renders empty.
let mut template = layout::Template::default();
let html = template.render(&mut Context::default()).await.into_string();
assert!(html.is_empty());
}
#[pagetop::test]
async fn theme_replaces_template_rendering_via_handle_component() {
setup().await;
let mut template = layout::Template::default();
let mut cx = Context::default().with_theme(&MarkerTheme);
let html = template.render(&mut cx).await.into_string();
assert_eq!(html, "marker-template-output");
}
// **< Page::render() reaches the active theme's `handle_component()` >*****************************
#[pagetop::test]
async fn page_admin_render_reflects_the_active_theme_template() {
setup().await;
let request = web::test::TestRequest::get().to_http_request();
let mut page = Page::admin(request).with_theme(&MarkerTheme);
let html = page
.render()
.await
.expect("page should render")
.into_string();
assert!(html.contains("marker-template-output"));
}

83
tests/theme_template.rs Normal file
View file

@ -0,0 +1,83 @@
use pagetop::prelude::*;
// **< Theme with its own template >****************************************************************
struct MarkerTemplate;
#[async_trait]
impl Template for MarkerTemplate {
async fn render(&self, _cx: &mut Context) -> Markup {
html! { "marker-template-output" }
}
}
struct MarkerTheme;
#[async_trait]
impl Extension for MarkerTheme {
fn theme(&self) -> Option<ThemeRef> {
Some(&Self)
}
}
#[async_trait]
impl Theme for MarkerTheme {
fn default_template(&self) -> TemplateRef {
&MarkerTemplate
}
fn admin_template(&self) -> TemplateRef {
&MarkerTemplate
}
}
async fn render_active_template(cx: &mut Context) -> String {
let template = cx.template();
template.render(cx).await.into_string()
}
// **< Context::template() follows the active theme >***********************************************
#[pagetop::test]
async fn with_theme_updates_the_effective_template() {
// Without changing theme, the active template is not `MarkerTheme`'s.
let mut cx = Context::new(None);
assert_ne!(
render_active_template(&mut cx).await,
"marker-template-output"
);
// After changing theme with `with_theme()`, the active template becomes that theme's, with no
// need to call `with_template()` explicitly.
let mut cx = Context::new(None).with_theme(&MarkerTheme);
assert_eq!(
render_active_template(&mut cx).await,
"marker-template-output"
);
}
#[pagetop::test]
async fn explicit_template_is_not_overridden_by_a_later_with_theme() {
// A template explicitly set with `with_template()` prevails even if `with_theme()` is called
// afterwards, regardless of order.
let mut cx = Context::new(None)
.with_template(&MarkerTemplate)
.with_theme(&pagetop::base::theme::Basic);
assert_eq!(
render_active_template(&mut cx).await,
"marker-template-output"
);
}
// **< Page::admin() follows the active theme >*****************************************************
#[pagetop::test]
async fn page_admin_template_follows_a_later_with_theme() {
let request = web::test::TestRequest::get().to_http_request();
let mut page = Page::admin(request).with_theme(&MarkerTheme);
let markup = page.context().template().render(page.context()).await;
assert_eq!(markup.into_string(), "marker-template-output");
}