@transclude/core 0.4.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 +55 -11
- package/bin/dev.js +32 -10
- package/package.json +1 -1
- package/skills/transclude/SKILL.md +26 -2
- package/skills/transclude/references/elements.md +40 -5
- package/skills/transclude/references/server.md +23 -1
- package/src/app.js +4 -0
- package/src/document.js +4 -2
- package/src/icons.js +101 -32
- package/src/project.js +1 -0
- package/src/runtime/index.js +49 -2
- package/src/speculate.js +103 -0
package/bin/build.js
CHANGED
|
@@ -22,8 +22,9 @@ import { includeContext } from '../src/include.js';
|
|
|
22
22
|
import { nodeLookup } from '../src/lookup.js';
|
|
23
23
|
import { sitemap } from '../src/sitemap.js';
|
|
24
24
|
import { etagOf, loadAssets, loadStatic } from '../src/static-cache.js';
|
|
25
|
-
import { buildSprite,
|
|
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,
|
|
@@ -356,20 +386,29 @@ function countFiles(dir) {
|
|
|
356
386
|
// It is not counted as a public file, because the author did not write it.
|
|
357
387
|
//
|
|
358
388
|
// An icon named for a file the author can see is worth a stop: `buildSprite`
|
|
359
|
-
// throws on a missing viewBox
|
|
360
|
-
//
|
|
389
|
+
// throws on a missing viewBox, and the build ends there rather than shipping
|
|
390
|
+
// icons that render wrong.
|
|
391
|
+
//
|
|
392
|
+
// One file per library. A downloaded icon set is a directory here, so the count
|
|
393
|
+
// this reports is two numbers: an icon total says nothing about how much any one
|
|
394
|
+
// page fetches, and the sheets are what a page fetches.
|
|
361
395
|
|
|
362
396
|
const iconsSrc = config.iconsDir ? path.join(root, config.appDir, config.iconsDir) : null;
|
|
363
397
|
let iconCount = 0;
|
|
398
|
+
let libraryCount = 0;
|
|
364
399
|
|
|
365
400
|
if (iconsSrc) {
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
401
|
+
const libraries = readLibraries(iconsSrc, root);
|
|
402
|
+
refuseSpriteClash(publicSrc, libraries);
|
|
403
|
+
|
|
404
|
+
if (libraries.length) fs.mkdirSync(publicOut, { recursive: true });
|
|
405
|
+
|
|
406
|
+
for (const { name, icons } of libraries) {
|
|
407
|
+
const file = path.join(publicOut, path.basename(spritePath(name)));
|
|
408
|
+
fs.writeFileSync(file, buildSprite(icons));
|
|
409
|
+
iconCount += icons.length;
|
|
372
410
|
}
|
|
411
|
+
libraryCount = libraries.length;
|
|
373
412
|
}
|
|
374
413
|
|
|
375
414
|
// ---- assets, for runtimes with no filesystem ------------------------------
|
|
@@ -495,7 +534,12 @@ const summary = [
|
|
|
495
534
|
`${dynamic.length} route${dynamic.length === 1 ? '' : 's'} left to the server`,
|
|
496
535
|
`${assets.size} client entr${assets.size === 1 ? 'y' : 'ies'}`,
|
|
497
536
|
...(publicFiles ? [`${publicFiles} public file${publicFiles === 1 ? '' : 's'}`] : []),
|
|
498
|
-
...(iconCount
|
|
537
|
+
...(iconCount
|
|
538
|
+
? [
|
|
539
|
+
`${iconCount} icon${iconCount === 1 ? '' : 's'} in ` +
|
|
540
|
+
`${libraryCount} sheet${libraryCount === 1 ? '' : 's'}`,
|
|
541
|
+
]
|
|
542
|
+
: []),
|
|
499
543
|
];
|
|
500
544
|
console.log(`\n${summary.join(', ')}`);
|
|
501
545
|
for (const url of prerendered) console.log(` ${url}`);
|
package/bin/dev.js
CHANGED
|
@@ -7,7 +7,7 @@ import http from 'node:http';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { getRequestListener } from '@hono/node-server';
|
|
9
9
|
import { publicFiles as publicHandler } from '../src/public-files.js';
|
|
10
|
-
import { buildSprite,
|
|
10
|
+
import { buildSprite, readLibraries, refuseSpriteClash } from '../src/icons.js';
|
|
11
11
|
import { createServer as createViteServer } from 'vite';
|
|
12
12
|
import {
|
|
13
13
|
ACTION_METHODS,
|
|
@@ -66,18 +66,36 @@ const publicFiles =
|
|
|
66
66
|
const iconsRoot = config.iconsDir ? path.join(root, config.appDir, config.iconsDir) : null;
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
*
|
|
69
|
+
* One library's sprite, built per request rather than read off disk.
|
|
70
70
|
*
|
|
71
71
|
* Reading a directory of small files on every request is what the rest of dev
|
|
72
72
|
* already does, and it is what makes adding an icon show up on reload. A refusal
|
|
73
|
-
*
|
|
74
|
-
*
|
|
73
|
+
* is returned as text rather than thrown, so a missing viewBox reads the same
|
|
74
|
+
* here as the message that would stop the build.
|
|
75
|
+
*
|
|
76
|
+
* A name no library answers to is a 404, not an empty sprite. `/lucdie.svg` is a
|
|
77
|
+
* typo, and a blank icon is a worse way to find that out than a missing file.
|
|
75
78
|
*/
|
|
76
|
-
function sprite() {
|
|
79
|
+
function sprite(name) {
|
|
77
80
|
try {
|
|
78
|
-
const
|
|
79
|
-
refuseSpriteClash(publicRoot);
|
|
80
|
-
|
|
81
|
+
const libraries = readLibraries(iconsRoot, root);
|
|
82
|
+
refuseSpriteClash(publicRoot, libraries);
|
|
83
|
+
|
|
84
|
+
const library = libraries.find((entry) => entry.name === name);
|
|
85
|
+
if (!library) {
|
|
86
|
+
const known = libraries.map((entry) => entry.name).join(', ') || 'none';
|
|
87
|
+
return {
|
|
88
|
+
status: 404,
|
|
89
|
+
type: 'text/plain; charset=utf-8',
|
|
90
|
+
body: `no icon library "${name}". There is: ${known}`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
status: 200,
|
|
96
|
+
type: 'image/svg+xml; charset=utf-8',
|
|
97
|
+
body: buildSprite(library.icons),
|
|
98
|
+
};
|
|
81
99
|
} catch (error) {
|
|
82
100
|
return { status: 500, type: 'text/plain; charset=utf-8', body: error.message };
|
|
83
101
|
}
|
|
@@ -284,8 +302,12 @@ async function buildApp() {
|
|
|
284
302
|
// A public file at this URL is refused rather than raced, so registering after
|
|
285
303
|
// `baseApp` costs nothing: the public handler can only fall through to here.
|
|
286
304
|
if (iconsRoot) {
|
|
287
|
-
|
|
288
|
-
|
|
305
|
+
// Any `/name.svg` at the root, because a library is named by a directory the
|
|
306
|
+
// author made and dev has no list of them until it reads the disk. The public
|
|
307
|
+
// handler ran first, so an .svg the author wrote still wins.
|
|
308
|
+
app.get('/:file{[^/]+\\.svg}', (c) => {
|
|
309
|
+
const name = c.req.param('file').slice(0, -'.svg'.length);
|
|
310
|
+
const { status, type, body } = sprite(name);
|
|
289
311
|
return c.body(body, status, { 'content-type': type });
|
|
290
312
|
});
|
|
291
313
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@transclude/core",
|
|
3
|
-
"version": "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",
|
|
@@ -37,8 +37,11 @@ app/
|
|
|
37
37
|
api/_shared.js # not a route, the _ prefix says so
|
|
38
38
|
elements/ # every custom element, one file each
|
|
39
39
|
note-card.html # <note-card>, the name needs a dash
|
|
40
|
+
svg-icon.html # scaffolded by npm create, yours to edit
|
|
40
41
|
icons/ # one SVG file per icon, compiled to /icons.svg
|
|
41
42
|
check.svg # <use href="/icons.svg#check">
|
|
43
|
+
lucide/ # a subdirectory is a library: /lucide.svg
|
|
44
|
+
check.svg # <use href="/lucide.svg#check">
|
|
42
45
|
public/ # copied to the site root as-is
|
|
43
46
|
transclude.config.js
|
|
44
47
|
```
|
|
@@ -168,8 +171,23 @@ shows. The file name is the id.
|
|
|
168
171
|
<svg width="16" height="16"><use href="/icons.svg#check"></use></svg>
|
|
169
172
|
```
|
|
170
173
|
|
|
171
|
-
|
|
172
|
-
|
|
174
|
+
A subdirectory is a library of its own, served under its name. Put a downloaded
|
|
175
|
+
icon set in whole and reference it by library and name:
|
|
176
|
+
|
|
177
|
+
```html
|
|
178
|
+
<svg width="16" height="16"><use href="/lucide.svg#check"></use></svg>
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Every icon file needs a `viewBox`. A library is one flat directory, so a
|
|
182
|
+
directory inside one is refused. Two libraries may each have a `check`.
|
|
183
|
+
|
|
184
|
+
A new project has `app/elements/svg-icon.html`, which wraps the `<use>` and gets
|
|
185
|
+
the two aria spellings right. It is the project's file, not the framework's.
|
|
186
|
+
|
|
187
|
+
```html
|
|
188
|
+
<svg-icon name="check"></svg-icon>
|
|
189
|
+
<svg-icon library="lucide" name="check" label="Mark as done"></svg-icon>
|
|
190
|
+
```
|
|
173
191
|
|
|
174
192
|
Most apps wrap this in a light element so a page names an icon instead of a URL.
|
|
175
193
|
See [references/elements.md](references/elements.md).
|
|
@@ -207,6 +225,12 @@ on something inside it.
|
|
|
207
225
|
into the DOM it already rendered and never replaces a child. That is a compile
|
|
208
226
|
error naming `shadow`. Add `export const shadow = true` or keep the list still.
|
|
209
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
|
+
|
|
210
234
|
**`setHTMLUnsafe()`, never `innerHTML`.** `innerHTML` does not process nested
|
|
211
235
|
declarative shadow roots, so a child element becomes a dead `<template>`.
|
|
212
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,
|
|
@@ -188,13 +217,15 @@ rest of them.
|
|
|
188
217
|
|
|
189
218
|
## An icon element
|
|
190
219
|
|
|
191
|
-
The framework compiles `app/icons/` into one `/icons.svg` and
|
|
192
|
-
for it.
|
|
193
|
-
|
|
220
|
+
The framework compiles `app/icons/` into one `/icons.svg` and defines no element
|
|
221
|
+
for it. `npm create @transclude` writes this file into a new project, so it is
|
|
222
|
+
already there and it belongs to the project. Reproduced here for a project that
|
|
223
|
+
predates it, or one that deleted it.
|
|
194
224
|
|
|
195
225
|
```html
|
|
196
226
|
<script properties>
|
|
197
227
|
export default {
|
|
228
|
+
library: 'icons',
|
|
198
229
|
name: '',
|
|
199
230
|
label: '',
|
|
200
231
|
};
|
|
@@ -211,10 +242,14 @@ inventing.
|
|
|
211
242
|
}
|
|
212
243
|
</style>
|
|
213
244
|
|
|
214
|
-
<svg if="label" role="img" aria-label="${label}"><use href="
|
|
215
|
-
<svg else aria-hidden="true"><use href="
|
|
245
|
+
<svg if="label" role="img" aria-label="${label}"><use href="/${library}.svg#${name}"></use></svg>
|
|
246
|
+
<svg else aria-hidden="true"><use href="/${library}.svg#${name}"></use></svg>
|
|
216
247
|
```
|
|
217
248
|
|
|
249
|
+
`library` is the directory in `app/icons/`, and `icons` is the files loose at the
|
|
250
|
+
top. `<svg-icon library="lucide" name="check">` draws
|
|
251
|
+
`app/icons/lucide/check.svg`.
|
|
252
|
+
|
|
218
253
|
`<svg-icon name="check">` is decorative and hidden from a screen reader, which is
|
|
219
254
|
right when the icon sits beside its own label. `<svg-icon name="check"
|
|
220
255
|
label="Mark as done">` is announced, which is what a control holding nothing but
|
|
@@ -85,7 +85,7 @@ Source is JavaScript with JSDoc. Do not convert it to TypeScript.
|
|
|
85
85
|
| `appDir` | `'app'` | Where the app lives, relative to the project root. |
|
|
86
86
|
| `routesDir` | `'routes'` | Pages and endpoints. Relative to `appDir`. |
|
|
87
87
|
| `elementsDir` | `'elements'` | Custom elements. Relative to `appDir`. |
|
|
88
|
-
| `iconsDir` | `'icons'` | One SVG file per icon, compiled to `/icons.svg`. Relative to `appDir`. |
|
|
88
|
+
| `iconsDir` | `'icons'` | One SVG file per icon, compiled to `/icons.svg`. A subdirectory is a library at `/<name>.svg`. Relative to `appDir`. |
|
|
89
89
|
| `publicDir` | `'public'` | Copied to the site root as-is. Relative to `appDir`. |
|
|
90
90
|
| `outDir` | `'dist'` | Where the build writes. |
|
|
91
91
|
| `stylesheet` | — | One global stylesheet, relative to the project root. |
|
|
@@ -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
|
|
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/icons.js
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
|
-
// A directory of SVG files, as one sprite.
|
|
1
|
+
// A directory of SVG files, as one sprite. A directory of those, as several.
|
|
2
2
|
//
|
|
3
3
|
// An icon stays a file the author manages: `app/icons/check.svg` is a whole SVG
|
|
4
4
|
// document they can open, edit and diff. What a browser wants is the other
|
|
5
5
|
// shape, one file of `<symbol>`s, so `<use href="/icons.svg#check">` costs one
|
|
6
6
|
// cached request however many icons a page shows.
|
|
7
7
|
//
|
|
8
|
+
// A subdirectory is a library, and it is the case people arrive with: an icon
|
|
9
|
+
// set is downloaded as a folder of files, and the way to use it should be to put
|
|
10
|
+
// the folder here. `app/icons/lucide/check.svg` is `/lucide.svg#check`, and
|
|
11
|
+
// nothing was renamed to get there. Loose files at the top are the `icons`
|
|
12
|
+
// library, which is why the default sheet keeps the name it had.
|
|
13
|
+
//
|
|
8
14
|
// Build-time only, like `public-files.js` and outside the portable core: the
|
|
9
15
|
// sprite is bytes on disk by the time any server answers for it. `buildSprite`
|
|
10
16
|
// takes contents rather than a directory anyway, so the half that decides what
|
|
11
17
|
// the markup is can be tested without fixtures.
|
|
12
18
|
//
|
|
13
|
-
// The dev server and the build both call `
|
|
14
|
-
// used to be the same two lines written twice, which is how `/icons.svg`
|
|
15
|
-
// in production and 404'd in dev.
|
|
19
|
+
// The dev server and the build both call `readLibraries` then `buildSprite`.
|
|
20
|
+
// They used to be the same two lines written twice, which is how `/icons.svg`
|
|
21
|
+
// served in production and 404'd in dev.
|
|
16
22
|
|
|
17
23
|
import fs from 'node:fs';
|
|
18
24
|
import path from 'node:path';
|
|
@@ -20,8 +26,23 @@ import { parse, serializeOuter } from 'parse5';
|
|
|
20
26
|
|
|
21
27
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
22
28
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
29
|
+
/**
|
|
30
|
+
* What loose files at the top of `app/icons/` are called.
|
|
31
|
+
*
|
|
32
|
+
* It is the name the one sheet already had, so a project that never makes a
|
|
33
|
+
* subdirectory sees the URL it always saw.
|
|
34
|
+
*/
|
|
35
|
+
export const DEFAULT_LIBRARY = 'icons';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Where a library is served. At the site root, beside the author's public files,
|
|
39
|
+
* because `<use href>` is written by hand and `/lucide.svg` is what someone
|
|
40
|
+
* guesses.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} library
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
export const spritePath = (library) => `/${library}.svg`;
|
|
25
46
|
|
|
26
47
|
/**
|
|
27
48
|
* Root attributes that must not survive into a `<symbol>`.
|
|
@@ -122,53 +143,101 @@ export function buildSprite(icons) {
|
|
|
122
143
|
}
|
|
123
144
|
|
|
124
145
|
/**
|
|
125
|
-
* Refuses a hand-written public file at
|
|
146
|
+
* Refuses a hand-written public file at a library's URL.
|
|
126
147
|
*
|
|
127
|
-
* Two things would answer for `/
|
|
148
|
+
* Two things would answer for `/lucide.svg`, and the two servers pick different
|
|
128
149
|
* winners: the build copies the public directory first and writes the sprite
|
|
129
150
|
* over it, while dev asks the public handler first and never reaches the sprite.
|
|
130
151
|
* Rather than pick one, neither runs until the author has.
|
|
131
152
|
*
|
|
153
|
+
* Every library is checked, not just the default one. A library is named by a
|
|
154
|
+
* directory the author made, so the set of URLs this claims grows with their
|
|
155
|
+
* tree rather than being one name written down here.
|
|
156
|
+
*
|
|
132
157
|
* @param {string|null} publicDir the author's public directory, not the copy
|
|
133
|
-
* @
|
|
158
|
+
* @param {Array<{ name: string }>} libraries
|
|
159
|
+
* @throws when a file already sits at a library's URL
|
|
134
160
|
*/
|
|
135
|
-
export function refuseSpriteClash(publicDir) {
|
|
161
|
+
export function refuseSpriteClash(publicDir, libraries) {
|
|
136
162
|
if (!publicDir) return;
|
|
137
163
|
|
|
138
|
-
const
|
|
139
|
-
|
|
164
|
+
for (const { name } of libraries) {
|
|
165
|
+
const url = spritePath(name);
|
|
166
|
+
const clash = path.join(publicDir, path.basename(url));
|
|
167
|
+
if (!fs.existsSync(clash)) continue;
|
|
168
|
+
|
|
169
|
+
throw new Error(
|
|
170
|
+
`[transclude] ${clash} and the ${name} icons both answer for ${url}. ` +
|
|
171
|
+
`The sprite is built from the icons, so rename the public file or delete it.`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The icon files directly inside `dir`, ignoring anything that is not an SVG. */
|
|
177
|
+
function iconsIn(dir, root) {
|
|
178
|
+
const icons = [];
|
|
179
|
+
|
|
180
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
181
|
+
if (entry.isDirectory() || !entry.name.endsWith('.svg')) continue;
|
|
140
182
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
183
|
+
const full = path.join(dir, entry.name);
|
|
184
|
+
icons.push({
|
|
185
|
+
id: path.basename(entry.name, '.svg'),
|
|
186
|
+
file: path.relative(root, full),
|
|
187
|
+
svg: fs.readFileSync(full, 'utf8'),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return icons;
|
|
145
191
|
}
|
|
146
192
|
|
|
147
193
|
/**
|
|
148
|
-
* Every
|
|
194
|
+
* Every library under `dir`, each ready for `buildSprite`.
|
|
149
195
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
196
|
+
* Loose files at the top are the default library. Each subdirectory is a library
|
|
197
|
+
* of its own, named by the directory, which is what makes dropping a downloaded
|
|
198
|
+
* icon set in here the whole of using it.
|
|
199
|
+
*
|
|
200
|
+
* One level. A directory inside a library is refused rather than flattened or
|
|
201
|
+
* skipped: flattening would give two files one id, and skipping loses icons
|
|
202
|
+
* without saying so. Sorted, so a build reads the same on any filesystem.
|
|
153
203
|
*
|
|
154
204
|
* @param {string} dir
|
|
155
205
|
* @param {string} [root] what the reported file paths are relative to
|
|
156
|
-
* @returns {Array<{ id: string, file: string, svg: string }>}
|
|
206
|
+
* @returns {Array<{ name: string, icons: Array<{ id: string, file: string, svg: string }> }>}
|
|
207
|
+
* empty if `dir` is absent, and a library with no icons in it is not one
|
|
208
|
+
* @throws when a library holds a directory
|
|
157
209
|
*/
|
|
158
|
-
export function
|
|
210
|
+
export function readLibraries(dir, root = dir) {
|
|
159
211
|
if (!fs.existsSync(dir)) return [];
|
|
160
212
|
|
|
161
|
-
const
|
|
213
|
+
const libraries = [];
|
|
214
|
+
|
|
215
|
+
const loose = iconsIn(dir, root);
|
|
216
|
+
if (loose.length) libraries.push({ name: DEFAULT_LIBRARY, icons: loose });
|
|
217
|
+
|
|
162
218
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
219
|
+
if (!entry.isDirectory()) continue;
|
|
220
|
+
|
|
163
221
|
const full = path.join(dir, entry.name);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
222
|
+
refuseNesting(full, root);
|
|
223
|
+
|
|
224
|
+
const icons = iconsIn(full, root);
|
|
225
|
+
if (icons.length) libraries.push({ name: entry.name, icons });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return libraries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** A library is a flat directory. Anything deeper has no name to be served under. */
|
|
232
|
+
function refuseNesting(dir, root) {
|
|
233
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
234
|
+
if (!entry.isDirectory()) continue;
|
|
235
|
+
|
|
236
|
+
const inner = path.relative(root, path.join(dir, entry.name));
|
|
237
|
+
throw new Error(
|
|
238
|
+
`[transclude] ${inner} is a directory inside a library, and a library is ` +
|
|
239
|
+
`one flat directory of icons. Move it up to be a library of its own, or ` +
|
|
240
|
+
`flatten it into the one it is in.`,
|
|
241
|
+
);
|
|
172
242
|
}
|
|
173
|
-
return icons;
|
|
174
243
|
}
|
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
|
+
}
|