@transclude/core 0.2.0 → 0.4.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.
- package/README.md +23 -5
- package/bin/build.js +35 -3
- package/bin/check.js +30 -8
- package/bin/dev.js +40 -2
- package/bin/release.js +53 -5
- package/package.json +3 -2
- package/skills/transclude/SKILL.md +20 -3
- package/skills/transclude/references/elements.md +43 -0
- package/skills/transclude/references/server.md +1 -0
- package/src/address.js +9 -2
- package/src/app.js +110 -81
- package/src/compiler/ambient.js +99 -0
- package/src/compiler/bind.js +34 -27
- package/src/compiler/codegen.js +40 -41
- package/src/compiler/directives.js +29 -0
- package/src/compiler/expr.js +10 -1
- package/src/compiler/html.js +41 -0
- package/src/compiler/index.js +46 -8
- package/src/compiler/script.js +10 -3
- package/src/compiler/shim.js +14 -41
- package/src/compiler/types.js +32 -5
- package/src/csp.js +7 -1
- package/src/document.js +63 -12
- package/src/extract.js +9 -8
- package/src/icons.js +174 -0
- package/src/plugin.js +28 -7
- package/src/precache.js +11 -1
- package/src/project.js +1 -0
- package/src/proxy.js +9 -1
- package/src/rewrite.js +12 -5
- package/src/runtime/index.js +8 -5
- package/src/sitemap.js +3 -3
- package/src/static-cache.js +4 -1
- package/src/typecheck.js +104 -10
package/src/compiler/codegen.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
858
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1024
|
-
if (!
|
|
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
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
function escapeAttr(value) {
|
|
1085
|
-
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
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
|
+
}
|
package/src/compiler/expr.js
CHANGED
|
@@ -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
|
|
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. `&` 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
41
|
+
}
|
package/src/compiler/index.js
CHANGED
|
@@ -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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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, {
|
|
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
|
-
|
|
862
|
-
return ` ${name}="${escaped}"`;
|
|
900
|
+
return ` ${name}="${escapeAttr(value)}"`;
|
|
863
901
|
}
|
|
864
902
|
|
|
865
903
|
function elementsExport(used) {
|
package/src/compiler/script.js
CHANGED
|
@@ -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)
|
|
410
|
-
|
|
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
|
|
473
|
+
if (NODE_BOOKKEEPING.has(key)) continue;
|
|
467
474
|
freeNames(node[key], bound, out);
|
|
468
475
|
}
|
|
469
476
|
return out;
|
package/src/compiler/shim.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
// came from.
|
|
15
15
|
|
|
16
16
|
import { parse, parseExpressionAt } from 'acorn';
|
|
17
|
+
import { ambientJsdoc } from './ambient.js';
|
|
18
|
+
import { parseEach } from './directives.js';
|
|
17
19
|
import { childrenOf } from './codegen.js';
|
|
18
20
|
import { splitInterpolations } from './interp.js';
|
|
19
21
|
import { splitBlocks } from './index.js';
|
|
@@ -46,23 +48,7 @@ const PASS_THROUGH = /^(?:data|aria|hx)-/;
|
|
|
46
48
|
* it type-checked happily and caught nothing. The endpoint shim
|
|
47
49
|
* did exactly that.
|
|
48
50
|
*/
|
|
49
|
-
const COOKIES_TYPEDEF =
|
|
50
|
-
'/**\n' +
|
|
51
|
-
' * @typedef {{ path?: string; domain?: string; maxAge?: number; expires?: Date;\n' +
|
|
52
|
-
' * httpOnly?: boolean; secure?: boolean; sameSite?: "Strict" | "Lax" | "None" }} __CookieOptions\n' +
|
|
53
|
-
' */\n' +
|
|
54
|
-
'/**\n' +
|
|
55
|
-
' * @typedef {{\n' +
|
|
56
|
-
' * get(name: string): string | undefined;\n' +
|
|
57
|
-
' * all(): Record<string, string>;\n' +
|
|
58
|
-
' * set(name: string, value: string, options?: __CookieOptions): void;\n' +
|
|
59
|
-
' * delete(name: string, options?: __CookieOptions): void;\n' +
|
|
60
|
-
' * signed: {\n' +
|
|
61
|
-
' * get(name: string): Promise<string | undefined>;\n' +
|
|
62
|
-
' * all(): Promise<Record<string, string>>;\n' +
|
|
63
|
-
' * set(name: string, value: string, options?: __CookieOptions): Promise<void>;\n' +
|
|
64
|
-
' * };\n' +
|
|
65
|
-
' * }} __Cookies\n */\n\n';
|
|
51
|
+
const COOKIES_TYPEDEF = `${ambientJsdoc(['__CookieOptions', '__Cookies'])}\n`;
|
|
66
52
|
|
|
67
53
|
class Builder {
|
|
68
54
|
constructor() {
|
|
@@ -278,20 +264,9 @@ export function buildShim(source, { kind, shadow = false, contextType = null, co
|
|
|
278
264
|
// types its own.
|
|
279
265
|
out.add('export {};\n');
|
|
280
266
|
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
// that came straight from an object literal in a .js file as open for expando
|
|
285
|
-
// properties, so reading an undeclared one is allowed. Remapping the keys gives
|
|
286
|
-
// an ordinary object type, where it is not.
|
|
287
|
-
//
|
|
288
|
-
// The conditional widens a bare `[]`, which otherwise infers `never[]` and
|
|
289
|
-
// turns "no annotation" from "less checking" into a page of errors about a
|
|
290
|
-
// type nobody wrote.
|
|
291
|
-
out.add(
|
|
292
|
-
'/**\n * @template T\n' +
|
|
293
|
-
' * @typedef {{ [K in keyof T]: T[K] extends never[] ? any[] : T[K] }} __Shape\n */\n',
|
|
294
|
-
);
|
|
267
|
+
// Both jobs `__Shape` does are about letting the author write plain JS, and
|
|
268
|
+
// both are written out in `ambient.js`.
|
|
269
|
+
out.add(ambientJsdoc(['__Shape']));
|
|
295
270
|
|
|
296
271
|
out.add(COOKIES_TYPEDEF);
|
|
297
272
|
|
|
@@ -573,20 +548,18 @@ function emitNodes(nodes, out, scope, components, depth) {
|
|
|
573
548
|
let closes = 0;
|
|
574
549
|
|
|
575
550
|
if (each) {
|
|
576
|
-
const spec =
|
|
577
|
-
each.value,
|
|
578
|
-
);
|
|
551
|
+
const spec = parseEach(each.value);
|
|
579
552
|
if (spec) {
|
|
580
|
-
const listOffset = attrValueOffset(node, 'each') + each.value.indexOf(spec
|
|
553
|
+
const listOffset = attrValueOffset(node, 'each') + each.value.indexOf(spec.list);
|
|
581
554
|
indent(out, depth);
|
|
582
|
-
out.add(`for (const ${spec
|
|
583
|
-
emitExpression(spec
|
|
555
|
+
out.add(`for (const ${spec.item} of `);
|
|
556
|
+
emitExpression(spec.list, listOffset, out, scope);
|
|
584
557
|
out.add(') {\n');
|
|
585
|
-
inner.add(spec
|
|
586
|
-
if (spec
|
|
558
|
+
inner.add(spec.item);
|
|
559
|
+
if (spec.index) {
|
|
587
560
|
indent(out, depth + 1);
|
|
588
|
-
out.add(`const ${spec
|
|
589
|
-
inner.add(spec
|
|
561
|
+
out.add(`const ${spec.index}: number = 0;\n`);
|
|
562
|
+
inner.add(spec.index);
|
|
590
563
|
}
|
|
591
564
|
closes++;
|
|
592
565
|
}
|
package/src/compiler/types.js
CHANGED
|
@@ -3,15 +3,24 @@
|
|
|
3
3
|
// Everything here is a type *string* produced by tsc's own printer, so this file
|
|
4
4
|
// formats and names things and nothing else. Nothing infers.
|
|
5
5
|
|
|
6
|
+
import { ambientDeclarations } from './ambient.js';
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* @param {{ components?: object[], partials?: object[], layouts?: object[],
|
|
8
|
-
* pages?: object[] }} [what] each
|
|
9
|
-
* its `
|
|
10
|
-
*
|
|
11
|
-
* callers change with it.
|
|
10
|
+
* pages?: object[], types?: {name: string, type: string}[] }} [what] each
|
|
11
|
+
* element carries its `tag`, its props `type`, its `members`, its `state` and
|
|
12
|
+
* whether anything `upgrades` it. `partials` is the light elements: the key is
|
|
13
|
+
* the old name and is load-bearing until the callers change with it. `types`
|
|
14
|
+
* are the names the app declared that the strings below use.
|
|
12
15
|
* @returns {string} the contents of transclude-env.d.ts
|
|
13
16
|
*/
|
|
14
|
-
export function emitTypes({
|
|
17
|
+
export function emitTypes({
|
|
18
|
+
components = [],
|
|
19
|
+
partials = [],
|
|
20
|
+
layouts = [],
|
|
21
|
+
pages = [],
|
|
22
|
+
types = [],
|
|
23
|
+
} = {}) {
|
|
15
24
|
const out = [
|
|
16
25
|
'// Generated by transclude. Do not edit. `npm run check` rewrites it.',
|
|
17
26
|
'//',
|
|
@@ -21,6 +30,9 @@ export function emitTypes({ components = [], partials = [], layouts = [], pages
|
|
|
21
30
|
'',
|
|
22
31
|
];
|
|
23
32
|
|
|
33
|
+
// Where the declarations go once it is known which are needed.
|
|
34
|
+
const head = out.length;
|
|
35
|
+
|
|
24
36
|
for (const { tag, type, members, state } of [...components, ...partials]) {
|
|
25
37
|
out.push(`/** Properties of \`<${tag}>\`, from its <script properties> block. */`);
|
|
26
38
|
out.push(`export type ${interfaceName(tag)}Props = ${pretty(type)};`);
|
|
@@ -83,6 +95,21 @@ export function emitTypes({ components = [], partials = [], layouts = [], pages
|
|
|
83
95
|
out.push('');
|
|
84
96
|
}
|
|
85
97
|
|
|
98
|
+
// Written last and put first. What has to be declared is whatever the types
|
|
99
|
+
// above ended up naming, which is not known until they are all built.
|
|
100
|
+
const body = out.slice(head).join('\n');
|
|
101
|
+
const declared = [
|
|
102
|
+
...ambientDeclarations(body, (type) => pretty(type)),
|
|
103
|
+
...(types.length
|
|
104
|
+
? [
|
|
105
|
+
'// Declared by the app, in the file each was written in.',
|
|
106
|
+
...types.map(({ name, type }) => `type ${name} = ${pretty(type)};`),
|
|
107
|
+
'',
|
|
108
|
+
]
|
|
109
|
+
: []),
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
out.splice(head, 0, ...declared);
|
|
86
113
|
return out.join('\n');
|
|
87
114
|
}
|
|
88
115
|
|
package/src/csp.js
CHANGED
|
@@ -104,9 +104,15 @@ export async function policyFor(html, { directives = CSP_DEFAULTS } = {}) {
|
|
|
104
104
|
inline.filter((one) => one.kind === 'style').map((one) => sha256(one.body)),
|
|
105
105
|
);
|
|
106
106
|
|
|
107
|
+
// Which set `'hashes'` stands for, by directive. Anything else gets none:
|
|
108
|
+
// there is nothing to hash for `default-src`, and a directive carrying a hash
|
|
109
|
+
// ignores `'unsafe-inline'` outright, so handing it an empty list is wrong in a
|
|
110
|
+
// way that only shows up in a browser.
|
|
111
|
+
const hashesFor = { 'script-src': scripts, 'style-src': styles };
|
|
112
|
+
|
|
107
113
|
const parts = [];
|
|
108
114
|
for (const [name, sources] of Object.entries(directives)) {
|
|
109
|
-
const hashes = name
|
|
115
|
+
const hashes = hashesFor[name] ?? [];
|
|
110
116
|
const resolved = sources.flatMap((source) => (source === "'hashes'" ? hashes : [source]));
|
|
111
117
|
|
|
112
118
|
// A directive left with nothing is dropped. An empty source list means
|