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/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 { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync } from 'node:fs';
7
- import { resolve, basename } from 'node:path';
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 { collectRoutes, findRoute } from './routes.js';
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(ast, buildServerScope(ast.script));
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
- let document = wrapDocument(html, projectTitle(projectDir));
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
- return routes.map((route) => buildRoute(projectDir, route, options));
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 route = findRoute(listRoutes(projectDir), pageName);
359
+ const routes = listRoutes(projectDir);
360
+ const route = findRoute(routes, pageName);
103
361
 
104
- if (!route) {
105
- throw new BuildError(`page "${PAGES_DIR}/${pageName}.azox" not found in ${projectDir}`);
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(/(['"])azox(?:\/reactivity)?\1/g, `'${runtimeSpecifier}'`);
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 `signal` stub rather
164
- // than loading the real reactive runtime.
165
- const body = script.replace(/^\s*import\s.+?;\s*$/gm, '');
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
- const fn = new Function('signal', `${body}\nreturn { ${declaredNames(body).join(', ')} };`);
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
- // SSR needs only the current value, not reactivity, so `signal()` on
176
- // the server is a plain boxed value.
177
- function serverSignal(initial) {
178
- let value = initial;
179
- const read = () => value;
180
- read.set = (next) => {
181
- value = typeof next === 'function' ? next(value) : next;
182
- };
183
- read.peek = () => value;
184
- return read;
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
- function declaredNames(script) {
188
- const names = [];
189
- const regex = /const\s+(\w+)\s*=/g;
190
- let match;
191
- while ((match = regex.exec(script))) names.push(match[1]);
192
- return names;
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
- function wrapDocument(bodyHtml, title) {
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
- function escapeHtml(str) {
214
- return String(str)
215
- .replace(/&/g, '&amp;')
216
- .replace(/</g, '&lt;')
217
- .replace(/>/g, '&gt;');
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
+
@@ -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;
@@ -15,7 +15,11 @@ export function compileCommand({ flags }) {
15
15
  const projectDir = process.cwd();
16
16
 
17
17
  try {
18
- const results = flags.page ? [buildPage(projectDir, flags.page)] : buildAll(projectDir);
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('');