diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 784bd919e6..58467c78ec 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -145,7 +145,7 @@ jobs: gh api \ -X POST \ -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" else # 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 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' - 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: 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: if: github.event_name == 'push' || inputs.windows @@ -302,16 +277,18 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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 }}" if [ -z "$PR_NUMBER" ]; then 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) fi - if [ -n "$PR_NUMBER" ] && [ -n "$ARTIFACT_URL" ]; then - gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "| šŸ“¦ **Windows Build Complete for** $(git rev-parse HEAD) | - |-| - | [Download artifact]($ARTIFACT_URL) |" + if [ -n "$PR_NUMBER" ] && [ -n "$ARTIFACT_ID" ]; then + BODY="| šŸ“¦ **Windows Build Complete for** $(git rev-parse HEAD) |"$'\n' + BODY+="|-|"$'\n' + BODY+="| [Download binary]($ARTIFACT_URL) |" + gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "$BODY" fi - name: šŸ”‘ Azure login @@ -488,16 +465,18 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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 }}" if [ -z "$PR_NUMBER" ]; then 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) fi - if [ -n "$PR_NUMBER" ] && [ -n "$ARTIFACT_URL" ]; then - gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "| šŸ“¦ **Mac Build Complete for** $(git rev-parse HEAD) | - |-| - | [Download artifact]($ARTIFACT_URL) |" + if [ -n "$PR_NUMBER" ] && [ -n "$ARTIFACT_ID" ]; then + BODY="| šŸ“¦ **Mac Build Complete for** $(git rev-parse HEAD) |"$'\n' + BODY+="|-|"$'\n' + BODY+="| [Download binary]($ARTIFACT_URL) |" + gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "$BODY" fi - name: šŸ” Sign and notarize (preparation) @@ -616,20 +595,24 @@ jobs: compression-level: 0 - name: šŸ’¬ Comment artifact link on PR + id: linux-comment if: github.event_name != 'push' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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 }}" if [ -z "$PR_NUMBER" ]; then 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) fi - if [ -n "$PR_NUMBER" ] && [ -n "$ARTIFACT_URL" ]; then - gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "| šŸ“¦ **Linux Build Complete for** $(git rev-parse HEAD) | - |-| - | [Download artifact]($ARTIFACT_URL) |" + if [ -n "$PR_NUMBER" ] && [ -n "$ARTIFACT_ID" ]; then + BODY="| šŸ“¦ **Linux Build Complete for** $(git rev-parse HEAD) |"$'\n' + BODY+="|-|"$'\n' + 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 - name: šŸ”§ Install Flatpak tooling @@ -640,7 +623,7 @@ jobs: - name: šŸ— Build Flatpak run: | - nix build .#graphite-flatpak-manifest + nix build .#graphite${{ inputs.debug && '-dev' || '' }}-flatpak-manifest rm -rf .flatpak mkdir -p .flatpak @@ -660,3 +643,18 @@ jobs: name: graphite-flatpak path: .flatpak/Graphite.flatpak 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 diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index fafa349598..b9c2af737b 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -49,30 +49,16 @@ jobs: # 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="" - - name: šŸ’æ Obtain cache of auto-generated code docs artifacts - 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' + - name: šŸ¦€ Produce auto-generated code docs data run: | - echo "šŸ¦€ Initial system version of Rust:" - rustc --version rustup update stable - echo "šŸ¦€ Latest updated version of Rust:" - rustc --version - cargo run -p crate-hierarchy-viz -- website/generated/crate_hierarchy.dot - cargo run -p editor-message-tree -- website/generated/hierarchical_message_system_tree.txt + cargo run -p crate-hierarchy-viz -- website/generated + cargo run -p editor-message-tree -- website/generated - - name: šŸ”§ Build auto-generated code docs artifacts into HTML/SVG + - name: šŸ”§ Install website npm dependencies run: | cd website npm ci - npm run generate-editor-structure - npm run generate-crate-hierarchy - name: šŸ“ƒ Generate node catalog documentation run: cargo run -p node-docs -- website/content/learn/node-catalog diff --git a/.nix/default.nix b/.nix/default.nix index 20e26a962b..1fa50b0015 100644 --- a/.nix/default.nix +++ b/.nix/default.nix @@ -61,7 +61,8 @@ in graphite-branding = lib.call ./pkgs/graphite-branding.nix; graphite-bundle = (lib.call ./pkgs/graphite-bundle.nix) { }; 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; diff --git a/.nix/pkgs/graphite-flatpak-manifest.nix b/.nix/pkgs/graphite-flatpak-manifest.nix index fee6603119..a01bde88c3 100644 --- a/.nix/pkgs/graphite-flatpak-manifest.nix +++ b/.nix/pkgs/graphite-flatpak-manifest.nix @@ -4,6 +4,9 @@ system, ... }: +{ + graphite-bundle ? self.packages.${system}.graphite-bundle, +}: (pkgs.formats.json { }).generate "art.graphite.Graphite.json" { app-id = "art.graphite.Graphite"; @@ -30,7 +33,7 @@ sources = [ { type = "archive"; - path = self.packages.${system}.graphite-bundle.tar; + path = graphite-bundle.tar; strip-components = 0; } ]; diff --git a/tools/crate-hierarchy-viz/src/main.rs b/tools/crate-hierarchy-viz/src/main.rs index 837b38768d..a53eff48c1 100644 --- a/tools/crate-hierarchy-viz/src/main.rs +++ b/tools/crate-hierarchy-viz/src/main.rs @@ -2,7 +2,9 @@ use anyhow::{Context, Result}; use serde::Deserialize; use std::collections::{HashMap, HashSet}; use std::fs; +use std::io::Write; use std::path::PathBuf; +use std::process::Command; #[derive(Debug, Deserialize)] struct WorkspaceToml { @@ -83,12 +85,13 @@ fn collect_all_dependencies(crate_name: &str, dep_map: &HashMap Result<()> { - let output_path = std::env::args_os() + let output_dir = std::env::args_os() .nth(1) .map(PathBuf::from) - .ok_or_else(|| anyhow::anyhow!("Usage: crate-hierarchy-viz "))?; + .ok_or_else(|| anyhow::anyhow!("Usage: crate-hierarchy-viz "))?; + 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"); // Parse workspace Cargo.toml @@ -173,17 +176,79 @@ fn main() -> Result<()> { 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 svg_content = dot_to_svg(&dot_content)?; - 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))?; + fs::create_dir_all(&output_dir).with_context(|| format!("Failed to create directory {:?}", output_dir))?; + fs::write(&output_path, &svg_content).with_context(|| format!("Failed to write to {:?}", output_path))?; 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 { + 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 { let mut out = String::new(); out.push_str("digraph CrateHierarchy {\n"); diff --git a/tools/editor-message-tree/src/main.rs b/tools/editor-message-tree/src/main.rs index 8b9108f895..58e5c4c0ec 100644 --- a/tools/editor-message-tree/src/main.rs +++ b/tools/editor-message-tree/src/main.rs @@ -3,28 +3,50 @@ use editor::utility_types::DebugMessageTree; use std::io::Write; use std::path::PathBuf; +const FRONTEND_MESSAGE_STR: &str = "FrontendMessage"; + fn main() -> Result<(), Box> { - let output_path = std::env::args_os().nth(1).map(PathBuf::from).ok_or("Usage: editor-message-tree ")?; + let output_dir = std::env::args_os().nth(1).map(PathBuf::from).ok_or("Usage: editor-message-tree ")?; + 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 +// ================= - if let Some(parent) = output_path.parent() { - std::fs::create_dir_all(parent).unwrap(); +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(); - 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() { + if let Some(variants) = tree.variants() { for (i, variant) in variants.iter().enumerate() { let is_last = i == variants.len() - 1; - print_tree_node(variant, "", is_last, &mut file); + write_tree_txt_node(variant, "", is_last, file)?; } } Ok(()) } -fn print_tree_node(tree: &DebugMessageTree, prefix: &str, is_last: bool, file: &mut std::fs::File) { - // Print the current node +fn write_tree_txt_node(tree: &DebugMessageTree, prefix: &str, is_last: bool, file: &mut std::fs::File) -> std::io::Result<()> { let (branch, child_prefix) = if tree.message_handler_data_fields().is_some() || tree.message_handler_fields().is_some() { ("ā”œā”€ā”€ ", format!("{prefix}│ ")) } 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() { - file.write_all(format!("{}{}{}\n", prefix, branch, tree.name()).as_bytes()).unwrap(); + file.write_all(format!("{}{}{}\n", prefix, branch, tree.name()).as_bytes())?; } else { - file.write_all(format!("{}{}{} `{}#L{}`\n", prefix, branch, tree.name(), tree.path(), tree.line_number()).as_bytes()) - .unwrap(); + file.write_all(format!("{}{}{} `{}#L{}`\n", prefix, branch, tree.name(), tree.path(), tree.line_number()).as_bytes())?; } - // Print children if any if let Some(variants) = tree.variants() { let len = variants.len(); for (i, variant) in variants.iter().enumerate() { 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() { let len = fields.len(); for (i, field) in fields.iter().enumerate() { let is_last_field = i == len - 1; let branch = if is_last_field { "└── " } else { "ā”œā”€ā”€ " }; - - file.write_all(format!("{child_prefix}{branch}{field}\n").as_bytes()).unwrap(); + file.write_all(format!("{child_prefix}{branch}{field}\n").as_bytes())?; } } - // Print handler field if any if let Some(data) = tree.message_handler_fields() { let len = data.fields().len(); 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} ")) }; - const FRONTEND_MESSAGE_STR: &str = "FrontendMessage"; if data.name().is_empty() && tree.name() != FRONTEND_MESSAGE_STR { panic!("{}'s MessageHandler is missing #[message_handler_data]", tree.name()); } else if tree.name() != FRONTEND_MESSAGE_STR { - file.write_all(format!("{}{}{} `{}#L{}`\n", prefix, branch, data.name(), data.path(), data.line_number()).as_bytes()) - .unwrap(); + file.write_all(format!("{}{}{} `{}#L{}`\n", prefix, branch, data.name(), data.path(), data.line_number()).as_bytes())?; for (i, field) in data.fields().iter().enumerate() { let is_last_field = i == len - 1; let branch = if is_last_field { "└── " } else { "ā”œā”€ā”€ " }; - - file.write_all(format!("{}{}{}\n", child_prefix, branch, field.0).as_bytes()).unwrap(); + file.write_all(format!("{}{}{}\n", child_prefix, branch, field.0).as_bytes())?; } } } - // Print data field if any if let Some(data) = tree.message_handler_data_fields() { let len = data.fields().len(); 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 { - file.write_all(format!("{}{}{} `{}#L{}`\n", prefix, "└── ", data.name(), data.path(), data.line_number()).as_bytes()) - .unwrap(); + file.write_all(format!("{}{}{} `{}#L{}`\n", prefix, "└── ", data.name(), data.path(), data.line_number()).as_bytes())?; } for (i, field) in data.fields().iter().enumerate() { let is_last_field = i == len - 1; let branch = if is_last_field { "└── " } else { "ā”œā”€ā”€ " }; 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('&', "&").replace('<', "<").replace('>', ">") +} + +fn github_link(path: &str, line: usize) -> String { + let path = path.replace('\\', "/"); + let filename = path.rsplit('/').next().unwrap_or(&path); + format!(r#"{filename}:{line}"#) +} + +fn naming_convention_warning(name: &str) -> &'static str { + // Strip generic parameters for the check (e.g. `Foo` -> `Foo`) + let base_name = name.split('<').next().unwrap_or(name); + if NAMING_SUFFIXES.iter().any(|suffix| base_name.ends_with(suffix)) { + "" + } else { + r#"(violates naming convention — should end with 'Message', 'MessageHandler', or 'MessageContext')"# + } +} + +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("
    \n"); + out.push_str(&format!(r#"
  • {escaped_name}{link}"#)); + + if let Some(variants) = tree.variants() { + out.push_str(r#"
    "#); + write_tree_html_children(variants, out); + out.push_str("
    "); + } + + out.push_str("
  • \n
\n"); +} + +fn write_tree_html_children(variants: &[DebugMessageTree], out: &mut String) { + out.push_str("
    \n"); + for variant in variants { + write_tree_html_node(variant, out); + } + out.push_str("
\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), + DataFields(String, String, usize, Vec), + } + + // Collect all child entries for this node + let mut children: Vec = 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#"
  • {escaped_name}{link}{warning}"#)); + out.push_str(r#"
      "#); + 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("
    \n
  • \n"); + } else { + out.push_str(&format!(r#"
  • {escaped_name}{link}{warning}
  • "#)); + 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#"
  • {name}: {ty}
  • "#)); + } else { + let escaped = escape_html(field); + out.push_str(&format!(r#"
  • {escaped}
  • "#)); + } + 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#"
  • {escaped_name}{link}{warning}
  • "#)); + } else { + out.push_str(&format!(r#"
  • {escaped_name}{link}{warning}"#)); + out.push_str(r#"
      "#); + out.push('\n'); + for field in fields { + write_field_html(field, out); } + out.push_str("
    \n
  • \n"); } } diff --git a/website/.build-scripts/generate-crate-hierarchy.ts b/website/.build-scripts/generate-crate-hierarchy.ts deleted file mode 100644 index aadc853e07..0000000000 --- a/website/.build-scripts/generate-crate-hierarchy.ts +++ /dev/null @@ -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 "); - 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/.build-scripts/generate-editor-structure.ts b/website/.build-scripts/generate-editor-structure.ts deleted file mode 100644 index 221a5b9292..0000000000 --- a/website/.build-scripts/generate-editor-structure.ts +++ /dev/null @@ -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 = "
      \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 ? `${path.basename(node.link.split("#L").join(":"))}` : ""; - 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 - ? "(violates naming convention — should end with 'Message', 'MessageHandler', or 'MessageContext')" - : ""; - - if (hasDirectChildren) { - html += `
    • ${escapedText}${linkHtml}${violatesNamingConvention}`; - const childResult = buildHtmlList(nodes, i + 1, node.level + 1); - html += `
      ${childResult.html}
    • \n`; - i = childResult.nextIndex; - } else if (role === "field") { - html += `
    • ${escapedText[0]}: ${escapedText[1]}${linkHtml}
    • \n`; - i++; - } else { - html += `
    • ${escapedText[0]}${linkHtml}${violatesNamingConvention}
    • \n`; - i++; - } - } - - html += "
    \n"; - return { html, nextIndex: i }; -} - -function escapeHtml(text: string) { - return text.replace(//g, ">"); -} - -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 "); - 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); -} diff --git a/website/content/volunteer/guide/codebase-overview/editor-structure.md b/website/content/volunteer/guide/codebase-overview/editor-structure.md index e7bb07aeb9..9177b047b6 100644 --- a/website/content/volunteer/guide/codebase-overview/editor-structure.md +++ b/website/content/volunteer/guide/codebase-overview/editor-structure.md @@ -22,7 +22,7 @@ The dispatcher lives at the root of the editor hierarchy and acts as the owner o 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 plain text file.
    diff --git a/website/package-lock.json b/website/package-lock.json index 59a81e65ec..c038887f85 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -16,7 +16,6 @@ "@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", @@ -1233,13 +1232,6 @@ "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 d06c6a1752..7423113691 100644 --- a/website/package.json +++ b/website/package.json @@ -12,8 +12,6 @@ "type": "module", "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" }, @@ -22,7 +20,6 @@ "@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", diff --git a/website/templates/macros/replacements.html b/website/templates/macros/replacements.html index e201d41460..bace183903 100644 --- a/website/templates/macros/replacements.html +++ b/website/templates/macros/replacements.html @@ -40,25 +40,21 @@

    {{ article.title } {% endmacro text_balancer %} {% 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 = "
    THIS CONTENT IS FILLED IN WHEN CI BUILDS THE WEBSITE.
     
     TO TEST IT LOCALLY, FROM THE ROOT OF THE PROJECT, RUN:
     
    -cargo run -p editor-message-tree -- website/generated/hierarchical_message_system_tree.txt
    -cd website
    -npm run generate-editor-structure
    " -%} +cargo run -p editor-message-tree -- website/generated" -%} {{ content | default(value = fallback) | safe }} {% endmacro hierarchical_message_system_tree %} {% 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 = "
    THIS CONTENT IS FILLED IN WHEN CI BUILDS THE WEBSITE.
     
     TO TEST IT LOCALLY, FROM THE ROOT OF THE PROJECT, RUN:
     
    -cargo run -p crate-hierarchy-viz -- website/generated/crate_hierarchy.dot
    -cd website
    -npm run generate-crate-hierarchy
    " -%} +cargo run -p crate-hierarchy-viz -- website/generated" -%} {{ content | default(value = fallback) | safe }} {% endmacro crate_hierarchy %}