@transclude/core 0.1.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/LICENSE +21 -0
- package/README.md +121 -0
- package/bin/build.js +469 -0
- package/bin/check.js +78 -0
- package/bin/dev.js +348 -0
- package/bin/release.js +176 -0
- package/bin/serve.bun.js +15 -0
- package/bin/serve.deno.js +15 -0
- package/bin/serve.js +12 -0
- package/editor/server.js +172 -0
- package/editor/vscode/extension.js +49 -0
- package/editor/vscode/package.json +32 -0
- package/editor/vscode/syntaxes/transclude.injection.json +41 -0
- package/package.json +82 -0
- package/src/address.js +183 -0
- package/src/app.js +492 -0
- package/src/cache.js +137 -0
- package/src/compiler/bind.js +496 -0
- package/src/compiler/codegen.js +1061 -0
- package/src/compiler/expr.js +221 -0
- package/src/compiler/index.js +964 -0
- package/src/compiler/interp.js +82 -0
- package/src/compiler/script.js +620 -0
- package/src/compiler/shim.js +756 -0
- package/src/compiler/sourcemap.js +140 -0
- package/src/compiler/types.js +163 -0
- package/src/compress.js +104 -0
- package/src/cookies.js +157 -0
- package/src/csp.js +192 -0
- package/src/document.js +604 -0
- package/src/extract.js +339 -0
- package/src/feed.js +194 -0
- package/src/include.js +89 -0
- package/src/lookup.js +49 -0
- package/src/negotiate.js +95 -0
- package/src/plugin.js +423 -0
- package/src/pool.js +29 -0
- package/src/precache.js +68 -0
- package/src/production.js +159 -0
- package/src/project.js +110 -0
- package/src/proxy.js +319 -0
- package/src/public-files.js +77 -0
- package/src/rewrite.js +281 -0
- package/src/routes.js +199 -0
- package/src/runtime/index.js +1345 -0
- package/src/server.js +183 -0
- package/src/sitemap.js +124 -0
- package/src/static-cache.js +170 -0
- package/src/typecheck.js +492 -0
- package/src/worker.js +87 -0
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
// Turns a .html file into JavaScript that means the same thing, so tsc can
|
|
2
|
+
// check it.
|
|
3
|
+
//
|
|
4
|
+
// JavaScript rather than TypeScript, on purpose. A JSDoc `@type` in the
|
|
5
|
+
// author's own `<script props>` is honoured in a .js file and silently ignored
|
|
6
|
+
// in a .ts one. The job is to check what the author wrote, so the shim speaks the
|
|
7
|
+
// same language they do. The scaffolding uses JSDoc too.
|
|
8
|
+
//
|
|
9
|
+
// The shim is never written to disk and never run. It exists only to be type
|
|
10
|
+
// checked. Its one hard requirement is that every position tsc reports can
|
|
11
|
+
// be traced back to the .html file, so it is assembled from chunks: text we
|
|
12
|
+
// generated (unmapped) and text copied verbatim from the source (mapped). A
|
|
13
|
+
// diagnostic offset lands inside one chunk, and a verbatim chunk knows where it
|
|
14
|
+
// came from.
|
|
15
|
+
|
|
16
|
+
import { parse, parseExpressionAt } from 'acorn';
|
|
17
|
+
import { childrenOf } from './codegen.js';
|
|
18
|
+
import { splitInterpolations } from './interp.js';
|
|
19
|
+
import { splitBlocks } from './index.js';
|
|
20
|
+
import { planLift } from './script.js';
|
|
21
|
+
import { ACTION_METHODS } from '../document.js';
|
|
22
|
+
import { ENDPOINT_METHODS } from '../server.js';
|
|
23
|
+
|
|
24
|
+
const DIRECTIVES = new Set(['if', 'else-if', 'else', 'each']);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Attributes that are not props, whatever they land on. `data-*` and `aria-*`
|
|
28
|
+
* are the platform's own, and `hx-*` belongs to whichever library the author
|
|
29
|
+
* brought. None of them are declared in `<script properties>`, so
|
|
30
|
+
* checking them as props turns a correct page into a type error. That is what
|
|
31
|
+
* `hx-get="/notes?id=${id}"` on a component used to be.
|
|
32
|
+
*
|
|
33
|
+
* They are still checked, just as expressions rather than as props: a typo in
|
|
34
|
+
* the `${…}` is an error the same as anywhere else. Only the claim that the name
|
|
35
|
+
* is a declared prop goes away.
|
|
36
|
+
*/
|
|
37
|
+
const PASS_THROUGH = /^(?:data|aria|hx)-/;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `ctx.cookies`, named rather than written into the route context. That context is
|
|
41
|
+
* a string built in typecheck.js, and it would otherwise carry this whole shape
|
|
42
|
+
* into every shim that mentions it.
|
|
43
|
+
*
|
|
44
|
+
* Emitted by every shim whose context names it. A type name JSDoc cannot resolve
|
|
45
|
+
* is not an error, it is `any`, so a shim that named `__Cookies` without defining
|
|
46
|
+
* it type-checked happily and caught nothing. The endpoint shim
|
|
47
|
+
* did exactly that.
|
|
48
|
+
*/
|
|
49
|
+
const COOKIES_TYPEDEF =
|
|
50
|
+
'/**\n' +
|
|
51
|
+
' * @typedef {{ path?: string; domain?: string; maxAge?: number; expires?: Date;\n' +
|
|
52
|
+
' * httpOnly?: boolean; secure?: boolean; sameSite?: "Strict" | "Lax" | "None" }} __CookieOptions\n' +
|
|
53
|
+
' */\n' +
|
|
54
|
+
'/**\n' +
|
|
55
|
+
' * @typedef {{\n' +
|
|
56
|
+
' * get(name: string): string | undefined;\n' +
|
|
57
|
+
' * all(): Record<string, string>;\n' +
|
|
58
|
+
' * set(name: string, value: string, options?: __CookieOptions): void;\n' +
|
|
59
|
+
' * delete(name: string, options?: __CookieOptions): void;\n' +
|
|
60
|
+
' * signed: {\n' +
|
|
61
|
+
' * get(name: string): Promise<string | undefined>;\n' +
|
|
62
|
+
' * all(): Promise<Record<string, string>>;\n' +
|
|
63
|
+
' * set(name: string, value: string, options?: __CookieOptions): Promise<void>;\n' +
|
|
64
|
+
' * };\n' +
|
|
65
|
+
' * }} __Cookies\n */\n\n';
|
|
66
|
+
|
|
67
|
+
class Builder {
|
|
68
|
+
constructor() {
|
|
69
|
+
this.chunks = [];
|
|
70
|
+
this.length = 0;
|
|
71
|
+
// A block that will not parse cannot be turned into anything tsc can read.
|
|
72
|
+
// Rather than emit a degraded shim and let tsc invent consequences in other
|
|
73
|
+
// files, the failure is carried out and reported where it happened.
|
|
74
|
+
this.syntaxErrors = [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** An acorn failure, relocated from inside the block to the .html file. */
|
|
78
|
+
failed(error, block) {
|
|
79
|
+
this.syntaxErrors.push({
|
|
80
|
+
offset: block.offset + (error.pos ?? 0),
|
|
81
|
+
message: error.message.replace(/\s*\(\d+:\d+\)$/, ''),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Text we invented. Diagnostics landing here have nowhere useful to point. */
|
|
86
|
+
add(text) {
|
|
87
|
+
if (!text) return;
|
|
88
|
+
this.chunks.push({ start: this.length, text, source: null });
|
|
89
|
+
this.length += text.length;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Text lifted from the .html file, carrying the offset it came from. */
|
|
93
|
+
copy(text, sourceOffset) {
|
|
94
|
+
if (!text) return;
|
|
95
|
+
this.chunks.push({ start: this.length, text, source: sourceOffset });
|
|
96
|
+
this.length += text.length;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Generated text that still has somewhere true to point: every offset inside it
|
|
101
|
+
* maps to one source position.
|
|
102
|
+
*
|
|
103
|
+
* For the annotations. A `@satisfies` is ours, not the author's, but the errors
|
|
104
|
+
* it produces are about their code. `TS1360: type '() => void' does not satisfy`
|
|
105
|
+
* is reported at the comment, so with `add` it mapped to nothing and was
|
|
106
|
+
* dropped. A handler that returned no Response type-checked silently.
|
|
107
|
+
*/
|
|
108
|
+
pin(text, sourceOffset) {
|
|
109
|
+
if (!text) return;
|
|
110
|
+
this.chunks.push({ start: this.length, text, source: sourceOffset, pinned: true });
|
|
111
|
+
this.length += text.length;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
build() {
|
|
115
|
+
return {
|
|
116
|
+
code: this.chunks.map((chunk) => chunk.text).join(''),
|
|
117
|
+
chunks: this.chunks,
|
|
118
|
+
syntaxErrors: this.syntaxErrors,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Maps an offset in the shim back to an offset in the .html file, or null when
|
|
125
|
+
* it landed in generated scaffolding.
|
|
126
|
+
*
|
|
127
|
+
* @param {object[]} chunks what the Builder recorded
|
|
128
|
+
* @param {number} offset into the shim
|
|
129
|
+
* @returns {number|null} the offset in the .html file, or null when it does not map
|
|
130
|
+
*/
|
|
131
|
+
export function originalOffset(chunks, offset) {
|
|
132
|
+
let low = 0;
|
|
133
|
+
let high = chunks.length - 1;
|
|
134
|
+
|
|
135
|
+
while (low <= high) {
|
|
136
|
+
const mid = (low + high) >> 1;
|
|
137
|
+
const chunk = chunks[mid];
|
|
138
|
+
if (offset < chunk.start) high = mid - 1;
|
|
139
|
+
else if (offset >= chunk.start + chunk.text.length) low = mid + 1;
|
|
140
|
+
else if (chunk.source === null) return null;
|
|
141
|
+
else return chunk.pinned ? chunk.source : chunk.source + (offset - chunk.start);
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* An endpoint is already a module. There is nothing to compile, only something to
|
|
148
|
+
* annotate. So the shim is the file, verbatim, with a `@satisfies` spliced in
|
|
149
|
+
* front of each verb export.
|
|
150
|
+
*
|
|
151
|
+
* That buys two things a page's shim also buys: the handler's own `ctx` is typed
|
|
152
|
+
* from the route context rather than being an implicit `any`, and the return type
|
|
153
|
+
* is held to `Response`, which is the one rule an endpoint has.
|
|
154
|
+
*
|
|
155
|
+
* Copied with offsets like every other shim, so a diagnostic points at the real
|
|
156
|
+
* line in the real file.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} source
|
|
159
|
+
* @param {{ contextType: string }} options
|
|
160
|
+
* @returns {{ code: string, chunks: object[], syntaxErrors: object[] }}
|
|
161
|
+
*/
|
|
162
|
+
export function buildEndpointShim(source, { contextType }) {
|
|
163
|
+
const out = new Builder();
|
|
164
|
+
out.add('export {};\n');
|
|
165
|
+
out.add(COOKIES_TYPEDEF);
|
|
166
|
+
|
|
167
|
+
let ast;
|
|
168
|
+
try {
|
|
169
|
+
ast = parse(source, {
|
|
170
|
+
ecmaVersion: 'latest',
|
|
171
|
+
sourceType: 'module',
|
|
172
|
+
allowAwaitOutsideFunction: true,
|
|
173
|
+
});
|
|
174
|
+
} catch (error) {
|
|
175
|
+
out.failed(error, { offset: 0 });
|
|
176
|
+
return out.build();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const signature = `(ctx: ${contextType}) => Response | Promise<Response>`;
|
|
180
|
+
const edits = [];
|
|
181
|
+
const declarations = [];
|
|
182
|
+
|
|
183
|
+
for (const node of ast.body) {
|
|
184
|
+
if (node.type !== 'ExportNamedDeclaration') continue;
|
|
185
|
+
const declared = node.declaration;
|
|
186
|
+
|
|
187
|
+
// Anything not spelled like a method is a helper and gets no signature.
|
|
188
|
+
if (declared?.type === 'VariableDeclaration') {
|
|
189
|
+
const name = declared.declarations[0]?.id?.name;
|
|
190
|
+
// `@satisfies` on the initialiser: it contextually types the handler's own
|
|
191
|
+
// `ctx` *and* holds the return type, which an annotation would flatten.
|
|
192
|
+
if (isVerb(name)) edits.push({ at: node.start, insert: `/** @satisfies {${signature}} */\n` });
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (declared?.type === 'FunctionDeclaration' && isVerb(declared.id?.name)) {
|
|
197
|
+
// TypeScript ignores `@satisfies` on a function declaration. Measured: it
|
|
198
|
+
// reports nothing at all. An assignment after the fact is checked, so the
|
|
199
|
+
// return type still cannot be wrong. What is lost is contextual typing of
|
|
200
|
+
// the parameter, which is why `export const` is the better spelling.
|
|
201
|
+
declarations.push({ name: declared.id.name, at: node.start });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let cursor = 0;
|
|
206
|
+
for (const edit of edits) {
|
|
207
|
+
out.copy(source.slice(cursor, edit.at), cursor);
|
|
208
|
+
out.pin(edit.insert, edit.at);
|
|
209
|
+
cursor = edit.at;
|
|
210
|
+
}
|
|
211
|
+
out.copy(source.slice(cursor), cursor);
|
|
212
|
+
|
|
213
|
+
for (const { name, at } of declarations) {
|
|
214
|
+
out.pin(`\n/** @type {${signature}} */\nconst __verb_${name} = ${name};\n`, at);
|
|
215
|
+
}
|
|
216
|
+
return out.build();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A handler is named for its method, the way HTTP spells it.
|
|
221
|
+
*
|
|
222
|
+
* Held to the methods the router dispatches rather than to any all-caps name,
|
|
223
|
+
* or `export const LIMIT = 10` beside a handler would be given a `Response`
|
|
224
|
+
* signature and reported as an error about code that is fine.
|
|
225
|
+
*/
|
|
226
|
+
function isVerb(name) {
|
|
227
|
+
return Boolean(name) && ENDPOINT_METHODS.includes(name);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A page's verb exports, in source order.
|
|
232
|
+
*
|
|
233
|
+
* Held to the methods a page actually dispatches on rather than to `isVerb`,
|
|
234
|
+
* because a page may export an all-caps constant and giving that a handler
|
|
235
|
+
* signature would be an error about code that is fine.
|
|
236
|
+
*/
|
|
237
|
+
function actionExports(ast) {
|
|
238
|
+
const found = [];
|
|
239
|
+
|
|
240
|
+
for (const node of ast.body) {
|
|
241
|
+
if (node.type !== 'ExportNamedDeclaration') continue;
|
|
242
|
+
const declaration = node.declaration;
|
|
243
|
+
|
|
244
|
+
const name =
|
|
245
|
+
declaration?.type === 'VariableDeclaration'
|
|
246
|
+
? declaration.declarations[0]?.id?.name
|
|
247
|
+
: declaration?.type === 'FunctionDeclaration'
|
|
248
|
+
? declaration.id?.name
|
|
249
|
+
: null;
|
|
250
|
+
|
|
251
|
+
if (name && ACTION_METHODS.includes(name)) {
|
|
252
|
+
found.push({
|
|
253
|
+
name,
|
|
254
|
+
at: node.start,
|
|
255
|
+
declared: declaration.type === 'FunctionDeclaration',
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return found;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* `page`, `layout` and `component` differ only in where their data comes from:
|
|
265
|
+
* a loader checked against a route context, or a props object.
|
|
266
|
+
*
|
|
267
|
+
* @param {string} source
|
|
268
|
+
* @param {{ kind: string, shadow?: boolean, contextType?: string|null,
|
|
269
|
+
* componentProps?: Map<string, string> }} options
|
|
270
|
+
* @returns {{ code: string, chunks: object[], syntaxErrors: object[] }}
|
|
271
|
+
*/
|
|
272
|
+
export function buildShim(source, { kind, shadow = false, contextType = null, componentProps = new Map() }) {
|
|
273
|
+
const blocks = splitBlocks(source);
|
|
274
|
+
const out = new Builder();
|
|
275
|
+
|
|
276
|
+
// Without at least one import or export a file is a *global script*, and every
|
|
277
|
+
// shim's __Data would collide in one shared scope. This keeps each file's
|
|
278
|
+
// types its own.
|
|
279
|
+
out.add('export {};\n');
|
|
280
|
+
|
|
281
|
+
// Two jobs, both about letting the author write plain JS.
|
|
282
|
+
//
|
|
283
|
+
// The mapping is what keeps `${user.nmae}` an error. TypeScript treats a type
|
|
284
|
+
// that came straight from an object literal in a .js file as open for expando
|
|
285
|
+
// properties, so reading an undeclared one is allowed. Remapping the keys gives
|
|
286
|
+
// an ordinary object type, where it is not.
|
|
287
|
+
//
|
|
288
|
+
// The conditional widens a bare `[]`, which otherwise infers `never[]` and
|
|
289
|
+
// turns "no annotation" from "less checking" into a page of errors about a
|
|
290
|
+
// type nobody wrote.
|
|
291
|
+
out.add(
|
|
292
|
+
'/**\n * @template T\n' +
|
|
293
|
+
' * @typedef {{ [K in keyof T]: T[K] extends never[] ? any[] : T[K] }} __Shape\n */\n',
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
out.add(COOKIES_TYPEDEF);
|
|
297
|
+
|
|
298
|
+
const used = [...collectUsedTags(blocks.nodes, componentProps)];
|
|
299
|
+
|
|
300
|
+
if (kind === 'component') {
|
|
301
|
+
// Props and state share one namespace in the template, so they are one type
|
|
302
|
+
// by the time anything reads them.
|
|
303
|
+
emitModule(blocks.properties, out, null, '__Props', '__props');
|
|
304
|
+
emitModule(blocks.state, out, null, '__State', '__stateDefaults');
|
|
305
|
+
out.add('/** @typedef {__Props & __State} __Data */\n\n');
|
|
306
|
+
// Each converter is tied to the prop it converts: `from` has to produce
|
|
307
|
+
// that prop's type, and `to` is handed it. Written out so the author does
|
|
308
|
+
// not have to annotate a parameter whose type is already known.
|
|
309
|
+
out.add(
|
|
310
|
+
'/**\n * @typedef {{ [K in keyof __Props]?: {\n' +
|
|
311
|
+
' * from?: (text: string) => __Props[K];\n' +
|
|
312
|
+
' * to?: (value: __Props[K]) => string | number | boolean | null | undefined;\n' +
|
|
313
|
+
' * } }} __Attrs\n */\n\n',
|
|
314
|
+
);
|
|
315
|
+
} else {
|
|
316
|
+
emitModule(blocks.server, out, contextType, '__Data', '__default');
|
|
317
|
+
}
|
|
318
|
+
emitMembers(kind === 'component' ? blocks.client : [], out, shadow);
|
|
319
|
+
|
|
320
|
+
// Helpers arrive as parameters rather than module-scope declarations: a
|
|
321
|
+
// parameter shadows, so it cannot collide with something the author imported.
|
|
322
|
+
out.add('/**\n');
|
|
323
|
+
out.add(' * @param {__Data} __d\n');
|
|
324
|
+
out.add(' * @param {(value: unknown) => string} html\n');
|
|
325
|
+
out.add(' * @param {(value: unknown) => string} json\n');
|
|
326
|
+
out.add(' * @param {(value: unknown) => void} __expr\n');
|
|
327
|
+
for (const tag of used) {
|
|
328
|
+
out.add(` * @param {(value: Partial<${componentProps.get(tag)}>) => void} ${propsFn(tag)}\n`);
|
|
329
|
+
}
|
|
330
|
+
out.add(' */\n');
|
|
331
|
+
out.add(`function __template(__d, html, json, __expr${used.map((tag) => `, ${propsFn(tag)}`).join('')}) {\n`);
|
|
332
|
+
emitNodes(blocks.nodes, out, new Set(), componentProps, 1);
|
|
333
|
+
out.add('}\n');
|
|
334
|
+
|
|
335
|
+
// Client blocks are not otherwise part of the shim. They run in the browser with
|
|
336
|
+
// `host`, `shadow` and `signal` in scope, which tsc has no way to know.
|
|
337
|
+
// They are still parsed, because a syntax error there should be reported by
|
|
338
|
+
// the same command that reports every other one.
|
|
339
|
+
for (const block of blocks.client) {
|
|
340
|
+
try {
|
|
341
|
+
parse(block.code, {
|
|
342
|
+
ecmaVersion: 'latest',
|
|
343
|
+
sourceType: 'module',
|
|
344
|
+
allowAwaitOutsideFunction: true,
|
|
345
|
+
allowReturnOutsideFunction: true,
|
|
346
|
+
});
|
|
347
|
+
} catch (error) {
|
|
348
|
+
out.failed(error, block);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// The one export the type extractor reads: whatever __Data resolved to is the
|
|
353
|
+
// file's data shape, and asking tsc for it is how transclude-env.d.ts gets written.
|
|
354
|
+
out.add('\n/** @type {__Data} */\n');
|
|
355
|
+
out.add('export let __data;\n');
|
|
356
|
+
out.add('/** @type {__Members} */\n');
|
|
357
|
+
out.add('export let __members;\n');
|
|
358
|
+
if (kind === 'component') {
|
|
359
|
+
// Read separately from __data: a parent passes props and cannot reach
|
|
360
|
+
// state, so the type a parent is checked against must not include it.
|
|
361
|
+
out.add('/** @type {__Props} */\n');
|
|
362
|
+
out.add('export let __propTypes;\n');
|
|
363
|
+
out.add('/** @type {__State} */\n');
|
|
364
|
+
out.add('export let __state;\n');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return out.build();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* `<script server>` and `<script props>` are module bodies, not expressions: they
|
|
372
|
+
* have imports and may have named exports of their own. Those stay where they
|
|
373
|
+
* are, because the shim is a module too, and only the default export is rebound. For a
|
|
374
|
+
* loader that rebinding is what types its parameter from the route context while
|
|
375
|
+
* leaving the return type inferred.
|
|
376
|
+
*/
|
|
377
|
+
function emitModule(block, out, contextType, name = '__Data', binding = '__default') {
|
|
378
|
+
if (!block) {
|
|
379
|
+
out.add(`/** @typedef {{}} ${name} */\n\n`);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
let ast;
|
|
383
|
+
try {
|
|
384
|
+
ast = parse(block.code, { ecmaVersion: 'latest', sourceType: 'module', allowAwaitOutsideFunction: true });
|
|
385
|
+
} catch (error) {
|
|
386
|
+
// Half a file gives tsc nothing useful to say, so the parse failure itself
|
|
387
|
+
// is the diagnostic.
|
|
388
|
+
out.failed(error, block);
|
|
389
|
+
out.add(`/** @typedef {{}} ${name} */\n\n`);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// The block is copied whole with pieces spliced in at exact offsets, so every
|
|
394
|
+
// position still maps back to the .html file. Copying statement by statement
|
|
395
|
+
// would drop everything that is not a statement, such as a loose `@typedef` or a
|
|
396
|
+
// JSDoc comment attached to an export, and those are how an author says what an
|
|
397
|
+
// empty array holds.
|
|
398
|
+
const edits = [];
|
|
399
|
+
|
|
400
|
+
// A props block annotates `attributes`; a server block annotates `actions`,
|
|
401
|
+
// which types every handler's own `ctx` from the same route context the
|
|
402
|
+
// loader gets. Neither depends on there being a default export.
|
|
403
|
+
const attrs = binding === '__props' ? namedExport(ast, 'attributes') : null;
|
|
404
|
+
if (attrs) edits.push({ at: attrs.start, insert: '/** @type {__Attrs} */\n' });
|
|
405
|
+
|
|
406
|
+
// A page's handlers are named for their methods, the same way an endpoint's
|
|
407
|
+
// are. The route context here has `request` non-nullable: it is null only
|
|
408
|
+
// while prerendering, and prerendering never runs an action.
|
|
409
|
+
const verbs = contextType ? actionExports(ast) : [];
|
|
410
|
+
for (const verb of verbs) {
|
|
411
|
+
// Only on `export const`. TypeScript ignores `@satisfies` on a function
|
|
412
|
+
// declaration, so emitting one there would be a check that reads as if it
|
|
413
|
+
// ran. The return type is still picked up below either way.
|
|
414
|
+
if (verb.declared) continue;
|
|
415
|
+
edits.push({
|
|
416
|
+
at: verb.at,
|
|
417
|
+
insert: `/** @satisfies {(ctx: ${contextType} & { request: Request }) => unknown} */\n`,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const statement = ast.body.find((node) => node.type === 'ExportDefaultDeclaration');
|
|
422
|
+
const declaration = statement?.declaration;
|
|
423
|
+
if (statement) edits.push({ at: statement.start, statement });
|
|
424
|
+
edits.sort((a, b) => a.at - b.at);
|
|
425
|
+
|
|
426
|
+
let cursor = 0;
|
|
427
|
+
for (const edit of edits) {
|
|
428
|
+
out.copy(block.code.slice(cursor, edit.at), block.offset + cursor);
|
|
429
|
+
cursor = edit.at;
|
|
430
|
+
|
|
431
|
+
if (edit.insert) {
|
|
432
|
+
out.pin(edit.insert, block.offset + edit.at);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
// `satisfies` is doing real work: it contextually types the loader's own
|
|
436
|
+
// parameter *and* leaves the return type intact for the template below. An
|
|
437
|
+
// annotation would flatten one or the other.
|
|
438
|
+
//
|
|
439
|
+
// The loader's `ctx.action` is whatever this page's own actions return. It is
|
|
440
|
+
// `unknown` in the route context, narrowed here by intersection to the union
|
|
441
|
+
// of their return types. A `Response` is excluded because returning
|
|
442
|
+
// one short-circuits: the loader never runs, so it can never see it.
|
|
443
|
+
if (contextType) {
|
|
444
|
+
const returns = verbs.map((verb) => `Awaited<ReturnType<typeof ${verb.name}>>`).join(' | ');
|
|
445
|
+
const action = verbs.length ? `Exclude<${returns}, Response>` : 'null';
|
|
446
|
+
out.add(
|
|
447
|
+
`\n/** @satisfies {(ctx: ${contextType} & { action: ${action} | null }) => unknown} */\n`,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
out.add(`const ${binding} = (`);
|
|
451
|
+
out.copy(
|
|
452
|
+
block.code.slice(declaration.start, declaration.end),
|
|
453
|
+
block.offset + declaration.start,
|
|
454
|
+
);
|
|
455
|
+
out.add(');\n');
|
|
456
|
+
cursor = statement.end;
|
|
457
|
+
}
|
|
458
|
+
out.copy(block.code.slice(cursor), block.offset + cursor);
|
|
459
|
+
out.add('\n');
|
|
460
|
+
|
|
461
|
+
// No default export means a block that only does other things, such as `paths`,
|
|
462
|
+
// `prerender` or `actions`. It renders from nothing, so its data shape is empty.
|
|
463
|
+
if (!statement) {
|
|
464
|
+
out.add(`/** @typedef {{}} ${name} */\n\n`);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
out.add(
|
|
469
|
+
contextType
|
|
470
|
+
? `/** @typedef {__Shape<Awaited<ReturnType<typeof ${binding}>>>} ${name} */\n\n`
|
|
471
|
+
: `/** @typedef {__Shape<typeof ${binding}>} ${name} */\n\n`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* `export const prototype` is checked the same way as everything else, with one
|
|
477
|
+
* addition: `this` inside its members has to mean the element.
|
|
478
|
+
*
|
|
479
|
+
* `T & ThisType<Host & __Data & T>` is the whole trick. `T` infers from the
|
|
480
|
+
* object literal, so the members keep their real types and transclude-env.d.ts can be
|
|
481
|
+
* written from them; `ThisType` contextually types `this` without changing what
|
|
482
|
+
* the value is. Including `T` in the intersection is what lets one member call
|
|
483
|
+
* another.
|
|
484
|
+
*
|
|
485
|
+
* `shadowRoot` is narrowed rather than left as `ShadowRoot | null`: a shadow
|
|
486
|
+
* element always has one by the time any of this runs, and a light one never
|
|
487
|
+
* does. The DOM's type cannot know that; the file's `shadow` flag does.
|
|
488
|
+
*
|
|
489
|
+
* Only the members and what they read come across. The rest of the block runs
|
|
490
|
+
* with `host`, `shadow` and `signal` in scope, which tsc has no way to know. Its
|
|
491
|
+
* imports may be browser-only, so they are copied only where a member uses
|
|
492
|
+
* them. A block that will not parse is skipped in silence here, because the
|
|
493
|
+
* syntax pass below is what reports it, once.
|
|
494
|
+
*/
|
|
495
|
+
function emitMembers(blocks, out, shadow) {
|
|
496
|
+
for (const block of blocks) {
|
|
497
|
+
let ast;
|
|
498
|
+
try {
|
|
499
|
+
ast = parse(block.code, {
|
|
500
|
+
ecmaVersion: 'latest',
|
|
501
|
+
sourceType: 'module',
|
|
502
|
+
allowAwaitOutsideFunction: true,
|
|
503
|
+
allowReturnOutsideFunction: true,
|
|
504
|
+
});
|
|
505
|
+
} catch {
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const plan = planLift(ast, 'prototype');
|
|
510
|
+
if (!plan) continue;
|
|
511
|
+
|
|
512
|
+
for (const statement of ast.body) {
|
|
513
|
+
if (statement.type !== 'ImportDeclaration') continue;
|
|
514
|
+
if (!statement.specifiers.some((spec) => plan.reads.has(spec.local.name))) continue;
|
|
515
|
+
out.copy(block.code.slice(statement.start, statement.end), block.offset + statement.start);
|
|
516
|
+
out.add('\n');
|
|
517
|
+
}
|
|
518
|
+
for (const dependency of plan.deps) {
|
|
519
|
+
out.copy(block.code.slice(dependency.start, dependency.end), block.offset + dependency.start);
|
|
520
|
+
out.add('\n');
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const host = `HTMLElement & { shadowRoot: ${shadow ? 'ShadowRoot' : 'null'} }`;
|
|
524
|
+
out.add(
|
|
525
|
+
'\n/**\n * @template T\n' +
|
|
526
|
+
` * @param {T & ThisType<${host} & __Data & T>} m\n` +
|
|
527
|
+
' * @returns {T}\n */\n' +
|
|
528
|
+
'function __self(m) { return m; }\n' +
|
|
529
|
+
'const __memberDefs = __self(',
|
|
530
|
+
);
|
|
531
|
+
out.copy(block.code.slice(plan.init.start, plan.init.end), block.offset + plan.init.start);
|
|
532
|
+
out.add(');\n/** @typedef {typeof __memberDefs} __Members */\n\n');
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
out.add('/** @typedef {{}} __Members */\n\n');
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** `export const <name> = …`, as a statement, or null. */
|
|
539
|
+
function namedExport(ast, name) {
|
|
540
|
+
return (
|
|
541
|
+
ast.body.find(
|
|
542
|
+
(node) =>
|
|
543
|
+
node.type === 'ExportNamedDeclaration' &&
|
|
544
|
+
node.declaration?.type === 'VariableDeclaration' &&
|
|
545
|
+
node.declaration.declarations.some(
|
|
546
|
+
(declarator) => declarator.id.type === 'Identifier' && declarator.id.name === name,
|
|
547
|
+
),
|
|
548
|
+
) ?? null
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** A distinct checker per component tag, since JS cannot pass a type argument. */
|
|
553
|
+
function propsFn(tag) {
|
|
554
|
+
return `__props_${tag.replace(/[^A-Za-z0-9]/g, '_')}`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function emitNodes(nodes, out, scope, components, depth) {
|
|
558
|
+
for (const node of nodes) {
|
|
559
|
+
if (node.nodeName === '#text') {
|
|
560
|
+
emitInterpolations(node.value ?? '', node.sourceCodeLocation?.startOffset ?? 0, out, scope, depth);
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
if (!node.tagName) continue;
|
|
564
|
+
|
|
565
|
+
const each = node.attrs?.find((attr) => attr.name === 'each');
|
|
566
|
+
const inner = new Set(scope);
|
|
567
|
+
let closes = 0;
|
|
568
|
+
|
|
569
|
+
if (each) {
|
|
570
|
+
const spec = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+of\s+([\s\S]+?)\s*$/.exec(
|
|
571
|
+
each.value,
|
|
572
|
+
);
|
|
573
|
+
if (spec) {
|
|
574
|
+
const listOffset = attrValueOffset(node, 'each') + each.value.indexOf(spec[3]);
|
|
575
|
+
indent(out, depth);
|
|
576
|
+
out.add(`for (const ${spec[1]} of `);
|
|
577
|
+
emitExpression(spec[3], listOffset, out, scope);
|
|
578
|
+
out.add(') {\n');
|
|
579
|
+
inner.add(spec[1]);
|
|
580
|
+
if (spec[2]) {
|
|
581
|
+
indent(out, depth + 1);
|
|
582
|
+
out.add(`const ${spec[2]}: number = 0;\n`);
|
|
583
|
+
inner.add(spec[2]);
|
|
584
|
+
}
|
|
585
|
+
closes++;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const body = depth + closes;
|
|
590
|
+
|
|
591
|
+
// A component's interpolated attributes are checked as props below; emitting
|
|
592
|
+
// them here as well would report every mistake twice.
|
|
593
|
+
const isComponent = components.has(node.tagName);
|
|
594
|
+
|
|
595
|
+
for (const attr of node.attrs ?? []) {
|
|
596
|
+
if (attr.name === 'each') continue;
|
|
597
|
+
const offset = attrValueOffset(node, attr.name);
|
|
598
|
+
|
|
599
|
+
if (DIRECTIVES.has(attr.name)) {
|
|
600
|
+
if (attr.name === 'else') continue;
|
|
601
|
+
indent(out, body);
|
|
602
|
+
out.add('__expr(');
|
|
603
|
+
emitExpression(attr.value, offset, out, inner);
|
|
604
|
+
out.add(');\n');
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
// A pass-through attribute is not a prop, so it is checked here like any
|
|
608
|
+
// other interpolation rather than below with the props.
|
|
609
|
+
if (isComponent && !PASS_THROUGH.test(attr.name) && /\$\{/.test(attr.value)) continue;
|
|
610
|
+
emitInterpolations(attr.value, offset, out, inner, body);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (isComponent) emitComponentProps(node, out, inner, components, body);
|
|
614
|
+
|
|
615
|
+
emitNodes(childrenOf(node), out, inner, components, body);
|
|
616
|
+
|
|
617
|
+
for (let i = 0; i < closes; i++) {
|
|
618
|
+
indent(out, depth + closes - 1 - i);
|
|
619
|
+
out.add('}\n');
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Only interpolated props are checked; a literal attribute is coerced at runtime. */
|
|
625
|
+
function emitComponentProps(node, out, scope, components, depth) {
|
|
626
|
+
const dynamic = (node.attrs ?? []).filter(
|
|
627
|
+
(attr) =>
|
|
628
|
+
!DIRECTIVES.has(attr.name) && !PASS_THROUGH.test(attr.name) && /\$\{/.test(attr.value),
|
|
629
|
+
);
|
|
630
|
+
if (!dynamic.length) return;
|
|
631
|
+
|
|
632
|
+
indent(out, depth);
|
|
633
|
+
out.add(`${propsFn(node.tagName)}({ `);
|
|
634
|
+
for (const attr of dynamic) {
|
|
635
|
+
const parts = splitInterpolations(attr.value);
|
|
636
|
+
|
|
637
|
+
// Props are declared camelCase and written dash-case, so the key is checked
|
|
638
|
+
// under the name `<script props>` gave it. Still mapped to the attribute in
|
|
639
|
+
// the source: an unmapped key would have its diagnostic dropped.
|
|
640
|
+
const prop = camelCase(attr.name);
|
|
641
|
+
if (/^[A-Za-z_$][\w$]*$/.test(prop)) {
|
|
642
|
+
out.copy(prop, attrNameOffset(node, attr.name));
|
|
643
|
+
out.add(': ');
|
|
644
|
+
} else {
|
|
645
|
+
out.add(`${JSON.stringify(attr.name)}: `);
|
|
646
|
+
}
|
|
647
|
+
if (parts.length === 1 && parts[0].type === 'expr') {
|
|
648
|
+
emitExpression(parts[0].value, attrValueOffset(node, attr.name) + 2, out, scope);
|
|
649
|
+
} else {
|
|
650
|
+
out.add('""');
|
|
651
|
+
}
|
|
652
|
+
out.add(', ');
|
|
653
|
+
}
|
|
654
|
+
out.add('});\n');
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function emitInterpolations(text, offset, out, scope, depth) {
|
|
658
|
+
let cursor = 0;
|
|
659
|
+
for (const part of splitInterpolations(text)) {
|
|
660
|
+
if (part.type === 'expr') {
|
|
661
|
+
// `${` is two characters before the expression itself.
|
|
662
|
+
const start = offset + text.indexOf(part.value, cursor);
|
|
663
|
+
indent(out, depth);
|
|
664
|
+
out.add('__expr(');
|
|
665
|
+
emitExpression(part.value, start, out, scope);
|
|
666
|
+
out.add(');\n');
|
|
667
|
+
}
|
|
668
|
+
cursor += part.value.length;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Copies an expression verbatim, prefixing the identifiers that come from
|
|
674
|
+
* template data with `__d.`. Prefixing only inserts, so everything the author
|
|
675
|
+
* wrote keeps its own offset and a diagnostic points at the real token.
|
|
676
|
+
*/
|
|
677
|
+
function emitExpression(text, offset, out, scope) {
|
|
678
|
+
let ast;
|
|
679
|
+
try {
|
|
680
|
+
ast = parseExpressionAt(text, 0, { ecmaVersion: 'latest' });
|
|
681
|
+
} catch {
|
|
682
|
+
out.add('undefined');
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const roots = [];
|
|
687
|
+
collectRoots(ast, scope, roots);
|
|
688
|
+
roots.sort((a, b) => a.start - b.start);
|
|
689
|
+
|
|
690
|
+
let cursor = 0;
|
|
691
|
+
for (const root of roots) {
|
|
692
|
+
out.copy(text.slice(cursor, root.start), offset + cursor);
|
|
693
|
+
out.add('__d.');
|
|
694
|
+
cursor = root.start;
|
|
695
|
+
}
|
|
696
|
+
out.copy(text.slice(cursor), offset + cursor);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Identifiers that are neither loop variables nor globals resolve to data. */
|
|
700
|
+
function collectRoots(node, scope, out) {
|
|
701
|
+
if (!node || typeof node !== 'object' || !node.type) return;
|
|
702
|
+
|
|
703
|
+
switch (node.type) {
|
|
704
|
+
case 'Identifier':
|
|
705
|
+
if (!scope.has(node.name) && !GLOBALS.has(node.name)) out.push(node);
|
|
706
|
+
return;
|
|
707
|
+
case 'MemberExpression':
|
|
708
|
+
collectRoots(node.object, scope, out);
|
|
709
|
+
if (node.computed) collectRoots(node.property, scope, out);
|
|
710
|
+
return;
|
|
711
|
+
case 'CallExpression':
|
|
712
|
+
collectRoots(node.callee, scope, out);
|
|
713
|
+
for (const arg of node.arguments) collectRoots(arg, scope, out);
|
|
714
|
+
return;
|
|
715
|
+
default:
|
|
716
|
+
for (const key of ['argument', 'left', 'right', 'test', 'consequent', 'alternate', 'expression']) {
|
|
717
|
+
if (node[key]) collectRoots(node[key], scope, out);
|
|
718
|
+
}
|
|
719
|
+
for (const element of node.elements ?? []) collectRoots(element, scope, out);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const GLOBALS = new Set([
|
|
725
|
+
'html', 'Math', 'JSON', 'String', 'Number', 'Boolean', 'Array', 'Object', 'Date',
|
|
726
|
+
'isNaN', 'parseInt', 'parseFloat', 'undefined', 'NaN', 'Infinity', 'true', 'false', 'null',
|
|
727
|
+
]);
|
|
728
|
+
|
|
729
|
+
function collectUsedTags(nodes, components, found = new Set()) {
|
|
730
|
+
for (const node of nodes) {
|
|
731
|
+
if (!node.tagName) continue;
|
|
732
|
+
if (components.has(node.tagName)) found.add(node.tagName);
|
|
733
|
+
collectUsedTags(childrenOf(node), components, found);
|
|
734
|
+
}
|
|
735
|
+
return found;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** `page-size` -> `pageSize`, matching the runtime's attrName in reverse. */
|
|
739
|
+
function camelCase(attr) {
|
|
740
|
+
return attr.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function attrNameOffset(node, name) {
|
|
744
|
+
return node.sourceCodeLocation?.attrs?.[name]?.startOffset ?? node.sourceCodeLocation?.startOffset ?? 0;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function attrValueOffset(node, name) {
|
|
748
|
+
const location = node.sourceCodeLocation?.attrs?.[name];
|
|
749
|
+
if (!location) return node.sourceCodeLocation?.startOffset ?? 0;
|
|
750
|
+
// startOffset points at the attribute name; step past `name="`.
|
|
751
|
+
return location.startOffset + name.length + 2;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function indent(out, depth) {
|
|
755
|
+
out.add(' '.repeat(Math.max(1, depth)));
|
|
756
|
+
}
|