@jxsuite/schema 5.5.0 → 5.6.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/class-schema.json CHANGED
@@ -8,6 +8,7 @@
8
8
  "type": "string"
9
9
  },
10
10
  "body": {
11
+ "description": "The member's body in erasable TypeScript, which plain JavaScript already is (spec §23.1), as one string or as an array of lines.",
11
12
  "oneOf": [
12
13
  {
13
14
  "type": "string"
@@ -120,6 +121,7 @@
120
121
  "type": "string"
121
122
  },
122
123
  "body": {
124
+ "description": "The member's body in erasable TypeScript, which plain JavaScript already is (spec §23.1), as one string or as an array of lines.",
123
125
  "oneOf": [
124
126
  {
125
127
  "type": "string"
@@ -67,6 +67,8 @@ export const classConstructorDefSchema = {
67
67
  properties: {
68
68
  $prototype: { const: "Function", type: "string" },
69
69
  body: {
70
+ description:
71
+ "The member's body in erasable TypeScript, which plain JavaScript already is (spec §23.1), as one string or as an array of lines.",
70
72
  oneOf: [{ type: "string" }, { items: { type: "string" }, type: "array" }],
71
73
  },
72
74
  description: { type: "string" },
@@ -103,6 +105,8 @@ export const classMethodDefSchema = {
103
105
  $prototype: { const: "Function", type: "string" },
104
106
  access: { enum: ["public", "private", "protected"], type: "string" },
105
107
  body: {
108
+ description:
109
+ "The member's body in erasable TypeScript, which plain JavaScript already is (spec §23.1), as one string or as an array of lines.",
106
110
  oneOf: [{ type: "string" }, { items: { type: "string" }, type: "array" }],
107
111
  },
108
112
  description: { type: "string" },
@@ -2,8 +2,9 @@ export const functionDefSchema = {
2
2
  additionalProperties: false,
3
3
  description:
4
4
  'A function declaration. $prototype must be "Function". ' +
5
- "body (inline) and $src (external) are mutually exclusive. " +
5
+ "body (inline) and $src (external) are mutually exclusive; declaring both is an error. " +
6
6
  "First parameter is always state (the reactive scope).",
7
+ not: { required: ["body", "$src"] },
7
8
  properties: {
8
9
  $export: {
9
10
  description: "Named export in $src module. Defaults to the state key name.",
@@ -32,11 +33,12 @@ export const functionDefSchema = {
32
33
  },
33
34
  body: {
34
35
  description:
35
- "Inline function body: an opaque JS source string (first implicit parameter is state), or a structured statement array (spec §20) — explicit structured function declaration, analyzable by tooling and editable visually. The escalation ladder within the shape is structured statements → JS string.",
36
+ "Inline function body: a source string in erasable TypeScript, which plain JavaScript already is (spec §23.1; types are erased before it is analysed or run; first implicit parameter is state), or a structured statement array (spec §20) — explicit structured function declaration, analyzable by tooling and editable visually. The escalation ladder within the shape is structured statements → source string.",
36
37
  examples: [
37
38
  "state.count++",
38
39
  'state.items.push({ id: Date.now(), text: "", done: false })',
39
40
  'return state.score >= 90 ? "gold" : "silver"',
41
+ "const input = event.target as HTMLInputElement; state.label = input.value",
40
42
  [
41
43
  { operator: "push", target: { $ref: "#/state/cart" }, value: 1 },
42
44
  { dispatchEvent: "cart-changed", detail: { $ref: "#/state/cart" } },
package/package.json CHANGED
@@ -18,6 +18,7 @@
18
18
  "./project-schemas": "./src/project-schemas.ts",
19
19
  "./validate-project": "./src/validate-project.ts",
20
20
  "./types": "./types.ts",
21
+ "./function-role": "./src/function-role.ts",
21
22
  "./guards": "./src/guards.ts",
22
23
  "./locale": "./src/locale.ts",
23
24
  "./intl": "./src/intl.ts",
@@ -29,6 +30,7 @@
29
30
  "./ijson": "./src/ijson.ts",
30
31
  "./parse": "./src/parse.ts",
31
32
  "./jxn": "./src/jxn/index.ts",
33
+ "./virtual-types": "./src/jxn/virtual-types.ts",
32
34
  "./project-file": "./src/project-file.ts",
33
35
  "./project-file/node": "./src/project-file-node.ts",
34
36
  "./doc-ops": "./src/doc-ops.ts",
@@ -66,7 +68,7 @@
66
68
  "upgrade": "bun update --latest && bun install"
67
69
  },
68
70
  "type": "module",
69
- "version": "5.5.0",
71
+ "version": "5.6.0",
70
72
  "devDependencies": {
71
73
  "json-schema-to-ts": "^3.1.1"
72
74
  }
package/schema.json CHANGED
@@ -205,7 +205,13 @@
205
205
  },
206
206
  "FunctionDef": {
207
207
  "additionalProperties": false,
208
- "description": "A function declaration. $prototype must be \"Function\". body (inline) and $src (external) are mutually exclusive. First parameter is always state (the reactive scope).",
208
+ "description": "A function declaration. $prototype must be \"Function\". body (inline) and $src (external) are mutually exclusive; declaring both is an error. First parameter is always state (the reactive scope).",
209
+ "not": {
210
+ "required": [
211
+ "body",
212
+ "$src"
213
+ ]
214
+ },
209
215
  "properties": {
210
216
  "$export": {
211
217
  "description": "Named export in $src module. Defaults to the state key name.",
@@ -241,11 +247,12 @@
241
247
  "type": "array"
242
248
  },
243
249
  "body": {
244
- "description": "Inline function body: an opaque JS source string (first implicit parameter is state), or a structured statement array (spec §20) — explicit structured function declaration, analyzable by tooling and editable visually. The escalation ladder within the shape is structured statements → JS string.",
250
+ "description": "Inline function body: a source string in erasable TypeScript, which plain JavaScript already is (spec §23.1; types are erased before it is analysed or run; first implicit parameter is state), or a structured statement array (spec §20) — explicit structured function declaration, analyzable by tooling and editable visually. The escalation ladder within the shape is structured statements → source string.",
245
251
  "examples": [
246
252
  "state.count++",
247
253
  "state.items.push({ id: Date.now(), text: \"\", done: false })",
248
254
  "return state.score >= 90 ? \"gold\" : \"silver\"",
255
+ "const input = event.target as HTMLInputElement; state.label = input.value",
249
256
  [
250
257
  {
251
258
  "operator": "push",
@@ -506,6 +513,7 @@
506
513
  "type": "string"
507
514
  },
508
515
  "body": {
516
+ "description": "The member's body in erasable TypeScript, which plain JavaScript already is (spec §23.1), as one string or as an array of lines.",
509
517
  "oneOf": [
510
518
  {
511
519
  "type": "string"
@@ -577,6 +585,7 @@
577
585
  "type": "string"
578
586
  },
579
587
  "body": {
588
+ "description": "The member's body in erasable TypeScript, which plain JavaScript already is (spec §23.1), as one string or as an array of lines.",
580
589
  "oneOf": [
581
590
  {
582
591
  "type": "string"
@@ -32,6 +32,11 @@ export interface ComponentPropDef {
32
32
  /** Absent for a full-form entry that declares no `default`. */
33
33
  default?: JsonValue | undefined;
34
34
  format?: unknown;
35
+ /**
36
+ * The values the entry admits, read from its JSON Schema `enum` (spec.md §5.3). Absent when the
37
+ * entry declares none, so a prop with a closed set of values is told apart from free text.
38
+ */
39
+ enum?: JsonValue[];
35
40
  }
36
41
 
37
42
  /** What discovery reports for one component document. */
@@ -150,12 +155,16 @@ function propFrom(name: string, def: JsonValue): ComponentPropDef | null {
150
155
  if (fields.$prototype || fields.$handler || fields.$compute) {
151
156
  return null;
152
157
  }
153
- return {
158
+ const prop: ComponentPropDef = {
154
159
  default: fields.default,
155
160
  format: fields.format,
156
161
  name,
157
162
  type: fields.type,
158
163
  };
164
+ if (Array.isArray(fields.enum)) {
165
+ prop.enum = fields.enum;
166
+ }
167
+ return prop;
159
168
  }
160
169
 
161
170
  /**
@@ -0,0 +1,182 @@
1
+ /**
2
+ * One verdict per `$prototype: "Function"` entry: is it a **computed** value, read reactively, or a
3
+ * **callable**, invoked by an event, a call site or the host?
4
+ *
5
+ * Every tier reads this one classifier (the interpreter's `buildScope`, both compiled targets and
6
+ * the build-time scope), so a document means the same thing in Studio and in production. It reads
7
+ * the document, never an imported function's arity or source: a compiler has only the document, and
8
+ * `fn.toString()` of a bundled import is not a stable input (spec.md §5.3 4b, 4d).
9
+ *
10
+ * @docs framework/concepts/functions
11
+ */
12
+ import {
13
+ bodyReturnsValue,
14
+ hasStructuredBody,
15
+ isFunctionDef,
16
+ isJsonObject,
17
+ isRef,
18
+ isString,
19
+ paramNames,
20
+ } from "./guards";
21
+ import type { JsonValue, JxElement, JxFunctionDef } from "../types";
22
+
23
+ /** What a Function entry is to the tiers that read it. */
24
+ export type FunctionRole = "computed" | "callable";
25
+
26
+ /** Lifecycle hooks the generated element calls itself, so they are never `$ref`'d (spec.md §16.4). */
27
+ export const LIFECYCLE_KEYS: ReadonlySet<string> = new Set(["onMount", "onUnmount", "onAdopted"]);
28
+
29
+ /**
30
+ * The state keys a document uses as a _callable_, at any depth: bound to an `on*` event (a
31
+ * definition's root-level handlers included, §16.1), invoked by an `$expression` `call` node
32
+ * (§19.4c), called as `state.key(…)` from a template string or another function's body, or named as
33
+ * a lifecycle hook (§16.4). `$defs` holds JSON Schema, not usage, and is skipped.
34
+ *
35
+ * Anything not used as a callable has its return value read reactively. Defaulting the other way
36
+ * round would turn a called helper into a value and break its call site.
37
+ */
38
+ function collectCallableKeys(doc: JxElement): Set<string> {
39
+ const keys = new Set<string>();
40
+ const seen = new Set<object>();
41
+
42
+ const addStateRef = (ref: string): void => {
43
+ if (ref.startsWith("#/state/")) {
44
+ /* SAFETY: `String.prototype.split` always returns at least one element (the whole string
45
+ when the separator is absent), so index 0 is present. */
46
+ keys.add(ref.slice("#/state/".length).split("/")[0] as string);
47
+ }
48
+ };
49
+
50
+ const visit = (node: JsonValue | undefined): void => {
51
+ if (isString(node)) {
52
+ // `${state.formatDate(d)}` in a template, or `state.helper(x)` in another function's body.
53
+ for (const call of node.matchAll(/\bstate\.([A-Za-z_$][\w$]*)\s*\(/gu)) {
54
+ /* SAFETY: the pattern's one capture group is not optional, so every match fills index 1
55
+ with the called member's name. */
56
+ keys.add(call[1] as string);
57
+ }
58
+ return;
59
+ }
60
+ if (Array.isArray(node)) {
61
+ if (!seen.has(node)) {
62
+ seen.add(node);
63
+ for (const item of node) {
64
+ visit(item);
65
+ }
66
+ }
67
+ return;
68
+ }
69
+ if (!isJsonObject(node) || seen.has(node)) {
70
+ return;
71
+ }
72
+ seen.add(node);
73
+ // An `$expression` `call` node names its callee in `target` (spec.md §19.4c).
74
+ if (node.operator === "call" && isRef(node.target)) {
75
+ addStateRef(node.target.$ref);
76
+ }
77
+ for (const [key, value] of Object.entries(node)) {
78
+ if (key === "$defs") {
79
+ continue;
80
+ }
81
+ if (key.startsWith("on") && isRef(value)) {
82
+ addStateRef(value.$ref);
83
+ }
84
+ visit(value);
85
+ }
86
+ };
87
+
88
+ // SAFETY: a Jx element is JSON data; its type names what it holds.
89
+ visit(doc as JsonValue);
90
+ for (const hook of LIFECYCLE_KEYS) {
91
+ if (isJsonObject(doc.state) && hook in doc.state) {
92
+ keys.add(hook);
93
+ }
94
+ }
95
+ return keys;
96
+ }
97
+
98
+ /**
99
+ * Does a Function declaration name both a `body` and a `$src`? The two are mutually exclusive
100
+ * (spec.md §5.3 4d), and the test is presence, as the `FunctionDef` schema's `not` is, so an empty
101
+ * `body: ""` beside a `$src` is refused too and `jx validate` agrees with every tier.
102
+ *
103
+ * @param def A Function declaration, a `state` entry or a handler written inline on an `on*` key.
104
+ */
105
+ export function declaresBodyAndSrc(def: JxFunctionDef): boolean {
106
+ return "body" in def && "$src" in def;
107
+ }
108
+
109
+ /**
110
+ * Does a Function declaration list any parameter of its own, by `parameters` or the legacy
111
+ * `arguments`? An inline handler that does not is rewritten to read `state` as the handler's alias,
112
+ * and one that does is built with {@link handlerParamNames}.
113
+ *
114
+ * @param def A Function declaration.
115
+ */
116
+ export function declaresParameters(def: JxFunctionDef): boolean {
117
+ return paramNames(def.parameters ?? def.arguments).length > 0;
118
+ }
119
+
120
+ /**
121
+ * The formal parameters a string `body` is built with, in every tier (spec.md §5.3 4d): `state`
122
+ * first, then the other declared names in declared order. Binding is by position, as ECMAScript's
123
+ * is, so a name that is not `state` receives whatever the call site passes in that position. An
124
+ * event binding calls a handler `(state, event)`, so the first other name is the event and a later
125
+ * one is `undefined` from an event (a call with too few arguments), filled by a call site that
126
+ * passes it. A declared `state` is moved to the front rather than repeated, since a duplicate
127
+ * parameter is a sloppy-mode quirk in which the later one wins and `state` would be `undefined`.
128
+ *
129
+ * @param def A Function declaration, a `state` entry or a handler written inline on an `on*` key.
130
+ */
131
+ export function handlerParamNames(def: JxFunctionDef): string[] {
132
+ const list = ["state"];
133
+ for (const name of paramNames(def.parameters ?? def.arguments)) {
134
+ if (name !== "state") {
135
+ list.push(name);
136
+ }
137
+ }
138
+ return list;
139
+ }
140
+
141
+ /** The rules in order: the first that applies decides. */
142
+ function functionEntryRole(
143
+ key: string,
144
+ def: JxFunctionDef,
145
+ callableKeys: () => Set<string>,
146
+ ): FunctionRole {
147
+ if (hasStructuredBody(def)) {
148
+ return "callable";
149
+ }
150
+ // Declared parameters or arguments make an entry callable wherever it is used (§19.4c).
151
+ if (paramNames(def.parameters ?? def.arguments).length > 0) {
152
+ return "callable";
153
+ }
154
+ if (isString(def.body)) {
155
+ return bodyReturnsValue(def.body) ? "computed" : "callable";
156
+ }
157
+ if (def.$src) {
158
+ return callableKeys().has(key) ? "callable" : "computed";
159
+ }
160
+ return "callable";
161
+ }
162
+
163
+ /**
164
+ * Classify every Function entry in a document's `state`. Other state shapes get no verdict. The
165
+ * document is the one a tier builds, after the project-state merge, so a project-global entry one
166
+ * page calls and another only reads is a function on the first and a computed on the second.
167
+ *
168
+ * The walk for usage runs at most once, and only when a bodyless `$src` entry reaches it.
169
+ *
170
+ * @param doc The document (or just `{ state }`) being built.
171
+ */
172
+ export function classifyFunctionEntries(doc: JxElement): Map<string, FunctionRole> {
173
+ const roles = new Map<string, FunctionRole>();
174
+ let callable: Set<string> | null = null;
175
+ const callableKeys = (): Set<string> => (callable ??= collectCallableKeys(doc));
176
+ for (const [key, def] of Object.entries(doc.state ?? {})) {
177
+ if (isFunctionDef(def)) {
178
+ roles.set(key, functionEntryRole(key, def, callableKeys));
179
+ }
180
+ }
181
+ return roles;
182
+ }
package/src/guards.ts CHANGED
@@ -245,6 +245,17 @@ export function bodyReturnsValue(body: string): boolean {
245
245
  return /\breturn\b[^\S\n]*(?![;}\s]|$)/.test(maskCode(body));
246
246
  }
247
247
 
248
+ /**
249
+ * Whether an object is a function entry, whose `body` is code (`spec.md` §23.1): a `Function`
250
+ * entry, or a class member, which a `.class.json` names by its `role` alone. A field is a member
251
+ * with no body. Any other `body` may be data, so it is never read as code.
252
+ *
253
+ * @param value An object in a document or class definition.
254
+ */
255
+ export function isFunctionEntry(value: JsonObject): boolean {
256
+ return value.$prototype === "Function" || (isString(value.role) && value.role !== "field");
257
+ }
258
+
248
259
  /**
249
260
  * A function body with its comments and the contents of its strings blanked, so a pattern reads
250
261
  * only code (`spec.md` §23.1). The result is the same length, every newline kept, so an offset in
@@ -0,0 +1,280 @@
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 { classifyFunctionEntries } from "../function-role";
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";
35
+
36
+ /** Where a body sits in a document. */
37
+ export type VirtualTarget =
38
+ | {
39
+ /** A `state` entry of the document root. */
40
+ kind: "state";
41
+ key: string;
42
+ }
43
+ | {
44
+ /** An inline `on*` handler on the element at `path`. */
45
+ kind: "event";
46
+ eventKey: string;
47
+ path: readonly (string | number)[];
48
+ };
49
+
50
+ /** One body, wrapped for a checker. */
51
+ export interface VirtualBody {
52
+ /** The file a checker reads. */
53
+ text: string;
54
+ /** Where the body's first character is in {@link VirtualBody.text}. */
55
+ bodyStart: number;
56
+ /** One past the body's last character in {@link VirtualBody.text}. */
57
+ bodyEnd: number;
58
+ /** The names the function is called with, as the runtime binds them. */
59
+ params: string[];
60
+ }
61
+
62
+ /**
63
+ * Diagnostic codes that say a module could not be found. A body is not a module: it cannot `import`
64
+ * statically, and a dynamic `import("x")` resolves in the browser, not in the checker, so an
65
+ * unresolved specifier is not something the author can fix from here (`studio.md` §16.7).
66
+ */
67
+ export const UNRESOLVED_MODULE_CODES: ReadonlySet<number> = new Set([2307, 2792, 7016]);
68
+
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;
160
+ }
161
+
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;
188
+ if (target.kind === "state") {
189
+ return stateOf(root)?.[target.key];
190
+ }
191
+ let owner: JsonValue | undefined = root;
192
+ for (const segment of target.path) {
193
+ owner = Array.isArray(owner)
194
+ ? owner[Number(segment)]
195
+ : isJsonObject(owner)
196
+ ? owner[String(segment)]
197
+ : undefined;
198
+ }
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];
214
+ }
215
+
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)));
224
+ }
225
+ }
226
+ return members;
227
+ }
228
+
229
+ /**
230
+ * Wrap one body for a checker.
231
+ *
232
+ * @param doc The document the body is in.
233
+ * @param target Which body: a root `state` entry, or an inline handler on an element.
234
+ * @param body The body's text, which need not be the text the document holds yet (an editor's
235
+ * buffer is checked as typed).
236
+ * @returns The file to check, and where the body sits in it.
237
+ */
238
+ export function generateBodyTypes(
239
+ doc: JxElement,
240
+ target: VirtualTarget,
241
+ body: string,
242
+ ): 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"}`);
250
+ /* The event a handler receives is bound to a name only when the entry declares one, though the
251
+ 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. */
253
+ const head = [
254
+ "export {};",
255
+ "interface State {",
256
+ ...members,
257
+ "}",
258
+ "declare const event: any;",
259
+ `async function body(${["this: any", ...signature].join(", ")}) {`,
260
+ "",
261
+ ].join("\n");
262
+ return {
263
+ bodyEnd: head.length + body.length,
264
+ bodyStart: head.length,
265
+ params,
266
+ text: `${head}${body}\n}\n`,
267
+ };
268
+ }
269
+
270
+ /**
271
+ * Where a position in {@link VirtualBody.text} is in the body, or null when it is outside it (the
272
+ * wrapper, which the author did not write).
273
+ *
274
+ * @param unit The wrapped body.
275
+ * @param offset An offset in `unit.text`.
276
+ * @returns An offset in the body, or null.
277
+ */
278
+ export function bodyOffsetOf(unit: VirtualBody, offset: number): number | null {
279
+ return offset >= unit.bodyStart && offset <= unit.bodyEnd ? offset - unit.bodyStart : null;
280
+ }