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

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
@@ -151,6 +151,16 @@ Rules the tools enforce and explain in their own descriptions:
151
151
  guessed. Any feature declaring `getSubFields` is picked up, custom ones
152
152
  included. `upload` nodes are the exception: their fields depend on the
153
153
  collection the node points at, so they are not addressable.
154
+ - Constraints a field declares travel with it: `minRows`/`maxRows` on arrays
155
+ and blocks fields, `maxLength`/`minLength` on text, `min`/`max` on numbers.
156
+ An array is described in its own right, so the `*` in `/items/*/title` has
157
+ something to read; a group or named tab only when it declares a description
158
+ or a constraint of its own.
159
+ - A `richText` field also reports `nodeOptions`, the node properties its editor
160
+ narrows. An editor built with `HeadingFeature({ enabledHeadingSizes: ["h4"] })`
161
+ answers `{ "heading": { "tag": ["h4"] } }`, and a write carrying any other
162
+ heading tag is refused. Lexical stores whatever tag it is given, so this is
163
+ the only place the restriction is checked.
154
164
  - Field and collection `admin.description` values are included in
155
165
  `describeSchema` and `listCapabilities`, so intent written for the admin
156
166
  panel reaches the client. Strings and locale-keyed records pass through;
@@ -0,0 +1 @@
1
+ import "payload";
@@ -69,5 +69,49 @@ const resolveSubSchema = (field, nodeType) => {
69
69
  * Node types of a rich text field that have a sub-schema, in the order their
70
70
  * features registered them.
71
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
+ };
72
116
  //#endregion
73
- export { allowedNodeTypes, lexicalSubSchema, subSchemaNodeTypes };
117
+ export { allowedNodeTypes, lexicalSubSchema, nodeOptions, subSchemaNodeTypes };
@@ -1,4 +1,4 @@
1
- import { blockOf, blockSlugsOf, describeFields, findBlocksField, joinPath, splitPath, targetOf } from "./walk.mjs";
1
+ import { blockOf, blockSlugsOf, describeAddressableFields, findBlocksField, joinPath, splitPath, targetOf } from "./walk.mjs";
2
2
  //#region src/schema/pointer.ts
3
3
  const isIndexSegment = (segment) => segment === "-" || /^\d+$/.test(segment);
4
4
  const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isIndexSegment(segment) : part === segment);
@@ -37,7 +37,7 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
37
37
  let blockType;
38
38
  let segments = splitPath(target.pointer);
39
39
  while (segments.length > 0) {
40
- const descriptors = describeFields(fields);
40
+ const descriptors = describeAddressableFields(fields);
41
41
  const match = longestMatch(descriptors, segments);
42
42
  if (!match) {
43
43
  if (isSubtreePrefix(descriptors, segments)) return {
@@ -1,5 +1,5 @@
1
1
  import { lexicalSubSchema } from "./lexical.mjs";
2
- import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextField, splitPath } from "./walk.mjs";
2
+ import { blockOf, blockSlugsOf, describeAddressableFields, findBlocksField, findRichTextField, splitPath } from "./walk.mjs";
3
3
  //#region src/schema/shape.ts
4
4
  /**
5
5
  * Keys Payload manages on a row that a client may echo back harmlessly.
@@ -54,7 +54,9 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
54
54
  * few node types that register one, so a `heading` inside a field whose
55
55
  * editor has no heading feature is stored without complaint and only fails
56
56
  * later, at render or when the document is reopened in the admin editor. A key
57
- * a node's fields do not declare is dropped just as silently.
57
+ * a node's fields do not declare is dropped just as silently. The same holds
58
+ * one level down, for the node properties a feature narrows: an `h3` in an
59
+ * editor restricted to `h4` is stored as readily as an `h4`.
58
60
  */ const checkRichText = (scope, editor, value) => {
59
61
  if (!isPlainObject(value) || !isPlainObject(value["root"])) {
60
62
  scope.problems.push(`${scope.pointer}: expected a Lexical editor state with a "root".`);
@@ -72,6 +74,10 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
72
74
  scope.problems.push(`${at}: "${node["type"]}" is not available in this field's editor. Allowed: ${editor.allowed.join(", ")}`);
73
75
  return;
74
76
  }
77
+ for (const [property, values] of Object.entries(editor.nodeOptions?.[node["type"]] ?? {})) {
78
+ const value = node[property];
79
+ if (typeof value === "string" && !values.includes(value)) scope.problems.push(`${at}/${property}: "${value}" is not available for a "${node["type"]}" node in this field's editor. Allowed: ${values.join(", ")}`);
80
+ }
75
81
  if (editor.field) checkNodeFields({
76
82
  ...scope,
77
83
  pointer: at
@@ -92,7 +98,8 @@ const checkLeafValue = (scope, descriptor, value) => {
92
98
  if (descriptor.type === "richText") {
93
99
  checkRichText(scope, {
94
100
  allowed: descriptor.nodes ?? [],
95
- field: findRichTextField(scope.fields, splitPath(descriptor.path))
101
+ field: findRichTextField(scope.fields, splitPath(descriptor.path)),
102
+ nodeOptions: descriptor.nodeOptions
96
103
  }, value);
97
104
  return;
98
105
  }
@@ -122,15 +129,16 @@ const checkLeafValue = (scope, descriptor, value) => {
122
129
  * Walks an incoming value against the schema, reporting every shape problem
123
130
  * rather than the first.
124
131
  *
125
- * Shape only: unknown field names, unknown block slugs, read-only fields and
126
- * unusable rich text nodes. Required-ness, row counts, enum membership and
127
- * relationship existence stay with Payload, which already checks them and
128
- * reports them per field. Without this pass a misspelled field inside a new
129
- * block would be stripped in silence.
132
+ * Shape only: unknown field names, unknown block slugs, read-only fields, and
133
+ * rich text nodes or node properties the field's editor cannot produce.
134
+ * Required-ness, row counts, lengths, enum membership and relationship
135
+ * existence stay with Payload, which already checks them and reports them per
136
+ * field. Without this pass a misspelled field inside a new block would be
137
+ * stripped in silence.
130
138
  */ const checkValue = (scope, value) => {
131
139
  if (!isPlainObject(value)) return;
132
140
  const prefixParts = scope.prefix;
133
- const relative = describeFields(scope.fields).flatMap((descriptor) => {
141
+ const relative = describeAddressableFields(scope.fields).flatMap((descriptor) => {
134
142
  const parts = splitPath(descriptor.path);
135
143
  return prefixParts.every((part, offset) => part === parts[offset]) ? [{
136
144
  descriptor,
@@ -1 +1,2 @@
1
+ import "./lexical.mjs";
1
2
  import "payload";
@@ -1,4 +1,4 @@
1
- import { allowedNodeTypes } from "./lexical.mjs";
1
+ import { allowedNodeTypes, nodeOptions } from "./lexical.mjs";
2
2
  import { fieldIsHiddenOrDisabled, fieldIsVirtual } from "payload/shared";
3
3
  //#region src/schema/walk.ts
4
4
  /**
@@ -70,7 +70,15 @@ const describeLeaf = (field, path, readOnly) => {
70
70
  if (field.type === "select" || field.type === "radio") descriptor.options = field.options.map((option) => typeof option === "string" ? option : option.value);
71
71
  if (field.type === "relationship" || field.type === "upload") descriptor.relationTo = field.relationTo;
72
72
  if ((field.type === "select" || field.type === "relationship" || field.type === "upload") && field.hasMany === true) descriptor.hasMany = true;
73
- if (field.type === "richText") descriptor.nodes = allowedNodeTypes(field);
73
+ if ((field.type === "text" || field.type === "textarea") && field.maxLength !== void 0) descriptor.maxLength = field.maxLength;
74
+ if ((field.type === "text" || field.type === "textarea") && field.minLength !== void 0) descriptor.minLength = field.minLength;
75
+ if (field.type === "number" && field.max !== void 0) descriptor.max = field.max;
76
+ if (field.type === "number" && field.min !== void 0) descriptor.min = field.min;
77
+ if (field.type === "richText") {
78
+ descriptor.nodes = allowedNodeTypes(field);
79
+ const options = nodeOptions(field, descriptor.nodes);
80
+ if (options) descriptor.nodeOptions = options;
81
+ }
74
82
  return descriptor;
75
83
  };
76
84
  const withRows = (descriptor, field) => ({
@@ -79,20 +87,37 @@ const withRows = (descriptor, field) => ({
79
87
  ...field.maxRows === void 0 ? {} : { maxRows: field.maxRows }
80
88
  });
81
89
  /**
90
+ * Whether a descriptor stands for a construct that only holds other fields.
91
+ *
92
+ * These describe a position rather than a value, so everything that resolves a
93
+ * path to something writable skips them; only {@link describeNode} reports
94
+ * them, to carry what the container itself declares.
95
+ */ const isContainer = (descriptor) => descriptor.type === "array" || descriptor.type === "group" || descriptor.type === "tab";
96
+ /**
97
+ * Whether a container declares anything a client could not infer from the
98
+ * fields beneath it. A group that exists only to nest is not worth reporting.
99
+ */ const isInformative = (descriptor) => descriptor.description !== void 0 || descriptor.required === true || descriptor.localized === true;
100
+ /**
82
101
  * Flattens a field list into descriptors addressed relative to the node.
83
102
  *
84
103
  * The input is Payload's own flattened shape, which has already merged every
85
104
  * construct that exists only in the admin UI (unnamed tabs, unnamed groups,
86
105
  * `row`, `collapsible`) and dropped `ui` fields. Named tabs, groups and
87
- * arrays contribute a path segment. The walk stops at every blocks field and
88
- * names the slugs instead of descending, which keeps a node proportional to
89
- * the number of blocks it allows rather than to the size of their definitions.
106
+ * arrays contribute a path segment, and are described in their own right when
107
+ * they declare something of their own: an array always, since its row counts
108
+ * live nowhere else, a group or tab only when it carries a description or a
109
+ * constraint. The walk stops at every blocks field and names the slugs instead
110
+ * of descending, which keeps a node proportional to the number of blocks it
111
+ * allows rather than to the size of their definitions.
90
112
  */ const describeFields = (fields, prefix = [], parentReadOnly = false) => fields.flatMap((field) => {
91
113
  if (isSkipped(field)) return [];
92
114
  const readOnly = parentReadOnly || isReadOnly(field);
93
115
  const path = [...prefix, field.name];
94
- if (field.type === "tab" || field.type === "group") return describeFields(field.flattenedFields, path, readOnly);
95
- if (field.type === "array") return describeFields(field.flattenedFields, [...path, "*"], readOnly);
116
+ if (field.type === "tab" || field.type === "group") {
117
+ const own = describeBase(field, joinPath(path), readOnly);
118
+ return [...isInformative(own) ? [own] : [], ...describeFields(field.flattenedFields, path, readOnly)];
119
+ }
120
+ if (field.type === "array") return [withRows(describeBase(field, joinPath(path), readOnly), field), ...describeFields(field.flattenedFields, [...path, "*"], readOnly)];
96
121
  if (field.type === "blocks") return [withRows({
97
122
  ...describeBase(field, joinPath(path), readOnly),
98
123
  blocks: blockSlugsOf(field)
@@ -100,6 +125,11 @@ const withRows = (descriptor, field) => ({
100
125
  return [describeLeaf(field, joinPath(path), readOnly)];
101
126
  });
102
127
  /**
128
+ * The descriptors that address a value, which is what every walk resolving a
129
+ * path against a document needs. A container describes a position rather than
130
+ * a value, so only {@link describeNode} reports one.
131
+ */ const describeAddressableFields = (fields) => describeFields(fields).filter((descriptor) => !isContainer(descriptor));
132
+ /**
103
133
  * Locates the blocks field that a resolved descriptor path refers to.
104
134
  */ const findBlocksField = (fields, path) => {
105
135
  for (const field of fields) {
@@ -126,4 +156,4 @@ const targetOf = (config, ref) => {
126
156
  return found;
127
157
  };
128
158
  //#endregion
129
- export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextField, joinPath, pointerFromPayloadPath, splitPath, staticDescription, targetOf };
159
+ export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, pointerFromPayloadPath, splitPath, staticDescription, targetOf };
@@ -1,4 +1,4 @@
1
- import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, describeFields, findBlocksField, splitPath } from "../schema/walk.mjs";
1
+ import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, describeAddressableFields, findBlocksField, splitPath } from "../schema/walk.mjs";
2
2
  import { validateWriteValue } from "../schema/shape.mjs";
3
3
  import { resolveDataPointer } from "../schema/pointer.mjs";
4
4
  import { z } from "zod";
@@ -154,7 +154,7 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
154
154
  * is left out, so the write-back carries only what a client could have set.
155
155
  */ const pickDescribed = (config, value, at) => {
156
156
  const { fields, prefix, isRow } = at;
157
- const relative = describeFields(fields).flatMap((descriptor) => {
157
+ const relative = describeAddressableFields(fields).flatMap((descriptor) => {
158
158
  const parts = splitPath(descriptor.path);
159
159
  return prefix.every((part, offset) => part === parts[offset]) ? [{
160
160
  descriptor,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@abinnovision/payloadcms-mcpx",
4
- "version": "1.0.0-beta.6",
4
+ "version": "1.0.0-beta.7",
5
5
  "description": "Payload CMS plugin exposing a fixed, schema-aware MCP tool surface with draft-only writes and per-API-key capabilities.",
6
6
  "keywords": [
7
7
  "payload",