@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.
@@ -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",