azoxjs 0.1.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 +188 -5
- package/core/build.js +472 -46
- package/core/cli/router.js +28 -0
- package/core/commands/compile.js +5 -1
- package/core/commands/create.js +44 -10
- package/core/commands/dev.js +42 -18
- package/core/commands/doctor.js +6 -1
- package/core/compiler/compileToJs.js +529 -36
- package/core/compiler/html.js +28 -0
- package/core/compiler/parser.js +219 -9
- package/core/compiler/resolveComponents.js +227 -39
- package/core/compiler/sourceResolver.js +42 -0
- package/core/dev/watcher.js +12 -1
- 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 +95 -0
- package/core/router/navigate.js +211 -0
- package/core/routes.js +44 -0
- package/package.json +1 -1
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';
|
|
13
|
-
import {
|
|
22
|
+
import { parseImports } from './renderer/moduleBindings.js';
|
|
23
|
+
import { evaluateScript } from './renderer/serverScope.js';
|
|
24
|
+
import { escapeHtml } from './compiler/html.js';
|
|
25
|
+
import { collectRoutes, findRoute, resolveRoute, PAGE_EXTENSION } 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,21 @@ 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(
|
|
71
|
+
html = renderToHtml(
|
|
72
|
+
ast,
|
|
73
|
+
buildServerScope(ast.script, inlineModules, route.params),
|
|
74
|
+
inlineModules
|
|
75
|
+
);
|
|
46
76
|
} catch (error) {
|
|
47
77
|
if (!(error instanceof BuildError)) throw error;
|
|
48
78
|
throw new BuildError(`in ${PAGES_DIR}/${name}.azox: ${error.message}`);
|
|
@@ -62,9 +92,16 @@ export function buildRoute(projectDir, route, { transformHtml } = {}) {
|
|
|
62
92
|
|
|
63
93
|
const clientModule = rewriteRuntimeImports(
|
|
64
94
|
compileToModule(ast, {
|
|
65
|
-
sourcePath,
|
|
66
|
-
outPath: clientPath,
|
|
67
95
|
runtimeSpecifier,
|
|
96
|
+
// The user's own relative imports were written next to the
|
|
97
|
+
// page; the compiled module lives in .azox/build/, so they need
|
|
98
|
+
// re-expressing from there.
|
|
99
|
+
// An import hoisted out of a component is relative to that
|
|
100
|
+
// component's file, which is why the hook takes a path.
|
|
101
|
+
rewriteImports: (script, from = sourcePath) =>
|
|
102
|
+
rebaseImports(script, dirname(from), clientPath),
|
|
103
|
+
inlineModules,
|
|
104
|
+
routeParams: route.params ?? null,
|
|
68
105
|
}),
|
|
69
106
|
runtimeSpecifier
|
|
70
107
|
);
|
|
@@ -76,7 +113,19 @@ export function buildRoute(projectDir, route, { transformHtml } = {}) {
|
|
|
76
113
|
const runtimePath = resolve(buildRoot, RUNTIME_FILENAME);
|
|
77
114
|
copyFileSync(resolve(ROOT_DIR, 'core/reactivity/signal.js'), runtimePath);
|
|
78
115
|
|
|
79
|
-
|
|
116
|
+
const router = routerEnabled(projectDir);
|
|
117
|
+
if (router) {
|
|
118
|
+
copyFileSync(resolve(ROOT_DIR, 'core/router/navigate.js'), resolve(buildRoot, ROUTER_FILENAME));
|
|
119
|
+
}
|
|
120
|
+
|
|
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, {
|
|
127
|
+
routerSrc: router ? `${route.assetPrefix}${ROUTER_FILENAME}` : null,
|
|
128
|
+
});
|
|
80
129
|
if (transformHtml) document = transformHtml(document);
|
|
81
130
|
|
|
82
131
|
const htmlPath = resolve(buildRoot, route.htmlPath);
|
|
@@ -94,24 +143,270 @@ export function buildAll(projectDir, options) {
|
|
|
94
143
|
);
|
|
95
144
|
}
|
|
96
145
|
|
|
97
|
-
|
|
146
|
+
const results = expandTemplates(routes).map((route) =>
|
|
147
|
+
buildRoute(projectDir, route, options)
|
|
148
|
+
);
|
|
149
|
+
const assets = copyPublicAssets(projectDir);
|
|
150
|
+
removeStaleOutput(projectDir, results, assets);
|
|
151
|
+
|
|
152
|
+
return results;
|
|
153
|
+
}
|
|
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
|
+
|
|
282
|
+
// Deletes output belonging to pages that no longer exist. Without
|
|
283
|
+
// this, deleting a page leaves its built copy behind and a deployed
|
|
284
|
+
// site keeps serving it.
|
|
285
|
+
//
|
|
286
|
+
// Deliberately narrow: it only ever removes an index.html or a
|
|
287
|
+
// page.client.js that this build did not just write, and only inside
|
|
288
|
+
// the build directory. Anything else found there — a file copied from
|
|
289
|
+
// public/, something a user put there — is left alone.
|
|
290
|
+
function removeStaleOutput(projectDir, results, assets = []) {
|
|
291
|
+
const buildRoot = resolve(projectDir, BUILD_DIR);
|
|
292
|
+
if (!existsSync(buildRoot)) return;
|
|
293
|
+
|
|
294
|
+
const written = new Set([
|
|
295
|
+
...results.flatMap((result) => [result.htmlPath, result.clientPath]),
|
|
296
|
+
...assets,
|
|
297
|
+
]);
|
|
298
|
+
|
|
299
|
+
const generated = new Set(['index.html', 'page.client.js']);
|
|
300
|
+
const emptied = [];
|
|
301
|
+
|
|
302
|
+
const walk = (dir) => {
|
|
303
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
304
|
+
const full = join(dir, entry.name);
|
|
305
|
+
|
|
306
|
+
if (entry.isDirectory()) {
|
|
307
|
+
walk(full);
|
|
308
|
+
// A directory left empty held only pages that are now gone.
|
|
309
|
+
if (readdirSync(full).length === 0) emptied.push(full);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (generated.has(entry.name) && !written.has(full)) rmSync(full);
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
walk(buildRoot);
|
|
318
|
+
|
|
319
|
+
// Innermost first, so a nested route's directories go too.
|
|
320
|
+
for (const dir of emptied.reverse()) {
|
|
321
|
+
if (existsSync(dir) && readdirSync(dir).length === 0) rmSync(dir, { recursive: true });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Everything in public/ is copied to the build root untouched, so a
|
|
326
|
+
// stylesheet, font or image is referenced by the same path in source
|
|
327
|
+
// and in the built site: public/style.css -> /style.css.
|
|
328
|
+
export function copyPublicAssets(projectDir) {
|
|
329
|
+
const publicDir = resolve(projectDir, PUBLIC_DIR);
|
|
330
|
+
if (!existsSync(publicDir)) return [];
|
|
331
|
+
|
|
332
|
+
const buildRoot = resolve(projectDir, BUILD_DIR);
|
|
333
|
+
const copied = [];
|
|
334
|
+
|
|
335
|
+
const walk = (dir, relativeDir) => {
|
|
336
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
337
|
+
if (entry.name.startsWith('.')) continue;
|
|
338
|
+
|
|
339
|
+
const from = join(dir, entry.name);
|
|
340
|
+
const to = join(buildRoot, relativeDir, entry.name);
|
|
341
|
+
|
|
342
|
+
if (entry.isDirectory()) {
|
|
343
|
+
mkdirSync(to, { recursive: true });
|
|
344
|
+
walk(from, join(relativeDir, entry.name));
|
|
345
|
+
} else {
|
|
346
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
347
|
+
copyFileSync(from, to);
|
|
348
|
+
copied.push(to);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
walk(publicDir, '');
|
|
354
|
+
return copied;
|
|
98
355
|
}
|
|
99
356
|
|
|
100
357
|
// Builds a single page by route name or URL.
|
|
101
358
|
export function buildPage(projectDir, pageName, options) {
|
|
102
|
-
const
|
|
359
|
+
const routes = listRoutes(projectDir);
|
|
360
|
+
const route = findRoute(routes, pageName);
|
|
103
361
|
|
|
104
|
-
|
|
105
|
-
|
|
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));
|
|
106
367
|
}
|
|
107
368
|
|
|
108
|
-
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}`);
|
|
109
381
|
}
|
|
110
382
|
|
|
111
383
|
// Rewrites the bare specifier a page's own <script> uses to the same
|
|
112
384
|
// path the compiler emitted for the runtime import.
|
|
385
|
+
//
|
|
386
|
+
// Anchored to an import statement on its own line. Matching the bare
|
|
387
|
+
// string anywhere would rewrite data that merely contains it — a JSON
|
|
388
|
+
// import inlined into the module turned `"bin": {"azox": …}` into a
|
|
389
|
+
// path to the runtime.
|
|
113
390
|
function rewriteRuntimeImports(code, runtimeSpecifier) {
|
|
114
|
-
return code.replace(
|
|
391
|
+
return code.replace(
|
|
392
|
+
/^([ \t]*import\s[\s\S]*?from\s+)(['"])azox(?:js)?(?:\/reactivity)?\2/gm,
|
|
393
|
+
`$1'${runtimeSpecifier}'`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Re-expresses the relative imports in a page's <script> so they
|
|
398
|
+
// still resolve from the compiled module's directory. Lives here
|
|
399
|
+
// rather than in the compiler because it is a fact about where files
|
|
400
|
+
// land on disk, which the compiler deliberately knows nothing about.
|
|
401
|
+
function rebaseImports(script, sourceDir, outPath) {
|
|
402
|
+
return script.replace(
|
|
403
|
+
/(from\s+|import\s+)(['"])(\.[^'"]*)\2/g,
|
|
404
|
+
(full, keyword, quote, specifier) => {
|
|
405
|
+
let rebased = relative(dirname(outPath), resolve(sourceDir, specifier));
|
|
406
|
+
if (!rebased.startsWith('.')) rebased = `./${rebased}`;
|
|
407
|
+
return `${keyword}${quote}${rebased}${quote}`;
|
|
408
|
+
}
|
|
409
|
+
);
|
|
115
410
|
}
|
|
116
411
|
|
|
117
412
|
// A compiler must never write output it knows is broken. Parsing the
|
|
@@ -159,60 +454,191 @@ function projectTitle(projectDir) {
|
|
|
159
454
|
// evaluate the expressions the template references. The script is
|
|
160
455
|
// trusted project source, not user input — the same assumption any
|
|
161
456
|
// template engine's SSR step makes.
|
|
162
|
-
function buildServerScope(script) {
|
|
163
|
-
// Strip imports: the server supplies its own
|
|
164
|
-
//
|
|
165
|
-
|
|
457
|
+
function buildServerScope(script, modules = {}, params = null) {
|
|
458
|
+
// Strip imports: the server supplies its own primitives rather than
|
|
459
|
+
// loading the real reactive runtime, and anything else a script
|
|
460
|
+
// imports was resolved by loadModules and is passed in.
|
|
461
|
+
const body = script.replace(/^\s*import\s.+?;?\s*$/gm, '');
|
|
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
|
+
: {};
|
|
166
469
|
|
|
167
470
|
try {
|
|
168
|
-
|
|
169
|
-
return fn(serverSignal);
|
|
471
|
+
return evaluateScript(body, [], [], modules, extras);
|
|
170
472
|
} catch (error) {
|
|
473
|
+
if (error instanceof BuildError) throw error;
|
|
171
474
|
throw new BuildError(`failed to evaluate the page's <script> block: ${error.message}`);
|
|
172
475
|
}
|
|
173
476
|
}
|
|
174
477
|
|
|
175
|
-
//
|
|
176
|
-
// the
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
478
|
+
// Resolves what a script's imports bring in, so server rendering sees
|
|
479
|
+
// the same values the browser will.
|
|
480
|
+
//
|
|
481
|
+
// Only JSON is loaded. A JSON import is synchronous and has no side
|
|
482
|
+
// effects, which suits a build step that must stay synchronous — and
|
|
483
|
+
// it covers the case this exists for: reading a version or some other
|
|
484
|
+
// constant out of package.json. Importing a .js module would mean
|
|
485
|
+
// executing project code during the build, and `require(esm)` only
|
|
486
|
+
// works from Node 22.12, below the floor this package declares.
|
|
487
|
+
function loadModules(script, sourcePath, componentImports = []) {
|
|
488
|
+
// A component's hoisted import is relative to the component's own
|
|
489
|
+
// file, so each is resolved against the path it came with.
|
|
490
|
+
const imports = [
|
|
491
|
+
...parseImports(script).map((entry) => ({ ...entry, from: sourcePath })),
|
|
492
|
+
...componentImports.flatMap((entry) =>
|
|
493
|
+
parseImports(entry.statement).map((parsed) => ({ ...parsed, from: entry.path }))
|
|
494
|
+
),
|
|
495
|
+
];
|
|
496
|
+
|
|
497
|
+
if (!imports.length) return {};
|
|
498
|
+
|
|
499
|
+
const bindings = {};
|
|
500
|
+
|
|
501
|
+
for (const { specifier, bindings: names, from } of imports) {
|
|
502
|
+
if (!specifier.endsWith('.json')) {
|
|
503
|
+
throw new BuildError(
|
|
504
|
+
`cannot import '${specifier}': a <script> block may import .azox components, ` +
|
|
505
|
+
`'azox/reactivity', and .json files. Other modules are not available during ` +
|
|
506
|
+
`server rendering.`
|
|
507
|
+
);
|
|
508
|
+
}
|
|
186
509
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
510
|
+
const require = createRequire(from ? `file://${from}` : import.meta.url);
|
|
511
|
+
|
|
512
|
+
let loaded;
|
|
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)];
|
|
519
|
+
loaded = require(specifier);
|
|
520
|
+
} catch (error) {
|
|
521
|
+
throw new BuildError(`cannot import '${specifier}': ${error.message.split('\n')[0]}`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
for (const { local, imported } of names) {
|
|
525
|
+
if (imported === '*' || imported === 'default') bindings[local] = loaded;
|
|
526
|
+
else bindings[local] = loaded[imported];
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return bindings;
|
|
193
531
|
}
|
|
194
532
|
|
|
195
533
|
// The client module sits next to the page's index.html, so the src is
|
|
196
534
|
// the same for every route regardless of how deep it is.
|
|
197
|
-
|
|
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
|
+
|
|
599
|
+
// A page's own <head> block wins over the fallback title, so a page
|
|
600
|
+
// can set its own <title>, stylesheets and meta tags.
|
|
601
|
+
function wrapDocument(bodyHtml, title, head = '', { routerSrc = null } = {}) {
|
|
602
|
+
const hasOwnTitle = /<title>/i.test(head);
|
|
603
|
+
|
|
604
|
+
// The router is loaded after the page's own module, so a page is
|
|
605
|
+
// interactive before navigation is enhanced.
|
|
606
|
+
const router = routerSrc
|
|
607
|
+
? `\n<script type="module">import { startRouter } from '${routerSrc}'; startRouter();</script>`
|
|
608
|
+
: '';
|
|
609
|
+
|
|
198
610
|
return `<!doctype html>
|
|
199
611
|
<html lang="en">
|
|
200
612
|
<head>
|
|
201
613
|
<meta charset="utf-8" />
|
|
202
614
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
203
|
-
<title>${escapeHtml(title)}</title>
|
|
204
|
-
</head>
|
|
615
|
+
${hasOwnTitle ? '' : ` <title>${escapeHtml(title)}</title>\n`}${head ? indent(head) + '\n' : ''}</head>
|
|
205
616
|
<body>
|
|
206
617
|
<div data-azox-root>${bodyHtml}</div>
|
|
207
|
-
<script type="module" src="./page.client.js"></script
|
|
618
|
+
<script type="module" src="./page.client.js"></script>${router}
|
|
208
619
|
</body>
|
|
209
620
|
</html>
|
|
210
621
|
`;
|
|
211
622
|
}
|
|
212
623
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
624
|
+
// Reads `router` from the project's package.json. A malformed file is
|
|
625
|
+
// not this function's problem to report — the build reads it again for
|
|
626
|
+
// the page title and will surface anything wrong there.
|
|
627
|
+
function routerEnabled(projectDir) {
|
|
628
|
+
const pkgPath = resolve(projectDir, 'package.json');
|
|
629
|
+
if (!existsSync(pkgPath)) return false;
|
|
630
|
+
|
|
631
|
+
try {
|
|
632
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).router === true;
|
|
633
|
+
} catch {
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
218
636
|
}
|
|
637
|
+
|
|
638
|
+
function indent(block) {
|
|
639
|
+
return block
|
|
640
|
+
.split('\n')
|
|
641
|
+
.map((line) => (line.trim() ? ` ${line.trim()}` : line))
|
|
642
|
+
.join('\n');
|
|
643
|
+
}
|
|
644
|
+
|
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/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('');
|