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 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: early development (v0.2.0). APIs are unstable and will
15
- > change without notice until v1.0.
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, and
258
- one shared copy of the runtime. No dev machinery is included. Serve
259
- that directory with any static host and it works with no install
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(ast, buildServerScope(ast.script, inlineModules), inlineModules);
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
- writeFileSync(clientPath, clientModule, 'utf8');
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
- let document = wrapDocument(html, projectTitle(projectDir), ast.head, {
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) => buildRoute(projectDir, route, options));
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 route = findRoute(listRoutes(projectDir), pageName);
371
+ const routes = listRoutes(projectDir);
372
+ const route = findRoute(routes, pageName);
221
373
 
222
- if (!route) {
223
- throw new BuildError(`page "${PAGES_DIR}/${pageName}.azox" not found in ${projectDir}`);
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(bodyHtml, title, head = '', { routerSrc = null } = {}) {
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
- <script type="module" src="./page.client.js"></script>${router}
635
+ ${clientSrc ? `<script type="module" src="${clientSrc}"></script>` : ''}${router}
389
636
  </body>
390
637
  </html>
391
638
  `;
@@ -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('');
@@ -119,8 +119,7 @@ function starterPage(name) {
119
119
  </ul>
120
120
 
121
121
  <input
122
- value={draft()}
123
- on:input={(e) => draft.set(e.target.value)}
122
+ bind:value={draft}
124
123
  placeholder="Add a task"
125
124
  />
126
125
  <button on:click={() => {
@@ -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();