@abinnovision/payloadcms-mcpx 1.0.0-beta.5 → 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
@@ -142,6 +142,15 @@ Rules the tools enforce and explain in their own descriptions:
142
142
  accept; every node carries `next`, the ready-to-use paths for those blocks
143
143
  (`/layout/sections/sectionWrapper`), so pass an entry of `next` as a `paths`
144
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.
145
154
  - Field and collection `admin.description` values are included in
146
155
  `describeSchema` and `listCapabilities`, so intent written for the admin
147
156
  panel reaches the client. Strings and locale-keyed records pass through;
@@ -302,9 +311,11 @@ key of every user.
302
311
 
303
312
  ## Non-goals of v1 / roadmap
304
313
 
305
- Deletes, uploads, markdown authoring for rich text, row addressing by id
306
- instead of index, cross-locale publish blockers, pagination of `describeSchema`
307
- with `expand`, and a handler-level timeout are all deliberate 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.
308
319
 
309
320
  ## License
310
321
 
@@ -1,32 +1,86 @@
1
- import { blockOf, blockSlugsOf, describeFields, findBlocksField, joinPath, splitPath, targetOf } 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.
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.
11
62
  */ const fieldsAtSchemaPath = (config, target, schemaPath) => {
12
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,12 +88,37 @@ const blocksDescriptors = (fields) => describeFields(fields).filter((descriptor)
34
88
  };
35
89
  };
36
90
  /**
37
- * Describes a collection or global root, or one block reached through a schema
38
- * path.
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.
39
118
  */ const describeNode = (config, ref, schemaPath = "") => {
40
119
  const { blockType, fields } = fieldsAtSchemaPath(config, targetOf(config, ref), schemaPath);
41
120
  const descriptors = describeFields(fields);
42
- 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));
43
122
  return {
44
123
  ...blockType === void 0 ? {} : { blockType },
45
124
  ...ref.kind === "collection" ? { collection: ref.slug } : { global: ref.slug },
@@ -61,9 +140,10 @@ const blocksDescriptors = (fields) => describeFields(fields).filter((descriptor)
61
140
  return;
62
141
  }
63
142
  seen.push(schemaPath);
64
- for (const descriptor of describeNode(config, ref, schemaPath).fields) for (const slug of descriptor.blocks ?? []) {
65
- if (visited.includes(slug)) continue;
66
- 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]);
67
147
  }
68
148
  };
69
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 };
@@ -1,4 +1,5 @@
1
- import { blockOf, blockSlugsOf, describeFields, findBlocksField, splitPath } from "./walk.mjs";
1
+ import { lexicalSubSchema } from "./lexical.mjs";
2
+ import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextField, splitPath } from "./walk.mjs";
2
3
  //#region src/schema/shape.ts
3
4
  /**
4
5
  * Keys Payload manages on a row that a client may echo back harmlessly.
@@ -9,30 +10,79 @@ import { blockOf, blockSlugsOf, describeFields, findBlocksField, splitPath } fro
9
10
  ]);
10
11
  const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
11
12
  /**
12
- * Checks every node type in an editor state against what the field's editor
13
- * can actually produce.
13
+ * Checks the fields a single Lexical node carries against the schema its
14
+ * feature declares for that node type.
15
+ *
16
+ * A node with nothing to declare, and one whose sub-fields cannot be named at
17
+ * a position, are both left alone.
18
+ */ const checkNodeFields = (scope, field, node) => {
19
+ const sub = lexicalSubSchema(field, node.type);
20
+ if (!sub) return;
21
+ const data = node.fields;
22
+ if (!isPlainObject(data)) {
23
+ scope.problems.push(`${scope.pointer}: a "${node.type}" node carries a "fields" object.`);
24
+ return;
25
+ }
26
+ const nested = {
27
+ ...scope,
28
+ pointer: `${scope.pointer}/fields`,
29
+ prefix: []
30
+ };
31
+ if (sub.kind === "fields") {
32
+ checkValue({
33
+ ...nested,
34
+ fields: sub.fields
35
+ }, data);
36
+ return;
37
+ }
38
+ const slug = data["blockType"];
39
+ const block = typeof slug === "string" ? blockOf(scope.config, sub.blocksField, slug) : void 0;
40
+ if (!block) {
41
+ scope.problems.push(`${scope.pointer}/fields: "${String(slug)}" is not allowed here. Allowed: ${blockSlugsOf(sub.blocksField).join(", ")}`);
42
+ return;
43
+ }
44
+ checkValue({
45
+ ...nested,
46
+ fields: block.flattenedFields
47
+ }, data);
48
+ };
49
+ /**
50
+ * Checks an editor state against what the field's editor can actually
51
+ * produce: every node type, and the fields each node carries.
14
52
  *
15
53
  * Payload does not: the Lexical validator runs node validations only for the
16
54
  * few node types that register one, so a `heading` inside a field whose
17
55
  * editor has no heading feature is stored without complaint and only fails
18
- * later, at render or when the document is reopened in the admin editor.
19
- */ const checkRichText = (scope, allowed, value) => {
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.
58
+ */ const checkRichText = (scope, editor, value) => {
20
59
  if (!isPlainObject(value) || !isPlainObject(value["root"])) {
21
60
  scope.problems.push(`${scope.pointer}: expected a Lexical editor state with a "root".`);
22
61
  return;
23
62
  }
24
- const walk = (nodes) => {
63
+ const walk = (nodes, pointer) => {
25
64
  if (!Array.isArray(nodes)) return;
26
- for (const node of nodes) {
65
+ nodes.forEach((node, index) => {
66
+ const at = `${pointer}/${String(index)}`;
27
67
  if (!isPlainObject(node) || typeof node["type"] !== "string") {
28
- scope.problems.push(`${scope.pointer}: every node needs a "type".`);
29
- continue;
68
+ scope.problems.push(`${at}: every node needs a "type".`);
69
+ return;
30
70
  }
31
- if (!allowed.includes(node["type"])) scope.problems.push(`${scope.pointer}: "${node["type"]}" is not available in this field's editor. Allowed: ${allowed.join(", ")}`);
32
- walk(node["children"]);
33
- }
71
+ if (!editor.allowed.includes(node["type"])) {
72
+ scope.problems.push(`${at}: "${node["type"]}" is not available in this field's editor. Allowed: ${editor.allowed.join(", ")}`);
73
+ return;
74
+ }
75
+ if (editor.field) checkNodeFields({
76
+ ...scope,
77
+ pointer: at
78
+ }, editor.field, {
79
+ fields: node["fields"],
80
+ type: node["type"]
81
+ });
82
+ walk(node["children"], `${at}/children`);
83
+ });
34
84
  };
35
- walk(value["root"]["children"]);
85
+ walk(value["root"]["children"], `${scope.pointer}/root/children`);
36
86
  };
37
87
  const checkLeafValue = (scope, descriptor, value) => {
38
88
  if (descriptor.readOnly) {
@@ -40,7 +90,10 @@ const checkLeafValue = (scope, descriptor, value) => {
40
90
  return;
41
91
  }
42
92
  if (descriptor.type === "richText") {
43
- checkRichText(scope, descriptor.nodes ?? [], value);
93
+ checkRichText(scope, {
94
+ allowed: descriptor.nodes ?? [],
95
+ field: findRichTextField(scope.fields, splitPath(descriptor.path))
96
+ }, value);
44
97
  return;
45
98
  }
46
99
  if (descriptor.type !== "blocks") return;
@@ -109,10 +109,21 @@ const withRows = (descriptor, field) => ({
109
109
  if (field.type === "array" && path[1] === "*") return findBlocksField(field.flattenedFields, path.slice(2));
110
110
  }
111
111
  };
112
+ /**
113
+ * Locates the rich text field that a resolved descriptor path refers to, so
114
+ * its editor can be introspected for the fields its nodes carry.
115
+ */ const findRichTextField = (fields, path) => {
116
+ for (const field of fields) {
117
+ if (!("name" in field) || field.name !== path[0]) continue;
118
+ if (field.type === "richText" && path.length === 1) return field;
119
+ if (field.type === "tab" || field.type === "group") return findRichTextField(field.flattenedFields, path.slice(1));
120
+ if (field.type === "array" && path[1] === "*") return findRichTextField(field.flattenedFields, path.slice(2));
121
+ }
122
+ };
112
123
  const targetOf = (config, ref) => {
113
124
  const found = ref.kind === "collection" ? config.collections.find((candidate) => candidate.slug === ref.slug) : config.globals.find((candidate) => candidate.slug === ref.slug);
114
125
  if (!found) throw new Error(`Unknown ${ref.kind} "${ref.slug}".`);
115
126
  return found;
116
127
  };
117
128
  //#endregion
118
- export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeFields, findBlocksField, joinPath, pointerFromPayloadPath, splitPath, staticDescription, targetOf };
129
+ export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextField, joinPath, pointerFromPayloadPath, splitPath, staticDescription, targetOf };
@@ -12,6 +12,8 @@ Pass exactly one of "collection" and "global". A global is a singleton: it has n
12
12
 
13
13
  Call it with no "paths" to get a collection's own fields. Every "blocks" field stops there and lists the block slugs it accepts instead of nesting them; each node's "next" lists the ready-to-use paths for those blocks, so pass any entry of "next" as a "paths" element to descend, e.g. "/layout/sections/sectionWrapper" and then "/layout/sections/sectionWrapper/modules/hero". A block is described as it exists at that position, because the same block can accept different children elsewhere.
14
14
 
15
+ A "richText" field stops there too. It lists the Lexical node types it accepts in "nodes", and "next" carries a path for every node type that holds fields of its own: "/content/link" for a link node, "/content/block/callout" and "/content/inlineBlock/badge" for the block nodes. Descend to get the real field list instead of guessing what a node carries. Upload nodes are not addressable, because their fields depend on the collection the node points at.
16
+
15
17
  Paths here use the same JSON Pointer syntax as getDocument and patchDocument, and are already resolved through anything that does not nest in the stored document. The difference is only what stands in an element position: a path names an array element "*" and a block by its slug, where a pointer into a document carries a 0-based index. So "/items/*/title" is written at "/items/0/title", and "/layout/sections/hero" at "/layout/sections/0".
16
18
 
17
19
  Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed and cannot be written. Fields marked readOnly are listed but refused on write.`,
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.5",
4
+ "version": "1.0.0-beta.6",
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",