@transclude/core 0.2.0 → 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.
@@ -24,14 +24,10 @@
24
24
  import { Scope, collectRefs, emit, parseExpr } from './expr.js';
25
25
  import { splitInterpolations } from './interp.js';
26
26
  import { childrenOf, gatherChain } from './codegen.js';
27
+ import { parseEach } from './directives.js';
28
+ import { RAW_TEXT, VOID } from './html.js';
27
29
 
28
30
  const DIRECTIVES = new Set(['if', 'else-if', 'else', 'each', 'key']);
29
- const VOID = new Set([
30
- 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
31
- 'link', 'meta', 'param', 'source', 'track', 'wbr',
32
- ]);
33
- const RAW_TEXT = new Set(['script', 'style']);
34
- const EACH = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+of\s+([\s\S]+?)\s*$/;
35
31
 
36
32
  /**
37
33
  * @param {object[]} nodes the same parse5 nodes the renderer walked
@@ -140,13 +136,13 @@ class Bindgen {
140
136
  let inner = scope;
141
137
 
142
138
  const each = attrs.find((attr) => attr.name === 'each');
143
- const spec = each && EACH.exec(each.value);
139
+ const spec = each ? parseEach(each.value) : null;
144
140
  if (spec) {
145
- this.giveUp(spec[3], scope);
141
+ this.giveUp(spec.list, scope);
146
142
  inner = new Scope(scope);
147
143
  // The name only has to exist for collectRefs to stop calling it data.
148
- inner.declare(spec[1], spec[1]);
149
- if (spec[2]) inner.declare(spec[2], spec[2]);
144
+ inner.declare(spec.item, spec.item);
145
+ if (spec.index) inner.declare(spec.index, spec.index);
150
146
  }
151
147
 
152
148
  for (const attr of attrs) {
@@ -265,11 +261,14 @@ class Bindgen {
265
261
  * rebuild. -1 is "none of them", which an `if` with no `else` can be.
266
262
  */
267
263
  emitBranchParts(id, branches) {
268
- const pick = branches.reduceRight(
269
- (rest, branch, at) =>
270
- branch.kind === 'else' ? String(at) : `${this.js(branch.cond)} ? ${at} : ${rest}`,
271
- '-1',
272
- );
264
+ // Built from the last branch back, so each condition wraps the answer for
265
+ // everything after it. An `else` has no condition and ends the chain.
266
+ let pick = '-1';
267
+ for (let at = branches.length - 1; at >= 0; at--) {
268
+ const branch = branches[at];
269
+ if (branch.kind === 'else') pick = String(at);
270
+ else pick = `${this.js(branch.cond)} ? ${at} : ${pick}`;
271
+ }
273
272
  // The condition may read the loop variables, so pick takes them too. The
274
273
  // runtime hands every piece of a block the same arguments.
275
274
  const outer = this.frame.loopArgs;
@@ -284,7 +283,7 @@ class Bindgen {
284
283
 
285
284
  /** One part, reused for every item the loop produces. */
286
285
  emitItemPart(id, element) {
287
- const spec = EACH.exec(element.attrs.find((attr) => attr.name === 'each').value);
286
+ const spec = parseEach(element.attrs.find((attr) => attr.name === 'each').value);
288
287
  if (!spec) return;
289
288
 
290
289
  const outer = this.frame.loopArgs;
@@ -293,8 +292,8 @@ class Bindgen {
293
292
  const index = `__i${depth}`;
294
293
 
295
294
  const scope = new Scope(this.scope);
296
- scope.declare(spec[1], item);
297
- if (spec[2]) scope.declare(spec[2], index);
295
+ scope.declare(spec.item, item);
296
+ if (spec.index) scope.declare(spec.index, index);
298
297
 
299
298
  const inner = [...outer, item, index];
300
299
  // Same shape as a branch: the element itself, whose `each` is already
@@ -393,7 +392,11 @@ class Bindgen {
393
392
  return `__b[${ref}]`;
394
393
  };
395
394
  // Descending only ever happens inside `bind`, so a stable path is fine.
396
- const parentExpr = () => (ref !== null ? `__b[${ref}]` : stable ? nodeExpr : slotFor());
395
+ const parentExpr = () => {
396
+ if (ref !== null) return `__b[${ref}]`;
397
+ if (stable) return nodeExpr;
398
+ return slotFor();
399
+ };
397
400
 
398
401
  for (const attr of node.attrs ?? []) {
399
402
  if (DIRECTIVES.has(attr.name)) continue;
@@ -407,14 +410,18 @@ class Bindgen {
407
410
 
408
411
  let value;
409
412
  try {
410
- value =
411
- parts.length === 1
412
- ? this.js(parts[0].value)
413
- : parts
414
- .map((part) =>
415
- part.type === 'expr' ? `__str(${this.js(part.value)})` : JSON.stringify(part.value),
416
- )
417
- .join(' + ');
413
+ if (parts.length === 1) {
414
+ value = this.js(parts[0].value);
415
+ } else {
416
+ // Several pieces, so each one is turned into a string and the lot
417
+ // concatenated. `__str` is what makes null and undefined empty rather
418
+ // than the words.
419
+ const pieces = parts.map((part) => {
420
+ if (part.type !== 'expr') return JSON.stringify(part.value);
421
+ return `__str(${this.js(part.value)})`;
422
+ });
423
+ value = pieces.join(' + ');
424
+ }
418
425
  } catch {
419
426
  this.giveUpText(attr.value);
420
427
  continue;
@@ -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,11 +35,6 @@ export class CompileError extends Error {
39
35
  }
40
36
  }
41
37
 
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
38
  /**
48
39
  * An attribute name is emitted exactly as written, so `${…}` in one reaches the
49
40
  * page as those characters rather than as a value. Nothing downstream reads it,
@@ -66,16 +57,24 @@ function assertStaticAttrNames(el) {
66
57
  }
67
58
  }
68
59
 
60
+ /**
61
+ * @param {object[]} nodes
62
+ * @param {object} [opts]
63
+ * @returns {object} the render body, the regions, the slots, the includes and the warnings
64
+ */
69
65
  export function compileFragment(nodes, opts = {}) {
70
66
  const gen = new Codegen(opts);
71
- gen.emitChildren(nodes, gen.body, gen.rootScope, true);
67
+ gen.emitChildren(nodes, gen.body, gen.rootScope, { topLevel: true });
72
68
 
73
69
  // `<html>` is read separately, because the fragment parser drops it: a nested
74
70
  // html start tag is not something that can appear in a body, so parse5 throws
75
71
  // it away attributes and all. `splitBlocks` reads it in document mode, where
76
72
  // it is the element it names.
73
+ // `<body>` is read the same way, for the same reason.
77
74
  const htmlNode = opts.html ?? null;
78
- 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;
79
78
 
80
79
  const body = joinOut(gen.body);
81
80
  const head = joinOut(gen.head);
@@ -108,6 +107,7 @@ export function compileFragment(nodes, opts = {}) {
108
107
  title: title.code,
109
108
  hasTitle: gen.title.length > 0,
110
109
  htmlAttrs,
110
+ bodyAttrs,
111
111
  warnings: gen.warnings,
112
112
  reads: gen.reads,
113
113
  components: [...gen.used.entries()].map(([tag, ref]) => ({ tag, ref })),
@@ -252,7 +252,7 @@ class Codegen {
252
252
 
253
253
  // ---- traversal ----------------------------------------------------------
254
254
 
255
- emitChildren(nodes, out, scope, topLevel = false) {
255
+ emitChildren(nodes, out, scope, { topLevel = false } = {}) {
256
256
  let i = 0;
257
257
  while (i < nodes.length) {
258
258
  const node = nodes[i];
@@ -264,7 +264,7 @@ class Codegen {
264
264
  if (slot) {
265
265
  const target = this.slots.get(slot) ?? [];
266
266
  this.slots.set(slot, target);
267
- this.emitChildren(childrenOf(node), target, scope, false);
267
+ this.emitChildren(childrenOf(node), target, scope);
268
268
  i++;
269
269
  continue;
270
270
  }
@@ -358,7 +358,7 @@ class Codegen {
358
358
 
359
359
  emitNodeAt(node, out, scope, topLevel) {
360
360
  if (node.nodeName === '#text') {
361
- this.emitText(node.value ?? '', out, scope, node, false);
361
+ this.emitText(node.value ?? '', out, scope, node);
362
362
  return;
363
363
  }
364
364
  // Authoring comments are stripped. They still count as "insignificant" when
@@ -591,14 +591,14 @@ class Codegen {
591
591
  return;
592
592
  }
593
593
  this.c(out, `if (${filled}) { __o += ${filled}; } else {`);
594
- this.emitChildren(fallback, out, scope, false);
594
+ this.emitChildren(fallback, out, scope);
595
595
  this.c(out, `}`);
596
596
  return;
597
597
  }
598
598
 
599
599
  // A <template> carrying a directive is structural: consumed, children emitted.
600
600
  if (tag === 'template' && directivesOf(el).size > 0) {
601
- this.emitChildren(childrenOf(el), out, scope, false);
601
+ this.emitChildren(childrenOf(el), out, scope);
602
602
  return;
603
603
  }
604
604
 
@@ -628,11 +628,11 @@ class Codegen {
628
628
  for (const child of childrenOf(el)) {
629
629
  if (child.nodeName === '#text') {
630
630
  assertRawTextSafe(tag, child.value ?? '', el);
631
- this.emitText(child.value ?? '', target, scope, child, true);
631
+ this.emitText(child.value ?? '', target, scope, child, { raw: true });
632
632
  }
633
633
  }
634
634
  } else {
635
- this.emitChildren(childrenOf(el), target, scope, false);
635
+ this.emitChildren(childrenOf(el), target, scope);
636
636
  }
637
637
 
638
638
  this.s(target, `</${tag}>`);
@@ -655,7 +655,7 @@ class Codegen {
655
655
  this.c(out, `__o += __sh(${ref}, {${props}}${this.fragments ? ', __fragment' : ''});`);
656
656
 
657
657
  // Light DOM children fill <slot>.
658
- this.emitChildren(childrenOf(el), out, scope, false);
658
+ this.emitChildren(childrenOf(el), out, scope);
659
659
 
660
660
  this.s(out, `</${tag}>`);
661
661
  }
@@ -773,7 +773,7 @@ class Codegen {
773
773
  const uid = ++this.uid;
774
774
  if (children.length) {
775
775
  this.c(out, `const __fb${uid} = (() => { let __o = '';`);
776
- this.emitChildren(children, out, scope, false);
776
+ this.emitChildren(children, out, scope);
777
777
  this.c(out, `return __o; })();`);
778
778
  }
779
779
 
@@ -795,7 +795,7 @@ class Codegen {
795
795
  const children = childrenOf(el);
796
796
  if (children.length) {
797
797
  this.c(out, `const __sl${id} = (() => { let __o = '';`);
798
- this.emitChildren(children, out, scope, false);
798
+ this.emitChildren(children, out, scope);
799
799
  this.c(out, `return __o; })();`);
800
800
  }
801
801
 
@@ -847,15 +847,17 @@ class Codegen {
847
847
  }
848
848
 
849
849
  /**
850
- * `<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>`.
851
852
  *
852
853
  * An object rather than serialized markup, because the chain merges these by
853
854
  * name: a root layout setting the theme and a page setting `dir` must both
854
855
  * survive, and two `data-theme` attributes in one tag would leave the parser
855
856
  * taking the first, which is the outermost. `renderDocument` serializes.
856
857
  */
857
- htmlAttrsJs(el, scope) {
858
- // <html> is read by a second parse, so it never reaches emitElement.
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.
859
861
  assertStaticAttrNames(el);
860
862
  const pairs = el.attrs
861
863
  .filter((attr) => !DIRECTIVES.has(attr.name))
@@ -883,7 +885,7 @@ class Codegen {
883
885
  .join(' + ');
884
886
  }
885
887
 
886
- emitText(value, out, scope, node, raw) {
888
+ emitText(value, out, scope, node, { raw = false } = {}) {
887
889
  const start = node?.sourceCodeLocation?.startLine ?? this.at;
888
890
  let line = start;
889
891
 
@@ -943,7 +945,9 @@ function assertRawTextSafe(tag, text, el) {
943
945
  function isJsonCall(source) {
944
946
  try {
945
947
  const node = parseExpr(source);
946
- 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';
947
951
  } catch {
948
952
  return false;
949
953
  }
@@ -958,7 +962,7 @@ export const ANCHOR_CLOSE = '<!--]-->';
958
962
  *
959
963
  * @param {object[]} nodes
960
964
  * @param {number} i where the `if` is
961
- * @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}
962
966
  * null when the element carries no `if`, so there is no chain to gather
963
967
  */
964
968
  export function gatherChain(nodes, i) {
@@ -977,7 +981,10 @@ export function gatherChain(nodes, i) {
977
981
  continue;
978
982
  }
979
983
  if (d?.has('else')) {
980
- 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 });
981
988
  next = k + 1;
982
989
  }
983
990
  break;
@@ -1020,14 +1027,14 @@ function nextSignificant(nodes, from) {
1020
1027
 
1021
1028
 
1022
1029
  function parseEach(value, node) {
1023
- const m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+of\s+([\s\S]+?)\s*$/.exec(value);
1024
- if (!m) {
1030
+ const spec = readEach(value);
1031
+ if (!spec) {
1025
1032
  throw new CompileError(
1026
1033
  `each="${value}" is malformed. Expected each="item of list" or each="item, index of list"`,
1027
1034
  node,
1028
1035
  );
1029
1036
  }
1030
- return { item: m[1], index: m[2] || null, list: m[3] };
1037
+ return spec;
1031
1038
  }
1032
1039
 
1033
1040
  // ---- output ---------------------------------------------------------------
@@ -1076,11 +1083,3 @@ function joinOut(entries) {
1076
1083
  return { code: lines.join('\n'), at };
1077
1084
  }
1078
1085
 
1079
- // parse5 hands us decoded text, so static output has to be re-encoded.
1080
- function escapeText(value) {
1081
- return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1082
- }
1083
-
1084
- function escapeAttr(value) {
1085
- return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
1086
- }
@@ -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 {
@@ -21,6 +22,7 @@ export { CompileError, ScriptError };
21
22
  const PAGE_EXPORTS = new Set([
22
23
  'css', 'load', 'render', 'renderHead', 'renderTitle', 'renderHtmlAttrs',
23
24
  'layouts', 'client', 'elements', 'headScript', 'hasTitle', 'includes',
25
+ 'renderBodyAttrs',
24
26
  ]);
25
27
  const COMPONENT_EXPORTS = new Set([
26
28
  'tag', 'light', 'css', 'elements', 'propDefs', 'propAttrs', 'stateDefs', 'members', 'render',
@@ -113,10 +115,17 @@ export function splitBlocks(source) {
113
115
  // away with its attributes. In document mode it is the element it names, and a
114
116
  // `<html>` inside a script block or a comment is still not one, because this
115
117
  // is the real parser rather than a search for a string.
116
- const html =
117
- parse(source, { sourceCodeLocationInfo: true }).childNodes.find((n) => n.nodeName === 'html') ??
118
- null;
119
- 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 };
120
129
 
121
130
  for (const node of doc.childNodes) {
122
131
  if (node.nodeName === 'script') {
@@ -367,6 +376,21 @@ const MARK = {
367
376
  title: '/*@transclude:title*/',
368
377
  };
369
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
+ */
370
394
  export function compilePage(
371
395
  source,
372
396
  {
@@ -392,7 +416,13 @@ export function compilePage(
392
416
  assertNoCollisions(server.declared ?? [], PAGE_EXPORTS, where, 'declares');
393
417
  assertNoActionsObject(server.exports, where);
394
418
 
395
- 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
+ });
396
426
  assertIncludesResolve(template.regionIncludes, template.regions);
397
427
 
398
428
  const code = `
@@ -426,6 +456,10 @@ export function renderHtmlAttrs(__d) {
426
456
  return ${template.htmlAttrs ?? '{}'};
427
457
  }
428
458
 
459
+ export function renderBodyAttrs(__d) {
460
+ return ${template.bodyAttrs ?? '{}'};
461
+ }
462
+
429
463
  export function renderHead(__d) {
430
464
  let __o = '';
431
465
  ${MARK.head}
@@ -509,6 +543,7 @@ export function compileLayout(source, { id, components = new Map(), shadowTags =
509
543
  page: true,
510
544
  layout: true,
511
545
  html: blocks.html,
546
+ body: blocks.body,
512
547
  });
513
548
 
514
549
  const warnings = [...template.warnings];
@@ -541,6 +576,10 @@ export function renderHtmlAttrs(__d) {
541
576
  return ${template.htmlAttrs ?? '{}'};
542
577
  }
543
578
 
579
+ export function renderBodyAttrs(__d) {
580
+ return ${template.bodyAttrs ?? '{}'};
581
+ }
582
+
544
583
  export function renderHead(__d) {
545
584
  let __o = '';
546
585
  ${indent(template.head)}
@@ -553,7 +592,7 @@ ${slotBodies(template)}
553
592
  return __out;
554
593
  }
555
594
 
556
- export default { css, headScript, elements, hasTitle, load, renderTitle, renderHead, renderHtmlAttrs, render };
595
+ export default { css, headScript, elements, hasTitle, load, renderTitle, renderHead, renderHtmlAttrs, renderBodyAttrs, render };
557
596
  `;
558
597
 
559
598
  return { code, warnings, components: template.components.map((c) => c.tag) };
@@ -858,8 +897,7 @@ function headScript(blocks, where) {
858
897
  /** A static attribute, escaped the way an HTML serializer must. */
859
898
  function serializeAttr({ name, value }) {
860
899
  if (value === '') return ` ${name}`;
861
- const escaped = value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
862
- return ` ${name}="${escaped}"`;
900
+ return ` ${name}="${escapeAttr(value)}"`;
863
901
  }
864
902
 
865
903
  function elementsExport(used) {
@@ -369,6 +369,9 @@ function scopedNames(statements) {
369
369
  return (statements ?? []).flatMap(declaredNames);
370
370
  }
371
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
+
372
375
  /**
373
376
  * Identifiers a subtree reads from outside itself.
374
377
  *
@@ -406,8 +409,12 @@ function freeNames(node, bound, out) {
406
409
  case 'ArrowFunctionExpression': {
407
410
  const inner = new Set(bound);
408
411
  if (node.id) inner.add(node.id.name);
409
- for (const param of node.params) for (const name of patternNames(param)) inner.add(name);
410
- 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
+ }
411
418
  // A default is evaluated in the function's own scope, so it sees the params.
412
419
  for (const param of node.params) freeNames(param, inner, out);
413
420
  freeNames(node.body, inner, out);
@@ -463,7 +470,7 @@ function freeNames(node, bound, out) {
463
470
 
464
471
  default:
465
472
  for (const key of Object.keys(node)) {
466
- if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue;
473
+ if (NODE_BOOKKEEPING.has(key)) continue;
467
474
  freeNames(node[key], bound, out);
468
475
  }
469
476
  return out;