(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.
This commit is contained in:
Manuel Cillero 2026-06-13 18:41:15 +02:00
parent 511149caa7
commit 38fd24453e
15 changed files with 1389 additions and 1 deletions

View file

@ -9,6 +9,7 @@ members = [
# Extensions
"extensions/pagetop-aliner",
"extensions/pagetop-bootsier",
"extensions/pagetop-htmx",
"extensions/pagetop-seaorm",
]
@ -62,6 +63,7 @@ pagetop-statics = { version = "0.1", path = "helpers/pagetop-statics" }
# Extensions
pagetop-aliner = { version = "0.1", path = "extensions/pagetop-aliner" }
pagetop-bootsier = { version = "0.1", path = "extensions/pagetop-bootsier" }
pagetop-htmx = { version = "0.1", path = "extensions/pagetop-htmx" }
pagetop-seaorm = { version = "0.0", path = "extensions/pagetop-seaorm" }
# PageTop
pagetop = { version = "0.5", path = "." }

View file

@ -0,0 +1,21 @@
[package]
name = "pagetop-htmx"
version = "0.1.0"
description = """
Extensión de PageTop que integra HTMX para enriquecer las páginas con interacciones dinámicas.
"""
categories = ["web-programming"]
keywords = ["pagetop", "htmx", "ajax", "ssr"]
repository.workspace = true
homepage.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
pagetop.workspace = true
[build-dependencies]
pagetop-build.workspace = true

View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Manuel Cillero
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Manuel Cillero
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,88 @@
<div align="center">
<h1>PageTop HTMX</h1>
<p>Extensión para <strong>PageTop</strong> que integra <a href="https://htmx.org">HTMX</a> para enriquecer las páginas con interacciones dinámicas.</p>
[![Doc API](https://img.shields.io/docsrs/pagetop-htmx?label=Doc%20API&style=for-the-badge&logo=Docs.rs)](https://docs.rs/pagetop-htmx)
[![Crates.io](https://img.shields.io/crates/v/pagetop-htmx.svg?style=for-the-badge&logo=ipfs)](https://crates.io/crates/pagetop-htmx)
[![Descargas](https://img.shields.io/crates/d/pagetop-htmx.svg?label=Descargas&style=for-the-badge&logo=transmission)](https://crates.io/crates/pagetop-htmx)
[![Licencia](https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label=Licencia&style=for-the-badge)](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/extensions/pagetop-htmx#licencia)
</div>
## Sobre PageTop
[PageTop](https://docs.rs/pagetop) es un entorno de desarrollo que reivindica la esencia de la web
clásica para crear soluciones web SSR (*renderizadas en el servidor*) modulares, extensibles y
configurables, basadas en HTML, CSS y JavaScript.
## Guía rápida
**Añade la dependencia** a tu `Cargo.toml`:
```toml
[dependencies]
pagetop-htmx = { ... }
```
**Declara la extensión** en tu aplicación (o extensión que la requiera). Recuerda que el orden en
`dependencies()` determina la prioridad relativa frente a las otras extensiones:
```rust
use pagetop::prelude::*;
struct MyApp;
impl Extension for MyApp {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![
// ...
&pagetop_htmx::Htmx
// ...
]
}
}
```
A partir de ese momento, todas las páginas de la aplicación incluirán automáticamente el script de
HTMX 2. Puedes usar los atributos `hx-*` directamente en tus componentes o el código HTML generado:
```rust
use pagetop::prelude::*;
async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_child(Html::with(|_| html! {
button hx-get="/api/hello" hx-target="#result" {
"Say hello"
}
div #result {}
}))
.render()
}
```
## Créditos
Este *crate* integra la biblioteca [HTMX 2.0.10](https://htmx.org), distribuida bajo licencia
[BSD 2-Clause](https://github.com/bigskysoftware/htmx/blob/master/LICENSE).
## Advertencia
**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos
hasta que se libere la versión **1.0.0**.
## Licencia
El código está disponible bajo una doble licencia:
* **Licencia MIT**
([LICENSE-MIT](LICENSE-MIT) o también https://opensource.org/licenses/MIT)
* **Licencia Apache, Versión 2.0**
([LICENSE-APACHE](LICENSE-APACHE) o también https://www.apache.org/licenses/LICENSE-2.0)
Puedes elegir la licencia que prefieras. Este enfoque de doble licencia es el estándar de facto en
el ecosistema Rust.

View file

@ -0,0 +1,7 @@
use pagetop_build::StaticFilesBundle;
fn main() -> std::io::Result<()> {
StaticFilesBundle::from_dir("./static", None)
.with_name("htmx")
.build()
}

View file

@ -0,0 +1,549 @@
//! Constantes para los atributos y valores de HTMX 2.
//!
//! Usar estas constantes en lugar de literales evita errores tipográficos en tiempo de compilación.
//! Una mala declaración como `"hx-gte"` no genera ningún error en tiempo de ejecución, falla
//! silenciosamente. Con constantes, el compilador lo detecta de inmediato.
//!
//! # Atributos estáticos en `html!`
//!
//! Para valores conocidos en tiempo de compilación, los atributos `hx-*` pueden escribirse
//! directamente en la macro `html!` sin necesidad de [`Props`](pagetop::html::Props):
//!
//! ```rust,no_run
//! use pagetop::prelude::*;
//!
//! let markup = html! {
//! button hx-get="/api/items" hx-target="#list" hx-swap="outerHTML" { "Load" }
//! };
//! ```
//!
//! # Atributos dinámicos con [`Props`](pagetop::html::Props)
//!
//! Cuando los valores se construyen en tiempo de ejecución o se inyectan desde una extensión,
//! puedes usar [`Props`](pagetop::html::Props) combinado con las constantes de este módulo:
//!
//! ```rust,no_run
//! use pagetop::prelude::*;
//! use pagetop_htmx::hx;
//!
//! 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::SWAP, hx::swap::OUTER_HTML));
//!
//! let markup = html! {
//! button (props) { "Load" }
//! };
//! ```
//!
//! # Integración en componentes
//!
//! El patrón recomendado es añadir un campo `props: Props` al componente y exponerlo con
//! `with_prop()`. Cualquier extensión puede entonces inyectar atributos HTMX sin que el componente
//! ni el tema necesiten conocer HTMX:
//!
//! ```rust,no_run
//! use pagetop::prelude::*;
//! use pagetop_htmx::hx;
//!
//! #[derive(AutoDefault, Getters)]
//! pub struct MyButton {
//! props: Props,
//! }
//!
//! impl MyButton {
//! pub fn new() -> Self { Self::default() }
//!
//! #[builder_fn]
//! pub fn with_prop(mut self, op: PropsOp) -> Self {
//! self.props.alter_prop(op);
//! self
//! }
//! }
//!
//! MyButton::new()
//! .with_prop(PropsOp::set(hx::POST, "/api/save"))
//! .with_prop(PropsOp::set(hx::TARGET, "#message"))
//! .with_prop(PropsOp::set(hx::SWAP, hx::swap::INNER_HTML));
//! ```
//!
//! # Eventos en línea
//!
//! Para el atributo `hx-on:*` (cuyo nombre incluye el evento y no puede ser una constante) usa las
//! funciones [`on()`] y [`on_htmx()`]:
//!
//! ```rust,no_run
//! use pagetop::prelude::*;
//! use pagetop_htmx::hx;
//!
//! // Evento nativo del DOM: hx-on:click="..."
//! // Evento propio de HTMX: hx-on::after-swap="..."
//! let props = Props::new(hx::on("click"), "this.classList.toggle('active')")
//! .with_prop(PropsOp::set(hx::on_htmx("after-swap"), "console.log('done')"));
//! ```
// **< HTTP Methods >*******************************************************************************
/// Realiza una petición GET al servidor y aplica la respuesta al objetivo.
///
/// Es el atributo HTMX más común: carga contenido desde el servidor sin recargar la página.
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_htmx::hx;
/// let props = Props::new(hx::GET, "/api/search")
/// .with_prop(PropsOp::set(hx::TARGET, "#results"));
/// ```
pub const GET: &str = "hx-get";
/// Realiza una petición POST al servidor.
///
/// Se usa habitualmente para enviar datos de formulario o acciones que modifican el estado del
/// servidor. Para subida de ficheros, combinar con [`ENCODING`] = `"multipart/form-data"`.
pub const POST: &str = "hx-post";
/// Realiza una petición PUT al servidor.
pub const PUT: &str = "hx-put";
/// Realiza una petición PATCH al servidor.
pub const PATCH: &str = "hx-patch";
/// Realiza una petición DELETE al servidor.
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_htmx::hx;
/// // Al eliminar un elemento, reemplazarlo con respuesta vacía borra el nodo del DOM.
/// let props = Props::new(hx::DELETE, "/api/item/42")
/// .with_prop(PropsOp::set(hx::TARGET, "closest li"))
/// .with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML));
/// ```
pub const DELETE: &str = "hx-delete";
// **< Target and Swap >****************************************************************************
/// Selector CSS del elemento que recibirá la respuesta. Por defecto, el elemento mismo.
///
/// Además de selectores CSS estándar, HTMX acepta `"this"` (el elemento), `"closest X"` (ancestro
/// más próximo), `"find X"` (descendiente) y `"next X"` / `"previous X"`.
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_htmx::hx;
/// let props = Props::new(hx::GET, "/api/detalles")
/// .with_prop(PropsOp::set(hx::TARGET, "closest article"));
/// ```
pub const TARGET: &str = "hx-target";
/// Cómo se inserta la respuesta en el DOM. Por defecto, `innerHTML`.
///
/// Los valores estándar están disponibles en el sub-módulo [`swap`]. Además del modo base, se
/// pueden añadir modificadores separados por espacio: retrasos (`swap:200ms`), tiempo de
/// asentamiento (`settle:300ms`), scroll (`scroll:top`) y foco (`focus-scroll:true`).
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_htmx::hx;
/// // Reemplaza el elemento completo con una transición de 300 ms.
/// let props = Props::new(hx::SWAP, "outerHTML swap:300ms");
/// // O usando la constante tipada más los modificadores:
/// let props = Props::new(hx::SWAP, format!("{} swap:300ms", hx::swap::OUTER_HTML));
/// ```
pub const SWAP: &str = "hx-swap";
/// Sustituye fuera de banda el elemento del DOM cuyo `id` coincida con el indicado.
///
/// La respuesta del servidor puede incluir elementos marcados con `hx-swap-oob="true"`: HTMX los
/// extrae y actualiza el DOM en la posición correcta, independientemente del objetivo principal.
pub const SWAP_OOB: &str = "hx-swap-oob";
/// Selector CSS del fragmento de la respuesta que se insertará en el objetivo.
///
/// Permite devolver una página completa desde el servidor y que HTMX extraiga sólo la parte
/// relevante, facilitando la reutilización de rutas existentes.
pub const SELECT: &str = "hx-select";
/// Selector CSS de fragmentos de la respuesta que se sustituyen fuera de banda.
pub const SELECT_OOB: &str = "hx-select-oob";
// **< Trigger >************************************************************************************
/// Evento que activa la petición. Por defecto, `click` en botones y links, `change` en inputs.
///
/// Los valores simples están disponibles en el sub-módulo [`trigger`]. Las expresiones de disparo
/// compuestas se escriben como literales de cadena:
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_htmx::hx;
/// // Buscar mientras se escribe, con 400 ms de espera y sólo si el valor cambia:
/// let props = Props::new(hx::GET, "/api/search")
/// .with_prop(PropsOp::set(hx::TRIGGER, "keyup changed delay:400ms"))
/// .with_prop(PropsOp::set(hx::TARGET, "#results"));
///
/// // Disparar una vez al cargar la página:
/// let lazy = Props::new(hx::GET, "/api/stats")
/// .with_prop(PropsOp::set(hx::TRIGGER, "load once"));
///
/// // Polling cada 5 segundos:
/// let poll = Props::new(hx::GET, "/api/estado")
/// .with_prop(PropsOp::set(hx::TRIGGER, "every 5s"));
/// ```
pub const TRIGGER: &str = "hx-trigger";
/// Convierte en AJAX todos los enlaces y formularios del elemento y sus descendientes.
///
/// El valor debe ser `"true"` o `"false"`. Cuando vale `"true"`, las respuestas se aplican al
/// `<body>` por defecto; se puede combinar con [`TARGET`] para redirigirlas.
pub const BOOST: &str = "hx-boost";
/// Empuja la URL de la respuesta al historial del navegador.
///
/// Acepta `"true"` (usa la URL de la petición), `"false"` (desactiva) o una URL concreta. Permite
/// navegación con el botón atrás manteniendo el comportamiento SPA.
pub const PUSH_URL: &str = "hx-push-url";
/// Reemplaza la URL actual en el historial sin añadir una nueva entrada.
///
/// Acepta `"true"`, `"false"` o una URL concreta. Útil cuando la petición refina la vista sin que
/// deba ser un paso independiente en el historial.
pub const REPLACE_URL: &str = "hx-replace-url";
/// Sincroniza las peticiones del elemento con las de otros elementos.
///
/// Formato: `"selector:estrategia"`. Estrategias disponibles: `drop` (descarta la nueva si hay una
/// en curso), `abort` (cancela la nueva), `replace` (cancela la anterior), `queue first|last|all`
/// (encola). Ejemplo: `"#form:abort"`.
pub const SYNC: &str = "hx-sync";
// **< Request Data >*******************************************************************************
/// Selector CSS de elementos adicionales cuyos valores se incluyen en la petición.
///
/// Acepta selectores CSS estándar más las extensiones de HTMX: `"this"`, `"closest X"`, `"find X"`.
/// Útil para incluir campos de un formulario padre en una petición de detalle.
pub const INCLUDE: &str = "hx-include";
/// Controla qué parámetros del formulario se envían en la petición.
///
/// - `"*"` - todos (valor por defecto).
/// - `"none"` - ninguno.
/// - Lista de nombres: `"name surname"` - sólo esos.
/// - Exclusión: `"not surname"` - todos excepto los indicados.
pub const PARAMS: &str = "hx-params";
/// Valores extra en JSON que se añaden a los parámetros de la petición.
///
/// Formato: objeto JSON. Los valores sobreescriben parámetros del formulario con el mismo nombre.
/// Admite JavaScript con el prefijo `js:`: `"js:{date: new Date().toISOString()}"`.
pub const VALS: &str = "hx-vals";
/// Cabeceras extra en JSON que se añaden a la petición.
///
/// Formato: objeto JSON. Permiten enviar contexto (token de sesión, versión de API, etc.) sin
/// exponerlo en los parámetros visibles del formulario.
pub const HEADERS: &str = "hx-headers";
/// Codificación de la petición. Por defecto, `"application/x-www-form-urlencoded"`.
///
/// Usar `"multipart/form-data"` para peticiones que incluyan campos de tipo `file`.
pub const ENCODING: &str = "hx-encoding";
// **< Element Behavior >***************************************************************************
/// Selector CSS del indicador de carga que se muestra mientras dura la petición.
///
/// El elemento indicado recibe la clase `htmx-request` durante la petición. Por defecto, si no se
/// especifica, la recibe el propio elemento que realiza la petición.
pub const INDICATOR: &str = "hx-indicator";
/// Selector CSS de elementos que se deshabilitan mientras dura la petición.
///
/// Añade el atributo `disabled` durante la petición y lo elimina al terminar. Evita envíos
/// duplicados al hacer clic varias veces.
pub const DISABLED_ELT: &str = "hx-disabled-elt";
/// Muestra un diálogo de confirmación (`window.confirm`) antes de enviar la petición.
///
/// Si el usuario cancela, la petición no se realiza. El valor es el texto del mensaje.
pub const CONFIRM: &str = "hx-confirm";
/// Muestra un `prompt` de texto y envía el resultado como cabecera `HX-Prompt`.
///
/// Si el usuario cancela el prompt, la petición no se realiza.
pub const PROMPT: &str = "hx-prompt";
/// Activa la validación HTML5 del formulario antes de enviar la petición.
///
/// Si algún campo no supera la validación nativa del navegador, la petición se cancela.
pub const VALIDATE: &str = "hx-validate";
/// Preserva el elemento entre respuestas HTMX.
///
/// El elemento debe tener un `id` único. HTMX no lo destruye ni lo recrea al aplicar la respuesta,
/// manteniendo su estado interno (p. ej. posición de reproducción de un vídeo).
pub const PRESERVE: &str = "hx-preserve";
// **< Config and Extensions >**********************************************************************
/// Activa una o varias extensiones HTMX en el elemento y sus descendientes.
///
/// Las extensiones se identifican por nombre y se separan con comas. Extensiones comunes:
/// - `"ws"` - soporte WebSocket.
/// - `"sse"` - soporte Server-Sent Events.
/// - `"json-enc"` - codifica la petición como JSON en lugar de form-urlencoded.
/// - `"loading-states"` - gestión avanzada de estados de carga.
pub const EXT: &str = "hx-ext";
/// Atributos HTMX que los elementos descendientes NO heredarán de este elemento.
///
/// Acepta una lista de atributos separados por comas o `"*"` para bloquear toda herencia.
pub const DISINHERIT: &str = "hx-disinherit";
/// Atributos HTMX que los elementos descendientes SÍ heredarán (anula [`DISINHERIT`]).
pub const INHERIT: &str = "hx-inherit";
/// Opciones de configuración de la petición en JSON.
///
/// Claves disponibles: `timeout` (ms), `credentials` (`"include"`, `"omit"`...), `noHeaders`
/// (bool), `getWithBody` (bool).
pub const REQUEST: &str = "hx-request";
/// Controla si este elemento participa en el historial del navegador.
///
/// Con el valor `"false"`, las peticiones de este elemento no se guardan en el historial aunque
/// [`PUSH_URL`] esté activo en un elemento padre.
pub const HISTORY: &str = "hx-history";
/// Designa el elemento como contenedor del historial del navegador.
///
/// HTMX guarda y restaura el contenido de este elemento al navegar hacia atrás/adelante. Sólo debe
/// haber un elemento con este atributo en la página.
pub const HISTORY_ELT: &str = "hx-history-elt";
/// Desactiva el procesamiento HTMX en el elemento y todos sus descendientes.
///
/// Útil para aislar zonas del DOM gestionadas por otra librería o para desactivar HTMX en secciones
/// de contenido generado dinámicamente donde no debe intervenir.
pub const DISABLE: &str = "hx-disable";
// **< Inline Events (hx-on) >**********************************************************************
/// Genera `hx-on:{event}` para escuchar eventos nativos del DOM en línea.
///
/// Es la alternativa de HTMX a los manejadores `on*` de HTML (`onclick`, `onmouseenter`, ...). La
/// diferencia clave está en cómo los trata el navegador bajo una política CSP (*Content Security
/// Policy*): los atributos `on*` son JavaScript en línea y quedan bloqueados si la CSP no incluye
/// `'unsafe-inline'`; en cambio, `hx-on:*` es un atributo de datos que HTMX lee e interpreta desde
/// su propio código ya autorizado, por lo que la CSP no lo bloquea.
///
/// La CSP puede definirla el servidor en la cabecera HTTP `Content-Security-Policy`, o la
/// aplicación en una etiqueta `<meta http-equiv="Content-Security-Policy">` en el `<head>` del
/// documento. En la práctica, pocas aplicaciones configuran una CSP estricta, pero es una buena
/// práctica de seguridad que conviene tener en cuenta.
///
/// El valor es código JavaScript que se ejecuta cuando el evento se dispara; `event` contiene el
/// objeto del evento.
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_htmx::hx;
/// let props = Props::new(hx::on("click"), "this.classList.toggle('active')")
/// .with_prop(PropsOp::set(hx::on("mouseenter"), "this.style.opacity='0.8'"));
/// ```
pub fn on(event: &str) -> String {
format!("hx-on:{event}")
}
/// Genera `hx-on::{event}` para escuchar eventos propios de HTMX en línea.
///
/// Los eventos de HTMX usan un doble carácter dos-puntos (`hx-on::evento`). El ciclo de vida
/// completo incluye `before-request`, `after-request`, `before-swap`, `after-swap`,
/// `before-settle`, `after-settle`, `after-on-load`, `history-restore`, entre otros.
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_htmx::hx;
/// let props = Props::new(hx::on_htmx("before-request"), "console.log('enviando...')")
/// .with_prop(PropsOp::set(hx::on_htmx("after-swap"), "initTooltips()"));
/// ```
pub fn on_htmx(event: &str) -> String {
format!("hx-on::{event}")
}
// **< HTMX Request Headers >***********************************************************************
/// Nombres de las cabeceras que HTMX envía con cada petición AJAX.
///
/// Están en minúsculas porque así las normaliza el módulo `http`. Se pueden usar con
/// [`HttpRequest::headers()`](pagetop::web::HttpRequest::headers) para leer sus valores
/// directamente, aunque lo habitual es usar el trait [`HtmxRequestExt`](crate::HtmxRequestExt).
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::hx;
///
/// async fn handler(request: HttpRequest) {
/// if let Some(target) = request.headers().get(hx::request::TARGET) {
/// // El elemento objetivo tenía este id.
/// }
/// }
/// ```
pub mod request {
/// Siempre `"true"` en peticiones HTMX. Permite distinguirlas de navegaciones directas.
pub const REQUEST: &str = "hx-request";
/// `"true"` si la petición viene de un enlace o formulario con `hx-boost`.
pub const BOOSTED: &str = "hx-boosted";
/// URL de la página activa en el navegador cuando se realizó la petición.
pub const CURRENT_URL: &str = "hx-current-url";
/// `"true"` si la petición es una restauración del historial del navegador.
pub const HISTORY_RESTORE_REQUEST: &str = "hx-history-restore-request";
/// Texto introducido por el usuario en un diálogo `hx-prompt`.
pub const PROMPT: &str = "hx-prompt";
/// Valor del atributo `id` del elemento objetivo de la petición.
pub const TARGET: &str = "hx-target";
/// Valor del atributo `id` del elemento que disparó la petición.
pub const TRIGGER: &str = "hx-trigger";
/// Valor del atributo `name` del elemento que disparó la petición.
pub const TRIGGER_NAME: &str = "hx-trigger-name";
}
// **< HTMX Response Headers >**********************************************************************
/// Cabeceras de respuesta HTTP para HTMX.
///
/// Se pueden usar con [`HeaderMap`](pagetop::web::http::HeaderMap) para construir respuestas
/// manualmente, aunque lo habitual es usar el constructor [`HtmxResponse`](crate::HtmxResponse).
///
/// ```rust,no_run
/// use pagetop_htmx::hx;
/// use pagetop::web::http::{HeaderMap, HeaderName, HeaderValue};
///
/// let mut headers = HeaderMap::new();
/// headers.insert(
/// hx::response::TRIGGER.parse::<HeaderName>().unwrap(),
/// HeaderValue::from_static("itemAdded"),
/// );
/// ```
pub mod response {
/// Redirige mediante AJAX a la URL o configuración JSON indicada. Ver
/// [`HtmxResponse::location()`](crate::HtmxResponse::location).
pub const LOCATION: &str = "HX-Location";
/// Empuja la URL indicada al historial del navegador. Ver
/// [`HtmxResponse::push_url()`](crate::HtmxResponse::push_url).
pub const PUSH_URL: &str = "HX-Push-Url";
/// Provoca una redirección completa del navegador. Ver
/// [`HtmxResponse::redirect()`](crate::HtmxResponse::redirect).
pub const REDIRECT: &str = "HX-Redirect";
/// Provoca una recarga completa de la página. Ver
/// [`HtmxResponse::refresh()`](crate::HtmxResponse::refresh).
pub const REFRESH: &str = "HX-Refresh";
/// Reemplaza la URL actual en el historial. Ver
/// [`HtmxResponse::replace_url()`](crate::HtmxResponse::replace_url).
pub const REPLACE_URL: &str = "HX-Replace-Url";
/// Anula el `hx-swap` del elemento. Ver
/// [`HtmxResponse::reswap()`](crate::HtmxResponse::reswap).
pub const RESWAP: &str = "HX-Reswap";
/// Anula el `hx-target` del elemento. Ver
/// [`HtmxResponse::retarget()`](crate::HtmxResponse::retarget).
pub const RETARGET: &str = "HX-Retarget";
/// Anula el `hx-select` del elemento. Ver
/// [`HtmxResponse::reselect()`](crate::HtmxResponse::reselect).
pub const RESELECT: &str = "HX-Reselect";
/// Dispara eventos JavaScript al completar la respuesta. Ver
/// [`HtmxResponse::trigger()`](crate::HtmxResponse::trigger).
pub const TRIGGER: &str = "HX-Trigger";
/// Dispara eventos tras la fase *settle*. Ver
/// [`HtmxResponse::trigger_after_settle()`](crate::HtmxResponse::trigger_after_settle).
pub const TRIGGER_AFTER_SETTLE: &str = "HX-Trigger-After-Settle";
/// Dispara eventos tras el *swap*. Ver
/// [`HtmxResponse::trigger_after_swap()`](crate::HtmxResponse::trigger_after_swap).
pub const TRIGGER_AFTER_SWAP: &str = "HX-Trigger-After-Swap";
}
// **< hx-swap Values >*****************************************************************************
/// Valores estándar del atributo [`SWAP`] (`hx-swap`).
///
/// Se pueden combinar con modificadores separados por espacio:
/// - `swap:Xms` - tiempo de espera antes de realizar el intercambio.
/// - `settle:Xms` - tiempo de espera antes de quitar las clases de transición.
/// - `scroll:top` / `scroll:bottom` - desplaza el objetivo tras el intercambio.
/// - `show:top` / `show:bottom` - hace visible el objetivo tras el intercambio.
/// - `focus-scroll:true` - sigue al elemento enfocado.
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_htmx::hx;
/// // 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));
/// ```
pub mod swap {
/// Reemplaza el contenido interior del objetivo (valor por defecto de HTMX).
pub const INNER_HTML: &str = "innerHTML";
/// Reemplaza el elemento objetivo completo.
pub const OUTER_HTML: &str = "outerHTML";
/// Inserta la respuesta antes de la etiqueta de apertura del objetivo.
pub const BEFORE_BEGIN: &str = "beforebegin";
/// Inserta la respuesta al inicio del contenido del objetivo.
pub const AFTER_BEGIN: &str = "afterbegin";
/// Inserta la respuesta al final del contenido del objetivo.
pub const BEFORE_END: &str = "beforeend";
/// Inserta la respuesta después de la etiqueta de cierre del objetivo.
pub const AFTER_END: &str = "afterend";
/// Elimina el elemento objetivo independientemente de la respuesta.
pub const DELETE: &str = "delete";
/// No realiza ningún intercambio; útil cuando sólo importan las cabeceras de respuesta.
pub const NONE: &str = "none";
}
// **< hx-trigger Values >**************************************************************************
/// Eventos comunes del atributo [`TRIGGER`] (`hx-trigger`).
///
/// Estos valores cubren los disparadores más simples. Las expresiones de disparo compuestas deben
/// escribirse como literales de cadena. Modificadores disponibles:
/// - `once` - se dispara sólo la primera vez.
/// - `changed` - sólo si el valor del elemento ha cambiado.
/// - `delay:Xms` - espera antes de disparar (se cancela si el evento vuelve a ocurrir).
/// - `throttle:Xms` - limita la frecuencia máxima de disparo.
/// - `from:selector` - escucha el evento en otro elemento.
/// - `target:selector` - sólo si el evento viene del selector indicado.
/// - `consume` - evita que el evento se propague a otros elementos HTMX.
/// - `queue:first|last|all|none` - política de cola cuando llegan eventos consecutivos.
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_htmx::hx;
/// // 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");
///
/// // Carga diferida al entrar en el viewport, una sola vez.
/// let lazy = Props::new(hx::TRIGGER, "intersect once");
///
/// // Polling: actualiza cada 10 segundos mientras el elemento esté en el DOM.
/// let poll = Props::new(hx::TRIGGER, "every 10s");
///
/// // Múltiples eventos: clic o pulsación de Enter en el campo.
/// let multi = Props::new(hx::TRIGGER, "click, keyup[key=='Enter']");
///
/// // Escucha un evento personalizado emitido desde otro elemento.
/// let custom = Props::new(hx::TRIGGER, "itemAdded from:body");
/// ```
pub mod trigger {
/// Se dispara al hacer clic (valor por defecto en la mayoría de elementos interactivos).
pub const CLICK: &str = "click";
/// Se dispara cuando el valor del elemento cambia (valor por defecto en `input`/`select`).
pub const CHANGE: &str = "change";
/// Se dispara al enviar un formulario.
pub const SUBMIT: &str = "submit";
/// Se dispara al soltar una tecla.
pub const KEYUP: &str = "keyup";
/// Se dispara cuando la página termina de cargarse.
pub const LOAD: &str = "load";
/// Se dispara cuando el elemento entra en el área visible del *viewport* al hacer scroll.
pub const REVEALED: &str = "revealed";
/// Se dispara cuando el elemento intersecta con el *viewport* (Intersection Observer API).
pub const INTERSECT: &str = "intersect";
}

View file

@ -0,0 +1,129 @@
/*!
<div align="center">
<h1>PageTop HTMX</h1>
<p>Extensión para <strong>PageTop</strong> que integra <a href="https://htmx.org">HTMX</a> para enriquecer las páginas con interacciones dinámicas.</p>
[![Doc API](https://img.shields.io/docsrs/pagetop-htmx?label=Doc%20API&style=for-the-badge&logo=Docs.rs)](https://docs.rs/pagetop-htmx)
[![Crates.io](https://img.shields.io/crates/v/pagetop-htmx.svg?style=for-the-badge&logo=ipfs)](https://crates.io/crates/pagetop-htmx)
[![Descargas](https://img.shields.io/crates/d/pagetop-htmx.svg?label=Descargas&style=for-the-badge&logo=transmission)](https://crates.io/crates/pagetop-htmx)
[![Licencia](https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label=Licencia&style=for-the-badge)](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/extensions/pagetop-htmx#licencia)
</div>
## Sobre PageTop
[PageTop](https://docs.rs/pagetop) es un entorno de desarrollo que reivindica la esencia de la web
clásica para crear soluciones web SSR (*renderizadas en el servidor*) modulares, extensibles y
configurables, basadas en HTML, CSS y JavaScript.
## Guía rápida
**Añade la dependencia** a tu `Cargo.toml`:
```toml
[dependencies]
pagetop-htmx = { ... }
```
**Declara la extensión** en tu aplicación (o extensión que la requiera). Recuerda que el orden en
`dependencies()` determina la prioridad relativa frente a las otras extensiones:
```rust
use pagetop::prelude::*;
struct MyApp;
impl Extension for MyApp {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![
// ...
&pagetop_htmx::Htmx
// ...
]
}
}
```
A partir de ese momento, todas las páginas de la aplicación incluirán automáticamente el script de
HTMX 2. Puedes usar los atributos `hx-*` directamente en tus componentes o el código HTML generado:
```rust
use pagetop::prelude::*;
async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_child(Html::with(|_| html! {
button hx-get="/api/hello" hx-target="#result" {
"Say hello"
}
div #result {}
}))
.render()
}
```
*/
use pagetop::prelude::*;
pub mod hx;
mod request;
pub use request::HtmxRequestExt;
mod response;
pub use response::HtmxResponse;
include_locales!(LOCALES_HTMX);
/// Integra HTMX 2 en cualquier aplicación PageTop.
///
/// Poner esta extensión en [`dependencies()`](pagetop::core::extension::Extension::dependencies)
/// hace que todas las páginas de la aplicación incluyan automáticamente el script de HTMX mediante
/// un atributo [`defer`](pagetop::html::JavaScript::defer). No es necesaria ninguna configuración
/// adicional.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// struct MyApp;
///
/// impl Extension for MyApp {
/// fn dependencies(&self) -> Vec<ExtensionRef> {
/// vec![
/// // ...
/// &pagetop_htmx::Htmx
/// // ...
/// ]
/// }
/// }
/// ```
pub struct Htmx;
impl Extension for Htmx {
fn name(&self) -> L10n {
L10n::t("extension_name", &LOCALES_HTMX)
}
fn description(&self) -> L10n {
L10n::t("extension_description", &LOCALES_HTMX)
}
fn actions(&self) -> Vec<ActionBox> {
actions![action::page::BeforeRenderBody::new(add_htmx_script)]
}
fn configure_router(&self, router: Router) -> Router {
serve_static_files!(router, [htmx] => "/htmx");
router
}
}
fn add_htmx_script(page: &mut Page) {
page.alter_assets(AssetsOp::AddJavaScript(
JavaScript::defer("/htmx/js/htmx.min.js").with_version("2.0.10"),
));
}

View file

@ -0,0 +1,2 @@
extension_name = HTMX
extension_description = Integrates HTMX to enrich pages with dynamic interactions.

View file

@ -0,0 +1,2 @@
extension_name = HTMX
extension_description = Integra HTMX para enriquecer las páginas con interacciones dinámicas.

View file

@ -0,0 +1,132 @@
//! 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())
}

View file

@ -0,0 +1,228 @@
//! Implementación de [`HtmxResponse`] e [`IntoResponse`](pagetop::web::IntoResponse) para HTMX.
use pagetop::prelude::*;
// **< HtmxResponse >*******************************************************************************
/// Generador de respuestas HTML parciales con cabeceras HTMX.
///
/// En una aplicación HTMX, los *handlers* del servidor devuelven con frecuencia fragmentos HTML
/// parciales acompañados de cabeceras especiales que instruyen al cliente sobre qué hacer con la
/// respuesta: actualizar la URL del historial, disparar eventos JavaScript, redirigir, etc.
///
/// Implementa [`IntoResponse`](pagetop::web::IntoResponse), por lo que puede devolverse
/// directamente desde cualquier *handler*.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::{HtmxResponse, hx};
///
/// async fn add_item(request: HttpRequest) -> impl IntoResponse {
/// let new_item = html! { li #item-42 { "New item" } };
///
/// HtmxResponse::new(new_item)
/// .retarget("#list")
/// .reswap(hx::swap::BEFORE_END)
/// .push_url("/items")
/// .trigger("itemAdded")
/// }
/// ```
///
/// # Respuestas de sólo cabeceras
///
/// Cuando la respuesta no lleva cuerpo HTML (por ejemplo, una redirección o un refresco), usa
/// [`HtmxResponse::empty()`](Self::empty):
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::HtmxResponse;
///
/// async fn delete_item() -> impl IntoResponse {
/// HtmxResponse::empty().redirect("/items")
/// }
/// ```
///
/// # Construcción
///
/// - [`HtmxResponse::new(markup)`](Self::new), con el fragmento HTML.
/// - [`HtmxResponse::empty()`](Self::empty), sin cuerpo, sólo cabeceras.
///
/// # Cabeceras disponibles
///
/// Los nombres de cabecera como constantes están en [`crate::hx::response`].
///
/// # Múltiples eventos en `trigger`
///
/// Para disparar varios eventos en una sola llamada, pasa una cadena con comas o un objeto JSON:
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::HtmxResponse;
///
/// // Dos eventos sin datos:
/// HtmxResponse::empty().trigger("itemAdded, listUpdated");
///
/// // Evento con datos en JSON:
/// HtmxResponse::empty().trigger(r#"{"itemAdded": {"id": 42}}"#);
/// ```
pub struct HtmxResponse {
markup: Markup,
headers: web::http::HeaderMap,
}
impl HtmxResponse {
/// Crea una respuesta con el fragmento HTML indicado.
pub fn new(markup: Markup) -> Self {
Self {
markup,
headers: web::http::HeaderMap::new(),
}
}
/// Crea una respuesta sin cuerpo HTML, útil para respuestas de sólo cabeceras.
pub fn empty() -> Self {
Self::new(html! {})
}
// **< HtmxResponse BUILDER >*******************************************************************
/// 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
/// objetivo definido por el destino. Acepta una URL o un objeto JSON con claves `path`,
/// `target`, `swap`, `select` y `values` para personalizar la navegación:
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::HtmxResponse;
///
/// // Navegación simple:
/// HtmxResponse::empty().location("/items");
///
/// // Navegación con destino personalizado:
/// HtmxResponse::empty()
/// .location(r##"{"path": "/items", "target": "#content"}"##);
/// ```
pub fn location(self, url: impl Into<String>) -> Self {
self.set_header(b"hx-location", url)
}
/// Empuja la URL indicada al historial del navegador.
///
/// 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.
pub fn push_url(self, url: impl Into<String>) -> Self {
self.set_header(b"hx-push-url", url)
}
/// Reemplaza la URL actual en el historial sin añadir una nueva entrada.
///
/// Usar `"false"` para desactivar el reemplazo.
pub fn replace_url(self, url: impl Into<String>) -> Self {
self.set_header(b"hx-replace-url", url)
}
/// 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
/// completo, como un `window.location.href = url` en JavaScript.
pub fn redirect(self, url: impl Into<String>) -> Self {
self.set_header(b"hx-redirect", url)
}
/// Provoca una recarga completa de la página actual.
///
/// Equivale a `window.location.reload()` en JavaScript.
pub fn refresh(self) -> Self {
self.set_header(b"hx-refresh", "true")
}
/// Anula el `hx-target` del elemento y redirige la respuesta al selector CSS indicado.
///
/// Útil cuando el servidor necesita actualizar un elemento distinto al que realizó la petición,
/// sin modificar el HTML del cliente.
pub fn retarget(self, selector: impl Into<String>) -> Self {
self.set_header(b"hx-retarget", selector)
}
/// Anula el `hx-swap` del elemento e impone la estrategia de sustitución indicada.
///
/// Acepta los mismos valores que el atributo `hx-swap`, incluidos modificadores (`swap:200ms`,
/// `scroll:top`, ...). Los valores tipados están en [`crate::hx::swap`].
pub fn reswap(self, strategy: impl Into<String>) -> Self {
self.set_header(b"hx-reswap", strategy)
}
/// Anula el `hx-select` del elemento y selecciona el fragmento CSS indicado de la respuesta
/// para insertarlo en el objetivo.
pub fn reselect(self, selector: impl Into<String>) -> Self {
self.set_header(b"hx-reselect", selector)
}
/// Dispara uno o varios eventos JavaScript en el cliente al completar la respuesta.
///
/// Los eventos se disparan inmediatamente tras procesar la respuesta. Para disparar eventos con
/// datos o después de otras fases del ciclo HTMX, ver
/// [`trigger_after_settle()`](Self::trigger_after_settle) y
/// [`trigger_after_swap()`](Self::trigger_after_swap).
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::HtmxResponse;
///
/// // Evento simple:
/// HtmxResponse::empty().trigger("itemAdded");
///
/// // Múltiples eventos sin datos:
/// HtmxResponse::empty().trigger("itemAdded, listUpdated");
///
/// // Evento con datos en JSON:
/// HtmxResponse::empty().trigger(r#"{"itemAdded": {"id": 42, "name": "Example"}}"#);
/// ```
pub fn trigger(self, event: impl Into<String>) -> Self {
self.set_header(b"hx-trigger", event)
}
/// Dispara eventos JavaScript después de que HTMX haya aplicado la respuesta al DOM y haya
/// completado la fase de *settle* (animaciones CSS).
///
/// Acepta los mismos formatos que [`trigger()`](Self::trigger).
pub fn trigger_after_settle(self, event: impl Into<String>) -> Self {
self.set_header(b"hx-trigger-after-settle", event)
}
/// Dispara eventos JavaScript después de que HTMX haya aplicado la respuesta al DOM, pero antes
/// de la fase de *settle*.
///
/// Acepta los mismos formatos que [`trigger()`](Self::trigger).
pub fn trigger_after_swap(self, event: impl Into<String>) -> Self {
self.set_header(b"hx-trigger-after-swap", event)
}
// Inserta o reemplaza una cabecera. Los nombres deben ser bytes ASCII en minúsculas.
fn set_header(mut self, name: &[u8], value: impl Into<String>) -> Self {
let value = value.into();
if let (Ok(n), Ok(v)) = (
web::http::HeaderName::from_bytes(name),
web::http::HeaderValue::from_str(&value),
) {
self.headers.insert(n, v);
} else {
trace::warn!(value = %value, "HtmxResponse: invalid header value, header discarded");
}
self
}
}
impl web::IntoResponse for HtmxResponse {
fn into_response(self) -> Response {
let mut headers = self.headers;
headers.insert(
web::http::header::CONTENT_TYPE,
web::http::HeaderValue::from_static("text/html; charset=utf-8"),
);
(headers, self.markup.into_string()).into_response()
}
}

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@ pub use crate::locale::*;
pub use crate::datetime::*;
pub use crate::web;
pub use crate::web::{HttpRequest, Router};
pub use crate::web::{HttpRequest, IntoResponse, Response, Router};
pub use crate::core::{AnyCast, AnyInfo, TypeInfo};

View file

@ -65,6 +65,7 @@ case "$CRATE" in
# Extensions
--exclude-path "extensions/pagetop-aliner/**/*"
--exclude-path "extensions/pagetop-bootsier/**/*"
--exclude-path "extensions/pagetop-htmx/**/*"
--exclude-path "extensions/pagetop-seaorm/**/*"
)
;;
@ -76,6 +77,10 @@ case "$CRATE" in
CHANGELOG_FILE="extensions/pagetop-bootsier/CHANGELOG.md"
PATH_FLAGS=(--include-path "extensions/pagetop-bootsier/**/*")
;;
pagetop-htmx)
CHANGELOG_FILE="extensions/pagetop-htmx/CHANGELOG.md"
PATH_FLAGS=(--include-path "extensions/pagetop-htmx/**/*")
;;
pagetop-seaorm)
CHANGELOG_FILE="extensions/pagetop-seaorm/CHANGELOG.md"
PATH_FLAGS=(--include-path "extensions/pagetop-seaorm/**/*")