Add the auto-generated node catalog to the website's user manual (#3662)

* Generate the MVP node catalog in the manual (with some placeholders)

* Implement nearly the rest of everything

* Move to the tools directory and make it generate nicer default values

* Add category descriptions

* Organize file structure and improve type naming

* Improve book table of contents code

* Add collapsing chapter navigation to the book template

* Add to build workflow

* Clean up site structure
This commit is contained in:
Keavon Chambers 2026-01-20 22:52:03 -08:00 committed by GitHub
parent 5543afd44b
commit 7af60e02a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 1131 additions and 384 deletions

View File

@ -80,6 +80,11 @@ jobs:
cd website cd website
npm run generate-editor-structure npm run generate-editor-structure
- name: Generate node catalog documentation
run: |
cd tools/node-docs
cargo run
- name: 🌐 Build Graphite website with Zola - name: 🌐 Build Graphite website with Zola
env: env:
MODE: prod MODE: prod

View File

@ -26,6 +26,10 @@
// Configured in `.prettierrc` // Configured in `.prettierrc`
"editor.defaultFormatter": "esbenp.prettier-vscode" "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 // Handlebars: don't save on format
// (`about.hbs` is used by Cargo About to encode license information) // (`about.hbs` is used by Cargo About to encode license information)
"[handlebars]": { "[handlebars]": {

19
Cargo.lock generated
View File

@ -2978,9 +2978,12 @@ dependencies = [
[[package]] [[package]]
name = "indoc" name = "indoc"
version = "2.0.6" version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]] [[package]]
name = "inotify" name = "inotify"
@ -3695,6 +3698,18 @@ dependencies = [
"spirv-std", "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]] [[package]]
name = "node-macro" name = "node-macro"
version = "0.0.0" version = "0.0.0"

View File

@ -39,7 +39,8 @@ members = [
"node-graph/node-macro", "node-graph/node-macro",
"node-graph/preprocessor", "node-graph/preprocessor",
"proc-macros", "proc-macros",
"tools/crate-hierarchy-viz" "tools/crate-hierarchy-viz",
"tools/node-docs"
] ]
default-members = [ default-members = [
"editor", "editor",
@ -137,6 +138,7 @@ log = "0.4"
bitflags = { version = "2.4", features = ["serde"] } bitflags = { version = "2.4", features = ["serde"] }
ctor = "0.2" ctor = "0.2"
convert_case = "0.8" convert_case = "0.8"
indoc = "2.0.5"
derivative = "2.2" derivative = "2.2"
thiserror = "2" thiserror = "2"
anyhow = "1.0" anyhow = "1.0"
@ -151,7 +153,7 @@ wgpu = { version = "27.0", features = [
"spirv", "spirv",
"strict_asserts", "strict_asserts",
] } ] }
once_cell = "1.13" # Remove when `core::cell::LazyCell` (<https://doc.rust-lang.org/core/cell/struct.LazyCell.html>) is stabilized in Rust 1.80 and we bump our MSRV once_cell = "1.13" # Remove and replace with `core::cell::LazyCell` (<https://doc.rust-lang.org/core/cell/struct.LazyCell.html>)
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 = "=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" wasm-bindgen-futures = "0.4"
js-sys = "=0.3.77" js-sys = "=0.3.77"
@ -177,10 +179,16 @@ winit = { git = "https://github.com/rust-windowing/winit.git" }
keyboard-types = "0.8" keyboard-types = "0.8"
url = "2.5" url = "2.5"
tokio = { version = "1.29", features = ["fs", "macros", "io-std", "rt"] } 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 = { git = "https://github.com/linebender/vello" }
vello_encoding = { git = "https://github.com/linebender/vello" } vello_encoding = { git = "https://github.com/linebender/vello" }
resvg = "0.45" resvg = "0.45"
usvg = "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 = { version = "0.9", default-features = false, features = ["std_rng"] }
rand_chacha = "0.9" rand_chacha = "0.9"
glam = { version = "0.29", default-features = false, features = [ glam = { version = "0.29", default-features = false, features = [
@ -194,8 +202,6 @@ image = { version = "0.25", default-features = false, features = [
"jpeg", "jpeg",
"bmp", "bmp",
] } ] }
parley = "0.6"
skrifa = "0.36"
pretty_assertions = "1.4" pretty_assertions = "1.4"
fern = { version = "0.7", features = ["colored"] } fern = { version = "0.7", features = ["colored"] }
num_enum = { version = "0.7", default-features = false } num_enum = { version = "0.7", default-features = false }
@ -217,7 +223,6 @@ syn = { version = "2.0", default-features = false, features = [
"extra-traits", "extra-traits",
"proc-macro", "proc-macro",
] } ] }
kurbo = { version = "0.12", features = ["serde"] }
lyon_geom = "1.0" lyon_geom = "1.0"
petgraph = { version = "0.7", default-features = false, features = ["graphmap"] } petgraph = { version = "0.7", default-features = false, features = ["graphmap"] }
half = { version = "2.4", default-features = false, features = ["bytemuck"] } half = { version = "2.4", default-features = false, features = ["bytemuck"] }
@ -234,7 +239,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing = "0.1" tracing = "0.1"
rfd = "0.15" rfd = "0.15"
open = "5.3" open = "5.3"
polycool = "0.4"
spin = "0.10" spin = "0.10"
clap = "4.5" clap = "4.5"
spirv-std = { git = "https://github.com/Firestar99/rust-gpu-new", rev = "c12f216121820580731440ee79ebc7403d6ea04f", features = ["bytemuck"] } spirv-std = { git = "https://github.com/Firestar99/rust-gpu-new", rev = "c12f216121820580731440ee79ebc7403d6ea04f", features = ["bytemuck"] }

View File

@ -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 // 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: If dynamic node library is required, use a Mutex as well
// TODO: Replace with `core::cell::LazyCell` (<https://doc.rust-lang.org/core/cell/struct.LazyCell.html>) or similar
static DOCUMENT_NODE_TYPES: once_cell::sync::Lazy<HashMap<DefinitionIdentifier, DocumentNodeDefinition>> = once_cell::sync::Lazy::new(document_node_definitions); static DOCUMENT_NODE_TYPES: once_cell::sync::Lazy<HashMap<DefinitionIdentifier, DocumentNodeDefinition>> = 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). /// Defines the "signature" or "header file"-like metadata for the document nodes, but not the implementation (which is defined in the node registry).
@ -1773,7 +1774,7 @@ fn document_node_definitions() -> HashMap<DefinitionIdentifier, DocumentNodeDefi
}, },
DocumentNodeDefinition { DocumentNodeDefinition {
identifier: "Boolean Operation", identifier: "Boolean Operation",
category: "Vector", category: "Vector: Modifier",
node_template: NodeTemplate { node_template: NodeTemplate {
document_node: DocumentNode { document_node: DocumentNode {
implementation: DocumentNodeImplementation::Network(NodeNetwork { implementation: DocumentNodeImplementation::Network(NodeNetwork {
@ -2076,6 +2077,7 @@ fn document_node_definitions() -> HashMap<DefinitionIdentifier, DocumentNodeDefi
type NodeProperties = HashMap<String, Box<dyn Fn(NodeId, &mut NodePropertiesContext) -> Vec<LayoutGroup> + Send + Sync>>; type NodeProperties = HashMap<String, Box<dyn Fn(NodeId, &mut NodePropertiesContext) -> Vec<LayoutGroup> + Send + Sync>>;
// TODO: Replace with `core::cell::LazyCell` (<https://doc.rust-lang.org/core/cell/struct.LazyCell.html>) or similar
pub static NODE_OVERRIDES: once_cell::sync::Lazy<NodeProperties> = once_cell::sync::Lazy::new(static_node_properties); pub static NODE_OVERRIDES: once_cell::sync::Lazy<NodeProperties> = once_cell::sync::Lazy::new(static_node_properties);
/// Defines the logic for inputs to display a custom properties panel widget. /// Defines the logic for inputs to display a custom properties panel widget.
@ -2102,6 +2104,7 @@ fn static_node_properties() -> NodeProperties {
type InputProperties = HashMap<String, Box<dyn Fn(NodeId, usize, &mut NodePropertiesContext) -> Result<Vec<LayoutGroup>, String> + Send + Sync>>; type InputProperties = HashMap<String, Box<dyn Fn(NodeId, usize, &mut NodePropertiesContext) -> Result<Vec<LayoutGroup>, String> + Send + Sync>>;
// TODO: Replace with `core::cell::LazyCell` (<https://doc.rust-lang.org/core/cell/struct.LazyCell.html>) or similar
static INPUT_OVERRIDES: once_cell::sync::Lazy<InputProperties> = once_cell::sync::Lazy::new(static_input_properties); static INPUT_OVERRIDES: once_cell::sync::Lazy<InputProperties> = once_cell::sync::Lazy::new(static_input_properties);
/// Defines the logic for inputs to display a custom properties panel widget. /// Defines the logic for inputs to display a custom properties panel widget.

View File

@ -75,7 +75,7 @@ pub(super) fn post_process_nodes(custom: Vec<DocumentNodeDefinition>) -> HashMap
..Default::default() ..Default::default()
}, },
}, },
category: category.unwrap_or("UNCATEGORIZED"), category,
description: Cow::Borrowed(description), description: Cow::Borrowed(description),
properties: *properties, properties: *properties,
}, },

View File

@ -22,7 +22,6 @@ use graphene_std::wasm_application_io::{RenderOutputType, WasmApplicationIo, Was
use graphene_std::{Artboard, Context, Graphic}; use graphene_std::{Artboard, Context, Graphic};
use interpreted_executor::dynamic_executor::{DynamicExecutor, IntrospectError, ResolvedDocumentNodeTypesDelta}; use interpreted_executor::dynamic_executor::{DynamicExecutor, IntrospectError, ResolvedDocumentNodeTypesDelta};
use interpreted_executor::util::wrap_network_in_scope; use interpreted_executor::util::wrap_network_in_scope;
use once_cell::sync::Lazy;
use spin::Mutex; use spin::Mutex;
use std::sync::Arc; use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender}; use std::sync::mpsc::{Receiver, Sender};
@ -108,7 +107,8 @@ impl NodeGraphUpdateSender for InternalNodeGraphUpdateSender {
} }
} }
pub static NODE_RUNTIME: Lazy<Mutex<Option<NodeRuntime>>> = Lazy::new(|| Mutex::new(None)); // TODO: Replace with `core::cell::LazyCell` (<https://doc.rust-lang.org/core/cell/struct.LazyCell.html>) or similar
pub static NODE_RUNTIME: once_cell::sync::Lazy<Mutex<Option<NodeRuntime>>> = once_cell::sync::Lazy::new(|| Mutex::new(None));
impl NodeRuntime { impl NodeRuntime {
pub fn new(receiver: Receiver<GraphRuntimeRequest>, sender: Sender<NodeGraphUpdate>) -> Self { pub fn new(receiver: Receiver<GraphRuntimeRequest>, sender: Sender<NodeGraphUpdate>) -> Self {

View File

@ -116,15 +116,15 @@ macro_rules! tagged_value {
_ => Err(format!("Cannot convert {:?} to TaggedValue",std::any::type_name_of_val(input))), _ => 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<Self> { pub fn from_type(input: &Type) -> Option<Self> {
match input { match input {
Type::Generic(_) => None, Type::Generic(_) => None,
Type::Concrete(concrete_type) => { Type::Concrete(concrete_type) => {
let internal_id = concrete_type.id?;
use std::any::TypeId; use std::any::TypeId;
// TODO: Add default implementations for types such as TaggedValue::Subpaths, and use the defaults here and in document_node_types // 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. // 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::<()>() => TaggedValue::None,
$( x if x == TypeId::of::<$ty>() => TaggedValue::$identifier(Default::default()), )* $( x if x == TypeId::of::<$ty>() => TaggedValue::$identifier(Default::default()), )*
_ => return None, _ => return None,
@ -139,6 +139,15 @@ macro_rules! tagged_value {
pub fn from_type_or_none(input: &Type) -> Self { pub fn from_type_or_none(input: &Type) -> Self {
Self::from_type(input).unwrap_or(TaggedValue::None) 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 { match ty {
Type::Generic(_) => None, Type::Generic(_) => None,
Type::Concrete(concrete_type) => { Type::Concrete(concrete_type) => {
let internal_id = concrete_type.id?; let ty = concrete_type.id?;
use std::any::TypeId; use std::any::TypeId;
// TODO: Add default implementations for types such as TaggedValue::Subpaths, and use the defaults here and in document_node_types // 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. // 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 { let ty = match () {
x if x == TypeId::of::<()>() => TaggedValue::None, () if ty == TypeId::of::<()>() => TaggedValue::None,
x if x == TypeId::of::<String>() => TaggedValue::String(string.into()), () if ty == TypeId::of::<String>() => TaggedValue::String(string.into()),
x if x == TypeId::of::<f64>() => FromStr::from_str(string).map(TaggedValue::F64).ok()?, () if ty == TypeId::of::<f64>() => FromStr::from_str(string).map(TaggedValue::F64).ok()?,
x if x == TypeId::of::<f32>() => FromStr::from_str(string).map(TaggedValue::F32).ok()?, () if ty == TypeId::of::<f32>() => FromStr::from_str(string).map(TaggedValue::F32).ok()?,
x if x == TypeId::of::<u64>() => FromStr::from_str(string).map(TaggedValue::U64).ok()?, () if ty == TypeId::of::<u64>() => FromStr::from_str(string).map(TaggedValue::U64).ok()?,
x if x == TypeId::of::<u32>() => FromStr::from_str(string).map(TaggedValue::U32).ok()?, () if ty == TypeId::of::<u32>() => FromStr::from_str(string).map(TaggedValue::U32).ok()?,
x if x == TypeId::of::<DVec2>() => to_dvec2(string).map(TaggedValue::DVec2)?, () if ty == TypeId::of::<DVec2>() => to_dvec2(string).map(TaggedValue::DVec2)?,
x if x == TypeId::of::<bool>() => FromStr::from_str(string).map(TaggedValue::Bool).ok()?, () if ty == TypeId::of::<bool>() => FromStr::from_str(string).map(TaggedValue::Bool).ok()?,
x if x == TypeId::of::<Color>() => to_color(string).map(TaggedValue::ColorNotInTable)?, () if ty == TypeId::of::<Color>() => to_color(string).map(TaggedValue::ColorNotInTable)?,
x if x == TypeId::of::<Option<Color>>() => TaggedValue::ColorNotInTable(to_color(string)?), () if ty == TypeId::of::<Option<Color>>() => TaggedValue::ColorNotInTable(to_color(string)?),
x if x == TypeId::of::<Table<Color>>() => to_color(string).map(|color| TaggedValue::Color(Table::new_from_element(color)))?, () if ty == TypeId::of::<Table<Color>>() => to_color(string).map(|color| TaggedValue::Color(Table::new_from_element(color)))?,
x if x == TypeId::of::<Fill>() => to_color(string).map(|color| TaggedValue::Fill(Fill::solid(color)))?, () if ty == TypeId::of::<Fill>() => to_color(string).map(|color| TaggedValue::Fill(Fill::solid(color)))?,
x if x == TypeId::of::<ReferencePoint>() => to_reference_point(string).map(TaggedValue::ReferencePoint)?, () if ty == TypeId::of::<ReferencePoint>() => to_reference_point(string).map(TaggedValue::ReferencePoint)?,
_ => return None, _ => return None,
}; };
Some(ty) Some(ty)

View File

@ -21,7 +21,7 @@ pub fn detect_file_type(path: &Path) -> Result<FileType, String> {
Some("svg") => Ok(FileType::Svg), Some("svg") => Ok(FileType::Svg),
Some("png") => Ok(FileType::Png), Some("png") => Ok(FileType::Png),
Some("jpg" | "jpeg") => Ok(FileType::Jpg), 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, output_path: PathBuf,
file_type: FileType, file_type: FileType,
scale: f64, scale: f64,
width: Option<u32>, (width, height): (Option<u32>, Option<u32>),
height: Option<u32>,
transparent: bool, transparent: bool,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
// Determine export format based on file type // Determine export format based on file type
@ -42,10 +41,12 @@ pub async fn export_document(
}; };
// Create render config with export settings // Create render config with export settings
let mut render_config = RenderConfig::default(); let mut render_config = RenderConfig {
render_config.export_format = export_format; scale,
render_config.for_export = true; export_format,
render_config.scale = scale; for_export: true,
..Default::default()
};
// Set viewport dimensions if specified // Set viewport dimensions if specified
if let (Some(w), Some(h)) = (width, height) { if let (Some(w), Some(h)) = (width, height) {

View File

@ -97,9 +97,9 @@ async fn main() -> Result<(), Box<dyn Error>> {
Command::Compile { ref document, .. } => document, Command::Compile { ref document, .. } => document,
Command::Export { ref document, .. } => document, Command::Export { ref document, .. } => document,
Command::ListNodeIdentifiers => { Command::ListNodeIdentifiers => {
let mut ids: Vec<_> = graphene_std::registry::NODE_METADATA.lock().unwrap().keys().cloned().collect(); let mut nodes: Vec<_> = graphene_std::registry::NODE_METADATA.lock().unwrap().keys().cloned().collect();
ids.sort_by_key(|x| x.as_str().to_string()); nodes.sort_by_key(|x| x.as_str().to_string());
for id in ids { for id in nodes {
println!("{}", id.as_str()); println!("{}", id.as_str());
} }
return Ok(()); return Ok(());
@ -108,7 +108,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
let document_string = std::fs::read_to_string(document_path).expect("Failed to read document"); 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()); let mut application_io = block_on(WasmApplicationIo::new_offscreen());
if let Command::Export { image: Some(ref image_path), .. } = app.command { if let Command::Export { image: Some(ref image_path), .. } = app.command {
@ -164,7 +164,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
let executor = create_executor(proto_graph)?; let executor = create_executor(proto_graph)?;
// Perform export // 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"), _ => unreachable!("All other commands should be handled before this match statement is run"),
} }

View File

@ -23,7 +23,6 @@ use graphene_std::wasm_application_io::WasmEditorApi;
use graphene_std::wasm_application_io::WasmSurfaceHandle; use graphene_std::wasm_application_io::WasmSurfaceHandle;
use graphene_std::{Artboard, Context, Graphic, NodeIO, NodeIOTypes, ProtoNodeIdentifier, concrete, fn_type_fut, future}; 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 node_registry_macros::{async_node, convert_node, into_node};
use once_cell::sync::Lazy;
use std::collections::HashMap; use std::collections::HashMap;
#[cfg(feature = "gpu")] #[cfg(feature = "gpu")]
use std::sync::Arc; use std::sync::Arc;
@ -282,7 +281,8 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
map map
} }
pub static NODE_REGISTRY: Lazy<HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeConstructor>>> = Lazy::new(|| node_registry()); // TODO: Replace with `core::cell::LazyCell` (<https://doc.rust-lang.org/core/cell/struct.LazyCell.html>) or similar
pub static NODE_REGISTRY: once_cell::sync::Lazy<HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeConstructor>>> = once_cell::sync::Lazy::new(|| node_registry());
mod node_registry_macros { mod node_registry_macros {
macro_rules! async_node { macro_rules! async_node {

View File

@ -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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, dyn_any::DynAny, serde::Serialize, serde::Deserialize, Default)]
pub struct ContextDependencies { pub struct ContextDependencies {
pub extract: ContextFeatures, pub extract: ContextFeatures,

View File

@ -11,7 +11,7 @@ use std::sync::{LazyLock, Mutex};
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct NodeMetadata { pub struct NodeMetadata {
pub display_name: &'static str, pub display_name: &'static str,
pub category: Option<&'static str>, pub category: &'static str,
pub fields: Vec<FieldMetadata>, pub fields: Vec<FieldMetadata>,
pub description: &'static str, pub description: &'static str,
pub properties: Option<&'static str>, pub properties: Option<&'static str>,
@ -23,6 +23,7 @@ pub struct NodeMetadata {
pub struct FieldMetadata { pub struct FieldMetadata {
pub name: &'static str, pub name: &'static str,
pub description: &'static str, pub description: &'static str,
pub hidden: bool,
pub exposed: bool, pub exposed: bool,
pub widget_override: RegistryWidgetOverride, pub widget_override: RegistryWidgetOverride,
pub value_source: RegistryValueSource, pub value_source: RegistryValueSource,

View File

@ -1,3 +1,4 @@
use crate::transform::Footprint;
use std::any::TypeId; use std::any::TypeId;
pub use std::borrow::Cow; pub use std::borrow::Cow;
use std::fmt::{Display, Formatter}; 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)] #[derive(Clone, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
pub struct NodeIOTypes { pub struct NodeIOTypes {
pub call_argument: Type, pub call_argument: Type,
@ -351,7 +353,10 @@ pub fn format_type(ty: &str) -> String {
} }
pub fn make_type_user_readable(ty: &str) -> String { pub fn make_type_user_readable(ty: &str) -> String {
ty.replace("Option<Arc<OwnedContextImpl>>", "Context").replace("Vector<Option<Table<Graphic>>>", "Vector") ty.replace("Option<Arc<OwnedContextImpl>>", "Context")
.replace("Vector<Option<Table<Graphic>>>", "Vector")
.replace("Raster<CPU>", "Raster")
.replace("Raster<GPU>", "Raster")
} }
impl std::fmt::Debug for Type { impl std::fmt::Debug for Type {
@ -372,6 +377,19 @@ impl std::fmt::Debug for Type {
impl std::fmt::Display for Type { impl std::fmt::Display for Type {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 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 { let text = match self {
Type::Generic(name) => name.to_string(), Type::Generic(name) => name.to_string(),
Type::Concrete(ty) => format_type(&ty.name), Type::Concrete(ty) => format_type(&ty.name),

View File

@ -23,8 +23,8 @@ proc-macro2 = { workspace = true }
quote = { workspace = true } quote = { workspace = true }
convert_case = { workspace = true } convert_case = { workspace = true }
strum = { workspace = true } strum = { workspace = true }
indoc = { workspace = true }
indoc = "2.0.5"
proc-macro-crate = "3.1.0" proc-macro-crate = "3.1.0"
proc-macro-error2 = "2" proc-macro-error2 = "2"

View File

@ -28,7 +28,10 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
} = parsed; } = parsed;
let core_types = crate_ident.gcore()?; 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 mod_name = format_ident!("_{}_mod", mod_name);
let display_name = match &attributes.display_name.as_ref() { let display_name = match &attributes.display_name.as_ref() {
@ -98,6 +101,8 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
}) })
.collect(); .collect();
let input_hidden = regular_field_names.iter().map(|name| name.to_string().starts_with('_')).collect::<Vec<_>>();
let input_descriptions: Vec<_> = regular_fields.iter().map(|f| &f.description).collect(); let input_descriptions: Vec<_> = regular_fields.iter().map(|f| &f.description).collect();
// Generate struct fields: data fields (concrete types) + regular fields (generic types) // Generate struct fields: data fields (concrete types) + regular fields (generic types)
@ -475,6 +480,7 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
name: #input_names, name: #input_names,
widget_override: #widget_override, widget_override: #widget_override,
description: #input_descriptions, description: #input_descriptions,
hidden: #input_hidden,
exposed: #exposed, exposed: #exposed,
value_source: #value_sources, value_source: #value_sources,
default_type: #default_types, default_type: #default_types,

View File

@ -211,9 +211,13 @@ impl Parse for NodeFnAttributes {
// syn::parenthesized!(content in input); // syn::parenthesized!(content in input);
let nested = content.call(Punctuated::<Meta, Comma>::parse_terminated)?; let nested = content.call(Punctuated::<Meta, Comma>::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"))?; 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() { 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" => { "category" => {
let meta = meta.require_list()?; let meta = meta.require_list()?;
if category.is_some() { 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\")"))?; .map_err(|_| Error::new_spanned(meta, "Expected a string literal for 'category', e.g., category(\"Value\")"))?;
category = Some(lit); 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" => { "name" => {
let meta = meta.require_list()?; let meta = meta.require_list()?;
if display_name.is_some() { 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\")"))?; 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); 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" => { "path" => {
let meta = meta.require_list()?; let meta = meta.require_list()?;
if path.is_some() { 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)"))?; .map_err(|_| Error::new_spanned(meta, "Expected a valid path for 'path', e.g., path(crate::MemoizeNode)"))?;
path = Some(parsed_path); 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" => { "skip_impl" => {
let path = meta.require_path_only()?; let path = meta.require_path_only()?;
if skip_impl { if skip_impl {
@ -249,31 +271,48 @@ impl Parse for NodeFnAttributes {
} }
skip_impl = true; 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" => { "properties" => {
let meta = meta.require_list()?; let meta = meta.require_list()?;
if properties_string.is_some() { 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 let parsed_properties_string: LitStr = meta
.parse_args() .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); 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" => { "cfg" => {
if cfg.is_some() { 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()?; let meta = meta.require_list()?;
cfg = Some(meta.tokens.clone()); 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" => { "shader_node" => {
if shader_node.is_some() { 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()?; let meta = meta.require_list()?;
shader_node = Some(syn::parse2(meta.tokens.to_token_stream())?); 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" => { "serialize" => {
let meta = meta.require_list()?; let meta = meta.require_list()?;
if serialize.is_some() { if serialize.is_some() {
@ -290,10 +329,9 @@ impl Parse for NodeFnAttributes {
indoc!( indoc!(
r#" r#"
Unsupported attribute in `node`. 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: 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 { Ok(NodeFnAttributes {
category, category,
display_name, display_name,
@ -315,7 +366,7 @@ impl Parse for NodeFnAttributes {
} }
fn parse_node_fn(attr: TokenStream2, item: TokenStream2) -> syn::Result<ParsedNodeFn> { fn parse_node_fn(attr: TokenStream2, item: TokenStream2) -> syn::Result<ParsedNodeFn> {
let attributes = syn::parse2::<NodeFnAttributes>(attr.clone()).map_err(|e| Error::new(e.span(), format!("Failed to parse node_fn attributes: {e}")))?; let attributes = syn::parse2::<NodeFnAttributes>(attr.clone()).map_err(|e| Error::new(e.span(), format!("Failed to parse node_fn attributes:\n{e}")))?;
let input_fn = syn::parse2::<ItemFn>(item.clone()).map_err(|e| Error::new(e.span(), format!("Failed to parse function: {e}. Make sure it's a valid Rust function.")))?; let input_fn = syn::parse2::<ItemFn>(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; let vis = input_fn.vis;
@ -482,7 +533,16 @@ fn parse_node_implementations<T: Parse>(attr: &Attribute, name: &Ident) -> syn::
fn parse_field(pat_ident: PatIdent, ty: Type, attrs: &[Attribute]) -> syn::Result<ParsedField> { fn parse_field(pat_ident: PatIdent, ty: Type, attrs: &[Attribute]) -> syn::Result<ParsedField> {
let ident = &pat_ident.ident; 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 is_data_field = extract_attribute(attrs, "data").is_some();
let default_value = extract_attribute(attrs, "default") 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 // Modify the new_node_fn function to use the code generation
pub fn new_node_fn(attr: TokenStream2, item: TokenStream2) -> syn::Result<TokenStream2> { pub fn new_node_fn(attr: TokenStream2, item: TokenStream2) -> syn::Result<TokenStream2> {
let crate_ident = CrateIdent::default(); 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(); 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}")))?; 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: {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 { impl ParsedNodeFn {

View File

@ -117,7 +117,7 @@ fn validate_implementations_for_generics(parsed: &ParsedNodeFn) {
quote!(#ty), quote!(#ty),
pat_ident.ident; pat_ident.ident;
help = "Add #[implementations(ConcreteType1, ConcreteType2)] to field '{}'", 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", "Generic types in Node field `{}` require an #[implementations(...)] attribute",
pat_ident.ident; pat_ident.ident;
help = "Add #[implementations(InputType1 -> OutputType1, InputType2 -> OutputType2)] to field '{}'", 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 // Additional check for Node implementations

View File

@ -176,7 +176,7 @@ impl SetClip for Table<GradientStops> {
} }
/// Applies the blend mode to the input graphics. Setting this allows for customizing how overlapping content is composited together. /// 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<T: SetBlendMode>( fn blend_mode<T: SetBlendMode>(
_: impl Ctx, _: impl Ctx,
/// The layer stack that will be composited when rendering. /// The layer stack that will be composited when rendering.
@ -198,7 +198,7 @@ fn blend_mode<T: SetBlendMode>(
/// Modifies the opacity of the input graphics by multiplying the existing opacity by this percentage. /// 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). /// 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<T: MultiplyAlpha>( fn opacity<T: MultiplyAlpha>(
_: impl Ctx, _: impl Ctx,
/// The layer stack that will be composited when rendering. /// The layer stack that will be composited when rendering.
@ -221,7 +221,7 @@ fn opacity<T: MultiplyAlpha>(
} }
/// 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. /// 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<T: SetBlendMode + MultiplyAlpha + MultiplyFill + SetClip>( fn blending<T: SetBlendMode + MultiplyAlpha + MultiplyFill + SetClip>(
_: impl Ctx, _: impl Ctx,
/// The layer stack that will be composited when rendering. /// The layer stack that will be composited when rendering.

View File

@ -184,7 +184,7 @@ pub fn blend_with_mode(background: TableRow<Raster<CPU>>, foreground: TableRow<R
/// Generates the brush strokes painted with the Brush tool as a raster image. /// Generates the brush strokes painted with the Brush tool as a raster image.
/// If an input image is supplied, strokes are drawn on top of it, expanding bounds as needed. /// If an input image is supplied, strokes are drawn on top of it, expanding bounds as needed.
#[node_macro::node(category("Raster"))] #[node_macro::node(category(""))]
async fn brush( async fn brush(
_: impl Ctx, _: impl Ctx,
/// Optional raster content that may be drawn onto. /// Optional raster content that may be drawn onto.

View File

@ -856,7 +856,7 @@ fn angle_to<T: ToPosition, U: ToPosition>(
#[expose] #[expose]
#[implementations(DVec2, DVec2, DAffine2, DAffine2)] #[implementations(DVec2, DVec2, DAffine2, DAffine2)]
target: U, 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, radians: bool,
) -> f64 { ) -> f64 {
let from = observer.to_position(); let from = observer.to_position();

View File

@ -73,7 +73,7 @@ fn luminance<T: Adjust<Color>>(
input input
} }
#[node_macro::node(category("Raster"), shader_node(PerPixelAdjust))] #[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))]
fn gamma_correction<T: Adjust<Color>>( fn gamma_correction<T: Adjust<Color>>(
_: impl Ctx, _: impl Ctx,
#[implementations( #[implementations(

View File

@ -1200,7 +1200,7 @@ async fn separate_subpaths(_: impl Ctx, content: Table<Vector>) -> Table<Vector>
.collect() .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<Vector> { fn instance_vector(ctx: impl Ctx + ExtractVarArgs) -> Table<Vector> {
let Ok(var_arg) = ctx.vararg(0) else { return Default::default() }; let Ok(var_arg) = ctx.vararg(0) else { return Default::default() };
let var_arg = var_arg as &dyn std::any::Any; let var_arg = var_arg as &dyn std::any::Any;
@ -1208,7 +1208,7 @@ fn instance_vector(ctx: impl Ctx + ExtractVarArgs) -> Table<Vector> {
var_arg.downcast_ref().cloned().unwrap_or_default() 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<Vector>, mapped: impl Node<Context<'static>, Output = Table<Vector>>) -> Table<Vector> { async fn instance_map(ctx: impl Ctx + CloneVarArgs + ExtractAll, content: Table<Vector>, mapped: impl Node<Context<'static>, Output = Table<Vector>>) -> Table<Vector> {
let mut rows = Vec::new(); let mut rows = Vec::new();
@ -2266,7 +2266,7 @@ async fn count_points(_: impl Ctx, content: Table<Vector>) -> f64 {
/// Retrieves the vec2 position (in local space) of the anchor point at the specified index in table of vector elements. /// 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. /// 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( async fn index_points(
_: impl Ctx, _: impl Ctx,
/// The vector element or elements containing the anchor points to be retrieved. /// The vector element or elements containing the anchor points to be retrieved.

View File

@ -37,7 +37,7 @@ pub fn generate_node_substitutions() -> HashMap<ProtoNodeIdentifier, DocumentNod
let NodeMetadata { fields, .. } = metadata; let NodeMetadata { fields, .. } = metadata;
let Some(implementations) = &node_registry.get(&id) else { continue }; let Some(implementations) = &node_registry.get(&id) else { continue };
let valid_inputs: HashSet<_> = 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 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()]; let mut node_io_types = vec![HashSet::new(); fields.len()];
for (_, node_io) in implementations.iter() { for (_, node_io) in implementations.iter() {
@ -46,7 +46,7 @@ pub fn generate_node_substitutions() -> HashMap<ProtoNodeIdentifier, DocumentNod
} }
} }
let mut input_type = &first_node_io.call_argument; let mut input_type = &first_node_io.call_argument;
if valid_inputs.len() > 1 { if valid_call_args.len() > 1 {
input_type = &const { generic!(D) }; input_type = &const { generic!(D) };
} }

View File

@ -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 }

View File

@ -0,0 +1,43 @@
mod page_catalog;
mod page_category;
mod page_node;
mod utility;
use crate::utility::*;
use convert_case::{Case, Casing};
use std::collections::HashMap;
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::<Vec<_>>();
categories.sort();
// Create _index.md for the node catalog page
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() {
// 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}");
page_category::write_category_index_page(index, category, &nodes, &category_path);
// Create individual node pages
for (index, (id, metadata)) in nodes.into_iter().enumerate() {
page_node::write_node_page(index, id, metadata, &category_path);
}
}
}

View File

@ -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.
<p><img src=\"https://static.graphite.art/content/learn/node-catalog/node-terminology.avif\" onerror=\"this.onerror = null; this.src = this.src.replace('.avif', '.png')\" alt=\"Terminology diagram covering how the node system operates\" /></p>
"
);
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", "</p><p>").replace('\n', "<br />");
format!("| [{category}](./{category_path_part}) | <p>{details}</p> |")
})
.collect::<Vec<_>>()
.join("\n");
page.write_all(content.as_bytes()).expect("Failed to write to index file");
}

View File

@ -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!("<p>{}</p>", line.trim())).collect::<Vec<_>>().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::<Vec<_>>();
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::<Vec<_>>()
};
let possible_types = valid_primary_inputs_to_outputs.join("<br />");
// Add table row
Some(format!("| [{name}]({name_url_part}) | {details} | {possible_types} |"))
})
.collect::<Vec<_>>()
.join("\n");
page.write_all(content.as_bytes()).expect("Failed to write to index file");
}

View File

@ -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::<Vec<_>>()
}
// Primary output types
let valid_primary_outputs = implementations.iter().map(|(_, node_io)| node_io.return_value.nested_type().clone()).collect::<Vec<_>>();
// 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::<Vec<_>>().join("<br />");
context_features.push_str(&format!("\n| **Reads** | {names} |"));
}
if !inject.is_empty() {
let names = inject.iter().map(|ty| format!("`{}`", ty.name())).collect::<Vec<_>>().join("<br />");
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<core_types::Type>], 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::<Vec<_>>();
possible_types.sort();
possible_types.dedup();
let mut possible_types = possible_types.join("<br />");
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!("<p>{}</p>", line.trim()))
.collect::<Vec<_>>();
// Details: primary input
if index == 0 {
details.push("<p>*Primary Input*</p>".to_string());
}
// Details: exposed by default
if field.exposed {
details.push("<p>*Exposed to the Graph by Default*</p>".to_string());
}
// Details: sourced from scope
if let RegistryValueSource::Scope(scope_name) = &field.value_source {
details.push(format!("<p>*Sourced From Scope: `{scope_name}`*</p>"));
}
// 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#"<span style="padding-right: 100px; border: 2px solid var(--color-fog); background: {color}"></span>"#);
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!("<p>*Default:*&nbsp;{default_value}</p>"));
}
// Construct the table row
let details = details.join("");
format!("| {parameter} | {details} | {possible_types} |")
})
.collect::<Vec<_>>()
.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!("<p>{details}</p>");
// Details: primary output
details.push_str("<p>*Primary Output*</p>");
// Possible types
let valid_primary_outputs = valid_primary_outputs.iter().map(|ty| format!("`{ty}`")).collect::<Vec<_>>();
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::<Vec<_>>()
};
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::<Vec<_>>()
};
let valid_primary_outputs = valid_primary_outputs.join("<br />");
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");
}

View File

@ -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::<String>();
// 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()
}

3
website/.gitignore vendored
View File

@ -1,4 +1,5 @@
node_modules/ node_modules/
public/ public/
static/ static/*
!static/js/ !static/js/
content/learn/node-catalog

View File

@ -4,7 +4,7 @@ template = "section.html"
[extra] [extra]
css = ["/page/index.css", "/component/carousel.css", "/component/feature-icons.css", "/component/feature-box.css", "/component/youtube-embed.css"] 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 = [] 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." 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."
+++ +++

View File

@ -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/" 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" twitter = "https://twitter.com/GraphiteEditor/status/1742576805532577937"
js = ["/js/youtube-embed.js"] js = ["/js/component/youtube-embed.js"]
css = ["/component/youtube-embed.css"] css = ["/component/youtube-embed.css"]
+++ +++

View File

@ -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" twitter = "https://x.com/GraphiteEditor/status/1880404337345851612"
bluesky = "https://bsky.app/profile/graphiteeditor.bsky.social/post/3lfxysayh622g" 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"] css = ["/component/youtube-embed.css"]
+++ +++

View File

@ -10,7 +10,7 @@ reddit = "https://www.reddit.com/r/graphite/comments/1jplm6t/internships_for_a_r
twitter = "https://x.com/GraphiteEditor/status/1907384498389651663" twitter = "https://x.com/GraphiteEditor/status/1907384498389651663"
bluesky = "https://bsky.app/profile/graphiteeditor.bsky.social/post/3llt7lbmm4s24" 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"] css = ["/component/youtube-embed.css"]
+++ +++

View File

@ -220,13 +220,3 @@ Also available to individuals wanting to make a larger impact. [Reach out](/cont
</div> </div>
</section> </section>
<!-- <div class="fundraising loading" data-fundraising>
<div class="fundraising-bar" data-fundraising-bar style="--fundraising-percent: 0%">
<div class="fundraising-bar-progress"></div>
</div>
<div class="goal-metrics">
<span data-fundraising-percent>Progress: <span data-dynamic>0</span>%</span>
<span data-fundraising-goal>Goal: $<span data-dynamic>0</span>/month</span>
</div>
</div> -->

View File

@ -3,7 +3,7 @@ title = "Graphite features"
[extra] [extra]
css = ["/page/features.css", "/component/feature-box.css", "/component/feature-icons.css", "/component/youtube-embed.css"] 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"]
+++ +++
<section> <section>

View File

@ -5,7 +5,7 @@ page_template = "book.html"
[extra] [extra]
book = true book = true
js = ["/js/youtube-embed.js"] js = ["/js/component/youtube-embed.js"]
css = ["/component/youtube-embed.css"] css = ["/component/youtube-embed.css"]
+++ +++

View File

@ -54,16 +54,16 @@ The right side of the control bar has controls related to the active document an
| | | | | |
|-|-| |-|-|
| Overlays | <p>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.</p> | | **Overlays** | <p>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.</p> |
| Snapping | <p>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.<br /><br />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.</p><p><img src="https://static.graphite.art/content/learn/interface/document-panel/snapping-popover__5.avif" onerror="this.onerror = null; this.src = this.src.replace('.avif', '.png')" onload="this.width = this.naturalWidth / 2" alt="Snapping options popover menu" /></p><p>Snapping options relating to **Bounding Boxes**:</p><p><ul><li>**Align with Edges**: Snaps to horizontal/vertical alignment with the edges of any layer's bounding box.</li><li>**Corner Points**: Snaps to the four corners of any layer's bounding box.</li><li>**Center Points**: Snaps to the center point of any layer's bounding box.</li><li>**Edge Midpoints**: Snaps to any of the four points at the middle of the edges of any layer's bounding box.</li><li>**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).</li></ul></p><p>Snapping options relating to **Paths**:</p><p><ul><li>**Align with Anchor Points**: Snaps to horizontal/vertical alignment with the anchor points of any vector path.</li><li>**Anchor Points**: Snaps to the anchor point of any vector path.</li><li>**Line Midpoints**: Snaps to the point at the middle of any straight line segment of a vector path.</li><li>**Path Intersection Points**: Snaps to any points where vector paths intersect.</li><li>**Along Paths**: Snaps along the length of any vector path.</li><li>**Normal to Paths**: Snaps a line to a point perpendicular to a vector path (due to a bug, **Intersections of Paths** must be enabled).</li><li>**Tangent to Paths**: Snaps a line to a point tangent to a vector path (due to a bug, **Intersections of Paths** must be enabled).</li></ul></p> | | **Snapping** | <p>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.<br /><br />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.</p><p><img src="https://static.graphite.art/content/learn/interface/document-panel/snapping-popover__5.avif" onerror="this.onerror = null; this.src = this.src.replace('.avif', '.png')" onload="this.width = this.naturalWidth / 2" alt="Snapping options popover menu" /></p><p>Snapping options relating to **Bounding Boxes**:</p><p><ul><li>**Align with Edges**: Snaps to horizontal/vertical alignment with the edges of any layer's bounding box.</li><li>**Corner Points**: Snaps to the four corners of any layer's bounding box.</li><li>**Center Points**: Snaps to the center point of any layer's bounding box.</li><li>**Edge Midpoints**: Snaps to any of the four points at the middle of the edges of any layer's bounding box.</li><li>**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).</li></ul></p><p>Snapping options relating to **Paths**:</p><p><ul><li>**Align with Anchor Points**: Snaps to horizontal/vertical alignment with the anchor points of any vector path.</li><li>**Anchor Points**: Snaps to the anchor point of any vector path.</li><li>**Line Midpoints**: Snaps to the point at the middle of any straight line segment of a vector path.</li><li>**Path Intersection Points**: Snaps to any points where vector paths intersect.</li><li>**Along Paths**: Snaps along the length of any vector path.</li><li>**Normal to Paths**: Snaps a line to a point perpendicular to a vector path (due to a bug, **Intersections of Paths** must be enabled).</li><li>**Tangent to Paths**: Snaps a line to a point tangent to a vector path (due to a bug, **Intersections of Paths** must be enabled).</li></ul></p> |
| Grid | <p>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.</p><ul><li><p>**Type** sets whether the grid pattern is made of squares or triangles.</p><p>**Rectangular** is a pattern of horizontal and vertical lines:</p><p><img src="https://static.graphite.art/content/learn/interface/document-panel/grid-rectangular-popover__2.avif" onerror="this.onerror = null; this.src = this.src.replace('.avif', '.png')" onload="this.width = this.naturalWidth / 2" alt="Snapping options popover menu" /></p><p>It has one option unique to this mode:</p><ul><li>**Spacing** is the width and height of the rectangle grid cells.</li></ul><p>**Isometric** is a pattern of triangles:</p><p><img src="https://static.graphite.art/content/learn/interface/document-panel/grid-isometric-popover__2.avif" onerror="this.onerror = null; this.src = this.src.replace('.avif', '.png')" onload="this.width = this.naturalWidth / 2" alt="Snapping options popover menu" /></p><p>It has two options unique to this mode:</p><ul><li>**Y Spacing** is the height between vertical repetitions of the grid.</li><li>**Angles** is the slant of the upward and downward sloped grid lines.</li></ul></li><li>**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.</li><li>**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.</li></ul> | | **Grid** | <p>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.</p><ul><li><p>**Type** sets whether the grid pattern is made of squares or triangles.</p><p>**Rectangular** is a pattern of horizontal and vertical lines:</p><p><img src="https://static.graphite.art/content/learn/interface/document-panel/grid-rectangular-popover__2.avif" onerror="this.onerror = null; this.src = this.src.replace('.avif', '.png')" onload="this.width = this.naturalWidth / 2" alt="Snapping options popover menu" /></p><p>It has one option unique to this mode:</p><ul><li>**Spacing** is the width and height of the rectangle grid cells.</li></ul><p>**Isometric** is a pattern of triangles:</p><p><img src="https://static.graphite.art/content/learn/interface/document-panel/grid-isometric-popover__2.avif" onerror="this.onerror = null; this.src = this.src.replace('.avif', '.png')" onload="this.width = this.naturalWidth / 2" alt="Snapping options popover menu" /></p><p>It has two options unique to this mode:</p><ul><li>**Y Spacing** is the height between vertical repetitions of the grid.</li><li>**Angles** is the slant of the upward and downward sloped grid lines.</li></ul></li><li>**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.</li><li>**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.</li></ul> |
| Render Mode | <p>**Normal** (default): The artwork is rendered normally.</p><p>**Outline**: The artwork is rendered as a wireframe.</p><p>**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.</p><p>**SVG Preview**: **Not implemented yet.** The artwork is rendered as it would appear when exported as an SVG image.</p> | | **Render Mode** | <p>**Normal** (default): The artwork is rendered normally.</p><p>**Outline**: The artwork is rendered as a wireframe.</p><p>**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.</p><p>**SVG Preview**: **Not implemented yet.** The artwork is rendered as it would appear when exported as an SVG image.</p> |
| Zoom In | <p>Zooms the viewport in to the next whole increment.</p> | | **Zoom In** | <p>Zooms the viewport in to the next whole increment.</p> |
| Zoom Out | <p>Zooms the viewport out to the next whole increment.</p> | | **Zoom Out** | <p>Zooms the viewport out to the next whole increment.</p> |
| Reset Tilt and Zoom to 100% | <p>Resets the viewport tilt to 0°. Resets the viewport zoom to 100% which matches the canvas and viewport pixel scale 1:1.</p> | | **Reset Tilt and Zoom to 100%** | <p>Resets the viewport tilt to 0°. Resets the viewport zoom to 100% which matches the canvas and viewport pixel scale 1:1.</p> |
| Viewport Zoom | <p>Indicates the current zoom level of the viewport and allows precise values to be chosen.</p> | | **Viewport Zoom** | <p>Indicates the current zoom level of the viewport and allows precise values to be chosen.</p> |
| Viewport Tilt | <p>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.</p> | | **Viewport Tilt** | <p>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.</p> |
| Node Graph | <p>Toggles the visibility of the overlaid node graph.</p> | | **Node Graph** | <p>Toggles the visibility of the overlaid node graph.</p> |
## Tool shelf ## Tool shelf

View File

@ -5,7 +5,7 @@ page_template = "book.html"
[extra] [extra]
order = 1 order = 1
js = ["/js/youtube-embed.js"] js = ["/js/component/youtube-embed.js"]
css = ["/component/youtube-embed.css"] css = ["/component/youtube-embed.css"]
+++ +++

View File

@ -5,7 +5,7 @@ page_template = "book.html"
[extra] [extra]
order = 2 # Chapter number order = 2 # Chapter number
js = ["/js/youtube-embed.js"] js = ["/js/component/youtube-embed.js"]
css = ["/component/youtube-embed.css"] css = ["/component/youtube-embed.css"]
+++ +++

View File

@ -3,8 +3,8 @@ title = "Editor structure"
[extra] [extra]
order = 1 # Page number after chapter intro order = 1 # Page number after chapter intro
css = ["/page/developer-guide-editor-structure.css"] css = ["/page/contributor-guide/editor-structure.css"]
js = ["/js/developer-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. 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.

View File

@ -32,7 +32,6 @@ Clone the project to a convenient location:
```sh ```sh
git clone https://github.com/GraphiteEditor/Graphite.git git clone https://github.com/GraphiteEditor/Graphite.git
cd Graphite
``` ```
## Development builds ## Development builds
@ -43,20 +42,27 @@ From either the `/` (root) or `/frontend` directories, you can run the project b
npm start npm start
``` ```
This spins up the dev server at <http://localhost:8080> 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 <kbd>Ctrl</kbd><kbd>C</kbd>. 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 <http://localhost:8080> 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 <kbd>Ctrl</kbd><kbd>C</kbd>. 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.) 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: <details>
<summary>Dev server optimized build instructions: click here</summary>
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 ```sh
# Includes debug symbols
npm run profiling npm run profiling
```
# Excludes (most) debug symbols, used in release builds To run the dev server without debug symbols, using the same release optimizations as production builds:
```sh
npm run production npm run production
``` ```
</details>
<details> <details>
<summary>Production build instructions: click here</summary> <summary>Production build instructions: click here</summary>

View File

@ -375,7 +375,7 @@ body > .page {
// ELEMENT SPACING RULES // 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, article > :first-child) ~ :is(ul, ol) li p + img,
:is(h1, h2, h3, h4, p) ~ .feature-icons, :is(h1, h2, h3, h4, p) ~ .feature-icons,
p ~ :is(h1, h2, h3, h4, details summary, blockquote, .image-comparison, .video-background, .youtube-embed), p ~ :is(h1, h2, h3, h4, details summary, blockquote, .image-comparison, .video-background, .youtube-embed),
@ -429,12 +429,12 @@ h6 {
} }
h2 { h2 {
font-size: calc(1rem * 16 / 9); font-size: 1.75rem;
font-weight: 700; font-weight: 700;
} }
h3 { h3 {
font-size: calc(1rem * 4 / 3); font-size: 1.25rem;
} }
h4, h4,
@ -496,6 +496,10 @@ table {
margin: 0; margin: 0;
padding: 20px; padding: 20px;
p {
text-align: left;
}
&:first-child { &:first-child {
padding-left: 10px; padding-left: 10px;
} }

View File

@ -1,21 +1,11 @@
.reading-material.reading-material { .reading-material.reading-material {
font-size: 16px;
line-height: 1.625;
max-width: var(--max-width-reading-material); max-width: var(--max-width-reading-material);
article { article {
width: 100%; width: 100%;
h2 {
margin-top: 80px;
}
h3 {
margin-top: 40px;
}
h4 {
margin-top: 20px;
}
h1, h1,
h2, h2,
h3, h3,
@ -23,6 +13,7 @@
h5, h5,
h6 { h6 {
display: block; display: block;
margin-top: 80px;
&:first-child { &:first-child {
margin-top: 0; margin-top: 0;

View File

@ -1,6 +1,5 @@
.structure-outline { .structure-outline {
font-family: monospace; font-family: monospace;
font-size: 18px;
line-height: 1.5; line-height: 1.5;
margin-top: 20px; margin-top: 20px;

View File

@ -107,46 +107,3 @@
background-color: var(--color-ale); background-color: var(--color-ale);
margin-top: 0; 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;
// }
// }
// }

View File

@ -0,0 +1,3 @@
table tr td:first-child a {
white-space: nowrap;
}

View File

@ -0,0 +1,14 @@
#nodes + table tr {
th:last-child {
width: 0;
white-space: nowrap;
}
td:last-child {
width: 0;
code {
white-space: nowrap;
}
}
}

View File

@ -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;
}
}
}

View File

@ -43,12 +43,12 @@
justify-content: space-between; justify-content: space-between;
width: 100%; width: 100%;
gap: 20px; gap: 20px;
a { a {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20px; gap: 20px;
svg { svg {
fill: var(--color-navy); fill: var(--color-navy);
flex: 0 0 auto; flex: 0 0 auto;
@ -77,7 +77,7 @@
// Overlaid fold-out menu for chapter selection // Overlaid fold-out menu for chapter selection
@media screen and (max-width: 1000px) { @media screen and (max-width: 1000px) {
gap: 0; gap: 0;
.chapters { .chapters {
position: sticky; position: sticky;
width: 0; width: 0;
@ -95,7 +95,7 @@
left: 0; left: 0;
} }
} }
.wrapper-outer { .wrapper-outer {
position: absolute; position: absolute;
background: white; background: white;
@ -110,8 +110,8 @@
border-right: var(--border-thickness) solid var(--color-walnut); border-right: var(--border-thickness) solid var(--color-walnut);
box-sizing: border-box; box-sizing: border-box;
transition: left 0.25s ease-in-out; transition: left 0.25s ease-in-out;
left: calc(-1 * (var(--aside-width) + 10px)); left: calc(-1 * (Min(var(--aside-width), 100vw) + 10px));
width: var(--aside-width); width: Min(var(--aside-width), 100vw);
&::after { &::after {
content: ""; content: "";
@ -128,8 +128,9 @@
overflow-y: auto; overflow-y: auto;
height: 100%; height: 100%;
padding-right: var(--page-edge-padding); 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)); margin-top: calc(120 * var(--variable-px) + var(--align-with-article-title-letter-cap-heights));
} }
@ -173,68 +174,107 @@
align-self: flex-start; align-self: flex-start;
overflow-y: auto; overflow-y: auto;
top: 0; top: 0;
width: var(--aside-width); width: Min(var(--aside-width), 100vw);
max-height: 100vh; max-height: 100vh;
margin-top: -40px; margin-top: calc(-40 * var(--variable-px));
flex: 0 1 auto; flex: 0 1 auto;
--level-indent: 0.75rem;
&.contents > ul, &.contents > ul,
.wrapper-inner > ul { .wrapper-inner > ul {
&:first-of-type { &: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 { &:last-of-type {
margin-bottom: calc(40 * var(--variable-px)); 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 { ul {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
margin-top: 40px;
&,
ul,
li {
margin-top: calc(40 * var(--variable-px));
}
ul { ul {
margin-top: 0; margin-left: var(--level-indent);
margin-left: 1em; }
ul { li:has(> label > input:not(:checked)) + ul {
margin-left: 2em; display: none;
ul {
margin-left: 3em;
ul {
margin-left: 4em;
ul {
margin-left: 5em;
}
}
}
}
} }
li { li {
margin-top: 0.5em; font-size: 0;
a {
color: var(--color-walnut);
&:hover {
color: var(--color-crimson);
}
}
&:not(.title) a { &:not(.title) a {
text-decoration: none; text-decoration: none;
} }
&.title, &.title,
&.chapter { &.chapter {
font-weight: 700; 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,\
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><polygon fill="%2316323f" points="4,0 1,0 6,5 1,10 4,10 9,5 4,0" /></svg>\
');
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;
}
}
} }
} }
@ -245,9 +285,7 @@
} }
ul a { ul a {
display: block; display: inline-block;
padding-left: 1em;
text-indent: -1em;
} }
} }

View File

@ -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);
}

View File

@ -29,7 +29,6 @@
{%- endblock %} {%- endblock %}
{#- ======================================================================== -#} {#- ======================================================================== -#}
{#- ON EVERY PAGE OF THE SITE: CSS AND JS TO LOAD EITHER AS A LINK OR INLINE -#} {#- ON EVERY PAGE OF THE SITE: CSS AND JS TO LOAD EITHER AS A LINK OR INLINE -#}
{#- ======================================================================== -#} {#- ======================================================================== -#}
{%- set global_linked_js = [] -%} {%- set global_linked_js = [] -%}
@ -41,6 +40,7 @@
{{ throw(message = "------------------------------------------------------------> FONTS ARE NOT INSTALLED! Before running Zola, execute `npm install` from the `/website` directory.") }} {{ throw(message = "------------------------------------------------------------> FONTS ARE NOT INSTALLED! Before running Zola, execute `npm install` from the `/website` directory.") }}
{%- endif -%} {%- endif -%}
{#- ================================================================================ -#}
{#- RETRIEVE FROM TEMPLATES AND PAGES: CSS AND JS TO LOAD EITHER AS A LINK OR INLINE -#} {#- 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 = [])) -%} {%- 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 css = page.extra.css | default(value = []) | concat(with = css | default(value = [])) -%}
{%- set js = page.extra.js | default(value = []) | concat(with = js | default(value = [])) -%} {%- set js = page.extra.js | default(value = []) | concat(with = js | default(value = [])) -%}
{#- =================================================== -#}
{#- COMBINE THE GLOBAL AND TEMPLATE/PAGE RESOURCE LISTS -#} {#- COMBINE THE GLOBAL AND TEMPLATE/PAGE RESOURCE LISTS -#}
{#- =================================================== -#} {#- =================================================== -#}
{%- set linked_css_list = linked_css | concat(with = global_linked_css) -%} {%- set linked_css_list = linked_css | concat(with = global_linked_css) -%}
@ -55,6 +56,7 @@
{%- set css_list = css | concat(with = global_css) -%} {%- set css_list = css | concat(with = global_css) -%}
{%- set js_list = js | concat(with = global_js) -%} {%- set js_list = js | concat(with = global_js) -%}
{#- ================================================================================== -#}
{#- CONDITIONALLY MAKE ONLY PROD BUILDS ACTUALLY INLINE THE CSS AND JS FOR CLEANLINESS -#} {#- CONDITIONALLY MAKE ONLY PROD BUILDS ACTUALLY INLINE THE CSS AND JS FOR CLEANLINESS -#}
{#- ================================================================================== -#} {#- ================================================================================== -#}
{%- if get_env(name = "MODE", default = "dev") != "prod" -%} {%- if get_env(name = "MODE", default = "dev") != "prod" -%}
@ -64,18 +66,21 @@
{%- set js_list = [] -%} {%- set js_list = [] -%}
{%- endif -%} {%- endif -%}
{#- ================ -#}
{#- INSERT CSS LINKS -#} {#- INSERT CSS LINKS -#}
{#- ================ -#} {#- ================ -#}
{%- for path in linked_css_list %} {%- for path in linked_css_list %}
<link rel="stylesheet" href="{{ path | safe }}" /> <link rel="stylesheet" href="{{ path | safe }}" />
{%- endfor %} {%- endfor %}
{#- =============== -#}
{#- INSERT JS LINKS -#} {#- INSERT JS LINKS -#}
{#- =============== -#} {#- =============== -#}
{%- for path in linked_js_list %} {%- for path in linked_js_list %}
<script src="{{ path | safe }}"></script> <script src="{{ path | safe }}"></script>
{%- endfor %} {%- endfor %}
{#- ====================== -#}
{#- INSERT INLINE CSS CODE -#} {#- INSERT INLINE CSS CODE -#}
{#- ====================== -#} {#- ====================== -#}
{%- if css_list | length > 0 %} {%- if css_list | length > 0 %}
@ -86,6 +91,7 @@
{{ "</" ~ "style>" | safe }} {{ "</" ~ "style>" | safe }}
{%- endif %} {%- endif %}
{#- ===================== -#}
{#- INSERT INLINE JS CODE -#} {#- INSERT INLINE JS CODE -#}
{#- ===================== -#} {#- ===================== -#}
{%- for path in js_list %} {%- for path in js_list %}
@ -97,51 +103,53 @@
{{- get_env(name = "INDEX_HTML_HEAD_INCLUSION", default = "") | safe }} {{- get_env(name = "INDEX_HTML_HEAD_INCLUSION", default = "") | safe }}
</head> </head>
<body> <body>
<div class="page"> <div class="page">
<header> <header>
<nav> <nav>
<div class="row"> <div class="row">
<div class="left"> <div class="left">
<a href="/"> <a href="/">
<img src="https://static.graphite.art/logos/graphite-logo-solid.svg" alt="Graphite Logo" /> <img src="https://static.graphite.art/logos/graphite-logo-solid.svg" alt="Graphite Logo" />
</a> </a>
</div> </div>
<div class="right"> <div class="right">
<a href="/learn">Learn</a> <a href="/learn">Learn</a>
<a href="/features">Features</a> <a href="/features">Features</a>
<a href="/about">About</a> <a href="/about">About</a>
<a href="/blog">Blog</a> <a href="/blog">Blog</a>
<a href="/volunteer">Volunteer</a> <a href="/volunteer">Volunteer</a>
<a href="/donate" class="heart">Donate</a> <a href="/donate" class="heart">Donate</a>
<a href="https://editor.graphite.art" class="button arrow">Launch</a> <a href="https://editor.graphite.art" class="button arrow">Launch</a>
</div> </div>
</div> </div>
</nav> </nav>
<svg class="ripple" xmlns="http://www.w3.org/2000/svg"> <svg class="ripple" xmlns="http://www.w3.org/2000/svg">
<path d="M 0,15 l 10000,0" /> <path d="M 0,15 l 10000,0" />
</svg> </svg>
<hr /> <hr />
</header> </header>
<main> <main>
{%- filter replace(from = "<!-- replacements::blog_posts(count = 2) -->", to = replacements::blog_posts(count = 2)) -%} {# This is a comment. It exists to prevent the {%- -%} on the lines below from removing the line break between `<main>` and the `content` block #}
{%- filter replace(from = "<!-- replacements::text_balancer() -->", to = replacements::text_balancer()) -%} {%- filter replace(from = "<!-- replacements::blog_posts(count = 2) -->", to = replacements::blog_posts(count = 2)) -%}
{%- filter replace(from = "<!-- replacements::hierarchical_message_system_tree() -->", to = replacements::hierarchical_message_system_tree()) -%} {%- filter replace(from = "<!-- replacements::text_balancer() -->", to = replacements::text_balancer()) -%}
{%- block content -%}{%- endblock -%} {%- filter replace(from = "<!-- replacements::hierarchical_message_system_tree() -->", to = replacements::hierarchical_message_system_tree()) -%}
{%- endfilter -%} {%- block content -%}{%- endblock -%}
{%- endfilter -%} {%- endfilter -%}
{%- endfilter -%} {%- endfilter -%}
</main> {%- endfilter -%}
<footer> {# This is a comment. It exists to prevent the {%- -%} on the lines above from removing the line break between the `content` block and `</main>` #}
<hr /> </main>
<nav> <footer>
<a href="https://github.com/GraphiteEditor/Graphite" class="link not-uppercase">GitHub</a> <hr />
<a href="/license" class="link not-uppercase">License</a> <nav>
<a href="/logo" class="link not-uppercase">Logo</a> <a href="https://github.com/GraphiteEditor/Graphite" class="link not-uppercase">GitHub</a>
<a href="/press" class="link not-uppercase">Press</a> <a href="/license" class="link not-uppercase">License</a>
<a href="/contact" class="link not-uppercase">Contact</a> <a href="/logo" class="link not-uppercase">Logo</a>
</nav> <a href="/press" class="link not-uppercase">Press</a>
<span>Copyright &copy; {{ now() | date(format = "%Y") }} Graphite Labs, LLC (an open source community organization)</span> <a href="/contact" class="link not-uppercase">Contact</a>
</footer> </nav>
</div> <span>Copyright &copy; {{ now() | date(format = "%Y") }} Graphite Labs, LLC (an open source community organization)</span>
</footer>
</div>
</body> </body>
</html> </html>

View File

@ -1,38 +1,52 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import "macros/book-outline.html" as book_outline %}
{%- block head -%}{%- set page = page | default(value = section) -%} {%- block head -%}{%- set page = page | default(value = section) -%}
{%- set title = page.title -%} {%- set title = page.title -%}
{%- set meta_article_type = true -%} {%- set meta_article_type = true -%}
{%- set meta_description = page.extra.summary | default(value = page.content | striptags | safe | linebreaksbr | replace(from = "<br>", to = " ") | replace(from = " ", to = " ") | trim | truncate(length = 200)) -%} {%- set meta_description = page.extra.summary | default(value = page.content | striptags | safe | linebreaksbr | replace(from = "<br>", to = " ") | replace(from = " ", to = " ") | trim | truncate(length = 200)) -%}
{%- set css = ["/template/book.css", "/layout/reading-material.css", "/component/code-snippet.css"] -%} {%- 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 -%} {%- endblock head -%}
{%- block content -%}{%- set page = page | default(value = section) -%} {%- 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 #} {#- 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) %} {%- for ancestor_path in page.ancestors | concat(with = page.relative_path) -%}
{# Get the ancestor section from this ancestor path string #} {#- Get the ancestor section from this ancestor path string -#}
{% if ancestor_path is ending_with("/_index.md") %} {%- if ancestor_path is ending_with("/_index.md") -%}
{% set potential_book = get_section(path = ancestor_path) %} {%- set potential_book = get_section(path = ancestor_path) -%}
{% endif %} {%- endif -%}
{# Check if the ancestor section is the root of a book, and if so, set it to a variable accessible outside the loop #} {#- 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 %} {%- if potential_book.extra.book -%}
{% set_global book = get_section(path = potential_book.path ~ "_index.md" | trim_start_matches(pat="/")) %} {%- set_global book = get_section(path = potential_book.path ~ "_index.md" | trim_start_matches(pat = "/")) -%}
{% endif %} {%- endif -%}
{% endfor %} {%- endfor -%}
{# Map this book's chapter path strings to an array of sections #} {#- Map this book's chapter path strings to an array of sections -#}
{% set chapters = [] %} {%- set chapters = [] -%}
{% for chapter_path in book.subsections %} {%- for chapter_path in book.subsections -%}
{% set_global chapters = chapters | concat(with = get_section(path = chapter_path)) %} {%- set_global chapters = chapters | concat(with = get_section(path = chapter_path)) -%}
{% endfor %} {%- endfor -%}
{% set chapters = chapters | sort(attribute = "extra.order") %} {%- 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 #} {#- A flat list of all pages in the ToC -#}
{% set flat_pages = [book] %} {%- set flattened_outline = book_outline::flatten_book_outline(section = book) -%}
{% set flat_index_of_this = 0 %} {%- 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 -%}
<section class="three-column-layout"> <section class="three-column-layout">
<aside class="chapters" data-chapters> <aside class="chapters" data-chapters>
@ -43,30 +57,7 @@
<polygon points="20.7,4.7 19.3,3.3 12,10.6 4.7,3.3 3.3,4.7 10.6,12 3.3,19.3 4.7,20.7 12,13.4 19.3,20.7 20.7,19.3 13.4,12" /> <polygon points="20.7,4.7 19.3,3.3 12,10.6 4.7,3.3 3.3,4.7 10.6,12 3.3,19.3 4.7,20.7 12,13.4 19.3,20.7 20.7,19.3 13.4,12" />
</svg> </svg>
</button> </button>
<ul> {{- book_outline::render_book_outline(parent = book, current_path = current_path, index = 0, indents = 3) }}
<li class="title{% if current_path == book.path %} active{% endif %}"><a href="{{ book.path | safe }}" title="{{ book.title | safe }}">{{ book.title }}</a></li>
</ul>
{% for chapter in chapters %}
<ul>
<li class="chapter{% if current_path == chapter.path %} active{% endif %}"><a href="{{ chapter.path | safe }}" title="{{ chapter.title | safe }}">&raquo; {{ chapter.title }}</a></li>
{% set_global flat_pages = flat_pages | concat(with = chapter) %}
{% if chapter == page %}{% set_global flat_index_of_this = flat_pages | length - 1 %}{% endif %}
{% if chapter.pages %}
{% for chapter_page in chapter.pages | sort(attribute = "extra.order") %}
{% set_global flat_pages = flat_pages | concat(with = chapter_page) %}
{% if chapter_page == page %}{% set_global flat_index_of_this = flat_pages | length - 1 %}{% endif %}
<li {% if current_path == chapter_page.path %}class="active"{% endif %}><a href="{{ chapter_page.path | safe }}" title="{{ page.title | safe }}">&raquo; {{ chapter_page.title }}</a></li>
{% endfor %}
{% endif %}
</ul>
{% endfor %}
</div> </div>
</div> </div>
</aside> </aside>
@ -86,39 +77,41 @@
</h1> </h1>
</div> </div>
<article> <article>
{{ page.content | safe }} {{ page.content | safe }}
</article> </article>
<hr /> <hr />
<div class="prev-next"> <div class="prev-next">
{% if flat_index_of_this >= 1 %} {%- if flat_index_of_this >= 1 -%}
{% set prev = flat_pages | nth(n = flat_index_of_this - 1) %} {%- set prev_path = flat_pages_path | nth(n = flat_index_of_this - 1) -%}
{% endif %} {%- set prev_title = flat_pages_title | nth(n = flat_index_of_this - 1) -%}
{% if prev %} {%- endif -%}
<a href="{{ prev.path | safe }}" title="{{ prev.title | safe }}"> {%- if prev_path %}
<a href="{{ prev_path | safe }}" title="{{ prev_title | safe }}">
<svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg"> <svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<path d="M20,0C8.95,0,0,8.95,0,20c0,11.05,8.95,20,20,20c11.05,0,20-8.95,20-20C40,8.95,31.05,0,20,0z M20,38c-9.93,0-18-8.07-18-18S10.07,2,20,2s18,8.07,18,18S29.93,38,20,38z" /> <path d="M20,0C8.95,0,0,8.95,0,20c0,11.05,8.95,20,20,20c11.05,0,20-8.95,20-20C40,8.95,31.05,0,20,0z M20,38c-9.93,0-18-8.07-18-18S10.07,2,20,2s18,8.07,18,18S29.93,38,20,38z" />
<polygon points="24.71,10.71 23.29,9.29 12.59,20 23.29,30.71 24.71,29.29 15.41,20" /> <polygon points="24.71,10.71 23.29,9.29 12.59,20 23.29,30.71 24.71,29.29 15.41,20" />
</svg> </svg>
{{ prev.title }} {{ prev_title }}
</a> </a>
{% else %} {%- else -%}
<a><!-- Spacer --></a> <a>{#- Spacer -#}</a>
{% endif %} {%- endif -%}
{% if flat_index_of_this < flat_pages | length - 1 %} {%- if flat_index_of_this < flat_pages_path | length - 1 -%}
{% set next = flat_pages | nth(n = flat_index_of_this + 1) %} {%- set next_path = flat_pages_path | nth(n = flat_index_of_this + 1) -%}
{% endif %} {%- set next_title = flat_pages_title | nth(n = flat_index_of_this + 1) -%}
{% if next %} {%- endif -%}
<a href="{{ next.path | safe }}" title="{{ next.title | safe }}"> {%- if next_path %}
{{ next.title }} <a href="{{ next_path | safe }}" title="{{ next_title | safe }}">
{{ next_title }}
<svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg"> <svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<path d="M20,0C8.95,0,0,8.95,0,20c0,11.05,8.95,20,20,20c11.05,0,20-8.95,20-20C40,8.95,31.05,0,20,0z M20,38c-9.93,0-18-8.07-18-18S10.07,2,20,2s18,8.07,18,18S29.93,38,20,38z" /> <path d="M20,0C8.95,0,0,8.95,0,20c0,11.05,8.95,20,20,20c11.05,0,20-8.95,20-20C40,8.95,31.05,0,20,0z M20,38c-9.93,0-18-8.07-18-18S10.07,2,20,2s18,8.07,18,18S29.93,38,20,38z" />
<polygon points="16.71,9.29 15.29,10.71 24.59,20 15.29,29.29 16.71,30.71 27.41,20" /> <polygon points="16.71,9.29 15.29,10.71 24.59,20 15.29,29.29 16.71,30.71 27.41,20" />
</svg> </svg>
</a> </a>
{% endif %} {%- endif %}
</div> </div>
</div> </div>
</section> </section>
@ -130,36 +123,7 @@
{% if page.toc | length > 0 %}Contents<span> (top ↑)</span>{% else %}Back to top ↑{% endif %} {% if page.toc | length > 0 %}Contents<span> (top ↑)</span>{% else %}Back to top ↑{% endif %}
</a> </a>
</li> </li>
</ul> {{- book_outline::render_book_page_toc(children = page.toc, indents = 1) }}
<ul>
{% for depth_1 in page.toc %}
<li><a href="#{{ depth_1.id }}" title="{{ depth_1.title | safe }}">{{ depth_1.title }}</a></li>
{% for depth_2 in depth_1.children %}
<ul>
<li><a href="#{{ depth_2.id }}" title="{{ depth_2.title | safe }}">{{ depth_2.title }}</a></li>
{% for depth_3 in depth_2.children %}
<ul>
<li><a href="#{{ depth_3.id }}" title="{{ depth_3.title | safe }}">{{ depth_3.title }}</a></li>
{% for depth_4 in depth_3.children %}
<ul>
<li><a href="#{{ depth_4.id }}" title="{{ depth_4.title | safe }}">{{ depth_4.title }}</a></li>
{% for depth_5 in depth_4.children %}
<ul>
<li><a href="#{{ depth_5.id }}" title="{{ depth_5.title | safe }}">{{ depth_5.title }}</a></li>
{% for depth_6 in depth_5.children %}
<ul>
<li><a href="#{{ depth_6.id }}" title="{{ depth_6.title | safe }}">{{ depth_6.title }}</a></li>
</ul>
{% endfor %}
</ul>
{% endfor %}
</ul>
{% endfor %}
</ul>
{% endfor %}
</ul>
{% endfor %}
{% endfor %}
</ul> </ul>
</aside> </aside>
</section> </section>

View File

@ -0,0 +1,87 @@
{# 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 -%}
{%- if children | length > 0 %}
{{ tabs }}<ul>
{%- for child in children %}
{{ tabs }} <li><a href="#{{ child.id }}" title="{{ child.title | safe }}">{{ child.title }}</a></li>
{{- self::render_book_page_toc(children = child.children, indents = indents + 1) -}}
{%- endfor %}
{{ tabs }}</ul>
{%- endif -%}
{%- endmacro render_book_page_toc -%}
{# Recursively render a book's chapters table of contents #}
{%- macro render_book_outline(parent, current_path, index, indents) -%}
{#- Setup -#}
{%- 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 index > 0 -%}
{%- set_global chapters = chapters | sort(attribute = "extra.order") -%}
{%- endif -%}
{#- End of setup -#}
{%- set tabs = "" -%}
{%- for i in range(end = indents) -%}
{%- set_global tabs = tabs ~ " " -%}
{%- endfor -%}
{%- if chapters | length > 0 %}
{{ tabs }}<ul>
{%- 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 }}<li {%- if classes | length > 0 %} class="{{ classes | join(sep = " ") }}"{% endif %}>
{{ tabs }}<label>{% if children and not index == 0 %}<input type="checkbox" {%- if current_path is starting_with(chapter.path) %} checked{% endif %} />{% endif %}</label>
{{ tabs }}<a href="{{ chapter.path | safe }}" title="{{ chapter.title | safe }}">{{ chapter.title }}</a>
{{ tabs }}</li>
{%- if children -%}
{{ self::render_book_outline(parent = chapter, current_path = current_path, index = index + 1, indents = indents + 1) }}
{%- endif %}
{%- endfor %}
{{ tabs }}</ul>
{%- endif -%}
{%- 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 -%}