@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.
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/bin/build.js +469 -0
- package/bin/check.js +78 -0
- package/bin/dev.js +348 -0
- package/bin/release.js +176 -0
- package/bin/serve.bun.js +15 -0
- package/bin/serve.deno.js +15 -0
- package/bin/serve.js +12 -0
- package/editor/server.js +172 -0
- package/editor/vscode/extension.js +49 -0
- package/editor/vscode/package.json +32 -0
- package/editor/vscode/syntaxes/transclude.injection.json +41 -0
- package/package.json +82 -0
- package/src/address.js +183 -0
- package/src/app.js +492 -0
- package/src/cache.js +137 -0
- package/src/compiler/bind.js +496 -0
- package/src/compiler/codegen.js +1061 -0
- package/src/compiler/expr.js +221 -0
- package/src/compiler/index.js +964 -0
- package/src/compiler/interp.js +82 -0
- package/src/compiler/script.js +620 -0
- package/src/compiler/shim.js +756 -0
- package/src/compiler/sourcemap.js +140 -0
- package/src/compiler/types.js +163 -0
- package/src/compress.js +104 -0
- package/src/cookies.js +157 -0
- package/src/csp.js +192 -0
- package/src/document.js +604 -0
- package/src/extract.js +339 -0
- package/src/feed.js +194 -0
- package/src/include.js +89 -0
- package/src/lookup.js +49 -0
- package/src/negotiate.js +95 -0
- package/src/plugin.js +423 -0
- package/src/pool.js +29 -0
- package/src/precache.js +68 -0
- package/src/production.js +159 -0
- package/src/project.js +110 -0
- package/src/proxy.js +319 -0
- package/src/public-files.js +77 -0
- package/src/rewrite.js +281 -0
- package/src/routes.js +199 -0
- package/src/runtime/index.js +1345 -0
- package/src/server.js +183 -0
- package/src/sitemap.js +124 -0
- package/src/static-cache.js +170 -0
- package/src/typecheck.js +492 -0
- package/src/worker.js +87 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Splits raw text / attribute values into static + `${expr}` parts.
|
|
2
|
+
// Brace matching is quote-aware so `${a ? "}" : "x"}` does not terminate early.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {{ type: 'text', value: string } | { type: 'expr', value: string }} Part
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Text and `${expr}` in source order.
|
|
10
|
+
*
|
|
11
|
+
* There is no escape for a literal `${`, so anything documenting the syntax has
|
|
12
|
+
* to pass its examples in as data rather than write them in a template.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} str raw text or an attribute value
|
|
15
|
+
* @returns {Part[]} empty only for an empty string
|
|
16
|
+
* @throws if a `${` is never closed
|
|
17
|
+
*/
|
|
18
|
+
export function splitInterpolations(str) {
|
|
19
|
+
const parts = [];
|
|
20
|
+
let text = '';
|
|
21
|
+
let i = 0;
|
|
22
|
+
|
|
23
|
+
while (i < str.length) {
|
|
24
|
+
// \${ escapes an interpolation
|
|
25
|
+
if (str[i] === '\\' && str[i + 1] === '$' && str[i + 2] === '{') {
|
|
26
|
+
text += '${';
|
|
27
|
+
i += 3;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (str[i] === '$' && str[i + 1] === '{') {
|
|
31
|
+
if (text) {
|
|
32
|
+
parts.push({ type: 'text', value: text });
|
|
33
|
+
text = '';
|
|
34
|
+
}
|
|
35
|
+
const { expr, end } = readExpr(str, i + 2);
|
|
36
|
+
parts.push({ type: 'expr', value: expr });
|
|
37
|
+
i = end;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
text += str[i];
|
|
41
|
+
i++;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (text) parts.push({ type: 'text', value: text });
|
|
45
|
+
return parts;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether a string holds an interpolation, without parsing one.
|
|
50
|
+
*
|
|
51
|
+
* A `\${` is a literal and does not count, which is what the leading character
|
|
52
|
+
* in the pattern is checking.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} str
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
export function hasInterpolation(str) {
|
|
58
|
+
return /(^|[^\\])\$\{/.test(str);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readExpr(str, start) {
|
|
62
|
+
let depth = 1;
|
|
63
|
+
let quote = null;
|
|
64
|
+
let i = start;
|
|
65
|
+
|
|
66
|
+
while (i < str.length) {
|
|
67
|
+
const c = str[i];
|
|
68
|
+
if (quote) {
|
|
69
|
+
if (c === '\\') i++;
|
|
70
|
+
else if (c === quote) quote = null;
|
|
71
|
+
} else if (c === '"' || c === "'" || c === '`') {
|
|
72
|
+
quote = c;
|
|
73
|
+
} else if (c === '{') {
|
|
74
|
+
depth++;
|
|
75
|
+
} else if (c === '}') {
|
|
76
|
+
depth--;
|
|
77
|
+
if (depth === 0) return { expr: str.slice(start, i), end: i + 1 };
|
|
78
|
+
}
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
throw new Error('unterminated ${ ... } interpolation');
|
|
82
|
+
}
|
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
// Real ESM handling for <script server>, <script props> and <script>.
|
|
2
|
+
//
|
|
3
|
+
// These blocks are authored as modules so editors treat them as JS, but they end
|
|
4
|
+
// up spliced into a generated module (or, for client blocks, into a function
|
|
5
|
+
// body). Doing that with regex silently mangles `export default` inside a
|
|
6
|
+
// comment, multi-line imports, and anything else that merely looks like one.
|
|
7
|
+
// acorn gives exact node ranges, so every rewrite here is a string splice at a
|
|
8
|
+
// position the parser vouched for.
|
|
9
|
+
|
|
10
|
+
import { parse } from 'acorn';
|
|
11
|
+
|
|
12
|
+
export class ScriptError extends Error {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'ScriptError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PARSE_OPTIONS = {
|
|
20
|
+
ecmaVersion: 'latest',
|
|
21
|
+
sourceType: 'module',
|
|
22
|
+
allowAwaitOutsideFunction: true,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Rewrites `export default <thing>` to `const <name> = <thing>` in place,
|
|
27
|
+
* leaving imports and named exports exactly where the author put them.
|
|
28
|
+
* Returns the exported names so callers can check them against the names the
|
|
29
|
+
* generated module already uses.
|
|
30
|
+
*
|
|
31
|
+
* @param {{ code: string, line?: number }} block
|
|
32
|
+
* @param {string} name what to bind the default export to
|
|
33
|
+
* @param {string} label for an error
|
|
34
|
+
* @param {{ flags?: string[] }} [options]
|
|
35
|
+
* @returns {{ code: string, exports: string[],
|
|
36
|
+
* imports: Array<{ source: string, specifiers: string }>,
|
|
37
|
+
* defaultNode: object|null, flags: object }}
|
|
38
|
+
*/
|
|
39
|
+
export function bindDefaultExport(block, name, label, { flags = [] } = {}) {
|
|
40
|
+
const { code: source, line = 1 } = block;
|
|
41
|
+
const ast = parseOrThrow(source, label, line);
|
|
42
|
+
|
|
43
|
+
// A flag is a fact about the element rather than part of this block's own
|
|
44
|
+
// shape. Each is read out here and blanked, not removed, so every offset
|
|
45
|
+
// after it still points where it did.
|
|
46
|
+
const found = {};
|
|
47
|
+
const cuts = [];
|
|
48
|
+
for (const flag of flags) {
|
|
49
|
+
const hit = literalExport(ast, flag, source, line, label);
|
|
50
|
+
found[flag] = hit ? hit.value : null;
|
|
51
|
+
if (hit) cuts.push([hit.start, hit.end]);
|
|
52
|
+
}
|
|
53
|
+
const code = cuts.length ? blank(source, cuts) : source;
|
|
54
|
+
|
|
55
|
+
// acorn rejects a duplicate `default` itself, so there is at most one.
|
|
56
|
+
const node = ast.body.find((s) => s.type === 'ExportDefaultDeclaration') ?? null;
|
|
57
|
+
const exports = namedExportsOf(ast, source, line, label).filter((n) => !flags.includes(n));
|
|
58
|
+
const imports = importsOf(ast);
|
|
59
|
+
|
|
60
|
+
if (!node) {
|
|
61
|
+
return { code: `${code}\nconst ${name} = null;`, exports, imports, defaultNode: null, flags: found };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Slicing the *declaration* rather than the statement means
|
|
65
|
+
// `export default function f() {}` becomes `const x = function f() {}`,
|
|
66
|
+
// which is a valid expression for every form of default export.
|
|
67
|
+
const rewritten =
|
|
68
|
+
code.slice(0, node.start) +
|
|
69
|
+
`const ${name} = ` +
|
|
70
|
+
code.slice(node.declaration.start, node.declaration.end) +
|
|
71
|
+
';' +
|
|
72
|
+
code.slice(node.end);
|
|
73
|
+
|
|
74
|
+
return { code: rewritten, exports, imports, defaultNode: node.declaration, flags: found };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `export const NAME = true` or `= false`, read out of a block.
|
|
79
|
+
*
|
|
80
|
+
* `null` when the block does not declare it, which is not the same as declaring
|
|
81
|
+
* it false. A component may say so in one block or the other, and telling those
|
|
82
|
+
* apart is what makes "declared in both" reportable.
|
|
83
|
+
*/
|
|
84
|
+
function literalExport(ast, flag, code, line, label) {
|
|
85
|
+
for (const statement of ast.body) {
|
|
86
|
+
if (!statement.type.startsWith('Export')) continue;
|
|
87
|
+
|
|
88
|
+
const value = booleanExport(statement, flag);
|
|
89
|
+
if (value !== null) return { value, start: statement.start, end: statement.end };
|
|
90
|
+
|
|
91
|
+
if (namesExport(statement, flag)) {
|
|
92
|
+
throw new ScriptError(
|
|
93
|
+
`${label}: \`${flag}\` must be \`true\` or \`false\`. It becomes a static ` +
|
|
94
|
+
`class field, the same for every element of this tag, so it cannot be decided at ` +
|
|
95
|
+
`run time (line ${lineOf(statement, code, line)})`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Turns a client <script> into a function body: imports are lifted out (they
|
|
105
|
+
* have to stay at module top level) and everything else is left alone, blanked
|
|
106
|
+
* in place so line and column numbers still line up with the .html file.
|
|
107
|
+
*
|
|
108
|
+
* `lift` names one export that is not setup code: the element's members. It goes
|
|
109
|
+
* to module scope, and so does anything it reads, because a prototype is shared by
|
|
110
|
+
* every instance and the function body is not.
|
|
111
|
+
*
|
|
112
|
+
* @param {Array<{ code: string, line?: number }>} blocks
|
|
113
|
+
* @param {string} label
|
|
114
|
+
* @param {{ lift?: object|null, binding?: string, flags?: string[] }} [options]
|
|
115
|
+
* @returns {{ imports: string, hoisted: string, body: string,
|
|
116
|
+
* lifted: object|null, flags: object, warnings: string[] }}
|
|
117
|
+
*/
|
|
118
|
+
export function toFunctionBody(blocks, label, { lift = null, binding = '__members', flags = [] } = {}) {
|
|
119
|
+
const imports = [];
|
|
120
|
+
const hoisted = [];
|
|
121
|
+
const bodies = [];
|
|
122
|
+
const warnings = [];
|
|
123
|
+
let lifted = null;
|
|
124
|
+
// `null` until a block declares one, so "not said" and "said false" differ.
|
|
125
|
+
const found = Object.fromEntries(flags.map((flag) => [flag, null]));
|
|
126
|
+
|
|
127
|
+
for (const block of blocks) {
|
|
128
|
+
const { code, line = 1 } = block;
|
|
129
|
+
// This block becomes a function body, so a top-level `return` is legal here
|
|
130
|
+
// even though it would not be in a module. That is how cleanup is declared.
|
|
131
|
+
const ast = parseOrThrow(code, label, line, { allowReturnOutsideFunction: true });
|
|
132
|
+
const cuts = [];
|
|
133
|
+
|
|
134
|
+
warnUnsignalled(ast, code, line, warnings);
|
|
135
|
+
|
|
136
|
+
const plan = lift ? planLift(ast, lift) : null;
|
|
137
|
+
if (plan) {
|
|
138
|
+
if (lifted) {
|
|
139
|
+
throw new ScriptError(
|
|
140
|
+
`${label}: \`${lift}\` is exported twice (line ${lineOf(plan.statement, code, line)})`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (plan.reaches.length) {
|
|
144
|
+
throw new ScriptError(
|
|
145
|
+
`${label}: \`${lift}\` reaches \`${plan.reaches.join('`, `')}\`, which exists once ` +
|
|
146
|
+
`per element. Members live on the prototype and are shared by every instance, ` +
|
|
147
|
+
`so they reach their own element through \`this\` instead ` +
|
|
148
|
+
`(line ${lineOf(plan.statement, code, line)})`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Order is preserved, so the hoisted code means exactly what it would have
|
|
153
|
+
// meant written at the top of the block.
|
|
154
|
+
for (const dependency of plan.deps) {
|
|
155
|
+
hoisted.push(code.slice(dependency.start, dependency.end));
|
|
156
|
+
cuts.push([dependency.start, dependency.end]);
|
|
157
|
+
}
|
|
158
|
+
hoisted.push(`const ${binding} = ${code.slice(plan.init.start, plan.init.end)};`);
|
|
159
|
+
cuts.push([plan.statement.start, plan.statement.end]);
|
|
160
|
+
lifted = plan.init;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const statement of ast.body) {
|
|
164
|
+
if (statement.type === 'ImportDeclaration') {
|
|
165
|
+
imports.push(code.slice(statement.start, statement.end));
|
|
166
|
+
cuts.push([statement.start, statement.end]);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (statement.type.startsWith('Export')) {
|
|
170
|
+
if (plan && statement === plan.statement) continue;
|
|
171
|
+
|
|
172
|
+
// A flag decides something about the tag rather than about one element,
|
|
173
|
+
// so it has to be a literal. A computed value would look like a
|
|
174
|
+
// per-element choice and could not be one.
|
|
175
|
+
let taken = false;
|
|
176
|
+
for (const flag of flags) {
|
|
177
|
+
const value = booleanExport(statement, flag);
|
|
178
|
+
if (value !== null) {
|
|
179
|
+
found[flag] = value;
|
|
180
|
+
cuts.push([statement.start, statement.end]);
|
|
181
|
+
taken = true;
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
if (namesExport(statement, flag)) {
|
|
185
|
+
throw new ScriptError(
|
|
186
|
+
`${label}: \`${flag}\` must be \`true\` or \`false\`. It decides something ` +
|
|
187
|
+
`about the tag, the same for every element of it, so it cannot be decided at ` +
|
|
188
|
+
`run time (line ${lineOf(statement, code, line)})`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (taken) continue;
|
|
193
|
+
|
|
194
|
+
throw new ScriptError(
|
|
195
|
+
`${label}: a client <script> runs as setup code, so it cannot export` +
|
|
196
|
+
(lift ? ` anything but \`${[lift, ...flags].join('`, `')}\`` : '') +
|
|
197
|
+
` (line ${lineOf(statement, code, line)})`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
bodies.push(blank(code, cuts));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
imports: imports.join('\n'),
|
|
207
|
+
hoisted: hoisted.join('\n\n'),
|
|
208
|
+
body: bodies.join('\n'),
|
|
209
|
+
lifted,
|
|
210
|
+
flags: found,
|
|
211
|
+
warnings,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Targets that outlive the element listening to them. */
|
|
216
|
+
const OUTLIVES = new Set(['document', 'window', 'globalThis', 'screen', 'navigator', 'visualViewport']);
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A listener on something that outlives this element, with no `signal`.
|
|
220
|
+
*
|
|
221
|
+
* A listener on `host` is collected with the element, so it needs nothing. One
|
|
222
|
+
* on `document` is not: the element goes and the listener stays, holding the
|
|
223
|
+
* closure and everything it captured, and every element after it adds another.
|
|
224
|
+
* Nothing reports that, which is why it is worth saying at compile time.
|
|
225
|
+
*/
|
|
226
|
+
function warnUnsignalled(ast, code, line, warnings) {
|
|
227
|
+
const seen = new Set();
|
|
228
|
+
|
|
229
|
+
const looksSignalled = (arg) => {
|
|
230
|
+
if (!arg) return false;
|
|
231
|
+
// A boolean third argument is `capture`, which is the old spelling and
|
|
232
|
+
// carries no signal.
|
|
233
|
+
if (arg.type === 'Literal' && typeof arg.value === 'boolean') return false;
|
|
234
|
+
// Anything else that is not a plain object could hold one, so this only
|
|
235
|
+
// reports the shapes it can read.
|
|
236
|
+
if (arg.type !== 'ObjectExpression') return true;
|
|
237
|
+
return arg.properties.some(
|
|
238
|
+
(property) =>
|
|
239
|
+
property.type === 'SpreadElement' ||
|
|
240
|
+
property.key?.name === 'signal' ||
|
|
241
|
+
property.key?.value === 'signal',
|
|
242
|
+
);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const visit = (node) => {
|
|
246
|
+
if (!node || typeof node.type !== 'string') return;
|
|
247
|
+
|
|
248
|
+
if (
|
|
249
|
+
node.type === 'CallExpression' &&
|
|
250
|
+
node.callee?.type === 'MemberExpression' &&
|
|
251
|
+
node.callee.property?.name === 'addEventListener' &&
|
|
252
|
+
node.callee.object?.type === 'Identifier' &&
|
|
253
|
+
OUTLIVES.has(node.callee.object.name) &&
|
|
254
|
+
!looksSignalled(node.arguments[2])
|
|
255
|
+
) {
|
|
256
|
+
const target = node.callee.object.name;
|
|
257
|
+
const event = node.arguments[0]?.value;
|
|
258
|
+
const at = lineOf(node, code, line);
|
|
259
|
+
const message =
|
|
260
|
+
`${target}.addEventListener(${event ? `"${event}"` : '…'}) has no \`signal\`, so the ` +
|
|
261
|
+
`listener stays after this element leaves the document. Pass \`{ signal }\` ` +
|
|
262
|
+
`(line ${at})`;
|
|
263
|
+
if (!seen.has(message)) {
|
|
264
|
+
seen.add(message);
|
|
265
|
+
warnings.push(message);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
for (const key of Object.keys(node)) {
|
|
270
|
+
const value = node[key];
|
|
271
|
+
if (Array.isArray(value)) value.forEach(visit);
|
|
272
|
+
else if (value && typeof value.type === 'string') visit(value);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
visit(ast);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** What only exists once the element does, and so cannot be reached from a prototype. */
|
|
280
|
+
const PER_INSTANCE = ['host', 'shadow', 'signal', 'internals'];
|
|
281
|
+
|
|
282
|
+
/** `export const NAME = true` / `= false`, or null when it is not that. */
|
|
283
|
+
function booleanExport(statement, name) {
|
|
284
|
+
const declared = namesExport(statement, name);
|
|
285
|
+
const init = declared?.init;
|
|
286
|
+
if (init?.type === 'Literal' && typeof init.value === 'boolean') return init.value;
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function namesExport(statement, name) {
|
|
291
|
+
if (statement.type !== 'ExportNamedDeclaration') return null;
|
|
292
|
+
if (statement.declaration?.type !== 'VariableDeclaration') return null;
|
|
293
|
+
return (
|
|
294
|
+
statement.declaration.declarations.find(
|
|
295
|
+
(d) => d.id.type === 'Identifier' && d.id.name === name,
|
|
296
|
+
) ?? null
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The plan for hoisting one named export out of a client block: the statement
|
|
302
|
+
* itself, its initializer, and the top-level declarations it reads. Those have to
|
|
303
|
+
* come along, or the hoisted code names things that stayed behind.
|
|
304
|
+
*
|
|
305
|
+
* Shared with the shim, which copies the same slices so tsc resolves what the
|
|
306
|
+
* generated module resolves.
|
|
307
|
+
*
|
|
308
|
+
* @param {object} ast an acorn program
|
|
309
|
+
* @param {string} name the binding members land on
|
|
310
|
+
* @param {Set<string>|string[]} [perInstance] names a member may not reach
|
|
311
|
+
* @returns {object|null} what to hoist, or null when nothing is exported
|
|
312
|
+
*/
|
|
313
|
+
export function planLift(ast, name, perInstance = PER_INSTANCE) {
|
|
314
|
+
const statement = ast.body.find(
|
|
315
|
+
(node) =>
|
|
316
|
+
node.type === 'ExportNamedDeclaration' &&
|
|
317
|
+
node.declaration?.type === 'VariableDeclaration' &&
|
|
318
|
+
node.declaration.declarations.length === 1 &&
|
|
319
|
+
node.declaration.declarations[0].id.type === 'Identifier' &&
|
|
320
|
+
node.declaration.declarations[0].id.name === name &&
|
|
321
|
+
node.declaration.declarations[0].init,
|
|
322
|
+
);
|
|
323
|
+
if (!statement) return null;
|
|
324
|
+
|
|
325
|
+
const init = statement.declaration.declarations[0].init;
|
|
326
|
+
const owners = new Map();
|
|
327
|
+
for (const node of ast.body) {
|
|
328
|
+
if (node === statement) continue;
|
|
329
|
+
for (const declared of declaredNames(node)) owners.set(declared, node);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const instance = new Set(perInstance);
|
|
333
|
+
const reaches = new Set();
|
|
334
|
+
const reads = new Set();
|
|
335
|
+
const deps = [];
|
|
336
|
+
const seen = new Set();
|
|
337
|
+
const queue = [init];
|
|
338
|
+
|
|
339
|
+
while (queue.length) {
|
|
340
|
+
for (const free of freeNames(queue.shift(), new Set(), new Set())) {
|
|
341
|
+
reads.add(free);
|
|
342
|
+
if (instance.has(free)) reaches.add(free);
|
|
343
|
+
const owner = owners.get(free);
|
|
344
|
+
if (!owner || seen.has(owner)) continue;
|
|
345
|
+
seen.add(owner);
|
|
346
|
+
deps.push(owner);
|
|
347
|
+
queue.push(owner);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
deps.sort((a, b) => a.start - b.start);
|
|
352
|
+
return { statement, init, deps, reads, reaches: [...reaches] };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The names a top-level statement binds. */
|
|
356
|
+
function declaredNames(statement) {
|
|
357
|
+
const node = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement;
|
|
358
|
+
if (node?.type === 'VariableDeclaration') {
|
|
359
|
+
return node.declarations.flatMap((declarator) => patternNames(declarator.id));
|
|
360
|
+
}
|
|
361
|
+
if (node?.type === 'FunctionDeclaration' || node?.type === 'ClassDeclaration') {
|
|
362
|
+
return node.id ? [node.id.name] : [];
|
|
363
|
+
}
|
|
364
|
+
return [];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function scopedNames(statements) {
|
|
368
|
+
return (statements ?? []).flatMap(declaredNames);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Identifiers a subtree reads from outside itself.
|
|
373
|
+
*
|
|
374
|
+
* Scopes are tracked rather than ignored: a method with a parameter named
|
|
375
|
+
* `host` is not reaching for the element, and reporting it as one would be a
|
|
376
|
+
* confusing error about code that is correct.
|
|
377
|
+
*/
|
|
378
|
+
function freeNames(node, bound, out) {
|
|
379
|
+
if (!node || typeof node !== 'object') return out;
|
|
380
|
+
if (Array.isArray(node)) {
|
|
381
|
+
for (const child of node) freeNames(child, bound, out);
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
if (typeof node.type !== 'string') return out;
|
|
385
|
+
|
|
386
|
+
switch (node.type) {
|
|
387
|
+
case 'Identifier':
|
|
388
|
+
if (!bound.has(node.name)) out.add(node.name);
|
|
389
|
+
return out;
|
|
390
|
+
|
|
391
|
+
// `a.b` reads `a`; `b` is a property name, not a binding.
|
|
392
|
+
case 'MemberExpression':
|
|
393
|
+
freeNames(node.object, bound, out);
|
|
394
|
+
if (node.computed) freeNames(node.property, bound, out);
|
|
395
|
+
return out;
|
|
396
|
+
case 'Property':
|
|
397
|
+
case 'PropertyDefinition':
|
|
398
|
+
case 'MethodDefinition':
|
|
399
|
+
if (node.computed) freeNames(node.key, bound, out);
|
|
400
|
+
freeNames(node.value, bound, out);
|
|
401
|
+
return out;
|
|
402
|
+
|
|
403
|
+
case 'FunctionDeclaration':
|
|
404
|
+
case 'FunctionExpression':
|
|
405
|
+
case 'ArrowFunctionExpression': {
|
|
406
|
+
const inner = new Set(bound);
|
|
407
|
+
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);
|
|
410
|
+
// A default is evaluated in the function's own scope, so it sees the params.
|
|
411
|
+
for (const param of node.params) freeNames(param, inner, out);
|
|
412
|
+
freeNames(node.body, inner, out);
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
case 'BlockStatement': {
|
|
416
|
+
const inner = new Set(bound);
|
|
417
|
+
for (const name of scopedNames(node.body)) inner.add(name);
|
|
418
|
+
for (const statement of node.body) freeNames(statement, inner, out);
|
|
419
|
+
return out;
|
|
420
|
+
}
|
|
421
|
+
case 'ForStatement':
|
|
422
|
+
case 'ForOfStatement':
|
|
423
|
+
case 'ForInStatement': {
|
|
424
|
+
const inner = new Set(bound);
|
|
425
|
+
const head = node.init ?? node.left;
|
|
426
|
+
if (head?.type === 'VariableDeclaration') {
|
|
427
|
+
for (const declarator of head.declarations) {
|
|
428
|
+
for (const name of patternNames(declarator.id)) inner.add(name);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
for (const key of ['init', 'left', 'right', 'test', 'update', 'body']) {
|
|
432
|
+
if (node[key]) freeNames(node[key], inner, out);
|
|
433
|
+
}
|
|
434
|
+
return out;
|
|
435
|
+
}
|
|
436
|
+
case 'CatchClause': {
|
|
437
|
+
const inner = new Set(bound);
|
|
438
|
+
if (node.param) for (const name of patternNames(node.param)) inner.add(name);
|
|
439
|
+
freeNames(node.body, inner, out);
|
|
440
|
+
return out;
|
|
441
|
+
}
|
|
442
|
+
case 'ClassDeclaration':
|
|
443
|
+
case 'ClassExpression': {
|
|
444
|
+
const inner = new Set(bound);
|
|
445
|
+
if (node.id) inner.add(node.id.name);
|
|
446
|
+
freeNames(node.superClass, inner, out);
|
|
447
|
+
freeNames(node.body, inner, out);
|
|
448
|
+
return out;
|
|
449
|
+
}
|
|
450
|
+
// The declared name is bound by the enclosing block already; a destructuring
|
|
451
|
+
// pattern can still carry defaults that read from outside.
|
|
452
|
+
case 'VariableDeclarator':
|
|
453
|
+
if (node.id.type !== 'Identifier') freeNames(node.id, bound, out);
|
|
454
|
+
freeNames(node.init, bound, out);
|
|
455
|
+
return out;
|
|
456
|
+
|
|
457
|
+
case 'ImportDeclaration':
|
|
458
|
+
case 'ExportAllDeclaration':
|
|
459
|
+
case 'BreakStatement':
|
|
460
|
+
case 'ContinueStatement':
|
|
461
|
+
return out;
|
|
462
|
+
|
|
463
|
+
default:
|
|
464
|
+
for (const key of Object.keys(node)) {
|
|
465
|
+
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue;
|
|
466
|
+
freeNames(node[key], bound, out);
|
|
467
|
+
}
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Module-level client code (a page entry) only needs validating.
|
|
474
|
+
*
|
|
475
|
+
* @param {Array<{ code: string, line?: number }>} blocks
|
|
476
|
+
* @param {string} label
|
|
477
|
+
* @returns {void}
|
|
478
|
+
* @throws with the offset mapped back to the .html file
|
|
479
|
+
*/
|
|
480
|
+
export function assertModule(blocks, label) {
|
|
481
|
+
for (const block of blocks) parseOrThrow(block.code, label, block.line ?? 1);
|
|
482
|
+
return blocks.map((b) => b.code).join('\n');
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Bindings a `<script server>` block pulls in, so the shapes of imported values
|
|
487
|
+
* can be resolved by whoever owns the filesystem.
|
|
488
|
+
*/
|
|
489
|
+
function importsOf(ast) {
|
|
490
|
+
const out = [];
|
|
491
|
+
for (const statement of ast.body) {
|
|
492
|
+
if (statement.type !== 'ImportDeclaration') continue;
|
|
493
|
+
out.push({
|
|
494
|
+
source: statement.source.value,
|
|
495
|
+
specifiers: statement.specifiers.map((spec) => ({
|
|
496
|
+
local: spec.local.name,
|
|
497
|
+
imported:
|
|
498
|
+
spec.type === 'ImportDefaultSpecifier'
|
|
499
|
+
? 'default'
|
|
500
|
+
: (spec.imported?.name ?? spec.imported?.value ?? spec.local.name),
|
|
501
|
+
namespace: spec.type === 'ImportNamespaceSpecifier',
|
|
502
|
+
})),
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
return out;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Guards against a block exporting a name the generated module already uses.
|
|
510
|
+
*
|
|
511
|
+
* @param {string[]} exports
|
|
512
|
+
* @param {Set<string>} reserved
|
|
513
|
+
* @param {string} label
|
|
514
|
+
* @returns {void}
|
|
515
|
+
* @throws naming the first collision
|
|
516
|
+
*/
|
|
517
|
+
export function assertNoCollisions(exports, reserved, label) {
|
|
518
|
+
for (const name of exports) {
|
|
519
|
+
if (reserved.has(name)) {
|
|
520
|
+
throw new ScriptError(
|
|
521
|
+
`${label}: exports "${name}", which the generated module already defines. ` +
|
|
522
|
+
`Reserved: ${[...reserved].join(', ')}.`,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* A page's handlers are verb exports. An `actions` object is what they used to
|
|
530
|
+
* be, and nothing reads one now, so leaving it would answer 405 to every form
|
|
531
|
+
* on the page and say nothing about why.
|
|
532
|
+
*
|
|
533
|
+
* @param {string[]} exports
|
|
534
|
+
* @param {string} label
|
|
535
|
+
* @returns {void}
|
|
536
|
+
* @throws because nothing reads one, so a page keeping it would 405 in silence
|
|
537
|
+
*/
|
|
538
|
+
export function assertNoActionsObject(exports, label) {
|
|
539
|
+
if (!exports.includes('actions')) return;
|
|
540
|
+
|
|
541
|
+
throw new ScriptError(
|
|
542
|
+
`${label}: exports "actions", which nothing reads. Handlers are named for ` +
|
|
543
|
+
`their method now: export const POST = (ctx) => …, and the same for PUT, ` +
|
|
544
|
+
`PATCH and DELETE.`,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ---- internals ------------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
function parseOrThrow(code, label, lineOffset, extra = {}) {
|
|
551
|
+
try {
|
|
552
|
+
return parse(code, { ...PARSE_OPTIONS, ...extra });
|
|
553
|
+
} catch (err) {
|
|
554
|
+
const line = (err.loc?.line ?? 1) + lineOffset - 1;
|
|
555
|
+
const message = String(err.message).replace(/\s*\(\d+:\d+\)$/, '');
|
|
556
|
+
throw new ScriptError(`${label}: ${message} (line ${line})`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function namedExportsOf(ast, code, lineOffset, label) {
|
|
561
|
+
const names = [];
|
|
562
|
+
|
|
563
|
+
for (const statement of ast.body) {
|
|
564
|
+
if (statement.type === 'ExportAllDeclaration') {
|
|
565
|
+
throw new ScriptError(
|
|
566
|
+
`${label}: \`export *\` is not supported here. Its names cannot be checked ` +
|
|
567
|
+
`against the generated module (line ${lineOf(statement, code, lineOffset)})`,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
if (statement.type !== 'ExportNamedDeclaration') continue;
|
|
571
|
+
|
|
572
|
+
if (statement.declaration) {
|
|
573
|
+
const decl = statement.declaration;
|
|
574
|
+
if (decl.type === 'VariableDeclaration') {
|
|
575
|
+
for (const d of decl.declarations) names.push(...patternNames(d.id));
|
|
576
|
+
} else if (decl.id) {
|
|
577
|
+
names.push(decl.id.name);
|
|
578
|
+
}
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
for (const spec of statement.specifiers) {
|
|
582
|
+
names.push(spec.exported.name ?? spec.exported.value);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return names;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function patternNames(node) {
|
|
590
|
+
switch (node.type) {
|
|
591
|
+
case 'Identifier':
|
|
592
|
+
return [node.name];
|
|
593
|
+
case 'ObjectPattern':
|
|
594
|
+
return node.properties.flatMap((p) =>
|
|
595
|
+
p.type === 'RestElement' ? patternNames(p.argument) : patternNames(p.value),
|
|
596
|
+
);
|
|
597
|
+
case 'ArrayPattern':
|
|
598
|
+
return node.elements.filter(Boolean).flatMap(patternNames);
|
|
599
|
+
case 'AssignmentPattern':
|
|
600
|
+
return patternNames(node.left);
|
|
601
|
+
case 'RestElement':
|
|
602
|
+
return patternNames(node.argument);
|
|
603
|
+
default:
|
|
604
|
+
return [];
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Blanks out ranges without changing any other character's line or column, so
|
|
609
|
+
// stack traces into the generated module still point at the right spot.
|
|
610
|
+
function blank(code, cuts) {
|
|
611
|
+
let out = code;
|
|
612
|
+
for (const [start, end] of cuts) {
|
|
613
|
+
out = out.slice(0, start) + code.slice(start, end).replace(/[^\n]/g, ' ') + out.slice(end);
|
|
614
|
+
}
|
|
615
|
+
return out;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function lineOf(node, code, lineOffset) {
|
|
619
|
+
return code.slice(0, node.start).split('\n').length + lineOffset - 1;
|
|
620
|
+
}
|