@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.
- package/CHANGELOG.md +33 -0
- package/README.md +331 -194
- package/dist/api-keys/collection.mjs +3 -3
- package/dist/api-keys/fields.mjs +56 -5
- package/dist/api-keys/setup-guide.mjs +56 -0
- package/dist/auth/resolve.mjs +5 -7
- package/dist/capabilities.mjs +23 -4
- package/dist/client/index.d.mts +2 -0
- package/dist/client/index.mjs +2 -0
- package/dist/client/setup-guide.d.mts +14 -0
- package/dist/client/setup-guide.mjs +87 -0
- package/dist/endpoint/{result.mjs → errors.mjs} +4 -23
- package/dist/endpoint/handler.mjs +11 -5
- package/dist/endpoint/index.mjs +4 -0
- package/dist/endpoint/server.mjs +18 -26
- package/dist/i18n.mjs +4 -15
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +4 -3
- package/dist/options.mjs +31 -26
- package/dist/plugin.mjs +1 -0
- package/dist/{write/draft-guard.d.mts → request.d.mts} +2 -2
- package/dist/request.mjs +8 -0
- package/dist/result.d.mts +11 -0
- package/dist/result.mjs +20 -0
- package/dist/schema/describe.mjs +3 -15
- package/dist/schema/index.mjs +8 -0
- package/dist/schema/lexical-pointer.mjs +125 -0
- package/dist/schema/lexical.mjs +195 -27
- package/dist/schema/outline.mjs +67 -0
- package/dist/schema/pointer.mjs +77 -30
- package/dist/schema/shape.mjs +133 -51
- package/dist/schema/walk.mjs +44 -64
- package/dist/tools/{index.mjs → builtin.mjs} +8 -5
- package/dist/tools/create-document.mjs +34 -15
- package/dist/tools/describe-schema.mjs +21 -7
- package/dist/tools/find-documents.mjs +13 -6
- package/dist/tools/get-document.mjs +45 -11
- package/dist/tools/list-capabilities.mjs +19 -9
- package/dist/tools/names.mjs +2 -1
- package/dist/tools/patch-document.mjs +32 -21
- package/dist/tools/publish-document.mjs +79 -0
- package/dist/tools/shared.mjs +84 -32
- package/dist/tools/target.mjs +7 -11
- package/dist/tools/validate-document.mjs +20 -12
- package/dist/types.d.mts +115 -42
- package/dist/types.mjs +3 -4
- package/dist/version.mjs +1 -1
- package/dist/write/draft-guard.mjs +47 -44
- package/dist/write/patch.mjs +174 -92
- package/dist/write/publish-blockers.mjs +13 -12
- package/dist/write/publish-intent.mjs +17 -0
- package/dist/write/transaction.mjs +8 -3
- package/package.json +24 -9
- package/dist/i18n.d.mts +0 -1
- package/dist/options.d.mts +0 -2
- package/dist/schema/lexical.d.mts +0 -1
- package/dist/schema/walk.d.mts +0 -3
- package/dist/tools/target.d.mts +0 -3
- package/dist/tools/types.d.mts +0 -5
- 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 };
|
package/dist/schema/pointer.mjs
CHANGED
|
@@ -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
|
-
*
|
|
24
|
-
*
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
121
|
+
...inLexical === void 0 ? {} : { inLexical },
|
|
122
|
+
prefix: [],
|
|
123
|
+
...readOnly === void 0 ? {} : { readOnly }
|
|
77
124
|
};
|
|
78
125
|
};
|
|
79
126
|
//#endregion
|
package/dist/schema/shape.mjs
CHANGED
|
@@ -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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
|
|
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
|
-
*
|
|
51
|
-
*
|
|
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
|
|
54
|
-
* few node types that register one, so a `heading`
|
|
55
|
-
* editor has no heading feature is stored without
|
|
56
|
-
* later, at render or when the document is reopened
|
|
57
|
-
* a node's fields do not declare is dropped just as
|
|
58
|
-
* one level down, for the node properties a feature
|
|
59
|
-
* editor restricted to `h4` is stored as readily as an
|
|
60
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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 };
|
package/dist/schema/walk.mjs
CHANGED
|
@@ -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
|
-
*
|
|
16
|
-
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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,
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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`
|
|
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
|
|
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 ===
|
|
144
|
-
if (field.type === "tab" || field.type === "group") return
|
|
145
|
-
if (field.type === "array" && path[1] === "*") return
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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 };
|