From 579304e53aadedc5dd31e10ed49e23fcb683bf14 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 17 Dec 2025 15:53:00 +0100 Subject: [PATCH] Deduplicate included assets --- crates/spa/src/lib.rs | 2 +- crates/spa/src/vite.rs | 83 +++--------------- crates/templates/src/functions.rs | 138 ++++++++++++++++++++++++++---- 3 files changed, 136 insertions(+), 87 deletions(-) diff --git a/crates/spa/src/lib.rs b/crates/spa/src/lib.rs index af3be8def..7e38f977b 100644 --- a/crates/spa/src/lib.rs +++ b/crates/spa/src/lib.rs @@ -10,4 +10,4 @@ mod vite; -pub use self::vite::Manifest as ViteManifest; +pub use self::vite::{FileType, Manifest as ViteManifest}; diff --git a/crates/spa/src/vite.rs b/crates/spa/src/vite.rs index b488bea6b..b2d7c0c2a 100644 --- a/crates/spa/src/vite.rs +++ b/crates/spa/src/vite.rs @@ -48,7 +48,7 @@ pub struct Manifest { } #[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] -enum FileType { +pub enum FileType { Script, Stylesheet, Woff, @@ -104,83 +104,24 @@ impl<'a> Asset<'a> { }) } - fn src(&self, assets_base: &Utf8Path) -> Utf8PathBuf { + /// Get the source path of this asset, relative to the assets base path + #[must_use] + pub fn src(&self, assets_base: &Utf8Path) -> Utf8PathBuf { assets_base.join(self.name) } - /// Generate a `` tag to preload this entry - pub fn preload_tag(&self, assets_base: &Utf8Path) -> String { - let href = self.src(assets_base); - let integrity = self - .integrity - .map(|i| format!(r#"integrity="{i}" "#)) - .unwrap_or_default(); - match self.file_type { - FileType::Stylesheet => { - format!(r#""#) - } - FileType::Script => { - format!(r#""#) - } - FileType::Woff | FileType::Woff2 => { - format!(r#""#,) - } - FileType::Json => { - format!(r#""#,) - } - FileType::Png => { - format!(r#""#,) - } - } - } - - /// Generate a `` or `"# - )), - FileType::Woff | FileType::Woff2 | FileType::Json | FileType::Png => None, - } - } - - /// Returns `true` if the asset type is a script + /// Get the file type of this asset #[must_use] - pub fn is_script(&self) -> bool { - self.file_type == FileType::Script + pub fn file_type(&self) -> FileType { + self.file_type } - /// Returns `true` if the asset type is a stylesheet + /// Get the integrity HTML tag attribute, with a leading space, if any #[must_use] - pub fn is_stylesheet(&self) -> bool { - self.file_type == FileType::Stylesheet - } - - /// Returns `true` if the asset type is JSON - #[must_use] - pub fn is_json(&self) -> bool { - self.file_type == FileType::Json - } - - /// Returns `true` if the asset type is a font - #[must_use] - pub fn is_font(&self) -> bool { - self.file_type == FileType::Woff || self.file_type == FileType::Woff2 - } - - /// Returns `true` if the asset type is image - #[must_use] - pub fn is_image(&self) -> bool { - self.file_type == FileType::Png + pub fn integrity_attr(&self) -> String { + self.integrity + .map(|i| format!(r#" integrity="{i}""#)) + .unwrap_or_default() } } diff --git a/crates/templates/src/functions.rs b/crates/templates/src/functions.rs index 3a8e3d43d..2ca7b12f6 100644 --- a/crates/templates/src/functions.rs +++ b/crates/templates/src/functions.rs @@ -10,13 +10,13 @@ //! Additional functions, tests and filters used in templates use std::{ - collections::{BTreeMap, HashMap}, - fmt::Formatter, + collections::{BTreeMap, HashMap, HashSet}, + fmt::{Formatter, Write as _}, str::FromStr, - sync::{Arc, atomic::AtomicUsize}, + sync::{Arc, Mutex, atomic::AtomicUsize}, }; -use camino::Utf8Path; +use camino::{Utf8Path, Utf8PathBuf}; use mas_i18n::{Argument, ArgumentList, DataLocale, Translator, sprintf::FormattedMessagePart}; use mas_router::UrlBuilder; use mas_spa::ViteManifest; @@ -419,6 +419,41 @@ impl mas_i18n::icu_datetime::input::IsoTimeInput for TimeAd } } +#[derive(Default, Debug)] +struct IncludedAssetsTrackerInner { + preloaded: HashSet, + included: HashSet, +} + +impl IncludedAssetsTrackerInner { + /// Mark an asset as preloaded. Returns true if it was not already marked. + fn mark_preloaded(&mut self, asset: &Utf8Path) -> bool { + self.preloaded.insert(asset.to_owned()) + } + + /// Mark an asset as included. Returns true if it was not already marked. + fn mark_included(&mut self, asset: &Utf8Path) -> bool { + self.preloaded.insert(asset.to_owned()); + self.included.insert(asset.to_owned()) + } +} + +/// Helper to track included assets during a template render +#[derive(Default, Debug)] +struct IncludedAssetsTracker { + inner: Mutex, +} + +impl IncludedAssetsTracker { + fn lock(&self) -> std::sync::MutexGuard<'_, IncludedAssetsTrackerInner> { + // There is no reason for this mutex to ever get poisoned, so it's fine + // to unwrap here + self.inner.lock().unwrap() + } +} + +impl Object for IncludedAssetsTracker {} + struct IncludeAsset { url_builder: UrlBuilder, vite_manifest: ViteManifest, @@ -440,11 +475,20 @@ impl std::fmt::Display for IncludeAsset { } impl Object for IncludeAsset { - fn call(self: &Arc, _state: &State, args: &[Value]) -> Result { + fn call(self: &Arc, state: &State, args: &[Value]) -> Result { let (path,): (&str,) = from_args(args)?; - let path: &Utf8Path = path.into(); + let assets_base: &Utf8Path = self.url_builder.assets_base().into(); + + // We store the list of assets we've already included and already preloaded in a + // 'temp' object. Those live throughout the template render and reset on each + // new render. + let tracker = + state.get_or_set_temp_object("included_assets_tracker", IncludedAssetsTracker::default); + let mut tracker = tracker.lock(); + + // Grab the main asset and its imports from the manifest let (main, imported) = self.vite_manifest.find_assets(path).map_err(|e| { Error::new( ErrorKind::InvalidOperation, @@ -452,18 +496,82 @@ impl Object for IncludeAsset { ) })?; - let assets = std::iter::once(main) - .chain(imported.iter().filter(|a| a.is_stylesheet()).copied()) - .filter_map(|asset| asset.include_tag(self.url_builder.assets_base().into())); + // We'll accumulate the output in this string + let mut output = String::new(); + match main.file_type() { + mas_spa::FileType::Script => { + let integrity = main.integrity_attr(); + let src = main.src(assets_base); + if tracker.mark_included(&src) { + writeln!( + output, + r#""# + ) + .unwrap(); + } + } + mas_spa::FileType::Stylesheet => { + let integrity = main.integrity_attr(); + let src = main.src(assets_base); + if tracker.mark_included(&src) { + writeln!( + output, + r#""# + ) + .unwrap(); + } + } - let preloads = imported - .iter() - .filter(|a| a.is_script()) - .map(|asset| asset.preload_tag(self.url_builder.assets_base().into())); + file_type => { + return Err(Error::new( + ErrorKind::InvalidOperation, + format!( + "The target asset is a {file_type:?} file, which is not supported by `include_asset`" + ), + )); + } + } - let tags: Vec = preloads.chain(assets).collect(); + for asset in imported { + let integrity = asset.integrity_attr(); + let src = asset.src(assets_base); + match asset.file_type() { + mas_spa::FileType::Stylesheet => { + // Imported stylesheets are inserted directly, not just preloaded + if tracker.mark_included(&src) { + writeln!( + output, + r#""# + ) + .unwrap(); + } + } + mas_spa::FileType::Script => { + if tracker.mark_preloaded(&src) { + writeln!( + output, + r#""#, + ) + .unwrap(); + } + } + mas_spa::FileType::Png => { + if tracker.mark_preloaded(&src) { + writeln!( + output, + r#""#, + ) + .unwrap(); + } + } + mas_spa::FileType::Woff | mas_spa::FileType::Woff2 | mas_spa::FileType::Json => { + // Skip pre-loading fonts and JSON (translations) as it will + // lead to many wasted preloads. + } + } + } - Ok(Value::from_safe_string(tags.join("\n"))) + Ok(Value::from_safe_string(output.trim_end().to_owned())) } }