azoxjs 0.1.0 → 0.2.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
@@ -1,6 +1,8 @@
1
1
  # Azox Framework
2
2
 
3
3
  [![CI](https://github.com/darilpratomo/azox/actions/workflows/ci.yml/badge.svg)](https://github.com/darilpratomo/azox/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/azoxjs.svg)](https://www.npmjs.com/package/azoxjs)
5
+ [![install size](https://img.shields.io/badge/dependencies-0-brightgreen)](https://www.npmjs.com/package/azoxjs)
4
6
 
5
7
  **The Sound of Future Web**
6
8
 
@@ -9,7 +11,7 @@ third-party CLI dependencies, no borrowed syntax from React, Vue, or
9
11
  Next.js. It compiles `.azox` components directly into fine-grained,
10
12
  signal-driven DOM updates.
11
13
 
12
- > Status: early development (v0.1.0). APIs are unstable and will
14
+ > Status: early development (v0.2.0). APIs are unstable and will
13
15
  > change without notice until v1.0.
14
16
 
15
17
  ## Why Azox
@@ -120,10 +122,114 @@ Declaring props with `props()` is what lets the compiler reject a
120
122
  caller that passes something the component never asked for, instead
121
123
  of dropping it silently.
122
124
 
123
- In this version components are presentational: they take props and
124
- render markup, and state lives in the page that uses them. A
125
- component that declares its own logic is rejected with an explicit
126
- error rather than quietly sharing the caller's scope.
125
+ A component may hold its own state. Its `<script>` becomes a scope
126
+ of its own, so two uses of the same component are independent — each
127
+ `<Counter />` below counts separately:
128
+
129
+ ```html
130
+ <!-- components/Counter.azox -->
131
+ <script>
132
+ import { signal } from 'azox/reactivity';
133
+ const count = signal(0);
134
+ </script>
135
+
136
+ <button on:click={() => count.set(count() + 1)}>{count()}</button>
137
+ ```
138
+
139
+ ```html
140
+ <main>
141
+ <Counter />
142
+ <Counter />
143
+ </main>
144
+ ```
145
+
146
+ There is still no component instance at runtime: the compiler wraps
147
+ each use in its own JavaScript scope, which is ordinary scoping
148
+ rather than a framework construct.
149
+
150
+ ## Loops and conditionals
151
+
152
+ Control flow is expressed as tags, so it nests inside markup like
153
+ anything else.
154
+
155
+ ```html
156
+ <ul>
157
+ <each item={todos()} as="todo" index="i">
158
+ <li>{i + 1}. {todo}</li>
159
+ </each>
160
+ </ul>
161
+
162
+ <if cond={user()}>
163
+ <p>Signed in as {user().name}</p>
164
+ <else />
165
+ <a href="/login">Sign in</a>
166
+ </if>
167
+ ```
168
+
169
+ Each block marks its place with a pair of comment nodes, and an
170
+ update replaces only the nodes between them.
171
+
172
+ By default a change to a list rebuilds its rows. Give a row an
173
+ identity with `key` and it survives instead: reordering moves it,
174
+ removing one leaves the rest untouched, and adding one does not
175
+ disturb what is already there.
176
+
177
+ ```html
178
+ <each item={tasks()} as="task" key={task.id}>
179
+ <li><TaskRow title={task.title} /></li>
180
+ </each>
181
+ ```
182
+
183
+ Use something stable and unique to the row — a database id, not its
184
+ position, since a position changes when the list does.
185
+
186
+ ## Client-side routing
187
+
188
+ By default every link is a full page load, which is the right
189
+ behaviour for a static site. Opt in to client-side navigation with
190
+ `router: true` in your project's `package.json`:
191
+
192
+ ```json
193
+ {
194
+ "router": true
195
+ }
196
+ ```
197
+
198
+ Internal links are then swapped in place: the new page's HTML is
199
+ fetched, the document body and `<head>` are replaced, and its module
200
+ runs. Scroll position, the back button, and `<a target>` all behave
201
+ as they would with a full load. A link is prefetched when the pointer
202
+ enters it, so the page is usually already in hand by the time it is
203
+ clicked.
204
+
205
+ Anything the router cannot handle — an external origin, a download,
206
+ a modifier-click — falls through to the browser untouched.
207
+
208
+ ## Importing data
209
+
210
+ A `<script>` block may import a `.json` file, which is how a page
211
+ reads a constant it should not have written out by hand:
212
+
213
+ ```html
214
+ <script>
215
+ import pkg from '../package.json' with { type: 'json' };
216
+ </script>
217
+
218
+ <span>v{pkg.version}</span>
219
+ ```
220
+
221
+ The file is read once during the build. Server rendering evaluates
222
+ against it, and the value is compiled into the module as a constant
223
+ rather than imported — the file sits outside the build directory and
224
+ is never deployed, so an import would 404 in the browser.
225
+
226
+ Only the properties the markup reads are included, so importing
227
+ `package.json` for a version does not ship the rest of the file to
228
+ every visitor.
229
+
230
+ Importing a `.js` module is not supported: it would mean executing
231
+ project code during the build. Use a `.json` file for data, and
232
+ `azox/reactivity` for signals.
127
233
 
128
234
  ## Getting Started
129
235
 
@@ -179,6 +285,7 @@ azox/
179
285
  │ ├── dev/ dev server, file watching, live reload
180
286
  │ ├── reactivity/ signal() / effect() / computed()
181
287
  │ ├── renderer/ server-side HTML rendering
288
+ │ ├── router/ opt-in client-side navigation
182
289
  │ ├── build.js the build pipeline, shared by commands
183
290
  │ ├── routes.js file layout → urls and output paths
184
291
  │ └── meta.js version and identity strings
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';
22
+ import { parseImports } from './renderer/moduleBindings.js';
23
+ import { evaluateScript } from './renderer/serverScope.js';
24
+ import { escapeHtml } from './compiler/html.js';
13
25
  import { collectRoutes, findRoute } 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,17 @@ 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(ast, buildServerScope(ast.script, inlineModules), inlineModules);
46
72
  } catch (error) {
47
73
  if (!(error instanceof BuildError)) throw error;
48
74
  throw new BuildError(`in ${PAGES_DIR}/${name}.azox: ${error.message}`);
@@ -62,9 +88,15 @@ export function buildRoute(projectDir, route, { transformHtml } = {}) {
62
88
 
63
89
  const clientModule = rewriteRuntimeImports(
64
90
  compileToModule(ast, {
65
- sourcePath,
66
- outPath: clientPath,
67
91
  runtimeSpecifier,
92
+ // The user's own relative imports were written next to the
93
+ // page; the compiled module lives in .azox/build/, so they need
94
+ // re-expressing from there.
95
+ // An import hoisted out of a component is relative to that
96
+ // component's file, which is why the hook takes a path.
97
+ rewriteImports: (script, from = sourcePath) =>
98
+ rebaseImports(script, dirname(from), clientPath),
99
+ inlineModules,
68
100
  }),
69
101
  runtimeSpecifier
70
102
  );
@@ -76,7 +108,14 @@ export function buildRoute(projectDir, route, { transformHtml } = {}) {
76
108
  const runtimePath = resolve(buildRoot, RUNTIME_FILENAME);
77
109
  copyFileSync(resolve(ROOT_DIR, 'core/reactivity/signal.js'), runtimePath);
78
110
 
79
- let document = wrapDocument(html, projectTitle(projectDir));
111
+ const router = routerEnabled(projectDir);
112
+ if (router) {
113
+ copyFileSync(resolve(ROOT_DIR, 'core/router/navigate.js'), resolve(buildRoot, ROUTER_FILENAME));
114
+ }
115
+
116
+ let document = wrapDocument(html, projectTitle(projectDir), ast.head, {
117
+ routerSrc: router ? `${route.assetPrefix}${ROUTER_FILENAME}` : null,
118
+ });
80
119
  if (transformHtml) document = transformHtml(document);
81
120
 
82
121
  const htmlPath = resolve(buildRoot, route.htmlPath);
@@ -94,7 +133,86 @@ export function buildAll(projectDir, options) {
94
133
  );
95
134
  }
96
135
 
97
- return routes.map((route) => buildRoute(projectDir, route, options));
136
+ const results = routes.map((route) => buildRoute(projectDir, route, options));
137
+ const assets = copyPublicAssets(projectDir);
138
+ removeStaleOutput(projectDir, results, assets);
139
+
140
+ return results;
141
+ }
142
+
143
+ // Deletes output belonging to pages that no longer exist. Without
144
+ // this, deleting a page leaves its built copy behind and a deployed
145
+ // site keeps serving it.
146
+ //
147
+ // Deliberately narrow: it only ever removes an index.html or a
148
+ // page.client.js that this build did not just write, and only inside
149
+ // the build directory. Anything else found there — a file copied from
150
+ // public/, something a user put there — is left alone.
151
+ function removeStaleOutput(projectDir, results, assets = []) {
152
+ const buildRoot = resolve(projectDir, BUILD_DIR);
153
+ if (!existsSync(buildRoot)) return;
154
+
155
+ const written = new Set([
156
+ ...results.flatMap((result) => [result.htmlPath, result.clientPath]),
157
+ ...assets,
158
+ ]);
159
+
160
+ const generated = new Set(['index.html', 'page.client.js']);
161
+ const emptied = [];
162
+
163
+ const walk = (dir) => {
164
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
165
+ const full = join(dir, entry.name);
166
+
167
+ if (entry.isDirectory()) {
168
+ walk(full);
169
+ // A directory left empty held only pages that are now gone.
170
+ if (readdirSync(full).length === 0) emptied.push(full);
171
+ continue;
172
+ }
173
+
174
+ if (generated.has(entry.name) && !written.has(full)) rmSync(full);
175
+ }
176
+ };
177
+
178
+ walk(buildRoot);
179
+
180
+ // Innermost first, so a nested route's directories go too.
181
+ for (const dir of emptied.reverse()) {
182
+ if (existsSync(dir) && readdirSync(dir).length === 0) rmSync(dir, { recursive: true });
183
+ }
184
+ }
185
+
186
+ // Everything in public/ is copied to the build root untouched, so a
187
+ // stylesheet, font or image is referenced by the same path in source
188
+ // and in the built site: public/style.css -> /style.css.
189
+ export function copyPublicAssets(projectDir) {
190
+ const publicDir = resolve(projectDir, PUBLIC_DIR);
191
+ if (!existsSync(publicDir)) return [];
192
+
193
+ const buildRoot = resolve(projectDir, BUILD_DIR);
194
+ const copied = [];
195
+
196
+ const walk = (dir, relativeDir) => {
197
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
198
+ if (entry.name.startsWith('.')) continue;
199
+
200
+ const from = join(dir, entry.name);
201
+ const to = join(buildRoot, relativeDir, entry.name);
202
+
203
+ if (entry.isDirectory()) {
204
+ mkdirSync(to, { recursive: true });
205
+ walk(from, join(relativeDir, entry.name));
206
+ } else {
207
+ mkdirSync(dirname(to), { recursive: true });
208
+ copyFileSync(from, to);
209
+ copied.push(to);
210
+ }
211
+ }
212
+ };
213
+
214
+ walk(publicDir, '');
215
+ return copied;
98
216
  }
99
217
 
100
218
  // Builds a single page by route name or URL.
@@ -110,8 +228,31 @@ export function buildPage(projectDir, pageName, options) {
110
228
 
111
229
  // Rewrites the bare specifier a page's own <script> uses to the same
112
230
  // path the compiler emitted for the runtime import.
231
+ //
232
+ // Anchored to an import statement on its own line. Matching the bare
233
+ // string anywhere would rewrite data that merely contains it — a JSON
234
+ // import inlined into the module turned `"bin": {"azox": …}` into a
235
+ // path to the runtime.
113
236
  function rewriteRuntimeImports(code, runtimeSpecifier) {
114
- return code.replace(/(['"])azox(?:\/reactivity)?\1/g, `'${runtimeSpecifier}'`);
237
+ return code.replace(
238
+ /^([ \t]*import\s[\s\S]*?from\s+)(['"])azox(?:js)?(?:\/reactivity)?\2/gm,
239
+ `$1'${runtimeSpecifier}'`
240
+ );
241
+ }
242
+
243
+ // Re-expresses the relative imports in a page's <script> so they
244
+ // still resolve from the compiled module's directory. Lives here
245
+ // rather than in the compiler because it is a fact about where files
246
+ // land on disk, which the compiler deliberately knows nothing about.
247
+ function rebaseImports(script, sourceDir, outPath) {
248
+ return script.replace(
249
+ /(from\s+|import\s+)(['"])(\.[^'"]*)\2/g,
250
+ (full, keyword, quote, specifier) => {
251
+ let rebased = relative(dirname(outPath), resolve(sourceDir, specifier));
252
+ if (!rebased.startsWith('.')) rebased = `./${rebased}`;
253
+ return `${keyword}${quote}${rebased}${quote}`;
254
+ }
255
+ );
115
256
  }
116
257
 
117
258
  // A compiler must never write output it knows is broken. Parsing the
@@ -159,60 +300,115 @@ function projectTitle(projectDir) {
159
300
  // evaluate the expressions the template references. The script is
160
301
  // trusted project source, not user input — the same assumption any
161
302
  // 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, '');
303
+ function buildServerScope(script, modules = {}) {
304
+ // Strip imports: the server supplies its own primitives rather than
305
+ // loading the real reactive runtime, and anything else a script
306
+ // imports was resolved by loadModules and is passed in.
307
+ const body = script.replace(/^\s*import\s.+?;?\s*$/gm, '');
166
308
 
167
309
  try {
168
- const fn = new Function('signal', `${body}\nreturn { ${declaredNames(body).join(', ')} };`);
169
- return fn(serverSignal);
310
+ return evaluateScript(body, [], [], modules);
170
311
  } catch (error) {
312
+ if (error instanceof BuildError) throw error;
171
313
  throw new BuildError(`failed to evaluate the page's <script> block: ${error.message}`);
172
314
  }
173
315
  }
174
316
 
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
- }
317
+ // Resolves what a script's imports bring in, so server rendering sees
318
+ // the same values the browser will.
319
+ //
320
+ // Only JSON is loaded. A JSON import is synchronous and has no side
321
+ // effects, which suits a build step that must stay synchronous — and
322
+ // it covers the case this exists for: reading a version or some other
323
+ // constant out of package.json. Importing a .js module would mean
324
+ // executing project code during the build, and `require(esm)` only
325
+ // works from Node 22.12, below the floor this package declares.
326
+ function loadModules(script, sourcePath, componentImports = []) {
327
+ // A component's hoisted import is relative to the component's own
328
+ // file, so each is resolved against the path it came with.
329
+ const imports = [
330
+ ...parseImports(script).map((entry) => ({ ...entry, from: sourcePath })),
331
+ ...componentImports.flatMap((entry) =>
332
+ parseImports(entry.statement).map((parsed) => ({ ...parsed, from: entry.path }))
333
+ ),
334
+ ];
335
+
336
+ if (!imports.length) return {};
337
+
338
+ const bindings = {};
339
+
340
+ for (const { specifier, bindings: names, from } of imports) {
341
+ if (!specifier.endsWith('.json')) {
342
+ throw new BuildError(
343
+ `cannot import '${specifier}': a <script> block may import .azox components, ` +
344
+ `'azox/reactivity', and .json files. Other modules are not available during ` +
345
+ `server rendering.`
346
+ );
347
+ }
348
+
349
+ const require = createRequire(from ? `file://${from}` : import.meta.url);
350
+
351
+ let loaded;
352
+ try {
353
+ loaded = require(specifier);
354
+ } catch (error) {
355
+ throw new BuildError(`cannot import '${specifier}': ${error.message.split('\n')[0]}`);
356
+ }
186
357
 
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;
358
+ for (const { local, imported } of names) {
359
+ if (imported === '*' || imported === 'default') bindings[local] = loaded;
360
+ else bindings[local] = loaded[imported];
361
+ }
362
+ }
363
+
364
+ return bindings;
193
365
  }
194
366
 
195
367
  // The client module sits next to the page's index.html, so the src is
196
368
  // the same for every route regardless of how deep it is.
197
- function wrapDocument(bodyHtml, title) {
369
+ // A page's own <head> block wins over the fallback title, so a page
370
+ // can set its own <title>, stylesheets and meta tags.
371
+ function wrapDocument(bodyHtml, title, head = '', { routerSrc = null } = {}) {
372
+ const hasOwnTitle = /<title>/i.test(head);
373
+
374
+ // The router is loaded after the page's own module, so a page is
375
+ // interactive before navigation is enhanced.
376
+ const router = routerSrc
377
+ ? `\n<script type="module">import { startRouter } from '${routerSrc}'; startRouter();</script>`
378
+ : '';
379
+
198
380
  return `<!doctype html>
199
381
  <html lang="en">
200
382
  <head>
201
383
  <meta charset="utf-8" />
202
384
  <meta name="viewport" content="width=device-width, initial-scale=1" />
203
- <title>${escapeHtml(title)}</title>
204
- </head>
385
+ ${hasOwnTitle ? '' : ` <title>${escapeHtml(title)}</title>\n`}${head ? indent(head) + '\n' : ''}</head>
205
386
  <body>
206
387
  <div data-azox-root>${bodyHtml}</div>
207
- <script type="module" src="./page.client.js"></script>
388
+ <script type="module" src="./page.client.js"></script>${router}
208
389
  </body>
209
390
  </html>
210
391
  `;
211
392
  }
212
393
 
213
- function escapeHtml(str) {
214
- return String(str)
215
- .replace(/&/g, '&amp;')
216
- .replace(/</g, '&lt;')
217
- .replace(/>/g, '&gt;');
394
+ // Reads `router` from the project's package.json. A malformed file is
395
+ // not this function's problem to report — the build reads it again for
396
+ // the page title and will surface anything wrong there.
397
+ function routerEnabled(projectDir) {
398
+ const pkgPath = resolve(projectDir, 'package.json');
399
+ if (!existsSync(pkgPath)) return false;
400
+
401
+ try {
402
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).router === true;
403
+ } catch {
404
+ return false;
405
+ }
406
+ }
407
+
408
+ function indent(block) {
409
+ return block
410
+ .split('\n')
411
+ .map((line) => (line.trim() ? ` ${line.trim()}` : line))
412
+ .join('\n');
218
413
  }
414
+
@@ -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;
@@ -88,18 +88,46 @@ function starterPage(name) {
88
88
  import Counter from '../components/Counter.azox';
89
89
  import { signal } from 'azox/reactivity';
90
90
 
91
- const count = signal(0);
91
+ const tasks = signal([
92
+ { id: 1, title: 'Edit this page', done: true },
93
+ { id: 2, title: 'Add a page of your own', done: false },
94
+ ]);
95
+ const draft = signal('');
96
+ let nextId = 3;
92
97
  </script>
93
98
 
94
99
  <main class="page">
95
100
  <h1>${name}</h1>
96
101
  <p>Built with Azox.</p>
97
102
 
98
- <Counter label="Clicks" value={count()} />
99
-
100
- <button on:click={() => count.set(count() + 1)}>
101
- Add one
102
- </button>
103
+ <!-- Each Counter holds a count of its own. -->
104
+ <Counter label="Left" />
105
+ <Counter label="Right" />
106
+
107
+ <!-- key={task.id} gives each row an identity, so adding to the
108
+ list leaves the rows already there untouched. -->
109
+ <ul>
110
+ <each item={tasks()} as="task" index="i" key={task.id}>
111
+ <li>
112
+ <if cond={task.done}>
113
+ <s>{i + 1}. {task.title}</s>
114
+ <else />
115
+ <span>{i + 1}. {task.title}</span>
116
+ </if>
117
+ </li>
118
+ </each>
119
+ </ul>
120
+
121
+ <input
122
+ value={draft()}
123
+ on:input={(e) => draft.set(e.target.value)}
124
+ placeholder="Add a task"
125
+ />
126
+ <button on:click={() => {
127
+ if (!draft()) return;
128
+ tasks.set([...tasks(), { id: nextId++, title: draft(), done: false }]);
129
+ draft.set('');
130
+ }}>Add</button>
103
131
 
104
132
  <p><a href="/about">About</a></p>
105
133
  </main>
@@ -117,14 +145,20 @@ function aboutPage(name) {
117
145
  `;
118
146
  }
119
147
 
120
- // Components take props and render markup. State lives in the page
121
- // that uses them.
148
+ // A component may hold state of its own, and each use gets its own
149
+ // copy the two Counters on the starter page count independently.
122
150
  function starterComponent() {
123
151
  return `<script>
124
- const { label, value } = props();
152
+ import { signal } from 'azox/reactivity';
153
+
154
+ const { label } = props();
155
+ const count = signal(0);
125
156
  </script>
126
157
 
127
- <p class="counter">{label}: {value}</p>
158
+ <span class="counter">
159
+ {label}: {count()}
160
+ <button on:click={() => count.set(count() + 1)}>+</button>
161
+ </span>
128
162
  `;
129
163
  }
130
164