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.
@@ -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.
@@ -58,24 +68,40 @@ export function declaredNames(script) {
58
68
  // Evaluates a script body and returns its declarations. `params` and
59
69
  // `args` pass a component's props in as arguments.
60
70
  //
71
+ // `extras` are further bindings the caller supplies by name — `params`
72
+ // for a dynamic page, and `routes` while its route list is collected.
73
+ //
61
74
  // `modules` carries what the script's imports brought in, as local
62
75
  // name → value. The body runs inside a `new Function`, which cannot
63
76
  // use `import`, so the bindings arrive as arguments instead — the
64
77
  // build resolves them, since loading a module needs the filesystem
65
78
  // and this file has to stay usable in a browser.
66
- export function evaluateScript(body, params = [], args = [], modules = {}) {
79
+ export function evaluateScript(body, params = [], args = [], modules = {}, extras = {}) {
67
80
  const imported = Object.keys(modules);
68
- const names = declaredNames(body).filter((name) => !imported.includes(name));
81
+ const extraNames = Object.keys(extras);
82
+ const reserved = new Set([...imported, ...extraNames]);
83
+ const names = declaredNames(body).filter((name) => !reserved.has(name));
69
84
 
70
85
  const fn = new Function(
71
86
  'signal',
72
87
  'computed',
88
+ 'onMount',
89
+ 'onCleanup',
73
90
  ...imported,
91
+ ...extraNames,
74
92
  ...params,
75
93
  `${body}\nreturn { ${names.join(', ')} };`
76
94
  );
77
95
 
78
- const declared = fn(serverSignal, serverComputed, ...imported.map((n) => modules[n]), ...args);
96
+ const declared = fn(
97
+ serverSignal,
98
+ serverComputed,
99
+ serverOnMount,
100
+ serverOnCleanup,
101
+ ...imported.map((n) => modules[n]),
102
+ ...extraNames.map((n) => extras[n]),
103
+ ...args
104
+ );
79
105
 
80
106
  // An imported binding is in scope for the markup too, the same way
81
107
  // it is in the compiled module.
package/core/routes.js CHANGED
@@ -5,6 +5,11 @@
5
5
  // pages/blog/index.azox → /blog → blog/index.html
6
6
  // pages/blog/first.azox → /blog/first → blog/first/index.html
7
7
  //
8
+ // A segment in brackets is a parameter, and the file is a template
9
+ // rather than a route of its own:
10
+ //
11
+ // pages/blog/[slug].azox → one page per entry the file declares
12
+ //
8
13
  // Emitting a directory with an index.html means clean URLs work on
9
14
  // any static host without rewrite rules, since serving index.html
10
15
  // for a directory is universal behaviour.
@@ -43,6 +48,38 @@ function walk(dir, pagesDir) {
43
48
  return found;
44
49
  }
45
50
 
51
+ // A bracketed segment names a parameter: [slug] matches one segment
52
+ // and binds it to `slug`.
53
+ const PARAM_SEGMENT = /^\[([A-Za-z_$][\w$]*)\]$/;
54
+
55
+ export function paramNames(segments) {
56
+ return segments.map((segment) => segment.match(PARAM_SEGMENT)?.[1]).filter(Boolean);
57
+ }
58
+
59
+ // Fills a template's bracketed segments from a set of parameter
60
+ // values, producing the concrete route that will be written.
61
+ export function resolveRoute(route, values) {
62
+ const segments = route.templateSegments.map((segment) => {
63
+ const name = segment.match(PARAM_SEGMENT)?.[1];
64
+ if (!name) return segment;
65
+ return String(values[name]);
66
+ });
67
+
68
+ const url = segments.length ? `/${segments.join('/')}` : '/';
69
+ const outputDir = segments.join('/');
70
+
71
+ return {
72
+ ...route,
73
+ params: values,
74
+ isTemplate: false,
75
+ name: segments.join('/'),
76
+ url,
77
+ htmlPath: outputDir ? `${outputDir}/index.html` : 'index.html',
78
+ assetPrefix: '../'.repeat(segments.length) || './',
79
+ outputDir,
80
+ };
81
+ }
82
+
46
83
  function describeRoute(pagesDir, sourcePath) {
47
84
  const relativePath = relative(pagesDir, sourcePath);
48
85
  const segments = relativePath.slice(0, -PAGE_EXTENSION.length).split(sep);
@@ -54,6 +91,8 @@ function describeRoute(pagesDir, sourcePath) {
54
91
  const url = routeSegments.length ? `/${routeSegments.join('/')}` : '/';
55
92
  const outputDir = routeSegments.join('/');
56
93
 
94
+ const params = paramNames(routeSegments);
95
+
57
96
  return {
58
97
  // The name used on the command line: `azox compile --page=blog/first`
59
98
  name: segments.join('/'),
@@ -64,6 +103,11 @@ function describeRoute(pagesDir, sourcePath) {
64
103
  // back out to reach the shared runtime at the build root.
65
104
  assetPrefix: '../'.repeat(routeSegments.length) || './',
66
105
  outputDir,
106
+ // A template is not a page: it stands in for however many the
107
+ // file declares, and is never written at this url.
108
+ isTemplate: params.length > 0,
109
+ paramNames: params,
110
+ templateSegments: routeSegments,
67
111
  };
68
112
  }
69
113
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azoxjs",
3
- "version": "0.2.0",
3
+ "version": "1.0.0",
4
4
  "description": "Azox Framework — The Sound of Future Web",
5
5
  "type": "module",
6
6
  "license": "MIT",