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 `` #}