@abinnovision/payloadcms-mcpx 1.0.0-beta.3

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 (40) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +258 -0
  3. package/dist/api-keys/collection.mjs +58 -0
  4. package/dist/api-keys/fields.mjs +88 -0
  5. package/dist/api-keys/key.mjs +10 -0
  6. package/dist/auth/resolve.mjs +62 -0
  7. package/dist/capabilities.mjs +30 -0
  8. package/dist/endpoint/handler.mjs +84 -0
  9. package/dist/endpoint/result.mjs +59 -0
  10. package/dist/endpoint/server.mjs +52 -0
  11. package/dist/index.d.mts +5 -0
  12. package/dist/index.mjs +4 -0
  13. package/dist/options.mjs +107 -0
  14. package/dist/plugin.d.mts +9 -0
  15. package/dist/plugin.mjs +42 -0
  16. package/dist/schema/describe.mjs +82 -0
  17. package/dist/schema/lexical.mjs +25 -0
  18. package/dist/schema/pointer.mjs +83 -0
  19. package/dist/schema/shape.mjs +147 -0
  20. package/dist/schema/walk.mjs +109 -0
  21. package/dist/tools/create-document.mjs +67 -0
  22. package/dist/tools/describe-schema.mjs +44 -0
  23. package/dist/tools/find-documents.mjs +54 -0
  24. package/dist/tools/get-document.mjs +54 -0
  25. package/dist/tools/index.mjs +23 -0
  26. package/dist/tools/list-capabilities.mjs +49 -0
  27. package/dist/tools/names.mjs +14 -0
  28. package/dist/tools/patch-document.mjs +113 -0
  29. package/dist/tools/shared.mjs +65 -0
  30. package/dist/tools/validate-document.mjs +48 -0
  31. package/dist/types.d.mts +113 -0
  32. package/dist/types.mjs +7 -0
  33. package/dist/version.mjs +6 -0
  34. package/dist/write/draft-guard.d.mts +10 -0
  35. package/dist/write/draft-guard.mjs +70 -0
  36. package/dist/write/patch.mjs +220 -0
  37. package/dist/write/publish-blockers.d.mts +13 -0
  38. package/dist/write/publish-blockers.mjs +72 -0
  39. package/dist/write/transaction.mjs +19 -0
  40. package/package.json +89 -0
@@ -0,0 +1,30 @@
1
+ //#region src/capabilities.ts
2
+ /** Name of the capability group on the key document. */ const CAPABILITIES_FIELD = "capabilities";
3
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4
+ const flag = (group, name) => isRecord(group) && group[name] === true;
5
+ /**
6
+ * Capabilities in force for a key: the plugin config decides what can exist,
7
+ * the key's checkboxes decide what does. A missing checkbox is `false`, so keys
8
+ * issued before a capability existed stay closed.
9
+ */ const resolveCapabilities = (options, keyCapabilities) => {
10
+ const collectionsGroup = isRecord(keyCapabilities) ? keyCapabilities["collections"] : void 0;
11
+ const toolsGroup = isRecord(keyCapabilities) ? keyCapabilities["tools"] : void 0;
12
+ const collections = {};
13
+ for (const collection of options.collections) {
14
+ const group = isRecord(collectionsGroup) ? collectionsGroup[collection.fieldName] : void 0;
15
+ collections[collection.slug] = {
16
+ read: collection.read && flag(group, "read"),
17
+ write: collection.write && flag(group, "write")
18
+ };
19
+ }
20
+ const tools = {};
21
+ for (const tool of options.tools) tools[tool.name] = flag(toolsGroup, tool.name);
22
+ return {
23
+ collections,
24
+ tools
25
+ };
26
+ };
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);
29
+ //#endregion
30
+ export { CAPABILITIES_FIELD, readableSlugs, resolveCapabilities, writableSlugs };
@@ -0,0 +1,84 @@
1
+ import { readableSlugs, resolveCapabilities, writableSlugs } from "../capabilities.mjs";
2
+ import { resolveApiKeyAuth } from "../auth/resolve.mjs";
3
+ import { jsonRpcError } from "./result.mjs";
4
+ import { createMcpServer } from "./server.mjs";
5
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
6
+ //#region src/endpoint/handler.ts
7
+ const buildScope = (req, options, capabilities) => {
8
+ const { localization } = req.payload.config;
9
+ return {
10
+ req,
11
+ options,
12
+ capabilities,
13
+ readable: readableSlugs(capabilities),
14
+ writable: writableSlugs(capabilities),
15
+ locales: localization ? localization.localeCodes : null,
16
+ defaultLocale: localization ? localization.defaultLocale : null
17
+ };
18
+ };
19
+ /**
20
+ * Answers GET and DELETE on the endpoint path. The server is stateless and
21
+ * never streams, so only POST carries meaning.
22
+ */ const methodNotAllowed = () => jsonRpcError({
23
+ status: 405,
24
+ code: -32e3,
25
+ message: "Method not allowed. MCP requests must use POST.",
26
+ headers: { allow: "POST" }
27
+ });
28
+ /**
29
+ * The MCP endpoint. Authenticates the bearer key, sets `req.user` and the
30
+ * request marker, then serves the JSON-RPC body with a fresh server and
31
+ * transport. Any user Payload resolved from cookies or a JWT is ignored: only
32
+ * an API key authenticates here.
33
+ */ const createMcpxHandler = (options) => async (req) => {
34
+ const resolveDefault = () => resolveApiKeyAuth(req, options);
35
+ const auth = options.auth?.resolve ? await options.auth.resolve({
36
+ req,
37
+ resolveDefault
38
+ }) : await resolveDefault();
39
+ if (!auth) return jsonRpcError({
40
+ status: 401,
41
+ code: -32001,
42
+ message: "Unauthorized: a valid API key is required.",
43
+ headers: { "www-authenticate": "Bearer" }
44
+ });
45
+ const capabilities = resolveCapabilities(options, auth.capabilities);
46
+ req.user = auth.user;
47
+ req.context = {
48
+ ...req.context,
49
+ mcpx: {
50
+ apiKeyId: auth.apiKeyId,
51
+ capabilities
52
+ }
53
+ };
54
+ let parsedBody;
55
+ try {
56
+ parsedBody = await req.json?.();
57
+ } catch {
58
+ return jsonRpcError({
59
+ status: 400,
60
+ code: -32700,
61
+ message: "Parse error: Invalid JSON"
62
+ });
63
+ }
64
+ if (parsedBody === void 0 || req.url === void 0) return jsonRpcError({
65
+ status: 400,
66
+ code: -32600,
67
+ message: "Invalid request: a JSON body is required."
68
+ });
69
+ const server = createMcpServer(buildScope(req, options, capabilities));
70
+ const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
71
+ await server.connect(transport);
72
+ const headers = new Headers(req.headers);
73
+ headers.set("accept", "application/json, text/event-stream");
74
+ try {
75
+ return await transport.handleRequest(new Request(req.url, {
76
+ method: "POST",
77
+ headers
78
+ }), { parsedBody });
79
+ } finally {
80
+ await server.close();
81
+ }
82
+ };
83
+ //#endregion
84
+ export { createMcpxHandler, methodNotAllowed };
@@ -0,0 +1,59 @@
1
+ import { APIError, ValidationError } from "payload";
2
+ //#region src/endpoint/result.ts
3
+ /**
4
+ * A JSON-RPC error response for failures that happen before the MCP server
5
+ * is involved (auth, method, body parsing).
6
+ */ const jsonRpcError = (args) => {
7
+ const headers = new Headers(args.headers);
8
+ headers.set("content-type", "application/json");
9
+ return new Response(JSON.stringify({
10
+ jsonrpc: "2.0",
11
+ id: null,
12
+ error: {
13
+ code: args.code,
14
+ message: args.message
15
+ }
16
+ }), {
17
+ status: args.status,
18
+ headers
19
+ });
20
+ };
21
+ /**
22
+ * A successful tool result carrying `value` as JSON text.
23
+ */ const jsonResult = (value) => ({ content: [{
24
+ type: "text",
25
+ text: JSON.stringify(value)
26
+ }] });
27
+ /**
28
+ * A failed tool result. `extras` travel alongside the message so the client
29
+ * can act on them (problems, validation errors, the current `updatedAt`).
30
+ */ const errorResult = (message, extras = {}) => ({
31
+ content: [{
32
+ type: "text",
33
+ text: JSON.stringify({
34
+ error: message,
35
+ ...extras
36
+ })
37
+ }],
38
+ isError: true
39
+ });
40
+ /**
41
+ * Maps an exception thrown by a tool to a result the client can read.
42
+ *
43
+ * Payload's public errors keep their message and status; a `ValidationError`
44
+ * also surfaces its per-field detail. Anything else is logged and reported as
45
+ * an internal error so no stack or driver message leaks to the client.
46
+ */ const toToolError = (error, logger) => {
47
+ if (error instanceof ValidationError) return errorResult(error.message, {
48
+ status: error.status,
49
+ validationErrors: error.data.errors
50
+ });
51
+ if (error instanceof APIError && error.isPublic) return errorResult(error.message, { status: error.status });
52
+ logger.error({
53
+ err: error,
54
+ msg: "[payloadcms-mcpx] Tool call failed."
55
+ });
56
+ return errorResult("Internal error");
57
+ };
58
+ //#endregion
59
+ export { errorResult, jsonResult, jsonRpcError, toToolError };
@@ -0,0 +1,52 @@
1
+ import { toToolError } from "./result.mjs";
2
+ import { BUILTIN_TOOLS } from "../tools/index.mjs";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { z } from "zod";
5
+ //#region src/endpoint/server.ts
6
+ /**
7
+ * Builds a builtin tool's input schema as a strict object, so an unknown
8
+ * argument is rejected with its name instead of being silently stripped and
9
+ * the tool answering as if it had not been passed.
10
+ */ const builtinInputSchema = (tool, scope) => z.strictObject(tool.inputSchema(scope));
11
+ /**
12
+ * Builds the MCP server for one request. Tools are registered against the
13
+ * key's capabilities, so `tools/list` shows exactly what the key may call and
14
+ * every `collection` enum is limited to what it may touch.
15
+ */ const createMcpServer = (scope) => {
16
+ const { req, options, capabilities } = scope;
17
+ const { logger } = req.payload;
18
+ const server = new McpServer({
19
+ name: options.serverInfo.name,
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." });
22
+ const guarded = (run) => async () => {
23
+ try {
24
+ return await run();
25
+ } catch (error) {
26
+ return toToolError(error, logger);
27
+ }
28
+ };
29
+ for (const tool of BUILTIN_TOOLS) {
30
+ if (!tool.isEnabled(scope)) continue;
31
+ server.registerTool(tool.name, {
32
+ description: tool.description,
33
+ inputSchema: builtinInputSchema(tool, scope),
34
+ annotations: tool.annotations
35
+ }, (args) => guarded(() => tool.handler(args, scope))());
36
+ }
37
+ for (const tool of options.tools) {
38
+ if (capabilities.tools[tool.name] !== true) continue;
39
+ server.registerTool(tool.name, {
40
+ description: tool.description,
41
+ inputSchema: tool.inputSchema ?? {},
42
+ ...tool.annotations ? { annotations: tool.annotations } : {}
43
+ }, (args, extra) => guarded(() => tool.handler({
44
+ args,
45
+ req,
46
+ extra
47
+ }))());
48
+ }
49
+ return server;
50
+ };
51
+ //#endregion
52
+ export { builtinInputSchema, createMcpServer };
@@ -0,0 +1,5 @@
1
+ import { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool } from "./types.mjs";
2
+ import { mcpxPlugin } from "./plugin.mjs";
3
+ import { isMcpxRequest } from "./write/draft-guard.mjs";
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 };
package/dist/index.mjs ADDED
@@ -0,0 +1,4 @@
1
+ import { isMcpxRequest } from "./write/draft-guard.mjs";
2
+ import { mcpxPlugin } from "./plugin.mjs";
3
+ import { defineMcpxTool } from "./types.mjs";
4
+ export { defineMcpxTool, isMcpxRequest, mcpxPlugin };
@@ -0,0 +1,107 @@
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
+ const normalizeCollections = (config, options, apiKeysSlug) => {
34
+ const collections = config.collections ?? [];
35
+ const fieldNames = /* @__PURE__ */ new Set();
36
+ return Object.entries(options.collections).flatMap(([slug, raw]) => {
37
+ if (raw === void 0) return [];
38
+ const collection = collections.find((candidate) => candidate.slug === slug);
39
+ if (!collection) return fail(`Exposed collection "${slug}" does not exist.`);
40
+ assertExposable(collection, apiKeysSlug);
41
+ const settings = raw === true ? {} : raw;
42
+ const hasDrafts = hasDraftsEnabled(collection);
43
+ const normalized = {
44
+ slug,
45
+ read: settings.read ?? true,
46
+ write: settings.write ?? false,
47
+ allowLiveWrites: settings.allowLiveWrites ?? false,
48
+ hasDrafts,
49
+ fieldName: toCamelCase(slug)
50
+ };
51
+ if (normalized.write) assertWritable(collection, normalized);
52
+ if (fieldNames.has(normalized.fieldName)) fail(`Collection "${slug}" maps to capability field "${normalized.fieldName}", which another exposed collection already uses.`);
53
+ fieldNames.add(normalized.fieldName);
54
+ return [normalized];
55
+ });
56
+ };
57
+ const assertUserCollection = (config, slug) => {
58
+ const collection = (config.collections ?? []).find((candidate) => candidate.slug === slug);
59
+ if (!collection) fail(`User collection "${slug}" does not exist.`);
60
+ else if (!collection.auth) fail(`User collection "${slug}" is not an auth collection.`);
61
+ };
62
+ const assertTools = (tools) => {
63
+ const names = /* @__PURE__ */ new Set();
64
+ for (const tool of tools) {
65
+ if (!TOOL_NAME_PATTERN.test(tool.name)) fail(`Tool name "${tool.name}" must match ${String(TOOL_NAME_PATTERN)}.`);
66
+ if (BUILTIN_TOOL_NAMES.includes(tool.name)) fail(`Tool name "${tool.name}" is reserved for a builtin tool.`);
67
+ if (names.has(tool.name)) fail(`Tool name "${tool.name}" is used twice.`);
68
+ names.add(tool.name);
69
+ }
70
+ };
71
+ const normalizeLimits = (limits) => {
72
+ const maxLimit = limits?.maxLimit ?? DEFAULT_MAX_LIMIT;
73
+ const maxDepth = limits?.maxDepth ?? DEFAULT_MAX_DEPTH;
74
+ if (!Number.isInteger(maxLimit) || maxLimit < 1) fail("limits.maxLimit must be a positive integer.");
75
+ if (!Number.isInteger(maxDepth) || maxDepth < 0) fail("limits.maxDepth must be a non-negative integer.");
76
+ return {
77
+ maxLimit,
78
+ maxDepth
79
+ };
80
+ };
81
+ /**
82
+ * Validates the plugin options against the incoming config and fills in
83
+ * defaults. Every problem is an `InvalidConfiguration` so misconfiguration
84
+ * fails at startup instead of at request time.
85
+ */ const normalizeOptions = (config, options) => {
86
+ const apiKeysSlug = options.apiKeys?.slug ?? DEFAULT_API_KEYS_SLUG;
87
+ const userCollection = options.userCollection ?? config.admin?.user ?? "users";
88
+ if ((config.collections ?? []).some((c) => c.slug === apiKeysSlug)) fail(`API key collection slug "${apiKeysSlug}" is already taken.`);
89
+ assertUserCollection(config, userCollection);
90
+ const tools = options.tools ?? [];
91
+ assertTools(tools);
92
+ return {
93
+ collections: normalizeCollections(config, options, apiKeysSlug),
94
+ userCollection,
95
+ apiKeysSlug,
96
+ endpointPath: options.endpoint?.path ?? DEFAULT_ENDPOINT_PATH,
97
+ limits: normalizeLimits(options.limits),
98
+ tools,
99
+ auth: options.auth,
100
+ serverInfo: {
101
+ name: options.serverInfo?.name ?? "payloadcms-mcpx",
102
+ version: options.serverInfo?.version ?? "1.0.0-beta.3"
103
+ }
104
+ };
105
+ };
106
+ //#endregion
107
+ 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.
6
+ */
7
+ declare const mcpxPlugin: (options: McpxPluginOptions) => import("payload").Plugin;
8
+ //#endregion
9
+ export { mcpxPlugin };
@@ -0,0 +1,42 @@
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 } 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.
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
+ endpoints: [
21
+ ...config.endpoints ?? [],
22
+ {
23
+ path: normalized.endpointPath,
24
+ method: "post",
25
+ handler: createMcpxHandler(normalized)
26
+ },
27
+ {
28
+ path: normalized.endpointPath,
29
+ method: "get",
30
+ handler: methodNotAllowed
31
+ },
32
+ {
33
+ path: normalized.endpointPath,
34
+ method: "delete",
35
+ handler: methodNotAllowed
36
+ }
37
+ ]
38
+ };
39
+ }
40
+ });
41
+ //#endregion
42
+ export { mcpxPlugin };
@@ -0,0 +1,82 @@
1
+ import { blockOf, blockSlugsOf, collectionOf, describeFields, findBlocksField, joinPath, splitPath } from "./walk.mjs";
2
+ //#region src/schema/describe.ts
3
+ const blocksDescriptors = (fields) => describeFields(fields).filter((descriptor) => descriptor.type === "blocks");
4
+ /**
5
+ * Walks a schema path to the field list it addresses.
6
+ *
7
+ * A schema path alternates a blocks field's own path with the slug of one of
8
+ * the blocks it accepts, so `layout.sections.sectionWrapper.modules.hero`
9
+ * reaches `hero` as it exists under `pages` specifically.
10
+ */ const fieldsAtSchemaPath = (config, collection, schemaPath) => {
11
+ let fields = collection.flattenedFields;
12
+ let blockType;
13
+ let remaining = splitPath(schemaPath).filter(Boolean);
14
+ while (remaining.length > 0) {
15
+ /**
16
+ * A blocks field's own path may span several segments
17
+ * (`layout.sections`), so the longest matching one is taken.
18
+ */ 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];
19
+ if (!match) throw new Error(`"${joinPath(remaining)}" does not address a blocks field. Blocks fields here: ${blocksDescriptors(fields).map((descriptor) => descriptor.path).join(", ") || "none"}`);
20
+ const slug = remaining.at(match.length);
21
+ const field = findBlocksField(fields, match);
22
+ if (!field) throw new Error(`"${match.join(".")}" could not be resolved.`);
23
+ if (slug === void 0) throw new Error(`"${joinPath(match)}" is a blocks field; append one of: ${blockSlugsOf(field).join(", ")}`);
24
+ const block = blockOf(config, field, slug);
25
+ if (!block) throw new Error(`"${slug}" is not allowed at "${joinPath(match)}". Allowed: ${blockSlugsOf(field).join(", ")}`);
26
+ fields = block.flattenedFields;
27
+ blockType = slug;
28
+ remaining = remaining.slice(match.length + 1);
29
+ }
30
+ return {
31
+ ...blockType === void 0 ? {} : { blockType },
32
+ fields
33
+ };
34
+ };
35
+ /**
36
+ * Describes a collection root, or one block reached through a schema path.
37
+ */ const describeNode = (config, collection, schemaPath = "") => {
38
+ const { blockType, fields } = fieldsAtSchemaPath(config, collectionOf(config, collection), schemaPath);
39
+ const descriptors = describeFields(fields);
40
+ const next = descriptors.flatMap((descriptor) => (descriptor.blocks ?? []).map((slug) => [
41
+ schemaPath,
42
+ descriptor.path,
43
+ slug
44
+ ].filter(Boolean).join(".")));
45
+ return {
46
+ ...blockType === void 0 ? {} : { blockType },
47
+ collection,
48
+ fields: descriptors,
49
+ ...next.length > 0 ? { next } : {},
50
+ schemaPath
51
+ };
52
+ };
53
+ /**
54
+ * Every schema path reachable from a collection root, capped at
55
+ * {@link REACHABLE_PATHS_LIMIT}. `truncated` tells the caller the cap was hit
56
+ * and explicit paths are the way to go deeper.
57
+ */ const reachableSchemaPaths = (config, collection) => {
58
+ const seen = [];
59
+ let truncated = false;
60
+ const walk = (schemaPath, visited) => {
61
+ if (seen.length >= 400) {
62
+ truncated = true;
63
+ return;
64
+ }
65
+ seen.push(schemaPath);
66
+ for (const descriptor of describeNode(config, collection, schemaPath).fields) for (const slug of descriptor.blocks ?? []) {
67
+ if (visited.includes(slug)) continue;
68
+ walk([
69
+ schemaPath,
70
+ descriptor.path,
71
+ slug
72
+ ].filter(Boolean).join("."), [...visited, slug]);
73
+ }
74
+ };
75
+ walk("", []);
76
+ return {
77
+ paths: seen,
78
+ truncated
79
+ };
80
+ };
81
+ //#endregion
82
+ export { describeNode, reachableSchemaPaths };
@@ -0,0 +1,25 @@
1
+ //#region src/schema/lexical.ts
2
+ /**
3
+ * Node types Lexical registers itself.
4
+ *
5
+ * `editorConfig.features.nodes` lists only what a feature contributed, so a
6
+ * field whose editor enables nothing but text formatting reports none at all.
7
+ */ const LEXICAL_CORE_NODES = [
8
+ "root",
9
+ "paragraph",
10
+ "text",
11
+ "linebreak",
12
+ "tab"
13
+ ];
14
+ /**
15
+ * Node types a rich text field accepts. Editors other than Lexical report
16
+ * only the core nodes.
17
+ */ const allowedNodeTypes = (field) => {
18
+ const registered = (field.editor?.editorConfig?.features?.nodes ?? []).flatMap((entry) => {
19
+ const type = entry.node?.getType?.();
20
+ return type ? [type] : [];
21
+ });
22
+ return [.../* @__PURE__ */ new Set([...LEXICAL_CORE_NODES, ...registered])];
23
+ };
24
+ //#endregion
25
+ export { allowedNodeTypes };
@@ -0,0 +1,83 @@
1
+ import { blockOf, blockSlugsOf, collectionOf, describeFields, findBlocksField, joinPath, splitPath } from "./walk.mjs";
2
+ //#region src/schema/pointer.ts
3
+ const isIndexSegment = (segment) => segment === "-" || /^\d+$/.test(segment);
4
+ /**
5
+ * Decodes a JSON Pointer into its segments, unescaping `~1` and `~0`.
6
+ */ const pointerSegments = (pointer) => pointer.split("/").slice(1).map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
7
+ const partMatches = (part, segment) => segment !== void 0 && (part === "[]" ? isIndexSegment(segment) : part === segment);
8
+ /**
9
+ * Longest descriptor whose path is fully consumed by the leading segments.
10
+ */ const longestMatch = (descriptors, segments) => descriptors.map((descriptor) => ({
11
+ consumed: splitPath(descriptor.path).length,
12
+ descriptor,
13
+ parts: splitPath(descriptor.path)
14
+ })).filter(({ parts }) => parts.every((part, offset) => partMatches(part, segments[offset]))).sort((left, right) => right.consumed - left.consumed)[0];
15
+ /**
16
+ * Whether the segments stop part-way through some descriptor's path, which
17
+ * means they address a subtree rather than a field.
18
+ */ const isSubtreePrefix = (descriptors, segments) => descriptors.some((descriptor) => {
19
+ const parts = splitPath(descriptor.path);
20
+ return parts.length > segments.length && segments.every((segment, offset) => {
21
+ const part = parts[offset];
22
+ return part !== void 0 && partMatches(part, segment);
23
+ });
24
+ });
25
+ /**
26
+ * Reads the value the given pointer segments address. Unlike a descriptor
27
+ * path, the segments carry real indices, so intervening array fields are
28
+ * descended through rather than skipped.
29
+ */ const valueAtSegments = (data, segments) => segments.reduce((current, segment) => current === null || typeof current !== "object" ? void 0 : current[segment], data);
30
+ /**
31
+ * Resolves a JSON Pointer against the schema, using the stored document to
32
+ * choose a branch at every blocks element.
33
+ *
34
+ * The document is required rather than optional: `/layout/sections/3/modules/1`
35
+ * can only be resolved by reading `blockType` off `sections[3]`, since a blocks
36
+ * field admits many shapes at the same index.
37
+ */ const resolveDataPointer = (config, target) => {
38
+ let fields = collectionOf(config, target.collection).flattenedFields;
39
+ let data = target.doc;
40
+ let blockType;
41
+ let segments = pointerSegments(target.pointer);
42
+ while (segments.length > 0) {
43
+ const descriptors = describeFields(fields);
44
+ const match = longestMatch(descriptors, segments);
45
+ if (!match) {
46
+ if (isSubtreePrefix(descriptors, segments)) return {
47
+ ...blockType === void 0 ? {} : { blockType },
48
+ fields,
49
+ prefix: joinPath(segments)
50
+ };
51
+ throw new Error(`"${joinPath(segments)}" is not a field here. Available: ${descriptors.map((descriptor) => descriptor.path).join(", ")}`);
52
+ }
53
+ const rest = segments.slice(match.consumed);
54
+ if (rest.length === 0) return {
55
+ ...blockType === void 0 ? {} : { blockType },
56
+ descriptor: match.descriptor,
57
+ fields,
58
+ prefix: ""
59
+ };
60
+ if (match.descriptor.type !== "blocks") throw new Error(`"${match.descriptor.path}" is a ${match.descriptor.type} field and has no "${rest.join("/")}" beneath it.`);
61
+ const [index, ...remaining] = rest;
62
+ if (!isIndexSegment(index)) throw new Error(`"${match.descriptor.path}" is an array; "${index}" is not an index.`);
63
+ const parts = splitPath(match.descriptor.path);
64
+ const field = findBlocksField(fields, parts);
65
+ const rows = valueAtSegments(data, segments.slice(0, match.consumed));
66
+ const existing = Array.isArray(rows) && index !== "-" ? rows[Number(index)] : void 0;
67
+ const slug = existing?.blockType ?? target.addedValue?.blockType;
68
+ 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(", ") : ""}`);
69
+ const block = blockOf(config, field, slug);
70
+ if (!block) throw new Error(`"${slug}" is not allowed at "${match.descriptor.path}". Allowed: ${blockSlugsOf(field).join(", ")}`);
71
+ blockType = slug;
72
+ fields = block.flattenedFields;
73
+ data = existing;
74
+ segments = remaining;
75
+ }
76
+ return {
77
+ ...blockType === void 0 ? {} : { blockType },
78
+ fields,
79
+ prefix: ""
80
+ };
81
+ };
82
+ //#endregion
83
+ export { pointerSegments, resolveDataPointer };