@abinnovision/payloadcms-mcpx 1.0.0-beta.9 → 1.0.0
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/CHANGELOG.md +33 -0
- package/README.md +328 -211
- package/dist/api-keys/fields.mjs +22 -5
- package/dist/api-keys/setup-guide.mjs +6 -4
- package/dist/auth/resolve.mjs +5 -7
- package/dist/capabilities.mjs +23 -4
- package/dist/client/index.d.mts +2 -2
- package/dist/client/setup-guide.d.mts +1 -1
- package/dist/endpoint/{result.mjs → errors.mjs} +4 -23
- package/dist/endpoint/handler.mjs +11 -5
- package/dist/endpoint/index.mjs +4 -0
- package/dist/endpoint/server.mjs +18 -26
- package/dist/i18n.mjs +4 -15
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +4 -3
- package/dist/options.mjs +30 -26
- package/dist/plugin.mjs +1 -0
- package/dist/{write/draft-guard.d.mts → request.d.mts} +2 -2
- package/dist/request.mjs +8 -0
- package/dist/result.d.mts +11 -0
- package/dist/result.mjs +20 -0
- package/dist/schema/describe.mjs +3 -15
- package/dist/schema/index.mjs +8 -0
- package/dist/schema/lexical-pointer.mjs +125 -0
- package/dist/schema/lexical.mjs +195 -27
- package/dist/schema/outline.mjs +67 -0
- package/dist/schema/pointer.mjs +77 -30
- package/dist/schema/shape.mjs +133 -51
- package/dist/schema/walk.mjs +44 -64
- package/dist/tools/{index.mjs → builtin.mjs} +8 -5
- package/dist/tools/create-document.mjs +34 -15
- package/dist/tools/describe-schema.mjs +21 -7
- package/dist/tools/find-documents.mjs +13 -6
- package/dist/tools/get-document.mjs +45 -11
- package/dist/tools/list-capabilities.mjs +19 -9
- package/dist/tools/names.mjs +2 -1
- package/dist/tools/patch-document.mjs +32 -21
- package/dist/tools/publish-document.mjs +79 -0
- package/dist/tools/shared.mjs +84 -32
- package/dist/tools/target.mjs +7 -11
- package/dist/tools/validate-document.mjs +20 -12
- package/dist/types.d.mts +110 -42
- package/dist/types.mjs +3 -4
- package/dist/version.mjs +1 -1
- package/dist/write/draft-guard.mjs +47 -44
- package/dist/write/patch.mjs +174 -92
- package/dist/write/publish-blockers.mjs +13 -12
- package/dist/write/publish-intent.mjs +17 -0
- package/dist/write/transaction.mjs +8 -3
- package/package.json +3 -3
- package/dist/i18n.d.mts +0 -1
- package/dist/options.d.mts +0 -2
- package/dist/schema/lexical.d.mts +0 -1
- package/dist/schema/walk.d.mts +0 -3
- package/dist/tools/target.d.mts +0 -3
- package/dist/tools/types.d.mts +0 -5
- package/dist/write/publish-blockers.d.mts +0 -15
|
@@ -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". getDocument with "outline" lists every position this field holds.`);
|
|
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. getDocument with "outline" reports the pointer of each node in it.`);
|
|
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. Call getDocument with "outline" for the pointer of each node, 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
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { flattenAllFields } from "payload";
|
|
2
2
|
//#region src/schema/lexical.ts
|
|
3
3
|
/**
|
|
4
|
-
* Node types Lexical registers itself.
|
|
5
|
-
*
|
|
6
4
|
* `editorConfig.features.nodes` lists only what a feature contributed, so a
|
|
7
5
|
* field whose editor enables nothing but text formatting reports none at all.
|
|
8
6
|
*/ const LEXICAL_CORE_NODES = [
|
|
@@ -13,26 +11,17 @@ import { flattenAllFields } from "payload";
|
|
|
13
11
|
"tab"
|
|
14
12
|
];
|
|
15
13
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* upload fields concatenated, so the result describes no single position. It
|
|
20
|
-
* would need addressing by `relationTo` to mean anything.
|
|
14
|
+
* Sub-fields exist but cannot be addressed by a schema path. Asked without a
|
|
15
|
+
* node, `upload` answers with every enabled collection's upload fields
|
|
16
|
+
* concatenated, so the result describes no single position.
|
|
21
17
|
*/ const OPAQUE_NODE_TYPES = /* @__PURE__ */ new Set(["upload"]);
|
|
22
18
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* Worth caching because the describe and validate paths resolve the same field
|
|
27
|
-
* repeatedly, and because the block features build their answer from scratch on
|
|
28
|
-
* every call. Keyed weakly on the sanitized field, which lives as long as the
|
|
29
|
-
* config does.
|
|
19
|
+
* `null` records a node type that was asked and has nothing to describe, so it
|
|
20
|
+
* is asked only once. Worth caching because describe and validate resolve the
|
|
21
|
+
* same field repeatedly and the block features rebuild their answer every call.
|
|
30
22
|
*/ const subSchemaCache = /* @__PURE__ */ new WeakMap();
|
|
31
23
|
const featuresOf = (field) => field.editor?.editorConfig?.features;
|
|
32
|
-
/**
|
|
33
|
-
* Node types a rich text field accepts. Editors other than Lexical report
|
|
34
|
-
* only the core nodes.
|
|
35
|
-
*/ const allowedNodeTypes = (field) => {
|
|
24
|
+
/** Editors other than Lexical report only the core nodes. */ const allowedNodeTypes = (field) => {
|
|
36
25
|
const registered = (featuresOf(field)?.nodes ?? []).flatMap((entry) => {
|
|
37
26
|
const type = entry.node?.getType?.();
|
|
38
27
|
return type ? [type] : [];
|
|
@@ -53,10 +42,7 @@ const resolveSubSchema = (field, nodeType) => {
|
|
|
53
42
|
kind: "fields"
|
|
54
43
|
};
|
|
55
44
|
};
|
|
56
|
-
|
|
57
|
-
* The sub-schema behind one node type of a rich text field, or `undefined`
|
|
58
|
-
* when that node carries no addressable fields.
|
|
59
|
-
*/ const lexicalSubSchema = (field, nodeType) => {
|
|
45
|
+
const lexicalSubSchema = (field, nodeType) => {
|
|
60
46
|
let cached = subSchemaCache.get(field);
|
|
61
47
|
if (!cached) {
|
|
62
48
|
cached = /* @__PURE__ */ new Map();
|
|
@@ -65,13 +51,195 @@ const resolveSubSchema = (field, nodeType) => {
|
|
|
65
51
|
if (!cached.has(nodeType)) cached.set(nodeType, resolveSubSchema(field, nodeType));
|
|
66
52
|
return cached.get(nodeType) ?? void 0;
|
|
67
53
|
};
|
|
54
|
+
/** In the order their features registered them. */ const subSchemaNodeTypes = (field) => [...featuresOf(field)?.getSubFields?.keys() ?? []].filter((nodeType) => lexicalSubSchema(field, nodeType) !== void 0);
|
|
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
|
+
};
|
|
68
119
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
|
|
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
|
+
const describeConstraints = (constraints) => Object.fromEntries(Object.entries(constraints).map(([property, constraint]) => [property, needs(constraint)]).sort((left, right) => left[0].localeCompare(right[0])));
|
|
182
|
+
/**
|
|
183
|
+
* What each of the given node types has to carry, phrased the way the write
|
|
184
|
+
* side phrases it when it refuses one, so the listing and the error message
|
|
185
|
+
* never disagree. Read straight off the tables above, which is what keeps it
|
|
186
|
+
* true: nothing here is stated a second time.
|
|
187
|
+
*
|
|
188
|
+
* Keyed by node type rather than reported per field, because that is what it
|
|
189
|
+
* depends on. A field says which types it allows, in its `nodes`; what a `text`
|
|
190
|
+
* node has to carry is the same wherever one is written, so a response that
|
|
191
|
+
* describes twenty rich text fields still states it once.
|
|
192
|
+
*
|
|
193
|
+
* `type` is listed although {@link UNIVERSAL_PROPERTIES} omits it. The
|
|
194
|
+
* validator never reports it missing, because a node's `type` is how it finds
|
|
195
|
+
* the entry to check against, but a client assembling a node from this listing
|
|
196
|
+
* still has to write one.
|
|
197
|
+
*/ const nodePropertiesFor = (types) => Object.fromEntries([...new Set(types)].sort().map((type) => [type, describeConstraints(type === "root" ? ROOT_PROPERTIES : {
|
|
198
|
+
...REQUIRED_NODE_PROPERTIES[type],
|
|
199
|
+
...UNIVERSAL_PROPERTIES,
|
|
200
|
+
type: "string"
|
|
201
|
+
})]));
|
|
202
|
+
/** Present but `null` counts as present: `direction` is serialized that way. */ const check = (node, constraints) => {
|
|
203
|
+
const problems = {
|
|
204
|
+
missing: [],
|
|
205
|
+
rejected: []
|
|
206
|
+
};
|
|
207
|
+
for (const [property, constraint] of Object.entries(constraints)) if (!(property in node)) problems.missing.push(property);
|
|
208
|
+
else if (!accepts(constraint, node[property])) problems.rejected.push({
|
|
209
|
+
needs: needs(constraint),
|
|
210
|
+
property
|
|
211
|
+
});
|
|
212
|
+
problems.missing.sort();
|
|
213
|
+
problems.rejected.sort((left, right) => left.property.localeCompare(right.property));
|
|
214
|
+
return problems;
|
|
215
|
+
};
|
|
216
|
+
const nodeProblems = (node) => check(node, {
|
|
217
|
+
...UNIVERSAL_PROPERTIES,
|
|
218
|
+
...REQUIRED_NODE_PROPERTIES[node["type"]] ?? {}
|
|
219
|
+
});
|
|
72
220
|
/**
|
|
73
|
-
*
|
|
221
|
+
* The root is the one node Payload describes itself, down to refusing an
|
|
222
|
+
* unknown property, so it is checked against that description rather than
|
|
223
|
+
* against the walk's table.
|
|
224
|
+
*/ const rootProblems = (root) => ({
|
|
225
|
+
...check(root, ROOT_PROPERTIES),
|
|
226
|
+
unexpected: Object.keys(root).filter((property) => !(property in ROOT_PROPERTIES))
|
|
227
|
+
});
|
|
228
|
+
/**
|
|
229
|
+
* What one serialized property has to be, for a write addressing a property
|
|
230
|
+
* rather than a whole node.
|
|
74
231
|
*
|
|
232
|
+
* Absent where the table says nothing, which is the same tolerance the node
|
|
233
|
+
* walk shows: a project's own node may carry any property, and guessing at one
|
|
234
|
+
* would reject content that works.
|
|
235
|
+
*/ const propertyProblem = (nodeType, property, value) => {
|
|
236
|
+
const constraint = (nodeType === "root" ? ROOT_PROPERTIES : {
|
|
237
|
+
...UNIVERSAL_PROPERTIES,
|
|
238
|
+
...REQUIRED_NODE_PROPERTIES[nodeType] ?? {}
|
|
239
|
+
})[property];
|
|
240
|
+
return constraint === void 0 || accepts(constraint, value) ? void 0 : { needs: needs(constraint) };
|
|
241
|
+
};
|
|
242
|
+
/**
|
|
75
243
|
* Only properties a feature narrows and Lexical does not check on its own
|
|
76
244
|
* belong here. Everything else a feature restricts is already visible: a
|
|
77
245
|
* link's targets through its sub-schema, a block node's choices through the
|
|
@@ -114,4 +282,4 @@ const stringList = (value) => Array.isArray(value) && value.every((entry) => typ
|
|
|
114
282
|
return entries.length > 0 ? Object.fromEntries(entries) : void 0;
|
|
115
283
|
};
|
|
116
284
|
//#endregion
|
|
117
|
-
export { allowedNodeTypes, lexicalSubSchema, nodeOptions, subSchemaNodeTypes };
|
|
285
|
+
export { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, nodePropertiesFor, 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.
|
|
@@ -9,10 +9,7 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
|
|
|
9
9
|
descriptor,
|
|
10
10
|
parts: splitPath(descriptor.path)
|
|
11
11
|
})).filter(({ parts }) => parts.every((part, offset) => partMatches(part, segments[offset]))).sort((left, right) => right.consumed - left.consumed)[0];
|
|
12
|
-
/**
|
|
13
|
-
* Whether the segments stop part-way through some descriptor's path, which
|
|
14
|
-
* means they address a subtree rather than a field.
|
|
15
|
-
*/ const isSubtreePrefix = (descriptors, segments) => descriptors.some((descriptor) => {
|
|
12
|
+
/** Stopping part-way through a descriptor's path means a subtree, not a field. */ const isSubtreePrefix = (descriptors, segments) => descriptors.some((descriptor) => {
|
|
16
13
|
const parts = splitPath(descriptor.path);
|
|
17
14
|
return parts.length > segments.length && segments.every((segment, offset) => {
|
|
18
15
|
const part = parts[offset];
|
|
@@ -20,15 +17,33 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
|
|
|
20
17
|
});
|
|
21
18
|
});
|
|
22
19
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* descended through rather than skipped.
|
|
20
|
+
* Unlike a descriptor path, the segments carry real indices, so intervening
|
|
21
|
+
* array fields are descended through rather than skipped.
|
|
26
22
|
*/ const valueAtSegments = (data, segments) => segments.reduce((current, segment) => current === null || typeof current !== "object" ? void 0 : current[segment], data);
|
|
27
23
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
|
|
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
|
+
/**
|
|
45
|
+
* The stored document chooses the branch at every blocks element, and is
|
|
46
|
+
* required rather than optional: `/layout/sections/3/modules/1`
|
|
32
47
|
* can only be resolved by reading `blockType` off `sections[3]`, since a blocks
|
|
33
48
|
* field admits many shapes at the same index.
|
|
34
49
|
*/ const resolveDataPointer = (config, target) => {
|
|
@@ -36,6 +51,8 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
|
|
|
36
51
|
let data = target.doc;
|
|
37
52
|
let blockType;
|
|
38
53
|
let segments = splitPath(target.pointer);
|
|
54
|
+
let readOnly;
|
|
55
|
+
let inLexical;
|
|
39
56
|
while (segments.length > 0) {
|
|
40
57
|
const descriptors = describeAddressableFields(fields);
|
|
41
58
|
const match = longestMatch(descriptors, segments);
|
|
@@ -43,7 +60,7 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
|
|
|
43
60
|
if (isSubtreePrefix(descriptors, segments)) return {
|
|
44
61
|
...blockType === void 0 ? {} : { blockType },
|
|
45
62
|
fields,
|
|
46
|
-
prefix: segments
|
|
63
|
+
prefix: segments.map((segment) => isIndexSegment(segment) ? "*" : segment)
|
|
47
64
|
};
|
|
48
65
|
throw new Error(`"${joinPath(segments)}" is not a field here. Available: ${descriptors.map((descriptor) => descriptor.path).join(", ")}`);
|
|
49
66
|
}
|
|
@@ -52,28 +69,58 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
|
|
|
52
69
|
...blockType === void 0 ? {} : { blockType },
|
|
53
70
|
descriptor: match.descriptor,
|
|
54
71
|
fields,
|
|
55
|
-
prefix: []
|
|
72
|
+
prefix: [],
|
|
73
|
+
...inLexical === void 0 ? {} : { inLexical },
|
|
74
|
+
...readOnly === void 0 ? {} : { readOnly }
|
|
56
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
|
+
}
|
|
57
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.`);
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
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;
|
|
72
117
|
}
|
|
73
118
|
return {
|
|
74
119
|
...blockType === void 0 ? {} : { blockType },
|
|
75
120
|
fields,
|
|
76
|
-
|
|
121
|
+
...inLexical === void 0 ? {} : { inLexical },
|
|
122
|
+
prefix: [],
|
|
123
|
+
...readOnly === void 0 ? {} : { readOnly }
|
|
77
124
|
};
|
|
78
125
|
};
|
|
79
126
|
//#endregion
|