@jxsuite/schema 3.0.0 → 4.1.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/defs/children-value.schema.ts +2 -2
- package/defs/class-def.schema.ts +1 -1
- package/defs/expression-node.schema.ts +2 -2
- package/defs/ref-object.schema.ts +3 -3
- package/package.json +4 -4
- package/schema.json +52 -26
- package/src/a11y.ts +91 -95
- package/src/asset-paths.ts +23 -1
- package/src/component-meta.ts +123 -55
- package/src/dialogs.ts +20 -19
- package/src/doc-ops.ts +291 -40
- package/src/extension-registry.ts +15 -8
- package/src/format-registry.ts +504 -31
- package/src/guards.ts +89 -14
- package/src/ijson.ts +8 -8
- package/src/json-layout.ts +64 -25
- package/src/locale.ts +23 -18
- package/src/media-type.ts +26 -12
- package/src/overlays.ts +50 -45
- package/src/parse.ts +42 -24
- package/src/project-schemas.ts +94 -66
- package/src/schema.ts +50 -38
- package/src/validate-project.ts +34 -11
- package/types.ts +116 -47
package/src/doc-ops.ts
CHANGED
|
@@ -15,18 +15,19 @@
|
|
|
15
15
|
* @module @jxsuite/schema/doc-ops
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import
|
|
18
|
+
import { isNonNullObject, isNumber, isString } from "./guards";
|
|
19
|
+
import type { JsonValue, JxDocumentValue, JxMutableNode, JxPath } from "../types.ts";
|
|
19
20
|
|
|
20
21
|
/** Value-carrying document mutation, replayable in either direction. */
|
|
21
22
|
export type JxDocOp =
|
|
22
23
|
/** Set (or delete, when value is undefined) a key on the node at path. */
|
|
23
|
-
| { op: "set-key"; path: JxPath; key: string; value?:
|
|
24
|
+
| { op: "set-key"; path: JxPath; key: string; value?: JxDocumentValue | undefined }
|
|
24
25
|
/** Insert a node at parentPath.children[index]. */
|
|
25
|
-
| { op: "insert-child"; parentPath: JxPath; index: number; node:
|
|
26
|
+
| { op: "insert-child"; parentPath: JxPath; index: number; node: JxMutableNode | string }
|
|
26
27
|
/** Remove parentPath.children[index]. */
|
|
27
28
|
| { op: "remove-child"; parentPath: JxPath; index: number }
|
|
28
29
|
/** Replace parentPath.children[index] with node. */
|
|
29
|
-
| { op: "set-child"; parentPath: JxPath; index: number; node:
|
|
30
|
+
| { op: "set-child"; parentPath: JxPath; index: number; node: JxMutableNode | string }
|
|
30
31
|
/** Raw two-splice move: remove fromParent.children[fromIndex], insert at toParent[toIndex]. */
|
|
31
32
|
| {
|
|
32
33
|
op: "move-child";
|
|
@@ -43,27 +44,34 @@ export interface JxDocOpPair {
|
|
|
43
44
|
|
|
44
45
|
/** JSON round-trip clone — also normalizes away reactive proxies / functions / undefined. */
|
|
45
46
|
function jsonClone<T>(value: T): T {
|
|
47
|
+
/* SAFETY: every value cloned here is document data (a node, a children member, or a key's value),
|
|
48
|
+
which is JSON by contract; the round trip reproduces that shape and drops only functions and
|
|
49
|
+
`undefined` members, which the document types already treat as absent. */
|
|
46
50
|
// oxlint-disable-next-line unicorn/prefer-structured-clone -- structuredClone throws on reactive proxies; JSON normalization is the point
|
|
47
51
|
return JSON.parse(JSON.stringify(value)) as T;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
/** Deep-clone a recorded value (undefined/null pass through; reactive proxies are read through). */
|
|
51
55
|
export function cloneValue<T>(v: T): T {
|
|
52
|
-
return v === undefined || v === null ? v :
|
|
56
|
+
return v === undefined || v === null ? v : jsonClone(v);
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
/**
|
|
56
|
-
* Walk a plain document tree by path. Paths address nodes by construction; missing
|
|
57
|
-
*
|
|
58
|
-
* historical helper).
|
|
60
|
+
* Walk a plain document tree by path. Paths address nodes by construction; a missing segment
|
|
61
|
+
* surfaces as undefined, so every caller checks the answer before treating it as a node.
|
|
59
62
|
*/
|
|
60
|
-
export function getNodeAtPath(doc: JxMutableNode, path: JxPath): JxMutableNode {
|
|
61
|
-
let node = doc;
|
|
63
|
+
export function getNodeAtPath(doc: JxMutableNode, path: JxPath): JxMutableNode | undefined {
|
|
64
|
+
let node: JxMutableNode | undefined = doc;
|
|
62
65
|
for (const key of path) {
|
|
66
|
+
// `== null` also stops at a JSON `null` partway down the path.
|
|
63
67
|
if (node == null) {
|
|
64
|
-
return undefined
|
|
68
|
+
return undefined;
|
|
65
69
|
}
|
|
66
|
-
node
|
|
70
|
+
/* SAFETY: a `JxPath` alternates node keys with a "children" key and an index, so each step
|
|
71
|
+
lands on a node, on a children array (whose only use is the next, numeric, lookup, which an
|
|
72
|
+
array answers as a node would), or on nothing. A path that wrongly ends on a string child or a
|
|
73
|
+
children array reaches a caller as one; `childArray` and `insertableChildren` refuse it. */
|
|
74
|
+
node = node[key] as JxMutableNode | undefined;
|
|
67
75
|
}
|
|
68
76
|
return node;
|
|
69
77
|
}
|
|
@@ -90,44 +98,40 @@ export function childArray(node: JxMutableNode): (JxMutableNode | string)[] {
|
|
|
90
98
|
* aliases the op object. Throws (with a machine-readable reason) when a target path is missing.
|
|
91
99
|
*/
|
|
92
100
|
export function applyDocOpToDoc(doc: JxMutableNode, op: JxDocOp): void {
|
|
101
|
+
// Read before the switch: an op from outside may carry a kind the union does not name.
|
|
102
|
+
const { op: kind } = op;
|
|
93
103
|
switch (op.op) {
|
|
94
104
|
case "set-key": {
|
|
95
105
|
const node = getNodeAtPath(doc, op.path);
|
|
96
106
|
if (!node) {
|
|
97
107
|
throw new Error(`doc-op-node-not-found:${op.path.join("/")}`);
|
|
98
108
|
}
|
|
99
|
-
const target = node as Record<string, unknown>;
|
|
100
109
|
if (op.value === undefined) {
|
|
101
|
-
delete
|
|
110
|
+
delete node[op.key];
|
|
102
111
|
} else {
|
|
103
|
-
|
|
112
|
+
node[op.key] = cloneValue(op.value);
|
|
104
113
|
}
|
|
105
114
|
return;
|
|
106
115
|
}
|
|
107
116
|
case "insert-child": {
|
|
108
|
-
|
|
109
|
-
childArray(parent).splice(op.index, 0, cloneValue(op.node) as JxMutableNode);
|
|
117
|
+
childArrayAt(doc, op.parentPath).splice(op.index, 0, cloneValue(op.node));
|
|
110
118
|
return;
|
|
111
119
|
}
|
|
112
120
|
case "remove-child": {
|
|
113
|
-
|
|
114
|
-
childArray(parent).splice(op.index, 1);
|
|
121
|
+
childArrayAt(doc, op.parentPath).splice(op.index, 1);
|
|
115
122
|
return;
|
|
116
123
|
}
|
|
117
124
|
case "set-child": {
|
|
118
|
-
|
|
119
|
-
childArray(parent).splice(op.index, 1, cloneValue(op.node) as JxMutableNode);
|
|
125
|
+
childArrayAt(doc, op.parentPath).splice(op.index, 1, cloneValue(op.node));
|
|
120
126
|
return;
|
|
121
127
|
}
|
|
122
128
|
case "move-child": {
|
|
123
129
|
assertNotIntoOwnSubtree(op);
|
|
124
|
-
const fromParent = getNodeAtPath(doc, op.fromParentPath);
|
|
125
|
-
const toParent = getNodeAtPath(doc, op.toParentPath);
|
|
126
130
|
/* Both arrays are resolved BEFORE the source is spliced, so a move whose target cannot receive
|
|
127
131
|
children throws with the document untouched rather than having detached the node it could
|
|
128
132
|
not place. */
|
|
129
|
-
const from =
|
|
130
|
-
const to =
|
|
133
|
+
const from = childArrayAt(doc, op.fromParentPath);
|
|
134
|
+
const to = childArrayAt(doc, op.toParentPath);
|
|
131
135
|
const [node] = from.splice(op.fromIndex, 1);
|
|
132
136
|
to.splice(op.toIndex, 0, node!);
|
|
133
137
|
return;
|
|
@@ -136,7 +140,19 @@ export function applyDocOpToDoc(doc: JxMutableNode, op: JxDocOp): void {
|
|
|
136
140
|
break;
|
|
137
141
|
}
|
|
138
142
|
}
|
|
139
|
-
throw new Error(`unknown-doc-op:${
|
|
143
|
+
throw new Error(`unknown-doc-op:${kind}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The children array of the node at `parentPath`, lazily created (see {@link childArray}); throws a
|
|
148
|
+
* `TypeError` naming the path when no node is there.
|
|
149
|
+
*/
|
|
150
|
+
function childArrayAt(doc: JxMutableNode, parentPath: JxPath): (JxMutableNode | string)[] {
|
|
151
|
+
const parent = getNodeAtPath(doc, parentPath);
|
|
152
|
+
if (parent == null) {
|
|
153
|
+
throw new TypeError(`doc-op-node-not-found:${parentPath.join("/")}`);
|
|
154
|
+
}
|
|
155
|
+
return childArray(parent);
|
|
140
156
|
}
|
|
141
157
|
|
|
142
158
|
/**
|
|
@@ -145,8 +161,15 @@ export function applyDocOpToDoc(doc: JxMutableNode, op: JxDocOp): void {
|
|
|
145
161
|
* insertion (delta +1, inclusive) shifts them up. Returns `path` unchanged when the splice does not
|
|
146
162
|
* affect it — including when `path` IS `parentPath`, since a splice moves a node's children, never
|
|
147
163
|
* the node.
|
|
164
|
+
*
|
|
165
|
+
* @param {JxPath} parentPath - The node whose children were spliced
|
|
166
|
+
* @param {JxPath} path - The path to translate
|
|
167
|
+
* @param {number} spliceIndex - Where the splice happened
|
|
168
|
+
* @param {number} delta - -1 for a removal, +1 for an insertion
|
|
169
|
+
* @param {boolean} inclusive - Whether a path AT the index shifts (an insertion's rule)
|
|
170
|
+
* @returns {JxPath}
|
|
148
171
|
*/
|
|
149
|
-
function
|
|
172
|
+
export function shiftPrefixedIndex(
|
|
150
173
|
parentPath: JxPath,
|
|
151
174
|
path: JxPath,
|
|
152
175
|
spliceIndex: number,
|
|
@@ -155,10 +178,10 @@ function shiftThroughSplice(
|
|
|
155
178
|
): JxPath {
|
|
156
179
|
const depth = parentPath.length;
|
|
157
180
|
const isProperPrefix = depth < path.length && parentPath.every((seg, i) => seg === path[i]);
|
|
158
|
-
|
|
181
|
+
const index = path[depth + 1];
|
|
182
|
+
if (!isProperPrefix || path[depth] !== "children" || !isNumber(index)) {
|
|
159
183
|
return path;
|
|
160
184
|
}
|
|
161
|
-
const index = path[depth + 1] as number;
|
|
162
185
|
if (inclusive ? index < spliceIndex : index <= spliceIndex) {
|
|
163
186
|
return path;
|
|
164
187
|
}
|
|
@@ -185,7 +208,7 @@ function assertNotIntoOwnSubtree(op: Extract<JxDocOp, { op: "move-child" }>): vo
|
|
|
185
208
|
* counts from the end and an index past the end appends. The applier inserts with `splice`, so an
|
|
186
209
|
* inverse must name the slot the node really landed in, not the one the op asked for.
|
|
187
210
|
*/
|
|
188
|
-
function insertionSlot(index: number, length: number): number {
|
|
211
|
+
export function insertionSlot(index: number, length: number): number {
|
|
189
212
|
return index < 0 ? Math.max(length + index, 0) : Math.min(index, length);
|
|
190
213
|
}
|
|
191
214
|
|
|
@@ -218,13 +241,21 @@ function insertionSlot(index: number, length: number): number {
|
|
|
218
241
|
* @returns {JxDocOp}
|
|
219
242
|
*/
|
|
220
243
|
export function inverseOf(doc: JxMutableNode, op: JxDocOp): JxDocOp {
|
|
244
|
+
// Read before the switch: an op from outside may carry a kind the union does not name.
|
|
245
|
+
const { op: kind } = op;
|
|
246
|
+
assertIntegerIndexes(op);
|
|
221
247
|
switch (op.op) {
|
|
222
248
|
case "set-key": {
|
|
223
249
|
const node = getNodeAtPath(doc, op.path);
|
|
224
|
-
if (
|
|
250
|
+
if (node === undefined) {
|
|
225
251
|
throw new Error(`doc-op-node-not-found:${op.path.join("/")}`);
|
|
226
252
|
}
|
|
227
|
-
|
|
253
|
+
/* A key set on a text child or a children array lands nowhere a renderer reads, so the edit
|
|
254
|
+
would succeed without effect; on `null` it would throw mid-transaction. */
|
|
255
|
+
if (!isNonNullObject(node) || Array.isArray(node)) {
|
|
256
|
+
throw new TypeError(`doc-op-not-a-node:${op.path.join("/")}`);
|
|
257
|
+
}
|
|
258
|
+
const before = cloneValue(node[op.key]);
|
|
228
259
|
return before === undefined
|
|
229
260
|
? { key: op.key, op: "set-key", path: op.path }
|
|
230
261
|
: { key: op.key, op: "set-key", path: op.path, value: before };
|
|
@@ -255,7 +286,7 @@ export function inverseOf(doc: JxMutableNode, op: JxDocOp): JxDocOp {
|
|
|
255
286
|
return {
|
|
256
287
|
fromIndex: toIndex,
|
|
257
288
|
// The removal shifted paths under the source parent; the insertion, under the target.
|
|
258
|
-
fromParentPath:
|
|
289
|
+
fromParentPath: shiftPrefixedIndex(
|
|
259
290
|
op.fromParentPath,
|
|
260
291
|
op.toParentPath,
|
|
261
292
|
op.fromIndex,
|
|
@@ -264,14 +295,34 @@ export function inverseOf(doc: JxMutableNode, op: JxDocOp): JxDocOp {
|
|
|
264
295
|
),
|
|
265
296
|
op: "move-child",
|
|
266
297
|
toIndex: op.fromIndex,
|
|
267
|
-
toParentPath:
|
|
298
|
+
toParentPath: shiftPrefixedIndex(op.toParentPath, op.fromParentPath, toIndex, 1, true),
|
|
268
299
|
};
|
|
269
300
|
}
|
|
270
301
|
default: {
|
|
271
302
|
break;
|
|
272
303
|
}
|
|
273
304
|
}
|
|
274
|
-
throw new Error(`unknown-doc-op:${
|
|
305
|
+
throw new Error(`unknown-doc-op:${kind}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Throw `doc-op-index:<path>` unless every index the op carries is an integer. A numeric string, a
|
|
310
|
+
* fraction or `NaN` would reach `splice`, which coerces or truncates it, so the op would land in a
|
|
311
|
+
* slot its recorded inverse does not name. Producers coerce canonical numeric strings before they
|
|
312
|
+
* build an op; this is the defence behind them.
|
|
313
|
+
*/
|
|
314
|
+
function assertIntegerIndexes(op: JxDocOp): void {
|
|
315
|
+
const indexes: [JxPath, unknown][] = [];
|
|
316
|
+
if (op.op === "move-child") {
|
|
317
|
+
indexes.push([op.fromParentPath, op.fromIndex], [op.toParentPath, op.toIndex]);
|
|
318
|
+
} else if (op.op === "insert-child" || op.op === "remove-child" || op.op === "set-child") {
|
|
319
|
+
indexes.push([op.parentPath, op.index]);
|
|
320
|
+
}
|
|
321
|
+
for (const [parentPath, index] of indexes) {
|
|
322
|
+
if (!Number.isInteger(index)) {
|
|
323
|
+
throw new TypeError(`doc-op-index:${[...parentPath, "children", String(index)].join("/")}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
275
326
|
}
|
|
276
327
|
|
|
277
328
|
/** The node's existing children array, or null when it has none (or is not a node). */
|
|
@@ -292,10 +343,15 @@ function assertIndex(
|
|
|
292
343
|
}
|
|
293
344
|
|
|
294
345
|
/** A clone of `parentPath`'s child at `index`, or the applier's reason when there is none. */
|
|
295
|
-
function existingChild(
|
|
346
|
+
function existingChild(
|
|
347
|
+
doc: JxMutableNode,
|
|
348
|
+
parentPath: JxPath,
|
|
349
|
+
index: number,
|
|
350
|
+
): JxMutableNode | string {
|
|
296
351
|
const children = childrenAt(doc, parentPath);
|
|
297
352
|
assertIndex(children, parentPath, index);
|
|
298
|
-
|
|
353
|
+
// `assertIndex` has just proven `index` is in range.
|
|
354
|
+
return cloneValue(children[index]!);
|
|
299
355
|
}
|
|
300
356
|
|
|
301
357
|
/**
|
|
@@ -304,8 +360,9 @@ function existingChild(doc: JxMutableNode, parentPath: JxPath, index: number): u
|
|
|
304
360
|
* children array addressed as if it were a node, or a mapped-array `children`.
|
|
305
361
|
*/
|
|
306
362
|
function insertableChildren(doc: JxMutableNode, parentPath: JxPath): (JxMutableNode | string)[] {
|
|
307
|
-
const node = getNodeAtPath(doc, parentPath)
|
|
308
|
-
|
|
363
|
+
const node = getNodeAtPath(doc, parentPath);
|
|
364
|
+
// A path that ends on a string child or a children array is refused here, not just a missing one.
|
|
365
|
+
if (!isNonNullObject(node) || Array.isArray(node)) {
|
|
309
366
|
throw new Error(`doc-op-node-not-found:${parentPath.join("/")}`);
|
|
310
367
|
}
|
|
311
368
|
if (node.children === undefined) {
|
|
@@ -314,7 +371,7 @@ function insertableChildren(doc: JxMutableNode, parentPath: JxPath): (JxMutableN
|
|
|
314
371
|
if (!Array.isArray(node.children)) {
|
|
315
372
|
throw new TypeError(`doc-op-mapped-children:${parentPath.join("/")}`);
|
|
316
373
|
}
|
|
317
|
-
return node.children
|
|
374
|
+
return node.children;
|
|
318
375
|
}
|
|
319
376
|
|
|
320
377
|
/**
|
|
@@ -346,3 +403,197 @@ export function applyDocOpsWithInverse(doc: JxMutableNode, ops: readonly JxDocOp
|
|
|
346
403
|
}
|
|
347
404
|
return pairs;
|
|
348
405
|
}
|
|
406
|
+
|
|
407
|
+
// ─── Paths ───────────────────────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Whether `path` is an ancestor of (or equal to) `descendant`.
|
|
411
|
+
*
|
|
412
|
+
* @param {JxPath} path
|
|
413
|
+
* @param {JxPath} descendant
|
|
414
|
+
* @returns {boolean}
|
|
415
|
+
*/
|
|
416
|
+
export function isAncestor(path: JxPath, descendant: JxPath): boolean {
|
|
417
|
+
if (path.length > descendant.length) {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
return path.every((v, i) => v === descendant[i]);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Order two paths the way the document reads: parents before children, siblings by index.
|
|
425
|
+
*
|
|
426
|
+
* Segments are compared pairwise; numbers numerically (so `children/10` follows `children/9`), and
|
|
427
|
+
* anything else by string order. A prefix sorts before what extends it.
|
|
428
|
+
*
|
|
429
|
+
* @param {JxPath} a
|
|
430
|
+
* @param {JxPath} b
|
|
431
|
+
* @returns {number}
|
|
432
|
+
*/
|
|
433
|
+
export function comparePaths(a: JxPath, b: JxPath): number {
|
|
434
|
+
const len = Math.min(a.length, b.length);
|
|
435
|
+
for (let i = 0; i < len; i++) {
|
|
436
|
+
const x = a[i]!;
|
|
437
|
+
const y = b[i]!;
|
|
438
|
+
if (x === y) {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (isNumber(x) && isNumber(y)) {
|
|
442
|
+
return x - y;
|
|
443
|
+
}
|
|
444
|
+
return String(x) < String(y) ? -1 : 1;
|
|
445
|
+
}
|
|
446
|
+
return a.length - b.length;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Drop any path whose ancestor is also in the list.
|
|
451
|
+
*
|
|
452
|
+
* Deleting a `<section>` and one of its own paragraphs is one deletion, not two: the paragraph goes
|
|
453
|
+
* with its parent, and a second splice into coordinates that no longer exist is how a batch
|
|
454
|
+
* corrupts a document.
|
|
455
|
+
*
|
|
456
|
+
* @param {readonly JxPath[]} paths
|
|
457
|
+
* @returns {JxPath[]}
|
|
458
|
+
*/
|
|
459
|
+
export function topLevelSelection(paths: readonly JxPath[]): JxPath[] {
|
|
460
|
+
const top: JxPath[] = [];
|
|
461
|
+
for (const path of paths) {
|
|
462
|
+
if (!paths.some((q) => !samePath(q, path) && isAncestor(q, path))) {
|
|
463
|
+
top.push([...path]);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return top;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function samePath(a: JxPath, b: JxPath): boolean {
|
|
470
|
+
return a.length === b.length && isAncestor(a, b);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Where a structural verb splices: the parent element's path and the index in its children. */
|
|
474
|
+
export interface SpliceCoordinate {
|
|
475
|
+
parentPath: JxPath;
|
|
476
|
+
index: number;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The splice coordinate `path` names, or `null` for no path and for every path
|
|
481
|
+
* {@link isSpliceablePath} refuses.
|
|
482
|
+
*
|
|
483
|
+
* @param {JxPath | null | undefined} path
|
|
484
|
+
* @returns {SpliceCoordinate | null}
|
|
485
|
+
*/
|
|
486
|
+
export function spliceCoordinate(path: JxPath | null | undefined): SpliceCoordinate | null {
|
|
487
|
+
const index = path?.at(-1);
|
|
488
|
+
return path && path.at(-2) === "children" && isNumber(index)
|
|
489
|
+
? { index, parentPath: path.slice(0, -2) }
|
|
490
|
+
: null;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Whether `path` names a position a structural edit can splice: a tail of `"children"` + index.
|
|
495
|
+
*
|
|
496
|
+
* Every structural edit reaches its parent by dropping the last two segments and its position by
|
|
497
|
+
* taking the last. That produces a splice coordinate for exactly one path shape, and a document
|
|
498
|
+
* holds three others a caller can name: the document element (`[]`), a repeater's map template
|
|
499
|
+
* (`[…, "children", "map"]`) and a `$switch` case (`[…, "cases", "warn"]`). For the template, the
|
|
500
|
+
* parent resolves to a children ARRAY, so the splice throws mid-edit; for a case it splices at
|
|
501
|
+
* `NaN` and takes the wrong child.
|
|
502
|
+
*
|
|
503
|
+
* @param {JxPath} path
|
|
504
|
+
* @returns {boolean}
|
|
505
|
+
*/
|
|
506
|
+
export function isSpliceablePath(path: JxPath): boolean {
|
|
507
|
+
return spliceCoordinate(path) !== null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The order a structural batch must run in: contained paths dropped, unspliceable paths dropped,
|
|
512
|
+
* then LAST node first.
|
|
513
|
+
*
|
|
514
|
+
* Descending document order is what makes a loop of splices safe. Removing or duplicating at index
|
|
515
|
+
* `n` renumbers every sibling after `n`, so a batch that starts at the end never touches a
|
|
516
|
+
* coordinate a later step still needs.
|
|
517
|
+
*
|
|
518
|
+
* Containment is decided BEFORE spliceability, on the paths as given: a repeater template still
|
|
519
|
+
* shadows its own descendants, so naming the template and something inside it yields nothing rather
|
|
520
|
+
* than quietly deleting the child nobody pointed at.
|
|
521
|
+
*
|
|
522
|
+
* @param {readonly JxPath[]} paths
|
|
523
|
+
* @returns {JxPath[]} Deepest/last first, every entry a splice coordinate.
|
|
524
|
+
*/
|
|
525
|
+
export function structuralBatch(paths: readonly JxPath[]): JxPath[] {
|
|
526
|
+
return topLevelSelection(paths)
|
|
527
|
+
.filter((path) => isSpliceablePath(path))
|
|
528
|
+
.toSorted((a, b) => comparePaths(b, a));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* An index written as JSON a model or a form produced, as the integer it names: an integer passes,
|
|
533
|
+
* a canonical decimal string (`"0"`, `"12"`, never `"01"`, `"1.0"` or `" 2"`) becomes its number,
|
|
534
|
+
* and anything else is `null`. Negative integers pass; whether one is meaningful is the caller's.
|
|
535
|
+
*
|
|
536
|
+
* @param {JsonValue | undefined} value - A tool argument or form field, as parsed JSON
|
|
537
|
+
* @returns {number | null}
|
|
538
|
+
*/
|
|
539
|
+
export function coerceIndex(value: JsonValue | undefined): number | null {
|
|
540
|
+
if (isNumber(value) && Number.isInteger(value)) {
|
|
541
|
+
return value;
|
|
542
|
+
}
|
|
543
|
+
return isString(value) && /^(?:0|[1-9]\d*)$/.test(value) ? Number(value) : null;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** A move's forward and inverse ops, both `move-child`. */
|
|
547
|
+
export interface PlannedMove {
|
|
548
|
+
forward: Extract<JxDocOp, { op: "move-child" }>;
|
|
549
|
+
inverse: Extract<JxDocOp, { op: "move-child" }>;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* The ops that move the node at `fromPath` to `toIndex` of `toParentPath`'s children, without
|
|
554
|
+
* moving anything; null when the move cannot happen (the source is no child in a static children
|
|
555
|
+
* array, or the target is no node).
|
|
556
|
+
*
|
|
557
|
+
* `toIndex` is read as the slot in the target's children BEFORE the move, the way a drop indicator
|
|
558
|
+
* names it, so a move later in the same parent lands one earlier once the source is gone. A slot
|
|
559
|
+
* past the end is clamped to the last one, so the forward names where the node really lands. Throws
|
|
560
|
+
* `doc-op-move-into-self` when the target is inside the moved node.
|
|
561
|
+
*
|
|
562
|
+
* @param {JxMutableNode} doc - The document the move applies to
|
|
563
|
+
* @param {JxPath} fromPath
|
|
564
|
+
* @param {JxPath} toParentPath
|
|
565
|
+
* @param {number} toIndex
|
|
566
|
+
* @returns {PlannedMove | null}
|
|
567
|
+
*/
|
|
568
|
+
export function planMove(
|
|
569
|
+
doc: JxMutableNode,
|
|
570
|
+
fromPath: JxPath,
|
|
571
|
+
toParentPath: JxPath,
|
|
572
|
+
toIndex: number,
|
|
573
|
+
): PlannedMove | null {
|
|
574
|
+
const at = spliceCoordinate(fromPath);
|
|
575
|
+
const fromParent = at && getNodeAtPath(doc, at.parentPath);
|
|
576
|
+
const toParent = getNodeAtPath(doc, toParentPath);
|
|
577
|
+
if (!at || !fromParent || !Array.isArray(fromParent.children) || !toParent) {
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
if (fromParent.children[at.index] === undefined) {
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
const sameParent = fromParent === toParent;
|
|
584
|
+
const adjusted = sameParent && at.index < toIndex ? toIndex - 1 : toIndex;
|
|
585
|
+
const targetLength = Array.isArray(toParent.children) ? toParent.children.length : 0;
|
|
586
|
+
const forward: PlannedMove["forward"] = {
|
|
587
|
+
fromIndex: at.index,
|
|
588
|
+
fromParentPath: at.parentPath,
|
|
589
|
+
op: "move-child",
|
|
590
|
+
toIndex: insertionSlot(adjusted, targetLength - (sameParent ? 1 : 0)),
|
|
591
|
+
toParentPath,
|
|
592
|
+
};
|
|
593
|
+
/* Computed against the document the move applies to. The inverse runs against the post-move
|
|
594
|
+
document, so `inverseOf` writes both of its parent paths in post-move coordinates: the removal
|
|
595
|
+
shifted paths under the source parent, the insertion paths under the target. */
|
|
596
|
+
const inverse = inverseOf(doc, forward);
|
|
597
|
+
/* SAFETY: `inverseOf` answers a `move-child` for a `move-child`. */
|
|
598
|
+
return { forward, inverse: inverse as PlannedMove["inverse"] };
|
|
599
|
+
}
|
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
* format-dispatch view reuses FormatRegistry (including its extension/capability conflict checks).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { FormatEntry, FormatRegistry } from "./format-registry";
|
|
14
|
+
import { FormatEntry, FormatRegistry, classDefFrom } from "./format-registry";
|
|
15
15
|
import type { FormatHostIO } from "./format-registry";
|
|
16
|
+
import { isJsonObject, isString } from "./guards";
|
|
17
|
+
import type { JsonObject, JsonValue } from "../types";
|
|
16
18
|
|
|
17
19
|
export type {
|
|
18
20
|
CapabilityInfo,
|
|
@@ -240,18 +242,23 @@ async function loadExtension(
|
|
|
240
242
|
}
|
|
241
243
|
}
|
|
242
244
|
|
|
243
|
-
let
|
|
245
|
+
let loaded: JsonValue;
|
|
244
246
|
try {
|
|
245
|
-
|
|
247
|
+
loaded = await io.loadJson(manifestPath);
|
|
246
248
|
} catch (error) {
|
|
247
249
|
throw new Error(
|
|
248
250
|
`Extension "${specifier}": cannot read manifest ${manifestPath}: ${errorMessage(error)}`,
|
|
249
251
|
{ cause: error },
|
|
250
252
|
);
|
|
251
253
|
}
|
|
252
|
-
if (!
|
|
254
|
+
if (!isJsonObject(loaded) || !isString(loaded.name)) {
|
|
253
255
|
throw new Error(`Extension "${specifier}": ${EXTENSION_MANIFEST} must declare a "name"`);
|
|
254
256
|
}
|
|
257
|
+
/* SAFETY: `name` was just checked to be a string. The other members are the shapes
|
|
258
|
+
extension-manifest.schema.json declares (string `title`/`description`, a `classes` map of
|
|
259
|
+
paths, a `schemas` record of paths), which every first-party extension's manifest test
|
|
260
|
+
validates its manifest against; this module resolves only the paths. */
|
|
261
|
+
const manifest: ExtensionManifest = loaded as JsonObject & ExtensionManifest;
|
|
255
262
|
if (!specifier.startsWith(".") && manifest.name !== specifier) {
|
|
256
263
|
throw new Error(
|
|
257
264
|
`Extension "${specifier}": manifest name "${manifest.name}" does not match the specifier`,
|
|
@@ -261,7 +268,7 @@ async function loadExtension(
|
|
|
261
268
|
const classes: FormatEntry[] = [];
|
|
262
269
|
for (const [name, ref] of Object.entries(manifest.classes ?? {})) {
|
|
263
270
|
const classPath = io.resolvePath(manifestPath, ref);
|
|
264
|
-
let classDef:
|
|
271
|
+
let classDef: JsonValue;
|
|
265
272
|
try {
|
|
266
273
|
classDef = await io.loadJson(classPath);
|
|
267
274
|
} catch (error) {
|
|
@@ -270,7 +277,7 @@ async function loadExtension(
|
|
|
270
277
|
{ cause: error },
|
|
271
278
|
);
|
|
272
279
|
}
|
|
273
|
-
classes.push(new FormatEntry(name, classPath, classDef, io));
|
|
280
|
+
classes.push(new FormatEntry(name, classPath, classDefFrom(classDef), io));
|
|
274
281
|
}
|
|
275
282
|
|
|
276
283
|
const schemas: ExtensionInfo["schemas"] = {};
|
|
@@ -287,6 +294,6 @@ async function loadExtension(
|
|
|
287
294
|
return { specifier, manifestPath, manifest, classes, schemas };
|
|
288
295
|
}
|
|
289
296
|
|
|
290
|
-
function errorMessage(
|
|
291
|
-
return
|
|
297
|
+
function errorMessage(cause: unknown): string {
|
|
298
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
292
299
|
}
|