From e7f2563967e4523fd26afc4e4a6ee0d91b1d245d Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Thu, 23 Jul 2026 07:21:27 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20(htmx):=20A=C3=B1ade=20soporte?= =?UTF-8?q?=20HTMX=20a=20tablas=20ordenables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nuevo `SortDir` en `pagetop::html` para representar direcciones de orden (asc/desc) y calcular la siguiente al pulsar una cabecera. - `hx_table::sort_link()` construye el enlace de ordenación con los cuatro atributos `hx-*` fijos, reutilizable en cualquier tabla. - `HtmxResponse` usa `RoutePath` en `location`/`push_url`/`replace_url`/ `redirect` para preservar "lang"; `location_json()` se separa de `location()` para el caso de configuración JSON personalizada. - Añade el módulo `prelude` y una batería de tests para hx, hx_table, request, response y extension. --- extensions/pagetop-htmx/Cargo.toml | 1 + extensions/pagetop-htmx/README.md | 21 ++ extensions/pagetop-htmx/src/hx.rs | 48 +++-- extensions/pagetop-htmx/src/hx_table.rs | 74 +++++++ extensions/pagetop-htmx/src/lib.rs | 33 ++- extensions/pagetop-htmx/src/request.rs | 2 +- extensions/pagetop-htmx/src/response.rs | 138 ++++++++++-- extensions/pagetop-htmx/tests/extension.rs | 62 ++++++ extensions/pagetop-htmx/tests/hx.rs | 160 ++++++++++++++ extensions/pagetop-htmx/tests/hx_table.rs | 162 ++++++++++++++ extensions/pagetop-htmx/tests/request.rs | 169 +++++++++++++++ extensions/pagetop-htmx/tests/response.rs | 233 +++++++++++++++++++++ src/html.rs | 3 + src/html/sort_dir.rs | 116 ++++++++++ 14 files changed, 1176 insertions(+), 46 deletions(-) create mode 100644 extensions/pagetop-htmx/src/hx_table.rs create mode 100644 extensions/pagetop-htmx/tests/extension.rs create mode 100644 extensions/pagetop-htmx/tests/hx.rs create mode 100644 extensions/pagetop-htmx/tests/hx_table.rs create mode 100644 extensions/pagetop-htmx/tests/request.rs create mode 100644 extensions/pagetop-htmx/tests/response.rs create mode 100644 src/html/sort_dir.rs diff --git a/extensions/pagetop-htmx/Cargo.toml b/extensions/pagetop-htmx/Cargo.toml index 5a1c254f..d1159b5b 100644 --- a/extensions/pagetop-htmx/Cargo.toml +++ b/extensions/pagetop-htmx/Cargo.toml @@ -16,6 +16,7 @@ authors.workspace = true [dependencies] pagetop.workspace = true +serde_json.workspace = true [build-dependencies] pagetop-build.workspace = true diff --git a/extensions/pagetop-htmx/README.md b/extensions/pagetop-htmx/README.md index 5d7cf15f..d7578a4d 100644 --- a/extensions/pagetop-htmx/README.md +++ b/extensions/pagetop-htmx/README.md @@ -64,6 +64,27 @@ async fn homepage(request: HttpRequest) -> Result { } ``` +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 { + 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 Este *crate* integra la biblioteca [HTMX 2.0.10](https://htmx.org), distribuida bajo licencia diff --git a/extensions/pagetop-htmx/src/hx.rs b/extensions/pagetop-htmx/src/hx.rs index e30c4651..2f1edc34 100644 --- a/extensions/pagetop-htmx/src/hx.rs +++ b/extensions/pagetop-htmx/src/hx.rs @@ -24,11 +24,9 @@ //! //! ```rust,no_run //! use pagetop::prelude::*; -//! use pagetop_htmx::hx; +//! use pagetop_htmx::prelude::*; //! -//! let endpoint = "/api/items"; // Calculado en tiempo de ejecución. -//! -//! let props = Props::new(hx::GET, endpoint) +//! let props = Props::new(hx::GET, "/api/items") //! .with_prop(PropsOp::set(hx::TARGET, "#list")) //! .with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML)); //! @@ -45,7 +43,7 @@ //! //! ```rust,no_run //! use pagetop::prelude::*; -//! use pagetop_htmx::hx; +//! use pagetop_htmx::prelude::*; //! //! #[derive(AutoDefault, Getters)] //! pub struct MyButton { @@ -75,7 +73,7 @@ //! //! ```rust,no_run //! use pagetop::prelude::*; -//! use pagetop_htmx::hx; +//! use pagetop_htmx::prelude::*; //! //! // Evento nativo del DOM: hx-on:click="..." //! // Evento propio de HTMX: hx-on::after-swap="..." @@ -91,7 +89,7 @@ /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// # use pagetop_htmx::hx; +/// # use pagetop_htmx::prelude::*; /// let props = Props::new(hx::GET, "/api/search") /// .with_prop(PropsOp::set(hx::TARGET, "#results")); /// ``` @@ -113,7 +111,7 @@ pub const PATCH: &str = "hx-patch"; /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// # use pagetop_htmx::hx; +/// # use pagetop_htmx::prelude::*; /// // 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")) @@ -130,7 +128,7 @@ pub const DELETE: &str = "hx-delete"; /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// # use pagetop_htmx::hx; +/// # use pagetop_htmx::prelude::*; /// let props = Props::new(hx::GET, "/api/detalles") /// .with_prop(PropsOp::set(hx::TARGET, "closest article")); /// ``` @@ -144,7 +142,7 @@ pub const TARGET: &str = "hx-target"; /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// # use pagetop_htmx::hx; +/// # use pagetop_htmx::prelude::*; /// // 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: @@ -176,7 +174,7 @@ pub const SELECT_OOB: &str = "hx-select-oob"; /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// # use pagetop_htmx::hx; +/// # use pagetop_htmx::prelude::*; /// // 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")) @@ -294,6 +292,11 @@ pub const PRESERVE: &str = "hx-preserve"; /// - `"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. +/// +/// `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"; /// Atributos HTMX que los elementos descendientes NO heredarán de este elemento. @@ -348,7 +351,7 @@ pub const DISABLE: &str = "hx-disable"; /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// # use pagetop_htmx::hx; +/// # use pagetop_htmx::prelude::*; /// let props = Props::new(hx::on("click"), "this.classList.toggle('active')") /// .with_prop(PropsOp::set(hx::on("mouseenter"), "this.style.opacity='0.8'")); /// ``` @@ -364,7 +367,7 @@ pub fn on(event: &str) -> String { /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// # use pagetop_htmx::hx; +/// # use pagetop_htmx::prelude::*; /// let props = Props::new(hx::on_htmx("before-request"), "console.log('enviando...')") /// .with_prop(PropsOp::set(hx::on_htmx("after-swap"), "initTooltips()")); /// ``` @@ -382,7 +385,7 @@ pub fn on_htmx(event: &str) -> String { /// /// ```rust,no_run /// use pagetop::prelude::*; -/// use pagetop_htmx::hx; +/// use pagetop_htmx::prelude::*; /// /// async fn handler(request: HttpRequest) { /// if let Some(target) = request.headers().get(hx::request::TARGET) { @@ -417,18 +420,19 @@ pub mod request { /// 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}; +/// use pagetop::prelude::*; +/// use pagetop_htmx::prelude::*; /// -/// let mut headers = HeaderMap::new(); +/// let mut headers = web::http::HeaderMap::new(); /// headers.insert( -/// hx::response::TRIGGER.parse::().unwrap(), -/// HeaderValue::from_static("itemAdded"), +/// hx::response::TRIGGER.parse::().unwrap(), +/// web::http::HeaderValue::from_static("itemAdded"), /// ); /// ``` pub mod response { /// Redirige mediante AJAX a la URL o configuración JSON indicada. Ver - /// [`HtmxResponse::location()`](crate::HtmxResponse::location). + /// [`HtmxResponse::location()`](crate::HtmxResponse::location) y + /// [`HtmxResponse::location_json()`](crate::HtmxResponse::location_json). pub const LOCATION: &str = "HX-Location"; /// Empuja la URL indicada al historial del navegador. Ver /// [`HtmxResponse::push_url()`](crate::HtmxResponse::push_url). @@ -475,7 +479,7 @@ pub mod response { /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// # use pagetop_htmx::hx; +/// # use pagetop_htmx::prelude::*; /// // 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)); /// ``` @@ -515,7 +519,7 @@ pub mod swap { /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// # use pagetop_htmx::hx; +/// # use pagetop_htmx::prelude::*; /// // 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"); /// diff --git a/extensions/pagetop-htmx/src/hx_table.rs b/extensions/pagetop-htmx/src/hx_table.rs new file mode 100644 index 00000000..b0cb1e51 --- /dev/null +++ b/extensions/pagetop-htmx/src/hx_table.rs @@ -0,0 +1,74 @@ +//! 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, + target: impl AsRef, + dir: impl Into>, +) -> 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")) +} diff --git a/extensions/pagetop-htmx/src/lib.rs b/extensions/pagetop-htmx/src/lib.rs index d3c0c782..30c96dba 100644 --- a/extensions/pagetop-htmx/src/lib.rs +++ b/extensions/pagetop-htmx/src/lib.rs @@ -64,11 +64,35 @@ async fn homepage(request: HttpRequest) -> Result { .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 { + 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::*; +include_locales!(LOCALES_HTMX); + pub mod hx; +pub mod hx_table; mod request; pub use request::HtmxRequestExt; @@ -76,7 +100,14 @@ pub use request::HtmxRequestExt; mod response; pub use response::HtmxResponse; -include_locales!(LOCALES_HTMX); +/// Prelude de `pagetop-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. /// diff --git a/extensions/pagetop-htmx/src/request.rs b/extensions/pagetop-htmx/src/request.rs index 55dee38f..ff14e2f0 100644 --- a/extensions/pagetop-htmx/src/request.rs +++ b/extensions/pagetop-htmx/src/request.rs @@ -16,7 +16,7 @@ use pagetop::prelude::*; /// /// ```rust,no_run /// use pagetop::prelude::*; -/// use pagetop_htmx::{HtmxRequestExt, HtmxResponse}; +/// use pagetop_htmx::prelude::*; /// /// async fn list_items(request: HttpRequest) -> Response { /// if request.is_htmx() { diff --git a/extensions/pagetop-htmx/src/response.rs b/extensions/pagetop-htmx/src/response.rs index ddb256a3..b9221e92 100644 --- a/extensions/pagetop-htmx/src/response.rs +++ b/extensions/pagetop-htmx/src/response.rs @@ -17,7 +17,7 @@ use pagetop::prelude::*; /// /// ```rust,no_run /// use pagetop::prelude::*; -/// use pagetop_htmx::{HtmxResponse, hx}; +/// use pagetop_htmx::prelude::*; /// /// async fn add_item(request: HttpRequest) -> impl IntoResponse { /// let new_item = html! { li #item-42 { "New item" } }; @@ -37,7 +37,7 @@ use pagetop::prelude::*; /// /// ```rust,no_run /// use pagetop::prelude::*; -/// use pagetop_htmx::HtmxResponse; +/// use pagetop_htmx::prelude::*; /// /// async fn delete_item() -> impl IntoResponse { /// HtmxResponse::empty().redirect("/items") @@ -59,7 +59,7 @@ use pagetop::prelude::*; /// /// ```rust,no_run /// use pagetop::prelude::*; -/// use pagetop_htmx::HtmxResponse; +/// use pagetop_htmx::prelude::*; /// /// // Dos eventos sin datos: /// HtmxResponse::empty().trigger("itemAdded, listUpdated"); @@ -67,6 +67,7 @@ use pagetop::prelude::*; /// // Evento con datos en JSON: /// HtmxResponse::empty().trigger(r#"{"itemAdded": {"id": 42}}"#); /// ``` +#[must_use] pub struct HtmxResponse { markup: Markup, headers: web::http::HeaderMap, @@ -91,45 +92,123 @@ impl HtmxResponse { /// 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: + /// objetivo definido por el destino. Para personalizar `target`, `swap`, `select` o `values`, + /// usa [`location_json()`](Self::location_json). + /// + /// 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 /// use pagetop::prelude::*; - /// use pagetop_htmx::HtmxResponse; + /// use pagetop_htmx::prelude::*; /// - /// // Navegación simple: - /// HtmxResponse::empty().location("/items"); - /// - /// // Navegación con destino personalizado: - /// HtmxResponse::empty() - /// .location(r##"{"path": "/items", "target": "#content"}"##); + /// # fn build_response(cx: &Context) -> HtmxResponse { + /// HtmxResponse::empty().location(cx.route("/items")) + /// # } /// ``` - pub fn location(self, url: impl Into) -> Self { - self.set_header(b"hx-location", url) + pub fn location(self, url: impl Into) -> 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::*; + /// + /// HtmxResponse::empty() + /// .location_json(r##"{"path": "/items", "target": "#content"}"##); + /// ``` + /// + /// Si algún valor se calcula en tiempo de ejecución, constrúyelo con [`serde_json::json!`] en + /// 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) -> Self { + let json = json.into(); + if let Err(error) = serde_json::from_str::(&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. /// /// 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) -> Self { - self.set_header(b"hx-push-url", url) + /// + /// 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 + /// 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) -> 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. /// - /// Usar `"false"` para desactivar el reemplazo. - pub fn replace_url(self, url: impl Into) -> Self { - self.set_header(b"hx-replace-url", url) + /// Usar `"false"` para desactivar el reemplazo. Usa + /// [`Context::route()`](pagetop::core::component::Context::route) en lugar de un literal para + /// que la URL preserve el parámetro `lang` cuando corresponda. + pub fn replace_url(self, url: impl Into) -> Self { + self.set_header(b"hx-replace-url", url.into().to_string()) } /// 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) -> Self { - self.set_header(b"hx-redirect", url) + /// + /// 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 + /// 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) -> Self { + self.set_header(b"hx-redirect", url.into().to_string()) } /// Provoca una recarga completa de la página actual. @@ -170,7 +249,7 @@ impl HtmxResponse { /// /// ```rust,no_run /// use pagetop::prelude::*; - /// use pagetop_htmx::HtmxResponse; + /// use pagetop_htmx::prelude::*; /// /// // Evento simple: /// HtmxResponse::empty().trigger("itemAdded"); @@ -181,6 +260,21 @@ impl HtmxResponse { /// // Evento con datos en JSON: /// 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) -> Self { self.set_header(b"hx-trigger", event) } diff --git a/extensions/pagetop-htmx/tests/extension.rs b/extensions/pagetop-htmx/tests/extension.rs new file mode 100644 index 00000000..c0452ada --- /dev/null +++ b/extensions/pagetop-htmx/tests/extension.rs @@ -0,0 +1,62 @@ +use pagetop::prelude::*; +use pagetop_htmx::Htmx; + +struct TestApp; + +#[async_trait] +impl Extension for TestApp { + fn dependencies(&self) -> Vec { + vec![&Htmx] + } + + fn configure_router(&self, router: Router) -> Router { + router.route("/page", web::get(render_page)) + } +} + +async fn render_page(request: HttpRequest) -> Result { + 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("

hello

")); +} diff --git a/extensions/pagetop-htmx/tests/hx.rs b/extensions/pagetop-htmx/tests/hx.rs new file mode 100644 index 00000000..c2a49fcd --- /dev/null +++ b/extensions/pagetop-htmx/tests/hx.rs @@ -0,0 +1,160 @@ +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"); +} diff --git a/extensions/pagetop-htmx/tests/hx_table.rs b/extensions/pagetop-htmx/tests/hx_table.rs new file mode 100644 index 00000000..08a67fa7 --- /dev/null +++ b/extensions/pagetop-htmx/tests/hx_table.rs @@ -0,0 +1,162 @@ +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 `&` because this ends up inside an HTML attribute value. + assert!(html.contains(r#"href="/admin/users?lang=es-ES&sort=username&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=")); +} diff --git a/extensions/pagetop-htmx/tests/request.rs b/extensions/pagetop-htmx/tests/request.rs new file mode 100644 index 00000000..63bd842c --- /dev/null +++ b/extensions/pagetop-htmx/tests/request.rs @@ -0,0 +1,169 @@ +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()); +} diff --git a/extensions/pagetop-htmx/tests/response.rs b/extensions/pagetop-htmx/tests/response.rs new file mode 100644 index 00000000..5a75d70e --- /dev/null +++ b/extensions/pagetop-htmx/tests/response.rs @@ -0,0 +1,233 @@ +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#"
  • New item
  • "#); +} + +#[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); +} diff --git a/src/html.rs b/src/html.rs index a368f2ad..1f8e346e 100644 --- a/src/html.rs +++ b/src/html.rs @@ -6,6 +6,9 @@ pub use maud::{DOCTYPE, Escaper, Markup, PreEscaped, Render, display, html, html mod route_path; pub use route_path::RoutePath; +mod sort_dir; +pub use sort_dir::SortDir; + // **< HTML DOCUMENT ASSETS >*********************************************************************** mod assets; diff --git a/src/html/sort_dir.rs b/src/html/sort_dir.rs new file mode 100644 index 00000000..c6706ea9 --- /dev/null +++ b/src/html/sort_dir.rs @@ -0,0 +1,116 @@ +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 { + match current { + Some(dir) => dir.toggled(), + None => SortDir::Asc, + } + } +} + +/// Permite pasar un [`SortDir`] allí donde se espere `impl Into`, 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 for String { + fn from(dir: SortDir) -> Self { + dir.as_str().to_owned() + } +} From 8573aca29eb733e8698c0e44d06086020f9ae40e Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 26 Jul 2026 09:51:27 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20(theme):=20A=C3=B1ade=20compone?= =?UTF-8?q?ntes=20Region=20y=20Template?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce Region y Template (`base::component::layout`) para componer el `` de una página, con captura explícita vía `handle_component()` en lugar de maquetado implícito: - `RegionName`/`TemplateName` (`core/theme.rs`) como interfaces de identidad; `CoreRegion` (`Header`/`Content`/`Footer`) y `CoreTemplate` (`Standard`/`Admin`) como catálogo por defecto. - `ReservedRegion` (`PageTop`/`PageBottom`, en `response/page.rs`) queda fuera de `CoreRegion`. `Page::render()` las renderiza siempre, envolviendo `Theme::render_page_body()`, con independencia de la plantilla o el tema activos. - Elimina `TemplateSource` en `Context`: la plantilla pasa a ser un `TemplateRef` fijo, sin resolución por tema (ese mecanismo nunca llegó a implementarse). - La personalización por tema es por captura (`handle_component()` + `downcast_ref()`), no por sustituir qué constante se resuelve. - Corrige referencias obsoletas a `DefaultRegions` por `CoreRegion`. - Sustituye `tests/theme_template.rs` (API anterior) por `tests/component_template.rs`, que cubre el patrón de captura con el API nuevo. --- examples/navbar-menus.rs | 2 +- src/base/component.rs | 2 + src/base/component/layout.rs | 7 + src/base/component/layout/region.rs | 111 ++++++++++++ src/base/component/layout/template.rs | 81 +++++++++ src/base/theme/basic.rs | 2 +- src/core/component/context.rs | 64 ++----- src/core/theme.rs | 249 ++++++++++++-------------- src/core/theme/definition.rs | 85 ++++----- src/core/theme/regions.rs | 64 ++++--- src/response/page.rs | 83 ++++----- tests/component_template.rs | 118 ++++++++++++ tests/theme_template.rs | 83 --------- 13 files changed, 560 insertions(+), 391 deletions(-) create mode 100644 src/base/component/layout.rs create mode 100644 src/base/component/layout/region.rs create mode 100644 src/base/component/layout/template.rs create mode 100644 tests/component_template.rs delete mode 100644 tests/theme_template.rs diff --git a/examples/navbar-menus.rs b/examples/navbar-menus.rs index 49b44d09..a4c182fa 100644 --- a/examples/navbar-menus.rs +++ b/examples/navbar-menus.rs @@ -102,7 +102,7 @@ impl Extension for SuperMenu { )), )); - InRegion::Global(&DefaultRegions::Header).add( + InRegion::Global(&CoreRegion::Header).add( bs::Container::new() .with_width(bs::container::Width::FluidMax(UnitValue::RelRem(75.0))) .with_child(navbar_menu), diff --git a/src/base/component.rs b/src/base/component.rs index 10832ff1..fe3c734c 100644 --- a/src/base/component.rs +++ b/src/base/component.rs @@ -1,5 +1,7 @@ //! Componentes nativos proporcionados por PageTop. +pub mod layout; + mod block; pub use block::Block; diff --git a/src/base/component/layout.rs b/src/base/component/layout.rs new file mode 100644 index 00000000..b9cfdf0c --- /dev/null +++ b/src/base/component/layout.rs @@ -0,0 +1,7 @@ +//! Definiciones para la composición de documentos ([`Region`] y [`Template`]). + +mod region; +pub use region::Region; + +mod template; +pub use template::Template; diff --git a/src/base/component/layout/region.rs b/src/base/component/layout/region.rs new file mode 100644 index 00000000..72c6cc96 --- /dev/null +++ b/src/base/component/layout/region.rs @@ -0,0 +1,111 @@ +use crate::prelude::*; + +use std::fmt; + +/// Componente que renderiza una región del ``. +/// +/// 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 { + Some(self.region().name().to_owned()) + } + + async fn prepare(&self, cx: &mut Context) -> Result { + 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 } + } +} diff --git a/src/base/component/layout/template.rs b/src/base/component/layout/template.rs new file mode 100644 index 00000000..54fd3f64 --- /dev/null +++ b/src/base/component/layout/template.rs @@ -0,0 +1,81 @@ +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 { + Some(self.template().name().to_owned()) + } + + async fn prepare(&self, cx: &mut Context) -> Result { + 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 } + } +} diff --git a/src/base/theme/basic.rs b/src/base/theme/basic.rs index 71aebdd0..a1929526 100644 --- a/src/base/theme/basic.rs +++ b/src/base/theme/basic.rs @@ -25,7 +25,7 @@ impl Theme for Basic { .with_weight(-99), )) .alter_child_in( - &DefaultRegions::Footer, + &CoreRegion::Footer, ChildOp::AddIfEmpty(PoweredBy::new().into()), ); } diff --git a/src/core/component/context.rs b/src/core/component/context.rs index d9dc70a9..b6e95457 100644 --- a/src/core/component/context.rs +++ b/src/core/component/context.rs @@ -2,7 +2,8 @@ use crate::auth::CurrentUser; use crate::core::TypeInfo; use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage}; use crate::core::theme::all::DEFAULT_THEME; -use crate::core::theme::{ChildrenInRegions, DefaultRegions, RegionRef, TemplateRef, ThemeRef}; +use crate::core::theme::{ChildrenInRegions, CoreRegion, CoreTemplate}; +use crate::core::theme::{RegionRef, TemplateRef, ThemeRef}; use crate::html::{Assets, Favicon, JavaScript, Preload, StyleSheet}; use crate::html::{Markup, Props, PropsOp, RoutePath, html}; use crate::locale::L10n; @@ -77,7 +78,7 @@ pub enum ContextError { /// fn prepare_context(cx: C) -> C { /// cx.with_langid(&Locale::resolve("es-ES")) /// .with_theme(&Aliner) -/// .with_template(&DefaultTemplates::Standard) +/// .with_template(&CoreTemplate::Standard) /// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) /// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/app.css"))) /// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/app.js"))) @@ -137,7 +138,7 @@ pub trait Contextual: LangId { /// Añade un componente o aplica una operación [`ChildOp`] en una región específica del /// documento. #[builder_fn] - fn with_child_in(self, region_ref: RegionRef, op: impl Into) -> Self; + fn with_child_in(self, region: RegionRef, op: impl Into) -> Self; // **< Contextual GETTERS >********************************************************************* @@ -236,28 +237,6 @@ pub trait Contextual: LangId { 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. /// /// Se crea una sola vez por petición usando [`Context::new()`] (típicamente a través de @@ -278,9 +257,8 @@ impl TemplateSource { /// identificadores HTML únicos por tipo de componente. /// - [`push_message()`](Self::push_message)/[`messages()`](Self::messages) para acumular /// [`StatusMessage`] que mostrar en algún momento del renderizado. -/// - [`render_assets()`](Self::render_assets)/[`render_region_named()`](Self::render_region_named), -/// usados internamente por [`Page`](crate::response::Page) para producir el HTML final del -/// documento. +/// - [`render_assets()`](Self::render_assets)/[`render_region()`](Self::render_region), usados +/// internamente por [`Page`](crate::response::Page) para producir el HTML final del documento. /// /// # Ejemplos /// @@ -332,7 +310,7 @@ pub struct Context { locale : RequestLocale, // Idioma asociado a la petición. current_user: CurrentUser, // Identidad del usuario actual. theme : ThemeRef, // Referencia al tema usado para renderizar. - template : TemplateSource, // Plantilla usada para renderizar. + template : TemplateRef, // Plantilla usada para renderizar. favicon : Option, // Favicon, si se ha definido. preloads : Assets, // Recursos para precarga. stylesheets : Assets, // Hojas de estilo CSS. @@ -364,7 +342,7 @@ impl Context { locale, current_user, theme : *DEFAULT_THEME, - template : TemplateSource::Default, + template : &CoreTemplate::Standard, favicon : None, preloads : Assets::::new(), stylesheets: Assets::::new(), @@ -386,13 +364,6 @@ impl Context { .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 >************************************************************************* /// Renderiza los recursos del contexto. @@ -426,9 +397,13 @@ impl Context { } /// 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 - .assemble_region(self.theme, region_name) + .assemble_region(self.theme, region) .render(self) .await } @@ -568,7 +543,7 @@ impl Contextual for Context { #[builder_fn] fn with_template(mut self, template: TemplateRef) -> Self { - self.template = TemplateSource::Explicit(template); + self.template = template; self } @@ -624,14 +599,13 @@ impl Contextual for Context { #[builder_fn] fn with_child(mut self, op: impl Into) -> Self { - self.regions - .alter_child_in(&DefaultRegions::Content, op.into()); + self.regions.alter_child_in(&CoreRegion::Content, op.into()); self } #[builder_fn] - fn with_child_in(mut self, region_ref: RegionRef, op: impl Into) -> Self { - self.regions.alter_child_in(region_ref, op.into()); + fn with_child_in(mut self, region: RegionRef, op: impl Into) -> Self { + self.regions.alter_child_in(region, op.into()); self } @@ -650,7 +624,7 @@ impl Contextual for Context { } fn template(&self) -> TemplateRef { - self.template.resolve(self.theme) + self.template } fn param(&self, key: &'static str) -> Result<&T, ContextError> { diff --git a/src/core/theme.rs b/src/core/theme.rs index 2dd31eef..fc54e3b3 100644 --- a/src/core/theme.rs +++ b/src/core/theme.rs @@ -1,14 +1,15 @@ //! API para añadir y gestionar nuevos temas. //! //! Un tema es la *piel* de la aplicación: define estilos, tipografías, espaciados o comportamientos -//! interactivos. Para ello utiliza plantillas ([`Template`]) que describen cómo maquetar el cuerpo -//! del documento a partir de regiones ([`Region`]). Cada región es un contenedor lógico -//! identificado por un nombre para agrupar y renderizar componentes. +//! interactivos. Usa plantillas ([`Template`](crate::base::component::layout::Template)) para +//! maquetar los contenidos en base a regiones ([`Region`](crate::base::component::layout::Region)). +//! Cada región es un contenedor lógico identificado por un nombre para agrupar y renderizar +//! componentes. //! //! Una página ([`Page`](crate::response::Page)) es un documento HTML completo. Implementa -//! [`Contextual`](crate::core::component::Contextual) para gestionar su propio [`Context`], donde -//! mantiene el tema activo, la plantilla seleccionada y los componentes asociados a cada región a -//! renderizar. +//! [`Contextual`](crate::core::component::Contextual) para gestionar su propio +//! [`Context`](crate::core::component::Context), donde mantiene el tema activo, la plantilla +//! seleccionada y los componentes asociados a cada región a renderizar. //! //! 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 @@ -26,24 +27,34 @@ //! Un tema puede personalizarse en tres pasos, cada uno necesario sólo si lo que ofrece PageTop por //! defecto no basta: //! -//! 1. **Definir regiones propias**. Por defecto PageTop define [`DefaultRegions`], con tres -//! regiones (`Header`, `Content` y `Footer`) que usan la implementación por defecto de -//! [`Region::render()`]. Un tema puede definir un *enum* propio que implemente [`Region`] para -//! exponer sus propias regiones (por ejemplo, una barra lateral) o para cambiar cómo se muestra -//! el contenido de una región ya existente (identificada por su nombre). -//! 2. **Definir plantillas propias**. Por defecto existe [`DefaultTemplates`], con dos plantillas -//! (`Standard` y `Admin`) que usan la implementación por defecto de [`Template::render()`] para -//! renderizar [`DefaultRegions::Header`], [`DefaultRegions::Content`] y -//! [`DefaultRegions::Footer`], en este orden. Un tema puede definir un *enum* propio que -//! implemente [`Template`] para crear nuevas plantillas, maquetar las regiones de otra forma, -//! cambiar su orden o envolverlas en contenedores adicionales. -//! 3. **Elegir las plantillas predeterminadas**. Por un lado, la plantilla por defecto vía -//! [`Theme::default_template()`] y, por otro, la plantilla para las páginas de administración, -//! [`Theme::admin_template()`]. De esta forma, las páginas creadas con `Page::new()` usarán -//! automáticamente la plantilla `default_template()` del tema activo, y las páginas creadas con -//! `Page::admin()` usarán la de `admin_template()`, sin tener que llamar manualmente a -//! [`with_template()`](crate::core::component::Contextual::with_template). Un tema que no -//! sobrescriba estos métodos sigue usando las plantillas por defecto de PageTop. +//! 1. **Definir regiones nuevas**. Por defecto, PageTop define [`CoreRegion`] (`Header`, `Content`, +//! `Footer`) como las regiones de plantilla que se asumen siempre disponibles, y +//! [`ReservedRegion`](crate::response::ReservedRegion) (`PageTop`, `PageBottom`) como las +//! regiones reservadas que se renderizan al margen de cualquier plantilla. Un tema puede definir +//! su propio *enum* que implemente [`RegionName`] para **añadir** nuevas regiones que PageTop no +//! ofrece (por ejemplo, una barra lateral). No es necesario redefinir las de [`CoreRegion`] ni +//! las de [`ReservedRegion`](crate::response::ReservedRegion), que ya existen y se asume que +//! cualquier tema respeta. +//! 2. **Definir plantillas nuevas**. Por defecto existe [`CoreTemplate`], con las plantillas +//! `Standard` y `Admin` que usan `Page::new()` y `Page::admin()` respectivamente, y que son +//! siempre las mismas: no hay un método de `Theme` para elegir una plantilla predeterminada +//! distinta. Un tema puede definir su propio *enum* que implemente [`TemplateName`] para +//! **añadir** plantillas que PageTop no ofrece, y no para redefinir `Standard`/`Admin`. +//! 3. **Cambiar cómo se renderiza** una región, una plantilla o un componente ya existente, se hace +//! capturando el componente ([`Region`](crate::base::component::layout::Region) o +//! [`Template`](crate::base::component::layout::Template), o el componente que sea) en +//! [`Theme::handle_component()`]. En el caso de regiones y plantillas, para distinguir *qué* +//! región o plantilla concreta envuelve el componente, sin comparar cadenas, basta con encadenar +//! 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 //! renderizan con la plantilla ya activa en la página, para que el usuario no pierda el contexto de @@ -51,9 +62,8 @@ //! [`Theme::error_403()`], [`Theme::error_404()`] o [`Theme::error_fatal()`], sin necesidad de una //! plantilla distinta. //! -//! El resto del comportamiento de un tema (renderizado del ``, o intervención en el -//! 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. +//! El resto del comportamiento de un tema (por ejemplo, el renderizado del ``) 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 //! @@ -69,7 +79,7 @@ //! //! ```rust,no_run //! # use pagetop::prelude::*; -//! InRegion::Global(&DefaultRegions::Footer).add(PoweredBy::new()); +//! InRegion::Global(&CoreRegion::Footer).add(PoweredBy::new()); //! ``` //! //! El componente se guarda como **prototipo**: cada página recibe un clon fresco en el momento del @@ -82,90 +92,66 @@ //! 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. -use crate::async_trait; -use crate::core::component::Context; -use crate::html::{Markup, html}; +use crate::AutoDefault; +use crate::core::AnyInfo; use crate::locale::L10n; -use crate::{AutoDefault, util}; -// **< Region >************************************************************************************* +// **< RegionName >********************************************************************************* -/// Interfaz común para las regiones lógicas de un documento. +/// Interfaz común para las regiones lógicas del ``. /// -/// Una `Region` representa un contenedor lógico identificado por un nombre de región. Su contenido -/// se obtiene del [`Context`], donde los componentes suelen registrarse usando implementaciones de -/// métodos como [`Contextual::with_child_in()`](crate::core::component::Contextual::with_child_in). +/// Una `RegionName` representa un contenedor lógico identificado por un nombre de región. Su +/// contenido se obtiene del [`Context`](crate::core::component::Context), donde los componentes +/// suelen registrarse usando implementaciones de métodos como +/// [`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 -/// implementaciones de [`Region`] que devuelvan el mismo nombre compartirán el mismo conjunto de -/// componentes registrados en el [`Context`], aunque cada región puede renderizar ese contenido de -/// forma diferente. Por ejemplo, [`DefaultRegions::Header`] y `BootsierRegions::Header` mostrarían -/// los mismos componentes si ambas devuelven el nombre `"header"`, pero podrían maquetarse de -/// manera distinta. +/// implementaciones de [`RegionName`] que devuelvan el mismo nombre comparten el mismo conjunto de +/// componentes registrados en el [`Context`](crate::core::component::Context). Un *enum* propio que +/// implemente [`RegionName`] está pensado para **añadir** regiones que PageTop no ofrece (con un +/// nombre propio que no colisione con los de [`CoreRegion`] o +/// [`ReservedRegion`](crate::response::ReservedRegion)). /// -/// El tema decide qué regiones mostrar en el cuerpo del documento, normalmente usando una plantilla -/// ([`Template`]) al renderizar la página ([`Page`](crate::response::Page)). -#[async_trait] -pub trait Region: Send + Sync { +/// El tema decide qué regiones mostrar en el ``, normalmente usando una plantilla +/// ([`TemplateName`]) al renderizar la página ([`Page`](crate::response::Page)). +/// +/// Requiere [`AnyInfo`] para que un [`RegionRef`] pueda recuperarse mediante +/// [`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. /// - /// Este nombre es el identificador lógico de la región y se usa como clave en el [`Context`] - /// para recuperar y renderizar el contenido registrado bajo ese nombre. Cualquier - /// implementación de [`Region`] que devuelva el mismo nombre compartirá el mismo conjunto de - /// 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-"`). + /// Este nombre es el identificador lógico de la región y se usa como clave en el + /// [`Context`](crate::core::component::Context) para recuperar y renderizar el contenido + /// registrado bajo ese nombre. Cualquier implementación de [`RegionName`] que devuelva el mismo + /// nombre compartirá el mismo conjunto de componentes. fn name(&self) -> &'static str; /// Devuelve un *texto localizado* como etiqueta de accesibilidad asociada a la región. /// - /// En la implementación predeterminada de [`Self::render()`], este valor se usa como - /// `aria-label` del contenedor de la región. + /// En la implementación predeterminada de [`Region`](crate::base::component::layout::Region), + /// este valor se usa como `aria-label` del contenedor de la región. 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 `
    ` con clases `"region region-"` y un `aria-label` basado en el - /// *texto localizado* de la etiqueta asociada a la región: - /// - /// ```html - ///
    - /// - ///
    - /// ``` - /// - /// 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. -pub type RegionRef = &'static dyn Region; +pub type RegionRef = &'static dyn RegionName; -// **< DefaultRegions >***************************************************************************** +// **< CoreRegion >********************************************************************************* /// Regiones básicas que PageTop proporciona por defecto. /// -/// Estas regiones comparten sus nombres (`"header"`, `"content"`, `"footer"`) con cualquier región -/// equivalente definida por otros temas, por lo que comparten también el contenido registrado bajo -/// esos nombres. +/// Comparten sus nombres (`"header"`, `"content"`, `"footer"`) con otras regiones que implementen +/// [`RegionName`], por lo que comparten también el contenido registrado bajo esos nombres. Por +/// defecto, son las regiones usadas por [`Template`](crate::base::component::layout::Template). +/// +/// 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)] -pub enum DefaultRegions { +pub enum CoreRegion { /// Región estándar para la **cabecera** del documento, de nombre `"header"`. /// /// Suele emplearse para mostrar un logotipo, navegación principal, barras superiores, etc. @@ -184,7 +170,7 @@ pub enum DefaultRegions { Footer, } -impl Region for DefaultRegions { +impl RegionName for CoreRegion { #[inline] fn name(&self) -> &'static str { match self { @@ -204,63 +190,64 @@ impl Region for DefaultRegions { } } -// **< Template >*********************************************************************************** +// **< TemplateName >******************************************************************************* -/// Interfaz común para definir plantillas de contenido. +/// Interfaz común para las plantillas lógicas de una página. /// -/// Una `Template` puede proporcionar una o más variantes para decidir la composición del `` -/// de una página ([`Page`](crate::response::Page)). El tema utiliza esta información para -/// determinar qué regiones ([`Region`]) deben renderizarse y en qué orden. -#[async_trait] -pub trait Template: Send + Sync { - /// Renderiza el contenido de la plantilla. - /// - /// Por defecto, renderiza las regiones básicas de [`DefaultRegions`] en este orden: - /// [`DefaultRegions::Header`], [`DefaultRegions::Content`] y [`DefaultRegions::Footer`]. - /// - /// Se puede sobrescribir este método para: - /// - /// - Cambiar el conjunto de regiones que se renderizan según variantes de la plantilla. - /// - Alterar el orden de dichas regiones. - /// - 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 `` 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) - } - } +/// Representa una variante identificada por un nombre. Un tema puede usar este nombre para decidir +/// la composición del cuerpo de una página ([`Page`](crate::response::Page)), es decir, qué +/// regiones ([`RegionName`]) renderizar y en qué orden. +/// +/// Requiere [`AnyInfo`] por el mismo motivo que [`RegionName`], para que un [`TemplateRef`] pueda +/// recuperarse mediante [`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 [`Template`](crate::base::component::layout::Template)). +pub trait TemplateName: Send + Sync + AnyInfo { + /// Devuelve el nombre de la plantilla. + fn name(&self) -> &'static str; + + /// Devuelve un *texto localizado* como etiqueta descriptiva de la plantilla. + fn label(&self) -> L10n; } /// Referencia estática a una plantilla. -pub type TemplateRef = &'static dyn Template; +pub type TemplateRef = &'static dyn TemplateName; -// **< DefaultTemplates >*************************************************************************** +// **< CoreTemplate >******************************************************************************* /// Plantillas que PageTop proporciona por defecto. #[derive(AutoDefault)] -pub enum DefaultTemplates { - /// Plantilla predeterminada. +pub enum CoreTemplate { + /// Plantilla predeterminada, de nombre `"standard"`. /// - /// Utiliza la implementación por defecto de [`Template::render()`] y se emplea cuando no se - /// selecciona ninguna otra plantilla explícitamente. + /// Se emplea cuando no se selecciona ninguna otra plantilla explícitamente. #[default] Standard, - /// Plantilla para la **interfaz de administración**. + /// Plantilla para la **interfaz de administración**, de nombre `"admin"`. /// - /// 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`]. + /// Se utiliza para páginas de administración o paneles de control. Admin, } -#[async_trait] -impl Template for DefaultTemplates {} +impl TemplateName for CoreTemplate { + #[inline] + 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! >************************************************************************** diff --git a/src/core/theme/definition.rs b/src/core/theme/definition.rs index b0e05ee2..3a120c4e 100644 --- a/src/core/theme/definition.rs +++ b/src/core/theme/definition.rs @@ -1,8 +1,9 @@ use crate::async_trait; -use crate::base::component::{Html, Intro, IntroOpening}; -use crate::core::component::{ChildOp, Component, ComponentError, Context, Contextual}; +use crate::base::component::{Html, Intro, IntroOpening, layout}; +use crate::core::component::{ChildOp, Component, ComponentError, ComponentRender}; +use crate::core::component::{Context, Contextual}; use crate::core::extension::Extension; -use crate::core::theme::{DefaultRegions, DefaultTemplates, TemplateRef}; +use crate::core::theme::CoreRegion; use crate::global; use crate::html::{Markup, html}; use crate::locale::L10n; @@ -13,10 +14,10 @@ use crate::web::http::StatusCode; /// /// Un tema es una [`Extension`](crate::core::extension::Extension) que define el aspecto general de /// las páginas: cómo se renderiza el ``, cómo se presenta el `` usando plantillas -/// ([`Template`](crate::core::theme::Template)) que maquetan regiones -/// ([`Region`](crate::core::theme::Region)) y qué contenido mostrar en las páginas de error. El -/// contenido de cada región depende del [`Context`](crate::core::component::Context) y de su nombre -/// lógico. +/// ([`TemplateName`](crate::core::theme::TemplateName)) que maquetan regiones +/// ([`RegionName`](crate::core::theme::RegionName)) y qué contenido mostrar en las páginas de +/// error. El contenido de cada región depende del [`Context`](crate::core::component::Context) y de +/// su nombre lógico. /// /// 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 @@ -59,34 +60,6 @@ pub trait Theme: Extension + Send + Sync { 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 `` de la página. /// /// Es un buen lugar para inicializar o ajustar recursos en función del contexto de la página, @@ -107,14 +80,13 @@ pub trait Theme: Extension + Send + Sync { /// Renderiza el contenido del `` de la página. /// /// La implementación predeterminada delega en la plantilla asociada a la página, obtenida desde - /// su [`Context`](crate::core::component::Context), y llama a - /// [`Template::render()`](crate::core::theme::Template::render) para componer el `` a - /// partir de las regiones. + /// su [`Context`](crate::core::component::Context), para componer el `` a partir de las + /// regiones. /// /// Con la configuración por defecto, la plantilla estándar utiliza las regiones - /// [`DefaultRegions::Header`](crate::core::theme::DefaultRegions::Header), - /// [`DefaultRegions::Content`](crate::core::theme::DefaultRegions::Content) y - /// [`DefaultRegions::Footer`](crate::core::theme::DefaultRegions::Footer) en ese orden. + /// [`CoreRegion::Header`](crate::core::theme::CoreRegion::Header), + /// [`CoreRegion::Content`](crate::core::theme::CoreRegion::Content) y + /// [`CoreRegion::Footer`](crate::core::theme::CoreRegion::Footer) en ese orden. /// /// Los temas pueden sobrescribir este método para: /// @@ -127,7 +99,8 @@ pub trait Theme: Extension + Send + Sync { if let Some(parent) = self.parent() { parent.render_page_body(page).await } else { - page.template().render(page.context()).await + let template = page.template(); + layout::Template::of(template).render(page.context()).await } } @@ -247,16 +220,16 @@ pub trait Theme: Extension + Send + Sync { /// Contenido predefinido para la página de error "*403 - Forbidden*" (acceso denegado). /// - /// Normalmente se renderiza con la plantilla predeterminada del tema (por defecto suele ser - /// [`DefaultTemplates::Standard`]), para que el usuario no pierda el contexto de navegación del - /// sitio. Los temas pueden sobrescribir este método para personalizar completamente el diseño y - /// el contenido de la página de error. + /// Normalmente se renderiza con la plantilla ya activa en la página (por ejemplo + /// [`CoreTemplate::Standard`](crate::core::theme::CoreTemplate::Standard)), para que el usuario + /// no pierda el contexto de navegación del sitio. Los temas pueden sobrescribir este método + /// para personalizar completamente el diseño y el contenido de la página de error. fn error_403(&self, page: &mut Page) { if let Some(parent) = self.parent() { return parent.error_403(page); } page.alter_title(L10n::l("error403_title")).alter_child_in( - &DefaultRegions::Content, + &CoreRegion::Content, ChildOp::Prepend( Html::with(move |cx| { html! { @@ -273,15 +246,16 @@ pub trait Theme: Extension + Send + Sync { /// Contenido predefinido para la página de error "*404 - Not Found*" (recurso no encontrado). /// - /// Normalmente se renderiza con la plantilla predeterminada del tema (por defecto suele ser - /// [`DefaultTemplates::Standard`]). Los temas pueden sobrescribir este método para personalizar - /// completamente el diseño y el contenido de la página de error. + /// Normalmente se renderiza con la plantilla ya activa en la página (por ejemplo + /// [`CoreTemplate::Standard`](crate::core::theme::CoreTemplate::Standard)). Los temas pueden + /// sobrescribir este método para personalizar completamente el diseño y el contenido de la + /// página de error. fn error_404(&self, page: &mut Page) { if let Some(parent) = self.parent() { return parent.error_404(page); } page.alter_title(L10n::l("error404_title")).alter_child_in( - &DefaultRegions::Content, + &CoreRegion::Content, ChildOp::Prepend( Html::with(move |cx| { html! { @@ -307,9 +281,10 @@ pub trait Theme: Extension + Send + Sync { /// funcionan con normalidad. /// /// Por defecto, asigna el título al documento (`title`), se renderiza con la plantilla ya - /// activa en la página (normalmente [`DefaultTemplates::Standard`]) y muestra un componente - /// [`Intro`] con el código HTTP del error (`code`) y los mensajes proporcionados (`alert` y - /// `help`) como descripción del error. + /// activa en la página (normalmente + /// [`CoreTemplate::Standard`](crate::core::theme::CoreTemplate::Standard)) y muestra un + /// componente [`Intro`] con el código HTTP del error (`code`) y los mensajes proporcionados + /// (`alert` y `help`) como descripción del error. /// /// 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. @@ -326,7 +301,7 @@ pub trait Theme: Extension + Send + Sync { return parent.error_fatal(page, code, title, alert, help); } page.alter_title(title).alter_child_in( - &DefaultRegions::Content, + &CoreRegion::Content, ChildOp::Prepend( Intro::new() .with_title(L10n::l("error_code").with_arg("code", code.to_string())) diff --git a/src/core/theme/regions.rs b/src/core/theme/regions.rs index abf19bfb..f6c00aed 100644 --- a/src/core/theme/regions.rs +++ b/src/core/theme/regions.rs @@ -1,5 +1,5 @@ use crate::core::component::{Child, ChildOp, Children, Component}; -use crate::core::theme::{DefaultRegions, RegionRef, ThemeRef}; +use crate::core::theme::{CoreRegion, RegionRef, ThemeRef}; use crate::{AutoDefault, UniqueId, builder_fn}; use parking_lot::RwLock; @@ -43,20 +43,19 @@ static COMMON_REGIONS: LazyLock> = pub(crate) struct ChildrenInRegions(HashMap); impl ChildrenInRegions { - pub fn with(region_ref: RegionRef, child: Child) -> Self { - Self::default().with_child_in(region_ref, child) + pub fn with(region: RegionRef, child: Child) -> Self { + Self::default().with_child_in(region, child) } #[builder_fn] - pub fn with_child_in(mut self, region_ref: RegionRef, op: impl Into) -> Self { + pub fn with_child_in(mut self, region: RegionRef, op: impl Into) -> Self { let child = op.into(); - if let Some(region) = self.0.get_mut(region_ref.name()) { + let region_name = region.name(); + if let Some(region) = self.0.get_mut(region_name) { region.alter_child(child); } else { - self.0.insert( - region_ref.name().to_owned(), - Children::new().with_child(child), - ); + let children = Children::new().with_child(child); + self.0.insert(region_name.to_owned(), children); } self } @@ -71,7 +70,8 @@ impl ChildrenInRegions { /// 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 /// que llegan a `setup()` con el mismo estado inicial. - pub fn assemble_region(&mut self, theme_ref: ThemeRef, region_name: &str) -> Children { + pub fn assemble_region(&mut self, theme: ThemeRef, region: RegionRef) -> Children { + let region_name = region.name(); let common = COMMON_REGIONS.read(); let themed = THEME_REGIONS.read(); @@ -90,7 +90,7 @@ impl ChildrenInRegions { } } // 3. Prototipos del tema activo. - if let Some(theme_map) = themed.get(&theme_ref.type_id()) { + if let Some(theme_map) = themed.get(&theme.type_id()) { if let Some(protos) = theme_map.get(region_name) { for proto in protos { result.add(proto.as_child()); @@ -118,31 +118,27 @@ impl ChildrenInRegions { /// InRegion::Content.add(Html::with(|_| html! { "🎉 ¡Bienvenido!" })); /// /// // Texto en la cabecera, visible en todos los temas. -/// InRegion::Global(&DefaultRegions::Header).add(Html::with(|_| html! { "Publicidad" })); +/// InRegion::Global(&CoreRegion::Header).add(Html::with(|_| html! { "Publicidad" })); /// ``` pub enum InRegion { /// Región principal de **contenido** por defecto. /// - /// Añade el componente a la región lógica de contenido principal de la aplicación. Por - /// 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)). + /// Añade el componente a la región lógica de contenido principal de la aplicación. Internamente + /// equivale a `InRegion::Global(&CoreRegion::Content)`. Content, /// Región global compartida por todos los temas. /// /// Los componentes añadidos aquí se asocian al nombre de la región indicado por [`RegionRef`], - /// es decir, al valor devuelto por [`Region::name()`](crate::core::theme::Region::name) para - /// esa región. Se mostrarán en cualquier tema cuya plantilla renderice una región que devuelva - /// ese mismo nombre. + /// es decir, al valor devuelto por + /// [`RegionName::name()`](crate::core::theme::RegionName::name) para esa región. Se mostrarán + /// en cualquier tema que renderice la región que devuelva ese nombre. Global(RegionRef), /// Región asociada a un tema concreto. /// - /// Los componentes sólo se renderizarán cuando el documento se procese con el tema indicado y - /// se utilice la región referenciada. Resulta útil para añadir contenido específico en un tema - /// sin afectar a otros. + /// Los componentes sólo se renderizarán cuando el documento se procese exactamente con el tema + /// indicado (no sirve un tema hijo que lo herede), y se utilice la región referenciada. A + /// diferencia del resto de comportamiento de `Theme`, este registro no sigue la cadena + /// `parent()`. Resulta útil para añadir contenido específico en un tema sin afectar a otros. ForTheme(ThemeRef, RegionRef), } @@ -163,26 +159,26 @@ impl InRegion { /// })); /// /// // Texto en la cabecera. - /// InRegion::Global(&DefaultRegions::Header).add(Html::with(|_| { + /// InRegion::Global(&CoreRegion::Header).add(Html::with(|_| { /// html! { "Publicidad" } /// })); /// /// // Contenido sólo para la región del pie de página en un tema concreto. - /// InRegion::ForTheme(&theme::Basic, &DefaultRegions::Footer).add(Html::with(|_| { + /// InRegion::ForTheme(&theme::Basic, &CoreRegion::Footer).add(Html::with(|_| { /// html! { "Aviso legal" } /// })); /// ``` pub fn add(&self, component: impl Component + Clone + 'static) -> &Self { let proto: Arc = Arc::new(component); match self { - InRegion::Content => Self::add_to_common(&DefaultRegions::Content, proto), - InRegion::Global(region_ref) => Self::add_to_common(*region_ref, proto), - InRegion::ForTheme(theme_ref, region_ref) => { + InRegion::Content => Self::add_to_common(&CoreRegion::Content, proto), + InRegion::Global(region) => Self::add_to_common(*region, proto), + InRegion::ForTheme(theme, region) => { THEME_REGIONS .write() - .entry(theme_ref.type_id()) + .entry(theme.type_id()) .or_default() - .entry((*region_ref).name().to_owned()) + .entry((*region).name().to_owned()) .or_default() .push(proto); } @@ -191,10 +187,10 @@ impl InRegion { } #[inline] - fn add_to_common(region_ref: RegionRef, proto: Arc) { + fn add_to_common(region: RegionRef, proto: Arc) { COMMON_REGIONS .write() - .entry(region_ref.name().to_owned()) + .entry(region.name().to_owned()) .or_default() .push(proto); } diff --git a/src/response/page.rs b/src/response/page.rs index aa59130c..cec7c776 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -2,16 +2,17 @@ //! //! 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 -//! ([`Template`](crate::core::theme::Template)) que define la disposición de las regiones -//! ([`Region`]), los componentes asociados y los recursos adicionales (hojas de estilo, scripts, -//! *favicon*, etc.). +//! ([`TemplateName`](crate::core::theme::TemplateName)) que define la disposición de las regiones +//! ([`RegionName`]), los componentes asociados y los recursos adicionales (hojas de estilo, +//! scripts, *favicon*, etc.). //! //! El renderizado ([`Page::render()`]) delega en el tema ([`Theme`](crate::core::theme::Theme)) la //! composición del `` y del ``, y se ejecutan las acciones registradas por las //! extensiones antes y después de generar los contenidos. //! -//! También introduce regiones internas reservadas ([`ReservedRegion`]) que actúan como puntos de -//! anclaje globales al inicio y al final del documento. +//! También define las regiones internas reservadas ([`ReservedRegion`]) que actúan como puntos de +//! anclaje globales al inicio y al final del ``, fuera de las regiones que maqueta la +//! plantilla activa. mod error; pub use error::ErrorPage; @@ -19,8 +20,10 @@ pub(crate) use error::{render_error_pages, response_for_panic, route_not_found}; use crate::auth::CurrentUser; use crate::base::action; -use crate::core::component::{AssetsOp, ChildOp, Context, ContextError, Contextual}; -use crate::core::theme::{DefaultRegions, Region, RegionRef, TemplateRef, ThemeRef}; +use crate::base::component::layout; +use crate::core::component::{AssetsOp, ChildOp, ComponentRender}; +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::{Attr, Props, PropsOp}; use crate::html::{DOCTYPE, Markup, html}; @@ -32,37 +35,35 @@ use crate::{AutoDefault, builder_fn}; /// Regiones internas reservadas como puntos de anclaje globales. /// -/// Representan contenedores especiales situados al inicio y al final de un documento. Están -/// pensadas para proporcionar regiones donde inyectar contenido global o técnico. No suelen usarse -/// como regiones visibles en los temas. +/// Representan contenedores especiales situados al inicio y al final del ``, fuera de las +/// regiones que maqueta la plantilla activa. Las renderiza directamente [`Page::render()`], +/// envolviendo el resultado de +/// [`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 { - /// Región interna situada al **inicio del documento**. + /// Región interna situada al **inicio del ``**, de nombre `"page-top"`. /// - /// Su función es proporcionar un contenedor donde las extensiones puedan inyectar contenido - /// global antes del resto de regiones principales (cabecera, contenido, etc.). - /// - /// 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. + /// Proporciona un contenedor donde las extensiones puedan inyectar elementos auxiliares antes + /// del resto de regiones (cabecera, contenido, etc.), como marcadores técnicos, inicializadores + /// o contenido de depuración. /// /// Se considera una región **reservada** para este tipo de usos globales. + #[default] PageTop, - /// Región interna situada al **final del documento**. + /// Región interna situada al **final del ``**, de nombre `"page-bottom"`. /// - /// Pensada para proporcionar un contenedor donde las extensiones puedan inyectar contenido - /// global después del resto de regiones principales (cabecera, contenido, etc.). - /// - /// 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. + /// Proporciona un contenedor donde las extensiones puedan inyectar contenido global después del + /// resto de regiones (cabecera, contenido, etc.), como elementos auxiliares asociados a + /// comportamientos dinámicos. /// /// Igual que [`Self::PageTop`], se considera una región **reservada** para este tipo de usos /// globales. PageBottom, } -impl Region for ReservedRegion { +impl RegionName for ReservedRegion { #[inline] fn name(&self) -> &'static str { match self { @@ -101,22 +102,23 @@ impl Page { /// [`CurrentUser`] inyectado por middleware en sus extensiones (ver /// [`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. - #[rustfmt::skip] pub fn new(request: HttpRequest) -> Self { Page { - title : Attr::::default(), - description : Attr::::default(), - metadata : Vec::default(), - properties : Vec::default(), - context : Context::new(Some(request)), + context: Context::new(Some(request)), + ..Default::default() } } - /// Crea una nueva instancia de página con la plantilla de administración del tema activo. + /// Crea una nueva instancia de página con la plantilla [`CoreTemplate::Admin`]. + /// + /// 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 { - let mut page = Page::new(request); - page.context().use_admin_template(); - page + Page { + context: Context::new(Some(request)).with_template(&CoreTemplate::Admin), + ..Default::default() + } } // **< Page BUILDER >*************************************************************************** @@ -217,9 +219,9 @@ impl Page { // Renderiza el . let body = html! { - (ReservedRegion::PageTop.render(&mut self.context).await) + (layout::Region::of(&ReservedRegion::PageTop).render(&mut self.context).await) (self.context.theme().render_page_body(self).await) - (ReservedRegion::PageBottom.render(&mut self.context).await) + (layout::Region::of(&ReservedRegion::PageBottom).render(&mut self.context).await) }; // Acciones específicas del tema después de renderizar el . @@ -310,14 +312,13 @@ impl Contextual for Page { #[builder_fn] fn with_child(mut self, op: impl Into) -> Self { - self.context - .alter_child_in(&DefaultRegions::Content, op.into()); + self.context.alter_child_in(&CoreRegion::Content, op.into()); self } #[builder_fn] - fn with_child_in(mut self, region_ref: RegionRef, op: impl Into) -> Self { - self.context.alter_child_in(region_ref, op.into()); + fn with_child_in(mut self, region: RegionRef, op: impl Into) -> Self { + self.context.alter_child_in(region, op.into()); self } diff --git a/tests/component_template.rs b/tests/component_template.rs new file mode 100644 index 00000000..cc61b8ce --- /dev/null +++ b/tests/component_template.rs @@ -0,0 +1,118 @@ +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 { + Some(&Self) + } +} + +#[async_trait] +impl Theme for MarkerTheme { + async fn handle_component( + &self, + component: &mut dyn Component, + _cx: &mut Context, + ) -> Option> { + let template = (&*component).downcast_ref::()?; + template.template().downcast_ref::()?; + 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")); +} diff --git a/tests/theme_template.rs b/tests/theme_template.rs deleted file mode 100644 index 032527e9..00000000 --- a/tests/theme_template.rs +++ /dev/null @@ -1,83 +0,0 @@ -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 { - 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"); -} From b9d9cdf6016738024d50b31321c8761bcd913e51 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 26 Jul 2026 09:55:27 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=9A=A7=20Ajustes=20menores=20en=20rut?= =?UTF-8?q?as=20y=20localizaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añade `Route::try_resolve()` y `RoutePath::is_empty()` para omitir atributos HTML opcionales cuando la ruta resultante está vacía. - Renombra `Locale::configured_langid()` a `try_langid()` (convención `try_*` para Option). - Reordena include_locales! y otros cambios menores. --- src/core/component/route.rs | 9 +++++++++ src/html/assets/stylesheet.rs | 2 +- src/html/route_path.rs | 6 ++++++ src/locale/definition.rs | 2 +- src/locale/l10n.rs | 4 ++-- src/locale/request.rs | 6 +++--- 6 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/core/component/route.rs b/src/core/component/route.rs index 8e563329..6636c687 100644 --- a/src/core/component/route.rs +++ b/src/core/component/route.rs @@ -144,6 +144,15 @@ impl Route { pub fn resolve(&self, cx: &Context) -> RoutePath { (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 { + let route = self.resolve(cx); + (!route.is_empty()).then_some(route) + } } impl fmt::Debug for Route { diff --git a/src/html/assets/stylesheet.rs b/src/html/assets/stylesheet.rs index f0f6ca63..35e547ac 100644 --- a/src/html/assets/stylesheet.rs +++ b/src/html/assets/stylesheet.rs @@ -19,7 +19,7 @@ enum Source { Inline(CowStr, Box String + Send + Sync>), } -/// Define el medio objetivo para la hoja de estilos. +/// Define el medio objetivo para una hoja de estilos. /// /// Permite especificar en qué contexto se aplica el CSS, adaptándose a diferentes dispositivos o /// situaciones de impresión. diff --git a/src/html/route_path.rs b/src/html/route_path.rs index 95aab33a..041a0628 100644 --- a/src/html/route_path.rs +++ b/src/html/route_path.rs @@ -97,6 +97,12 @@ impl RoutePath { 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 >********************************************************************** // Codifica un valor para su uso seguro como parte de una *query string* según RFC 3986: los diff --git a/src/locale/definition.rs b/src/locale/definition.rs index e9dfe9fd..1b754e13 100644 --- a/src/locale/definition.rs +++ b/src/locale/definition.rs @@ -170,7 +170,7 @@ impl Locale { /// 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`. - pub fn configured_langid() -> Option<&'static LanguageIdentifier> { + pub fn try_langid() -> Option<&'static LanguageIdentifier> { *CONFIG_LANGID } diff --git a/src/locale/l10n.rs b/src/locale/l10n.rs index 66f20dc5..02b255f5 100644 --- a/src/locale/l10n.rs +++ b/src/locale/l10n.rs @@ -3,6 +3,8 @@ use crate::{AutoDefault, CowStr, include_locales}; use super::{LangId, Locale}; +include_locales!(LOCALES_PAGETOP); + use fluent_templates::Loader; use fluent_templates::StaticLoader as Locales; @@ -10,8 +12,6 @@ use std::collections::HashMap; use std::fmt; -include_locales!(LOCALES_PAGETOP); - /// Operación de localización a realizar. /// /// * `None` - No se aplica ninguna localización. diff --git a/src/locale/request.rs b/src/locale/request.rs index 53e4e032..7f5167f0 100644 --- a/src/locale/request.rs +++ b/src/locale/request.rs @@ -29,13 +29,13 @@ impl RequestLocale { /// - [`LangNegotiation::Full`](crate::global::LangNegotiation::Full) determina el idioma en /// este orden: /// 1. Parámetro de *query* `?lang=...`, si existe y corresponde a un idioma soportado. - /// 2. [`Locale::configured_langid()`], si la aplicación tiene un idioma por defecto válido. + /// 2. [`Locale::try_langid()`], si la aplicación tiene un idioma por defecto válido. /// 3. Cabecera `Accept-Language`, si puede resolverse con [`Locale::resolve()`]. /// 4. Idioma de respaldo. /// /// - [`LangNegotiation::NoQuery`](crate::global::LangNegotiation::NoQuery) descarta el uso del /// parámetro `?lang=...` y determina el idioma en este orden: - /// 1. [`Locale::configured_langid()`], si la aplicación tiene un idioma por defecto válido. + /// 1. [`Locale::try_langid()`], si la aplicación tiene un idioma por defecto válido. /// 2. Cabecera `Accept-Language`, si puede resolverse con [`Locale::resolve()`]. /// 3. Idioma de respaldo. /// @@ -56,7 +56,7 @@ impl RequestLocale { Locale::default_langid() } global::LangNegotiation::Full | global::LangNegotiation::NoQuery => { - if let Some(default) = Locale::configured_langid() { + if let Some(default) = Locale::try_langid() { default } else { // Sin idioma por defecto, se evalúa la cabecera `Accept-Language`.