azoxjs 0.2.0 → 0.3.0

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