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.
@@ -10,6 +10,52 @@
10
10
  let uid = 0;
11
11
  const nextId = () => `_el${uid++}`;
12
12
 
13
+ // Names the build resolved to constants — an inlined JSON import, say.
14
+ // An expression reading only these can never change, so it is emitted
15
+ // as text rather than wrapped in an effect. Set per compile.
16
+ let constantNames = new Set();
17
+ let constantValues = {};
18
+
19
+ // True when `expr` reads nothing that could ever change: literals,
20
+ // operators, and property paths rooted in a build-time constant.
21
+ //
22
+ // Deliberately conservative. A call, an unknown identifier, or anything
23
+ // it cannot account for means "assume it changes" — being wrong that way
24
+ // costs an effect that never fires, while the opposite silently freezes
25
+ // a binding that should update.
26
+ function isConstantExpression(expr) {
27
+ if (!constantNames.size) return false;
28
+
29
+ const source = expr.trim();
30
+ if (!source) return false;
31
+
32
+ // A call could return anything, and assignment or increment means the
33
+ // value is meant to change.
34
+ if (/[(]/.test(source)) return false;
35
+ if (/(\+\+|--|[^=!<>]=[^=])/.test(source)) return false;
36
+
37
+ // Strip strings and template literals before looking at identifiers,
38
+ // so words inside them are not mistaken for names.
39
+ const withoutStrings = source
40
+ .replace(/'(?:[^'\\]|\\.)*'/g, "''")
41
+ .replace(/"(?:[^"\\]|\\.)*"/g, '""')
42
+ .replace(/`(?:[^`\\$]|\\.|\$(?!\{))*`/g, '``');
43
+
44
+ // A template literal with a placeholder is not handled here.
45
+ if (/`/.test(withoutStrings) && /\$\{/.test(source)) return false;
46
+
47
+ // Every identifier that is not a property access must be a known
48
+ // constant. `pkg.version` yields `pkg`; `a.b.c` yields `a`.
49
+ const roots = [...withoutStrings.matchAll(/(\.)?\b([A-Za-z_$][\w$]*)\b/g)]
50
+ .filter(([, dot]) => !dot)
51
+ .map(([, , name]) => name);
52
+
53
+ if (!roots.length) return false;
54
+
55
+ const allowed = new Set(['true', 'false', 'null', 'undefined']);
56
+ return roots.every((name) => constantNames.has(name) || allowed.has(name));
57
+ }
58
+
13
59
  // runtimeSpecifier: how the emitted module should import the Azox
14
60
  // runtime — a relative path to the copied runtime for a build, or
15
61
  // whatever the playground wants to point at.
@@ -23,12 +69,25 @@ const nextId = () => `_el${uid++}`;
23
69
  // value, whose import must not reach the browser. A JSON import
24
70
  // points outside the build directory at a file that is never
25
71
  // deployed, so the value is emitted as a constant instead.
26
- export function compileToModule(ast, { runtimeSpecifier, rewriteImports, inlineModules }) {
72
+ // routeParams: the resolved parameters for this page of a dynamic
73
+ // route. routes() is a build-time declaration and params() is
74
+ // answered before the browser is involved, so both are removed from
75
+ // the emitted module and the values are inlined.
76
+ export function compileToModule(
77
+ ast,
78
+ { runtimeSpecifier, rewriteImports, inlineModules, routeParams }
79
+ ) {
27
80
  uid = 0;
81
+ // Values the build resolved: their bindings need no effect, which is
82
+ // what lets a page whose only "dynamic" text is a version number ship
83
+ // as a static page.
84
+ constantValues = inlineModules ?? {};
85
+ constantNames = new Set(Object.keys(constantValues));
28
86
  const statements = [];
29
87
  const rootVar = emitNode(ast.markup, statements, 'root');
30
88
 
31
89
  let script = dropComponentImports(ast.script);
90
+ script = resolveRouteDeclarations(script, routeParams);
32
91
  if (rewriteImports) script = rewriteImports(script);
33
92
 
34
93
  // A page with no bindings and no listeners has nothing to hydrate:
@@ -57,13 +116,15 @@ export function compileToModule(ast, { runtimeSpecifier, rewriteImports, inlineM
57
116
  // A keyed list disposes the effects of rows that leave, so the
58
117
  // module needs dispose as well as effect.
59
118
  const needsDispose = statements.some((line) => line.includes('dispose('));
119
+ const needsUntracked = statements.some((line) => line.includes('untracked('));
60
120
 
61
121
  const { imports, body } = mergeImports(
62
122
  script,
63
123
  hoisted,
64
124
  runtimeSpecifier,
65
125
  needsDispose,
66
- inlineModules
126
+ inlineModules,
127
+ needsUntracked
67
128
  );
68
129
 
69
130
  // Narrowed to what the module actually reads, so importing
@@ -94,7 +155,8 @@ function mergeImports(
94
155
  componentImports,
95
156
  runtimeSpecifier,
96
157
  needsDispose = false,
97
- inlineModules = null
158
+ inlineModules = null,
159
+ needsUntracked = false
98
160
  ) {
99
161
  const pageImports = [...script.matchAll(/^\s*(import\s[^;\n]+;?)\s*$/gm)]
100
162
  .map((m) => m[1].trim())
@@ -108,6 +170,24 @@ function mergeImports(
108
170
  const named = new Map();
109
171
  const verbatim = new Set();
110
172
 
173
+ // A page writes `azox/reactivity`; the compiler's own imports use
174
+ // whatever runtimeSpecifier it was given. They name the same module,
175
+ // so they are grouped under one key — otherwise a page importing
176
+ // `signal` and a keyed list needing it emit two imports that the
177
+ // build later rewrites to the same path, which is a redeclaration and
178
+ // a syntax error.
179
+ //
180
+ // Only the grouping is normalised. The emitted specifier stays as
181
+ // written, because rewriting it to a path on disk is the build's job —
182
+ // the playground compiles in the browser with no build at all.
183
+ const RUNTIME_ALIASES = new Set([
184
+ 'azox',
185
+ 'azox/reactivity',
186
+ 'azoxjs',
187
+ 'azoxjs/reactivity',
188
+ runtimeSpecifier,
189
+ ]);
190
+
111
191
  const record = (statement) => {
112
192
  const match = statement.match(/^import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/);
113
193
 
@@ -116,7 +196,13 @@ function mergeImports(
116
196
  return;
117
197
  }
118
198
 
119
- const [, bindings, specifier] = match;
199
+ const [, bindings, rawSpecifier] = match;
200
+ // Grouped under the first spelling seen for the runtime, so the
201
+ // page's own `azox/reactivity` survives into the output when that is
202
+ // what was written.
203
+ const specifier = RUNTIME_ALIASES.has(rawSpecifier)
204
+ ? runtimeKey(named, rawSpecifier, RUNTIME_ALIASES)
205
+ : rawSpecifier;
120
206
  const set = named.get(specifier) ?? new Set();
121
207
  for (const binding of bindings.split(',')) {
122
208
  if (binding.trim()) set.add(binding.trim());
@@ -124,10 +210,17 @@ function mergeImports(
124
210
  named.set(specifier, set);
125
211
  };
126
212
 
213
+ // The author's imports are recorded first, so the runtime group keeps
214
+ // the spelling they wrote — `azox/reactivity`, which the build rewrites
215
+ // later. Recording the compiler's own first would name the group after
216
+ // runtimeSpecifier and rewrite the author's import here, which is the
217
+ // build's job and would break the playground's build-free compile.
218
+ for (const statement of [...componentImports, ...pageImports]) record(statement);
219
+
127
220
  // `effect` is always needed: the compiler emits calls to it.
128
221
  record(`import { effect } from '${runtimeSpecifier}';`);
129
222
  if (needsDispose) record(`import { dispose } from '${runtimeSpecifier}';`);
130
- for (const statement of [...componentImports, ...pageImports]) record(statement);
223
+ if (needsUntracked) record(`import { untracked } from '${runtimeSpecifier}';`);
131
224
 
132
225
  const lines = [
133
226
  ...[...named].map(([specifier, bindings]) => {
@@ -139,6 +232,15 @@ function mergeImports(
139
232
  return { imports: lines.join('\n'), body };
140
233
  }
141
234
 
235
+ // Returns the key the runtime's imports are already grouped under, or
236
+ // this spelling if it is the first one seen.
237
+ function runtimeKey(named, specifier, aliases) {
238
+ for (const existing of named.keys()) {
239
+ if (aliases.has(existing)) return existing;
240
+ }
241
+ return specifier;
242
+ }
243
+
142
244
  // An import is inlined when every binding it declares was loaded by
143
245
  // the build. Matching on the specifier would be wrong: the same file
144
246
  // could be imported for some other reason.
@@ -187,10 +289,20 @@ function emitInlineModules(inlineModules, usage = '') {
187
289
  const entries = Object.entries(inlineModules);
188
290
  if (!entries.length) return '';
189
291
 
190
- const lines = entries.map(
191
- ([name, value]) => `const ${name} = ${JSON.stringify(narrow(name, value, usage))};`
192
- );
193
- return `${lines.join('\n')}\n`;
292
+ const lines = entries
293
+ // A value whose every read was folded into the markup needs no
294
+ // declaration at all. Emitting it anyway would publish the rest of
295
+ // the file — an author's address included — for nothing.
296
+ .filter(([name]) => isRead(name, usage))
297
+ .map(([name, value]) => `const ${name} = ${JSON.stringify(narrow(name, value, usage))};`);
298
+
299
+ return lines.length ? `${lines.join('\n')}\n` : '';
300
+ }
301
+
302
+ // Whether the emitted code still mentions the binding.
303
+ function isRead(name, usage) {
304
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
305
+ return new RegExp(`\\b${escaped}\\b`).test(usage);
194
306
  }
195
307
 
196
308
  // Keeps only the properties the module reads by name. A page that
@@ -230,11 +342,32 @@ function narrow(name, value, usage) {
230
342
  return narrowed;
231
343
  }
232
344
 
345
+ // Removes the build-time route declarations from a page's script.
346
+ //
347
+ // routes([...]) says which pages to build, which the browser has no
348
+ // use for — and calling it there is a ReferenceError that leaves the
349
+ // page inert. params() is replaced by the values this page was built
350
+ // with, so the markup reads them as plain data.
351
+ function resolveRouteDeclarations(script, routeParams) {
352
+ if (!routeParams) return script;
353
+
354
+ return script
355
+ // A whole statement, so the trailing semicolon and newline go too.
356
+ .replace(/^[ \t]*routes\s*\([\s\S]*?\)\s*;?[ \t]*$/gm, '')
357
+ .replace(/\bparams\s*\(\s*\)/g, JSON.stringify(routeParams));
358
+ }
359
+
360
+ // Marks a module that does nothing on load. The build reads it to decide
361
+ // whether the page needs to reference the module at all — a static page
362
+ // that still downloads it pays for a render() nobody calls, and pulls in
363
+ // the runtime with it.
364
+ export const STATIC_MARKER = 'azox:static';
365
+
233
366
  function staticNote() {
234
367
  return `
235
- // This page has no bindings and no listeners, so the server-rendered
236
- // markup is already complete and is left untouched. render() is
237
- // exported for anyone who wants to mount it somewhere else.
368
+ // ${STATIC_MARKER} — this page has no bindings and no listeners, so the
369
+ // server-rendered markup is already complete and is left untouched.
370
+ // render() is exported for anyone who wants to mount it somewhere else.
238
371
  `;
239
372
  }
240
373
 
@@ -274,7 +407,7 @@ function emitNode(node, statements, fallbackVar) {
274
407
  statements.push(`const ${varName} = document.createElement(${JSON.stringify(node.name)});`);
275
408
 
276
409
  for (const [key, attr] of Object.entries(node.attrs)) {
277
- emitAttr(varName, key, attr, statements);
410
+ emitAttr(varName, key, attr, statements, node.name);
278
411
  }
279
412
 
280
413
  appendChildren(varName, node.children, statements, fallbackVar);
@@ -387,7 +520,7 @@ function emitEach(node, statements) {
387
520
  // inside their own effect scope rather than inside the block's, since
388
521
  // re-running the block must not tear down rows it is keeping.
389
522
  function emitKeyedEach(node, statements) {
390
- const params = node.index ? `${node.alias}, ${node.index}` : node.alias;
523
+ const buildParams = node.index ? `${node.alias}, ${node.index}` : node.alias;
391
524
 
392
525
  const start = nextId();
393
526
  const end = nextId();
@@ -409,12 +542,21 @@ function emitKeyedEach(node, statements) {
409
542
  const bodyLines = [];
410
543
  const roots = node.children.map((child) => emitNode(child, bodyLines, 'root'));
411
544
 
412
- statements.push(`const ${build} = (${params}) => {`);
545
+ // The index is the position the row was built at, and a keyed row is
546
+ // built once. Reordering therefore moves rows without renumbering
547
+ // them — the cost of keeping a row rather than rebuilding it. Use the
548
+ // index for a stable list, and read the position from the data when a
549
+ // list reorders.
550
+ statements.push(`const ${build} = (${buildParams}) => {`);
413
551
  statements.push(` let _nodes;`);
414
- statements.push(` const _scope = effect(() => {`);
552
+ // untracked: the row is built while the list's effect is running, so
553
+ // without this it becomes that effect's child — and the next list
554
+ // change tears the row down even though it survived, leaving its
555
+ // bindings dead and its onCleanup callbacks fired.
556
+ statements.push(` const _scope = untracked(() => effect(() => {`);
415
557
  for (const line of bodyLines) statements.push(` ${line}`);
416
558
  statements.push(` _nodes = [${roots.filter((r) => r !== 'null').join(', ')}];`);
417
- statements.push(` });`);
559
+ statements.push(` }));`);
418
560
  statements.push(` return { nodes: _nodes, scope: _scope };`);
419
561
  statements.push(`};`);
420
562
 
@@ -552,7 +694,7 @@ function foldLiteralParts(parts) {
552
694
 
553
695
  function emitText(node, statements, fallbackVar) {
554
696
  const isFixed = (part) => part.kind === 'static' || part.kind === 'literal';
555
- node = { ...node, parts: foldLiteralParts(node.parts) };
697
+ node = { ...node, parts: foldConstantParts(foldLiteralParts(node.parts)) };
556
698
 
557
699
  // Nothing dynamic: one text node, no effect needed.
558
700
  if (node.parts.every(isFixed)) {
@@ -572,6 +714,46 @@ function emitText(node, statements, fallbackVar) {
572
714
  return varName;
573
715
  }
574
716
 
717
+ // Turns an expression the build already resolved into a literal part, so
718
+ // the text node is created with the value rather than an effect being
719
+ // attached to write it. The page whose only "dynamic" text is a version
720
+ // number then ships as a static page.
721
+ function foldConstantParts(parts) {
722
+ if (!constantNames.size) return parts;
723
+
724
+ return parts.map((part) => {
725
+ if (part.kind !== 'expr' || !isConstantExpression(part.expr)) return part;
726
+
727
+ const value = evaluateInlinedExpression(part.expr);
728
+ if (value === undefined) return part;
729
+
730
+ return { kind: 'literal', value: String(value) };
731
+ });
732
+ }
733
+
734
+ // Evaluates a constant expression against the values the build resolved.
735
+ // Returns undefined when it cannot be evaluated, which leaves the part
736
+ // dynamic — the safe direction.
737
+ //
738
+ // Named for this file: the playground concatenates the compiler's
739
+ // modules into one scope, so a bare `evaluateConstant` collides with the
740
+ // one in resolveComponents.js and the whole bundle fails to parse.
741
+ function evaluateInlinedExpression(expr) {
742
+ try {
743
+ const names = [...constantNames];
744
+ const fn = new Function(...names, `return (${expr});`);
745
+ const value = fn(...names.map((name) => constantValues[name]));
746
+
747
+ // Only a primitive can be written into the markup as text.
748
+ if (value === null || value === undefined) return undefined;
749
+ if (typeof value === 'object' || typeof value === 'function') return undefined;
750
+
751
+ return value;
752
+ } catch {
753
+ return undefined;
754
+ }
755
+ }
756
+
575
757
  // The five entities that matter for text content. Numeric forms are
576
758
  // handled too, since documentation snippets tend to use them.
577
759
  function decodeEntities(text) {
@@ -585,22 +767,77 @@ function decodeEntities(text) {
585
767
  .replace(/&amp;/g, '&');
586
768
  }
587
769
 
588
- function emitAttr(varName, key, attr, statements) {
770
+ // Properties that must be set as properties rather than attributes:
771
+ // setAttribute("value") only sets the *initial* value, so after a user
772
+ // types, writing the attribute changes nothing they can see.
773
+ const DOM_PROPERTIES = new Set(['value', 'checked', 'selected', 'indeterminate']);
774
+
775
+ function emitAttr(varName, key, attr, statements, tagName) {
589
776
  if (key.startsWith('on:')) {
590
777
  const event = key.slice(3);
591
778
  statements.push(`${varName}.addEventListener(${JSON.stringify(event)}, ${attr.expr});`);
592
779
  return;
593
780
  }
594
781
 
782
+ if (key.startsWith('bind:')) {
783
+ emitBinding(varName, key.slice(5), attr, statements, tagName);
784
+ return;
785
+ }
786
+
595
787
  if (attr.kind === 'static') {
596
788
  statements.push(`${varName}.setAttribute(${JSON.stringify(key)}, ${JSON.stringify(attr.value)});`);
597
789
  return;
598
790
  }
599
791
 
792
+ // A property has to be assigned, not set as an attribute — see
793
+ // DOM_PROPERTIES. Everything else is an attribute.
794
+ if (DOM_PROPERTIES.has(key)) {
795
+ statements.push(`effect(() => { ${varName}.${key} = ${attr.expr}; });`);
796
+ return;
797
+ }
798
+
600
799
  // Dynamic attribute: wrap in its own effect, same fine-grained rule as text.
601
800
  statements.push(`effect(() => { ${varName}.setAttribute(${JSON.stringify(key)}, String(${attr.expr})); });`);
602
801
  }
603
802
 
803
+ // Two-way binding: the element shows the signal, and the signal follows
804
+ // the element. Writing it by hand means a value= and an on:input= that
805
+ // have to agree, and getting the event or the property wrong is easy —
806
+ // a checkbox reports `checked`, not `value`, and a number input reports
807
+ // a string.
808
+ function emitBinding(varName, property, attr, statements, tagName) {
809
+ const signal = attr.expr.trim();
810
+
811
+ // The signal itself, not a call: `bind:value={draft}`. Binding needs
812
+ // to write back, which a value cannot do.
813
+ if (!/^[A-Za-z_$][\w$]*$/.test(signal)) {
814
+ throw new Error(
815
+ `Azox: bind:${property}={${signal}} needs a signal by name — ` +
816
+ `write bind:${property}={draft}, not bind:${property}={draft()}`
817
+ );
818
+ }
819
+
820
+ // A checkbox's state is `checked`, and the event that reports it is
821
+ // "change" rather than "input".
822
+ const isCheckbox = property === 'checked';
823
+ const event = isCheckbox || tagName === 'select' ? 'change' : 'input';
824
+
825
+ statements.push(`effect(() => { ${varName}.${property} = ${signal}(); });`);
826
+ statements.push(
827
+ `${varName}.addEventListener(${JSON.stringify(event)}, (_e) => ` +
828
+ `${signal}.set(${readTarget(property, tagName)}));`
829
+ );
830
+ }
831
+
832
+ // How the value is read back off the element. A number input reports a
833
+ // string, so it is converted — otherwise arithmetic on the signal
834
+ // silently concatenates.
835
+ function readTarget(property, tagName) {
836
+ if (property === 'checked') return '_e.target.checked';
837
+ if (tagName === 'input') return '(_e.target.type === "number" ? _e.target.valueAsNumber : _e.target.value)';
838
+ return '_e.target.value';
839
+ }
840
+
604
841
  // Component imports are resolved at build time and inlined, so the
605
842
  // .azox specifier must not survive into JavaScript the browser loads.
606
843
  function dropComponentImports(script) {
@@ -31,17 +31,36 @@ export function parseAzox(source) {
31
31
  .trim();
32
32
 
33
33
  const tokens = tokenize(template);
34
- const { node, rest } = parseNode(tokens);
34
+
35
+ // Several roots become one fragment, so a component can return a pair
36
+ // of <li>s or a label beside its input without a wrapper element that
37
+ // exists only to satisfy the parser.
38
+ const roots = [];
39
+ let rest = tokens;
40
+
41
+ while (rest.length) {
42
+ const parsed = parseNode(rest);
43
+ if (!parsed.node) break;
44
+
45
+ roots.push(parsed.node);
46
+ rest = parsed.rest;
47
+ }
48
+
35
49
  if (rest.length) {
36
50
  throw new ParseError(`Azox parse error: unexpected trailing markup near "${rest[0]?.value ?? ''}"`);
37
51
  }
38
52
 
53
+ const markup = roots.length === 1 ? roots[0] : { type: 'fragment', children: roots };
54
+
39
55
  return {
40
56
  script,
41
57
  head,
42
- markup: node,
58
+ markup,
43
59
  components: parseComponentImports(script),
44
60
  props: parsePropNames(script),
61
+ // A dynamic page destructures its route parameters the same way a
62
+ // component destructures its props.
63
+ params: parseParamNames(script),
45
64
  };
46
65
  }
47
66
 
@@ -63,6 +82,18 @@ function parseComponentImports(script) {
63
82
  // `const { title, count } = props();` declares what a component
64
83
  // accepts. Declaring them explicitly lets the compiler reject a
65
84
  // caller that passes something the component never asked for.
85
+ // `const { slug } = params()` names the route parameters the page
86
+ // reads, mirroring how props() declares a component's inputs.
87
+ function parseParamNames(script) {
88
+ const match = script.match(/const\s*\{([^}]*)\}\s*=\s*params\(\)/);
89
+ if (!match) return [];
90
+
91
+ return match[1]
92
+ .split(',')
93
+ .map((name) => name.trim())
94
+ .filter(Boolean);
95
+ }
96
+
66
97
  function parsePropNames(script) {
67
98
  const match = script.match(/const\s*\{([^}]*)\}\s*=\s*props\(\)/);
68
99
  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 {
package/core/index.js CHANGED
@@ -1,6 +1,17 @@
1
1
  // Public entry point for `import ... from 'azoxjs'`.
2
2
 
3
- export { signal, effect, computed } from './reactivity/signal.js';
3
+ // The whole reactivity surface, so `azoxjs` and `azoxjs/reactivity`
4
+ // offer the same thing rather than the main entry quietly omitting the
5
+ // newer half.
6
+ export {
7
+ signal,
8
+ effect,
9
+ computed,
10
+ dispose,
11
+ untracked,
12
+ onMount,
13
+ onCleanup,
14
+ } from './reactivity/signal.js';
4
15
  export { parseAzox } from './compiler/parser.js';
5
16
  export { compileToModule } from './compiler/compileToJs.js';
6
17
  export { renderToHtml } from './renderer/renderToHtml.js';