@transclude/core 0.12.0 → 0.13.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
  }
@@ -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.13.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",
@@ -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/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/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