@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/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/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
- 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
@@ -86,6 +86,7 @@ const DEFAULTS = {
86
86
  routesDir: 'routes',
87
87
  elementsDir: 'elements',
88
88
  publicDir: 'public',
89
+ iconsDir: 'icons',
89
90
  outDir: 'dist',
90
91
  typesFile: 'app/transclude-env.d.ts',
91
92
  stylesheet: null,
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') {
@@ -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 as behavior: 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
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
 
@@ -139,7 +139,10 @@ function read(file) {
139
139
  * @returns {string} a quoted ETag
140
140
  */
141
141
  export function etagOf(body) {
142
- return `"${createHash('sha1').update(body).digest('base64url').slice(0, 20)}"`;
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) {