azoxjs 0.1.0 → 0.2.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 +112 -5
- package/core/build.js +237 -41
- package/core/cli/router.js +28 -0
- package/core/commands/create.js +44 -10
- package/core/commands/dev.js +34 -19
- package/core/commands/doctor.js +6 -1
- package/core/compiler/compileToJs.js +506 -36
- package/core/compiler/html.js +28 -0
- package/core/compiler/parser.js +204 -9
- package/core/compiler/resolveComponents.js +212 -39
- package/core/compiler/sourceResolver.js +42 -0
- package/core/nodeResolver.js +15 -0
- package/core/reactivity/signal.js +36 -1
- package/core/renderer/moduleBindings.js +88 -0
- package/core/renderer/renderToHtml.js +94 -18
- package/core/renderer/serverScope.js +83 -0
- package/core/router/navigate.js +211 -0
- package/package.json +1 -1
|
@@ -3,37 +3,243 @@
|
|
|
3
3
|
// its own effect — no Virtual DOM tree, no diffing. A signal update
|
|
4
4
|
// touches exactly the text node or attribute it owns.
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// This module has no Node built-ins on purpose: it runs unchanged in
|
|
7
|
+
// the browser, which is what makes the playground possible. Anything
|
|
8
|
+
// that needs to know about paths on disk belongs in build.js.
|
|
7
9
|
|
|
8
10
|
let uid = 0;
|
|
9
11
|
const nextId = () => `_el${uid++}`;
|
|
10
12
|
|
|
11
|
-
// sourcePath: absolute path of the .azox file being compiled.
|
|
12
|
-
// outPath: absolute path of the client module being written.
|
|
13
13
|
// runtimeSpecifier: how the emitted module should import the Azox
|
|
14
|
-
// runtime — a
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
14
|
+
// runtime — a relative path to the copied runtime for a build, or
|
|
15
|
+
// whatever the playground wants to point at.
|
|
16
|
+
// rewriteImports: optional hook the build uses to rebase the user's
|
|
17
|
+
// own relative imports, since compiled output lives in
|
|
18
|
+
// .azox/build/ rather than next to the page. Called as
|
|
19
|
+
// (script, sourcePath) — an import hoisted out of a component is
|
|
20
|
+
// relative to that component, not to the page. The playground has
|
|
21
|
+
// no output directory, so it omits this.
|
|
22
|
+
// inlineModules: values the build already loaded, as local name →
|
|
23
|
+
// value, whose import must not reach the browser. A JSON import
|
|
24
|
+
// points outside the build directory at a file that is never
|
|
25
|
+
// deployed, so the value is emitted as a constant instead.
|
|
26
|
+
export function compileToModule(ast, { runtimeSpecifier, rewriteImports, inlineModules }) {
|
|
22
27
|
uid = 0;
|
|
23
28
|
const statements = [];
|
|
24
29
|
const rootVar = emitNode(ast.markup, statements, 'root');
|
|
25
|
-
|
|
30
|
+
|
|
31
|
+
let script = dropComponentImports(ast.script);
|
|
32
|
+
if (rewriteImports) script = rewriteImports(script);
|
|
33
|
+
|
|
34
|
+
// A page with no bindings and no listeners has nothing to hydrate:
|
|
35
|
+
// the server-rendered markup is already the finished page. Leaving
|
|
36
|
+
// it alone avoids a pointless rebuild, and avoids destroying nodes
|
|
37
|
+
// that other scripts on the page may be holding.
|
|
38
|
+
const isStatic = !statements.some(
|
|
39
|
+
(line) =>
|
|
40
|
+
line.startsWith('effect(') || line.includes('.addEventListener(')
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// Stateful components keep their logic inside a scope function, but
|
|
44
|
+
// their imports cannot live there, so the resolver hoisted them,
|
|
45
|
+
// each carrying the file it was written in. They need the same path
|
|
46
|
+
// rewriting the page's own imports get — but relative to their own
|
|
47
|
+
// file, not the page's — then merging with them: declaring the same
|
|
48
|
+
// binding twice is a syntax error.
|
|
49
|
+
let hoisted = (ast.componentImports ?? [])
|
|
50
|
+
// An inlined import becomes a constant below, so the statement
|
|
51
|
+
// must be dropped here too — it would declare the binding twice.
|
|
52
|
+
.filter((entry) => !isInlined(entry.statement, inlineModules))
|
|
53
|
+
.map((entry) =>
|
|
54
|
+
rewriteImports ? rewriteImports(entry.statement, entry.path) : entry.statement
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// A keyed list disposes the effects of rows that leave, so the
|
|
58
|
+
// module needs dispose as well as effect.
|
|
59
|
+
const needsDispose = statements.some((line) => line.includes('dispose('));
|
|
60
|
+
|
|
61
|
+
const { imports, body } = mergeImports(
|
|
62
|
+
script,
|
|
63
|
+
hoisted,
|
|
64
|
+
runtimeSpecifier,
|
|
65
|
+
needsDispose,
|
|
66
|
+
inlineModules
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Narrowed to what the module actually reads, so importing
|
|
70
|
+
// package.json for a version does not publish the whole file.
|
|
71
|
+
const inlined = emitInlineModules(inlineModules, [...statements, body].join('\n'));
|
|
26
72
|
|
|
27
73
|
return `
|
|
28
|
-
|
|
29
|
-
${
|
|
74
|
+
${imports}
|
|
75
|
+
${inlined}${body}
|
|
30
76
|
|
|
31
77
|
export function render(mount) {
|
|
32
78
|
${statements.map((line) => ' ' + line).join('\n')}
|
|
33
79
|
mount.appendChild(${rootVar});
|
|
34
80
|
return ${rootVar};
|
|
35
81
|
}
|
|
82
|
+
${isStatic ? staticNote() : hydrateBlock()}`.trimStart();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Collects every import the module needs into one set of statements,
|
|
86
|
+
// pulling the page's own imports out of its script so they cannot be
|
|
87
|
+
// duplicated by an identical import hoisted from a component.
|
|
88
|
+
//
|
|
89
|
+
// Named imports from the same specifier are merged into one
|
|
90
|
+
// statement, so `signal` imported by both the page and a component is
|
|
91
|
+
// declared once rather than twice — which would be a syntax error.
|
|
92
|
+
function mergeImports(
|
|
93
|
+
script,
|
|
94
|
+
componentImports,
|
|
95
|
+
runtimeSpecifier,
|
|
96
|
+
needsDispose = false,
|
|
97
|
+
inlineModules = null
|
|
98
|
+
) {
|
|
99
|
+
const pageImports = [...script.matchAll(/^\s*(import\s[^;\n]+;?)\s*$/gm)]
|
|
100
|
+
.map((m) => m[1].trim())
|
|
101
|
+
// An inlined import becomes a constant below, so its statement
|
|
102
|
+
// must not also be emitted — the binding would be declared twice.
|
|
103
|
+
.filter((line) => !isInlined(line, inlineModules));
|
|
104
|
+
const body = script.replace(/^\s*import\s[^;\n]+;?\s*$/gm, '').trim();
|
|
105
|
+
|
|
106
|
+
// specifier -> set of named bindings; anything not a plain named
|
|
107
|
+
// import is kept verbatim.
|
|
108
|
+
const named = new Map();
|
|
109
|
+
const verbatim = new Set();
|
|
110
|
+
|
|
111
|
+
const record = (statement) => {
|
|
112
|
+
const match = statement.match(/^import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/);
|
|
113
|
+
|
|
114
|
+
if (!match) {
|
|
115
|
+
verbatim.add(statement);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const [, bindings, specifier] = match;
|
|
120
|
+
const set = named.get(specifier) ?? new Set();
|
|
121
|
+
for (const binding of bindings.split(',')) {
|
|
122
|
+
if (binding.trim()) set.add(binding.trim());
|
|
123
|
+
}
|
|
124
|
+
named.set(specifier, set);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// `effect` is always needed: the compiler emits calls to it.
|
|
128
|
+
record(`import { effect } from '${runtimeSpecifier}';`);
|
|
129
|
+
if (needsDispose) record(`import { dispose } from '${runtimeSpecifier}';`);
|
|
130
|
+
for (const statement of [...componentImports, ...pageImports]) record(statement);
|
|
131
|
+
|
|
132
|
+
const lines = [
|
|
133
|
+
...[...named].map(([specifier, bindings]) => {
|
|
134
|
+
return `import { ${[...bindings].join(', ')} } from '${specifier}';`;
|
|
135
|
+
}),
|
|
136
|
+
...verbatim,
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
return { imports: lines.join('\n'), body };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// An import is inlined when every binding it declares was loaded by
|
|
143
|
+
// the build. Matching on the specifier would be wrong: the same file
|
|
144
|
+
// could be imported for some other reason.
|
|
145
|
+
function isInlined(statement, inlineModules) {
|
|
146
|
+
if (!inlineModules) return false;
|
|
147
|
+
|
|
148
|
+
const match = statement.match(/^import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"]/);
|
|
149
|
+
if (!match) return false;
|
|
150
|
+
|
|
151
|
+
const locals = localNames(match[1]);
|
|
152
|
+
return locals.length > 0 && locals.every((name) => name in inlineModules);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The local names an import clause declares, for deciding whether the
|
|
156
|
+
// statement has been replaced by constants.
|
|
157
|
+
function localNames(clause) {
|
|
158
|
+
const text = clause.trim();
|
|
159
|
+
const names = [];
|
|
36
160
|
|
|
161
|
+
const namespace = text.match(/^\*\s+as\s+([A-Za-z_$][\w$]*)$/);
|
|
162
|
+
if (namespace) return [namespace[1]];
|
|
163
|
+
|
|
164
|
+
const braceAt = text.indexOf('{');
|
|
165
|
+
const head = (braceAt === -1 ? text : text.slice(0, braceAt)).replace(/,\s*$/, '').trim();
|
|
166
|
+
if (/^[A-Za-z_$][\w$]*$/.test(head)) names.push(head);
|
|
167
|
+
|
|
168
|
+
if (braceAt !== -1) {
|
|
169
|
+
const closeAt = text.lastIndexOf('}');
|
|
170
|
+
for (const part of text.slice(braceAt + 1, closeAt === -1 ? undefined : closeAt).split(',')) {
|
|
171
|
+
const entry = part.trim();
|
|
172
|
+
if (!entry) continue;
|
|
173
|
+
const aliased = entry.match(/^(.+?)\s+as\s+([A-Za-z_$][\w$]*)$/);
|
|
174
|
+
const local = (aliased ? aliased[2] : entry).trim();
|
|
175
|
+
if (/^[A-Za-z_$][\w$]*$/.test(local)) names.push(local);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return names;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Emits a loaded value as a constant. JSON.stringify is exact for what
|
|
183
|
+
// JSON can hold, which is all this path accepts.
|
|
184
|
+
function emitInlineModules(inlineModules, usage = '') {
|
|
185
|
+
if (!inlineModules) return '';
|
|
186
|
+
|
|
187
|
+
const entries = Object.entries(inlineModules);
|
|
188
|
+
if (!entries.length) return '';
|
|
189
|
+
|
|
190
|
+
const lines = entries.map(
|
|
191
|
+
([name, value]) => `const ${name} = ${JSON.stringify(narrow(name, value, usage))};`
|
|
192
|
+
);
|
|
193
|
+
return `${lines.join('\n')}\n`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Keeps only the properties the module reads by name. A page that
|
|
197
|
+
// imports package.json for its version has no business shipping the
|
|
198
|
+
// author's address to every visitor, and the unread half is dead
|
|
199
|
+
// weight in the bundle.
|
|
200
|
+
//
|
|
201
|
+
// Narrowing applies to a plain object read as `name.prop`. Anything
|
|
202
|
+
// else — a primitive, an array, or a value the code indexes
|
|
203
|
+
// dynamically — is kept whole, since what is needed cannot be known
|
|
204
|
+
// from the source alone.
|
|
205
|
+
function narrow(name, value, usage) {
|
|
206
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
|
|
207
|
+
|
|
208
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
209
|
+
|
|
210
|
+
// A dynamic read — name[expr] — could reach any property, and
|
|
211
|
+
// passing the object on (or spreading it) could read any of them
|
|
212
|
+
// later. Both mean the whole value has to stay.
|
|
213
|
+
if (new RegExp(`\\b${escaped}\\s*\\[`).test(usage)) return value;
|
|
214
|
+
if (new RegExp(`\\.\\.\\.\\s*${escaped}\\b`).test(usage)) return value;
|
|
215
|
+
|
|
216
|
+
const read = new Set();
|
|
217
|
+
for (const match of usage.matchAll(new RegExp(`\\b${escaped}\\.([A-Za-z_$][\\w$]*)`, 'g'))) {
|
|
218
|
+
read.add(match[1]);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// No property read by name at all: the object itself is being used,
|
|
222
|
+
// so it is kept as it is.
|
|
223
|
+
if (!read.size) return value;
|
|
224
|
+
|
|
225
|
+
const narrowed = {};
|
|
226
|
+
for (const key of read) {
|
|
227
|
+
if (key in value) narrowed[key] = value[key];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return narrowed;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function staticNote() {
|
|
234
|
+
return `
|
|
235
|
+
// This page has no bindings and no listeners, so the server-rendered
|
|
236
|
+
// markup is already complete and is left untouched. render() is
|
|
237
|
+
// exported for anyone who wants to mount it somewhere else.
|
|
238
|
+
`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function hydrateBlock() {
|
|
242
|
+
return `
|
|
37
243
|
// Hydrate: the SSR markup is already on the page, so clear it and
|
|
38
244
|
// mount the reactive version in its place.
|
|
39
245
|
if (typeof document !== 'undefined') {
|
|
@@ -41,7 +247,7 @@ if (typeof document !== 'undefined') {
|
|
|
41
247
|
mount.innerHTML = '';
|
|
42
248
|
render(mount);
|
|
43
249
|
}
|
|
44
|
-
|
|
250
|
+
`;
|
|
45
251
|
}
|
|
46
252
|
|
|
47
253
|
function emitNode(node, statements, fallbackVar) {
|
|
@@ -60,6 +266,10 @@ function emitNode(node, statements, fallbackVar) {
|
|
|
60
266
|
return varName;
|
|
61
267
|
}
|
|
62
268
|
|
|
269
|
+
if (node.type === 'each') return emitEach(node, statements);
|
|
270
|
+
if (node.type === 'if') return emitIf(node, statements);
|
|
271
|
+
if (node.type === 'scope') return emitScope(node, statements, fallbackVar);
|
|
272
|
+
|
|
63
273
|
const varName = nextId();
|
|
64
274
|
statements.push(`const ${varName} = document.createElement(${JSON.stringify(node.name)});`);
|
|
65
275
|
|
|
@@ -72,6 +282,244 @@ function emitNode(node, statements, fallbackVar) {
|
|
|
72
282
|
return varName;
|
|
73
283
|
}
|
|
74
284
|
|
|
285
|
+
// A component that declares its own state becomes an immediately
|
|
286
|
+
// called function: its declarations are locals of that call, so two
|
|
287
|
+
// uses of the same component hold two independent sets of them.
|
|
288
|
+
//
|
|
289
|
+
// This is scoping the language already provides, not a component
|
|
290
|
+
// instance. There is nothing to mount, nothing to reconcile, and no
|
|
291
|
+
// object representing the component at runtime — just a function that
|
|
292
|
+
// runs once and returns the nodes it built.
|
|
293
|
+
function emitScope(node, statements, fallbackVar) {
|
|
294
|
+
const varName = nextId();
|
|
295
|
+
const bodyLines = [];
|
|
296
|
+
|
|
297
|
+
const roots = node.children.map((child) => emitNode(child, bodyLines, fallbackVar));
|
|
298
|
+
|
|
299
|
+
statements.push(`const ${varName} = ((${node.params.join(', ')}) => {`);
|
|
300
|
+
|
|
301
|
+
// The component's own script comes first, so its declarations exist
|
|
302
|
+
// before the markup that reads them is built.
|
|
303
|
+
for (const line of node.script.split('\n')) {
|
|
304
|
+
statements.push(` ${line}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
for (const line of bodyLines) statements.push(` ${line}`);
|
|
308
|
+
|
|
309
|
+
// A component renders one root; more than one would need a fragment,
|
|
310
|
+
// and the parser already requires a single root element.
|
|
311
|
+
statements.push(` return ${roots[0] ?? 'null'};`);
|
|
312
|
+
statements.push(`})(${node.args.join(', ')});`);
|
|
313
|
+
|
|
314
|
+
return varName;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Control flow needs a fixed point in the DOM to work from, since the
|
|
318
|
+
// nodes it manages come and go. An empty comment node serves as that
|
|
319
|
+
// anchor: it stays put, and everything the block renders is inserted
|
|
320
|
+
// before it and removed by walking back from it.
|
|
321
|
+
//
|
|
322
|
+
// Only the nodes between the markers are touched when the source
|
|
323
|
+
// signal changes — the rest of the page is never involved, which is
|
|
324
|
+
// the same guarantee a text binding gives.
|
|
325
|
+
function emitControlBlock(statements, buildBody, sourceExpr, renderCall) {
|
|
326
|
+
const start = nextId();
|
|
327
|
+
const end = nextId();
|
|
328
|
+
const frag = nextId();
|
|
329
|
+
|
|
330
|
+
const holder = nextId();
|
|
331
|
+
|
|
332
|
+
statements.push(`const ${start} = document.createComment('');`);
|
|
333
|
+
statements.push(`const ${end} = document.createComment('');`);
|
|
334
|
+
|
|
335
|
+
// The markers go into their own fragment straight away, so they
|
|
336
|
+
// always have a parent to insert into. Waiting for the page to be
|
|
337
|
+
// mounted instead would break a nested block: its markers are
|
|
338
|
+
// rebuilt every time the outer block re-runs, long after any
|
|
339
|
+
// one-time mount step has passed.
|
|
340
|
+
statements.push(`const ${holder} = document.createDocumentFragment();`);
|
|
341
|
+
statements.push(`${holder}.append(${start}, ${end});`);
|
|
342
|
+
|
|
343
|
+
// The body is compiled once into a function, then called as needed.
|
|
344
|
+
const bodyLines = [];
|
|
345
|
+
const bodyVar = buildBody(bodyLines);
|
|
346
|
+
|
|
347
|
+
statements.push(`const ${frag} = (${bodyVar.params}) => {`);
|
|
348
|
+
statements.push(` const _frag = document.createDocumentFragment();`);
|
|
349
|
+
for (const line of bodyLines) statements.push(` ${line}`);
|
|
350
|
+
for (const rootVar of bodyVar.roots) {
|
|
351
|
+
statements.push(` if (${rootVar} !== null) _frag.appendChild(${rootVar});`);
|
|
352
|
+
}
|
|
353
|
+
statements.push(` return _frag;`);
|
|
354
|
+
statements.push(`};`);
|
|
355
|
+
|
|
356
|
+
statements.push(`effect(() => {`);
|
|
357
|
+
statements.push(` // Read the source before anything can return early. An effect`);
|
|
358
|
+
statements.push(` // subscribes only to what it reads, so bailing out first would`);
|
|
359
|
+
statements.push(` // leave this block subscribed to nothing and never update.`);
|
|
360
|
+
statements.push(` const _source = ${sourceExpr};`);
|
|
361
|
+
statements.push(``);
|
|
362
|
+
statements.push(` // Clearing walks back from the end marker, so nothing outside`);
|
|
363
|
+
statements.push(` // the block can be removed by accident.`);
|
|
364
|
+
statements.push(` while (${end}.previousSibling && ${end}.previousSibling !== ${start}) {`);
|
|
365
|
+
statements.push(` ${end}.previousSibling.remove();`);
|
|
366
|
+
statements.push(` }`);
|
|
367
|
+
statements.push(``);
|
|
368
|
+
statements.push(` const _parent = ${end}.parentNode;`);
|
|
369
|
+
statements.push(` if (!_parent) return;`);
|
|
370
|
+
for (const line of renderCall(frag, end)) statements.push(` ${line}`);
|
|
371
|
+
statements.push(`});`);
|
|
372
|
+
|
|
373
|
+
return holder;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function emitEach(node, statements) {
|
|
377
|
+
return node.key ? emitKeyedEach(node, statements) : emitPlainEach(node, statements);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// A keyed list remembers the rows it built, so an update moves the
|
|
381
|
+
// ones that are still there instead of discarding every row and
|
|
382
|
+
// building it again. That is what lets a row keep its own state, its
|
|
383
|
+
// focus, and its scroll position across a change to the list.
|
|
384
|
+
//
|
|
385
|
+
// Rows that leave have their effects disposed, which is the one place
|
|
386
|
+
// the runtime's dispose() is needed: a row's bindings are created
|
|
387
|
+
// inside their own effect scope rather than inside the block's, since
|
|
388
|
+
// re-running the block must not tear down rows it is keeping.
|
|
389
|
+
function emitKeyedEach(node, statements) {
|
|
390
|
+
const params = node.index ? `${node.alias}, ${node.index}` : node.alias;
|
|
391
|
+
|
|
392
|
+
const start = nextId();
|
|
393
|
+
const end = nextId();
|
|
394
|
+
const build = nextId();
|
|
395
|
+
const rows = nextId();
|
|
396
|
+
const holder = nextId();
|
|
397
|
+
|
|
398
|
+
statements.push(`const ${start} = document.createComment('');`);
|
|
399
|
+
statements.push(`const ${end} = document.createComment('');`);
|
|
400
|
+
statements.push(`const ${holder} = document.createDocumentFragment();`);
|
|
401
|
+
statements.push(`${holder}.append(${start}, ${end});`);
|
|
402
|
+
|
|
403
|
+
// key -> { nodes, scope }. Kept across runs; this is the memory that
|
|
404
|
+
// makes the list keyed rather than rebuilt.
|
|
405
|
+
statements.push(`const ${rows} = new Map();`);
|
|
406
|
+
|
|
407
|
+
// The row builder returns its nodes and the effect owning them, so
|
|
408
|
+
// the block can both re-insert a row and dispose it later.
|
|
409
|
+
const bodyLines = [];
|
|
410
|
+
const roots = node.children.map((child) => emitNode(child, bodyLines, 'root'));
|
|
411
|
+
|
|
412
|
+
statements.push(`const ${build} = (${params}) => {`);
|
|
413
|
+
statements.push(` let _nodes;`);
|
|
414
|
+
statements.push(` const _scope = effect(() => {`);
|
|
415
|
+
for (const line of bodyLines) statements.push(` ${line}`);
|
|
416
|
+
statements.push(` _nodes = [${roots.filter((r) => r !== 'null').join(', ')}];`);
|
|
417
|
+
statements.push(` });`);
|
|
418
|
+
statements.push(` return { nodes: _nodes, scope: _scope };`);
|
|
419
|
+
statements.push(`};`);
|
|
420
|
+
|
|
421
|
+
statements.push(`effect(() => {`);
|
|
422
|
+
statements.push(` const _source = ${node.expr};`);
|
|
423
|
+
statements.push(` const _parent = ${end}.parentNode;`);
|
|
424
|
+
statements.push(` if (!_parent) return;`);
|
|
425
|
+
statements.push(``);
|
|
426
|
+
statements.push(` const _seen = new Set();`);
|
|
427
|
+
statements.push(` let _i = 0;`);
|
|
428
|
+
statements.push(``);
|
|
429
|
+
statements.push(` for (const _item of _source ?? []) {`);
|
|
430
|
+
statements.push(` const ${node.alias} = _item;`);
|
|
431
|
+
if (node.index) statements.push(` const ${node.index} = _i;`);
|
|
432
|
+
statements.push(` const _key = ${node.key};`);
|
|
433
|
+
statements.push(``);
|
|
434
|
+
statements.push(` if (_seen.has(_key)) {`);
|
|
435
|
+
statements.push(` throw new Error(`);
|
|
436
|
+
statements.push(
|
|
437
|
+
" `Azox: <each> saw the key ${String(_key)} twice. Keys must be unique within a list.`"
|
|
438
|
+
);
|
|
439
|
+
statements.push(` );`);
|
|
440
|
+
statements.push(` }`);
|
|
441
|
+
statements.push(` _seen.add(_key);`);
|
|
442
|
+
statements.push(``);
|
|
443
|
+
statements.push(` let _row = ${rows}.get(_key);`);
|
|
444
|
+
statements.push(` if (!_row) {`);
|
|
445
|
+
statements.push(` _row = ${build}(_item${node.index ? ', _i' : ''});`);
|
|
446
|
+
statements.push(` ${rows}.set(_key, _row);`);
|
|
447
|
+
statements.push(` }`);
|
|
448
|
+
statements.push(``);
|
|
449
|
+
statements.push(` // Moving a node that is already in place is a no-op in`);
|
|
450
|
+
statements.push(` // the DOM, so ordering costs nothing when nothing moved.`);
|
|
451
|
+
statements.push(` for (const _node of _row.nodes) _parent.insertBefore(_node, ${end});`);
|
|
452
|
+
statements.push(` _i++;`);
|
|
453
|
+
statements.push(` }`);
|
|
454
|
+
statements.push(``);
|
|
455
|
+
statements.push(` // Whatever is left in the map is a row that has gone.`);
|
|
456
|
+
statements.push(` for (const [_key, _row] of ${rows}) {`);
|
|
457
|
+
statements.push(` if (_seen.has(_key)) continue;`);
|
|
458
|
+
statements.push(` for (const _node of _row.nodes) _node.remove();`);
|
|
459
|
+
statements.push(` dispose(_row.scope);`);
|
|
460
|
+
statements.push(` ${rows}.delete(_key);`);
|
|
461
|
+
statements.push(` }`);
|
|
462
|
+
statements.push(`});`);
|
|
463
|
+
|
|
464
|
+
return holder;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function emitPlainEach(node, statements) {
|
|
468
|
+
const params = node.index ? `${node.alias}, ${node.index}` : node.alias;
|
|
469
|
+
|
|
470
|
+
return emitControlBlock(
|
|
471
|
+
statements,
|
|
472
|
+
(bodyLines) => {
|
|
473
|
+
// Compile the body once; each iteration calls it with its own
|
|
474
|
+
// item, so the DOM calls are shared rather than duplicated.
|
|
475
|
+
const roots = node.children.map((child) => emitNode(child, bodyLines, 'root'));
|
|
476
|
+
return { params, roots };
|
|
477
|
+
},
|
|
478
|
+
node.expr,
|
|
479
|
+
(frag, endVar) => [
|
|
480
|
+
`if (_source) {`,
|
|
481
|
+
` let _i = 0;`,
|
|
482
|
+
` for (const _item of _source) {`,
|
|
483
|
+
` _parent.insertBefore(${frag}(_item${node.index ? ', _i' : ''}), ${endVar});`,
|
|
484
|
+
` _i++;`,
|
|
485
|
+
` }`,
|
|
486
|
+
`}`,
|
|
487
|
+
]
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function emitIf(node, statements) {
|
|
492
|
+
return emitControlBlock(
|
|
493
|
+
statements,
|
|
494
|
+
(bodyLines) => {
|
|
495
|
+
// Both branches are compiled into the same function, selected by
|
|
496
|
+
// a flag, so a conditional costs one function rather than two.
|
|
497
|
+
const thenLines = [];
|
|
498
|
+
const thenRoots = node.then.map((child) => emitNode(child, thenLines, 'root'));
|
|
499
|
+
|
|
500
|
+
const elseLines = [];
|
|
501
|
+
const elseRoots = node.otherwise.map((child) => emitNode(child, elseLines, 'root'));
|
|
502
|
+
|
|
503
|
+
bodyLines.push(`const _out = document.createDocumentFragment();`);
|
|
504
|
+
bodyLines.push(`if (_branch) {`);
|
|
505
|
+
for (const line of thenLines) bodyLines.push(` ${line}`);
|
|
506
|
+
for (const root of thenRoots) {
|
|
507
|
+
if (root !== 'null') bodyLines.push(` _out.appendChild(${root});`);
|
|
508
|
+
}
|
|
509
|
+
bodyLines.push(`} else {`);
|
|
510
|
+
for (const line of elseLines) bodyLines.push(` ${line}`);
|
|
511
|
+
for (const root of elseRoots) {
|
|
512
|
+
if (root !== 'null') bodyLines.push(` _out.appendChild(${root});`);
|
|
513
|
+
}
|
|
514
|
+
bodyLines.push(`}`);
|
|
515
|
+
|
|
516
|
+
return { params: '_branch', roots: ['_out'] };
|
|
517
|
+
},
|
|
518
|
+
node.expr,
|
|
519
|
+
(frag, endVar) => [`_parent.insertBefore(${frag}(Boolean(_source)), ${endVar});`]
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
75
523
|
function appendChildren(parentVar, children, statements, fallbackVar) {
|
|
76
524
|
for (const child of children) {
|
|
77
525
|
const childVar = emitNode(child, statements, fallbackVar);
|
|
@@ -79,10 +527,36 @@ function appendChildren(parentVar, children, statements, fallbackVar) {
|
|
|
79
527
|
}
|
|
80
528
|
}
|
|
81
529
|
|
|
530
|
+
// createTextNode takes text, not markup, so an entity the author
|
|
531
|
+
// wrote in static markup has to be decoded here — otherwise the
|
|
532
|
+
// browser would show a literal "<" where the server rendered "<",
|
|
533
|
+
// and the page would visibly change on hydration. Content from a
|
|
534
|
+
// <text> block is already literal and passes through untouched.
|
|
535
|
+
const textValue = (part) => (part.kind === 'literal' ? part.value : decodeEntities(part.value));
|
|
536
|
+
|
|
537
|
+
// `{'{'}` is how a page writes a literal brace, since bare braces
|
|
538
|
+
// start an expression. It is a constant, so it should not produce an
|
|
539
|
+
// effect — a page whose only "dynamic" content is escaped punctuation
|
|
540
|
+
// would otherwise be treated as reactive and hydrated needlessly.
|
|
541
|
+
function foldLiteralParts(parts) {
|
|
542
|
+
return parts.map((part) => {
|
|
543
|
+
if (part.kind !== 'expr') return part;
|
|
544
|
+
|
|
545
|
+
const match = part.expr.trim().match(/^'((?:[^'\\]|\\.)*)'$|^"((?:[^"\\]|\\.)*)"$/);
|
|
546
|
+
if (!match) return part;
|
|
547
|
+
|
|
548
|
+
const raw = match[1] ?? match[2];
|
|
549
|
+
return { kind: 'literal', value: raw.replace(/\\(['"\\])/g, '$1') };
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
82
553
|
function emitText(node, statements, fallbackVar) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
554
|
+
const isFixed = (part) => part.kind === 'static' || part.kind === 'literal';
|
|
555
|
+
node = { ...node, parts: foldLiteralParts(node.parts) };
|
|
556
|
+
|
|
557
|
+
// Nothing dynamic: one text node, no effect needed.
|
|
558
|
+
if (node.parts.every(isFixed)) {
|
|
559
|
+
const value = node.parts.map(textValue).join('');
|
|
86
560
|
const varName = nextId();
|
|
87
561
|
statements.push(`const ${varName} = document.createTextNode(${JSON.stringify(value)});`);
|
|
88
562
|
return varName;
|
|
@@ -92,12 +566,25 @@ function emitText(node, statements, fallbackVar) {
|
|
|
92
566
|
const varName = nextId();
|
|
93
567
|
statements.push(`const ${varName} = document.createTextNode('');`);
|
|
94
568
|
const expr = node.parts
|
|
95
|
-
.map((
|
|
569
|
+
.map((part) => (isFixed(part) ? JSON.stringify(textValue(part)) : `String(${part.expr})`))
|
|
96
570
|
.join(' + ');
|
|
97
571
|
statements.push(`effect(() => { ${varName}.data = ${expr}; });`);
|
|
98
572
|
return varName;
|
|
99
573
|
}
|
|
100
574
|
|
|
575
|
+
// The five entities that matter for text content. Numeric forms are
|
|
576
|
+
// handled too, since documentation snippets tend to use them.
|
|
577
|
+
function decodeEntities(text) {
|
|
578
|
+
return text
|
|
579
|
+
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
|
|
580
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16)))
|
|
581
|
+
.replace(/</g, '<')
|
|
582
|
+
.replace(/>/g, '>')
|
|
583
|
+
.replace(/"/g, '"')
|
|
584
|
+
.replace(/'/g, "'")
|
|
585
|
+
.replace(/&/g, '&');
|
|
586
|
+
}
|
|
587
|
+
|
|
101
588
|
function emitAttr(varName, key, attr, statements) {
|
|
102
589
|
if (key.startsWith('on:')) {
|
|
103
590
|
const event = key.slice(3);
|
|
@@ -120,20 +607,3 @@ function dropComponentImports(script) {
|
|
|
120
607
|
return script.replace(/^\s*import\s+[A-Z]\w*\s+from\s+['"][^'"]+\.azox['"]\s*;?\s*$/gm, '');
|
|
121
608
|
}
|
|
122
609
|
|
|
123
|
-
// Rewrites every relative import specifier in the user's <script>
|
|
124
|
-
// block so it still resolves once the module lives in outPath
|
|
125
|
-
// instead of next to sourceDir.
|
|
126
|
-
function rebaseImports(script, sourceDir, outPath) {
|
|
127
|
-
return script.replace(
|
|
128
|
-
/(from\s+|import\s+)(['"])(\.[^'"]*)\2/g,
|
|
129
|
-
(full, keyword, quote, specifier) =>
|
|
130
|
-
`${keyword}${quote}${rebaseSpecifier(resolve(sourceDir, specifier), outPath)}${quote}`
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Re-expresses an absolute target path as a path relative to outPath.
|
|
135
|
-
function rebaseSpecifier(absoluteTarget, outPath) {
|
|
136
|
-
let rebased = relative(dirname(outPath), absoluteTarget);
|
|
137
|
-
if (!rebased.startsWith('.')) rebased = `./${rebased}`;
|
|
138
|
-
return rebased;
|
|
139
|
-
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Facts about HTML shared by the parser and the renderer. Keeping one
|
|
2
|
+
// list means the two can never disagree about what a void element is,
|
|
3
|
+
// which would show up as a mismatch between server output and what
|
|
4
|
+
// the browser builds.
|
|
5
|
+
|
|
6
|
+
export const VOID_TAGS = new Set([
|
|
7
|
+
'area',
|
|
8
|
+
'base',
|
|
9
|
+
'br',
|
|
10
|
+
'col',
|
|
11
|
+
'embed',
|
|
12
|
+
'hr',
|
|
13
|
+
'img',
|
|
14
|
+
'input',
|
|
15
|
+
'link',
|
|
16
|
+
'meta',
|
|
17
|
+
'source',
|
|
18
|
+
'track',
|
|
19
|
+
'wbr',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export function escapeHtml(str) {
|
|
23
|
+
return String(str)
|
|
24
|
+
.replace(/&/g, '&')
|
|
25
|
+
.replace(/</g, '<')
|
|
26
|
+
.replace(/>/g, '>')
|
|
27
|
+
.replace(/"/g, '"');
|
|
28
|
+
}
|