@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.
@@ -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 honoured in a .js file and silently ignored
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 initialiser: it contextually types the handler's own
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
- // Two jobs, both about letting the author write plain JS.
282
- //
283
- // The mapping is what keeps `${user.nmae}` an error. TypeScript treats a type
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}>>>} ${name} */\n\n`
471
- : `/** @typedef {__Shape<typeof ${binding}>} ${name} */\n\n`,
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 = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+of\s+([\s\S]+?)\s*$/.exec(
571
- each.value,
572
- );
551
+ const spec = parseEach(each.value);
573
552
  if (spec) {
574
- const listOffset = attrValueOffset(node, 'each') + each.value.indexOf(spec[3]);
553
+ const listOffset = attrValueOffset(node, 'each') + each.value.indexOf(spec.list);
575
554
  indent(out, depth);
576
- out.add(`for (const ${spec[1]} of `);
577
- emitExpression(spec[3], listOffset, out, scope);
555
+ out.add(`for (const ${spec.item} of `);
556
+ emitExpression(spec.list, listOffset, out, scope);
578
557
  out.add(') {\n');
579
- inner.add(spec[1]);
580
- if (spec[2]) {
558
+ inner.add(spec.item);
559
+ if (spec.index) {
581
560
  indent(out, depth + 1);
582
- out.add(`const ${spec[2]}: number = 0;\n`);
583
- inner.add(spec[2]);
561
+ out.add(`const ${spec.index}: number = 0;\n`);
562
+ inner.add(spec.index);
584
563
  }
585
564
  closes++;
586
565
  }
@@ -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 element carries its `tag`, its props `type`,
9
- * its `members`, its `state` and whether anything `upgrades` it. `partials` is
10
- * the light elements: the key is the old name and is load-bearing until the
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({ components = [], partials = [], layouts = [], pages = [] } = {}) {
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 === 'script-src' ? scripts : name === 'style-src' ? styles : [];
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 htmlAttrsOf(chain, datas) {
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].renderHtmlAttrs?.(datas[i]));
198
+ Object.assign(merged, chain[i][render]?.(datas[i]));
199
199
  }
200
200
  return merged;
201
201
  }
202
202
 
203
- function htmlOpenTag(lang, attrs) {
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({ lang, ...attrs })) {
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 <html>. ` +
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 `<html ${parts.join(' ')}>`;
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, (_, name) => (names.push(name), '/(.+)'))
295
- .replace(/:([A-Za-z0-9_]+)/g, (_, name) => (names.push(name), '([^/]+)'));
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, {}, true).default ?? '';
406
- return target(data, {}, true);
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
- ${htmlOpenTag(lang, htmlAttrsOf(chain, datas))}
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
- <body>
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
- return {
331
- id,
332
- implicit,
333
- tag,
334
- rank,
335
- kind: rank ? 'heading-run' : tag === 'dt' ? 'dt-run' : 'element',
336
- text: textOf(element).replace(/\s+/g, ' ').trim().slice(0, 120),
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 optimisation, it is a corrupt response: a
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
- return {
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
- notFound: scanned.notFound
198
- ? { id: scanned.notFound.id, rel: scanned.notFound.rel, params: [], client: clientManifest(scanned.notFound) }
199
- : null,
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
- entries.sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
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, ...Object.fromEntries(Object.entries(revalidate).filter(([, v]) => v)) },
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 === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f';
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
- attr.value =
264
- attr.name === 'ping'
265
- ? attr.value.split(/\s+/).filter(Boolean).map((u) => absolute(u, base)).join(' ')
266
- : absolute(attr.value, base);
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 behaviour deterministic
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;
@@ -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 behaviour only: the
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 behaviour still has, and the half a swapped-in one arrives without.
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
- // No behaviour to attach means nothing to register. A light element with no
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 as behaviour: a shadow root is not required to be
963
- // one, and an element that submits a value has to exist to do it.
964
- if (!init && !hasMembers(def) && !def.formAssociated && !hasState(def)) return;
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 behaviour that was torn down on the
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();