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 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:
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:
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 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:
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:
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` |
+
+### 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 @@
{{ "" ~ "style>" | safe }}
{%- endif %}
+ {#- ===================== -#}
{#- INSERT INLINE JS CODE -#}
{#- ===================== -#}
{%- for path in js_list %}
@@ -97,51 +103,53 @@
{{- get_env(name = "INDEX_HTML_HEAD_INCLUSION", default = "") | safe }}
-
+
+
+
+
+
+
+{# 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 `` #}
+
+
+