typstify_generator/
assets.rs1use std::{
6 collections::HashMap,
7 fs,
8 io::Read,
9 path::{Path, PathBuf},
10};
11
12use thiserror::Error;
13use tracing::{debug, info};
14
15#[derive(Debug, Error)]
17pub enum AssetError {
18 #[error("IO error: {0}")]
20 Io(#[from] std::io::Error),
21
22 #[error("invalid asset path: {0}")]
24 InvalidPath(PathBuf),
25}
26
27pub type Result<T> = std::result::Result<T, AssetError>;
29
30#[derive(Debug, Clone, Default)]
32pub struct AssetManifest {
33 assets: HashMap<String, String>,
35}
36
37impl AssetManifest {
38 #[must_use]
40 pub fn new() -> Self {
41 Self::default()
42 }
43
44 pub fn add(&mut self, original: impl Into<String>, fingerprinted: impl Into<String>) {
46 self.assets.insert(original.into(), fingerprinted.into());
47 }
48
49 #[must_use]
51 pub fn get(&self, original: &str) -> Option<&str> {
52 self.assets.get(original).map(String::as_str)
53 }
54
55 #[must_use]
57 pub fn assets(&self) -> &HashMap<String, String> {
58 &self.assets
59 }
60
61 pub fn to_json(&self) -> String {
63 let mut json = String::from("{\n");
64 let entries: Vec<_> = self.assets.iter().collect();
65 for (i, (orig, fp)) in entries.iter().enumerate() {
66 json.push_str(&format!(r#" "{orig}": "{fp}""#));
67 if i < entries.len() - 1 {
68 json.push(',');
69 }
70 json.push('\n');
71 }
72 json.push('}');
73 json
74 }
75}
76
77#[derive(Debug)]
79pub struct AssetProcessor {
80 fingerprint: bool,
82
83 fingerprint_extensions: Vec<String>,
85}
86
87impl AssetProcessor {
88 #[must_use]
90 pub fn new(fingerprint: bool) -> Self {
91 Self {
92 fingerprint,
93 fingerprint_extensions: vec![
94 "css".to_string(),
95 "js".to_string(),
96 "woff".to_string(),
97 "woff2".to_string(),
98 "png".to_string(),
99 "jpg".to_string(),
100 "jpeg".to_string(),
101 "gif".to_string(),
102 "svg".to_string(),
103 "webp".to_string(),
104 ],
105 }
106 }
107
108 #[must_use]
110 pub fn with_fingerprint_extensions(mut self, extensions: Vec<String>) -> Self {
111 self.fingerprint_extensions = extensions;
112 self
113 }
114
115 pub fn process(&self, source_dir: &Path, dest_dir: &Path) -> Result<AssetManifest> {
117 info!(
118 source = %source_dir.display(),
119 dest = %dest_dir.display(),
120 "processing assets"
121 );
122
123 let mut manifest = AssetManifest::new();
124
125 if !source_dir.exists() {
126 debug!("source directory does not exist, skipping");
127 return Ok(manifest);
128 }
129
130 self.process_dir(source_dir, source_dir, dest_dir, &mut manifest)?;
131
132 info!(count = manifest.assets.len(), "assets processed");
133 Ok(manifest)
134 }
135
136 fn process_dir(
138 &self,
139 base_dir: &Path,
140 current_dir: &Path,
141 dest_base: &Path,
142 manifest: &mut AssetManifest,
143 ) -> Result<()> {
144 for entry in fs::read_dir(current_dir)? {
145 let entry = entry?;
146 let path = entry.path();
147
148 if path
150 .file_name()
151 .is_some_and(|n| n.to_string_lossy().starts_with('.'))
152 {
153 continue;
154 }
155
156 if path.is_dir() {
157 self.process_dir(base_dir, &path, dest_base, manifest)?;
158 } else if path.is_file() {
159 self.process_file(base_dir, &path, dest_base, manifest)?;
160 }
161 }
162
163 Ok(())
164 }
165
166 fn process_file(
168 &self,
169 base_dir: &Path,
170 file_path: &Path,
171 dest_base: &Path,
172 manifest: &mut AssetManifest,
173 ) -> Result<()> {
174 let relative = file_path
175 .strip_prefix(base_dir)
176 .map_err(|_| AssetError::InvalidPath(file_path.to_path_buf()))?;
177
178 let should_fingerprint = self.fingerprint
179 && file_path.extension().is_some_and(|ext| {
180 self.fingerprint_extensions
181 .contains(&ext.to_string_lossy().to_string())
182 });
183
184 let dest_relative = if should_fingerprint {
185 let hash = self.compute_hash(file_path)?;
186 let stem = file_path.file_stem().unwrap_or_default().to_string_lossy();
187 let ext = file_path.extension().unwrap_or_default().to_string_lossy();
188
189 let fingerprinted_name = format!("{stem}.{hash}.{ext}");
190 let parent = relative.parent().unwrap_or(Path::new(""));
191 parent.join(&fingerprinted_name)
192 } else {
193 relative.to_path_buf()
194 };
195
196 let dest_path = dest_base.join(&dest_relative);
197
198 if let Some(parent) = dest_path.parent() {
200 fs::create_dir_all(parent)?;
201 }
202
203 fs::copy(file_path, &dest_path)?;
205
206 let orig_path = format!("/{}", relative.display()).replace('\\', "/");
208 let dest_path_str = format!("/{}", dest_relative.display()).replace('\\', "/");
209 manifest.add(orig_path, dest_path_str);
210
211 debug!(
212 src = %file_path.display(),
213 dest = %dest_path.display(),
214 "copied asset"
215 );
216
217 Ok(())
218 }
219
220 fn compute_hash(&self, path: &Path) -> Result<String> {
222 let mut file = fs::File::open(path)?;
223 let mut buffer = Vec::new();
224 file.read_to_end(&mut buffer)?;
225
226 let mut hash: u64 = 0xcbf29ce484222325;
228 for byte in &buffer {
229 hash ^= u64::from(*byte);
230 hash = hash.wrapping_mul(0x100000001b3);
231 }
232
233 Ok(format!("{hash:016x}")[..8].to_string())
235 }
236
237 pub fn copy_file(source: &Path, dest: &Path) -> Result<()> {
239 if let Some(parent) = dest.parent() {
240 fs::create_dir_all(parent)?;
241 }
242 fs::copy(source, dest)?;
243 Ok(())
244 }
245
246 pub fn ensure_dir(path: &Path) -> Result<()> {
248 if !path.exists() {
249 fs::create_dir_all(path)?;
250 }
251 Ok(())
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use std::io::Write;
258
259 use tempfile::TempDir;
260
261 use super::*;
262
263 #[test]
264 fn test_asset_manifest() {
265 let mut manifest = AssetManifest::new();
266 manifest.add("/css/style.css", "/css/style.abc12345.css");
267 manifest.add("/js/main.js", "/js/main.def67890.js");
268
269 assert_eq!(
270 manifest.get("/css/style.css"),
271 Some("/css/style.abc12345.css")
272 );
273 assert_eq!(manifest.get("/js/main.js"), Some("/js/main.def67890.js"));
274 assert!(manifest.get("/other.txt").is_none());
275 }
276
277 #[test]
278 fn test_manifest_to_json() {
279 let mut manifest = AssetManifest::new();
280 manifest.add("/style.css", "/style.abc.css");
281
282 let json = manifest.to_json();
283 assert!(json.contains(r#""/style.css": "/style.abc.css""#));
284 }
285
286 #[test]
287 fn test_process_assets() {
288 let source = TempDir::new().unwrap();
289 let dest = TempDir::new().unwrap();
290
291 let css_path = source.path().join("style.css");
293 let mut css_file = fs::File::create(&css_path).unwrap();
294 css_file.write_all(b"body { color: red; }").unwrap();
295
296 let txt_path = source.path().join("readme.txt");
297 let mut txt_file = fs::File::create(&txt_path).unwrap();
298 txt_file.write_all(b"Hello world").unwrap();
299
300 let processor = AssetProcessor::new(false);
302 let manifest = processor.process(source.path(), dest.path()).unwrap();
303
304 assert!(dest.path().join("style.css").exists());
305 assert!(dest.path().join("readme.txt").exists());
306 assert_eq!(manifest.assets().len(), 2);
307 }
308
309 #[test]
310 fn test_process_with_fingerprinting() {
311 let source = TempDir::new().unwrap();
312 let dest = TempDir::new().unwrap();
313
314 let css_path = source.path().join("style.css");
316 let mut css_file = fs::File::create(&css_path).unwrap();
317 css_file.write_all(b"body { color: blue; }").unwrap();
318
319 let processor = AssetProcessor::new(true);
321 let manifest = processor.process(source.path(), dest.path()).unwrap();
322
323 let fingerprinted = manifest.get("/style.css").unwrap();
325 assert!(fingerprinted.starts_with("/style."));
326 assert!(fingerprinted.ends_with(".css"));
327 assert!(fingerprinted.len() > "/style.css".len());
328 }
329
330 #[test]
331 fn test_compute_hash_deterministic() {
332 let dir = TempDir::new().unwrap();
333 let path = dir.path().join("test.txt");
334 let mut file = fs::File::create(&path).unwrap();
335 file.write_all(b"test content").unwrap();
336 drop(file);
337
338 let processor = AssetProcessor::new(true);
339 let hash1 = processor.compute_hash(&path).unwrap();
340 let hash2 = processor.compute_hash(&path).unwrap();
341
342 assert_eq!(hash1, hash2);
343 assert_eq!(hash1.len(), 8);
344 }
345
346 #[test]
347 fn test_ensure_dir() {
348 let dir = TempDir::new().unwrap();
349 let nested = dir.path().join("a/b/c");
350
351 assert!(!nested.exists());
352 AssetProcessor::ensure_dir(&nested).unwrap();
353 assert!(nested.exists());
354 }
355}