diff --git a/examples/intro-flex.rs b/examples/intro-flex.rs index a5e54e30..c51e5bb3 100644 --- a/examples/intro-flex.rs +++ b/examples/intro-flex.rs @@ -17,7 +17,8 @@ impl Extension for IntroFlex { async fn intro_flex(request: HttpRequest) -> Result { Page::new(request) - .with_assets(AssetsOp::AddStyleSheet(demo_styles())) + .with_assets(demo_box_styles()) + .with_assets(demo_row_styles()) .with_child( Intro::default() .with_opening(IntroOpening::Custom) @@ -478,32 +479,37 @@ fn other_block() -> Block { // **< HELPERS >************************************************************************************ -// Aspecto fijo de las cajas y filas de muestra. -fn demo_styles() -> StyleSheet { - StyleSheet::inline("intro-flex", |_| { - util::indoc!( - r#" - .flex-demo-box { - background-color: #0d6efd; - color: #fff; - min-width: 3rem; - width: auto; - max-width: none; - margin: 0; - border-radius: 0.375rem; - text-align: center; - } - .flex-demo-row { - background-color: #f1f3f5; - width: 100%; - max-width: none; - margin: 0 0 1.5rem; - padding: 0.75rem; - } - "# - ) - .to_string() - }) +// Aspecto fijo de las cajas de muestra. +fn demo_box_styles() -> AssetsOp { + AssetsOp::add_responsive_styles( + None, + "flex-demo-box", + [ + ("background-color", "#0d6efd"), + ("color", "#fff"), + ("min-width", "3rem"), + ("width", "auto"), + ("max-width", "none"), + ("margin", "0"), + ("border-radius", "0.375rem"), + ("text-align", "center"), + ], + ) +} + +// Aspecto fijo de las filas de muestra. +fn demo_row_styles() -> AssetsOp { + AssetsOp::add_responsive_styles( + None, + "flex-demo-row", + [ + ("background-color", "#f1f3f5"), + ("width", "100%"), + ("max-width", "none"), + ("margin", "0 0 1.5rem"), + ("padding", "0.75rem"), + ], + ) } // Caja con fondo azul y relleno vertical configurable, para mostrar diferencias de altura. diff --git a/examples/intro-responsive.rs b/examples/intro-responsive.rs index f01a7ebb..782ecc1f 100644 --- a/examples/intro-responsive.rs +++ b/examples/intro-responsive.rs @@ -17,7 +17,9 @@ impl Extension for IntroResponsive { async fn intro_responsive(request: HttpRequest) -> Result { Page::new(request) - .with_assets(AssetsOp::AddStyleSheet(demo_styles())) + .with_assets(demo_box_styles()) + .with_assets(demo_row_styles()) + .with_assets(demo_code_styles()) .with_child( Intro::default() .with_opening(IntroOpening::Custom) @@ -189,35 +191,42 @@ fn gap_grow_block() -> Block { // **< HELPERS >************************************************************************************ -// Aspecto fijo de las cajas y filas de muestra. -fn demo_styles() -> StyleSheet { - StyleSheet::inline("intro-responsive", |_| { - util::indoc!( - r#" - .flex-demo-box { - background-color: #0d6efd; - color: #fff; - min-width: 3rem; - width: auto; - max-width: none; - margin: 0; - border-radius: 0.375rem; - text-align: center; - } - .flex-demo-row { - background-color: #f1f3f5; - width: 100%; - max-width: none; - margin: 0 0 1.5rem; - padding: 0.75rem; - } - code { - overflow-wrap: anywhere; - } - "# - ) - .to_string() - }) +// Aspecto fijo de las cajas de muestra. +fn demo_box_styles() -> AssetsOp { + AssetsOp::add_responsive_styles( + None, + "flex-demo-box", + [ + ("background-color", "#0d6efd"), + ("color", "#fff"), + ("min-width", "3rem"), + ("width", "auto"), + ("max-width", "none"), + ("margin", "0"), + ("border-radius", "0.375rem"), + ("text-align", "center"), + ], + ) +} + +// Aspecto fijo de las filas de muestra. +fn demo_row_styles() -> AssetsOp { + AssetsOp::add_responsive_styles( + None, + "flex-demo-row", + [ + ("background-color", "#f1f3f5"), + ("width", "100%"), + ("max-width", "none"), + ("margin", "0 0 1.5rem"), + ("padding", "0.75rem"), + ], + ) +} + +// Evita que los fragmentos de código largos desborden su contenedor. +fn demo_code_styles() -> AssetsOp { + AssetsOp::add_responsive_styles(None, "flex-demo-code", [("overflow-wrap", "anywhere")]) } // Caja con fondo azul y relleno vertical configurable, para mostrar diferencias de altura. @@ -250,7 +259,7 @@ fn caption(title: Lc, code: Lc) -> Html { Html::with(move |cx| { html! { h3 { (title.using(cx)) } - p { code { (code.using(cx)) } } + p { code class="flex-demo-code" { (code.using(cx)) } } } }) } diff --git a/extensions/pagetop-aliner/src/lib.rs b/extensions/pagetop-aliner/src/lib.rs index 29f43244..fe7e1b19 100644 --- a/extensions/pagetop-aliner/src/lib.rs +++ b/extensions/pagetop-aliner/src/lib.rs @@ -113,21 +113,21 @@ impl Extension for Aliner { #[async_trait] impl Theme for Aliner { fn before_render_page_body(&self, page: &mut Page) { - page.alter_assets(AssetsOp::AddStyleSheet( + page.alter_assets( StyleSheet::from("/pagetop/css/normalize.css") .with_version("8.0.1") .with_weight(-99), - )) - .alter_assets(AssetsOp::AddStyleSheet( + ) + .alter_assets( StyleSheet::from("/pagetop/css/basic.css") .with_version(PAGETOP_VERSION) .with_weight(-99), - )) - .alter_assets(AssetsOp::AddStyleSheet( + ) + .alter_assets( StyleSheet::from("/aliner/css/styles.css") .with_version(env!("CARGO_PKG_VERSION")) .with_weight(-99), - )) + ) .alter_child_in( &CoreRegions::Footer, ChildOp::AddIfEmpty(PoweredBy::new().into()), diff --git a/extensions/pagetop-bootsier/src/lib.rs b/extensions/pagetop-bootsier/src/lib.rs index 8d38ae47..7991078d 100644 --- a/extensions/pagetop-bootsier/src/lib.rs +++ b/extensions/pagetop-bootsier/src/lib.rs @@ -178,42 +178,40 @@ impl Theme for Bootsier { // Las URLs de las fuentes deben coincidir exactamente con las declaradas en @font-face de // _bootsier-custom.scss; cualquier discrepancia hace que el navegador descargue dos veces. - page.alter_assets(AssetsOp::AddPreload( - Preload::font("/bootsier/fonts/bootsier.font.woff2").with_weight(-99), - )) - .alter_assets(AssetsOp::AddPreload( - Preload::font("/bootsier/fonts/bootsier.font.italic.woff2").with_weight(-99), - )) - .alter_assets(AssetsOp::AddStyleSheet( - StyleSheet::from("/bootsier/css/bootsier.min.css") - .with_version(ADMINLTE_VERSION) - .with_weight(-99), - )) - .alter_assets(AssetsOp::AddJavaScript( - JavaScript::defer("/bootsier/js/bootsier.bundle.min.js") - .with_version(BOOTSTRAP_VERSION) - .with_weight(-99), - )) - .alter_assets(AssetsOp::AddJavaScript( - JavaScript::defer("/bootsier/js/bootsier.extended.min.js") - .with_version(ADMINLTE_VERSION) - .with_weight(-99), - )) - .alter_assets(AssetsOp::AddJavaScript( - JavaScript::defer("/bootsier/js/bootsier.dialog.min.js") - .with_version(BOOTSTRAP_VERSION) - .with_weight(-99), - )) - .alter_assets(AssetsOp::AddJavaScript( - JavaScript::defer("/bootsier/js/bootsier.confirm.min.js") - .with_version(BOOTSTRAP_VERSION) - .with_weight(-99), - )) - .alter_body_props(PropsOp::set("data-confirm-ok", confirm_ok)) - .alter_body_props(PropsOp::set("data-confirm-cancel", confirm_cancel)) - .alter_child_in( - &CoreRegions::Footer, - ChildOp::AddIfEmpty(PoweredBy::new().into()), - ); + page.alter_assets(Preload::font("/bootsier/fonts/bootsier.font.woff2").with_weight(-99)) + .alter_assets( + Preload::font("/bootsier/fonts/bootsier.font.italic.woff2").with_weight(-99), + ) + .alter_assets( + StyleSheet::from("/bootsier/css/bootsier.min.css") + .with_version(ADMINLTE_VERSION) + .with_weight(-99), + ) + .alter_assets( + JavaScript::defer("/bootsier/js/bootsier.bundle.min.js") + .with_version(BOOTSTRAP_VERSION) + .with_weight(-99), + ) + .alter_assets( + JavaScript::defer("/bootsier/js/bootsier.extended.min.js") + .with_version(ADMINLTE_VERSION) + .with_weight(-99), + ) + .alter_assets( + JavaScript::defer("/bootsier/js/bootsier.dialog.min.js") + .with_version(BOOTSTRAP_VERSION) + .with_weight(-99), + ) + .alter_assets( + JavaScript::defer("/bootsier/js/bootsier.confirm.min.js") + .with_version(BOOTSTRAP_VERSION) + .with_weight(-99), + ) + .alter_body_props(PropsOp::set("data-confirm-ok", confirm_ok)) + .alter_body_props(PropsOp::set("data-confirm-cancel", confirm_cancel)) + .alter_child_in( + &CoreRegions::Footer, + ChildOp::AddIfEmpty(PoweredBy::new().into()), + ); } } diff --git a/extensions/pagetop-bootsier/src/theme/bs/layout/template.rs b/extensions/pagetop-bootsier/src/theme/bs/layout/template.rs index b22d56e5..99a33065 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/layout/template.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/layout/template.rs @@ -41,11 +41,11 @@ async fn render_admin(cx: &mut Context) -> Markup { cx.alter_body_props(PropsOp::add_classes( "layout-fixed sidebar-expand-lg bg-body-tertiary", )); - cx.alter_assets(AssetsOp::AddJavaScript( + cx.alter_assets( JavaScript::defer("/bootsier/js/bootsier.shell.min.js") .with_version(ADMINLTE_VERSION) .with_weight(-88), - )); + ); // `CoreRegions::Aside` es una región neutra del core: la usa `pagetop-admin` para su menú de // secciones sin que este tema tenga que depender de él. `BootsierRegions::Sidebar` sigue // disponible para que cualquier extensión añada elementos propios a mano. diff --git a/extensions/pagetop-htmx/src/lib.rs b/extensions/pagetop-htmx/src/lib.rs index cdcfc83b..9404b49b 100644 --- a/extensions/pagetop-htmx/src/lib.rs +++ b/extensions/pagetop-htmx/src/lib.rs @@ -154,7 +154,5 @@ impl Extension for Htmx { } fn add_htmx_script(page: &mut Page) { - page.alter_assets(AssetsOp::AddJavaScript( - JavaScript::defer("/htmx/js/htmx.min.js").with_version("2.0.10"), - )); + page.alter_assets(JavaScript::defer("/htmx/js/htmx.min.js").with_version("2.0.10")); } diff --git a/src/base/component/intro.rs b/src/base/component/intro.rs index 9aaa3995..00fa866b 100644 --- a/src/base/component/intro.rs +++ b/src/base/component/intro.rs @@ -114,11 +114,9 @@ impl Component for Intro { } async fn prepare(&self, cx: &mut Context) -> Result { - cx.alter_assets(AssetsOp::AddStyleSheet( - StyleSheet::from("/pagetop/css/intro.css").with_version(PAGETOP_VERSION), - )); + cx.alter_assets(StyleSheet::from("/pagetop/css/intro.css").with_version(PAGETOP_VERSION)); if *self.opening() == IntroOpening::PageTop { - cx.alter_assets(AssetsOp::AddJavaScript(JavaScript::on_load_async("intro-js", |cx| + cx.alter_assets(JavaScript::on_load_async("intro-js", |cx| util::indoc!(r#" try { const resp = await fetch("https://crates.io/api/v1/crates/pagetop"); @@ -134,7 +132,7 @@ impl Component for Intro { "#) .replace("LANGID", cx.langid().to_string().as_str()) .replace("LABEL", Lc::l("intro_release_label").using(cx).as_str()) - ))); + )); } Ok(html! { diff --git a/src/base/theme/basic.rs b/src/base/theme/basic.rs index 8fedbf44..7b1aa66d 100644 --- a/src/base/theme/basic.rs +++ b/src/base/theme/basic.rs @@ -14,28 +14,26 @@ impl Extension for Basic { #[async_trait] impl Theme for Basic { fn before_render_page_body(&self, page: &mut Page) { - page.alter_assets(AssetsOp::AddStyleSheet( + page.alter_assets( StyleSheet::from("/pagetop/css/normalize.css") .with_version("8.0.1") .with_weight(-99), - )) - .alter_assets(AssetsOp::AddStyleSheet( + ) + .alter_assets( StyleSheet::from("/pagetop/css/basic.min.css") .with_version(PAGETOP_VERSION) .with_weight(-99), - )) - .alter_assets(AssetsOp::AddJavaScript( - JavaScript::defer("/pagetop/js/basic.menu.min.js").with_version("4.4.0"), - )) - .alter_assets(AssetsOp::AddJavaScript( + ) + .alter_assets(JavaScript::defer("/pagetop/js/basic.menu.min.js").with_version("4.4.0")) + .alter_assets( JavaScript::defer("/pagetop/js/basic.dropdown.min.js").with_version(PAGETOP_VERSION), - )) - .alter_assets(AssetsOp::AddJavaScript( + ) + .alter_assets( JavaScript::defer("/pagetop/js/basic.navbar.init.js").with_version(PAGETOP_VERSION), - )) - .alter_assets(AssetsOp::AddJavaScript( + ) + .alter_assets( JavaScript::defer("/pagetop/js/basic.dialog.min.js").with_version(PAGETOP_VERSION), - )) + ) .alter_child_in( &CoreRegions::Footer, ChildOp::AddIfEmpty(PoweredBy::new().into()), diff --git a/src/core/component/context.rs b/src/core/component/context.rs index a490cd26..66ce4e08 100644 --- a/src/core/component/context.rs +++ b/src/core/component/context.rs @@ -2,246 +2,28 @@ use crate::auth::CurrentUser; use crate::core::TypeInfo; use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage}; use crate::core::theme::all::DEFAULT_THEME; -use crate::core::theme::{Breakpoint, ChildrenInRegions, CoreRegions, CoreTemplates}; +use crate::core::theme::{ChildrenInRegions, CoreRegions, CoreTemplates}; use crate::core::theme::{RegionRef, TemplateRef, ThemeRef}; use crate::html::{Assets, Favicon, JavaScript, Preload, ResponsiveStyles, StyleSheet}; use crate::html::{Markup, Props, PropsOp, RoutePath, html}; use crate::locale::Lc; use crate::locale::{LangId, LanguageIdentifier, RequestLocale}; use crate::web::HttpRequest; -use crate::{CowStr, builder_impl, util}; +use crate::{builder_impl, util}; use parking_lot::Mutex; -use thiserror::Error; use std::any::{Any, TypeId}; use std::collections::HashMap; -/// Operaciones para modificar recursos asociados al [`Context`] de un documento. -pub enum AssetsOp { - /// Define el *favicon* del documento. Sobrescribe cualquier valor anterior. - SetFavicon(Option), - /// Define el *favicon* solo si no se ha establecido previamente. - SetFaviconIfNone(Favicon), +mod assets_op; +pub use assets_op::AssetsOp; - /// Añade un recurso para precarga al documento. - AddPreload(Preload), - /// Elimina un recurso para precarga por su ruta. - RemovePreload(&'static str), +mod error; +pub use error::ContextError; - /// Añade una hoja de estilos CSS al documento. - AddStyleSheet(StyleSheet), - /// Elimina una hoja de estilos por su ruta o identificador. - RemoveStyleSheet(&'static str), - - /// Añade un script JavaScript al documento. - AddJavaScript(JavaScript), - /// Elimina un script por su ruta o identificador. - RemoveJavaScript(&'static str), - - /// Añade una declaración de estilo responsive (`property: value`) para las clases indicadas, - /// dentro del punto de corte dado (`None` para una regla siempre activa). Ver - /// [`ResponsiveStyles::add_style()`]. - AddResponsiveStyle(Option, CowStr, CowStr, CowStr), -} - -/// Errores de acceso a parámetros dinámicos del contexto. -#[derive(Debug, Error)] -pub enum ContextError { - /// La clave no existe. - #[error("parameter not found")] - ParamNotFound, - /// La clave existe, pero el valor guardado no coincide con el tipo solicitado. Incluye - /// nombre de la clave (`key`), tipo esperado (`expected`) y tipo realmente guardado (`saved`) - /// para facilitar el diagnóstico. - #[error("type mismatch for parameter \"{key}\": expected \"{expected}\", found \"{saved}\"")] - ParamTypeMismatch { - key: &'static str, - expected: &'static str, - saved: &'static str, - }, -} - -/// 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: -/// -/// - Almacenar la **petición HTTP** de origen. -/// - Seleccionar la **plantilla** y el **tema** de renderizado. -/// - Administrar **recursos** del documento como el icono [`Favicon`], las hojas de estilo -/// [`StyleSheet`] o los scripts [`JavaScript`] mediante [`AssetsOp`]. -/// - Leer y mantener **parámetros dinámicos tipados** de contexto. -/// -/// Lo implementan, típicamente, estructuras que manejan el contexto de renderizado, como -/// [`Context`](crate::core::component::Context) o [`Page`](crate::response::Page). -/// -/// # Ejemplo -/// -/// ```rust,no_run -/// # use pagetop::prelude::*; -/// # use pagetop_aliner::Aliner; -/// fn prepare_context(cx: C) -> C { -/// cx.with_langid(&Locale::resolve("es-ES")) -/// .with_template(&CoreTemplates::Standard) -/// .with_theme(&Aliner) -/// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) -/// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/app.css"))) -/// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/app.js"))) -/// .with_param("user_id", 42_i32) -/// } -/// ``` -#[builder_impl] -pub trait Contextual: LangId { - // **< Contextual BUILDER >********************************************************************* - - /// Establece el idioma del documento. - fn with_langid(self, language: &impl LangId) -> Self; - - /// Almacena la petición HTTP de origen en el contexto. - /// - /// También recalcula el idioma ([`RequestLocale::from_request()`]) y - /// [`current_user()`](Self::current_user) a partir de la petición indicada, descartando - /// cualquier idioma forzado antes con [`with_langid()`](Self::with_langid) o el usuario ya - /// resuelto. Si necesitas forzar el idioma o el usuario, llama a `with_request()` primero en - /// la cadena de construcción, nunca después. - fn with_request(self, request: Option) -> Self; - - /// Especifica la plantilla para renderizar el documento. - fn with_template(self, template: TemplateRef) -> Self; - - /// Especifica el tema para renderizar el documento. - fn with_theme(self, theme: ThemeRef) -> Self; - - /// Añade o modifica un parámetro dinámico del contexto. - /// - /// El valor se almacena junto con el nombre de su tipo, lo que permite generar mensajes de - /// error precisos al recuperarlo con [`param`](Contextual::param) si el tipo solicitado no - /// coincide. - /// - /// # Ejemplo - /// - /// ```rust,no_run - /// # use pagetop::prelude::*; - /// let cx = Context::default() - /// .with_param("user_id", 42_i32) - /// .with_param("title", "Hello".to_string()) - /// .with_param("flags", vec!["a", "b"]); - /// ``` - fn with_param(self, key: &'static str, value: T) -> Self; - - /// Define los recursos del contexto usando [`AssetsOp`]. - fn with_assets(self, op: AssetsOp) -> Self; - - /// Modifica identificador, clases CSS, atributos HTML o valores extra del elemento ``. - fn with_body_props(self, op: PropsOp) -> Self; - - /// Añade un componente o aplica una operación [`ChildOp`] en la región por defecto del - /// documento. - fn with_child(self, op: impl Into) -> Self; - - /// Añade un componente o aplica una operación [`ChildOp`] en una región específica del - /// documento. - fn with_child_in(self, region: RegionRef, op: impl Into) -> Self; - - // **< Contextual GETTERS >********************************************************************* - - /// Devuelve una referencia a la petición HTTP asociada, si existe. - fn request(&self) -> Option<&HttpRequest>; - - /// Devuelve la identidad del usuario actual. - /// - /// Si ninguna extensión de autenticación ha inyectado un - /// [`CurrentUser`](crate::auth::CurrentUser) en las extensiones de la petición HTTP, devuelve - /// `&CurrentUser::Anonymous`. - /// - /// # Ejemplo - /// - /// ```rust,no_run - /// # use pagetop::prelude::*; - /// async fn greet(request: HttpRequest) -> Result { - /// let mut page = Page::new(request); - /// if page.current_user().is_authenticated() { - /// // Personalizar la página para el usuario autenticado. - /// } - /// page.render().await - /// } - /// ``` - fn current_user(&self) -> &CurrentUser; - - /// Devuelve la plantilla configurada para renderizar el documento. - fn template(&self) -> TemplateRef; - - /// Devuelve el tema que se usará para renderizar el documento. - fn theme(&self) -> ThemeRef; - - /// Recupera una *referencia tipada* al parámetro solicitado. - /// - /// Devuelve: - /// - /// - `Ok(&T)` si la clave existe y el tipo coincide. - /// - `Err(ContextError::ParamNotFound)` si la clave no existe. - /// - `Err(ContextError::ParamTypeMismatch)` si la clave existe pero el tipo no coincide. - /// - /// # Ejemplo - /// - /// ```rust - /// # use pagetop::prelude::*; - /// let cx = Context::default() - /// .with_param("user_id", 42_i32) - /// .with_param("title", "Hello".to_string()); - /// - /// let id: i32 = *cx.param("user_id").unwrap(); - /// let title: &String = cx.param("title").unwrap(); - /// - /// // Error de tipo: - /// assert!(cx.param::("user_id").is_err()); - /// ``` - fn param(&self, key: &'static str) -> Result<&T, ContextError>; - - /// Devuelve el parámetro clonado o el **valor por defecto del tipo** (`T::default()`). - fn param_or_default(&self, key: &'static str) -> T { - self.param::(key).ok().cloned().unwrap_or_default() - } - - /// Devuelve el parámetro clonado o un **valor por defecto** si no existe. - fn param_or(&self, key: &'static str, default: T) -> T { - self.param::(key).ok().cloned().unwrap_or(default) - } - - /// Devuelve el parámetro clonado o el **valor evaluado** por la función `f` si no existe. - fn param_or_else T>(&self, key: &'static str, f: F) -> T { - self.param::(key).ok().cloned().unwrap_or_else(f) - } - - /// Devuelve el Favicon de los recursos del contexto. - fn favicon(&self) -> Option<&Favicon>; - - /// Devuelve las hojas de estilo de los recursos del contexto. - fn stylesheets(&self) -> &Assets; - - /// Devuelve los scripts JavaScript de los recursos del contexto. - fn javascripts(&self) -> &Assets; - - /// Devuelve los estilos *responsive* acumulados en el contexto. - fn responsive_styles(&self) -> &ResponsiveStyles; - - /// Devuelve identificador, clases CSS, atributos HTML y valores extra del elemento ``. - fn body_props(&self) -> &Props; - - // **< Contextual HELPERS >********************************************************************* - - /// Elimina un parámetro del contexto. Devuelve `true` si la clave existía y se eliminó. - /// - /// # Ejemplo - /// - /// ```rust - /// # use pagetop::prelude::*; - /// let mut cx = Context::default().with_param("temp", 1u8); - /// assert!(cx.remove_param("temp")); - /// assert!(!cx.remove_param("temp")); // ya no existe - /// ``` - fn remove_param(&mut self, key: &'static str) -> bool; -} +mod contextual; +pub use contextual::Contextual; /// Implementa un **contexto de renderizado** para un documento HTML. /// @@ -280,11 +62,11 @@ pub trait Contextual: LangId { /// // Establece el tema para renderizar. /// .with_theme(&Aliner) /// // Asigna un favicon. -/// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) +/// .with_assets(Favicon::new().with_icon("/favicon.ico")) /// // Añade una hoja de estilo externa. -/// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/style.css"))) +/// .with_assets(StyleSheet::from("/css/style.css")) /// // Añade un script JavaScript. -/// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/main.js"))) +/// .with_assets(JavaScript::defer("/js/main.js")) /// // Añade un parámetro dinámico al contexto. /// .with_param("user_id", 42); /// # cx } @@ -586,8 +368,8 @@ impl Contextual for Context { self } - fn with_assets(mut self, op: AssetsOp) -> Self { - match op { + fn with_assets(mut self, op: impl Into) -> Self { + match op.into() { // Favicon. AssetsOp::SetFavicon(favicon) => { self.favicon = favicon; @@ -623,6 +405,9 @@ impl Contextual for Context { self.responsives .add_style(breakpoint, classes, property, value); } + AssetsOp::AddResponsiveStyles(breakpoint, classes, styles) => { + self.responsives.add_styles(breakpoint, classes, styles); + } } self } diff --git a/src/core/component/context/assets_op.rs b/src/core/component/context/assets_op.rs new file mode 100644 index 00000000..b0a59bdf --- /dev/null +++ b/src/core/component/context/assets_op.rs @@ -0,0 +1,169 @@ +use crate::CowStr; +use crate::core::theme::Breakpoint; +use crate::html::{Favicon, JavaScript, Preload, StyleSheet}; + +/// Operaciones para modificar recursos asociados al [`Context`](super::Context) de un documento. +/// +/// [`Favicon`], [`Preload`], [`StyleSheet`] y [`JavaScript`] se convierten implícitamente en la +/// operación de añadir correspondiente (ver sus `impl From<...>` más abajo), por lo que no +/// necesitarían ningún constructor. Para el resto de operaciones, el método recomendado es recurrir +/// a los constructores asociados como [`remove_stylesheet()`], [`add_responsive_style()`], etc. +/// +/// [`remove_stylesheet()`]: Self::remove_stylesheet +/// [`add_responsive_style()`]: Self::add_responsive_style +pub enum AssetsOp { + /// Define el *favicon* del documento. Sobrescribe cualquier valor anterior. + SetFavicon(Option), + /// Define el *favicon* sólo si no se ha establecido previamente. + SetFaviconIfNone(Favicon), + + /// Añade un recurso para precarga al documento. + AddPreload(Preload), + /// Elimina un recurso para precarga por su ruta. + RemovePreload(&'static str), + + /// Añade una hoja de estilos CSS al documento. + AddStyleSheet(StyleSheet), + /// Elimina una hoja de estilos por su ruta. + RemoveStyleSheet(&'static str), + + /// Añade un script JavaScript al documento. + AddJavaScript(JavaScript), + /// Elimina un script por su ruta o identificador. + RemoveJavaScript(&'static str), + + /// Añade una declaración de estilo *responsive* (`property: value`) para las clases indicadas, + /// para un punto de corte dado (`None` para una regla siempre activa). Ver + /// [`ResponsiveStyles::add_style()`](crate::html::ResponsiveStyles::add_style). + AddResponsiveStyle(Option, CowStr, CowStr, CowStr), + /// Añade varias declaraciones de estilo *responsive* (`property: value`) para las clases + /// indicadas, para un punto de corte dado, en una única llamada. Ver + /// [`ResponsiveStyles::add_styles()`](crate::html::ResponsiveStyles::add_styles). + AddResponsiveStyles(Option, CowStr, Vec<(CowStr, CowStr)>), +} + +impl AssetsOp { + /// Crea la variante [`SetFavicon`](Self::SetFavicon) con el favicon indicado, o `None` para + /// eliminar cualquier favicon ya establecido. + pub fn set_favicon(favicon: impl Into>) -> Self { + Self::SetFavicon(favicon.into()) + } + + /// Crea la variante [`SetFaviconIfNone`](Self::SetFaviconIfNone) con el favicon indicado. + pub fn set_favicon_if_none(favicon: Favicon) -> Self { + Self::SetFaviconIfNone(favicon) + } + + /// Crea la variante [`AddPreload`](Self::AddPreload) con el recurso indicado. + pub fn add_preload(preload: Preload) -> Self { + Self::AddPreload(preload) + } + + /// Crea la variante [`RemovePreload`](Self::RemovePreload) para la ruta indicada. + pub fn remove_preload(path: &'static str) -> Self { + Self::RemovePreload(path) + } + + /// Crea la variante [`AddStyleSheet`](Self::AddStyleSheet) con la hoja de estilos indicada. + pub fn add_stylesheet(stylesheet: StyleSheet) -> Self { + Self::AddStyleSheet(stylesheet) + } + + /// Crea la variante [`RemoveStyleSheet`](Self::RemoveStyleSheet) para la ruta indicada. + pub fn remove_stylesheet(path: &'static str) -> Self { + Self::RemoveStyleSheet(path) + } + + /// Crea la variante [`AddJavaScript`](Self::AddJavaScript) con el script indicado. + pub fn add_javascript(js: JavaScript) -> Self { + Self::AddJavaScript(js) + } + + /// Crea la variante [`RemoveJavaScript`](Self::RemoveJavaScript) para la ruta o identificador + /// indicado. + pub fn remove_javascript(path: &'static str) -> Self { + Self::RemoveJavaScript(path) + } + + /// Crea la variante [`AddResponsiveStyle`](Self::AddResponsiveStyle) con la declaración de + /// estilo (`property: value`) indicada, para las clases y el punto de corte dados. + pub fn add_responsive_style( + breakpoint: impl Into>, + classes: impl Into, + property: impl Into, + value: impl Into, + ) -> Self { + Self::AddResponsiveStyle( + breakpoint.into(), + classes.into(), + property.into(), + value.into(), + ) + } + + /// Crea la variante [`AddResponsiveStyles`](Self::AddResponsiveStyles) con las declaraciones + /// de estilo (`property: value`) indicadas, para las clases y el punto de corte dados. + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// let op = AssetsOp::add_responsive_styles( + /// None, + /// "flex-demo-box", + /// [("background-color", "#0d6efd"), ("color", "#fff")], + /// ); + /// ``` + pub fn add_responsive_styles( + breakpoint: impl Into>, + classes: impl Into, + styles: impl IntoIterator, impl Into)>, + ) -> Self { + Self::AddResponsiveStyles( + breakpoint.into(), + classes.into(), + styles + .into_iter() + .map(|(p, v)| (p.into(), v.into())) + .collect(), + ) + } +} + +impl From for AssetsOp { + /// Convierte un favicon en [`AssetsOp::SetFavicon`] (lo sobrescribe siempre), permitiendo + /// pasarlo directamente a métodos como [`Contextual::with_assets`] sin envolverlo + /// explícitamente. Para establecerlo sólo si no hay uno ya definido, usar + /// [`AssetsOp::set_favicon_if_none()`] explícitamente. + /// + /// [`Contextual::with_assets`]: crate::core::component::Contextual::with_assets + #[inline] + fn from(favicon: Favicon) -> Self { + Self::SetFavicon(Some(favicon)) + } +} + +impl From for AssetsOp { + /// Convierte un recurso de precarga en [`AssetsOp::AddPreload`]. Ver la conversión + /// equivalente para [`Favicon`]. + #[inline] + fn from(preload: Preload) -> Self { + Self::AddPreload(preload) + } +} + +impl From for AssetsOp { + /// Convierte una hoja de estilos en [`AssetsOp::AddStyleSheet`]. Ver la conversión + /// equivalente para [`Favicon`]. + #[inline] + fn from(stylesheet: StyleSheet) -> Self { + Self::AddStyleSheet(stylesheet) + } +} + +impl From for AssetsOp { + /// Convierte un script en [`AssetsOp::AddJavaScript`]. Ver la conversión equivalente para + /// [`Favicon`]. + #[inline] + fn from(js: JavaScript) -> Self { + Self::AddJavaScript(js) + } +} diff --git a/src/core/component/context/contextual.rs b/src/core/component/context/contextual.rs new file mode 100644 index 00000000..3d436a81 --- /dev/null +++ b/src/core/component/context/contextual.rs @@ -0,0 +1,195 @@ +use super::{AssetsOp, ContextError}; + +use crate::auth::CurrentUser; +use crate::builder_impl; +use crate::core::component::ChildOp; +use crate::core::theme::{RegionRef, TemplateRef, ThemeRef}; +use crate::html::{Assets, Favicon, JavaScript, Props, PropsOp, ResponsiveStyles, StyleSheet}; +use crate::locale::LangId; +use crate::web::HttpRequest; + +/// 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: +/// +/// - Almacenar la **petición HTTP** de origen. +/// - Seleccionar la **plantilla** y el **tema** de renderizado. +/// - Administrar **recursos** del documento como el icono [`Favicon`], las hojas de estilo +/// [`StyleSheet`] o los scripts [`JavaScript`], directamente o mediante una operación +/// [`AssetsOp`]. +/// - Leer y mantener **parámetros dinámicos tipados** de contexto. +/// +/// Lo implementan, típicamente, estructuras que manejan el contexto de renderizado, como +/// [`Context`](crate::core::component::Context) o [`Page`](crate::response::Page). +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// # use pagetop_aliner::Aliner; +/// fn prepare_context(cx: C) -> C { +/// cx.with_langid(&Locale::resolve("es-ES")) +/// .with_template(&CoreTemplates::Standard) +/// .with_theme(&Aliner) +/// .with_assets(Favicon::new().with_icon("/favicon.ico")) +/// .with_assets(StyleSheet::from("/css/app.css")) +/// .with_assets(JavaScript::defer("/js/app.js")) +/// .with_param("user_id", 42_i32) +/// } +/// ``` +#[builder_impl] +pub trait Contextual: LangId { + // **< Contextual BUILDER >********************************************************************* + + /// Establece el idioma del documento. + fn with_langid(self, language: &impl LangId) -> Self; + + /// Almacena la petición HTTP de origen en el contexto. + /// + /// También recalcula el idioma ([`RequestLocale::from_request()`]) y + /// [`current_user()`](Self::current_user) a partir de la petición indicada, descartando + /// cualquier idioma forzado antes con [`with_langid()`](Self::with_langid) o el usuario ya + /// resuelto. Si necesitas forzar el idioma o el usuario, llama a `with_request()` primero en + /// la cadena de construcción, nunca después. + /// + /// [`RequestLocale::from_request()`]: crate::locale::RequestLocale::from_request + fn with_request(self, request: Option) -> Self; + + /// Especifica la plantilla para renderizar el documento. + fn with_template(self, template: TemplateRef) -> Self; + + /// Especifica el tema para renderizar el documento. + fn with_theme(self, theme: ThemeRef) -> Self; + + /// Añade o modifica un parámetro dinámico del contexto. + /// + /// El valor se almacena junto con el nombre de su tipo, lo que permite generar mensajes de + /// error precisos al recuperarlo con [`param`](Contextual::param) si el tipo solicitado no + /// coincide. + /// + /// # Ejemplo + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// let cx = Context::default() + /// .with_param("user_id", 42_i32) + /// .with_param("title", "Hello".to_string()) + /// .with_param("flags", vec!["a", "b"]); + /// ``` + fn with_param(self, key: &'static str, value: T) -> Self; + + /// Añade un recurso ([`Favicon`], [`StyleSheet`], [`JavaScript`] o + /// [`Preload`](crate::html::Preload)) directamente, o aplica una operación [`AssetsOp`] sobre + /// los recursos del contexto. + fn with_assets(self, op: impl Into) -> Self; + + /// Modifica identificador, clases CSS, atributos HTML o valores extra del elemento ``. + fn with_body_props(self, op: PropsOp) -> Self; + + /// Añade un componente o aplica una operación [`ChildOp`] en la región por defecto del + /// documento. + fn with_child(self, op: impl Into) -> Self; + + /// Añade un componente o aplica una operación [`ChildOp`] en una región específica del + /// documento. + fn with_child_in(self, region: RegionRef, op: impl Into) -> Self; + + // **< Contextual GETTERS >********************************************************************* + + /// Devuelve una referencia a la petición HTTP asociada, si existe. + fn request(&self) -> Option<&HttpRequest>; + + /// Devuelve la identidad del usuario actual. + /// + /// Si ninguna extensión de autenticación ha inyectado un + /// [`CurrentUser`](crate::auth::CurrentUser) en las extensiones de la petición HTTP, devuelve + /// `&CurrentUser::Anonymous`. + /// + /// # Ejemplo + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// async fn greet(request: HttpRequest) -> Result { + /// let mut page = Page::new(request); + /// if page.current_user().is_authenticated() { + /// // Personalizar la página para el usuario autenticado. + /// } + /// page.render().await + /// } + /// ``` + fn current_user(&self) -> &CurrentUser; + + /// Devuelve la plantilla configurada para renderizar el documento. + fn template(&self) -> TemplateRef; + + /// Devuelve el tema que se usará para renderizar el documento. + fn theme(&self) -> ThemeRef; + + /// Recupera una *referencia tipada* al parámetro solicitado. + /// + /// Devuelve: + /// + /// - `Ok(&T)` si la clave existe y el tipo coincide. + /// - `Err(ContextError::ParamNotFound)` si la clave no existe. + /// - `Err(ContextError::ParamTypeMismatch)` si la clave existe pero el tipo no coincide. + /// + /// # Ejemplo + /// + /// ```rust + /// # use pagetop::prelude::*; + /// let cx = Context::default() + /// .with_param("user_id", 42_i32) + /// .with_param("title", "Hello".to_string()); + /// + /// let id: i32 = *cx.param("user_id").unwrap(); + /// let title: &String = cx.param("title").unwrap(); + /// + /// // Error de tipo: + /// assert!(cx.param::("user_id").is_err()); + /// ``` + fn param(&self, key: &'static str) -> Result<&T, ContextError>; + + /// Devuelve el parámetro clonado o el **valor por defecto del tipo** (`T::default()`). + fn param_or_default(&self, key: &'static str) -> T { + self.param::(key).ok().cloned().unwrap_or_default() + } + + /// Devuelve el parámetro clonado o un **valor por defecto** si no existe. + fn param_or(&self, key: &'static str, default: T) -> T { + self.param::(key).ok().cloned().unwrap_or(default) + } + + /// Devuelve el parámetro clonado o el **valor evaluado** por la función `f` si no existe. + fn param_or_else T>(&self, key: &'static str, f: F) -> T { + self.param::(key).ok().cloned().unwrap_or_else(f) + } + + /// Devuelve el Favicon de los recursos del contexto. + fn favicon(&self) -> Option<&Favicon>; + + /// Devuelve las hojas de estilo de los recursos del contexto. + fn stylesheets(&self) -> &Assets; + + /// Devuelve los scripts JavaScript de los recursos del contexto. + fn javascripts(&self) -> &Assets; + + /// Devuelve los estilos *responsive* acumulados en el contexto. + fn responsive_styles(&self) -> &ResponsiveStyles; + + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del elemento ``. + fn body_props(&self) -> &Props; + + // **< Contextual HELPERS >********************************************************************* + + /// Elimina un parámetro del contexto. Devuelve `true` si la clave existía y se eliminó. + /// + /// # Ejemplo + /// + /// ```rust + /// # use pagetop::prelude::*; + /// let mut cx = Context::default().with_param("temp", 1u8); + /// assert!(cx.remove_param("temp")); + /// assert!(!cx.remove_param("temp")); // ya no existe + /// ``` + fn remove_param(&mut self, key: &'static str) -> bool; +} diff --git a/src/core/component/context/error.rs b/src/core/component/context/error.rs new file mode 100644 index 00000000..15fcd070 --- /dev/null +++ b/src/core/component/context/error.rs @@ -0,0 +1,18 @@ +use thiserror::Error; + +/// Errores de acceso a parámetros dinámicos del contexto. +#[derive(Debug, Error)] +pub enum ContextError { + /// La clave no existe. + #[error("parameter not found")] + ParamNotFound, + /// La clave existe, pero el valor guardado no coincide con el tipo solicitado. Incluye + /// nombre de la clave (`key`), tipo esperado (`expected`) y tipo realmente guardado (`saved`) + /// para facilitar el diagnóstico. + #[error("type mismatch for parameter \"{key}\": expected \"{expected}\", found \"{saved}\"")] + ParamTypeMismatch { + key: &'static str, + expected: &'static str, + saved: &'static str, + }, +} diff --git a/src/html/assets/responsive.rs b/src/html/assets/responsive.rs index ef839823..7b78d14b 100644 --- a/src/html/assets/responsive.rs +++ b/src/html/assets/responsive.rs @@ -19,9 +19,9 @@ type Entry = (Option, CowStr, Vec<(CowStr, CowStr)>); /// El punto de corte es opcional, donde `None` declara una regla siempre activa, sin pasar por el /// tema ni depender de que resuelva algún [`Breakpoint`]. No ordena ni combina las clases de dos /// llamadas que las declaren en distinto orden (por ejemplo, `"foo bar"` y `"bar foo"` generan dos -/// entradas distintas). La llamada es a través de [`AssetsOp::AddResponsiveStyle`]. +/// entradas distintas). La llamada es a través de [`AssetsOp::add_responsive_style()`]. /// -/// [`AssetsOp::AddResponsiveStyle`]: crate::core::component::AssetsOp::AddResponsiveStyle +/// [`AssetsOp::add_responsive_style()`]: crate::core::component::AssetsOp::add_responsive_style #[derive(AutoDefault, Clone, Debug)] pub struct ResponsiveStyles(Vec); @@ -50,50 +50,94 @@ impl ResponsiveStyles { value: impl AsRef, ) { let breakpoint = breakpoint.into(); - - let Some(classes) = util::normalize_ascii(classes.as_ref()) else { + let Some(classes) = Self::normalize_classes(classes.as_ref()) else { return; }; - if classes.is_empty() { - return; - } - let classes: CowStr = classes.into_owned().into(); - - let property_norm = property.as_ref().trim().to_ascii_lowercase(); - if property_norm.is_empty() { - return; - } - let property: CowStr = property_norm.into(); match self .0 .iter_mut() .find(|(bp, cls, _)| *bp == breakpoint && *cls == classes) { - Some((_, _, styles)) => { - // Ya declarada: se descarta sin normalizar `value`, el camino habitual (y que debe - // ser barato) cuando muchos componentes comparten la misma clase utilitaria. - if styles.iter().any(|(k, _)| *k == property) { - return; - } - let Some(value) = util::non_blank(value.as_ref()) else { - return; - }; - styles.push((property, value.to_string().into())); - } + Some((_, _, styles)) => Self::insert_style(styles, property.as_ref(), value.as_ref()), None => { - let Some(value) = util::non_blank(value.as_ref()) else { - return; - }; - self.0.push(( - breakpoint, - classes, - vec![(property, value.to_string().into())], - )); + let mut styles = Vec::new(); + Self::insert_style(&mut styles, property.as_ref(), value.as_ref()); + if !styles.is_empty() { + self.0.push((breakpoint, classes, styles)); + } } } } + /// Añade varias declaraciones de estilo (`property: value`) para las clases indicadas, dentro + /// del punto de corte dado, en una única llamada. + /// + /// Equivale a invocar [`add_style()`](Self::add_style) una vez por cada par `(property, + /// value)` de `styles`, con las mismas reglas de normalización y de descarte silencioso, pero + /// normalizando `classes` y localizando la entrada una sola vez para todo el lote. + pub fn add_styles( + &mut self, + breakpoint: impl Into>, + classes: impl AsRef, + styles: impl IntoIterator, impl AsRef)>, + ) { + let breakpoint = breakpoint.into(); + let Some(classes) = Self::normalize_classes(classes.as_ref()) else { + return; + }; + + match self + .0 + .iter_mut() + .find(|(bp, cls, _)| *bp == breakpoint && *cls == classes) + { + Some((_, _, existing)) => { + for (property, value) in styles { + Self::insert_style(existing, property.as_ref(), value.as_ref()); + } + } + None => { + let mut new_styles = Vec::new(); + for (property, value) in styles { + Self::insert_style(&mut new_styles, property.as_ref(), value.as_ref()); + } + if !new_styles.is_empty() { + self.0.push((breakpoint, classes, new_styles)); + } + } + } + } + + // Normaliza `classes` para usarla como clave de entrada. Devuelve `None` si contiene caracteres + // no ASCII o si el resultado queda vacío tras recortar espacios. Compartida por `add_style()`, + // `add_styles()` y `entry()`. + fn normalize_classes(classes: &str) -> Option { + let classes = util::normalize_ascii(classes)?; + if classes.is_empty() { + return None; + } + Some(classes.into_owned().into()) + } + + // Inserta una declaración (`property: value`) en la lista de destino, ya localizada por el + // llamador. Aplica las mismas reglas que `add_style()`: normaliza `property`, descarta si ya + // existe una declaración para esa propiedad (sin normalizar `value`, el camino barato cuando + // muchos componentes comparten la misma clase utilitaria) y descarta si `property` o `value` + // quedan vacíos tras recortar espacios. + fn insert_style(styles: &mut Vec<(CowStr, CowStr)>, property: &str, value: &str) { + let Some(property) = util::normalize_property(property) else { + return; + }; + if styles.iter().any(|(k, _)| k.as_ref() == property) { + return; + } + let Some(value) = util::non_blank(value) else { + return; + }; + styles.push((property.into(), value.to_string().into())); + } + // **< ResponsiveStyles GETTERS >*************************************************************** /// Devuelve el valor de la propiedad indicada para el punto de corte y las clases dados, si @@ -105,7 +149,7 @@ impl ResponsiveStyles { property: impl AsRef, ) -> Option { let styles = self.entry(breakpoint.into(), classes.as_ref())?; - let property = property.as_ref().trim().to_ascii_lowercase(); + let property = util::normalize_property(property)?; styles .iter() .find(|(k, _)| k.as_ref() == property) @@ -197,20 +241,17 @@ impl ResponsiveStyles { rules } - // Normaliza `classes` igual que `add_style()` y busca las declaraciones de la entrada - // correspondiente al punto de corte y las clases dados. + // Normaliza `classes` y busca las declaraciones de la entrada correspondiente al punto de corte + // y las clases dados. fn entry( &self, breakpoint: Option, classes: &str, ) -> Option<&Vec<(CowStr, CowStr)>> { - let classes = util::normalize_ascii(classes)?; - if classes.is_empty() { - return None; - } + let classes = Self::normalize_classes(classes)?; self.0 .iter() - .find(|(bp, cls, _)| *bp == breakpoint && cls.as_ref() == classes.as_ref()) + .find(|(bp, cls, _)| *bp == breakpoint && *cls == classes) .map(|(_, _, styles)| styles) } } diff --git a/src/html/assets/stylesheet.rs b/src/html/assets/stylesheet.rs index 35e547ac..6872a4c2 100644 --- a/src/html/assets/stylesheet.rs +++ b/src/html/assets/stylesheet.rs @@ -1,24 +1,8 @@ use crate::core::component::Context; use crate::html::assets::Asset; -use crate::html::{Markup, PreEscaped, html}; +use crate::html::{Markup, html}; use crate::{AutoDefault, CowStr, Weight, util}; -/// Define el origen del recurso CSS y cómo se incluye en el documento. -/// -/// Los estilos pueden cargarse desde un archivo externo o estar embebidos directamente en una -/// etiqueta ``. El parámetro `name` se usa como identificador interno del - /// recurso. - /// - /// Un closure recibirá el [`Context`] por si se necesita durante el renderizado. - pub fn inline(name: impl Into, f: F) -> Self - where - F: Fn(&mut Context) -> String + Send + Sync + 'static, - { - Self { - source: Source::Inline(name.into(), Box::new(f)), + path: path.into(), ..Default::default() } } @@ -147,14 +113,9 @@ impl StyleSheet { } impl Asset for StyleSheet { - /// Devuelve el nombre del recurso, utilizado como clave única. - /// - /// Para hojas de estilos externas es la ruta del recurso; para las embebidas, un identificador. + /// Devuelve la ruta del recurso, utilizada como clave única. fn name(&self) -> &str { - match &self.source { - Source::From(path) => path, - Source::Inline(name, _) => name, - } + &self.path } fn weight(&self) -> Weight { @@ -163,17 +124,12 @@ impl Asset for StyleSheet { // **< StyleSheet RENDER >********************************************************************** - fn render(&self, cx: &mut Context) -> Markup { - match &self.source { - Source::From(path) => html! { - link - rel="stylesheet" - href=(util::join_pair!(path, "?v=", &self.version)) - media=[self.media.as_str()]; - }, - Source::Inline(_, f) => html! { - style { (PreEscaped((f)(cx))) }; - }, + fn render(&self, _cx: &mut Context) -> Markup { + html! { + link + rel="stylesheet" + href=(util::join_pair!(&self.path, "?v=", &self.version)) + media=[self.media.as_str()]; } } } diff --git a/src/html/flex.rs b/src/html/flex.rs index 72adf0cb..a810bded 100644 --- a/src/html/flex.rs +++ b/src/html/flex.rs @@ -16,12 +16,12 @@ //! manera independiente a cualquier tema o framework CSS. El nombre interno de cada clase se deriva //! de la propiedad y el valor que representa, así que dos elementos con la misma configuración //! comparten la misma regla en vez de duplicarla. Las declaraciones correspondientes se registran -//! vía [`AssetsOp::AddResponsiveStyle`] y se renderizan como reglas en el `` del documento. -//! Funciona igual conviva con quien conviva en la misma página, sin necesidad de coordinar nombres -//! de clase ni orden alguno en la carga de hojas de estilo. +//! vía [`AssetsOp::add_responsive_style()`] y se renderizan como reglas en el `` del +//! documento. Funciona igual conviva con quien conviva en la misma página, sin necesidad de +//! coordinar nombres de clase ni orden alguno en la carga de hojas de estilo. //! //! [Flexbox]: https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Flexible_box_layout -//! [`AssetsOp::AddResponsiveStyle`]: crate::core::component::AssetsOp::AddResponsiveStyle +//! [`AssetsOp::add_responsive_style()`]: crate::core::component::AssetsOp::add_responsive_style //! [`PropsOp::flex_item()`]: crate::html::props::PropsOp::flex_item //! [`Container`]: crate::base::component::Container //! [`Navbar`]: crate::base::component::Navbar @@ -72,10 +72,10 @@ fn styles( } classes.push_str(&class); - cx.alter_assets(AssetsOp::AddResponsiveStyle( + cx.alter_assets(AssetsOp::add_responsive_style( entry.map(|e| e.breakpoint), class, - property.into(), + property, value, )); } diff --git a/src/html/flex/container.rs b/src/html/flex/container.rs index e9e7e1fc..6fd00aab 100644 --- a/src/html/flex/container.rs +++ b/src/html/flex/container.rs @@ -23,7 +23,7 @@ enum DisplayFlex { /// /// Se resuelve como clases CSS generadas dinámicamente (`display`, `flex-direction`, `flex-wrap`, /// `justify-content`, `align-items`, `align-content`, `gap`), registradas vía -/// [`AssetsOp::AddResponsiveStyle`] en [`ResponsiveStyles`] y renderizadas como reglas en el +/// [`AssetsOp::add_responsive_style()`] en [`ResponsiveStyles`] y renderizadas como reglas en el /// `` del documento. Son propiedades nativas que no requieren interpretación por parte de los /// temas, siempre funcionan igual, sin una sola línea de CSS ni de código específico. /// @@ -32,7 +32,7 @@ enum DisplayFlex { /// regla generada en vez de duplicarla, y el nombre generado no coincide por accidente con clases /// de terceros. /// -/// [`AssetsOp::AddResponsiveStyle`]: crate::core::component::AssetsOp::AddResponsiveStyle +/// [`AssetsOp::add_responsive_style()`]: crate::core::component::AssetsOp::add_responsive_style /// [`ResponsiveStyles`]: crate::html::ResponsiveStyles /// /// # Ejemplo diff --git a/src/html/props/definition.rs b/src/html/props/definition.rs index fd2f1059..9c218510 100644 --- a/src/html/props/definition.rs +++ b/src/html/props/definition.rs @@ -378,7 +378,7 @@ impl Props { /// Devuelve el valor de la propiedad de estilo indicada, si existe. pub fn get_style(&self, property: impl AsRef) -> Option { - let property = property.as_ref().trim().to_ascii_lowercase(); + let property = util::normalize_property(property)?; self.styles .iter() .find(|(k, _)| k.as_ref() == property) @@ -639,10 +639,9 @@ impl Props { // la documentación de `PropsOp::AddStyle` sobre por qué los valores de estilo no se restringen // a ASCII. fn set_style(&mut self, property: &str, value: &str) { - let Some(property) = util::non_blank(property) else { + let Some(property) = util::normalize_property(property) else { return; }; - let property = property.to_ascii_lowercase(); let Some(value) = util::non_blank(value) else { return; }; @@ -703,8 +702,9 @@ impl Props { // Elimina la propiedad de estilo indicada, si existe. fn remove_style(&mut self, property: &str) { - let property = property.trim().to_ascii_lowercase(); - self.styles.retain(|(k, _)| k.as_ref() != property); + if let Some(property) = util::normalize_property(property) { + self.styles.retain(|(k, _)| k.as_ref() != property); + }; } } diff --git a/src/lib.rs b/src/lib.rs index 2d56ebcb..dbcea9d2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -109,15 +109,15 @@ use std::ops::Deref; /// impl Theme for MyTheme { /// fn before_render_page_body(&self, page: &mut Page) { /// page -/// .alter_assets(AssetsOp::AddStyleSheet( -/// StyleSheet::from("/pagetop/css/normalize.css").with_version("8.0.1"), -/// )) -/// .alter_assets(AssetsOp::AddStyleSheet( +/// .alter_assets( +/// StyleSheet::from("/pagetop/css/normalize.css").with_version("8.0.1") +/// ) +/// .alter_assets( /// StyleSheet::from("/pagetop/css/basic.css").with_version(PAGETOP_VERSION), -/// )) -/// .alter_assets(AssetsOp::AddStyleSheet( +/// ) +/// .alter_assets( /// StyleSheet::from("/mytheme/styles.css").with_version(env!("CARGO_PKG_VERSION")), -/// )); +/// ); /// } /// } /// ``` diff --git a/src/response/page.rs b/src/response/page.rs index fde52ec2..8685e13b 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -295,7 +295,7 @@ impl Contextual for Page { self } - fn with_assets(mut self, op: AssetsOp) -> Self { + fn with_assets(mut self, op: impl Into) -> Self { self.context.alter_assets(op); self } diff --git a/src/util.rs b/src/util.rs index ed3f9c1e..c6539ce4 100644 --- a/src/util.rs +++ b/src/util.rs @@ -212,6 +212,20 @@ pub fn normalize_ascii(input: &str) -> Option> { } } +/// Recorta espacios y pasa a minúsculas el nombre de una propiedad, tratando el resultado vacío +/// como ausencia. +/// +/// # Ejemplo +/// +/// ```rust +/// # use pagetop::util; +/// assert_eq!(util::normalize_property(" Flex-Basis "), Some("flex-basis".to_string())); +/// assert_eq!(util::normalize_property(" "), None); +/// ``` +pub fn normalize_property(property: impl AsRef) -> Option { + non_blank(property.as_ref()).map(str::to_ascii_lowercase) +} + /// Recorta espacios, convierte una cadena vacía en `None` y normaliza el resto. /// /// Convierte en un único token: en minúsculas y con cada espacio en blanco sustituido por `_`. diff --git a/tests/html_responsives.rs b/tests/html_responsives.rs index 49b2b774..78c33387 100644 --- a/tests/html_responsives.rs +++ b/tests/html_responsives.rs @@ -105,6 +105,75 @@ async fn add_style_ignores_non_ascii_classes() { assert!(r.is_empty()); } +// **< ResponsiveStyles::add_styles >*************************************************************** + +#[pagetop::test] +async fn add_styles_adds_multiple_declarations_in_order() { + let mut r = ResponsiveStyles::new(); + r.add_styles( + Breakpoint::Md, + "col", + [("flex-basis", "50%"), ("margin-inline-start", "0")], + ); + assert_eq!( + r.get_styles(Breakpoint::Md, "col"), + Some("flex-basis: 50%; margin-inline-start: 0".to_string()) + ); +} + +#[pagetop::test] +async fn add_styles_merges_into_an_entry_already_created_by_add_style() { + // The batch must reuse the entry created by a prior add_style() call, not duplicate it. + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "col", "flex-basis", "50%"); + r.add_styles(Breakpoint::Md, "col", [("margin-inline-start", "0")]); + assert_eq!( + r.get_styles(Breakpoint::Md, "col"), + Some("flex-basis: 50%; margin-inline-start: 0".to_string()) + ); +} + +#[pagetop::test] +async fn add_styles_keeps_first_value_when_property_repeats_within_the_batch() { + // Same first-write-wins rule as add_style(), applied within a single batch. + let mut r = ResponsiveStyles::new(); + r.add_styles( + Breakpoint::Md, + "col", + [("flex-basis", "50%"), ("flex-basis", "33%")], + ); + assert_eq!( + r.get_styles(Breakpoint::Md, "col"), + Some("flex-basis: 50%".to_string()) + ); +} + +#[pagetop::test] +async fn add_styles_empty_batch_leaves_no_trace() { + // A batch that yields no declarations must not create an empty entry. + let none: Vec<(&str, &str)> = Vec::new(); + let mut r = ResponsiveStyles::new(); + r.add_styles(Breakpoint::Md, "col", none); + assert!(r.is_empty()); +} + +#[pagetop::test] +async fn add_styles_ignores_all_invalid_declarations_in_the_batch() { + // Same silent-discard rules as add_style(), so a batch with only invalid declarations must + // also leave no trace (no empty entry left behind). + let mut r = ResponsiveStyles::new(); + r.add_styles(Breakpoint::Md, "col", [("", "50%"), ("flex-basis", "")]); + assert!(r.is_empty()); +} + +#[pagetop::test] +async fn add_styles_ignores_empty_or_non_ascii_classes() { + let mut r = ResponsiveStyles::new(); + r.add_styles(Breakpoint::Md, "", [("flex-basis", "50%")]); + r.add_styles(Breakpoint::Md, "cañón", [("flex-basis", "50%")]); + assert!(r.is_empty()); +} + // **< Class normalization >************************************************************************ #[pagetop::test] @@ -355,11 +424,11 @@ async fn render_has_no_line_breaks() { #[pagetop::test] async fn context_add_responsive_style_feeds_responsives() { - let cx = Context::default().with_assets(AssetsOp::AddResponsiveStyle( + let cx = Context::default().with_assets(AssetsOp::add_responsive_style( Some(Breakpoint::Md), - "col".into(), - "flex-basis".into(), - "50%".into(), + "col", + "flex-basis", + "50%", )); assert_eq!( cx.responsive_styles().get_styles(Breakpoint::Md, "col"), @@ -370,17 +439,17 @@ async fn context_add_responsive_style_feeds_responsives() { #[pagetop::test] async fn context_add_responsive_style_accumulates_across_calls() { let cx = Context::default() - .with_assets(AssetsOp::AddResponsiveStyle( + .with_assets(AssetsOp::add_responsive_style( Some(Breakpoint::Md), - "col".into(), - "flex-basis".into(), - "50%".into(), + "col", + "flex-basis", + "50%", )) - .with_assets(AssetsOp::AddResponsiveStyle( + .with_assets(AssetsOp::add_responsive_style( Some(Breakpoint::Md), - "col".into(), - "margin-inline-start".into(), - "0".into(), + "col", + "margin-inline-start", + "0", )); assert_eq!( cx.responsive_styles().get_styles(Breakpoint::Md, "col"), @@ -388,6 +457,19 @@ async fn context_add_responsive_style_accumulates_across_calls() { ); } +#[pagetop::test] +async fn context_add_responsive_styles_feeds_responsives() { + let cx = Context::default().with_assets(AssetsOp::add_responsive_styles( + Some(Breakpoint::Md), + "col", + [("flex-basis", "50%"), ("margin-inline-start", "0")], + )); + assert_eq!( + cx.responsive_styles().get_styles(Breakpoint::Md, "col"), + Some("flex-basis: 50%; margin-inline-start: 0".to_string()) + ); +} + #[pagetop::test] async fn context_default_has_no_responsive_styles() { assert!(Context::default().responsive_styles().is_empty()); @@ -397,11 +479,11 @@ async fn context_default_has_no_responsive_styles() { #[pagetop::test] async fn render_assets_includes_style_tag_with_responsive_styles() { - let mut cx = Context::default().with_assets(AssetsOp::AddResponsiveStyle( + let mut cx = Context::default().with_assets(AssetsOp::add_responsive_style( Some(Breakpoint::Xs), - "col".into(), - "flex-basis".into(), - "100%".into(), + "col", + "flex-basis", + "100%", )); assert_eq!( cx.render_assets().into_string(),