@abinnovision/payloadcms-mcpx 1.0.0-beta.13 → 1.0.0-beta.15
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 +229 -166
- package/dist/api-keys/fields.mjs +7 -3
- package/dist/api-keys/setup-guide.mjs +6 -4
- package/dist/auth/resolve.mjs +1 -3
- package/dist/capabilities.mjs +8 -8
- package/dist/endpoint/errors.mjs +0 -2
- package/dist/endpoint/server.mjs +6 -20
- package/dist/i18n.mjs +4 -15
- package/dist/options.mjs +7 -20
- package/dist/result.d.mts +3 -5
- package/dist/result.mjs +3 -5
- package/dist/schema/describe.mjs +3 -15
- package/dist/schema/index.mjs +2 -2
- package/dist/schema/lexical.mjs +160 -27
- package/dist/schema/pointer.mjs +5 -11
- package/dist/schema/shape.mjs +21 -19
- package/dist/schema/walk.mjs +36 -54
- package/dist/tools/builtin.mjs +4 -6
- package/dist/tools/create-document.mjs +19 -6
- package/dist/tools/describe-schema.mjs +9 -1
- package/dist/tools/find-documents.mjs +8 -1
- package/dist/tools/get-document.mjs +5 -1
- package/dist/tools/list-capabilities.mjs +10 -2
- package/dist/tools/patch-document.mjs +7 -1
- package/dist/tools/publish-document.mjs +13 -15
- package/dist/tools/shared.mjs +39 -37
- package/dist/tools/target.mjs +3 -7
- package/dist/tools/validate-document.mjs +9 -1
- package/dist/types.d.mts +38 -77
- package/dist/write/draft-guard.mjs +33 -65
- package/dist/write/patch.mjs +22 -60
- package/dist/write/publish-blockers.mjs +6 -12
- package/dist/write/publish-intent.mjs +13 -35
- package/dist/write/transaction.mjs +2 -3
- package/package.json +1 -1
package/dist/endpoint/server.mjs
CHANGED
|
@@ -4,27 +4,13 @@ import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
|
|
|
4
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
//#region src/endpoint/server.ts
|
|
7
|
+
/** Strict, so an unknown argument is rejected by name rather than stripped. */ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
|
|
8
|
+
/** May be built from the scope, to name the targets this key writes live. */ const toolDescription = (tool, scope) => typeof tool.description === "function" ? tool.description(scope) : tool.description;
|
|
9
|
+
/** A tool that does not decide for itself is gated by its own checkbox. */ const isToolEnabled = (tool, scope) => tool.isEnabled ? tool.isEnabled(scope) : scope.capabilities.tools[tool.name] === true;
|
|
7
10
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* scope to narrow enums to what the key may touch.
|
|
12
|
-
*/ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
|
|
13
|
-
/**
|
|
14
|
-
* A tool's description, which may be built from the scope so it can name the
|
|
15
|
-
* targets this key writes live.
|
|
16
|
-
*/ const toolDescription = (tool, scope) => typeof tool.description === "function" ? tool.description(scope) : tool.description;
|
|
17
|
-
/**
|
|
18
|
-
* Whether the key may call the tool. A tool that does not decide for itself is
|
|
19
|
-
* gated by its own checkbox on the key, which is how the tools from
|
|
20
|
-
* `options.tools` work; the builtins derive it from the key's collection and
|
|
21
|
-
* global capabilities instead.
|
|
22
|
-
*/ const isToolEnabled = (tool, scope) => tool.isEnabled ? tool.isEnabled(scope) : scope.capabilities.tools[tool.name] === true;
|
|
23
|
-
/**
|
|
24
|
-
* Builds the MCP server for one request. Builtin and configured tools take the
|
|
25
|
-
* same route: each is registered against the key's capabilities, so
|
|
26
|
-
* `tools/list` shows exactly what the key may call and every `collection` enum
|
|
27
|
-
* is limited to what it may touch.
|
|
11
|
+
* One server per request. Builtin and configured tools take the same route,
|
|
12
|
+
* each registered against the key's capabilities, so `tools/list` shows exactly
|
|
13
|
+
* what the key may call.
|
|
28
14
|
*/ const createMcpServer = (scope, options) => {
|
|
29
15
|
const { req } = scope;
|
|
30
16
|
const { logger } = req.payload;
|
package/dist/i18n.mjs
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
//#region src/i18n.ts
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
*/ const stringRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string") ? value : void 0;
|
|
5
|
-
/**
|
|
6
|
-
* Picks the entry a language addresses, treating an empty value as absent so
|
|
7
|
-
* the chain continues rather than yielding a useless string.
|
|
8
|
-
*/ const pick = (record, language) => {
|
|
2
|
+
const stringRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string") ? value : void 0;
|
|
3
|
+
/** Treats an empty value as absent, so the fallback chain continues. */ const pick = (record, language) => {
|
|
9
4
|
for (const code of Array.isArray(language) ? language : [language]) {
|
|
10
5
|
const entry = record[code];
|
|
11
6
|
if (entry !== void 0 && entry.trim() !== "") return entry;
|
|
@@ -25,14 +20,8 @@
|
|
|
25
20
|
if (!record) return;
|
|
26
21
|
return pick(record, language.language) ?? pick(record, language.fallbackLanguage) ?? Object.values(record).find((entry) => entry.trim() !== "");
|
|
27
22
|
};
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
* resolves many descriptions carries no request of its own.
|
|
31
|
-
*/ const translatorFor = (i18n) => (value) => translateStatic(value, i18n);
|
|
32
|
-
/**
|
|
33
|
-
* Translator for callers with no request in hand. Both language keys miss, so
|
|
34
|
-
* the chain degrades to the record's first entry.
|
|
35
|
-
*/ const translateAny = translatorFor({
|
|
23
|
+
/** Bound to one request's language, so a walk carries no request of its own. */ const translatorFor = (i18n) => (value) => translateStatic(value, i18n);
|
|
24
|
+
/** For callers with no request: both keys miss, so the first entry wins. */ const translateAny = translatorFor({
|
|
36
25
|
fallbackLanguage: "",
|
|
37
26
|
language: ""
|
|
38
27
|
});
|
package/dist/options.mjs
CHANGED
|
@@ -11,23 +11,16 @@ const TOOL_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9]*$/;
|
|
|
11
11
|
const fail = (message) => {
|
|
12
12
|
throw new InvalidConfiguration(`[payloadcms-mcpx] ${message}`);
|
|
13
13
|
};
|
|
14
|
+
/** The same transform the stock MCP plugin uses to derive field names. */ const toCamelCase = (value) => value.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : "").replace(/^(.)/, (_, char) => char.toLowerCase());
|
|
14
15
|
/**
|
|
15
|
-
* Lower camel case of a slug, the same transform the stock MCP plugin applies
|
|
16
|
-
* to derive field names from collection slugs.
|
|
17
|
-
*/ const toCamelCase = (value) => value.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : "").replace(/^(.)/, (_, char) => char.toLowerCase());
|
|
18
|
-
/**
|
|
19
|
-
* Refuses collections that must never be reachable through MCP, read included.
|
|
20
16
|
* Auth collections carry credentials: `useAPIKey` stores a key that decrypts on
|
|
21
|
-
* read, and email or lockout state is PII either way.
|
|
17
|
+
* read, and email or lockout state is PII either way. Refused for read too.
|
|
22
18
|
*/ const assertExposable = (collection, apiKeysSlug) => {
|
|
23
19
|
const { slug } = collection;
|
|
24
20
|
if (slug === apiKeysSlug || slug.startsWith("payload-")) fail(`Collection "${slug}" cannot be exposed.`);
|
|
25
21
|
if (collection.auth) fail(`Auth collection "${slug}" cannot be exposed. Its documents carry credentials.`);
|
|
26
22
|
};
|
|
27
|
-
/**
|
|
28
|
-
* The write mode, checked at runtime as well as in the type. JS callers get no
|
|
29
|
-
* type checking, and a typo reading as "no write" would be a silent downgrade.
|
|
30
|
-
*/ const normalizeWriteMode = (kind, slug, value) => {
|
|
23
|
+
/** Checked at runtime too: for a JS caller a typo would silently mean "no write". */ const normalizeWriteMode = (kind, slug, value) => {
|
|
31
24
|
if (value === void 0 || value === false) return false;
|
|
32
25
|
if (value === "draft" || value === "live") return value;
|
|
33
26
|
return fail(`${kind} "${slug}" has write: ${JSON.stringify(value)}. Use false, "draft" or "live".`);
|
|
@@ -43,15 +36,11 @@ const fail = (message) => {
|
|
|
43
36
|
};
|
|
44
37
|
const assertWritable = (collection, options) => {
|
|
45
38
|
const { slug } = collection;
|
|
46
|
-
if (collection.upload) fail(`Upload collection "${slug}" cannot be exposed for write.`);
|
|
47
39
|
if (collection.timestamps === false) fail(`Collection "${slug}" has timestamps disabled, which write tools need for concurrency checks.`);
|
|
48
40
|
if (options.write === "draft" && !options.hasDrafts) fail(`Collection "${slug}" has no drafts. Enable versions.drafts or set write: "live".`);
|
|
49
41
|
if (options.write === "live") assertPublishable("Collection", collection);
|
|
50
42
|
};
|
|
51
|
-
/**
|
|
52
|
-
* Refuses globals that must never be reachable. Globals cannot be auth or
|
|
53
|
-
* upload entities, so only Payload's own reserved namespace is left to guard.
|
|
54
|
-
*/ const assertGlobalExposable = (global) => {
|
|
43
|
+
/** Globals cannot be auth or upload, so only the reserved namespace is left. */ const assertGlobalExposable = (global) => {
|
|
55
44
|
if (global.slug.startsWith("payload-")) fail(`Global "${global.slug}" cannot be exposed.`);
|
|
56
45
|
};
|
|
57
46
|
/**
|
|
@@ -77,6 +66,7 @@ const normalizeCollections = (config, options, apiKeysSlug) => {
|
|
|
77
66
|
read: settings.read ?? true,
|
|
78
67
|
write: normalizeWriteMode("Collection", slug, settings.write),
|
|
79
68
|
hasDrafts,
|
|
69
|
+
isUpload: Boolean(collection.upload),
|
|
80
70
|
fieldName: toCamelCase(slug)
|
|
81
71
|
};
|
|
82
72
|
if (normalized.write !== false) assertWritable(collection, normalized);
|
|
@@ -100,6 +90,7 @@ const normalizeGlobals = (config, options) => {
|
|
|
100
90
|
read: settings.read ?? true,
|
|
101
91
|
write: normalizeWriteMode("Global", slug, settings.write),
|
|
102
92
|
hasDrafts,
|
|
93
|
+
isUpload: false,
|
|
103
94
|
fieldName: toCamelCase(slug)
|
|
104
95
|
};
|
|
105
96
|
if (normalized.write !== false) assertGlobalWritable(global, normalized);
|
|
@@ -132,11 +123,7 @@ const normalizeLimits = (limits) => {
|
|
|
132
123
|
maxDepth
|
|
133
124
|
};
|
|
134
125
|
};
|
|
135
|
-
/**
|
|
136
|
-
* Validates the plugin options against the incoming config and fills in
|
|
137
|
-
* defaults. Every problem is an `InvalidConfiguration` so misconfiguration
|
|
138
|
-
* fails at startup instead of at request time.
|
|
139
|
-
*/ const normalizeOptions = (config, options) => {
|
|
126
|
+
/** Every problem is an `InvalidConfiguration`, so it fails at startup. */ const normalizeOptions = (config, options) => {
|
|
140
127
|
const apiKeysSlug = options.apiKeys?.slug ?? DEFAULT_API_KEYS_SLUG;
|
|
141
128
|
const userCollection = options.userCollection ?? config.admin?.user ?? "users";
|
|
142
129
|
if ((config.collections ?? []).some((c) => c.slug === apiKeysSlug)) fail(`API key collection slug "${apiKeysSlug}" is already taken.`);
|
package/dist/result.d.mts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
2
|
//#region src/result.d.ts
|
|
3
|
-
/**
|
|
4
|
-
* A successful tool result carrying `value` as JSON text.
|
|
5
|
-
*/
|
|
3
|
+
/** `value` as JSON text. */
|
|
6
4
|
declare const jsonResult: (value: unknown) => CallToolResult;
|
|
7
5
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* `extras` travel alongside the message so the client can act on them:
|
|
7
|
+
* problems, validation errors, the current `updatedAt`.
|
|
10
8
|
*/
|
|
11
9
|
declare const errorResult: (message: string, extras?: Record<string, unknown>) => CallToolResult;
|
|
12
10
|
//#endregion
|
package/dist/result.mjs
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
//#region src/result.ts
|
|
2
|
-
/**
|
|
3
|
-
* A successful tool result carrying `value` as JSON text.
|
|
4
|
-
*/ const jsonResult = (value) => ({ content: [{
|
|
2
|
+
/** `value` as JSON text. */ const jsonResult = (value) => ({ content: [{
|
|
5
3
|
type: "text",
|
|
6
4
|
text: JSON.stringify(value)
|
|
7
5
|
}] });
|
|
8
6
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* `extras` travel alongside the message so the client can act on them:
|
|
8
|
+
* problems, validation errors, the current `updatedAt`.
|
|
11
9
|
*/ const errorResult = (message, extras = {}) => ({
|
|
12
10
|
content: [{
|
|
13
11
|
type: "text",
|
package/dist/schema/describe.mjs
CHANGED
|
@@ -2,13 +2,8 @@ import { lexicalSubSchema, subSchemaNodeTypes } from "./lexical.mjs";
|
|
|
2
2
|
import { translateAny } from "../i18n.mjs";
|
|
3
3
|
import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextField, joinPath, splitPath, targetOf } from "./walk.mjs";
|
|
4
4
|
//#region src/schema/describe.ts
|
|
5
|
-
/**
|
|
6
|
-
|
|
7
|
-
* text fields are both leaves of the walk, so at most one can match.
|
|
8
|
-
*/ 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];
|
|
9
|
-
/**
|
|
10
|
-
* Walks one step of a schema path through a blocks field.
|
|
11
|
-
*/ const stepThroughBlocks = ({ config, fields, match, remaining }) => {
|
|
5
|
+
/** Blocks and rich text are both leaves of the walk, so at most one matches. */ 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];
|
|
6
|
+
const stepThroughBlocks = ({ config, fields, match, remaining }) => {
|
|
12
7
|
const field = findBlocksField(fields, match);
|
|
13
8
|
if (!field) throw new Error(`"${joinPath(match)}" could not be resolved.`);
|
|
14
9
|
const slug = remaining.at(match.length);
|
|
@@ -22,8 +17,6 @@ import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextFie
|
|
|
22
17
|
};
|
|
23
18
|
};
|
|
24
19
|
/**
|
|
25
|
-
* Walks one step of a schema path into a Lexical node's own fields.
|
|
26
|
-
*
|
|
27
20
|
* A node that picks a block by slug takes one segment more, so `/content/block`
|
|
28
21
|
* addresses the choice and `/content/block/callout` the definition. Everything
|
|
29
22
|
* else, a link node being the usual case, resolves in a single segment.
|
|
@@ -52,8 +45,6 @@ import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextFie
|
|
|
52
45
|
};
|
|
53
46
|
};
|
|
54
47
|
/**
|
|
55
|
-
* Walks a schema path to the field list it addresses.
|
|
56
|
-
*
|
|
57
48
|
* A schema path alternates a blocks field's own path with the slug of one of
|
|
58
49
|
* the blocks it accepts, so `/layout/sections/sectionWrapper/modules/hero`
|
|
59
50
|
* reaches `hero` as it exists under `pages` specifically. The slug sits where
|
|
@@ -114,11 +105,8 @@ import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextFie
|
|
|
114
105
|
}));
|
|
115
106
|
};
|
|
116
107
|
/**
|
|
117
|
-
* Describes a collection or global root, one block reached through a schema
|
|
118
|
-
* path, or the fields a Lexical node carries.
|
|
119
|
-
*
|
|
120
108
|
* Curried on the translator that resolves each `admin.description`, so a
|
|
121
|
-
* request binds its language once and the walk
|
|
109
|
+
* request binds its language once and the walk stays request-free.
|
|
122
110
|
*/ const nodeDescriber = (translate = translateAny) => (config, ref, schemaPath = "") => {
|
|
123
111
|
const { blockType, fields } = fieldsAtSchemaPath(config, targetOf(config, ref), schemaPath);
|
|
124
112
|
const descriptors = describeFields(fields, translate);
|
package/dist/schema/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { allowedNodeTypes, lexicalSubSchema, nodeOptions, subSchemaNodeTypes } from "./lexical.mjs";
|
|
1
|
+
import { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, rootProblems, subSchemaNodeTypes } from "./lexical.mjs";
|
|
2
2
|
import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, pointerFromPayloadPath, splitPath, targetOf } from "./walk.mjs";
|
|
3
3
|
import { nodeDescriber, reachableSchemaPaths } from "./describe.mjs";
|
|
4
4
|
import { resolveDataPointer } from "./pointer.mjs";
|
|
5
5
|
import { validateWriteValue } from "./shape.mjs";
|
|
6
|
-
export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, allowedNodeTypes, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, lexicalSubSchema, nodeDescriber, nodeOptions, pointerFromPayloadPath, reachableSchemaPaths, resolveDataPointer, splitPath, subSchemaNodeTypes, targetOf, validateWriteValue };
|
|
6
|
+
export { JSON_POINTER_PATTERN, REQUIRED_NODE_PROPERTIES, RESERVED_FIELD_NAMES, ROOT_PROPERTIES, allowedNodeTypes, blockOf, blockSlugsOf, constrainsFields, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, lexicalSubSchema, nodeDescriber, nodeOptions, nodeProblems, pointerFromPayloadPath, reachableSchemaPaths, resolveDataPointer, rootProblems, splitPath, subSchemaNodeTypes, targetOf, validateWriteValue };
|
package/dist/schema/lexical.mjs
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { flattenAllFields } from "payload";
|
|
2
2
|
//#region src/schema/lexical.ts
|
|
3
3
|
/**
|
|
4
|
-
* Node types Lexical registers itself.
|
|
5
|
-
*
|
|
6
4
|
* `editorConfig.features.nodes` lists only what a feature contributed, so a
|
|
7
5
|
* field whose editor enables nothing but text formatting reports none at all.
|
|
8
6
|
*/ const LEXICAL_CORE_NODES = [
|
|
@@ -13,26 +11,17 @@ import { flattenAllFields } from "payload";
|
|
|
13
11
|
"tab"
|
|
14
12
|
];
|
|
15
13
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* upload fields concatenated, so the result describes no single position. It
|
|
20
|
-
* would need addressing by `relationTo` to mean anything.
|
|
14
|
+
* Sub-fields exist but cannot be addressed by a schema path. Asked without a
|
|
15
|
+
* node, `upload` answers with every enabled collection's upload fields
|
|
16
|
+
* concatenated, so the result describes no single position.
|
|
21
17
|
*/ const OPAQUE_NODE_TYPES = /* @__PURE__ */ new Set(["upload"]);
|
|
22
18
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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.
|
|
19
|
+
* `null` records a node type that was asked and has nothing to describe, so it
|
|
20
|
+
* is asked only once. Worth caching because describe and validate resolve the
|
|
21
|
+
* same field repeatedly and the block features rebuild their answer every call.
|
|
30
22
|
*/ const subSchemaCache = /* @__PURE__ */ new WeakMap();
|
|
31
23
|
const featuresOf = (field) => field.editor?.editorConfig?.features;
|
|
32
|
-
/**
|
|
33
|
-
* Node types a rich text field accepts. Editors other than Lexical report
|
|
34
|
-
* only the core nodes.
|
|
35
|
-
*/ const allowedNodeTypes = (field) => {
|
|
24
|
+
/** Editors other than Lexical report only the core nodes. */ const allowedNodeTypes = (field) => {
|
|
36
25
|
const registered = (featuresOf(field)?.nodes ?? []).flatMap((entry) => {
|
|
37
26
|
const type = entry.node?.getType?.();
|
|
38
27
|
return type ? [type] : [];
|
|
@@ -53,10 +42,7 @@ const resolveSubSchema = (field, nodeType) => {
|
|
|
53
42
|
kind: "fields"
|
|
54
43
|
};
|
|
55
44
|
};
|
|
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) => {
|
|
45
|
+
const lexicalSubSchema = (field, nodeType) => {
|
|
60
46
|
let cached = subSchemaCache.get(field);
|
|
61
47
|
if (!cached) {
|
|
62
48
|
cached = /* @__PURE__ */ new Map();
|
|
@@ -65,13 +51,160 @@ const resolveSubSchema = (field, nodeType) => {
|
|
|
65
51
|
if (!cached.has(nodeType)) cached.set(nodeType, resolveSubSchema(field, nodeType));
|
|
66
52
|
return cached.get(nodeType) ?? void 0;
|
|
67
53
|
};
|
|
54
|
+
/** In the order their features registered them. */ const subSchemaNodeTypes = (field) => [...featuresOf(field)?.getSubFields?.keys() ?? []].filter((nodeType) => lexicalSubSchema(field, nodeType) !== void 0);
|
|
55
|
+
/**
|
|
56
|
+
* `direction` carries Payload's own declaration for it, `oneOf` the two
|
|
57
|
+
* directions or null, rather than a looser "string or null".
|
|
58
|
+
*/ const KINDS = {
|
|
59
|
+
array: {
|
|
60
|
+
accepts: (value) => Array.isArray(value),
|
|
61
|
+
needs: "an array"
|
|
62
|
+
},
|
|
63
|
+
direction: {
|
|
64
|
+
accepts: (value) => value === null || value === "ltr" || value === "rtl",
|
|
65
|
+
needs: "\"ltr\", \"rtl\" or null"
|
|
66
|
+
},
|
|
67
|
+
number: {
|
|
68
|
+
accepts: (value) => typeof value === "number",
|
|
69
|
+
needs: "a number"
|
|
70
|
+
},
|
|
71
|
+
object: {
|
|
72
|
+
accepts: (value) => typeof value === "object" && value !== null && !Array.isArray(value),
|
|
73
|
+
needs: "an object"
|
|
74
|
+
},
|
|
75
|
+
optionalObject: {
|
|
76
|
+
accepts: (value) => value === null || typeof value === "object" && !Array.isArray(value),
|
|
77
|
+
needs: "an object or null"
|
|
78
|
+
},
|
|
79
|
+
string: {
|
|
80
|
+
accepts: (value) => typeof value === "string",
|
|
81
|
+
needs: "a string"
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const accepts = (constraint, value) => typeof constraint === "string" ? KINDS[constraint].accepts(value) : value === constraint.is;
|
|
85
|
+
const needs = (constraint) => typeof constraint === "string" ? KINDS[constraint].needs : JSON.stringify(constraint.is);
|
|
86
|
+
const ELEMENT_PROPERTIES = {
|
|
87
|
+
children: "array",
|
|
88
|
+
direction: "direction",
|
|
89
|
+
indent: "number"
|
|
90
|
+
};
|
|
91
|
+
/** A text node and everything built on one. */ const TEXT_PROPERTIES = {
|
|
92
|
+
detail: "number",
|
|
93
|
+
format: "number",
|
|
94
|
+
mode: "string",
|
|
95
|
+
style: "string",
|
|
96
|
+
text: "string"
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Carried by every node, whatever its type.
|
|
100
|
+
*
|
|
101
|
+
* Aligned with Payload rather than measured: its `outputSchema` declares `type`
|
|
102
|
+
* and `version` required for every node in the tree, and that declaration is
|
|
103
|
+
* what types the field in `payload-types.ts`. Lexical itself hydrates a node
|
|
104
|
+
* without a `version`, or with the wrong kind of one, unchanged - but a
|
|
105
|
+
* consumer reading the document through the generated types has been promised
|
|
106
|
+
* an integer, and `BlockNode.importJSON` migrates on it.
|
|
107
|
+
*/ const UNIVERSAL_PROPERTIES = { version: "number" };
|
|
68
108
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*/ const
|
|
109
|
+
* The root, as Payload declares it and as an editor exports it: these six
|
|
110
|
+
* properties, these kinds, and nothing else.
|
|
111
|
+
*/ const ROOT_PROPERTIES = {
|
|
112
|
+
children: "array",
|
|
113
|
+
direction: "direction",
|
|
114
|
+
format: "string",
|
|
115
|
+
indent: "number",
|
|
116
|
+
type: "string",
|
|
117
|
+
version: "number"
|
|
118
|
+
};
|
|
72
119
|
/**
|
|
73
|
-
*
|
|
120
|
+
* What a serialized node must carry beyond {@link UNIVERSAL_PROPERTIES}, keyed
|
|
121
|
+
* by node type.
|
|
122
|
+
*
|
|
123
|
+
* Payload stores an editor state without hydrating it, so a node written
|
|
124
|
+
* without these, or with the wrong kind of value, is accepted and only fails
|
|
125
|
+
* later, in the admin editor. Payload declares nothing per node type, so this
|
|
126
|
+
* table is measured instead: an entry belongs here only if breaking it makes
|
|
127
|
+
* Lexical throw, or changes what the editor reads back. An element's `format`
|
|
128
|
+
* and a paragraph's text defaults are absent for that reason.
|
|
129
|
+
* `lexical.spec.ts` holds every entry to the rule against the node classes
|
|
130
|
+
* `@payloadcms/richtext-lexical` ships, so extend that test first.
|
|
74
131
|
*
|
|
132
|
+
* A node type with no entry is checked for the universal properties only.
|
|
133
|
+
* Guessing at the requirements of a project's own nodes would reject content
|
|
134
|
+
* that works.
|
|
135
|
+
*/ const REQUIRED_NODE_PROPERTIES = {
|
|
136
|
+
autolink: {
|
|
137
|
+
...ELEMENT_PROPERTIES,
|
|
138
|
+
fields: "object"
|
|
139
|
+
},
|
|
140
|
+
block: { fields: "object" },
|
|
141
|
+
heading: {
|
|
142
|
+
...ELEMENT_PROPERTIES,
|
|
143
|
+
tag: "string"
|
|
144
|
+
},
|
|
145
|
+
inlineBlock: { fields: "object" },
|
|
146
|
+
link: {
|
|
147
|
+
...ELEMENT_PROPERTIES,
|
|
148
|
+
fields: "object"
|
|
149
|
+
},
|
|
150
|
+
list: {
|
|
151
|
+
...ELEMENT_PROPERTIES,
|
|
152
|
+
listType: "string",
|
|
153
|
+
start: "number"
|
|
154
|
+
},
|
|
155
|
+
listitem: {
|
|
156
|
+
...ELEMENT_PROPERTIES,
|
|
157
|
+
value: "number"
|
|
158
|
+
},
|
|
159
|
+
paragraph: ELEMENT_PROPERTIES,
|
|
160
|
+
quote: ELEMENT_PROPERTIES,
|
|
161
|
+
relationship: {
|
|
162
|
+
relationTo: "string",
|
|
163
|
+
value: "number"
|
|
164
|
+
},
|
|
165
|
+
tab: {
|
|
166
|
+
...TEXT_PROPERTIES,
|
|
167
|
+
detail: { is: 2 },
|
|
168
|
+
text: { is: " " }
|
|
169
|
+
},
|
|
170
|
+
text: TEXT_PROPERTIES,
|
|
171
|
+
upload: {
|
|
172
|
+
fields: "optionalObject",
|
|
173
|
+
relationTo: "string",
|
|
174
|
+
value: "number"
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
/**
|
|
178
|
+
* Whether the table already says what a node type's `fields` has to be, so the
|
|
179
|
+
* sub-field walk does not report the same problem a second time.
|
|
180
|
+
*/ const constrainsFields = (type) => "fields" in (REQUIRED_NODE_PROPERTIES[type] ?? {});
|
|
181
|
+
/** Present but `null` counts as present: `direction` is serialized that way. */ const check = (node, constraints) => {
|
|
182
|
+
const problems = {
|
|
183
|
+
missing: [],
|
|
184
|
+
rejected: []
|
|
185
|
+
};
|
|
186
|
+
for (const [property, constraint] of Object.entries(constraints)) if (!(property in node)) problems.missing.push(property);
|
|
187
|
+
else if (!accepts(constraint, node[property])) problems.rejected.push({
|
|
188
|
+
needs: needs(constraint),
|
|
189
|
+
property
|
|
190
|
+
});
|
|
191
|
+
problems.missing.sort();
|
|
192
|
+
problems.rejected.sort((left, right) => left.property.localeCompare(right.property));
|
|
193
|
+
return problems;
|
|
194
|
+
};
|
|
195
|
+
const nodeProblems = (node) => check(node, {
|
|
196
|
+
...UNIVERSAL_PROPERTIES,
|
|
197
|
+
...REQUIRED_NODE_PROPERTIES[node["type"]] ?? {}
|
|
198
|
+
});
|
|
199
|
+
/**
|
|
200
|
+
* The root is the one node Payload describes itself, down to refusing an
|
|
201
|
+
* unknown property, so it is checked against that description rather than
|
|
202
|
+
* against the walk's table.
|
|
203
|
+
*/ const rootProblems = (root) => ({
|
|
204
|
+
...check(root, ROOT_PROPERTIES),
|
|
205
|
+
unexpected: Object.keys(root).filter((property) => !(property in ROOT_PROPERTIES))
|
|
206
|
+
});
|
|
207
|
+
/**
|
|
75
208
|
* Only properties a feature narrows and Lexical does not check on its own
|
|
76
209
|
* belong here. Everything else a feature restricts is already visible: a
|
|
77
210
|
* link's targets through its sub-schema, a block node's choices through the
|
|
@@ -114,4 +247,4 @@ const stringList = (value) => Array.isArray(value) && value.every((entry) => typ
|
|
|
114
247
|
return entries.length > 0 ? Object.fromEntries(entries) : void 0;
|
|
115
248
|
};
|
|
116
249
|
//#endregion
|
|
117
|
-
export { allowedNodeTypes, lexicalSubSchema, nodeOptions, subSchemaNodeTypes };
|
|
250
|
+
export { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, rootProblems, subSchemaNodeTypes };
|
package/dist/schema/pointer.mjs
CHANGED
|
@@ -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,12 @@ 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
|
-
* The document is required rather than optional: `/layout/sections/3/modules/1`
|
|
24
|
+
* The stored document chooses the branch at every blocks element, and is
|
|
25
|
+
* required rather than optional: `/layout/sections/3/modules/1`
|
|
32
26
|
* can only be resolved by reading `blockType` off `sections[3]`, since a blocks
|
|
33
27
|
* field admits many shapes at the same index.
|
|
34
28
|
*/ const resolveDataPointer = (config, target) => {
|
package/dist/schema/shape.mjs
CHANGED
|
@@ -1,26 +1,22 @@
|
|
|
1
|
-
import { lexicalSubSchema } from "./lexical.mjs";
|
|
1
|
+
import { ROOT_PROPERTIES, constrainsFields, lexicalSubSchema, nodeProblems, rootProblems } from "./lexical.mjs";
|
|
2
2
|
import { blockOf, blockSlugsOf, describeAddressableFields, findBlocksField, findRichTextField, 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
9
|
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
|
+
const quoted = (properties) => properties.map((property) => `"${property}"`).join(", ");
|
|
12
11
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* A node with nothing to declare, and one whose sub-fields cannot be named at
|
|
17
|
-
* a position, are both left alone.
|
|
12
|
+
* A node with nothing to declare, and one whose sub-fields cannot be named at a
|
|
13
|
+
* position, are both left alone.
|
|
18
14
|
*/ const checkNodeFields = (scope, field, node) => {
|
|
19
15
|
const sub = lexicalSubSchema(field, node.type);
|
|
20
16
|
if (!sub) return;
|
|
21
17
|
const data = node.fields;
|
|
22
18
|
if (!isPlainObject(data)) {
|
|
23
|
-
scope.problems.push(`${scope.pointer}: a "${node.type}" node carries a "fields" object.`);
|
|
19
|
+
if (!constrainsFields(node.type)) scope.problems.push(`${scope.pointer}: a "${node.type}" node carries a "fields" object.`);
|
|
24
20
|
return;
|
|
25
21
|
}
|
|
26
22
|
const nested = {
|
|
@@ -47,21 +43,25 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
47
43
|
}, data);
|
|
48
44
|
};
|
|
49
45
|
/**
|
|
50
|
-
*
|
|
51
|
-
* produce: every node type, and the fields each node carries.
|
|
52
|
-
*
|
|
53
|
-
* Payload does not: the Lexical validator runs node validations only for the
|
|
46
|
+
* Payload does not check this: the Lexical validator runs node validations only for the
|
|
54
47
|
* few node types that register one, so a `heading` inside a field whose
|
|
55
48
|
* editor has no heading feature is stored without complaint and only fails
|
|
56
49
|
* later, at render or when the document is reopened in the admin editor. A key
|
|
57
50
|
* a node's fields do not declare is dropped just as silently. The same holds
|
|
58
51
|
* 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
|
|
52
|
+
* editor restricted to `h4` is stored as readily as an `h4`, and a node written
|
|
53
|
+
* without the properties its class hydrates from is stored and then throws when
|
|
54
|
+
* the editor opens it.
|
|
60
55
|
*/ const checkRichText = (scope, editor, value) => {
|
|
61
56
|
if (!isPlainObject(value) || !isPlainObject(value["root"])) {
|
|
62
57
|
scope.problems.push(`${scope.pointer}: expected a Lexical editor state with a "root".`);
|
|
63
58
|
return;
|
|
64
59
|
}
|
|
60
|
+
const root = value["root"];
|
|
61
|
+
const { missing, rejected, unexpected } = rootProblems(root);
|
|
62
|
+
if (missing.length > 0) scope.problems.push(`${scope.pointer}/root: the root node is missing ${quoted(missing)}. Write nodes as Lexical serializes them.`);
|
|
63
|
+
for (const problem of rejected) scope.problems.push(`${scope.pointer}/root/${problem.property}: the root node needs ${problem.needs} here.`);
|
|
64
|
+
for (const property of unexpected) scope.problems.push(`${scope.pointer}/root/${property}: no such property on the root node. Available: ${Object.keys(ROOT_PROPERTIES).join(", ")}`);
|
|
65
65
|
const walk = (nodes, pointer) => {
|
|
66
66
|
if (!Array.isArray(nodes)) return;
|
|
67
67
|
nodes.forEach((node, index) => {
|
|
@@ -74,9 +74,12 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
74
74
|
scope.problems.push(`${at}: "${node["type"]}" is not available in this field's editor. Allowed: ${editor.allowed.join(", ")}`);
|
|
75
75
|
return;
|
|
76
76
|
}
|
|
77
|
+
const problems = nodeProblems(node);
|
|
78
|
+
if (problems.missing.length > 0) scope.problems.push(`${at}: a "${node["type"]}" node is missing ${quoted(problems.missing)}. Write nodes as Lexical serializes them.`);
|
|
79
|
+
for (const problem of problems.rejected) scope.problems.push(`${at}/${problem.property}: a "${node["type"]}" node needs ${problem.needs} here.`);
|
|
77
80
|
for (const [property, values] of Object.entries(editor.nodeOptions?.[node["type"]] ?? {})) {
|
|
78
81
|
const value = node[property];
|
|
79
|
-
if (typeof value === "string" &&
|
|
82
|
+
if (value !== void 0 && !(typeof value === "string" && values.includes(value))) scope.problems.push(`${at}/${property}: ${JSON.stringify(value)} is not available for a "${node["type"]}" node in this field's editor. Allowed: ${values.join(", ")}`);
|
|
80
83
|
}
|
|
81
84
|
if (editor.field) checkNodeFields({
|
|
82
85
|
...scope,
|
|
@@ -88,7 +91,7 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
88
91
|
walk(node["children"], `${at}/children`);
|
|
89
92
|
});
|
|
90
93
|
};
|
|
91
|
-
walk(
|
|
94
|
+
walk(root["children"], `${scope.pointer}/root/children`);
|
|
92
95
|
};
|
|
93
96
|
const checkLeafValue = (scope, descriptor, value) => {
|
|
94
97
|
if (descriptor.readOnly) {
|
|
@@ -126,8 +129,7 @@ const checkLeafValue = (scope, descriptor, value) => {
|
|
|
126
129
|
});
|
|
127
130
|
};
|
|
128
131
|
/**
|
|
129
|
-
*
|
|
130
|
-
* rather than the first.
|
|
132
|
+
* Reports every shape problem rather than the first.
|
|
131
133
|
*
|
|
132
134
|
* Shape only: unknown field names, unknown block slugs, read-only fields, and
|
|
133
135
|
* rich text nodes or node properties the field's editor cannot produce.
|