@transclude/core 0.2.0 → 0.3.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 +22 -3
- package/bin/build.js +9 -3
- package/bin/check.js +30 -8
- package/bin/dev.js +10 -2
- package/package.json +3 -2
- package/skills/transclude/SKILL.md +1 -1
- 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/plugin.js +28 -7
- package/src/precache.js +11 -1
- 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/compiler/shim.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
// came from.
|
|
15
15
|
|
|
16
16
|
import { parse, parseExpressionAt } from 'acorn';
|
|
17
|
+
import { ambientJsdoc } from './ambient.js';
|
|
18
|
+
import { parseEach } from './directives.js';
|
|
17
19
|
import { childrenOf } from './codegen.js';
|
|
18
20
|
import { splitInterpolations } from './interp.js';
|
|
19
21
|
import { splitBlocks } from './index.js';
|
|
@@ -46,23 +48,7 @@ const PASS_THROUGH = /^(?:data|aria|hx)-/;
|
|
|
46
48
|
* it type-checked happily and caught nothing. The endpoint shim
|
|
47
49
|
* did exactly that.
|
|
48
50
|
*/
|
|
49
|
-
const COOKIES_TYPEDEF =
|
|
50
|
-
'/**\n' +
|
|
51
|
-
' * @typedef {{ path?: string; domain?: string; maxAge?: number; expires?: Date;\n' +
|
|
52
|
-
' * httpOnly?: boolean; secure?: boolean; sameSite?: "Strict" | "Lax" | "None" }} __CookieOptions\n' +
|
|
53
|
-
' */\n' +
|
|
54
|
-
'/**\n' +
|
|
55
|
-
' * @typedef {{\n' +
|
|
56
|
-
' * get(name: string): string | undefined;\n' +
|
|
57
|
-
' * all(): Record<string, string>;\n' +
|
|
58
|
-
' * set(name: string, value: string, options?: __CookieOptions): void;\n' +
|
|
59
|
-
' * delete(name: string, options?: __CookieOptions): void;\n' +
|
|
60
|
-
' * signed: {\n' +
|
|
61
|
-
' * get(name: string): Promise<string | undefined>;\n' +
|
|
62
|
-
' * all(): Promise<Record<string, string>>;\n' +
|
|
63
|
-
' * set(name: string, value: string, options?: __CookieOptions): Promise<void>;\n' +
|
|
64
|
-
' * };\n' +
|
|
65
|
-
' * }} __Cookies\n */\n\n';
|
|
51
|
+
const COOKIES_TYPEDEF = `${ambientJsdoc(['__CookieOptions', '__Cookies'])}\n`;
|
|
66
52
|
|
|
67
53
|
class Builder {
|
|
68
54
|
constructor() {
|
|
@@ -278,20 +264,9 @@ export function buildShim(source, { kind, shadow = false, contextType = null, co
|
|
|
278
264
|
// types its own.
|
|
279
265
|
out.add('export {};\n');
|
|
280
266
|
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
// that came straight from an object literal in a .js file as open for expando
|
|
285
|
-
// properties, so reading an undeclared one is allowed. Remapping the keys gives
|
|
286
|
-
// an ordinary object type, where it is not.
|
|
287
|
-
//
|
|
288
|
-
// The conditional widens a bare `[]`, which otherwise infers `never[]` and
|
|
289
|
-
// turns "no annotation" from "less checking" into a page of errors about a
|
|
290
|
-
// type nobody wrote.
|
|
291
|
-
out.add(
|
|
292
|
-
'/**\n * @template T\n' +
|
|
293
|
-
' * @typedef {{ [K in keyof T]: T[K] extends never[] ? any[] : T[K] }} __Shape\n */\n',
|
|
294
|
-
);
|
|
267
|
+
// Both jobs `__Shape` does are about letting the author write plain JS, and
|
|
268
|
+
// both are written out in `ambient.js`.
|
|
269
|
+
out.add(ambientJsdoc(['__Shape']));
|
|
295
270
|
|
|
296
271
|
out.add(COOKIES_TYPEDEF);
|
|
297
272
|
|
|
@@ -573,20 +548,18 @@ function emitNodes(nodes, out, scope, components, depth) {
|
|
|
573
548
|
let closes = 0;
|
|
574
549
|
|
|
575
550
|
if (each) {
|
|
576
|
-
const spec =
|
|
577
|
-
each.value,
|
|
578
|
-
);
|
|
551
|
+
const spec = parseEach(each.value);
|
|
579
552
|
if (spec) {
|
|
580
|
-
const listOffset = attrValueOffset(node, 'each') + each.value.indexOf(spec
|
|
553
|
+
const listOffset = attrValueOffset(node, 'each') + each.value.indexOf(spec.list);
|
|
581
554
|
indent(out, depth);
|
|
582
|
-
out.add(`for (const ${spec
|
|
583
|
-
emitExpression(spec
|
|
555
|
+
out.add(`for (const ${spec.item} of `);
|
|
556
|
+
emitExpression(spec.list, listOffset, out, scope);
|
|
584
557
|
out.add(') {\n');
|
|
585
|
-
inner.add(spec
|
|
586
|
-
if (spec
|
|
558
|
+
inner.add(spec.item);
|
|
559
|
+
if (spec.index) {
|
|
587
560
|
indent(out, depth + 1);
|
|
588
|
-
out.add(`const ${spec
|
|
589
|
-
inner.add(spec
|
|
561
|
+
out.add(`const ${spec.index}: number = 0;\n`);
|
|
562
|
+
inner.add(spec.index);
|
|
590
563
|
}
|
|
591
564
|
closes++;
|
|
592
565
|
}
|
package/src/compiler/types.js
CHANGED
|
@@ -3,15 +3,24 @@
|
|
|
3
3
|
// Everything here is a type *string* produced by tsc's own printer, so this file
|
|
4
4
|
// formats and names things and nothing else. Nothing infers.
|
|
5
5
|
|
|
6
|
+
import { ambientDeclarations } from './ambient.js';
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* @param {{ components?: object[], partials?: object[], layouts?: object[],
|
|
8
|
-
* pages?: object[] }} [what] each
|
|
9
|
-
* its `
|
|
10
|
-
*
|
|
11
|
-
* callers change with it.
|
|
10
|
+
* pages?: object[], types?: {name: string, type: string}[] }} [what] each
|
|
11
|
+
* element carries its `tag`, its props `type`, its `members`, its `state` and
|
|
12
|
+
* whether anything `upgrades` it. `partials` is the light elements: the key is
|
|
13
|
+
* the old name and is load-bearing until the callers change with it. `types`
|
|
14
|
+
* are the names the app declared that the strings below use.
|
|
12
15
|
* @returns {string} the contents of transclude-env.d.ts
|
|
13
16
|
*/
|
|
14
|
-
export function emitTypes({
|
|
17
|
+
export function emitTypes({
|
|
18
|
+
components = [],
|
|
19
|
+
partials = [],
|
|
20
|
+
layouts = [],
|
|
21
|
+
pages = [],
|
|
22
|
+
types = [],
|
|
23
|
+
} = {}) {
|
|
15
24
|
const out = [
|
|
16
25
|
'// Generated by transclude. Do not edit. `npm run check` rewrites it.',
|
|
17
26
|
'//',
|
|
@@ -21,6 +30,9 @@ export function emitTypes({ components = [], partials = [], layouts = [], pages
|
|
|
21
30
|
'',
|
|
22
31
|
];
|
|
23
32
|
|
|
33
|
+
// Where the declarations go once it is known which are needed.
|
|
34
|
+
const head = out.length;
|
|
35
|
+
|
|
24
36
|
for (const { tag, type, members, state } of [...components, ...partials]) {
|
|
25
37
|
out.push(`/** Properties of \`<${tag}>\`, from its <script properties> block. */`);
|
|
26
38
|
out.push(`export type ${interfaceName(tag)}Props = ${pretty(type)};`);
|
|
@@ -83,6 +95,21 @@ export function emitTypes({ components = [], partials = [], layouts = [], pages
|
|
|
83
95
|
out.push('');
|
|
84
96
|
}
|
|
85
97
|
|
|
98
|
+
// Written last and put first. What has to be declared is whatever the types
|
|
99
|
+
// above ended up naming, which is not known until they are all built.
|
|
100
|
+
const body = out.slice(head).join('\n');
|
|
101
|
+
const declared = [
|
|
102
|
+
...ambientDeclarations(body, (type) => pretty(type)),
|
|
103
|
+
...(types.length
|
|
104
|
+
? [
|
|
105
|
+
'// Declared by the app, in the file each was written in.',
|
|
106
|
+
...types.map(({ name, type }) => `type ${name} = ${pretty(type)};`),
|
|
107
|
+
'',
|
|
108
|
+
]
|
|
109
|
+
: []),
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
out.splice(head, 0, ...declared);
|
|
86
113
|
return out.join('\n');
|
|
87
114
|
}
|
|
88
115
|
|
package/src/csp.js
CHANGED
|
@@ -104,9 +104,15 @@ export async function policyFor(html, { directives = CSP_DEFAULTS } = {}) {
|
|
|
104
104
|
inline.filter((one) => one.kind === 'style').map((one) => sha256(one.body)),
|
|
105
105
|
);
|
|
106
106
|
|
|
107
|
+
// Which set `'hashes'` stands for, by directive. Anything else gets none:
|
|
108
|
+
// there is nothing to hash for `default-src`, and a directive carrying a hash
|
|
109
|
+
// ignores `'unsafe-inline'` outright, so handing it an empty list is wrong in a
|
|
110
|
+
// way that only shows up in a browser.
|
|
111
|
+
const hashesFor = { 'script-src': scripts, 'style-src': styles };
|
|
112
|
+
|
|
107
113
|
const parts = [];
|
|
108
114
|
for (const [name, sources] of Object.entries(directives)) {
|
|
109
|
-
const hashes = name
|
|
115
|
+
const hashes = hashesFor[name] ?? [];
|
|
110
116
|
const resolved = sources.flatMap((source) => (source === "'hashes'" ? hashes : [source]));
|
|
111
117
|
|
|
112
118
|
// A directive left with nothing is dropped. An empty source list means
|
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/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/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) {
|