@transclude/core 0.1.1 → 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/LICENSE +1 -1
- package/README.md +65 -14
- package/bin/build.js +22 -4
- package/bin/check.js +30 -8
- package/bin/dev.js +10 -2
- package/bin/release.js +19 -4
- package/package.json +13 -4
- package/skills/transclude/SKILL.md +219 -0
- package/skills/transclude/references/elements.md +206 -0
- package/skills/transclude/references/fragments.md +168 -0
- package/skills/transclude/references/server.md +155 -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 +59 -35
- 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 +63 -11
- package/src/compiler/script.js +40 -11
- package/src/compiler/shim.js +24 -45
- 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/negotiate.js +1 -1
- package/src/plugin.js +28 -7
- package/src/precache.js +11 -1
- package/src/project.js +29 -1
- package/src/proxy.js +9 -1
- package/src/rewrite.js +12 -5
- package/src/routes.js +1 -1
- package/src/runtime/index.js +12 -9
- package/src/server.js +2 -2
- package/src/sitemap.js +3 -3
- package/src/static-cache.js +6 -3
- package/src/typecheck.js +105 -11
package/src/compiler/shim.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// check it.
|
|
3
3
|
//
|
|
4
4
|
// JavaScript rather than TypeScript, on purpose. A JSDoc `@type` in the
|
|
5
|
-
// author's own `<script props>` is
|
|
5
|
+
// author's own `<script props>` is honored in a .js file and silently ignored
|
|
6
6
|
// in a .ts one. The job is to check what the author wrote, so the shim speaks the
|
|
7
7
|
// same language they do. The scaffolding uses JSDoc too.
|
|
8
8
|
//
|
|
@@ -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() {
|
|
@@ -187,7 +173,7 @@ export function buildEndpointShim(source, { contextType }) {
|
|
|
187
173
|
// Anything not spelled like a method is a helper and gets no signature.
|
|
188
174
|
if (declared?.type === 'VariableDeclaration') {
|
|
189
175
|
const name = declared.declarations[0]?.id?.name;
|
|
190
|
-
// `@satisfies` on the
|
|
176
|
+
// `@satisfies` on the initializer: it contextually types the handler's own
|
|
191
177
|
// `ctx` *and* holds the return type, which an annotation would flatten.
|
|
192
178
|
if (isVerb(name)) edits.push({ at: node.start, insert: `/** @satisfies {${signature}} */\n` });
|
|
193
179
|
continue;
|
|
@@ -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
|
|
|
@@ -465,10 +440,16 @@ function emitModule(block, out, contextType, name = '__Data', binding = '__defau
|
|
|
465
440
|
return;
|
|
466
441
|
}
|
|
467
442
|
|
|
443
|
+
// A loader answering with a Response answers the request, and the template
|
|
444
|
+
// never renders. So the data a template reads is the loader's return with
|
|
445
|
+
// Response taken out of it. Without this, the documented way to write a login
|
|
446
|
+
// guard, `return Response.redirect(...)` from a layout, makes every name in
|
|
447
|
+
// that layout's own markup an error about a union it can never see.
|
|
448
|
+
// `ctx.action` has excluded Response since it was written, for the same reason.
|
|
468
449
|
out.add(
|
|
469
450
|
contextType
|
|
470
|
-
? `/** @typedef {__Shape<Awaited<ReturnType<typeof ${binding}
|
|
471
|
-
: `/** @typedef {__Shape<typeof ${binding}
|
|
451
|
+
? `/** @typedef {__Shape<Exclude<Awaited<ReturnType<typeof ${binding}>>, Response>>} ${name} */\n\n`
|
|
452
|
+
: `/** @typedef {__Shape<Exclude<typeof ${binding}, Response>>} ${name} */\n\n`,
|
|
472
453
|
);
|
|
473
454
|
}
|
|
474
455
|
|
|
@@ -567,20 +548,18 @@ function emitNodes(nodes, out, scope, components, depth) {
|
|
|
567
548
|
let closes = 0;
|
|
568
549
|
|
|
569
550
|
if (each) {
|
|
570
|
-
const spec =
|
|
571
|
-
each.value,
|
|
572
|
-
);
|
|
551
|
+
const spec = parseEach(each.value);
|
|
573
552
|
if (spec) {
|
|
574
|
-
const listOffset = attrValueOffset(node, 'each') + each.value.indexOf(spec
|
|
553
|
+
const listOffset = attrValueOffset(node, 'each') + each.value.indexOf(spec.list);
|
|
575
554
|
indent(out, depth);
|
|
576
|
-
out.add(`for (const ${spec
|
|
577
|
-
emitExpression(spec
|
|
555
|
+
out.add(`for (const ${spec.item} of `);
|
|
556
|
+
emitExpression(spec.list, listOffset, out, scope);
|
|
578
557
|
out.add(') {\n');
|
|
579
|
-
inner.add(spec
|
|
580
|
-
if (spec
|
|
558
|
+
inner.add(spec.item);
|
|
559
|
+
if (spec.index) {
|
|
581
560
|
indent(out, depth + 1);
|
|
582
|
-
out.add(`const ${spec
|
|
583
|
-
inner.add(spec
|
|
561
|
+
out.add(`const ${spec.index}: number = 0;\n`);
|
|
562
|
+
inner.add(spec.index);
|
|
584
563
|
}
|
|
585
564
|
closes++;
|
|
586
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/negotiate.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Picks a content encoding from an Accept-Encoding header.
|
|
3
3
|
*
|
|
4
|
-
* Getting this wrong is not a missed
|
|
4
|
+
* Getting this wrong is not a missed optimization, it is a corrupt response: a
|
|
5
5
|
* client that did not ask for brotli must never be handed brotli. So the rules
|
|
6
6
|
* are followed properly: q-values, `*`, and `q=0` as a refusal.
|
|
7
7
|
*
|
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
|
@@ -69,6 +69,34 @@ export function findRoot(from = process.cwd()) {
|
|
|
69
69
|
);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* What a config means when it says nothing.
|
|
74
|
+
*
|
|
75
|
+
* These were documented as defaults and were not applied anywhere: `loadProject`
|
|
76
|
+
* handed the object back as written, so a config that left `outDir` out reached
|
|
77
|
+
* `path.join(root, undefined)` and threw `ERR_INVALID_ARG_TYPE`, which names
|
|
78
|
+
* neither the key nor the file. The starter templates set every one of them,
|
|
79
|
+
* which is why nothing caught it.
|
|
80
|
+
*
|
|
81
|
+
* `port` is not here. `portOf` already answers it, and it reads the environment
|
|
82
|
+
* first, which a plain default cannot do.
|
|
83
|
+
*/
|
|
84
|
+
const DEFAULTS = {
|
|
85
|
+
appDir: 'app',
|
|
86
|
+
routesDir: 'routes',
|
|
87
|
+
elementsDir: 'elements',
|
|
88
|
+
publicDir: 'public',
|
|
89
|
+
outDir: 'dist',
|
|
90
|
+
typesFile: 'app/transclude-env.d.ts',
|
|
91
|
+
stylesheet: null,
|
|
92
|
+
lang: 'en',
|
|
93
|
+
fragmentParam: 'fragment',
|
|
94
|
+
trailingSlash: 'never',
|
|
95
|
+
strict: false,
|
|
96
|
+
csrf: true,
|
|
97
|
+
csp: false,
|
|
98
|
+
};
|
|
99
|
+
|
|
72
100
|
/**
|
|
73
101
|
* The root and its config together, because nothing needs one without the other.
|
|
74
102
|
*
|
|
@@ -87,7 +115,7 @@ export async function loadProject(from = process.cwd()) {
|
|
|
87
115
|
throw new Error(`[transclude] ${CONFIG_FILE} must export a config object as its default`);
|
|
88
116
|
}
|
|
89
117
|
assertNoSplitDirs(config, file);
|
|
90
|
-
return { root, config, configFile: file };
|
|
118
|
+
return { root, config: { ...DEFAULTS, ...config }, configFile: file };
|
|
91
119
|
}
|
|
92
120
|
|
|
93
121
|
/**
|
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/routes.js
CHANGED
|
@@ -145,7 +145,7 @@ function idOf(parts) {
|
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
// Static beats dynamic, dynamic beats catch-all, and among equals the longer
|
|
148
|
-
// path wins. Registration order then makes Hono's
|
|
148
|
+
// path wins. Registration order then makes Hono's behavior deterministic
|
|
149
149
|
// rather than something to reason about per-router.
|
|
150
150
|
function bySpecificity(a, b) {
|
|
151
151
|
if (a.hasRest !== b.hasRest) return a.hasRest ? 1 : -1;
|
package/src/runtime/index.js
CHANGED
|
@@ -940,7 +940,7 @@ export function watch(loaders, root = globalThis.document) {
|
|
|
940
940
|
*/
|
|
941
941
|
/**
|
|
942
942
|
* A light element has no shadow root to repaint, and repainting would destroy
|
|
943
|
-
* the children the page put inside it. So it upgrades for
|
|
943
|
+
* the children the page put inside it. So it upgrades for behavior only: the
|
|
944
944
|
* markup it was served is the markup it keeps.
|
|
945
945
|
*
|
|
946
946
|
* @param {object} def
|
|
@@ -949,19 +949,22 @@ export function watch(loaders, root = globalThis.document) {
|
|
|
949
949
|
*/
|
|
950
950
|
export function defineLight(def, init) {
|
|
951
951
|
// Before every other exit below: styles are the half of this that an element
|
|
952
|
-
// with no
|
|
952
|
+
// with no behavior still has, and the half a swapped-in one arrives without.
|
|
953
953
|
adoptStyles(def);
|
|
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
|
|
@@ -1253,7 +1256,7 @@ export function defineComponent(def, init) {
|
|
|
1253
1256
|
this.#adopt();
|
|
1254
1257
|
}
|
|
1255
1258
|
// Runs on every connect, not just the first: moving an element in the DOM
|
|
1256
|
-
// disconnects and reconnects it, and
|
|
1259
|
+
// disconnects and reconnects it, and behavior that was torn down on the
|
|
1257
1260
|
// way out has to come back on the way in.
|
|
1258
1261
|
this.#ready = true;
|
|
1259
1262
|
this.#abort = new AbortController();
|