@transclude/core 0.6.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 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 { absoluteFrom, renderRoute, responseOf, urlFor } from '../src/document.js';
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';
@@ -25,7 +26,6 @@ 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
28
  import { speculateSettings, speculationRules } from '../src/speculate.js';
28
- import { cookiesOf } from '../src/cookies.js';
29
29
  import { pool } from '../src/pool.js';
30
30
  import { precompress } from '../src/compress.js';
31
31
 
@@ -148,24 +148,19 @@ async function urlsFor(route) {
148
148
  }
149
149
 
150
150
  /**
151
- * A prerendered file has no status and no headers. It is a file. So a loader
152
- * that answered with a Response, or set a status other than 200, is saying this
153
- * URL is not a page you can write down, and the build says so rather than
154
- * writing a file that lies about it.
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.
155
155
  */
156
156
  async function render(route, { url, params }) {
157
- const response = responseOf();
158
- const ctx = {
159
- url: `http://localhost${url}`,
157
+ const ctx = prerenderContext({
158
+ route,
159
+ url,
160
160
  params,
161
- route: { id: route.id, pattern: route.pattern ?? '', path: url },
162
- request: null,
163
- fragment: null,
164
- action: null,
165
- response,
166
- cookies: cookiesOf(null, response, config.cookieSecret),
167
- absolute: absoluteFrom(config.metadataBase, null),
168
- };
161
+ cookieSecret: config.cookieSecret,
162
+ metadataBase: config.metadataBase,
163
+ });
169
164
 
170
165
  const html = await renderRoute(pages[route.id], ctx, {
171
166
  clientEntry: assets.get(route.id) ?? null,
@@ -176,30 +171,7 @@ async function render(route, { url, params }) {
176
171
  include,
177
172
  });
178
173
 
179
- if (html instanceof Response) {
180
- throw new Error(`answered with ${html.status} instead of markup, so it cannot be prerendered`);
181
- }
182
- if (ctx.response.status !== 200) {
183
- throw new Error(`answered ${ctx.response.status}, which no file can carry`);
184
- }
185
- // A file carries no headers either. A Set-Cookie or a Cache-Control written here
186
- // would be thrown away, which is worse than being told.
187
- const [header] = [...ctx.response.headers.keys()];
188
- if (header) {
189
- throw new Error(`set a ${header} header, which no file can carry`);
190
- }
191
- // Reading a cookie is what makes a page personal, and there is no request
192
- // here to read one from. Whatever this file says about the reader is what a
193
- // reader with no cookies would have seen, and every visitor gets that copy.
194
- // A layout or an included route can do this without the page mentioning it,
195
- // which is what makes it worth saying out loud.
196
- if (ctx.cookies.personal) {
197
- throw new Error(
198
- `read a cookie, so it is different for each visitor and cannot be one file. ` +
199
- `Give it \`export const prerender = false\`, or stop reading the cookie ` +
200
- `here or in what it includes`,
201
- );
202
- }
174
+ refusePrerender(ctx, html);
203
175
  return html;
204
176
  }
205
177
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transclude/core",
3
- "version": "0.6.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",
@@ -215,6 +215,31 @@ document.addEventListener(
215
215
  The element is then a real form field: it submits, resets and validates with the
216
216
  rest of them.
217
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
+
218
243
  ## An icon element
219
244
 
220
245
  The framework compiles `app/icons/` into one `/icons.svg` and defines no element
@@ -154,6 +154,63 @@ something built on Wasm fails there and nowhere else. A prerendered page never
154
154
  runs its loader in production, so this stays hidden until something asks for a
155
155
  fragment.
156
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
+
157
214
  ## Testing
158
215
 
159
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
  };
@@ -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 ?? [];
@@ -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/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