use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize)]
pub struct SeamManifest {
pub js: Vec<String>,
pub css: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct AssetFiles {
pub css: Vec<String>,
pub js: Vec<String>,
}
impl From<SeamManifest> for AssetFiles {
fn from(m: SeamManifest) -> Self {
Self { css: m.css, js: m.js }
}
}
#[derive(Debug, Deserialize)]
struct ViteManifestEntry {
file: String,
#[serde(default)]
css: Vec<String>,
#[serde(default, rename = "isEntry")]
is_entry: bool,
#[serde(default, rename = "isDynamicEntry")]
is_dynamic_entry: bool,
#[serde(default)]
imports: Vec<String>,
#[serde(default, rename = "dynamicImports")]
dynamic_imports: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct EntryAssets {
pub scripts: Vec<String>,
pub styles: Vec<String>,
pub preload: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct BundleManifest {
pub entries: BTreeMap<String, EntryAssets>,
pub template: AssetFiles,
}
pub use seam_skeleton::ViteDevInfo;
pub fn read_bundle_manifest(path: &Path) -> Result<AssetFiles> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read bundle manifest at {}", path.display()))?;
if let Ok(vite) = serde_json::from_str::<HashMap<String, ViteManifestEntry>>(&content)
&& vite.values().any(|e| e.is_entry)
{
let mut js = vec![];
let mut css = vec![];
for entry in vite.values() {
if entry.is_entry {
js.push(entry.file.clone());
css.extend(entry.css.iter().cloned());
}
}
return Ok(AssetFiles { js, css });
}
let manifest: SeamManifest =
serde_json::from_str(&content).context("failed to parse bundle manifest")?;
Ok(manifest.into())
}
pub fn read_bundle_manifest_extended(path: &Path) -> Result<BundleManifest> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read bundle manifest at {}", path.display()))?;
let vite: HashMap<String, ViteManifestEntry> =
serde_json::from_str(&content).context("failed to parse Vite manifest for extended reading")?;
let dynamically_imported: HashSet<&str> =
vite.values().flat_map(|e| e.dynamic_imports.iter()).map(String::as_str).collect();
let mut entries = BTreeMap::new();
let mut tmpl_js = Vec::new();
let mut tmpl_css = HashSet::new();
for (key, entry) in &vite {
if !entry.is_entry && !entry.is_dynamic_entry {
continue;
}
let mut styles = Vec::new();
let mut preload = Vec::new();
let mut visited = HashSet::new();
collect_imports(key, &vite, &mut styles, &mut preload, &mut visited);
for css in &entry.css {
if !styles.contains(css) {
styles.push(css.clone());
}
}
let scripts = vec![entry.file.clone()];
if entry.is_entry && !entry.is_dynamic_entry && !dynamically_imported.contains(key.as_str()) {
tmpl_js.push(entry.file.clone());
tmpl_css.extend(entry.css.iter().cloned());
}
entries.insert(key.clone(), EntryAssets { scripts, styles, preload });
}
let template = AssetFiles { js: tmpl_js, css: sorted_vec(tmpl_css) };
Ok(BundleManifest { entries, template })
}
fn collect_imports(
key: &str,
manifest: &HashMap<String, ViteManifestEntry>,
styles: &mut Vec<String>,
preload: &mut Vec<String>,
visited: &mut HashSet<String>,
) {
if !visited.insert(key.to_string()) {
return;
}
let Some(entry) = manifest.get(key) else { return };
for import_key in &entry.imports {
if let Some(imported) = manifest.get(import_key.as_str()) {
if !imported.is_entry && !imported.is_dynamic_entry && !preload.contains(&imported.file) {
preload.push(imported.file.clone());
}
for css in &imported.css {
if !styles.contains(css) {
styles.push(css.clone());
}
}
collect_imports(import_key, manifest, styles, preload, visited);
}
}
}
fn sorted_vec(set: HashSet<String>) -> Vec<String> {
let mut v: Vec<_> = set.into_iter().collect();
v.sort();
v
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_extended_manifest_single_entry() {
let json = r#"{
"src/main.tsx": {
"file": "assets/main-abc.js",
"css": ["assets/main-abc.css"],
"isEntry": true,
"imports": []
}
}"#;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("manifest.json");
std::fs::write(&path, json).unwrap();
let result = read_bundle_manifest_extended(&path).unwrap();
assert_eq!(result.entries.len(), 1);
let entry = &result.entries["src/main.tsx"];
assert_eq!(entry.scripts, vec!["assets/main-abc.js"]);
assert_eq!(entry.styles, vec!["assets/main-abc.css"]);
assert!(entry.preload.is_empty());
assert_eq!(result.template.js, vec!["assets/main-abc.js"]);
assert_eq!(result.template.css, vec!["assets/main-abc.css"]);
}
#[test]
fn parse_extended_manifest_multi_entry_with_shared_chunk() {
let json = r#"{
"src/main.tsx": {
"file": "assets/main-abc.js",
"css": ["assets/main-abc.css"],
"isEntry": true,
"imports": ["_shared-xyz"],
"dynamicImports": ["src/pages/home.tsx"]
},
"src/pages/home.tsx": {
"file": "assets/home-def.js",
"css": ["assets/home-def.css"],
"isEntry": true,
"imports": ["_shared-xyz"]
},
"_shared-xyz": {
"file": "assets/shared-xyz.js",
"css": ["assets/shared-xyz.css"]
}
}"#;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("manifest.json");
std::fs::write(&path, json).unwrap();
let result = read_bundle_manifest_extended(&path).unwrap();
assert_eq!(result.entries.len(), 2);
let main = &result.entries["src/main.tsx"];
assert_eq!(main.scripts, vec!["assets/main-abc.js"]);
assert!(main.styles.contains(&"assets/main-abc.css".to_string()));
assert!(main.styles.contains(&"assets/shared-xyz.css".to_string()));
assert_eq!(main.preload, vec!["assets/shared-xyz.js"]);
let home = &result.entries["src/pages/home.tsx"];
assert_eq!(home.scripts, vec!["assets/home-def.js"]);
assert!(home.styles.contains(&"assets/home-def.css".to_string()));
assert!(home.styles.contains(&"assets/shared-xyz.css".to_string()));
assert_eq!(home.preload, vec!["assets/shared-xyz.js"]);
assert_eq!(result.template.js, vec!["assets/main-abc.js"]);
assert_eq!(result.template.css, vec!["assets/main-abc.css"]);
}
#[test]
fn parse_extended_manifest_rolldown_style() {
let json = r#"{
"src/main.tsx": {
"file": "assets/main-abc.js",
"css": ["assets/main-abc.css"],
"isEntry": true,
"imports": [],
"dynamicImports": ["src/pages/home.tsx", "src/pages/about.tsx"]
},
"src/pages/home.tsx": {
"file": "assets/home-def.js",
"css": ["assets/home-def.css"],
"isEntry": true,
"imports": []
},
"src/pages/about.tsx": {
"file": "assets/about-ghi.js",
"css": ["assets/about-ghi.css"],
"isEntry": true,
"imports": []
}
}"#;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("manifest.json");
std::fs::write(&path, json).unwrap();
let result = read_bundle_manifest_extended(&path).unwrap();
assert_eq!(result.entries.len(), 3);
assert!(result.entries.contains_key("src/main.tsx"));
assert!(result.entries.contains_key("src/pages/home.tsx"));
assert!(result.entries.contains_key("src/pages/about.tsx"));
assert_eq!(result.template.js, vec!["assets/main-abc.js"]);
assert_eq!(result.template.css, vec!["assets/main-abc.css"]);
}
#[test]
fn parse_extended_manifest_entry_no_imports() {
let json = r#"{
"src/main.tsx": {
"file": "assets/main.js",
"isEntry": true
}
}"#;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("manifest.json");
std::fs::write(&path, json).unwrap();
let result = read_bundle_manifest_extended(&path).unwrap();
let entry = &result.entries["src/main.tsx"];
assert_eq!(entry.scripts, vec!["assets/main.js"]);
assert!(entry.styles.is_empty());
assert!(entry.preload.is_empty());
assert_eq!(result.template.js, vec!["assets/main.js"]);
assert!(result.template.css.is_empty());
}
}