@transclude/core 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,14 +9,10 @@
9
9
 
10
10
  import { Scope, collectRefs, emit, parseExpr } from './expr.js';
11
11
  import { splitInterpolations } from './interp.js';
12
+ import { parseEach as readEach } from './directives.js';
13
+ import { escapeAttr, escapeText, RAW_TEXT, VOID } from './html.js';
12
14
 
13
- const VOID = new Set([
14
- 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
15
- 'link', 'meta', 'param', 'source', 'track', 'wbr',
16
- ]);
17
15
 
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
16
 
21
17
  // Hoisted out of a page body into <head>.
22
18
  const HEAD_TAGS = new Set(['title', 'meta', 'link', 'base']);
@@ -39,6 +35,28 @@ export class CompileError extends Error {
39
35
  }
40
36
  }
41
37
 
38
+ /**
39
+ * An attribute name is emitted exactly as written, so `${…}` in one reaches the
40
+ * page as those characters rather than as a value. Nothing downstream reads it,
41
+ * which made this the one interpolation mistake that rendered instead of
42
+ * failing. A spread parses as an attribute name too, and is caught here.
43
+ *
44
+ * @param {object} el
45
+ * @throws {CompileError} when a name holds an interpolation
46
+ */
47
+ function assertStaticAttrNames(el) {
48
+ for (const attr of el.attrs ?? []) {
49
+ if (!attr.name.includes('${')) continue;
50
+
51
+ throw new CompileError(
52
+ `<${el.tagName}> interpolates an attribute name: "${attr.name}". ` +
53
+ `Only a value takes \${…}, so write the name out. To choose between two, ` +
54
+ `put the condition in the value: class="\${cond ? 'a' : 'b'}".`,
55
+ el,
56
+ );
57
+ }
58
+ }
59
+
42
60
  /**
43
61
  * @param {object[]} nodes
44
62
  * @param {object} [opts]
@@ -46,14 +64,17 @@ export class CompileError extends Error {
46
64
  */
47
65
  export function compileFragment(nodes, opts = {}) {
48
66
  const gen = new Codegen(opts);
49
- gen.emitChildren(nodes, gen.body, gen.rootScope, true);
67
+ gen.emitChildren(nodes, gen.body, gen.rootScope, { topLevel: true });
50
68
 
51
69
  // `<html>` is read separately, because the fragment parser drops it: a nested
52
70
  // html start tag is not something that can appear in a body, so parse5 throws
53
71
  // it away attributes and all. `splitBlocks` reads it in document mode, where
54
72
  // it is the element it names.
73
+ // `<body>` is read the same way, for the same reason.
55
74
  const htmlNode = opts.html ?? null;
56
- const htmlAttrs = htmlNode?.attrs?.length ? gen.htmlAttrsJs(htmlNode, gen.rootScope) : null;
75
+ const bodyNode = opts.body ?? null;
76
+ const htmlAttrs = htmlNode?.attrs?.length ? gen.openTagAttrsJs(htmlNode, gen.rootScope) : null;
77
+ const bodyAttrs = bodyNode?.attrs?.length ? gen.openTagAttrsJs(bodyNode, gen.rootScope) : null;
57
78
 
58
79
  const body = joinOut(gen.body);
59
80
  const head = joinOut(gen.head);
@@ -86,6 +107,7 @@ export function compileFragment(nodes, opts = {}) {
86
107
  title: title.code,
87
108
  hasTitle: gen.title.length > 0,
88
109
  htmlAttrs,
110
+ bodyAttrs,
89
111
  warnings: gen.warnings,
90
112
  reads: gen.reads,
91
113
  components: [...gen.used.entries()].map(([tag, ref]) => ({ tag, ref })),
@@ -230,7 +252,7 @@ class Codegen {
230
252
 
231
253
  // ---- traversal ----------------------------------------------------------
232
254
 
233
- emitChildren(nodes, out, scope, topLevel = false) {
255
+ emitChildren(nodes, out, scope, { topLevel = false } = {}) {
234
256
  let i = 0;
235
257
  while (i < nodes.length) {
236
258
  const node = nodes[i];
@@ -242,7 +264,7 @@ class Codegen {
242
264
  if (slot) {
243
265
  const target = this.slots.get(slot) ?? [];
244
266
  this.slots.set(slot, target);
245
- this.emitChildren(childrenOf(node), target, scope, false);
267
+ this.emitChildren(childrenOf(node), target, scope);
246
268
  i++;
247
269
  continue;
248
270
  }
@@ -336,7 +358,7 @@ class Codegen {
336
358
 
337
359
  emitNodeAt(node, out, scope, topLevel) {
338
360
  if (node.nodeName === '#text') {
339
- this.emitText(node.value ?? '', out, scope, node, false);
361
+ this.emitText(node.value ?? '', out, scope, node);
340
362
  return;
341
363
  }
342
364
  // Authoring comments are stripped. They still count as "insignificant" when
@@ -553,6 +575,7 @@ class Codegen {
553
575
  }
554
576
 
555
577
  emitElement(el, out, scope, topLevel) {
578
+ assertStaticAttrNames(el);
556
579
  const tag = el.tagName;
557
580
 
558
581
  // In a layout, <slot> is where the child's content goes. In a component it
@@ -568,14 +591,14 @@ class Codegen {
568
591
  return;
569
592
  }
570
593
  this.c(out, `if (${filled}) { __o += ${filled}; } else {`);
571
- this.emitChildren(fallback, out, scope, false);
594
+ this.emitChildren(fallback, out, scope);
572
595
  this.c(out, `}`);
573
596
  return;
574
597
  }
575
598
 
576
599
  // A <template> carrying a directive is structural: consumed, children emitted.
577
600
  if (tag === 'template' && directivesOf(el).size > 0) {
578
- this.emitChildren(childrenOf(el), out, scope, false);
601
+ this.emitChildren(childrenOf(el), out, scope);
579
602
  return;
580
603
  }
581
604
 
@@ -605,11 +628,11 @@ class Codegen {
605
628
  for (const child of childrenOf(el)) {
606
629
  if (child.nodeName === '#text') {
607
630
  assertRawTextSafe(tag, child.value ?? '', el);
608
- this.emitText(child.value ?? '', target, scope, child, true);
631
+ this.emitText(child.value ?? '', target, scope, child, { raw: true });
609
632
  }
610
633
  }
611
634
  } else {
612
- this.emitChildren(childrenOf(el), target, scope, false);
635
+ this.emitChildren(childrenOf(el), target, scope);
613
636
  }
614
637
 
615
638
  this.s(target, `</${tag}>`);
@@ -632,7 +655,7 @@ class Codegen {
632
655
  this.c(out, `__o += __sh(${ref}, {${props}}${this.fragments ? ', __fragment' : ''});`);
633
656
 
634
657
  // Light DOM children fill <slot>.
635
- this.emitChildren(childrenOf(el), out, scope, false);
658
+ this.emitChildren(childrenOf(el), out, scope);
636
659
 
637
660
  this.s(out, `</${tag}>`);
638
661
  }
@@ -750,7 +773,7 @@ class Codegen {
750
773
  const uid = ++this.uid;
751
774
  if (children.length) {
752
775
  this.c(out, `const __fb${uid} = (() => { let __o = '';`);
753
- this.emitChildren(children, out, scope, false);
776
+ this.emitChildren(children, out, scope);
754
777
  this.c(out, `return __o; })();`);
755
778
  }
756
779
 
@@ -772,7 +795,7 @@ class Codegen {
772
795
  const children = childrenOf(el);
773
796
  if (children.length) {
774
797
  this.c(out, `const __sl${id} = (() => { let __o = '';`);
775
- this.emitChildren(children, out, scope, false);
798
+ this.emitChildren(children, out, scope);
776
799
  this.c(out, `return __o; })();`);
777
800
  }
778
801
 
@@ -824,14 +847,18 @@ class Codegen {
824
847
  }
825
848
 
826
849
  /**
827
- * `<html lang="en" data-theme="${theme}">` as `{ "lang": "en", "data-theme": theme }`.
850
+ * `<html lang="en" data-theme="${theme}">` as `{ "lang": "en", "data-theme": theme }`,
851
+ * and the same for `<body>`.
828
852
  *
829
853
  * An object rather than serialized markup, because the chain merges these by
830
854
  * name: a root layout setting the theme and a page setting `dir` must both
831
855
  * survive, and two `data-theme` attributes in one tag would leave the parser
832
856
  * taking the first, which is the outermost. `renderDocument` serializes.
833
857
  */
834
- htmlAttrsJs(el, scope) {
858
+ openTagAttrsJs(el, scope) {
859
+ // `<html>` and `<body>` are read by a second parse, so neither reaches
860
+ // emitElement and neither is checked by it.
861
+ assertStaticAttrNames(el);
835
862
  const pairs = el.attrs
836
863
  .filter((attr) => !DIRECTIVES.has(attr.name))
837
864
  .map((attr) => {
@@ -858,7 +885,7 @@ class Codegen {
858
885
  .join(' + ');
859
886
  }
860
887
 
861
- emitText(value, out, scope, node, raw) {
888
+ emitText(value, out, scope, node, { raw = false } = {}) {
862
889
  const start = node?.sourceCodeLocation?.startLine ?? this.at;
863
890
  let line = start;
864
891
 
@@ -918,7 +945,9 @@ function assertRawTextSafe(tag, text, el) {
918
945
  function isJsonCall(source) {
919
946
  try {
920
947
  const node = parseExpr(source);
921
- return node.type === 'CallExpression' && node.callee?.type === 'Identifier' && node.callee.name === 'json';
948
+ if (node.type !== 'CallExpression') return false;
949
+ if (node.callee?.type !== 'Identifier') return false;
950
+ return node.callee.name === 'json';
922
951
  } catch {
923
952
  return false;
924
953
  }
@@ -933,7 +962,7 @@ export const ANCHOR_CLOSE = '<!--]-->';
933
962
  *
934
963
  * @param {object[]} nodes
935
964
  * @param {number} i where the `if` is
936
- * @returns {{ chain: Array<{ node: object, kind: string, cond?: string }>, next: number }|null}
965
+ * @returns {{ chain: Array<{ node: object, kind: string, cond: string|null }>, next: number }|null}
937
966
  * null when the element carries no `if`, so there is no chain to gather
938
967
  */
939
968
  export function gatherChain(nodes, i) {
@@ -952,7 +981,10 @@ export function gatherChain(nodes, i) {
952
981
  continue;
953
982
  }
954
983
  if (d?.has('else')) {
955
- chain.push({ node: nodes[k], kind: 'else' });
984
+ // `cond: null` rather than absent, so every entry in the chain is one
985
+ // shape. `emitBranches` reads `kind` to decide, and never reads the
986
+ // condition of an `else`.
987
+ chain.push({ node: nodes[k], kind: 'else', cond: null });
956
988
  next = k + 1;
957
989
  }
958
990
  break;
@@ -995,14 +1027,14 @@ function nextSignificant(nodes, from) {
995
1027
 
996
1028
 
997
1029
  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) {
1030
+ const spec = readEach(value);
1031
+ if (!spec) {
1000
1032
  throw new CompileError(
1001
1033
  `each="${value}" is malformed. Expected each="item of list" or each="item, index of list"`,
1002
1034
  node,
1003
1035
  );
1004
1036
  }
1005
- return { item: m[1], index: m[2] || null, list: m[3] };
1037
+ return spec;
1006
1038
  }
1007
1039
 
1008
1040
  // ---- output ---------------------------------------------------------------
@@ -1051,11 +1083,3 @@ function joinOut(entries) {
1051
1083
  return { code: lines.join('\n'), at };
1052
1084
  }
1053
1085
 
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
- }
@@ -0,0 +1,29 @@
1
+ // What a directive value means. One answer, because three files ask.
2
+ //
3
+ // `each` was parsed by the same regular expression written out in bind.js,
4
+ // codegen.js and shim.js. Three copies of one rule is three places to change and
5
+ // two to forget, and the three do different things with the answer: one emits a
6
+ // loop, one emits a binding, one emits the JS tsc checks. They agreed by luck.
7
+
8
+ /**
9
+ * `item of items`, or `item, i of items`.
10
+ *
11
+ * The value is an expression, not an interpolation: there is no `${}` in it. A
12
+ * parser that treated it as one would read the whole thing as text and get the
13
+ * volatile set wrong, which is a silent bug rather than a loud one.
14
+ */
15
+ const EACH = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+of\s+([\s\S]+?)\s*$/;
16
+
17
+ /**
18
+ * The three parts of an `each`, or null when it does not parse.
19
+ *
20
+ * @param {string} value the attribute as written
21
+ * @returns {{ item: string, index: string|null, list: string }|null}
22
+ */
23
+ export function parseEach(value) {
24
+ const found = EACH.exec(value ?? '');
25
+ if (!found) return null;
26
+
27
+ const [, item, index, list] = found;
28
+ return { item, index: index ?? null, list };
29
+ }
@@ -175,6 +175,14 @@ export function chainOf(node, scope, computed = []) {
175
175
  return null;
176
176
  }
177
177
 
178
+ /**
179
+ * Every jsep node key that can hold another node. Anything else on a node is a
180
+ * name, a value or a flag, and walking into one finds nothing.
181
+ */
182
+ const CHILD_KEYS = [
183
+ 'argument', 'left', 'right', 'test', 'consequent', 'alternate', 'object', 'property',
184
+ ];
185
+
178
186
  /**
179
187
  * Every data or loop-variable path an expression reads.
180
188
  *
@@ -212,9 +220,10 @@ export function collectRefs(node, scope, out = []) {
212
220
  return out;
213
221
 
214
222
  default:
215
- for (const key of ['argument', 'left', 'right', 'test', 'consequent', 'alternate', 'object', 'property']) {
223
+ for (const key of CHILD_KEYS) {
216
224
  if (node[key]) collectRefs(node[key], scope, out);
217
225
  }
226
+
218
227
  for (const element of node.elements ?? []) collectRefs(element, scope, out);
219
228
  return out;
220
229
  }
@@ -0,0 +1,41 @@
1
+ // Facts about HTML that both compiler passes need.
2
+ //
3
+ // These are the spec's, not this codebase's, so they belong in one place and
4
+ // never change for a reason either pass would know about. Both lists were
5
+ // written out twice, in codegen.js and bind.js, and agreed by luck.
6
+
7
+ /** Elements with no closing tag and no children. */
8
+ export const VOID = new Set([
9
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
10
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
11
+ ]);
12
+
13
+ /**
14
+ * Elements whose text is not entity-decoded by the parser, so escaping it would
15
+ * change what the browser reads. `&amp;` is one character in prose and five in
16
+ * JavaScript.
17
+ */
18
+ export const RAW_TEXT = new Set(['script', 'style']);
19
+
20
+ /** parse5 hands back decoded text, so static output has to be re-encoded. */
21
+ export function escapeText(value) {
22
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
23
+ }
24
+
25
+ /**
26
+ * A static attribute value.
27
+ *
28
+ * Three characters, and `>` is deliberately not one of them: a quoted attribute
29
+ * value may hold one, and leaving it is what a serializer does. That has a
30
+ * consequence worth knowing, because `content="a > b"` then reaches the page
31
+ * with a bare `>` in it, and anything scanning compiled markup for the end of a
32
+ * tag has to be quote-aware rather than stopping at the first one. `mergeHead`
33
+ * in document.js is that scanner, and it had this wrong once.
34
+ *
35
+ * The runtime escapes `>` as well, so an interpolated value and a static one
36
+ * come out spelled differently and parse the same. That is not worth making
37
+ * agree: the runtime ships to a browser and must not import from here.
38
+ */
39
+ export function escapeAttr(value) {
40
+ return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
41
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { parse, parseFragment } from 'parse5';
4
4
  import { compileFragment, childrenOf, CompileError } from './codegen.js';
5
+ import { escapeAttr } from './html.js';
5
6
  import { lineMap, sourceMap } from './sourcemap.js';
6
7
  import { compileBindings } from './bind.js';
7
8
  import {
@@ -15,7 +16,14 @@ import {
15
16
 
16
17
  export { CompileError, ScriptError };
17
18
 
18
- const PAGE_EXPORTS = new Set(['css', 'load', 'render', 'renderHead', 'renderTitle', 'renderHtmlAttrs', 'layouts', 'client', 'elements', 'headScript']);
19
+ // Everything the generated page and layout modules define at the top level. A
20
+ // block naming one of these is checked whether it exports it or merely declares
21
+ // it, because both land in the same scope.
22
+ const PAGE_EXPORTS = new Set([
23
+ 'css', 'load', 'render', 'renderHead', 'renderTitle', 'renderHtmlAttrs',
24
+ 'layouts', 'client', 'elements', 'headScript', 'hasTitle', 'includes',
25
+ 'renderBodyAttrs',
26
+ ]);
19
27
  const COMPONENT_EXPORTS = new Set([
20
28
  'tag', 'light', 'css', 'elements', 'propDefs', 'propAttrs', 'stateDefs', 'members', 'render',
21
29
  'coerce', 'def', 'init', 'define', 'default', 'bind', 'update', 'volatile', 'formAssociated',
@@ -107,10 +115,17 @@ export function splitBlocks(source) {
107
115
  // away with its attributes. In document mode it is the element it names, and a
108
116
  // `<html>` inside a script block or a comment is still not one, because this
109
117
  // 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 };
118
+ const document = parse(source, { sourceCodeLocationInfo: true });
119
+ const html = document.childNodes.find((n) => n.nodeName === 'html') ?? null;
120
+
121
+ // `<body>` is dropped by the fragment parser for the same reason `<html>` is,
122
+ // and was silently going missing: writing `<body class="admin">` in a page
123
+ // produced no attribute and no error. In document mode it is a real element.
124
+ // parse5's ChildNode union does not carry `childNodes`; in document mode this
125
+ // one is the html element and does.
126
+ const parent = /** @type {{ childNodes?: Array<{ nodeName: string }> }|null} */ (html);
127
+ const body = parent?.childNodes?.find((n) => n.nodeName === 'body') ?? null;
128
+ const out = { server: null, properties: null, state: null, client: [], head: [], styles: [], nodes: [], html, body };
114
129
 
115
130
  for (const node of doc.childNodes) {
116
131
  if (node.nodeName === 'script') {
@@ -141,6 +156,12 @@ export function splitBlocks(source) {
141
156
  // before first paint, or a `pagereveal` listener, which fires too early for
142
157
  // any script in the body to see.
143
158
  else if (attrs.has('head')) out.head.push({ ...block, attrs: node.attrs ?? [] });
159
+ // A `src` means there is no code here to compile, so this is markup: an
160
+ // ordinary external script the page wants in its body. Read as a client
161
+ // block it became an empty one and the tag was dropped, `src` and all,
162
+ // with nothing said. A nested `<script src>` was always markup; only a
163
+ // top-level one went missing.
164
+ else if (attrs.has('src')) out.nodes.push(node);
144
165
  else out.client.push(block);
145
166
  continue;
146
167
  }
@@ -355,6 +376,21 @@ const MARK = {
355
376
  title: '/*@transclude:title*/',
356
377
  };
357
378
 
379
+ /**
380
+ * A page: the `<script server>` block, the markup, and whatever layouts wrap it.
381
+ *
382
+ * `filename` is what an error message says, which is the short route id.
383
+ * `sourcePath` is what the source map names, which is a path an editor opens.
384
+ *
385
+ * @param {string} source
386
+ * @param {{ components?: Map<string, string>, shadowTags?: Set<string>,
387
+ * runtime: string, filename?: string, sourcePath?: string|null,
388
+ * layouts?: string[],
389
+ * client?: { tags: string[], hasScript: boolean, needed: boolean } }} options
390
+ * @returns {{ code: string, map: object|null, warnings: string[],
391
+ * components: string[] }} the module, a line-level map or null when there is
392
+ * no markup to map, whatever the template warned about, and the tags it used
393
+ */
358
394
  export function compilePage(
359
395
  source,
360
396
  {
@@ -375,11 +411,18 @@ export function compilePage(
375
411
 
376
412
  const server = blocks.server
377
413
  ? bindDefaultExport(blocks.server, '__load', where)
378
- : { code: 'const __load = null;', exports: [], imports: [], defaultNode: null };
414
+ : { code: 'const __load = null;', exports: [], imports: [], declared: [], defaultNode: null };
379
415
  assertNoCollisions(server.exports, PAGE_EXPORTS, where);
416
+ assertNoCollisions(server.declared ?? [], PAGE_EXPORTS, where, 'declares');
380
417
  assertNoActionsObject(server.exports, where);
381
418
 
382
- const template = compileFragment(blocks.nodes, { components, shadowTags, page: true, html: blocks.html });
419
+ const template = compileFragment(blocks.nodes, {
420
+ components,
421
+ shadowTags,
422
+ page: true,
423
+ html: blocks.html,
424
+ body: blocks.body,
425
+ });
383
426
  assertIncludesResolve(template.regionIncludes, template.regions);
384
427
 
385
428
  const code = `
@@ -413,6 +456,10 @@ export function renderHtmlAttrs(__d) {
413
456
  return ${template.htmlAttrs ?? '{}'};
414
457
  }
415
458
 
459
+ export function renderBodyAttrs(__d) {
460
+ return ${template.bodyAttrs ?? '{}'};
461
+ }
462
+
416
463
  export function renderHead(__d) {
417
464
  let __o = '';
418
465
  ${MARK.head}
@@ -485,8 +532,9 @@ export function compileLayout(source, { id, components = new Map(), shadowTags =
485
532
 
486
533
  const server = blocks.server
487
534
  ? bindDefaultExport(blocks.server, '__load', where)
488
- : { code: 'const __load = null;', exports: [], imports: [], defaultNode: null };
535
+ : { code: 'const __load = null;', exports: [], imports: [], declared: [], defaultNode: null };
489
536
  assertNoCollisions(server.exports, PAGE_EXPORTS, where);
537
+ assertNoCollisions(server.declared ?? [], PAGE_EXPORTS, where, 'declares');
490
538
  assertNoActionsObject(server.exports, where);
491
539
 
492
540
  const template = compileFragment(blocks.nodes, {
@@ -495,6 +543,7 @@ export function compileLayout(source, { id, components = new Map(), shadowTags =
495
543
  page: true,
496
544
  layout: true,
497
545
  html: blocks.html,
546
+ body: blocks.body,
498
547
  });
499
548
 
500
549
  const warnings = [...template.warnings];
@@ -527,6 +576,10 @@ export function renderHtmlAttrs(__d) {
527
576
  return ${template.htmlAttrs ?? '{}'};
528
577
  }
529
578
 
579
+ export function renderBodyAttrs(__d) {
580
+ return ${template.bodyAttrs ?? '{}'};
581
+ }
582
+
530
583
  export function renderHead(__d) {
531
584
  let __o = '';
532
585
  ${indent(template.head)}
@@ -539,7 +592,7 @@ ${slotBodies(template)}
539
592
  return __out;
540
593
  }
541
594
 
542
- export default { css, headScript, elements, hasTitle, load, renderTitle, renderHead, renderHtmlAttrs, render };
595
+ export default { css, headScript, elements, hasTitle, load, renderTitle, renderHead, renderHtmlAttrs, renderBodyAttrs, render };
543
596
  `;
544
597
 
545
598
  return { code, warnings, components: template.components.map((c) => c.tag) };
@@ -844,8 +897,7 @@ function headScript(blocks, where) {
844
897
  /** A static attribute, escaped the way an HTML serializer must. */
845
898
  function serializeAttr({ name, value }) {
846
899
  if (value === '') return ` ${name}`;
847
- const escaped = value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
848
- return ` ${name}="${escaped}"`;
900
+ return ` ${name}="${escapeAttr(value)}"`;
849
901
  }
850
902
 
851
903
  function elementsExport(used) {
@@ -34,7 +34,7 @@ const PARSE_OPTIONS = {
34
34
  * @param {{ flags?: string[] }} [options]
35
35
  * @returns {{ code: string, exports: string[],
36
36
  * imports: Array<{ source: string, specifiers: string }>,
37
- * defaultNode: object|null, flags: object }}
37
+ * declared: string[], defaultNode: object|null, flags: object }}
38
38
  */
39
39
  export function bindDefaultExport(block, name, label, { flags = [] } = {}) {
40
40
  const { code: source, line = 1 } = block;
@@ -56,9 +56,10 @@ export function bindDefaultExport(block, name, label, { flags = [] } = {}) {
56
56
  const node = ast.body.find((s) => s.type === 'ExportDefaultDeclaration') ?? null;
57
57
  const exports = namedExportsOf(ast, source, line, label).filter((n) => !flags.includes(n));
58
58
  const imports = importsOf(ast);
59
+ const declared = topLevelNames(ast);
59
60
 
60
61
  if (!node) {
61
- return { code: `${code}\nconst ${name} = null;`, exports, imports, defaultNode: null, flags: found };
62
+ return { code: `${code}\nconst ${name} = null;`, exports, imports, declared, defaultNode: null, flags: found };
62
63
  }
63
64
 
64
65
  // Slicing the *declaration* rather than the statement means
@@ -71,7 +72,7 @@ export function bindDefaultExport(block, name, label, { flags = [] } = {}) {
71
72
  ';' +
72
73
  code.slice(node.end);
73
74
 
74
- return { code: rewritten, exports, imports, defaultNode: node.declaration, flags: found };
75
+ return { code: rewritten, exports, imports, declared, defaultNode: node.declaration, flags: found };
75
76
  }
76
77
 
77
78
  /**
@@ -368,6 +369,9 @@ function scopedNames(statements) {
368
369
  return (statements ?? []).flatMap(declaredNames);
369
370
  }
370
371
 
372
+ /** Keys acorn puts on every node. They hold positions, never child nodes. */
373
+ const NODE_BOOKKEEPING = new Set(['type', 'start', 'end', 'loc', 'range']);
374
+
371
375
  /**
372
376
  * Identifiers a subtree reads from outside itself.
373
377
  *
@@ -405,8 +409,12 @@ function freeNames(node, bound, out) {
405
409
  case 'ArrowFunctionExpression': {
406
410
  const inner = new Set(bound);
407
411
  if (node.id) inner.add(node.id.name);
408
- for (const param of node.params) for (const name of patternNames(param)) inner.add(name);
409
- if (node.body.type === 'BlockStatement') for (const name of scopedNames(node.body.body)) inner.add(name);
412
+ for (const param of node.params) {
413
+ for (const name of patternNames(param)) inner.add(name);
414
+ }
415
+ if (node.body.type === 'BlockStatement') {
416
+ for (const name of scopedNames(node.body.body)) inner.add(name);
417
+ }
410
418
  // A default is evaluated in the function's own scope, so it sees the params.
411
419
  for (const param of node.params) freeNames(param, inner, out);
412
420
  freeNames(node.body, inner, out);
@@ -462,7 +470,7 @@ function freeNames(node, bound, out) {
462
470
 
463
471
  default:
464
472
  for (const key of Object.keys(node)) {
465
- if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue;
473
+ if (NODE_BOOKKEEPING.has(key)) continue;
466
474
  freeNames(node[key], bound, out);
467
475
  }
468
476
  return out;
@@ -506,19 +514,20 @@ function importsOf(ast) {
506
514
  }
507
515
 
508
516
  /**
509
- * Guards against a block exporting a name the generated module already uses.
517
+ * Guards against a block using a name the generated module already defines.
510
518
  *
511
- * @param {string[]} exports
519
+ * @param {string[]} names
512
520
  * @param {Set<string>} reserved
513
521
  * @param {string} label
522
+ * @param {string} [verb] how the block used it, for the message
514
523
  * @returns {void}
515
524
  * @throws naming the first collision
516
525
  */
517
- export function assertNoCollisions(exports, reserved, label) {
518
- for (const name of exports) {
526
+ export function assertNoCollisions(names, reserved, label, verb = 'exports') {
527
+ for (const name of names) {
519
528
  if (reserved.has(name)) {
520
529
  throw new ScriptError(
521
- `${label}: exports "${name}", which the generated module already defines. ` +
530
+ `${label}: ${verb} "${name}", which the generated module already defines. ` +
522
531
  `Reserved: ${[...reserved].join(', ')}.`,
523
532
  );
524
533
  }
@@ -586,6 +595,26 @@ function namedExportsOf(ast, code, lineOffset, label) {
586
595
  return names;
587
596
  }
588
597
 
598
+ /**
599
+ * Every name a block binds at the top level, imports included.
600
+ *
601
+ * The generated module puts this block's code beside its own `export const`
602
+ * statements, so any of these can collide, not only the exported ones. An
603
+ * import was the case that got through: `import { elements } from './x.js'`
604
+ * binds `elements` and exports nothing, so the export check never saw it and
605
+ * the build failed inside rolldown, pointing at a virtual module.
606
+ *
607
+ * @param {object} ast
608
+ * @returns {string[]}
609
+ */
610
+ function topLevelNames(ast) {
611
+ return ast.body.flatMap((statement) =>
612
+ statement.type === 'ImportDeclaration'
613
+ ? statement.specifiers.map((spec) => spec.local.name)
614
+ : declaredNames(statement),
615
+ );
616
+ }
617
+
589
618
  function patternNames(node) {
590
619
  switch (node.type) {
591
620
  case 'Identifier':