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
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:
|
|
15
|
-
> change without
|
|
14
|
+
> Status: stable (v1.0.0). 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
|
|
|
@@ -65,6 +68,46 @@ pages/blog/first-post.azox → /blog/first-post
|
|
|
65
68
|
Build one page with `azox compile --page=blog/first-post`, or by its
|
|
66
69
|
URL: `azox compile --page=/blog/first-post`.
|
|
67
70
|
|
|
71
|
+
### Dynamic routes
|
|
72
|
+
|
|
73
|
+
A bracketed segment in a filename is a parameter, and the file becomes
|
|
74
|
+
a template that builds one page per entry it declares:
|
|
75
|
+
|
|
76
|
+
```html
|
|
77
|
+
<!-- pages/blog/[slug].azox -->
|
|
78
|
+
<script>
|
|
79
|
+
import posts from '../../posts.json' with { type: 'json' };
|
|
80
|
+
|
|
81
|
+
// Which pages to build.
|
|
82
|
+
routes(posts.map((p) => ({ slug: p.slug })));
|
|
83
|
+
|
|
84
|
+
// The parameters of the page being built.
|
|
85
|
+
const { slug } = params();
|
|
86
|
+
const post = posts.find((p) => p.slug === slug);
|
|
87
|
+
</script>
|
|
88
|
+
|
|
89
|
+
<article>
|
|
90
|
+
<h1>{post.title}</h1>
|
|
91
|
+
<p>{post.body}</p>
|
|
92
|
+
</article>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
posts.json with two entries → /blog/hello
|
|
97
|
+
→ /blog/second
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`routes()` takes an array of objects, one per page, each supplying
|
|
101
|
+
every parameter the filename asks for. A filename may hold several
|
|
102
|
+
(`pages/[lang]/[slug].azox`), and `params()` returns them all.
|
|
103
|
+
|
|
104
|
+
Both are build-time declarations: neither reaches the browser. The
|
|
105
|
+
parameters for each page are compiled into its module as a constant.
|
|
106
|
+
|
|
107
|
+
A missing `routes()` call, an entry missing a parameter, a value
|
|
108
|
+
containing a `/`, and two entries producing the same URL are all
|
|
109
|
+
reported as build errors rather than producing a broken site.
|
|
110
|
+
|
|
68
111
|
## Components
|
|
69
112
|
|
|
70
113
|
A component is a `.azox` file that declares what it accepts and
|
|
@@ -147,6 +190,75 @@ There is still no component instance at runtime: the compiler wraps
|
|
|
147
190
|
each use in its own JavaScript scope, which is ordinary scoping
|
|
148
191
|
rather than a framework construct.
|
|
149
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
|
+
|
|
226
|
+
## Layouts and the document head
|
|
227
|
+
|
|
228
|
+
A component can carry a `<head>` block, so one shared component holds
|
|
229
|
+
the stylesheet, fonts and scripts every page needs:
|
|
230
|
+
|
|
231
|
+
```html
|
|
232
|
+
<!-- components/Shell.azox -->
|
|
233
|
+
<head>
|
|
234
|
+
<link rel="stylesheet" href="/style.css" />
|
|
235
|
+
</head>
|
|
236
|
+
|
|
237
|
+
<div class="shell">
|
|
238
|
+
<header>My site</header>
|
|
239
|
+
<slot />
|
|
240
|
+
</div>
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
```html
|
|
244
|
+
<!-- pages/index.azox -->
|
|
245
|
+
<head>
|
|
246
|
+
<title>Home — my site</title>
|
|
247
|
+
</head>
|
|
248
|
+
|
|
249
|
+
<script>
|
|
250
|
+
import Shell from '../components/Shell.azox';
|
|
251
|
+
</script>
|
|
252
|
+
|
|
253
|
+
<Shell><main>Just this page's content.</main></Shell>
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Blocks are merged with the component's first, so the page has the last
|
|
257
|
+
word. Identical lines are emitted once, and a component used twice
|
|
258
|
+
contributes once. A `<title>` or `<meta name="…">` set by the page
|
|
259
|
+
replaces the component's rather than joining it — a document may hold
|
|
260
|
+
only one of each — so a layout's title is a default, not a conflict.
|
|
261
|
+
|
|
150
262
|
## Loops and conditionals
|
|
151
263
|
|
|
152
264
|
Control flow is expressed as tags, so it nests inside markup like
|
|
@@ -254,10 +366,17 @@ npm run build
|
|
|
254
366
|
```
|
|
255
367
|
|
|
256
368
|
The build lands in `.azox/build/` as a self-contained static bundle —
|
|
257
|
-
an `index.html` per route, a compiled hydration module beside it
|
|
258
|
-
one shared copy of the runtime. No dev machinery is
|
|
259
|
-
that directory with any static host and it works with
|
|
260
|
-
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.
|
|
261
380
|
|
|
262
381
|
## CLI
|
|
263
382
|
|
package/core/build.js
CHANGED
|
@@ -17,12 +17,12 @@ 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';
|
|
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,12 +101,24 @@ 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
|
);
|
|
103
108
|
|
|
104
109
|
assertValidJavaScript(clientModule, name);
|
|
105
|
-
|
|
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
|
+
}
|
|
106
122
|
|
|
107
123
|
// One runtime at the build root, shared by every page.
|
|
108
124
|
const runtimePath = resolve(buildRoot, RUNTIME_FILENAME);
|
|
@@ -113,8 +129,14 @@ export function buildRoute(projectDir, route, { transformHtml } = {}) {
|
|
|
113
129
|
copyFileSync(resolve(ROOT_DIR, 'core/router/navigate.js'), resolve(buildRoot, ROUTER_FILENAME));
|
|
114
130
|
}
|
|
115
131
|
|
|
116
|
-
|
|
132
|
+
// A component's <head> block is merged in behind the page's own, so
|
|
133
|
+
// a layout can carry the stylesheet and fonts every page needs while
|
|
134
|
+
// the page keeps the last word on its title and description.
|
|
135
|
+
const head = mergeHeads(ast.head, ast.componentHeads ?? []);
|
|
136
|
+
|
|
137
|
+
let document = wrapDocument(html, projectTitle(projectDir), head, {
|
|
117
138
|
routerSrc: router ? `${route.assetPrefix}${ROUTER_FILENAME}` : null,
|
|
139
|
+
clientSrc: isStatic ? null : './page.client.js',
|
|
118
140
|
});
|
|
119
141
|
if (transformHtml) document = transformHtml(document);
|
|
120
142
|
|
|
@@ -133,13 +155,142 @@ export function buildAll(projectDir, options) {
|
|
|
133
155
|
);
|
|
134
156
|
}
|
|
135
157
|
|
|
136
|
-
const results = routes.map((route) =>
|
|
158
|
+
const results = expandTemplates(routes).map((route) =>
|
|
159
|
+
buildRoute(projectDir, route, options)
|
|
160
|
+
);
|
|
137
161
|
const assets = copyPublicAssets(projectDir);
|
|
138
162
|
removeStaleOutput(projectDir, results, assets);
|
|
139
163
|
|
|
140
164
|
return results;
|
|
141
165
|
}
|
|
142
166
|
|
|
167
|
+
// Turns each template into the concrete routes it declares, leaving
|
|
168
|
+
// ordinary pages as they are. A template stands in for however many
|
|
169
|
+
// pages its routes() call names, and is never written at its own url.
|
|
170
|
+
function expandTemplates(routes) {
|
|
171
|
+
const expanded = [];
|
|
172
|
+
const seen = new Map();
|
|
173
|
+
|
|
174
|
+
for (const route of routes) {
|
|
175
|
+
if (!route.isTemplate) {
|
|
176
|
+
expanded.push(route);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const values of discoverRoutes(route)) {
|
|
181
|
+
const resolved = resolveRoute(route, values);
|
|
182
|
+
|
|
183
|
+
// Two entries producing the same url would have one silently
|
|
184
|
+
// overwrite the other, leaving a page missing with no sign of it.
|
|
185
|
+
const clash = seen.get(resolved.url);
|
|
186
|
+
if (clash) {
|
|
187
|
+
throw new BuildError(
|
|
188
|
+
`${relative(process.cwd(), route.sourcePath)} declares ${resolved.url} twice — ` +
|
|
189
|
+
`route parameters must be unique`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
seen.set(resolved.url, route);
|
|
194
|
+
expanded.push(resolved);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return expanded;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Runs a template's <script> with `routes` bound to a collector, so
|
|
202
|
+
// the page declares its own pages from whatever data it imports.
|
|
203
|
+
//
|
|
204
|
+
// The script runs twice per page in total — once here to discover the
|
|
205
|
+
// list, then once per page to render it. That is the cost of letting
|
|
206
|
+
// the declaration be ordinary JavaScript rather than a separate
|
|
207
|
+
// manifest the author has to keep in step.
|
|
208
|
+
function discoverRoutes(route) {
|
|
209
|
+
const source = readFileSync(route.sourcePath, 'utf8');
|
|
210
|
+
const parsed = parseAzox(source);
|
|
211
|
+
const where = `${PAGES_DIR}/${route.name}${PAGE_EXTENSION}`;
|
|
212
|
+
|
|
213
|
+
if (!/\broutes\s*\(/.test(parsed.script)) {
|
|
214
|
+
throw new BuildError(
|
|
215
|
+
`in ${where}: a page with a [parameter] in its name must declare its pages — ` +
|
|
216
|
+
`add routes([...]) to its <script> block, for example ` +
|
|
217
|
+
`routes(posts.map((p) => ({ ${route.paramNames[0]}: p.${route.paramNames[0]} })))`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const collected = [];
|
|
222
|
+
const body = parsed.script.replace(/^\s*import\s.+?;?\s*$/gm, '');
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
evaluateScript(
|
|
226
|
+
body,
|
|
227
|
+
[],
|
|
228
|
+
[],
|
|
229
|
+
loadModules(parsed.script, route.sourcePath),
|
|
230
|
+
{
|
|
231
|
+
routes: (list) => {
|
|
232
|
+
collected.push(...normaliseRouteList(list, where, route.paramNames));
|
|
233
|
+
return list;
|
|
234
|
+
},
|
|
235
|
+
// Discovery happens before any page exists, so params() has
|
|
236
|
+
// nothing to report yet. Returning an empty object lets a
|
|
237
|
+
// script that destructures it run without a special case.
|
|
238
|
+
params: () => ({}),
|
|
239
|
+
}
|
|
240
|
+
);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (error instanceof BuildError) throw error;
|
|
243
|
+
throw new BuildError(`in ${where}: failed to work out which pages to build: ${error.message}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (!collected.length) {
|
|
247
|
+
throw new BuildError(
|
|
248
|
+
`in ${where}: routes() was called with nothing, so no pages would be built. ` +
|
|
249
|
+
`If the list can be empty, that is fine — but the build has nothing to write.`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return collected;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Each entry must supply every parameter the filename asks for. A
|
|
257
|
+
// missing one would otherwise land in a url as "undefined".
|
|
258
|
+
function normaliseRouteList(list, where, names) {
|
|
259
|
+
if (!Array.isArray(list)) {
|
|
260
|
+
throw new BuildError(
|
|
261
|
+
`in ${where}: routes() needs an array of objects — got ${typeof list}`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return list.map((entry, index) => {
|
|
266
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
267
|
+
throw new BuildError(
|
|
268
|
+
`in ${where}: routes() entry ${index} must be an object like { ${names[0]}: 'value' }`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for (const name of names) {
|
|
273
|
+
const value = entry[name];
|
|
274
|
+
|
|
275
|
+
if (value === undefined || value === null || value === '') {
|
|
276
|
+
throw new BuildError(
|
|
277
|
+
`in ${where}: routes() entry ${index} is missing "${name}", ` +
|
|
278
|
+
`which the filename asks for`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (String(value).includes('/')) {
|
|
283
|
+
throw new BuildError(
|
|
284
|
+
`in ${where}: routes() entry ${index} has "${name}" set to "${value}", ` +
|
|
285
|
+
`which contains a "/" — a parameter fills one url segment`
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return entry;
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
143
294
|
// Deletes output belonging to pages that no longer exist. Without
|
|
144
295
|
// this, deleting a page leaves its built copy behind and a deployed
|
|
145
296
|
// site keeps serving it.
|
|
@@ -217,13 +368,28 @@ export function copyPublicAssets(projectDir) {
|
|
|
217
368
|
|
|
218
369
|
// Builds a single page by route name or URL.
|
|
219
370
|
export function buildPage(projectDir, pageName, options) {
|
|
220
|
-
const
|
|
371
|
+
const routes = listRoutes(projectDir);
|
|
372
|
+
const route = findRoute(routes, pageName);
|
|
221
373
|
|
|
222
|
-
|
|
223
|
-
|
|
374
|
+
// A template cannot be built at its own url, so naming it builds
|
|
375
|
+
// every page it declares — which is what the author means by
|
|
376
|
+
// `azox compile --page=blog/[slug]`.
|
|
377
|
+
if (route?.isTemplate) {
|
|
378
|
+
return expandTemplates([route]).map((page) => buildRoute(projectDir, page, options));
|
|
224
379
|
}
|
|
225
380
|
|
|
226
|
-
return buildRoute(projectDir, route, options);
|
|
381
|
+
if (route) return buildRoute(projectDir, route, options);
|
|
382
|
+
|
|
383
|
+
// Not a file, so it may be one of the pages a template declares.
|
|
384
|
+
// Expanding them is the only way to know.
|
|
385
|
+
const generated = findRoute(
|
|
386
|
+
expandTemplates(routes.filter((candidate) => candidate.isTemplate)),
|
|
387
|
+
pageName
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
if (generated) return buildRoute(projectDir, generated, options);
|
|
391
|
+
|
|
392
|
+
throw new BuildError(`page "${PAGES_DIR}/${pageName}.azox" not found in ${projectDir}`);
|
|
227
393
|
}
|
|
228
394
|
|
|
229
395
|
// Rewrites the bare specifier a page's own <script> uses to the same
|
|
@@ -300,14 +466,21 @@ function projectTitle(projectDir) {
|
|
|
300
466
|
// evaluate the expressions the template references. The script is
|
|
301
467
|
// trusted project source, not user input — the same assumption any
|
|
302
468
|
// template engine's SSR step makes.
|
|
303
|
-
function buildServerScope(script, modules = {}) {
|
|
469
|
+
function buildServerScope(script, modules = {}, params = null) {
|
|
304
470
|
// Strip imports: the server supplies its own primitives rather than
|
|
305
471
|
// loading the real reactive runtime, and anything else a script
|
|
306
472
|
// imports was resolved by loadModules and is passed in.
|
|
307
473
|
const body = script.replace(/^\s*import\s.+?;?\s*$/gm, '');
|
|
308
474
|
|
|
475
|
+
// A dynamic page reads its own parameters through params(), and its
|
|
476
|
+
// routes() call has already been answered by discovery — calling it
|
|
477
|
+
// again here would collect the list a second time to no purpose.
|
|
478
|
+
const extras = params
|
|
479
|
+
? { params: () => params, routes: (list) => list }
|
|
480
|
+
: {};
|
|
481
|
+
|
|
309
482
|
try {
|
|
310
|
-
return evaluateScript(body, [], [], modules);
|
|
483
|
+
return evaluateScript(body, [], [], modules, extras);
|
|
311
484
|
} catch (error) {
|
|
312
485
|
if (error instanceof BuildError) throw error;
|
|
313
486
|
throw new BuildError(`failed to evaluate the page's <script> block: ${error.message}`);
|
|
@@ -350,6 +523,11 @@ function loadModules(script, sourcePath, componentImports = []) {
|
|
|
350
523
|
|
|
351
524
|
let loaded;
|
|
352
525
|
try {
|
|
526
|
+
// require caches by resolved path, which is wrong for a build
|
|
527
|
+
// that runs repeatedly in one process: `azox dev` would keep
|
|
528
|
+
// serving the data as it was when the server started, so editing
|
|
529
|
+
// a post changed nothing. Dropping the entry re-reads the file.
|
|
530
|
+
delete require.cache[require.resolve(specifier)];
|
|
353
531
|
loaded = require(specifier);
|
|
354
532
|
} catch (error) {
|
|
355
533
|
throw new BuildError(`cannot import '${specifier}': ${error.message.split('\n')[0]}`);
|
|
@@ -366,9 +544,78 @@ function loadModules(script, sourcePath, componentImports = []) {
|
|
|
366
544
|
|
|
367
545
|
// The client module sits next to the page's index.html, so the src is
|
|
368
546
|
// the same for every route regardless of how deep it is.
|
|
547
|
+
// Combines the <head> blocks a page and its components contributed.
|
|
548
|
+
//
|
|
549
|
+
// Components come first so the page can override them: a <title> or a
|
|
550
|
+
// <meta name="description"> set by a layout is a sensible default, and
|
|
551
|
+
// the page that knows its own subject should win. Identical lines are
|
|
552
|
+
// emitted once, so a layout and a page both asking for the same
|
|
553
|
+
// stylesheet do not produce it twice.
|
|
554
|
+
function mergeHeads(pageHead, componentHeads) {
|
|
555
|
+
const seen = new Set();
|
|
556
|
+
const lines = [];
|
|
557
|
+
|
|
558
|
+
// A page's <title> and description replace a component's rather than
|
|
559
|
+
// appearing alongside: two titles in one document is invalid.
|
|
560
|
+
const pageTags = uniqueHeadTags(pageHead);
|
|
561
|
+
|
|
562
|
+
for (const block of [...componentHeads, pageHead]) {
|
|
563
|
+
for (const line of (block ?? '').split('\n')) {
|
|
564
|
+
const trimmed = line.trim();
|
|
565
|
+
if (!trimmed) continue;
|
|
566
|
+
|
|
567
|
+
const key = normaliseHeadLine(trimmed);
|
|
568
|
+
if (seen.has(key)) continue;
|
|
569
|
+
|
|
570
|
+
// Dropped in favour of the page's own.
|
|
571
|
+
const tag = uniqueTagName(trimmed);
|
|
572
|
+
if (tag && block !== pageHead && pageTags.has(tag)) continue;
|
|
573
|
+
|
|
574
|
+
seen.add(key);
|
|
575
|
+
lines.push(trimmed);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return lines.join('\n');
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Tags a document may only hold one of, so a component's must give way
|
|
583
|
+
// to the page's. Keyed by tag name, plus the `name` of a <meta> — two
|
|
584
|
+
// different meta tags are fine, two descriptions are not.
|
|
585
|
+
function uniqueTagName(line) {
|
|
586
|
+
const title = /^<title[\s>]/i.test(line);
|
|
587
|
+
if (title) return 'title';
|
|
588
|
+
|
|
589
|
+
const meta = line.match(/^<meta\s[^>]*name=["']([^"']+)["']/i);
|
|
590
|
+
if (meta) return `meta:${meta[1].toLowerCase()}`;
|
|
591
|
+
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function uniqueHeadTags(head) {
|
|
596
|
+
const names = new Set();
|
|
597
|
+
|
|
598
|
+
for (const line of (head ?? '').split('\n')) {
|
|
599
|
+
const tag = uniqueTagName(line.trim());
|
|
600
|
+
if (tag) names.add(tag);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return names;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Whitespace inside a tag should not make two identical links differ.
|
|
607
|
+
function normaliseHeadLine(line) {
|
|
608
|
+
return line.replace(/\s+/g, ' ');
|
|
609
|
+
}
|
|
610
|
+
|
|
369
611
|
// A page's own <head> block wins over the fallback title, so a page
|
|
370
612
|
// can set its own <title>, stylesheets and meta tags.
|
|
371
|
-
function wrapDocument(
|
|
613
|
+
function wrapDocument(
|
|
614
|
+
bodyHtml,
|
|
615
|
+
title,
|
|
616
|
+
head = '',
|
|
617
|
+
{ routerSrc = null, clientSrc = './page.client.js' } = {}
|
|
618
|
+
) {
|
|
372
619
|
const hasOwnTitle = /<title>/i.test(head);
|
|
373
620
|
|
|
374
621
|
// The router is loaded after the page's own module, so a page is
|
|
@@ -385,7 +632,7 @@ function wrapDocument(bodyHtml, title, head = '', { routerSrc = null } = {}) {
|
|
|
385
632
|
${hasOwnTitle ? '' : ` <title>${escapeHtml(title)}</title>\n`}${head ? indent(head) + '\n' : ''}</head>
|
|
386
633
|
<body>
|
|
387
634
|
<div data-azox-root>${bodyHtml}</div>
|
|
388
|
-
|
|
635
|
+
${clientSrc ? `<script type="module" src="${clientSrc}"></script>` : ''}${router}
|
|
389
636
|
</body>
|
|
390
637
|
</html>
|
|
391
638
|
`;
|
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/create.js
CHANGED
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();
|