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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +331 -194
  3. package/dist/api-keys/collection.mjs +3 -3
  4. package/dist/api-keys/fields.mjs +56 -5
  5. package/dist/api-keys/setup-guide.mjs +56 -0
  6. package/dist/auth/resolve.mjs +5 -7
  7. package/dist/capabilities.mjs +23 -4
  8. package/dist/client/index.d.mts +2 -0
  9. package/dist/client/index.mjs +2 -0
  10. package/dist/client/setup-guide.d.mts +14 -0
  11. package/dist/client/setup-guide.mjs +87 -0
  12. package/dist/endpoint/{result.mjs → errors.mjs} +4 -23
  13. package/dist/endpoint/handler.mjs +11 -5
  14. package/dist/endpoint/index.mjs +4 -0
  15. package/dist/endpoint/server.mjs +18 -26
  16. package/dist/i18n.mjs +4 -15
  17. package/dist/index.d.mts +4 -4
  18. package/dist/index.mjs +4 -3
  19. package/dist/options.mjs +31 -26
  20. package/dist/plugin.mjs +1 -0
  21. package/dist/{write/draft-guard.d.mts → request.d.mts} +2 -2
  22. package/dist/request.mjs +8 -0
  23. package/dist/result.d.mts +11 -0
  24. package/dist/result.mjs +20 -0
  25. package/dist/schema/describe.mjs +3 -15
  26. package/dist/schema/index.mjs +8 -0
  27. package/dist/schema/lexical-pointer.mjs +125 -0
  28. package/dist/schema/lexical.mjs +195 -27
  29. package/dist/schema/outline.mjs +67 -0
  30. package/dist/schema/pointer.mjs +77 -30
  31. package/dist/schema/shape.mjs +133 -51
  32. package/dist/schema/walk.mjs +44 -64
  33. package/dist/tools/{index.mjs → builtin.mjs} +8 -5
  34. package/dist/tools/create-document.mjs +34 -15
  35. package/dist/tools/describe-schema.mjs +21 -7
  36. package/dist/tools/find-documents.mjs +13 -6
  37. package/dist/tools/get-document.mjs +45 -11
  38. package/dist/tools/list-capabilities.mjs +19 -9
  39. package/dist/tools/names.mjs +2 -1
  40. package/dist/tools/patch-document.mjs +32 -21
  41. package/dist/tools/publish-document.mjs +79 -0
  42. package/dist/tools/shared.mjs +84 -32
  43. package/dist/tools/target.mjs +7 -11
  44. package/dist/tools/validate-document.mjs +20 -12
  45. package/dist/types.d.mts +115 -42
  46. package/dist/types.mjs +3 -4
  47. package/dist/version.mjs +1 -1
  48. package/dist/write/draft-guard.mjs +47 -44
  49. package/dist/write/patch.mjs +174 -92
  50. package/dist/write/publish-blockers.mjs +13 -12
  51. package/dist/write/publish-intent.mjs +17 -0
  52. package/dist/write/transaction.mjs +8 -3
  53. package/package.json +24 -9
  54. package/dist/i18n.d.mts +0 -1
  55. package/dist/options.d.mts +0 -2
  56. package/dist/schema/lexical.d.mts +0 -1
  57. package/dist/schema/walk.d.mts +0 -3
  58. package/dist/tools/target.d.mts +0 -3
  59. package/dist/tools/types.d.mts +0 -5
  60. package/dist/write/publish-blockers.d.mts +0 -15
package/dist/options.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { BUILTIN_TOOL_NAMES } from "./tools/names.mjs";
2
2
  import "./version.mjs";
3
3
  import { InvalidConfiguration } from "payload";
4
- import { hasDraftsEnabled } from "payload/shared";
4
+ import { hasDraftsEnabled, hasLocalizeStatusEnabled } from "payload/shared";
5
5
  //#region src/options.ts
6
6
  const DEFAULT_API_KEYS_SLUG = "mcpx-api-keys";
7
7
  const DEFAULT_ENDPOINT_PATH = "/mcpx";
@@ -11,29 +11,36 @@ 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
  };
23
+ /** Checked at runtime too: for a JS caller a typo would silently mean "no write". */ const normalizeWriteMode = (kind, slug, value) => {
24
+ if (value === void 0 || value === false) return false;
25
+ if (value === "draft" || value === "live") return value;
26
+ return fail(`${kind} "${slug}" has write: ${JSON.stringify(value)}. Use false, "draft" or "live".`);
27
+ };
28
+ /**
29
+ * `localizeStatus` makes `_status` a localized field, which flips Payload's
30
+ * `publishAllLocales` default to false and turns `_status` into a locale-keyed
31
+ * object. Publishing would then cover one locale while reporting success, and
32
+ * the tool responses model `_status` as a string. Refused until both are
33
+ * handled.
34
+ */ const assertPublishable = (kind, config) => {
35
+ if (hasLocalizeStatusEnabled(config)) fail(`${kind} "${config.slug}" has versions.drafts.localizeStatus enabled, which write: "live" does not support yet.`);
36
+ };
27
37
  const assertWritable = (collection, options) => {
28
38
  const { slug } = collection;
29
- if (collection.upload) fail(`Upload collection "${slug}" cannot be exposed for write.`);
30
39
  if (collection.timestamps === false) fail(`Collection "${slug}" has timestamps disabled, which write tools need for concurrency checks.`);
31
- if (!options.hasDrafts && !options.allowLiveWrites) fail(`Collection "${slug}" has no drafts. Enable versions.drafts or set allowLiveWrites.`);
40
+ if (options.write === "draft" && !options.hasDrafts) fail(`Collection "${slug}" has no drafts. Enable versions.drafts or set write: "live".`);
41
+ if (options.write === "live") assertPublishable("Collection", collection);
32
42
  };
33
- /**
34
- * Refuses globals that must never be reachable. Globals cannot be auth or
35
- * upload entities, so only Payload's own reserved namespace is left to guard.
36
- */ const assertGlobalExposable = (global) => {
43
+ /** Globals cannot be auth or upload, so only the reserved namespace is left. */ const assertGlobalExposable = (global) => {
37
44
  if (global.slug.startsWith("payload-")) fail(`Global "${global.slug}" cannot be exposed.`);
38
45
  };
39
46
  /**
@@ -41,7 +48,8 @@ const assertWritable = (collection, options) => {
41
48
  * `createdAt`/`updatedAt`, so the concurrency check the collection path guards
42
49
  * for is always available here. Drafts are the only requirement left.
43
50
  */ const assertGlobalWritable = (global, options) => {
44
- if (!options.hasDrafts && !options.allowLiveWrites) fail(`Global "${global.slug}" has no drafts. Enable versions.drafts or set allowLiveWrites.`);
51
+ if (options.write === "draft" && !options.hasDrafts) fail(`Global "${global.slug}" has no drafts. Enable versions.drafts or set write: "live".`);
52
+ if (options.write === "live") assertPublishable("Global", global);
45
53
  };
46
54
  const normalizeCollections = (config, options, apiKeysSlug) => {
47
55
  const collections = config.collections ?? [];
@@ -56,12 +64,12 @@ const normalizeCollections = (config, options, apiKeysSlug) => {
56
64
  const normalized = {
57
65
  slug,
58
66
  read: settings.read ?? true,
59
- write: settings.write ?? false,
60
- allowLiveWrites: settings.allowLiveWrites ?? false,
67
+ write: normalizeWriteMode("Collection", slug, settings.write),
61
68
  hasDrafts,
69
+ isUpload: Boolean(collection.upload),
62
70
  fieldName: toCamelCase(slug)
63
71
  };
64
- if (normalized.write) assertWritable(collection, normalized);
72
+ if (normalized.write !== false) assertWritable(collection, normalized);
65
73
  if (fieldNames.has(normalized.fieldName)) fail(`Collection "${slug}" maps to capability field "${normalized.fieldName}", which another exposed collection already uses.`);
66
74
  fieldNames.add(normalized.fieldName);
67
75
  return [normalized];
@@ -80,12 +88,12 @@ const normalizeGlobals = (config, options) => {
80
88
  const normalized = {
81
89
  slug,
82
90
  read: settings.read ?? true,
83
- write: settings.write ?? false,
84
- allowLiveWrites: settings.allowLiveWrites ?? false,
91
+ write: normalizeWriteMode("Global", slug, settings.write),
85
92
  hasDrafts,
93
+ isUpload: false,
86
94
  fieldName: toCamelCase(slug)
87
95
  };
88
- if (normalized.write) assertGlobalWritable(global, normalized);
96
+ if (normalized.write !== false) assertGlobalWritable(global, normalized);
89
97
  if (fieldNames.has(normalized.fieldName)) fail(`Global "${slug}" maps to capability field "${normalized.fieldName}", which another exposed global already uses.`);
90
98
  fieldNames.add(normalized.fieldName);
91
99
  return [normalized];
@@ -115,11 +123,7 @@ const normalizeLimits = (limits) => {
115
123
  maxDepth
116
124
  };
117
125
  };
118
- /**
119
- * Validates the plugin options against the incoming config and fills in
120
- * defaults. Every problem is an `InvalidConfiguration` so misconfiguration
121
- * fails at startup instead of at request time.
122
- */ const normalizeOptions = (config, options) => {
126
+ /** Every problem is an `InvalidConfiguration`, so it fails at startup. */ const normalizeOptions = (config, options) => {
123
127
  const apiKeysSlug = options.apiKeys?.slug ?? DEFAULT_API_KEYS_SLUG;
124
128
  const userCollection = options.userCollection ?? config.admin?.user ?? "users";
125
129
  if ((config.collections ?? []).some((c) => c.slug === apiKeysSlug)) fail(`API key collection slug "${apiKeysSlug}" is already taken.`);
@@ -132,12 +136,13 @@ const normalizeLimits = (limits) => {
132
136
  userCollection,
133
137
  apiKeysSlug,
134
138
  endpointPath: options.endpoint?.path ?? DEFAULT_ENDPOINT_PATH,
139
+ setupGuide: options.apiKeys?.setupGuide ?? true,
135
140
  limits: normalizeLimits(options.limits),
136
141
  tools,
137
142
  auth: options.auth,
138
143
  serverInfo: {
139
144
  name: options.serverInfo?.name ?? "payloadcms-mcpx",
140
- version: options.serverInfo?.version ?? "0.0.0"
145
+ version: options.serverInfo?.version ?? "1.0.0"
141
146
  }
142
147
  };
143
148
  };
package/dist/plugin.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createApiKeysCollection } from "./api-keys/collection.mjs";
2
2
  import { createMcpxHandler, methodNotAllowed } from "./endpoint/handler.mjs";
3
+ import "./endpoint/index.mjs";
3
4
  import { normalizeOptions } from "./options.mjs";
4
5
  import { installDraftGuards, installGlobalDraftGuards } from "./write/draft-guard.mjs";
5
6
  import { definePlugin } from "payload";
@@ -1,5 +1,5 @@
1
- import { CollectionConfig, PayloadRequest } from "payload";
2
- //#region src/write/draft-guard.d.ts
1
+ import { PayloadRequest } from "payload";
2
+ //#region src/request.d.ts
3
3
  /**
4
4
  * Whether a request originated from the MCP endpoint. The endpoint stamps
5
5
  * `req.context.mcpx`, which travels into every local API call made with the
@@ -0,0 +1,8 @@
1
+ //#region src/request.ts
2
+ /**
3
+ * Whether a request originated from the MCP endpoint. The endpoint stamps
4
+ * `req.context.mcpx`, which travels into every local API call made with the
5
+ * same `req`, including those made by custom tools.
6
+ */ const isMcpxRequest = (req) => req.context.mcpx !== void 0;
7
+ //#endregion
8
+ export { isMcpxRequest };
@@ -0,0 +1,11 @@
1
+ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
+ //#region src/result.d.ts
3
+ /** `value` as JSON text. */
4
+ declare const jsonResult: (value: unknown) => CallToolResult;
5
+ /**
6
+ * `extras` travel alongside the message so the client can act on them:
7
+ * problems, validation errors, the current `updatedAt`.
8
+ */
9
+ declare const errorResult: (message: string, extras?: Record<string, unknown>) => CallToolResult;
10
+ //#endregion
11
+ export { errorResult, jsonResult };
@@ -0,0 +1,20 @@
1
+ //#region src/result.ts
2
+ /** `value` as JSON text. */ const jsonResult = (value) => ({ content: [{
3
+ type: "text",
4
+ text: JSON.stringify(value)
5
+ }] });
6
+ /**
7
+ * `extras` travel alongside the message so the client can act on them:
8
+ * problems, validation errors, the current `updatedAt`.
9
+ */ const errorResult = (message, extras = {}) => ({
10
+ content: [{
11
+ type: "text",
12
+ text: JSON.stringify({
13
+ error: message,
14
+ ...extras
15
+ })
16
+ }],
17
+ isError: true
18
+ });
19
+ //#endregion
20
+ export { errorResult, jsonResult };
@@ -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
- * The longest descriptor path that is a prefix of `remaining`. Blocks and rich
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 itself stays request-free.
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);
@@ -0,0 +1,8 @@
1
+ import { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, nodePropertiesFor, propertyProblem, rootProblems, subSchemaNodeTypes } from "./lexical.mjs";
2
+ import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, isIndexSegment, isPlainObject, joinPath, pointerFromPayloadPath, splitPath, targetOf } from "./walk.mjs";
3
+ import { nodeDescriber, reachableSchemaPaths } from "./describe.mjs";
4
+ import { resolveLexicalPointer } from "./lexical-pointer.mjs";
5
+ import { lexicalOutline } from "./outline.mjs";
6
+ import { resolveDataPointer } from "./pointer.mjs";
7
+ import { EMPTY_ROOT, validateWriteValue } from "./shape.mjs";
8
+ export { EMPTY_ROOT, JSON_POINTER_PATTERN, REQUIRED_NODE_PROPERTIES, RESERVED_FIELD_NAMES, ROOT_PROPERTIES, allowedNodeTypes, blockOf, blockSlugsOf, constrainsFields, describeAddressableFields, describeFields, findBlocksField, findRichTextField, isIndexSegment, isPlainObject, joinPath, lexicalOutline, lexicalSubSchema, nodeDescriber, nodeOptions, nodeProblems, nodePropertiesFor, pointerFromPayloadPath, propertyProblem, reachableSchemaPaths, resolveDataPointer, resolveLexicalPointer, rootProblems, splitPath, subSchemaNodeTypes, targetOf, validateWriteValue };
@@ -0,0 +1,125 @@
1
+ import { lexicalSubSchema, subSchemaNodeTypes } from "./lexical.mjs";
2
+ import { blockOf, blockSlugsOf, isIndexSegment, isPlainObject, joinPath } from "./walk.mjs";
3
+ //#region src/schema/lexical-pointer.ts
4
+ /**
5
+ * A node's `fields` is ordinary Payload field-land, reached either through the
6
+ * schema a feature declares for the node or, where the node picks a block by
7
+ * slug, through that block.
8
+ */ const stepIntoFields = (at) => {
9
+ const { addedValue, config, field, node, nodeType, rest } = at;
10
+ const sub = lexicalSubSchema(field, nodeType);
11
+ if (!sub) throw new Error(`"${nodeType}" nodes carry no addressable fields in this field's editor. Node types with fields here: ${subSchemaNodeTypes(field).join(", ")}`);
12
+ const data = node?.["fields"];
13
+ if (sub.kind === "fields") return {
14
+ data,
15
+ fields: sub.fields,
16
+ kind: "fields",
17
+ rest
18
+ };
19
+ const added = addedValue?.fields;
20
+ const slug = (isPlainObject(data) ? data["blockType"] : void 0) ?? (isPlainObject(added) ? added["blockType"] : void 0);
21
+ if (typeof slug !== "string") throw new Error(`Cannot tell which block a "${nodeType}" node holds. Supply a "blockType" on the value, one of: ${blockSlugsOf(sub.blocksField).join(", ")}`);
22
+ const block = blockOf(config, sub.blocksField, slug);
23
+ if (!block) throw new Error(`"${slug}" is not allowed in a "${nodeType}" node here. Allowed: ${blockSlugsOf(sub.blocksField).join(", ")}`);
24
+ return {
25
+ blockType: slug,
26
+ data,
27
+ fields: block.flattenedFields,
28
+ kind: "fields",
29
+ rest
30
+ };
31
+ };
32
+ /**
33
+ * Walks the segments left over once a pointer has reached a rich text field.
34
+ *
35
+ * The stored state chooses the branch at every index, exactly as the stored
36
+ * document chooses it at a blocks element: an editor state admits many node
37
+ * shapes at the same position, and only what is there says which one it is.
38
+ * A position the document does not have yet takes its type from the value
39
+ * being added, and is addressable no further.
40
+ */ const resolveLexicalPointer = (at) => {
41
+ const { addedValue, config, descriptor, field, state } = at;
42
+ const base = {
43
+ descriptor,
44
+ field
45
+ };
46
+ if (!isPlainObject(state) || !isPlainObject(state["root"])) throw new Error(`"${descriptor.path}" holds no editor state yet. Write the whole field once, then address positions inside it.`);
47
+ const [entry, ...rest] = at.segments;
48
+ if (entry !== "root") throw new Error(`"${String(entry)}" is not a position in a rich text field. An editor state is entered at "root", e.g. "${descriptor.path}/root/children/0". getDocument with "outline" lists every position this field holds.`);
49
+ let node = state["root"];
50
+ let nodeType = "root";
51
+ let segments = rest;
52
+ let walked = ["root"];
53
+ for (;;) {
54
+ if (segments.length === 0) return {
55
+ kind: "position",
56
+ position: {
57
+ ...base,
58
+ ...nodeType === "root" ? { isRoot: true } : {},
59
+ kind: "node",
60
+ nodeType
61
+ }
62
+ };
63
+ const [segment, ...remaining] = segments;
64
+ if (segment === "children") {
65
+ if (remaining.length === 0) return {
66
+ kind: "position",
67
+ position: {
68
+ ...base,
69
+ ...nodeType === "root" ? { isRoot: true } : {},
70
+ kind: "nodes",
71
+ nodeType
72
+ }
73
+ };
74
+ const [index, ...beyond] = remaining;
75
+ if (!isIndexSegment(index)) throw new Error(`"${descriptor.path}${joinPath([...walked, "children"])}" is a list; "${index}" is not an index. getDocument with "outline" reports the pointer of each node in it.`);
76
+ const children = node?.["children"];
77
+ const child = Array.isArray(children) && index !== "-" ? children[Number(index)] : void 0;
78
+ const type = isPlainObject(child) ? child["type"] : addedValue?.type;
79
+ if (typeof type !== "string") {
80
+ if (beyond.length === 0) return {
81
+ kind: "position",
82
+ position: {
83
+ ...base,
84
+ kind: "node"
85
+ }
86
+ };
87
+ throw new Error(`Cannot tell which node "${descriptor.path}${joinPath([
88
+ ...walked,
89
+ "children",
90
+ index
91
+ ])}" is. Call getDocument with "outline" for the pointer of each node, or address an existing position.`);
92
+ }
93
+ node = isPlainObject(child) ? child : void 0;
94
+ nodeType = type;
95
+ segments = beyond;
96
+ walked = [
97
+ ...walked,
98
+ "children",
99
+ index
100
+ ];
101
+ continue;
102
+ }
103
+ if (segment === "fields") return stepIntoFields({
104
+ addedValue,
105
+ config,
106
+ field,
107
+ node,
108
+ nodeType,
109
+ rest: remaining
110
+ });
111
+ if (remaining.length > 0) throw new Error(`"${segment}" is a property of a "${nodeType}" node and nothing beneath it can be addressed.`);
112
+ return {
113
+ kind: "position",
114
+ position: {
115
+ ...base,
116
+ ...nodeType === "root" ? { isRoot: true } : {},
117
+ kind: "property",
118
+ nodeType,
119
+ property: segment
120
+ }
121
+ };
122
+ }
123
+ };
124
+ //#endregion
125
+ export { resolveLexicalPointer };
@@ -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
- * 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.
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
- * 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.
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,195 @@ 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" };
108
+ /**
109
+ * The root, as Payload declares it and as an editor exports it: these six
110
+ * properties, these kinds, and nothing else.
111
+ */ const ROOT_PROPERTIES = {
112
+ children: "array",
113
+ direction: "direction",
114
+ format: "string",
115
+ indent: "number",
116
+ type: "string",
117
+ version: "number"
118
+ };
68
119
  /**
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);
120
+ * What a serialized node must carry beyond {@link UNIVERSAL_PROPERTIES}, keyed
121
+ * by node type.
122
+ *
123
+ * Payload stores an editor state without hydrating it, so a node written
124
+ * without these, or with the wrong kind of value, is accepted and only fails
125
+ * later, in the admin editor. Payload declares nothing per node type, so this
126
+ * table is measured instead: an entry belongs here only if breaking it makes
127
+ * Lexical throw, or changes what the editor reads back. An element's `format`
128
+ * and a paragraph's text defaults are absent for that reason.
129
+ * `lexical.spec.ts` holds every entry to the rule against the node classes
130
+ * `@payloadcms/richtext-lexical` ships, so extend that test first.
131
+ *
132
+ * A node type with no entry is checked for the universal properties only.
133
+ * Guessing at the requirements of a project's own nodes would reject content
134
+ * that works.
135
+ */ const REQUIRED_NODE_PROPERTIES = {
136
+ autolink: {
137
+ ...ELEMENT_PROPERTIES,
138
+ fields: "object"
139
+ },
140
+ block: { fields: "object" },
141
+ heading: {
142
+ ...ELEMENT_PROPERTIES,
143
+ tag: "string"
144
+ },
145
+ inlineBlock: { fields: "object" },
146
+ link: {
147
+ ...ELEMENT_PROPERTIES,
148
+ fields: "object"
149
+ },
150
+ list: {
151
+ ...ELEMENT_PROPERTIES,
152
+ listType: "string",
153
+ start: "number"
154
+ },
155
+ listitem: {
156
+ ...ELEMENT_PROPERTIES,
157
+ value: "number"
158
+ },
159
+ paragraph: ELEMENT_PROPERTIES,
160
+ quote: ELEMENT_PROPERTIES,
161
+ relationship: {
162
+ relationTo: "string",
163
+ value: "number"
164
+ },
165
+ tab: {
166
+ ...TEXT_PROPERTIES,
167
+ detail: { is: 2 },
168
+ text: { is: " " }
169
+ },
170
+ text: TEXT_PROPERTIES,
171
+ upload: {
172
+ fields: "optionalObject",
173
+ relationTo: "string",
174
+ value: "number"
175
+ }
176
+ };
177
+ /**
178
+ * Whether the table already says what a node type's `fields` has to be, so the
179
+ * sub-field walk does not report the same problem a second time.
180
+ */ const constrainsFields = (type) => "fields" in (REQUIRED_NODE_PROPERTIES[type] ?? {});
181
+ const describeConstraints = (constraints) => Object.fromEntries(Object.entries(constraints).map(([property, constraint]) => [property, needs(constraint)]).sort((left, right) => left[0].localeCompare(right[0])));
182
+ /**
183
+ * What each of the given node types has to carry, phrased the way the write
184
+ * side phrases it when it refuses one, so the listing and the error message
185
+ * never disagree. Read straight off the tables above, which is what keeps it
186
+ * true: nothing here is stated a second time.
187
+ *
188
+ * Keyed by node type rather than reported per field, because that is what it
189
+ * depends on. A field says which types it allows, in its `nodes`; what a `text`
190
+ * node has to carry is the same wherever one is written, so a response that
191
+ * describes twenty rich text fields still states it once.
192
+ *
193
+ * `type` is listed although {@link UNIVERSAL_PROPERTIES} omits it. The
194
+ * validator never reports it missing, because a node's `type` is how it finds
195
+ * the entry to check against, but a client assembling a node from this listing
196
+ * still has to write one.
197
+ */ const nodePropertiesFor = (types) => Object.fromEntries([...new Set(types)].sort().map((type) => [type, describeConstraints(type === "root" ? ROOT_PROPERTIES : {
198
+ ...REQUIRED_NODE_PROPERTIES[type],
199
+ ...UNIVERSAL_PROPERTIES,
200
+ type: "string"
201
+ })]));
202
+ /** Present but `null` counts as present: `direction` is serialized that way. */ const check = (node, constraints) => {
203
+ const problems = {
204
+ missing: [],
205
+ rejected: []
206
+ };
207
+ for (const [property, constraint] of Object.entries(constraints)) if (!(property in node)) problems.missing.push(property);
208
+ else if (!accepts(constraint, node[property])) problems.rejected.push({
209
+ needs: needs(constraint),
210
+ property
211
+ });
212
+ problems.missing.sort();
213
+ problems.rejected.sort((left, right) => left.property.localeCompare(right.property));
214
+ return problems;
215
+ };
216
+ const nodeProblems = (node) => check(node, {
217
+ ...UNIVERSAL_PROPERTIES,
218
+ ...REQUIRED_NODE_PROPERTIES[node["type"]] ?? {}
219
+ });
72
220
  /**
73
- * Node properties worth reporting and enforcing.
221
+ * The root is the one node Payload describes itself, down to refusing an
222
+ * unknown property, so it is checked against that description rather than
223
+ * against the walk's table.
224
+ */ const rootProblems = (root) => ({
225
+ ...check(root, ROOT_PROPERTIES),
226
+ unexpected: Object.keys(root).filter((property) => !(property in ROOT_PROPERTIES))
227
+ });
228
+ /**
229
+ * What one serialized property has to be, for a write addressing a property
230
+ * rather than a whole node.
74
231
  *
232
+ * Absent where the table says nothing, which is the same tolerance the node
233
+ * walk shows: a project's own node may carry any property, and guessing at one
234
+ * would reject content that works.
235
+ */ const propertyProblem = (nodeType, property, value) => {
236
+ const constraint = (nodeType === "root" ? ROOT_PROPERTIES : {
237
+ ...UNIVERSAL_PROPERTIES,
238
+ ...REQUIRED_NODE_PROPERTIES[nodeType] ?? {}
239
+ })[property];
240
+ return constraint === void 0 || accepts(constraint, value) ? void 0 : { needs: needs(constraint) };
241
+ };
242
+ /**
75
243
  * Only properties a feature narrows and Lexical does not check on its own
76
244
  * belong here. Everything else a feature restricts is already visible: a
77
245
  * link's targets through its sub-schema, a block node's choices through the
@@ -114,4 +282,4 @@ const stringList = (value) => Array.isArray(value) && value.every((entry) => typ
114
282
  return entries.length > 0 ? Object.fromEntries(entries) : void 0;
115
283
  };
116
284
  //#endregion
117
- export { allowedNodeTypes, lexicalSubSchema, nodeOptions, subSchemaNodeTypes };
285
+ export { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, nodePropertiesFor, propertyProblem, rootProblems, subSchemaNodeTypes };