@abinnovision/payloadcms-mcpx 1.0.0-beta.14 → 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 +33 -3
- package/dist/schema/index.mjs +6 -4
- package/dist/schema/lexical-pointer.mjs +125 -0
- package/dist/schema/lexical.mjs +167 -1
- package/dist/schema/outline.mjs +67 -0
- package/dist/schema/pointer.mjs +71 -18
- package/dist/schema/shape.mjs +130 -39
- package/dist/schema/walk.mjs +3 -1
- package/dist/tools/describe-schema.mjs +4 -0
- 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
|
@@ -222,6 +222,37 @@ Rules the tools enforce and explain in their own descriptions:
|
|
|
222
222
|
answers `{ "heading": { "tag": ["h4"] } }`, and a write carrying any other
|
|
223
223
|
heading tag is refused. Lexical stores whatever tag it is given, so this is
|
|
224
224
|
the only place the restriction is checked.
|
|
225
|
+
- A Lexical node must be written the way Lexical serializes it, and carry the
|
|
226
|
+
values Lexical would have written. The admin editor rehydrates nodes through
|
|
227
|
+
their classes, so a list item whose `indent` is absent, `null` or `"0"` throws
|
|
228
|
+
when the document is opened, and a heading whose `tag` is `3` comes back
|
|
229
|
+
untagged, none of which Payload notices on write. A write breaking either is
|
|
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.
|
|
249
|
+
- Where Payload states the shape itself, that statement is what is enforced: its
|
|
250
|
+
`outputSchema` declares `version` required on every node, and gives the root
|
|
251
|
+
exactly `children`, `direction`, `format`, `indent`, `type` and `version`, so
|
|
252
|
+
an unknown property on the root is refused too. Payload declares nothing per
|
|
253
|
+
node type, so the rest is measured against the node classes
|
|
254
|
+
`@payloadcms/richtext-lexical` ships. Both halves are pinned by
|
|
255
|
+
`src/schema/lexical.spec.ts` rather than assumed.
|
|
225
256
|
- Field and collection `admin.description` values are included in
|
|
226
257
|
`describeSchema` and `listCapabilities`, so intent written for the admin
|
|
227
258
|
panel reaches the client. A locale-keyed record is resolved to one string for
|
|
@@ -504,9 +535,8 @@ How the draft and publish guarantees are enforced, and where they stop, is in
|
|
|
504
535
|
## Non-goals of v1 / roadmap
|
|
505
536
|
|
|
506
537
|
Unpublishing, `versions.drafts.localizeStatus`, deletes, creating upload
|
|
507
|
-
documents and any file handling, markdown authoring for rich text,
|
|
508
|
-
|
|
509
|
-
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,
|
|
510
540
|
cross-locale publish blockers, pagination of `describeSchema` with `expand`,
|
|
511
541
|
and a handler-level timeout are all deliberate omissions for now.
|
|
512
542
|
|
package/dist/schema/index.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { allowedNodeTypes, lexicalSubSchema, nodeOptions, 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, RESERVED_FIELD_NAMES, allowedNodeTypes, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, lexicalSubSchema, nodeDescriber, nodeOptions, pointerFromPayloadPath, reachableSchemaPaths, resolveDataPointer, 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
|
@@ -53,6 +53,172 @@ const lexicalSubSchema = (field, nodeType) => {
|
|
|
53
53
|
};
|
|
54
54
|
/** In the order their features registered them. */ const subSchemaNodeTypes = (field) => [...featuresOf(field)?.getSubFields?.keys() ?? []].filter((nodeType) => lexicalSubSchema(field, nodeType) !== void 0);
|
|
55
55
|
/**
|
|
56
|
+
* `direction` carries Payload's own declaration for it, `oneOf` the two
|
|
57
|
+
* directions or null, rather than a looser "string or null".
|
|
58
|
+
*/ const KINDS = {
|
|
59
|
+
array: {
|
|
60
|
+
accepts: (value) => Array.isArray(value),
|
|
61
|
+
needs: "an array"
|
|
62
|
+
},
|
|
63
|
+
direction: {
|
|
64
|
+
accepts: (value) => value === null || value === "ltr" || value === "rtl",
|
|
65
|
+
needs: "\"ltr\", \"rtl\" or null"
|
|
66
|
+
},
|
|
67
|
+
number: {
|
|
68
|
+
accepts: (value) => typeof value === "number",
|
|
69
|
+
needs: "a number"
|
|
70
|
+
},
|
|
71
|
+
object: {
|
|
72
|
+
accepts: (value) => typeof value === "object" && value !== null && !Array.isArray(value),
|
|
73
|
+
needs: "an object"
|
|
74
|
+
},
|
|
75
|
+
optionalObject: {
|
|
76
|
+
accepts: (value) => value === null || typeof value === "object" && !Array.isArray(value),
|
|
77
|
+
needs: "an object or null"
|
|
78
|
+
},
|
|
79
|
+
string: {
|
|
80
|
+
accepts: (value) => typeof value === "string",
|
|
81
|
+
needs: "a string"
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const accepts = (constraint, value) => typeof constraint === "string" ? KINDS[constraint].accepts(value) : value === constraint.is;
|
|
85
|
+
const needs = (constraint) => typeof constraint === "string" ? KINDS[constraint].needs : JSON.stringify(constraint.is);
|
|
86
|
+
const ELEMENT_PROPERTIES = {
|
|
87
|
+
children: "array",
|
|
88
|
+
direction: "direction",
|
|
89
|
+
indent: "number"
|
|
90
|
+
};
|
|
91
|
+
/** A text node and everything built on one. */ const TEXT_PROPERTIES = {
|
|
92
|
+
detail: "number",
|
|
93
|
+
format: "number",
|
|
94
|
+
mode: "string",
|
|
95
|
+
style: "string",
|
|
96
|
+
text: "string"
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Carried by every node, whatever its type.
|
|
100
|
+
*
|
|
101
|
+
* Aligned with Payload rather than measured: its `outputSchema` declares `type`
|
|
102
|
+
* and `version` required for every node in the tree, and that declaration is
|
|
103
|
+
* what types the field in `payload-types.ts`. Lexical itself hydrates a node
|
|
104
|
+
* without a `version`, or with the wrong kind of one, unchanged - but a
|
|
105
|
+
* consumer reading the document through the generated types has been promised
|
|
106
|
+
* an integer, and `BlockNode.importJSON` migrates on it.
|
|
107
|
+
*/ const UNIVERSAL_PROPERTIES = { version: "number" };
|
|
108
|
+
/**
|
|
109
|
+
* The root, as Payload declares it and as an editor exports it: these six
|
|
110
|
+
* properties, these kinds, and nothing else.
|
|
111
|
+
*/ const ROOT_PROPERTIES = {
|
|
112
|
+
children: "array",
|
|
113
|
+
direction: "direction",
|
|
114
|
+
format: "string",
|
|
115
|
+
indent: "number",
|
|
116
|
+
type: "string",
|
|
117
|
+
version: "number"
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* What a serialized node must carry beyond {@link UNIVERSAL_PROPERTIES}, keyed
|
|
121
|
+
* by node type.
|
|
122
|
+
*
|
|
123
|
+
* Payload stores an editor state without hydrating it, so a node written
|
|
124
|
+
* without these, or with the wrong kind of value, is accepted and only fails
|
|
125
|
+
* later, in the admin editor. Payload declares nothing per node type, so this
|
|
126
|
+
* table is measured instead: an entry belongs here only if breaking it makes
|
|
127
|
+
* Lexical throw, or changes what the editor reads back. An element's `format`
|
|
128
|
+
* and a paragraph's text defaults are absent for that reason.
|
|
129
|
+
* `lexical.spec.ts` holds every entry to the rule against the node classes
|
|
130
|
+
* `@payloadcms/richtext-lexical` ships, so extend that test first.
|
|
131
|
+
*
|
|
132
|
+
* A node type with no entry is checked for the universal properties only.
|
|
133
|
+
* Guessing at the requirements of a project's own nodes would reject content
|
|
134
|
+
* that works.
|
|
135
|
+
*/ const REQUIRED_NODE_PROPERTIES = {
|
|
136
|
+
autolink: {
|
|
137
|
+
...ELEMENT_PROPERTIES,
|
|
138
|
+
fields: "object"
|
|
139
|
+
},
|
|
140
|
+
block: { fields: "object" },
|
|
141
|
+
heading: {
|
|
142
|
+
...ELEMENT_PROPERTIES,
|
|
143
|
+
tag: "string"
|
|
144
|
+
},
|
|
145
|
+
inlineBlock: { fields: "object" },
|
|
146
|
+
link: {
|
|
147
|
+
...ELEMENT_PROPERTIES,
|
|
148
|
+
fields: "object"
|
|
149
|
+
},
|
|
150
|
+
list: {
|
|
151
|
+
...ELEMENT_PROPERTIES,
|
|
152
|
+
listType: "string",
|
|
153
|
+
start: "number"
|
|
154
|
+
},
|
|
155
|
+
listitem: {
|
|
156
|
+
...ELEMENT_PROPERTIES,
|
|
157
|
+
value: "number"
|
|
158
|
+
},
|
|
159
|
+
paragraph: ELEMENT_PROPERTIES,
|
|
160
|
+
quote: ELEMENT_PROPERTIES,
|
|
161
|
+
relationship: {
|
|
162
|
+
relationTo: "string",
|
|
163
|
+
value: "number"
|
|
164
|
+
},
|
|
165
|
+
tab: {
|
|
166
|
+
...TEXT_PROPERTIES,
|
|
167
|
+
detail: { is: 2 },
|
|
168
|
+
text: { is: " " }
|
|
169
|
+
},
|
|
170
|
+
text: TEXT_PROPERTIES,
|
|
171
|
+
upload: {
|
|
172
|
+
fields: "optionalObject",
|
|
173
|
+
relationTo: "string",
|
|
174
|
+
value: "number"
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
/**
|
|
178
|
+
* Whether the table already says what a node type's `fields` has to be, so the
|
|
179
|
+
* sub-field walk does not report the same problem a second time.
|
|
180
|
+
*/ const constrainsFields = (type) => "fields" in (REQUIRED_NODE_PROPERTIES[type] ?? {});
|
|
181
|
+
/** Present but `null` counts as present: `direction` is serialized that way. */ const check = (node, constraints) => {
|
|
182
|
+
const problems = {
|
|
183
|
+
missing: [],
|
|
184
|
+
rejected: []
|
|
185
|
+
};
|
|
186
|
+
for (const [property, constraint] of Object.entries(constraints)) if (!(property in node)) problems.missing.push(property);
|
|
187
|
+
else if (!accepts(constraint, node[property])) problems.rejected.push({
|
|
188
|
+
needs: needs(constraint),
|
|
189
|
+
property
|
|
190
|
+
});
|
|
191
|
+
problems.missing.sort();
|
|
192
|
+
problems.rejected.sort((left, right) => left.property.localeCompare(right.property));
|
|
193
|
+
return problems;
|
|
194
|
+
};
|
|
195
|
+
const nodeProblems = (node) => check(node, {
|
|
196
|
+
...UNIVERSAL_PROPERTIES,
|
|
197
|
+
...REQUIRED_NODE_PROPERTIES[node["type"]] ?? {}
|
|
198
|
+
});
|
|
199
|
+
/**
|
|
200
|
+
* The root is the one node Payload describes itself, down to refusing an
|
|
201
|
+
* unknown property, so it is checked against that description rather than
|
|
202
|
+
* against the walk's table.
|
|
203
|
+
*/ const rootProblems = (root) => ({
|
|
204
|
+
...check(root, ROOT_PROPERTIES),
|
|
205
|
+
unexpected: Object.keys(root).filter((property) => !(property in ROOT_PROPERTIES))
|
|
206
|
+
});
|
|
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
|
+
/**
|
|
56
222
|
* Only properties a feature narrows and Lexical does not check on its own
|
|
57
223
|
* belong here. Everything else a feature restricts is already visible: a
|
|
58
224
|
* link's targets through its sub-schema, a block node's choices through the
|
|
@@ -95,4 +261,4 @@ const stringList = (value) => Array.isArray(value) && value.every((entry) => typ
|
|
|
95
261
|
return entries.length > 0 ? Object.fromEntries(entries) : void 0;
|
|
96
262
|
};
|
|
97
263
|
//#endregion
|
|
98
|
-
export { allowedNodeTypes, lexicalSubSchema, nodeOptions, 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,18 @@
|
|
|
1
|
-
import { lexicalSubSchema } 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.";
|
|
15
|
+
const quoted = (properties) => properties.map((property) => `"${property}"`).join(", ");
|
|
10
16
|
/**
|
|
11
17
|
* A node with nothing to declare, and one whose sub-fields cannot be named at a
|
|
12
18
|
* position, are both left alone.
|
|
@@ -15,7 +21,7 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
15
21
|
if (!sub) return;
|
|
16
22
|
const data = node.fields;
|
|
17
23
|
if (!isPlainObject(data)) {
|
|
18
|
-
scope.problems.push(`${scope.pointer}: a "${node.type}" node carries a "fields" object.`);
|
|
24
|
+
if (!constrainsFields(node.type)) scope.problems.push(`${scope.pointer}: a "${node.type}" node carries a "fields" object.`);
|
|
19
25
|
return;
|
|
20
26
|
}
|
|
21
27
|
const nested = {
|
|
@@ -42,45 +48,126 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
42
48
|
}, data);
|
|
43
49
|
};
|
|
44
50
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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) => {
|
|
53
105
|
if (!isPlainObject(value) || !isPlainObject(value["root"])) {
|
|
54
106
|
scope.problems.push(`${scope.pointer}: expected a Lexical editor state with a "root".`);
|
|
55
107
|
return;
|
|
56
108
|
}
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
109
|
+
const root = value["root"];
|
|
110
|
+
const { missing, rejected, unexpected } = rootProblems(root);
|
|
111
|
+
if (missing.length > 0) scope.problems.push(`${scope.pointer}/root: the root node is missing ${quoted(missing)}. Write nodes as Lexical serializes them.`);
|
|
112
|
+
for (const problem of rejected) scope.problems.push(`${scope.pointer}/root/${problem.property}: the root node needs ${problem.needs} here.`);
|
|
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(", ")}`);
|
|
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
|
|
82
149
|
};
|
|
83
|
-
|
|
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);
|
|
84
171
|
};
|
|
85
172
|
const checkLeafValue = (scope, descriptor, value) => {
|
|
86
173
|
if (descriptor.readOnly) {
|
|
@@ -188,6 +275,10 @@ const checkLeafValue = (scope, descriptor, value) => {
|
|
|
188
275
|
prefix: target.resolution.prefix,
|
|
189
276
|
problems
|
|
190
277
|
};
|
|
278
|
+
if (target.resolution.lexical) {
|
|
279
|
+
checkLexicalWrite(scope, target.resolution.lexical, value);
|
|
280
|
+
return problems;
|
|
281
|
+
}
|
|
191
282
|
if (target.resolution.descriptor) {
|
|
192
283
|
checkLeafValue(scope, target.resolution.descriptor, value);
|
|
193
284
|
return problems;
|
|
@@ -196,4 +287,4 @@ const checkLeafValue = (scope, descriptor, value) => {
|
|
|
196
287
|
return problems;
|
|
197
288
|
};
|
|
198
289
|
//#endregion
|
|
199
|
-
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,6 +22,10 @@ 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. 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.
|
|
28
|
+
|
|
25
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".
|
|
26
30
|
|
|
27
31
|
Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed and cannot be written. Fields marked readOnly are listed but refused on write.`,
|
|
@@ -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",
|