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
12 changed files with 345 additions and 268 deletions

View File

@@ -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<String, HashSet<
}
fn main() -> 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 <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");
// 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<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 {
let mut out = String::new();
out.push_str("digraph CrateHierarchy {\n");

View File

@@ -3,28 +3,50 @@ use editor::utility_types::DebugMessageTree;
use std::io::Write;
use std::path::PathBuf;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let output_path = std::env::args_os().nth(1).map(PathBuf::from).ok_or("Usage: editor-message-tree <output-file>")?;
const FRONTEND_MESSAGE_STR: &str = "FrontendMessage";
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent).unwrap();
fn main() -> Result<(), Box<dyn std::error::Error>> {
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();
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('&', "&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");
}
}