azoxjs 0.2.0 → 1.0.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 +125 -6
- package/core/build.js +261 -14
- package/core/commands/compile.js +5 -1
- package/core/commands/create.js +1 -2
- package/core/commands/dev.js +10 -1
- package/core/compiler/compileToJs.js +256 -19
- package/core/compiler/parser.js +33 -2
- package/core/compiler/resolveComponents.js +22 -7
- package/core/dev/watcher.js +12 -1
- package/core/index.js +12 -1
- package/core/reactivity/signal.js +101 -1
- package/core/renderer/renderToHtml.js +27 -2
- package/core/renderer/serverScope.js +29 -3
- package/core/routes.js +44 -0
- package/package.json +1 -1
|
@@ -10,6 +10,52 @@
|
|
|
10
10
|
let uid = 0;
|
|
11
11
|
const nextId = () => `_el${uid++}`;
|
|
12
12
|
|
|
13
|
+
// Names the build resolved to constants — an inlined JSON import, say.
|
|
14
|
+
// An expression reading only these can never change, so it is emitted
|
|
15
|
+
// as text rather than wrapped in an effect. Set per compile.
|
|
16
|
+
let constantNames = new Set();
|
|
17
|
+
let constantValues = {};
|
|
18
|
+
|
|
19
|
+
// True when `expr` reads nothing that could ever change: literals,
|
|
20
|
+
// operators, and property paths rooted in a build-time constant.
|
|
21
|
+
//
|
|
22
|
+
// Deliberately conservative. A call, an unknown identifier, or anything
|
|
23
|
+
// it cannot account for means "assume it changes" — being wrong that way
|
|
24
|
+
// costs an effect that never fires, while the opposite silently freezes
|
|
25
|
+
// a binding that should update.
|
|
26
|
+
function isConstantExpression(expr) {
|
|
27
|
+
if (!constantNames.size) return false;
|
|
28
|
+
|
|
29
|
+
const source = expr.trim();
|
|
30
|
+
if (!source) return false;
|
|
31
|
+
|
|
32
|
+
// A call could return anything, and assignment or increment means the
|
|
33
|
+
// value is meant to change.
|
|
34
|
+
if (/[(]/.test(source)) return false;
|
|
35
|
+
if (/(\+\+|--|[^=!<>]=[^=])/.test(source)) return false;
|
|
36
|
+
|
|
37
|
+
// Strip strings and template literals before looking at identifiers,
|
|
38
|
+
// so words inside them are not mistaken for names.
|
|
39
|
+
const withoutStrings = source
|
|
40
|
+
.replace(/'(?:[^'\\]|\\.)*'/g, "''")
|
|
41
|
+
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
|
|
42
|
+
.replace(/`(?:[^`\\$]|\\.|\$(?!\{))*`/g, '``');
|
|
43
|
+
|
|
44
|
+
// A template literal with a placeholder is not handled here.
|
|
45
|
+
if (/`/.test(withoutStrings) && /\$\{/.test(source)) return false;
|
|
46
|
+
|
|
47
|
+
// Every identifier that is not a property access must be a known
|
|
48
|
+
// constant. `pkg.version` yields `pkg`; `a.b.c` yields `a`.
|
|
49
|
+
const roots = [...withoutStrings.matchAll(/(\.)?\b([A-Za-z_$][\w$]*)\b/g)]
|
|
50
|
+
.filter(([, dot]) => !dot)
|
|
51
|
+
.map(([, , name]) => name);
|
|
52
|
+
|
|
53
|
+
if (!roots.length) return false;
|
|
54
|
+
|
|
55
|
+
const allowed = new Set(['true', 'false', 'null', 'undefined']);
|
|
56
|
+
return roots.every((name) => constantNames.has(name) || allowed.has(name));
|
|
57
|
+
}
|
|
58
|
+
|
|
13
59
|
// runtimeSpecifier: how the emitted module should import the Azox
|
|
14
60
|
// runtime — a relative path to the copied runtime for a build, or
|
|
15
61
|
// whatever the playground wants to point at.
|
|
@@ -23,12 +69,25 @@ const nextId = () => `_el${uid++}`;
|
|
|
23
69
|
// value, whose import must not reach the browser. A JSON import
|
|
24
70
|
// points outside the build directory at a file that is never
|
|
25
71
|
// deployed, so the value is emitted as a constant instead.
|
|
26
|
-
|
|
72
|
+
// routeParams: the resolved parameters for this page of a dynamic
|
|
73
|
+
// route. routes() is a build-time declaration and params() is
|
|
74
|
+
// answered before the browser is involved, so both are removed from
|
|
75
|
+
// the emitted module and the values are inlined.
|
|
76
|
+
export function compileToModule(
|
|
77
|
+
ast,
|
|
78
|
+
{ runtimeSpecifier, rewriteImports, inlineModules, routeParams }
|
|
79
|
+
) {
|
|
27
80
|
uid = 0;
|
|
81
|
+
// Values the build resolved: their bindings need no effect, which is
|
|
82
|
+
// what lets a page whose only "dynamic" text is a version number ship
|
|
83
|
+
// as a static page.
|
|
84
|
+
constantValues = inlineModules ?? {};
|
|
85
|
+
constantNames = new Set(Object.keys(constantValues));
|
|
28
86
|
const statements = [];
|
|
29
87
|
const rootVar = emitNode(ast.markup, statements, 'root');
|
|
30
88
|
|
|
31
89
|
let script = dropComponentImports(ast.script);
|
|
90
|
+
script = resolveRouteDeclarations(script, routeParams);
|
|
32
91
|
if (rewriteImports) script = rewriteImports(script);
|
|
33
92
|
|
|
34
93
|
// A page with no bindings and no listeners has nothing to hydrate:
|
|
@@ -57,13 +116,15 @@ export function compileToModule(ast, { runtimeSpecifier, rewriteImports, inlineM
|
|
|
57
116
|
// A keyed list disposes the effects of rows that leave, so the
|
|
58
117
|
// module needs dispose as well as effect.
|
|
59
118
|
const needsDispose = statements.some((line) => line.includes('dispose('));
|
|
119
|
+
const needsUntracked = statements.some((line) => line.includes('untracked('));
|
|
60
120
|
|
|
61
121
|
const { imports, body } = mergeImports(
|
|
62
122
|
script,
|
|
63
123
|
hoisted,
|
|
64
124
|
runtimeSpecifier,
|
|
65
125
|
needsDispose,
|
|
66
|
-
inlineModules
|
|
126
|
+
inlineModules,
|
|
127
|
+
needsUntracked
|
|
67
128
|
);
|
|
68
129
|
|
|
69
130
|
// Narrowed to what the module actually reads, so importing
|
|
@@ -94,7 +155,8 @@ function mergeImports(
|
|
|
94
155
|
componentImports,
|
|
95
156
|
runtimeSpecifier,
|
|
96
157
|
needsDispose = false,
|
|
97
|
-
inlineModules = null
|
|
158
|
+
inlineModules = null,
|
|
159
|
+
needsUntracked = false
|
|
98
160
|
) {
|
|
99
161
|
const pageImports = [...script.matchAll(/^\s*(import\s[^;\n]+;?)\s*$/gm)]
|
|
100
162
|
.map((m) => m[1].trim())
|
|
@@ -108,6 +170,24 @@ function mergeImports(
|
|
|
108
170
|
const named = new Map();
|
|
109
171
|
const verbatim = new Set();
|
|
110
172
|
|
|
173
|
+
// A page writes `azox/reactivity`; the compiler's own imports use
|
|
174
|
+
// whatever runtimeSpecifier it was given. They name the same module,
|
|
175
|
+
// so they are grouped under one key — otherwise a page importing
|
|
176
|
+
// `signal` and a keyed list needing it emit two imports that the
|
|
177
|
+
// build later rewrites to the same path, which is a redeclaration and
|
|
178
|
+
// a syntax error.
|
|
179
|
+
//
|
|
180
|
+
// Only the grouping is normalised. The emitted specifier stays as
|
|
181
|
+
// written, because rewriting it to a path on disk is the build's job —
|
|
182
|
+
// the playground compiles in the browser with no build at all.
|
|
183
|
+
const RUNTIME_ALIASES = new Set([
|
|
184
|
+
'azox',
|
|
185
|
+
'azox/reactivity',
|
|
186
|
+
'azoxjs',
|
|
187
|
+
'azoxjs/reactivity',
|
|
188
|
+
runtimeSpecifier,
|
|
189
|
+
]);
|
|
190
|
+
|
|
111
191
|
const record = (statement) => {
|
|
112
192
|
const match = statement.match(/^import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/);
|
|
113
193
|
|
|
@@ -116,7 +196,13 @@ function mergeImports(
|
|
|
116
196
|
return;
|
|
117
197
|
}
|
|
118
198
|
|
|
119
|
-
const [, bindings,
|
|
199
|
+
const [, bindings, rawSpecifier] = match;
|
|
200
|
+
// Grouped under the first spelling seen for the runtime, so the
|
|
201
|
+
// page's own `azox/reactivity` survives into the output when that is
|
|
202
|
+
// what was written.
|
|
203
|
+
const specifier = RUNTIME_ALIASES.has(rawSpecifier)
|
|
204
|
+
? runtimeKey(named, rawSpecifier, RUNTIME_ALIASES)
|
|
205
|
+
: rawSpecifier;
|
|
120
206
|
const set = named.get(specifier) ?? new Set();
|
|
121
207
|
for (const binding of bindings.split(',')) {
|
|
122
208
|
if (binding.trim()) set.add(binding.trim());
|
|
@@ -124,10 +210,17 @@ function mergeImports(
|
|
|
124
210
|
named.set(specifier, set);
|
|
125
211
|
};
|
|
126
212
|
|
|
213
|
+
// The author's imports are recorded first, so the runtime group keeps
|
|
214
|
+
// the spelling they wrote — `azox/reactivity`, which the build rewrites
|
|
215
|
+
// later. Recording the compiler's own first would name the group after
|
|
216
|
+
// runtimeSpecifier and rewrite the author's import here, which is the
|
|
217
|
+
// build's job and would break the playground's build-free compile.
|
|
218
|
+
for (const statement of [...componentImports, ...pageImports]) record(statement);
|
|
219
|
+
|
|
127
220
|
// `effect` is always needed: the compiler emits calls to it.
|
|
128
221
|
record(`import { effect } from '${runtimeSpecifier}';`);
|
|
129
222
|
if (needsDispose) record(`import { dispose } from '${runtimeSpecifier}';`);
|
|
130
|
-
|
|
223
|
+
if (needsUntracked) record(`import { untracked } from '${runtimeSpecifier}';`);
|
|
131
224
|
|
|
132
225
|
const lines = [
|
|
133
226
|
...[...named].map(([specifier, bindings]) => {
|
|
@@ -139,6 +232,15 @@ function mergeImports(
|
|
|
139
232
|
return { imports: lines.join('\n'), body };
|
|
140
233
|
}
|
|
141
234
|
|
|
235
|
+
// Returns the key the runtime's imports are already grouped under, or
|
|
236
|
+
// this spelling if it is the first one seen.
|
|
237
|
+
function runtimeKey(named, specifier, aliases) {
|
|
238
|
+
for (const existing of named.keys()) {
|
|
239
|
+
if (aliases.has(existing)) return existing;
|
|
240
|
+
}
|
|
241
|
+
return specifier;
|
|
242
|
+
}
|
|
243
|
+
|
|
142
244
|
// An import is inlined when every binding it declares was loaded by
|
|
143
245
|
// the build. Matching on the specifier would be wrong: the same file
|
|
144
246
|
// could be imported for some other reason.
|
|
@@ -187,10 +289,20 @@ function emitInlineModules(inlineModules, usage = '') {
|
|
|
187
289
|
const entries = Object.entries(inlineModules);
|
|
188
290
|
if (!entries.length) return '';
|
|
189
291
|
|
|
190
|
-
const lines = entries
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
292
|
+
const lines = entries
|
|
293
|
+
// A value whose every read was folded into the markup needs no
|
|
294
|
+
// declaration at all. Emitting it anyway would publish the rest of
|
|
295
|
+
// the file — an author's address included — for nothing.
|
|
296
|
+
.filter(([name]) => isRead(name, usage))
|
|
297
|
+
.map(([name, value]) => `const ${name} = ${JSON.stringify(narrow(name, value, usage))};`);
|
|
298
|
+
|
|
299
|
+
return lines.length ? `${lines.join('\n')}\n` : '';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Whether the emitted code still mentions the binding.
|
|
303
|
+
function isRead(name, usage) {
|
|
304
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
305
|
+
return new RegExp(`\\b${escaped}\\b`).test(usage);
|
|
194
306
|
}
|
|
195
307
|
|
|
196
308
|
// Keeps only the properties the module reads by name. A page that
|
|
@@ -230,11 +342,32 @@ function narrow(name, value, usage) {
|
|
|
230
342
|
return narrowed;
|
|
231
343
|
}
|
|
232
344
|
|
|
345
|
+
// Removes the build-time route declarations from a page's script.
|
|
346
|
+
//
|
|
347
|
+
// routes([...]) says which pages to build, which the browser has no
|
|
348
|
+
// use for — and calling it there is a ReferenceError that leaves the
|
|
349
|
+
// page inert. params() is replaced by the values this page was built
|
|
350
|
+
// with, so the markup reads them as plain data.
|
|
351
|
+
function resolveRouteDeclarations(script, routeParams) {
|
|
352
|
+
if (!routeParams) return script;
|
|
353
|
+
|
|
354
|
+
return script
|
|
355
|
+
// A whole statement, so the trailing semicolon and newline go too.
|
|
356
|
+
.replace(/^[ \t]*routes\s*\([\s\S]*?\)\s*;?[ \t]*$/gm, '')
|
|
357
|
+
.replace(/\bparams\s*\(\s*\)/g, JSON.stringify(routeParams));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Marks a module that does nothing on load. The build reads it to decide
|
|
361
|
+
// whether the page needs to reference the module at all — a static page
|
|
362
|
+
// that still downloads it pays for a render() nobody calls, and pulls in
|
|
363
|
+
// the runtime with it.
|
|
364
|
+
export const STATIC_MARKER = 'azox:static';
|
|
365
|
+
|
|
233
366
|
function staticNote() {
|
|
234
367
|
return `
|
|
235
|
-
//
|
|
236
|
-
// markup is already complete and is left untouched.
|
|
237
|
-
// exported for anyone who wants to mount it somewhere else.
|
|
368
|
+
// ${STATIC_MARKER} — this page has no bindings and no listeners, so the
|
|
369
|
+
// server-rendered markup is already complete and is left untouched.
|
|
370
|
+
// render() is exported for anyone who wants to mount it somewhere else.
|
|
238
371
|
`;
|
|
239
372
|
}
|
|
240
373
|
|
|
@@ -274,7 +407,7 @@ function emitNode(node, statements, fallbackVar) {
|
|
|
274
407
|
statements.push(`const ${varName} = document.createElement(${JSON.stringify(node.name)});`);
|
|
275
408
|
|
|
276
409
|
for (const [key, attr] of Object.entries(node.attrs)) {
|
|
277
|
-
emitAttr(varName, key, attr, statements);
|
|
410
|
+
emitAttr(varName, key, attr, statements, node.name);
|
|
278
411
|
}
|
|
279
412
|
|
|
280
413
|
appendChildren(varName, node.children, statements, fallbackVar);
|
|
@@ -387,7 +520,7 @@ function emitEach(node, statements) {
|
|
|
387
520
|
// inside their own effect scope rather than inside the block's, since
|
|
388
521
|
// re-running the block must not tear down rows it is keeping.
|
|
389
522
|
function emitKeyedEach(node, statements) {
|
|
390
|
-
const
|
|
523
|
+
const buildParams = node.index ? `${node.alias}, ${node.index}` : node.alias;
|
|
391
524
|
|
|
392
525
|
const start = nextId();
|
|
393
526
|
const end = nextId();
|
|
@@ -409,12 +542,21 @@ function emitKeyedEach(node, statements) {
|
|
|
409
542
|
const bodyLines = [];
|
|
410
543
|
const roots = node.children.map((child) => emitNode(child, bodyLines, 'root'));
|
|
411
544
|
|
|
412
|
-
|
|
545
|
+
// The index is the position the row was built at, and a keyed row is
|
|
546
|
+
// built once. Reordering therefore moves rows without renumbering
|
|
547
|
+
// them — the cost of keeping a row rather than rebuilding it. Use the
|
|
548
|
+
// index for a stable list, and read the position from the data when a
|
|
549
|
+
// list reorders.
|
|
550
|
+
statements.push(`const ${build} = (${buildParams}) => {`);
|
|
413
551
|
statements.push(` let _nodes;`);
|
|
414
|
-
|
|
552
|
+
// untracked: the row is built while the list's effect is running, so
|
|
553
|
+
// without this it becomes that effect's child — and the next list
|
|
554
|
+
// change tears the row down even though it survived, leaving its
|
|
555
|
+
// bindings dead and its onCleanup callbacks fired.
|
|
556
|
+
statements.push(` const _scope = untracked(() => effect(() => {`);
|
|
415
557
|
for (const line of bodyLines) statements.push(` ${line}`);
|
|
416
558
|
statements.push(` _nodes = [${roots.filter((r) => r !== 'null').join(', ')}];`);
|
|
417
|
-
statements.push(` });`);
|
|
559
|
+
statements.push(` }));`);
|
|
418
560
|
statements.push(` return { nodes: _nodes, scope: _scope };`);
|
|
419
561
|
statements.push(`};`);
|
|
420
562
|
|
|
@@ -552,7 +694,7 @@ function foldLiteralParts(parts) {
|
|
|
552
694
|
|
|
553
695
|
function emitText(node, statements, fallbackVar) {
|
|
554
696
|
const isFixed = (part) => part.kind === 'static' || part.kind === 'literal';
|
|
555
|
-
node = { ...node, parts: foldLiteralParts(node.parts) };
|
|
697
|
+
node = { ...node, parts: foldConstantParts(foldLiteralParts(node.parts)) };
|
|
556
698
|
|
|
557
699
|
// Nothing dynamic: one text node, no effect needed.
|
|
558
700
|
if (node.parts.every(isFixed)) {
|
|
@@ -572,6 +714,46 @@ function emitText(node, statements, fallbackVar) {
|
|
|
572
714
|
return varName;
|
|
573
715
|
}
|
|
574
716
|
|
|
717
|
+
// Turns an expression the build already resolved into a literal part, so
|
|
718
|
+
// the text node is created with the value rather than an effect being
|
|
719
|
+
// attached to write it. The page whose only "dynamic" text is a version
|
|
720
|
+
// number then ships as a static page.
|
|
721
|
+
function foldConstantParts(parts) {
|
|
722
|
+
if (!constantNames.size) return parts;
|
|
723
|
+
|
|
724
|
+
return parts.map((part) => {
|
|
725
|
+
if (part.kind !== 'expr' || !isConstantExpression(part.expr)) return part;
|
|
726
|
+
|
|
727
|
+
const value = evaluateInlinedExpression(part.expr);
|
|
728
|
+
if (value === undefined) return part;
|
|
729
|
+
|
|
730
|
+
return { kind: 'literal', value: String(value) };
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Evaluates a constant expression against the values the build resolved.
|
|
735
|
+
// Returns undefined when it cannot be evaluated, which leaves the part
|
|
736
|
+
// dynamic — the safe direction.
|
|
737
|
+
//
|
|
738
|
+
// Named for this file: the playground concatenates the compiler's
|
|
739
|
+
// modules into one scope, so a bare `evaluateConstant` collides with the
|
|
740
|
+
// one in resolveComponents.js and the whole bundle fails to parse.
|
|
741
|
+
function evaluateInlinedExpression(expr) {
|
|
742
|
+
try {
|
|
743
|
+
const names = [...constantNames];
|
|
744
|
+
const fn = new Function(...names, `return (${expr});`);
|
|
745
|
+
const value = fn(...names.map((name) => constantValues[name]));
|
|
746
|
+
|
|
747
|
+
// Only a primitive can be written into the markup as text.
|
|
748
|
+
if (value === null || value === undefined) return undefined;
|
|
749
|
+
if (typeof value === 'object' || typeof value === 'function') return undefined;
|
|
750
|
+
|
|
751
|
+
return value;
|
|
752
|
+
} catch {
|
|
753
|
+
return undefined;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
575
757
|
// The five entities that matter for text content. Numeric forms are
|
|
576
758
|
// handled too, since documentation snippets tend to use them.
|
|
577
759
|
function decodeEntities(text) {
|
|
@@ -585,22 +767,77 @@ function decodeEntities(text) {
|
|
|
585
767
|
.replace(/&/g, '&');
|
|
586
768
|
}
|
|
587
769
|
|
|
588
|
-
|
|
770
|
+
// Properties that must be set as properties rather than attributes:
|
|
771
|
+
// setAttribute("value") only sets the *initial* value, so after a user
|
|
772
|
+
// types, writing the attribute changes nothing they can see.
|
|
773
|
+
const DOM_PROPERTIES = new Set(['value', 'checked', 'selected', 'indeterminate']);
|
|
774
|
+
|
|
775
|
+
function emitAttr(varName, key, attr, statements, tagName) {
|
|
589
776
|
if (key.startsWith('on:')) {
|
|
590
777
|
const event = key.slice(3);
|
|
591
778
|
statements.push(`${varName}.addEventListener(${JSON.stringify(event)}, ${attr.expr});`);
|
|
592
779
|
return;
|
|
593
780
|
}
|
|
594
781
|
|
|
782
|
+
if (key.startsWith('bind:')) {
|
|
783
|
+
emitBinding(varName, key.slice(5), attr, statements, tagName);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
|
|
595
787
|
if (attr.kind === 'static') {
|
|
596
788
|
statements.push(`${varName}.setAttribute(${JSON.stringify(key)}, ${JSON.stringify(attr.value)});`);
|
|
597
789
|
return;
|
|
598
790
|
}
|
|
599
791
|
|
|
792
|
+
// A property has to be assigned, not set as an attribute — see
|
|
793
|
+
// DOM_PROPERTIES. Everything else is an attribute.
|
|
794
|
+
if (DOM_PROPERTIES.has(key)) {
|
|
795
|
+
statements.push(`effect(() => { ${varName}.${key} = ${attr.expr}; });`);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
|
|
600
799
|
// Dynamic attribute: wrap in its own effect, same fine-grained rule as text.
|
|
601
800
|
statements.push(`effect(() => { ${varName}.setAttribute(${JSON.stringify(key)}, String(${attr.expr})); });`);
|
|
602
801
|
}
|
|
603
802
|
|
|
803
|
+
// Two-way binding: the element shows the signal, and the signal follows
|
|
804
|
+
// the element. Writing it by hand means a value= and an on:input= that
|
|
805
|
+
// have to agree, and getting the event or the property wrong is easy —
|
|
806
|
+
// a checkbox reports `checked`, not `value`, and a number input reports
|
|
807
|
+
// a string.
|
|
808
|
+
function emitBinding(varName, property, attr, statements, tagName) {
|
|
809
|
+
const signal = attr.expr.trim();
|
|
810
|
+
|
|
811
|
+
// The signal itself, not a call: `bind:value={draft}`. Binding needs
|
|
812
|
+
// to write back, which a value cannot do.
|
|
813
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(signal)) {
|
|
814
|
+
throw new Error(
|
|
815
|
+
`Azox: bind:${property}={${signal}} needs a signal by name — ` +
|
|
816
|
+
`write bind:${property}={draft}, not bind:${property}={draft()}`
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// A checkbox's state is `checked`, and the event that reports it is
|
|
821
|
+
// "change" rather than "input".
|
|
822
|
+
const isCheckbox = property === 'checked';
|
|
823
|
+
const event = isCheckbox || tagName === 'select' ? 'change' : 'input';
|
|
824
|
+
|
|
825
|
+
statements.push(`effect(() => { ${varName}.${property} = ${signal}(); });`);
|
|
826
|
+
statements.push(
|
|
827
|
+
`${varName}.addEventListener(${JSON.stringify(event)}, (_e) => ` +
|
|
828
|
+
`${signal}.set(${readTarget(property, tagName)}));`
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// How the value is read back off the element. A number input reports a
|
|
833
|
+
// string, so it is converted — otherwise arithmetic on the signal
|
|
834
|
+
// silently concatenates.
|
|
835
|
+
function readTarget(property, tagName) {
|
|
836
|
+
if (property === 'checked') return '_e.target.checked';
|
|
837
|
+
if (tagName === 'input') return '(_e.target.type === "number" ? _e.target.valueAsNumber : _e.target.value)';
|
|
838
|
+
return '_e.target.value';
|
|
839
|
+
}
|
|
840
|
+
|
|
604
841
|
// Component imports are resolved at build time and inlined, so the
|
|
605
842
|
// .azox specifier must not survive into JavaScript the browser loads.
|
|
606
843
|
function dropComponentImports(script) {
|
package/core/compiler/parser.js
CHANGED
|
@@ -31,17 +31,36 @@ export function parseAzox(source) {
|
|
|
31
31
|
.trim();
|
|
32
32
|
|
|
33
33
|
const tokens = tokenize(template);
|
|
34
|
-
|
|
34
|
+
|
|
35
|
+
// Several roots become one fragment, so a component can return a pair
|
|
36
|
+
// of <li>s or a label beside its input without a wrapper element that
|
|
37
|
+
// exists only to satisfy the parser.
|
|
38
|
+
const roots = [];
|
|
39
|
+
let rest = tokens;
|
|
40
|
+
|
|
41
|
+
while (rest.length) {
|
|
42
|
+
const parsed = parseNode(rest);
|
|
43
|
+
if (!parsed.node) break;
|
|
44
|
+
|
|
45
|
+
roots.push(parsed.node);
|
|
46
|
+
rest = parsed.rest;
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
if (rest.length) {
|
|
36
50
|
throw new ParseError(`Azox parse error: unexpected trailing markup near "${rest[0]?.value ?? ''}"`);
|
|
37
51
|
}
|
|
38
52
|
|
|
53
|
+
const markup = roots.length === 1 ? roots[0] : { type: 'fragment', children: roots };
|
|
54
|
+
|
|
39
55
|
return {
|
|
40
56
|
script,
|
|
41
57
|
head,
|
|
42
|
-
markup
|
|
58
|
+
markup,
|
|
43
59
|
components: parseComponentImports(script),
|
|
44
60
|
props: parsePropNames(script),
|
|
61
|
+
// A dynamic page destructures its route parameters the same way a
|
|
62
|
+
// component destructures its props.
|
|
63
|
+
params: parseParamNames(script),
|
|
45
64
|
};
|
|
46
65
|
}
|
|
47
66
|
|
|
@@ -63,6 +82,18 @@ function parseComponentImports(script) {
|
|
|
63
82
|
// `const { title, count } = props();` declares what a component
|
|
64
83
|
// accepts. Declaring them explicitly lets the compiler reject a
|
|
65
84
|
// caller that passes something the component never asked for.
|
|
85
|
+
// `const { slug } = params()` names the route parameters the page
|
|
86
|
+
// reads, mirroring how props() declares a component's inputs.
|
|
87
|
+
function parseParamNames(script) {
|
|
88
|
+
const match = script.match(/const\s*\{([^}]*)\}\s*=\s*params\(\)/);
|
|
89
|
+
if (!match) return [];
|
|
90
|
+
|
|
91
|
+
return match[1]
|
|
92
|
+
.split(',')
|
|
93
|
+
.map((name) => name.trim())
|
|
94
|
+
.filter(Boolean);
|
|
95
|
+
}
|
|
96
|
+
|
|
66
97
|
function parsePropNames(script) {
|
|
67
98
|
const match = script.match(/const\s*\{([^}]*)\}\s*=\s*props\(\)/);
|
|
68
99
|
if (!match) return [];
|
|
@@ -20,7 +20,7 @@ export class ComponentError extends BuildError {}
|
|
|
20
20
|
// `resolver` decides how an import specifier becomes source text, so
|
|
21
21
|
// this runs unchanged against disk or against an in-memory map. See
|
|
22
22
|
// sourceResolver.js.
|
|
23
|
-
export function resolveComponents(ast, sourcePath, resolver, seen = new Set()) {
|
|
23
|
+
export function resolveComponents(ast, sourcePath, resolver, seen = new Set(), heads = null) {
|
|
24
24
|
// Imports from stateful components are hoisted here: they cannot
|
|
25
25
|
// live inside the scope function the compiler builds for each one.
|
|
26
26
|
//
|
|
@@ -30,16 +30,24 @@ export function resolveComponents(ast, sourcePath, resolver, seen = new Set()) {
|
|
|
30
30
|
// rebasing a component's import against the page's directory points
|
|
31
31
|
// it at a file that is not there.
|
|
32
32
|
const hoisted = new Map();
|
|
33
|
-
|
|
33
|
+
|
|
34
|
+
// A component's <head> block is collected the same way, keyed by the
|
|
35
|
+
// file it came from so the same component used twice contributes
|
|
36
|
+
// once. A stylesheet link belongs in the document head, and until
|
|
37
|
+
// now only a page could put one there.
|
|
38
|
+
const collected = heads ?? new Map();
|
|
39
|
+
|
|
40
|
+
const markup = expand(ast.markup, ast, sourcePath, resolver, seen, hoisted, collected);
|
|
34
41
|
|
|
35
42
|
return {
|
|
36
43
|
...ast,
|
|
37
44
|
markup,
|
|
38
45
|
componentImports: [...hoisted.values()],
|
|
46
|
+
componentHeads: [...collected.values()],
|
|
39
47
|
};
|
|
40
48
|
}
|
|
41
49
|
|
|
42
|
-
function expand(node, ast, sourcePath, resolver, seen, hoisted) {
|
|
50
|
+
function expand(node, ast, sourcePath, resolver, seen, hoisted, heads) {
|
|
43
51
|
if (!node || node.type === 'text') return node;
|
|
44
52
|
|
|
45
53
|
// <if> keeps its children in two branches rather than in `children`,
|
|
@@ -48,15 +56,17 @@ function expand(node, ast, sourcePath, resolver, seen, hoisted) {
|
|
|
48
56
|
if (node.type === 'if') {
|
|
49
57
|
return {
|
|
50
58
|
...node,
|
|
51
|
-
then: node.then.map((child) =>
|
|
59
|
+
then: node.then.map((child) =>
|
|
60
|
+
expand(child, ast, sourcePath, resolver, seen, hoisted, heads)
|
|
61
|
+
),
|
|
52
62
|
otherwise: node.otherwise.map((child) =>
|
|
53
|
-
expand(child, ast, sourcePath, resolver, seen, hoisted)
|
|
63
|
+
expand(child, ast, sourcePath, resolver, seen, hoisted, heads)
|
|
54
64
|
),
|
|
55
65
|
};
|
|
56
66
|
}
|
|
57
67
|
|
|
58
68
|
const children = (node.children ?? []).map((child) =>
|
|
59
|
-
expand(child, ast, sourcePath, resolver, seen, hoisted)
|
|
69
|
+
expand(child, ast, sourcePath, resolver, seen, hoisted, heads)
|
|
60
70
|
);
|
|
61
71
|
|
|
62
72
|
if (node.type !== 'component') {
|
|
@@ -72,9 +82,14 @@ function expand(node, ast, sourcePath, resolver, seen, hoisted) {
|
|
|
72
82
|
component.ast,
|
|
73
83
|
component.path,
|
|
74
84
|
resolver,
|
|
75
|
-
new Set([...seen, component.path])
|
|
85
|
+
new Set([...seen, component.path]),
|
|
86
|
+
heads
|
|
76
87
|
);
|
|
77
88
|
|
|
89
|
+
// Keyed by path: a component used on a page twice must not emit its
|
|
90
|
+
// stylesheet link twice.
|
|
91
|
+
if (component.ast.head) heads.set(component.path, component.ast.head);
|
|
92
|
+
|
|
78
93
|
const values = propValues(node, component.ast.props);
|
|
79
94
|
const { logic, imports } = componentLogic(component.ast.script);
|
|
80
95
|
|
package/core/dev/watcher.js
CHANGED
|
@@ -11,7 +11,10 @@ import { join } from 'node:path';
|
|
|
11
11
|
|
|
12
12
|
const DEBOUNCE_MS = 40;
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// `recursive: false` watches only the directory itself. The project
|
|
15
|
+
// root holds the build output and node_modules, so sweeping it
|
|
16
|
+
// recursively would rebuild in a loop.
|
|
17
|
+
export function watchDirectory(dir, onChange, { filter = () => true, recursive = true } = {}) {
|
|
15
18
|
const watchers = [];
|
|
16
19
|
let timer = null;
|
|
17
20
|
|
|
@@ -22,6 +25,14 @@ export function watchDirectory(dir, onChange, { filter = () => true } = {}) {
|
|
|
22
25
|
timer = setTimeout(() => onChange(filename), DEBOUNCE_MS);
|
|
23
26
|
};
|
|
24
27
|
|
|
28
|
+
if (!recursive) {
|
|
29
|
+
watchers.push(watch(dir, (_event, filename) => trigger(filename)));
|
|
30
|
+
return () => {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
for (const watcher of watchers) watcher.close();
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
try {
|
|
26
37
|
watchers.push(watch(dir, { recursive: true }, (_event, filename) => trigger(filename)));
|
|
27
38
|
} catch {
|
package/core/index.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
// Public entry point for `import ... from 'azoxjs'`.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// The whole reactivity surface, so `azoxjs` and `azoxjs/reactivity`
|
|
4
|
+
// offer the same thing rather than the main entry quietly omitting the
|
|
5
|
+
// newer half.
|
|
6
|
+
export {
|
|
7
|
+
signal,
|
|
8
|
+
effect,
|
|
9
|
+
computed,
|
|
10
|
+
dispose,
|
|
11
|
+
untracked,
|
|
12
|
+
onMount,
|
|
13
|
+
onCleanup,
|
|
14
|
+
} from './reactivity/signal.js';
|
|
4
15
|
export { parseAzox } from './compiler/parser.js';
|
|
5
16
|
export { compileToModule } from './compiler/compileToJs.js';
|
|
6
17
|
export { renderToHtml } from './renderer/renderToHtml.js';
|