Deduplicate included assets

This commit is contained in:
Quentin Gliech
2025-12-17 15:53:00 +01:00
parent d523715473
commit 579304e53a
3 changed files with 136 additions and 87 deletions

View File

@@ -10,4 +10,4 @@
mod vite;
pub use self::vite::Manifest as ViteManifest;
pub use self::vite::{FileType, Manifest as ViteManifest};

View File

@@ -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 `<link rel="preload">` 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#"<link rel="preload" href="{href}" as="style" crossorigin {integrity}/>"#)
}
FileType::Script => {
format!(r#"<link rel="modulepreload" href="{href}" crossorigin {integrity}/>"#)
}
FileType::Woff | FileType::Woff2 => {
format!(r#"<link rel="preload" href="{href}" as="font" crossorigin {integrity}/>"#,)
}
FileType::Json => {
format!(r#"<link rel="preload" href="{href}" as="fetch" crossorigin {integrity}/>"#,)
}
FileType::Png => {
format!(r#"<link rel="preload" href="{href}" as="image" crossorigin {integrity}/>"#,)
}
}
}
/// Generate a `<link>` or `<script>` tag to include this entry
pub fn include_tag(&self, assets_base: &Utf8Path) -> Option<String> {
let src = self.src(assets_base);
let integrity = self
.integrity
.map(|i| format!(r#"integrity="{i}" "#))
.unwrap_or_default();
match self.file_type {
FileType::Stylesheet => Some(format!(
r#"<link rel="stylesheet" href="{src}" crossorigin {integrity}/>"#
)),
FileType::Script => Some(format!(
r#"<script type="module" src="{src}" crossorigin {integrity}></script>"#
)),
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()
}
}

View File

@@ -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<T: chrono::Timelike> mas_i18n::icu_datetime::input::IsoTimeInput for TimeAd
}
}
#[derive(Default, Debug)]
struct IncludedAssetsTrackerInner {
preloaded: HashSet<Utf8PathBuf>,
included: HashSet<Utf8PathBuf>,
}
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<IncludedAssetsTrackerInner>,
}
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<Self>, _state: &State, args: &[Value]) -> Result<Value, Error> {
fn call(self: &Arc<Self>, state: &State, args: &[Value]) -> Result<Value, Error> {
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#"<script type="module" src="{src}" crossorigin{integrity}></script>"#
)
.unwrap();
}
}
mas_spa::FileType::Stylesheet => {
let integrity = main.integrity_attr();
let src = main.src(assets_base);
if tracker.mark_included(&src) {
writeln!(
output,
r#"<link rel="stylesheet" href="{src}" crossorigin{integrity} />"#
)
.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<String> = 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#"<link rel="stylesheet" href="{src}" crossorigin{integrity} />"#
)
.unwrap();
}
}
mas_spa::FileType::Script => {
if tracker.mark_preloaded(&src) {
writeln!(
output,
r#"<link rel="modulepreload" href="{src}" crossorigin{integrity} />"#,
)
.unwrap();
}
}
mas_spa::FileType::Png => {
if tracker.mark_preloaded(&src) {
writeln!(
output,
r#"<link rel="preload" href="{src}" as="image" fetchpriority="low" crossorigin{integrity} />"#,
)
.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()))
}
}