azoxjs 0.1.0 → 0.2.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 +112 -5
- package/core/build.js +237 -41
- package/core/cli/router.js +28 -0
- package/core/commands/create.js +44 -10
- package/core/commands/dev.js +34 -19
- package/core/commands/doctor.js +6 -1
- package/core/compiler/compileToJs.js +506 -36
- package/core/compiler/html.js +28 -0
- package/core/compiler/parser.js +204 -9
- package/core/compiler/resolveComponents.js +212 -39
- package/core/compiler/sourceResolver.js +42 -0
- package/core/nodeResolver.js +15 -0
- package/core/reactivity/signal.js +36 -1
- package/core/renderer/moduleBindings.js +88 -0
- package/core/renderer/renderToHtml.js +94 -18
- package/core/renderer/serverScope.js +83 -0
- package/core/router/navigate.js +211 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Azox Framework
|
|
2
2
|
|
|
3
3
|
[](https://github.com/darilpratomo/azox/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/azoxjs)
|
|
5
|
+
[](https://www.npmjs.com/package/azoxjs)
|
|
4
6
|
|
|
5
7
|
**The Sound of Future Web**
|
|
6
8
|
|
|
@@ -9,7 +11,7 @@ third-party CLI dependencies, no borrowed syntax from React, Vue, or
|
|
|
9
11
|
Next.js. It compiles `.azox` components directly into fine-grained,
|
|
10
12
|
signal-driven DOM updates.
|
|
11
13
|
|
|
12
|
-
> Status: early development (v0.
|
|
14
|
+
> Status: early development (v0.2.0). APIs are unstable and will
|
|
13
15
|
> change without notice until v1.0.
|
|
14
16
|
|
|
15
17
|
## Why Azox
|
|
@@ -120,10 +122,114 @@ Declaring props with `props()` is what lets the compiler reject a
|
|
|
120
122
|
caller that passes something the component never asked for, instead
|
|
121
123
|
of dropping it silently.
|
|
122
124
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
A component may hold its own state. Its `<script>` becomes a scope
|
|
126
|
+
of its own, so two uses of the same component are independent — each
|
|
127
|
+
`<Counter />` below counts separately:
|
|
128
|
+
|
|
129
|
+
```html
|
|
130
|
+
<!-- components/Counter.azox -->
|
|
131
|
+
<script>
|
|
132
|
+
import { signal } from 'azox/reactivity';
|
|
133
|
+
const count = signal(0);
|
|
134
|
+
</script>
|
|
135
|
+
|
|
136
|
+
<button on:click={() => count.set(count() + 1)}>{count()}</button>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
```html
|
|
140
|
+
<main>
|
|
141
|
+
<Counter />
|
|
142
|
+
<Counter />
|
|
143
|
+
</main>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
There is still no component instance at runtime: the compiler wraps
|
|
147
|
+
each use in its own JavaScript scope, which is ordinary scoping
|
|
148
|
+
rather than a framework construct.
|
|
149
|
+
|
|
150
|
+
## Loops and conditionals
|
|
151
|
+
|
|
152
|
+
Control flow is expressed as tags, so it nests inside markup like
|
|
153
|
+
anything else.
|
|
154
|
+
|
|
155
|
+
```html
|
|
156
|
+
<ul>
|
|
157
|
+
<each item={todos()} as="todo" index="i">
|
|
158
|
+
<li>{i + 1}. {todo}</li>
|
|
159
|
+
</each>
|
|
160
|
+
</ul>
|
|
161
|
+
|
|
162
|
+
<if cond={user()}>
|
|
163
|
+
<p>Signed in as {user().name}</p>
|
|
164
|
+
<else />
|
|
165
|
+
<a href="/login">Sign in</a>
|
|
166
|
+
</if>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Each block marks its place with a pair of comment nodes, and an
|
|
170
|
+
update replaces only the nodes between them.
|
|
171
|
+
|
|
172
|
+
By default a change to a list rebuilds its rows. Give a row an
|
|
173
|
+
identity with `key` and it survives instead: reordering moves it,
|
|
174
|
+
removing one leaves the rest untouched, and adding one does not
|
|
175
|
+
disturb what is already there.
|
|
176
|
+
|
|
177
|
+
```html
|
|
178
|
+
<each item={tasks()} as="task" key={task.id}>
|
|
179
|
+
<li><TaskRow title={task.title} /></li>
|
|
180
|
+
</each>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Use something stable and unique to the row — a database id, not its
|
|
184
|
+
position, since a position changes when the list does.
|
|
185
|
+
|
|
186
|
+
## Client-side routing
|
|
187
|
+
|
|
188
|
+
By default every link is a full page load, which is the right
|
|
189
|
+
behaviour for a static site. Opt in to client-side navigation with
|
|
190
|
+
`router: true` in your project's `package.json`:
|
|
191
|
+
|
|
192
|
+
```json
|
|
193
|
+
{
|
|
194
|
+
"router": true
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Internal links are then swapped in place: the new page's HTML is
|
|
199
|
+
fetched, the document body and `<head>` are replaced, and its module
|
|
200
|
+
runs. Scroll position, the back button, and `<a target>` all behave
|
|
201
|
+
as they would with a full load. A link is prefetched when the pointer
|
|
202
|
+
enters it, so the page is usually already in hand by the time it is
|
|
203
|
+
clicked.
|
|
204
|
+
|
|
205
|
+
Anything the router cannot handle — an external origin, a download,
|
|
206
|
+
a modifier-click — falls through to the browser untouched.
|
|
207
|
+
|
|
208
|
+
## Importing data
|
|
209
|
+
|
|
210
|
+
A `<script>` block may import a `.json` file, which is how a page
|
|
211
|
+
reads a constant it should not have written out by hand:
|
|
212
|
+
|
|
213
|
+
```html
|
|
214
|
+
<script>
|
|
215
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
216
|
+
</script>
|
|
217
|
+
|
|
218
|
+
<span>v{pkg.version}</span>
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The file is read once during the build. Server rendering evaluates
|
|
222
|
+
against it, and the value is compiled into the module as a constant
|
|
223
|
+
rather than imported — the file sits outside the build directory and
|
|
224
|
+
is never deployed, so an import would 404 in the browser.
|
|
225
|
+
|
|
226
|
+
Only the properties the markup reads are included, so importing
|
|
227
|
+
`package.json` for a version does not ship the rest of the file to
|
|
228
|
+
every visitor.
|
|
229
|
+
|
|
230
|
+
Importing a `.js` module is not supported: it would mean executing
|
|
231
|
+
project code during the build. Use a `.json` file for data, and
|
|
232
|
+
`azox/reactivity` for signals.
|
|
127
233
|
|
|
128
234
|
## Getting Started
|
|
129
235
|
|
|
@@ -179,6 +285,7 @@ azox/
|
|
|
179
285
|
│ ├── dev/ dev server, file watching, live reload
|
|
180
286
|
│ ├── reactivity/ signal() / effect() / computed()
|
|
181
287
|
│ ├── renderer/ server-side HTML rendering
|
|
288
|
+
│ ├── router/ opt-in client-side navigation
|
|
182
289
|
│ ├── build.js the build pipeline, shared by commands
|
|
183
290
|
│ ├── routes.js file layout → urls and output paths
|
|
184
291
|
│ └── meta.js version and identity strings
|
package/core/build.js
CHANGED
|
@@ -3,14 +3,27 @@
|
|
|
3
3
|
// result. `azox compile` runs it once; `azox dev` runs it on every
|
|
4
4
|
// change, so it returns data rather than printing.
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
readFileSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
copyFileSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
} from 'node:fs';
|
|
15
|
+
import { resolve, basename, relative, dirname, join } from 'node:path';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
8
17
|
|
|
9
18
|
import { parseAzox } from './compiler/parser.js';
|
|
10
19
|
import { resolveComponents } from './compiler/resolveComponents.js';
|
|
11
20
|
import { compileToModule } from './compiler/compileToJs.js';
|
|
12
21
|
import { renderToHtml } from './renderer/renderToHtml.js';
|
|
22
|
+
import { parseImports } from './renderer/moduleBindings.js';
|
|
23
|
+
import { evaluateScript } from './renderer/serverScope.js';
|
|
24
|
+
import { escapeHtml } from './compiler/html.js';
|
|
13
25
|
import { collectRoutes, findRoute } from './routes.js';
|
|
26
|
+
import { createNodeResolver } from './nodeResolver.js';
|
|
14
27
|
import { ROOT_DIR } from './meta.js';
|
|
15
28
|
import { BuildError } from './buildError.js';
|
|
16
29
|
|
|
@@ -20,7 +33,15 @@ import { BuildError } from './buildError.js';
|
|
|
20
33
|
// host can serve it with no install step.
|
|
21
34
|
const RUNTIME_FILENAME = 'azox-runtime.js';
|
|
22
35
|
|
|
36
|
+
// The client-side router is opt-in, via `router: true` in the project's
|
|
37
|
+
// package.json. Changing how every link behaves is not something a
|
|
38
|
+
// project should get without asking, and a site of plain documents is
|
|
39
|
+
// perfectly well served by ordinary navigation.
|
|
40
|
+
const ROUTER_FILENAME = 'azox-router.js';
|
|
41
|
+
|
|
23
42
|
export const PAGES_DIR = 'pages';
|
|
43
|
+
export const COMPONENTS_DIR = 'components';
|
|
44
|
+
export const PUBLIC_DIR = 'public';
|
|
24
45
|
export const BUILD_DIR = '.azox/build';
|
|
25
46
|
|
|
26
47
|
export { BuildError };
|
|
@@ -37,12 +58,17 @@ export function buildRoute(projectDir, route, { transformHtml } = {}) {
|
|
|
37
58
|
|
|
38
59
|
// Components are inlined here, before either output is produced, so
|
|
39
60
|
// the compiler and the renderer both see plain markup.
|
|
40
|
-
const ast = resolveComponents(parsed, sourcePath);
|
|
61
|
+
const ast = resolveComponents(parsed, sourcePath, createNodeResolver());
|
|
62
|
+
|
|
63
|
+
// Imports the script pulls in are loaded once: server rendering
|
|
64
|
+
// evaluates against them, and the client module inlines them rather
|
|
65
|
+
// than importing a path that is never deployed.
|
|
66
|
+
const inlineModules = loadModules(ast.script, sourcePath, ast.componentImports ?? []);
|
|
41
67
|
|
|
42
68
|
// SSR pass: render initial markup without touching browser DOM APIs.
|
|
43
69
|
let html;
|
|
44
70
|
try {
|
|
45
|
-
html = renderToHtml(ast, buildServerScope(ast.script));
|
|
71
|
+
html = renderToHtml(ast, buildServerScope(ast.script, inlineModules), inlineModules);
|
|
46
72
|
} catch (error) {
|
|
47
73
|
if (!(error instanceof BuildError)) throw error;
|
|
48
74
|
throw new BuildError(`in ${PAGES_DIR}/${name}.azox: ${error.message}`);
|
|
@@ -62,9 +88,15 @@ export function buildRoute(projectDir, route, { transformHtml } = {}) {
|
|
|
62
88
|
|
|
63
89
|
const clientModule = rewriteRuntimeImports(
|
|
64
90
|
compileToModule(ast, {
|
|
65
|
-
sourcePath,
|
|
66
|
-
outPath: clientPath,
|
|
67
91
|
runtimeSpecifier,
|
|
92
|
+
// The user's own relative imports were written next to the
|
|
93
|
+
// page; the compiled module lives in .azox/build/, so they need
|
|
94
|
+
// re-expressing from there.
|
|
95
|
+
// An import hoisted out of a component is relative to that
|
|
96
|
+
// component's file, which is why the hook takes a path.
|
|
97
|
+
rewriteImports: (script, from = sourcePath) =>
|
|
98
|
+
rebaseImports(script, dirname(from), clientPath),
|
|
99
|
+
inlineModules,
|
|
68
100
|
}),
|
|
69
101
|
runtimeSpecifier
|
|
70
102
|
);
|
|
@@ -76,7 +108,14 @@ export function buildRoute(projectDir, route, { transformHtml } = {}) {
|
|
|
76
108
|
const runtimePath = resolve(buildRoot, RUNTIME_FILENAME);
|
|
77
109
|
copyFileSync(resolve(ROOT_DIR, 'core/reactivity/signal.js'), runtimePath);
|
|
78
110
|
|
|
79
|
-
|
|
111
|
+
const router = routerEnabled(projectDir);
|
|
112
|
+
if (router) {
|
|
113
|
+
copyFileSync(resolve(ROOT_DIR, 'core/router/navigate.js'), resolve(buildRoot, ROUTER_FILENAME));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let document = wrapDocument(html, projectTitle(projectDir), ast.head, {
|
|
117
|
+
routerSrc: router ? `${route.assetPrefix}${ROUTER_FILENAME}` : null,
|
|
118
|
+
});
|
|
80
119
|
if (transformHtml) document = transformHtml(document);
|
|
81
120
|
|
|
82
121
|
const htmlPath = resolve(buildRoot, route.htmlPath);
|
|
@@ -94,7 +133,86 @@ export function buildAll(projectDir, options) {
|
|
|
94
133
|
);
|
|
95
134
|
}
|
|
96
135
|
|
|
97
|
-
|
|
136
|
+
const results = routes.map((route) => buildRoute(projectDir, route, options));
|
|
137
|
+
const assets = copyPublicAssets(projectDir);
|
|
138
|
+
removeStaleOutput(projectDir, results, assets);
|
|
139
|
+
|
|
140
|
+
return results;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Deletes output belonging to pages that no longer exist. Without
|
|
144
|
+
// this, deleting a page leaves its built copy behind and a deployed
|
|
145
|
+
// site keeps serving it.
|
|
146
|
+
//
|
|
147
|
+
// Deliberately narrow: it only ever removes an index.html or a
|
|
148
|
+
// page.client.js that this build did not just write, and only inside
|
|
149
|
+
// the build directory. Anything else found there — a file copied from
|
|
150
|
+
// public/, something a user put there — is left alone.
|
|
151
|
+
function removeStaleOutput(projectDir, results, assets = []) {
|
|
152
|
+
const buildRoot = resolve(projectDir, BUILD_DIR);
|
|
153
|
+
if (!existsSync(buildRoot)) return;
|
|
154
|
+
|
|
155
|
+
const written = new Set([
|
|
156
|
+
...results.flatMap((result) => [result.htmlPath, result.clientPath]),
|
|
157
|
+
...assets,
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
const generated = new Set(['index.html', 'page.client.js']);
|
|
161
|
+
const emptied = [];
|
|
162
|
+
|
|
163
|
+
const walk = (dir) => {
|
|
164
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
165
|
+
const full = join(dir, entry.name);
|
|
166
|
+
|
|
167
|
+
if (entry.isDirectory()) {
|
|
168
|
+
walk(full);
|
|
169
|
+
// A directory left empty held only pages that are now gone.
|
|
170
|
+
if (readdirSync(full).length === 0) emptied.push(full);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (generated.has(entry.name) && !written.has(full)) rmSync(full);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
walk(buildRoot);
|
|
179
|
+
|
|
180
|
+
// Innermost first, so a nested route's directories go too.
|
|
181
|
+
for (const dir of emptied.reverse()) {
|
|
182
|
+
if (existsSync(dir) && readdirSync(dir).length === 0) rmSync(dir, { recursive: true });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Everything in public/ is copied to the build root untouched, so a
|
|
187
|
+
// stylesheet, font or image is referenced by the same path in source
|
|
188
|
+
// and in the built site: public/style.css -> /style.css.
|
|
189
|
+
export function copyPublicAssets(projectDir) {
|
|
190
|
+
const publicDir = resolve(projectDir, PUBLIC_DIR);
|
|
191
|
+
if (!existsSync(publicDir)) return [];
|
|
192
|
+
|
|
193
|
+
const buildRoot = resolve(projectDir, BUILD_DIR);
|
|
194
|
+
const copied = [];
|
|
195
|
+
|
|
196
|
+
const walk = (dir, relativeDir) => {
|
|
197
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
198
|
+
if (entry.name.startsWith('.')) continue;
|
|
199
|
+
|
|
200
|
+
const from = join(dir, entry.name);
|
|
201
|
+
const to = join(buildRoot, relativeDir, entry.name);
|
|
202
|
+
|
|
203
|
+
if (entry.isDirectory()) {
|
|
204
|
+
mkdirSync(to, { recursive: true });
|
|
205
|
+
walk(from, join(relativeDir, entry.name));
|
|
206
|
+
} else {
|
|
207
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
208
|
+
copyFileSync(from, to);
|
|
209
|
+
copied.push(to);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
walk(publicDir, '');
|
|
215
|
+
return copied;
|
|
98
216
|
}
|
|
99
217
|
|
|
100
218
|
// Builds a single page by route name or URL.
|
|
@@ -110,8 +228,31 @@ export function buildPage(projectDir, pageName, options) {
|
|
|
110
228
|
|
|
111
229
|
// Rewrites the bare specifier a page's own <script> uses to the same
|
|
112
230
|
// path the compiler emitted for the runtime import.
|
|
231
|
+
//
|
|
232
|
+
// Anchored to an import statement on its own line. Matching the bare
|
|
233
|
+
// string anywhere would rewrite data that merely contains it — a JSON
|
|
234
|
+
// import inlined into the module turned `"bin": {"azox": …}` into a
|
|
235
|
+
// path to the runtime.
|
|
113
236
|
function rewriteRuntimeImports(code, runtimeSpecifier) {
|
|
114
|
-
return code.replace(
|
|
237
|
+
return code.replace(
|
|
238
|
+
/^([ \t]*import\s[\s\S]*?from\s+)(['"])azox(?:js)?(?:\/reactivity)?\2/gm,
|
|
239
|
+
`$1'${runtimeSpecifier}'`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Re-expresses the relative imports in a page's <script> so they
|
|
244
|
+
// still resolve from the compiled module's directory. Lives here
|
|
245
|
+
// rather than in the compiler because it is a fact about where files
|
|
246
|
+
// land on disk, which the compiler deliberately knows nothing about.
|
|
247
|
+
function rebaseImports(script, sourceDir, outPath) {
|
|
248
|
+
return script.replace(
|
|
249
|
+
/(from\s+|import\s+)(['"])(\.[^'"]*)\2/g,
|
|
250
|
+
(full, keyword, quote, specifier) => {
|
|
251
|
+
let rebased = relative(dirname(outPath), resolve(sourceDir, specifier));
|
|
252
|
+
if (!rebased.startsWith('.')) rebased = `./${rebased}`;
|
|
253
|
+
return `${keyword}${quote}${rebased}${quote}`;
|
|
254
|
+
}
|
|
255
|
+
);
|
|
115
256
|
}
|
|
116
257
|
|
|
117
258
|
// A compiler must never write output it knows is broken. Parsing the
|
|
@@ -159,60 +300,115 @@ function projectTitle(projectDir) {
|
|
|
159
300
|
// evaluate the expressions the template references. The script is
|
|
160
301
|
// trusted project source, not user input — the same assumption any
|
|
161
302
|
// template engine's SSR step makes.
|
|
162
|
-
function buildServerScope(script) {
|
|
163
|
-
// Strip imports: the server supplies its own
|
|
164
|
-
//
|
|
165
|
-
|
|
303
|
+
function buildServerScope(script, modules = {}) {
|
|
304
|
+
// Strip imports: the server supplies its own primitives rather than
|
|
305
|
+
// loading the real reactive runtime, and anything else a script
|
|
306
|
+
// imports was resolved by loadModules and is passed in.
|
|
307
|
+
const body = script.replace(/^\s*import\s.+?;?\s*$/gm, '');
|
|
166
308
|
|
|
167
309
|
try {
|
|
168
|
-
|
|
169
|
-
return fn(serverSignal);
|
|
310
|
+
return evaluateScript(body, [], [], modules);
|
|
170
311
|
} catch (error) {
|
|
312
|
+
if (error instanceof BuildError) throw error;
|
|
171
313
|
throw new BuildError(`failed to evaluate the page's <script> block: ${error.message}`);
|
|
172
314
|
}
|
|
173
315
|
}
|
|
174
316
|
|
|
175
|
-
//
|
|
176
|
-
// the
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
317
|
+
// Resolves what a script's imports bring in, so server rendering sees
|
|
318
|
+
// the same values the browser will.
|
|
319
|
+
//
|
|
320
|
+
// Only JSON is loaded. A JSON import is synchronous and has no side
|
|
321
|
+
// effects, which suits a build step that must stay synchronous — and
|
|
322
|
+
// it covers the case this exists for: reading a version or some other
|
|
323
|
+
// constant out of package.json. Importing a .js module would mean
|
|
324
|
+
// executing project code during the build, and `require(esm)` only
|
|
325
|
+
// works from Node 22.12, below the floor this package declares.
|
|
326
|
+
function loadModules(script, sourcePath, componentImports = []) {
|
|
327
|
+
// A component's hoisted import is relative to the component's own
|
|
328
|
+
// file, so each is resolved against the path it came with.
|
|
329
|
+
const imports = [
|
|
330
|
+
...parseImports(script).map((entry) => ({ ...entry, from: sourcePath })),
|
|
331
|
+
...componentImports.flatMap((entry) =>
|
|
332
|
+
parseImports(entry.statement).map((parsed) => ({ ...parsed, from: entry.path }))
|
|
333
|
+
),
|
|
334
|
+
];
|
|
335
|
+
|
|
336
|
+
if (!imports.length) return {};
|
|
337
|
+
|
|
338
|
+
const bindings = {};
|
|
339
|
+
|
|
340
|
+
for (const { specifier, bindings: names, from } of imports) {
|
|
341
|
+
if (!specifier.endsWith('.json')) {
|
|
342
|
+
throw new BuildError(
|
|
343
|
+
`cannot import '${specifier}': a <script> block may import .azox components, ` +
|
|
344
|
+
`'azox/reactivity', and .json files. Other modules are not available during ` +
|
|
345
|
+
`server rendering.`
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const require = createRequire(from ? `file://${from}` : import.meta.url);
|
|
350
|
+
|
|
351
|
+
let loaded;
|
|
352
|
+
try {
|
|
353
|
+
loaded = require(specifier);
|
|
354
|
+
} catch (error) {
|
|
355
|
+
throw new BuildError(`cannot import '${specifier}': ${error.message.split('\n')[0]}`);
|
|
356
|
+
}
|
|
186
357
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
358
|
+
for (const { local, imported } of names) {
|
|
359
|
+
if (imported === '*' || imported === 'default') bindings[local] = loaded;
|
|
360
|
+
else bindings[local] = loaded[imported];
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return bindings;
|
|
193
365
|
}
|
|
194
366
|
|
|
195
367
|
// The client module sits next to the page's index.html, so the src is
|
|
196
368
|
// the same for every route regardless of how deep it is.
|
|
197
|
-
|
|
369
|
+
// A page's own <head> block wins over the fallback title, so a page
|
|
370
|
+
// can set its own <title>, stylesheets and meta tags.
|
|
371
|
+
function wrapDocument(bodyHtml, title, head = '', { routerSrc = null } = {}) {
|
|
372
|
+
const hasOwnTitle = /<title>/i.test(head);
|
|
373
|
+
|
|
374
|
+
// The router is loaded after the page's own module, so a page is
|
|
375
|
+
// interactive before navigation is enhanced.
|
|
376
|
+
const router = routerSrc
|
|
377
|
+
? `\n<script type="module">import { startRouter } from '${routerSrc}'; startRouter();</script>`
|
|
378
|
+
: '';
|
|
379
|
+
|
|
198
380
|
return `<!doctype html>
|
|
199
381
|
<html lang="en">
|
|
200
382
|
<head>
|
|
201
383
|
<meta charset="utf-8" />
|
|
202
384
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
203
|
-
<title>${escapeHtml(title)}</title>
|
|
204
|
-
</head>
|
|
385
|
+
${hasOwnTitle ? '' : ` <title>${escapeHtml(title)}</title>\n`}${head ? indent(head) + '\n' : ''}</head>
|
|
205
386
|
<body>
|
|
206
387
|
<div data-azox-root>${bodyHtml}</div>
|
|
207
|
-
<script type="module" src="./page.client.js"></script
|
|
388
|
+
<script type="module" src="./page.client.js"></script>${router}
|
|
208
389
|
</body>
|
|
209
390
|
</html>
|
|
210
391
|
`;
|
|
211
392
|
}
|
|
212
393
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
394
|
+
// Reads `router` from the project's package.json. A malformed file is
|
|
395
|
+
// not this function's problem to report — the build reads it again for
|
|
396
|
+
// the page title and will surface anything wrong there.
|
|
397
|
+
function routerEnabled(projectDir) {
|
|
398
|
+
const pkgPath = resolve(projectDir, 'package.json');
|
|
399
|
+
if (!existsSync(pkgPath)) return false;
|
|
400
|
+
|
|
401
|
+
try {
|
|
402
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).router === true;
|
|
403
|
+
} catch {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function indent(block) {
|
|
409
|
+
return block
|
|
410
|
+
.split('\n')
|
|
411
|
+
.map((line) => (line.trim() ? ` ${line.trim()}` : line))
|
|
412
|
+
.join('\n');
|
|
218
413
|
}
|
|
414
|
+
|
package/core/cli/router.js
CHANGED
|
@@ -20,11 +20,13 @@ export const registry = {
|
|
|
20
20
|
run: devCommand,
|
|
21
21
|
describe: 'Serve the project and rebuild on every change',
|
|
22
22
|
examples: ['azox dev', 'azox dev --port=5000'],
|
|
23
|
+
flags: ['port', 'host'],
|
|
23
24
|
},
|
|
24
25
|
compile: {
|
|
25
26
|
run: compileCommand,
|
|
26
27
|
describe: 'Compile pages to HTML + hydration modules',
|
|
27
28
|
examples: ['azox compile', 'azox compile --page=about'],
|
|
29
|
+
flags: ['page'],
|
|
28
30
|
},
|
|
29
31
|
doctor: {
|
|
30
32
|
run: doctorCommand,
|
|
@@ -65,9 +67,35 @@ export function runCommand(command, context) {
|
|
|
65
67
|
return;
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
const unknown = unknownFlags(context.flags, entry.flags ?? []);
|
|
71
|
+
|
|
72
|
+
if (unknown.length) {
|
|
73
|
+
const plural = unknown.length === 1 ? 'flag' : 'flags';
|
|
74
|
+
console.error(
|
|
75
|
+
`Azox: unknown ${plural} for "${resolved}": ${unknown.map((f) => `--${f}`).join(', ')}`
|
|
76
|
+
);
|
|
77
|
+
console.error(
|
|
78
|
+
entry.flags?.length
|
|
79
|
+
? `It accepts: ${entry.flags.map((f) => `--${f}`).join(', ')}.`
|
|
80
|
+
: 'It accepts no flags.'
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
68
87
|
return entry.run({ ...context, registry });
|
|
69
88
|
}
|
|
70
89
|
|
|
90
|
+
// A mistyped flag used to be ignored, so `azox compile --pge=about`
|
|
91
|
+
// quietly built every page while looking as though it had built one.
|
|
92
|
+
// Command-selecting flags are allowed everywhere, since they are how
|
|
93
|
+
// the command was chosen in the first place.
|
|
94
|
+
function unknownFlags(flags, accepted) {
|
|
95
|
+
const always = new Set([...Object.keys(FLAG_ALIASES), ...accepted]);
|
|
96
|
+
return Object.keys(flags).filter((flag) => !always.has(flag));
|
|
97
|
+
}
|
|
98
|
+
|
|
71
99
|
function aliasFor(flags) {
|
|
72
100
|
for (const [flag, command] of Object.entries(FLAG_ALIASES)) {
|
|
73
101
|
if (flags[flag]) return command;
|
package/core/commands/create.js
CHANGED
|
@@ -88,18 +88,46 @@ function starterPage(name) {
|
|
|
88
88
|
import Counter from '../components/Counter.azox';
|
|
89
89
|
import { signal } from 'azox/reactivity';
|
|
90
90
|
|
|
91
|
-
const
|
|
91
|
+
const tasks = signal([
|
|
92
|
+
{ id: 1, title: 'Edit this page', done: true },
|
|
93
|
+
{ id: 2, title: 'Add a page of your own', done: false },
|
|
94
|
+
]);
|
|
95
|
+
const draft = signal('');
|
|
96
|
+
let nextId = 3;
|
|
92
97
|
</script>
|
|
93
98
|
|
|
94
99
|
<main class="page">
|
|
95
100
|
<h1>${name}</h1>
|
|
96
101
|
<p>Built with Azox.</p>
|
|
97
102
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
+
<!-- Each Counter holds a count of its own. -->
|
|
104
|
+
<Counter label="Left" />
|
|
105
|
+
<Counter label="Right" />
|
|
106
|
+
|
|
107
|
+
<!-- key={task.id} gives each row an identity, so adding to the
|
|
108
|
+
list leaves the rows already there untouched. -->
|
|
109
|
+
<ul>
|
|
110
|
+
<each item={tasks()} as="task" index="i" key={task.id}>
|
|
111
|
+
<li>
|
|
112
|
+
<if cond={task.done}>
|
|
113
|
+
<s>{i + 1}. {task.title}</s>
|
|
114
|
+
<else />
|
|
115
|
+
<span>{i + 1}. {task.title}</span>
|
|
116
|
+
</if>
|
|
117
|
+
</li>
|
|
118
|
+
</each>
|
|
119
|
+
</ul>
|
|
120
|
+
|
|
121
|
+
<input
|
|
122
|
+
value={draft()}
|
|
123
|
+
on:input={(e) => draft.set(e.target.value)}
|
|
124
|
+
placeholder="Add a task"
|
|
125
|
+
/>
|
|
126
|
+
<button on:click={() => {
|
|
127
|
+
if (!draft()) return;
|
|
128
|
+
tasks.set([...tasks(), { id: nextId++, title: draft(), done: false }]);
|
|
129
|
+
draft.set('');
|
|
130
|
+
}}>Add</button>
|
|
103
131
|
|
|
104
132
|
<p><a href="/about">About</a></p>
|
|
105
133
|
</main>
|
|
@@ -117,14 +145,20 @@ function aboutPage(name) {
|
|
|
117
145
|
`;
|
|
118
146
|
}
|
|
119
147
|
|
|
120
|
-
//
|
|
121
|
-
//
|
|
148
|
+
// A component may hold state of its own, and each use gets its own
|
|
149
|
+
// copy — the two Counters on the starter page count independently.
|
|
122
150
|
function starterComponent() {
|
|
123
151
|
return `<script>
|
|
124
|
-
|
|
152
|
+
import { signal } from 'azox/reactivity';
|
|
153
|
+
|
|
154
|
+
const { label } = props();
|
|
155
|
+
const count = signal(0);
|
|
125
156
|
</script>
|
|
126
157
|
|
|
127
|
-
<
|
|
158
|
+
<span class="counter">
|
|
159
|
+
{label}: {count()}
|
|
160
|
+
<button on:click={() => count.set(count() + 1)}>+</button>
|
|
161
|
+
</span>
|
|
128
162
|
`;
|
|
129
163
|
}
|
|
130
164
|
|