MallaDocs

El documento de página

PageDoc, nodos, ops, Yjs y cómo cada edición viaja desde un gesto hasta Postgres.

Guía para desarrolladores

Cada página, cada componente y el área de borrador del lienzo es un PageDoc: un mapa plano de nodos con enlaces a su padre y a sus hijos. Se guarda como un único valor jsonb por página, se edita como un documento Yjs y lo renderiza el mismo renderizador en todas partes.

Forma

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 ordena a los hermanos; parent da búsquedas en O(1). La capa de ops mantiene ambos coherentes.
  • Los ids se crean en el cliente para las inserciones optimistas. Al duplicar o pegar un subárbol se reasignan todos los ids.
  • Los enlaces son tipados ({ type: 'page', pageId } sobrevive a los cambios de nombre); los recursos se referencian por id, nunca por URL.
  • Las props de identidad y de estructura (componentId, variant, overrides, query, layout…) no se pueden enlazar.

Por qué un documento por página

Cargar es una fila, guardar es una escritura, y publicar, versionar y duplicar son copias del documento. Yjs se corresponde con él uno a uno. Los movimientos se validan en código, sin SQL recursivo. Una página típica tiene entre 200 y 2.000 nodos. Como el mapa plano ya tiene forma de filas, pasar más adelante a una tabla de nodos sería un script, no un rediseño.

Ops

Los paneles, los gestos del lienzo, la IA y MCP nunca tocan directamente el documento Yjs. Construyen ops tipadas, y el núcleo las valida y las aplica en una sola transacción.

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 validación rechaza ciclos al mover, borrar la raíz, hijos en tipos hoja, y props o estilos que no cumplen los esquemas.

Mapeo a Yjs

  • Cada nodo es un Y.Map; children es un Y.Array, así que las inserciones simultáneas se combinan.
  • props y styles son mapas, así que dos personas que editan propiedades distintas de un mismo nodo nunca entran en conflicto.
  • El gestor de deshacer registra los orígenes user, ai y mcp; cada usuario deshace solo sus propios cambios.
  • El estado a nivel de sitio (lista de páginas, clases, tokens, componentes, variables) vive en un documento de sitio aparte.