@transclude/core 0.1.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/bin/build.js +469 -0
  4. package/bin/check.js +78 -0
  5. package/bin/dev.js +348 -0
  6. package/bin/release.js +176 -0
  7. package/bin/serve.bun.js +15 -0
  8. package/bin/serve.deno.js +15 -0
  9. package/bin/serve.js +12 -0
  10. package/editor/server.js +172 -0
  11. package/editor/vscode/extension.js +49 -0
  12. package/editor/vscode/package.json +32 -0
  13. package/editor/vscode/syntaxes/transclude.injection.json +41 -0
  14. package/package.json +82 -0
  15. package/src/address.js +183 -0
  16. package/src/app.js +492 -0
  17. package/src/cache.js +137 -0
  18. package/src/compiler/bind.js +496 -0
  19. package/src/compiler/codegen.js +1061 -0
  20. package/src/compiler/expr.js +221 -0
  21. package/src/compiler/index.js +964 -0
  22. package/src/compiler/interp.js +82 -0
  23. package/src/compiler/script.js +620 -0
  24. package/src/compiler/shim.js +756 -0
  25. package/src/compiler/sourcemap.js +140 -0
  26. package/src/compiler/types.js +163 -0
  27. package/src/compress.js +104 -0
  28. package/src/cookies.js +157 -0
  29. package/src/csp.js +192 -0
  30. package/src/document.js +604 -0
  31. package/src/extract.js +339 -0
  32. package/src/feed.js +194 -0
  33. package/src/include.js +89 -0
  34. package/src/lookup.js +49 -0
  35. package/src/negotiate.js +95 -0
  36. package/src/plugin.js +423 -0
  37. package/src/pool.js +29 -0
  38. package/src/precache.js +68 -0
  39. package/src/production.js +159 -0
  40. package/src/project.js +110 -0
  41. package/src/proxy.js +319 -0
  42. package/src/public-files.js +77 -0
  43. package/src/rewrite.js +281 -0
  44. package/src/routes.js +199 -0
  45. package/src/runtime/index.js +1345 -0
  46. package/src/server.js +183 -0
  47. package/src/sitemap.js +124 -0
  48. package/src/static-cache.js +170 -0
  49. package/src/typecheck.js +492 -0
  50. package/src/worker.js +87 -0
@@ -0,0 +1,604 @@
1
+ import { withPolicy } from './csp.js';
2
+
3
+ // Assembles the document around a layout chain.
4
+ //
5
+ // The chain is outermost layout first, page last. Body markup folds inward-out:
6
+ // the page renders, then each layout renders around what it got.
7
+
8
+ /**
9
+ * Loads a page's chain and renders the document. Layout loaders run outermost
10
+ * first, each one given what the ones above returned, so they have to run one
11
+ * after another.
12
+ */
13
+ /**
14
+ * The part of the answer that is not markup: a status and some headers.
15
+ *
16
+ * One object, handed to every loader in the chain and mutated in place. Loaders
17
+ * are called with `{ ...ctx, layout }`, so a scalar assigned onto `ctx` would be
18
+ * lost on the copy. This survives because the copy carries the same reference.
19
+ * Built here rather than in each server, because there are three of them and
20
+ * that is exactly how two servers end up disagreeing.
21
+ */
22
+ export function responseOf() {
23
+ return { status: 200, headers: new Headers() };
24
+ }
25
+
26
+ /**
27
+ * `ctx.absolute('/og.png')` -> `https://site.com/og.png`.
28
+ *
29
+ * A canonical URL, an `og:image` and a feed all have to be absolute, and the
30
+ * request's own origin is the wrong answer twice: behind a proxy it is the
31
+ * internal one, and while prerendering there is no request at all. So the origin
32
+ * comes from `metadataBase` when it is set, and falls back to the request.
33
+ *
34
+ * A path that is already absolute is returned untouched, so a value that came
35
+ * from somewhere else can be passed through without checking it first.
36
+ *
37
+ * @param {string|null|undefined} base `metadataBase` from the config
38
+ * @param {string} requestUrl
39
+ * @returns {URL}
40
+ */
41
+ export function absoluteFrom(base, requestUrl) {
42
+ return (path) => {
43
+ if (/^[a-z][a-z0-9+.-]*:/i.test(path)) return path;
44
+
45
+ const origin = base ?? requestUrl;
46
+ if (!origin) {
47
+ throw new Error(
48
+ `[transclude] absolute(${JSON.stringify(path)}) has no origin to resolve against. ` +
49
+ `Set \`metadataBase\` in the config, which is what a prerendered page uses.`,
50
+ );
51
+ }
52
+ return new URL(path, origin).href;
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Which `<meta>` and `<link>` may appear once, and what identifies them.
58
+ *
59
+ * `name`, `property` and `http-equiv` each name a different meta. Only
60
+ * `rel="canonical"` is unique among links: a page has several `alternate`s for
61
+ * its feeds and translations, and several `preload`s, all meant.
62
+ */
63
+ function headKey(tag) {
64
+ const name = tagAttr(tag, 'name');
65
+ if (name !== null) return `meta name=${name.toLowerCase()}`;
66
+
67
+ const property = tagAttr(tag, 'property');
68
+ if (property !== null) return `meta property=${property.toLowerCase()}`;
69
+
70
+ const equiv = tagAttr(tag, 'http-equiv');
71
+ if (equiv !== null) return `meta http-equiv=${equiv.toLowerCase()}`;
72
+
73
+ const rel = tagAttr(tag, 'rel');
74
+ if (rel !== null && rel.toLowerCase() === 'canonical') return 'link rel=canonical';
75
+
76
+ return null;
77
+ }
78
+
79
+ const ATTR = /([a-zA-Z][\w-]*)\s*=\s*"([^"]*)"/g;
80
+
81
+ /** A quoted value cannot hold a `"`, because both escapers turn it into `&quot;`. */
82
+ function tagAttr(tag, want) {
83
+ ATTR.lastIndex = 0;
84
+ for (let m = ATTR.exec(tag); m; m = ATTR.exec(tag)) {
85
+ if (m[1].toLowerCase() === want) return m[2];
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Where a `<meta>` or `<link>` ends.
92
+ *
93
+ * Quote-aware, because `escapeAttr` leaves `>` alone: `content="a > b"` is legal
94
+ * HTML and stops nothing, and looking for the first `>` would cut the tag in
95
+ * half.
96
+ */
97
+ function tagEnd(html, from) {
98
+ let quote = null;
99
+ for (let i = from; i < html.length; i++) {
100
+ const c = html[i];
101
+ if (quote) {
102
+ if (c === quote) quote = null;
103
+ } else if (c === '"' || c === "'") {
104
+ quote = c;
105
+ } else if (c === '>') {
106
+ return i + 1;
107
+ }
108
+ }
109
+ return html.length;
110
+ }
111
+
112
+ const HEAD_TAG = /<(meta|link)\b/gi;
113
+
114
+ /** Every uniquely-named tag in one level's head, as `{ start, end, key }`. */
115
+ function keyedTags(html) {
116
+ const found = [];
117
+ HEAD_TAG.lastIndex = 0;
118
+ for (let m = HEAD_TAG.exec(html); m; m = HEAD_TAG.exec(html)) {
119
+ const end = tagEnd(html, m.index);
120
+ const key = headKey(html.slice(m.index, end));
121
+ if (key) found.push({ start: m.index, end, key });
122
+ HEAD_TAG.lastIndex = end;
123
+ }
124
+ return found;
125
+ }
126
+
127
+ /**
128
+ * A tag an inner level restates, dropped from the outer one.
129
+ *
130
+ * The chain was concatenated, so a root layout with a default `og:image` and a
131
+ * page with its own shipped both. That is not an override: a crawler reads two
132
+ * `og:image` as two images and takes the first, which is the outermost, which is
133
+ * backwards. This is the rule `htmlAttrsOf` applies to `<html>`, which `<head>`
134
+ * never got.
135
+ *
136
+ * Across levels only. Two `og:image` at one level are deliberate and both stay.
137
+ *
138
+ * @param {string[]} parts one rendered head per level, outermost first
139
+ * @returns {string[]} the same, with what an inner level owns removed
140
+ */
141
+ function mergeHead(parts) {
142
+ if (parts.length < 2) return parts;
143
+
144
+ const tags = parts.map((html) => (html ? keyedTags(html) : []));
145
+ const claimed = new Set();
146
+ const out = new Array(parts.length);
147
+
148
+ // Innermost first, so the first level to claim a key is the one that keeps it.
149
+ for (let i = parts.length - 1; i >= 0; i--) {
150
+ const drop = tags[i].filter((tag) => claimed.has(tag.key));
151
+
152
+ if (drop.length) {
153
+ let html = '';
154
+ let at = 0;
155
+ for (const tag of drop) {
156
+ html += parts[i].slice(at, tag.start);
157
+ at = tag.end;
158
+ }
159
+ out[i] = (html + parts[i].slice(at)).trim();
160
+ } else {
161
+ out[i] = parts[i];
162
+ }
163
+
164
+ // After the level is done, so two of one key written here both survive.
165
+ for (const tag of tags[i]) claimed.add(tag.key);
166
+ }
167
+ return out;
168
+ }
169
+
170
+ /** A name that cannot break out of the tag it is written into. */
171
+ const ATTR_NAME = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
172
+
173
+ // The same four the runtime's `attr` escapes. A quoted value only strictly needs
174
+ // `&` and `"`, but two escapers that disagree is a difference somebody has to
175
+ // hold in their head.
176
+ const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
177
+
178
+ const escapeAttr = (value) => String(value).replace(/[&<>"]/g, (c) => ESCAPES[c]);
179
+
180
+ /**
181
+ * `<html …>`, with `lang` first and whatever a loader added after it.
182
+ *
183
+ * Values are escaped, because the reason this exists is putting a preference on
184
+ * the element, and a preference usually comes from a cookie. `true` writes the
185
+ * name bare and `false` drops it, the same rule the template compiler uses.
186
+ */
187
+ /**
188
+ * Every `<html>` in the chain, merged by name.
189
+ *
190
+ * Outermost first so the innermost wins, per attribute rather than outright: a
191
+ * root layout setting the theme and a page setting `dir` both survive. Written
192
+ * as one tag, because two `data-theme` attributes would leave the parser taking
193
+ * the first, which is the outermost, which is backwards.
194
+ */
195
+ function htmlAttrsOf(chain, datas) {
196
+ const merged = { __proto__: null };
197
+ for (let i = 0; i < chain.length; i++) {
198
+ Object.assign(merged, chain[i].renderHtmlAttrs?.(datas[i]));
199
+ }
200
+ return merged;
201
+ }
202
+
203
+ function htmlOpenTag(lang, attrs) {
204
+ const parts = [];
205
+
206
+ for (const [name, value] of Object.entries({ lang, ...attrs })) {
207
+ if (value === false || value === null || value === undefined) continue;
208
+ if (!ATTR_NAME.test(name)) {
209
+ throw new Error(
210
+ `[transclude] \`${name}\` cannot be an attribute on <html>. ` +
211
+ `Use lowercase letters, digits and dashes.`,
212
+ );
213
+ }
214
+ parts.push(value === true ? name : `${name}="${escapeAttr(value)}"`);
215
+ }
216
+
217
+ return `<html ${parts.join(' ')}>`;
218
+ }
219
+
220
+ /**
221
+ * Loads a page's chain and renders the document, or returns the `Response` a
222
+ * loader answered with instead.
223
+ *
224
+ * Returning one is how a loader redirects, or serves something that is not this
225
+ * page at all. It is the same convention an action already uses, so there is one
226
+ * rule rather than two. A layout can do it too, which is what makes an auth redirect
227
+ * a layout's job: nothing below it runs.
228
+ *
229
+ * For everything else the page still renders, and `ctx.response` decides what it
230
+ * is wrapped in: a 404 status on a page that renders its own "not found" body, an
231
+ * `HX-Trigger` header, a `Set-Cookie`.
232
+ *
233
+ * @param {object} page a compiled page module
234
+ * @param {object} ctx the request context
235
+ * @param {object} [options] `clientEntry`, `stylesheet`, `csp`, `lang`, `include`
236
+ * @returns {Promise<string|Response>} a Response when a loader answered for itself
237
+ */
238
+ export async function renderRoute(page, ctx, options = {}) {
239
+ // Held for this request only. A page including one route twice should run its
240
+ // loaders once; a store that outlived the request would be a second page cache
241
+ // nobody audited.
242
+ const request = { ...options, includeMemo: options.includeMemo ?? new Map() };
243
+ const chain = [...page.layouts, page];
244
+ const datas = [];
245
+ let inherited = {};
246
+
247
+ for (const mod of chain) {
248
+ const data = await mod.load({ ...ctx, layout: inherited });
249
+ if (data instanceof Response) return data;
250
+
251
+ // Before the render, because render is synchronous the whole way down and
252
+ // reading another document is not. A layout and a page each resolve their
253
+ // own, so neither can see the other's by accident.
254
+ datas.push(
255
+ mod.includes?.length
256
+ ? { ...data, __included: await resolveIncludes(mod.includes, ctx, request) }
257
+ : data,
258
+ );
259
+ if (mod !== page) inherited = { ...inherited, ...data };
260
+ }
261
+
262
+ const html = renderDocument(chain, datas, options);
263
+
264
+ // After the document exists, because the policy is built from what it inlined.
265
+ // A prerendered page runs this once at build time and carries the result.
266
+ return withPolicy(html, options.csp);
267
+ }
268
+
269
+ /**
270
+ * The markup for every `<transclude>` naming another document.
271
+ *
272
+ * All of them at once: ten includes off one page should be one round of work,
273
+ * and the resolver holds the parsed document so several from one source cost one
274
+ * read. A source that cannot be read is null here and the element falls back to
275
+ * its own children, or throws if it has none.
276
+ */
277
+ export const INCLUDE_DEPTH = 10;
278
+
279
+ /**
280
+ * The parameters a route's pattern takes from a path, or null if it does not
281
+ * match.
282
+ *
283
+ * Only what a route pattern can hold: `:name` and a trailing `:name{.+}`. An
284
+ * include names a path an author wrote, so this answers the same question the
285
+ * router does without needing the router.
286
+ *
287
+ * @param {{ pattern: string }} route
288
+ * @param {string} pathname
289
+ * @returns {Record<string, string>|null} null when the route does not match
290
+ */
291
+ export function paramsFor(route, pathname) {
292
+ const names = [];
293
+ 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), '([^/]+)'));
296
+
297
+ const found = new RegExp(`^${source}$`).exec(pathname);
298
+ if (!found) return null;
299
+
300
+ return Object.fromEntries(names.map((name, at) => [name, decodeURIComponent(found[at + 1])]));
301
+ }
302
+
303
+ /**
304
+ * @param {Array<{ key: string, kind: string, where: string, id: string }>} includes
305
+ * @param {object} ctx
306
+ * @param {object} [options] carries `include`, `includeMemo` and `includeChain`
307
+ * @returns {Promise<Record<string, string|null>>} keyed by the src as written
308
+ */
309
+ export async function resolveIncludes(includes, ctx, options = {}) {
310
+ const include = options.include ?? null;
311
+ const chain = options.includeChain ?? [];
312
+
313
+ const pairs = await Promise.all(
314
+ includes.map(async ({ key, kind, where, id }) => {
315
+ // A page that includes itself, directly or through three others, would
316
+ // otherwise render until the stack ran out. The chain is carried so the
317
+ // error can name the way round.
318
+ if (chain.includes(key)) {
319
+ throw new Error(
320
+ `[transclude] <transclude> includes itself: ` +
321
+ `${[...chain, key].join(' includes ')}.`,
322
+ );
323
+ }
324
+ if (chain.length >= INCLUDE_DEPTH) {
325
+ throw new Error(
326
+ `[transclude] <transclude src="${key}"> is ${chain.length} includes deep, ` +
327
+ `past the limit of ${INCLUDE_DEPTH}.`,
328
+ );
329
+ }
330
+
331
+ const next = { ...options, includeChain: [...chain, key] };
332
+
333
+ try {
334
+ if (kind === 'route') {
335
+ if (!include?.route) {
336
+ throw new Error(
337
+ `[transclude] <transclude src="${key}"> reads another route, ` +
338
+ `and this renderer was given no way to reach one.`,
339
+ );
340
+ }
341
+ return [key, await include.route(where, id, ctx, next)];
342
+ }
343
+
344
+ if (!include?.resolve) {
345
+ throw new Error(
346
+ `[transclude] <transclude src="${key}"> reads another site, ` +
347
+ `and no host is allowed to be read. Name one in \`proxy.allow\`.`,
348
+ );
349
+ }
350
+ return [key, await include.resolve(where, id)];
351
+ } catch (error) {
352
+ // A source that is unreachable is what the fallback is for. A source
353
+ // that is misconfigured, or a loop, is a mistake and should be heard.
354
+ if (/includes itself|past the limit|no host is allowed|no way to reach/.test(error.message)) {
355
+ throw error;
356
+ }
357
+ return [key, null];
358
+ }
359
+ }),
360
+ );
361
+ return Object.fromEntries(pairs);
362
+ }
363
+
364
+ /**
365
+ * One region of a page, for swapping into a document that already exists.
366
+ *
367
+ * The layout loaders still run: a page's own loader is handed what they
368
+ * returned, so skipping them would change the data the region renders from.
369
+ * What is skipped is the layouts' markup. A fragment is a piece of the page, not a
370
+ * document.
371
+ *
372
+ * Returns null when the page has no region by that name, which is a 404 rather
373
+ * than an empty swap: asking for something that does not exist should say so.
374
+ *
375
+ * @param {object} page
376
+ * @param {object} ctx
377
+ * @param {{ region?: string|null, include?: object, includeMemo?: Map<string, unknown> }}
378
+ * [options] everything but `region` travels on to the includes
379
+ * @returns {Promise<string|Response|null>} null when no such region
380
+ */
381
+ export async function renderFragment(page, ctx, { region = null, ...options } = {}) {
382
+ const target = region ? regionOf(page, region) : null;
383
+ if (region && !target) return null;
384
+
385
+ const chain = [...page.layouts, page];
386
+ let inherited = {};
387
+ let data = {};
388
+
389
+ for (const mod of chain) {
390
+ data = await mod.load({ ...ctx, layout: inherited });
391
+ // A loader answering with a Response outranks the region that was asked for.
392
+ if (data instanceof Response) return data;
393
+ if (mod !== page) inherited = { ...inherited, ...data };
394
+ }
395
+
396
+ const last = chain[chain.length - 1];
397
+ if (last.includes?.length) {
398
+ // The same memo `renderRoute` makes, for the same reason: one route
399
+ // included twice renders once, and the store dies with the request.
400
+ const request = { ...options, includeMemo: options.includeMemo ?? new Map() };
401
+ data = { ...data, __included: await resolveIncludes(last.includes, ctx, request) };
402
+ }
403
+
404
+ // 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);
407
+ }
408
+
409
+ /**
410
+ * The methods a server routes to `runAction`. Both servers register all of them
411
+ * for every route: a page that answers none of them should say 405 with an
412
+ * `Allow` header rather than fall through to the not-found page, because the URL
413
+ * is not what was wrong.
414
+ *
415
+ * A `<form>` only ever sends GET or POST. The rest are here for the callers that
416
+ * are not forms.
417
+ */
418
+ export const ACTION_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
419
+
420
+ /**
421
+ * Runs the page's handler for a request that is not a GET.
422
+ *
423
+ * A `Response` is the author's own answer and goes out as it is: a redirect after
424
+ * a POST, JSON, a 404. Anything else becomes `ctx.action`, and the page
425
+ * then renders exactly the way it does for a GET: `load` stays the one thing
426
+ * that decides what a page renders, whatever method asked for it. So a form
427
+ * that re-renders with an error reads the same as a form that redirects, and
428
+ * neither has to restate the page's data.
429
+ *
430
+ * `null` is "this page does not answer that method", which is a 405 rather than
431
+ * a 404. The URL exists.
432
+ *
433
+ * @param {object} page
434
+ * @param {object} ctx
435
+ * @param {string} method
436
+ * @returns {Promise<object>} what the handler returned, for the render after it
437
+ */
438
+ export async function runAction(page, ctx, method) {
439
+ const action = page[method];
440
+ if (typeof action !== 'function') return null;
441
+
442
+ const result = await action(ctx);
443
+ return result instanceof Response ? { response: result } : { action: result ?? {} };
444
+ }
445
+
446
+ /**
447
+ * A short-circuiting `Response`, carrying whatever the envelope collected.
448
+ *
449
+ * Set a session cookie and then redirect, which is an ordinary thing for an action
450
+ * to do, and the cookie would otherwise be dropped. The action's `Response` is
451
+ * returned directly, and nothing looks at `ctx.response` on that path.
452
+ * `Response.redirect()` makes it worse than a silent loss, because its headers
453
+ * are immutable and appending to them throws.
454
+ *
455
+ * So the headers go onto a copy. `new Response(body, response)` keeps the status
456
+ * and every header the author set, and comes with a mutable guard.
457
+ *
458
+ * @param {Response} response
459
+ * @param {object} ctx
460
+ * @returns {Response} a copy, because a redirect's headers cannot be written to
461
+ */
462
+ export function withEnvelope(response, ctx) {
463
+ const collected = [...(ctx?.response?.headers ?? [])];
464
+ if (!collected.length) return response;
465
+
466
+ const merged = new Response(response.body, response);
467
+ for (const [name, value] of collected) merged.headers.append(name, value);
468
+ return merged;
469
+ }
470
+
471
+ /**
472
+ * Whether a page can answer for a region name. An empty name is the page's own
473
+ * body, which always exists.
474
+ *
475
+ * Asked *before* an action runs. A misspelled region is a 404 either way, but a
476
+ * request that cannot be answered should not have mutated anything on its way to
477
+ * saying so.
478
+ */
479
+ /**
480
+ * The render function for a named region, or null.
481
+ *
482
+ * Own properties only, and it has to be a function. The name comes from a query
483
+ * string, so a plain lookup answers for everything on `Object.prototype`:
484
+ * `?fragment=constructor` found `Object`, which is truthy and callable, so the
485
+ * region was "found", the action before it ran, and the reply was whatever
486
+ * `Object(data)` stringifies to. `hasRegion` is the check that an action runs
487
+ * behind, so a name that gets past it gets past that too.
488
+ *
489
+ * @param {object|null|undefined} page a compiled page module
490
+ * @param {string} region the name from the URL
491
+ * @returns {Function|null}
492
+ */
493
+ export function regionOf(page, region) {
494
+ const regions = page?.regions;
495
+ if (!regions || !Object.hasOwn(regions, region)) return null;
496
+ return typeof regions[region] === 'function' ? regions[region] : null;
497
+ }
498
+
499
+ /**
500
+ * Whether a page answers for this region. An empty name means the whole page,
501
+ * which every page answers for.
502
+ *
503
+ * @param {object|null|undefined} page
504
+ * @param {string} region
505
+ * @returns {boolean}
506
+ */
507
+ export function hasRegion(page, region) {
508
+ return !region || regionOf(page, region) !== null;
509
+ }
510
+
511
+ /**
512
+ * What a page answers, for an `Allow` header. GET is not optional.
513
+ *
514
+ * @param {object|null|undefined} page
515
+ * @returns {string[]} for an Allow header
516
+ */
517
+ export function methodsOf(page) {
518
+ return ['GET', ...ACTION_METHODS.filter((method) => typeof page?.[method] === 'function')];
519
+ }
520
+
521
+ export function renderDocument(
522
+ chain,
523
+ datas,
524
+ { clientEntry, stylesheet, lang = 'en' } = {},
525
+ ) {
526
+ // Each level renders to a slot map and hands it to the level above, so a page
527
+ // can fill more than one hole in its layout.
528
+ let slots = {};
529
+ for (let i = chain.length - 1; i >= 0; i--) {
530
+ slots = chain[i].render(datas[i], slots);
531
+ }
532
+ const body = slots.default ?? '';
533
+
534
+ // The innermost <title> wins outright. Kept as its own render function rather
535
+ // than sliced out of rendered head markup, so this is a compile-time fact.
536
+ let title = '';
537
+ for (let i = chain.length - 1; i >= 0; i--) {
538
+ if (!chain[i].hasTitle) continue;
539
+ title = chain[i].renderTitle(datas[i]);
540
+ break;
541
+ }
542
+
543
+ // Everything else accumulates outermost first, so a page's <meta> comes last
544
+ // and a page's <style> can override a layout's.
545
+ // The framework's own defaults go through the merge as the outermost level, so
546
+ // a page or a layout writing its own `viewport` replaces this one instead of
547
+ // shipping beside it. `charset` is not here: it has to be inside the first
548
+ // 1024 bytes and is not something to override.
549
+ const [defaults, ...rest] = mergeHead([
550
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
551
+ ...chain.map((mod, i) => mod.renderHead(datas[i])),
552
+ ]);
553
+ const head = rest.filter(Boolean);
554
+
555
+ // Ahead of the stylesheet, because a <link> blocks the scripts after it and
556
+ // the point of a head script is to run before anything else.
557
+ const headScripts = chain.map((mod) => mod.headScript).filter(Boolean);
558
+ // A light element's styles are @scope-d and belong in <head> exactly once,
559
+ // however many times it was rendered. A shadow one carries its own.
560
+ //
561
+ // One <style> per tag rather than one block for all of them, each named. The
562
+ // name is what lets the client answer "are these already here?" for an element
563
+ // that arrives later in a fragment. The document says what it has, so nothing
564
+ // has to be tracked beside it.
565
+ const seen = new Set();
566
+ const scoped = [];
567
+ const collect = (defs) => {
568
+ for (const def of defs ?? []) {
569
+ if (seen.has(def.tag)) continue;
570
+ seen.add(def.tag);
571
+ if (def.light && def.css) {
572
+ scoped.push(`<style data-transclude="${def.tag}">\n${def.css}\n</style>`);
573
+ }
574
+ collect(def.elements);
575
+ }
576
+ };
577
+ for (const mod of chain) collect(mod.elements);
578
+
579
+ // Marked, and last: a page's own rules override an element's, and a style
580
+ // adopted later has to know where to insert itself to keep that true.
581
+ const own = chain.map((mod) => mod.css).filter(Boolean);
582
+ const css = [
583
+ ...scoped,
584
+ ...(own.length ? [`<style data-transclude-page>\n${own.join('\n')}\n</style>`] : []),
585
+ ];
586
+
587
+ return `<!doctype html>
588
+ ${htmlOpenTag(lang, htmlAttrsOf(chain, datas))}
589
+ <head>
590
+ <meta charset="utf-8">
591
+ ${defaults}
592
+ ${title}
593
+ ${headScripts.join('\n')}
594
+ ${stylesheet ? `<link rel="stylesheet" href="${stylesheet}">` : ''}
595
+ ${head.join('\n')}
596
+ ${css.join('\n')}
597
+ </head>
598
+ <body>
599
+ ${body}
600
+ ${clientEntry ? `<script type="module" src="${clientEntry}"></script>` : ''}
601
+ </body>
602
+ </html>
603
+ `;
604
+ }