#[cfg(test)]
mod tests;
use std::collections::{BTreeMap, HashSet};
use std::sync::{OnceLock, RwLock};
#[derive(Debug, Clone, Default, serde::Deserialize)]
struct Chunk {
file: String,
#[serde(default)]
css: Vec<String>,
#[serde(default)]
imports: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum Vite {
Manifest { base: String, chunks: BTreeMap<String, ChunkFiles> },
Dev(String),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ChunkFiles {
pub file: String,
pub css: Vec<String>,
pub imports: Vec<String>,
}
impl Vite {
pub fn from_manifest_file(path: &str, base: &str) -> Result<Vite, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("Vite manifest {path}: {e}"))?;
Self::from_manifest_str(&text, base)
}
pub fn from_manifest_str(json: &str, base: &str) -> Result<Vite, String> {
let raw: BTreeMap<String, Chunk> = serde_json::from_str(json).map_err(|e| format!("Vite manifest: {e}"))?;
let chunks = raw.into_iter().map(|(k, c)| (k, ChunkFiles { file: c.file, css: c.css, imports: c.imports })).collect();
let base = if base.ends_with('/') { base.to_string() } else { format!("{base}/") };
Ok(Vite::Manifest { base, chunks })
}
pub fn dev(url: &str) -> Vite {
Vite::Dev(url.trim_end_matches('/').to_string())
}
pub fn from_env() -> Result<Vite, String> {
if let Ok(url) = std::env::var("VITE_DEV_SERVER") {
if !url.is_empty() {
return Ok(Vite::dev(&url));
}
}
let path = std::env::var("VITE_MANIFEST").unwrap_or_else(|_| "frontend/dist/.vite/manifest.json".into());
let base = std::env::var("VITE_BASE").unwrap_or_else(|_| "/".into());
Self::from_manifest_file(&path, &base)
}
pub fn tags(&self, entry: &str) -> Option<String> {
match self {
Vite::Dev(url) => Some(format!(
"<script type=\"module\" src=\"{0}/@vite/client\"></script>\n<script type=\"module\" src=\"{0}/{1}\"></script>",
escape(url),
escape(entry.trim_start_matches('/'))
)),
Vite::Manifest { base, chunks } => {
let root = chunks.get(entry)?;
let mut css = Vec::new();
let mut preload = Vec::new();
css.extend(root.css.iter().cloned());
let mut seen: HashSet<&str> = HashSet::new();
let mut queue: Vec<&str> = root.imports.iter().map(String::as_str).collect();
while let Some(key) = queue.pop() {
if !seen.insert(key) {
continue;
}
if let Some(chunk) = chunks.get(key) {
preload.push(chunk.file.clone());
css.extend(chunk.css.iter().cloned());
queue.extend(chunk.imports.iter().map(String::as_str));
}
}
let mut out = Vec::new();
let mut emitted = HashSet::new();
for file in css {
if emitted.insert(file.clone()) {
out.push(format!("<link rel=\"stylesheet\" href=\"{}{}\">", escape(base), escape(&file)));
}
}
for file in preload {
if emitted.insert(file.clone()) {
out.push(format!("<link rel=\"modulepreload\" href=\"{}{}\">", escape(base), escape(&file)));
}
}
out.push(format!("<script type=\"module\" src=\"{}{}\"></script>", escape(base), escape(&root.file)));
Some(out.join("\n"))
}
}
}
pub fn asset(&self, source: &str) -> Option<String> {
match self {
Vite::Dev(url) => Some(format!("{url}/{}", source.trim_start_matches('/'))),
Vite::Manifest { base, chunks } => chunks.get(source).map(|c| format!("{base}{}", c.file)),
}
}
}
fn escape(text: &str) -> String {
text.replace('&', "&").replace('"', """).replace('<', "<").replace('>', ">")
}
fn global() -> &'static RwLock<Option<Vite>> {
static VITE: OnceLock<RwLock<Option<Vite>>> = OnceLock::new();
VITE.get_or_init(|| RwLock::new(None))
}
pub fn install(vite: Vite) {
if let Ok(mut slot) = global().write() {
*slot = Some(vite);
}
}
pub fn tags(entry: &str) -> Option<String> {
global().read().ok()?.as_ref()?.tags(entry)
}
pub fn asset(source: &str) -> Option<String> {
global().read().ok()?.as_ref()?.asset(source)
}