azoxjs 0.2.0 → 0.3.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 +77 -1
- package/core/build.js +240 -10
- package/core/commands/compile.js +5 -1
- package/core/commands/dev.js +10 -1
- package/core/compiler/compileToJs.js +24 -1
- package/core/compiler/parser.js +15 -0
- package/core/compiler/resolveComponents.js +22 -7
- package/core/dev/watcher.js +12 -1
- package/core/renderer/serverScope.js +15 -3
- package/core/routes.js +44 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ 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.
|
|
14
|
+
> Status: early development (v0.3.0). APIs are unstable and will
|
|
15
15
|
> change without notice until v1.0.
|
|
16
16
|
|
|
17
17
|
## Why Azox
|
|
@@ -65,6 +65,46 @@ pages/blog/first-post.azox → /blog/first-post
|
|
|
65
65
|
Build one page with `azox compile --page=blog/first-post`, or by its
|
|
66
66
|
URL: `azox compile --page=/blog/first-post`.
|
|
67
67
|
|
|
68
|
+
### Dynamic routes
|
|
69
|
+
|
|
70
|
+
A bracketed segment in a filename is a parameter, and the file becomes
|
|
71
|
+
a template that builds one page per entry it declares:
|
|
72
|
+
|
|
73
|
+
```html
|
|
74
|
+
<!-- pages/blog/[slug].azox -->
|
|
75
|
+
<script>
|
|
76
|
+
import posts from '../../posts.json' with { type: 'json' };
|
|
77
|
+
|
|
78
|
+
// Which pages to build.
|
|
79
|
+
routes(posts.map((p) => ({ slug: p.slug })));
|
|
80
|
+
|
|
81
|
+
// The parameters of the page being built.
|
|
82
|
+
const { slug } = params();
|
|
83
|
+
const post = posts.find((p) => p.slug === slug);
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
<article>
|
|
87
|
+
<h1>{post.title}</h1>
|
|
88
|
+
<p>{post.body}</p>
|
|
89
|
+
</article>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
posts.json with two entries → /blog/hello
|
|
94
|
+
→ /blog/second
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`routes()` takes an array of objects, one per page, each supplying
|
|
98
|
+
every parameter the filename asks for. A filename may hold several
|
|
99
|
+
(`pages/[lang]/[slug].azox`), and `params()` returns them all.
|
|
100
|
+
|
|
101
|
+
Both are build-time declarations: neither reaches the browser. The
|
|
102
|
+
parameters for each page are compiled into its module as a constant.
|
|
103
|
+
|
|
104
|
+
A missing `routes()` call, an entry missing a parameter, a value
|
|
105
|
+
containing a `/`, and two entries producing the same URL are all
|
|
106
|
+
reported as build errors rather than producing a broken site.
|
|
107
|
+
|
|
68
108
|
## Components
|
|
69
109
|
|
|
70
110
|
A component is a `.azox` file that declares what it accepts and
|
|
@@ -147,6 +187,42 @@ There is still no component instance at runtime: the compiler wraps
|
|
|
147
187
|
each use in its own JavaScript scope, which is ordinary scoping
|
|
148
188
|
rather than a framework construct.
|
|
149
189
|
|
|
190
|
+
## Layouts and the document head
|
|
191
|
+
|
|
192
|
+
A component can carry a `<head>` block, so one shared component holds
|
|
193
|
+
the stylesheet, fonts and scripts every page needs:
|
|
194
|
+
|
|
195
|
+
```html
|
|
196
|
+
<!-- components/Shell.azox -->
|
|
197
|
+
<head>
|
|
198
|
+
<link rel="stylesheet" href="/style.css" />
|
|
199
|
+
</head>
|
|
200
|
+
|
|
201
|
+
<div class="shell">
|
|
202
|
+
<header>My site</header>
|
|
203
|
+
<slot />
|
|
204
|
+
</div>
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```html
|
|
208
|
+
<!-- pages/index.azox -->
|
|
209
|
+
<head>
|
|
210
|
+
<title>Home — my site</title>
|
|
211
|
+
</head>
|
|
212
|
+
|
|
213
|
+
<script>
|
|
214
|
+
import Shell from '../components/Shell.azox';
|
|
215
|
+
</script>
|
|
216
|
+
|
|
217
|
+
<Shell><main>Just this page's content.</main></Shell>
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Blocks are merged with the component's first, so the page has the last
|
|
221
|
+
word. Identical lines are emitted once, and a component used twice
|
|
222
|
+
contributes once. A `<title>` or `<meta name="…">` set by the page
|
|
223
|
+
replaces the component's rather than joining it — a document may hold
|
|
224
|
+
only one of each — so a layout's title is a default, not a conflict.
|
|
225
|
+
|
|
150
226
|
## Loops and conditionals
|
|
151
227
|
|
|
152
228
|
Control flow is expressed as tags, so it nests inside markup like
|
package/core/build.js
CHANGED
|
@@ -22,7 +22,7 @@ import { renderToHtml } from './renderer/renderToHtml.js';
|
|
|
22
22
|
import { parseImports } from './renderer/moduleBindings.js';
|
|
23
23
|
import { evaluateScript } from './renderer/serverScope.js';
|
|
24
24
|
import { escapeHtml } from './compiler/html.js';
|
|
25
|
-
import { collectRoutes, findRoute } from './routes.js';
|
|
25
|
+
import { collectRoutes, findRoute, resolveRoute, PAGE_EXTENSION } from './routes.js';
|
|
26
26
|
import { createNodeResolver } from './nodeResolver.js';
|
|
27
27
|
import { ROOT_DIR } from './meta.js';
|
|
28
28
|
import { BuildError } from './buildError.js';
|
|
@@ -68,7 +68,11 @@ export function buildRoute(projectDir, route, { transformHtml } = {}) {
|
|
|
68
68
|
// SSR pass: render initial markup without touching browser DOM APIs.
|
|
69
69
|
let html;
|
|
70
70
|
try {
|
|
71
|
-
html = renderToHtml(
|
|
71
|
+
html = renderToHtml(
|
|
72
|
+
ast,
|
|
73
|
+
buildServerScope(ast.script, inlineModules, route.params),
|
|
74
|
+
inlineModules
|
|
75
|
+
);
|
|
72
76
|
} catch (error) {
|
|
73
77
|
if (!(error instanceof BuildError)) throw error;
|
|
74
78
|
throw new BuildError(`in ${PAGES_DIR}/${name}.azox: ${error.message}`);
|
|
@@ -97,6 +101,7 @@ export function buildRoute(projectDir, route, { transformHtml } = {}) {
|
|
|
97
101
|
rewriteImports: (script, from = sourcePath) =>
|
|
98
102
|
rebaseImports(script, dirname(from), clientPath),
|
|
99
103
|
inlineModules,
|
|
104
|
+
routeParams: route.params ?? null,
|
|
100
105
|
}),
|
|
101
106
|
runtimeSpecifier
|
|
102
107
|
);
|
|
@@ -113,7 +118,12 @@ export function buildRoute(projectDir, route, { transformHtml } = {}) {
|
|
|
113
118
|
copyFileSync(resolve(ROOT_DIR, 'core/router/navigate.js'), resolve(buildRoot, ROUTER_FILENAME));
|
|
114
119
|
}
|
|
115
120
|
|
|
116
|
-
|
|
121
|
+
// A component's <head> block is merged in behind the page's own, so
|
|
122
|
+
// a layout can carry the stylesheet and fonts every page needs while
|
|
123
|
+
// the page keeps the last word on its title and description.
|
|
124
|
+
const head = mergeHeads(ast.head, ast.componentHeads ?? []);
|
|
125
|
+
|
|
126
|
+
let document = wrapDocument(html, projectTitle(projectDir), head, {
|
|
117
127
|
routerSrc: router ? `${route.assetPrefix}${ROUTER_FILENAME}` : null,
|
|
118
128
|
});
|
|
119
129
|
if (transformHtml) document = transformHtml(document);
|
|
@@ -133,13 +143,142 @@ export function buildAll(projectDir, options) {
|
|
|
133
143
|
);
|
|
134
144
|
}
|
|
135
145
|
|
|
136
|
-
const results = routes.map((route) =>
|
|
146
|
+
const results = expandTemplates(routes).map((route) =>
|
|
147
|
+
buildRoute(projectDir, route, options)
|
|
148
|
+
);
|
|
137
149
|
const assets = copyPublicAssets(projectDir);
|
|
138
150
|
removeStaleOutput(projectDir, results, assets);
|
|
139
151
|
|
|
140
152
|
return results;
|
|
141
153
|
}
|
|
142
154
|
|
|
155
|
+
// Turns each template into the concrete routes it declares, leaving
|
|
156
|
+
// ordinary pages as they are. A template stands in for however many
|
|
157
|
+
// pages its routes() call names, and is never written at its own url.
|
|
158
|
+
function expandTemplates(routes) {
|
|
159
|
+
const expanded = [];
|
|
160
|
+
const seen = new Map();
|
|
161
|
+
|
|
162
|
+
for (const route of routes) {
|
|
163
|
+
if (!route.isTemplate) {
|
|
164
|
+
expanded.push(route);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const values of discoverRoutes(route)) {
|
|
169
|
+
const resolved = resolveRoute(route, values);
|
|
170
|
+
|
|
171
|
+
// Two entries producing the same url would have one silently
|
|
172
|
+
// overwrite the other, leaving a page missing with no sign of it.
|
|
173
|
+
const clash = seen.get(resolved.url);
|
|
174
|
+
if (clash) {
|
|
175
|
+
throw new BuildError(
|
|
176
|
+
`${relative(process.cwd(), route.sourcePath)} declares ${resolved.url} twice — ` +
|
|
177
|
+
`route parameters must be unique`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
seen.set(resolved.url, route);
|
|
182
|
+
expanded.push(resolved);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return expanded;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Runs a template's <script> with `routes` bound to a collector, so
|
|
190
|
+
// the page declares its own pages from whatever data it imports.
|
|
191
|
+
//
|
|
192
|
+
// The script runs twice per page in total — once here to discover the
|
|
193
|
+
// list, then once per page to render it. That is the cost of letting
|
|
194
|
+
// the declaration be ordinary JavaScript rather than a separate
|
|
195
|
+
// manifest the author has to keep in step.
|
|
196
|
+
function discoverRoutes(route) {
|
|
197
|
+
const source = readFileSync(route.sourcePath, 'utf8');
|
|
198
|
+
const parsed = parseAzox(source);
|
|
199
|
+
const where = `${PAGES_DIR}/${route.name}${PAGE_EXTENSION}`;
|
|
200
|
+
|
|
201
|
+
if (!/\broutes\s*\(/.test(parsed.script)) {
|
|
202
|
+
throw new BuildError(
|
|
203
|
+
`in ${where}: a page with a [parameter] in its name must declare its pages — ` +
|
|
204
|
+
`add routes([...]) to its <script> block, for example ` +
|
|
205
|
+
`routes(posts.map((p) => ({ ${route.paramNames[0]}: p.${route.paramNames[0]} })))`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const collected = [];
|
|
210
|
+
const body = parsed.script.replace(/^\s*import\s.+?;?\s*$/gm, '');
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
evaluateScript(
|
|
214
|
+
body,
|
|
215
|
+
[],
|
|
216
|
+
[],
|
|
217
|
+
loadModules(parsed.script, route.sourcePath),
|
|
218
|
+
{
|
|
219
|
+
routes: (list) => {
|
|
220
|
+
collected.push(...normaliseRouteList(list, where, route.paramNames));
|
|
221
|
+
return list;
|
|
222
|
+
},
|
|
223
|
+
// Discovery happens before any page exists, so params() has
|
|
224
|
+
// nothing to report yet. Returning an empty object lets a
|
|
225
|
+
// script that destructures it run without a special case.
|
|
226
|
+
params: () => ({}),
|
|
227
|
+
}
|
|
228
|
+
);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (error instanceof BuildError) throw error;
|
|
231
|
+
throw new BuildError(`in ${where}: failed to work out which pages to build: ${error.message}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!collected.length) {
|
|
235
|
+
throw new BuildError(
|
|
236
|
+
`in ${where}: routes() was called with nothing, so no pages would be built. ` +
|
|
237
|
+
`If the list can be empty, that is fine — but the build has nothing to write.`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return collected;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Each entry must supply every parameter the filename asks for. A
|
|
245
|
+
// missing one would otherwise land in a url as "undefined".
|
|
246
|
+
function normaliseRouteList(list, where, names) {
|
|
247
|
+
if (!Array.isArray(list)) {
|
|
248
|
+
throw new BuildError(
|
|
249
|
+
`in ${where}: routes() needs an array of objects — got ${typeof list}`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return list.map((entry, index) => {
|
|
254
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
255
|
+
throw new BuildError(
|
|
256
|
+
`in ${where}: routes() entry ${index} must be an object like { ${names[0]}: 'value' }`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
for (const name of names) {
|
|
261
|
+
const value = entry[name];
|
|
262
|
+
|
|
263
|
+
if (value === undefined || value === null || value === '') {
|
|
264
|
+
throw new BuildError(
|
|
265
|
+
`in ${where}: routes() entry ${index} is missing "${name}", ` +
|
|
266
|
+
`which the filename asks for`
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (String(value).includes('/')) {
|
|
271
|
+
throw new BuildError(
|
|
272
|
+
`in ${where}: routes() entry ${index} has "${name}" set to "${value}", ` +
|
|
273
|
+
`which contains a "/" — a parameter fills one url segment`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return entry;
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
143
282
|
// Deletes output belonging to pages that no longer exist. Without
|
|
144
283
|
// this, deleting a page leaves its built copy behind and a deployed
|
|
145
284
|
// site keeps serving it.
|
|
@@ -217,13 +356,28 @@ export function copyPublicAssets(projectDir) {
|
|
|
217
356
|
|
|
218
357
|
// Builds a single page by route name or URL.
|
|
219
358
|
export function buildPage(projectDir, pageName, options) {
|
|
220
|
-
const
|
|
359
|
+
const routes = listRoutes(projectDir);
|
|
360
|
+
const route = findRoute(routes, pageName);
|
|
221
361
|
|
|
222
|
-
|
|
223
|
-
|
|
362
|
+
// A template cannot be built at its own url, so naming it builds
|
|
363
|
+
// every page it declares — which is what the author means by
|
|
364
|
+
// `azox compile --page=blog/[slug]`.
|
|
365
|
+
if (route?.isTemplate) {
|
|
366
|
+
return expandTemplates([route]).map((page) => buildRoute(projectDir, page, options));
|
|
224
367
|
}
|
|
225
368
|
|
|
226
|
-
return buildRoute(projectDir, route, options);
|
|
369
|
+
if (route) return buildRoute(projectDir, route, options);
|
|
370
|
+
|
|
371
|
+
// Not a file, so it may be one of the pages a template declares.
|
|
372
|
+
// Expanding them is the only way to know.
|
|
373
|
+
const generated = findRoute(
|
|
374
|
+
expandTemplates(routes.filter((candidate) => candidate.isTemplate)),
|
|
375
|
+
pageName
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
if (generated) return buildRoute(projectDir, generated, options);
|
|
379
|
+
|
|
380
|
+
throw new BuildError(`page "${PAGES_DIR}/${pageName}.azox" not found in ${projectDir}`);
|
|
227
381
|
}
|
|
228
382
|
|
|
229
383
|
// Rewrites the bare specifier a page's own <script> uses to the same
|
|
@@ -300,14 +454,21 @@ function projectTitle(projectDir) {
|
|
|
300
454
|
// evaluate the expressions the template references. The script is
|
|
301
455
|
// trusted project source, not user input — the same assumption any
|
|
302
456
|
// template engine's SSR step makes.
|
|
303
|
-
function buildServerScope(script, modules = {}) {
|
|
457
|
+
function buildServerScope(script, modules = {}, params = null) {
|
|
304
458
|
// Strip imports: the server supplies its own primitives rather than
|
|
305
459
|
// loading the real reactive runtime, and anything else a script
|
|
306
460
|
// imports was resolved by loadModules and is passed in.
|
|
307
461
|
const body = script.replace(/^\s*import\s.+?;?\s*$/gm, '');
|
|
308
462
|
|
|
463
|
+
// A dynamic page reads its own parameters through params(), and its
|
|
464
|
+
// routes() call has already been answered by discovery — calling it
|
|
465
|
+
// again here would collect the list a second time to no purpose.
|
|
466
|
+
const extras = params
|
|
467
|
+
? { params: () => params, routes: (list) => list }
|
|
468
|
+
: {};
|
|
469
|
+
|
|
309
470
|
try {
|
|
310
|
-
return evaluateScript(body, [], [], modules);
|
|
471
|
+
return evaluateScript(body, [], [], modules, extras);
|
|
311
472
|
} catch (error) {
|
|
312
473
|
if (error instanceof BuildError) throw error;
|
|
313
474
|
throw new BuildError(`failed to evaluate the page's <script> block: ${error.message}`);
|
|
@@ -350,6 +511,11 @@ function loadModules(script, sourcePath, componentImports = []) {
|
|
|
350
511
|
|
|
351
512
|
let loaded;
|
|
352
513
|
try {
|
|
514
|
+
// require caches by resolved path, which is wrong for a build
|
|
515
|
+
// that runs repeatedly in one process: `azox dev` would keep
|
|
516
|
+
// serving the data as it was when the server started, so editing
|
|
517
|
+
// a post changed nothing. Dropping the entry re-reads the file.
|
|
518
|
+
delete require.cache[require.resolve(specifier)];
|
|
353
519
|
loaded = require(specifier);
|
|
354
520
|
} catch (error) {
|
|
355
521
|
throw new BuildError(`cannot import '${specifier}': ${error.message.split('\n')[0]}`);
|
|
@@ -366,6 +532,70 @@ function loadModules(script, sourcePath, componentImports = []) {
|
|
|
366
532
|
|
|
367
533
|
// The client module sits next to the page's index.html, so the src is
|
|
368
534
|
// the same for every route regardless of how deep it is.
|
|
535
|
+
// Combines the <head> blocks a page and its components contributed.
|
|
536
|
+
//
|
|
537
|
+
// Components come first so the page can override them: a <title> or a
|
|
538
|
+
// <meta name="description"> set by a layout is a sensible default, and
|
|
539
|
+
// the page that knows its own subject should win. Identical lines are
|
|
540
|
+
// emitted once, so a layout and a page both asking for the same
|
|
541
|
+
// stylesheet do not produce it twice.
|
|
542
|
+
function mergeHeads(pageHead, componentHeads) {
|
|
543
|
+
const seen = new Set();
|
|
544
|
+
const lines = [];
|
|
545
|
+
|
|
546
|
+
// A page's <title> and description replace a component's rather than
|
|
547
|
+
// appearing alongside: two titles in one document is invalid.
|
|
548
|
+
const pageTags = uniqueHeadTags(pageHead);
|
|
549
|
+
|
|
550
|
+
for (const block of [...componentHeads, pageHead]) {
|
|
551
|
+
for (const line of (block ?? '').split('\n')) {
|
|
552
|
+
const trimmed = line.trim();
|
|
553
|
+
if (!trimmed) continue;
|
|
554
|
+
|
|
555
|
+
const key = normaliseHeadLine(trimmed);
|
|
556
|
+
if (seen.has(key)) continue;
|
|
557
|
+
|
|
558
|
+
// Dropped in favour of the page's own.
|
|
559
|
+
const tag = uniqueTagName(trimmed);
|
|
560
|
+
if (tag && block !== pageHead && pageTags.has(tag)) continue;
|
|
561
|
+
|
|
562
|
+
seen.add(key);
|
|
563
|
+
lines.push(trimmed);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return lines.join('\n');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Tags a document may only hold one of, so a component's must give way
|
|
571
|
+
// to the page's. Keyed by tag name, plus the `name` of a <meta> — two
|
|
572
|
+
// different meta tags are fine, two descriptions are not.
|
|
573
|
+
function uniqueTagName(line) {
|
|
574
|
+
const title = /^<title[\s>]/i.test(line);
|
|
575
|
+
if (title) return 'title';
|
|
576
|
+
|
|
577
|
+
const meta = line.match(/^<meta\s[^>]*name=["']([^"']+)["']/i);
|
|
578
|
+
if (meta) return `meta:${meta[1].toLowerCase()}`;
|
|
579
|
+
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function uniqueHeadTags(head) {
|
|
584
|
+
const names = new Set();
|
|
585
|
+
|
|
586
|
+
for (const line of (head ?? '').split('\n')) {
|
|
587
|
+
const tag = uniqueTagName(line.trim());
|
|
588
|
+
if (tag) names.add(tag);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return names;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Whitespace inside a tag should not make two identical links differ.
|
|
595
|
+
function normaliseHeadLine(line) {
|
|
596
|
+
return line.replace(/\s+/g, ' ');
|
|
597
|
+
}
|
|
598
|
+
|
|
369
599
|
// A page's own <head> block wins over the fallback title, so a page
|
|
370
600
|
// can set its own <title>, stylesheets and meta tags.
|
|
371
601
|
function wrapDocument(bodyHtml, title, head = '', { routerSrc = null } = {}) {
|
package/core/commands/compile.js
CHANGED
|
@@ -15,7 +15,11 @@ export function compileCommand({ flags }) {
|
|
|
15
15
|
const projectDir = process.cwd();
|
|
16
16
|
|
|
17
17
|
try {
|
|
18
|
-
|
|
18
|
+
// Naming a [parameter] template builds every page it declares, so
|
|
19
|
+
// buildPage may answer with more than one.
|
|
20
|
+
const results = flags.page
|
|
21
|
+
? [buildPage(projectDir, flags.page)].flat()
|
|
22
|
+
: buildAll(projectDir);
|
|
19
23
|
|
|
20
24
|
console.log(BANNER);
|
|
21
25
|
console.log('');
|
package/core/commands/dev.js
CHANGED
|
@@ -82,11 +82,20 @@ export async function devCommand({ flags }) {
|
|
|
82
82
|
// public/ holds stylesheets, fonts and images; any of them
|
|
83
83
|
// changing is worth a reload.
|
|
84
84
|
{ dir: resolve(projectDir, PUBLIC_DIR), filter: () => true },
|
|
85
|
+
// A page can import a .json file for its content, and a dynamic
|
|
86
|
+
// route builds its pages from one. Editing the data has to rebuild
|
|
87
|
+
// or the new entry never appears. Not recursive: the project root
|
|
88
|
+
// also holds the build output, and watching that rebuilds forever.
|
|
89
|
+
{
|
|
90
|
+
dir: projectDir,
|
|
91
|
+
filter: (name) => name.endsWith('.json') && !name.includes('package-lock'),
|
|
92
|
+
recursive: false,
|
|
93
|
+
},
|
|
85
94
|
];
|
|
86
95
|
|
|
87
96
|
const stoppers = watched
|
|
88
97
|
.filter(({ dir }) => existsSync(dir))
|
|
89
|
-
.map(({ dir, filter }) => watchDirectory(dir, onChange, { filter }));
|
|
98
|
+
.map(({ dir, filter, recursive }) => watchDirectory(dir, onChange, { filter, recursive }));
|
|
90
99
|
|
|
91
100
|
const stopWatching = () => {
|
|
92
101
|
for (const stop of stoppers) stop();
|
|
@@ -23,12 +23,20 @@ const nextId = () => `_el${uid++}`;
|
|
|
23
23
|
// value, whose import must not reach the browser. A JSON import
|
|
24
24
|
// points outside the build directory at a file that is never
|
|
25
25
|
// deployed, so the value is emitted as a constant instead.
|
|
26
|
-
|
|
26
|
+
// routeParams: the resolved parameters for this page of a dynamic
|
|
27
|
+
// route. routes() is a build-time declaration and params() is
|
|
28
|
+
// answered before the browser is involved, so both are removed from
|
|
29
|
+
// the emitted module and the values are inlined.
|
|
30
|
+
export function compileToModule(
|
|
31
|
+
ast,
|
|
32
|
+
{ runtimeSpecifier, rewriteImports, inlineModules, routeParams }
|
|
33
|
+
) {
|
|
27
34
|
uid = 0;
|
|
28
35
|
const statements = [];
|
|
29
36
|
const rootVar = emitNode(ast.markup, statements, 'root');
|
|
30
37
|
|
|
31
38
|
let script = dropComponentImports(ast.script);
|
|
39
|
+
script = resolveRouteDeclarations(script, routeParams);
|
|
32
40
|
if (rewriteImports) script = rewriteImports(script);
|
|
33
41
|
|
|
34
42
|
// A page with no bindings and no listeners has nothing to hydrate:
|
|
@@ -230,6 +238,21 @@ function narrow(name, value, usage) {
|
|
|
230
238
|
return narrowed;
|
|
231
239
|
}
|
|
232
240
|
|
|
241
|
+
// Removes the build-time route declarations from a page's script.
|
|
242
|
+
//
|
|
243
|
+
// routes([...]) says which pages to build, which the browser has no
|
|
244
|
+
// use for — and calling it there is a ReferenceError that leaves the
|
|
245
|
+
// page inert. params() is replaced by the values this page was built
|
|
246
|
+
// with, so the markup reads them as plain data.
|
|
247
|
+
function resolveRouteDeclarations(script, routeParams) {
|
|
248
|
+
if (!routeParams) return script;
|
|
249
|
+
|
|
250
|
+
return script
|
|
251
|
+
// A whole statement, so the trailing semicolon and newline go too.
|
|
252
|
+
.replace(/^[ \t]*routes\s*\([\s\S]*?\)\s*;?[ \t]*$/gm, '')
|
|
253
|
+
.replace(/\bparams\s*\(\s*\)/g, JSON.stringify(routeParams));
|
|
254
|
+
}
|
|
255
|
+
|
|
233
256
|
function staticNote() {
|
|
234
257
|
return `
|
|
235
258
|
// This page has no bindings and no listeners, so the server-rendered
|
package/core/compiler/parser.js
CHANGED
|
@@ -42,6 +42,9 @@ export function parseAzox(source) {
|
|
|
42
42
|
markup: node,
|
|
43
43
|
components: parseComponentImports(script),
|
|
44
44
|
props: parsePropNames(script),
|
|
45
|
+
// A dynamic page destructures its route parameters the same way a
|
|
46
|
+
// component destructures its props.
|
|
47
|
+
params: parseParamNames(script),
|
|
45
48
|
};
|
|
46
49
|
}
|
|
47
50
|
|
|
@@ -63,6 +66,18 @@ function parseComponentImports(script) {
|
|
|
63
66
|
// `const { title, count } = props();` declares what a component
|
|
64
67
|
// accepts. Declaring them explicitly lets the compiler reject a
|
|
65
68
|
// caller that passes something the component never asked for.
|
|
69
|
+
// `const { slug } = params()` names the route parameters the page
|
|
70
|
+
// reads, mirroring how props() declares a component's inputs.
|
|
71
|
+
function parseParamNames(script) {
|
|
72
|
+
const match = script.match(/const\s*\{([^}]*)\}\s*=\s*params\(\)/);
|
|
73
|
+
if (!match) return [];
|
|
74
|
+
|
|
75
|
+
return match[1]
|
|
76
|
+
.split(',')
|
|
77
|
+
.map((name) => name.trim())
|
|
78
|
+
.filter(Boolean);
|
|
79
|
+
}
|
|
80
|
+
|
|
66
81
|
function parsePropNames(script) {
|
|
67
82
|
const match = script.match(/const\s*\{([^}]*)\}\s*=\s*props\(\)/);
|
|
68
83
|
if (!match) return [];
|
|
@@ -20,7 +20,7 @@ export class ComponentError extends BuildError {}
|
|
|
20
20
|
// `resolver` decides how an import specifier becomes source text, so
|
|
21
21
|
// this runs unchanged against disk or against an in-memory map. See
|
|
22
22
|
// sourceResolver.js.
|
|
23
|
-
export function resolveComponents(ast, sourcePath, resolver, seen = new Set()) {
|
|
23
|
+
export function resolveComponents(ast, sourcePath, resolver, seen = new Set(), heads = null) {
|
|
24
24
|
// Imports from stateful components are hoisted here: they cannot
|
|
25
25
|
// live inside the scope function the compiler builds for each one.
|
|
26
26
|
//
|
|
@@ -30,16 +30,24 @@ export function resolveComponents(ast, sourcePath, resolver, seen = new Set()) {
|
|
|
30
30
|
// rebasing a component's import against the page's directory points
|
|
31
31
|
// it at a file that is not there.
|
|
32
32
|
const hoisted = new Map();
|
|
33
|
-
|
|
33
|
+
|
|
34
|
+
// A component's <head> block is collected the same way, keyed by the
|
|
35
|
+
// file it came from so the same component used twice contributes
|
|
36
|
+
// once. A stylesheet link belongs in the document head, and until
|
|
37
|
+
// now only a page could put one there.
|
|
38
|
+
const collected = heads ?? new Map();
|
|
39
|
+
|
|
40
|
+
const markup = expand(ast.markup, ast, sourcePath, resolver, seen, hoisted, collected);
|
|
34
41
|
|
|
35
42
|
return {
|
|
36
43
|
...ast,
|
|
37
44
|
markup,
|
|
38
45
|
componentImports: [...hoisted.values()],
|
|
46
|
+
componentHeads: [...collected.values()],
|
|
39
47
|
};
|
|
40
48
|
}
|
|
41
49
|
|
|
42
|
-
function expand(node, ast, sourcePath, resolver, seen, hoisted) {
|
|
50
|
+
function expand(node, ast, sourcePath, resolver, seen, hoisted, heads) {
|
|
43
51
|
if (!node || node.type === 'text') return node;
|
|
44
52
|
|
|
45
53
|
// <if> keeps its children in two branches rather than in `children`,
|
|
@@ -48,15 +56,17 @@ function expand(node, ast, sourcePath, resolver, seen, hoisted) {
|
|
|
48
56
|
if (node.type === 'if') {
|
|
49
57
|
return {
|
|
50
58
|
...node,
|
|
51
|
-
then: node.then.map((child) =>
|
|
59
|
+
then: node.then.map((child) =>
|
|
60
|
+
expand(child, ast, sourcePath, resolver, seen, hoisted, heads)
|
|
61
|
+
),
|
|
52
62
|
otherwise: node.otherwise.map((child) =>
|
|
53
|
-
expand(child, ast, sourcePath, resolver, seen, hoisted)
|
|
63
|
+
expand(child, ast, sourcePath, resolver, seen, hoisted, heads)
|
|
54
64
|
),
|
|
55
65
|
};
|
|
56
66
|
}
|
|
57
67
|
|
|
58
68
|
const children = (node.children ?? []).map((child) =>
|
|
59
|
-
expand(child, ast, sourcePath, resolver, seen, hoisted)
|
|
69
|
+
expand(child, ast, sourcePath, resolver, seen, hoisted, heads)
|
|
60
70
|
);
|
|
61
71
|
|
|
62
72
|
if (node.type !== 'component') {
|
|
@@ -72,9 +82,14 @@ function expand(node, ast, sourcePath, resolver, seen, hoisted) {
|
|
|
72
82
|
component.ast,
|
|
73
83
|
component.path,
|
|
74
84
|
resolver,
|
|
75
|
-
new Set([...seen, component.path])
|
|
85
|
+
new Set([...seen, component.path]),
|
|
86
|
+
heads
|
|
76
87
|
);
|
|
77
88
|
|
|
89
|
+
// Keyed by path: a component used on a page twice must not emit its
|
|
90
|
+
// stylesheet link twice.
|
|
91
|
+
if (component.ast.head) heads.set(component.path, component.ast.head);
|
|
92
|
+
|
|
78
93
|
const values = propValues(node, component.ast.props);
|
|
79
94
|
const { logic, imports } = componentLogic(component.ast.script);
|
|
80
95
|
|
package/core/dev/watcher.js
CHANGED
|
@@ -11,7 +11,10 @@ import { join } from 'node:path';
|
|
|
11
11
|
|
|
12
12
|
const DEBOUNCE_MS = 40;
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// `recursive: false` watches only the directory itself. The project
|
|
15
|
+
// root holds the build output and node_modules, so sweeping it
|
|
16
|
+
// recursively would rebuild in a loop.
|
|
17
|
+
export function watchDirectory(dir, onChange, { filter = () => true, recursive = true } = {}) {
|
|
15
18
|
const watchers = [];
|
|
16
19
|
let timer = null;
|
|
17
20
|
|
|
@@ -22,6 +25,14 @@ export function watchDirectory(dir, onChange, { filter = () => true } = {}) {
|
|
|
22
25
|
timer = setTimeout(() => onChange(filename), DEBOUNCE_MS);
|
|
23
26
|
};
|
|
24
27
|
|
|
28
|
+
if (!recursive) {
|
|
29
|
+
watchers.push(watch(dir, (_event, filename) => trigger(filename)));
|
|
30
|
+
return () => {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
for (const watcher of watchers) watcher.close();
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
try {
|
|
26
37
|
watchers.push(watch(dir, { recursive: true }, (_event, filename) => trigger(filename)));
|
|
27
38
|
} catch {
|
|
@@ -58,24 +58,36 @@ export function declaredNames(script) {
|
|
|
58
58
|
// Evaluates a script body and returns its declarations. `params` and
|
|
59
59
|
// `args` pass a component's props in as arguments.
|
|
60
60
|
//
|
|
61
|
+
// `extras` are further bindings the caller supplies by name — `params`
|
|
62
|
+
// for a dynamic page, and `routes` while its route list is collected.
|
|
63
|
+
//
|
|
61
64
|
// `modules` carries what the script's imports brought in, as local
|
|
62
65
|
// name → value. The body runs inside a `new Function`, which cannot
|
|
63
66
|
// use `import`, so the bindings arrive as arguments instead — the
|
|
64
67
|
// build resolves them, since loading a module needs the filesystem
|
|
65
68
|
// and this file has to stay usable in a browser.
|
|
66
|
-
export function evaluateScript(body, params = [], args = [], modules = {}) {
|
|
69
|
+
export function evaluateScript(body, params = [], args = [], modules = {}, extras = {}) {
|
|
67
70
|
const imported = Object.keys(modules);
|
|
68
|
-
const
|
|
71
|
+
const extraNames = Object.keys(extras);
|
|
72
|
+
const reserved = new Set([...imported, ...extraNames]);
|
|
73
|
+
const names = declaredNames(body).filter((name) => !reserved.has(name));
|
|
69
74
|
|
|
70
75
|
const fn = new Function(
|
|
71
76
|
'signal',
|
|
72
77
|
'computed',
|
|
73
78
|
...imported,
|
|
79
|
+
...extraNames,
|
|
74
80
|
...params,
|
|
75
81
|
`${body}\nreturn { ${names.join(', ')} };`
|
|
76
82
|
);
|
|
77
83
|
|
|
78
|
-
const declared = fn(
|
|
84
|
+
const declared = fn(
|
|
85
|
+
serverSignal,
|
|
86
|
+
serverComputed,
|
|
87
|
+
...imported.map((n) => modules[n]),
|
|
88
|
+
...extraNames.map((n) => extras[n]),
|
|
89
|
+
...args
|
|
90
|
+
);
|
|
79
91
|
|
|
80
92
|
// An imported binding is in scope for the markup too, the same way
|
|
81
93
|
// 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
|
|