@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,1061 @@
1
+ // Walks a parse5 tree and emits the body of a render function.
2
+ //
3
+ // Rules this file encodes:
4
+ // - every ${} is escaped; html() opts out
5
+ // - `if` / `else-if` / `else` bind across whitespace + comments only
6
+ // - `if` and `each` on the same element is a hard error
7
+ // - a <template> carrying a directive is consumed; one without any is emitted verbatim
8
+ // - false/null/undefined attribute values drop the attribute entirely
9
+
10
+ import { Scope, collectRefs, emit, parseExpr } from './expr.js';
11
+ import { splitInterpolations } from './interp.js';
12
+
13
+ const VOID = new Set([
14
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
15
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
16
+ ]);
17
+
18
+ // Content is not entity-decoded by the parser and must not be escaped by us.
19
+ const RAW_TEXT = new Set(['script', 'style']);
20
+
21
+ // Hoisted out of a page body into <head>.
22
+ const HEAD_TAGS = new Set(['title', 'meta', 'link', 'base']);
23
+
24
+ const DIRECTIVES = new Set(['if', 'else-if', 'else', 'each', 'key', 'fragment']);
25
+
26
+ /**
27
+ * The inclusion element. Reserved: an app cannot define one in `elements/`,
28
+ * because this is read before the component table is consulted.
29
+ */
30
+ export const INCLUDE_TAG = 'transclude';
31
+ const BRANCH = ['if', 'else-if', 'else'];
32
+
33
+ export class CompileError extends Error {
34
+ constructor(message, node) {
35
+ const line = node?.sourceCodeLocation?.startLine;
36
+ super(line ? `${message} (line ${line})` : message);
37
+ this.name = 'CompileError';
38
+ this.line = line;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * @param {object[]} nodes
44
+ * @param {object} [opts]
45
+ * @returns {object} the render body, the regions, the slots, the includes and the warnings
46
+ */
47
+ export function compileFragment(nodes, opts = {}) {
48
+ const gen = new Codegen(opts);
49
+ gen.emitChildren(nodes, gen.body, gen.rootScope, true);
50
+
51
+ // `<html>` is read separately, because the fragment parser drops it: a nested
52
+ // html start tag is not something that can appear in a body, so parse5 throws
53
+ // it away attributes and all. `splitBlocks` reads it in document mode, where
54
+ // it is the element it names.
55
+ const htmlNode = opts.html ?? null;
56
+ const htmlAttrs = htmlNode?.attrs?.length ? gen.htmlAttrsJs(htmlNode, gen.rootScope) : null;
57
+
58
+ const body = joinOut(gen.body);
59
+ const head = joinOut(gen.head);
60
+ const title = joinOut(gen.title);
61
+ const slots = [...gen.slots].map(([name, out]) => [name, joinOut(out)]);
62
+ const regions = [...gen.regions].map(([name, out]) => [name, joinOut(out)]);
63
+
64
+ // Every block's code, and beside it which source line each of its lines came
65
+ // from. The assemblers put the code into a module and record where each block
66
+ // landed; `lineMap` turns the two into a source map.
67
+ const at = {
68
+ body: body.at,
69
+ head: head.at,
70
+ title: title.at,
71
+ slots: Object.fromEntries(slots.map(([name, out]) => [name, out.at])),
72
+ regions: Object.fromEntries(regions.map(([name, out]) => [name, out.at])),
73
+ };
74
+
75
+ return {
76
+ body: body.code,
77
+ at,
78
+ blockDefs: gen.blockDefs.join('\n'),
79
+ blockOf: gen.blockOf,
80
+ slots: Object.fromEntries(slots.map(([name, out]) => [name, out.code])),
81
+ regions: Object.fromEntries(regions.map(([name, out]) => [name, out.code])),
82
+ regionIncludes: gen.regionIncludes,
83
+ includes: gen.includes.map(({ key, kind, where, id }) => ({ key, kind, where, id })),
84
+ consumed: [...gen.consumed],
85
+ head: head.code,
86
+ title: title.code,
87
+ hasTitle: gen.title.length > 0,
88
+ htmlAttrs,
89
+ warnings: gen.warnings,
90
+ reads: gen.reads,
91
+ components: [...gen.used.entries()].map(([tag, ref]) => ({ tag, ref })),
92
+ };
93
+ }
94
+
95
+ class Codegen {
96
+ constructor({
97
+ components = new Map(),
98
+ shadowTags = new Set(),
99
+ page = false,
100
+ layout = false,
101
+ blocks = false,
102
+ fragments = true,
103
+ } = {}) {
104
+ // Whether this template can be asked for a fragment. A page or a light
105
+ // element can; a shadow one cannot, because a fragment emits the element bare
106
+ // and never calls its render at all. That matters beyond tidiness: with
107
+ // `blocks` on, an `if` or `each` compiles to its own module-scope function,
108
+ // and a `__fragment` passed from render would not be in scope inside it.
109
+ this.fragments = fragments;
110
+ // With `blocks` on, `if` and `each` at the top level compile to their own
111
+ // function and are wrapped in comment anchors, so an update can re-render
112
+ // one region instead of the whole shadow root. Only an element is ever
113
+ // updated, so nothing else pays for the anchors.
114
+ this.blocks = blocks && !layout;
115
+ this.blockDefs = [];
116
+ this.blockOf = new Map();
117
+ this.inBlock = 0;
118
+ // The loop variables in scope, outermost first, two per level. A block
119
+ // inside a loop renders from them, so its function has to take them.
120
+ this.loops = [];
121
+ this.components = components;
122
+ // Which of them render into a shadow root. Everything else renders inline.
123
+ // That depends on the child's own declaration, not its parent's.
124
+ this.shadowTags = shadowTags;
125
+ this.page = page;
126
+ this.layout = layout;
127
+ this.seen = new Set();
128
+ this.reads = new Set();
129
+ this.rootScope = new Scope();
130
+ this.body = [];
131
+ this.head = [];
132
+ this.title = [];
133
+ // `<template slot="x">` at the top level of a page or layout fills the named
134
+ // slot of the level above it, so it compiles to its own buffer.
135
+ this.slots = new Map();
136
+ // Slot names this level actually renders. Anything else it was handed
137
+ // belongs to a level further out and has to travel on.
138
+ this.consumed = new Set();
139
+ // `<ul id="results" fragment>` is a region of the page that can be asked for
140
+ // on its own. It renders inline like anything else *and* compiles to its own
141
+ // function, so the same markup serves the document and the swap.
142
+ this.regions = new Map();
143
+ // Every `<transclude src="#id">`, with the region it sits inside, so a cycle
144
+ // is found at compile time rather than as a stack overflow during a render.
145
+ // Kept apart from the ones below because these need no server to resolve.
146
+ this.regionIncludes = [];
147
+ // The ones a server has to resolve before the render: another route of this
148
+ // app, or a document elsewhere.
149
+ this.includes = [];
150
+ this.inRegion = [];
151
+ this.regionRoot = null;
152
+ this.warnings = [];
153
+ this.used = new Map();
154
+ this.uid = 0;
155
+ }
156
+
157
+ // ---- helpers ------------------------------------------------------------
158
+
159
+ s(out, text) {
160
+ if (text) out.push({ t: 's', v: text, at: this.at });
161
+ }
162
+
163
+ c(out, code) {
164
+ out.push({ t: 'c', v: code, at: this.at });
165
+ }
166
+
167
+ /**
168
+ * The source line everything emitted from here belongs to.
169
+ *
170
+ * Carried on the chunk rather than worked out later, because by the time the
171
+ * lines are joined the node that produced them is gone. `null` when parse5 gave
172
+ * no location, which is what a synthesised node has: the map simply says
173
+ * nothing about those lines rather than guessing.
174
+ */
175
+ at = null;
176
+
177
+ /** Runs `fn` with the position set to `node`'s, and puts it back after. */
178
+ from(node, fn) {
179
+ const was = this.at;
180
+ this.at = node?.sourceCodeLocation?.startLine ?? was;
181
+ try {
182
+ return fn();
183
+ } finally {
184
+ this.at = was;
185
+ }
186
+ }
187
+
188
+ warn(message, node) {
189
+ const line = node?.sourceCodeLocation?.startLine;
190
+ this.warnings.push(line ? `${message} (line ${line})` : message);
191
+ }
192
+
193
+ expr(source, scope, node) {
194
+ const ast = this.parse(source, node);
195
+ this.note(collectRefs(ast, scope));
196
+ try {
197
+ return emit(ast, scope);
198
+ } catch (err) {
199
+ throw new CompileError(`bad expression ${JSON.stringify(String(source).trim())}: ${err.message}`, node);
200
+ }
201
+ }
202
+
203
+ parse(source, node) {
204
+ try {
205
+ return parseExpr(source);
206
+ } catch (err) {
207
+ throw new CompileError(`bad expression ${JSON.stringify(String(source).trim())}: ${err.message}`, node);
208
+ }
209
+ }
210
+
211
+ /** Records which data keys the template reads, for the unused-prop check. */
212
+ note(refs) {
213
+ for (const ref of refs) {
214
+ if (ref.base === 'data') this.reads.add(ref.name);
215
+ }
216
+ }
217
+
218
+ report(message, node) {
219
+ const line = node?.sourceCodeLocation?.startLine;
220
+ const text = line ? `${message} (line ${line})` : message;
221
+ if (this.seen.has(text)) return;
222
+ this.seen.add(text);
223
+ this.warnings.push(text);
224
+ }
225
+
226
+ componentRef(tag) {
227
+ if (!this.used.has(tag)) this.used.set(tag, `__C${this.used.size}`);
228
+ return this.used.get(tag);
229
+ }
230
+
231
+ // ---- traversal ----------------------------------------------------------
232
+
233
+ emitChildren(nodes, out, scope, topLevel = false) {
234
+ let i = 0;
235
+ while (i < nodes.length) {
236
+ const node = nodes[i];
237
+
238
+ // A top-level `<template slot="x">` is not content of this level; it is
239
+ // content *for the level above*, so it is compiled somewhere else.
240
+ if (topLevel && node.tagName === 'template') {
241
+ const slot = node.attrs?.find((a) => a.name === 'slot')?.value;
242
+ if (slot) {
243
+ const target = this.slots.get(slot) ?? [];
244
+ this.slots.set(slot, target);
245
+ this.emitChildren(childrenOf(node), target, scope, false);
246
+ i++;
247
+ continue;
248
+ }
249
+ }
250
+
251
+ const dirs = directivesOf(node);
252
+
253
+ if (dirs?.has('if')) {
254
+ const { chain, next } = gatherChain(nodes, i);
255
+ this.emitIfChain(chain, out, scope, topLevel);
256
+ i = next;
257
+ continue;
258
+ }
259
+
260
+ if (dirs?.has('else-if') || dirs?.has('else')) {
261
+ const name = dirs.has('else') ? 'else' : 'else-if';
262
+ throw new CompileError(
263
+ `"${name}" on <${node.tagName}> has no "if" before it`,
264
+ node,
265
+ );
266
+ }
267
+
268
+ this.emitNode(node, out, scope, topLevel);
269
+ i++;
270
+ }
271
+ }
272
+
273
+ /**
274
+ * True where a structural block is addressable on its own. Anchors nest and
275
+ * the runtime counts depth, and a block inside a loop takes that loop's
276
+ * variables as arguments, so nesting is not a reason to give up on either.
277
+ */
278
+ standalone() {
279
+ return this.blocks;
280
+ }
281
+
282
+ /** Flat list of the loop variables in scope, outermost first. */
283
+ loopArgs() {
284
+ return this.loops.flatMap((loop) => [loop.item, loop.index]);
285
+ }
286
+
287
+ /**
288
+ * The block's markup lives in exactly one place: this function. `render` calls
289
+ * it, and so does an update. Anything nested inside is emitted inline, because
290
+ * re-rendering the outer block covers it.
291
+ */
292
+ emitBlock(node, out, body, extra = '', args = []) {
293
+ const id = this.blockDefs.length;
294
+ const params = ['__d', ...args].join(', ');
295
+ this.blockOf.set(node, id);
296
+ this.blockDefs.push(
297
+ `const __blk${id} = { ${extra}html: (${params}) => { let __o = '';\n${joinOut(body).code}\nreturn __o; } };`,
298
+ );
299
+ this.s(out, ANCHOR_OPEN);
300
+ this.c(out, `__o += __blk${id}.html(${params});`);
301
+ this.s(out, ANCHOR_CLOSE);
302
+ }
303
+
304
+ emitIfChain(chain, out, scope, topLevel) {
305
+ if (this.standalone()) {
306
+ const args = this.loopArgs();
307
+ const body = [];
308
+ this.inBlock++;
309
+ this.emitBranches(chain, body, scope, topLevel);
310
+ this.inBlock--;
311
+ this.emitBlock(chain[0].node, out, body, '', args);
312
+ return;
313
+ }
314
+ this.emitBranches(chain, out, scope, topLevel);
315
+ }
316
+
317
+ emitBranches(chain, out, scope, topLevel) {
318
+ for (const branch of chain) {
319
+ if (branch.kind === 'if') {
320
+ this.c(out, `if (${this.expr(branch.cond, scope, branch.node)}) {`);
321
+ } else if (branch.kind === 'else-if') {
322
+ this.c(out, `} else if (${this.expr(branch.cond, scope, branch.node)}) {`);
323
+ } else {
324
+ this.c(out, `} else {`);
325
+ }
326
+ // Inside a branch the element may still carry `each`; emitNode handles it,
327
+ // but the if+each combination is rejected up front.
328
+ this.emitNode(branch.node, out, scope, topLevel);
329
+ }
330
+ this.c(out, `}`);
331
+ }
332
+
333
+ emitNode(node, out, scope, topLevel) {
334
+ return this.from(node, () => this.emitNodeAt(node, out, scope, topLevel));
335
+ }
336
+
337
+ emitNodeAt(node, out, scope, topLevel) {
338
+ if (node.nodeName === '#text') {
339
+ this.emitText(node.value ?? '', out, scope, node, false);
340
+ return;
341
+ }
342
+ // Authoring comments are stripped. They still count as "insignificant" when
343
+ // linking an else to its if, so they can sit between branches.
344
+ if (node.nodeName === '#comment') return;
345
+ if (!node.tagName) return;
346
+
347
+ const dirs = directivesOf(node);
348
+ if (dirs.has('each') && BRANCH.some((b) => dirs.has(b))) {
349
+ throw new CompileError(
350
+ `<${node.tagName}> carries both "each" and "${BRANCH.find((b) => dirs.has(b))}". ` +
351
+ `Which runs first would be unclear. Wrap one in a <template>.`,
352
+ node,
353
+ );
354
+ }
355
+
356
+ const region = this.regionOf(node, dirs);
357
+ if (region) {
358
+ // Emitted once, used twice: the items go into the region's own buffer and
359
+ // then straight into the page. Rendering it separately would be a second
360
+ // copy of the markup that could drift from the first.
361
+ const buffer = [];
362
+ this.inRegion.push(region);
363
+ this.regionRoot = node;
364
+ this.emitElement(node, buffer, scope, topLevel);
365
+ this.regionRoot = null;
366
+ this.inRegion.pop();
367
+ this.regions.set(region, buffer);
368
+ for (const item of buffer) out.push(item);
369
+ return;
370
+ }
371
+
372
+ if (dirs.has('each')) this.emitEach(node, out, scope, topLevel);
373
+ else this.emitElement(node, out, scope, topLevel);
374
+ }
375
+
376
+ /**
377
+ * The name of the addressable region this element is, or null.
378
+ *
379
+ * The name is the element's `id`, on purpose, rather than a second name that
380
+ * could disagree with it. The URL that asks for the region and the selector that
381
+ * swaps it in are the same word, so `?fragment=results` targets `#results` and
382
+ * there is nothing to keep in sync.
383
+ */
384
+ regionOf(node, dirs) {
385
+ const attr = node.attrs?.find((a) => a.name === 'fragment');
386
+ if (!attr) return null;
387
+
388
+ if (!this.page || this.layout) {
389
+ throw new CompileError(
390
+ `<${node.tagName}> carries "fragment", which addresses a region of a page over ` +
391
+ `HTTP. A component re-renders itself and has no URL to be asked for.`,
392
+ node,
393
+ );
394
+ }
395
+ const clash = ['if', 'else-if', 'else', 'each'].find((name) => dirs.has(name));
396
+ if (clash) {
397
+ throw new CompileError(
398
+ `<${node.tagName}> carries both "fragment" and "${clash}". A region is one ` +
399
+ `element with one id, so it cannot be conditional or repeated. Put the ` +
400
+ `"${clash}" on something inside it.`,
401
+ node,
402
+ );
403
+ }
404
+ if (this.loops.length || this.inBlock) {
405
+ throw new CompileError(
406
+ `<${node.tagName}> carries "fragment" inside a loop, so it has no id of its own ` +
407
+ `and its markup depends on a loop variable the region could not be given.`,
408
+ node,
409
+ );
410
+ }
411
+
412
+ const id = node.attrs.find((a) => a.name === 'id')?.value;
413
+ if (!id) {
414
+ throw new CompileError(
415
+ `<${node.tagName}> carries "fragment" but has no id. The id is the region's ` +
416
+ `name. It is what the URL asks for and what a swap targets.`,
417
+ node,
418
+ );
419
+ }
420
+ if (/\$\{/.test(id)) {
421
+ throw new CompileError(
422
+ `<${node.tagName}> has an interpolated id, so its name is not knowable at ` +
423
+ `compile time and no URL could ask for it.`,
424
+ node,
425
+ );
426
+ }
427
+ if (attr.value !== '') {
428
+ throw new CompileError(
429
+ `"fragment" takes no value. The region is named by its id, which is ` +
430
+ `"${id}" here.`,
431
+ node,
432
+ );
433
+ }
434
+ if (this.regions.has(id)) {
435
+ throw new CompileError(`two regions are both named "${id}"`, node);
436
+ }
437
+ return id;
438
+ }
439
+
440
+ emitEach(el, out, scope, topLevel) {
441
+ // The loops around this one, not this one. This loop's own variables are
442
+ // created by the block's html.
443
+ const args = this.loopArgs();
444
+
445
+ if (this.standalone()) {
446
+ // The id is reserved before the pieces are built, because building them
447
+ // registers any block nested inside the item.
448
+ const id = this.blockDefs.length;
449
+ this.blockDefs.push('');
450
+ this.blockOf.set(el, id);
451
+
452
+ // A <template each> renders several nodes per item, so an item is a
453
+ // region rather than a node and needs anchors of its own to be found.
454
+ // Everything else is one element, which is its own delimiter.
455
+ const ranged = el.tagName === 'template';
456
+
457
+ this.inBlock++;
458
+ const extra = this.eachPieces(el, scope, topLevel, args, ranged);
459
+ this.inBlock--;
460
+
461
+ // html is the loop over item, not a second copy of it. Emitting the
462
+ // markup twice would register every nested block twice, and the second
463
+ // registration would be the one this pass handed on.
464
+ const outer = ['__d', ...args].join(', ');
465
+ this.blockDefs[id] =
466
+ `const __blk${id} = { ${extra}html: (${outer}) => { let __o = ''; let __n = 0; ` +
467
+ `for (const __it of __blk${id}.list(${outer})) __o += __blk${id}.item(${outer}, __it, __n++); ` +
468
+ `return __o; } };`;
469
+
470
+ this.s(out, ANCHOR_OPEN);
471
+ this.c(out, `__o += __blk${id}.html(${outer});`);
472
+ this.s(out, ANCHOR_CLOSE);
473
+ return;
474
+ }
475
+
476
+ this.emitEachBody(el, out, scope, topLevel);
477
+ }
478
+
479
+ /** `list`, `key` and `item`. One loop, taken apart so it can be reconciled. */
480
+ eachPieces(el, scope, topLevel, enclosing, ranged) {
481
+ const spec = parseEach(el.attrs.find((a) => a.name === 'each').value, el);
482
+ const id = ++this.uid;
483
+
484
+ const listAst = this.parse(spec.list, el);
485
+ this.note(collectRefs(listAst, scope));
486
+ const listJs = emit(listAst, scope);
487
+ this.warnShadowing(spec, scope, el);
488
+
489
+ const itemJs = `_u${id}_${spec.item}`;
490
+ const indexJs = `_u${id}_${spec.index ?? 'index'}`;
491
+
492
+ const inner = new Scope(scope);
493
+ inner.declare(spec.item, itemJs);
494
+ if (spec.index) inner.declare(spec.index, indexJs);
495
+
496
+ this.loops.push({ item: itemJs, index: indexJs });
497
+ const item = [];
498
+ this.emitElement(el, item, inner, topLevel);
499
+ this.loops.pop();
500
+
501
+ const key = el.attrs.find((attr) => attr.name === 'key');
502
+ const outer = ['__d', ...enclosing].join(', ');
503
+ const each = [outer, itemJs, indexJs].join(', ');
504
+
505
+ // Without a `key` the position is the key, which is what "unkeyed" has
506
+ // always meant.
507
+ const open = ranged ? JSON.stringify(ANCHOR_OPEN) : `''`;
508
+ const close = ranged ? ` + ${JSON.stringify(ANCHOR_CLOSE)}` : '';
509
+
510
+ return (
511
+ `keyed: true, ` +
512
+ (ranged ? `ranged: true, ` : '') +
513
+ `list: (${outer}) => (${listJs}) ?? [], ` +
514
+ `key: (${each}) => ${key ? `(${this.expr(key.value, inner, el)})` : indexJs}, ` +
515
+ `item: (${each}) => { let __o = ${open};\n${joinOut(item).code}\nreturn __o${close}; }, `
516
+ );
517
+ }
518
+
519
+ warnShadowing(spec, scope, el) {
520
+ if (!scope.lookup(spec.item)) return;
521
+ this.warn(
522
+ `"${spec.item}" shadows an outer loop variable of the same name; ` +
523
+ `the outer one is unreachable inside this block`,
524
+ el,
525
+ );
526
+ }
527
+
528
+ emitEachBody(el, out, scope, topLevel) {
529
+ const spec = parseEach(el.attrs.find((a) => a.name === 'each').value, el);
530
+ const id = ++this.uid;
531
+
532
+ const listAst = this.parse(spec.list, el);
533
+ this.note(collectRefs(listAst, scope));
534
+ const listJs = emit(listAst, scope);
535
+ this.warnShadowing(spec, scope, el);
536
+
537
+ const itemJs = `_u${id}_${spec.item}`;
538
+ // Always named, whether or not the author asked for it: a block nested in
539
+ // this loop takes it as an argument, and both passes have to agree on how
540
+ // many arguments that is.
541
+ const indexJs = `_u${id}_${spec.index ?? 'index'}`;
542
+
543
+ const inner = new Scope(scope);
544
+ inner.declare(spec.item, itemJs);
545
+ if (spec.index) inner.declare(spec.index, indexJs);
546
+
547
+ this.loops.push({ item: itemJs, index: indexJs });
548
+ this.c(out, `{ let _n${id} = 0; for (const ${itemJs} of (${listJs}) ?? []) {`);
549
+ this.c(out, `const ${indexJs} = _n${id};`);
550
+ this.emitElement(el, out, inner, topLevel);
551
+ this.c(out, `_n${id}++; } }`);
552
+ this.loops.pop();
553
+ }
554
+
555
+ emitElement(el, out, scope, topLevel) {
556
+ const tag = el.tagName;
557
+
558
+ // In a layout, <slot> is where the child's content goes. In a component it
559
+ // is a real shadow DOM slot and has to reach the browser untouched.
560
+ if (this.layout && tag === 'slot') {
561
+ const name = el.attrs.find((a) => a.name === 'name')?.value ?? 'default';
562
+ this.consumed.add(name);
563
+ const filled = `__slots[${JSON.stringify(name)}]`;
564
+ const fallback = childrenOf(el);
565
+
566
+ if (!fallback.length) {
567
+ this.c(out, `__o += ${filled} ?? '';`);
568
+ return;
569
+ }
570
+ this.c(out, `if (${filled}) { __o += ${filled}; } else {`);
571
+ this.emitChildren(fallback, out, scope, false);
572
+ this.c(out, `}`);
573
+ return;
574
+ }
575
+
576
+ // A <template> carrying a directive is structural: consumed, children emitted.
577
+ if (tag === 'template' && directivesOf(el).size > 0) {
578
+ this.emitChildren(childrenOf(el), out, scope, false);
579
+ return;
580
+ }
581
+
582
+ if (tag === INCLUDE_TAG) {
583
+ this.emitInclude(el, out, scope);
584
+ return;
585
+ }
586
+
587
+ if (this.components.has(tag)) {
588
+ if (this.shadowTags.has(tag)) this.emitShadow(el, out, scope);
589
+ else this.emitLight(el, out, scope);
590
+ return;
591
+ }
592
+
593
+ // <title> is kept apart from the rest of the head so the innermost one can
594
+ // win outright, without anything having to re-parse rendered markup.
595
+ let target = out;
596
+ if (topLevel && this.page && HEAD_TAGS.has(tag)) target = tag === 'title' ? this.title : this.head;
597
+
598
+ this.s(target, `<${tag}`);
599
+ this.emitAttrs(el, target, scope);
600
+ this.s(target, `>`);
601
+
602
+ if (VOID.has(tag)) return;
603
+
604
+ if (RAW_TEXT.has(tag)) {
605
+ for (const child of childrenOf(el)) {
606
+ if (child.nodeName === '#text') {
607
+ assertRawTextSafe(tag, child.value ?? '', el);
608
+ this.emitText(child.value ?? '', target, scope, child, true);
609
+ }
610
+ }
611
+ } else {
612
+ this.emitChildren(childrenOf(el), target, scope, false);
613
+ }
614
+
615
+ this.s(target, `</${tag}>`);
616
+ }
617
+
618
+ emitShadow(el, out, scope) {
619
+ const tag = el.tagName;
620
+ const ref = this.componentRef(tag);
621
+
622
+ // Host element: attributes are serialized so the client can re-read them.
623
+ this.s(out, `<${tag}`);
624
+ this.emitAttrs(el, out, scope, ref);
625
+ this.s(out, `>`);
626
+
627
+ // Shadow root, server-rendered.
628
+ const props = el.attrs
629
+ .filter((a) => !DIRECTIVES.has(a.name))
630
+ .map((a) => `${JSON.stringify(a.name)}: ${this.attrValueJs(a, scope, el)}`)
631
+ .join(', ');
632
+ this.c(out, `__o += __sh(${ref}, {${props}}${this.fragments ? ', __fragment' : ''});`);
633
+
634
+ // Light DOM children fill <slot>.
635
+ this.emitChildren(childrenOf(el), out, scope, false);
636
+
637
+ this.s(out, `</${tag}>`);
638
+ }
639
+
640
+ /**
641
+ * Light DOM: no shadow root, no template, markup straight into the page. Its
642
+ * children become its default slot, rendered into their own buffer first.
643
+ */
644
+ /**
645
+ * `<transclude src="#pricing">`, the same page's own region, in a second
646
+ * place.
647
+ *
648
+ * This is the case that should cost nothing: the region is already compiled to
649
+ * a function of the page's data, so including it is calling that function.
650
+ * Nothing is fetched, nothing is parsed and the markup cannot drift from the
651
+ * region it came from, because there is only one copy of it.
652
+ */
653
+ emitInclude(el, out, scope) {
654
+ const src = el.attrs?.find((a) => a.name === 'src')?.value ?? null;
655
+
656
+ if (src === null || src.trim() === '') {
657
+ throw new CompileError(`<${INCLUDE_TAG}> has no src. It names what to include.`, el);
658
+ }
659
+
660
+ // HTML has no self-closing tag outside SVG and MathML, so `/>` is read as
661
+ // `>` and everything after it becomes this element's children. The children
662
+ // are the fallback, so that is silent: the include works and the rest of the
663
+ // page disappears. An element that was never closed at all has the same
664
+ // shape and the same outcome.
665
+ if (el.sourceCodeLocation && !el.sourceCodeLocation.endTag) {
666
+ throw new CompileError(
667
+ `<${INCLUDE_TAG}> is never closed. HTML has no self-closing tag here, so ` +
668
+ `"/>" is read as ">" and the rest of the page is taken as this ` +
669
+ `element's fallback content. Write </${INCLUDE_TAG}>.`,
670
+ el,
671
+ );
672
+ }
673
+ if (!this.page || this.layout) {
674
+ throw new CompileError(
675
+ `<${INCLUDE_TAG}> includes a region of a page, and only a page has regions. ` +
676
+ `Put it in a route rather than in an element or a layout.`,
677
+ el,
678
+ );
679
+ }
680
+ if (/\$\{/.test(src)) {
681
+ throw new CompileError(
682
+ `<${INCLUDE_TAG}> has an interpolated src, so what it includes is not ` +
683
+ `knowable at compile time. Write the id out, or render the markup ` +
684
+ `yourself from the loader.`,
685
+ el,
686
+ );
687
+ }
688
+ if (!src.startsWith('#')) {
689
+ this.emitElsewhere(el, out, scope, src);
690
+ return;
691
+ }
692
+
693
+ const id = src.slice(1);
694
+ this.regionIncludes.push({ id, node: el, within: this.inRegion.at(-1) ?? null });
695
+
696
+ // The element leaves no trace. A region is several nodes inline and one node
697
+ // wrapping them here would mean the two spellings rendered differently.
698
+ // `false` for the id: the region keeps its name where it is declared, and
699
+ // this copy is the same content rather than a second element answering to
700
+ // `#id`. Two elements with one id would be invalid, and a swap aimed at the
701
+ // region would find whichever came first.
702
+ this.c(out, `__o += regions[${JSON.stringify(id)}](__d, {}, false, false);`);
703
+ }
704
+
705
+ /**
706
+ * `<transclude src="https://…#intro">`, a piece of a document somebody
707
+ * else wrote.
708
+ *
709
+ * Resolved before the render rather than during it. Render is synchronous all
710
+ * the way down, and a fetch is not, so what the page declares is collected at
711
+ * compile time and the server has the answers ready by the time render runs.
712
+ * That also means a prerendered page reads the source once, at build time.
713
+ */
714
+ emitElsewhere(el, out, scope, src) {
715
+ const hash = src.indexOf('#');
716
+ if (hash === -1 || hash === src.length - 1) {
717
+ throw new CompileError(
718
+ `<${INCLUDE_TAG} src="${src}"> names a document but no piece of it. ` +
719
+ `Add "#id" to say what to include.`,
720
+ el,
721
+ );
722
+ }
723
+
724
+ const id = decodeURIComponent(src.slice(hash + 1));
725
+ const where = src.slice(0, hash);
726
+
727
+ if (where.startsWith('/')) {
728
+ // Another route of this app. Rendered here rather than fetched: it is the
729
+ // same process, and going out over HTTP to reach ourselves would run the
730
+ // whole middleware stack to answer a question we can answer directly.
731
+ this.includes.push({ key: src, kind: 'route', where, id, node: el });
732
+ } else {
733
+ let url;
734
+ try {
735
+ url = new URL(where);
736
+ } catch {
737
+ throw new CompileError(
738
+ `<${INCLUDE_TAG} src="${src}"> is none of "#id" for a region of this page, ` +
739
+ `"/path#id" for another route, or an absolute URL for a document elsewhere.`,
740
+ el,
741
+ );
742
+ }
743
+ this.includes.push({ key: src, kind: 'external', where: url.href, id, node: el });
744
+ }
745
+
746
+ // Children are the fallback. On success they are dropped; with none, a
747
+ // source that cannot be read stops the render rather than leaving a hole
748
+ // nobody notices.
749
+ const children = childrenOf(el);
750
+ const uid = ++this.uid;
751
+ if (children.length) {
752
+ this.c(out, `const __fb${uid} = (() => { let __o = '';`);
753
+ this.emitChildren(children, out, scope, false);
754
+ this.c(out, `return __o; })();`);
755
+ }
756
+
757
+ this.c(
758
+ out,
759
+ `__o += __incl(__d, ${JSON.stringify(src)}, ${children.length ? `__fb${uid}` : 'null'});`,
760
+ );
761
+ }
762
+
763
+ emitLight(el, out, scope) {
764
+ const tag = el.tagName;
765
+ const ref = this.componentRef(tag);
766
+ const id = ++this.uid;
767
+
768
+ this.s(out, `<${tag}`);
769
+ this.emitAttrs(el, out, scope, ref);
770
+ this.s(out, `>`);
771
+
772
+ const children = childrenOf(el);
773
+ if (children.length) {
774
+ this.c(out, `const __sl${id} = (() => { let __o = '';`);
775
+ this.emitChildren(children, out, scope, false);
776
+ this.c(out, `return __o; })();`);
777
+ }
778
+
779
+ const props = el.attrs
780
+ .filter((attr) => !DIRECTIVES.has(attr.name))
781
+ .map((attr) => `${JSON.stringify(attr.name)}: ${this.attrValueJs(attr, scope, el)}`)
782
+ .join(', ');
783
+
784
+ this.c(
785
+ out,
786
+ `__o += ${ref}.render(__data(${ref}, {${props}}), ` +
787
+ `{ default: ${children.length ? `__sl${id}` : `''`} }` +
788
+ `${this.fragments ? ', __fragment' : ''});`,
789
+ );
790
+
791
+ this.s(out, `</${tag}>`);
792
+ }
793
+
794
+ /**
795
+ * `ref` is the component this element is, when it is one. Its own converters
796
+ * decide how a value becomes an attribute. The parent has no way to know that a
797
+ * Date crosses as an ISO string rather than as JSON.
798
+ */
799
+ emitAttrs(el, out, scope, ref = null) {
800
+ for (const attr of el.attrs) {
801
+ if (DIRECTIVES.has(attr.name)) continue;
802
+
803
+ // The id of a region's own root. It names the region, and a second copy
804
+ // of the region in the same document must not answer to that name too.
805
+ if (attr.name === 'id' && el === this.regionRoot) {
806
+ this.c(out, `__o += __named ? ${JSON.stringify(` id="${attr.value}"`)} : '';`);
807
+ continue;
808
+ }
809
+
810
+ const parts = splitInterpolations(attr.value);
811
+ const dynamic = parts.some((p) => p.type === 'expr');
812
+
813
+ if (!dynamic) {
814
+ this.s(out, attr.value === '' ? ` ${attr.name}` : ` ${attr.name}="${escapeAttr(attr.value)}"`);
815
+ continue;
816
+ }
817
+ this.c(
818
+ out,
819
+ ref
820
+ ? `__o += __ap(${ref}, ${JSON.stringify(attr.name)}, ${this.attrValueJs(attr, scope, el)});`
821
+ : `__o += __a(${JSON.stringify(attr.name)}, ${this.attrValueJs(attr, scope, el)});`,
822
+ );
823
+ }
824
+ }
825
+
826
+ /**
827
+ * `<html lang="en" data-theme="${theme}">` as `{ "lang": "en", "data-theme": theme }`.
828
+ *
829
+ * An object rather than serialized markup, because the chain merges these by
830
+ * name: a root layout setting the theme and a page setting `dir` must both
831
+ * survive, and two `data-theme` attributes in one tag would leave the parser
832
+ * taking the first, which is the outermost. `renderDocument` serializes.
833
+ */
834
+ htmlAttrsJs(el, scope) {
835
+ const pairs = el.attrs
836
+ .filter((attr) => !DIRECTIVES.has(attr.name))
837
+ .map((attr) => {
838
+ const name = JSON.stringify(attr.name);
839
+ if (attr.value === '') return `${name}: true`;
840
+ return `${name}: ${this.attrValueJs(attr, scope, el)}`;
841
+ });
842
+
843
+ return `{ ${pairs.join(', ')} }`;
844
+ }
845
+
846
+ // A lone `${expr}` keeps the value's type (arrays/objects/booleans survive to
847
+ // the runtime, which decides how to serialize). Mixed content becomes a concat.
848
+ attrValueJs(attr, scope, el) {
849
+ const parts = splitInterpolations(attr.value);
850
+ if (parts.length === 0) return `""`;
851
+ if (parts.length === 1) {
852
+ return parts[0].type === 'expr'
853
+ ? this.expr(parts[0].value, scope, el)
854
+ : JSON.stringify(parts[0].value);
855
+ }
856
+ return parts
857
+ .map((p) => (p.type === 'expr' ? `__str(${this.expr(p.value, scope, el)})` : JSON.stringify(p.value)))
858
+ .join(' + ');
859
+ }
860
+
861
+ emitText(value, out, scope, node, raw) {
862
+ const start = node?.sourceCodeLocation?.startLine ?? this.at;
863
+ let line = start;
864
+
865
+ for (const part of splitInterpolations(value)) {
866
+ this.at = line;
867
+ if (part.type === 'text') {
868
+ this.s(out, raw ? part.value : escapeText(part.value));
869
+ } else {
870
+ const js = this.expr(part.value, scope, node);
871
+ this.c(out, raw ? `__o += __str(${js});` : `__o += __e(${js});`);
872
+ }
873
+ // A paragraph of prose is one text node. Counting as we go is what puts a
874
+ // `${}` on line 40 at line 40 rather than at the line the node opened on.
875
+ if (start !== null) line += countNewlines(part.value);
876
+ }
877
+ this.at = start;
878
+ }
879
+ }
880
+
881
+ /**
882
+ * What a `<script>` or a `<style>` may interpolate, which is almost nothing.
883
+ *
884
+ * Text in these two is raw: escaping it would change what the browser reads,
885
+ * since `&amp;` is an ampersand in prose and four characters in JavaScript. So a
886
+ * value lands in code, and one that closes the string it was written into runs
887
+ * whatever follows. No escape fixes that, which is why this refuses.
888
+ *
889
+ * `json()` answers the narrower question, and only as the whole text of a
890
+ * script. A style needs no carve-out, because a custom property is an attribute.
891
+ *
892
+ * @param {string} tag 'script' or 'style'
893
+ * @param {string} text the raw text node's contents
894
+ * @param {object} el the element, for the line number
895
+ */
896
+ function assertRawTextSafe(tag, text, el) {
897
+ const parts = splitInterpolations(text);
898
+ const exprs = parts.filter((p) => p.type === 'expr');
899
+ if (exprs.length === 0) return;
900
+
901
+ if (tag === 'script' && parts.length === 1 && isJsonCall(exprs[0].value)) return;
902
+
903
+ const alternative =
904
+ tag === 'script'
905
+ ? 'Pass data with ${json(value)}, which is the whole text of the script, ' +
906
+ 'or put it in an attribute and read it from a file.'
907
+ : 'Set a custom property on the element instead: ' +
908
+ '<div style="--brand: ${color}">, which is escaped.';
909
+
910
+ throw new CompileError(
911
+ `\${} inside <${tag}> is written to the page as code, so a value could end ` +
912
+ `the element or the statement it sits in. ${alternative}`,
913
+ el,
914
+ );
915
+ }
916
+
917
+ /** `json(x)` and nothing else: not `json(x) + y`, and not `notJson(x)`. */
918
+ function isJsonCall(source) {
919
+ try {
920
+ const node = parseExpr(source);
921
+ return node.type === 'CallExpression' && node.callee?.type === 'Identifier' && node.callee.name === 'json';
922
+ } catch {
923
+ return false;
924
+ }
925
+ }
926
+
927
+ export const ANCHOR_OPEN = '<!--[-->';
928
+ export const ANCHOR_CLOSE = '<!--]-->';
929
+
930
+ /**
931
+ * `else` / `else-if` bind to the `if` before them, so a chain is one unit. Both
932
+ * passes have to agree on where it ends, so they share the same walk.
933
+ *
934
+ * @param {object[]} nodes
935
+ * @param {number} i where the `if` is
936
+ * @returns {{ chain: Array<{ node: object, kind: string, cond?: string }>, next: number }|null}
937
+ * null when the element carries no `if`, so there is no chain to gather
938
+ */
939
+ export function gatherChain(nodes, i) {
940
+ const dirs = directivesOf(nodes[i]);
941
+ if (!dirs?.has('if')) return null;
942
+
943
+ const chain = [{ node: nodes[i], kind: 'if', cond: dirs.get('if') }];
944
+ let next = i + 1;
945
+ for (;;) {
946
+ const k = nextSignificant(nodes, next);
947
+ if (k < 0) break;
948
+ const d = directivesOf(nodes[k]);
949
+ if (d?.has('else-if')) {
950
+ chain.push({ node: nodes[k], kind: 'else-if', cond: d.get('else-if') });
951
+ next = k + 1;
952
+ continue;
953
+ }
954
+ if (d?.has('else')) {
955
+ chain.push({ node: nodes[k], kind: 'else' });
956
+ next = k + 1;
957
+ }
958
+ break;
959
+ }
960
+ return { chain, next };
961
+ }
962
+
963
+ // ---- tree helpers ---------------------------------------------------------
964
+
965
+ // parse5 puts template children on `.content`, not `.childNodes`. Forgetting
966
+ // this silently skips everything inside every template.
967
+ /**
968
+ * @param {object} node
969
+ * @returns {object[]} a template's live under `.content`, so walking `childNodes` finds nothing
970
+ */
971
+ export function childrenOf(node) {
972
+ if (node.tagName === 'template' && node.content) return node.content.childNodes ?? [];
973
+ return node.childNodes ?? [];
974
+ }
975
+
976
+ function directivesOf(node) {
977
+ if (!node.tagName) return null;
978
+ const found = new Map();
979
+ for (const attr of node.attrs ?? []) {
980
+ if (DIRECTIVES.has(attr.name)) found.set(attr.name, attr.value);
981
+ }
982
+ return found;
983
+ }
984
+
985
+ // else / else-if bind to the previous element, skipping whitespace and comments.
986
+ function nextSignificant(nodes, from) {
987
+ for (let i = from; i < nodes.length; i++) {
988
+ const n = nodes[i];
989
+ if (n.nodeName === '#comment') continue;
990
+ if (n.nodeName === '#text' && /^\s*$/.test(n.value ?? '')) continue;
991
+ return n.tagName ? i : -1;
992
+ }
993
+ return -1;
994
+ }
995
+
996
+
997
+ function parseEach(value, node) {
998
+ const m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+of\s+([\s\S]+?)\s*$/.exec(value);
999
+ if (!m) {
1000
+ throw new CompileError(
1001
+ `each="${value}" is malformed. Expected each="item of list" or each="item, index of list"`,
1002
+ node,
1003
+ );
1004
+ }
1005
+ return { item: m[1], index: m[2] || null, list: m[3] };
1006
+ }
1007
+
1008
+ // ---- output ---------------------------------------------------------------
1009
+
1010
+ /** Newlines in a string, for walking a multi-line text node. */
1011
+ function countNewlines(text) {
1012
+ let n = 0;
1013
+ for (let i = 0; i < text.length; i++) if (text[i] === '\n') n += 1;
1014
+ return n;
1015
+ }
1016
+
1017
+ /**
1018
+ * Chunks to code, and which source line each generated line came from.
1019
+ *
1020
+ * `.at` is one entry per line of the returned code, so a caller that knows where
1021
+ * this block landed in the assembled module can turn it into a mapping. Static
1022
+ * chunks are buffered into one `__o +=`, and the line it reports is the first
1023
+ * chunk's: that is where the run of markup started.
1024
+ */
1025
+ function joinOut(entries) {
1026
+ const lines = [];
1027
+ const at = [];
1028
+ let buffer = '';
1029
+ let bufferAt = null;
1030
+
1031
+ const flush = () => {
1032
+ if (!buffer) return;
1033
+ lines.push(`__o += ${JSON.stringify(buffer)};`);
1034
+ at.push(bufferAt);
1035
+ buffer = '';
1036
+ bufferAt = null;
1037
+ };
1038
+
1039
+ for (const entry of entries) {
1040
+ if (entry.t === 's') {
1041
+ if (!buffer) bufferAt = entry.at ?? null;
1042
+ buffer += entry.v;
1043
+ continue;
1044
+ }
1045
+ flush();
1046
+ lines.push(entry.v);
1047
+ at.push(entry.at ?? null);
1048
+ }
1049
+ flush();
1050
+
1051
+ return { code: lines.join('\n'), at };
1052
+ }
1053
+
1054
+ // parse5 hands us decoded text, so static output has to be re-encoded.
1055
+ function escapeText(value) {
1056
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1057
+ }
1058
+
1059
+ function escapeAttr(value) {
1060
+ return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
1061
+ }