@abinnovision/payloadcms-mcpx 1.0.0-beta.8 → 1.0.0

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +331 -194
  3. package/dist/api-keys/collection.mjs +3 -3
  4. package/dist/api-keys/fields.mjs +56 -5
  5. package/dist/api-keys/setup-guide.mjs +56 -0
  6. package/dist/auth/resolve.mjs +5 -7
  7. package/dist/capabilities.mjs +23 -4
  8. package/dist/client/index.d.mts +2 -0
  9. package/dist/client/index.mjs +2 -0
  10. package/dist/client/setup-guide.d.mts +14 -0
  11. package/dist/client/setup-guide.mjs +87 -0
  12. package/dist/endpoint/{result.mjs → errors.mjs} +4 -23
  13. package/dist/endpoint/handler.mjs +11 -5
  14. package/dist/endpoint/index.mjs +4 -0
  15. package/dist/endpoint/server.mjs +18 -26
  16. package/dist/i18n.mjs +4 -15
  17. package/dist/index.d.mts +4 -4
  18. package/dist/index.mjs +4 -3
  19. package/dist/options.mjs +31 -26
  20. package/dist/plugin.mjs +1 -0
  21. package/dist/{write/draft-guard.d.mts → request.d.mts} +2 -2
  22. package/dist/request.mjs +8 -0
  23. package/dist/result.d.mts +11 -0
  24. package/dist/result.mjs +20 -0
  25. package/dist/schema/describe.mjs +3 -15
  26. package/dist/schema/index.mjs +8 -0
  27. package/dist/schema/lexical-pointer.mjs +125 -0
  28. package/dist/schema/lexical.mjs +195 -27
  29. package/dist/schema/outline.mjs +67 -0
  30. package/dist/schema/pointer.mjs +77 -30
  31. package/dist/schema/shape.mjs +133 -51
  32. package/dist/schema/walk.mjs +44 -64
  33. package/dist/tools/{index.mjs → builtin.mjs} +8 -5
  34. package/dist/tools/create-document.mjs +34 -15
  35. package/dist/tools/describe-schema.mjs +21 -7
  36. package/dist/tools/find-documents.mjs +13 -6
  37. package/dist/tools/get-document.mjs +45 -11
  38. package/dist/tools/list-capabilities.mjs +19 -9
  39. package/dist/tools/names.mjs +2 -1
  40. package/dist/tools/patch-document.mjs +32 -21
  41. package/dist/tools/publish-document.mjs +79 -0
  42. package/dist/tools/shared.mjs +84 -32
  43. package/dist/tools/target.mjs +7 -11
  44. package/dist/tools/validate-document.mjs +20 -12
  45. package/dist/types.d.mts +115 -42
  46. package/dist/types.mjs +3 -4
  47. package/dist/version.mjs +1 -1
  48. package/dist/write/draft-guard.mjs +47 -44
  49. package/dist/write/patch.mjs +174 -92
  50. package/dist/write/publish-blockers.mjs +13 -12
  51. package/dist/write/publish-intent.mjs +17 -0
  52. package/dist/write/transaction.mjs +8 -3
  53. package/package.json +24 -9
  54. package/dist/i18n.d.mts +0 -1
  55. package/dist/options.d.mts +0 -2
  56. package/dist/schema/lexical.d.mts +0 -1
  57. package/dist/schema/walk.d.mts +0 -3
  58. package/dist/tools/target.d.mts +0 -3
  59. package/dist/tools/types.d.mts +0 -5
  60. package/dist/write/publish-blockers.d.mts +0 -15
@@ -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.
@@ -9,10 +9,7 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
9
9
  descriptor,
10
10
  parts: splitPath(descriptor.path)
11
11
  })).filter(({ parts }) => parts.every((part, offset) => partMatches(part, segments[offset]))).sort((left, right) => right.consumed - left.consumed)[0];
12
- /**
13
- * Whether the segments stop part-way through some descriptor's path, which
14
- * means they address a subtree rather than a field.
15
- */ const isSubtreePrefix = (descriptors, segments) => descriptors.some((descriptor) => {
12
+ /** Stopping part-way through a descriptor's path means a subtree, not a field. */ const isSubtreePrefix = (descriptors, segments) => descriptors.some((descriptor) => {
16
13
  const parts = splitPath(descriptor.path);
17
14
  return parts.length > segments.length && segments.every((segment, offset) => {
18
15
  const part = parts[offset];
@@ -20,15 +17,33 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
20
17
  });
21
18
  });
22
19
  /**
23
- * Reads the value the given pointer segments address. Unlike a descriptor
24
- * path, the segments carry real indices, so intervening array fields are
25
- * descended through rather than skipped.
20
+ * Unlike a descriptor path, the segments carry real indices, so intervening
21
+ * array fields are descended through rather than skipped.
26
22
  */ const valueAtSegments = (data, segments) => segments.reduce((current, segment) => current === null || typeof current !== "object" ? void 0 : current[segment], data);
27
23
  /**
28
- * Resolves a JSON Pointer against the schema, using the stored document to
29
- * choose a branch at every blocks element.
30
- *
31
- * The document is required rather than optional: `/layout/sections/3/modules/1`
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
+ /**
45
+ * The stored document chooses the branch at every blocks element, and is
46
+ * required rather than optional: `/layout/sections/3/modules/1`
32
47
  * can only be resolved by reading `blockType` off `sections[3]`, since a blocks
33
48
  * field admits many shapes at the same index.
34
49
  */ const resolveDataPointer = (config, target) => {
@@ -36,6 +51,8 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
36
51
  let data = target.doc;
37
52
  let blockType;
38
53
  let segments = splitPath(target.pointer);
54
+ let readOnly;
55
+ let inLexical;
39
56
  while (segments.length > 0) {
40
57
  const descriptors = describeAddressableFields(fields);
41
58
  const match = longestMatch(descriptors, segments);
@@ -43,7 +60,7 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
43
60
  if (isSubtreePrefix(descriptors, segments)) return {
44
61
  ...blockType === void 0 ? {} : { blockType },
45
62
  fields,
46
- prefix: segments
63
+ prefix: segments.map((segment) => isIndexSegment(segment) ? "*" : segment)
47
64
  };
48
65
  throw new Error(`"${joinPath(segments)}" is not a field here. Available: ${descriptors.map((descriptor) => descriptor.path).join(", ")}`);
49
66
  }
@@ -52,28 +69,58 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
52
69
  ...blockType === void 0 ? {} : { blockType },
53
70
  descriptor: match.descriptor,
54
71
  fields,
55
- prefix: []
72
+ prefix: [],
73
+ ...inLexical === void 0 ? {} : { inLexical },
74
+ ...readOnly === void 0 ? {} : { readOnly }
56
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
+ }
57
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.`);
58
- const [index, ...remaining] = rest;
59
- if (!isIndexSegment(index)) throw new Error(`"${match.descriptor.path}" is an array; "${index}" is not an index.`);
60
- const parts = splitPath(match.descriptor.path);
61
- const field = findBlocksField(fields, parts);
62
- const rows = valueAtSegments(data, segments.slice(0, match.consumed));
63
- const existing = Array.isArray(rows) && index !== "-" ? rows[Number(index)] : void 0;
64
- const slug = existing?.blockType ?? target.addedValue?.blockType;
65
- 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(", ") : ""}`);
66
- const block = blockOf(config, field, slug);
67
- if (!block) throw new Error(`"${slug}" is not allowed at "${match.descriptor.path}". Allowed: ${blockSlugsOf(field).join(", ")}`);
68
- blockType = slug;
69
- fields = block.flattenedFields;
70
- data = existing;
71
- 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;
72
117
  }
73
118
  return {
74
119
  ...blockType === void 0 ? {} : { blockType },
75
120
  fields,
76
- prefix: []
121
+ ...inLexical === void 0 ? {} : { inLexical },
122
+ prefix: [],
123
+ ...readOnly === void 0 ? {} : { readOnly }
77
124
  };
78
125
  };
79
126
  //#endregion
@@ -1,26 +1,27 @@
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
- /**
5
- * Keys Payload manages on a row that a client may echo back harmlessly.
6
- */ const TOLERATED_VALUE_KEYS = /* @__PURE__ */ new Set([
4
+ const TOLERATED_VALUE_KEYS = /* @__PURE__ */ new Set([
7
5
  "blockName",
8
6
  "blockType",
9
7
  "id"
10
8
  ]);
11
- const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
12
9
  /**
13
- * Checks the fields a single Lexical node carries against the schema its
14
- * feature declares for that node type.
15
- *
16
- * A node with nothing to declare, and one whose sub-fields cannot be named at
17
- * a position, are both left alone.
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(", ");
16
+ /**
17
+ * A node with nothing to declare, and one whose sub-fields cannot be named at a
18
+ * position, are both left alone.
18
19
  */ const checkNodeFields = (scope, field, node) => {
19
20
  const sub = lexicalSubSchema(field, node.type);
20
21
  if (!sub) return;
21
22
  const data = node.fields;
22
23
  if (!isPlainObject(data)) {
23
- 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.`);
24
25
  return;
25
26
  }
26
27
  const nested = {
@@ -47,48 +48,126 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
47
48
  }, data);
48
49
  };
49
50
  /**
50
- * Checks an editor state against what the field's editor can actually
51
- * produce: every node type, and the fields each node carries.
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.
52
61
  *
53
- * Payload does not: the Lexical validator runs node validations only for the
54
- * few node types that register one, so a `heading` inside a field whose
55
- * editor has no heading feature is stored without complaint and only fails
56
- * later, at render or when the document is reopened in the admin editor. A key
57
- * a node's fields do not declare is dropped just as silently. The same holds
58
- * one level down, for the node properties a feature narrows: an `h3` in an
59
- * editor restricted to `h4` is stored as readily as an `h4`.
60
- */ const checkRichText = (scope, editor, value) => {
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) => {
61
105
  if (!isPlainObject(value) || !isPlainObject(value["root"])) {
62
106
  scope.problems.push(`${scope.pointer}: expected a Lexical editor state with a "root".`);
63
107
  return;
64
108
  }
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
- for (const [property, values] of Object.entries(editor.nodeOptions?.[node["type"]] ?? {})) {
78
- const value = node[property];
79
- 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(", ")}`);
80
- }
81
- if (editor.field) checkNodeFields({
82
- ...scope,
83
- pointer: at
84
- }, editor.field, {
85
- fields: node["fields"],
86
- type: node["type"]
87
- });
88
- walk(node["children"], `${at}/children`);
89
- });
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
90
149
  };
91
- 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);
92
171
  };
93
172
  const checkLeafValue = (scope, descriptor, value) => {
94
173
  if (descriptor.readOnly) {
@@ -126,8 +205,7 @@ const checkLeafValue = (scope, descriptor, value) => {
126
205
  });
127
206
  };
128
207
  /**
129
- * Walks an incoming value against the schema, reporting every shape problem
130
- * rather than the first.
208
+ * Reports every shape problem rather than the first.
131
209
  *
132
210
  * Shape only: unknown field names, unknown block slugs, read-only fields, and
133
211
  * rich text nodes or node properties the field's editor cannot produce.
@@ -197,6 +275,10 @@ const checkLeafValue = (scope, descriptor, value) => {
197
275
  prefix: target.resolution.prefix,
198
276
  problems
199
277
  };
278
+ if (target.resolution.lexical) {
279
+ checkLexicalWrite(scope, target.resolution.lexical, value);
280
+ return problems;
281
+ }
200
282
  if (target.resolution.descriptor) {
201
283
  checkLeafValue(scope, target.resolution.descriptor, value);
202
284
  return problems;
@@ -205,4 +287,4 @@ const checkLeafValue = (scope, descriptor, value) => {
205
287
  return problems;
206
288
  };
207
289
  //#endregion
208
- export { validateWriteValue };
290
+ export { EMPTY_ROOT, validateWriteValue };
@@ -2,50 +2,39 @@ import { allowedNodeTypes, nodeOptions } from "./lexical.mjs";
2
2
  import { translateAny } from "../i18n.mjs";
3
3
  import { fieldIsHiddenOrDisabled, fieldIsVirtual } from "payload/shared";
4
4
  //#region src/schema/walk.ts
5
- /**
6
- * Fields Payload maintains, which a client may neither address nor supply.
7
- */ const RESERVED_FIELD_NAMES = /* @__PURE__ */ new Set([
5
+ const RESERVED_FIELD_NAMES = /* @__PURE__ */ new Set([
8
6
  "_status",
9
7
  "createdAt",
10
8
  "deletedAt",
11
9
  "id",
12
10
  "updatedAt"
13
11
  ]);
12
+ const JSON_POINTER_PATTERN = /^(\/([^~/]|~[01])*)*$/;
13
+ /** No segments is the root pointer, `""`. */ const joinPath = (parts) => parts.map((part) => `/${part.replace(/~/g, "~0").replace(/\//g, "~1")}`).join("");
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);
14
17
  /**
15
- * Shape a JSON Pointer must have to be parseable at all.
16
- */ const JSON_POINTER_PATTERN = /^(\/([^~/]|~[01])*)*$/;
17
- /**
18
- * Joins segments into a JSON Pointer, so the segments `items`, `*`, `title`
19
- * read as one path to a subfield of every element of `items`. No segments is
20
- * the root pointer, `""`.
21
- */ const joinPath = (parts) => parts.map((part) => `/${part.replace(/~/g, "~0").replace(/\//g, "~1")}`).join("");
22
- /**
23
- * Splits a JSON Pointer into its segments, unescaping `~1` and `~0`. The root
24
- * pointer yields no segments.
25
- */ const splitPath = (path) => path.split("/").slice(1).map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
26
- /**
27
- * Restates a path Payload reports on a validation error (`layout.0.title`) as
28
- * a JSON Pointer, so everything this plugin hands back addresses documents the
29
- * same way. Payload's path already carries real indices, so it maps directly.
18
+ * A path Payload reports on a validation error (`layout.0.title`) as a JSON
19
+ * Pointer, so everything handed back addresses documents the same way. The
20
+ * path already carries real indices, so it maps directly.
30
21
  */ const pointerFromPayloadPath = (path) => path ? joinPath(path.split(".")) : "";
22
+ /** On a flattened field, whichever of `blockReferences` and `blocks` was declared. */ const blockSlugsOf = (field) => [...new Set((field.blockReferences ?? field.blocks).map((block) => typeof block === "string" ? block : block.slug))];
31
23
  /**
32
- * Blocks a blocks field accepts, by slug. On a flattened field, whichever of
33
- * `blockReferences` and `blocks` was declared carries the definitions.
34
- */ const blockSlugsOf = (field) => [...new Set((field.blockReferences ?? field.blocks).map((block) => typeof block === "string" ? block : block.slug))];
35
- /**
36
- * Resolves one of a blocks field's slugs to its definition.
37
- *
38
- * A definition inlined on the field wins over the shared registry. A block's
39
- * own fields are identical wherever it appears, but the blocks its children
40
- * accept are not, so an inline definition has to be read at its position.
41
- * The registry (`config.blocks`) is the fallback for slugs referenced by name.
24
+ * An inlined definition wins over the registry (`config.blocks`). A block's own
25
+ * fields are identical wherever it appears, but the blocks its children accept
26
+ * are not, so an inline definition has to be read at its position.
42
27
  */ const blockOf = (config, field, slug) => {
43
28
  const declared = field.blockReferences ?? field.blocks;
44
29
  const inline = declared.find((block) => typeof block !== "string" && block.slug === slug);
45
30
  if (inline) return inline;
46
31
  return declared.includes(slug) ? config.blocks?.find((block) => block.slug === slug) : void 0;
47
32
  };
48
- const isSkipped = (field) => !("name" in field) || field.type === "join" || RESERVED_FIELD_NAMES.has(field.name) || fieldIsVirtual(field) || fieldIsHiddenOrDisabled(field);
33
+ /**
34
+ * Payload's `fieldIsHiddenOrDisabled` reads `hidden` and `admin.disabled`, not
35
+ * `admin.hidden`, which is what its own upload base fields carry.
36
+ */ const isAdminHidden = (field) => "admin" in field && field.admin.hidden === true;
37
+ /** A field kept out of the admin panel is kept out of the MCP surface too. */ const isSkipped = (field) => !("name" in field) || field.type === "join" || RESERVED_FIELD_NAMES.has(field.name) || fieldIsVirtual(field) || isAdminHidden(field) || fieldIsHiddenOrDisabled(field);
49
38
  const isReadOnly = (field) => "admin" in field && field.admin.readOnly === true;
50
39
  const describeBase = (field, { path, readOnly, translate }) => {
51
40
  const description = translate("admin" in field ? field.admin.description : void 0);
@@ -80,11 +69,9 @@ const withRows = (descriptor, field) => ({
80
69
  ...field.maxRows === void 0 ? {} : { maxRows: field.maxRows }
81
70
  });
82
71
  /**
83
- * Whether a descriptor stands for a construct that only holds other fields.
84
- *
85
- * These describe a position rather than a value, so everything that resolves a
86
- * path to something writable skips them; only {@link describeNode} reports
87
- * them, to carry what the container itself declares.
72
+ * A container describes a position rather than a value, so everything resolving
73
+ * a path to something writable skips it. Only {@link nodeDescriber} reports one,
74
+ * to carry what the container itself declares.
88
75
  */ const isContainer = (descriptor) => descriptor.type === "array" || descriptor.type === "group" || descriptor.type === "tab";
89
76
  /**
90
77
  * Whether a container declares anything a client could not infer from the
@@ -93,20 +80,16 @@ const withRows = (descriptor, field) => ({
93
80
  /**
94
81
  * Flattens a field list into descriptors addressed relative to the node.
95
82
  *
96
- * The input is Payload's own flattened shape, which has already merged every
97
- * construct that exists only in the admin UI (unnamed tabs, unnamed groups,
98
- * `row`, `collapsible`) and dropped `ui` fields. Named tabs, groups and
99
- * arrays contribute a path segment, and are described in their own right when
100
- * they declare something of their own: an array always, since its row counts
101
- * live nowhere else, a group or tab only when it carries a description or a
102
- * constraint. The walk stops at every blocks field and names the slugs instead
103
- * of descending, which keeps a node proportional to the number of blocks it
104
- * allows rather than to the size of their definitions.
83
+ * The input is Payload's own flattened shape, so the admin-only constructs
84
+ * (unnamed tabs and groups, `row`, `collapsible`, `ui`) are already gone. Named
85
+ * tabs, groups and arrays contribute a path segment, and are described in their
86
+ * own right when they declare something of their own: an array always, since
87
+ * its row counts live nowhere else, a group or tab only when it carries a
88
+ * description or a constraint. The walk stops at every blocks field and names
89
+ * the slugs, which keeps a node proportional to the number of blocks it allows
90
+ * rather than to the size of their definitions.
105
91
  *
106
- * `translate` resolves each `admin.description` to the request's language.
107
- * Callers that walk for paths alone leave it out and get the language-agnostic
108
- * default, so a missing argument costs language selection, never the
109
- * description itself.
92
+ * Omitting `translate` costs language selection, never the description itself.
110
93
  */ const describeFields = (fields, translate = translateAny) => {
111
94
  const walk = (current, prefix, parentReadOnly) => current.flatMap((field) => {
112
95
  if (isSkipped(field)) return [];
@@ -133,33 +116,30 @@ const withRows = (descriptor, field) => ({
133
116
  /**
134
117
  * The descriptors that address a value, which is what every walk resolving a
135
118
  * path against a document needs. A container describes a position rather than
136
- * a value, so only {@link describeNode} reports one.
119
+ * a value, so only {@link nodeDescriber} reports one.
137
120
  */ const describeAddressableFields = (fields) => describeFields(fields).filter((descriptor) => !isContainer(descriptor));
138
- /**
139
- * Locates the blocks field that a resolved descriptor path refers to.
140
- */ const findBlocksField = (fields, path) => {
121
+ const findFieldAt = (fields, path, type) => {
141
122
  for (const field of fields) {
142
123
  if (!("name" in field) || field.name !== path[0]) continue;
143
- if (field.type === "blocks" && path.length === 1) return field;
144
- if (field.type === "tab" || field.type === "group") return findBlocksField(field.flattenedFields, path.slice(1));
145
- if (field.type === "array" && path[1] === "*") return findBlocksField(field.flattenedFields, path.slice(2));
124
+ if (field.type === type && path.length === 1) return field;
125
+ if (field.type === "tab" || field.type === "group") return findFieldAt(field.flattenedFields, path.slice(1), type);
126
+ if (field.type === "array" && path[1] === "*") return findFieldAt(field.flattenedFields, path.slice(2), type);
146
127
  }
147
128
  };
129
+ const findBlocksField = (fields, path) => findFieldAt(fields, path, "blocks");
148
130
  /**
149
131
  * Locates the rich text field that a resolved descriptor path refers to, so
150
132
  * its editor can be introspected for the fields its nodes carry.
151
- */ const findRichTextField = (fields, path) => {
152
- for (const field of fields) {
153
- if (!("name" in field) || field.name !== path[0]) continue;
154
- if (field.type === "richText" && path.length === 1) return field;
155
- if (field.type === "tab" || field.type === "group") return findRichTextField(field.flattenedFields, path.slice(1));
156
- if (field.type === "array" && path[1] === "*") return findRichTextField(field.flattenedFields, path.slice(2));
157
- }
158
- };
159
- const targetOf = (config, ref) => {
133
+ */ const findRichTextField = (fields, path) => findFieldAt(fields, path, "richText");
134
+ /**
135
+ * Looks up the sanitized config for a collection or global, as a
136
+ * {@link SchemaTarget}. Throws on an unknown slug rather than returning
137
+ * undefined, because a reference reaching here has already been checked against
138
+ * the key's capabilities and a miss means the config changed underneath it.
139
+ */ const targetOf = (config, ref) => {
160
140
  const found = ref.kind === "collection" ? config.collections.find((candidate) => candidate.slug === ref.slug) : config.globals.find((candidate) => candidate.slug === ref.slug);
161
141
  if (!found) throw new Error(`Unknown ${ref.kind} "${ref.slug}".`);
162
142
  return found;
163
143
  };
164
144
  //#endregion
165
- 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 };