@jxsuite/schema 5.5.0 → 6.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.
@@ -0,0 +1,205 @@
1
+ /**
2
+ * The TypeScript a checker reads for one function body (`spec.md` §23.3).
3
+ *
4
+ * A body is the inside of a function, so no checker can read it alone: `return` is not allowed at
5
+ * the top of a file, and `state` is not declared anywhere. This module writes the file around it. A
6
+ * checker is handed {@link VirtualBody.text}, finds its errors there, and {@link bodyOffsetOf} puts
7
+ * each one back at its place in the body the author wrote. The body is copied into the file
8
+ * verbatim, so that mapping is a single shift and cannot drift.
9
+ *
10
+ * It is pure and has no dependency on a compiler, a DOM or Node, so every surface that checks types
11
+ * (Studio's editor, and later a mapper for `tsc`) reads the same file and cannot disagree about
12
+ * what a document's types are.
13
+ *
14
+ * **What `State` says is what the document can prove, and no more.** A literal gives its type, an
15
+ * object its shape and an array the types of its items. Anything a document does not determine (a
16
+ * `$prototype` instance, an `$expression`, a template, a `timing: "server"` call, a `null`) is
17
+ * `any`, because an error about code the author cannot fix teaches them to ignore the checker. What
18
+ * `State` does not declare is an error: `state.nope = 1` is the case this exists to catch.
19
+ *
20
+ * @docs framework/concepts/functions
21
+ */
22
+ import { isJsonObject } from "../guards";
23
+ import {
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";
43
+
44
+ /** Where a body sits in a document. */
45
+ export type VirtualTarget =
46
+ | {
47
+ /** A `state` entry of the document root. */
48
+ kind: "state";
49
+ key: string;
50
+ }
51
+ | {
52
+ /** An inline `on*` handler on the element at `path`. */
53
+ kind: "event";
54
+ eventKey: string;
55
+ path: readonly (string | number)[];
56
+ };
57
+
58
+ /** One body, wrapped for a checker. */
59
+ export interface VirtualBody {
60
+ /** The file a checker reads. */
61
+ text: string;
62
+ /** Where the body's first character is in {@link VirtualBody.text}. */
63
+ bodyStart: number;
64
+ /** One past the body's last character in {@link VirtualBody.text}. */
65
+ bodyEnd: number;
66
+ /** The names the function is called with, as the runtime binds them. */
67
+ params: string[];
68
+ }
69
+
70
+ /**
71
+ * Diagnostic codes that say a module could not be found. A body is not a module: it cannot `import`
72
+ * statically, and a dynamic `import("x")` resolves in the browser, not in the checker, so an
73
+ * unresolved specifier is not something the author can fix from here (`studio.md` §16.7).
74
+ */
75
+ export const UNRESOLVED_MODULE_CODES: ReadonlySet<number> = new Set([2307, 2792, 7016]);
76
+
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;
83
+ }
84
+
85
+ /** Find a target in a document. */
86
+ function locate(doc: JsonObject, target: VirtualTarget): Located {
87
+ let site = rootSite(doc);
88
+ if (target.kind === "state") {
89
+ const event = boundEvents(doc).get(pointerInto("/state", target.key)) ?? "any";
90
+ return { definition: isJsonObject(doc.state) ? doc.state[target.key] : undefined, event, site };
91
+ }
92
+ let owner: JsonValue | undefined = doc;
93
+ let pointer = "";
94
+ for (const segment of target.path) {
95
+ const child: JsonValue | undefined = Array.isArray(owner)
96
+ ? owner[Number(segment)]
97
+ : isJsonObject(owner)
98
+ ? owner[String(segment)]
99
+ : undefined;
100
+ pointer = pointerInto(pointer, segment);
101
+ site = siteInto(site, owner, segment, child, pointer);
102
+ owner = child;
103
+ }
104
+ return isJsonObject(owner)
105
+ ? { definition: owner[target.eventKey], event: eventType(owner, target.eventKey, site), site }
106
+ : { definition: undefined, event: "any", site };
107
+ }
108
+
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)};`);
133
+ }
134
+ lines.push(...(around === null ? [` ${index}`, "}"] : ["}"]));
135
+ }
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;
144
+ }
145
+
146
+ /**
147
+ * Wrap one body for a checker.
148
+ *
149
+ * @param doc The document the body is in.
150
+ * @param target Which body: a root `state` entry, or an inline handler on an element.
151
+ * @param body The body's text, which need not be the text the document holds yet (an editor's
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.
155
+ * @returns The file to check, and where the body sits in it.
156
+ */
157
+ export function generateBodyTypes(
158
+ doc: JxElement,
159
+ target: VirtualTarget,
160
+ body: string,
161
+ outside: OutsideState = {},
162
+ ): VirtualBody {
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);
169
+ /* The event a handler receives is bound to a name only when the entry declares one, though the
170
+ runtime passes it and `window.event` holds it. Not declaring it would call a working handler
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
+ });
180
+ const head = [
181
+ "export {};",
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(", ")}) {`,
185
+ "",
186
+ ].join("\n");
187
+ return {
188
+ bodyEnd: head.length + body.length,
189
+ bodyStart: head.length,
190
+ params,
191
+ text: `${head}${body}\n}\n`,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Where a position in {@link VirtualBody.text} is in the body, or null when it is outside it (the
197
+ * wrapper, which the author did not write).
198
+ *
199
+ * @param unit The wrapped body.
200
+ * @param offset An offset in `unit.text`.
201
+ * @returns An offset in the body, or null.
202
+ */
203
+ export function bodyOffsetOf(unit: VirtualBody, offset: number): number | null {
204
+ return offset >= unit.bodyStart && offset <= unit.bodyEnd ? offset - unit.bodyStart : null;
205
+ }
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;