diff --git a/examples/navbar-menus.rs b/examples/navbar-menus.rs index 341d394a..071d24b1 100644 --- a/examples/navbar-menus.rs +++ b/examples/navbar-menus.rs @@ -95,7 +95,7 @@ impl Extension for SuperMenu { })), )); - InRegion::Named("header").add(Child::with( + InRegion::Key("header").add(Child::with( Container::new() .with_width(container::Width::FluidMax(UnitValue::RelRem(75.0))) .add_child(navbar_menu), diff --git a/extensions/pagetop-aliner/README.md b/extensions/pagetop-aliner/README.md index f4670aae..43fb65a5 100644 --- a/extensions/pagetop-aliner/README.md +++ b/extensions/pagetop-aliner/README.md @@ -63,11 +63,10 @@ theme = "Aliner" ```rust,no_run use pagetop::prelude::*; -use pagetop_aliner::Aliner; async fn homepage(request: HttpRequest) -> ResultPage { Page::new(request) - .with_theme(&Aliner) + .with_theme("Aliner") .add_child( Block::new() .with_title(L10n::l("sample_title")) diff --git a/extensions/pagetop-aliner/src/lib.rs b/extensions/pagetop-aliner/src/lib.rs index 4ae4121e..edbb5040 100644 --- a/extensions/pagetop-aliner/src/lib.rs +++ b/extensions/pagetop-aliner/src/lib.rs @@ -64,11 +64,10 @@ theme = "Aliner" ```rust,no_run use pagetop::prelude::*; -use pagetop_aliner::Aliner; async fn homepage(request: HttpRequest) -> ResultPage { Page::new(request) - .with_theme(&Aliner) + .with_theme("Aliner") .add_child( Block::new() .with_title(L10n::l("sample_title")) @@ -83,11 +82,15 @@ async fn homepage(request: HttpRequest) -> ResultPage { use pagetop::prelude::*; +/// El tema usa las mismas regiones predefinidas por [`ThemeRegion`]. +pub type AlinerRegion = ThemeRegion; + /// Implementa el tema para usar en pruebas que muestran el esquema de páginas HTML. /// -/// Define un tema mínimo útil para: +/// Tema mínimo ideal para **pruebas y demos** que renderiza el **esqueleto HTML** con las mismas +/// regiones básicas definidas por [`ThemeRegion`]. No pretende ser un tema para producción, está +/// pensado para: /// -/// - Comprobar el funcionamiento de temas, plantillas y regiones. /// - Verificar integración de componentes y composiciones (*layouts*) sin estilos complejos. /// - Realizar pruebas de renderizado rápido con salida estable y predecible. /// - Preparar ejemplos y documentación, sin dependencias visuales (CSS/JS) innecesarias. diff --git a/extensions/pagetop-bootsier/README.md b/extensions/pagetop-bootsier/README.md index d6e1666a..84e11b57 100644 --- a/extensions/pagetop-bootsier/README.md +++ b/extensions/pagetop-bootsier/README.md @@ -63,11 +63,10 @@ theme = "Bootsier" ```rust,no_run use pagetop::prelude::*; -use pagetop_bootsier::Bootsier; async fn homepage(request: HttpRequest) -> ResultPage { Page::new(request) - .with_theme(&Bootsier) + .with_theme("Bootsier") .add_child( Block::new() .with_title(L10n::l("sample_title")) diff --git a/extensions/pagetop-bootsier/src/lib.rs b/extensions/pagetop-bootsier/src/lib.rs index 0bf94f47..4cb35a27 100644 --- a/extensions/pagetop-bootsier/src/lib.rs +++ b/extensions/pagetop-bootsier/src/lib.rs @@ -64,11 +64,10 @@ theme = "Bootsier" ```rust,no_run use pagetop::prelude::*; -use pagetop_bootsier::Bootsier; async fn homepage(request: HttpRequest) -> ResultPage { Page::new(request) - .with_theme(&Bootsier) + .with_theme("Bootsier") .add_child( Block::new() .with_title(L10n::l("sample_title")) @@ -102,6 +101,9 @@ pub mod prelude { pub use crate::theme::*; } +/// El tema usa las mismas regiones predefinidas por [`ThemeRegion`]. +pub type BootsierRegion = ThemeRegion; + /// Implementa el tema. pub struct Bootsier; diff --git a/helpers/pagetop-macros/src/lib.rs b/helpers/pagetop-macros/src/lib.rs index 194cd378..732bbad7 100644 --- a/helpers/pagetop-macros/src/lib.rs +++ b/helpers/pagetop-macros/src/lib.rs @@ -54,56 +54,6 @@ pub fn html(input: TokenStream) -> TokenStream { /// [`Default`]. Aunque, a diferencia de un simple `#[derive(Default)]`, el atributo /// `#[derive(AutoDefault)]` permite usar anotaciones en los campos como `#[default = "..."]`, /// funcionando incluso en estructuras con campos que no implementan [`Default`] o en *enums*. -/// -/// # Ejemplos -/// -/// ```rust -/// # use pagetop_macros::AutoDefault; -/// # fn main() { -/// #[derive(AutoDefault)] -/// # #[derive(PartialEq)] -/// # #[allow(dead_code)] -/// enum Foo { -/// Bar, -/// #[default] -/// Baz { -/// #[default = 12] -/// a: i32, -/// b: i32, -/// #[default(Some(Default::default()))] -/// c: Option, -/// #[default(_code = "vec![1, 2, 3]")] -/// d: Vec, -/// #[default = "four"] -/// e: String, -/// }, -/// Qux(i32), -/// } -/// -/// assert!(Foo::default() == Foo::Baz { -/// a: 12, -/// b: 0, -/// c: Some(0), -/// d: vec![1, 2, 3], -/// e: "four".to_owned(), -/// }); -/// # } -/// ``` -/// -/// * `Baz` tiene el atributo `#[default]`. Esto significa que el valor por defecto de `Foo` es -/// `Foo::Baz`. Solo una variante puede tener el atributo `#[default]`, y dicho atributo no debe -/// tener ningún valor asociado. -/// * `a` tiene el atributo `#[default = 12]`. Esto significa que su valor por defecto es `12`. -/// * `b` no tiene ningún atributo `#[default = ...]`. Su valor por defecto será, por tanto, el -/// valor por defecto de `i32`, es decir, `0`. -/// * `c` es un `Option`, y su valor por defecto es `Some(Default::default())`. Rust no puede -/// (actualmente) analizar `#[default = Some(Default::default())]`, pero podemos escribir -/// `#[default(Some(Default::default))]`. -/// * `d` contiene el token `!`, que (actualmente) no puede ser analizado ni siquiera usando -/// `#[default(...)]`, así que debemos codificarlo como una cadena y marcarlo con `_code =`. -/// * `e` es un `String`, por lo que el literal de cadena `"four"` se convierte automáticamente en -/// él. Esta conversión automática **solo** ocurre con literales de cadena (o de bytes), y solo si -/// no se usa `_code`. #[proc_macro_derive(AutoDefault, attributes(default))] pub fn derive_auto_default(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); @@ -115,49 +65,46 @@ pub fn derive_auto_default(input: TokenStream) -> TokenStream { /// Macro (*attribute*) que asocia un método *builder* `with_` con un método `alter_`. /// -/// La macro añade automáticamente un método `alter_` que permite modificar la instancia actual -/// usando `&mut self`; y redefine el método *builder* `with_`, que consume `mut self`, para delegar -/// la lógica al nuevo método `alter_`, reutilizando así la misma implementación. +/// La macro añade automáticamente un método `alter_` para modificar la instancia actual usando +/// `&mut self`, y redefine el método *builder* `with_`, que consume la instancia (`mut self`), para +/// delegar la lógica de la modificación al nuevo método `alter_`, reutilizando así la misma +/// implementación. /// /// Esta macro emitirá un error en tiempo de compilación si la función anotada no cumple con la /// firma esperada para el método *builder*: `pub fn with_...(mut self, ...) -> Self`. /// -/// # Ejemplo +/// # Ejemplos /// /// Si defines un método `with_` como este: /// -/// ```rust -/// # use pagetop_macros::builder_fn; -/// # struct Example {value: Option}; -/// # impl Example { +/// ```rust,ignore /// #[builder_fn] /// pub fn with_example(mut self, value: impl Into) -> Self { /// self.value = Some(value.into()); /// self /// } -/// # } /// ``` /// -/// la macro rescribirá el método `with_` y generará un nuevo método `alter_`: -/// -/// ```rust -/// # struct Example {value: Option}; -/// # impl Example { -/// #[inline] -/// pub fn with_example(mut self, value: impl Into) -> Self { -/// self.alter_example(value); -/// self -/// } +/// la macro generará automáticamente el siguiente método `alter_`: /// +/// ```rust,ignore /// pub fn alter_example(&mut self, value: impl Into) -> &mut Self { /// self.value = Some(value.into()); /// self /// } -/// # } /// ``` /// -/// De esta forma, cada método *builder* `with_...()` generará automáticamente su correspondiente -/// método `alter_...()` para dejar modificar instancias existentes. +/// y reescribirá el método `with_` para delegar la modificación al método `alter_`: +/// +/// ```rust,ignore +/// pub fn with_example(mut self, value: impl Into) -> Self { +/// self.alter_example(value); +/// self +/// } +/// ``` +/// +/// Así, cada método *builder* `with_...()` generará automáticamente su correspondiente método +/// `alter_...()`, que permitirá más adelante modificar instancias existentes. #[proc_macro_attribute] pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream { use syn::{parse2, FnArg, Ident, ImplItemFn, Pat, ReturnType, TraitItemFn, Type}; diff --git a/src/base/action/component.rs b/src/base/action/component.rs index 30c7ba4a..aaef1ce9 100644 --- a/src/base/action/component.rs +++ b/src/base/action/component.rs @@ -1,5 +1,8 @@ //! Acciones que operan sobre componentes. +mod is_renderable; +pub use is_renderable::*; + mod before_render_component; pub use before_render_component::*; diff --git a/src/base/action/component/is_renderable.rs b/src/base/action/component/is_renderable.rs new file mode 100644 index 00000000..5a0e244e --- /dev/null +++ b/src/base/action/component/is_renderable.rs @@ -0,0 +1,96 @@ +use crate::prelude::*; + +/// Tipo de función para determinar si un componente se renderiza o no. +/// +/// Se usa en la acción [`IsRenderable`] para controlar dinámicamente la visibilidad del componente +/// `component` según el contexto `cx`. El componente **no se renderiza** en cuanto una de las +/// funciones devuelva `false`. +pub type FnIsRenderable = fn(component: &C, cx: &Context) -> bool; + +/// Con la función [`FnIsRenderable`] se puede decidir si se renderiza o no un componente. +pub struct IsRenderable { + f: FnIsRenderable, + referer_type_id: Option, + referer_id: AttrId, + weight: Weight, +} + +/// Filtro para despachar [`FnIsRenderable`] para decidir si se renderiza o no un componente `C`. +impl ActionDispatcher for IsRenderable { + /// Devuelve el identificador de tipo ([`UniqueId`]) del componente `C`. + fn referer_type_id(&self) -> Option { + self.referer_type_id + } + + /// Devuelve el identificador del componente. + fn referer_id(&self) -> Option { + self.referer_id.get() + } + + /// Devuelve el peso para definir el orden de ejecución. + fn weight(&self) -> Weight { + self.weight + } +} + +impl IsRenderable { + /// Permite [registrar](Extension::actions) una nueva acción [`FnIsRenderable`]. + pub fn new(f: FnIsRenderable) -> Self { + IsRenderable { + f, + referer_type_id: Some(UniqueId::of::()), + referer_id: AttrId::default(), + weight: 0, + } + } + + /// Afina el registro para ejecutar la acción [`FnIsRenderable`] sólo para el componente `C` + /// con identificador `id`. + pub fn filter_by_referer_id(mut self, id: impl AsRef) -> Self { + self.referer_id.alter_value(id); + self + } + + /// Opcional. Acciones con pesos más bajos se aplican antes. Se pueden usar valores negativos. + pub fn with_weight(mut self, value: Weight) -> Self { + self.weight = value; + self + } + + // Despacha las acciones. Se detiene en cuanto una [`FnIsRenderable`] devuelve `false`. + #[inline] + pub(crate) fn dispatch(component: &C, cx: &mut Context) -> bool { + let mut renderable = true; + dispatch_actions( + &ActionKey::new( + UniqueId::of::(), + None, + Some(UniqueId::of::()), + None, + ), + |action: &Self| { + if renderable && !(action.f)(component, cx) { + renderable = false; + } + }, + ); + if renderable { + if let Some(id) = component.id() { + dispatch_actions( + &ActionKey::new( + UniqueId::of::(), + None, + Some(UniqueId::of::()), + Some(id), + ), + |action: &Self| { + if renderable && !(action.f)(component, cx) { + renderable = false; + } + }, + ); + } + } + renderable + } +} diff --git a/src/base/component.rs b/src/base/component.rs index fa9ed2ad..bdab35c6 100644 --- a/src/base/component.rs +++ b/src/base/component.rs @@ -1,46 +1,48 @@ //! Componentes nativos proporcionados por PageTop. -//! -//! Conviene destacar que PageTop distingue entre: -//! -//! - **Componentes estructurales** que definen el esqueleto de un documento HTML, como [`Template`] -//! y [`Region`], utilizados por [`Page`](crate::response::page::Page) para generar la estructura -//! final. -//! - **Componentes de contenido** (menús, barras, tarjetas, etc.), que se incluyen en las regiones -//! gestionadas por los componentes estructurales. -//! -//! El componente [`Template`] describe cómo maquetar el cuerpo del documento a partir de varias -//! regiones lógicas ([`Region`]). En función de la plantilla seleccionada, determina qué regiones -//! se renderizan y en qué orden. Por ejemplo, la plantilla predeterminada [`Template::DEFAULT`] -//! utiliza las regiones [`Region::HEADER`], [`Region::CONTENT`] y [`Region::FOOTER`]. -//! -//! Un componente [`Region`] es un contenedor lógico asociado a un nombre de región. Su contenido se -//! obtiene del [`Context`](crate::core::component::Context), donde los componentes se registran -//! mediante [`Contextual::with_child_in()`](crate::core::component::Contextual::with_child_in) y -//! otros mecanismos similares, y se integra en el documento a través de [`Template`]. -//! -//! Por su parte, una página ([`Page`](crate::response::page::Page)) representa un documento HTML -//! completo. Implementa [`Contextual`](crate::core::component::Contextual) para mantener su propio -//! [`Context`](crate::core::component::Context), donde gestiona el tema activo, la plantilla -//! seleccionada y los componentes asociados a cada región, y se encarga de generar la estructura -//! final de la página. -//! -//! De este modo, temas y extensiones colaboran sobre una estructura común: las aplicaciones -//! registran componentes en el [`Context`](crate::core::component::Context), las plantillas -//! organizan las regiones y las páginas generan el documento HTML resultante. -//! -//! Los temas pueden sobrescribir [`Template`] para exponer nuevas plantillas o adaptar las -//! predeterminadas, y lo mismo con [`Region`] para añadir regiones adicionales o personalizar su -//! representación. + +use crate::prelude::*; + +// **< FontSize >*********************************************************************************** + +#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] +pub enum FontSize { + ExtraLarge, + XxLarge, + XLarge, + Large, + Medium, + #[default] + Normal, + Small, + XSmall, + XxSmall, + ExtraSmall, +} + +#[rustfmt::skip] +impl FontSize { + #[inline] + pub const fn as_str(self) -> &'static str { + match self { + FontSize::ExtraLarge => "fs__x3l", + FontSize::XxLarge => "fs__x2l", + FontSize::XLarge => "fs__xl", + FontSize::Large => "fs__l", + FontSize::Medium => "fs__m", + FontSize::Normal => "", + FontSize::Small => "fs__s", + FontSize::XSmall => "fs__xs", + FontSize::XxSmall => "fs__x2s", + FontSize::ExtraSmall => "fs__x3s", + } + } +} + +// ************************************************************************************************* mod html; pub use html::Html; -mod region; -pub use region::Region; - -mod template; -pub use template::Template; - mod block; pub use block::Block; @@ -49,3 +51,6 @@ pub use intro::{Intro, IntroOpening}; mod poweredby; pub use poweredby::PoweredBy; + +mod icon; +pub use icon::{Icon, IconKind}; diff --git a/extensions/pagetop-bootsier/src/theme/icon.rs b/src/base/component/icon.rs similarity index 100% rename from extensions/pagetop-bootsier/src/theme/icon.rs rename to src/base/component/icon.rs diff --git a/src/base/component/poweredby.rs b/src/base/component/poweredby.rs index 797253dc..51ab79d8 100644 --- a/src/base/component/poweredby.rs +++ b/src/base/component/poweredby.rs @@ -3,7 +3,7 @@ use crate::prelude::*; // Enlace a la página oficial de PageTop. const LINK: &str = "PageTop"; -/// Componente que informa del 'Powered by' (*Funciona con*) típica del pie de página. +/// 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 diff --git a/src/base/component/region.rs b/src/base/component/region.rs deleted file mode 100644 index 5dfa25ce..00000000 --- a/src/base/component/region.rs +++ /dev/null @@ -1,150 +0,0 @@ -use crate::prelude::*; - -/// Componente estructural que renderiza el contenido de una región del documento. -/// -/// `Region` actúa como un contenedor lógico asociado a un nombre de región. Su contenido se obtiene -/// del contexto de renderizado ([`Context`]), donde los componentes suelen registrarse con métodos -/// como [`Contextual::with_child_in()`]. Cada región puede integrarse posteriormente en el cuerpo -/// del documento mediante [`Template`], normalmente desde una página ([`Page`]). -#[derive(AutoDefault)] -pub struct Region { - #[default(AttrName::new(Self::DEFAULT))] - name: AttrName, - #[default(L10n::l("region-content"))] - label: L10n, -} - -impl Component for Region { - fn new() -> Self { - Region::default() - } - - fn id(&self) -> Option { - self.name.get() - } - - fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { - let Some(name) = self.name().get() else { - return PrepareMarkup::None; - }; - let output = cx.render_region(&name); - if output.is_empty() { - return PrepareMarkup::None; - } - PrepareMarkup::With(html! { - div - id=[self.id()] - class=(join!("region region-", &name)) - role="region" - aria-label=[self.label().lookup(cx)] - { - (output) - } - }) - } -} - -impl Region { - /// Región especial situada al **inicio del documento**. - /// - /// Su función es proporcionar un punto estable donde las extensiones puedan inyectar contenido - /// global antes de renderizar el resto de regiones principales (cabecera, contenido, etc.). - /// - /// No suele utilizarse en los temas como una región “visible” dentro del maquetado habitual, - /// sino como punto de anclaje para elementos auxiliares, marcadores técnicos, inicializadores o - /// contenido de depuración que deban situarse en la parte superior del documento. - /// - /// Se considera una región **reservada** para este tipo de usos globales. - pub const PAGETOP: &str = "page-top"; - - /// Región estándar para la **cabecera** del documento. - /// - /// Suele emplearse para mostrar un logotipo, navegación principal, barras superiores, etc. - pub const HEADER: &str = "header"; - - /// Región principal de **contenido**. - /// - /// Es la región donde se espera que se renderice el contenido principal de la página (p. ej. - /// cuerpo de la ruta actual, bloques centrales, vistas principales, etc.). En muchos temas será - /// la región mínima imprescindible para que la página tenga sentido. - pub const CONTENT: &str = "content"; - - /// Región estándar para el **pie de página**. - /// - /// Suele contener información legal, enlaces secundarios, créditos, etc. - pub const FOOTER: &str = "footer"; - - /// Región especial situada al **final del documento**. - /// - /// Pensada para proporcionar un punto estable donde las extensiones puedan inyectar contenido - /// global después de renderizar el resto de regiones principales (cabecera, contenido, etc.). - /// - /// No suele utilizarse en los temas como una región “visible” dentro del maquetado habitual, - /// sino como punto de anclaje para elementos auxiliares asociados a comportamientos dinámicos - /// que deban situarse en la parte inferior del documento. - /// - /// Igual que [`Self::PAGETOP`], se considera una región **reservada** para este tipo de usos - /// globales. - pub const PAGEBOTTOM: &str = "page-bottom"; - - /// Región por defecto que se asigna cuando no se especifica ningún nombre. - /// - /// Por diseño, la región por defecto es la de contenido principal ([`Self::CONTENT`]), de - /// manera que un tema sencillo pueda limitarse a definir una sola región funcional. - pub const DEFAULT: &str = Self::CONTENT; - - /// Prepara una región para el nombre indicado. - /// - /// El valor de `name` se utiliza como nombre de la región y como identificador (`id`) del - /// contenedor. Al renderizarse, este componente mostrará el contenido registrado en el contexto - /// bajo ese nombre. - pub fn named(name: impl AsRef) -> Self { - Region { - name: AttrName::new(name), - label: L10n::default(), - } - } - - /// Prepara una región para el nombre indicado con una etiqueta de accesibilidad. - /// - /// El valor de `name` se utiliza como nombre de la región y como identificador (`id`) del - /// contenedor, mientras que `label` será el texto localizado que se usará como `aria-label` del - /// contenedor. - pub fn labeled(name: impl AsRef, label: L10n) -> Self { - Region { - name: AttrName::new(name), - label, - } - } - - // **< Region BUILDER >************************************************************************* - - /// Establece o modifica el nombre de la región. - #[builder_fn] - pub fn with_name(mut self, name: impl AsRef) -> Self { - self.name.alter_value(name); - self - } - - /// Establece la etiqueta localizada de la región. - /// - /// Esta etiqueta se utiliza como `aria-label` del contenedor predefinido `
`, - /// lo que mejora la accesibilidad para lectores de pantalla y otras tecnologías de apoyo. - #[builder_fn] - pub fn with_label(mut self, label: L10n) -> Self { - self.label = label; - self - } - - // **< Region GETTERS >************************************************************************* - - /// Devuelve el nombre de la región. - pub fn name(&self) -> &AttrName { - &self.name - } - - /// Devuelve la etiqueta localizada asociada a la región. - pub fn label(&self) -> &L10n { - &self.label - } -} diff --git a/src/base/component/template.rs b/src/base/component/template.rs deleted file mode 100644 index 6c70d00e..00000000 --- a/src/base/component/template.rs +++ /dev/null @@ -1,84 +0,0 @@ -use crate::prelude::*; - -/// Componente estructural para renderizar plantillas de contenido. -/// -/// `Template` describe cómo se compone el cuerpo del documento a partir de varias regiones lógicas -/// ([`Region`]). En función de su nombre, decide qué regiones se renderizan y en qué orden. -/// -/// Normalmente se invoca desde una página ([`Page`]), que consulta el nombre de plantilla guardado -/// en el [`Context`] y delega en `Template` la composición de las regiones que forman el cuerpo del -/// documento. -/// -/// Los temas pueden sobrescribir este componente para exponer sus propias plantillas o adaptar las -/// plantillas predeterminadas. -#[derive(AutoDefault)] -pub struct Template { - #[default(AttrName::new(Self::DEFAULT))] - name: AttrName, -} - -impl Component for Template { - fn new() -> Self { - Template::default() - } - - fn id(&self) -> Option { - self.name.get() - } - - fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { - let Some(name) = self.name().get() else { - return PrepareMarkup::None; - }; - match name.as_str() { - Self::DEFAULT | Self::ERROR => PrepareMarkup::With(html! { - (Region::labeled(Region::HEADER, L10n::l("region-header")).render(cx)) - (Region::default().render(cx)) - (Region::labeled(Region::FOOTER, L10n::l("region-footer")).render(cx)) - }), - _ => PrepareMarkup::None, - } - } -} - -impl Template { - /// Nombre de la plantilla predeterminada. - /// - /// Por defecto define una estructura básica con las regiones [`Region::HEADER`], - /// [`Region::CONTENT`] y [`Region::FOOTER`], en ese orden. Esta plantilla se usa cuando no se - /// selecciona ninguna otra de forma explícita (ver [`Contextual::with_template()`]). - pub const DEFAULT: &str = "default"; - - /// Nombre de la plantilla de error. - /// - /// Se utiliza para páginas de error u otros estados excepcionales. Por defecto reutiliza - /// la misma estructura que [`Self::DEFAULT`], pero permite a temas y extensiones distinguir - /// el contexto de error para aplicar estilos o contenidos específicos. - pub const ERROR: &str = "error"; - - /// Selecciona la plantilla asociada al nombre indicado. - /// - /// El valor de `name` se utiliza como nombre de la plantilla y como identificador (`id`) del - /// componente. - pub fn named(name: impl AsRef) -> Self { - Template { - name: AttrName::new(name), - } - } - - // **< Template BUILDER >*********************************************************************** - - /// Establece o modifica el nombre de la plantilla seleccionada. - #[builder_fn] - pub fn with_name(mut self, name: impl AsRef) -> Self { - self.name.alter_value(name); - self - } - - // **< Template GETTERS >*********************************************************************** - - /// Devuelve el nombre de la plantilla seleccionada. - pub fn name(&self) -> &AttrName { - &self.name - } -} diff --git a/src/base/theme.rs b/src/base/theme.rs index 4a13a4e4..a4b2df5b 100644 --- a/src/base/theme.rs +++ b/src/base/theme.rs @@ -1,4 +1,4 @@ -//! Tema básico soportados por PageTop. +//! Temas básicos soportados por PageTop. mod basic; -pub use basic::Basic; +pub use basic::{Basic, BasicRegion}; diff --git a/src/base/theme/basic.rs b/src/base/theme/basic.rs index eb2274f6..a6711859 100644 --- a/src/base/theme/basic.rs +++ b/src/base/theme/basic.rs @@ -1,6 +1,9 @@ /// Es el tema básico que incluye PageTop por defecto. use crate::prelude::*; +/// El tema básico usa las mismas regiones predefinidas por [`ThemeRegion`]. +pub type BasicRegion = ThemeRegion; + /// Tema básico por defecto que extiende el funcionamiento predeterminado de [`Theme`]. pub struct Basic; diff --git a/src/core/action/all.rs b/src/core/action/all.rs index 2a3dfd2d..fbbf8427 100644 --- a/src/core/action/all.rs +++ b/src/core/action/all.rs @@ -19,7 +19,7 @@ static ACTIONS: LazyLock>> = // // Las extensiones llamarán a esta función durante su inicialización para instalar acciones // personalizadas que modifiquen el comportamiento del *core* o de otros componentes. -pub(crate) fn add_action(action: ActionBox) { +pub fn add_action(action: ActionBox) { let key = ActionKey::new( action.type_id(), action.theme_type_id(), diff --git a/src/core/component.rs b/src/core/component.rs index 9c9ade2e..a7faa2fb 100644 --- a/src/core/component.rs +++ b/src/core/component.rs @@ -11,59 +11,6 @@ pub use children::{Typed, TypedOp}; mod context; pub use context::{Context, ContextError, ContextOp, Contextual}; -/// Alias de función (*callback*) para **determinar si un componente se renderiza o no**. -/// -/// Puede usarse para permitir que una instancia concreta de un tipo de componente dado decida -/// dinámicamente durante el proceso de renderizado ([`Component::is_renderable()`]) si se renderiza -/// o no. -/// -/// # Ejemplo -/// -/// ```rust -/// # use pagetop::prelude::*; -/// #[derive(AutoDefault)] -/// struct SampleComponent { -/// renderable: Option, -/// } -/// -/// impl Component for SampleComponent { -/// fn new() -> Self { -/// Self::default() -/// } -/// -/// fn is_renderable(&self, cx: &mut Context) -> bool { -/// // Si hay callback, se usa; en caso contrario, se renderiza por defecto. -/// self.renderable.map_or(true, |f| f(cx)) -/// } -/// -/// fn prepare_component(&self, _cx: &mut Context) -> PrepareMarkup { -/// PrepareMarkup::Escaped("Visible component".into()) -/// } -/// } -/// -/// impl SampleComponent { -/// /// Asigna una función que decidirá si el componente se renderiza o no. -/// #[builder_fn] -/// pub fn with_renderable(mut self, f: Option) -> Self { -/// self.renderable = f; -/// self -/// } -/// } -/// -/// fn sample() { -/// let mut cx = Context::default().with_param("user_logged_in", true); -/// -/// // Se instancia un componente que sólo se renderiza si `user_logged_in` es `true`. -/// let mut component = SampleComponent::new().with_renderable(Some(|cx: &Context| { -/// cx.param::("user_logged_in").copied().unwrap_or(false) -/// })); -/// -/// // Aquí simplemente se comprueba que compila y se puede invocar. -/// let _markup = component.render(&mut cx); -/// } -/// ``` -pub type FnIsRenderable = fn(cx: &Context) -> bool; - /// Alias de función (*callback*) para **resolver una URL** según el contexto de renderizado. /// /// Se usa para generar enlaces dinámicos en función del contexto (petición, idioma, etc.). Debe diff --git a/src/core/component/children.rs b/src/core/component/children.rs index b3670433..3b8f2abf 100644 --- a/src/core/component/children.rs +++ b/src/core/component/children.rs @@ -200,7 +200,7 @@ pub enum TypedOp { /// Esta lista permite añadir, modificar, renderizar y consultar componentes hijo en orden de /// inserción, soportando operaciones avanzadas como inserción relativa o reemplazo por /// identificador. -#[derive(AutoDefault, Clone)] +#[derive(Clone, Default)] pub struct Children(Vec); impl Children { diff --git a/src/core/component/context.rs b/src/core/component/context.rs index 5cc5d2e8..8c4e47e1 100644 --- a/src/core/component/context.rs +++ b/src/core/component/context.rs @@ -1,6 +1,5 @@ -use crate::base::component::Template; use crate::core::component::ChildOp; -use crate::core::theme::all::DEFAULT_THEME; +use crate::core::theme::all::{theme_by_short_name, DEFAULT_THEME}; use crate::core::theme::{ChildrenInRegions, ThemeRef}; use crate::core::TypeInfo; use crate::html::{html, Markup}; @@ -14,16 +13,19 @@ use std::collections::HashMap; /// Operaciones para modificar recursos asociados al contexto ([`Context`]) de un documento. pub enum ContextOp { + // Favicon. /// Define el *favicon* del documento. Sobrescribe cualquier valor anterior. SetFavicon(Option), /// Define el *favicon* solo si no se ha establecido previamente. SetFaviconIfNone(Favicon), + // Stylesheets. /// Añade una hoja de estilos CSS al documento. AddStyleSheet(StyleSheet), /// Elimina una hoja de estilos por su ruta o identificador. RemoveStyleSheet(&'static str), + // JavaScripts. /// Añade un script JavaScript al documento. AddJavaScript(JavaScript), /// Elimina un script por su ruta o identificador. @@ -48,27 +50,27 @@ pub enum ContextError { /// Interfaz para gestionar el **contexto de renderizado** de un documento HTML. /// -/// `Contextual` extiende [`LangId`] para establecer el idioma del documento y añade métodos para: +/// `Contextual` extiende [`LangId`] y define los métodos para: /// +/// - Establecer el **idioma** del documento. /// - Almacenar la **solicitud HTTP** de origen. -/// - Seleccionar el **tema** y la **plantilla** de renderizado. +/// - 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 [`ContextOp`]. /// - Leer y mantener **parámetros dinámicos tipados** de contexto. /// - Generar **identificadores únicos** por tipo de componente. /// -/// Lo implementan, típicamente, estructuras que manejan el contexto de renderizado, como +/// Lo implementan, típicamente, estructuras que representan el contexto de renderizado, como /// [`Context`](crate::core::component::Context) o [`Page`](crate::response::page::Page). /// /// # Ejemplo /// /// ```rust /// # use pagetop::prelude::*; -/// # use pagetop_aliner::Aliner; /// fn prepare_context(cx: C) -> C { /// cx.with_langid(&LangMatch::resolve("es-ES")) -/// .with_theme(&Aliner) -/// .with_template(Template::DEFAULT) +/// .with_theme("aliner") +/// .with_layout("default") /// .with_assets(ContextOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) /// .with_assets(ContextOp::AddStyleSheet(StyleSheet::from("/css/app.css"))) /// .with_assets(ContextOp::AddJavaScript(JavaScript::defer("/js/app.js"))) @@ -88,11 +90,11 @@ pub trait Contextual: LangId { /// Especifica el tema para renderizar el documento. #[builder_fn] - fn with_theme(self, theme: ThemeRef) -> Self; + fn with_theme(self, theme_name: &'static str) -> Self; - /// Especifica la plantilla para renderizar el documento. + /// Especifica la composición para renderizar el documento. #[builder_fn] - fn with_template(self, template_name: &'static str) -> Self; + fn with_layout(self, layout_name: &'static str) -> Self; /// Añade o modifica un parámetro dinámico del contexto. #[builder_fn] @@ -102,9 +104,9 @@ pub trait Contextual: LangId { #[builder_fn] fn with_assets(self, op: ContextOp) -> Self; - /// Opera con [`ChildOp`] en una región (`region_name`) del documento. + /// Opera con [`ChildOp`] en una región (`region_key`) de la página. #[builder_fn] - fn with_child_in(self, region_name: impl AsRef, op: ChildOp) -> Self; + fn with_child_in(self, region_key: &'static str, op: ChildOp) -> Self; // **< Contextual GETTERS >********************************************************************* @@ -114,8 +116,8 @@ pub trait Contextual: LangId { /// Devuelve el tema que se usará para renderizar el documento. fn theme(&self) -> ThemeRef; - /// Devuelve el nombre de la plantilla usada para renderizar el documento. - fn template(&self) -> &str; + /// Devuelve la composición para renderizar el documento. Por defecto es `"default"`. + fn layout(&self) -> &str; /// Recupera un parámetro como [`Option`]. fn param(&self, key: &'static str) -> Option<&T>; @@ -166,13 +168,12 @@ pub trait Contextual: LangId { /// /// ```rust /// # use pagetop::prelude::*; -/// # use pagetop_aliner::Aliner; /// fn new_context(request: HttpRequest) -> Context { /// Context::new(Some(request)) /// // Establece el idioma del documento a español. /// .with_langid(&LangMatch::resolve("es-ES")) -/// // Establece el tema para renderizar. -/// .with_theme(&Aliner) +/// // Selecciona un tema (por su nombre corto). +/// .with_theme("aliner") /// // Asigna un favicon. /// .with_assets(ContextOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) /// // Añade una hoja de estilo externa. @@ -207,8 +208,8 @@ pub trait Contextual: LangId { pub struct Context { request : Option, // Solicitud HTTP de origen. langid : &'static LanguageIdentifier, // Identificador de idioma. - theme : ThemeRef, // Referencia al tema usado para renderizar. - template : &'static str, // Nombre de la plantilla usada para renderizar. + theme : ThemeRef, // Referencia al tema para renderizar. + layout : &'static str, // Composición del documento para renderizar. favicon : Option, // Favicon, si se ha definido. stylesheets: Assets, // Hojas de estilo CSS. javascripts: Assets, // Scripts JavaScript. @@ -226,8 +227,8 @@ impl Default for Context { impl Context { /// Crea un nuevo contexto asociado a una solicitud HTTP. /// - /// El contexto inicializa el idioma, el tema y la plantilla por defecto, sin favicon ni otros - /// recursos cargados. + /// El contexto inicializa el idioma, tema y composición por defecto, sin favicon ni recursos + /// cargados. #[rustfmt::skip] pub fn new(request: Option) -> Self { // Se intenta DEFAULT_LANGID. @@ -248,7 +249,7 @@ impl Context { request, langid, theme : *DEFAULT_THEME, - template : Template::DEFAULT, + layout : "default", favicon : None, stylesheets: Assets::::new(), javascripts: Assets::::new(), @@ -286,10 +287,10 @@ impl Context { markup } - /// Renderiza los componentes de la región `region_name`. - pub fn render_region(&mut self, region_name: impl AsRef) -> Markup { + /// Renderiza los componentes de una región (`region_key`). + pub fn render_components_of(&mut self, region_key: &'static str) -> Markup { self.regions - .children_for(self.theme, region_name) + .merge_all_components(self.theme, region_key) .render(self) } @@ -363,7 +364,7 @@ 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 sólo interesa borrar la entrada. + /// Devuelve `false` en caso contrario. Usar cuando solo interesa borrar la entrada. /// /// # Ejemplos /// @@ -410,15 +411,19 @@ impl Contextual for Context { 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: ThemeRef) -> Self { - self.theme = theme; + 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_template(mut self, template_name: &'static str) -> Self { - self.template = template_name; + fn with_layout(mut self, layout_name: &'static str) -> Self { + self.layout = layout_name; self } @@ -462,7 +467,7 @@ impl Contextual for Context { ContextOp::RemoveStyleSheet(path) => { self.stylesheets.remove(path); } - // Scripts JavaScript. + // JavaScripts. ContextOp::AddJavaScript(js) => { self.javascripts.add(js); } @@ -474,8 +479,8 @@ impl Contextual for Context { } #[builder_fn] - fn with_child_in(mut self, region_name: impl AsRef, op: ChildOp) -> Self { - self.regions.alter_child_in(region_name, op); + fn with_child_in(mut self, region_key: &'static str, op: ChildOp) -> Self { + self.regions.alter_child_in(region_key, op); self } @@ -489,8 +494,8 @@ impl Contextual for Context { self.theme } - fn template(&self) -> &str { - self.template + fn layout(&self) -> &str { + self.layout } /// Recupera un parámetro como [`Option`], simplificando el acceso. diff --git a/src/core/component/definition.rs b/src/core/component/definition.rs index 13b03851..c0573b44 100644 --- a/src/core/component/definition.rs +++ b/src/core/component/definition.rs @@ -45,20 +45,6 @@ pub trait Component: AnyInfo + ComponentRender + Send + Sync { None } - /// Indica si el componente es renderizable. - /// - /// Por defecto, todos los componentes son renderizables (`true`). Sin embargo, este método - /// puede sobrescribirse para decidir dinámicamente si los componentes de este tipo se - /// renderizan o no en función del contexto de renderizado. - /// - /// También puede usarse junto con un alias de función como - /// ([`FnIsRenderable`](crate::core::component::FnIsRenderable)) para permitir que instancias - /// concretas del componente decidan si se renderizan o no. - #[allow(unused_variables)] - fn is_renderable(&self, cx: &mut Context) -> bool { - true - } - /// Configura el componente justo antes de preparar el renderizado. /// /// Este método puede sobrescribirse para modificar la estructura interna del componente o el @@ -86,30 +72,30 @@ pub trait Component: AnyInfo + ComponentRender + Send + Sync { /// Implementa [`render()`](ComponentRender::render) para todos los componentes. /// -/// El proceso de renderizado de cada componente sigue esta secuencia: +/// Y para cada componente ejecuta la siguiente secuencia: /// -/// 1. Ejecuta [`is_renderable()`](Component::is_renderable) para ver si puede renderizarse en el -/// contexto actual. Si no es así, devuelve un [`Markup`] vacío. +/// 1. Despacha [`action::component::IsRenderable`](crate::base::action::component::IsRenderable) +/// para ver si se puede renderizar. Si no es así, devuelve un [`Markup`] vacío. /// 2. Ejecuta [`setup_before_prepare()`](Component::setup_before_prepare) para que el componente /// pueda ajustar su estructura interna o modificar el contexto. /// 3. Despacha [`action::theme::BeforeRender`](crate::base::action::theme::BeforeRender) para -/// permitir que el tema realice ajustes previos. +/// que el tema pueda hacer ajustes en el componente o el contexto. /// 4. Despacha [`action::component::BeforeRender`](crate::base::action::component::BeforeRender) -/// para que otras extensiones puedan también hacer ajustes previos. +/// para que otras extensiones puedan hacer ajustes. /// 5. **Prepara el renderizado del componente**: /// - Despacha [`action::theme::PrepareRender`](crate::base::action::theme::PrepareRender) -/// para permitir al tema generar un renderizado alternativo. -/// - Si el tema no lo modifica, llama a [`prepare_component()`](Component::prepare_component) -/// para obtener el renderizado por defecto del componente. +/// para permitir al tema preparar un renderizado diferente al predefinido. +/// - Si no es así, ejecuta [`prepare_component()`](Component::prepare_component) para preparar +/// el renderizado predefinido del componente. /// 6. Despacha [`action::theme::AfterRender`](crate::base::action::theme::AfterRender) para -/// que el tema pueda aplicar ajustes finales. +/// que el tema pueda hacer sus últimos ajustes. /// 7. Despacha [`action::component::AfterRender`](crate::base::action::component::AfterRender) /// para que otras extensiones puedan hacer sus últimos ajustes. -/// 8. Devuelve el [`Markup`] generado en el paso 5. +/// 8. Finalmente devuelve un [`Markup`] del renderizado preparado en el paso 5. impl ComponentRender for C { fn render(&mut self, cx: &mut Context) -> Markup { // Si no es renderizable, devuelve un bloque HTML vacío. - if !self.is_renderable(cx) { + if !action::component::IsRenderable::dispatch(self, cx) { return html! {}; } diff --git a/src/core/theme.rs b/src/core/theme.rs index 8774276e..64f40f33 100644 --- a/src/core/theme.rs +++ b/src/core/theme.rs @@ -1,24 +1,24 @@ //! API para añadir y gestionar nuevos temas. //! -//! En PageTop un tema es la *piel* de la aplicación. Es responsable último de los estilos, -//! tipografías, espaciados y cualquier otro detalle visual o interactivo (animaciones, scripts de -//! interfaz, etc.). +//! 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. //! -//! Un tema determina el aspecto final de un documento HTML sin alterar la lógica interna de los -//! componentes ni la estructura del documento, que queda definida por la plantilla -//! ([`Template`](crate::base::component::Template)) utilizada por cada página. +//! 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.). //! -//! Los temas son extensiones que implementan [`Extension`](crate::core::extension::Extension), por -//! lo que se instancian, declaran dependencias y se inician igual que cualquier otra extensión. -//! También deben implementar [`Theme`] y sobrescribir el método -//! [`Extension::theme()`](crate::core::extension::Extension::theme) para que PageTop pueda -//! registrarlos como temas. +//! 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}; +pub use definition::{Theme, ThemePage, ThemeRef, ThemeRegion}; mod regions; -pub(crate) use regions::ChildrenInRegions; -pub use regions::InRegion; +pub(crate) use regions::{ChildrenInRegions, REGION_CONTENT}; +pub use regions::{InRegion, Region, RegionRef}; pub(crate) mod all; diff --git a/src/core/theme/definition.rs b/src/core/theme/definition.rs index de11d1ba..2a20c078 100644 --- a/src/core/theme/definition.rs +++ b/src/core/theme/definition.rs @@ -1,136 +1,126 @@ -use crate::base::component::Template; -use crate::core::component::{ComponentRender, ContextOp, Contextual}; +use crate::core::component::{ContextOp, Contextual}; use crate::core::extension::Extension; -use crate::global; +use crate::core::theme::{Region, RegionRef, REGION_CONTENT}; use crate::html::{html, Markup, StyleSheet}; use crate::locale::L10n; use crate::response::page::Page; +use crate::{global, join}; + +use std::sync::LazyLock; /// Referencia estática a un tema. /// /// Los temas son también extensiones. Por tanto, deben declararse como **instancias estáticas** que -/// implementen [`Theme`] y, a su vez, [`Extension`]. Estas instancias se exponen usando -/// [`Extension::theme()`](crate::core::extension::Extension::theme). +/// implementen [`Theme`] y, a su vez, [`Extension`]. pub type ThemeRef = &'static dyn Theme; -/// Interfaz común que debe implementar cualquier tema de PageTop. +/// Conjunto de regiones que los temas pueden exponer para el renderizado. /// -/// Un tema es una [`Extension`](crate::core::extension::Extension) que define el aspecto general de -/// las páginas: cómo se renderiza el ``, cómo se presenta el `` mediante plantillas -/// ([`Template`]) y qué contenido mostrar en las páginas de error. +/// `ThemeRegion` define un conjunto de regiones predefinidas para estructurar un documento HTML. +/// Proporciona **identificadores estables** (vía [`Region::key()`]) y **etiquetas localizables** +/// (vía [`Region::label()`]) a las regiones donde se añadirán los componentes. /// -/// Todos los métodos de este *trait* tienen una implementación por defecto, por lo que pueden -/// sobrescribirse selectivamente para crear nuevos temas con comportamientos distintos a los -/// predeterminados. -/// -/// El único método **obligatorio** de `Extension` para un tema es [`theme()`](Extension::theme), -/// que debe devolver una referencia estática al propio tema: -/// -/// ```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 + Send + Sync { - /// Acciones específicas del tema antes de renderizar el `` de la página. +/// Se usa por defecto en [`Theme::page_regions()`](crate::core::theme::Theme::page_regions) y sus +/// variantes representan el conjunto mínimo recomendado para cualquier tema. Sin embargo, cada tema +/// podría exponer su propio conjunto de regiones. +pub enum ThemeRegion { + /// Cabecera de la página. /// - /// Se invoca antes de que se procese la plantilla ([`Template`]) asociada a la página - /// ([`Page::template()`](crate::response::page::Page::template)). Es un buen lugar para - /// inicializar o ajustar recursos en función del contexto de la página, por ejemplo: - /// - /// - Añadir metadatos o propiedades a la página. - /// - Preparar atributos compartidos. - /// - Registrar *assets* condicionales en el contexto. - #[allow(unused_variables)] - fn before_render_page_body(&self, page: &mut Page) {} + /// Clave: `"header"`. Suele contener *branding*, navegación principal o avisos globales. + Header, - /// Renderiza el contenido del `` de la página. + /// Contenido principal de la página (**obligatoria**). /// - /// Por defecto, delega en la plantilla ([`Template`]) asociada a la página - /// ([`Page::template()`](crate::response::page::Page::template)). La plantilla se encarga de - /// procesar las regiones y renderizar los componentes registrados en el contexto. - /// - /// Los temas pueden sobrescribir este método para: - /// - /// - Forzar una plantilla concreta en determinadas páginas. - /// - Envolver el contenido en marcadores adicionales. - /// - Implementar lógicas de composición alternativas. - #[inline] - fn render_page_body(&self, page: &mut Page) -> Markup { - Template::named(page.template()).render(page.context()) - } + /// Clave: `"content"`. Es el destino por defecto para insertar componentes a nivel de página. + Content, - /// Acciones específicas del tema después de renderizar el `` de la página. + /// Pie de página. /// - /// Se invoca tras la generación del contenido del ``. Es útil para: - /// - /// - Ajustar o registrar recursos en función de lo que se haya renderizado. - /// - Realizar *tracing* o recopilar métricas. - /// - Aplicar ajustes finales al estado de la página antes de producir el `` o la - /// respuesta final. - /// - /// La implementación por defecto añade una serie de hojas de estilo básicas (`normalize.css`, - /// `root.css`, `basic.css`) cuando el parámetro `include_basic_assets` de la página está - /// activado. - #[allow(unused_variables)] - fn after_render_page_body(&self, page: &mut Page) { - if page.param_or("include_basic_assets", false) { - let pkg_version = env!("CARGO_PKG_VERSION"); + /// Clave: `"footer"`. Suele contener enlaces legales, créditos o navegación secundaria. + Footer, +} - page.alter_assets(ContextOp::AddStyleSheet( - StyleSheet::from("/css/normalize.css") - .with_version("8.0.1") - .with_weight(-99), - )) - .alter_assets(ContextOp::AddStyleSheet( - StyleSheet::from("/css/root.css") - .with_version(pkg_version) - .with_weight(-99), - )) - .alter_assets(ContextOp::AddStyleSheet( - StyleSheet::from("/css/basic.css") - .with_version(pkg_version) - .with_weight(-99), - )); +impl Region for ThemeRegion { + fn key(&self) -> &str { + match self { + ThemeRegion::Header => "header", + ThemeRegion::Content => REGION_CONTENT, + ThemeRegion::Footer => "footer", } } - /// Renderiza el contenido del `` de la página. + fn label(&self) -> L10n { + L10n::l(join!("region_", self.key())) + } +} + +/// Métodos predefinidos de renderizado para las páginas de un tema. +/// +/// Contiene las implementaciones base para renderizar las **secciones** `` y ``. Se +/// implementa automáticamente para cualquier tipo que implemente [`Theme`], por lo que normalmente +/// no requiere implementación explícita. +/// +/// Si un tema **sobrescribe** uno o más de estos métodos de [`Theme`]: +/// +/// - [`render_page_region()`](Theme::render_page_region), +/// - [`render_page_head()`](Theme::render_page_head), o +/// - [`render_page_body()`](Theme::render_page_body); +/// +/// es posible volver al comportamiento por defecto usando FQS (*Fully Qualified Syntax*): +/// +/// - `::render_body(self, page, self.page_regions())` +/// - `::render_head(self, page)` +pub trait ThemePage { + /// Renderiza el **contenedor** de una región concreta del `` de la página. /// - /// Aunque en una página el `` se encuentra antes del ``, internamente se renderiza - /// después para contar con los ajustes que hayan ido acumulando los componentes. Por ejemplo, - /// permitiría añadir un archivo de iconos sólo si se ha incluido un icono en la página. + /// Obtiene los componentes asociados a `region.key()` desde el contexto de la página y, si hay + /// salida, envuelve el contenido en un contenedor `
` predefinido. /// - /// Por defecto incluye: - /// - /// - La codificación (`charset="utf-8"`). - /// - El título, usando el título de la página si existe y, en caso contrario, sólo el nombre de - /// la aplicación. - /// - La descripción (``), si está definida. - /// - La etiqueta `viewport` básica para diseño adaptable. - /// - Los metadatos (`name`/`content`) y propiedades (`property`/`content`) declarados en la - /// página. - /// - Todos los *assets* registrados en el contexto de la página. - /// - /// Los temas pueden sobrescribir este método para añadir etiquetas adicionales (por ejemplo, - /// *favicons* personalizados, manifest, etiquetas de analítica, etc.). + /// Si la región **no produce contenido**, devuelve un `Markup` vacío. #[inline] - fn render_page_head(&self, page: &mut Page) -> Markup { + fn render_region(&self, page: &mut Page, region: RegionRef) -> Markup { + html! { + @let key = region.key(); + @let output = page.context().render_components_of(key); + @if !output.is_empty() { + div + id=(key) + class={ "region region--" (key) } + role="region" + aria-label=[region.label().lookup(page)] + { + (output) + } + } + } + } + + /// Renderiza el **contenido interior** del `` de la página. + /// + /// Recorre `regions` en el **orden declarado** y, para cada región con contenido, delega en + /// [`render_region()`](Self::render_region) la generación del contenedor. Las regiones sin + /// contenido **no** producen salida. Se asume que cada identificador de región es **único** + /// dentro de la página. + /// + /// La etiqueta `` no se incluye aquí; únicamente renderiza su contenido. + #[inline] + fn render_body(&self, page: &mut Page, regions: &[RegionRef]) -> Markup { + html! { + @for region in regions { + (self.render_region(page, *region)) + } + } + } + + /// Renderiza el **contenido interior** del `` de la página. + /// + /// Incluye por defecto 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. + /// + /// La etiqueta `` no se incluye aquí; únicamente se renderiza su contenido. + #[inline] + fn render_head(&self, page: &mut Page) -> Markup { let viewport = "width=device-width, initial-scale=1, shrink-to-fit=no"; html! { meta charset="utf-8"; @@ -158,20 +148,155 @@ pub trait Theme: Extension + Send + Sync { (page.context().render_assets()) } } +} + +/// 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. + /// + /// Retorna una **lista estática** de referencias ([`RegionRef`](crate::core::theme::RegionRef)) + /// que representan las regiones que el tema admite dentro del ``. + /// + /// Cada referencia apunta a una instancia que implementa [`Region`](crate::core::theme::Region) + /// para definir cada región de forma segura y estable. Y si un tema necesita un conjunto + /// distinto de regiones, puede **sobrescribir** este método siguiendo estas recomendaciones: + /// + /// - Los identificadores devueltos por [`Region::key()`](crate::core::theme::Region::key) + /// deben ser **estables** (p. ej. `"sidebar-left"`, `"content"`). + /// - La región `"content"` es **obligatoria**, ya que se usa como destino por defecto para + /// insertar componentes y renderizarlos. + /// - El orden de la lista podría tener relevancia como **orden de renderizado** dentro del + /// `` segun la implementación de [`render_page_body()`](Self::render_page_body). + /// - Las etiquetas (`L10n`) de cada región se evaluarán con el idioma activo de la página. + /// + /// # Ejemplo + /// + /// ```rust,ignore + /// fn page_regions(&self) -> &'static [RegionRef] { + /// static REGIONS: LazyLock<[RegionRef; 4]> = LazyLock::new(|| { + /// [ + /// &ThemeRegion::Header, + /// &ThemeRegion::Content, + /// &ThemeRegion::Footer, + /// ] + /// }); + /// &*REGIONS + /// } + /// ``` + fn page_regions(&self) -> &'static [RegionRef] { + static REGIONS: LazyLock<[RegionRef; 3]> = LazyLock::new(|| { + [ + &ThemeRegion::Header, + &ThemeRegion::Content, + &ThemeRegion::Footer, + ] + }); + &*REGIONS + } + + /// Renderiza una región de la página. + /// + /// Si se sobrescribe este método, se puede volver al comportamiento base con: + /// `::render_region(self, page, region)`. + #[inline] + fn render_page_region(&self, page: &mut Page, region: RegionRef) -> Markup { + ::render_region(self, page, region) + } + + /// 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 renderizado 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 renderizado base con: + /// `::render_head(self, page)`. + #[inline] + fn render_page_head(&self, page: &mut Page) -> Markup { + if page.param_or("include_basic_assets", false) { + let pkg_version = env!("CARGO_PKG_VERSION"); + + page.alter_assets(ContextOp::AddStyleSheet( + StyleSheet::from("/css/normalize.css") + .with_version("8.0.1") + .with_weight(-99), + )) + .alter_assets(ContextOp::AddStyleSheet( + StyleSheet::from("/css/root.css") + .with_version(pkg_version) + .with_weight(-99), + )) + .alter_assets(ContextOp::AddStyleSheet( + StyleSheet::from("/css/basic.css") + .with_version(pkg_version) + .with_weight(-99), + )); + } + ::render_head(self, page) + } /// Contenido predeterminado para la página de error "*403 - Forbidden*". /// - /// Los temas pueden sobrescribir este método para personalizar el diseño y el contenido de la - /// página de error, manteniendo o no el mensaje de los textos localizados. + /// 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").using(page)) } } } } /// Contenido predeterminado para la página de error "*404 - Not Found*". /// - /// Los temas pueden sobrescribir este método para personalizar el diseño y el contenido de la - /// página de error, manteniendo o no el mensaje de los textos localizados. + /// 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").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 259417eb..8e386f55 100644 --- a/src/core/theme/regions.rs +++ b/src/core/theme/regions.rs @@ -1,6 +1,6 @@ -use crate::base::component::Region; use crate::core::component::{Child, ChildOp, Children}; use crate::core::theme::ThemeRef; +use crate::locale::L10n; use crate::{builder_fn, AutoDefault, UniqueId}; use parking_lot::RwLock; @@ -16,41 +16,102 @@ static THEME_REGIONS: LazyLock>> = static COMMON_REGIONS: LazyLock> = LazyLock::new(|| RwLock::new(ChildrenInRegions::default())); +/// Nombre de la región de contenido por defecto (`"content"`). +pub const REGION_CONTENT: &str = "content"; + +/// Define la interfaz mínima que describe una **región de renderizado** dentro de una página. +/// +/// Una *región* representa una zona del documento HTML (por ejemplo: `"header"`, `"content"` o +/// `"sidebar-left"`), en la que se pueden incluir y renderizar componentes dinámicamente. +/// +/// Este `trait` abstrae los metadatos básicos de cada región, esencialmente: +/// +/// - su **clave interna** (`key()`), que la identifica de forma única dentro de la página, y +/// - su **etiqueta localizada** (`label()`), que se usa como texto accesible (por ejemplo en +/// `aria-label` o en descripciones semánticas del contenedor). +/// +/// Las implementaciones típicas son *enumeraciones estáticas* declaradas por cada tema (ver como +/// ejemplo [`ThemeRegion`](crate::core::theme::ThemeRegion)), de modo que las claves y etiquetas +/// permanecen inmutables y fácilmente referenciables. +/// +/// # Ejemplo +/// +/// ```rust +/// # use pagetop::prelude::*; +/// pub enum MyThemeRegion { +/// Header, +/// Content, +/// Footer, +/// } +/// +/// impl Region for MyThemeRegion { +/// fn key(&self) -> &str { +/// match self { +/// MyThemeRegion::Header => "header", +/// MyThemeRegion::Content => "content", +/// MyThemeRegion::Footer => "footer", +/// } +/// } +/// +/// fn label(&self) -> L10n { +/// L10n::l(join!("region__", self.key())) +/// } +/// } +/// ``` +pub trait Region: Send + Sync { + /// Devuelve la **clave interna** que identifica de forma única una región. + /// + /// La clave se utiliza para asociar los componentes de la región con su contenedor HTML + /// correspondiente. Por convención, se emplean nombres en minúsculas y con guiones (`"header"`, + /// `"main"`, `"sidebar-right"`, etc.), y la región `"content"` es **obligatoria** en todos los + /// temas. + fn key(&self) -> &str; + + /// Devuelve la **etiqueta localizada** (`L10n`) asociada a la región. + /// + /// Esta etiqueta se evalúa en el idioma activo de la página y se utiliza principalmente para + /// accesibilidad, como el valor de `aria-label` en el contenedor generado por + /// [`ThemePage::render_region()`](crate::core::theme::ThemePage::render_region). + fn label(&self) -> L10n; +} + +/// Referencia estática a una región. +pub type RegionRef = &'static dyn Region; + // Contenedor interno de componentes agrupados por región. #[derive(AutoDefault)] -pub(crate) struct ChildrenInRegions(HashMap); +pub struct ChildrenInRegions(HashMap<&'static str, Children>); impl ChildrenInRegions { - pub fn with(region_name: impl AsRef, child: Child) -> Self { - Self::default().with_child_in(region_name, ChildOp::Add(child)) + pub fn with(region_key: &'static str, child: Child) -> Self { + ChildrenInRegions::default().with_child_in(region_key, ChildOp::Add(child)) } #[builder_fn] - pub fn with_child_in(mut self, region_name: impl AsRef, op: ChildOp) -> Self { - let name = region_name.as_ref(); - if let Some(region) = self.0.get_mut(name) { + pub fn with_child_in(mut self, region_key: &'static str, op: ChildOp) -> Self { + if let Some(region) = self.0.get_mut(region_key) { region.alter_child(op); } else { - self.0 - .insert(name.to_owned(), Children::new().with_child(op)); + self.0.insert(region_key, Children::new().with_child(op)); } self } - pub fn children_for(&self, theme_ref: ThemeRef, region_name: impl AsRef) -> Children { - let name = region_name.as_ref(); + pub fn merge_all_components(&self, theme_ref: ThemeRef, region_key: &'static str) -> Children { let common = COMMON_REGIONS.read(); - let themed = THEME_REGIONS.read(); - - if let Some(r) = themed.get(&theme_ref.type_id()) { - Children::merge(&[common.0.get(name), self.0.get(name), r.0.get(name)]) + if let Some(r) = THEME_REGIONS.read().get(&theme_ref.type_id()) { + Children::merge(&[ + common.0.get(region_key), + self.0.get(region_key), + r.0.get(region_key), + ]) } else { - Children::merge(&[common.0.get(name), self.0.get(name)]) + Children::merge(&[common.0.get(region_key), self.0.get(region_key)]) } } } -/// Permite añadir componentes a regiones globales o específicas de un tema. +/// Punto de acceso para añadir componentes a regiones globales o específicas de un tema. /// /// Según la variante, se pueden añadir componentes ([`add()`](Self::add)) que permanecerán /// disponibles durante toda la ejecución. @@ -59,10 +120,10 @@ impl ChildrenInRegions { /// estas regiones, como las páginas de contenido ([`Page`](crate::response::page::Page)). pub enum InRegion { /// Región de contenido por defecto. - Default, - /// Región identificada por el nombre proporcionado. - Named(&'static str), - /// Región identificada por su nombre para un tema concreto. + Content, + /// Región identificada por la clave proporcionado. + Key(&'static str), + /// Región identificada por una clave para un tema concreto. OfTheme(&'static str, ThemeRef), } @@ -74,38 +135,39 @@ impl InRegion { /// ```rust /// # use pagetop::prelude::*; /// // Banner global, en la región por defecto de cualquier página. - /// InRegion::Default.add(Child::with(Html::with(|_| + /// InRegion::Content.add(Child::with(Html::with(|_| /// html! { ("🎉 ¡Bienvenido!") } /// ))); /// /// // Texto en la región "sidebar". - /// InRegion::Named("sidebar").add(Child::with(Html::with(|_| + /// InRegion::Key("sidebar").add(Child::with(Html::with(|_| /// html! { ("Publicidad") } /// ))); /// ``` pub fn add(&self, child: Child) -> &Self { match self { - InRegion::Default => Self::add_to_common(Region::DEFAULT, child), - InRegion::Named(region_name) => Self::add_to_common(region_name, child), - InRegion::OfTheme(region_name, theme_ref) => { + InRegion::Content => { + COMMON_REGIONS + .write() + .alter_child_in(REGION_CONTENT, ChildOp::Add(child)); + } + InRegion::Key(region_key) => { + COMMON_REGIONS + .write() + .alter_child_in(region_key, ChildOp::Add(child)); + } + InRegion::OfTheme(region_key, theme_ref) => { let mut regions = THEME_REGIONS.write(); if let Some(r) = regions.get_mut(&theme_ref.type_id()) { - r.alter_child_in(region_name, ChildOp::Add(child)); + r.alter_child_in(region_key, ChildOp::Add(child)); } else { regions.insert( theme_ref.type_id(), - ChildrenInRegions::with(region_name, child), + ChildrenInRegions::with(region_key, child), ); } } } self } - - #[inline] - fn add_to_common(region_name: &str, child: Child) { - COMMON_REGIONS - .write() - .alter_child_in(region_name, ChildOp::Add(child)); - } } diff --git a/src/html.rs b/src/html.rs index 82fdcd73..5f5b833a 100644 --- a/src/html.rs +++ b/src/html.rs @@ -104,11 +104,11 @@ pub use unit::UnitValue; /// # use pagetop::prelude::*; /// // Texto normal, se escapa automáticamente para evitar inyección de HTML. /// let fragment = PrepareMarkup::Escaped("Hola mundo".to_string()); -/// assert_eq!(fragment.into_string(), "Hola <b>mundo</b>"); +/// assert_eq!(fragment.render().into_string(), "Hola <b>mundo</b>"); /// /// // HTML literal, se inserta directamente, sin escapado adicional. /// let raw_html = PrepareMarkup::Raw("negrita".to_string()); -/// assert_eq!(raw_html.into_string(), "negrita"); +/// assert_eq!(raw_html.render().into_string(), "negrita"); /// /// // Fragmento ya preparado con la macro `html!`. /// let prepared = PrepareMarkup::With(html! { @@ -116,11 +116,11 @@ pub use unit::UnitValue; /// p { "Este es un párrafo con contenido dinámico." } /// }); /// assert_eq!( -/// prepared.into_string(), +/// prepared.render().into_string(), /// "

Título de ejemplo

Este es un párrafo con contenido dinámico.

" /// ); /// ``` -#[derive(AutoDefault, Clone)] +#[derive(AutoDefault)] pub enum PrepareMarkup { /// No se genera contenido HTML (equivale a `html! {}`). #[default] @@ -152,13 +152,8 @@ impl PrepareMarkup { } } - /// Convierte el contenido en una cadena HTML renderizada. Usar sólo para pruebas o depuración. - pub fn into_string(&self) -> String { - self.render().into_string() - } - - // Integra el renderizado fácilmente en la macro [`html!`]. - pub(crate) fn render(&self) -> Markup { + /// Integra el renderizado fácilmente en la macro [`html!`]. + pub fn render(&self) -> Markup { match self { PrepareMarkup::None => html! {}, PrepareMarkup::Escaped(text) => html! { (text) }, diff --git a/src/response/page.rs b/src/response/page.rs index 7d7789d4..036c999c 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -4,10 +4,8 @@ pub use error::ErrorPage; pub use actix_web::Result as ResultPage; use crate::base::action; -use crate::base::component::Region; -use crate::core::component::{Child, ChildOp, Component, ComponentRender}; -use crate::core::component::{Context, ContextOp, Contextual}; -use crate::core::theme::ThemeRef; +use crate::core::component::{Child, ChildOp, Component, Context, ContextOp, Contextual}; +use crate::core::theme::{ThemeRef, REGION_CONTENT}; use crate::html::{html, Markup, DOCTYPE}; use crate::html::{Assets, Favicon, JavaScript, StyleSheet}; use crate::html::{AttrClasses, ClassesOp}; @@ -111,14 +109,14 @@ impl Page { /// Añade un componente hijo a la región de contenido por defecto. pub fn add_child(mut self, component: impl Component) -> Self { self.context - .alter_child_in(Region::DEFAULT, ChildOp::Add(Child::with(component))); + .alter_child_in(REGION_CONTENT, ChildOp::Add(Child::with(component))); self } - /// Añade un componente hijo en la región `region_name` de la página. - pub fn add_child_in(mut self, region_name: &'static str, component: impl Component) -> Self { + /// Añade un componente hijo en una región (`region_key`) de la página. + pub fn add_child_in(mut self, region_key: &'static str, component: impl Component) -> Self { self.context - .alter_child_in(region_name, ChildOp::Add(Child::with(component))); + .alter_child_in(region_key, ChildOp::Add(Child::with(component))); self } @@ -193,11 +191,7 @@ impl Page { action::page::BeforeRenderBody::dispatch(self); // Renderiza el . - let body = html! { - (Region::named(Region::PAGETOP).render(&mut self.context)) - (self.context.theme().render_page_body(self)) - (Region::named(Region::PAGEBOTTOM).render(&mut self.context)) - }; + let body = self.context.theme().render_page_body(self); // Acciones específicas del tema después de renderizar el . self.context.theme().after_render_page_body(self); @@ -222,7 +216,9 @@ impl Page { (head) } body id=[self.body_id().get()] class=[self.body_classes().get()] { + (self.context.render_components_of("page-top")) (body) + (self.context.render_components_of("page-bottom")) } } }) @@ -251,14 +247,14 @@ impl Contextual for Page { } #[builder_fn] - fn with_theme(mut self, theme: ThemeRef) -> Self { - self.context.alter_theme(theme); + fn with_theme(mut self, theme_name: &'static str) -> Self { + self.context.alter_theme(theme_name); self } #[builder_fn] - fn with_template(mut self, template_name: &'static str) -> Self { - self.context.alter_template(template_name); + fn with_layout(mut self, layout_name: &'static str) -> Self { + self.context.alter_layout(layout_name); self } @@ -275,8 +271,8 @@ impl Contextual for Page { } #[builder_fn] - fn with_child_in(mut self, region_name: impl AsRef, op: ChildOp) -> Self { - self.context.alter_child_in(region_name, op); + fn with_child_in(mut self, region_key: &'static str, op: ChildOp) -> Self { + self.context.alter_child_in(region_key, op); self } @@ -290,8 +286,8 @@ impl Contextual for Page { self.context.theme() } - fn template(&self) -> &str { - self.context.template() + fn layout(&self) -> &str { + self.context.layout() } fn param(&self, key: &'static str) -> Option<&T> { diff --git a/src/response/page/error.rs b/src/response/page/error.rs index 7a590e6e..9945a948 100644 --- a/src/response/page/error.rs +++ b/src/response/page/error.rs @@ -1,4 +1,4 @@ -use crate::base::component::{Html, Template}; +use crate::base::component::Html; use crate::core::component::Contextual; use crate::locale::L10n; use crate::response::ResponseError; @@ -33,7 +33,7 @@ impl Display for ErrorPage { let error403 = error_page.theme().error403(&mut error_page); if let Ok(page) = error_page .with_title(L10n::n("Error FORBIDDEN")) - .with_template(Template::ERROR) + .with_layout("error") .add_child(Html::with(move |_| error403.clone())) .render() { @@ -48,7 +48,7 @@ impl Display for ErrorPage { let error404 = error_page.theme().error404(&mut error_page); if let Ok(page) = error_page .with_title(L10n::n("Error RESOURCE NOT FOUND")) - .with_template(Template::ERROR) + .with_layout("error") .add_child(Html::with(move |_| error404.clone())) .render() { diff --git a/tests/component_html.rs b/tests/component_html.rs index 06d77ec9..851315a9 100644 --- a/tests/component_html.rs +++ b/tests/component_html.rs @@ -2,28 +2,32 @@ use pagetop::prelude::*; #[pagetop::test] async fn component_html_renders_static_markup() { - let mut component = Html::with(|_| { + let component = Html::with(|_| { html! { p { "Test" } } }); - let markup = component.render(&mut Context::default()); + let markup = component + .prepare_component(&mut Context::new(None)) + .render(); + assert_eq!(markup.0, "

Test

"); } #[pagetop::test] async fn component_html_renders_using_context_param() { - let mut cx = Context::default().with_param("username", "Alice".to_string()); + let mut cx = Context::new(None).with_param("username", "Alice".to_string()); - let mut component = Html::with(|cx| { + let component = Html::with(|cx| { let name = cx.param::("username").cloned().unwrap_or_default(); html! { span { (name) } } }); - let markup = component.render(&mut cx); + let markup = component.prepare_component(&mut cx).render(); + assert_eq!(markup.0, "Alice"); } @@ -33,15 +37,21 @@ async fn component_html_allows_replacing_render_function() { component.alter_fn(|_| html! { div { "Modified" } }); - let markup = component.render(&mut Context::default()); + let markup = component + .prepare_component(&mut Context::new(None)) + .render(); + assert_eq!(markup.0, "
Modified
"); } #[pagetop::test] async fn component_html_default_renders_empty_markup() { - let mut component = Html::default(); + let component = Html::default(); + + let markup = component + .prepare_component(&mut Context::new(None)) + .render(); - let markup = component.render(&mut Context::default()); assert_eq!(markup.0, ""); } @@ -50,7 +60,7 @@ async fn component_html_can_access_http_method() { let req = service::test::TestRequest::with_uri("/").to_http_request(); let mut cx = Context::new(Some(req)); - let mut component = Html::with(|cx| { + let component = Html::with(|cx| { let method = cx .request() .map(|r| r.method().to_string()) @@ -58,6 +68,7 @@ async fn component_html_can_access_http_method() { html! { span { (method) } } }); - let markup = component.render(&mut cx); + let markup = component.prepare_component(&mut cx).render(); + assert_eq!(markup.0, "GET"); } diff --git a/tests/component_poweredby.rs b/tests/component_poweredby.rs index 7e5a062c..27683d95 100644 --- a/tests/component_poweredby.rs +++ b/tests/component_poweredby.rs @@ -4,8 +4,8 @@ use pagetop::prelude::*; async fn poweredby_default_shows_only_pagetop_recognition() { let _app = service::test::init_service(Application::new().test()).await; - let mut p = PoweredBy::default(); - let html = p.render(&mut Context::default()); + let p = PoweredBy::default(); + let html = render_component(&p); // Debe mostrar el bloque de reconocimiento a PageTop. assert!(html.as_str().contains("poweredby__pagetop")); @@ -18,8 +18,8 @@ async fn poweredby_default_shows_only_pagetop_recognition() { async fn poweredby_new_includes_current_year_and_app_name() { let _app = service::test::init_service(Application::new().test()).await; - let mut p = PoweredBy::new(); - let html = p.render(&mut Context::default()); + let p = PoweredBy::new(); + let html = render_component(&p); let year = Utc::now().format("%Y").to_string(); assert!( @@ -43,8 +43,8 @@ async fn poweredby_with_copyright_overrides_text() { let _app = service::test::init_service(Application::new().test()).await; let custom = "2001 © FooBar Inc."; - let mut p = PoweredBy::default().with_copyright(Some(custom)); - let html = p.render(&mut Context::default()); + let p = PoweredBy::default().with_copyright(Some(custom)); + let html = render_component(&p); assert!(html.as_str().contains(custom)); assert!(html.as_str().contains("poweredby__copyright")); @@ -54,8 +54,8 @@ async fn poweredby_with_copyright_overrides_text() { async fn poweredby_with_copyright_none_hides_text() { let _app = service::test::init_service(Application::new().test()).await; - let mut p = PoweredBy::new().with_copyright(None::); - let html = p.render(&mut Context::default()); + let p = PoweredBy::new().with_copyright(None::); + let html = render_component(&p); assert!(!html.as_str().contains("poweredby__copyright")); // El reconocimiento a PageTop siempre debe aparecer. @@ -66,8 +66,8 @@ async fn poweredby_with_copyright_none_hides_text() { async fn poweredby_link_points_to_crates_io() { let _app = service::test::init_service(Application::new().test()).await; - let mut p = PoweredBy::default(); - let html = p.render(&mut Context::default()); + let p = PoweredBy::default(); + let html = render_component(&p); assert!( html.as_str().contains("https://pagetop.cillero.es"), @@ -89,3 +89,11 @@ async fn poweredby_getter_reflects_internal_state() { assert!(c1.contains(&Utc::now().format("%Y").to_string())); assert!(c1.contains(&global::SETTINGS.app.name)); } + +// **< HELPERS >************************************************************************************ + +fn render_component(c: &C) -> Markup { + let mut cx = Context::default(); + let pm = c.prepare_component(&mut cx); + pm.render() +} diff --git a/tests/html_pm.rs b/tests/html_pm.rs index 615ea470..ae4517bc 100644 --- a/tests/html_pm.rs +++ b/tests/html_pm.rs @@ -1,69 +1,70 @@ use pagetop::prelude::*; -/// Componente mínimo para probar `PrepareMarkup` pasando por el ciclo real -/// de renderizado de componentes (`ComponentRender`). -#[derive(AutoDefault)] -struct TestPrepareComponent { - pm: PrepareMarkup, -} - -impl Component for TestPrepareComponent { - fn new() -> Self { - Self { - pm: PrepareMarkup::None, - } - } - - fn prepare_component(&self, _cx: &mut Context) -> PrepareMarkup { - self.pm.clone() - } -} - -impl TestPrepareComponent { - fn render_pm(pm: PrepareMarkup) -> String { - let mut c = TestPrepareComponent { pm }; - c.render(&mut Context::default()).into_string() - } +#[pagetop::test] +async fn prepare_markup_render_none_is_empty_string() { + assert_eq!(PrepareMarkup::None.render().as_str(), ""); } #[pagetop::test] -async fn prepare_markup_none_is_empty_string() { - assert_eq!(PrepareMarkup::None.into_string(), ""); -} - -#[pagetop::test] -async fn prepare_markup_escaped_escapes_html_and_ampersands() { +async fn prepare_markup_render_escaped_escapes_html_and_ampersands() { let pm = PrepareMarkup::Escaped("& \" ' ".to_string()); - assert_eq!(pm.into_string(), "<b>& " ' </b>"); + assert_eq!(pm.render().as_str(), "<b>& " ' </b>"); } #[pagetop::test] -async fn prepare_markup_raw_is_inserted_verbatim() { +async fn prepare_markup_render_raw_is_inserted_verbatim() { let pm = PrepareMarkup::Raw("bold".to_string()); - assert_eq!(pm.into_string(), "bold"); + assert_eq!(pm.render().as_str(), "bold"); } #[pagetop::test] -async fn prepare_markup_with_keeps_structure() { +async fn prepare_markup_render_with_keeps_structure() { let pm = PrepareMarkup::With(html! { h2 { "Sample title" } - p { "This is a paragraph." } + p { "This is a paragraph." } }); assert_eq!( - pm.into_string(), + pm.render().as_str(), "

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.render()) } }; + 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.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.render()) } }; + 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!(esc.into_string(), "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!(raw.into_string(), "Title — section © 2025"); + assert_eq!(raw.render().as_str(), "Title — section © 2025"); } #[pagetop::test] @@ -87,36 +88,7 @@ async fn prepare_markup_is_empty_semantics() { } #[pagetop::test] -async fn prepare_markup_does_not_double_escape_when_markup_is_reinjected_in_html_macro() { - let mut cx = Context::default(); - - // Escaped: dentro de `html!` no debe volver a escaparse. - let mut comp = TestPrepareComponent { - pm: PrepareMarkup::Escaped("x".into()), - }; - let markup = comp.render(&mut cx); // Markup - let wrapped_escaped = html! { div { (markup) } }.into_string(); - assert_eq!(wrapped_escaped, "
<i>x</i>
"); - - // Raw: tampoco debe escaparse al integrarlo. - let mut comp = TestPrepareComponent { - pm: PrepareMarkup::Raw("x".into()), - }; - let markup = comp.render(&mut cx); - let wrapped_raw = html! { div { (markup) } }.into_string(); - assert_eq!(wrapped_raw, "
x
"); - - // With: debe incrustar el Markup tal cual. - let mut comp = TestPrepareComponent { - pm: PrepareMarkup::With(html! { span.title { "ok" } }), - }; - let markup = comp.render(&mut cx); - let wrapped_with = html! { div { (markup) } }.into_string(); - assert_eq!(wrapped_with, "
ok
"); -} - -#[pagetop::test] -async fn prepare_markup_equivalence_between_component_render_and_markup_reinjected_in_html_macro() { +async fn prepare_markup_equivalence_between_render_and_inline_in_html_macro() { let cases = [ PrepareMarkup::None, PrepareMarkup::Escaped("x".into()), @@ -125,20 +97,12 @@ async fn prepare_markup_equivalence_between_component_render_and_markup_reinject ]; for pm in cases { - // Vía 1: renderizamos y obtenemos directamente el String. - let via_component = TestPrepareComponent::render_pm(pm.clone()); - - // Vía 2: renderizamos, reinyectamos el Markup en `html!` y volvemos a obtener String. - let via_macro = { - let mut cx = Context::default(); - let mut comp = TestPrepareComponent { pm }; - let markup = comp.render(&mut cx); - html! { (markup) }.into_string() - }; - + let rendered = pm.render(); + let in_macro = html! { (rendered) }.into_string(); assert_eq!( - via_component, via_macro, - "The output of component render and (Markup) inside html! must match" + rendered.as_str(), + in_macro, + "The output of Render and (pm) inside html! must match" ); } }