Skip to main content

kick_rs_assets/
lib.rs

1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3#![warn(missing_docs, rust_2018_idioms)]
4
5use kick_rs_core::{KickError, KickResult};
6use std::collections::BTreeMap;
7use std::path::Path;
8
9/// Map of logical asset keys (`"app.js"`) to their hashed filenames
10/// (`"app.a1b2c3.js"`), plus an optional URL prefix prepended at
11/// resolve time.
12///
13/// The JSON shape we accept is the lowest common denominator —
14/// a flat object of `key: string` entries:
15///
16/// ```json
17/// {
18///   "app.js":  "app.a1b2c3.js",
19///   "app.css": "app.d4e5f6.css"
20/// }
21/// ```
22///
23/// Vite's full manifest (with nested `imports` / `css` arrays) is
24/// also accepted — call [`Self::from_vite_json`] to parse it
25/// directly; we reduce each entry to its `file` field.
26#[derive(Debug, Default, Clone)]
27pub struct AssetManifest {
28    entries: BTreeMap<String, String>,
29    url_prefix: String,
30}
31
32impl AssetManifest {
33    /// Read + parse a manifest from disk. Errors fall into two codes:
34    /// `RK_C_IO` for read failure, `RK_C_PARSE` for malformed JSON.
35    pub fn load<P: AsRef<Path>>(path: P) -> KickResult<Self> {
36        let path = path.as_ref();
37        let raw = std::fs::read_to_string(path).map_err(|e| {
38            KickError::new(
39                "RK_C_IO",
40                format!("could not read asset manifest `{}`: {e}", path.display()),
41            )
42        })?;
43        Self::from_json(&raw).map_err(|e| {
44            // Re-wrap to mention the file path, since the from_json
45            // error doesn't know where the string came from.
46            KickError::new(e.code, format!("{} (file: {})", e.message, path.display()))
47        })
48    }
49
50    /// Parse a manifest from a JSON string. Useful for tests + when
51    /// the manifest is bundled into the binary via `embed_assets!`.
52    pub fn from_json(json: &str) -> KickResult<Self> {
53        let entries: BTreeMap<String, String> = serde_json::from_str(json)
54            .map_err(|e| KickError::new("RK_C_PARSE", format!("invalid asset manifest: {e}")))?;
55        Ok(Self {
56            entries,
57            url_prefix: String::new(),
58        })
59    }
60
61    /// Parse vite's full `manifest.json` shape — the one vite emits
62    /// when `build.manifest = true`. Each top-level key maps to a
63    /// record whose `file` field is the hashed output filename:
64    ///
65    /// ```json
66    /// {
67    ///   "src/main.js": {
68    ///     "file": "assets/main.4889e940.js",
69    ///     "src": "src/main.js",
70    ///     "isEntry": true,
71    ///     "imports": ["_shared.83069a53.js"],
72    ///     "css":     ["assets/main.b82dbe22.css"]
73    ///   }
74    /// }
75    /// ```
76    ///
77    /// We reduce that to the flat `entry_key → file` map [`Self::resolve`]
78    /// uses. The CSS / imports / asset arrays nested under each entry
79    /// are *not* surfaced separately yet — `resolve("src/main.js")`
80    /// returns the JS file URL; an API for retrieving the matching
81    /// CSS files for an entry is planned for a follow-up release (the
82    /// underlying data isn't kept right now).
83    ///
84    /// Rejects shape errors with `RK_C_PARSE`.
85    pub fn from_vite_json(json: &str) -> KickResult<Self> {
86        // Local, intentionally lenient deser type — vite emits a few
87        // optional fields we don't use; serde gracefully ignores any
88        // unknown keys by default.
89        #[derive(serde::Deserialize)]
90        struct ViteEntry {
91            file: String,
92        }
93
94        let raw: BTreeMap<String, ViteEntry> = serde_json::from_str(json)
95            .map_err(|e| KickError::new("RK_C_PARSE", format!("invalid vite manifest: {e}")))?;
96
97        let entries: BTreeMap<String, String> = raw.into_iter().map(|(k, v)| (k, v.file)).collect();
98
99        Ok(Self {
100            entries,
101            url_prefix: String::new(),
102        })
103    }
104
105    /// Set the URL prefix prepended to every resolved value. Trailing
106    /// slashes are normalized away so adopters get the expected
107    /// joined form regardless of input.
108    pub fn with_url_prefix(mut self, prefix: impl Into<String>) -> Self {
109        let mut p = prefix.into();
110        while p.ends_with('/') {
111            p.pop();
112        }
113        self.url_prefix = p;
114        self
115    }
116
117    /// The current URL prefix (without trailing slash).
118    pub fn url_prefix(&self) -> &str {
119        &self.url_prefix
120    }
121
122    /// Look up the versioned URL for `key`. Returns
123    /// `<url_prefix>/<hashed_filename>`. Errors with
124    /// `RK_C_UNKNOWN_ASSET` if the key isn't in the manifest, with a
125    /// list of known keys in the hint.
126    pub fn resolve(&self, key: &str) -> KickResult<String> {
127        let hashed = self.entries.get(key).ok_or_else(|| {
128            let known: Vec<&str> = self.entries.keys().map(String::as_str).collect();
129            KickError::new(
130                "RK_C_UNKNOWN_ASSET",
131                format!("no asset entry for key `{key}`"),
132            )
133            .with_hint(format!(
134                "known keys: {}",
135                if known.is_empty() {
136                    "<none — manifest is empty>".into()
137                } else {
138                    known.join(", ")
139                }
140            ))
141        })?;
142        if self.url_prefix.is_empty() {
143            Ok(format!("/{hashed}"))
144        } else {
145            Ok(format!("{}/{}", self.url_prefix, hashed))
146        }
147    }
148
149    /// Iterate `(key, hashed_filename)` pairs in key order.
150    pub fn entries(&self) -> impl Iterator<Item = (&str, &str)> {
151        self.entries.iter().map(|(k, v)| (k.as_str(), v.as_str()))
152    }
153
154    /// Number of entries in the manifest.
155    pub fn len(&self) -> usize {
156        self.entries.len()
157    }
158
159    /// Whether the manifest has any entries.
160    pub fn is_empty(&self) -> bool {
161        self.entries.is_empty()
162    }
163}
164
165// ─────────────────────────── Embedded assets ─────────────────────────────
166#[cfg(feature = "embed")]
167pub use embed::*;
168
169#[cfg(feature = "embed")]
170mod embed {
171    //! Compile-time bundling via the `kick-rs-assets-macros` proc-macro.
172    //!
173    //! The tree is a plain `&'static` cascade — no `Box`, `Vec`, or
174    //! lazy allocation. Every file's contents come from `include_bytes!`
175    //! emitted by the proc-macro.
176
177    use kick_rs_core::{KickError, KickResult};
178
179    /// Embed a directory tree into the binary at compile time.
180    ///
181    /// ```ignore
182    /// use kick_rs_assets::{embed_assets, EmbeddedAssets};
183    ///
184    /// static ASSETS: EmbeddedAssets = embed_assets!("$CARGO_MANIFEST_DIR/dist");
185    ///
186    /// fn handler() {
187    ///     if let Some(file) = ASSETS.get_file("app.a1b2c3.js") {
188    ///         // serve file.contents()
189    ///     }
190    /// }
191    /// ```
192    ///
193    /// Accepted path forms: absolute, `$CARGO_MANIFEST_DIR/...`,
194    /// `$OUT_DIR/...`, or relative (resolved against
195    /// `$CARGO_MANIFEST_DIR`).
196    pub use kick_rs_assets_macros::embed_assets;
197
198    /// One bundled directory. Static — every field lives in `'static`
199    /// memory; iteration is cheap and allocation-free.
200    #[derive(Debug, Clone, Copy)]
201    pub struct EmbeddedAssets {
202        path: &'static str,
203        entries: &'static [EmbeddedEntry],
204    }
205
206    /// One bundled file.
207    #[derive(Debug, Clone, Copy)]
208    pub struct EmbeddedFile {
209        path: &'static str,
210        contents: &'static [u8],
211    }
212
213    /// Entry in an embedded tree — either a file or a sub-directory.
214    #[derive(Debug, Clone, Copy)]
215    pub enum EmbeddedEntry {
216        /// A file with its bytes loaded via `include_bytes!`.
217        File(EmbeddedFile),
218        /// A nested directory.
219        Dir(EmbeddedAssets),
220    }
221
222    impl EmbeddedAssets {
223        // Constructor exposed for use by the proc-macro's expansion.
224        // `pub` + `#[doc(hidden)]` is the established Rust pattern for
225        // "callable from generated code, not from humans".
226        #[doc(hidden)]
227        pub const fn __new(path: &'static str, entries: &'static [EmbeddedEntry]) -> Self {
228            Self { path, entries }
229        }
230
231        /// Path the tree was rooted at, relative to its parent. Empty
232        /// string for the top-level tree.
233        pub fn path(&self) -> &'static str {
234            self.path
235        }
236
237        /// Direct child entries (one level — does not recurse).
238        pub fn entries(&self) -> &'static [EmbeddedEntry] {
239            self.entries
240        }
241
242        /// Find a file by its forward-slash-separated path relative
243        /// to *this* directory. Walks sub-directories as needed.
244        pub fn get_file(&self, rel: &str) -> Option<&'static EmbeddedFile> {
245            // Strip a leading slash so `/foo.js` and `foo.js` both work.
246            let rel = rel.strip_prefix('/').unwrap_or(rel);
247            for entry in self.entries {
248                match entry {
249                    EmbeddedEntry::File(f) => {
250                        if path_matches(f.path, self.path, rel) {
251                            return Some(f);
252                        }
253                    }
254                    EmbeddedEntry::Dir(d) => {
255                        if let Some(f) = d.get_file(rel) {
256                            return Some(f);
257                        }
258                    }
259                }
260            }
261            None
262        }
263    }
264
265    impl EmbeddedFile {
266        // Same convention as EmbeddedAssets::__new.
267        #[doc(hidden)]
268        pub const fn __new(path: &'static str, contents: &'static [u8]) -> Self {
269            Self { path, contents }
270        }
271
272        /// The file's path relative to the embedded tree's root.
273        pub fn path(&self) -> &'static str {
274            self.path
275        }
276
277        /// The file's bytes.
278        pub fn contents(&self) -> &'static [u8] {
279            self.contents
280        }
281    }
282
283    /// Path-comparison helper. `file_path` is the file's path relative
284    /// to the *root* of the embedded tree. `dir_prefix` is the path of
285    /// the directory we're searching from. `target` is the path the
286    /// caller is looking up, relative to `dir_prefix`. Returns true
287    /// when `file_path == join(dir_prefix, target)`.
288    fn path_matches(file_path: &str, dir_prefix: &str, target: &str) -> bool {
289        if dir_prefix.is_empty() {
290            return file_path == target;
291        }
292        // file_path should be `<dir_prefix>/<target>`. Avoid an alloc
293        // by comparing in pieces.
294        file_path
295            .strip_prefix(dir_prefix)
296            .and_then(|rest| rest.strip_prefix('/'))
297            == Some(target)
298    }
299
300    /// Best-effort content-type guess from a file extension. Returns
301    /// `application/octet-stream` for unknown extensions so the
302    /// caller always has *something* safe to send.
303    pub fn content_type_for(name: &str) -> &'static str {
304        let lower = name.to_ascii_lowercase();
305        let Some(dot) = lower.rfind('.') else {
306            return "application/octet-stream";
307        };
308        match &lower[dot + 1..] {
309            "html" | "htm" => "text/html; charset=utf-8",
310            "css" => "text/css; charset=utf-8",
311            "js" | "mjs" => "application/javascript; charset=utf-8",
312            "json" => "application/json",
313            "wasm" => "application/wasm",
314            "svg" => "image/svg+xml",
315            "png" => "image/png",
316            "jpg" | "jpeg" => "image/jpeg",
317            "gif" => "image/gif",
318            "webp" => "image/webp",
319            "ico" => "image/x-icon",
320            "woff" => "font/woff",
321            "woff2" => "font/woff2",
322            "ttf" => "font/ttf",
323            "otf" => "font/otf",
324            "txt" | "text" => "text/plain; charset=utf-8",
325            "map" => "application/json",
326            _ => "application/octet-stream",
327        }
328    }
329
330    /// Read a file from the embedded tree as a `&[u8]`. Errors with
331    /// `RK_C_UNKNOWN_ASSET` if the path isn't bundled.
332    pub fn read_embedded(dir: &EmbeddedAssets, rel: &str) -> KickResult<&'static [u8]> {
333        dir.get_file(rel)
334            .map(EmbeddedFile::contents)
335            .ok_or_else(|| {
336                KickError::new(
337                    "RK_C_UNKNOWN_ASSET",
338                    format!("no embedded asset at `{rel}`"),
339                )
340            })
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn from_vite_json_reduces_to_flat() {
350        // Realistic vite manifest fixture: two entries, both with the
351        // optional `imports` / `css` arrays vite emits. We only need
352        // `file` — the other fields exist to verify we ignore them
353        // without erroring.
354        let m = AssetManifest::from_vite_json(
355            r#"{
356              "src/main.js": {
357                "file": "assets/main.4889e940.js",
358                "src": "src/main.js",
359                "isEntry": true,
360                "imports": ["_shared.83069a53.js"],
361                "css": ["assets/main.b82dbe22.css"]
362              },
363              "_shared.83069a53.js": {
364                "file": "assets/shared.83069a53.js"
365              }
366            }"#,
367        )
368        .unwrap()
369        .with_url_prefix("/static");
370
371        // Adopter looks up by the same key vite uses internally.
372        assert_eq!(
373            m.resolve("src/main.js").unwrap(),
374            "/static/assets/main.4889e940.js"
375        );
376        assert_eq!(
377            m.resolve("_shared.83069a53.js").unwrap(),
378            "/static/assets/shared.83069a53.js"
379        );
380        // CSS / imports aren't surfaced separately yet — documented
381        // limitation. Only the entry's `file` field maps over.
382        assert_eq!(m.len(), 2);
383    }
384
385    #[test]
386    fn from_vite_json_rejects_malformed() {
387        // Missing the `file` field on the entry — vite would never
388        // emit this, but we should fail loudly if a hand-rolled or
389        // truncated manifest sneaks through.
390        let err = AssetManifest::from_vite_json(r#"{"x": {"src": "x.js"}}"#).unwrap_err();
391        assert_eq!(err.code, "RK_C_PARSE");
392        let err2 = AssetManifest::from_vite_json("not even json").unwrap_err();
393        assert_eq!(err2.code, "RK_C_PARSE");
394    }
395
396    #[test]
397    fn from_vite_json_ignores_unknown_top_level_keys() {
398        // serde deserializes the same way regardless of what's nested
399        // inside the entry — we explicitly use a struct with only
400        // `file`. Sanity: unknown fields in the entry don't trip the
401        // parser.
402        let m = AssetManifest::from_vite_json(
403            r#"{"x.js": {"file": "x.HASH.js", "isDynamicEntry": true, "extra": 42}}"#,
404        )
405        .unwrap();
406        assert_eq!(m.resolve("x.js").unwrap(), "/x.HASH.js");
407    }
408
409    #[test]
410    fn from_json_parses_flat_object() {
411        let m = AssetManifest::from_json(
412            r#"{ "app.js": "app.a1b2c3.js", "app.css": "app.d4e5f6.css" }"#,
413        )
414        .unwrap();
415        assert_eq!(m.len(), 2);
416        assert_eq!(m.entries().count(), 2);
417        // BTreeMap order — keys are sorted, .css comes before .js.
418        let pairs: Vec<_> = m.entries().collect();
419        assert_eq!(pairs[0].0, "app.css");
420        assert_eq!(pairs[1].0, "app.js");
421    }
422
423    #[test]
424    fn from_json_rejects_malformed_input() {
425        let err = AssetManifest::from_json("not json").unwrap_err();
426        assert_eq!(err.code, "RK_C_PARSE");
427    }
428
429    #[test]
430    fn resolve_prepends_prefix_with_normalized_slash() {
431        let m = AssetManifest::from_json(r#"{ "app.js": "app.a1b2c3.js" }"#)
432            .unwrap()
433            .with_url_prefix("/static///");
434        assert_eq!(m.url_prefix(), "/static");
435        assert_eq!(m.resolve("app.js").unwrap(), "/static/app.a1b2c3.js");
436    }
437
438    #[test]
439    fn resolve_without_prefix_starts_with_slash() {
440        let m = AssetManifest::from_json(r#"{ "app.js": "app.a1b2c3.js" }"#).unwrap();
441        assert_eq!(m.resolve("app.js").unwrap(), "/app.a1b2c3.js");
442    }
443
444    #[test]
445    fn resolve_unknown_key_errors_with_catalog_in_hint() {
446        let m = AssetManifest::from_json(r#"{ "a.js": "a.x.js", "b.js": "b.y.js" }"#).unwrap();
447        let err = m.resolve("c.js").unwrap_err();
448        assert_eq!(err.code, "RK_C_UNKNOWN_ASSET");
449        let hint = err.fix_hint.as_deref().unwrap_or("");
450        assert!(hint.contains("a.js"), "hint: {hint}");
451        assert!(hint.contains("b.js"), "hint: {hint}");
452    }
453
454    #[test]
455    fn load_reads_from_tempfile() {
456        let tmp = tempfile::NamedTempFile::new().unwrap();
457        std::fs::write(tmp.path(), r#"{ "app.js": "app.fff.js" }"#).unwrap();
458        let m = AssetManifest::load(tmp.path()).unwrap();
459        assert_eq!(m.resolve("app.js").unwrap(), "/app.fff.js");
460    }
461
462    #[test]
463    fn load_missing_file_errors() {
464        let err = AssetManifest::load("does-not-exist.json").unwrap_err();
465        assert_eq!(err.code, "RK_C_IO");
466    }
467
468    #[cfg(feature = "embed")]
469    #[test]
470    fn content_type_for_common_extensions() {
471        assert_eq!(
472            content_type_for("app.js"),
473            "application/javascript; charset=utf-8"
474        );
475        assert_eq!(content_type_for("app.css"), "text/css; charset=utf-8");
476        // Case-insensitive — `HTML` works the same as `html`.
477        assert_eq!(content_type_for("index.HTML"), "text/html; charset=utf-8");
478        assert_eq!(content_type_for("logo.svg"), "image/svg+xml");
479        assert_eq!(content_type_for("font.woff2"), "font/woff2");
480        assert_eq!(content_type_for("noext"), "application/octet-stream");
481        assert_eq!(content_type_for("weird.exotic"), "application/octet-stream");
482    }
483}