@transclude/core 0.12.0 → 0.14.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/bin/build.js CHANGED
@@ -18,7 +18,7 @@ import transclude from '../src/plugin.js';
18
18
  import { loadProject } from '../src/project.js';
19
19
  import { renderRoute, urlFor } from '../src/document.js';
20
20
  import { prerenderContext, refusePrerender } from '../src/prerender.js';
21
- import { isGated, readGated } from '../src/gate.js';
21
+ import { isGated, readGated, unmatched } from '../src/gate.js';
22
22
  import { feed, feedPath } from '../src/feed.js';
23
23
  import { includeContext } from '../src/include.js';
24
24
  import { nodeLookup } from '../src/lookup.js';
@@ -28,6 +28,7 @@ import { buildSprite, readLibraries, refuseSpriteClash, spritePath } from '../sr
28
28
  import { PRECACHE_PATH, precacheDocument, precacheList } from '../src/precache.js';
29
29
  import { speculateSettings, speculationRules } from '../src/speculate.js';
30
30
  import { pool } from '../src/pool.js';
31
+ import { mappedFrames } from '../src/stack.js';
31
32
  import { precompress } from '../src/compress.js';
32
33
 
33
34
  const { root, config } = await loadProject();
@@ -104,6 +105,10 @@ await build({
104
105
  build: {
105
106
  outDir: `${config.outDir}/server`,
106
107
  emptyOutDir: true,
108
+ // The map that lets a prerender failure name the .html line. The bundler
109
+ // composes it from what the plugin's load hook returns, which is why the
110
+ // virtual ids carry no '\0' prefix: rolldown leaves '\0' modules out.
111
+ sourcemap: true,
107
112
  // `ssr: true` rather than a path: Vite resolves a string entry against the
108
113
  // project root before any plugin sees it, which a virtual id cannot survive.
109
114
  ssr: true,
@@ -120,6 +125,15 @@ await build({
120
125
  const entry = path.join(dist, 'server/entry.js');
121
126
  fs.writeFileSync(entry, `// @ts-nocheck\n${fs.readFileSync(entry, 'utf8')}`);
122
127
 
128
+ // The banner is one more line the map does not know about, so every position
129
+ // it reports would be off by one, in the direction that names the wrong line
130
+ // with full confidence. One empty group in front keeps every mapping true.
131
+ if (fs.existsSync(`${entry}.map`)) {
132
+ const shifted = JSON.parse(fs.readFileSync(`${entry}.map`, 'utf8'));
133
+ shifted.mappings = `;${shifted.mappings}`;
134
+ fs.writeFileSync(`${entry}.map`, JSON.stringify(shifted));
135
+ }
136
+
123
137
  // ---- prerender ------------------------------------------------------------
124
138
 
125
139
  const { pages, gated: declared } = await import(pathToFileURL(entry).href);
@@ -170,6 +184,11 @@ manifest.routes = manifest.routes.filter((route) => !isDraft(route));
170
184
  * string. A prerendered file is one file for every URL that resolves to it, so
171
185
  * `?q=` cannot change it.
172
186
  */
187
+ // Every URL `paths()` named, before the gate. The covers-nothing check below
188
+ // asks whether each gated entry could match anything, and a URL a gate held
189
+ // back is exactly a matched one, so the list has to be taken before filtering.
190
+ const namedByPaths = [];
191
+
173
192
  async function urlsFor(route) {
174
193
  if (pages[route.id]?.prerender === false) return [];
175
194
  if (!route.params.length) {
@@ -180,11 +199,12 @@ async function urlsFor(route) {
180
199
  if (typeof paths !== 'function') return [];
181
200
 
182
201
  const listed = (await paths()) ?? [];
183
- return listed
184
- .map((params) => ({ url: urlFor(route, params), params }))
185
- // Matched per URL, not per route: `/notes/[id]` can be open while
186
- // `/notes/secret` is not, and the pattern is the same for both.
187
- .filter(({ url }) => !isGated(url, gated));
202
+ const named = listed.map((params) => ({ url: urlFor(route, params), params }));
203
+ for (const { url } of named) namedByPaths.push(url);
204
+
205
+ // Matched per URL, not per route: `/notes/[id]` can be open while
206
+ // `/notes/secret` is not, and the pattern is the same for both.
207
+ return named.filter(({ url }) => !isGated(url, gated));
188
208
  }
189
209
 
190
210
  /**
@@ -237,6 +257,42 @@ for (const route of manifest.routes) {
237
257
  for (const target of urls) targets.push({ route, target });
238
258
  }
239
259
 
260
+ // ---- gated entries that cover nothing ---------------------------------------
261
+ //
262
+ // A typo in `gated` fails open: the entry matches nothing, the page it meant to
263
+ // hold back is written, and the build reports a success. So every entry has to
264
+ // cover something that exists: a page or endpoint pattern, a URL `paths()`
265
+ // named, or a public file, which the gate also guards at runtime.
266
+ {
267
+ const publicDir = config.publicDir ? path.join(root, config.appDir, config.publicDir) : null;
268
+ const files = [];
269
+ const walk = (dir, at) => {
270
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
271
+ if (entry.isDirectory()) walk(path.join(dir, entry.name), `${at}${entry.name}/`);
272
+ else files.push(`${at}${entry.name}`);
273
+ }
274
+ };
275
+ if (publicDir && fs.existsSync(publicDir)) walk(publicDir, '/');
276
+
277
+ const missed = unmatched(gated, {
278
+ patterns: [
279
+ ...manifest.routes.map((route) => route.pattern),
280
+ // A gated draft is a declared intent, not a typo.
281
+ ...drafts.map((route) => route.pattern),
282
+ ...manifest.endpoints.map((route) => route.pattern),
283
+ ],
284
+ urls: [...namedByPaths, ...files],
285
+ });
286
+
287
+ if (missed.length) {
288
+ throw new Error(
289
+ `[transclude] "gated" in app/server.js has ${missed.map((entry) => `"${entry}"`).join(', ')}, ` +
290
+ `which matches no route, no URL a paths() names, and no public file. ` +
291
+ `An entry that covers nothing fails open: the page it meant to hold back is written and served.`,
292
+ );
293
+ }
294
+ }
295
+
240
296
  if (manifest.notFound) {
241
297
  targets.push({
242
298
  route: { ...manifest.notFound, pattern: '' },
@@ -313,9 +369,26 @@ if (config.feed) {
313
369
  }
314
370
 
315
371
  if (failures.length) {
372
+ // Where each failure happened, in the author's file. The bundle's map is
373
+ // read exactly: a frame on a line the map says nothing about adds no
374
+ // position, rather than a neighbor's line with full confidence. The first
375
+ // mapped frame outside the runtime is the author's, because a throw that
376
+ // starts inside the runtime belongs to whatever line called it.
377
+ const mapFile = `${entry}.map`;
378
+ const bundleMap = fs.existsSync(mapFile) ? JSON.parse(fs.readFileSync(mapFile, 'utf8')) : null;
379
+ const positionOf = (error) => {
380
+ if (!bundleMap || typeof error?.stack !== 'string') return null;
381
+ const frames = mappedFrames(error.stack, 'server/entry.js', bundleMap);
382
+ const frame = frames.find((one) => !one.source.includes('/runtime/'));
383
+ if (!frame) return null;
384
+ const file = path.resolve(path.dirname(mapFile), frame.source);
385
+ return `${path.relative(root, file)}:${frame.line}`;
386
+ };
387
+
316
388
  console.error(`\n${failures.length} page${failures.length === 1 ? '' : 's'} failed to render:`);
317
389
  for (const failure of failures) {
318
- console.error(` ${failure.url}\n ${failure.error.message}`);
390
+ const at = positionOf(failure.error);
391
+ console.error(` ${failure.url}\n ${failure.error.message}${at ? `\n at ${at}` : ''}`);
319
392
  }
320
393
  process.exitCode = 1;
321
394
  }
package/bin/dev.js CHANGED
@@ -264,17 +264,24 @@ const handleAction = async (route, c) => {
264
264
  : sendFragment(route, c, region, extra);
265
265
  };
266
266
 
267
- const onError = (c, err) => {
267
+ const onError = (c, err, at = null) => {
268
268
  // Before anything reads the stack: Vite's transform means the raw one points
269
269
  // at generated code, and a reporter given that is worse than none.
270
270
  vite.ssrFixStacktrace(err);
271
271
  console.error(err);
272
272
 
273
- // The same seam production has, so a reporter is exercised while you are the
274
- // one looking at it rather than first on a live site.
273
+ // The same seam production has, with the same shape, so a reporter is
274
+ // exercised while you are the one looking at it rather than first on a live
275
+ // site. A field dev left null would read as a production bug later.
275
276
  if (typeof config.onError === 'function') {
276
277
  try {
277
- config.onError(err, { request: c.req.raw, url: c.req.url, method: c.req.method });
278
+ config.onError(err, {
279
+ request: c.req.raw,
280
+ url: c.req.url,
281
+ method: c.req.method,
282
+ route: at ? { id: at.route.id, pattern: at.route.pattern, params: c.req.param() } : null,
283
+ phase: at?.phase ?? null,
284
+ });
278
285
  } catch (failed) {
279
286
  console.error('[transclude] onError itself threw:', failed);
280
287
  }
@@ -389,7 +396,7 @@ async function buildApp() {
389
396
  const fragment = fragmentOf(c);
390
397
  return fragment === null ? await renderPage(route, c) : await sendFragment(route, c, fragment);
391
398
  } catch (err) {
392
- return onError(c, err);
399
+ return onError(c, err, { route, phase: fragmentOf(c) === null ? 'page' : 'fragment' });
393
400
  }
394
401
  });
395
402
 
@@ -400,7 +407,7 @@ async function buildApp() {
400
407
  try {
401
408
  return await handleAction(route, c);
402
409
  } catch (err) {
403
- return onError(c, err);
410
+ return onError(c, err, { route, phase: 'action' });
404
411
  }
405
412
  });
406
413
  }
@@ -422,7 +429,7 @@ async function buildApp() {
422
429
  Allow: endpointMethods(mod).join(', '),
423
430
  });
424
431
  } catch (err) {
425
- return onError(c, err);
432
+ return onError(c, err, { route, phase: 'endpoint' });
426
433
  }
427
434
  });
428
435
  }
@@ -432,7 +439,7 @@ async function buildApp() {
432
439
  try {
433
440
  return await renderPage(notFound, c, 404);
434
441
  } catch (err) {
435
- return onError(c, err);
442
+ return onError(c, err, { route: notFound, phase: 'page' });
436
443
  }
437
444
  });
438
445
 
package/bin/serve.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // Node adapter. The app is in src/production.js; this listens with it.
3
3
 
4
4
  import { serve } from '@hono/node-server';
5
+ import { drainOn } from '../src/drain.js';
5
6
  import { app, noBuild, port, summary } from '../src/production.js';
6
7
 
7
8
  if (noBuild) {
@@ -9,4 +10,8 @@ if (noBuild) {
9
10
  process.exit(1);
10
11
  }
11
12
 
12
- serve({ fetch: app.fetch, port }, ({ port }) => summary(port));
13
+ const server = serve({ fetch: app.fetch, port }, ({ port }) => summary(port));
14
+
15
+ // A container sends SIGTERM and waits. Node's default is to die on the spot,
16
+ // which cuts a render that was halfway through answering.
17
+ drainOn(server);
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joe Dakroub
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,30 @@
1
+ # transclude for VS Code
2
+
3
+ Diagnostics, hovers and syntax highlighting for the `.html` files of a
4
+ [transclude](https://transclude.dev) project.
5
+
6
+ A page here holds script blocks that are separate modules, which the editor's
7
+ built-in HTML support reads as one. This extension understands the real shape:
8
+ `${…}` is an expression, a directive is an expression, and a misspelled field
9
+ is an error with a line number, the same ones `npm run check` reports.
10
+
11
+ ## How it works
12
+
13
+ The extension ships no checker. It starts the language server that comes with
14
+ your project's own `@transclude/core`, so the diagnostics match the framework
15
+ version you build with. A workspace without a `transclude.config.js` is left
16
+ alone.
17
+
18
+ ## Settings
19
+
20
+ - `transclude.enable`: type check `.html` files in a transclude project. On by
21
+ default.
22
+
23
+ ## Building it yourself
24
+
25
+ ```sh
26
+ cd editor/vscode
27
+ npm install
28
+ npx @vscode/vsce package
29
+ code --install-extension transclude-0.1.0.vsix
30
+ ```
@@ -1,4 +1,4 @@
1
- // Starts the language server for workspaces that look like an transclude
1
+ // Starts the language server for workspaces that look like a transclude
2
2
  // project. Anything else is left alone. The grammar is harmless everywhere, and
3
3
  // the checker only makes sense where transclude.config.js exists.
4
4
 
@@ -18,10 +18,13 @@ function activate(context) {
18
18
  const root = folder.uri.fsPath;
19
19
  if (!fs.existsSync(path.join(root, 'transclude.config.js'))) return;
20
20
 
21
- // Installed, the server is in the package. In the framework's own repo it is
22
- // beside this file. Try both rather than assume a layout.
21
+ // Installed, the server is in the package, which is @transclude/core: the
22
+ // unscoped name pointed at a package that does not exist, so the server was
23
+ // found only inside the framework's own repository. In that repository it is
24
+ // beside this file. Try both rather than assume a layout, and a test pins the
25
+ // first path to the name in package.json.
23
26
  const server = [
24
- path.join(root, 'node_modules/transclude/editor/server.js'),
27
+ path.join(root, 'node_modules/@transclude/core/editor/server.js'),
25
28
  path.join(root, 'editor/server.js'),
26
29
  ].find((file) => fs.existsSync(file));
27
30
  if (!server) return;
@@ -0,0 +1,97 @@
1
+ {
2
+ "name": "transclude",
3
+ "version": "0.1.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "transclude",
9
+ "version": "0.1.0",
10
+ "license": "MIT",
11
+ "dependencies": {
12
+ "vscode-languageclient": "^9.0.1"
13
+ },
14
+ "engines": {
15
+ "vscode": "^1.85.0"
16
+ }
17
+ },
18
+ "node_modules/balanced-match": {
19
+ "version": "1.0.2",
20
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
21
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
22
+ "license": "MIT"
23
+ },
24
+ "node_modules/brace-expansion": {
25
+ "version": "2.1.4",
26
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
27
+ "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
28
+ "license": "MIT",
29
+ "dependencies": {
30
+ "balanced-match": "^1.0.0"
31
+ }
32
+ },
33
+ "node_modules/minimatch": {
34
+ "version": "5.1.9",
35
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
36
+ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
37
+ "license": "ISC",
38
+ "dependencies": {
39
+ "brace-expansion": "^2.0.1"
40
+ },
41
+ "engines": {
42
+ "node": ">=10"
43
+ }
44
+ },
45
+ "node_modules/semver": {
46
+ "version": "7.8.5",
47
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
48
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
49
+ "license": "ISC",
50
+ "bin": {
51
+ "semver": "bin/semver.js"
52
+ },
53
+ "engines": {
54
+ "node": ">=10"
55
+ }
56
+ },
57
+ "node_modules/vscode-jsonrpc": {
58
+ "version": "8.2.0",
59
+ "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
60
+ "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
61
+ "license": "MIT",
62
+ "engines": {
63
+ "node": ">=14.0.0"
64
+ }
65
+ },
66
+ "node_modules/vscode-languageclient": {
67
+ "version": "9.0.1",
68
+ "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-9.0.1.tgz",
69
+ "integrity": "sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==",
70
+ "license": "MIT",
71
+ "dependencies": {
72
+ "minimatch": "^5.1.0",
73
+ "semver": "^7.3.7",
74
+ "vscode-languageserver-protocol": "3.17.5"
75
+ },
76
+ "engines": {
77
+ "vscode": "^1.82.0"
78
+ }
79
+ },
80
+ "node_modules/vscode-languageserver-protocol": {
81
+ "version": "3.17.5",
82
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
83
+ "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
84
+ "license": "MIT",
85
+ "dependencies": {
86
+ "vscode-jsonrpc": "8.2.0",
87
+ "vscode-languageserver-types": "3.17.5"
88
+ }
89
+ },
90
+ "node_modules/vscode-languageserver-types": {
91
+ "version": "3.17.5",
92
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
93
+ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
94
+ "license": "MIT"
95
+ }
96
+ }
97
+ }
@@ -3,18 +3,42 @@
3
3
  "displayName": "transclude",
4
4
  "description": "Diagnostics, hovers and highlighting for transclude .html files",
5
5
  "version": "0.1.0",
6
+ "publisher": "transclude",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/transclude-dev/transclude.git",
11
+ "directory": "editor/vscode"
12
+ },
13
+ "keywords": [
14
+ "transclude",
15
+ "html",
16
+ "hypermedia",
17
+ "language-server"
18
+ ],
6
19
  "private": true,
7
- "engines": { "vscode": "^1.85.0" },
8
- "categories": ["Programming Languages"],
9
- "activationEvents": ["onLanguage:html"],
20
+ "engines": {
21
+ "vscode": "^1.85.0"
22
+ },
23
+ "categories": [
24
+ "Programming Languages"
25
+ ],
26
+ "activationEvents": [
27
+ "onLanguage:html"
28
+ ],
10
29
  "main": "./extension.js",
11
30
  "contributes": {
12
31
  "grammars": [
13
32
  {
14
33
  "scopeName": "transclude.injection",
15
34
  "path": "./syntaxes/transclude.injection.json",
16
- "injectTo": ["text.html.basic", "text.html.derivative"],
17
- "embeddedLanguages": { "meta.embedded.expression.transclude": "javascript" }
35
+ "injectTo": [
36
+ "text.html.basic",
37
+ "text.html.derivative"
38
+ ],
39
+ "embeddedLanguages": {
40
+ "meta.embedded.expression.transclude": "javascript"
41
+ }
18
42
  }
19
43
  ],
20
44
  "configuration": {
@@ -28,5 +52,7 @@
28
52
  }
29
53
  }
30
54
  },
31
- "dependencies": { "vscode-languageclient": "^9.0.1" }
55
+ "dependencies": {
56
+ "vscode-languageclient": "^9.0.1"
57
+ }
32
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transclude/core",
3
- "version": "0.12.0",
3
+ "version": "0.14.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",
package/src/app.js CHANGED
@@ -35,6 +35,11 @@ import { withDefaults } from './defaults.js';
35
35
 
36
36
  const IMMUTABLE = 'public, max-age=31536000, immutable';
37
37
  const REVALIDATE = 'public, max-age=0, must-revalidate';
38
+ // What a personal render says instead. `public` is an explicit grant, and a
39
+ // page that read a cookie is one visitor's. A conforming shared cache would
40
+ // revalidate and miss on the ETag anyway; this is for the CDN whose edge rule
41
+ // skips revalidation and would hand one visitor's page to the next.
42
+ const PERSONAL = 'private, no-cache';
38
43
 
39
44
  // One per process rather than one per render. It holds no state between calls.
40
45
  const encoder = new TextEncoder();
@@ -220,7 +225,7 @@ export function createApp({
220
225
  revalidateTag: cache.revalidateTag,
221
226
  // Reported through `report`, so work that fails after the reader is gone
222
227
  // is not quieter than work that fails in front of them.
223
- after: afterFor(c, (error) => report(error, c)),
228
+ after: afterFor(c, (error) => report(error, c, { route, phase: 'after' })),
224
229
  ...extra,
225
230
  };
226
231
  };
@@ -316,7 +321,7 @@ export function createApp({
316
321
  if (html === null) return c.text(`no fragment "${region}"`, 404);
317
322
  return sendRendered(c, html, ctx);
318
323
  } catch (err) {
319
- return internalError(c, err);
324
+ return internalError(c, err, { route, phase: 'fragment' });
320
325
  }
321
326
  });
322
327
 
@@ -369,7 +374,7 @@ export function createApp({
369
374
  if (html instanceof Response) return withEnvelope(html, ctx);
370
375
  return sendRendered(c, html, ctx);
371
376
  } catch (err) {
372
- return internalError(c, err);
377
+ return internalError(c, err, { route, phase: 'action' });
373
378
  }
374
379
  });
375
380
  }
@@ -391,7 +396,7 @@ export function createApp({
391
396
  Allow: endpointMethods(mod).join(', '),
392
397
  });
393
398
  } catch (err) {
394
- return internalError(c, err);
399
+ return internalError(c, err, { route, phase: 'endpoint' });
395
400
  }
396
401
  });
397
402
  }
@@ -450,7 +455,7 @@ export function createApp({
450
455
  // workerd stops the rebuild when this response is sent, and the entry it
451
456
  // leaves in the in-flight map answers every later request with a dead
452
457
  // promise.
453
- const after = afterFor(c, (error) => report(error, c));
458
+ const after = afterFor(c, (error) => report(error, c, { route, phase: 'revalidate' }));
454
459
  const html = await cache.read(cacheKey(c.req.url), window, render, after);
455
460
 
456
461
  // A miss rendered through the cache, and that render can answer with a
@@ -462,7 +467,7 @@ export function createApp({
462
467
  const ctx = last ? last.ctx : contextFor(route, c);
463
468
  return sendRendered(c, html, ctx, preload);
464
469
  } catch (err) {
465
- return internalError(c, err);
470
+ return internalError(c, err, { route, phase: 'page' });
466
471
  }
467
472
  });
468
473
  }
@@ -475,19 +480,28 @@ export function createApp({
475
480
  * `console.error` is the default and not much of one: a real site sends this
476
481
  * to something that can page a person. `onError` is that seam, and it is given
477
482
  * the request as well, because an error with no URL and no method is most of
478
- * the way to useless.
483
+ * the way to useless. `route` and `phase` say where: the reader starts at the
484
+ * loader of `people/[slug]` with `slug: 'ada'` rather than at a URL to
485
+ * re-derive that from. The phases are page, fragment, action, endpoint,
486
+ * after and revalidate.
479
487
  *
480
488
  * It is called inside a `try`. A reporter that throws would otherwise replace
481
489
  * the error being reported, which is the one failure mode a reporting hook
482
490
  * must not have.
483
491
  */
484
- function report(err, c) {
492
+ function report(err, c, at = null) {
485
493
  if (typeof config.onError !== 'function') {
486
494
  console.error(err);
487
495
  return;
488
496
  }
489
497
  try {
490
- config.onError(err, { request: c.req.raw, url: c.req.url, method: c.req.method });
498
+ config.onError(err, {
499
+ request: c.req.raw,
500
+ url: c.req.url,
501
+ method: c.req.method,
502
+ route: at ? { id: at.route.id, pattern: at.route.pattern, params: c.req.param() } : null,
503
+ phase: at?.phase ?? null,
504
+ });
491
505
  } catch (failed) {
492
506
  console.error(err);
493
507
  console.error('[transclude] onError itself threw:', failed);
@@ -495,8 +509,8 @@ export function createApp({
495
509
  }
496
510
 
497
511
  /** Every `catch` above. One place decides what a failed request looks like. */
498
- function internalError(c, err) {
499
- report(err, c);
512
+ function internalError(c, err, at = null) {
513
+ report(err, c, at);
500
514
  // No ETag and no Cache-Control: nothing about a failure should be stored or
501
515
  // revalidated, and the same bytes would be sent for an unrelated one next time.
502
516
  if (!errorPage) return c.text('Internal error', 500);
@@ -529,7 +543,10 @@ export function createApp({
529
543
  const etag = encoding ? `${base.slice(0, -1)}-${encoding}"` : base;
530
544
 
531
545
  c.header('Vary', varyOn);
532
- c.header('Cache-Control', REVALIDATE);
546
+ // The same test that gates the held-page store. A shareable render is
547
+ // anyone's; a personal one has to say so, or a cache told `public` would
548
+ // be within its rights to believe it.
549
+ c.header('Cache-Control', ctx && !isShareable(html, ctx) ? PERSONAL : REVALIDATE);
533
550
  c.header('ETag', etag);
534
551
 
535
552
  // Whatever the loaders put on `ctx.response`, after the defaults above so a
@@ -400,6 +400,7 @@ const MARK = {
400
400
  body: '/*@transclude:body*/',
401
401
  head: '/*@transclude:head*/',
402
402
  title: '/*@transclude:title*/',
403
+ server: '/*@transclude:server*/',
403
404
  };
404
405
 
405
406
  /**
@@ -451,10 +452,13 @@ export function compilePage(
451
452
  });
452
453
  assertIncludesResolve(template.regionIncludes, template.regions);
453
454
 
455
+ const serverAt = serverLines(blocks, server);
456
+
454
457
  const code = `
455
458
  ${runtimeImport(runtime)}
456
459
  ${componentImports(template.components)}
457
460
  ${layoutImports(layouts)}
461
+ ${MARK.server}
458
462
  ${server.code}
459
463
 
460
464
  export const css = ${JSON.stringify(blocks.styles.join('\n').trim())};
@@ -500,7 +504,9 @@ ${slotBodies(template)}
500
504
  }
501
505
  `;
502
506
 
503
- const mapped = withMap(code, template, source, sourcePath ?? `${filename}.html`);
507
+ const mapped = withMap(code, template, source, sourcePath ?? `${filename}.html`, [
508
+ { marker: MARK.server, at: serverAt },
509
+ ]);
504
510
 
505
511
  return {
506
512
  code: mapped.code,
@@ -510,6 +516,23 @@ ${slotBodies(template)}
510
516
  };
511
517
  }
512
518
 
519
+ /**
520
+ * The source line behind each line of the compiled loader.
521
+ *
522
+ * `bindDefaultExport` rewrites the export in place, so line i of the block's
523
+ * code is line `blocks.server.line + i` of the file. A page with no loader
524
+ * maps nothing: the placeholder is the compiler's own line.
525
+ *
526
+ * @param {object} blocks what `splitBlocks` returned
527
+ * @param {{ code: string }} server the bound loader
528
+ * @returns {number[]} one source line per line of `server.code`
529
+ */
530
+ function serverLines(blocks, server) {
531
+ if (!blocks.server) return [];
532
+ const start = blocks.server.line ?? 1;
533
+ return server.code.split('\n').map((_, i) => start + i);
534
+ }
535
+
513
536
  /**
514
537
  * The module, its markers removed, with a map from its lines to the file's.
515
538
  *
@@ -521,10 +544,13 @@ ${slotBodies(template)}
521
544
  * @param {object} template what `compileFragment` returned
522
545
  * @param {string} source the original `.html`
523
546
  * @param {string} filename how it should be named in a stack
547
+ * @param {Array<{ marker: string, at: (number|null)[] }>} [extra] blocks the
548
+ * template does not know about, which today is the loader
524
549
  * @returns {{ code: string, map: string|null }}
525
550
  */
526
- function withMap(code, template, source, filename) {
551
+ function withMap(code, template, source, filename, extra = []) {
527
552
  const blocks = [
553
+ ...extra,
528
554
  { marker: MARK.body, at: template.at?.body ?? [] },
529
555
  { marker: MARK.head, at: template.at?.head ?? [] },
530
556
  { marker: MARK.title, at: template.at?.title ?? [] },
@@ -548,10 +574,16 @@ function withMap(code, template, source, filename) {
548
574
  *
549
575
  * @param {string} source
550
576
  * @param {{ id: string, components?: Map<string, string>,
551
- * shadowTags?: Set<string>, runtime: string }} options
552
- * @returns {{ code: string, warnings: string[], components: string[] }}
577
+ * shadowTags?: Set<string>, runtime: string,
578
+ * sourcePath?: string|null }} options
579
+ * @returns {{ code: string, map: string|null, warnings: string[],
580
+ * components: string[] }} the module, a line-level map or null when there is
581
+ * nothing to map, the warnings, and the tags it used
553
582
  */
554
- export function compileLayout(source, { id, components = new Map(), shadowTags = new Set(), runtime }) {
583
+ export function compileLayout(
584
+ source,
585
+ { id, components = new Map(), shadowTags = new Set(), runtime, sourcePath = null },
586
+ ) {
555
587
  const blocks = splitBlocks(source);
556
588
  const where = `${id}/_layout.html <script server>`;
557
589
  const headWhere = `${id}/_layout.html <script head>`;
@@ -577,9 +609,12 @@ export function compileLayout(source, { id, components = new Map(), shadowTags =
577
609
  warnings.push('no <slot>, so nothing rendered inside this layout would appear');
578
610
  }
579
611
 
612
+ const serverAt = serverLines(blocks, server);
613
+
580
614
  const code = `
581
615
  ${runtimeImport(runtime)}
582
616
  ${componentImports(template.components)}
617
+ ${MARK.server}
583
618
  ${server.code}
584
619
 
585
620
  export const css = ${JSON.stringify(blocks.styles.join('\n').trim())};
@@ -594,6 +629,7 @@ export async function load(ctx) {
594
629
 
595
630
  export function renderTitle(__d) {
596
631
  let __o = '';
632
+ ${MARK.title}
597
633
  ${indent(template.title)}
598
634
  return __o;
599
635
  }
@@ -608,6 +644,7 @@ export function renderBodyAttrs(__d) {
608
644
 
609
645
  export function renderHead(__d) {
610
646
  let __o = '';
647
+ ${MARK.head}
611
648
  ${indent(template.head)}
612
649
  return __o;
613
650
  }
@@ -621,7 +658,11 @@ ${slotBodies(template)}
621
658
  export default { css, headScript, elements, hasTitle, load, renderTitle, renderHead, renderHtmlAttrs, renderBodyAttrs, render };
622
659
  `;
623
660
 
624
- return { code, warnings, components: template.components.map((c) => c.tag) };
661
+ const mapped = withMap(code, template, source, sourcePath ?? `${id}/_layout.html`, [
662
+ { marker: MARK.server, at: serverAt },
663
+ ]);
664
+
665
+ return { code: mapped.code, map: mapped.map, warnings, components: template.components.map((c) => c.tag) };
625
666
  }
626
667
 
627
668
  /**
package/src/cookies.js CHANGED
@@ -45,7 +45,9 @@ export function cookiesOf(request, response, secret = null) {
45
45
  // `typeof` along the way said `string`, and the config carried it all the
46
46
  // way here. The only thing that said otherwise was the length. Reading
47
47
  // "needs a secret" while looking at a secret that is plainly set sends you
48
- // hunting through the wiring instead of the value.
48
+ // hunting through the wiring instead of the value. `withDefaults` refuses
49
+ // the empty string at boot now; this stays for a `cookiesOf` reached
50
+ // without it.
49
51
  if (typeof secret === 'string') {
50
52
  throw new Error(
51
53
  `[transclude] ${what} needs a secret, and \`cookieSecret\` is set to an ` +
@@ -168,5 +170,18 @@ function overTls(request) {
168
170
  * turns the whole thing off. Set it yourself to override either way.
169
171
  */
170
172
  function withDefaults(options, request) {
171
- return { path: '/', httpOnly: true, sameSite: 'Lax', secure: overTls(request), ...options };
173
+ const merged = { path: '/', httpOnly: true, sameSite: 'Lax', secure: overTls(request), ...options };
174
+
175
+ // Every browser drops this pair, silently, so writing it is never right.
176
+ // `None` is for a cookie sent cross-site, and those are Secure-only
177
+ // everywhere. Refused here rather than left to the browser, because a cookie
178
+ // that never arrives reads exactly like a bug somewhere else.
179
+ if (String(merged.sameSite).toLowerCase() === 'none' && !merged.secure) {
180
+ throw new Error(
181
+ `[transclude] a cookie with \`sameSite: 'None'\` needs \`secure: true\`. Every ` +
182
+ `browser drops the pair without it, silently. Set both, or use 'Lax'.`,
183
+ );
184
+ }
185
+
186
+ return merged;
172
187
  }
package/src/defaults.js CHANGED
@@ -97,6 +97,19 @@ export function withDefaults(config = {}) {
97
97
 
98
98
  const merged = { ...DEFAULTS, ...config };
99
99
 
100
+ // Set but empty is refused at boot rather than at the first signed cookie,
101
+ // because that first read happens in production, at request time, days after
102
+ // the deploy that broke it. It happened: `wrangler secret put` took a blank
103
+ // line, so the binding existed and carried nothing. `null` stays fine, since
104
+ // that is how an app says it signs nothing.
105
+ if (merged.cookieSecret === '') {
106
+ throw new Error(
107
+ `[transclude] \`cookieSecret\` is an empty string. Whatever supplies it handed ` +
108
+ `over nothing: on a worker that is usually a \`wrangler secret put\` that took ` +
109
+ `a blank line. Set a real secret, or \`null\` for none.`,
110
+ );
111
+ }
112
+
100
113
  // Refused here because there are four places that render a page and only two of
101
114
  // them could fall back to a request's origin. Left to the render, `canonical`
102
115
  // would work in dev and throw in the build, which is the dev-and-production
package/src/drain.js ADDED
@@ -0,0 +1,63 @@
1
+ // Finishing what is in flight when the platform says stop.
2
+ //
3
+ // A container sends SIGTERM and waits a moment before SIGKILL. Node's default
4
+ // for SIGTERM is to die on the spot, so a render halfway through its loader
5
+ // answers nobody, and an action may have happened with its response cut on the
6
+ // wire. Draining instead refuses new connections, finishes what is running,
7
+ // and leaves.
8
+ //
9
+ // No imports. `process` and the timers are globals, and the server arrives as
10
+ // an argument.
11
+
12
+ /**
13
+ * Exit cleanly on a stop signal, once the work in flight is done.
14
+ *
15
+ * `close` stops the listener and waits for every open connection. A keep-alive
16
+ * connection counts as open with no request on it, so idle ones are swept
17
+ * while the close waits; without the sweep, the first browser that ever
18
+ * connected would hold the wait to the cap. The cap is for a render that
19
+ * hangs: past it, every connection is cut and the exit code says the drain was
20
+ * not clean. Both timers are unref'd, so neither keeps a finished process
21
+ * alive.
22
+ *
23
+ * @param {object} server what `serve` returned: a `node:http` server
24
+ * @param {{ signals?: string[], grace?: number, sweep?: number, exit?: Function }} [options]
25
+ * @returns {() => void} the drain itself, so a test can run one without a signal
26
+ */
27
+ export function drainOn(server, options = {}) {
28
+ const {
29
+ signals = ['SIGTERM', 'SIGINT'],
30
+ grace = 10_000,
31
+ sweep = 500,
32
+ exit = (code) => process.exit(code),
33
+ } = options;
34
+
35
+ // The cap and the close both want to be the exit. First one wins.
36
+ let left = false;
37
+ const leave = (code) => {
38
+ if (left) return;
39
+ left = true;
40
+ exit(code);
41
+ };
42
+
43
+ const drain = () => {
44
+ const idle = setInterval(() => server.closeIdleConnections?.(), sweep);
45
+ idle.unref?.();
46
+
47
+ const cap = setTimeout(() => {
48
+ server.closeAllConnections?.();
49
+ leave(1);
50
+ }, grace);
51
+ cap.unref?.();
52
+
53
+ server.close(() => {
54
+ clearInterval(idle);
55
+ clearTimeout(cap);
56
+ leave(0);
57
+ });
58
+ server.closeIdleConnections?.();
59
+ };
60
+
61
+ for (const signal of signals) process.once(signal, drain);
62
+ return drain;
63
+ }
package/src/gate.js CHANGED
@@ -36,12 +36,69 @@ export function isGated(url, patterns = []) {
36
36
  });
37
37
  }
38
38
 
39
+ /**
40
+ * Whether an entry could gate some URL of a route.
41
+ *
42
+ * A pattern is Hono's spelling: `/notes/:id` takes one segment, and a brace
43
+ * parameter like `/docs/:path{.+}` can take the rest of the path. This asks
44
+ * about possibility, not fact: `/notes/secret` covers `/notes/:id` whether or
45
+ * not `paths()` ever names it, and a brace parameter is taken to match
46
+ * anything, so an unsure answer errs toward covered rather than refused.
47
+ *
48
+ * @param {string} entry one gated path
49
+ * @param {string} pattern a route pattern
50
+ * @returns {boolean}
51
+ */
52
+ export function coversPattern(entry, pattern) {
53
+ const rest = entry.endsWith('/*');
54
+ const entrySegs = (rest ? entry.slice(0, -2) : entry).split('/').slice(1);
55
+ const patternSegs = pattern.split('/').slice(1);
56
+
57
+ for (let i = 0; i < patternSegs.length; i++) {
58
+ // The entry ran out. `/api/*` still covers whatever follows; `/api` does not.
59
+ if (i >= entrySegs.length) return rest;
60
+
61
+ const seg = patternSegs[i];
62
+ if (seg.startsWith(':')) {
63
+ if (seg.includes('{')) return true;
64
+ continue;
65
+ }
66
+ if (seg !== entrySegs[i]) return false;
67
+ }
68
+
69
+ // The pattern ran out. An entry asking for more segments than the route's
70
+ // URLs have covers none of them.
71
+ return rest || entrySegs.length === patternSegs.length;
72
+ }
73
+
74
+ /**
75
+ * The gated entries that cover nothing.
76
+ *
77
+ * A typo in `gated` fails open: the entry matches nothing, the page it meant to
78
+ * hold back is written, and the build reports a success. So the build asks
79
+ * whether each entry could ever match, and refuses the ones that could not.
80
+ *
81
+ * @param {string[]} gated
82
+ * @param {{ patterns?: string[], urls?: string[] }} site every route pattern,
83
+ * and every concrete URL the build knows: what `paths()` named, and the
84
+ * public files, which the gate also guards at runtime
85
+ * @returns {string[]} the entries with nothing to cover
86
+ */
87
+ export function unmatched(gated, { patterns = [], urls = [] }) {
88
+ return gated.filter(
89
+ (entry) =>
90
+ !patterns.some((pattern) => coversPattern(entry, pattern)) &&
91
+ !urls.some((url) => isGated(url, [entry])),
92
+ );
93
+ }
94
+
39
95
  /**
40
96
  * The declaration, or a refusal naming what is wrong with it.
41
97
  *
42
- * Checked rather than trusted, because every mistake here fails open. A typo
43
- * matches nothing, the page is written, and the build says it prerendered a page
44
- * that was supposed to need paying for.
98
+ * Checked rather than trusted, because a mistake here fails open: the page is
99
+ * written, and the build says it prerendered a page that was supposed to need
100
+ * paying for. This refuses the wrong shape. `unmatched` catches the typo that
101
+ * is still a path.
45
102
  *
46
103
  * @param {unknown} gated whatever `app/server.js` exported
47
104
  * @returns {string[]}
package/src/plugin.js CHANGED
@@ -241,18 +241,24 @@ export default function transclude({
241
241
 
242
242
  resolveId(id, importer) {
243
243
  if (duplicate) return null;
244
- if (id === SERVER_ENTRY || id === ELEMENTS_ENTRY) return '\0' + id;
244
+ // No '\0' prefix, on purpose. The convention marks a virtual id, and
245
+ // rolldown leaves '\0' modules out of the map it composes for a bundle,
246
+ // so `dist/server/entry.js.map` listed no page at all and a prerender
247
+ // failure could name no .html. Measured on Vite 8.2.1: with the prefix,
248
+ // no page is a source; without it, every page is. Resolution still ends
249
+ // here, because this hook answers for these ids before anything else.
250
+ if (id === SERVER_ENTRY || id === ELEMENTS_ENTRY) return id;
245
251
  if (
246
252
  id.startsWith(P_COMPONENT) ||
247
253
  id.startsWith(P_PAGE) ||
248
254
  id.startsWith(P_CLIENT) ||
249
255
  id.startsWith(P_LAYOUT)
250
256
  ) {
251
- return '\0' + id;
257
+ return id;
252
258
  }
253
259
  // A virtual module has no directory, so Vite cannot resolve `../data/x.js`
254
260
  // on its own. The block was authored in a real file; use that file's dir.
255
- if (importer?.startsWith('\0virtual:transclude-') && /^\.\.?\//.test(id)) {
261
+ if (importer?.startsWith('virtual:transclude-') && /^\.\.?\//.test(id)) {
256
262
  const source = origin.get(importer);
257
263
  if (source) return path.resolve(path.dirname(source), id);
258
264
  }
@@ -261,8 +267,8 @@ export default function transclude({
261
267
 
262
268
  load(id) {
263
269
  if (duplicate) return null;
264
- if (!id.startsWith('\0virtual:transclude-')) return null;
265
- const virt = id.slice(1);
270
+ if (!id.startsWith('virtual:transclude-')) return null;
271
+ const virt = id;
266
272
 
267
273
  // Every element in the app, not only the ones some page renders: a
268
274
  // fragment can name any of them, and which one it names is a runtime fact.
@@ -335,9 +341,16 @@ export const gated = ${hasMiddleware ? '__server.gated ?? []' : '[]'};
335
341
  const file = layouts.get(layoutId);
336
342
  if (!file) throw new Error(`[transclude] no layout "${layoutId}"`);
337
343
  origin.set(id, file);
338
- const out = compileLayout(read(file), { id: layoutId, components, shadowTags, runtime });
344
+ // `sourcePath` absolute for the same reason as the page's below.
345
+ const out = compileLayout(read(file), {
346
+ id: layoutId,
347
+ components,
348
+ shadowTags,
349
+ runtime,
350
+ sourcePath: file,
351
+ });
339
352
  report(`${layoutId} layout`, out.warnings);
340
- return out.code;
353
+ return out.map ? { code: out.code, map: out.map } : out.code;
341
354
  }
342
355
 
343
356
  if (virt.startsWith(P_PAGE)) {
@@ -387,7 +400,7 @@ export const gated = ${hasMiddleware ? '__server.gated ?? []' : '[]'};
387
400
 
388
401
  scan();
389
402
  for (const mod of server.moduleGraph.idToModuleMap.values()) {
390
- if (mod.id?.startsWith('\0virtual:transclude-')) server.moduleGraph.invalidateModule(mod);
403
+ if (mod.id?.startsWith('virtual:transclude-')) server.moduleGraph.invalidateModule(mod);
391
404
  }
392
405
  const hot = server.hot ?? server.ws;
393
406
  hot?.send({ type: 'full-reload' });
@@ -401,11 +414,15 @@ export const gated = ${hasMiddleware ? '__server.gated ?? []' : '[]'};
401
414
  /**
402
415
  * Browser URL for a virtual module id.
403
416
  *
417
+ * No `__x00__`, because the ids carry no '\0' prefix. That encoding is Vite's
418
+ * spelling of the prefix in a URL, and with it here the browser asked for a
419
+ * module the graph no longer holds, on every page that ships JS, in dev only.
420
+ *
404
421
  * @param {string} page the route id
405
422
  * @returns {string} the URL Vite serves its entry from
406
423
  */
407
424
  export function clientEntryUrl(page) {
408
- return `/@id/__x00__${P_CLIENT}${page}`;
425
+ return `/@id/${P_CLIENT}${page}`;
409
426
  }
410
427
 
411
428
  /**
package/src/proxy.js CHANGED
@@ -33,13 +33,26 @@ const DEFAULTS = {
33
33
 
34
34
  const STYLE_MODES = new Set(['keep', 'strip']);
35
35
 
36
+ /** Every key `proxy` may set. `lookup` has no default: absent means the runtime's. */
37
+ const KEYS = new Set([...Object.keys(DEFAULTS), 'lookup']);
38
+
36
39
  /**
37
- * Defaults filled in, and the one value worth checking checked. A misspelled
38
- * `styles` would keep every style attribute and say nothing, which reads exactly
39
- * like the setting working.
40
+ * Defaults filled in, and what the author wrote checked. A misspelled `maxage`
41
+ * would fall back to the default and say nothing, which reads exactly like the
42
+ * setting working. That is the failure the config's own keys refuse by name,
43
+ * one level up, and these keys get the same treatment.
40
44
  */
41
45
  function settings(options) {
42
46
  const config = { ...DEFAULTS, ...options };
47
+
48
+ const unknown = Object.keys(options ?? {}).filter((key) => !KEYS.has(key));
49
+ if (unknown.length) {
50
+ throw new Error(
51
+ `[transclude] \`proxy\` sets ${unknown.join(', ')}, which nothing reads. ` +
52
+ `The keys are ${[...KEYS].sort().join(', ')}.`,
53
+ );
54
+ }
55
+
43
56
  if (!STYLE_MODES.has(config.styles)) {
44
57
  throw new Error(
45
58
  `[transclude] proxy.styles is ${JSON.stringify(config.styles)}. It is 'keep' or 'strip'.`,
@@ -289,10 +302,12 @@ export function proxyHandler(options = {}, deps = {}) {
289
302
  try {
290
303
  const entry = await readForeign(url, config, { ...deps, store });
291
304
 
292
- // No id is a question about the document rather than a piece of it.
305
+ // No id is a question about the document rather than a piece of it, so
306
+ // the answer also says what the cleaning took out. The list was already
307
+ // kept for exactly this; nothing read it until here.
293
308
  if (!id) {
294
309
  const { listFragments } = await import('./extract.js');
295
- return json(200, { url, fragments: listFragments(entry.doc) });
310
+ return json(200, { url, fragments: listFragments(entry.doc), removed: entry.removed });
296
311
  }
297
312
 
298
313
  const found = resolveFragment(entry.doc, id);
package/src/rewrite.js CHANGED
@@ -81,15 +81,15 @@ export function sanitize(root, { styles = 'keep' } = {}) {
81
81
  removed.push('@style');
82
82
  return false;
83
83
  }
84
- return true;
85
- });
86
-
87
- for (const attr of child.attrs) {
84
+ // Removed rather than emptied. An empty value still means something:
85
+ // `href=""` names the page the fragment lands in, and `action=""`
86
+ // submits to it, neither of which the source wrote.
88
87
  if (!allowedUrl(child, attr)) {
89
88
  removed.push(`@${attr.name}`);
90
- attr.value = '';
89
+ return false;
91
90
  }
92
- }
91
+ return true;
92
+ });
93
93
 
94
94
  visit(child);
95
95
  }
@@ -100,7 +100,7 @@ export function sanitize(root, { styles = 'keep' } = {}) {
100
100
  }
101
101
 
102
102
  /**
103
- * Whether a URL-bearing attribute may keep its value.
103
+ * Whether a URL-bearing attribute may stay.
104
104
  *
105
105
  * `javascript:` is refused everywhere. `data:` is refused everywhere except an
106
106
  * image source, where it is ordinary and cannot navigate anything.
package/src/stack.js ADDED
@@ -0,0 +1,106 @@
1
+ // The author's position behind a frame in a bundled stack.
2
+ //
3
+ // Node can rewrite stacks itself, but its consumer takes the nearest earlier
4
+ // mapping when a position has none, and in a bundle the nearest mapping can
5
+ // belong to a different file. That answer arrives with full confidence: a
6
+ // throw in colophon.html was once reported as app/lib/code.js:81. So the map
7
+ // is read exactly here. A frame on a generated line the map says nothing
8
+ // about names no file, rather than the neighbor's.
9
+ //
10
+ // Pure. No `node:` imports: the caller reads the files, this reads the strings.
11
+
12
+ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
13
+
14
+ /**
15
+ * The numbers in one VLQ segment, the reverse of what the compiler writes.
16
+ *
17
+ * @param {string} segment
18
+ * @returns {number[]}
19
+ */
20
+ function unvlq(segment) {
21
+ const values = [];
22
+ let shift = 0;
23
+ let value = 0;
24
+
25
+ for (const ch of segment) {
26
+ const digit = ALPHABET.indexOf(ch);
27
+ value += (digit & 31) << shift;
28
+ if (digit & 32) {
29
+ shift += 5;
30
+ continue;
31
+ }
32
+ values.push(value & 1 ? -(value >>> 1) : value >>> 1);
33
+ shift = 0;
34
+ value = 0;
35
+ }
36
+
37
+ return values;
38
+ }
39
+
40
+ /**
41
+ * The frames of a stack that sit in one bundle, mapped to their sources.
42
+ *
43
+ * Only a frame whose generated line carries a mapping is returned. The source
44
+ * index and line are running totals across the whole `mappings` string, so
45
+ * every line is walked once, in order, whichever lines the stack asks about.
46
+ *
47
+ * @param {string} stack whatever `error.stack` holds
48
+ * @param {string} bundle how the bundle is named in a frame, like `server/entry.js`
49
+ * @param {{ sources: string[], mappings: string }} map the bundle's source map
50
+ * @returns {Array<{ source: string, line: number }>} outermost frame first
51
+ */
52
+ export function mappedFrames(stack, bundle, map) {
53
+ /** The bundle position a stack line names, or null. */
54
+ const positionOf = (line) => {
55
+ const at = line.indexOf(bundle);
56
+ if (at === -1) return null;
57
+ const found = line.slice(at + bundle.length).match(/^:(\d+):(\d+)/);
58
+ if (!found) return null;
59
+ return { line: Number(found[1]), column: Number(found[2]) };
60
+ };
61
+
62
+ const positions = stack.split('\n').map(positionOf).filter(Boolean);
63
+ if (!positions.length) return [];
64
+ const asked = new Set(positions.map((position) => position.line));
65
+
66
+ // One pass over the mappings, keeping only the lines the stack named. The
67
+ // source index and line are running totals across the whole string, so every
68
+ // line is walked whichever ones are kept.
69
+ const lines = map.mappings.split(';');
70
+ const kept = new Map();
71
+ let sourceIndex = 0;
72
+ let sourceLine = 0;
73
+
74
+ for (let i = 0; i < lines.length; i++) {
75
+ const decoded = [];
76
+ let column = 0;
77
+
78
+ for (const segment of lines[i] ? lines[i].split(',') : []) {
79
+ const fields = unvlq(segment);
80
+ column += fields[0];
81
+ if (fields.length < 4) continue;
82
+ sourceIndex += fields[1];
83
+ sourceLine += fields[2];
84
+ decoded.push({ column, source: map.sources[sourceIndex], line: sourceLine + 1 });
85
+ }
86
+
87
+ if (decoded.length && asked.has(i + 1)) kept.set(i + 1, decoded);
88
+ }
89
+
90
+ const frames = [];
91
+ for (const position of positions) {
92
+ const decoded = kept.get(position.line);
93
+ if (!decoded) continue;
94
+
95
+ // The nearest mapping at or before the column. Within one generated line
96
+ // every mapping is the same module's, so this cannot name a neighbor. A
97
+ // stack column is 1-based and a map column is not.
98
+ let hit = null;
99
+ for (const segment of decoded) {
100
+ if (segment.column <= position.column - 1) hit = segment;
101
+ }
102
+ if (hit) frames.push({ source: hit.source, line: hit.line });
103
+ }
104
+
105
+ return frames;
106
+ }
package/src/typecheck.js CHANGED
@@ -303,7 +303,7 @@ export function createChecker({
303
303
  `route: { id: string; pattern: string; path: string }; ` +
304
304
  `layout: ${layoutType}; request: Request | null; fragment: string | null; ` +
305
305
  `action: unknown; response: { status: number; headers: Headers }; ` +
306
- `cookies: __Cookies; htmlAttrs: Record<string, string | boolean | null>; ` +
306
+ `cookies: __Cookies; ` +
307
307
  `absolute: (path: string) => string; revalidateTag: (tag: string) => void; ` +
308
308
  `after: (work: Promise<unknown>) => void }`;
309
309