@jxsuite/schema 4.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.
|
@@ -15,7 +15,7 @@ export const arrayNamespaceSchema = {
|
|
|
15
15
|
"removals; without a key, rows are keyed by index. $map/index is not a key — an index " +
|
|
16
16
|
"names a position, not a row.",
|
|
17
17
|
properties: {
|
|
18
|
-
$ref: { pattern:
|
|
18
|
+
$ref: { pattern: String.raw`^\$map/item(/.+)?$`, type: "string" },
|
|
19
19
|
},
|
|
20
20
|
required: ["$ref"],
|
|
21
21
|
type: "object",
|
|
@@ -60,7 +60,7 @@ export const childrenValueSchema = {
|
|
|
60
60
|
"Computed children — a ${…} template that resolves at build time to an array of child " +
|
|
61
61
|
'definitions (e.g. "${state.entry.$children}" for parsed content). A bare plain-text ' +
|
|
62
62
|
"string is NOT a valid children value; text children must be array items.",
|
|
63
|
-
pattern:
|
|
63
|
+
pattern: String.raw`\$\{`,
|
|
64
64
|
type: "string",
|
|
65
65
|
},
|
|
66
66
|
],
|
package/defs/class-def.schema.ts
CHANGED
|
@@ -167,7 +167,7 @@ export const formatDefSchema = {
|
|
|
167
167
|
},
|
|
168
168
|
extensions: {
|
|
169
169
|
description: 'File extensions this format claims, with leading dot (e.g. [".md"]).',
|
|
170
|
-
items: { pattern:
|
|
170
|
+
items: { pattern: String.raw`^\.`, type: "string" },
|
|
171
171
|
minItems: 1,
|
|
172
172
|
type: "array",
|
|
173
173
|
},
|
|
@@ -6,7 +6,7 @@ export const expressionPointerSchema = {
|
|
|
6
6
|
"A JSON Pointer $ref operand within an expression node. $args/<name> resolves a named formula's parameter (callable entries only).",
|
|
7
7
|
properties: {
|
|
8
8
|
$ref: {
|
|
9
|
-
pattern:
|
|
9
|
+
pattern: String.raw`^(\$map/|\$reduce/|\$args/|event#/|#/|parent#/|window#/|document#/)`,
|
|
10
10
|
type: "string",
|
|
11
11
|
},
|
|
12
12
|
},
|
|
@@ -16,7 +16,7 @@ export const expressionPointerSchema = {
|
|
|
16
16
|
|
|
17
17
|
export const expressionLiteralSchema = {
|
|
18
18
|
anyOf: [
|
|
19
|
-
{ not: { pattern:
|
|
19
|
+
{ not: { pattern: String.raw`\$\{` }, type: "string" },
|
|
20
20
|
{ type: "number" },
|
|
21
21
|
{ type: "boolean" },
|
|
22
22
|
{ type: "null" },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const internalRefSchema = {
|
|
2
2
|
description: "Reference to a $defs type definition in the current component.",
|
|
3
3
|
examples: ["#/$defs/Count", "#/$defs/TodoItem", "#/$defs/Status"],
|
|
4
|
-
pattern:
|
|
4
|
+
pattern: String.raw`^#/\$defs/`,
|
|
5
5
|
type: "string",
|
|
6
6
|
} as const;
|
|
7
7
|
|
|
@@ -15,7 +15,7 @@ export const stateRefSchema = {
|
|
|
15
15
|
export const externalRefSchema = {
|
|
16
16
|
description: "Reference to an external Jx component file.",
|
|
17
17
|
examples: ["./card.json", "https://cdn.example.com/button.json"],
|
|
18
|
-
pattern:
|
|
18
|
+
pattern: String.raw`^(\./|\.\./).*\.json$|^https?://`,
|
|
19
19
|
type: "string",
|
|
20
20
|
} as const;
|
|
21
21
|
|
|
@@ -36,7 +36,7 @@ export const parentRefSchema = {
|
|
|
36
36
|
export const mapRefSchema = {
|
|
37
37
|
description: "Reference to the current Array map iteration context.",
|
|
38
38
|
examples: ["$map/item", "$map/index", "$map/item/text", "$map/item/done"],
|
|
39
|
-
pattern:
|
|
39
|
+
pattern: String.raw`^\$map/(item|index)(/.*)?$`,
|
|
40
40
|
type: "string",
|
|
41
41
|
} as const;
|
|
42
42
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"dependencies": {
|
|
3
|
-
"@webref/css": "^8.7.
|
|
3
|
+
"@webref/css": "^8.7.8",
|
|
4
4
|
"@webref/elements": "^2.9.2",
|
|
5
|
-
"@webref/idl": "^3.
|
|
5
|
+
"@webref/idl": "^3.86.0",
|
|
6
6
|
"ajv": "^8.20.0",
|
|
7
7
|
"ajv-formats": "^3.0.1"
|
|
8
8
|
},
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"upgrade": "bun update --latest && bun install"
|
|
61
61
|
},
|
|
62
62
|
"type": "module",
|
|
63
|
-
"version": "4.
|
|
63
|
+
"version": "4.1.0",
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"json-schema-to-ts": "^3.1.1"
|
|
66
66
|
}
|
package/src/doc-ops.ts
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* @module @jxsuite/schema/doc-ops
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { isNonNullObject, isNumber } from "./guards";
|
|
19
|
-
import type { JxDocumentValue, JxMutableNode, JxPath } from "../types.ts";
|
|
18
|
+
import { isNonNullObject, isNumber, isString } from "./guards";
|
|
19
|
+
import type { JsonValue, JxDocumentValue, JxMutableNode, JxPath } from "../types.ts";
|
|
20
20
|
|
|
21
21
|
/** Value-carrying document mutation, replayable in either direction. */
|
|
22
22
|
export type JxDocOp =
|
|
@@ -161,8 +161,15 @@ function childArrayAt(doc: JxMutableNode, parentPath: JxPath): (JxMutableNode |
|
|
|
161
161
|
* insertion (delta +1, inclusive) shifts them up. Returns `path` unchanged when the splice does not
|
|
162
162
|
* affect it — including when `path` IS `parentPath`, since a splice moves a node's children, never
|
|
163
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}
|
|
164
171
|
*/
|
|
165
|
-
function
|
|
172
|
+
export function shiftPrefixedIndex(
|
|
166
173
|
parentPath: JxPath,
|
|
167
174
|
path: JxPath,
|
|
168
175
|
spliceIndex: number,
|
|
@@ -201,7 +208,7 @@ function assertNotIntoOwnSubtree(op: Extract<JxDocOp, { op: "move-child" }>): vo
|
|
|
201
208
|
* counts from the end and an index past the end appends. The applier inserts with `splice`, so an
|
|
202
209
|
* inverse must name the slot the node really landed in, not the one the op asked for.
|
|
203
210
|
*/
|
|
204
|
-
function insertionSlot(index: number, length: number): number {
|
|
211
|
+
export function insertionSlot(index: number, length: number): number {
|
|
205
212
|
return index < 0 ? Math.max(length + index, 0) : Math.min(index, length);
|
|
206
213
|
}
|
|
207
214
|
|
|
@@ -236,12 +243,18 @@ function insertionSlot(index: number, length: number): number {
|
|
|
236
243
|
export function inverseOf(doc: JxMutableNode, op: JxDocOp): JxDocOp {
|
|
237
244
|
// Read before the switch: an op from outside may carry a kind the union does not name.
|
|
238
245
|
const { op: kind } = op;
|
|
246
|
+
assertIntegerIndexes(op);
|
|
239
247
|
switch (op.op) {
|
|
240
248
|
case "set-key": {
|
|
241
249
|
const node = getNodeAtPath(doc, op.path);
|
|
242
|
-
if (
|
|
250
|
+
if (node === undefined) {
|
|
243
251
|
throw new Error(`doc-op-node-not-found:${op.path.join("/")}`);
|
|
244
252
|
}
|
|
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
|
+
}
|
|
245
258
|
const before = cloneValue(node[op.key]);
|
|
246
259
|
return before === undefined
|
|
247
260
|
? { key: op.key, op: "set-key", path: op.path }
|
|
@@ -273,7 +286,7 @@ export function inverseOf(doc: JxMutableNode, op: JxDocOp): JxDocOp {
|
|
|
273
286
|
return {
|
|
274
287
|
fromIndex: toIndex,
|
|
275
288
|
// The removal shifted paths under the source parent; the insertion, under the target.
|
|
276
|
-
fromParentPath:
|
|
289
|
+
fromParentPath: shiftPrefixedIndex(
|
|
277
290
|
op.fromParentPath,
|
|
278
291
|
op.toParentPath,
|
|
279
292
|
op.fromIndex,
|
|
@@ -282,7 +295,7 @@ export function inverseOf(doc: JxMutableNode, op: JxDocOp): JxDocOp {
|
|
|
282
295
|
),
|
|
283
296
|
op: "move-child",
|
|
284
297
|
toIndex: op.fromIndex,
|
|
285
|
-
toParentPath:
|
|
298
|
+
toParentPath: shiftPrefixedIndex(op.toParentPath, op.fromParentPath, toIndex, 1, true),
|
|
286
299
|
};
|
|
287
300
|
}
|
|
288
301
|
default: {
|
|
@@ -292,6 +305,26 @@ export function inverseOf(doc: JxMutableNode, op: JxDocOp): JxDocOp {
|
|
|
292
305
|
throw new Error(`unknown-doc-op:${kind}`);
|
|
293
306
|
}
|
|
294
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
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
295
328
|
/** The node's existing children array, or null when it has none (or is not a node). */
|
|
296
329
|
function childrenAt(doc: JxMutableNode, parentPath: JxPath): (JxMutableNode | string)[] | null {
|
|
297
330
|
const parent = getNodeAtPath(doc, parentPath);
|
|
@@ -370,3 +403,197 @@ export function applyDocOpsWithInverse(doc: JxMutableNode, ops: readonly JxDocOp
|
|
|
370
403
|
}
|
|
371
404
|
return pairs;
|
|
372
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
|
+
}
|