@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 +81 -7
- package/bin/dev.js +1 -0
- 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/app.js +2 -0
- package/src/compiler/bind.js +36 -4
- package/src/compiler/codegen.js +59 -14
- package/src/compiler/index.js +49 -7
- package/src/defaults.js +58 -1
- package/src/document.js +38 -10
- package/src/gate.js +60 -3
- package/src/plugin.js +26 -9
- package/src/runtime/index.js +14 -0
- package/src/server.js +3 -1
- 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
|
/**
|
|
@@ -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
|
-
|
|
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
|
|
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/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
|
});
|
package/src/compiler/bind.js
CHANGED
|
@@ -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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
|
package/src/compiler/codegen.js
CHANGED
|
@@ -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`
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
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
|
|
364
|
-
* the runtime counts depth,
|
|
365
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
661
|
-
this.
|
|
662
|
-
this.c(out, `}`);
|
|
706
|
+
|
|
707
|
+
if (fenced) this.s(out, ANCHOR_CLOSE);
|
|
663
708
|
return;
|
|
664
709
|
}
|
|
665
710
|
|
package/src/compiler/index.js
CHANGED
|
@@ -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
|
|
551
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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/runtime/index.js
CHANGED
|
@@ -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,
|
|
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;
|
|
306
|
+
`cookies: __Cookies; ` +
|
|
307
307
|
`absolute: (path: string) => string; revalidateTag: (tag: string) => void; ` +
|
|
308
308
|
`after: (work: Promise<unknown>) => void }`;
|
|
309
309
|
|