mas_spa/
vite.rs

1// Copyright 2024 New Vector Ltd.
2// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
3//
4// SPDX-License-Identifier: AGPL-3.0-only
5// Please see LICENSE in the repository root for full details.
6
7use std::collections::{BTreeSet, HashMap};
8
9use camino::{Utf8Path, Utf8PathBuf};
10use thiserror::Error;
11
12#[derive(serde::Deserialize, Debug, Clone)]
13#[serde(rename_all = "camelCase", deny_unknown_fields)]
14pub struct ManifestEntry {
15    #[allow(dead_code)]
16    name: Option<String>,
17
18    #[allow(dead_code)]
19    src: Option<Utf8PathBuf>,
20
21    file: Utf8PathBuf,
22
23    css: Option<Vec<Utf8PathBuf>>,
24
25    assets: Option<Vec<Utf8PathBuf>>,
26
27    #[allow(dead_code)]
28    is_entry: Option<bool>,
29
30    #[allow(dead_code)]
31    is_dynamic_entry: Option<bool>,
32
33    imports: Option<Vec<Utf8PathBuf>>,
34
35    #[allow(dead_code)]
36    dynamic_imports: Option<Vec<Utf8PathBuf>>,
37
38    integrity: Option<String>,
39}
40
41#[derive(serde::Deserialize, Debug, Clone)]
42pub struct Manifest {
43    #[serde(flatten)]
44    inner: HashMap<Utf8PathBuf, ManifestEntry>,
45}
46
47#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
48enum FileType {
49    Script,
50    Stylesheet,
51    Woff,
52    Woff2,
53    Json,
54    Png,
55}
56
57impl FileType {
58    fn from_name(name: &Utf8Path) -> Option<Self> {
59        match name.extension() {
60            Some("css") => Some(Self::Stylesheet),
61            Some("js") => Some(Self::Script),
62            Some("woff") => Some(Self::Woff),
63            Some("woff2") => Some(Self::Woff2),
64            Some("json") => Some(Self::Json),
65            Some("png") => Some(Self::Png),
66            _ => None,
67        }
68    }
69}
70
71#[derive(Debug, Error)]
72#[error("Invalid Vite manifest")]
73pub enum InvalidManifest<'a> {
74    #[error("Can't find asset for name {name:?}")]
75    CantFindAssetByName { name: &'a Utf8Path },
76
77    #[error("Can't find asset for file {file:?}")]
78    CantFindAssetByFile { file: &'a Utf8Path },
79
80    #[error("Invalid file type")]
81    InvalidFileType,
82}
83
84/// Represents an entry which should be preloaded and included
85#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
86pub struct Asset<'a> {
87    file_type: FileType,
88    name: &'a Utf8Path,
89    integrity: Option<&'a str>,
90}
91
92impl<'a> Asset<'a> {
93    fn new(entry: &'a ManifestEntry) -> Result<Self, InvalidManifest<'a>> {
94        let name = &entry.file;
95        let integrity = entry.integrity.as_deref();
96        let file_type = FileType::from_name(name).ok_or(InvalidManifest::InvalidFileType)?;
97        Ok(Self {
98            file_type,
99            name,
100            integrity,
101        })
102    }
103
104    fn src(&self, assets_base: &Utf8Path) -> Utf8PathBuf {
105        assets_base.join(self.name)
106    }
107
108    /// Generate a `<link rel="preload">` tag to preload this entry
109    pub fn preload_tag(&self, assets_base: &Utf8Path) -> String {
110        let href = self.src(assets_base);
111        let integrity = self
112            .integrity
113            .map(|i| format!(r#"integrity="{i}" "#))
114            .unwrap_or_default();
115        match self.file_type {
116            FileType::Stylesheet => {
117                format!(r#"<link rel="preload" href="{href}" as="style" crossorigin {integrity}/>"#)
118            }
119            FileType::Script => {
120                format!(r#"<link rel="modulepreload" href="{href}" crossorigin {integrity}/>"#)
121            }
122            FileType::Woff | FileType::Woff2 => {
123                format!(r#"<link rel="preload" href="{href}" as="font" crossorigin {integrity}/>"#,)
124            }
125            FileType::Json => {
126                format!(r#"<link rel="preload" href="{href}" as="fetch" crossorigin {integrity}/>"#,)
127            }
128            FileType::Png => {
129                format!(r#"<link rel="preload" href="{href}" as="image" crossorigin {integrity}/>"#,)
130            }
131        }
132    }
133
134    /// Generate a `<link>` or `<script>` tag to include this entry
135    pub fn include_tag(&self, assets_base: &Utf8Path) -> Option<String> {
136        let src = self.src(assets_base);
137        let integrity = self
138            .integrity
139            .map(|i| format!(r#"integrity="{i}" "#))
140            .unwrap_or_default();
141
142        match self.file_type {
143            FileType::Stylesheet => Some(format!(
144                r#"<link rel="stylesheet" href="{src}" crossorigin {integrity}/>"#
145            )),
146            FileType::Script => Some(format!(
147                r#"<script type="module" src="{src}" crossorigin {integrity}></script>"#
148            )),
149            FileType::Woff | FileType::Woff2 | FileType::Json | FileType::Png => None,
150        }
151    }
152
153    /// Returns `true` if the asset type is a script
154    #[must_use]
155    pub fn is_script(&self) -> bool {
156        self.file_type == FileType::Script
157    }
158
159    /// Returns `true` if the asset type is a stylesheet
160    #[must_use]
161    pub fn is_stylesheet(&self) -> bool {
162        self.file_type == FileType::Stylesheet
163    }
164
165    /// Returns `true` if the asset type is JSON
166    #[must_use]
167    pub fn is_json(&self) -> bool {
168        self.file_type == FileType::Json
169    }
170
171    /// Returns `true` if the asset type is a font
172    #[must_use]
173    pub fn is_font(&self) -> bool {
174        self.file_type == FileType::Woff || self.file_type == FileType::Woff2
175    }
176
177    /// Returns `true` if the asset type is image
178    #[must_use]
179    pub fn is_image(&self) -> bool {
180        self.file_type == FileType::Png
181    }
182}
183
184impl Manifest {
185    /// Find all assets which should be loaded for a given entrypoint
186    ///
187    /// Returns the main asset and all the assets it imports
188    ///
189    /// # Errors
190    ///
191    /// Returns an error if the entrypoint is invalid for this manifest
192    pub fn find_assets<'a>(
193        &'a self,
194        entrypoint: &'a Utf8Path,
195    ) -> Result<(Asset<'a>, BTreeSet<Asset<'a>>), InvalidManifest<'a>> {
196        let entry = self.lookup_by_name(entrypoint)?;
197        let mut entries = BTreeSet::new();
198        let main_asset = self.find_imported_chunks(entry, &mut entries)?;
199
200        // Remove the main asset from the set of imported entries. We had it mainly to
201        // deduplicate the list of assets, but we don't want to include it twice
202        entries.remove(&main_asset);
203
204        Ok((main_asset, entries))
205    }
206
207    /// Lookup an entry in the manifest by its original name
208    fn lookup_by_name<'a>(
209        &self,
210        name: &'a Utf8Path,
211    ) -> Result<&ManifestEntry, InvalidManifest<'a>> {
212        self.inner
213            .get(name)
214            .ok_or(InvalidManifest::CantFindAssetByName { name })
215    }
216
217    /// Lookup an entry in the manifest by its output name
218    fn lookup_by_file<'a>(
219        &self,
220        file: &'a Utf8Path,
221    ) -> Result<&ManifestEntry, InvalidManifest<'a>> {
222        self.inner
223            .values()
224            .find(|e| e.file == file)
225            .ok_or(InvalidManifest::CantFindAssetByFile { file })
226    }
227
228    fn find_imported_chunks<'a>(
229        &'a self,
230        current_entry: &'a ManifestEntry,
231        entries: &mut BTreeSet<Asset<'a>>,
232    ) -> Result<Asset<'a>, InvalidManifest<'a>> {
233        let asset = Asset::new(current_entry)?;
234        let inserted = entries.insert(asset);
235
236        // If we inserted the entry, we need to find its dependencies
237        if inserted {
238            if let Some(css) = &current_entry.css {
239                for file in css {
240                    let entry = self.lookup_by_file(file)?;
241                    self.find_imported_chunks(entry, entries)?;
242                }
243            }
244
245            if let Some(assets) = &current_entry.assets {
246                for file in assets {
247                    let entry = self.lookup_by_file(file)?;
248                    self.find_imported_chunks(entry, entries)?;
249                }
250            }
251
252            if let Some(imports) = &current_entry.imports {
253                for import in imports {
254                    let entry = self.lookup_by_name(import)?;
255                    self.find_imported_chunks(entry, entries)?;
256                }
257            }
258        }
259
260        Ok(asset)
261    }
262}