@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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/bin/build.js +469 -0
  4. package/bin/check.js +78 -0
  5. package/bin/dev.js +348 -0
  6. package/bin/release.js +176 -0
  7. package/bin/serve.bun.js +15 -0
  8. package/bin/serve.deno.js +15 -0
  9. package/bin/serve.js +12 -0
  10. package/editor/server.js +172 -0
  11. package/editor/vscode/extension.js +49 -0
  12. package/editor/vscode/package.json +32 -0
  13. package/editor/vscode/syntaxes/transclude.injection.json +41 -0
  14. package/package.json +82 -0
  15. package/src/address.js +183 -0
  16. package/src/app.js +492 -0
  17. package/src/cache.js +137 -0
  18. package/src/compiler/bind.js +496 -0
  19. package/src/compiler/codegen.js +1061 -0
  20. package/src/compiler/expr.js +221 -0
  21. package/src/compiler/index.js +964 -0
  22. package/src/compiler/interp.js +82 -0
  23. package/src/compiler/script.js +620 -0
  24. package/src/compiler/shim.js +756 -0
  25. package/src/compiler/sourcemap.js +140 -0
  26. package/src/compiler/types.js +163 -0
  27. package/src/compress.js +104 -0
  28. package/src/cookies.js +157 -0
  29. package/src/csp.js +192 -0
  30. package/src/document.js +604 -0
  31. package/src/extract.js +339 -0
  32. package/src/feed.js +194 -0
  33. package/src/include.js +89 -0
  34. package/src/lookup.js +49 -0
  35. package/src/negotiate.js +95 -0
  36. package/src/plugin.js +423 -0
  37. package/src/pool.js +29 -0
  38. package/src/precache.js +68 -0
  39. package/src/production.js +159 -0
  40. package/src/project.js +110 -0
  41. package/src/proxy.js +319 -0
  42. package/src/public-files.js +77 -0
  43. package/src/rewrite.js +281 -0
  44. package/src/routes.js +199 -0
  45. package/src/runtime/index.js +1345 -0
  46. package/src/server.js +183 -0
  47. package/src/sitemap.js +124 -0
  48. package/src/static-cache.js +170 -0
  49. package/src/typecheck.js +492 -0
  50. 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
+ }