@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/src/doc-ops.ts CHANGED
@@ -15,18 +15,19 @@
15
15
  * @module @jxsuite/schema/doc-ops
16
16
  */
17
17
 
18
- import type { JxMutableNode, JxPath } from "../types.ts";
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?: unknown }
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: unknown }
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: unknown }
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 : (jsonClone(v as object) as T);
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 segments and
57
- * non-node leaves surface as undefined (typed as node for caller ergonomics, matching Studio's
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 as unknown as JxMutableNode;
68
+ return undefined;
65
69
  }
66
- node = node[key] as JxMutableNode;
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 target[op.key];
110
+ delete node[op.key];
102
111
  } else {
103
- target[op.key] = cloneValue(op.value);
112
+ node[op.key] = cloneValue(op.value);
104
113
  }
105
114
  return;
106
115
  }
107
116
  case "insert-child": {
108
- const parent = getNodeAtPath(doc, op.parentPath);
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
- const parent = getNodeAtPath(doc, op.parentPath);
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
- const parent = getNodeAtPath(doc, op.parentPath);
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 = childArray(fromParent);
130
- const to = childArray(toParent);
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:${(op as JxDocOp).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 shiftThroughSplice(
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
- if (!isProperPrefix || path[depth] !== "children" || typeof path[depth + 1] !== "number") {
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 (!node) {
250
+ if (node === undefined) {
225
251
  throw new Error(`doc-op-node-not-found:${op.path.join("/")}`);
226
252
  }
227
- const before = cloneValue((node as Record<string, unknown>)[op.key]);
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: shiftThroughSplice(
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: shiftThroughSplice(op.toParentPath, op.fromParentPath, toIndex, 1, true),
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:${(op as JxDocOp).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(doc: JxMutableNode, parentPath: JxPath, index: number): unknown {
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
- return cloneValue(children[index]);
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) as unknown as { children?: unknown } | null;
308
- if (!node || typeof node !== "object" || Array.isArray(node)) {
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 as (JxMutableNode | string)[];
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 manifest: ExtensionManifest;
245
+ let loaded: JsonValue;
244
246
  try {
245
- manifest = (await io.loadJson(manifestPath)) as unknown as ExtensionManifest;
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 (!manifest || typeof manifest !== "object" || typeof manifest.name !== "string") {
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: Record<string, unknown>;
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(error: unknown): string {
291
- return error instanceof Error ? error.message : String(error);
297
+ function errorMessage(cause: unknown): string {
298
+ return cause instanceof Error ? cause.message : String(cause);
292
299
  }