@abinnovision/payloadcms-mcpx 1.0.0-beta.15 → 1.0.0-beta.16
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 +20 -3
- package/dist/schema/index.mjs +6 -4
- package/dist/schema/lexical-pointer.mjs +125 -0
- package/dist/schema/lexical.mjs +15 -1
- package/dist/schema/outline.mjs +67 -0
- package/dist/schema/pointer.mjs +71 -18
- package/dist/schema/shape.mjs +123 -43
- package/dist/schema/walk.mjs +3 -1
- package/dist/tools/describe-schema.mjs +3 -1
- package/dist/tools/get-document.mjs +36 -7
- package/dist/tools/patch-document.mjs +4 -0
- package/dist/write/patch.mjs +51 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -228,6 +228,24 @@ Rules the tools enforce and explain in their own descriptions:
|
|
|
228
228
|
when the document is opened, and a heading whose `tag` is `3` comes back
|
|
229
229
|
untagged, none of which Payload notices on write. A write breaking either is
|
|
230
230
|
refused, and the message names the property and what belongs there.
|
|
231
|
+
- A rich text field's value is addressable, so a small edit does not have to
|
|
232
|
+
rewrite the whole state. `/content/root/children/2` is a node,
|
|
233
|
+
`/content/root/children/2/tag` one of its properties, and
|
|
234
|
+
`/content/root/children/2/fields/url` a field the node carries, resolved
|
|
235
|
+
through the same node schema `describeSchema` publishes. A node written at a
|
|
236
|
+
position is held to exactly what a node inside a whole state is held to. The
|
|
237
|
+
root and a node's `type` cannot be replaced on their own, and a node property
|
|
238
|
+
cannot be removed, because a node needs it.
|
|
239
|
+
- Node positions shift the moment anything is added or removed, and a text or
|
|
240
|
+
paragraph node carries no id to fall back on. `getDocument` with `outline`
|
|
241
|
+
answers with one line per node, its pointer, its `version` and an excerpt, so
|
|
242
|
+
a position can be chosen and a complete node written without holding the
|
|
243
|
+
whole state. `expectedUpdatedAt` still guards the document, and a `test`
|
|
244
|
+
operation on a node's `type` guards the position.
|
|
245
|
+
- A state whose root holds nothing is refused, however it is written. Lexical
|
|
246
|
+
reads such a state as empty and throws rather than rendering it, so neither a
|
|
247
|
+
whole-field write of one, nor emptying the node list, nor removing the last
|
|
248
|
+
node is allowed. An empty field is stored as null instead.
|
|
231
249
|
- Where Payload states the shape itself, that statement is what is enforced: its
|
|
232
250
|
`outputSchema` declares `version` required on every node, and gives the root
|
|
233
251
|
exactly `children`, `direction`, `format`, `indent`, `type` and `version`, so
|
|
@@ -517,9 +535,8 @@ How the draft and publish guarantees are enforced, and where they stop, is in
|
|
|
517
535
|
## Non-goals of v1 / roadmap
|
|
518
536
|
|
|
519
537
|
Unpublishing, `versions.drafts.localizeStatus`, deletes, creating upload
|
|
520
|
-
documents and any file handling, markdown authoring for rich text,
|
|
521
|
-
|
|
522
|
-
schemas for `upload` node fields, row addressing by id instead of index,
|
|
538
|
+
documents and any file handling, markdown authoring for rich text, schemas for
|
|
539
|
+
`upload` node fields, row addressing by id instead of index,
|
|
523
540
|
cross-locale publish blockers, pagination of `describeSchema` with `expand`,
|
|
524
541
|
and a handler-level timeout are all deliberate omissions for now.
|
|
525
542
|
|
package/dist/schema/index.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, rootProblems, subSchemaNodeTypes } from "./lexical.mjs";
|
|
2
|
-
import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, pointerFromPayloadPath, splitPath, targetOf } from "./walk.mjs";
|
|
1
|
+
import { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, propertyProblem, rootProblems, subSchemaNodeTypes } from "./lexical.mjs";
|
|
2
|
+
import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, isIndexSegment, isPlainObject, joinPath, pointerFromPayloadPath, splitPath, targetOf } from "./walk.mjs";
|
|
3
3
|
import { nodeDescriber, reachableSchemaPaths } from "./describe.mjs";
|
|
4
|
+
import { resolveLexicalPointer } from "./lexical-pointer.mjs";
|
|
5
|
+
import { lexicalOutline } from "./outline.mjs";
|
|
4
6
|
import { resolveDataPointer } from "./pointer.mjs";
|
|
5
|
-
import { validateWriteValue } from "./shape.mjs";
|
|
6
|
-
export { JSON_POINTER_PATTERN, REQUIRED_NODE_PROPERTIES, RESERVED_FIELD_NAMES, ROOT_PROPERTIES, allowedNodeTypes, blockOf, blockSlugsOf, constrainsFields, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, lexicalSubSchema, nodeDescriber, nodeOptions, nodeProblems, pointerFromPayloadPath, reachableSchemaPaths, resolveDataPointer, rootProblems, splitPath, subSchemaNodeTypes, targetOf, validateWriteValue };
|
|
7
|
+
import { EMPTY_ROOT, validateWriteValue } from "./shape.mjs";
|
|
8
|
+
export { EMPTY_ROOT, JSON_POINTER_PATTERN, REQUIRED_NODE_PROPERTIES, RESERVED_FIELD_NAMES, ROOT_PROPERTIES, allowedNodeTypes, blockOf, blockSlugsOf, constrainsFields, describeAddressableFields, describeFields, findBlocksField, findRichTextField, isIndexSegment, isPlainObject, joinPath, lexicalOutline, lexicalSubSchema, nodeDescriber, nodeOptions, nodeProblems, pointerFromPayloadPath, propertyProblem, reachableSchemaPaths, resolveDataPointer, resolveLexicalPointer, rootProblems, splitPath, subSchemaNodeTypes, targetOf, validateWriteValue };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { lexicalSubSchema, subSchemaNodeTypes } from "./lexical.mjs";
|
|
2
|
+
import { blockOf, blockSlugsOf, isIndexSegment, isPlainObject, joinPath } from "./walk.mjs";
|
|
3
|
+
//#region src/schema/lexical-pointer.ts
|
|
4
|
+
/**
|
|
5
|
+
* A node's `fields` is ordinary Payload field-land, reached either through the
|
|
6
|
+
* schema a feature declares for the node or, where the node picks a block by
|
|
7
|
+
* slug, through that block.
|
|
8
|
+
*/ const stepIntoFields = (at) => {
|
|
9
|
+
const { addedValue, config, field, node, nodeType, rest } = at;
|
|
10
|
+
const sub = lexicalSubSchema(field, nodeType);
|
|
11
|
+
if (!sub) throw new Error(`"${nodeType}" nodes carry no addressable fields in this field's editor. Node types with fields here: ${subSchemaNodeTypes(field).join(", ")}`);
|
|
12
|
+
const data = node?.["fields"];
|
|
13
|
+
if (sub.kind === "fields") return {
|
|
14
|
+
data,
|
|
15
|
+
fields: sub.fields,
|
|
16
|
+
kind: "fields",
|
|
17
|
+
rest
|
|
18
|
+
};
|
|
19
|
+
const added = addedValue?.fields;
|
|
20
|
+
const slug = (isPlainObject(data) ? data["blockType"] : void 0) ?? (isPlainObject(added) ? added["blockType"] : void 0);
|
|
21
|
+
if (typeof slug !== "string") throw new Error(`Cannot tell which block a "${nodeType}" node holds. Supply a "blockType" on the value, one of: ${blockSlugsOf(sub.blocksField).join(", ")}`);
|
|
22
|
+
const block = blockOf(config, sub.blocksField, slug);
|
|
23
|
+
if (!block) throw new Error(`"${slug}" is not allowed in a "${nodeType}" node here. Allowed: ${blockSlugsOf(sub.blocksField).join(", ")}`);
|
|
24
|
+
return {
|
|
25
|
+
blockType: slug,
|
|
26
|
+
data,
|
|
27
|
+
fields: block.flattenedFields,
|
|
28
|
+
kind: "fields",
|
|
29
|
+
rest
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Walks the segments left over once a pointer has reached a rich text field.
|
|
34
|
+
*
|
|
35
|
+
* The stored state chooses the branch at every index, exactly as the stored
|
|
36
|
+
* document chooses it at a blocks element: an editor state admits many node
|
|
37
|
+
* shapes at the same position, and only what is there says which one it is.
|
|
38
|
+
* A position the document does not have yet takes its type from the value
|
|
39
|
+
* being added, and is addressable no further.
|
|
40
|
+
*/ const resolveLexicalPointer = (at) => {
|
|
41
|
+
const { addedValue, config, descriptor, field, state } = at;
|
|
42
|
+
const base = {
|
|
43
|
+
descriptor,
|
|
44
|
+
field
|
|
45
|
+
};
|
|
46
|
+
if (!isPlainObject(state) || !isPlainObject(state["root"])) throw new Error(`"${descriptor.path}" holds no editor state yet. Write the whole field once, then address positions inside it.`);
|
|
47
|
+
const [entry, ...rest] = at.segments;
|
|
48
|
+
if (entry !== "root") throw new Error(`"${String(entry)}" is not a position in a rich text field. An editor state is entered at "root", e.g. "${descriptor.path}/root/children/0".`);
|
|
49
|
+
let node = state["root"];
|
|
50
|
+
let nodeType = "root";
|
|
51
|
+
let segments = rest;
|
|
52
|
+
let walked = ["root"];
|
|
53
|
+
for (;;) {
|
|
54
|
+
if (segments.length === 0) return {
|
|
55
|
+
kind: "position",
|
|
56
|
+
position: {
|
|
57
|
+
...base,
|
|
58
|
+
...nodeType === "root" ? { isRoot: true } : {},
|
|
59
|
+
kind: "node",
|
|
60
|
+
nodeType
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const [segment, ...remaining] = segments;
|
|
64
|
+
if (segment === "children") {
|
|
65
|
+
if (remaining.length === 0) return {
|
|
66
|
+
kind: "position",
|
|
67
|
+
position: {
|
|
68
|
+
...base,
|
|
69
|
+
...nodeType === "root" ? { isRoot: true } : {},
|
|
70
|
+
kind: "nodes",
|
|
71
|
+
nodeType
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const [index, ...beyond] = remaining;
|
|
75
|
+
if (!isIndexSegment(index)) throw new Error(`"${descriptor.path}${joinPath([...walked, "children"])}" is a list; "${index}" is not an index.`);
|
|
76
|
+
const children = node?.["children"];
|
|
77
|
+
const child = Array.isArray(children) && index !== "-" ? children[Number(index)] : void 0;
|
|
78
|
+
const type = isPlainObject(child) ? child["type"] : addedValue?.type;
|
|
79
|
+
if (typeof type !== "string") {
|
|
80
|
+
if (beyond.length === 0) return {
|
|
81
|
+
kind: "position",
|
|
82
|
+
position: {
|
|
83
|
+
...base,
|
|
84
|
+
kind: "node"
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
throw new Error(`Cannot tell which node "${descriptor.path}${joinPath([
|
|
88
|
+
...walked,
|
|
89
|
+
"children",
|
|
90
|
+
index
|
|
91
|
+
])}" is. Read the document first, or address an existing position.`);
|
|
92
|
+
}
|
|
93
|
+
node = isPlainObject(child) ? child : void 0;
|
|
94
|
+
nodeType = type;
|
|
95
|
+
segments = beyond;
|
|
96
|
+
walked = [
|
|
97
|
+
...walked,
|
|
98
|
+
"children",
|
|
99
|
+
index
|
|
100
|
+
];
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (segment === "fields") return stepIntoFields({
|
|
104
|
+
addedValue,
|
|
105
|
+
config,
|
|
106
|
+
field,
|
|
107
|
+
node,
|
|
108
|
+
nodeType,
|
|
109
|
+
rest: remaining
|
|
110
|
+
});
|
|
111
|
+
if (remaining.length > 0) throw new Error(`"${segment}" is a property of a "${nodeType}" node and nothing beneath it can be addressed.`);
|
|
112
|
+
return {
|
|
113
|
+
kind: "position",
|
|
114
|
+
position: {
|
|
115
|
+
...base,
|
|
116
|
+
...nodeType === "root" ? { isRoot: true } : {},
|
|
117
|
+
kind: "property",
|
|
118
|
+
nodeType,
|
|
119
|
+
property: segment
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
//#endregion
|
|
125
|
+
export { resolveLexicalPointer };
|
package/dist/schema/lexical.mjs
CHANGED
|
@@ -205,6 +205,20 @@ const nodeProblems = (node) => check(node, {
|
|
|
205
205
|
unexpected: Object.keys(root).filter((property) => !(property in ROOT_PROPERTIES))
|
|
206
206
|
});
|
|
207
207
|
/**
|
|
208
|
+
* What one serialized property has to be, for a write addressing a property
|
|
209
|
+
* rather than a whole node.
|
|
210
|
+
*
|
|
211
|
+
* Absent where the table says nothing, which is the same tolerance the node
|
|
212
|
+
* walk shows: a project's own node may carry any property, and guessing at one
|
|
213
|
+
* would reject content that works.
|
|
214
|
+
*/ const propertyProblem = (nodeType, property, value) => {
|
|
215
|
+
const constraint = (nodeType === "root" ? ROOT_PROPERTIES : {
|
|
216
|
+
...UNIVERSAL_PROPERTIES,
|
|
217
|
+
...REQUIRED_NODE_PROPERTIES[nodeType] ?? {}
|
|
218
|
+
})[property];
|
|
219
|
+
return constraint === void 0 || accepts(constraint, value) ? void 0 : { needs: needs(constraint) };
|
|
220
|
+
};
|
|
221
|
+
/**
|
|
208
222
|
* Only properties a feature narrows and Lexical does not check on its own
|
|
209
223
|
* belong here. Everything else a feature restricts is already visible: a
|
|
210
224
|
* link's targets through its sub-schema, a block node's choices through the
|
|
@@ -247,4 +261,4 @@ const stringList = (value) => Array.isArray(value) && value.every((entry) => typ
|
|
|
247
261
|
return entries.length > 0 ? Object.fromEntries(entries) : void 0;
|
|
248
262
|
};
|
|
249
263
|
//#endregion
|
|
250
|
-
export { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, rootProblems, subSchemaNodeTypes };
|
|
264
|
+
export { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, propertyProblem, rootProblems, subSchemaNodeTypes };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { allowedNodeTypes, nodeOptions } from "./lexical.mjs";
|
|
2
|
+
import { isPlainObject } from "./walk.mjs";
|
|
3
|
+
//#region src/schema/outline.ts
|
|
4
|
+
/**
|
|
5
|
+
* Long enough to identify a paragraph, short enough that an outline of a real
|
|
6
|
+
* document stays a fraction of the size of its editor state.
|
|
7
|
+
*/ const TEXT_PREVIEW_LENGTH = 80;
|
|
8
|
+
/**
|
|
9
|
+
* Every descendant text node contributes, not only direct children, since a
|
|
10
|
+
* link or a formatting mark nests the text a level deeper.
|
|
11
|
+
*/ const collectText = (node) => {
|
|
12
|
+
if (node["type"] === "text") return typeof node["text"] === "string" ? node["text"] : "";
|
|
13
|
+
const children = node["children"];
|
|
14
|
+
return Array.isArray(children) ? children.filter(isPlainObject).map((child) => collectText(child)).join("") : "";
|
|
15
|
+
};
|
|
16
|
+
const preview = (text) => text.length > TEXT_PREVIEW_LENGTH ? `${text.slice(0, TEXT_PREVIEW_LENGTH)}…` : text;
|
|
17
|
+
/**
|
|
18
|
+
* Only the properties a feature actually narrows for this node type, and only
|
|
19
|
+
* where the node carries a string for one. A node missing the property, or
|
|
20
|
+
* carrying something the feature never produces, says nothing worth reporting.
|
|
21
|
+
*/ const narrowedOptions = (node, narrowed) => {
|
|
22
|
+
if (!narrowed) return;
|
|
23
|
+
const set = Object.keys(narrowed).flatMap((property) => {
|
|
24
|
+
const value = node[property];
|
|
25
|
+
return typeof value === "string" ? [[property, value]] : [];
|
|
26
|
+
});
|
|
27
|
+
return set.length > 0 ? Object.fromEntries(set) : void 0;
|
|
28
|
+
};
|
|
29
|
+
const walk = (node, pointer, options, entries) => {
|
|
30
|
+
const type = node["type"];
|
|
31
|
+
if (typeof type !== "string") return;
|
|
32
|
+
const version = node["version"];
|
|
33
|
+
const text = preview(collectText(node));
|
|
34
|
+
const nodeOptionsFound = narrowedOptions(node, options?.[type]);
|
|
35
|
+
const children = node["children"];
|
|
36
|
+
const childCount = Array.isArray(children) ? children.length : 0;
|
|
37
|
+
entries.push({
|
|
38
|
+
...childCount === 0 ? {} : { children: childCount },
|
|
39
|
+
...nodeOptionsFound === void 0 ? {} : { options: nodeOptionsFound },
|
|
40
|
+
pointer,
|
|
41
|
+
...text === "" ? {} : { text },
|
|
42
|
+
type,
|
|
43
|
+
...typeof version === "number" ? { version } : {}
|
|
44
|
+
});
|
|
45
|
+
if (Array.isArray(children)) children.forEach((child, index) => {
|
|
46
|
+
if (isPlainObject(child)) walk(child, `${pointer}/children/${String(index)}`, options, entries);
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* A depth-first listing of every node under an editor state's root, so an
|
|
51
|
+
* agent can find a position and a sibling's `version` without reading the
|
|
52
|
+
* whole state. `basePointer` is the field's own pointer, e.g. "/content"; each
|
|
53
|
+
* entry's `pointer` extends it with "/root" and the node's real indices, which
|
|
54
|
+
* makes it directly usable in a patch operation.
|
|
55
|
+
*/ const lexicalOutline = (state, basePointer, field) => {
|
|
56
|
+
if (!isPlainObject(state)) return [];
|
|
57
|
+
const root = state["root"];
|
|
58
|
+
if (!isPlainObject(root) || !Array.isArray(root["children"])) return [];
|
|
59
|
+
const options = nodeOptions(field, allowedNodeTypes(field));
|
|
60
|
+
const entries = [];
|
|
61
|
+
root["children"].forEach((child, index) => {
|
|
62
|
+
if (isPlainObject(child)) walk(child, `${basePointer}/root/children/${String(index)}`, options, entries);
|
|
63
|
+
});
|
|
64
|
+
return entries;
|
|
65
|
+
};
|
|
66
|
+
//#endregion
|
|
67
|
+
export { lexicalOutline };
|
package/dist/schema/pointer.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { blockOf, blockSlugsOf, describeAddressableFields, findBlocksField, joinPath, splitPath, targetOf } from "./walk.mjs";
|
|
1
|
+
import { blockOf, blockSlugsOf, describeAddressableFields, findBlocksField, findRichTextField, isIndexSegment, joinPath, splitPath, targetOf } from "./walk.mjs";
|
|
2
|
+
import { resolveLexicalPointer } from "./lexical-pointer.mjs";
|
|
2
3
|
//#region src/schema/pointer.ts
|
|
3
|
-
const isIndexSegment = (segment) => segment === "-" || /^\d+$/.test(segment);
|
|
4
4
|
const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isIndexSegment(segment) : part === segment);
|
|
5
5
|
/**
|
|
6
6
|
* Longest descriptor whose path is fully consumed by the leading segments.
|
|
@@ -21,6 +21,27 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
|
|
|
21
21
|
* array fields are descended through rather than skipped.
|
|
22
22
|
*/ const valueAtSegments = (data, segments) => segments.reduce((current, segment) => current === null || typeof current !== "object" ? void 0 : current[segment], data);
|
|
23
23
|
/**
|
|
24
|
+
* The stored row decides which block sits at an index, since a blocks field
|
|
25
|
+
* admits many shapes at the same position. A row the document does not have
|
|
26
|
+
* yet takes its slug from the value being added.
|
|
27
|
+
*/ const stepIntoBlock = (at) => {
|
|
28
|
+
const { addedValue, config, descriptor, rows } = at;
|
|
29
|
+
const [index, ...remaining] = at.rest;
|
|
30
|
+
if (!isIndexSegment(index)) throw new Error(`"${descriptor.path}" is an array; "${index}" is not an index.`);
|
|
31
|
+
const field = findBlocksField(at.fields, splitPath(descriptor.path));
|
|
32
|
+
const existing = Array.isArray(rows) && index !== "-" ? rows[Number(index)] : void 0;
|
|
33
|
+
const slug = existing?.blockType ?? addedValue?.blockType;
|
|
34
|
+
if (!field || slug === void 0) throw new Error(`Cannot tell which block "${descriptor.path}/${index}" is. Supply a "blockType" on the value, one of: ${field ? blockSlugsOf(field).join(", ") : ""}`);
|
|
35
|
+
const block = blockOf(config, field, slug);
|
|
36
|
+
if (!block) throw new Error(`"${slug}" is not allowed at "${descriptor.path}". Allowed: ${blockSlugsOf(field).join(", ")}`);
|
|
37
|
+
return {
|
|
38
|
+
blockType: slug,
|
|
39
|
+
data: existing,
|
|
40
|
+
fields: block.flattenedFields,
|
|
41
|
+
rest: remaining
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
24
45
|
* The stored document chooses the branch at every blocks element, and is
|
|
25
46
|
* required rather than optional: `/layout/sections/3/modules/1`
|
|
26
47
|
* can only be resolved by reading `blockType` off `sections[3]`, since a blocks
|
|
@@ -30,6 +51,8 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
|
|
|
30
51
|
let data = target.doc;
|
|
31
52
|
let blockType;
|
|
32
53
|
let segments = splitPath(target.pointer);
|
|
54
|
+
let readOnly;
|
|
55
|
+
let inLexical;
|
|
33
56
|
while (segments.length > 0) {
|
|
34
57
|
const descriptors = describeAddressableFields(fields);
|
|
35
58
|
const match = longestMatch(descriptors, segments);
|
|
@@ -46,28 +69,58 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
|
|
|
46
69
|
...blockType === void 0 ? {} : { blockType },
|
|
47
70
|
descriptor: match.descriptor,
|
|
48
71
|
fields,
|
|
49
|
-
prefix: []
|
|
72
|
+
prefix: [],
|
|
73
|
+
...inLexical === void 0 ? {} : { inLexical },
|
|
74
|
+
...readOnly === void 0 ? {} : { readOnly }
|
|
50
75
|
};
|
|
76
|
+
if (match.descriptor.type === "richText") {
|
|
77
|
+
const field = findRichTextField(fields, splitPath(match.descriptor.path));
|
|
78
|
+
if (!field) throw new Error(`"${match.descriptor.path}" could not be resolved.`);
|
|
79
|
+
const step = resolveLexicalPointer({
|
|
80
|
+
...target.addedValue === void 0 ? {} : { addedValue: target.addedValue },
|
|
81
|
+
config,
|
|
82
|
+
descriptor: match.descriptor,
|
|
83
|
+
field,
|
|
84
|
+
segments: rest,
|
|
85
|
+
state: valueAtSegments(data, segments.slice(0, match.consumed))
|
|
86
|
+
});
|
|
87
|
+
if (step.kind === "position") return {
|
|
88
|
+
...blockType === void 0 ? {} : { blockType },
|
|
89
|
+
descriptor: match.descriptor,
|
|
90
|
+
fields,
|
|
91
|
+
lexical: step.position,
|
|
92
|
+
prefix: [],
|
|
93
|
+
...readOnly === void 0 ? {} : { readOnly }
|
|
94
|
+
};
|
|
95
|
+
inLexical = true;
|
|
96
|
+
readOnly = match.descriptor.readOnly ?? readOnly;
|
|
97
|
+
blockType = step.blockType;
|
|
98
|
+
fields = step.fields;
|
|
99
|
+
data = step.data;
|
|
100
|
+
segments = step.rest;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
51
103
|
if (match.descriptor.type !== "blocks") throw new Error(`"${match.descriptor.path}" is a ${match.descriptor.type} field and has no "${joinPath(rest)}" beneath it.`);
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
segments = remaining;
|
|
104
|
+
const step = stepIntoBlock({
|
|
105
|
+
addedValue: target.addedValue,
|
|
106
|
+
descriptor: match.descriptor,
|
|
107
|
+
fields,
|
|
108
|
+
rest,
|
|
109
|
+
rows: valueAtSegments(data, segments.slice(0, match.consumed)),
|
|
110
|
+
config
|
|
111
|
+
});
|
|
112
|
+
readOnly = match.descriptor.readOnly ?? readOnly;
|
|
113
|
+
blockType = step.blockType;
|
|
114
|
+
fields = step.fields;
|
|
115
|
+
data = step.data;
|
|
116
|
+
segments = step.rest;
|
|
66
117
|
}
|
|
67
118
|
return {
|
|
68
119
|
...blockType === void 0 ? {} : { blockType },
|
|
69
120
|
fields,
|
|
70
|
-
|
|
121
|
+
...inLexical === void 0 ? {} : { inLexical },
|
|
122
|
+
prefix: [],
|
|
123
|
+
...readOnly === void 0 ? {} : { readOnly }
|
|
71
124
|
};
|
|
72
125
|
};
|
|
73
126
|
//#endregion
|
package/dist/schema/shape.mjs
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import { ROOT_PROPERTIES, constrainsFields, lexicalSubSchema, nodeProblems, rootProblems } from "./lexical.mjs";
|
|
2
|
-
import { blockOf, blockSlugsOf, describeAddressableFields, findBlocksField, findRichTextField, splitPath } from "./walk.mjs";
|
|
1
|
+
import { ROOT_PROPERTIES, constrainsFields, lexicalSubSchema, nodeProblems, propertyProblem, rootProblems } from "./lexical.mjs";
|
|
2
|
+
import { blockOf, blockSlugsOf, describeAddressableFields, findBlocksField, findRichTextField, isPlainObject, splitPath } from "./walk.mjs";
|
|
3
3
|
//#region src/schema/shape.ts
|
|
4
4
|
const TOLERATED_VALUE_KEYS = /* @__PURE__ */ new Set([
|
|
5
5
|
"blockName",
|
|
6
6
|
"blockType",
|
|
7
7
|
"id"
|
|
8
8
|
]);
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Lexical refuses to hydrate a state whose root holds nothing: `isEmpty` is a
|
|
11
|
+
* node map of one, and the editor throws on it rather than rendering nothing.
|
|
12
|
+
* An empty field is stored as null instead, so this is only ever reached by
|
|
13
|
+
* emptying one that was there.
|
|
14
|
+
*/ const EMPTY_ROOT = "an editor state needs at least one node. Clear the field with null instead.";
|
|
10
15
|
const quoted = (properties) => properties.map((property) => `"${property}"`).join(", ");
|
|
11
16
|
/**
|
|
12
17
|
* A node with nothing to declare, and one whose sub-fields cannot be named at a
|
|
@@ -43,16 +48,60 @@ const quoted = (properties) => properties.map((property) => `"${property}"`).joi
|
|
|
43
48
|
}, data);
|
|
44
49
|
};
|
|
45
50
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
|
|
51
|
+
* An absent property is already reported as missing. Anything else present is
|
|
52
|
+
* checked, not just a string: Lexical stores a heading tag of 3 as readily as
|
|
53
|
+
* one of "h3".
|
|
54
|
+
*/ const checkNarrowedProperty = (scope, at, value) => {
|
|
55
|
+
if (value !== void 0 && !(typeof value === "string" && at.values.includes(value))) scope.problems.push(`${at.pointer}: ${JSON.stringify(value)} is not available for a "${at.type}" node in this field's editor. Allowed: ${at.values.join(", ")}`);
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* One node, wherever it came from. `pointer` addresses the node itself, so a
|
|
59
|
+
* node written at a position and a node written inside a whole editor state
|
|
60
|
+
* are held to the same rules and report them the same way.
|
|
61
|
+
*
|
|
62
|
+
* Payload does not check any of this: the Lexical validator runs node
|
|
63
|
+
* validations only for the few node types that register one, so a `heading`
|
|
64
|
+
* inside a field whose editor has no heading feature is stored without
|
|
65
|
+
* complaint and only fails later, at render or when the document is reopened
|
|
66
|
+
* in the admin editor. A key a node's fields do not declare is dropped just as
|
|
67
|
+
* silently. The same holds one level down, for the node properties a feature
|
|
68
|
+
* narrows: an `h3` in an editor restricted to `h4` is stored as readily as an
|
|
69
|
+
* `h4`, and a node written without the properties its class hydrates from is
|
|
70
|
+
* stored and then throws when the editor opens it.
|
|
71
|
+
*/ const checkNode = (scope, editor, node, pointer) => {
|
|
72
|
+
if (!isPlainObject(node) || typeof node["type"] !== "string") {
|
|
73
|
+
scope.problems.push(`${pointer}: every node needs a "type".`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const type = node["type"];
|
|
77
|
+
if (!editor.allowed.includes(type)) {
|
|
78
|
+
scope.problems.push(`${pointer}: "${type}" is not available in this field's editor. Allowed: ${editor.allowed.join(", ")}`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const problems = nodeProblems(node);
|
|
82
|
+
if (problems.missing.length > 0) scope.problems.push(`${pointer}: a "${type}" node is missing ${quoted(problems.missing)}. Write nodes as Lexical serializes them.`);
|
|
83
|
+
for (const problem of problems.rejected) scope.problems.push(`${pointer}/${problem.property}: a "${type}" node needs ${problem.needs} here.`);
|
|
84
|
+
for (const [property, values] of Object.entries(editor.nodeOptions?.[type] ?? {})) checkNarrowedProperty(scope, {
|
|
85
|
+
pointer: `${pointer}/${property}`,
|
|
86
|
+
type,
|
|
87
|
+
values
|
|
88
|
+
}, node[property]);
|
|
89
|
+
if (editor.field) checkNodeFields({
|
|
90
|
+
...scope,
|
|
91
|
+
pointer
|
|
92
|
+
}, editor.field, {
|
|
93
|
+
fields: node["fields"],
|
|
94
|
+
type
|
|
95
|
+
});
|
|
96
|
+
checkNodes(scope, editor, node["children"], `${pointer}/children`);
|
|
97
|
+
};
|
|
98
|
+
/** `pointer` addresses the list. A node holding none is left alone. */ const checkNodes = (scope, editor, nodes, pointer) => {
|
|
99
|
+
if (!Array.isArray(nodes)) return;
|
|
100
|
+
nodes.forEach((node, index) => {
|
|
101
|
+
checkNode(scope, editor, node, `${pointer}/${String(index)}`);
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
const checkRichText = (scope, editor, value) => {
|
|
56
105
|
if (!isPlainObject(value) || !isPlainObject(value["root"])) {
|
|
57
106
|
scope.problems.push(`${scope.pointer}: expected a Lexical editor state with a "root".`);
|
|
58
107
|
return;
|
|
@@ -62,36 +111,63 @@ const quoted = (properties) => properties.map((property) => `"${property}"`).joi
|
|
|
62
111
|
if (missing.length > 0) scope.problems.push(`${scope.pointer}/root: the root node is missing ${quoted(missing)}. Write nodes as Lexical serializes them.`);
|
|
63
112
|
for (const problem of rejected) scope.problems.push(`${scope.pointer}/root/${problem.property}: the root node needs ${problem.needs} here.`);
|
|
64
113
|
for (const property of unexpected) scope.problems.push(`${scope.pointer}/root/${property}: no such property on the root node. Available: ${Object.keys(ROOT_PROPERTIES).join(", ")}`);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
114
|
+
if (Array.isArray(root["children"]) && root["children"].length === 0) scope.problems.push(`${scope.pointer}/root/children: ${EMPTY_ROOT}`);
|
|
115
|
+
checkNodes(scope, editor, root["children"], `${scope.pointer}/root/children`);
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* `type` decides how every other property on a node is read, so replacing it
|
|
119
|
+
* alone would leave a heading shaped like a text node. Everything else is held
|
|
120
|
+
* to the constraint the node walk holds it to and nothing more, since a
|
|
121
|
+
* feature may put any property on a node.
|
|
122
|
+
*/ const checkNodeProperty = (scope, editor, position, value) => {
|
|
123
|
+
const { nodeType: type, property } = position;
|
|
124
|
+
if (property === "type") {
|
|
125
|
+
scope.problems.push(`${scope.pointer}: a node's "type" cannot be replaced on its own. Replace the whole node.`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (position.isRoot && !(property in ROOT_PROPERTIES)) {
|
|
129
|
+
scope.problems.push(`${scope.pointer}: no such property on the root node. Available: ${Object.keys(ROOT_PROPERTIES).join(", ")}`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const problem = propertyProblem(type, property, value);
|
|
133
|
+
if (problem) scope.problems.push(`${scope.pointer}: a "${type}" node needs ${problem.needs} here.`);
|
|
134
|
+
const values = editor.nodeOptions?.[type]?.[property];
|
|
135
|
+
if (values) checkNarrowedProperty(scope, {
|
|
136
|
+
pointer: scope.pointer,
|
|
137
|
+
type,
|
|
138
|
+
values
|
|
139
|
+
}, value);
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* A value written at a position inside an editor state rather than as the
|
|
143
|
+
* whole state.
|
|
144
|
+
*/ const checkLexicalWrite = (scope, position, value) => {
|
|
145
|
+
const editor = {
|
|
146
|
+
allowed: position.descriptor.nodes ?? [],
|
|
147
|
+
field: position.field,
|
|
148
|
+
nodeOptions: position.descriptor.nodeOptions
|
|
93
149
|
};
|
|
94
|
-
|
|
150
|
+
if (position.kind === "property") {
|
|
151
|
+
checkNodeProperty(scope, editor, position, value);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (position.kind === "nodes") {
|
|
155
|
+
if (!Array.isArray(value)) {
|
|
156
|
+
scope.problems.push(`${scope.pointer}: expected an array of nodes.`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (position.isRoot && value.length === 0) {
|
|
160
|
+
scope.problems.push(`${scope.pointer}: ${EMPTY_ROOT}`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
checkNodes(scope, editor, value, scope.pointer);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (position.isRoot) {
|
|
167
|
+
scope.problems.push(`${scope.pointer}: the root of an editor state cannot be replaced on its own. Write the whole field instead.`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
checkNode(scope, editor, value, scope.pointer);
|
|
95
171
|
};
|
|
96
172
|
const checkLeafValue = (scope, descriptor, value) => {
|
|
97
173
|
if (descriptor.readOnly) {
|
|
@@ -199,6 +275,10 @@ const checkLeafValue = (scope, descriptor, value) => {
|
|
|
199
275
|
prefix: target.resolution.prefix,
|
|
200
276
|
problems
|
|
201
277
|
};
|
|
278
|
+
if (target.resolution.lexical) {
|
|
279
|
+
checkLexicalWrite(scope, target.resolution.lexical, value);
|
|
280
|
+
return problems;
|
|
281
|
+
}
|
|
202
282
|
if (target.resolution.descriptor) {
|
|
203
283
|
checkLeafValue(scope, target.resolution.descriptor, value);
|
|
204
284
|
return problems;
|
|
@@ -207,4 +287,4 @@ const checkLeafValue = (scope, descriptor, value) => {
|
|
|
207
287
|
return problems;
|
|
208
288
|
};
|
|
209
289
|
//#endregion
|
|
210
|
-
export { validateWriteValue };
|
|
290
|
+
export { EMPTY_ROOT, validateWriteValue };
|
package/dist/schema/walk.mjs
CHANGED
|
@@ -12,6 +12,8 @@ const RESERVED_FIELD_NAMES = /* @__PURE__ */ new Set([
|
|
|
12
12
|
const JSON_POINTER_PATTERN = /^(\/([^~/]|~[01])*)*$/;
|
|
13
13
|
/** No segments is the root pointer, `""`. */ const joinPath = (parts) => parts.map((part) => `/${part.replace(/~/g, "~0").replace(/\//g, "~1")}`).join("");
|
|
14
14
|
/** Unescapes `~1` and `~0`. The root pointer yields no segments. */ const splitPath = (path) => path.split("/").slice(1).map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
15
|
+
/** `-` included, since RFC 6901 reads it as the position after the last. */ const isIndexSegment = (segment) => segment === "-" || /^\d+$/.test(segment);
|
|
16
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
15
17
|
/**
|
|
16
18
|
* A path Payload reports on a validation error (`layout.0.title`) as a JSON
|
|
17
19
|
* Pointer, so everything handed back addresses documents the same way. The
|
|
@@ -140,4 +142,4 @@ const findBlocksField = (fields, path) => findFieldAt(fields, path, "blocks");
|
|
|
140
142
|
return found;
|
|
141
143
|
};
|
|
142
144
|
//#endregion
|
|
143
|
-
export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, pointerFromPayloadPath, splitPath, targetOf };
|
|
145
|
+
export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, isIndexSegment, isPlainObject, joinPath, pointerFromPayloadPath, splitPath, targetOf };
|
|
@@ -22,7 +22,9 @@ Call it with no "paths" to get a collection's own fields. Every "blocks" field s
|
|
|
22
22
|
|
|
23
23
|
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.
|
|
24
24
|
|
|
25
|
-
Write each Lexical node the way Lexical serializes it, with every property its type carries rather than a trimmed subset, and with the value Lexical would have written there. Every node needs a "version"; the root takes exactly "children", "direction", "format", "indent", "type" and "version" and refuses anything else; each node type adds its own on top. The admin editor rehydrates nodes through their classes, so a list item whose "indent" is missing, null or a string is stored and then throws on open, and a heading whose "tag" is a number is stored untagged. A write naming a property means exactly that.
|
|
25
|
+
Write each Lexical node the way Lexical serializes it, with every property its type carries rather than a trimmed subset, and with the value Lexical would have written there. Every node needs a "version"; the root takes exactly "children", "direction", "format", "indent", "type" and "version" and refuses anything else; each node type adds its own on top. The admin editor rehydrates nodes through their classes, so a list item whose "indent" is missing, null or a string is stored and then throws on open, and a heading whose "tag" is a number is stored untagged. A write naming a property means exactly that. A state whose root holds no children is refused however it is written, because Lexical reads it as empty and throws; clear a field with null instead.
|
|
26
|
+
|
|
27
|
+
A rich text field's value is addressable too, so an edit does not have to rewrite the whole state: "/content/root/children/0" is the first top-level node, "/content/root/children/0/children/1" a node inside it, "/content/root/children/0/tag" one property of a node, and "/content/root/children/0/fields/url" a field of a node, described at the "next" path for that node type. Append a node with "/-". Read the document first: which node sits at an index is only knowable from what is stored.
|
|
26
28
|
|
|
27
29
|
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".
|
|
28
30
|
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { errorResult, jsonResult } from "../result.mjs";
|
|
2
|
-
import { JSON_POINTER_PATTERN } from "../schema/walk.mjs";
|
|
2
|
+
import { JSON_POINTER_PATTERN, findRichTextField, splitPath } from "../schema/walk.mjs";
|
|
3
|
+
import { lexicalOutline } from "../schema/outline.mjs";
|
|
4
|
+
import { resolveDataPointer } from "../schema/pointer.mjs";
|
|
3
5
|
import "../schema/index.mjs";
|
|
4
6
|
import { depthShape, idShape, localeOf, localeShape, targetShape } from "./shared.mjs";
|
|
5
|
-
import { requireIdFor, resolveTarget } from "./target.mjs";
|
|
7
|
+
import { refOf, requireIdFor, resolveTarget } from "./target.mjs";
|
|
6
8
|
import { defineMcpxTool } from "../types.mjs";
|
|
7
9
|
import { z } from "zod";
|
|
8
10
|
import { Pointer } from "rfc6902";
|
|
11
|
+
//#region src/tools/get-document.ts
|
|
12
|
+
const OUTLINE_ERROR = "\"outline\" applies to a rich text field; give \"path\" for one.";
|
|
9
13
|
/**
|
|
10
14
|
* With `path` the handler returns the subtree plus the `id`, `_status` and
|
|
11
15
|
* `updatedAt` a client needs to write back, so a caller reading one branch
|
|
@@ -14,7 +18,9 @@ import { Pointer } from "rfc6902";
|
|
|
14
18
|
name: "getDocument",
|
|
15
19
|
description: `Reads one document, or one subtree of it when "path" is given as a JSON pointer such as "/layout/sections/2". Returns the latest draft by default. Read before patching: the response carries "updatedAt" for expectedUpdatedAt and the indices pointers need.
|
|
16
20
|
|
|
17
|
-
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton
|
|
21
|
+
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
|
|
22
|
+
|
|
23
|
+
Set "outline" on a rich text "path" to get a compact positional listing of its nodes instead of the raw editor state.`,
|
|
18
24
|
annotations: {
|
|
19
25
|
readOnlyHint: true,
|
|
20
26
|
openWorldHint: false
|
|
@@ -32,7 +38,8 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
|
|
|
32
38
|
required: false,
|
|
33
39
|
description: "Locale to read. Defaults to the default locale."
|
|
34
40
|
}),
|
|
35
|
-
draft: z.boolean().optional().describe("Return the latest draft. Default true.")
|
|
41
|
+
draft: z.boolean().optional().describe("Return the latest draft. Default true."),
|
|
42
|
+
outline: z.boolean().optional().describe("For a rich text field, return a compact positional outline instead of the editor state. Requires \"path\".")
|
|
36
43
|
}),
|
|
37
44
|
handler: async ({ args, scope }) => {
|
|
38
45
|
const target = resolveTarget(scope, args, "read");
|
|
@@ -53,20 +60,42 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
|
|
|
53
60
|
...shared,
|
|
54
61
|
slug: target.slug
|
|
55
62
|
}));
|
|
56
|
-
if (args.path === void 0 || args.path === "")
|
|
63
|
+
if (args.path === void 0 || args.path === "") {
|
|
64
|
+
if (args.outline) return errorResult(OUTLINE_ERROR);
|
|
65
|
+
return jsonResult(doc);
|
|
66
|
+
}
|
|
57
67
|
let value;
|
|
58
68
|
try {
|
|
59
69
|
value = Pointer.fromJSON(args.path).get(doc);
|
|
60
70
|
} catch {
|
|
61
71
|
return errorResult(`"${args.path}" is not a valid JSON pointer.`);
|
|
62
72
|
}
|
|
63
|
-
|
|
73
|
+
const envelope = {
|
|
64
74
|
...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
|
|
65
75
|
status: doc["_status"],
|
|
66
76
|
updatedAt: doc["updatedAt"],
|
|
67
|
-
path: args.path
|
|
77
|
+
path: args.path
|
|
78
|
+
};
|
|
79
|
+
if (!args.outline) return jsonResult({
|
|
80
|
+
...envelope,
|
|
68
81
|
value
|
|
69
82
|
});
|
|
83
|
+
let resolution;
|
|
84
|
+
try {
|
|
85
|
+
resolution = resolveDataPointer(scope.req.payload.config, {
|
|
86
|
+
doc,
|
|
87
|
+
pointer: args.path,
|
|
88
|
+
ref: refOf(target)
|
|
89
|
+
});
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return errorResult(error instanceof Error ? error.message : OUTLINE_ERROR);
|
|
92
|
+
}
|
|
93
|
+
const field = resolution.descriptor?.type === "richText" && !resolution.lexical ? findRichTextField(resolution.fields, splitPath(resolution.descriptor.path)) : void 0;
|
|
94
|
+
if (!field) return errorResult(OUTLINE_ERROR);
|
|
95
|
+
return jsonResult({
|
|
96
|
+
...envelope,
|
|
97
|
+
outline: lexicalOutline(value, args.path, field)
|
|
98
|
+
});
|
|
70
99
|
}
|
|
71
100
|
});
|
|
72
101
|
//#endregion
|
|
@@ -18,6 +18,10 @@ Only the fields describeSchema lists can be addressed. A pointer that does not r
|
|
|
18
18
|
|
|
19
19
|
Adding a block requires "blockType" on the value. Append with "/-" as the last segment. To clear a field use "replace" with null; an array or blocks field refuses null and is emptied with [] instead. "remove" is only for list elements, because a field left out of a write is kept rather than cleared. Read the document first to learn the indices, and pass its "updatedAt" as expectedUpdatedAt so an edit made since that read is refused rather than overwritten.
|
|
20
20
|
|
|
21
|
+
Inside a rich text field a pointer keeps going: "/content/root/children/2" is a node, "/content/root/children/2/tag" one of its properties, and "/content/root/children/2/fields/url" a field it carries. A node written at a position must carry everything Lexical serializes, "version" included, exactly as one written inside a whole state must; getDocument with "outline" returns each node's pointer and version, which is the cheapest way to get both right. A node's "type" cannot be replaced on its own, and neither can the root.
|
|
22
|
+
|
|
23
|
+
Node positions shift as soon as anything is added or removed, so read immediately before patching, order removals from the last index to the first, and use a "test" operation on "/content/root/children/2/type" to assert a position is what you think it is before writing to it.
|
|
24
|
+
|
|
21
25
|
A successful write may come back with "publishBlockers": everything still wrong with the draft, such as required fields left empty. Those do not fail the write, because a draft is allowed to be incomplete, but the document cannot be published until the list is empty. "notApplied" lists pointers whose value Payload kept unchanged, which happens when field-level access denies the update. "publishBlockersUnavailable" means the check itself failed, so the empty list says nothing about whether the document is publishable.`;
|
|
22
26
|
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
27
|
/**
|
package/dist/write/patch.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, describeAddressableFields, findBlocksField, joinPath, splitPath } from "../schema/walk.mjs";
|
|
1
|
+
import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, describeAddressableFields, findBlocksField, isIndexSegment, joinPath, splitPath } from "../schema/walk.mjs";
|
|
2
2
|
import { resolveDataPointer } from "../schema/pointer.mjs";
|
|
3
|
-
import { validateWriteValue } from "../schema/shape.mjs";
|
|
3
|
+
import { EMPTY_ROOT, validateWriteValue } from "../schema/shape.mjs";
|
|
4
4
|
import "../schema/index.mjs";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { Pointer, applyPatch } from "rfc6902";
|
|
@@ -43,8 +43,7 @@ const droppedPointer = (operation) => {
|
|
|
43
43
|
return operation.op === "move" ? operation.from : void 0;
|
|
44
44
|
};
|
|
45
45
|
const isElementPointer = (pointer) => {
|
|
46
|
-
|
|
47
|
-
return last === "-" || /^\d+$/.test(last);
|
|
46
|
+
return isIndexSegment(pointer.split("/").pop() ?? "");
|
|
48
47
|
};
|
|
49
48
|
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
50
49
|
/** Its nodes manage their own ids, so it is never descended into. */ const isRichTextState = (value) => isPlainObject(value["root"]) && Array.isArray(value["root"]["children"]);
|
|
@@ -108,6 +107,7 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
108
107
|
return "from" in operation ? Pointer.fromJSON(operation.from).get(doc) : void 0;
|
|
109
108
|
};
|
|
110
109
|
/** A pointer stopping short addresses a subtree; the fields beneath decide. */ const resolvesReadOnly = (resolution) => {
|
|
110
|
+
if (resolution.readOnly) return true;
|
|
111
111
|
if (resolution.descriptor) return resolution.descriptor.readOnly === true;
|
|
112
112
|
const below = describeAddressableFields(resolution.fields).filter((descriptor) => resolution.prefix.every((part, offset) => part === splitPath(descriptor.path)[offset]));
|
|
113
113
|
return below.length > 0 && below.every((descriptor) => descriptor.readOnly);
|
|
@@ -117,6 +117,43 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
117
117
|
pointer: isElementPointer(target.pointer) ? joinPath(splitPath(target.pointer).slice(0, -1)) : target.pointer,
|
|
118
118
|
ref: target.ref
|
|
119
119
|
}));
|
|
120
|
+
/** Unresolvable pointers say nothing here; the caller reports them anyway. */ const resolutionAt = (config, target) => {
|
|
121
|
+
try {
|
|
122
|
+
return resolveDataPointer(config, {
|
|
123
|
+
doc: target.doc,
|
|
124
|
+
pointer: target.pointer,
|
|
125
|
+
ref: target.ref
|
|
126
|
+
});
|
|
127
|
+
} catch {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* Removing a field does nothing, but removing part of an editor state does
|
|
133
|
+
* something, and something worse: a node written without the properties its
|
|
134
|
+
* class hydrates from throws when the admin editor opens it. Both are refused,
|
|
135
|
+
* and only the reason differs.
|
|
136
|
+
*/ const droppedFieldProblem = (config, target) => {
|
|
137
|
+
const resolution = resolutionAt(config, target);
|
|
138
|
+
const lexical = resolution?.lexical;
|
|
139
|
+
if (lexical?.kind === "property") return `"${target.pointer}" is a node property, not a list element. A "${lexical.nodeType}" node needs it, so replace it rather than removing it.`;
|
|
140
|
+
if (lexical ?? resolution?.inLexical) return `"${target.pointer}" sits inside an editor state, which is written whole, so removing it would take effect and leave a node the admin editor cannot open. Replace it instead, or remove the node that holds it.`;
|
|
141
|
+
return `"${target.pointer}" is a field, not a list element, and removing it would do nothing. The patched document is written whole, and Payload keeps any field absent from a write rather than clearing it. Use "replace" with null to clear a field, or with [] to empty a list.`;
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* Dropping the only node under a root empties the state, which Lexical refuses
|
|
145
|
+
* to hydrate. Checked here rather than on the written value, because a
|
|
146
|
+
* `remove` carries none.
|
|
147
|
+
*/ const emptiesTheRoot = (config, target) => {
|
|
148
|
+
const list = joinPath(splitPath(target.pointer).slice(0, -1));
|
|
149
|
+
const owner = resolutionAt(config, {
|
|
150
|
+
...target,
|
|
151
|
+
pointer: list
|
|
152
|
+
})?.lexical;
|
|
153
|
+
if (owner?.kind !== "nodes" || owner.isRoot !== true) return false;
|
|
154
|
+
const nodes = Pointer.fromJSON(list).get(target.doc);
|
|
155
|
+
return Array.isArray(nodes) && nodes.length === 1;
|
|
156
|
+
};
|
|
120
157
|
/**
|
|
121
158
|
* Checked against the document as it stands when this operation runs: both
|
|
122
159
|
* pointers must resolve, the written value must pass write validation, and what
|
|
@@ -128,7 +165,11 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
128
165
|
const reserved = pointers.find(isReservedPointer);
|
|
129
166
|
if (reserved !== void 0) return [`"${reserved}" addresses a field Payload maintains. This tool only ever writes drafts, and id, _status, createdAt and updatedAt are not writable; use publishDocument to publish.`];
|
|
130
167
|
const dropped = droppedPointer(operation);
|
|
131
|
-
if (dropped !== void 0 && !isElementPointer(dropped)) return [
|
|
168
|
+
if (dropped !== void 0 && !isElementPointer(dropped)) return [droppedFieldProblem(config, {
|
|
169
|
+
doc,
|
|
170
|
+
pointer: dropped,
|
|
171
|
+
ref
|
|
172
|
+
})];
|
|
132
173
|
try {
|
|
133
174
|
const value = effectiveValue(operation, doc);
|
|
134
175
|
if (value !== void 0 && operation.op !== "test" && isReadOnlyPointer(config, {
|
|
@@ -141,6 +182,11 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
141
182
|
pointer: dropped,
|
|
142
183
|
ref
|
|
143
184
|
})) return [`"${dropped}" sits in a read-only field and cannot be removed.`];
|
|
185
|
+
if (dropped !== void 0 && emptiesTheRoot(config, {
|
|
186
|
+
doc,
|
|
187
|
+
pointer: dropped,
|
|
188
|
+
ref
|
|
189
|
+
})) return [`"${dropped}": ${EMPTY_ROOT}`];
|
|
144
190
|
for (const pointer of pointers) {
|
|
145
191
|
const resolution = resolveDataPointer(config, {
|
|
146
192
|
addedValue: value,
|
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.
|
|
4
|
+
"version": "1.0.0-beta.16",
|
|
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",
|