use serde::Deserialize;
use std::collections::HashMap;
use std::sync::OnceLock;
#[derive(Debug, Clone)]
pub struct ResolvedAssets {
pub js: String,
pub css: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct ManifestEntry {
file: String,
css: Option<Vec<String>>,
#[serde(rename = "isEntry")]
#[allow(dead_code)]
is_entry: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct ViteManifest(HashMap<String, ManifestEntry>);
impl ViteManifest {
fn load(path: &str) -> Option<Self> {
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
fn resolve(&self, entry_point: &str) -> Option<ResolvedAssets> {
let entry = self.0.get(entry_point)?;
let js = format!("/{}", entry.file);
let css = entry
.css
.as_ref()
.map(|paths| paths.iter().map(|p| format!("/{p}")).collect())
.unwrap_or_default();
Some(ResolvedAssets { js, css })
}
}
static MANIFEST: OnceLock<Option<ViteManifest>> = OnceLock::new();
pub fn resolve_assets(manifest_path: &str, entry_point: &str) -> ResolvedAssets {
let manifest = MANIFEST.get_or_init(|| ViteManifest::load(manifest_path));
manifest
.as_ref()
.and_then(|m| m.resolve(entry_point))
.unwrap_or_else(|| ResolvedAssets {
js: "/assets/main.js".to_string(),
css: vec!["/assets/main.css".to_string()],
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn parse_manifest_and_resolve_entry() {
let manifest_json = r#"{
"src/main.tsx": {
"file": "assets/main-BiRGL2vC.js",
"isEntry": true,
"css": ["assets/main-DXEjMDJ3.css"]
}
}"#;
let manifest: ViteManifest = serde_json::from_str(manifest_json).unwrap();
let resolved = manifest.resolve("src/main.tsx").unwrap();
assert_eq!(resolved.js, "/assets/main-BiRGL2vC.js");
assert_eq!(resolved.css, vec!["/assets/main-DXEjMDJ3.css"]);
}
#[test]
fn resolve_missing_entry_returns_none() {
let manifest_json = r#"{
"src/main.tsx": {
"file": "assets/main-abc.js",
"isEntry": true,
"css": ["assets/main-def.css"]
}
}"#;
let manifest: ViteManifest = serde_json::from_str(manifest_json).unwrap();
assert!(manifest.resolve("src/other.tsx").is_none());
}
#[test]
fn resolve_entry_without_css() {
let manifest_json = r#"{
"src/main.tsx": {
"file": "assets/main-abc.js",
"isEntry": true
}
}"#;
let manifest: ViteManifest = serde_json::from_str(manifest_json).unwrap();
let resolved = manifest.resolve("src/main.tsx").unwrap();
assert_eq!(resolved.js, "/assets/main-abc.js");
assert!(resolved.css.is_empty());
}
#[test]
fn load_from_file() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
write!(
tmp,
r#"{{"src/main.tsx":{{"file":"assets/main-xyz.js","isEntry":true,"css":["assets/main-xyz.css"]}}}}"#
)
.unwrap();
let manifest = ViteManifest::load(tmp.path().to_str().unwrap()).unwrap();
let resolved = manifest.resolve("src/main.tsx").unwrap();
assert_eq!(resolved.js, "/assets/main-xyz.js");
assert_eq!(resolved.css, vec!["/assets/main-xyz.css"]);
}
#[test]
fn load_nonexistent_returns_none() {
assert!(ViteManifest::load("/nonexistent/path/manifest.json").is_none());
}
#[test]
fn load_invalid_json_returns_none() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
write!(tmp, "not json").unwrap();
assert!(ViteManifest::load(tmp.path().to_str().unwrap()).is_none());
}
#[test]
fn multiple_css_files() {
let manifest_json = r#"{
"src/main.tsx": {
"file": "assets/main-abc.js",
"isEntry": true,
"css": ["assets/main-abc.css", "assets/vendor-def.css"]
}
}"#;
let manifest: ViteManifest = serde_json::from_str(manifest_json).unwrap();
let resolved = manifest.resolve("src/main.tsx").unwrap();
assert_eq!(resolved.css.len(), 2);
assert_eq!(resolved.css[0], "/assets/main-abc.css");
assert_eq!(resolved.css[1], "/assets/vendor-def.css");
}
}