@jxsuite/schema 3.0.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.
- package/package.json +4 -4
- package/schema.json +52 -26
- package/src/a11y.ts +91 -95
- package/src/asset-paths.ts +23 -1
- package/src/component-meta.ts +123 -55
- package/src/dialogs.ts +20 -19
- package/src/doc-ops.ts +59 -35
- package/src/extension-registry.ts +15 -8
- package/src/format-registry.ts +504 -31
- package/src/guards.ts +89 -14
- package/src/ijson.ts +8 -8
- package/src/json-layout.ts +64 -25
- package/src/locale.ts +23 -18
- package/src/media-type.ts +26 -12
- package/src/overlays.ts +50 -45
- package/src/parse.ts +42 -24
- package/src/project-schemas.ts +94 -66
- package/src/schema.ts +50 -38
- package/src/validate-project.ts +34 -11
- package/types.ts +116 -47
package/src/component-meta.ts
CHANGED
|
@@ -14,21 +14,23 @@
|
|
|
14
14
|
* @license MIT
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import
|
|
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?:
|
|
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
|
|
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 {
|
|
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(
|
|
56
|
-
|
|
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
|
|
60
|
-
if (
|
|
61
|
-
const attrs =
|
|
62
|
-
const rawName = attrs
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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(
|
|
71
|
-
for (const c of
|
|
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
|
-
*
|
|
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 {
|
|
86
|
-
* @
|
|
136
|
+
* @param {string} name - The `state` key.
|
|
137
|
+
* @param {JsonValue} def - The `state` entry.
|
|
138
|
+
* @returns {ComponentPropDef | null}
|
|
87
139
|
*/
|
|
88
|
-
function
|
|
140
|
+
function propFrom(name: string, def: JsonValue): ComponentPropDef | null {
|
|
89
141
|
if (def == null) {
|
|
90
|
-
return
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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 {
|
|
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(
|
|
111
|
-
|
|
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
|
|
115
|
-
|
|
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(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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:
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 =
|
|
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. ${
|
|
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. ${
|
|
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. ${
|
|
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."} ${
|
|
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."} ${
|
|
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. ${
|
|
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 && !
|
|
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. ${
|
|
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. ${
|
|
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. ${
|
|
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"\`. ${
|
|
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
|
|
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?:
|
|
24
|
+
| { op: "set-key"; path: JxPath; key: string; value?: JxDocumentValue | undefined }
|
|
24
25
|
/** Insert a node at parentPath.children[index]. */
|
|
25
|
-
| { op: "insert-child"; parentPath: JxPath; index: number; node:
|
|
26
|
+
| { op: "insert-child"; parentPath: JxPath; index: number; node: JxMutableNode | string }
|
|
26
27
|
/** Remove parentPath.children[index]. */
|
|
27
28
|
| { op: "remove-child"; parentPath: JxPath; index: number }
|
|
28
29
|
/** Replace parentPath.children[index] with node. */
|
|
29
|
-
| { op: "set-child"; parentPath: JxPath; index: number; node:
|
|
30
|
+
| { op: "set-child"; parentPath: JxPath; index: number; node: JxMutableNode | string }
|
|
30
31
|
/** Raw two-splice move: remove fromParent.children[fromIndex], insert at toParent[toIndex]. */
|
|
31
32
|
| {
|
|
32
33
|
op: "move-child";
|
|
@@ -43,27 +44,34 @@ export interface JxDocOpPair {
|
|
|
43
44
|
|
|
44
45
|
/** JSON round-trip clone — also normalizes away reactive proxies / functions / undefined. */
|
|
45
46
|
function jsonClone<T>(value: T): T {
|
|
47
|
+
/* SAFETY: every value cloned here is document data (a node, a children member, or a key's value),
|
|
48
|
+
which is JSON by contract; the round trip reproduces that shape and drops only functions and
|
|
49
|
+
`undefined` members, which the document types already treat as absent. */
|
|
46
50
|
// oxlint-disable-next-line unicorn/prefer-structured-clone -- structuredClone throws on reactive proxies; JSON normalization is the point
|
|
47
51
|
return JSON.parse(JSON.stringify(value)) as T;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
/** Deep-clone a recorded value (undefined/null pass through; reactive proxies are read through). */
|
|
51
55
|
export function cloneValue<T>(v: T): T {
|
|
52
|
-
return v === undefined || v === null ? v :
|
|
56
|
+
return v === undefined || v === null ? v : jsonClone(v);
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
/**
|
|
56
|
-
* Walk a plain document tree by path. Paths address nodes by construction; missing
|
|
57
|
-
*
|
|
58
|
-
* historical helper).
|
|
60
|
+
* Walk a plain document tree by path. Paths address nodes by construction; a missing segment
|
|
61
|
+
* surfaces as undefined, so every caller checks the answer before treating it as a node.
|
|
59
62
|
*/
|
|
60
|
-
export function getNodeAtPath(doc: JxMutableNode, path: JxPath): JxMutableNode {
|
|
61
|
-
let node = doc;
|
|
63
|
+
export function getNodeAtPath(doc: JxMutableNode, path: JxPath): JxMutableNode | undefined {
|
|
64
|
+
let node: JxMutableNode | undefined = doc;
|
|
62
65
|
for (const key of path) {
|
|
66
|
+
// `== null` also stops at a JSON `null` partway down the path.
|
|
63
67
|
if (node == null) {
|
|
64
|
-
return undefined
|
|
68
|
+
return undefined;
|
|
65
69
|
}
|
|
66
|
-
node
|
|
70
|
+
/* SAFETY: a `JxPath` alternates node keys with a "children" key and an index, so each step
|
|
71
|
+
lands on a node, on a children array (whose only use is the next, numeric, lookup, which an
|
|
72
|
+
array answers as a node would), or on nothing. A path that wrongly ends on a string child or a
|
|
73
|
+
children array reaches a caller as one; `childArray` and `insertableChildren` refuse it. */
|
|
74
|
+
node = node[key] as JxMutableNode | undefined;
|
|
67
75
|
}
|
|
68
76
|
return node;
|
|
69
77
|
}
|
|
@@ -90,44 +98,40 @@ export function childArray(node: JxMutableNode): (JxMutableNode | string)[] {
|
|
|
90
98
|
* aliases the op object. Throws (with a machine-readable reason) when a target path is missing.
|
|
91
99
|
*/
|
|
92
100
|
export function applyDocOpToDoc(doc: JxMutableNode, op: JxDocOp): void {
|
|
101
|
+
// Read before the switch: an op from outside may carry a kind the union does not name.
|
|
102
|
+
const { op: kind } = op;
|
|
93
103
|
switch (op.op) {
|
|
94
104
|
case "set-key": {
|
|
95
105
|
const node = getNodeAtPath(doc, op.path);
|
|
96
106
|
if (!node) {
|
|
97
107
|
throw new Error(`doc-op-node-not-found:${op.path.join("/")}`);
|
|
98
108
|
}
|
|
99
|
-
const target = node as Record<string, unknown>;
|
|
100
109
|
if (op.value === undefined) {
|
|
101
|
-
delete
|
|
110
|
+
delete node[op.key];
|
|
102
111
|
} else {
|
|
103
|
-
|
|
112
|
+
node[op.key] = cloneValue(op.value);
|
|
104
113
|
}
|
|
105
114
|
return;
|
|
106
115
|
}
|
|
107
116
|
case "insert-child": {
|
|
108
|
-
|
|
109
|
-
childArray(parent).splice(op.index, 0, cloneValue(op.node) as JxMutableNode);
|
|
117
|
+
childArrayAt(doc, op.parentPath).splice(op.index, 0, cloneValue(op.node));
|
|
110
118
|
return;
|
|
111
119
|
}
|
|
112
120
|
case "remove-child": {
|
|
113
|
-
|
|
114
|
-
childArray(parent).splice(op.index, 1);
|
|
121
|
+
childArrayAt(doc, op.parentPath).splice(op.index, 1);
|
|
115
122
|
return;
|
|
116
123
|
}
|
|
117
124
|
case "set-child": {
|
|
118
|
-
|
|
119
|
-
childArray(parent).splice(op.index, 1, cloneValue(op.node) as JxMutableNode);
|
|
125
|
+
childArrayAt(doc, op.parentPath).splice(op.index, 1, cloneValue(op.node));
|
|
120
126
|
return;
|
|
121
127
|
}
|
|
122
128
|
case "move-child": {
|
|
123
129
|
assertNotIntoOwnSubtree(op);
|
|
124
|
-
const fromParent = getNodeAtPath(doc, op.fromParentPath);
|
|
125
|
-
const toParent = getNodeAtPath(doc, op.toParentPath);
|
|
126
130
|
/* Both arrays are resolved BEFORE the source is spliced, so a move whose target cannot receive
|
|
127
131
|
children throws with the document untouched rather than having detached the node it could
|
|
128
132
|
not place. */
|
|
129
|
-
const from =
|
|
130
|
-
const to =
|
|
133
|
+
const from = childArrayAt(doc, op.fromParentPath);
|
|
134
|
+
const to = childArrayAt(doc, op.toParentPath);
|
|
131
135
|
const [node] = from.splice(op.fromIndex, 1);
|
|
132
136
|
to.splice(op.toIndex, 0, node!);
|
|
133
137
|
return;
|
|
@@ -136,7 +140,19 @@ export function applyDocOpToDoc(doc: JxMutableNode, op: JxDocOp): void {
|
|
|
136
140
|
break;
|
|
137
141
|
}
|
|
138
142
|
}
|
|
139
|
-
throw new Error(`unknown-doc-op:${
|
|
143
|
+
throw new Error(`unknown-doc-op:${kind}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The children array of the node at `parentPath`, lazily created (see {@link childArray}); throws a
|
|
148
|
+
* `TypeError` naming the path when no node is there.
|
|
149
|
+
*/
|
|
150
|
+
function childArrayAt(doc: JxMutableNode, parentPath: JxPath): (JxMutableNode | string)[] {
|
|
151
|
+
const parent = getNodeAtPath(doc, parentPath);
|
|
152
|
+
if (parent == null) {
|
|
153
|
+
throw new TypeError(`doc-op-node-not-found:${parentPath.join("/")}`);
|
|
154
|
+
}
|
|
155
|
+
return childArray(parent);
|
|
140
156
|
}
|
|
141
157
|
|
|
142
158
|
/**
|
|
@@ -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
|
-
|
|
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(
|
|
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:${
|
|
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(
|
|
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
|
-
|
|
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)
|
|
308
|
-
|
|
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
|
|
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
|
|
245
|
+
let loaded: JsonValue;
|
|
244
246
|
try {
|
|
245
|
-
|
|
247
|
+
loaded = await io.loadJson(manifestPath);
|
|
246
248
|
} catch (error) {
|
|
247
249
|
throw new Error(
|
|
248
250
|
`Extension "${specifier}": cannot read manifest ${manifestPath}: ${errorMessage(error)}`,
|
|
249
251
|
{ cause: error },
|
|
250
252
|
);
|
|
251
253
|
}
|
|
252
|
-
if (!
|
|
254
|
+
if (!isJsonObject(loaded) || !isString(loaded.name)) {
|
|
253
255
|
throw new Error(`Extension "${specifier}": ${EXTENSION_MANIFEST} must declare a "name"`);
|
|
254
256
|
}
|
|
257
|
+
/* SAFETY: `name` was just checked to be a string. The other members are the shapes
|
|
258
|
+
extension-manifest.schema.json declares (string `title`/`description`, a `classes` map of
|
|
259
|
+
paths, a `schemas` record of paths), which every first-party extension's manifest test
|
|
260
|
+
validates its manifest against; this module resolves only the paths. */
|
|
261
|
+
const manifest: ExtensionManifest = loaded as JsonObject & ExtensionManifest;
|
|
255
262
|
if (!specifier.startsWith(".") && manifest.name !== specifier) {
|
|
256
263
|
throw new Error(
|
|
257
264
|
`Extension "${specifier}": manifest name "${manifest.name}" does not match the specifier`,
|
|
@@ -261,7 +268,7 @@ async function loadExtension(
|
|
|
261
268
|
const classes: FormatEntry[] = [];
|
|
262
269
|
for (const [name, ref] of Object.entries(manifest.classes ?? {})) {
|
|
263
270
|
const classPath = io.resolvePath(manifestPath, ref);
|
|
264
|
-
let classDef:
|
|
271
|
+
let classDef: JsonValue;
|
|
265
272
|
try {
|
|
266
273
|
classDef = await io.loadJson(classPath);
|
|
267
274
|
} catch (error) {
|
|
@@ -270,7 +277,7 @@ async function loadExtension(
|
|
|
270
277
|
{ cause: error },
|
|
271
278
|
);
|
|
272
279
|
}
|
|
273
|
-
classes.push(new FormatEntry(name, classPath, classDef, io));
|
|
280
|
+
classes.push(new FormatEntry(name, classPath, classDefFrom(classDef), io));
|
|
274
281
|
}
|
|
275
282
|
|
|
276
283
|
const schemas: ExtensionInfo["schemas"] = {};
|
|
@@ -287,6 +294,6 @@ async function loadExtension(
|
|
|
287
294
|
return { specifier, manifestPath, manifest, classes, schemas };
|
|
288
295
|
}
|
|
289
296
|
|
|
290
|
-
function errorMessage(
|
|
291
|
-
return
|
|
297
|
+
function errorMessage(cause: unknown): string {
|
|
298
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
292
299
|
}
|