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
|
@@ -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.
|
|
@@ -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
|
|
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(
|
|
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
|
|