@jxsuite/schema 5.4.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 +2 -0
- package/defs/class-def.schema.ts +4 -0
- package/defs/function-def.schema.ts +4 -2
- package/package.json +3 -1
- package/schema.json +11 -2
- package/src/component-meta.ts +10 -1
- package/src/function-role.ts +182 -0
- package/src/guards.ts +125 -1
- package/src/jxn/virtual-types.ts +280 -0
- package/src/project-file.ts +1 -1
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"
|
package/defs/class-def.schema.ts
CHANGED
|
@@ -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:
|
|
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.
|
|
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:
|
|
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"
|
package/src/component-meta.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -242,7 +242,131 @@ export function isTemplateString(value?: unknown): value is string {
|
|
|
242
242
|
* because a newline after `return` is an ASI bare return.
|
|
243
243
|
*/
|
|
244
244
|
export function bodyReturnsValue(body: string): boolean {
|
|
245
|
-
return /\breturn\b[^\S\n]*(?![;}\s]|$)/.test(body);
|
|
245
|
+
return /\breturn\b[^\S\n]*(?![;}\s]|$)/.test(maskCode(body));
|
|
246
|
+
}
|
|
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
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A function body with its comments and the contents of its strings blanked, so a pattern reads
|
|
261
|
+
* only code (`spec.md` §23.1). The result is the same length, every newline kept, so an offset in
|
|
262
|
+
* it is an offset in the body. A string keeps its quotes, so `return "x"` still returns a value,
|
|
263
|
+
* and a template literal's `${…}` keeps its contents, which are code.
|
|
264
|
+
*
|
|
265
|
+
* A scanner and not a parser, because the interpreter must answer the same way and cannot carry
|
|
266
|
+
* one. Its known limit: a regular expression literal is read as code, so a quote inside one can
|
|
267
|
+
* blank what follows it.
|
|
268
|
+
*
|
|
269
|
+
* @param body The body, already JavaScript.
|
|
270
|
+
*/
|
|
271
|
+
export function maskCode(body: string): string {
|
|
272
|
+
// Nothing can open a comment or a string: the body is all code, and most handlers are.
|
|
273
|
+
if (!/[/'"`]/u.test(body)) {
|
|
274
|
+
return body;
|
|
275
|
+
}
|
|
276
|
+
const known = masked.get(body);
|
|
277
|
+
if (known !== undefined) {
|
|
278
|
+
return known;
|
|
279
|
+
}
|
|
280
|
+
const result = scanCode(body);
|
|
281
|
+
if (masked.size >= MASKED_LIMIT) {
|
|
282
|
+
masked.clear();
|
|
283
|
+
}
|
|
284
|
+
masked.set(body, result);
|
|
285
|
+
return result;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Bodies already masked. Every pattern a compile runs over a body reads it masked, and a dev server
|
|
290
|
+
* compiles the same bodies again on every change, so the scan is done once per text. Bounded,
|
|
291
|
+
* because a long session sees many texts.
|
|
292
|
+
*/
|
|
293
|
+
const masked = new Map<string, string>();
|
|
294
|
+
const MASKED_LIMIT = 2048;
|
|
295
|
+
|
|
296
|
+
/** The scan {@link maskCode} caches. */
|
|
297
|
+
function scanCode(body: string): string {
|
|
298
|
+
/* UTF-16 units, as `at` counts them. Code points (`[...body]`) made an astral character in a
|
|
299
|
+
string one element for two offsets, so the result came back short and every offset after it,
|
|
300
|
+
which is what a caller splices the body at, was off by one. */
|
|
301
|
+
const out = body.split("");
|
|
302
|
+
const blank = (at: number): void => {
|
|
303
|
+
if (out[at] !== undefined && out[at] !== "\n") {
|
|
304
|
+
out[at] = " ";
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
/** The brace depth of each template substitution still open, innermost last. */
|
|
308
|
+
const substitutions: number[] = [];
|
|
309
|
+
let inTemplate = false;
|
|
310
|
+
let at = 0;
|
|
311
|
+
while (at < body.length) {
|
|
312
|
+
const char = body.charAt(at);
|
|
313
|
+
if (inTemplate) {
|
|
314
|
+
if (char === "\\") {
|
|
315
|
+
blank(at);
|
|
316
|
+
blank(at + 1);
|
|
317
|
+
at += 2;
|
|
318
|
+
} else if (char === "`") {
|
|
319
|
+
inTemplate = false;
|
|
320
|
+
at += 1;
|
|
321
|
+
} else if (char === "$" && body.charAt(at + 1) === "{") {
|
|
322
|
+
substitutions.push(0);
|
|
323
|
+
inTemplate = false;
|
|
324
|
+
at += 2;
|
|
325
|
+
} else {
|
|
326
|
+
blank(at);
|
|
327
|
+
at += 1;
|
|
328
|
+
}
|
|
329
|
+
} else if (char === "/" && body.charAt(at + 1) === "/") {
|
|
330
|
+
while (at < body.length && body.charAt(at) !== "\n") {
|
|
331
|
+
blank(at);
|
|
332
|
+
at += 1;
|
|
333
|
+
}
|
|
334
|
+
} else if (char === "/" && body.charAt(at + 1) === "*") {
|
|
335
|
+
const end = body.indexOf("*/", at + 2);
|
|
336
|
+
const stop = end === -1 ? body.length : end + 2;
|
|
337
|
+
for (; at < stop; at += 1) {
|
|
338
|
+
blank(at);
|
|
339
|
+
}
|
|
340
|
+
} else if (char === "'" || char === '"') {
|
|
341
|
+
at += 1;
|
|
342
|
+
while (at < body.length && body.charAt(at) !== char && body.charAt(at) !== "\n") {
|
|
343
|
+
if (body.charAt(at) === "\\") {
|
|
344
|
+
blank(at);
|
|
345
|
+
at += 1;
|
|
346
|
+
}
|
|
347
|
+
blank(at);
|
|
348
|
+
at += 1;
|
|
349
|
+
}
|
|
350
|
+
at += 1;
|
|
351
|
+
} else if (char === "`") {
|
|
352
|
+
inTemplate = true;
|
|
353
|
+
at += 1;
|
|
354
|
+
} else {
|
|
355
|
+
const open = substitutions.length - 1;
|
|
356
|
+
if (open >= 0 && char === "{") {
|
|
357
|
+
substitutions[open] = (substitutions[open] ?? 0) + 1;
|
|
358
|
+
} else if (open >= 0 && char === "}") {
|
|
359
|
+
if (substitutions[open] === 0) {
|
|
360
|
+
substitutions.pop();
|
|
361
|
+
inTemplate = true;
|
|
362
|
+
} else {
|
|
363
|
+
substitutions[open] = (substitutions[open] ?? 1) - 1;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
at += 1;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return out.join("");
|
|
246
370
|
}
|
|
247
371
|
|
|
248
372
|
// ─── Style guards & accessors ───────────────────────────────────────────────────
|
|
@@ -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
|
+
}
|
package/src/project-file.ts
CHANGED
|
@@ -37,7 +37,7 @@ export type ProjectFileName = (typeof PROJECT_FILE_NAMES)[number];
|
|
|
37
37
|
* What a new project's file is called (`site-architecture.md` §2.4). Never what an existing
|
|
38
38
|
* project's is: that is whichever of {@link PROJECT_FILE_NAMES} its folder holds.
|
|
39
39
|
*/
|
|
40
|
-
export const NEW_PROJECT_FILE_NAME: ProjectFileName = "project.
|
|
40
|
+
export const NEW_PROJECT_FILE_NAME: ProjectFileName = "project.jxn";
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
43
|
* Whether a file name is one a project file may have.
|