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