Malla文档

页面文档

PageDoc、节点、操作、Yjs,以及每次编辑如何从一个手势流向 Postgres。

开发者指南

每个页面、组件以及画布草稿区都是一个 PageDoc:一张扁平的节点映射表,节点之间通过 parent 和 children 相互链接。它以每个页面一个 jsonb 值的形式存储,作为 Yjs 文档进行编辑,并在所有地方由同一个渲染器渲染。

结构

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 决定兄弟节点的顺序;parent 提供 O(1) 查找。操作层负责保持两者一致。
  • id 在客户端生成,以支持乐观插入。复制或粘贴子树时会重新映射所有 id。
  • 链接带有类型({ type: 'page', pageId } 在页面重命名后依然有效);资源通过 id 引用,绝不通过 URL。
  • 标识类和结构类的属性(componentId、variant、overrides、query、layout……)不能被绑定。

为什么每个页面一个文档

加载只需读一行,保存只需写一次,发布、版本管理和复制都只是复制文档。Yjs 与它一一对应。移动操作在代码中校验,无需递归 SQL。典型页面有 200–2,000 个节点。由于扁平映射本身就是“行”的形状,将来如果改用节点表,只需一个脚本,而不必重新设计。

操作(ops)

面板、画布手势、AI 和 MCP 都从不直接接触 Yjs 文档。它们构建带类型的 ops,由核心在一个事务中校验并应用。

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

校验会拒绝:移动时产生循环、删除根节点、给叶子类型添加子节点,以及不符合结构定义的属性或样式。

Yjs 映射

  • 每个节点是一个 Y.Map;children 是一个 Y.Array,因此并发插入可以合并。
  • props 和 styles 都是映射,所以两个人编辑同一节点的不同属性时绝不会冲突。
  • 撤销管理器跟踪 user、ai 和 mcp 这几种来源;每个用户只撤销自己的改动。
  • 站点级状态(页面列表、类、令牌、组件、变量)存放在单独的站点文档中。