1use dashmap::DashMap;
12use mlua::{Lua, Result, Table};
13use sha2::{Digest, Sha256};
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16
17use crate::lua::async_io::{AsyncIOTask, runtime};
18use crate::tracker::SharedTracker;
19
20pub type AssetManifest = Arc<DashMap<String, String>>;
22
23pub fn create_manifest() -> AssetManifest {
25 Arc::new(DashMap::new())
26}
27
28pub fn compute_hash(content: &[u8], length: usize) -> String {
30 let mut hasher = Sha256::new();
31 hasher.update(content);
32 let result = hasher.finalize();
33 hex::encode(result)[..length.min(64)].to_string()
34}
35
36pub fn hashed_filename(original_path: &Path, hash: &str) -> PathBuf {
39 let stem = original_path
40 .file_stem()
41 .and_then(|s| s.to_str())
42 .unwrap_or("file");
43 let ext = original_path
44 .extension()
45 .and_then(|s| s.to_str())
46 .unwrap_or("");
47
48 let new_filename = if ext.is_empty() {
49 format!("{}.{}", stem, hash)
50 } else {
51 format!("{}.{}.{}", stem, hash, ext)
52 };
53
54 original_path.with_file_name(new_filename)
55}
56
57pub async fn write_hashed_file(
59 content: Vec<u8>,
60 original_path: PathBuf,
61 output_dir: PathBuf,
62 manifest: AssetManifest,
63 hash_length: usize,
64) -> std::result::Result<(PathBuf, String), String> {
65 let content_clone = content.clone();
67 let hash = tokio::task::spawn_blocking(move || compute_hash(&content_clone, hash_length))
68 .await
69 .map_err(|e| format!("Hash computation failed: {}", e))?;
70
71 let hashed_path = hashed_filename(&original_path, &hash);
72 let full_path = output_dir.join(&hashed_path);
73
74 if let Some(parent) = full_path.parent() {
76 tokio::fs::create_dir_all(parent)
77 .await
78 .map_err(|e| format!("Failed to create directory: {}", e))?;
79 }
80
81 tokio::fs::write(&full_path, &content)
83 .await
84 .map_err(|e| format!("Failed to write file: {}", e))?;
85
86 let original_web_path = format!("/{}", original_path.to_string_lossy());
88 let hashed_web_path = format!("/{}", hashed_path.to_string_lossy());
89 manifest.insert(original_web_path, hashed_web_path.clone());
90
91 Ok((full_path, hashed_web_path))
92}
93
94pub fn create_module(
96 lua: &Lua,
97 root: &Path,
98 manifest: AssetManifest,
99 tracker: SharedTracker,
100) -> Result<Table> {
101 let module = lua.create_table()?;
102 let root = root.to_path_buf();
103
104 let hash_fn = lua.create_function(|_, (content, length): (mlua::String, Option<usize>)| {
106 let content = content.as_bytes().to_vec();
107 let len = length.unwrap_or(8);
108
109 let handle = runtime().spawn(async move {
110 let hash = tokio::task::spawn_blocking(move || compute_hash(&content, len))
111 .await
112 .map_err(|e| format!("Hash computation failed: {}", e))?;
113 Ok(hash)
114 });
115
116 Ok(AsyncIOTask::from_string_handle(handle))
117 })?;
118 module.set("hash", hash_fn)?;
119
120 let hash_sync_fn =
122 lua.create_function(|_, (content, length): (mlua::String, Option<usize>)| {
123 let bytes = content.as_bytes().to_vec();
124 let hash = compute_hash(&bytes, length.unwrap_or(8));
125 Ok(hash)
126 })?;
127 module.set("hash_sync", hash_sync_fn)?;
128
129 let manifest_clone = manifest.clone();
131 let root_clone = root.clone();
132 let tracker_clone = tracker.clone();
133 let write_hashed_fn = lua.create_function(
134 move |_, (content, path, options): (mlua::String, String, Option<Table>)| {
135 let content = content.as_bytes().to_vec();
136 let original_path = PathBuf::from(&path);
137 let output_dir = root_clone.clone();
138 let manifest = manifest_clone.clone();
139 let tracker = tracker_clone.clone();
140 let hash_length = options
141 .as_ref()
142 .and_then(|o| o.get::<i64>("hash_length").ok())
143 .unwrap_or(8) as usize;
144
145 let content_for_tracking = content.clone();
146 let handle = runtime().spawn(async move {
147 let (full_path, hashed_web_path) =
148 write_hashed_file(content, original_path, output_dir, manifest, hash_length)
149 .await?;
150
151 tracker.record_write(full_path, &content_for_tracking);
153 tracker.merge_thread_locals();
154
155 Ok(hashed_web_path)
157 });
158
159 Ok(AsyncIOTask::from_string_handle(handle))
160 },
161 )?;
162 module.set("write_hashed", write_hashed_fn)?;
163
164 let manifest_clone = manifest.clone();
166 let register_fn = lua.create_function(move |_, (original, hashed): (String, String)| {
167 let normalized_original = if original.starts_with('/') {
168 original.clone()
169 } else {
170 format!("/{}", original)
171 };
172 let normalized_hashed = if hashed.starts_with('/') {
173 hashed.clone()
174 } else {
175 format!("/{}", hashed)
176 };
177 manifest_clone.insert(normalized_original, normalized_hashed);
178 Ok(())
179 })?;
180 module.set("register", register_fn)?;
181
182 let manifest_clone = manifest.clone();
184 let get_path_fn = lua.create_function(move |_, path: String| {
185 let normalized = if path.starts_with('/') {
186 path.clone()
187 } else {
188 format!("/{}", path)
189 };
190 Ok(manifest_clone
191 .get(&normalized)
192 .map(|v| v.clone())
193 .unwrap_or(path))
194 })?;
195 module.set("get_path", get_path_fn)?;
196
197 let manifest_clone = manifest.clone();
199 let manifest_fn = lua.create_function(move |lua, ()| {
200 let table = lua.create_table()?;
201 for entry in manifest_clone.iter() {
202 table.set(entry.key().clone(), entry.value().clone())?;
203 }
204 Ok(table)
205 })?;
206 module.set("manifest", manifest_fn)?;
207
208 let manifest_clone = manifest.clone();
210 let clear_fn = lua.create_function(move |_, ()| {
211 manifest_clone.clear();
212 Ok(())
213 })?;
214 module.set("clear", clear_fn)?;
215
216 let root_clone = root.clone();
218 let check_unused_fn = lua.create_function(move |lua, output_dir: String| {
219 use regex::Regex;
220 use std::collections::HashSet;
221
222 let output_path = if Path::new(&output_dir).is_absolute() {
223 PathBuf::from(&output_dir)
224 } else {
225 root_clone.join(&output_dir)
226 };
227
228 let mut asset_files: HashSet<String> = HashSet::new();
229 for subdir in &["static", "fonts"] {
230 let dir = output_path.join(subdir);
231 if dir.exists() {
232 collect_files_recursive(&dir, subdir, &mut asset_files);
233 }
234 }
235
236 let mut referenced: HashSet<String> = HashSet::new();
237 let patterns = [
238 r#"src=["']([^"']+)["']"#,
239 r#"href=["']([^"']+)["']"#,
240 r#"url\(["']?([^"')]+)["']?\)"#,
241 r#"srcset=["']([^"']+)["']"#,
242 ];
243 let regexes: Vec<Regex> = patterns.iter().filter_map(|p| Regex::new(p).ok()).collect();
244
245 scan_files_for_refs(&output_path, "html", ®exes, &mut referenced);
246 scan_files_for_refs(&output_path, "css", ®exes, &mut referenced);
247
248 let mut unused: Vec<String> = asset_files
249 .iter()
250 .filter(|asset| !is_asset_referenced(asset, &referenced))
251 .cloned()
252 .collect();
253 unused.sort();
254
255 let result = lua.create_table()?;
256 for (i, path) in unused.iter().enumerate() {
257 result.set(i + 1, path.clone())?;
258 }
259 Ok(mlua::Value::Table(result))
260 })?;
261 module.set("check_unused", check_unused_fn)?;
262
263 Ok(module)
264}
265
266fn collect_files_recursive(
267 dir: &Path,
268 prefix: &str,
269 files: &mut std::collections::HashSet<String>,
270) {
271 if let Ok(entries) = std::fs::read_dir(dir) {
272 for entry in entries.flatten() {
273 let path = entry.path();
274 if path.is_file()
275 && let Some(name) = path.file_name().and_then(|n| n.to_str())
276 && !name.starts_with('.')
277 {
278 files.insert(format!("/{}/{}", prefix, name));
279 } else if path.is_dir()
280 && let Some(name) = path.file_name().and_then(|n| n.to_str())
281 && !name.starts_with('.')
282 {
283 let new_prefix = format!("{}/{}", prefix, name);
284 collect_files_recursive(&path, &new_prefix, files);
285 }
286 }
287 }
288}
289
290fn scan_files_for_refs(
291 dir: &Path,
292 ext: &str,
293 regexes: &[regex::Regex],
294 referenced: &mut std::collections::HashSet<String>,
295) {
296 let pattern = format!("{}/**/*.{}", dir.display(), ext);
297 if let Ok(paths) = glob::glob(&pattern) {
298 for path in paths.flatten() {
299 if let Ok(content) = std::fs::read_to_string(&path) {
300 for regex in regexes {
301 for cap in regex.captures_iter(&content) {
302 if let Some(m) = cap.get(1) {
303 let reference = m.as_str();
304 if reference.contains(',') {
305 for part in reference.split(',') {
306 let url = part.split_whitespace().next().unwrap_or("");
307 if !url.is_empty() {
308 referenced.insert(normalize_ref(url));
309 }
310 }
311 } else {
312 referenced.insert(normalize_ref(reference));
313 }
314 }
315 }
316 }
317 }
318 }
319 }
320}
321
322fn normalize_ref(reference: &str) -> String {
323 if reference.starts_with("http://")
324 || reference.starts_with("https://")
325 || reference.starts_with("//")
326 {
327 return reference.to_string();
328 }
329 let path = if reference.starts_with('/') {
330 reference.to_string()
331 } else if let Some(stripped) = reference.strip_prefix("./") {
332 format!("/{}", stripped)
333 } else {
334 format!("/{}", reference)
335 };
336 path.split('?')
337 .next()
338 .unwrap_or(&path)
339 .split('#')
340 .next()
341 .unwrap_or(&path)
342 .to_string()
343}
344
345fn is_asset_referenced(asset: &str, referenced: &std::collections::HashSet<String>) -> bool {
346 if referenced.contains(asset) {
347 return true;
348 }
349 let without_slash = asset.trim_start_matches('/');
350 referenced.iter().any(|r| {
351 r == without_slash || r.ends_with(asset) || asset.ends_with(r.trim_start_matches('/'))
352 })
353}