From d9214c7292bc5c24989e27add8f7d143792d3517 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 17 Mar 2026 01:35:56 -0700 Subject: [PATCH] Add the crate dependency graph visualization to the contributor guide (#3907) * Add the crate dependency graph visualization to the contributor guide * Code review fixes * And more --- .github/workflows/build.yml | 35 +- .github/workflows/ci.yml | 12 +- .../workflows/comment-profiling-changes.yaml | 6 +- .github/workflows/release.yml | 6 +- .github/workflows/website.yml | 26 +- Cargo.lock | 8 +- tools/cargo-run/src/main.rs | 2 + tools/crate-hierarchy-viz/Cargo.toml | 2 +- tools/crate-hierarchy-viz/src/main.rs | 392 ++++++------------ tools/editor-message-tree/src/main.rs | 14 +- tools/node-docs/src/main.rs | 14 +- tools/node-docs/src/page_catalog.rs | 10 +- tools/node-docs/src/utility.rs | 1 - .../generate-crate-hierarchy.ts | 19 + .../guide/codebase-overview/_index.md | 14 +- website/package-lock.json | 8 + website/package.json | 4 +- .../contributor-guide/crate-hierarchy.scss | 73 ++++ .../page/contributor-guide/crate-hierarchy.js | 181 ++++++++ website/templates/base.html | 2 + website/templates/macros/replacements.html | 17 +- 21 files changed, 497 insertions(+), 349 deletions(-) create mode 100644 website/.build-scripts/generate-crate-hierarchy.ts create mode 100644 website/sass/page/contributor-guide/crate-hierarchy.scss create mode 100644 website/static/js/page/contributor-guide/crate-hierarchy.js diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 11af2efd..784bd919 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -92,8 +92,7 @@ jobs: - name: โœ‚ Replace template in of index.html if: github.event_name == 'push' - run: | - sed -i "s||$INDEX_HTML_HEAD_REPLACEMENT|" frontend/index.html + run: sed -i "s||$INDEX_HTML_HEAD_REPLACEMENT|" frontend/index.html - name: ๐ŸŒ Build Graphite web code env: @@ -165,8 +164,7 @@ jobs: - name: โœ‚ Strip analytics script from built output for clean artifact if: github.event_name == 'push' - run: | - sed -i "s|$INDEX_HTML_HEAD_REPLACEMENT||" frontend/dist/index.html + run: sed -i "s|$INDEX_HTML_HEAD_REPLACEMENT||" frontend/dist/index.html - name: ๐Ÿ“ฆ Upload web bundle artifact uses: actions/upload-artifact@v6 @@ -177,49 +175,38 @@ jobs: - name: ๐Ÿ“ƒ Generate code documentation info for website if: github.event_name == 'push' run: | - cd tools/editor-message-tree - cargo run - cd ../.. - mkdir -p artifacts-generated - mv website/generated/hierarchical_message_system_tree.txt artifacts-generated/hierarchical_message_system_tree.txt + mkdir -p website/generated-new + cargo run -p crate-hierarchy-viz -- website/generated-new/crate_hierarchy.dot + cargo run -p editor-message-tree -- website/generated-new/hierarchical_message_system_tree.txt - name: ๐Ÿ’ฟ Obtain cache of auto-generated code docs artifacts, to check if they've changed if: github.event_name == 'push' id: cache-website-code-docs uses: actions/cache/restore@v5 with: - path: artifacts + path: website/generated key: website-code-docs - name: ๐Ÿ” Check if auto-generated code docs artifacts changed if: github.event_name == 'push' id: website-code-docs-changed run: | - if ! diff --brief --recursive artifacts-generated artifacts; then - echo "Auto-generated code docs artifacts have changed." - rm -rf artifacts - mv artifacts-generated artifacts - echo "changed=true" >> $GITHUB_OUTPUT - else - echo "Auto-generated code docs artifacts have not changed." - rm -rf artifacts - rm -rf artifacts-generated - fi + diff --brief --recursive website/generated-new website/generated || echo "changed=true" >> $GITHUB_OUTPUT + rm -rf website/generated + mv website/generated-new website/generated - name: ๐Ÿ’พ Save cache of auto-generated code docs artifacts if: github.event_name == 'push' && steps.website-code-docs-changed.outputs.changed == 'true' uses: actions/cache/save@v5 with: - path: artifacts + path: website/generated key: ${{ steps.cache-website-code-docs.outputs.cache-primary-key }} - name: โ™ป๏ธ Trigger website rebuild if the auto-generated code docs artifacts have changed if: github.event_name == 'push' && steps.website-code-docs-changed.outputs.changed == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - rm -rf artifacts - gh workflow run website.yml --ref master + run: gh workflow run website.yml --ref master windows: if: github.event_name == 'push' || inputs.windows diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f825072e..b57caa18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,8 +57,7 @@ jobs: - name: ๐Ÿฆ€ Fetch Rust dependencies if: steps.skip-check.outputs.skip-check != 'true' - run: | - cargo fetch --locked + run: cargo fetch --locked - name: ๐ŸŒ Build Graphite web code if: steps.skip-check.outputs.skip-check != 'true' @@ -72,8 +71,7 @@ jobs: env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - run: | - npx wrangler@3 pages deploy "frontend/dist" --project-name="graphite-dev" --commit-dirty=true + run: npx wrangler@3 pages deploy "frontend/dist" --project-name="graphite-dev" --commit-dirty=true - name: ๐Ÿ‘• Lint Graphite web formatting if: steps.skip-check.outputs.skip-check != 'true' @@ -110,15 +108,13 @@ jobs: - name: ๐Ÿฆ€ Fetch Rust dependencies if: steps.skip-check.outputs.skip-check != 'true' - run: | - cargo fetch --locked + run: cargo fetch --locked - name: ๐Ÿงช Run Rust tests if: steps.skip-check.outputs.skip-check != 'true' env: RUSTFLAGS: -Dwarnings - run: | - mold -run cargo test --all-features + run: mold -run cargo test --all-features # Rust format check on GitHub runner rust-fmt: diff --git a/.github/workflows/comment-profiling-changes.yaml b/.github/workflows/comment-profiling-changes.yaml index 275f6589..e336139a 100644 --- a/.github/workflows/comment-profiling-changes.yaml +++ b/.github/workflows/comment-profiling-changes.yaml @@ -42,8 +42,7 @@ jobs: - name: Install iai-callgrind if: steps.cache-iai.outputs.cache-hit != 'true' - run: | - cargo install iai-callgrind-runner@0.16.1 + run: cargo install iai-callgrind-runner@0.16.1 - name: Checkout master branch run: | @@ -84,8 +83,7 @@ jobs: cargo bench --bench run_cached_iai -- --save-baseline=master - name: Checkout PR branch - run: | - git checkout ${{ github.event.pull_request.head.sha }} + run: git checkout ${{ github.event.pull_request.head.sha }} - name: Run PR benchmarks run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 602f4016..b04ad5f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,8 +47,7 @@ jobs: target: wasm32-unknown-unknown - name: โœ‚ Replace template in of index.html - run: | - sed -i "s||$INDEX_HTML_HEAD_REPLACEMENT|" frontend/index.html + run: sed -i "s||$INDEX_HTML_HEAD_REPLACEMENT|" frontend/index.html - name: ๐ŸŒ Build Graphite web code env: @@ -59,8 +58,7 @@ jobs: env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - run: | - npx wrangler@3 pages deploy "frontend/dist" --project-name="graphite-editor" --branch="master" --commit-dirty=true + run: npx wrangler@3 pages deploy "frontend/dist" --project-name="graphite-editor" --branch="master" --commit-dirty=true - name: ๐Ÿ“ฆ Upload assets to GitHub release env: diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 99964261..fafa3495 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -53,7 +53,7 @@ jobs: id: cache-website-code-docs uses: actions/cache/restore@v5 with: - path: artifacts + path: website/generated key: website-code-docs - name: ๐Ÿ“ Fallback in case auto-generated code docs artifacts weren't cached @@ -64,33 +64,24 @@ jobs: rustup update stable echo "๐Ÿฆ€ Latest updated version of Rust:" rustc --version - cd tools/editor-message-tree - cargo run - cd ../.. - mkdir artifacts - mv website/generated/hierarchical_message_system_tree.txt artifacts/hierarchical_message_system_tree.txt + cargo run -p crate-hierarchy-viz -- website/generated/crate_hierarchy.dot + cargo run -p editor-message-tree -- website/generated/hierarchical_message_system_tree.txt - - name: ๐Ÿšš Move `artifacts` contents to website/generated - run: | - mkdir -p website/generated - mv artifacts/* website/generated/ - - - name: ๐Ÿ”ง Build auto-generated code docs artifacts into HTML + - name: ๐Ÿ”ง Build auto-generated code docs artifacts into HTML/SVG run: | cd website + npm ci npm run generate-editor-structure + npm run generate-crate-hierarchy - name: ๐Ÿ“ƒ Generate node catalog documentation - run: | - cd tools/node-docs - cargo run + run: cargo run -p node-docs -- website/content/learn/node-catalog - name: ๐ŸŒ Build Graphite website with Zola env: MODE: prod run: | cd website - npm ci npm run check zola --config config.toml build --minify @@ -99,5 +90,4 @@ jobs: env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - run: | - npx wrangler@3 pages deploy "website/public" --project-name="graphite-website" --commit-dirty=true + run: npx wrangler@3 pages deploy "website/public" --project-name="graphite-website" --commit-dirty=true diff --git a/Cargo.lock b/Cargo.lock index dd6a0688..0fb7990e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1106,7 +1106,7 @@ name = "crate-hierarchy-viz" version = "0.0.0" dependencies = [ "anyhow", - "clap", + "glob", "serde", "toml 0.8.23", ] @@ -2064,6 +2064,12 @@ dependencies = [ "serde", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "glow" version = "0.16.0" diff --git a/tools/cargo-run/src/main.rs b/tools/cargo-run/src/main.rs index f667df9f..0a840934 100644 --- a/tools/cargo-run/src/main.rs +++ b/tools/cargo-run/src/main.rs @@ -59,6 +59,7 @@ fn explore_usage() { println!("OPTIONS:"); println!(":"); println!(" bisect Binary search through recent commits to find which introduced a bug or feature"); + println!(" deps View the crate dependency graph for the workspace"); println!(" editor View an interactive outline of the editor's message system architecture"); println!(); } @@ -67,6 +68,7 @@ fn run_task(task: &Task) -> Result<(), Error> { if let Action::Explore(tool) = &task.action { match tool.as_deref() { Some("bisect") => return open_url("https://graphite.art/volunteer/guide/codebase-overview/debugging-tips/#build-bisect-tool"), + Some("deps") => return open_url("https://graphite.art/volunteer/guide/codebase-overview/#crate-dependency-graph"), Some("editor") => return open_url("https://graphite.art/volunteer/guide/codebase-overview/editor-structure/#editor-outline"), None | Some("--help") => { explore_usage(); diff --git a/tools/crate-hierarchy-viz/Cargo.toml b/tools/crate-hierarchy-viz/Cargo.toml index d9199f4b..910690db 100644 --- a/tools/crate-hierarchy-viz/Cargo.toml +++ b/tools/crate-hierarchy-viz/Cargo.toml @@ -8,6 +8,6 @@ authors.workspace = true [dependencies] serde = { workspace = true } -clap = { workspace = true, features = ["derive"] } toml = "0.8" anyhow = { workspace = true } +glob = "0.3" diff --git a/tools/crate-hierarchy-viz/src/main.rs b/tools/crate-hierarchy-viz/src/main.rs index f964e8fb..837b3876 100644 --- a/tools/crate-hierarchy-viz/src/main.rs +++ b/tools/crate-hierarchy-viz/src/main.rs @@ -1,37 +1,8 @@ -use anyhow::{Context, Result, anyhow}; -use clap::{Parser, ValueEnum}; +use anyhow::{Context, Result}; use serde::Deserialize; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::PathBuf; -use std::process::Command; - -#[derive(Debug, Clone, ValueEnum)] -enum OutputFormat { - /// Output DOT format (GraphViz) - Dot, - /// Output PNG image (requires GraphViz) - Png, - /// Output SVG image (requires GraphViz) - Svg, -} - -#[derive(Parser)] -#[command(name = "crate-hierarchy-viz")] -#[command(about = "Visualize the crate hierarchy in the Graphite workspace")] -struct Args { - /// Workspace root directory (defaults to current directory) - #[arg(short, long)] - workspace: Option, - - /// Output file (defaults to stdout for DOT format, required for PNG/SVG) - #[arg(short, long)] - output: Option, - - /// Output format - #[arg(short, long, value_enum, default_value = "dot")] - format: OutputFormat, -} #[derive(Debug, Deserialize)] struct WorkspaceToml { @@ -41,20 +12,6 @@ struct WorkspaceToml { #[derive(Debug, Deserialize)] struct WorkspaceConfig { members: Vec, - dependencies: Option>, -} - -/// Represents a workspace-level dependency in Cargo.toml -/// The Simple variant's String is needed for serde deserialization but never read directly -#[derive(Debug, Deserialize)] -#[serde(untagged)] -#[allow(dead_code)] -enum WorkspaceDependency { - Simple(String), - Detailed { - #[serde(flatten)] - _other: HashMap, - }, } #[derive(Debug, Deserialize)] @@ -77,18 +34,15 @@ enum CrateDependency { Simple(String), Detailed { path: Option, - workspace: Option, #[serde(flatten)] other: HashMap, }, } -#[derive(Debug, Clone, PartialEq)] struct CrateInfo { name: String, path: PathBuf, dependencies: Vec, - external_dependencies: Vec, } /// Remove transitive dependencies from the crate list. @@ -121,7 +75,6 @@ fn collect_all_dependencies(crate_name: &str, dep_map: &HashMap Result<()> { - let args = Args::parse(); + let output_path = std::env::args_os() + .nth(1) + .map(PathBuf::from) + .ok_or_else(|| anyhow::anyhow!("Usage: crate-hierarchy-viz "))?; - let workspace_root = args.workspace.unwrap_or_else(|| std::env::current_dir().unwrap()); + let workspace_root = std::env::current_dir().unwrap(); let workspace_toml_path = workspace_root.join("Cargo.toml"); // Parse workspace Cargo.toml let workspace_content = fs::read_to_string(&workspace_toml_path).with_context(|| format!("Failed to read {:?}", workspace_toml_path))?; let workspace_toml: WorkspaceToml = toml::from_str(&workspace_content).with_context(|| "Failed to parse workspace Cargo.toml")?; - // Get workspace dependencies (external crates defined at workspace level) - let workspace_deps: HashSet = workspace_toml.workspace.dependencies.unwrap_or_default().keys().cloned().collect(); - - // Parse each member crate and build name mapping - let mut crates = Vec::new(); - let mut workspace_crate_names = HashSet::new(); - - // First pass: collect all workspace crate names + // Expand glob patterns in workspace members (e.g., "node-graph/libraries/*") + let mut resolved_members = Vec::new(); + let mut seen_members = HashSet::new(); + let abs_root = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.clone()); for member in &workspace_toml.workspace.members { + if member.contains('*') { + let pattern = abs_root.join(member).to_string_lossy().to_string(); + let matched: Vec<_> = glob::glob(&pattern) + .with_context(|| format!("Failed to expand glob pattern: {member}"))? + .filter_map(|entry| entry.ok()) + .filter_map(|path| path.strip_prefix(&abs_root).ok().map(|p| p.to_string_lossy().to_string())) + .collect(); + if matched.is_empty() { + eprintln!("Warning: No matches for glob pattern: {member}"); + } + for m in matched { + let normalized = m.replace('\\', "/"); + if seen_members.insert(normalized.clone()) { + resolved_members.push(normalized); + } + } + } else { + let normalized = member.replace('\\', "/"); + if seen_members.insert(normalized.clone()) { + resolved_members.push(normalized); + } + } + } + + // Parse each member crate's Cargo.toml + let mut parsed_crates = Vec::new(); + for member in &resolved_members { let crate_path = workspace_root.join(member); let cargo_toml_path = crate_path.join("Cargo.toml"); @@ -159,243 +138,114 @@ fn main() -> Result<()> { let crate_content = fs::read_to_string(&cargo_toml_path).with_context(|| format!("Failed to read {:?}", cargo_toml_path))?; let crate_toml: CrateToml = toml::from_str(&crate_content).with_context(|| format!("Failed to parse Cargo.toml for {}", member))?; - workspace_crate_names.insert(crate_toml.package.name.clone()); + parsed_crates.push((crate_path, crate_toml)); } - // Second pass: parse dependencies now that we know all workspace crate names - for member in &workspace_toml.workspace.members { - let crate_path = workspace_root.join(member); - let cargo_toml_path = crate_path.join("Cargo.toml"); + // Collect all workspace crate names + let workspace_crate_names: HashSet = parsed_crates.iter().map(|(_, toml)| toml.package.name.clone()).collect(); - if !cargo_toml_path.exists() { - continue; - } + // Build dependency graph, keeping only workspace-internal dependencies + let mut crates: Vec = parsed_crates + .into_iter() + .map(|(path, crate_toml)| { + let dependencies = crate_toml + .dependencies + .unwrap_or_default() + .into_iter() + .filter_map(|(dep_name, dep_config)| { + // Resolve the actual package name (handles renamed dependencies) + let actual_name = match &dep_config { + CrateDependency::Detailed { other, .. } => other.get("package").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or(dep_name), + CrateDependency::Simple(_) => dep_name, + }; + // Only keep dependencies that are workspace crates + workspace_crate_names.contains(&actual_name).then_some(actual_name) + }) + .collect(); - let crate_content = fs::read_to_string(&cargo_toml_path).with_context(|| format!("Failed to read {:?}", cargo_toml_path))?; - let crate_toml: CrateToml = toml::from_str(&crate_content).with_context(|| format!("Failed to parse Cargo.toml for {}", member))?; - - let mut dependencies = Vec::new(); - let mut external_dependencies = Vec::new(); - - if let Some(deps) = &crate_toml.dependencies { - for (dep_name, dep_config) in deps { - let is_workspace_crate = workspace_crate_names.contains(dep_name); - let is_workspace_dep = workspace_deps.contains(dep_name); - - let is_local_dep = match dep_config { - CrateDependency::Detailed { workspace: Some(true), .. } => is_workspace_dep, - CrateDependency::Detailed { path: Some(_), .. } => true, - CrateDependency::Simple(_) => is_workspace_dep, - _ => false, - }; - - // Check if this dependency has a different package name - let actual_dep_name = match dep_config { - CrateDependency::Detailed { other, .. } => { - // Check if there's a "package" field that renames the dependency - if let Some(toml::Value::String(package_name)) = other.get("package") { - package_name.clone() - } else { - dep_name.clone() - } - } - _ => dep_name.clone(), - }; - - let is_actual_workspace_crate = workspace_crate_names.contains(&actual_dep_name); - - if is_workspace_crate || is_actual_workspace_crate || is_local_dep { - dependencies.push(actual_dep_name); - } else { - external_dependencies.push(actual_dep_name); - } + CrateInfo { + name: crate_toml.package.name, + path, + dependencies, } - } + }) + .collect(); - crates.push(CrateInfo { - name: crate_toml.package.name.clone(), - path: crate_path, - dependencies, - external_dependencies, - }); - } - - // Filter dependencies to only include workspace crates - for crate_info in &mut crates { - crate_info.dependencies.retain(|dep| workspace_crate_names.contains(dep)); - } - - // Remove transitive dependencies remove_transitive_dependencies(&mut crates); - // Generate DOT format - let dot_content = generate_dot_format(&crates)?; + // Generate DOT format and write to output file + let dot_content = generate_dot(&crates); - // Handle output based on format - match args.format { - OutputFormat::Dot => { - // Write DOT output - if let Some(output_path) = args.output { - fs::write(&output_path, &dot_content).with_context(|| format!("Failed to write to {:?}", output_path))?; - println!("DOT output written to: {:?}", output_path); - } else { - print!("{}", dot_content); - } - } - OutputFormat::Png | OutputFormat::Svg => { - // Require output file for PNG/SVG - let output_path = args.output.ok_or_else(|| anyhow!("Output file (-o/--output) is required for PNG/SVG formats"))?; - - // Check if dot command is available - let dot_check = Command::new("dot").arg("-V").output(); - if dot_check.is_err() || !dot_check.as_ref().unwrap().status.success() { - return Err(anyhow!( - "GraphViz 'dot' command not found. Please install GraphViz to generate PNG/SVG output.\n\ - On Ubuntu/Debian: sudo apt-get install graphviz\n\ - On macOS: brew install graphviz\n\ - On Windows: Download from https://graphviz.org/download/" - )); - } - - // Determine the format argument for dot - let format_arg = match args.format { - OutputFormat::Png => "png", - OutputFormat::Svg => "svg", - _ => unreachable!(), - }; - - // Run dot command to generate the output - let mut dot_process = Command::new("dot") - .arg(format!("-T{}", format_arg)) - .arg("-o") - .arg(&output_path) - .stdin(std::process::Stdio::piped()) - .spawn() - .with_context(|| "Failed to spawn 'dot' command")?; - - // Write DOT content to stdin - use std::io::Write; - if let Some(mut stdin) = dot_process.stdin.take() { - stdin.write_all(dot_content.as_bytes()).with_context(|| "Failed to write DOT content to 'dot' command")?; - // Close stdin to signal EOF - drop(stdin); - } - - // Wait for the command to complete - let status = dot_process.wait().with_context(|| "Failed to wait for 'dot' command")?; - if !status.success() { - return Err(anyhow!("'dot' command failed with exit code: {:?}", status.code())); - } - - println!("{} output written to: {:?}", format_arg.to_uppercase(), output_path); - } + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent).with_context(|| format!("Failed to create directory {:?}", parent))?; } + fs::write(&output_path, &dot_content).with_context(|| format!("Failed to write to {:?}", output_path))?; Ok(()) } -fn generate_dot_format(crates: &[CrateInfo]) -> Result { - let mut output = String::new(); - output.push_str("digraph CrateHierarchy {\n"); - output.push_str(" rankdir=LR;\n"); - output.push_str(" node [shape=box, style=\"rounded,filled\", fillcolor=lightblue];\n"); - output.push_str(" edge [color=gray];\n\n"); +fn generate_dot(crates: &[CrateInfo]) -> String { + let mut out = String::new(); + out.push_str("digraph CrateHierarchy {\n"); + out.push_str(" rankdir=LR;\n"); + out.push_str(" node [shape=box, style=\"rounded,filled\", fillcolor=lightblue];\n"); + out.push_str(" edge [color=gray];\n\n"); - // Add subgraphs for different categories - output.push_str(" subgraph cluster_core {\n"); - output.push_str(" label=\"Core Components\";\n"); - output.push_str(" style=filled;\n"); - output.push_str(" fillcolor=lightgray;\n"); + // Define subgraph clusters + let clusters: &[(&str, &str, &str, Box bool>)] = &[ + ( + "cluster_core", + "Core Components", + "lightgray", + Box::new(|c| (c.name.starts_with("graphite-") || c.name == "editor" || c.name == "graphene-cli") && !c.name.contains("desktop")), + ), + ( + "cluster_nodegraph", + "Node Graph System", + "lightyellow", + Box::new(|c| c.name == "graph-craft" || c.name == "interpreted-executor" || c.name == "node-macro" || c.name == "preprocessor" || c.name == "graphene-cli"), + ), + ( + "cluster_node_libraries", + "Node Graph Libraries", + "lightcyan", + Box::new(|c| c.path.to_string_lossy().replace('\\', "/").contains("node-graph/libraries")), + ), + ( + "cluster_nodes", + "Nodes", + "lightblue", + Box::new(|c| c.path.to_string_lossy().replace('\\', "/").contains("node-graph/nodes")), + ), + ( + "cluster_desktop", + "Desktop", + "lightgreen", + Box::new(|c| c.path.to_string_lossy().replace('\\', "/").contains("desktop")), + ), + ]; - let core_crates: Vec<_> = crates - .iter() - .filter(|c| (c.name.starts_with("graphite-") || c.name == "editor" || c.name == "graphene-cli") && !c.name.contains("desktop")) - .collect(); - - for crate_info in &core_crates { - output.push_str(&format!(" \"{}\";\n", crate_info.name)); + for (id, label, color, filter) in clusters { + out.push_str(&format!(" subgraph {id} {{\n")); + out.push_str(&format!(" label=\"{label}\";\n")); + out.push_str(" style=filled;\n"); + out.push_str(&format!(" fillcolor={color};\n")); + for c in crates.iter().filter(|c| filter(c)) { + out.push_str(&format!(" \"{}\";\n", c.name)); + } + out.push_str(" }\n\n"); } - output.push_str(" }\n\n"); - output.push_str(" subgraph cluster_nodegraph {\n"); - output.push_str(" label=\"Node Graph System\";\n"); - output.push_str(" style=filled;\n"); - output.push_str(" fillcolor=lightyellow;\n"); - - let nodegraph_crates: Vec<_> = crates - .iter() - .filter(|c| c.name == "graph-craft" || c.name == "interpreted-executor" || c.name == "node-macro" || c.name == "preprocessor" || c.name == "graphene-cli") - .collect(); - - for crate_info in &nodegraph_crates { - output.push_str(&format!(" \"{}\";\n", crate_info.name)); - } - output.push_str(" }\n\n"); - - output.push_str(" subgraph cluster_node_libraries {\n"); - output.push_str(" label=\"Node Graph Libraries\";\n"); - output.push_str(" style=filled;\n"); - output.push_str(" fillcolor=lightcyan;\n"); - - let node_library_crates: Vec<_> = crates - .iter() - .filter(|c| { - let path_str = c.path.to_string_lossy(); - path_str.contains("node-graph/libraries") - }) - .collect(); - - for crate_info in &node_library_crates { - output.push_str(&format!(" \"{}\";\n", crate_info.name)); - } - output.push_str(" }\n\n"); - - output.push_str(" subgraph cluster_nodes {\n"); - output.push_str(" label=\"Nodes\";\n"); - output.push_str(" style=filled;\n"); - output.push_str(" fillcolor=lightblue;\n"); - - let node_crates: Vec<_> = crates - .iter() - .filter(|c| { - let path_str = c.path.to_string_lossy(); - path_str.contains("node-graph/nodes") - }) - .collect(); - - for crate_info in &node_crates { - output.push_str(&format!(" \"{}\";\n", crate_info.name)); - } - output.push_str(" }\n\n"); - - output.push_str(" subgraph cluster_desktop{\n"); - output.push_str(" label=\"Desktop\";\n"); - output.push_str(" style=filled;\n"); - output.push_str(" fillcolor=lightgreen;\n"); - - let desktop_crates: Vec<_> = crates - .iter() - .filter(|c| { - let path_str = c.path.to_string_lossy(); - path_str.contains("desktop") - }) - .collect(); - - for crate_info in &desktop_crates { - output.push_str(&format!(" \"{}\";\n", crate_info.name)); - } - output.push_str(" }\n\n"); - - // Add dependencies as edges + // Add dependency edges for crate_info in crates { for dep in &crate_info.dependencies { if dep == "dyn-any" || dep == "node-macro" { continue; } - output.push_str(&format!(" \"{}\" -> \"{}\";\n", crate_info.name, dep)); + out.push_str(&format!(" \"{}\" -> \"{}\";\n", crate_info.name, dep)); } } - output.push_str("}\n"); - Ok(output) + out.push_str("}\n"); + out } diff --git a/tools/editor-message-tree/src/main.rs b/tools/editor-message-tree/src/main.rs index e7764de8..8b9108f8 100644 --- a/tools/editor-message-tree/src/main.rs +++ b/tools/editor-message-tree/src/main.rs @@ -1,11 +1,17 @@ use editor::messages::message::Message; use editor::utility_types::DebugMessageTree; use std::io::Write; +use std::path::PathBuf; + +fn main() -> Result<(), Box> { + let output_path = std::env::args_os().nth(1).map(PathBuf::from).ok_or("Usage: editor-message-tree ")?; + + if let Some(parent) = output_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } -fn main() { let result = Message::message_tree(); - std::fs::create_dir_all("../../website/generated").unwrap(); - let mut file = std::fs::File::create("../../website/generated/hierarchical_message_system_tree.txt").unwrap(); + let mut file = std::fs::File::create(&output_path).unwrap(); file.write_all(format!("{} `{}#L{}`\n", result.name(), result.path(), result.line_number()).as_bytes()).unwrap(); if let Some(variants) = result.variants() { for (i, variant) in variants.iter().enumerate() { @@ -13,6 +19,8 @@ fn main() { print_tree_node(variant, "", is_last, &mut file); } } + + Ok(()) } fn print_tree_node(tree: &DebugMessageTree, prefix: &str, is_last: bool, file: &mut std::fs::File) { diff --git a/tools/node-docs/src/main.rs b/tools/node-docs/src/main.rs index 73c4944e..edd409ca 100644 --- a/tools/node-docs/src/main.rs +++ b/tools/node-docs/src/main.rs @@ -7,7 +7,13 @@ use crate::utility::*; use convert_case::{Case, Casing}; use std::collections::HashMap; -fn main() { +fn main() -> Result<(), Box> { + let output_path = std::env::args_os() + .nth(1) + .ok_or("Usage: node-docs ")? + .into_string() + .map_err(|_| "Output path is not valid UTF-8")?; + // TODO: Also obtain document nodes, not only proto nodes let nodes = graphene_std::registry::NODE_METADATA.lock().unwrap(); @@ -22,7 +28,7 @@ fn main() { categories.sort(); // Create _index.md for the node catalog page - page_catalog::write_catalog_index_page(&categories); + page_catalog::write_catalog_index_page(&output_path, &categories); // Create node category pages and individual node pages for (index, category) in categories.iter().map(|c| if !OMIT_HIDDEN && c.is_empty() { "Hidden" } else { c }).filter(|c| !c.is_empty()).enumerate() { @@ -32,7 +38,7 @@ fn main() { // Create _index.md file for category let category_path_part = sanitize_path(&category.to_case(Case::Kebab)); - let category_path = format!("{NODE_CATALOG_PATH}/{category_path_part}"); + let category_path = format!("{output_path}/{category_path_part}"); page_category::write_category_index_page(index, category, &nodes, &category_path); // Create individual node pages @@ -40,4 +46,6 @@ fn main() { page_node::write_node_page(index, id, metadata, &category_path); } } + + Ok(()) } diff --git a/tools/node-docs/src/page_catalog.rs b/tools/node-docs/src/page_catalog.rs index f0f02b66..c3bc00a3 100644 --- a/tools/node-docs/src/page_catalog.rs +++ b/tools/node-docs/src/page_catalog.rs @@ -3,12 +3,12 @@ use convert_case::{Case, Casing}; use indoc::formatdoc; use std::io::Write; -pub fn write_catalog_index_page(categories: &[String]) { - if std::path::Path::new(NODE_CATALOG_PATH).exists() { - std::fs::remove_dir_all(NODE_CATALOG_PATH).expect("Failed to remove existing node catalog directory"); +pub fn write_catalog_index_page(output_path: &str, categories: &[String]) { + if std::path::Path::new(output_path).exists() { + std::fs::remove_dir_all(output_path).expect("Failed to remove existing node catalog directory"); } - std::fs::create_dir_all(NODE_CATALOG_PATH).expect("Failed to create node catalog directory"); - let page_path = format!("{NODE_CATALOG_PATH}/_index.md"); + std::fs::create_dir_all(output_path).expect("Failed to create node catalog directory"); + let page_path = format!("{output_path}/_index.md"); let mut page = std::fs::File::create(&page_path).expect("Failed to create index file"); write_frontmatter(&mut page); diff --git a/tools/node-docs/src/utility.rs b/tools/node-docs/src/utility.rs index 1f790804..b50b8486 100644 --- a/tools/node-docs/src/utility.rs +++ b/tools/node-docs/src/utility.rs @@ -1,7 +1,6 @@ use graph_craft::proto::NodeMetadata; use indoc::indoc; -pub const NODE_CATALOG_PATH: &str = "../../website/content/learn/node-catalog"; pub const OMIT_HIDDEN: bool = true; pub fn category_description(category: &str) -> &str { diff --git a/website/.build-scripts/generate-crate-hierarchy.ts b/website/.build-scripts/generate-crate-hierarchy.ts new file mode 100644 index 00000000..aadc853e --- /dev/null +++ b/website/.build-scripts/generate-crate-hierarchy.ts @@ -0,0 +1,19 @@ +/* eslint-disable no-console */ + +import fs from "fs"; + +import { instance } from "@viz-js/viz"; + +const [inputFile, outputFile] = process.argv.slice(2); +if (!inputFile || !outputFile) { + console.error("Usage: node generate-crate-hierarchy.ts "); + process.exit(1); +} + +const dot = fs.readFileSync(inputFile, "utf-8"); + +const viz = await instance(); +const svg = viz.renderString(dot, { format: "svg" }); + +fs.writeFileSync(outputFile, svg); +console.log(`SVG output written to: ${outputFile}`); diff --git a/website/content/volunteer/guide/codebase-overview/_index.md b/website/content/volunteer/guide/codebase-overview/_index.md index e9b13d34..009fcc25 100644 --- a/website/content/volunteer/guide/codebase-overview/_index.md +++ b/website/content/volunteer/guide/codebase-overview/_index.md @@ -5,8 +5,8 @@ page_template = "book.html" [extra] order = 2 # Chapter number -js = ["/js/component/youtube-embed.js"] -css = ["/component/youtube-embed.css"] +js = ["/js/component/youtube-embed.js", "/js/page/contributor-guide/crate-hierarchy.js"] +css = ["/component/youtube-embed.css", "/page/contributor-guide/crate-hierarchy.css"] +++ The best introduction for getting up-to-speed with Graphite contribution comes from watching this webcast recording. Before asking questions in Discord, please watch the full video because it gives a comprehensive overview of most things you will need to know. @@ -37,6 +37,16 @@ The frontend is the GUI for Graphite which users see and interact with. It is bu [Graphene](../graphene/) is the node graph engine which manages and renders the documents. It is itself a programming language, where Graphene programs are compiled while being edited live by the user, and where executing the program renders the document. +## Crate dependency graph + +This diagram shows the structure of the crates that comprise the Graphite codebase and how they depend on each other. Every Arrow points from a crate to another which it depends on. + +
+ + + +
+ ## Frontend/backend communication Frontend-to-backend communication is achieved through a thin Rust translation layer in [`/frontend/wasm/src/editor_api.rs`](https://github.com/GraphiteEditor/Graphite/tree/master/frontend/wasm/src/editor_api.rs) which wraps the editor backend's Rust-based message system API and provides the TypeScript-compatible API of callable functions. These wrapper functions are compiled by [wasm-bindgen](https://github.com/rustwasm/wasm-bindgen) into autogenerated TS functions that serve as an entry point from TS into the Wasm binary. diff --git a/website/package-lock.json b/website/package-lock.json index c038887f..59a81e65 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -16,6 +16,7 @@ "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.39.2", "@types/node": "^25.0.9", + "@viz-js/viz": "^3.25.0", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", @@ -1232,6 +1233,13 @@ "win32" ] }, + "node_modules/@viz-js/viz": { + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/@viz-js/viz/-/viz-3.25.0.tgz", + "integrity": "sha512-dM7zAYMdf7mcRz5Kdb+YJb6+qv5Rjk0rPZ18gROdpMrP/3S7RFOp8uxybeiz5RypHrE1zo1vccA8Twh4mIcLZw==", + "dev": true, + "license": "MIT" + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", diff --git a/website/package.json b/website/package.json index 6993956a..d06c6a17 100644 --- a/website/package.json +++ b/website/package.json @@ -13,6 +13,7 @@ "scripts": { "postinstall": "node .build-scripts/install.ts", "generate-editor-structure": "node .build-scripts/generate-editor-structure.ts generated/hierarchical_message_system_tree.txt generated/hierarchical_message_system_tree.html", + "generate-crate-hierarchy": "node .build-scripts/generate-crate-hierarchy.ts generated/crate_hierarchy.dot generated/crate_hierarchy.svg", "check": "tsc --noEmit && eslint", "fix": "eslint --fix" }, @@ -21,11 +22,12 @@ "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.39.2", "@types/node": "^25.0.9", + "@viz-js/viz": "^3.25.0", + "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.32.0", "eslint-plugin-prettier": "^5.5.5", - "eslint": "^9.39.2", "prettier": "^3.8.0", "sass": "1.97.2", "tar": "^7.5.6", diff --git a/website/sass/page/contributor-guide/crate-hierarchy.scss b/website/sass/page/contributor-guide/crate-hierarchy.scss new file mode 100644 index 00000000..e7d940ff --- /dev/null +++ b/website/sass/page/contributor-guide/crate-hierarchy.scss @@ -0,0 +1,73 @@ +.crate-hierarchy { + position: relative; + margin-top: 20px; + + .crate-hierarchy-controls { + position: absolute; + display: flex; + flex-direction: column; + gap: 4px; + top: 8px; + right: 8px; + z-index: 1; + + button { + position: relative; + border: none; + border-radius: 2px; + width: 32px; + height: 32px; + background: var(--color-navy); + user-select: none; + + &:hover:not(:disabled) { + opacity: 0.5; + } + + &:disabled { + opacity: 0.25; + } + + // + and - icon geometry + &::before, + &.zoom-in::after { + content: ""; + background: white; + position: absolute; + transform: translate(-50%, -50%); + top: 50%; + left: 50%; + } + + &::before { + width: 14px; + height: 2px; + } + + &.zoom-in::after { + width: 2px; + height: 14px; + } + } + } + + .crate-hierarchy-viewport { + touch-action: none; + overflow: hidden; + cursor: grab; + + svg { + display: block; + transform-origin: 0 0; + width: 100%; + height: auto; + pointer-events: none; + user-select: none; + + text { + font-family: inherit; + font-size: 0.7em; + } + } + } +} diff --git a/website/static/js/page/contributor-guide/crate-hierarchy.js b/website/static/js/page/contributor-guide/crate-hierarchy.js new file mode 100644 index 00000000..d77f3f95 --- /dev/null +++ b/website/static/js/page/contributor-guide/crate-hierarchy.js @@ -0,0 +1,181 @@ +document.addEventListener("DOMContentLoaded", () => { + const container = document.querySelector(".crate-hierarchy"); + if (!container) return; + + const svg = container.querySelector("svg"); + if (!svg) return; + + // Wrap SVG in a viewport container + const viewport = document.createElement("div"); + viewport.className = "crate-hierarchy-viewport"; + svg?.parentNode?.insertBefore(viewport, svg); + viewport.appendChild(svg); + + // Remove any width/height attributes so CSS controls sizing + svg.removeAttribute("width"); + svg.removeAttribute("height"); + + // Create zoom controls + const controls = document.createElement("div"); + controls.className = "crate-hierarchy-controls"; + controls.innerHTML = ``; + container.insertBefore(controls, viewport); + const zoomInBtn = controls.querySelector(".zoom-in"); + const zoomOutBtn = controls.querySelector(".zoom-out"); + if (!(zoomInBtn instanceof HTMLButtonElement) || !(zoomOutBtn instanceof HTMLButtonElement)) return; + + // Lock the viewport height to the SVG's natural rendered height (ignoring any zoom transform) + const updateViewportHeight = () => { + const prevTransform = svg.style.transform; + svg.style.transform = ""; + viewport.style.height = `${svg.getBoundingClientRect().height}px`; + svg.style.transform = prevTransform; + }; + updateViewportHeight(); + window.addEventListener("resize", () => { + updateViewportHeight(); + applyTransform(); + }); + + const MIN_SCALE = 1; + const MAX_SCALE = 4; + const ZOOM_STEP = 0.15; + const BUTTON_ZOOM_STEP = 0.5; + const ANIMATION_DURATION = 200; + + let scale = MIN_SCALE; + let panX = 0; + let panY = 0; + let animationFrameId = 0; + let isDragging = false; + let dragStartX = 0; + let dragStartY = 0; + let panStartX = 0; + let panStartY = 0; + + function clampPan() { + const viewportRect = viewport.getBoundingClientRect(); + const viewportW = viewportRect.width; + const viewportH = viewportRect.height; + + // The SVG is scaled to fill the viewport width at scale=1 + const scaledW = viewportW * scale; + const scaledH = svg?.getBoundingClientRect()?.height || 0; + + // How much overflow exists on each axis + const overflowX = Math.max(0, scaledW - viewportW); + const overflowY = Math.max(0, scaledH - viewportH); + + // Pan is constrained so scaled content edges don't pull away from viewport edges + panX = Math.min(0, Math.max(-overflowX, panX)); + panY = Math.min(0, Math.max(-overflowY, panY)); + } + + function updateButtons() { + if (zoomInBtn instanceof HTMLButtonElement) zoomInBtn.disabled = scale >= MAX_SCALE; + if (zoomOutBtn instanceof HTMLButtonElement) zoomOutBtn.disabled = scale <= MIN_SCALE; + } + + function applyTransform() { + clampPan(); + if (svg) svg.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`; + updateButtons(); + } + + function zoomAt(/** @type {number} */ clientX, /** @type {number} */ clientY, /** @type {number} */ newScale) { + const viewportRect = viewport.getBoundingClientRect(); + + // Point in viewport-local coordinates + const pointX = clientX - viewportRect.left; + const pointY = clientY - viewportRect.top; + + // Where this point maps in the pre-zoom content + const contentX = (pointX - panX) / scale; + const contentY = (pointY - panY) / scale; + + scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, newScale)); + + // Adjust pan so the same content point stays under the cursor + panX = pointX - contentX * scale; + panY = pointY - contentY * scale; + + applyTransform(); + } + + function animateZoomAt(/** @type {number} */ clientX, /** @type {number} */ clientY, /** @type {number} */ newTargetScale) { + cancelAnimationFrame(animationFrameId); + + const targetScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, newTargetScale)); + const startScale = scale; + const startPanX = panX; + const startPanY = panY; + + const viewportRect = viewport.getBoundingClientRect(); + const pointX = clientX - viewportRect.left; + const pointY = clientY - viewportRect.top; + const contentX = (pointX - panX) / scale; + const contentY = (pointY - panY) / scale; + + const targetPanX = pointX - contentX * targetScale; + const targetPanY = pointY - contentY * targetScale; + + const startTime = performance.now(); + const step = (/** @type {number} */ now) => { + const t = Math.min(1, (now - startTime) / ANIMATION_DURATION); + const ease = t * (2 - t); // ease-out quadratic + scale = startScale + (targetScale - startScale) * ease; + panX = startPanX + (targetPanX - startPanX) * ease; + panY = startPanY + (targetPanY - startPanY) * ease; + applyTransform(); + if (t < 1) animationFrameId = requestAnimationFrame(step); + }; + animationFrameId = requestAnimationFrame(step); + } + + // Scroll wheel zoom + viewport.addEventListener( + "wheel", + (e) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP; + zoomAt(e.clientX, e.clientY, scale + delta); + }, + { passive: false }, + ); + + // Button zoom (animated, zoom toward center of viewport) + zoomInBtn?.addEventListener("click", () => { + const rect = viewport.getBoundingClientRect(); + animateZoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, scale + BUTTON_ZOOM_STEP); + }); + zoomOutBtn?.addEventListener("click", () => { + const rect = viewport.getBoundingClientRect(); + animateZoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, scale - BUTTON_ZOOM_STEP); + }); + + // Click-drag to pan + viewport.addEventListener("pointerdown", (e) => { + if (e.button !== 0) return; + e.preventDefault(); + isDragging = true; + dragStartX = e.clientX; + dragStartY = e.clientY; + panStartX = panX; + panStartY = panY; + viewport.setPointerCapture(e.pointerId); + viewport.style.cursor = "grabbing"; + }); + window.addEventListener("pointermove", (e) => { + if (!isDragging) return; + panX = panStartX + (e.clientX - dragStartX); + panY = panStartY + (e.clientY - dragStartY); + applyTransform(); + }); + window.addEventListener("pointerup", () => { + if (!isDragging) return; + isDragging = false; + viewport.style.cursor = ""; + }); + + applyTransform(); +}); diff --git a/website/templates/base.html b/website/templates/base.html index 74a9ae3a..19a642ff 100644 --- a/website/templates/base.html +++ b/website/templates/base.html @@ -133,10 +133,12 @@ {%- filter replace(from = "", to = replacements::blog_posts(count = 2)) -%} {%- filter replace(from = "", to = replacements::text_balancer()) -%} {%- filter replace(from = "", to = replacements::hierarchical_message_system_tree()) -%} +{%- filter replace(from = "", to = replacements::crate_hierarchy()) -%} {%- block content -%}{%- endblock -%} {%- endfilter -%} {%- endfilter -%} {%- endfilter -%} +{%- endfilter -%} {# This is a comment. It exists to prevent the {%- -%} on the lines above from removing the line break between the `content` block and `` #}