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

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
@@ -228,6 +228,48 @@ Rules the tools enforce and explain in their own descriptions:
228
228
  when the document is opened, and a heading whose `tag` is `3` comes back
229
229
  untagged, none of which Payload notices on write. A write breaking either is
230
230
  refused, and the message names the property and what belongs there.
231
+ - Those requirements are also published, so a node can be built without being
232
+ corrected into shape first. A `describeSchema` response that reached a
233
+ `richText` field ends with a `nodeProperties` entry stating what each node
234
+ type has to carry, in the same words the refusal uses:
235
+
236
+ ```json
237
+ {
238
+ "text": {
239
+ "detail": "a number",
240
+ "format": "a number",
241
+ "mode": "a string",
242
+ "style": "a string",
243
+ "text": "a string",
244
+ "type": "a string",
245
+ "version": "a number"
246
+ }
247
+ }
248
+ ```
249
+
250
+ It is keyed by node type and stated once for the whole response, because what
251
+ a node carries does not vary by where it is written; the field's own `nodes`
252
+ says which of those types it accepts. The listing is read off the same tables
253
+ the validator checks against, so the two cannot drift apart.
254
+
255
+ - A rich text field's value is addressable, so a small edit does not have to
256
+ rewrite the whole state. `/content/root/children/2` is a node,
257
+ `/content/root/children/2/tag` one of its properties, and
258
+ `/content/root/children/2/fields/url` a field the node carries, resolved
259
+ through the same node schema `describeSchema` publishes. A node written at a
260
+ position is held to exactly what a node inside a whole state is held to. The
261
+ root and a node's `type` cannot be replaced on their own, and a node property
262
+ cannot be removed, because a node needs it.
263
+ - Node positions shift the moment anything is added or removed, and a text or
264
+ paragraph node carries no id to fall back on. `getDocument` with `outline`
265
+ answers with one line per node, its pointer, its `version` and an excerpt, so
266
+ a position can be chosen and a complete node written without holding the
267
+ whole state. `expectedUpdatedAt` still guards the document, and a `test`
268
+ operation on a node's `type` guards the position.
269
+ - A state whose root holds nothing is refused, however it is written. Lexical
270
+ reads such a state as empty and throws rather than rendering it, so neither a
271
+ whole-field write of one, nor emptying the node list, nor removing the last
272
+ node is allowed. An empty field is stored as null instead.
231
273
  - Where Payload states the shape itself, that statement is what is enforced: its
232
274
  `outputSchema` declares `version` required on every node, and gives the root
233
275
  exactly `children`, `direction`, `format`, `indent`, `type` and `version`, so
@@ -248,6 +290,14 @@ Rules the tools enforce and explain in their own descriptions:
248
290
  its slug, where a pointer carries a 0-based index. So `/items/*/title` is
249
291
  written at `/items/0/title`, and `/layout/sections/hero` at
250
292
  `/layout/sections/0`.
293
+ - Inside a rich text field that substitution does not apply, because an editor
294
+ state is a tree rather than a list per type. A path there names the node type,
295
+ and a block node its slug; a pointer enters the state at `root` and walks
296
+ `children` by an index counted over every child at that level, not over the
297
+ blocks among them, with the node's own fields under `fields`. So the path
298
+ `/content/block/practice-note/variant` is written at the pointer
299
+ `/content/root/children/7/fields/variant`, and only the stored state says
300
+ which index that is. `getDocument` with `outline` answers that.
251
301
  - Adding a block requires `blockType` on the value; append with `/-`.
252
302
  - Clearing is `replace` with `null`; a list is emptied with `[]` and refuses
253
303
  `null`. `remove` is only valid on list elements, because Payload keeps
@@ -517,9 +567,8 @@ How the draft and publish guarantees are enforced, and where they stop, is in
517
567
  ## Non-goals of v1 / roadmap
518
568
 
519
569
  Unpublishing, `versions.drafts.localizeStatus`, deletes, creating upload
520
- documents and any file handling, markdown authoring for rich text, addressing a
521
- rich text node by position in a patch (an editor state is written whole),
522
- schemas for `upload` node fields, row addressing by id instead of index,
570
+ documents and any file handling, markdown authoring for rich text, schemas for
571
+ `upload` node fields, row addressing by id instead of index,
523
572
  cross-locale publish blockers, pagination of `describeSchema` with `expand`,
524
573
  and a handler-level timeout are all deliberate omissions for now.
525
574
 
@@ -1,6 +1,8 @@
1
- import { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, rootProblems, subSchemaNodeTypes } from "./lexical.mjs";
2
- import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, pointerFromPayloadPath, splitPath, targetOf } from "./walk.mjs";
1
+ import { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, nodePropertiesFor, propertyProblem, rootProblems, subSchemaNodeTypes } from "./lexical.mjs";
2
+ import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, isIndexSegment, isPlainObject, joinPath, pointerFromPayloadPath, splitPath, targetOf } from "./walk.mjs";
3
3
  import { nodeDescriber, reachableSchemaPaths } from "./describe.mjs";
4
+ import { resolveLexicalPointer } from "./lexical-pointer.mjs";
5
+ import { lexicalOutline } from "./outline.mjs";
4
6
  import { resolveDataPointer } from "./pointer.mjs";
5
- import { validateWriteValue } from "./shape.mjs";
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 };
7
+ import { EMPTY_ROOT, validateWriteValue } from "./shape.mjs";
8
+ export { EMPTY_ROOT, JSON_POINTER_PATTERN, REQUIRED_NODE_PROPERTIES, RESERVED_FIELD_NAMES, ROOT_PROPERTIES, allowedNodeTypes, blockOf, blockSlugsOf, constrainsFields, describeAddressableFields, describeFields, findBlocksField, findRichTextField, isIndexSegment, isPlainObject, joinPath, lexicalOutline, lexicalSubSchema, nodeDescriber, nodeOptions, nodeProblems, nodePropertiesFor, pointerFromPayloadPath, propertyProblem, reachableSchemaPaths, resolveDataPointer, resolveLexicalPointer, rootProblems, splitPath, subSchemaNodeTypes, targetOf, validateWriteValue };
@@ -0,0 +1,125 @@
1
+ import { lexicalSubSchema, subSchemaNodeTypes } from "./lexical.mjs";
2
+ import { blockOf, blockSlugsOf, isIndexSegment, isPlainObject, joinPath } from "./walk.mjs";
3
+ //#region src/schema/lexical-pointer.ts
4
+ /**
5
+ * A node's `fields` is ordinary Payload field-land, reached either through the
6
+ * schema a feature declares for the node or, where the node picks a block by
7
+ * slug, through that block.
8
+ */ const stepIntoFields = (at) => {
9
+ const { addedValue, config, field, node, nodeType, rest } = at;
10
+ const sub = lexicalSubSchema(field, nodeType);
11
+ if (!sub) throw new Error(`"${nodeType}" nodes carry no addressable fields in this field's editor. Node types with fields here: ${subSchemaNodeTypes(field).join(", ")}`);
12
+ const data = node?.["fields"];
13
+ if (sub.kind === "fields") return {
14
+ data,
15
+ fields: sub.fields,
16
+ kind: "fields",
17
+ rest
18
+ };
19
+ const added = addedValue?.fields;
20
+ const slug = (isPlainObject(data) ? data["blockType"] : void 0) ?? (isPlainObject(added) ? added["blockType"] : void 0);
21
+ if (typeof slug !== "string") throw new Error(`Cannot tell which block a "${nodeType}" node holds. Supply a "blockType" on the value, one of: ${blockSlugsOf(sub.blocksField).join(", ")}`);
22
+ const block = blockOf(config, sub.blocksField, slug);
23
+ if (!block) throw new Error(`"${slug}" is not allowed in a "${nodeType}" node here. Allowed: ${blockSlugsOf(sub.blocksField).join(", ")}`);
24
+ return {
25
+ blockType: slug,
26
+ data,
27
+ fields: block.flattenedFields,
28
+ kind: "fields",
29
+ rest
30
+ };
31
+ };
32
+ /**
33
+ * Walks the segments left over once a pointer has reached a rich text field.
34
+ *
35
+ * The stored state chooses the branch at every index, exactly as the stored
36
+ * document chooses it at a blocks element: an editor state admits many node
37
+ * shapes at the same position, and only what is there says which one it is.
38
+ * A position the document does not have yet takes its type from the value
39
+ * being added, and is addressable no further.
40
+ */ const resolveLexicalPointer = (at) => {
41
+ const { addedValue, config, descriptor, field, state } = at;
42
+ const base = {
43
+ descriptor,
44
+ field
45
+ };
46
+ if (!isPlainObject(state) || !isPlainObject(state["root"])) throw new Error(`"${descriptor.path}" holds no editor state yet. Write the whole field once, then address positions inside it.`);
47
+ const [entry, ...rest] = at.segments;
48
+ if (entry !== "root") throw new Error(`"${String(entry)}" is not a position in a rich text field. An editor state is entered at "root", e.g. "${descriptor.path}/root/children/0". getDocument with "outline" lists every position this field holds.`);
49
+ let node = state["root"];
50
+ let nodeType = "root";
51
+ let segments = rest;
52
+ let walked = ["root"];
53
+ for (;;) {
54
+ if (segments.length === 0) return {
55
+ kind: "position",
56
+ position: {
57
+ ...base,
58
+ ...nodeType === "root" ? { isRoot: true } : {},
59
+ kind: "node",
60
+ nodeType
61
+ }
62
+ };
63
+ const [segment, ...remaining] = segments;
64
+ if (segment === "children") {
65
+ if (remaining.length === 0) return {
66
+ kind: "position",
67
+ position: {
68
+ ...base,
69
+ ...nodeType === "root" ? { isRoot: true } : {},
70
+ kind: "nodes",
71
+ nodeType
72
+ }
73
+ };
74
+ const [index, ...beyond] = remaining;
75
+ if (!isIndexSegment(index)) throw new Error(`"${descriptor.path}${joinPath([...walked, "children"])}" is a list; "${index}" is not an index. getDocument with "outline" reports the pointer of each node in it.`);
76
+ const children = node?.["children"];
77
+ const child = Array.isArray(children) && index !== "-" ? children[Number(index)] : void 0;
78
+ const type = isPlainObject(child) ? child["type"] : addedValue?.type;
79
+ if (typeof type !== "string") {
80
+ if (beyond.length === 0) return {
81
+ kind: "position",
82
+ position: {
83
+ ...base,
84
+ kind: "node"
85
+ }
86
+ };
87
+ throw new Error(`Cannot tell which node "${descriptor.path}${joinPath([
88
+ ...walked,
89
+ "children",
90
+ index
91
+ ])}" is. Call getDocument with "outline" for the pointer of each node, or address an existing position.`);
92
+ }
93
+ node = isPlainObject(child) ? child : void 0;
94
+ nodeType = type;
95
+ segments = beyond;
96
+ walked = [
97
+ ...walked,
98
+ "children",
99
+ index
100
+ ];
101
+ continue;
102
+ }
103
+ if (segment === "fields") return stepIntoFields({
104
+ addedValue,
105
+ config,
106
+ field,
107
+ node,
108
+ nodeType,
109
+ rest: remaining
110
+ });
111
+ if (remaining.length > 0) throw new Error(`"${segment}" is a property of a "${nodeType}" node and nothing beneath it can be addressed.`);
112
+ return {
113
+ kind: "position",
114
+ position: {
115
+ ...base,
116
+ ...nodeType === "root" ? { isRoot: true } : {},
117
+ kind: "property",
118
+ nodeType,
119
+ property: segment
120
+ }
121
+ };
122
+ }
123
+ };
124
+ //#endregion
125
+ export { resolveLexicalPointer };
@@ -178,6 +178,27 @@ const ELEMENT_PROPERTIES = {
178
178
  * Whether the table already says what a node type's `fields` has to be, so the
179
179
  * sub-field walk does not report the same problem a second time.
180
180
  */ const constrainsFields = (type) => "fields" in (REQUIRED_NODE_PROPERTIES[type] ?? {});
181
+ const describeConstraints = (constraints) => Object.fromEntries(Object.entries(constraints).map(([property, constraint]) => [property, needs(constraint)]).sort((left, right) => left[0].localeCompare(right[0])));
182
+ /**
183
+ * What each of the given node types has to carry, phrased the way the write
184
+ * side phrases it when it refuses one, so the listing and the error message
185
+ * never disagree. Read straight off the tables above, which is what keeps it
186
+ * true: nothing here is stated a second time.
187
+ *
188
+ * Keyed by node type rather than reported per field, because that is what it
189
+ * depends on. A field says which types it allows, in its `nodes`; what a `text`
190
+ * node has to carry is the same wherever one is written, so a response that
191
+ * describes twenty rich text fields still states it once.
192
+ *
193
+ * `type` is listed although {@link UNIVERSAL_PROPERTIES} omits it. The
194
+ * validator never reports it missing, because a node's `type` is how it finds
195
+ * the entry to check against, but a client assembling a node from this listing
196
+ * still has to write one.
197
+ */ const nodePropertiesFor = (types) => Object.fromEntries([...new Set(types)].sort().map((type) => [type, describeConstraints(type === "root" ? ROOT_PROPERTIES : {
198
+ ...REQUIRED_NODE_PROPERTIES[type],
199
+ ...UNIVERSAL_PROPERTIES,
200
+ type: "string"
201
+ })]));
181
202
  /** Present but `null` counts as present: `direction` is serialized that way. */ const check = (node, constraints) => {
182
203
  const problems = {
183
204
  missing: [],
@@ -205,6 +226,20 @@ const nodeProblems = (node) => check(node, {
205
226
  unexpected: Object.keys(root).filter((property) => !(property in ROOT_PROPERTIES))
206
227
  });
207
228
  /**
229
+ * What one serialized property has to be, for a write addressing a property
230
+ * rather than a whole node.
231
+ *
232
+ * Absent where the table says nothing, which is the same tolerance the node
233
+ * walk shows: a project's own node may carry any property, and guessing at one
234
+ * would reject content that works.
235
+ */ const propertyProblem = (nodeType, property, value) => {
236
+ const constraint = (nodeType === "root" ? ROOT_PROPERTIES : {
237
+ ...UNIVERSAL_PROPERTIES,
238
+ ...REQUIRED_NODE_PROPERTIES[nodeType] ?? {}
239
+ })[property];
240
+ return constraint === void 0 || accepts(constraint, value) ? void 0 : { needs: needs(constraint) };
241
+ };
242
+ /**
208
243
  * Only properties a feature narrows and Lexical does not check on its own
209
244
  * belong here. Everything else a feature restricts is already visible: a
210
245
  * link's targets through its sub-schema, a block node's choices through the
@@ -247,4 +282,4 @@ const stringList = (value) => Array.isArray(value) && value.every((entry) => typ
247
282
  return entries.length > 0 ? Object.fromEntries(entries) : void 0;
248
283
  };
249
284
  //#endregion
250
- export { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, rootProblems, subSchemaNodeTypes };
285
+ export { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, nodePropertiesFor, propertyProblem, rootProblems, subSchemaNodeTypes };
@@ -0,0 +1,67 @@
1
+ import { allowedNodeTypes, nodeOptions } from "./lexical.mjs";
2
+ import { isPlainObject } from "./walk.mjs";
3
+ //#region src/schema/outline.ts
4
+ /**
5
+ * Long enough to identify a paragraph, short enough that an outline of a real
6
+ * document stays a fraction of the size of its editor state.
7
+ */ const TEXT_PREVIEW_LENGTH = 80;
8
+ /**
9
+ * Every descendant text node contributes, not only direct children, since a
10
+ * link or a formatting mark nests the text a level deeper.
11
+ */ const collectText = (node) => {
12
+ if (node["type"] === "text") return typeof node["text"] === "string" ? node["text"] : "";
13
+ const children = node["children"];
14
+ return Array.isArray(children) ? children.filter(isPlainObject).map((child) => collectText(child)).join("") : "";
15
+ };
16
+ const preview = (text) => text.length > TEXT_PREVIEW_LENGTH ? `${text.slice(0, TEXT_PREVIEW_LENGTH)}…` : text;
17
+ /**
18
+ * Only the properties a feature actually narrows for this node type, and only
19
+ * where the node carries a string for one. A node missing the property, or
20
+ * carrying something the feature never produces, says nothing worth reporting.
21
+ */ const narrowedOptions = (node, narrowed) => {
22
+ if (!narrowed) return;
23
+ const set = Object.keys(narrowed).flatMap((property) => {
24
+ const value = node[property];
25
+ return typeof value === "string" ? [[property, value]] : [];
26
+ });
27
+ return set.length > 0 ? Object.fromEntries(set) : void 0;
28
+ };
29
+ const walk = (node, pointer, options, entries) => {
30
+ const type = node["type"];
31
+ if (typeof type !== "string") return;
32
+ const version = node["version"];
33
+ const text = preview(collectText(node));
34
+ const nodeOptionsFound = narrowedOptions(node, options?.[type]);
35
+ const children = node["children"];
36
+ const childCount = Array.isArray(children) ? children.length : 0;
37
+ entries.push({
38
+ ...childCount === 0 ? {} : { children: childCount },
39
+ ...nodeOptionsFound === void 0 ? {} : { options: nodeOptionsFound },
40
+ pointer,
41
+ ...text === "" ? {} : { text },
42
+ type,
43
+ ...typeof version === "number" ? { version } : {}
44
+ });
45
+ if (Array.isArray(children)) children.forEach((child, index) => {
46
+ if (isPlainObject(child)) walk(child, `${pointer}/children/${String(index)}`, options, entries);
47
+ });
48
+ };
49
+ /**
50
+ * A depth-first listing of every node under an editor state's root, so an
51
+ * agent can find a position and a sibling's `version` without reading the
52
+ * whole state. `basePointer` is the field's own pointer, e.g. "/content"; each
53
+ * entry's `pointer` extends it with "/root" and the node's real indices, which
54
+ * makes it directly usable in a patch operation.
55
+ */ const lexicalOutline = (state, basePointer, field) => {
56
+ if (!isPlainObject(state)) return [];
57
+ const root = state["root"];
58
+ if (!isPlainObject(root) || !Array.isArray(root["children"])) return [];
59
+ const options = nodeOptions(field, allowedNodeTypes(field));
60
+ const entries = [];
61
+ root["children"].forEach((child, index) => {
62
+ if (isPlainObject(child)) walk(child, `${basePointer}/root/children/${String(index)}`, options, entries);
63
+ });
64
+ return entries;
65
+ };
66
+ //#endregion
67
+ export { lexicalOutline };
@@ -1,6 +1,6 @@
1
- import { blockOf, blockSlugsOf, describeAddressableFields, findBlocksField, joinPath, splitPath, targetOf } from "./walk.mjs";
1
+ import { blockOf, blockSlugsOf, describeAddressableFields, findBlocksField, findRichTextField, isIndexSegment, joinPath, splitPath, targetOf } from "./walk.mjs";
2
+ import { resolveLexicalPointer } from "./lexical-pointer.mjs";
2
3
  //#region src/schema/pointer.ts
3
- const isIndexSegment = (segment) => segment === "-" || /^\d+$/.test(segment);
4
4
  const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isIndexSegment(segment) : part === segment);
5
5
  /**
6
6
  * Longest descriptor whose path is fully consumed by the leading segments.
@@ -21,6 +21,27 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
21
21
  * array fields are descended through rather than skipped.
22
22
  */ const valueAtSegments = (data, segments) => segments.reduce((current, segment) => current === null || typeof current !== "object" ? void 0 : current[segment], data);
23
23
  /**
24
+ * The stored row decides which block sits at an index, since a blocks field
25
+ * admits many shapes at the same position. A row the document does not have
26
+ * yet takes its slug from the value being added.
27
+ */ const stepIntoBlock = (at) => {
28
+ const { addedValue, config, descriptor, rows } = at;
29
+ const [index, ...remaining] = at.rest;
30
+ if (!isIndexSegment(index)) throw new Error(`"${descriptor.path}" is an array; "${index}" is not an index.`);
31
+ const field = findBlocksField(at.fields, splitPath(descriptor.path));
32
+ const existing = Array.isArray(rows) && index !== "-" ? rows[Number(index)] : void 0;
33
+ const slug = existing?.blockType ?? addedValue?.blockType;
34
+ if (!field || slug === void 0) throw new Error(`Cannot tell which block "${descriptor.path}/${index}" is. Supply a "blockType" on the value, one of: ${field ? blockSlugsOf(field).join(", ") : ""}`);
35
+ const block = blockOf(config, field, slug);
36
+ if (!block) throw new Error(`"${slug}" is not allowed at "${descriptor.path}". Allowed: ${blockSlugsOf(field).join(", ")}`);
37
+ return {
38
+ blockType: slug,
39
+ data: existing,
40
+ fields: block.flattenedFields,
41
+ rest: remaining
42
+ };
43
+ };
44
+ /**
24
45
  * The stored document chooses the branch at every blocks element, and is
25
46
  * required rather than optional: `/layout/sections/3/modules/1`
26
47
  * can only be resolved by reading `blockType` off `sections[3]`, since a blocks
@@ -30,6 +51,8 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
30
51
  let data = target.doc;
31
52
  let blockType;
32
53
  let segments = splitPath(target.pointer);
54
+ let readOnly;
55
+ let inLexical;
33
56
  while (segments.length > 0) {
34
57
  const descriptors = describeAddressableFields(fields);
35
58
  const match = longestMatch(descriptors, segments);
@@ -46,28 +69,58 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
46
69
  ...blockType === void 0 ? {} : { blockType },
47
70
  descriptor: match.descriptor,
48
71
  fields,
49
- prefix: []
72
+ prefix: [],
73
+ ...inLexical === void 0 ? {} : { inLexical },
74
+ ...readOnly === void 0 ? {} : { readOnly }
50
75
  };
76
+ if (match.descriptor.type === "richText") {
77
+ const field = findRichTextField(fields, splitPath(match.descriptor.path));
78
+ if (!field) throw new Error(`"${match.descriptor.path}" could not be resolved.`);
79
+ const step = resolveLexicalPointer({
80
+ ...target.addedValue === void 0 ? {} : { addedValue: target.addedValue },
81
+ config,
82
+ descriptor: match.descriptor,
83
+ field,
84
+ segments: rest,
85
+ state: valueAtSegments(data, segments.slice(0, match.consumed))
86
+ });
87
+ if (step.kind === "position") return {
88
+ ...blockType === void 0 ? {} : { blockType },
89
+ descriptor: match.descriptor,
90
+ fields,
91
+ lexical: step.position,
92
+ prefix: [],
93
+ ...readOnly === void 0 ? {} : { readOnly }
94
+ };
95
+ inLexical = true;
96
+ readOnly = match.descriptor.readOnly ?? readOnly;
97
+ blockType = step.blockType;
98
+ fields = step.fields;
99
+ data = step.data;
100
+ segments = step.rest;
101
+ continue;
102
+ }
51
103
  if (match.descriptor.type !== "blocks") throw new Error(`"${match.descriptor.path}" is a ${match.descriptor.type} field and has no "${joinPath(rest)}" beneath it.`);
52
- const [index, ...remaining] = rest;
53
- if (!isIndexSegment(index)) throw new Error(`"${match.descriptor.path}" is an array; "${index}" is not an index.`);
54
- const parts = splitPath(match.descriptor.path);
55
- const field = findBlocksField(fields, parts);
56
- const rows = valueAtSegments(data, segments.slice(0, match.consumed));
57
- const existing = Array.isArray(rows) && index !== "-" ? rows[Number(index)] : void 0;
58
- const slug = existing?.blockType ?? target.addedValue?.blockType;
59
- if (!field || slug === void 0) throw new Error(`Cannot tell which block "${match.descriptor.path}/${index}" is. Supply a "blockType" on the value, one of: ${field ? blockSlugsOf(field).join(", ") : ""}`);
60
- const block = blockOf(config, field, slug);
61
- if (!block) throw new Error(`"${slug}" is not allowed at "${match.descriptor.path}". Allowed: ${blockSlugsOf(field).join(", ")}`);
62
- blockType = slug;
63
- fields = block.flattenedFields;
64
- data = existing;
65
- segments = remaining;
104
+ const step = stepIntoBlock({
105
+ addedValue: target.addedValue,
106
+ descriptor: match.descriptor,
107
+ fields,
108
+ rest,
109
+ rows: valueAtSegments(data, segments.slice(0, match.consumed)),
110
+ config
111
+ });
112
+ readOnly = match.descriptor.readOnly ?? readOnly;
113
+ blockType = step.blockType;
114
+ fields = step.fields;
115
+ data = step.data;
116
+ segments = step.rest;
66
117
  }
67
118
  return {
68
119
  ...blockType === void 0 ? {} : { blockType },
69
120
  fields,
70
- prefix: []
121
+ ...inLexical === void 0 ? {} : { inLexical },
122
+ prefix: [],
123
+ ...readOnly === void 0 ? {} : { readOnly }
71
124
  };
72
125
  };
73
126
  //#endregion
@@ -1,12 +1,17 @@
1
- import { ROOT_PROPERTIES, constrainsFields, lexicalSubSchema, nodeProblems, rootProblems } from "./lexical.mjs";
2
- import { blockOf, blockSlugsOf, describeAddressableFields, findBlocksField, findRichTextField, splitPath } from "./walk.mjs";
1
+ import { ROOT_PROPERTIES, constrainsFields, lexicalSubSchema, nodeProblems, propertyProblem, rootProblems } from "./lexical.mjs";
2
+ import { blockOf, blockSlugsOf, describeAddressableFields, findBlocksField, findRichTextField, isPlainObject, splitPath } from "./walk.mjs";
3
3
  //#region src/schema/shape.ts
4
4
  const TOLERATED_VALUE_KEYS = /* @__PURE__ */ new Set([
5
5
  "blockName",
6
6
  "blockType",
7
7
  "id"
8
8
  ]);
9
- const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
9
+ /**
10
+ * Lexical refuses to hydrate a state whose root holds nothing: `isEmpty` is a
11
+ * node map of one, and the editor throws on it rather than rendering nothing.
12
+ * An empty field is stored as null instead, so this is only ever reached by
13
+ * emptying one that was there.
14
+ */ const EMPTY_ROOT = "an editor state needs at least one node. Clear the field with null instead.";
10
15
  const quoted = (properties) => properties.map((property) => `"${property}"`).join(", ");
11
16
  /**
12
17
  * A node with nothing to declare, and one whose sub-fields cannot be named at a
@@ -43,16 +48,60 @@ const quoted = (properties) => properties.map((property) => `"${property}"`).joi
43
48
  }, data);
44
49
  };
45
50
  /**
46
- * Payload does not check this: the Lexical validator runs node validations only for the
47
- * few node types that register one, so a `heading` inside a field whose
48
- * editor has no heading feature is stored without complaint and only fails
49
- * later, at render or when the document is reopened in the admin editor. A key
50
- * a node's fields do not declare is dropped just as silently. The same holds
51
- * one level down, for the node properties a feature narrows: an `h3` in an
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.
55
- */ const checkRichText = (scope, editor, value) => {
51
+ * An absent property is already reported as missing. Anything else present is
52
+ * checked, not just a string: Lexical stores a heading tag of 3 as readily as
53
+ * one of "h3".
54
+ */ const checkNarrowedProperty = (scope, at, value) => {
55
+ if (value !== void 0 && !(typeof value === "string" && at.values.includes(value))) scope.problems.push(`${at.pointer}: ${JSON.stringify(value)} is not available for a "${at.type}" node in this field's editor. Allowed: ${at.values.join(", ")}`);
56
+ };
57
+ /**
58
+ * One node, wherever it came from. `pointer` addresses the node itself, so a
59
+ * node written at a position and a node written inside a whole editor state
60
+ * are held to the same rules and report them the same way.
61
+ *
62
+ * Payload does not check any of this: the Lexical validator runs node
63
+ * validations only for the few node types that register one, so a `heading`
64
+ * inside a field whose editor has no heading feature is stored without
65
+ * complaint and only fails later, at render or when the document is reopened
66
+ * in the admin editor. A key a node's fields do not declare is dropped just as
67
+ * silently. The same holds one level down, for the node properties a feature
68
+ * narrows: an `h3` in an editor restricted to `h4` is stored as readily as an
69
+ * `h4`, and a node written without the properties its class hydrates from is
70
+ * stored and then throws when the editor opens it.
71
+ */ const checkNode = (scope, editor, node, pointer) => {
72
+ if (!isPlainObject(node) || typeof node["type"] !== "string") {
73
+ scope.problems.push(`${pointer}: every node needs a "type".`);
74
+ return;
75
+ }
76
+ const type = node["type"];
77
+ if (!editor.allowed.includes(type)) {
78
+ scope.problems.push(`${pointer}: "${type}" is not available in this field's editor. Allowed: ${editor.allowed.join(", ")}`);
79
+ return;
80
+ }
81
+ const problems = nodeProblems(node);
82
+ if (problems.missing.length > 0) scope.problems.push(`${pointer}: a "${type}" node is missing ${quoted(problems.missing)}. Write nodes as Lexical serializes them.`);
83
+ for (const problem of problems.rejected) scope.problems.push(`${pointer}/${problem.property}: a "${type}" node needs ${problem.needs} here.`);
84
+ for (const [property, values] of Object.entries(editor.nodeOptions?.[type] ?? {})) checkNarrowedProperty(scope, {
85
+ pointer: `${pointer}/${property}`,
86
+ type,
87
+ values
88
+ }, node[property]);
89
+ if (editor.field) checkNodeFields({
90
+ ...scope,
91
+ pointer
92
+ }, editor.field, {
93
+ fields: node["fields"],
94
+ type
95
+ });
96
+ checkNodes(scope, editor, node["children"], `${pointer}/children`);
97
+ };
98
+ /** `pointer` addresses the list. A node holding none is left alone. */ const checkNodes = (scope, editor, nodes, pointer) => {
99
+ if (!Array.isArray(nodes)) return;
100
+ nodes.forEach((node, index) => {
101
+ checkNode(scope, editor, node, `${pointer}/${String(index)}`);
102
+ });
103
+ };
104
+ const checkRichText = (scope, editor, value) => {
56
105
  if (!isPlainObject(value) || !isPlainObject(value["root"])) {
57
106
  scope.problems.push(`${scope.pointer}: expected a Lexical editor state with a "root".`);
58
107
  return;
@@ -62,36 +111,63 @@ const quoted = (properties) => properties.map((property) => `"${property}"`).joi
62
111
  if (missing.length > 0) scope.problems.push(`${scope.pointer}/root: the root node is missing ${quoted(missing)}. Write nodes as Lexical serializes them.`);
63
112
  for (const problem of rejected) scope.problems.push(`${scope.pointer}/root/${problem.property}: the root node needs ${problem.needs} here.`);
64
113
  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(", ")}`);
65
- const walk = (nodes, pointer) => {
66
- if (!Array.isArray(nodes)) return;
67
- nodes.forEach((node, index) => {
68
- const at = `${pointer}/${String(index)}`;
69
- if (!isPlainObject(node) || typeof node["type"] !== "string") {
70
- scope.problems.push(`${at}: every node needs a "type".`);
71
- return;
72
- }
73
- if (!editor.allowed.includes(node["type"])) {
74
- scope.problems.push(`${at}: "${node["type"]}" is not available in this field's editor. Allowed: ${editor.allowed.join(", ")}`);
75
- return;
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.`);
80
- for (const [property, values] of Object.entries(editor.nodeOptions?.[node["type"]] ?? {})) {
81
- const value = node[property];
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(", ")}`);
83
- }
84
- if (editor.field) checkNodeFields({
85
- ...scope,
86
- pointer: at
87
- }, editor.field, {
88
- fields: node["fields"],
89
- type: node["type"]
90
- });
91
- walk(node["children"], `${at}/children`);
92
- });
114
+ if (Array.isArray(root["children"]) && root["children"].length === 0) scope.problems.push(`${scope.pointer}/root/children: ${EMPTY_ROOT}`);
115
+ checkNodes(scope, editor, root["children"], `${scope.pointer}/root/children`);
116
+ };
117
+ /**
118
+ * `type` decides how every other property on a node is read, so replacing it
119
+ * alone would leave a heading shaped like a text node. Everything else is held
120
+ * to the constraint the node walk holds it to and nothing more, since a
121
+ * feature may put any property on a node.
122
+ */ const checkNodeProperty = (scope, editor, position, value) => {
123
+ const { nodeType: type, property } = position;
124
+ if (property === "type") {
125
+ scope.problems.push(`${scope.pointer}: a node's "type" cannot be replaced on its own. Replace the whole node.`);
126
+ return;
127
+ }
128
+ if (position.isRoot && !(property in ROOT_PROPERTIES)) {
129
+ scope.problems.push(`${scope.pointer}: no such property on the root node. Available: ${Object.keys(ROOT_PROPERTIES).join(", ")}`);
130
+ return;
131
+ }
132
+ const problem = propertyProblem(type, property, value);
133
+ if (problem) scope.problems.push(`${scope.pointer}: a "${type}" node needs ${problem.needs} here.`);
134
+ const values = editor.nodeOptions?.[type]?.[property];
135
+ if (values) checkNarrowedProperty(scope, {
136
+ pointer: scope.pointer,
137
+ type,
138
+ values
139
+ }, value);
140
+ };
141
+ /**
142
+ * A value written at a position inside an editor state rather than as the
143
+ * whole state.
144
+ */ const checkLexicalWrite = (scope, position, value) => {
145
+ const editor = {
146
+ allowed: position.descriptor.nodes ?? [],
147
+ field: position.field,
148
+ nodeOptions: position.descriptor.nodeOptions
93
149
  };
94
- walk(root["children"], `${scope.pointer}/root/children`);
150
+ if (position.kind === "property") {
151
+ checkNodeProperty(scope, editor, position, value);
152
+ return;
153
+ }
154
+ if (position.kind === "nodes") {
155
+ if (!Array.isArray(value)) {
156
+ scope.problems.push(`${scope.pointer}: expected an array of nodes.`);
157
+ return;
158
+ }
159
+ if (position.isRoot && value.length === 0) {
160
+ scope.problems.push(`${scope.pointer}: ${EMPTY_ROOT}`);
161
+ return;
162
+ }
163
+ checkNodes(scope, editor, value, scope.pointer);
164
+ return;
165
+ }
166
+ if (position.isRoot) {
167
+ scope.problems.push(`${scope.pointer}: the root of an editor state cannot be replaced on its own. Write the whole field instead.`);
168
+ return;
169
+ }
170
+ checkNode(scope, editor, value, scope.pointer);
95
171
  };
96
172
  const checkLeafValue = (scope, descriptor, value) => {
97
173
  if (descriptor.readOnly) {
@@ -199,6 +275,10 @@ const checkLeafValue = (scope, descriptor, value) => {
199
275
  prefix: target.resolution.prefix,
200
276
  problems
201
277
  };
278
+ if (target.resolution.lexical) {
279
+ checkLexicalWrite(scope, target.resolution.lexical, value);
280
+ return problems;
281
+ }
202
282
  if (target.resolution.descriptor) {
203
283
  checkLeafValue(scope, target.resolution.descriptor, value);
204
284
  return problems;
@@ -207,4 +287,4 @@ const checkLeafValue = (scope, descriptor, value) => {
207
287
  return problems;
208
288
  };
209
289
  //#endregion
210
- export { validateWriteValue };
290
+ export { EMPTY_ROOT, validateWriteValue };
@@ -12,6 +12,8 @@ const RESERVED_FIELD_NAMES = /* @__PURE__ */ new Set([
12
12
  const JSON_POINTER_PATTERN = /^(\/([^~/]|~[01])*)*$/;
13
13
  /** No segments is the root pointer, `""`. */ const joinPath = (parts) => parts.map((part) => `/${part.replace(/~/g, "~0").replace(/\//g, "~1")}`).join("");
14
14
  /** Unescapes `~1` and `~0`. The root pointer yields no segments. */ const splitPath = (path) => path.split("/").slice(1).map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
15
+ /** `-` included, since RFC 6901 reads it as the position after the last. */ const isIndexSegment = (segment) => segment === "-" || /^\d+$/.test(segment);
16
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
15
17
  /**
16
18
  * A path Payload reports on a validation error (`layout.0.title`) as a JSON
17
19
  * Pointer, so everything handed back addresses documents the same way. The
@@ -140,4 +142,4 @@ const findBlocksField = (fields, path) => findFieldAt(fields, path, "blocks");
140
142
  return found;
141
143
  };
142
144
  //#endregion
143
- export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, pointerFromPayloadPath, splitPath, targetOf };
145
+ export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, isIndexSegment, isPlainObject, joinPath, pointerFromPayloadPath, splitPath, targetOf };
@@ -1,4 +1,5 @@
1
1
  import { jsonResult } from "../result.mjs";
2
+ import { nodePropertiesFor } from "../schema/lexical.mjs";
2
3
  import { translatorFor } from "../i18n.mjs";
3
4
  import { nodeDescriber, reachableSchemaPaths } from "../schema/describe.mjs";
4
5
  import "../schema/index.mjs";
@@ -22,9 +23,11 @@ Call it with no "paths" to get a collection's own fields. Every "blocks" field s
22
23
 
23
24
  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
25
 
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
+ 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. Those requirements are stated rather than left to be discovered: the response carries one final "nodeProperties" entry keyed by node type, naming each property and what belongs there in the same words a refused write uses, so a node can be built from this response alone. A field's own "nodes" says which of those types it accepts. The root takes exactly "children", "direction", "format", "indent", "type" and "version" and refuses anything else. 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. A state whose root holds no children is refused however it is written, because Lexical reads it as empty and throws; clear a field with null instead.
26
27
 
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".
28
+ A rich text field's value is addressable too, so an edit does not have to rewrite the whole state: "/content/root/children/0" is the first top-level node, "/content/root/children/0/children/1" a node inside it, "/content/root/children/0/tag" one property of a node, and "/content/root/children/0/fields/url" a field of a node, described at the "next" path for that node type. Append a node with "/-". Which node sits at an index is only knowable from what is stored, so read it first: getDocument with "outline" answers with the pointer, type, "version" and a text excerpt for every node, which is far cheaper than reading the whole state.
29
+
30
+ 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". Inside a rich text field that substitution does not apply: a path there names the node type, and a block node its slug, where a pointer enters the stored state at "root" and walks "children" by an index counted over every child at that level, not over the blocks among them, with the node's own fields under "fields". So "/content/block/practice-note/variant" is written at "/content/root/children/7/fields/variant".
28
31
 
29
32
  Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed and cannot be written. Fields marked readOnly are listed but refused on write.`,
30
33
  annotations: {
@@ -55,6 +58,8 @@ Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed an
55
58
  };
56
59
  }
57
60
  });
61
+ const nodeTypes = nodes.flatMap((node) => (node.fields ?? []).flatMap((field) => field.nodes ?? []));
62
+ if (nodeTypes.length > 0) nodes.push({ nodeProperties: nodePropertiesFor(nodeTypes) });
58
63
  if (expanded?.truncated) nodes.push({ error: `Result truncated after ${String(400)} nodes. Request explicit paths instead.` });
59
64
  return Promise.resolve(jsonResult(nodes));
60
65
  }
@@ -1,11 +1,15 @@
1
1
  import { errorResult, jsonResult } from "../result.mjs";
2
- import { JSON_POINTER_PATTERN } from "../schema/walk.mjs";
2
+ import { JSON_POINTER_PATTERN, findRichTextField, splitPath } from "../schema/walk.mjs";
3
+ import { lexicalOutline } from "../schema/outline.mjs";
4
+ import { resolveDataPointer } from "../schema/pointer.mjs";
3
5
  import "../schema/index.mjs";
4
6
  import { depthShape, idShape, localeOf, localeShape, targetShape } from "./shared.mjs";
5
- import { requireIdFor, resolveTarget } from "./target.mjs";
7
+ import { refOf, requireIdFor, resolveTarget } from "./target.mjs";
6
8
  import { defineMcpxTool } from "../types.mjs";
7
9
  import { z } from "zod";
8
10
  import { Pointer } from "rfc6902";
11
+ //#region src/tools/get-document.ts
12
+ const OUTLINE_ERROR = "\"outline\" applies to a rich text field; give \"path\" for one.";
9
13
  /**
10
14
  * With `path` the handler returns the subtree plus the `id`, `_status` and
11
15
  * `updatedAt` a client needs to write back, so a caller reading one branch
@@ -14,7 +18,9 @@ import { Pointer } from "rfc6902";
14
18
  name: "getDocument",
15
19
  description: `Reads one document, or one subtree of it when "path" is given as a JSON pointer such as "/layout/sections/2". Returns the latest draft by default. Read before patching: the response carries "updatedAt" for expectedUpdatedAt and the indices pointers need.
16
20
 
17
- Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.`,
21
+ Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
22
+
23
+ Set "outline" on a rich text "path" to get a compact positional listing of its nodes instead of the raw editor state.`,
18
24
  annotations: {
19
25
  readOnlyHint: true,
20
26
  openWorldHint: false
@@ -32,7 +38,8 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
32
38
  required: false,
33
39
  description: "Locale to read. Defaults to the default locale."
34
40
  }),
35
- draft: z.boolean().optional().describe("Return the latest draft. Default true.")
41
+ draft: z.boolean().optional().describe("Return the latest draft. Default true."),
42
+ outline: z.boolean().optional().describe("For a rich text field, return a compact positional outline instead of the editor state. Requires \"path\".")
36
43
  }),
37
44
  handler: async ({ args, scope }) => {
38
45
  const target = resolveTarget(scope, args, "read");
@@ -53,20 +60,42 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
53
60
  ...shared,
54
61
  slug: target.slug
55
62
  }));
56
- if (args.path === void 0 || args.path === "") return jsonResult(doc);
63
+ if (args.path === void 0 || args.path === "") {
64
+ if (args.outline) return errorResult(OUTLINE_ERROR);
65
+ return jsonResult(doc);
66
+ }
57
67
  let value;
58
68
  try {
59
69
  value = Pointer.fromJSON(args.path).get(doc);
60
70
  } catch {
61
71
  return errorResult(`"${args.path}" is not a valid JSON pointer.`);
62
72
  }
63
- return jsonResult({
73
+ const envelope = {
64
74
  ...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
65
75
  status: doc["_status"],
66
76
  updatedAt: doc["updatedAt"],
67
- path: args.path,
77
+ path: args.path
78
+ };
79
+ if (!args.outline) return jsonResult({
80
+ ...envelope,
68
81
  value
69
82
  });
83
+ let resolution;
84
+ try {
85
+ resolution = resolveDataPointer(scope.req.payload.config, {
86
+ doc,
87
+ pointer: args.path,
88
+ ref: refOf(target)
89
+ });
90
+ } catch (error) {
91
+ return errorResult(error instanceof Error ? error.message : OUTLINE_ERROR);
92
+ }
93
+ const field = resolution.descriptor?.type === "richText" && !resolution.lexical ? findRichTextField(resolution.fields, splitPath(resolution.descriptor.path)) : void 0;
94
+ if (!field) return errorResult(OUTLINE_ERROR);
95
+ return jsonResult({
96
+ ...envelope,
97
+ outline: lexicalOutline(value, args.path, field)
98
+ });
70
99
  }
71
100
  });
72
101
  //#endregion
@@ -14,10 +14,14 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
14
14
 
15
15
  ${draftSentence(scope)}
16
16
 
17
- Only the fields describeSchema lists can be addressed. A pointer that does not resolve is refused with the fields that are valid at that point, and nothing is applied unless every operation in the batch validates first. describeSchema reports field paths in this same pointer syntax; a path becomes a pointer into a document by replacing each "*" and each block slug with its 0-based index.
17
+ Only the fields describeSchema lists can be addressed. A pointer that does not resolve is refused with the fields that are valid at that point, and nothing is applied unless every operation in the batch validates first. describeSchema reports field paths in this same pointer syntax; a path becomes a pointer into a document by replacing each "*" and each block slug with its 0-based index. Inside a rich text field that substitution does not apply: a path there names the node type, and a block node its slug, where a pointer enters the stored state at "root" and walks "children" by an index counted over every child at that level, not over the blocks among them, with the node's own fields under "fields". So "/content/block/practice-note/variant" is written at "/content/root/children/7/fields/variant".
18
18
 
19
19
  Adding a block requires "blockType" on the value. Append with "/-" as the last segment. To clear a field use "replace" with null; an array or blocks field refuses null and is emptied with [] instead. "remove" is only for list elements, because a field left out of a write is kept rather than cleared. Read the document first to learn the indices, and pass its "updatedAt" as expectedUpdatedAt so an edit made since that read is refused rather than overwritten.
20
20
 
21
+ Inside a rich text field a pointer keeps going: "/content/root/children/2" is a node, "/content/root/children/2/tag" one of its properties, and "/content/root/children/2/fields/url" a field it carries. A node written at a position must carry everything Lexical serializes, "version" included, exactly as one written inside a whole state must; getDocument with "outline" returns each node's pointer and version, which is the cheapest way to get both right. A node's "type" cannot be replaced on its own, and neither can the root.
22
+
23
+ Node positions shift as soon as anything is added or removed, so read immediately before patching, order removals from the last index to the first, and use a "test" operation on "/content/root/children/2/type" to assert a position is what you think it is before writing to it.
24
+
21
25
  A successful write may come back with "publishBlockers": everything still wrong with the draft, such as required fields left empty. Those do not fail the write, because a draft is allowed to be incomplete, but the document cannot be published until the list is empty. "notApplied" lists pointers whose value Payload kept unchanged, which happens when field-level access denies the update. "publishBlockersUnavailable" means the check itself failed, so the empty list says nothing about whether the document is publishable.`;
22
26
  const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
23
27
  /**
@@ -1,6 +1,6 @@
1
- import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, describeAddressableFields, findBlocksField, joinPath, splitPath } from "../schema/walk.mjs";
1
+ import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, describeAddressableFields, findBlocksField, isIndexSegment, joinPath, splitPath } from "../schema/walk.mjs";
2
2
  import { resolveDataPointer } from "../schema/pointer.mjs";
3
- import { validateWriteValue } from "../schema/shape.mjs";
3
+ import { EMPTY_ROOT, validateWriteValue } from "../schema/shape.mjs";
4
4
  import "../schema/index.mjs";
5
5
  import { z } from "zod";
6
6
  import { Pointer, applyPatch } from "rfc6902";
@@ -43,8 +43,7 @@ const droppedPointer = (operation) => {
43
43
  return operation.op === "move" ? operation.from : void 0;
44
44
  };
45
45
  const isElementPointer = (pointer) => {
46
- const last = pointer.split("/").pop() ?? "";
47
- return last === "-" || /^\d+$/.test(last);
46
+ return isIndexSegment(pointer.split("/").pop() ?? "");
48
47
  };
49
48
  const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
50
49
  /** Its nodes manage their own ids, so it is never descended into. */ const isRichTextState = (value) => isPlainObject(value["root"]) && Array.isArray(value["root"]["children"]);
@@ -108,6 +107,7 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
108
107
  return "from" in operation ? Pointer.fromJSON(operation.from).get(doc) : void 0;
109
108
  };
110
109
  /** A pointer stopping short addresses a subtree; the fields beneath decide. */ const resolvesReadOnly = (resolution) => {
110
+ if (resolution.readOnly) return true;
111
111
  if (resolution.descriptor) return resolution.descriptor.readOnly === true;
112
112
  const below = describeAddressableFields(resolution.fields).filter((descriptor) => resolution.prefix.every((part, offset) => part === splitPath(descriptor.path)[offset]));
113
113
  return below.length > 0 && below.every((descriptor) => descriptor.readOnly);
@@ -117,6 +117,43 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
117
117
  pointer: isElementPointer(target.pointer) ? joinPath(splitPath(target.pointer).slice(0, -1)) : target.pointer,
118
118
  ref: target.ref
119
119
  }));
120
+ /** Unresolvable pointers say nothing here; the caller reports them anyway. */ const resolutionAt = (config, target) => {
121
+ try {
122
+ return resolveDataPointer(config, {
123
+ doc: target.doc,
124
+ pointer: target.pointer,
125
+ ref: target.ref
126
+ });
127
+ } catch {
128
+ return;
129
+ }
130
+ };
131
+ /**
132
+ * Removing a field does nothing, but removing part of an editor state does
133
+ * something, and something worse: a node written without the properties its
134
+ * class hydrates from throws when the admin editor opens it. Both are refused,
135
+ * and only the reason differs.
136
+ */ const droppedFieldProblem = (config, target) => {
137
+ const resolution = resolutionAt(config, target);
138
+ const lexical = resolution?.lexical;
139
+ if (lexical?.kind === "property") return `"${target.pointer}" is a node property, not a list element. A "${lexical.nodeType}" node needs it, so replace it rather than removing it.`;
140
+ if (lexical ?? resolution?.inLexical) return `"${target.pointer}" sits inside an editor state, which is written whole, so removing it would take effect and leave a node the admin editor cannot open. Replace it instead, or remove the node that holds it.`;
141
+ return `"${target.pointer}" is a field, not a list element, and removing it would do nothing. The patched document is written whole, and Payload keeps any field absent from a write rather than clearing it. Use "replace" with null to clear a field, or with [] to empty a list.`;
142
+ };
143
+ /**
144
+ * Dropping the only node under a root empties the state, which Lexical refuses
145
+ * to hydrate. Checked here rather than on the written value, because a
146
+ * `remove` carries none.
147
+ */ const emptiesTheRoot = (config, target) => {
148
+ const list = joinPath(splitPath(target.pointer).slice(0, -1));
149
+ const owner = resolutionAt(config, {
150
+ ...target,
151
+ pointer: list
152
+ })?.lexical;
153
+ if (owner?.kind !== "nodes" || owner.isRoot !== true) return false;
154
+ const nodes = Pointer.fromJSON(list).get(target.doc);
155
+ return Array.isArray(nodes) && nodes.length === 1;
156
+ };
120
157
  /**
121
158
  * Checked against the document as it stands when this operation runs: both
122
159
  * pointers must resolve, the written value must pass write validation, and what
@@ -128,7 +165,11 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
128
165
  const reserved = pointers.find(isReservedPointer);
129
166
  if (reserved !== void 0) return [`"${reserved}" addresses a field Payload maintains. This tool only ever writes drafts, and id, _status, createdAt and updatedAt are not writable; use publishDocument to publish.`];
130
167
  const dropped = droppedPointer(operation);
131
- if (dropped !== void 0 && !isElementPointer(dropped)) return [`"${dropped}" is a field, not a list element, and removing it would do nothing. The patched document is written whole, and Payload keeps any field absent from a write rather than clearing it. Use "replace" with null to clear a field, or with [] to empty a list.`];
168
+ if (dropped !== void 0 && !isElementPointer(dropped)) return [droppedFieldProblem(config, {
169
+ doc,
170
+ pointer: dropped,
171
+ ref
172
+ })];
132
173
  try {
133
174
  const value = effectiveValue(operation, doc);
134
175
  if (value !== void 0 && operation.op !== "test" && isReadOnlyPointer(config, {
@@ -141,6 +182,11 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
141
182
  pointer: dropped,
142
183
  ref
143
184
  })) return [`"${dropped}" sits in a read-only field and cannot be removed.`];
185
+ if (dropped !== void 0 && emptiesTheRoot(config, {
186
+ doc,
187
+ pointer: dropped,
188
+ ref
189
+ })) return [`"${dropped}": ${EMPTY_ROOT}`];
144
190
  for (const pointer of pointers) {
145
191
  const resolution = resolveDataPointer(config, {
146
192
  addedValue: value,
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.15",
4
+ "version": "1.0.0-beta.17",
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",