From 5ada0da984e3a2c992348e23f20a17bb68b41fe7 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 9 Jan 2026 04:11:53 -0800 Subject: [PATCH 1/9] Generate the MVP node catalog in the manual (with some placeholders) --- Cargo.lock | 9 +- Cargo.toml | 1 + .../document_node_derive.rs | 2 +- node-graph/graphene-cli/Cargo.toml | 2 + node-graph/graphene-cli/src/export.rs | 15 +- node-graph/graphene-cli/src/main.rs | 207 +++++++++++++++++- .../libraries/core-types/src/registry.rs | 2 +- node-graph/node-macro/Cargo.toml | 2 +- node-graph/node-macro/src/codegen.rs | 5 +- node-graph/node-macro/src/parsing.rs | 86 ++++++-- website/.gitignore | 1 + .../content/learn/interface/document-panel.md | 20 +- .../learn/node-catalog-example/_index.md | 22 ++ .../_vector-style/_index.md | 24 ++ .../_vector-style/assign-colors.md | 37 ++++ .../_vector-style/fill.md | 31 +++ .../_vector-style/stroke.md | 39 ++++ website/sass/template/book.scss | 34 +-- website/templates/base.html | 102 +++++---- website/templates/book.html | 147 +++++++------ website/templates/macros/book-outline.html | 57 +++++ 21 files changed, 661 insertions(+), 184 deletions(-) create mode 100644 website/content/learn/node-catalog-example/_index.md create mode 100644 website/content/learn/node-catalog-example/_vector-style/_index.md create mode 100644 website/content/learn/node-catalog-example/_vector-style/assign-colors.md create mode 100644 website/content/learn/node-catalog-example/_vector-style/fill.md create mode 100644 website/content/learn/node-catalog-example/_vector-style/stroke.md create mode 100644 website/templates/macros/book-outline.html diff --git a/Cargo.lock b/Cargo.lock index ce4bcaf338..3681796f1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2236,11 +2236,13 @@ version = "0.1.0" dependencies = [ "chrono", "clap", + "convert_case 0.8.0", "fern", "futures", "graph-craft", "graphene-std", "image", + "indoc", "interpreted-executor", "log", "preprocessor", @@ -2978,9 +2980,12 @@ dependencies = [ [[package]] name = "indoc" -version = "2.0.6" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] [[package]] name = "inotify" diff --git a/Cargo.toml b/Cargo.toml index 1a7fbd44bb..6154a55173 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -137,6 +137,7 @@ log = "0.4" bitflags = { version = "2.4", features = ["serde"] } ctor = "0.2" convert_case = "0.8" +indoc = "2.0.5" derivative = "2.2" thiserror = "2" anyhow = "1.0" diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions/document_node_derive.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions/document_node_derive.rs index 90911997dd..72a4558a67 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions/document_node_derive.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions/document_node_derive.rs @@ -75,7 +75,7 @@ pub(super) fn post_process_nodes(custom: Vec) -> HashMap ..Default::default() }, }, - category: category.unwrap_or("UNCATEGORIZED"), + category, description: Cow::Borrowed(description), properties: *properties, }, diff --git a/node-graph/graphene-cli/Cargo.toml b/node-graph/graphene-cli/Cargo.toml index b5dcfd71fd..5d4318bbb3 100644 --- a/node-graph/graphene-cli/Cargo.toml +++ b/node-graph/graphene-cli/Cargo.toml @@ -28,6 +28,8 @@ tokio = { workspace = true, features = ["rt-multi-thread"] } clap = { workspace = true, features = ["cargo", "derive"] } image = { workspace = true } wgpu-executor = { workspace = true, optional = true } +convert_case = { workspace = true } +indoc = { workspace = true } [package.metadata.cargo-shear] ignored = ["wgpu-executor"] diff --git a/node-graph/graphene-cli/src/export.rs b/node-graph/graphene-cli/src/export.rs index daf8172386..d54015ac28 100644 --- a/node-graph/graphene-cli/src/export.rs +++ b/node-graph/graphene-cli/src/export.rs @@ -21,7 +21,7 @@ pub fn detect_file_type(path: &Path) -> Result { Some("svg") => Ok(FileType::Svg), Some("png") => Ok(FileType::Png), Some("jpg" | "jpeg") => Ok(FileType::Jpg), - _ => Err(format!("Unsupported file extension. Supported formats: .svg, .png, .jpg")), + _ => Err("Unsupported file extension. Supported formats: .svg, .png, .jpg".to_string()), } } @@ -31,8 +31,7 @@ pub async fn export_document( output_path: PathBuf, file_type: FileType, scale: f64, - width: Option, - height: Option, + (width, height): (Option, Option), transparent: bool, ) -> Result<(), Box> { // Determine export format based on file type @@ -42,10 +41,12 @@ pub async fn export_document( }; // Create render config with export settings - let mut render_config = RenderConfig::default(); - render_config.export_format = export_format; - render_config.for_export = true; - render_config.scale = scale; + let mut render_config = RenderConfig { + scale, + export_format, + for_export: true, + ..Default::default() + }; // Set viewport dimensions if specified if let (Some(w), Some(h)) = (width, height) { diff --git a/node-graph/graphene-cli/src/main.rs b/node-graph/graphene-cli/src/main.rs index 6b9c4f2d0e..d1da0fbf8d 100644 --- a/node-graph/graphene-cli/src/main.rs +++ b/node-graph/graphene-cli/src/main.rs @@ -1,6 +1,7 @@ mod export; use clap::{Args, Parser, Subcommand}; +use convert_case::{Case, Casing}; use fern::colors::{Color, ColoredLevelConfig}; use futures::executor::block_on; use graph_craft::document::*; @@ -11,9 +12,11 @@ use graph_craft::wasm_application_io::EditorPreferences; use graphene_std::application_io::{ApplicationIo, NodeGraphUpdateMessage, NodeGraphUpdateSender}; use graphene_std::text::FontCache; use graphene_std::wasm_application_io::{WasmApplicationIo, WasmEditorApi}; +use indoc::formatdoc; use interpreted_executor::dynamic_executor::DynamicExecutor; use interpreted_executor::util::wrap_network_in_scope; use std::error::Error; +use std::io::Write; use std::path::PathBuf; use std::sync::Arc; @@ -76,6 +79,7 @@ enum Command { transparent: bool, }, ListNodeIdentifiers, + BuildNodeDocs, } #[derive(Debug, Args)] @@ -97,13 +101,208 @@ async fn main() -> Result<(), Box> { Command::Compile { ref document, .. } => document, Command::Export { ref document, .. } => document, Command::ListNodeIdentifiers => { - let mut ids: Vec<_> = graphene_std::registry::NODE_METADATA.lock().unwrap().keys().cloned().collect(); - ids.sort_by_key(|x| x.as_str().to_string()); - for id in ids { + let mut nodes: Vec<_> = graphene_std::registry::NODE_METADATA.lock().unwrap().keys().cloned().collect(); + nodes.sort_by_key(|x| x.as_str().to_string()); + for id in nodes { println!("{}", id.as_str()); } return Ok(()); } + Command::BuildNodeDocs => { + // TODO: Also obtain document nodes, not only proto nodes + let nodes: Vec<_> = graphene_std::registry::NODE_METADATA.lock().unwrap().values().cloned().collect(); + + // Group nodes by category + use std::collections::HashMap; + let mut map: HashMap> = HashMap::new(); + for node in nodes { + map.entry(node.category.to_string()).or_default().push(node); + } + + // Sort the categories + let mut categories: Vec<_> = map.keys().cloned().collect(); + categories.sort(); + + let allowed_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~[]@!$&'()*+,;="; + let omit_disallowed_chars = |s: &str| s.chars().filter(|c| allowed_chars.contains(*c)).collect::(); + + let node_catalog_path = "../../website/content/learn/node-catalog"; + + let page_path = format!("{node_catalog_path}/_index.md"); + let mut index_file = std::fs::File::create(&page_path).expect("Failed to create index file"); + let content = formatdoc!( + " + +++ + title = \"Node catalog\" + template = \"book.html\" + page_template = \"book.html\" + + [extra] + order = 3 + +++ + + + + The node catalog documents all of the nodes available in Graphite's node graph system, organized by category. + + ## Node categories + + | Category | Details | + |:-|:-| + " + ); + index_file.write_all(content.as_bytes()).expect("Failed to write to index file"); + + let content = categories + .iter() + .filter(|c| !c.is_empty()) + .map(|category| { + let category_path_part = omit_disallowed_chars(&category.to_case(Case::Kebab)); + let details = format!("This is the {category} category of nodes."); + format!("| [{category}](./{category_path_part}) | {details} |") + }) + .collect::>() + .join("\n"); + index_file.write_all(content.as_bytes()).expect("Failed to write to index file"); + + // For each category, sort nodes by display_name and print + for (index, category) in categories.iter().filter(|c| !c.is_empty()).enumerate() { + let mut items = map.remove(category).unwrap(); + items.sort_by_key(|x| x.display_name.to_string()); + + let category_path_part = omit_disallowed_chars(&category.to_case(Case::Kebab)); + let category_path = format!("{node_catalog_path}/{category_path_part}"); + + // Create directory for category path + std::fs::create_dir_all(&category_path).expect("Failed to create category directory"); + + // Create _index.md file for category + let page_path = format!("{category_path}/_index.md"); + let mut index_file = std::fs::File::create(&page_path).expect("Failed to create index file"); + + // Write the frontmatter and initial content + let order = index + 1; + let content = formatdoc!( + " + +++ + title = \"{category}\" + template = \"book.html\" + page_template = \"book.html\" + + [extra] + order = {order} + +++ + + + + This is the {category} category of nodes. + + ## Nodes + + | Node | Details | Possible Types | + |:-|:-|:-| + " + ); + index_file.write_all(content.as_bytes()).expect("Failed to write to index file"); + + let content = items + .iter() + .map(|id| { + let name_url_part = omit_disallowed_chars(&id.display_name.to_case(Case::Kebab)); + let details = id.description.trim().split('\n').map(|line| format!("

{}

", line.trim())).collect::>().join(""); + let mut possible_types = id + .fields + .iter() + .map(|field| format!("`{} → Unknown`", if let Some(t) = &field.default_type { format!("{t:?}") } else { "()".to_string() })) + .collect::>(); + if possible_types.is_empty() { + possible_types.push("`Unknown → Unknown`".to_string()); + } + possible_types.sort(); + possible_types.dedup(); + let possible_types = possible_types.join("
"); + format!("| [{name}]({name_url_part}) | {details} | {possible_types} |", name = id.display_name) + }) + .collect::>() + .join("\n"); + index_file.write_all(content.as_bytes()).expect("Failed to write to index file"); + + for (index, id) in items.iter().enumerate() { + let name = id.display_name; + let description = id.description.trim(); + let name_url_part = omit_disallowed_chars(&id.display_name.to_case(Case::Kebab)); + let page_path = format!("{category_path}/{name_url_part}.md"); + + let order = index + 1; + let content = formatdoc!( + " + +++ + title = \"{name}\" + + [extra] + order = {order} + +++ + + + + {description} + + ### Inputs + + | Parameter | Details | Possible Types | + |:-|:-|:-| + " + ); + let mut page_file = std::fs::File::create(&page_path).expect("Failed to create node page file"); + page_file.write_all(content.as_bytes()).expect("Failed to write to node page file"); + + let content = id + .fields + .iter() + .map(|field| { + let parameter = field.name; + let details = field.description.trim().split('\n').map(|line| format!("

{}

", line.trim())).collect::>().join(""); + let mut possible_types = vec![if let Some(t) = &field.default_type { format!("`{t:?}`") } else { "`Unknown`".to_string() }]; + possible_types.sort(); + possible_types.dedup(); + let possible_types = possible_types.join("
"); + format!("| {parameter} | {details} | {possible_types} |") + }) + .collect::>() + .join("\n"); + page_file.write_all(content.as_bytes()).expect("Failed to write to node page file"); + page_file.write_all("\n\n".as_bytes()).expect("Failed to write to node page file"); + + let content = formatdoc!( + " + ### Outputs + + | Product | Details | Possible Types | + |:-|:-|:-| + | Result |

The value produced by the node operation.

Primary Output

| `Unknown` | + + ### Context + + Not context-aware. + " + ); + page_file.write_all(content.as_bytes()).expect("Failed to write to node page file"); + } + } + return Ok(()); + } }; let document_string = std::fs::read_to_string(document_path).expect("Failed to read document"); @@ -164,7 +363,7 @@ async fn main() -> Result<(), Box> { let executor = create_executor(proto_graph)?; // Perform export - export::export_document(&executor, wgpu_executor_ref, output, file_type, scale, width, height, transparent).await?; + export::export_document(&executor, wgpu_executor_ref, output, file_type, scale, (width, height), transparent).await?; } _ => unreachable!("All other commands should be handled before this match statement is run"), } diff --git a/node-graph/libraries/core-types/src/registry.rs b/node-graph/libraries/core-types/src/registry.rs index 3b35ca568d..1db6f1c9c8 100644 --- a/node-graph/libraries/core-types/src/registry.rs +++ b/node-graph/libraries/core-types/src/registry.rs @@ -11,7 +11,7 @@ use std::sync::{LazyLock, Mutex}; #[derive(Clone, Debug)] pub struct NodeMetadata { pub display_name: &'static str, - pub category: Option<&'static str>, + pub category: &'static str, pub fields: Vec, pub description: &'static str, pub properties: Option<&'static str>, diff --git a/node-graph/node-macro/Cargo.toml b/node-graph/node-macro/Cargo.toml index d8f9f5d568..47dd09e896 100644 --- a/node-graph/node-macro/Cargo.toml +++ b/node-graph/node-macro/Cargo.toml @@ -23,8 +23,8 @@ proc-macro2 = { workspace = true } quote = { workspace = true } convert_case = { workspace = true } strum = { workspace = true } +indoc = { workspace = true } -indoc = "2.0.5" proc-macro-crate = "3.1.0" proc-macro-error2 = "2" diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index aebf163ceb..2ec08bebc5 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -28,7 +28,10 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn } = parsed; let core_types = crate_ident.gcore()?; - let category = &attributes.category.as_ref().map(|value| quote!(Some(#value))).unwrap_or(quote!(None)); + let category = attributes + .category + .as_ref() + .expect("The 'category' attribute is required and should be checked during parsing, but was not found during codegen"); let mod_name = format_ident!("_{}_mod", mod_name); let display_name = match &attributes.display_name.as_ref() { diff --git a/node-graph/node-macro/src/parsing.rs b/node-graph/node-macro/src/parsing.rs index ca58b14988..863a58936a 100644 --- a/node-graph/node-macro/src/parsing.rs +++ b/node-graph/node-macro/src/parsing.rs @@ -211,9 +211,13 @@ impl Parse for NodeFnAttributes { // syn::parenthesized!(content in input); let nested = content.call(Punctuated::::parse_terminated)?; - for meta in nested { + for meta in nested.iter() { let name = meta.path().get_ident().ok_or_else(|| Error::new_spanned(meta.path(), "Node macro expects a known Ident, not a path"))?; match name.to_string().as_str() { + // User-facing category in the node catalog. The empty string `category("")` hides the node from the catalog. + // + // Example usage: + // #[node_macro::node(..., category("Math: Arithmetic"), ...)] "category" => { let meta = meta.require_list()?; if category.is_some() { @@ -224,6 +228,11 @@ impl Parse for NodeFnAttributes { .map_err(|_| Error::new_spanned(meta, "Expected a string literal for 'category', e.g., category(\"Value\")"))?; category = Some(lit); } + // Override for the display name in the node catalog in place of the auto-generated name taken from the function name with inferred Title Case formatting. + // Use this if capitalization or formatting needs to be overridden. + // + // Example usage: + // #[node_macro::node(..., name("Request URL"), ...)] "name" => { let meta = meta.require_list()?; if display_name.is_some() { @@ -232,6 +241,12 @@ impl Parse for NodeFnAttributes { let parsed_name: LitStr = meta.parse_args().map_err(|_| Error::new_spanned(meta, "Expected a string for 'name', e.g., name(\"Memoize\")"))?; display_name = Some(parsed_name); } + // Override for the fully qualified path used by Graphene to identify the node implementation. + // If not provided, the path will be inferred from the module path and function name. + // Use this if the node implementation has moved to a different module or crate but a migration to that new path is not desired. + // + // Example usage: + // #[node_macro::node(..., path(core_types::vector), ...)] "path" => { let meta = meta.require_list()?; if path.is_some() { @@ -242,6 +257,13 @@ impl Parse for NodeFnAttributes { .map_err(|_| Error::new_spanned(meta, "Expected a valid path for 'path', e.g., path(crate::MemoizeNode)"))?; path = Some(parsed_path); } + // Indicator that the node should allow generic type arguments but skip the automatic generation of concrete type implementations. + // It allows the type arguments in this node to not include the normally required `#[implementations(...)]` attribute on each generic parameter. + // Instead, concrete implementations must be manually listed in the Node Registry, or where impossible, produced at runtime by the compile server. + // This is used by a few advanced nodes that need to support many types where listing them all would be cumbersome or impossible. + // + // Example usage: + // #[node_macro::node(..., skip_impl, ...)] "skip_impl" => { let path = meta.require_path_only()?; if skip_impl { @@ -249,31 +271,48 @@ impl Parse for NodeFnAttributes { } skip_impl = true; } + // Override UI layout generator function name defined in `node_properties.rs` that returns a custom Properties panel layout for this node. + // This is used to create custom UI for the input parameters of the node in cases where the defaults generated from the type and attributes are insufficient. + // + // Example usage: + // #[node_macro::node(..., properties("channel_mixer_properties"), ...)] "properties" => { let meta = meta.require_list()?; if properties_string.is_some() { - return Err(Error::new_spanned(path, "Multiple 'properties_string' attributes are not allowed")); + return Err(Error::new_spanned(path, "Multiple 'properties' attributes are not allowed")); } let parsed_properties_string: LitStr = meta .parse_args() - .map_err(|_| Error::new_spanned(meta, "Expected a string for 'properties', e.g., name(\"channel_mixer_properties\")"))?; + .map_err(|_| Error::new_spanned(meta, "Expected a string for 'properties', e.g., properties(\"channel_mixer_properties\")"))?; properties_string = Some(parsed_properties_string); } + // Conditional compilation tokens to gate when this node is included in the build. + // + // Example usage: + // #[node_macro::node(..., cfg(feature = "std"), ...)] "cfg" => { if cfg.is_some() { - return Err(Error::new_spanned(path, "Multiple 'feature' attributes are not allowed")); + return Err(Error::new_spanned(path, "Multiple 'cfg' attributes are not allowed")); } let meta = meta.require_list()?; cfg = Some(meta.tokens.clone()); } + // Reference to a specific shader definition struct that is used to run the logic of this node on the GPU. + // + // Example usage: + // #[node_macro::node(..., shader_node(PerPixelAdjust), ...)] "shader_node" => { if shader_node.is_some() { - return Err(Error::new_spanned(path, "Multiple 'feature' attributes are not allowed")); + return Err(Error::new_spanned(path, "Multiple 'shader_node' attributes are not allowed")); } let meta = meta.require_list()?; shader_node = Some(syn::parse2(meta.tokens.to_token_stream())?); } + // Function name for custom serialization of this node's data. This is only used by the Monitor node. + // + // Example usage: + // #[node_macro::node(..., serialize(my_module::custom_serialize), ...)] "serialize" => { let meta = meta.require_list()?; if serialize.is_some() { @@ -290,10 +329,9 @@ impl Parse for NodeFnAttributes { indoc!( r#" Unsupported attribute in `node`. - Supported attributes are 'category', 'path', 'name', 'skip_impl', 'cfg', 'properties', 'serialize', and 'shader_node'. - + Supported attributes are 'category', 'name', 'path', 'skip_impl', 'properties', 'cfg', 'shader_node', and 'serialize'. Example usage: - #[node_macro::node(category("Value"), name("Test Node"))] + #[node_macro::node(..., name("Test Node"), ...)] "# ), )); @@ -301,6 +339,19 @@ impl Parse for NodeFnAttributes { } } + if category.is_none() { + return Err(Error::new_spanned( + nested, + indoc!( + r#" + The attribute 'category' is required. + Example usage: + #[node_macro::node(..., category("Value"), ...)] + "#, + ), + )); + } + Ok(NodeFnAttributes { category, display_name, @@ -315,7 +366,7 @@ impl Parse for NodeFnAttributes { } fn parse_node_fn(attr: TokenStream2, item: TokenStream2) -> syn::Result { - let attributes = syn::parse2::(attr.clone()).map_err(|e| Error::new(e.span(), format!("Failed to parse node_fn attributes: {e}")))?; + let attributes = syn::parse2::(attr.clone()).map_err(|e| Error::new(e.span(), format!("Failed to parse node_fn attributes:\n{e}")))?; let input_fn = syn::parse2::(item.clone()).map_err(|e| Error::new(e.span(), format!("Failed to parse function: {e}. Make sure it's a valid Rust function.")))?; let vis = input_fn.vis; @@ -482,7 +533,16 @@ fn parse_node_implementations(attr: &Attribute, name: &Ident) -> syn:: fn parse_field(pat_ident: PatIdent, ty: Type, attrs: &[Attribute]) -> syn::Result { let ident = &pat_ident.ident; - // Check if this is a data field (struct field, not a parameter) + // Checks for the #[data] attribute, indicating that this is a data field rather than an input parameter to the node. + // Data fields act as internal state, using interior mutability to cache data between node evaluations. + // + // Normally, an input parameter is a construction argument to the node that is stored as a field on the node struct. + // Specifically, its struct field stores the connected upstream node (an evaluatable lambda that returns data of the connection wire's type). + // By comparison, a data field is also stored as a field on the node struct, allowing it to persist state between evaluations. + // But it acts as internal state only, not exposed as a parameter in the UI or able to be wired to another node. + // + // Nodes implemented using a data field must ensure the persistent state is used in a manner that respects the invariant of idempotence, + // meaning the node's output is always deterministic whether or not the internal state is present. let is_data_field = extract_attribute(attrs, "data").is_some(); let default_value = extract_attribute(attrs, "default") @@ -723,10 +783,10 @@ fn extract_attribute<'a>(attrs: &'a [Attribute], name: &str) -> Option<&'a Attri // Modify the new_node_fn function to use the code generation pub fn new_node_fn(attr: TokenStream2, item: TokenStream2) -> syn::Result { let crate_ident = CrateIdent::default(); - let mut parsed_node = parse_node_fn(attr, item.clone()).map_err(|e| Error::new(e.span(), format!("Failed to parse node function: {e}")))?; + let mut parsed_node = parse_node_fn(attr, item.clone()).map_err(|e| Error::new(e.span(), format!("Failed to parse node function:\n{e}")))?; parsed_node.replace_impl_trait_in_input(); - crate::validation::validate_node_fn(&parsed_node).map_err(|e| Error::new(e.span(), format!("Validation Error: {e}")))?; - generate_node_code(&crate_ident, &parsed_node).map_err(|e| Error::new(e.span(), format!("Failed to generate node code: {e}"))) + crate::validation::validate_node_fn(&parsed_node).map_err(|e| Error::new(e.span(), format!("Validation error:\n{e}")))?; + generate_node_code(&crate_ident, &parsed_node).map_err(|e| Error::new(e.span(), format!("Failed to generate node code:\n{e}"))) } impl ParsedNodeFn { diff --git a/website/.gitignore b/website/.gitignore index ec007841ba..5d0fa15946 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -2,3 +2,4 @@ node_modules/ public/ static/ !static/js/ +content/learn/node-catalog diff --git a/website/content/learn/interface/document-panel.md b/website/content/learn/interface/document-panel.md index 4287358478..48517ef64c 100644 --- a/website/content/learn/interface/document-panel.md +++ b/website/content/learn/interface/document-panel.md @@ -54,16 +54,16 @@ The right side of the control bar has controls related to the active document an | | | |-|-| -| Overlays |

When checked (default), overlays are shown. When unchecked, they are hidden. Overlays are the temporary contextual visualizations (like bounding boxes and vector manipulators) that are usually blue and appear atop the viewport when using tools.

| -| Snapping |

When checked (default), drawing and dragging shapes and vector points means they will snap to other areas of geometric interest like corners or anchor points. When unchecked, the selection moves freely.

Fine-grained options are available by clicking the overflow button to access its options popover menu. Each option has a tooltip explaining what it does by hovering the cursor over it.

Snapping options popover menu

Snapping options relating to **Bounding Boxes**:

  • **Align with Edges**: Snaps to horizontal/vertical alignment with the edges of any layer's bounding box.
  • **Corner Points**: Snaps to the four corners of any layer's bounding box.
  • **Center Points**: Snaps to the center point of any layer's bounding box.
  • **Edge Midpoints**: Snaps to any of the four points at the middle of the edges of any layer's bounding box.
  • **Distribute Evenly**: Snaps to a consistent distance offset established by the bounding boxes of nearby layers (due to a bug, **Corner Points** and **Center Points** must be enabled).

Snapping options relating to **Paths**:

  • **Align with Anchor Points**: Snaps to horizontal/vertical alignment with the anchor points of any vector path.
  • **Anchor Points**: Snaps to the anchor point of any vector path.
  • **Line Midpoints**: Snaps to the point at the middle of any straight line segment of a vector path.
  • **Path Intersection Points**: Snaps to any points where vector paths intersect.
  • **Along Paths**: Snaps along the length of any vector path.
  • **Normal to Paths**: Snaps a line to a point perpendicular to a vector path (due to a bug, **Intersections of Paths** must be enabled).
  • **Tangent to Paths**: Snaps a line to a point tangent to a vector path (due to a bug, **Intersections of Paths** must be enabled).

| -| Grid |

When checked (off by default), grid lines are shown and snapping to them becomes active. The initial grid scale is 1 document unit, helping you draw pixel-perfect artwork.

  • **Type** sets whether the grid pattern is made of squares or triangles.

    **Rectangular** is a pattern of horizontal and vertical lines:

    Snapping options popover menu

    It has one option unique to this mode:

    • **Spacing** is the width and height of the rectangle grid cells.

    **Isometric** is a pattern of triangles:

    Snapping options popover menu

    It has two options unique to this mode:

    • **Y Spacing** is the height between vertical repetitions of the grid.
    • **Angles** is the slant of the upward and downward sloped grid lines.
  • **Display** gives control over the appearance of the grid. The **Display as dotted grid** checkbox (off by default) replaces the solid lines with dots at their intersection points.
  • **Origin** is the position in the canvas where the repeating grid pattern begins from. If you need an offset for the grid where an intersection occurs at a specific location, set those coordinates.
| -| Render Mode |

**Normal** (default): The artwork is rendered normally.

**Outline**: The artwork is rendered as a wireframe.

**Pixel Preview**: **Not implemented yet.** The artwork is rendered as it would appear when exported as a bitmap image at 100% scale regardless of the viewport zoom level.

**SVG Preview**: **Not implemented yet.** The artwork is rendered as it would appear when exported as an SVG image.

| -| Zoom In |

Zooms the viewport in to the next whole increment.

| -| Zoom Out |

Zooms the viewport out to the next whole increment.

| -| Reset Tilt and Zoom to 100% |

Resets the viewport tilt to 0°. Resets the viewport zoom to 100% which matches the canvas and viewport pixel scale 1:1.

| -| Viewport Zoom |

Indicates the current zoom level of the viewport and allows precise values to be chosen.

| -| Viewport Tilt |

Hidden except when the viewport is tilted (use the *View* > *Tilt* menu action). Indicates the current tilt angle of the viewport and allows precise values to be chosen.

| -| Node Graph |

Toggles the visibility of the overlaid node graph.

| +| **Overlays** |

When checked (default), overlays are shown. When unchecked, they are hidden. Overlays are the temporary contextual visualizations (like bounding boxes and vector manipulators) that are usually blue and appear atop the viewport when using tools.

| +| **Snapping** |

When checked (default), drawing and dragging shapes and vector points means they will snap to other areas of geometric interest like corners or anchor points. When unchecked, the selection moves freely.

Fine-grained options are available by clicking the overflow button to access its options popover menu. Each option has a tooltip explaining what it does by hovering the cursor over it.

Snapping options popover menu

Snapping options relating to **Bounding Boxes**:

  • **Align with Edges**: Snaps to horizontal/vertical alignment with the edges of any layer's bounding box.
  • **Corner Points**: Snaps to the four corners of any layer's bounding box.
  • **Center Points**: Snaps to the center point of any layer's bounding box.
  • **Edge Midpoints**: Snaps to any of the four points at the middle of the edges of any layer's bounding box.
  • **Distribute Evenly**: Snaps to a consistent distance offset established by the bounding boxes of nearby layers (due to a bug, **Corner Points** and **Center Points** must be enabled).

Snapping options relating to **Paths**:

  • **Align with Anchor Points**: Snaps to horizontal/vertical alignment with the anchor points of any vector path.
  • **Anchor Points**: Snaps to the anchor point of any vector path.
  • **Line Midpoints**: Snaps to the point at the middle of any straight line segment of a vector path.
  • **Path Intersection Points**: Snaps to any points where vector paths intersect.
  • **Along Paths**: Snaps along the length of any vector path.
  • **Normal to Paths**: Snaps a line to a point perpendicular to a vector path (due to a bug, **Intersections of Paths** must be enabled).
  • **Tangent to Paths**: Snaps a line to a point tangent to a vector path (due to a bug, **Intersections of Paths** must be enabled).

| +| **Grid** |

When checked (off by default), grid lines are shown and snapping to them becomes active. The initial grid scale is 1 document unit, helping you draw pixel-perfect artwork.

  • **Type** sets whether the grid pattern is made of squares or triangles.

    **Rectangular** is a pattern of horizontal and vertical lines:

    Snapping options popover menu

    It has one option unique to this mode:

    • **Spacing** is the width and height of the rectangle grid cells.

    **Isometric** is a pattern of triangles:

    Snapping options popover menu

    It has two options unique to this mode:

    • **Y Spacing** is the height between vertical repetitions of the grid.
    • **Angles** is the slant of the upward and downward sloped grid lines.
  • **Display** gives control over the appearance of the grid. The **Display as dotted grid** checkbox (off by default) replaces the solid lines with dots at their intersection points.
  • **Origin** is the position in the canvas where the repeating grid pattern begins from. If you need an offset for the grid where an intersection occurs at a specific location, set those coordinates.
| +| **Render Mode** |

**Normal** (default): The artwork is rendered normally.

**Outline**: The artwork is rendered as a wireframe.

**Pixel Preview**: **Not implemented yet.** The artwork is rendered as it would appear when exported as a bitmap image at 100% scale regardless of the viewport zoom level.

**SVG Preview**: **Not implemented yet.** The artwork is rendered as it would appear when exported as an SVG image.

| +| **Zoom In** |

Zooms the viewport in to the next whole increment.

| +| **Zoom Out** |

Zooms the viewport out to the next whole increment.

| +| **Reset Tilt and Zoom to 100%** |

Resets the viewport tilt to 0°. Resets the viewport zoom to 100% which matches the canvas and viewport pixel scale 1:1.

| +| **Viewport Zoom** |

Indicates the current zoom level of the viewport and allows precise values to be chosen.

| +| **Viewport Tilt** |

Hidden except when the viewport is tilted (use the *View* > *Tilt* menu action). Indicates the current tilt angle of the viewport and allows precise values to be chosen.

| +| **Node Graph** |

Toggles the visibility of the overlaid node graph.

| ## Tool shelf diff --git a/website/content/learn/node-catalog-example/_index.md b/website/content/learn/node-catalog-example/_index.md new file mode 100644 index 0000000000..f4dc0e5bbc --- /dev/null +++ b/website/content/learn/node-catalog-example/_index.md @@ -0,0 +1,22 @@ ++++ +title = "Node catalog example" +template = "book.html" +page_template = "book.html" + +[extra] +order = 4 ++++ + + + +The node catalog documents all of the nodes available in Graphite's node graph system, organized by category. + +## Node categories + +| Category | Details | +|:-|:-| +| [Vector: Style](./vector-style) |

Nodes in this category apply styling effects to vector graphics, such as controlling stroke (outline) and fill properties.

| diff --git a/website/content/learn/node-catalog-example/_vector-style/_index.md b/website/content/learn/node-catalog-example/_vector-style/_index.md new file mode 100644 index 0000000000..97ba4fd03e --- /dev/null +++ b/website/content/learn/node-catalog-example/_vector-style/_index.md @@ -0,0 +1,24 @@ ++++ +title = "Vector: Style" +template = "book.html" +page_template = "book.html" + +[extra] +order = 1 ++++ + + + +Nodes in this category apply styling effects to vector graphics, such as controlling stroke (outline) and fill properties. + +## Nodes + +| Node | Details | Possible Types | +|:-|:-|:-| +| [Fill](../fill) |

Applies a fill style to the vector content, giving an appearance to the area within the interior of the geometry.

| `Table → Table`
`Table → Table` | +| [Stroke](../stroke) |

Applies a stroke style to the vector content, giving an appearance to the area within the outline of the geometry.

| `Table → Table`
`Table → Table` | +| [Assign Colors](../assign-colors) |

Uniquely sets the fill and/or stroke style of every vector element to individual colors sampled along a chosen gradient.

| `Table → Table`
`Table → Table` | diff --git a/website/content/learn/node-catalog-example/_vector-style/assign-colors.md b/website/content/learn/node-catalog-example/_vector-style/assign-colors.md new file mode 100644 index 0000000000..a0f9a1dea2 --- /dev/null +++ b/website/content/learn/node-catalog-example/_vector-style/assign-colors.md @@ -0,0 +1,37 @@ ++++ +title = "Assign Colors" + +[extra] +order = 3 ++++ + + + +Uniquely sets the fill and/or stroke style of every vector element to individual colors sampled along a chosen gradient. + +### Inputs + +| Parameter | Details | Possible Types | +|:-|:-|:-| +| Content |

The content with vector paths to apply the fill and/or stroke style to.

Primary Input

| `Table`
`Table` | +| Fill |

Whether to style the fill.

Default: `true`

| `bool` | +| Stroke |

Whether to style the stroke.

Default: `false`

| `bool` | +| Gradient |

The range of colors to select from.

Default:  

| `GradientStops` | +| Reverse |

Whether to reverse the gradient.

Default: `false`

| `bool` | +| Randomize |

Whether to randomize the color selection for each element from throughout the gradient.

Default: `false`

| `bool` | +| Seed |

The seed used for randomization.

Seed to determine unique variations on the randomized color selection.

Default: `0`

| `SeedValue` | +| Repeat Every |

The number of elements to span across the gradient before repeating. A 0 value will span the entire gradient once.

Default: `0`

| `u32` | + +### Outputs + +| Product | Details | Possible Types | +|:-|:-|:-| +| Result |

The vector content with the fill and/or stroke styles applied.

Primary Output

| `Table`
`Table` | + +### Context + +Not context-aware. diff --git a/website/content/learn/node-catalog-example/_vector-style/fill.md b/website/content/learn/node-catalog-example/_vector-style/fill.md new file mode 100644 index 0000000000..f46709dee3 --- /dev/null +++ b/website/content/learn/node-catalog-example/_vector-style/fill.md @@ -0,0 +1,31 @@ ++++ +title = "Fill" + +[extra] +order = 1 ++++ + + + +Applies a fill style to the vector content, giving an appearance to the area within the interior of the geometry. + +### Inputs + +| Parameter | Details | Possible Types | +|:-|:-|:-| +| Content |

The content with vector paths to apply the fill style to.

Primary Input

| `Table`
`Table` | +| Fill |

The fill to paint the path with.

Default:  

| `Fill`
`Table`
`Table`
`Gradient` | + +### Outputs + +| Product | Details | Possible Types | +|:-|:-|:-| +| Result |

The vector content with the fill style applied.

Primary Output

| `Table`
`Table` | + +### Context + +Not context-aware. diff --git a/website/content/learn/node-catalog-example/_vector-style/stroke.md b/website/content/learn/node-catalog-example/_vector-style/stroke.md new file mode 100644 index 0000000000..d74fcc2e6b --- /dev/null +++ b/website/content/learn/node-catalog-example/_vector-style/stroke.md @@ -0,0 +1,39 @@ ++++ +title = "Stroke" + +[extra] +order = 2 ++++ + + + +Applies a stroke style to the vector content, giving an appearance to the area within the outline of the geometry. + +### Inputs + +| Parameter | Details | Possible Types | +|:-|:-|:-| +| Content |

The content with vector paths to apply the stroke style to.

Primary Input

| `Table`
`Table` | +| Color |

The stroke color.

Default:  

| `Table` | +| Weight |

The stroke thickness.

Default: `2 px`

| `f64` | +| Align |

The alignment of stroke to the path's centerline or (for closed shapes) the inside or outside of the shape.

Default: `Center`

| `StrokeAlign` | +| Cap |

The shape of the stroke at open endpoints.

Default: `Butt`

| `StrokeCap` | +| Join |

The curvature of the bent stroke at sharp corners.

Default: `Miter`

| `StrokeJoin` | +| Miter Limit |

The threshold for when a miter-joined stroke is converted to a bevel-joined stroke when a sharp angle becomes pointier than this ratio.

Default: `4`

| `f64` | +| Paint Order |

The order to paint the stroke on top of the fill, or the fill on top of the stroke.

Default: `StrokeAbove`

| `PaintOrder` | +| Dash Lengths |

The stroke dash lengths. Each length forms a distance in a pattern where the first length is a dash, the second is a gap, and so on. If the list is an odd length, the pattern repeats with solid-gap roles reversed.

Default: `[]`

| `Vec`
`f64`
`String` | +| Dash Offset |

The phase offset distance from the starting point of the dash pattern.

Default: `0 px`

| `f64` | + +### Outputs + +| Product | Details | Possible Types | +|:-|:-|:-| +| Result |

The vector content with the stroke style applied.

Primary Output

| `Table`
`Table` | + +### Context + +Not context-aware. diff --git a/website/sass/template/book.scss b/website/sass/template/book.scss index c0dd7adc37..15384e548c 100644 --- a/website/sass/template/book.scss +++ b/website/sass/template/book.scss @@ -43,12 +43,12 @@ justify-content: space-between; width: 100%; gap: 20px; - + a { display: flex; align-items: center; gap: 20px; - + svg { fill: var(--color-navy); flex: 0 0 auto; @@ -77,7 +77,7 @@ // Overlaid fold-out menu for chapter selection @media screen and (max-width: 1000px) { gap: 0; - + .chapters { position: sticky; width: 0; @@ -95,7 +95,7 @@ left: 0; } } - + .wrapper-outer { position: absolute; background: white; @@ -198,39 +198,23 @@ ul { margin-top: 0; margin-left: 1em; - - ul { - margin-left: 2em; - - ul { - margin-left: 3em; - - ul { - margin-left: 4em; - - ul { - margin-left: 5em; - } - } - } - } } li { margin-top: 0.5em; - + a { color: var(--color-walnut); - + &:hover { color: var(--color-crimson); } } - + &:not(.title) a { text-decoration: none; } - + &.title, &.chapter { font-weight: 700; @@ -245,7 +229,7 @@ } ul a { - display: block; + display: inline-block; padding-left: 1em; text-indent: -1em; } diff --git a/website/templates/base.html b/website/templates/base.html index 96d06bdc1b..74a9ae3aae 100644 --- a/website/templates/base.html +++ b/website/templates/base.html @@ -29,7 +29,6 @@ {%- endblock %} {#- ======================================================================== -#} - {#- ON EVERY PAGE OF THE SITE: CSS AND JS TO LOAD EITHER AS A LINK OR INLINE -#} {#- ======================================================================== -#} {%- set global_linked_js = [] -%} @@ -41,6 +40,7 @@ {{ throw(message = "------------------------------------------------------------> FONTS ARE NOT INSTALLED! Before running Zola, execute `npm install` from the `/website` directory.") }} {%- endif -%} + {#- ================================================================================ -#} {#- RETRIEVE FROM TEMPLATES AND PAGES: CSS AND JS TO LOAD EITHER AS A LINK OR INLINE -#} {#- ================================================================================ -#} {%- set linked_css = page.extra.linked_css | default(value = []) | concat(with = linked_css | default(value = [])) -%} @@ -48,6 +48,7 @@ {%- set css = page.extra.css | default(value = []) | concat(with = css | default(value = [])) -%} {%- set js = page.extra.js | default(value = []) | concat(with = js | default(value = [])) -%} + {#- =================================================== -#} {#- COMBINE THE GLOBAL AND TEMPLATE/PAGE RESOURCE LISTS -#} {#- =================================================== -#} {%- set linked_css_list = linked_css | concat(with = global_linked_css) -%} @@ -55,6 +56,7 @@ {%- set css_list = css | concat(with = global_css) -%} {%- set js_list = js | concat(with = global_js) -%} + {#- ================================================================================== -#} {#- CONDITIONALLY MAKE ONLY PROD BUILDS ACTUALLY INLINE THE CSS AND JS FOR CLEANLINESS -#} {#- ================================================================================== -#} {%- if get_env(name = "MODE", default = "dev") != "prod" -%} @@ -64,18 +66,21 @@ {%- set js_list = [] -%} {%- endif -%} + {#- ================ -#} {#- INSERT CSS LINKS -#} {#- ================ -#} {%- for path in linked_css_list %} {%- endfor %} + {#- =============== -#} {#- INSERT JS LINKS -#} {#- =============== -#} {%- for path in linked_js_list %} {%- endfor %} + {#- ====================== -#} {#- INSERT INLINE CSS CODE -#} {#- ====================== -#} {%- if css_list | length > 0 %} @@ -86,6 +91,7 @@ {{ "" | safe }} {%- endif %} + {#- ===================== -#} {#- INSERT INLINE JS CODE -#} {#- ===================== -#} {%- for path in js_list %} @@ -97,51 +103,53 @@ {{- get_env(name = "INDEX_HTML_HEAD_INCLUSION", default = "") | safe }} -
-
- - - - -
-
-
- {%- 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()) -%} - {%- block content -%}{%- endblock -%} - {%- endfilter -%} - {%- endfilter -%} - {%- endfilter -%} -
-
-
- - Copyright © {{ now() | date(format = "%Y") }} Graphite Labs, LLC (an open source community organization) -
-
+
+
+ + + + +
+
+
+{# This is a comment. It exists to prevent the {%- -%} on the lines below from removing the line break between `
` and the `content` block #} +{%- 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()) -%} +{%- block content -%}{%- endblock -%} +{%- 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 `
` #} +
+
+
+ + Copyright © {{ now() | date(format = "%Y") }} Graphite Labs, LLC (an open source community organization) +
+
diff --git a/website/templates/book.html b/website/templates/book.html index c843e9736b..4569cc4621 100644 --- a/website/templates/book.html +++ b/website/templates/book.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% import "macros/book-outline.html" as book_outline %} {%- block head -%}{%- set page = page | default(value = section) -%} {%- set title = page.title -%} @@ -10,29 +11,42 @@ {%- block content -%}{%- set page = page | default(value = section) -%} -{# Search this page-or-section's ancestor tree for a section that identifies itself as a book, and save it to a `book` variable #} -{% for ancestor_path in page.ancestors | concat(with = page.relative_path) %} - {# Get the ancestor section from this ancestor path string #} - {% if ancestor_path is ending_with("/_index.md") %} - {% set potential_book = get_section(path = ancestor_path) %} - {% endif %} - - {# Check if the ancestor section is the root of a book, and if so, set it to a variable accessible outside the loop #} - {% if potential_book.extra.book %} - {% set_global book = get_section(path = potential_book.path ~ "_index.md" | trim_start_matches(pat="/")) %} - {% endif %} -{% endfor %} - -{# Map this book's chapter path strings to an array of sections #} -{% set chapters = [] %} -{% for chapter_path in book.subsections %} - {% set_global chapters = chapters | concat(with = get_section(path = chapter_path)) %} -{% endfor %} -{% set chapters = chapters | sort(attribute = "extra.order") %} - -{# A flat list of all pages in the ToC, initialized to just the book root section but updated when we generate the ToC #} -{% set flat_pages = [book] %} -{% set flat_index_of_this = 0 %} +{#- Search this page-or-section's ancestor tree for a section that identifies itself as a book, and save it to a `book` variable -#} +{%- for ancestor_path in page.ancestors | concat(with = page.relative_path) -%} + {#- Get the ancestor section from this ancestor path string -#} + {%- if ancestor_path is ending_with("/_index.md") -%} + {%- set potential_book = get_section(path = ancestor_path) -%} + {%- endif -%} + + {#- Check if the ancestor section is the root of a book, and if so, set it to a variable accessible outside the loop -#} + {%- if potential_book.extra.book -%} + {%- set_global book = get_section(path = potential_book.path ~ "_index.md" | trim_start_matches(pat = "/")) -%} + {%- endif -%} +{%- endfor -%} + +{#- Map this book's chapter path strings to an array of sections -#} +{%- set chapters = [] -%} +{%- for chapter_path in book.subsections -%} + {%- set_global chapters = chapters | concat(with = get_section(path = chapter_path)) -%} +{%- endfor -%} +{%- set chapters = chapters | sort(attribute = "extra.order") -%} + +{#- A flat list of all pages in the ToC -#} +{%- set flattened_outline = book_outline::flatten_book_outline(section = book) -%} +{%- set flat_pages_list = book.path ~ ",,,,," ~ book.title ~ ";;;;;" ~ flattened_outline | split(pat = ";;;;;") -%} +{%- set flat_index_of_this = 0 -%} +{%- set flat_pages_path = [] -%} +{%- set flat_pages_title = [] -%} +{%- for item_str in flat_pages_list -%} + {%- if item_str | trim | length > 0 -%} + {%- set parts = item_str | split(pat = ",,,,,") -%} + {%- if current_path == parts | first -%} + {%- set_global flat_index_of_this = loop.index0 -%} + {%- endif -%} + {%- set_global flat_pages_path = flat_pages_path | concat(with = parts | first) -%} + {%- set_global flat_pages_title = flat_pages_title | concat(with = parts | last) -%} + {%- endif -%} +{%- endfor -%}
@@ -86,39 +87,41 @@

- {{ page.content | safe }} +{{ page.content | safe }}

- {% if flat_index_of_this >= 1 %} - {% set prev = flat_pages | nth(n = flat_index_of_this - 1) %} - {% endif %} - {% if prev %} - + {%- if flat_index_of_this >= 1 -%} + {%- set prev_path = flat_pages_path | nth(n = flat_index_of_this - 1) -%} + {%- set prev_title = flat_pages_title | nth(n = flat_index_of_this - 1) -%} + {%- endif -%} + {%- if prev_path %} + - {{ prev.title }} + {{ prev_title }} - {% else %} - - {% endif %} - - {% if flat_index_of_this < flat_pages | length - 1 %} - {% set next = flat_pages | nth(n = flat_index_of_this + 1) %} - {% endif %} - {% if next %} - - {{ next.title }} + {%- else -%} + {#- Spacer -#} + {%- endif -%} + + {%- if flat_index_of_this < flat_pages_path | length - 1 -%} + {%- set next_path = flat_pages_path | nth(n = flat_index_of_this + 1) -%} + {%- set next_title = flat_pages_title | nth(n = flat_index_of_this + 1) -%} + {%- endif -%} + {%- if next_path %} + + {{ next_title }} - {% endif %} + {%- endif %}
@@ -132,34 +135,34 @@

    - {% for depth_1 in page.toc %} + {% for depth_1 in page.toc -%}
  • {{ depth_1.title }}
  • - {% for depth_2 in depth_1.children %} + {% for depth_2 in depth_1.children -%}
    • {{ depth_2.title }}
    • - {% for depth_3 in depth_2.children %} + {% for depth_3 in depth_2.children -%}
      • {{ depth_3.title }}
      • - {% for depth_4 in depth_3.children %} + {% for depth_4 in depth_3.children -%}
        • {{ depth_4.title }}
        • - {% for depth_5 in depth_4.children %} + {% for depth_5 in depth_4.children -%} - {% endfor %} + {%- endfor %}
        - {% endfor %} + {%- endfor %}
      - {% endfor %} + {%- endfor %}
    {% endfor %} - {% endfor %} + {%- endfor %}
diff --git a/website/templates/macros/book-outline.html b/website/templates/macros/book-outline.html new file mode 100644 index 0000000000..760af1397f --- /dev/null +++ b/website/templates/macros/book-outline.html @@ -0,0 +1,57 @@ + +{# Recursively render a book's table of contents #} +{%- macro render_book_outline(section, current_path, indents) -%} + {#- Setup -#} + {%- set items = [] -%} + {%- if section.pages -%} + {%- set_global items = items | concat(with = section.pages) -%} + {%- endif -%} + {%- if section.subsections -%} + {%- for subsection_path in section.subsections -%} + {%- set subsection = get_section(path = subsection_path) -%} + {%- set_global items = items | concat(with = subsection) -%} + {%- endfor -%} + {%- endif -%} + {%- set items = items | sort(attribute="extra.order") -%} + {#- End of setup -#} + + {%- set tabs = "" -%} + {%- for i in range(end = indents) -%} + {%- set_global tabs = tabs ~ " " -%} + {%- endfor -%} + + {%- for item in items %} + {{ tabs }}
    + {{ tabs }}
  • + {{ tabs }} {{ item.title }} + {{ tabs }}
  • + {%- if item.pages or item.subsections -%} + {{ self::render_book_outline(section = item, current_path = current_path, indents = indents + 1) }} + {%- endif %} + {{ tabs }}
+ {%- endfor -%} +{%- endmacro render_book_outline -%} + +{# Recursively flatten the book outline to a string for sequential navigation #} +{%- macro flatten_book_outline(section) -%} + {#- Setup -#} + {%- set items = [] -%} + {%- if section.pages -%} + {%- set_global items = items | concat(with = section.pages) -%} + {%- endif -%} + {%- if section.subsections -%} + {%- for subsection_path in section.subsections -%} + {%- set subsection = get_section(path = subsection_path) -%} + {%- set_global items = items | concat(with = subsection) -%} + {%- endfor -%} + {%- endif -%} + {%- set items = items | sort(attribute="extra.order") -%} + {#- End of setup -#} + + {%- for item in items -%} + {{ item.path }},,,,,{{ item.title }};;;;; + {%- if item.pages or item.subsections -%} + {{ self::flatten_book_outline(section = item) }} + {%- endif -%} + {%- endfor -%} +{%- endmacro flatten_book_outline -%} \ No newline at end of file From 79ab69d25c0755dbaac4de5473504f4f4567695d Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 11 Jan 2026 02:23:09 -0800 Subject: [PATCH 2/9] Implement nearly the rest of everything --- node-graph/graphene-cli/src/main.rs | 431 ++++++++++++++---- .../libraries/core-types/src/context.rs | 14 + node-graph/libraries/core-types/src/types.rs | 15 + node-graph/node-macro/src/validation.rs | 4 +- node-graph/preprocessor/src/lib.rs | 4 +- .../learn/node-catalog-example/_index.md | 7 +- .../_vector-style/_index.md | 24 - .../vector-style/_index.md | 19 + .../assign-colors.md | 21 +- .../{_vector-style => vector-style}/fill.md | 19 +- .../{_vector-style => vector-style}/stroke.md | 19 +- .../codebase-overview/editor-structure.md | 4 +- website/sass/base.scss | 4 +- website/sass/layout/reading-material.scss | 15 +- .../editor-structure.scss} | 1 - .../sass/page/user-manual/node-catalog.scss | 3 + .../sass/page/user-manual/node-category.scss | 14 + website/sass/page/user-manual/node.scss | 14 + 18 files changed, 451 insertions(+), 181 deletions(-) delete mode 100644 website/content/learn/node-catalog-example/_vector-style/_index.md create mode 100644 website/content/learn/node-catalog-example/vector-style/_index.md rename website/content/learn/node-catalog-example/{_vector-style => vector-style}/assign-colors.md (76%) rename website/content/learn/node-catalog-example/{_vector-style => vector-style}/fill.md (51%) rename website/content/learn/node-catalog-example/{_vector-style => vector-style}/stroke.md (82%) rename website/sass/page/{developer-guide-editor-structure.scss => contributor-guide/editor-structure.scss} (98%) create mode 100644 website/sass/page/user-manual/node-catalog.scss create mode 100644 website/sass/page/user-manual/node-category.scss create mode 100644 website/sass/page/user-manual/node.scss diff --git a/node-graph/graphene-cli/src/main.rs b/node-graph/graphene-cli/src/main.rs index d1da0fbf8d..707351a482 100644 --- a/node-graph/graphene-cli/src/main.rs +++ b/node-graph/graphene-cli/src/main.rs @@ -4,17 +4,21 @@ use clap::{Args, Parser, Subcommand}; use convert_case::{Case, Casing}; use fern::colors::{Color, ColoredLevelConfig}; use futures::executor::block_on; +use graph_craft::concrete; use graph_craft::document::*; use graph_craft::graphene_compiler::Compiler; -use graph_craft::proto::ProtoNetwork; +use graph_craft::proto::{NodeMetadata, ProtoNetwork, RegistryValueSource}; use graph_craft::util::load_network; use graph_craft::wasm_application_io::EditorPreferences; use graphene_std::application_io::{ApplicationIo, NodeGraphUpdateMessage, NodeGraphUpdateSender}; use graphene_std::text::FontCache; use graphene_std::wasm_application_io::{WasmApplicationIo, WasmEditorApi}; +use graphene_std::{ContextDependencies, core_types}; use indoc::formatdoc; use interpreted_executor::dynamic_executor::DynamicExecutor; use interpreted_executor::util::wrap_network_in_scope; +use std::collections::HashMap; +use std::collections::HashSet; use std::error::Error; use std::io::Write; use std::path::PathBuf; @@ -110,26 +114,59 @@ async fn main() -> Result<(), Box> { } Command::BuildNodeDocs => { // TODO: Also obtain document nodes, not only proto nodes - let nodes: Vec<_> = graphene_std::registry::NODE_METADATA.lock().unwrap().values().cloned().collect(); + let nodes = graphene_std::registry::NODE_METADATA.lock().unwrap(); + let node_registry = core_types::registry::NODE_REGISTRY.lock().unwrap(); + + let sanitize_path = |s: &str| { + // Replace disallowed characters with a dash + let allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~[]@!$&'()*+,;="; + let filtered = s.chars().map(|c| if allowed_characters.contains(c) { c } else { '-' }).collect::(); + + // Fix letter-number type names + let mut filtered = format!("-{filtered}-"); + filtered = filtered.replace("-vec-2-", "-vec2-"); + filtered = filtered.replace("-f-32-", "-f32-"); + filtered = filtered.replace("-f-64-", "-f64-"); + filtered = filtered.replace("-u-32-", "-u32-"); + filtered = filtered.replace("-u-64-", "-u64-"); + filtered = filtered.replace("-i-32-", "-i32-"); + filtered = filtered.replace("-i-64-", "-i64-"); + + // Remove consecutive dashes + while filtered.contains("--") { + filtered = filtered.replace("--", "-"); + } + + // Trim leading and trailing dashes + filtered.trim_matches('-').to_string() + }; + + // ================= + // NODE CATALOG PAGE + // ================= // Group nodes by category - use std::collections::HashMap; - let mut map: HashMap> = HashMap::new(); - for node in nodes { - map.entry(node.category.to_string()).or_default().push(node); + let mut nodes_by_category: HashMap> = HashMap::new(); + for (id, metadata) in nodes.iter() { + nodes_by_category.entry(metadata.category.to_string()).or_default().push((id, metadata)); } // Sort the categories - let mut categories: Vec<_> = map.keys().cloned().collect(); + let mut categories: Vec<_> = nodes_by_category.keys().cloned().collect(); categories.sort(); - let allowed_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~[]@!$&'()*+,;="; - let omit_disallowed_chars = |s: &str| s.chars().filter(|c| allowed_chars.contains(*c)).collect::(); - + // Create _index.md for the node catalog page let node_catalog_path = "../../website/content/learn/node-catalog"; - + 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"); + } + std::fs::create_dir_all(node_catalog_path).expect("Failed to create node catalog directory"); let page_path = format!("{node_catalog_path}/_index.md"); - let mut index_file = std::fs::File::create(&page_path).expect("Failed to create index file"); + let mut page = std::fs::File::create(&page_path).expect("Failed to create index file"); + + // =============================== + // NODE CATALOG: WRITE FRONTMATTER + // =============================== let content = formatdoc!( " +++ @@ -139,52 +176,70 @@ async fn main() -> Result<(), Box> { [extra] order = 3 + css = [\"/page/user-manual/node-catalog.css\"] +++ + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); - - + // =============================== + // NODE CATALOG: WRITE DESCRIPTION + // =============================== + let content = formatdoc!( + " + The node catalog documents all of the nodes available in Graphite's node graph system, organized by category. +

\"Terminology

+ " + ); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); + + // ============================== + // NODE CATALOG: WRITE CATEGORIES + // ============================== + let content = formatdoc!( + " + ## Node categories | Category | Details | |:-|:-| " ); - index_file.write_all(content.as_bytes()).expect("Failed to write to index file"); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); let content = categories .iter() - .filter(|c| !c.is_empty()) + // .filter(|c| !c.is_empty()) + .map(|c| if c.is_empty() { "Hidden" } else { c }) .map(|category| { - let category_path_part = omit_disallowed_chars(&category.to_case(Case::Kebab)); + let category_path_part = sanitize_path(&category.to_case(Case::Kebab)); let details = format!("This is the {category} category of nodes."); format!("| [{category}](./{category_path_part}) | {details} |") }) .collect::>() .join("\n"); - index_file.write_all(content.as_bytes()).expect("Failed to write to index file"); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); - // For each category, sort nodes by display_name and print - for (index, category) in categories.iter().filter(|c| !c.is_empty()).enumerate() { - let mut items = map.remove(category).unwrap(); - items.sort_by_key(|x| x.display_name.to_string()); + // =================== + // NODE CATEGORY PAGES + // =================== + for (index, category) in categories.iter().map(|c| if c.is_empty() { "Hidden" } else { c }).filter(|c| !c.is_empty()).enumerate() { + // Get nodes in this category + let mut nodes = nodes_by_category.remove(if category == "Hidden" { "" } else { category }).unwrap(); + nodes.sort_by_key(|(_, metadata)| metadata.display_name.to_string()); - let category_path_part = omit_disallowed_chars(&category.to_case(Case::Kebab)); + // 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}"); - - // Create directory for category path std::fs::create_dir_all(&category_path).expect("Failed to create category directory"); - - // Create _index.md file for category let page_path = format!("{category_path}/_index.md"); - let mut index_file = std::fs::File::create(&page_path).expect("Failed to create index file"); + let mut page = std::fs::File::create(&page_path).expect("Failed to create index file"); - // Write the frontmatter and initial content + // ================================ + // NODE CATEGORY: WRITE FRONTMATTER + // ================================ let order = index + 1; let content = formatdoc!( " @@ -195,15 +250,28 @@ async fn main() -> Result<(), Box> { [extra] order = {order} + css = [\"/page/user-manual/node-category.css\"] +++ + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); - + // ================================ + // NODE CATEGORY: WRITE DESCRIPTION + // ================================ + let content = formatdoc!( + " This is the {category} category of nodes. + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); + + // ================================ + // NODE CATEGORY: WRITE NODES TABLE + // ================================ + let content = formatdoc!( + " ## Nodes @@ -211,36 +279,108 @@ async fn main() -> Result<(), Box> { |:-|:-|:-| " ); - index_file.write_all(content.as_bytes()).expect("Failed to write to index file"); - - let content = items + page.write_all(content.as_bytes()).expect("Failed to write to index file"); + + let name_and_description = |metadata: &NodeMetadata| { + let name = metadata.display_name; + let mut description = metadata.description.trim(); + if description.is_empty() { + description = "*Node description coming soon.*"; + } + (name, description) + }; + + let content = nodes .iter() - .map(|id| { - let name_url_part = omit_disallowed_chars(&id.display_name.to_case(Case::Kebab)); - let details = id.description.trim().split('\n').map(|line| format!("

{}

", line.trim())).collect::>().join(""); - let mut possible_types = id - .fields + .filter_map(|&(id, metadata)| { + // Path to page + let name_url_part = sanitize_path(&metadata.display_name.to_case(Case::Kebab)); + + // Name and description + let (name, description) = name_and_description(metadata); + let details = description.split('\n').map(|line| format!("

{}

", line.trim())).collect::>().join(""); + + // Possible types + let implementations = node_registry.get(id)?; + let valid_primary_inputs_to_outputs = implementations .iter() - .map(|field| format!("`{} → Unknown`", if let Some(t) = &field.default_type { format!("{t:?}") } else { "()".to_string() })) + .map(|(_, node_io)| { + format!( + "`{} → {}`", + node_io + .inputs + .first() + .map(|t| t.nested_type()) + .filter(|&t| t != &concrete!(())) + .map(ToString::to_string) + .unwrap_or_default(), + node_io.return_value.nested_type() + ) + }) .collect::>(); - if possible_types.is_empty() { - possible_types.push("`Unknown → Unknown`".to_string()); - } - possible_types.sort(); - possible_types.dedup(); - let possible_types = possible_types.join("
"); - format!("| [{name}]({name_url_part}) | {details} | {possible_types} |", name = id.display_name) + let valid_primary_inputs_to_outputs = { + // Dedupe while preserving order + let mut found = HashSet::new(); + valid_primary_inputs_to_outputs.into_iter().filter(|s| found.insert(s.clone())).collect::>() + }; + let possible_types = valid_primary_inputs_to_outputs.join("
"); + + // Add table row + Some(format!("| [{name}]({name_url_part}) | {details} | {possible_types} |")) }) .collect::>() .join("\n"); - index_file.write_all(content.as_bytes()).expect("Failed to write to index file"); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); + + // ========== + // NODE PAGES + // ========== + for (index, (id, metadata)) in nodes.into_iter().enumerate() { + let Some(implementations) = node_registry.get(id) else { continue }; - for (index, id) in items.iter().enumerate() { - let name = id.display_name; - let description = id.description.trim(); - let name_url_part = omit_disallowed_chars(&id.display_name.to_case(Case::Kebab)); + // Path to page + let name_url_part = sanitize_path(&metadata.display_name.to_case(Case::Kebab)); let page_path = format!("{category_path}/{name_url_part}.md"); + let mut page = std::fs::File::create(&page_path).expect("Failed to create node page file"); + // Name and description + let (name, description) = name_and_description(metadata); + + // Context features + let context_features = &metadata.context_features; + let context_dependencies: ContextDependencies = context_features.as_slice().into(); + + // Input types + let mut valid_input_types = vec![Vec::new(); metadata.fields.len()]; + for (_, node_io) in implementations.iter() { + for (i, ty) in node_io.inputs.iter().enumerate() { + valid_input_types[i].push(ty.clone()); + } + } + for item in valid_input_types.iter_mut() { + // Dedupe while preserving order + let mut found = HashSet::new(); + *item = item.clone().into_iter().filter(|s| found.insert(s.clone())).collect::>() + } + + // Primary output types + let valid_primary_outputs = implementations.iter().map(|(_, node_io)| node_io.return_value.nested_type().clone()).collect::>(); + let valid_primary_outputs = { + // Dedupe while preserving order + let mut found = HashSet::new(); + valid_primary_outputs.into_iter().filter(|s| found.insert(s.clone())).collect::>() + }; + let valid_primary_outputs = valid_primary_outputs.iter().map(|ty| format!("`{ty}`")).collect::>(); + let valid_primary_outputs = { + // Dedupe while preserving order + let mut found = HashSet::new(); + valid_primary_outputs.into_iter().filter(|s| found.insert(s.clone())).collect::>() + }; + let valid_primary_outputs = valid_primary_outputs.join("
"); + + // ======================= + // NODE: WRITE FRONTMATTER + // ======================= let order = index + 1; let content = formatdoc!( " @@ -249,56 +389,167 @@ async fn main() -> Result<(), Box> { [extra] order = {order} + css = [\"/page/user-manual/node.css\"] +++ + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to node page file"); - + // ======================= + // NODE: WRITE DESCRIPTION + // ======================= + let content = formatdoc!( + " {description} - ### Inputs - - | Parameter | Details | Possible Types | - |:-|:-|:-| + ## Interface " ); - let mut page_file = std::fs::File::create(&page_path).expect("Failed to create node page file"); - page_file.write_all(content.as_bytes()).expect("Failed to write to node page file"); + page.write_all(content.as_bytes()).expect("Failed to write to node page file"); + + // =================== + // NODE: WRITE CONTEXT + // =================== + let extract = context_dependencies.extract; + let inject = context_dependencies.inject; + if !extract.is_empty() || !inject.is_empty() { + let mut context_features = "| | |\n|:-|:-|".to_string(); + if !extract.is_empty() { + let names = extract.iter().map(|ty| format!("`{}`", ty.name())).collect::>().join("
"); + context_features.push_str(&format!("\n| **Reads** | {names} |")); + } + if !inject.is_empty() { + let names = inject.iter().map(|ty| format!("`{}`", ty.name())).collect::>().join("
"); + context_features.push_str(&format!("\n| **Sets** | {names} |")); + } - let content = id + let content = formatdoc!( + " + + ### Context + + {context_features} + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to node page file"); + }; + + // ================== + // NODE: WRITE INPUTS + // ================== + let rows = metadata .fields .iter() - .map(|field| { + .enumerate() + .map(|(index, field)| { + // Parameter let parameter = field.name; - let details = field.description.trim().split('\n').map(|line| format!("

{}

", line.trim())).collect::>().join(""); - let mut possible_types = vec![if let Some(t) = &field.default_type { format!("`{t:?}`") } else { "`Unknown`".to_string() }]; - possible_types.sort(); - possible_types.dedup(); - let possible_types = possible_types.join("
"); - format!("| {parameter} | {details} | {possible_types} |") - }) - .collect::>() - .join("\n"); - page_file.write_all(content.as_bytes()).expect("Failed to write to node page file"); - page_file.write_all("\n\n".as_bytes()).expect("Failed to write to node page file"); + // Possible types + let mut possible_types_list = valid_input_types.get(index).unwrap_or(&Vec::new()).iter().map(|ty| ty.nested_type()).cloned().collect::>(); + possible_types_list.sort_by_key(|ty| ty.to_string()); + possible_types_list.dedup(); + let mut possible_types = possible_types_list.iter().map(|ty| format!("`{ty}`")).collect::>().join("
"); + if possible_types.is_empty() { + possible_types = "*Any Type*".to_string(); + } + + // Details + let mut details = field + .description + .trim() + .split('\n') + .filter(|line| !line.is_empty()) + .map(|line| format!("

{}

", line.trim())) + .collect::>(); + if index == 0 { + details.push("

*Primary Input*

".to_string()); + } + if field.exposed { + details.push("

*Exposed to the Graph by Default*

".to_string()); + } + if let Some(default_value) = match field.value_source { + RegistryValueSource::None => None, + RegistryValueSource::Scope(scope_name) => { + details.push(format!("

*Sourced From Scope: `{scope_name}`*

")); + None + } + RegistryValueSource::Default(default_value) => Some(default_value.to_string().replace(" :: ", "::")), + } + .or_else(|| { + let ty = field + .default_type + .as_ref() + .or(match possible_types_list.as_slice() { + [single] => Some(single), + _ => None, + })? + .nested_type(); + Some(match () { + () if ty == &concrete!(f32) => f32::default().to_string(), + () if ty == &concrete!(f64) => f64::default().to_string(), + () if ty == &concrete!(u32) => u32::default().to_string(), + () if ty == &concrete!(u64) => u64::default().to_string(), + () if ty == &concrete!(i32) => i32::default().to_string(), + () if ty == &concrete!(i64) => i64::default().to_string(), + () if ty == &concrete!(bool) => bool::default().to_string(), + () if ty == &concrete!(&str) => "\"\"".to_string(), + () if ty == &concrete!(String) => "\"\"".to_string(), + () if ty == &concrete!(Vec) => "[]".to_string(), + () if ty == &concrete!(value::DVec2) => "(0, 0)".to_string(), + () if ty == &concrete!(value::DAffine2) => value::DAffine2::default().to_string(), + () if ty == &concrete!(graphene_std::gradient::GradientStops) => "BLACK_TO_WHITE".to_string(), + _ => return None, + }) + }) { + let default_value = default_value.trim_end_matches('.'); // Remove trailing period on whole-number floats + let render_color = |color| format!(r#""#); + let default_value = match default_value { + "Color::BLACK" => render_color("black"), + "BLACK_TO_WHITE" => render_color("linear-gradient(to right, black, white)"), + _ => format!("`{default_value}{}`", field.unit.unwrap_or_default()), + }; + details.push(format!("

*Default: {default_value}*

")); + } + let details = details.join(""); + + if index == 0 && possible_types_list.as_slice() == [concrete!(())] { + "| - | *No Primary Input* | - |".to_string() + } else { + format!("| {parameter} | {details} | {possible_types} |") + } + }) + .collect::>(); + if !rows.is_empty() { + let rows = rows.join("\n"); + let content = formatdoc!( + " + + ### Inputs + + | Parameter | Details | Possible Types | + |:-|:-|:-| + {rows} + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to node page file"); + } + + // =================== + // NODE: WRITE OUTPUTS + // =================== let content = formatdoc!( " + ### Outputs | Product | Details | Possible Types | |:-|:-|:-| - | Result |

The value produced by the node operation.

Primary Output

| `Unknown` | - - ### Context - - Not context-aware. + | Result |

The value produced by the node operation.

*Primary Output*

| {valid_primary_outputs} | " ); - page_file.write_all(content.as_bytes()).expect("Failed to write to node page file"); + page.write_all(content.as_bytes()).expect("Failed to write to node page file"); } } return Ok(()); @@ -307,7 +558,7 @@ async fn main() -> Result<(), Box> { let document_string = std::fs::read_to_string(document_path).expect("Failed to read document"); - log::info!("creating gpu context",); + log::info!("Creating GPU context"); let mut application_io = block_on(WasmApplicationIo::new_offscreen()); if let Command::Export { image: Some(ref image_path), .. } = app.command { diff --git a/node-graph/libraries/core-types/src/context.rs b/node-graph/libraries/core-types/src/context.rs index 1ad2005f6c..c85712dd7f 100644 --- a/node-graph/libraries/core-types/src/context.rs +++ b/node-graph/libraries/core-types/src/context.rs @@ -113,6 +113,20 @@ bitflags! { } } +impl ContextFeatures { + pub fn name(&self) -> &'static str { + match *self { + ContextFeatures::FOOTPRINT => "Footprint", + ContextFeatures::REAL_TIME => "RealTime", + ContextFeatures::ANIMATION_TIME => "AnimationTime", + ContextFeatures::POINTER => "Pointer", + ContextFeatures::INDEX => "Index", + ContextFeatures::VARARGS => "VarArgs", + _ => "Multiple Features", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, dyn_any::DynAny, serde::Serialize, serde::Deserialize, Default)] pub struct ContextDependencies { pub extract: ContextFeatures, diff --git a/node-graph/libraries/core-types/src/types.rs b/node-graph/libraries/core-types/src/types.rs index a0ad71c67f..0b24927325 100644 --- a/node-graph/libraries/core-types/src/types.rs +++ b/node-graph/libraries/core-types/src/types.rs @@ -1,3 +1,4 @@ +use crate::transform::Footprint; use std::any::TypeId; pub use std::borrow::Cow; use std::fmt::{Display, Formatter}; @@ -75,6 +76,7 @@ macro_rules! fn_type_fut { }; } +// TODO: Rename to NodeSignatureMonomorphization #[derive(Clone, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] pub struct NodeIOTypes { pub call_argument: Type, @@ -372,6 +374,19 @@ impl std::fmt::Debug for Type { impl std::fmt::Display for Type { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self == &concrete!(glam::DVec2) { + return write!(f, "vec2"); + } + if self == &concrete!(glam::DAffine2) { + return write!(f, "transform"); + } + if self == &concrete!(Footprint) { + return write!(f, "footprint"); + } + if self == &concrete!(&str) || self == &concrete!(String) { + return write!(f, "string"); + } + let text = match self { Type::Generic(name) => name.to_string(), Type::Concrete(ty) => format_type(&ty.name), diff --git a/node-graph/node-macro/src/validation.rs b/node-graph/node-macro/src/validation.rs index 703bac1805..8f8a3e3716 100644 --- a/node-graph/node-macro/src/validation.rs +++ b/node-graph/node-macro/src/validation.rs @@ -117,7 +117,7 @@ fn validate_implementations_for_generics(parsed: &ParsedNodeFn) { quote!(#ty), pat_ident.ident; help = "Add #[implementations(ConcreteType1, ConcreteType2)] to field '{}'", pat_ident.ident; - help = "Or use #[node_macro::node(skip_impl)] if you want to manually implement the node" + help = "Or use #[node_macro::node(category(...), skip_impl)] if you want to manually implement the node" ); } } @@ -133,7 +133,7 @@ fn validate_implementations_for_generics(parsed: &ParsedNodeFn) { "Generic types in Node field `{}` require an #[implementations(...)] attribute", pat_ident.ident; help = "Add #[implementations(InputType1 -> OutputType1, InputType2 -> OutputType2)] to field '{}'", pat_ident.ident; - help = "Or use #[node_macro::node(skip_impl)] if you want to manually implement the node" + help = "Or use #[node_macro::node(category(...), skip_impl)] if you want to manually implement the node" ); } // Additional check for Node implementations diff --git a/node-graph/preprocessor/src/lib.rs b/node-graph/preprocessor/src/lib.rs index 188b451058..b945466a57 100644 --- a/node-graph/preprocessor/src/lib.rs +++ b/node-graph/preprocessor/src/lib.rs @@ -37,7 +37,7 @@ pub fn generate_node_substitutions() -> HashMap = implementations.iter().map(|(_, node_io)| node_io.call_argument.clone()).collect(); + let valid_call_args: HashSet<_> = implementations.iter().map(|(_, node_io)| node_io.call_argument.clone()).collect(); let first_node_io = implementations.first().map(|(_, node_io)| node_io).unwrap_or(const { &NodeIOTypes::empty() }); let mut node_io_types = vec![HashSet::new(); fields.len()]; for (_, node_io) in implementations.iter() { @@ -46,7 +46,7 @@ pub fn generate_node_substitutions() -> HashMap 1 { + if valid_call_args.len() > 1 { input_type = &const { generic!(D) }; } diff --git a/website/content/learn/node-catalog-example/_index.md b/website/content/learn/node-catalog-example/_index.md index f4dc0e5bbc..1a845ebf98 100644 --- a/website/content/learn/node-catalog-example/_index.md +++ b/website/content/learn/node-catalog-example/_index.md @@ -5,14 +5,9 @@ page_template = "book.html" [extra] order = 4 +css = ["/page/user-manual/node-catalog.css"] +++ - - The node catalog documents all of the nodes available in Graphite's node graph system, organized by category. ## Node categories diff --git a/website/content/learn/node-catalog-example/_vector-style/_index.md b/website/content/learn/node-catalog-example/_vector-style/_index.md deleted file mode 100644 index 97ba4fd03e..0000000000 --- a/website/content/learn/node-catalog-example/_vector-style/_index.md +++ /dev/null @@ -1,24 +0,0 @@ -+++ -title = "Vector: Style" -template = "book.html" -page_template = "book.html" - -[extra] -order = 1 -+++ - - - -Nodes in this category apply styling effects to vector graphics, such as controlling stroke (outline) and fill properties. - -## Nodes - -| Node | Details | Possible Types | -|:-|:-|:-| -| [Fill](../fill) |

Applies a fill style to the vector content, giving an appearance to the area within the interior of the geometry.

| `Table → Table`
`Table → Table` | -| [Stroke](../stroke) |

Applies a stroke style to the vector content, giving an appearance to the area within the outline of the geometry.

| `Table → Table`
`Table → Table` | -| [Assign Colors](../assign-colors) |

Uniquely sets the fill and/or stroke style of every vector element to individual colors sampled along a chosen gradient.

| `Table → Table`
`Table → Table` | diff --git a/website/content/learn/node-catalog-example/vector-style/_index.md b/website/content/learn/node-catalog-example/vector-style/_index.md new file mode 100644 index 0000000000..807e05de68 --- /dev/null +++ b/website/content/learn/node-catalog-example/vector-style/_index.md @@ -0,0 +1,19 @@ ++++ +title = "Vector: Style" +template = "book.html" +page_template = "book.html" + +[extra] +order = 1 +css = ["/page/user-manual/node-category.css"] ++++ + +Nodes in this category apply styling effects to vector graphics, such as controlling stroke (outline) and fill properties. + +## Nodes + +| Node | Details | Possible Types | +|:-|:-|:-| +| [Assign Colors](./assign-colors) |

Uniquely sets the fill and/or stroke style of every vector element to individual colors sampled along a chosen gradient.

| `Table → Table`
`Table → Table` | +| [Fill](./fill) |

Applies a fill style to the vector content, giving an appearance to the area within the interior of the geometry.

| `Table → Table`
`Table → Table` | +| [Stroke](./stroke) |

Applies a stroke style to the vector content, giving an appearance to the area within the outline of the geometry.

| `Table → Table`
`Table → Table` | diff --git a/website/content/learn/node-catalog-example/_vector-style/assign-colors.md b/website/content/learn/node-catalog-example/vector-style/assign-colors.md similarity index 76% rename from website/content/learn/node-catalog-example/_vector-style/assign-colors.md rename to website/content/learn/node-catalog-example/vector-style/assign-colors.md index a0f9a1dea2..daaa3bb88d 100644 --- a/website/content/learn/node-catalog-example/_vector-style/assign-colors.md +++ b/website/content/learn/node-catalog-example/vector-style/assign-colors.md @@ -2,25 +2,22 @@ title = "Assign Colors" [extra] -order = 3 +order = 1 +css = ["/page/user-manual/node.css"] +++ - - Uniquely sets the fill and/or stroke style of every vector element to individual colors sampled along a chosen gradient. +## Interface + ### Inputs | Parameter | Details | Possible Types | |:-|:-|:-| -| Content |

The content with vector paths to apply the fill and/or stroke style to.

Primary Input

| `Table`
`Table` | +| Content |

The content with vector paths to apply the fill and/or stroke style to.

Primary Input

| `Table`
`Table` | | Fill |

Whether to style the fill.

Default: `true`

| `bool` | | Stroke |

Whether to style the stroke.

Default: `false`

| `bool` | -| Gradient |

The range of colors to select from.

Default:  

| `GradientStops` | +| Gradient |

The range of colors to select from.

Default: 

| `GradientStops` | | Reverse |

Whether to reverse the gradient.

Default: `false`

| `bool` | | Randomize |

Whether to randomize the color selection for each element from throughout the gradient.

Default: `false`

| `bool` | | Seed |

The seed used for randomization.

Seed to determine unique variations on the randomized color selection.

Default: `0`

| `SeedValue` | @@ -30,8 +27,4 @@ Uniquely sets the fill and/or stroke style of every vector element to individual | Product | Details | Possible Types | |:-|:-|:-| -| Result |

The vector content with the fill and/or stroke styles applied.

Primary Output

| `Table`
`Table` | - -### Context - -Not context-aware. +| Result |

The vector content with the fill and/or stroke styles applied.

Primary Output

| `Table`
`Table` | diff --git a/website/content/learn/node-catalog-example/_vector-style/fill.md b/website/content/learn/node-catalog-example/vector-style/fill.md similarity index 51% rename from website/content/learn/node-catalog-example/_vector-style/fill.md rename to website/content/learn/node-catalog-example/vector-style/fill.md index f46709dee3..1e4a4f4537 100644 --- a/website/content/learn/node-catalog-example/_vector-style/fill.md +++ b/website/content/learn/node-catalog-example/vector-style/fill.md @@ -2,30 +2,23 @@ title = "Fill" [extra] -order = 1 +order = 2 +css = ["/page/user-manual/node.css"] +++ - - Applies a fill style to the vector content, giving an appearance to the area within the interior of the geometry. +## Interface + ### Inputs | Parameter | Details | Possible Types | |:-|:-|:-| -| Content |

The content with vector paths to apply the fill style to.

Primary Input

| `Table`
`Table` | -| Fill |

The fill to paint the path with.

Default:  

| `Fill`
`Table`
`Table`
`Gradient` | +| Content |

The content with vector paths to apply the fill style to.

Primary Input

| `Table`
`Table` | +| Fill |

The fill to paint the path with.

Default: 

| `Fill`
`Gradient`
`Table`
`Table` | ### Outputs | Product | Details | Possible Types | |:-|:-|:-| | Result |

The vector content with the fill style applied.

Primary Output

| `Table`
`Table` | - -### Context - -Not context-aware. diff --git a/website/content/learn/node-catalog-example/_vector-style/stroke.md b/website/content/learn/node-catalog-example/vector-style/stroke.md similarity index 82% rename from website/content/learn/node-catalog-example/_vector-style/stroke.md rename to website/content/learn/node-catalog-example/vector-style/stroke.md index d74fcc2e6b..dc7584831f 100644 --- a/website/content/learn/node-catalog-example/_vector-style/stroke.md +++ b/website/content/learn/node-catalog-example/vector-style/stroke.md @@ -2,23 +2,20 @@ title = "Stroke" [extra] -order = 2 +order = 3 +css = ["/page/user-manual/node.css"] +++ - - Applies a stroke style to the vector content, giving an appearance to the area within the outline of the geometry. +## Interface + ### Inputs | Parameter | Details | Possible Types | |:-|:-|:-| -| Content |

The content with vector paths to apply the stroke style to.

Primary Input

| `Table`
`Table` | -| Color |

The stroke color.

Default:  

| `Table` | +| Content |

The content with vector paths to apply the stroke style to.

Primary Input

| `Table`
`Table` | +| Color |

The stroke color.

Default: 

| `Table` | | Weight |

The stroke thickness.

Default: `2 px`

| `f64` | | Align |

The alignment of stroke to the path's centerline or (for closed shapes) the inside or outside of the shape.

Default: `Center`

| `StrokeAlign` | | Cap |

The shape of the stroke at open endpoints.

Default: `Butt`

| `StrokeCap` | @@ -33,7 +30,3 @@ Applies a stroke style to the vector content, giving an appearance to the area w | Product | Details | Possible Types | |:-|:-|:-| | Result |

The vector content with the stroke style applied.

Primary Output

| `Table`
`Table` | - -### Context - -Not context-aware. diff --git a/website/content/volunteer/guide/codebase-overview/editor-structure.md b/website/content/volunteer/guide/codebase-overview/editor-structure.md index d922c4dc57..c4a866135e 100644 --- a/website/content/volunteer/guide/codebase-overview/editor-structure.md +++ b/website/content/volunteer/guide/codebase-overview/editor-structure.md @@ -3,8 +3,8 @@ title = "Editor structure" [extra] order = 1 # Page number after chapter intro -css = ["/page/developer-guide-editor-structure.css"] -js = ["/js/developer-guide-editor-structure.js"] +css = ["/page/contributor-guide/editor-structure.css"] +js = ["/js/contributor-guide/editor-structure.js"] +++ The Graphite editor is the application users interact with to create documents. Its code is a single Rust crate that lives below the frontend (web code) and above [Graphene](../../graphene) (the node-based graphics engine). The main business logic of all visual editing is handled by the editor backend. When running in the browser, it is compiled to WebAssembly and passes messages to the frontend. diff --git a/website/sass/base.scss b/website/sass/base.scss index 75bbe2a17e..bd53752526 100644 --- a/website/sass/base.scss +++ b/website/sass/base.scss @@ -429,12 +429,12 @@ h6 { } h2 { - font-size: calc(1rem * 16 / 9); + font-size: 1.75rem; font-weight: 700; } h3 { - font-size: calc(1rem * 4 / 3); + font-size: 1.25rem; } h4, diff --git a/website/sass/layout/reading-material.scss b/website/sass/layout/reading-material.scss index 316112aeda..f2895fa5d5 100644 --- a/website/sass/layout/reading-material.scss +++ b/website/sass/layout/reading-material.scss @@ -1,21 +1,11 @@ .reading-material.reading-material { + font-size: 16px; + line-height: 1.625; max-width: var(--max-width-reading-material); article { width: 100%; - h2 { - margin-top: 80px; - } - - h3 { - margin-top: 40px; - } - - h4 { - margin-top: 20px; - } - h1, h2, h3, @@ -23,6 +13,7 @@ h5, h6 { display: block; + margin-top: 80px; &:first-child { margin-top: 0; diff --git a/website/sass/page/developer-guide-editor-structure.scss b/website/sass/page/contributor-guide/editor-structure.scss similarity index 98% rename from website/sass/page/developer-guide-editor-structure.scss rename to website/sass/page/contributor-guide/editor-structure.scss index 7d145f207b..0158704766 100644 --- a/website/sass/page/developer-guide-editor-structure.scss +++ b/website/sass/page/contributor-guide/editor-structure.scss @@ -1,6 +1,5 @@ .structure-outline { font-family: monospace; - font-size: 18px; line-height: 1.5; margin-top: 20px; diff --git a/website/sass/page/user-manual/node-catalog.scss b/website/sass/page/user-manual/node-catalog.scss new file mode 100644 index 0000000000..323294113d --- /dev/null +++ b/website/sass/page/user-manual/node-catalog.scss @@ -0,0 +1,3 @@ +table tr td:first-child a { + white-space: nowrap; +} diff --git a/website/sass/page/user-manual/node-category.scss b/website/sass/page/user-manual/node-category.scss new file mode 100644 index 0000000000..5c93bf94cd --- /dev/null +++ b/website/sass/page/user-manual/node-category.scss @@ -0,0 +1,14 @@ +#nodes + table tr { + th:last-child { + width: 0; + white-space: nowrap; + } + + td:last-child { + width: 0; + + code { + white-space: nowrap; + } + } +} diff --git a/website/sass/page/user-manual/node.scss b/website/sass/page/user-manual/node.scss new file mode 100644 index 0000000000..f0d3bb06b4 --- /dev/null +++ b/website/sass/page/user-manual/node.scss @@ -0,0 +1,14 @@ +:is(#context, #inputs, #outputs) + table tr { + th:is(:first-child, :nth-child(3)) { + width: 0; + white-space: nowrap; + } + + td:is(:first-child, :nth-child(3)) { + width: 0; + + code { + white-space: nowrap; + } + } +} From e5b9d63cda834347cafd38391350b6a3b39b5afc Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 19 Jan 2026 01:28:52 -0800 Subject: [PATCH 3/9] Move to the tools directory and make it generate nicer default values --- Cargo.lock | 14 +- Cargo.toml | 3 +- node-graph/graph-craft/src/document/value.rs | 43 +- node-graph/graphene-cli/Cargo.toml | 2 - node-graph/graphene-cli/src/main.rs | 452 +----------------- tools/node-docs/Cargo.toml | 18 + tools/node-docs/src/main.rs | 444 +++++++++++++++++ .../learn/node-catalog-example/_index.md | 17 - .../vector-style/_index.md | 19 - .../vector-style/assign-colors.md | 30 -- .../node-catalog-example/vector-style/fill.md | 24 - .../vector-style/stroke.md | 32 -- website/sass/base.scss | 4 + 13 files changed, 507 insertions(+), 595 deletions(-) create mode 100644 tools/node-docs/Cargo.toml create mode 100644 tools/node-docs/src/main.rs delete mode 100644 website/content/learn/node-catalog-example/_index.md delete mode 100644 website/content/learn/node-catalog-example/vector-style/_index.md delete mode 100644 website/content/learn/node-catalog-example/vector-style/assign-colors.md delete mode 100644 website/content/learn/node-catalog-example/vector-style/fill.md delete mode 100644 website/content/learn/node-catalog-example/vector-style/stroke.md diff --git a/Cargo.lock b/Cargo.lock index 3681796f1b..53ea375a56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2236,13 +2236,11 @@ version = "0.1.0" dependencies = [ "chrono", "clap", - "convert_case 0.8.0", "fern", "futures", "graph-craft", "graphene-std", "image", - "indoc", "interpreted-executor", "log", "preprocessor", @@ -3700,6 +3698,18 @@ dependencies = [ "spirv-std", ] +[[package]] +name = "node-docs" +version = "0.0.0" +dependencies = [ + "convert_case 0.8.0", + "graph-craft", + "graphene-std", + "indoc", + "interpreted-executor", + "preprocessor", +] + [[package]] name = "node-macro" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 6154a55173..55f7809008 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,8 @@ members = [ "node-graph/node-macro", "node-graph/preprocessor", "proc-macros", - "tools/crate-hierarchy-viz" + "tools/crate-hierarchy-viz", + "tools/node-docs" ] default-members = [ "editor", diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 72e81f874d..7a9d6ba906 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -116,15 +116,15 @@ macro_rules! tagged_value { _ => Err(format!("Cannot convert {:?} to TaggedValue",std::any::type_name_of_val(input))), } } + /// Returns a TaggedValue from the type, where that value is its type's `Default::default()` pub fn from_type(input: &Type) -> Option { match input { Type::Generic(_) => None, Type::Concrete(concrete_type) => { - let internal_id = concrete_type.id?; use std::any::TypeId; // TODO: Add default implementations for types such as TaggedValue::Subpaths, and use the defaults here and in document_node_types // Tries using the default for the tagged value type. If it not implemented, then uses the default used in document_node_types. If it is not used there, then TaggedValue::None is returned. - Some(match internal_id { + Some(match concrete_type.id? { x if x == TypeId::of::<()>() => TaggedValue::None, $( x if x == TypeId::of::<$ty>() => TaggedValue::$identifier(Default::default()), )* _ => return None, @@ -139,6 +139,15 @@ macro_rules! tagged_value { pub fn from_type_or_none(input: &Type) -> Self { Self::from_type(input).unwrap_or(TaggedValue::None) } + pub fn to_debug_string(&self) -> String { + match self { + Self::None => "()".to_string(), + $( Self::$identifier(x) => format!("{:?}", x), )* + Self::RenderOutput(_) => "RenderOutput".to_string(), + Self::SurfaceFrame(_) => "SurfaceFrame".to_string(), + Self::EditorApi(_) => "WasmEditorApi".to_string(), + } + } } $( @@ -351,24 +360,24 @@ impl TaggedValue { match ty { Type::Generic(_) => None, Type::Concrete(concrete_type) => { - let internal_id = concrete_type.id?; + let ty = concrete_type.id?; use std::any::TypeId; // TODO: Add default implementations for types such as TaggedValue::Subpaths, and use the defaults here and in document_node_types // Tries using the default for the tagged value type. If it not implemented, then uses the default used in document_node_types. If it is not used there, then TaggedValue::None is returned. - let ty = match internal_id { - x if x == TypeId::of::<()>() => TaggedValue::None, - x if x == TypeId::of::() => TaggedValue::String(string.into()), - x if x == TypeId::of::() => FromStr::from_str(string).map(TaggedValue::F64).ok()?, - x if x == TypeId::of::() => FromStr::from_str(string).map(TaggedValue::F32).ok()?, - x if x == TypeId::of::() => FromStr::from_str(string).map(TaggedValue::U64).ok()?, - x if x == TypeId::of::() => FromStr::from_str(string).map(TaggedValue::U32).ok()?, - x if x == TypeId::of::() => to_dvec2(string).map(TaggedValue::DVec2)?, - x if x == TypeId::of::() => FromStr::from_str(string).map(TaggedValue::Bool).ok()?, - x if x == TypeId::of::() => to_color(string).map(TaggedValue::ColorNotInTable)?, - x if x == TypeId::of::>() => TaggedValue::ColorNotInTable(to_color(string)?), - x if x == TypeId::of::>() => to_color(string).map(|color| TaggedValue::Color(Table::new_from_element(color)))?, - x if x == TypeId::of::() => to_color(string).map(|color| TaggedValue::Fill(Fill::solid(color)))?, - x if x == TypeId::of::() => to_reference_point(string).map(TaggedValue::ReferencePoint)?, + let ty = match () { + () if ty == TypeId::of::<()>() => TaggedValue::None, + () if ty == TypeId::of::() => TaggedValue::String(string.into()), + () if ty == TypeId::of::() => FromStr::from_str(string).map(TaggedValue::F64).ok()?, + () if ty == TypeId::of::() => FromStr::from_str(string).map(TaggedValue::F32).ok()?, + () if ty == TypeId::of::() => FromStr::from_str(string).map(TaggedValue::U64).ok()?, + () if ty == TypeId::of::() => FromStr::from_str(string).map(TaggedValue::U32).ok()?, + () if ty == TypeId::of::() => to_dvec2(string).map(TaggedValue::DVec2)?, + () if ty == TypeId::of::() => FromStr::from_str(string).map(TaggedValue::Bool).ok()?, + () if ty == TypeId::of::() => to_color(string).map(TaggedValue::ColorNotInTable)?, + () if ty == TypeId::of::>() => TaggedValue::ColorNotInTable(to_color(string)?), + () if ty == TypeId::of::>() => to_color(string).map(|color| TaggedValue::Color(Table::new_from_element(color)))?, + () if ty == TypeId::of::() => to_color(string).map(|color| TaggedValue::Fill(Fill::solid(color)))?, + () if ty == TypeId::of::() => to_reference_point(string).map(TaggedValue::ReferencePoint)?, _ => return None, }; Some(ty) diff --git a/node-graph/graphene-cli/Cargo.toml b/node-graph/graphene-cli/Cargo.toml index 5d4318bbb3..b5dcfd71fd 100644 --- a/node-graph/graphene-cli/Cargo.toml +++ b/node-graph/graphene-cli/Cargo.toml @@ -28,8 +28,6 @@ tokio = { workspace = true, features = ["rt-multi-thread"] } clap = { workspace = true, features = ["cargo", "derive"] } image = { workspace = true } wgpu-executor = { workspace = true, optional = true } -convert_case = { workspace = true } -indoc = { workspace = true } [package.metadata.cargo-shear] ignored = ["wgpu-executor"] diff --git a/node-graph/graphene-cli/src/main.rs b/node-graph/graphene-cli/src/main.rs index 707351a482..41f4e15515 100644 --- a/node-graph/graphene-cli/src/main.rs +++ b/node-graph/graphene-cli/src/main.rs @@ -1,26 +1,19 @@ mod export; use clap::{Args, Parser, Subcommand}; -use convert_case::{Case, Casing}; use fern::colors::{Color, ColoredLevelConfig}; use futures::executor::block_on; -use graph_craft::concrete; use graph_craft::document::*; use graph_craft::graphene_compiler::Compiler; -use graph_craft::proto::{NodeMetadata, ProtoNetwork, RegistryValueSource}; +use graph_craft::proto::ProtoNetwork; use graph_craft::util::load_network; use graph_craft::wasm_application_io::EditorPreferences; use graphene_std::application_io::{ApplicationIo, NodeGraphUpdateMessage, NodeGraphUpdateSender}; use graphene_std::text::FontCache; use graphene_std::wasm_application_io::{WasmApplicationIo, WasmEditorApi}; -use graphene_std::{ContextDependencies, core_types}; -use indoc::formatdoc; use interpreted_executor::dynamic_executor::DynamicExecutor; use interpreted_executor::util::wrap_network_in_scope; -use std::collections::HashMap; -use std::collections::HashSet; use std::error::Error; -use std::io::Write; use std::path::PathBuf; use std::sync::Arc; @@ -83,7 +76,6 @@ enum Command { transparent: bool, }, ListNodeIdentifiers, - BuildNodeDocs, } #[derive(Debug, Args)] @@ -112,448 +104,6 @@ async fn main() -> Result<(), Box> { } return Ok(()); } - Command::BuildNodeDocs => { - // TODO: Also obtain document nodes, not only proto nodes - let nodes = graphene_std::registry::NODE_METADATA.lock().unwrap(); - let node_registry = core_types::registry::NODE_REGISTRY.lock().unwrap(); - - let sanitize_path = |s: &str| { - // Replace disallowed characters with a dash - let allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~[]@!$&'()*+,;="; - let filtered = s.chars().map(|c| if allowed_characters.contains(c) { c } else { '-' }).collect::(); - - // Fix letter-number type names - let mut filtered = format!("-{filtered}-"); - filtered = filtered.replace("-vec-2-", "-vec2-"); - filtered = filtered.replace("-f-32-", "-f32-"); - filtered = filtered.replace("-f-64-", "-f64-"); - filtered = filtered.replace("-u-32-", "-u32-"); - filtered = filtered.replace("-u-64-", "-u64-"); - filtered = filtered.replace("-i-32-", "-i32-"); - filtered = filtered.replace("-i-64-", "-i64-"); - - // Remove consecutive dashes - while filtered.contains("--") { - filtered = filtered.replace("--", "-"); - } - - // Trim leading and trailing dashes - filtered.trim_matches('-').to_string() - }; - - // ================= - // NODE CATALOG PAGE - // ================= - - // Group nodes by category - let mut nodes_by_category: HashMap> = HashMap::new(); - for (id, metadata) in nodes.iter() { - nodes_by_category.entry(metadata.category.to_string()).or_default().push((id, metadata)); - } - - // Sort the categories - let mut categories: Vec<_> = nodes_by_category.keys().cloned().collect(); - categories.sort(); - - // Create _index.md for the node catalog page - let node_catalog_path = "../../website/content/learn/node-catalog"; - 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"); - } - std::fs::create_dir_all(node_catalog_path).expect("Failed to create node catalog directory"); - let page_path = format!("{node_catalog_path}/_index.md"); - let mut page = std::fs::File::create(&page_path).expect("Failed to create index file"); - - // =============================== - // NODE CATALOG: WRITE FRONTMATTER - // =============================== - let content = formatdoc!( - " - +++ - title = \"Node catalog\" - template = \"book.html\" - page_template = \"book.html\" - - [extra] - order = 3 - css = [\"/page/user-manual/node-catalog.css\"] - +++ - " - ); - page.write_all(content.as_bytes()).expect("Failed to write to index file"); - - // =============================== - // NODE CATALOG: WRITE DESCRIPTION - // =============================== - let content = formatdoc!( - " - - The node catalog documents all of the nodes available in Graphite's node graph system, organized by category. - -

\"Terminology

- " - ); - page.write_all(content.as_bytes()).expect("Failed to write to index file"); - - // ============================== - // NODE CATALOG: WRITE CATEGORIES - // ============================== - let content = formatdoc!( - " - - ## Node categories - - | Category | Details | - |:-|:-| - " - ); - page.write_all(content.as_bytes()).expect("Failed to write to index file"); - - let content = categories - .iter() - // .filter(|c| !c.is_empty()) - .map(|c| if c.is_empty() { "Hidden" } else { c }) - .map(|category| { - let category_path_part = sanitize_path(&category.to_case(Case::Kebab)); - let details = format!("This is the {category} category of nodes."); - format!("| [{category}](./{category_path_part}) | {details} |") - }) - .collect::>() - .join("\n"); - page.write_all(content.as_bytes()).expect("Failed to write to index file"); - - // =================== - // NODE CATEGORY PAGES - // =================== - for (index, category) in categories.iter().map(|c| if c.is_empty() { "Hidden" } else { c }).filter(|c| !c.is_empty()).enumerate() { - // Get nodes in this category - let mut nodes = nodes_by_category.remove(if category == "Hidden" { "" } else { category }).unwrap(); - nodes.sort_by_key(|(_, metadata)| metadata.display_name.to_string()); - - // 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}"); - std::fs::create_dir_all(&category_path).expect("Failed to create category directory"); - let page_path = format!("{category_path}/_index.md"); - let mut page = std::fs::File::create(&page_path).expect("Failed to create index file"); - - // ================================ - // NODE CATEGORY: WRITE FRONTMATTER - // ================================ - let order = index + 1; - let content = formatdoc!( - " - +++ - title = \"{category}\" - template = \"book.html\" - page_template = \"book.html\" - - [extra] - order = {order} - css = [\"/page/user-manual/node-category.css\"] - +++ - " - ); - page.write_all(content.as_bytes()).expect("Failed to write to index file"); - - // ================================ - // NODE CATEGORY: WRITE DESCRIPTION - // ================================ - let content = formatdoc!( - " - - This is the {category} category of nodes. - " - ); - page.write_all(content.as_bytes()).expect("Failed to write to index file"); - - // ================================ - // NODE CATEGORY: WRITE NODES TABLE - // ================================ - let content = formatdoc!( - " - - ## Nodes - - | Node | Details | Possible Types | - |:-|:-|:-| - " - ); - page.write_all(content.as_bytes()).expect("Failed to write to index file"); - - let name_and_description = |metadata: &NodeMetadata| { - let name = metadata.display_name; - let mut description = metadata.description.trim(); - if description.is_empty() { - description = "*Node description coming soon.*"; - } - (name, description) - }; - - let content = nodes - .iter() - .filter_map(|&(id, metadata)| { - // Path to page - let name_url_part = sanitize_path(&metadata.display_name.to_case(Case::Kebab)); - - // Name and description - let (name, description) = name_and_description(metadata); - let details = description.split('\n').map(|line| format!("

{}

", line.trim())).collect::>().join(""); - - // Possible types - let implementations = node_registry.get(id)?; - let valid_primary_inputs_to_outputs = implementations - .iter() - .map(|(_, node_io)| { - format!( - "`{} → {}`", - node_io - .inputs - .first() - .map(|t| t.nested_type()) - .filter(|&t| t != &concrete!(())) - .map(ToString::to_string) - .unwrap_or_default(), - node_io.return_value.nested_type() - ) - }) - .collect::>(); - let valid_primary_inputs_to_outputs = { - // Dedupe while preserving order - let mut found = HashSet::new(); - valid_primary_inputs_to_outputs.into_iter().filter(|s| found.insert(s.clone())).collect::>() - }; - let possible_types = valid_primary_inputs_to_outputs.join("
"); - - // Add table row - Some(format!("| [{name}]({name_url_part}) | {details} | {possible_types} |")) - }) - .collect::>() - .join("\n"); - page.write_all(content.as_bytes()).expect("Failed to write to index file"); - - // ========== - // NODE PAGES - // ========== - for (index, (id, metadata)) in nodes.into_iter().enumerate() { - let Some(implementations) = node_registry.get(id) else { continue }; - - // Path to page - let name_url_part = sanitize_path(&metadata.display_name.to_case(Case::Kebab)); - let page_path = format!("{category_path}/{name_url_part}.md"); - let mut page = std::fs::File::create(&page_path).expect("Failed to create node page file"); - - // Name and description - let (name, description) = name_and_description(metadata); - - // Context features - let context_features = &metadata.context_features; - let context_dependencies: ContextDependencies = context_features.as_slice().into(); - - // Input types - let mut valid_input_types = vec![Vec::new(); metadata.fields.len()]; - for (_, node_io) in implementations.iter() { - for (i, ty) in node_io.inputs.iter().enumerate() { - valid_input_types[i].push(ty.clone()); - } - } - for item in valid_input_types.iter_mut() { - // Dedupe while preserving order - let mut found = HashSet::new(); - *item = item.clone().into_iter().filter(|s| found.insert(s.clone())).collect::>() - } - - // Primary output types - let valid_primary_outputs = implementations.iter().map(|(_, node_io)| node_io.return_value.nested_type().clone()).collect::>(); - let valid_primary_outputs = { - // Dedupe while preserving order - let mut found = HashSet::new(); - valid_primary_outputs.into_iter().filter(|s| found.insert(s.clone())).collect::>() - }; - let valid_primary_outputs = valid_primary_outputs.iter().map(|ty| format!("`{ty}`")).collect::>(); - let valid_primary_outputs = { - // Dedupe while preserving order - let mut found = HashSet::new(); - valid_primary_outputs.into_iter().filter(|s| found.insert(s.clone())).collect::>() - }; - let valid_primary_outputs = valid_primary_outputs.join("
"); - - // ======================= - // NODE: WRITE FRONTMATTER - // ======================= - let order = index + 1; - let content = formatdoc!( - " - +++ - title = \"{name}\" - - [extra] - order = {order} - css = [\"/page/user-manual/node.css\"] - +++ - " - ); - page.write_all(content.as_bytes()).expect("Failed to write to node page file"); - - // ======================= - // NODE: WRITE DESCRIPTION - // ======================= - let content = formatdoc!( - " - - {description} - - ## Interface - " - ); - page.write_all(content.as_bytes()).expect("Failed to write to node page file"); - - // =================== - // NODE: WRITE CONTEXT - // =================== - let extract = context_dependencies.extract; - let inject = context_dependencies.inject; - if !extract.is_empty() || !inject.is_empty() { - let mut context_features = "| | |\n|:-|:-|".to_string(); - if !extract.is_empty() { - let names = extract.iter().map(|ty| format!("`{}`", ty.name())).collect::>().join("
"); - context_features.push_str(&format!("\n| **Reads** | {names} |")); - } - if !inject.is_empty() { - let names = inject.iter().map(|ty| format!("`{}`", ty.name())).collect::>().join("
"); - context_features.push_str(&format!("\n| **Sets** | {names} |")); - } - - let content = formatdoc!( - " - - ### Context - - {context_features} - " - ); - page.write_all(content.as_bytes()).expect("Failed to write to node page file"); - }; - - // ================== - // NODE: WRITE INPUTS - // ================== - let rows = metadata - .fields - .iter() - .enumerate() - .map(|(index, field)| { - // Parameter - let parameter = field.name; - - // Possible types - let mut possible_types_list = valid_input_types.get(index).unwrap_or(&Vec::new()).iter().map(|ty| ty.nested_type()).cloned().collect::>(); - possible_types_list.sort_by_key(|ty| ty.to_string()); - possible_types_list.dedup(); - let mut possible_types = possible_types_list.iter().map(|ty| format!("`{ty}`")).collect::>().join("
"); - if possible_types.is_empty() { - possible_types = "*Any Type*".to_string(); - } - - // Details - let mut details = field - .description - .trim() - .split('\n') - .filter(|line| !line.is_empty()) - .map(|line| format!("

{}

", line.trim())) - .collect::>(); - if index == 0 { - details.push("

*Primary Input*

".to_string()); - } - if field.exposed { - details.push("

*Exposed to the Graph by Default*

".to_string()); - } - if let Some(default_value) = match field.value_source { - RegistryValueSource::None => None, - RegistryValueSource::Scope(scope_name) => { - details.push(format!("

*Sourced From Scope: `{scope_name}`*

")); - None - } - RegistryValueSource::Default(default_value) => Some(default_value.to_string().replace(" :: ", "::")), - } - .or_else(|| { - let ty = field - .default_type - .as_ref() - .or(match possible_types_list.as_slice() { - [single] => Some(single), - _ => None, - })? - .nested_type(); - Some(match () { - () if ty == &concrete!(f32) => f32::default().to_string(), - () if ty == &concrete!(f64) => f64::default().to_string(), - () if ty == &concrete!(u32) => u32::default().to_string(), - () if ty == &concrete!(u64) => u64::default().to_string(), - () if ty == &concrete!(i32) => i32::default().to_string(), - () if ty == &concrete!(i64) => i64::default().to_string(), - () if ty == &concrete!(bool) => bool::default().to_string(), - () if ty == &concrete!(&str) => "\"\"".to_string(), - () if ty == &concrete!(String) => "\"\"".to_string(), - () if ty == &concrete!(Vec) => "[]".to_string(), - () if ty == &concrete!(value::DVec2) => "(0, 0)".to_string(), - () if ty == &concrete!(value::DAffine2) => value::DAffine2::default().to_string(), - () if ty == &concrete!(graphene_std::gradient::GradientStops) => "BLACK_TO_WHITE".to_string(), - _ => return None, - }) - }) { - let default_value = default_value.trim_end_matches('.'); // Remove trailing period on whole-number floats - let render_color = |color| format!(r#""#); - let default_value = match default_value { - "Color::BLACK" => render_color("black"), - "BLACK_TO_WHITE" => render_color("linear-gradient(to right, black, white)"), - _ => format!("`{default_value}{}`", field.unit.unwrap_or_default()), - }; - details.push(format!("

*Default: {default_value}*

")); - } - let details = details.join(""); - - if index == 0 && possible_types_list.as_slice() == [concrete!(())] { - "| - | *No Primary Input* | - |".to_string() - } else { - format!("| {parameter} | {details} | {possible_types} |") - } - }) - .collect::>(); - if !rows.is_empty() { - let rows = rows.join("\n"); - let content = formatdoc!( - " - - ### Inputs - - | Parameter | Details | Possible Types | - |:-|:-|:-| - {rows} - " - ); - page.write_all(content.as_bytes()).expect("Failed to write to node page file"); - } - - // =================== - // NODE: WRITE OUTPUTS - // =================== - let content = formatdoc!( - " - - ### Outputs - - | Product | Details | Possible Types | - |:-|:-|:-| - | Result |

The value produced by the node operation.

*Primary Output*

| {valid_primary_outputs} | - " - ); - page.write_all(content.as_bytes()).expect("Failed to write to node page file"); - } - } - return Ok(()); - } }; let document_string = std::fs::read_to_string(document_path).expect("Failed to read document"); diff --git a/tools/node-docs/Cargo.toml b/tools/node-docs/Cargo.toml new file mode 100644 index 0000000000..385e815074 --- /dev/null +++ b/tools/node-docs/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "node-docs" +description = "Tool to generate node documentation for the node catalog on the Graphite website" +edition.workspace = true +version.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +# Local dependencies +graphene-std = { workspace = true } +interpreted-executor = { workspace = true } +graph-craft = { workspace = true, features = ["loading"] } +preprocessor = { workspace = true } + +# Workspace dependencies +indoc = { workspace = true } +convert_case = { workspace = true } diff --git a/tools/node-docs/src/main.rs b/tools/node-docs/src/main.rs new file mode 100644 index 0000000000..b128778f12 --- /dev/null +++ b/tools/node-docs/src/main.rs @@ -0,0 +1,444 @@ +use convert_case::{Case, Casing}; +use graph_craft::concrete; +use graph_craft::document::value; +use graph_craft::proto::{NodeMetadata, RegistryValueSource}; +use graphene_std::{ContextDependencies, core_types}; +use indoc::formatdoc; +use std::collections::{HashMap, HashSet}; +use std::io::Write; + +const NODE_CATALOG_PATH: &str = "../../website/content/learn/node-catalog"; +const OMIT_HIDDEN: bool = true; + +fn main() { + // TODO: Also obtain document nodes, not only proto nodes + let nodes = graphene_std::registry::NODE_METADATA.lock().unwrap(); + + // Group nodes by category + let mut nodes_by_category: HashMap<_, Vec<_>> = HashMap::new(); + for (id, metadata) in nodes.iter() { + nodes_by_category.entry(metadata.category.to_string()).or_default().push((id, metadata)); + } + + // Sort the categories + let mut categories = nodes_by_category.keys().cloned().collect::>(); + categories.sort(); + + // Create _index.md for the node catalog page + write_catalog_index_page(&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() { + // Get nodes in this category + let mut nodes = nodes_by_category.remove(if !OMIT_HIDDEN && category == "Hidden" { "" } else { category }).unwrap(); + nodes.sort_by_key(|(_, metadata)| metadata.display_name.to_string()); + + // 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}"); + write_category_index_page(index, category, &nodes, &category_path); + + // Create individual node pages + for (index, (id, metadata)) in nodes.into_iter().enumerate() { + write_node_page(index, id, metadata, &category_path); + } + } +} + +fn write_node_page(index: usize, id: &core_types::ProtoNodeIdentifier, metadata: &NodeMetadata, category_path: &String) { + let node_registry = core_types::registry::NODE_REGISTRY.lock().unwrap(); + let Some(implementations) = node_registry.get(id) else { return }; + + // Path to page + let name_url_part = sanitize_path(&metadata.display_name.to_case(Case::Kebab)); + let page_path = format!("{category_path}/{name_url_part}.md"); + let mut page = std::fs::File::create(&page_path).expect("Failed to create node page file"); + + // Context features + let context_features = &metadata.context_features; + let context_dependencies: ContextDependencies = context_features.as_slice().into(); + + // Input types + let mut valid_input_types = vec![Vec::new(); metadata.fields.len()]; + for (_, node_io) in implementations.iter() { + for (i, ty) in node_io.inputs.iter().enumerate() { + valid_input_types[i].push(ty.clone()); + } + } + for item in valid_input_types.iter_mut() { + // Dedupe while preserving order + let mut found = HashSet::new(); + *item = item.clone().into_iter().filter(|s| found.insert(s.clone())).collect::>() + } + + // Primary output types + let valid_primary_outputs = implementations.iter().map(|(_, node_io)| node_io.return_value.nested_type().clone()).collect::>(); + let valid_primary_outputs = { + // Dedupe while preserving order + let mut found = HashSet::new(); + valid_primary_outputs.into_iter().filter(|s| found.insert(s.clone())).collect::>() + }; + let valid_primary_outputs = valid_primary_outputs.iter().map(|ty| format!("`{ty}`")).collect::>(); + let valid_primary_outputs = { + // Dedupe while preserving order + let mut found = HashSet::new(); + valid_primary_outputs.into_iter().filter(|s| found.insert(s.clone())).collect::>() + }; + let valid_primary_outputs = valid_primary_outputs.join("
"); + + // Write sections to the file + write_node_frontmatter(&mut page, metadata, index + 1); + write_node_description(&mut page, metadata); + write_node_interface_header(&mut page); + node_write_context(&mut page, context_dependencies); + node_write_inputs(&mut page, valid_input_types, metadata); + node_write_outputs(page, valid_primary_outputs); +} + +fn write_node_frontmatter(page: &mut std::fs::File, metadata: &NodeMetadata, order: usize) { + let (name, _) = name_and_description(metadata); + + let content = formatdoc!( + " + +++ + title = \"{name}\" + + [extra] + order = {order} + css = [\"/page/user-manual/node.css\"] + +++ + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to node page file"); +} + +fn write_node_description(page: &mut std::fs::File, metadata: &NodeMetadata) { + let (_, description) = name_and_description(metadata); + + let content = formatdoc!( + " + + {description} + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to node page file"); +} + +fn write_node_interface_header(page: &mut std::fs::File) { + let content = formatdoc!( + " + + ## Interface + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to node page file"); +} + +fn node_write_context(page: &mut std::fs::File, context_dependencies: ContextDependencies) { + let extract = context_dependencies.extract; + let inject = context_dependencies.inject; + if !extract.is_empty() || !inject.is_empty() { + let mut context_features = "| | |\n|:-|:-|".to_string(); + if !extract.is_empty() { + let names = extract.iter().map(|ty| format!("`{}`", ty.name())).collect::>().join("
"); + context_features.push_str(&format!("\n| **Reads** | {names} |")); + } + if !inject.is_empty() { + let names = inject.iter().map(|ty| format!("`{}`", ty.name())).collect::>().join("
"); + context_features.push_str(&format!("\n| **Sets** | {names} |")); + } + + let content = formatdoc!( + " + + ### Context + + {context_features} + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to node page file"); + }; +} + +fn node_write_inputs(page: &mut std::fs::File, valid_input_types: Vec>, metadata: &NodeMetadata) { + let rows = metadata + .fields + .iter() + .enumerate() + .map(|(index, field)| { + // Parameter + let parameter = field.name; + + // Possible types + let mut possible_types_list = valid_input_types.get(index).unwrap_or(&Vec::new()).iter().map(|ty| ty.nested_type()).cloned().collect::>(); + possible_types_list.sort_by_key(|ty| ty.to_string()); + possible_types_list.dedup(); + let mut possible_types = possible_types_list.iter().map(|ty| format!("`{ty}`")).collect::>().join("
"); + if possible_types.is_empty() { + possible_types = "*Any Type*".to_string(); + } + + // Details + let mut details = field + .description + .trim() + .split('\n') + .filter(|line| !line.is_empty()) + .map(|line| format!("

{}

", line.trim())) + .collect::>(); + if index == 0 { + details.push("

*Primary Input*

".to_string()); + } + if field.exposed { + details.push("

*Exposed to the Graph by Default*

".to_string()); + } + if let RegistryValueSource::Scope(scope_name) = &field.value_source { + details.push(format!("

*Sourced From Scope: `{scope_name}`*

")); + } + let default_value = match field.value_source { + RegistryValueSource::Default(default_value) => Some(default_value.to_string().replace(" :: ", "::")), + _ => field + .default_type + .as_ref() + .or(match possible_types_list.as_slice() { + [single] => Some(single), + _ => None, + }) + .and_then(|ty| value::TaggedValue::from_type(ty.nested_type())) + .map(|ty| ty.to_debug_string()), + }; + if index > 0 + && !field.exposed + && let Some(default_value) = default_value + { + let default_value = default_value.trim_end_matches('.').trim_end_matches(".0"); // Display whole-number floats as integers + + let render_color = |color| format!(r#""#); + let default_value = match default_value { + "Color::BLACK" => render_color("black"), + "BLACK_TO_WHITE" => render_color("linear-gradient(to right, black, white)"), + _ => format!("`{default_value}{}`", field.unit.unwrap_or_default()), + }; + + details.push(format!("

*Default:* {default_value}

")); + } + let details = details.join(""); + + if index == 0 && possible_types_list.as_slice() == [concrete!(())] { + "| - | *No Primary Input* | - |".to_string() + } else { + format!("| {parameter} | {details} | {possible_types} |") + } + }) + .collect::>(); + if !rows.is_empty() { + let rows = rows.join("\n"); + let content = formatdoc!( + " + + ### Inputs + + | Parameter | Details | Possible Types | + |:-|:-|:-| + {rows} + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to node page file"); + } +} + +fn node_write_outputs(mut page: std::fs::File, valid_primary_outputs: String) { + let content = formatdoc!( + " + + ### Outputs + + | Product | Details | Possible Types | + |:-|:-|:-| + | Result |

The value produced by the node operation.

*Primary Output*

| {valid_primary_outputs} | + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to node page file"); +} + +fn write_category_index_page(index: usize, category: &str, nodes: &[(&core_types::ProtoNodeIdentifier, &NodeMetadata)], category_path: &String) { + std::fs::create_dir_all(category_path).expect("Failed to create category directory"); + let page_path = format!("{category_path}/_index.md"); + let mut page = std::fs::File::create(&page_path).expect("Failed to create index file"); + + // Write frontmatter + let order = index + 1; + let content = formatdoc!( + " + +++ + title = \"{category}\" + template = \"book.html\" + page_template = \"book.html\" + + [extra] + order = {order} + css = [\"/page/user-manual/node-category.css\"] + +++ + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); + + // Write description + let content = formatdoc!( + " + + This is the {category} category of nodes. + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); + + // Write nodes table header + let content = formatdoc!( + " + + ## Nodes + + | Node | Details | Possible Types | + |:-|:-|:-| + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); + + // Write nodes table rows + let content = nodes + .iter() + .filter_map(|&(id, metadata)| { + // Path to page + let name_url_part = sanitize_path(&metadata.display_name.to_case(Case::Kebab)); + + // Name and description + let (name, description) = name_and_description(metadata); + let details = description.split('\n').map(|line| format!("

{}

", line.trim())).collect::>().join(""); + + // Possible types + let node_registry = core_types::registry::NODE_REGISTRY.lock().unwrap(); + let implementations = node_registry.get(id)?; + let valid_primary_inputs_to_outputs = implementations + .iter() + .map(|(_, node_io)| { + format!( + "`{} → {}`", + node_io + .inputs + .first() + .map(|t| t.nested_type()) + .filter(|&t| t != &concrete!(())) + .map(ToString::to_string) + .unwrap_or_default(), + node_io.return_value.nested_type() + ) + }) + .collect::>(); + let valid_primary_inputs_to_outputs = { + // Dedupe while preserving order + let mut found = HashSet::new(); + valid_primary_inputs_to_outputs.into_iter().filter(|s| found.insert(s.clone())).collect::>() + }; + let possible_types = valid_primary_inputs_to_outputs.join("
"); + + // Add table row + Some(format!("| [{name}]({name_url_part}) | {details} | {possible_types} |")) + }) + .collect::>() + .join("\n"); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); +} + +fn sanitize_path(s: &str) -> String { + // Replace disallowed characters with a dash + let allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~[]@!$&'()*+,;="; + let filtered = s.chars().map(|c| if allowed_characters.contains(c) { c } else { '-' }).collect::(); + + // Fix letter-number type names + let mut filtered = format!("-{filtered}-"); + filtered = filtered.replace("-vec-2-", "-vec2-"); + filtered = filtered.replace("-f-32-", "-f32-"); + filtered = filtered.replace("-f-64-", "-f64-"); + filtered = filtered.replace("-u-32-", "-u32-"); + filtered = filtered.replace("-u-64-", "-u64-"); + filtered = filtered.replace("-i-32-", "-i32-"); + filtered = filtered.replace("-i-64-", "-i64-"); + + // Remove consecutive dashes + while filtered.contains("--") { + filtered = filtered.replace("--", "-"); + } + + // Trim leading and trailing dashes + filtered.trim_matches('-').to_string() +} + +fn name_and_description(metadata: &NodeMetadata) -> (&str, &str) { + let name = metadata.display_name; + let mut description = metadata.description.trim(); + if description.is_empty() { + description = "*Node description coming soon.*"; + } + (name, description) +} + +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"); + } + std::fs::create_dir_all(NODE_CATALOG_PATH).expect("Failed to create node catalog directory"); + let page_path = format!("{NODE_CATALOG_PATH}/_index.md"); + let mut page = std::fs::File::create(&page_path).expect("Failed to create index file"); + + // Write frontmatter + let content = formatdoc!( + " + +++ + title = \"Node catalog\" + template = \"book.html\" + page_template = \"book.html\" + + [extra] + order = 3 + css = [\"/page/user-manual/node-catalog.css\"] + +++ + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); + + // Write description + let content = formatdoc!( + " + + The node catalog documents all of the nodes available in Graphite's node graph system, organized by category. + +

\"Terminology

+ " + ); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); + + // Write node categories table header + let content = formatdoc!( + " + + ## Node categories + + | Category | Details | + |:-|:-| + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); + + // Write node categories table rows + let content = categories + .iter() + .filter_map(|c| if c.is_empty() { if OMIT_HIDDEN { None } else { Some("Hidden") } } else { Some(c) }) + .map(|category| { + let category_path_part = sanitize_path(&category.to_case(Case::Kebab)); + let details = format!("This is the {category} category of nodes."); + format!("| [{category}](./{category_path_part}) | {details} |") + }) + .collect::>() + .join("\n"); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); +} diff --git a/website/content/learn/node-catalog-example/_index.md b/website/content/learn/node-catalog-example/_index.md deleted file mode 100644 index 1a845ebf98..0000000000 --- a/website/content/learn/node-catalog-example/_index.md +++ /dev/null @@ -1,17 +0,0 @@ -+++ -title = "Node catalog example" -template = "book.html" -page_template = "book.html" - -[extra] -order = 4 -css = ["/page/user-manual/node-catalog.css"] -+++ - -The node catalog documents all of the nodes available in Graphite's node graph system, organized by category. - -## Node categories - -| Category | Details | -|:-|:-| -| [Vector: Style](./vector-style) |

Nodes in this category apply styling effects to vector graphics, such as controlling stroke (outline) and fill properties.

| diff --git a/website/content/learn/node-catalog-example/vector-style/_index.md b/website/content/learn/node-catalog-example/vector-style/_index.md deleted file mode 100644 index 807e05de68..0000000000 --- a/website/content/learn/node-catalog-example/vector-style/_index.md +++ /dev/null @@ -1,19 +0,0 @@ -+++ -title = "Vector: Style" -template = "book.html" -page_template = "book.html" - -[extra] -order = 1 -css = ["/page/user-manual/node-category.css"] -+++ - -Nodes in this category apply styling effects to vector graphics, such as controlling stroke (outline) and fill properties. - -## Nodes - -| Node | Details | Possible Types | -|:-|:-|:-| -| [Assign Colors](./assign-colors) |

Uniquely sets the fill and/or stroke style of every vector element to individual colors sampled along a chosen gradient.

| `Table → Table`
`Table → Table` | -| [Fill](./fill) |

Applies a fill style to the vector content, giving an appearance to the area within the interior of the geometry.

| `Table → Table`
`Table → Table` | -| [Stroke](./stroke) |

Applies a stroke style to the vector content, giving an appearance to the area within the outline of the geometry.

| `Table → Table`
`Table → Table` | diff --git a/website/content/learn/node-catalog-example/vector-style/assign-colors.md b/website/content/learn/node-catalog-example/vector-style/assign-colors.md deleted file mode 100644 index daaa3bb88d..0000000000 --- a/website/content/learn/node-catalog-example/vector-style/assign-colors.md +++ /dev/null @@ -1,30 +0,0 @@ -+++ -title = "Assign Colors" - -[extra] -order = 1 -css = ["/page/user-manual/node.css"] -+++ - -Uniquely sets the fill and/or stroke style of every vector element to individual colors sampled along a chosen gradient. - -## Interface - -### Inputs - -| Parameter | Details | Possible Types | -|:-|:-|:-| -| Content |

The content with vector paths to apply the fill and/or stroke style to.

Primary Input

| `Table`
`Table` | -| Fill |

Whether to style the fill.

Default: `true`

| `bool` | -| Stroke |

Whether to style the stroke.

Default: `false`

| `bool` | -| Gradient |

The range of colors to select from.

Default: 

| `GradientStops` | -| Reverse |

Whether to reverse the gradient.

Default: `false`

| `bool` | -| Randomize |

Whether to randomize the color selection for each element from throughout the gradient.

Default: `false`

| `bool` | -| Seed |

The seed used for randomization.

Seed to determine unique variations on the randomized color selection.

Default: `0`

| `SeedValue` | -| Repeat Every |

The number of elements to span across the gradient before repeating. A 0 value will span the entire gradient once.

Default: `0`

| `u32` | - -### Outputs - -| Product | Details | Possible Types | -|:-|:-|:-| -| Result |

The vector content with the fill and/or stroke styles applied.

Primary Output

| `Table`
`Table` | diff --git a/website/content/learn/node-catalog-example/vector-style/fill.md b/website/content/learn/node-catalog-example/vector-style/fill.md deleted file mode 100644 index 1e4a4f4537..0000000000 --- a/website/content/learn/node-catalog-example/vector-style/fill.md +++ /dev/null @@ -1,24 +0,0 @@ -+++ -title = "Fill" - -[extra] -order = 2 -css = ["/page/user-manual/node.css"] -+++ - -Applies a fill style to the vector content, giving an appearance to the area within the interior of the geometry. - -## Interface - -### Inputs - -| Parameter | Details | Possible Types | -|:-|:-|:-| -| Content |

The content with vector paths to apply the fill style to.

Primary Input

| `Table`
`Table` | -| Fill |

The fill to paint the path with.

Default: 

| `Fill`
`Gradient`
`Table`
`Table` | - -### Outputs - -| Product | Details | Possible Types | -|:-|:-|:-| -| Result |

The vector content with the fill style applied.

Primary Output

| `Table`
`Table` | diff --git a/website/content/learn/node-catalog-example/vector-style/stroke.md b/website/content/learn/node-catalog-example/vector-style/stroke.md deleted file mode 100644 index dc7584831f..0000000000 --- a/website/content/learn/node-catalog-example/vector-style/stroke.md +++ /dev/null @@ -1,32 +0,0 @@ -+++ -title = "Stroke" - -[extra] -order = 3 -css = ["/page/user-manual/node.css"] -+++ - -Applies a stroke style to the vector content, giving an appearance to the area within the outline of the geometry. - -## Interface - -### Inputs - -| Parameter | Details | Possible Types | -|:-|:-|:-| -| Content |

The content with vector paths to apply the stroke style to.

Primary Input

| `Table`
`Table` | -| Color |

The stroke color.

Default: 

| `Table` | -| Weight |

The stroke thickness.

Default: `2 px`

| `f64` | -| Align |

The alignment of stroke to the path's centerline or (for closed shapes) the inside or outside of the shape.

Default: `Center`

| `StrokeAlign` | -| Cap |

The shape of the stroke at open endpoints.

Default: `Butt`

| `StrokeCap` | -| Join |

The curvature of the bent stroke at sharp corners.

Default: `Miter`

| `StrokeJoin` | -| Miter Limit |

The threshold for when a miter-joined stroke is converted to a bevel-joined stroke when a sharp angle becomes pointier than this ratio.

Default: `4`

| `f64` | -| Paint Order |

The order to paint the stroke on top of the fill, or the fill on top of the stroke.

Default: `StrokeAbove`

| `PaintOrder` | -| Dash Lengths |

The stroke dash lengths. Each length forms a distance in a pattern where the first length is a dash, the second is a gap, and so on. If the list is an odd length, the pattern repeats with solid-gap roles reversed.

Default: `[]`

| `Vec`
`f64`
`String` | -| Dash Offset |

The phase offset distance from the starting point of the dash pattern.

Default: `0 px`

| `f64` | - -### Outputs - -| Product | Details | Possible Types | -|:-|:-|:-| -| Result |

The vector content with the stroke style applied.

Primary Output

| `Table`
`Table` | diff --git a/website/sass/base.scss b/website/sass/base.scss index bd53752526..c702e378f6 100644 --- a/website/sass/base.scss +++ b/website/sass/base.scss @@ -496,6 +496,10 @@ table { margin: 0; padding: 20px; + p { + text-align: left; + } + &:first-child { padding-left: 10px; } From e9cf05712ee3e143ef9629e6bab5e46c7e397925 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 19 Jan 2026 04:58:26 -0800 Subject: [PATCH 4/9] Add category descriptions --- .../node_graph/document_node_definitions.rs | 2 +- .../libraries/core-types/src/registry.rs | 1 + node-graph/node-macro/src/codegen.rs | 3 + node-graph/nodes/blending/src/lib.rs | 6 +- node-graph/nodes/brush/src/brush.rs | 2 +- node-graph/nodes/math/src/lib.rs | 2 +- node-graph/nodes/raster/src/adjustments.rs | 2 +- node-graph/nodes/vector/src/vector_nodes.rs | 6 +- tools/node-docs/src/main.rs | 75 ++++++++++++++++--- 9 files changed, 77 insertions(+), 22 deletions(-) diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index 91e244dca7..f24d8396c7 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -1773,7 +1773,7 @@ fn document_node_definitions() -> HashMap>(); + let input_descriptions: Vec<_> = regular_fields.iter().map(|f| &f.description).collect(); // Generate struct fields: data fields (concrete types) + regular fields (generic types) @@ -478,6 +480,7 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn name: #input_names, widget_override: #widget_override, description: #input_descriptions, + hidden: #input_hidden, exposed: #exposed, value_source: #value_sources, default_type: #default_types, diff --git a/node-graph/nodes/blending/src/lib.rs b/node-graph/nodes/blending/src/lib.rs index a770c85b5e..c277fc9b44 100644 --- a/node-graph/nodes/blending/src/lib.rs +++ b/node-graph/nodes/blending/src/lib.rs @@ -176,7 +176,7 @@ impl SetClip for Table { } /// Applies the blend mode to the input graphics. Setting this allows for customizing how overlapping content is composited together. -#[node_macro::node(category("Style"))] +#[node_macro::node(category("Blending"))] fn blend_mode( _: impl Ctx, /// The layer stack that will be composited when rendering. @@ -198,7 +198,7 @@ fn blend_mode( /// Modifies the opacity of the input graphics by multiplying the existing opacity by this percentage. /// This affects the transparency of the content (together with anything above which is clipped to it). -#[node_macro::node(category("Style"))] +#[node_macro::node(category("Blending"))] fn opacity( _: impl Ctx, /// The layer stack that will be composited when rendering. @@ -221,7 +221,7 @@ fn opacity( } /// Sets each of the blending properties at once. The blend mode determines how overlapping content is composited together. The opacity affects the transparency of the content (together with anything above which is clipped to it). The fill affects the transparency of the content itself, without affecting that of content clipped to it. The clip property determines whether the content inherits the alpha of the content beneath it. -#[node_macro::node(category("Style"))] +#[node_macro::node(category("Blending"))] fn blending( _: impl Ctx, /// The layer stack that will be composited when rendering. diff --git a/node-graph/nodes/brush/src/brush.rs b/node-graph/nodes/brush/src/brush.rs index 219bf8b4fa..0716ca25a5 100644 --- a/node-graph/nodes/brush/src/brush.rs +++ b/node-graph/nodes/brush/src/brush.rs @@ -184,7 +184,7 @@ pub fn blend_with_mode(background: TableRow>, foreground: TableRow( #[expose] #[implementations(DVec2, DVec2, DAffine2, DAffine2)] target: U, - /// Whether the resulting angle should be given in as radians instead of degrees. + /// Whether the resulting angle should be given in radians instead of degrees. radians: bool, ) -> f64 { let from = observer.to_position(); diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index 412476bb96..0b3a0b0af8 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -73,7 +73,7 @@ fn luminance>( input } -#[node_macro::node(category("Raster"), shader_node(PerPixelAdjust))] +#[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))] fn gamma_correction>( _: impl Ctx, #[implementations( diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index de75778159..b68067d789 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -1200,7 +1200,7 @@ async fn separate_subpaths(_: impl Ctx, content: Table) -> Table .collect() } -#[node_macro::node(category("Vector: Modifier"), path(graphene_core::vector))] +#[node_macro::node(category("Vector"), path(graphene_core::vector))] fn instance_vector(ctx: impl Ctx + ExtractVarArgs) -> Table { let Ok(var_arg) = ctx.vararg(0) else { return Default::default() }; let var_arg = var_arg as &dyn std::any::Any; @@ -1208,7 +1208,7 @@ fn instance_vector(ctx: impl Ctx + ExtractVarArgs) -> Table { var_arg.downcast_ref().cloned().unwrap_or_default() } -#[node_macro::node(category("Vector: Modifier"), path(graphene_core::vector))] +#[node_macro::node(category("Vector"), path(graphene_core::vector))] async fn instance_map(ctx: impl Ctx + CloneVarArgs + ExtractAll, content: Table, mapped: impl Node, Output = Table>) -> Table { let mut rows = Vec::new(); @@ -2266,7 +2266,7 @@ async fn count_points(_: impl Ctx, content: Table) -> f64 { /// Retrieves the vec2 position (in local space) of the anchor point at the specified index in table of vector elements. /// If no value exists at that index, the position (0, 0) is returned. -#[node_macro::node(category("Vector"), path(graphene_core::vector))] +#[node_macro::node(category("Vector: Measure"), path(graphene_core::vector))] async fn index_points( _: impl Ctx, /// The vector element or elements containing the anchor points to be retrieved. diff --git a/tools/node-docs/src/main.rs b/tools/node-docs/src/main.rs index b128778f12..40c92e7623 100644 --- a/tools/node-docs/src/main.rs +++ b/tools/node-docs/src/main.rs @@ -3,7 +3,7 @@ use graph_craft::concrete; use graph_craft::document::value; use graph_craft::proto::{NodeMetadata, RegistryValueSource}; use graphene_std::{ContextDependencies, core_types}; -use indoc::formatdoc; +use indoc::{formatdoc, indoc}; use std::collections::{HashMap, HashSet}; use std::io::Write; @@ -96,7 +96,7 @@ fn write_node_page(index: usize, id: &core_types::ProtoNodeIdentifier, metadata: } fn write_node_frontmatter(page: &mut std::fs::File, metadata: &NodeMetadata, order: usize) { - let (name, _) = name_and_description(metadata); + let name = metadata.display_name; let content = formatdoc!( " @@ -113,7 +113,7 @@ fn write_node_frontmatter(page: &mut std::fs::File, metadata: &NodeMetadata, ord } fn write_node_description(page: &mut std::fs::File, metadata: &NodeMetadata) { - let (_, description) = name_and_description(metadata); + let description = node_description(metadata); let content = formatdoc!( " @@ -165,6 +165,7 @@ fn node_write_inputs(page: &mut std::fs::File, valid_input_types: Vec"#); let default_value = match default_value { "Color::BLACK" => render_color("black"), - "BLACK_TO_WHITE" => render_color("linear-gradient(to right, black, white)"), + "GradientStops([(0.0, Color { red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0 }), (1.0, Color { red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0 })])" => { + render_color("linear-gradient(to right, black, white)") + } _ => format!("`{default_value}{}`", field.unit.unwrap_or_default()), }; @@ -248,6 +251,12 @@ fn node_write_inputs(page: &mut std::fs::File, valid_input_types: Vec{details}

"); + details.push_str("

*Primary Output*

"); + let content = formatdoc!( " @@ -255,7 +264,7 @@ fn node_write_outputs(mut page: std::fs::File, valid_primary_outputs: String) { | Product | Details | Possible Types | |:-|:-|:-| - | Result |

The value produced by the node operation.

*Primary Output*

| {valid_primary_outputs} | + | {product} | {details} | {valid_primary_outputs} | " ); page.write_all(content.as_bytes()).expect("Failed to write to node page file"); @@ -284,10 +293,11 @@ fn write_category_index_page(index: usize, category: &str, nodes: &[(&core_types page.write_all(content.as_bytes()).expect("Failed to write to index file"); // Write description + let category_description = category_description(category); let content = formatdoc!( " - This is the {category} category of nodes. + {category_description} " ); page.write_all(content.as_bytes()).expect("Failed to write to index file"); @@ -312,7 +322,8 @@ fn write_category_index_page(index: usize, category: &str, nodes: &[(&core_types let name_url_part = sanitize_path(&metadata.display_name.to_case(Case::Kebab)); // Name and description - let (name, description) = name_and_description(metadata); + let name = metadata.display_name; + let description = node_description(metadata); let details = description.split('\n').map(|line| format!("

{}

", line.trim())).collect::>().join(""); // Possible types @@ -373,13 +384,53 @@ fn sanitize_path(s: &str) -> String { filtered.trim_matches('-').to_string() } -fn name_and_description(metadata: &NodeMetadata) -> (&str, &str) { - let name = metadata.display_name; +fn node_description(metadata: &NodeMetadata) -> &str { let mut description = metadata.description.trim(); if description.is_empty() { description = "*Node description coming soon.*"; } - (name, description) + description +} + +fn category_description(category: &str) -> &str { + match category { + "Animation" => indoc!( + " + Nodes in this category enable the creation of animated, real-time, and interactive motion graphics involving paramters that change over time. + + These nodes require that playback is activated by pressing the play button above the viewport. + " + ), + "Blending" => "Nodes in this category control how overlapping graphical content is composited together, considering blend modes, opacity, and clipping.", + "Color" => "Nodes in this category deal with selecting and manipulating colors, gradients, and palettes.", + "Debug" => indoc!( + " + Nodes in this category are temporarily included for debugging purposes by Graphite's developers. They may have rare potential uses for advanced users, but are not intended for general use and will be removed in future releases. + " + ), + "General" => "Nodes in this category deal with general data handling, such as merging and flattening graphical elements.", + "Instancing" => "Nodes in this category enable the duplication, arrangement, and looped generation of graphical elements.", + "Math: Arithmetic" => "Nodes in this category perform common arithmetic operations on numerical values (and where applicable, `vec2` values).", + "Math: Logic" => "Nodes in this category perform boolean logic operations such as comparisons, conditionals, logic gates, and switching.", + "Math: Numeric" => "Nodes in this category perform discontinuous numeric operations such as rounding, clamping, mapping, and randomization.", + "Math: Transform" => "Nodes in this category perform transformations on graphical elements and calculations involving transformation matrices.", + "Math: Trig" => "Nodes in this category perform trigonometric operations such as sine, cosine, tangent, and their inverses.", + "Math: Vector" => "Nodes in this category perform operations involving `vec2` values (points or arrows in 2D space) such as the dot product, normalization, and distance calculations.", + "Raster: Adjustment" => "Nodes in this category perform per-pixel color adjustments on raster graphics, such as brightness and contrast modifications.", + "Raster: Channels" => "Nodes in this category enable channel-specific manipulation of the RGB and alpha channels of raster graphics.", + "Raster: Filter" => "Nodes in this category apply filtering effects to raster graphics such as blurs and sharpening.", + "Raster: Pattern" => "Nodes in this category generate procedural raster patterns, fractals, textures, and noise.", + "Raster" => "Nodes in this category deal with fundamental raster image operations.", + "Text" => "Nodes in this category support the manipulation, formatting, and rendering of text strings.", + "Value" => "Nodes in this category supply data values of common types such as numbers, colors, booleans, and strings.", + "Vector: Measure" => "Nodes in this category perform measurements and analysis on vector graphics, such as length/area calculations, path traversal, and hit testing.", + "Vector: Modifier" => "Nodes in this category modify the geometry of vector graphics, such as boolean operations, smoothing, and morphing.", + "Vector: Shape" => "Nodes in this category generate parametrically-described primitive vector shapes such as rectangles, grids, stars, and spirals.", + "Vector: Style" => "Nodes in this category apply fill and stroke styles to alter the appearance of vector graphics.", + "Vector" => "Nodes in this category deal with fundamental vector graphics data handling and operations.", + "Web Request" => "Nodes in this category facilitate fetching and handling resources from HTTP endpoints and sending webhook requests to external services.", + _ => panic!("Category '{category}' is missing a description"), + }.trim() } fn write_catalog_index_page(categories: &[String]) { @@ -435,8 +486,8 @@ fn write_catalog_index_page(categories: &[String]) { .filter_map(|c| if c.is_empty() { if OMIT_HIDDEN { None } else { Some("Hidden") } } else { Some(c) }) .map(|category| { let category_path_part = sanitize_path(&category.to_case(Case::Kebab)); - let details = format!("This is the {category} category of nodes."); - format!("| [{category}](./{category_path_part}) | {details} |") + let details = category_description(category).replace("\n\n", "

").replace('\n', "
"); + format!("| [{category}](./{category_path_part}) |

{details}

|") }) .collect::>() .join("\n"); From 960dec58161d54d4a04003dbed3aedd50654e6dd Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 19 Jan 2026 16:34:01 -0800 Subject: [PATCH 5/9] Organize file structure and improve type naming --- Cargo.toml | 12 +- .../node_graph/document_node_definitions.rs | 3 + editor/src/node_graph_executor/runtime.rs | 4 +- .../interpreted-executor/src/node_registry.rs | 4 +- node-graph/libraries/core-types/src/types.rs | 5 +- tools/node-docs/src/main.rs | 472 +----------------- tools/node-docs/src/page_catalog.rs | 74 +++ tools/node-docs/src/page_category.rs | 104 ++++ tools/node-docs/src/page_node.rs | 249 +++++++++ tools/node-docs/src/utility.rs | 78 +++ website/templates/book.html | 2 +- 11 files changed, 534 insertions(+), 473 deletions(-) create mode 100644 tools/node-docs/src/page_catalog.rs create mode 100644 tools/node-docs/src/page_category.rs create mode 100644 tools/node-docs/src/page_node.rs create mode 100644 tools/node-docs/src/utility.rs diff --git a/Cargo.toml b/Cargo.toml index 55f7809008..b2fd92d7af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -153,7 +153,7 @@ wgpu = { version = "27.0", features = [ "spirv", "strict_asserts", ] } -once_cell = "1.13" # Remove when `core::cell::LazyCell` () is stabilized in Rust 1.80 and we bump our MSRV +once_cell = "1.13" # Remove and replace with `core::cell::LazyCell` () wasm-bindgen = "=0.2.100" # NOTICE: ensure this stays in sync with the `wasm-bindgen-cli` version in `website/content/volunteer/guide/project-setup/_index.md`. We pin this version because wasm-bindgen upgrades may break various things. wasm-bindgen-futures = "0.4" js-sys = "=0.3.77" @@ -179,10 +179,16 @@ winit = { git = "https://github.com/rust-windowing/winit.git" } keyboard-types = "0.8" url = "2.5" tokio = { version = "1.29", features = ["fs", "macros", "io-std", "rt"] } +# Linebender ecosystem (BEGIN) +kurbo = { version = "0.12", features = ["serde"] } vello = { git = "https://github.com/linebender/vello" } vello_encoding = { git = "https://github.com/linebender/vello" } resvg = "0.45" usvg = "0.45" +parley = "0.6" +skrifa = "0.36" +polycool = "0.4" +# Linebender ecosystem (END) rand = { version = "0.9", default-features = false, features = ["std_rng"] } rand_chacha = "0.9" glam = { version = "0.29", default-features = false, features = [ @@ -196,8 +202,6 @@ image = { version = "0.25", default-features = false, features = [ "jpeg", "bmp", ] } -parley = "0.6" -skrifa = "0.36" pretty_assertions = "1.4" fern = { version = "0.7", features = ["colored"] } num_enum = { version = "0.7", default-features = false } @@ -219,7 +223,6 @@ syn = { version = "2.0", default-features = false, features = [ "extra-traits", "proc-macro", ] } -kurbo = { version = "0.12", features = ["serde"] } lyon_geom = "1.0" petgraph = { version = "0.7", default-features = false, features = ["graphmap"] } half = { version = "2.4", default-features = false, features = ["bytemuck"] } @@ -236,7 +239,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing = "0.1" rfd = "0.15" open = "5.3" -polycool = "0.4" spin = "0.10" clap = "4.5" spirv-std = { git = "https://github.com/Firestar99/rust-gpu-new", rev = "c12f216121820580731440ee79ebc7403d6ea04f", features = ["bytemuck"] } diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index f24d8396c7..1f72162800 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -124,6 +124,7 @@ pub struct DocumentNodeDefinition { // We use the once_cell to use the document node definitions throughout the editor without passing a reference // TODO: If dynamic node library is required, use a Mutex as well +// TODO: Replace with `core::cell::LazyCell` () or similar static DOCUMENT_NODE_TYPES: once_cell::sync::Lazy> = once_cell::sync::Lazy::new(document_node_definitions); /// Defines the "signature" or "header file"-like metadata for the document nodes, but not the implementation (which is defined in the node registry). @@ -2076,6 +2077,7 @@ fn document_node_definitions() -> HashMap Vec + Send + Sync>>; +// TODO: Replace with `core::cell::LazyCell` () or similar pub static NODE_OVERRIDES: once_cell::sync::Lazy = once_cell::sync::Lazy::new(static_node_properties); /// Defines the logic for inputs to display a custom properties panel widget. @@ -2102,6 +2104,7 @@ fn static_node_properties() -> NodeProperties { type InputProperties = HashMap Result, String> + Send + Sync>>; +// TODO: Replace with `core::cell::LazyCell` () or similar static INPUT_OVERRIDES: once_cell::sync::Lazy = once_cell::sync::Lazy::new(static_input_properties); /// Defines the logic for inputs to display a custom properties panel widget. diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index 49f99fde0e..bef56a57b6 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -22,7 +22,6 @@ use graphene_std::wasm_application_io::{RenderOutputType, WasmApplicationIo, Was use graphene_std::{Artboard, Context, Graphic}; use interpreted_executor::dynamic_executor::{DynamicExecutor, IntrospectError, ResolvedDocumentNodeTypesDelta}; use interpreted_executor::util::wrap_network_in_scope; -use once_cell::sync::Lazy; use spin::Mutex; use std::sync::Arc; use std::sync::mpsc::{Receiver, Sender}; @@ -108,7 +107,8 @@ impl NodeGraphUpdateSender for InternalNodeGraphUpdateSender { } } -pub static NODE_RUNTIME: Lazy>> = Lazy::new(|| Mutex::new(None)); +// TODO: Replace with `core::cell::LazyCell` () or similar +pub static NODE_RUNTIME: once_cell::sync::Lazy>> = once_cell::sync::Lazy::new(|| Mutex::new(None)); impl NodeRuntime { pub fn new(receiver: Receiver, sender: Sender) -> Self { diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index a7d7c3ffa1..d98adf2066 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -23,7 +23,6 @@ use graphene_std::wasm_application_io::WasmEditorApi; use graphene_std::wasm_application_io::WasmSurfaceHandle; use graphene_std::{Artboard, Context, Graphic, NodeIO, NodeIOTypes, ProtoNodeIdentifier, concrete, fn_type_fut, future}; use node_registry_macros::{async_node, convert_node, into_node}; -use once_cell::sync::Lazy; use std::collections::HashMap; #[cfg(feature = "gpu")] use std::sync::Arc; @@ -282,7 +281,8 @@ fn node_registry() -> HashMap>> = Lazy::new(|| node_registry()); +// TODO: Replace with `core::cell::LazyCell` () or similar +pub static NODE_REGISTRY: once_cell::sync::Lazy>> = once_cell::sync::Lazy::new(|| node_registry()); mod node_registry_macros { macro_rules! async_node { diff --git a/node-graph/libraries/core-types/src/types.rs b/node-graph/libraries/core-types/src/types.rs index 0b24927325..cdef025ae4 100644 --- a/node-graph/libraries/core-types/src/types.rs +++ b/node-graph/libraries/core-types/src/types.rs @@ -353,7 +353,10 @@ pub fn format_type(ty: &str) -> String { } pub fn make_type_user_readable(ty: &str) -> String { - ty.replace("Option>", "Context").replace("Vector>>", "Vector") + ty.replace("Option>", "Context") + .replace("Vector>>", "Vector") + .replace("Raster", "Raster") + .replace("Raster", "Raster") } impl std::fmt::Debug for Type { diff --git a/tools/node-docs/src/main.rs b/tools/node-docs/src/main.rs index 40c92e7623..73c4944e44 100644 --- a/tools/node-docs/src/main.rs +++ b/tools/node-docs/src/main.rs @@ -1,14 +1,11 @@ -use convert_case::{Case, Casing}; -use graph_craft::concrete; -use graph_craft::document::value; -use graph_craft::proto::{NodeMetadata, RegistryValueSource}; -use graphene_std::{ContextDependencies, core_types}; -use indoc::{formatdoc, indoc}; -use std::collections::{HashMap, HashSet}; -use std::io::Write; +mod page_catalog; +mod page_category; +mod page_node; +mod utility; -const NODE_CATALOG_PATH: &str = "../../website/content/learn/node-catalog"; -const OMIT_HIDDEN: bool = true; +use crate::utility::*; +use convert_case::{Case, Casing}; +use std::collections::HashMap; fn main() { // TODO: Also obtain document nodes, not only proto nodes @@ -25,7 +22,7 @@ fn main() { categories.sort(); // Create _index.md for the node catalog page - write_catalog_index_page(&categories); + page_catalog::write_catalog_index_page(&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() { @@ -36,460 +33,11 @@ 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}"); - write_category_index_page(index, category, &nodes, &category_path); + page_category::write_category_index_page(index, category, &nodes, &category_path); // Create individual node pages for (index, (id, metadata)) in nodes.into_iter().enumerate() { - write_node_page(index, id, metadata, &category_path); + page_node::write_node_page(index, id, metadata, &category_path); } } } - -fn write_node_page(index: usize, id: &core_types::ProtoNodeIdentifier, metadata: &NodeMetadata, category_path: &String) { - let node_registry = core_types::registry::NODE_REGISTRY.lock().unwrap(); - let Some(implementations) = node_registry.get(id) else { return }; - - // Path to page - let name_url_part = sanitize_path(&metadata.display_name.to_case(Case::Kebab)); - let page_path = format!("{category_path}/{name_url_part}.md"); - let mut page = std::fs::File::create(&page_path).expect("Failed to create node page file"); - - // Context features - let context_features = &metadata.context_features; - let context_dependencies: ContextDependencies = context_features.as_slice().into(); - - // Input types - let mut valid_input_types = vec![Vec::new(); metadata.fields.len()]; - for (_, node_io) in implementations.iter() { - for (i, ty) in node_io.inputs.iter().enumerate() { - valid_input_types[i].push(ty.clone()); - } - } - for item in valid_input_types.iter_mut() { - // Dedupe while preserving order - let mut found = HashSet::new(); - *item = item.clone().into_iter().filter(|s| found.insert(s.clone())).collect::>() - } - - // Primary output types - let valid_primary_outputs = implementations.iter().map(|(_, node_io)| node_io.return_value.nested_type().clone()).collect::>(); - let valid_primary_outputs = { - // Dedupe while preserving order - let mut found = HashSet::new(); - valid_primary_outputs.into_iter().filter(|s| found.insert(s.clone())).collect::>() - }; - let valid_primary_outputs = valid_primary_outputs.iter().map(|ty| format!("`{ty}`")).collect::>(); - let valid_primary_outputs = { - // Dedupe while preserving order - let mut found = HashSet::new(); - valid_primary_outputs.into_iter().filter(|s| found.insert(s.clone())).collect::>() - }; - let valid_primary_outputs = valid_primary_outputs.join("
"); - - // Write sections to the file - write_node_frontmatter(&mut page, metadata, index + 1); - write_node_description(&mut page, metadata); - write_node_interface_header(&mut page); - node_write_context(&mut page, context_dependencies); - node_write_inputs(&mut page, valid_input_types, metadata); - node_write_outputs(page, valid_primary_outputs); -} - -fn write_node_frontmatter(page: &mut std::fs::File, metadata: &NodeMetadata, order: usize) { - let name = metadata.display_name; - - let content = formatdoc!( - " - +++ - title = \"{name}\" - - [extra] - order = {order} - css = [\"/page/user-manual/node.css\"] - +++ - " - ); - page.write_all(content.as_bytes()).expect("Failed to write to node page file"); -} - -fn write_node_description(page: &mut std::fs::File, metadata: &NodeMetadata) { - let description = node_description(metadata); - - let content = formatdoc!( - " - - {description} - " - ); - page.write_all(content.as_bytes()).expect("Failed to write to node page file"); -} - -fn write_node_interface_header(page: &mut std::fs::File) { - let content = formatdoc!( - " - - ## Interface - " - ); - page.write_all(content.as_bytes()).expect("Failed to write to node page file"); -} - -fn node_write_context(page: &mut std::fs::File, context_dependencies: ContextDependencies) { - let extract = context_dependencies.extract; - let inject = context_dependencies.inject; - if !extract.is_empty() || !inject.is_empty() { - let mut context_features = "| | |\n|:-|:-|".to_string(); - if !extract.is_empty() { - let names = extract.iter().map(|ty| format!("`{}`", ty.name())).collect::>().join("
"); - context_features.push_str(&format!("\n| **Reads** | {names} |")); - } - if !inject.is_empty() { - let names = inject.iter().map(|ty| format!("`{}`", ty.name())).collect::>().join("
"); - context_features.push_str(&format!("\n| **Sets** | {names} |")); - } - - let content = formatdoc!( - " - - ### Context - - {context_features} - " - ); - page.write_all(content.as_bytes()).expect("Failed to write to node page file"); - }; -} - -fn node_write_inputs(page: &mut std::fs::File, valid_input_types: Vec>, metadata: &NodeMetadata) { - let rows = metadata - .fields - .iter() - .enumerate() - .filter(|&(index, field)| !field.hidden || index == 0) - .map(|(index, field)| { - // Parameter - let parameter = field.name; - - // Possible types - let mut possible_types_list = valid_input_types.get(index).unwrap_or(&Vec::new()).iter().map(|ty| ty.nested_type()).cloned().collect::>(); - possible_types_list.sort_by_key(|ty| ty.to_string()); - possible_types_list.dedup(); - let mut possible_types = possible_types_list.iter().map(|ty| format!("`{ty}`")).collect::>().join("
"); - if possible_types.is_empty() { - possible_types = "*Any Type*".to_string(); - } - - // Details - let mut details = field - .description - .trim() - .split('\n') - .filter(|line| !line.is_empty()) - .map(|line| format!("

{}

", line.trim())) - .collect::>(); - if index == 0 { - details.push("

*Primary Input*

".to_string()); - } - if field.exposed { - details.push("

*Exposed to the Graph by Default*

".to_string()); - } - if let RegistryValueSource::Scope(scope_name) = &field.value_source { - details.push(format!("

*Sourced From Scope: `{scope_name}`*

")); - } - let default_value = match field.value_source { - RegistryValueSource::Default(default_value) => Some(default_value.to_string().replace(" :: ", "::")), - _ => field - .default_type - .as_ref() - .or(match possible_types_list.as_slice() { - [single] => Some(single), - _ => None, - }) - .and_then(|ty| value::TaggedValue::from_type(ty.nested_type())) - .map(|ty| ty.to_debug_string()), - }; - if index > 0 - && !field.exposed - && let Some(default_value) = default_value - { - let default_value = default_value.trim_end_matches('.').trim_end_matches(".0"); // Display whole-number floats as integers - - let render_color = |color| format!(r#""#); - let default_value = match default_value { - "Color::BLACK" => render_color("black"), - "GradientStops([(0.0, Color { red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0 }), (1.0, Color { red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0 })])" => { - render_color("linear-gradient(to right, black, white)") - } - _ => format!("`{default_value}{}`", field.unit.unwrap_or_default()), - }; - - details.push(format!("

*Default:* {default_value}

")); - } - let details = details.join(""); - - if index == 0 && possible_types_list.as_slice() == [concrete!(())] { - "| - | *No Primary Input* | - |".to_string() - } else { - format!("| {parameter} | {details} | {possible_types} |") - } - }) - .collect::>(); - if !rows.is_empty() { - let rows = rows.join("\n"); - let content = formatdoc!( - " - - ### Inputs - - | Parameter | Details | Possible Types | - |:-|:-|:-| - {rows} - " - ); - page.write_all(content.as_bytes()).expect("Failed to write to node page file"); - } -} - -fn node_write_outputs(mut page: std::fs::File, valid_primary_outputs: String) { - let product = "Result"; - let details = "The value produced by the node operation."; - - let mut details = format!("

{details}

"); - details.push_str("

*Primary Output*

"); - - let content = formatdoc!( - " - - ### Outputs - - | Product | Details | Possible Types | - |:-|:-|:-| - | {product} | {details} | {valid_primary_outputs} | - " - ); - page.write_all(content.as_bytes()).expect("Failed to write to node page file"); -} - -fn write_category_index_page(index: usize, category: &str, nodes: &[(&core_types::ProtoNodeIdentifier, &NodeMetadata)], category_path: &String) { - std::fs::create_dir_all(category_path).expect("Failed to create category directory"); - let page_path = format!("{category_path}/_index.md"); - let mut page = std::fs::File::create(&page_path).expect("Failed to create index file"); - - // Write frontmatter - let order = index + 1; - let content = formatdoc!( - " - +++ - title = \"{category}\" - template = \"book.html\" - page_template = \"book.html\" - - [extra] - order = {order} - css = [\"/page/user-manual/node-category.css\"] - +++ - " - ); - page.write_all(content.as_bytes()).expect("Failed to write to index file"); - - // Write description - let category_description = category_description(category); - let content = formatdoc!( - " - - {category_description} - " - ); - page.write_all(content.as_bytes()).expect("Failed to write to index file"); - - // Write nodes table header - let content = formatdoc!( - " - - ## Nodes - - | Node | Details | Possible Types | - |:-|:-|:-| - " - ); - page.write_all(content.as_bytes()).expect("Failed to write to index file"); - - // Write nodes table rows - let content = nodes - .iter() - .filter_map(|&(id, metadata)| { - // Path to page - let name_url_part = sanitize_path(&metadata.display_name.to_case(Case::Kebab)); - - // Name and description - let name = metadata.display_name; - let description = node_description(metadata); - let details = description.split('\n').map(|line| format!("

{}

", line.trim())).collect::>().join(""); - - // Possible types - let node_registry = core_types::registry::NODE_REGISTRY.lock().unwrap(); - let implementations = node_registry.get(id)?; - let valid_primary_inputs_to_outputs = implementations - .iter() - .map(|(_, node_io)| { - format!( - "`{} → {}`", - node_io - .inputs - .first() - .map(|t| t.nested_type()) - .filter(|&t| t != &concrete!(())) - .map(ToString::to_string) - .unwrap_or_default(), - node_io.return_value.nested_type() - ) - }) - .collect::>(); - let valid_primary_inputs_to_outputs = { - // Dedupe while preserving order - let mut found = HashSet::new(); - valid_primary_inputs_to_outputs.into_iter().filter(|s| found.insert(s.clone())).collect::>() - }; - let possible_types = valid_primary_inputs_to_outputs.join("
"); - - // Add table row - Some(format!("| [{name}]({name_url_part}) | {details} | {possible_types} |")) - }) - .collect::>() - .join("\n"); - page.write_all(content.as_bytes()).expect("Failed to write to index file"); -} - -fn sanitize_path(s: &str) -> String { - // Replace disallowed characters with a dash - let allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~[]@!$&'()*+,;="; - let filtered = s.chars().map(|c| if allowed_characters.contains(c) { c } else { '-' }).collect::(); - - // Fix letter-number type names - let mut filtered = format!("-{filtered}-"); - filtered = filtered.replace("-vec-2-", "-vec2-"); - filtered = filtered.replace("-f-32-", "-f32-"); - filtered = filtered.replace("-f-64-", "-f64-"); - filtered = filtered.replace("-u-32-", "-u32-"); - filtered = filtered.replace("-u-64-", "-u64-"); - filtered = filtered.replace("-i-32-", "-i32-"); - filtered = filtered.replace("-i-64-", "-i64-"); - - // Remove consecutive dashes - while filtered.contains("--") { - filtered = filtered.replace("--", "-"); - } - - // Trim leading and trailing dashes - filtered.trim_matches('-').to_string() -} - -fn node_description(metadata: &NodeMetadata) -> &str { - let mut description = metadata.description.trim(); - if description.is_empty() { - description = "*Node description coming soon.*"; - } - description -} - -fn category_description(category: &str) -> &str { - match category { - "Animation" => indoc!( - " - Nodes in this category enable the creation of animated, real-time, and interactive motion graphics involving paramters that change over time. - - These nodes require that playback is activated by pressing the play button above the viewport. - " - ), - "Blending" => "Nodes in this category control how overlapping graphical content is composited together, considering blend modes, opacity, and clipping.", - "Color" => "Nodes in this category deal with selecting and manipulating colors, gradients, and palettes.", - "Debug" => indoc!( - " - Nodes in this category are temporarily included for debugging purposes by Graphite's developers. They may have rare potential uses for advanced users, but are not intended for general use and will be removed in future releases. - " - ), - "General" => "Nodes in this category deal with general data handling, such as merging and flattening graphical elements.", - "Instancing" => "Nodes in this category enable the duplication, arrangement, and looped generation of graphical elements.", - "Math: Arithmetic" => "Nodes in this category perform common arithmetic operations on numerical values (and where applicable, `vec2` values).", - "Math: Logic" => "Nodes in this category perform boolean logic operations such as comparisons, conditionals, logic gates, and switching.", - "Math: Numeric" => "Nodes in this category perform discontinuous numeric operations such as rounding, clamping, mapping, and randomization.", - "Math: Transform" => "Nodes in this category perform transformations on graphical elements and calculations involving transformation matrices.", - "Math: Trig" => "Nodes in this category perform trigonometric operations such as sine, cosine, tangent, and their inverses.", - "Math: Vector" => "Nodes in this category perform operations involving `vec2` values (points or arrows in 2D space) such as the dot product, normalization, and distance calculations.", - "Raster: Adjustment" => "Nodes in this category perform per-pixel color adjustments on raster graphics, such as brightness and contrast modifications.", - "Raster: Channels" => "Nodes in this category enable channel-specific manipulation of the RGB and alpha channels of raster graphics.", - "Raster: Filter" => "Nodes in this category apply filtering effects to raster graphics such as blurs and sharpening.", - "Raster: Pattern" => "Nodes in this category generate procedural raster patterns, fractals, textures, and noise.", - "Raster" => "Nodes in this category deal with fundamental raster image operations.", - "Text" => "Nodes in this category support the manipulation, formatting, and rendering of text strings.", - "Value" => "Nodes in this category supply data values of common types such as numbers, colors, booleans, and strings.", - "Vector: Measure" => "Nodes in this category perform measurements and analysis on vector graphics, such as length/area calculations, path traversal, and hit testing.", - "Vector: Modifier" => "Nodes in this category modify the geometry of vector graphics, such as boolean operations, smoothing, and morphing.", - "Vector: Shape" => "Nodes in this category generate parametrically-described primitive vector shapes such as rectangles, grids, stars, and spirals.", - "Vector: Style" => "Nodes in this category apply fill and stroke styles to alter the appearance of vector graphics.", - "Vector" => "Nodes in this category deal with fundamental vector graphics data handling and operations.", - "Web Request" => "Nodes in this category facilitate fetching and handling resources from HTTP endpoints and sending webhook requests to external services.", - _ => panic!("Category '{category}' is missing a description"), - }.trim() -} - -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"); - } - std::fs::create_dir_all(NODE_CATALOG_PATH).expect("Failed to create node catalog directory"); - let page_path = format!("{NODE_CATALOG_PATH}/_index.md"); - let mut page = std::fs::File::create(&page_path).expect("Failed to create index file"); - - // Write frontmatter - let content = formatdoc!( - " - +++ - title = \"Node catalog\" - template = \"book.html\" - page_template = \"book.html\" - - [extra] - order = 3 - css = [\"/page/user-manual/node-catalog.css\"] - +++ - " - ); - page.write_all(content.as_bytes()).expect("Failed to write to index file"); - - // Write description - let content = formatdoc!( - " - - The node catalog documents all of the nodes available in Graphite's node graph system, organized by category. - -

\"Terminology

- " - ); - page.write_all(content.as_bytes()).expect("Failed to write to index file"); - - // Write node categories table header - let content = formatdoc!( - " - - ## Node categories - - | Category | Details | - |:-|:-| - " - ); - page.write_all(content.as_bytes()).expect("Failed to write to index file"); - - // Write node categories table rows - let content = categories - .iter() - .filter_map(|c| if c.is_empty() { if OMIT_HIDDEN { None } else { Some("Hidden") } } else { Some(c) }) - .map(|category| { - let category_path_part = sanitize_path(&category.to_case(Case::Kebab)); - let details = category_description(category).replace("\n\n", "

").replace('\n', "
"); - format!("| [{category}](./{category_path_part}) |

{details}

|") - }) - .collect::>() - .join("\n"); - page.write_all(content.as_bytes()).expect("Failed to write to index file"); -} diff --git a/tools/node-docs/src/page_catalog.rs b/tools/node-docs/src/page_catalog.rs new file mode 100644 index 0000000000..f0f02b66f4 --- /dev/null +++ b/tools/node-docs/src/page_catalog.rs @@ -0,0 +1,74 @@ +use crate::utility::*; +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"); + } + std::fs::create_dir_all(NODE_CATALOG_PATH).expect("Failed to create node catalog directory"); + let page_path = format!("{NODE_CATALOG_PATH}/_index.md"); + let mut page = std::fs::File::create(&page_path).expect("Failed to create index file"); + + write_frontmatter(&mut page); + write_description(&mut page); + write_categories_table_header(&mut page); + write_categories_table_rows(&mut page, categories); +} + +fn write_frontmatter(page: &mut std::fs::File) { + let content = formatdoc!( + " + +++ + title = \"Node catalog\" + template = \"book.html\" + page_template = \"book.html\" + + [extra] + order = 3 + css = [\"/page/user-manual/node-catalog.css\"] + +++ + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); +} + +fn write_description(page: &mut std::fs::File) { + let content = formatdoc!( + " + + The node catalog documents all of the nodes available in Graphite's node graph system, organized by category. + +

\"Terminology

+ " + ); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); +} + +fn write_categories_table_header(page: &mut std::fs::File) { + let content = formatdoc!( + " + + ## Node categories + + | Category | Details | + |:-|:-| + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); +} + +fn write_categories_table_rows(page: &mut std::fs::File, categories: &[String]) { + let content = categories + .iter() + .filter_map(|c| if c.is_empty() { if OMIT_HIDDEN { None } else { Some("Hidden") } } else { Some(c) }) + .map(|category| { + let category_path_part = sanitize_path(&category.to_case(Case::Kebab)); + let details = category_description(category).replace("\n\n", "

").replace('\n', "
"); + format!("| [{category}](./{category_path_part}) |

{details}

|") + }) + .collect::>() + .join("\n"); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); +} diff --git a/tools/node-docs/src/page_category.rs b/tools/node-docs/src/page_category.rs new file mode 100644 index 0000000000..e16a50723d --- /dev/null +++ b/tools/node-docs/src/page_category.rs @@ -0,0 +1,104 @@ +use crate::utility::*; +use convert_case::{Case, Casing}; +use graph_craft::concrete; +use graph_craft::proto::NodeMetadata; +use graphene_std::core_types; +use indoc::formatdoc; +use std::collections::HashSet; +use std::io::Write; + +pub fn write_category_index_page(index: usize, category: &str, nodes: &[(&core_types::ProtoNodeIdentifier, &NodeMetadata)], category_path: &String) { + std::fs::create_dir_all(category_path).expect("Failed to create category directory"); + let page_path = format!("{category_path}/_index.md"); + let mut page = std::fs::File::create(&page_path).expect("Failed to create index file"); + + write_frontmatter(&mut page, category, index + 1); + write_description(&mut page, category); + write_nodes_table_header(&mut page); + write_nodes_table_rows(&mut page, nodes); +} + +fn write_frontmatter(page: &mut std::fs::File, category: &str, order: usize) { + let content = formatdoc!( + " + +++ + title = \"{category}\" + template = \"book.html\" + page_template = \"book.html\" + + [extra] + order = {order} + css = [\"/page/user-manual/node-category.css\"] + +++ + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); +} + +fn write_description(page: &mut std::fs::File, category: &str) { + let category_description = category_description(category); + let content = formatdoc!( + " + + {category_description} + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); +} + +fn write_nodes_table_header(page: &mut std::fs::File) { + let content = formatdoc!( + " + + ## Nodes + + | Node | Details | Possible Types | + |:-|:-|:-| + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); +} + +fn write_nodes_table_rows(page: &mut std::fs::File, nodes: &[(&core_types::ProtoNodeIdentifier, &NodeMetadata)]) { + let content = nodes + .iter() + .filter_map(|&(id, metadata)| { + // Path to page + let name_url_part = sanitize_path(&metadata.display_name.to_case(Case::Kebab)); + + // Name and description + let name = metadata.display_name; + let description = node_description(metadata); + let details = description.split('\n').map(|line| format!("

{}

", line.trim())).collect::>().join(""); + + // Possible types + let node_registry = core_types::registry::NODE_REGISTRY.lock().unwrap(); + let implementations = node_registry.get(id)?; + let valid_primary_inputs_to_outputs = implementations + .iter() + .map(|(_, node_io)| { + let input = node_io + .inputs + .first() + .map(|ty| ty.nested_type()) + .filter(|&ty| ty != &concrete!(())) + .map(ToString::to_string) + .unwrap_or_default(); + let output = node_io.return_value.nested_type().to_string(); + format!("`{input} → {output}`") + }) + .collect::>(); + let valid_primary_inputs_to_outputs = { + // Dedupe while preserving order + let mut found = HashSet::new(); + valid_primary_inputs_to_outputs.into_iter().filter(|s| found.insert(s.clone())).collect::>() + }; + let possible_types = valid_primary_inputs_to_outputs.join("
"); + + // Add table row + Some(format!("| [{name}]({name_url_part}) | {details} | {possible_types} |")) + }) + .collect::>() + .join("\n"); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); +} diff --git a/tools/node-docs/src/page_node.rs b/tools/node-docs/src/page_node.rs new file mode 100644 index 0000000000..dabef2ff0c --- /dev/null +++ b/tools/node-docs/src/page_node.rs @@ -0,0 +1,249 @@ +use crate::utility::*; +use convert_case::{Case, Casing}; +use graph_craft::concrete; +use graph_craft::document::value; +use graph_craft::proto::{NodeMetadata, RegistryValueSource}; +use graphene_std::{ContextDependencies, core_types}; +use indoc::formatdoc; +use std::collections::HashSet; +use std::io::Write; + +pub fn write_node_page(index: usize, id: &core_types::ProtoNodeIdentifier, metadata: &NodeMetadata, category_path: &String) { + let node_registry = core_types::registry::NODE_REGISTRY.lock().unwrap(); + let Some(implementations) = node_registry.get(id) else { return }; + + // Path to page + let name_url_part = sanitize_path(&metadata.display_name.to_case(Case::Kebab)); + let page_path = format!("{category_path}/{name_url_part}.md"); + let mut page = std::fs::File::create(&page_path).expect("Failed to create node page file"); + + // Context features + let context_features = &metadata.context_features; + let context_dependencies: ContextDependencies = context_features.as_slice().into(); + + // Input types + let mut valid_input_types = vec![Vec::new(); metadata.fields.len()]; + for (_, node_io) in implementations.iter() { + for (i, ty) in node_io.inputs.iter().enumerate() { + valid_input_types[i].push(ty.nested_type().clone()); + } + } + for item in valid_input_types.iter_mut() { + // Dedupe while preserving order + let mut found = HashSet::new(); + *item = item.clone().into_iter().filter(|s| found.insert(s.clone())).collect::>() + } + + // Primary output types + let valid_primary_outputs = implementations.iter().map(|(_, node_io)| node_io.return_value.nested_type().clone()).collect::>(); + + // Write sections to the file + write_frontmatter(&mut page, metadata, index + 1); + write_description(&mut page, metadata); + write_interface_header(&mut page); + write_context(&mut page, context_dependencies); + write_inputs(&mut page, &valid_input_types, metadata); + write_outputs(&mut page, &valid_primary_outputs); +} + +fn write_frontmatter(page: &mut std::fs::File, metadata: &NodeMetadata, order: usize) { + let name = metadata.display_name; + + let content = formatdoc!( + " + +++ + title = \"{name}\" + + [extra] + order = {order} + css = [\"/page/user-manual/node.css\"] + +++ + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to node page file"); +} + +fn write_description(page: &mut std::fs::File, metadata: &NodeMetadata) { + let description = node_description(metadata); + + let content = formatdoc!( + " + + {description} + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to node page file"); +} + +fn write_interface_header(page: &mut std::fs::File) { + let content = formatdoc!( + " + + ## Interface + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to node page file"); +} + +fn write_context(page: &mut std::fs::File, context_dependencies: ContextDependencies) { + let extract = context_dependencies.extract; + let inject = context_dependencies.inject; + if !extract.is_empty() || !inject.is_empty() { + let mut context_features = "| | |\n|:-|:-|".to_string(); + if !extract.is_empty() { + let names = extract.iter().map(|ty| format!("`{}`", ty.name())).collect::>().join("
"); + context_features.push_str(&format!("\n| **Reads** | {names} |")); + } + if !inject.is_empty() { + let names = inject.iter().map(|ty| format!("`{}`", ty.name())).collect::>().join("
"); + context_features.push_str(&format!("\n| **Sets** | {names} |")); + } + + let content = formatdoc!( + " + + ### Context + + {context_features} + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to node page file"); + }; +} + +fn write_inputs(page: &mut std::fs::File, valid_input_types: &[Vec], metadata: &NodeMetadata) { + let rows = metadata + .fields + .iter() + .enumerate() + .filter(|&(index, field)| !field.hidden || index == 0) + .map(|(index, field)| { + // Parameter + let parameter = field.name; + + // Possible types + let possible_types_list = valid_input_types.get(index).cloned().unwrap_or_default(); + if index == 0 && possible_types_list.as_slice() == [concrete!(())] { + return "| - | *No Primary Input* | - |".to_string(); + } + let mut possible_types = possible_types_list.iter().map(|ty| format!("`{ty}`")).collect::>(); + possible_types.sort(); + possible_types.dedup(); + let mut possible_types = possible_types.join("
"); + if possible_types.is_empty() { + possible_types = "*Any Type*".to_string(); + } + + // Details: description + let mut details = field + .description + .trim() + .split('\n') + .filter(|line| !line.is_empty()) + .map(|line| format!("

{}

", line.trim())) + .collect::>(); + + // Details: primary input + if index == 0 { + details.push("

*Primary Input*

".to_string()); + } + + // Details: exposed by default + if field.exposed { + details.push("

*Exposed to the Graph by Default*

".to_string()); + } + + // Details: sourced from scope + if let RegistryValueSource::Scope(scope_name) = &field.value_source { + details.push(format!("

*Sourced From Scope: `{scope_name}`*

")); + } + + // Details: default value + let default_value = match field.value_source { + RegistryValueSource::Default(default_value) => Some(default_value.to_string().replace(" :: ", "::")), + _ => field + .default_type + .as_ref() + .or(match possible_types_list.as_slice() { + [single] => Some(single), + _ => None, + }) + .and_then(|ty| value::TaggedValue::from_type(ty.nested_type())) + .map(|ty| ty.to_debug_string()), + }; + if index > 0 + && !field.exposed + && let Some(default_value) = default_value + { + let default_value = default_value.trim_end_matches('.').trim_end_matches(".0"); // Display whole-number floats as integers + + let render_color = |color| format!(r#""#); + let default_value = match default_value { + "Color::BLACK" => render_color("black"), + "GradientStops([(0.0, Color { red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0 }), (1.0, Color { red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0 })])" => { + render_color("linear-gradient(to right, black, white)") + } + _ => format!("`{default_value}{}`", field.unit.unwrap_or_default()), + }; + + details.push(format!("

*Default:* {default_value}

")); + } + + // Construct the table row + let details = details.join(""); + format!("| {parameter} | {details} | {possible_types} |") + }) + .collect::>() + .join("\n"); + if !rows.is_empty() { + let content = formatdoc!( + " + + ### Inputs + + | Parameter | Details | Possible Types | + |:-|:-|:-| + {rows} + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to node page file"); + } +} + +fn write_outputs(page: &mut std::fs::File, valid_primary_outputs: &[core_types::Type]) { + // Product + let product = "Result"; + + // Details: description + let details = "The value produced by the node operation."; + let mut details = format!("

{details}

"); + + // Details: primary output + details.push_str("

*Primary Output*

"); + + // Possible types + let valid_primary_outputs = valid_primary_outputs.iter().map(|ty| format!("`{ty}`")).collect::>(); + let valid_primary_outputs = { + // Dedupe while preserving order + let mut found = HashSet::new(); + valid_primary_outputs.into_iter().filter(|s| found.insert(s.clone())).collect::>() + }; + let valid_primary_outputs = { + // Dedupe while preserving order + let mut found = HashSet::new(); + valid_primary_outputs.into_iter().filter(|s| found.insert(s.clone())).collect::>() + }; + let valid_primary_outputs = valid_primary_outputs.join("
"); + + let content = formatdoc!( + " + + ### Outputs + + | Product | Details | Possible Types | + |:-|:-|:-| + | {product} | {details} | {valid_primary_outputs} | + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to node page file"); +} diff --git a/tools/node-docs/src/utility.rs b/tools/node-docs/src/utility.rs new file mode 100644 index 0000000000..b5e71eed36 --- /dev/null +++ b/tools/node-docs/src/utility.rs @@ -0,0 +1,78 @@ +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 { + match category { + "Animation" => indoc!( + " + Nodes in this category enable the creation of animated, real-time, and interactive motion graphics involving paramters that change over time. + + These nodes require that playback is activated by pressing the play button above the viewport. + " + ), + "Blending" => "Nodes in this category control how overlapping graphical content is composited together, considering blend modes, opacity, and clipping.", + "Color" => "Nodes in this category deal with selecting and manipulating colors, gradients, and palettes.", + "Debug" => indoc!( + " + Nodes in this category are temporarily included for debugging purposes by Graphite's developers. They may have rare potential uses for advanced users, but are not intended for general use and will be removed in future releases. + " + ), + "General" => "Nodes in this category deal with general data handling, such as merging and flattening graphical elements.", + "Instancing" => "Nodes in this category enable the duplication, arrangement, and looped generation of graphical elements.", + "Math: Arithmetic" => "Nodes in this category perform common arithmetic operations on numerical values (and where applicable, `vec2` values).", + "Math: Logic" => "Nodes in this category perform boolean logic operations such as comparisons, conditionals, logic gates, and switching.", + "Math: Numeric" => "Nodes in this category perform discontinuous numeric operations such as rounding, clamping, mapping, and randomization.", + "Math: Transform" => "Nodes in this category perform transformations on graphical elements and calculations involving transformation matrices.", + "Math: Trig" => "Nodes in this category perform trigonometric operations such as sine, cosine, tangent, and their inverses.", + "Math: Vector" => "Nodes in this category perform operations involving `vec2` values (points or arrows in 2D space) such as the dot product, normalization, and distance calculations.", + "Raster: Adjustment" => "Nodes in this category perform per-pixel color adjustments on raster graphics, such as brightness and contrast modifications.", + "Raster: Channels" => "Nodes in this category enable channel-specific manipulation of the RGB and alpha channels of raster graphics.", + "Raster: Filter" => "Nodes in this category apply filtering effects to raster graphics such as blurs and sharpening.", + "Raster: Pattern" => "Nodes in this category generate procedural raster patterns, fractals, textures, and noise.", + "Raster" => "Nodes in this category deal with fundamental raster image operations.", + "Text" => "Nodes in this category support the manipulation, formatting, and rendering of text strings.", + "Value" => "Nodes in this category supply data values of common types such as numbers, colors, booleans, and strings.", + "Vector: Measure" => "Nodes in this category perform measurements and analysis on vector graphics, such as length/area calculations, path traversal, and hit testing.", + "Vector: Modifier" => "Nodes in this category modify the geometry of vector graphics, such as boolean operations, smoothing, and morphing.", + "Vector: Shape" => "Nodes in this category generate parametrically-described primitive vector shapes such as rectangles, grids, stars, and spirals.", + "Vector: Style" => "Nodes in this category apply fill and stroke styles to alter the appearance of vector graphics.", + "Vector" => "Nodes in this category deal with fundamental vector graphics data handling and operations.", + "Web Request" => "Nodes in this category facilitate fetching and handling resources from HTTP endpoints and sending webhook requests to external services.", + _ => panic!("Category '{category}' is missing a description"), + }.trim() +} + +pub fn node_description(metadata: &NodeMetadata) -> &str { + let mut description = metadata.description.trim(); + if description.is_empty() { + description = "*Node description coming soon.*"; + } + description +} + +pub fn sanitize_path(s: &str) -> String { + // Replace disallowed characters with a dash + let allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~[]@!$&'()*+,;="; + let filtered = s.chars().map(|c| if allowed_characters.contains(c) { c } else { '-' }).collect::(); + + // Fix letter-number type names + let mut filtered = format!("-{filtered}-"); + filtered = filtered.replace("-vec-2-", "-vec2-"); + filtered = filtered.replace("-f-32-", "-f32-"); + filtered = filtered.replace("-f-64-", "-f64-"); + filtered = filtered.replace("-u-32-", "-u32-"); + filtered = filtered.replace("-u-64-", "-u64-"); + filtered = filtered.replace("-i-32-", "-i32-"); + filtered = filtered.replace("-i-64-", "-i64-"); + + // Remove consecutive dashes + while filtered.contains("--") { + filtered = filtered.replace("--", "-"); + } + + // Trim leading and trailing dashes + filtered.trim_matches('-').to_string() +} diff --git a/website/templates/book.html b/website/templates/book.html index 4569cc4621..c4fb86b48c 100644 --- a/website/templates/book.html +++ b/website/templates/book.html @@ -58,7 +58,7 @@ {%- for chapter in chapters %}
    From 4b88260de619bd50b4fee1f194cff0f89067f2c9 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 20 Jan 2026 01:40:03 -0800 Subject: [PATCH 6/9] Improve book table of contents code --- website/templates/book.html | 31 +---------------- website/templates/macros/book-outline.html | 40 +++++++++++++++------- 2 files changed, 29 insertions(+), 42 deletions(-) diff --git a/website/templates/book.html b/website/templates/book.html index c4fb86b48c..ef4b6007eb 100644 --- a/website/templates/book.html +++ b/website/templates/book.html @@ -134,36 +134,7 @@

- + {{- book_outline::render_book_page_toc(children = page.toc, indents = 1) }} {%- endblock content -%} diff --git a/website/templates/macros/book-outline.html b/website/templates/macros/book-outline.html index 760af1397f..18e1374089 100644 --- a/website/templates/macros/book-outline.html +++ b/website/templates/macros/book-outline.html @@ -1,5 +1,21 @@ +{# Recursively render a page's headings table of contents #} +{%- macro render_book_page_toc(children, indents) -%} + {%- set tabs = "" -%} + {%- for i in range(end = indents) -%} + {%- set_global tabs = tabs ~ " " -%} + {%- endfor -%} -{# Recursively render a book's table of contents #} + {%- if children | length > 0 %} + {{ tabs }}
    + {%- for child in children %} + {{ tabs }}
  • {{ child.title }}
  • + {{- self::render_book_page_toc(children = child.children, indents = indents + 1) -}} + {%- endfor %} + {{ tabs }}
+ {%- endif -%} +{%- endmacro render_book_page_toc -%} + +{# Recursively render a book's chapters table of contents #} {%- macro render_book_outline(section, current_path, indents) -%} {#- Setup -#} {%- set items = [] -%} @@ -12,7 +28,7 @@ {%- set_global items = items | concat(with = subsection) -%} {%- endfor -%} {%- endif -%} - {%- set items = items | sort(attribute="extra.order") -%} + {%- set items = items | sort(attribute = "extra.order") -%} {#- End of setup -#} {%- set tabs = "" -%} @@ -20,16 +36,16 @@ {%- set_global tabs = tabs ~ " " -%} {%- endfor -%} - {%- for item in items %} + {%- if items | length > 0 %} {{ tabs }}
    - {{ tabs }}
  • - {{ tabs }} {{ item.title }} - {{ tabs }}
  • - {%- if item.pages or item.subsections -%} - {{ self::render_book_outline(section = item, current_path = current_path, indents = indents + 1) }} - {%- endif %} + {%- for item in items %} + {{ tabs }}
  • {{ item.title }}
  • + {%- if item.pages or item.subsections -%} + {{ self::render_book_outline(section = item, current_path = current_path, indents = indents + 1) }} + {%- endif %} + {%- endfor %} {{ tabs }}
- {%- endfor -%} + {%- endif -%} {%- endmacro render_book_outline -%} {# Recursively flatten the book outline to a string for sequential navigation #} @@ -45,7 +61,7 @@ {%- set_global items = items | concat(with = subsection) -%} {%- endfor -%} {%- endif -%} - {%- set items = items | sort(attribute="extra.order") -%} + {%- set items = items | sort(attribute = "extra.order") -%} {#- End of setup -#} {%- for item in items -%} @@ -54,4 +70,4 @@ {{ self::flatten_book_outline(section = item) }} {%- endif -%} {%- endfor -%} -{%- endmacro flatten_book_outline -%} \ No newline at end of file +{%- endmacro flatten_book_outline -%} From b8a0b801fcea72cd66c194a49f76f90f712beb7f Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 20 Jan 2026 05:57:04 -0800 Subject: [PATCH 7/9] Add collapsing chapter navigation to the book template --- .vscode/settings.json | 4 + .../volunteer/guide/project-setup/_index.md | 16 +++- website/sass/base.scss | 2 +- website/sass/template/book.scss | 94 +++++++++++++++---- website/templates/book.html | 14 +-- website/templates/macros/book-outline.html | 44 ++++++--- 6 files changed, 121 insertions(+), 53 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 2b554eefae..5fbf8c7f1b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,6 +26,10 @@ // Configured in `.prettierrc` "editor.defaultFormatter": "esbenp.prettier-vscode" }, + // Website: don't format Zola/Tera-templated HTML on save + "[html]": { + "editor.formatOnSave": false + }, // Handlebars: don't save on format // (`about.hbs` is used by Cargo About to encode license information) "[handlebars]": { diff --git a/website/content/volunteer/guide/project-setup/_index.md b/website/content/volunteer/guide/project-setup/_index.md index e40c55c43e..17c47708e8 100644 --- a/website/content/volunteer/guide/project-setup/_index.md +++ b/website/content/volunteer/guide/project-setup/_index.md @@ -32,7 +32,6 @@ Clone the project to a convenient location: ```sh git clone https://github.com/GraphiteEditor/Graphite.git -cd Graphite ``` ## Development builds @@ -43,20 +42,27 @@ From either the `/` (root) or `/frontend` directories, you can run the project b npm start ``` -This spins up the dev server at with a file watcher that performs hot reloading of the web page. You should be able to start the server, edit and save web and Rust code, and shut it down by double pressing CtrlC. You sometimes may need to reload the browser's page if hot reloading didn't behave right— always refresh when Rust recompiles. +This spins up the dev server at with a file watcher that performs hot reloading of the web page. You should be able to start the server, edit and save web and Rust code, and shut it down by double pressing CtrlC. TypeScript and HTML changes require a manual page reload to fix broken state. This method compiles Graphite code in debug mode which includes debug symbols for viewing function names in stack traces. But be aware, it runs slower and the Wasm binary is much larger. (Having your browser's developer tools open will also significantly impact performance in both debug and release builds, so it's best to close that when not in use.) -To run the dev server in optimized mode, which is faster and produces a smaller Wasm binary: +
+Dev server optimized build instructions: click here + +On rare occasions (like while running advanced performance profiles or proxying the dev server connection over a slow network where the >100 MB unoptimized binary size would pose an issue), you may need to run the dev server with release optimizations. To do that while keeping debug symbols: ```sh -# Includes debug symbols npm run profiling +``` + +To run the dev server without debug symbols, using the same release optimizations as production builds: -# Excludes (most) debug symbols, used in release builds +```sh npm run production ``` +
+
Production build instructions: click here diff --git a/website/sass/base.scss b/website/sass/base.scss index c702e378f6..3ad0985807 100644 --- a/website/sass/base.scss +++ b/website/sass/base.scss @@ -375,7 +375,7 @@ body > .page { // ELEMENT SPACING RULES // ===================== -:is(h1, h2, h3, h4, article > :first-child, details > summary) ~ :is(p, ul, ol, ol li p, img, a:has(> img:only-child)), +:is(h1, h2, h3, h4, article > :first-child, details > summary) ~ :is(p, ul, ol, ol li p, img, details, a:has(> img:only-child)), :is(h1, h2, h3, h4, article > :first-child) ~ :is(ul, ol) li p + img, :is(h1, h2, h3, h4, p) ~ .feature-icons, p ~ :is(h1, h2, h3, h4, details summary, blockquote, .image-comparison, .video-background, .youtube-embed), diff --git a/website/sass/template/book.scss b/website/sass/template/book.scss index 15384e548c..90d1dd6a90 100644 --- a/website/sass/template/book.scss +++ b/website/sass/template/book.scss @@ -110,8 +110,8 @@ border-right: var(--border-thickness) solid var(--color-walnut); box-sizing: border-box; transition: left 0.25s ease-in-out; - left: calc(-1 * (var(--aside-width) + 10px)); - width: var(--aside-width); + left: calc(-1 * (Min(var(--aside-width), 100vw) + 10px)); + width: Min(var(--aside-width), 100vw); &::after { content: ""; @@ -128,8 +128,9 @@ overflow-y: auto; height: 100%; padding-right: var(--page-edge-padding); + margin-left: -24px; - ul:first-of-type { + > ul:first-of-type { margin-top: calc(120 * var(--variable-px) + var(--align-with-article-title-letter-cap-heights)); } @@ -173,43 +174,54 @@ align-self: flex-start; overflow-y: auto; top: 0; - width: var(--aside-width); + width: Min(var(--aside-width), 100vw); max-height: 100vh; - margin-top: -40px; + margin-top: calc(-40 * var(--variable-px)); flex: 0 1 auto; + --level-indent: 0.75rem; &.contents > ul, .wrapper-inner > ul { &:first-of-type { - margin-top: calc(40px + var(--align-with-article-title-letter-cap-heights)); + margin-top: calc(40 * var(--variable-px) + var(--align-with-article-title-letter-cap-heights)); } &:last-of-type { margin-bottom: calc(40 * var(--variable-px)); } + + > ul { + margin-left: 0; + } + } + + &.contents > ul > ul :is(ul, li), + .wrapper-inner > ul > ul > ul, + .wrapper-inner > ul > ul > ul :is(ul, li) { + margin-top: 0.5rem; } ul { list-style: none; padding: 0; margin: 0; - margin-top: 40px; - ul { - margin-top: 0; - margin-left: 1em; + &, + ul, + li { + margin-top: calc(40 * var(--variable-px)); } - li { - margin-top: 0.5em; + ul { + margin-left: var(--level-indent); + } - a { - color: var(--color-walnut); + li:has(> label > input:not(:checked)) + ul { + display: none; + } - &:hover { - color: var(--color-crimson); - } - } + li { + font-size: 0; &:not(.title) a { text-decoration: none; @@ -219,6 +231,50 @@ &.chapter { font-weight: 700; } + + label { + display: inline-block; + position: relative; + user-select: none; + vertical-align: bottom; + margin-right: 4px; + width: 20px; + height: calc(1rem * 1.5); + + &:has(input):hover { + background: var(--color-fog); + } + + &:has(input)::before { + content: ""; + background: url('data:image/svg+xml;utf8,\ + \ + '); + position: absolute; + margin: auto; + inset: 0; + width: 10px; + height: 10px; + } + + &:has(input:checked)::before { + transform: rotate(90deg); + } + + input { + display: none; + } + } + + a { + color: var(--color-walnut); + font-size: 1rem; + + &:hover { + color: var(--color-crimson); + text-decoration: underline; + } + } } } @@ -230,8 +286,6 @@ ul a { display: inline-block; - padding-left: 1em; - text-indent: -1em; } } diff --git a/website/templates/book.html b/website/templates/book.html index ef4b6007eb..59d99c739c 100644 --- a/website/templates/book.html +++ b/website/templates/book.html @@ -57,17 +57,7 @@ - - {%- for chapter in chapters %} -
    -
  • - {{ chapter.title }} -
  • - {{- book_outline::render_book_outline(section = chapter, current_path = current_path, indents = 4) }} -
- {%- endfor %} + {{- book_outline::render_book_outline(parent = book, current_path = current_path, index = 0, indents = 3) }} @@ -133,8 +123,8 @@

{% if page.toc | length > 0 %}Contents (top ↑){% else %}Back to top ↑{% endif %} + {{- book_outline::render_book_page_toc(children = page.toc, indents = 1) }} - {{- book_outline::render_book_page_toc(children = page.toc, indents = 1) }} {%- endblock content -%} diff --git a/website/templates/macros/book-outline.html b/website/templates/macros/book-outline.html index 18e1374089..e8062cfd97 100644 --- a/website/templates/macros/book-outline.html +++ b/website/templates/macros/book-outline.html @@ -16,19 +16,19 @@ {%- endmacro render_book_page_toc -%} {# Recursively render a book's chapters table of contents #} -{%- macro render_book_outline(section, current_path, indents) -%} +{%- macro render_book_outline(parent, current_path, index, indents) -%} {#- Setup -#} - {%- set items = [] -%} - {%- if section.pages -%} - {%- set_global items = items | concat(with = section.pages) -%} + {%- set chapters = parent.pages | default(value = []) -%} + {%- if index == 0 -%} + {%- set_global chapters = [parent] -%} + {%- else -%} + {%- for subsection_path in parent.subsections -%} + {%- set_global chapters = chapters | concat(with = get_section(path = subsection_path)) -%} + {%- endfor -%} {%- endif -%} - {%- if section.subsections -%} - {%- for subsection_path in section.subsections -%} - {%- set subsection = get_section(path = subsection_path) -%} - {%- set_global items = items | concat(with = subsection) -%} - {%- endfor -%} + {%- if index > 0 -%} + {%- set_global chapters = chapters | sort(attribute = "extra.order") -%} {%- endif -%} - {%- set items = items | sort(attribute = "extra.order") -%} {#- End of setup -#} {%- set tabs = "" -%} @@ -36,12 +36,26 @@ {%- set_global tabs = tabs ~ " " -%} {%- endfor -%} - {%- if items | length > 0 %} + {%- if chapters | length > 0 %} {{ tabs }}
    - {%- for item in items %} - {{ tabs }}
  • {{ item.title }}
  • - {%- if item.pages or item.subsections -%} - {{ self::render_book_outline(section = item, current_path = current_path, indents = indents + 1) }} + {%- for chapter in chapters %} + {%- set children = chapter.pages or chapter.subsections | default(value = []) -%} + {%- set_global classes = [] -%} + {%- if index == 0 -%} + {%- set_global classes = classes | concat(with = "title") -%} + {%- endif -%} + {%- if index == 1 -%} + {%- set_global classes = classes | concat(with = "chapter") -%} + {%- endif -%} + {%- if current_path == chapter.path -%} + {%- set_global classes = classes | concat(with = "active") -%} + {%- endif %} + {{ tabs }}
  • 0 %} class="{{ classes | join(sep = " ") }}"{% endif %}> + {{ tabs }} + {{ tabs }}{{ chapter.title }} + {{ tabs }}
  • + {%- if children -%} + {{ self::render_book_outline(parent = chapter, current_path = current_path, index = index + 1, indents = indents + 1) }} {%- endif %} {%- endfor %} {{ tabs }}
From c16f310e83e96c1cf3b8d79de23dac6a7821e3e5 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 20 Jan 2026 21:54:00 -0800 Subject: [PATCH 8/9] Add to build workflow --- .github/workflows/website.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 9a5f63494d..2586393077 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -80,6 +80,11 @@ jobs: cd website npm run generate-editor-structure + - name: Generate node catalog documentation + run: | + cd tools/node-docs + cargo run + - name: 🌐 Build Graphite website with Zola env: MODE: prod From 470ac318ff2406e9f71309f0271de2b8fd5b25eb Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 20 Jan 2026 22:23:51 -0800 Subject: [PATCH 9/9] Clean up site structure --- website/.gitignore | 2 +- website/content/_index.md | 2 +- ...01-looking-back-on-2023-and-what's-next.md | 2 +- ...view-2024-highlights-and-a-peek-at-2025.md | 2 +- ...ps-for-a-rust-graphics-engine-gsoc-2025.md | 2 +- website/content/donate.md | 10 ----- website/content/features.md | 2 +- website/content/learn/_index.md | 2 +- website/content/learn/introduction/_index.md | 2 +- .../guide/codebase-overview/_index.md | 2 +- .../codebase-overview/editor-structure.md | 2 +- website/sass/page/donate.scss | 43 ------------------- website/static/js/{ => component}/carousel.js | 0 .../js/{ => component}/image-comparison.js | 0 .../js/{ => component}/video-autoplay.js | 0 .../js/{ => component}/youtube-embed.js | 0 website/static/js/fundraising.js | 35 --------------- .../contributor-guide/editor-structure.js} | 0 website/static/js/{ => template}/book.js | 0 website/templates/book.html | 2 +- 20 files changed, 11 insertions(+), 99 deletions(-) rename website/static/js/{ => component}/carousel.js (100%) rename website/static/js/{ => component}/image-comparison.js (100%) rename website/static/js/{ => component}/video-autoplay.js (100%) rename website/static/js/{ => component}/youtube-embed.js (100%) delete mode 100644 website/static/js/fundraising.js rename website/static/js/{developer-guide-editor-structure.js => page/contributor-guide/editor-structure.js} (100%) rename website/static/js/{ => template}/book.js (100%) diff --git a/website/.gitignore b/website/.gitignore index 5d0fa15946..ccaacaef2c 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -1,5 +1,5 @@ node_modules/ public/ -static/ +static/* !static/js/ content/learn/node-catalog diff --git a/website/content/_index.md b/website/content/_index.md index e90cbb23f6..5e0c9e814a 100644 --- a/website/content/_index.md +++ b/website/content/_index.md @@ -4,7 +4,7 @@ template = "section.html" [extra] css = ["/page/index.css", "/component/carousel.css", "/component/feature-icons.css", "/component/feature-box.css", "/component/youtube-embed.css"] -js = ["/js/carousel.js", "/js/youtube-embed.js", "/js/video-autoplay.js"] +js = ["/js/component/carousel.js", "/js/component/youtube-embed.js", "/js/component/video-autoplay.js"] linked_js = [] meta_description = "Open source free software. A vector graphics creativity suite with a clean, intuitive interface. Opens instantly (no signup) and runs locally in a browser. Exports SVG, PNG, JPG." +++ diff --git a/website/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.md b/website/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.md index c095ab6a73..ccc171a1af 100644 --- a/website/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.md +++ b/website/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.md @@ -10,7 +10,7 @@ summary = "Looking back on 2023, we reflect on our significant achievements and reddit = "https://www.reddit.com/r/graphite/comments/18xmoti/blog_post_looking_back_on_2023_and_whats_next/" twitter = "https://twitter.com/GraphiteEditor/status/1742576805532577937" -js = ["/js/youtube-embed.js"] +js = ["/js/component/youtube-embed.js"] css = ["/component/youtube-embed.css"] +++ diff --git a/website/content/blog/2025-01-16-year-in-review-2024-highlights-and-a-peek-at-2025.md b/website/content/blog/2025-01-16-year-in-review-2024-highlights-and-a-peek-at-2025.md index 8324c6b994..199d5ca549 100644 --- a/website/content/blog/2025-01-16-year-in-review-2024-highlights-and-a-peek-at-2025.md +++ b/website/content/blog/2025-01-16-year-in-review-2024-highlights-and-a-peek-at-2025.md @@ -11,7 +11,7 @@ reddit = "https://www.reddit.com/r/graphite/comments/1i3umnl/blog_post_year_in_r twitter = "https://x.com/GraphiteEditor/status/1880404337345851612" bluesky = "https://bsky.app/profile/graphiteeditor.bsky.social/post/3lfxysayh622g" -js = ["/js/youtube-embed.js"] +js = ["/js/component/youtube-embed.js"] css = ["/component/youtube-embed.css"] +++ diff --git a/website/content/blog/2025-04-02-internships-for-a-rust-graphics-engine-gsoc-2025.md b/website/content/blog/2025-04-02-internships-for-a-rust-graphics-engine-gsoc-2025.md index f7ca6427d7..358ff48b72 100644 --- a/website/content/blog/2025-04-02-internships-for-a-rust-graphics-engine-gsoc-2025.md +++ b/website/content/blog/2025-04-02-internships-for-a-rust-graphics-engine-gsoc-2025.md @@ -10,7 +10,7 @@ reddit = "https://www.reddit.com/r/graphite/comments/1jplm6t/internships_for_a_r twitter = "https://x.com/GraphiteEditor/status/1907384498389651663" bluesky = "https://bsky.app/profile/graphiteeditor.bsky.social/post/3llt7lbmm4s24" -js = ["/js/youtube-embed.js"] +js = ["/js/component/youtube-embed.js"] css = ["/component/youtube-embed.css"] +++ diff --git a/website/content/donate.md b/website/content/donate.md index 2cd93d6398..6f14f59822 100644 --- a/website/content/donate.md +++ b/website/content/donate.md @@ -220,13 +220,3 @@ Also available to individuals wanting to make a larger impact. [Reach out](/cont - - diff --git a/website/content/features.md b/website/content/features.md index b8d567f1ec..056db60b32 100644 --- a/website/content/features.md +++ b/website/content/features.md @@ -3,7 +3,7 @@ title = "Graphite features" [extra] css = ["/page/features.css", "/component/feature-box.css", "/component/feature-icons.css", "/component/youtube-embed.css"] -js = ["/js/youtube-embed.js"] +js = ["/js/component/youtube-embed.js"] +++
diff --git a/website/content/learn/_index.md b/website/content/learn/_index.md index 9a3b983a2e..5fe4aaaabf 100644 --- a/website/content/learn/_index.md +++ b/website/content/learn/_index.md @@ -5,7 +5,7 @@ page_template = "book.html" [extra] book = true -js = ["/js/youtube-embed.js"] +js = ["/js/component/youtube-embed.js"] css = ["/component/youtube-embed.css"] +++ diff --git a/website/content/learn/introduction/_index.md b/website/content/learn/introduction/_index.md index 18137b7f55..dcd3c980fd 100644 --- a/website/content/learn/introduction/_index.md +++ b/website/content/learn/introduction/_index.md @@ -5,7 +5,7 @@ page_template = "book.html" [extra] order = 1 -js = ["/js/youtube-embed.js"] +js = ["/js/component/youtube-embed.js"] css = ["/component/youtube-embed.css"] +++ diff --git a/website/content/volunteer/guide/codebase-overview/_index.md b/website/content/volunteer/guide/codebase-overview/_index.md index 9938a66650..a5014c6dc0 100644 --- a/website/content/volunteer/guide/codebase-overview/_index.md +++ b/website/content/volunteer/guide/codebase-overview/_index.md @@ -5,7 +5,7 @@ page_template = "book.html" [extra] order = 2 # Chapter number -js = ["/js/youtube-embed.js"] +js = ["/js/component/youtube-embed.js"] css = ["/component/youtube-embed.css"] +++ diff --git a/website/content/volunteer/guide/codebase-overview/editor-structure.md b/website/content/volunteer/guide/codebase-overview/editor-structure.md index c4a866135e..250eea74cd 100644 --- a/website/content/volunteer/guide/codebase-overview/editor-structure.md +++ b/website/content/volunteer/guide/codebase-overview/editor-structure.md @@ -4,7 +4,7 @@ title = "Editor structure" [extra] order = 1 # Page number after chapter intro css = ["/page/contributor-guide/editor-structure.css"] -js = ["/js/contributor-guide/editor-structure.js"] +js = ["/js/page/contributor-guide/editor-structure.js"] +++ The Graphite editor is the application users interact with to create documents. Its code is a single Rust crate that lives below the frontend (web code) and above [Graphene](../../graphene) (the node-based graphics engine). The main business logic of all visual editing is handled by the editor backend. When running in the browser, it is compiled to WebAssembly and passes messages to the frontend. diff --git a/website/sass/page/donate.scss b/website/sass/page/donate.scss index e4f0e78863..876519daf4 100644 --- a/website/sass/page/donate.scss +++ b/website/sass/page/donate.scss @@ -107,46 +107,3 @@ background-color: var(--color-ale); margin-top: 0; } - -// .fundraising { -// margin-top: 20px; -// width: 100%; -// -// .fundraising-bar { -// width: 100%; -// height: 32px; -// border-radius: 10000px; -// background: var(--color-fog); -// overflow: hidden; -// -// .fundraising-bar-progress { -// width: calc(var(--fundraising-percent) - (4px * 2) - (32px - 4px * 2)); -// padding-left: calc(32px - 4px * 2); -// height: calc(100% - 4px * 2); -// margin: 4px; -// border-radius: 10000px; -// background: linear-gradient(to right, var(--color-navy), var(--color-crimson)); -// transition: opacity 1s, width 2s; -// } -// } -// -// .goal-metrics { -// display: flex; -// justify-content: space-between; -// font-weight: 800; -// margin-top: 8px; -// margin-left: 20px; -// width: calc(100% - 40px); - -// > span { -// transition: opacity 1s; -// } -// } -// -// &.fundraising.loading { -// .goal-metrics > span, -// .fundraising-bar .fundraising-bar-progress { -// opacity: 0; -// } -// } -// } diff --git a/website/static/js/carousel.js b/website/static/js/component/carousel.js similarity index 100% rename from website/static/js/carousel.js rename to website/static/js/component/carousel.js diff --git a/website/static/js/image-comparison.js b/website/static/js/component/image-comparison.js similarity index 100% rename from website/static/js/image-comparison.js rename to website/static/js/component/image-comparison.js diff --git a/website/static/js/video-autoplay.js b/website/static/js/component/video-autoplay.js similarity index 100% rename from website/static/js/video-autoplay.js rename to website/static/js/component/video-autoplay.js diff --git a/website/static/js/youtube-embed.js b/website/static/js/component/youtube-embed.js similarity index 100% rename from website/static/js/youtube-embed.js rename to website/static/js/component/youtube-embed.js diff --git a/website/static/js/fundraising.js b/website/static/js/fundraising.js deleted file mode 100644 index b935b316da..0000000000 --- a/website/static/js/fundraising.js +++ /dev/null @@ -1,35 +0,0 @@ -window.addEventListener("DOMContentLoaded", initializeFundraisingBar); - -function initializeFundraisingBar() { - const VISIBILITY_COVERAGE_FRACTION = 0.5; - - let loaded = false; - - const fundraising = document.querySelector("[data-fundraising]"); - if (!fundraising) return; - const bar = fundraising.querySelector("[data-fundraising-bar]"); - const dynamicPercent = fundraising.querySelector("[data-fundraising-percent] [data-dynamic]"); - const dynamicGoal = fundraising.querySelector("[data-fundraising-goal] [data-dynamic]"); - if (!(fundraising instanceof HTMLElement && bar instanceof HTMLElement && dynamicPercent instanceof HTMLElement && dynamicGoal instanceof HTMLElement)) return; - - const setFundraisingGoal = async () => { - const request = await fetch("https://graphite.art/fundraising-goal"); - /** @type {{ percentComplete: number, targetValue: number }} */ - const data = await request.json(); - - fundraising.classList.remove("loading"); - bar.style.setProperty("--fundraising-percent", `${data.percentComplete}%`); - dynamicPercent.textContent = `${data.percentComplete}`; - dynamicGoal.textContent = `${data.targetValue}`; - - loaded = true; - }; - new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (!loaded && entry.intersectionRatio > VISIBILITY_COVERAGE_FRACTION) setFundraisingGoal(); - }); - }, - { threshold: VISIBILITY_COVERAGE_FRACTION }, - ).observe(fundraising); -} diff --git a/website/static/js/developer-guide-editor-structure.js b/website/static/js/page/contributor-guide/editor-structure.js similarity index 100% rename from website/static/js/developer-guide-editor-structure.js rename to website/static/js/page/contributor-guide/editor-structure.js diff --git a/website/static/js/book.js b/website/static/js/template/book.js similarity index 100% rename from website/static/js/book.js rename to website/static/js/template/book.js diff --git a/website/templates/book.html b/website/templates/book.html index 59d99c739c..22e2c47957 100644 --- a/website/templates/book.html +++ b/website/templates/book.html @@ -6,7 +6,7 @@ {%- set meta_article_type = true -%} {%- set meta_description = page.extra.summary | default(value = page.content | striptags | safe | linebreaksbr | replace(from = "
", to = " ") | replace(from = " ", to = " ") | trim | truncate(length = 200)) -%} {%- set css = ["/template/book.css", "/layout/reading-material.css", "/component/code-snippet.css"] -%} -{%- set js = ["/js/book.js"] -%} +{%- set js = ["/js/template/book.js"] -%} {%- endblock head -%} {%- block content -%}{%- set page = page | default(value = section) -%}