@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 +80 -7
- package/editor/vscode/LICENSE +21 -0
- package/editor/vscode/README.md +30 -0
- package/editor/vscode/extension.js +7 -4
- package/editor/vscode/package-lock.json +97 -0
- package/editor/vscode/package.json +32 -6
- package/package.json +1 -1
- package/src/compiler/index.js +47 -6
- package/src/gate.js +60 -3
- package/src/plugin.js +26 -9
- package/src/stack.js +106 -0
- package/src/typecheck.js +1 -1
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
22
|
-
//
|
|
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
|
|
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": {
|
|
8
|
-
|
|
9
|
-
|
|
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": [
|
|
17
|
-
|
|
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": {
|
|
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.
|
|
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/compiler/index.js
CHANGED
|
@@ -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
|
|
552
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
-
|
|
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
|
|
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('
|
|
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('
|
|
265
|
-
const virt = id
|
|
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
|
-
|
|
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('
|
|
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
|
|
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;
|
|
306
|
+
`cookies: __Cookies; ` +
|
|
307
307
|
`absolute: (path: string) => string; revalidateTag: (tag: string) => void; ` +
|
|
308
308
|
`after: (work: Promise<unknown>) => void }`;
|
|
309
309
|
|