@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.
- package/README.md +1 -0
- 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 +7 -1
- package/schema.json +26 -2
- package/src/component-meta.ts +10 -1
- package/src/document-scripts.ts +304 -0
- package/src/dynamic-tag.ts +570 -0
- package/src/function-role.ts +182 -0
- package/src/guards.ts +190 -4
- package/src/jxn/complete.ts +582 -0
- package/src/jxn/document-scopes.ts +581 -0
- package/src/jxn/document.ts +6 -0
- package/src/jxn/index.ts +14 -2
- package/src/jxn/outline.ts +452 -0
- package/src/jxn/parse.ts +234 -13
- package/src/jxn/schema-types.ts +299 -0
- package/src/jxn/schema-walk.ts +221 -0
- package/src/jxn/serialize.ts +113 -5
- package/src/jxn/state-types.ts +271 -0
- package/src/jxn/string-pieces.ts +240 -0
- package/src/jxn/virtual-document.ts +1077 -0
- package/src/jxn/virtual-types.ts +205 -0
- package/src/schema.ts +22 -0
- package/types.ts +16 -0
|
@@ -0,0 +1,1077 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The TypeScript a checker reads for a whole `.jxn` document (`spec.md` §23.3).
|
|
3
|
+
*
|
|
4
|
+
* A document is not TypeScript, and most of what a checker should read in one is: its script, and
|
|
5
|
+
* the body of each function. This writes that as modules, and says for each stretch of them where
|
|
6
|
+
* in the file it was written, so a checker's errors, and an editor's hover and rename, land on the
|
|
7
|
+
* author's own text.
|
|
8
|
+
*
|
|
9
|
+
* - **The main module** is the document's client script as written, followed by `State`: what the
|
|
10
|
+
* document's `state` proves, exported so that a `.ts` file importing the document can name it. An
|
|
11
|
+
* `(export)` entry has the type of the export it names, so a wrong name is an error at the entry
|
|
12
|
+
* and renaming the function renames the entry. One written under its own name alone is one word
|
|
13
|
+
* for both, so `State` takes that property from the module itself and a rename started at the
|
|
14
|
+
* function, the entry or a body reaches all three. After it comes one interface for each element
|
|
15
|
+
* below the root that has `state` of its own, and one for each map a body is drawn in, so that
|
|
16
|
+
* every name a body reads is declared once, in one module, at the place its author wrote it.
|
|
17
|
+
* - **The server module** is the server script, followed by what it adds to those interfaces: each
|
|
18
|
+
* `(server)` entry is what its function resolves to, or `null` before it has answered.
|
|
19
|
+
* - **One module for each body**: the function a string `body` is the inside of, with the `State` of
|
|
20
|
+
* where it stands, the event its handler is given, and the names the runtime binds (`spec.md`
|
|
21
|
+
* §5.3 4d). A body does not see what the script declares (§23.2), which is why it is not written
|
|
22
|
+
* into the main module.
|
|
23
|
+
* - **One module for each template**: the function a `${}` string is the value of, given the names
|
|
24
|
+
* the runtime gives one. It is JavaScript and not TypeScript, because that is what the runtime
|
|
25
|
+
* compiles it as, so a type written inside one is an error here as it is a blank on the page.
|
|
26
|
+
* - **One module for every `$ref`** into `state` or the row of a map: each path read off the `State`
|
|
27
|
+
* of where it stands, so a name that is not there is an error at the name, and renaming an entry
|
|
28
|
+
* renames what refers to it. An `on*` member's is checked as the handler it binds.
|
|
29
|
+
* - **What a component is given** is checked in that module too. The main module ends with
|
|
30
|
+
* `$elements`: the tag the document defines, and its `State`. Each value an instance passes as
|
|
31
|
+
* `$props` is held against the `State` of the component its tag names, which is found among the
|
|
32
|
+
* document itself, the `.jxn` files its `$elements` names and the components its project keeps. A
|
|
33
|
+
* prop the component does not declare is an error at its name, a value of the wrong type is one
|
|
34
|
+
* at the value, and renaming a component's entry renames the prop.
|
|
35
|
+
*
|
|
36
|
+
* It is pure: text in, text and spans out, with no compiler, DOM or Node. A mapper for `tsc` is one
|
|
37
|
+
* reader of it and an editor inside Studio can be another, and neither can disagree with the other
|
|
38
|
+
* about what a document's types are.
|
|
39
|
+
*
|
|
40
|
+
* @docs framework/concepts/type-checking
|
|
41
|
+
*/
|
|
42
|
+
import { CLIENT_SCRIPT_REF, SERVER_SCRIPT_REF } from "../document-scripts";
|
|
43
|
+
import type { FunctionRole } from "../function-role";
|
|
44
|
+
import {
|
|
45
|
+
isBoolean,
|
|
46
|
+
isFunctionDef,
|
|
47
|
+
isJsonObject,
|
|
48
|
+
isNumber,
|
|
49
|
+
isRef,
|
|
50
|
+
isServerFnDef,
|
|
51
|
+
isString,
|
|
52
|
+
} from "../guards";
|
|
53
|
+
import type { JsonObject, JsonValue, JxElement } from "../../types";
|
|
54
|
+
import { classifyJxn } from "./classify.ts";
|
|
55
|
+
import { JxnError } from "./errors.ts";
|
|
56
|
+
import type { JxnSpan } from "./errors.ts";
|
|
57
|
+
import { parseJxnWithTokens, token } from "./parse.ts";
|
|
58
|
+
import type { JxnTokens } from "./parse.ts";
|
|
59
|
+
import {
|
|
60
|
+
boundEvents,
|
|
61
|
+
eachSite,
|
|
62
|
+
EVENT_GLOBALS,
|
|
63
|
+
eventLines,
|
|
64
|
+
eventType,
|
|
65
|
+
expressionsAt,
|
|
66
|
+
itemType,
|
|
67
|
+
EXCEPT,
|
|
68
|
+
heritageOf,
|
|
69
|
+
membersOf,
|
|
70
|
+
readsRow,
|
|
71
|
+
} from "./document-scopes";
|
|
72
|
+
import type { RefSite, Row, Site, StateScope } from "./document-scopes";
|
|
73
|
+
import {
|
|
74
|
+
DEFINITIONS,
|
|
75
|
+
definitionParts,
|
|
76
|
+
definitionsOf,
|
|
77
|
+
IDENTIFIER,
|
|
78
|
+
propertyName,
|
|
79
|
+
} from "./schema-types";
|
|
80
|
+
import { declaredParameters, entryParts, stateIndex, stateOf } from "./state-types";
|
|
81
|
+
import { stringPieces } from "./string-pieces.ts";
|
|
82
|
+
import type { TypePart } from "./schema-types";
|
|
83
|
+
import type { OutsideState } from "./state-types";
|
|
84
|
+
import type { StringPiece } from "./string-pieces.ts";
|
|
85
|
+
|
|
86
|
+
export { givenByProject, outsideAt } from "./state-types";
|
|
87
|
+
export type { OutsideState, ProjectGives } from "./state-types";
|
|
88
|
+
|
|
89
|
+
/** A stretch of a written module, and the stretch of the file it stands for. */
|
|
90
|
+
export interface VirtualSpan {
|
|
91
|
+
/**
|
|
92
|
+
* `verbatim` when the two are the same text, so a position inside one is the same position inside
|
|
93
|
+
* the other. `atom` when they are not (an escape, a quoted name), so the stretch maps as a
|
|
94
|
+
* whole.
|
|
95
|
+
*/
|
|
96
|
+
kind: "atom" | "verbatim";
|
|
97
|
+
originalLength: number;
|
|
98
|
+
originalStart: number;
|
|
99
|
+
virtualLength: number;
|
|
100
|
+
virtualStart: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One module a checker reads. Offsets count UTF-16 code units, as a JavaScript string does. */
|
|
104
|
+
export interface VirtualUnit {
|
|
105
|
+
/** What the module is written in. A template is JavaScript, as the runtime compiles it. */
|
|
106
|
+
extension: ".js" | ".ts";
|
|
107
|
+
/** In the order written, which is ascending in the module. */
|
|
108
|
+
spans: VirtualSpan[];
|
|
109
|
+
text: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Something wrong with the file itself, which no module can be written around. */
|
|
113
|
+
export interface VirtualProblem {
|
|
114
|
+
length: number;
|
|
115
|
+
message: string;
|
|
116
|
+
offset: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** What a checker reads for one `.jxn` file. */
|
|
120
|
+
export interface VirtualDocument {
|
|
121
|
+
/** The module the file is to anything that imports it. */
|
|
122
|
+
main: VirtualUnit;
|
|
123
|
+
problems: VirtualProblem[];
|
|
124
|
+
/** Modules beside it, which may import from the main one and are imported by nothing. */
|
|
125
|
+
supplemental: VirtualUnit[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** What a reader knows that the file does not say. */
|
|
129
|
+
export interface VirtualDocumentOptions {
|
|
130
|
+
/** What the document's project gives it, which makes `State` open. */
|
|
131
|
+
outside?: OutsideState;
|
|
132
|
+
/** How a module beside the file imports it: `./` and the file's name, extension included. */
|
|
133
|
+
specifier: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** What a file that is not a document is to a checker: a module that defines no element. */
|
|
137
|
+
const EMPTY_MODULE = "export interface $elements {}\n";
|
|
138
|
+
|
|
139
|
+
/** The name `State` takes when the script has a `State` of its own. */
|
|
140
|
+
const FALLBACK_STATE = "JxState";
|
|
141
|
+
|
|
142
|
+
/** A script that declares or imports a `State`, which ours would collide or merge with. */
|
|
143
|
+
const DECLARES_STATE =
|
|
144
|
+
/\b(?:interface|type|class|enum|namespace|const|let|var|function)\s+State\b|\bimport\b[^;]*\bState\b/u;
|
|
145
|
+
|
|
146
|
+
/** One module being written. */
|
|
147
|
+
class Writer {
|
|
148
|
+
readonly source: string;
|
|
149
|
+
readonly spans: VirtualSpan[] = [];
|
|
150
|
+
text = "";
|
|
151
|
+
|
|
152
|
+
/** @param source The file every span points into. */
|
|
153
|
+
constructor(source: string) {
|
|
154
|
+
this.source = source;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Text the author did not write. */
|
|
158
|
+
add(text: string): this {
|
|
159
|
+
this.text += text;
|
|
160
|
+
return this;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Text that stands for `start` to `end` of the file. */
|
|
164
|
+
mapped(text: string, start: number, end: number, kind: VirtualSpan["kind"]): this {
|
|
165
|
+
this.spans.push({
|
|
166
|
+
kind,
|
|
167
|
+
originalLength: end - start,
|
|
168
|
+
originalStart: start,
|
|
169
|
+
virtualLength: text.length,
|
|
170
|
+
virtualStart: this.text.length,
|
|
171
|
+
});
|
|
172
|
+
return this.add(text);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Text that stands for a whole token, when the file says where it is.
|
|
177
|
+
*
|
|
178
|
+
* @param text What the module says.
|
|
179
|
+
* @param span The token, when the read recorded one.
|
|
180
|
+
*/
|
|
181
|
+
whole(text: string, span: JxnSpan | undefined): this {
|
|
182
|
+
return span ? this.mapped(text, span.start, span.end, "atom") : this.add(text);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A string's value, each run of it beside where it was written. */
|
|
186
|
+
pieces(pieces: readonly StringPiece[]): this {
|
|
187
|
+
for (const piece of pieces) {
|
|
188
|
+
this.mapped(piece.text, piece.start, piece.end, piece.verbatim ? "verbatim" : "atom");
|
|
189
|
+
}
|
|
190
|
+
return this;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A string's value, mapped when the file says where it is.
|
|
195
|
+
*
|
|
196
|
+
* @param value The value.
|
|
197
|
+
* @param span Its token, when the read recorded one.
|
|
198
|
+
*/
|
|
199
|
+
value(value: string, span: JxnSpan | undefined): this {
|
|
200
|
+
return span ? this.pieces(stringPieces(this.source, span, value)) : this.add(value);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A name. Where the file spells it exactly, the two are one name to an editor, so renaming either
|
|
205
|
+
* renames both. Where it does not (a name in quotes that TypeScript writes differently), the name
|
|
206
|
+
* stands for its token as a whole.
|
|
207
|
+
*
|
|
208
|
+
* @param name The name as the document holds it.
|
|
209
|
+
* @param span Its token, when the read recorded one.
|
|
210
|
+
* @param written The name as TypeScript writes it here.
|
|
211
|
+
*/
|
|
212
|
+
name(name: string, span: JxnSpan | undefined, written = name): this {
|
|
213
|
+
if (!span) {
|
|
214
|
+
return this.add(written);
|
|
215
|
+
}
|
|
216
|
+
const [piece, ...rest] = stringPieces(this.source, span, name);
|
|
217
|
+
return piece?.verbatim && rest.length === 0 && written === name
|
|
218
|
+
? this.mapped(name, piece.start, piece.end, "verbatim")
|
|
219
|
+
: this.mapped(written, span.start, span.end, "atom");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Part of a string's value, written as itself or as something that stands for it.
|
|
224
|
+
*
|
|
225
|
+
* @param written What the module says.
|
|
226
|
+
* @param span The string's token, which the part stands for whole when no piece of it does.
|
|
227
|
+
* @param pieces The string's pieces.
|
|
228
|
+
* @param from Where the part starts in the value.
|
|
229
|
+
* @param to One past where it ends.
|
|
230
|
+
*/
|
|
231
|
+
part(
|
|
232
|
+
written: string,
|
|
233
|
+
span: JxnSpan,
|
|
234
|
+
pieces: readonly StringPiece[],
|
|
235
|
+
from: number,
|
|
236
|
+
to: number,
|
|
237
|
+
): this {
|
|
238
|
+
let { end, start } = span;
|
|
239
|
+
let met = false;
|
|
240
|
+
let offset = 0;
|
|
241
|
+
for (const piece of pieces) {
|
|
242
|
+
const next = offset + piece.text.length;
|
|
243
|
+
if (next > from && offset < to) {
|
|
244
|
+
// A piece that is not the source character for character is indivisible.
|
|
245
|
+
const first = piece.verbatim ? piece.start + Math.max(0, from - offset) : piece.start;
|
|
246
|
+
start = met ? start : first;
|
|
247
|
+
end = piece.verbatim ? piece.start + Math.min(piece.text.length, to - offset) : piece.end;
|
|
248
|
+
met = true;
|
|
249
|
+
}
|
|
250
|
+
offset = next;
|
|
251
|
+
}
|
|
252
|
+
const kind = this.source.slice(start, end) === written ? "verbatim" : "atom";
|
|
253
|
+
return this.mapped(written, start, end, kind);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The end of a string's value, mapped when the file says where the string is.
|
|
258
|
+
*
|
|
259
|
+
* @param written What the value ends in, which is what the module says.
|
|
260
|
+
* @param span The string's token, when the read recorded one.
|
|
261
|
+
* @param value The value.
|
|
262
|
+
*/
|
|
263
|
+
ending(written: string, span: JxnSpan | undefined, value: string): this {
|
|
264
|
+
const pieces = span ? stringPieces(this.source, span, value) : [];
|
|
265
|
+
const from = value.length - written.length;
|
|
266
|
+
return span ? this.part(written, span, pieces, from, value.length) : this.add(written);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The module, ended. */
|
|
270
|
+
unit(extension: VirtualUnit["extension"] = ".ts"): VirtualUnit {
|
|
271
|
+
return { extension, spans: this.spans, text: this.text };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** One read of a file, and what was asked about it. */
|
|
276
|
+
interface Reading {
|
|
277
|
+
/** The document, as the JSON data it is. */
|
|
278
|
+
doc: JsonObject;
|
|
279
|
+
/** The definitions it holds, when it has `$defs`. */
|
|
280
|
+
defs: JsonObject | null;
|
|
281
|
+
/** The file each component of the document's project is, by its tag. */
|
|
282
|
+
components: Readonly<Record<string, string>>;
|
|
283
|
+
/** The index signature that closes every `State` of this document. */
|
|
284
|
+
index: string;
|
|
285
|
+
source: string;
|
|
286
|
+
specifier: string;
|
|
287
|
+
/** What `State` is called in the main module. */
|
|
288
|
+
stateName: string;
|
|
289
|
+
tokens: JxnTokens;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The `State` of a body inside a map: its scope's, and the row it is drawn for. */
|
|
293
|
+
interface RowView {
|
|
294
|
+
name: string;
|
|
295
|
+
row: Row;
|
|
296
|
+
scope: StateScope;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** A string body, and what the main module says of where it stands. */
|
|
300
|
+
interface Body {
|
|
301
|
+
definition: JsonObject;
|
|
302
|
+
/** The type of the event it is given. */
|
|
303
|
+
event: string;
|
|
304
|
+
pointer: string;
|
|
305
|
+
/** The interface its `state` is, by the name the main module exports it under. */
|
|
306
|
+
state: string;
|
|
307
|
+
text: string;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** A `${}` string, and what the main module says of where it stands. */
|
|
311
|
+
interface Template {
|
|
312
|
+
/** Its token. */
|
|
313
|
+
span: JxnSpan;
|
|
314
|
+
/** The interface its `state` is, by the name the main module exports it under. */
|
|
315
|
+
state: string;
|
|
316
|
+
text: string;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** A `$ref` into `state` or a row, and what the main module says of where it stands. */
|
|
320
|
+
interface Reference extends RefSite {
|
|
321
|
+
/** The token of the string it is written as. */
|
|
322
|
+
span: JxnSpan;
|
|
323
|
+
/** The interface it reads, by the name the main module exports it under. */
|
|
324
|
+
state: string;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** One value an element with a component's tag passes as `$props`. */
|
|
328
|
+
interface Prop {
|
|
329
|
+
key: string;
|
|
330
|
+
/** Where the value is. */
|
|
331
|
+
pointer: string;
|
|
332
|
+
tag: string;
|
|
333
|
+
value: JsonValue | undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** A `.jxn` file a document's `$elements` names. */
|
|
337
|
+
interface DeclaredElement {
|
|
338
|
+
/** The token of the string it is written as. */
|
|
339
|
+
span: JxnSpan | undefined;
|
|
340
|
+
specifier: string;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** What a document's elements declare, in the order the main module writes it. */
|
|
344
|
+
interface Layout {
|
|
345
|
+
bodies: Body[];
|
|
346
|
+
/** What each scope's interface is called. The root's is `State`. */
|
|
347
|
+
names: Map<StateScope, string>;
|
|
348
|
+
props: Prop[];
|
|
349
|
+
refs: Reference[];
|
|
350
|
+
templates: Template[];
|
|
351
|
+
views: RowView[];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The export a `state` entry names in one of the document's own modules, or null. */
|
|
355
|
+
function exportOf(entry: JsonValue | undefined, key: string, ref: string): string | null {
|
|
356
|
+
if (!isJsonObject(entry) || entry.$src !== ref) {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
const serves = ref === SERVER_SCRIPT_REF ? isServerFnDef(entry) : isFunctionDef(entry);
|
|
360
|
+
const name = isString(entry.$export) ? entry.$export : key;
|
|
361
|
+
// An export named by a string literal is legal and rare, and is left to the entry's own shape.
|
|
362
|
+
return serves && IDENTIFIER.test(name) ? name : null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The token an entry's export was written as: its `$export` when it has one, or its own name. */
|
|
366
|
+
function exportToken(reading: Reading, pointer: string): JxnSpan | undefined {
|
|
367
|
+
return reading.tokens.values.get(`${pointer}/$export`) ?? reading.tokens.names.get(pointer);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Whether the document has a module of its own on one side. */
|
|
371
|
+
function hasScript(doc: JsonObject, side: "client" | "server"): boolean {
|
|
372
|
+
return isJsonObject(doc.$script) && isString(doc.$script[side]);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* What the main module declares before its interfaces when an entry is an export under its own
|
|
377
|
+
* name.
|
|
378
|
+
*
|
|
379
|
+
* Written `(export)double`, one word is both the entry and the export, so the two have to be one
|
|
380
|
+
* name to an editor or a rename started at either leaves the other behind. A property a mapped type
|
|
381
|
+
* takes from the module IS the export, to the language service: renaming `state.double` in a body
|
|
382
|
+
* reaches the function, and renaming the function reaches the body. An export the script does not
|
|
383
|
+
* have is `any`, so it is one error at the entry and none at each place the entry is read.
|
|
384
|
+
*/
|
|
385
|
+
const EXPORTED = [
|
|
386
|
+
"type $exportedAs<V, R> = R extends 1 ? (V extends (...args: any[]) => infer X ? X : any) : R extends 2 ? (V extends (...args: infer A) => infer X ? (...args: A) => Promise<Awaited<X>> : any) : V;",
|
|
387
|
+
"type $exported<T, K extends keyof T, R> = { [P in K]: $exportedAs<T[P], R> };",
|
|
388
|
+
"type $export<T, K extends PropertyKey, R, _At> = $exported<T, Extract<keyof T, K>, R> & { [P in Exclude<K, keyof T>]: any };",
|
|
389
|
+
"",
|
|
390
|
+
].join("\n");
|
|
391
|
+
|
|
392
|
+
/** How an entry reads the export it names: as it is, as what it returns, or as what it resolves to. */
|
|
393
|
+
function exportReading(entry: JsonObject, role: FunctionRole | undefined): 0 | 1 | 2 {
|
|
394
|
+
if (role === "computed") {
|
|
395
|
+
return 1;
|
|
396
|
+
}
|
|
397
|
+
// A lazy entry imports on its first call, so what it returns is a promise (§5.3 4d).
|
|
398
|
+
return entry.$lazy === true ? 2 : 0;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** An entry of a scope that is an export of the client script under the entry's own name. */
|
|
402
|
+
interface LinkedExport {
|
|
403
|
+
entry: JsonObject;
|
|
404
|
+
key: string;
|
|
405
|
+
role: FunctionRole | undefined;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The entries of a scope that are each one word for an entry and an export. An entry that says its
|
|
410
|
+
* export apart, even under the same name, wrote two words and each is renamed alone.
|
|
411
|
+
*/
|
|
412
|
+
function linkedExports(reading: Reading, scope: StateScope): LinkedExport[] {
|
|
413
|
+
return membersOf(scope).own.flatMap(({ entry, key, role }) => {
|
|
414
|
+
const apart = reading.tokens.values.has(`${scope.pointer}/state/${token(key)}/$export`);
|
|
415
|
+
const linked = isJsonObject(entry) && !apart && exportOf(entry, key, CLIENT_SCRIPT_REF) === key;
|
|
416
|
+
return linked ? [{ entry, key, role }] : [];
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Write what an interface extends for one such entry. The word is joined to the one place that
|
|
422
|
+
* names the export, inside a type nothing reads until the interface is checked: an export whose own
|
|
423
|
+
* type reads `State` would otherwise be asked for while `State` is still being put together.
|
|
424
|
+
*/
|
|
425
|
+
function writeLinkedExport(out: Writer, reading: Reading, pointer: string, linked: LinkedExport) {
|
|
426
|
+
const module = `typeof import(${JSON.stringify(reading.specifier)})`;
|
|
427
|
+
const reads = exportReading(linked.entry, linked.role);
|
|
428
|
+
out.add(`$export<${module}, ${JSON.stringify(linked.key)}, ${reads}, { at: ${module}.`);
|
|
429
|
+
return out.name(linked.key, reading.tokens.names.get(pointer)).add(" }>");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Write the type of an entry that names an export of the client script. */
|
|
433
|
+
function writeClientExport(
|
|
434
|
+
out: Writer,
|
|
435
|
+
reading: Reading,
|
|
436
|
+
pointer: string,
|
|
437
|
+
entry: JsonObject,
|
|
438
|
+
name: string,
|
|
439
|
+
role: FunctionRole | undefined,
|
|
440
|
+
): void {
|
|
441
|
+
const open = `typeof import(${JSON.stringify(reading.specifier)}).`;
|
|
442
|
+
const span = exportToken(reading, pointer);
|
|
443
|
+
/* Each form names the export once, so one token carries it, and none constrains it: a constraint
|
|
444
|
+
that fails is reported on text the author did not write. */
|
|
445
|
+
if (role === "computed") {
|
|
446
|
+
out.add(`(${open}`).name(name, span).add(") extends (...args: any[]) => infer R ? R : any");
|
|
447
|
+
} else if (entry.$lazy === true) {
|
|
448
|
+
// A lazy entry imports on its first call, so what it returns is a promise (§5.3 4d).
|
|
449
|
+
out
|
|
450
|
+
.add(`(${open}`)
|
|
451
|
+
.name(name, span)
|
|
452
|
+
.add(") extends (...args: infer A) => infer R ? (...args: A) => Promise<Awaited<R>> : any");
|
|
453
|
+
} else {
|
|
454
|
+
out.add(open).name(name, span);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Write a type, each name in it beside where the document wrote it. The definition a `$ref` ends in
|
|
460
|
+
* is the end of that reference's string, so renaming a definition rewrites every reference to it.
|
|
461
|
+
*/
|
|
462
|
+
function writeParts(out: Writer, reading: Reading, parts: readonly TypePart[]): void {
|
|
463
|
+
const { tokens } = reading;
|
|
464
|
+
for (const part of parts) {
|
|
465
|
+
if (isString(part)) {
|
|
466
|
+
out.add(part);
|
|
467
|
+
} else if (part.kind === "text") {
|
|
468
|
+
out.value(part.written, tokens.values.get(part.pointer));
|
|
469
|
+
} else if (part.kind === "member") {
|
|
470
|
+
out.name(part.name, tokens.names.get(part.pointer), part.written);
|
|
471
|
+
} else {
|
|
472
|
+
// Written `(ref)"…"` the string is the member's own value, and in a block it is its `$ref`.
|
|
473
|
+
const span = tokens.values.get(part.pointer) ?? tokens.values.get(`${part.pointer}/$ref`);
|
|
474
|
+
out.ending(part.written, span, `#/${DEFINITIONS}/${part.name}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Write one scope's interface: the root's `State`, or a nested element's under the one around it. */
|
|
480
|
+
function writeScope(out: Writer, reading: Reading, layout: Layout, scope: StateScope): void {
|
|
481
|
+
const { tokens } = reading;
|
|
482
|
+
const { own, shadowed } = membersOf(scope);
|
|
483
|
+
const around = scope.parent ? layout.names.get(scope.parent) : undefined;
|
|
484
|
+
/* It extends the one around it, so a member declared there is the same member here and renaming
|
|
485
|
+
it in one place renames it in both. That holds where it declares one over it too, because it
|
|
486
|
+
extends that one less the names it declares itself (see `membersOf`). */
|
|
487
|
+
const linked = linkedExports(reading, scope);
|
|
488
|
+
out.add(`export interface ${layout.names.get(scope)}`);
|
|
489
|
+
out.add(around === undefined ? "" : ` extends ${heritageOf(around, shadowed)}`);
|
|
490
|
+
for (const [index, one] of linked.entries()) {
|
|
491
|
+
out.add(index > 0 || around !== undefined ? ",\n " : "\n extends ");
|
|
492
|
+
writeLinkedExport(out, reading, `${scope.pointer}/state/${token(one.key)}`, one);
|
|
493
|
+
}
|
|
494
|
+
out.add(" {\n");
|
|
495
|
+
const served = hasScript(reading.doc, "server");
|
|
496
|
+
for (const { entry, key, role } of own) {
|
|
497
|
+
const pointer = `${scope.pointer}/state/${token(key)}`;
|
|
498
|
+
if (served && exportOf(entry, key, SERVER_SCRIPT_REF) !== null) {
|
|
499
|
+
// The server module says what this is, because only it can see the function.
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (linked.some((one) => one.key === key)) {
|
|
503
|
+
// The interface extends what the export is, and declares nothing over it.
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
out.add(" ").name(key, tokens.names.get(pointer), propertyName(key)).add(": ");
|
|
507
|
+
const exported = exportOf(entry, key, CLIENT_SCRIPT_REF);
|
|
508
|
+
if (exported !== null && isJsonObject(entry)) {
|
|
509
|
+
writeClientExport(out, reading, pointer, entry, exported, role);
|
|
510
|
+
} else {
|
|
511
|
+
writeParts(out, reading, entryParts(entry, role, { defs: reading.defs !== null, pointer }));
|
|
512
|
+
}
|
|
513
|
+
out.add(";\n");
|
|
514
|
+
}
|
|
515
|
+
out.add(around === undefined ? ` ${reading.index}\n}\n` : "}\n");
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Write the main module: the client script, then `State` and each interface under it. */
|
|
519
|
+
function writeMain(reading: Reading, layout: Layout): VirtualUnit {
|
|
520
|
+
const { doc, tokens } = reading;
|
|
521
|
+
const out = new Writer(reading.source);
|
|
522
|
+
const client = isJsonObject(doc.$script) ? doc.$script.client : undefined;
|
|
523
|
+
if (isString(client)) {
|
|
524
|
+
out.value(client, tokens.values.get("/$script/client"));
|
|
525
|
+
out.add(client.endsWith("\n") ? "" : "\n");
|
|
526
|
+
}
|
|
527
|
+
const scopes = [...layout.names.keys()];
|
|
528
|
+
out.add(scopes.some((scope) => linkedExports(reading, scope).length > 0) ? EXPORTED : "");
|
|
529
|
+
out.add(scopes.some((scope) => membersOf(scope).shadowed.length > 0) ? `${EXCEPT}\n` : "");
|
|
530
|
+
for (const scope of scopes) {
|
|
531
|
+
writeScope(out, reading, layout, scope);
|
|
532
|
+
}
|
|
533
|
+
const nameOf = (scope: StateScope): string => layout.names.get(scope) ?? "any";
|
|
534
|
+
for (const view of layout.views) {
|
|
535
|
+
out.add(`export interface ${view.name} extends ${nameOf(view.scope)} {\n`);
|
|
536
|
+
out.add(` $map: { item: ${itemType(view.row, nameOf)}; index: number };\n}\n`);
|
|
537
|
+
}
|
|
538
|
+
if (reading.defs) {
|
|
539
|
+
writeParts(out, reading, definitionParts(reading.defs, true));
|
|
540
|
+
out.add("\n");
|
|
541
|
+
}
|
|
542
|
+
/* Every document says which element it defines, a page that defines none included, so that a
|
|
543
|
+
document naming this one among its `$elements` can always ask. */
|
|
544
|
+
const tag = doc.tagName;
|
|
545
|
+
const defined = isComponentTag(tag) ? ` ${JSON.stringify(tag)}: ${reading.stateName} ` : "";
|
|
546
|
+
return out.add(`export interface $elements {${defined}}\n`).unit();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Write the server module: the server script, then what its functions add to each interface. */
|
|
550
|
+
function writeServer(reading: Reading, layout: Layout, script: string): VirtualUnit {
|
|
551
|
+
const { tokens } = reading;
|
|
552
|
+
const out = new Writer(reading.source);
|
|
553
|
+
out.value(script, tokens.values.get("/$script/server"));
|
|
554
|
+
out.add(script.endsWith("\n") ? "" : "\n");
|
|
555
|
+
out.add(`declare module ${JSON.stringify(reading.specifier)} {\n`);
|
|
556
|
+
for (const [scope, name] of layout.names) {
|
|
557
|
+
const served = Object.entries(stateOf(scope.node) ?? {}).flatMap(([key, entry]) => {
|
|
558
|
+
const exported = exportOf(entry, key, SERVER_SCRIPT_REF);
|
|
559
|
+
return exported === null ? [] : [{ exported, key }];
|
|
560
|
+
});
|
|
561
|
+
// The root's is written whatever it holds, so the module always says what it is for.
|
|
562
|
+
if (served.length === 0 && scope.parent) {
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
out.add(` interface ${name} {\n`);
|
|
566
|
+
for (const { exported, key } of served) {
|
|
567
|
+
const pointer = `${scope.pointer}/state/${token(key)}`;
|
|
568
|
+
out.add(" ").name(key, tokens.names.get(pointer), propertyName(key));
|
|
569
|
+
out.add(": typeof ").name(exported, exportToken(reading, pointer));
|
|
570
|
+
out.add(" extends (...args: any[]) => infer R ? Awaited<R> | null : any;\n");
|
|
571
|
+
}
|
|
572
|
+
out.add(" }\n");
|
|
573
|
+
}
|
|
574
|
+
// A module with no import and no export is a script, and a script cannot augment a module.
|
|
575
|
+
return out.add("}\nexport {};\n").unit();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Whether a tag is one a component can be registered under, which a name with a hyphen is. */
|
|
579
|
+
function isComponentTag(tag: JsonValue | undefined): tag is string {
|
|
580
|
+
return isString(tag) && tag.includes("-") && !tag.includes("${");
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Whether a value is a function written as a string body, which is what a body module holds. */
|
|
584
|
+
function isStringBody(value: JsonValue | undefined): value is JsonObject & { body: string } {
|
|
585
|
+
return isFunctionDef(value) && isString(value.body) && value.$src === undefined;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Read what a document's elements declare: its scopes, the rows its bodies read, and its bodies. */
|
|
589
|
+
function layoutOf(reading: Reading): Layout {
|
|
590
|
+
const { doc, stateName } = reading;
|
|
591
|
+
const layout: Layout = {
|
|
592
|
+
bodies: [],
|
|
593
|
+
names: new Map(),
|
|
594
|
+
props: [],
|
|
595
|
+
refs: [],
|
|
596
|
+
templates: [],
|
|
597
|
+
views: [],
|
|
598
|
+
};
|
|
599
|
+
const { values } = reading.tokens;
|
|
600
|
+
const bound = boundEvents(doc);
|
|
601
|
+
let declared = 0;
|
|
602
|
+
/** What a scope's interface is called, decided the first time it is met. */
|
|
603
|
+
const scopeName = (scope: StateScope): string => {
|
|
604
|
+
let name = layout.names.get(scope);
|
|
605
|
+
if (name === undefined) {
|
|
606
|
+
declared += scope.parent ? 1 : 0;
|
|
607
|
+
name = scope.parent ? `${stateName}$${declared}` : stateName;
|
|
608
|
+
layout.names.set(scope, name);
|
|
609
|
+
}
|
|
610
|
+
return name;
|
|
611
|
+
};
|
|
612
|
+
/** The interface a body at a site reads, declared the first time one does. */
|
|
613
|
+
const stateAt = (site: Site): string => {
|
|
614
|
+
const { row, scope } = site;
|
|
615
|
+
if (!row || !readsRow(scope)) {
|
|
616
|
+
return scopeName(scope);
|
|
617
|
+
}
|
|
618
|
+
let view = layout.views.find((made) => made.row === row && made.scope === scope);
|
|
619
|
+
if (!view) {
|
|
620
|
+
declared += 1;
|
|
621
|
+
view = { name: `${stateName}$${declared}`, row, scope };
|
|
622
|
+
layout.views.push(view);
|
|
623
|
+
}
|
|
624
|
+
return view.name;
|
|
625
|
+
};
|
|
626
|
+
eachSite(doc, (node, pointer, site) => {
|
|
627
|
+
// Each scope is met first at its own element, so the root is first and a parent before a child.
|
|
628
|
+
scopeName(site.scope);
|
|
629
|
+
for (const [key, entry] of Object.entries(stateOf(node) ?? {})) {
|
|
630
|
+
if (isStringBody(entry)) {
|
|
631
|
+
const at = `${pointer}/state/${token(key)}`;
|
|
632
|
+
layout.bodies.push({
|
|
633
|
+
definition: entry,
|
|
634
|
+
event: bound.get(at) ?? "any",
|
|
635
|
+
pointer: at,
|
|
636
|
+
state: stateAt(site),
|
|
637
|
+
text: entry.body,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
for (const [key, entry] of Object.entries(node)) {
|
|
642
|
+
if (key.startsWith("on") && isStringBody(entry)) {
|
|
643
|
+
layout.bodies.push({
|
|
644
|
+
definition: entry,
|
|
645
|
+
event: eventType(node, key, site),
|
|
646
|
+
pointer: `${pointer}/${token(key)}`,
|
|
647
|
+
state: stateAt(site),
|
|
648
|
+
text: entry.body,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
const { $props: given, tagName: tag } = node;
|
|
653
|
+
if (isComponentTag(tag) && isJsonObject(given)) {
|
|
654
|
+
for (const [key, value] of Object.entries(given)) {
|
|
655
|
+
layout.props.push({ key, pointer: `${pointer}/$props/${token(key)}`, tag, value });
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/* One the file does not place is left out: an error in it would be shown at a place in a
|
|
659
|
+
module nobody wrote, which is worse than not being told. */
|
|
660
|
+
const said = expressionsAt(node, pointer, site);
|
|
661
|
+
for (const template of said.templates) {
|
|
662
|
+
const span = values.get(template.pointer);
|
|
663
|
+
if (span) {
|
|
664
|
+
layout.templates.push({ span, state: stateAt(site), text: template.text });
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
for (const ref of said.refs) {
|
|
668
|
+
// Written `(ref)name "…"` the string is the member's own, and in a block it is its `$ref`.
|
|
669
|
+
const span = values.get(ref.pointer) ?? values.get(`${ref.pointer}/$ref`);
|
|
670
|
+
if (span) {
|
|
671
|
+
const row = ref.path[0]?.key === "$map";
|
|
672
|
+
layout.refs.push({ ...ref, span, state: row ? stateAt(site) : scopeName(site.scope) });
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
return layout;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** `$defs.` where it begins a name, in TypeScript an author wrote as a parameter's type. */
|
|
680
|
+
const NAMES_DEFINITION = /(?<![\w$.])\$defs\./u;
|
|
681
|
+
|
|
682
|
+
/** Whether a type names a definition of the document: by a `$ref`, or in the text it is. */
|
|
683
|
+
function namesDefinition(parts: readonly TypePart[]): boolean {
|
|
684
|
+
return parts.some(
|
|
685
|
+
(part) =>
|
|
686
|
+
!isString(part) &&
|
|
687
|
+
(part.kind === "ref" || (part.kind === "text" && NAMES_DEFINITION.test(part.written))),
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Write the module for one body: the function it is the inside of. */
|
|
692
|
+
function writeBody(reading: Reading, body: Body): VirtualUnit {
|
|
693
|
+
const { tokens } = reading;
|
|
694
|
+
const out = new Writer(reading.source);
|
|
695
|
+
const state = body.state === "State" ? "State" : `${body.state} as State`;
|
|
696
|
+
const declared = declaredParameters(body.definition, {
|
|
697
|
+
defs: reading.defs !== null,
|
|
698
|
+
pointer: body.pointer,
|
|
699
|
+
});
|
|
700
|
+
/* A type a parameter declares may name a definition, which only the main module holds. It is
|
|
701
|
+
imported where one does and nowhere else, since a project that reports an unused import would
|
|
702
|
+
report this one on a line the author did not write. */
|
|
703
|
+
const defined = declared.some((parameter) => namesDefinition(parameter.type ?? []));
|
|
704
|
+
const imported = defined && reading.defs ? `${state}, ${DEFINITIONS}` : state;
|
|
705
|
+
out.add(`import type { ${imported} } from ${JSON.stringify(reading.specifier)};\n`);
|
|
706
|
+
const params = declared.map((parameter) => parameter.name);
|
|
707
|
+
/* The event a handler receives is bound to a name only when the entry declares one, though the
|
|
708
|
+
runtime passes it and `window.event` holds it. Not declaring it would call a working handler
|
|
709
|
+
an error, so it is declared wherever it is not a parameter. */
|
|
710
|
+
const event = eventLines(body.event, params);
|
|
711
|
+
out.add(event.head);
|
|
712
|
+
out.add("export async function body(this: any");
|
|
713
|
+
for (const [index, parameter] of declared.entries()) {
|
|
714
|
+
/* `state` is first whether or not the entry names it, and wherever it names it, so a name is
|
|
715
|
+
not where it was written. A bare name is the entry's own token, and one written as an object
|
|
716
|
+
is that object's `name`. */
|
|
717
|
+
const at = `${body.pointer}/parameters/${parameter.at}`;
|
|
718
|
+
const span = tokens.values.get(at) ?? tokens.values.get(`${at}/name`);
|
|
719
|
+
out.add(", ").name(parameter.name, span).add(": ");
|
|
720
|
+
if (index > 0 && parameter.type !== null) {
|
|
721
|
+
// A parameter that declares a type is that type, the event's place included (§23.3).
|
|
722
|
+
writeParts(out, reading, parameter.type);
|
|
723
|
+
} else {
|
|
724
|
+
// A handler is called with its state and the event, in that order (§5.3 4d).
|
|
725
|
+
out.add(index === 0 ? "State" : index === 1 ? event.parameter : "any");
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
/* A project that reports unused parameters would report these on a line the author did not
|
|
729
|
+
write, for every body that does not happen to read one. */
|
|
730
|
+
const named = params.filter((name) => IDENTIFIER.test(name));
|
|
731
|
+
out.add(`) {\nvoid [${named.join(", ")}];\n`);
|
|
732
|
+
out.value(body.text, tokens.values.get(`${body.pointer}/body`));
|
|
733
|
+
return out.add("\n}\n").unit();
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** What every template module says before the template: the names the runtime gives one. */
|
|
737
|
+
const TEMPLATE_HEAD = [
|
|
738
|
+
"/**",
|
|
739
|
+
" * @param {State} state",
|
|
740
|
+
' * @param {State["$map"]} $map',
|
|
741
|
+
' * @param {State["$map"] extends { item: infer I } ? I : any} item',
|
|
742
|
+
' * @param {State["$map"] extends { index: infer I } ? I : any} index',
|
|
743
|
+
' * @param {State["$site"]} $site',
|
|
744
|
+
' * @param {State["$page"]} $page',
|
|
745
|
+
" */",
|
|
746
|
+
"export function template(state, $map, item, index, $site, $page) {",
|
|
747
|
+
// A template that does not read one of them is not an error.
|
|
748
|
+
"void [state, $map, item, index, $site, $page];",
|
|
749
|
+
"return ",
|
|
750
|
+
].join("\n");
|
|
751
|
+
|
|
752
|
+
/** Write the module for one template: the function it is the value of. */
|
|
753
|
+
function writeTemplate(reading: Reading, template: Template): VirtualUnit {
|
|
754
|
+
const { span } = template;
|
|
755
|
+
const out = new Writer(reading.source);
|
|
756
|
+
const imported = template.state === "State" ? "State" : `${template.state} as State`;
|
|
757
|
+
// Without this line a JavaScript module is not checked at all.
|
|
758
|
+
out.add("// @ts-check\n");
|
|
759
|
+
out.add(`/** @import { ${imported} } from ${JSON.stringify(reading.specifier)} */\n`);
|
|
760
|
+
out.add(TEMPLATE_HEAD);
|
|
761
|
+
/* The quotes stand for the backticks, so a template that does not end is reported on the string
|
|
762
|
+
it is, and not at the end of a module nobody wrote. */
|
|
763
|
+
out.mapped("`", span.start, span.start + 1, "atom");
|
|
764
|
+
out.value(template.text, span);
|
|
765
|
+
return out.mapped("`;\n}\n", span.end - 1, span.end, "atom").unit(".js");
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** What the references module declares when it checks a handler: what a binding calls one with. */
|
|
769
|
+
const BOUND = "declare function bound<E>(handler: (state: any, event: E) => unknown): void;\n";
|
|
770
|
+
|
|
771
|
+
/** Whether a reference is the handler of the `on*` member it is written at. */
|
|
772
|
+
function bindsHandler(ref: Reference): boolean {
|
|
773
|
+
return ref.binding !== null && ref.prefix === "#/state/" && ref.path.length === 1;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Write what a reference reads: its path off the interface it stands in. */
|
|
777
|
+
function writePath(out: Writer, ref: Reference, receiver: string): void {
|
|
778
|
+
const { span } = ref;
|
|
779
|
+
const pieces = stringPieces(out.source, span, ref.ref);
|
|
780
|
+
for (const [index, { end, key, start }] of ref.path.entries()) {
|
|
781
|
+
const plain = IDENTIFIER.test(key);
|
|
782
|
+
const member = plain ? key : `[${JSON.stringify(key)}]`;
|
|
783
|
+
if (index > 0) {
|
|
784
|
+
// A token that matches nothing reads as nothing (§7.1), so each step after the first asks.
|
|
785
|
+
out.part("?.", span, pieces, start - 1, start).part(member, span, pieces, start, end);
|
|
786
|
+
} else if (ref.prefix === "") {
|
|
787
|
+
out.part(`${receiver}${plain ? "." : ""}${member}`, span, pieces, start, end);
|
|
788
|
+
} else {
|
|
789
|
+
out.part(`${receiver}${plain ? "." : ""}`, span, pieces, 0, ref.prefix.length);
|
|
790
|
+
out.part(member, span, pieces, start, end);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** Write one reference that is read for itself: a value, or the handler an `on*` member binds. */
|
|
796
|
+
function writeReference(out: Writer, ref: Reference, receiver: string): void {
|
|
797
|
+
const { binding, site } = ref;
|
|
798
|
+
const handler = binding !== null && bindsHandler(ref);
|
|
799
|
+
out.add(handler ? `bound<${eventType(binding.element, binding.key, site)}>(` : "void ");
|
|
800
|
+
writePath(out, ref, receiver);
|
|
801
|
+
out.add(handler ? ");\n" : ";\n");
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** A path from one document to another beside or below a folder above it, as `$elements` writes one. */
|
|
805
|
+
const RELATIVE_NOTATION = /^\.{1,2}\/.*\.jxn$/u;
|
|
806
|
+
|
|
807
|
+
/** The `.jxn` files a document's `$elements` names. A package, or a `.json` file, says no types. */
|
|
808
|
+
function declaredElements(reading: Reading): DeclaredElement[] {
|
|
809
|
+
const { doc, tokens } = reading;
|
|
810
|
+
const entries = Array.isArray(doc.$elements) ? doc.$elements : [];
|
|
811
|
+
return entries.flatMap((entry, index) => {
|
|
812
|
+
if (!isRef(entry) || !RELATIVE_NOTATION.test(entry.$ref)) {
|
|
813
|
+
return [];
|
|
814
|
+
}
|
|
815
|
+
// Written `(ref)"…"` the string is the item's own, and in a block it is its `$ref`.
|
|
816
|
+
const at = `/$elements/${index}`;
|
|
817
|
+
return [
|
|
818
|
+
{ span: tokens.values.get(at) ?? tokens.values.get(`${at}/$ref`), specifier: entry.$ref },
|
|
819
|
+
];
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* What the references module declares when a document passes props: what a tag may be given, which
|
|
825
|
+
* is any of its component's `State`, or anything at all for a tag no document read here defines. A
|
|
826
|
+
* prop may be empty, because a reference that finds nothing passes nothing (`spec.md` §7.1).
|
|
827
|
+
*/
|
|
828
|
+
const PROPS = [
|
|
829
|
+
"type Props<S> = { [K in keyof S]?: S[K] | null | undefined };",
|
|
830
|
+
"declare function props<T extends string>(",
|
|
831
|
+
" given: T extends keyof Elements ? Props<Elements[T]> : any,",
|
|
832
|
+
"): void;",
|
|
833
|
+
"declare const unchecked: any;",
|
|
834
|
+
"",
|
|
835
|
+
].join("\n");
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Write which documents define the elements this one uses: itself, each `.jxn` file its `$elements`
|
|
839
|
+
* names, and each component of its project that an instance names by its tag.
|
|
840
|
+
*/
|
|
841
|
+
function writeElements(
|
|
842
|
+
out: Writer,
|
|
843
|
+
reading: Reading,
|
|
844
|
+
layout: Layout,
|
|
845
|
+
declared: readonly DeclaredElement[],
|
|
846
|
+
): void {
|
|
847
|
+
const from = new Map<string, JxnSpan | undefined>([[reading.specifier, undefined]]);
|
|
848
|
+
for (const { span, specifier } of declared) {
|
|
849
|
+
from.set(specifier, span);
|
|
850
|
+
}
|
|
851
|
+
for (const { tag } of layout.props) {
|
|
852
|
+
const specifier = reading.components[tag];
|
|
853
|
+
if (specifier !== undefined && !from.has(specifier)) {
|
|
854
|
+
from.set(specifier, undefined);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
const names = [...from].map(([specifier, span], index) => {
|
|
858
|
+
// A file that is not there is an error at the entry that names it.
|
|
859
|
+
out.add(`import type { $elements as e${index} } from `);
|
|
860
|
+
out.whole(JSON.stringify(specifier), span).add(";\n");
|
|
861
|
+
return `e${index}`;
|
|
862
|
+
});
|
|
863
|
+
// Exported, so that a document which names components and gives them nothing leaves none unused.
|
|
864
|
+
out.add(`export type Elements = ${names.join(" & ")};\n`);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Write one prop: its name and its value, held against what the component its tag names takes.
|
|
869
|
+
*
|
|
870
|
+
* @param out The module.
|
|
871
|
+
* @param reading The file.
|
|
872
|
+
* @param prop The prop.
|
|
873
|
+
* @param read Writes the path the prop's value reads, when it is a reference into `state`.
|
|
874
|
+
*/
|
|
875
|
+
function writeProp(out: Writer, reading: Reading, prop: Prop, read: (() => void) | null): void {
|
|
876
|
+
const { key, pointer, value } = prop;
|
|
877
|
+
const { tokens } = reading;
|
|
878
|
+
out.add(`props<${JSON.stringify(prop.tag)}>({ `);
|
|
879
|
+
out.name(key, tokens.names.get(pointer), propertyName(key)).add(": ");
|
|
880
|
+
if (read) {
|
|
881
|
+
read();
|
|
882
|
+
} else if (isBoolean(value) || isNumber(value) || (isString(value) && !value.includes("${"))) {
|
|
883
|
+
out.whole(JSON.stringify(value), tokens.values.get(pointer));
|
|
884
|
+
} else {
|
|
885
|
+
/* A template is checked as the function it is, and what it makes is not known here. Anything
|
|
886
|
+
else (a list, an object, a reference to another document) is not held against a type. */
|
|
887
|
+
out.add("unchecked");
|
|
888
|
+
}
|
|
889
|
+
out.add(" });\n");
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Write the module for a document's references and the props it passes, or nothing when it has
|
|
894
|
+
* neither and names no component.
|
|
895
|
+
*/
|
|
896
|
+
function writeReferences(reading: Reading, layout: Layout): VirtualUnit[] {
|
|
897
|
+
const { props, refs } = layout;
|
|
898
|
+
const declared = declaredElements(reading);
|
|
899
|
+
if (refs.length === 0 && props.length === 0 && declared.length === 0) {
|
|
900
|
+
return [];
|
|
901
|
+
}
|
|
902
|
+
const out = new Writer(reading.source);
|
|
903
|
+
const states = [...new Set(refs.map((ref) => ref.state))];
|
|
904
|
+
if (states.length > 0) {
|
|
905
|
+
out.add(`import type { ${states.join(", ")} } from ${JSON.stringify(reading.specifier)};\n`);
|
|
906
|
+
}
|
|
907
|
+
if (props.length > 0 || declared.length > 0) {
|
|
908
|
+
writeElements(out, reading, layout, declared);
|
|
909
|
+
}
|
|
910
|
+
if (refs.some((ref) => bindsHandler(ref))) {
|
|
911
|
+
out.add(EVENT_GLOBALS).add(BOUND);
|
|
912
|
+
}
|
|
913
|
+
out.add(props.length > 0 ? PROPS : "");
|
|
914
|
+
for (const [index, state] of states.entries()) {
|
|
915
|
+
out.add(`declare const s${index}: ${state};\n`);
|
|
916
|
+
}
|
|
917
|
+
const receiver = (ref: Reference): string => `s${states.indexOf(ref.state)}`;
|
|
918
|
+
const given = new Set(props.map((prop) => prop.pointer));
|
|
919
|
+
for (const ref of refs) {
|
|
920
|
+
// One that is a prop is written as the prop's value, so that it is told of once.
|
|
921
|
+
if (!given.has(ref.pointer)) {
|
|
922
|
+
writeReference(out, ref, receiver(ref));
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
for (const prop of props) {
|
|
926
|
+
const ref = refs.find((made) => made.pointer === prop.pointer);
|
|
927
|
+
writeProp(out, reading, prop, ref ? () => writePath(out, ref, receiver(ref)) : null);
|
|
928
|
+
}
|
|
929
|
+
return [out.unit()];
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/** A file no module can be written around. */
|
|
933
|
+
function unreadable(problems: VirtualProblem[]): VirtualDocument {
|
|
934
|
+
return { main: { extension: ".ts", spans: [], text: EMPTY_MODULE }, problems, supplemental: [] };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Write the modules a checker reads for one `.jxn` file.
|
|
939
|
+
*
|
|
940
|
+
* @param source The file.
|
|
941
|
+
* @param options What its reader knows about where it stands.
|
|
942
|
+
* @returns The modules. A project file is an empty one, because it holds no code, and so is a file
|
|
943
|
+
* that is not Jx Notation, which is the one problem reported for it.
|
|
944
|
+
*/
|
|
945
|
+
export function generateVirtualDocument(
|
|
946
|
+
source: string,
|
|
947
|
+
options: VirtualDocumentOptions,
|
|
948
|
+
): VirtualDocument {
|
|
949
|
+
if (classifyJxn(source) === "project") {
|
|
950
|
+
return unreadable([]);
|
|
951
|
+
}
|
|
952
|
+
let read: ReturnType<typeof parseJxnWithTokens>;
|
|
953
|
+
try {
|
|
954
|
+
read = parseJxnWithTokens(source);
|
|
955
|
+
} catch (error) {
|
|
956
|
+
const problem = error instanceof JxnError ? error : new JxnError(String(error));
|
|
957
|
+
const offset = Math.min(problem.offset ?? 0, source.length);
|
|
958
|
+
return unreadable([
|
|
959
|
+
{ length: Math.min(1, source.length - offset), message: problem.reason, offset },
|
|
960
|
+
]);
|
|
961
|
+
}
|
|
962
|
+
// SAFETY: a document is JSON data; its types name what it holds.
|
|
963
|
+
const doc = read.document as JsonObject;
|
|
964
|
+
const scripts = isJsonObject(doc.$script) ? doc.$script : {};
|
|
965
|
+
const reading: Reading = {
|
|
966
|
+
components: options.outside?.components ?? {},
|
|
967
|
+
defs: definitionsOf(doc),
|
|
968
|
+
doc,
|
|
969
|
+
// SAFETY: a document is an element with `$` members beside it, and only `$layout` is read.
|
|
970
|
+
index: stateIndex(doc as JxElement, options.outside),
|
|
971
|
+
source,
|
|
972
|
+
specifier: options.specifier,
|
|
973
|
+
stateName:
|
|
974
|
+
isString(scripts.client) && DECLARES_STATE.test(scripts.client) ? FALLBACK_STATE : "State",
|
|
975
|
+
tokens: read.tokens,
|
|
976
|
+
};
|
|
977
|
+
const layout = layoutOf(reading);
|
|
978
|
+
const supplemental: VirtualUnit[] = [];
|
|
979
|
+
if (isString(scripts.server)) {
|
|
980
|
+
supplemental.push(writeServer(reading, layout, scripts.server));
|
|
981
|
+
}
|
|
982
|
+
for (const body of layout.bodies) {
|
|
983
|
+
supplemental.push(writeBody(reading, body));
|
|
984
|
+
}
|
|
985
|
+
for (const template of layout.templates) {
|
|
986
|
+
supplemental.push(writeTemplate(reading, template));
|
|
987
|
+
}
|
|
988
|
+
supplemental.push(...writeReferences(reading, layout));
|
|
989
|
+
return { main: writeMain(reading, layout), problems: [], supplemental };
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// The stretch of a file a position maps back to is a place in it, which `errors.ts` already names.
|
|
993
|
+
export type { JxnSpan } from "./errors.ts";
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* The span a position of a module is in. A span that ends there counts where none holds it, so the
|
|
997
|
+
* place just past a name or a line is still the author's.
|
|
998
|
+
*/
|
|
999
|
+
function spanHolding(unit: VirtualUnit, offset: number): VirtualSpan | undefined {
|
|
1000
|
+
let ending: VirtualSpan | undefined;
|
|
1001
|
+
for (const span of unit.spans) {
|
|
1002
|
+
if (span.virtualStart > offset) {
|
|
1003
|
+
break;
|
|
1004
|
+
}
|
|
1005
|
+
const end = span.virtualStart + span.virtualLength;
|
|
1006
|
+
if (offset < end) {
|
|
1007
|
+
return span;
|
|
1008
|
+
}
|
|
1009
|
+
if (offset === end) {
|
|
1010
|
+
ending = span;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
return ending;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** The last span that starts at or before a position of a module. */
|
|
1017
|
+
function spanBefore(unit: VirtualUnit, offset: number): VirtualSpan | undefined {
|
|
1018
|
+
let last: VirtualSpan | undefined;
|
|
1019
|
+
for (const span of unit.spans) {
|
|
1020
|
+
if (span.virtualStart > offset) {
|
|
1021
|
+
break;
|
|
1022
|
+
}
|
|
1023
|
+
last = span;
|
|
1024
|
+
}
|
|
1025
|
+
return last;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** One past the last character of the file a span stands for. */
|
|
1029
|
+
const writtenEnd = (span: VirtualSpan): number => span.originalStart + span.originalLength;
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Where a stretch of a module was written in the file, which is how a reader that asks a compiler
|
|
1033
|
+
* about the modules itself puts an answer back where the author can see it.
|
|
1034
|
+
*
|
|
1035
|
+
* @param unit The module.
|
|
1036
|
+
* @param start The stretch's first character, an offset in the module's text.
|
|
1037
|
+
* @param length How many characters it runs for. Nothing is read as one character.
|
|
1038
|
+
* @returns The stretch of the file. A stretch that starts in a `verbatim` span starts at the same
|
|
1039
|
+
* character of the file, and one that starts in an `atom` takes the whole of what the atom stands
|
|
1040
|
+
* for. It ends where its last character was written, or at the end of the last span it reaches
|
|
1041
|
+
* where that character is the module's own. Null where it starts in text the author did not
|
|
1042
|
+
* write.
|
|
1043
|
+
*/
|
|
1044
|
+
export function writtenRange(unit: VirtualUnit, start: number, length: number): JxnSpan | null {
|
|
1045
|
+
const first = spanHolding(unit, start);
|
|
1046
|
+
if (!first) {
|
|
1047
|
+
return null;
|
|
1048
|
+
}
|
|
1049
|
+
const from =
|
|
1050
|
+
first.kind === "atom" ? first.originalStart : first.originalStart + start - first.virtualStart;
|
|
1051
|
+
const lastAt = start + Math.max(length, 1) - 1;
|
|
1052
|
+
const last = spanBefore(unit, lastAt) ?? first;
|
|
1053
|
+
const inside = lastAt < last.virtualStart + last.virtualLength && last.kind === "verbatim";
|
|
1054
|
+
const to = inside ? last.originalStart + lastAt - last.virtualStart + 1 : writtenEnd(last);
|
|
1055
|
+
// Modules are not always written in the file's order, and a stretch cannot end before it starts.
|
|
1056
|
+
return { end: to < from ? Math.max(writtenEnd(first), from) : to, start: from };
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* The end of the last thing the author wrote at or before a position of a module. A compiler places
|
|
1061
|
+
* some syntax errors after the code that caused them (an unclosed brace is found where the module
|
|
1062
|
+
* closes it), and this is where such an error belongs in the file.
|
|
1063
|
+
*
|
|
1064
|
+
* @param unit The module.
|
|
1065
|
+
* @param offset A position in the module's text.
|
|
1066
|
+
* @returns The place in the file, or null where the author wrote nothing before the position.
|
|
1067
|
+
*/
|
|
1068
|
+
export function writtenBefore(unit: VirtualUnit, offset: number): number | null {
|
|
1069
|
+
const span = spanBefore(unit, offset);
|
|
1070
|
+
if (!span) {
|
|
1071
|
+
return null;
|
|
1072
|
+
}
|
|
1073
|
+
const into = offset - span.virtualStart;
|
|
1074
|
+
return span.kind === "verbatim" && into < span.virtualLength
|
|
1075
|
+
? span.originalStart + into
|
|
1076
|
+
: writtenEnd(span);
|
|
1077
|
+
}
|