pagetop/extensions/pagetop-htmx/src/request.rs
Manuel Cillero 38fd24453e (htmx): Añade integración con HTMX 2
Constantes `hx-*`, `HtmxRequestExt` y `HtmxResponse` cubren el ciclo
completo: escribir atributos, leer la petición y construir la respuesta.
La extensión Htmx inyecta el script automáticamente.

Añade `IntoResponse` y `Response` al prelude de PageTop.
2026-06-13 18:41:15 +02:00

132 lines
4.9 KiB
Rust

//! Implementación de [`HtmxRequestExt`] para [`pagetop::web::HttpRequest`].
use pagetop::prelude::*;
// **< HtmxRequestExt >*****************************************************************************
/// Extiende [`HttpRequest`](pagetop::web::HttpRequest) con métodos para detectar y leer peticiones
/// HTMX.
///
/// HTMX añade cabeceras especiales a cada petición AJAX. Este trait permite acceder a ellas de
/// forma expresiva, sin manipular [`pagetop::web::http::HeaderMap`] directamente.
///
/// El patrón más común es devolver una respuesta distinta según si la petición viene de HTMX
/// (fragmento parcial) o de una navegación directa (página completa). Cuando las dos ramas retornan
/// tipos distintos, se usa [`pagetop::web::Response`] como tipo común:
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::{HtmxRequestExt, HtmxResponse};
///
/// async fn list_items(request: HttpRequest) -> Response {
/// if request.is_htmx() {
/// // Fragmento parcial con cabeceras HTMX opcionales.
/// HtmxResponse::new(html! { ul { li { "Item 1" } li { "Item 2" } } })
/// .into_response()
/// } else {
/// // Página completa para navegación directa.
/// Page::new(request)
/// .with_child(Html::with(|_| html! {
/// ul { li { "Item 1" } li { "Item 2" } }
/// }))
/// .render()
/// .into_response()
/// }
/// }
/// ```
///
/// Los nombres de cabecera como constantes están en [`crate::hx::request`].
pub trait HtmxRequestExt {
/// Devuelve `true` si la petición proviene de HTMX (`HX-Request: true`).
///
/// Es la comprobación principal para distinguir una petición HTMX de una navegación directa del
/// navegador.
fn is_htmx(&self) -> bool;
/// Devuelve `true` si la petición proviene de un enlace o formulario con `hx-boost`.
///
/// Las peticiones boosted son HTMX pero conservan la semántica de navegación completa: el
/// objetivo por defecto es `<body>`. Puede ser útil para no devolver un fragmento parcial en
/// este caso.
fn is_boosted(&self) -> bool;
/// Devuelve `true` si la petición es una restauración del historial del navegador.
///
/// Ocurre cuando el usuario navega hacia atrás o adelante y HTMX necesita restaurar el estado
/// de la página. En este caso conviene devolver la página completa.
fn is_history_restore(&self) -> bool;
/// URL de la página activa en el navegador en el momento de la petición (`HX-Current-URL`).
///
/// Útil para redirigir o actualizar la URL del historial en función de dónde estaba el usuario.
fn hx_current_url(&self) -> Option<&str>;
/// Valor del atributo `id` del elemento objetivo de la petición (`HX-Target`).
///
/// Si el elemento objetivo no tiene `id`, esta cabecera no se envía.
fn hx_target(&self) -> Option<&str>;
/// Valor del atributo `id` del elemento que disparó la petición (`HX-Trigger`).
///
/// Si el elemento disparador no tiene `id`, esta cabecera no se envía. Ver también
/// [`hx_trigger_name()`](Self::hx_trigger_name).
fn hx_trigger_id(&self) -> Option<&str>;
/// Valor del atributo `name` del elemento que disparó la petición (`HX-Trigger-Name`).
///
/// Especialmente útil en formularios, donde el elemento disparador puede tener `name` pero no
/// `id`.
fn hx_trigger_name(&self) -> Option<&str>;
/// Texto introducido por el usuario en un diálogo `hx-prompt` (`HX-Prompt`).
///
/// Sólo presente si el elemento tiene el atributo `hx-prompt` y el usuario no canceló el
/// diálogo.
fn hx_prompt(&self) -> Option<&str>;
}
impl HtmxRequestExt for HttpRequest {
fn is_htmx(&self) -> bool {
header_equals(self.headers(), "hx-request", "true")
}
fn is_boosted(&self) -> bool {
header_equals(self.headers(), "hx-boosted", "true")
}
fn is_history_restore(&self) -> bool {
header_equals(self.headers(), "hx-history-restore-request", "true")
}
fn hx_current_url(&self) -> Option<&str> {
header_str(self.headers(), "hx-current-url")
}
fn hx_target(&self) -> Option<&str> {
header_str(self.headers(), "hx-target")
}
fn hx_trigger_id(&self) -> Option<&str> {
header_str(self.headers(), "hx-trigger")
}
fn hx_trigger_name(&self) -> Option<&str> {
header_str(self.headers(), "hx-trigger-name")
}
fn hx_prompt(&self) -> Option<&str> {
header_str(self.headers(), "hx-prompt")
}
}
fn header_equals(headers: &web::http::HeaderMap, name: &str, expected: &str) -> bool {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(|v| v == expected)
.unwrap_or(false)
}
fn header_str<'a>(headers: &'a web::http::HeaderMap, name: &str) -> Option<&'a str> {
headers.get(name).and_then(|v| v.to_str().ok())
}