webserver_base/assets/
manifest.rs1use std::collections::BTreeMap;
15use std::fmt::Write as _;
16use std::fs::File;
17use std::io::BufWriter;
18use std::path::{Path, PathBuf};
19
20use super::error::CacheBusterError;
21
22pub const MANIFEST_PATH: &str = "cache-buster.json";
24
25pub const TYPESCRIPT_MODULE_PATH: &str = "static/script/generated/cache-buster.ts";
27
28#[derive(Debug, Clone, Default, PartialEq, Eq)]
30pub struct Manifest {
31 entries: BTreeMap<String, String>,
32}
33
34impl Manifest {
35 pub fn load_or_empty() -> Result<Self, CacheBusterError> {
42 Self::load_or_empty_in(Path::new(""))
43 }
44
45 pub(crate) fn load_or_empty_in(root: &Path) -> Result<Self, CacheBusterError> {
47 if !root.join(MANIFEST_PATH).is_file() {
48 return Ok(Self::default());
49 }
50 Self::load_in(root)
51 }
52
53 pub fn load() -> Result<Self, CacheBusterError> {
60 Self::load_in(Path::new(""))
61 }
62
63 pub(crate) fn load_in(root: &Path) -> Result<Self, CacheBusterError> {
65 let path: PathBuf = root.join(MANIFEST_PATH);
66 let contents: String =
67 std::fs::read_to_string(&path).map_err(|source| CacheBusterError::ReadFile {
68 path: path.clone(),
69 source,
70 })?;
71 let entries: BTreeMap<String, String> = serde_json::from_str(&contents)
72 .map_err(|source| CacheBusterError::ParseManifest { path, source })?;
73 Ok(Self { entries })
74 }
75
76 pub fn extend(&mut self, entries: BTreeMap<String, String>) {
78 self.entries.extend(entries);
79 }
80
81 #[must_use]
83 pub fn len(&self) -> usize {
84 self.entries.len()
85 }
86
87 #[must_use]
89 pub fn is_empty(&self) -> bool {
90 self.entries.is_empty()
91 }
92
93 #[must_use]
95 pub const fn entries(&self) -> &BTreeMap<String, String> {
96 &self.entries
97 }
98
99 #[must_use]
101 pub fn into_entries(self) -> BTreeMap<String, String> {
102 self.entries
103 }
104
105 #[must_use]
108 pub fn resolve<'a>(&'a self, original: &'a str) -> &'a str {
109 let key: &str = original.trim_start_matches('/');
110 self.entries.get(key).map_or(original, String::as_str)
111 }
112
113 #[must_use]
115 pub fn contains(&self, original: &str) -> bool {
116 self.entries.contains_key(original.trim_start_matches('/'))
117 }
118
119 pub fn write_json(&self) -> Result<(), CacheBusterError> {
125 self.write_json_in(Path::new(""))
126 }
127
128 pub(crate) fn write_json_in(&self, root: &Path) -> Result<(), CacheBusterError> {
130 let path: PathBuf = root.join(MANIFEST_PATH);
131 let file: File = File::create(&path).map_err(|source| CacheBusterError::WriteManifest {
132 path: path.clone(),
133 source,
134 })?;
135 serde_json::to_writer_pretty(BufWriter::new(file), &self.entries).map_err(|source| {
136 CacheBusterError::WriteManifest {
137 path,
138 source: std::io::Error::other(source),
139 }
140 })
141 }
142
143 pub fn write_typescript(&self) -> Result<(), CacheBusterError> {
153 self.write_typescript_in(Path::new(""))
154 }
155
156 pub(crate) fn write_typescript_in(&self, root: &Path) -> Result<(), CacheBusterError> {
158 let path: PathBuf = root.join(TYPESCRIPT_MODULE_PATH);
159 if let Some(parent) = path.parent() {
160 std::fs::create_dir_all(parent).map_err(|source| CacheBusterError::WriteManifest {
161 path: parent.to_path_buf(),
162 source,
163 })?;
164 }
165
166 let mut source: String = String::from(
167 "// Generated by `gen_static_assets`. Do not edit, do not commit.\n\n\
168 /**\n\
169 \x20* Maps a logical static-asset path to its content-hashed one. The build\n\
170 \x20* inlines this, so resolving an asset costs the browser nothing at\n\
171 \x20* runtime.\n\
172 \x20*/\n\
173 export const CACHE_BUSTER = {\n",
174 );
175 for (original, hashed) in &self.entries {
176 let _ = writeln!(source, " {original:?}: {hashed:?},");
178 }
179 source.push_str(
180 "} as const;\n\n\
181 /** Every asset path the build knows about. */\n\
182 export type CacheBustedPath = keyof typeof CACHE_BUSTER;\n\n\
183 /** Resolves a static asset to its root-absolute, hashed URL. */\n\
184 export function asset(path: CacheBustedPath): string {\n\
185 \x20 return `/${CACHE_BUSTER[path]}`;\n\
186 }\n",
187 );
188
189 std::fs::write(&path, source)
190 .map_err(|source| CacheBusterError::WriteManifest { path, source })
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use std::collections::BTreeMap;
197
198 use super::Manifest;
199
200 fn manifest() -> Manifest {
201 let mut entries: BTreeMap<String, String> = BTreeMap::new();
202 entries.insert(
203 String::from("static/stylesheet/main.css"),
204 String::from("static/stylesheet/main.abc123.css"),
205 );
206 Manifest { entries }
207 }
208
209 #[test]
210 fn a_known_asset_resolves_to_its_hashed_path() {
211 let manifest: Manifest = manifest();
212
213 let expected: &str = "static/stylesheet/main.abc123.css";
214 let actual: &str = manifest.resolve("static/stylesheet/main.css");
215 assert_eq!(expected, actual);
216 }
217
218 #[test]
219 fn a_leading_slash_still_resolves() {
220 let manifest: Manifest = manifest();
221
222 let expected: &str = "static/stylesheet/main.abc123.css";
223 let actual: &str = manifest.resolve("/static/stylesheet/main.css");
224 assert_eq!(expected, actual);
225 }
226
227 #[test]
228 fn an_unknown_asset_is_returned_unchanged() {
229 let manifest: Manifest = manifest();
230
231 let expected: &str = "https://cdn.example.com/a.css";
232 let actual: &str = manifest.resolve("https://cdn.example.com/a.css");
233 assert_eq!(expected, actual);
234 }
235
236 #[test]
237 fn extending_replaces_an_existing_entry_rather_than_duplicating_it() {
238 let mut manifest: Manifest = manifest();
239 let mut second: BTreeMap<String, String> = BTreeMap::new();
240 second.insert(
241 String::from("static/stylesheet/main.css"),
242 String::from("static/stylesheet/main.def456.css"),
243 );
244 manifest.extend(second);
245
246 let expected: usize = 1;
247 let actual: usize = manifest.len();
248 assert_eq!(expected, actual);
249
250 let expected: &str = "static/stylesheet/main.def456.css";
251 let actual: &str = manifest.resolve("static/stylesheet/main.css");
252 assert_eq!(expected, actual);
253 }
254}