azoxjs 0.3.0 → 1.0.1
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 +49 -6
- package/core/build.js +21 -4
- package/core/commands/create.js +1 -2
- package/core/compiler/compileToJs.js +240 -19
- package/core/compiler/parser.js +18 -2
- 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 +14 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,8 +11,11 @@ third-party CLI dependencies, no borrowed syntax from React, Vue, or
|
|
|
11
11
|
Next.js. It compiles `.azox` components directly into fine-grained,
|
|
12
12
|
signal-driven DOM updates.
|
|
13
13
|
|
|
14
|
-
> Status:
|
|
15
|
-
> change without
|
|
14
|
+
> Status: stable (v1.0.1). The template syntax, the reactivity exports
|
|
15
|
+
> and the shape of the build output will not change without a 2.0. What
|
|
16
|
+
> Azox does **not** do is listed in
|
|
17
|
+
> [Limitations](https://azox.dev/docs/limitations) — those are stopping
|
|
18
|
+
> points, not bugs.
|
|
16
19
|
|
|
17
20
|
## Why Azox
|
|
18
21
|
|
|
@@ -187,6 +190,39 @@ There is still no component instance at runtime: the compiler wraps
|
|
|
187
190
|
each use in its own JavaScript scope, which is ordinary scoping
|
|
188
191
|
rather than a framework construct.
|
|
189
192
|
|
|
193
|
+
## Lifecycle
|
|
194
|
+
|
|
195
|
+
`onMount` runs once the DOM is in the document; `onCleanup` runs when
|
|
196
|
+
the scope goes away.
|
|
197
|
+
|
|
198
|
+
```html
|
|
199
|
+
<script>
|
|
200
|
+
import { signal, onMount, onCleanup } from 'azox/reactivity';
|
|
201
|
+
|
|
202
|
+
const width = signal(0);
|
|
203
|
+
let box;
|
|
204
|
+
|
|
205
|
+
onMount(() => {
|
|
206
|
+
// The nodes exist now, so they can be measured.
|
|
207
|
+
const onResize = () => width.set(box.clientWidth);
|
|
208
|
+
onResize();
|
|
209
|
+
|
|
210
|
+
window.addEventListener('resize', onResize);
|
|
211
|
+
// Returned from onMount, so it is the cleanup for this setup.
|
|
212
|
+
return () => window.removeEventListener('resize', onResize);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
onCleanup(() => console.log('gone'));
|
|
216
|
+
</script>
|
|
217
|
+
|
|
218
|
+
<div>{width()}px</div>
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
A scope goes away when a row leaves a keyed list, or when an `<if>`
|
|
222
|
+
takes the other branch. At the top level of a page nothing ever removes
|
|
223
|
+
it, so `onCleanup` there never runs — that is a page living as long as
|
|
224
|
+
the document, not a failure.
|
|
225
|
+
|
|
190
226
|
## Layouts and the document head
|
|
191
227
|
|
|
192
228
|
A component can carry a `<head>` block, so one shared component holds
|
|
@@ -330,10 +366,17 @@ npm run build
|
|
|
330
366
|
```
|
|
331
367
|
|
|
332
368
|
The build lands in `.azox/build/` as a self-contained static bundle —
|
|
333
|
-
an `index.html` per route, a compiled hydration module beside it
|
|
334
|
-
one shared copy of the runtime. No dev machinery is
|
|
335
|
-
that directory with any static host and it works with
|
|
336
|
-
step and no rewrite configuration.
|
|
369
|
+
an `index.html` per route, a compiled hydration module beside it where
|
|
370
|
+
one is needed, and one shared copy of the runtime. No dev machinery is
|
|
371
|
+
included. Serve that directory with any static host and it works with
|
|
372
|
+
no install step and no rewrite configuration.
|
|
373
|
+
|
|
374
|
+
A page with no bindings and no listeners ships **no JavaScript at
|
|
375
|
+
all**: it arrives complete from the build, so the document references
|
|
376
|
+
no module and none is written. An expression that reads only
|
|
377
|
+
build-time constants — a version from `package.json`, say — is folded
|
|
378
|
+
into the markup rather than wrapped in an effect, which is often what
|
|
379
|
+
decides whether a page needs a module in the first place.
|
|
337
380
|
|
|
338
381
|
## CLI
|
|
339
382
|
|
package/core/build.js
CHANGED
|
@@ -17,7 +17,7 @@ import { createRequire } from 'node:module';
|
|
|
17
17
|
|
|
18
18
|
import { parseAzox } from './compiler/parser.js';
|
|
19
19
|
import { resolveComponents } from './compiler/resolveComponents.js';
|
|
20
|
-
import { compileToModule } from './compiler/compileToJs.js';
|
|
20
|
+
import { compileToModule, STATIC_MARKER } from './compiler/compileToJs.js';
|
|
21
21
|
import { renderToHtml } from './renderer/renderToHtml.js';
|
|
22
22
|
import { parseImports } from './renderer/moduleBindings.js';
|
|
23
23
|
import { evaluateScript } from './renderer/serverScope.js';
|
|
@@ -107,7 +107,18 @@ export function buildRoute(projectDir, route, { transformHtml } = {}) {
|
|
|
107
107
|
);
|
|
108
108
|
|
|
109
109
|
assertValidJavaScript(clientModule, name);
|
|
110
|
-
|
|
110
|
+
// A page with no bindings and no listeners does nothing on load, so
|
|
111
|
+
// the document does not reference its module — and without a reference
|
|
112
|
+
// there is no reason to write it. The page still arrives complete,
|
|
113
|
+
// because the markup was rendered during the build.
|
|
114
|
+
const isStatic = clientModule.includes(STATIC_MARKER);
|
|
115
|
+
|
|
116
|
+
if (isStatic) {
|
|
117
|
+
// A previous build may have left one behind.
|
|
118
|
+
if (existsSync(clientPath)) rmSync(clientPath);
|
|
119
|
+
} else {
|
|
120
|
+
writeFileSync(clientPath, clientModule, 'utf8');
|
|
121
|
+
}
|
|
111
122
|
|
|
112
123
|
// One runtime at the build root, shared by every page.
|
|
113
124
|
const runtimePath = resolve(buildRoot, RUNTIME_FILENAME);
|
|
@@ -125,6 +136,7 @@ export function buildRoute(projectDir, route, { transformHtml } = {}) {
|
|
|
125
136
|
|
|
126
137
|
let document = wrapDocument(html, projectTitle(projectDir), head, {
|
|
127
138
|
routerSrc: router ? `${route.assetPrefix}${ROUTER_FILENAME}` : null,
|
|
139
|
+
clientSrc: isStatic ? null : './page.client.js',
|
|
128
140
|
});
|
|
129
141
|
if (transformHtml) document = transformHtml(document);
|
|
130
142
|
|
|
@@ -598,7 +610,12 @@ function normaliseHeadLine(line) {
|
|
|
598
610
|
|
|
599
611
|
// A page's own <head> block wins over the fallback title, so a page
|
|
600
612
|
// can set its own <title>, stylesheets and meta tags.
|
|
601
|
-
function wrapDocument(
|
|
613
|
+
function wrapDocument(
|
|
614
|
+
bodyHtml,
|
|
615
|
+
title,
|
|
616
|
+
head = '',
|
|
617
|
+
{ routerSrc = null, clientSrc = './page.client.js' } = {}
|
|
618
|
+
) {
|
|
602
619
|
const hasOwnTitle = /<title>/i.test(head);
|
|
603
620
|
|
|
604
621
|
// The router is loaded after the page's own module, so a page is
|
|
@@ -615,7 +632,7 @@ function wrapDocument(bodyHtml, title, head = '', { routerSrc = null } = {}) {
|
|
|
615
632
|
${hasOwnTitle ? '' : ` <title>${escapeHtml(title)}</title>\n`}${head ? indent(head) + '\n' : ''}</head>
|
|
616
633
|
<body>
|
|
617
634
|
<div data-azox-root>${bodyHtml}</div>
|
|
618
|
-
|
|
635
|
+
${clientSrc ? `<script type="module" src="${clientSrc}"></script>` : ''}${router}
|
|
619
636
|
</body>
|
|
620
637
|
</html>
|
|
621
638
|
`;
|
package/core/commands/create.js
CHANGED
|
@@ -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.
|
|
@@ -32,6 +78,11 @@ export function compileToModule(
|
|
|
32
78
|
{ runtimeSpecifier, rewriteImports, inlineModules, routeParams }
|
|
33
79
|
) {
|
|
34
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));
|
|
35
86
|
const statements = [];
|
|
36
87
|
const rootVar = emitNode(ast.markup, statements, 'root');
|
|
37
88
|
|
|
@@ -65,13 +116,15 @@ export function compileToModule(
|
|
|
65
116
|
// A keyed list disposes the effects of rows that leave, so the
|
|
66
117
|
// module needs dispose as well as effect.
|
|
67
118
|
const needsDispose = statements.some((line) => line.includes('dispose('));
|
|
119
|
+
const needsUntracked = statements.some((line) => line.includes('untracked('));
|
|
68
120
|
|
|
69
121
|
const { imports, body } = mergeImports(
|
|
70
122
|
script,
|
|
71
123
|
hoisted,
|
|
72
124
|
runtimeSpecifier,
|
|
73
125
|
needsDispose,
|
|
74
|
-
inlineModules
|
|
126
|
+
inlineModules,
|
|
127
|
+
needsUntracked
|
|
75
128
|
);
|
|
76
129
|
|
|
77
130
|
// Narrowed to what the module actually reads, so importing
|
|
@@ -102,7 +155,8 @@ function mergeImports(
|
|
|
102
155
|
componentImports,
|
|
103
156
|
runtimeSpecifier,
|
|
104
157
|
needsDispose = false,
|
|
105
|
-
inlineModules = null
|
|
158
|
+
inlineModules = null,
|
|
159
|
+
needsUntracked = false
|
|
106
160
|
) {
|
|
107
161
|
const pageImports = [...script.matchAll(/^\s*(import\s[^;\n]+;?)\s*$/gm)]
|
|
108
162
|
.map((m) => m[1].trim())
|
|
@@ -116,6 +170,24 @@ function mergeImports(
|
|
|
116
170
|
const named = new Map();
|
|
117
171
|
const verbatim = new Set();
|
|
118
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
|
+
|
|
119
191
|
const record = (statement) => {
|
|
120
192
|
const match = statement.match(/^import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/);
|
|
121
193
|
|
|
@@ -124,7 +196,13 @@ function mergeImports(
|
|
|
124
196
|
return;
|
|
125
197
|
}
|
|
126
198
|
|
|
127
|
-
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;
|
|
128
206
|
const set = named.get(specifier) ?? new Set();
|
|
129
207
|
for (const binding of bindings.split(',')) {
|
|
130
208
|
if (binding.trim()) set.add(binding.trim());
|
|
@@ -132,10 +210,17 @@ function mergeImports(
|
|
|
132
210
|
named.set(specifier, set);
|
|
133
211
|
};
|
|
134
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
|
+
|
|
135
220
|
// `effect` is always needed: the compiler emits calls to it.
|
|
136
221
|
record(`import { effect } from '${runtimeSpecifier}';`);
|
|
137
222
|
if (needsDispose) record(`import { dispose } from '${runtimeSpecifier}';`);
|
|
138
|
-
|
|
223
|
+
if (needsUntracked) record(`import { untracked } from '${runtimeSpecifier}';`);
|
|
139
224
|
|
|
140
225
|
const lines = [
|
|
141
226
|
...[...named].map(([specifier, bindings]) => {
|
|
@@ -147,6 +232,15 @@ function mergeImports(
|
|
|
147
232
|
return { imports: lines.join('\n'), body };
|
|
148
233
|
}
|
|
149
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
|
+
|
|
150
244
|
// An import is inlined when every binding it declares was loaded by
|
|
151
245
|
// the build. Matching on the specifier would be wrong: the same file
|
|
152
246
|
// could be imported for some other reason.
|
|
@@ -195,10 +289,20 @@ function emitInlineModules(inlineModules, usage = '') {
|
|
|
195
289
|
const entries = Object.entries(inlineModules);
|
|
196
290
|
if (!entries.length) return '';
|
|
197
291
|
|
|
198
|
-
const lines = entries
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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);
|
|
202
306
|
}
|
|
203
307
|
|
|
204
308
|
// Keeps only the properties the module reads by name. A page that
|
|
@@ -253,11 +357,17 @@ function resolveRouteDeclarations(script, routeParams) {
|
|
|
253
357
|
.replace(/\bparams\s*\(\s*\)/g, JSON.stringify(routeParams));
|
|
254
358
|
}
|
|
255
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
|
+
|
|
256
366
|
function staticNote() {
|
|
257
367
|
return `
|
|
258
|
-
//
|
|
259
|
-
// markup is already complete and is left untouched.
|
|
260
|
-
// 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.
|
|
261
371
|
`;
|
|
262
372
|
}
|
|
263
373
|
|
|
@@ -297,7 +407,7 @@ function emitNode(node, statements, fallbackVar) {
|
|
|
297
407
|
statements.push(`const ${varName} = document.createElement(${JSON.stringify(node.name)});`);
|
|
298
408
|
|
|
299
409
|
for (const [key, attr] of Object.entries(node.attrs)) {
|
|
300
|
-
emitAttr(varName, key, attr, statements);
|
|
410
|
+
emitAttr(varName, key, attr, statements, node.name);
|
|
301
411
|
}
|
|
302
412
|
|
|
303
413
|
appendChildren(varName, node.children, statements, fallbackVar);
|
|
@@ -410,7 +520,7 @@ function emitEach(node, statements) {
|
|
|
410
520
|
// inside their own effect scope rather than inside the block's, since
|
|
411
521
|
// re-running the block must not tear down rows it is keeping.
|
|
412
522
|
function emitKeyedEach(node, statements) {
|
|
413
|
-
const
|
|
523
|
+
const buildParams = node.index ? `${node.alias}, ${node.index}` : node.alias;
|
|
414
524
|
|
|
415
525
|
const start = nextId();
|
|
416
526
|
const end = nextId();
|
|
@@ -432,12 +542,28 @@ function emitKeyedEach(node, statements) {
|
|
|
432
542
|
const bodyLines = [];
|
|
433
543
|
const roots = node.children.map((child) => emitNode(child, bodyLines, 'root'));
|
|
434
544
|
|
|
435
|
-
|
|
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}) => {`);
|
|
436
551
|
statements.push(` let _nodes;`);
|
|
437
|
-
|
|
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(() => {`);
|
|
438
557
|
for (const line of bodyLines) statements.push(` ${line}`);
|
|
439
|
-
|
|
440
|
-
|
|
558
|
+
// A root may be a DocumentFragment — a component with several roots
|
|
559
|
+
// returns one. A fragment empties when it is inserted and has no
|
|
560
|
+
// .remove(), so its children are recorded instead; otherwise removing
|
|
561
|
+
// the row threw and left it on screen with its cleanups unrun.
|
|
562
|
+
statements.push(
|
|
563
|
+
` _nodes = [${roots.filter((r) => r !== 'null').join(', ')}]` +
|
|
564
|
+
`.flatMap((_n) => (_n instanceof DocumentFragment ? [..._n.childNodes] : [_n]));`
|
|
565
|
+
);
|
|
566
|
+
statements.push(` }));`);
|
|
441
567
|
statements.push(` return { nodes: _nodes, scope: _scope };`);
|
|
442
568
|
statements.push(`};`);
|
|
443
569
|
|
|
@@ -575,7 +701,7 @@ function foldLiteralParts(parts) {
|
|
|
575
701
|
|
|
576
702
|
function emitText(node, statements, fallbackVar) {
|
|
577
703
|
const isFixed = (part) => part.kind === 'static' || part.kind === 'literal';
|
|
578
|
-
node = { ...node, parts: foldLiteralParts(node.parts) };
|
|
704
|
+
node = { ...node, parts: foldConstantParts(foldLiteralParts(node.parts)) };
|
|
579
705
|
|
|
580
706
|
// Nothing dynamic: one text node, no effect needed.
|
|
581
707
|
if (node.parts.every(isFixed)) {
|
|
@@ -595,6 +721,46 @@ function emitText(node, statements, fallbackVar) {
|
|
|
595
721
|
return varName;
|
|
596
722
|
}
|
|
597
723
|
|
|
724
|
+
// Turns an expression the build already resolved into a literal part, so
|
|
725
|
+
// the text node is created with the value rather than an effect being
|
|
726
|
+
// attached to write it. The page whose only "dynamic" text is a version
|
|
727
|
+
// number then ships as a static page.
|
|
728
|
+
function foldConstantParts(parts) {
|
|
729
|
+
if (!constantNames.size) return parts;
|
|
730
|
+
|
|
731
|
+
return parts.map((part) => {
|
|
732
|
+
if (part.kind !== 'expr' || !isConstantExpression(part.expr)) return part;
|
|
733
|
+
|
|
734
|
+
const value = evaluateInlinedExpression(part.expr);
|
|
735
|
+
if (value === undefined) return part;
|
|
736
|
+
|
|
737
|
+
return { kind: 'literal', value: String(value) };
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Evaluates a constant expression against the values the build resolved.
|
|
742
|
+
// Returns undefined when it cannot be evaluated, which leaves the part
|
|
743
|
+
// dynamic — the safe direction.
|
|
744
|
+
//
|
|
745
|
+
// Named for this file: the playground concatenates the compiler's
|
|
746
|
+
// modules into one scope, so a bare `evaluateConstant` collides with the
|
|
747
|
+
// one in resolveComponents.js and the whole bundle fails to parse.
|
|
748
|
+
function evaluateInlinedExpression(expr) {
|
|
749
|
+
try {
|
|
750
|
+
const names = [...constantNames];
|
|
751
|
+
const fn = new Function(...names, `return (${expr});`);
|
|
752
|
+
const value = fn(...names.map((name) => constantValues[name]));
|
|
753
|
+
|
|
754
|
+
// Only a primitive can be written into the markup as text.
|
|
755
|
+
if (value === null || value === undefined) return undefined;
|
|
756
|
+
if (typeof value === 'object' || typeof value === 'function') return undefined;
|
|
757
|
+
|
|
758
|
+
return value;
|
|
759
|
+
} catch {
|
|
760
|
+
return undefined;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
598
764
|
// The five entities that matter for text content. Numeric forms are
|
|
599
765
|
// handled too, since documentation snippets tend to use them.
|
|
600
766
|
function decodeEntities(text) {
|
|
@@ -608,22 +774,77 @@ function decodeEntities(text) {
|
|
|
608
774
|
.replace(/&/g, '&');
|
|
609
775
|
}
|
|
610
776
|
|
|
611
|
-
|
|
777
|
+
// Properties that must be set as properties rather than attributes:
|
|
778
|
+
// setAttribute("value") only sets the *initial* value, so after a user
|
|
779
|
+
// types, writing the attribute changes nothing they can see.
|
|
780
|
+
const DOM_PROPERTIES = new Set(['value', 'checked', 'selected', 'indeterminate']);
|
|
781
|
+
|
|
782
|
+
function emitAttr(varName, key, attr, statements, tagName) {
|
|
612
783
|
if (key.startsWith('on:')) {
|
|
613
784
|
const event = key.slice(3);
|
|
614
785
|
statements.push(`${varName}.addEventListener(${JSON.stringify(event)}, ${attr.expr});`);
|
|
615
786
|
return;
|
|
616
787
|
}
|
|
617
788
|
|
|
789
|
+
if (key.startsWith('bind:')) {
|
|
790
|
+
emitBinding(varName, key.slice(5), attr, statements, tagName);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
|
|
618
794
|
if (attr.kind === 'static') {
|
|
619
795
|
statements.push(`${varName}.setAttribute(${JSON.stringify(key)}, ${JSON.stringify(attr.value)});`);
|
|
620
796
|
return;
|
|
621
797
|
}
|
|
622
798
|
|
|
799
|
+
// A property has to be assigned, not set as an attribute — see
|
|
800
|
+
// DOM_PROPERTIES. Everything else is an attribute.
|
|
801
|
+
if (DOM_PROPERTIES.has(key)) {
|
|
802
|
+
statements.push(`effect(() => { ${varName}.${key} = ${attr.expr}; });`);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
623
806
|
// Dynamic attribute: wrap in its own effect, same fine-grained rule as text.
|
|
624
807
|
statements.push(`effect(() => { ${varName}.setAttribute(${JSON.stringify(key)}, String(${attr.expr})); });`);
|
|
625
808
|
}
|
|
626
809
|
|
|
810
|
+
// Two-way binding: the element shows the signal, and the signal follows
|
|
811
|
+
// the element. Writing it by hand means a value= and an on:input= that
|
|
812
|
+
// have to agree, and getting the event or the property wrong is easy —
|
|
813
|
+
// a checkbox reports `checked`, not `value`, and a number input reports
|
|
814
|
+
// a string.
|
|
815
|
+
function emitBinding(varName, property, attr, statements, tagName) {
|
|
816
|
+
const signal = attr.expr.trim();
|
|
817
|
+
|
|
818
|
+
// The signal itself, not a call: `bind:value={draft}`. Binding needs
|
|
819
|
+
// to write back, which a value cannot do.
|
|
820
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(signal)) {
|
|
821
|
+
throw new Error(
|
|
822
|
+
`Azox: bind:${property}={${signal}} needs a signal by name — ` +
|
|
823
|
+
`write bind:${property}={draft}, not bind:${property}={draft()}`
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// A checkbox's state is `checked`, and the event that reports it is
|
|
828
|
+
// "change" rather than "input".
|
|
829
|
+
const isCheckbox = property === 'checked';
|
|
830
|
+
const event = isCheckbox || tagName === 'select' ? 'change' : 'input';
|
|
831
|
+
|
|
832
|
+
statements.push(`effect(() => { ${varName}.${property} = ${signal}(); });`);
|
|
833
|
+
statements.push(
|
|
834
|
+
`${varName}.addEventListener(${JSON.stringify(event)}, (_e) => ` +
|
|
835
|
+
`${signal}.set(${readTarget(property, tagName)}));`
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// How the value is read back off the element. A number input reports a
|
|
840
|
+
// string, so it is converted — otherwise arithmetic on the signal
|
|
841
|
+
// silently concatenates.
|
|
842
|
+
function readTarget(property, tagName) {
|
|
843
|
+
if (property === 'checked') return '_e.target.checked';
|
|
844
|
+
if (tagName === 'input') return '(_e.target.type === "number" ? _e.target.valueAsNumber : _e.target.value)';
|
|
845
|
+
return '_e.target.value';
|
|
846
|
+
}
|
|
847
|
+
|
|
627
848
|
// Component imports are resolved at build time and inlined, so the
|
|
628
849
|
// .azox specifier must not survive into JavaScript the browser loads.
|
|
629
850
|
function dropComponentImports(script) {
|
package/core/compiler/parser.js
CHANGED
|
@@ -31,15 +31,31 @@ 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),
|
|
45
61
|
// A dynamic page destructures its route parameters the same way a
|
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';
|
|
@@ -32,6 +32,23 @@ export function signal(initialValue) {
|
|
|
32
32
|
return read;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// Runs `fn` with no effect considered active, so anything it creates
|
|
36
|
+
// belongs to nobody and survives the caller re-running.
|
|
37
|
+
//
|
|
38
|
+
// A keyed list needs this: its rows are built inside the list's own
|
|
39
|
+
// effect, which would make them its children — and re-running the list
|
|
40
|
+
// then tears down every surviving row, leaving it on screen but no
|
|
41
|
+
// longer reactive.
|
|
42
|
+
export function untracked(fn) {
|
|
43
|
+
const previous = activeEffect;
|
|
44
|
+
activeEffect = null;
|
|
45
|
+
try {
|
|
46
|
+
return fn();
|
|
47
|
+
} finally {
|
|
48
|
+
activeEffect = previous;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
35
52
|
export function effect(fn) {
|
|
36
53
|
const wrapped = () => {
|
|
37
54
|
// Drop last run's subscriptions before re-reading. Without this,
|
|
@@ -55,25 +72,108 @@ export function effect(fn) {
|
|
|
55
72
|
if (activeEffect) activeEffect.children.add(wrapped);
|
|
56
73
|
wrapped.children = new Set();
|
|
57
74
|
|
|
75
|
+
// Callbacks registered by onCleanup() while this effect runs.
|
|
76
|
+
wrapped.cleanups = new Set();
|
|
77
|
+
|
|
58
78
|
wrapped();
|
|
59
79
|
return wrapped;
|
|
60
80
|
}
|
|
61
81
|
|
|
82
|
+
// Registers work to undo when the surrounding scope goes away: a
|
|
83
|
+
// timer to clear, a listener to remove, a subscription to close.
|
|
84
|
+
//
|
|
85
|
+
// Called inside a component's script it runs when the component is
|
|
86
|
+
// removed — a row leaving a keyed list, or a branch of an <if> that is
|
|
87
|
+
// no longer taken. Called at the top level of a page there is nothing
|
|
88
|
+
// that ever removes it, so it never runs; that is a page living as long
|
|
89
|
+
// as the document, not a failure.
|
|
90
|
+
export function onCleanup(fn) {
|
|
91
|
+
if (typeof fn !== 'function') {
|
|
92
|
+
throw new TypeError('onCleanup() needs a function to call when the scope goes away');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!activeEffect) return;
|
|
96
|
+
activeEffect.cleanups.add(fn);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Runs after the DOM this scope builds is in the document.
|
|
100
|
+
//
|
|
101
|
+
// A component's script runs while its nodes are still being created,
|
|
102
|
+
// so measuring an element or focusing an input has to wait. Queued as
|
|
103
|
+
// a microtask, which is after the synchronous render and before the
|
|
104
|
+
// browser paints.
|
|
105
|
+
export function onMount(fn) {
|
|
106
|
+
if (typeof fn !== 'function') {
|
|
107
|
+
throw new TypeError('onMount() needs a function to call once the DOM is ready');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Captured now: by the time the microtask runs, the effect that owns
|
|
111
|
+
// this registration is no longer the active one.
|
|
112
|
+
const owner = activeEffect;
|
|
113
|
+
|
|
114
|
+
queueMicrotask(() => {
|
|
115
|
+
// The scope was disposed before it ever mounted — a row added and
|
|
116
|
+
// removed in the same tick. Running setup for something already
|
|
117
|
+
// gone would leak whatever it creates.
|
|
118
|
+
if (owner && owner.disposed) return;
|
|
119
|
+
|
|
120
|
+
const result = fn();
|
|
121
|
+
|
|
122
|
+
// A function returned from onMount is treated as its cleanup, so
|
|
123
|
+
// the common setup-and-teardown pair can stay in one place.
|
|
124
|
+
if (typeof result === 'function') {
|
|
125
|
+
if (owner && !owner.disposed) owner.cleanups.add(result);
|
|
126
|
+
else result();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
62
131
|
// Detaches an effect from every signal it read, and disposes anything
|
|
63
132
|
// it created. Called before a re-run and by dispose().
|
|
64
133
|
function unsubscribe(effectFn) {
|
|
65
134
|
for (const child of effectFn.children) unsubscribe(child);
|
|
66
135
|
effectFn.children.clear();
|
|
67
136
|
|
|
137
|
+
// Cleanups run before the effect is detached, so they still see the
|
|
138
|
+
// state they were registered against. One throwing must not stop the
|
|
139
|
+
// rest — a half-cleaned scope leaks whatever the others held.
|
|
140
|
+
for (const cleanup of effectFn.cleanups ?? []) {
|
|
141
|
+
try {
|
|
142
|
+
cleanup();
|
|
143
|
+
} catch (error) {
|
|
144
|
+
reportCleanupError(error);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
effectFn.cleanups?.clear();
|
|
148
|
+
|
|
68
149
|
for (const subscribers of effectFn.sources) subscribers.delete(effectFn);
|
|
69
150
|
effectFn.sources.clear();
|
|
70
151
|
}
|
|
71
152
|
|
|
153
|
+
// A cleanup that throws is a bug in the callback, not in the scope
|
|
154
|
+
// being torn down. Reported rather than swallowed, and rather than
|
|
155
|
+
// taking the rest of the teardown with it.
|
|
156
|
+
function reportCleanupError(error) {
|
|
157
|
+
if (typeof console !== 'undefined') {
|
|
158
|
+
console.error('Azox: an onCleanup callback threw', error);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
72
162
|
// Stops an effect permanently. Control-flow blocks use this to clean
|
|
73
163
|
// up the effects belonging to content they are about to remove;
|
|
74
164
|
// without it, every list item ever rendered would stay subscribed.
|
|
75
165
|
export function dispose(effectFn) {
|
|
76
|
-
if (effectFn)
|
|
166
|
+
if (!effectFn) return;
|
|
167
|
+
|
|
168
|
+
// Marked before the walk, so an onMount microtask that has not run
|
|
169
|
+
// yet knows its scope is gone and skips its setup.
|
|
170
|
+
markDisposed(effectFn);
|
|
171
|
+
unsubscribe(effectFn);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function markDisposed(effectFn) {
|
|
175
|
+
effectFn.disposed = true;
|
|
176
|
+
for (const child of effectFn.children) markDisposed(child);
|
|
77
177
|
}
|
|
78
178
|
|
|
79
179
|
export function computed(fn) {
|
|
@@ -45,8 +45,26 @@ function renderNode(node, scope, outer, modules = {}) {
|
|
|
45
45
|
const attrs = Object.entries(node.attrs)
|
|
46
46
|
.filter(([key]) => !key.startsWith('on:'))
|
|
47
47
|
.map(([key, attr]) => {
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
// bind:value={draft} renders as the plain attribute, so the input
|
|
49
|
+
// arrives holding its value rather than showing "bind:value" in
|
|
50
|
+
// the markup and filling in once scripts run.
|
|
51
|
+
//
|
|
52
|
+
// A binding names the signal rather than calling it — that is what
|
|
53
|
+
// lets it write back — so it has to be read here.
|
|
54
|
+
const bound = key.startsWith('bind:');
|
|
55
|
+
const name = bound ? key.slice(5) : key;
|
|
56
|
+
|
|
57
|
+
const raw = attr.kind === 'static'
|
|
58
|
+
? attr.value
|
|
59
|
+
: readAttrValue(attr.expr, scope, bound);
|
|
60
|
+
|
|
61
|
+
// A checkbox is checked by the attribute being present at all, so
|
|
62
|
+
// a falsy value must omit it rather than render checked="false".
|
|
63
|
+
if (name === 'checked' || name === 'selected') {
|
|
64
|
+
return raw ? ` ${name}` : '';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return ` ${name}="${escapeHtml(String(raw ?? ''))}"`;
|
|
50
68
|
})
|
|
51
69
|
.join('');
|
|
52
70
|
|
|
@@ -111,6 +129,13 @@ function renderIf(node, scope, outer, modules = {}) {
|
|
|
111
129
|
return branch.map((child) => renderNode(child, scope, outer, modules)).join('');
|
|
112
130
|
}
|
|
113
131
|
|
|
132
|
+
// Reads an attribute expression. A bound one names a signal, so it is
|
|
133
|
+
// called; anything else is evaluated as written.
|
|
134
|
+
function readAttrValue(expr, scope, bound) {
|
|
135
|
+
const value = evalExpr(expr, scope);
|
|
136
|
+
return bound && typeof value === 'function' ? value() : value;
|
|
137
|
+
}
|
|
138
|
+
|
|
114
139
|
// Three kinds of text, three rules:
|
|
115
140
|
// static — markup the author wrote, so an entity they typed
|
|
116
141
|
// (<) is meant to stay an entity. Passed through.
|
|
@@ -28,6 +28,16 @@ export function serverComputed(fn) {
|
|
|
28
28
|
return () => fn();
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Server rendering produces a string: there is no DOM to mount into,
|
|
32
|
+
// and nothing is ever removed, so both hooks do nothing here. The real
|
|
33
|
+
// ones take over in the browser.
|
|
34
|
+
//
|
|
35
|
+
// They still have to exist, or a component that uses them fails the
|
|
36
|
+
// build with "onMount is not defined" — the page would be unbuildable
|
|
37
|
+
// rather than merely non-reactive on the server.
|
|
38
|
+
export function serverOnMount() {}
|
|
39
|
+
export function serverOnCleanup() {}
|
|
40
|
+
|
|
31
41
|
// Every top-level binding the script introduces, so they can all be
|
|
32
42
|
// handed to the markup. Function and class declarations count too — a
|
|
33
43
|
// component may well define a helper the template calls.
|
|
@@ -75,6 +85,8 @@ export function evaluateScript(body, params = [], args = [], modules = {}, extra
|
|
|
75
85
|
const fn = new Function(
|
|
76
86
|
'signal',
|
|
77
87
|
'computed',
|
|
88
|
+
'onMount',
|
|
89
|
+
'onCleanup',
|
|
78
90
|
...imported,
|
|
79
91
|
...extraNames,
|
|
80
92
|
...params,
|
|
@@ -84,6 +96,8 @@ export function evaluateScript(body, params = [], args = [], modules = {}, extra
|
|
|
84
96
|
const declared = fn(
|
|
85
97
|
serverSignal,
|
|
86
98
|
serverComputed,
|
|
99
|
+
serverOnMount,
|
|
100
|
+
serverOnCleanup,
|
|
87
101
|
...imported.map((n) => modules[n]),
|
|
88
102
|
...extraNames.map((n) => extras[n]),
|
|
89
103
|
...args
|