@jxsuite/schema 5.6.0 → 6.0.2

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.
@@ -19,19 +19,27 @@
19
19
  *
20
20
  * @docs framework/concepts/functions
21
21
  */
22
- import { classifyFunctionEntries } from "../function-role";
22
+ import { isJsonObject } from "../guards";
23
23
  import {
24
- isBoolean,
25
- isExpressionDef,
26
- isFunctionDef,
27
- isJsonObject,
28
- isNumber,
29
- isServerFnDef,
30
- isString,
31
- paramNames,
32
- hasSchemaKeywords,
33
- } from "../guards";
34
- import type { JsonValue, JxElement } from "../../types";
24
+ boundEvents,
25
+ eventLines,
26
+ eventType,
27
+ itemType,
28
+ EXCEPT,
29
+ heritageOf,
30
+ membersOf,
31
+ readsRow,
32
+ rootSite,
33
+ siteInto,
34
+ } from "./document-scopes";
35
+ import type { Site, StateScope } from "./document-scopes";
36
+ import { definitionParts, definitionsOf, pointerInto, propertyName, written } from "./schema-types";
37
+ import { declaredParameters, stateIndex, typeOfEntry } from "./state-types";
38
+ import type { OutsideState } from "./state-types";
39
+ import type { JsonObject, JsonValue, JxElement } from "../../types";
40
+
41
+ export { givenByProject, outsideAt } from "./state-types";
42
+ export type { OutsideState, ProjectGives } from "./state-types";
35
43
 
36
44
  /** Where a body sits in a document. */
37
45
  export type VirtualTarget =
@@ -66,164 +74,73 @@ export interface VirtualBody {
66
74
  */
67
75
  export const UNRESOLVED_MODULE_CODES: ReadonlySet<number> = new Set([2307, 2792, 7016]);
68
76
 
69
- /** The most levels of an object's shape written out before it is `any`. */
70
- const MAX_NESTING = 4;
71
-
72
- /** The most distinct item types an array's type is built from. */
73
- const MAX_ITEM_TYPES = 6;
74
-
75
- /** Whether a key can be written as a bare property name. */
76
- const IDENTIFIER = /^[A-Za-z_$][\w$]*$/u;
77
-
78
- /** A property name as TypeScript writes it. */
79
- function propertyName(key: string): string {
80
- return IDENTIFIER.test(key) ? key : JSON.stringify(key);
81
- }
82
-
83
- /** The type a value's shape gives it, widened the way a mutable binding widens. */
84
- function typeOfValue(value: JsonValue | undefined, depth: number): string {
85
- if (isString(value)) {
86
- return "string";
87
- }
88
- if (isNumber(value)) {
89
- return "number";
90
- }
91
- if (isBoolean(value)) {
92
- return "boolean";
93
- }
94
- if (Array.isArray(value)) {
95
- const types = [...new Set(value.map((item) => typeOfValue(item, depth + 1)))];
96
- if (types.length === 0 || types.length > MAX_ITEM_TYPES || types.includes("any")) {
97
- return "any[]";
98
- }
99
- return types.length === 1 ? `${types[0]}[]` : `(${types.join(" | ")})[]`;
100
- }
101
- if (isJsonObject(value) && depth < MAX_NESTING) {
102
- const members = Object.entries(value).map(
103
- ([key, item]) => `${propertyName(key)}: ${typeOfValue(item, depth + 1)}`,
104
- );
105
- return members.length === 0 ? "Record<string, any>" : `{ ${members.join("; ")} }`;
106
- }
107
- return "any";
108
- }
109
-
110
- /** The type a JSON Schema `type` keyword names. */
111
- function typeOfKeyword(type: JsonValue | undefined): string {
112
- switch (type) {
113
- case "string": {
114
- return "string";
115
- }
116
- case "number":
117
- case "integer": {
118
- return "number";
119
- }
120
- case "boolean": {
121
- return "boolean";
122
- }
123
- case "array": {
124
- return "any[]";
125
- }
126
- default: {
127
- return "any";
128
- }
129
- }
130
- }
131
-
132
- /** The type of one `state` entry, by the shapes of `spec.md` §5. */
133
- function typeOfEntry(
134
- entry: JsonValue | undefined,
135
- role: "computed" | "callable" | undefined,
136
- ): string {
137
- if (isString(entry)) {
138
- // A template is a computed value of a type the document does not give.
139
- return entry.includes("${") ? "any" : "string";
140
- }
141
- if (!isJsonObject(entry)) {
142
- return typeOfValue(entry, 0);
143
- }
144
- if (isFunctionDef(entry)) {
145
- return role === "computed" ? "any" : "(...args: any[]) => any";
146
- }
147
- if (isString(entry.$prototype) || isExpressionDef(entry) || isServerFnDef(entry)) {
148
- return "any";
149
- }
150
- if ("default" in entry) {
151
- const { default: initial } = entry;
152
- return initial === null ? typeOfKeyword(entry.type) : typeOfValue(initial, 0);
153
- }
154
- return hasSchemaKeywords(entry) ? typeOfKeyword(entry.type) : typeOfValue(entry, 0);
155
- }
156
-
157
- /** The `state` object of an element, when it has one. */
158
- function stateOf(node: JsonValue | undefined): Record<string, JsonValue> | null {
159
- return isJsonObject(node) && isJsonObject(node.state) ? node.state : null;
77
+ /** What a target names: the function entry, and what surrounds the element that holds it. */
78
+ interface Located {
79
+ definition: JsonValue | undefined;
80
+ /** The type of the event the body is given. */
81
+ event: string;
82
+ site: Site;
160
83
  }
161
84
 
162
- /**
163
- * The elements whose `state` a body can read, outermost first: the root, then each element on the
164
- * way down to the one holding the handler. A nested element's scope extends its parent's.
165
- */
166
- function scopeChain(doc: JxElement, path: readonly (string | number)[]): JsonValue[] {
167
- // SAFETY: a document is JSON data; its types name what it holds.
168
- let current: JsonValue | undefined = doc as JsonValue;
169
- const chain: JsonValue[] = [current];
170
- for (const segment of path) {
171
- current = Array.isArray(current)
172
- ? current[Number(segment)]
173
- : isJsonObject(current)
174
- ? current[String(segment)]
175
- : undefined;
176
- if (stateOf(current)) {
177
- // SAFETY: `stateOf` only answers for an object.
178
- chain.push(current as JsonValue);
179
- }
180
- }
181
- return chain;
182
- }
183
-
184
- /** The function entry a target names. */
185
- function definitionOf(doc: JxElement, target: VirtualTarget): JsonValue | undefined {
186
- // SAFETY: a document is JSON data; its types name what it holds.
187
- const root = doc as JsonValue;
85
+ /** Find a target in a document. */
86
+ function locate(doc: JsonObject, target: VirtualTarget): Located {
87
+ let site = rootSite(doc);
188
88
  if (target.kind === "state") {
189
- return stateOf(root)?.[target.key];
89
+ const event = boundEvents(doc).get(pointerInto("/state", target.key)) ?? "any";
90
+ return { definition: isJsonObject(doc.state) ? doc.state[target.key] : undefined, event, site };
190
91
  }
191
- let owner: JsonValue | undefined = root;
92
+ let owner: JsonValue | undefined = doc;
93
+ let pointer = "";
192
94
  for (const segment of target.path) {
193
- owner = Array.isArray(owner)
95
+ const child: JsonValue | undefined = Array.isArray(owner)
194
96
  ? owner[Number(segment)]
195
97
  : isJsonObject(owner)
196
98
  ? owner[String(segment)]
197
99
  : undefined;
100
+ pointer = pointerInto(pointer, segment);
101
+ site = siteInto(site, owner, segment, child, pointer);
102
+ owner = child;
198
103
  }
199
- return isJsonObject(owner) ? owner[target.eventKey] : undefined;
200
- }
201
-
202
- /** The names a function is called with: `state` first, then the declared names (`spec.md` §5.3). */
203
- function parametersOf(definition: JsonValue | undefined): string[] {
204
- if (!isJsonObject(definition)) {
205
- return ["state"];
206
- }
207
- const declared = Array.isArray(definition.parameters)
208
- ? // SAFETY: `parameters` holds bare names or CEM parameter objects, the form `paramNames` reads.
209
- paramNames(definition.parameters as Parameters<typeof paramNames>[0])
210
- : (Array.isArray(definition.arguments) ? definition.arguments : []).filter(
211
- (name): name is string => isString(name),
212
- );
213
- return declared[0] === "state" ? declared : ["state", ...declared];
104
+ return isJsonObject(owner)
105
+ ? { definition: owner[target.eventKey], event: eventType(owner, target.eventKey, site), site }
106
+ : { definition: undefined, event: "any", site };
214
107
  }
215
108
 
216
- /** The members of `State`, each from the innermost element that declares it. */
217
- function stateMembers(chain: readonly JsonValue[]): Map<string, string> {
218
- const members = new Map<string, string>();
219
- for (const node of chain) {
220
- // SAFETY: a document is JSON data, and the chain holds only elements that carry state.
221
- const roles = classifyFunctionEntries(node as JxElement);
222
- for (const [key, entry] of Object.entries(stateOf(node) ?? {})) {
223
- members.set(propertyName(key), typeOfEntry(entry, roles.get(key)));
109
+ /**
110
+ * Write the `State` a body reads: one interface for each element on the way down that has `state`,
111
+ * each under the one around it, and over them the row of the map the body is drawn in.
112
+ */
113
+ function stateLines(site: Site, index: string, defs: boolean): string[] {
114
+ const chain: StateScope[] = [];
115
+ for (let at: StateScope | null = site.scope; at; at = at.parent) {
116
+ chain.unshift(at);
117
+ }
118
+ const inRow = site.row !== null && readsRow(site.scope);
119
+ // The innermost is `State` itself, unless the row is written over it.
120
+ const nameOf = (scope: StateScope): string =>
121
+ scope === site.scope && !inRow ? "State" : `State$${chain.indexOf(scope)}`;
122
+ // The helper is declared only where a scope reads through it: an unused one is a finding.
123
+ const lines: string[] = chain.some((scope) => membersOf(scope).shadowed.length > 0)
124
+ ? [EXCEPT]
125
+ : [];
126
+ for (const scope of chain) {
127
+ const { own, shadowed } = membersOf(scope);
128
+ const around = scope.parent ? nameOf(scope.parent) : null;
129
+ const heritage = around === null ? "" : ` extends ${heritageOf(around, shadowed)}`;
130
+ lines.push(`interface ${nameOf(scope)}${heritage} {`);
131
+ for (const { entry, key, role } of own) {
132
+ lines.push(` ${propertyName(key)}: ${typeOfEntry(entry, role, defs)};`);
224
133
  }
134
+ lines.push(...(around === null ? [` ${index}`, "}"] : ["}"]));
225
135
  }
226
- return members;
136
+ if (site.row && inRow) {
137
+ lines.push(
138
+ `interface State extends ${nameOf(site.scope)} {`,
139
+ ` $map: { item: ${itemType(site.row, nameOf)}; index: number };`,
140
+ "}",
141
+ );
142
+ }
143
+ return lines;
227
144
  }
228
145
 
229
146
  /**
@@ -233,30 +150,38 @@ function stateMembers(chain: readonly JsonValue[]): Map<string, string> {
233
150
  * @param target Which body: a root `state` entry, or an inline handler on an element.
234
151
  * @param body The body's text, which need not be the text the document holds yet (an editor's
235
152
  * buffer is checked as typed).
153
+ * @param outside What the document's project gives it, which makes `State` open. Nothing, when the
154
+ * reader knows of no project.
236
155
  * @returns The file to check, and where the body sits in it.
237
156
  */
238
157
  export function generateBodyTypes(
239
158
  doc: JxElement,
240
159
  target: VirtualTarget,
241
160
  body: string,
161
+ outside: OutsideState = {},
242
162
  ): VirtualBody {
243
- const params = parametersOf(definitionOf(doc, target));
244
- const chain = scopeChain(doc, target.kind === "event" ? target.path : []);
245
- const members = [...stateMembers(chain)].map(([name, type]) => ` ${name}: ${type};`);
246
- /* A page with a layout inherits state the page does not name, so its `State` is open. Without
247
- one, only the `$` members the host adds (`$media`, `$props`) are. */
248
- members.push(doc.$layout === undefined ? " [key: `$${string}`]: any;" : " [key: string]: any;");
249
- const signature = params.map((name) => `${name}: ${name === "state" ? "State" : "any"}`);
163
+ // SAFETY: a document is JSON data; its types name what it holds.
164
+ const data = doc as JsonObject;
165
+ const located = locate(data, target);
166
+ const defs = definitionsOf(data);
167
+ const declared = declaredParameters(located.definition, { defs: defs !== null, pointer: "" });
168
+ const params = declared.map((parameter) => parameter.name);
250
169
  /* The event a handler receives is bound to a name only when the entry declares one, though the
251
170
  runtime passes it and `window.event` holds it. Not declaring it would call a working handler
252
- an error, so it is `any` wherever it is not a parameter. */
171
+ an error, so it is declared wherever it is not a parameter. */
172
+ const event = eventLines(located.event, params);
173
+ /* A handler is called with its state and the event, in that order (`spec.md` §5.3), and a
174
+ parameter that declares a type is that type, the event's place included (§23.3). */
175
+ const signature = declared.map((parameter, at) => {
176
+ const untyped = at === 1 ? event.parameter : "any";
177
+ const type = parameter.type === null ? untyped : written(parameter.type);
178
+ return `${parameter.name}: ${at === 0 ? "State" : type}`;
179
+ });
253
180
  const head = [
254
181
  "export {};",
255
- "interface State {",
256
- ...members,
257
- "}",
258
- "declare const event: any;",
259
- `async function body(${["this: any", ...signature].join(", ")}) {`,
182
+ ...(defs ? [written(definitionParts(defs, false))] : []),
183
+ ...stateLines(located.site, stateIndex(doc, outside), defs !== null),
184
+ `${event.head}async function body(${["this: any", ...signature].join(", ")}) {`,
260
185
  "",
261
186
  ].join("\n");
262
187
  return {
package/src/schema.ts CHANGED
@@ -408,6 +408,28 @@ export async function generateSchema() {
408
408
  examples: ["./layouts/base.json"],
409
409
  oneOf: [{ type: "string" }, { const: false, type: "boolean" }],
410
410
  },
411
+ $script: {
412
+ additionalProperties: false,
413
+ description:
414
+ "This document's own modules, as TypeScript module text (spec.md §23.2). " +
415
+ 'A state entry names an export of one with `$src: "#client"` or `$src: "#server"`. ' +
416
+ "`client` runs in the browser; `server` runs only on the server and is never sent to one.",
417
+ properties: {
418
+ client: {
419
+ description:
420
+ 'The module the browser runs. A Function entry with `$src: "#client"` is one of ' +
421
+ "its exports.",
422
+ type: "string",
423
+ },
424
+ server: {
425
+ description:
426
+ 'The module the server runs. A `timing: "server"` entry with `$src: "#server"` is ' +
427
+ "one of its exports, called with `(args, env)`.",
428
+ type: "string",
429
+ },
430
+ },
431
+ type: "object",
432
+ },
411
433
  $shadow: {
412
434
  description:
413
435
  "Render this component into a shadow root instead of the light DOM (spec.md §16.6). " +
package/types.ts CHANGED
@@ -385,6 +385,20 @@ export interface JxMappedArray {
385
385
  key?: JxRef;
386
386
  }
387
387
 
388
+ /**
389
+ * A document's own modules, as `$script` holds them: TypeScript module text for each side.
390
+ *
391
+ * A type alias rather than an interface, because a document member has to be a `JxDocumentValue`,
392
+ * and only an alias is given the implicit index signature that makes an object type one.
393
+ */
394
+ // oxlint-disable-next-line typescript/consistent-type-definitions -- See above.
395
+ export type JxDocumentScripts = {
396
+ /** The module the browser runs. Its exports are what `$src: "#client"` entries name. */
397
+ client?: string;
398
+ /** The module the server runs. Its exports are what `$src: "#server"` entries name. */
399
+ server?: string;
400
+ };
401
+
388
402
  export interface JxDocument extends JxElement {
389
403
  /**
390
404
  * The custom element's NAME, always a literal.
@@ -397,6 +411,8 @@ export interface JxDocument extends JxElement {
397
411
  tagName?: string;
398
412
  state?: Record<string, JxStateDefinition>;
399
413
  $layout?: string | false;
414
+ /** This document's own client and server modules (spec.md §23.2). */
415
+ $script?: JxDocumentScripts;
400
416
  /** This page's identity across languages, overriding the one its path implies (§13.5). */
401
417
  $translationKey?: string;
402
418
  $paths?: JxPathsDef;