@transclude/core 0.11.3 → 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
  /**
@@ -207,6 +227,7 @@ async function render(route, { url, params }) {
207
227
  stylesheet,
208
228
  csp: config.csp,
209
229
  lang: config.lang,
230
+ canonical: config.canonical,
210
231
  speculate: speculateRules,
211
232
  include,
212
233
  });
@@ -236,6 +257,42 @@ for (const route of manifest.routes) {
236
257
  for (const target of urls) targets.push({ route, target });
237
258
  }
238
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
+
239
296
  if (manifest.notFound) {
240
297
  targets.push({
241
298
  route: { ...manifest.notFound, pattern: '' },
@@ -312,9 +369,26 @@ if (config.feed) {
312
369
  }
313
370
 
314
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
+
315
388
  console.error(`\n${failures.length} page${failures.length === 1 ? '' : 's'} failed to render:`);
316
389
  for (const failure of failures) {
317
- 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}` : ''}`);
318
392
  }
319
393
  process.exitCode = 1;
320
394
  }
package/bin/dev.js CHANGED
@@ -191,6 +191,7 @@ const renderPage = async (route, c, status = null, extra = {}) => {
191
191
  stylesheet: config.stylesheet ? `/${config.stylesheet}` : null,
192
192
  csp: config.csp,
193
193
  lang: config.lang,
194
+ canonical: config.canonical,
194
195
  include,
195
196
  });
196
197
  // A loader answered for itself: a redirect, or something that is not a page.
@@ -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.11.3",
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",
package/src/app.js CHANGED
@@ -358,6 +358,7 @@ export function createApp({
358
358
  stylesheet: manifest.stylesheet,
359
359
  csp: config.csp,
360
360
  lang: config.lang,
361
+ canonical: config.canonical,
361
362
  // Written by the build and carried here, so a server-rendered
362
363
  // page says the same thing about speculation that a file does.
363
364
  speculate: manifest.speculate ?? null,
@@ -429,6 +430,7 @@ export function createApp({
429
430
  stylesheet: manifest.stylesheet,
430
431
  csp: config.csp,
431
432
  lang: config.lang,
433
+ canonical: config.canonical,
432
434
  speculate: manifest.speculate ?? null,
433
435
  include,
434
436
  });
@@ -69,6 +69,7 @@ class Bindgen {
69
69
  components = new Map(),
70
70
  shadowTags = new Set(),
71
71
  blockOf = new Map(),
72
+ anchoredOf = new Set(),
72
73
  refs = new Map(),
73
74
  } = {}) {
74
75
  this.components = components;
@@ -76,6 +77,10 @@ class Bindgen {
76
77
  // Which tree node owns which compiled block. Sharing the map is what keeps
77
78
  // this pass and the renderer from drifting apart over the same tree.
78
79
  this.blockOf = blockOf;
80
+ // The blocks the renderer fenced in anchors, which is more of them than it
81
+ // compiled. Shared for the same reason, and read for one question: is this a
82
+ // block the walk can step over.
83
+ this.anchoredOf = anchoredOf;
79
84
  // tag -> the local name the renderer imported that component under.
80
85
  this.refs = refs;
81
86
  this.frames = [new Frame(new Scope())];
@@ -165,6 +170,11 @@ class Bindgen {
165
170
  }
166
171
  }
167
172
 
173
+ /** A `<slot>` the renderer fenced, which is a light element's and never a shadow one's. */
174
+ isHole(node) {
175
+ return node.tagName === 'slot' && this.anchoredOf.has(node);
176
+ }
177
+
168
178
  abandon(slot) {
169
179
  if (slot.kind === 'text') {
170
180
  for (const node of slot.nodes) this.giveUpText(node.value);
@@ -208,14 +218,36 @@ class Bindgen {
208
218
  if (slot.kind === 'block') {
209
219
  const ref = this.bindBlock(slot, here);
210
220
  if (ref === null) {
211
- for (const rest of rendered.slice(i)) this.abandon(rest);
212
- this.frame.gaveUp = true;
213
- return;
221
+ // A light element's block: rendered once and never rebuilt. What it
222
+ // read is volatile and its nodes are nothing to hold, but its anchors
223
+ // are in the markup, so the walk goes on past it. A block without them
224
+ // has no past it, and nothing after it binds.
225
+ if (!this.anchoredOf.has(slot.nodes[0])) {
226
+ for (const rest of rendered.slice(i)) this.abandon(rest);
227
+ this.frame.gaveUp = true;
228
+ return;
229
+ }
230
+ this.abandon(slot);
214
231
  }
215
232
  // Past a block the node count is not knowable, so addressing becomes
216
233
  // relative from here on.
217
234
  cursor = cursor ?? this.cursor();
218
- this.locate(`${cursor} = __b[${ref}].end.nextSibling;`);
235
+ const past = ref === null ? `__afterBlock(${here})` : `__b[${ref}].end.nextSibling`;
236
+ this.locate(`${cursor} = ${past};`);
237
+ continue;
238
+ }
239
+
240
+ // A light element's `<slot>` is a compile-time hole holding the caller's
241
+ // markup, which is fenced for the same reason a block is. Nothing in it is
242
+ // ours to write, and the walk steps over it to reach what follows.
243
+ //
244
+ // The tag is asked for, not just the set: a branch arrives at its own part
245
+ // bare, with its directive already consumed, so a block's node reaches here
246
+ // as an ordinary element and is in the set too.
247
+ if (slot.kind === 'element' && this.isHole(slot.nodes[0])) {
248
+ this.abandon(slot);
249
+ cursor = cursor ?? this.cursor();
250
+ this.locate(`${cursor} = __afterBlock(${here});`);
219
251
  continue;
220
252
  }
221
253
 
@@ -98,6 +98,7 @@ export function compileFragment(nodes, opts = {}) {
98
98
  at,
99
99
  blockDefs: gen.blockDefs.join('\n'),
100
100
  blockOf: gen.blockOf,
101
+ anchoredOf: gen.anchoredOf,
101
102
  slots: Object.fromEntries(slots.map(([name, out]) => [name, out.code])),
102
103
  regions: Object.fromEntries(regions.map(([name, out]) => [name, out.code])),
103
104
  regionIncludes: gen.regionIncludes,
@@ -129,13 +130,16 @@ class Codegen {
129
130
  // `blocks` on, an `if` or `each` compiles to its own module-scope function,
130
131
  // and a `__fragment` passed from render would not be in scope inside it.
131
132
  this.fragments = fragments;
132
- // With `blocks` on, `if` and `each` at the top level compile to their own
133
- // function and are wrapped in comment anchors, so an update can re-render
134
- // one region instead of the whole shadow root. Only an element is ever
135
- // updated, so nothing else pays for the anchors.
136
- this.blocks = blocks && !layout;
133
+ // With `blocks` on, every `if` and `each` is wrapped in comment anchors, so
134
+ // whatever binds this markup can find where one ends. Only an element is ever
135
+ // updated, so nothing else pays for them.
136
+ this.blocks = blocks;
137
137
  this.blockDefs = [];
138
138
  this.blockOf = new Map();
139
+ // Every block that got anchors, whether or not it also got a function. The
140
+ // binding pass reads it the way it reads `blockOf`: to know that a block it
141
+ // cannot bind is still one it can step over.
142
+ this.anchoredOf = new Set();
139
143
  this.inBlock = 0;
140
144
  // The loop variables in scope, outermost first, two per level. A block
141
145
  // inside a loop renders from them, so its function has to take them.
@@ -360,14 +364,25 @@ class Codegen {
360
364
  }
361
365
 
362
366
  /**
363
- * True where a structural block is addressable on its own. Anchors nest and
364
- * the runtime counts depth, and a block inside a loop takes that loop's
365
- * variables as arguments, so nesting is not a reason to give up on either.
367
+ * True where a block is wrapped in anchors, which is where anything binds this
368
+ * markup at all. Anchors nest and the runtime counts depth, so nesting is not a
369
+ * reason to give up.
366
370
  */
367
- standalone() {
371
+ anchored() {
368
372
  return this.blocks && this.inHead === 0;
369
373
  }
370
374
 
375
+ /**
376
+ * True where a block also compiles to a function of its own, which is what
377
+ * re-rendering one region rather than the whole root needs. Not in a layout,
378
+ * and a light element is compiled as one: `<slot>` there is a compile-time hole
379
+ * reading `__slots`, a parameter of `render` that a module-scope block function
380
+ * would not have. A light element rebuilds nothing anyway.
381
+ */
382
+ standalone() {
383
+ return this.anchored() && !this.layout;
384
+ }
385
+
371
386
  /** Flat list of the loop variables in scope, outermost first. */
372
387
  loopArgs() {
373
388
  return this.loops.flatMap((loop) => [loop.item, loop.index]);
@@ -382,6 +397,7 @@ class Codegen {
382
397
  const id = this.blockDefs.length;
383
398
  const params = ['__d', ...args].join(', ');
384
399
  this.blockOf.set(node, id);
400
+ this.anchoredOf.add(node);
385
401
  this.blockDefs.push(
386
402
  `const __blk${id} = { ${extra}html: (${params}) => { let __o = '';\n${joinOut(body).code}\nreturn __o; } };`,
387
403
  );
@@ -400,7 +416,16 @@ class Codegen {
400
416
  this.emitBlock(chain[0].node, out, body, '', args);
401
417
  return;
402
418
  }
419
+ // Emitted where it stands, and still fenced. The markup is rendered once and
420
+ // nothing will replace it, but the anchors are how a walk gets to the nodes
421
+ // after it: what a branch renders is only known once the data is.
422
+ const fenced = this.anchored();
423
+ if (fenced) {
424
+ this.anchoredOf.add(chain[0].node);
425
+ this.s(out, ANCHOR_OPEN);
426
+ }
403
427
  this.emitBranches(chain, out, scope, topLevel);
428
+ if (fenced) this.s(out, ANCHOR_CLOSE);
404
429
  }
405
430
 
406
431
  emitBranches(chain, out, scope, topLevel) {
@@ -537,6 +562,7 @@ class Codegen {
537
562
  const id = this.blockDefs.length;
538
563
  this.blockDefs.push('');
539
564
  this.blockOf.set(el, id);
565
+ this.anchoredOf.add(el);
540
566
 
541
567
  // A <template each> renders several nodes per item, so an item is a
542
568
  // region rather than a node and needs anchors of its own to be found.
@@ -562,7 +588,15 @@ class Codegen {
562
588
  return;
563
589
  }
564
590
 
591
+ // The same fence an inline `if` gets, and for the same reason: how many nodes
592
+ // the loop produces is a question only the data answers.
593
+ const fenced = this.anchored();
594
+ if (fenced) {
595
+ this.anchoredOf.add(el);
596
+ this.s(out, ANCHOR_OPEN);
597
+ }
565
598
  this.emitEachBody(el, out, scope, topLevel);
599
+ if (fenced) this.s(out, ANCHOR_CLOSE);
566
600
  }
567
601
 
568
602
  /** `list`, `key` and `item`. One loop, taken apart so it can be reconciled. */
@@ -653,13 +687,24 @@ class Codegen {
653
687
  const filled = `__slots[${JSON.stringify(name)}]`;
654
688
  const fallback = childrenOf(el);
655
689
 
656
- if (!fallback.length) {
690
+ // The caller's markup, and how many nodes it is only this render knows. So
691
+ // it is fenced like a block, and for the same reason: nothing that binds
692
+ // this markup afterwards can count its way past it.
693
+ const fenced = this.anchored();
694
+ if (fenced) {
695
+ this.anchoredOf.add(el);
696
+ this.s(out, ANCHOR_OPEN);
697
+ }
698
+
699
+ if (fallback.length) {
700
+ this.c(out, `if (${filled}) { __o += ${filled}; } else {`);
701
+ this.emitChildren(fallback, out, scope);
702
+ this.c(out, `}`);
703
+ } else {
657
704
  this.c(out, `__o += ${filled} ?? '';`);
658
- return;
659
705
  }
660
- this.c(out, `if (${filled}) { __o += ${filled}; } else {`);
661
- this.emitChildren(fallback, out, scope);
662
- this.c(out, `}`);
706
+
707
+ if (fenced) this.s(out, ANCHOR_CLOSE);
663
708
  return;
664
709
  }
665
710
 
@@ -279,6 +279,7 @@ export function compileComponent(
279
279
  components,
280
280
  shadowTags,
281
281
  blockOf: template.blockOf,
282
+ anchoredOf: template.anchoredOf,
282
283
  refs: new Map(template.components.map(({ tag: name, ref }) => [name, ref])),
283
284
  // The runtime prepends <style> to the shadow root, so a component's own
284
285
  // first node is not at index 0. A light element's styles are hoisted
@@ -399,6 +400,7 @@ const MARK = {
399
400
  body: '/*@transclude:body*/',
400
401
  head: '/*@transclude:head*/',
401
402
  title: '/*@transclude:title*/',
403
+ server: '/*@transclude:server*/',
402
404
  };
403
405
 
404
406
  /**
@@ -450,10 +452,13 @@ export function compilePage(
450
452
  });
451
453
  assertIncludesResolve(template.regionIncludes, template.regions);
452
454
 
455
+ const serverAt = serverLines(blocks, server);
456
+
453
457
  const code = `
454
458
  ${runtimeImport(runtime)}
455
459
  ${componentImports(template.components)}
456
460
  ${layoutImports(layouts)}
461
+ ${MARK.server}
457
462
  ${server.code}
458
463
 
459
464
  export const css = ${JSON.stringify(blocks.styles.join('\n').trim())};
@@ -499,7 +504,9 @@ ${slotBodies(template)}
499
504
  }
500
505
  `;
501
506
 
502
- 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
+ ]);
503
510
 
504
511
  return {
505
512
  code: mapped.code,
@@ -509,6 +516,23 @@ ${slotBodies(template)}
509
516
  };
510
517
  }
511
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
+
512
536
  /**
513
537
  * The module, its markers removed, with a map from its lines to the file's.
514
538
  *
@@ -520,10 +544,13 @@ ${slotBodies(template)}
520
544
  * @param {object} template what `compileFragment` returned
521
545
  * @param {string} source the original `.html`
522
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
523
549
  * @returns {{ code: string, map: string|null }}
524
550
  */
525
- function withMap(code, template, source, filename) {
551
+ function withMap(code, template, source, filename, extra = []) {
526
552
  const blocks = [
553
+ ...extra,
527
554
  { marker: MARK.body, at: template.at?.body ?? [] },
528
555
  { marker: MARK.head, at: template.at?.head ?? [] },
529
556
  { marker: MARK.title, at: template.at?.title ?? [] },
@@ -547,10 +574,16 @@ function withMap(code, template, source, filename) {
547
574
  *
548
575
  * @param {string} source
549
576
  * @param {{ id: string, components?: Map<string, string>,
550
- * shadowTags?: Set<string>, runtime: string }} options
551
- * @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
552
582
  */
553
- 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
+ ) {
554
587
  const blocks = splitBlocks(source);
555
588
  const where = `${id}/_layout.html <script server>`;
556
589
  const headWhere = `${id}/_layout.html <script head>`;
@@ -576,9 +609,12 @@ export function compileLayout(source, { id, components = new Map(), shadowTags =
576
609
  warnings.push('no <slot>, so nothing rendered inside this layout would appear');
577
610
  }
578
611
 
612
+ const serverAt = serverLines(blocks, server);
613
+
579
614
  const code = `
580
615
  ${runtimeImport(runtime)}
581
616
  ${componentImports(template.components)}
617
+ ${MARK.server}
582
618
  ${server.code}
583
619
 
584
620
  export const css = ${JSON.stringify(blocks.styles.join('\n').trim())};
@@ -593,6 +629,7 @@ export async function load(ctx) {
593
629
 
594
630
  export function renderTitle(__d) {
595
631
  let __o = '';
632
+ ${MARK.title}
596
633
  ${indent(template.title)}
597
634
  return __o;
598
635
  }
@@ -607,6 +644,7 @@ export function renderBodyAttrs(__d) {
607
644
 
608
645
  export function renderHead(__d) {
609
646
  let __o = '';
647
+ ${MARK.head}
610
648
  ${indent(template.head)}
611
649
  return __o;
612
650
  }
@@ -620,7 +658,11 @@ ${slotBodies(template)}
620
658
  export default { css, headScript, elements, hasTitle, load, renderTitle, renderHead, renderHtmlAttrs, renderBodyAttrs, render };
621
659
  `;
622
660
 
623
- 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) };
624
666
  }
625
667
 
626
668
  /**
@@ -851,7 +893,7 @@ function unusedProps(defaultNode, reads, blocks) {
851
893
  // ---- module assembly helpers ---------------------------------------------
852
894
 
853
895
  function runtimeImport(runtime) {
854
- return `import { escape as __e, attr as __a, attrProp as __ap, str as __str, json, shadow as __sh, data as __data, included as __incl, textAt as __textAt, setText as __setText, setParts as __setParts, setAttr as __setAttr, setAttrProp as __setAttrProp, blockAt as __blockAt, updateBlock as __updateBlock, coerceProps, defineComponent, defineLight, html } from ${JSON.stringify(runtime)};`;
896
+ return `import { escape as __e, attr as __a, attrProp as __ap, str as __str, json, shadow as __sh, data as __data, included as __incl, textAt as __textAt, setText as __setText, setParts as __setParts, setAttr as __setAttr, setAttrProp as __setAttrProp, blockAt as __blockAt, afterBlock as __afterBlock, updateBlock as __updateBlock, coerceProps, defineComponent, defineLight, html } from ${JSON.stringify(runtime)};`;
855
897
  }
856
898
 
857
899
  function layoutImports(layouts) {
package/src/defaults.js CHANGED
@@ -34,6 +34,11 @@ export const DEFAULTS = {
34
34
  csrf: true,
35
35
  csp: false,
36
36
  speculate: false,
37
+ // `<link rel="canonical">` on every page, pointing at the page's own URL. Off
38
+ // by default because a page mounted at a second URL on purpose would get a
39
+ // wrong one, and a wrong canonical is worse than none: it hands the ranking to
40
+ // the other URL.
41
+ canonical: false,
37
42
  // `(source, file) => html`, and a `.md` page under `routes/` without one is an
38
43
  // error naming the file. This package ships no Markdown parser: which flavor
39
44
  // and which extensions are the app's to pick, the same way `cache` is a store
@@ -41,6 +46,31 @@ export const DEFAULTS = {
41
46
  markdown: null,
42
47
  };
43
48
 
49
+ /**
50
+ * Keys a config may set that have no default, listed so the check below does not
51
+ * read them as typos.
52
+ *
53
+ * A key is here when leaving it out has to mean something other than a value.
54
+ * There is no feed to write down as the default feed, and no store to write down
55
+ * as the default `cache`: absent is how an app says it wants neither.
56
+ */
57
+ const UNDEFAULTED = [
58
+ 'cache',
59
+ 'cookieSecret',
60
+ 'feed',
61
+ 'fragmentHeader',
62
+ 'metadataBase',
63
+ 'onError',
64
+ 'port',
65
+ 'precache',
66
+ 'proxy',
67
+ 'sitemap',
68
+ 'watchElements',
69
+ ];
70
+
71
+ /** Every key `transclude.config.js` may set. */
72
+ export const KEYS = new Set([...Object.keys(DEFAULTS), ...UNDEFAULTED]);
73
+
44
74
  /**
45
75
  * A config with every default filled in.
46
76
  *
@@ -50,7 +80,34 @@ export const DEFAULTS = {
50
80
  *
51
81
  * @param {object} [config] whatever `transclude.config.js` exported
52
82
  * @returns {object} the same keys, plus the ones it did not mention
83
+ * @throws when `canonical` is on and there is no origin to build a URL from
53
84
  */
54
85
  export function withDefaults(config = {}) {
55
- return { ...DEFAULTS, ...config };
86
+ // A key nothing reads is a line the author believes is doing something. The
87
+ // failure it replaces is silent and expensive: `stylesheeet` cost a site its
88
+ // whole stylesheet and said nothing, because an ignored key looks exactly like
89
+ // a key that worked.
90
+ const unknown = Object.keys(config).filter((key) => !KEYS.has(key));
91
+ if (unknown.length) {
92
+ throw new Error(
93
+ `[transclude] transclude.config.js sets ${unknown.join(', ')}, which nothing reads. ` +
94
+ `The keys are ${[...KEYS].sort().join(', ')}.`,
95
+ );
96
+ }
97
+
98
+ const merged = { ...DEFAULTS, ...config };
99
+
100
+ // Refused here because there are four places that render a page and only two of
101
+ // them could fall back to a request's origin. Left to the render, `canonical`
102
+ // would work in dev and throw in the build, which is the dev-and-production
103
+ // disagreement this file exists to stop.
104
+ if (merged.canonical && !merged.metadataBase) {
105
+ throw new Error(
106
+ `[transclude] \`canonical: true\` needs \`metadataBase\`, which is the origin the ` +
107
+ `URL is built from. A request's own origin is the wrong one twice: behind a proxy ` +
108
+ `it is the internal address, and a prerendered page has no request at all.`,
109
+ );
110
+ }
111
+
112
+ return merged;
56
113
  }
package/src/document.js CHANGED
@@ -177,6 +177,23 @@ const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
177
177
 
178
178
  const escapeAttr = (value) => String(value).replace(/[&<>"]/g, (c) => ESCAPES[c]);
179
179
 
180
+ /**
181
+ * The head the framework writes, as the outermost level of the merge.
182
+ *
183
+ * Through `mergeHead` rather than beside it, so a page or a layout writing its
184
+ * own `viewport` or `canonical` replaces this one instead of shipping a second
185
+ * copy. `charset` is not here: it has to be inside the first 1024 bytes, and it
186
+ * is not something to override.
187
+ *
188
+ * @param {string|null} canonical the page's own URL, absolute, or null for none
189
+ * @returns {string} one level's worth of head markup
190
+ */
191
+ function frameworkHead(canonical) {
192
+ const tags = ['<meta name="viewport" content="width=device-width, initial-scale=1">'];
193
+ if (canonical) tags.push(`<link rel="canonical" href="${escapeAttr(canonical)}">`);
194
+ return tags.join('\n');
195
+ }
196
+
180
197
  /**
181
198
  * `<html …>`, with `lang` first and whatever a loader added after it.
182
199
  *
@@ -239,7 +256,8 @@ function openTag(tag, attrs) {
239
256
  *
240
257
  * @param {object} page a compiled page module
241
258
  * @param {object} ctx the request context
242
- * @param {object} [options] `clientEntry`, `stylesheet`, `csp`, `lang`, `include`
259
+ * @param {object} [options] `clientEntry`, `stylesheet`, `csp`, `lang`, `include`,
260
+ * `canonical`
243
261
  * @returns {Promise<string|Response>} a Response when a loader answered for itself
244
262
  */
245
263
  export async function renderRoute(page, ctx, options = {}) {
@@ -266,7 +284,18 @@ export async function renderRoute(page, ctx, options = {}) {
266
284
  if (mod !== page) inherited = { ...inherited, ...data };
267
285
  }
268
286
 
269
- const html = renderDocument(chain, datas, options);
287
+ // The config's `canonical` is a yes or no; the document's is the URL. Turned
288
+ // into one here because this is the layer that holds the request, and because
289
+ // the four callers that render a page would otherwise each compute it.
290
+ //
291
+ // `route.path` and not the request's URL: a canonical URL names the page, so a
292
+ // query parameter has no place in it. That the path is already free of a
293
+ // trailing slash is Hono's doing under `trailingSlash: 'ignore'`, which is the
294
+ // setting that makes this option worth having.
295
+ const html = renderDocument(chain, datas, {
296
+ ...options,
297
+ canonical: options.canonical ? ctx.absolute(ctx.route.path) : null,
298
+ });
270
299
 
271
300
  // After the document exists, because the policy is built from what it inlined.
272
301
  // A prerendered page runs this once at build time and carries the result.
@@ -598,13 +627,15 @@ export function methodsOf(page) {
598
627
  * @param {object[]} chain the compiled modules, outermost first
599
628
  * @param {object[]} datas one per level, in the same order
600
629
  * @param {{ clientEntry?: string|null, stylesheet?: string|null, lang?: string,
601
- * speculate?: string|null }} [options]
630
+ * speculate?: string|null, canonical?: string|null }} [options] `canonical` is
631
+ * the URL itself, already absolute. `renderRoute` is what turns the config's
632
+ * yes-or-no into one, because this function sees no request.
602
633
  * @returns {string} the document, starting at `<!doctype html>`
603
634
  */
604
635
  export function renderDocument(
605
636
  chain,
606
637
  datas,
607
- { clientEntry, stylesheet, lang = 'en', speculate = null } = {},
638
+ { clientEntry, stylesheet, lang = 'en', speculate = null, canonical = null } = {},
608
639
  ) {
609
640
  // Each level renders to a slot map and hands it to the level above, so a page
610
641
  // can fill more than one hole in its layout.
@@ -624,13 +655,10 @@ export function renderDocument(
624
655
  }
625
656
 
626
657
  // Everything else accumulates outermost first, so a page's <meta> comes last
627
- // and a page's <style> can override a layout's.
628
- // The framework's own defaults go through the merge as the outermost level, so
629
- // a page or a layout writing its own `viewport` replaces this one instead of
630
- // shipping beside it. `charset` is not here: it has to be inside the first
631
- // 1024 bytes and is not something to override.
658
+ // and a page's <style> can override a layout's. The framework's own head is the
659
+ // outermost level; `frameworkHead` says what is in it and why.
632
660
  const [defaults, ...rest] = mergeHead([
633
- '<meta name="viewport" content="width=device-width, initial-scale=1">',
661
+ frameworkHead(canonical),
634
662
  ...chain.map((mod, i) => mod.renderHead(datas[i])),
635
663
  ]);
636
664
  const head = rest.filter(Boolean);
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
  /**
@@ -308,6 +308,20 @@ function closingAnchor(open) {
308
308
  return null;
309
309
  }
310
310
 
311
+ /**
312
+ * The node after a block nobody bound.
313
+ *
314
+ * A light element renders its blocks once and never rebuilds them, so it holds
315
+ * no state for one. The walk that finds every node after it still has to get
316
+ * past it, and how wide it is only the anchors say.
317
+ *
318
+ * @param {Comment} open the opening anchor
319
+ * @returns {Node|null}
320
+ */
321
+ export function afterBlock(open) {
322
+ return closingAnchor(open)?.nextSibling ?? null;
323
+ }
324
+
311
325
  /**
312
326
  * An item spans one node or, where it renders several, the region between its
313
327
  * own anchors. Everything downstream works on the range, so a single-element
package/src/server.js CHANGED
@@ -69,7 +69,9 @@ export function baseApp(options = {}) {
69
69
  * So 'never' means strict routing plus a 301 to the one URL, and every
70
70
  * URL this framework generates is already that form: `routes/about.html` is
71
71
  * `/about`. 'ignore' is the loose router, which answers both with 200. Two URLs
72
- * for one page, and nothing emits <link rel="canonical">.
72
+ * for one page, which is what `canonical: true` in the config answers: the
73
+ * loose router hands `c.req.path` over with the slash already gone, so the URL
74
+ * the tag names is the one form either way.
73
75
  *
74
76
  * `alwaysRedirect` matters because Hono's default only redirects a request that
75
77
  * already 404'd, and a catch-all route answers before it can. `/docs/intro/`
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