MallaDocs

Le document de page

PageDoc, nœuds, ops, Yjs et le trajet de chaque modification, du geste jusqu’à Postgres.

Guide développeur

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

ts
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>
}
json
{
  "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": {}
    }
  }
}
  • children ordonne les nœuds frères ; parent permet 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.

ts
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 ; children est un Y.Array, pour que les insertions simultanées fusionnent.
  • props et styles sont 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, ai et mcp ; 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é.