Logic & structure blocks: per-filetype transformation rules
At a glance
- Logic blocks = what a node can do (~50 blocks, 9 categories); structure blocks = how a
Maplays out children (6 blocks, no per-type overrides). - Both go through a
*_RULESET, keyed bySchemaVariant, before anything else uses them. - Most file types (
default/email/text) use the base logic blocks unmodified — onlyjson,xml,tableoverride theanchorblock, because "reference point" means something different per format. - Four hooks (
useLogicBlocks,useStructureBlocks,useRules,useConnectors) are the entire public surface.
Every node type a user can place in the graph editor is a logic block; every way a Map node can lay out its children in the output is a structure block. Both are defined once, then a ruleset picks which set applies for the file type (SchemaVariant) currently being edited.
Logic blocks
A logic block (LogicBlockConfig, built via the identity helper block for inference) is a plain object describing one node type: title/description/category/ icon for display, a node (GraphNode.Generic or GraphNode.Terminal), ports (input/output connection points), options (its configuration fields — each an OptionConfig with a Zod type, an Input widget from node-inputs, and conditions for when it's shown), rules (a set of BlockRule values: Configurable, NoChildren, SingleChild, MultipleChildren, MultipleElement), and a mutation function that produces the node's output given its resolved inputs and options.
packages/node-logic/src/logic/base/index.ts defines the full library of blocks — around 50 of them, grouped by category into LOGIC_BLOCKS_CORE (anchor, literal, docVariables, ...), LOGIC_BLOCKS_TRANSFORMER (concatenate, split, dateTime, substring, removeWords, ...), LOGIC_BLOCKS_LOGIC (logicOr/And/Equal/Not, regex test, if/else, switchCase), LOGIC_BLOCKS_MATH (plus/subtract/multiply/round/ceil/floor/ divide/max/comparisons), LOGIC_BLOCKS_REQUEST (genericRequest, vesselRequest), LOGIC_BLOCKS_TABLE (composeTableColumns, defineTableCell, iterateRowByColumn), LOGIC_BLOCKS_ITERATOR (countOccurences, iterableTable, getFromArray, sumFromArray, containerIterator), and LOGIC_BLOCKS_CASE (caseDefinition) — plus LOGIC_BLOCKS_INTERNAL for the two blocks every variant needs regardless of category (terminal, the template's final output node; context). They're merged into one LOGIC_BLOCKS object at the bottom of the file.
Per-filetype overrides
packages/node-logic/src/logic/{default,table,xml,email,text,json}/index.ts each export a LOGIC_BLOCKS for one SchemaVariant. Most don't add anything: default (used for SchemaVariant.Pdf), email, and text all just re-export the base set verbatim. json and xml both override just the anchor block — its mutation and options differ because the "reference point" the user selects means something different in a JSON document than in an XML one, even though the option is called the same thing in both. table goes further: it overrides anchor with a row/column-aware version (reference: 'absolute' | 'relative', with row/column/whitespace options) and adds a table-only iterateRowByColumn block. This is the concrete shape of the node-logic → node-inputs dependency noted in the overview — every option in every block references an Input value to say which widget renders it.
From raw blocks to a usable ruleset
logic/index.ts builds LOGIC_BLOCKS_RULESET — a Record<SchemaVariant, ...> — by running each per-filetype LOGIC_BLOCKS through a three-step pipeline (logic/pipe.ts):
transformRulesToSet— converts each block'srulesfrom an array (easier to author) to aSet<BlockRule>(faster.has()lookups at render time).wrapValidation— wraps every block'smutationwith the checks implied by its own rules, so individual block authors don't have to repeat them: a block withBlockRule.NoChildrenthrows if it's ever given children rather than silently ignoring them, and aBlockRule.SingleChildblock throws if given more than one. This runs uniformly across every block, mutation logic doesn't need to check its own arity.translate— replaces each block'stitle/description, and each option'stitle/description, with i18next translation key strings (logicBlocks.<category>.<type>.title, etc.) rather than literal text, forshared-i18nto hydrate at render time.
Structure blocks
Structure blocks are a much smaller, simpler system — six blocks total (unwrap, code, group, array, forEach, data), and unlike logic blocks they're not overridden per file type: STRUCTURE_BLOCKS_RULESET maps every SchemaVariant to the same STRUCTURE_BLOCKS object. Each (StructureBlockConfig) has no ports, options, or rules — just a render(key) function returning a [pointer, template] tuple: pointer is the path (as a string array) to where descendant tree nodes should be written, and template is the object shape to wrap them in. array's render, for instance, returns [[key, '0'], { [key]: [{}] }] — write descendants under <key>[0], output-shaped as { <key>: [...] }. This is the mechanism behind a TreeMap's outputType described in Tree nodes and schemas.
Exposing this to the UI
Four hooks form the public interface everything above:
useLogicBlocks(variant)/useStructureBlocks(variant)— look upLOGIC_BLOCKS_RULESET[variant]/STRUCTURE_BLOCKS_RULESET[variant], and additionally group the blocks by category (skipping'internal'blocks, which aren't meant to appear in a block picker) forLOGIC_CATEGORIES/STRUCTURE_CATEGORIES— the human-readable category labels defined inlogic/base/shared.tsandstructure/base/shared.tsrespectively.useRules(variant)— built on top ofuseLogicBlocks, exposes one boolean-returning function perBlockRule(hasNoChildren,hasSingleChild,hasMultipleChildren,isConfigurable,hasMultipleElements) so UI code can ask "can this block type have children?" without importingBlockRuleitself.useConnectors()— exposesLOGIC_CONNECTORS, currently just a singledefaultconnector (GraphEdge.Default) used to render the edges between nodes. It isn't variant-specific.