diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 9a5f63494d..2586393077 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -80,6 +80,11 @@ jobs: cd website npm run generate-editor-structure + - name: Generate node catalog documentation + run: | + cd tools/node-docs + cargo run + - name: 🌐 Build Graphite website with Zola env: MODE: prod diff --git a/.vscode/settings.json b/.vscode/settings.json index 2b554eefae..5fbf8c7f1b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,6 +26,10 @@ // Configured in `.prettierrc` "editor.defaultFormatter": "esbenp.prettier-vscode" }, + // Website: don't format Zola/Tera-templated HTML on save + "[html]": { + "editor.formatOnSave": false + }, // Handlebars: don't save on format // (`about.hbs` is used by Cargo About to encode license information) "[handlebars]": { diff --git a/Cargo.lock b/Cargo.lock index ce4bcaf338..53ea375a56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2978,9 +2978,12 @@ dependencies = [ [[package]] name = "indoc" -version = "2.0.6" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] [[package]] name = "inotify" @@ -3695,6 +3698,18 @@ dependencies = [ "spirv-std", ] +[[package]] +name = "node-docs" +version = "0.0.0" +dependencies = [ + "convert_case 0.8.0", + "graph-craft", + "graphene-std", + "indoc", + "interpreted-executor", + "preprocessor", +] + [[package]] name = "node-macro" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 1a7fbd44bb..b2fd92d7af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,8 @@ members = [ "node-graph/node-macro", "node-graph/preprocessor", "proc-macros", - "tools/crate-hierarchy-viz" + "tools/crate-hierarchy-viz", + "tools/node-docs" ] default-members = [ "editor", @@ -137,6 +138,7 @@ log = "0.4" bitflags = { version = "2.4", features = ["serde"] } ctor = "0.2" convert_case = "0.8" +indoc = "2.0.5" derivative = "2.2" thiserror = "2" anyhow = "1.0" @@ -151,7 +153,7 @@ wgpu = { version = "27.0", features = [ "spirv", "strict_asserts", ] } -once_cell = "1.13" # Remove when `core::cell::LazyCell` () is stabilized in Rust 1.80 and we bump our MSRV +once_cell = "1.13" # Remove and replace with `core::cell::LazyCell` () wasm-bindgen = "=0.2.100" # NOTICE: ensure this stays in sync with the `wasm-bindgen-cli` version in `website/content/volunteer/guide/project-setup/_index.md`. We pin this version because wasm-bindgen upgrades may break various things. wasm-bindgen-futures = "0.4" js-sys = "=0.3.77" @@ -177,10 +179,16 @@ winit = { git = "https://github.com/rust-windowing/winit.git" } keyboard-types = "0.8" url = "2.5" tokio = { version = "1.29", features = ["fs", "macros", "io-std", "rt"] } +# Linebender ecosystem (BEGIN) +kurbo = { version = "0.12", features = ["serde"] } vello = { git = "https://github.com/linebender/vello" } vello_encoding = { git = "https://github.com/linebender/vello" } resvg = "0.45" usvg = "0.45" +parley = "0.6" +skrifa = "0.36" +polycool = "0.4" +# Linebender ecosystem (END) rand = { version = "0.9", default-features = false, features = ["std_rng"] } rand_chacha = "0.9" glam = { version = "0.29", default-features = false, features = [ @@ -194,8 +202,6 @@ image = { version = "0.25", default-features = false, features = [ "jpeg", "bmp", ] } -parley = "0.6" -skrifa = "0.36" pretty_assertions = "1.4" fern = { version = "0.7", features = ["colored"] } num_enum = { version = "0.7", default-features = false } @@ -217,7 +223,6 @@ syn = { version = "2.0", default-features = false, features = [ "extra-traits", "proc-macro", ] } -kurbo = { version = "0.12", features = ["serde"] } lyon_geom = "1.0" petgraph = { version = "0.7", default-features = false, features = ["graphmap"] } half = { version = "2.4", default-features = false, features = ["bytemuck"] } @@ -234,7 +239,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing = "0.1" rfd = "0.15" open = "5.3" -polycool = "0.4" spin = "0.10" clap = "4.5" spirv-std = { git = "https://github.com/Firestar99/rust-gpu-new", rev = "c12f216121820580731440ee79ebc7403d6ea04f", features = ["bytemuck"] } diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index 91e244dca7..1f72162800 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -124,6 +124,7 @@ pub struct DocumentNodeDefinition { // We use the once_cell to use the document node definitions throughout the editor without passing a reference // TODO: If dynamic node library is required, use a Mutex as well +// TODO: Replace with `core::cell::LazyCell` () or similar static DOCUMENT_NODE_TYPES: once_cell::sync::Lazy> = once_cell::sync::Lazy::new(document_node_definitions); /// Defines the "signature" or "header file"-like metadata for the document nodes, but not the implementation (which is defined in the node registry). @@ -1773,7 +1774,7 @@ fn document_node_definitions() -> HashMap HashMap Vec + Send + Sync>>; +// TODO: Replace with `core::cell::LazyCell` () or similar pub static NODE_OVERRIDES: once_cell::sync::Lazy = once_cell::sync::Lazy::new(static_node_properties); /// Defines the logic for inputs to display a custom properties panel widget. @@ -2102,6 +2104,7 @@ fn static_node_properties() -> NodeProperties { type InputProperties = HashMap Result, String> + Send + Sync>>; +// TODO: Replace with `core::cell::LazyCell` () or similar static INPUT_OVERRIDES: once_cell::sync::Lazy = once_cell::sync::Lazy::new(static_input_properties); /// Defines the logic for inputs to display a custom properties panel widget. diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions/document_node_derive.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions/document_node_derive.rs index 90911997dd..72a4558a67 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions/document_node_derive.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions/document_node_derive.rs @@ -75,7 +75,7 @@ pub(super) fn post_process_nodes(custom: Vec) -> HashMap ..Default::default() }, }, - category: category.unwrap_or("UNCATEGORIZED"), + category, description: Cow::Borrowed(description), properties: *properties, }, diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index 49f99fde0e..bef56a57b6 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -22,7 +22,6 @@ use graphene_std::wasm_application_io::{RenderOutputType, WasmApplicationIo, Was use graphene_std::{Artboard, Context, Graphic}; use interpreted_executor::dynamic_executor::{DynamicExecutor, IntrospectError, ResolvedDocumentNodeTypesDelta}; use interpreted_executor::util::wrap_network_in_scope; -use once_cell::sync::Lazy; use spin::Mutex; use std::sync::Arc; use std::sync::mpsc::{Receiver, Sender}; @@ -108,7 +107,8 @@ impl NodeGraphUpdateSender for InternalNodeGraphUpdateSender { } } -pub static NODE_RUNTIME: Lazy>> = Lazy::new(|| Mutex::new(None)); +// TODO: Replace with `core::cell::LazyCell` () or similar +pub static NODE_RUNTIME: once_cell::sync::Lazy>> = once_cell::sync::Lazy::new(|| Mutex::new(None)); impl NodeRuntime { pub fn new(receiver: Receiver, sender: Sender) -> Self { diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 72e81f874d..7a9d6ba906 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -116,15 +116,15 @@ macro_rules! tagged_value { _ => Err(format!("Cannot convert {:?} to TaggedValue",std::any::type_name_of_val(input))), } } + /// Returns a TaggedValue from the type, where that value is its type's `Default::default()` pub fn from_type(input: &Type) -> Option { match input { Type::Generic(_) => None, Type::Concrete(concrete_type) => { - let internal_id = concrete_type.id?; use std::any::TypeId; // TODO: Add default implementations for types such as TaggedValue::Subpaths, and use the defaults here and in document_node_types // Tries using the default for the tagged value type. If it not implemented, then uses the default used in document_node_types. If it is not used there, then TaggedValue::None is returned. - Some(match internal_id { + Some(match concrete_type.id? { x if x == TypeId::of::<()>() => TaggedValue::None, $( x if x == TypeId::of::<$ty>() => TaggedValue::$identifier(Default::default()), )* _ => return None, @@ -139,6 +139,15 @@ macro_rules! tagged_value { pub fn from_type_or_none(input: &Type) -> Self { Self::from_type(input).unwrap_or(TaggedValue::None) } + pub fn to_debug_string(&self) -> String { + match self { + Self::None => "()".to_string(), + $( Self::$identifier(x) => format!("{:?}", x), )* + Self::RenderOutput(_) => "RenderOutput".to_string(), + Self::SurfaceFrame(_) => "SurfaceFrame".to_string(), + Self::EditorApi(_) => "WasmEditorApi".to_string(), + } + } } $( @@ -351,24 +360,24 @@ impl TaggedValue { match ty { Type::Generic(_) => None, Type::Concrete(concrete_type) => { - let internal_id = concrete_type.id?; + let ty = concrete_type.id?; use std::any::TypeId; // TODO: Add default implementations for types such as TaggedValue::Subpaths, and use the defaults here and in document_node_types // Tries using the default for the tagged value type. If it not implemented, then uses the default used in document_node_types. If it is not used there, then TaggedValue::None is returned. - let ty = match internal_id { - x if x == TypeId::of::<()>() => TaggedValue::None, - x if x == TypeId::of::() => TaggedValue::String(string.into()), - x if x == TypeId::of::() => FromStr::from_str(string).map(TaggedValue::F64).ok()?, - x if x == TypeId::of::() => FromStr::from_str(string).map(TaggedValue::F32).ok()?, - x if x == TypeId::of::() => FromStr::from_str(string).map(TaggedValue::U64).ok()?, - x if x == TypeId::of::() => FromStr::from_str(string).map(TaggedValue::U32).ok()?, - x if x == TypeId::of::() => to_dvec2(string).map(TaggedValue::DVec2)?, - x if x == TypeId::of::() => FromStr::from_str(string).map(TaggedValue::Bool).ok()?, - x if x == TypeId::of::() => to_color(string).map(TaggedValue::ColorNotInTable)?, - x if x == TypeId::of::>() => TaggedValue::ColorNotInTable(to_color(string)?), - x if x == TypeId::of::>() => to_color(string).map(|color| TaggedValue::Color(Table::new_from_element(color)))?, - x if x == TypeId::of::() => to_color(string).map(|color| TaggedValue::Fill(Fill::solid(color)))?, - x if x == TypeId::of::() => to_reference_point(string).map(TaggedValue::ReferencePoint)?, + let ty = match () { + () if ty == TypeId::of::<()>() => TaggedValue::None, + () if ty == TypeId::of::() => TaggedValue::String(string.into()), + () if ty == TypeId::of::() => FromStr::from_str(string).map(TaggedValue::F64).ok()?, + () if ty == TypeId::of::() => FromStr::from_str(string).map(TaggedValue::F32).ok()?, + () if ty == TypeId::of::() => FromStr::from_str(string).map(TaggedValue::U64).ok()?, + () if ty == TypeId::of::() => FromStr::from_str(string).map(TaggedValue::U32).ok()?, + () if ty == TypeId::of::() => to_dvec2(string).map(TaggedValue::DVec2)?, + () if ty == TypeId::of::() => FromStr::from_str(string).map(TaggedValue::Bool).ok()?, + () if ty == TypeId::of::() => to_color(string).map(TaggedValue::ColorNotInTable)?, + () if ty == TypeId::of::>() => TaggedValue::ColorNotInTable(to_color(string)?), + () if ty == TypeId::of::>() => to_color(string).map(|color| TaggedValue::Color(Table::new_from_element(color)))?, + () if ty == TypeId::of::() => to_color(string).map(|color| TaggedValue::Fill(Fill::solid(color)))?, + () if ty == TypeId::of::() => to_reference_point(string).map(TaggedValue::ReferencePoint)?, _ => return None, }; Some(ty) diff --git a/node-graph/graphene-cli/src/export.rs b/node-graph/graphene-cli/src/export.rs index daf8172386..d54015ac28 100644 --- a/node-graph/graphene-cli/src/export.rs +++ b/node-graph/graphene-cli/src/export.rs @@ -21,7 +21,7 @@ pub fn detect_file_type(path: &Path) -> Result { Some("svg") => Ok(FileType::Svg), Some("png") => Ok(FileType::Png), Some("jpg" | "jpeg") => Ok(FileType::Jpg), - _ => Err(format!("Unsupported file extension. Supported formats: .svg, .png, .jpg")), + _ => Err("Unsupported file extension. Supported formats: .svg, .png, .jpg".to_string()), } } @@ -31,8 +31,7 @@ pub async fn export_document( output_path: PathBuf, file_type: FileType, scale: f64, - width: Option, - height: Option, + (width, height): (Option, Option), transparent: bool, ) -> Result<(), Box> { // Determine export format based on file type @@ -42,10 +41,12 @@ pub async fn export_document( }; // Create render config with export settings - let mut render_config = RenderConfig::default(); - render_config.export_format = export_format; - render_config.for_export = true; - render_config.scale = scale; + let mut render_config = RenderConfig { + scale, + export_format, + for_export: true, + ..Default::default() + }; // Set viewport dimensions if specified if let (Some(w), Some(h)) = (width, height) { diff --git a/node-graph/graphene-cli/src/main.rs b/node-graph/graphene-cli/src/main.rs index 6b9c4f2d0e..41f4e15515 100644 --- a/node-graph/graphene-cli/src/main.rs +++ b/node-graph/graphene-cli/src/main.rs @@ -97,9 +97,9 @@ async fn main() -> Result<(), Box> { Command::Compile { ref document, .. } => document, Command::Export { ref document, .. } => document, Command::ListNodeIdentifiers => { - let mut ids: Vec<_> = graphene_std::registry::NODE_METADATA.lock().unwrap().keys().cloned().collect(); - ids.sort_by_key(|x| x.as_str().to_string()); - for id in ids { + let mut nodes: Vec<_> = graphene_std::registry::NODE_METADATA.lock().unwrap().keys().cloned().collect(); + nodes.sort_by_key(|x| x.as_str().to_string()); + for id in nodes { println!("{}", id.as_str()); } return Ok(()); @@ -108,7 +108,7 @@ async fn main() -> Result<(), Box> { let document_string = std::fs::read_to_string(document_path).expect("Failed to read document"); - log::info!("creating gpu context",); + log::info!("Creating GPU context"); let mut application_io = block_on(WasmApplicationIo::new_offscreen()); if let Command::Export { image: Some(ref image_path), .. } = app.command { @@ -164,7 +164,7 @@ async fn main() -> Result<(), Box> { let executor = create_executor(proto_graph)?; // Perform export - export::export_document(&executor, wgpu_executor_ref, output, file_type, scale, width, height, transparent).await?; + export::export_document(&executor, wgpu_executor_ref, output, file_type, scale, (width, height), transparent).await?; } _ => unreachable!("All other commands should be handled before this match statement is run"), } diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index a7d7c3ffa1..d98adf2066 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -23,7 +23,6 @@ use graphene_std::wasm_application_io::WasmEditorApi; use graphene_std::wasm_application_io::WasmSurfaceHandle; use graphene_std::{Artboard, Context, Graphic, NodeIO, NodeIOTypes, ProtoNodeIdentifier, concrete, fn_type_fut, future}; use node_registry_macros::{async_node, convert_node, into_node}; -use once_cell::sync::Lazy; use std::collections::HashMap; #[cfg(feature = "gpu")] use std::sync::Arc; @@ -282,7 +281,8 @@ fn node_registry() -> HashMap>> = Lazy::new(|| node_registry()); +// TODO: Replace with `core::cell::LazyCell` () or similar +pub static NODE_REGISTRY: once_cell::sync::Lazy>> = once_cell::sync::Lazy::new(|| node_registry()); mod node_registry_macros { macro_rules! async_node { diff --git a/node-graph/libraries/core-types/src/context.rs b/node-graph/libraries/core-types/src/context.rs index 1ad2005f6c..c85712dd7f 100644 --- a/node-graph/libraries/core-types/src/context.rs +++ b/node-graph/libraries/core-types/src/context.rs @@ -113,6 +113,20 @@ bitflags! { } } +impl ContextFeatures { + pub fn name(&self) -> &'static str { + match *self { + ContextFeatures::FOOTPRINT => "Footprint", + ContextFeatures::REAL_TIME => "RealTime", + ContextFeatures::ANIMATION_TIME => "AnimationTime", + ContextFeatures::POINTER => "Pointer", + ContextFeatures::INDEX => "Index", + ContextFeatures::VARARGS => "VarArgs", + _ => "Multiple Features", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, dyn_any::DynAny, serde::Serialize, serde::Deserialize, Default)] pub struct ContextDependencies { pub extract: ContextFeatures, diff --git a/node-graph/libraries/core-types/src/registry.rs b/node-graph/libraries/core-types/src/registry.rs index 3b35ca568d..a472ef45b2 100644 --- a/node-graph/libraries/core-types/src/registry.rs +++ b/node-graph/libraries/core-types/src/registry.rs @@ -11,7 +11,7 @@ use std::sync::{LazyLock, Mutex}; #[derive(Clone, Debug)] pub struct NodeMetadata { pub display_name: &'static str, - pub category: Option<&'static str>, + pub category: &'static str, pub fields: Vec, pub description: &'static str, pub properties: Option<&'static str>, @@ -23,6 +23,7 @@ pub struct NodeMetadata { pub struct FieldMetadata { pub name: &'static str, pub description: &'static str, + pub hidden: bool, pub exposed: bool, pub widget_override: RegistryWidgetOverride, pub value_source: RegistryValueSource, diff --git a/node-graph/libraries/core-types/src/types.rs b/node-graph/libraries/core-types/src/types.rs index a0ad71c67f..cdef025ae4 100644 --- a/node-graph/libraries/core-types/src/types.rs +++ b/node-graph/libraries/core-types/src/types.rs @@ -1,3 +1,4 @@ +use crate::transform::Footprint; use std::any::TypeId; pub use std::borrow::Cow; use std::fmt::{Display, Formatter}; @@ -75,6 +76,7 @@ macro_rules! fn_type_fut { }; } +// TODO: Rename to NodeSignatureMonomorphization #[derive(Clone, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] pub struct NodeIOTypes { pub call_argument: Type, @@ -351,7 +353,10 @@ pub fn format_type(ty: &str) -> String { } pub fn make_type_user_readable(ty: &str) -> String { - ty.replace("Option>", "Context").replace("Vector>>", "Vector") + ty.replace("Option>", "Context") + .replace("Vector>>", "Vector") + .replace("Raster", "Raster") + .replace("Raster", "Raster") } impl std::fmt::Debug for Type { @@ -372,6 +377,19 @@ impl std::fmt::Debug for Type { impl std::fmt::Display for Type { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self == &concrete!(glam::DVec2) { + return write!(f, "vec2"); + } + if self == &concrete!(glam::DAffine2) { + return write!(f, "transform"); + } + if self == &concrete!(Footprint) { + return write!(f, "footprint"); + } + if self == &concrete!(&str) || self == &concrete!(String) { + return write!(f, "string"); + } + let text = match self { Type::Generic(name) => name.to_string(), Type::Concrete(ty) => format_type(&ty.name), diff --git a/node-graph/node-macro/Cargo.toml b/node-graph/node-macro/Cargo.toml index d8f9f5d568..47dd09e896 100644 --- a/node-graph/node-macro/Cargo.toml +++ b/node-graph/node-macro/Cargo.toml @@ -23,8 +23,8 @@ proc-macro2 = { workspace = true } quote = { workspace = true } convert_case = { workspace = true } strum = { workspace = true } +indoc = { workspace = true } -indoc = "2.0.5" proc-macro-crate = "3.1.0" proc-macro-error2 = "2" diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index aebf163ceb..8fbfe88815 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -28,7 +28,10 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn } = parsed; let core_types = crate_ident.gcore()?; - let category = &attributes.category.as_ref().map(|value| quote!(Some(#value))).unwrap_or(quote!(None)); + let category = attributes + .category + .as_ref() + .expect("The 'category' attribute is required and should be checked during parsing, but was not found during codegen"); let mod_name = format_ident!("_{}_mod", mod_name); let display_name = match &attributes.display_name.as_ref() { @@ -98,6 +101,8 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn }) .collect(); + let input_hidden = regular_field_names.iter().map(|name| name.to_string().starts_with('_')).collect::>(); + let input_descriptions: Vec<_> = regular_fields.iter().map(|f| &f.description).collect(); // 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, widget_override: #widget_override, description: #input_descriptions, + hidden: #input_hidden, exposed: #exposed, value_source: #value_sources, default_type: #default_types, diff --git a/node-graph/node-macro/src/parsing.rs b/node-graph/node-macro/src/parsing.rs index ca58b14988..863a58936a 100644 --- a/node-graph/node-macro/src/parsing.rs +++ b/node-graph/node-macro/src/parsing.rs @@ -211,9 +211,13 @@ impl Parse for NodeFnAttributes { // syn::parenthesized!(content in input); let nested = content.call(Punctuated::::parse_terminated)?; - for meta in nested { + for meta in nested.iter() { let name = meta.path().get_ident().ok_or_else(|| Error::new_spanned(meta.path(), "Node macro expects a known Ident, not a path"))?; match name.to_string().as_str() { + // User-facing category in the node catalog. The empty string `category("")` hides the node from the catalog. + // + // Example usage: + // #[node_macro::node(..., category("Math: Arithmetic"), ...)] "category" => { let meta = meta.require_list()?; if category.is_some() { @@ -224,6 +228,11 @@ impl Parse for NodeFnAttributes { .map_err(|_| Error::new_spanned(meta, "Expected a string literal for 'category', e.g., category(\"Value\")"))?; category = Some(lit); } + // Override for the display name in the node catalog in place of the auto-generated name taken from the function name with inferred Title Case formatting. + // Use this if capitalization or formatting needs to be overridden. + // + // Example usage: + // #[node_macro::node(..., name("Request URL"), ...)] "name" => { let meta = meta.require_list()?; if display_name.is_some() { @@ -232,6 +241,12 @@ impl Parse for NodeFnAttributes { let parsed_name: LitStr = meta.parse_args().map_err(|_| Error::new_spanned(meta, "Expected a string for 'name', e.g., name(\"Memoize\")"))?; display_name = Some(parsed_name); } + // Override for the fully qualified path used by Graphene to identify the node implementation. + // If not provided, the path will be inferred from the module path and function name. + // Use this if the node implementation has moved to a different module or crate but a migration to that new path is not desired. + // + // Example usage: + // #[node_macro::node(..., path(core_types::vector), ...)] "path" => { let meta = meta.require_list()?; if path.is_some() { @@ -242,6 +257,13 @@ impl Parse for NodeFnAttributes { .map_err(|_| Error::new_spanned(meta, "Expected a valid path for 'path', e.g., path(crate::MemoizeNode)"))?; path = Some(parsed_path); } + // Indicator that the node should allow generic type arguments but skip the automatic generation of concrete type implementations. + // It allows the type arguments in this node to not include the normally required `#[implementations(...)]` attribute on each generic parameter. + // Instead, concrete implementations must be manually listed in the Node Registry, or where impossible, produced at runtime by the compile server. + // This is used by a few advanced nodes that need to support many types where listing them all would be cumbersome or impossible. + // + // Example usage: + // #[node_macro::node(..., skip_impl, ...)] "skip_impl" => { let path = meta.require_path_only()?; if skip_impl { @@ -249,31 +271,48 @@ impl Parse for NodeFnAttributes { } skip_impl = true; } + // Override UI layout generator function name defined in `node_properties.rs` that returns a custom Properties panel layout for this node. + // This is used to create custom UI for the input parameters of the node in cases where the defaults generated from the type and attributes are insufficient. + // + // Example usage: + // #[node_macro::node(..., properties("channel_mixer_properties"), ...)] "properties" => { let meta = meta.require_list()?; if properties_string.is_some() { - return Err(Error::new_spanned(path, "Multiple 'properties_string' attributes are not allowed")); + return Err(Error::new_spanned(path, "Multiple 'properties' attributes are not allowed")); } let parsed_properties_string: LitStr = meta .parse_args() - .map_err(|_| Error::new_spanned(meta, "Expected a string for 'properties', e.g., name(\"channel_mixer_properties\")"))?; + .map_err(|_| Error::new_spanned(meta, "Expected a string for 'properties', e.g., properties(\"channel_mixer_properties\")"))?; properties_string = Some(parsed_properties_string); } + // Conditional compilation tokens to gate when this node is included in the build. + // + // Example usage: + // #[node_macro::node(..., cfg(feature = "std"), ...)] "cfg" => { if cfg.is_some() { - return Err(Error::new_spanned(path, "Multiple 'feature' attributes are not allowed")); + return Err(Error::new_spanned(path, "Multiple 'cfg' attributes are not allowed")); } let meta = meta.require_list()?; cfg = Some(meta.tokens.clone()); } + // Reference to a specific shader definition struct that is used to run the logic of this node on the GPU. + // + // Example usage: + // #[node_macro::node(..., shader_node(PerPixelAdjust), ...)] "shader_node" => { if shader_node.is_some() { - return Err(Error::new_spanned(path, "Multiple 'feature' attributes are not allowed")); + return Err(Error::new_spanned(path, "Multiple 'shader_node' attributes are not allowed")); } let meta = meta.require_list()?; shader_node = Some(syn::parse2(meta.tokens.to_token_stream())?); } + // Function name for custom serialization of this node's data. This is only used by the Monitor node. + // + // Example usage: + // #[node_macro::node(..., serialize(my_module::custom_serialize), ...)] "serialize" => { let meta = meta.require_list()?; if serialize.is_some() { @@ -290,10 +329,9 @@ impl Parse for NodeFnAttributes { indoc!( r#" Unsupported attribute in `node`. - Supported attributes are 'category', 'path', 'name', 'skip_impl', 'cfg', 'properties', 'serialize', and 'shader_node'. - + Supported attributes are 'category', 'name', 'path', 'skip_impl', 'properties', 'cfg', 'shader_node', and 'serialize'. Example usage: - #[node_macro::node(category("Value"), name("Test Node"))] + #[node_macro::node(..., name("Test Node"), ...)] "# ), )); @@ -301,6 +339,19 @@ impl Parse for NodeFnAttributes { } } + if category.is_none() { + return Err(Error::new_spanned( + nested, + indoc!( + r#" + The attribute 'category' is required. + Example usage: + #[node_macro::node(..., category("Value"), ...)] + "#, + ), + )); + } + Ok(NodeFnAttributes { category, display_name, @@ -315,7 +366,7 @@ impl Parse for NodeFnAttributes { } fn parse_node_fn(attr: TokenStream2, item: TokenStream2) -> syn::Result { - let attributes = syn::parse2::(attr.clone()).map_err(|e| Error::new(e.span(), format!("Failed to parse node_fn attributes: {e}")))?; + let attributes = syn::parse2::(attr.clone()).map_err(|e| Error::new(e.span(), format!("Failed to parse node_fn attributes:\n{e}")))?; let input_fn = syn::parse2::(item.clone()).map_err(|e| Error::new(e.span(), format!("Failed to parse function: {e}. Make sure it's a valid Rust function.")))?; let vis = input_fn.vis; @@ -482,7 +533,16 @@ fn parse_node_implementations(attr: &Attribute, name: &Ident) -> syn:: fn parse_field(pat_ident: PatIdent, ty: Type, attrs: &[Attribute]) -> syn::Result { let ident = &pat_ident.ident; - // Check if this is a data field (struct field, not a parameter) + // Checks for the #[data] attribute, indicating that this is a data field rather than an input parameter to the node. + // Data fields act as internal state, using interior mutability to cache data between node evaluations. + // + // Normally, an input parameter is a construction argument to the node that is stored as a field on the node struct. + // Specifically, its struct field stores the connected upstream node (an evaluatable lambda that returns data of the connection wire's type). + // By comparison, a data field is also stored as a field on the node struct, allowing it to persist state between evaluations. + // But it acts as internal state only, not exposed as a parameter in the UI or able to be wired to another node. + // + // Nodes implemented using a data field must ensure the persistent state is used in a manner that respects the invariant of idempotence, + // meaning the node's output is always deterministic whether or not the internal state is present. let is_data_field = extract_attribute(attrs, "data").is_some(); let default_value = extract_attribute(attrs, "default") @@ -723,10 +783,10 @@ fn extract_attribute<'a>(attrs: &'a [Attribute], name: &str) -> Option<&'a Attri // Modify the new_node_fn function to use the code generation pub fn new_node_fn(attr: TokenStream2, item: TokenStream2) -> syn::Result { let crate_ident = CrateIdent::default(); - let mut parsed_node = parse_node_fn(attr, item.clone()).map_err(|e| Error::new(e.span(), format!("Failed to parse node function: {e}")))?; + let mut parsed_node = parse_node_fn(attr, item.clone()).map_err(|e| Error::new(e.span(), format!("Failed to parse node function:\n{e}")))?; parsed_node.replace_impl_trait_in_input(); - crate::validation::validate_node_fn(&parsed_node).map_err(|e| Error::new(e.span(), format!("Validation Error: {e}")))?; - generate_node_code(&crate_ident, &parsed_node).map_err(|e| Error::new(e.span(), format!("Failed to generate node code: {e}"))) + crate::validation::validate_node_fn(&parsed_node).map_err(|e| Error::new(e.span(), format!("Validation error:\n{e}")))?; + generate_node_code(&crate_ident, &parsed_node).map_err(|e| Error::new(e.span(), format!("Failed to generate node code:\n{e}"))) } impl ParsedNodeFn { diff --git a/node-graph/node-macro/src/validation.rs b/node-graph/node-macro/src/validation.rs index 703bac1805..8f8a3e3716 100644 --- a/node-graph/node-macro/src/validation.rs +++ b/node-graph/node-macro/src/validation.rs @@ -117,7 +117,7 @@ fn validate_implementations_for_generics(parsed: &ParsedNodeFn) { quote!(#ty), pat_ident.ident; help = "Add #[implementations(ConcreteType1, ConcreteType2)] to field '{}'", pat_ident.ident; - help = "Or use #[node_macro::node(skip_impl)] if you want to manually implement the node" + help = "Or use #[node_macro::node(category(...), skip_impl)] if you want to manually implement the node" ); } } @@ -133,7 +133,7 @@ fn validate_implementations_for_generics(parsed: &ParsedNodeFn) { "Generic types in Node field `{}` require an #[implementations(...)] attribute", pat_ident.ident; help = "Add #[implementations(InputType1 -> OutputType1, InputType2 -> OutputType2)] to field '{}'", pat_ident.ident; - help = "Or use #[node_macro::node(skip_impl)] if you want to manually implement the node" + help = "Or use #[node_macro::node(category(...), skip_impl)] if you want to manually implement the node" ); } // Additional check for Node implementations diff --git a/node-graph/nodes/blending/src/lib.rs b/node-graph/nodes/blending/src/lib.rs index a770c85b5e..c277fc9b44 100644 --- a/node-graph/nodes/blending/src/lib.rs +++ b/node-graph/nodes/blending/src/lib.rs @@ -176,7 +176,7 @@ impl SetClip for Table { } /// Applies the blend mode to the input graphics. Setting this allows for customizing how overlapping content is composited together. -#[node_macro::node(category("Style"))] +#[node_macro::node(category("Blending"))] fn blend_mode( _: impl Ctx, /// The layer stack that will be composited when rendering. @@ -198,7 +198,7 @@ fn blend_mode( /// Modifies the opacity of the input graphics by multiplying the existing opacity by this percentage. /// This affects the transparency of the content (together with anything above which is clipped to it). -#[node_macro::node(category("Style"))] +#[node_macro::node(category("Blending"))] fn opacity( _: impl Ctx, /// The layer stack that will be composited when rendering. @@ -221,7 +221,7 @@ fn opacity( } /// Sets each of the blending properties at once. The blend mode determines how overlapping content is composited together. The opacity affects the transparency of the content (together with anything above which is clipped to it). The fill affects the transparency of the content itself, without affecting that of content clipped to it. The clip property determines whether the content inherits the alpha of the content beneath it. -#[node_macro::node(category("Style"))] +#[node_macro::node(category("Blending"))] fn blending( _: impl Ctx, /// The layer stack that will be composited when rendering. diff --git a/node-graph/nodes/brush/src/brush.rs b/node-graph/nodes/brush/src/brush.rs index 219bf8b4fa..0716ca25a5 100644 --- a/node-graph/nodes/brush/src/brush.rs +++ b/node-graph/nodes/brush/src/brush.rs @@ -184,7 +184,7 @@ pub fn blend_with_mode(background: TableRow>, foreground: TableRow( #[expose] #[implementations(DVec2, DVec2, DAffine2, DAffine2)] target: U, - /// Whether the resulting angle should be given in as radians instead of degrees. + /// Whether the resulting angle should be given in radians instead of degrees. radians: bool, ) -> f64 { let from = observer.to_position(); diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index 412476bb96..0b3a0b0af8 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -73,7 +73,7 @@ fn luminance>( input } -#[node_macro::node(category("Raster"), shader_node(PerPixelAdjust))] +#[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))] fn gamma_correction>( _: impl Ctx, #[implementations( diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index de75778159..b68067d789 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -1200,7 +1200,7 @@ async fn separate_subpaths(_: impl Ctx, content: Table) -> Table .collect() } -#[node_macro::node(category("Vector: Modifier"), path(graphene_core::vector))] +#[node_macro::node(category("Vector"), path(graphene_core::vector))] fn instance_vector(ctx: impl Ctx + ExtractVarArgs) -> Table { let Ok(var_arg) = ctx.vararg(0) else { return Default::default() }; let var_arg = var_arg as &dyn std::any::Any; @@ -1208,7 +1208,7 @@ fn instance_vector(ctx: impl Ctx + ExtractVarArgs) -> Table { var_arg.downcast_ref().cloned().unwrap_or_default() } -#[node_macro::node(category("Vector: Modifier"), path(graphene_core::vector))] +#[node_macro::node(category("Vector"), path(graphene_core::vector))] async fn instance_map(ctx: impl Ctx + CloneVarArgs + ExtractAll, content: Table, mapped: impl Node, Output = Table>) -> Table { let mut rows = Vec::new(); @@ -2266,7 +2266,7 @@ async fn count_points(_: impl Ctx, content: Table) -> f64 { /// Retrieves the vec2 position (in local space) of the anchor point at the specified index in table of vector elements. /// If no value exists at that index, the position (0, 0) is returned. -#[node_macro::node(category("Vector"), path(graphene_core::vector))] +#[node_macro::node(category("Vector: Measure"), path(graphene_core::vector))] async fn index_points( _: impl Ctx, /// The vector element or elements containing the anchor points to be retrieved. diff --git a/node-graph/preprocessor/src/lib.rs b/node-graph/preprocessor/src/lib.rs index 188b451058..b945466a57 100644 --- a/node-graph/preprocessor/src/lib.rs +++ b/node-graph/preprocessor/src/lib.rs @@ -37,7 +37,7 @@ pub fn generate_node_substitutions() -> HashMap = implementations.iter().map(|(_, node_io)| node_io.call_argument.clone()).collect(); + let valid_call_args: HashSet<_> = implementations.iter().map(|(_, node_io)| node_io.call_argument.clone()).collect(); let first_node_io = implementations.first().map(|(_, node_io)| node_io).unwrap_or(const { &NodeIOTypes::empty() }); let mut node_io_types = vec![HashSet::new(); fields.len()]; for (_, node_io) in implementations.iter() { @@ -46,7 +46,7 @@ pub fn generate_node_substitutions() -> HashMap 1 { + if valid_call_args.len() > 1 { input_type = &const { generic!(D) }; } diff --git a/tools/node-docs/Cargo.toml b/tools/node-docs/Cargo.toml new file mode 100644 index 0000000000..385e815074 --- /dev/null +++ b/tools/node-docs/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "node-docs" +description = "Tool to generate node documentation for the node catalog on the Graphite website" +edition.workspace = true +version.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +# Local dependencies +graphene-std = { workspace = true } +interpreted-executor = { workspace = true } +graph-craft = { workspace = true, features = ["loading"] } +preprocessor = { workspace = true } + +# Workspace dependencies +indoc = { workspace = true } +convert_case = { workspace = true } diff --git a/tools/node-docs/src/main.rs b/tools/node-docs/src/main.rs new file mode 100644 index 0000000000..73c4944e44 --- /dev/null +++ b/tools/node-docs/src/main.rs @@ -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::>(); + 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); + } + } +} diff --git a/tools/node-docs/src/page_catalog.rs b/tools/node-docs/src/page_catalog.rs new file mode 100644 index 0000000000..f0f02b66f4 --- /dev/null +++ b/tools/node-docs/src/page_catalog.rs @@ -0,0 +1,74 @@ +use crate::utility::*; +use convert_case::{Case, Casing}; +use indoc::formatdoc; +use std::io::Write; + +pub fn write_catalog_index_page(categories: &[String]) { + if std::path::Path::new(NODE_CATALOG_PATH).exists() { + std::fs::remove_dir_all(NODE_CATALOG_PATH).expect("Failed to remove existing node catalog directory"); + } + std::fs::create_dir_all(NODE_CATALOG_PATH).expect("Failed to create node catalog directory"); + let page_path = format!("{NODE_CATALOG_PATH}/_index.md"); + let mut page = std::fs::File::create(&page_path).expect("Failed to create index file"); + + write_frontmatter(&mut page); + write_description(&mut page); + write_categories_table_header(&mut page); + write_categories_table_rows(&mut page, categories); +} + +fn write_frontmatter(page: &mut std::fs::File) { + let content = formatdoc!( + " + +++ + title = \"Node catalog\" + template = \"book.html\" + page_template = \"book.html\" + + [extra] + order = 3 + css = [\"/page/user-manual/node-catalog.css\"] + +++ + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to index file"); +} + +fn write_description(page: &mut std::fs::File) { + let content = formatdoc!( + " + + The node catalog documents all of the nodes available in Graphite's node graph system, organized by category. + +

\"Terminology

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

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

{details}

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

{}

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

{}

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

*Primary Input*

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

*Exposed to the Graph by Default*

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

*Sourced From Scope: `{scope_name}`*

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

*Default:* {default_value}

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

{details}

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

*Primary Output*

"); + + // Possible types + let valid_primary_outputs = valid_primary_outputs.iter().map(|ty| format!("`{ty}`")).collect::>(); + let valid_primary_outputs = { + // Dedupe while preserving order + let mut found = HashSet::new(); + valid_primary_outputs.into_iter().filter(|s| found.insert(s.clone())).collect::>() + }; + let valid_primary_outputs = { + // Dedupe while preserving order + let mut found = HashSet::new(); + valid_primary_outputs.into_iter().filter(|s| found.insert(s.clone())).collect::>() + }; + let valid_primary_outputs = valid_primary_outputs.join("
"); + + let content = formatdoc!( + " + + ### Outputs + + | Product | Details | Possible Types | + |:-|:-|:-| + | {product} | {details} | {valid_primary_outputs} | + " + ); + page.write_all(content.as_bytes()).expect("Failed to write to node page file"); +} diff --git a/tools/node-docs/src/utility.rs b/tools/node-docs/src/utility.rs new file mode 100644 index 0000000000..b5e71eed36 --- /dev/null +++ b/tools/node-docs/src/utility.rs @@ -0,0 +1,78 @@ +use graph_craft::proto::NodeMetadata; +use indoc::indoc; + +pub const NODE_CATALOG_PATH: &str = "../../website/content/learn/node-catalog"; +pub const OMIT_HIDDEN: bool = true; + +pub fn category_description(category: &str) -> &str { + match category { + "Animation" => indoc!( + " + Nodes in this category enable the creation of animated, real-time, and interactive motion graphics involving paramters that change over time. + + These nodes require that playback is activated by pressing the play button above the viewport. + " + ), + "Blending" => "Nodes in this category control how overlapping graphical content is composited together, considering blend modes, opacity, and clipping.", + "Color" => "Nodes in this category deal with selecting and manipulating colors, gradients, and palettes.", + "Debug" => indoc!( + " + Nodes in this category are temporarily included for debugging purposes by Graphite's developers. They may have rare potential uses for advanced users, but are not intended for general use and will be removed in future releases. + " + ), + "General" => "Nodes in this category deal with general data handling, such as merging and flattening graphical elements.", + "Instancing" => "Nodes in this category enable the duplication, arrangement, and looped generation of graphical elements.", + "Math: Arithmetic" => "Nodes in this category perform common arithmetic operations on numerical values (and where applicable, `vec2` values).", + "Math: Logic" => "Nodes in this category perform boolean logic operations such as comparisons, conditionals, logic gates, and switching.", + "Math: Numeric" => "Nodes in this category perform discontinuous numeric operations such as rounding, clamping, mapping, and randomization.", + "Math: Transform" => "Nodes in this category perform transformations on graphical elements and calculations involving transformation matrices.", + "Math: Trig" => "Nodes in this category perform trigonometric operations such as sine, cosine, tangent, and their inverses.", + "Math: Vector" => "Nodes in this category perform operations involving `vec2` values (points or arrows in 2D space) such as the dot product, normalization, and distance calculations.", + "Raster: Adjustment" => "Nodes in this category perform per-pixel color adjustments on raster graphics, such as brightness and contrast modifications.", + "Raster: Channels" => "Nodes in this category enable channel-specific manipulation of the RGB and alpha channels of raster graphics.", + "Raster: Filter" => "Nodes in this category apply filtering effects to raster graphics such as blurs and sharpening.", + "Raster: Pattern" => "Nodes in this category generate procedural raster patterns, fractals, textures, and noise.", + "Raster" => "Nodes in this category deal with fundamental raster image operations.", + "Text" => "Nodes in this category support the manipulation, formatting, and rendering of text strings.", + "Value" => "Nodes in this category supply data values of common types such as numbers, colors, booleans, and strings.", + "Vector: Measure" => "Nodes in this category perform measurements and analysis on vector graphics, such as length/area calculations, path traversal, and hit testing.", + "Vector: Modifier" => "Nodes in this category modify the geometry of vector graphics, such as boolean operations, smoothing, and morphing.", + "Vector: Shape" => "Nodes in this category generate parametrically-described primitive vector shapes such as rectangles, grids, stars, and spirals.", + "Vector: Style" => "Nodes in this category apply fill and stroke styles to alter the appearance of vector graphics.", + "Vector" => "Nodes in this category deal with fundamental vector graphics data handling and operations.", + "Web Request" => "Nodes in this category facilitate fetching and handling resources from HTTP endpoints and sending webhook requests to external services.", + _ => panic!("Category '{category}' is missing a description"), + }.trim() +} + +pub fn node_description(metadata: &NodeMetadata) -> &str { + let mut description = metadata.description.trim(); + if description.is_empty() { + description = "*Node description coming soon.*"; + } + description +} + +pub fn sanitize_path(s: &str) -> String { + // Replace disallowed characters with a dash + let allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~[]@!$&'()*+,;="; + let filtered = s.chars().map(|c| if allowed_characters.contains(c) { c } else { '-' }).collect::(); + + // Fix letter-number type names + let mut filtered = format!("-{filtered}-"); + filtered = filtered.replace("-vec-2-", "-vec2-"); + filtered = filtered.replace("-f-32-", "-f32-"); + filtered = filtered.replace("-f-64-", "-f64-"); + filtered = filtered.replace("-u-32-", "-u32-"); + filtered = filtered.replace("-u-64-", "-u64-"); + filtered = filtered.replace("-i-32-", "-i32-"); + filtered = filtered.replace("-i-64-", "-i64-"); + + // Remove consecutive dashes + while filtered.contains("--") { + filtered = filtered.replace("--", "-"); + } + + // Trim leading and trailing dashes + filtered.trim_matches('-').to_string() +} diff --git a/website/.gitignore b/website/.gitignore index ec007841ba..ccaacaef2c 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -1,4 +1,5 @@ node_modules/ public/ -static/ +static/* !static/js/ +content/learn/node-catalog diff --git a/website/content/_index.md b/website/content/_index.md index e90cbb23f6..5e0c9e814a 100644 --- a/website/content/_index.md +++ b/website/content/_index.md @@ -4,7 +4,7 @@ template = "section.html" [extra] css = ["/page/index.css", "/component/carousel.css", "/component/feature-icons.css", "/component/feature-box.css", "/component/youtube-embed.css"] -js = ["/js/carousel.js", "/js/youtube-embed.js", "/js/video-autoplay.js"] +js = ["/js/component/carousel.js", "/js/component/youtube-embed.js", "/js/component/video-autoplay.js"] linked_js = [] meta_description = "Open source free software. A vector graphics creativity suite with a clean, intuitive interface. Opens instantly (no signup) and runs locally in a browser. Exports SVG, PNG, JPG." +++ diff --git a/website/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.md b/website/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.md index c095ab6a73..ccc171a1af 100644 --- a/website/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.md +++ b/website/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.md @@ -10,7 +10,7 @@ summary = "Looking back on 2023, we reflect on our significant achievements and reddit = "https://www.reddit.com/r/graphite/comments/18xmoti/blog_post_looking_back_on_2023_and_whats_next/" twitter = "https://twitter.com/GraphiteEditor/status/1742576805532577937" -js = ["/js/youtube-embed.js"] +js = ["/js/component/youtube-embed.js"] css = ["/component/youtube-embed.css"] +++ diff --git a/website/content/blog/2025-01-16-year-in-review-2024-highlights-and-a-peek-at-2025.md b/website/content/blog/2025-01-16-year-in-review-2024-highlights-and-a-peek-at-2025.md index 8324c6b994..199d5ca549 100644 --- a/website/content/blog/2025-01-16-year-in-review-2024-highlights-and-a-peek-at-2025.md +++ b/website/content/blog/2025-01-16-year-in-review-2024-highlights-and-a-peek-at-2025.md @@ -11,7 +11,7 @@ reddit = "https://www.reddit.com/r/graphite/comments/1i3umnl/blog_post_year_in_r twitter = "https://x.com/GraphiteEditor/status/1880404337345851612" bluesky = "https://bsky.app/profile/graphiteeditor.bsky.social/post/3lfxysayh622g" -js = ["/js/youtube-embed.js"] +js = ["/js/component/youtube-embed.js"] css = ["/component/youtube-embed.css"] +++ diff --git a/website/content/blog/2025-04-02-internships-for-a-rust-graphics-engine-gsoc-2025.md b/website/content/blog/2025-04-02-internships-for-a-rust-graphics-engine-gsoc-2025.md index f7ca6427d7..358ff48b72 100644 --- a/website/content/blog/2025-04-02-internships-for-a-rust-graphics-engine-gsoc-2025.md +++ b/website/content/blog/2025-04-02-internships-for-a-rust-graphics-engine-gsoc-2025.md @@ -10,7 +10,7 @@ reddit = "https://www.reddit.com/r/graphite/comments/1jplm6t/internships_for_a_r twitter = "https://x.com/GraphiteEditor/status/1907384498389651663" bluesky = "https://bsky.app/profile/graphiteeditor.bsky.social/post/3llt7lbmm4s24" -js = ["/js/youtube-embed.js"] +js = ["/js/component/youtube-embed.js"] css = ["/component/youtube-embed.css"] +++ diff --git a/website/content/donate.md b/website/content/donate.md index 2cd93d6398..6f14f59822 100644 --- a/website/content/donate.md +++ b/website/content/donate.md @@ -220,13 +220,3 @@ Also available to individuals wanting to make a larger impact. [Reach out](/cont - - diff --git a/website/content/features.md b/website/content/features.md index b8d567f1ec..056db60b32 100644 --- a/website/content/features.md +++ b/website/content/features.md @@ -3,7 +3,7 @@ title = "Graphite features" [extra] css = ["/page/features.css", "/component/feature-box.css", "/component/feature-icons.css", "/component/youtube-embed.css"] -js = ["/js/youtube-embed.js"] +js = ["/js/component/youtube-embed.js"] +++
diff --git a/website/content/learn/_index.md b/website/content/learn/_index.md index 9a3b983a2e..5fe4aaaabf 100644 --- a/website/content/learn/_index.md +++ b/website/content/learn/_index.md @@ -5,7 +5,7 @@ page_template = "book.html" [extra] book = true -js = ["/js/youtube-embed.js"] +js = ["/js/component/youtube-embed.js"] css = ["/component/youtube-embed.css"] +++ diff --git a/website/content/learn/interface/document-panel.md b/website/content/learn/interface/document-panel.md index 4287358478..48517ef64c 100644 --- a/website/content/learn/interface/document-panel.md +++ b/website/content/learn/interface/document-panel.md @@ -54,16 +54,16 @@ The right side of the control bar has controls related to the active document an | | | |-|-| -| Overlays |

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

| -| Snapping |

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

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

Snapping options popover menu

Snapping options relating to **Bounding Boxes**:

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

Snapping options relating to **Paths**:

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

| -| Grid |

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

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

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

    Snapping options popover menu

    It has one option unique to this mode:

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

    **Isometric** is a pattern of triangles:

    Snapping options popover menu

    It has two options unique to this mode:

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

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

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

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

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

| -| Zoom In |

Zooms the viewport in to the next whole increment.

| -| Zoom Out |

Zooms the viewport out to the next whole increment.

| -| Reset Tilt and Zoom to 100% |

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

| -| Viewport Zoom |

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

| -| Viewport Tilt |

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

| -| Node Graph |

Toggles the visibility of the overlaid node graph.

| +| **Overlays** |

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

| +| **Snapping** |

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

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

Snapping options popover menu

Snapping options relating to **Bounding Boxes**:

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

Snapping options relating to **Paths**:

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

| +| **Grid** |

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

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

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

    Snapping options popover menu

    It has one option unique to this mode:

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

    **Isometric** is a pattern of triangles:

    Snapping options popover menu

    It has two options unique to this mode:

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

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

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

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

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

| +| **Zoom In** |

Zooms the viewport in to the next whole increment.

| +| **Zoom Out** |

Zooms the viewport out to the next whole increment.

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

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

| +| **Viewport Zoom** |

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

| +| **Viewport Tilt** |

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

| +| **Node Graph** |

Toggles the visibility of the overlaid node graph.

| ## Tool shelf diff --git a/website/content/learn/introduction/_index.md b/website/content/learn/introduction/_index.md index 18137b7f55..dcd3c980fd 100644 --- a/website/content/learn/introduction/_index.md +++ b/website/content/learn/introduction/_index.md @@ -5,7 +5,7 @@ page_template = "book.html" [extra] order = 1 -js = ["/js/youtube-embed.js"] +js = ["/js/component/youtube-embed.js"] css = ["/component/youtube-embed.css"] +++ diff --git a/website/content/volunteer/guide/codebase-overview/_index.md b/website/content/volunteer/guide/codebase-overview/_index.md index 9938a66650..a5014c6dc0 100644 --- a/website/content/volunteer/guide/codebase-overview/_index.md +++ b/website/content/volunteer/guide/codebase-overview/_index.md @@ -5,7 +5,7 @@ page_template = "book.html" [extra] order = 2 # Chapter number -js = ["/js/youtube-embed.js"] +js = ["/js/component/youtube-embed.js"] css = ["/component/youtube-embed.css"] +++ diff --git a/website/content/volunteer/guide/codebase-overview/editor-structure.md b/website/content/volunteer/guide/codebase-overview/editor-structure.md index d922c4dc57..250eea74cd 100644 --- a/website/content/volunteer/guide/codebase-overview/editor-structure.md +++ b/website/content/volunteer/guide/codebase-overview/editor-structure.md @@ -3,8 +3,8 @@ title = "Editor structure" [extra] order = 1 # Page number after chapter intro -css = ["/page/developer-guide-editor-structure.css"] -js = ["/js/developer-guide-editor-structure.js"] +css = ["/page/contributor-guide/editor-structure.css"] +js = ["/js/page/contributor-guide/editor-structure.js"] +++ The Graphite editor is the application users interact with to create documents. Its code is a single Rust crate that lives below the frontend (web code) and above [Graphene](../../graphene) (the node-based graphics engine). The main business logic of all visual editing is handled by the editor backend. When running in the browser, it is compiled to WebAssembly and passes messages to the frontend. diff --git a/website/content/volunteer/guide/project-setup/_index.md b/website/content/volunteer/guide/project-setup/_index.md index e40c55c43e..17c47708e8 100644 --- a/website/content/volunteer/guide/project-setup/_index.md +++ b/website/content/volunteer/guide/project-setup/_index.md @@ -32,7 +32,6 @@ Clone the project to a convenient location: ```sh git clone https://github.com/GraphiteEditor/Graphite.git -cd Graphite ``` ## Development builds @@ -43,20 +42,27 @@ From either the `/` (root) or `/frontend` directories, you can run the project b npm start ``` -This spins up the dev server at with a file watcher that performs hot reloading of the web page. You should be able to start the server, edit and save web and Rust code, and shut it down by double pressing CtrlC. You sometimes may need to reload the browser's page if hot reloading didn't behave right— always refresh when Rust recompiles. +This spins up the dev server at with a file watcher that performs hot reloading of the web page. You should be able to start the server, edit and save web and Rust code, and shut it down by double pressing CtrlC. TypeScript and HTML changes require a manual page reload to fix broken state. This method compiles Graphite code in debug mode which includes debug symbols for viewing function names in stack traces. But be aware, it runs slower and the Wasm binary is much larger. (Having your browser's developer tools open will also significantly impact performance in both debug and release builds, so it's best to close that when not in use.) -To run the dev server in optimized mode, which is faster and produces a smaller Wasm binary: +
+Dev server optimized build instructions: click here + +On rare occasions (like while running advanced performance profiles or proxying the dev server connection over a slow network where the >100 MB unoptimized binary size would pose an issue), you may need to run the dev server with release optimizations. To do that while keeping debug symbols: ```sh -# Includes debug symbols npm run profiling +``` + +To run the dev server without debug symbols, using the same release optimizations as production builds: -# Excludes (most) debug symbols, used in release builds +```sh npm run production ``` +
+
Production build instructions: click here diff --git a/website/sass/base.scss b/website/sass/base.scss index 75bbe2a17e..3ad0985807 100644 --- a/website/sass/base.scss +++ b/website/sass/base.scss @@ -375,7 +375,7 @@ body > .page { // ELEMENT SPACING RULES // ===================== -:is(h1, h2, h3, h4, article > :first-child, details > summary) ~ :is(p, ul, ol, ol li p, img, a:has(> img:only-child)), +:is(h1, h2, h3, h4, article > :first-child, details > summary) ~ :is(p, ul, ol, ol li p, img, details, a:has(> img:only-child)), :is(h1, h2, h3, h4, article > :first-child) ~ :is(ul, ol) li p + img, :is(h1, h2, h3, h4, p) ~ .feature-icons, p ~ :is(h1, h2, h3, h4, details summary, blockquote, .image-comparison, .video-background, .youtube-embed), @@ -429,12 +429,12 @@ h6 { } h2 { - font-size: calc(1rem * 16 / 9); + font-size: 1.75rem; font-weight: 700; } h3 { - font-size: calc(1rem * 4 / 3); + font-size: 1.25rem; } h4, @@ -496,6 +496,10 @@ table { margin: 0; padding: 20px; + p { + text-align: left; + } + &:first-child { padding-left: 10px; } diff --git a/website/sass/layout/reading-material.scss b/website/sass/layout/reading-material.scss index 316112aeda..f2895fa5d5 100644 --- a/website/sass/layout/reading-material.scss +++ b/website/sass/layout/reading-material.scss @@ -1,21 +1,11 @@ .reading-material.reading-material { + font-size: 16px; + line-height: 1.625; max-width: var(--max-width-reading-material); article { width: 100%; - h2 { - margin-top: 80px; - } - - h3 { - margin-top: 40px; - } - - h4 { - margin-top: 20px; - } - h1, h2, h3, @@ -23,6 +13,7 @@ h5, h6 { display: block; + margin-top: 80px; &:first-child { margin-top: 0; diff --git a/website/sass/page/developer-guide-editor-structure.scss b/website/sass/page/contributor-guide/editor-structure.scss similarity index 98% rename from website/sass/page/developer-guide-editor-structure.scss rename to website/sass/page/contributor-guide/editor-structure.scss index 7d145f207b..0158704766 100644 --- a/website/sass/page/developer-guide-editor-structure.scss +++ b/website/sass/page/contributor-guide/editor-structure.scss @@ -1,6 +1,5 @@ .structure-outline { font-family: monospace; - font-size: 18px; line-height: 1.5; margin-top: 20px; diff --git a/website/sass/page/donate.scss b/website/sass/page/donate.scss index e4f0e78863..876519daf4 100644 --- a/website/sass/page/donate.scss +++ b/website/sass/page/donate.scss @@ -107,46 +107,3 @@ background-color: var(--color-ale); margin-top: 0; } - -// .fundraising { -// margin-top: 20px; -// width: 100%; -// -// .fundraising-bar { -// width: 100%; -// height: 32px; -// border-radius: 10000px; -// background: var(--color-fog); -// overflow: hidden; -// -// .fundraising-bar-progress { -// width: calc(var(--fundraising-percent) - (4px * 2) - (32px - 4px * 2)); -// padding-left: calc(32px - 4px * 2); -// height: calc(100% - 4px * 2); -// margin: 4px; -// border-radius: 10000px; -// background: linear-gradient(to right, var(--color-navy), var(--color-crimson)); -// transition: opacity 1s, width 2s; -// } -// } -// -// .goal-metrics { -// display: flex; -// justify-content: space-between; -// font-weight: 800; -// margin-top: 8px; -// margin-left: 20px; -// width: calc(100% - 40px); - -// > span { -// transition: opacity 1s; -// } -// } -// -// &.fundraising.loading { -// .goal-metrics > span, -// .fundraising-bar .fundraising-bar-progress { -// opacity: 0; -// } -// } -// } diff --git a/website/sass/page/user-manual/node-catalog.scss b/website/sass/page/user-manual/node-catalog.scss new file mode 100644 index 0000000000..323294113d --- /dev/null +++ b/website/sass/page/user-manual/node-catalog.scss @@ -0,0 +1,3 @@ +table tr td:first-child a { + white-space: nowrap; +} diff --git a/website/sass/page/user-manual/node-category.scss b/website/sass/page/user-manual/node-category.scss new file mode 100644 index 0000000000..5c93bf94cd --- /dev/null +++ b/website/sass/page/user-manual/node-category.scss @@ -0,0 +1,14 @@ +#nodes + table tr { + th:last-child { + width: 0; + white-space: nowrap; + } + + td:last-child { + width: 0; + + code { + white-space: nowrap; + } + } +} diff --git a/website/sass/page/user-manual/node.scss b/website/sass/page/user-manual/node.scss new file mode 100644 index 0000000000..f0d3bb06b4 --- /dev/null +++ b/website/sass/page/user-manual/node.scss @@ -0,0 +1,14 @@ +:is(#context, #inputs, #outputs) + table tr { + th:is(:first-child, :nth-child(3)) { + width: 0; + white-space: nowrap; + } + + td:is(:first-child, :nth-child(3)) { + width: 0; + + code { + white-space: nowrap; + } + } +} diff --git a/website/sass/template/book.scss b/website/sass/template/book.scss index c0dd7adc37..90d1dd6a90 100644 --- a/website/sass/template/book.scss +++ b/website/sass/template/book.scss @@ -43,12 +43,12 @@ justify-content: space-between; width: 100%; gap: 20px; - + a { display: flex; align-items: center; gap: 20px; - + svg { fill: var(--color-navy); flex: 0 0 auto; @@ -77,7 +77,7 @@ // Overlaid fold-out menu for chapter selection @media screen and (max-width: 1000px) { gap: 0; - + .chapters { position: sticky; width: 0; @@ -95,7 +95,7 @@ left: 0; } } - + .wrapper-outer { position: absolute; background: white; @@ -110,8 +110,8 @@ border-right: var(--border-thickness) solid var(--color-walnut); box-sizing: border-box; transition: left 0.25s ease-in-out; - left: calc(-1 * (var(--aside-width) + 10px)); - width: var(--aside-width); + left: calc(-1 * (Min(var(--aside-width), 100vw) + 10px)); + width: Min(var(--aside-width), 100vw); &::after { content: ""; @@ -128,8 +128,9 @@ overflow-y: auto; height: 100%; padding-right: var(--page-edge-padding); + margin-left: -24px; - ul:first-of-type { + > ul:first-of-type { margin-top: calc(120 * var(--variable-px) + var(--align-with-article-title-letter-cap-heights)); } @@ -173,68 +174,107 @@ align-self: flex-start; overflow-y: auto; top: 0; - width: var(--aside-width); + width: Min(var(--aside-width), 100vw); max-height: 100vh; - margin-top: -40px; + margin-top: calc(-40 * var(--variable-px)); flex: 0 1 auto; + --level-indent: 0.75rem; &.contents > ul, .wrapper-inner > ul { &:first-of-type { - margin-top: calc(40px + var(--align-with-article-title-letter-cap-heights)); + margin-top: calc(40 * var(--variable-px) + var(--align-with-article-title-letter-cap-heights)); } &:last-of-type { margin-bottom: calc(40 * var(--variable-px)); } + + > ul { + margin-left: 0; + } + } + + &.contents > ul > ul :is(ul, li), + .wrapper-inner > ul > ul > ul, + .wrapper-inner > ul > ul > ul :is(ul, li) { + margin-top: 0.5rem; } ul { list-style: none; padding: 0; margin: 0; - margin-top: 40px; + + &, + ul, + li { + margin-top: calc(40 * var(--variable-px)); + } ul { - margin-top: 0; - margin-left: 1em; - - ul { - margin-left: 2em; - - ul { - margin-left: 3em; - - ul { - margin-left: 4em; - - ul { - margin-left: 5em; - } - } - } - } + margin-left: var(--level-indent); + } + + li:has(> label > input:not(:checked)) + ul { + display: none; } li { - margin-top: 0.5em; - - a { - color: var(--color-walnut); - - &:hover { - color: var(--color-crimson); - } - } - + font-size: 0; + &:not(.title) a { text-decoration: none; } - + &.title, &.chapter { font-weight: 700; } + + label { + display: inline-block; + position: relative; + user-select: none; + vertical-align: bottom; + margin-right: 4px; + width: 20px; + height: calc(1rem * 1.5); + + &:has(input):hover { + background: var(--color-fog); + } + + &:has(input)::before { + content: ""; + background: url('data:image/svg+xml;utf8,\ + \ + '); + position: absolute; + margin: auto; + inset: 0; + width: 10px; + height: 10px; + } + + &:has(input:checked)::before { + transform: rotate(90deg); + } + + input { + display: none; + } + } + + a { + color: var(--color-walnut); + font-size: 1rem; + + &:hover { + color: var(--color-crimson); + text-decoration: underline; + } + } } } @@ -245,9 +285,7 @@ } ul a { - display: block; - padding-left: 1em; - text-indent: -1em; + display: inline-block; } } diff --git a/website/static/js/carousel.js b/website/static/js/component/carousel.js similarity index 100% rename from website/static/js/carousel.js rename to website/static/js/component/carousel.js diff --git a/website/static/js/image-comparison.js b/website/static/js/component/image-comparison.js similarity index 100% rename from website/static/js/image-comparison.js rename to website/static/js/component/image-comparison.js diff --git a/website/static/js/video-autoplay.js b/website/static/js/component/video-autoplay.js similarity index 100% rename from website/static/js/video-autoplay.js rename to website/static/js/component/video-autoplay.js diff --git a/website/static/js/youtube-embed.js b/website/static/js/component/youtube-embed.js similarity index 100% rename from website/static/js/youtube-embed.js rename to website/static/js/component/youtube-embed.js diff --git a/website/static/js/fundraising.js b/website/static/js/fundraising.js deleted file mode 100644 index b935b316da..0000000000 --- a/website/static/js/fundraising.js +++ /dev/null @@ -1,35 +0,0 @@ -window.addEventListener("DOMContentLoaded", initializeFundraisingBar); - -function initializeFundraisingBar() { - const VISIBILITY_COVERAGE_FRACTION = 0.5; - - let loaded = false; - - const fundraising = document.querySelector("[data-fundraising]"); - if (!fundraising) return; - const bar = fundraising.querySelector("[data-fundraising-bar]"); - const dynamicPercent = fundraising.querySelector("[data-fundraising-percent] [data-dynamic]"); - const dynamicGoal = fundraising.querySelector("[data-fundraising-goal] [data-dynamic]"); - if (!(fundraising instanceof HTMLElement && bar instanceof HTMLElement && dynamicPercent instanceof HTMLElement && dynamicGoal instanceof HTMLElement)) return; - - const setFundraisingGoal = async () => { - const request = await fetch("https://graphite.art/fundraising-goal"); - /** @type {{ percentComplete: number, targetValue: number }} */ - const data = await request.json(); - - fundraising.classList.remove("loading"); - bar.style.setProperty("--fundraising-percent", `${data.percentComplete}%`); - dynamicPercent.textContent = `${data.percentComplete}`; - dynamicGoal.textContent = `${data.targetValue}`; - - loaded = true; - }; - new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (!loaded && entry.intersectionRatio > VISIBILITY_COVERAGE_FRACTION) setFundraisingGoal(); - }); - }, - { threshold: VISIBILITY_COVERAGE_FRACTION }, - ).observe(fundraising); -} diff --git a/website/static/js/developer-guide-editor-structure.js b/website/static/js/page/contributor-guide/editor-structure.js similarity index 100% rename from website/static/js/developer-guide-editor-structure.js rename to website/static/js/page/contributor-guide/editor-structure.js diff --git a/website/static/js/book.js b/website/static/js/template/book.js similarity index 100% rename from website/static/js/book.js rename to website/static/js/template/book.js diff --git a/website/templates/base.html b/website/templates/base.html index 96d06bdc1b..74a9ae3aae 100644 --- a/website/templates/base.html +++ b/website/templates/base.html @@ -29,7 +29,6 @@ {%- endblock %} {#- ======================================================================== -#} - {#- ON EVERY PAGE OF THE SITE: CSS AND JS TO LOAD EITHER AS A LINK OR INLINE -#} {#- ======================================================================== -#} {%- set global_linked_js = [] -%} @@ -41,6 +40,7 @@ {{ throw(message = "------------------------------------------------------------> FONTS ARE NOT INSTALLED! Before running Zola, execute `npm install` from the `/website` directory.") }} {%- endif -%} + {#- ================================================================================ -#} {#- RETRIEVE FROM TEMPLATES AND PAGES: CSS AND JS TO LOAD EITHER AS A LINK OR INLINE -#} {#- ================================================================================ -#} {%- set linked_css = page.extra.linked_css | default(value = []) | concat(with = linked_css | default(value = [])) -%} @@ -48,6 +48,7 @@ {%- set css = page.extra.css | default(value = []) | concat(with = css | default(value = [])) -%} {%- set js = page.extra.js | default(value = []) | concat(with = js | default(value = [])) -%} + {#- =================================================== -#} {#- COMBINE THE GLOBAL AND TEMPLATE/PAGE RESOURCE LISTS -#} {#- =================================================== -#} {%- set linked_css_list = linked_css | concat(with = global_linked_css) -%} @@ -55,6 +56,7 @@ {%- set css_list = css | concat(with = global_css) -%} {%- set js_list = js | concat(with = global_js) -%} + {#- ================================================================================== -#} {#- CONDITIONALLY MAKE ONLY PROD BUILDS ACTUALLY INLINE THE CSS AND JS FOR CLEANLINESS -#} {#- ================================================================================== -#} {%- if get_env(name = "MODE", default = "dev") != "prod" -%} @@ -64,18 +66,21 @@ {%- set js_list = [] -%} {%- endif -%} + {#- ================ -#} {#- INSERT CSS LINKS -#} {#- ================ -#} {%- for path in linked_css_list %} {%- endfor %} + {#- =============== -#} {#- INSERT JS LINKS -#} {#- =============== -#} {%- for path in linked_js_list %} {%- endfor %} + {#- ====================== -#} {#- INSERT INLINE CSS CODE -#} {#- ====================== -#} {%- if css_list | length > 0 %} @@ -86,6 +91,7 @@ {{ "" | safe }} {%- endif %} + {#- ===================== -#} {#- INSERT INLINE JS CODE -#} {#- ===================== -#} {%- for path in js_list %} @@ -97,51 +103,53 @@ {{- get_env(name = "INDEX_HTML_HEAD_INCLUSION", default = "") | safe }} -
-
- - - - -
-
-
- {%- filter replace(from = "", to = replacements::blog_posts(count = 2)) -%} - {%- filter replace(from = "", to = replacements::text_balancer()) -%} - {%- filter replace(from = "", to = replacements::hierarchical_message_system_tree()) -%} - {%- block content -%}{%- endblock -%} - {%- endfilter -%} - {%- endfilter -%} - {%- endfilter -%} -
-
-
- - Copyright © {{ now() | date(format = "%Y") }} Graphite Labs, LLC (an open source community organization) -
-
+
+
+ + + + +
+
+
+{# This is a comment. It exists to prevent the {%- -%} on the lines below from removing the line break between `
` and the `content` block #} +{%- filter replace(from = "", to = replacements::blog_posts(count = 2)) -%} +{%- filter replace(from = "", to = replacements::text_balancer()) -%} +{%- filter replace(from = "", to = replacements::hierarchical_message_system_tree()) -%} +{%- block content -%}{%- endblock -%} +{%- endfilter -%} +{%- endfilter -%} +{%- endfilter -%} +{# This is a comment. It exists to prevent the {%- -%} on the lines above from removing the line break between the `content` block and `
` #} +
+
+
+ + Copyright © {{ now() | date(format = "%Y") }} Graphite Labs, LLC (an open source community organization) +
+
diff --git a/website/templates/book.html b/website/templates/book.html index c843e9736b..22e2c47957 100644 --- a/website/templates/book.html +++ b/website/templates/book.html @@ -1,38 +1,52 @@ {% extends "base.html" %} +{% import "macros/book-outline.html" as book_outline %} {%- block head -%}{%- set page = page | default(value = section) -%} {%- set title = page.title -%} {%- set meta_article_type = true -%} {%- set meta_description = page.extra.summary | default(value = page.content | striptags | safe | linebreaksbr | replace(from = "
", to = " ") | replace(from = " ", to = " ") | trim | truncate(length = 200)) -%} {%- set css = ["/template/book.css", "/layout/reading-material.css", "/component/code-snippet.css"] -%} -{%- set js = ["/js/book.js"] -%} +{%- set js = ["/js/template/book.js"] -%} {%- endblock head -%} {%- block content -%}{%- set page = page | default(value = section) -%} -{# Search this page-or-section's ancestor tree for a section that identifies itself as a book, and save it to a `book` variable #} -{% for ancestor_path in page.ancestors | concat(with = page.relative_path) %} - {# Get the ancestor section from this ancestor path string #} - {% if ancestor_path is ending_with("/_index.md") %} - {% set potential_book = get_section(path = ancestor_path) %} - {% endif %} - - {# Check if the ancestor section is the root of a book, and if so, set it to a variable accessible outside the loop #} - {% if potential_book.extra.book %} - {% set_global book = get_section(path = potential_book.path ~ "_index.md" | trim_start_matches(pat="/")) %} - {% endif %} -{% endfor %} - -{# Map this book's chapter path strings to an array of sections #} -{% set chapters = [] %} -{% for chapter_path in book.subsections %} - {% set_global chapters = chapters | concat(with = get_section(path = chapter_path)) %} -{% endfor %} -{% set chapters = chapters | sort(attribute = "extra.order") %} - -{# A flat list of all pages in the ToC, initialized to just the book root section but updated when we generate the ToC #} -{% set flat_pages = [book] %} -{% set flat_index_of_this = 0 %} +{#- Search this page-or-section's ancestor tree for a section that identifies itself as a book, and save it to a `book` variable -#} +{%- for ancestor_path in page.ancestors | concat(with = page.relative_path) -%} + {#- Get the ancestor section from this ancestor path string -#} + {%- if ancestor_path is ending_with("/_index.md") -%} + {%- set potential_book = get_section(path = ancestor_path) -%} + {%- endif -%} + + {#- Check if the ancestor section is the root of a book, and if so, set it to a variable accessible outside the loop -#} + {%- if potential_book.extra.book -%} + {%- set_global book = get_section(path = potential_book.path ~ "_index.md" | trim_start_matches(pat = "/")) -%} + {%- endif -%} +{%- endfor -%} + +{#- Map this book's chapter path strings to an array of sections -#} +{%- set chapters = [] -%} +{%- for chapter_path in book.subsections -%} + {%- set_global chapters = chapters | concat(with = get_section(path = chapter_path)) -%} +{%- endfor -%} +{%- set chapters = chapters | sort(attribute = "extra.order") -%} + +{#- A flat list of all pages in the ToC -#} +{%- set flattened_outline = book_outline::flatten_book_outline(section = book) -%} +{%- set flat_pages_list = book.path ~ ",,,,," ~ book.title ~ ";;;;;" ~ flattened_outline | split(pat = ";;;;;") -%} +{%- set flat_index_of_this = 0 -%} +{%- set flat_pages_path = [] -%} +{%- set flat_pages_title = [] -%} +{%- for item_str in flat_pages_list -%} + {%- if item_str | trim | length > 0 -%} + {%- set parts = item_str | split(pat = ",,,,,") -%} + {%- if current_path == parts | first -%} + {%- set_global flat_index_of_this = loop.index0 -%} + {%- endif -%} + {%- set_global flat_pages_path = flat_pages_path | concat(with = parts | first) -%} + {%- set_global flat_pages_title = flat_pages_title | concat(with = parts | last) -%} + {%- endif -%} +{%- endfor -%}
@@ -86,39 +77,41 @@

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

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

{% if page.toc | length > 0 %}Contents (top ↑){% else %}Back to top ↑{% endif %} - -

diff --git a/website/templates/macros/book-outline.html b/website/templates/macros/book-outline.html new file mode 100644 index 0000000000..e8062cfd97 --- /dev/null +++ b/website/templates/macros/book-outline.html @@ -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 }}
    + {%- for child in children %} + {{ tabs }}
  • {{ child.title }}
  • + {{- self::render_book_page_toc(children = child.children, indents = indents + 1) -}} + {%- endfor %} + {{ tabs }}
+ {%- endif -%} +{%- endmacro render_book_page_toc -%} + +{# Recursively render a book's chapters table of contents #} +{%- macro render_book_outline(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 }}
    + {%- for chapter in chapters %} + {%- set children = chapter.pages or chapter.subsections | default(value = []) -%} + {%- set_global classes = [] -%} + {%- if index == 0 -%} + {%- set_global classes = classes | concat(with = "title") -%} + {%- endif -%} + {%- if index == 1 -%} + {%- set_global classes = classes | concat(with = "chapter") -%} + {%- endif -%} + {%- if current_path == chapter.path -%} + {%- set_global classes = classes | concat(with = "active") -%} + {%- endif %} + {{ tabs }}
  • 0 %} class="{{ classes | join(sep = " ") }}"{% endif %}> + {{ tabs }} + {{ tabs }}{{ chapter.title }} + {{ tabs }}
  • + {%- if children -%} + {{ self::render_book_outline(parent = chapter, current_path = current_path, index = index + 1, indents = indents + 1) }} + {%- endif %} + {%- endfor %} + {{ tabs }}
+ {%- 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 -%}