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 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: early development (v0.3.0). APIs are unstable and will
15
- > change without notice until v1.0.
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, and
334
- one shared copy of the runtime. No dev machinery is included. Serve
335
- that directory with any static host and it works with no install
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
- writeFileSync(clientPath, clientModule, 'utf8');
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(bodyHtml, title, head = '', { routerSrc = null } = {}) {
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
- <script type="module" src="./page.client.js"></script>${router}
635
+ ${clientSrc ? `<script type="module" src="${clientSrc}"></script>` : ''}${router}
619
636
  </body>
620
637
  </html>
621
638
  `;
@@ -119,8 +119,7 @@ function starterPage(name) {
119
119
  </ul>
120
120
 
121
121
  <input
122
- value={draft()}
123
- on:input={(e) => draft.set(e.target.value)}
122
+ bind:value={draft}
124
123
  placeholder="Add a task"
125
124
  />
126
125
  <button on:click={() => {
@@ -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, specifier] = match;
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
- for (const statement of [...componentImports, ...pageImports]) record(statement);
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.map(
199
- ([name, value]) => `const ${name} = ${JSON.stringify(narrow(name, value, usage))};`
200
- );
201
- return `${lines.join('\n')}\n`;
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
- // This page has no bindings and no listeners, so the server-rendered
259
- // markup is already complete and is left untouched. render() is
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 params = node.index ? `${node.alias}, ${node.index}` : node.alias;
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
- statements.push(`const ${build} = (${params}) => {`);
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
- statements.push(` const _scope = effect(() => {`);
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
- statements.push(` _nodes = [${roots.filter((r) => r !== 'null').join(', ')}];`);
440
- statements.push(` });`);
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(/&amp;/g, '&');
609
775
  }
610
776
 
611
- function emitAttr(varName, key, attr, statements) {
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) {
@@ -31,15 +31,31 @@ export function parseAzox(source) {
31
31
  .trim();
32
32
 
33
33
  const tokens = tokenize(template);
34
- const { node, rest } = parseNode(tokens);
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: node,
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
- export { signal, effect, computed } from './reactivity/signal.js';
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) unsubscribe(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
- const value = attr.kind === 'static' ? attr.value : String(evalExpr(attr.expr, scope));
49
- return ` ${key}="${escapeHtml(value)}"`;
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
  // (&lt;) 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azoxjs",
3
- "version": "0.3.0",
3
+ "version": "1.0.1",
4
4
  "description": "Azox Framework — The Sound of Future Web",
5
5
  "type": "module",
6
6
  "license": "MIT",