xtask_wasm/
dist.rs

1use crate::{
2    anyhow::{ensure, Context, Result},
3    camino, clap, default_build_command, metadata,
4};
5use lazy_static::lazy_static;
6use std::{fs, path::PathBuf, process};
7use wasm_bindgen_cli_support::Bindgen;
8
9/// A helper to generate the distributed package.
10///
11/// # Usage
12///
13/// ```rust,no_run
14/// use std::process;
15/// use xtask_wasm::{anyhow::Result, clap};
16///
17/// #[derive(clap::Parser)]
18/// enum Opt {
19///     Dist(xtask_wasm::Dist),
20/// }
21///
22/// fn main() -> Result<()> {
23///     let opt: Opt = clap::Parser::parse();
24///
25///     match opt {
26///         Opt::Dist(dist) => {
27///             log::info!("Generating package...");
28///
29///             dist
30///                 .static_dir_path("my-project/static")
31///                 .app_name("my-project")
32///                 .run_in_workspace(true)
33///                 .run("my-project")?;
34///         }
35///     }
36///
37///     Ok(())
38/// }
39/// ```
40///
41/// In this example, we added a `dist` subcommand to build and package the
42/// `my-project` crate. It will run the [`default_build_command`](crate::default_build_command)
43/// at the workspace root, copy the content of the `project/static` directory,
44/// generate JS bindings and output two files: `project.js` and `project.wasm`
45/// into the dist directory.
46#[non_exhaustive]
47#[derive(Debug, clap::Parser)]
48#[clap(
49    about = "Generate the distributed package.",
50    long_about = "Generate the distributed package.\n\
51        It will build and package the project for WASM."
52)]
53pub struct Dist {
54    /// No output printed to stdout.
55    #[clap(short, long)]
56    pub quiet: bool,
57    /// Number of parallel jobs, defaults to # of CPUs.
58    #[clap(short, long)]
59    pub jobs: Option<String>,
60    /// Build artifacts with the specified profile.
61    #[clap(long)]
62    pub profile: Option<String>,
63    /// Build artifacts in release mode, with optimizations.
64    #[clap(long)]
65    pub release: bool,
66    /// Space or comma separated list of features to activate.
67    #[clap(long)]
68    pub features: Vec<String>,
69    /// Activate all available features.
70    #[clap(long)]
71    pub all_features: bool,
72    /// Do not activate the `default` features.
73    #[clap(long)]
74    pub no_default_features: bool,
75    /// Use verbose output
76    #[clap(short, long)]
77    pub verbose: bool,
78    /// Coloring: auto, always, never.
79    #[clap(long)]
80    pub color: Option<String>,
81    /// Require Cargo.lock and cache are up to date.
82    #[clap(long)]
83    pub frozen: bool,
84    /// Require Cargo.lock is up to date.
85    #[clap(long)]
86    pub locked: bool,
87    /// Run without accessing the network.
88    #[clap(long)]
89    pub offline: bool,
90    /// Ignore `rust-version` specification in packages.
91    #[clap(long)]
92    pub ignore_rust_version: bool,
93    /// Name of the example target to run.
94    #[clap(long)]
95    pub example: Option<String>,
96
97    /// Command passed to the build process.
98    #[clap(skip = default_build_command())]
99    pub build_command: process::Command,
100    /// Directory of all generated artifacts.
101    #[clap(skip)]
102    pub dist_dir_path: Option<PathBuf>,
103    /// Directory of all static artifacts.
104    #[clap(skip)]
105    pub static_dir_path: Option<PathBuf>,
106    /// Set the resulting app name, default to `app`.
107    #[clap(skip)]
108    pub app_name: Option<String>,
109    /// Set the command's current directory as the workspace root.
110    #[clap(skip = true)]
111    pub run_in_workspace: bool,
112    /// Output style for SASS/SCSS
113    #[cfg(feature = "sass")]
114    #[clap(skip)]
115    pub sass_options: sass_rs::Options,
116}
117
118impl Dist {
119    /// Set the command used by the build process.
120    ///
121    /// The default command is the result of the [`default_build_command`].
122    pub fn build_command(mut self, command: process::Command) -> Self {
123        self.build_command = command;
124        self
125    }
126
127    /// Set the directory for the generated artifacts.
128    ///
129    /// The default for debug build is `target/debug/dist` and
130    /// `target/release/dist` for the release build.
131    pub fn dist_dir_path(mut self, path: impl Into<PathBuf>) -> Self {
132        self.dist_dir_path = Some(path.into());
133        self
134    }
135
136    /// Set the directory for the static artifacts (like `index.html`).
137    pub fn static_dir_path(mut self, path: impl Into<PathBuf>) -> Self {
138        self.static_dir_path = Some(path.into());
139        self
140    }
141
142    /// Set the resulting package name.
143    ///
144    /// The default is `app`.
145    pub fn app_name(mut self, app_name: impl Into<String>) -> Self {
146        self.app_name = Some(app_name.into());
147        self
148    }
149
150    /// Set the dist process current directory as the workspace root.
151    pub fn run_in_workspace(mut self, res: bool) -> Self {
152        self.run_in_workspace = res;
153        self
154    }
155
156    #[cfg(feature = "sass")]
157    /// Set the output style for SCSS/SASS
158    pub fn sass_options(mut self, output_style: sass_rs::Options) -> Self {
159        self.sass_options = output_style;
160        self
161    }
162
163    /// Set the example to build.
164    pub fn example(mut self, example: impl Into<String>) -> Self {
165        self.example = Some(example.into());
166        self
167    }
168
169    /// Build the given package for Wasm.
170    ///
171    /// This will generate JS bindings via [`wasm-bindgen`](https://docs.rs/wasm-bindgen/latest/wasm_bindgen/)
172    /// and copy files from a given static directory if any to finally return
173    /// the path of the generated artifacts.
174    ///
175    /// Wasm optimizations can be achieved using [`crate::WasmOpt`] if the
176    /// feature `wasm-opt` is enabled.
177    pub fn run(self, package_name: &str) -> Result<PathBuf> {
178        log::trace!("Getting package's metadata");
179        let metadata = metadata();
180
181        let dist_dir_path = self
182            .dist_dir_path
183            .unwrap_or_else(|| default_dist_dir(self.release).as_std_path().to_path_buf());
184
185        log::trace!("Initializing dist process");
186        let mut build_command = self.build_command;
187
188        if self.run_in_workspace {
189            build_command.current_dir(&metadata.workspace_root);
190        }
191
192        if self.quiet {
193            build_command.arg("--quiet");
194        }
195
196        if let Some(number) = self.jobs {
197            build_command.args(["--jobs", &number]);
198        }
199
200        if let Some(profile) = self.profile {
201            build_command.args(["--profile", &profile]);
202        }
203
204        if self.release {
205            build_command.arg("--release");
206        }
207
208        for feature in &self.features {
209            build_command.args(["--features", feature]);
210        }
211
212        if self.all_features {
213            build_command.arg("--all-features");
214        }
215
216        if self.no_default_features {
217            build_command.arg("--no-default-features");
218        }
219
220        if self.verbose {
221            build_command.arg("--verbose");
222        }
223
224        if let Some(color) = self.color {
225            build_command.args(["--color", &color]);
226        }
227
228        if self.frozen {
229            build_command.arg("--frozen");
230        }
231
232        if self.locked {
233            build_command.arg("--locked");
234        }
235
236        if self.offline {
237            build_command.arg("--offline");
238        }
239
240        if self.ignore_rust_version {
241            build_command.arg("--ignore-rust-version");
242        }
243
244        build_command.args(["--package", package_name]);
245
246        if let Some(example) = &self.example {
247            build_command.args(["--example", example]);
248        }
249
250        let build_dir = metadata
251            .target_directory
252            .join("wasm32-unknown-unknown")
253            .join(if self.release { "release" } else { "debug" });
254        let input_path = if let Some(example) = &self.example {
255            build_dir
256                .join("examples")
257                .join(example.replace('-', "_"))
258                .with_extension("wasm")
259        } else {
260            build_dir
261                .join(package_name.replace('-', "_"))
262                .with_extension("wasm")
263        };
264
265        if input_path.exists() {
266            log::trace!("Removing existing target directory");
267            fs::remove_file(&input_path).context("cannot remove existing target")?;
268        }
269
270        log::trace!("Spawning build process");
271        ensure!(
272            build_command
273                .status()
274                .context("could not start cargo")?
275                .success(),
276            "cargo command failed"
277        );
278
279        let app_name = self.app_name.unwrap_or_else(|| "app".to_string());
280
281        log::trace!("Generating Wasm output");
282        let mut output = Bindgen::new()
283            .omit_default_module_path(false)
284            .input_path(input_path)
285            .out_name(&app_name)
286            .web(true)
287            .expect("web have panic")
288            .debug(!self.release)
289            .generate_output()
290            .context("could not generate Wasm bindgen file")?;
291
292        if dist_dir_path.exists() {
293            log::trace!("Removing already existing dist directory");
294            fs::remove_dir_all(&dist_dir_path)?;
295        }
296
297        log::trace!("Writing outputs to dist directory");
298        output.emit(&dist_dir_path)?;
299
300        if let Some(static_dir) = self.static_dir_path {
301            #[cfg(feature = "sass")]
302            {
303                log::trace!("Generating CSS files from SASS/SCSS");
304                sass(&static_dir, &dist_dir_path, &self.sass_options)?;
305            }
306
307            #[cfg(not(feature = "sass"))]
308            {
309                let mut copy_options = fs_extra::dir::CopyOptions::new();
310                copy_options.overwrite = true;
311                copy_options.content_only = true;
312
313                log::trace!("Copying static directory into dist directory");
314                fs_extra::dir::copy(static_dir, &dist_dir_path, &copy_options)
315                    .context("cannot copy static directory")?;
316            }
317        }
318
319        log::info!("Successfully built in {}", dist_dir_path.display());
320
321        Ok(dist_dir_path)
322    }
323}
324
325impl Default for Dist {
326    fn default() -> Dist {
327        Dist {
328            quiet: Default::default(),
329            jobs: Default::default(),
330            profile: Default::default(),
331            release: Default::default(),
332            features: Default::default(),
333            all_features: Default::default(),
334            no_default_features: Default::default(),
335            verbose: Default::default(),
336            color: Default::default(),
337            frozen: Default::default(),
338            locked: Default::default(),
339            offline: Default::default(),
340            ignore_rust_version: Default::default(),
341            example: Default::default(),
342            build_command: default_build_command(),
343            dist_dir_path: Default::default(),
344            static_dir_path: Default::default(),
345            app_name: Default::default(),
346            run_in_workspace: Default::default(),
347            #[cfg(feature = "sass")]
348            sass_options: Default::default(),
349        }
350    }
351}
352
353#[cfg(feature = "sass")]
354fn sass(
355    static_dir: &std::path::Path,
356    dist_dir: &std::path::Path,
357    options: &sass_rs::Options,
358) -> Result<()> {
359    fn is_sass(path: &std::path::Path) -> bool {
360        matches!(
361            path.extension()
362                .and_then(|x| x.to_str().map(|x| x.to_lowercase()))
363                .as_deref(),
364            Some("sass") | Some("scss")
365        )
366    }
367
368    fn should_ignore(path: &std::path::Path) -> bool {
369        path.file_name()
370            .expect("WalkDir does not yield paths ending with `..`  or `.`")
371            .to_str()
372            .map(|x| x.starts_with('_'))
373            .unwrap_or(false)
374    }
375
376    log::trace!("Generating dist artifacts");
377    let walker = walkdir::WalkDir::new(static_dir);
378    for entry in walker {
379        let entry = entry
380            .with_context(|| format!("cannot walk into directory `{}`", &static_dir.display()))?;
381        let source = entry.path();
382        let dest = dist_dir.join(source.strip_prefix(static_dir).unwrap());
383        let _ = fs::create_dir_all(dest.parent().unwrap());
384
385        if !source.is_file() {
386            continue;
387        } else if is_sass(source) {
388            if !should_ignore(source) {
389                let dest = dest.with_extension("css");
390
391                let css = sass_rs::compile_file(source, options.clone())
392                    .expect("could not convert SASS/ file");
393                fs::write(&dest, css)
394                    .with_context(|| format!("could not write CSS to file `{}`", dest.display()))?;
395            }
396        } else {
397            fs::copy(source, &dest).with_context(|| {
398                format!("cannot move `{}` to `{}`", source.display(), dest.display())
399            })?;
400        }
401    }
402
403    Ok(())
404}
405
406/// Get the default dist directory.
407///
408/// The default for debug build is `target/debug/dist` and `target/release/dist`
409/// for the release build.
410pub fn default_dist_dir(release: bool) -> &'static camino::Utf8Path {
411    lazy_static! {
412        static ref DEFAULT_RELEASE_PATH: camino::Utf8PathBuf =
413            metadata().target_directory.join("release").join("dist");
414        static ref DEFAULT_DEBUG_PATH: camino::Utf8PathBuf =
415            metadata().target_directory.join("debug").join("dist");
416    }
417
418    if release {
419        &DEFAULT_RELEASE_PATH
420    } else {
421        &DEFAULT_DEBUG_PATH
422    }
423}