pagetop/src/core/theme/definition.rs
Manuel Cillero 56a6691c87 🚧 (theme): Renombra variantes de Intent
Renombra `Intent::Secondary`/`Danger` a `Neutral`/`Severe` y actualiza
Badge y su CSS por defecto (basic.css) al nuevo vocabulario.

Introduce el punto de extensión `Theme::intent_color()`, que traduce una
`Intent` al nombre de color de la paleta propia de cada tema, con
`Intent::color(cx)` como atajo de uso desde los componentes.
2026-08-28 01:10:45 +02:00

464 lines
20 KiB
Rust

use crate::async_trait;
use crate::base::component::{Html, Intro, IntroOpening, layout};
use crate::core::component::{ChildOp, Component, ComponentError, ComponentRender};
use crate::core::component::{Context, Contextual};
use crate::core::extension::Extension;
use crate::core::theme::{CoreRegions, Intent};
use crate::global;
use crate::html::{Markup, html};
use crate::locale::Lc;
use crate::response::Page;
use crate::web::http::StatusCode;
/// Interfaz común que debe implementar cualquier tema de PageTop.
///
/// Un tema es una [`Extension`](crate::core::extension::Extension) que define el aspecto general de
/// las páginas: cómo se renderiza el `<head>`, cómo se presenta el `<body>` usando plantillas
/// ([`TemplateName`](crate::core::theme::TemplateName)) que maquetan regiones
/// ([`RegionName`](crate::core::theme::RegionName)) y qué contenido mostrar en las páginas de
/// error. El contenido de cada región depende del [`Context`](crate::core::component::Context) y de
/// su nombre lógico.
///
/// Todos los métodos de este trait tienen una implementación por defecto, por lo que pueden
/// sobrescribirse selectivamente para crear nuevos temas con comportamientos distintos a los
/// predeterminados.
///
/// El único método **obligatorio** de `Extension` para un tema es [`theme()`](Extension::theme),
/// que debe devolver una referencia al propio tema:
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// pub struct MyTheme;
///
/// #[async_trait]
/// impl Extension for MyTheme {
/// fn name(&self) -> Lc {
/// Lc::n("My theme")
/// }
///
/// fn description(&self) -> Lc {
/// Lc::n("A personal theme")
/// }
///
/// fn theme(&self) -> Option<ThemeRef> {
/// Some(&Self)
/// }
/// }
///
/// #[async_trait]
/// impl Theme for MyTheme {}
/// ```
#[async_trait]
pub trait Theme: Extension + Send + Sync {
/// Devuelve el tema padre del que hereda este tema, si existe.
///
/// Un tema hijo delega automáticamente todos los métodos de esta interfaz al tema padre cuando
/// no los sobrescribe.
///
/// La implementación por defecto devuelve `None` (tema sin padre).
///
/// Una referencia circular (un tema acaba siendo padre de sí mismo, directa o transitivamente)
/// no puede descartarse en tiempo de compilación. PageTop la detecta al registrar el tema y
/// aborta el arranque de la aplicación si encuentra una, para evitar bucles infinitos o
/// desbordamientos de pila al usar el tema.
fn parent(&self) -> Option<ThemeRef> {
None
}
/// Traduce una [`Intent`] al nombre de color de la paleta propia del tema.
///
/// `Intent` no define ninguna cadena propia. Cada tema decide qué nombre le corresponde a cada
/// variante en su paleta (p. ej. un tema basado en Bootstrap traduce `Severe` a `"danger"`).
/// Los componentes que generan clases CSS a partir de una `Intent` (`Button`, `Badge`,
/// `Dropdown`, etc.) no llaman a este método directamente en su `setup()`, usan mejor
/// [`Intent::color()`](crate::core::theme::Intent::color) como la forma más sencilla de obtener
/// este mismo valor a través de [`Context::theme()`](crate::core::component::Context::theme),
/// para que la clase resultante ya nazca en la paleta del tema activo.
///
/// La implementación por defecto devuelve el vocabulario semántico propio de PageTop
/// (`"primary"`, `"severe"`, etc.), que actúa como paleta base cuando ningún tema la
/// sobrescribe.
#[rustfmt::skip]
fn intent_color(&self, intent: Intent) -> &'static str {
if let Some(parent) = self.parent() {
return parent.intent_color(intent);
}
match intent {
Intent::Primary => "primary",
Intent::Neutral => "neutral",
Intent::Info => "info",
Intent::Success => "success",
Intent::Warning => "warning",
Intent::Severe => "severe",
}
}
/// Acciones específicas del tema antes de renderizar el `<body>` de la página.
///
/// 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 cabecera de la página.
/// - Preparar atributos compartidos.
/// - Registrar *assets* condicionales en el contexto.
///
/// La implementación por defecto no realiza ninguna acción.
#[allow(unused_variables)]
fn before_render_page_body(&self, page: &mut Page) {
if let Some(parent) = self.parent() {
parent.before_render_page_body(page);
}
}
/// Renderiza el contenido del `<body>` de la página.
///
/// La implementación predeterminada delega en la plantilla asociada a la página, obtenida desde
/// su [`Context`](crate::core::component::Context), para componer el `<body>` a partir de las
/// regiones.
///
/// Con la configuración por defecto, la plantilla estándar utiliza las regiones
/// [`CoreRegions::Header`](crate::core::theme::CoreRegions::Header),
/// [`CoreRegions::Aside`](crate::core::theme::CoreRegions::Aside),
/// [`CoreRegions::Content`](crate::core::theme::CoreRegions::Content) y
/// [`CoreRegions::Footer`](crate::core::theme::CoreRegions::Footer) en ese orden.
///
/// Los temas pueden sobrescribir este método para:
///
/// - Forzar una plantilla concreta en determinadas páginas.
/// - Consultar la plantilla de la página y variar la composición según su nombre.
/// - Envolver el contenido en contenedores adicionales.
/// - Implementar lógicas de composición alternativas.
#[inline]
async fn render_page_body(&self, page: &mut Page) -> Markup {
if let Some(parent) = self.parent() {
parent.render_page_body(page).await
} else {
let template = page.template();
layout::Template::of(template).render(page.context()).await
}
}
/// Acciones específicas del tema después de renderizar el `<body>` de la página.
///
/// Se invoca tras la generación del contenido del `<body>`. 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 `<head>` o la
/// respuesta final.
///
/// La implementación por defecto no realiza ninguna acción.
#[allow(unused_variables)]
fn after_render_page_body(&self, page: &mut Page) {
if let Some(parent) = self.parent() {
parent.after_render_page_body(page);
}
}
/// Renderiza el contenido del `<head>` de la página.
///
/// Aunque en una página el `<head>` se encuentra antes del `<body>`, 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.
///
/// 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 (`<meta name="description">`), 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.
/// - 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.).
async fn render_page_head(&self, page: &mut Page) -> Markup {
if let Some(parent) = self.parent() {
return parent.render_page_head(page).await;
}
let viewport = "width=device-width, initial-scale=1, shrink-to-fit=no";
html! {
meta charset="utf-8";
@if let Some(title) = page.title() {
title { (global::SETTINGS.app.name) (" | ") (title) }
} @else {
title { (global::SETTINGS.app.name) }
}
@if let Some(description) = page.description() {
meta name="description" content=(description);
}
meta name="viewport" content=(viewport);
@for (name, content) in page.metadata() {
meta name=(name) content=(content) {}
}
meta http-equiv="X-UA-Compatible" content="IE=edge";
@for (property, content) in page.properties() {
meta property=(property) content=(content) {}
}
(page.context().render_assets())
}
}
/// Permite al tema intervenir en el ciclo de renderizado de un componente.
///
/// Este método tiene especial utilidad en los **temas hijo** porque permite sobrescribir el
/// renderizado que el propio componente o el tema padre ofrece para un componente concreto, sin
/// modificar el resto del comportamiento heredado.
///
/// Recibe una referencia mutable al componente (como objeto dinámico [`Component`]) y el
/// contexto de renderizado. Devuelve:
///
/// - `None` si este tema no sobrescribe el renderizado. Es la implementación por defecto. El
/// sistema continúa con el siguiente tema de la cadena y, si ninguno lo sobrescribe, usa
/// [`Component::prepare()`](crate::core::component::Component::prepare).
/// El tema puede mutar el componente antes de devolver `None`, dejando que otro nivel de la
/// cadena se encargue del renderizado.
/// - `Some(Ok(markup))` con el HTML generado por el tema para el componente.
/// - `Some(Err(e))` si el tema intentó renderizarlo pero falló.
///
/// Para renderizar usa [`render_component!`](crate::render_component), que devuelve `None` si
/// ningún tipo coincide. Para mutar sin renderizar usa
/// [`setup_component!`](crate::setup_component) y devuelve `None` explícitamente:
///
/// ```rust,ignore
/// fn handle_component(
/// &self,
/// component: &mut dyn Component,
/// cx: &mut Context,
/// ) -> Option<Result<Markup, ComponentError>> {
/// // Sólo mutación: ajusta el componente y deja que otro nivel lo renderice.
/// setup_component!(component, {
/// Button => |btn| { btn.add_class("btn-primary"); },
/// });
/// // O renderizado completo:
/// render_component!(component, {
/// Button => |btn| Ok(html! { button.btn.btn-primary { (btn.label()) } }),
/// Heading => |h| Ok(html! { h2.display-4 { (h.text()) } }),
/// })
/// }
/// ```
#[allow(unused_variables)]
async fn handle_component(
&self,
component: &mut dyn Component,
cx: &mut Context,
) -> Option<Result<Markup, ComponentError>> {
None
}
/// Contenido predefinido para la página de error "*403 - Forbidden*" (acceso denegado).
///
/// Normalmente se renderiza con la plantilla ya activa en la página (por ejemplo
/// [`CoreTemplates::Standard`](crate::core::theme::CoreTemplates::Standard)), para que el
/// usuario no pierda el contexto de navegación del sitio. Los temas pueden sobrescribir este
/// método para personalizar completamente el diseño y el contenido de la página de error.
fn error_403(&self, page: &mut Page) {
if let Some(parent) = self.parent() {
return parent.error_403(page);
}
page.alter_title(Lc::l("error403_title")).alter_child_in(
&CoreRegions::Content,
ChildOp::Prepend(
Html::with(move |cx| {
html! {
div {
h1 { (Lc::l("error403_alert").using(cx)) }
p { (Lc::l("error403_help").using(cx)) }
}
}
})
.into(),
),
);
}
/// Contenido predefinido para la página de error "*404 - Not Found*" (recurso no encontrado).
///
/// Normalmente se renderiza con la plantilla ya activa en la página (por ejemplo
/// [`CoreTemplates::Standard`](crate::core::theme::CoreTemplates::Standard)). Los temas pueden
/// sobrescribir este método para personalizar completamente el diseño y el contenido de la
/// página de error.
fn error_404(&self, page: &mut Page) {
if let Some(parent) = self.parent() {
return parent.error_404(page);
}
page.alter_title(Lc::l("error404_title")).alter_child_in(
&CoreRegions::Content,
ChildOp::Prepend(
Html::with(move |cx| {
html! {
div {
h1 { (Lc::l("error404_alert").using(cx)) }
p { (Lc::l("error404_help").using(cx)) }
}
}
})
.into(),
),
);
}
/// Permite al tema preparar y componer una página de **error fatal controlado**.
///
/// Devuelve explícitamente [`ErrorPage::BadRequest`], [`ErrorPage::InternalError`],
/// [`ErrorPage::ServiceUnavailable`] o [`ErrorPage::GatewayTimeout`], porque algo ha fallado,
/// pero el servidor sigue activo y el tema, el renderizado y el resto de componentes funcionan
/// con normalidad.
///
/// Por defecto, asigna el título al documento (`title`), se renderiza con la plantilla ya
/// activa en la página (normalmente [`CoreTemplates::Standard`]) y muestra un componente
/// [`Intro`] con el código HTTP del error (`code`) y los mensajes proporcionados (`alert` y
/// `help`) como descripción del error.
///
/// Este método no se utiliza en las implementaciones predefinidas de [`Self::error_403()`] ni
/// [`Self::error_404()`], que definen su propio contenido específico.
///
/// Tampoco cubre los **fallos catastróficos** (un `panic!` en un handler, componente o
/// plantilla). Ese caso se intercepta en una última capa que responde con un HTML mínimo y
/// autónomo, sin pasar por el tema ni por el ciclo de renderizado de componentes, porque no es
/// seguro asumir que ese ciclo sigue funcionando tras un `panic!`.
///
/// Los temas pueden sobrescribir este método para personalizar el diseño y el contenido de la
/// página de error.
///
/// [`ErrorPage::BadRequest`]: crate::response::ErrorPage::BadRequest
/// [`ErrorPage::InternalError`]: crate::response::ErrorPage::InternalError
/// [`ErrorPage::ServiceUnavailable`]: crate::response::ErrorPage::ServiceUnavailable
/// [`ErrorPage::GatewayTimeout`]: crate::response::ErrorPage::GatewayTimeout
/// [`CoreTemplates::Standard`]: crate::core::theme::CoreTemplates::Standard
fn error_fatal(&self, page: &mut Page, code: StatusCode, title: Lc, alert: Lc, help: Lc) {
if let Some(parent) = self.parent() {
return parent.error_fatal(page, code, title, alert, help);
}
page.alter_title(title).alter_child_in(
&CoreRegions::Content,
ChildOp::Prepend(
Intro::new()
.with_title(Lc::l("error_code").with_arg("code", code.to_string()))
.with_slogan(Lc::n(code.to_string()))
.with_button(None)
.with_opening(IntroOpening::Custom)
.with_child(Html::with(move |cx| {
html! {
h1 { (alert.using(cx)) }
p { (help.using(cx)) }
}
}))
.into(),
),
);
}
}
/// Referencia estática a un tema.
pub type ThemeRef = &'static dyn Theme;
// **< render_component! >**************************************************************************
/// Sobrescribe el renderizado de componentes en [`Theme::handle_component()`].
///
/// Evalúa `$component` contra cada tipo de componente listado en orden. En cuanto encuentra
/// coincidencia, devuelve `Some(Ok(markup))` o `Some(Err(e))` según el resultado de la expresión
/// asociada. Si ningún tipo coincide, devuelve `None` para que el sistema continúe con la cadena de
/// herencia o con el renderizado por defecto del propio componente.
///
/// # Ejemplo
///
/// ```rust,ignore
/// fn handle_component(
/// &self,
/// component: &dyn Component,
/// cx: &mut Context,
/// ) -> Option<Result<Markup, ComponentError>> {
/// render_component!(component, {
/// Button => |btn| { Ok(html! { button.btn.btn-primary { (btn.label()) } }) },
/// Heading => |h| self.render_heading(h, cx),
/// })
/// }
///
/// fn render_heading(&self, h: &Heading, cx: &mut Context) -> Result<Markup, ComponentError> {
/// Ok(html! { h2.display-4 { (h.text()) } })
/// }
/// ```
#[macro_export]
macro_rules! render_component {
($component:expr, { $($type:ty => |$var:ident| $body:expr),* $(,)? }) => {
'render_component: {
// Reborrow explícito como referencia compartida para que `downcast_ref` funcione
// correctamente con `&mut dyn Component` (limitación del compilador con trait objects).
let __c = &*($component);
$(
if let Some($var) = __c.downcast_ref::<$type>() {
break 'render_component Some($body);
}
)*
None
}
};
}
// **< setup_component! >***************************************************************************
/// Muta un componente dentro de [`Theme::handle_component()`].
///
/// Evalúa `$component` contra cada tipo de componente listado en orden. En cuanto encuentra
/// coincidencia, ejecuta el bloque asociado y detiene la evaluación. Si ningún tipo coincide, no
/// hace nada.
///
/// Usa acceso mutable al componente mediante [`downcast_mut`](crate::core::AnyCast::downcast_mut),
/// lo que permite modificar su estado. El tema puede devolver `None` tras la mutación para que otro
/// nivel de la cadena se encargue del renderizado.
///
/// # Ejemplos
///
/// Solo mutación: el tema ajusta el componente y delega el renderizado al siguiente nivel:
///
/// ```rust,ignore
/// fn handle_component(
/// &self,
/// component: &mut dyn Component,
/// cx: &mut Context,
/// ) -> Option<Result<Markup, ComponentError>> {
/// setup_component!(component, { Button => |btn| { btn.add_class("btn-primary"); } });
/// None
/// }
/// ```
///
/// Mutación y renderizado combinados: el `Button` se muta y se renderiza aquí; el `Heading` se
/// muta pero continúa la cadena para que otro nivel lo renderice:
///
/// ```rust,ignore
/// fn handle_component(
/// &self,
/// component: &mut dyn Component,
/// cx: &mut Context,
/// ) -> Option<Result<Markup, ComponentError>> {
/// setup_component!(component, {
/// Button => |btn| { btn.add_class("btn-primary"); },
/// Heading => |h| { h.add_class("display-4"); },
/// });
/// render_component!(component, {
/// Button => |btn| Ok(html! { button.btn { (btn.label()) } }),
/// })
/// }
/// ```
#[macro_export]
macro_rules! setup_component {
($component:expr, { $($type:ty => |$var:ident| $body:expr),* $(,)? }) => {
'setup_component: {
$(
if let Some($var) = ($component).downcast_mut::<$type>() {
$body;
break 'setup_component;
}
)*
}
};
}