@abinnovision/payloadcms-mcpx 1.0.0-beta.5 → 1.0.0-beta.7
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 +25 -4
- package/dist/schema/describe.mjs +102 -22
- package/dist/schema/lexical.d.mts +1 -0
- package/dist/schema/lexical.mjs +94 -2
- package/dist/schema/pointer.mjs +2 -2
- package/dist/schema/shape.mjs +81 -20
- package/dist/schema/walk.d.mts +1 -0
- package/dist/schema/walk.mjs +49 -8
- package/dist/tools/describe-schema.mjs +2 -0
- package/dist/write/patch.mjs +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ small and static: collection slugs, locales and operations as enums, everything
|
|
|
10
10
|
else scalars. The field shapes are pulled on demand through `describeSchema`,
|
|
11
11
|
one node at a time, stopping at every blocks boundary. And every write is
|
|
12
12
|
resolved server-side against the real config and the real document, so unknown
|
|
13
|
-
fields, misplaced blocks and unusable rich text nodes are refused with the
|
|
13
|
+
fields, misplaced blocks and unusable rich text nodes or node fields are refused with the
|
|
14
14
|
valid alternatives listed, never silently dropped.
|
|
15
15
|
|
|
16
16
|
Writes are RFC 6902 patches that always land as drafts; publishing stays a
|
|
@@ -142,6 +142,25 @@ Rules the tools enforce and explain in their own descriptions:
|
|
|
142
142
|
accept; every node carries `next`, the ready-to-use paths for those blocks
|
|
143
143
|
(`/layout/sections/sectionWrapper`), so pass an entry of `next` as a `paths`
|
|
144
144
|
element to descend. A block is described as it exists at that position.
|
|
145
|
+
- Rich text paths continue the same way. A `richText` field lists the Lexical
|
|
146
|
+
node types it accepts in `nodes`, and `next` carries a path for every node
|
|
147
|
+
type that holds fields of its own: `/content/link` for a link node,
|
|
148
|
+
`/content/block/callout` and `/content/inlineBlock/badge` for the block
|
|
149
|
+
nodes. Descending returns the real field list, so a link extended through
|
|
150
|
+
`LinkFeature({ fields })` and a Lexical block are both described rather than
|
|
151
|
+
guessed. Any feature declaring `getSubFields` is picked up, custom ones
|
|
152
|
+
included. `upload` nodes are the exception: their fields depend on the
|
|
153
|
+
collection the node points at, so they are not addressable.
|
|
154
|
+
- Constraints a field declares travel with it: `minRows`/`maxRows` on arrays
|
|
155
|
+
and blocks fields, `maxLength`/`minLength` on text, `min`/`max` on numbers.
|
|
156
|
+
An array is described in its own right, so the `*` in `/items/*/title` has
|
|
157
|
+
something to read; a group or named tab only when it declares a description
|
|
158
|
+
or a constraint of its own.
|
|
159
|
+
- A `richText` field also reports `nodeOptions`, the node properties its editor
|
|
160
|
+
narrows. An editor built with `HeadingFeature({ enabledHeadingSizes: ["h4"] })`
|
|
161
|
+
answers `{ "heading": { "tag": ["h4"] } }`, and a write carrying any other
|
|
162
|
+
heading tag is refused. Lexical stores whatever tag it is given, so this is
|
|
163
|
+
the only place the restriction is checked.
|
|
145
164
|
- Field and collection `admin.description` values are included in
|
|
146
165
|
`describeSchema` and `listCapabilities`, so intent written for the admin
|
|
147
166
|
panel reaches the client. Strings and locale-keyed records pass through;
|
|
@@ -302,9 +321,11 @@ key of every user.
|
|
|
302
321
|
|
|
303
322
|
## Non-goals of v1 / roadmap
|
|
304
323
|
|
|
305
|
-
Deletes, uploads, markdown authoring for rich text,
|
|
306
|
-
|
|
307
|
-
|
|
324
|
+
Deletes, uploads, markdown authoring for rich text, addressing a rich text node
|
|
325
|
+
by position in a patch (an editor state is written whole), schemas for `upload`
|
|
326
|
+
node fields, row addressing by id instead of index, cross-locale publish
|
|
327
|
+
blockers, pagination of `describeSchema` with `expand`, and a handler-level
|
|
328
|
+
timeout are all deliberate omissions for now.
|
|
308
329
|
|
|
309
330
|
## License
|
|
310
331
|
|
package/dist/schema/describe.mjs
CHANGED
|
@@ -1,32 +1,86 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { lexicalSubSchema, subSchemaNodeTypes } from "./lexical.mjs";
|
|
2
|
+
import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextField, joinPath, splitPath, targetOf } from "./walk.mjs";
|
|
2
3
|
//#region src/schema/describe.ts
|
|
3
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The longest descriptor path that is a prefix of `remaining`. Blocks and rich
|
|
6
|
+
* text fields are both leaves of the walk, so at most one can match.
|
|
7
|
+
*/ const longestMatch = (descriptors, remaining) => descriptors.map((descriptor) => splitPath(descriptor.path)).filter((parts) => parts.every((part, offset) => part === remaining[offset])).sort((left, right) => right.length - left.length)[0];
|
|
8
|
+
/**
|
|
9
|
+
* Walks one step of a schema path through a blocks field.
|
|
10
|
+
*/ const stepThroughBlocks = ({ config, fields, match, remaining }) => {
|
|
11
|
+
const field = findBlocksField(fields, match);
|
|
12
|
+
if (!field) throw new Error(`"${joinPath(match)}" could not be resolved.`);
|
|
13
|
+
const slug = remaining.at(match.length);
|
|
14
|
+
if (slug === void 0) throw new Error(`"${joinPath(match)}" is a blocks field; append one of: ${blockSlugsOf(field).join(", ")}`);
|
|
15
|
+
const block = blockOf(config, field, slug);
|
|
16
|
+
if (!block) throw new Error(`"${slug}" is not allowed at "${joinPath(match)}". Allowed: ${blockSlugsOf(field).join(", ")}`);
|
|
17
|
+
return {
|
|
18
|
+
blockType: slug,
|
|
19
|
+
fields: block.flattenedFields,
|
|
20
|
+
rest: remaining.slice(match.length + 1)
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Walks one step of a schema path into a Lexical node's own fields.
|
|
25
|
+
*
|
|
26
|
+
* A node that picks a block by slug takes one segment more, so `/content/block`
|
|
27
|
+
* addresses the choice and `/content/block/callout` the definition. Everything
|
|
28
|
+
* else, a link node being the usual case, resolves in a single segment.
|
|
29
|
+
*/ const stepThroughLexical = ({ config, fields, match, remaining }) => {
|
|
30
|
+
const field = findRichTextField(fields, match);
|
|
31
|
+
if (!field) throw new Error(`"${joinPath(match)}" could not be resolved.`);
|
|
32
|
+
const available = subSchemaNodeTypes(field).join(", ") || "none";
|
|
33
|
+
const nodeType = remaining.at(match.length);
|
|
34
|
+
if (nodeType === void 0) throw new Error(`"${joinPath(match)}" is a rich text field; append one of: ${available}`);
|
|
35
|
+
const sub = lexicalSubSchema(field, nodeType);
|
|
36
|
+
const reached = joinPath([...match, nodeType]);
|
|
37
|
+
if (!sub) throw new Error(`"${nodeType}" carries no fields in this field's editor. Node types with fields here: ${available}`);
|
|
38
|
+
if (sub.kind === "fields") return {
|
|
39
|
+
fields: sub.fields,
|
|
40
|
+
rest: remaining.slice(match.length + 1)
|
|
41
|
+
};
|
|
42
|
+
const slug = remaining.at(match.length + 1);
|
|
43
|
+
const slugs = blockSlugsOf(sub.blocksField).join(", ");
|
|
44
|
+
if (slug === void 0) throw new Error(`"${reached}" selects a block; append one of: ${slugs}`);
|
|
45
|
+
const block = blockOf(config, sub.blocksField, slug);
|
|
46
|
+
if (!block) throw new Error(`"${slug}" is not allowed at "${reached}". Allowed: ${slugs}`);
|
|
47
|
+
return {
|
|
48
|
+
blockType: slug,
|
|
49
|
+
fields: block.flattenedFields,
|
|
50
|
+
rest: remaining.slice(match.length + 2)
|
|
51
|
+
};
|
|
52
|
+
};
|
|
4
53
|
/**
|
|
5
54
|
* Walks a schema path to the field list it addresses.
|
|
6
55
|
*
|
|
7
56
|
* A schema path alternates a blocks field's own path with the slug of one of
|
|
8
57
|
* the blocks it accepts, so `/layout/sections/sectionWrapper/modules/hero`
|
|
9
58
|
* reaches `hero` as it exists under `pages` specifically. The slug sits where
|
|
10
|
-
* a pointer into a document would carry the element's index.
|
|
59
|
+
* a pointer into a document would carry the element's index. A rich text
|
|
60
|
+
* field's path continues the same way, naming a Lexical node type and, for the
|
|
61
|
+
* block nodes, the slug it holds.
|
|
11
62
|
*/ const fieldsAtSchemaPath = (config, target, schemaPath) => {
|
|
12
63
|
let fields = target.flattenedFields;
|
|
13
64
|
let blockType;
|
|
14
65
|
let remaining = splitPath(schemaPath);
|
|
15
66
|
while (remaining.length > 0) {
|
|
67
|
+
const descendable = describeFields(fields).filter((descriptor) => descriptor.type === "blocks" || descriptor.type === "richText");
|
|
16
68
|
/**
|
|
17
|
-
* A
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
69
|
+
* A field's own path may span several segments (`/layout/sections`), so
|
|
70
|
+
* the longest matching one is taken. Blocks and rich text fields are both
|
|
71
|
+
* leaves of the walk, so no two of these paths overlap.
|
|
72
|
+
*/ const match = longestMatch(descendable, remaining);
|
|
73
|
+
if (!match) throw new Error(`"${joinPath(remaining)}" does not address a blocks or rich text field. Available here: ${descendable.map((descriptor) => descriptor.path).join(", ") || "none"}`);
|
|
74
|
+
const at = {
|
|
75
|
+
config,
|
|
76
|
+
fields,
|
|
77
|
+
match,
|
|
78
|
+
remaining
|
|
79
|
+
};
|
|
80
|
+
const step = findBlocksField(fields, match) === void 0 ? stepThroughLexical(at) : stepThroughBlocks(at);
|
|
81
|
+
blockType = step.blockType;
|
|
82
|
+
fields = step.fields;
|
|
83
|
+
remaining = step.rest;
|
|
30
84
|
}
|
|
31
85
|
return {
|
|
32
86
|
...blockType === void 0 ? {} : { blockType },
|
|
@@ -34,12 +88,37 @@ const blocksDescriptors = (fields) => describeFields(fields).filter((descriptor)
|
|
|
34
88
|
};
|
|
35
89
|
};
|
|
36
90
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
91
|
+
* Where a descriptor can be drilled into: one branch per block a blocks field
|
|
92
|
+
* accepts, and one per Lexical node type that carries fields.
|
|
93
|
+
*/ const branchesOf = (fields, descriptor, schemaPath) => {
|
|
94
|
+
const base = `${schemaPath}${descriptor.path}`;
|
|
95
|
+
if (descriptor.type === "richText") {
|
|
96
|
+
const field = findRichTextField(fields, splitPath(descriptor.path));
|
|
97
|
+
if (!field) return [];
|
|
98
|
+
return subSchemaNodeTypes(field).flatMap((nodeType) => {
|
|
99
|
+
const sub = lexicalSubSchema(field, nodeType);
|
|
100
|
+
if (sub?.kind !== "blocks") return [{
|
|
101
|
+
path: `${base}/${nodeType}`,
|
|
102
|
+
token: `lexical:${nodeType}`
|
|
103
|
+
}];
|
|
104
|
+
return blockSlugsOf(sub.blocksField).map((slug) => ({
|
|
105
|
+
path: `${base}/${nodeType}/${slug}`,
|
|
106
|
+
token: `lexical:${nodeType}:${slug}`
|
|
107
|
+
}));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return (descriptor.blocks ?? []).map((slug) => ({
|
|
111
|
+
path: `${base}/${slug}`,
|
|
112
|
+
token: slug
|
|
113
|
+
}));
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Describes a collection or global root, one block reached through a schema
|
|
117
|
+
* path, or the fields a Lexical node carries.
|
|
39
118
|
*/ const describeNode = (config, ref, schemaPath = "") => {
|
|
40
119
|
const { blockType, fields } = fieldsAtSchemaPath(config, targetOf(config, ref), schemaPath);
|
|
41
120
|
const descriptors = describeFields(fields);
|
|
42
|
-
const next = descriptors.flatMap((descriptor) => (descriptor
|
|
121
|
+
const next = descriptors.flatMap((descriptor) => branchesOf(fields, descriptor, schemaPath).map((branch) => branch.path));
|
|
43
122
|
return {
|
|
44
123
|
...blockType === void 0 ? {} : { blockType },
|
|
45
124
|
...ref.kind === "collection" ? { collection: ref.slug } : { global: ref.slug },
|
|
@@ -61,9 +140,10 @@ const blocksDescriptors = (fields) => describeFields(fields).filter((descriptor)
|
|
|
61
140
|
return;
|
|
62
141
|
}
|
|
63
142
|
seen.push(schemaPath);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
143
|
+
const { fields } = fieldsAtSchemaPath(config, targetOf(config, ref), schemaPath);
|
|
144
|
+
for (const descriptor of describeFields(fields)) for (const branch of branchesOf(fields, descriptor, schemaPath)) {
|
|
145
|
+
if (visited.includes(branch.token)) continue;
|
|
146
|
+
walk(branch.path, [...visited, branch.token]);
|
|
67
147
|
}
|
|
68
148
|
};
|
|
69
149
|
walk("", []);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "payload";
|
package/dist/schema/lexical.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { flattenAllFields } from "payload";
|
|
1
2
|
//#region src/schema/lexical.ts
|
|
2
3
|
/**
|
|
3
4
|
* Node types Lexical registers itself.
|
|
@@ -12,14 +13,105 @@
|
|
|
12
13
|
"tab"
|
|
13
14
|
];
|
|
14
15
|
/**
|
|
16
|
+
* Node types whose sub-fields exist but cannot be addressed by a schema path.
|
|
17
|
+
*
|
|
18
|
+
* Asked without a node, `upload` answers with every enabled collection's
|
|
19
|
+
* upload fields concatenated, so the result describes no single position. It
|
|
20
|
+
* would need addressing by `relationTo` to mean anything.
|
|
21
|
+
*/ const OPAQUE_NODE_TYPES = /* @__PURE__ */ new Set(["upload"]);
|
|
22
|
+
/**
|
|
23
|
+
* Sub-schemas per rich text field, keyed by node type. `null` records a node
|
|
24
|
+
* type that was asked and has nothing to describe, so it is asked only once.
|
|
25
|
+
*
|
|
26
|
+
* Worth caching because the describe and validate paths resolve the same field
|
|
27
|
+
* repeatedly, and because the block features build their answer from scratch on
|
|
28
|
+
* every call. Keyed weakly on the sanitized field, which lives as long as the
|
|
29
|
+
* config does.
|
|
30
|
+
*/ const subSchemaCache = /* @__PURE__ */ new WeakMap();
|
|
31
|
+
const featuresOf = (field) => field.editor?.editorConfig?.features;
|
|
32
|
+
/**
|
|
15
33
|
* Node types a rich text field accepts. Editors other than Lexical report
|
|
16
34
|
* only the core nodes.
|
|
17
35
|
*/ const allowedNodeTypes = (field) => {
|
|
18
|
-
const registered = (field
|
|
36
|
+
const registered = (featuresOf(field)?.nodes ?? []).flatMap((entry) => {
|
|
19
37
|
const type = entry.node?.getType?.();
|
|
20
38
|
return type ? [type] : [];
|
|
21
39
|
});
|
|
22
40
|
return [.../* @__PURE__ */ new Set([...LEXICAL_CORE_NODES, ...registered])];
|
|
23
41
|
};
|
|
42
|
+
const resolveSubSchema = (field, nodeType) => {
|
|
43
|
+
if (OPAQUE_NODE_TYPES.has(nodeType)) return null;
|
|
44
|
+
const fields = featuresOf(field)?.getSubFields?.get(nodeType)?.({});
|
|
45
|
+
if (!fields?.length) return null;
|
|
46
|
+
const flattened = flattenAllFields({ fields });
|
|
47
|
+
const only = flattened.length === 1 ? flattened[0] : void 0;
|
|
48
|
+
return only?.type === "blocks" ? {
|
|
49
|
+
blocksField: only,
|
|
50
|
+
kind: "blocks"
|
|
51
|
+
} : {
|
|
52
|
+
fields: flattened,
|
|
53
|
+
kind: "fields"
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The sub-schema behind one node type of a rich text field, or `undefined`
|
|
58
|
+
* when that node carries no addressable fields.
|
|
59
|
+
*/ const lexicalSubSchema = (field, nodeType) => {
|
|
60
|
+
let cached = subSchemaCache.get(field);
|
|
61
|
+
if (!cached) {
|
|
62
|
+
cached = /* @__PURE__ */ new Map();
|
|
63
|
+
subSchemaCache.set(field, cached);
|
|
64
|
+
}
|
|
65
|
+
if (!cached.has(nodeType)) cached.set(nodeType, resolveSubSchema(field, nodeType));
|
|
66
|
+
return cached.get(nodeType) ?? void 0;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Node types of a rich text field that have a sub-schema, in the order their
|
|
70
|
+
* features registered them.
|
|
71
|
+
*/ const subSchemaNodeTypes = (field) => [...featuresOf(field)?.getSubFields?.keys() ?? []].filter((nodeType) => lexicalSubSchema(field, nodeType) !== void 0);
|
|
72
|
+
/**
|
|
73
|
+
* Node properties worth reporting and enforcing.
|
|
74
|
+
*
|
|
75
|
+
* Only properties a feature narrows and Lexical does not check on its own
|
|
76
|
+
* belong here. Everything else a feature restricts is already visible: a
|
|
77
|
+
* link's targets through its sub-schema, a block node's choices through the
|
|
78
|
+
* slugs it accepts.
|
|
79
|
+
*/ const NODE_OPTION_SOURCES = [{
|
|
80
|
+
defaults: [
|
|
81
|
+
"h1",
|
|
82
|
+
"h2",
|
|
83
|
+
"h3",
|
|
84
|
+
"h4",
|
|
85
|
+
"h5",
|
|
86
|
+
"h6"
|
|
87
|
+
],
|
|
88
|
+
featureKey: "heading",
|
|
89
|
+
featureProp: "enabledHeadingSizes",
|
|
90
|
+
nodeProp: "tag",
|
|
91
|
+
nodeType: "heading"
|
|
92
|
+
}];
|
|
93
|
+
/**
|
|
94
|
+
* The props a feature was resolved with.
|
|
95
|
+
*
|
|
96
|
+
* Sanitizing the editor drops every feature's props from `editorConfig.features`
|
|
97
|
+
* but leaves them on `resolvedFeatureMap`. A feature that declares no server
|
|
98
|
+
* props keeps only the client ones, so both are tried.
|
|
99
|
+
*/ const featurePropsOf = (field, featureKey) => {
|
|
100
|
+
const resolved = field.editor?.editorConfig?.resolvedFeatureMap?.get(featureKey);
|
|
101
|
+
const props = resolved?.sanitizedServerFeatureProps ?? resolved?.clientFeatureProps;
|
|
102
|
+
return typeof props === "object" && props !== null ? props : void 0;
|
|
103
|
+
};
|
|
104
|
+
const stringList = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value : void 0;
|
|
105
|
+
/**
|
|
106
|
+
* The narrowed node properties of a rich text field, for the node types it
|
|
107
|
+
* actually accepts.
|
|
108
|
+
*/ const nodeOptions = (field, allowed) => {
|
|
109
|
+
const entries = NODE_OPTION_SOURCES.flatMap((source) => {
|
|
110
|
+
if (!allowed.includes(source.nodeType)) return [];
|
|
111
|
+
const values = stringList(featurePropsOf(field, source.featureKey)?.[source.featureProp]) ?? [...source.defaults];
|
|
112
|
+
return [[source.nodeType, { [source.nodeProp]: values }]];
|
|
113
|
+
});
|
|
114
|
+
return entries.length > 0 ? Object.fromEntries(entries) : void 0;
|
|
115
|
+
};
|
|
24
116
|
//#endregion
|
|
25
|
-
export { allowedNodeTypes };
|
|
117
|
+
export { allowedNodeTypes, lexicalSubSchema, nodeOptions, subSchemaNodeTypes };
|
package/dist/schema/pointer.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { blockOf, blockSlugsOf,
|
|
1
|
+
import { blockOf, blockSlugsOf, describeAddressableFields, findBlocksField, joinPath, splitPath, targetOf } from "./walk.mjs";
|
|
2
2
|
//#region src/schema/pointer.ts
|
|
3
3
|
const isIndexSegment = (segment) => segment === "-" || /^\d+$/.test(segment);
|
|
4
4
|
const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isIndexSegment(segment) : part === segment);
|
|
@@ -37,7 +37,7 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
|
|
|
37
37
|
let blockType;
|
|
38
38
|
let segments = splitPath(target.pointer);
|
|
39
39
|
while (segments.length > 0) {
|
|
40
|
-
const descriptors =
|
|
40
|
+
const descriptors = describeAddressableFields(fields);
|
|
41
41
|
const match = longestMatch(descriptors, segments);
|
|
42
42
|
if (!match) {
|
|
43
43
|
if (isSubtreePrefix(descriptors, segments)) return {
|
package/dist/schema/shape.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { lexicalSubSchema } from "./lexical.mjs";
|
|
2
|
+
import { blockOf, blockSlugsOf, describeAddressableFields, findBlocksField, findRichTextField, splitPath } from "./walk.mjs";
|
|
2
3
|
//#region src/schema/shape.ts
|
|
3
4
|
/**
|
|
4
5
|
* Keys Payload manages on a row that a client may echo back harmlessly.
|
|
@@ -9,30 +10,85 @@ import { blockOf, blockSlugsOf, describeFields, findBlocksField, splitPath } fro
|
|
|
9
10
|
]);
|
|
10
11
|
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11
12
|
/**
|
|
12
|
-
* Checks
|
|
13
|
-
*
|
|
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.
|
|
18
|
+
*/ const checkNodeFields = (scope, field, node) => {
|
|
19
|
+
const sub = lexicalSubSchema(field, node.type);
|
|
20
|
+
if (!sub) return;
|
|
21
|
+
const data = node.fields;
|
|
22
|
+
if (!isPlainObject(data)) {
|
|
23
|
+
scope.problems.push(`${scope.pointer}: a "${node.type}" node carries a "fields" object.`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const nested = {
|
|
27
|
+
...scope,
|
|
28
|
+
pointer: `${scope.pointer}/fields`,
|
|
29
|
+
prefix: []
|
|
30
|
+
};
|
|
31
|
+
if (sub.kind === "fields") {
|
|
32
|
+
checkValue({
|
|
33
|
+
...nested,
|
|
34
|
+
fields: sub.fields
|
|
35
|
+
}, data);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const slug = data["blockType"];
|
|
39
|
+
const block = typeof slug === "string" ? blockOf(scope.config, sub.blocksField, slug) : void 0;
|
|
40
|
+
if (!block) {
|
|
41
|
+
scope.problems.push(`${scope.pointer}/fields: "${String(slug)}" is not allowed here. Allowed: ${blockSlugsOf(sub.blocksField).join(", ")}`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
checkValue({
|
|
45
|
+
...nested,
|
|
46
|
+
fields: block.flattenedFields
|
|
47
|
+
}, data);
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Checks an editor state against what the field's editor can actually
|
|
51
|
+
* produce: every node type, and the fields each node carries.
|
|
14
52
|
*
|
|
15
53
|
* Payload does not: the Lexical validator runs node validations only for the
|
|
16
54
|
* few node types that register one, so a `heading` inside a field whose
|
|
17
55
|
* editor has no heading feature is stored without complaint and only fails
|
|
18
|
-
* later, at render or when the document is reopened in the admin editor.
|
|
19
|
-
|
|
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) => {
|
|
20
61
|
if (!isPlainObject(value) || !isPlainObject(value["root"])) {
|
|
21
62
|
scope.problems.push(`${scope.pointer}: expected a Lexical editor state with a "root".`);
|
|
22
63
|
return;
|
|
23
64
|
}
|
|
24
|
-
const walk = (nodes) => {
|
|
65
|
+
const walk = (nodes, pointer) => {
|
|
25
66
|
if (!Array.isArray(nodes)) return;
|
|
26
|
-
|
|
67
|
+
nodes.forEach((node, index) => {
|
|
68
|
+
const at = `${pointer}/${String(index)}`;
|
|
27
69
|
if (!isPlainObject(node) || typeof node["type"] !== "string") {
|
|
28
|
-
scope.problems.push(`${
|
|
29
|
-
|
|
70
|
+
scope.problems.push(`${at}: every node needs a "type".`);
|
|
71
|
+
return;
|
|
30
72
|
}
|
|
31
|
-
if (!allowed.includes(node["type"]))
|
|
32
|
-
|
|
33
|
-
|
|
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
|
+
});
|
|
34
90
|
};
|
|
35
|
-
walk(value["root"]["children"]);
|
|
91
|
+
walk(value["root"]["children"], `${scope.pointer}/root/children`);
|
|
36
92
|
};
|
|
37
93
|
const checkLeafValue = (scope, descriptor, value) => {
|
|
38
94
|
if (descriptor.readOnly) {
|
|
@@ -40,7 +96,11 @@ const checkLeafValue = (scope, descriptor, value) => {
|
|
|
40
96
|
return;
|
|
41
97
|
}
|
|
42
98
|
if (descriptor.type === "richText") {
|
|
43
|
-
checkRichText(scope,
|
|
99
|
+
checkRichText(scope, {
|
|
100
|
+
allowed: descriptor.nodes ?? [],
|
|
101
|
+
field: findRichTextField(scope.fields, splitPath(descriptor.path)),
|
|
102
|
+
nodeOptions: descriptor.nodeOptions
|
|
103
|
+
}, value);
|
|
44
104
|
return;
|
|
45
105
|
}
|
|
46
106
|
if (descriptor.type !== "blocks") return;
|
|
@@ -69,15 +129,16 @@ const checkLeafValue = (scope, descriptor, value) => {
|
|
|
69
129
|
* Walks an incoming value against the schema, reporting every shape problem
|
|
70
130
|
* rather than the first.
|
|
71
131
|
*
|
|
72
|
-
* Shape only: unknown field names, unknown block slugs, read-only fields and
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* block would be
|
|
132
|
+
* Shape only: unknown field names, unknown block slugs, read-only fields, and
|
|
133
|
+
* rich text nodes or node properties the field's editor cannot produce.
|
|
134
|
+
* Required-ness, row counts, lengths, enum membership and relationship
|
|
135
|
+
* existence stay with Payload, which already checks them and reports them per
|
|
136
|
+
* field. Without this pass a misspelled field inside a new block would be
|
|
137
|
+
* stripped in silence.
|
|
77
138
|
*/ const checkValue = (scope, value) => {
|
|
78
139
|
if (!isPlainObject(value)) return;
|
|
79
140
|
const prefixParts = scope.prefix;
|
|
80
|
-
const relative =
|
|
141
|
+
const relative = describeAddressableFields(scope.fields).flatMap((descriptor) => {
|
|
81
142
|
const parts = splitPath(descriptor.path);
|
|
82
143
|
return prefixParts.every((part, offset) => part === parts[offset]) ? [{
|
|
83
144
|
descriptor,
|
package/dist/schema/walk.d.mts
CHANGED
package/dist/schema/walk.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { allowedNodeTypes } from "./lexical.mjs";
|
|
1
|
+
import { allowedNodeTypes, nodeOptions } from "./lexical.mjs";
|
|
2
2
|
import { fieldIsHiddenOrDisabled, fieldIsVirtual } from "payload/shared";
|
|
3
3
|
//#region src/schema/walk.ts
|
|
4
4
|
/**
|
|
@@ -70,7 +70,15 @@ const describeLeaf = (field, path, readOnly) => {
|
|
|
70
70
|
if (field.type === "select" || field.type === "radio") descriptor.options = field.options.map((option) => typeof option === "string" ? option : option.value);
|
|
71
71
|
if (field.type === "relationship" || field.type === "upload") descriptor.relationTo = field.relationTo;
|
|
72
72
|
if ((field.type === "select" || field.type === "relationship" || field.type === "upload") && field.hasMany === true) descriptor.hasMany = true;
|
|
73
|
-
if (field.type === "
|
|
73
|
+
if ((field.type === "text" || field.type === "textarea") && field.maxLength !== void 0) descriptor.maxLength = field.maxLength;
|
|
74
|
+
if ((field.type === "text" || field.type === "textarea") && field.minLength !== void 0) descriptor.minLength = field.minLength;
|
|
75
|
+
if (field.type === "number" && field.max !== void 0) descriptor.max = field.max;
|
|
76
|
+
if (field.type === "number" && field.min !== void 0) descriptor.min = field.min;
|
|
77
|
+
if (field.type === "richText") {
|
|
78
|
+
descriptor.nodes = allowedNodeTypes(field);
|
|
79
|
+
const options = nodeOptions(field, descriptor.nodes);
|
|
80
|
+
if (options) descriptor.nodeOptions = options;
|
|
81
|
+
}
|
|
74
82
|
return descriptor;
|
|
75
83
|
};
|
|
76
84
|
const withRows = (descriptor, field) => ({
|
|
@@ -79,20 +87,37 @@ const withRows = (descriptor, field) => ({
|
|
|
79
87
|
...field.maxRows === void 0 ? {} : { maxRows: field.maxRows }
|
|
80
88
|
});
|
|
81
89
|
/**
|
|
90
|
+
* Whether a descriptor stands for a construct that only holds other fields.
|
|
91
|
+
*
|
|
92
|
+
* These describe a position rather than a value, so everything that resolves a
|
|
93
|
+
* path to something writable skips them; only {@link describeNode} reports
|
|
94
|
+
* them, to carry what the container itself declares.
|
|
95
|
+
*/ const isContainer = (descriptor) => descriptor.type === "array" || descriptor.type === "group" || descriptor.type === "tab";
|
|
96
|
+
/**
|
|
97
|
+
* Whether a container declares anything a client could not infer from the
|
|
98
|
+
* fields beneath it. A group that exists only to nest is not worth reporting.
|
|
99
|
+
*/ const isInformative = (descriptor) => descriptor.description !== void 0 || descriptor.required === true || descriptor.localized === true;
|
|
100
|
+
/**
|
|
82
101
|
* Flattens a field list into descriptors addressed relative to the node.
|
|
83
102
|
*
|
|
84
103
|
* The input is Payload's own flattened shape, which has already merged every
|
|
85
104
|
* construct that exists only in the admin UI (unnamed tabs, unnamed groups,
|
|
86
105
|
* `row`, `collapsible`) and dropped `ui` fields. Named tabs, groups and
|
|
87
|
-
* arrays contribute a path segment
|
|
88
|
-
*
|
|
89
|
-
*
|
|
106
|
+
* arrays contribute a path segment, and are described in their own right when
|
|
107
|
+
* they declare something of their own: an array always, since its row counts
|
|
108
|
+
* live nowhere else, a group or tab only when it carries a description or a
|
|
109
|
+
* constraint. The walk stops at every blocks field and names the slugs instead
|
|
110
|
+
* of descending, which keeps a node proportional to the number of blocks it
|
|
111
|
+
* allows rather than to the size of their definitions.
|
|
90
112
|
*/ const describeFields = (fields, prefix = [], parentReadOnly = false) => fields.flatMap((field) => {
|
|
91
113
|
if (isSkipped(field)) return [];
|
|
92
114
|
const readOnly = parentReadOnly || isReadOnly(field);
|
|
93
115
|
const path = [...prefix, field.name];
|
|
94
|
-
if (field.type === "tab" || field.type === "group")
|
|
95
|
-
|
|
116
|
+
if (field.type === "tab" || field.type === "group") {
|
|
117
|
+
const own = describeBase(field, joinPath(path), readOnly);
|
|
118
|
+
return [...isInformative(own) ? [own] : [], ...describeFields(field.flattenedFields, path, readOnly)];
|
|
119
|
+
}
|
|
120
|
+
if (field.type === "array") return [withRows(describeBase(field, joinPath(path), readOnly), field), ...describeFields(field.flattenedFields, [...path, "*"], readOnly)];
|
|
96
121
|
if (field.type === "blocks") return [withRows({
|
|
97
122
|
...describeBase(field, joinPath(path), readOnly),
|
|
98
123
|
blocks: blockSlugsOf(field)
|
|
@@ -100,6 +125,11 @@ const withRows = (descriptor, field) => ({
|
|
|
100
125
|
return [describeLeaf(field, joinPath(path), readOnly)];
|
|
101
126
|
});
|
|
102
127
|
/**
|
|
128
|
+
* The descriptors that address a value, which is what every walk resolving a
|
|
129
|
+
* path against a document needs. A container describes a position rather than
|
|
130
|
+
* a value, so only {@link describeNode} reports one.
|
|
131
|
+
*/ const describeAddressableFields = (fields) => describeFields(fields).filter((descriptor) => !isContainer(descriptor));
|
|
132
|
+
/**
|
|
103
133
|
* Locates the blocks field that a resolved descriptor path refers to.
|
|
104
134
|
*/ const findBlocksField = (fields, path) => {
|
|
105
135
|
for (const field of fields) {
|
|
@@ -109,10 +139,21 @@ const withRows = (descriptor, field) => ({
|
|
|
109
139
|
if (field.type === "array" && path[1] === "*") return findBlocksField(field.flattenedFields, path.slice(2));
|
|
110
140
|
}
|
|
111
141
|
};
|
|
142
|
+
/**
|
|
143
|
+
* Locates the rich text field that a resolved descriptor path refers to, so
|
|
144
|
+
* its editor can be introspected for the fields its nodes carry.
|
|
145
|
+
*/ const findRichTextField = (fields, path) => {
|
|
146
|
+
for (const field of fields) {
|
|
147
|
+
if (!("name" in field) || field.name !== path[0]) continue;
|
|
148
|
+
if (field.type === "richText" && path.length === 1) return field;
|
|
149
|
+
if (field.type === "tab" || field.type === "group") return findRichTextField(field.flattenedFields, path.slice(1));
|
|
150
|
+
if (field.type === "array" && path[1] === "*") return findRichTextField(field.flattenedFields, path.slice(2));
|
|
151
|
+
}
|
|
152
|
+
};
|
|
112
153
|
const targetOf = (config, ref) => {
|
|
113
154
|
const found = ref.kind === "collection" ? config.collections.find((candidate) => candidate.slug === ref.slug) : config.globals.find((candidate) => candidate.slug === ref.slug);
|
|
114
155
|
if (!found) throw new Error(`Unknown ${ref.kind} "${ref.slug}".`);
|
|
115
156
|
return found;
|
|
116
157
|
};
|
|
117
158
|
//#endregion
|
|
118
|
-
export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeFields, findBlocksField, joinPath, pointerFromPayloadPath, splitPath, staticDescription, targetOf };
|
|
159
|
+
export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, pointerFromPayloadPath, splitPath, staticDescription, targetOf };
|
|
@@ -12,6 +12,8 @@ Pass exactly one of "collection" and "global". A global is a singleton: it has n
|
|
|
12
12
|
|
|
13
13
|
Call it with no "paths" to get a collection's own fields. Every "blocks" field stops there and lists the block slugs it accepts instead of nesting them; each node's "next" lists the ready-to-use paths for those blocks, so pass any entry of "next" as a "paths" element to descend, e.g. "/layout/sections/sectionWrapper" and then "/layout/sections/sectionWrapper/modules/hero". A block is described as it exists at that position, because the same block can accept different children elsewhere.
|
|
14
14
|
|
|
15
|
+
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.
|
|
16
|
+
|
|
15
17
|
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".
|
|
16
18
|
|
|
17
19
|
Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed and cannot be written. Fields marked readOnly are listed but refused on write.`,
|
package/dist/write/patch.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf,
|
|
1
|
+
import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, describeAddressableFields, findBlocksField, splitPath } from "../schema/walk.mjs";
|
|
2
2
|
import { validateWriteValue } from "../schema/shape.mjs";
|
|
3
3
|
import { resolveDataPointer } from "../schema/pointer.mjs";
|
|
4
4
|
import { z } from "zod";
|
|
@@ -154,7 +154,7 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
154
154
|
* is left out, so the write-back carries only what a client could have set.
|
|
155
155
|
*/ const pickDescribed = (config, value, at) => {
|
|
156
156
|
const { fields, prefix, isRow } = at;
|
|
157
|
-
const relative =
|
|
157
|
+
const relative = describeAddressableFields(fields).flatMap((descriptor) => {
|
|
158
158
|
const parts = splitPath(descriptor.path);
|
|
159
159
|
return prefix.every((part, offset) => part === parts[offset]) ? [{
|
|
160
160
|
descriptor,
|
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.
|
|
4
|
+
"version": "1.0.0-beta.7",
|
|
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",
|