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 let path: &Path = Path::new(MANIFEST_PATH);
43 if !path.is_file() {
44 return Ok(Self::default());
45 }
46 Self::load()
47 }
48
49 pub fn load() -> Result<Self, CacheBusterError> {
56 let path: PathBuf = PathBuf::from(MANIFEST_PATH);
57 let contents: String =
58 std::fs::read_to_string(&path).map_err(|source| CacheBusterError::ReadFile {
59 path: path.clone(),
60 source,
61 })?;
62 let entries: BTreeMap<String, String> = serde_json::from_str(&contents)
63 .map_err(|source| CacheBusterError::ParseManifest { path, source })?;
64 Ok(Self { entries })
65 }
66
67 pub fn extend(&mut self, entries: BTreeMap<String, String>) {
69 self.entries.extend(entries);
70 }
71
72 #[must_use]
74 pub fn len(&self) -> usize {
75 self.entries.len()
76 }
77
78 #[must_use]
80 pub fn is_empty(&self) -> bool {
81 self.entries.is_empty()
82 }
83
84 #[must_use]
86 pub const fn entries(&self) -> &BTreeMap<String, String> {
87 &self.entries
88 }
89
90 #[must_use]
92 pub fn into_entries(self) -> BTreeMap<String, String> {
93 self.entries
94 }
95
96 #[must_use]
99 pub fn resolve<'a>(&'a self, original: &'a str) -> &'a str {
100 let key: &str = original.trim_start_matches('/');
101 self.entries.get(key).map_or(original, String::as_str)
102 }
103
104 #[must_use]
106 pub fn contains(&self, original: &str) -> bool {
107 self.entries.contains_key(original.trim_start_matches('/'))
108 }
109
110 pub fn write_json(&self) -> Result<(), CacheBusterError> {
116 let path: PathBuf = PathBuf::from(MANIFEST_PATH);
117 let file: File = File::create(&path).map_err(|source| CacheBusterError::WriteManifest {
118 path: path.clone(),
119 source,
120 })?;
121 serde_json::to_writer_pretty(BufWriter::new(file), &self.entries).map_err(|source| {
122 CacheBusterError::WriteManifest {
123 path,
124 source: std::io::Error::other(source),
125 }
126 })
127 }
128
129 pub fn write_typescript(&self) -> Result<(), CacheBusterError> {
139 let path: PathBuf = PathBuf::from(TYPESCRIPT_MODULE_PATH);
140 if let Some(parent) = path.parent() {
141 std::fs::create_dir_all(parent).map_err(|source| CacheBusterError::WriteManifest {
142 path: parent.to_path_buf(),
143 source,
144 })?;
145 }
146
147 let mut source: String = String::from(
148 "// Generated by `gen_static_assets`. Do not edit, do not commit.\n\n\
149 /**\n\
150 \x20* Maps a logical static-asset path to its content-hashed one. The build\n\
151 \x20* inlines this, so resolving an asset costs the browser nothing at\n\
152 \x20* runtime.\n\
153 \x20*/\n\
154 export const CACHE_BUSTER = {\n",
155 );
156 for (original, hashed) in &self.entries {
157 let _ = writeln!(source, " {original:?}: {hashed:?},");
159 }
160 source.push_str(
161 "} as const;\n\n\
162 /** Every asset path the build knows about. */\n\
163 export type CacheBustedPath = keyof typeof CACHE_BUSTER;\n\n\
164 /** Resolves a static asset to its root-absolute, hashed URL. */\n\
165 export function asset(path: CacheBustedPath): string {\n\
166 \x20 return `/${CACHE_BUSTER[path]}`;\n\
167 }\n",
168 );
169
170 std::fs::write(&path, source)
171 .map_err(|source| CacheBusterError::WriteManifest { path, source })
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use std::collections::BTreeMap;
178
179 use super::Manifest;
180
181 fn manifest() -> Manifest {
182 let mut entries: BTreeMap<String, String> = BTreeMap::new();
183 entries.insert(
184 String::from("static/stylesheet/main.css"),
185 String::from("static/stylesheet/main.abc123.css"),
186 );
187 Manifest { entries }
188 }
189
190 #[test]
191 fn a_known_asset_resolves_to_its_hashed_path() {
192 let manifest: Manifest = manifest();
193
194 let expected: &str = "static/stylesheet/main.abc123.css";
195 let actual: &str = manifest.resolve("static/stylesheet/main.css");
196 assert_eq!(expected, actual);
197 }
198
199 #[test]
200 fn a_leading_slash_still_resolves() {
201 let manifest: Manifest = manifest();
202
203 let expected: &str = "static/stylesheet/main.abc123.css";
204 let actual: &str = manifest.resolve("/static/stylesheet/main.css");
205 assert_eq!(expected, actual);
206 }
207
208 #[test]
209 fn an_unknown_asset_is_returned_unchanged() {
210 let manifest: Manifest = manifest();
211
212 let expected: &str = "https://cdn.example.com/a.css";
213 let actual: &str = manifest.resolve("https://cdn.example.com/a.css");
214 assert_eq!(expected, actual);
215 }
216
217 #[test]
218 fn extending_replaces_an_existing_entry_rather_than_duplicating_it() {
219 let mut manifest: Manifest = manifest();
220 let mut second: BTreeMap<String, String> = BTreeMap::new();
221 second.insert(
222 String::from("static/stylesheet/main.css"),
223 String::from("static/stylesheet/main.def456.css"),
224 );
225 manifest.extend(second);
226
227 let expected: usize = 1;
228 let actual: usize = manifest.len();
229 assert_eq!(expected, actual);
230
231 let expected: &str = "static/stylesheet/main.def456.css";
232 let actual: &str = manifest.resolve("static/stylesheet/main.css");
233 assert_eq!(expected, actual);
234 }
235}