@transclude/core 0.2.0 → 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/README.md CHANGED
@@ -1,4 +1,21 @@
1
- # transclude
1
+ <h1 align="center">transclude</h1>
2
+
3
+ <p align="center">An HTML-first server-side web framework.</p>
4
+
5
+ <p align="center">
6
+ <a href="https://github.com/transclude-dev/transclude/actions/workflows/ci.yml"
7
+ ><img alt="CI" src="https://github.com/transclude-dev/transclude/actions/workflows/ci.yml/badge.svg"
8
+ /></a>
9
+ <a href="https://www.npmjs.com/package/@transclude/core"
10
+ ><img alt="npm" src="https://img.shields.io/npm/v/%40transclude%2Fcore?color=0b7285"
11
+ /></a>
12
+ <a href="https://transclude.dev/docs/runtimes"
13
+ ><img alt="node" src="https://img.shields.io/node/v/%40transclude%2Fcore?color=0b7285"
14
+ /></a>
15
+ <a href="https://github.com/transclude-dev/transclude/blob/main/LICENSE"
16
+ ><img alt="MIT" src="https://img.shields.io/npm/l/%40transclude%2Fcore?color=0b7285"
17
+ /></a>
18
+ </p>
2
19
 
3
20
  HTML is the product. A page is an `.html` file, the directory tree is the route
4
21
  table, and any fragment of a page is a URL of its own. Nothing has to run in the
@@ -52,7 +69,7 @@ so a swap cannot drift from the page it replaces part of.
52
69
 
53
70
  ## What is in it
54
71
 
55
- - **Pages and endpoints.** An `.html` file answers GET; its `POST`, `PUT`,
72
+ - **Pages and endpoints.** An `.html` file responds to GET; its `POST`, `PUT`,
56
73
  `PATCH` and `DELETE` exports answer the rest, so a plain `<form method="post">`
57
74
  works. A `.js` file in the same tree returns a `Response`.
58
75
  - **Fragments.** Mark an element `fragment` and it has a URL of its own. htmx,
@@ -106,6 +123,7 @@ npm run htmx # the same, driven by htmx, on http://localhost:1965
106
123
  npm run includes # transclusion on http://localhost:1966
107
124
  npm run auth # a guarded section on http://localhost:1967
108
125
  npm run live # server-sent events on http://localhost:1968
126
+ npm run elements # light and shadow elements on http://localhost:1969
109
127
  npm run check:src # type-check the framework itself
110
128
  ```
111
129
 
@@ -115,7 +133,8 @@ prerendered site with a sitemap and a feed, `search` swaps a fragment into a
115
133
  page that works without it, `htmx` does the same with htmx and the
116
134
  `HX-Target` header, `includes` shows transclusion from three sources,
117
135
  `auth` guards a section with a layout and a signed cookie, `live` pushes
118
- updates over server-sent events, and `showcase` uses every feature and is where the
136
+ updates over server-sent events, `elements` puts a light and a shadow element
137
+ side by side, and `showcase` uses every feature and is where the
119
138
  browser checks live, because those need an app to run against. `www/` is the site at transclude.dev: a landing page, the
120
139
  documentation under `/docs`, and itself built with the framework.
121
140
 
package/bin/build.js CHANGED
@@ -16,7 +16,7 @@ import { pathToFileURL } from 'node:url';
16
16
  import { build } from 'vite';
17
17
  import transclude from '../src/plugin.js';
18
18
  import { loadProject } from '../src/project.js';
19
- import { absoluteFrom, renderRoute, responseOf } from '../src/document.js';
19
+ import { absoluteFrom, renderRoute, responseOf, urlFor } from '../src/document.js';
20
20
  import { feed, feedPath } from '../src/feed.js';
21
21
  import { includeContext } from '../src/include.js';
22
22
  import { nodeLookup } from '../src/lookup.js';
@@ -111,9 +111,15 @@ await build({
111
111
  },
112
112
  });
113
113
 
114
+ // A worker entry imports this bundle, and an editor set to check the app's JS
115
+ // then checks a file nobody wrote. Twenty errors in the showcase, all of them
116
+ // about generated code. The banner is what keeps it out of that program.
117
+ const entry = path.join(dist, 'server/entry.js');
118
+ fs.writeFileSync(entry, `// @ts-nocheck\n${fs.readFileSync(entry, 'utf8')}`);
119
+
114
120
  // ---- prerender ------------------------------------------------------------
115
121
 
116
- const { pages } = await import(pathToFileURL(path.join(dist, 'server/entry.js')).href);
122
+ const { pages } = await import(pathToFileURL(entry).href);
117
123
 
118
124
  /**
119
125
  * A static route has one URL. A dynamic route has as many as its `paths` export
@@ -134,7 +140,7 @@ async function urlsFor(route) {
134
140
 
135
141
  const listed = (await paths()) ?? [];
136
142
  return listed.map((params) => ({
137
- url: route.pattern.replace(/:(\w+)(\{[^}]*\})?/g, (_, name) => String(params[name] ?? '')),
143
+ url: urlFor(route, params),
138
144
  params,
139
145
  }));
140
146
  }
package/bin/check.js CHANGED
@@ -23,7 +23,22 @@ if (!fs.existsSync(types) || fs.readFileSync(types, 'utf8') !== next) {
23
23
 
24
24
  // Nothing downstream reads this file, so nothing else would notice it being
25
25
  // wrong. Parse what we just wrote, or a bad identifier ships silently.
26
- const emitted = ts.createProgram([types], { noEmit: true, skipLibCheck: true });
26
+ //
27
+ // `skipLibCheck` has to be off, and it was on. This is a .d.ts, which is the one
28
+ // kind of file that flag skips, so the guard checked nothing at all: every
29
+ // project shipped a file naming `__Cookies` and declaring it nowhere. An editor
30
+ // missed it too, because a jsconfig.json implies the same flag.
31
+ //
32
+ // `types: []` keeps it to this file: whatever `@types` a project happens to have
33
+ // installed is not what is being checked here, and one of them failing to
34
+ // resolve its own dependency would read as our file being broken.
35
+ const emitted = ts.createProgram([types], {
36
+ noEmit: true,
37
+ skipLibCheck: false,
38
+ types: [],
39
+ target: ts.ScriptTarget.ESNext,
40
+ lib: ['lib.esnext.d.ts', 'lib.dom.d.ts'],
41
+ });
27
42
  const broken = [
28
43
  ...emitted.getSyntacticDiagnostics(),
29
44
  ...emitted.getSemanticDiagnostics(),
@@ -63,16 +78,23 @@ for (const file of files) {
63
78
  const text = lines[line - 1] ?? '';
64
79
  const trimmed = text.replace(/^\s+/, '');
65
80
  const shift = text.length - trimmed.length;
81
+ // The caret line is drawn under the trimmed source, so the column moves left
82
+ // by however much indentation was cut. A run is capped so one long span does
83
+ // not wrap the terminal.
84
+ const pad = ' '.repeat(Math.max(0, column - shift));
85
+ const run = '~'.repeat(Math.max(1, Math.min(diagnostic.length, 60)));
86
+
66
87
  console.log(`\n ${trimmed}`);
67
- console.log(` ${' '.repeat(Math.max(0, column - shift))}${'~'.repeat(Math.max(1, Math.min(diagnostic.length, 60)))}`);
88
+ console.log(` ${pad}${run}`);
68
89
  }
69
90
  }
70
91
 
71
- const total = errors + warnings;
72
- console.log(
73
- total
74
- ? `\n${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'} in ${files.length} files`
75
- : `\nNo type errors in ${files.length} files.`,
76
- );
92
+ const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
93
+
94
+ if (errors + warnings) {
95
+ console.log(`\n${plural(errors, 'error')}, ${plural(warnings, 'warning')} in ${files.length} files`);
96
+ } else {
97
+ console.log(`\nNo type errors in ${files.length} files.`);
98
+ }
77
99
 
78
100
  process.exitCode = errors ? 1 : 0;
package/bin/dev.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  runAction,
20
20
  withEnvelope,
21
21
  } from '../src/document.js';
22
- import { clientEntryUrl, pageModuleId } from '../src/plugin.js';
22
+ import transclude, { clientEntryUrl, pageModuleId } from '../src/plugin.js';
23
23
  import { resolveRoutesDir, scanRoutes } from '../src/routes.js';
24
24
  import { baseApp, endpointMethods, runEndpoint, SERVER_FILE } from '../src/server.js';
25
25
  import { randomBytes } from 'node:crypto';
@@ -71,6 +71,11 @@ const server = http.createServer();
71
71
  const vite = await createViteServer({
72
72
  root,
73
73
  appType: 'custom',
74
+ // Passed here rather than left to the project's own `vite.config.js`, which is
75
+ // where dev used to get it. A project needs no Vite config at all, and the one
76
+ // built here is the one `loadProject` filled in, so dev compiles against the
77
+ // same config the build does. `configResolved` ignores a second registration.
78
+ plugins: [transclude(config)],
74
79
  server: { middlewareMode: true, hmr: { server } },
75
80
  // Vite would serve these itself, ahead of Hono, and production would serve
76
81
  // them a different way, which is how dev and production come to disagree. One
@@ -229,7 +234,10 @@ async function loadMiddleware() {
229
234
  if (!fs.existsSync(serverFile)) return null;
230
235
 
231
236
  const url = `/${config.appDir}/${SERVER_FILE}`;
232
- const node = await vite.moduleGraph.getModuleByUrl(url, true);
237
+ // Vite's second argument is `ssr`. This module is only ever loaded through
238
+ // `ssrLoadModule`, so the SSR graph is the one holding it.
239
+ const ssr = true;
240
+ const node = await vite.moduleGraph.getModuleByUrl(url, ssr);
233
241
  if (node) vite.moduleGraph.invalidateModule(node);
234
242
 
235
243
  const mod = await vite.ssrLoadModule(url);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transclude/core",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "An HTML-first server framework. A page is an .html file, the directory tree is the route table, and any fragment of a page is a URL of its own. Runs on Node, Bun, Deno and workerd, and ships no client JavaScript by default.",
5
5
  "keywords": [
6
6
  "html",
@@ -57,7 +57,7 @@
57
57
  ],
58
58
  "scripts": {
59
59
  "test": "node --test \"test/**/*.test.js\"",
60
- "test:examples": "npm test --prefix examples/showcase && npm test --prefix examples/todomvc && npm test --prefix examples/blog && npm test --prefix examples/search && npm test --prefix examples/htmx && npm test --prefix examples/includes && npm test --prefix examples/auth && npm test --prefix examples/live",
60
+ "test:examples": "npm test --prefix examples/showcase && npm test --prefix examples/todomvc && npm test --prefix examples/blog && npm test --prefix examples/search && npm test --prefix examples/htmx && npm test --prefix examples/includes && npm test --prefix examples/auth && npm test --prefix examples/live && npm test --prefix examples/elements",
61
61
  "test:www": "npm test --prefix www",
62
62
  "showcase": "npm run dev --prefix examples/showcase",
63
63
  "todomvc": "npm run dev --prefix examples/todomvc",
@@ -67,6 +67,7 @@
67
67
  "includes": "npm run dev --prefix examples/includes",
68
68
  "auth": "npm run dev --prefix examples/auth",
69
69
  "live": "npm run dev --prefix examples/live",
70
+ "elements": "npm run dev --prefix examples/elements",
70
71
  "www": "npm run dev --prefix www",
71
72
  "check:src": "tsc -p tsconfig.src.json",
72
73
  "release": "node bin/release.js"
@@ -96,7 +96,7 @@ the render, which is how a layout does a login redirect.
96
96
 
97
97
  ## Forms and actions
98
98
 
99
- A page answers GET with its loader. Other verbs are named exports on the same
99
+ A page responds to GET with its loader. Other verbs are named exports on the same
100
100
  file.
101
101
 
102
102
  ```html
package/src/address.js CHANGED
@@ -69,8 +69,15 @@ export function parseV6(text) {
69
69
  const halves = body.split('::');
70
70
  if (halves.length > 2) return null;
71
71
 
72
- const read = (part) =>
73
- part === '' ? [] : part.split(':').map((g) => (/^[0-9a-f]{1,4}$/i.test(g) ? parseInt(g, 16) : NaN));
72
+ // Each half of a `::` is groups of up to four hex digits. A group that is not
73
+ // becomes NaN, which the caller checks for rather than throwing here.
74
+ const read = (part) => {
75
+ if (part === '') return [];
76
+ return part.split(':').map((group) => {
77
+ if (!/^[0-9a-f]{1,4}$/i.test(group)) return NaN;
78
+ return parseInt(group, 16);
79
+ });
80
+ };
74
81
 
75
82
  const head = read(halves[0]);
76
83
  const rest = halves.length === 2 ? read(halves[1]) : [];
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
+ }