@jxsuite/schema 2.3.0 → 4.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.
@@ -14,21 +14,23 @@
14
14
  * @license MIT
15
15
  */
16
16
 
17
- import type { JsonValue } from "../types";
17
+ import { isJsonObject, isNonNullObject, isNumber, isString } from "./guards";
18
+ import type { JsonObject, JsonValue } from "../types";
18
19
 
19
20
  /** One `<slot>` found in a component's tree. */
20
21
  export interface ComponentSlotDef {
21
22
  /** The slot's name; `""` for the unnamed slot. */
22
23
  name: string;
23
24
  /** Fallback children rendered when nothing is slotted in. */
24
- fallback?: unknown[];
25
+ fallback?: JsonValue[];
25
26
  }
26
27
 
27
28
  /** One editable prop, derived from a `state` entry. */
28
29
  export interface ComponentPropDef {
29
30
  name: string;
30
31
  type?: unknown;
31
- default?: JsonValue;
32
+ /** Absent for a full-form entry that declares no `default`. */
33
+ default?: JsonValue | undefined;
32
34
  format?: unknown;
33
35
  }
34
36
 
@@ -40,6 +42,12 @@ export interface ComponentMetaOut {
40
42
  props: ComponentPropDef[];
41
43
  slots?: ComponentSlotDef[];
42
44
  hasElements: boolean;
45
+ /**
46
+ * The custom-element tags its own tree renders, sorted; absent when there are none. A component
47
+ * written against the project's other components by bare `tagName` declares no `$elements`, so
48
+ * this is how a live renderer learns what one instance pulls in without fetching the definition.
49
+ */
50
+ uses?: string[];
43
51
  }
44
52
 
45
53
  /**
@@ -48,27 +56,29 @@ export interface ComponentMetaOut {
48
56
  * Whitespace-only names count as unnamed (`""`), and only static `children` arrays are walked — a
49
57
  * slot produced by a `$map` or a `$switch` has no fixed identity to report.
50
58
  *
51
- * @param {unknown} node - A node of the component document.
59
+ * @param {JsonValue | undefined} node - A node of the component document.
52
60
  * @param {ComponentSlotDef[]} [out] - Accumulator, for the recursive walk.
53
61
  * @returns {ComponentSlotDef[]}
54
62
  */
55
- export function collectSlotDefs(node: unknown, out: ComponentSlotDef[] = []): ComponentSlotDef[] {
56
- if (!node || typeof node !== "object" || Array.isArray(node)) {
63
+ export function collectSlotDefs(
64
+ node: JsonValue | undefined,
65
+ out: ComponentSlotDef[] = [],
66
+ ): ComponentSlotDef[] {
67
+ if (!isJsonObject(node)) {
57
68
  return out;
58
69
  }
59
- const el = node as Record<string, unknown>;
60
- if (el.tagName === "slot") {
61
- const attrs = el.attributes as Record<string, unknown> | undefined;
62
- const rawName = attrs?.name;
63
- const name = typeof rawName === "string" ? rawName.trim() : "";
64
- const { children } = el;
65
- out.push({
66
- name,
67
- ...(Array.isArray(children) && children.length > 0 ? { fallback: children } : {}),
68
- });
70
+ const { children } = node;
71
+ if (node.tagName === "slot") {
72
+ const attrs = node.attributes;
73
+ const rawName = isJsonObject(attrs) ? attrs.name : undefined;
74
+ const slot: ComponentSlotDef = { name: isString(rawName) ? rawName.trim() : "" };
75
+ if (Array.isArray(children) && children.length > 0) {
76
+ slot.fallback = children;
77
+ }
78
+ out.push(slot);
69
79
  }
70
- if (Array.isArray(el.children)) {
71
- for (const c of el.children) {
80
+ if (Array.isArray(children)) {
81
+ for (const c of children) {
72
82
  collectSlotDefs(c, out);
73
83
  }
74
84
  }
@@ -76,24 +86,76 @@ export function collectSlotDefs(node: unknown, out: ComponentSlotDef[] = []): Co
76
86
  }
77
87
 
78
88
  /**
79
- * Whether a `state` entry is an editable prop.
89
+ * Every custom-element tag (a `tagName` with a hyphen) in a document tree, its root excluded.
90
+ *
91
+ * Walks every place a node can sit — `children`, a repeater's `map`, a `$switch`'s `cases` — since
92
+ * an instance rendered through any of them still needs its definition registered.
93
+ *
94
+ * @param {JsonValue | undefined} node - The document, or a node of it.
95
+ * @param {Set<string>} [out] - Accumulator, for the recursive walk.
96
+ * @returns {Set<string>}
97
+ */
98
+ export function collectUsedTags(node: JsonValue | undefined, out = new Set<string>()): Set<string> {
99
+ if (!isJsonObject(node)) {
100
+ return out;
101
+ }
102
+ const visit = (child: JsonValue | undefined) => {
103
+ if (isJsonObject(child)) {
104
+ if (isString(child.tagName) && child.tagName.includes("-")) {
105
+ out.add(child.tagName);
106
+ }
107
+ collectUsedTags(child, out);
108
+ }
109
+ };
110
+ const { cases, children, map } = node;
111
+ const nodes = [...(Array.isArray(children) ? children : [children]), map];
112
+ if (isJsonObject(cases)) {
113
+ nodes.push(...Object.values(cases));
114
+ }
115
+ for (const child of nodes) {
116
+ visit(child);
117
+ }
118
+ return out;
119
+ }
120
+
121
+ /** What `typeof` names a shorthand `state` entry's value: the only type a shorthand has on offer. */
122
+ function shorthandTypeName(value: string | number | boolean): "string" | "number" | "boolean" {
123
+ if (isString(value)) {
124
+ return "string";
125
+ }
126
+ return isNumber(value) ? "number" : "boolean";
127
+ }
128
+
129
+ /**
130
+ * The editable prop a `state` entry offers, or null when it is not one.
80
131
  *
81
132
  * The shorthand form (`"title": "Hello"`) always is. The full form is one only when it is plain
82
133
  * data: a computed value, a handler or a prototype entry is machinery the author does not type
83
134
  * into, and offering it as a prop would put a text box in front of a function.
84
135
  *
85
- * @param {unknown} def - The `state` entry.
86
- * @returns {boolean}
136
+ * @param {string} name - The `state` key.
137
+ * @param {JsonValue} def - The `state` entry.
138
+ * @returns {ComponentPropDef | null}
87
139
  */
88
- function isPropEntry(def: unknown): boolean {
140
+ function propFrom(name: string, def: JsonValue): ComponentPropDef | null {
89
141
  if (def == null) {
90
- return false;
142
+ return null;
143
+ }
144
+ if (!isNonNullObject(def)) {
145
+ // Shorthand: the value IS the default, and its runtime type is the only type on offer.
146
+ return { default: def, name, type: shorthandTypeName(def) };
91
147
  }
92
- if (typeof def !== "object") {
93
- return true;
148
+ // An array entry declares nothing, so it reads as a full-form entry with every field absent.
149
+ const fields: JsonObject = isJsonObject(def) ? def : {};
150
+ if (fields.$prototype || fields.$handler || fields.$compute) {
151
+ return null;
94
152
  }
95
- const obj = def as Record<string, unknown>;
96
- return !obj.$prototype && !obj.$handler && !obj.$compute;
153
+ return {
154
+ default: fields.default,
155
+ format: fields.format,
156
+ name,
157
+ type: fields.type,
158
+ };
97
159
  }
98
160
 
99
161
  /**
@@ -103,41 +165,47 @@ function isPropEntry(def: unknown): boolean {
103
165
  * hyphen. That single test is what keeps a page or a data file from being reported as a component
104
166
  * when a whole project tree is scanned.
105
167
  *
106
- * @param {unknown} doc - The parsed document.
168
+ * @param {JsonValue | undefined} doc - The parsed document.
107
169
  * @param {string} path - Project-relative path, reported verbatim.
108
170
  * @returns {ComponentMetaOut | null}
109
171
  */
110
- export function componentMetaFrom(doc: unknown, path: string): ComponentMetaOut | null {
111
- if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
172
+ export function componentMetaFrom(
173
+ doc: JsonValue | undefined,
174
+ path: string,
175
+ ): ComponentMetaOut | null {
176
+ if (!isJsonObject(doc)) {
112
177
  return null;
113
178
  }
114
- const content = doc as Record<string, unknown>;
115
- const { tagName } = content;
116
- if (typeof tagName !== "string" || !tagName.includes("-")) {
179
+ const { tagName } = doc;
180
+ if (!isString(tagName) || !tagName.includes("-")) {
117
181
  return null;
118
182
  }
119
- const slots = collectSlotDefs(content);
120
- const state = (content.state ?? {}) as Record<string, unknown>;
121
- return {
122
- $id: typeof content.$id === "string" && content.$id ? content.$id : null,
123
- hasElements: Array.isArray(content.$elements) && content.$elements.length > 0,
183
+ const slots = collectSlotDefs(doc);
184
+ /*
185
+ * `state` is read the way `Object.entries` reads whatever is there: a malformed array or string
186
+ * yields its members, and a scalar or a missing entry yields nothing.
187
+ */
188
+ const declared = doc.state;
189
+ const state =
190
+ isJsonObject(declared) || Array.isArray(declared) || isString(declared)
191
+ ? Object.entries(declared)
192
+ : [];
193
+ // Everything before `tagName`, so `slots` lands where it always has in the serialized order.
194
+ const meta: Omit<ComponentMetaOut, "tagName"> = {
195
+ $id: isString(doc.$id) && doc.$id ? doc.$id : null,
196
+ hasElements: Array.isArray(doc.$elements) && doc.$elements.length > 0,
124
197
  path,
125
- props: Object.entries(state)
126
- .filter(([, def]) => isPropEntry(def))
127
- .map(([name, def]) => {
128
- if (typeof def !== "object" || def === null) {
129
- // Shorthand: the value IS the default, and its runtime type is the only type on offer.
130
- return { default: def as JsonValue, name, type: typeof def };
131
- }
132
- const obj = def as Record<string, unknown>;
133
- return {
134
- default: obj.default as JsonValue,
135
- format: obj.format,
136
- name,
137
- type: obj.type,
138
- };
139
- }),
140
- ...(slots.length > 0 ? { slots } : {}),
141
- tagName,
198
+ props: state.flatMap(([name, def]) => {
199
+ const prop = propFrom(name, def);
200
+ return prop === null ? [] : [prop];
201
+ }),
142
202
  };
203
+ if (slots.length > 0) {
204
+ meta.slots = slots;
205
+ }
206
+ const uses = [...collectUsedTags(doc)].filter((tag) => tag !== tagName).toSorted();
207
+ if (uses.length > 0) {
208
+ meta.uses = uses;
209
+ }
210
+ return { ...meta, tagName };
143
211
  }
package/src/dialogs.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  isPopover,
33
33
  labelOf,
34
34
  literalAttr,
35
+ OVERLAY_DOCS,
35
36
  popoverIdsIn,
36
37
  scalar,
37
38
  tagOf,
@@ -73,6 +74,12 @@ export const POPOVER_COMMANDS: ReadonlySet<string> = new Set([
73
74
  /** The values `closedby` takes. */
74
75
  export const DIALOG_CLOSEDBY = ["any", "closerequest", "none"] as const;
75
76
 
77
+ /** {@link INVOKER_COMMANDS} as a lookup an attribute's literal text can be tested against. */
78
+ const INVOKER_COMMAND_SET: ReadonlySet<string> = new Set(INVOKER_COMMANDS);
79
+
80
+ /** {@link DIALOG_CLOSEDBY} as a lookup an attribute's literal text can be tested against. */
81
+ const DIALOG_CLOSEDBY_SET: ReadonlySet<string> = new Set(DIALOG_CLOSEDBY);
82
+
76
83
  /**
77
84
  * Only a `<button>` carries `command` and `commandfor` (`HTMLButtonElement`, and no other
78
85
  * interface).
@@ -106,8 +113,6 @@ export interface DialogDefect {
106
113
  fix?: DialogFix;
107
114
  }
108
115
 
109
- const DOCS = "See docs/framework/concepts/overlays.";
110
-
111
116
  /** Whether the node is a `<dialog>`. */
112
117
  export function isDialog(node: JxElement): boolean {
113
118
  return tagOf(node) === "dialog";
@@ -142,7 +147,7 @@ export function documentHasDialog(doc: JxElement): boolean {
142
147
 
143
148
  /** Whether a node declares an `oncommand` handler, in any of the handler spellings. */
144
149
  function handlesCommand(node: JxElement): boolean {
145
- const handler = (node as Record<string, unknown>).oncommand;
150
+ const handler = node.oncommand;
146
151
  return handler !== undefined && handler !== null;
147
152
  }
148
153
 
@@ -240,7 +245,7 @@ function invokerDefects(
240
245
  detail:
241
246
  "`command` and `commandfor` are attributes of `HTMLButtonElement` and of no other " +
242
247
  "interface — not even `<input>`, which `popovertarget` does allow. Elsewhere they parse " +
243
- `and do nothing. Make it a <button>, or remove both attributes. ${DOCS}`,
248
+ `and do nothing. Make it a <button>, or remove both attributes. ${OVERLAY_DOCS}`,
244
249
  fix: "invoker",
245
250
  message: `<${tag || "element"}> carries command/commandfor, which only <button> supports.`,
246
251
  path,
@@ -250,16 +255,12 @@ function invokerDefects(
250
255
  return out;
251
256
  }
252
257
 
253
- if (
254
- command !== null &&
255
- !(INVOKER_COMMANDS as readonly string[]).includes(command) &&
256
- !isCustomCommand(command)
257
- ) {
258
+ if (command !== null && !INVOKER_COMMAND_SET.has(command) && !isCustomCommand(command)) {
258
259
  out.push({
259
260
  detail:
260
261
  `\`command\` is an enumerated attribute taking ${INVOKER_COMMANDS.map((c) => `\`${c}\``).join(", ")}, ` +
261
262
  "or a custom command starting with `--`. Anything else is the invalid-value state, and a " +
262
- `button in that state does nothing when clicked. ${DOCS}`,
263
+ `button in that state does nothing when clicked. ${OVERLAY_DOCS}`,
263
264
  message: `${label} declares command="${command}", which is not a built-in or --custom command.`,
264
265
  path,
265
266
  rule: "command-invalid",
@@ -277,7 +278,7 @@ function invokerDefects(
277
278
  out.push({
278
279
  detail:
279
280
  `No element in this document has the id "${target}". The dialogs and popovers here are: ${names}. ` +
280
- `A commandfor that names nothing does nothing, silently. ${DOCS}`,
281
+ `A commandfor that names nothing does nothing, silently. ${OVERLAY_DOCS}`,
281
282
  message: `commandfor="${target}" names nothing in this document.`,
282
283
  path,
283
284
  rule: "command-target-missing",
@@ -290,7 +291,7 @@ function invokerDefects(
290
291
  out.push({
291
292
  detail:
292
293
  `\`${command}\` acts on a <dialog>, and "${target}" is ${targetsPopover ? "a popover" : `a <${tagOf(known) || "element"}>`}. ` +
293
- `The click is ignored. ${targetsPopover ? "Use `toggle-popover`, `show-popover` or `hide-popover` for a popover." : "Point it at a dialog, or make the target one."} ${DOCS}`,
294
+ `The click is ignored. ${targetsPopover ? "Use `toggle-popover`, `show-popover` or `hide-popover` for a popover." : "Point it at a dialog, or make the target one."} ${OVERLAY_DOCS}`,
294
295
  message: `${label} sends ${command} to "${target}", which is not a dialog.`,
295
296
  path,
296
297
  rule: "command-target-mismatch",
@@ -300,7 +301,7 @@ function invokerDefects(
300
301
  out.push({
301
302
  detail:
302
303
  `\`${command}\` acts on a popover, and "${target}" is ${targetsDialog ? "a dialog" : `a <${tagOf(known) || "element"}>`} that declares no \`popover\`. ` +
303
- `The click is ignored. ${targetsDialog ? "Use `show-modal`, `close` or `request-close` for a dialog." : "Give the target a `popover` attribute, or point the button elsewhere."} ${DOCS}`,
304
+ `The click is ignored. ${targetsDialog ? "Use `show-modal`, `close` or `request-close` for a dialog." : "Give the target a `popover` attribute, or point the button elsewhere."} ${OVERLAY_DOCS}`,
304
305
  message: `${label} sends ${command} to "${target}", which is not a popover.`,
305
306
  path,
306
307
  rule: "command-target-mismatch",
@@ -311,7 +312,7 @@ function invokerDefects(
311
312
  detail:
312
313
  `A custom command is a \`CommandEvent\` dispatched at its target, and "${target}" has no ` +
313
314
  "`oncommand` handler to answer it — so the click does nothing. Add one that switches on " +
314
- `\`event#/command\`, or use a built-in command. ${DOCS}`,
315
+ `\`event#/command\`, or use a built-in command. ${OVERLAY_DOCS}`,
315
316
  message: `${label} sends ${command} to "${target}", which handles no command.`,
316
317
  path,
317
318
  rule: "custom-command-unhandled",
@@ -335,12 +336,12 @@ function dialogDefects(
335
336
  const { style } = node;
336
337
 
337
338
  const closedby = literalAttr(node, "closedby");
338
- if (closedby !== null && !(DIALOG_CLOSEDBY as readonly string[]).includes(closedby)) {
339
+ if (closedby !== null && !DIALOG_CLOSEDBY_SET.has(closedby)) {
339
340
  out.push({
340
341
  detail:
341
342
  "`closedby` is an enumerated attribute taking `any` (light dismiss and Escape), " +
342
343
  "`closerequest` (Escape only, the default for a modal) or `none`. Anything else is the " +
343
- `invalid-value default, \`auto\`, which is the same as leaving it out. ${DOCS}`,
344
+ `invalid-value default, \`auto\`, which is the same as leaving it out. ${OVERLAY_DOCS}`,
344
345
  message: `${label} declares closedby="${closedby}", which is not any, closerequest or none.`,
345
346
  path,
346
347
  rule: "dialog-closedby-invalid",
@@ -354,7 +355,7 @@ function dialogDefects(
354
355
  "A closed dialog is hidden by the browser's own `dialog:not([open]) { display: none }`. " +
355
356
  "That rule is UA-origin, so any author `display` beats it, and the dialog is laid out on " +
356
357
  "every page whether or not anyone opened it. `display` belongs in the `&[open]` rule and " +
357
- `nowhere else. ${DOCS}`,
358
+ `nowhere else. ${OVERLAY_DOCS}`,
358
359
  fix: "display",
359
360
  message: `${label} sets display in its base rule.`,
360
361
  path,
@@ -372,7 +373,7 @@ function dialogDefects(
372
373
  detail:
373
374
  'No <button> in this document opens it with `command="show-modal"`. If it is opened from another ' +
374
375
  "file or from a function that calls `showModal()`, this is fine; otherwise there is no " +
375
- `way for a reader to open it. ${DOCS}`,
376
+ `way for a reader to open it. ${OVERLAY_DOCS}`,
376
377
  message: `${label} has no invoker in this document.`,
377
378
  path,
378
379
  rule: "dialog-no-invoker",
@@ -386,7 +387,7 @@ function dialogDefects(
386
387
  "A modal dialog makes everything outside it inert, so the only ways out are the ones it " +
387
388
  'offers: `closedby="none"` turns off Escape and light dismiss, and nothing inside it ' +
388
389
  'closes it. A reader who opens it cannot leave. Add a <button command="close"> inside, or ' +
389
- `drop \`closedby="none"\`. ${DOCS}`,
390
+ `drop \`closedby="none"\`. ${OVERLAY_DOCS}`,
390
391
  message: `${label} opens modally, closes on nothing, and has no close button.`,
391
392
  path,
392
393
  rule: "modal-without-close",
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 } from "./guards";
19
+ import type { 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
  /**
@@ -155,10 +171,10 @@ function shiftThroughSplice(
155
171
  ): JxPath {
156
172
  const depth = parentPath.length;
157
173
  const isProperPrefix = depth < path.length && parentPath.every((seg, i) => seg === path[i]);
158
- if (!isProperPrefix || path[depth] !== "children" || typeof path[depth + 1] !== "number") {
174
+ const index = path[depth + 1];
175
+ if (!isProperPrefix || path[depth] !== "children" || !isNumber(index)) {
159
176
  return path;
160
177
  }
161
- const index = path[depth + 1] as number;
162
178
  if (inclusive ? index < spliceIndex : index <= spliceIndex) {
163
179
  return path;
164
180
  }
@@ -218,13 +234,15 @@ function insertionSlot(index: number, length: number): number {
218
234
  * @returns {JxDocOp}
219
235
  */
220
236
  export function inverseOf(doc: JxMutableNode, op: JxDocOp): JxDocOp {
237
+ // Read before the switch: an op from outside may carry a kind the union does not name.
238
+ const { op: kind } = op;
221
239
  switch (op.op) {
222
240
  case "set-key": {
223
241
  const node = getNodeAtPath(doc, op.path);
224
242
  if (!node) {
225
243
  throw new Error(`doc-op-node-not-found:${op.path.join("/")}`);
226
244
  }
227
- const before = cloneValue((node as Record<string, unknown>)[op.key]);
245
+ const before = cloneValue(node[op.key]);
228
246
  return before === undefined
229
247
  ? { key: op.key, op: "set-key", path: op.path }
230
248
  : { key: op.key, op: "set-key", path: op.path, value: before };
@@ -271,7 +289,7 @@ export function inverseOf(doc: JxMutableNode, op: JxDocOp): JxDocOp {
271
289
  break;
272
290
  }
273
291
  }
274
- throw new Error(`unknown-doc-op:${(op as JxDocOp).op}`);
292
+ throw new Error(`unknown-doc-op:${kind}`);
275
293
  }
276
294
 
277
295
  /** The node's existing children array, or null when it has none (or is not a node). */
@@ -292,10 +310,15 @@ function assertIndex(
292
310
  }
293
311
 
294
312
  /** 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 {
313
+ function existingChild(
314
+ doc: JxMutableNode,
315
+ parentPath: JxPath,
316
+ index: number,
317
+ ): JxMutableNode | string {
296
318
  const children = childrenAt(doc, parentPath);
297
319
  assertIndex(children, parentPath, index);
298
- return cloneValue(children[index]);
320
+ // `assertIndex` has just proven `index` is in range.
321
+ return cloneValue(children[index]!);
299
322
  }
300
323
 
301
324
  /**
@@ -304,8 +327,9 @@ function existingChild(doc: JxMutableNode, parentPath: JxPath, index: number): u
304
327
  * children array addressed as if it were a node, or a mapped-array `children`.
305
328
  */
306
329
  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)) {
330
+ const node = getNodeAtPath(doc, parentPath);
331
+ // A path that ends on a string child or a children array is refused here, not just a missing one.
332
+ if (!isNonNullObject(node) || Array.isArray(node)) {
309
333
  throw new Error(`doc-op-node-not-found:${parentPath.join("/")}`);
310
334
  }
311
335
  if (node.children === undefined) {
@@ -314,7 +338,7 @@ function insertableChildren(doc: JxMutableNode, parentPath: JxPath): (JxMutableN
314
338
  if (!Array.isArray(node.children)) {
315
339
  throw new TypeError(`doc-op-mapped-children:${parentPath.join("/")}`);
316
340
  }
317
- return node.children as (JxMutableNode | string)[];
341
+ return node.children;
318
342
  }
319
343
 
320
344
  /**
@@ -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
  }