Chaque page, chaque composant et la zone de brouillon du canevas sont un PageDoc : une table plate de nœuds reliés par des liens parent et enfants. Il est stocké sous forme d'une seule valeur jsonb par page, modifié comme un document Yjs, et rendu partout par le même moteur de rendu.
Forme
type NodeId = string
interface Node {
type: 'body' | 'section' | 'frame' | 'text' | 'link' | 'image' | 'button'
| 'richText' | 'collectionList' | 'form' | 'input' | 'textarea' | 'select'
| 'checkbox' | 'instance' | …
parent: NodeId | null
children: NodeId[]
props: Record<string, unknown> // literals, { $expr }, or { $model }
styles: Styles // { base: {…}, 'base:hover': {…}, tablet: {…} }
}
interface PageDoc {
schema: 1
root: NodeId
nodes: Record<NodeId, Node>
variables?: Record<string, VariableDef>
queries?: Record<string, QueryDef>
workflows?: Record<string, WorkflowDef> // client workflows only
interactions?: Record<string, Interaction>
}
{
"schema": 1,
"root": "n_root",
"nodes": {
"n_root": { "type": "body", "parent": null, "children": ["n_hero"], "props": {}, "styles": {} },
"n_hero": {
"type": "section", "parent": "n_root", "children": ["n_h1"],
"props": { "classes": ["cls_hero"] },
"styles": { "base": { "paddingBlock": "120px" }, "mobile": { "paddingBlock": "64px" } }
},
"n_h1": {
"type": "text", "parent": "n_hero", "children": [],
"props": { "tag": "h1", "text": { "$expr": "item.title" } }, "styles": {}
}
}
}
childrenordonne les nœuds frères ;parentpermet des recherches en O(1). La couche des ops maintient la cohérence des deux.- Les ids sont créés côté client pour les insertions optimistes. Dupliquer ou coller une sous-arborescence réattribue tous les ids.
- Les liens sont typés (
{ type: 'page', pageId }survit aux renommages) ; les ressources sont référencées par id, jamais par URL. - Les props d'identité et de structure (
componentId,variant,overrides,query,layout…) ne peuvent pas être liées.
Pourquoi un document par page
Charger, c'est lire une ligne ; enregistrer, c'est une seule écriture ; publier, versionner et dupliquer reviennent à copier un document. Yjs s'y projette un pour un. Les déplacements sont validés dans le code, sans SQL récursif. Une page typique compte de 200 à 2 000 nœuds. Comme la table plate a déjà la forme de lignes, passer plus tard à une table de nœuds serait l'affaire d'un script, pas d'une refonte.
Ops
Les panneaux, les gestes sur le canevas, l'IA et MCP ne touchent jamais directement au document Yjs. Ils construisent des ops typées, que le cœur valide et applique en une seule transaction.
type Op =
| { t: 'setProp'; id: NodeId; path: string[]; value: unknown }
| { t: 'setStyle'; id: NodeId; key: StyleKey; prop: string; value: string | null }
| { t: 'setStyleBinding'; id: NodeId; key: StyleKey; prop: string; value: { $expr: string } | null }
| { t: 'setMeta'; id: NodeId; name?: string; hidden?: boolean; locked?: boolean }
| { t: 'insert'; id: NodeId; node: Omit<Node, 'parent' | 'children'>; parent: NodeId; index: number }
| { t: 'insertSubtree'; root: NodeId; nodes: Record<NodeId, Node>; parent: NodeId; index: number }
| { t: 'move'; id: NodeId; parent: NodeId; index: number }
| { t: 'delete'; id: NodeId }
| { t: 'setInteraction'; id: string; interaction: Interaction | null }
| { t: 'setVariable'; id: string; variable: VariableDef | null }
| { t: 'setQuery'; id: string; query: QueryDef | null }
| { t: 'setWorkflow'; id: string; workflow: WorkflowDef | null }
editor.document.transact(ops, { mergeKey }) // one undo step; mergeKey joins a drag
La validation refuse les cycles lors d'un déplacement, la suppression de la racine, les enfants sur les types feuilles, ainsi que les props ou les styles qui ne respectent pas les schémas.
Correspondance Yjs
- Chaque nœud est une
Y.Map;childrenest unY.Array, pour que les insertions simultanées fusionnent. propsetstylessont des maps : deux personnes qui modifient des propriétés différentes d'un même nœud n'entrent donc jamais en conflit.- Le gestionnaire d'annulation suit les origines
user,aietmcp; chaque utilisateur n'annule que ses propres modifications. - L'état du site (liste des pages, classes, jetons, composants, variables) vit dans un document de site séparé.