(html): Añade Preload para precarga de recursos

Nuevo tipo `Preload` (junto a `StyleSheet` y `JavaScript`) que emite
`<link rel="preload">` 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.
This commit is contained in:
Manuel Cillero 2026-07-07 00:03:36 +02:00
parent 9b2d430d8b
commit c7b680a7f7
6 changed files with 232 additions and 3 deletions

View file

@ -2,6 +2,7 @@
// Self-hosted Source Sans 3 (SIL OFL 1.1), served from /bootsier/fonts. // 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. // 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-face {
font-family: "Source Sans 3"; font-family: "Source Sans 3";
src: url("/bootsier/fonts/bootsier.font.woff2") format("woff2"); src: url("/bootsier/fonts/bootsier.font.woff2") format("woff2");

View file

@ -155,7 +155,15 @@ impl Theme for Bootsier {
} }
fn before_render_page_body(&self, page: &mut Page) { 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") StyleSheet::from("/bootsier/css/bootsier.min.css")
.with_version(ADMINLTE_VERSION) .with_version(ADMINLTE_VERSION)
.with_weight(-90), .with_weight(-90),
@ -168,7 +176,7 @@ impl Theme for Bootsier {
.alter_assets(AssetsOp::AddJavaScript( .alter_assets(AssetsOp::AddJavaScript(
JavaScript::defer("/bootsier/js/bootsier.extended.min.js") JavaScript::defer("/bootsier/js/bootsier.extended.min.js")
.with_version(ADMINLTE_VERSION) .with_version(ADMINLTE_VERSION)
.with_weight(-89), .with_weight(-90),
)) ))
.alter_child_in( .alter_child_in(
&DefaultRegion::Footer, &DefaultRegion::Footer,

View file

@ -3,7 +3,7 @@ use crate::core::TypeInfo;
use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage}; use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage};
use crate::core::theme::all::DEFAULT_THEME; use crate::core::theme::all::DEFAULT_THEME;
use crate::core::theme::{ChildrenInRegions, DefaultRegion, RegionRef, TemplateRef, ThemeRef}; 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::html::{Markup, Props, PropsOp, RoutePath, html};
use crate::locale::L10n; use crate::locale::L10n;
use crate::locale::{LangId, LanguageIdentifier, RequestLocale}; use crate::locale::{LangId, LanguageIdentifier, RequestLocale};
@ -23,6 +23,11 @@ pub enum AssetsOp {
/// Define el *favicon* solo si no se ha establecido previamente. /// Define el *favicon* solo si no se ha establecido previamente.
SetFaviconIfNone(Favicon), 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. /// Añade una hoja de estilos CSS al documento.
AddStyleSheet(StyleSheet), AddStyleSheet(StyleSheet),
/// Elimina una hoja de estilos por su ruta o identificador. /// 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. theme : ThemeRef, // Referencia al tema usado para renderizar.
template : TemplateRef, // Plantilla usada para renderizar. template : TemplateRef, // Plantilla usada para renderizar.
favicon : Option<Favicon>, // Favicon, si se ha definido. favicon : Option<Favicon>, // Favicon, si se ha definido.
preloads : Assets<Preload>, // Recursos para precarga.
stylesheets : Assets<StyleSheet>, // Hojas de estilo CSS. stylesheets : Assets<StyleSheet>, // Hojas de estilo CSS.
javascripts : Assets<JavaScript>, // Scripts JavaScript. javascripts : Assets<JavaScript>, // Scripts JavaScript.
body_props : Props, // Id, clases CSS y atributos del <body>. body_props : Props, // Id, clases CSS y atributos del <body>.
@ -343,6 +349,7 @@ impl Context {
theme : *DEFAULT_THEME, theme : *DEFAULT_THEME,
template : DEFAULT_THEME.default_template(), template : DEFAULT_THEME.default_template(),
favicon : None, favicon : None,
preloads : Assets::<Preload>::new(),
stylesheets: Assets::<StyleSheet>::new(), stylesheets: Assets::<StyleSheet>::new(),
javascripts: Assets::<JavaScript>::new(), javascripts: Assets::<JavaScript>::new(),
body_props : Props::default(), body_props : Props::default(),
@ -370,6 +377,7 @@ impl Context {
// Extrae temporalmente los recursos. // Extrae temporalmente los recursos.
let favicon = mem_take(&mut self.favicon); // Deja valor por defecto (None) en self. let favicon = mem_take(&mut self.favicon); // Deja valor por defecto (None) en self.
let preloads = mem_take(&mut self.preloads); // Assets<Preload>::default() en self.
let stylesheets = mem_take(&mut self.stylesheets); // Assets<StyleSheet>::default() en self. let stylesheets = mem_take(&mut self.stylesheets); // Assets<StyleSheet>::default() en self.
let javascripts = mem_take(&mut self.javascripts); // Assets<JavaScript>::default() en self. let javascripts = mem_take(&mut self.javascripts); // Assets<JavaScript>::default() en self.
@ -378,12 +386,15 @@ impl Context {
@if let Some(fi) = &favicon { @if let Some(fi) = &favicon {
(fi.render(self)) (fi.render(self))
} }
// Primero los recursos para precarga para iniciar las descargas inmediatamente.
(preloads.render(self))
(stylesheets.render(self)) (stylesheets.render(self))
(javascripts.render(self)) (javascripts.render(self))
}; };
// Restaura los campos tal y como estaban. // Restaura los campos tal y como estaban.
self.favicon = favicon; self.favicon = favicon;
self.preloads = preloads;
self.stylesheets = stylesheets; self.stylesheets = stylesheets;
self.javascripts = javascripts; self.javascripts = javascripts;
@ -549,6 +560,13 @@ impl Contextual for Context {
self.favicon = Some(icon); self.favicon = Some(icon);
} }
} }
// Preloads.
AssetsOp::AddPreload(preload) => {
self.preloads.add(preload);
}
AssetsOp::RemovePreload(path) => {
self.preloads.remove(path);
}
// Stylesheets. // Stylesheets.
AssetsOp::AddStyleSheet(css) => { AssetsOp::AddStyleSheet(css) => {
self.stylesheets.add(css); self.stylesheets.add(css);

View file

@ -11,6 +11,7 @@ pub use route::RoutePath;
mod assets; mod assets;
pub use assets::favicon::Favicon; pub use assets::favicon::Favicon;
pub use assets::javascript::JavaScript; pub use assets::javascript::JavaScript;
pub use assets::preload::Preload;
pub use assets::stylesheet::{StyleSheet, TargetMedia}; pub use assets::stylesheet::{StyleSheet, TargetMedia};
pub use assets::{Asset, Assets}; pub use assets::{Asset, Assets};

View file

@ -1,5 +1,6 @@
pub mod favicon; pub mod favicon;
pub mod javascript; pub mod javascript;
pub mod preload;
pub mod stylesheet; pub mod stylesheet;
use crate::core::component::Context; use crate::core::component::Context;

200
src/html/assets/preload.rs Normal file
View file

@ -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 `<link rel="preload" as="font" type="font/woff2" href="..." crossorigin>`.
///
/// 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<CowStr>) -> 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 `<link rel="preload" as="style" href="...">`.
pub fn style(path: impl Into<CowStr>) -> Self {
Self {
path: path.into(),
as_type: AsType::Style,
..Default::default()
}
}
/// Precarga un script JavaScript.
///
/// Equivale a `<link rel="preload" as="script" href="...">`.
pub fn script(path: impl Into<CowStr>) -> Self {
Self {
path: path.into(),
as_type: AsType::Script,
..Default::default()
}
}
/// Precarga una imagen.
///
/// Equivale a `<link rel="preload" as="image" href="...">`. 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<CowStr>) -> Self {
Self {
path: path.into(),
as_type: AsType::Image,
..Default::default()
}
}
/// Precarga una imagen en formato WebP.
///
/// Equivale a `<link rel="preload" as="image" type="image/webp" href="...">`.
///
/// El atributo `type` indica al navegador que omita la descarga si no soporta WebP.
pub fn webp(path: impl Into<CowStr>) -> Self {
Self {
path: path.into(),
as_type: AsType::Image,
mime_type: "image/webp".into(),
..Default::default()
}
}
/// Precarga una imagen en formato AVIF.
///
/// Equivale a `<link rel="preload" as="image" type="image/avif" href="...">`.
///
/// El atributo `type` indica al navegador que omita la descarga si no soporta AVIF.
pub fn avif(path: impl Into<CowStr>) -> 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<CowStr>) -> 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<CowStr>) -> 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];
}
}
}