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

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,37 @@ 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
+ - A rich text field's value is addressable, so a small edit does not have to
232
+ rewrite the whole state. `/content/root/children/2` is a node,
233
+ `/content/root/children/2/tag` one of its properties, and
234
+ `/content/root/children/2/fields/url` a field the node carries, resolved
235
+ through the same node schema `describeSchema` publishes. A node written at a
236
+ position is held to exactly what a node inside a whole state is held to. The
237
+ root and a node's `type` cannot be replaced on their own, and a node property
238
+ cannot be removed, because a node needs it.
239
+ - Node positions shift the moment anything is added or removed, and a text or
240
+ paragraph node carries no id to fall back on. `getDocument` with `outline`
241
+ answers with one line per node, its pointer, its `version` and an excerpt, so
242
+ a position can be chosen and a complete node written without holding the
243
+ whole state. `expectedUpdatedAt` still guards the document, and a `test`
244
+ operation on a node's `type` guards the position.
245
+ - A state whose root holds nothing is refused, however it is written. Lexical
246
+ reads such a state as empty and throws rather than rendering it, so neither a
247
+ whole-field write of one, nor emptying the node list, nor removing the last
248
+ node is allowed. An empty field is stored as null instead.
249
+ - Where Payload states the shape itself, that statement is what is enforced: its
250
+ `outputSchema` declares `version` required on every node, and gives the root
251
+ exactly `children`, `direction`, `format`, `indent`, `type` and `version`, so
252
+ an unknown property on the root is refused too. Payload declares nothing per
253
+ node type, so the rest is measured against the node classes
254
+ `@payloadcms/richtext-lexical` ships. Both halves are pinned by
255
+ `src/schema/lexical.spec.ts` rather than assumed.
225
256
  - Field and collection `admin.description` values are included in
226
257
  `describeSchema` and `listCapabilities`, so intent written for the admin
227
258
  panel reaches the client. A locale-keyed record is resolved to one string for
@@ -504,9 +535,8 @@ How the draft and publish guarantees are enforced, and where they stop, is in
504
535
  ## Non-goals of v1 / roadmap
505
536
 
506
537
  Unpublishing, `versions.drafts.localizeStatus`, deletes, creating upload
507
- documents and any file handling, markdown authoring for rich text, addressing a
508
- rich text node by position in a patch (an editor state is written whole),
509
- schemas for `upload` node fields, row addressing by id instead of index,
538
+ documents and any file handling, markdown authoring for rich text, schemas for
539
+ `upload` node fields, row addressing by id instead of index,
510
540
  cross-locale publish blockers, pagination of `describeSchema` with `expand`,
511
541
  and a handler-level timeout are all deliberate omissions for now.
512
542
 
@@ -1,6 +1,8 @@
1
- import { allowedNodeTypes, lexicalSubSchema, nodeOptions, 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, 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, RESERVED_FIELD_NAMES, allowedNodeTypes, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, lexicalSubSchema, nodeDescriber, nodeOptions, pointerFromPayloadPath, reachableSchemaPaths, resolveDataPointer, 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, 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".`);
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.`);
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. Read the document first, 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 };
@@ -53,6 +53,172 @@ 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
+ /**
208
+ * What one serialized property has to be, for a write addressing a property
209
+ * rather than a whole node.
210
+ *
211
+ * Absent where the table says nothing, which is the same tolerance the node
212
+ * walk shows: a project's own node may carry any property, and guessing at one
213
+ * would reject content that works.
214
+ */ const propertyProblem = (nodeType, property, value) => {
215
+ const constraint = (nodeType === "root" ? ROOT_PROPERTIES : {
216
+ ...UNIVERSAL_PROPERTIES,
217
+ ...REQUIRED_NODE_PROPERTIES[nodeType] ?? {}
218
+ })[property];
219
+ return constraint === void 0 || accepts(constraint, value) ? void 0 : { needs: needs(constraint) };
220
+ };
221
+ /**
56
222
  * Only properties a feature narrows and Lexical does not check on its own
57
223
  * belong here. Everything else a feature restricts is already visible: a
58
224
  * link's targets through its sub-schema, a block node's choices through the
@@ -95,4 +261,4 @@ const stringList = (value) => Array.isArray(value) && value.every((entry) => typ
95
261
  return entries.length > 0 ? Object.fromEntries(entries) : void 0;
96
262
  };
97
263
  //#endregion
98
- export { allowedNodeTypes, lexicalSubSchema, nodeOptions, subSchemaNodeTypes };
264
+ export { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, 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,18 @@
1
- import { lexicalSubSchema } 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.";
15
+ const quoted = (properties) => properties.map((property) => `"${property}"`).join(", ");
10
16
  /**
11
17
  * A node with nothing to declare, and one whose sub-fields cannot be named at a
12
18
  * position, are both left alone.
@@ -15,7 +21,7 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
15
21
  if (!sub) return;
16
22
  const data = node.fields;
17
23
  if (!isPlainObject(data)) {
18
- scope.problems.push(`${scope.pointer}: a "${node.type}" node carries a "fields" object.`);
24
+ if (!constrainsFields(node.type)) scope.problems.push(`${scope.pointer}: a "${node.type}" node carries a "fields" object.`);
19
25
  return;
20
26
  }
21
27
  const nested = {
@@ -42,45 +48,126 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
42
48
  }, data);
43
49
  };
44
50
  /**
45
- * Payload does not check this: the Lexical validator runs node validations only for the
46
- * few node types that register one, so a `heading` inside a field whose
47
- * editor has no heading feature is stored without complaint and only fails
48
- * later, at render or when the document is reopened in the admin editor. A key
49
- * a node's fields do not declare is dropped just as silently. The same holds
50
- * 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
- */ 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) => {
53
105
  if (!isPlainObject(value) || !isPlainObject(value["root"])) {
54
106
  scope.problems.push(`${scope.pointer}: expected a Lexical editor state with a "root".`);
55
107
  return;
56
108
  }
57
- const walk = (nodes, pointer) => {
58
- if (!Array.isArray(nodes)) return;
59
- nodes.forEach((node, index) => {
60
- const at = `${pointer}/${String(index)}`;
61
- if (!isPlainObject(node) || typeof node["type"] !== "string") {
62
- scope.problems.push(`${at}: every node needs a "type".`);
63
- return;
64
- }
65
- if (!editor.allowed.includes(node["type"])) {
66
- scope.problems.push(`${at}: "${node["type"]}" is not available in this field's editor. Allowed: ${editor.allowed.join(", ")}`);
67
- return;
68
- }
69
- for (const [property, values] of Object.entries(editor.nodeOptions?.[node["type"]] ?? {})) {
70
- 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(", ")}`);
72
- }
73
- if (editor.field) checkNodeFields({
74
- ...scope,
75
- pointer: at
76
- }, editor.field, {
77
- fields: node["fields"],
78
- type: node["type"]
79
- });
80
- walk(node["children"], `${at}/children`);
81
- });
109
+ const root = value["root"];
110
+ const { missing, rejected, unexpected } = rootProblems(root);
111
+ if (missing.length > 0) scope.problems.push(`${scope.pointer}/root: the root node is missing ${quoted(missing)}. Write nodes as Lexical serializes them.`);
112
+ for (const problem of rejected) scope.problems.push(`${scope.pointer}/root/${problem.property}: the root node needs ${problem.needs} here.`);
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(", ")}`);
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
82
149
  };
83
- walk(value["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);
84
171
  };
85
172
  const checkLeafValue = (scope, descriptor, value) => {
86
173
  if (descriptor.readOnly) {
@@ -188,6 +275,10 @@ const checkLeafValue = (scope, descriptor, value) => {
188
275
  prefix: target.resolution.prefix,
189
276
  problems
190
277
  };
278
+ if (target.resolution.lexical) {
279
+ checkLexicalWrite(scope, target.resolution.lexical, value);
280
+ return problems;
281
+ }
191
282
  if (target.resolution.descriptor) {
192
283
  checkLeafValue(scope, target.resolution.descriptor, value);
193
284
  return problems;
@@ -196,4 +287,4 @@ const checkLeafValue = (scope, descriptor, value) => {
196
287
  return problems;
197
288
  };
198
289
  //#endregion
199
- 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 };
@@ -22,6 +22,10 @@ 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. 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
+ 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 "/-". Read the document first: which node sits at an index is only knowable from what is stored.
28
+
25
29
  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
30
 
27
31
  Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed and cannot be written. Fields marked readOnly are listed but refused on write.`,
@@ -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
@@ -18,6 +18,10 @@ Only the fields describeSchema lists can be addressed. A pointer that does not r
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.14",
4
+ "version": "1.0.0-beta.16",
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",