1use std::ffi::OsString;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::tool::{self, group_by_parent};
6
7#[non_exhaustive]
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum JsTool {
19 Esbuild,
22 #[cfg(test)]
24 TestEcho,
25 #[cfg(test)]
27 TestMissing,
28}
29
30impl JsTool {
31 pub(crate) fn binary_name(&self) -> &'static str {
32 match self {
33 JsTool::Esbuild => "esbuild",
34 #[cfg(test)]
35 JsTool::TestEcho => "cp",
36 #[cfg(test)]
37 JsTool::TestMissing => "definitely-not-a-real-binary-9f3c2a",
38 }
39 }
40
41 pub(crate) fn install_hint(&self) -> &'static str {
42 match self {
43 JsTool::Esbuild => {
44 "install via `npm install -g esbuild` (or add it as a project \
45 devDependency and put its bin/ on PATH)"
46 }
47 #[cfg(test)]
48 JsTool::TestEcho | JsTool::TestMissing => "test-only tool, not installable",
49 }
50 }
51
52 fn args(&self, bundle: bool, minify: bool, entry: &Path, output: &Path) -> Vec<OsString> {
53 match self {
54 JsTool::Esbuild => {
55 let mut args = vec![OsString::from(entry)];
56 if bundle {
57 args.push(OsString::from("--bundle"));
58 }
59 if minify {
60 args.push(OsString::from("--minify"));
61 }
62 let mut outfile = OsString::from("--outfile=");
63 outfile.push(output);
64 args.push(outfile);
65 args
66 }
67 #[cfg(test)]
68 JsTool::TestEcho => vec![entry.into(), output.into()],
69 #[cfg(test)]
70 JsTool::TestMissing => vec![],
71 }
72 }
73
74 fn batch_args(&self, minify: bool, inputs: &[PathBuf], out_dir: &Path) -> Vec<OsString> {
81 match self {
82 JsTool::Esbuild => {
83 let mut args: Vec<OsString> = inputs.iter().map(OsString::from).collect();
84 if minify {
85 args.push(OsString::from("--minify"));
86 }
87 let mut outdir = OsString::from("--outdir=");
88 outdir.push(out_dir);
89 args.push(outdir);
90 args
91 }
92 #[cfg(test)]
93 JsTool::TestEcho => {
94 let mut args: Vec<OsString> = inputs.iter().map(OsString::from).collect();
95 args.push(out_dir.into());
96 args
97 }
98 #[cfg(test)]
99 JsTool::TestMissing => vec![],
100 }
101 }
102}
103
104#[derive(Debug, Clone, Default)]
114pub struct JsOptions {
115 bundle: bool,
116 minify: bool,
117 entry: Option<PathBuf>,
118 bundle_output_name: Option<String>,
119}
120
121impl JsOptions {
122 pub fn new() -> Self {
124 JsOptions::default()
125 }
126
127 pub fn minify(mut self, minify: bool) -> Self {
130 self.minify = minify;
131 self
132 }
133
134 pub fn bundle_entry(mut self, entry: &Path, output_name: impl Into<String>) -> Self {
139 self.bundle = true;
140 self.entry = Some(entry.to_path_buf());
141 self.bundle_output_name = Some(output_name.into());
142 self
143 }
144
145 pub(crate) fn is_bundle(&self) -> bool {
146 self.bundle
147 }
148
149 pub(crate) fn is_minify(&self) -> bool {
150 self.minify
151 }
152
153 pub(crate) fn entry(&self) -> Option<&Path> {
154 self.entry.as_deref()
155 }
156
157 pub(crate) fn output_file_name(&self) -> Option<&str> {
158 self.bundle_output_name.as_deref()
159 }
160}
161
162#[derive(Debug)]
164pub enum JsError {
165 WriteOutput { path: PathBuf, reason: String },
166 Tool(tool::ToolError),
167}
168
169impl std::fmt::Display for JsError {
170 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171 match self {
172 JsError::WriteOutput { path, reason } => {
173 write!(f, "failed to write {}: {reason}", path.display())
174 }
175 JsError::Tool(e) => write!(f, "{e}"),
176 }
177 }
178}
179
180impl std::error::Error for JsError {}
181
182impl From<tool::ToolError> for JsError {
183 fn from(e: tool::ToolError) -> Self {
184 JsError::Tool(e)
185 }
186}
187
188pub(crate) fn build_js_bundle(
195 js_tool: JsTool,
196 options: &JsOptions,
197 entry: &Path,
198 output_path: &Path,
199) -> Result<(), JsError> {
200 if !options.bundle && !options.minify {
201 return copy_file(entry, output_path);
202 }
203 Ok(run_tool(js_tool, true, options.minify, entry, output_path)?)
204}
205
206fn run_tool_batch(
213 js_tool: JsTool,
214 minify: bool,
215 inputs: &[PathBuf],
216 out_dir: &Path,
217 expected: &[PathBuf],
218) -> Result<(), tool::ToolError> {
219 let args = js_tool.batch_args(minify, inputs, out_dir);
220 let expected: Vec<&Path> = expected.iter().map(PathBuf::as_path).collect();
221 tool::execute(
222 js_tool.binary_name(),
223 js_tool.install_hint(),
224 js_tool.binary_name(),
225 &args,
226 &expected,
227 tool::TOOL_TIMEOUT,
228 )
229}
230
231pub(crate) fn build_js_files(
234 js_tool: JsTool,
235 options: &JsOptions,
236 pairs: &[(PathBuf, PathBuf)],
237) -> Result<(), JsError> {
238 let (transform, bypass): (Vec<_>, Vec<_>) = pairs
239 .iter()
240 .partition(|(source, _)| options.minify && !is_already_minified(source));
241
242 for (source, output) in bypass {
243 copy_file(source, output)?;
244 }
245 if transform.is_empty() {
246 return Ok(());
247 }
248
249 let by_output_dir = group_by_parent(
250 &transform
251 .iter()
252 .map(|(_, output)| output.clone())
253 .collect::<Vec<_>>(),
254 );
255
256 for (out_dir, outputs) in by_output_dir {
257 fs::create_dir_all(&out_dir).map_err(|e| JsError::WriteOutput {
258 path: out_dir.clone(),
259 reason: e.to_string(),
260 })?;
261
262 let group: Vec<&(PathBuf, PathBuf)> = transform
263 .iter()
264 .copied()
265 .filter(|(_, output)| outputs.contains(output))
266 .collect();
267 let inputs: Vec<PathBuf> = group.iter().map(|(source, _)| source.clone()).collect();
268
269 if run_tool_batch(js_tool, true, &inputs, &out_dir, &outputs).is_err() {
270 for (source, output) in group {
271 build_js_file(js_tool, options, source, output)?;
272 }
273 }
274 }
275
276 Ok(())
277}
278
279pub(crate) fn build_js_file(
280 js_tool: JsTool,
281 options: &JsOptions,
282 source: &Path,
283 output: &Path,
284) -> Result<(), JsError> {
285 if !options.minify || is_already_minified(source) {
286 return copy_file(source, output);
287 }
288
289 if let Err(e) = run_tool(js_tool, false, true, source, output) {
290 eprintln!(
291 "js tool: minify failed for {}, serving raw bytes: {e}",
292 source.display()
293 );
294 return copy_file(source, output);
295 }
296 Ok(())
297}
298
299fn run_tool(
300 js_tool: JsTool,
301 bundle: bool,
302 minify: bool,
303 entry: &Path,
304 output: &Path,
305) -> Result<(), tool::ToolError> {
306 if let Some(parent) = output.parent() {
307 let _ = fs::create_dir_all(parent);
308 }
309 let args = js_tool.args(bundle, minify, entry, output);
310 tool::execute(
311 js_tool.binary_name(),
312 js_tool.install_hint(),
313 js_tool.binary_name(),
314 &args,
315 &[output],
316 tool::TOOL_TIMEOUT,
317 )
318}
319
320fn is_already_minified(path: &Path) -> bool {
324 path.file_name()
325 .and_then(|name| name.to_str())
326 .is_some_and(|name| name.ends_with(".min.js"))
327}
328
329fn copy_file(source: &Path, output: &Path) -> Result<(), JsError> {
330 if let Some(parent) = output.parent() {
331 fs::create_dir_all(parent).map_err(|e| JsError::WriteOutput {
332 path: output.to_path_buf(),
333 reason: e.to_string(),
334 })?;
335 }
336 fs::copy(source, output).map_err(|e| JsError::WriteOutput {
337 path: output.to_path_buf(),
338 reason: e.to_string(),
339 })?;
340 Ok(())
341}
342
343#[cfg(test)]
344#[path = "../tests/unit/js.rs"]
345mod tests;