1#[cfg(test)]
31mod tests;
32
33use std::collections::{BTreeMap, HashSet};
34use std::sync::{OnceLock, RwLock};
35
36#[derive(Debug, Clone, Default, serde::Deserialize)]
38struct Chunk {
39 file: String,
40 #[serde(default)]
41 css: Vec<String>,
42 #[serde(default)]
43 imports: Vec<String>,
44}
45
46#[derive(Debug, Clone)]
48pub enum Vite {
49 Manifest { base: String, chunks: BTreeMap<String, ChunkFiles> },
51 Dev(String),
53}
54
55#[derive(Debug, Clone, Default, PartialEq, Eq)]
57pub struct ChunkFiles {
58 pub file: String,
59 pub css: Vec<String>,
60 pub imports: Vec<String>,
61}
62
63impl Vite {
64 pub fn from_manifest_file(path: &str, base: &str) -> Result<Vite, String> {
66 let text = std::fs::read_to_string(path).map_err(|e| format!("Vite manifest {path}: {e}"))?;
67 Self::from_manifest_str(&text, base)
68 }
69
70 pub fn from_manifest_str(json: &str, base: &str) -> Result<Vite, String> {
72 let raw: BTreeMap<String, Chunk> = serde_json::from_str(json).map_err(|e| format!("Vite manifest: {e}"))?;
73 let chunks = raw.into_iter().map(|(k, c)| (k, ChunkFiles { file: c.file, css: c.css, imports: c.imports })).collect();
74 let base = if base.ends_with('/') { base.to_string() } else { format!("{base}/") };
75 Ok(Vite::Manifest { base, chunks })
76 }
77
78 pub fn dev(url: &str) -> Vite {
80 Vite::Dev(url.trim_end_matches('/').to_string())
81 }
82
83 pub fn from_env() -> Result<Vite, String> {
87 if let Ok(url) = std::env::var("VITE_DEV_SERVER") {
88 if !url.is_empty() {
89 return Ok(Vite::dev(&url));
90 }
91 }
92 let path = std::env::var("VITE_MANIFEST").unwrap_or_else(|_| "frontend/dist/.vite/manifest.json".into());
93 let base = std::env::var("VITE_BASE").unwrap_or_else(|_| "/".into());
94 Self::from_manifest_file(&path, &base)
95 }
96
97 pub fn tags(&self, entry: &str) -> Option<String> {
101 match self {
102 Vite::Dev(url) => Some(format!(
103 "<script type=\"module\" src=\"{0}/@vite/client\"></script>\n<script type=\"module\" src=\"{0}/{1}\"></script>",
104 escape(url),
105 escape(entry.trim_start_matches('/'))
106 )),
107 Vite::Manifest { base, chunks } => {
108 let root = chunks.get(entry)?;
109 let mut css = Vec::new();
110 let mut preload = Vec::new();
111 css.extend(root.css.iter().cloned());
112 let mut seen: HashSet<&str> = HashSet::new();
115 let mut queue: Vec<&str> = root.imports.iter().map(String::as_str).collect();
116 while let Some(key) = queue.pop() {
117 if !seen.insert(key) {
118 continue;
119 }
120 if let Some(chunk) = chunks.get(key) {
121 preload.push(chunk.file.clone());
122 css.extend(chunk.css.iter().cloned());
123 queue.extend(chunk.imports.iter().map(String::as_str));
124 }
125 }
126 let mut out = Vec::new();
127 let mut emitted = HashSet::new();
128 for file in css {
129 if emitted.insert(file.clone()) {
130 out.push(format!("<link rel=\"stylesheet\" href=\"{}{}\">", escape(base), escape(&file)));
131 }
132 }
133 for file in preload {
134 if emitted.insert(file.clone()) {
135 out.push(format!("<link rel=\"modulepreload\" href=\"{}{}\">", escape(base), escape(&file)));
136 }
137 }
138 out.push(format!("<script type=\"module\" src=\"{}{}\"></script>", escape(base), escape(&root.file)));
139 Some(out.join("\n"))
140 }
141 }
142 }
143
144 pub fn asset(&self, source: &str) -> Option<String> {
147 match self {
148 Vite::Dev(url) => Some(format!("{url}/{}", source.trim_start_matches('/'))),
149 Vite::Manifest { base, chunks } => chunks.get(source).map(|c| format!("{base}{}", c.file)),
150 }
151 }
152}
153
154fn escape(text: &str) -> String {
156 text.replace('&', "&").replace('"', """).replace('<', "<").replace('>', ">")
157}
158
159fn global() -> &'static RwLock<Option<Vite>> {
160 static VITE: OnceLock<RwLock<Option<Vite>>> = OnceLock::new();
161 VITE.get_or_init(|| RwLock::new(None))
162}
163
164pub fn install(vite: Vite) {
167 if let Ok(mut slot) = global().write() {
168 *slot = Some(vite);
169 }
170}
171
172pub fn tags(entry: &str) -> Option<String> {
174 global().read().ok()?.as_ref()?.tags(entry)
175}
176
177pub fn asset(source: &str) -> Option<String> {
179 global().read().ok()?.as_ref()?.asset(source)
180}