azoxjs 1.0.1 → 1.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: stable (v1.0.1). The template syntax, the reactivity exports
14
+ > Status: stable (v1.3.0). The template syntax, the reactivity exports
15
15
  > and the shape of the build output will not change without a 2.0. What
16
16
  > Azox does **not** do is listed in
17
17
  > [Limitations](https://azox.dev/docs/limitations) — those are stopping
package/core/build.js CHANGED
@@ -132,7 +132,18 @@ export function buildRoute(projectDir, route, { transformHtml } = {}) {
132
132
  // A component's <head> block is merged in behind the page's own, so
133
133
  // a layout can carry the stylesheet and fonts every page needs while
134
134
  // the page keeps the last word on its title and description.
135
- const head = mergeHeads(ast.head, ast.componentHeads ?? []);
135
+ // A component's scoped CSS is plain stylesheet text by this point,
136
+ // with every selector already rewritten to require its scope
137
+ // attribute. One <style> carries all of them, so a page makes no
138
+ // extra request for styles that are only a few lines each.
139
+ // A page's own <style> is global: it has no caller to be scoped
140
+ // against, and `body { … }` in a page should mean what it says.
141
+ // Without this it was extracted by the parser and then silently
142
+ // dropped, which is worse than the error it used to raise.
143
+ const scoped = [ast.style, ...(ast.componentStyles ?? [])].filter(Boolean);
144
+ const styleTag = scoped.length ? `<style>\n${scoped.join('\n')}\n</style>` : '';
145
+
146
+ const head = mergeHeads(ast.head, [...(ast.componentHeads ?? []), styleTag].filter(Boolean));
136
147
 
137
148
  let document = wrapDocument(html, projectTitle(projectDir), head, {
138
149
  routerSrc: router ? `${route.assetPrefix}${ROUTER_FILENAME}` : null,
@@ -337,6 +348,17 @@ function removeStaleOutput(projectDir, results, assets = []) {
337
348
  // Everything in public/ is copied to the build root untouched, so a
338
349
  // stylesheet, font or image is referenced by the same path in source
339
350
  // and in the built site: public/style.css -> /style.css.
351
+ // Files an editor or OS leaves behind, which should not be published.
352
+ //
353
+ // Every dotfile used to be skipped, which also dropped the ones a static
354
+ // host needs: .nojekyll tells GitHub Pages not to run Jekyll over the
355
+ // output, and .well-known/ is how a domain or certificate is verified.
356
+ const EDITOR_JUNK = new Set(['.DS_Store', 'Thumbs.db', '.git', 'node_modules']);
357
+
358
+ function isEditorJunk(name) {
359
+ return EDITOR_JUNK.has(name);
360
+ }
361
+
340
362
  export function copyPublicAssets(projectDir) {
341
363
  const publicDir = resolve(projectDir, PUBLIC_DIR);
342
364
  if (!existsSync(publicDir)) return [];
@@ -346,7 +368,7 @@ export function copyPublicAssets(projectDir) {
346
368
 
347
369
  const walk = (dir, relativeDir) => {
348
370
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
349
- if (entry.name.startsWith('.')) continue;
371
+ if (isEditorJunk(entry.name)) continue;
350
372
 
351
373
  const from = join(dir, entry.name);
352
374
  const to = join(buildRoot, relativeDir, entry.name);
@@ -589,6 +611,12 @@ function uniqueTagName(line) {
589
611
  const meta = line.match(/^<meta\s[^>]*name=["']([^"']+)["']/i);
590
612
  if (meta) return `meta:${meta[1].toLowerCase()}`;
591
613
 
614
+ // Open Graph and Twitter cards key on `property`, not `name`. Without
615
+ // this a shared default and a page's own tag both shipped, and a
616
+ // scraper takes whichever it sees first.
617
+ const property = line.match(/^<meta\s[^>]*property=["']([^"']+)["']/i);
618
+ if (property) return `meta:${property[1].toLowerCase()}`;
619
+
592
620
  return null;
593
621
  }
594
622
 
@@ -7,9 +7,35 @@
7
7
  // the browser, which is what makes the playground possible. Anything
8
8
  // that needs to know about paths on disk belongs in build.js.
9
9
 
10
+ // Control-flow markers carry a label. An empty comment serialises to
11
+ // <!---->, so two adjacent blocks produce four identical nodes and a
12
+ // walker adopting server markup cannot tell a start from an end, nor
13
+ // one block from the next. The server emits the same pair.
14
+ //
15
+ // See docs/hydration.md.
16
+ export const BLOCK_START = '[';
17
+ export const BLOCK_END = ']';
18
+
10
19
  let uid = 0;
11
20
  const nextId = () => `_el${uid++}`;
12
21
 
22
+ // Node variable -> the binding holding what the cursor handed back for
23
+ // it, or absent when the node was created outright. Whoever appends a
24
+ // node consults this: an adopted node is already in place, and
25
+ // appending an existing child detaches and re-attaches it, which blurs
26
+ // it and loses the reader's focus. Set per compile, and cleared with
27
+ // the variable counter — a page compiles twice when it adopts, and the
28
+ // second pass reuses the same names.
29
+ let adoptedOf = new Map();
30
+
31
+ // Tags that open an SVG document fragment. Everything inside one is in
32
+ // the SVG namespace too, which is threaded down as `inSvg` — <circle>
33
+ // and <path> carry no hint of their own.
34
+ //
35
+ // <a> and <script> exist in both languages; they are left as HTML,
36
+ // which is what they almost always are on a page.
37
+ const SVG_TAGS = new Set(['svg']);
38
+
13
39
  // Names the build resolved to constants — an inlined JSON import, say.
14
40
  // An expression reading only these can never change, so it is emitted
15
41
  // as text rather than wrapped in an effect. Set per compile.
@@ -78,13 +104,42 @@ export function compileToModule(
78
104
  { runtimeSpecifier, rewriteImports, inlineModules, routeParams }
79
105
  ) {
80
106
  uid = 0;
107
+ adoptedOf = new Map();
81
108
  // Values the build resolved: their bindings need no effect, which is
82
109
  // what lets a page whose only "dynamic" text is a version number ship
83
110
  // as a static page.
84
111
  constantValues = inlineModules ?? {};
85
112
  constantNames = new Set(Object.keys(constantValues));
86
- const statements = [];
87
- const rootVar = emitNode(ast.markup, statements, 'root');
113
+ let statements = [];
114
+ let rootVar = emitNode(ast.markup, statements, 'root');
115
+
116
+ // Adoption reuses the nodes the server sent instead of rebuilding
117
+ // them, which is what preserves focus, a caret position and an open
118
+ // <details> across hydration.
119
+ //
120
+ // Control flow is excluded for now. An <if> or <each> creates its
121
+ // marker pair and its rows fresh on every run, so a page mixing
122
+ // adopted markup with a rebuilt block would bind effects across two
123
+ // generations of nodes — worse than the wholesale rebuild it
124
+ // replaces. Pages carrying a block keep that rebuild until the
125
+ // walker can adopt between the markers too; see docs/hydration.md.
126
+ const hasControlFlow = statements.some((line) => line.includes('createComment('));
127
+ // A static page ships no hydration block, so no cursor can ever reach
128
+ // its render(): adopting there would import the walker for nothing.
129
+ // Decided from this pass, whose effect and listener lines are the same
130
+ // ones the second pass emits.
131
+ const staticFirstPass = !statements.some(
132
+ (line) => line.startsWith('effect(') || line.includes('.addEventListener(')
133
+ );
134
+ const adopts = !hasControlFlow && !staticFirstPass;
135
+ if (adopts) {
136
+ // Emitted again from a clean slate: the cursor changes almost every
137
+ // creation site, and reusing the first pass's variable counter
138
+ // would leave gaps in the names.
139
+ uid = 0;
140
+ statements = [];
141
+ rootVar = emitNode(ast.markup, statements, 'root', false, '_cursor');
142
+ }
88
143
 
89
144
  let script = dropComponentImports(ast.script);
90
145
  script = resolveRouteDeclarations(script, routeParams);
@@ -117,6 +172,10 @@ export function compileToModule(
117
172
  // module needs dispose as well as effect.
118
173
  const needsDispose = statements.some((line) => line.includes('dispose('));
119
174
  const needsUntracked = statements.some((line) => line.includes('untracked('));
175
+ const needsAdopt = statements.some((line) => line.includes('adopt('));
176
+ // The root is adopted from the mount the same way children are, so it
177
+ // must not be re-appended either.
178
+ const rootTaken = adoptedOf.get(rootVar);
120
179
 
121
180
  const { imports, body } = mergeImports(
122
181
  script,
@@ -124,7 +183,8 @@ export function compileToModule(
124
183
  runtimeSpecifier,
125
184
  needsDispose,
126
185
  inlineModules,
127
- needsUntracked
186
+ needsUntracked,
187
+ needsAdopt
128
188
  );
129
189
 
130
190
  // Narrowed to what the module actually reads, so importing
@@ -135,12 +195,12 @@ export function compileToModule(
135
195
  ${imports}
136
196
  ${inlined}${body}
137
197
 
138
- export function render(mount) {
198
+ export function render(mount${adopts ? ', _cursor = null' : ''}) {
139
199
  ${statements.map((line) => ' ' + line).join('\n')}
140
- mount.appendChild(${rootVar});
200
+ ${rootTaken ? ` if (!${rootTaken}) mount.appendChild(${rootVar});` : ` mount.appendChild(${rootVar});`}
141
201
  return ${rootVar};
142
202
  }
143
- ${isStatic ? staticNote() : hydrateBlock()}`.trimStart();
203
+ ${isStatic ? staticNote() : hydrateBlock(adopts)}`.trimStart();
144
204
  }
145
205
 
146
206
  // Collects every import the module needs into one set of statements,
@@ -156,7 +216,8 @@ function mergeImports(
156
216
  runtimeSpecifier,
157
217
  needsDispose = false,
158
218
  inlineModules = null,
159
- needsUntracked = false
219
+ needsUntracked = false,
220
+ needsAdopt = false
160
221
  ) {
161
222
  const pageImports = [...script.matchAll(/^\s*(import\s[^;\n]+;?)\s*$/gm)]
162
223
  .map((m) => m[1].trim())
@@ -221,6 +282,7 @@ function mergeImports(
221
282
  record(`import { effect } from '${runtimeSpecifier}';`);
222
283
  if (needsDispose) record(`import { dispose } from '${runtimeSpecifier}';`);
223
284
  if (needsUntracked) record(`import { untracked } from '${runtimeSpecifier}';`);
285
+ if (needsAdopt) record(`import { adopt } from '${runtimeSpecifier}';`);
224
286
 
225
287
  const lines = [
226
288
  ...[...named].map(([specifier, bindings]) => {
@@ -371,23 +433,49 @@ function staticNote() {
371
433
  `;
372
434
  }
373
435
 
374
- function hydrateBlock() {
436
+ function hydrateBlock(adopts = false) {
437
+ const mountLines = adopts
438
+ ? ` // Adopting: the walker takes each node the server already sent,
439
+ // so focus, a caret position and an open <details> survive. Anything
440
+ // that does not match is created instead, and anything left over is
441
+ // removed — a stale page costs the rebuild we used to do always.
442
+ const _cursor = adopt(mount);
443
+ render(mount, _cursor);
444
+ _cursor.done();`
445
+ : ` mount.innerHTML = '';
446
+ render(mount);`;
447
+
375
448
  return `
376
449
  // Hydrate: the SSR markup is already on the page, so clear it and
377
450
  // mount the reactive version in its place.
451
+ //
452
+ // Everything inside the root is replaced, so a listener another script
453
+ // attached to server-rendered markup is discarded with it — a menu
454
+ // button that looked right and did nothing. The event says the DOM has
455
+ // been rebuilt, and is the same one the router dispatches after a
456
+ // navigation, so one listener covers both.
378
457
  if (typeof document !== 'undefined') {
379
458
  const mount = document.querySelector('[data-azox-root]') ?? document.body;
380
- mount.innerHTML = '';
381
- render(mount);
459
+ ${mountLines}
460
+ // Guarded: the emitted module is also run against minimal DOM stubs —
461
+ // in tests, and anywhere rendering happens outside a browser — where
462
+ // CustomEvent and dispatchEvent need not exist.
463
+ if (typeof CustomEvent === 'function' && typeof document.dispatchEvent === 'function') {
464
+ document.dispatchEvent(
465
+ new CustomEvent('azox:navigate', {
466
+ detail: { url: typeof location !== 'undefined' ? location.href : null, from: null, hydrated: true },
467
+ })
468
+ );
469
+ }
382
470
  }
383
471
  `;
384
472
  }
385
473
 
386
- function emitNode(node, statements, fallbackVar) {
474
+ function emitNode(node, statements, fallbackVar, inSvg = false, cursor = null) {
387
475
  if (!node) return 'null';
388
476
 
389
477
  if (node.type === 'text') {
390
- return emitText(node, statements, fallbackVar);
478
+ return emitText(node, statements, fallbackVar, cursor);
391
479
  }
392
480
 
393
481
  // A fragment (from <slot />) has no element of its own; it wraps
@@ -395,7 +483,9 @@ function emitNode(node, statements, fallbackVar) {
395
483
  if (node.type === 'fragment') {
396
484
  const varName = nextId();
397
485
  statements.push(`const ${varName} = document.createDocumentFragment();`);
398
- appendChildren(varName, node.children, statements, fallbackVar);
486
+ // A fragment owns no node of its own, so its children are adopted
487
+ // against the parent's cursor rather than a new one.
488
+ appendChildren(varName, node.children, statements, fallbackVar, inSvg, cursor, true);
399
489
  return varName;
400
490
  }
401
491
 
@@ -404,13 +494,40 @@ function emitNode(node, statements, fallbackVar) {
404
494
  if (node.type === 'scope') return emitScope(node, statements, fallbackVar);
405
495
 
406
496
  const varName = nextId();
407
- statements.push(`const ${varName} = document.createElement(${JSON.stringify(node.name)});`);
497
+
498
+ // An SVG element needs its namespace. createElement always makes an
499
+ // HTML element, so an inline <svg> compiled to something the browser
500
+ // laid out as an unknown HTML tag: present in the DOM, 0×0 on screen.
501
+ const svg = inSvg || SVG_TAGS.has(node.name);
502
+ // With a cursor, take the node the server already sent when it
503
+ // matches. A mismatch yields null and this falls back to creating, so
504
+ // a stale page costs the work we do today rather than breaking.
505
+ const create = svg
506
+ ? `document.createElementNS("http://www.w3.org/2000/svg", ${JSON.stringify(node.name)})`
507
+ : `document.createElement(${JSON.stringify(node.name)})`;
508
+
509
+ // Optional call: render() is exported, and a direct caller passes no
510
+ // cursor at all. Without the guard the root line would throw on null
511
+ // instead of falling back to creating the node.
512
+ //
513
+ // The adopted node is kept in its own binding so whoever appends this
514
+ // one can tell the two cases apart: an adopted node is already in
515
+ // place, and re-appending it would detach and re-attach it, which
516
+ // blurs it. See docs/hydration.md.
517
+ if (cursor) {
518
+ const taken = nextId();
519
+ statements.push(`const ${taken} = ${cursor}?.next(${JSON.stringify(node.name)});`);
520
+ statements.push(`const ${varName} = ${taken} ?? ${create};`);
521
+ adoptedOf.set(varName, taken);
522
+ } else {
523
+ statements.push(`const ${varName} = ${create};`);
524
+ }
408
525
 
409
526
  for (const [key, attr] of Object.entries(node.attrs)) {
410
527
  emitAttr(varName, key, attr, statements, node.name);
411
528
  }
412
529
 
413
- appendChildren(varName, node.children, statements, fallbackVar);
530
+ appendChildren(varName, node.children, statements, fallbackVar, svg, cursor);
414
531
 
415
532
  return varName;
416
533
  }
@@ -462,8 +579,8 @@ function emitControlBlock(statements, buildBody, sourceExpr, renderCall) {
462
579
 
463
580
  const holder = nextId();
464
581
 
465
- statements.push(`const ${start} = document.createComment('');`);
466
- statements.push(`const ${end} = document.createComment('');`);
582
+ statements.push(`const ${start} = document.createComment('${BLOCK_START}');`);
583
+ statements.push(`const ${end} = document.createComment('${BLOCK_END}');`);
467
584
 
468
585
  // The markers go into their own fragment straight away, so they
469
586
  // always have a parent to insert into. Waiting for the page to be
@@ -528,8 +645,8 @@ function emitKeyedEach(node, statements) {
528
645
  const rows = nextId();
529
646
  const holder = nextId();
530
647
 
531
- statements.push(`const ${start} = document.createComment('');`);
532
- statements.push(`const ${end} = document.createComment('');`);
648
+ statements.push(`const ${start} = document.createComment('${BLOCK_START}');`);
649
+ statements.push(`const ${end} = document.createComment('${BLOCK_END}');`);
533
650
  statements.push(`const ${holder} = document.createDocumentFragment();`);
534
651
  statements.push(`${holder}.append(${start}, ${end});`);
535
652
 
@@ -595,8 +712,10 @@ function emitKeyedEach(node, statements) {
595
712
  statements.push(` ${rows}.set(_key, _row);`);
596
713
  statements.push(` }`);
597
714
  statements.push(``);
598
- statements.push(` // Moving a node that is already in place is a no-op in`);
599
- statements.push(` // the DOM, so ordering costs nothing when nothing moved.`);
715
+ statements.push(` // insertBefore detaches and re-attaches, even when the node`);
716
+ statements.push(` // is already where it belongs — so this re-seeds the row and`);
717
+ statements.push(` // would blur anything focused inside it. Harmless while rows`);
718
+ statements.push(` // are built fresh, and the thing to fix when they are adopted.`);
600
719
  statements.push(` for (const _node of _row.nodes) _parent.insertBefore(_node, ${end});`);
601
720
  statements.push(` _i++;`);
602
721
  statements.push(` }`);
@@ -669,11 +788,48 @@ function emitIf(node, statements) {
669
788
  );
670
789
  }
671
790
 
672
- function appendChildren(parentVar, children, statements, fallbackVar) {
791
+ function appendChildren(
792
+ parentVar,
793
+ children,
794
+ statements,
795
+ fallbackVar,
796
+ inSvg = false,
797
+ cursor = null,
798
+ reuseCursor = false
799
+ ) {
800
+ // Each element walks its own children, so a nested cursor is opened
801
+ // for it. A node the cursor handed back is already in place and must
802
+ // not be appended: appending an existing child detaches and
803
+ // re-attaches it, which blurs it and loses the reader's focus. Only a
804
+ // created node is appended. See docs/hydration.md.
805
+ //
806
+ // A fragment is the exception: it owns no node in the document, so
807
+ // there is nothing to open a cursor against. Its children continue
808
+ // the parent's walk, and the parent closes it.
809
+ //
810
+ // Nothing to walk: a void element like <input> would otherwise open a
811
+ // cursor and close it again without ever calling next().
812
+ if (!children.length) return;
813
+
814
+ let inner = reuseCursor ? cursor : null;
815
+ if (!reuseCursor && cursor) {
816
+ inner = nextId();
817
+ statements.push(`const ${inner} = adopt(${parentVar});`);
818
+ }
819
+
673
820
  for (const child of children) {
674
- const childVar = emitNode(child, statements, fallbackVar);
675
- if (childVar !== 'null') statements.push(`${parentVar}.appendChild(${childVar});`);
821
+ const childVar = emitNode(child, statements, fallbackVar, inSvg, inner);
822
+ if (childVar === 'null') continue;
823
+ const taken = adoptedOf.get(childVar);
824
+ statements.push(
825
+ taken
826
+ ? `if (!${taken}) ${parentVar}.appendChild(${childVar});`
827
+ : `${parentVar}.appendChild(${childVar});`
828
+ );
676
829
  }
830
+
831
+ // Anything the server sent that the module did not claim is stale.
832
+ if (inner && !reuseCursor) statements.push(`${inner}.done();`);
677
833
  }
678
834
 
679
835
  // createTextNode takes text, not markup, so an entity the author
@@ -699,7 +855,7 @@ function foldLiteralParts(parts) {
699
855
  });
700
856
  }
701
857
 
702
- function emitText(node, statements, fallbackVar) {
858
+ function emitText(node, statements, fallbackVar, cursor = null) {
703
859
  const isFixed = (part) => part.kind === 'static' || part.kind === 'literal';
704
860
  node = { ...node, parts: foldConstantParts(foldLiteralParts(node.parts)) };
705
861
 
@@ -707,13 +863,29 @@ function emitText(node, statements, fallbackVar) {
707
863
  if (node.parts.every(isFixed)) {
708
864
  const value = node.parts.map(textValue).join('');
709
865
  const varName = nextId();
710
- statements.push(`const ${varName} = document.createTextNode(${JSON.stringify(value)});`);
866
+ if (cursor) {
867
+ const taken = nextId();
868
+ statements.push(`const ${taken} = ${cursor}?.next(null);`);
869
+ statements.push(
870
+ `const ${varName} = ${taken} ?? document.createTextNode(${JSON.stringify(value)});`
871
+ );
872
+ adoptedOf.set(varName, taken);
873
+ } else {
874
+ statements.push(`const ${varName} = document.createTextNode(${JSON.stringify(value)});`);
875
+ }
711
876
  return varName;
712
877
  }
713
878
 
714
879
  // Dynamic text: one text node, one effect that rewrites its data.
715
880
  const varName = nextId();
716
- statements.push(`const ${varName} = document.createTextNode('');`);
881
+ if (cursor) {
882
+ const taken = nextId();
883
+ statements.push(`const ${taken} = ${cursor}?.next(null);`);
884
+ statements.push(`const ${varName} = ${taken} ?? document.createTextNode('');`);
885
+ adoptedOf.set(varName, taken);
886
+ } else {
887
+ statements.push(`const ${varName} = document.createTextNode('');`);
888
+ }
717
889
  const expr = node.parts
718
890
  .map((part) => (isFixed(part) ? JSON.stringify(textValue(part)) : `String(${part.expr})`))
719
891
  .join(' + ');
@@ -782,6 +954,7 @@ const DOM_PROPERTIES = new Set(['value', 'checked', 'selected', 'indeterminate']
782
954
  function emitAttr(varName, key, attr, statements, tagName) {
783
955
  if (key.startsWith('on:')) {
784
956
  const event = key.slice(3);
957
+ assertHandler(key, attr.expr);
785
958
  statements.push(`${varName}.addEventListener(${JSON.stringify(event)}, ${attr.expr});`);
786
959
  return;
787
960
  }
@@ -807,6 +980,43 @@ function emitAttr(varName, key, attr, statements, tagName) {
807
980
  statements.push(`effect(() => { ${varName}.setAttribute(${JSON.stringify(key)}, String(${attr.expr})); });`);
808
981
  }
809
982
 
983
+ // A listener is the expression itself, so `on:click={n.set(1)}` passes
984
+ // addEventListener whatever the call returned — and the call already ran
985
+ // while the page was being built. The button then appears to fire once on
986
+ // load and never again, with no error anywhere. Caught here instead.
987
+ //
988
+ // Only a call at the top level is rejected. A handler is far more often a
989
+ // name (`handler`, `obj.method`, `fns[i]`) or an arrow whose body happens
990
+ // to contain a call, and all of those have to keep working.
991
+ function assertHandler(key, rawExpr) {
992
+ const expr = rawExpr.trim();
993
+
994
+ if (/=>/.test(expr)) return;
995
+ if (/^(async\s+)?function\b/.test(expr)) return;
996
+
997
+ const opening = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\[[^\]]*\])*\(/.exec(expr);
998
+ if (!opening || !expr.endsWith(')')) return;
999
+
1000
+ // Balanced from the first `(`: only a call that closes at the very end
1001
+ // is the whole expression. `f(1) || g` is something else, and left
1002
+ // alone.
1003
+ let depth = 0;
1004
+ for (let i = opening[0].length - 1; i < expr.length; i++) {
1005
+ if (expr[i] === '(') depth++;
1006
+ else if (expr[i] === ')') {
1007
+ depth--;
1008
+ if (depth === 0 && i !== expr.length - 1) return;
1009
+ }
1010
+ }
1011
+
1012
+ const name = opening[0].slice(0, -1);
1013
+ throw new Error(
1014
+ `Azox: ${key}={${expr}} calls ${name}() while the page is built, and ` +
1015
+ `passes the result as the listener — ` +
1016
+ `write ${key}={() => ${expr}} to call it on the event instead`
1017
+ );
1018
+ }
1019
+
810
1020
  // Two-way binding: the element shows the signal, and the signal follows
811
1021
  // the element. Writing it by hand means a value= and an on:input= that
812
1022
  // have to agree, and getting the event or the property wrong is easy —
@@ -0,0 +1,41 @@
1
+ // Types for `azoxjs/compiler`.
2
+ //
3
+ // The compiler is free of Node built-ins on purpose, so these describe
4
+ // an API that runs in a browser as well as in the build.
5
+
6
+ /** A parsed `.azox` file. The node shapes are internal and may change. */
7
+ export interface AzoxAst {
8
+ /** The `<script>` block, with its imports intact. */
9
+ script: string;
10
+ /** The `<head>` block, verbatim. */
11
+ head: string;
12
+ /** The markup, as a tree the compiler and renderer both walk. */
13
+ markup: unknown;
14
+ /** Component name → the specifier it was imported from. */
15
+ components: Record<string, string>;
16
+ /** Prop names the file declared with `props()`. */
17
+ props: string[];
18
+ /** Route parameter names the file declared with `params()`. */
19
+ params: string[];
20
+ }
21
+
22
+ export interface CompileOptions {
23
+ /** How the emitted module should import the runtime. */
24
+ runtimeSpecifier: string;
25
+ /**
26
+ * Rebases the author's relative imports, since compiled output does
27
+ * not sit beside the source. Called with the file an import was
28
+ * written in, which differs from the page for a hoisted one.
29
+ */
30
+ rewriteImports?: (script: string, sourcePath?: string) => string;
31
+ /** Values the build resolved, inlined instead of imported. */
32
+ inlineModules?: Record<string, unknown>;
33
+ /** Resolved parameters for one page of a dynamic route. */
34
+ routeParams?: Record<string, string> | null;
35
+ }
36
+
37
+ /** Parses a `.azox` file. Throws on malformed markup, naming the tag. */
38
+ export declare function parseAzox(source: string): AzoxAst;
39
+
40
+ /** Compiles a parsed file into a JavaScript module, as a string. */
41
+ export declare function compileToModule(ast: AzoxAst, options: CompileOptions): string;
@@ -16,19 +16,40 @@ export class ParseError extends BuildError {}
16
16
  const isComponentName = (name) => /^[A-Z]/.test(name);
17
17
 
18
18
  export function parseAzox(source) {
19
- const scriptMatch = source.match(/<script>([\s\S]*?)<\/script>/);
19
+ // <script>, <head> and <style> are pulled out of the source before
20
+ // anything else looks at it. A literal <text> block may contain those
21
+ // very tags — documenting them requires it — so the spans are blanked
22
+ // first, and only what is left is searched. Without this, a code
23
+ // sample showing a <style> block was eaten as the page's own CSS.
24
+ const searchable = blankTextBlocks(source);
25
+
26
+ const scriptMatch = searchable.match(/<script>([\s\S]*?)<\/script>/);
20
27
  const script = scriptMatch ? scriptMatch[1].trim() : '';
21
28
 
22
29
  // An optional <head> block is copied into the document head
23
30
  // verbatim: stylesheets, meta tags, fonts. It is markup for the
24
31
  // document, not for the page body, so it skips the AST entirely.
25
- const headMatch = source.match(/<head>([\s\S]*?)<\/head>/);
32
+ const headMatch = searchable.match(/<head>([\s\S]*?)<\/head>/);
26
33
  const head = headMatch ? headMatch[1].trim() : '';
27
34
 
28
- const template = source
29
- .replace(/<script>[\s\S]*?<\/script>/, '')
30
- .replace(/<head>[\s\S]*?<\/head>/, '')
31
- .trim();
35
+ // A <style> block is CSS, not markup. It has to come out before the
36
+ // tokeniser runs: left in, its braces are read as {expressions} and a
37
+ // rule like `.a { color: red }` failed the build with "Unexpected
38
+ // token ':'" — an error about JavaScript, pointing at a stylesheet.
39
+ const styleMatch = searchable.match(/<style>([\s\S]*?)<\/style>/);
40
+ const style = styleMatch ? styleMatch[1].trim() : '';
41
+
42
+ // Cut by position rather than by pattern: replacing on the source
43
+ // would remove the first match anywhere, including one inside a
44
+ // <text> block that was deliberately ignored above.
45
+ const cuts = [scriptMatch, headMatch, styleMatch]
46
+ .filter(Boolean)
47
+ .map((match) => [match.index, match.index + match[0].length])
48
+ .sort((a, b) => b[0] - a[0]);
49
+
50
+ let template = source;
51
+ for (const [from, to] of cuts) template = template.slice(0, from) + template.slice(to);
52
+ template = template.trim();
32
53
 
33
54
  const tokens = tokenize(template);
34
55
 
@@ -55,6 +76,7 @@ export function parseAzox(source) {
55
76
  return {
56
77
  script,
57
78
  head,
79
+ style,
58
80
  markup,
59
81
  components: parseComponentImports(script),
60
82
  props: parsePropNames(script),
@@ -64,6 +86,16 @@ export function parseAzox(source) {
64
86
  };
65
87
  }
66
88
 
89
+ // Replaces the contents of every <text> block with spaces, keeping the
90
+ // length and therefore every offset in the original. What a <text>
91
+ // block holds is literal by definition, so nothing inside one should be
92
+ // mistaken for the page's script, head or style.
93
+ function blankTextBlocks(source) {
94
+ return source.replace(/<text>[\s\S]*?<\/text>/g, (block) =>
95
+ `<text>${' '.repeat(Math.max(0, block.length - '<text></text>'.length))}</text>`
96
+ );
97
+ }
98
+
67
99
  // Component imports are written as ordinary import statements, so an
68
100
  // editor treats them like any other module reference:
69
101
  // import Card from '../components/Card.azox';