Skip to content

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 Map lays out children (6 blocks, no per-type overrides).
  • Both go through a *_RULESET, keyed by SchemaVariant, before anything else uses them.
  • Most file types (default/email/text) use the base logic blocks unmodified — only json, xml, table override the anchor block, 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):

  1. transformRulesToSet — converts each block's rules from an array (easier to author) to a Set<BlockRule> (faster .has() lookups at render time).
  2. wrapValidation — wraps every block's mutation with the checks implied by its own rules, so individual block authors don't have to repeat them: a block with BlockRule.NoChildren throws if it's ever given children rather than silently ignoring them, and a BlockRule.SingleChild block throws if given more than one. This runs uniformly across every block, mutation logic doesn't need to check its own arity.
  3. translate — replaces each block's title/description, and each option's title/description, with i18next translation key strings (logicBlocks.<category>.<type>.title, etc.) rather than literal text, for shared-i18n to 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 up LOGIC_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) for LOGIC_CATEGORIES/STRUCTURE_CATEGORIES — the human-readable category labels defined in logic/base/shared.ts and structure/base/shared.ts respectively.
  • useRules(variant) — built on top of useLogicBlocks, exposes one boolean-returning function per BlockRule (hasNoChildren, hasSingleChild, hasMultipleChildren, isConfigurable, hasMultipleElements) so UI code can ask "can this block type have children?" without importing BlockRule itself.
  • useConnectors() — exposes LOGIC_CONNECTORS, currently just a single default connector (GraphEdge.Default) used to render the edges between nodes. It isn't variant-specific.