@transclude/core 0.1.1 → 0.3.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 +1 -1
- package/README.md +65 -14
- package/bin/build.js +22 -4
- package/bin/check.js +30 -8
- package/bin/dev.js +10 -2
- package/bin/release.js +19 -4
- package/package.json +13 -4
- package/skills/transclude/SKILL.md +219 -0
- package/skills/transclude/references/elements.md +206 -0
- package/skills/transclude/references/fragments.md +168 -0
- package/skills/transclude/references/server.md +155 -0
- package/src/address.js +9 -2
- package/src/app.js +110 -81
- package/src/compiler/ambient.js +99 -0
- package/src/compiler/bind.js +34 -27
- package/src/compiler/codegen.js +59 -35
- package/src/compiler/directives.js +29 -0
- package/src/compiler/expr.js +10 -1
- package/src/compiler/html.js +41 -0
- package/src/compiler/index.js +63 -11
- package/src/compiler/script.js +40 -11
- package/src/compiler/shim.js +24 -45
- package/src/compiler/types.js +32 -5
- package/src/csp.js +7 -1
- package/src/document.js +63 -12
- package/src/extract.js +9 -8
- package/src/negotiate.js +1 -1
- package/src/plugin.js +28 -7
- package/src/precache.js +11 -1
- package/src/project.js +29 -1
- package/src/proxy.js +9 -1
- package/src/rewrite.js +12 -5
- package/src/routes.js +1 -1
- package/src/runtime/index.js +12 -9
- package/src/server.js +2 -2
- package/src/sitemap.js +3 -3
- package/src/static-cache.js +6 -3
- package/src/typecheck.js +105 -11
package/src/app.js
CHANGED
|
@@ -39,6 +39,71 @@ const encoder = new TextEncoder();
|
|
|
39
39
|
/** Below this, the framing costs more than it saves. A 91 byte file gzips to 120. */
|
|
40
40
|
export const COMPRESSIBLE_FLOOR = 512;
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Sends the best representation the client will accept. `Vary` is not optional
|
|
44
|
+
* here: without it a shared cache would serve one encoding to everyone.
|
|
45
|
+
*/
|
|
46
|
+
function send(c, entry, cacheControl, status = 200) {
|
|
47
|
+
// The key list is built once per entry rather than per request. An entry is
|
|
48
|
+
// produced at load time and never changes, so the spread was a fresh array
|
|
49
|
+
// for every hit on the same file.
|
|
50
|
+
entry.encodingList ??= [...entry.encodings.keys()];
|
|
51
|
+
const encoding = pickEncoding(c.req.header('accept-encoding'), entry.encodingList);
|
|
52
|
+
const chosen = encoding ? entry.encodings.get(encoding) : null;
|
|
53
|
+
|
|
54
|
+
const body = chosen?.body ?? entry.body;
|
|
55
|
+
const etag = chosen?.etag ?? entry.etag;
|
|
56
|
+
|
|
57
|
+
c.header('Vary', 'Accept-Encoding');
|
|
58
|
+
c.header('Cache-Control', cacheControl);
|
|
59
|
+
c.header('ETag', etag);
|
|
60
|
+
|
|
61
|
+
if (c.req.header('if-none-match') === etag) return c.body(null, 304);
|
|
62
|
+
|
|
63
|
+
if (chosen) c.header('Content-Encoding', encoding);
|
|
64
|
+
c.header('Content-Type', entry.type);
|
|
65
|
+
c.header('Content-Length', String(body.length));
|
|
66
|
+
return c.body(body, status);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What a page is going to ask for, said in a header.
|
|
71
|
+
*
|
|
72
|
+
* This is not streaming. Render is `__o += …` to the last component, which is
|
|
73
|
+
* what lets an include resolve before it and a prerendered page stay a file,
|
|
74
|
+
* and making it async would tax every component call for something most pages
|
|
75
|
+
* here do not need. So the body still leaves in one piece.
|
|
76
|
+
*
|
|
77
|
+
* What it does buy: the stylesheet and the client entry come from the route
|
|
78
|
+
* table, not from a loader, so they are known before any loader runs. A proxy
|
|
79
|
+
* that reads this sends a 103 and the browser fetches them while the page is
|
|
80
|
+
* still being made. Cloudflare and Fastly do. A browser reading it directly
|
|
81
|
+
* gets less, since these headers arrive with the body anyway.
|
|
82
|
+
*/
|
|
83
|
+
function preloadHeader(stylesheet, clientEntry) {
|
|
84
|
+
const parts = [];
|
|
85
|
+
if (stylesheet) parts.push(`<${stylesheet}>; rel=preload; as=style`);
|
|
86
|
+
if (clientEntry) parts.push(`<${clientEntry}>; rel=preload; as=script; crossorigin`);
|
|
87
|
+
return parts.length ? parts.join(', ') : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether this render can be handed to the next visitor.
|
|
92
|
+
*
|
|
93
|
+
* Three ways it cannot. It answered with a `Response`, it is not 2xx, or it is
|
|
94
|
+
* personal. Personal means a header was written *or* a cookie was read. The
|
|
95
|
+
* second half matters: a page that only reads a cookie and renders a count from
|
|
96
|
+
* it sets no header at all, and holding it would hand one visitor's count to the
|
|
97
|
+
* next.
|
|
98
|
+
*/
|
|
99
|
+
function isShareable(html, ctx) {
|
|
100
|
+
if (html instanceof Response) return false;
|
|
101
|
+
if (ctx.response.status >= 300) return false;
|
|
102
|
+
|
|
103
|
+
const wroteHeader = [...ctx.response.headers.keys()].length > 0;
|
|
104
|
+
return !wroteHeader && !ctx.cookies.personal;
|
|
105
|
+
}
|
|
106
|
+
|
|
42
107
|
/**
|
|
43
108
|
* `statics`, `assets`, `notFound` and `errorPage` are bytes from wherever the
|
|
44
109
|
* runtime keeps them; `publicFiles` is a Hono handler or null; `compress` is null
|
|
@@ -50,6 +115,24 @@ export const COMPRESSIBLE_FLOOR = 512;
|
|
|
50
115
|
* `subtle.digest`. Awaiting costs nothing on the first and is the only way to
|
|
51
116
|
* accept the second.
|
|
52
117
|
*
|
|
118
|
+
* The body is long because the order routes are registered in *is* the behavior,
|
|
119
|
+
* so it is written out once, in that order, rather than split across functions
|
|
120
|
+
* that could be called in a different one. What gets registered, in order:
|
|
121
|
+
*
|
|
122
|
+
* /assets/* hashed, so immutable
|
|
123
|
+
* sitemap, precache, feed, proxy each only if the config asked for it
|
|
124
|
+
* fragments and actions every route, before anything static
|
|
125
|
+
* endpoints before the static handler, which matches
|
|
126
|
+
* on path alone and would answer first
|
|
127
|
+
* prerendered pages bytes from disk
|
|
128
|
+
* pages every route, not only the dynamic ones
|
|
129
|
+
* not found
|
|
130
|
+
*
|
|
131
|
+
* The two rules worth knowing: a fragment or an action has to come before the
|
|
132
|
+
* prerendered handler, and an endpoint's path has no file behind it but
|
|
133
|
+
* `/api/notes` and a prerendered `/api/notes/index.html` look the same to a
|
|
134
|
+
* matcher.
|
|
135
|
+
*
|
|
53
136
|
* @param {{ config: object, manifest: object, pages: Record<string, object>,
|
|
54
137
|
* endpoints?: Record<string, object>, statics?: object, assets?: object,
|
|
55
138
|
* notFound?: object|null, errorPage?: object|null, hash: Function,
|
|
@@ -162,12 +245,6 @@ export function createApp({
|
|
|
162
245
|
*/
|
|
163
246
|
const varyOn = header ? `Accept-Encoding, ${header}` : 'Accept-Encoding';
|
|
164
247
|
|
|
165
|
-
/**
|
|
166
|
-
* Fragments and actions come first, and for every route rather than only the
|
|
167
|
-
* dynamic ones: a page whose document was prerendered still has regions worth
|
|
168
|
-
* asking for and mutations worth accepting, and the prerendered handler below
|
|
169
|
-
* matches on path alone, so it would answer either one with a static document.
|
|
170
|
-
*/
|
|
171
248
|
// Before the route table, like the public files, so a `[...path]` catch-all
|
|
172
249
|
// cannot answer for it.
|
|
173
250
|
if (config.sitemap) {
|
|
@@ -209,6 +286,12 @@ export function createApp({
|
|
|
209
286
|
app.get(PROXY_PATH, (c) => handler(c.req.raw));
|
|
210
287
|
}
|
|
211
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Fragments and actions come first, and for every route rather than only the
|
|
291
|
+
* dynamic ones: a page whose document was prerendered still has regions worth
|
|
292
|
+
* asking for and mutations worth accepting, and the prerendered handler below
|
|
293
|
+
* matches on path alone, so it would answer either one with a static document.
|
|
294
|
+
*/
|
|
212
295
|
for (const route of manifest.routes ?? []) {
|
|
213
296
|
app.get(route.pattern, async (c, next) => {
|
|
214
297
|
const region = regionOf(route, c);
|
|
@@ -313,12 +396,11 @@ export function createApp({
|
|
|
313
396
|
|
|
314
397
|
app.get(route.pattern, async (c) => {
|
|
315
398
|
try {
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
let rendered = null;
|
|
399
|
+
// The cache calls this when it needs a render and nothing calls it on a
|
|
400
|
+
// hit, so what it produced is left here for the lines after. A hit
|
|
401
|
+
// leaves it null, which is the difference the code below reads.
|
|
402
|
+
let last = null;
|
|
403
|
+
|
|
322
404
|
const render = async () => {
|
|
323
405
|
const ctx = contextFor(route, c);
|
|
324
406
|
const html = await renderRoute(pages[route.id], ctx, {
|
|
@@ -329,32 +411,27 @@ export function createApp({
|
|
|
329
411
|
include,
|
|
330
412
|
});
|
|
331
413
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
// Three ways a page is not a shared answer: it answered with a
|
|
335
|
-
// Response, it is not 2xx, or it is personal. Personal means a header
|
|
336
|
-
// was written *or* a cookie was read. The second half matters: a page
|
|
337
|
-
// that only reads a cookie and renders a count from it sets no header
|
|
338
|
-
// at all, and holding it would hand one visitor's count to the next.
|
|
339
|
-
const shared = [...ctx.response.headers.keys()].length === 0 && !ctx.cookies.personal;
|
|
340
|
-
return { html, cacheable: ok && shared };
|
|
414
|
+
last = { ctx, html };
|
|
415
|
+
return { html, cacheable: isShareable(html, ctx) };
|
|
341
416
|
};
|
|
342
417
|
|
|
343
|
-
if (window) {
|
|
344
|
-
|
|
418
|
+
if (!window) {
|
|
419
|
+
await render();
|
|
420
|
+
const { ctx, html } = last;
|
|
421
|
+
if (html instanceof Response) return withEnvelope(html, ctx);
|
|
422
|
+
return sendRendered(c, html, ctx, preload);
|
|
423
|
+
}
|
|
345
424
|
|
|
346
|
-
|
|
347
|
-
// `Response`. It was not stored, but it is still the answer.
|
|
348
|
-
if (html instanceof Response) return withEnvelope(html, rendered.ctx);
|
|
425
|
+
const html = await cache.read(cacheKey(c.req.url), window, render);
|
|
349
426
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
}
|
|
427
|
+
// A miss rendered through the cache, and that render can answer with a
|
|
428
|
+
// `Response`. It was not stored, but it is still the answer.
|
|
429
|
+
if (html instanceof Response) return withEnvelope(html, last.ctx);
|
|
354
430
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
431
|
+
// A hit ran no loader, so there is no envelope to carry: a page with a
|
|
432
|
+
// header was never stored. It still needs a context to send with.
|
|
433
|
+
const ctx = last ? last.ctx : contextFor(route, c);
|
|
434
|
+
return sendRendered(c, html, ctx, preload);
|
|
358
435
|
} catch (err) {
|
|
359
436
|
return internalError(c, err);
|
|
360
437
|
}
|
|
@@ -388,27 +465,6 @@ export function createApp({
|
|
|
388
465
|
}
|
|
389
466
|
}
|
|
390
467
|
|
|
391
|
-
/**
|
|
392
|
-
* What a page is going to ask for, said in a header.
|
|
393
|
-
*
|
|
394
|
-
* This is not streaming. Render is `__o += …` to the last component, which is
|
|
395
|
-
* what lets an include resolve before it and a prerendered page stay a file,
|
|
396
|
-
* and making it async would tax every component call for something most pages
|
|
397
|
-
* here do not need. So the body still leaves in one piece.
|
|
398
|
-
*
|
|
399
|
-
* What it does buy: the stylesheet and the client entry come from the route
|
|
400
|
-
* table, not from a loader, so they are known before any loader runs. A proxy
|
|
401
|
-
* that reads this sends a 103 and the browser fetches them while the page is
|
|
402
|
-
* still being made. Cloudflare and Fastly do. A browser reading it directly
|
|
403
|
-
* gets less, since these headers arrive with the body anyway.
|
|
404
|
-
*/
|
|
405
|
-
function preloadHeader(stylesheet, clientEntry) {
|
|
406
|
-
const parts = [];
|
|
407
|
-
if (stylesheet) parts.push(`<${stylesheet}>; rel=preload; as=style`);
|
|
408
|
-
if (clientEntry) parts.push(`<${clientEntry}>; rel=preload; as=script; crossorigin`);
|
|
409
|
-
return parts.length ? parts.join(', ') : null;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
468
|
/** Every `catch` above. One place decides what a failed request looks like. */
|
|
413
469
|
function internalError(c, err) {
|
|
414
470
|
report(err, c);
|
|
@@ -421,33 +477,6 @@ export function createApp({
|
|
|
421
477
|
return c.body(errorPage.body, 500);
|
|
422
478
|
}
|
|
423
479
|
|
|
424
|
-
/**
|
|
425
|
-
* Sends the best representation the client will accept. `Vary` is not optional
|
|
426
|
-
* here: without it a shared cache would serve one encoding to everyone.
|
|
427
|
-
*/
|
|
428
|
-
function send(c, entry, cacheControl, status = 200) {
|
|
429
|
-
// The key list is built once per entry rather than per request. An entry is
|
|
430
|
-
// produced at load time and never changes, so the spread was a fresh array
|
|
431
|
-
// for every hit on the same file.
|
|
432
|
-
entry.encodingList ??= [...entry.encodings.keys()];
|
|
433
|
-
const encoding = pickEncoding(c.req.header('accept-encoding'), entry.encodingList);
|
|
434
|
-
const chosen = encoding ? entry.encodings.get(encoding) : null;
|
|
435
|
-
|
|
436
|
-
const body = chosen?.body ?? entry.body;
|
|
437
|
-
const etag = chosen?.etag ?? entry.etag;
|
|
438
|
-
|
|
439
|
-
c.header('Vary', 'Accept-Encoding');
|
|
440
|
-
c.header('Cache-Control', cacheControl);
|
|
441
|
-
c.header('ETag', etag);
|
|
442
|
-
|
|
443
|
-
if (c.req.header('if-none-match') === etag) return c.body(null, 304);
|
|
444
|
-
|
|
445
|
-
if (chosen) c.header('Content-Encoding', encoding);
|
|
446
|
-
c.header('Content-Type', entry.type);
|
|
447
|
-
c.header('Content-Length', String(body.length));
|
|
448
|
-
return c.body(body, status);
|
|
449
|
-
}
|
|
450
|
-
|
|
451
480
|
/**
|
|
452
481
|
* A response rendered for this request. There is no prebuilt variant to reach
|
|
453
482
|
* for, so the ETag is computed here and the body is compressed on the way out.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The types a shim declares for itself, in one place.
|
|
2
|
+
//
|
|
3
|
+
// A shim writes JSDoc and transclude-env.d.ts writes TypeScript, so the same
|
|
4
|
+
// shape had two spellings and only one of them was ever written. Every context
|
|
5
|
+
// type in the emitted file named `__Cookies` and nothing declared it, which no
|
|
6
|
+
// check reported: a jsconfig.json implies `skipLibCheck`, and the guard in
|
|
7
|
+
// `bin/check.js` written to catch exactly this passes it too, so the file it
|
|
8
|
+
// checks is the one kind of file that flag skips.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Each entry is one type, written the way TypeScript spells it. `params` are the
|
|
12
|
+
* type parameters, which JSDoc writes as `@template` and a `.d.ts` writes in
|
|
13
|
+
* angle brackets.
|
|
14
|
+
*/
|
|
15
|
+
export const AMBIENT = [
|
|
16
|
+
{
|
|
17
|
+
name: '__CookieOptions',
|
|
18
|
+
params: [],
|
|
19
|
+
text:
|
|
20
|
+
"{ path?: string; domain?: string; maxAge?: number; expires?: Date; httpOnly?: boolean; secure?: boolean; sameSite?: 'Strict' | 'Lax' | 'None' }",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: '__Cookies',
|
|
24
|
+
params: [],
|
|
25
|
+
text:
|
|
26
|
+
'{ get(name: string): string | undefined; all(): Record<string, string>; ' +
|
|
27
|
+
'set(name: string, value: string, options?: __CookieOptions): void; ' +
|
|
28
|
+
'delete(name: string, options?: __CookieOptions): void; ' +
|
|
29
|
+
'signed: { get(name: string): Promise<string | undefined>; ' +
|
|
30
|
+
'all(): Promise<Record<string, string>>; ' +
|
|
31
|
+
'set(name: string, value: string, options?: __CookieOptions): Promise<void> } }',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
// The mapping is what keeps `${user.nmae}` an error. TypeScript treats a type
|
|
35
|
+
// that came straight from an object literal in a .js file as open for expando
|
|
36
|
+
// properties, so reading an undeclared one is allowed. Remapping the keys
|
|
37
|
+
// gives an ordinary object type, where it is not.
|
|
38
|
+
//
|
|
39
|
+
// The conditional widens a bare `[]`, which otherwise infers `never[]` and
|
|
40
|
+
// turns "no annotation" from "less checking" into a page of errors about a
|
|
41
|
+
// type nobody wrote.
|
|
42
|
+
name: '__Shape',
|
|
43
|
+
params: ['T'],
|
|
44
|
+
text: '{ [K in keyof T]: T[K] extends never[] ? any[] : T[K] }',
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export const AMBIENT_NAMES = new Set(AMBIENT.map(({ name }) => name));
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The JSDoc a shim carries for the given names. A name JSDoc cannot resolve is
|
|
52
|
+
* `any` rather than an error, so a shim that names one of these without this is
|
|
53
|
+
* checking nothing and saying so nowhere.
|
|
54
|
+
*
|
|
55
|
+
* @param {string[]} names
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function ambientJsdoc(names) {
|
|
59
|
+
return AMBIENT.filter(({ name }) => names.includes(name))
|
|
60
|
+
.map(({ name, params, text }) => {
|
|
61
|
+
const template = params.length ? ` * @template ${params.join(', ')}\n` : '';
|
|
62
|
+
return `/**\n${template} * @typedef {${text}} ${name}\n */\n`;
|
|
63
|
+
})
|
|
64
|
+
.join('');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The same types as TypeScript declarations, for the emitted file. Only the ones
|
|
69
|
+
* it mentions: an unused type in a generated file is noise.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} body what the file says so far
|
|
72
|
+
* @param {(type: string) => string} [format] how to lay a type out
|
|
73
|
+
* @returns {string[]} the lines to put above it
|
|
74
|
+
*/
|
|
75
|
+
export function ambientDeclarations(body, format = (type) => type) {
|
|
76
|
+
// One of these names another, so keep looking until a pass adds nothing:
|
|
77
|
+
// `__Cookies` alone would leave `__CookieOptions` undeclared, which is the
|
|
78
|
+
// whole bug again one level down.
|
|
79
|
+
const used = [];
|
|
80
|
+
for (let text = body, added = true; added; ) {
|
|
81
|
+
added = false;
|
|
82
|
+
for (const type of AMBIENT) {
|
|
83
|
+
if (used.includes(type) || !new RegExp(`\\b${type.name}\\b`).test(text)) continue;
|
|
84
|
+
used.push(type);
|
|
85
|
+
text += type.text;
|
|
86
|
+
added = true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!used.length) return [];
|
|
90
|
+
|
|
91
|
+
return [
|
|
92
|
+
'// Declared by the compiler. Every context type below names these.',
|
|
93
|
+
...used.map(({ name, params, text }) => {
|
|
94
|
+
const generics = params.length ? `<${params.join(', ')}>` : '';
|
|
95
|
+
return `type ${name}${generics} = ${format(text)};`;
|
|
96
|
+
}),
|
|
97
|
+
'',
|
|
98
|
+
];
|
|
99
|
+
}
|
package/src/compiler/bind.js
CHANGED
|
@@ -24,14 +24,10 @@
|
|
|
24
24
|
import { Scope, collectRefs, emit, parseExpr } from './expr.js';
|
|
25
25
|
import { splitInterpolations } from './interp.js';
|
|
26
26
|
import { childrenOf, gatherChain } from './codegen.js';
|
|
27
|
+
import { parseEach } from './directives.js';
|
|
28
|
+
import { RAW_TEXT, VOID } from './html.js';
|
|
27
29
|
|
|
28
30
|
const DIRECTIVES = new Set(['if', 'else-if', 'else', 'each', 'key']);
|
|
29
|
-
const VOID = new Set([
|
|
30
|
-
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
31
|
-
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
32
|
-
]);
|
|
33
|
-
const RAW_TEXT = new Set(['script', 'style']);
|
|
34
|
-
const EACH = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+of\s+([\s\S]+?)\s*$/;
|
|
35
31
|
|
|
36
32
|
/**
|
|
37
33
|
* @param {object[]} nodes the same parse5 nodes the renderer walked
|
|
@@ -140,13 +136,13 @@ class Bindgen {
|
|
|
140
136
|
let inner = scope;
|
|
141
137
|
|
|
142
138
|
const each = attrs.find((attr) => attr.name === 'each');
|
|
143
|
-
const spec = each
|
|
139
|
+
const spec = each ? parseEach(each.value) : null;
|
|
144
140
|
if (spec) {
|
|
145
|
-
this.giveUp(spec
|
|
141
|
+
this.giveUp(spec.list, scope);
|
|
146
142
|
inner = new Scope(scope);
|
|
147
143
|
// The name only has to exist for collectRefs to stop calling it data.
|
|
148
|
-
inner.declare(spec
|
|
149
|
-
if (spec
|
|
144
|
+
inner.declare(spec.item, spec.item);
|
|
145
|
+
if (spec.index) inner.declare(spec.index, spec.index);
|
|
150
146
|
}
|
|
151
147
|
|
|
152
148
|
for (const attr of attrs) {
|
|
@@ -265,11 +261,14 @@ class Bindgen {
|
|
|
265
261
|
* rebuild. -1 is "none of them", which an `if` with no `else` can be.
|
|
266
262
|
*/
|
|
267
263
|
emitBranchParts(id, branches) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
264
|
+
// Built from the last branch back, so each condition wraps the answer for
|
|
265
|
+
// everything after it. An `else` has no condition and ends the chain.
|
|
266
|
+
let pick = '-1';
|
|
267
|
+
for (let at = branches.length - 1; at >= 0; at--) {
|
|
268
|
+
const branch = branches[at];
|
|
269
|
+
if (branch.kind === 'else') pick = String(at);
|
|
270
|
+
else pick = `${this.js(branch.cond)} ? ${at} : ${pick}`;
|
|
271
|
+
}
|
|
273
272
|
// The condition may read the loop variables, so pick takes them too. The
|
|
274
273
|
// runtime hands every piece of a block the same arguments.
|
|
275
274
|
const outer = this.frame.loopArgs;
|
|
@@ -284,7 +283,7 @@ class Bindgen {
|
|
|
284
283
|
|
|
285
284
|
/** One part, reused for every item the loop produces. */
|
|
286
285
|
emitItemPart(id, element) {
|
|
287
|
-
const spec =
|
|
286
|
+
const spec = parseEach(element.attrs.find((attr) => attr.name === 'each').value);
|
|
288
287
|
if (!spec) return;
|
|
289
288
|
|
|
290
289
|
const outer = this.frame.loopArgs;
|
|
@@ -293,8 +292,8 @@ class Bindgen {
|
|
|
293
292
|
const index = `__i${depth}`;
|
|
294
293
|
|
|
295
294
|
const scope = new Scope(this.scope);
|
|
296
|
-
scope.declare(spec
|
|
297
|
-
if (spec
|
|
295
|
+
scope.declare(spec.item, item);
|
|
296
|
+
if (spec.index) scope.declare(spec.index, index);
|
|
298
297
|
|
|
299
298
|
const inner = [...outer, item, index];
|
|
300
299
|
// Same shape as a branch: the element itself, whose `each` is already
|
|
@@ -393,7 +392,11 @@ class Bindgen {
|
|
|
393
392
|
return `__b[${ref}]`;
|
|
394
393
|
};
|
|
395
394
|
// Descending only ever happens inside `bind`, so a stable path is fine.
|
|
396
|
-
const parentExpr = () =>
|
|
395
|
+
const parentExpr = () => {
|
|
396
|
+
if (ref !== null) return `__b[${ref}]`;
|
|
397
|
+
if (stable) return nodeExpr;
|
|
398
|
+
return slotFor();
|
|
399
|
+
};
|
|
397
400
|
|
|
398
401
|
for (const attr of node.attrs ?? []) {
|
|
399
402
|
if (DIRECTIVES.has(attr.name)) continue;
|
|
@@ -407,14 +410,18 @@ class Bindgen {
|
|
|
407
410
|
|
|
408
411
|
let value;
|
|
409
412
|
try {
|
|
410
|
-
|
|
411
|
-
parts.
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
413
|
+
if (parts.length === 1) {
|
|
414
|
+
value = this.js(parts[0].value);
|
|
415
|
+
} else {
|
|
416
|
+
// Several pieces, so each one is turned into a string and the lot
|
|
417
|
+
// concatenated. `__str` is what makes null and undefined empty rather
|
|
418
|
+
// than the words.
|
|
419
|
+
const pieces = parts.map((part) => {
|
|
420
|
+
if (part.type !== 'expr') return JSON.stringify(part.value);
|
|
421
|
+
return `__str(${this.js(part.value)})`;
|
|
422
|
+
});
|
|
423
|
+
value = pieces.join(' + ');
|
|
424
|
+
}
|
|
418
425
|
} catch {
|
|
419
426
|
this.giveUpText(attr.value);
|
|
420
427
|
continue;
|