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 +1 -1
- package/core/build.js +30 -2
- package/core/compiler/compileToJs.js +237 -27
- package/core/compiler/index.d.ts +41 -0
- package/core/compiler/parser.js +38 -6
- package/core/compiler/resolveComponents.js +67 -7
- package/core/compiler/scopeStyles.js +151 -0
- package/core/index.d.ts +43 -0
- package/core/index.js +1 -0
- package/core/reactivity/signal.d.ts +106 -0
- package/core/reactivity/signal.js +61 -0
- package/core/renderer/renderToHtml.js +14 -3
- package/core/renderer/serverScope.js +102 -4
- package/core/router/navigate.js +25 -2
- package/package.json +17 -7
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(` //
|
|
599
|
-
statements.push(` //
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/core/compiler/parser.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
32
|
+
const headMatch = searchable.match(/<head>([\s\S]*?)<\/head>/);
|
|
26
33
|
const head = headMatch ? headMatch[1].trim() : '';
|
|
27
34
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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';
|