#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
#![warn(missing_docs, rust_2018_idioms)]
use kick_rs_core::{KickError, KickResult};
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug, Default, Clone)]
pub struct AssetManifest {
entries: BTreeMap<String, String>,
url_prefix: String,
}
impl AssetManifest {
pub fn load<P: AsRef<Path>>(path: P) -> KickResult<Self> {
let path = path.as_ref();
let raw = std::fs::read_to_string(path).map_err(|e| {
KickError::new(
"RK_C_IO",
format!("could not read asset manifest `{}`: {e}", path.display()),
)
})?;
Self::from_json(&raw).map_err(|e| {
KickError::new(e.code, format!("{} (file: {})", e.message, path.display()))
})
}
pub fn from_json(json: &str) -> KickResult<Self> {
let entries: BTreeMap<String, String> = serde_json::from_str(json)
.map_err(|e| KickError::new("RK_C_PARSE", format!("invalid asset manifest: {e}")))?;
Ok(Self {
entries,
url_prefix: String::new(),
})
}
pub fn from_vite_json(json: &str) -> KickResult<Self> {
#[derive(serde::Deserialize)]
struct ViteEntry {
file: String,
}
let raw: BTreeMap<String, ViteEntry> = serde_json::from_str(json)
.map_err(|e| KickError::new("RK_C_PARSE", format!("invalid vite manifest: {e}")))?;
let entries: BTreeMap<String, String> = raw.into_iter().map(|(k, v)| (k, v.file)).collect();
Ok(Self {
entries,
url_prefix: String::new(),
})
}
pub fn with_url_prefix(mut self, prefix: impl Into<String>) -> Self {
let mut p = prefix.into();
while p.ends_with('/') {
p.pop();
}
self.url_prefix = p;
self
}
pub fn url_prefix(&self) -> &str {
&self.url_prefix
}
pub fn resolve(&self, key: &str) -> KickResult<String> {
let hashed = self.entries.get(key).ok_or_else(|| {
let known: Vec<&str> = self.entries.keys().map(String::as_str).collect();
KickError::new(
"RK_C_UNKNOWN_ASSET",
format!("no asset entry for key `{key}`"),
)
.with_hint(format!(
"known keys: {}",
if known.is_empty() {
"<none — manifest is empty>".into()
} else {
known.join(", ")
}
))
})?;
if self.url_prefix.is_empty() {
Ok(format!("/{hashed}"))
} else {
Ok(format!("{}/{}", self.url_prefix, hashed))
}
}
pub fn entries(&self) -> impl Iterator<Item = (&str, &str)> {
self.entries.iter().map(|(k, v)| (k.as_str(), v.as_str()))
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[cfg(feature = "embed")]
pub use embed::*;
#[cfg(feature = "embed")]
mod embed {
use kick_rs_core::{KickError, KickResult};
pub use kick_rs_assets_macros::embed_assets;
#[derive(Debug, Clone, Copy)]
pub struct EmbeddedAssets {
path: &'static str,
entries: &'static [EmbeddedEntry],
}
#[derive(Debug, Clone, Copy)]
pub struct EmbeddedFile {
path: &'static str,
contents: &'static [u8],
}
#[derive(Debug, Clone, Copy)]
pub enum EmbeddedEntry {
File(EmbeddedFile),
Dir(EmbeddedAssets),
}
impl EmbeddedAssets {
#[doc(hidden)]
pub const fn __new(path: &'static str, entries: &'static [EmbeddedEntry]) -> Self {
Self { path, entries }
}
pub fn path(&self) -> &'static str {
self.path
}
pub fn entries(&self) -> &'static [EmbeddedEntry] {
self.entries
}
pub fn get_file(&self, rel: &str) -> Option<&'static EmbeddedFile> {
let rel = rel.strip_prefix('/').unwrap_or(rel);
for entry in self.entries {
match entry {
EmbeddedEntry::File(f) => {
if path_matches(f.path, self.path, rel) {
return Some(f);
}
}
EmbeddedEntry::Dir(d) => {
if let Some(f) = d.get_file(rel) {
return Some(f);
}
}
}
}
None
}
}
impl EmbeddedFile {
#[doc(hidden)]
pub const fn __new(path: &'static str, contents: &'static [u8]) -> Self {
Self { path, contents }
}
pub fn path(&self) -> &'static str {
self.path
}
pub fn contents(&self) -> &'static [u8] {
self.contents
}
}
fn path_matches(file_path: &str, dir_prefix: &str, target: &str) -> bool {
if dir_prefix.is_empty() {
return file_path == target;
}
file_path
.strip_prefix(dir_prefix)
.and_then(|rest| rest.strip_prefix('/'))
== Some(target)
}
pub fn content_type_for(name: &str) -> &'static str {
let lower = name.to_ascii_lowercase();
let Some(dot) = lower.rfind('.') else {
return "application/octet-stream";
};
match &lower[dot + 1..] {
"html" | "htm" => "text/html; charset=utf-8",
"css" => "text/css; charset=utf-8",
"js" | "mjs" => "application/javascript; charset=utf-8",
"json" => "application/json",
"wasm" => "application/wasm",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"ico" => "image/x-icon",
"woff" => "font/woff",
"woff2" => "font/woff2",
"ttf" => "font/ttf",
"otf" => "font/otf",
"txt" | "text" => "text/plain; charset=utf-8",
"map" => "application/json",
_ => "application/octet-stream",
}
}
pub fn read_embedded(dir: &EmbeddedAssets, rel: &str) -> KickResult<&'static [u8]> {
dir.get_file(rel)
.map(EmbeddedFile::contents)
.ok_or_else(|| {
KickError::new(
"RK_C_UNKNOWN_ASSET",
format!("no embedded asset at `{rel}`"),
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_vite_json_reduces_to_flat() {
let m = AssetManifest::from_vite_json(
r#"{
"src/main.js": {
"file": "assets/main.4889e940.js",
"src": "src/main.js",
"isEntry": true,
"imports": ["_shared.83069a53.js"],
"css": ["assets/main.b82dbe22.css"]
},
"_shared.83069a53.js": {
"file": "assets/shared.83069a53.js"
}
}"#,
)
.unwrap()
.with_url_prefix("/static");
assert_eq!(
m.resolve("src/main.js").unwrap(),
"/static/assets/main.4889e940.js"
);
assert_eq!(
m.resolve("_shared.83069a53.js").unwrap(),
"/static/assets/shared.83069a53.js"
);
assert_eq!(m.len(), 2);
}
#[test]
fn from_vite_json_rejects_malformed() {
let err = AssetManifest::from_vite_json(r#"{"x": {"src": "x.js"}}"#).unwrap_err();
assert_eq!(err.code, "RK_C_PARSE");
let err2 = AssetManifest::from_vite_json("not even json").unwrap_err();
assert_eq!(err2.code, "RK_C_PARSE");
}
#[test]
fn from_vite_json_ignores_unknown_top_level_keys() {
let m = AssetManifest::from_vite_json(
r#"{"x.js": {"file": "x.HASH.js", "isDynamicEntry": true, "extra": 42}}"#,
)
.unwrap();
assert_eq!(m.resolve("x.js").unwrap(), "/x.HASH.js");
}
#[test]
fn from_json_parses_flat_object() {
let m = AssetManifest::from_json(
r#"{ "app.js": "app.a1b2c3.js", "app.css": "app.d4e5f6.css" }"#,
)
.unwrap();
assert_eq!(m.len(), 2);
assert_eq!(m.entries().count(), 2);
let pairs: Vec<_> = m.entries().collect();
assert_eq!(pairs[0].0, "app.css");
assert_eq!(pairs[1].0, "app.js");
}
#[test]
fn from_json_rejects_malformed_input() {
let err = AssetManifest::from_json("not json").unwrap_err();
assert_eq!(err.code, "RK_C_PARSE");
}
#[test]
fn resolve_prepends_prefix_with_normalized_slash() {
let m = AssetManifest::from_json(r#"{ "app.js": "app.a1b2c3.js" }"#)
.unwrap()
.with_url_prefix("/static///");
assert_eq!(m.url_prefix(), "/static");
assert_eq!(m.resolve("app.js").unwrap(), "/static/app.a1b2c3.js");
}
#[test]
fn resolve_without_prefix_starts_with_slash() {
let m = AssetManifest::from_json(r#"{ "app.js": "app.a1b2c3.js" }"#).unwrap();
assert_eq!(m.resolve("app.js").unwrap(), "/app.a1b2c3.js");
}
#[test]
fn resolve_unknown_key_errors_with_catalog_in_hint() {
let m = AssetManifest::from_json(r#"{ "a.js": "a.x.js", "b.js": "b.y.js" }"#).unwrap();
let err = m.resolve("c.js").unwrap_err();
assert_eq!(err.code, "RK_C_UNKNOWN_ASSET");
let hint = err.fix_hint.as_deref().unwrap_or("");
assert!(hint.contains("a.js"), "hint: {hint}");
assert!(hint.contains("b.js"), "hint: {hint}");
}
#[test]
fn load_reads_from_tempfile() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), r#"{ "app.js": "app.fff.js" }"#).unwrap();
let m = AssetManifest::load(tmp.path()).unwrap();
assert_eq!(m.resolve("app.js").unwrap(), "/app.fff.js");
}
#[test]
fn load_missing_file_errors() {
let err = AssetManifest::load("does-not-exist.json").unwrap_err();
assert_eq!(err.code, "RK_C_IO");
}
#[cfg(feature = "embed")]
#[test]
fn content_type_for_common_extensions() {
assert_eq!(
content_type_for("app.js"),
"application/javascript; charset=utf-8"
);
assert_eq!(content_type_for("app.css"), "text/css; charset=utf-8");
assert_eq!(content_type_for("index.HTML"), "text/html; charset=utf-8");
assert_eq!(content_type_for("logo.svg"), "image/svg+xml");
assert_eq!(content_type_for("font.woff2"), "font/woff2");
assert_eq!(content_type_for("noext"), "application/octet-stream");
assert_eq!(content_type_for("weird.exotic"), "application/octet-stream");
}
}