DocumentationThe block DSL

The block DSL

An interactive block is described as data in its tree.json file: its shape, its copy, its local state, its computed values, its validation and its actions. This page covers every key the engine reads, the available operators, functions and verbs, the link to the site memory and to the project's API, a complete order flow, and the known limits.

Updated on

On this page10 sections

Overview

A block is a web/configurations/components/<id>/ folder (or fragments/<id>/). When that folder holds a tree.json whose root carries a primitive key, the page renders the block from that tree: its shape, copy, state, computed values, validation and actions are JSON, read by an engine shared by every project. The DSL is that engine's closed vocabulary. A key it does not know is ignored, with no error message.

The order form: a cart of two cakes, a slot grid with 10:30 picked, a summary at 56 euros.
What a tree.json renders: a bakery demo's order block, with the cart, the pickup slot and the summary.
text
web/configurations/components/orderFlow/ tree.json slots.json options.json style.css index.js dictionaries/ fr.json web/configurations/logic/orderFlow/index.js (optional)
FileRole
tree.jsonThe tree: the root (state, computed values, validation, actions) and the nodes to render.
slots.jsonThe list of the block's texts: one slot per text, with its id and defaultValue.
dictionaries/<language>.jsonThe translated texts, under the key <slot>_<suffix> (see Copy).
options.jsonThe block's configuration. Its config and fragments objects join the value scope.
style.cssHand-written CSS, followed by the region generated from the nodes' styleJson.
index.jsOptional. The page mounts a tree.json block without reading it; it is only used when the block is embedded in another one through a fragment node.
logic/<id>/index.jsOptional. A JavaScript useLogic hook whose return value joins the scope (see The block root).

A page mounts the block from its structure.json, by folder id: a component node reads components/<id>/, a fragment node reads fragments/<id>/.

json
{ "primitive": "wrapper", "type": "section", "classname": "frame-page frame-space-m", "children": [{ "component": "OrderFlow", "id": "orderFlow", "_nid": "orderFlow" }], "_nid": "orderFlowSection" }

Static block, interactive block

Two engines render a tree. A static block is rendered on the server, without JavaScript, and only understands {slot} text markers. A block becomes interactive as soon as its root carries logic, state, actions, onMount, derive, watch or effects, one of its nodes carries on, when, unless or repeat, or a logic/<id>/bindings.json file declares calls. It then goes to the interactive engine, which runs in the browser, and everything below applies.

On the Workshop canvas, behavior does not run by default: values are resolved from the initial state, the derive values and a dataset inferred from stores/stores.json, and the Block states control shows, one at a time, the screens declared by when and unless. A field the tree reads but nothing declares stays empty there.

Tree nodes

A node is a JSON object. The root of tree.json is a node like the others, which also carries the behavior keys described in The block root.

KeyRole
idThe node identifier, unique in the tree, using A-Z a-z 0-9 _ -. It anchors the generated CSS and addresses the node in the Workshop.
primitiveWhat the node renders: a node type (wrapper, group, fragment, region) or a primitive name (Heading, Button, Input...).
propsThe node's properties. Every value is resolved (see Values).
childrenThe children: nodes, or strings (inline text, resolved like a value).
styleJsonThe node's style by block (base, hover, md...), compiled into style.css.
when / unlessRender the node only when the value is true (when) or false (unless).
repeatRender the node once per item of a list.
onThe node's events, and the action of each one.
name / passOn a fragment node: the block to mount and the properties to pass to it.
json
{ "id": "cartTitle", "primitive": "Heading", "props": { "text": "{cartTitle}", "level": 2 }, "styleJson": { "base": { "fontFamily": "var(--font-display)", "fontSize": "var(--text-3xl)", "fontWeight": "800", "lineHeight": "0.95", "textTransform": "uppercase", "letterSpacing": "0", "margin": "0" } } }

Node types

Node typeOutput
wrapperA container. props.type picks the tag from a closed list: div, section, article, aside, header, footer, main, nav, address, figure, figcaption, ul, ol, li, dl, dt, dd, cite, fieldset, legend, strong, em, canvas and the SVG shape elements. Any other value gives div.
groupA grouping with no HTML element: its children take its place. It lets a repeat produce several siblings per item.
fragmentAnother block, mounted by its name (see below).
regionA placeholder the block's behavior fills under the name props.region; otherwise its own children; empty, it renders nothing.
IconA Lucide icon: props.name (Lock, ChevronDown...) and props.size. An unknown name renders nothing.
ImgAn image: props.src is the name of a file in assets/images/ or an address (starting with /, http or data:); plus alt, width, height, sizes, fill, priority, loading.
A primitiveThe component of the same name (list below). All its props are passed to it resolved; text becomes its content when the node has no children.

Primitives

FamilyPrimitives
TextHeading (level), Paragraph, Span, Blockquote, Mark, Strong, Em, Underline, Strike, Code, Time
FormsForm, Input, Textarea, Select, Option, Label, Button, Checkbox, Radio, SliderCheckbox, CloseButton
Links and mediaLink (href), Img, Figure, Figcaption, Iframe, Video, Audio
StructureArticle, Aside, Address, Details, Summary, List, ListItem, Table, TableHead, TableBody, TableRow, TableCell, Hr, Br, Divider
OtherBadge, Loader, CodeEditor

A wrapper's attributes go through a closed list: className, id, role, hidden, inert, tabIndex, title, disabled, data-* and aria-*, style (an object, filtered like a styleJson) and, on an SVG element, its geometry attributes (viewBox, d, fill...), plus ref and region. Others are dropped without an error. Write ARIA with a dash (aria-label, aria-pressed); the ariaLabel form is converted.

A field whose value is bound without an input or change event is rendered read-only. Checkbox, Radio and SliderCheckbox are driven by checked, not by value.

Embedding another block

A fragment node mounts a block by its name: a project block (its id) or a block provided by Kubbler (its name, for example StripeCheckoutForm). props.__variantId picks an id other than the name, and the other props replace its options.json. pass hands it resolved properties; a { "action": ... } value becomes a function that runs that action in the parent block, the received value being readable as $arg. name and __variantId accept a $ref.

json
{ "id": "paying", "primitive": "wrapper", "when": "$store.paymentReducer.providerSecret", "props": { "className": "d-flex flex-column" }, "children": [{ "id": "paymentStep", "primitive": "fragment", "name": "paymentStep" }] }
json
{ "id": "stripeForm", "primitive": "fragment", "name": "StripeCheckoutForm", "pass": { "stripeOptions": "$stripeOptions", "onSuccess": "$onPaymentSuccess", "returnUrl": "$returnUrl", ... } }

An embedded block is mounted through Slot and rendered by its own index.js, with the page's dictionary. Its dictionaries are merged into the page only if it is also listed in the view's structure.json.

Tree writing rules

  • Every node carries a primitive (a string) and an id matching [A-Za-z0-9_-]+, unique in the tree.
  • Maximum depth: 64 levels. At most 2000 nodes.
  • children is a list. The keys __proto__, constructor and prototype are refused.
  • The Workshop checks these rules on every write of the file: a tree.json or slots.json that breaks them is refused (400).

Copy: slots and dictionaries

Block copy is not written in clear in the tree. The tree carries a {slot} marker, slots.json declares the slot, and each language dictionary holds its value.

json
[ { "id": "cartTitle", "type": "string", "required": false, "defaultValue": "" }, { "id": "errCart", "type": "string", "required": false, "defaultValue": "" }, ... ]
json
{ "cartTitle_orderFlow": "1. Votre panier", "errCart_orderFlow": "Ajoutez au moins un gâteau avant de payer.", "errName_orderFlow": "Indiquez votre nom et votre prénom.", ... }

A slot's dictionary key is <slot>_<suffix>. The suffix is the last segment of the block id after its last dash: the orderFlow block gives _orderFlow, an order-flow block would give _flow. The block's dictionaries are merged into the page's when the view lists the block in its structure.json.

The Inspector listing the header block's texts, each with its dictionary key.
Slots in the Inspector: each text of the block with its <slot>_<suffix> key (titleA_homeHero, subtitle_homeHero) and its value.w/[wsSlug]/p/[slug]?sel=ui:{id}

How a marker resolves

  • {x}, when x is a slot of the block: the engine reads the key x_<suffix>. If it is missing, an interactive block shows the slot's defaultValue, a static block shows need_trads.
  • {x}, when x is not a slot: the engine reads the key x as is in the page dictionary; if that is missing too, it shows x.
  • {$path}: the key is the value read at $path, then the same resolution applies. This is how a block shows a label chosen by its configuration.

In the grocery demo, options.json lists the aisles; each aisle names its title by a slot id (aisleFl). The tree writes {$a.title} inside the loop: the value aisleFl is a slot, so the text shown is the one under the key aisleFl_productList.

json
{ "__variantId": "productList", "config": { "aisles": [ { "key": "fruits-legumes", "title": "aisleFl", "lead": "aisleFlLead", "photo": "rayon-legumes-bacs.jpg", "alt": "aisleFlAlt" }, ... ] } }
json
{ "id": "aisle", "primitive": "wrapper", "repeat": { "over": "$aisles", "as": "a", "key": "a.key" }, "children": [ ... { "id": "signTitle", "primitive": "Heading", "props": { "level": 2, "text": "{$a.title}" } }, ... ] }

Text goes in props.text, or as a string in children when it sits between nodes. A string can mix fixed text, values and keys: "x${it.quantity}", "${p.name}, ${v.label} : {addedToast}".

Validation messages do not go through a slot: a rule's error field is a key read as is in the dictionary, so it is written with its suffix (errName_orderFlow). A message set by an action ("to": "{errCart}"), on the other hand, goes through the errCart slot, which must exist in slots.json.

Values and operators

Every value in the tree (a prop, a condition, an action argument) is resolved at render time, or when the action runs, according to its form:

FormExampleResult
"$path""$store.cartReducer.items"The value read from the scope, of any type. The path uses dots; a list index is written .0.
"!$path" / "!!$path""!!$emailError"The value converted to a boolean, negated (!) or not (!!).
"${path}""x${it.quantity}"The value inserted into the text; empty when missing.
"{key}""{cartTitle}"The text of the slot or dictionary key.
"{$path}""{$a.title}"The text of the key that $path holds.
An object with one operator key{ "empty": "$store.cartReducer.items" }The operator's result.
Any other object, a list{ "price": "$v.price" }Resolved key by key, deeply.
Any other string, a number, a boolean"button", 2, trueThe value as is.

$path is only read when it fills the whole string. "$it.price €" is literal text, shown as is: write "${it.price} €". A whole-value binding in a text position that resolves to an object renders nothing: bind a field ("$slot.text"), not the object.

The scope

The scope is the set of names a $path can read. From weakest to strongest (a stronger name hides the same weaker name):

  • store: the site memory, read as $store.<name>Reducer.<path>.
  • locale: the page language, read as $locale.
  • The keys of options.config, then of options.fragments: config.aisles is read as $aisles.
  • The behavior hook's return value, if there is one.
  • The state atoms.
  • The derive values, computed in the order they are written.

Depending on context, the scope also holds: the variables of a repeat (the item, index, n, isFirst, isLast, count), $event inside an action, the result named by as, $prev and $next inside a watch, $arg inside an action passed through pass.

A derive or an atom named like an options.config key hides it, without a warning. Name an options key that holds a label xxxLabel (read as {$xxxLabel}), and keep the bare name for the computed value.

Operators

An operator is an object with exactly one key, and that key is an operator name. Its arguments are written as a list (a single argument may skip the list) and are resolved before the call, so an operator can contain another one. when, unless, where and an action's when treat an empty list as false; everywhere else, and so inside not, and, or and if, a value is true in the JavaScript sense, and an empty list is true there: test a list with empty. The operator list is closed:

OperatorFormResult
eq{ "eq": [a, b] }True when a and b are strictly equal, with no type conversion.
ne{ "ne": [a, b] }True when a and b differ.
lt, lte, gt, gte{ "lt": [a, b] }a < b, a <= b, a > b, a >= b.
in{ "in": [x, list] }True when the list contains x.
empty{ "empty": a }True for an empty list, null, a missing value or "". 0 and false are not empty.
not{ "not": a }The negation of a.
and{ "and": [a, b, ...] }The first false value, otherwise the last one.
or{ "or": [a, b, ...] }The first true value, otherwise the last one. Also used as a fallback value.
if{ "if": [cond, then, else] }then when cond is true, otherwise else (undefined when omitted).
join{ "join": [sep, a, b, ...] }The non-empty parts, joined by sep. Builds a class or a text.
format{ "format": [v, "money", "eur"] }An amount in units (not cents), formatted in the page language; the currency defaults to eur. Empty when v is empty.
format{ "format": [v, "date"] }A date in the page language's medium format, in UTC.
number{ "number": v }The number read from a text ("12,5" gives 12.5); undefined when the text is empty; the text unchanged when it is not a number.
match{ "match": [list, "r", cond] }The list items for which cond is true, the item being named r.
json
{ "id": "payText", "primitive": "Span", "props": { "text": { "join": [" ", "{payCta}", { "format": ["$store.cartReducer.total", "money", "eur"] }] } } }
json
{ "id": "slot", "primitive": "Button", "repeat": { "over": "$store.ordersReducer.slots", "as": "s", "key": "s.value" }, "props": { "type": "button", "text": "$s.label", "aria-pressed": { "eq": ["$s.value", "$pickupAt"] }, "className": { "join": [ " ", "orderFlow-slot", { "if": [{ "eq": ["$s.value", "$pickupAt"] }, "is-picked", ""] } ] } }, "on": { "click": "pickSlot" } }

A value typed into a field ($event.value) is a string. { "eq": ["$visits", 4] } is only true when visits was set as a number ("to": 4); convert with number before comparing or sending input. An object that carries an operator key plus another key is not an operator: it is resolved as a plain object.

The block root: state, computed values, validation

Besides its node keys, the root of tree.json carries the block's behavior:

KeyFormRole
state{ "<atom>": value }The local state. An initial value may be a $ref to options.config or to the behavior, read once at mount.
derive{ "<name>": { "fn": "...", "args": [...] } }Values computed on every render.
validate{ "<field>": [ { "rule": "...", "error": "..." } ] }The rules of the form fields.
actions{ "<name>": action }Named actions, called by their name.
onMountactionAn action run once, in the browser, after the first render.
watch{ "<value>": action }An action run when the value changes.
logic"<Name>"The name of a behavior provided by Kubbler.
refs / effects["<ref>"] / { "<name>": { "fn": "autofocus", "ref": "<ref>" } }Element references and named effects. autofocus is the only available effect.
json
{ "state": { "date": "", "pickupAt": "", "pickupLabel": "", "customerName": "", "email": "", "phone": "", "note": "", "customerNameError": "", "emailError": "", "phoneError": "", "pickupError": "", "cartError": "", "submitError": "", "snapshot": [], "snapshotTotal": 0 }, "derive": { "minD": { "fn": "minDate", "args": [2] } } }

state

Each atom is read as $atom and written by the set action. A dotted name writes into an object atom ("set": "form.email"). Validation writes a field's message into the <field>Error atom; the demos declare it as "".

derive

A derive calls a named function with arguments resolved in the scope (args may be a single value). The derive entries are computed in the order they are written, and each one reads those before it: a derive that reads one declared further down receives undefined, which numeric functions count as 0. An unknown function gives undefined. The function list is closed:

FunctionArgumentsResult
adda, ba + b. A non-numeric argument counts as 0.
mula, ba * b, same rule. true counts as 1, false as 0.
clampv, min, maxv brought within min and max.
amountFromCentscentsThe amount in units, rounded to a whole number, as text (21800 gives "218"). "-" when the argument is not a number.
rangenThe list [1, 2, ..., n], from 0 to 200 items.
minDatedaysToday's date plus days days, formatted YYYY-MM-DD, in local time.
findBylist, key, valueThe first item whose key field equals value, otherwise null.
filterBylist, key, queryThe items whose key field contains query, ignoring case; the whole list when query is empty.
trimvalueThe text without leading and trailing spaces.
mapWithlist, fn, key, asEach item, plus an as field (value by default) computed by the fn function from its key field.
passwordRulespasswordThe password criteria list, { key, met, label, iconName }, labels read from pwRuleLength, pwRuleCase, pwRuleDigit, pwRuleSpecial.

A price simulator chains its computations from top to bottom; waste is a boolean, which mul counts as 1 or 0:

json
{ "derive": { "lawnCost": { "fn": "mul", "args": ["$surface", 0.08] }, "hedgeCost": { "fn": "mul", "args": ["$hedge", 1.2] }, "perVisit": { "fn": "add", "args": ["$lawnCost", "$hedgeCost"] }, "visitsCost": { "fn": "mul", "args": ["$perVisit", "$visits"] }, "wasteCost": { "fn": "mul", "args": ["$waste", 15] }, "monthly": { "fn": "add", "args": ["$visitsCost", "$wasteCost"] } } }

validate

validate maps each field (a state atom) to a list of rules, tried in order: the first one that fails gives the field's message. A rule is written { "rule": "<name>", "args": [...], "error": "<key>" }; with "when": <condition>, it only applies when the condition is true. Validation only runs through an action (validate or validateAll): it clears <field>Error for every checked field, then writes the text of the error key there, or the key itself when the dictionary does not hold it.

RuleArgumentsAccepts
required-A non-empty text, spaces removed.
minLengthnAt least n characters.
maxLengthnAt most n characters.
email-An email address.
alphaNummin, maxUnaccented letters and digits; min and max bound the length.
emailOrAlphaNummin, maxAn email address, or an identifier accepted by alphaNum.
match"$otherField"The same value as the named atom (password confirmation).
passwordPolicy-A password that meets the passwordRules criteria.
number-A number (decimal comma accepted), or an empty field.
date-A readable date, or an empty field.
json
{ "validate": { "customerName": [ { "rule": "required", "error": "errName_orderFlow" }, { "rule": "minLength", "args": [2], "error": "errName_orderFlow" } ], "email": [ { "rule": "required", "error": "errEmail_orderFlow" }, { "rule": "email", "error": "errEmail_orderFlow" } ] }, ... }

A validated field is a top-level state atom: validate reads neither a dotted path, nor a derive, nor $store. number and date accept an empty field: put required before them. A rule with an unknown name is skipped.

Behavior in JavaScript

When the DSL is not enough, a useLogic hook completes the block. The engine looks for web/configurations/logic/<id>/index.js (the block id), then for the Kubbler-provided behavior named by the logic key. The hook receives { name, id, options, dictionary, args }; what it returns joins the scope: a value is read as $name, a function is called through the call verb or by its name. A few-line hook is enough to expose a route parameter:

js
'use client'; export function useLogic({ args } = {}) { return { quoteId: (args && args.id) || '' }; }
json
{ "onMount": { "dispatch": { "store": "quotesReducer", "thunk": "fetchQuote", "with": { "id": "$quoteId" } } } }

onMount and watch

onMount runs an action once, in the browser, after the first render, with that render's scope. watch maps a value to an action: the value is read again on every render, and the action runs when it has changed (never at mount), with $prev (the old value) and $next (the new one). This is how a block reacts to a change in the site memory.

json
{ "onMount": [ { "dispatch": { "store": "storesReducer", "thunk": "fetchStores" } }, { "dispatch": { "store": "ordersReducer", "thunk": "fetchSlots", "skipEmpty": true, "with": { "storeId": "$store.storesReducer.currentId" } } } ], "watch": { "$store.storesReducer.currentId": { "when": "$next", "then": [ { "set": "day", "to": "" }, ... { "dispatch": { "store": "ordersReducer", "thunk": "fetchSlots", "with": { "storeId": "$next" } } } ] } } }

At mount, the chosen store may not be known yet: skipEmpty does not send an empty storeId, and the watch fetches the slots again as soon as currentId changes.

refs declares element references ("refs": ["field"]), which a node receives through "ref": "$field". effects attaches a named effect to one ({ "fn": "autofocus", "ref": "field" }), run at mount and again when its when value changes.

Conditions, lists and events

when and unless

when renders the node when its value is true, unless when it is false. The value can be any DSL value: a $ref or an operator. A hidden node is not rendered at all, neither it nor its children.

json
{ "id": "slotsEmpty", "primitive": "Span", "props": { "text": "{slotsEmpty}" }, "when": { "and": [ "$date", { "ne": ["$store.ordersReducer.status", "loading"] }, { "empty": "$store.ordersReducer.slots" } ] } }

repeat

repeat renders the node once per item of over. A value that is not a list renders nothing.

KeyRole
overThe list, as a $ref or an operator.
asThe item name in the scope; item by default.
keyThe path of each item's key, without $ ("it.id"); the index by default.
whereA condition evaluated for each item: only those that meet it are rendered.

Each iteration exposes the item under its name, index (from 0), n (from 1), isFirst, isLast and count (the number of rendered items). "unless": "$isLast" on a node inside a repeated group places a separator between items.

json
{ "id": "card", "primitive": "wrapper", "repeat": { "over": "$store.productsReducer.products", "as": "p", "key": "p._id", "where": { "eq": ["$p.category", "entremet"] } }, "props": { "type": "article", "className": "d-flex flex-column" }, ... }

when and unless on the node that carries repeat are evaluated once, before the list, with the outer scope: they show or hide the whole list and do not see the item. To filter items, write the condition in repeat.where.

The iteration variables are written after the item: with "as": "n" (or index, isFirst, isLast, count), the item is overwritten by the ordinal. Inside where, these variables are not defined yet.

on

on maps an event to an action. The engine wires these events, and $event.value holds:

Event$event.value
clickThe value of the clicked element, often empty.
input, changeThe field's value, as text. For a checkbox (change), true or false.
submitNo useful value. The native form submission is cancelled: the page does not reload.
keydownThe key: "Enter", "Escape"...
blur, focus, mouseenter, mouseleave, errorThe element value, if it has one.

input and change wire the same React handler (onChange): on a text field, both fire on every keystroke. A set without to writes $event.value: { "set": "email" } on input keeps the atom up to date, and the second step clears the error message.

json
{ "id": "in-email", "primitive": "Input", "props": { "id": "orderFlow-email", "name": "email", "type": "email", "className": "orderFlow-input", "autoComplete": "email", "value": "$email", "variant": { "if": ["$emailError", "error", "default"] } }, "on": { "input": [{ "set": "email" }, { "set": "emailError", "to": "" }] } }

To react to a single key, guard the action with a condition: { "when": { "eq": ["$event.value", "Enter"] }, "then": "submit" } on keydown.

Actions

An action takes one of three forms:

  • A string: the name of an action in actions; failing that, a behavior function, called with no argument.
  • A list: actions run one after the other; each step waits for the previous one to finish, API calls included.
  • An object: one or more verbs.

Inside an object, verbs always run in this order, whatever order they are written in: when, validate, validateAll, set, toggle, forEach, call, dispatch, fetch, reset, navigate, toast, then then.

VerbFormEffect
when / else{ "when": cond, ..., "else": action }When cond is false, runs else (if present) and stops this object.
validate{ "validate": ["email", "phone"] }Validates these fields; on error, writes the messages and stops this object. A value that is not a list is ignored.
validateAll{ "validateAll": true }Validates the block's whole schema, and that of every interactive block rendered inside it; stops this object on error.
set / to{ "set": "atom", "to": value }Writes the atom. Without to, writes $event.value. A name starting with $ is first read from the scope.
toggle{ "toggle": "atom", "value": v, "off": null }Single selection: sets v, or off when the atom already holds v. With "mode": "multi", adds v to or removes it from a list.
forEach{ "forEach": "$list", "as": "item", "do": action }Runs do for each item, with the item and index in the scope.
call{ "call": "function", "arg": v }Calls a behavior function with a value (arg) or an object (with).
dispatch{ "dispatch": { "store", "thunk", "with" } }Runs a call or an action of the site memory (see the next section).
fetch{ "fetch": { "path", "into" } }Calls an API route without going through the memory.
reset{ "reset": true } / { "reset": ["atom"] }Puts atoms back to their initial value, with their <atom>Error messages. true targets every state atom and every validated field.
navigate{ "navigate": "/${locale}/confirmation" }Opens the page, at that exact path.
toast{ "toast": "{saved}" }Shows a short, information-level message.
as"as": "res"Names the result of call, dispatch or fetch for the then of the same object.
then"then": actionThe next action, run after every verb of the object.

An object stopped by when, validate or validateAll goes no further, but the list that contains it carries on. A validation written alone in one step therefore does not prevent the next step: write the validation and the submission in the same object.

json
{ "on": { "click": { "toggle": "changing", "value": true, "off": false } } }

What an action chain reads

An action reads the scope of the render the event came from. A set changes the state for the next render, not for the following steps of the same chain: $atom keeps its old value there, so does validate, and $store stays as it was before a dispatch. To chain on a result, name it with as; to react to a memory change, use watch.

A complete save: the empty with keys listed in skipEmpty are not sent, the result is named created, and the rest shows a message then opens the created record, or sets an error message.

json
{ ... "dispatch": { "store": "quotesReducer", "thunk": "createQuote", "with": { "client": "$client", "city": "$city", "projectType": "$projectType", "amountHt": { "number": "$amountHt" }, "sentAt": "$sentAt", "nextFollowUpAt": "$nextFollowUpAt", "assignedTo": "$assignedTo" }, "skipEmpty": ["assignedTo"] }, "as": "created", "then": { "when": { "or": ["$created.result.body.quoteId", "$created.result.body.data.quoteId"] }, "then": [ { "toast": "{saved}" }, { "navigate": { "join": [ "", "/devis/", { "or": ["$created.result.body.quoteId", "$created.result.body.data.quoteId"] } ] } } ], "else": { "set": "submitError", "to": "{errSubmit}" } } }

navigate pushes the path as is. The Link primitive adds the language, navigate does not: on a multilingual site, write "/${locale}/...". A function called by call that throws stops the whole chain; a failing dispatch, on the other hand, gives an undefined result and the chain carries on.

The site memory and the project's API

The site memory (the Redux stores) is declared in web/configurations/stores/stores.json. Each entry creates a memory named <name>Reducer.

json
{ "cart": { "persist": true, "initialState": { "items": [], "count": 0, "total": 0 }, "statics": [ "addItem", "updateQuantity", "removeItem", "clearCart", "incrementItem", "decrementItem" ], "cases": [] }, "orders": { "statics": [], "cases": [ { "thunk": "fetchSlots", "actions": ["fulfilled", "pending", "rejected"] }, { "thunk": "submitOrder", "actions": ["fulfilled", "pending", "rejected"] }, ... ], "initialState": { "slots": [], "lastOrder": null, ... } }, "payment": { "persist": false, "initialState": { "providerSecret": null, "phase": "form" }, "statics": ["setProviderSecret", "setPaymentPhase"], "cases": [{ "thunk": "paymentUpdated", "actions": ["fulfilled", "pending", "rejected"] }] }, ... }
KeyRole
initialStateThe initial state. Kubbler adds status ("idle"), auth, error and monitoring: do not declare status yourself.
persisttrue: the memory is kept in the browser from one visit to the next. false by default.
persistOmitWith persist, the fields never written to the browser.
staticsSynchronous actions. Each one is a stores/<name>/statics/<action>/index.js file exporting staticContext(state, action).
casesAsynchronous calls: { "thunk", "actions": ["fulfilled", "pending", "rejected"], "cache" }, with stores/<name>/thunks/<thunk>/index.js (thunkContext) and stores/<name>/actions/<thunk>/index.js (actionContext). cache is a duration in seconds.

A thunk calls a route of the project's API and returns its response as is, { status, body }: it does not throw on a 4xx or 5xx status. The actions/<thunk>/index.js file decides what the memory keeps.

js
import { _post } from '@asynccode/ac_tools'; import { getApiEndpoint } from '@utils'; export const thunkContext = async (args) => { const { recaptchaToken, ...rest } = args || {}; return _post({ endpoint: getApiEndpoint('endpoint1'), path: '/orders/submit', body: rest, headers: recaptchaToken ? { 'x-recaptcha-token': recaptchaToken } : {}, }); };
js
export const actionContext = { pending: (state) => { state.status = 'loading'; state.result = null; }, fulfilled: (state, action) => { const { status, body } = action.payload.result; state.loading = false; const data = body?.data || body || {}; state.slots = status === 200 && Array.isArray(data.slots) ? data.slots : []; state.status = 'idle'; }, rejected: (state) => { ... }, };

From the tree

A tree reads the memory through $store.<name>Reducer.<path> ($store.cartReducer.items) and renders again when it changes. It acts on it through the dispatch verb:

KeyRole
storeThe memory name, with its suffix: ordersReducer.
thunkThe name of the thunk or of the synchronous action.
withAn object whose values are each resolved: the argument sent.
argA single value, instead of with ("arg": "$it.id").
skipEmptytrue, or a list of keys: empty with keys (undefined, null, "") are not sent. 0 and false are.
layer"extras" (thunks) or "statics" (synchronous actions). Without layer, the engine looks for a thunk, then for a synchronous action of the same name.

A thunk result, named by as, has the shape { result: { status, body }, thunkDuration }: read $res.result.status and $res.result.body. It is undefined when the call failed (network down) or was cancelled; the rest of the action still runs, with an else branch for that case.

Calling the project's API

  • The route exists in the project's API: here POST /orders/submit, in the orders group.
  • stores.json declares the thunk in cases, with its two files thunks/ and actions/. The thunk targets the endpoint1 entry of api.json and the route path.
  • The tree runs { "dispatch": { "store": "ordersReducer", "thunk": "submitOrder", "with": { ... } } }, then reads the response through as or through $store.ordersReducer.
The Data view on the orders collection, one order open in the Inspector.
Where the order lands: the orders collection in the Workshop's Data, one order open in the Inspector.w/[wsSlug]/p/[slug]?sel=data:col:orders

The fetch verb calls a route without a thunk: path (resolved), endpoint (an api.json entry, endpoint1 by default), method, params (the query string), body and into. The response is written into the atom named by into, with <into>.loading and <into>.error. GET reads; any other method is sent as POST, with body or, failing that, the whole local state of the block. A web/configurations/logic/<id>/bindings.json file can declare these calls outside the tree, { "calls": [{ "endpoint", "path", "method", "into", "trigger" }] }: without trigger (or with "mount"), at mount; otherwise after the action of the same name.

A thunk missing from cases does not exist, even when its files are in place: dispatch does nothing, with no error and no request.

Two Kubbler safeguards cancel a dispatch silently, and its result is then undefined: an identical call (same thunk, same arguments) still in progress, and, for 10 seconds, a call identical to one that received a status of 400 or more (401 excepted) or a network error.

Complete example: an order flow

The bakery demo sells cakes for in-store pickup. Its orderFlow block (web/configurations/components/orderFlow/) chains the cart, the choice of a pickup slot, the contact details and payment, with no block-specific JavaScript. The commander page mounts it through a component node with id orderFlow.

The Mechanisms Inspector on the order block, and its chain drawn to two routes then the products and orders collections.
The orderFlow block inspected in the Mechanisms: its tree.json, 15 state variables, 3 actions, and the two routes it calls (listSlots, submitOrder).w/[wsSlug]/p/[slug]?sel=map:behavior:orderFlow

State and computed value

The root declares the form atoms (date, pickupAt, pickupLabel, customerName, email, phone, note), one message atom per validated field, three step messages (cartError, pickupError, submitError) and two copy atoms (snapshot, snapshotTotal). A single computed value: minD, today plus two days, set as the min of the date field to enforce 48 hours of notice. The block root section shows the full state and the validation of customerName and email.

The cart comes from the memory

The cart is not in the block state. It lives in the cart memory, kept in the browser (persist: true) and filled through the synchronous addItem action from the home page and the cakes page. The block reads it (repeat over $store.cartReducer.items, total $store.cartReducer.total formatted in euros) and changes it through its synchronous actions incrementItem, decrementItem and removeItem, with "layer": "statics" and the line id as arg.

json
{ "id": "less", "primitive": "Button", "props": { "type": "button", "aria-label": "{qtyLess}", "className": "orderFlow-icon d-inline-flex align-items-center justify-content-center" }, "on": { "click": { "dispatch": { "store": "cartReducer", "thunk": "decrementItem", "layer": "statics", "arg": "$it.id" } } }, "children": [ { "id": "lessIcon", "primitive": "Icon", "props": { "name": "Minus", "size": 16 } } ] }

The pickup slot

The date field writes date, then calls the pickDate action with the same event. pickDate clears the chosen slot and, when a date is set, runs the fetchSlots thunk with { "date": "$event.value" }. Depending on the orders memory, the block then shows a loading message (status is "loading"), a message when the list is empty, or one button per slot. A click calls pickSlot, which copies $s.value and $s.label; the chosen button gets aria-pressed and the is-picked class (see the join example in Values).

json
{ "id": "in-date", "primitive": "Input", "props": { "id": "orderFlow-date", "name": "date", "type": "date", "min": "$minD", "value": "$date", "className": "orderFlow-input orderFlow-input--chalk" }, "on": { "change": [{ "set": "date" }, "pickDate"] } }
json
{ "pickDate": [ { "set": "pickupAt", "to": "" }, { "set": "pickupLabel", "to": "" }, { "set": "pickupError", "to": "" }, { "when": "$event.value", "then": { "dispatch": { "store": "ordersReducer", "thunk": "fetchSlots", "with": { "date": "$event.value" } } } } ], "pickSlot": [ { "set": "pickupAt", "to": "$s.value" }, { "set": "pickupLabel", "to": "$s.label" }, { "set": "pickupError", "to": "" } ] }

Paying

The pay action is a list of four steps. The first three set the messages: submitError cleared, cartError depending on whether the cart is empty (when, then, else), pickupError when no slot is chosen. None of these steps stops the others: all messages show at once.

json
{ "pay": [ { "set": "submitError", "to": "" }, { "when": { "empty": "$store.cartReducer.items" }, "then": { "set": "cartError", "to": "{errCart}" }, "else": { "set": "cartError", "to": "" } }, { "when": { "not": "$pickupAt" }, "then": { "set": "pickupError", "to": "{errPickup}" } }, ... ] }

The fourth step carries the whole submission in a single object. Its when requires a non-empty cart and a slot; validate checks the name, email and phone and stops the object if one fails; dispatch sends the order to the submitOrder thunk. note goes through { "if": ["$note", "$note"] }: when empty, it is undefined and the key is not sent, since JSON does not write an undefined value.

json
{ "when": { "and": [{ "not": { "empty": "$store.cartReducer.items" } }, "$pickupAt"] }, "validate": ["customerName", "email", "phone"], "dispatch": { "store": "ordersReducer", "thunk": "submitOrder", "with": { "customerName": "$customerName", "email": "$email", "phone": "$phone", "note": { "if": ["$note", "$note"] }, "pickupAt": "$pickupAt", "items": "$store.cartReducer.items" } }, "as": "order", "then": [ { "when": { "or": [ "$order.result.body.providerSecret", "$order.result.body.data.providerSecret" ] }, "then": [ { "set": "snapshot", "to": "$store.cartReducer.items" }, { "set": "snapshotTotal", "to": "$store.cartReducer.total" }, { "dispatch": { "store": "paymentReducer", "thunk": "setProviderSecret", "layer": "statics", "arg": { "or": [ "$order.result.body.providerSecret", "$order.result.body.data.providerSecret" ] } } }, { "dispatch": { "store": "cartReducer", "thunk": "clearCart", "layer": "statics" } } ], "else": { "set": "submitError", "to": "{errSubmit}" } } ] }

The result is named order. When the response carries a providerSecret (looked up in two places with or, since the response may wrap it in data), the block copies the cart and its total into snapshot and snapshotTotal, hands the secret to the payment memory (setProviderSecret), then empties the cart (clearCart). The copy reads the cart from the render the click came from, so before it is emptied. Otherwise submitError receives {errSubmit}; a network error gives an undefined result and takes the same branch.

The payment screen

Moving on to payment is not a block atom: it is $store.paymentReducer.providerSecret. The form steps carry "unless": "$store.paymentReducer.providerSecret", the paying node carries the opposite when and embeds the paymentStep block through a fragment node. The summary reads the cart until payment has started, then snapshot, and the total follows the same rule with an if:

json
{ "id": "recapTotalValue", "primitive": "Span", "props": { "text": { "format": [ { "if": [ "$store.paymentReducer.providerSecret", "$snapshotTotal", "$store.cartReducer.total" ] }, "money", "eur" ] } } }

Limits and pitfalls

What the DSL does not do, and the mistakes that produce no message.

No route parameter in the scope

The scope contains neither the dynamic route segments (devis/[id]) nor the query string. A few-line hook, web/configurations/logic/<id>/index.js, exposes them: the page passes it args, which carries locale, pathname and one field per dynamic segment (see Behavior in JavaScript). For the query string, the hook reads it with Next's useSearchParams.

No division, no rounding

For arithmetic, derive offers add, mul and clamp: no subtraction, no division, no rounding. A subtraction is written add(a, mul(b, -1)), a division mul(a, 0.01) (the inverse of the divisor). The only rounding is amountFromCents, which rounds to the unit and returns a text: multiply by 100 first to round an amount to the euro. A configurator computes a price, a deposit and a balance this way:

json
{ "derive": { ... "unitRaw": { "fn": "add", "args": ["$sub1", "$motorCost"] }, "unitCents": { "fn": "mul", "args": ["$unitRaw", 100] }, "unitPrice": { "fn": "amountFromCents", "args": ["$unitCents"] }, "total": { "fn": "mul", "args": ["$unitPrice", "$quantity"] }, "dRate": { "fn": "mul", "args": ["$store.configuratorReducer.rule.depositPercent", 0.01] }, "depRaw": { "fn": "mul", "args": ["$total", "$dRate"] }, "depCents": { "fn": "mul", "args": ["$depRaw", 100] }, "deposit": { "fn": "amountFromCents", "args": ["$depCents"] }, "depNeg": { "fn": "mul", "args": ["$deposit", -1] }, "balance": { "fn": "add", "args": ["$total", "$depNeg"] } } }

format with money expects units: a price in cents shows a hundred times too large, convert it with mul(price, 0.01). amountFromCents returns "-" when its argument is not a JavaScript number, a "21800" text included: give it the result of a mul.

The CSS specificity of styleJson

styleJson styles are compiled into the /* forge:nodes:start */ ... /* forge:nodes:end */ region of the block's style.css, under a doubled selector carrying the block id and the node id: [data-forge-id="orderFlow--root"][data-forge-id="orderFlow--root"], with a specificity of (0,2,0). The region is appended at the end of the file, after the hand-written CSS. A one-class rule (.orderFlow-pay) loses against it; a two-part rule (.orderFlow-slot.is-picked, .orderFlow-pay:hover) loses too, at equal specificity, because it comes first.

css
.orderFlow-pay { gap: var(--space-2); padding: var(--space-4) var(--space-6); font: inherit; font-size: var(--text-lg); font-weight: var(--weight-bold); color: var(--ink-text-on-brand); background: var(--brand); border: none; border-radius: var(--radius-sm); box-shadow: var(--shadow-brand); cursor: pointer; transition: background-color var(--duration-base) var(--ease-out), transform var(--duration-base) var(--ease-out); } .orderFlow-pay:hover { background: var(--brand-secondary); transform: translateY(-2px); } .orderFlow-pay:disabled { opacity: 0.6; cursor: progress; transform: none; } /* forge:nodes:start */ [data-forge-id="orderFlow--root"][data-forge-id="orderFlow--root"] { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-10); padding-bottom: var(--space-16); } ... /* forge:nodes:end */
  • A property that changes with a state (hover, an is-on class, :disabled) must not also be set in the node's styleJson: keep it in the class, or write the state in the hover, focus-visible or active blocks of the styleJson. In the demo, the payment button has no styleJson: its background, hover and disabled state live together in .orderFlow-pay.
  • The blocks of a styleJson: base, hover, focus-visible, active, before, after, hover-before, hover-after, and the minimum-width thresholds sm (576 px), md (768 px), lg (992 px), xl (1200 px), xxl (1400 px).
  • The region is rewritten by the tools that save the block (Workshop, API); the CSS outside the region is never touched. A styleJson edited by hand only shows once the region is recompiled.
  • Every item of a repeat shares the node's data-forge-id: one rule reaches them all.

Other pitfalls

  • when on a repeat node is evaluated before the list: filter with repeat.where.
  • "as": "n", "index", "count", "isFirst" or "isLast": the item is overwritten by the iteration variable.
  • A derive that reads one declared further down receives undefined, counted as 0.
  • Input is text: eq compares without conversion, go through number.
  • An empty list is false for when, but true inside not, and, or and if: test it with empty.
  • A set is not visible to the following steps of the same chain.
  • A validate alone in a step does not stop the next step.
  • navigate does not prefix the language; Link does.
  • fetch sends any method other than GET as POST.
  • toast always shows an information-level message.
  • A thunk missing from cases does nothing; an identical call repeated within 10 seconds of a failure is cancelled.
  • An embedded block only gets its own dictionaries if it is also listed in the page's structure.json.