@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/defs/children-value.schema.ts +2 -2
- package/defs/class-def.schema.ts +1 -1
- package/defs/expression-node.schema.ts +2 -2
- package/defs/ref-object.schema.ts +3 -3
- 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 +291 -40
- 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",
|