@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,964 @@
1
+ // Turns a single .html file into one JS module that serves both renders.
2
+
3
+ import { parse, parseFragment } from 'parse5';
4
+ import { compileFragment, childrenOf, CompileError } from './codegen.js';
5
+ import { lineMap, sourceMap } from './sourcemap.js';
6
+ import { compileBindings } from './bind.js';
7
+ import {
8
+ ScriptError,
9
+ assertModule,
10
+ assertNoActionsObject,
11
+ assertNoCollisions,
12
+ bindDefaultExport,
13
+ toFunctionBody,
14
+ } from './script.js';
15
+
16
+ export { CompileError, ScriptError };
17
+
18
+ const PAGE_EXPORTS = new Set(['css', 'load', 'render', 'renderHead', 'renderTitle', 'renderHtmlAttrs', 'layouts', 'client', 'elements', 'headScript']);
19
+ const COMPONENT_EXPORTS = new Set([
20
+ 'tag', 'light', 'css', 'elements', 'propDefs', 'propAttrs', 'stateDefs', 'members', 'render',
21
+ 'coerce', 'def', 'init', 'define', 'default', 'bind', 'update', 'volatile', 'formAssociated',
22
+ ]);
23
+
24
+ /**
25
+ * What an element may declare about itself, in `<script properties>` or in
26
+ * `<script>`. Neither is a prop and neither is setup code: each decides
27
+ * something about the tag, the same for every element of it.
28
+ *
29
+ * `shadow` decides how the tag renders, so it is read before anything is
30
+ * compiled: every other file mentioning the tag compiles differently for it.
31
+ */
32
+ export const ELEMENT_FLAGS = ['shadow', 'formAssociated'];
33
+
34
+ /**
35
+ * One value per flag, from whichever block declared it.
36
+ *
37
+ * Declaring one twice is refused rather than resolved. Two homes for one fact
38
+ * leaves nothing to say which is right when they disagree.
39
+ */
40
+ function resolveFlags(fromProps = {}, fromClient = {}, tag) {
41
+ const out = {};
42
+
43
+ for (const flag of ELEMENT_FLAGS) {
44
+ const a = fromProps[flag] ?? null;
45
+ const b = fromClient[flag] ?? null;
46
+
47
+ if (a !== null && b !== null) {
48
+ throw new ScriptError(
49
+ `<${tag}> declares \`${flag}\` in both <script properties> and <script>. ` +
50
+ `Keep the one that reads better and delete the other.`,
51
+ );
52
+ }
53
+ out[flag] = a ?? b;
54
+ }
55
+
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * An element's flags, read without compiling it.
61
+ *
62
+ * The plugin needs `shadow` before it can compile anything, because how a tag
63
+ * renders decides how every other file that mentions it compiles. Same blocks
64
+ * and the same extractor as the compile itself, so there is one answer.
65
+ *
66
+ * @param {string} source the whole .html file
67
+ * @param {string} [label] what to call it in an error
68
+ * @returns {Record<string, boolean>} one entry per `ELEMENT_FLAGS` name
69
+ */
70
+ export function readFlags(source, label = 'element') {
71
+ const blocks = splitBlocks(source);
72
+
73
+ const fromProps = blocks.properties
74
+ ? bindDefaultExport(blocks.properties, '__probe', `${label} <script properties>`, {
75
+ flags: ELEMENT_FLAGS,
76
+ }).flags
77
+ : {};
78
+ const fromClient = toFunctionBody(blocks.client, `${label} <script>`, {
79
+ lift: 'prototype',
80
+ flags: ELEMENT_FLAGS,
81
+ }).flags;
82
+
83
+ return resolveFlags(fromProps, fromClient, label);
84
+ }
85
+
86
+ /**
87
+ * Top-level <script>/<style> blocks are pulled out; everything else is template.
88
+ *
89
+ * <script server> data loading, page only
90
+ * <script properties> defaults + implied types for the element's properties
91
+ * <script state> internal reactive state, component only
92
+ * <script> client code, and `export const prototype` with it
93
+ * <style> scoped to the shadow root (component) or the page
94
+ *
95
+ * Each block carries the line it starts on so parse errors can point back into
96
+ * the .html file rather than into generated output.
97
+ *
98
+ * @param {string} source
99
+ * @returns {object} the script blocks, the styles, the markup nodes and the
100
+ * `<html>` element read from a second parse
101
+ */
102
+ export function splitBlocks(source) {
103
+ const doc = parseFragment(source, { sourceCodeLocationInfo: true });
104
+
105
+ // A second parse, in document mode, only to read `<html>`. The fragment parser
106
+ // above drops it: a nested html start tag cannot appear in a body, so it goes
107
+ // away with its attributes. In document mode it is the element it names, and a
108
+ // `<html>` inside a script block or a comment is still not one, because this
109
+ // is the real parser rather than a search for a string.
110
+ const html =
111
+ parse(source, { sourceCodeLocationInfo: true }).childNodes.find((n) => n.nodeName === 'html') ??
112
+ null;
113
+ const out = { server: null, properties: null, state: null, client: [], head: [], styles: [], nodes: [], html };
114
+
115
+ for (const node of doc.childNodes) {
116
+ if (node.nodeName === 'script') {
117
+ const attrs = new Set((node.attrs ?? []).map((a) => a.name));
118
+ const block = blockOf(node);
119
+ if (attrs.has('server')) out.server = block;
120
+ else if (attrs.has('properties')) out.properties = block;
121
+ else if (attrs.has('props')) {
122
+ throw new CompileError(
123
+ '`<script props>` is now `<script properties>`. A property is what the ' +
124
+ 'platform calls it, and what the element gets.',
125
+ node,
126
+ );
127
+ }
128
+ // Members used to have a block of their own. They belong with the setup
129
+ // code that calls them, so they moved into it.
130
+ else if (attrs.has('element')) {
131
+ throw new CompileError(
132
+ '`<script element>` is gone. Move its members into `<script>` as ' +
133
+ '`export const prototype = { … }`. The same object, next to the code that uses it.',
134
+ node,
135
+ );
136
+ }
137
+ // `<script state>` is the component's own, not in the document.
138
+ else if (attrs.has('state')) out.state = block;
139
+ // `<script head>` is emitted verbatim into <head>, ahead of everything
140
+ // else. Some things have to run before the body parses: a theme applied
141
+ // before first paint, or a `pagereveal` listener, which fires too early for
142
+ // any script in the body to see.
143
+ else if (attrs.has('head')) out.head.push({ ...block, attrs: node.attrs ?? [] });
144
+ else out.client.push(block);
145
+ continue;
146
+ }
147
+ if (node.nodeName === 'style') {
148
+ out.styles.push(blockOf(node).code);
149
+ continue;
150
+ }
151
+ out.nodes.push(node);
152
+ }
153
+
154
+ // Drop the blank lines left behind where the blocks used to be.
155
+ while (out.nodes.length && isBlank(out.nodes[0])) out.nodes.shift();
156
+ while (out.nodes.length && isBlank(out.nodes.at(-1))) out.nodes.pop();
157
+
158
+ return out;
159
+ }
160
+
161
+ /**
162
+ * Compiles one element. `export const shadow = true` in the file decides which
163
+ * kind it is, so the file answers for itself.
164
+ *
165
+ * Light is the default: styles scoped with `@scope`, markup inline, page CSS
166
+ * reaching it, and form controls and `<label for>` working because there is no
167
+ * boundary. A shadow root is the opt-in, with everything that follows from it.
168
+ */
169
+ export function compileComponent(
170
+ source,
171
+ { tag, shadow = false, components = new Map(), shadowTags = new Set(), runtime, filename = '', nested = [] },
172
+ ) {
173
+ const blocks = splitBlocks(source);
174
+ const where = (kind) => `${filename || tag}.html <script${kind ? ` ${kind}` : ''}>`;
175
+
176
+ // A flag is a fact about the element rather than a prop or a piece of setup,
177
+ // so either block can carry it. An element that only needs one would otherwise
178
+ // need a block holding nothing else.
179
+ const props = blocks.properties
180
+ ? bindDefaultExport(blocks.properties, '__propDefs', where('properties'), { flags: ELEMENT_FLAGS })
181
+ : { code: 'const __propDefs = {};', exports: [], defaultNode: null, flags: {} };
182
+ assertNoCollisions(props.exports, COMPONENT_EXPORTS, where('properties'));
183
+
184
+ // Members ride along in the client block: `export const prototype`, hoisted to
185
+ // module scope with anything it reads, because a prototype is shared and the
186
+ // setup body is per element.
187
+ const client = toFunctionBody(blocks.client, where(''), { lift: 'prototype', flags: ELEMENT_FLAGS });
188
+ assertNoLifecycle(client.lifted, where(''));
189
+
190
+ const flags = resolveFlags(props.flags, client.flags, tag);
191
+ const formAssociated = flags.formAssociated ?? false;
192
+ // The file decides. `shadow` is still an argument so a caller can compile one
193
+ // element on its own, but a file that says which it is always wins.
194
+ const isShadow = flags.shadow ?? shadow;
195
+
196
+ const state = blocks.state
197
+ ? bindDefaultExport(blocks.state, '__stateDefs', where('state'))
198
+ : { code: 'const __stateDefs = {};', exports: [], defaultNode: null };
199
+ assertNoCollisions(state.exports, COMPONENT_EXPORTS, where('state'));
200
+
201
+ assertDistinct(props.defaultNode, state.defaultNode, tag);
202
+
203
+ // Whether this element is registered at all. A light element with no behavior
204
+ // is markup that was already rendered and ships nothing, so it can never see
205
+ // an attribute change and has nothing to update. Anchors would be bytes on
206
+ // every page that pay for a repaint that cannot happen.
207
+ const defined =
208
+ isShadow ||
209
+ Boolean(client.body.trim()) ||
210
+ client.lifted !== null ||
211
+ formAssociated === true ||
212
+ Boolean(blocks.state);
213
+
214
+ const template = compileFragment(blocks.nodes, {
215
+ components,
216
+ shadowTags,
217
+ page: false,
218
+ // A light element's `<slot>` is a compile-time hole, like a layout's. In a
219
+ // shadow root it is a real slot and must reach the browser untouched.
220
+ layout: !isShadow,
221
+ // Anchors are what an update writes through, so every element that can be
222
+ // updated needs them and no other element should carry them.
223
+ blocks: defined,
224
+ // A fragment emits a shadow element bare and lets it paint itself, so its
225
+ // own render is never what a fragment asks for. A light element's is.
226
+ fragments: !isShadow,
227
+ });
228
+
229
+ const styles = blocks.styles.join('\n').trim();
230
+
231
+ const bindings = defined
232
+ ? compileBindings(blocks.nodes, {
233
+ components,
234
+ shadowTags,
235
+ blockOf: template.blockOf,
236
+ refs: new Map(template.components.map(({ tag: name, ref }) => [name, ref])),
237
+ // The runtime prepends <style> to the shadow root, so a component's own
238
+ // first node is not at index 0. A light element's styles are hoisted
239
+ // into <head>, so its root starts where the template does.
240
+ rootOffset: isShadow && styles ? 1 : 0,
241
+ })
242
+ : null;
243
+
244
+ // A light element updates the nodes it already has: text and attributes are
245
+ // written in place, which never touches what the caller slotted in. Structure
246
+ // is different. Rebuilding an `if` or an `each` means replacing children, and a
247
+ // light element does not own its children: the page's CSS reaches them, the
248
+ // page's script can hold them, and the caller's slotted markup sits among them.
249
+ // A shadow root is what makes that subtree the element's to replace.
250
+ const volatileProps = bindings?.volatile ?? [];
251
+ if (!isShadow && volatileProps.length) {
252
+ throw new CompileError(
253
+ `<${tag}> re-renders \`${volatileProps.join('`, `')}\` by rebuilding structure, ` +
254
+ `which a light element cannot do: it does not own its own children. ` +
255
+ `Add \`export const shadow = true\`, or move the \`if\` or \`each\` to the page.`,
256
+ blocks.nodes[0] ?? null,
257
+ );
258
+ }
259
+
260
+ const stray = blocks.nodes.find(
261
+ (node) => node.tagName === 'template' && node.attrs?.some((a) => a.name === 'shadowrootmode'),
262
+ );
263
+ if (stray) {
264
+ throw new CompileError(
265
+ isShadow
266
+ ? `<${tag}> is a component, so it already has a shadow root. Drop the ` +
267
+ `<template shadowrootmode> wrapper and write the markup directly`
268
+ : `<${tag}> is a partial and has no shadow root. Move it to the components ` +
269
+ `directory if it needs one.`,
270
+ stray,
271
+ );
272
+ }
273
+
274
+ const warnings = [
275
+ ...template.warnings,
276
+ ...client.warnings,
277
+ ...unusedProps(props.defaultNode, template.reads, blocks),
278
+ ];
279
+
280
+ const code = `
281
+ ${runtimeImport(runtime)}
282
+ ${componentImports(template.components, { defines: true })}
283
+ ${client.imports}
284
+ ${props.code}
285
+ ${state.code}
286
+ ${client.lifted ? client.hoisted : 'const __members = {};'}
287
+
288
+ export const tag = ${JSON.stringify(tag)};
289
+ export const light = ${!isShadow};
290
+ export const formAssociated = ${formAssociated === true};
291
+ export const css = ${JSON.stringify(isShadow ? styles : scopeCss(styles, tag, nested))};
292
+ export const propDefs = __propDefs;
293
+ export const propAttrs = ${props.exports.includes('attributes') ? 'attributes' : '{}'};
294
+ export const stateDefs = __stateDefs;
295
+ export const members = __members;
296
+ ${elementsExport(template.components)}
297
+
298
+ export function render(__d, __slots = {}, __fragment = false) {
299
+ let __o = '';
300
+ ${indent(template.body)}
301
+ return __o;
302
+ }
303
+
304
+ export function coerce(props) {
305
+ return coerceProps(propDefs, props, propAttrs);
306
+ }
307
+
308
+ ${template.blockDefs}
309
+ ${bindingsCode(bindings)}
310
+ export const def = {
311
+ tag, light, css, elements, propDefs, propAttrs, stateDefs, members, render, coerce, bind,
312
+ update, volatile, formAssociated,
313
+ };
314
+ export default def;
315
+
316
+ export async function init(host, shadow, signal, internals) {
317
+ ${client.body}
318
+ }
319
+
320
+ // Defining an element defines what it renders. A page's entry lists the whole
321
+ // set up front for first paint. An element that arrives on its own, in a fragment
322
+ // found by the element watcher, has only itself to start from, and a shadow root
323
+ // it paints is out of reach of anything watching the document.
324
+ //
325
+ // The flag is for the cycle: an element may render itself.
326
+ let __defined = false;
327
+
328
+ export function define() {
329
+ if (__defined) return;
330
+ __defined = true;
331
+ ${isShadow ? 'defineComponent' : 'defineLight'}(def, ${client.body.trim() ? 'init' : 'null'});
332
+ ${template.components.map(({ ref }) => ` ${ref}_define();`).join('\n')}
333
+ }
334
+ `;
335
+
336
+ return {
337
+ code,
338
+ warnings,
339
+ isShadow,
340
+ hasScript: Boolean(client.body.trim()),
341
+ components: template.components.map((c) => c.tag),
342
+ };
343
+ }
344
+
345
+ /**
346
+ * Where a mapped block starts, once the module is assembled.
347
+ *
348
+ * The assemblers are one template literal each. Rather than restructure them to
349
+ * count lines as they go, each block is written under a marker that `lineMap`
350
+ * finds, measures and removes. Nothing reaches the output.
351
+ */
352
+ const MARK = {
353
+ body: '/*@transclude:body*/',
354
+ head: '/*@transclude:head*/',
355
+ title: '/*@transclude:title*/',
356
+ };
357
+
358
+ export function compilePage(
359
+ source,
360
+ {
361
+ components = new Map(),
362
+ shadowTags = new Set(),
363
+ runtime,
364
+ filename = 'page',
365
+ // What the source map names. `filename` is what an error message says, which
366
+ // is the short route id; a stack wants the path an editor can open.
367
+ sourcePath = null,
368
+ layouts = [],
369
+ client = { tags: [], hasScript: false, needed: false },
370
+ },
371
+ ) {
372
+ const blocks = splitBlocks(source);
373
+ const where = `${filename}.html <script server>`;
374
+ const headWhere = `${filename}.html <script head>`;
375
+
376
+ const server = blocks.server
377
+ ? bindDefaultExport(blocks.server, '__load', where)
378
+ : { code: 'const __load = null;', exports: [], imports: [], defaultNode: null };
379
+ assertNoCollisions(server.exports, PAGE_EXPORTS, where);
380
+ assertNoActionsObject(server.exports, where);
381
+
382
+ const template = compileFragment(blocks.nodes, { components, shadowTags, page: true, html: blocks.html });
383
+ assertIncludesResolve(template.regionIncludes, template.regions);
384
+
385
+ const code = `
386
+ ${runtimeImport(runtime)}
387
+ ${componentImports(template.components)}
388
+ ${layoutImports(layouts)}
389
+ ${server.code}
390
+
391
+ export const css = ${JSON.stringify(blocks.styles.join('\n').trim())};
392
+ export const headScript = ${JSON.stringify(headScript(blocks, headWhere))};
393
+ ${elementsExport(template.components)}
394
+ export const hasTitle = ${template.hasTitle};
395
+ export const layouts = [${layouts.map((_, i) => `__L${i}`).join(', ')}];
396
+ export const client = ${JSON.stringify(client)};
397
+ ${regionsExport(template.regions)}
398
+ export const includes = ${JSON.stringify(template.includes ?? [])};
399
+
400
+ export async function load(ctx) {
401
+ if (typeof __load === 'function') return (await __load(ctx)) ?? {};
402
+ return __load ?? {};
403
+ }
404
+
405
+ export function renderTitle(__d) {
406
+ let __o = '';
407
+ ${MARK.title}
408
+ ${indent(template.title)}
409
+ return __o;
410
+ }
411
+
412
+ export function renderHtmlAttrs(__d) {
413
+ return ${template.htmlAttrs ?? '{}'};
414
+ }
415
+
416
+ export function renderHead(__d) {
417
+ let __o = '';
418
+ ${MARK.head}
419
+ ${indent(template.head)}
420
+ return __o;
421
+ }
422
+
423
+ export function render(__d, __slots = {}, __fragment = false) {
424
+ const __out = {};
425
+ ${slotBodies(template)}
426
+ return __out;
427
+ }
428
+ `;
429
+
430
+ const mapped = withMap(code, template, source, sourcePath ?? `${filename}.html`);
431
+
432
+ return {
433
+ code: mapped.code,
434
+ map: mapped.map,
435
+ warnings: template.warnings,
436
+ components: template.components.map((c) => c.tag),
437
+ };
438
+ }
439
+
440
+ /**
441
+ * The module, its markers removed, with a map from its lines to the file's.
442
+ *
443
+ * Server-side only: a page module is never sent to a browser, so embedding the
444
+ * source costs a visitor nothing and is what lets a stack read on a host with no
445
+ * access to the file.
446
+ *
447
+ * @param {string} code the assembled module, markers and all
448
+ * @param {object} template what `compileFragment` returned
449
+ * @param {string} source the original `.html`
450
+ * @param {string} filename how it should be named in a stack
451
+ * @returns {{ code: string, map: string|null }}
452
+ */
453
+ function withMap(code, template, source, filename) {
454
+ const blocks = [
455
+ { marker: MARK.body, at: template.at?.body ?? [] },
456
+ { marker: MARK.head, at: template.at?.head ?? [] },
457
+ { marker: MARK.title, at: template.at?.title ?? [] },
458
+ ];
459
+
460
+ const { code: clean, lines } = lineMap(code, blocks);
461
+ // Nothing mapped means nothing to say. An empty map is a file a tool will
462
+ // fetch and read to learn that it knows nothing.
463
+ if (!lines.some((line) => line !== null)) return { code: clean, map: null };
464
+
465
+ // Handed back rather than written into the code as a comment. Vite reads a
466
+ // map a `load` hook returns and composes it; an inline comment on the code it
467
+ // returns is not looked at, which is why the stack still named the virtual
468
+ // module and a generated line.
469
+ return { code: clean, map: sourceMap(lines, filename, source) };
470
+ }
471
+
472
+ /**
473
+ * A layout is a page that renders a hole. `render` receives the slot map its
474
+ * child produced, and returns its own for the level above.
475
+ *
476
+ * @param {string} source
477
+ * @param {{ id: string, components?: Map<string, string>,
478
+ * shadowTags?: Set<string>, runtime: string }} options
479
+ * @returns {{ code: string, warnings: string[], components: string[] }}
480
+ */
481
+ export function compileLayout(source, { id, components = new Map(), shadowTags = new Set(), runtime }) {
482
+ const blocks = splitBlocks(source);
483
+ const where = `${id}/_layout.html <script server>`;
484
+ const headWhere = `${id}/_layout.html <script head>`;
485
+
486
+ const server = blocks.server
487
+ ? bindDefaultExport(blocks.server, '__load', where)
488
+ : { code: 'const __load = null;', exports: [], imports: [], defaultNode: null };
489
+ assertNoCollisions(server.exports, PAGE_EXPORTS, where);
490
+ assertNoActionsObject(server.exports, where);
491
+
492
+ const template = compileFragment(blocks.nodes, {
493
+ components,
494
+ shadowTags,
495
+ page: true,
496
+ layout: true,
497
+ html: blocks.html,
498
+ });
499
+
500
+ const warnings = [...template.warnings];
501
+ if (!/__slots\[/.test(template.body)) {
502
+ warnings.push('no <slot>, so nothing rendered inside this layout would appear');
503
+ }
504
+
505
+ const code = `
506
+ ${runtimeImport(runtime)}
507
+ ${componentImports(template.components)}
508
+ ${server.code}
509
+
510
+ export const css = ${JSON.stringify(blocks.styles.join('\n').trim())};
511
+ export const headScript = ${JSON.stringify(headScript(blocks, headWhere))};
512
+ ${elementsExport(template.components)}
513
+ export const hasTitle = ${template.hasTitle};
514
+
515
+ export async function load(ctx) {
516
+ if (typeof __load === 'function') return (await __load(ctx)) ?? {};
517
+ return __load ?? {};
518
+ }
519
+
520
+ export function renderTitle(__d) {
521
+ let __o = '';
522
+ ${indent(template.title)}
523
+ return __o;
524
+ }
525
+
526
+ export function renderHtmlAttrs(__d) {
527
+ return ${template.htmlAttrs ?? '{}'};
528
+ }
529
+
530
+ export function renderHead(__d) {
531
+ let __o = '';
532
+ ${indent(template.head)}
533
+ return __o;
534
+ }
535
+
536
+ export function render(__d, __slots = {}, __fragment = false) {
537
+ const __out = {};
538
+ ${slotBodies(template)}
539
+ return __out;
540
+ }
541
+
542
+ export default { css, headScript, elements, hasTitle, load, renderTitle, renderHead, renderHtmlAttrs, render };
543
+ `;
544
+
545
+ return { code, warnings, components: template.components.map((c) => c.tag) };
546
+ }
547
+
548
+ /**
549
+ * Wraps a light element's styles in `@scope`, rooted at its own tag. A custom
550
+ * element name is already a valid selector, so nothing has to be hashed.
551
+ *
552
+ * The `to` clause is the donut: styles stop at any light element nested inside,
553
+ * so an outer one cannot reach into one it merely contains.
554
+ *
555
+ * @param {string} css
556
+ * @param {string} tag the element the rules belong to
557
+ * @param {string[]} [nested] tags rendered inside it, which the scope has to reach
558
+ * @returns {string}
559
+ */
560
+ export function scopeCss(css, tag, nested = []) {
561
+ if (!css) return '';
562
+ const limit = nested.length ? ` to (${nested.map((inner) => `${tag} ${inner}`).join(', ')})` : '';
563
+ const indented = css.split('\n').map((line) => (line ? ` ${line}` : line)).join('\n');
564
+ return `@scope (${tag})${limit} {\n${indented}\n}`;
565
+ }
566
+
567
+ /**
568
+ * Component tags a template uses. This is how only those get shipped.
569
+ *
570
+ * @param {string} source
571
+ * @param {Map<string, string>|Set<string>} registry every known tag
572
+ * @returns {Set<string>} the tags this source renders
573
+ */
574
+ export function usedComponents(source, registry) {
575
+ const found = new Set();
576
+
577
+ const walk = (nodes) => {
578
+ for (const node of nodes) {
579
+ if (!node.tagName) continue;
580
+ if (registry.has(node.tagName)) found.add(node.tagName);
581
+ walk(childrenOf(node));
582
+ }
583
+ };
584
+
585
+ walk(splitBlocks(source).nodes);
586
+ return found;
587
+ }
588
+
589
+ /**
590
+ * Browser entry: define every component, then run the page's own client code.
591
+ * This one is a real module, so the client block keeps its imports and may use
592
+ * top-level await. It is only checked, not rewritten.
593
+ *
594
+ * `elements` adds the loader for everything else: the page's own tags are
595
+ * imported statically and defined before first paint, and any other tag in the
596
+ * app is one dynamic import away, taken only if it ever shows up in the DOM.
597
+ *
598
+ * @param {Array<{ source: string, filename: string }>} sources the files whose
599
+ * `<script>` blocks run in the browser, layouts first and the page last
600
+ * @param {{ tags?: string[] }} [what] the elements to define
601
+ * @param {{ runtime: string, elements?: boolean }} options required, not
602
+ * defaulted: `runtime` is written into the module's import, and without it the
603
+ * output says `from undefined` and fails only when something tries to load it
604
+ * @returns {{ code: string }} the code is empty when the page needs no entry
605
+ */
606
+ export function compileClientEntry(sources, { tags = [] } = {}, { runtime, elements = false }) {
607
+ // Layouts first, page last: the same order they wrap in.
608
+ const blocks = sources.map(({ source, filename }) =>
609
+ assertModule(splitBlocks(source).client, `${filename} <script>`),
610
+ );
611
+
612
+ // Markup can arrive after the page did, from something this framework does not
613
+ // provide, and whatever it names has to be able to define itself.
614
+ const imports = elements
615
+ ? `import { watch as __watch } from ${JSON.stringify(runtime)};\n` +
616
+ `import { elements as __elements } from ${JSON.stringify(ELEMENTS_ENTRY)};`
617
+ : '';
618
+
619
+ const start = elements ? '__watch(__elements);' : '';
620
+
621
+ return {
622
+ code: `
623
+ ${imports}
624
+ ${tags.map((tag, i) => `import { define as __D${i} } from ${JSON.stringify(`virtual:transclude-component/${tag}`)};`).join('\n')}
625
+
626
+ ${tags.map((_, i) => `__D${i}();`).join('\n')}
627
+ ${start}
628
+
629
+ ${blocks.join('\n')}
630
+ `,
631
+ };
632
+ }
633
+
634
+ /** The id of the module `compileClientEntry` reaches for when `elements` is on. */
635
+ export const ELEMENTS_ENTRY = 'virtual:transclude-elements';
636
+
637
+ /**
638
+ * tag -> dynamic import, for every element in the app.
639
+ *
640
+ * A thunk rather than a URL: the bundler is the only thing that knows where the
641
+ * chunk lands, and `import()` is how you ask it. Nothing has to be written into
642
+ * a manifest, threaded through the server, or kept in sync with a hash.
643
+ *
644
+ * @param {Iterable<string>} tags every element the app defines
645
+ * @returns {{ code: string }}
646
+ */
647
+ export function compileElementsEntry(tags) {
648
+ const entries = [...tags]
649
+ .sort()
650
+ .map(
651
+ (tag) =>
652
+ ` ${JSON.stringify(tag)}: () => import(${JSON.stringify(`virtual:transclude-component/${tag}`)}),`,
653
+ );
654
+ return { code: `export const elements = {\n${entries.join('\n')}\n};\n` };
655
+ }
656
+
657
+ /**
658
+ * `bind` finds the node behind every expression the compiler could place, once;
659
+ * `update` writes to them. `volatile` is the list of prop names whose change needs
660
+ * a full repaint, because nothing here can reach them.
661
+ *
662
+ * An element with nothing to bind gets the same shape, empty, so the runtime
663
+ * never has to ask whether it exists.
664
+ */
665
+ function bindingsCode(bindings) {
666
+ if (!bindings) {
667
+ return [
668
+ 'export function bind() { return null; }',
669
+ 'export function update() { return false; }',
670
+ 'export const volatile = [];',
671
+ ].join('\n');
672
+ }
673
+ return [
674
+ 'export function bind(__root, __d) {',
675
+ ' const __b = [];',
676
+ bindings.cursors
677
+ ? ` let ${Array.from({ length: bindings.cursors }, (_, i) => `__c${i}`).join(', ')};`
678
+ : '',
679
+ indent(bindings.locate),
680
+ ' return __b;',
681
+ '}',
682
+ '',
683
+ 'export function update(__b, __d) {',
684
+ ' let __ok = true;',
685
+ indent(bindings.writes),
686
+ ' return __ok;',
687
+ '}',
688
+ '',
689
+ bindings.parts,
690
+ `export const volatile = ${JSON.stringify(bindings.volatile)};`,
691
+ ].join('\n');
692
+ }
693
+
694
+ // Overwriting these on the prototype replaces the framework's own, and the
695
+ // element silently stops rendering. `adoptedCallback` is not among them: nothing
696
+ // implements it, so there is nothing to break.
697
+ const RESERVED_LIFECYCLE = {
698
+ connectedCallback: 'setup belongs in the <script> body, which runs on connect',
699
+ disconnectedCallback: 'return a cleanup function from the <script> body instead',
700
+ attributeChangedCallback: 'an attribute change already re-renders; use updated() for the side effect',
701
+ };
702
+
703
+ /** The keys of an `export default { … }`, where it is a plain object literal. */
704
+ function objectKeys(defaultNode) {
705
+ if (defaultNode?.type !== 'ObjectExpression') return [];
706
+ return defaultNode.properties
707
+ .filter((prop) => prop.type === 'Property' && !prop.computed)
708
+ .map((prop) => (prop.key.type === 'Identifier' ? prop.key.name : String(prop.key.value)));
709
+ }
710
+
711
+ /**
712
+ * Props and state share one set of names in the template. `${open}` cannot say
713
+ * which one it meant, so a name can only belong to one of them.
714
+ */
715
+ function assertDistinct(propsNode, stateNode, tag) {
716
+ const declared = new Set(objectKeys(propsNode));
717
+ for (const key of objectKeys(stateNode)) {
718
+ if (declared.has(key)) {
719
+ throw new CompileError(
720
+ `<${tag}>: \`${key}\` is declared in both <script properties> and <script state>. ` +
721
+ `A template reads them from one namespace, so the name has to be one or the other.`,
722
+ stateNode,
723
+ );
724
+ }
725
+ }
726
+ }
727
+
728
+ function assertNoLifecycle(defaultNode, label) {
729
+ if (defaultNode?.type !== 'ObjectExpression') return;
730
+
731
+ for (const prop of defaultNode.properties) {
732
+ if (prop.type !== 'Property' || prop.computed) continue;
733
+ const name = prop.key.type === 'Identifier' ? prop.key.name : String(prop.key.value);
734
+ const advice = RESERVED_LIFECYCLE[name];
735
+ if (advice) {
736
+ throw new CompileError(`${label}: \`${name}\` belongs to the framework. ${advice}`, prop);
737
+ }
738
+ }
739
+ }
740
+
741
+ function escapeRegExp(text) {
742
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
743
+ }
744
+
745
+ /**
746
+ * A prop nobody reads is usually a rename that only got half done. A prop can
747
+ * fairly never appear in the template. `compact` drives `:host([compact])` in CSS
748
+ * and is toggled from the client block. So a plain word match against <style> and
749
+ * <script> is what keeps this quiet enough to leave on.
750
+ */
751
+ function unusedProps(defaultNode, reads, blocks) {
752
+ if (defaultNode?.type !== 'ObjectExpression') return [];
753
+
754
+ const declared = [];
755
+ for (const prop of defaultNode.properties) {
756
+ if (prop.type !== 'Property' || prop.computed) return [];
757
+ if (prop.key.type === 'Identifier') declared.push(prop.key.name);
758
+ else if (prop.key.type === 'Literal') declared.push(String(prop.key.value));
759
+ }
760
+
761
+ const elsewhere = [...blocks.styles, ...blocks.client.map((b) => b.code)].join('\n');
762
+
763
+ return declared
764
+ .filter((name) => !reads.has(name))
765
+ .filter((name) => !new RegExp(`\\b${escapeRegExp(name)}\\b`).test(elsewhere))
766
+ .map(
767
+ (name) =>
768
+ `prop \`${name}\` is declared but never used. It is not read in the template, ` +
769
+ `and does not appear in <style> or <script>`,
770
+ );
771
+ }
772
+
773
+ // ---- module assembly helpers ---------------------------------------------
774
+
775
+ function runtimeImport(runtime) {
776
+ return `import { escape as __e, attr as __a, attrProp as __ap, str as __str, json, shadow as __sh, data as __data, included as __incl, textAt as __textAt, setText as __setText, setParts as __setParts, setAttr as __setAttr, setAttrProp as __setAttrProp, blockAt as __blockAt, updateBlock as __updateBlock, coerceProps, defineComponent, defineLight, html } from ${JSON.stringify(runtime)};`;
777
+ }
778
+
779
+ function layoutImports(layouts) {
780
+ return layouts
781
+ .map((layout, i) => `import __L${i} from ${JSON.stringify(`virtual:transclude-layout/${layout.id}`)};`)
782
+ .join('\n');
783
+ }
784
+
785
+ /**
786
+ * `defines` pulls each nested element's `define` in alongside its def, so a
787
+ * component can register the elements it renders. Only a component needs that. A
788
+ * page or layout never renders itself into a document that has not already loaded
789
+ * its entry.
790
+ */
791
+ function componentImports(used, { defines = false } = {}) {
792
+ return used
793
+ .map(({ tag, ref }) => {
794
+ const from = JSON.stringify(`virtual:transclude-component/${tag}`);
795
+ const named = defines ? `, { define as ${ref}_define }` : '';
796
+ return `import ${ref}${named} from ${from};`;
797
+ })
798
+ .join('\n');
799
+ }
800
+
801
+ /**
802
+ * A light element's styles are hoisted into <head> once, so every level exports
803
+ * the elements it pulled in. Nested ones come along through their own export,
804
+ * and the document dedupes by tag.
805
+ */
806
+ /**
807
+ * `<script head>` blocks, verbatim, in the order they were written. Attributes
808
+ * included.
809
+ *
810
+ * They used to be dropped, which turned `<script head src="/theme.js">` into
811
+ * `<script></script>`: no error, no script, nothing to see. `src` is the obvious
812
+ * one, but `type="module"`, `nonce`, `defer` and `integrity` all mean something
813
+ * here too.
814
+ */
815
+ function headScript(blocks, where) {
816
+ return blocks.head
817
+ .map((block) => {
818
+ const attrs = (block.attrs ?? []).filter((attr) => attr.name !== 'head');
819
+ const external = attrs.find((attr) => attr.name === 'src');
820
+
821
+ // The browser ignores the body of a script with a src. Emitting both would
822
+ // silently throw away whichever the author meant.
823
+ if (external && block.code.trim()) {
824
+ throw new CompileError(
825
+ `${where}: a <script head src="${external.value}"> cannot also have a body. ` +
826
+ `the browser runs the file and ignores the code`,
827
+ );
828
+ }
829
+ // This is emitted as a static string, so there is nothing to interpolate
830
+ // into. Left alone it would ship a literal `${…}` as the URL.
831
+ for (const attr of attrs) {
832
+ if (!attr.value.includes('${')) continue;
833
+ throw new CompileError(
834
+ `${where}: \`${attr.name}\` on a <script head> cannot interpolate. ` +
835
+ `the block is emitted into <head> before any data exists`,
836
+ );
837
+ }
838
+
839
+ return `<script${attrs.map(serializeAttr).join('')}>${block.code}</script>`;
840
+ })
841
+ .join('\n');
842
+ }
843
+
844
+ /** A static attribute, escaped the way an HTML serializer must. */
845
+ function serializeAttr({ name, value }) {
846
+ if (value === '') return ` ${name}`;
847
+ const escaped = value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
848
+ return ` ${name}="${escaped}"`;
849
+ }
850
+
851
+ function elementsExport(used) {
852
+ return `export const elements = [${used.map(({ ref }) => ref).join(', ')}];`;
853
+ }
854
+
855
+ /**
856
+ * Each `[fragment]` region as a function of the page's own data. The same markup
857
+ * the document got, so a swap cannot drift from the page it replaces part of.
858
+ */
859
+ /**
860
+ * Every `<transclude src="#id">` names a region this page has, and no
861
+ * region includes itself.
862
+ *
863
+ * Both are compile-time answers. A missing region would be a call to undefined
864
+ * on a page that looked fine, and a cycle would be a stack overflow while
865
+ * answering a request.
866
+ */
867
+ function assertIncludesResolve(includes, regions) {
868
+ const names = new Set(Object.keys(regions ?? {}));
869
+
870
+ for (const { id, node } of includes ?? []) {
871
+ if (names.has(id)) continue;
872
+ throw new CompileError(
873
+ `<transclude src="#${id}"> names no region of this page. ` +
874
+ `A region is an element with an id and a "fragment" attribute.`,
875
+ node,
876
+ );
877
+ }
878
+
879
+ // An edge from the region an include sits in to the region it pulls. An
880
+ // include outside every region cannot be part of a cycle: nothing includes
881
+ // the page body.
882
+ const edges = new Map();
883
+ for (const { id, within } of includes ?? []) {
884
+ if (!within) continue;
885
+ if (!edges.has(within)) edges.set(within, new Set());
886
+ edges.get(within).add(id);
887
+ }
888
+
889
+ const seen = new Set();
890
+ const walk = (name, chain) => {
891
+ if (chain.includes(name)) {
892
+ throw new CompileError(
893
+ `<transclude> includes itself: ${[...chain, name].map((n) => `#${n}`).join(' includes ')}. ` +
894
+ `Rendering it would not finish.`,
895
+ (includes ?? []).find(({ id }) => id === name)?.node ?? null,
896
+ );
897
+ }
898
+ if (seen.has(name)) return;
899
+ seen.add(name);
900
+ for (const next of edges.get(name) ?? []) walk(next, [...chain, name]);
901
+ };
902
+
903
+ for (const name of edges.keys()) walk(name, []);
904
+ }
905
+
906
+ function regionsExport(regions) {
907
+ const entries = Object.entries(regions ?? {});
908
+ if (!entries.length) return 'export const regions = {};';
909
+
910
+ const bodies = entries.map(
911
+ ([name, body]) =>
912
+ ` ${JSON.stringify(name)}: (__d, __slots = {}, __fragment = true, __named = true) => {\n` +
913
+ ` let __o = '';\n${indent(indent(body))}\n return __o;\n },`,
914
+ );
915
+ return `export const regions = {\n${bodies.join('\n')}\n};`;
916
+ }
917
+
918
+ function blockOf(node) {
919
+ const text = node.childNodes[0];
920
+ return {
921
+ code: text?.value ?? '',
922
+ line: text?.sourceCodeLocation?.startLine ?? node.sourceCodeLocation?.startTag?.endLine ?? 1,
923
+ offset: text?.sourceCodeLocation?.startOffset ?? node.sourceCodeLocation?.startTag?.endOffset ?? 0,
924
+ };
925
+ }
926
+
927
+ /**
928
+ * Every level renders to a map of slots, not a string: `default` is its own
929
+ * content, and any `<template slot="x">` it declared is content for the level
930
+ * above. One shape for pages and layouts alike keeps the fold uniform.
931
+ */
932
+ function slotBodies(template) {
933
+ // A slot this level does not render belongs to one further out, so it is
934
+ // handed on rather than dropped. Without that, a page could only fill a slot in
935
+ // its nearest layout.
936
+ const consumed = JSON.stringify([...(template.consumed ?? []), 'default']);
937
+ const parts = [
938
+ // A region's markup is emitted once and used twice, in the page and in the
939
+ // region's own function, so the id it carries is written conditionally. The
940
+ // copy that renders inline is the one that keeps the name.
941
+ ` const __named = true;`,
942
+ ` const __pass = new Set(${consumed});`,
943
+ ` for (const __name in __slots) if (!__pass.has(__name)) __out[__name] = __slots[__name];`,
944
+ ` {\n let __o = '';\n${MARK.body}\n${indent(indent(template.body))}\n __out.default = __o;\n }`,
945
+ ];
946
+
947
+ for (const [name, body] of Object.entries(template.slots ?? {})) {
948
+ parts.push(
949
+ ` {\n let __o = '';\n${indent(indent(body))}\n __out[${JSON.stringify(name)}] = __o;\n }`,
950
+ );
951
+ }
952
+ return parts.join('\n');
953
+ }
954
+
955
+ function indent(code) {
956
+ return code
957
+ .split('\n')
958
+ .map((line) => (line ? ` ${line}` : line))
959
+ .join('\n');
960
+ }
961
+
962
+ function isBlank(node) {
963
+ return node.nodeName === '#text' && /^\s*$/.test(node.value ?? '');
964
+ }