diff --git a/Cargo.lock b/Cargo.lock index d7300c21de..8876dcadba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5334,6 +5334,7 @@ dependencies = [ "dioxus-cli-config", "dioxus-cli-opt", "dioxus-cli-telemetry", + "dioxus-component-manifest", "dioxus-core", "dioxus-core-types", "dioxus-devtools-types", @@ -5352,6 +5353,7 @@ dependencies = [ "fs2", "futures-channel", "futures-util", + "git2", "handlebars", "headers", "html_parser", @@ -5497,6 +5499,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "dioxus-component-manifest" +version = "0.7.0-rc.0" +dependencies = [ + "schemars 1.0.4", + "serde", + "serde_json", +] + [[package]] name = "dioxus-config-macro" version = "0.7.0-rc.0" @@ -14721,10 +14732,23 @@ checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" dependencies = [ "dyn-clone", "ref-cast", + "schemars_derive", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.104", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -15078,16 +15102,28 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index cdfe924f2d..b0e31e0e79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,7 @@ members = [ "packages/native-dom", "packages/asset-resolver", "packages/depinfo", + "packages/component-manifest", # CLI harnesses, all included "packages/cli-harnesses/*", @@ -182,6 +183,7 @@ dioxus-native = { path = "packages/native", version = "0.7.0-rc.0" } dioxus-native-dom = { path = "packages/native-dom", version = "0.7.0-rc.0" } dioxus-asset-resolver = { path = "packages/asset-resolver", version = "0.7.0-rc.0" } dioxus-config-macros = { path = "packages/config-macros", version = "0.7.0-rc.0" } +dioxus-component-manifest = { path = "packages/component-manifest", version = "0.7.0-rc.0" } generational-box = { path = "packages/generational-box", version = "0.7.0-rc.0" } lazy-js-bundle = { path = "packages/lazy-js-bundle", version = "0.7.0-rc.0" } @@ -948,4 +950,4 @@ doc-scrape-examples = true [[example]] name = "__scrape_example_list" -path = "examples/scripts/scrape_examples.rs" \ No newline at end of file +path = "examples/scripts/scrape_examples.rs" diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml index bd7154e0d2..908ba93143 100644 --- a/packages/cli/Cargo.toml +++ b/packages/cli/Cargo.toml @@ -27,6 +27,7 @@ wasm-split-cli = { workspace = true } depinfo = { workspace = true } subsecond-types = { workspace = true } dioxus-cli-telemetry = { workspace = true } +dioxus-component-manifest = { workspace = true } clap = { workspace = true, features = ["derive", "cargo"] } convert_case = { workspace = true } @@ -78,6 +79,7 @@ dunce = { workspace = true } dirs = { workspace = true } reqwest = { workspace = true, features = ["rustls-tls", "trust-dns", "json"] } tower = { workspace = true } +git2 = "0.20.2" # path lookup which = { version = "8.0.0" } diff --git a/packages/cli/src/build/builder.rs b/packages/cli/src/build/builder.rs index efaf87d5c8..cfc34c8af0 100644 --- a/packages/cli/src/build/builder.rs +++ b/packages/cli/src/build/builder.rs @@ -1,6 +1,6 @@ use crate::{ - serve::WebServer, BuildArtifacts, BuildRequest, BuildStage, BuilderUpdate, BundleFormat, - ProgressRx, ProgressTx, Result, RustcArgs, StructuredOutput, + serve::WebServer, verbosity_or_default, BuildArtifacts, BuildRequest, BuildStage, + BuilderUpdate, BundleFormat, ProgressRx, ProgressTx, Result, RustcArgs, StructuredOutput, }; use anyhow::{bail, Context}; use dioxus_cli_opt::process_file_to; @@ -489,11 +489,7 @@ impl AppBuilder { )); } - if crate::VERBOSITY - .get() - .map(|f| f.verbose) - .unwrap_or_default() - { + if verbosity_or_default().verbose { envs.push(("RUST_BACKTRACE".into(), "1".to_string())); } diff --git a/packages/cli/src/build/request.rs b/packages/cli/src/build/request.rs index d2fc71df01..d63cfcb209 100644 --- a/packages/cli/src/build/request.rs +++ b/packages/cli/src/build/request.rs @@ -2511,7 +2511,7 @@ impl BuildRequest { cargo_args.push(self.executable_name().to_string()); // Set offline/locked/frozen - let lock_opts = crate::VERBOSITY.get().cloned().unwrap_or_default(); + let lock_opts = crate::verbosity_or_default(); if lock_opts.frozen { cargo_args.push("--frozen".to_string()); } diff --git a/packages/cli/src/cli/component.rs b/packages/cli/src/cli/component.rs new file mode 100644 index 0000000000..701c8cd372 --- /dev/null +++ b/packages/cli/src/cli/component.rs @@ -0,0 +1,821 @@ +use std::{ + collections::{HashMap, HashSet}, + ops::Deref, + path::{Path, PathBuf}, +}; + +use crate::{verbosity_or_default, DioxusConfig, Result, StructuredOutput, Workspace}; +use anyhow::{bail, Context}; +use clap::Parser; +use dioxus_component_manifest::{ + component_manifest_schema, CargoDependency, Component, ComponentDependency, +}; +use git2::Repository; +use serde::{Deserialize, Serialize}; +use tokio::{process::Command, task::JoinSet}; +use tracing::debug; + +#[derive(Clone, Debug, Parser)] +pub enum ComponentCommand { + /// Add a component from a registry + Add { + #[clap(flatten)] + component: ComponentArgs, + + /// The registry to use + #[clap(flatten)] + registry: Option, + + /// Overwrite the component if it already exists + #[clap(long)] + force: bool, + }, + + /// Remove a component + Remove { + #[clap(flatten)] + component: ComponentArgs, + + /// The registry to use + #[clap(flatten)] + registry: Option, + }, + + /// Update a component registry + Update { + /// The registry to update + #[clap(flatten)] + registry: Option, + }, + + /// List available components in a registry + List { + /// The registry to list components in + #[clap(flatten)] + registry: Option, + }, + + /// Clear the component registry cache + Clean, + + /// Print the schema for component manifests + Schema, +} + +/// Arguments for a component and component module location +#[derive(Clone, Debug, Parser, Serialize)] +pub struct ComponentArgs { + /// The components to add or remove + #[clap(required_unless_present = "all", value_delimiter = ',')] + components: Vec, + + /// The location of the component module in your project (default: src/components) + #[clap(long)] + module_path: Option, + + /// The location of the global assets in your project (default: assets) + #[clap(long)] + global_assets_path: Option, + + /// Include all components in the registry + #[clap(long)] + all: bool, +} + +impl ComponentCommand { + /// Run the component command + pub async fn run(self) -> Result { + match self { + // List all components in the registry + Self::List { registry } => { + let config = Self::resolve_config().await?; + let registry = Self::resolve_registry(registry, &config)?; + let mut components = registry.read_components().await?; + components.sort_by_key(|c| c.name.clone()); + for component in components { + println!("- {}: {}", component.name, component.description); + } + } + + // Add a component to the managed component module + Self::Add { + component: component_args, + registry, + force, + } => { + // Resolve the config + let config = Self::resolve_config().await?; + + // Resolve the registry + let registry = Self::resolve_registry(registry, &config)?; + + // Read all components from the registry + let components = registry.read_components().await?; + let mode = if force { + ComponentExistsBehavior::Overwrite + } else { + ComponentExistsBehavior::Error + }; + + // Find the requested components + let components = if component_args.all { + components + } else { + component_args + .components + .iter() + .map(|component| find_component(&components, component)) + .collect::>>()? + }; + + // Find and initialize the components module if it doesn't exist + let components_root = + components_root(component_args.module_path.as_deref(), &config)?; + let new_components_module = + ensure_components_module_exists(&components_root).await?; + + // Recursively add dependencies + // A map of the components that have been added or are queued to be added + let mut required_components = HashMap::new(); + required_components.extend(components.iter().cloned().map(|c| (c, mode))); + // A stack of components to process + let mut queued_components = components; + while let Some(queued_component) = queued_components.pop() { + for dependency in &queued_component.component_dependencies { + let (registry, name) = match dependency { + ComponentDependency::Builtin(name) => { + (ComponentRegistry::default(), name) + } + ComponentDependency::ThirdParty { name, git, rev } => ( + ComponentRegistry { + remote: RemoteComponentRegistry { + git: Some(git.clone()), + rev: rev.clone(), + }, + path: None, + }, + name, + ), + }; + let registry_components = registry.read_components().await?; + let dependency_component = find_component(®istry_components, name)?; + if required_components + .insert( + dependency_component.clone(), + ComponentExistsBehavior::Return, + ) + .is_none() + { + queued_components.push(dependency_component); + } + } + } + + // Then collect all required rust dependencies + let mut rust_dependencies = HashSet::new(); + for component in required_components.keys() { + rust_dependencies.extend(component.cargo_dependencies.iter().cloned()); + } + + // And add them to Cargo.toml + Self::add_rust_dependencies(&rust_dependencies).await?; + + // Once we have all required components, add them + for (component, mode) in required_components { + add_component( + component_args.global_assets_path.as_deref(), + component_args.module_path.as_deref(), + &component, + mode, + &config, + ) + .await?; + } + + // If we created a new components module, print instructions about the final setup steps required + if new_components_module { + println!( + "Created new components module at {}.", + components_root.display() + ); + println!("To finish setting up components, you will need to:"); + println!("- manually reference the module by adding `mod components;` to your `main.rs` file"); + if registry.is_default() { + println!("- add a reference to `asset!(\"/assets/dx-components-theme.css\")` as a stylesheet in your app"); + } + } + } + + // Update the remote component registry + Self::Update { registry } => { + let config = Self::resolve_config().await?; + registry + .unwrap_or(config.component.registry.remote) + .update() + .await?; + } + + // Remove a component from the managed component module + Self::Remove { + component, + registry, + } => { + Self::remove_component(&component, registry).await?; + } + + // Clear the component registry cache + Self::Clean => { + _ = tokio::fs::remove_dir_all(&Workspace::component_cache_dir()).await; + } + + // Print the schema for component manifests + Self::Schema => { + let schema = component_manifest_schema(); + println!( + "{}", + serde_json::to_string_pretty(&schema).unwrap_or_default() + ); + } + } + + Ok(StructuredOutput::Success) + } + + /// Remove a component from the managed component module + async fn remove_component( + component_args: &ComponentArgs, + registry: Option, + ) -> Result<()> { + let config = Self::resolve_config().await?; + let registry = Self::resolve_registry(registry, &config)?; + + let components_root = components_root(component_args.module_path.as_deref(), &config)?; + + // Find the requested components + let components = if component_args.all { + registry + .read_components() + .await? + .into_iter() + .map(|c| c.component.name) + .collect() + } else { + component_args.components.clone() + }; + + for component_name in components { + // Remove the component module + _ = tokio::fs::remove_dir_all(&components_root.join(&component_name)).await; + + // Remove the module from the components mod.rs + let mod_rs_path = components_root.join("mod.rs"); + let mod_rs_content = tokio::fs::read_to_string(&mod_rs_path) + .await + .with_context(|| format!("Failed to read {}", mod_rs_path.display()))?; + let mod_line = format!("pub mod {};\n", component_name); + let new_mod_rs_content = mod_rs_content.replace(&mod_line, ""); + tokio::fs::write(&mod_rs_path, new_mod_rs_content) + .await + .with_context(|| format!("Failed to write to {}", mod_rs_path.display()))?; + } + Ok(()) + } + + /// Load the config + async fn resolve_config() -> Result { + let workspace = Workspace::current().await?; + + let crate_package = workspace.find_main_package(None)?; + + Ok(workspace + .load_dioxus_config(crate_package)? + .unwrap_or_default()) + } + + /// Resolve a registry from the config if none is provided + fn resolve_registry( + registry: Option, + config: &DioxusConfig, + ) -> Result { + if let Some(registry) = registry { + return Ok(registry); + } + + Ok(config.component.registry.clone()) + } + + /// Add any rust dependencies required for a component + async fn add_rust_dependencies(dependencies: &HashSet) -> Result<()> { + for dep in dependencies { + let status = Command::from(dep.add_command()) + .status() + .await + .with_context(|| { + format!( + "Failed to run command to add dependency {} to Cargo.toml", + dep.name() + ) + })?; + if !status.success() { + bail!("Failed to add dependency {} to Cargo.toml", dep.name()); + } + } + + Ok(()) + } +} + +/// Arguments for the default or custom remote registry +/// If both values are None, the default registry will be used +#[derive(Clone, Debug, Parser, Default, Serialize, Deserialize)] +pub struct RemoteComponentRegistry { + /// The url of the the component registry + #[arg(long)] + git: Option, + + /// The revision of the the component registry + #[arg(long)] + rev: Option, +} + +impl RemoteComponentRegistry { + /// Resolve the path to the component registry, downloading the remote registry if needed + async fn resolve(&self) -> Result { + // If a git url is provided use that (plus optional rev) + // Otherwise use the built-in registry + let (git, rev) = self.resolve_or_default(); + + let repo_dir = Workspace::component_cache_path(&git, rev.as_deref()); + + // If the repo already exists, use it otherwise clone it + if !repo_dir.exists() { + // If offline, we cannot download the registry + if verbosity_or_default().offline { + bail!("Cannot download component registry '{}' while offline", git); + } + + // Make sure the parent directory exists + tokio::fs::create_dir_all(&repo_dir).await?; + tokio::task::spawn_blocking({ + let git = git.clone(); + let repo_dir = repo_dir.clone(); + move || { + println!("Downloading {git}..."); + + // Clone the repo + let repo = Repository::clone(&git, repo_dir)?; + + // If a rev is provided, checkout that rev + if let Some(rev) = &rev { + Self::checkout_rev(&repo, &git, rev)?; + } + + anyhow::Ok(()) + } + }) + .await??; + } + + Ok(repo_dir) + } + + /// Update the component registry by fetching the latest changes from the remote + async fn update(&self) -> Result<()> { + let (git, rev) = self.resolve_or_default(); + + // Make sure the repo is cloned + let path = self.resolve().await?; + + // Open the repo and update it + tokio::task::spawn_blocking({ + let path = path.clone(); + move || { + let repo = Repository::open(path)?; + let mut remote = repo.find_remote("origin")?; + // Fetch all remote branches with the same name as local branches + remote.fetch(&["refs/heads/*:refs/heads/*"], None, None)?; + // If a rev is provided, checkout that rev + if let Some(rev) = &rev { + Self::checkout_rev(&repo, &git, rev)?; + } + anyhow::Ok(()) + } + }) + .await??; + + Ok(()) + } + + /// If a git url is provided use that (plus optional rev) + /// Otherwise use the built-in registry + fn resolve_or_default(&self) -> (String, Option) { + if let Some(git) = &self.git { + (git.clone(), self.rev.clone()) + } else { + ("https://github.com/dioxuslabs/components".into(), None) + } + } + + /// Checkout the given rev in the given repo + fn checkout_rev(repo: &Repository, git: &str, rev: &str) -> Result<()> { + let (object, reference) = repo + .revparse_ext(rev) + .with_context(|| format!("Failed to find revision '{}' in '{}'", rev, git))?; + repo.checkout_tree(&object, None)?; + + if let Some(gref) = reference { + if let Some(name) = gref.name() { + repo.set_head(name)?; + } + } else { + repo.set_head_detached(object.id())?; + } + + Ok(()) + } +} + +/// Arguments for a component registry +/// Either a path to a local directory or a remote git repo (with optional rev) +#[derive(Clone, Debug, Parser, Default, Serialize, Deserialize)] +pub struct ComponentRegistry { + /// The remote repo args + #[clap(flatten)] + #[serde(flatten)] + remote: RemoteComponentRegistry, + + /// The path to the components directory + #[arg(long)] + path: Option, +} + +impl ComponentRegistry { + /// Resolve the path to the component registry, downloading the remote registry if needed + async fn resolve(&self) -> Result { + // If a path is provided, use that + if let Some(path) = &self.path { + return Ok(PathBuf::from(path)); + } + + // Otherwise use the remote/default registry + self.remote.resolve().await + } + + /// Read all components that are part of this registry + async fn read_components(&self) -> Result> { + let path = self.resolve().await?; + + let root = read_component(&path).await?; + let mut components = discover_components(root).await?; + + // Filter out any virtual components with members + components.retain(|c| c.members.is_empty()); + + Ok(components) + } + + /// Check if this is the default registry + fn is_default(&self) -> bool { + self.path.is_none() && self.remote.git.is_none() && self.remote.rev.is_none() + } +} + +/// A component that has been downloaded and resolved at a specific path +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct ResolvedComponent { + path: PathBuf, + component: Component, +} + +impl ResolvedComponent { + /// Get the absolute paths to members of this component + fn member_paths(&self) -> Vec { + self.component + .members + .iter() + .map(|m| self.path.join(m)) + .collect() + } +} + +impl Deref for ResolvedComponent { + type Target = Component; + + fn deref(&self) -> &Self::Target { + &self.component + } +} + +// Find a component by name in a list of components +fn find_component(components: &[ResolvedComponent], component: &str) -> Result { + components + .iter() + .find(|c| c.name == component) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Component '{}' not found in registry", component)) +} + +/// Get the path to the components module, defaulting to src/components +fn components_root(module_path: Option<&Path>, config: &DioxusConfig) -> Result { + if let Some(module_path) = module_path { + return Ok(PathBuf::from(module_path)); + } + + let root = Workspace::crate_root_from_path()?; + + if let Some(component_path) = &config.component.component_dir { + return Ok(root.join(component_path)); + } + + Ok(root.join("src").join("components")) +} + +/// Get the path to the global assets directory, defaulting to assets +async fn global_assets_root(assets_path: Option<&Path>, config: &DioxusConfig) -> Result { + if let Some(assets_path) = assets_path { + return Ok(PathBuf::from(assets_path)); + } + + if let Some(asset_dir) = &config.application.asset_dir { + return Ok(asset_dir.clone()); + } + + let root = Workspace::crate_root_from_path()?; + + Ok(root.join("assets")) +} + +/// How should we handle the component if it already exists +#[derive(Clone, Copy, Debug)] +enum ComponentExistsBehavior { + /// Return an error (default) + Error, + + /// Return early for component dependencies + Return, + + /// Overwrite the existing component + Overwrite, +} + +/// Add a component to the managed component module +async fn add_component( + assets_path: Option<&Path>, + component_path: Option<&Path>, + component: &ResolvedComponent, + behavior: ComponentExistsBehavior, + config: &DioxusConfig, +) -> Result<()> { + // Copy the folder content to the components directory + let components_root = components_root(component_path, config)?; + let copied = copy_component_files( + &component.path, + &components_root.join(&component.name), + &component.exclude, + behavior, + ) + .await?; + if !copied { + debug!( + "Component '{}' already exists, skipping copy", + component.name + ); + return Ok(()); + } + + // Copy any global assets + let assets_root = global_assets_root(assets_path, config).await?; + copy_global_assets(&assets_root, component).await?; + + // Add the module to the components mod.rs + let mod_rs_path = components_root.join("mod.rs"); + let mut mod_rs = tokio::fs::OpenOptions::new() + .append(true) + .read(true) + .open(&mod_rs_path) + .await + .with_context(|| format!("Failed to open {}", mod_rs_path.display()))?; + + // Check if the module already exists + let mod_rs_content = tokio::fs::read_to_string(&mod_rs_path) + .await + .with_context(|| format!("Failed to read {}", mod_rs_path.display()))?; + if !mod_rs_content.contains(&format!("mod {};", component.name)) { + let mod_line = format!("pub mod {};\n", component.name); + tokio::io::AsyncWriteExt::write_all(&mut mod_rs, mod_line.as_bytes()) + .await + .with_context(|| format!("Failed to write to {}", mod_rs_path.display()))?; + } + + Ok(()) +} + +/// Copy the component files. Returns true if the component was copied, false if it was skipped. +async fn copy_component_files( + src: &Path, + dest: &Path, + exclude: &[String], + behavior: ComponentExistsBehavior, +) -> Result { + async fn read_dir_paths(src: &Path) -> Result> { + let mut entries = tokio::fs::read_dir(src).await?; + let mut paths = vec![]; + while let Some(entry) = entries.next_entry().await? { + paths.push(entry.path()); + } + Ok(paths) + } + + // If the directory already exists, return an error, return silently or overwrite it depending on the behavior + if dest.exists() { + match behavior { + // The default behavior is to return an error + ComponentExistsBehavior::Error => { + bail!("Destination directory '{}' already exists", dest.display()); + } + // For dependencies, we return early + ComponentExistsBehavior::Return => { + debug!( + "Destination directory '{}' already exists, returning early", + dest.display() + ); + return Ok(false); + } + // If the force flag is set, we overwrite the existing component + ComponentExistsBehavior::Overwrite => { + debug!( + "Destination directory '{}' already exists, overwriting", + dest.display() + ); + tokio::fs::remove_dir_all(dest).await?; + } + } + } + + tokio::fs::create_dir_all(dest).await?; + + let exclude = exclude + .iter() + .map(|exclude| dunce::canonicalize(src.join(exclude))) + .collect::, _>>()?; + + // Set set of tasks to read directories + let mut read_folder_tasks = JoinSet::new(); + // Set set of tasks to copy files + let mut copy_tasks = JoinSet::new(); + + // Start by reading the source directory + let src = src.to_path_buf(); + read_folder_tasks.spawn({ + let src = src.clone(); + async move { read_dir_paths(&src).await } + }); + + // Continue while there are read tasks + while let Some(res) = read_folder_tasks.join_next().await { + let paths = res??; + for path in paths { + let path = dunce::canonicalize(path)?; + + // Skip excluded paths + if exclude.iter().any(|e| *e == path || path.starts_with(e)) { + debug!("Excluding path {}", path.display()); + continue; + } + + // Find the path in the destination directory + let Ok(path_relative_to_src) = path.strip_prefix(&src) else { + continue; + }; + let dest = dest.join(path_relative_to_src); + + // If it's a directory, read it, otherwise copy the file + if path.is_dir() { + read_folder_tasks.spawn(async move { read_dir_paths(&path).await }); + } else { + copy_tasks.spawn(async move { + if let Some(parent) = dest.parent() { + if !parent.exists() { + tokio::fs::create_dir_all(parent).await?; + } + } + tokio::fs::copy(&path, &dest).await + }); + } + } + } + + // Wait for all copy tasks to finish + while let Some(res) = copy_tasks.join_next().await { + res??; + } + + Ok(true) +} + +/// Make sure the components directory and a mod.rs file exists. Returns true if the directory was created, false if it already existed. +async fn ensure_components_module_exists(components_dir: &Path) -> Result { + if components_dir.exists() { + return Ok(false); + } + tokio::fs::create_dir_all(&components_dir).await?; + let mod_rs_path = components_dir.join("mod.rs"); + if mod_rs_path.exists() { + return Ok(false); + } + tokio::fs::write(&mod_rs_path, "// AUTOGENERTED Components module\n").await?; + + Ok(true) +} + +/// Read a component from the given path +async fn read_component(path: &Path) -> Result { + let json_path = path.join("component.json"); + let bytes = tokio::fs::read(&json_path).await.with_context(|| { + format!( + "Failed to open component manifest at {}", + json_path.display() + ) + })?; + + let component = serde_json::from_slice(&bytes)?; + Ok(ResolvedComponent { + path: path.to_path_buf(), + component, + }) +} + +/// Recursively discover all components starting from the root component +async fn discover_components(root: ResolvedComponent) -> Result> { + // Create a queue of members to read + let mut queue = root.member_paths(); + // The list of discovered components + let mut components = vec![root]; + // The set of pending read tasks + let mut pending = JoinSet::new(); + loop { + // First, spawn tasks for all queued paths + while let Some(root_path) = queue.pop() { + pending.spawn(async move { read_component(&root_path).await }); + } + // Then try to join the next task + let Some(component) = pending.join_next().await else { + break; + }; + let component = component??; + // And add the result to the queue and list + queue.extend(component.member_paths()); + components.push(component); + } + Ok(components) +} + +/// Copy any global assets for the component +async fn copy_global_assets(assets_root: &Path, component: &ResolvedComponent) -> Result<()> { + let cache_dir = Workspace::component_cache_dir(); + + for path in &component.global_assets { + let src = component.path.join(path); + let absolute_source = dunce::canonicalize(&src).with_context(|| { + format!( + "Failed to find global asset '{}' for component '{}'", + src.display(), + component.name + ) + })?; + + // Make sure the source is inside the component registry somewhere + if !absolute_source.starts_with(&cache_dir) { + bail!( + "Cannot copy global asset '{}' for component '{}' because it is outside of the component registry", + src.display(), + component.name + ); + } + + // Copy the file into the assets directory, preserving the file name and extension + let dest = assets_root.join( + absolute_source + .components() + .next_back() + .context("Global assets must have at least one file component")?, + ); + + // Make sure the asset dir exists + if let Some(parent) = dest.parent() { + if !parent.exists() { + tokio::fs::create_dir_all(parent).await?; + } + } + + tokio::fs::copy(&src, &dest).await.with_context(|| { + format!( + "Failed to copy global asset from {} to {}", + src.display(), + dest.display() + ) + })?; + } + + Ok(()) +} diff --git a/packages/cli/src/cli/create.rs b/packages/cli/src/cli/create.rs index 888f9d53dd..8c28ea277f 100644 --- a/packages/cli/src/cli/create.rs +++ b/packages/cli/src/cli/create.rs @@ -235,11 +235,7 @@ fn remove_triple_newlines(string: &str) -> String { /// Perform a health check against github itself before we attempt to download any templates hosted /// on github. pub(crate) async fn connectivity_check() -> Result<()> { - if crate::VERBOSITY - .get() - .map(|f| f.offline) - .unwrap_or_default() - { + if crate::verbosity_or_default().offline { return Ok(()); } diff --git a/packages/cli/src/cli/mod.rs b/packages/cli/src/cli/mod.rs index 0c46920989..31a2427327 100644 --- a/packages/cli/src/cli/mod.rs +++ b/packages/cli/src/cli/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod build; pub(crate) mod build_assets; pub(crate) mod bundle; pub(crate) mod check; +pub(crate) mod component; pub(crate) mod config; pub(crate) mod create; pub(crate) mod doctor; @@ -114,6 +115,11 @@ pub(crate) enum Commands { #[clap(name = "tools")] #[clap(subcommand)] Tools(BuildTools), + + /// Run a dioxus build tool. IE `build-assets`, `hotpatch`, etc + #[clap(name = "component")] + #[clap(subcommand)] + Component(component::ComponentCommand), } #[allow(clippy::large_enum_variant)] diff --git a/packages/cli/src/config/app.rs b/packages/cli/src/config/app.rs index 4ffde0a68c..32372bbb47 100644 --- a/packages/cli/src/config/app.rs +++ b/packages/cli/src/config/app.rs @@ -3,6 +3,10 @@ use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct ApplicationConfig { + /// The path where global assets will be added when components are added with `dx component add` + #[serde(default)] + pub(crate) asset_dir: Option, + #[serde(default)] pub(crate) out_dir: Option, diff --git a/packages/cli/src/config/component.rs b/packages/cli/src/config/component.rs new file mode 100644 index 0000000000..58f2e4ee73 --- /dev/null +++ b/packages/cli/src/config/component.rs @@ -0,0 +1,15 @@ +use crate::component::ComponentRegistry; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Configuration for the `dioxus component` commands +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub(crate) struct ComponentConfig { + /// The component registry to default to when adding components + #[serde(default)] + pub(crate) registry: ComponentRegistry, + + /// The path where components are stored when adding or removing components + #[serde(default)] + pub(crate) component_dir: Option, +} diff --git a/packages/cli/src/config/dioxus_config.rs b/packages/cli/src/config/dioxus_config.rs index 30548d0b93..e110841c49 100644 --- a/packages/cli/src/config/dioxus_config.rs +++ b/packages/cli/src/config/dioxus_config.rs @@ -1,3 +1,5 @@ +use crate::config::component::ComponentConfig; + use super::*; use serde::{Deserialize, Serialize}; @@ -10,12 +12,16 @@ pub(crate) struct DioxusConfig { #[serde(default)] pub(crate) bundle: BundleConfig, + + #[serde(default)] + pub(crate) component: ComponentConfig, } impl Default for DioxusConfig { fn default() -> Self { Self { application: ApplicationConfig { + asset_dir: None, out_dir: None, tailwind_input: None, tailwind_output: None, @@ -52,6 +58,7 @@ impl Default for DioxusConfig { wasm_opt: Default::default(), }, bundle: BundleConfig::default(), + component: ComponentConfig::default(), } } } diff --git a/packages/cli/src/config/mod.rs b/packages/cli/src/config/mod.rs index c18400473f..0647a1dc5a 100644 --- a/packages/cli/src/config/mod.rs +++ b/packages/cli/src/config/mod.rs @@ -1,5 +1,6 @@ mod app; mod bundle; +mod component; mod dioxus_config; mod serve; mod web; diff --git a/packages/cli/src/error.rs b/packages/cli/src/error.rs index 9cee5b138f..ccea17d9d5 100644 --- a/packages/cli/src/error.rs +++ b/packages/cli/src/error.rs @@ -25,7 +25,7 @@ pub fn log_stacktrace(err: &anyhow::Error, padding: usize) -> String { )); } - if crate::VERBOSITY.get().unwrap().trace { + if crate::verbosity_or_default().trace { trace.push_str(&format!("\nBacktrace:\n{}", err.backtrace())); } diff --git a/packages/cli/src/logging.rs b/packages/cli/src/logging.rs index 194d2127b9..e2dbdcb80c 100644 --- a/packages/cli/src/logging.rs +++ b/packages/cli/src/logging.rs @@ -29,6 +29,7 @@ //! - set `dx config set disable-telemetry true` //! +use crate::component::ComponentCommand; use crate::{dx_build_info::GIT_COMMIT_HASH_SHORT, serve::ServeUpdate, Cli, Commands, Verbosity}; use crate::{BundleFormat, CliSettings, Workspace}; use anyhow::{bail, Context, Error, Result}; @@ -68,6 +69,10 @@ const DX_SRC_FLAG: &str = "dx_src"; pub static VERBOSITY: OnceLock = OnceLock::new(); +pub fn verbosity_or_default() -> Verbosity { + crate::VERBOSITY.get().cloned().unwrap_or_default() +} + fn reset_cursor() { use std::io::IsTerminal; @@ -902,6 +907,44 @@ impl TraceController { Print::ClientArgs(_args) => ("print client-args".to_string(), json!({})), Print::ServerArgs(_args) => ("print server-args".to_string(), json!({})), }, + Commands::Component(cmd) => match cmd { + ComponentCommand::Add { + component, + registry, + force, + } => ( + "component add".to_string(), + json!({ + "component": component, + "registry": registry, + "force": force, + }), + ), + ComponentCommand::Remove { + component, + registry, + } => ( + "component remove".to_string(), + json!({ + "component": component, + "registry": registry, + }), + ), + ComponentCommand::Update { registry } => ( + "component update".to_string(), + json!({ + "registry": registry, + }), + ), + ComponentCommand::List { registry } => ( + "component list".to_string(), + json!({ + "registry": registry, + }), + ), + ComponentCommand::Clean => ("component clean".to_string(), json!({})), + ComponentCommand::Schema => ("component schema".to_string(), json!({})), + }, } } diff --git a/packages/cli/src/main.rs b/packages/cli/src/main.rs index fbca1aec33..32e65d9f13 100644 --- a/packages/cli/src/main.rs +++ b/packages/cli/src/main.rs @@ -70,6 +70,7 @@ async fn main() -> ExitCode { Commands::Tools(BuildTools::HotpatchTip(opts)) => opts.run().await, Commands::Doctor(opts) => opts.doctor().await, Commands::Print(opts) => opts.print().await, + Commands::Component(opts) => opts.run().await, } }); diff --git a/packages/cli/src/workspace.rs b/packages/cli/src/workspace.rs index 58aa2f46c7..8a529ce1c0 100644 --- a/packages/cli/src/workspace.rs +++ b/packages/cli/src/workspace.rs @@ -533,6 +533,24 @@ impl Workspace { pub(crate) fn global_settings_file() -> PathBuf { Self::dioxus_data_dir().join("settings.toml") } + + /// The path where components downloaded from git are cached + pub(crate) fn component_cache_dir() -> PathBuf { + Self::dioxus_data_dir().join("components") + } + + /// Get the path to a specific component in the cache + pub(crate) fn component_cache_path(git: &str, rev: Option<&str>) -> PathBuf { + use std::hash::Hasher; + + let mut hasher = std::hash::DefaultHasher::new(); + std::hash::Hash::hash(git, &mut hasher); + if let Some(rev) = rev { + std::hash::Hash::hash(rev, &mut hasher); + } + let hash = hasher.finish(); + Self::component_cache_dir().join(format!("{hash:016x}")) + } } impl std::fmt::Debug for Workspace { diff --git a/packages/component-manifest/Cargo.toml b/packages/component-manifest/Cargo.toml new file mode 100644 index 0000000000..cd00e5ba9c --- /dev/null +++ b/packages/component-manifest/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "dioxus-component-manifest" +edition = "2021" +version.workspace = true +description = "Wire format for the dioxus CLI telemetry type" +authors = ["Evan Almloff"] +license = "MIT OR Apache-2.0" +repository = "https://github.com/DioxusLabs/dioxus/" +homepage = "https://dioxuslabs.com" +keywords = ["dom", "ui", "gui", "react"] + +[dependencies] +schemars = "1.0.4" +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.143" diff --git a/packages/component-manifest/src/lib.rs b/packages/component-manifest/src/lib.rs new file mode 100644 index 0000000000..5033bf612f --- /dev/null +++ b/packages/component-manifest/src/lib.rs @@ -0,0 +1,121 @@ +use std::process::Command; + +use schemars::{schema_for, JsonSchema, Schema}; +use serde::{Deserialize, Serialize}; + +/// A component compatible with the dioxus components system. +/// This may be a "virtual" component which is empty except for a list of members. +#[derive(Deserialize, Serialize, JsonSchema, Clone, Debug, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Component { + pub name: String, + + #[serde(default)] + pub description: String, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub authors: Vec, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub component_dependencies: Vec, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cargo_dependencies: Vec, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub members: Vec, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub exclude: Vec, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub global_assets: Vec, +} + +/// A dependency on another component, either built-in or third-party. +#[derive(Deserialize, Serialize, JsonSchema, Clone, Debug, PartialEq, Eq, Hash)] +#[serde(untagged)] +pub enum ComponentDependency { + Builtin(String), + ThirdParty { + name: String, + git: String, + #[serde(default)] + rev: Option, + }, +} + +/// A dependency on a cargo crate required for a component. +#[derive(Deserialize, Serialize, JsonSchema, Clone, Debug, PartialEq, Eq, Hash)] +#[serde(untagged)] +pub enum CargoDependency { + Simple(String), + Detailed { + name: String, + #[serde(default)] + version: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + features: Vec, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + default_features: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + git: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + rev: Option, + }, +} + +impl CargoDependency { + /// Get the `cargo add` command for this dependency. + pub fn add_command(&self) -> Command { + let mut cmd = Command::new("cargo"); + cmd.arg("add"); + match self { + CargoDependency::Simple(name) => { + cmd.arg(name); + } + CargoDependency::Detailed { + name, + version, + features, + default_features, + git, + rev, + } => { + cmd.arg(format!( + "{name}{}", + version + .as_ref() + .map(|version| format!("@{version}")) + .unwrap_or_default() + )); + if !features.is_empty() { + cmd.arg("--features").arg(features.join(",")); + } + if !*default_features { + cmd.arg("--no-default-features"); + } + if let Some(git) = git { + cmd.arg("--git").arg(git); + } + if let Some(rev) = rev { + cmd.arg("--rev").arg(rev); + } + } + } + cmd + } + + /// Get the name of the dependency. + pub fn name(&self) -> &str { + match self { + CargoDependency::Simple(name) => name, + CargoDependency::Detailed { name, .. } => name, + } + } +} + +/// Get the JSON schema for the `Component` struct. +pub fn component_manifest_schema() -> Schema { + schema_for!(Component) +}