@abinnovision/payloadcms-mcpx 1.0.0-beta.10

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 (53) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +353 -0
  3. package/dist/api-keys/collection.mjs +58 -0
  4. package/dist/api-keys/fields.mjs +137 -0
  5. package/dist/api-keys/key.mjs +10 -0
  6. package/dist/api-keys/setup-guide.mjs +54 -0
  7. package/dist/auth/resolve.mjs +62 -0
  8. package/dist/capabilities.mjs +43 -0
  9. package/dist/client/index.d.mts +2 -0
  10. package/dist/client/index.mjs +2 -0
  11. package/dist/client/setup-guide.d.mts +14 -0
  12. package/dist/client/setup-guide.mjs +87 -0
  13. package/dist/endpoint/handler.mjs +86 -0
  14. package/dist/endpoint/result.mjs +65 -0
  15. package/dist/endpoint/server.mjs +52 -0
  16. package/dist/i18n.d.mts +1 -0
  17. package/dist/i18n.mjs +40 -0
  18. package/dist/index.d.mts +5 -0
  19. package/dist/index.mjs +4 -0
  20. package/dist/options.d.mts +2 -0
  21. package/dist/options.mjs +146 -0
  22. package/dist/plugin.d.mts +9 -0
  23. package/dist/plugin.mjs +43 -0
  24. package/dist/schema/describe.mjs +160 -0
  25. package/dist/schema/lexical.d.mts +1 -0
  26. package/dist/schema/lexical.mjs +117 -0
  27. package/dist/schema/pointer.mjs +80 -0
  28. package/dist/schema/shape.mjs +208 -0
  29. package/dist/schema/walk.d.mts +3 -0
  30. package/dist/schema/walk.mjs +165 -0
  31. package/dist/tools/create-document.mjs +68 -0
  32. package/dist/tools/describe-schema.mjs +54 -0
  33. package/dist/tools/find-documents.mjs +55 -0
  34. package/dist/tools/get-document.mjs +68 -0
  35. package/dist/tools/index.mjs +23 -0
  36. package/dist/tools/list-capabilities.mjs +68 -0
  37. package/dist/tools/names.mjs +14 -0
  38. package/dist/tools/patch-document.mjs +127 -0
  39. package/dist/tools/shared.mjs +97 -0
  40. package/dist/tools/target.d.mts +3 -0
  41. package/dist/tools/target.mjs +49 -0
  42. package/dist/tools/types.d.mts +5 -0
  43. package/dist/tools/validate-document.mjs +55 -0
  44. package/dist/types.d.mts +143 -0
  45. package/dist/types.mjs +7 -0
  46. package/dist/version.mjs +6 -0
  47. package/dist/write/draft-guard.d.mts +10 -0
  48. package/dist/write/draft-guard.mjs +113 -0
  49. package/dist/write/patch.mjs +219 -0
  50. package/dist/write/publish-blockers.d.mts +15 -0
  51. package/dist/write/publish-blockers.mjs +74 -0
  52. package/dist/write/transaction.mjs +19 -0
  53. package/package.json +104 -0
@@ -0,0 +1,2 @@
1
+ import "./types.mjs";
2
+ import "payload";
@@ -0,0 +1,146 @@
1
+ import { BUILTIN_TOOL_NAMES } from "./tools/names.mjs";
2
+ import "./version.mjs";
3
+ import { InvalidConfiguration } from "payload";
4
+ import { hasDraftsEnabled } from "payload/shared";
5
+ //#region src/options.ts
6
+ const DEFAULT_API_KEYS_SLUG = "mcpx-api-keys";
7
+ const DEFAULT_ENDPOINT_PATH = "/mcpx";
8
+ const DEFAULT_MAX_LIMIT = 25;
9
+ const DEFAULT_MAX_DEPTH = 1;
10
+ const TOOL_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9]*$/;
11
+ const fail = (message) => {
12
+ throw new InvalidConfiguration(`[payloadcms-mcpx] ${message}`);
13
+ };
14
+ /**
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
+ * Auth collections carry credentials: `useAPIKey` stores a key that decrypts on
21
+ * read, and email or lockout state is PII either way.
22
+ */ const assertExposable = (collection, apiKeysSlug) => {
23
+ const { slug } = collection;
24
+ if (slug === apiKeysSlug || slug.startsWith("payload-")) fail(`Collection "${slug}" cannot be exposed.`);
25
+ if (collection.auth) fail(`Auth collection "${slug}" cannot be exposed. Its documents carry credentials.`);
26
+ };
27
+ const assertWritable = (collection, options) => {
28
+ const { slug } = collection;
29
+ if (collection.upload) fail(`Upload collection "${slug}" cannot be exposed for write.`);
30
+ 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.`);
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
+ };
46
+ const normalizeCollections = (config, options, apiKeysSlug) => {
47
+ const collections = config.collections ?? [];
48
+ const fieldNames = /* @__PURE__ */ new Set();
49
+ return Object.entries(options.collections).flatMap(([slug, raw]) => {
50
+ if (raw === void 0) return [];
51
+ const collection = collections.find((candidate) => candidate.slug === slug);
52
+ if (!collection) return fail(`Exposed collection "${slug}" does not exist.`);
53
+ assertExposable(collection, apiKeysSlug);
54
+ const settings = raw === true ? {} : raw;
55
+ const hasDrafts = hasDraftsEnabled(collection);
56
+ const normalized = {
57
+ slug,
58
+ read: settings.read ?? true,
59
+ write: settings.write ?? false,
60
+ allowLiveWrites: settings.allowLiveWrites ?? false,
61
+ hasDrafts,
62
+ fieldName: toCamelCase(slug)
63
+ };
64
+ if (normalized.write) assertWritable(collection, normalized);
65
+ if (fieldNames.has(normalized.fieldName)) fail(`Collection "${slug}" maps to capability field "${normalized.fieldName}", which another exposed collection already uses.`);
66
+ fieldNames.add(normalized.fieldName);
67
+ return [normalized];
68
+ });
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
+ };
94
+ const assertUserCollection = (config, slug) => {
95
+ const collection = (config.collections ?? []).find((candidate) => candidate.slug === slug);
96
+ if (!collection) fail(`User collection "${slug}" does not exist.`);
97
+ else if (!collection.auth) fail(`User collection "${slug}" is not an auth collection.`);
98
+ };
99
+ const assertTools = (tools) => {
100
+ const names = /* @__PURE__ */ new Set();
101
+ for (const tool of tools) {
102
+ if (!TOOL_NAME_PATTERN.test(tool.name)) fail(`Tool name "${tool.name}" must match ${String(TOOL_NAME_PATTERN)}.`);
103
+ if (BUILTIN_TOOL_NAMES.includes(tool.name)) fail(`Tool name "${tool.name}" is reserved for a builtin tool.`);
104
+ if (names.has(tool.name)) fail(`Tool name "${tool.name}" is used twice.`);
105
+ names.add(tool.name);
106
+ }
107
+ };
108
+ const normalizeLimits = (limits) => {
109
+ const maxLimit = limits?.maxLimit ?? DEFAULT_MAX_LIMIT;
110
+ const maxDepth = limits?.maxDepth ?? DEFAULT_MAX_DEPTH;
111
+ if (!Number.isInteger(maxLimit) || maxLimit < 1) fail("limits.maxLimit must be a positive integer.");
112
+ if (!Number.isInteger(maxDepth) || maxDepth < 0) fail("limits.maxDepth must be a non-negative integer.");
113
+ return {
114
+ maxLimit,
115
+ maxDepth
116
+ };
117
+ };
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) => {
123
+ const apiKeysSlug = options.apiKeys?.slug ?? DEFAULT_API_KEYS_SLUG;
124
+ const userCollection = options.userCollection ?? config.admin?.user ?? "users";
125
+ if ((config.collections ?? []).some((c) => c.slug === apiKeysSlug)) fail(`API key collection slug "${apiKeysSlug}" is already taken.`);
126
+ assertUserCollection(config, userCollection);
127
+ const tools = options.tools ?? [];
128
+ assertTools(tools);
129
+ return {
130
+ collections: normalizeCollections(config, options, apiKeysSlug),
131
+ globals: normalizeGlobals(config, options),
132
+ userCollection,
133
+ apiKeysSlug,
134
+ endpointPath: options.endpoint?.path ?? DEFAULT_ENDPOINT_PATH,
135
+ setupGuide: options.apiKeys?.setupGuide ?? true,
136
+ limits: normalizeLimits(options.limits),
137
+ tools,
138
+ auth: options.auth,
139
+ serverInfo: {
140
+ name: options.serverInfo?.name ?? "payloadcms-mcpx",
141
+ version: options.serverInfo?.version ?? "0.0.0"
142
+ }
143
+ };
144
+ };
145
+ //#endregion
146
+ export { normalizeOptions, toCamelCase };
@@ -0,0 +1,9 @@
1
+ import { McpxPluginOptions } from "./types.mjs";
2
+ //#region src/plugin.d.ts
3
+ /**
4
+ * Mounts the MCP endpoint, adds the API key collection and installs the
5
+ * draft guard on every collection and global.
6
+ */
7
+ declare const mcpxPlugin: (options: McpxPluginOptions) => import("payload").Plugin;
8
+ //#endregion
9
+ export { mcpxPlugin };
@@ -0,0 +1,43 @@
1
+ import { createApiKeysCollection } from "./api-keys/collection.mjs";
2
+ import { createMcpxHandler, methodNotAllowed } from "./endpoint/handler.mjs";
3
+ import { normalizeOptions } from "./options.mjs";
4
+ import { installDraftGuards, installGlobalDraftGuards } from "./write/draft-guard.mjs";
5
+ import { definePlugin } from "payload";
6
+ //#region src/plugin.ts
7
+ /**
8
+ * Mounts the MCP endpoint, adds the API key collection and installs the
9
+ * draft guard on every collection and global.
10
+ */ const mcpxPlugin = definePlugin({
11
+ slug: "@abinnovision/payloadcms-mcpx",
12
+ order: 100,
13
+ plugin: ({ config, plugins: _plugins, ...options }) => {
14
+ const normalized = normalizeOptions(config, options);
15
+ const apiKeys = createApiKeysCollection(normalized);
16
+ const apiKeysCollection = options.apiKeys?.overrideCollection?.(apiKeys) ?? apiKeys;
17
+ return {
18
+ ...config,
19
+ collections: installDraftGuards([...config.collections ?? [], apiKeysCollection]),
20
+ globals: installGlobalDraftGuards(config.globals ?? []),
21
+ endpoints: [
22
+ ...config.endpoints ?? [],
23
+ {
24
+ path: normalized.endpointPath,
25
+ method: "post",
26
+ handler: createMcpxHandler(normalized)
27
+ },
28
+ {
29
+ path: normalized.endpointPath,
30
+ method: "get",
31
+ handler: methodNotAllowed
32
+ },
33
+ {
34
+ path: normalized.endpointPath,
35
+ method: "delete",
36
+ handler: methodNotAllowed
37
+ }
38
+ ]
39
+ };
40
+ }
41
+ });
42
+ //#endregion
43
+ export { mcpxPlugin };
@@ -0,0 +1,160 @@
1
+ import { lexicalSubSchema, subSchemaNodeTypes } from "./lexical.mjs";
2
+ import { translateAny } from "../i18n.mjs";
3
+ import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextField, joinPath, splitPath, targetOf } from "./walk.mjs";
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 }) => {
12
+ const field = findBlocksField(fields, match);
13
+ if (!field) throw new Error(`"${joinPath(match)}" could not be resolved.`);
14
+ const slug = remaining.at(match.length);
15
+ if (slug === void 0) throw new Error(`"${joinPath(match)}" is a blocks field; append one of: ${blockSlugsOf(field).join(", ")}`);
16
+ const block = blockOf(config, field, slug);
17
+ if (!block) throw new Error(`"${slug}" is not allowed at "${joinPath(match)}". Allowed: ${blockSlugsOf(field).join(", ")}`);
18
+ return {
19
+ blockType: slug,
20
+ fields: block.flattenedFields,
21
+ rest: remaining.slice(match.length + 1)
22
+ };
23
+ };
24
+ /**
25
+ * Walks one step of a schema path into a Lexical node's own fields.
26
+ *
27
+ * A node that picks a block by slug takes one segment more, so `/content/block`
28
+ * addresses the choice and `/content/block/callout` the definition. Everything
29
+ * else, a link node being the usual case, resolves in a single segment.
30
+ */ const stepThroughLexical = ({ config, fields, match, remaining }) => {
31
+ const field = findRichTextField(fields, match);
32
+ if (!field) throw new Error(`"${joinPath(match)}" could not be resolved.`);
33
+ const available = subSchemaNodeTypes(field).join(", ") || "none";
34
+ const nodeType = remaining.at(match.length);
35
+ if (nodeType === void 0) throw new Error(`"${joinPath(match)}" is a rich text field; append one of: ${available}`);
36
+ const sub = lexicalSubSchema(field, nodeType);
37
+ const reached = joinPath([...match, nodeType]);
38
+ if (!sub) throw new Error(`"${nodeType}" carries no fields in this field's editor. Node types with fields here: ${available}`);
39
+ if (sub.kind === "fields") return {
40
+ fields: sub.fields,
41
+ rest: remaining.slice(match.length + 1)
42
+ };
43
+ const slug = remaining.at(match.length + 1);
44
+ const slugs = blockSlugsOf(sub.blocksField).join(", ");
45
+ if (slug === void 0) throw new Error(`"${reached}" selects a block; append one of: ${slugs}`);
46
+ const block = blockOf(config, sub.blocksField, slug);
47
+ if (!block) throw new Error(`"${slug}" is not allowed at "${reached}". Allowed: ${slugs}`);
48
+ return {
49
+ blockType: slug,
50
+ fields: block.flattenedFields,
51
+ rest: remaining.slice(match.length + 2)
52
+ };
53
+ };
54
+ /**
55
+ * Walks a schema path to the field list it addresses.
56
+ *
57
+ * A schema path alternates a blocks field's own path with the slug of one of
58
+ * the blocks it accepts, so `/layout/sections/sectionWrapper/modules/hero`
59
+ * reaches `hero` as it exists under `pages` specifically. The slug sits where
60
+ * a pointer into a document would carry the element's index. A rich text
61
+ * field's path continues the same way, naming a Lexical node type and, for the
62
+ * block nodes, the slug it holds.
63
+ */ const fieldsAtSchemaPath = (config, target, schemaPath) => {
64
+ let fields = target.flattenedFields;
65
+ let blockType;
66
+ let remaining = splitPath(schemaPath);
67
+ while (remaining.length > 0) {
68
+ const descendable = describeFields(fields).filter((descriptor) => descriptor.type === "blocks" || descriptor.type === "richText");
69
+ /**
70
+ * A field's own path may span several segments (`/layout/sections`), so
71
+ * the longest matching one is taken. Blocks and rich text fields are both
72
+ * leaves of the walk, so no two of these paths overlap.
73
+ */ const match = longestMatch(descendable, remaining);
74
+ 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"}`);
75
+ const at = {
76
+ config,
77
+ fields,
78
+ match,
79
+ remaining
80
+ };
81
+ const step = findBlocksField(fields, match) === void 0 ? stepThroughLexical(at) : stepThroughBlocks(at);
82
+ blockType = step.blockType;
83
+ fields = step.fields;
84
+ remaining = step.rest;
85
+ }
86
+ return {
87
+ ...blockType === void 0 ? {} : { blockType },
88
+ fields
89
+ };
90
+ };
91
+ /**
92
+ * Where a descriptor can be drilled into: one branch per block a blocks field
93
+ * accepts, and one per Lexical node type that carries fields.
94
+ */ const branchesOf = (fields, descriptor, schemaPath) => {
95
+ const base = `${schemaPath}${descriptor.path}`;
96
+ if (descriptor.type === "richText") {
97
+ const field = findRichTextField(fields, splitPath(descriptor.path));
98
+ if (!field) return [];
99
+ return subSchemaNodeTypes(field).flatMap((nodeType) => {
100
+ const sub = lexicalSubSchema(field, nodeType);
101
+ if (sub?.kind !== "blocks") return [{
102
+ path: `${base}/${nodeType}`,
103
+ token: `lexical:${nodeType}`
104
+ }];
105
+ return blockSlugsOf(sub.blocksField).map((slug) => ({
106
+ path: `${base}/${nodeType}/${slug}`,
107
+ token: `lexical:${nodeType}:${slug}`
108
+ }));
109
+ });
110
+ }
111
+ return (descriptor.blocks ?? []).map((slug) => ({
112
+ path: `${base}/${slug}`,
113
+ token: slug
114
+ }));
115
+ };
116
+ /**
117
+ * Describes a collection or global root, one block reached through a schema
118
+ * path, or the fields a Lexical node carries.
119
+ *
120
+ * Curried on the translator that resolves each `admin.description`, so a
121
+ * request binds its language once and the walk itself stays request-free.
122
+ */ const nodeDescriber = (translate = translateAny) => (config, ref, schemaPath = "") => {
123
+ const { blockType, fields } = fieldsAtSchemaPath(config, targetOf(config, ref), schemaPath);
124
+ const descriptors = describeFields(fields, translate);
125
+ const next = descriptors.flatMap((descriptor) => branchesOf(fields, descriptor, schemaPath).map((branch) => branch.path));
126
+ return {
127
+ ...blockType === void 0 ? {} : { blockType },
128
+ ...ref.kind === "collection" ? { collection: ref.slug } : { global: ref.slug },
129
+ fields: descriptors,
130
+ ...next.length > 0 ? { next } : {},
131
+ schemaPath
132
+ };
133
+ };
134
+ /**
135
+ * Every schema path reachable from an entity root, capped at
136
+ * {@link REACHABLE_PATHS_LIMIT}. `truncated` tells the caller the cap was hit
137
+ * and explicit paths are the way to go deeper.
138
+ */ const reachableSchemaPaths = (config, ref) => {
139
+ const seen = [];
140
+ let truncated = false;
141
+ const walk = (schemaPath, visited) => {
142
+ if (seen.length >= 400) {
143
+ truncated = true;
144
+ return;
145
+ }
146
+ seen.push(schemaPath);
147
+ const { fields } = fieldsAtSchemaPath(config, targetOf(config, ref), schemaPath);
148
+ for (const descriptor of describeFields(fields)) for (const branch of branchesOf(fields, descriptor, schemaPath)) {
149
+ if (visited.includes(branch.token)) continue;
150
+ walk(branch.path, [...visited, branch.token]);
151
+ }
152
+ };
153
+ walk("", []);
154
+ return {
155
+ paths: seen,
156
+ truncated
157
+ };
158
+ };
159
+ //#endregion
160
+ export { nodeDescriber, reachableSchemaPaths };
@@ -0,0 +1 @@
1
+ import "payload";
@@ -0,0 +1,117 @@
1
+ import { flattenAllFields } from "payload";
2
+ //#region src/schema/lexical.ts
3
+ /**
4
+ * Node types Lexical registers itself.
5
+ *
6
+ * `editorConfig.features.nodes` lists only what a feature contributed, so a
7
+ * field whose editor enables nothing but text formatting reports none at all.
8
+ */ const LEXICAL_CORE_NODES = [
9
+ "root",
10
+ "paragraph",
11
+ "text",
12
+ "linebreak",
13
+ "tab"
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
+ /**
33
+ * Node types a rich text field accepts. Editors other than Lexical report
34
+ * only the core nodes.
35
+ */ const allowedNodeTypes = (field) => {
36
+ const registered = (featuresOf(field)?.nodes ?? []).flatMap((entry) => {
37
+ const type = entry.node?.getType?.();
38
+ return type ? [type] : [];
39
+ });
40
+ return [.../* @__PURE__ */ new Set([...LEXICAL_CORE_NODES, ...registered])];
41
+ };
42
+ const resolveSubSchema = (field, nodeType) => {
43
+ if (OPAQUE_NODE_TYPES.has(nodeType)) return null;
44
+ const fields = featuresOf(field)?.getSubFields?.get(nodeType)?.({});
45
+ if (!fields?.length) return null;
46
+ const flattened = flattenAllFields({ fields });
47
+ const only = flattened.length === 1 ? flattened[0] : void 0;
48
+ return only?.type === "blocks" ? {
49
+ blocksField: only,
50
+ kind: "blocks"
51
+ } : {
52
+ fields: flattened,
53
+ kind: "fields"
54
+ };
55
+ };
56
+ /**
57
+ * The sub-schema behind one node type of a rich text field, or `undefined`
58
+ * when that node carries no addressable fields.
59
+ */ const lexicalSubSchema = (field, nodeType) => {
60
+ let cached = subSchemaCache.get(field);
61
+ if (!cached) {
62
+ cached = /* @__PURE__ */ new Map();
63
+ subSchemaCache.set(field, cached);
64
+ }
65
+ if (!cached.has(nodeType)) cached.set(nodeType, resolveSubSchema(field, nodeType));
66
+ return cached.get(nodeType) ?? void 0;
67
+ };
68
+ /**
69
+ * Node types of a rich text field that have a sub-schema, in the order their
70
+ * features registered them.
71
+ */ const subSchemaNodeTypes = (field) => [...featuresOf(field)?.getSubFields?.keys() ?? []].filter((nodeType) => lexicalSubSchema(field, nodeType) !== void 0);
72
+ /**
73
+ * Node properties worth reporting and enforcing.
74
+ *
75
+ * Only properties a feature narrows and Lexical does not check on its own
76
+ * belong here. Everything else a feature restricts is already visible: a
77
+ * link's targets through its sub-schema, a block node's choices through the
78
+ * slugs it accepts.
79
+ */ const NODE_OPTION_SOURCES = [{
80
+ defaults: [
81
+ "h1",
82
+ "h2",
83
+ "h3",
84
+ "h4",
85
+ "h5",
86
+ "h6"
87
+ ],
88
+ featureKey: "heading",
89
+ featureProp: "enabledHeadingSizes",
90
+ nodeProp: "tag",
91
+ nodeType: "heading"
92
+ }];
93
+ /**
94
+ * The props a feature was resolved with.
95
+ *
96
+ * Sanitizing the editor drops every feature's props from `editorConfig.features`
97
+ * but leaves them on `resolvedFeatureMap`. A feature that declares no server
98
+ * props keeps only the client ones, so both are tried.
99
+ */ const featurePropsOf = (field, featureKey) => {
100
+ const resolved = field.editor?.editorConfig?.resolvedFeatureMap?.get(featureKey);
101
+ const props = resolved?.sanitizedServerFeatureProps ?? resolved?.clientFeatureProps;
102
+ return typeof props === "object" && props !== null ? props : void 0;
103
+ };
104
+ const stringList = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value : void 0;
105
+ /**
106
+ * The narrowed node properties of a rich text field, for the node types it
107
+ * actually accepts.
108
+ */ const nodeOptions = (field, allowed) => {
109
+ const entries = NODE_OPTION_SOURCES.flatMap((source) => {
110
+ if (!allowed.includes(source.nodeType)) return [];
111
+ const values = stringList(featurePropsOf(field, source.featureKey)?.[source.featureProp]) ?? [...source.defaults];
112
+ return [[source.nodeType, { [source.nodeProp]: values }]];
113
+ });
114
+ return entries.length > 0 ? Object.fromEntries(entries) : void 0;
115
+ };
116
+ //#endregion
117
+ export { allowedNodeTypes, lexicalSubSchema, nodeOptions, subSchemaNodeTypes };
@@ -0,0 +1,80 @@
1
+ import { blockOf, blockSlugsOf, describeAddressableFields, findBlocksField, joinPath, splitPath, targetOf } from "./walk.mjs";
2
+ //#region src/schema/pointer.ts
3
+ const isIndexSegment = (segment) => segment === "-" || /^\d+$/.test(segment);
4
+ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isIndexSegment(segment) : part === segment);
5
+ /**
6
+ * Longest descriptor whose path is fully consumed by the leading segments.
7
+ */ const longestMatch = (descriptors, segments) => descriptors.map((descriptor) => ({
8
+ consumed: splitPath(descriptor.path).length,
9
+ descriptor,
10
+ parts: splitPath(descriptor.path)
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) => {
16
+ const parts = splitPath(descriptor.path);
17
+ return parts.length > segments.length && segments.every((segment, offset) => {
18
+ const part = parts[offset];
19
+ return part !== void 0 && partMatches(part, segment);
20
+ });
21
+ });
22
+ /**
23
+ * Reads the value the given pointer segments address. Unlike a descriptor
24
+ * path, the segments carry real indices, so intervening array fields are
25
+ * descended through rather than skipped.
26
+ */ const valueAtSegments = (data, segments) => segments.reduce((current, segment) => current === null || typeof current !== "object" ? void 0 : current[segment], data);
27
+ /**
28
+ * Resolves a JSON Pointer against the schema, using the stored document to
29
+ * choose a branch at every blocks element.
30
+ *
31
+ * The document is required rather than optional: `/layout/sections/3/modules/1`
32
+ * can only be resolved by reading `blockType` off `sections[3]`, since a blocks
33
+ * field admits many shapes at the same index.
34
+ */ const resolveDataPointer = (config, target) => {
35
+ let fields = targetOf(config, target.ref).flattenedFields;
36
+ let data = target.doc;
37
+ let blockType;
38
+ let segments = splitPath(target.pointer);
39
+ while (segments.length > 0) {
40
+ const descriptors = describeAddressableFields(fields);
41
+ const match = longestMatch(descriptors, segments);
42
+ if (!match) {
43
+ if (isSubtreePrefix(descriptors, segments)) return {
44
+ ...blockType === void 0 ? {} : { blockType },
45
+ fields,
46
+ prefix: segments
47
+ };
48
+ throw new Error(`"${joinPath(segments)}" is not a field here. Available: ${descriptors.map((descriptor) => descriptor.path).join(", ")}`);
49
+ }
50
+ const rest = segments.slice(match.consumed);
51
+ if (rest.length === 0) return {
52
+ ...blockType === void 0 ? {} : { blockType },
53
+ descriptor: match.descriptor,
54
+ fields,
55
+ prefix: []
56
+ };
57
+ if (match.descriptor.type !== "blocks") throw new Error(`"${match.descriptor.path}" is a ${match.descriptor.type} field and has no "${joinPath(rest)}" beneath it.`);
58
+ const [index, ...remaining] = rest;
59
+ if (!isIndexSegment(index)) throw new Error(`"${match.descriptor.path}" is an array; "${index}" is not an index.`);
60
+ const parts = splitPath(match.descriptor.path);
61
+ const field = findBlocksField(fields, parts);
62
+ const rows = valueAtSegments(data, segments.slice(0, match.consumed));
63
+ const existing = Array.isArray(rows) && index !== "-" ? rows[Number(index)] : void 0;
64
+ const slug = existing?.blockType ?? target.addedValue?.blockType;
65
+ if (!field || slug === void 0) throw new Error(`Cannot tell which block "${match.descriptor.path}/${index}" is. Supply a "blockType" on the value, one of: ${field ? blockSlugsOf(field).join(", ") : ""}`);
66
+ const block = blockOf(config, field, slug);
67
+ if (!block) throw new Error(`"${slug}" is not allowed at "${match.descriptor.path}". Allowed: ${blockSlugsOf(field).join(", ")}`);
68
+ blockType = slug;
69
+ fields = block.flattenedFields;
70
+ data = existing;
71
+ segments = remaining;
72
+ }
73
+ return {
74
+ ...blockType === void 0 ? {} : { blockType },
75
+ fields,
76
+ prefix: []
77
+ };
78
+ };
79
+ //#endregion
80
+ export { resolveDataPointer };