@transclude/core 0.2.0 → 0.4.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/README.md +23 -5
- package/bin/build.js +35 -3
- package/bin/check.js +30 -8
- package/bin/dev.js +40 -2
- package/bin/release.js +53 -5
- package/package.json +3 -2
- package/skills/transclude/SKILL.md +20 -3
- package/skills/transclude/references/elements.md +43 -0
- package/skills/transclude/references/server.md +1 -0
- package/src/address.js +9 -2
- package/src/app.js +110 -81
- package/src/compiler/ambient.js +99 -0
- package/src/compiler/bind.js +34 -27
- package/src/compiler/codegen.js +40 -41
- package/src/compiler/directives.js +29 -0
- package/src/compiler/expr.js +10 -1
- package/src/compiler/html.js +41 -0
- package/src/compiler/index.js +46 -8
- package/src/compiler/script.js +10 -3
- package/src/compiler/shim.js +14 -41
- package/src/compiler/types.js +32 -5
- package/src/csp.js +7 -1
- package/src/document.js +63 -12
- package/src/extract.js +9 -8
- package/src/icons.js +174 -0
- package/src/plugin.js +28 -7
- package/src/precache.js +11 -1
- package/src/project.js +1 -0
- package/src/proxy.js +9 -1
- package/src/rewrite.js +12 -5
- package/src/runtime/index.js +8 -5
- package/src/sitemap.js +3 -3
- package/src/static-cache.js +4 -1
- package/src/typecheck.js +104 -10
package/src/document.js
CHANGED
|
@@ -192,29 +192,36 @@ const escapeAttr = (value) => String(value).replace(/[&<>"]/g, (c) => ESCAPES[c]
|
|
|
192
192
|
* as one tag, because two `data-theme` attributes would leave the parser taking
|
|
193
193
|
* the first, which is the outermost, which is backwards.
|
|
194
194
|
*/
|
|
195
|
-
function
|
|
195
|
+
function attrsOf(chain, datas, render) {
|
|
196
196
|
const merged = { __proto__: null };
|
|
197
197
|
for (let i = 0; i < chain.length; i++) {
|
|
198
|
-
Object.assign(merged, chain[i]
|
|
198
|
+
Object.assign(merged, chain[i][render]?.(datas[i]));
|
|
199
199
|
}
|
|
200
200
|
return merged;
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
/**
|
|
204
|
+
* `<html …>` or `<body …>`, from a merged attribute object.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} tag
|
|
207
|
+
* @param {object} attrs
|
|
208
|
+
* @returns {string} the open tag
|
|
209
|
+
*/
|
|
210
|
+
function openTag(tag, attrs) {
|
|
204
211
|
const parts = [];
|
|
205
212
|
|
|
206
|
-
for (const [name, value] of Object.entries(
|
|
213
|
+
for (const [name, value] of Object.entries(attrs)) {
|
|
207
214
|
if (value === false || value === null || value === undefined) continue;
|
|
208
215
|
if (!ATTR_NAME.test(name)) {
|
|
209
216
|
throw new Error(
|
|
210
|
-
`[transclude] \`${name}\` cannot be an attribute on
|
|
217
|
+
`[transclude] \`${name}\` cannot be an attribute on <${tag}>. ` +
|
|
211
218
|
`Use lowercase letters, digits and dashes.`,
|
|
212
219
|
);
|
|
213
220
|
}
|
|
214
221
|
parts.push(value === true ? name : `${name}="${escapeAttr(value)}"`);
|
|
215
222
|
}
|
|
216
223
|
|
|
217
|
-
return
|
|
224
|
+
return parts.length ? `<${tag} ${parts.join(' ')}>` : `<${tag}>`;
|
|
218
225
|
}
|
|
219
226
|
|
|
220
227
|
/**
|
|
@@ -290,9 +297,19 @@ export const INCLUDE_DEPTH = 10;
|
|
|
290
297
|
*/
|
|
291
298
|
export function paramsFor(route, pathname) {
|
|
292
299
|
const names = [];
|
|
300
|
+
|
|
301
|
+
const catchAll = (_, name) => {
|
|
302
|
+
names.push(name);
|
|
303
|
+
return '/(.+)';
|
|
304
|
+
};
|
|
305
|
+
const segment = (_, name) => {
|
|
306
|
+
names.push(name);
|
|
307
|
+
return '([^/]+)';
|
|
308
|
+
};
|
|
309
|
+
|
|
293
310
|
const source = route.pattern
|
|
294
|
-
.replace(/\/:([A-Za-z0-9_]+)\{\.\+\}/g,
|
|
295
|
-
.replace(/:([A-Za-z0-9_]+)/g,
|
|
311
|
+
.replace(/\/:([A-Za-z0-9_]+)\{\.\+\}/g, catchAll)
|
|
312
|
+
.replace(/:([A-Za-z0-9_]+)/g, segment);
|
|
296
313
|
|
|
297
314
|
const found = new RegExp(`^${source}$`).exec(pathname);
|
|
298
315
|
if (!found) return null;
|
|
@@ -300,6 +317,22 @@ export function paramsFor(route, pathname) {
|
|
|
300
317
|
return Object.fromEntries(names.map((name, at) => [name, decodeURIComponent(found[at + 1])]));
|
|
301
318
|
}
|
|
302
319
|
|
|
320
|
+
/**
|
|
321
|
+
* The URL a route and a set of params name. The other direction from
|
|
322
|
+
* `paramsFor`, and the two have to agree.
|
|
323
|
+
*
|
|
324
|
+
* The build writes a file at this URL and the sitemap advertises it. Each had
|
|
325
|
+
* its own copy of the substitution, so a pattern shape one handled and the other
|
|
326
|
+
* did not would have meant a sitemap listing URLs that were never written.
|
|
327
|
+
*
|
|
328
|
+
* @param {{ pattern: string }} route
|
|
329
|
+
* @param {Record<string, string>} params
|
|
330
|
+
* @returns {string} the pattern with every `:name` filled in
|
|
331
|
+
*/
|
|
332
|
+
export function urlFor(route, params) {
|
|
333
|
+
return route.pattern.replace(/:(\w+)(\{[^}]*\})?/g, (_, name) => String(params[name] ?? ''));
|
|
334
|
+
}
|
|
335
|
+
|
|
303
336
|
/**
|
|
304
337
|
* @param {Array<{ key: string, kind: string, where: string, id: string }>} includes
|
|
305
338
|
* @param {object} ctx
|
|
@@ -401,9 +434,15 @@ export async function renderFragment(page, ctx, { region = null, ...options } =
|
|
|
401
434
|
data = { ...data, __included: await resolveIncludes(last.includes, ctx, request) };
|
|
402
435
|
}
|
|
403
436
|
|
|
437
|
+
// `true` is `__named`: the fragment keeps the id it was declared with, because
|
|
438
|
+
// that is what a swap is matched against. An include of the same region passes
|
|
439
|
+
// false, since two copies of one id in a document is the bug that rule exists
|
|
440
|
+
// to stop.
|
|
441
|
+
const named = true;
|
|
442
|
+
|
|
404
443
|
// No region named: the page's whole body, still without its layouts.
|
|
405
|
-
if (!target) return page.render(data, {},
|
|
406
|
-
return target(data, {},
|
|
444
|
+
if (!target) return page.render(data, {}, named).default ?? '';
|
|
445
|
+
return target(data, {}, named);
|
|
407
446
|
}
|
|
408
447
|
|
|
409
448
|
/**
|
|
@@ -518,6 +557,18 @@ export function methodsOf(page) {
|
|
|
518
557
|
return ['GET', ...ACTION_METHODS.filter((method) => typeof page?.[method] === 'function')];
|
|
519
558
|
}
|
|
520
559
|
|
|
560
|
+
/**
|
|
561
|
+
* The whole document, from the innermost page outward.
|
|
562
|
+
*
|
|
563
|
+
* `chain` is the layouts and then the page; `datas` is what each one's loader
|
|
564
|
+
* returned, in the same order. Both are walked from the end, because a level
|
|
565
|
+
* renders into the slot map of the one above it.
|
|
566
|
+
*
|
|
567
|
+
* @param {object[]} chain the compiled modules, outermost first
|
|
568
|
+
* @param {object[]} datas one per level, in the same order
|
|
569
|
+
* @param {{ clientEntry?: string|null, stylesheet?: string|null, lang?: string }} [options]
|
|
570
|
+
* @returns {string} the document, starting at `<!doctype html>`
|
|
571
|
+
*/
|
|
521
572
|
export function renderDocument(
|
|
522
573
|
chain,
|
|
523
574
|
datas,
|
|
@@ -585,7 +636,7 @@ export function renderDocument(
|
|
|
585
636
|
];
|
|
586
637
|
|
|
587
638
|
return `<!doctype html>
|
|
588
|
-
${
|
|
639
|
+
${openTag('html', { lang, ...attrsOf(chain, datas, 'renderHtmlAttrs') })}
|
|
589
640
|
<head>
|
|
590
641
|
<meta charset="utf-8">
|
|
591
642
|
${defaults}
|
|
@@ -595,7 +646,7 @@ ${stylesheet ? `<link rel="stylesheet" href="${stylesheet}">` : ''}
|
|
|
595
646
|
${head.join('\n')}
|
|
596
647
|
${css.join('\n')}
|
|
597
648
|
</head>
|
|
598
|
-
|
|
649
|
+
${openTag('body', attrsOf(chain, datas, 'renderBodyAttrs'))}
|
|
599
650
|
${body}
|
|
600
651
|
${clientEntry ? `<script type="module" src="${clientEntry}"></script>` : ''}
|
|
601
652
|
</body>
|
package/src/extract.js
CHANGED
|
@@ -327,13 +327,14 @@ export function listFragments(input) {
|
|
|
327
327
|
return doc.order.map(({ id, element, implicit }) => {
|
|
328
328
|
const tag = tagOf(element);
|
|
329
329
|
const rank = rankOf(element);
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
330
|
+
// A heading and a `<dt>` each name the run of content that follows them. Any
|
|
331
|
+
// other element is only itself.
|
|
332
|
+
let kind = 'element';
|
|
333
|
+
if (rank) kind = 'heading-run';
|
|
334
|
+
else if (tag === 'dt') kind = 'dt-run';
|
|
335
|
+
|
|
336
|
+
const collapsed = textOf(element).replace(/\s+/g, ' ').trim();
|
|
337
|
+
|
|
338
|
+
return { id, implicit, tag, rank, kind, text: collapsed.slice(0, 120) };
|
|
338
339
|
});
|
|
339
340
|
}
|
package/src/icons.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// A directory of SVG files, as one sprite.
|
|
2
|
+
//
|
|
3
|
+
// An icon stays a file the author manages: `app/icons/check.svg` is a whole SVG
|
|
4
|
+
// document they can open, edit and diff. What a browser wants is the other
|
|
5
|
+
// shape, one file of `<symbol>`s, so `<use href="/icons.svg#check">` costs one
|
|
6
|
+
// cached request however many icons a page shows.
|
|
7
|
+
//
|
|
8
|
+
// Build-time only, like `public-files.js` and outside the portable core: the
|
|
9
|
+
// sprite is bytes on disk by the time any server answers for it. `buildSprite`
|
|
10
|
+
// takes contents rather than a directory anyway, so the half that decides what
|
|
11
|
+
// the markup is can be tested without fixtures.
|
|
12
|
+
//
|
|
13
|
+
// The dev server and the build both call `readIcons` then `buildSprite`. They
|
|
14
|
+
// used to be the same two lines written twice, which is how `/icons.svg` served
|
|
15
|
+
// in production and 404'd in dev.
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { parse, serializeOuter } from 'parse5';
|
|
20
|
+
|
|
21
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
22
|
+
|
|
23
|
+
/** Where the sprite is served. Fixed, because `<use href>` is written by hand. */
|
|
24
|
+
export const SPRITE_PATH = '/icons.svg';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Root attributes that must not survive into a `<symbol>`.
|
|
28
|
+
*
|
|
29
|
+
* `width` and `height` are the ones that matter: an icon file carries them so it
|
|
30
|
+
* renders on its own, and inside a sprite they fight whatever CSS sizes the
|
|
31
|
+
* `<use>`. Everything else here would be a second element's identity or a
|
|
32
|
+
* document's namespace, neither of which means anything on a symbol.
|
|
33
|
+
*
|
|
34
|
+
* Presentation attributes are deliberately not listed. `fill="none"
|
|
35
|
+
* stroke="currentColor"` on the root is how most icon sets say what they are,
|
|
36
|
+
* and dropping those turns every icon into a black blob.
|
|
37
|
+
*/
|
|
38
|
+
const DROPPED = new Set(['width', 'height', 'xmlns', 'xmlns:xlink', 'version', 'id', 'role']);
|
|
39
|
+
|
|
40
|
+
const kept = (attr) => !DROPPED.has(attr.name) && !attr.name.startsWith('aria-');
|
|
41
|
+
|
|
42
|
+
/** The `<svg>` a file starts with, or null. Parsed as HTML, which is where SVG lives. */
|
|
43
|
+
function rootSvgOf(source) {
|
|
44
|
+
const find = (node) => {
|
|
45
|
+
for (const child of node.childNodes ?? []) {
|
|
46
|
+
if (child.tagName === 'svg' && child.namespaceURI === SVG_NS) return child;
|
|
47
|
+
const found = find(child);
|
|
48
|
+
if (found) return found;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
};
|
|
52
|
+
return find(parse(source));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One file as one `<symbol>`.
|
|
57
|
+
*
|
|
58
|
+
* The parsed node is renamed and re-serialized rather than rebuilt from strings.
|
|
59
|
+
* parse5 already knows how to escape an attribute value and which SVG attributes
|
|
60
|
+
* keep their capitals, and a second hand-written serializer here would get
|
|
61
|
+
* `viewBox` wrong first.
|
|
62
|
+
*
|
|
63
|
+
* @param {{ id: string, file: string, svg: string }} icon
|
|
64
|
+
* @returns {string}
|
|
65
|
+
* @throws when the file is not an SVG, or has no `viewBox`
|
|
66
|
+
*/
|
|
67
|
+
function symbolFor({ id, file, svg }) {
|
|
68
|
+
const root = rootSvgOf(svg);
|
|
69
|
+
if (!root) {
|
|
70
|
+
throw new Error(`[transclude] ${file} has no <svg> in it, so it is not an icon.`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Refused rather than warned. Without a viewBox the symbol has no coordinate
|
|
74
|
+
// system to scale into, so the icon renders at some other size and nothing
|
|
75
|
+
// says why. That is the failure this check exists for.
|
|
76
|
+
const viewBox = root.attrs.find((attr) => attr.name === 'viewBox');
|
|
77
|
+
if (!viewBox) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`[transclude] ${file} has no viewBox. A symbol scales by its viewBox, so ` +
|
|
80
|
+
`without one the icon renders at the wrong size and says nothing. ` +
|
|
81
|
+
`Add viewBox="0 0 24 24", with the numbers the artwork was drawn at.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
root.tagName = 'symbol';
|
|
86
|
+
root.nodeName = 'symbol';
|
|
87
|
+
root.attrs = [{ name: 'id', value: id }, ...root.attrs.filter(kept)];
|
|
88
|
+
|
|
89
|
+
return serializeOuter(root);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Every icon as one SVG document.
|
|
94
|
+
*
|
|
95
|
+
* Sorted by id, so two builds of the same directory produce the same bytes and
|
|
96
|
+
* an ETag means what it says.
|
|
97
|
+
*
|
|
98
|
+
* @param {Array<{ id: string, file: string, svg: string }>} icons
|
|
99
|
+
* @returns {string} an SVG document of `<symbol>`s
|
|
100
|
+
* @throws when two files claim one id
|
|
101
|
+
*/
|
|
102
|
+
export function buildSprite(icons) {
|
|
103
|
+
const byId = new Map();
|
|
104
|
+
for (const icon of icons) {
|
|
105
|
+
const first = byId.get(icon.id);
|
|
106
|
+
if (first) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`[transclude] ${first.file} and ${icon.file} would both be #${icon.id}. ` +
|
|
109
|
+
`An icon is named by its file, so two files cannot share a name even in ` +
|
|
110
|
+
`different directories. Rename one.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
byId.set(icon.id, icon);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const sorted = [...icons].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
117
|
+
const symbols = sorted.map(symbolFor).join('');
|
|
118
|
+
|
|
119
|
+
// No `display:none` and no `<defs>`. A `<symbol>` renders nothing on its own,
|
|
120
|
+
// which is the whole reason the sprite is symbols rather than groups.
|
|
121
|
+
return `<svg xmlns="${SVG_NS}">${symbols}</svg>`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Refuses a hand-written public file at the sprite's URL.
|
|
126
|
+
*
|
|
127
|
+
* Two things would answer for `/icons.svg`, and the two servers pick different
|
|
128
|
+
* winners: the build copies the public directory first and writes the sprite
|
|
129
|
+
* over it, while dev asks the public handler first and never reaches the sprite.
|
|
130
|
+
* Rather than pick one, neither runs until the author has.
|
|
131
|
+
*
|
|
132
|
+
* @param {string|null} publicDir the author's public directory, not the copy
|
|
133
|
+
* @throws when a file already sits at the sprite's URL
|
|
134
|
+
*/
|
|
135
|
+
export function refuseSpriteClash(publicDir) {
|
|
136
|
+
if (!publicDir) return;
|
|
137
|
+
|
|
138
|
+
const clash = path.join(publicDir, path.basename(SPRITE_PATH));
|
|
139
|
+
if (!fs.existsSync(clash)) return;
|
|
140
|
+
|
|
141
|
+
throw new Error(
|
|
142
|
+
`[transclude] ${clash} and the icons directory both answer for ${SPRITE_PATH}. ` +
|
|
143
|
+
`The sprite is built from the icons, so rename the public file or delete it.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Every `.svg` under `dir`, ready for `buildSprite`.
|
|
149
|
+
*
|
|
150
|
+
* Nested directories are read, and an icon is still named by its file alone, so
|
|
151
|
+
* `ui/check.svg` and `nav/check.svg` collide. `buildSprite` says so by name.
|
|
152
|
+
* Sorting is left to it, so one directory reads the same on any filesystem.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} dir
|
|
155
|
+
* @param {string} [root] what the reported file paths are relative to
|
|
156
|
+
* @returns {Array<{ id: string, file: string, svg: string }>} empty if `dir` is absent
|
|
157
|
+
*/
|
|
158
|
+
export function readIcons(dir, root = dir) {
|
|
159
|
+
if (!fs.existsSync(dir)) return [];
|
|
160
|
+
|
|
161
|
+
const icons = [];
|
|
162
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
163
|
+
const full = path.join(dir, entry.name);
|
|
164
|
+
if (entry.isDirectory()) icons.push(...readIcons(full, root));
|
|
165
|
+
else if (entry.name.endsWith('.svg')) {
|
|
166
|
+
icons.push({
|
|
167
|
+
id: path.basename(entry.name, '.svg'),
|
|
168
|
+
file: path.relative(root, full),
|
|
169
|
+
svg: fs.readFileSync(full, 'utf8'),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return icons;
|
|
174
|
+
}
|
package/src/plugin.js
CHANGED
|
@@ -164,11 +164,25 @@ export default function transclude({
|
|
|
164
164
|
};
|
|
165
165
|
};
|
|
166
166
|
|
|
167
|
+
/** The 404 or the 500 page, as the manifest holds it, or null if there is none. */
|
|
168
|
+
const errorPageEntry = (route) => {
|
|
169
|
+
if (!route) return null;
|
|
170
|
+
return { id: route.id, rel: route.rel, params: [], client: clientManifest(route) };
|
|
171
|
+
};
|
|
172
|
+
|
|
167
173
|
const report = (label, warnings) => {
|
|
168
174
|
for (const w of warnings ?? []) console.warn(`[transclude] ${label}: ${w}`);
|
|
169
175
|
};
|
|
170
176
|
|
|
171
|
-
|
|
177
|
+
// The bins pass this plugin to Vite themselves, and Vite merges a project's
|
|
178
|
+
// own `vite.config.js` rather than deduping, so a project that registers it
|
|
179
|
+
// again gets two: the second scans the app a second time and adds a second
|
|
180
|
+
// dev watcher, which reloads the browser twice for one edit. `api` is the one
|
|
181
|
+
// thing Vite keeps by reference when it copies a plugin, so it is how an
|
|
182
|
+
// instance recognizes itself in the resolved list.
|
|
183
|
+
let duplicate = false;
|
|
184
|
+
|
|
185
|
+
const plugin = {
|
|
172
186
|
name: 'transclude',
|
|
173
187
|
enforce: 'pre',
|
|
174
188
|
|
|
@@ -194,12 +208,10 @@ export default function transclude({
|
|
|
194
208
|
rel: route.rel,
|
|
195
209
|
params: route.params,
|
|
196
210
|
})),
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
error: scanned.error
|
|
201
|
-
? { id: scanned.error.id, rel: scanned.error.rel, params: [], client: clientManifest(scanned.error) }
|
|
202
|
-
: null,
|
|
211
|
+
// Neither is a route: both are reached for rather than matched, so
|
|
212
|
+
// they carry no pattern and no params.
|
|
213
|
+
notFound: errorPageEntry(scanned.notFound),
|
|
214
|
+
error: errorPageEntry(scanned.error),
|
|
203
215
|
};
|
|
204
216
|
},
|
|
205
217
|
configure(config) {
|
|
@@ -211,6 +223,9 @@ export default function transclude({
|
|
|
211
223
|
},
|
|
212
224
|
|
|
213
225
|
configResolved(config) {
|
|
226
|
+
duplicate = config.plugins.find((p) => p.name === 'transclude')?.api !== plugin.api;
|
|
227
|
+
if (duplicate) return;
|
|
228
|
+
|
|
214
229
|
root = config.root;
|
|
215
230
|
app = path.resolve(root, appDir);
|
|
216
231
|
runtime = '/' + path.relative(root, RUNTIME_FILE).split(path.sep).join('/');
|
|
@@ -218,6 +233,7 @@ export default function transclude({
|
|
|
218
233
|
},
|
|
219
234
|
|
|
220
235
|
resolveId(id, importer) {
|
|
236
|
+
if (duplicate) return null;
|
|
221
237
|
if (id === SERVER_ENTRY || id === ELEMENTS_ENTRY) return '\0' + id;
|
|
222
238
|
if (
|
|
223
239
|
id.startsWith(P_COMPONENT) ||
|
|
@@ -237,6 +253,7 @@ export default function transclude({
|
|
|
237
253
|
},
|
|
238
254
|
|
|
239
255
|
load(id) {
|
|
256
|
+
if (duplicate) return null;
|
|
240
257
|
if (!id.startsWith('\0virtual:transclude-')) return null;
|
|
241
258
|
const virt = id.slice(1);
|
|
242
259
|
|
|
@@ -350,6 +367,8 @@ export const middleware = ${hasMiddleware ? '__middleware ?? null' : 'null'};
|
|
|
350
367
|
},
|
|
351
368
|
|
|
352
369
|
configureServer(server) {
|
|
370
|
+
if (duplicate) return;
|
|
371
|
+
|
|
353
372
|
server.watcher.on('all', (_event, file) => {
|
|
354
373
|
if (!file.endsWith('.html')) return;
|
|
355
374
|
if (!file.startsWith(app)) return;
|
|
@@ -363,6 +382,8 @@ export const middleware = ${hasMiddleware ? '__middleware ?? null' : 'null'};
|
|
|
363
382
|
});
|
|
364
383
|
},
|
|
365
384
|
};
|
|
385
|
+
|
|
386
|
+
return plugin;
|
|
366
387
|
}
|
|
367
388
|
|
|
368
389
|
/**
|
package/src/precache.js
CHANGED
|
@@ -20,6 +20,13 @@
|
|
|
20
20
|
* @typedef {{ url: string, revision: string|null }} Entry
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
/** Orders two entries by URL. */
|
|
24
|
+
function byUrl(a, b) {
|
|
25
|
+
if (a.url < b.url) return -1;
|
|
26
|
+
if (a.url > b.url) return 1;
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
/**
|
|
24
31
|
* @param {object} sources
|
|
25
32
|
* @param {Iterable<[string, object]>} sources.pages prerendered documents
|
|
@@ -46,7 +53,10 @@ export function precacheList({ pages, assets, files = [] }) {
|
|
|
46
53
|
}
|
|
47
54
|
}
|
|
48
55
|
|
|
49
|
-
|
|
56
|
+
// Sorted by URL so two builds of the same site write the same file. Compared
|
|
57
|
+
// as code units rather than with `localeCompare`, which is locale-dependent
|
|
58
|
+
// and would order the list differently on different machines.
|
|
59
|
+
entries.sort(byUrl);
|
|
50
60
|
return entries;
|
|
51
61
|
}
|
|
52
62
|
|
package/src/project.js
CHANGED
package/src/proxy.js
CHANGED
|
@@ -163,6 +163,11 @@ export function documentStore(max = DEFAULTS.cache) {
|
|
|
163
163
|
};
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/** The entries with a value, so an empty one is left out rather than sent blank. */
|
|
167
|
+
function present(headers) {
|
|
168
|
+
return Object.fromEntries(Object.entries(headers).filter(([, value]) => value));
|
|
169
|
+
}
|
|
170
|
+
|
|
166
171
|
/**
|
|
167
172
|
* A foreign document, fetched, cleaned and indexed.
|
|
168
173
|
*
|
|
@@ -183,6 +188,9 @@ export async function readForeign(url, options = {}, deps = {}) {
|
|
|
183
188
|
const held = store?.get(url) ?? null;
|
|
184
189
|
if (held && now() - held.at < config.maxAge) return held;
|
|
185
190
|
|
|
191
|
+
// A held copy with no ETag and no Last-Modified gives nothing to revalidate
|
|
192
|
+
// against, and sending the headers empty would ask the origin to compare
|
|
193
|
+
// against nothing. `present` drops them.
|
|
186
194
|
const revalidate = held
|
|
187
195
|
? { 'if-none-match': held.etag ?? '', 'if-modified-since': held.lastModified ?? '' }
|
|
188
196
|
: {};
|
|
@@ -192,7 +200,7 @@ export async function readForeign(url, options = {}, deps = {}) {
|
|
|
192
200
|
? (at, init) =>
|
|
193
201
|
get(at, {
|
|
194
202
|
...init,
|
|
195
|
-
headers: { ...init.headers, ...
|
|
203
|
+
headers: { ...init.headers, ...present(revalidate) },
|
|
196
204
|
})
|
|
197
205
|
: get;
|
|
198
206
|
|
package/src/rewrite.js
CHANGED
|
@@ -167,6 +167,9 @@ const absolute = (value, base) => {
|
|
|
167
167
|
}
|
|
168
168
|
};
|
|
169
169
|
|
|
170
|
+
/** What HTML counts as whitespace between srcset candidates. */
|
|
171
|
+
const SRCSET_SPACE = new Set([' ', '\t', '\n', '\r', '\f']);
|
|
172
|
+
|
|
170
173
|
/**
|
|
171
174
|
* A `srcset`, as a list of candidates.
|
|
172
175
|
*
|
|
@@ -180,7 +183,7 @@ const absolute = (value, base) => {
|
|
|
180
183
|
*/
|
|
181
184
|
export function parseSrcset(value) {
|
|
182
185
|
const out = [];
|
|
183
|
-
const ws = (c) => c
|
|
186
|
+
const ws = (c) => SRCSET_SPACE.has(c);
|
|
184
187
|
let i = 0;
|
|
185
188
|
|
|
186
189
|
while (i < value.length) {
|
|
@@ -260,10 +263,14 @@ export function absolutize(root, base) {
|
|
|
260
263
|
if (!holdsUrl || !attr.value.trim()) continue;
|
|
261
264
|
if (/^(data|blob|mailto:|tel:|javascript:)/i.test(attr.value.trim())) continue;
|
|
262
265
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
266
|
+
// `ping` is the one attribute here holding a list rather than a URL.
|
|
267
|
+
if (attr.name !== 'ping') {
|
|
268
|
+
attr.value = absolute(attr.value, base);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const urls = attr.value.split(/\s+/).filter(Boolean);
|
|
273
|
+
attr.value = urls.map((url) => absolute(url, base)).join(' ');
|
|
267
274
|
}
|
|
268
275
|
|
|
269
276
|
if (child.tagName === 'style') {
|
package/src/runtime/index.js
CHANGED
|
@@ -954,14 +954,17 @@ export function defineLight(def, init) {
|
|
|
954
954
|
|
|
955
955
|
if (typeof customElements === 'undefined') return;
|
|
956
956
|
if (customElements.get(def.tag)) return;
|
|
957
|
+
|
|
957
958
|
// No behavior to attach means nothing to register. A light element with no
|
|
958
959
|
// <script> is markup that was already rendered, and it ships no JavaScript at
|
|
959
|
-
// all, accessors included. That is the trade the
|
|
960
|
-
// zero-JS default makes.
|
|
960
|
+
// all, accessors included. That is the trade the zero-JS default makes.
|
|
961
961
|
//
|
|
962
|
-
// Being a form control counts
|
|
963
|
-
//
|
|
964
|
-
|
|
962
|
+
// Being a form control counts: a shadow root is not required to be one, and an
|
|
963
|
+
// element that submits a value has to exist to do it. So does state, because
|
|
964
|
+
// its accessor is what schedules the write.
|
|
965
|
+
const hasBehavior =
|
|
966
|
+
Boolean(init) || hasMembers(def) || def.formAssociated === true || hasState(def);
|
|
967
|
+
if (!hasBehavior) return;
|
|
965
968
|
|
|
966
969
|
class Light extends HTMLElement {
|
|
967
970
|
// Every declared prop, so a change reaches the template. A light element
|
package/src/sitemap.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// else (a route with no `paths`, an endpoint, an error page) is not a page a
|
|
7
7
|
// crawler can reach by guessing, so it is left out.
|
|
8
8
|
|
|
9
|
+
import { urlFor } from './document.js';
|
|
10
|
+
|
|
9
11
|
/** The protocol's cap for one file. Past it the response is an index of files. */
|
|
10
12
|
const LIMIT = 50000;
|
|
11
13
|
|
|
@@ -56,9 +58,7 @@ export async function sitemapEntries(manifest, pages, { entries = [], exclude =
|
|
|
56
58
|
if (typeof page?.paths !== 'function') continue;
|
|
57
59
|
|
|
58
60
|
for (const params of (await page.paths()) ?? []) {
|
|
59
|
-
found.push({
|
|
60
|
-
path: route.pattern.replace(/:(\w+)(\{[^}]*\})?/g, (_, name) => String(params[name] ?? '')),
|
|
61
|
-
});
|
|
61
|
+
found.push({ path: urlFor(route, params) });
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
package/src/static-cache.js
CHANGED
|
@@ -139,7 +139,10 @@ function read(file) {
|
|
|
139
139
|
* @returns {string} a quoted ETag
|
|
140
140
|
*/
|
|
141
141
|
export function etagOf(body) {
|
|
142
|
-
|
|
142
|
+
const digest = createHash('sha1').update(body).digest('base64url');
|
|
143
|
+
// Twenty characters of base64url is 120 bits, which is far more than a cache
|
|
144
|
+
// key needs and short enough to read in a header.
|
|
145
|
+
return `"${digest.slice(0, 20)}"`;
|
|
143
146
|
}
|
|
144
147
|
|
|
145
148
|
function variantSize(file) {
|