@abinnovision/payloadcms-mcpx 1.0.0-beta.4 → 1.0.0-beta.6

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 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
@@ -44,6 +44,9 @@ export default buildConfig({
44
44
  posts: { read: true, write: true },
45
45
  tags: true, // shorthand for { read: true }
46
46
  },
47
+ globals: {
48
+ "site-settings": { read: true, write: true },
49
+ },
47
50
  limits: { maxLimit: 25, maxDepth: 1 },
48
51
  }),
49
52
  ],
@@ -56,8 +59,8 @@ The plugin adds:
56
59
  JSON responses; `GET`/`DELETE` answer 405),
57
60
  - an `mcpx-api-keys` collection (admin group "MCP") holding the keys and their
58
61
  capability checkboxes,
59
- - a draft guard on every collection, so any write carrying the MCP request
60
- marker lands as a draft, including writes made by custom tools.
62
+ - a draft guard on every collection and global, so any write carrying the MCP
63
+ request marker lands as a draft, including writes made by custom tools.
61
64
 
62
65
  ## API keys
63
66
 
@@ -118,19 +121,20 @@ Claude Desktop (no direct HTTP header support) via `mcp-remote`:
118
121
 
119
122
  ## Tools
120
123
 
121
- The surface is fixed at seven tools plus your custom ones. `tools/list`
122
- reflects the key: write tools disappear for read-only keys, and every
123
- `collection` enum contains only the slugs the key may touch.
124
+ The surface is fixed at seven tools plus your custom ones; exposing a global
125
+ adds an argument, never a tool. `tools/list` reflects the key: write tools
126
+ disappear for read-only keys, and every `collection` and `global` enum contains
127
+ only the slugs the key may touch.
124
128
 
125
129
  | Tool | Purpose | Key arguments |
126
130
  | ------------------ | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
127
131
  | `listCapabilities` | What this key may do; call first to orient. | none |
128
- | `describeSchema` | Field shape of one node; `next` lists the drill-down paths. | `collection`, `paths?`, `expand?` |
132
+ | `describeSchema` | Field shape of one node; `next` lists the drill-down paths. | `collection` \| `global`, `paths?`, `expand?` |
129
133
  | `findDocuments` | Query documents. | `collection`, `where?`, `sort?`, `limit?`, `page?`, `depth?`, `select?`, `locale?`, `draft?` |
130
- | `getDocument` | Read one document or a subtree of it. | `collection`, `id`, `path?` (JSON pointer), `depth?`, `locale?`, `draft?` |
131
- | `patchDocument` | Apply RFC 6902 operations to the current draft. | `collection`, `id`, `locale`, `patches`, `expectedUpdatedAt?` |
134
+ | `getDocument` | Read one document or a subtree of it. | `collection` + `id` \| `global`, `path?` (JSON pointer), `depth?`, `locale?`, `draft?` |
135
+ | `patchDocument` | Apply RFC 6902 operations to the current draft. | `collection` + `id` \| `global`, `locale`, `patches`, `expectedUpdatedAt?` |
132
136
  | `createDocument` | Create a draft from a minimal seed. | `collection`, `locale`, `data` |
133
- | `validateDocument` | Publish blockers without writing. | `collection`, `id`, `locale` |
137
+ | `validateDocument` | Publish blockers without writing. | `collection` + `id` \| `global`, `locale` |
134
138
 
135
139
  Rules the tools enforce and explain in their own descriptions:
136
140
 
@@ -138,6 +142,15 @@ Rules the tools enforce and explain in their own descriptions:
138
142
  accept; every node carries `next`, the ready-to-use paths for those blocks
139
143
  (`/layout/sections/sectionWrapper`), so pass an entry of `next` as a `paths`
140
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.
141
154
  - Field and collection `admin.description` values are included in
142
155
  `describeSchema` and `listCapabilities`, so intent written for the admin
143
156
  panel reaches the client. Strings and locale-keyed records pass through;
@@ -161,13 +174,55 @@ Rules the tools enforce and explain in their own descriptions:
161
174
  `deletedAt`) are never listed and never writable; `readOnly` fields are
162
175
  listed but refused on write.
163
176
 
177
+ ## Globals
178
+
179
+ A global is exposed the same way a collection is, and reached through the same
180
+ tools rather than tools of its own:
181
+
182
+ ```ts
183
+ mcpxPlugin({
184
+ collections: { pages: { read: true, write: true } },
185
+ globals: { "site-settings": { read: true, write: true } },
186
+ });
187
+ ```
188
+
189
+ Two rules follow from a global being a singleton, and because JSON Schema cannot
190
+ state either one, both are enforced in the handler and repeated in every
191
+ affected tool description:
192
+
193
+ - Pass exactly **one** of `collection` and `global`.
194
+ - `id` is required with `collection` and must be omitted with `global`.
195
+
196
+ Refusals name the offending argument and the slug, so one failed call teaches
197
+ the rule. `findDocuments` and `createDocument` stay collection-only: there is
198
+ nothing to list and nothing to create when the document always exists. They
199
+ reject a `global` argument by name.
200
+
201
+ Globals get their own `capabilities.globals.<name>` checkbox group, a separate
202
+ namespace from `capabilities.collections.<name>`, so a global may share a
203
+ camelCase name with a collection. Keys issued before a global was exposed have
204
+ no such group, and an absent checkbox reads as `false`, so they stay closed to
205
+ every global until one is ticked.
206
+
207
+ Globals always carry `updatedAt` — Payload appends it and there is no
208
+ `timestamps: false` for globals — so `expectedUpdatedAt` behaves as it does for
209
+ collections. The one exception is a global that has never been saved: it has no
210
+ `updatedAt` to compare against, so the first write must omit
211
+ `expectedUpdatedAt`, and supplying one is refused as a concurrency failure.
212
+
213
+ If `tools/list` omits `global` entirely, no global is exposed to that key; the
214
+ argument only appears once one is. A deployment that uses no globals sees the
215
+ tool schemas exactly as they were.
216
+
164
217
  ## Drafts and publish blockers
165
218
 
166
219
  Draft-only writing is enforced on the Payload operation, not in the tool
167
220
  handlers: a `beforeOperation` hook forces `draft: true` and strips `_status`
168
221
  from every write carrying the MCP request marker, so custom tools and anything
169
222
  else writing through the same request are covered too. A `beforeChange` hook
170
- refuses any write that would still not land as a draft.
223
+ refuses any write that would still not land as a draft. Globals expose the same
224
+ `beforeOperation` interception point at the same position in the operation, so
225
+ they are guarded exactly as strongly as collections, exposed or not.
171
226
 
172
227
  Publish blockers are advisory. Payload skips validation on draft saves (unless
173
228
  `versions.drafts.validate` is set), so after every write the plugin re-runs
@@ -224,6 +279,10 @@ Builtin tools reject them instead.
224
279
  | `collections.<slug>.read` | `true` | Expose `describeSchema`, `findDocuments`, `getDocument`. |
225
280
  | `collections.<slug>.write` | `false` | Expose `patchDocument`, `createDocument`, `validateDocument`. Requires `versions.drafts` unless `allowLiveWrites`. |
226
281
  | `collections.<slug>.allowLiveWrites` | `false` | Permit writes to a collection without drafts (they land live). |
282
+ | `globals` | `{}` | Allow-list of globals. `true` means `{ read: true }`. |
283
+ | `globals.<slug>.read` | `true` | Expose `describeSchema`, `getDocument`. |
284
+ | `globals.<slug>.write` | `false` | Expose `patchDocument`, `validateDocument`. Requires `versions.drafts` unless `allowLiveWrites`. |
285
+ | `globals.<slug>.allowLiveWrites` | `false` | Permit writes to a global without drafts (they land live). |
227
286
  | `userCollection` | `config.admin.user` or `users` | Auth collection the keys act as. |
228
287
  | `apiKeys.slug` | `mcpx-api-keys` | Slug of the generated key collection. |
229
288
  | `apiKeys.overrideCollection` | none | Final override applied to the generated collection. |
@@ -247,15 +306,16 @@ key of every user.
247
306
  - The endpoint authenticates with Bearer keys only; admin JWTs and cookies are
248
307
  ignored. Keys cannot authenticate REST or GraphQL.
249
308
  - Every operation runs under the linked user with `overrideAccess: false`.
250
- - Not covered in v1: `delete` (no tool exists and none is generated), globals,
251
- uploads. Custom tools are trusted code and can do what the linked user may.
309
+ - Not covered in v1: `delete` (no tool exists and none is generated), uploads.
310
+ Custom tools are trusted code and can do what the linked user may.
252
311
 
253
312
  ## Non-goals of v1 / roadmap
254
313
 
255
- Globals, deletes, uploads, markdown authoring for rich text, row addressing by
256
- id instead of index, cross-locale publish blockers, pagination of
257
- `describeSchema` with `expand`, and a handler-level timeout are all deliberate
258
- omissions for now.
314
+ Deletes, uploads, markdown authoring for rich text, addressing a rich text node
315
+ by position in a patch (an editor state is written whole), schemas for `upload`
316
+ node fields, row addressing by id instead of index, cross-locale publish
317
+ blockers, pagination of `describeSchema` with `expand`, and a handler-level
318
+ timeout are all deliberate omissions for now.
259
319
 
260
320
  ## License
261
321
 
@@ -56,9 +56,10 @@ const checkbox = (name, description) => ({
56
56
  }
57
57
  ];
58
58
  /**
59
- * One checkbox per exposed operation, grouped per collection and per custom
60
- * tool. Only operations the plugin config exposes get a checkbox, so a key can
61
- * never enable more than the config allows. Everything defaults to off.
59
+ * One checkbox per exposed operation, grouped per collection, per global and
60
+ * per custom tool. Only operations the plugin config exposes get a checkbox, so
61
+ * a key can never enable more than the config allows. Everything defaults to
62
+ * off, which is why a key issued before a capability existed stays closed to it.
62
63
  */ const createCapabilityFields = (options) => {
63
64
  const collectionGroups = options.collections.map((collection) => ({
64
65
  name: collection.fieldName,
@@ -66,16 +67,30 @@ const checkbox = (name, description) => ({
66
67
  label: collection.slug,
67
68
  fields: [...collection.read ? [checkbox("read", "Describe, find and read documents.")] : [], ...collection.write ? [checkbox("write", "Create, patch and validate drafts.")] : []]
68
69
  }));
69
- const toolCheckboxes = options.tools.map((tool) => checkbox(tool.name, tool.description));
70
- const groups = [...collectionGroups.length > 0 ? [{
71
- name: "collections",
72
- type: "group",
73
- fields: collectionGroups
74
- }] : [], ...toolCheckboxes.length > 0 ? [{
75
- name: "tools",
70
+ const globalGroups = options.globals.map((global) => ({
71
+ name: global.fieldName,
76
72
  type: "group",
77
- fields: toolCheckboxes
78
- }] : []];
73
+ label: global.slug,
74
+ fields: [...global.read ? [checkbox("read", "Describe and read this global.")] : [], ...global.write ? [checkbox("write", "Patch and validate this global's draft.")] : []]
75
+ }));
76
+ const toolCheckboxes = options.tools.map((tool) => checkbox(tool.name, tool.description));
77
+ const groups = [
78
+ ...collectionGroups.length > 0 ? [{
79
+ name: "collections",
80
+ type: "group",
81
+ fields: collectionGroups
82
+ }] : [],
83
+ ...globalGroups.length > 0 ? [{
84
+ name: "globals",
85
+ type: "group",
86
+ fields: globalGroups
87
+ }] : [],
88
+ ...toolCheckboxes.length > 0 ? [{
89
+ name: "tools",
90
+ type: "group",
91
+ fields: toolCheckboxes
92
+ }] : []
93
+ ];
79
94
  if (groups.length === 0) return [];
80
95
  return [{
81
96
  name: CAPABILITIES_FIELD,
@@ -8,6 +8,7 @@ const flag = (group, name) => isRecord(group) && group[name] === true;
8
8
  * issued before a capability existed stay closed.
9
9
  */ const resolveCapabilities = (options, keyCapabilities) => {
10
10
  const collectionsGroup = isRecord(keyCapabilities) ? keyCapabilities["collections"] : void 0;
11
+ const globalsGroup = isRecord(keyCapabilities) ? keyCapabilities["globals"] : void 0;
11
12
  const toolsGroup = isRecord(keyCapabilities) ? keyCapabilities["tools"] : void 0;
12
13
  const collections = {};
13
14
  for (const collection of options.collections) {
@@ -17,14 +18,26 @@ const flag = (group, name) => isRecord(group) && group[name] === true;
17
18
  write: collection.write && flag(group, "write")
18
19
  };
19
20
  }
21
+ const globals = {};
22
+ for (const global of options.globals) {
23
+ const group = isRecord(globalsGroup) ? globalsGroup[global.fieldName] : void 0;
24
+ globals[global.slug] = {
25
+ read: global.read && flag(group, "read"),
26
+ write: global.write && flag(group, "write")
27
+ };
28
+ }
20
29
  const tools = {};
21
30
  for (const tool of options.tools) tools[tool.name] = flag(toolsGroup, tool.name);
22
31
  return {
23
32
  collections,
33
+ globals,
24
34
  tools
25
35
  };
26
36
  };
27
- const readableSlugs = (capabilities) => Object.entries(capabilities.collections).filter(([, value]) => value.read).map(([slug]) => slug);
28
- const writableSlugs = (capabilities) => Object.entries(capabilities.collections).filter(([, value]) => value.write).map(([slug]) => slug);
37
+ const pick = (entries, operation) => Object.entries(entries).filter(([, value]) => value[operation]).map(([slug]) => slug);
38
+ const readableSlugs = (capabilities) => pick(capabilities.collections, "read");
39
+ const writableSlugs = (capabilities) => pick(capabilities.collections, "write");
40
+ const readableGlobalSlugs = (capabilities) => pick(capabilities.globals, "read");
41
+ const writableGlobalSlugs = (capabilities) => pick(capabilities.globals, "write");
29
42
  //#endregion
30
- export { CAPABILITIES_FIELD, readableSlugs, resolveCapabilities, writableSlugs };
43
+ export { CAPABILITIES_FIELD, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs };
@@ -1,4 +1,4 @@
1
- import { readableSlugs, resolveCapabilities, writableSlugs } from "../capabilities.mjs";
1
+ import { readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs } from "../capabilities.mjs";
2
2
  import { resolveApiKeyAuth } from "../auth/resolve.mjs";
3
3
  import { jsonRpcError } from "./result.mjs";
4
4
  import { createMcpServer } from "./server.mjs";
@@ -12,6 +12,8 @@ const buildScope = (req, options, capabilities) => {
12
12
  capabilities,
13
13
  readable: readableSlugs(capabilities),
14
14
  writable: writableSlugs(capabilities),
15
+ readableGlobals: readableGlobalSlugs(capabilities),
16
+ writableGlobals: writableGlobalSlugs(capabilities),
15
17
  locales: localization ? localization.localeCodes : null,
16
18
  defaultLocale: localization ? localization.defaultLocale : null
17
19
  };
@@ -18,7 +18,7 @@ import { z } from "zod";
18
18
  const server = new McpServer({
19
19
  name: options.serverInfo.name,
20
20
  version: options.serverInfo.version
21
- }, { instructions: "Start with listCapabilities, then describeSchema for the collection you work on. Writes always land as drafts; a human publishes." });
21
+ }, { instructions: "Start with listCapabilities, then describeSchema for the collection or global you work on. Writes always land as drafts; a human publishes." });
22
22
  const guarded = (run) => async () => {
23
23
  try {
24
24
  return await run();
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool } from "./types.mjs";
1
+ import { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool } from "./types.mjs";
2
2
  import { mcpxPlugin } from "./plugin.mjs";
3
3
  import { isMcpxRequest } from "./write/draft-guard.mjs";
4
4
  import { PublishBlocker } from "./write/publish-blockers.mjs";
5
- export { type McpxAuthResult, type McpxCollectionCapabilities, type McpxCollectionOptions, type McpxPluginOptions, type McpxRequestContext, type McpxResolvedCapabilities, type McpxTool, type McpxToolExtra, type PublishBlocker, defineMcpxTool, isMcpxRequest, mcpxPlugin };
5
+ export { type McpxAuthResult, type McpxCollectionCapabilities, type McpxCollectionOptions, type McpxGlobalOptions, type McpxPluginOptions, type McpxRequestContext, type McpxResolvedCapabilities, type McpxTool, type McpxToolExtra, type PublishBlocker, defineMcpxTool, isMcpxRequest, mcpxPlugin };
@@ -0,0 +1,2 @@
1
+ import "./types.mjs";
2
+ import "payload";
package/dist/options.mjs CHANGED
@@ -30,6 +30,19 @@ const assertWritable = (collection, options) => {
30
30
  if (collection.timestamps === false) fail(`Collection "${slug}" has timestamps disabled, which write tools need for concurrency checks.`);
31
31
  if (!options.hasDrafts && !options.allowLiveWrites) fail(`Collection "${slug}" has no drafts. Enable versions.drafts or set allowLiveWrites.`);
32
32
  };
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) => {
37
+ if (global.slug.startsWith("payload-")) fail(`Global "${global.slug}" cannot be exposed.`);
38
+ };
39
+ /**
40
+ * `GlobalConfig` has no `timestamps` option and `sanitizeGlobal` always appends
41
+ * `createdAt`/`updatedAt`, so the concurrency check the collection path guards
42
+ * for is always available here. Drafts are the only requirement left.
43
+ */ const assertGlobalWritable = (global, options) => {
44
+ if (!options.hasDrafts && !options.allowLiveWrites) fail(`Global "${global.slug}" has no drafts. Enable versions.drafts or set allowLiveWrites.`);
45
+ };
33
46
  const normalizeCollections = (config, options, apiKeysSlug) => {
34
47
  const collections = config.collections ?? [];
35
48
  const fieldNames = /* @__PURE__ */ new Set();
@@ -54,6 +67,30 @@ const normalizeCollections = (config, options, apiKeysSlug) => {
54
67
  return [normalized];
55
68
  });
56
69
  };
70
+ const normalizeGlobals = (config, options) => {
71
+ const globals = config.globals ?? [];
72
+ const fieldNames = /* @__PURE__ */ new Set();
73
+ return Object.entries(options.globals ?? {}).flatMap(([slug, raw]) => {
74
+ if (raw === void 0) return [];
75
+ const global = globals.find((candidate) => candidate.slug === slug);
76
+ if (!global) return fail(`Exposed global "${slug}" does not exist.`);
77
+ assertGlobalExposable(global);
78
+ const settings = raw === true ? {} : raw;
79
+ const hasDrafts = hasDraftsEnabled(global);
80
+ const normalized = {
81
+ slug,
82
+ read: settings.read ?? true,
83
+ write: settings.write ?? false,
84
+ allowLiveWrites: settings.allowLiveWrites ?? false,
85
+ hasDrafts,
86
+ fieldName: toCamelCase(slug)
87
+ };
88
+ if (normalized.write) assertGlobalWritable(global, normalized);
89
+ if (fieldNames.has(normalized.fieldName)) fail(`Global "${slug}" maps to capability field "${normalized.fieldName}", which another exposed global already uses.`);
90
+ fieldNames.add(normalized.fieldName);
91
+ return [normalized];
92
+ });
93
+ };
57
94
  const assertUserCollection = (config, slug) => {
58
95
  const collection = (config.collections ?? []).find((candidate) => candidate.slug === slug);
59
96
  if (!collection) fail(`User collection "${slug}" does not exist.`);
@@ -91,6 +128,7 @@ const normalizeLimits = (limits) => {
91
128
  assertTools(tools);
92
129
  return {
93
130
  collections: normalizeCollections(config, options, apiKeysSlug),
131
+ globals: normalizeGlobals(config, options),
94
132
  userCollection,
95
133
  apiKeysSlug,
96
134
  endpointPath: options.endpoint?.path ?? DEFAULT_ENDPOINT_PATH,
package/dist/plugin.d.mts CHANGED
@@ -2,7 +2,7 @@ import { McpxPluginOptions } from "./types.mjs";
2
2
  //#region src/plugin.d.ts
3
3
  /**
4
4
  * Mounts the MCP endpoint, adds the API key collection and installs the
5
- * draft guard on every collection.
5
+ * draft guard on every collection and global.
6
6
  */
7
7
  declare const mcpxPlugin: (options: McpxPluginOptions) => import("payload").Plugin;
8
8
  //#endregion
package/dist/plugin.mjs CHANGED
@@ -1,12 +1,12 @@
1
1
  import { createApiKeysCollection } from "./api-keys/collection.mjs";
2
2
  import { createMcpxHandler, methodNotAllowed } from "./endpoint/handler.mjs";
3
3
  import { normalizeOptions } from "./options.mjs";
4
- import { installDraftGuards } from "./write/draft-guard.mjs";
4
+ import { installDraftGuards, installGlobalDraftGuards } from "./write/draft-guard.mjs";
5
5
  import { definePlugin } from "payload";
6
6
  //#region src/plugin.ts
7
7
  /**
8
8
  * Mounts the MCP endpoint, adds the API key collection and installs the
9
- * draft guard on every collection.
9
+ * draft guard on every collection and global.
10
10
  */ const mcpxPlugin = definePlugin({
11
11
  slug: "@abinnovision/payloadcms-mcpx",
12
12
  order: 100,
@@ -17,6 +17,7 @@ import { definePlugin } from "payload";
17
17
  return {
18
18
  ...config,
19
19
  collections: installDraftGuards([...config.collections ?? [], apiKeysCollection]),
20
+ globals: installGlobalDraftGuards(config.globals ?? []),
20
21
  endpoints: [
21
22
  ...config.endpoints ?? [],
22
23
  {
@@ -1,32 +1,86 @@
1
- import { blockOf, blockSlugsOf, collectionOf, describeFields, findBlocksField, joinPath, splitPath } from "./walk.mjs";
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
- const blocksDescriptors = (fields) => describeFields(fields).filter((descriptor) => descriptor.type === "blocks");
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.
11
- */ const fieldsAtSchemaPath = (config, collection, schemaPath) => {
12
- let fields = collection.flattenedFields;
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.
62
+ */ const fieldsAtSchemaPath = (config, target, schemaPath) => {
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 blocks field's own path may span several segments
18
- * (`/layout/sections`), so the longest matching one is taken.
19
- */ const match = blocksDescriptors(fields).map((descriptor) => splitPath(descriptor.path)).filter((parts) => parts.every((part, offset) => part === remaining[offset])).sort((left, right) => right.length - left.length)[0];
20
- if (!match) throw new Error(`"${joinPath(remaining)}" does not address a blocks field. Blocks fields here: ${blocksDescriptors(fields).map((descriptor) => descriptor.path).join(", ") || "none"}`);
21
- const slug = remaining.at(match.length);
22
- const field = findBlocksField(fields, match);
23
- if (!field) throw new Error(`"${joinPath(match)}" could not be resolved.`);
24
- if (slug === void 0) throw new Error(`"${joinPath(match)}" is a blocks field; append one of: ${blockSlugsOf(field).join(", ")}`);
25
- const block = blockOf(config, field, slug);
26
- if (!block) throw new Error(`"${slug}" is not allowed at "${joinPath(match)}". Allowed: ${blockSlugsOf(field).join(", ")}`);
27
- fields = block.flattenedFields;
28
- blockType = slug;
29
- remaining = remaining.slice(match.length + 1);
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,24 +88,50 @@ const blocksDescriptors = (fields) => describeFields(fields).filter((descriptor)
34
88
  };
35
89
  };
36
90
  /**
37
- * Describes a collection root, or one block reached through a schema path.
38
- */ const describeNode = (config, collection, schemaPath = "") => {
39
- const { blockType, fields } = fieldsAtSchemaPath(config, collectionOf(config, collection), schemaPath);
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.
118
+ */ const describeNode = (config, ref, schemaPath = "") => {
119
+ const { blockType, fields } = fieldsAtSchemaPath(config, targetOf(config, ref), schemaPath);
40
120
  const descriptors = describeFields(fields);
41
- const next = descriptors.flatMap((descriptor) => (descriptor.blocks ?? []).map((slug) => `${schemaPath}${descriptor.path}/${slug}`));
121
+ const next = descriptors.flatMap((descriptor) => branchesOf(fields, descriptor, schemaPath).map((branch) => branch.path));
42
122
  return {
43
123
  ...blockType === void 0 ? {} : { blockType },
44
- collection,
124
+ ...ref.kind === "collection" ? { collection: ref.slug } : { global: ref.slug },
45
125
  fields: descriptors,
46
126
  ...next.length > 0 ? { next } : {},
47
127
  schemaPath
48
128
  };
49
129
  };
50
130
  /**
51
- * Every schema path reachable from a collection root, capped at
131
+ * Every schema path reachable from an entity root, capped at
52
132
  * {@link REACHABLE_PATHS_LIMIT}. `truncated` tells the caller the cap was hit
53
133
  * and explicit paths are the way to go deeper.
54
- */ const reachableSchemaPaths = (config, collection) => {
134
+ */ const reachableSchemaPaths = (config, ref) => {
55
135
  const seen = [];
56
136
  let truncated = false;
57
137
  const walk = (schemaPath, visited) => {
@@ -60,9 +140,10 @@ const blocksDescriptors = (fields) => describeFields(fields).filter((descriptor)
60
140
  return;
61
141
  }
62
142
  seen.push(schemaPath);
63
- for (const descriptor of describeNode(config, collection, schemaPath).fields) for (const slug of descriptor.blocks ?? []) {
64
- if (visited.includes(slug)) continue;
65
- walk(`${schemaPath}${descriptor.path}/${slug}`, [...visited, slug]);
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]);
66
147
  }
67
148
  };
68
149
  walk("", []);
@@ -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,61 @@
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.editor?.editorConfig?.features?.nodes ?? []).flatMap((entry) => {
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);
24
72
  //#endregion
25
- export { allowedNodeTypes };
73
+ export { allowedNodeTypes, lexicalSubSchema, subSchemaNodeTypes };