Port website dev docs generated content scripts from JS to Rust to avoid intermediate parsing (#3909)

* Remove the crate dependency graph website generation intermediate generation step

* Remove the message system tree website generation intermediate generation step

* Code cleanup

* Remove cache system

* Fix Windows comment URL error

* Fix incorrect artifact download URLs

* Add Flatpak to comment

* Make Flatpak use debug/release mode choice instead of always release mode
This commit is contained in:
Keavon Chambers 2026-03-17 15:02:15 -07:00 committed by GitHub
parent d9214c7292
commit 187b4c38b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 345 additions and 268 deletions

View File

@ -145,7 +145,7 @@ jobs:
gh api \ gh api \
-X POST \ -X POST \
-H "Accept: application/vnd.github+json" \ -H "Accept: application/vnd.github+json" \
/repos/${{ github.repository }}/commits/$(git rev-parse HEAD)/comments \ repos/${{ github.repository }}/commits/$(git rev-parse HEAD)/comments \
-f body="$COMMENT_BODY" -f body="$COMMENT_BODY"
else else
# Comment on the PR (use provided PR number from !build, or look it up by branch name) # Comment on the PR (use provided PR number from !build, or look it up by branch name)
@ -172,41 +172,16 @@ jobs:
name: graphite-web-bundle name: graphite-web-bundle
path: frontend/dist path: frontend/dist
- name: 📃 Generate code documentation info for website - name: 📃 Trigger website rebuild if auto-generated code docs are stale
if: github.event_name == 'push' if: github.event_name == 'push'
run: |
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: 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: |
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: 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: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh workflow run website.yml --ref master run: |
cargo run -p editor-message-tree -- website/generated
TREE=volunteer/guide/codebase-overview/hierarchical-message-system-tree
curl -sf "https://graphite.art/$TREE.txt" -o "website/static/$TREE.live.txt" \
&& diff -q "website/static/$TREE.txt" "website/static/$TREE.live.txt" > /dev/null \
|| gh workflow run website.yml --ref master
windows: windows:
if: github.event_name == 'push' || inputs.windows if: github.event_name == 'push' || inputs.windows
@ -302,16 +277,18 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
ARTIFACT_URL=$(gh api /repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts --jq '.artifacts[] | select(.name == "graphite-windows-bundle") | .archive_download_url') ARTIFACT_ID=$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts --jq '.artifacts[] | select(.name == "graphite-windows-bundle") | .id')
ARTIFACT_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts/$ARTIFACT_ID"
PR_NUMBER="${{ inputs.pr_number }}" PR_NUMBER="${{ inputs.pr_number }}"
if [ -z "$PR_NUMBER" ]; then if [ -z "$PR_NUMBER" ]; then
BRANCH=$(git rev-parse --abbrev-ref HEAD) BRANCH=$(git rev-parse --abbrev-ref HEAD)
PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --head "$BRANCH" --json number --jq '.[0].number' 2>/dev/null || true) PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --head "$BRANCH" --json number --jq '.[0].number' 2>/dev/null || true)
fi fi
if [ -n "$PR_NUMBER" ] && [ -n "$ARTIFACT_URL" ]; then if [ -n "$PR_NUMBER" ] && [ -n "$ARTIFACT_ID" ]; then
gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "| 📦 **Windows Build Complete for** $(git rev-parse HEAD) | BODY="| 📦 **Windows Build Complete for** $(git rev-parse HEAD) |"$'\n'
|-| BODY+="|-|"$'\n'
| [Download artifact]($ARTIFACT_URL) |" BODY+="| [Download binary]($ARTIFACT_URL) |"
gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "$BODY"
fi fi
- name: 🔑 Azure login - name: 🔑 Azure login
@ -488,16 +465,18 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
ARTIFACT_URL=$(gh api /repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts --jq '.artifacts[] | select(.name == "graphite-mac-bundle") | .archive_download_url') ARTIFACT_ID=$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts --jq '.artifacts[] | select(.name == "graphite-mac-bundle") | .id')
ARTIFACT_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts/$ARTIFACT_ID"
PR_NUMBER="${{ inputs.pr_number }}" PR_NUMBER="${{ inputs.pr_number }}"
if [ -z "$PR_NUMBER" ]; then if [ -z "$PR_NUMBER" ]; then
BRANCH=$(git rev-parse --abbrev-ref HEAD) BRANCH=$(git rev-parse --abbrev-ref HEAD)
PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --head "$BRANCH" --json number --jq '.[0].number' 2>/dev/null || true) PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --head "$BRANCH" --json number --jq '.[0].number' 2>/dev/null || true)
fi fi
if [ -n "$PR_NUMBER" ] && [ -n "$ARTIFACT_URL" ]; then if [ -n "$PR_NUMBER" ] && [ -n "$ARTIFACT_ID" ]; then
gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "| 📦 **Mac Build Complete for** $(git rev-parse HEAD) | BODY="| 📦 **Mac Build Complete for** $(git rev-parse HEAD) |"$'\n'
|-| BODY+="|-|"$'\n'
| [Download artifact]($ARTIFACT_URL) |" BODY+="| [Download binary]($ARTIFACT_URL) |"
gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "$BODY"
fi fi
- name: 🔏 Sign and notarize (preparation) - name: 🔏 Sign and notarize (preparation)
@ -616,20 +595,24 @@ jobs:
compression-level: 0 compression-level: 0
- name: 💬 Comment artifact link on PR - name: 💬 Comment artifact link on PR
id: linux-comment
if: github.event_name != 'push' if: github.event_name != 'push'
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
ARTIFACT_URL=$(gh api /repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts --jq '.artifacts[] | select(.name == "graphite-linux-bundle") | .archive_download_url') ARTIFACT_ID=$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts --jq '.artifacts[] | select(.name == "graphite-linux-bundle") | .id')
ARTIFACT_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts/$ARTIFACT_ID"
PR_NUMBER="${{ inputs.pr_number }}" PR_NUMBER="${{ inputs.pr_number }}"
if [ -z "$PR_NUMBER" ]; then if [ -z "$PR_NUMBER" ]; then
BRANCH=$(git rev-parse --abbrev-ref HEAD) BRANCH=$(git rev-parse --abbrev-ref HEAD)
PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --head "$BRANCH" --json number --jq '.[0].number' 2>/dev/null || true) PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --head "$BRANCH" --json number --jq '.[0].number' 2>/dev/null || true)
fi fi
if [ -n "$PR_NUMBER" ] && [ -n "$ARTIFACT_URL" ]; then if [ -n "$PR_NUMBER" ] && [ -n "$ARTIFACT_ID" ]; then
gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "| 📦 **Linux Build Complete for** $(git rev-parse HEAD) | BODY="| 📦 **Linux Build Complete for** $(git rev-parse HEAD) |"$'\n'
|-| BODY+="|-|"$'\n'
| [Download artifact]($ARTIFACT_URL) |" BODY+="| [Download binary]($ARTIFACT_URL) |"
COMMENT_ID=$(gh api repos/${{ github.repository }}/issues/$PR_NUMBER/comments -f body="$BODY" --jq '.id')
echo "comment_id=$COMMENT_ID" >> "$GITHUB_OUTPUT"
fi fi
- name: 🔧 Install Flatpak tooling - name: 🔧 Install Flatpak tooling
@ -640,7 +623,7 @@ jobs:
- name: 🏗 Build Flatpak - name: 🏗 Build Flatpak
run: | run: |
nix build .#graphite-flatpak-manifest nix build .#graphite${{ inputs.debug && '-dev' || '' }}-flatpak-manifest
rm -rf .flatpak rm -rf .flatpak
mkdir -p .flatpak mkdir -p .flatpak
@ -660,3 +643,18 @@ jobs:
name: graphite-flatpak name: graphite-flatpak
path: .flatpak/Graphite.flatpak path: .flatpak/Graphite.flatpak
compression-level: 0 compression-level: 0
- name: 💬 Update PR comment with Flatpak artifact link
if: github.event_name != 'push' && steps.linux-comment.outputs.comment_id
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
ARTIFACT_ID=$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts --jq '.artifacts[] | select(.name == "graphite-flatpak") | .id')
ARTIFACT_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts/$ARTIFACT_ID"
COMMENT_ID="${{ steps.linux-comment.outputs.comment_id }}"
if [ -n "$ARTIFACT_ID" ]; then
EXISTING_BODY=$(gh api repos/${{ github.repository }}/issues/comments/$COMMENT_ID --jq '.body')
BODY="$EXISTING_BODY"$'\n'
BODY+="| [Download Flatpak]($ARTIFACT_URL) |"
gh api repos/${{ github.repository }}/issues/comments/$COMMENT_ID -X PATCH -f body="$BODY"
fi

View File

@ -49,30 +49,16 @@ jobs:
# Remove the INDEX_HTML_HEAD_INCLUSION environment variable for build links (not master deploys) # Remove the INDEX_HTML_HEAD_INCLUSION environment variable for build links (not master deploys)
git rev-parse --abbrev-ref HEAD | grep master > /dev/null || export INDEX_HTML_HEAD_INCLUSION="" git rev-parse --abbrev-ref HEAD | grep master > /dev/null || export INDEX_HTML_HEAD_INCLUSION=""
- name: 💿 Obtain cache of auto-generated code docs artifacts - name: 🦀 Produce auto-generated code docs data
id: cache-website-code-docs
uses: actions/cache/restore@v5
with:
path: website/generated
key: website-code-docs
- name: 📁 Fallback in case auto-generated code docs artifacts weren't cached
if: steps.cache-website-code-docs.outputs.cache-hit != 'true'
run: | run: |
echo "🦀 Initial system version of Rust:"
rustc --version
rustup update stable rustup update stable
echo "🦀 Latest updated version of Rust:" cargo run -p crate-hierarchy-viz -- website/generated
rustc --version cargo run -p editor-message-tree -- website/generated
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: 🔧 Build auto-generated code docs artifacts into HTML/SVG - name: 🔧 Install website npm dependencies
run: | run: |
cd website cd website
npm ci npm ci
npm run generate-editor-structure
npm run generate-crate-hierarchy
- name: 📃 Generate node catalog documentation - name: 📃 Generate node catalog documentation
run: cargo run -p node-docs -- website/content/learn/node-catalog run: cargo run -p node-docs -- website/content/learn/node-catalog

View File

@ -61,7 +61,8 @@ in
graphite-branding = lib.call ./pkgs/graphite-branding.nix; graphite-branding = lib.call ./pkgs/graphite-branding.nix;
graphite-bundle = (lib.call ./pkgs/graphite-bundle.nix) { }; graphite-bundle = (lib.call ./pkgs/graphite-bundle.nix) { };
graphite-dev-bundle = (lib.call ./pkgs/graphite-bundle.nix) { graphite = graphite-dev; }; graphite-dev-bundle = (lib.call ./pkgs/graphite-bundle.nix) { graphite = graphite-dev; };
graphite-flatpak-manifest = lib.call ./pkgs/graphite-flatpak-manifest.nix; graphite-flatpak-manifest = (lib.call ./pkgs/graphite-flatpak-manifest.nix) { };
graphite-dev-flatpak-manifest = (lib.call ./pkgs/graphite-flatpak-manifest.nix) { graphite-bundle = graphite-dev-bundle; };
# TODO: graphene-cli = lib.call ./pkgs/graphene-cli.nix; # TODO: graphene-cli = lib.call ./pkgs/graphene-cli.nix;

View File

@ -4,6 +4,9 @@
system, system,
... ...
}: }:
{
graphite-bundle ? self.packages.${system}.graphite-bundle,
}:
(pkgs.formats.json { }).generate "art.graphite.Graphite.json" { (pkgs.formats.json { }).generate "art.graphite.Graphite.json" {
app-id = "art.graphite.Graphite"; app-id = "art.graphite.Graphite";
@ -30,7 +33,7 @@
sources = [ sources = [
{ {
type = "archive"; type = "archive";
path = self.packages.${system}.graphite-bundle.tar; path = graphite-bundle.tar;
strip-components = 0; strip-components = 0;
} }
]; ];

View File

@ -2,7 +2,9 @@ use anyhow::{Context, Result};
use serde::Deserialize; use serde::Deserialize;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct WorkspaceToml { struct WorkspaceToml {
@ -83,12 +85,13 @@ fn collect_all_dependencies(crate_name: &str, dep_map: &HashMap<String, HashSet<
} }
fn main() -> Result<()> { fn main() -> Result<()> {
let output_path = std::env::args_os() let output_dir = std::env::args_os()
.nth(1) .nth(1)
.map(PathBuf::from) .map(PathBuf::from)
.ok_or_else(|| anyhow::anyhow!("Usage: crate-hierarchy-viz <output-file>"))?; .ok_or_else(|| anyhow::anyhow!("Usage: crate-hierarchy-viz <output-directory>"))?;
let output_path = output_dir.join("crate-hierarchy.svg");
let workspace_root = std::env::current_dir().unwrap(); let workspace_root = std::env::current_dir()?;
let workspace_toml_path = workspace_root.join("Cargo.toml"); let workspace_toml_path = workspace_root.join("Cargo.toml");
// Parse workspace Cargo.toml // Parse workspace Cargo.toml
@ -173,17 +176,79 @@ fn main() -> Result<()> {
remove_transitive_dependencies(&mut crates); remove_transitive_dependencies(&mut crates);
// Generate DOT format and write to output file // Generate DOT format, convert to SVG, and write to output file
let dot_content = generate_dot(&crates); let dot_content = generate_dot(&crates);
let svg_content = dot_to_svg(&dot_content)?;
if let Some(parent) = output_path.parent() { fs::create_dir_all(&output_dir).with_context(|| format!("Failed to create directory {:?}", output_dir))?;
fs::create_dir_all(parent).with_context(|| format!("Failed to create directory {:?}", parent))?; fs::write(&output_path, &svg_content).with_context(|| format!("Failed to write to {:?}", output_path))?;
}
fs::write(&output_path, &dot_content).with_context(|| format!("Failed to write to {:?}", output_path))?;
Ok(()) Ok(())
} }
/// Convert a DOT graph string to SVG by shelling out to @viz-js/viz via Node.js
fn dot_to_svg(dot: &str) -> Result<String> {
let temp_dir = std::env::temp_dir().join("crate-hierarchy-viz");
fs::create_dir_all(&temp_dir).with_context(|| "Failed to create temp directory")?;
// Install @viz-js/viz into the temp directory if not already present
let viz_package = temp_dir.join("node_modules").join("@viz-js").join("viz");
if !viz_package.exists() {
let npm = if cfg!(target_os = "windows") { "npm.cmd" } else { "npm" };
let status = Command::new(npm)
.args(["install", "--prefix", &temp_dir.to_string_lossy(), "@viz-js/viz"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.status()
.with_context(|| "Failed to run `npm install`. Is Node.js installed?")?;
if !status.success() {
anyhow::bail!("Executing `npm install @viz-js/viz` failed");
}
}
// Write a small script that reads DOT from stdin and outputs SVG
let script_path = temp_dir.join("convert.mjs");
fs::write(
&script_path,
r#"
import { instance } from "@viz-js/viz";
let dot = "";
for await (const chunk of process.stdin) dot += chunk;
const viz = await instance();
process.stdout.write(viz.renderString(dot, { format: "svg" }));
"#
.trim(),
)?;
let mut child = Command::new("node")
.arg(&script_path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.with_context(|| "Failed to spawn `node`. Is Node.js installed?")?;
// Write DOT content to stdin then close the pipe
child
.stdin
.take()
.context("Failed to get stdin handle for node process")?
.write_all(dot.as_bytes())
.with_context(|| "Failed to write DOT content to stdin")?;
let output = child.wait_with_output().with_context(|| "Failed to wait for `node` process")?;
// Clean up the temp script (node_modules is intentionally kept as a cache)
let _ = fs::remove_file(&script_path);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("DOT to SVG conversion failed (exit code {:?}):\n{}", output.status.code(), stderr);
}
String::from_utf8(output.stdout).with_context(|| "SVG output was not valid UTF-8")
}
fn generate_dot(crates: &[CrateInfo]) -> String { fn generate_dot(crates: &[CrateInfo]) -> String {
let mut out = String::new(); let mut out = String::new();
out.push_str("digraph CrateHierarchy {\n"); out.push_str("digraph CrateHierarchy {\n");

View File

@ -3,28 +3,50 @@ use editor::utility_types::DebugMessageTree;
use std::io::Write; use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
fn main() -> Result<(), Box<dyn std::error::Error>> { const FRONTEND_MESSAGE_STR: &str = "FrontendMessage";
let output_path = std::env::args_os().nth(1).map(PathBuf::from).ok_or("Usage: editor-message-tree <output-file>")?;
if let Some(parent) = output_path.parent() { fn main() -> Result<(), Box<dyn std::error::Error>> {
std::fs::create_dir_all(parent).unwrap(); let output_dir = std::env::args_os().nth(1).map(PathBuf::from).ok_or("Usage: editor-message-tree <output-directory>")?;
std::fs::create_dir_all(&output_dir)?;
let tree = Message::message_tree();
// Write the .txt file (plain text tree outline, served as a static download)
let static_dir = output_dir.join("../static/volunteer/guide/codebase-overview");
std::fs::create_dir_all(&static_dir)?;
let mut txt_file = std::fs::File::create(static_dir.join("hierarchical-message-system-tree.txt"))?;
write_tree_txt(&tree, &mut txt_file)?;
// Write the .html file (structured HTML embedded in the website page)
let mut html = String::new();
write_tree_html(&tree, &mut html);
std::fs::write(output_dir.join("hierarchical-message-system-tree.html"), &html)?;
Ok(())
}
// =================
// PLAIN TEXT OUTPUT
// =================
fn write_tree_txt(tree: &DebugMessageTree, file: &mut std::fs::File) -> std::io::Result<()> {
if tree.path().is_empty() {
file.write_all(format!("{}\n", tree.name()).as_bytes())?;
} else {
file.write_all(format!("{} `{}#L{}`\n", tree.name(), tree.path(), tree.line_number()).as_bytes())?;
} }
let result = Message::message_tree(); if let Some(variants) = tree.variants() {
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() { for (i, variant) in variants.iter().enumerate() {
let is_last = i == variants.len() - 1; let is_last = i == variants.len() - 1;
print_tree_node(variant, "", is_last, &mut file); write_tree_txt_node(variant, "", is_last, file)?;
} }
} }
Ok(()) Ok(())
} }
fn print_tree_node(tree: &DebugMessageTree, prefix: &str, is_last: bool, file: &mut std::fs::File) { fn write_tree_txt_node(tree: &DebugMessageTree, prefix: &str, is_last: bool, file: &mut std::fs::File) -> std::io::Result<()> {
// Print the current node
let (branch, child_prefix) = if tree.message_handler_data_fields().is_some() || tree.message_handler_fields().is_some() { let (branch, child_prefix) = if tree.message_handler_data_fields().is_some() || tree.message_handler_fields().is_some() {
("├── ", format!("{prefix}")) ("├── ", format!("{prefix}"))
} else if is_last { } else if is_last {
@ -34,33 +56,28 @@ fn print_tree_node(tree: &DebugMessageTree, prefix: &str, is_last: bool, file: &
}; };
if tree.path().is_empty() { if tree.path().is_empty() {
file.write_all(format!("{}{}{}\n", prefix, branch, tree.name()).as_bytes()).unwrap(); file.write_all(format!("{}{}{}\n", prefix, branch, tree.name()).as_bytes())?;
} else { } else {
file.write_all(format!("{}{}{} `{}#L{}`\n", prefix, branch, tree.name(), tree.path(), tree.line_number()).as_bytes()) file.write_all(format!("{}{}{} `{}#L{}`\n", prefix, branch, tree.name(), tree.path(), tree.line_number()).as_bytes())?;
.unwrap();
} }
// Print children if any
if let Some(variants) = tree.variants() { if let Some(variants) = tree.variants() {
let len = variants.len(); let len = variants.len();
for (i, variant) in variants.iter().enumerate() { for (i, variant) in variants.iter().enumerate() {
let is_last_child = i == len - 1; let is_last_child = i == len - 1;
print_tree_node(variant, &child_prefix, is_last_child, file); write_tree_txt_node(variant, &child_prefix, is_last_child, file)?;
} }
} }
// Print message field if any
if let Some(fields) = tree.fields() { if let Some(fields) = tree.fields() {
let len = fields.len(); let len = fields.len();
for (i, field) in fields.iter().enumerate() { for (i, field) in fields.iter().enumerate() {
let is_last_field = i == len - 1; let is_last_field = i == len - 1;
let branch = if is_last_field { "└── " } else { "├── " }; let branch = if is_last_field { "└── " } else { "├── " };
file.write_all(format!("{child_prefix}{branch}{field}\n").as_bytes())?;
file.write_all(format!("{child_prefix}{branch}{field}\n").as_bytes()).unwrap();
} }
} }
// Print handler field if any
if let Some(data) = tree.message_handler_fields() { if let Some(data) = tree.message_handler_fields() {
let len = data.fields().len(); let len = data.fields().len();
let (branch, child_prefix) = if tree.message_handler_data_fields().is_some() { let (branch, child_prefix) = if tree.message_handler_data_fields().is_some() {
@ -69,36 +86,199 @@ fn print_tree_node(tree: &DebugMessageTree, prefix: &str, is_last: bool, file: &
("└── ", format!("{prefix} ")) ("└── ", format!("{prefix} "))
}; };
const FRONTEND_MESSAGE_STR: &str = "FrontendMessage";
if data.name().is_empty() && tree.name() != FRONTEND_MESSAGE_STR { if data.name().is_empty() && tree.name() != FRONTEND_MESSAGE_STR {
panic!("{}'s MessageHandler is missing #[message_handler_data]", tree.name()); panic!("{}'s MessageHandler is missing #[message_handler_data]", tree.name());
} else if tree.name() != FRONTEND_MESSAGE_STR { } else if tree.name() != FRONTEND_MESSAGE_STR {
file.write_all(format!("{}{}{} `{}#L{}`\n", prefix, branch, data.name(), data.path(), data.line_number()).as_bytes()) file.write_all(format!("{}{}{} `{}#L{}`\n", prefix, branch, data.name(), data.path(), data.line_number()).as_bytes())?;
.unwrap();
for (i, field) in data.fields().iter().enumerate() { for (i, field) in data.fields().iter().enumerate() {
let is_last_field = i == len - 1; let is_last_field = i == len - 1;
let branch = if is_last_field { "└── " } else { "├── " }; let branch = if is_last_field { "└── " } else { "├── " };
file.write_all(format!("{}{}{}\n", child_prefix, branch, field.0).as_bytes())?;
file.write_all(format!("{}{}{}\n", child_prefix, branch, field.0).as_bytes()).unwrap();
} }
} }
} }
// Print data field if any
if let Some(data) = tree.message_handler_data_fields() { if let Some(data) = tree.message_handler_data_fields() {
let len = data.fields().len(); let len = data.fields().len();
if data.path().is_empty() { if data.path().is_empty() {
file.write_all(format!("{}{}{}\n", prefix, "└── ", data.name()).as_bytes()).unwrap(); file.write_all(format!("{}{}{}\n", prefix, "└── ", data.name()).as_bytes())?;
} else { } else {
file.write_all(format!("{}{}{} `{}#L{}`\n", prefix, "└── ", data.name(), data.path(), data.line_number()).as_bytes()) file.write_all(format!("{}{}{} `{}#L{}`\n", prefix, "└── ", data.name(), data.path(), data.line_number()).as_bytes())?;
.unwrap();
} }
for (i, field) in data.fields().iter().enumerate() { for (i, field) in data.fields().iter().enumerate() {
let is_last_field = i == len - 1; let is_last_field = i == len - 1;
let branch = if is_last_field { "└── " } else { "├── " }; let branch = if is_last_field { "└── " } else { "├── " };
let field = &field.0; let field = &field.0;
file.write_all(format!("{prefix} {branch}{field}\n").as_bytes()).unwrap(); file.write_all(format!("{prefix} {branch}{field}\n").as_bytes())?;
} }
} }
Ok(())
}
// ===========
// HTML OUTPUT
// ===========
const GITHUB_BASE: &str = "https://github.com/GraphiteEditor/Graphite/blob/master/";
const NAMING_SUFFIXES: &[&str] = &["Message", "MessageHandler", "MessageContext"];
fn escape_html(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
}
fn github_link(path: &str, line: usize) -> String {
let path = path.replace('\\', "/");
let filename = path.rsplit('/').next().unwrap_or(&path);
format!(r#"<a href="{GITHUB_BASE}{path}#L{line}" target="_blank">{filename}:{line}</a>"#)
}
fn naming_convention_warning(name: &str) -> &'static str {
// Strip generic parameters for the check (e.g. `Foo<Bar>` -> `Foo`)
let base_name = name.split('<').next().unwrap_or(name);
if NAMING_SUFFIXES.iter().any(|suffix| base_name.ends_with(suffix)) {
""
} else {
r#"<span class="warn">(violates naming convention — should end with 'Message', 'MessageHandler', or 'MessageContext')</span>"#
}
}
fn write_tree_html(tree: &DebugMessageTree, out: &mut String) {
// Root node
let link = if !tree.path().is_empty() { github_link(tree.path(), tree.line_number()) } else { String::new() };
let escaped_name = escape_html(tree.name());
out.push_str("<ul>\n");
out.push_str(&format!(r#"<li><span class="tree-node"><span class="subsystem">{escaped_name}</span>{link}</span>"#));
if let Some(variants) = tree.variants() {
out.push_str(r#"<div class="nested">"#);
write_tree_html_children(variants, out);
out.push_str("</div>");
}
out.push_str("</li>\n</ul>\n");
}
fn write_tree_html_children(variants: &[DebugMessageTree], out: &mut String) {
out.push_str("<ul>\n");
for variant in variants {
write_tree_html_node(variant, out);
}
out.push_str("</ul>\n");
}
fn write_tree_html_node(tree: &DebugMessageTree, out: &mut String) {
let has_link = !tree.path().is_empty();
let link = if has_link { github_link(tree.path(), tree.line_number()) } else { String::new() };
let escaped_name = escape_html(tree.name());
enum HtmlChild<'a> {
Subtree(&'a DebugMessageTree),
Field(String),
HandlerFields(String, String, usize, Vec<String>),
DataFields(String, String, usize, Vec<String>),
}
// Collect all child entries for this node
let mut children: Vec<HtmlChild> = Vec::new();
if let Some(variants) = tree.variants() {
for variant in variants {
children.push(HtmlChild::Subtree(variant));
}
}
if let Some(fields) = tree.fields() {
for field in fields {
children.push(HtmlChild::Field(field.to_string()));
}
}
if let Some(data) = tree.message_handler_fields()
&& (!data.name().is_empty() || tree.name() == FRONTEND_MESSAGE_STR)
&& tree.name() != FRONTEND_MESSAGE_STR
{
children.push(HtmlChild::HandlerFields(
data.name().to_string(),
data.path().to_string(),
data.line_number(),
data.fields().iter().map(|f| f.0.clone()).collect(),
));
}
if let Some(data) = tree.message_handler_data_fields() {
children.push(HtmlChild::DataFields(
data.name().to_string(),
data.path().to_string(),
data.line_number(),
data.fields().iter().map(|f| f.0.clone()).collect(),
));
}
let has_children = !children.is_empty();
let has_deeper_children = children.iter().any(|child| matches!(child, HtmlChild::Subtree(t) if t.variants().is_some() || t.fields().is_some()));
// Determine role
let role = if has_link {
"subsystem"
} else if has_deeper_children {
"submessage"
} else {
"message"
};
// Naming convention warning (only for linked/subsystem nodes)
let warning = if has_link { naming_convention_warning(tree.name()) } else { "" };
if has_children {
out.push_str(&format!(r#"<li><span class="tree-node"><span class="{role}">{escaped_name}</span>{link}{warning}</span>"#));
out.push_str(r#"<div class="nested"><ul>"#);
out.push('\n');
for child in &children {
match child {
HtmlChild::Subtree(subtree) => write_tree_html_node(subtree, out),
HtmlChild::Field(field) => write_field_html(field, out),
HtmlChild::HandlerFields(name, path, line, fields) => write_handler_or_data_html(name, path, *line, fields, out),
HtmlChild::DataFields(name, path, line, fields) => write_handler_or_data_html(name, path, *line, fields, out),
}
}
out.push_str("</ul>\n</div></li>\n");
} else {
out.push_str(&format!(r#"<li><span class="tree-leaf {role}">{escaped_name}</span>{link}{warning}</li>"#));
out.push('\n');
}
}
fn write_field_html(field: &str, out: &mut String) {
if let Some((name, ty)) = field.split_once(':') {
let name = escape_html(name.trim());
let ty = escape_html(ty.trim());
out.push_str(&format!(r#"<li><span class="tree-leaf field">{name}</span>: <span>{ty}</span></li>"#));
} else {
let escaped = escape_html(field);
out.push_str(&format!(r#"<li><span class="tree-leaf message">{escaped}</span></li>"#));
}
out.push('\n');
}
fn write_handler_or_data_html(name: &str, path: &str, line: usize, fields: &[String], out: &mut String) {
let escaped_name = escape_html(name);
let link = if !path.is_empty() { github_link(path, line) } else { String::new() };
let warning = if !path.is_empty() { naming_convention_warning(name) } else { "" };
if fields.is_empty() {
out.push_str(&format!(r#"<li><span class="tree-leaf subsystem">{escaped_name}</span>{link}{warning}</li>"#));
} else {
out.push_str(&format!(r#"<li><span class="tree-node"><span class="subsystem">{escaped_name}</span>{link}{warning}</span>"#));
out.push_str(r#"<div class="nested"><ul>"#);
out.push('\n');
for field in fields {
write_field_html(field, out);
}
out.push_str("</ul>\n</div></li>\n");
}
} }

View File

@ -1,19 +0,0 @@
/* 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 <input.dot> <output.svg>");
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}`);

View File

@ -1,122 +0,0 @@
// TODO: Port this script to Rust as part of `tools/editor-message-tree/src/main.rs`
/* eslint-disable no-console */
import fs from "fs";
import path from "path";
type Entry = { level: number; text: string; link: string | undefined };
/// Parses a single line of the input text.
function parseLine(line: string) {
const linkRegex = /`([^`]+)`$/;
const linkMatch = line.match(linkRegex);
let link = undefined;
if (linkMatch) {
const filePath = linkMatch[1].replace(/\\/g, "/");
link = `https://github.com/GraphiteEditor/Graphite/blob/master/${filePath}`;
}
const textContent = line
.replace(/^[\s│├└─]*/, "")
.replace(linkRegex, "")
.trim();
const indentation = line.indexOf(textContent);
// Each level of indentation is 4 characters.
const level = Math.floor(indentation / 4);
return { level, text: textContent, link };
}
/// Recursively builds the HTML list from the parsed nodes.
function buildHtmlList(nodes: Entry[], currentIndex: number, currentLevel: number) {
if (currentIndex >= nodes.length) {
return { html: "", nextIndex: currentIndex };
}
let html = "<ul>\n";
let i = currentIndex;
while (i < nodes.length && nodes[i].level >= currentLevel) {
const node = nodes[i];
if (node.level > currentLevel) {
// This case handles malformed input, skip to next valid line
i++;
continue;
}
const hasDirectChildren = i + 1 < nodes.length && nodes[i + 1].level > node.level;
const hasDeeperChildren = hasDirectChildren && i + 2 < nodes.length && nodes[i + 2].level > nodes[i + 1].level;
const linkHtml = node.link ? `<a href="${node.link}" target="_blank">${path.basename(node.link.split("#L").join(":"))}</a>` : "";
const fieldPieces = node.text.match(/([^:]*):(.*)/);
let escapedText;
if (fieldPieces && fieldPieces.length === 3) {
escapedText = [escapeHtml(fieldPieces[1].trim()), escapeHtml(fieldPieces[2].trim())];
} else {
escapedText = [escapeHtml(node.text)];
}
let role = "message";
if (node.link) role = "subsystem";
else if (hasDeeperChildren) role = "submessage";
else if (escapedText.length === 2) role = "field";
const partOfMessageFromNamingConvention = ["Message", "MessageHandler", "MessageContext"].some((suffix) => node.text.replace(/(.*)<.*>/g, "$1").endsWith(suffix));
const partOfMessageViolatesNamingConvention = node.link && !partOfMessageFromNamingConvention;
const violatesNamingConvention = partOfMessageViolatesNamingConvention
? "<span class=\"warn\">(violates naming convention — should end with 'Message', 'MessageHandler', or 'MessageContext')</span>"
: "";
if (hasDirectChildren) {
html += `<li><span class="tree-node"><span class="${role}">${escapedText}</span>${linkHtml}${violatesNamingConvention}</span>`;
const childResult = buildHtmlList(nodes, i + 1, node.level + 1);
html += `<div class="nested">${childResult.html}</div></li>\n`;
i = childResult.nextIndex;
} else if (role === "field") {
html += `<li><span class="tree-leaf field">${escapedText[0]}</span>: <span>${escapedText[1]}</span>${linkHtml}</li>\n`;
i++;
} else {
html += `<li><span class="tree-leaf ${role}">${escapedText[0]}</span>${linkHtml}${violatesNamingConvention}</li>\n`;
i++;
}
}
html += "</ul>\n";
return { html, nextIndex: i };
}
function escapeHtml(text: string) {
return text.replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
const inputFile = process.argv[2];
const outputFile = process.argv[3];
if (!inputFile || !outputFile) {
console.error("Error: Please provide the input text and output HTML file paths as arguments.");
console.log("Usage: node generate-editor-structure.ts <input txt> <output html>");
process.exit(1);
}
if (!fs.existsSync(inputFile)) {
console.error(`Error: File not found at "${inputFile}"`);
process.exit(1);
}
try {
const fileContent = fs.readFileSync(inputFile, "utf-8");
const lines = fileContent.split(/\r?\n/).filter((line) => line.trim() !== "" && !line.startsWith("// filepath:"));
const parsedNodes = lines.map(parseLine);
const { html } = buildHtmlList(parsedNodes, 0, 0);
fs.writeFileSync(outputFile, html, "utf-8");
console.log(`Successfully generated HTML outline at: ${outputFile}`);
} catch (error) {
console.error("An error occurred during processing:", error);
process.exit(1);
}

View File

@ -22,7 +22,7 @@ The dispatcher lives at the root of the editor hierarchy and acts as the owner o
cargo run explore editor cargo run explore editor
``` ```
Click to explore the outline of the editor subsystem hierarchy which forms the structure of the editor's subsystems, state, and interactions. Click to explore the outline of the editor subsystem hierarchy which forms the structure of the editor's subsystems, state, and interactions. Also available as a searchable <a href="/volunteer/guide/codebase-overview/hierarchical-message-system-tree.txt">plain text file</a>.
<div class="structure-outline"> <div class="structure-outline">
<!-- replacements::hierarchical_message_system_tree() --> <!-- replacements::hierarchical_message_system_tree() -->

View File

@ -16,7 +16,6 @@
"@eslint/eslintrc": "^3.3.3", "@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@types/node": "^25.0.9", "@types/node": "^25.0.9",
"@viz-js/viz": "^3.25.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4", "eslint-import-resolver-typescript": "^4.4.4",
@ -1233,13 +1232,6 @@
"win32" "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": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",

View File

@ -12,8 +12,6 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"postinstall": "node .build-scripts/install.ts", "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", "check": "tsc --noEmit && eslint",
"fix": "eslint --fix" "fix": "eslint --fix"
}, },
@ -22,7 +20,6 @@
"@eslint/eslintrc": "^3.3.3", "@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@types/node": "^25.0.9", "@types/node": "^25.0.9",
"@viz-js/viz": "^3.25.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4", "eslint-import-resolver-typescript": "^4.4.4",

View File

@ -40,25 +40,21 @@
{% endmacro text_balancer %} {% endmacro text_balancer %}
{% macro hierarchical_message_system_tree() %} {% macro hierarchical_message_system_tree() %}
{%- set content = load_data(path = "../generated/hierarchical_message_system_tree.html", format = "plain", required = false) -%} {%- set content = load_data(path = "../generated/hierarchical-message-system-tree.html", format = "plain", required = false) -%}
{%- set fallback = "<pre>THIS CONTENT IS FILLED IN WHEN CI BUILDS THE WEBSITE. {%- set fallback = "<pre>THIS CONTENT IS FILLED IN WHEN CI BUILDS THE WEBSITE.
TO TEST IT LOCALLY, FROM THE ROOT OF THE PROJECT, RUN: TO TEST IT LOCALLY, FROM THE ROOT OF THE PROJECT, RUN:
cargo run -p editor-message-tree -- website/generated/hierarchical_message_system_tree.txt cargo run -p editor-message-tree -- website/generated</pre>" -%}
cd website
npm run generate-editor-structure</pre>" -%}
{{ content | default(value = fallback) | safe }} {{ content | default(value = fallback) | safe }}
{% endmacro hierarchical_message_system_tree %} {% endmacro hierarchical_message_system_tree %}
{% macro crate_hierarchy() %} {% macro crate_hierarchy() %}
{%- set content = load_data(path = "../generated/crate_hierarchy.svg", format = "plain", required = false) -%} {%- set content = load_data(path = "../generated/crate-hierarchy.svg", format = "plain", required = false) -%}
{%- set fallback = "<pre>THIS CONTENT IS FILLED IN WHEN CI BUILDS THE WEBSITE. {%- set fallback = "<pre>THIS CONTENT IS FILLED IN WHEN CI BUILDS THE WEBSITE.
TO TEST IT LOCALLY, FROM THE ROOT OF THE PROJECT, RUN: TO TEST IT LOCALLY, FROM THE ROOT OF THE PROJECT, RUN:
cargo run -p crate-hierarchy-viz -- website/generated/crate_hierarchy.dot cargo run -p crate-hierarchy-viz -- website/generated</pre>" -%}
cd website
npm run generate-crate-hierarchy</pre>" -%}
{{ content | default(value = fallback) | safe }} {{ content | default(value = fallback) | safe }}
{% endmacro crate_hierarchy %} {% endmacro crate_hierarchy %}