webserver_base/assets/
generate.rs1use std::collections::BTreeMap;
21use std::fs::{self, File};
22use std::io::Read;
23use std::path::{Path, PathBuf};
24
25use tracing::{debug, info};
26
27use super::error::CacheBusterError;
28use super::manifest::{Manifest, TYPESCRIPT_MODULE_PATH};
29
30pub const STATIC_DIRECTORY: &str = "static";
36
37pub const FAVICON_DIRECTORY: &str = "static/image/favicon";
39
40pub const FAVICON_SVG_SOURCE: &str = "static/image/favicon/favicon.svg";
42
43pub const FAVICON_PNG_SOURCE: &str = "static/image/favicon/favicon-512.png";
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Phase {
54 NonScripts,
57 Scripts,
60}
61
62impl Phase {
63 #[must_use]
65 pub fn from_subcommand(subcommand: &str) -> Option<Self> {
66 match subcommand {
67 "gen-static-assets" => Some(Self::NonScripts),
68 "gen-static-scripts" => Some(Self::Scripts),
69 _ => None,
70 }
71 }
72
73 fn owns(self, path: &Path) -> bool {
75 let is_script: bool = path.starts_with("static/script");
76 match self {
77 Self::NonScripts => !is_script,
78 Self::Scripts => is_script,
79 }
80 }
81}
82
83pub fn generate_static_assets(phase: Phase) -> Result<(), CacheBusterError> {
91 generate_static_assets_in(Path::new(""), phase)
92}
93
94pub(crate) fn generate_static_assets_in(root: &Path, phase: Phase) -> Result<(), CacheBusterError> {
96 let static_directory: PathBuf = root.join(STATIC_DIRECTORY);
97 if !static_directory.is_dir() {
98 return Err(CacheBusterError::MissingStaticDirectory {
99 path: static_directory,
100 });
101 }
102
103 let mut manifest: Manifest = Manifest::load_or_empty_in(root)?;
106
107 if phase == Phase::NonScripts {
108 super::icons::generate_missing_icons_in(root, &manifest)?;
109 }
110
111 manifest.extend(hash_tree(root, phase)?);
112 manifest.write_json_in(root)?;
113
114 if phase == Phase::NonScripts {
115 manifest.write_typescript_in(root)?;
116 info!(
117 "hashed {} static asset(s); wrote the manifest and {TYPESCRIPT_MODULE_PATH}",
118 manifest.len()
119 );
120 } else {
121 info!(
122 "hashed the built scripts; manifest now holds {} entries",
123 manifest.len()
124 );
125 }
126
127 Ok(())
128}
129
130fn hash_tree(root: &Path, phase: Phase) -> Result<BTreeMap<String, String>, CacheBusterError> {
137 let static_directory: PathBuf = root.join(STATIC_DIRECTORY);
138 let mut cache: BTreeMap<String, String> = BTreeMap::new();
139 let mut directories: Vec<PathBuf> = vec![static_directory.clone()];
140
141 while let Some(directory) = directories.pop() {
142 let entries =
143 fs::read_dir(&directory).map_err(|source| CacheBusterError::ReadDirectory {
144 path: directory.clone(),
145 source,
146 })?;
147
148 for entry in entries {
149 let entry: std::fs::DirEntry =
150 entry.map_err(|source| CacheBusterError::ReadDirectory {
151 path: directory.clone(),
152 source,
153 })?;
154 let path: PathBuf = entry.path();
155
156 if path.is_dir() {
157 directories.push(path);
158 continue;
159 }
160 let relative: &Path = path.strip_prefix(root).unwrap_or(&path);
161 if !phase.owns(relative) {
162 continue;
163 }
164 if is_content_hashed(&path) {
168 debug!("`{}` is already hashed; leaving it alone", path.display());
169 continue;
170 }
171
172 let hashed: PathBuf = content_hashed_path(&path, &static_directory)?;
173 fs::rename(&path, &hashed).map_err(|source| CacheBusterError::Rename {
174 from: path.clone(),
175 to: hashed.clone(),
176 source,
177 })?;
178
179 cache.insert(
180 relative.to_string_lossy().to_string(),
181 hashed
182 .strip_prefix(root)
183 .unwrap_or(&hashed)
184 .to_string_lossy()
185 .to_string(),
186 );
187 }
188 }
189
190 Ok(cache)
191}
192
193fn is_content_hashed(path: &Path) -> bool {
195 path.file_name()
196 .and_then(|name| name.to_str())
197 .is_some_and(|name| {
198 name.split('.').any(|segment| {
199 segment.len() == 32 && segment.bytes().all(|byte| byte.is_ascii_hexdigit())
200 })
201 })
202}
203
204fn content_hashed_path(file_path: &Path, root: &Path) -> Result<PathBuf, CacheBusterError> {
207 let mut file: File = File::open(file_path).map_err(|source| CacheBusterError::ReadFile {
208 path: file_path.to_path_buf(),
209 source,
210 })?;
211 let mut contents: Vec<u8> = Vec::new();
212 file.read_to_end(&mut contents)
213 .map_err(|source| CacheBusterError::ReadFile {
214 path: file_path.to_path_buf(),
215 source,
216 })?;
217
218 let hash: String = format!("{:x}", md5::compute(contents));
219
220 let relative: &Path = file_path.strip_prefix(root).unwrap_or(file_path);
221 let parent: &Path = relative.parent().unwrap_or_else(|| Path::new(""));
222 let name: &str = relative
223 .file_name()
224 .and_then(|name| name.to_str())
225 .unwrap_or_default();
226
227 let hashed_name: String = match name.split_once('.') {
228 Some((stem, extension)) => format!("{stem}.{hash}.{extension}"),
229 None => format!("{name}.{hash}"),
230 };
231
232 Ok(root.join(parent).join(hashed_name))
233}
234
235#[cfg(test)]
236mod tests {
237 use std::path::{Path, PathBuf};
238
239 use super::{Phase, is_content_hashed};
240
241 #[test]
242 fn an_already_hashed_file_is_recognised_so_it_is_never_hashed_twice() {
243 let hashed: PathBuf =
244 PathBuf::from("static/stylesheet/main.aa676972bbd2b68e94ef8e91e81d20be.css");
245
246 let expected: bool = true;
247 let actual: bool = is_content_hashed(&hashed);
248 assert_eq!(expected, actual);
249 }
250
251 #[test]
252 fn a_plain_file_is_not_mistaken_for_a_hashed_one() {
253 let expected: bool = false;
254 let actual: bool = is_content_hashed(Path::new("static/stylesheet/main.css"));
255 assert_eq!(expected, actual);
256 }
257
258 #[test]
259 fn a_long_but_non_hex_segment_is_not_a_hash() {
260 let path: PathBuf = PathBuf::from("static/zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz.css");
262
263 let expected: bool = false;
264 let actual: bool = is_content_hashed(&path);
265 assert_eq!(expected, actual);
266 }
267
268 #[test]
269 fn a_source_map_keeps_its_double_extension() {
270 let hashed: PathBuf =
271 super::content_hashed_path(Path::new("Cargo.toml"), Path::new(".")).expect("readable");
272 let name: &str = hashed.file_name().and_then(|n| n.to_str()).expect("named");
273
274 assert!(name.starts_with("Cargo."));
277 assert!(
278 std::path::Path::new(name)
279 .extension()
280 .is_some_and(|extension| extension.eq_ignore_ascii_case("toml"))
281 );
282 }
283
284 #[test]
285 fn each_phase_owns_a_disjoint_half_of_the_tree() {
286 let script: &Path = Path::new("static/script/main.js");
287 let image: &Path = Path::new("static/image/social/card.webp");
288
289 assert!(!Phase::NonScripts.owns(script));
290 assert!(Phase::NonScripts.owns(image));
291 assert!(Phase::Scripts.owns(script));
292 assert!(!Phase::Scripts.owns(image));
293 }
294
295 #[test]
296 fn the_subcommands_map_to_their_phases() {
297 assert_eq!(
298 Some(Phase::NonScripts),
299 Phase::from_subcommand("gen-static-assets")
300 );
301 assert_eq!(
302 Some(Phase::Scripts),
303 Phase::from_subcommand("gen-static-scripts")
304 );
305 assert_eq!(None, Phase::from_subcommand("serve"));
306 }
307}