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#[derive(Debug, Default, Clone)]
27pub struct AssetManifest {
28 entries: BTreeMap<String, String>,
29 url_prefix: String,
30}
31
32impl AssetManifest {
33 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 KickError::new(e.code, format!("{} (file: {})", e.message, path.display()))
47 })
48 }
49
50 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 pub fn from_vite_json(json: &str) -> KickResult<Self> {
86 #[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 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 pub fn url_prefix(&self) -> &str {
119 &self.url_prefix
120 }
121
122 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 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 pub fn len(&self) -> usize {
156 self.entries.len()
157 }
158
159 pub fn is_empty(&self) -> bool {
161 self.entries.is_empty()
162 }
163}
164
165#[cfg(feature = "embed")]
167pub use embed::*;
168
169#[cfg(feature = "embed")]
170mod embed {
171 use kick_rs_core::{KickError, KickResult};
178
179 pub use kick_rs_assets_macros::embed_assets;
197
198 #[derive(Debug, Clone, Copy)]
201 pub struct EmbeddedAssets {
202 path: &'static str,
203 entries: &'static [EmbeddedEntry],
204 }
205
206 #[derive(Debug, Clone, Copy)]
208 pub struct EmbeddedFile {
209 path: &'static str,
210 contents: &'static [u8],
211 }
212
213 #[derive(Debug, Clone, Copy)]
215 pub enum EmbeddedEntry {
216 File(EmbeddedFile),
218 Dir(EmbeddedAssets),
220 }
221
222 impl EmbeddedAssets {
223 #[doc(hidden)]
227 pub const fn __new(path: &'static str, entries: &'static [EmbeddedEntry]) -> Self {
228 Self { path, entries }
229 }
230
231 pub fn path(&self) -> &'static str {
234 self.path
235 }
236
237 pub fn entries(&self) -> &'static [EmbeddedEntry] {
239 self.entries
240 }
241
242 pub fn get_file(&self, rel: &str) -> Option<&'static EmbeddedFile> {
245 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 #[doc(hidden)]
268 pub const fn __new(path: &'static str, contents: &'static [u8]) -> Self {
269 Self { path, contents }
270 }
271
272 pub fn path(&self) -> &'static str {
274 self.path
275 }
276
277 pub fn contents(&self) -> &'static [u8] {
279 self.contents
280 }
281 }
282
283 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
295 .strip_prefix(dir_prefix)
296 .and_then(|rest| rest.strip_prefix('/'))
297 == Some(target)
298 }
299
300 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 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 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 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 assert_eq!(m.len(), 2);
383 }
384
385 #[test]
386 fn from_vite_json_rejects_malformed() {
387 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 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 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 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}