@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: "^\\$map/item(/.+)?$", type: "string" },
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
  ],
@@ -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: "^\\.", type: "string" },
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: "^(\\$map/|\\$reduce/|\\$args/|event#/|#/|parent#/|window#/|document#/)",
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: "\\$\\{" }, type: "string" },
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: "^#/\\$defs/",
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: "^(\\./|\\.\\./).*\\.json$|^https?://",
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: "^\\$map/(item|index)(/.*)?$",
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.7",
3
+ "@webref/css": "^8.7.8",
4
4
  "@webref/elements": "^2.9.2",
5
- "@webref/idl": "^3.85.2",
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.0.0",
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 shiftThroughSplice(
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 (!node) {
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: shiftThroughSplice(
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: shiftThroughSplice(op.toParentPath, op.fromParentPath, toIndex, 1, true),
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
+ }