Le DSL des blocs
Un bloc interactif se décrit en données dans son fichier tree.json : sa forme, ses textes, son état local, ses calculs, ses validations et ses actions. Cette page décrit chaque clé que le moteur lit, les opérateurs, fonctions et verbes disponibles, le lien avec la mémoire du site et l'API du projet, un parcours de commande complet, puis les limites connues.
Mis à jour le
Sur cette page10 sections
Vue d'ensemble
Un bloc est un dossier web/configurations/components/<id>/ (ou fragments/<id>/). Quand ce dossier contient un tree.json dont la racine porte une clé primitive, la page rend le bloc à partir de cet arbre : sa forme, ses textes, son état, ses calculs, ses validations et ses actions sont du JSON, lu par un moteur commun à tous les projets. Le DSL est le vocabulaire fermé de ce moteur. Une clé qu'il ne connaît pas est ignorée, sans message d'erreur.
web/configurations/components/orderFlow/
tree.json
slots.json
options.json
style.css
index.js
dictionaries/
fr.json
web/configurations/logic/orderFlow/index.js (optional)| Fichier | Rôle |
|---|---|
tree.json | L'arbre : la racine (état, calculs, validations, actions) et les nœuds à rendre. |
slots.json | La liste des textes du bloc : un slot par texte, avec son id et son defaultValue. |
dictionaries/<langue>.json | Les textes traduits, sous la clé <slot>_<suffixe> (voir Les textes). |
options.json | La configuration du bloc. Ses objets config et fragments entrent dans le scope des valeurs. |
style.css | Le CSS écrit à la main, suivi de la région générée depuis les styleJson des nœuds. |
index.js | Facultatif. La page monte un bloc à tree.json sans le lire ; il ne sert que si le bloc est embarqué dans un autre par un nœud fragment. |
logic/<id>/index.js | Facultatif. Un hook useLogic en JavaScript dont le retour entre dans le scope (voir La racine du bloc). |
Une page monte le bloc depuis son structure.json, par l'id du dossier : un nœud component lit components/<id>/, un nœud fragment lit fragments/<id>/.
{
"primitive": "wrapper",
"type": "section",
"classname": "frame-page frame-space-m",
"children": [{ "component": "OrderFlow", "id": "orderFlow", "_nid": "orderFlow" }],
"_nid": "orderFlowSection"
}Bloc statique, bloc interactif
Deux moteurs rendent un arbre. Un bloc statique est rendu sur le serveur, sans JavaScript, et ne comprend que les marqueurs de texte {slot}. Un bloc devient interactif dès que sa racine porte logic, state, actions, onMount, derive, watch ou effects, qu'un de ses nœuds porte on, when, unless ou repeat, ou qu'un fichier logic/<id>/bindings.json déclare des appels. Il passe alors au moteur interactif, qui s'exécute dans le navigateur, et tout ce qui suit s'applique.
Sur le canevas de l'Atelier, le comportement ne s'exécute pas par défaut : les valeurs sont résolues avec l'état initial, les derive et un jeu de données déduit de stores/stores.json, et la commande États du bloc affiche un à un les écrans que déclarent les when et unless. Un champ que l'arbre lit mais que rien ne déclare y reste vide.
Les nœuds de l'arbre
Un nœud est un objet JSON. La racine de tree.json est un nœud comme les autres, qui porte en plus les clés de comportement décrites dans La racine du bloc.
| Clé | Rôle |
|---|---|
id | L'identifiant du nœud, unique dans l'arbre, en caractères A-Z a-z 0-9 _ -. Il sert d'ancre au CSS généré et d'adresse dans l'Atelier. |
primitive | Ce que le nœud rend : un type de nœud (wrapper, group, fragment, region) ou le nom d'une primitive (Heading, Button, Input...). |
props | Les propriétés du nœud. Chaque valeur est résolue (voir Les valeurs). |
children | Les enfants : des nœuds, ou des chaînes (un texte en ligne, résolu comme une valeur). |
styleJson | Le style du nœud par blocs (base, hover, md...), compilé dans style.css. |
when / unless | Rendre le nœud seulement si la valeur est vraie (when) ou fausse (unless). |
repeat | Rendre le nœud une fois par élément d'une liste. |
on | Les événements du nœud, et l'action de chacun. |
name / pass | Sur un nœud fragment : le bloc à monter et les propriétés à lui passer. |
{
"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"
}
}
}Les types de nœuds
| Type de nœud | Rendu |
|---|---|
wrapper | Un conteneur. props.type choisit la balise dans une liste fermée : div, section, article, aside, header, footer, main, nav, address, figure, figcaption, ul, ol, li, dl, dt, dd, cite, fieldset, legend, strong, em, canvas et les éléments SVG de forme. Toute autre valeur donne div. |
group | Un regroupement sans élément HTML : ses enfants prennent sa place. Il sert à un repeat qui doit produire plusieurs frères par élément. |
fragment | Un autre bloc, monté par son name (voir plus bas). |
region | Un emplacement que le comportement du bloc remplit sous le nom props.region ; sinon ses propres enfants ; vide, il ne rend rien. |
Icon | Une icône Lucide : props.name (Lock, ChevronDown...) et props.size. Un nom inconnu ne rend rien. |
Img | Une image : props.src est le nom d'un fichier de assets/images/ ou une adresse (commençant par /, http ou data:) ; plus alt, width, height, sizes, fill, priority, loading. |
| Une primitive | Le composant de même nom (liste ci-dessous). Toutes ses props lui sont passées résolues ; text devient son contenu quand le nœud n'a pas d'enfants. |
Les primitives
| Famille | Primitives |
|---|---|
| Texte | Heading (level), Paragraph, Span, Blockquote, Mark, Strong, Em, Underline, Strike, Code, Time |
| Formulaire | Form, Input, Textarea, Select, Option, Label, Button, Checkbox, Radio, SliderCheckbox, CloseButton |
| Liens et médias | Link (href), Img, Figure, Figcaption, Iframe, Video, Audio |
| Structure | Article, Aside, Address, Details, Summary, List, ListItem, Table, TableHead, TableBody, TableRow, TableCell, Hr, Br, Divider |
| Divers | Badge, Loader, CodeEditor |
Les attributs d'un wrapper passent par une liste fermée : className, id, role, hidden, inert, tabIndex, title, disabled, les data-* et aria-*, style (un objet, filtré comme un styleJson) et, sur un élément SVG, ses attributs de géométrie (viewBox, d, fill...), plus ref et region. Les autres sont écartés sans erreur. Écrivez l'ARIA avec un tiret (aria-label, aria-pressed) ; la forme ariaLabel est convertie.
Un champ dont value est lié sans événement input ou change est rendu en lecture seule. Checkbox, Radio et SliderCheckbox se pilotent par checked, pas par value.
Embarquer un autre bloc
Un nœud fragment monte un bloc par son name : un bloc du projet (son id) ou un bloc fourni par Kubbler (son nom, par exemple StripeCheckoutForm). props.__variantId choisit un autre id que le nom, et les autres props remplacent son options.json. pass lui transmet des propriétés résolues ; une valeur { "action": ... } y devient une fonction qui exécute cette action dans le bloc parent, la valeur reçue étant lisible en $arg. name et __variantId acceptent un $ref.
{
"id": "paying",
"primitive": "wrapper",
"when": "$store.paymentReducer.providerSecret",
"props": { "className": "d-flex flex-column" },
"children": [{ "id": "paymentStep", "primitive": "fragment", "name": "paymentStep" }]
}{
"id": "stripeForm",
"primitive": "fragment",
"name": "StripeCheckoutForm",
"pass": {
"stripeOptions": "$stripeOptions",
"onSuccess": "$onPaymentSuccess",
"returnUrl": "$returnUrl",
...
}
}Un bloc embarqué est monté par Slot et rendu par son propre index.js, avec le dictionnaire de la page. Ses dictionnaires ne sont fusionnés dans la page que s'il figure lui aussi dans le structure.json de la vue.
Règles d'écriture de l'arbre
- Chaque nœud porte un
primitive(une chaîne) et unidau format[A-Za-z0-9_-]+, unique dans l'arbre. - Profondeur maximale : 64 niveaux. Au plus 2000 nœuds.
childrenest une liste. Les clés__proto__,constructoretprototypesont refusées.- L'Atelier vérifie ces règles à chaque écriture du fichier : un
tree.jsonou unslots.jsonqui les viole est refusé (400).
Les textes : slots et dictionnaires
Un texte du bloc ne s'écrit pas en clair dans l'arbre. L'arbre porte un marqueur {slot}, slots.json déclare le slot, et le dictionnaire de chaque langue porte sa valeur.
[
{ "id": "cartTitle", "type": "string", "required": false, "defaultValue": "" },
{ "id": "errCart", "type": "string", "required": false, "defaultValue": "" },
...
]{
"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.",
...
}La clé de dictionnaire d'un slot est <slot>_<suffixe>. Le suffixe est le dernier segment de l'id du bloc après son dernier tiret : le bloc orderFlow donne _orderFlow, un bloc order-flow donnerait _flow. Les dictionnaires du bloc sont fusionnés dans celui de la page quand la vue le liste dans son structure.json.
Résolution d'un marqueur
{x}, quandxest un slot du bloc : le moteur lit la cléx_<suffixe>. Si elle manque, un bloc interactif affiche ledefaultValuedu slot, un bloc statique afficheneed_trads.{x}, quandxn'est pas un slot : le moteur lit la cléxtelle quelle dans le dictionnaire de la page ; si elle manque aussi, il affichex.{$chemin}: la clé est la valeur lue en$chemin, puis la même résolution s'applique. C'est ainsi qu'un bloc affiche un libellé choisi par sa configuration.
Dans la démo d'épicerie, options.json liste les rayons ; chaque rayon nomme son titre par un id de slot (aisleFl). L'arbre écrit {$a.title} dans la boucle : la valeur aisleFl est un slot, donc le texte affiché est celui de la clé aisleFl_productList.
{
"__variantId": "productList",
"config": {
"aisles": [
{
"key": "fruits-legumes",
"title": "aisleFl",
"lead": "aisleFlLead",
"photo": "rayon-legumes-bacs.jpg",
"alt": "aisleFlAlt"
},
...
]
}
}{
"id": "aisle",
"primitive": "wrapper",
"repeat": { "over": "$aisles", "as": "a", "key": "a.key" },
"children": [
...
{
"id": "signTitle",
"primitive": "Heading",
"props": { "level": 2, "text": "{$a.title}" }
},
...
]
}Un texte se pose dans props.text, ou comme chaîne dans children quand il s'intercale entre des nœuds. Une chaîne peut mêler texte fixe, valeurs et clés : "x${it.quantity}", "${p.name}, ${v.label} : {addedToast}".
Les messages de validation ne passent pas par un slot : le champ error d'une règle est une clé lue telle quelle dans le dictionnaire, donc écrite avec son suffixe (errName_orderFlow). Un message posé par une action ("to": "{errCart}") passe, lui, par le slot errCart, qui doit exister dans slots.json.
Les valeurs et les opérateurs
Toute valeur de l'arbre (une prop, une condition, un argument d'action) est résolue au rendu, ou au moment où l'action s'exécute, selon sa forme :
| Forme | Exemple | Résultat |
|---|---|---|
"$chemin" | "$store.cartReducer.items" | La valeur lue dans le scope, de n'importe quel type. Le chemin suit des points ; un index de liste s'écrit .0. |
"!$chemin" / "!!$chemin" | "!!$emailError" | La valeur convertie en booléen, inversée (!) ou non (!!). |
"${chemin}" | "x${it.quantity}" | La valeur insérée dans le texte ; vide si elle est absente. |
"{clé}" | "{cartTitle}" | Le texte du slot ou de la clé de dictionnaire. |
"{$chemin}" | "{$a.title}" | Le texte de la clé que vaut $chemin. |
| Un objet à une clé d'opérateur | { "empty": "$store.cartReducer.items" } | Le résultat de l'opérateur. |
| Un autre objet, une liste | { "price": "$v.price" } | Résolu clé par clé, en profondeur. |
| Une autre chaîne, un nombre, un booléen | "button", 2, true | La valeur telle quelle. |
$chemin n'est lu que s'il occupe toute la chaîne. "$it.price €" est un texte littéral, affiché tel quel : écrivez "${it.price} €". Une valeur liée en entier à une position de texte et qui vaut un objet ne rend rien : liez un champ ("$slot.text"), pas l'objet.
Le scope
Le scope est l'ensemble des noms qu'un $chemin peut lire. Du plus faible au plus fort (un nom plus fort masque le même nom plus faible) :
store: la mémoire du site, lue en$store.<nom>Reducer.<chemin>.locale: la langue de la page, lue en$locale.- Les clés de
options.config, puis deoptions.fragments:config.aislesse lit$aisles. - Le retour du hook de comportement, s'il existe.
- Les atomes de
state. - Les valeurs de
derive, calculées dans l'ordre où elles sont écrites.
S'y ajoutent selon le contexte : les variables d'un repeat (l'élément, index, n, isFirst, isLast, count), $event dans une action, le résultat nommé par as, $prev et $next dans un watch, $arg dans une action passée par pass.
Un derive ou un atome qui porte le nom d'une clé de options.config la masque, sans avertissement. Nommez xxxLabel une clé d'options qui porte un libellé (lue en {$xxxLabel}), et gardez le nom nu pour la valeur calculée.
Les opérateurs
Un opérateur est un objet qui a exactement une clé, et cette clé est un nom d'opérateur. Ses arguments s'écrivent en liste (un argument seul peut s'écrire sans liste) et sont résolus avant l'appel, donc un opérateur peut en contenir un autre. when, unless, where et le when d'une action tiennent une liste vide pour fausse ; partout ailleurs, et donc dans not, and, or et if, une valeur est vraie au sens de JavaScript, et une liste vide y est vraie : testez une liste avec empty. La liste des opérateurs est fermée :
| Opérateur | Forme | Résultat |
|---|---|---|
eq | { "eq": [a, b] } | Vrai si a et b sont strictement égaux, sans conversion de type. |
ne | { "ne": [a, b] } | Vrai si a et b diffèrent. |
lt, lte, gt, gte | { "lt": [a, b] } | a < b, a <= b, a > b, a >= b. |
in | { "in": [x, liste] } | Vrai si la liste contient x. |
empty | { "empty": a } | Vrai pour une liste vide, null, une valeur absente ou "". 0 et false ne sont pas vides. |
not | { "not": a } | La négation de a. |
and | { "and": [a, b, ...] } | La première valeur fausse, sinon la dernière. |
or | { "or": [a, b, ...] } | La première valeur vraie, sinon la dernière. Sert aussi de valeur de repli. |
if | { "if": [cond, alors, sinon] } | alors si cond est vraie, sinon sinon (undefined s'il est omis). |
join | { "join": [sep, a, b, ...] } | Les parties non vides, jointes par sep. Compose une classe ou un texte. |
format | { "format": [v, "money", "eur"] } | Un montant en unités (pas en centimes), formaté dans la langue de la page ; la devise vaut eur par défaut. Vide si v est vide. |
format | { "format": [v, "date"] } | Une date au format moyen de la langue de la page, en UTC. |
number | { "number": v } | Le nombre lu dans un texte ("12,5" donne 12.5) ; undefined si le texte est vide ; le texte inchangé s'il n'est pas un nombre. |
match | { "match": [liste, "r", cond] } | Les éléments de la liste pour lesquels cond est vraie, l'élément étant nommé r. |
{
"id": "payText",
"primitive": "Span",
"props": {
"text": {
"join": [" ", "{payCta}", { "format": ["$store.cartReducer.total", "money", "eur"] }]
}
}
}{
"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" }
}Une valeur saisie dans un champ ($event.value) est un texte. { "eq": ["$visits", 4] } n'est vrai que si visits a été posé en nombre ("to": 4) ; convertissez avec number avant de comparer ou d'envoyer une saisie. Un objet qui porte une clé d'opérateur et une autre clé n'est pas un opérateur : il est résolu comme un objet ordinaire.
La racine du bloc : état, calculs, validations
La racine de tree.json porte, en plus de ses clés de nœud, le comportement du bloc :
| Clé | Forme | Rôle |
|---|---|---|
state | { "<atome>": valeur } | L'état local. Une valeur initiale peut être un $ref vers options.config ou le comportement, lu une fois au montage. |
derive | { "<nom>": { "fn": "...", "args": [...] } } | Des valeurs calculées à chaque rendu. |
validate | { "<champ>": [ { "rule": "...", "error": "..." } ] } | Les règles des champs du formulaire. |
actions | { "<nom>": action } | Des actions nommées, appelées par leur nom. |
onMount | action | Une action exécutée une fois, dans le navigateur, après le premier rendu. |
watch | { "<valeur>": action } | Une action exécutée quand la valeur change. |
logic | "<Nom>" | Le nom d'un comportement fourni par Kubbler. |
refs / effects | ["<ref>"] / { "<nom>": { "fn": "autofocus", "ref": "<ref>" } } | Des références d'éléments et des effets nommés. autofocus est le seul effet disponible. |
{
"state": {
"date": "",
"pickupAt": "",
"pickupLabel": "",
"customerName": "",
"email": "",
"phone": "",
"note": "",
"customerNameError": "",
"emailError": "",
"phoneError": "",
"pickupError": "",
"cartError": "",
"submitError": "",
"snapshot": [],
"snapshotTotal": 0
},
"derive": { "minD": { "fn": "minDate", "args": [2] } }
}state
Chaque atome se lit $atome et s'écrit par l'action set. Un nom à points écrit dans un atome objet ("set": "form.email"). La validation écrit le message d'un champ dans l'atome <champ>Error ; les démos le déclarent à "".
derive
Un derive appelle une fonction nommée avec des arguments résolus dans le scope (args peut être une valeur seule). Les derive sont calculés dans l'ordre où ils sont écrits, et chacun lit ceux qui le précèdent : un derive qui lit un derive déclaré plus bas reçoit undefined, que les fonctions numériques comptent pour 0. Une fonction inconnue donne undefined. La liste des fonctions est fermée :
| Fonction | Arguments | Résultat |
|---|---|---|
add | a, b | a + b. Un argument non numérique compte pour 0. |
mul | a, b | a * b, même règle. true compte pour 1, false pour 0. |
clamp | v, min, max | v ramené entre min et max. |
amountFromCents | cents | Le montant en unités, arrondi à l'entier, sous forme de texte (21800 donne "218"). "-" si l'argument n'est pas un nombre. |
range | n | La liste [1, 2, ..., n], de 0 à 200 éléments. |
minDate | days | La date du jour plus days jours, au format AAAA-MM-JJ, en heure locale. |
findBy | list, key, value | Le premier élément dont le champ key vaut value, sinon null. |
filterBy | list, key, query | Les éléments dont le champ key contient query, sans tenir compte de la casse ; toute la liste si query est vide. |
trim | value | Le texte sans ses espaces de début et de fin. |
mapWith | list, fn, key, as | Chaque élément, plus un champ as (par défaut value) calculé par la fonction fn sur son champ key. |
passwordRules | password | La liste des critères du mot de passe, { key, met, label, iconName }, libellés lus sous pwRuleLength, pwRuleCase, pwRuleDigit, pwRuleSpecial. |
Un simulateur de tarif enchaîne ses calculs du haut vers le bas ; waste est un booléen, que mul compte pour 1 ou 0 :
{
"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 associe à chaque champ (un atome de state) une liste de règles, essayées dans l'ordre : la première qui échoue donne le message du champ. Une règle s'écrit { "rule": "<nom>", "args": [...], "error": "<clé>" } ; avec "when": <condition>, elle ne s'applique que si la condition est vraie. La validation ne s'exécute que par une action (validate ou validateAll) : elle vide <champ>Error pour chaque champ vérifié, puis y écrit le texte de la clé error, ou la clé elle-même si le dictionnaire ne la porte pas.
| Règle | Arguments | Accepte |
|---|---|---|
required | - | Un texte non vide, espaces retirés. |
minLength | n | Au moins n caractères. |
maxLength | n | Au plus n caractères. |
email | - | Une adresse e-mail. |
alphaNum | min, max | Des lettres sans accent et des chiffres ; min et max bornent la longueur. |
emailOrAlphaNum | min, max | Une adresse e-mail, ou un identifiant accepté par alphaNum. |
match | "$autreChamp" | La même valeur que l'atome nommé (confirmation d'un mot de passe). |
passwordPolicy | - | Un mot de passe qui remplit les critères de passwordRules. |
number | - | Un nombre (virgule décimale acceptée), ou un champ vide. |
date | - | Une date lisible, ou un champ vide. |
{
"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" }
]
},
...
}Un champ validé est un atome de premier niveau de state : validate ne lit ni un chemin à points, ni un derive, ni $store. number et date acceptent un champ vide : ajoutez required devant. Une règle au nom inconnu est sautée.
Le comportement en JavaScript
Quand le DSL ne suffit pas, un hook useLogic complète le bloc. Le moteur cherche web/configurations/logic/<id>/index.js (l'id du bloc), puis le comportement fourni par Kubbler que nomme la clé logic. Le hook reçoit { name, id, options, dictionary, args } ; ce qu'il renvoie entre dans le scope : une valeur se lit $nom, une fonction s'appelle par le verbe call ou par son nom. Un hook de quelques lignes suffit à exposer un paramètre de route :
'use client';
export function useLogic({ args } = {}) {
return { quoteId: (args && args.id) || '' };
}{
"onMount": {
"dispatch": {
"store": "quotesReducer",
"thunk": "fetchQuote",
"with": { "id": "$quoteId" }
}
}
}onMount et watch
onMount exécute une action une seule fois, dans le navigateur, après le premier rendu, avec le scope de ce rendu. watch associe une valeur à une action : à chaque rendu la valeur est relue, et l'action s'exécute quand elle a changé (jamais au montage), avec $prev (l'ancienne valeur) et $next (la nouvelle). C'est ainsi qu'un bloc réagit à un changement de la mémoire du site.
{
"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" }
}
}
]
}
}
}Au montage, la boutique choisie n'est peut-être pas encore connue : skipEmpty n'envoie pas un storeId vide, et le watch relance la lecture des créneaux dès que currentId change.
refs déclare des références d'éléments ("refs": ["field"]), qu'un nœud reçoit par "ref": "$field". effects y attache un effet nommé ({ "fn": "autofocus", "ref": "field" }), exécuté au montage et relancé quand sa valeur when change.
Conditions, listes et événements
when et unless
when rend le nœud si sa valeur est vraie, unless si elle est fausse. La valeur est n'importe quelle valeur du DSL : un $ref ou un opérateur. Un nœud masqué n'est pas rendu du tout, ni lui ni ses enfants.
{
"id": "slotsEmpty",
"primitive": "Span",
"props": { "text": "{slotsEmpty}" },
"when": {
"and": [
"$date",
{ "ne": ["$store.ordersReducer.status", "loading"] },
{ "empty": "$store.ordersReducer.slots" }
]
}
}repeat
repeat rend le nœud une fois par élément de over. Une valeur qui n'est pas une liste ne rend rien.
| Clé | Rôle |
|---|---|
over | La liste, en $ref ou en opérateur. |
as | Le nom de l'élément dans le scope ; item par défaut. |
key | Le chemin de la clé de chaque élément, sans $ ("it.id") ; l'index par défaut. |
where | Une condition évaluée pour chaque élément : seuls ceux qui la remplissent sont rendus. |
Chaque itération expose l'élément sous son nom, index (à partir de 0), n (à partir de 1), isFirst, isLast et count (le nombre d'éléments rendus). "unless": "$isLast" sur un nœud d'un group répété pose un séparateur entre les éléments.
{
"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 et unless posés sur le nœud qui porte repeat sont évalués une seule fois, avant la liste, avec le scope extérieur : ils montrent ou cachent toute la liste et ne voient pas l'élément. Pour filtrer les éléments, écrivez la condition dans repeat.where.
Les variables d'itération sont écrites après l'élément : avec "as": "n" (ou index, isFirst, isLast, count), l'élément est écrasé par le numéro d'ordre. Dans where, ces variables ne sont pas encore définies.
on
on associe un événement à une action. Le moteur branche ces événements, et $event.value y vaut :
| Événement | $event.value |
|---|---|
click | La valeur de l'élément cliqué, souvent vide. |
input, change | La valeur du champ, en texte. Pour une case à cocher (change), true ou false. |
submit | Pas de valeur utile. L'envoi natif du formulaire est annulé : la page ne se recharge pas. |
keydown | La touche : "Enter", "Escape"... |
blur, focus, mouseenter, mouseleave, error | La valeur de l'élément, s'il en a une. |
input et change branchent le même gestionnaire React (onChange) : sur un champ texte, les deux partent à chaque frappe. Un set sans to écrit $event.value : { "set": "email" } sur input garde l'atome à jour, et la seconde étape efface le message d'erreur.
{
"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": "" }] }
}Pour réagir à une seule touche, gardez l'action par une condition : { "when": { "eq": ["$event.value", "Enter"] }, "then": "submit" } sur keydown.
Les actions
Une action prend une de trois formes :
- Une chaîne : le nom d'une action de
actions; à défaut, une fonction du comportement, appelée sans argument. - Une liste : des actions exécutées l'une après l'autre ; chaque étape attend la fin de la précédente, appel à l'API compris.
- Un objet : un ou plusieurs verbes.
Dans un objet, les verbes s'exécutent toujours dans cet ordre, quel que soit l'ordre d'écriture : when, validate, validateAll, set, toggle, forEach, call, dispatch, fetch, reset, navigate, toast, puis then.
| Verbe | Forme | Effet |
|---|---|---|
when / else | { "when": cond, ..., "else": action } | Si cond est fausse, exécute else (s'il existe) et arrête cet objet. |
validate | { "validate": ["email", "phone"] } | Valide ces champs ; en cas d'erreur, écrit les messages et arrête cet objet. Une valeur qui n'est pas une liste est ignorée. |
validateAll | { "validateAll": true } | Valide tout le schéma du bloc, et celui de chaque bloc interactif rendu à l'intérieur ; arrête cet objet en cas d'erreur. |
set / to | { "set": "atome", "to": valeur } | Écrit l'atome. Sans to, écrit $event.value. Un nom qui commence par $ est d'abord lu dans le scope. |
toggle | { "toggle": "atome", "value": v, "off": null } | Sélection simple : pose v, ou off si l'atome vaut déjà v. Avec "mode": "multi", ajoute ou retire v d'une liste. |
forEach | { "forEach": "$liste", "as": "item", "do": action } | Exécute do pour chaque élément, avec l'élément et index dans le scope. |
call | { "call": "fonction", "arg": v } | Appelle une fonction du comportement avec une valeur (arg) ou un objet (with). |
dispatch | { "dispatch": { "store", "thunk", "with" } } | Lance un appel ou une action de la mémoire du site (voir la section suivante). |
fetch | { "fetch": { "path", "into" } } | Appelle une route de l'API sans passer par la mémoire. |
reset | { "reset": true } / { "reset": ["atome"] } | Remet les atomes à leur valeur initiale, avec leurs messages <atome>Error. true vise tous les atomes de state et tous les champs validés. |
navigate | { "navigate": "/${locale}/confirmation" } | Ouvre la page, à ce chemin exact. |
toast | { "toast": "{saved}" } | Affiche un message bref, de niveau information. |
as | "as": "res" | Nomme le résultat de call, dispatch ou fetch pour le then du même objet. |
then | "then": action | L'action suivante, exécutée après tous les verbes de l'objet. |
Un objet arrêté par when, validate ou validateAll ne va pas plus loin, mais la liste qui le contient continue. Une validation écrite seule dans une étape n'empêche donc pas l'étape suivante : écrivez la validation et l'envoi dans le même objet.
{ "on": { "click": { "toggle": "changing", "value": true, "off": false } } }Ce que lit une chaîne d'actions
Une action lit le scope du rendu où l'événement est parti. Un set change l'état pour le rendu suivant, pas pour les étapes suivantes de la même chaîne : $atome y garde l'ancienne valeur, validate aussi, et $store reste l'état d'avant une dispatch. Pour enchaîner sur un résultat, nommez-le avec as ; pour réagir à un changement de la mémoire, utilisez watch.
Un enregistrement complet : les clés vides de with listées dans skipEmpty ne sont pas envoyées, le résultat est nommé created, et la suite affiche un message puis ouvre la fiche créée, ou pose un message d'erreur.
{
...
"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 pousse le chemin tel quel. La primitive Link ajoute la langue, navigate non : sur un site multilingue, écrivez "/${locale}/...". Une fonction appelée par call qui lève une erreur arrête toute la chaîne ; une dispatch qui échoue, elle, donne un résultat undefined et la chaîne continue.
La mémoire du site et l'API du projet
La mémoire du site (les stores Redux) se déclare dans web/configurations/stores/stores.json. Chaque entrée crée une mémoire nommée <nom>Reducer.
{
"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"] }]
},
...
}| Clé | Rôle |
|---|---|
initialState | L'état initial. Kubbler y ajoute status ("idle"), auth, error et monitoring : ne redéclarez pas status. |
persist | true : la mémoire est gardée dans le navigateur d'une visite à l'autre. false par défaut. |
persistOmit | Avec persist, les champs jamais écrits dans le navigateur. |
statics | Les actions synchrones. Chacune est un fichier stores/<nom>/statics/<action>/index.js qui exporte staticContext(state, action). |
cases | Les appels asynchrones : { "thunk", "actions": ["fulfilled", "pending", "rejected"], "cache" }, avec stores/<nom>/thunks/<thunk>/index.js (thunkContext) et stores/<nom>/actions/<thunk>/index.js (actionContext). cache est une durée en secondes. |
Un thunk appelle une route de l'API du projet et renvoie sa réponse telle quelle, { status, body } : il ne lève pas d'erreur sur un statut 4xx ou 5xx. Le fichier actions/<thunk>/index.js décide de ce que la mémoire garde.
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 } : {},
});
};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) => { ... },
};Depuis l'arbre
Un arbre lit la mémoire par $store.<nom>Reducer.<chemin> ($store.cartReducer.items) et se rend à nouveau quand elle change. Il agit sur elle par le verbe dispatch :
| Clé | Rôle |
|---|---|
store | Le nom de la mémoire, avec son suffixe : ordersReducer. |
thunk | Le nom du thunk ou de l'action synchrone. |
with | Un objet dont chaque valeur est résolue : l'argument envoyé. |
arg | Une valeur seule, à la place de with ("arg": "$it.id"). |
skipEmpty | true, ou une liste de clés : les clés de with vides (undefined, null, "") ne sont pas envoyées. 0 et false le sont. |
layer | "extras" (les thunks) ou "statics" (les actions synchrones). Sans layer, le moteur cherche un thunk, puis une action synchrone du même nom. |
Le résultat d'un thunk, nommé par as, a la forme { result: { status, body }, thunkDuration } : lisez $res.result.status et $res.result.body. Il vaut undefined si l'appel a échoué (réseau coupé) ou a été annulé ; la suite de l'action s'exécute quand même, avec une branche else pour ce cas.
Appeler l'API du projet
- La route existe dans l'API du projet : ici
POST /orders/submit, dans le groupeorders. stores.jsondéclare le thunk danscases, avec ses deux fichiersthunks/etactions/. Le thunk vise l'entréeendpoint1deapi.jsonet le chemin de la route.- L'arbre lance
{ "dispatch": { "store": "ordersReducer", "thunk": "submitOrder", "with": { ... } } }, puis lit la réponse parasou par$store.ordersReducer.
Le verbe fetch appelle une route sans thunk : path (résolu), endpoint (une entrée de api.json, endpoint1 par défaut), method, params (la query string), body et into. La réponse est écrite dans l'atome nommé par into, avec <into>.loading et <into>.error. GET lit ; toute autre méthode part en POST, avec body ou, à défaut, tout l'état local du bloc. Un fichier web/configurations/logic/<id>/bindings.json peut déclarer ces appels hors de l'arbre, { "calls": [{ "endpoint", "path", "method", "into", "trigger" }] } : sans trigger (ou avec "mount"), au montage ; sinon après l'action du même nom.
Un thunk absent de cases n'existe pas, même si ses fichiers sont en place : dispatch ne fait rien, sans erreur ni requête.
Deux garde-fous de Kubbler annulent une dispatch sans bruit, et son résultat vaut alors undefined : un appel identique (même thunk, mêmes arguments) encore en cours, et, pendant 10 secondes, un appel identique à un appel qui a reçu un statut 400 ou plus (401 excepté) ou une erreur réseau.
Exemple complet : un parcours de commande
La démo de pâtisserie vend des gâteaux à retirer en boutique. Son bloc orderFlow (web/configurations/components/orderFlow/) enchaîne le panier, le choix d'un créneau, les coordonnées et le paiement, sans JavaScript propre au bloc. La page commander le monte par un nœud component d'id orderFlow.
L'état et le calcul
La racine déclare les atomes du formulaire (date, pickupAt, pickupLabel, customerName, email, phone, note), un atome de message par champ validé, trois messages d'étape (cartError, pickupError, submitError) et deux atomes de copie (snapshot, snapshotTotal). Un seul calcul : minD, la date du jour plus deux jours, posée en min du champ de date pour imposer 48 heures de délai. La section La racine du bloc montre l'état complet et la validation de customerName et email.
Le panier vient de la mémoire
Le panier n'est pas dans l'état du bloc. Il vit dans la mémoire cart, gardée dans le navigateur (persist: true) et remplie par l'action synchrone addItem depuis l'accueil et la page des gâteaux. Le bloc la lit (repeat sur $store.cartReducer.items, total $store.cartReducer.total formaté en euros) et la modifie par ses actions synchrones incrementItem, decrementItem et removeItem, avec "layer": "statics" et l'id de la ligne en arg.
{
"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 }
}
]
}Le créneau
Le champ de date écrit date, puis appelle l'action pickDate avec le même événement. pickDate vide le créneau choisi et, si une date est posée, lance le thunk fetchSlots avec { "date": "$event.value" }. Le bloc affiche alors, selon la mémoire orders, un message de chargement (status vaut "loading"), un message si la liste est vide, ou un bouton par créneau. Un clic appelle pickSlot, qui copie $s.value et $s.label ; le bouton choisi reçoit aria-pressed et la classe is-picked (voir l'exemple de join dans Les valeurs).
{
"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"] }
}{
"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": "" }
]
}Payer
L'action pay est une liste de quatre étapes. Les trois premières posent les messages : submitError vidé, cartError selon que le panier est vide ou non (when, then, else), pickupError si aucun créneau n'est choisi. Aucune de ces étapes n'arrête les autres : tous les messages s'affichent ensemble.
{
"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}" }
},
...
]
}La quatrième étape porte tout l'envoi dans un seul objet. Son when exige un panier non vide et un créneau ; validate vérifie le nom, l'e-mail et le téléphone et arrête l'objet si l'un échoue ; dispatch envoie la commande au thunk submitOrder. note passe par { "if": ["$note", "$note"] } : vide, il vaut undefined et la clé n'est pas envoyée, le JSON n'écrivant pas une valeur undefined.
{
"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}" }
}
]
}Le résultat est nommé order. Si la réponse porte un providerSecret (cherché à deux endroits avec or, la réponse pouvant l'envelopper dans data), le bloc copie le panier et son total dans snapshot et snapshotTotal, transmet le secret à la mémoire payment (setProviderSecret), puis vide le panier (clearCart). La copie lit le panier du rendu où le clic est parti, donc avant qu'il soit vidé. Sinon, submitError reçoit {errSubmit} ; une erreur réseau donne un résultat undefined et passe par la même branche.
L'écran de paiement
Le passage au paiement n'est pas un atome du bloc : c'est $store.paymentReducer.providerSecret. Les étapes du formulaire portent "unless": "$store.paymentReducer.providerSecret", le nœud paying porte le when contraire et embarque le bloc paymentStep par un nœud fragment. Le récapitulatif lit le panier tant que le paiement n'a pas commencé, puis snapshot, et le total suit la même règle avec un if :
{
"id": "recapTotalValue",
"primitive": "Span",
"props": {
"text": {
"format": [
{
"if": [
"$store.paymentReducer.providerSecret",
"$snapshotTotal",
"$store.cartReducer.total"
]
},
"money",
"eur"
]
}
}
}Limites et pièges
Ce que le DSL ne fait pas, et les erreurs qui ne produisent aucun message.
Pas de paramètre de route dans le scope
Le scope ne contient ni les segments dynamiques de la route (devis/[id]), ni la query string. Un hook de quelques lignes, web/configurations/logic/<id>/index.js, les expose : la page lui passe args, qui porte locale, pathname et un champ par segment dynamique (voir Le comportement en JavaScript). Pour la query string, le hook la lit avec useSearchParams de Next.
Pas de division ni d'arrondi
Pour calculer, derive offre add, mul et clamp : ni soustraction, ni division, ni arrondi. Une soustraction s'écrit add(a, mul(b, -1)), une division mul(a, 0.01) (l'inverse du diviseur). Le seul arrondi est amountFromCents, qui arrondit à l'unité et renvoie un texte : multipliez d'abord par 100 pour arrondir un montant à l'euro. Un configurateur calcule ainsi un prix, un acompte et un solde :
{
"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 avec money attend des unités : un prix en centimes s'affiche cent fois trop grand, convertissez-le avec mul(prix, 0.01). amountFromCents renvoie "-" si son argument n'est pas un nombre JavaScript, un texte "21800" compris : passez-lui le résultat d'un mul.
La spécificité CSS des styleJson
Le style des styleJson est compilé dans la région /* forge:nodes:start */ ... /* forge:nodes:end */ du style.css du bloc, sous un sélecteur doublé qui porte l'id du bloc et celui du nœud : [data-forge-id="orderFlow--root"][data-forge-id="orderFlow--root"], de spécificité (0,2,0). La région est ajoutée en fin de fichier, après le CSS écrit à la main. Une règle à une classe (.orderFlow-pay) perd contre elle ; une règle à deux (.orderFlow-slot.is-picked, .orderFlow-pay:hover) perd aussi, à spécificité égale, parce qu'elle vient avant.
.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 */- Une propriété qui change avec un état (survol, classe
is-on,:disabled) ne doit pas être posée aussi dans lestyleJsondu nœud : gardez-la dans la classe, ou écrivez l'état dans les blocshover,focus-visibleouactivedustyleJson. Dans la démo, le bouton de paiement n'a pas destyleJson: son fond, son survol et son état désactivé vivent ensemble dans.orderFlow-pay. - Les blocs d'un
styleJson:base,hover,focus-visible,active,before,after,hover-before,hover-after, et les seuils de largeur minimalesm(576 px),md(768 px),lg(992 px),xl(1200 px),xxl(1400 px). - La région est réécrite par les outils qui enregistrent le bloc (Atelier, API) ; le CSS hors de la région n'est jamais touché. Un
styleJsonmodifié à la main ne s'affiche qu'une fois la région recompilée. - Tous les éléments d'un
repeatpartagent ledata-forge-iddu nœud : une règle les touche tous.
Autres pièges
whensur un nœudrepeatest évalué avant la liste : filtrez avecrepeat.where."as": "n","index","count","isFirst"ou"isLast": l'élément est écrasé par la variable d'itération.- Un
derivequi en lit un déclaré plus bas reçoitundefined, compté0. - Une saisie est un texte :
eqcompare sans conversion, passez parnumber. - Une liste vide est fausse pour
when, mais vraie dansnot,and,oretif: testez-la avecempty. - Un
setn'est pas visible des étapes suivantes de la même chaîne. - Une
validateseule dans une étape n'arrête pas l'étape suivante. navigatene préfixe pas la langue ;Linkle fait.fetchenvoie toute méthode autre queGETenPOST.toastaffiche toujours un message de niveau information.- Un thunk absent de
casesne fait rien ; un appel répété à l'identique dans les 10 secondes qui suivent un échec est annulé. - Un bloc embarqué ne reçoit ses propres dictionnaires que s'il figure aussi dans le
structure.jsonde la page.



