commit aebf6678a8ea81ef48ac24c0a259130d9ab9e42e Author: Jonathan Kelley Date: Wed Jul 7 16:54:14 2021 -0400 wip: move out of dioxus main diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..96ef6c0b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..80f51cff8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "rust-analyzer.inlayHints.enable": false +} \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..299a26ae6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "dioxus-studio" +version = "0.1.0" +authors = ["Jonathan Kelley "] +edition = "2018" +description = "CLI tool for developing, testing, and publishing Dioxus apps" +"license" = "MIT/Apache-2.0" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +thiserror = "1.0.23" +log = "0.4.13" +fern = { version="0.6.0", features=["colored"] } +wasm-bindgen-cli-support = "0.2.73" +anyhow = "1.0.38" +argh = "0.1.4" +serde = "1.0.120" +serde_json = "1.0.61" +async-std = { version="1.9.0", features=["attributes"] } +tide = "0.15.0" +fs_extra = "1.2.0" + +cargo_toml = "0.8.1" +futures = "0.3.12" +notify = "5.0.0-pre.4" +rjdebounce = "0.2.1" +tempfile = "3.2.0" + +[[bin]] + +path = "src/main.rs" +name = "dioxus" diff --git a/README.md b/README.md new file mode 100644 index 000000000..bf2e3310a --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +
+

📦✨ dioxus-cli

+

+ Tooling to supercharge dioxus projects +

+
+ +# About +--- +dioxus-cli (inspired by wasm-pack and webpack) is a tool to help get dioxus projects off the ground. It handles all the build, development, bundling, and publishing to make web development just a simple two commands: `cargo init` and `dioxus-cli publish`. + +Best thing: +- No NPM. +- No Webpack. +- No `node_modules`. +- No Babel +- No parcel +- No rollup +- No ESLint + +Just install Rust, dioxus-cli, and you're good to go. +`cargo install --git github.com/jkelleyrtp/dioxus-cli` + +Need a development server? +`dioxus develop` + +Need to run an example? +`dioxus develop --example textbox` + +Need to benchmark a component? +`dioxus bench` + +Need to test your code? +`dioxus test` + +Need to build your code into a bundle? +`dioxus build --outdir public` + +Need to publish your code to GitHub pages, Netlify, etc? +`dioxus publish --ghpages myrepo.git` + +# Use in your project +--- +Sometimes you'll want to include static assets without bundling them into your .wasm content. dioxus-cli provides a few ways of doing this: + +- Load in dynamic content using `dioxus::asset("./static/images/blah.svg")` +- Live-reload HTML templates without rebuilding your .wasm with `dioxus::template("./templates/blah.html")` +- Use a CSS library like tailwind in your dioxus configuration with diff --git a/src/builder.rs b/src/builder.rs new file mode 100644 index 000000000..1d06b3ae8 --- /dev/null +++ b/src/builder.rs @@ -0,0 +1,146 @@ +use crate::{ + cli::BuildOptions, + config::{Config, ExecutableType}, + error::Result, +}; +use log::{info, warn}; +use std::{io::Write, process::Command}; +use wasm_bindgen_cli_support::Bindgen; + +pub struct BuildConfig {} +impl Into for BuildOptions { + fn into(self) -> BuildConfig { + BuildConfig {} + } +} +impl Default for BuildConfig { + fn default() -> Self { + Self {} + } +} + +pub fn build(config: &Config, _build_config: &BuildConfig) -> Result<()> { + /* + [1] Build the project with cargo, generating a wasm32-unknown-unknown target (is there a more specific, better target to leverage?) + [2] Generate the appropriate build folders + [3] Wasm-bindgen the .wasm fiile, and move it into the {builddir}/modules/xxxx/xxxx_bg.wasm + [4] Wasm-opt the .wasm file with whatever optimizations need to be done + [5] Link up the html page to the wasm module + */ + + let Config { + out_dir, + crate_dir, + target_dir, + static_dir, + executable, + .. + } = config; + + let t_start = std::time::Instant::now(); + + // [1] Build the .wasm module + info!("Running build commands..."); + let mut cmd = Command::new("cargo"); + cmd.current_dir(&crate_dir) + .arg("build") + .arg("--target") + .arg("wasm32-unknown-unknown") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + if config.release { + cmd.arg("--release"); + } + + match executable { + ExecutableType::Binary(name) => cmd.arg("--bin").arg(name), + ExecutableType::Lib(name) => cmd.arg("--lib").arg(name), + ExecutableType::Example(name) => cmd.arg("--example").arg(name), + }; + + let mut child = cmd.spawn()?; + let _err_code = child.wait()?; + + info!("Build complete!"); + + // [2] Establish the output directory structure + let bindgen_outdir = out_dir.join("wasm"); + + // [3] Bindgen the final binary for use easy linking + let mut bindgen_builder = Bindgen::new(); + + let release_type = match config.release { + true => "release", + false => "debug", + }; + + let input_path = match executable { + ExecutableType::Binary(name) | ExecutableType::Lib(name) => target_dir + // .join("wasm32-unknown-unknown/release") + .join(format!("wasm32-unknown-unknown/{}", release_type)) + .join(format!("{}.wasm", name)), + + ExecutableType::Example(name) => target_dir + // .join("wasm32-unknown-unknown/release/examples") + .join(format!("wasm32-unknown-unknown/{}/examples", release_type)) + .join(format!("{}.wasm", name)), + }; + + bindgen_builder + .input_path(input_path) + .web(true)? + .debug(true) + .demangle(true) + .keep_debug(true) + .remove_name_section(false) + .remove_producers_section(false) + .out_name("module") + .generate(&bindgen_outdir)?; + + // [4] + // TODO: wasm-opt + + // [5] Generate the html file with the module name + // TODO: support names via options + info!("Writing to '{:#?}' directory...", out_dir); + let mut file = std::fs::File::create(out_dir.join("index.html"))?; + file.write_all(gen_page("./wasm/module.js").as_str().as_bytes())?; + + let copy_options = fs_extra::dir::CopyOptions::new(); + match fs_extra::dir::copy(static_dir, out_dir, ©_options) { + Ok(_) => {} + Err(_e) => { + warn!("Error copying dir"); + } + } + + let t_end = std::time::Instant::now(); + log::info!("Done in {}ms! 🎉", (t_end - t_start).as_millis()); + Ok(()) +} + +fn gen_page(module: &str) -> String { + format!( + r#" + + + + + + + + + + + +"#, + module + ) +} diff --git a/src/cargo.rs b/src/cargo.rs new file mode 100644 index 000000000..3a911a81e --- /dev/null +++ b/src/cargo.rs @@ -0,0 +1,69 @@ +//! Utilities for working with cargo and rust files +use crate::error::{Error, Result}; +use std::{env, fs, path::PathBuf, process::Command, str}; + +/// How many parent folders are searched for a `Cargo.toml` +const MAX_ANCESTORS: u32 = 10; + +/// Returns the root of the crate that the command is run from +/// +/// If the command is run from the workspace root, this will return the top-level Cargo.toml +pub fn crate_root() -> Result { + // From the current directory we work our way up, looking for `Cargo.toml` + env::current_dir() + .ok() + .and_then(|mut wd| { + for _ in 0..MAX_ANCESTORS { + if contains_manifest(&wd) { + return Some(wd); + } + if !wd.pop() { + break; + } + } + None + }) + .ok_or_else(|| Error::CargoError("Failed to find the cargo directory".to_string())) +} + +/// Returns the root of a workspace +/// TODO @Jon, find a different way that doesn't rely on the cargo metadata command (it's slow) +pub fn workspace_root() -> Result { + let output = Command::new("cargo") + .args(&["metadata"]) + .output() + .map_err(|_| Error::CargoError("Manifset".to_string()))?; + + if !output.status.success() { + let mut msg = str::from_utf8(&output.stderr).unwrap().trim(); + if msg.starts_with("error: ") { + msg = &msg[7..]; + } + + return Err(Error::CargoError(msg.to_string())); + } + + let stdout = str::from_utf8(&output.stdout).unwrap(); + for line in stdout.lines() { + let meta: serde_json::Value = serde_json::from_str(line) + .map_err(|_| Error::CargoError("InvalidOutput".to_string()))?; + + let root = meta["workspace_root"] + .as_str() + .ok_or_else(|| Error::CargoError("InvalidOutput".to_string()))?; + return Ok(root.into()); + } + + Err(Error::CargoError("InvalidOutput".to_string())) +} + +/// Checks if the directory contains `Cargo.toml` +fn contains_manifest(path: &PathBuf) -> bool { + fs::read_dir(path) + .map(|entries| { + entries + .filter_map(Result::ok) + .any(|ent| &ent.file_name() == "Cargo.toml") + }) + .unwrap_or(false) +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 000000000..852d4ee71 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,62 @@ +use argh::FromArgs; + +#[derive(FromArgs, PartialEq, Debug)] +/// Top-level command. +pub struct LaunchOptions { + #[argh(subcommand)] + pub command: LaunchCommand, +} + +/// The various kinds of commands that `wasm-pack` can execute. +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand)] +pub enum LaunchCommand { + Develop(DevelopOptions), + Build(BuildOptions), + Test(TestOptions), + Publish(PublishOptions), +} + +/// Publish your yew application to Github Pages, Netlify, or S3 +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand, name = "publish")] +pub struct PublishOptions {} + +/// 🔬 test your yew application! +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand, name = "test")] +pub struct TestOptions { + /// an example in the crate + #[argh(option)] + pub example: Option, +} + +/// 🏗️ Build your yew application +#[derive(FromArgs, PartialEq, Debug, Clone)] +#[argh(subcommand, name = "build")] +pub struct BuildOptions { + /// an optional direction which is "up" by default + #[argh(option, short = 'o', default = "String::from(\"public\")")] + pub outdir: String, + + /// an example in the crate + #[argh(option)] + pub example: Option, + + /// develop in release mode + #[argh(switch, short = 'r')] + pub release: bool, +} + +/// 🛠 Start a development server +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand, name = "develop")] +pub struct DevelopOptions { + /// an example in the crate + #[argh(option)] + pub example: Option, + + /// develop in release mode + #[argh(switch, short = 'r')] + pub release: bool, +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 000000000..02a04eaf7 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,92 @@ +use crate::{ + cli::{BuildOptions, DevelopOptions}, + error::Result, +}; +use std::{io::Write, path::PathBuf, process::Command}; + +#[derive(Debug, Clone)] +pub struct Config { + pub out_dir: PathBuf, + pub crate_dir: PathBuf, + pub workspace_dir: PathBuf, + pub target_dir: PathBuf, + pub static_dir: PathBuf, + pub manifest: cargo_toml::Manifest, + pub executable: ExecutableType, + pub release: bool, +} + +#[derive(Debug, Clone)] +pub enum ExecutableType { + Binary(String), + Lib(String), + Example(String), +} + +impl Config { + pub fn new() -> Result { + let crate_dir = crate::cargo::crate_root()?; + let workspace_dir = crate::cargo::workspace_root()?; + let target_dir = workspace_dir.join("target"); + let out_dir = crate_dir.join("public"); + let cargo_def = &crate_dir.join("Cargo.toml"); + let static_dir = crate_dir.join("static"); + + let manifest = cargo_toml::Manifest::from_path(&cargo_def).unwrap(); + + // We just assume they're using a 'main.rs' + // Anyway, we've already parsed the manifest, so it should be easy to change the type + let output_filename = (&manifest) + .lib + .as_ref() + .and_then(|lib| lib.name.clone()) + .or_else(|| { + (&manifest) + .package + .as_ref() + .and_then(|pkg| Some(pkg.name.replace("-", "_"))) + .clone() + }) + .expect("No lib found from cargo metadata"); + let executable = ExecutableType::Binary(output_filename); + + let release = false; + + Ok(Self { + out_dir, + crate_dir, + workspace_dir, + target_dir, + static_dir, + manifest, + executable, + release, + }) + } + + pub fn as_example(&mut self, example_name: String) -> &mut Self { + self.executable = ExecutableType::Example(example_name); + self + } + + pub fn with_release(&mut self, release: bool) -> &mut Self { + self.release = release; + self + } + + pub fn with_build_options(&mut self, options: &BuildOptions) { + if let Some(name) = &options.example { + self.as_example(name.clone()); + } + self.release = options.release; + self.out_dir = options.outdir.clone().into(); + } + + pub fn with_develop_options(&mut self, options: &DevelopOptions) { + if let Some(name) = &options.example { + self.as_example(name.clone()); + } + self.release = options.release; + self.out_dir = tempfile::Builder::new().tempdir().expect("").into_path(); + } +} diff --git a/src/develop.rs b/src/develop.rs new file mode 100644 index 000000000..1ae016b0d --- /dev/null +++ b/src/develop.rs @@ -0,0 +1,111 @@ +use crate::{builder::BuildConfig, cli::DevelopOptions, config::Config, error::Result}; +use async_std::prelude::FutureExt; + +use async_std::future; +use async_std::prelude::*; + +use log::info; +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; +use std::path::PathBuf; +use std::time::Duration; + +pub struct DevelopConfig {} +impl Into for DevelopOptions { + fn into(self) -> DevelopConfig { + DevelopConfig {} + } +} + +pub async fn start(config: &Config, _options: &DevelopConfig) -> Result<()> { + log::info!("Starting development server 🚀"); + + let Config { out_dir, .. } = config; + + // Spawn the server onto a seperate task + // This lets the task progress while we handle file updates + let server = async_std::task::spawn(launch_server(out_dir.clone())); + let watcher = async_std::task::spawn(watch_directory(config.clone())); + + match server.race(watcher).await { + Err(e) => log::warn!("Error running development server, {:?}", e), + _ => {} + } + + Ok(()) +} + +async fn watch_directory(config: Config) -> Result<()> { + // Create a channel to receive the events. + let (watcher_tx, watcher_rx) = async_std::channel::bounded(100); + + // Automatically select the best implementation for your platform. + // You can also access each implementation directly e.g. INotifyWatcher. + let mut watcher: RecommendedWatcher = Watcher::new_immediate(move |res| { + async_std::task::block_on(watcher_tx.send(res)); + }) + .expect("failed to make watcher"); + + let src_dir = crate::cargo::crate_root()?; + + // Add a path to be watched. All files and directories at that path and + // below will be monitored for changes. + watcher + .watch(src_dir.join("src"), RecursiveMode::Recursive) + .expect("Failed to watch dir"); + + watcher + .watch(src_dir.join("examples"), RecursiveMode::Recursive) + .expect("Failed to watch dir"); + + let build_config = BuildConfig::default(); + + 'run: loop { + crate::builder::build(&config, &build_config)?; + + // Wait for the message with a debounce + let _msg = watcher_rx + .recv() + .join(future::ready(1_usize).delay(Duration::from_millis(2000))) + .await; + + info!("File updated, rebuilding app"); + } + Ok(()) +} + +async fn launch_server(outdir: PathBuf) -> Result<()> { + let _crate_dir = crate::cargo::crate_root()?; + let _workspace_dir = crate::cargo::workspace_root()?; + + let mut app = tide::with_state(ServerState::new(outdir.to_owned())); + let p = outdir.display().to_string(); + + app.at("/") + .get(|req: tide::Request| async move { + log::info!("Connected to development server"); + let state = req.state(); + Ok(tide::Body::from_file(state.serv_path.clone().join("index.html")).await?) + }) + .serve_dir(p)?; + + let port = "8080"; + let serve_addr = format!("0.0.0.0:{}", port); + // let serve_addr = format!("127.0.0.1:{}", port); + + info!("App available at http://{}", serve_addr); + app.listen(serve_addr).await?; + Ok(()) +} + +/// https://github.com/http-rs/tide/blob/main/examples/state.rs +/// Tide seems to prefer using state instead of injecting into the app closure +/// The app closure needs to be static and +#[derive(Clone)] +struct ServerState { + serv_path: PathBuf, +} +impl ServerState { + fn new(serv_path: PathBuf) -> Self { + Self { serv_path } + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 000000000..80a884956 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,34 @@ +use thiserror::Error as ThisError; + +pub type Result = std::result::Result; + +#[derive(ThisError, Debug)] +pub enum Error { + /// Used when errors need to propogate but are too unique to be typed + #[error("{0}")] + Unique(String), + + #[error("I/O Error: {0}")] + IO(#[from] std::io::Error), + + #[error("Failed to write error")] + FailedToWrite, + + #[error("Failed to write error")] + CargoError(String), + + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +impl From<&str> for Error { + fn from(s: &str) -> Self { + Error::Unique(s.to_string()) + } +} + +impl From for Error { + fn from(s: String) -> Self { + Error::Unique(s) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 000000000..d6e7c838d --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,8 @@ +pub mod builder; +pub mod cargo; +pub mod cli; +pub mod config; +pub mod develop; +pub mod error; +pub mod logging; +pub mod watch; diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 000000000..94a9ce85b --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,49 @@ +use fern::colors::{Color, ColoredLevelConfig}; +use log::debug; + +pub fn set_up_logging() { + // configure colors for the whole line + let colors_line = ColoredLevelConfig::new() + .error(Color::Red) + .warn(Color::Yellow) + // we actually don't need to specify the color for debug and info, they are white by default + .info(Color::White) + .debug(Color::White) + // depending on the terminals color scheme, this is the same as the background color + .trace(Color::BrightBlack); + + // configure colors for the name of the level. + // since almost all of them are the same as the color for the whole line, we + // just clone `colors_line` and overwrite our changes + let colors_level = colors_line.clone().info(Color::Green); + // here we set up our fern Dispatch + fern::Dispatch::new() + .format(move |out, message, record| { + out.finish(format_args!( + "{color_line}[{level}{color_line}] {message}\x1B[0m", + color_line = format_args!( + "\x1B[{}m", + colors_line.get_color(&record.level()).to_fg_str() + ), + level = colors_level.color(record.level()), + message = message, + )); + }) + // set the default log level. to filter out verbose log messages from dependencies, set + // this to Warn and overwrite the log level for your crate. + .level(log::LevelFilter::Info) + // .level(log::LevelFilter::Warn) + // change log levels for individual modules. Note: This looks for the record's target + // field which defaults to the module path but can be overwritten with the `target` + // parameter: + // `info!(target="special_target", "This log message is about special_target");` + // .level_for("dioxus", log::LevelFilter::Debug) + // .level_for("dioxus", log::LevelFilter::Info) + // .level_for("pretty_colored", log::LevelFilter::Trace) + // output to stdout + .chain(std::io::stdout()) + .apply() + .unwrap(); + + debug!("finished setting up logging! yay!"); +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 000000000..7a5df372c --- /dev/null +++ b/src/main.rs @@ -0,0 +1,28 @@ +use diopack::cli::{LaunchCommand, LaunchOptions}; +use dioxus_cli as diopack; + +#[async_std::main] +async fn main() -> diopack::error::Result<()> { + diopack::logging::set_up_logging(); + + let opts: LaunchOptions = argh::from_env(); + let mut config = diopack::config::Config::new()?; + + match opts.command { + LaunchCommand::Build(options) => { + config.with_build_options(&options); + diopack::builder::build(&config, &(options.into()))?; + } + + LaunchCommand::Develop(options) => { + config.with_develop_options(&options); + diopack::develop::start(&config, &(options.into())).await?; + } + + _ => { + todo!("Command not currently implemented"); + } + } + + Ok(()) +} diff --git a/src/watch.rs b/src/watch.rs new file mode 100644 index 000000000..ae39bc670 --- /dev/null +++ b/src/watch.rs @@ -0,0 +1,11 @@ +pub struct Watcher {} + +impl Watcher { + // Spawns a new notify instance that alerts us of changes + pub fn new() -> Self { + Self {} + } + + /// Subsribe to the watcher + pub fn subscribe() {} +}