@transclude/core 0.5.0 → 0.7.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 +44 -42
- package/package.json +1 -1
- package/skills/transclude/SKILL.md +6 -0
- package/skills/transclude/references/elements.md +54 -0
- package/skills/transclude/references/server.md +79 -0
- package/src/after.js +67 -0
- package/src/app.js +8 -0
- package/src/compiler/codegen.js +41 -0
- package/src/document.js +4 -2
- package/src/prerender.js +110 -0
- package/src/project.js +1 -0
- package/src/runtime/index.js +49 -2
- package/src/speculate.js +103 -0
- package/src/typecheck.js +4 -2
package/bin/build.js
CHANGED
|
@@ -16,7 +16,8 @@ import { pathToFileURL } from 'node:url';
|
|
|
16
16
|
import { build } from 'vite';
|
|
17
17
|
import transclude from '../src/plugin.js';
|
|
18
18
|
import { loadProject } from '../src/project.js';
|
|
19
|
-
import {
|
|
19
|
+
import { renderRoute, urlFor } from '../src/document.js';
|
|
20
|
+
import { prerenderContext, refusePrerender } from '../src/prerender.js';
|
|
20
21
|
import { feed, feedPath } from '../src/feed.js';
|
|
21
22
|
import { includeContext } from '../src/include.js';
|
|
22
23
|
import { nodeLookup } from '../src/lookup.js';
|
|
@@ -24,7 +25,7 @@ import { sitemap } from '../src/sitemap.js';
|
|
|
24
25
|
import { etagOf, loadAssets, loadStatic } from '../src/static-cache.js';
|
|
25
26
|
import { buildSprite, readLibraries, refuseSpriteClash, spritePath } from '../src/icons.js';
|
|
26
27
|
import { PRECACHE_PATH, precacheDocument, precacheList } from '../src/precache.js';
|
|
27
|
-
import {
|
|
28
|
+
import { speculateSettings, speculationRules } from '../src/speculate.js';
|
|
28
29
|
import { pool } from '../src/pool.js';
|
|
29
30
|
import { precompress } from '../src/compress.js';
|
|
30
31
|
|
|
@@ -147,57 +148,30 @@ async function urlsFor(route) {
|
|
|
147
148
|
}
|
|
148
149
|
|
|
149
150
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
151
|
+
* One page, rendered to the markup that gets written.
|
|
152
|
+
*
|
|
153
|
+
* What it is allowed to be, and the `ctx` it is given, are both in
|
|
154
|
+
* `src/prerender.js`, where they can be tested. Nothing imports this file.
|
|
154
155
|
*/
|
|
155
156
|
async function render(route, { url, params }) {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
url
|
|
157
|
+
const ctx = prerenderContext({
|
|
158
|
+
route,
|
|
159
|
+
url,
|
|
159
160
|
params,
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
action: null,
|
|
164
|
-
response,
|
|
165
|
-
cookies: cookiesOf(null, response, config.cookieSecret),
|
|
166
|
-
absolute: absoluteFrom(config.metadataBase, null),
|
|
167
|
-
};
|
|
161
|
+
cookieSecret: config.cookieSecret,
|
|
162
|
+
metadataBase: config.metadataBase,
|
|
163
|
+
});
|
|
168
164
|
|
|
169
165
|
const html = await renderRoute(pages[route.id], ctx, {
|
|
170
166
|
clientEntry: assets.get(route.id) ?? null,
|
|
171
167
|
stylesheet,
|
|
172
168
|
csp: config.csp,
|
|
173
169
|
lang: config.lang,
|
|
170
|
+
speculate: speculateRules,
|
|
174
171
|
include,
|
|
175
172
|
});
|
|
176
173
|
|
|
177
|
-
|
|
178
|
-
throw new Error(`answered with ${html.status} instead of markup, so it cannot be prerendered`);
|
|
179
|
-
}
|
|
180
|
-
if (ctx.response.status !== 200) {
|
|
181
|
-
throw new Error(`answered ${ctx.response.status}, which no file can carry`);
|
|
182
|
-
}
|
|
183
|
-
// A file carries no headers either. A Set-Cookie or a Cache-Control written here
|
|
184
|
-
// would be thrown away, which is worse than being told.
|
|
185
|
-
const [header] = [...ctx.response.headers.keys()];
|
|
186
|
-
if (header) {
|
|
187
|
-
throw new Error(`set a ${header} header, which no file can carry`);
|
|
188
|
-
}
|
|
189
|
-
// Reading a cookie is what makes a page personal, and there is no request
|
|
190
|
-
// here to read one from. Whatever this file says about the reader is what a
|
|
191
|
-
// reader with no cookies would have seen, and every visitor gets that copy.
|
|
192
|
-
// A layout or an included route can do this without the page mentioning it,
|
|
193
|
-
// which is what makes it worth saying out loud.
|
|
194
|
-
if (ctx.cookies.personal) {
|
|
195
|
-
throw new Error(
|
|
196
|
-
`read a cookie, so it is different for each visitor and cannot be one file. ` +
|
|
197
|
-
`Give it \`export const prerender = false\`, or stop reading the cookie ` +
|
|
198
|
-
`here or in what it includes`,
|
|
199
|
-
);
|
|
200
|
-
}
|
|
174
|
+
refusePrerender(ctx, html);
|
|
201
175
|
return html;
|
|
202
176
|
}
|
|
203
177
|
|
|
@@ -240,6 +214,32 @@ if (manifest.error) {
|
|
|
240
214
|
});
|
|
241
215
|
}
|
|
242
216
|
|
|
217
|
+
// ---- speculation rules ------------------------------------------------------
|
|
218
|
+
//
|
|
219
|
+
// Before the render, because every page carries the block and the pages are
|
|
220
|
+
// about to be rendered. The URLs are already known: `targets` is what will be
|
|
221
|
+
// written and `dynamic` is what will not, and a target that then fails to render
|
|
222
|
+
// fails the build rather than leaving a rule pointing at nothing.
|
|
223
|
+
//
|
|
224
|
+
// Computed once and carried in the manifest, so the server rendering the dynamic
|
|
225
|
+
// routes sends the same block the files carry. Two computations is two answers
|
|
226
|
+
// to what a browser may prerender, and only one of them was ever checked.
|
|
227
|
+
//
|
|
228
|
+
// The split is the whole point. A file has no loader left to run, so
|
|
229
|
+
// prerendering it is free. A server render's loader may read a cookie or count a
|
|
230
|
+
// view, so the browser may fetch that and not run it. The 404 and 500 pages
|
|
231
|
+
// carry a `file` rather than a route URL, which is what keeps them out of both.
|
|
232
|
+
const speculate = speculateSettings(config.speculate);
|
|
233
|
+
const speculateRules = speculate
|
|
234
|
+
? speculationRules(
|
|
235
|
+
{
|
|
236
|
+
prerendered: targets.filter((entry) => !entry.file).map((entry) => entry.target.url),
|
|
237
|
+
dynamic: dynamic.map((route) => route.pattern),
|
|
238
|
+
},
|
|
239
|
+
speculate,
|
|
240
|
+
)
|
|
241
|
+
: null;
|
|
242
|
+
|
|
243
243
|
const CONCURRENCY = Number(process.env.TRANSCLUDE_BUILD_CONCURRENCY ?? 8);
|
|
244
244
|
|
|
245
245
|
const outcomes = await pool(targets, CONCURRENCY, async ({ route, target, file, label }) => {
|
|
@@ -256,7 +256,6 @@ const outcomes = await pool(targets, CONCURRENCY, async ({ route, target, file,
|
|
|
256
256
|
|
|
257
257
|
const failures = outcomes.filter((outcome) => !outcome.ok);
|
|
258
258
|
const prerendered = outcomes.filter((outcome) => outcome.ok).map((outcome) => outcome.url);
|
|
259
|
-
|
|
260
259
|
// A file, like every other page. The served route answers the same document, but
|
|
261
260
|
// `dist/static` is meant to be servable by a host that runs none of this, and a
|
|
262
261
|
// site with no sitemap there would be missing one only on the host that needs it
|
|
@@ -309,6 +308,9 @@ fs.writeFileSync(
|
|
|
309
308
|
notFound: manifest.notFound ? { id: manifest.notFound.id } : null,
|
|
310
309
|
error: manifest.error ? { id: manifest.error.id } : null,
|
|
311
310
|
stylesheet,
|
|
311
|
+
// Carried rather than recomputed. The server renders the routes that are
|
|
312
|
+
// not files, and those pages have to say what the files say.
|
|
313
|
+
speculate: speculateRules,
|
|
312
314
|
},
|
|
313
315
|
null,
|
|
314
316
|
2,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@transclude/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -225,6 +225,12 @@ on something inside it.
|
|
|
225
225
|
into the DOM it already rendered and never replaces a child. That is a compile
|
|
226
226
|
error naming `shadow`. Add `export const shadow = true` or keep the list still.
|
|
227
227
|
|
|
228
|
+
**A dialog does not need a click handler.** `command` and `commandfor` are the
|
|
229
|
+
platform's invoker: `<button command="show-modal" commandfor="prefs">` opens
|
|
230
|
+
`<dialog id="prefs">` with no script. Writing a listener for this is the common
|
|
231
|
+
mistake, and it loses the keyboard and screen reader behavior the attributes
|
|
232
|
+
already carry.
|
|
233
|
+
|
|
228
234
|
**`setHTMLUnsafe()`, never `innerHTML`.** `innerHTML` does not process nested
|
|
229
235
|
declarative shadow roots, so a child element becomes a dead `<template>`.
|
|
230
236
|
|
|
@@ -125,6 +125,35 @@ schedules the render, the way an attribute change does for a prop.
|
|
|
125
125
|
</script>
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
+
## Styling on state
|
|
129
|
+
|
|
130
|
+
A boolean state field is reflected as a custom state, so CSS can select it. No
|
|
131
|
+
attribute is written and no class is added, which is the point: the document
|
|
132
|
+
still cannot read the state, and a stylesheet still reacts to it.
|
|
133
|
+
|
|
134
|
+
```html
|
|
135
|
+
<script state>
|
|
136
|
+
export default {
|
|
137
|
+
hot: false,
|
|
138
|
+
};
|
|
139
|
+
</script>
|
|
140
|
+
|
|
141
|
+
<style>
|
|
142
|
+
:scope:state(hot) output {
|
|
143
|
+
color: #b4232c;
|
|
144
|
+
}
|
|
145
|
+
</style>
|
|
146
|
+
|
|
147
|
+
<output>${n}</output>
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Booleans only. A custom state is a name and not a value, so a number or a string
|
|
151
|
+
has nothing to select on. The state lands with the render rather than with the
|
|
152
|
+
assignment, so `await element.updateComplete` before asserting on it.
|
|
153
|
+
|
|
154
|
+
Nothing is reflected on the server. A state field starts at the default its
|
|
155
|
+
block declares, so the first paint is that default either way.
|
|
156
|
+
|
|
128
157
|
## Behavior
|
|
129
158
|
|
|
130
159
|
A plain `<script>` block is the element's own code. `host` is the element,
|
|
@@ -186,6 +215,31 @@ document.addEventListener(
|
|
|
186
215
|
The element is then a real form field: it submits, resets and validates with the
|
|
187
216
|
rest of them.
|
|
188
217
|
|
|
218
|
+
### Saying it is invalid
|
|
219
|
+
|
|
220
|
+
`host.internals` is the handle the platform hands out, so `setValidity` works the
|
|
221
|
+
way it does on an input. `:invalid` matches, a submit is blocked, and the browser
|
|
222
|
+
shows its own message.
|
|
223
|
+
|
|
224
|
+
```html
|
|
225
|
+
<script>
|
|
226
|
+
export const prototype = {
|
|
227
|
+
updated() {
|
|
228
|
+
const empty = !this.value;
|
|
229
|
+
this.internals.setValidity(
|
|
230
|
+
empty ? { valueMissing: true } : {},
|
|
231
|
+
empty ? 'Pick at least one tag.' : '',
|
|
232
|
+
this,
|
|
233
|
+
);
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
</script>
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Call it whenever the value changes, and pass no arguments to clear it. A field
|
|
240
|
+
that cannot say it is wrong is not really a field, and this is the part most
|
|
241
|
+
custom controls leave out.
|
|
242
|
+
|
|
189
243
|
## An icon element
|
|
190
244
|
|
|
191
245
|
The framework compiles `app/icons/` into one `/icons.svg` and defines no element
|
|
@@ -94,6 +94,7 @@ Source is JavaScript with JSDoc. Do not convert it to TypeScript.
|
|
|
94
94
|
| `strict` | `false` | Full TypeScript strictness. |
|
|
95
95
|
| `csrf` | `true` | `false` to turn it off, or an object for `hono/csrf`. |
|
|
96
96
|
| `csp` | `false` | `true`, or `{ directives, reportOnly }`. |
|
|
97
|
+
| `speculate` | `false` | `true` emits speculation rules. See below. |
|
|
97
98
|
| `cookieSecret` | `null` | Signs cookies. |
|
|
98
99
|
| `fragmentParam` | `'fragment'` | The query parameter that asks for a fragment. |
|
|
99
100
|
| `fragmentHeader` | `null` | A request header that may name one. Adds it to `Vary`. |
|
|
@@ -106,6 +107,27 @@ Source is JavaScript with JSDoc. Do not convert it to TypeScript.
|
|
|
106
107
|
| `precache` | `false` | `true` writes `/precache.json`. |
|
|
107
108
|
| `onError` | `null` | `(error, { request, url, method })` per failed request. |
|
|
108
109
|
|
|
110
|
+
|
|
111
|
+
### speculate
|
|
112
|
+
|
|
113
|
+
`true` writes a `<script type="speculationrules">` block into every page, so the
|
|
114
|
+
browser can fetch or render the next document before the reader clicks. No
|
|
115
|
+
JavaScript of the framework's is involved.
|
|
116
|
+
|
|
117
|
+
The split matters and the build decides it. A URL prerendered to a file has no
|
|
118
|
+
loader left to run, so it goes in `prerender`. Every route the server still
|
|
119
|
+
renders goes in `prefetch` only, because its loader may read a cookie or count a
|
|
120
|
+
view and a prerender would run that for a reader who never clicked. Endpoints are
|
|
121
|
+
in neither.
|
|
122
|
+
|
|
123
|
+
```js
|
|
124
|
+
speculate: { eagerness: 'moderate', exclude: ['/logout'] }
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`eagerness` defaults to `moderate`, which waits for a hover. `exclude` is matched
|
|
128
|
+
against the emitted pattern, so a route `/docs/:path{.+}` is excluded as
|
|
129
|
+
`/docs/*`.
|
|
130
|
+
|
|
109
131
|
## The build
|
|
110
132
|
|
|
111
133
|
```sh
|
|
@@ -132,6 +154,63 @@ something built on Wasm fails there and nowhere else. A prerendered page never
|
|
|
132
154
|
runs its loader in production, so this stays hidden until something asks for a
|
|
133
155
|
fragment.
|
|
134
156
|
|
|
157
|
+
### Bindings
|
|
158
|
+
|
|
159
|
+
`ctx` has no `env`. It carries nothing that names one runtime, and `env` names
|
|
160
|
+
one: the other three fill that slot with something else. A KV namespace, a D1
|
|
161
|
+
database or a secret reaches a loader through the app instead.
|
|
162
|
+
|
|
163
|
+
`worker.js` belongs to the app and does get `env`. Hold it in a module and
|
|
164
|
+
import that.
|
|
165
|
+
|
|
166
|
+
```js
|
|
167
|
+
// app/lib/bindings.js — no `node:` imports, this ends up in the worker bundle
|
|
168
|
+
/** @type {Env|null} */
|
|
169
|
+
let current = null;
|
|
170
|
+
|
|
171
|
+
export const hold = (env) => (current = env);
|
|
172
|
+
|
|
173
|
+
export const bindings = () => {
|
|
174
|
+
if (!current) throw new Error('No bindings: this app is running off workerd.');
|
|
175
|
+
return current;
|
|
176
|
+
};
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`worker.js` calls `hold(env)` inside `appFor`, before the `app ??=` line. A
|
|
180
|
+
loader then imports `bindings` and reads `bindings().DB`.
|
|
181
|
+
|
|
182
|
+
Module scope is safe here because `env` is one object for the life of the
|
|
183
|
+
isolate. Holding a `Request` the same way is not, since two visitors would share
|
|
184
|
+
whichever arrived last.
|
|
185
|
+
|
|
186
|
+
**A page that reads a binding needs `prerender = false`.** The build runs on
|
|
187
|
+
Node, where nothing calls `hold`, so the build runs that loader and throws.
|
|
188
|
+
|
|
189
|
+
### ctx.after
|
|
190
|
+
|
|
191
|
+
`after(work)` takes a promise the reader does not wait for.
|
|
192
|
+
|
|
193
|
+
```js
|
|
194
|
+
export const prerender = false;
|
|
195
|
+
|
|
196
|
+
export default async ({ after, url }) => {
|
|
197
|
+
after(recordView(url));
|
|
198
|
+
return { notes: await notes.all() };
|
|
199
|
+
};
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
On workerd this is `waitUntil`, which keeps the isolate up past the response.
|
|
203
|
+
Node, Bun and Deno keep running anyway, so it changes nothing there.
|
|
204
|
+
|
|
205
|
+
It takes the promise, not a function returning one. A function is refused, since
|
|
206
|
+
wrapping one would resolve to the function and run nothing.
|
|
207
|
+
|
|
208
|
+
A rejection goes to `onError` with the request that started it. Nothing awaits
|
|
209
|
+
this work, so leaving it would end the process on Node.
|
|
210
|
+
|
|
211
|
+
Calling it from a prerendered page stops the build: a file has no response to
|
|
212
|
+
outlive. `prerender = false` is the fix, as it is for a binding.
|
|
213
|
+
|
|
135
214
|
## Testing
|
|
136
215
|
|
|
137
216
|
The app is a function from a request to a response.
|
package/src/after.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Work that outlives the response.
|
|
2
|
+
//
|
|
3
|
+
// Every runtime here keeps a promise running after a response, except the one
|
|
4
|
+
// that matters most for this. Node, Bun and Deno are processes, and a promise
|
|
5
|
+
// nobody awaits finishes on its own. workerd is not: the isolate is allowed to
|
|
6
|
+
// stop once the response is sent, and anything still running stops with it.
|
|
7
|
+
// `waitUntil` is how a worker asks to stay up, and it exists nowhere else.
|
|
8
|
+
//
|
|
9
|
+
// So `ctx.after` is a capability rather than a runtime's API. It means the same
|
|
10
|
+
// thing on all four: this work does not have to finish before the reader is
|
|
11
|
+
// served. That is the bar `env` failed, which is why there is no `ctx.env`.
|
|
12
|
+
//
|
|
13
|
+
// No `node:` imports.
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The runtime's `ExecutionContext`, or null when it has none.
|
|
17
|
+
*
|
|
18
|
+
* Hono's getter throws rather than answering undefined, so the only way to ask
|
|
19
|
+
* is to try. The `try` covers the getter and nothing else: a `waitUntil` that
|
|
20
|
+
* throws is a real failure and belongs to the caller.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} c a Hono context
|
|
23
|
+
* @returns {{ waitUntil: (work: Promise<unknown>) => void }|null}
|
|
24
|
+
*/
|
|
25
|
+
export function executionCtxOf(c) {
|
|
26
|
+
try {
|
|
27
|
+
return c.executionCtx;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `ctx.after` for one request.
|
|
35
|
+
*
|
|
36
|
+
* The rejection handling is the reason this is not one line at the call site.
|
|
37
|
+
* Nothing is awaiting this work, so a throw inside it reaches the runtime's
|
|
38
|
+
* unhandled rejection handler, which on Node ends the process. It goes to the
|
|
39
|
+
* same place a failed request goes instead, with the request that started it.
|
|
40
|
+
*
|
|
41
|
+
* `report` is called with the error alone. Whoever builds this already knows
|
|
42
|
+
* which request it belongs to.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} c a Hono context
|
|
45
|
+
* @param {(error: unknown) => void} report where a failure goes
|
|
46
|
+
* @returns {(work: Promise<unknown>) => void}
|
|
47
|
+
*/
|
|
48
|
+
export function afterFor(c, report) {
|
|
49
|
+
return (work) => {
|
|
50
|
+
// A function is the mistake worth naming. `after(() => log())` would be
|
|
51
|
+
// wrapped by `Promise.resolve`, resolve to the function itself, and do
|
|
52
|
+
// nothing at all, which is the one shape that fails without a symptom.
|
|
53
|
+
if (typeof work?.then !== 'function') {
|
|
54
|
+
throw new TypeError(
|
|
55
|
+
'[transclude] ctx.after takes a promise. Call the work and pass what it ' +
|
|
56
|
+
'returns, as in `after(log(url))` rather than `after(() => log(url))`.',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const settled = Promise.resolve(work).catch(report);
|
|
61
|
+
|
|
62
|
+
// Only workerd has one. Everywhere else the promise is already running and
|
|
63
|
+
// the process is still there to finish it.
|
|
64
|
+
const execution = executionCtxOf(c);
|
|
65
|
+
if (execution) execution.waitUntil(settled);
|
|
66
|
+
};
|
|
67
|
+
}
|
package/src/app.js
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
import { pickEncoding } from './negotiate.js';
|
|
30
30
|
import { baseApp, endpointMethods, runEndpoint } from './server.js';
|
|
31
31
|
import { cookiesOf } from './cookies.js';
|
|
32
|
+
import { afterFor } from './after.js';
|
|
32
33
|
|
|
33
34
|
const IMMUTABLE = 'public, max-age=31536000, immutable';
|
|
34
35
|
const REVALIDATE = 'public, max-age=0, must-revalidate';
|
|
@@ -210,6 +211,9 @@ export function createApp({
|
|
|
210
211
|
cookies: cookiesOf(c.req.raw, response, config.cookieSecret),
|
|
211
212
|
absolute: absoluteFrom(config.metadataBase, c.req.url),
|
|
212
213
|
revalidateTag: cache.revalidateTag,
|
|
214
|
+
// Reported through `report`, so work that fails after the reader is gone
|
|
215
|
+
// is not quieter than work that fails in front of them.
|
|
216
|
+
after: afterFor(c, (error) => report(error, c)),
|
|
213
217
|
...extra,
|
|
214
218
|
};
|
|
215
219
|
};
|
|
@@ -340,6 +344,9 @@ export function createApp({
|
|
|
340
344
|
stylesheet: manifest.stylesheet,
|
|
341
345
|
csp: config.csp,
|
|
342
346
|
lang: config.lang,
|
|
347
|
+
// Written by the build and carried here, so a server-rendered
|
|
348
|
+
// page says the same thing about speculation that a file does.
|
|
349
|
+
speculate: manifest.speculate ?? null,
|
|
343
350
|
include,
|
|
344
351
|
})
|
|
345
352
|
: await renderFragment(page, ctx, { region: region || null, include });
|
|
@@ -408,6 +415,7 @@ export function createApp({
|
|
|
408
415
|
stylesheet: manifest.stylesheet,
|
|
409
416
|
csp: config.csp,
|
|
410
417
|
lang: config.lang,
|
|
418
|
+
speculate: manifest.speculate ?? null,
|
|
411
419
|
include,
|
|
412
420
|
});
|
|
413
421
|
|
package/src/compiler/codegen.js
CHANGED
|
@@ -820,6 +820,8 @@ class Codegen {
|
|
|
820
820
|
* Date crosses as an ISO string rather than as JSON.
|
|
821
821
|
*/
|
|
822
822
|
emitAttrs(el, out, scope, ref = null) {
|
|
823
|
+
if (this.loops.length) assertUniqueTransitionName(el);
|
|
824
|
+
|
|
823
825
|
for (const attr of el.attrs) {
|
|
824
826
|
if (DIRECTIVES.has(attr.name)) continue;
|
|
825
827
|
|
|
@@ -1000,6 +1002,45 @@ export function gatherChain(nodes, i) {
|
|
|
1000
1002
|
* @param {object} node
|
|
1001
1003
|
* @returns {object[]} a template's live under `.content`, so walking `childNodes` finds nothing
|
|
1002
1004
|
*/
|
|
1005
|
+
/**
|
|
1006
|
+
* A repeated element may not carry a `view-transition-name` written out.
|
|
1007
|
+
*
|
|
1008
|
+
* The name has to be unique in the document. Every copy of a repeated element
|
|
1009
|
+
* would carry the same one, and the browser's answer to that is to run no
|
|
1010
|
+
* transition at all: nothing throws, nothing is logged, the page just stops
|
|
1011
|
+
* animating. That is the whole reason this is a compile error.
|
|
1012
|
+
*
|
|
1013
|
+
* `none` is allowed, because it is the one value that means "not part of a
|
|
1014
|
+
* transition" and is safe to repeat.
|
|
1015
|
+
*
|
|
1016
|
+
* Only the `style` attribute is checked. A name applied through a class is in a
|
|
1017
|
+
* stylesheet this compiler does not read, so this catches the spelling everyone
|
|
1018
|
+
* writes rather than every spelling there is.
|
|
1019
|
+
*/
|
|
1020
|
+
function assertUniqueTransitionName(el) {
|
|
1021
|
+
const style = el.attrs?.find((a) => a.name === 'style')?.value;
|
|
1022
|
+
if (!style) return;
|
|
1023
|
+
|
|
1024
|
+
const at = style.indexOf('view-transition-name');
|
|
1025
|
+
if (at === -1) return;
|
|
1026
|
+
|
|
1027
|
+
// Just this declaration, so `${}` elsewhere in the attribute does not excuse
|
|
1028
|
+
// a name that is still written out.
|
|
1029
|
+
const end = style.indexOf(';', at);
|
|
1030
|
+
const declaration = end === -1 ? style.slice(at) : style.slice(at, end);
|
|
1031
|
+
|
|
1032
|
+
if (declaration.includes('${')) return;
|
|
1033
|
+
if (/:\s*none\s*$/.test(declaration)) return;
|
|
1034
|
+
|
|
1035
|
+
throw new CompileError(
|
|
1036
|
+
`<${el.tagName}> is repeated and writes out a view-transition-name, so every ` +
|
|
1037
|
+
`copy would carry the same one. A name has to be unique in the document, and ` +
|
|
1038
|
+
`a browser that finds two runs no transition at all rather than saying so. ` +
|
|
1039
|
+
`Derive it from the loop, as in "view-transition-name: card-\${item.id}".`,
|
|
1040
|
+
el,
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1003
1044
|
export function childrenOf(node) {
|
|
1004
1045
|
if (node.tagName === 'template' && node.content) return node.content.childNodes ?? [];
|
|
1005
1046
|
return node.childNodes ?? [];
|
package/src/document.js
CHANGED
|
@@ -566,13 +566,14 @@ export function methodsOf(page) {
|
|
|
566
566
|
*
|
|
567
567
|
* @param {object[]} chain the compiled modules, outermost first
|
|
568
568
|
* @param {object[]} datas one per level, in the same order
|
|
569
|
-
* @param {{ clientEntry?: string|null, stylesheet?: string|null, lang?: string
|
|
569
|
+
* @param {{ clientEntry?: string|null, stylesheet?: string|null, lang?: string,
|
|
570
|
+
* speculate?: string|null }} [options]
|
|
570
571
|
* @returns {string} the document, starting at `<!doctype html>`
|
|
571
572
|
*/
|
|
572
573
|
export function renderDocument(
|
|
573
574
|
chain,
|
|
574
575
|
datas,
|
|
575
|
-
{ clientEntry, stylesheet, lang = 'en' } = {},
|
|
576
|
+
{ clientEntry, stylesheet, lang = 'en', speculate = null } = {},
|
|
576
577
|
) {
|
|
577
578
|
// Each level renders to a slot map and hands it to the level above, so a page
|
|
578
579
|
// can fill more than one hole in its layout.
|
|
@@ -641,6 +642,7 @@ ${openTag('html', { lang, ...attrsOf(chain, datas, 'renderHtmlAttrs') })}
|
|
|
641
642
|
<meta charset="utf-8">
|
|
642
643
|
${defaults}
|
|
643
644
|
${title}
|
|
645
|
+
${speculate ? `<script type="speculationrules">${speculate}</script>` : ''}
|
|
644
646
|
${headScripts.join('\n')}
|
|
645
647
|
${stylesheet ? `<link rel="stylesheet" href="${stylesheet}">` : ''}
|
|
646
648
|
${head.join('\n')}
|
package/src/prerender.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// What a page is allowed to be, if it is going to be a file.
|
|
2
|
+
//
|
|
3
|
+
// A prerendered page is written once and served to everyone. It has no status,
|
|
4
|
+
// no headers and no reader. So a loader that answers with a `Response`, sets a
|
|
5
|
+
// status, writes a header or reads a cookie is saying this URL is not a page you
|
|
6
|
+
// can write down, and the build says so rather than writing a file that lies
|
|
7
|
+
// about it.
|
|
8
|
+
//
|
|
9
|
+
// Split out of `bin/build.js` so the refusals can be tested. Nothing imports
|
|
10
|
+
// that file: it is a script that runs a build the moment it is loaded, so every
|
|
11
|
+
// message here used to be checked by hand or not at all.
|
|
12
|
+
//
|
|
13
|
+
// No `node:` imports, though nothing needs that of this file yet. It is here
|
|
14
|
+
// because the three modules it calls have the same rule.
|
|
15
|
+
|
|
16
|
+
import { absoluteFrom, responseOf } from './document.js';
|
|
17
|
+
import { cookiesOf } from './cookies.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The `ctx` a loader is handed while the build renders it to a file.
|
|
21
|
+
*
|
|
22
|
+
* `revalidateTag` and `after` are refusals rather than absences. Left off the
|
|
23
|
+
* object they are `undefined`, and a loader calling one fails with `x is not a
|
|
24
|
+
* function`, which names neither what the page did nor how to stop. Both stay in
|
|
25
|
+
* the generated type either way, because the checker cannot know which pages
|
|
26
|
+
* become files.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} options
|
|
29
|
+
* @param {{ id: string, pattern?: string }} options.route
|
|
30
|
+
* @param {string} options.url the path being written
|
|
31
|
+
* @param {Record<string, string>} options.params
|
|
32
|
+
* @param {string|null} [options.cookieSecret]
|
|
33
|
+
* @param {string} [options.metadataBase]
|
|
34
|
+
* @returns {object} the same shape a request gets, minus what a file cannot have
|
|
35
|
+
*/
|
|
36
|
+
export function prerenderContext({ route, url, params, cookieSecret = null, metadataBase }) {
|
|
37
|
+
const response = responseOf();
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
url: `http://localhost${url}`,
|
|
41
|
+
params,
|
|
42
|
+
route: { id: route.id, pattern: route.pattern ?? '', path: url },
|
|
43
|
+
// Null here and at no other time, which is how a shared layout can skip the
|
|
44
|
+
// part that needs a visitor.
|
|
45
|
+
request: null,
|
|
46
|
+
fragment: null,
|
|
47
|
+
action: null,
|
|
48
|
+
response,
|
|
49
|
+
cookies: cookiesOf(null, response, cookieSecret),
|
|
50
|
+
absolute: absoluteFrom(metadataBase, null),
|
|
51
|
+
|
|
52
|
+
revalidateTag: () => {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`called \`ctx.revalidateTag\`, and a build holds no rendered pages to drop. ` +
|
|
55
|
+
`Give it \`export const prerender = false\`, or move the call to the action ` +
|
|
56
|
+
`or endpoint that changes the data`,
|
|
57
|
+
);
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
after: () => {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`called \`ctx.after\`, and a file has no response for that work to outlive. ` +
|
|
63
|
+
`Give it \`export const prerender = false\`, or start the work from an ` +
|
|
64
|
+
`endpoint or an action instead`,
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Throws unless what was rendered can be written to a file.
|
|
72
|
+
*
|
|
73
|
+
* Called after the render rather than during it, because three of the four are
|
|
74
|
+
* things a loader does on the way past and only the finished `ctx` knows about.
|
|
75
|
+
* Every message continues a sentence whose subject is the page, since that is
|
|
76
|
+
* what the build prints above it.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} ctx the context the render was given
|
|
79
|
+
* @param {string|Response} html what the render answered
|
|
80
|
+
* @throws when this URL cannot be one file
|
|
81
|
+
*/
|
|
82
|
+
export function refusePrerender(ctx, html) {
|
|
83
|
+
if (html instanceof Response) {
|
|
84
|
+
throw new Error(`answered with ${html.status} instead of markup, so it cannot be prerendered`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (ctx.response.status !== 200) {
|
|
88
|
+
throw new Error(`answered ${ctx.response.status}, which no file can carry`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A file carries no headers either. A Set-Cookie or a Cache-Control written
|
|
92
|
+
// here would be thrown away, which is worse than being told.
|
|
93
|
+
const [header] = [...ctx.response.headers.keys()];
|
|
94
|
+
if (header) {
|
|
95
|
+
throw new Error(`set a ${header} header, which no file can carry`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Reading a cookie is what makes a page personal, and there is no request here
|
|
99
|
+
// to read one from. Whatever this file says about the reader is what a reader
|
|
100
|
+
// with no cookies would have seen, and every visitor gets that copy. A layout
|
|
101
|
+
// or an included route can do this without the page mentioning it, which is
|
|
102
|
+
// what makes it worth saying out loud.
|
|
103
|
+
if (ctx.cookies.personal) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`read a cookie, so it is different for each visitor and cannot be one file. ` +
|
|
106
|
+
`Give it \`export const prerender = false\`, or stop reading the cookie ` +
|
|
107
|
+
`here or in what it includes`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
package/src/project.js
CHANGED
package/src/runtime/index.js
CHANGED
|
@@ -986,7 +986,10 @@ export function defineLight(def, init) {
|
|
|
986
986
|
|
|
987
987
|
constructor() {
|
|
988
988
|
super();
|
|
989
|
-
|
|
989
|
+
// Also when a boolean state can be reflected. Narrow on purpose: internals
|
|
990
|
+
// is attached for the thing that needs it and not for every element that
|
|
991
|
+
// happens to hold a number.
|
|
992
|
+
if (def.formAssociated || hasCustomStates(def)) this.#internals = this.attachInternals();
|
|
990
993
|
}
|
|
991
994
|
|
|
992
995
|
get internals() {
|
|
@@ -1059,6 +1062,9 @@ export function defineLight(def, init) {
|
|
|
1059
1062
|
|
|
1060
1063
|
#data(raw) {
|
|
1061
1064
|
this.#was = { ...stateOf(this, def.stateDefs) };
|
|
1065
|
+
// The one place both classes work out current state, so the one place a
|
|
1066
|
+
// custom state can be kept true without a third copy of the rule.
|
|
1067
|
+
reflectStates(this.#internals, def.stateDefs, this.#was);
|
|
1062
1068
|
return { ...this.#was, ...def.coerce(raw) };
|
|
1063
1069
|
}
|
|
1064
1070
|
|
|
@@ -1088,6 +1094,41 @@ function hasMembers(def) {
|
|
|
1088
1094
|
return Object.keys(def.members ?? {}).length > 0;
|
|
1089
1095
|
}
|
|
1090
1096
|
|
|
1097
|
+
/**
|
|
1098
|
+
* A boolean state field, as a custom state CSS can select.
|
|
1099
|
+
*
|
|
1100
|
+
* `:state(open)` rather than an attribute, which is the whole reason state is
|
|
1101
|
+
* not in the document: the page has no business reading it, and CSS still has
|
|
1102
|
+
* to be able to react to it. Booleans only, because a custom state is a name
|
|
1103
|
+
* and not a value.
|
|
1104
|
+
*
|
|
1105
|
+
* Nothing is reflected on the server. A state field always starts at its
|
|
1106
|
+
* declared default, so the first paint is the default either way and there was
|
|
1107
|
+
* never anything for the markup to say.
|
|
1108
|
+
*/
|
|
1109
|
+
function reflectStates(internals, defs, state) {
|
|
1110
|
+
if (!internals?.states) return;
|
|
1111
|
+
|
|
1112
|
+
for (const name of Object.keys(defs ?? {})) {
|
|
1113
|
+
const value = state[name];
|
|
1114
|
+
if (typeof value !== 'boolean') continue;
|
|
1115
|
+
|
|
1116
|
+
if (value) internals.states.add(name);
|
|
1117
|
+
else internals.states.delete(name);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Whether any state field is declared a boolean.
|
|
1123
|
+
*
|
|
1124
|
+
* The declared default decides, at define time. A custom state is a name and
|
|
1125
|
+
* not a value, so a number or a string has nothing to reflect, and an element
|
|
1126
|
+
* holding only those is left without internals it would never use.
|
|
1127
|
+
*/
|
|
1128
|
+
function hasCustomStates(def) {
|
|
1129
|
+
return Object.values(def.stateDefs ?? {}).some((value) => typeof value === 'boolean');
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1091
1132
|
/** State counts as behavior: its accessors are the only way to change it. */
|
|
1092
1133
|
function hasState(def) {
|
|
1093
1134
|
return Object.keys(def.stateDefs ?? {}).length > 0;
|
|
@@ -1202,7 +1243,10 @@ export function defineComponent(def, init) {
|
|
|
1202
1243
|
super();
|
|
1203
1244
|
// In the constructor, which is where it belongs and where it can only
|
|
1204
1245
|
// happen once. For a server-rendered element that is upgrade time.
|
|
1205
|
-
|
|
1246
|
+
// Also when a boolean state can be reflected. Narrow on purpose: internals
|
|
1247
|
+
// is attached for the thing that needs it and not for every element that
|
|
1248
|
+
// happens to hold a number.
|
|
1249
|
+
if (def.formAssociated || hasCustomStates(def)) this.#internals = this.attachInternals();
|
|
1206
1250
|
}
|
|
1207
1251
|
|
|
1208
1252
|
/** What this element would submit, if it is a control. */
|
|
@@ -1308,6 +1352,9 @@ export function defineComponent(def, init) {
|
|
|
1308
1352
|
* be hidden by one. The compiler rejects that clash anyway. */
|
|
1309
1353
|
#data(raw) {
|
|
1310
1354
|
this.#was = { ...stateOf(this, def.stateDefs) };
|
|
1355
|
+
// The one place both classes work out current state, so the one place a
|
|
1356
|
+
// custom state can be kept true without a third copy of the rule.
|
|
1357
|
+
reflectStates(this.#internals, def.stateDefs, this.#was);
|
|
1311
1358
|
return { ...this.#was, ...def.coerce(raw) };
|
|
1312
1359
|
}
|
|
1313
1360
|
|
package/src/speculate.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// What the browser may fetch, or run, before the reader clicks.
|
|
2
|
+
//
|
|
3
|
+
// This framework ships no client router: every link is a document request. That
|
|
4
|
+
// is the whole bet, and the cost of it is one round trip per navigation.
|
|
5
|
+
// Speculation rules are the platform's answer, and they cost no JavaScript of
|
|
6
|
+
// ours: a JSON block in the head, and the browser decides.
|
|
7
|
+
//
|
|
8
|
+
// The part worth writing down is what must *not* be speculated. A prerender
|
|
9
|
+
// runs the page. A route this build wrote to a file has no loader left to run,
|
|
10
|
+
// so prerendering it is free and safe. Everything else is a server render whose
|
|
11
|
+
// loader may read a cookie, count a view or hand out a one-time token, and
|
|
12
|
+
// prerendering that for a reader who never clicked is wrong rather than slow.
|
|
13
|
+
// The build knows which is which. A hand-written rules block does not.
|
|
14
|
+
//
|
|
15
|
+
// No `node:` imports. The build calls this with the lists it already holds.
|
|
16
|
+
|
|
17
|
+
/** What the spec allows, so a typo is caught here rather than ignored by Chrome. */
|
|
18
|
+
const EAGERNESS = new Set(['immediate', 'eager', 'moderate', 'conservative']);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A route pattern as something `href_matches` understands.
|
|
22
|
+
*
|
|
23
|
+
* Hono writes `/docs/:path{.+}` and `/people/:name`. Both become `*`: the regex
|
|
24
|
+
* half is Hono's own spelling and means nothing to a URL pattern, and a rule
|
|
25
|
+
* that matches too little is a missed prefetch while one that matches too much
|
|
26
|
+
* speculates a URL that 404s.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} pattern
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function hrefPattern(pattern) {
|
|
32
|
+
return pattern.replace(/:[A-Za-z0-9_]+(\{[^}]*\})?/g, '*');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `{ href_matches }` for each, or null when there is nothing to match. */
|
|
36
|
+
function where(patterns) {
|
|
37
|
+
if (!patterns.length) return null;
|
|
38
|
+
return { or: patterns.map((pattern) => ({ href_matches: pattern })) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The `<script type="speculationrules">` body for a site, or null.
|
|
43
|
+
*
|
|
44
|
+
* Two lists, because they are two different promises. `prerendered` is every URL
|
|
45
|
+
* written to a file, and the browser may run those. `dynamic` is every route the
|
|
46
|
+
* server still renders, and the browser may only fetch those: the response is
|
|
47
|
+
* the same document a click would have got, and no page script runs early.
|
|
48
|
+
*
|
|
49
|
+
* Endpoints are in neither. A `.js` route answers with whatever it builds, and
|
|
50
|
+
* speculating one spends a request on something no navigation will reuse.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} site
|
|
53
|
+
* @param {string[]} [site.prerendered] URLs written to a file
|
|
54
|
+
* @param {string[]} [site.dynamic] route patterns the server renders
|
|
55
|
+
* @param {object} [options]
|
|
56
|
+
* @param {string[]} [options.exclude] patterns to leave out of both
|
|
57
|
+
* @param {string} [options.eagerness] how soon the browser may act
|
|
58
|
+
* @returns {string|null} JSON, or null when nothing is speculated
|
|
59
|
+
* @throws when `eagerness` is not one the spec names
|
|
60
|
+
*/
|
|
61
|
+
export function speculationRules({ prerendered = [], dynamic = [] }, options = {}) {
|
|
62
|
+
const { exclude = [], eagerness = 'moderate' } = options;
|
|
63
|
+
|
|
64
|
+
if (!EAGERNESS.has(eagerness)) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`[transclude] speculate.eagerness is ${JSON.stringify(eagerness)}. ` +
|
|
67
|
+
`It is one of ${[...EAGERNESS].join(', ')}.`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const excluded = new Set(exclude);
|
|
72
|
+
const keep = (pattern) => !excluded.has(pattern);
|
|
73
|
+
|
|
74
|
+
// Sorted and deduplicated, so two builds of one site produce the same bytes
|
|
75
|
+
// and the CSP hash of this block does not change for no reason.
|
|
76
|
+
const clean = (list) => [...new Set(list)].filter(keep).sort();
|
|
77
|
+
|
|
78
|
+
const rules = {};
|
|
79
|
+
// A prerendered URL is already a URL. A route is a pattern, and `exclude` is
|
|
80
|
+
// matched against what comes out, so what an author writes is what they read
|
|
81
|
+
// in the emitted rules.
|
|
82
|
+
const run = where(clean(prerendered));
|
|
83
|
+
const fetchOnly = where(clean(dynamic.map(hrefPattern)));
|
|
84
|
+
|
|
85
|
+
if (run) rules.prerender = [{ where: run, eagerness }];
|
|
86
|
+
if (fetchOnly) rules.prefetch = [{ where: fetchOnly, eagerness }];
|
|
87
|
+
|
|
88
|
+
return run || fetchOnly ? JSON.stringify(rules) : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* `speculate` as `{ exclude, eagerness }`, or null for off.
|
|
93
|
+
*
|
|
94
|
+
* Off by default, like every other thing here that changes what a browser is
|
|
95
|
+
* told to do. `true` is the defaults.
|
|
96
|
+
*
|
|
97
|
+
* @param {boolean|object} [setting]
|
|
98
|
+
* @returns {object|null}
|
|
99
|
+
*/
|
|
100
|
+
export function speculateSettings(setting) {
|
|
101
|
+
if (!setting) return null;
|
|
102
|
+
return setting === true ? {} : setting;
|
|
103
|
+
}
|
package/src/typecheck.js
CHANGED
|
@@ -289,7 +289,8 @@ export function createChecker({
|
|
|
289
289
|
`route: { id: string; pattern: string; path: string }; ` +
|
|
290
290
|
`request: Request; fragment: string | null; ` +
|
|
291
291
|
`response: { status: number; headers: Headers }; cookies: __Cookies; ` +
|
|
292
|
-
`absolute: (path: string) => string; revalidateTag: (tag: string) => void
|
|
292
|
+
`absolute: (path: string) => string; revalidateTag: (tag: string) => void; ` +
|
|
293
|
+
`after: (work: Promise<unknown>) => void }`;
|
|
293
294
|
|
|
294
295
|
const contextLiteral = (params, layoutType) =>
|
|
295
296
|
`{ url: string; params: { ${params.map((name) => `${name}: string`).join('; ')} }; ` +
|
|
@@ -297,7 +298,8 @@ export function createChecker({
|
|
|
297
298
|
`layout: ${layoutType}; request: Request | null; fragment: string | null; ` +
|
|
298
299
|
`action: unknown; response: { status: number; headers: Headers }; ` +
|
|
299
300
|
`cookies: __Cookies; htmlAttrs: Record<string, string | boolean | null>; ` +
|
|
300
|
-
`absolute: (path: string) => string; revalidateTag: (tag: string) => void
|
|
301
|
+
`absolute: (path: string) => string; revalidateTag: (tag: string) => void; ` +
|
|
302
|
+
`after: (work: Promise<unknown>) => void }`;
|
|
301
303
|
|
|
302
304
|
/**
|
|
303
305
|
* Builds every shim in dependency order: components depend on nothing, a
|