From c7b680a7f7076d828e535db12551778206d9fdd7 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Tue, 7 Jul 2026 00:03:36 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20(html):=20A=C3=B1ade=20Preload=20pa?= =?UTF-8?q?ra=20precarga=20de=20recursos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nuevo tipo `Preload` (junto a `StyleSheet` y `JavaScript`) que emite `` con soporte para fuentes, estilos, scripts e imágenes (WebP y AVIF incluidos). Bootsier lo usa para precargar las fuentes Source Sans 3 y eliminar el FOUT en la carga inicial. --- .../assets/_bootsier-custom.scss | 1 + extensions/pagetop-bootsier/src/lib.rs | 12 +- src/core/component/context.rs | 20 +- src/html.rs | 1 + src/html/assets.rs | 1 + src/html/assets/preload.rs | 200 ++++++++++++++++++ 6 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 src/html/assets/preload.rs diff --git a/extensions/pagetop-bootsier/assets/_bootsier-custom.scss b/extensions/pagetop-bootsier/assets/_bootsier-custom.scss index 51f08dd8..f4c6d5d0 100644 --- a/extensions/pagetop-bootsier/assets/_bootsier-custom.scss +++ b/extensions/pagetop-bootsier/assets/_bootsier-custom.scss @@ -2,6 +2,7 @@ // Self-hosted Source Sans 3 (SIL OFL 1.1), served from /bootsier/fonts. // Required by AdminLTE 4, which declares it as the primary font family in $font-family-sans-serif. +// Font URLs must match the Preload declarations in src/lib.rs; a mismatch causes double downloads. @font-face { font-family: "Source Sans 3"; src: url("/bootsier/fonts/bootsier.font.woff2") format("woff2"); diff --git a/extensions/pagetop-bootsier/src/lib.rs b/extensions/pagetop-bootsier/src/lib.rs index 5fabdacc..71253238 100644 --- a/extensions/pagetop-bootsier/src/lib.rs +++ b/extensions/pagetop-bootsier/src/lib.rs @@ -155,7 +155,15 @@ impl Theme for Bootsier { } fn before_render_page_body(&self, page: &mut Page) { - page.alter_assets(AssetsOp::AddStyleSheet( + // 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(-90), @@ -168,7 +176,7 @@ impl Theme for Bootsier { .alter_assets(AssetsOp::AddJavaScript( JavaScript::defer("/bootsier/js/bootsier.extended.min.js") .with_version(ADMINLTE_VERSION) - .with_weight(-89), + .with_weight(-90), )) .alter_child_in( &DefaultRegion::Footer, diff --git a/src/core/component/context.rs b/src/core/component/context.rs index 78bc66e6..33e009d6 100644 --- a/src/core/component/context.rs +++ b/src/core/component/context.rs @@ -3,7 +3,7 @@ use crate::core::TypeInfo; use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage}; use crate::core::theme::all::DEFAULT_THEME; use crate::core::theme::{ChildrenInRegions, DefaultRegion, RegionRef, TemplateRef, ThemeRef}; -use crate::html::{Assets, Favicon, JavaScript, StyleSheet}; +use crate::html::{Assets, Favicon, JavaScript, Preload, StyleSheet}; use crate::html::{Markup, Props, PropsOp, RoutePath, html}; use crate::locale::L10n; use crate::locale::{LangId, LanguageIdentifier, RequestLocale}; @@ -23,6 +23,11 @@ pub enum AssetsOp { /// Define el *favicon* solo 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 o identificador. @@ -312,6 +317,7 @@ pub struct Context { theme : ThemeRef, // Referencia al tema usado para renderizar. template : TemplateRef, // Plantilla usada para renderizar. favicon : Option, // Favicon, si se ha definido. + preloads : Assets, // Recursos para precarga. stylesheets : Assets, // Hojas de estilo CSS. javascripts : Assets, // Scripts JavaScript. body_props : Props, // Id, clases CSS y atributos del . @@ -343,6 +349,7 @@ impl Context { theme : *DEFAULT_THEME, template : DEFAULT_THEME.default_template(), favicon : None, + preloads : Assets::::new(), stylesheets: Assets::::new(), javascripts: Assets::::new(), body_props : Props::default(), @@ -370,6 +377,7 @@ impl Context { // Extrae temporalmente los recursos. let favicon = mem_take(&mut self.favicon); // Deja valor por defecto (None) en self. + let preloads = mem_take(&mut self.preloads); // Assets::default() en self. let stylesheets = mem_take(&mut self.stylesheets); // Assets::default() en self. let javascripts = mem_take(&mut self.javascripts); // Assets::default() en self. @@ -378,12 +386,15 @@ impl Context { @if let Some(fi) = &favicon { (fi.render(self)) } + // Primero los recursos para precarga para iniciar las descargas inmediatamente. + (preloads.render(self)) (stylesheets.render(self)) (javascripts.render(self)) }; // Restaura los campos tal y como estaban. self.favicon = favicon; + self.preloads = preloads; self.stylesheets = stylesheets; self.javascripts = javascripts; @@ -549,6 +560,13 @@ impl Contextual for Context { self.favicon = Some(icon); } } + // Preloads. + AssetsOp::AddPreload(preload) => { + self.preloads.add(preload); + } + AssetsOp::RemovePreload(path) => { + self.preloads.remove(path); + } // Stylesheets. AssetsOp::AddStyleSheet(css) => { self.stylesheets.add(css); diff --git a/src/html.rs b/src/html.rs index 9f2daace..f8a5dfc4 100644 --- a/src/html.rs +++ b/src/html.rs @@ -11,6 +11,7 @@ pub use route::RoutePath; mod assets; pub use assets::favicon::Favicon; pub use assets::javascript::JavaScript; +pub use assets::preload::Preload; pub use assets::stylesheet::{StyleSheet, TargetMedia}; pub use assets::{Asset, Assets}; diff --git a/src/html/assets.rs b/src/html/assets.rs index 80cb3b26..c3e111df 100644 --- a/src/html/assets.rs +++ b/src/html/assets.rs @@ -1,5 +1,6 @@ pub mod favicon; pub mod javascript; +pub mod preload; pub mod stylesheet; use crate::core::component::Context; diff --git a/src/html/assets/preload.rs b/src/html/assets/preload.rs new file mode 100644 index 00000000..6696c515 --- /dev/null +++ b/src/html/assets/preload.rs @@ -0,0 +1,200 @@ +use crate::core::component::Context; +use crate::html::assets::Asset; +use crate::html::{Markup, html}; +use crate::{AutoDefault, CowStr, Weight, util}; + +// Tipo de recurso para el atributo `as` de un recurso para precargar. +// Informa al navegador de la naturaleza del recurso para que pueda asignarle la prioridad correcta +// y aplicar la política de caché adecuada. +#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] +pub(crate) enum AsType { + // Fuente web (`@font-face`). Implica `crossorigin` automático. + #[default] + Font, + // Hoja de estilos CSS. + Style, + // Script JavaScript. + Script, + // Imagen. + Image, +} + +impl AsType { + const fn as_str(self) -> &'static str { + match self { + AsType::Font => "font", + AsType::Style => "style", + AsType::Script => "script", + AsType::Image => "image", + } + } +} + +/// Define un recurso **Preload** para precargar en el documento. +/// +/// Indica al navegador que descargue el recurso con alta prioridad antes de que el analizador del +/// HTML lo descubra de forma natural, reduciendo la latencia percibida. +/// +/// > **Nota** +/// > Los recursos deben estar disponibles en el servidor web de la aplicación. Pueden servirse +/// > usando [`serve_static_files!`](crate::serve_static_files). +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// // Precarga una fuente web (crossorigin se activa automáticamente). +/// let preload = Preload::font("/assets/fonts/inter.woff2").with_weight(-100); +/// +/// // Precarga una imagen WebP crítica para la métrica "Largest Contentful Paint" (LCP). +/// let preload = Preload::webp("/assets/img/hero.webp"); +/// ``` +#[derive(AutoDefault)] +pub struct Preload { + path: CowStr, // Ruta del recurso (href). + as_type: AsType, // Tipo de recurso (atributo `as`). + mime_type: CowStr, // Tipo MIME (atributo `type`), vacío si no se especifica. + crossorigin: bool, // Activa el atributo `crossorigin`. + version: CowStr, // Versión del recurso para la caché del navegador. + weight: Weight, // Peso que determina el orden. +} + +impl Preload { + /// Precarga una fuente web. + /// + /// Equivale a ``. + /// + /// El atributo `crossorigin` se activa siempre porque `@font-face` usa CORS internamente; sin + /// él el navegador descargaría el recurso dos veces. + pub fn font(path: impl Into) -> Self { + Self { + path: path.into(), + as_type: AsType::Font, + mime_type: "font/woff2".into(), + crossorigin: true, + ..Default::default() + } + } + + /// Precarga una hoja de estilos CSS. + /// + /// Equivale a ``. + pub fn style(path: impl Into) -> Self { + Self { + path: path.into(), + as_type: AsType::Style, + ..Default::default() + } + } + + /// Precarga un script JavaScript. + /// + /// Equivale a ``. + pub fn script(path: impl Into) -> Self { + Self { + path: path.into(), + as_type: AsType::Script, + ..Default::default() + } + } + + /// Precarga una imagen. + /// + /// Equivale a ``. Adecuado para formatos con soporte + /// universal (JPEG, PNG, GIF); para WebP o AVIF usar [`Self::webp()`] o [`Self::avif()`], que + /// añaden el atributo `type` para que el navegador omita la descarga si no soporta el formato. + pub fn image(path: impl Into) -> Self { + Self { + path: path.into(), + as_type: AsType::Image, + ..Default::default() + } + } + + /// Precarga una imagen en formato WebP. + /// + /// Equivale a ``. + /// + /// El atributo `type` indica al navegador que omita la descarga si no soporta WebP. + pub fn webp(path: impl Into) -> Self { + Self { + path: path.into(), + as_type: AsType::Image, + mime_type: "image/webp".into(), + ..Default::default() + } + } + + /// Precarga una imagen en formato AVIF. + /// + /// Equivale a ``. + /// + /// El atributo `type` indica al navegador que omita la descarga si no soporta AVIF. + pub fn avif(path: impl Into) -> Self { + Self { + path: path.into(), + as_type: AsType::Image, + mime_type: "image/avif".into(), + ..Default::default() + } + } + + // **< Preload BUILDER >************************************************************************ + + /// Asocia una versión al recurso (usada para control de la caché del navegador). + /// + /// Si `version` está vacío, no se añade ningún parámetro a la URL. + pub fn with_version(mut self, version: impl Into) -> Self { + self.version = version.into(); + self + } + + /// Modifica el peso del recurso. + /// + /// Los recursos se renderizan de menor a mayor peso. Por defecto es `0`, que respeta el orden + /// de creación. + pub fn with_weight(mut self, value: Weight) -> Self { + self.weight = value; + self + } + + /// Activa el atributo `crossorigin`. + /// + /// Necesario para recursos servidos desde otro origen. Para fuentes (`as="font"`) se activa + /// automáticamente. + pub fn with_crossorigin(mut self) -> Self { + self.crossorigin = true; + self + } + + /// Especifica el tipo MIME del recurso (atributo `type`). + /// + /// Permite que el navegador omita la descarga si no soporta el formato indicado. Los + /// constructores [`Self::font()`], [`Self::webp()`] y [`Self::avif()`] lo establecen + /// automáticamente; este método cubre otros formatos o permite cambiar el valor por defecto. + pub fn with_mime_type(mut self, mime: impl Into) -> Self { + self.mime_type = mime.into(); + self + } +} + +impl Asset for Preload { + fn name(&self) -> &str { + &self.path + } + + fn weight(&self) -> Weight { + self.weight + } + + fn render(&self, _cx: &mut Context) -> Markup { + html! { + link + rel="preload" + href=(util::join_pair!(&self.path, "?v=", &self.version)) + as=(self.as_type.as_str()) + type=[(!self.mime_type.is_empty()).then_some(self.mime_type.as_ref())] + crossorigin[self.crossorigin]; + } + } +}