@abinnovision/payloadcms-mcpx 1.0.0-beta.14 → 1.0.0-beta.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -222,6 +222,19 @@ Rules the tools enforce and explain in their own descriptions:
222
222
  answers `{ "heading": { "tag": ["h4"] } }`, and a write carrying any other
223
223
  heading tag is refused. Lexical stores whatever tag it is given, so this is
224
224
  the only place the restriction is checked.
225
+ - A Lexical node must be written the way Lexical serializes it, and carry the
226
+ values Lexical would have written. The admin editor rehydrates nodes through
227
+ their classes, so a list item whose `indent` is absent, `null` or `"0"` throws
228
+ when the document is opened, and a heading whose `tag` is `3` comes back
229
+ untagged, none of which Payload notices on write. A write breaking either is
230
+ refused, and the message names the property and what belongs there.
231
+ - Where Payload states the shape itself, that statement is what is enforced: its
232
+ `outputSchema` declares `version` required on every node, and gives the root
233
+ exactly `children`, `direction`, `format`, `indent`, `type` and `version`, so
234
+ an unknown property on the root is refused too. Payload declares nothing per
235
+ node type, so the rest is measured against the node classes
236
+ `@payloadcms/richtext-lexical` ships. Both halves are pinned by
237
+ `src/schema/lexical.spec.ts` rather than assumed.
225
238
  - Field and collection `admin.description` values are included in
226
239
  `describeSchema` and `listCapabilities`, so intent written for the admin
227
240
  panel reaches the client. A locale-keyed record is resolved to one string for
@@ -1,6 +1,6 @@
1
- import { allowedNodeTypes, lexicalSubSchema, nodeOptions, subSchemaNodeTypes } from "./lexical.mjs";
1
+ import { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, rootProblems, subSchemaNodeTypes } from "./lexical.mjs";
2
2
  import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, pointerFromPayloadPath, splitPath, targetOf } from "./walk.mjs";
3
3
  import { nodeDescriber, reachableSchemaPaths } from "./describe.mjs";
4
4
  import { resolveDataPointer } from "./pointer.mjs";
5
5
  import { validateWriteValue } from "./shape.mjs";
6
- export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, allowedNodeTypes, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, lexicalSubSchema, nodeDescriber, nodeOptions, pointerFromPayloadPath, reachableSchemaPaths, resolveDataPointer, splitPath, subSchemaNodeTypes, targetOf, validateWriteValue };
6
+ export { JSON_POINTER_PATTERN, REQUIRED_NODE_PROPERTIES, RESERVED_FIELD_NAMES, ROOT_PROPERTIES, allowedNodeTypes, blockOf, blockSlugsOf, constrainsFields, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, lexicalSubSchema, nodeDescriber, nodeOptions, nodeProblems, pointerFromPayloadPath, reachableSchemaPaths, resolveDataPointer, rootProblems, splitPath, subSchemaNodeTypes, targetOf, validateWriteValue };
@@ -53,6 +53,158 @@ const lexicalSubSchema = (field, nodeType) => {
53
53
  };
54
54
  /** In the order their features registered them. */ const subSchemaNodeTypes = (field) => [...featuresOf(field)?.getSubFields?.keys() ?? []].filter((nodeType) => lexicalSubSchema(field, nodeType) !== void 0);
55
55
  /**
56
+ * `direction` carries Payload's own declaration for it, `oneOf` the two
57
+ * directions or null, rather than a looser "string or null".
58
+ */ const KINDS = {
59
+ array: {
60
+ accepts: (value) => Array.isArray(value),
61
+ needs: "an array"
62
+ },
63
+ direction: {
64
+ accepts: (value) => value === null || value === "ltr" || value === "rtl",
65
+ needs: "\"ltr\", \"rtl\" or null"
66
+ },
67
+ number: {
68
+ accepts: (value) => typeof value === "number",
69
+ needs: "a number"
70
+ },
71
+ object: {
72
+ accepts: (value) => typeof value === "object" && value !== null && !Array.isArray(value),
73
+ needs: "an object"
74
+ },
75
+ optionalObject: {
76
+ accepts: (value) => value === null || typeof value === "object" && !Array.isArray(value),
77
+ needs: "an object or null"
78
+ },
79
+ string: {
80
+ accepts: (value) => typeof value === "string",
81
+ needs: "a string"
82
+ }
83
+ };
84
+ const accepts = (constraint, value) => typeof constraint === "string" ? KINDS[constraint].accepts(value) : value === constraint.is;
85
+ const needs = (constraint) => typeof constraint === "string" ? KINDS[constraint].needs : JSON.stringify(constraint.is);
86
+ const ELEMENT_PROPERTIES = {
87
+ children: "array",
88
+ direction: "direction",
89
+ indent: "number"
90
+ };
91
+ /** A text node and everything built on one. */ const TEXT_PROPERTIES = {
92
+ detail: "number",
93
+ format: "number",
94
+ mode: "string",
95
+ style: "string",
96
+ text: "string"
97
+ };
98
+ /**
99
+ * Carried by every node, whatever its type.
100
+ *
101
+ * Aligned with Payload rather than measured: its `outputSchema` declares `type`
102
+ * and `version` required for every node in the tree, and that declaration is
103
+ * what types the field in `payload-types.ts`. Lexical itself hydrates a node
104
+ * without a `version`, or with the wrong kind of one, unchanged - but a
105
+ * consumer reading the document through the generated types has been promised
106
+ * an integer, and `BlockNode.importJSON` migrates on it.
107
+ */ const UNIVERSAL_PROPERTIES = { version: "number" };
108
+ /**
109
+ * The root, as Payload declares it and as an editor exports it: these six
110
+ * properties, these kinds, and nothing else.
111
+ */ const ROOT_PROPERTIES = {
112
+ children: "array",
113
+ direction: "direction",
114
+ format: "string",
115
+ indent: "number",
116
+ type: "string",
117
+ version: "number"
118
+ };
119
+ /**
120
+ * What a serialized node must carry beyond {@link UNIVERSAL_PROPERTIES}, keyed
121
+ * by node type.
122
+ *
123
+ * Payload stores an editor state without hydrating it, so a node written
124
+ * without these, or with the wrong kind of value, is accepted and only fails
125
+ * later, in the admin editor. Payload declares nothing per node type, so this
126
+ * table is measured instead: an entry belongs here only if breaking it makes
127
+ * Lexical throw, or changes what the editor reads back. An element's `format`
128
+ * and a paragraph's text defaults are absent for that reason.
129
+ * `lexical.spec.ts` holds every entry to the rule against the node classes
130
+ * `@payloadcms/richtext-lexical` ships, so extend that test first.
131
+ *
132
+ * A node type with no entry is checked for the universal properties only.
133
+ * Guessing at the requirements of a project's own nodes would reject content
134
+ * that works.
135
+ */ const REQUIRED_NODE_PROPERTIES = {
136
+ autolink: {
137
+ ...ELEMENT_PROPERTIES,
138
+ fields: "object"
139
+ },
140
+ block: { fields: "object" },
141
+ heading: {
142
+ ...ELEMENT_PROPERTIES,
143
+ tag: "string"
144
+ },
145
+ inlineBlock: { fields: "object" },
146
+ link: {
147
+ ...ELEMENT_PROPERTIES,
148
+ fields: "object"
149
+ },
150
+ list: {
151
+ ...ELEMENT_PROPERTIES,
152
+ listType: "string",
153
+ start: "number"
154
+ },
155
+ listitem: {
156
+ ...ELEMENT_PROPERTIES,
157
+ value: "number"
158
+ },
159
+ paragraph: ELEMENT_PROPERTIES,
160
+ quote: ELEMENT_PROPERTIES,
161
+ relationship: {
162
+ relationTo: "string",
163
+ value: "number"
164
+ },
165
+ tab: {
166
+ ...TEXT_PROPERTIES,
167
+ detail: { is: 2 },
168
+ text: { is: " " }
169
+ },
170
+ text: TEXT_PROPERTIES,
171
+ upload: {
172
+ fields: "optionalObject",
173
+ relationTo: "string",
174
+ value: "number"
175
+ }
176
+ };
177
+ /**
178
+ * Whether the table already says what a node type's `fields` has to be, so the
179
+ * sub-field walk does not report the same problem a second time.
180
+ */ const constrainsFields = (type) => "fields" in (REQUIRED_NODE_PROPERTIES[type] ?? {});
181
+ /** Present but `null` counts as present: `direction` is serialized that way. */ const check = (node, constraints) => {
182
+ const problems = {
183
+ missing: [],
184
+ rejected: []
185
+ };
186
+ for (const [property, constraint] of Object.entries(constraints)) if (!(property in node)) problems.missing.push(property);
187
+ else if (!accepts(constraint, node[property])) problems.rejected.push({
188
+ needs: needs(constraint),
189
+ property
190
+ });
191
+ problems.missing.sort();
192
+ problems.rejected.sort((left, right) => left.property.localeCompare(right.property));
193
+ return problems;
194
+ };
195
+ const nodeProblems = (node) => check(node, {
196
+ ...UNIVERSAL_PROPERTIES,
197
+ ...REQUIRED_NODE_PROPERTIES[node["type"]] ?? {}
198
+ });
199
+ /**
200
+ * The root is the one node Payload describes itself, down to refusing an
201
+ * unknown property, so it is checked against that description rather than
202
+ * against the walk's table.
203
+ */ const rootProblems = (root) => ({
204
+ ...check(root, ROOT_PROPERTIES),
205
+ unexpected: Object.keys(root).filter((property) => !(property in ROOT_PROPERTIES))
206
+ });
207
+ /**
56
208
  * Only properties a feature narrows and Lexical does not check on its own
57
209
  * belong here. Everything else a feature restricts is already visible: a
58
210
  * link's targets through its sub-schema, a block node's choices through the
@@ -95,4 +247,4 @@ const stringList = (value) => Array.isArray(value) && value.every((entry) => typ
95
247
  return entries.length > 0 ? Object.fromEntries(entries) : void 0;
96
248
  };
97
249
  //#endregion
98
- export { allowedNodeTypes, lexicalSubSchema, nodeOptions, subSchemaNodeTypes };
250
+ export { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, rootProblems, subSchemaNodeTypes };
@@ -1,4 +1,4 @@
1
- import { lexicalSubSchema } from "./lexical.mjs";
1
+ import { ROOT_PROPERTIES, constrainsFields, lexicalSubSchema, nodeProblems, rootProblems } from "./lexical.mjs";
2
2
  import { blockOf, blockSlugsOf, describeAddressableFields, findBlocksField, findRichTextField, splitPath } from "./walk.mjs";
3
3
  //#region src/schema/shape.ts
4
4
  const TOLERATED_VALUE_KEYS = /* @__PURE__ */ new Set([
@@ -7,6 +7,7 @@ const TOLERATED_VALUE_KEYS = /* @__PURE__ */ new Set([
7
7
  "id"
8
8
  ]);
9
9
  const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
10
+ const quoted = (properties) => properties.map((property) => `"${property}"`).join(", ");
10
11
  /**
11
12
  * A node with nothing to declare, and one whose sub-fields cannot be named at a
12
13
  * position, are both left alone.
@@ -15,7 +16,7 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
15
16
  if (!sub) return;
16
17
  const data = node.fields;
17
18
  if (!isPlainObject(data)) {
18
- scope.problems.push(`${scope.pointer}: a "${node.type}" node carries a "fields" object.`);
19
+ if (!constrainsFields(node.type)) scope.problems.push(`${scope.pointer}: a "${node.type}" node carries a "fields" object.`);
19
20
  return;
20
21
  }
21
22
  const nested = {
@@ -48,12 +49,19 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
48
49
  * later, at render or when the document is reopened in the admin editor. A key
49
50
  * a node's fields do not declare is dropped just as silently. The same holds
50
51
  * one level down, for the node properties a feature narrows: an `h3` in an
51
- * editor restricted to `h4` is stored as readily as an `h4`.
52
+ * editor restricted to `h4` is stored as readily as an `h4`, and a node written
53
+ * without the properties its class hydrates from is stored and then throws when
54
+ * the editor opens it.
52
55
  */ const checkRichText = (scope, editor, value) => {
53
56
  if (!isPlainObject(value) || !isPlainObject(value["root"])) {
54
57
  scope.problems.push(`${scope.pointer}: expected a Lexical editor state with a "root".`);
55
58
  return;
56
59
  }
60
+ const root = value["root"];
61
+ const { missing, rejected, unexpected } = rootProblems(root);
62
+ if (missing.length > 0) scope.problems.push(`${scope.pointer}/root: the root node is missing ${quoted(missing)}. Write nodes as Lexical serializes them.`);
63
+ for (const problem of rejected) scope.problems.push(`${scope.pointer}/root/${problem.property}: the root node needs ${problem.needs} here.`);
64
+ for (const property of unexpected) scope.problems.push(`${scope.pointer}/root/${property}: no such property on the root node. Available: ${Object.keys(ROOT_PROPERTIES).join(", ")}`);
57
65
  const walk = (nodes, pointer) => {
58
66
  if (!Array.isArray(nodes)) return;
59
67
  nodes.forEach((node, index) => {
@@ -66,9 +74,12 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
66
74
  scope.problems.push(`${at}: "${node["type"]}" is not available in this field's editor. Allowed: ${editor.allowed.join(", ")}`);
67
75
  return;
68
76
  }
77
+ const problems = nodeProblems(node);
78
+ if (problems.missing.length > 0) scope.problems.push(`${at}: a "${node["type"]}" node is missing ${quoted(problems.missing)}. Write nodes as Lexical serializes them.`);
79
+ for (const problem of problems.rejected) scope.problems.push(`${at}/${problem.property}: a "${node["type"]}" node needs ${problem.needs} here.`);
69
80
  for (const [property, values] of Object.entries(editor.nodeOptions?.[node["type"]] ?? {})) {
70
81
  const value = node[property];
71
- if (typeof value === "string" && !values.includes(value)) scope.problems.push(`${at}/${property}: "${value}" is not available for a "${node["type"]}" node in this field's editor. Allowed: ${values.join(", ")}`);
82
+ if (value !== void 0 && !(typeof value === "string" && values.includes(value))) scope.problems.push(`${at}/${property}: ${JSON.stringify(value)} is not available for a "${node["type"]}" node in this field's editor. Allowed: ${values.join(", ")}`);
72
83
  }
73
84
  if (editor.field) checkNodeFields({
74
85
  ...scope,
@@ -80,7 +91,7 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
80
91
  walk(node["children"], `${at}/children`);
81
92
  });
82
93
  };
83
- walk(value["root"]["children"], `${scope.pointer}/root/children`);
94
+ walk(root["children"], `${scope.pointer}/root/children`);
84
95
  };
85
96
  const checkLeafValue = (scope, descriptor, value) => {
86
97
  if (descriptor.readOnly) {
@@ -22,6 +22,8 @@ Call it with no "paths" to get a collection's own fields. Every "blocks" field s
22
22
 
23
23
  A "richText" field stops there too. It lists the Lexical node types it accepts in "nodes", and "next" carries a path for every node type that holds fields of its own: "/content/link" for a link node, "/content/block/callout" and "/content/inlineBlock/badge" for the block nodes. Descend to get the real field list instead of guessing what a node carries. Upload nodes are not addressable, because their fields depend on the collection the node points at.
24
24
 
25
+ Write each Lexical node the way Lexical serializes it, with every property its type carries rather than a trimmed subset, and with the value Lexical would have written there. Every node needs a "version"; the root takes exactly "children", "direction", "format", "indent", "type" and "version" and refuses anything else; each node type adds its own on top. The admin editor rehydrates nodes through their classes, so a list item whose "indent" is missing, null or a string is stored and then throws on open, and a heading whose "tag" is a number is stored untagged. A write naming a property means exactly that.
26
+
25
27
  Paths here use the same JSON Pointer syntax as getDocument and patchDocument, and are already resolved through anything that does not nest in the stored document. The difference is only what stands in an element position: a path names an array element "*" and a block by its slug, where a pointer into a document carries a 0-based index. So "/items/*/title" is written at "/items/0/title", and "/layout/sections/hero" at "/layout/sections/0".
26
28
 
27
29
  Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed and cannot be written. Fields marked readOnly are listed but refused on write.`,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@abinnovision/payloadcms-mcpx",
4
- "version": "1.0.0-beta.14",
4
+ "version": "1.0.0-beta.15",
5
5
  "description": "Payload CMS plugin exposing a fixed, schema-aware MCP tool surface with draft-only writes and per-API-key capabilities.",
6
6
  "keywords": [
7
7
  "payload",