From 8b06b1752ab673135b8d09d9dd95cf7d6d62f6aa Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Thu, 21 Aug 2025 09:12:22 +0200 Subject: [PATCH 01/35] =?UTF-8?q?=F0=9F=93=9D=20Retoques=20menores=20en=20?= =?UTF-8?q?la=20documentaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/theme.rs | 7 ++++--- src/core/theme/definition.rs | 11 ++++++++--- src/util.rs | 4 +++- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/core/theme.rs b/src/core/theme.rs index 0a0f819..2cd5bb1 100644 --- a/src/core/theme.rs +++ b/src/core/theme.rs @@ -9,9 +9,10 @@ //! tipografías, espaciados y cualquier otro detalle visual o de comportamiento (como animaciones, //! *scripts* de interfaz, etc.). //! -//! Es una extensión más (implementando [`Extension`](crate::core::extension::Extension)). Se -//! instala, activa y declara dependencias igual que el resto de extensiones; y se señala a sí misma -//! como tema (implementando [`theme()`](crate::core::extension::Extension::theme) y [`Theme`]). +//! Los temas son extensiones que implementan [`Extension`](crate::core::extension::Extension); por +//! lo que se instancian, declaran sus dependencias y se inician igual que el resto de extensiones; +//! pero serán temas si además implementan [`theme()`](crate::core::extension::Extension::theme) y +//! [`Theme`]. mod definition; pub use definition::{Theme, ThemeRef}; diff --git a/src/core/theme/definition.rs b/src/core/theme/definition.rs index 8de88bd..e9cfbba 100644 --- a/src/core/theme/definition.rs +++ b/src/core/theme/definition.rs @@ -14,7 +14,7 @@ pub type ThemeRef = &'static dyn Theme; /// Interfaz común que debe implementar cualquier tema de `PageTop`. /// /// Un tema implementará [`Theme`] y los métodos que sean necesarios de [`Extension`], aunque el -/// único obligatorio es [`theme()`](Extension::theme). +/// único obligatorio será [`theme()`](Extension::theme). /// /// ```rust /// use pagetop::prelude::*; @@ -22,8 +22,13 @@ pub type ThemeRef = &'static dyn Theme; /// pub struct MyTheme; /// /// impl Extension for MyTheme { -/// fn name(&self) -> L10n { L10n::n("My theme") } -/// fn description(&self) -> L10n { L10n::n("Un tema personal") } +/// fn name(&self) -> L10n { +/// L10n::n("My theme") +/// } +/// +/// fn description(&self) -> L10n { +/// L10n::n("A personal theme") +/// } /// /// fn theme(&self) -> Option { /// Some(&Self) diff --git a/src/util.rs b/src/util.rs index 21537c5..e70b099 100644 --- a/src/util.rs +++ b/src/util.rs @@ -56,8 +56,10 @@ pub fn resolve_absolute_dir>(path: P) -> io::Result { } } +/// **Obsoleto desde la versión 0.3.0**: usar [`resolve_absolute_dir()`] en su lugar. +/// /// Devuelve la ruta absoluta a un directorio existente. -#[deprecated(since = "0.3.0", note = "Use [`resolve_absolute_dir`] instead")] +#[deprecated(since = "0.3.0", note = "Use `resolve_absolute_dir()` instead")] pub fn absolute_dir(root_path: P, relative_path: Q) -> io::Result where P: AsRef, From 97581659bf76fd3696553bb600e401c27b83fa3f Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Thu, 21 Aug 2025 09:31:20 +0200 Subject: [PATCH 02/35] =?UTF-8?q?=F0=9F=A6=BA=20Modifica=20tipos=20para=20?= =?UTF-8?q?atributos=20HTML=20a=20min=C3=BAsculas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/html/opt_classes.rs | 7 ++++--- src/html/opt_id.rs | 5 +++-- src/html/opt_name.rs | 5 +++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/html/opt_classes.rs b/src/html/opt_classes.rs index abb3ba4..a985762 100644 --- a/src/html/opt_classes.rs +++ b/src/html/opt_classes.rs @@ -25,6 +25,7 @@ pub enum ClassesOp { /// /// - El [orden de las clases no es relevante](https://stackoverflow.com/a/1321712) en CSS. /// - No se permiten clases duplicadas. +/// - Las clases se convierten a minúsculas. /// - Las clases vacías se ignoran. /// /// # Ejemplo @@ -32,8 +33,8 @@ pub enum ClassesOp { /// ```rust /// use pagetop::prelude::*; /// -/// let classes = OptionClasses::new("btn btn-primary") -/// .with_value(ClassesOp::Add, "active") +/// let classes = OptionClasses::new("Btn btn-primary") +/// .with_value(ClassesOp::Add, "Active") /// .with_value(ClassesOp::Remove, "btn-primary"); /// /// assert_eq!(classes.get(), Some(String::from("btn active"))); @@ -51,7 +52,7 @@ impl OptionClasses { #[builder_fn] pub fn with_value(mut self, op: ClassesOp, classes: impl AsRef) -> Self { - let classes: &str = classes.as_ref(); + let classes = classes.as_ref().to_ascii_lowercase(); let classes: Vec<&str> = classes.split_ascii_whitespace().collect(); if classes.is_empty() { diff --git a/src/html/opt_id.rs b/src/html/opt_id.rs index 893ac6d..139fdcd 100644 --- a/src/html/opt_id.rs +++ b/src/html/opt_id.rs @@ -7,6 +7,7 @@ use crate::{builder_fn, AutoDefault}; /// # Normalización /// /// - Se eliminan los espacios al principio y al final. +/// - Se convierte a minúsculas. /// - Se sustituyen los espacios intermedios por guiones bajos (`_`). /// - Si el resultado es una cadena vacía, se guarda `None`. /// @@ -15,7 +16,7 @@ use crate::{builder_fn, AutoDefault}; /// ```rust /// use pagetop::prelude::*; /// -/// let id = OptionId::new("main section"); +/// let id = OptionId::new(" main Section "); /// assert_eq!(id.get(), Some(String::from("main_section"))); /// /// let empty = OptionId::default(); @@ -39,7 +40,7 @@ impl OptionId { /// El valor se normaliza automáticamente. #[builder_fn] pub fn with_value(mut self, value: impl AsRef) -> Self { - let value = value.as_ref().trim().replace(' ', "_"); + let value = value.as_ref().trim().to_ascii_lowercase().replace(' ', "_"); self.0 = (!value.is_empty()).then_some(value); self } diff --git a/src/html/opt_name.rs b/src/html/opt_name.rs index aa74e3b..ffb0b98 100644 --- a/src/html/opt_name.rs +++ b/src/html/opt_name.rs @@ -7,6 +7,7 @@ use crate::{builder_fn, AutoDefault}; /// # Normalización /// /// - Se eliminan los espacios al principio y al final. +/// - Se convierte a minúsculas. /// - Se sustituyen los espacios intermedios por guiones bajos (`_`). /// - Si el resultado es una cadena vacía, se guarda `None`. /// @@ -15,7 +16,7 @@ use crate::{builder_fn, AutoDefault}; /// ```rust /// use pagetop::prelude::*; /// -/// let name = OptionName::new(" display name "); +/// let name = OptionName::new(" DISplay name "); /// assert_eq!(name.get(), Some(String::from("display_name"))); /// /// let empty = OptionName::default(); @@ -39,7 +40,7 @@ impl OptionName { /// El valor se normaliza automáticamente. #[builder_fn] pub fn with_value(mut self, value: impl AsRef) -> Self { - let value = value.as_ref().trim().replace(' ', "_"); + let value = value.as_ref().trim().to_ascii_lowercase().replace(' ', "_"); self.0 = (!value.is_empty()).then_some(value); self } From 8de3f0b7a52755e3d7e3b8c514f9b8ae67081604 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Thu, 21 Aug 2025 09:36:25 +0200 Subject: [PATCH 03/35] =?UTF-8?q?=F0=9F=8E=A8=20Retoques=20al=20importar?= =?UTF-8?q?=20`fmt`=20para=20usar=20`Display`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/html/context.rs | 5 ++--- src/response/page/error.rs | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/html/context.rs b/src/html/context.rs index 5fbb39b..72c0ff2 100644 --- a/src/html/context.rs +++ b/src/html/context.rs @@ -9,10 +9,9 @@ use crate::{builder_fn, join}; use std::collections::HashMap; use std::error::Error; +use std::fmt::{self, Display}; use std::str::FromStr; -use std::fmt; - /// Operaciones para modificar el contexto ([`Context`]) del documento. pub enum AssetsOp { // Favicon. @@ -43,7 +42,7 @@ pub enum ErrorParam { ParseError(String), } -impl fmt::Display for ErrorParam { +impl Display for ErrorParam { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { ErrorParam::NotFound => write!(f, "Parameter not found"), diff --git a/src/response/page/error.rs b/src/response/page/error.rs index 3a2511c..99ba62b 100644 --- a/src/response/page/error.rs +++ b/src/response/page/error.rs @@ -6,7 +6,7 @@ use crate::service::{HttpRequest, HttpResponse}; use super::Page; -use std::fmt; +use std::fmt::{self, Display}; #[derive(Debug)] pub enum ErrorPage { @@ -19,7 +19,7 @@ pub enum ErrorPage { Timeout(HttpRequest), } -impl fmt::Display for ErrorPage { +impl Display for ErrorPage { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { // Error 304. From 3e76c656ebad8d561e8eb02bb09287d3849cbf9d Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Thu, 21 Aug 2025 09:40:10 +0200 Subject: [PATCH 04/35] =?UTF-8?q?=F0=9F=90=9B=20[welcome]=20Corrige=20giro?= =?UTF-8?q?=20bot=C3=B3n=20con=20ancho=20estrecho?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/css/welcome.css | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/static/css/welcome.css b/static/css/welcome.css index 8f60348..76b042b 100644 --- a/static/css/welcome.css +++ b/static/css/welcome.css @@ -295,11 +295,6 @@ a:hover:visited { transform: translateX(-100%); } } -#poweredby-link:hover { - transition: all .5s; - transform: rotate(-3deg) scale(1.1); - box-shadow: 0px 3px 5px rgba(0,0,0,.4); -} #poweredby-link:hover span { animation-play-state: paused; } @@ -323,6 +318,11 @@ a:hover:visited { max-width: 29.375rem; margin-bottom: 0; } + #poweredby-link:hover { + transition: all .5s; + transform: rotate(-3deg) scale(1.1); + box-shadow: 0px 3px 5px rgba(0,0,0,.4); + } } .content-text { From 512a406ede10267001ac011cb8efdc1db6f11337 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Fri, 22 Aug 2025 07:46:36 +0200 Subject: [PATCH 05/35] =?UTF-8?q?=E2=9C=A8=20[app]=20A=C3=B1ade=20manejo?= =?UTF-8?q?=20de=20rutas=20no=20encontradas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index c3576fc..400b0cd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,6 +3,9 @@ mod figfont; use crate::core::{extension, extension::ExtensionRef}; +use crate::html::Markup; +use crate::response::page::{ErrorPage, ResultPage}; +use crate::service::HttpRequest; use crate::{global, locale, service, trace}; use actix_session::config::{BrowserSession, PersistentSession, SessionLifecycle}; @@ -170,6 +173,12 @@ impl Application { InitError = (), >, > { - service::App::new().configure(extension::all::configure_services) + service::App::new() + .configure(extension::all::configure_services) + .default_service(service::web::route().to(service_not_found)) } } + +async fn service_not_found(request: HttpRequest) -> ResultPage { + Err(ErrorPage::NotFound(request)) +} From 75eec8bebc996d1e9fb8b256bee1a3ddd58f7977 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Fri, 22 Aug 2025 08:29:11 +0200 Subject: [PATCH 06/35] =?UTF-8?q?=F0=9F=8E=A8=20[theme]=20Mejora=20gesti?= =?UTF-8?q?=C3=B3n=20de=20regiones=20en=20p=C3=A1ginas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/extension/definition.rs | 19 +++---- src/core/theme.rs | 5 +- src/core/theme/definition.rs | 81 ++++++++++++++++++++++++++---- src/core/theme/regions.rs | 85 ++++++++++++++++++++++++++------ src/locale/en-US/theme.ftl | 9 +++- src/locale/es-ES/theme.ftl | 9 +++- src/response/page.rs | 15 +++--- 7 files changed, 174 insertions(+), 49 deletions(-) diff --git a/src/core/extension/definition.rs b/src/core/extension/definition.rs index ac29259..6df7042 100644 --- a/src/core/extension/definition.rs +++ b/src/core/extension/definition.rs @@ -26,7 +26,7 @@ pub type ExtensionRef = &'static dyn Extension; /// } /// ``` pub trait Extension: AnyInfo + Send + Sync { - /// Nombre legible para el usuario. + /// Nombre localizado de la extensión legible para el usuario. /// /// Predeterminado por el [`short_name()`](AnyInfo::short_name) del tipo asociado a la /// extensión. @@ -34,18 +34,15 @@ pub trait Extension: AnyInfo + Send + Sync { L10n::n(self.short_name()) } - /// Descripción corta para paneles, listados, etc. + /// Descripción corta localizada de la extensión para paneles, listados, etc. fn description(&self) -> L10n { L10n::default() } - /// Los temas son extensiones que implementan [`Extension`] y también - /// [`Theme`](crate::core::theme::Theme). + /// Devuelve una referencia a esta misma extensión cuando se trata de un tema. /// - /// Si la extensión no es un tema, este método devuelve `None` por defecto. - /// - /// En caso contrario, este método debe implementarse para devolver una referencia de sí mismo - /// como tema. Por ejemplo: + /// Para ello, debe implementar [`Extension`] y también [`Theme`](crate::core::theme::Theme). Si + /// la extensión no es un tema, este método devuelve `None` por defecto. /// /// ```rust /// use pagetop::prelude::*; @@ -81,7 +78,7 @@ pub trait Extension: AnyInfo + Send + Sync { actions_boxed![] } - /// Inicializa la extensión durante la lógica de arranque de la aplicación. + /// Inicializa la extensión durante la fase de arranque de la aplicación. /// /// Se llama una sola vez, después de que todas las dependencias se han inicializado y antes de /// aceptar cualquier petición HTTP. @@ -104,8 +101,8 @@ pub trait Extension: AnyInfo + Send + Sync { #[allow(unused_variables)] fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {} - /// Permite crear extensiones para deshabilitar y desinstalar los recursos de otras extensiones - /// utilizadas en versiones anteriores de la aplicación. + /// Permite crear extensiones para deshabilitar y desinstalar recursos de otras de versiones + /// anteriores de la aplicación. /// /// Actualmente no se usa, pero se deja como *placeholder* para futuras implementaciones. fn drop_extensions(&self) -> Vec { diff --git a/src/core/theme.rs b/src/core/theme.rs index 2cd5bb1..aa526f1 100644 --- a/src/core/theme.rs +++ b/src/core/theme.rs @@ -19,9 +19,6 @@ pub use definition::{Theme, ThemeRef}; mod regions; pub(crate) use regions::ChildrenInRegions; -pub use regions::InRegion; +pub use regions::{InRegion, Region, REGION_CONTENT}; pub(crate) mod all; - -/// Nombre de la región por defecto: `content`. -pub const CONTENT_REGION_NAME: &str = "content"; diff --git a/src/core/theme/definition.rs b/src/core/theme/definition.rs index e9cfbba..8d1b632 100644 --- a/src/core/theme/definition.rs +++ b/src/core/theme/definition.rs @@ -1,10 +1,12 @@ use crate::core::extension::Extension; -use crate::core::theme::CONTENT_REGION_NAME; +use crate::core::theme::Region; use crate::global; use crate::html::{html, Markup}; use crate::locale::L10n; use crate::response::page::Page; +use std::sync::LazyLock; + /// Representa una referencia a un tema. /// /// Los temas son también extensiones. Por tanto se deben definir igual, es decir, como instancias @@ -38,21 +40,67 @@ pub type ThemeRef = &'static dyn Theme; /// impl Theme for MyTheme {} /// ``` pub trait Theme: Extension + Send + Sync { + /// **Obsoleto desde la versión 0.4.0**: usar [`declared_regions()`](Self::declared_regions) en + /// su lugar. + #[deprecated(since = "0.4.0", note = "Use `declared_regions()` instead")] fn regions(&self) -> Vec<(&'static str, L10n)> { - vec![(CONTENT_REGION_NAME, L10n::l("content"))] + vec![("content", L10n::l("content"))] } + /// Declaración ordenada de las regiones disponibles en la página. + /// + /// Devuelve una lista estática de pares `(Region, L10n)` que se usará para renderizar en el + /// orden indicado todas las regiones que componen una página. Los identificadores deben ser + /// **estables** como `"sidebar-left"` o `"content"`. La etiqueta `L10n` devuelve el nombre de la + /// región en el idioma activo de la página. + /// + /// Si el tema requiere un conjunto distinto de regiones, se puede sobrescribir este método para + /// devolver una lista diferente. Si no, se usará la lista predeterminada: + /// + /// - `"header"`: cabecera. + /// - `"content"`: contenido principal (**obligatoria**). + /// - `"footer"`: pie. + /// + /// Sólo la región `"content"` es obligatoria, usa [`Region::default()`] para declararla. + #[inline] + fn declared_regions(&self) -> &'static [(Region, L10n)] { + static REGIONS: LazyLock<[(Region, L10n); 3]> = LazyLock::new(|| { + [ + (Region::declare("header"), L10n::l("region_header")), + (Region::default(), L10n::l("region_content")), + (Region::declare("footer"), L10n::l("region_footer")), + ] + }); + ®IONS[..] + } + + /// Acciones específicas del tema antes de renderizar el `` de la página. + /// + /// Útil para preparar clases, inyectar recursos o ajustar metadatos. #[allow(unused_variables)] fn before_render_page_body(&self, page: &mut Page) {} + /// Renderiza el contenido del `` de la página. + /// + /// Por defecto, recorre [`declared_regions()`](Self::declared_regions) **en el orden que se han + /// declarado** y, para cada región con contenido, genera un contenedor con `role="region"` y + /// `aria-label` localizado. fn render_page_body(&self, page: &mut Page) -> Markup { html! { body id=[page.body_id().get()] class=[page.body_classes().get()] { - @for (region_name, _) in self.regions() { - @let output = page.render_region(region_name); + @for (region, region_label) in self.declared_regions() { + @let output = page.render_region(region.key()); @if !output.is_empty() { - div id=(region_name) class={ "region-container region-" (region_name) } { - (output) + @let region_name = region.name(); + div + id=(region_name) + class="region" + role="region" + aria-label=[region_label.using(page)] + { + div class={ "region__" (region_name) } { + (output) + } } } } @@ -60,9 +108,16 @@ pub trait Theme: Extension + Send + Sync { } } + /// Acciones específicas del tema después de renderizar el `` de la página. + /// + /// Útil para *tracing*, métricas o ajustes finales del estado de la página. #[allow(unused_variables)] fn after_render_page_body(&self, page: &mut Page) {} + /// Renderiza el contenido del `` de la página. + /// + /// Por defecto, genera las etiquetas básicas (`charset`, `title`, `description`, `viewport`, + /// `X-UA-Compatible`), los metadatos y propiedades de la página y los recursos (CSS/JS). fn render_page_head(&self, page: &mut Page) -> Markup { let viewport = "width=device-width, initial-scale=1, shrink-to-fit=no"; html! { @@ -94,11 +149,17 @@ pub trait Theme: Extension + Send + Sync { } } - fn error403(&self, _page: &mut Page) -> Markup { - html! { div { h1 { ("FORBIDDEN ACCESS") } } } + /// Página de error "*403 – Forbidden*" predeterminada. + /// + /// Se puede sobrescribir este método para personalizar y adaptar este contenido al tema. + fn error403(&self, page: &mut Page) -> Markup { + html! { div { h1 { (L10n::l("error403_notice").to_markup(page)) } } } } - fn error404(&self, _page: &mut Page) -> Markup { - html! { div { h1 { ("RESOURCE NOT FOUND") } } } + /// Página de error "*404 – Not Found*" predeterminada. + /// + /// Se puede sobrescribir este método para personalizar y adaptar este contenido al tema. + fn error404(&self, page: &mut Page) -> Markup { + html! { div { h1 { (L10n::l("error404_notice").to_markup(page)) } } } } } diff --git a/src/core/theme/regions.rs b/src/core/theme/regions.rs index 22ab6f2..c8a0555 100644 --- a/src/core/theme/regions.rs +++ b/src/core/theme/regions.rs @@ -1,5 +1,5 @@ use crate::core::component::{Child, ChildOp, Children}; -use crate::core::theme::{ThemeRef, CONTENT_REGION_NAME}; +use crate::core::theme::ThemeRef; use crate::{builder_fn, AutoDefault, UniqueId}; use parking_lot::RwLock; @@ -7,15 +7,71 @@ use parking_lot::RwLock; use std::collections::HashMap; use std::sync::LazyLock; -// Regiones globales con componentes para un tema dado. +// Conjunto de regiones globales asociadas a un tema específico. static THEME_REGIONS: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::new())); -// Regiones globales con componentes para cualquier tema. +// Conjunto de regiones globales comunes a todos los temas. static COMMON_REGIONS: LazyLock> = LazyLock::new(|| RwLock::new(ChildrenInRegions::default())); -// Estructura interna para mantener los componentes de una región. +/// Nombre de la región de contenido por defecto (`"content"`). +pub const REGION_CONTENT: &str = "content"; + +/// Identificador de una región de página. +/// +/// Incluye una **clave estática** ([`key()`](Self::key)) que identifica la región en el tema, y un +/// **nombre normalizado** ([`name()`](Self::name)) en minúsculas para su uso en atributos HTML +/// (p.ej., clases `region__{name}`). +/// +/// Se utiliza para declarar las regiones que componen una página en un tema (ver +/// [`declared_regions()`](crate::core::theme::Theme::declared_regions)). +pub struct Region { + key: &'static str, + name: String, +} + +impl Default for Region { + #[inline] + fn default() -> Self { + Self { + key: REGION_CONTENT, + name: String::from(REGION_CONTENT), + } + } +} + +impl Region { + /// Declara una región a partir de su clave estática. + /// + /// Genera además un nombre normalizado de la clave, eliminando espacios iniciales y finales, + /// convirtiendo a minúsculas y sustituyendo los espacios intermedios por guiones (`-`). + /// + /// Esta clave se usará para añadir componentes a la región; por ello se recomiendan nombres + /// sencillos, limitando los caracteres a `[a-z0-9-]` (p.ej., `"sidebar"` o `"main-menu"`), cuyo + /// nombre normalizado coincidirá con la clave. + #[inline] + pub fn declare(key: &'static str) -> Self { + Self { + key, + name: key.trim().to_ascii_lowercase().replace(' ', "-"), + } + } + + /// Devuelve la clave estática asignada a la región. + #[inline] + pub fn key(&self) -> &'static str { + self.key + } + + /// Devuelve el nombre normalizado de la región (para atributos y búsquedas). + #[inline] + pub fn name(&self) -> &str { + &self.name + } +} + +// Contenedor interno de componentes agrupados por región. #[derive(AutoDefault)] pub struct ChildrenInRegions(HashMap<&'static str, Children>); @@ -48,25 +104,24 @@ impl ChildrenInRegions { } } -/// Permite añadir componentes a regiones globales o regiones de temas concretos. +/// Punto de acceso para añadir componentes a regiones globales o específicas de un tema. /// -/// Dada una región, según la variante seleccionada, se le podrán añadir ([`add()`](Self::add)) -/// componentes que se mantendrán durante la ejecución de la aplicación. +/// Según la variante, se pueden añadir componentes ([`add()`](Self::add)) que permanecerán +/// disponibles durante toda la ejecución. /// -/// Estas estructuras de componentes se renderizarán automáticamente al procesar los documentos HTML -/// que las usan, como las páginas de contenido ([`Page`](crate::response::page::Page)), por -/// ejemplo. +/// Estos componentes se renderizarán automáticamente al procesar los documentos HTML que incluyen +/// estas regiones, como las páginas de contenido ([`Page`](crate::response::page::Page)). pub enum InRegion { - /// Representa la región por defecto en la que se pueden añadir componentes. + /// Región de contenido por defecto. Content, - /// Representa la región con el nombre del argumento. + /// Región identificada por el nombre proporcionado. Named(&'static str), - /// Representa la región con el nombre y del tema especificado en los argumentos. + /// Región identificada por un nombre y asociada a un tema concreto. OfTheme(&'static str, ThemeRef), } impl InRegion { - /// Permite añadir un componente en la región de la variante seleccionada. + /// Añade un componente a la región indicada por la variante. /// /// # Ejemplo /// @@ -88,7 +143,7 @@ impl InRegion { InRegion::Content => { COMMON_REGIONS .write() - .alter_child_in_region(CONTENT_REGION_NAME, ChildOp::Add(child)); + .alter_child_in_region(REGION_CONTENT, ChildOp::Add(child)); } InRegion::Named(name) => { COMMON_REGIONS diff --git a/src/locale/en-US/theme.ftl b/src/locale/en-US/theme.ftl index 9c71e6b..f766766 100644 --- a/src/locale/en-US/theme.ftl +++ b/src/locale/en-US/theme.ftl @@ -1,2 +1,9 @@ -content = Content +# Regions. +region_header = Header +region_content = Content +region_footer = Footer + +error403_notice = FORBIDDEN ACCESS +error404_notice = RESOURCE NOT FOUND + pagetop_logo = PageTop Logo diff --git a/src/locale/es-ES/theme.ftl b/src/locale/es-ES/theme.ftl index f193c53..b8b9144 100644 --- a/src/locale/es-ES/theme.ftl +++ b/src/locale/es-ES/theme.ftl @@ -1,2 +1,9 @@ -content = Contenido +# Regions. +region_header = Cabecera +region_content = Contenido +region_footer = Pie de página + +error403_notice = ACCESO NO PERMITIDO +error404_notice = RECURSO NO ENCONTRADO + pagetop_logo = Logotipo de PageTop diff --git a/src/response/page.rs b/src/response/page.rs index 44cab72..f30e299 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -6,7 +6,7 @@ pub use actix_web::Result as ResultPage; use crate::base::action; use crate::builder_fn; use crate::core::component::{Child, ChildOp, Component}; -use crate::core::theme::{ChildrenInRegions, ThemeRef, CONTENT_REGION_NAME}; +use crate::core::theme::{ChildrenInRegions, ThemeRef, REGION_CONTENT}; use crate::html::{html, AssetsOp, Context, Markup, DOCTYPE}; use crate::html::{ClassesOp, OptionClasses, OptionId, OptionTranslated}; use crate::locale::{CharacterDirection, L10n, LangId, LanguageIdentifier}; @@ -123,7 +123,7 @@ impl Page { /// Añade un componente a la región de contenido por defecto. pub fn with_component(mut self, component: impl Component) -> Self { self.regions - .alter_child_in_region(CONTENT_REGION_NAME, ChildOp::Add(Child::with(component))); + .alter_child_in_region(REGION_CONTENT, ChildOp::Add(Child::with(component))); self } @@ -172,11 +172,6 @@ impl Page { self.context.request() } - /// Devuelve el identificador de idioma asociado. - pub fn langid(&self) -> &LanguageIdentifier { - self.context.langid() - } - /// Devuelve el tema que se usará para renderizar la página. pub fn theme(&self) -> ThemeRef { self.context.theme() @@ -250,3 +245,9 @@ impl Page { }) } } + +impl LangId for Page { + fn langid(&self) -> &'static LanguageIdentifier { + self.context.langid() + } +} From bb34ba5887e61a71afc5d65833c07a8d76c3c29b Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sat, 23 Aug 2025 18:52:45 +0200 Subject: [PATCH 07/35] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20[html]=20Cambia=20ti?= =?UTF-8?q?pos=20`Option...`=20por=20`Attr...`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renombra los tipos para atributos HTML `Id`, `Name`, `Value` (`String`), `L10n` (`Translate`) y `Classes`. Y mueve `OptionComponent` al *core* de componentes como `TypedSlot`. --- .../component/after_render_component.rs | 4 +- .../component/before_render_component.rs | 4 +- src/base/action/component/is_renderable.rs | 4 +- src/core/component.rs | 3 + src/core/component/children.rs | 17 ++-- src/core/component/slot.rs | 64 +++++++++++++ src/html.rs | 90 +++++++++++++------ src/html/{opt_classes.rs => attr_classes.rs} | 14 +-- src/html/attr_id.rs | 63 +++++++++++++ src/html/{opt_translated.rs => attr_l10n.rs} | 16 ++-- src/html/attr_name.rs | 63 +++++++++++++ src/html/attr_value.rs | 65 ++++++++++++++ src/html/opt_component.rs | 68 -------------- src/html/opt_id.rs | 59 ------------ src/html/opt_name.rs | 59 ------------ src/html/opt_string.rs | 57 ------------ src/response/page.rs | 28 +++--- 17 files changed, 367 insertions(+), 311 deletions(-) create mode 100644 src/core/component/slot.rs rename src/html/{opt_classes.rs => attr_classes.rs} (90%) create mode 100644 src/html/attr_id.rs rename src/html/{opt_translated.rs => attr_l10n.rs} (79%) create mode 100644 src/html/attr_name.rs create mode 100644 src/html/attr_value.rs delete mode 100644 src/html/opt_component.rs delete mode 100644 src/html/opt_id.rs delete mode 100644 src/html/opt_name.rs delete mode 100644 src/html/opt_string.rs diff --git a/src/base/action/component/after_render_component.rs b/src/base/action/component/after_render_component.rs index 917f322..0cb0334 100644 --- a/src/base/action/component/after_render_component.rs +++ b/src/base/action/component/after_render_component.rs @@ -6,7 +6,7 @@ use crate::base::action::FnActionWithComponent; pub struct AfterRender { f: FnActionWithComponent, referer_type_id: Option, - referer_id: OptionId, + referer_id: AttrId, weight: Weight, } @@ -34,7 +34,7 @@ impl AfterRender { AfterRender { f, referer_type_id: Some(UniqueId::of::()), - referer_id: OptionId::default(), + referer_id: AttrId::default(), weight: 0, } } diff --git a/src/base/action/component/before_render_component.rs b/src/base/action/component/before_render_component.rs index 8c2e38d..46ff9aa 100644 --- a/src/base/action/component/before_render_component.rs +++ b/src/base/action/component/before_render_component.rs @@ -6,7 +6,7 @@ use crate::base::action::FnActionWithComponent; pub struct BeforeRender { f: FnActionWithComponent, referer_type_id: Option, - referer_id: OptionId, + referer_id: AttrId, weight: Weight, } @@ -34,7 +34,7 @@ impl BeforeRender { BeforeRender { f, referer_type_id: Some(UniqueId::of::()), - referer_id: OptionId::default(), + referer_id: AttrId::default(), weight: 0, } } diff --git a/src/base/action/component/is_renderable.rs b/src/base/action/component/is_renderable.rs index baa86f1..5a0e244 100644 --- a/src/base/action/component/is_renderable.rs +++ b/src/base/action/component/is_renderable.rs @@ -11,7 +11,7 @@ pub type FnIsRenderable = fn(component: &C, cx: &Context) -> bool; pub struct IsRenderable { f: FnIsRenderable, referer_type_id: Option, - referer_id: OptionId, + referer_id: AttrId, weight: Weight, } @@ -39,7 +39,7 @@ impl IsRenderable { IsRenderable { f, referer_type_id: Some(UniqueId::of::()), - referer_id: OptionId::default(), + referer_id: AttrId::default(), weight: 0, } } diff --git a/src/core/component.rs b/src/core/component.rs index 17b9b73..3691472 100644 --- a/src/core/component.rs +++ b/src/core/component.rs @@ -7,3 +7,6 @@ mod children; pub use children::Children; pub use children::{Child, ChildOp}; pub use children::{Typed, TypedOp}; + +mod slot; +pub use slot::TypedSlot; diff --git a/src/core/component/children.rs b/src/core/component/children.rs index fb85db7..cb112e1 100644 --- a/src/core/component/children.rs +++ b/src/core/component/children.rs @@ -9,13 +9,13 @@ use std::vec::IntoIter; /// Representa un componente encapsulado de forma segura y compartida. /// -/// Esta estructura permite manipular y renderizar cualquier tipo que implemente [`Component`], -/// garantizando acceso concurrente a través de [`Arc>`]. +/// Esta estructura permite manipular y renderizar un componente que implemente [`Component`], y +/// habilita acceso concurrente mediante [`Arc>`]. #[derive(Clone)] pub struct Child(Arc>); impl Child { - /// Crea un nuevo [`Child`] a partir de un componente. + /// Crea un nuevo `Child` a partir de un componente. pub fn with(component: impl Component) -> Self { Child(Arc::new(RwLock::new(component))) } @@ -46,7 +46,8 @@ impl Child { /// Variante tipada de [`Child`] para evitar conversiones durante el uso. /// -/// Facilita el acceso a componentes del mismo tipo sin necesidad de hacer `downcast`. +/// Esta estructura permite manipular y renderizar un componente concreto que implemente +/// [`Component`], y habilita acceso concurrente mediante [`Arc>`]. pub struct Typed(Arc>); impl Clone for Typed { @@ -56,7 +57,7 @@ impl Clone for Typed { } impl Typed { - /// Crea un nuevo [`Typed`] a partir de un componente. + /// Crea un nuevo `Typed` a partir de un componente. pub fn with(component: C) -> Self { Typed(Arc::new(RwLock::new(component))) } @@ -284,7 +285,7 @@ impl IntoIterator for Children { /// /// # Ejemplo de uso: /// - /// ```rust#ignore + /// ```rust,ignore /// let children = Children::new().with(child1).with(child2); /// for child in children { /// println!("{:?}", child.id()); @@ -303,7 +304,7 @@ impl<'a> IntoIterator for &'a Children { /// /// # Ejemplo de uso: /// - /// ```rust#ignore + /// ```rust,ignore /// let children = Children::new().with(child1).with(child2); /// for child in &children { /// println!("{:?}", child.id()); @@ -322,7 +323,7 @@ impl<'a> IntoIterator for &'a mut Children { /// /// # Ejemplo de uso: /// - /// ```rust#ignore + /// ```rust,ignore /// let mut children = Children::new().with(child1).with(child2); /// for child in &mut children { /// child.render(&mut context); diff --git a/src/core/component/slot.rs b/src/core/component/slot.rs new file mode 100644 index 0000000..19ed72a --- /dev/null +++ b/src/core/component/slot.rs @@ -0,0 +1,64 @@ +use crate::builder_fn; +use crate::core::component::{Component, Typed}; +use crate::html::{html, Context, Markup}; + +/// Contenedor para un componente [`Typed`] opcional. +/// +/// Un `TypedSlot` actúa como un contenedor dentro de otro componente para incluir o no un +/// subcomponente. Internamente encapsula `Option>`, pero proporciona una API más sencilla +/// para construir estructuras jerárquicas. +/// +/// # Ejemplo +/// +/// ```rust,ignore +/// use pagetop::prelude::*; +/// +/// let comp = MyComponent::new(); +/// let opt = TypedSlot::new(comp); +/// assert!(opt.get().is_some()); +/// ``` +pub struct TypedSlot(Option>); + +impl Default for TypedSlot { + fn default() -> Self { + TypedSlot(None) + } +} + +impl TypedSlot { + /// Crea un nuevo [`TypedSlot`]. + /// + /// El componente se envuelve automáticamente en un [`Typed`] y se almacena. + pub fn new(component: C) -> Self { + TypedSlot(Some(Typed::with(component))) + } + + // TypedSlot BUILDER ********************************************************************* + + /// Establece un componente nuevo, o lo vacía. + /// + /// Si se proporciona `Some(component)`, se guarda en [`Typed`]; y si es `None`, se limpia. + #[builder_fn] + pub fn with_value(mut self, component: Option) -> Self { + self.0 = component.map(Typed::with); + self + } + + // TypedSlot GETTERS ********************************************************************* + + /// Devuelve un clon (incrementa el contador `Arc`) de [`Typed`], si existe. + pub fn get(&self) -> Option> { + self.0.clone() + } + + // TypedSlot RENDER ************************************************************************ + + /// Renderiza el componente, si existe. + pub fn render(&self, cx: &mut Context) -> Markup { + if let Some(component) = &self.0 { + component.render(cx) + } else { + html! {} + } + } +} diff --git a/src/html.rs b/src/html.rs index 82fa906..784457e 100644 --- a/src/html.rs +++ b/src/html.rs @@ -3,52 +3,82 @@ mod maud; pub use maud::{display, html, html_private, Escaper, Markup, PreEscaped, Render, DOCTYPE}; +// HTML DOCUMENT ASSETS **************************************************************************** + mod assets; pub use assets::favicon::Favicon; pub use assets::javascript::JavaScript; pub use assets::stylesheet::{StyleSheet, TargetMedia}; pub(crate) use assets::Assets; +// HTML DOCUMENT CONTEXT *************************************************************************** + mod context; pub use context::{AssetsOp, Context, ErrorParam}; -mod opt_id; -pub use opt_id::OptionId; +// HTML ATTRIBUTES ********************************************************************************* -mod opt_name; -pub use opt_name::OptionName; +mod attr_id; +pub use attr_id::AttrId; +/// **Obsoleto desde la versión 0.4.0**: usar [`AttrId`] en su lugar. +#[deprecated(since = "0.4.0", note = "Use `AttrId` instead")] +pub type OptionId = AttrId; -mod opt_string; -pub use opt_string::OptionString; +mod attr_name; +pub use attr_name::AttrName; +/// **Obsoleto desde la versión 0.4.0**: usar [`AttrName`] en su lugar. +#[deprecated(since = "0.4.0", note = "Use `AttrName` instead")] +pub type OptionName = AttrName; -mod opt_translated; -pub use opt_translated::OptionTranslated; +mod attr_value; +pub use attr_value::AttrValue; +/// **Obsoleto desde la versión 0.4.0**: usar [`AttrValue`] en su lugar. +#[deprecated(since = "0.4.0", note = "Use `AttrValue` instead")] +pub type OptionString = AttrValue; -mod opt_classes; -pub use opt_classes::{ClassesOp, OptionClasses}; +mod attr_l10n; +pub use attr_l10n::AttrL10n; +/// **Obsoleto desde la versión 0.4.0**: usar [`AttrL10n`] en su lugar. +#[deprecated(since = "0.4.0", note = "Use `AttrL10n` instead")] +pub type OptionTranslated = AttrL10n; -mod opt_component; -pub use opt_component::OptionComponent; +mod attr_classes; +pub use attr_classes::{AttrClasses, ClassesOp}; +/// **Obsoleto desde la versión 0.4.0**: usar [`AttrClasses`] en su lugar. +#[deprecated(since = "0.4.0", note = "Use `AttrClasses` instead")] +pub type OptionClasses = AttrClasses; -use crate::AutoDefault; +use crate::{core, AutoDefault}; + +/// **Obsoleto desde la versión 0.4.0**: usar [`TypedSlot`](crate::core::component::TypedSlot) en su +/// lugar. +#[deprecated( + since = "0.4.0", + note = "Use `pagetop::core::component::TypedSlot` instead" +)] +#[allow(type_alias_bounds)] +pub type OptionComponent = core::component::TypedSlot; /// Prepara contenido HTML para su conversión a [`Markup`]. /// -/// Este tipo encapsula distintos orígenes de contenido HTML (texto plano, HTML escapado o marcado -/// ya procesado) para renderizar de forma homogénea en plantillas sin interferir con el uso -/// estándar de [`Markup`]. +/// Este tipo encapsula distintos orígenes de contenido HTML (texto plano, HTML sin escapar o +/// fragmentos ya procesados) para renderizarlos de forma homogénea en plantillas, sin interferir +/// con el uso estándar de [`Markup`]. /// /// # Ejemplo /// /// ```rust /// use pagetop::prelude::*; /// -/// let fragment = PrepareMarkup::Text(String::from("Hola mundo")); +/// // Texto normal, se escapa automáticamente para evitar inyección de HTML. +/// let fragment = PrepareMarkup::Escaped(String::from("Hola mundo")); /// assert_eq!(fragment.render().into_string(), "Hola <b>mundo</b>"); /// -/// let raw_html = PrepareMarkup::Escaped(String::from("negrita")); +/// // HTML literal, se inserta directamente, sin escapado adicional. +/// let raw_html = PrepareMarkup::Raw(String::from("negrita")); /// assert_eq!(raw_html.render().into_string(), "negrita"); /// +/// // Fragmento ya preparado con la macro `html!`. /// let prepared = PrepareMarkup::With(html! { /// h2 { "Título de ejemplo" } /// p { "Este es un párrafo con contenido dinámico." } @@ -60,14 +90,22 @@ use crate::AutoDefault; /// ``` #[derive(AutoDefault)] pub enum PrepareMarkup { - /// No se genera contenido HTML (devuelve `html! {}`). + /// No se genera contenido HTML (equivale a `html! {}`). #[default] None, - /// Texto estático que se escapará automáticamente para no ser interpretado como HTML. - Text(String), - /// Contenido sin escapado adicional, útil para HTML generado externamente. + /// Texto plano que se **escapará automáticamente** para que no sea interpretado como HTML. + /// + /// Úsalo con textos que provengan de usuarios u otras fuentes externas para garantizar la + /// seguridad contra inyección de código. Escaped(String), + /// HTML literal que se inserta **sin escapado adicional**. + /// + /// Úsalo únicamente para contenido generado de forma confiable o controlada, ya que cualquier + /// etiqueta o script incluido será renderizado directamente en el documento. + Raw(String), /// Fragmento HTML ya preparado como [`Markup`], listo para insertarse directamente. + /// + /// Normalmente proviene de expresiones `html! { ... }`. With(Markup), } @@ -76,8 +114,8 @@ impl PrepareMarkup { pub fn is_empty(&self) -> bool { match self { PrepareMarkup::None => true, - PrepareMarkup::Text(text) => text.is_empty(), - PrepareMarkup::Escaped(string) => string.is_empty(), + PrepareMarkup::Escaped(text) => text.is_empty(), + PrepareMarkup::Raw(string) => string.is_empty(), PrepareMarkup::With(markup) => markup.is_empty(), } } @@ -88,8 +126,8 @@ impl Render for PrepareMarkup { fn render(&self) -> Markup { match self { PrepareMarkup::None => html! {}, - PrepareMarkup::Text(text) => html! { (text) }, - PrepareMarkup::Escaped(string) => html! { (PreEscaped(string)) }, + PrepareMarkup::Escaped(text) => html! { (text) }, + PrepareMarkup::Raw(string) => html! { (PreEscaped(string)) }, PrepareMarkup::With(markup) => html! { (markup) }, } } diff --git a/src/html/opt_classes.rs b/src/html/attr_classes.rs similarity index 90% rename from src/html/opt_classes.rs rename to src/html/attr_classes.rs index a985762..92851aa 100644 --- a/src/html/opt_classes.rs +++ b/src/html/attr_classes.rs @@ -1,6 +1,6 @@ use crate::{builder_fn, AutoDefault}; -/// Operaciones disponibles sobre la lista de clases en [`OptionClasses`]. +/// Operaciones disponibles sobre la lista de clases en [`AttrClasses`]. pub enum ClassesOp { /// Añade al final (si no existe). Add, @@ -33,7 +33,7 @@ pub enum ClassesOp { /// ```rust /// use pagetop::prelude::*; /// -/// let classes = OptionClasses::new("Btn btn-primary") +/// let classes = AttrClasses::new("Btn btn-primary") /// .with_value(ClassesOp::Add, "Active") /// .with_value(ClassesOp::Remove, "btn-primary"); /// @@ -41,14 +41,14 @@ pub enum ClassesOp { /// assert!(classes.contains("active")); /// ``` #[derive(AutoDefault, Clone, Debug)] -pub struct OptionClasses(Vec); +pub struct AttrClasses(Vec); -impl OptionClasses { +impl AttrClasses { pub fn new(classes: impl AsRef) -> Self { - OptionClasses::default().with_value(ClassesOp::Prepend, classes) + AttrClasses::default().with_value(ClassesOp::Prepend, classes) } - // OptionClasses BUILDER *********************************************************************** + // AttrClasses BUILDER ************************************************************************* #[builder_fn] pub fn with_value(mut self, op: ClassesOp, classes: impl AsRef) -> Self { @@ -114,7 +114,7 @@ impl OptionClasses { } } - // OptionClasses GETTERS *********************************************************************** + // AttrClasses GETTERS ************************************************************************* /// Devuele la cadena de clases, si existe. pub fn get(&self) -> Option { diff --git a/src/html/attr_id.rs b/src/html/attr_id.rs new file mode 100644 index 0000000..8bb1d33 --- /dev/null +++ b/src/html/attr_id.rs @@ -0,0 +1,63 @@ +use crate::{builder_fn, AutoDefault}; + +/// Identificador normalizado para el atributo `id` o similar de HTML. +/// +/// Este tipo encapsula `Option` garantizando un valor normalizado para su uso: +/// +/// - Se eliminan los espacios al principio y al final. +/// - Se convierte a minúsculas. +/// - Se sustituyen los espacios intermedios por guiones bajos (`_`). +/// - Si el resultado es una cadena vacía, se guarda `None`. +/// +/// # Ejemplo +/// +/// ```rust +/// use pagetop::prelude::*; +/// +/// let id = AttrId::new(" main Section "); +/// assert_eq!(id.as_str(), Some("main_section")); +/// +/// let empty = AttrId::default(); +/// assert_eq!(empty.get(), None); +/// ``` +#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)] +pub struct AttrId(Option); + +impl AttrId { + /// Crea un nuevo `AttrId` normalizando el valor. + pub fn new(value: impl AsRef) -> Self { + AttrId::default().with_value(value) + } + + // AttrId BUILDER ****************************************************************************** + + /// Establece un identificador nuevo normalizando el valor. + #[builder_fn] + pub fn with_value(mut self, value: impl AsRef) -> Self { + let value = value.as_ref().trim().to_ascii_lowercase().replace(' ', "_"); + self.0 = if value.is_empty() { None } else { Some(value) }; + self + } + + // AttrId GETTERS ****************************************************************************** + + /// Devuelve el identificador normalizado, si existe. + pub fn get(&self) -> Option { + self.0.as_ref().cloned() + } + + /// Devuelve el identificador normalizado (sin clonar), si existe. + pub fn as_str(&self) -> Option<&str> { + self.0.as_deref() + } + + /// Devuelve el identificador normalizado (propiedad), si existe. + pub fn into_inner(self) -> Option { + self.0 + } + + /// `true` si no hay valor. + pub fn is_empty(&self) -> bool { + self.0.is_none() + } +} diff --git a/src/html/opt_translated.rs b/src/html/attr_l10n.rs similarity index 79% rename from src/html/opt_translated.rs rename to src/html/attr_l10n.rs index b15ea18..cd5b389 100644 --- a/src/html/opt_translated.rs +++ b/src/html/attr_l10n.rs @@ -2,7 +2,7 @@ use crate::html::Markup; use crate::locale::{L10n, LangId}; use crate::{builder_fn, AutoDefault}; -/// Cadena para traducir al renderizar ([`locale`](crate::locale)). +/// Texto para [traducir](crate::locale) en atributos HTML. /// /// Encapsula un tipo [`L10n`] para manejar traducciones de forma segura. /// @@ -12,7 +12,7 @@ use crate::{builder_fn, AutoDefault}; /// use pagetop::prelude::*; /// /// // Traducción por clave en las locales por defecto de PageTop. -/// let hello = OptionTranslated::new(L10n::l("test-hello-world")); +/// let hello = AttrL10n::new(L10n::l("test-hello-world")); /// /// // Español disponible. /// assert_eq!( @@ -31,15 +31,15 @@ use crate::{builder_fn, AutoDefault}; /// assert_eq!(markup.into_string(), "¡Hola mundo!"); /// ``` #[derive(AutoDefault, Clone, Debug)] -pub struct OptionTranslated(L10n); +pub struct AttrL10n(L10n); -impl OptionTranslated { - /// Crea una nueva instancia [`OptionTranslated`]. +impl AttrL10n { + /// Crea una nueva instancia `AttrL10n`. pub fn new(value: L10n) -> Self { - OptionTranslated(value) + AttrL10n(value) } - // OptionTranslated BUILDER ******************************************************************** + // AttrL10n BUILDER **************************************************************************** /// Establece una traducción nueva. #[builder_fn] @@ -48,7 +48,7 @@ impl OptionTranslated { self } - // OptionTranslated GETTERS ******************************************************************** + // AttrL10n GETTERS **************************************************************************** /// Devuelve la traducción para `language`, si existe. pub fn using(&self, language: &impl LangId) -> Option { diff --git a/src/html/attr_name.rs b/src/html/attr_name.rs new file mode 100644 index 0000000..928f841 --- /dev/null +++ b/src/html/attr_name.rs @@ -0,0 +1,63 @@ +use crate::{builder_fn, AutoDefault}; + +/// Nombre normalizado para el atributo `name` o similar de HTML. +/// +/// Este tipo encapsula `Option` garantizando un valor normalizado para su uso: +/// +/// - Se eliminan los espacios al principio y al final. +/// - Se convierte a minúsculas. +/// - Se sustituyen los espacios intermedios por guiones bajos (`_`). +/// - Si el resultado es una cadena vacía, se guarda `None`. +/// +/// # Ejemplo +/// +/// ```rust +/// use pagetop::prelude::*; +/// +/// let name = AttrName::new(" DISplay name "); +/// assert_eq!(name.as_str(), Some("display_name")); +/// +/// let empty = AttrName::default(); +/// assert_eq!(empty.get(), None); +/// ``` +#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)] +pub struct AttrName(Option); + +impl AttrName { + /// Crea un nuevo `AttrName` normalizando el valor. + pub fn new(value: impl AsRef) -> Self { + AttrName::default().with_value(value) + } + + // AttrName BUILDER **************************************************************************** + + /// Establece un nombre nuevo normalizando el valor. + #[builder_fn] + pub fn with_value(mut self, value: impl AsRef) -> Self { + let value = value.as_ref().trim().to_ascii_lowercase().replace(' ', "_"); + self.0 = if value.is_empty() { None } else { Some(value) }; + self + } + + // AttrName GETTERS **************************************************************************** + + /// Devuelve el nombre normalizado, si existe. + pub fn get(&self) -> Option { + self.0.as_ref().cloned() + } + + /// Devuelve el nombre normalizado (sin clonar), si existe. + pub fn as_str(&self) -> Option<&str> { + self.0.as_deref() + } + + /// Devuelve el nombre normalizado (propiedad), si existe. + pub fn into_inner(self) -> Option { + self.0 + } + + /// `true` si no hay valor. + pub fn is_empty(&self) -> bool { + self.0.is_none() + } +} diff --git a/src/html/attr_value.rs b/src/html/attr_value.rs new file mode 100644 index 0000000..c70229f --- /dev/null +++ b/src/html/attr_value.rs @@ -0,0 +1,65 @@ +use crate::{builder_fn, AutoDefault}; + +/// Cadena normalizada para renderizar en atributos HTML. +/// +/// Este tipo encapsula `Option` garantizando un valor normalizado para su uso: +/// +/// - Se eliminan los espacios al principio y al final. +/// - Si el resultado es una cadena vacía, se guarda `None`. +/// +/// # Ejemplo +/// +/// ```rust +/// use pagetop::prelude::*; +/// +/// let s = AttrValue::new(" a new string "); +/// assert_eq!(s.as_str(), Some("a new string")); +/// +/// let empty = AttrValue::default(); +/// assert_eq!(empty.get(), None); +/// ``` +#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)] +pub struct AttrValue(Option); + +impl AttrValue { + /// Crea un nuevo `AttrValue` normalizando el valor. + pub fn new(value: impl AsRef) -> Self { + AttrValue::default().with_value(value) + } + + // AttrValue BUILDER *************************************************************************** + + /// Establece una cadena nueva normalizando el valor. + #[builder_fn] + pub fn with_value(mut self, value: impl AsRef) -> Self { + let value = value.as_ref().trim(); + self.0 = if value.is_empty() { + None + } else { + Some(value.to_owned()) + }; + self + } + + // AttrValue GETTERS *************************************************************************** + + /// Devuelve la cadena normalizada, si existe. + pub fn get(&self) -> Option { + self.0.as_ref().cloned() + } + + /// Devuelve la cadena normalizada (sin clonar), si existe. + pub fn as_str(&self) -> Option<&str> { + self.0.as_deref() + } + + /// Devuelve la cadena normalizada (propiedad), si existe. + pub fn into_inner(self) -> Option { + self.0 + } + + /// `true` si no hay valor. + pub fn is_empty(&self) -> bool { + self.0.is_none() + } +} diff --git a/src/html/opt_component.rs b/src/html/opt_component.rs deleted file mode 100644 index 39106d9..0000000 --- a/src/html/opt_component.rs +++ /dev/null @@ -1,68 +0,0 @@ -use crate::builder_fn; -use crate::core::component::{Component, Typed}; -use crate::html::{html, Context, Markup}; - -/// Contenedor de componente para incluir en otros componentes. -/// -/// Este tipo encapsula `Option>` para incluir un componente de manera segura en otros -/// componentes, útil para representar estructuras complejas. -/// -/// # Ejemplo -/// -/// ```rust,ignore -/// use pagetop::prelude::*; -/// -/// let comp = MyComponent::new(); -/// let opt = OptionComponent::new(comp); -/// assert!(opt.get().is_some()); -/// ``` -pub struct OptionComponent(Option>); - -impl Default for OptionComponent { - fn default() -> Self { - OptionComponent(None) - } -} - -impl OptionComponent { - /// Crea un nuevo [`OptionComponent`]. - /// - /// El componente se envuelve automáticamente en un [`Typed`] y se almacena. - pub fn new(component: C) -> Self { - OptionComponent::default().with_value(Some(component)) - } - - // OptionComponent BUILDER ********************************************************************* - - /// Establece un componente nuevo, o lo vacía. - /// - /// Si se proporciona `Some(component)`, se guarda en [`Typed`]; y si es `None`, se limpia. - #[builder_fn] - pub fn with_value(mut self, component: Option) -> Self { - if let Some(component) = component { - self.0 = Some(Typed::with(component)); - } else { - self.0 = None; - } - self - } - - // OptionComponent GETTERS ********************************************************************* - - /// Devuelve el componente, si existe. - pub fn get(&self) -> Option> { - if let Some(value) = &self.0 { - return Some(value.clone()); - } - None - } - - /// Renderiza el componente, si existe. - pub fn render(&self, cx: &mut Context) -> Markup { - if let Some(component) = &self.0 { - component.render(cx) - } else { - html! {} - } - } -} diff --git a/src/html/opt_id.rs b/src/html/opt_id.rs deleted file mode 100644 index 139fdcd..0000000 --- a/src/html/opt_id.rs +++ /dev/null @@ -1,59 +0,0 @@ -use crate::{builder_fn, AutoDefault}; - -/// Identificador normalizado para el atributo `id` o similar de HTML. -/// -/// Este tipo encapsula `Option` garantizando un valor normalizado para su uso. -/// -/// # Normalización -/// -/// - Se eliminan los espacios al principio y al final. -/// - Se convierte a minúsculas. -/// - Se sustituyen los espacios intermedios por guiones bajos (`_`). -/// - Si el resultado es una cadena vacía, se guarda `None`. -/// -/// # Ejemplo -/// -/// ```rust -/// use pagetop::prelude::*; -/// -/// let id = OptionId::new(" main Section "); -/// assert_eq!(id.get(), Some(String::from("main_section"))); -/// -/// let empty = OptionId::default(); -/// assert_eq!(empty.get(), None); -/// ``` -#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)] -pub struct OptionId(Option); - -impl OptionId { - /// Crea un nuevo [`OptionId`]. - /// - /// El valor se normaliza automáticamente. - pub fn new(value: impl AsRef) -> Self { - OptionId::default().with_value(value) - } - - // OptionId BUILDER **************************************************************************** - - /// Establece un identificador nuevo. - /// - /// El valor se normaliza automáticamente. - #[builder_fn] - pub fn with_value(mut self, value: impl AsRef) -> Self { - let value = value.as_ref().trim().to_ascii_lowercase().replace(' ', "_"); - self.0 = (!value.is_empty()).then_some(value); - self - } - - // OptionId GETTERS **************************************************************************** - - /// Devuelve el identificador, si existe. - pub fn get(&self) -> Option { - if let Some(value) = &self.0 { - if !value.is_empty() { - return Some(value.to_owned()); - } - } - None - } -} diff --git a/src/html/opt_name.rs b/src/html/opt_name.rs deleted file mode 100644 index ffb0b98..0000000 --- a/src/html/opt_name.rs +++ /dev/null @@ -1,59 +0,0 @@ -use crate::{builder_fn, AutoDefault}; - -/// Nombre normalizado para el atributo `name` o similar de HTML. -/// -/// Este tipo encapsula `Option` garantizando un valor normalizado para su uso. -/// -/// # Normalización -/// -/// - Se eliminan los espacios al principio y al final. -/// - Se convierte a minúsculas. -/// - Se sustituyen los espacios intermedios por guiones bajos (`_`). -/// - Si el resultado es una cadena vacía, se guarda `None`. -/// -/// # Ejemplo -/// -/// ```rust -/// use pagetop::prelude::*; -/// -/// let name = OptionName::new(" DISplay name "); -/// assert_eq!(name.get(), Some(String::from("display_name"))); -/// -/// let empty = OptionName::default(); -/// assert_eq!(empty.get(), None); -/// ``` -#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)] -pub struct OptionName(Option); - -impl OptionName { - /// Crea un nuevo [`OptionName`]. - /// - /// El valor se normaliza automáticamente. - pub fn new(value: impl AsRef) -> Self { - OptionName::default().with_value(value) - } - - // OptionName BUILDER ************************************************************************** - - /// Establece un nombre nuevo. - /// - /// El valor se normaliza automáticamente. - #[builder_fn] - pub fn with_value(mut self, value: impl AsRef) -> Self { - let value = value.as_ref().trim().to_ascii_lowercase().replace(' ', "_"); - self.0 = (!value.is_empty()).then_some(value); - self - } - - // OptionName GETTERS ************************************************************************** - - /// Devuelve el nombre, si existe. - pub fn get(&self) -> Option { - if let Some(value) = &self.0 { - if !value.is_empty() { - return Some(value.to_owned()); - } - } - None - } -} diff --git a/src/html/opt_string.rs b/src/html/opt_string.rs deleted file mode 100644 index 5bfd9c7..0000000 --- a/src/html/opt_string.rs +++ /dev/null @@ -1,57 +0,0 @@ -use crate::{builder_fn, AutoDefault}; - -/// Cadena normalizada para renderizar en atributos HTML. -/// -/// Este tipo encapsula `Option` garantizando un valor normalizado para su uso. -/// -/// # Normalización -/// -/// - Se eliminan los espacios al principio y al final. -/// - Si el resultado es una cadena vacía, se guarda `None`. -/// -/// # Ejemplo -/// -/// ```rust -/// use pagetop::prelude::*; -/// -/// let s = OptionString::new(" a new string "); -/// assert_eq!(s.get(), Some(String::from("a new string"))); -/// -/// let empty = OptionString::default(); -/// assert_eq!(empty.get(), None); -/// ``` -#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)] -pub struct OptionString(Option); - -impl OptionString { - /// Crea un nuevo [`OptionString`]. - /// - /// El valor se normaliza automáticamente. - pub fn new(value: impl AsRef) -> Self { - OptionString::default().with_value(value) - } - - // OptionString BUILDER ************************************************************************ - - /// Establece una cadena nueva. - /// - /// El valor se normaliza automáticamente. - #[builder_fn] - pub fn with_value(mut self, value: impl AsRef) -> Self { - let value = value.as_ref().trim().to_owned(); - self.0 = (!value.is_empty()).then_some(value); - self - } - - // OptionString GETTERS ************************************************************************ - - /// Devuelve la cadena, si existe. - pub fn get(&self) -> Option { - if let Some(value) = &self.0 { - if !value.is_empty() { - return Some(value.to_owned()); - } - } - None - } -} diff --git a/src/response/page.rs b/src/response/page.rs index f30e299..ea88e84 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -7,8 +7,10 @@ use crate::base::action; use crate::builder_fn; use crate::core::component::{Child, ChildOp, Component}; use crate::core::theme::{ChildrenInRegions, ThemeRef, REGION_CONTENT}; -use crate::html::{html, AssetsOp, Context, Markup, DOCTYPE}; -use crate::html::{ClassesOp, OptionClasses, OptionId, OptionTranslated}; +use crate::html::{html, Markup, DOCTYPE}; +use crate::html::{AssetsOp, Context}; +use crate::html::{AttrClasses, ClassesOp}; +use crate::html::{AttrId, AttrL10n}; use crate::locale::{CharacterDirection, L10n, LangId, LanguageIdentifier}; use crate::service::HttpRequest; @@ -19,13 +21,13 @@ use crate::service::HttpRequest; /// renderizado. #[rustfmt::skip] pub struct Page { - title : OptionTranslated, - description : OptionTranslated, + title : AttrL10n, + description : AttrL10n, metadata : Vec<(&'static str, &'static str)>, properties : Vec<(&'static str, &'static str)>, context : Context, - body_id : OptionId, - body_classes: OptionClasses, + body_id : AttrId, + body_classes: AttrClasses, regions : ChildrenInRegions, } @@ -37,13 +39,13 @@ impl Page { #[rustfmt::skip] pub fn new(request: Option) -> Self { Page { - title : OptionTranslated::default(), - description : OptionTranslated::default(), + title : AttrL10n::default(), + description : AttrL10n::default(), metadata : Vec::default(), properties : Vec::default(), context : Context::new(request), - body_id : OptionId::default(), - body_classes: OptionClasses::default(), + body_id : AttrId::default(), + body_classes: AttrClasses::default(), regions : ChildrenInRegions::default(), } } @@ -113,7 +115,7 @@ impl Page { self } - /// Modifica las clases CSS del elemento `` con una operación sobre [`OptionClasses`]. + /// Modifica las clases CSS del elemento `` con una operación sobre [`AttrClasses`]. #[builder_fn] pub fn with_body_classes(mut self, op: ClassesOp, classes: impl AsRef) -> Self { self.body_classes.alter_value(op, classes); @@ -183,12 +185,12 @@ impl Page { } /// Devuelve el identificador del elemento ``. - pub fn body_id(&self) -> &OptionId { + pub fn body_id(&self) -> &AttrId { &self.body_id } /// Devuelve las clases CSS del elemento ``. - pub fn body_classes(&self) -> &OptionClasses { + pub fn body_classes(&self) -> &AttrClasses { &self.body_classes } From 7ebd7b0e4972c4a907c34ef2ffcbe97708fd0d13 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sat, 23 Aug 2025 19:34:26 +0200 Subject: [PATCH 08/35] =?UTF-8?q?=E2=9C=85=20[tests]=20Ampl=C3=ADa=20prueb?= =?UTF-8?q?as=20para=20`PrepareMarkup'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/html.rs | 105 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 99 insertions(+), 6 deletions(-) diff --git a/tests/html.rs b/tests/html.rs index 315f74a..1499c70 100644 --- a/tests/html.rs +++ b/tests/html.rs @@ -1,17 +1,110 @@ use pagetop::prelude::*; #[pagetop::test] -async fn prepare_markup_is_empty() { - let _app = service::test::init_service(Application::new().test()).await; +async fn prepare_markup_render_none_is_empty_string() { + assert_eq!(render(&PrepareMarkup::None), ""); +} +#[pagetop::test] +async fn prepare_markup_render_escaped_escapes_html_and_ampersands() { + let pm = PrepareMarkup::Escaped(String::from("& \" ' ")); + assert_eq!(render(&pm), "<b>& " ' </b>"); +} + +#[pagetop::test] +async fn prepare_markup_render_raw_is_inserted_verbatim() { + let pm = PrepareMarkup::Raw(String::from("bold")); + assert_eq!(render(&pm), "bold"); +} + +#[pagetop::test] +async fn prepare_markup_render_with_keeps_structure() { + let pm = PrepareMarkup::With(html! { + h2 { "Sample title" } + p { "This is a paragraph." } + }); + assert_eq!( + render(&pm), + "

Sample title

This is a paragraph.

" + ); +} + +#[pagetop::test] +async fn prepare_markup_does_not_double_escape_when_wrapped_in_html_macro() { + // Escaped: dentro de `html!` no debe volver a escaparse. + let escaped = PrepareMarkup::Escaped("x".into()); + let wrapped_escaped = html! { div { (escaped) } }; + assert_eq!( + wrapped_escaped.into_string(), + "
<i>x</i>
" + ); + + // Raw: tampoco debe escaparse al integrarlo. + let raw = PrepareMarkup::Raw("x".into()); + let wrapped_raw = html! { div { (raw) } }; + assert_eq!(wrapped_raw.into_string(), "
x
"); + + // With: debe incrustar el Markup tal cual. + let with = PrepareMarkup::With(html! { span.title { "ok" } }); + let wrapped_with = html! { div { (with) } }; + assert_eq!( + wrapped_with.into_string(), + "
ok
" + ); +} + +#[pagetop::test] +async fn prepare_markup_unicode_is_preserved() { + // Texto con acentos y emojis debe conservarse (salvo el escape HTML de signos). + let esc = PrepareMarkup::Escaped("Hello, tomorrow coffee ☕ & donuts!".into()); + assert_eq!(render(&esc), "Hello, tomorrow coffee ☕ & donuts!"); + + // Raw debe pasar íntegro. + let raw = PrepareMarkup::Raw("Title — section © 2025".into()); + assert_eq!(render(&raw), "Title — section © 2025"); +} + +#[pagetop::test] +async fn prepare_markup_is_empty_semantics() { assert!(PrepareMarkup::None.is_empty()); - assert!(PrepareMarkup::Text(String::from("")).is_empty()); - assert!(!PrepareMarkup::Text(String::from("x")).is_empty()); - assert!(PrepareMarkup::Escaped(String::new()).is_empty()); - assert!(!PrepareMarkup::Escaped("a".into()).is_empty()); + assert!(PrepareMarkup::Escaped(String::from("")).is_empty()); + assert!(!PrepareMarkup::Escaped(String::from("x")).is_empty()); + + assert!(PrepareMarkup::Raw(String::new()).is_empty()); + assert!(PrepareMarkup::Raw(String::from("")).is_empty()); + assert!(!PrepareMarkup::Raw("a".into()).is_empty()); assert!(PrepareMarkup::With(html! {}).is_empty()); assert!(!PrepareMarkup::With(html! { span { "!" } }).is_empty()); + + // Ojo: espacios NO deberían considerarse vacíos (comportamiento actual). + assert!(!PrepareMarkup::Escaped(" ".into()).is_empty()); + assert!(!PrepareMarkup::Raw(" ".into()).is_empty()); +} + +#[pagetop::test] +async fn prepare_markup_equivalence_between_render_and_inline_in_html_macro() { + let cases = [ + PrepareMarkup::None, + PrepareMarkup::Escaped("x".into()), + PrepareMarkup::Raw("x".into()), + PrepareMarkup::With(html! { b { "x" } }), + ]; + + for pm in cases { + let rendered = render(&pm); + let in_macro = html! { (pm) }.into_string(); + assert_eq!( + rendered, in_macro, + "The output of Render and (pm) inside html! must match" + ); + } +} + +// HELPERS ***************************************************************************************** + +fn render(x: &impl Render) -> String { + x.render().into_string() } From 282b903eaf343ca0cd2c92950d11eadb98fc7a88 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 24 Aug 2025 10:04:51 +0200 Subject: [PATCH 09/35] =?UTF-8?q?=F0=9F=93=9D=20[component]=20Ampl=C3=ADa?= =?UTF-8?q?=20documentaci=C3=B3n=20de=20preparaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Añade un párrafo explicando la mejor manera de que `prepare_component()` pueda ser útil a los programadores que sobrescriban su comportamiento. --- src/core/component/definition.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/core/component/definition.rs b/src/core/component/definition.rs index 2818570..e9a792d 100644 --- a/src/core/component/definition.rs +++ b/src/core/component/definition.rs @@ -51,12 +51,17 @@ pub trait Component: AnyInfo + ComponentRender + Send + Sync { #[allow(unused_variables)] fn setup_before_prepare(&mut self, cx: &mut Context) {} - /// Devuelve una representación estructurada del componente lista para renderizar. + /// Devuelve una representación estructurada del componente preparada para el renderizado. /// /// Este método forma parte del ciclo de vida de los componentes y se invoca automáticamente /// durante el proceso de construcción del documento. Puede sobrescribirse para generar /// dinámicamente el contenido HTML con acceso al contexto de renderizado. /// + /// Este método debe ser capaz de preparar el renderizado del componente con los métodos del + /// propio componente y el contexto proporcionado, no debería hacerlo accediendo directamente a + /// los campos de la estructura del componente. Es una forma de garantizar que los programadores + /// podrán sobrescribir este método sin preocuparse por los detalles internos del componente. + /// /// Por defecto, devuelve [`PrepareMarkup::None`]. #[allow(unused_variables)] fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { From 0496b9dc5d9ba8c37c4d9baba8987e194b100852 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 24 Aug 2025 10:05:46 +0200 Subject: [PATCH 10/35] =?UTF-8?q?=F0=9F=92=A1=20Correcci=C3=B3n=20menor=20?= =?UTF-8?q?en=20comentario?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/html/attr_classes.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/html/attr_classes.rs b/src/html/attr_classes.rs index 92851aa..91ccfaf 100644 --- a/src/html/attr_classes.rs +++ b/src/html/attr_classes.rs @@ -116,7 +116,7 @@ impl AttrClasses { // AttrClasses GETTERS ************************************************************************* - /// Devuele la cadena de clases, si existe. + /// Devuelve la cadena de clases, si existe. pub fn get(&self) -> Option { if self.0.is_empty() { None From c6c8c66a978b8329ae2208fba782788100487392 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 24 Aug 2025 10:09:22 +0200 Subject: [PATCH 11/35] =?UTF-8?q?=F0=9F=9A=A7=20[html]=20Implementa=20`Def?= =?UTF-8?q?ault`=20en=20`Context`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/html/context.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/html/context.rs b/src/html/context.rs index 72c0ff2..9678786 100644 --- a/src/html/context.rs +++ b/src/html/context.rs @@ -117,6 +117,12 @@ pub struct Context { id_counter : usize, // Contador para generar identificadores únicos. } +impl Default for Context { + fn default() -> Self { + Context::new(None) + } +} + impl Context { /// Crea un nuevo contexto asociado a una solicitud HTTP. /// From 0c1b12aacd24da6083f50c39ade4516d7a0c1ade Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 24 Aug 2025 10:16:02 +0200 Subject: [PATCH 12/35] =?UTF-8?q?=F0=9F=9A=A7=20Aplica=20recomendaciones?= =?UTF-8?q?=20en=20componente=20Html?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/base/component/html.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/base/component/html.rs b/src/base/component/html.rs index 8f273ed..cac39ea 100644 --- a/src/base/component/html.rs +++ b/src/base/component/html.rs @@ -44,11 +44,13 @@ impl Component for Html { } fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { - PrepareMarkup::With((self.0)(cx)) + PrepareMarkup::With(self.html(cx)) } } impl Html { + // Html BUILDER ******************************************************************************** + /// Crea una instancia que generará el `Markup`, con acceso opcional al contexto. /// /// El método [`prepare_component()`](crate::core::component::Component::prepare_component) @@ -66,11 +68,24 @@ impl Html { /// Permite a otras extensiones modificar la función de renderizado que se ejecutará cuando /// [`prepare_component()`](crate::core::component::Component::prepare_component) invoque esta /// instancia. La nueva función también recibe una referencia al contexto ([`Context`]). - pub fn alter_html(&mut self, f: F) -> &mut Self + #[builder_fn] + pub fn with_fn(mut self, f: F) -> Self where F: Fn(&mut Context) -> Markup + Send + Sync + 'static, { self.0 = Box::new(f); self } + + // Html GETTERS ******************************************************************************** + + /// Aplica la función interna de renderizado con el [`Context`] proporcionado. + /// + /// Normalmente no se invoca manualmente, ya que el proceso de renderizado de los componentes lo + /// invoca automáticamente durante la construcción de la página. Puede usarse, no obstante, para + /// sobrescribir [`prepare_component()`](crate::core::component::Component::prepare_component) + /// y alterar el comportamiento del componente. + pub fn html(&self, cx: &mut Context) -> Markup { + (self.0)(cx) + } } From 4311e9f33504dc4aa0d53bd4b0165c8711cf08dd Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 24 Aug 2025 10:19:17 +0200 Subject: [PATCH 13/35] =?UTF-8?q?=F0=9F=92=84=20A=C3=B1ade=20componente=20?= =?UTF-8?q?`PoweredBy`=20para=20copyright?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapta la página de bienvenida al tratamiento revisado de regiones y añade en el pie el componente `PoweredBy` para la nota de copyright. --- src/base/component.rs | 3 + src/base/component/poweredby.rs | 69 ++++++++++++++++++++++ src/base/extension/welcome.rs | 49 ++++++++-------- src/base/theme/basic.rs | 5 ++ src/core/theme/definition.rs | 6 +- src/locale/en-US/base.ftl | 2 + src/locale/es-ES/base.ftl | 2 + static/css/basic.css | 11 ++++ static/css/welcome.css | 54 ++++++++++------- tests/component_poweredby.rs | 100 ++++++++++++++++++++++++++++++++ 10 files changed, 251 insertions(+), 50 deletions(-) create mode 100644 src/base/component/poweredby.rs create mode 100644 src/locale/en-US/base.ftl create mode 100644 src/locale/es-ES/base.ftl create mode 100644 static/css/basic.css create mode 100644 tests/component_poweredby.rs diff --git a/src/base/component.rs b/src/base/component.rs index 27f0f73..1bb160b 100644 --- a/src/base/component.rs +++ b/src/base/component.rs @@ -2,3 +2,6 @@ mod html; pub use html::Html; + +mod poweredby; +pub use poweredby::PoweredBy; diff --git a/src/base/component/poweredby.rs b/src/base/component/poweredby.rs new file mode 100644 index 0000000..5a37464 --- /dev/null +++ b/src/base/component/poweredby.rs @@ -0,0 +1,69 @@ +use crate::prelude::*; + +/// Muestra un texto con información de copyright, típica en un pie de página. +/// +/// Por defecto, usando [`default()`](Self::default) sólo se muestra un +/// reconocimiento a PageTop. Sin embargo, se puede usar [`new()`](Self::new) +/// para crear una instancia con un texto de copyright predeterminado. +#[derive(AutoDefault)] +pub struct PoweredBy { + copyright: Option, +} + +impl Component for PoweredBy { + /// Crea una nueva instancia de `PoweredBy`. + /// + /// El copyright se genera automáticamente con el año actual y el nombre de + /// la aplicación configurada en [`global::SETTINGS`]. + fn new() -> Self { + let year = Utc::now().format("%Y").to_string(); + let c = join!(year, " © ", global::SETTINGS.app.name); + PoweredBy { copyright: Some(c) } + } + + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { + let poweredby_pagetop = L10n::l("poweredby_pagetop") + .with_arg( + "pagetop_link", + "PageTop", + ) + .to_markup(cx); + + PrepareMarkup::With(html! { + div id=[self.id()] class="poweredby" { + @if let Some(c) = self.copyright() { + span class="poweredby__copyright" { (c) "." } " " + } + span class="poweredby__pagetop" { (poweredby_pagetop) } + } + }) + } +} + +impl PoweredBy { + // PoweredBy BUILDER *************************************************************************** + + /// Establece el texto de copyright que mostrará el componente. + /// + /// Al pasar `Some(valor)` se sobrescribe el texto de copyright por defecto. Al pasar `None` se + /// eliminará, pero en este caso es necesario especificar el tipo explícitamente: + /// + /// ```rust + /// use pagetop::prelude::*; + /// + /// let p1 = PoweredBy::default().with_copyright(Some("2001 © Foo Inc.")); + /// let p2 = PoweredBy::new().with_copyright(None::); + /// ``` + #[builder_fn] + pub fn with_copyright(mut self, copyright: Option>) -> Self { + self.copyright = copyright.map(Into::into); + self + } + + // PoweredBy GETTERS *************************************************************************** + + /// Devuelve el texto de copyright actual, si existe. + pub fn copyright(&self) -> Option<&str> { + self.copyright.as_deref() + } +} diff --git a/src/base/extension/welcome.rs b/src/base/extension/welcome.rs index 3dda43e..5f413d3 100644 --- a/src/base/extension/welcome.rs +++ b/src/base/extension/welcome.rs @@ -28,6 +28,7 @@ async fn homepage(request: HttpRequest) -> ResultPage { .with_title(L10n::l("welcome_page")) .with_theme("Basic") .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/welcome.css"))) + .with_body_classes(ClassesOp::Add, "welcome") .with_component(Html::with(move |cx| html! { div id="main-header" { header { @@ -58,7 +59,8 @@ async fn homepage(request: HttpRequest) -> ResultPage { } } } - + })) + .with_component(Html::with(move |cx| html! { main id="main-content" { section class="content-body" { div id="poweredby-button" { @@ -85,32 +87,31 @@ async fn homepage(request: HttpRequest) -> ResultPage { } } } - - footer id="footer" { - section class="footer-inner" { - div class="footer-logo" { - svg - viewBox="0 0 1614 1614" - xmlns="http://www.w3.org/2000/svg" - role="img" - aria-label=[L10n::l("pagetop_logo").using(cx)] - preserveAspectRatio="xMidYMid slice" - focusable="false" - { - path fill="rgb(255,255,255)" d="M 1573,357 L 1415,357 C 1400,357 1388,369 1388,383 L 1388,410 1335,410 1335,357 C 1335,167 1181,13 992,13 L 621,13 C 432,13 278,167 278,357 L 278,410 225,410 225,383 C 225,369 213,357 198,357 L 40,357 C 25,357 13,369 13,383 L 13,648 C 13,662 25,674 40,674 L 198,674 C 213,674 225,662 225,648 L 225,621 278,621 278,1256 C 278,1446 432,1600 621,1600 L 992,1600 C 1181,1600 1335,1446 1335,1256 L 1335,621 1388,621 1388,648 C 1388,662 1400,674 1415,674 L 1573,674 C 1588,674 1600,662 1600,648 L 1600,383 C 1600,369 1588,357 1573,357 L 1573,357 1573,357 Z M 66,410 L 172,410 172,621 66,621 66,410 66,410 Z M 1282,357 L 1282,488 C 1247,485 1213,477 1181,464 L 1196,437 C 1203,425 1199,409 1186,401 1174,394 1158,398 1150,411 L 1133,440 C 1105,423 1079,401 1056,376 L 1075,361 C 1087,352 1089,335 1079,324 1070,313 1054,311 1042,320 L 1023,335 C 1000,301 981,263 967,221 L 1011,196 C 1023,189 1028,172 1021,160 1013,147 997,143 984,150 L 953,168 C 945,136 941,102 940,66 L 992,66 C 1152,66 1282,197 1282,357 L 1282,357 1282,357 Z M 621,66 L 674,66 674,225 648,225 C 633,225 621,237 621,251 621,266 633,278 648,278 L 674,278 674,357 648,357 C 633,357 621,369 621,383 621,398 633,410 648,410 L 674,410 674,489 648,489 C 633,489 621,501 621,516 621,530 633,542 648,542 L 664,542 C 651,582 626,623 600,662 583,653 563,648 542,648 469,648 410,707 410,780 410,787 411,794 412,801 388,805 361,806 331,806 L 331,357 C 331,197 461,66 621,66 L 621,66 621,66 Z M 621,780 C 621,824 586,859 542,859 498,859 463,824 463,780 463,736 498,701 542,701 586,701 621,736 621,780 L 621,780 621,780 Z M 225,463 L 278,463 278,569 225,569 225,463 225,463 Z M 992,1547 L 621,1547 C 461,1547 331,1416 331,1256 L 331,859 C 367,859 400,858 431,851 454,888 495,912 542,912 615,912 674,853 674,780 674,747 662,718 642,695 675,645 706,594 720,542 L 780,542 C 795,542 807,530 807,516 807,501 795,489 780,489 L 727,489 727,410 780,410 C 795,410 807,398 807,383 807,369 795,357 780,357 L 727,357 727,278 780,278 C 795,278 807,266 807,251 807,237 795,225 780,225 L 727,225 727,66 887,66 C 889,111 895,155 905,196 L 869,217 C 856,224 852,240 859,253 864,261 873,266 882,266 887,266 891,265 895,263 L 921,248 C 937,291 958,331 983,367 L 938,403 C 926,412 925,429 934,440 939,447 947,450 954,450 960,450 966,448 971,444 L 1016,408 C 1043,438 1074,465 1108,485 L 1084,527 C 1076,539 1081,555 1093,563 1098,565 1102,566 1107,566 1116,566 1125,561 1129,553 L 1155,509 C 1194,527 1237,538 1282,541 L 1282,1256 C 1282,1416 1152,1547 992,1547 L 992,1547 992,1547 Z M 1335,463 L 1388,463 1388,569 1335,569 1335,463 1335,463 Z M 1441,410 L 1547,410 1547,621 1441,621 1441,410 1441,410 Z" {} - path fill="rgb(255,255,255)" d="M 1150,1018 L 463,1018 C 448,1018 436,1030 436,1044 L 436,1177 C 436,1348 545,1468 701,1468 L 912,1468 C 1068,1468 1177,1348 1177,1177 L 1177,1044 C 1177,1030 1165,1018 1150,1018 L 1150,1018 1150,1018 Z M 912,1071 L 1018,1071 1018,1124 912,1124 912,1071 912,1071 Z M 489,1071 L 542,1071 542,1124 489,1124 489,1071 489,1071 Z M 701,1415 L 700,1415 C 701,1385 704,1352 718,1343 731,1335 759,1341 795,1359 802,1363 811,1363 818,1359 854,1341 882,1335 895,1343 909,1352 912,1385 913,1415 L 912,1415 701,1415 701,1415 701,1415 Z M 1124,1177 C 1124,1296 1061,1384 966,1408 964,1365 958,1320 922,1298 894,1281 856,1283 807,1306 757,1283 719,1281 691,1298 655,1320 649,1365 647,1408 552,1384 489,1296 489,1177 L 569,1177 C 583,1177 595,1165 595,1150 L 595,1071 859,1071 859,1150 C 859,1165 871,1177 886,1177 L 1044,1177 C 1059,1177 1071,1165 1071,1150 L 1071,1071 1124,1071 1124,1177 1124,1177 1124,1177 Z" {} - path fill="rgb(255,255,255)" d="M 1071,648 C 998,648 939,707 939,780 939,853 998,912 1071,912 1144,912 1203,853 1203,780 1203,707 1144,648 1071,648 L 1071,648 1071,648 Z M 1071,859 C 1027,859 992,824 992,780 992,736 1027,701 1071,701 1115,701 1150,736 1150,780 1150,824 1115,859 1071,859 L 1071,859 1071,859 Z" {} - } - } - div class="footer-links" { - a href="https://crates.io/crates/pagetop" target="_blank" rel="noreferrer" { ("Crates.io") } - a href="https://docs.rs/pagetop" target="_blank" rel="noreferrer" { ("Docs.rs") } - a href="https://git.cillero.es/manuelcillero/pagetop" target="_blank" rel="noreferrer" { (L10n::l("welcome_code").to_markup(cx)) } - em { (L10n::l("welcome_have_fun").to_markup(cx)) } + })) + .with_component_in("footer", Html::with(move |cx| html! { + section class="welcome-footer" { + div class="welcome-footer__logo" { + svg + viewBox="0 0 1614 1614" + xmlns="http://www.w3.org/2000/svg" + role="img" + aria-label=[L10n::l("pagetop_logo").using(cx)] + preserveAspectRatio="xMidYMid slice" + focusable="false" + { + path fill="rgb(255,255,255)" d="M 1573,357 L 1415,357 C 1400,357 1388,369 1388,383 L 1388,410 1335,410 1335,357 C 1335,167 1181,13 992,13 L 621,13 C 432,13 278,167 278,357 L 278,410 225,410 225,383 C 225,369 213,357 198,357 L 40,357 C 25,357 13,369 13,383 L 13,648 C 13,662 25,674 40,674 L 198,674 C 213,674 225,662 225,648 L 225,621 278,621 278,1256 C 278,1446 432,1600 621,1600 L 992,1600 C 1181,1600 1335,1446 1335,1256 L 1335,621 1388,621 1388,648 C 1388,662 1400,674 1415,674 L 1573,674 C 1588,674 1600,662 1600,648 L 1600,383 C 1600,369 1588,357 1573,357 L 1573,357 1573,357 Z M 66,410 L 172,410 172,621 66,621 66,410 66,410 Z M 1282,357 L 1282,488 C 1247,485 1213,477 1181,464 L 1196,437 C 1203,425 1199,409 1186,401 1174,394 1158,398 1150,411 L 1133,440 C 1105,423 1079,401 1056,376 L 1075,361 C 1087,352 1089,335 1079,324 1070,313 1054,311 1042,320 L 1023,335 C 1000,301 981,263 967,221 L 1011,196 C 1023,189 1028,172 1021,160 1013,147 997,143 984,150 L 953,168 C 945,136 941,102 940,66 L 992,66 C 1152,66 1282,197 1282,357 L 1282,357 1282,357 Z M 621,66 L 674,66 674,225 648,225 C 633,225 621,237 621,251 621,266 633,278 648,278 L 674,278 674,357 648,357 C 633,357 621,369 621,383 621,398 633,410 648,410 L 674,410 674,489 648,489 C 633,489 621,501 621,516 621,530 633,542 648,542 L 664,542 C 651,582 626,623 600,662 583,653 563,648 542,648 469,648 410,707 410,780 410,787 411,794 412,801 388,805 361,806 331,806 L 331,357 C 331,197 461,66 621,66 L 621,66 621,66 Z M 621,780 C 621,824 586,859 542,859 498,859 463,824 463,780 463,736 498,701 542,701 586,701 621,736 621,780 L 621,780 621,780 Z M 225,463 L 278,463 278,569 225,569 225,463 225,463 Z M 992,1547 L 621,1547 C 461,1547 331,1416 331,1256 L 331,859 C 367,859 400,858 431,851 454,888 495,912 542,912 615,912 674,853 674,780 674,747 662,718 642,695 675,645 706,594 720,542 L 780,542 C 795,542 807,530 807,516 807,501 795,489 780,489 L 727,489 727,410 780,410 C 795,410 807,398 807,383 807,369 795,357 780,357 L 727,357 727,278 780,278 C 795,278 807,266 807,251 807,237 795,225 780,225 L 727,225 727,66 887,66 C 889,111 895,155 905,196 L 869,217 C 856,224 852,240 859,253 864,261 873,266 882,266 887,266 891,265 895,263 L 921,248 C 937,291 958,331 983,367 L 938,403 C 926,412 925,429 934,440 939,447 947,450 954,450 960,450 966,448 971,444 L 1016,408 C 1043,438 1074,465 1108,485 L 1084,527 C 1076,539 1081,555 1093,563 1098,565 1102,566 1107,566 1116,566 1125,561 1129,553 L 1155,509 C 1194,527 1237,538 1282,541 L 1282,1256 C 1282,1416 1152,1547 992,1547 L 992,1547 992,1547 Z M 1335,463 L 1388,463 1388,569 1335,569 1335,463 1335,463 Z M 1441,410 L 1547,410 1547,621 1441,621 1441,410 1441,410 Z" {} + path fill="rgb(255,255,255)" d="M 1150,1018 L 463,1018 C 448,1018 436,1030 436,1044 L 436,1177 C 436,1348 545,1468 701,1468 L 912,1468 C 1068,1468 1177,1348 1177,1177 L 1177,1044 C 1177,1030 1165,1018 1150,1018 L 1150,1018 1150,1018 Z M 912,1071 L 1018,1071 1018,1124 912,1124 912,1071 912,1071 Z M 489,1071 L 542,1071 542,1124 489,1124 489,1071 489,1071 Z M 701,1415 L 700,1415 C 701,1385 704,1352 718,1343 731,1335 759,1341 795,1359 802,1363 811,1363 818,1359 854,1341 882,1335 895,1343 909,1352 912,1385 913,1415 L 912,1415 701,1415 701,1415 701,1415 Z M 1124,1177 C 1124,1296 1061,1384 966,1408 964,1365 958,1320 922,1298 894,1281 856,1283 807,1306 757,1283 719,1281 691,1298 655,1320 649,1365 647,1408 552,1384 489,1296 489,1177 L 569,1177 C 583,1177 595,1165 595,1150 L 595,1071 859,1071 859,1150 C 859,1165 871,1177 886,1177 L 1044,1177 C 1059,1177 1071,1165 1071,1150 L 1071,1071 1124,1071 1124,1177 1124,1177 1124,1177 Z" {} + path fill="rgb(255,255,255)" d="M 1071,648 C 998,648 939,707 939,780 939,853 998,912 1071,912 1144,912 1203,853 1203,780 1203,707 1144,648 1071,648 L 1071,648 1071,648 Z M 1071,859 C 1027,859 992,824 992,780 992,736 1027,701 1071,701 1115,701 1150,736 1150,780 1150,824 1115,859 1071,859 L 1071,859 1071,859 Z" {} } } + div class="welcome-footer__links" { + a href="https://crates.io/crates/pagetop" target="_blank" rel="noreferrer" { ("Crates.io") } + a href="https://docs.rs/pagetop" target="_blank" rel="noreferrer" { ("Docs.rs") } + a href="https://git.cillero.es/manuelcillero/pagetop" target="_blank" rel="noreferrer" { (L10n::l("welcome_code").to_markup(cx)) } + em { (L10n::l("welcome_have_fun").to_markup(cx)) } + } } - })) + .with_component_in("footer", PoweredBy::new()) .render() } diff --git a/src/base/theme/basic.rs b/src/base/theme/basic.rs index b02abfb..bc380ac 100644 --- a/src/base/theme/basic.rs +++ b/src/base/theme/basic.rs @@ -17,6 +17,11 @@ impl Theme for Basic { StyleSheet::from("/css/normalize.css") .with_version("8.0.1") .with_weight(-99), + )) + .alter_assets(AssetsOp::AddStyleSheet( + StyleSheet::from("/css/basic.css") + .with_version(env!("CARGO_PKG_VERSION")) + .with_weight(-99), )); } } diff --git a/src/core/theme/definition.rs b/src/core/theme/definition.rs index 8d1b632..3b26a57 100644 --- a/src/core/theme/definition.rs +++ b/src/core/theme/definition.rs @@ -94,13 +94,11 @@ pub trait Theme: Extension + Send + Sync { @let region_name = region.name(); div id=(region_name) - class="region" + class={ "region region--" (region_name) } role="region" aria-label=[region_label.using(page)] { - div class={ "region__" (region_name) } { - (output) - } + (output) } } } diff --git a/src/locale/en-US/base.ftl b/src/locale/en-US/base.ftl new file mode 100644 index 0000000..b2c9256 --- /dev/null +++ b/src/locale/en-US/base.ftl @@ -0,0 +1,2 @@ +# PoweredBy component. +poweredby_pagetop = Powered by { $pagetop_link } diff --git a/src/locale/es-ES/base.ftl b/src/locale/es-ES/base.ftl new file mode 100644 index 0000000..74eb62e --- /dev/null +++ b/src/locale/es-ES/base.ftl @@ -0,0 +1,2 @@ +# PoweredBy component. +poweredby_pagetop = Funciona con { $pagetop_link } \ No newline at end of file diff --git a/static/css/basic.css b/static/css/basic.css new file mode 100644 index 0000000..312ddf0 --- /dev/null +++ b/static/css/basic.css @@ -0,0 +1,11 @@ +/* Page layout */ + +.region--footer { + padding-bottom: 2rem; +} + +/* PoweredBy component */ + +.poweredby { + text-align: center; +} diff --git a/static/css/welcome.css b/static/css/welcome.css index 76b042b..7630f3e 100644 --- a/static/css/welcome.css +++ b/static/css/welcome.css @@ -410,51 +410,61 @@ a:hover:visited { transform: rotate(2deg); } -#footer { - width: 100%; +/* + * Region footer + */ + +.region--footer { background-color: black; color: var(--color-gray); +} + +.welcome-footer { font-size: 1.15rem; font-weight: 300; line-height: 100%; - display: flex; justify-content: center; + display: flex; + flex-direction: column; + max-width: 80rem; + padding: 0 10.625rem 2rem; +/* z-index: 10; +*/ } -#footer a:visited { +.welcome-footer a:visited { color: var(--color-gray); } -.footer-logo { - max-height: 12.625rem; -} -.footer-logo svg { - width: 100%; -} -.footer-logo, -.footer-links, -.footer-inner { +.welcome-footer__logo, +.welcome-footer__links { display: flex; justify-content: center; width: 100%; } -.footer-links { +.welcome-footer__logo { + max-height: 12.625rem; +} +.welcome-footer__logo svg { + width: 100%; +} +.welcome-footer__links { gap: 1.875rem; flex-wrap: wrap; margin-top: 2rem; } -.footer-inner { - max-width: 80rem; - display: flex; - flex-direction: column; - padding: 0 10.625rem 2rem; -} @media (max-width: 48rem) { - .footer-logo { + .welcome-footer__logo { display: none; } } @media (max-width: 64rem) { - .footer-inner { + .welcome-footer { padding: 0 1rem 2rem; } } + +/* PoweredBy component */ + +.poweredby a:visited { + color: var(--color-gray); +} diff --git a/tests/component_poweredby.rs b/tests/component_poweredby.rs new file mode 100644 index 0000000..b2e4418 --- /dev/null +++ b/tests/component_poweredby.rs @@ -0,0 +1,100 @@ +use pagetop::prelude::*; + +#[pagetop::test] +async fn poweredby_default_shows_only_pagetop_recognition() { + let _app = service::test::init_service(Application::new().test()).await; + + let p = PoweredBy::default(); + let html = render_component(&p); + + // Debe mostrar el bloque de reconocimiento a PageTop. + assert!(html.contains("poweredby__pagetop")); + + // Y NO debe mostrar el bloque de copyright. + assert!(!html.contains("poweredby__copyright")); +} + +#[pagetop::test] +async fn poweredby_new_includes_current_year_and_app_name() { + let _app = service::test::init_service(Application::new().test()).await; + + let p = PoweredBy::new(); + let html = render_component(&p); + + let year = Utc::now().format("%Y").to_string(); + assert!(html.contains(&year), "HTML should include the current year"); + + // El nombre de la app proviene de `global::SETTINGS.app.name`. + let app_name = &global::SETTINGS.app.name; + assert!( + html.contains(app_name), + "HTML should include the application name" + ); + + // Debe existir el span de copyright. + assert!(html.contains("poweredby__copyright")); +} + +#[pagetop::test] +async fn poweredby_with_copyright_overrides_text() { + let _app = service::test::init_service(Application::new().test()).await; + + let custom = "2001 © FooBar Inc."; + let p = PoweredBy::default().with_copyright(Some(custom)); + let html = render_component(&p); + + assert!(html.contains(custom)); + assert!(html.contains("poweredby__copyright")); +} + +#[pagetop::test] +async fn poweredby_with_copyright_none_hides_text() { + let _app = service::test::init_service(Application::new().test()).await; + + let p = PoweredBy::new().with_copyright(None::); + let html = render_component(&p); + + assert!(!html.contains("poweredby__copyright")); + // El reconocimiento a PageTop siempre debe aparecer. + assert!(html.contains("poweredby__pagetop")); +} + +#[pagetop::test] +async fn poweredby_link_points_to_crates_io() { + let _app = service::test::init_service(Application::new().test()).await; + + let p = PoweredBy::default(); + let html = render_component(&p); + + assert!( + html.contains("https://crates.io/crates/pagetop"), + "Link should point to crates.io/pagetop" + ); +} + +#[pagetop::test] +async fn poweredby_getter_reflects_internal_state() { + let _app = service::test::init_service(Application::new().test()).await; + + // Por defecto no hay copyright. + let p0 = PoweredBy::default(); + assert_eq!(p0.copyright(), None); + + // Y `new()` lo inicializa con año + nombre de app. + let p1 = PoweredBy::new(); + let c1 = p1.copyright().expect("Expected copyright to exis"); + assert!(c1.contains(&Utc::now().format("%Y").to_string())); + assert!(c1.contains(&global::SETTINGS.app.name)); +} + +// HELPERS ***************************************************************************************** + +fn render(x: &impl Render) -> String { + x.render().into_string() +} + +fn render_component(c: &C) -> String { + let mut cx = Context::default(); + let pm = c.prepare_component(&mut cx); + render(&pm) +} From d4d55146d3da7bfa32e04f9d3eaab214909279ca Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Mon, 25 Aug 2025 07:12:30 +0200 Subject: [PATCH 14/35] =?UTF-8?q?=F0=9F=90=9B=20[welcome]=20Corrige=20cent?= =?UTF-8?q?rado=20del=20pie=20de=20p=C3=A1gina?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/css/welcome.css | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/static/css/welcome.css b/static/css/welcome.css index 7630f3e..906c05d 100644 --- a/static/css/welcome.css +++ b/static/css/welcome.css @@ -420,17 +420,15 @@ a:hover:visited { } .welcome-footer { + display: flex; + justify-content: center; + flex-direction: column; + margin: 0 auto; + padding: 0 10.625rem 2rem; + max-width: 80rem; font-size: 1.15rem; font-weight: 300; line-height: 100%; - justify-content: center; - display: flex; - flex-direction: column; - max-width: 80rem; - padding: 0 10.625rem 2rem; -/* - z-index: 10; -*/ } .welcome-footer a:visited { color: var(--color-gray); From c2a8a580576433da7d1f4882a8f898c3823e92f4 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Mon, 25 Aug 2025 23:25:39 +0200 Subject: [PATCH 15/35] =?UTF-8?q?=F0=9F=90=9B=20Corrige=20nombre=20de=20fu?= =?UTF-8?q?nci=C3=B3n=20en=20prueba=20de=20`Html`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/component_html.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/component_html.rs b/tests/component_html.rs index 978248f..b9b8e5e 100644 --- a/tests/component_html.rs +++ b/tests/component_html.rs @@ -35,7 +35,7 @@ async fn component_html_renders_using_context_param() { async fn component_html_allows_replacing_render_function() { let mut component = Html::with(|_| html! { div { "Original" } }); - component.alter_html(|_| html! { div { "Modified" } }); + component.alter_fn(|_| html! { div { "Modified" } }); let markup = component .prepare_component(&mut Context::new(None)) From d43b699a32ee20cc31d24bd6aba2b3a6b9d88dc5 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Mon, 25 Aug 2025 23:30:53 +0200 Subject: [PATCH 16/35] =?UTF-8?q?=F0=9F=92=84=20Aplica=20BEM=20a=20estilos?= =?UTF-8?q?=20de=20bienvenida=20y=20componente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/base/component/poweredby.rs | 26 ++++---- src/base/extension/welcome.rs | 30 +++++---- static/css/welcome.css | 111 +++++++++++++++++--------------- 3 files changed, 88 insertions(+), 79 deletions(-) diff --git a/src/base/component/poweredby.rs b/src/base/component/poweredby.rs index 5a37464..afa8db7 100644 --- a/src/base/component/poweredby.rs +++ b/src/base/component/poweredby.rs @@ -1,10 +1,13 @@ use crate::prelude::*; -/// Muestra un texto con información de copyright, típica en un pie de página. +// Enlace a la página oficial de PageTop. +const LINK: &str = "PageTop"; + +/// Componente que renderiza la sección 'Powered by' (*Funciona con*) típica del pie de página. /// -/// Por defecto, usando [`default()`](Self::default) sólo se muestra un -/// reconocimiento a PageTop. Sin embargo, se puede usar [`new()`](Self::new) -/// para crear una instancia con un texto de copyright predeterminado. +/// Por defecto, usando [`default()`](Self::default) sólo se muestra un reconocimiento a PageTop. +/// Sin embargo, se puede usar [`new()`](Self::new) para crear una instancia con un texto de +/// copyright predeterminado. #[derive(AutoDefault)] pub struct PoweredBy { copyright: Option, @@ -13,8 +16,8 @@ pub struct PoweredBy { impl Component for PoweredBy { /// Crea una nueva instancia de `PoweredBy`. /// - /// El copyright se genera automáticamente con el año actual y el nombre de - /// la aplicación configurada en [`global::SETTINGS`]. + /// El copyright se genera automáticamente con el año actual y el nombre de la aplicación + /// configurada en [`global::SETTINGS`]. fn new() -> Self { let year = Utc::now().format("%Y").to_string(); let c = join!(year, " © ", global::SETTINGS.app.name); @@ -22,19 +25,14 @@ impl Component for PoweredBy { } fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { - let poweredby_pagetop = L10n::l("poweredby_pagetop") - .with_arg( - "pagetop_link", - "PageTop", - ) - .to_markup(cx); - PrepareMarkup::With(html! { div id=[self.id()] class="poweredby" { @if let Some(c) = self.copyright() { span class="poweredby__copyright" { (c) "." } " " } - span class="poweredby__pagetop" { (poweredby_pagetop) } + span class="poweredby__pagetop" { + (L10n::l("poweredby_pagetop").with_arg("pagetop_link", LINK).to_markup(cx)) + } } }) } diff --git a/src/base/extension/welcome.rs b/src/base/extension/welcome.rs index 5f413d3..f768210 100644 --- a/src/base/extension/welcome.rs +++ b/src/base/extension/welcome.rs @@ -29,19 +29,19 @@ async fn homepage(request: HttpRequest) -> ResultPage { .with_theme("Basic") .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/welcome.css"))) .with_body_classes(ClassesOp::Add, "welcome") - .with_component(Html::with(move |cx| html! { - div id="main-header" { - header { + .with_component_in("header", Html::with(move |cx| html! { + div class="welcome-header" { + header class="welcome-header__body" { h1 - id="header-title" + class="welcome-header__title" aria-label=(L10n::l("welcome_aria").with_arg("app", app).to_markup(cx)) { span { (L10n::l("welcome_title").to_markup(cx)) } (L10n::l("welcome_intro").with_arg("app", app).to_markup(cx)) } } - aside id="header-image" aria-hidden="true" { - div id="monster" { + aside class="welcome-header__image" aria-hidden="true" { + div class="welcome-header__monster" { picture { source type="image/avif" @@ -61,25 +61,27 @@ async fn homepage(request: HttpRequest) -> ResultPage { } })) .with_component(Html::with(move |cx| html! { - main id="main-content" { - section class="content-body" { - div id="poweredby-button" { + main class="welcome-content" { + section class="welcome-content__body" { + div class="welcome-poweredby" { a - id="poweredby-link" + class="welcome-poweredby__link" href="https://pagetop.cillero.es" target="_blank" rel="noreferrer" { span {} span {} span {} - div id="poweredby-text" { (L10n::l("welcome_powered").to_markup(cx)) } + div class="welcome-poweredby__text" { + (L10n::l("welcome_powered").to_markup(cx)) + } } } - div class="content-text" { + div class="welcome-text" { p { (L10n::l("welcome_text1").to_markup(cx)) } p { (L10n::l("welcome_text2").to_markup(cx)) } - div class="subcontent" { - h1 { span { (L10n::l("welcome_about").to_markup(cx)) } } + div class="welcome-text__block" { + h2 { span { (L10n::l("welcome_about").to_markup(cx)) } } p { (L10n::l("welcome_pagetop").to_markup(cx)) } p { (L10n::l("welcome_issues1").to_markup(cx)) } p { (L10n::l("welcome_issues2").with_arg("app", app).to_markup(cx)) } diff --git a/static/css/welcome.css b/static/css/welcome.css index 906c05d..4ce8046 100644 --- a/static/css/welcome.css +++ b/static/css/welcome.css @@ -58,12 +58,17 @@ a:hover:visited { align-items: center; } -#main-header { +/* + * Region header + */ + +.welcome-header { display: flex; flex-direction: column-reverse; - padding-bottom: 9rem; - max-width: 80rem; width: 100%; + max-width: 80rem; + margin: 0 auto; + padding-bottom: 9rem; background-image: var(--bg-img-sm); background-image: var(--bg-img-sm-set); background-position: top center; @@ -71,11 +76,11 @@ a:hover:visited { background-size: contain; background-repeat: no-repeat; } -#main-header header { +.welcome-header__body { padding: 0; background: none; } -#header-title { +.welcome-header__title { margin: 0 0 0 1.5rem; text-align: left; display: flex; @@ -89,7 +94,7 @@ a:hover:visited { line-height: 110%; text-shadow: 0 0.125rem 0.1875rem rgba(0, 0, 0, 0.3); } -#header-title > span { +.welcome-header__title > span { background: linear-gradient(180deg, #ddff95 30%, #ffb84b 100%); background-clip: text; -webkit-background-clip: text; @@ -100,40 +105,44 @@ a:hover:visited { line-height: 110%; text-shadow: none; } -#header-image { - width: 100%; - text-align: right; +.welcome-header__image { display: flex; justify-content: flex-start; + text-align: right; + width: 100%; } -#header-image #monster { +.welcome-header__monster { margin-right: 12rem; margin-top: 1rem; flex-shrink: 1; } @media (min-width: 64rem) { - #main-header { + .welcome-header { background-image: var(--bg-img); background-image: var(--bg-img-set); } - #header-title { + .welcome-header__title { padding: 1.2rem 2rem 2.6rem 2rem; } - #header-image { + .welcome-header__image { justify-content: flex-end; } } -#main-content { +/* + * Region content + */ + +.welcome-content { height: auto; margin-top: 1.6rem; } -.content-body { +.welcome-content__body { box-sizing: border-box; max-width: 80rem; } -.content-body:before, -.content-body:after { +.welcome-content__body:before, +.welcome-content__body:after { content: ''; position: absolute; left: 0; @@ -143,38 +152,38 @@ a:hover:visited { filter: blur(2.75rem); opacity: 0.8; inset: 11.75rem; - z-index: 0; + /*z-index: 0;*/ } -.content-body:before { +.welcome-content__body:before { top: -1rem; } -.content-body:after { +.welcome-content__body:after { bottom: -1rem; } @media (max-width: 48rem) { - .content-body { + .welcome-content__body { margin-top: -9.8rem; } - .content-body:before, - .content-body:after { + .welcome-content__body:before, + .welcome-content__body:after { inset: unset; } } @media (min-width: 64rem) { - #main-content { + .welcome-content { margin-top: 0; } - .content-body { + .welcome-content__body { margin-top: -5.7rem; } } -#poweredby-button { +.welcome-poweredby { width: 100%; margin: 0 auto 3rem; z-index: 10; } -#poweredby-link { +.welcome-poweredby__link { background: #7f1d1d; background-image: linear-gradient(to bottom, rgba(255,0,0,0.8), rgba(255,255,255,0)); background-position: top left, center; @@ -187,7 +196,7 @@ a:hover:visited { font-size: 1.5rem; line-height: 1.3; text-decoration: none; - text-shadow: var(--shadow); + /*text-shadow: var(--shadow);*/ transition: transform 0.3s ease-in-out; position: relative; overflow: hidden; @@ -195,7 +204,7 @@ a:hover:visited { min-height: 7.6875rem; outline: none; } -#poweredby-link::before { +.welcome-poweredby__link::before { content: ''; position: absolute; top: -13.125rem; @@ -207,7 +216,7 @@ a:hover:visited { transition: transform 0.3s ease-in-out; z-index: 5; } -#poweredby-text { +.welcome-poweredby__text { display: flex; flex-direction: column; flex: 1; @@ -217,25 +226,25 @@ a:hover:visited { padding: 1rem 1.5rem; text-align: left; color: white; - text-shadow: 0 0.101125rem 0.2021875rem rgba(0, 0, 0, 0.25); + /*text-shadow: 0 0.101125rem 0.2021875rem rgba(0, 0, 0, 0.25);*/ font-size: 1.65rem; font-style: normal; font-weight: 600; line-height: 130.023%; letter-spacing: 0.0075rem; } -#poweredby-text strong { +.welcome-poweredby__text strong { font-size: 2.625rem; font-weight: 600; line-height: 130.023%; letter-spacing: 0.013125rem; } -#poweredby-link span { +.welcome-poweredby__link span { position: absolute; display: block; pointer-events: none; } -#poweredby-link span:nth-child(1) { +.welcome-poweredby__link span:nth-child(1) { height: 8px; width: 100%; top: 0; @@ -255,7 +264,7 @@ a:hover:visited { transform: translateX(100%); } } -#poweredby-link span:nth-child(2) { +.welcome-poweredby__link span:nth-child(2) { width: 8px; height: 100%; top: 0; @@ -275,7 +284,7 @@ a:hover:visited { transform: translateY(100%); } } -#poweredby-link span:nth-child(3) { +.welcome-poweredby__link span:nth-child(3) { height: 8px; width: 100%; bottom: 0; @@ -295,22 +304,22 @@ a:hover:visited { transform: translateX(-100%); } } -#poweredby-link:hover span { +.welcome-poweredby__link:hover span { animation-play-state: paused; } @media (max-width: 48rem) { - #poweredby-link { + .welcome-poweredby__link { height: 6.25rem; min-width: auto; border-radius: 0; } - #poweredby-text { + .welcome-poweredby__text { display: inline; padding-top: .5rem; } } @media (min-width: 48rem) { - #poweredby-button { + .welcome-poweredby { position: absolute; top: 0; left: 50%; @@ -318,14 +327,14 @@ a:hover:visited { max-width: 29.375rem; margin-bottom: 0; } - #poweredby-link:hover { + .welcome-poweredby__link:hover { transition: all .5s; transform: rotate(-3deg) scale(1.1); - box-shadow: 0px 3px 5px rgba(0,0,0,.4); + /*box-shadow: 0px 3px 5px rgba(0,0,0,.4);*/ } } -.content-text { +.welcome-text { z-index: 1; width: 100%; display: flex; @@ -343,7 +352,7 @@ a:hover:visited { padding: 6rem 1.063rem 0.75rem; overflow: hidden; } -.content-text p { +.welcome-text p { width: 100%; line-height: 150%; font-weight: 400; @@ -351,14 +360,14 @@ a:hover:visited { margin: 0 0 1.5rem; } @media (min-width: 48rem) { - .content-text { + .welcome-text { font-size: 1.375rem; line-height: 2rem; padding-top: 7rem; } } @media (min-width: 64rem) { - .content-text { + .welcome-text { border-radius: 0.75rem; box-shadow: var(--shadow); max-width: 60rem; @@ -368,13 +377,13 @@ a:hover:visited { } } -.subcontent { +.welcome-text__block { position: relative; } -.subcontent h1 { +.welcome-text__block h2 { margin: 1em 0 .8em; } -.subcontent h1 span { +.welcome-text__block h2 span { display: inline-block; padding: 10px 30px 14px; margin: 0 0 0 20px; @@ -385,7 +394,7 @@ a:hover:visited { border-color: orangered; transform: rotate(-3deg) translateY(-25%); } -.subcontent h1:before { +.welcome-text__block h2:before { content: ""; height: 5px; position: absolute; @@ -398,7 +407,7 @@ a:hover:visited { transform: rotate(2deg) translateY(-50%); transform-origin: top left; } -.subcontent h1:after { +.welcome-text__block h2:after { content: ""; height: 70rem; position: absolute; From d7fcd6ccc4c391b8891b640169a51e9f5d3d49a7 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Wed, 3 Sep 2025 21:05:35 +0200 Subject: [PATCH 17/35] =?UTF-8?q?=F0=9F=93=9D=20[doc]=20Normaliza=20refere?= =?UTF-8?q?ncias=20al=20nombre=20PageTop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CREDITS.md | 13 ++++++------- README.md | 24 ++++++++++++------------ helpers/pagetop-build/README.md | 2 +- helpers/pagetop-macros/README.md | 4 ++-- helpers/pagetop-macros/src/lib.rs | 6 +++--- helpers/pagetop-statics/README.md | 6 +++--- helpers/pagetop-statics/src/lib.rs | 4 ++-- src/app.rs | 4 ++-- src/base/action.rs | 2 +- src/base/component.rs | 2 +- src/base/extension.rs | 2 +- src/base/extension/welcome.rs | 2 +- src/base/theme.rs | 2 +- src/base/theme/basic.rs | 2 +- src/config.rs | 6 +++--- src/core.rs | 2 +- src/core/component/definition.rs | 2 +- src/core/extension.rs | 2 +- src/core/extension/definition.rs | 4 ++-- src/core/theme.rs | 2 +- src/global.rs | 2 +- src/lib.rs | 18 +++++++++--------- src/locale.rs | 14 +++++++------- src/prelude.rs | 2 +- src/trace.rs | 4 ++-- 25 files changed, 66 insertions(+), 67 deletions(-) diff --git a/CREDITS.md b/CREDITS.md index f5c1b0f..c5a7bd2 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -1,8 +1,7 @@ # 🔃 Dependencias -`PageTop` está basado en [Rust](https://www.rust-lang.org/) y crece a hombros de gigantes -aprovechando algunas de las librerías más robustas y populares del [ecosistema Rust](https://lib.rs) -como son: +PageTop está basado en [Rust](https://www.rust-lang.org/) y crece a hombros de gigantes aprovechando +algunas de las librerías más robustas y populares del [ecosistema Rust](https://lib.rs) como son: * [Actix Web](https://actix.rs/) para los servicios web. * [Config](https://docs.rs/config) para cargar y procesar las opciones de configuración. @@ -11,14 +10,14 @@ como son: * [Fluent templates](https://github.com/XAMPPRocky/fluent-templates), que integra [Fluent](https://projectfluent.org/) para internacionalizar las aplicaciones. * Además de otros *crates* adicionales que se pueden explorar en los archivos `Cargo.toml` de - `PageTop` y sus extensiones. + PageTop y sus extensiones. # 🗚 FIGfonts -`PageTop` usa el *crate* [figlet-rs](https://crates.io/crates/figlet-rs) desarrollado por -*yuanbohan* para mostrar un banner de presentación en el terminal con el nombre de la aplicación en -caracteres [FIGlet](http://www.figlet.org). Las fuentes incluidas en `pagetop/src/app` son: +PageTop usa el *crate* [figlet-rs](https://crates.io/crates/figlet-rs) desarrollado por *yuanbohan* +para mostrar un banner de presentación en el terminal con el nombre de la aplicación en caracteres +[FIGlet](http://www.figlet.org). Las fuentes incluidas en `pagetop/src/app` son: * [slant.flf](http://www.figlet.org/fontdb_example.cgi?font=slant.flf) de *Glenn Chappell* * [small.flf](http://www.figlet.org/fontdb_example.cgi?font=small.flf) de *Glenn Chappell* diff --git a/README.md b/README.md index e7fab94..9d5efc8 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@
-`PageTop` reivindica la esencia de la web clásica usando [Rust](https://www.rust-lang.org/es) para -la creación de soluciones web SSR (*renderizadas en el servidor*) basadas en HTML, CSS y JavaScript. +PageTop reivindica la esencia de la web clásica usando [Rust](https://www.rust-lang.org/es) para la +creación de soluciones web SSR (*renderizadas en el servidor*) basadas en HTML, CSS y JavaScript. Ofrece un conjunto de herramientas que los desarrolladores pueden implementar, extender o adaptar según las necesidades de cada proyecto, incluyendo: @@ -24,14 +24,14 @@ según las necesidades de cada proyecto, incluyendo: * **Componentes** (*components*): encapsulan HTML, CSS y JavaScript en unidades funcionales, configurables y reutilizables. * **Extensiones** (*extensions*): añaden, extienden o personalizan funcionalidades usando las APIs - de `PageTop` o de terceros. + de PageTop o de terceros. * **Temas** (*themes*): son extensiones que permiten modificar la apariencia de páginas y componentes sin comprometer su funcionalidad. # ⚡️ Guía rápida -La aplicación más sencilla de `PageTop` se ve así: +La aplicación más sencilla de PageTop se ve así: ```rust,no_run use pagetop::prelude::*; @@ -42,10 +42,10 @@ async fn main() -> std::io::Result<()> { } ``` -Este código arranca el servidor de `PageTop`. Con la configuración por defecto, muestra una página -de bienvenida accesible desde un navegador local en la dirección `http://localhost:8080`. +Este código arranca el servidor de PageTop. Con la configuración por defecto, muestra una página de +bienvenida accesible desde un navegador local en la dirección `http://localhost:8080`. -Para personalizar el servicio, se puede crear una extensión de `PageTop` de la siguiente manera: +Para personalizar el servicio, se puede crear una extensión de PageTop de la siguiente manera: ```rust,no_run use pagetop::prelude::*; @@ -86,15 +86,15 @@ El código se organiza en un *workspace* donde actualmente se incluyen los sigui * **[pagetop-statics](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-statics)**, es la librería que permite incluir archivos estáticos en el ejecutable de las aplicaciones - `PageTop` para servirlos de forma eficiente, con detección de cambios que optimizan el tiempo - de compilación. + PageTop para servirlos de forma eficiente, con detección de cambios que optimizan el tiempo de + compilación. * **[pagetop-build](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-build)**, prepara los archivos estáticos o archivos SCSS compilados para incluirlos en el binario de las - aplicaciones `PageTop` durante la compilación de los ejecutables. + aplicaciones PageTop durante la compilación de los ejecutables. * **[pagetop-macros](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-macros)**, - proporciona una colección de macros que mejoran la experiencia de desarrollo con `PageTop`. + proporciona una colección de macros que mejoran la experiencia de desarrollo con PageTop. # 🧪 Pruebas @@ -116,7 +116,7 @@ Para simplificar el flujo de trabajo, el repositorio incluye varios **alias de C # 🚧 Advertencia -`PageTop` es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su +**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos hasta que se libere la versión **1.0.0**. diff --git a/helpers/pagetop-build/README.md b/helpers/pagetop-build/README.md index 80d6bba..57273e8 100644 --- a/helpers/pagetop-build/README.md +++ b/helpers/pagetop-build/README.md @@ -113,7 +113,7 @@ impl Extension for MyExtension { # 🚧 Advertencia -`PageTop` es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su +**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos hasta que se libere la versión **1.0.0**. diff --git a/helpers/pagetop-macros/README.md b/helpers/pagetop-macros/README.md index e58d24c..7c9c2e8 100644 --- a/helpers/pagetop-macros/README.md +++ b/helpers/pagetop-macros/README.md @@ -26,12 +26,12 @@ Esta librería incluye entre sus macros una adaptación de [SmartDefault](https://crates.io/crates/smart_default) (0.7.1) de [Jane Doe](https://crates.io/users/jane-doe), llamada `AutoDefault`. Estas macros eliminan la necesidad de referenciar `maud` o `smart_default` en las dependencias del archivo `Cargo.toml` de -cada proyecto `PageTop`. +cada proyecto PageTop. # 🚧 Advertencia -`PageTop` es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su +**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos hasta que se libere la versión **1.0.0**. diff --git a/helpers/pagetop-macros/src/lib.rs b/helpers/pagetop-macros/src/lib.rs index 6421ca6..b462ea1 100644 --- a/helpers/pagetop-macros/src/lib.rs +++ b/helpers/pagetop-macros/src/lib.rs @@ -27,7 +27,7 @@ Esta librería incluye entre sus macros una adaptación de [SmartDefault](https://crates.io/crates/smart_default) (0.7.1) de [Jane Doe](https://crates.io/users/jane-doe), llamada `AutoDefault`. Estas macros eliminan la necesidad de referenciar `maud` o `smart_default` en las dependencias del archivo `Cargo.toml` de -cada proyecto `PageTop`. +cada proyecto PageTop. */ #![doc( @@ -219,7 +219,7 @@ pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream { expanded.into() } -/// Define una función `main` asíncrona como punto de entrada de `PageTop`. +/// Define una función `main` asíncrona como punto de entrada de PageTop. /// /// # Ejemplo /// @@ -240,7 +240,7 @@ pub fn main(_: TokenStream, item: TokenStream) -> TokenStream { output } -/// Define funciones de prueba asíncronas para usar con `PageTop`. +/// Define funciones de prueba asíncronas para usar con PageTop. /// /// # Ejemplo /// diff --git a/helpers/pagetop-statics/README.md b/helpers/pagetop-statics/README.md index 92999c0..4168cd4 100644 --- a/helpers/pagetop-statics/README.md +++ b/helpers/pagetop-statics/README.md @@ -16,7 +16,7 @@ configurables, basadas en HTML, CSS y JavaScript. ## Descripción general -Esta librería permite incluir archivos estáticos en el ejecutable de las aplicaciones `PageTop` para +Esta librería permite incluir archivos estáticos en el ejecutable de las aplicaciones PageTop para servirlos de forma eficiente vía web, con detección de cambios que optimizan el tiempo de compilación. @@ -28,13 +28,13 @@ Para ello, adapta el código de los *crates* [static-files](https://crates.io/cr [4.0.1](https://github.com/kilork/actix-web-static-files/tree/v4.0.1)), desarrollados ambos por [Alexander Korolev](https://crates.io/users/kilork). -Estas implementaciones se integran en `PageTop` para evitar que cada proyecto tenga que declarar +Estas implementaciones se integran en PageTop para evitar que cada proyecto tenga que declarar `static-files` manualmente como dependencia en su `Cargo.toml`. # 🚧 Advertencia -`PageTop` es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su +**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos hasta que se libere la versión **1.0.0**. diff --git a/helpers/pagetop-statics/src/lib.rs b/helpers/pagetop-statics/src/lib.rs index dab50d9..201d90e 100644 --- a/helpers/pagetop-statics/src/lib.rs +++ b/helpers/pagetop-statics/src/lib.rs @@ -17,7 +17,7 @@ configurables, basadas en HTML, CSS y JavaScript. ## Descripción general -Esta librería permite incluir archivos estáticos en el ejecutable de las aplicaciones `PageTop` para +Esta librería permite incluir archivos estáticos en el ejecutable de las aplicaciones PageTop para servirlos de forma eficiente vía web, con detección de cambios que optimizan el tiempo de compilación. @@ -29,7 +29,7 @@ Para ello, adapta el código de los *crates* [static-files](https://crates.io/cr [4.0.1](https://github.com/kilork/actix-web-static-files/tree/v4.0.1)), desarrollados ambos por [Alexander Korolev](https://crates.io/users/kilork). -Estas implementaciones se integran en `PageTop` para evitar que cada proyecto tenga que declarar +Estas implementaciones se integran en PageTop para evitar que cada proyecto tenga que declarar `static-files` manualmente como dependencia en su `Cargo.toml`. */ diff --git a/src/app.rs b/src/app.rs index 400b0cd..94d901f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,4 @@ -//! Prepara y ejecuta una aplicación creada con `Pagetop`. +//! Prepara y ejecuta una aplicación creada con PageTop. mod figfont; @@ -17,7 +17,7 @@ use substring::Substring; use std::io::Error; use std::sync::LazyLock; -/// Punto de entrada de una aplicación `PageTop`. +/// Punto de entrada de una aplicación PageTop. /// /// No almacena datos, **encapsula** el inicio completo de configuración y puesta en marcha. Para /// instanciarla se puede usar [`new()`](Application::new) o [`prepare()`](Application::prepare). diff --git a/src/base/action.rs b/src/base/action.rs index be35e92..977ae9e 100644 --- a/src/base/action.rs +++ b/src/base/action.rs @@ -1,4 +1,4 @@ -//! Acciones predefinidas para alterar el funcionamiento interno de `PageTop`. +//! Acciones predefinidas para alterar el funcionamiento interno de PageTop. use crate::prelude::*; diff --git a/src/base/component.rs b/src/base/component.rs index 1bb160b..30cb686 100644 --- a/src/base/component.rs +++ b/src/base/component.rs @@ -1,4 +1,4 @@ -//! Componentes nativos proporcionados por `PageTop`. +//! Componentes nativos proporcionados por PageTop. mod html; pub use html::Html; diff --git a/src/base/extension.rs b/src/base/extension.rs index 49e408d..1f94fe2 100644 --- a/src/base/extension.rs +++ b/src/base/extension.rs @@ -1,4 +1,4 @@ -//! Extensiones para funcionalidades avanzadas de `PageTop`. +//! Extensiones para funcionalidades avanzadas de PageTop. mod welcome; pub use welcome::Welcome; diff --git a/src/base/extension/welcome.rs b/src/base/extension/welcome.rs index f768210..0252cff 100644 --- a/src/base/extension/welcome.rs +++ b/src/base/extension/welcome.rs @@ -1,6 +1,6 @@ use crate::prelude::*; -/// Página de bienvenida predeterminada de `PageTop`. +/// Página de bienvenida predeterminada de PageTop. /// /// Esta extensión se instala por defecto y muestra una página en la ruta raíz (`/`) cuando no se ha /// configurado ninguna página de inicio personalizada. Permite confirmar que el servidor está diff --git a/src/base/theme.rs b/src/base/theme.rs index ea9eeb6..40129bf 100644 --- a/src/base/theme.rs +++ b/src/base/theme.rs @@ -1,4 +1,4 @@ -//! Temas básicos soportados por `PageTop`. +//! Temas básicos soportados por PageTop. mod basic; pub use basic::Basic; diff --git a/src/base/theme/basic.rs b/src/base/theme/basic.rs index bc380ac..961864b 100644 --- a/src/base/theme/basic.rs +++ b/src/base/theme/basic.rs @@ -1,4 +1,4 @@ -//! Es el tema básico que incluye `PageTop` por defecto. +//! Es el tema básico que incluye PageTop por defecto. use crate::prelude::*; diff --git a/src/config.rs b/src/config.rs index 27cf630..f2fb9f7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,7 +3,7 @@ //! Estos ajustes se obtienen de archivos [TOML](https://toml.io) como pares `clave = valor` que se //! mapean a estructuras **fuertemente tipadas** y valores predefinidos. //! -//! Siguiendo la metodología [Twelve-Factor App](https://12factor.net/config), `PageTop` separa el +//! Siguiendo la metodología [Twelve-Factor App](https://12factor.net/config), PageTop separa el //! **código** de la **configuración**, lo que permite tener configuraciones diferentes para cada //! despliegue, como *dev*, *staging* o *production*, sin modificar el código fuente. //! @@ -13,14 +13,14 @@ //! Si tu aplicación necesita archivos de configuración, crea un directorio `config` en la raíz del //! proyecto, al mismo nivel que el archivo *Cargo.toml* o que el binario de la aplicación. //! -//! `PageTop` carga en este orden, y siempre de forma opcional, los siguientes archivos TOML: +//! PageTop carga en este orden, y siempre de forma opcional, los siguientes archivos TOML: //! //! 1. **config/common.toml**, para ajustes comunes a todos los entornos. Este enfoque simplifica el //! mantenimiento al centralizar los valores de configuración comunes. //! //! 2. **config/{rm}.toml**, donde `{rm}` es el valor de la variable de entorno `PAGETOP_RUN_MODE`: //! -//! * Si `PAGETOP_RUN_MODE` no está definida, se asume el valor `default`, y `PageTop` intentará +//! * Si `PAGETOP_RUN_MODE` no está definida, se asume el valor `default`, y PageTop intentará //! cargar *config/default.toml* si el archivo existe. //! //! * Útil para definir configuraciones específicas por entorno, garantizando que cada uno (p.ej. diff --git a/src/core.rs b/src/core.rs index 0c8aa21..79d9207 100644 --- a/src/core.rs +++ b/src/core.rs @@ -117,7 +117,7 @@ impl TypeInfo { /// /// Este *trait* se implementa automáticamente para **todos** los tipos que implementen [`Any`], de /// modo que basta con traer [`AnyInfo`] al ámbito (`use crate::AnyInfo;`) para disponer de estos -/// métodos adicionales, o usar el [`prelude`](crate::prelude) de `PageTop`. +/// métodos adicionales, o usar el [`prelude`](crate::prelude) de PageTop. /// /// # Ejemplo /// diff --git a/src/core/component/definition.rs b/src/core/component/definition.rs index e9a792d..c43dfb0 100644 --- a/src/core/component/definition.rs +++ b/src/core/component/definition.rs @@ -11,7 +11,7 @@ pub trait ComponentRender { fn render(&mut self, cx: &mut Context) -> Markup; } -/// Interfaz común que debe implementar un componente renderizable en `PageTop`. +/// Interfaz común que debe implementar un componente renderizable en PageTop. /// /// Se recomienda que los componentes deriven [`AutoDefault`](crate::AutoDefault). También deben /// implementar explícitamente el método [`new()`](Self::new) y pueden sobrescribir los otros diff --git a/src/core/extension.rs b/src/core/extension.rs index cabae5c..6ae6d33 100644 --- a/src/core/extension.rs +++ b/src/core/extension.rs @@ -1,6 +1,6 @@ //! API para añadir nuevas funcionalidades usando extensiones. //! -//! Cada funcionalidad adicional que quiera incorporarse a una aplicación `PageTop` se debe modelar +//! Cada funcionalidad adicional que quiera incorporarse a una aplicación PageTop se debe modelar //! como una **extensión**. Todas comparten la misma interfaz declarada en [`Extension`]. mod definition; diff --git a/src/core/extension/definition.rs b/src/core/extension/definition.rs index 6df7042..90bdbad 100644 --- a/src/core/extension/definition.rs +++ b/src/core/extension/definition.rs @@ -10,7 +10,7 @@ use crate::{actions_boxed, service}; /// cualquier hilo de la ejecución sin necesidad de sincronización adicional. pub type ExtensionRef = &'static dyn Extension; -/// Interfaz común que debe implementar cualquier extensión de `PageTop`. +/// Interfaz común que debe implementar cualquier extensión de PageTop. /// /// Este *trait* es fácil de implementar, basta con declarar una estructura de tamaño cero para la /// extensión y sobreescribir los métodos que sea necesario. @@ -63,7 +63,7 @@ pub trait Extension: AnyInfo + Send + Sync { /// Otras extensiones que deben habilitarse **antes** de esta. /// - /// `PageTop` las resolverá automáticamente respetando el orden durante el arranque de la + /// PageTop las resolverá automáticamente respetando el orden durante el arranque de la /// aplicación. fn dependencies(&self) -> Vec { vec![] diff --git a/src/core/theme.rs b/src/core/theme.rs index aa526f1..e0c3008 100644 --- a/src/core/theme.rs +++ b/src/core/theme.rs @@ -1,6 +1,6 @@ //! API para añadir y gestionar nuevos temas. //! -//! En `PageTop` un tema es la *piel* de la aplicación, decide cómo se muestra cada documento HTML, +//! En PageTop un tema es la *piel* de la aplicación, decide cómo se muestra cada documento HTML, //! especialmente las páginas de contenido ([`Page`](crate::response::page::Page)), sin alterar la //! lógica interna de sus componentes. //! diff --git a/src/global.rs b/src/global.rs index 8a03589..6be0774 100644 --- a/src/global.rs +++ b/src/global.rs @@ -68,7 +68,7 @@ pub struct App { #[derive(Debug, Deserialize)] /// Sección `[Dev]` de la configuración. Forma parte de [`Settings`]. pub struct Dev { - /// Directorio desde el que servir los archivos estáticos de `PageTop`. + /// Directorio desde el que servir los archivos estáticos de PageTop. /// /// Por defecto, los archivos se integran en el binario de la aplicación. Si aquí se indica una /// ruta válida, ya sea absoluta o relativa al directorio del proyecto o del binario en diff --git a/src/lib.rs b/src/lib.rs index 90ea462..e43da2f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,8 +15,8 @@
-`PageTop` reivindica la esencia de la web clásica usando [Rust](https://www.rust-lang.org/es) para -la creación de soluciones web SSR (*renderizadas en el servidor*) basadas en HTML, CSS y JavaScript. +PageTop reivindica la esencia de la web clásica usando [Rust](https://www.rust-lang.org/es) para la +creación de soluciones web SSR (*renderizadas en el servidor*) basadas en HTML, CSS y JavaScript. Ofrece un conjunto de herramientas que los desarrolladores pueden implementar, extender o adaptar según las necesidades de cada proyecto, incluyendo: @@ -25,14 +25,14 @@ según las necesidades de cada proyecto, incluyendo: * **Componentes** (*components*): encapsulan HTML, CSS y JavaScript en unidades funcionales, configurables y reutilizables. * **Extensiones** (*extensions*): añaden, extienden o personalizan funcionalidades usando las APIs - de `PageTop` o de terceros. + de PageTop o de terceros. * **Temas** (*themes*): son extensiones que permiten modificar la apariencia de páginas y componentes sin comprometer su funcionalidad. # ⚡️ Guía rápida -La aplicación más sencilla de `PageTop` se ve así: +La aplicación más sencilla de PageTop se ve así: ```rust,no_run use pagetop::prelude::*; @@ -43,10 +43,10 @@ async fn main() -> std::io::Result<()> { } ``` -Este código arranca el servidor de `PageTop`. Con la configuración por defecto, muestra una página -de bienvenida accesible desde un navegador local en la dirección `http://localhost:8080`. +Este código arranca el servidor de PageTop. Con la configuración por defecto, muestra una página de +bienvenida accesible desde un navegador local en la dirección `http://localhost:8080`. -Para personalizar el servicio, se puede crear una extensión de `PageTop` de la siguiente manera: +Para personalizar el servicio, se puede crear una extensión de PageTop de la siguiente manera: ```rust,no_run use pagetop::prelude::*; @@ -77,11 +77,11 @@ Este programa implementa una extensión llamada `HelloWorld` que sirve una pági # 🧩 Gestión de Dependencias -Los proyectos que utilizan `PageTop` gestionan las dependencias con `cargo`, como cualquier otro +Los proyectos que utilizan PageTop gestionan las dependencias con `cargo`, como cualquier otro proyecto en Rust. Sin embargo, es fundamental que cada extensión declare explícitamente sus -[dependencias](core::extension::Extension::dependencies), si las tiene, para que `PageTop` pueda +[dependencias](core::extension::Extension::dependencies), si las tiene, para que PageTop pueda estructurar e inicializar la aplicación de forma modular. */ diff --git a/src/locale.rs b/src/locale.rs index f23f51e..43612bd 100644 --- a/src/locale.rs +++ b/src/locale.rs @@ -1,6 +1,6 @@ //! Localización (L10n). //! -//! `PageTop` utiliza las especificaciones de [Fluent](https://www.projectfluent.org/) para la +//! PageTop utiliza las especificaciones de [Fluent](https://www.projectfluent.org/) para la //! localización de aplicaciones, y aprovecha [fluent-templates](https://docs.rs/fluent-templates/) //! para integrar los recursos de traducción directamente en el binario de la aplicación. //! @@ -87,7 +87,7 @@ //! include_locales!(LOCALES_SAMPLE from "ruta/a/las/traducciones"); //! ``` //! -//! Y *voilà*, sólo queda operar con los idiomas soportados por `PageTop` usando [`LangMatch`] y +//! Y *voilà*, sólo queda operar con los idiomas soportados por PageTop usando [`LangMatch`] y //! traducir textos con [`L10n`]. use crate::html::{Markup, PreEscaped}; @@ -141,10 +141,10 @@ pub trait LangId { fn langid(&self) -> &'static LanguageIdentifier; } -/// Operaciones con los idiomas soportados por `PageTop`. +/// Operaciones con los idiomas soportados por PageTop. /// /// Utiliza [`LangMatch`] para transformar un identificador de idioma en un [`LanguageIdentifier`] -/// soportado por `PageTop`. +/// soportado por PageTop. /// /// # Ejemplos /// @@ -183,11 +183,11 @@ pub trait LangId { pub enum LangMatch { /// Cuando el identificador de idioma es una cadena vacía. Unspecified, - /// Si encuentra un [`LanguageIdentifier`] en la lista de idiomas soportados por `PageTop` que + /// Si encuentra un [`LanguageIdentifier`] en la lista de idiomas soportados por PageTop que /// coincide exactamente con el identificador de idioma (p.ej. "es-ES"), o con el identificador /// del idioma base (p.ej. "es"). Found(&'static LanguageIdentifier), - /// Si el identificador de idioma no está entre los soportados por `PageTop`. + /// Si el identificador de idioma no está entre los soportados por PageTop. Unsupported(String), } @@ -319,7 +319,7 @@ enum L10nOp { /// Cada instancia puede representar: /// /// - Un texto puro (`n()`) que no requiere traducción. -/// - Una clave para traducir un texto de las traducciones predefinidas de `PageTop` (`l()`). +/// - Una clave para traducir un texto de las traducciones predefinidas de PageTop (`l()`). /// - Una clave para traducir de un conjunto concreto de traducciones (`t()`). /// /// # Ejemplo diff --git a/src/prelude.rs b/src/prelude.rs index 9072dec..484e53c 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -1,4 +1,4 @@ -//! *Prelude* de `PageTop`. +//! *Prelude* de PageTop. // RE-EXPORTED. diff --git a/src/trace.rs b/src/trace.rs index c57c6a3..12e428a 100644 --- a/src/trace.rs +++ b/src/trace.rs @@ -1,13 +1,13 @@ //! Gestión de trazas y registro de eventos de la aplicación. //! -//! `PageTop` recopila información de diagnóstico de la aplicación de forma estructurada y basada en +//! PageTop recopila información de diagnóstico de la aplicación de forma estructurada y basada en //! eventos. //! //! En los sistemas asíncronos, interpretar los mensajes de log tradicionales suele volverse //! complicado. Las tareas individuales se multiplexan en el mismo hilo y los eventos y registros //! asociados se entremezclan, lo que dificulta seguir la secuencia lógica. //! -//! `PageTop` usa [`tracing`](https://docs.rs/tracing) para registrar eventos estructurados y con +//! PageTop usa [`tracing`](https://docs.rs/tracing) para registrar eventos estructurados y con //! información adicional sobre la *temporalidad* y la *causalidad*. A diferencia de un mensaje de //! log, un *span* (intervalo) tiene un momento de inicio y de fin, puede entrar y salir del flujo //! de ejecución y puede existir dentro de un árbol anidado de *spans* similares. Además, estos From 71c7793131f94adcb7da64821667bd0a24165283 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Wed, 3 Sep 2025 21:12:19 +0200 Subject: [PATCH 18/35] =?UTF-8?q?=F0=9F=93=9D=20[doc]=20Simplifica=20docum?= =?UTF-8?q?entaci=C3=B3n=20de=20obsoletos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/service.rs | 67 -------------------------------------------------- src/util.rs | 2 -- 2 files changed, 69 deletions(-) diff --git a/src/service.rs b/src/service.rs index 288e1eb..09dc618 100644 --- a/src/service.rs +++ b/src/service.rs @@ -17,31 +17,6 @@ pub use actix_web::test; /// **Obsoleto desde la versión 0.3.0**: usar [`static_files_service!`](crate::static_files_service) /// en su lugar. -/// -/// Incluye en código un conjunto de recursos previamente preparado con `build.rs`. -/// -/// # Formas de uso -/// -/// * `include_files!(media)` - Para incluir un conjunto de recursos llamado `media`. Normalmente se -/// usará esta forma. -/// -/// * `include_files!(BLOG => media)` - También se puede asignar el conjunto de recursos a una -/// variable global; p.ej. `BLOG`. -/// -/// # Argumentos -/// -/// * `$bundle` – Nombre del conjunto de recursos generado por `build.rs` (consultar -/// [`pagetop_build`](https://docs.rs/pagetop-build)). -/// * `$STATIC` – Asigna el conjunto de recursos a una variable global de tipo -/// [`StaticResources`](crate::StaticResources). -/// -/// # Ejemplos -/// -/// ```rust,ignore -/// include_files!(assets); // Uso habitual. -/// -/// include_files!(STATIC_ASSETS => assets); -/// ``` #[deprecated(since = "0.3.0", note = "Use `static_files_service!` instead")] #[macro_export] macro_rules! include_files { @@ -69,48 +44,6 @@ macro_rules! include_files { /// **Obsoleto desde la versión 0.3.0**: usar [`static_files_service!`](crate::static_files_service) /// en su lugar. -/// -/// Configura un servicio web para publicar los recursos embebidos con [`include_files!`]. -/// -/// El código expandido de la macro decide durante el arranque de la aplicación si debe servir los -/// archivos de los recursos embebidos o directamente desde el sistema de ficheros, si se ha -/// indicado una ruta válida a un directorio de recursos. -/// -/// # Argumentos -/// -/// * `$scfg` – Instancia de [`ServiceConfig`](crate::service::web::ServiceConfig) donde aplicar la -/// configuración del servicio web. -/// * `$bundle` – Nombre del conjunto de recursos incluido con [`include_files!`]. -/// * `$route` – Ruta URL de origen desde la que se servirán los archivos. -/// * `[ $root, $relative ]` *(opcional)* – Directorio raíz y ruta relativa para construir la ruta -/// absoluta donde buscar los archivos en el sistema de ficheros (ver -/// [`absolute_dir()`](crate::util::absolute_dir)). Si no existe, se usarán los recursos -/// embebidos. -/// -/// # Ejemplos -/// -/// ```rust,ignore -/// use pagetop::prelude::*; -/// -/// include_files!(assets); -/// -/// pub struct MyExtension; -/// -/// impl Extension for MyExtension { -/// fn configure_service(&self, scfg: &mut service::web::ServiceConfig) { -/// include_files_service!(scfg, assets => "/public"); -/// } -/// } -/// ``` -/// -/// Y para buscar los recursos en el sistema de ficheros (si existe la ruta absoluta): -/// -/// ```rust,ignore -/// include_files_service!(cfg, assets => "/public", ["/var/www", "assets"]); -/// -/// // También desde el directorio actual de ejecución. -/// include_files_service!(cfg, assets => "/public", ["", "static"]); -/// ``` #[deprecated(since = "0.3.0", note = "Use `static_files_service!` instead")] #[macro_export] macro_rules! include_files_service { diff --git a/src/util.rs b/src/util.rs index e70b099..808014b 100644 --- a/src/util.rs +++ b/src/util.rs @@ -57,8 +57,6 @@ pub fn resolve_absolute_dir>(path: P) -> io::Result { } /// **Obsoleto desde la versión 0.3.0**: usar [`resolve_absolute_dir()`] en su lugar. -/// -/// Devuelve la ruta absoluta a un directorio existente. #[deprecated(since = "0.3.0", note = "Use `resolve_absolute_dir()` instead")] pub fn absolute_dir(root_path: P, relative_path: Q) -> io::Result where From 19c16d962f708deaa2376eba20618de10ef89212 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Thu, 4 Sep 2025 00:27:25 +0200 Subject: [PATCH 19/35] =?UTF-8?q?=F0=9F=8E=A8=20[locale]=20Mejora=20el=20u?= =?UTF-8?q?so=20de=20`lookup`=20/=20`using`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/base/component/poweredby.rs | 2 +- src/html/attr_l10n.rs | 29 ++++++----- src/html/context.rs | 3 +- src/locale.rs | 86 +++++++++++++++++++-------------- src/response/page.rs | 4 +- tests/locale.rs | 10 ++-- 6 files changed, 75 insertions(+), 59 deletions(-) diff --git a/src/base/component/poweredby.rs b/src/base/component/poweredby.rs index afa8db7..bfe3835 100644 --- a/src/base/component/poweredby.rs +++ b/src/base/component/poweredby.rs @@ -31,7 +31,7 @@ impl Component for PoweredBy { span class="poweredby__copyright" { (c) "." } " " } span class="poweredby__pagetop" { - (L10n::l("poweredby_pagetop").with_arg("pagetop_link", LINK).to_markup(cx)) + (L10n::l("poweredby_pagetop").with_arg("pagetop_link", LINK).using(cx)) } } }) diff --git a/src/html/attr_l10n.rs b/src/html/attr_l10n.rs index cd5b389..3e8a4e4 100644 --- a/src/html/attr_l10n.rs +++ b/src/html/attr_l10n.rs @@ -4,7 +4,7 @@ use crate::{builder_fn, AutoDefault}; /// Texto para [traducir](crate::locale) en atributos HTML. /// -/// Encapsula un tipo [`L10n`] para manejar traducciones de forma segura. +/// Encapsula un [`L10n`] para manejar traducciones de forma segura en atributos. /// /// # Ejemplo /// @@ -16,19 +16,19 @@ use crate::{builder_fn, AutoDefault}; /// /// // Español disponible. /// assert_eq!( -/// hello.using(&LangMatch::resolve("es-ES")), +/// hello.lookup(&LangMatch::resolve("es-ES")), /// Some(String::from("¡Hola mundo!")) /// ); /// /// // Japonés no disponible, traduce al idioma de respaldo ("en-US"). /// assert_eq!( -/// hello.using(&LangMatch::resolve("ja-JP")), +/// hello.lookup(&LangMatch::resolve("ja-JP")), /// Some(String::from("Hello world!")) /// ); /// -/// // Para incrustar en HTML escapado: -/// let markup = hello.to_markup(&LangMatch::resolve("es-ES")); -/// assert_eq!(markup.into_string(), "¡Hola mundo!"); +/// // Uso típico en un atributo: +/// let title = hello.value(&LangMatch::resolve("es-ES")); +/// // Ejemplo: html! { a title=(title) { "Link" } } /// ``` #[derive(AutoDefault, Clone, Debug)] pub struct AttrL10n(L10n); @@ -51,15 +51,18 @@ impl AttrL10n { // AttrL10n GETTERS **************************************************************************** /// Devuelve la traducción para `language`, si existe. - pub fn using(&self, language: &impl LangId) -> Option { - self.0.using(language) + pub fn lookup(&self, language: &impl LangId) -> Option { + self.0.lookup(language) } - /// Devuelve la traducción *escapada* como [`Markup`] para `language`, si existe. - /// - /// Útil para incrustar el texto directamente en plantillas HTML sin riesgo de inyección de - /// contenido. + /// Devuelve la traducción para `language` o una cadena vacía si no existe. + pub fn value(&self, language: &impl LangId) -> String { + self.0.lookup(language).unwrap_or_default() + } + + /// **Obsoleto desde la versión 0.4.0**: no recomendado para atributos HTML. + #[deprecated(since = "0.4.0", note = "For attributes use `lookup()` or `value()`")] pub fn to_markup(&self, language: &impl LangId) -> Markup { - self.0.to_markup(language) + self.0.using(language) } } diff --git a/src/html/context.rs b/src/html/context.rs index 9678786..8b7afba 100644 --- a/src/html/context.rs +++ b/src/html/context.rs @@ -306,8 +306,7 @@ impl Context { /// 4. Y si ninguna de las opciones anteriores aplica, se usa el idioma de respaldo (`"en-US"`). /// /// Resulta útil para usar un contexto ([`Context`]) como fuente de traducción en -/// [`L10n::using()`](crate::locale::L10n::using) o -/// [`L10n::to_markup()`](crate::locale::L10n::to_markup). +/// [`L10n::lookup()`](crate::locale::L10n::lookup) o [`L10n::using()`](crate::locale::L10n::using). impl LangId for Context { fn langid(&self) -> &'static LanguageIdentifier { self.langid diff --git a/src/locale.rs b/src/locale.rs index 43612bd..cf44dd8 100644 --- a/src/locale.rs +++ b/src/locale.rs @@ -13,7 +13,7 @@ //! //! # Recursos Fluent //! -//! Por defecto las traducciones están en el directorio `src/locale`, con subdirectorios para cada +//! Por defecto, las traducciones están en el directorio `src/locale`, con subdirectorios para cada //! [Identificador de Idioma Unicode](https://unicode.org/reports/tr35/tr35.html#Unicode_language_identifier) //! válido. Podríamos tener una estructura como esta: //! @@ -34,7 +34,7 @@ //! └── main.ftl //! ``` //! -//! Ejemplo de un archivo en `src/locale/en-US/main.ftl` +//! Ejemplo de un archivo en `src/locale/en-US/main.ftl`: //! //! ```text //! hello-world = Hello world! @@ -53,7 +53,7 @@ //! Y su archivo equivalente para español en `src/locale/es-ES/main.ftl`: //! //! ```text -//! hello-world = Hola mundo! +//! hello-world = ¡Hola, mundo! //! hello-user = ¡Hola, {$userName}! //! shared-photos = //! {$userName} {$photoCount -> @@ -81,7 +81,7 @@ //! include_locales!(LOCALES_SAMPLE); //! ``` //! -//! Si están ubicados en otro directorio se puede usar la forma: +//! Si están ubicados en otro directorio, se puede usar la forma: //! //! ```rust,ignore //! include_locales!(LOCALES_SAMPLE from "ruta/a/las/traducciones"); @@ -129,7 +129,7 @@ pub(crate) static FALLBACK_LANGID: LazyLock = // Identificador de idioma **por defecto** para la aplicación. // // Se resuelve a partir de [`global::SETTINGS.app.language`](global::SETTINGS). Si el identificador -// de idioma no es válido o no está disponible entonces resuelve como [`FALLBACK_LANGID`]. +// de idioma no es válido o no está disponible, se usa [`FALLBACK_LANGID`]. pub(crate) static DEFAULT_LANGID: LazyLock> = LazyLock::new(|| LangMatch::resolve(&global::SETTINGS.app.language).as_option()); @@ -155,7 +155,7 @@ pub trait LangId { /// let lang = LangMatch::resolve("es-ES"); /// assert_eq!(lang.langid().to_string(), "es-ES"); /// -/// // Coincidencia parcial (con el idioma base). +/// // Coincidencia parcial (retrocede al idioma base si no hay variante regional). /// let lang = LangMatch::resolve("es-EC"); /// assert_eq!(lang.langid().to_string(), "es-ES"); // Porque "es-EC" no está soportado. /// @@ -221,7 +221,7 @@ impl LangMatch { } } - // En otro caso indica que el idioma no está soportado. + // En caso contrario, indica que el idioma no está soportado. Self::Unsupported(String::from(language)) } @@ -241,7 +241,7 @@ impl LangMatch { /// let lang = LangMatch::resolve("es-ES").as_option(); /// assert_eq!(lang.unwrap().to_string(), "es-ES"); /// - /// let lang = LangMatch::resolve("jp-JP").as_option(); + /// let lang = LangMatch::resolve("ja-JP").as_option(); /// assert!(lang.is_none()); /// ``` #[inline] @@ -259,8 +259,8 @@ impl LangMatch { /// devuelve el idioma por defecto de la aplicación y, si tampoco está disponible, el idioma de /// respaldo ("en-US"). /// -/// Resulta útil para usar un valor de [`LangMatch`] como fuente de traducción en [`L10n::using()`] -/// o [`L10n::to_markup()`]. +/// Resulta útil para usar un valor de [`LangMatch`] como fuente de traducción en [`L10n::lookup()`] +/// o [`L10n::using()`]. impl LangId for LangMatch { fn langid(&self) -> &'static LanguageIdentifier { match self { @@ -271,10 +271,10 @@ impl LangId for LangMatch { } #[macro_export] -/// Define un conjunto de elementos de localización y textos de traducción local. +/// Incluye un conjunto de recursos **Fluent** y textos de traducción propios. macro_rules! include_locales { - // Se eliminan las marcas de aislamiento Unicode en los argumentos para mejorar la legibilidad y - // la compatibilidad en ciertos contextos de renderizado. + // Se desactiva la inserción de marcas de aislamiento Unicode (FSI/PDI) en los argumentos para + // mejorar la legibilidad y la compatibilidad en ciertos contextos de renderizado. ( $LOCALES:ident $(, $core_locales:literal)? ) => { $crate::locale::fluent_templates::static_loader! { static $LOCALES = { @@ -310,8 +310,8 @@ include_locales!(LOCALES_PAGETOP); enum L10nOp { #[default] None, - Text(String), - Translate(String), + Text(Cow<'static, str>), + Translate(Cow<'static, str>), } /// Crea instancias para traducir textos localizados. @@ -324,7 +324,7 @@ enum L10nOp { /// /// # Ejemplo /// -/// Los argumentos dinámicos se añaden usando `with_arg()` o `with_args()`. +/// Los argumentos dinámicos se añaden con `with_arg()` o `with_args()`. /// /// ```rust /// use pagetop::prelude::*; @@ -338,11 +338,11 @@ enum L10nOp { /// .get(); /// ``` /// -/// También para traducciones a idiomas concretos. +/// También sirve para traducciones contra un conjunto de recursos concreto. /// /// ```rust,ignore /// // Traducción con clave, conjunto de traducciones y fuente de idioma. -/// let bye = L10n::t("goodbye", &LOCALES_CUSTOM).using(&LangMatch::resolve("it")); +/// let bye = L10n::t("goodbye", &LOCALES_CUSTOM).lookup(&LangMatch::resolve("it")); /// ``` #[derive(AutoDefault, Clone)] pub struct L10n { @@ -354,7 +354,7 @@ pub struct L10n { impl L10n { /// **n** = *“native”*. Crea una instancia con una cadena literal sin traducción. - pub fn n(text: impl Into) -> Self { + pub fn n(text: impl Into>) -> Self { L10n { op: L10nOp::Text(text.into()), ..Default::default() @@ -363,7 +363,7 @@ impl L10n { /// **l** = *“lookup”*. Crea una instancia para traducir usando una clave del conjunto de /// traducciones predefinidas. - pub fn l(key: impl Into) -> Self { + pub fn l(key: impl Into>) -> Self { L10n { op: L10nOp::Translate(key.into()), ..Default::default() @@ -372,7 +372,7 @@ impl L10n { /// **t** = *“translate”*. Crea una instancia para traducir usando una clave de un conjunto de /// traducciones específico. - pub fn t(key: impl Into, locales: &'static Locales) -> Self { + pub fn t(key: impl Into>, locales: &'static Locales) -> Self { L10n { op: L10nOp::Translate(key.into()), locales, @@ -399,7 +399,8 @@ impl L10n { self } - /// Resuelve la traducción usando el idioma por defecto o de respaldo de la aplicación. + /// Resuelve la traducción usando el idioma por defecto o, si no procede, el de respaldo de la + /// aplicación. /// /// Devuelve `None` si no aplica o no encuentra una traducción válida. /// @@ -411,7 +412,7 @@ impl L10n { /// let text = L10n::l("greeting").with_arg("name", "Manuel").get(); /// ``` pub fn get(&self) -> Option { - self.using(&LangMatch::default()) + self.lookup(&LangMatch::default()) } /// Resuelve la traducción usando la fuente de idioma proporcionada. @@ -432,20 +433,27 @@ impl L10n { /// } /// /// let r = ResourceLang; - /// let text = L10n::l("greeting").with_arg("name", "Usuario").using(&r); + /// let text = L10n::l("greeting").with_arg("name", "Usuario").lookup(&r); /// ``` - pub fn using(&self, language: &impl LangId) -> Option { + pub fn lookup(&self, language: &impl LangId) -> Option { match &self.op { L10nOp::None => None, - L10nOp::Text(text) => Some(text.to_owned()), - L10nOp::Translate(key) => self.locales.try_lookup_with_args( - language.langid(), - key, - &self.args.iter().fold(HashMap::new(), |mut arg, (k, v)| { - arg.insert(Cow::Owned(k.clone()), v.to_owned().into()); - arg - }), - ), + L10nOp::Text(text) => Some(text.clone().into_owned()), + L10nOp::Translate(key) => { + if self.args.is_empty() { + self.locales.try_lookup(language.langid(), key.as_ref()) + } else { + self.locales.try_lookup_with_args( + language.langid(), + key.as_ref(), + &self + .args + .iter() + .map(|(k, v)| (Cow::Owned(k.clone()), v.clone().into())) + .collect::>(), + ) + } + } } } @@ -458,10 +466,16 @@ impl L10n { /// ```rust /// use pagetop::prelude::*; /// - /// let html = L10n::l("welcome.message").to_markup(&LangMatch::resolve("es")); + /// let html = L10n::l("welcome.message").using(&LangMatch::resolve("es")); /// ``` + pub fn using(&self, language: &impl LangId) -> Markup { + PreEscaped(self.lookup(language).unwrap_or_default()) + } + + /// **Obsoleto desde la versión 0.4.0**: usar [`using()`](Self::using) en su lugar. + #[deprecated(since = "0.4.0", note = "Use `using()` instead")] pub fn to_markup(&self, language: &impl LangId) -> Markup { - PreEscaped(self.using(language).unwrap_or_default()) + self.using(language) } } diff --git a/src/response/page.rs b/src/response/page.rs index ea88e84..7ef5270 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -151,12 +151,12 @@ impl Page { /// Devuelve el título traducido para el idioma de la página, si existe. pub fn title(&mut self) -> Option { - self.title.using(&self.context) + self.title.lookup(&self.context) } /// Devuelve la descripción traducida para el idioma de la página, si existe. pub fn description(&mut self) -> Option { - self.description.using(&self.context) + self.description.lookup(&self.context) } /// Devuelve la lista de metadatos ``. diff --git a/tests/locale.rs b/tests/locale.rs index ef875d7..ee7ac27 100644 --- a/tests/locale.rs +++ b/tests/locale.rs @@ -13,7 +13,7 @@ async fn translation_without_args() { let _app = service::test::init_service(Application::new().test()).await; let l10n = L10n::l("test-hello-world"); - let translation = l10n.using(&LangMatch::resolve("es-ES")); + let translation = l10n.lookup(&LangMatch::resolve("es-ES")); assert_eq!(translation, Some("¡Hola mundo!".to_string())); } @@ -22,7 +22,7 @@ async fn translation_with_args() { let _app = service::test::init_service(Application::new().test()).await; let l10n = L10n::l("test-hello-user").with_arg("userName", "Manuel"); - let translation = l10n.using(&LangMatch::resolve("es-ES")); + let translation = l10n.lookup(&LangMatch::resolve("es-ES")); assert_eq!(translation, Some("¡Hola, Manuel!".to_string())); } @@ -35,7 +35,7 @@ async fn translation_with_plural_and_select() { ("photoCount", "3"), ("userGender", "male"), ]); - let translation = l10n.using(&LangMatch::resolve("es-ES")).unwrap(); + let translation = l10n.lookup(&LangMatch::resolve("es-ES")).unwrap(); assert!(translation.contains("añadido 3 nuevas fotos de él")); } @@ -44,7 +44,7 @@ async fn check_fallback_language() { let _app = service::test::init_service(Application::new().test()).await; let l10n = L10n::l("test-hello-world"); - let translation = l10n.using(&LangMatch::resolve("xx-YY")); // Retrocede a "en-US". + let translation = l10n.lookup(&LangMatch::resolve("xx-YY")); // Retrocede a "en-US". assert_eq!(translation, Some("Hello world!".to_string())); } @@ -53,6 +53,6 @@ async fn check_unknown_key() { let _app = service::test::init_service(Application::new().test()).await; let l10n = L10n::l("non-existent-key"); - let translation = l10n.using(&LangMatch::resolve("en-US")); + let translation = l10n.lookup(&LangMatch::resolve("en-US")); assert_eq!(translation, None); } From 0e4f10237da6c97a67965e135278cb1ee1e21308 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Thu, 4 Sep 2025 01:11:03 +0200 Subject: [PATCH 20/35] =?UTF-8?q?=F0=9F=9A=9A=20Renombra=20`with=5Fcompone?= =?UTF-8?q?nt`=20por=20`add=5Fcomponent`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/hello-name.rs | 2 +- examples/hello-world.rs | 2 +- src/core/theme/regions.rs | 12 +++++------ src/response/page.rs | 42 ++++++++++++++++++++++++++++++++------ src/response/page/error.rs | 4 ++-- 5 files changed, 46 insertions(+), 16 deletions(-) diff --git a/examples/hello-name.rs b/examples/hello-name.rs index 7a6db54..3a491a6 100644 --- a/examples/hello-name.rs +++ b/examples/hello-name.rs @@ -14,7 +14,7 @@ async fn hello_name( ) -> ResultPage { let name = path.into_inner(); Page::new(Some(request)) - .with_component(Html::with(move |_| html! { h1 { "Hello " (name) "!" } })) + .add_component(Html::with(move |_| html! { h1 { "Hello " (name) "!" } })) .render() } diff --git a/examples/hello-world.rs b/examples/hello-world.rs index ba268dc..5550514 100644 --- a/examples/hello-world.rs +++ b/examples/hello-world.rs @@ -10,7 +10,7 @@ impl Extension for HelloWorld { async fn hello_world(request: HttpRequest) -> ResultPage { Page::new(Some(request)) - .with_component(Html::with(move |_| html! { h1 { "Hello World!" } })) + .add_component(Html::with(move |_| html! { h1 { "Hello World!" } })) .render() } diff --git a/src/core/theme/regions.rs b/src/core/theme/regions.rs index c8a0555..4fcd7df 100644 --- a/src/core/theme/regions.rs +++ b/src/core/theme/regions.rs @@ -77,11 +77,11 @@ pub struct ChildrenInRegions(HashMap<&'static str, Children>); impl ChildrenInRegions { pub fn with(region_name: &'static str, child: Child) -> Self { - ChildrenInRegions::default().with_child_in_region(region_name, ChildOp::Add(child)) + ChildrenInRegions::default().with_child_in(region_name, ChildOp::Add(child)) } #[builder_fn] - pub fn with_child_in_region(mut self, region_name: &'static str, op: ChildOp) -> Self { + pub fn with_child_in(mut self, region_name: &'static str, op: ChildOp) -> Self { if let Some(region) = self.0.get_mut(region_name) { region.alter_child(op); } else { @@ -143,17 +143,17 @@ impl InRegion { InRegion::Content => { COMMON_REGIONS .write() - .alter_child_in_region(REGION_CONTENT, ChildOp::Add(child)); + .alter_child_in(REGION_CONTENT, ChildOp::Add(child)); } - InRegion::Named(name) => { + InRegion::Named(region_name) => { COMMON_REGIONS .write() - .alter_child_in_region(name, ChildOp::Add(child)); + .alter_child_in(region_name, ChildOp::Add(child)); } InRegion::OfTheme(region_name, theme_ref) => { let mut regions = THEME_REGIONS.write(); if let Some(r) = regions.get_mut(&theme_ref.type_id()) { - r.alter_child_in_region(region_name, ChildOp::Add(child)); + r.alter_child_in(region_name, ChildOp::Add(child)); } else { regions.insert( theme_ref.type_id(), diff --git a/src/response/page.rs b/src/response/page.rs index 7ef5270..5ac3720 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -122,28 +122,58 @@ impl Page { self } + /// **Obsoleto desde la versión 0.4.0**: usar [`add_component()`](Self::add_component) en su + /// lugar. + #[deprecated(since = "0.4.0", note = "Use `add_component()` instead")] + pub fn with_component(self, component: impl Component) -> Self { + self.add_component(component) + } + + /// **Obsoleto desde la versión 0.4.0**: usar [`add_component_in()`](Self::add_component_in) en + /// su lugar. + #[deprecated(since = "0.4.0", note = "Use `add_component_in()` instead")] + pub fn with_component_in(self, region_name: &'static str, component: impl Component) -> Self { + self.add_component_in(region_name, component) + } + /// Añade un componente a la región de contenido por defecto. - pub fn with_component(mut self, component: impl Component) -> Self { + pub fn add_component(mut self, component: impl Component) -> Self { self.regions - .alter_child_in_region(REGION_CONTENT, ChildOp::Add(Child::with(component))); + .alter_child_in(REGION_CONTENT, ChildOp::Add(Child::with(component))); self } /// Añade un componente en una región (`region_name`) de la página. - pub fn with_component_in( + pub fn add_component_in( mut self, region_name: &'static str, component: impl Component, ) -> Self { self.regions - .alter_child_in_region(region_name, ChildOp::Add(Child::with(component))); + .alter_child_in(region_name, ChildOp::Add(Child::with(component))); + self + } + + /// **Obsoleto desde la versión 0.4.0**: usar [`with_child_in()`](Self::with_child_in) en su + /// lugar. + #[deprecated(since = "0.4.0", note = "Use `with_child_in()` instead")] + pub fn with_child_in_region(mut self, region_name: &'static str, op: ChildOp) -> Self { + self.alter_child_in(region_name, op); + self + } + + /// **Obsoleto desde la versión 0.4.0**: usar [`alter_child_in()`](Self::alter_child_in) en su + /// lugar. + #[deprecated(since = "0.4.0", note = "Use `alter_child_in()` instead")] + pub fn alter_child_in_region(&mut self, region_name: &'static str, op: ChildOp) -> &mut Self { + self.alter_child_in(region_name, op); self } /// Opera con [`ChildOp`] en una región (`region_name`) de la página. #[builder_fn] - pub fn with_child_in_region(mut self, region_name: &'static str, op: ChildOp) -> Self { - self.regions.alter_child_in_region(region_name, op); + pub fn with_child_in(mut self, region_name: &'static str, op: ChildOp) -> Self { + self.regions.alter_child_in(region_name, op); self } diff --git a/src/response/page/error.rs b/src/response/page/error.rs index 99ba62b..ab56338 100644 --- a/src/response/page/error.rs +++ b/src/response/page/error.rs @@ -33,7 +33,7 @@ impl Display for ErrorPage { if let Ok(page) = error_page .with_title(L10n::n("Error FORBIDDEN")) .with_layout("error") - .with_component(Html::with(move |_| error403.clone())) + .add_component(Html::with(move |_| error403.clone())) .render() { write!(f, "{}", page.into_string()) @@ -48,7 +48,7 @@ impl Display for ErrorPage { if let Ok(page) = error_page .with_title(L10n::n("Error RESOURCE NOT FOUND")) .with_layout("error") - .with_component(Html::with(move |_| error404.clone())) + .add_component(Html::with(move |_| error404.clone())) .render() { write!(f, "{}", page.into_string()) From fe3bbcb131b9b973ce251338c8eee0c0cd61021b Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Thu, 4 Sep 2025 01:12:59 +0200 Subject: [PATCH 21/35] =?UTF-8?q?=F0=9F=9A=A7=20Retoques=20en=20el=20c?= =?UTF-8?q?=C3=B3digo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/pagetop-macros/src/lib.rs | 2 +- tests/component_poweredby.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/helpers/pagetop-macros/src/lib.rs b/helpers/pagetop-macros/src/lib.rs index b462ea1..0a60d53 100644 --- a/helpers/pagetop-macros/src/lib.rs +++ b/helpers/pagetop-macros/src/lib.rs @@ -191,7 +191,7 @@ pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream { // Genera el método alter_...() con el código del método with_...(). let fn_alter_doc = - format!("Igual que [`Self::{fn_with_name_str}()`], pero sin usar el patrón *builder*."); + format!("Equivalente a [`Self::{fn_with_name_str}()`], pero sin usar el patrón *builder*."); let fn_alter = quote! { #[doc = #fn_alter_doc] diff --git a/tests/component_poweredby.rs b/tests/component_poweredby.rs index b2e4418..9f8e822 100644 --- a/tests/component_poweredby.rs +++ b/tests/component_poweredby.rs @@ -67,8 +67,8 @@ async fn poweredby_link_points_to_crates_io() { let html = render_component(&p); assert!( - html.contains("https://crates.io/crates/pagetop"), - "Link should point to crates.io/pagetop" + html.contains("https://pagetop.cillero.es"), + "Link should point to pagetop.cillero.es" ); } From 8274519405382c52070f6dc68fea4a8caffc029b Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Thu, 4 Sep 2025 01:53:51 +0200 Subject: [PATCH 22/35] =?UTF-8?q?=F0=9F=9A=A7=20[welcome]=20Crea=20p=C3=A1?= =?UTF-8?q?gina=20de=20bienvenida=20desde=20intro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implementa un nuevo *layout* en el tema `Basic` para crear una plantilla de páginas de introducción. - Añade nuevo fichero CSS `intro.css` para los estilos globales de la página de introducción. - Incorpora nuevos recursos gráficos para la cabecera de la página de introducción en varios formatos (AVIF, WebP, JPEG). - Revisa los ficheros de localización. --- src/base/component.rs | 3 + src/base/component/block.rs | 103 +++++++++ src/base/extension/welcome.rs | 105 ++------- src/base/theme/basic.rs | 91 +++++++- src/core/component/definition.rs | 6 +- src/core/theme.rs | 6 +- src/core/theme/definition.rs | 210 +++++++++++------- src/core/theme/regions.rs | 2 +- src/locale/en-US/welcome.ftl | 1 - src/locale/es-ES/welcome.ftl | 1 - static/css/{welcome.css => intro.css} | 150 ++++++------- ...me-header-sm.avif => intro-header-sm.avif} | Bin ...come-header-sm.jpg => intro-header-sm.jpg} | Bin ...me-header-sm.webp => intro-header-sm.webp} | Bin ...{welcome-header.avif => intro-header.avif} | Bin .../{welcome-header.jpg => intro-header.jpg} | Bin ...{welcome-header.webp => intro-header.webp} | Bin 17 files changed, 420 insertions(+), 258 deletions(-) create mode 100644 src/base/component/block.rs rename static/css/{welcome.css => intro.css} (78%) rename static/img/{welcome-header-sm.avif => intro-header-sm.avif} (100%) rename static/img/{welcome-header-sm.jpg => intro-header-sm.jpg} (100%) rename static/img/{welcome-header-sm.webp => intro-header-sm.webp} (100%) rename static/img/{welcome-header.avif => intro-header.avif} (100%) rename static/img/{welcome-header.jpg => intro-header.jpg} (100%) rename static/img/{welcome-header.webp => intro-header.webp} (100%) diff --git a/src/base/component.rs b/src/base/component.rs index 30cb686..4df64ff 100644 --- a/src/base/component.rs +++ b/src/base/component.rs @@ -3,5 +3,8 @@ mod html; pub use html::Html; +mod block; +pub use block::Block; + mod poweredby; pub use poweredby::PoweredBy; diff --git a/src/base/component/block.rs b/src/base/component/block.rs new file mode 100644 index 0000000..c96f2ba --- /dev/null +++ b/src/base/component/block.rs @@ -0,0 +1,103 @@ +use crate::prelude::*; + +/// Componente genérico que representa un bloque de contenido. +/// +/// Los bloques se utilizan como contenedores de otros componentes o contenidos, con un título +/// opcional y un cuerpo que sólo se renderiza si existen componentes hijos (*children*). +#[rustfmt::skip] +#[derive(AutoDefault)] +pub struct Block { + id : AttrId, + classes : AttrClasses, + title : L10n, + children: Children, +} + +impl Component for Block { + fn new() -> Self { + Block::default() + } + + fn id(&self) -> Option { + self.id.get() + } + + fn setup_before_prepare(&mut self, _cx: &mut Context) { + self.alter_classes(ClassesOp::Prepend, "block"); + } + + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { + let block_body = self.children().render(cx); + + if block_body.is_empty() { + return PrepareMarkup::None; + } + + let id = cx.required_id::(self.id()); + + PrepareMarkup::With(html! { + div id=(id) class=[self.classes().get()] { + @if let Some(title) = self.title().lookup(cx) { + h2 class="block__title" { span { (title) } } + } + div class="block__body" { (block_body) } + } + }) + } +} + +impl Block { + // Block BUILDER ******************************************************************************* + + /// Establece el identificador único (`id`) del bloque. + #[builder_fn] + pub fn with_id(mut self, id: impl AsRef) -> Self { + self.id.alter_value(id); + self + } + + /// Modifica la lista de clases CSS aplicadas al bloque. + #[builder_fn] + pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef) -> Self { + self.classes.alter_value(op, classes); + self + } + + /// Establece el título del bloque. + #[builder_fn] + pub fn with_title(mut self, title: L10n) -> Self { + self.title = title; + self + } + + /// Añade un nuevo componente hijo al bloque. + pub fn add_component(mut self, component: impl Component) -> Self { + self.children + .alter_child(ChildOp::Add(Child::with(component))); + self + } + + /// Modifica la lista de hijos (`children`) aplicando una operación. + #[builder_fn] + pub fn with_child(mut self, op: ChildOp) -> Self { + self.children.alter_child(op); + self + } + + // Block GETTERS ******************************************************************************* + + /// Devuelve las clases CSS asociadas al bloque. + pub fn classes(&self) -> &AttrClasses { + &self.classes + } + + /// Devuelve el título del bloque como [`L10n`]. + pub fn title(&self) -> &L10n { + &self.title + } + + /// Devuelve la lista de hijos (`children`) del bloque. + pub fn children(&self) -> &Children { + &self.children + } +} diff --git a/src/base/extension/welcome.rs b/src/base/extension/welcome.rs index 0252cff..3c0de3d 100644 --- a/src/base/extension/welcome.rs +++ b/src/base/extension/welcome.rs @@ -25,95 +25,26 @@ async fn homepage(request: HttpRequest) -> ResultPage { let app = &global::SETTINGS.app.name; Page::new(Some(request)) - .with_title(L10n::l("welcome_page")) - .with_theme("Basic") - .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/welcome.css"))) - .with_body_classes(ClassesOp::Add, "welcome") - .with_component_in("header", Html::with(move |cx| html! { - div class="welcome-header" { - header class="welcome-header__body" { - h1 - class="welcome-header__title" - aria-label=(L10n::l("welcome_aria").with_arg("app", app).to_markup(cx)) - { - span { (L10n::l("welcome_title").to_markup(cx)) } - (L10n::l("welcome_intro").with_arg("app", app).to_markup(cx)) - } - } - aside class="welcome-header__image" aria-hidden="true" { - div class="welcome-header__monster" { - picture { - source - type="image/avif" - src="/img/monster-pagetop_250.avif" - srcset="/img/monster-pagetop_500.avif 1.5x"; - source - type="image/webp" - src="/img/monster-pagetop_250.webp" - srcset="/img/monster-pagetop_500.webp 1.5x"; - img - src="/img/monster-pagetop_250.png" - srcset="/img/monster-pagetop_500.png 1.5x" - alt="Monster PageTop"; - } - } - } + .with_theme("basic") + .with_layout("intro") + .with_title(L10n::l("welcome_title")) + .with_description(L10n::l("welcome_intro").with_arg("app", app)) + .add_component(Html::with(|cx| { + html! { + p { (L10n::l("welcome_text1").using(cx)) } + p { (L10n::l("welcome_text2").using(cx)) } } })) - .with_component(Html::with(move |cx| html! { - main class="welcome-content" { - section class="welcome-content__body" { - div class="welcome-poweredby" { - a - class="welcome-poweredby__link" - href="https://pagetop.cillero.es" - target="_blank" - rel="noreferrer" - { - span {} span {} span {} - div class="welcome-poweredby__text" { - (L10n::l("welcome_powered").to_markup(cx)) - } - } + .add_component( + Block::new() + .with_title(L10n::l("welcome_about")) + .add_component(Html::with(move |cx| { + html! { + p { (L10n::l("welcome_pagetop").using(cx)) } + p { (L10n::l("welcome_issues1").using(cx)) } + p { (L10n::l("welcome_issues2").with_arg("app", app).using(cx)) } } - div class="welcome-text" { - p { (L10n::l("welcome_text1").to_markup(cx)) } - p { (L10n::l("welcome_text2").to_markup(cx)) } - - div class="welcome-text__block" { - h2 { span { (L10n::l("welcome_about").to_markup(cx)) } } - p { (L10n::l("welcome_pagetop").to_markup(cx)) } - p { (L10n::l("welcome_issues1").to_markup(cx)) } - p { (L10n::l("welcome_issues2").with_arg("app", app).to_markup(cx)) } - } - } - } - } - })) - .with_component_in("footer", Html::with(move |cx| html! { - section class="welcome-footer" { - div class="welcome-footer__logo" { - svg - viewBox="0 0 1614 1614" - xmlns="http://www.w3.org/2000/svg" - role="img" - aria-label=[L10n::l("pagetop_logo").using(cx)] - preserveAspectRatio="xMidYMid slice" - focusable="false" - { - path fill="rgb(255,255,255)" d="M 1573,357 L 1415,357 C 1400,357 1388,369 1388,383 L 1388,410 1335,410 1335,357 C 1335,167 1181,13 992,13 L 621,13 C 432,13 278,167 278,357 L 278,410 225,410 225,383 C 225,369 213,357 198,357 L 40,357 C 25,357 13,369 13,383 L 13,648 C 13,662 25,674 40,674 L 198,674 C 213,674 225,662 225,648 L 225,621 278,621 278,1256 C 278,1446 432,1600 621,1600 L 992,1600 C 1181,1600 1335,1446 1335,1256 L 1335,621 1388,621 1388,648 C 1388,662 1400,674 1415,674 L 1573,674 C 1588,674 1600,662 1600,648 L 1600,383 C 1600,369 1588,357 1573,357 L 1573,357 1573,357 Z M 66,410 L 172,410 172,621 66,621 66,410 66,410 Z M 1282,357 L 1282,488 C 1247,485 1213,477 1181,464 L 1196,437 C 1203,425 1199,409 1186,401 1174,394 1158,398 1150,411 L 1133,440 C 1105,423 1079,401 1056,376 L 1075,361 C 1087,352 1089,335 1079,324 1070,313 1054,311 1042,320 L 1023,335 C 1000,301 981,263 967,221 L 1011,196 C 1023,189 1028,172 1021,160 1013,147 997,143 984,150 L 953,168 C 945,136 941,102 940,66 L 992,66 C 1152,66 1282,197 1282,357 L 1282,357 1282,357 Z M 621,66 L 674,66 674,225 648,225 C 633,225 621,237 621,251 621,266 633,278 648,278 L 674,278 674,357 648,357 C 633,357 621,369 621,383 621,398 633,410 648,410 L 674,410 674,489 648,489 C 633,489 621,501 621,516 621,530 633,542 648,542 L 664,542 C 651,582 626,623 600,662 583,653 563,648 542,648 469,648 410,707 410,780 410,787 411,794 412,801 388,805 361,806 331,806 L 331,357 C 331,197 461,66 621,66 L 621,66 621,66 Z M 621,780 C 621,824 586,859 542,859 498,859 463,824 463,780 463,736 498,701 542,701 586,701 621,736 621,780 L 621,780 621,780 Z M 225,463 L 278,463 278,569 225,569 225,463 225,463 Z M 992,1547 L 621,1547 C 461,1547 331,1416 331,1256 L 331,859 C 367,859 400,858 431,851 454,888 495,912 542,912 615,912 674,853 674,780 674,747 662,718 642,695 675,645 706,594 720,542 L 780,542 C 795,542 807,530 807,516 807,501 795,489 780,489 L 727,489 727,410 780,410 C 795,410 807,398 807,383 807,369 795,357 780,357 L 727,357 727,278 780,278 C 795,278 807,266 807,251 807,237 795,225 780,225 L 727,225 727,66 887,66 C 889,111 895,155 905,196 L 869,217 C 856,224 852,240 859,253 864,261 873,266 882,266 887,266 891,265 895,263 L 921,248 C 937,291 958,331 983,367 L 938,403 C 926,412 925,429 934,440 939,447 947,450 954,450 960,450 966,448 971,444 L 1016,408 C 1043,438 1074,465 1108,485 L 1084,527 C 1076,539 1081,555 1093,563 1098,565 1102,566 1107,566 1116,566 1125,561 1129,553 L 1155,509 C 1194,527 1237,538 1282,541 L 1282,1256 C 1282,1416 1152,1547 992,1547 L 992,1547 992,1547 Z M 1335,463 L 1388,463 1388,569 1335,569 1335,463 1335,463 Z M 1441,410 L 1547,410 1547,621 1441,621 1441,410 1441,410 Z" {} - path fill="rgb(255,255,255)" d="M 1150,1018 L 463,1018 C 448,1018 436,1030 436,1044 L 436,1177 C 436,1348 545,1468 701,1468 L 912,1468 C 1068,1468 1177,1348 1177,1177 L 1177,1044 C 1177,1030 1165,1018 1150,1018 L 1150,1018 1150,1018 Z M 912,1071 L 1018,1071 1018,1124 912,1124 912,1071 912,1071 Z M 489,1071 L 542,1071 542,1124 489,1124 489,1071 489,1071 Z M 701,1415 L 700,1415 C 701,1385 704,1352 718,1343 731,1335 759,1341 795,1359 802,1363 811,1363 818,1359 854,1341 882,1335 895,1343 909,1352 912,1385 913,1415 L 912,1415 701,1415 701,1415 701,1415 Z M 1124,1177 C 1124,1296 1061,1384 966,1408 964,1365 958,1320 922,1298 894,1281 856,1283 807,1306 757,1283 719,1281 691,1298 655,1320 649,1365 647,1408 552,1384 489,1296 489,1177 L 569,1177 C 583,1177 595,1165 595,1150 L 595,1071 859,1071 859,1150 C 859,1165 871,1177 886,1177 L 1044,1177 C 1059,1177 1071,1165 1071,1150 L 1071,1071 1124,1071 1124,1177 1124,1177 1124,1177 Z" {} - path fill="rgb(255,255,255)" d="M 1071,648 C 998,648 939,707 939,780 939,853 998,912 1071,912 1144,912 1203,853 1203,780 1203,707 1144,648 1071,648 L 1071,648 1071,648 Z M 1071,859 C 1027,859 992,824 992,780 992,736 1027,701 1071,701 1115,701 1150,736 1150,780 1150,824 1115,859 1071,859 L 1071,859 1071,859 Z" {} - } - } - div class="welcome-footer__links" { - a href="https://crates.io/crates/pagetop" target="_blank" rel="noreferrer" { ("Crates.io") } - a href="https://docs.rs/pagetop" target="_blank" rel="noreferrer" { ("Docs.rs") } - a href="https://git.cillero.es/manuelcillero/pagetop" target="_blank" rel="noreferrer" { (L10n::l("welcome_code").to_markup(cx)) } - em { (L10n::l("welcome_have_fun").to_markup(cx)) } - } - } - })) - .with_component_in("footer", PoweredBy::new()) + })), + ) .render() } diff --git a/src/base/theme/basic.rs b/src/base/theme/basic.rs index 961864b..dc16f2a 100644 --- a/src/base/theme/basic.rs +++ b/src/base/theme/basic.rs @@ -12,16 +12,105 @@ impl Extension for Basic { } impl Theme for Basic { + fn render_page_body(&self, page: &mut Page) -> Markup { + match page.layout() { + "intro" => render_intro(page), + _ => ::render_body(self, page, self.page_regions()), + } + } + fn after_render_page_body(&self, page: &mut Page) { + let styles = match page.layout() { + "intro" => "/css/intro.css", + _ => "/css/basic.css", + }; page.alter_assets(AssetsOp::AddStyleSheet( StyleSheet::from("/css/normalize.css") .with_version("8.0.1") .with_weight(-99), )) .alter_assets(AssetsOp::AddStyleSheet( - StyleSheet::from("/css/basic.css") + StyleSheet::from(styles) .with_version(env!("CARGO_PKG_VERSION")) .with_weight(-99), )); } } + +fn render_intro(page: &mut Page) -> Markup { + let title = page.title().unwrap_or_default(); + let intro = page.description().unwrap_or_default(); + + html! { + body id=[page.body_id().get()] class=[page.body_classes().get()] { + header class="intro-header" { + section class="intro-header__body" { + h1 class="intro-header__title" { + span { (title) } + (intro) + } + } + aside class="intro-header__image" aria-hidden="true" { + div class="intro-header__monster" { + picture { + source + type="image/avif" + src="/img/monster-pagetop_250.avif" + srcset="/img/monster-pagetop_500.avif 1.5x"; + source + type="image/webp" + src="/img/monster-pagetop_250.webp" + srcset="/img/monster-pagetop_500.webp 1.5x"; + img + src="/img/monster-pagetop_250.png" + srcset="/img/monster-pagetop_500.png 1.5x" + alt="Monster PageTop"; + } + } + } + } + main class="intro-content" { + section class="intro-content__body" { + div class="intro-button" { + a + class="intro-button__link" + href="https://pagetop.cillero.es" + target="_blank" + rel="noreferrer" + { + span {} span {} span {} + div class="intro-button__text" { + (L10n::l("welcome_powered").using(page)) + } + } + } + div class="intro-text" { (page.render_region("content")) } + } + } + footer class="intro-footer" { + section class="intro-footer__body" { + div class="intro-footer__logo" { + svg + viewBox="0 0 1614 1614" + xmlns="http://www.w3.org/2000/svg" + role="img" + aria-label=[L10n::l("pagetop_logo").lookup(page)] + preserveAspectRatio="xMidYMid slice" + focusable="false" + { + path fill="rgb(255,255,255)" d="M 1573,357 L 1415,357 C 1400,357 1388,369 1388,383 L 1388,410 1335,410 1335,357 C 1335,167 1181,13 992,13 L 621,13 C 432,13 278,167 278,357 L 278,410 225,410 225,383 C 225,369 213,357 198,357 L 40,357 C 25,357 13,369 13,383 L 13,648 C 13,662 25,674 40,674 L 198,674 C 213,674 225,662 225,648 L 225,621 278,621 278,1256 C 278,1446 432,1600 621,1600 L 992,1600 C 1181,1600 1335,1446 1335,1256 L 1335,621 1388,621 1388,648 C 1388,662 1400,674 1415,674 L 1573,674 C 1588,674 1600,662 1600,648 L 1600,383 C 1600,369 1588,357 1573,357 L 1573,357 1573,357 Z M 66,410 L 172,410 172,621 66,621 66,410 66,410 Z M 1282,357 L 1282,488 C 1247,485 1213,477 1181,464 L 1196,437 C 1203,425 1199,409 1186,401 1174,394 1158,398 1150,411 L 1133,440 C 1105,423 1079,401 1056,376 L 1075,361 C 1087,352 1089,335 1079,324 1070,313 1054,311 1042,320 L 1023,335 C 1000,301 981,263 967,221 L 1011,196 C 1023,189 1028,172 1021,160 1013,147 997,143 984,150 L 953,168 C 945,136 941,102 940,66 L 992,66 C 1152,66 1282,197 1282,357 L 1282,357 1282,357 Z M 621,66 L 674,66 674,225 648,225 C 633,225 621,237 621,251 621,266 633,278 648,278 L 674,278 674,357 648,357 C 633,357 621,369 621,383 621,398 633,410 648,410 L 674,410 674,489 648,489 C 633,489 621,501 621,516 621,530 633,542 648,542 L 664,542 C 651,582 626,623 600,662 583,653 563,648 542,648 469,648 410,707 410,780 410,787 411,794 412,801 388,805 361,806 331,806 L 331,357 C 331,197 461,66 621,66 L 621,66 621,66 Z M 621,780 C 621,824 586,859 542,859 498,859 463,824 463,780 463,736 498,701 542,701 586,701 621,736 621,780 L 621,780 621,780 Z M 225,463 L 278,463 278,569 225,569 225,463 225,463 Z M 992,1547 L 621,1547 C 461,1547 331,1416 331,1256 L 331,859 C 367,859 400,858 431,851 454,888 495,912 542,912 615,912 674,853 674,780 674,747 662,718 642,695 675,645 706,594 720,542 L 780,542 C 795,542 807,530 807,516 807,501 795,489 780,489 L 727,489 727,410 780,410 C 795,410 807,398 807,383 807,369 795,357 780,357 L 727,357 727,278 780,278 C 795,278 807,266 807,251 807,237 795,225 780,225 L 727,225 727,66 887,66 C 889,111 895,155 905,196 L 869,217 C 856,224 852,240 859,253 864,261 873,266 882,266 887,266 891,265 895,263 L 921,248 C 937,291 958,331 983,367 L 938,403 C 926,412 925,429 934,440 939,447 947,450 954,450 960,450 966,448 971,444 L 1016,408 C 1043,438 1074,465 1108,485 L 1084,527 C 1076,539 1081,555 1093,563 1098,565 1102,566 1107,566 1116,566 1125,561 1129,553 L 1155,509 C 1194,527 1237,538 1282,541 L 1282,1256 C 1282,1416 1152,1547 992,1547 L 992,1547 992,1547 Z M 1335,463 L 1388,463 1388,569 1335,569 1335,463 1335,463 Z M 1441,410 L 1547,410 1547,621 1441,621 1441,410 1441,410 Z" {} + path fill="rgb(255,255,255)" d="M 1150,1018 L 463,1018 C 448,1018 436,1030 436,1044 L 436,1177 C 436,1348 545,1468 701,1468 L 912,1468 C 1068,1468 1177,1348 1177,1177 L 1177,1044 C 1177,1030 1165,1018 1150,1018 L 1150,1018 1150,1018 Z M 912,1071 L 1018,1071 1018,1124 912,1124 912,1071 912,1071 Z M 489,1071 L 542,1071 542,1124 489,1124 489,1071 489,1071 Z M 701,1415 L 700,1415 C 701,1385 704,1352 718,1343 731,1335 759,1341 795,1359 802,1363 811,1363 818,1359 854,1341 882,1335 895,1343 909,1352 912,1385 913,1415 L 912,1415 701,1415 701,1415 701,1415 Z M 1124,1177 C 1124,1296 1061,1384 966,1408 964,1365 958,1320 922,1298 894,1281 856,1283 807,1306 757,1283 719,1281 691,1298 655,1320 649,1365 647,1408 552,1384 489,1296 489,1177 L 569,1177 C 583,1177 595,1165 595,1150 L 595,1071 859,1071 859,1150 C 859,1165 871,1177 886,1177 L 1044,1177 C 1059,1177 1071,1165 1071,1150 L 1071,1071 1124,1071 1124,1177 1124,1177 1124,1177 Z" {} + path fill="rgb(255,255,255)" d="M 1071,648 C 998,648 939,707 939,780 939,853 998,912 1071,912 1144,912 1203,853 1203,780 1203,707 1144,648 1071,648 L 1071,648 1071,648 Z M 1071,859 C 1027,859 992,824 992,780 992,736 1027,701 1071,701 1115,701 1150,736 1150,780 1150,824 1115,859 1071,859 L 1071,859 1071,859 Z" {} + } + } + div class="intro-footer__links" { + a href="https://crates.io/crates/pagetop" target="_blank" rel="noreferrer" { ("Crates.io") } + a href="https://docs.rs/pagetop" target="_blank" rel="noreferrer" { ("Docs.rs") } + a href="https://git.cillero.es/manuelcillero/pagetop" target="_blank" rel="noreferrer" { (L10n::l("welcome_code").using(page)) } + em { (L10n::l("welcome_have_fun").using(page)) } + } + } + } + } + } +} diff --git a/src/core/component/definition.rs b/src/core/component/definition.rs index c43dfb0..d547c4b 100644 --- a/src/core/component/definition.rs +++ b/src/core/component/definition.rs @@ -29,14 +29,14 @@ pub trait Component: AnyInfo + ComponentRender + Send + Sync { TypeInfo::ShortName.of::() } - /// Devuelve una descripción opcional del componente. + /// Devuelve una descripción del componente, si existe. /// /// Por defecto, no se proporciona ninguna descripción (`None`). fn description(&self) -> Option { None } - /// Devuelve un identificador opcional para el componente. + /// Devuelve el identificador del componente, si existe. /// /// Este identificador puede usarse para referenciar el componente en el HTML. Por defecto, no /// tiene ningún identificador (`None`). @@ -51,7 +51,7 @@ pub trait Component: AnyInfo + ComponentRender + Send + Sync { #[allow(unused_variables)] fn setup_before_prepare(&mut self, cx: &mut Context) {} - /// Devuelve una representación estructurada del componente preparada para el renderizado. + /// Devuelve una representación renderizada del componente. /// /// Este método forma parte del ciclo de vida de los componentes y se invoca automáticamente /// durante el proceso de construcción del documento. Puede sobrescribirse para generar diff --git a/src/core/theme.rs b/src/core/theme.rs index e0c3008..5889dcf 100644 --- a/src/core/theme.rs +++ b/src/core/theme.rs @@ -15,10 +15,10 @@ //! [`Theme`]. mod definition; -pub use definition::{Theme, ThemeRef}; +pub use definition::{Theme, ThemePage, ThemeRef}; mod regions; -pub(crate) use regions::ChildrenInRegions; -pub use regions::{InRegion, Region, REGION_CONTENT}; +pub(crate) use regions::{ChildrenInRegions, REGION_CONTENT}; +pub use regions::{InRegion, Region}; pub(crate) mod all; diff --git a/src/core/theme/definition.rs b/src/core/theme/definition.rs index 3b26a57..a4faf0b 100644 --- a/src/core/theme/definition.rs +++ b/src/core/theme/definition.rs @@ -7,88 +7,34 @@ use crate::response::page::Page; use std::sync::LazyLock; -/// Representa una referencia a un tema. +/// Referencia estática a un tema. /// -/// Los temas son también extensiones. Por tanto se deben definir igual, es decir, como instancias -/// estáticas globales que implementan [`Theme`], pero también [`Extension`]. +/// Los temas son también extensiones. Por tanto, deben declararse como **instancias estáticas** que +/// implementen [`Theme`] y, a su vez, [`Extension`]. pub type ThemeRef = &'static dyn Theme; -/// Interfaz común que debe implementar cualquier tema de `PageTop`. +/// Métodos predefinidos de renderizado para las páginas de un tema. /// -/// Un tema implementará [`Theme`] y los métodos que sean necesarios de [`Extension`], aunque el -/// único obligatorio será [`theme()`](Extension::theme). +/// Contiene las implementaciones base de las **secciones** `` y ``. Se implementa +/// automáticamente para cualquier tipo que implemente [`Theme`], por lo que normalmente no requiere +/// implementación explícita. /// -/// ```rust -/// use pagetop::prelude::*; +/// Si un tema **sobrescribe** [`render_page_head()`](Theme::render_page_head) o +/// [`render_page_body()`](Theme::render_page_body), se puede volver al comportamiento por defecto +/// cuando se necesite usando FQS (*Fully Qualified Syntax*): /// -/// pub struct MyTheme; -/// -/// impl Extension for MyTheme { -/// fn name(&self) -> L10n { -/// L10n::n("My theme") -/// } -/// -/// fn description(&self) -> L10n { -/// L10n::n("A personal theme") -/// } -/// -/// fn theme(&self) -> Option { -/// Some(&Self) -/// } -/// } -/// -/// impl Theme for MyTheme {} -/// ``` -pub trait Theme: Extension + Send + Sync { - /// **Obsoleto desde la versión 0.4.0**: usar [`declared_regions()`](Self::declared_regions) en - /// su lugar. - #[deprecated(since = "0.4.0", note = "Use `declared_regions()` instead")] - fn regions(&self) -> Vec<(&'static str, L10n)> { - vec![("content", L10n::l("content"))] - } - - /// Declaración ordenada de las regiones disponibles en la página. - /// - /// Devuelve una lista estática de pares `(Region, L10n)` que se usará para renderizar en el - /// orden indicado todas las regiones que componen una página. Los identificadores deben ser - /// **estables** como `"sidebar-left"` o `"content"`. La etiqueta `L10n` devuelve el nombre de la - /// región en el idioma activo de la página. - /// - /// Si el tema requiere un conjunto distinto de regiones, se puede sobrescribir este método para - /// devolver una lista diferente. Si no, se usará la lista predeterminada: - /// - /// - `"header"`: cabecera. - /// - `"content"`: contenido principal (**obligatoria**). - /// - `"footer"`: pie. - /// - /// Sólo la región `"content"` es obligatoria, usa [`Region::default()`] para declararla. - #[inline] - fn declared_regions(&self) -> &'static [(Region, L10n)] { - static REGIONS: LazyLock<[(Region, L10n); 3]> = LazyLock::new(|| { - [ - (Region::declare("header"), L10n::l("region_header")), - (Region::default(), L10n::l("region_content")), - (Region::declare("footer"), L10n::l("region_footer")), - ] - }); - ®IONS[..] - } - - /// Acciones específicas del tema antes de renderizar el `` de la página. - /// - /// Útil para preparar clases, inyectar recursos o ajustar metadatos. - #[allow(unused_variables)] - fn before_render_page_body(&self, page: &mut Page) {} - +/// - `::render_body(self, page, self.page_regions())` +/// - `::render_head(self, page)` +pub trait ThemePage { /// Renderiza el contenido del `` de la página. /// - /// Por defecto, recorre [`declared_regions()`](Self::declared_regions) **en el orden que se han - /// declarado** y, para cada región con contenido, genera un contenedor con `role="region"` y - /// `aria-label` localizado. - fn render_page_body(&self, page: &mut Page) -> Markup { + /// Recorre `regions` en el **orden declarado** y, para cada región con contenido, genera un + /// contenedor con `role="region"` y un `aria-label` localizado. Se asume que cada identificador + /// de región es **único** dentro de la página. + fn render_body(&self, page: &mut Page, regions: &[(Region, L10n)]) -> Markup { html! { body id=[page.body_id().get()] class=[page.body_classes().get()] { - @for (region, region_label) in self.declared_regions() { + @for (region, region_label) in regions { @let output = page.render_region(region.key()); @if !output.is_empty() { @let region_name = region.name(); @@ -96,7 +42,7 @@ pub trait Theme: Extension + Send + Sync { id=(region_name) class={ "region region--" (region_name) } role="region" - aria-label=[region_label.using(page)] + aria-label=[region_label.lookup(page)] { (output) } @@ -106,17 +52,12 @@ pub trait Theme: Extension + Send + Sync { } } - /// Acciones específicas del tema después de renderizar el `` de la página. - /// - /// Útil para *tracing*, métricas o ajustes finales del estado de la página. - #[allow(unused_variables)] - fn after_render_page_body(&self, page: &mut Page) {} - /// Renderiza el contenido del `` de la página. /// - /// Por defecto, genera las etiquetas básicas (`charset`, `title`, `description`, `viewport`, - /// `X-UA-Compatible`), los metadatos y propiedades de la página y los recursos (CSS/JS). - fn render_page_head(&self, page: &mut Page) -> Markup { + /// Por defecto incluye las etiquetas básicas (`charset`, `title`, `description`, `viewport`, + /// `X-UA-Compatible`), los metadatos (`name/content`) y propiedades (`property/content`), + /// además de los recursos CSS/JS de la página. + fn render_head(&self, page: &mut Page) -> Markup { let viewport = "width=device-width, initial-scale=1, shrink-to-fit=no"; html! { head { @@ -146,18 +87,115 @@ pub trait Theme: Extension + Send + Sync { } } } +} - /// Página de error "*403 – Forbidden*" predeterminada. +/// Interfaz común que debe implementar cualquier tema de PageTop. +/// +/// Un tema implementa [`Theme`] y los métodos necesarios de [`Extension`]. El único método +/// **obligatorio** de `Extension` para un tema es [`theme()`](Extension::theme). +/// +/// ```rust +/// use pagetop::prelude::*; +/// +/// pub struct MyTheme; +/// +/// impl Extension for MyTheme { +/// fn name(&self) -> L10n { +/// L10n::n("My theme") +/// } +/// +/// fn description(&self) -> L10n { +/// L10n::n("A personal theme") +/// } +/// +/// fn theme(&self) -> Option { +/// Some(&Self) +/// } +/// } +/// +/// impl Theme for MyTheme {} +/// ``` +pub trait Theme: Extension + ThemePage + Send + Sync { + /// **Obsoleto desde la versión 0.4.0**: usar [`page_regions()`](Self::page_regions) en su + /// lugar. + #[deprecated(since = "0.4.0", note = "Use `page_regions()` instead")] + fn regions(&self) -> Vec<(&'static str, L10n)> { + vec![("content", L10n::l("content"))] + } + + /// Declaración ordenada de las regiones disponibles en la página. + /// + /// Devuelve una **lista estática** de pares `(Region, L10n)` que se usará para renderizar en el + /// orden indicado todas las regiones que componen una página. + /// + /// Requisitos y recomendaciones: + /// + /// - Los identificadores deben ser **estables** (p. ej. `"sidebar-left"`, `"content"`). + /// - La región `"content"` es **obligatoria**. Se puede usar [`Region::default()`] para + /// declararla. + /// - La etiqueta `L10n` se evalúa con el idioma activo de la página. + /// + /// Si tu tema define un conjunto distinto, se puede **sobrescribir** este método. Por defecto + /// devuelve: + /// + /// - `"header"`: cabecera. + /// - `"content"`: contenido principal (**obligatoria**). + /// - `"footer"`: pie. + fn page_regions(&self) -> &'static [(Region, L10n)] { + static REGIONS: LazyLock<[(Region, L10n); 3]> = LazyLock::new(|| { + [ + (Region::declare("header"), L10n::l("region_header")), + (Region::default(), L10n::l("region_content")), + (Region::declare("footer"), L10n::l("region_footer")), + ] + }); + ®IONS[..] + } + + /// Acciones específicas del tema antes de renderizar el `` de la página. + /// + /// Útil para preparar clases, inyectar recursos o ajustar metadatos. + #[allow(unused_variables)] + fn before_render_page_body(&self, page: &mut Page) {} + + /// Renderiza el contenido del `` de la página. + /// + /// Si se sobrescribe este método, se puede volver al comportamiento base con: + /// `::render_body(self, page, self.page_regions())`. + #[inline] + fn render_page_body(&self, page: &mut Page) -> Markup { + ::render_body(self, page, self.page_regions()) + } + + /// Acciones específicas del tema después de renderizar el `` de la página. + /// + /// Útil para *tracing*, métricas o ajustes finales del estado de la página. + #[allow(unused_variables)] + fn after_render_page_body(&self, page: &mut Page) {} + + /// Renderiza el contenido del `` de la página. + /// + /// Si se sobrescribe este método, se puede volver al comportamiento base con: + /// `::render_head(self, page)`. + #[inline] + fn render_page_head(&self, page: &mut Page) -> Markup { + ::render_head(self, page) + } + + /// Contenido predeterminado para la página de error "*403 – Forbidden*". /// /// Se puede sobrescribir este método para personalizar y adaptar este contenido al tema. fn error403(&self, page: &mut Page) -> Markup { - html! { div { h1 { (L10n::l("error403_notice").to_markup(page)) } } } + html! { div { h1 { (L10n::l("error403_notice").using(page)) } } } } - /// Página de error "*404 – Not Found*" predeterminada. + /// Contenido predeterminado para la página de error "*404 – Not Found*". /// /// Se puede sobrescribir este método para personalizar y adaptar este contenido al tema. fn error404(&self, page: &mut Page) -> Markup { - html! { div { h1 { (L10n::l("error404_notice").to_markup(page)) } } } + html! { div { h1 { (L10n::l("error404_notice").using(page)) } } } } } + +/// Se implementa automáticamente `ThemePage` para cualquier tema. +impl ThemePage for T {} diff --git a/src/core/theme/regions.rs b/src/core/theme/regions.rs index 4fcd7df..1a2e0fb 100644 --- a/src/core/theme/regions.rs +++ b/src/core/theme/regions.rs @@ -25,7 +25,7 @@ pub const REGION_CONTENT: &str = "content"; /// (p.ej., clases `region__{name}`). /// /// Se utiliza para declarar las regiones que componen una página en un tema (ver -/// [`declared_regions()`](crate::core::theme::Theme::declared_regions)). +/// [`page_regions()`](crate::core::theme::Theme::page_regions)). pub struct Region { key: &'static str, name: String, diff --git a/src/locale/en-US/welcome.ftl b/src/locale/en-US/welcome.ftl index 7d98f44..7b7d74d 100644 --- a/src/locale/en-US/welcome.ftl +++ b/src/locale/en-US/welcome.ftl @@ -3,7 +3,6 @@ welcome_extension_description = Displays a landing page when none is configured. welcome_page = Welcome Page welcome_title = Hello world! -welcome_aria = Say hello to your { $app } installation welcome_intro = Discover⚡{ $app } welcome_powered = A web solution powered by PageTop! diff --git a/src/locale/es-ES/welcome.ftl b/src/locale/es-ES/welcome.ftl index 8a38425..7823832 100644 --- a/src/locale/es-ES/welcome.ftl +++ b/src/locale/es-ES/welcome.ftl @@ -3,7 +3,6 @@ welcome_extension_description = Muestra una página de inicio predeterminada cua welcome_page = Página de Bienvenida welcome_title = ¡Hola mundo! -welcome_aria = Saluda a tu instalación { $app } welcome_intro = Descubre⚡{ $app } welcome_powered = Una solución web creada con PageTop! diff --git a/static/css/welcome.css b/static/css/intro.css similarity index 78% rename from static/css/welcome.css rename to static/css/intro.css index 4ce8046..5a5461e 100644 --- a/static/css/welcome.css +++ b/static/css/intro.css @@ -1,8 +1,8 @@ :root { - --bg-img: url('/img/welcome-header.jpg'); - --bg-img-set: image-set(url('/img/welcome-header.avif') type('image/avif'), url('/img/welcome-header.webp') type('image/webp'), var(--bg-img) type('image/jpeg')); - --bg-img-sm: url('/img/welcome-header-sm.jpg'); - --bg-img-sm-set: image-set(url('/img/welcome-header-sm.avif') type('image/avif'), url('/img/welcome-header-sm.webp') type('image/webp'), var(--bg-img-sm) type('image/jpeg')); + --bg-img: url('/img/intro-header.jpg'); + --bg-img-set: image-set(url('/img/intro-header.avif') type('image/avif'), url('/img/intro-header.webp') type('image/webp'), var(--bg-img) type('image/jpeg')); + --bg-img-sm: url('/img/intro-header-sm.jpg'); + --bg-img-sm-set: image-set(url('/img/intro-header-sm.avif') type('image/avif'), url('/img/intro-header-sm.webp') type('image/webp'), var(--bg-img-sm) type('image/jpeg')); --bg-color: #8c5919; --color: #1a202c; --color-red: #fecaca; @@ -28,9 +28,14 @@ body { font-weight: 300; color: var(--color); line-height: 1.6; + + width: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; } -header, section { position: relative; text-align: center; @@ -50,19 +55,11 @@ a:hover:visited { text-decoration-color: var(--color-link); } -#content { - width: 100%; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -} - /* - * Region header + * Header */ -.welcome-header { +.intro-header { display: flex; flex-direction: column-reverse; width: 100%; @@ -76,11 +73,11 @@ a:hover:visited { background-size: contain; background-repeat: no-repeat; } -.welcome-header__body { +.intro-header__body { padding: 0; background: none; } -.welcome-header__title { +.intro-header__title { margin: 0 0 0 1.5rem; text-align: left; display: flex; @@ -94,7 +91,7 @@ a:hover:visited { line-height: 110%; text-shadow: 0 0.125rem 0.1875rem rgba(0, 0, 0, 0.3); } -.welcome-header__title > span { +.intro-header__title > span { background: linear-gradient(180deg, #ddff95 30%, #ffb84b 100%); background-clip: text; -webkit-background-clip: text; @@ -105,44 +102,44 @@ a:hover:visited { line-height: 110%; text-shadow: none; } -.welcome-header__image { +.intro-header__image { display: flex; justify-content: flex-start; text-align: right; width: 100%; } -.welcome-header__monster { +.intro-header__monster { margin-right: 12rem; margin-top: 1rem; flex-shrink: 1; } @media (min-width: 64rem) { - .welcome-header { + .intro-header { background-image: var(--bg-img); background-image: var(--bg-img-set); } - .welcome-header__title { + .intro-header__title { padding: 1.2rem 2rem 2.6rem 2rem; } - .welcome-header__image { + .intro-header__image { justify-content: flex-end; } } /* - * Region content + * Content */ -.welcome-content { +.intro-content { height: auto; margin-top: 1.6rem; } -.welcome-content__body { +.intro-content__body { box-sizing: border-box; max-width: 80rem; } -.welcome-content__body:before, -.welcome-content__body:after { +.intro-content__body:before, +.intro-content__body:after { content: ''; position: absolute; left: 0; @@ -152,38 +149,37 @@ a:hover:visited { filter: blur(2.75rem); opacity: 0.8; inset: 11.75rem; - /*z-index: 0;*/ } -.welcome-content__body:before { +.intro-content__body:before { top: -1rem; } -.welcome-content__body:after { +.intro-content__body:after { bottom: -1rem; } @media (max-width: 48rem) { - .welcome-content__body { + .intro-content__body { margin-top: -9.8rem; } - .welcome-content__body:before, - .welcome-content__body:after { + .intro-content__body:before, + .intro-content__body:after { inset: unset; } } @media (min-width: 64rem) { - .welcome-content { + .intro-content { margin-top: 0; } - .welcome-content__body { + .intro-content__body { margin-top: -5.7rem; } } -.welcome-poweredby { +.intro-button { width: 100%; margin: 0 auto 3rem; z-index: 10; } -.welcome-poweredby__link { +.intro-button__link { background: #7f1d1d; background-image: linear-gradient(to bottom, rgba(255,0,0,0.8), rgba(255,255,255,0)); background-position: top left, center; @@ -196,7 +192,6 @@ a:hover:visited { font-size: 1.5rem; line-height: 1.3; text-decoration: none; - /*text-shadow: var(--shadow);*/ transition: transform 0.3s ease-in-out; position: relative; overflow: hidden; @@ -204,7 +199,7 @@ a:hover:visited { min-height: 7.6875rem; outline: none; } -.welcome-poweredby__link::before { +.intro-button__link::before { content: ''; position: absolute; top: -13.125rem; @@ -216,7 +211,7 @@ a:hover:visited { transition: transform 0.3s ease-in-out; z-index: 5; } -.welcome-poweredby__text { +.intro-button__text { display: flex; flex-direction: column; flex: 1; @@ -226,25 +221,24 @@ a:hover:visited { padding: 1rem 1.5rem; text-align: left; color: white; - /*text-shadow: 0 0.101125rem 0.2021875rem rgba(0, 0, 0, 0.25);*/ font-size: 1.65rem; font-style: normal; font-weight: 600; line-height: 130.023%; letter-spacing: 0.0075rem; } -.welcome-poweredby__text strong { +.intro-button__text strong { font-size: 2.625rem; font-weight: 600; line-height: 130.023%; letter-spacing: 0.013125rem; } -.welcome-poweredby__link span { +.intro-button__link span { position: absolute; display: block; pointer-events: none; } -.welcome-poweredby__link span:nth-child(1) { +.intro-button__link span:nth-child(1) { height: 8px; width: 100%; top: 0; @@ -264,7 +258,7 @@ a:hover:visited { transform: translateX(100%); } } -.welcome-poweredby__link span:nth-child(2) { +.intro-button__link span:nth-child(2) { width: 8px; height: 100%; top: 0; @@ -284,7 +278,7 @@ a:hover:visited { transform: translateY(100%); } } -.welcome-poweredby__link span:nth-child(3) { +.intro-button__link span:nth-child(3) { height: 8px; width: 100%; bottom: 0; @@ -304,22 +298,22 @@ a:hover:visited { transform: translateX(-100%); } } -.welcome-poweredby__link:hover span { +.intro-button__link:hover span { animation-play-state: paused; } @media (max-width: 48rem) { - .welcome-poweredby__link { + .intro-button__link { height: 6.25rem; min-width: auto; border-radius: 0; } - .welcome-poweredby__text { + .intro-button__text { display: inline; padding-top: .5rem; } } @media (min-width: 48rem) { - .welcome-poweredby { + .intro-button { position: absolute; top: 0; left: 50%; @@ -327,14 +321,13 @@ a:hover:visited { max-width: 29.375rem; margin-bottom: 0; } - .welcome-poweredby__link:hover { + .intro-button__link:hover { transition: all .5s; transform: rotate(-3deg) scale(1.1); - /*box-shadow: 0px 3px 5px rgba(0,0,0,.4);*/ } } -.welcome-text { +.intro-text { z-index: 1; width: 100%; display: flex; @@ -346,13 +339,16 @@ a:hover:visited { font-weight: 400; line-height: 1.5; margin-top: -6rem; - background: #fff; margin-bottom: 0; + background: #fff; position: relative; - padding: 6rem 1.063rem 0.75rem; + padding: 2.5rem 1.063rem 0.75rem; overflow: hidden; } -.welcome-text p { +.intro-button + .intro-text { + padding-top: 6rem; +} +.intro-text p { width: 100%; line-height: 150%; font-weight: 400; @@ -360,14 +356,16 @@ a:hover:visited { margin: 0 0 1.5rem; } @media (min-width: 48rem) { - .welcome-text { + .intro-text { font-size: 1.375rem; line-height: 2rem; + } + .intro-button + .intro-text { padding-top: 7rem; } } @media (min-width: 64rem) { - .welcome-text { + .intro-text { border-radius: 0.75rem; box-shadow: var(--shadow); max-width: 60rem; @@ -377,13 +375,13 @@ a:hover:visited { } } -.welcome-text__block { +.intro-text .block { position: relative; } -.welcome-text__block h2 { +.intro-text .block__title { margin: 1em 0 .8em; } -.welcome-text__block h2 span { +.intro-text .block__title span { display: inline-block; padding: 10px 30px 14px; margin: 0 0 0 20px; @@ -394,7 +392,7 @@ a:hover:visited { border-color: orangered; transform: rotate(-3deg) translateY(-25%); } -.welcome-text__block h2:before { +.intro-text .block__title:before { content: ""; height: 5px; position: absolute; @@ -407,7 +405,7 @@ a:hover:visited { transform: rotate(2deg) translateY(-50%); transform-origin: top left; } -.welcome-text__block h2:after { +.intro-text .block__title:after { content: ""; height: 70rem; position: absolute; @@ -420,15 +418,17 @@ a:hover:visited { } /* - * Region footer + * Footer */ -.region--footer { +.intro-footer { + width: 100%; background-color: black; color: var(--color-gray); + padding-bottom: 2rem; } -.welcome-footer { +.intro-footer__body { display: flex; justify-content: center; flex-direction: column; @@ -439,33 +439,33 @@ a:hover:visited { font-weight: 300; line-height: 100%; } -.welcome-footer a:visited { +.intro-footer__body a:visited { color: var(--color-gray); } -.welcome-footer__logo, -.welcome-footer__links { +.intro-footer__logo, +.intro-footer__links { display: flex; justify-content: center; width: 100%; } -.welcome-footer__logo { +.intro-footer__logo { max-height: 12.625rem; } -.welcome-footer__logo svg { +.intro-footer__logo svg { width: 100%; } -.welcome-footer__links { +.intro-footer__links { gap: 1.875rem; flex-wrap: wrap; margin-top: 2rem; } @media (max-width: 48rem) { - .welcome-footer__logo { + .intro-footer__logo { display: none; } } @media (max-width: 64rem) { - .welcome-footer { + .intro-footer__body { padding: 0 1rem 2rem; } } diff --git a/static/img/welcome-header-sm.avif b/static/img/intro-header-sm.avif similarity index 100% rename from static/img/welcome-header-sm.avif rename to static/img/intro-header-sm.avif diff --git a/static/img/welcome-header-sm.jpg b/static/img/intro-header-sm.jpg similarity index 100% rename from static/img/welcome-header-sm.jpg rename to static/img/intro-header-sm.jpg diff --git a/static/img/welcome-header-sm.webp b/static/img/intro-header-sm.webp similarity index 100% rename from static/img/welcome-header-sm.webp rename to static/img/intro-header-sm.webp diff --git a/static/img/welcome-header.avif b/static/img/intro-header.avif similarity index 100% rename from static/img/welcome-header.avif rename to static/img/intro-header.avif diff --git a/static/img/welcome-header.jpg b/static/img/intro-header.jpg similarity index 100% rename from static/img/welcome-header.jpg rename to static/img/intro-header.jpg diff --git a/static/img/welcome-header.webp b/static/img/intro-header.webp similarity index 100% rename from static/img/welcome-header.webp rename to static/img/intro-header.webp From 3bf058b8a54f66f6fcc3dc9c940bf7ea9c190013 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sat, 6 Sep 2025 09:41:01 +0200 Subject: [PATCH 23/35] =?UTF-8?q?=F0=9F=9A=A7=20[context]=20Generaliza=20l?= =?UTF-8?q?os=20par=C3=A1metros=20de=20contexto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/base/component/html.rs | 2 +- src/html/context.rs | 181 ++++++++++++++++++++++++++++++------- src/response/page.rs | 21 ++++- tests/component_html.rs | 2 +- 4 files changed, 168 insertions(+), 38 deletions(-) diff --git a/src/base/component/html.rs b/src/base/component/html.rs index cac39ea..7bde94a 100644 --- a/src/base/component/html.rs +++ b/src/base/component/html.rs @@ -25,7 +25,7 @@ use crate::prelude::*; /// use pagetop::prelude::*; /// /// let component = Html::with(|cx| { -/// let user = cx.get_param::("username").unwrap_or(String::from("visitor")); +/// let user = cx.param::("username").cloned().unwrap_or(String::from("visitor")); /// html! { /// h1 { "Hello, " (user) } /// } diff --git a/src/html/context.rs b/src/html/context.rs index 8b7afba..4ebd510 100644 --- a/src/html/context.rs +++ b/src/html/context.rs @@ -7,10 +7,8 @@ use crate::locale::{LangId, LangMatch, LanguageIdentifier, DEFAULT_LANGID, FALLB use crate::service::HttpRequest; use crate::{builder_fn, join}; +use std::any::Any; use std::collections::HashMap; -use std::error::Error; -use std::fmt::{self, Display}; -use std::str::FromStr; /// Operaciones para modificar el contexto ([`Context`]) del documento. pub enum AssetsOp { @@ -33,32 +31,28 @@ pub enum AssetsOp { RemoveJavaScript(&'static str), } -/// Errores de lectura o conversión de parámetros almacenados en el contexto. +/// Errores de acceso a parámetros dinámicos del contexto. +/// +/// - [`ErrorParam::NotFound`]: la clave no existe. +/// - [`ErrorParam::TypeMismatch`]: la clave existe, pero el valor guardado no coincide con el tipo +/// solicitado. Incluye nombre de la clave (`key`), tipo esperado (`expected`) y tipo realmente +/// guardado (`saved`) para facilitar el diagnóstico. #[derive(Debug)] pub enum ErrorParam { - /// El parámetro solicitado no existe. NotFound, - /// El valor del parámetro no pudo convertirse al tipo requerido. - ParseError(String), + TypeMismatch { + key: &'static str, + expected: &'static str, + saved: &'static str, + }, } -impl Display for ErrorParam { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ErrorParam::NotFound => write!(f, "Parameter not found"), - ErrorParam::ParseError(e) => write!(f, "Parse error: {e}"), - } - } -} - -impl Error for ErrorParam {} - /// Representa el contexto de un documento HTML. /// /// Se crea internamente para manejar información relevante del documento, como la solicitud HTTP de /// origen, el idioma, tema y composición para el renderizado, los recursos *favicon* ([`Favicon`]), -/// hojas de estilo ([`StyleSheet`]) y *scripts* ([`JavaScript`]), así como parámetros de contexto -/// definidos en tiempo de ejecución. +/// hojas de estilo ([`StyleSheet`]) y *scripts* ([`JavaScript`]), así como *parámetros dinámicos +/// heterogéneos* de contexto definidos en tiempo de ejecución. /// /// # Ejemplos /// @@ -95,7 +89,7 @@ impl Error for ErrorParam {} /// assert_eq!(active_theme.short_name(), "aliner"); /// /// // Recupera el parámetro a su tipo original. -/// let id: i32 = cx.get_param("usuario_id").unwrap(); +/// let id: i32 = *cx.get_param::("usuario_id").unwrap(); /// assert_eq!(id, 42); /// /// // Genera un identificador para un componente de tipo `Menu`. @@ -113,7 +107,7 @@ pub struct Context { favicon : Option, // Favicon, si se ha definido. stylesheets: Assets, // Hojas de estilo CSS. javascripts: Assets, // Scripts JavaScript. - params : HashMap<&'static str, String>, // Parámetros definidos en tiempo de ejecución. + params : HashMap<&'static str, (Box, &'static str)>, // Parámetros definidos en tiempo de ejecución. id_counter : usize, // Contador para generar identificadores únicos. } @@ -152,7 +146,7 @@ impl Context { favicon : None, stylesheets: Assets::::new(), javascripts: Assets::::new(), - params : HashMap::<&str, String>::new(), + params : HashMap::default(), id_counter : 0, } } @@ -246,29 +240,146 @@ impl Context { // Context PARAMS ****************************************************************************** - /// Añade o modifica un parámetro del contexto almacenando el valor como [`String`]. + /// Añade o modifica un parámetro dinámico del contexto. + /// + /// El valor se guarda conservando el *nombre del tipo* real para mejorar los mensajes de error + /// posteriores. + /// + /// # Ejemplos + /// + /// ```rust + /// use pagetop::prelude::*; + /// + /// let cx = Context::new(None) + /// .with_param("usuario_id", 42_i32) + /// .with_param("titulo", String::from("Hola")) + /// .with_param("flags", vec!["a", "b"]); + /// ``` #[builder_fn] - pub fn with_param(mut self, key: &'static str, value: T) -> Self { - self.params.insert(key, value.to_string()); + pub fn with_param(mut self, key: &'static str, value: T) -> Self { + let type_name = TypeInfo::FullName.of::(); + self.params.insert(key, (Box::new(value), type_name)); self } - /// Recupera un parámetro del contexto convertido al tipo especificado. + /// Recupera un parámetro como [`Option`], simplificando el acceso. /// - /// Devuelve un error si el parámetro no existe ([`ErrorParam::NotFound`]) o la conversión falla - /// ([`ErrorParam::ParseError`]). - pub fn get_param(&self, key: &'static str) -> Result { - self.params - .get(key) - .ok_or(ErrorParam::NotFound) - .and_then(|v| T::from_str(v).map_err(|_| ErrorParam::ParseError(v.clone()))) + /// A diferencia de [`get_param`](Self::get_param), que devuelve un [`Result`] con información + /// detallada de error, este método devuelve `None` tanto si la clave no existe como si el valor + /// guardado no coincide con el tipo solicitado. + /// + /// Resulta útil en escenarios donde sólo interesa saber si el valor existe y es del tipo + /// correcto, sin necesidad de diferenciar entre error de ausencia o de tipo. + /// + /// # Ejemplo + /// + /// ```rust + /// use pagetop::prelude::*; + /// + /// let cx = Context::new(None).with_param("username", String::from("Alice")); + /// + /// // Devuelve Some(&String) si existe y coincide el tipo. + /// assert_eq!(cx.param::("username").map(|s| s.as_str()), Some("Alice")); + /// + /// // Devuelve None si no existe o si el tipo no coincide. + /// assert!(cx.param::("username").is_none()); + /// assert!(cx.param::("missing").is_none()); + /// + /// // Acceso con valor por defecto. + /// let user = cx.param::("missing") + /// .cloned() + /// .unwrap_or_else(|| "visitor".to_string()); + /// assert_eq!(user, "visitor"); + /// ``` + pub fn param(&self, key: &'static str) -> Option<&T> { + self.get_param::(key).ok() } - /// Elimina un parámetro del contexto. Devuelve `true` si existía y se eliminó. + /// Recupera una *referencia tipada* al parámetro solicitado. + /// + /// Devuelve: + /// + /// - `Ok(&T)` si la clave existe y el tipo coincide. + /// - `Err(ErrorParam::NotFound)` si la clave no existe. + /// - `Err(ErrorParam::TypeMismatch)` si la clave existe pero el tipo no coincide. + /// + /// # Ejemplos + /// + /// ```rust + /// use pagetop::prelude::*; + /// + /// let cx = Context::new(None) + /// .with_param("usuario_id", 42_i32) + /// .with_param("titulo", String::from("Hola")); + /// + /// let id: &i32 = cx.get_param("usuario_id").unwrap(); + /// let titulo: &String = cx.get_param("titulo").unwrap(); + /// + /// // Error de tipo: + /// assert!(cx.get_param::("usuario_id").is_err()); + /// ``` + pub fn get_param(&self, key: &'static str) -> Result<&T, ErrorParam> { + let (any, type_name) = self.params.get(key).ok_or(ErrorParam::NotFound)?; + any.downcast_ref::() + .ok_or_else(|| ErrorParam::TypeMismatch { + key, + expected: TypeInfo::FullName.of::(), + saved: *type_name, + }) + } + + /// Elimina un parámetro del contexto. Devuelve `true` si la clave existía y se eliminó. + /// + /// Devuelve `false` en caso contrario. Usar cuando solo interesa borrar la entrada. + /// + /// # Ejemplos + /// + /// ```rust + /// use pagetop::prelude::*; + /// + /// let mut cx = Context::new(None).with_param("temp", 1u8); + /// assert!(cx.remove_param("temp")); + /// assert!(!cx.remove_param("temp")); // ya no existe + /// ``` pub fn remove_param(&mut self, key: &'static str) -> bool { self.params.remove(key).is_some() } + /// Recupera el parámetro solicitado y lo elimina del contexto. + /// + /// Devuelve: + /// + /// - `Ok(T)` si la clave existía y el tipo coincide. + /// - `Err(ErrorParam::NotFound)` si la clave no existe. + /// - `Err(ErrorParam::TypeMismatch)` si el tipo no coincide. + /// + /// # Ejemplos + /// + /// ```rust + /// use pagetop::prelude::*; + /// + /// let mut cx = Context::new(None) + /// .with_param("contador", 7_i32) + /// .with_param("titulo", String::from("Hola")); + /// + /// let n: i32 = cx.take_param("contador").unwrap(); + /// assert!(cx.get_param::("contador").is_err()); // ya no está + /// + /// // Error de tipo: + /// assert!(cx.take_param::("titulo").is_err()); + /// ``` + pub fn take_param(&mut self, key: &'static str) -> Result { + let (boxed, saved) = self.params.remove(key).ok_or(ErrorParam::NotFound)?; + boxed + .downcast::() + .map(|b| *b) + .map_err(|_| ErrorParam::TypeMismatch { + key, + expected: TypeInfo::FullName.of::(), + saved, + }) + } + // Context EXTRAS ****************************************************************************** /// Genera un identificador único si no se proporciona uno explícito. diff --git a/src/response/page.rs b/src/response/page.rs index 5ac3720..0942f8c 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -108,6 +108,12 @@ impl Page { self } + #[builder_fn] + pub fn with_param(mut self, key: &'static str, value: T) -> Self { + self.context.alter_param(key, value); + self + } + /// Establece el atributo `id` del elemento ``. #[builder_fn] pub fn with_body_id(mut self, id: impl AsRef) -> Self { @@ -214,6 +220,10 @@ impl Page { self.context.layout() } + pub fn param(&self, key: &'static str) -> Option<&T> { + self.context.param(key) + } + /// Devuelve el identificador del elemento ``. pub fn body_id(&self) -> &AttrId { &self.body_id @@ -223,7 +233,16 @@ impl Page { pub fn body_classes(&self) -> &AttrClasses { &self.body_classes } - + /* + /// Devuelve una referencia mutable al [`Context`] de la página. + /// + /// El [`Context`] actúa como intermediario para muchos métodos de `Page` (idioma, tema, + /// *layout*, recursos, solicitud HTTP, etc.). Resulta especialmente útil cuando un componente + /// o un tema necesita recibir el contexto como parámetro. + pub fn context(&mut self) -> &mut Context { + &mut self.context + } + */ // Page RENDER ********************************************************************************* /// Renderiza los componentes de una región (`regiona_name`) de la página. diff --git a/tests/component_html.rs b/tests/component_html.rs index b9b8e5e..bd7f3c0 100644 --- a/tests/component_html.rs +++ b/tests/component_html.rs @@ -20,7 +20,7 @@ async fn component_html_renders_using_context_param() { let mut cx = Context::new(None).with_param("username", String::from("Alice")); let component = Html::with(|cx| { - let name = cx.get_param::("username").unwrap_or_default(); + let name = cx.param::("username").cloned().unwrap_or_default(); html! { span { (name) } } From f182eb31783fc1c937b5af57edbb0debbfb3ae2b Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 7 Sep 2025 21:06:41 +0200 Subject: [PATCH 24/35] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20[macros]=20Majora=20?= =?UTF-8?q?la=20validaci=C3=B3n=20de=20`builder=5Ffn`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/pagetop-macros/src/lib.rs | 260 +++++++++++++++++++++--------- 1 file changed, 181 insertions(+), 79 deletions(-) diff --git a/helpers/pagetop-macros/src/lib.rs b/helpers/pagetop-macros/src/lib.rs index 0a60d53..28c6b1b 100644 --- a/helpers/pagetop-macros/src/lib.rs +++ b/helpers/pagetop-macros/src/lib.rs @@ -39,7 +39,7 @@ mod smart_default; use proc_macro::TokenStream; use quote::{quote, quote_spanned}; -use syn::{parse_macro_input, spanned::Spanned, DeriveInput, ItemFn}; +use syn::{parse_macro_input, spanned::Spanned, DeriveInput}; /// Macro para escribir plantillas HTML (basada en [Maud](https://docs.rs/maud)). #[proc_macro] @@ -107,114 +107,216 @@ pub fn derive_auto_default(input: TokenStream) -> TokenStream { /// `alter_...()`, que permitirá más adelante modificar instancias existentes. #[proc_macro_attribute] pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream { - let fn_with = parse_macro_input!(item as ItemFn); - let fn_with_name = fn_with.sig.ident.clone(); - let fn_with_name_str = fn_with.sig.ident.to_string(); + use syn::{parse2, FnArg, Ident, ImplItemFn, Pat, ReturnType, TraitItemFn, Type}; + + let ts: proc_macro2::TokenStream = item.clone().into(); + + enum Kind { + Impl(ImplItemFn), + Trait(TraitItemFn), + } + + // Detecta si estamos en `impl` o `trait`. + let kind = if let Ok(it) = parse2::(ts.clone()) { + Kind::Impl(it) + } else if let Ok(tt) = parse2::(ts.clone()) { + Kind::Trait(tt) + } else { + return quote! { + compile_error!("#[builder_fn] only supports methods in `impl` blocks or `trait` items"); + } + .into(); + }; + + // Extrae piezas comunes (sig, attrs, vis, bloque?, es_trait?). + let (sig, attrs, vis, body_opt, is_trait) = match &kind { + Kind::Impl(m) => (&m.sig, &m.attrs, Some(&m.vis), Some(&m.block), false), + Kind::Trait(t) => (&t.sig, &t.attrs, None, t.default.as_ref(), true), + }; + + let with_name = sig.ident.clone(); + let with_name_str = sig.ident.to_string(); // Valida el nombre del método. - if !fn_with_name_str.starts_with("with_") { - let expanded = quote_spanned! { - fn_with.sig.ident.span() => - compile_error!("expected a \"pub fn with_...(mut self, ...) -> Self\" method"); - }; - return expanded.into(); - } - // Valida que el método es público. - if !matches!(fn_with.vis, syn::Visibility::Public(_)) { + if !with_name_str.starts_with("with_") { return quote_spanned! { - fn_with.sig.ident.span() => compile_error!("expected method to be `pub`"); + sig.ident.span() => compile_error!("expected a named `with_...()` method"); } .into(); } - // Valida que el primer argumento es exactamente `mut self`. - if let Some(syn::FnArg::Receiver(receiver)) = fn_with.sig.inputs.first() { - if receiver.mutability.is_none() || receiver.reference.is_some() { - return quote_spanned! { - receiver.span() => compile_error!("expected `mut self` as the first argument"); + + // Sólo se exige `pub` en `impl` (en `trait` no aplica). + let vis_pub = match (is_trait, vis) { + (false, Some(v)) => quote! { #v }, + _ => quote! {}, + }; + + // Validaciones comunes. + if sig.asyncness.is_some() { + return quote_spanned! { + sig.asyncness.span() => compile_error!("`with_...()` cannot be `async`"); + } + .into(); + } + if sig.constness.is_some() { + return quote_spanned! { + sig.constness.span() => compile_error!("`with_...()` cannot be `const`"); + } + .into(); + } + if sig.abi.is_some() { + return quote_spanned! { + sig.abi.span() => compile_error!("`with_...()` cannot be `extern`"); + } + .into(); + } + if sig.unsafety.is_some() { + return quote_spanned! { + sig.unsafety.span() => compile_error!("`with_...()` cannot be `unsafe`"); + } + .into(); + } + + // En `impl` se exige exactamente `mut self`; y en `trait` se exige `self` (sin &). + let receiver_ok = match sig.inputs.first() { + Some(FnArg::Receiver(r)) => { + // Rechaza `self: SomeType`. + if r.colon_token.is_some() { + false + } else if is_trait { + // Exactamente `self` (sin &, sin mut). + r.reference.is_none() && r.mutability.is_none() + } else { + // Exactamente `mut self`. + r.reference.is_none() && r.mutability.is_some() } - .into(); } - } else { + _ => false, + }; + if !receiver_ok { + let msg = if is_trait { + "expected `self` (not `mut self`, `&self` or `&mut self`) in trait method" + } else { + "expected first argument to be exactly `mut self`" + }; + let err = sig + .inputs + .first() + .map(|a| a.span()) + .unwrap_or(sig.ident.span()); return quote_spanned! { - fn_with.sig.ident.span() => compile_error!("expected `mut self` as the first argument"); + err => compile_error!(#msg); } .into(); } + // Valida que el método devuelve exactamente `Self`. - if let syn::ReturnType::Type(_, ty) = &fn_with.sig.output { - if let syn::Type::Path(type_path) = ty.as_ref() { - if type_path.qself.is_some() || !type_path.path.is_ident("Self") { - return quote_spanned! { ty.span() => - compile_error!("expected return type to be exactly `Self`"); + match &sig.output { + ReturnType::Type(_, ty) => match ty.as_ref() { + Type::Path(p) if p.qself.is_none() && p.path.is_ident("Self") => {} + _ => { + return quote_spanned! { + ty.span() => compile_error!("expected return type to be exactly `Self`"); } .into(); } - } else { - return quote_spanned! { ty.span() => - compile_error!("expected return type to be exactly `Self`"); + }, + _ => { + return quote_spanned! { + sig.output.span() => compile_error!("expected return type to be exactly `Self`"); } .into(); } - } else { - return quote_spanned! { - fn_with.sig.output.span() => compile_error!("expected method to return `Self`"); - } - .into(); } // Genera el nombre del método alter_...(). - let fn_alter_name_str = fn_with_name_str.replace("with_", "alter_"); - let fn_alter_name = syn::Ident::new(&fn_alter_name_str, fn_with.sig.ident.span()); + let stem = with_name_str.strip_prefix("with_").expect("validated"); + let alter_ident = Ident::new(&format!("alter_{stem}"), with_name.span()); // Extrae genéricos y cláusulas where. - let fn_generics = &fn_with.sig.generics; - let where_clause = &fn_with.sig.generics.where_clause; + let generics = &sig.generics; + let where_clause = &sig.generics.where_clause; - // Extrae argumentos y parámetros de llamada. - let args: Vec<_> = fn_with.sig.inputs.iter().skip(1).collect(); - let params: Vec<_> = fn_with - .sig - .inputs + // Extrae identificadores de los argumentos para la llamada (sin `mut` ni patrones complejos). + let args: Vec<_> = sig.inputs.iter().skip(1).collect(); + let call_idents: Vec = { + let mut v = Vec::new(); + for arg in sig.inputs.iter().skip(1) { + match arg { + FnArg::Typed(pat) => { + if let Pat::Ident(pat_ident) = pat.pat.as_ref() { + v.push(pat_ident.ident.clone()); + } else { + return quote_spanned! { + pat.pat.span() => compile_error!( + "each parameter must be a simple identifier, e.g. `value: T`" + ); + } + .into(); + } + } + _ => { + return quote_spanned! { + arg.span() => compile_error!("unexpected receiver in parameter list"); + } + .into(); + } + } + } + v + }; + + // Extrae atributos descartando la documentación para incluir en `alter_...()`. + let non_doc_attrs: Vec<_> = attrs .iter() - .skip(1) - .map(|arg| match arg { - syn::FnArg::Typed(pat) => &pat.pat, - _ => panic!("unexpected argument type"), - }) + .cloned() + .filter(|a| !a.path().is_ident("doc")) .collect(); - // Extrae bloque del método. - let fn_with_block = &fn_with.block; - - // Extrae documentación y otros atributos del método. - let fn_with_attrs = &fn_with.attrs; - - // Genera el método alter_...() con el código del método with_...(). - let fn_alter_doc = - format!("Equivalente a [`Self::{fn_with_name_str}()`], pero sin usar el patrón *builder*."); - - let fn_alter = quote! { - #[doc = #fn_alter_doc] - pub fn #fn_alter_name #fn_generics(&mut self, #(#args),*) -> &mut Self #where_clause { - #fn_with_block - } - }; - - // Redefine el método with_...() para que llame a alter_...(). - let fn_with = quote! { - #(#fn_with_attrs)* - #[inline] - pub fn #fn_with_name #fn_generics(mut self, #(#args),*) -> Self #where_clause { - self.#fn_alter_name(#(#params),*); - self - } - }; + // Documentación del método alter_...(). + let alter_doc = + format!("Equivalente a [`Self::{with_name_str}()`], pero fuera del patrón *builder*."); // Genera el código final. - let expanded = quote! { - #fn_with - #[inline] - #fn_alter + let expanded = match body_opt { + None => { + quote! { + #(#attrs)* + fn #with_name #generics (self, #(#args),*) -> Self #where_clause; + + #(#non_doc_attrs)* + #[doc = #alter_doc] + fn #alter_ident #generics (&mut self, #(#args),*) -> &mut Self #where_clause; + } + } + Some(body) => { + let with_fn = if is_trait { + quote! { + #vis_pub fn #with_name #generics (self, #(#args),*) -> Self #where_clause { + let mut s = self; + s.#alter_ident(#(#call_idents),*); + s + } + } + } else { + quote! { + #vis_pub fn #with_name #generics (mut self, #(#args),*) -> Self #where_clause { + self.#alter_ident(#(#call_idents),*); + self + } + } + }; + quote! { + #(#attrs)* + #with_fn + + #(#non_doc_attrs)* + #[doc = #alter_doc] + #vis_pub fn #alter_ident #generics (&mut self, #(#args),*) -> &mut Self #where_clause { + #body + } + } + } }; expanded.into() } From 1af3776a505b0492e67ebb8e8d27fdd96f61b513 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 7 Sep 2025 21:06:50 +0200 Subject: [PATCH 25/35] =?UTF-8?q?=F0=9F=9A=A7=20[context]=20Define=20un=20?= =?UTF-8?q?`trait`=20com=C3=BAn=20de=20contexto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/global.rs | 13 +- src/html.rs | 4 +- src/html/assets.rs | 16 +- src/html/assets/javascript.rs | 4 +- src/html/assets/stylesheet.rs | 4 +- src/html/context.rs | 416 ++++++++++++++++++++-------------- src/response/page.rs | 158 +++++++------ src/response/page/error.rs | 1 + 8 files changed, 356 insertions(+), 260 deletions(-) diff --git a/src/global.rs b/src/global.rs index 6be0774..c81eec9 100644 --- a/src/global.rs +++ b/src/global.rs @@ -48,14 +48,13 @@ pub struct App { pub description: String, /// Tema predeterminado. pub theme: String, - /// Idioma por defecto para la aplicación. + /// Idioma por defecto de la aplicación. /// - /// Si no se especifica un valor válido, normalmente se usará el idioma devuelto por la - /// implementación de [`LangId`](crate::locale::LangId) para [`Context`](crate::html::Context), - /// en el siguiente orden: primero, el idioma establecido explícitamente con - /// [`Context::with_langid()`](crate::html::Context::with_langid); si no se ha definido, se - /// usará el indicado en la cabecera `Accept-Language` del navegador; y, si ninguno aplica, se - /// empleará el idioma de respaldo ("en-US"). + /// Si este valor no es válido, el idioma efectivo para el renderizado se resolverá mediante la + /// implementación de [`LangId`](crate::locale::LangId) en este orden: primero, el establecido + /// explícitamente con [`Contextual::with_langid()`](crate::html::Contextual::with_langid); si + /// no se ha definido, se usará el indicado en la cabecera `Accept-Language` del navegador; y, + /// si ninguno aplica, se empleará el idioma de respaldo ("en-US"). pub language: String, /// Banner ASCII mostrado al inicio: *"Off"* (desactivado), *"Slant"*, *"Small"*, *"Speed"* o /// *"Starwars"*. diff --git a/src/html.rs b/src/html.rs index 784457e..9f3d70c 100644 --- a/src/html.rs +++ b/src/html.rs @@ -9,12 +9,12 @@ mod assets; pub use assets::favicon::Favicon; pub use assets::javascript::JavaScript; pub use assets::stylesheet::{StyleSheet, TargetMedia}; -pub(crate) use assets::Assets; +pub use assets::{Asset, Assets}; // HTML DOCUMENT CONTEXT *************************************************************************** mod context; -pub use context::{AssetsOp, Context, ErrorParam}; +pub use context::{AssetsOp, Context, Contextual, ErrorParam}; // HTML ATTRIBUTES ********************************************************************************* diff --git a/src/html/assets.rs b/src/html/assets.rs index 894b7e8..e53e8e3 100644 --- a/src/html/assets.rs +++ b/src/html/assets.rs @@ -5,20 +5,20 @@ pub mod stylesheet; use crate::html::{html, Markup, Render}; use crate::{AutoDefault, Weight}; -pub trait AssetsTrait: Render { - // Devuelve el nombre del recurso, utilizado como clave única. +pub trait Asset: Render { + /// Devuelve el nombre del recurso, utilizado como clave única. fn name(&self) -> &str; - // Devuelve el peso del recurso, durante el renderizado se procesan de menor a mayor peso. + /// Devuelve el peso del recurso, durante el renderizado se procesan de menor a mayor peso. fn weight(&self) -> Weight; } #[derive(AutoDefault)] -pub(crate) struct Assets(Vec); +pub struct Assets(Vec); -impl Assets { +impl Assets { pub fn new() -> Self { - Assets::(Vec::::new()) + Self(Vec::new()) } pub fn add(&mut self, asset: T) -> bool { @@ -49,14 +49,14 @@ impl Assets { } } -impl Render for Assets { +impl Render for Assets { fn render(&self) -> Markup { let mut assets = self.0.iter().collect::>(); assets.sort_by_key(|a| a.weight()); html! { @for a in assets { - (a.render()) + (a) } } } diff --git a/src/html/assets/javascript.rs b/src/html/assets/javascript.rs index db5754e..89b5261 100644 --- a/src/html/assets/javascript.rs +++ b/src/html/assets/javascript.rs @@ -1,4 +1,4 @@ -use crate::html::assets::AssetsTrait; +use crate::html::assets::Asset; use crate::html::{html, Markup, Render}; use crate::{join, join_pair, AutoDefault, Weight}; @@ -137,7 +137,7 @@ impl JavaScript { } } -impl AssetsTrait for JavaScript { +impl Asset for JavaScript { // Para *scripts* externos es la ruta; para *scripts* embebidos, un identificador. fn name(&self) -> &str { match &self.source { diff --git a/src/html/assets/stylesheet.rs b/src/html/assets/stylesheet.rs index bb60b01..a553726 100644 --- a/src/html/assets/stylesheet.rs +++ b/src/html/assets/stylesheet.rs @@ -1,4 +1,4 @@ -use crate::html::assets::AssetsTrait; +use crate::html::assets::Asset; use crate::html::{html, Markup, PreEscaped, Render}; use crate::{join_pair, AutoDefault, Weight}; @@ -142,7 +142,7 @@ impl StyleSheet { } } -impl AssetsTrait for StyleSheet { +impl Asset for StyleSheet { // Para hojas de estilos externas es la ruta; para las embebidas, un identificador. fn name(&self) -> &str { match &self.source { diff --git a/src/html/context.rs b/src/html/context.rs index 4ebd510..7af884c 100644 --- a/src/html/context.rs +++ b/src/html/context.rs @@ -10,7 +10,7 @@ use crate::{builder_fn, join}; use std::any::Any; use std::collections::HashMap; -/// Operaciones para modificar el contexto ([`Context`]) del documento. +/// Operaciones para modificar el contexto ([`Context`]) de un documento. pub enum AssetsOp { // Favicon. /// Define el *favicon* del documento. Sobrescribe cualquier valor anterior. @@ -47,7 +47,64 @@ pub enum ErrorParam { }, } -/// Representa el contexto de un documento HTML. +pub trait Contextual: LangId { + // Contextual BUILDER ************************************************************************** + + /// Asigna la fuente de idioma del documento. + #[builder_fn] + fn with_langid(self, language: &impl LangId) -> Self; + + /// Asigna la solicitud HTTP al contexto. + #[builder_fn] + fn with_request(self, request: Option) -> Self; + + /// Asigna el tema para renderizar el documento. + #[builder_fn] + fn with_theme(self, theme_name: &'static str) -> Self; + + /// Asigna la composición para renderizar el documento. + #[builder_fn] + fn with_layout(self, layout_name: &'static str) -> Self; + + /// Añade o modifica un parámetro dinámico del contexto. + #[builder_fn] + fn with_param(self, key: &'static str, value: T) -> Self; + + /// Define los recursos del contexto usando [`AssetsOp`]. + #[builder_fn] + fn with_assets(self, op: AssetsOp) -> Self; + + // Contextual GETTERS ************************************************************************** + + /// Devuelve una referencia a la solicitud HTTP asociada, si existe. + fn request(&self) -> Option<&HttpRequest>; + + /// Devuelve el tema que se usará para renderizar el documento. + fn theme(&self) -> ThemeRef; + + /// Devuelve la composición para renderizar el documento. Por defecto es `"default"`. + fn layout(&self) -> &str; + + /// Recupera un parámetro como [`Option`], simplificando el acceso. + fn param(&self, key: &'static str) -> Option<&T>; + + /// Devuelve el Favicon de los recursos del contexto. + fn favicon(&self) -> Option<&Favicon>; + + /// Devuelve las hojas de estilo de los recursos del contexto. + fn stylesheets(&self) -> &Assets; + + /// Devuelve los scripts JavaScript de los recursos del contexto. + fn javascripts(&self) -> &Assets; + + // Contextual HELPERS ************************************************************************** + + /// Devuelve un identificador único dentro del contexto para el tipo `T`, si no se proporciona + /// un `id` explícito. + fn required_id(&mut self, id: Option) -> String; +} + +/// Implementa el contexto de un documento HTML. /// /// Se crea internamente para manejar información relevante del documento, como la solicitud HTTP de /// origen, el idioma, tema y composición para el renderizado, los recursos *favicon* ([`Favicon`]), @@ -107,7 +164,7 @@ pub struct Context { favicon : Option, // Favicon, si se ha definido. stylesheets: Assets, // Hojas de estilo CSS. javascripts: Assets, // Scripts JavaScript. - params : HashMap<&'static str, (Box, &'static str)>, // Parámetros definidos en tiempo de ejecución. + params : HashMap<&'static str, (Box, &'static str)>, // Parámetros en ejecución. id_counter : usize, // Contador para generar identificadores únicos. } @@ -151,80 +208,6 @@ impl Context { } } - // Context BUILDER ***************************************************************************** - - /// Modifica la fuente de idioma del documento. - #[builder_fn] - pub fn with_langid(mut self, language: &impl LangId) -> Self { - self.langid = language.langid(); - self - } - - /// Modifica el tema que se usará para renderizar el documento. - /// - /// Localiza el tema por su [`short_name()`](crate::core::AnyInfo::short_name), y si no aplica - /// ninguno entonces usará el tema por defecto. - #[builder_fn] - pub fn with_theme(mut self, theme_name: &'static str) -> Self { - self.theme = theme_by_short_name(theme_name).unwrap_or(*DEFAULT_THEME); - self - } - - /// Modifica la composición para renderizar el documento. - #[builder_fn] - pub fn with_layout(mut self, layout_name: &'static str) -> Self { - self.layout = layout_name; - self - } - - /// Define los recursos del contexto usando [`AssetsOp`]. - #[builder_fn] - pub fn with_assets(mut self, op: AssetsOp) -> Self { - match op { - // Favicon. - AssetsOp::SetFavicon(favicon) => { - self.favicon = favicon; - } - AssetsOp::SetFaviconIfNone(icon) => { - if self.favicon.is_none() { - self.favicon = Some(icon); - } - } - // Stylesheets. - AssetsOp::AddStyleSheet(css) => { - self.stylesheets.add(css); - } - AssetsOp::RemoveStyleSheet(path) => { - self.stylesheets.remove(path); - } - // JavaScripts. - AssetsOp::AddJavaScript(js) => { - self.javascripts.add(js); - } - AssetsOp::RemoveJavaScript(path) => { - self.javascripts.remove(path); - } - } - self - } - - // Context GETTERS ***************************************************************************** - - /// Devuelve una referencia a la solicitud HTTP asociada, si existe. - pub fn request(&self) -> Option<&HttpRequest> { - self.request.as_ref() - } - - /// Devuelve el tema que se usará para renderizar el documento. - pub fn theme(&self) -> ThemeRef { - self.theme - } - - /// Devuelve la composición para renderizar el documento. Por defecto es `"default"`. - pub fn layout(&self) -> &str { - self.layout - } - // Context RENDER ****************************************************************************** /// Renderiza los recursos del contexto. @@ -240,61 +223,6 @@ impl Context { // Context PARAMS ****************************************************************************** - /// Añade o modifica un parámetro dinámico del contexto. - /// - /// El valor se guarda conservando el *nombre del tipo* real para mejorar los mensajes de error - /// posteriores. - /// - /// # Ejemplos - /// - /// ```rust - /// use pagetop::prelude::*; - /// - /// let cx = Context::new(None) - /// .with_param("usuario_id", 42_i32) - /// .with_param("titulo", String::from("Hola")) - /// .with_param("flags", vec!["a", "b"]); - /// ``` - #[builder_fn] - pub fn with_param(mut self, key: &'static str, value: T) -> Self { - let type_name = TypeInfo::FullName.of::(); - self.params.insert(key, (Box::new(value), type_name)); - self - } - - /// Recupera un parámetro como [`Option`], simplificando el acceso. - /// - /// A diferencia de [`get_param`](Self::get_param), que devuelve un [`Result`] con información - /// detallada de error, este método devuelve `None` tanto si la clave no existe como si el valor - /// guardado no coincide con el tipo solicitado. - /// - /// Resulta útil en escenarios donde sólo interesa saber si el valor existe y es del tipo - /// correcto, sin necesidad de diferenciar entre error de ausencia o de tipo. - /// - /// # Ejemplo - /// - /// ```rust - /// use pagetop::prelude::*; - /// - /// let cx = Context::new(None).with_param("username", String::from("Alice")); - /// - /// // Devuelve Some(&String) si existe y coincide el tipo. - /// assert_eq!(cx.param::("username").map(|s| s.as_str()), Some("Alice")); - /// - /// // Devuelve None si no existe o si el tipo no coincide. - /// assert!(cx.param::("username").is_none()); - /// assert!(cx.param::("missing").is_none()); - /// - /// // Acceso con valor por defecto. - /// let user = cx.param::("missing") - /// .cloned() - /// .unwrap_or_else(|| "visitor".to_string()); - /// assert_eq!(user, "visitor"); - /// ``` - pub fn param(&self, key: &'static str) -> Option<&T> { - self.get_param::(key).ok() - } - /// Recupera una *referencia tipada* al parámetro solicitado. /// /// Devuelve: @@ -328,23 +256,6 @@ impl Context { }) } - /// Elimina un parámetro del contexto. Devuelve `true` si la clave existía y se eliminó. - /// - /// Devuelve `false` en caso contrario. Usar cuando solo interesa borrar la entrada. - /// - /// # Ejemplos - /// - /// ```rust - /// use pagetop::prelude::*; - /// - /// let mut cx = Context::new(None).with_param("temp", 1u8); - /// assert!(cx.remove_param("temp")); - /// assert!(!cx.remove_param("temp")); // ya no existe - /// ``` - pub fn remove_param(&mut self, key: &'static str) -> bool { - self.params.remove(key).is_some() - } - /// Recupera el parámetro solicitado y lo elimina del contexto. /// /// Devuelve: @@ -380,30 +291,21 @@ impl Context { }) } - // Context EXTRAS ****************************************************************************** - - /// Genera un identificador único si no se proporciona uno explícito. + /// Elimina un parámetro del contexto. Devuelve `true` si la clave existía y se eliminó. /// - /// Si no se proporciona un `id`, se genera un identificador único en la forma `-` - /// donde `` es el nombre corto del tipo en minúsculas (sin espacios) y `` es un - /// contador interno incremental. - pub fn required_id(&mut self, id: Option) -> String { - if let Some(id) = id { - id - } else { - let prefix = TypeInfo::ShortName - .of::() - .trim() - .replace(' ', "_") - .to_lowercase(); - let prefix = if prefix.is_empty() { - "prefix".to_owned() - } else { - prefix - }; - self.id_counter += 1; - join!(prefix, "-", self.id_counter.to_string()) - } + /// Devuelve `false` en caso contrario. Usar cuando solo interesa borrar la entrada. + /// + /// # Ejemplos + /// + /// ```rust + /// use pagetop::prelude::*; + /// + /// let mut cx = Context::new(None).with_param("temp", 1u8); + /// assert!(cx.remove_param("temp")); + /// assert!(!cx.remove_param("temp")); // ya no existe + /// ``` + pub fn remove_param(&mut self, key: &'static str) -> bool { + self.params.remove(key).is_some() } } @@ -423,3 +325,173 @@ impl LangId for Context { self.langid } } + +impl Contextual for Context { + // Contextual BUILDER ************************************************************************** + + #[builder_fn] + fn with_request(mut self, request: Option) -> Self { + self.request = request; + self + } + + #[builder_fn] + fn with_langid(mut self, language: &impl LangId) -> Self { + self.langid = language.langid(); + self + } + + /// Asigna el tema para renderizar el documento. + /// + /// Localiza el tema por su [`short_name()`](crate::core::AnyInfo::short_name), y si no aplica + /// ninguno entonces usará el tema por defecto. + #[builder_fn] + fn with_theme(mut self, theme_name: &'static str) -> Self { + self.theme = theme_by_short_name(theme_name).unwrap_or(*DEFAULT_THEME); + self + } + + #[builder_fn] + fn with_layout(mut self, layout_name: &'static str) -> Self { + self.layout = layout_name; + self + } + + /// Añade o modifica un parámetro dinámico del contexto. + /// + /// El valor se guarda conservando el *nombre del tipo* real para mejorar los mensajes de error + /// posteriores. + /// + /// # Ejemplos + /// + /// ```rust + /// use pagetop::prelude::*; + /// + /// let cx = Context::new(None) + /// .with_param("usuario_id", 42_i32) + /// .with_param("titulo", String::from("Hola")) + /// .with_param("flags", vec!["a", "b"]); + /// ``` + #[builder_fn] + fn with_param(mut self, key: &'static str, value: T) -> Self { + let type_name = TypeInfo::FullName.of::(); + self.params.insert(key, (Box::new(value), type_name)); + self + } + + #[builder_fn] + fn with_assets(mut self, op: AssetsOp) -> Self { + match op { + // Favicon. + AssetsOp::SetFavicon(favicon) => { + self.favicon = favicon; + } + AssetsOp::SetFaviconIfNone(icon) => { + if self.favicon.is_none() { + self.favicon = Some(icon); + } + } + // Stylesheets. + AssetsOp::AddStyleSheet(css) => { + self.stylesheets.add(css); + } + AssetsOp::RemoveStyleSheet(path) => { + self.stylesheets.remove(path); + } + // JavaScripts. + AssetsOp::AddJavaScript(js) => { + self.javascripts.add(js); + } + AssetsOp::RemoveJavaScript(path) => { + self.javascripts.remove(path); + } + } + self + } + + // Contextual GETTERS ************************************************************************** + + fn request(&self) -> Option<&HttpRequest> { + self.request.as_ref() + } + + fn theme(&self) -> ThemeRef { + self.theme + } + + fn layout(&self) -> &str { + self.layout + } + + /// Recupera un parámetro como [`Option`], simplificando el acceso. + /// + /// A diferencia de [`get_param`](Self::get_param), que devuelve un [`Result`] con información + /// detallada de error, este método devuelve `None` tanto si la clave no existe como si el valor + /// guardado no coincide con el tipo solicitado. + /// + /// Resulta útil en escenarios donde sólo interesa saber si el valor existe y es del tipo + /// correcto, sin necesidad de diferenciar entre error de ausencia o de tipo. + /// + /// # Ejemplo + /// + /// ```rust + /// use pagetop::prelude::*; + /// + /// let cx = Context::new(None).with_param("username", String::from("Alice")); + /// + /// // Devuelve Some(&String) si existe y coincide el tipo. + /// assert_eq!(cx.param::("username").map(|s| s.as_str()), Some("Alice")); + /// + /// // Devuelve None si no existe o si el tipo no coincide. + /// assert!(cx.param::("username").is_none()); + /// assert!(cx.param::("missing").is_none()); + /// + /// // Acceso con valor por defecto. + /// let user = cx.param::("missing") + /// .cloned() + /// .unwrap_or_else(|| "visitor".to_string()); + /// assert_eq!(user, "visitor"); + /// ``` + fn param(&self, key: &'static str) -> Option<&T> { + self.get_param::(key).ok() + } + + fn favicon(&self) -> Option<&Favicon> { + self.favicon.as_ref() + } + + fn stylesheets(&self) -> &Assets { + &self.stylesheets + } + + fn javascripts(&self) -> &Assets { + &self.javascripts + } + + // Contextual HELPERS ************************************************************************** + + /// Devuelve un identificador único dentro del contexto para el tipo `T`, si no se proporciona + /// un `id` explícito. + /// + /// Si no se proporciona un `id`, se genera un identificador único en la forma `-` + /// donde `` es el nombre corto del tipo en minúsculas (sin espacios) y `` es un + /// contador interno incremental. + fn required_id(&mut self, id: Option) -> String { + if let Some(id) = id { + id + } else { + let prefix = TypeInfo::ShortName + .of::() + .trim() + .replace(' ', "_") + .to_lowercase(); + let prefix = if prefix.is_empty() { + "prefix".to_owned() + } else { + prefix + }; + self.id_counter += 1; + join!(prefix, "-", self.id_counter.to_string()) + } + } +} diff --git a/src/response/page.rs b/src/response/page.rs index 0942f8c..77bc9c4 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -8,7 +8,8 @@ use crate::builder_fn; use crate::core::component::{Child, ChildOp, Component}; use crate::core::theme::{ChildrenInRegions, ThemeRef, REGION_CONTENT}; use crate::html::{html, Markup, DOCTYPE}; -use crate::html::{AssetsOp, Context}; +use crate::html::{Assets, Favicon, JavaScript, StyleSheet}; +use crate::html::{AssetsOp, Context, Contextual}; use crate::html::{AttrClasses, ClassesOp}; use crate::html::{AttrId, AttrL10n}; use crate::locale::{CharacterDirection, L10n, LangId, LanguageIdentifier}; @@ -25,9 +26,9 @@ pub struct Page { description : AttrL10n, metadata : Vec<(&'static str, &'static str)>, properties : Vec<(&'static str, &'static str)>, - context : Context, body_id : AttrId, body_classes: AttrClasses, + context : Context, regions : ChildrenInRegions, } @@ -43,9 +44,9 @@ impl Page { description : AttrL10n::default(), metadata : Vec::default(), properties : Vec::default(), - context : Context::new(request), body_id : AttrId::default(), body_classes: AttrClasses::default(), + context : Context::new(request), regions : ChildrenInRegions::default(), } } @@ -80,40 +81,6 @@ impl Page { self } - /// Modifica la fuente de idioma de la página ([`Context::with_langid()`]). - #[builder_fn] - pub fn with_langid(mut self, language: &impl LangId) -> Self { - self.context.alter_langid(language); - self - } - - /// Modifica el tema que se usará para renderizar la página ([`Context::with_theme()`]). - #[builder_fn] - pub fn with_theme(mut self, theme_name: &'static str) -> Self { - self.context.alter_theme(theme_name); - self - } - - /// Modifica la composición para renderizar la página ([`Context::with_layout()`]). - #[builder_fn] - pub fn with_layout(mut self, layout_name: &'static str) -> Self { - self.context.alter_layout(layout_name); - self - } - - /// Define los recursos de la página usando [`AssetsOp`]. - #[builder_fn] - pub fn with_assets(mut self, op: AssetsOp) -> Self { - self.context.alter_assets(op); - self - } - - #[builder_fn] - pub fn with_param(mut self, key: &'static str, value: T) -> Self { - self.context.alter_param(key, value); - self - } - /// Establece el atributo `id` del elemento ``. #[builder_fn] pub fn with_body_id(mut self, id: impl AsRef) -> Self { @@ -205,25 +172,6 @@ impl Page { &self.properties } - /// Devuelve la solicitud HTTP asociada. - pub fn request(&self) -> Option<&HttpRequest> { - self.context.request() - } - - /// Devuelve el tema que se usará para renderizar la página. - pub fn theme(&self) -> ThemeRef { - self.context.theme() - } - - /// Devuelve la composición para renderizar la página. Por defecto es `"default"`. - pub fn layout(&self) -> &str { - self.context.layout() - } - - pub fn param(&self, key: &'static str) -> Option<&T> { - self.context.param(key) - } - /// Devuelve el identificador del elemento ``. pub fn body_id(&self) -> &AttrId { &self.body_id @@ -233,19 +181,19 @@ impl Page { pub fn body_classes(&self) -> &AttrClasses { &self.body_classes } - /* - /// Devuelve una referencia mutable al [`Context`] de la página. - /// - /// El [`Context`] actúa como intermediario para muchos métodos de `Page` (idioma, tema, - /// *layout*, recursos, solicitud HTTP, etc.). Resulta especialmente útil cuando un componente - /// o un tema necesita recibir el contexto como parámetro. - pub fn context(&mut self) -> &mut Context { - &mut self.context - } - */ + + /// Devuelve una referencia mutable al [`Context`] de la página. + /// + /// El [`Context`] actúa como intermediario para muchos métodos de `Page` (idioma, tema, + /// *layout*, recursos, solicitud HTTP, etc.). Resulta especialmente útil cuando un componente + /// o un tema necesita recibir el contexto como parámetro. + pub fn context(&mut self) -> &mut Context { + &mut self.context + } + // Page RENDER ********************************************************************************* - /// Renderiza los componentes de una región (`regiona_name`) de la página. + /// Renderiza los componentes de una región (`region_name`) de la página. pub fn render_region(&mut self, region_name: &'static str) -> Markup { self.regions .merge_all_components(self.context.theme(), region_name) @@ -302,3 +250,79 @@ impl LangId for Page { self.context.langid() } } + +impl Contextual for Page { + // Contextual BUILDER ************************************************************************** + + #[builder_fn] + fn with_request(mut self, request: Option) -> Self { + self.context.alter_request(request); + self + } + + #[builder_fn] + fn with_langid(mut self, language: &impl LangId) -> Self { + self.context.alter_langid(language); + self + } + + #[builder_fn] + fn with_theme(mut self, theme_name: &'static str) -> Self { + self.context.alter_theme(theme_name); + self + } + + #[builder_fn] + fn with_layout(mut self, layout_name: &'static str) -> Self { + self.context.alter_layout(layout_name); + self + } + + #[builder_fn] + fn with_param(mut self, key: &'static str, value: T) -> Self { + self.context.alter_param(key, value); + self + } + + #[builder_fn] + fn with_assets(mut self, op: AssetsOp) -> Self { + self.context.alter_assets(op); + self + } + + // Contextual GETTERS ************************************************************************** + + fn request(&self) -> Option<&HttpRequest> { + self.context.request() + } + + fn theme(&self) -> ThemeRef { + self.context.theme() + } + + fn layout(&self) -> &str { + self.context.layout() + } + + fn param(&self, key: &'static str) -> Option<&T> { + self.context.param(key) + } + + fn favicon(&self) -> Option<&Favicon> { + self.context.favicon() + } + + fn stylesheets(&self) -> &Assets { + self.context.stylesheets() + } + + fn javascripts(&self) -> &Assets { + self.context.javascripts() + } + + // Contextual HELPERS ************************************************************************** + + fn required_id(&mut self, id: Option) -> String { + self.context.required_id::(id) + } +} diff --git a/src/response/page/error.rs b/src/response/page/error.rs index ab56338..be48e3e 100644 --- a/src/response/page/error.rs +++ b/src/response/page/error.rs @@ -1,4 +1,5 @@ use crate::base::component::Html; +use crate::html::Contextual; use crate::locale::L10n; use crate::response::ResponseError; use crate::service::http::{header::ContentType, StatusCode}; From 0127d17459be19a92e508c8e4f70d2e5df6ede04 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Mon, 8 Sep 2025 00:10:23 +0200 Subject: [PATCH 26/35] =?UTF-8?q?=F0=9F=93=9D=20Mejora=20la=20documentaci?= =?UTF-8?q?=C3=B3n=20de=20recursos=20y=20contexto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/global.rs | 12 ++++---- src/html/assets.rs | 32 ++++++++++++++++++- src/html/assets/javascript.rs | 4 ++- src/html/assets/stylesheet.rs | 4 ++- src/html/context.rs | 58 +++++++++++++++++++++++++++-------- 5 files changed, 88 insertions(+), 22 deletions(-) diff --git a/src/global.rs b/src/global.rs index c81eec9..ccc6d9d 100644 --- a/src/global.rs +++ b/src/global.rs @@ -48,13 +48,13 @@ pub struct App { pub description: String, /// Tema predeterminado. pub theme: String, - /// Idioma por defecto de la aplicación. + /// Idioma por defecto para la aplicación. /// - /// Si este valor no es válido, el idioma efectivo para el renderizado se resolverá mediante la - /// implementación de [`LangId`](crate::locale::LangId) en este orden: primero, el establecido - /// explícitamente con [`Contextual::with_langid()`](crate::html::Contextual::with_langid); si - /// no se ha definido, se usará el indicado en la cabecera `Accept-Language` del navegador; y, - /// si ninguno aplica, se empleará el idioma de respaldo ("en-US"). + /// Si no está definido o no es válido, el idioma efectivo para el renderizado se resolverá + /// según la implementación de [`LangId`](crate::locale::LangId) en este orden: primero intenta + /// con el establecido en [`Contextual::with_langid()`](crate::html::Contextual::with_langid); + /// pero si no se ha definido explícitamente, usará el indicado en la cabecera `Accept-Language` + /// del navegador; y, si ninguno aplica, se empleará el idioma de respaldo ("en-US"). pub language: String, /// Banner ASCII mostrado al inicio: *"Off"* (desactivado), *"Slant"*, *"Small"*, *"Speed"* o /// *"Starwars"*. diff --git a/src/html/assets.rs b/src/html/assets.rs index e53e8e3..ee5431f 100644 --- a/src/html/assets.rs +++ b/src/html/assets.rs @@ -5,22 +5,49 @@ pub mod stylesheet; use crate::html::{html, Markup, Render}; use crate::{AutoDefault, Weight}; +/// Representación genérica de un *script* [`JavaScript`](crate::html::JavaScript) o una hoja de +/// estilos [`StyleSheet`](crate::html::StyleSheet). +/// +/// Estos recursos se incluyen en los conjuntos de recursos ([`Assets`]) que suelen renderizarse en +/// un documento HTML. +/// +/// Cada recurso se identifica por un **nombre único** ([`Asset::name()`]), usado como clave; y un +/// **peso** ([`Asset::weight()`]), que determina su orden relativo de renderizado. pub trait Asset: Render { /// Devuelve el nombre del recurso, utilizado como clave única. fn name(&self) -> &str; - /// Devuelve el peso del recurso, durante el renderizado se procesan de menor a mayor peso. + /// Devuelve el peso del recurso, usado para ordenar el renderizado de menor a mayor peso. fn weight(&self) -> Weight; } +/// Gestión común para conjuntos de recursos como [`JavaScript`](crate::html::JavaScript) y +/// [`StyleSheet`](crate::html::StyleSheet). +/// +/// Se emplea normalmente para agrupar, administrar y renderizar los recursos de un documento HTML. +/// Cada recurso se identifica por un nombre único ([`Asset::name()`]) y tiene asociado un peso +/// ([`Asset::weight()`]) que determina su orden de renderizado. +/// +/// Durante el renderizado, los recursos se procesan en orden ascendente de peso. En caso de +/// igualdad, se respeta el orden de inserción. #[derive(AutoDefault)] pub struct Assets(Vec); impl Assets { + /// Crea un nuevo conjunto vacío de recursos. + /// + /// Normalmente no se instancia directamente, sino como parte de la gestión de recursos que + /// hacen páginas o temas. pub fn new() -> Self { Self(Vec::new()) } + /// Inserta un recurso. + /// + /// Si no existe otro con el mismo nombre, lo añade. Si ya existe y su peso era mayor, lo + /// reemplaza. Y si su peso era menor o igual, entonces no realiza ningún cambio. + /// + /// Devuelve `true` si el recurso fue insertado o reemplazado. pub fn add(&mut self, asset: T) -> bool { match self.0.iter().position(|x| x.name() == asset.name()) { Some(index) => { @@ -39,6 +66,9 @@ impl Assets { } } + /// Elimina un recurso por nombre. + /// + /// Devuelve `true` si el recurso existía y fue eliminado. pub fn remove(&mut self, name: impl AsRef) -> bool { if let Some(index) = self.0.iter().position(|x| x.name() == name.as_ref()) { self.0.remove(index); diff --git a/src/html/assets/javascript.rs b/src/html/assets/javascript.rs index 89b5261..be6f906 100644 --- a/src/html/assets/javascript.rs +++ b/src/html/assets/javascript.rs @@ -138,7 +138,9 @@ impl JavaScript { } impl Asset for JavaScript { - // Para *scripts* externos es la ruta; para *scripts* embebidos, un identificador. + /// Devuelve el nombre del recurso, utilizado como clave única. + /// + /// Para *scripts* externos es la ruta del recurso; para *scripts* embebidos, un identificador. fn name(&self) -> &str { match &self.source { Source::From(path) => path, diff --git a/src/html/assets/stylesheet.rs b/src/html/assets/stylesheet.rs index a553726..38a97d7 100644 --- a/src/html/assets/stylesheet.rs +++ b/src/html/assets/stylesheet.rs @@ -143,7 +143,9 @@ impl StyleSheet { } impl Asset for StyleSheet { - // Para hojas de estilos externas es la ruta; para las embebidas, un identificador. + /// Devuelve el nombre del recurso, utilizado como clave única. + /// + /// Para hojas de estilos externas es la ruta del recurso; para las embebidas, un identificador. fn name(&self) -> &str { match &self.source { Source::From(path) => path, diff --git a/src/html/context.rs b/src/html/context.rs index 7af884c..79148b0 100644 --- a/src/html/context.rs +++ b/src/html/context.rs @@ -47,22 +47,52 @@ pub enum ErrorParam { }, } +/// Interfaz para gestionar el **contexto de renderizado** de un documento HTML. +/// +/// `Contextual` extiende [`LangId`] y define los métodos para: +/// +/// - Establecer el **idioma** del documento. +/// - Almacenar la **solicitud HTTP** de origen. +/// - Seleccionar **tema** y **composición** (*layout*) de renderizado. +/// - Administrar **recursos** del documento como el icono [`Favicon`], las hojas de estilo +/// [`StyleSheet`] o los *scripts* [`JavaScript`] mediante [`AssetsOp`]. +/// - Leer y mantener **parámetros dinámicos tipados** de contexto. +/// - Generar **identificadores únicos** por tipo de componente. +/// +/// Lo implementan, típicamente, estructuras que representan el contexto de renderizado, como +/// [`Context`](crate::html::Context) o [`Page`](crate::response::page::Page). +/// +/// # Ejemplo +/// +/// ```rust +/// use pagetop::prelude::*; +/// +/// fn prepare_context(cx: C) -> C { +/// cx.with_langid(&LangMatch::resolve("es-ES")) +/// .with_theme("aliner") +/// .with_layout("default") +/// .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"))) +/// .with_param("usuario_id", 42_i32) +/// } +/// ``` pub trait Contextual: LangId { // Contextual BUILDER ************************************************************************** - /// Asigna la fuente de idioma del documento. + /// Establece el idioma del documento. #[builder_fn] fn with_langid(self, language: &impl LangId) -> Self; - /// Asigna la solicitud HTTP al contexto. + /// Almacena la solicitud HTTP de origen en el contexto. #[builder_fn] fn with_request(self, request: Option) -> Self; - /// Asigna el tema para renderizar el documento. + /// Especifica el tema para renderizar el documento. #[builder_fn] fn with_theme(self, theme_name: &'static str) -> Self; - /// Asigna la composición para renderizar el documento. + /// Especifica la composición para renderizar el documento. #[builder_fn] fn with_layout(self, layout_name: &'static str) -> Self; @@ -85,7 +115,7 @@ pub trait Contextual: LangId { /// Devuelve la composición para renderizar el documento. Por defecto es `"default"`. fn layout(&self) -> &str; - /// Recupera un parámetro como [`Option`], simplificando el acceso. + /// Recupera un parámetro como [`Option`]. fn param(&self, key: &'static str) -> Option<&T>; /// Devuelve el Favicon de los recursos del contexto. @@ -94,22 +124,24 @@ pub trait Contextual: LangId { /// Devuelve las hojas de estilo de los recursos del contexto. fn stylesheets(&self) -> &Assets; - /// Devuelve los scripts JavaScript de los recursos del contexto. + /// Devuelve los *scripts* JavaScript de los recursos del contexto. fn javascripts(&self) -> &Assets; // Contextual HELPERS ************************************************************************** - /// Devuelve un identificador único dentro del contexto para el tipo `T`, si no se proporciona - /// un `id` explícito. + /// Genera un identificador único por tipo (`-`) cuando no se aporta uno explícito. + /// + /// Es útil para componentes u otros elementos HTML que necesitan un identificador predecible si + /// no se proporciona ninguno. fn required_id(&mut self, id: Option) -> String; } -/// Implementa el contexto de un documento HTML. +/// Implementa un **contexto de renderizado** para un documento HTML. /// -/// Se crea internamente para manejar información relevante del documento, como la solicitud HTTP de -/// origen, el idioma, tema y composición para el renderizado, los recursos *favicon* ([`Favicon`]), -/// hojas de estilo ([`StyleSheet`]) y *scripts* ([`JavaScript`]), así como *parámetros dinámicos -/// heterogéneos* de contexto definidos en tiempo de ejecución. +/// Extiende [`Contextual`] con métodos para **instanciar** y configurar un nuevo contexto, +/// **renderizar los recursos** del documento (incluyendo el [`Favicon`], las hojas de estilo +/// [`StyleSheet`] y los *scripts* [`JavaScript`]), o extender el uso de **parámetros dinámicos +/// tipados** con nuevos métodos. /// /// # Ejemplos /// From 824024b96db0b63801f8a4cd0b2e4c5d2f944e68 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Mon, 8 Sep 2025 18:57:09 +0200 Subject: [PATCH 27/35] =?UTF-8?q?=E2=9C=A8=20[context]=20A=C3=B1ade=20m?= =?UTF-8?q?=C3=A9todos=20auxiliares=20de=20par=C3=A1metros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/html/context.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/html/context.rs b/src/html/context.rs index 79148b0..26e2478 100644 --- a/src/html/context.rs +++ b/src/html/context.rs @@ -118,6 +118,21 @@ pub trait Contextual: LangId { /// Recupera un parámetro como [`Option`]. fn param(&self, key: &'static str) -> Option<&T>; + /// Devuelve el parámetro clonado o el **valor por defecto del tipo** (`T::default()`). + fn param_or_default(&self, key: &'static str) -> T { + self.param::(key).cloned().unwrap_or_default() + } + + /// Devuelve el parámetro clonado o un **valor por defecto** si no existe. + fn param_or(&self, key: &'static str, default: T) -> T { + self.param::(key).cloned().unwrap_or(default) + } + + /// Devuelve el parámetro clonado o el **valor evaluado** por la función `f` si no existe. + fn param_or_else T>(&self, key: &'static str, f: F) -> T { + self.param::(key).cloned().unwrap_or_else(f) + } + /// Devuelve el Favicon de los recursos del contexto. fn favicon(&self) -> Option<&Favicon>; From 3ba71dbe456e208d19b8145bef7d78c7e6b0160a Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Mon, 8 Sep 2025 23:27:56 +0200 Subject: [PATCH 28/35] =?UTF-8?q?=F0=9F=8E=A8=20Generaliza=20p=C3=A1gina?= =?UTF-8?q?=20de=20bienvenida=20con=20par=C3=A1metros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/base/extension/welcome.rs | 4 +++- src/base/theme/basic.rs | 25 +++++++++++++++---------- src/response/page.rs | 11 ++++++----- src/response/page/error.rs | 4 ++-- static/css/intro.css | 23 +++++++++++++++++++++-- 5 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/base/extension/welcome.rs b/src/base/extension/welcome.rs index 3c0de3d..eaa9f33 100644 --- a/src/base/extension/welcome.rs +++ b/src/base/extension/welcome.rs @@ -24,11 +24,13 @@ impl Extension for Welcome { async fn homepage(request: HttpRequest) -> ResultPage { let app = &global::SETTINGS.app.name; - Page::new(Some(request)) + Page::new(request) .with_theme("basic") .with_layout("intro") .with_title(L10n::l("welcome_title")) .with_description(L10n::l("welcome_intro").with_arg("app", app)) + .with_param("intro_button_text", L10n::l("welcome_powered")) + .with_param("intro_button_link", "https://pagetop.cillero.es".to_owned()) .add_component(Html::with(|cx| { html! { p { (L10n::l("welcome_text1").using(cx)) } diff --git a/src/base/theme/basic.rs b/src/base/theme/basic.rs index dc16f2a..fbf4caf 100644 --- a/src/base/theme/basic.rs +++ b/src/base/theme/basic.rs @@ -41,6 +41,9 @@ fn render_intro(page: &mut Page) -> Markup { let title = page.title().unwrap_or_default(); let intro = page.description().unwrap_or_default(); + let intro_button_text: L10n = page.param_or_default("intro_button_text"); + let intro_button_link: Option<&String> = page.param("intro_button_link"); + html! { body id=[page.body_id().get()] class=[page.body_classes().get()] { header class="intro-header" { @@ -71,16 +74,18 @@ fn render_intro(page: &mut Page) -> Markup { } main class="intro-content" { section class="intro-content__body" { - div class="intro-button" { - a - class="intro-button__link" - href="https://pagetop.cillero.es" - target="_blank" - rel="noreferrer" - { - span {} span {} span {} - div class="intro-button__text" { - (L10n::l("welcome_powered").using(page)) + @if intro_button_link.is_some() { + div class="intro-button" { + a + class="intro-button__link" + href=[intro_button_link] + target="_blank" + rel="noreferrer" + { + span {} span {} span {} + div class="intro-button__text" { + (intro_button_text.using(page)) + } } } } diff --git a/src/response/page.rs b/src/response/page.rs index 77bc9c4..86a0bdc 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -4,7 +4,6 @@ pub use error::ErrorPage; pub use actix_web::Result as ResultPage; use crate::base::action; -use crate::builder_fn; use crate::core::component::{Child, ChildOp, Component}; use crate::core::theme::{ChildrenInRegions, ThemeRef, REGION_CONTENT}; use crate::html::{html, Markup, DOCTYPE}; @@ -14,6 +13,7 @@ use crate::html::{AttrClasses, ClassesOp}; use crate::html::{AttrId, AttrL10n}; use crate::locale::{CharacterDirection, L10n, LangId, LanguageIdentifier}; use crate::service::HttpRequest; +use crate::{builder_fn, AutoDefault}; /// Representa una página HTML completa lista para renderizar. /// @@ -21,6 +21,7 @@ use crate::service::HttpRequest; /// regiones donde disponer los componentes, atributos de `` y otros aspectos del contexto de /// renderizado. #[rustfmt::skip] +#[derive(AutoDefault)] pub struct Page { title : AttrL10n, description : AttrL10n, @@ -35,10 +36,10 @@ pub struct Page { impl Page { /// Crea una nueva instancia de página. /// - /// Si se proporciona la solicitud HTTP, se guardará en el contexto de renderizado de la página - /// para poder ser recuperada por los componentes si es necesario. + /// La solicitud HTTP se guardará en el contexto de renderizado de la página para poder ser + /// recuperada por los componentes si es necesario. #[rustfmt::skip] - pub fn new(request: Option) -> Self { + pub fn new(request: HttpRequest) -> Self { Page { title : AttrL10n::default(), description : AttrL10n::default(), @@ -46,7 +47,7 @@ impl Page { properties : Vec::default(), body_id : AttrId::default(), body_classes: AttrClasses::default(), - context : Context::new(request), + context : Context::new(Some(request)), regions : ChildrenInRegions::default(), } } diff --git a/src/response/page/error.rs b/src/response/page/error.rs index be48e3e..2355d23 100644 --- a/src/response/page/error.rs +++ b/src/response/page/error.rs @@ -29,7 +29,7 @@ impl Display for ErrorPage { ErrorPage::BadRequest(_) => write!(f, "Bad Client Data"), // Error 403. ErrorPage::AccessDenied(request) => { - let mut error_page = Page::new(Some(request.clone())); + let mut error_page = Page::new(request.clone()); let error403 = error_page.theme().error403(&mut error_page); if let Ok(page) = error_page .with_title(L10n::n("Error FORBIDDEN")) @@ -44,7 +44,7 @@ impl Display for ErrorPage { } // Error 404. ErrorPage::NotFound(request) => { - let mut error_page = Page::new(Some(request.clone())); + let mut error_page = Page::new(request.clone()); let error404 = error_page.theme().error404(&mut error_page); if let Ok(page) = error_page .with_title(L10n::n("Error RESOURCE NOT FOUND")) diff --git a/static/css/intro.css b/static/css/intro.css index 5a5461e..19fa9f1 100644 --- a/static/css/intro.css +++ b/static/css/intro.css @@ -5,9 +5,13 @@ --bg-img-sm-set: image-set(url('/img/intro-header-sm.avif') type('image/avif'), url('/img/intro-header-sm.webp') type('image/webp'), var(--bg-img-sm) type('image/jpeg')); --bg-color: #8c5919; --color: #1a202c; - --color-red: #fecaca; --color-gray: #e4e4e7; --color-link: #1e4eae; + --color-block-1: #fecaca; + --color-block-2: #e6a9e2; + --color-block-3: #b689ff; + --color-block-4: #ffedca; + --color-block-5: #ffffff; --focus-outline: 2px solid var(--color-link); --focus-outline-offset: 2px; --shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); @@ -413,9 +417,24 @@ a:hover:visited { left: -15%; width: 130%; z-index: -10; - background: var(--color-red); + background: var(--color-block-1); transform: rotate(2deg); } +.intro-text .block:nth-of-type(5n+1) .block__title:after { + background: var(--color-block-1); +} +.intro-text .block:nth-of-type(5n+2) .block__title:after { + background: var(--color-block-2); +} +.intro-text .block:nth-of-type(5n+3) .block__title:after { + background: var(--color-block-3); +} +.intro-text .block:nth-of-type(5n+4) .block__title:after { + background: var(--color-block-4); +} +.intro-text .block:nth-of-type(5n+5) .block__title:after { + background: var(--color-block-5); +} /* * Footer From 2a4d6a78909a66280d0695dd072a5f3616ab65da Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Tue, 9 Sep 2025 01:01:18 +0200 Subject: [PATCH 29/35] =?UTF-8?q?=F0=9F=A9=B9=20Corrige=20doc=20y=20c?= =?UTF-8?q?=C3=B3digo=20por=20cambios=20en=20Page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- examples/hello-name.rs | 2 +- examples/hello-world.rs | 2 +- src/lib.rs | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9d5efc8..c6c12e0 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,8 @@ impl Extension for HelloWorld { } async fn hello_world(request: HttpRequest) -> ResultPage { - Page::new(Some(request)) - .with_component(Html::with(move |_| html! { h1 { "Hello World!" } })) + Page::new(request) + .add_component(Html::with(move |_| html! { h1 { "Hello World!" } })) .render() } diff --git a/examples/hello-name.rs b/examples/hello-name.rs index 3a491a6..e1285d0 100644 --- a/examples/hello-name.rs +++ b/examples/hello-name.rs @@ -13,7 +13,7 @@ async fn hello_name( path: service::web::Path, ) -> ResultPage { let name = path.into_inner(); - Page::new(Some(request)) + Page::new(request) .add_component(Html::with(move |_| html! { h1 { "Hello " (name) "!" } })) .render() } diff --git a/examples/hello-world.rs b/examples/hello-world.rs index 5550514..d56f210 100644 --- a/examples/hello-world.rs +++ b/examples/hello-world.rs @@ -9,7 +9,7 @@ impl Extension for HelloWorld { } async fn hello_world(request: HttpRequest) -> ResultPage { - Page::new(Some(request)) + Page::new(request) .add_component(Html::with(move |_| html! { h1 { "Hello World!" } })) .render() } diff --git a/src/lib.rs b/src/lib.rs index e43da2f..93b8564 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -60,8 +60,8 @@ impl Extension for HelloWorld { } async fn hello_world(request: HttpRequest) -> ResultPage { - Page::new(Some(request)) - .with_component(Html::with(move |_| html! { h1 { "Hello World!" } })) + Page::new(request) + .add_component(Html::with(move |_| html! { h1 { "Hello World!" } })) .render() } From ddf78c2de899ce7a8577aeff2c6d96c5f2e097e4 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Thu, 11 Sep 2025 19:03:34 +0200 Subject: [PATCH 30/35] =?UTF-8?q?=F0=9F=8E=A8=20Unifica=20conversiones=20a?= =?UTF-8?q?=20String=20con=20`to=5Fstring()`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Como `String::from()` y `.to_string()` son equivalentes, se sustituyen todas las ocurrencias de `String::from()` por `to_string()` para mayor coherencia y legibilidad. --- src/app.rs | 2 +- src/base/component/html.rs | 2 +- src/core/theme/regions.rs | 2 +- src/html.rs | 4 ++-- src/html/assets/favicon.rs | 2 +- src/html/attr_classes.rs | 2 +- src/html/attr_l10n.rs | 4 ++-- src/html/attr_value.rs | 2 +- src/html/context.rs | 10 +++++----- src/locale.rs | 4 ++-- src/util.rs | 22 +++++++++++----------- tests/component_html.rs | 2 +- 12 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/app.rs b/src/app.rs index 94d901f..c8ffba1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -84,7 +84,7 @@ impl Application { if let Some((Width(term_width), _)) = terminal_size() { if term_width >= 80 { let maxlen: usize = ((term_width / 10) - 2).into(); - let mut app = app_name.substring(0, maxlen).to_owned(); + let mut app = app_name.substring(0, maxlen).to_string(); if app_name.len() > maxlen { app = format!("{app}..."); } diff --git a/src/base/component/html.rs b/src/base/component/html.rs index 7bde94a..8fa5690 100644 --- a/src/base/component/html.rs +++ b/src/base/component/html.rs @@ -25,7 +25,7 @@ use crate::prelude::*; /// use pagetop::prelude::*; /// /// let component = Html::with(|cx| { -/// let user = cx.param::("username").cloned().unwrap_or(String::from("visitor")); +/// let user = cx.param::("username").cloned().unwrap_or("visitor".to_string()); /// html! { /// h1 { "Hello, " (user) } /// } diff --git a/src/core/theme/regions.rs b/src/core/theme/regions.rs index 1a2e0fb..8082aac 100644 --- a/src/core/theme/regions.rs +++ b/src/core/theme/regions.rs @@ -36,7 +36,7 @@ impl Default for Region { fn default() -> Self { Self { key: REGION_CONTENT, - name: String::from(REGION_CONTENT), + name: REGION_CONTENT.to_string(), } } } diff --git a/src/html.rs b/src/html.rs index 9f3d70c..37ab3f4 100644 --- a/src/html.rs +++ b/src/html.rs @@ -71,11 +71,11 @@ pub type OptionComponent = core::component::Typed /// use pagetop::prelude::*; /// /// // Texto normal, se escapa automáticamente para evitar inyección de HTML. -/// let fragment = PrepareMarkup::Escaped(String::from("Hola mundo")); +/// let fragment = PrepareMarkup::Escaped("Hola mundo".to_string()); /// assert_eq!(fragment.render().into_string(), "Hola <b>mundo</b>"); /// /// // HTML literal, se inserta directamente, sin escapado adicional. -/// let raw_html = PrepareMarkup::Raw(String::from("negrita")); +/// let raw_html = PrepareMarkup::Raw("negrita".to_string()); /// assert_eq!(raw_html.render().into_string(), "negrita"); /// /// // Fragmento ya preparado con la macro `html!`. diff --git a/src/html/assets/favicon.rs b/src/html/assets/favicon.rs index 1a8b29e..e951df5 100644 --- a/src/html/assets/favicon.rs +++ b/src/html/assets/favicon.rs @@ -129,7 +129,7 @@ impl Favicon { icon_color: Option, ) -> Self { let icon_type = match icon_source.rfind('.') { - Some(i) => match icon_source[i..].to_owned().to_lowercase().as_str() { + Some(i) => match icon_source[i..].to_string().to_lowercase().as_str() { ".avif" => Some("image/avif"), ".gif" => Some("image/gif"), ".ico" => Some("image/x-icon"), diff --git a/src/html/attr_classes.rs b/src/html/attr_classes.rs index 91ccfaf..098c26c 100644 --- a/src/html/attr_classes.rs +++ b/src/html/attr_classes.rs @@ -37,7 +37,7 @@ pub enum ClassesOp { /// .with_value(ClassesOp::Add, "Active") /// .with_value(ClassesOp::Remove, "btn-primary"); /// -/// assert_eq!(classes.get(), Some(String::from("btn active"))); +/// assert_eq!(classes.get(), Some("btn active".to_string())); /// assert!(classes.contains("active")); /// ``` #[derive(AutoDefault, Clone, Debug)] diff --git a/src/html/attr_l10n.rs b/src/html/attr_l10n.rs index 3e8a4e4..8250c74 100644 --- a/src/html/attr_l10n.rs +++ b/src/html/attr_l10n.rs @@ -17,13 +17,13 @@ use crate::{builder_fn, AutoDefault}; /// // Español disponible. /// assert_eq!( /// hello.lookup(&LangMatch::resolve("es-ES")), -/// Some(String::from("¡Hola mundo!")) +/// Some("¡Hola mundo!".to_string()) /// ); /// /// // Japonés no disponible, traduce al idioma de respaldo ("en-US"). /// assert_eq!( /// hello.lookup(&LangMatch::resolve("ja-JP")), -/// Some(String::from("Hello world!")) +/// Some("Hello world!".to_string()) /// ); /// /// // Uso típico en un atributo: diff --git a/src/html/attr_value.rs b/src/html/attr_value.rs index c70229f..4e03120 100644 --- a/src/html/attr_value.rs +++ b/src/html/attr_value.rs @@ -36,7 +36,7 @@ impl AttrValue { self.0 = if value.is_empty() { None } else { - Some(value.to_owned()) + Some(value.to_string()) }; self } diff --git a/src/html/context.rs b/src/html/context.rs index 26e2478..8ef3a05 100644 --- a/src/html/context.rs +++ b/src/html/context.rs @@ -285,7 +285,7 @@ impl Context { /// /// let cx = Context::new(None) /// .with_param("usuario_id", 42_i32) - /// .with_param("titulo", String::from("Hola")); + /// .with_param("titulo", "Hola".to_string()); /// /// let id: &i32 = cx.get_param("usuario_id").unwrap(); /// let titulo: &String = cx.get_param("titulo").unwrap(); @@ -318,7 +318,7 @@ impl Context { /// /// let mut cx = Context::new(None) /// .with_param("contador", 7_i32) - /// .with_param("titulo", String::from("Hola")); + /// .with_param("titulo", "Hola".to_string()); /// /// let n: i32 = cx.take_param("contador").unwrap(); /// assert!(cx.get_param::("contador").is_err()); // ya no está @@ -416,7 +416,7 @@ impl Contextual for Context { /// /// let cx = Context::new(None) /// .with_param("usuario_id", 42_i32) - /// .with_param("titulo", String::from("Hola")) + /// .with_param("titulo", "Hola".to_string()) /// .with_param("flags", vec!["a", "b"]); /// ``` #[builder_fn] @@ -484,7 +484,7 @@ impl Contextual for Context { /// ```rust /// use pagetop::prelude::*; /// - /// let cx = Context::new(None).with_param("username", String::from("Alice")); + /// let cx = Context::new(None).with_param("username", "Alice".to_string()); /// /// // Devuelve Some(&String) si existe y coincide el tipo. /// assert_eq!(cx.param::("username").map(|s| s.as_str()), Some("Alice")); @@ -533,7 +533,7 @@ impl Contextual for Context { .replace(' ', "_") .to_lowercase(); let prefix = if prefix.is_empty() { - "prefix".to_owned() + "prefix".to_string() } else { prefix }; diff --git a/src/locale.rs b/src/locale.rs index cf44dd8..2bf0da9 100644 --- a/src/locale.rs +++ b/src/locale.rs @@ -165,7 +165,7 @@ pub trait LangId { /// /// // Idioma no soportado. /// let lang = LangMatch::resolve("ja-JP"); -/// assert_eq!(lang, LangMatch::Unsupported(String::from("ja-JP"))); +/// assert_eq!(lang, LangMatch::Unsupported("ja-JP".to_string())); /// ``` /// /// Con la siguiente instrucción siempre se obtiene un [`LanguageIdentifier`] válido, ya sea porque @@ -222,7 +222,7 @@ impl LangMatch { } // En caso contrario, indica que el idioma no está soportado. - Self::Unsupported(String::from(language)) + Self::Unsupported(language.to_string()) } /// Devuelve el [`LanguageIdentifier`] si el idioma fue reconocido. diff --git a/src/util.rs b/src/util.rs index 808014b..56b098d 100644 --- a/src/util.rs +++ b/src/util.rs @@ -110,15 +110,15 @@ macro_rules! hm { /// /// // Concatena todos los fragmentos directamente. /// let result = join!("Hello", " ", "World"); -/// assert_eq!(result, String::from("Hello World")); +/// assert_eq!(result, "Hello World".to_string()); /// /// // También funciona con valores vacíos. /// let result_with_empty = join!("Hello", "", "World"); -/// assert_eq!(result_with_empty, String::from("HelloWorld")); +/// assert_eq!(result_with_empty, "HelloWorld".to_string()); /// /// // Un único fragmento devuelve el mismo valor. /// let single_result = join!("Hello"); -/// assert_eq!(single_result, String::from("Hello")); +/// assert_eq!(single_result, "Hello".to_string()); /// ``` #[macro_export] macro_rules! join { @@ -141,11 +141,11 @@ macro_rules! join { /// /// // Concatena los fragmentos no vacíos con un espacio como separador. /// let result_with_separator = join_opt!(["Hello", "", "World"]; " "); -/// assert_eq!(result_with_separator, Some(String::from("Hello World"))); +/// assert_eq!(result_with_separator, Some("Hello World".to_string())); /// /// // Concatena los fragmentos no vacíos sin un separador. /// let result_without_separator = join_opt!(["Hello", "", "World"]); -/// assert_eq!(result_without_separator, Some(String::from("HelloWorld"))); +/// assert_eq!(result_without_separator, Some("HelloWorld".to_string())); /// /// // Devuelve `None` si todos los fragmentos están vacíos. /// let result_empty = join_opt!(["", "", ""]); @@ -185,19 +185,19 @@ macro_rules! join_opt { /// /// // Concatena los dos fragmentos cuando ambos no están vacíos. /// let result = join_pair!(first, separator, second); -/// assert_eq!(result, String::from("Hello-World")); +/// assert_eq!(result, "Hello-World".to_string()); /// /// // Si el primer fragmento está vacío, devuelve el segundo. /// let result_empty_first = join_pair!("", separator, second); -/// assert_eq!(result_empty_first, String::from("World")); +/// assert_eq!(result_empty_first, "World".to_string()); /// /// // Si el segundo fragmento está vacío, devuelve el primero. /// let result_empty_second = join_pair!(first, separator, ""); -/// assert_eq!(result_empty_second, String::from("Hello")); +/// assert_eq!(result_empty_second, "Hello".to_string()); /// /// // Si ambos fragmentos están vacíos, devuelve una cadena vacía. /// let result_both_empty = join_pair!("", separator, ""); -/// assert_eq!(result_both_empty, String::from("")); +/// assert_eq!(result_both_empty, "".to_string()); /// ``` #[macro_export] macro_rules! join_pair { @@ -224,11 +224,11 @@ macro_rules! join_pair { /// /// // Concatena los fragmentos. /// let result = join_strict!(["Hello", "World"]); -/// assert_eq!(result, Some(String::from("HelloWorld"))); +/// assert_eq!(result, Some("HelloWorld".to_string())); /// /// // Concatena los fragmentos con un separador. /// let result_with_separator = join_strict!(["Hello", "World"]; " "); -/// assert_eq!(result_with_separator, Some(String::from("Hello World"))); +/// assert_eq!(result_with_separator, Some("Hello World".to_string())); /// /// // Devuelve `None` si alguno de los fragmentos está vacío. /// let result_with_empty = join_strict!(["Hello", "", "World"]); diff --git a/tests/component_html.rs b/tests/component_html.rs index bd7f3c0..851315a 100644 --- a/tests/component_html.rs +++ b/tests/component_html.rs @@ -17,7 +17,7 @@ async fn component_html_renders_static_markup() { #[pagetop::test] async fn component_html_renders_using_context_param() { - let mut cx = Context::new(None).with_param("username", String::from("Alice")); + let mut cx = Context::new(None).with_param("username", "Alice".to_string()); let component = Html::with(|cx| { let name = cx.param::("username").cloned().unwrap_or_default(); From e3ca6079ffc76d226be2f0afae641e8d5190cdfc Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Thu, 11 Sep 2025 19:58:50 +0200 Subject: [PATCH 31/35] =?UTF-8?q?=F0=9F=94=A5=20Elimina=20`Render`=20para?= =?UTF-8?q?=20usar=20siempre=20el=20contexto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/component/definition.rs | 2 +- src/core/theme.rs | 2 +- src/html.rs | 6 +- src/html/assets.rs | 16 ++-- src/html/assets/favicon.rs | 10 ++- src/html/assets/javascript.rs | 144 +++++++++++++++++++++---------- src/html/assets/stylesheet.rs | 34 ++++---- src/html/context.rs | 39 ++++++--- src/html/maud.rs | 21 +---- src/response/page.rs | 2 +- tests/component_poweredby.rs | 31 ++++--- tests/html.rs | 44 +++++----- 12 files changed, 205 insertions(+), 146 deletions(-) diff --git a/src/core/component/definition.rs b/src/core/component/definition.rs index d547c4b..333cf69 100644 --- a/src/core/component/definition.rs +++ b/src/core/component/definition.rs @@ -1,6 +1,6 @@ use crate::base::action; use crate::core::{AnyInfo, TypeInfo}; -use crate::html::{html, Context, Markup, PrepareMarkup, Render}; +use crate::html::{html, Context, Markup, PrepareMarkup}; /// Define la función de renderizado para todos los componentes. /// diff --git a/src/core/theme.rs b/src/core/theme.rs index 5889dcf..61d820b 100644 --- a/src/core/theme.rs +++ b/src/core/theme.rs @@ -7,7 +7,7 @@ //! Un tema **declara las regiones** (*cabecera*, *barra lateral*, *pie*, etc.) que estarán //! disponibles para colocar contenido. Los temas son responsables últimos de los estilos, //! tipografías, espaciados y cualquier otro detalle visual o de comportamiento (como animaciones, -//! *scripts* de interfaz, etc.). +//! scripts de interfaz, etc.). //! //! Los temas son extensiones que implementan [`Extension`](crate::core::extension::Extension); por //! lo que se instancian, declaran sus dependencias y se inician igual que el resto de extensiones; diff --git a/src/html.rs b/src/html.rs index 37ab3f4..4858bbf 100644 --- a/src/html.rs +++ b/src/html.rs @@ -1,7 +1,7 @@ //! HTML en código. mod maud; -pub use maud::{display, html, html_private, Escaper, Markup, PreEscaped, Render, DOCTYPE}; +pub use maud::{display, html, html_private, Escaper, Markup, PreEscaped, DOCTYPE}; // HTML DOCUMENT ASSETS **************************************************************************** @@ -119,11 +119,9 @@ impl PrepareMarkup { PrepareMarkup::With(markup) => markup.is_empty(), } } -} -impl Render for PrepareMarkup { /// Integra el renderizado fácilmente en la macro [`html!`]. - fn render(&self) -> Markup { + pub fn render(&self) -> Markup { match self { PrepareMarkup::None => html! {}, PrepareMarkup::Escaped(text) => html! { (text) }, diff --git a/src/html/assets.rs b/src/html/assets.rs index ee5431f..41cd471 100644 --- a/src/html/assets.rs +++ b/src/html/assets.rs @@ -2,10 +2,10 @@ pub mod favicon; pub mod javascript; pub mod stylesheet; -use crate::html::{html, Markup, Render}; +use crate::html::{html, Context, Markup}; use crate::{AutoDefault, Weight}; -/// Representación genérica de un *script* [`JavaScript`](crate::html::JavaScript) o una hoja de +/// Representación genérica de un script [`JavaScript`](crate::html::JavaScript) o una hoja de /// estilos [`StyleSheet`](crate::html::StyleSheet). /// /// Estos recursos se incluyen en los conjuntos de recursos ([`Assets`]) que suelen renderizarse en @@ -13,12 +13,15 @@ use crate::{AutoDefault, Weight}; /// /// Cada recurso se identifica por un **nombre único** ([`Asset::name()`]), usado como clave; y un /// **peso** ([`Asset::weight()`]), que determina su orden relativo de renderizado. -pub trait Asset: Render { +pub trait Asset { /// Devuelve el nombre del recurso, utilizado como clave única. fn name(&self) -> &str; /// Devuelve el peso del recurso, usado para ordenar el renderizado de menor a mayor peso. fn weight(&self) -> Weight; + + /// Renderiza el recurso en el contexto proporcionado. + fn render(&self, cx: &mut Context) -> Markup; } /// Gestión común para conjuntos de recursos como [`JavaScript`](crate::html::JavaScript) y @@ -77,16 +80,13 @@ impl Assets { false } } -} -impl Render for Assets { - fn render(&self) -> Markup { + pub fn render(&self, cx: &mut Context) -> Markup { let mut assets = self.0.iter().collect::>(); assets.sort_by_key(|a| a.weight()); - html! { @for a in assets { - (a) + (a.render(cx)) } } } diff --git a/src/html/assets/favicon.rs b/src/html/assets/favicon.rs index e951df5..d731b8f 100644 --- a/src/html/assets/favicon.rs +++ b/src/html/assets/favicon.rs @@ -1,4 +1,4 @@ -use crate::html::{html, Markup, Render}; +use crate::html::{html, Context, Markup}; use crate::AutoDefault; /// Un **Favicon** es un recurso gráfico que usa el navegador como icono asociado al sitio. @@ -151,10 +151,12 @@ impl Favicon { }); self } -} -impl Render for Favicon { - fn render(&self) -> Markup { + /// Renderiza el **Favicon** completo con todas las etiquetas declaradas. + /// + /// El parámetro `Context` se acepta por coherencia con el resto de *assets*, aunque en este + /// caso es ignorado. + pub fn render(&self, _cx: &mut Context) -> Markup { html! { @for item in &self.0 { (item) diff --git a/src/html/assets/javascript.rs b/src/html/assets/javascript.rs index be6f906..a8ed3e8 100644 --- a/src/html/assets/javascript.rs +++ b/src/html/assets/javascript.rs @@ -1,35 +1,45 @@ use crate::html::assets::Asset; -use crate::html::{html, Markup, Render}; +use crate::html::{html, Context, Markup, PreEscaped}; use crate::{join, join_pair, AutoDefault, Weight}; // Define el origen del recurso JavaScript y cómo debe cargarse en el navegador. // // Los distintos modos de carga permiten optimizar el rendimiento y controlar el comportamiento del -// script. +// script en relación con el análisis del documento HTML y la ejecución del resto de scripts. // -// - [`From`] – Carga el script de forma estándar con la etiqueta ``. El parámetro `name` se usa como identificador interno del - /// *script*. - pub fn inline(name: impl Into, script: impl Into) -> Self { + /// script. + /// + /// La función *closure* recibirá el [`Context`] por si se necesita durante el renderizado. + pub fn inline(name: impl Into, f: F) -> Self + where + F: Fn(&mut Context) -> String + Send + Sync + 'static, + { JavaScript { - source: Source::Inline(name.into(), script.into()), + source: Source::Inline(name.into(), Box::new(f)), ..Default::default() } } - /// Crea un **script embebido** que se ejecuta automáticamente al terminar de cargarse el - /// documento HTML. + /// Crea un **script embebido** que se ejecuta cuando **el DOM está listo**. /// - /// El código se envuelve automáticamente en un `addEventListener('DOMContentLoaded', ...)`. El - /// parámetro `name` se usa como identificador interno del *script*. - pub fn on_load(name: impl Into, script: impl Into) -> Self { + /// El código se envuelve en un `addEventListener('DOMContentLoaded',function(){...})` que lo + /// ejecuta tras analizar el documento HTML, **no** espera imágenes ni otros recursos externos. + /// Útil para inicializaciones que no dependen de `await`. El parámetro `name` se usa como + /// identificador interno del script. + /// + /// Los scripts con `defer` se ejecutan antes de `DOMContentLoaded`. + /// + /// La función *closure* recibirá el [`Context`] por si se necesita durante el renderizado. + pub fn on_load(name: impl Into, f: F) -> Self + where + F: Fn(&mut Context) -> String + Send + Sync + 'static, + { JavaScript { - source: Source::OnLoad(name.into(), script.into()), + source: Source::OnLoad(name.into(), Box::new(f)), + ..Default::default() + } + } + + /// Crea un **script embebido** con un **manejador asíncrono**. + /// + /// El código se envuelve en un `addEventListener('DOMContentLoaded',async()=>{...})`, que + /// emplea una función `async` para que el cuerpo devuelto por la función *closure* pueda usar + /// `await`. Ideal para hidratar la interfaz, cargar módulos dinámicos o realizar lecturas + /// iniciales. + /// + /// La función *closure* recibirá el [`Context`] por si se necesita durante el renderizado. + pub fn on_load_async(name: impl Into, f: F) -> Self + where + F: Fn(&mut Context) -> String + Send + Sync + 'static, + { + JavaScript { + source: Source::OnLoadAsync(name.into(), Box::new(f)), ..Default::default() } } // JavaScript BUILDER ************************************************************************** - /// Asocia una versión al recurso (usada para control de la caché del navegador). + /// Asocia una **versión** al recurso (usada para control de la caché del navegador). /// - /// Si `version` está vacío, no se añade ningún parámetro a la URL. + /// Si `version` está vacío, **no** se añade ningún parámetro a la URL. pub fn with_version(mut self, version: impl Into) -> Self { self.version = version.into(); self } - /// Modifica el peso del recurso. + /// Modifica el **peso** del recurso. /// /// Los recursos se renderizan de menor a mayor peso. Por defecto es `0`, que respeta el orden /// de creación. @@ -140,7 +194,7 @@ impl JavaScript { impl Asset for JavaScript { /// Devuelve el nombre del recurso, utilizado como clave única. /// - /// Para *scripts* externos es la ruta del recurso; para *scripts* embebidos, un identificador. + /// Para scripts externos es la ruta del recurso; para scripts embebidos, un identificador. fn name(&self) -> &str { match &self.source { Source::From(path) => path, @@ -148,16 +202,15 @@ impl Asset for JavaScript { Source::Async(path) => path, Source::Inline(name, _) => name, Source::OnLoad(name, _) => name, + Source::OnLoadAsync(name, _) => name, } } fn weight(&self) -> Weight { self.weight } -} -impl Render for JavaScript { - fn render(&self) -> Markup { + fn render(&self, cx: &mut Context) -> Markup { match &self.source { Source::From(path) => html! { script src=(join_pair!(path, "?v=", self.version.as_str())) {}; @@ -168,12 +221,15 @@ impl Render for JavaScript { Source::Async(path) => html! { script src=(join_pair!(path, "?v=", self.version.as_str())) async {}; }, - Source::Inline(_, code) => html! { - script { (code) }; + Source::Inline(_, f) => html! { + script { (PreEscaped((f)(cx))) }; }, - Source::OnLoad(_, code) => html! { (join!( - "document.addEventListener('DOMContentLoaded',function(){", code, "});" - )) }, + Source::OnLoad(_, f) => html! { script { (PreEscaped(join!( + "document.addEventListener(\"DOMContentLoaded\",function(){", (f)(cx), "});" + ))) } }, + Source::OnLoadAsync(_, f) => html! { script { (PreEscaped(join!( + "document.addEventListener(\"DOMContentLoaded\",async()=>{", (f)(cx), "});" + ))) } }, } } } diff --git a/src/html/assets/stylesheet.rs b/src/html/assets/stylesheet.rs index 38a97d7..3ecc77f 100644 --- a/src/html/assets/stylesheet.rs +++ b/src/html/assets/stylesheet.rs @@ -1,5 +1,5 @@ use crate::html::assets::Asset; -use crate::html::{html, Markup, PreEscaped, Render}; +use crate::html::{html, Context, Markup, PreEscaped}; use crate::{join_pair, AutoDefault, Weight}; // Define el origen del recurso CSS y cómo se incluye en el documento. @@ -14,7 +14,8 @@ use crate::{join_pair, AutoDefault, Weight}; enum Source { #[default] From(String), - Inline(String, String), + // `name`, `closure(Context) -> String`. + Inline(String, Box String + Send + Sync>), } /// Define el medio objetivo para la hoja de estilos. @@ -34,7 +35,7 @@ pub enum TargetMedia { Speech, } -/// Devuelve el texto asociado al punto de interrupción usado por Bootstrap. +/// Devuelve el valor para el atributo `media` (`Some(...)`) o `None` para `Default`. #[rustfmt::skip] impl TargetMedia { fn as_str_opt(&self) -> Option<&str> { @@ -69,12 +70,12 @@ impl TargetMedia { /// .with_weight(-10); /// /// // Crea una hoja de estilos embebida en el documento HTML. -/// let embedded = StyleSheet::inline("custom_theme", r#" +/// let embedded = StyleSheet::inline("custom_theme", |_| r#" /// body { /// background-color: #f5f5f5; /// font-family: 'Segoe UI', sans-serif; /// } -/// "#); +/// "#.to_string()); /// ``` #[rustfmt::skip] #[derive(AutoDefault)] @@ -100,9 +101,14 @@ impl StyleSheet { /// /// Equivale a ``. El parámetro `name` se usa como identificador interno del /// recurso. - pub fn inline(name: impl Into, styles: impl Into) -> Self { + /// + /// La función *closure* recibirá el [`Context`] por si se necesita durante el renderizado. + pub fn inline(name: impl Into, f: F) -> Self + where + F: Fn(&mut Context) -> String + Send + Sync + 'static, + { StyleSheet { - source: Source::Inline(name.into(), styles.into()), + source: Source::Inline(name.into(), Box::new(f)), ..Default::default() } } @@ -133,9 +139,9 @@ impl StyleSheet { /// Según el argumento `media`: /// /// - `TargetMedia::Default` - Se aplica en todos los casos (medio por defecto). - /// - `TargetMedia::Print` - Se aplican cuando el documento se imprime. - /// - `TargetMedia::Screen` - Se aplican en pantallas. - /// - `TargetMedia::Speech` - Se aplican en dispositivos que convierten el texto a voz. + /// - `TargetMedia::Print` - Se aplica cuando el documento se imprime. + /// - `TargetMedia::Screen` - Se aplica en pantallas. + /// - `TargetMedia::Speech` - Se aplica en dispositivos que convierten el texto a voz. pub fn for_media(mut self, media: TargetMedia) -> Self { self.media = media; self @@ -156,10 +162,8 @@ impl Asset for StyleSheet { fn weight(&self) -> Weight { self.weight } -} -impl Render for StyleSheet { - fn render(&self) -> Markup { + fn render(&self, cx: &mut Context) -> Markup { match &self.source { Source::From(path) => html! { link @@ -167,8 +171,8 @@ impl Render for StyleSheet { href=(join_pair!(path, "?v=", self.version.as_str())) media=[self.media.as_str_opt()]; }, - Source::Inline(_, code) => html! { - style { (PreEscaped(code)) }; + Source::Inline(_, f) => html! { + style { (PreEscaped((f)(cx))) }; }, } } diff --git a/src/html/context.rs b/src/html/context.rs index 8ef3a05..2f3e0f0 100644 --- a/src/html/context.rs +++ b/src/html/context.rs @@ -25,9 +25,9 @@ pub enum AssetsOp { RemoveStyleSheet(&'static str), // JavaScripts. - /// Añade un *script* JavaScript al documento. + /// Añade un script JavaScript al documento. AddJavaScript(JavaScript), - /// Elimina un *script* por su ruta o identificador. + /// Elimina un script por su ruta o identificador. RemoveJavaScript(&'static str), } @@ -55,7 +55,7 @@ pub enum ErrorParam { /// - Almacenar la **solicitud HTTP** de origen. /// - Seleccionar **tema** y **composición** (*layout*) de renderizado. /// - Administrar **recursos** del documento como el icono [`Favicon`], las hojas de estilo -/// [`StyleSheet`] o los *scripts* [`JavaScript`] mediante [`AssetsOp`]. +/// [`StyleSheet`] o los scripts [`JavaScript`] mediante [`AssetsOp`]. /// - Leer y mantener **parámetros dinámicos tipados** de contexto. /// - Generar **identificadores únicos** por tipo de componente. /// @@ -139,7 +139,7 @@ pub trait Contextual: LangId { /// Devuelve las hojas de estilo de los recursos del contexto. fn stylesheets(&self) -> &Assets; - /// Devuelve los *scripts* JavaScript de los recursos del contexto. + /// Devuelve los scripts JavaScript de los recursos del contexto. fn javascripts(&self) -> &Assets; // Contextual HELPERS ************************************************************************** @@ -155,7 +155,7 @@ pub trait Contextual: LangId { /// /// Extiende [`Contextual`] con métodos para **instanciar** y configurar un nuevo contexto, /// **renderizar los recursos** del documento (incluyendo el [`Favicon`], las hojas de estilo -/// [`StyleSheet`] y los *scripts* [`JavaScript`]), o extender el uso de **parámetros dinámicos +/// [`StyleSheet`] y los scripts [`JavaScript`]), o extender el uso de **parámetros dinámicos /// tipados** con nuevos métodos. /// /// # Ejemplos @@ -258,14 +258,29 @@ impl Context { // Context RENDER ****************************************************************************** /// Renderiza los recursos del contexto. - pub fn render_assets(&self) -> Markup { - html! { - @if let Some(favicon) = &self.favicon { - (favicon) + pub fn render_assets(&mut self) -> Markup { + use std::mem::take as mem_take; + + // Extrae temporalmente los recursos. + let favicon = mem_take(&mut self.favicon); // Deja valor por defecto (None) en self. + let stylesheets = mem_take(&mut self.stylesheets); // Assets::default() en self. + let javascripts = mem_take(&mut self.javascripts); // Assets::default() en self. + + // Renderiza con `&mut self` como contexto. + let markup = html! { + @if let Some(fi) = &favicon { + (fi.render(self)) } - (self.stylesheets) - (self.javascripts) - } + (stylesheets.render(self)) + (javascripts.render(self)) + }; + + // Restaura los campos tal y como estaban. + self.favicon = favicon; + self.stylesheets = stylesheets; + self.javascripts = javascripts; + + markup } // Context PARAMS ****************************************************************************** diff --git a/src/html/maud.rs b/src/html/maud.rs index 9bf179e..6536036 100644 --- a/src/html/maud.rs +++ b/src/html/maud.rs @@ -69,23 +69,6 @@ impl fmt::Write for Escaper<'_> { /// `.render()` or `.render_to()`. Since the default definitions of /// these methods call each other, not doing this will result in /// infinite recursion. -/// -/// # Example -/// -/// ```rust -/// use pagetop::prelude::*; -/// -/// /// Provides a shorthand for linking to a CSS stylesheet. -/// pub struct Stylesheet(&'static str); -/// -/// impl Render for Stylesheet { -/// fn render(&self) -> Markup { -/// html! { -/// link rel="stylesheet" type="text/css" href=(self.0); -/// } -/// } -/// } -/// ``` pub trait Render { /// Renders `self` as a block of `Markup`. fn render(&self) -> Markup { @@ -238,6 +221,10 @@ impl Markup { pub fn is_empty(&self) -> bool { self.0.is_empty() } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } } impl> PreEscaped { diff --git a/src/response/page.rs b/src/response/page.rs index 86a0bdc..2dc27f9 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -202,7 +202,7 @@ impl Page { } /// Renderiza los recursos de la página. - pub fn render_assets(&self) -> Markup { + pub fn render_assets(&mut self) -> Markup { self.context.render_assets() } diff --git a/tests/component_poweredby.rs b/tests/component_poweredby.rs index 9f8e822..e4551d1 100644 --- a/tests/component_poweredby.rs +++ b/tests/component_poweredby.rs @@ -8,10 +8,10 @@ async fn poweredby_default_shows_only_pagetop_recognition() { let html = render_component(&p); // Debe mostrar el bloque de reconocimiento a PageTop. - assert!(html.contains("poweredby__pagetop")); + assert!(html.as_str().contains("poweredby__pagetop")); // Y NO debe mostrar el bloque de copyright. - assert!(!html.contains("poweredby__copyright")); + assert!(!html.as_str().contains("poweredby__copyright")); } #[pagetop::test] @@ -22,17 +22,20 @@ async fn poweredby_new_includes_current_year_and_app_name() { let html = render_component(&p); let year = Utc::now().format("%Y").to_string(); - assert!(html.contains(&year), "HTML should include the current year"); + assert!( + html.as_str().contains(&year), + "HTML should include the current year" + ); // El nombre de la app proviene de `global::SETTINGS.app.name`. let app_name = &global::SETTINGS.app.name; assert!( - html.contains(app_name), + html.as_str().contains(app_name), "HTML should include the application name" ); // Debe existir el span de copyright. - assert!(html.contains("poweredby__copyright")); + assert!(html.as_str().contains("poweredby__copyright")); } #[pagetop::test] @@ -43,8 +46,8 @@ async fn poweredby_with_copyright_overrides_text() { let p = PoweredBy::default().with_copyright(Some(custom)); let html = render_component(&p); - assert!(html.contains(custom)); - assert!(html.contains("poweredby__copyright")); + assert!(html.as_str().contains(custom)); + assert!(html.as_str().contains("poweredby__copyright")); } #[pagetop::test] @@ -54,9 +57,9 @@ async fn poweredby_with_copyright_none_hides_text() { let p = PoweredBy::new().with_copyright(None::); let html = render_component(&p); - assert!(!html.contains("poweredby__copyright")); + assert!(!html.as_str().contains("poweredby__copyright")); // El reconocimiento a PageTop siempre debe aparecer. - assert!(html.contains("poweredby__pagetop")); + assert!(html.as_str().contains("poweredby__pagetop")); } #[pagetop::test] @@ -67,7 +70,7 @@ async fn poweredby_link_points_to_crates_io() { let html = render_component(&p); assert!( - html.contains("https://pagetop.cillero.es"), + html.as_str().contains("https://pagetop.cillero.es"), "Link should point to pagetop.cillero.es" ); } @@ -89,12 +92,8 @@ async fn poweredby_getter_reflects_internal_state() { // HELPERS ***************************************************************************************** -fn render(x: &impl Render) -> String { - x.render().into_string() -} - -fn render_component(c: &C) -> String { +fn render_component(c: &C) -> Markup { let mut cx = Context::default(); let pm = c.prepare_component(&mut cx); - render(&pm) + pm.render() } diff --git a/tests/html.rs b/tests/html.rs index 1499c70..ae4517b 100644 --- a/tests/html.rs +++ b/tests/html.rs @@ -2,19 +2,19 @@ use pagetop::prelude::*; #[pagetop::test] async fn prepare_markup_render_none_is_empty_string() { - assert_eq!(render(&PrepareMarkup::None), ""); + assert_eq!(PrepareMarkup::None.render().as_str(), ""); } #[pagetop::test] async fn prepare_markup_render_escaped_escapes_html_and_ampersands() { - let pm = PrepareMarkup::Escaped(String::from("& \" ' ")); - assert_eq!(render(&pm), "<b>& " ' </b>"); + let pm = PrepareMarkup::Escaped("& \" ' ".to_string()); + assert_eq!(pm.render().as_str(), "<b>& " ' </b>"); } #[pagetop::test] async fn prepare_markup_render_raw_is_inserted_verbatim() { - let pm = PrepareMarkup::Raw(String::from("bold")); - assert_eq!(render(&pm), "bold"); + let pm = PrepareMarkup::Raw("bold".to_string()); + assert_eq!(pm.render().as_str(), "bold"); } #[pagetop::test] @@ -24,7 +24,7 @@ async fn prepare_markup_render_with_keeps_structure() { p { "This is a paragraph." } }); assert_eq!( - render(&pm), + pm.render().as_str(), "

Sample title

This is a paragraph.

" ); } @@ -33,7 +33,7 @@ async fn prepare_markup_render_with_keeps_structure() { async fn prepare_markup_does_not_double_escape_when_wrapped_in_html_macro() { // Escaped: dentro de `html!` no debe volver a escaparse. let escaped = PrepareMarkup::Escaped("x".into()); - let wrapped_escaped = html! { div { (escaped) } }; + let wrapped_escaped = html! { div { (escaped.render()) } }; assert_eq!( wrapped_escaped.into_string(), "
<i>x</i>
" @@ -41,12 +41,12 @@ async fn prepare_markup_does_not_double_escape_when_wrapped_in_html_macro() { // Raw: tampoco debe escaparse al integrarlo. let raw = PrepareMarkup::Raw("x".into()); - let wrapped_raw = html! { div { (raw) } }; + let wrapped_raw = html! { div { (raw.render()) } }; assert_eq!(wrapped_raw.into_string(), "
x
"); // With: debe incrustar el Markup tal cual. let with = PrepareMarkup::With(html! { span.title { "ok" } }); - let wrapped_with = html! { div { (with) } }; + let wrapped_with = html! { div { (with.render()) } }; assert_eq!( wrapped_with.into_string(), "
ok
" @@ -57,11 +57,14 @@ async fn prepare_markup_does_not_double_escape_when_wrapped_in_html_macro() { async fn prepare_markup_unicode_is_preserved() { // Texto con acentos y emojis debe conservarse (salvo el escape HTML de signos). let esc = PrepareMarkup::Escaped("Hello, tomorrow coffee ☕ & donuts!".into()); - assert_eq!(render(&esc), "Hello, tomorrow coffee ☕ & donuts!"); + assert_eq!( + esc.render().as_str(), + "Hello, tomorrow coffee ☕ & donuts!" + ); // Raw debe pasar íntegro. let raw = PrepareMarkup::Raw("Title — section © 2025".into()); - assert_eq!(render(&raw), "Title — section © 2025"); + assert_eq!(raw.render().as_str(), "Title — section © 2025"); } #[pagetop::test] @@ -69,11 +72,11 @@ async fn prepare_markup_is_empty_semantics() { assert!(PrepareMarkup::None.is_empty()); assert!(PrepareMarkup::Escaped(String::new()).is_empty()); - assert!(PrepareMarkup::Escaped(String::from("")).is_empty()); - assert!(!PrepareMarkup::Escaped(String::from("x")).is_empty()); + assert!(PrepareMarkup::Escaped("".to_string()).is_empty()); + assert!(!PrepareMarkup::Escaped("x".to_string()).is_empty()); assert!(PrepareMarkup::Raw(String::new()).is_empty()); - assert!(PrepareMarkup::Raw(String::from("")).is_empty()); + assert!(PrepareMarkup::Raw("".to_string()).is_empty()); assert!(!PrepareMarkup::Raw("a".into()).is_empty()); assert!(PrepareMarkup::With(html! {}).is_empty()); @@ -94,17 +97,12 @@ async fn prepare_markup_equivalence_between_render_and_inline_in_html_macro() { ]; for pm in cases { - let rendered = render(&pm); - let in_macro = html! { (pm) }.into_string(); + let rendered = pm.render(); + let in_macro = html! { (rendered) }.into_string(); assert_eq!( - rendered, in_macro, + rendered.as_str(), + in_macro, "The output of Render and (pm) inside html! must match" ); } } - -// HELPERS ***************************************************************************************** - -fn render(x: &impl Render) -> String { - x.render().into_string() -} From 940e6aaf18e13a50312cc186f33b1d1c6e14c86f Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Fri, 12 Sep 2025 01:13:17 +0200 Subject: [PATCH 32/35] =?UTF-8?q?=E2=9C=A8=20[util]=20A=C3=B1ade=20`indoc`?= =?UTF-8?q?=20para=20indentar=20c=C3=B3digo=20bien?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 7 +++ Cargo.toml | 1 + src/lib.rs | 2 +- src/util.rs | 128 +++++++++++++++++++++++++++------------------------- 4 files changed, 75 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 944027d..3053e20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1307,6 +1307,12 @@ dependencies = [ "hashbrown 0.15.4", ] +[[package]] +name = "indoc" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" + [[package]] name = "inout" version = "0.1.4" @@ -1568,6 +1574,7 @@ dependencies = [ "config", "figlet-rs", "fluent-templates", + "indoc", "itoa", "pagetop-build", "pagetop-macros", diff --git a/Cargo.toml b/Cargo.toml index 8ccd69e..ab7551f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ colored = "3.0.0" concat-string = "1.0.1" config = { version = "0.15.13", default-features = false, features = ["toml"] } figlet-rs = "0.1.5" +indoc = "2.0.6" itoa = "1.0.15" parking_lot = "0.12.4" paste = { package = "pastey", version = "0.1.0" } diff --git a/src/lib.rs b/src/lib.rs index 93b8564..1c1ba2c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -138,7 +138,7 @@ pub type Weight = i8; // API ********************************************************************************************* -// Funciones y macros útiles. +// Macros y funciones útiles. pub mod util; // Carga las opciones de configuración. pub mod config; diff --git a/src/util.rs b/src/util.rs index 56b098d..cb10176 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,4 +1,4 @@ -//! Funciones y macros útiles. +//! Macros y funciones útiles. use crate::trace; @@ -6,67 +6,7 @@ use std::env; use std::io; use std::path::{Path, PathBuf}; -// FUNCIONES ÚTILES ******************************************************************************** - -/// Resuelve y valida la ruta de un directorio existente, devolviendo una ruta absoluta. -/// -/// - Si la ruta es relativa, se resuelve respecto al directorio del proyecto según la variable de -/// entorno `CARGO_MANIFEST_DIR` (si existe) o, en su defecto, respecto al directorio actual de -/// trabajo. -/// - Normaliza y valida la ruta final (resuelve `.`/`..` y enlaces simbólicos). -/// - Devuelve error si la ruta no existe o no es un directorio. -/// -/// # Ejemplos -/// -/// ```rust,no_run -/// use pagetop::prelude::*; -/// -/// // Ruta relativa, se resuelve respecto a CARGO_MANIFEST_DIR o al directorio actual (`cwd`). -/// println!("{:#?}", util::resolve_absolute_dir("documents")); -/// -/// // Ruta absoluta, se normaliza y valida tal cual. -/// println!("{:#?}", util::resolve_absolute_dir("/var/www")); -/// ``` -pub fn resolve_absolute_dir>(path: P) -> io::Result { - let path = path.as_ref(); - - let candidate = if path.is_absolute() { - path.to_path_buf() - } else { - // Directorio base CARGO_MANIFEST_DIR si está disponible; o current_dir() en su defecto. - env::var_os("CARGO_MANIFEST_DIR") - .map(PathBuf::from) - .or_else(|| env::current_dir().ok()) - .unwrap_or_else(|| PathBuf::from(".")) - .join(path) - }; - - // Resuelve `.`/`..`, enlaces simbólicos y obtiene la ruta absoluta en un único paso. - let absolute_dir = candidate.canonicalize()?; - - // Asegura que realmente es un directorio existente. - if absolute_dir.is_dir() { - Ok(absolute_dir) - } else { - Err({ - let msg = format!("Path \"{}\" is not a directory", absolute_dir.display()); - trace::warn!(msg); - io::Error::new(io::ErrorKind::InvalidInput, msg) - }) - } -} - -/// **Obsoleto desde la versión 0.3.0**: usar [`resolve_absolute_dir()`] en su lugar. -#[deprecated(since = "0.3.0", note = "Use `resolve_absolute_dir()` instead")] -pub fn absolute_dir(root_path: P, relative_path: Q) -> io::Result -where - P: AsRef, - Q: AsRef, -{ - resolve_absolute_dir(root_path.as_ref().join(relative_path.as_ref())) -} - -// MACROS ÚTILES *********************************************************************************** +// MACROS INTEGRADAS ******************************************************************************* #[doc(hidden)] pub use paste::paste; @@ -74,6 +14,10 @@ pub use paste::paste; #[doc(hidden)] pub use concat_string::concat_string; +pub use indoc::{concatdoc, formatdoc, indoc}; + +// MACROS ÚTILES *********************************************************************************** + #[macro_export] /// Macro para construir una colección de pares clave-valor. /// @@ -253,3 +197,63 @@ macro_rules! join_strict { } }}; } + +// FUNCIONES ÚTILES ******************************************************************************** + +/// Resuelve y valida la ruta de un directorio existente, devolviendo una ruta absoluta. +/// +/// - Si la ruta es relativa, se resuelve respecto al directorio del proyecto según la variable de +/// entorno `CARGO_MANIFEST_DIR` (si existe) o, en su defecto, respecto al directorio actual de +/// trabajo. +/// - Normaliza y valida la ruta final (resuelve `.`/`..` y enlaces simbólicos). +/// - Devuelve error si la ruta no existe o no es un directorio. +/// +/// # Ejemplos +/// +/// ```rust,no_run +/// use pagetop::prelude::*; +/// +/// // Ruta relativa, se resuelve respecto a CARGO_MANIFEST_DIR o al directorio actual (`cwd`). +/// println!("{:#?}", util::resolve_absolute_dir("documents")); +/// +/// // Ruta absoluta, se normaliza y valida tal cual. +/// println!("{:#?}", util::resolve_absolute_dir("/var/www")); +/// ``` +pub fn resolve_absolute_dir>(path: P) -> io::Result { + let path = path.as_ref(); + + let candidate = if path.is_absolute() { + path.to_path_buf() + } else { + // Directorio base CARGO_MANIFEST_DIR si está disponible; o current_dir() en su defecto. + env::var_os("CARGO_MANIFEST_DIR") + .map(PathBuf::from) + .or_else(|| env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")) + .join(path) + }; + + // Resuelve `.`/`..`, enlaces simbólicos y obtiene la ruta absoluta en un único paso. + let absolute_dir = candidate.canonicalize()?; + + // Asegura que realmente es un directorio existente. + if absolute_dir.is_dir() { + Ok(absolute_dir) + } else { + Err({ + let msg = format!("Path \"{}\" is not a directory", absolute_dir.display()); + trace::warn!(msg); + io::Error::new(io::ErrorKind::InvalidInput, msg) + }) + } +} + +/// **Obsoleto desde la versión 0.3.0**: usar [`resolve_absolute_dir()`] en su lugar. +#[deprecated(since = "0.3.0", note = "Use `resolve_absolute_dir()` instead")] +pub fn absolute_dir(root_path: P, relative_path: Q) -> io::Result +where + P: AsRef, + Q: AsRef, +{ + resolve_absolute_dir(root_path.as_ref().join(relative_path.as_ref())) +} From accab251d84d97b449cf0d9b600f377e1464ec29 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Thu, 18 Sep 2025 14:06:33 +0200 Subject: [PATCH 33/35] =?UTF-8?q?=F0=9F=9A=A8=20Ajustes=20menores=20sugeri?= =?UTF-8?q?dos=20por=20clippy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/pagetop-macros/src/lib.rs | 2 +- src/html/context.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/helpers/pagetop-macros/src/lib.rs b/helpers/pagetop-macros/src/lib.rs index 28c6b1b..5af5f9c 100644 --- a/helpers/pagetop-macros/src/lib.rs +++ b/helpers/pagetop-macros/src/lib.rs @@ -269,8 +269,8 @@ pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream { // Extrae atributos descartando la documentación para incluir en `alter_...()`. let non_doc_attrs: Vec<_> = attrs .iter() + .filter(|&a| !a.path().is_ident("doc")) .cloned() - .filter(|a| !a.path().is_ident("doc")) .collect(); // Documentación del método alter_...(). diff --git a/src/html/context.rs b/src/html/context.rs index 2f3e0f0..7b78268 100644 --- a/src/html/context.rs +++ b/src/html/context.rs @@ -314,7 +314,7 @@ impl Context { .ok_or_else(|| ErrorParam::TypeMismatch { key, expected: TypeInfo::FullName.of::(), - saved: *type_name, + saved: type_name, }) } From aae6c7df1582f6d956664af8a1c18e8e4967d7d1 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Thu, 18 Sep 2025 14:17:01 +0200 Subject: [PATCH 34/35] =?UTF-8?q?=F0=9F=8E=A8=20Mejora=20la=20p=C3=A1gina?= =?UTF-8?q?=20de=20bienvenida=20y=20el=20tema=20b=C3=A1sico?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convierte la estructura de la página de bienvenida en una composición del tema básico para ser usada en otros contextos. Por ejemplo, para la página de mantenimiento, o la página de inicio de la guía de uso de PageTop. --- src/base/extension/welcome.rs | 52 +++++++++++++++++++++++++++-------- src/base/theme/basic.rs | 4 +-- src/locale/en-US/base.ftl | 4 +++ src/locale/en-US/welcome.ftl | 29 +++++++++---------- src/locale/es-ES/base.ftl | 6 +++- src/locale/es-ES/welcome.ftl | 25 +++++++++-------- 6 files changed, 79 insertions(+), 41 deletions(-) diff --git a/src/base/extension/welcome.rs b/src/base/extension/welcome.rs index eaa9f33..e2a7712 100644 --- a/src/base/extension/welcome.rs +++ b/src/base/extension/welcome.rs @@ -30,22 +30,50 @@ async fn homepage(request: HttpRequest) -> ResultPage { .with_title(L10n::l("welcome_title")) .with_description(L10n::l("welcome_intro").with_arg("app", app)) .with_param("intro_button_text", L10n::l("welcome_powered")) - .with_param("intro_button_link", "https://pagetop.cillero.es".to_owned()) - .add_component(Html::with(|cx| { - html! { - p { (L10n::l("welcome_text1").using(cx)) } - p { (L10n::l("welcome_text2").using(cx)) } + .with_param("intro_button_link", "https://pagetop.cillero.es".to_string()) + .with_assets(AssetsOp::AddJavaScript(JavaScript::on_load_async("welcome-js", |cx| + util::indoc!(r#" + try { + const resp = await fetch("https://crates.io/api/v1/crates/pagetop"); + const data = await resp.json(); + const date = new Date(data.versions[0].created_at); + const formatted = date.toLocaleDateString("LANGID", { year: "numeric", month: "2-digit", day: "2-digit" }); + document.getElementById("welcome-release").src = `https://img.shields.io/badge/Release%20date-${encodeURIComponent(formatted)}-blue?label=LABEL&style=for-the-badge`; + document.getElementById("welcome-badges").style.display = "block"; + } catch (e) { + console.error("Failed to fetch release date from crates.io:", e); } + "#) + .replace("LANGID", cx.langid().to_string().as_str()) + .replace("LABEL", L10n::l("welcome_release_label").using(cx).as_str()) + .to_string(), + ))) + .add_component(Html::with(|cx| html! { + p { (L10n::l("welcome_text1").using(cx)) } + div id="welcome-badges" style="display: none; margin-bottom: 1.1rem;" { + img + src="https://img.shields.io/crates/v/pagetop.svg?label=PageTop&style=for-the-badge" + alt=[L10n::l("welcome_pagetop_label").lookup(cx)] {} (" ") + img + id="welcome-release" + alt=[L10n::l("welcome_release_label").lookup(cx)] {} (" ") + img + src=(format!( + "https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label={}&style=for-the-badge", + L10n::l("welcome_license_label").lookup(cx).unwrap_or_default() + )) + alt=[L10n::l("welcome_license_label").lookup(cx)] {} + } + p { (L10n::l("welcome_text2").using(cx)) } })) .add_component( Block::new() - .with_title(L10n::l("welcome_about")) - .add_component(Html::with(move |cx| { - html! { - p { (L10n::l("welcome_pagetop").using(cx)) } - p { (L10n::l("welcome_issues1").using(cx)) } - p { (L10n::l("welcome_issues2").with_arg("app", app).using(cx)) } - } + .with_title(L10n::l("welcome_notice_title")) + .add_component(Html::with(move |cx| html! { + p { (L10n::l("welcome_notice_1").using(cx)) } + p { (L10n::l("welcome_notice_2").using(cx)) } + p { (L10n::l("welcome_notice_3").using(cx)) } + p { (L10n::l("welcome_notice_4").with_arg("app", app).using(cx)) } })), ) .render() diff --git a/src/base/theme/basic.rs b/src/base/theme/basic.rs index fbf4caf..e652548 100644 --- a/src/base/theme/basic.rs +++ b/src/base/theme/basic.rs @@ -111,8 +111,8 @@ fn render_intro(page: &mut Page) -> Markup { div class="intro-footer__links" { a href="https://crates.io/crates/pagetop" target="_blank" rel="noreferrer" { ("Crates.io") } a href="https://docs.rs/pagetop" target="_blank" rel="noreferrer" { ("Docs.rs") } - a href="https://git.cillero.es/manuelcillero/pagetop" target="_blank" rel="noreferrer" { (L10n::l("welcome_code").using(page)) } - em { (L10n::l("welcome_have_fun").using(page)) } + a href="https://git.cillero.es/manuelcillero/pagetop" target="_blank" rel="noreferrer" { (L10n::l("intro_code").using(page)) } + em { (L10n::l("intro_have_fun").using(page)) } } } } diff --git a/src/locale/en-US/base.ftl b/src/locale/en-US/base.ftl index b2c9256..7a9701d 100644 --- a/src/locale/en-US/base.ftl +++ b/src/locale/en-US/base.ftl @@ -1,2 +1,6 @@ +# Basic theme, intro layout. +intro_code = Code +intro_have_fun = Coding is creating + # PoweredBy component. poweredby_pagetop = Powered by { $pagetop_link } diff --git a/src/locale/en-US/welcome.ftl b/src/locale/en-US/welcome.ftl index 7b7d74d..0a227f1 100644 --- a/src/locale/en-US/welcome.ftl +++ b/src/locale/en-US/welcome.ftl @@ -1,20 +1,21 @@ -welcome_extension_name = Default homepage -welcome_extension_description = Displays a landing page when none is configured. +welcome_extension_name = Default Homepage +welcome_extension_description = Displays a default homepage when none is configured. -welcome_page = Welcome Page -welcome_title = Hello world! +welcome_page = Welcome page +welcome_title = Hello, world! welcome_intro = Discover⚡{ $app } -welcome_powered = A web solution powered by PageTop! +welcome_powered = A web solution powered by PageTop -welcome_text1 = If you can read this page, it means that the PageTop server is running correctly but has not yet been fully configured. This usually means the site is either experiencing temporary issues or is undergoing routine maintenance. -welcome_text2 = If the issue persists, please contact your system administrator for assistance. +welcome_pagetop_label = PageTop version on Crates.io +welcome_release_label = Release date +welcome_license_label = License -welcome_about = About -welcome_pagetop = PageTop is a Rust-based web development framework for building modular, extensible, and configurable web solutions. +welcome_text1 = PageTop is a Rust-based web development framework designed to build modular, extensible, and configurable web solutions. +welcome_text2 = PageTop brings back the essence of the classic web, renders on the server (SSR) and uses HTML-first components, CSS and JavaScript, with the performance and security of Rust. -welcome_issues1 = To report issues related to the PageTop framework, please use SoloGit. Before opening a new issue, check existing reports to avoid duplicates. -welcome_issues2 = For issues related specifically to { $app }, please refer to its official repository or support channel, rather than directly to PageTop. - -welcome_code = Code -welcome_have_fun = Coding is creating +welcome_notice_title = Notice +welcome_notice_1 = If you can see this page, the PageTop server is running correctly, but the application is not fully configured. This may be due to routine maintenance or a temporary issue. +welcome_notice_2 = If the issue persists, please contact the system administrator. +welcome_notice_3 = To report issues with the PageTop framework, use SoloGit. Before opening a new issue, review the existing ones to avoid duplicates. +welcome_notice_4 = For issues specific to the application ({ $app }), please use its official repository or support channel. diff --git a/src/locale/es-ES/base.ftl b/src/locale/es-ES/base.ftl index 74eb62e..99f6c7e 100644 --- a/src/locale/es-ES/base.ftl +++ b/src/locale/es-ES/base.ftl @@ -1,2 +1,6 @@ +# Basic theme, intro layout. +intro_code = Código +intro_have_fun = Programar es crear + # PoweredBy component. -poweredby_pagetop = Funciona con { $pagetop_link } \ No newline at end of file +poweredby_pagetop = Funciona con { $pagetop_link } diff --git a/src/locale/es-ES/welcome.ftl b/src/locale/es-ES/welcome.ftl index 7823832..b98d919 100644 --- a/src/locale/es-ES/welcome.ftl +++ b/src/locale/es-ES/welcome.ftl @@ -1,20 +1,21 @@ welcome_extension_name = Página de inicio predeterminada welcome_extension_description = Muestra una página de inicio predeterminada cuando no hay ninguna configurada. -welcome_page = Página de Bienvenida -welcome_title = ¡Hola mundo! +welcome_page = Página de bienvenida +welcome_title = ¡Hola, mundo! welcome_intro = Descubre⚡{ $app } -welcome_powered = Una solución web creada con PageTop! +welcome_powered = Una solución web creada con PageTop -welcome_text1 = Si puedes leer esta página, significa que el servidor de PageTop funciona correctamente, pero aún no ha sido completamente configurado. Esto suele indicar que el sitio está experimentando problemas temporales o está pasando por un mantenimiento de rutina. -welcome_text2 = Si el problema persiste, por favor contacta con el administrador del sistema para recibir asistencia técnica. +welcome_pagetop_label = Versión de PageTop en Crates.io +welcome_release_label = Lanzamiento +welcome_license_label = Licencia -welcome_about = Acerca de -welcome_pagetop = PageTop es un entorno de desarrollo web basado en Rust, diseñado para crear soluciones web modulares, extensibles y configurables. +welcome_text1 = PageTop es un entorno de desarrollo web basado en Rust, pensado para construir soluciones web modulares, extensibles y configurables. +welcome_text2 = PageTop reivindica la esencia de la web clásica, renderiza en el servidor (SSR) utilizando componentes HTML-first, CSS y JavaScript, con el rendimiento y la seguridad de Rust. -welcome_issues1 = Para comunicar cualquier problema con PageTop, utiliza SoloGit. Antes de informar de una incidencia, revisa los informes ya existentes para evitar duplicados. -welcome_issues2 = Si se trata de fallos específicos de { $app }, por favor acude a su repositorio oficial o canal de soporte, y no al de PageTop directamente. - -welcome_code = Código -welcome_have_fun = Programar es crear +welcome_notice_title = Aviso +welcome_notice_1 = Si puedes ver esta página, el servidor de PageTop está funcionando correctamente, pero la aplicación no está completamente configurada. Esto puede deberse a tareas de mantenimiento o a una incidencia temporal. +welcome_notice_2 = Si el problema persiste, por favor, contacta con el administrador del sistema. +welcome_notice_3 = Para comunicar incidencias del propio entorno PageTop, utiliza SoloGit. Antes de abrir una nueva incidencia, revisa las existentes para evitar duplicados. +welcome_notice_4 = Para fallos específicos de la aplicación ({ $app }), utiliza su repositorio oficial o su canal de soporte. From 36e2d9bec81788f6a1f7fad855ea59e4cd40567a Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Thu, 18 Sep 2025 23:38:25 +0200 Subject: [PATCH 35/35] =?UTF-8?q?=F0=9F=8E=A8=20Unifica=20par=C3=A1metros?= =?UTF-8?q?=20y=20estilos=20del=20tema=20b=C3=A1sico?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ofrece dos composiciones de página dirigidas a una introducción de contenidos neutra o con referencias a PageTop. --- src/base/extension/welcome.rs | 65 +++++++++------------------ src/base/theme/basic.rs | 85 +++++++++++++++++++++++++++++++---- src/core/theme/definition.rs | 10 ++--- src/locale/en-US/base.ftl | 7 +++ src/locale/en-US/welcome.ftl | 17 +++---- src/locale/es-ES/base.ftl | 7 +++ src/locale/es-ES/welcome.ftl | 17 +++---- static/css/intro.css | 6 +-- 8 files changed, 130 insertions(+), 84 deletions(-) diff --git a/src/base/extension/welcome.rs b/src/base/extension/welcome.rs index e2a7712..5c6fec5 100644 --- a/src/base/extension/welcome.rs +++ b/src/base/extension/welcome.rs @@ -25,55 +25,30 @@ async fn homepage(request: HttpRequest) -> ResultPage { let app = &global::SETTINGS.app.name; Page::new(request) - .with_theme("basic") - .with_layout("intro") + .with_theme("Basic") + .with_layout("PageTopIntro") .with_title(L10n::l("welcome_title")) .with_description(L10n::l("welcome_intro").with_arg("app", app)) - .with_param("intro_button_text", L10n::l("welcome_powered")) - .with_param("intro_button_link", "https://pagetop.cillero.es".to_string()) - .with_assets(AssetsOp::AddJavaScript(JavaScript::on_load_async("welcome-js", |cx| - util::indoc!(r#" - try { - const resp = await fetch("https://crates.io/api/v1/crates/pagetop"); - const data = await resp.json(); - const date = new Date(data.versions[0].created_at); - const formatted = date.toLocaleDateString("LANGID", { year: "numeric", month: "2-digit", day: "2-digit" }); - document.getElementById("welcome-release").src = `https://img.shields.io/badge/Release%20date-${encodeURIComponent(formatted)}-blue?label=LABEL&style=for-the-badge`; - document.getElementById("welcome-badges").style.display = "block"; - } catch (e) { - console.error("Failed to fetch release date from crates.io:", e); - } - "#) - .replace("LANGID", cx.langid().to_string().as_str()) - .replace("LABEL", L10n::l("welcome_release_label").using(cx).as_str()) - .to_string(), - ))) - .add_component(Html::with(|cx| html! { - p { (L10n::l("welcome_text1").using(cx)) } - div id="welcome-badges" style="display: none; margin-bottom: 1.1rem;" { - img - src="https://img.shields.io/crates/v/pagetop.svg?label=PageTop&style=for-the-badge" - alt=[L10n::l("welcome_pagetop_label").lookup(cx)] {} (" ") - img - id="welcome-release" - alt=[L10n::l("welcome_release_label").lookup(cx)] {} (" ") - img - src=(format!( - "https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label={}&style=for-the-badge", - L10n::l("welcome_license_label").lookup(cx).unwrap_or_default() - )) - alt=[L10n::l("welcome_license_label").lookup(cx)] {} - } - p { (L10n::l("welcome_text2").using(cx)) } - })) + .with_param("intro_button_txt", L10n::l("welcome_powered")) + .with_param("intro_button_lnk", "https://pagetop.cillero.es".to_string()) .add_component( Block::new() - .with_title(L10n::l("welcome_notice_title")) - .add_component(Html::with(move |cx| html! { - p { (L10n::l("welcome_notice_1").using(cx)) } - p { (L10n::l("welcome_notice_2").using(cx)) } - p { (L10n::l("welcome_notice_3").using(cx)) } - p { (L10n::l("welcome_notice_4").with_arg("app", app).using(cx)) } + .with_title(L10n::l("welcome_status_title")) + .add_component(Html::with(move |cx| { + html! { + p { (L10n::l("welcome_status_1").using(cx)) } + p { (L10n::l("welcome_status_2").using(cx)) } + } + })), + ) + .add_component( + Block::new() + .with_title(L10n::l("welcome_support_title")) + .add_component(Html::with(move |cx| { + html! { + p { (L10n::l("welcome_support_1").using(cx)) } + p { (L10n::l("welcome_support_2").with_arg("app", app).using(cx)) } + } })), ) .render() diff --git a/src/base/theme/basic.rs b/src/base/theme/basic.rs index e652548..2f49274 100644 --- a/src/base/theme/basic.rs +++ b/src/base/theme/basic.rs @@ -1,8 +1,33 @@ -//! Es el tema básico que incluye PageTop por defecto. - +/// Es el tema básico que incluye PageTop por defecto. use crate::prelude::*; /// Tema básico por defecto. +/// +/// Ofrece las siguientes composiciones (*layouts*): +/// +/// - **Composición predeterminada** +/// - Renderizado genérico con +/// [`ThemePage::render_body()`](crate::core::theme::ThemePage::render_body) usando las regiones +/// predefinidas en [`page_regions()`](crate::core::theme::Theme::page_regions). +/// +/// - **`Intro`** +/// - Página de entrada con cabecera visual, título y descripción y un botón opcional de llamada a +/// la acción. Ideal para una página de inicio o bienvenida en el contexto de PageTop. +/// - **Regiones:** `content` (se renderiza dentro de `.intro-content__body`). +/// - **Parámetros:** +/// - `intro_button_txt` (`L10n`) – Texto del botón. +/// - `intro_button_lnk` (`Option`) – URL del botón; si no se indica, el botón no se +/// muestra. +/// +/// - **`PageTopIntro`** +/// - Variante de `Intro` con textos predefinidos sobre PageTop al inicio del contenido. Añade una +/// banda de *badges* con la versión de [PageTop en crates.io](https://crates.io/crates/pagetop) +/// más la fecha de la última versión publicada y la licencia de uso. +/// - **Regiones:** `content` (igual que `Intro`). +/// - **Parámetros:** los mismos que `Intro`. +/// +/// **Nota:** si no se especifica `layout` o el valor no coincide con ninguno de los anteriores, se +/// aplica la composición predeterminada. pub struct Basic; impl Extension for Basic { @@ -14,14 +39,16 @@ impl Extension for Basic { impl Theme for Basic { fn render_page_body(&self, page: &mut Page) -> Markup { match page.layout() { - "intro" => render_intro(page), + "Intro" => render_intro(page), + "PageTopIntro" => render_pagetop_intro(page), _ => ::render_body(self, page, self.page_regions()), } } fn after_render_page_body(&self, page: &mut Page) { let styles = match page.layout() { - "intro" => "/css/intro.css", + "Intro" => "/css/intro.css", + "PageTopIntro" => "/css/intro.css", _ => "/css/basic.css", }; page.alter_assets(AssetsOp::AddStyleSheet( @@ -41,8 +68,8 @@ fn render_intro(page: &mut Page) -> Markup { let title = page.title().unwrap_or_default(); let intro = page.description().unwrap_or_default(); - let intro_button_text: L10n = page.param_or_default("intro_button_text"); - let intro_button_link: Option<&String> = page.param("intro_button_link"); + let intro_button_txt: L10n = page.param_or_default("intro_button_txt"); + let intro_button_lnk: Option<&String> = page.param("intro_button_lnk"); html! { body id=[page.body_id().get()] class=[page.body_classes().get()] { @@ -74,17 +101,17 @@ fn render_intro(page: &mut Page) -> Markup { } main class="intro-content" { section class="intro-content__body" { - @if intro_button_link.is_some() { + @if intro_button_lnk.is_some() { div class="intro-button" { a class="intro-button__link" - href=[intro_button_link] + href=[intro_button_lnk] target="_blank" rel="noreferrer" { span {} span {} span {} div class="intro-button__text" { - (intro_button_text.using(page)) + (intro_button_txt.using(page)) } } } @@ -119,3 +146,43 @@ fn render_intro(page: &mut Page) -> Markup { } } } + +fn render_pagetop_intro(page: &mut Page) -> Markup { + page.alter_assets(AssetsOp::AddJavaScript(JavaScript::on_load_async("intro-js", |cx| + util::indoc!(r#" + try { + const resp = await fetch("https://crates.io/api/v1/crates/pagetop"); + const data = await resp.json(); + const date = new Date(data.versions[0].created_at); + const formatted = date.toLocaleDateString("LANGID", { year: "numeric", month: "2-digit", day: "2-digit" }); + document.getElementById("intro-release").src = `https://img.shields.io/badge/Release%20date-${encodeURIComponent(formatted)}-blue?label=LABEL&style=for-the-badge`; + document.getElementById("intro-badges").style.display = "block"; + } catch (e) { + console.error("Failed to fetch release date from crates.io:", e); + } + "#) + .replace("LANGID", cx.langid().to_string().as_str()) + .replace("LABEL", L10n::l("intro_release_label").using(cx).as_str()) + .to_string(), + ))) + .alter_child_in("content", ChildOp::Prepend(Child::with(Html::with(|cx| html! { + p { (L10n::l("intro_text1").using(cx)) } + div id="intro-badges" style="display: none; margin-bottom: 1.1rem;" { + img + src="https://img.shields.io/crates/v/pagetop.svg?label=PageTop&style=for-the-badge" + alt=[L10n::l("intro_pagetop_label").lookup(cx)] {} (" ") + img + id="intro-release" + alt=[L10n::l("intro_release_label").lookup(cx)] {} (" ") + img + src=(format!( + "https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label={}&style=for-the-badge", + L10n::l("intro_license_label").lookup(cx).unwrap_or_default() + )) + alt=[L10n::l("intro_license_label").lookup(cx)] {} + } + p { (L10n::l("intro_text2").using(cx)) } + })))); + + render_intro(page) +} diff --git a/src/core/theme/definition.rs b/src/core/theme/definition.rs index a4faf0b..38a0bfc 100644 --- a/src/core/theme/definition.rs +++ b/src/core/theme/definition.rs @@ -125,18 +125,18 @@ pub trait Theme: Extension + ThemePage + Send + Sync { /// Declaración ordenada de las regiones disponibles en la página. /// - /// Devuelve una **lista estática** de pares `(Region, L10n)` que se usará para renderizar en el - /// orden indicado todas las regiones que componen una página. + /// Devuelve una **lista estática** de pares `(Region, L10n)` que se usará para renderizar todas + /// las regiones que componen una página en el orden indicado . /// - /// Requisitos y recomendaciones: + /// Si un tema necesita un conjunto distinto de regiones, se puede **sobrescribir** este método + /// con los siguientes requisitos y recomendaciones: /// /// - Los identificadores deben ser **estables** (p. ej. `"sidebar-left"`, `"content"`). /// - La región `"content"` es **obligatoria**. Se puede usar [`Region::default()`] para /// declararla. /// - La etiqueta `L10n` se evalúa con el idioma activo de la página. /// - /// Si tu tema define un conjunto distinto, se puede **sobrescribir** este método. Por defecto - /// devuelve: + /// Por defecto devuelve: /// /// - `"header"`: cabecera. /// - `"content"`: contenido principal (**obligatoria**). diff --git a/src/locale/en-US/base.ftl b/src/locale/en-US/base.ftl index 7a9701d..16b1a3e 100644 --- a/src/locale/en-US/base.ftl +++ b/src/locale/en-US/base.ftl @@ -1,4 +1,11 @@ # Basic theme, intro layout. +intro_pagetop_label = PageTop version on Crates.io +intro_release_label = Release date +intro_license_label = License + +intro_text1 = PageTop is an opinionated Rust web development framework designed to build modular, extensible, and configurable web solutions. +intro_text2 = PageTop brings back the essence of the classic web, renders on the server (SSR) and uses HTML-first components, CSS and JavaScript, with the performance and security of Rust. + intro_code = Code intro_have_fun = Coding is creating diff --git a/src/locale/en-US/welcome.ftl b/src/locale/en-US/welcome.ftl index 0a227f1..20faf96 100644 --- a/src/locale/en-US/welcome.ftl +++ b/src/locale/en-US/welcome.ftl @@ -7,15 +7,10 @@ welcome_title = Hello, world! welcome_intro = Discover⚡{ $app } welcome_powered = A web solution powered by PageTop -welcome_pagetop_label = PageTop version on Crates.io -welcome_release_label = Release date -welcome_license_label = License +welcome_status_title = Status +welcome_status_1 = If you can see this page, it means the PageTop server is running correctly, but the application is not fully configured. This may be due to routine maintenance or a temporary issue. +welcome_status_2 = If the issue persists, please contact the system administrator. -welcome_text1 = PageTop is a Rust-based web development framework designed to build modular, extensible, and configurable web solutions. -welcome_text2 = PageTop brings back the essence of the classic web, renders on the server (SSR) and uses HTML-first components, CSS and JavaScript, with the performance and security of Rust. - -welcome_notice_title = Notice -welcome_notice_1 = If you can see this page, the PageTop server is running correctly, but the application is not fully configured. This may be due to routine maintenance or a temporary issue. -welcome_notice_2 = If the issue persists, please contact the system administrator. -welcome_notice_3 = To report issues with the PageTop framework, use SoloGit. Before opening a new issue, review the existing ones to avoid duplicates. -welcome_notice_4 = For issues specific to the application ({ $app }), please use its official repository or support channel. +welcome_support_title = Support +welcome_support_1 = To report issues with the PageTop framework, use SoloGit. Remember, before opening a new issue, review the existing ones to avoid duplicates. +welcome_support_2 = For issues specific to the application ({ $app }), please use its official repository or support channel. diff --git a/src/locale/es-ES/base.ftl b/src/locale/es-ES/base.ftl index 99f6c7e..fee21a9 100644 --- a/src/locale/es-ES/base.ftl +++ b/src/locale/es-ES/base.ftl @@ -1,4 +1,11 @@ # Basic theme, intro layout. +intro_pagetop_label = Versión de PageTop en Crates.io +intro_release_label = Lanzamiento +intro_license_label = Licencia + +intro_text1 = PageTop es un entorno de desarrollo web basado en Rust, pensado para construir soluciones web modulares, extensibles y configurables. +intro_text2 = PageTop reivindica la esencia de la web clásica, renderiza en el servidor (SSR) utilizando componentes HTML-first, CSS y JavaScript, con el rendimiento y la seguridad de Rust. + intro_code = Código intro_have_fun = Programar es crear diff --git a/src/locale/es-ES/welcome.ftl b/src/locale/es-ES/welcome.ftl index b98d919..13330db 100644 --- a/src/locale/es-ES/welcome.ftl +++ b/src/locale/es-ES/welcome.ftl @@ -7,15 +7,10 @@ welcome_title = ¡Hola, mundo! welcome_intro = Descubre⚡{ $app } welcome_powered = Una solución web creada con PageTop -welcome_pagetop_label = Versión de PageTop en Crates.io -welcome_release_label = Lanzamiento -welcome_license_label = Licencia +welcome_status_title = Estado +welcome_status_1 = Si puedes ver esta página, es porque el servidor de PageTop está funcionando correctamente, pero la aplicación no está completamente configurada. Esto puede deberse a tareas de mantenimiento o a una incidencia temporal. +welcome_status_2 = Si el problema persiste, por favor, contacta con el administrador del sistema. -welcome_text1 = PageTop es un entorno de desarrollo web basado en Rust, pensado para construir soluciones web modulares, extensibles y configurables. -welcome_text2 = PageTop reivindica la esencia de la web clásica, renderiza en el servidor (SSR) utilizando componentes HTML-first, CSS y JavaScript, con el rendimiento y la seguridad de Rust. - -welcome_notice_title = Aviso -welcome_notice_1 = Si puedes ver esta página, el servidor de PageTop está funcionando correctamente, pero la aplicación no está completamente configurada. Esto puede deberse a tareas de mantenimiento o a una incidencia temporal. -welcome_notice_2 = Si el problema persiste, por favor, contacta con el administrador del sistema. -welcome_notice_3 = Para comunicar incidencias del propio entorno PageTop, utiliza SoloGit. Antes de abrir una nueva incidencia, revisa las existentes para evitar duplicados. -welcome_notice_4 = Para fallos específicos de la aplicación ({ $app }), utiliza su repositorio oficial o su canal de soporte. +welcome_support_title = Soporte +welcome_support_1 = Para comunicar incidencias del propio entorno PageTop, utiliza SoloGit. Recuerda, antes de abrir una nueva incidencia, revisa las existentes para evitar duplicados. +welcome_support_2 = Para fallos específicos de la aplicación ({ $app }), utiliza su repositorio oficial o su canal de soporte. diff --git a/static/css/intro.css b/static/css/intro.css index 19fa9f1..39c9d6a 100644 --- a/static/css/intro.css +++ b/static/css/intro.css @@ -7,9 +7,9 @@ --color: #1a202c; --color-gray: #e4e4e7; --color-link: #1e4eae; - --color-block-1: #fecaca; - --color-block-2: #e6a9e2; - --color-block-3: #b689ff; + --color-block-1: #b689ff; + --color-block-2: #fecaca; + --color-block-3: #e6a9e2; --color-block-4: #ffedca; --color-block-5: #ffffff; --focus-outline: 2px solid var(--color-link);