@transclude/core 0.2.0 → 0.4.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/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
- // One render, called by the cache when it needs one and directly when
317
- // the route has no window. `cacheable` is the same rule the build uses
318
- // to decide a route can be a file: a 2xx with no header a file could not
319
- // carry. A `Set-Cookie` in a shared cache is one visitor's session
320
- // handed to the next.
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
- rendered = { ctx, html };
333
- const ok = !(html instanceof Response) && ctx.response.status < 300;
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
- const html = await cache.read(cacheKey(c.req.url), window, render);
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
- // A miss renders through the cache, and that render can answer with a
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
- // A hit ran no loader, so there is no envelope to carry. A cached page
351
- // has none by definition: one with a header was never stored.
352
- return sendRendered(c, html, rendered?.ctx ?? contextFor(route, c), preload);
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
- await render();
356
- if (rendered.html instanceof Response) return withEnvelope(rendered.html, rendered.ctx);
357
- return sendRendered(c, rendered.html, rendered.ctx, preload);
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
+ }
@@ -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 && EACH.exec(each.value);
139
+ const spec = each ? parseEach(each.value) : null;
144
140
  if (spec) {
145
- this.giveUp(spec[3], scope);
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[1], spec[1]);
149
- if (spec[2]) inner.declare(spec[2], spec[2]);
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
- const pick = branches.reduceRight(
269
- (rest, branch, at) =>
270
- branch.kind === 'else' ? String(at) : `${this.js(branch.cond)} ? ${at} : ${rest}`,
271
- '-1',
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 = EACH.exec(element.attrs.find((attr) => attr.name === 'each').value);
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[1], item);
297
- if (spec[2]) scope.declare(spec[2], index);
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 = () => (ref !== null ? `__b[${ref}]` : stable ? nodeExpr : slotFor());
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
- value =
411
- parts.length === 1
412
- ? this.js(parts[0].value)
413
- : parts
414
- .map((part) =>
415
- part.type === 'expr' ? `__str(${this.js(part.value)})` : JSON.stringify(part.value),
416
- )
417
- .join(' + ');
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;