@transclude/core 0.5.0 → 0.6.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
@@ -24,6 +24,7 @@ import { sitemap } from '../src/sitemap.js';
24
24
  import { etagOf, loadAssets, loadStatic } from '../src/static-cache.js';
25
25
  import { buildSprite, readLibraries, refuseSpriteClash, spritePath } from '../src/icons.js';
26
26
  import { PRECACHE_PATH, precacheDocument, precacheList } from '../src/precache.js';
27
+ import { speculateSettings, speculationRules } from '../src/speculate.js';
27
28
  import { cookiesOf } from '../src/cookies.js';
28
29
  import { pool } from '../src/pool.js';
29
30
  import { precompress } from '../src/compress.js';
@@ -171,6 +172,7 @@ async function render(route, { url, params }) {
171
172
  stylesheet,
172
173
  csp: config.csp,
173
174
  lang: config.lang,
175
+ speculate: speculateRules,
174
176
  include,
175
177
  });
176
178
 
@@ -240,6 +242,32 @@ if (manifest.error) {
240
242
  });
241
243
  }
242
244
 
245
+ // ---- speculation rules ------------------------------------------------------
246
+ //
247
+ // Before the render, because every page carries the block and the pages are
248
+ // about to be rendered. The URLs are already known: `targets` is what will be
249
+ // written and `dynamic` is what will not, and a target that then fails to render
250
+ // fails the build rather than leaving a rule pointing at nothing.
251
+ //
252
+ // Computed once and carried in the manifest, so the server rendering the dynamic
253
+ // routes sends the same block the files carry. Two computations is two answers
254
+ // to what a browser may prerender, and only one of them was ever checked.
255
+ //
256
+ // The split is the whole point. A file has no loader left to run, so
257
+ // prerendering it is free. A server render's loader may read a cookie or count a
258
+ // view, so the browser may fetch that and not run it. The 404 and 500 pages
259
+ // carry a `file` rather than a route URL, which is what keeps them out of both.
260
+ const speculate = speculateSettings(config.speculate);
261
+ const speculateRules = speculate
262
+ ? speculationRules(
263
+ {
264
+ prerendered: targets.filter((entry) => !entry.file).map((entry) => entry.target.url),
265
+ dynamic: dynamic.map((route) => route.pattern),
266
+ },
267
+ speculate,
268
+ )
269
+ : null;
270
+
243
271
  const CONCURRENCY = Number(process.env.TRANSCLUDE_BUILD_CONCURRENCY ?? 8);
244
272
 
245
273
  const outcomes = await pool(targets, CONCURRENCY, async ({ route, target, file, label }) => {
@@ -256,7 +284,6 @@ const outcomes = await pool(targets, CONCURRENCY, async ({ route, target, file,
256
284
 
257
285
  const failures = outcomes.filter((outcome) => !outcome.ok);
258
286
  const prerendered = outcomes.filter((outcome) => outcome.ok).map((outcome) => outcome.url);
259
-
260
287
  // A file, like every other page. The served route answers the same document, but
261
288
  // `dist/static` is meant to be servable by a host that runs none of this, and a
262
289
  // site with no sitemap there would be missing one only on the host that needs it
@@ -309,6 +336,9 @@ fs.writeFileSync(
309
336
  notFound: manifest.notFound ? { id: manifest.notFound.id } : null,
310
337
  error: manifest.error ? { id: manifest.error.id } : null,
311
338
  stylesheet,
339
+ // Carried rather than recomputed. The server renders the routes that are
340
+ // not files, and those pages have to say what the files say.
341
+ speculate: speculateRules,
312
342
  },
313
343
  null,
314
344
  2,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transclude/core",
3
- "version": "0.5.0",
3
+ "version": "0.6.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,
@@ -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
package/src/app.js CHANGED
@@ -340,6 +340,9 @@ export function createApp({
340
340
  stylesheet: manifest.stylesheet,
341
341
  csp: config.csp,
342
342
  lang: config.lang,
343
+ // Written by the build and carried here, so a server-rendered
344
+ // page says the same thing about speculation that a file does.
345
+ speculate: manifest.speculate ?? null,
343
346
  include,
344
347
  })
345
348
  : await renderFragment(page, ctx, { region: region || null, include });
@@ -408,6 +411,7 @@ export function createApp({
408
411
  stylesheet: manifest.stylesheet,
409
412
  csp: config.csp,
410
413
  lang: config.lang,
414
+ speculate: manifest.speculate ?? null,
411
415
  include,
412
416
  });
413
417
 
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 }} [options]
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/project.js CHANGED
@@ -96,6 +96,7 @@ const DEFAULTS = {
96
96
  strict: false,
97
97
  csrf: true,
98
98
  csp: false,
99
+ speculate: false,
99
100
  };
100
101
 
101
102
  /**
@@ -986,7 +986,10 @@ export function defineLight(def, init) {
986
986
 
987
987
  constructor() {
988
988
  super();
989
- if (def.formAssociated) this.#internals = this.attachInternals();
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
- if (def.formAssociated) this.#internals = this.attachInternals();
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
 
@@ -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
+ }