rs_web/lua/
assets.rs

1//! Asset hashing module (rs.assets)
2//!
3//! Provides content-based hashing for cache busting:
4//! - rs.assets.hash(content) - Compute hash of content (async)
5//! - rs.assets.write_hashed(content, path, options) - Write with hashed filename (async)
6//! - rs.assets.get_path(original_path) - Get hashed path for original
7//! - rs.assets.register(original_path, hashed_path) - Manually register a mapping
8//!
9//! Also provides a global manifest accessible via Tera filter.
10
11use 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
20/// Shared asset manifest mapping original paths to hashed paths
21pub type AssetManifest = Arc<DashMap<String, String>>;
22
23/// Create a new asset manifest
24pub fn create_manifest() -> AssetManifest {
25    Arc::new(DashMap::new())
26}
27
28/// Compute SHA256 hash of content and return first N characters
29pub 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
36/// Generate hashed filename from original path and content
37/// e.g., "styles/main.css" + hash -> "styles/main.a1b2c3d4.css"
38pub 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
57/// Write content to a hashed file and update the manifest (async)
58pub 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    // Compute hash in blocking task (CPU-bound)
66    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    // Ensure parent directory exists
75    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    // Write file
82    tokio::fs::write(&full_path, &content)
83        .await
84        .map_err(|e| format!("Failed to write file: {}", e))?;
85
86    // Update manifest with web path (leading /)
87    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
94/// Create the assets Lua module
95pub 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    // rs.assets.hash(content, length?) -> AsyncIOTask<string>
105    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    // rs.assets.hash_sync(content, length?) -> string (blocking)
121    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    // rs.assets.write_hashed(content, path, options?) -> AsyncIOTask<{path, hash_path}>
130    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                // Track the written file
152                tracker.record_write(full_path, &content_for_tracking);
153                tracker.merge_thread_locals();
154
155                // Return the hashed web path
156                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    // rs.assets.register(original_path, hashed_path) - manually register a mapping
165    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    // rs.assets.get_path(original_path) -> hashed path or original if not found
183    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    // rs.assets.manifest() -> table of all mappings
198    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    // rs.assets.clear() -> clear the manifest
209    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    // rs.assets.check_unused(output_dir) -> list of unused assets
217    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", &regexes, &mut referenced);
246        scan_files_for_refs(&output_path, "css", &regexes, &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}