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
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// been written by hand.
|
|
8
8
|
|
|
9
9
|
import { parseAzox } from './parser.js';
|
|
10
|
+
import { scopeId, scopeAttribute, scopeCss } from './scopeStyles.js';
|
|
10
11
|
import { BuildError } from '../buildError.js';
|
|
11
12
|
|
|
12
13
|
// Extends BuildError so the CLI reports it as a user-facing problem
|
|
@@ -20,7 +21,14 @@ export class ComponentError extends BuildError {}
|
|
|
20
21
|
// `resolver` decides how an import specifier becomes source text, so
|
|
21
22
|
// this runs unchanged against disk or against an in-memory map. See
|
|
22
23
|
// sourceResolver.js.
|
|
23
|
-
export function resolveComponents(
|
|
24
|
+
export function resolveComponents(
|
|
25
|
+
ast,
|
|
26
|
+
sourcePath,
|
|
27
|
+
resolver,
|
|
28
|
+
seen = new Set(),
|
|
29
|
+
heads = null,
|
|
30
|
+
styles = null
|
|
31
|
+
) {
|
|
24
32
|
// Imports from stateful components are hoisted here: they cannot
|
|
25
33
|
// live inside the scope function the compiler builds for each one.
|
|
26
34
|
//
|
|
@@ -37,17 +45,31 @@ export function resolveComponents(ast, sourcePath, resolver, seen = new Set(), h
|
|
|
37
45
|
// now only a page could put one there.
|
|
38
46
|
const collected = heads ?? new Map();
|
|
39
47
|
|
|
40
|
-
|
|
48
|
+
// A component's <style> block is collected the same way and keyed by
|
|
49
|
+
// the same path, so a component used twice contributes its CSS once.
|
|
50
|
+
const collectedStyles = styles ?? new Map();
|
|
51
|
+
|
|
52
|
+
const markup = expand(
|
|
53
|
+
ast.markup,
|
|
54
|
+
ast,
|
|
55
|
+
sourcePath,
|
|
56
|
+
resolver,
|
|
57
|
+
seen,
|
|
58
|
+
hoisted,
|
|
59
|
+
collected,
|
|
60
|
+
collectedStyles
|
|
61
|
+
);
|
|
41
62
|
|
|
42
63
|
return {
|
|
43
64
|
...ast,
|
|
44
65
|
markup,
|
|
45
66
|
componentImports: [...hoisted.values()],
|
|
46
67
|
componentHeads: [...collected.values()],
|
|
68
|
+
componentStyles: [...collectedStyles.values()],
|
|
47
69
|
};
|
|
48
70
|
}
|
|
49
71
|
|
|
50
|
-
function expand(node, ast, sourcePath, resolver, seen, hoisted, heads) {
|
|
72
|
+
function expand(node, ast, sourcePath, resolver, seen, hoisted, heads, styles) {
|
|
51
73
|
if (!node || node.type === 'text') return node;
|
|
52
74
|
|
|
53
75
|
// <if> keeps its children in two branches rather than in `children`,
|
|
@@ -57,16 +79,16 @@ function expand(node, ast, sourcePath, resolver, seen, hoisted, heads) {
|
|
|
57
79
|
return {
|
|
58
80
|
...node,
|
|
59
81
|
then: node.then.map((child) =>
|
|
60
|
-
expand(child, ast, sourcePath, resolver, seen, hoisted, heads)
|
|
82
|
+
expand(child, ast, sourcePath, resolver, seen, hoisted, heads, styles)
|
|
61
83
|
),
|
|
62
84
|
otherwise: node.otherwise.map((child) =>
|
|
63
|
-
expand(child, ast, sourcePath, resolver, seen, hoisted, heads)
|
|
85
|
+
expand(child, ast, sourcePath, resolver, seen, hoisted, heads, styles)
|
|
64
86
|
),
|
|
65
87
|
};
|
|
66
88
|
}
|
|
67
89
|
|
|
68
90
|
const children = (node.children ?? []).map((child) =>
|
|
69
|
-
expand(child, ast, sourcePath, resolver, seen, hoisted, heads)
|
|
91
|
+
expand(child, ast, sourcePath, resolver, seen, hoisted, heads, styles)
|
|
70
92
|
);
|
|
71
93
|
|
|
72
94
|
if (node.type !== 'component') {
|
|
@@ -83,13 +105,27 @@ function expand(node, ast, sourcePath, resolver, seen, hoisted, heads) {
|
|
|
83
105
|
component.path,
|
|
84
106
|
resolver,
|
|
85
107
|
new Set([...seen, component.path]),
|
|
86
|
-
heads
|
|
108
|
+
heads,
|
|
109
|
+
styles
|
|
87
110
|
);
|
|
88
111
|
|
|
89
112
|
// Keyed by path: a component used on a page twice must not emit its
|
|
90
113
|
// stylesheet link twice.
|
|
91
114
|
if (component.ast.head) heads.set(component.path, component.ast.head);
|
|
92
115
|
|
|
116
|
+
// A <style> block scopes to this component: its selectors are
|
|
117
|
+
// rewritten to require an attribute, and that attribute is put on the
|
|
118
|
+
// markup below. Both halves key off the same path, so the CSS and the
|
|
119
|
+
// elements it targets always agree.
|
|
120
|
+
let scope = null;
|
|
121
|
+
|
|
122
|
+
if (component.ast.style) {
|
|
123
|
+
scope = scopeId(component.path);
|
|
124
|
+
styles.set(component.path, scopeCss(component.ast.style, scope));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (scope) markScope(inner.markup, scopeAttribute(scope));
|
|
128
|
+
|
|
93
129
|
const values = propValues(node, component.ast.props);
|
|
94
130
|
const { logic, imports } = componentLogic(component.ast.script);
|
|
95
131
|
|
|
@@ -160,6 +196,30 @@ function componentLogic(script) {
|
|
|
160
196
|
return { logic: meaningful.trim() ? body.trim() : null, imports };
|
|
161
197
|
}
|
|
162
198
|
|
|
199
|
+
// Puts the scope attribute on every element the component owns.
|
|
200
|
+
//
|
|
201
|
+
// Slot content is skipped: it was written by whoever used the tag, so
|
|
202
|
+
// it belongs to the caller's scope, and a component must not restyle
|
|
203
|
+
// markup it did not write.
|
|
204
|
+
function markScope(node, attribute) {
|
|
205
|
+
if (!node || node.type === 'text') return;
|
|
206
|
+
|
|
207
|
+
if (node.type === 'fragment' && node.slot) return;
|
|
208
|
+
|
|
209
|
+
if (node.type === 'element' || node.type === 'component') {
|
|
210
|
+
node.attrs = { ...node.attrs, [attribute]: { kind: 'static', value: '' } };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (node.type === 'if') {
|
|
214
|
+
for (const child of [...(node.then ?? []), ...(node.otherwise ?? [])]) {
|
|
215
|
+
markScope(child, attribute);
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
for (const child of node.children ?? []) markScope(child, attribute);
|
|
221
|
+
}
|
|
222
|
+
|
|
163
223
|
function loadComponent(name, ast, sourcePath, resolver, seen) {
|
|
164
224
|
const specifier = ast.components[name];
|
|
165
225
|
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Scoped styles: a component's CSS applies to that component's markup
|
|
2
|
+
// and nothing else.
|
|
3
|
+
//
|
|
4
|
+
// The mechanism is the one most frameworks settled on, because it needs
|
|
5
|
+
// no runtime: every element the component owns gets an attribute, and
|
|
6
|
+
// every selector in its <style> block is rewritten to require that
|
|
7
|
+
// attribute. The result is plain CSS a browser can parse — no shadow
|
|
8
|
+
// DOM, no class-name mangling, nothing to ship.
|
|
9
|
+
//
|
|
10
|
+
// This file has no Node built-ins on purpose: it runs in the browser
|
|
11
|
+
// playground like the rest of the compiler.
|
|
12
|
+
|
|
13
|
+
// A short, stable id from the component's path. Not cryptographic — it
|
|
14
|
+
// only has to be stable across builds and unlikely to collide between
|
|
15
|
+
// the files of one project.
|
|
16
|
+
export function scopeId(path) {
|
|
17
|
+
let hash = 0;
|
|
18
|
+
|
|
19
|
+
for (let i = 0; i < path.length; i++) {
|
|
20
|
+
hash = (hash * 31 + path.charCodeAt(i)) | 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return `a${(hash >>> 0).toString(36).slice(0, 6)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const scopeAttribute = (id) => `data-azox-${id}`;
|
|
27
|
+
|
|
28
|
+
// At-rules whose body is more CSS, so the selectors inside them need
|
|
29
|
+
// rewriting too — a rule in a @media block still belongs to the
|
|
30
|
+
// component.
|
|
31
|
+
const NESTED_AT_RULES = /^@(media|supports|container|layer)\b/;
|
|
32
|
+
|
|
33
|
+
// At-rules that contain no selectors. @keyframes names its steps `from`
|
|
34
|
+
// and `50%`, and scoping those would break the animation.
|
|
35
|
+
const OPAQUE_AT_RULES =
|
|
36
|
+
/^@(keyframes|-\w+-keyframes|font-face|import|charset|namespace|property)\b/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Rewrites every selector in `css` to require the scope attribute.
|
|
40
|
+
*
|
|
41
|
+
* .title { … } → .title[data-azox-a1b2c3] { … }
|
|
42
|
+
* .a, .b { … } → .a[data-azox-a1b2c3], .b[data-azox-a1b2c3] { … }
|
|
43
|
+
* :global(.x) { … } → .x { … }
|
|
44
|
+
*/
|
|
45
|
+
export function scopeCss(css, id) {
|
|
46
|
+
return rewriteBlock(css, `[${scopeAttribute(id)}]`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function rewriteBlock(css, attr) {
|
|
50
|
+
let out = '';
|
|
51
|
+
let i = 0;
|
|
52
|
+
|
|
53
|
+
while (i < css.length) {
|
|
54
|
+
const open = css.indexOf('{', i);
|
|
55
|
+
|
|
56
|
+
// Trailing text with no rule after it: a stray comment, or nothing.
|
|
57
|
+
if (open === -1) {
|
|
58
|
+
out += css.slice(i);
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const prelude = css.slice(i, open);
|
|
63
|
+
const close = matchingBrace(css, open);
|
|
64
|
+
const body = css.slice(open + 1, close);
|
|
65
|
+
const trimmed = prelude.trim();
|
|
66
|
+
|
|
67
|
+
if (OPAQUE_AT_RULES.test(trimmed)) {
|
|
68
|
+
// Kept whole: its body is not selectors.
|
|
69
|
+
out += `${prelude}{${body}}`;
|
|
70
|
+
} else if (NESTED_AT_RULES.test(trimmed)) {
|
|
71
|
+
// The condition stays; what is inside it is scoped.
|
|
72
|
+
out += `${prelude}{${rewriteBlock(body, attr)}}`;
|
|
73
|
+
} else {
|
|
74
|
+
out += `${scopeSelectorList(prelude, attr)}{${body}}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
i = close + 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Finds the brace closing the one at `open`, so a @media body is taken
|
|
84
|
+
// whole rather than cut at its first inner rule.
|
|
85
|
+
function matchingBrace(css, open) {
|
|
86
|
+
let depth = 0;
|
|
87
|
+
|
|
88
|
+
for (let i = open; i < css.length; i++) {
|
|
89
|
+
if (css[i] === '{') depth++;
|
|
90
|
+
else if (css[i] === '}' && --depth === 0) return i;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return css.length;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A placeholder for a comment while the selector list is split on
|
|
97
|
+
// commas. Printable on purpose: a control character would be invisible
|
|
98
|
+
// in any output that quotes this source.
|
|
99
|
+
const COMMENT_MARK = (n) => `/*__azox${n}__*/`;
|
|
100
|
+
|
|
101
|
+
function scopeSelectorList(prelude, attr) {
|
|
102
|
+
// Comments carry no selectors and may contain commas.
|
|
103
|
+
const comments = [];
|
|
104
|
+
const stripped = prelude.replace(/\/\*[\s\S]*?\*\//g, (comment) => {
|
|
105
|
+
comments.push(comment);
|
|
106
|
+
return COMMENT_MARK(comments.length - 1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const scoped = stripped
|
|
110
|
+
.split(',')
|
|
111
|
+
.map((selector) => scopeSelector(selector, attr))
|
|
112
|
+
.join(',');
|
|
113
|
+
|
|
114
|
+
return scoped.replace(/\/\*__azox(\d+)__\*\//g, (_, n) => comments[Number(n)]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function scopeSelector(selector, attr) {
|
|
118
|
+
const trimmed = selector.trim();
|
|
119
|
+
if (!trimmed) return selector;
|
|
120
|
+
|
|
121
|
+
// An escape hatch for the times a component must reach outside
|
|
122
|
+
// itself — a body class, or markup it renders into a portal.
|
|
123
|
+
const global = trimmed.match(/^:global\(([\s\S]*)\)$/);
|
|
124
|
+
if (global) return selector.replace(trimmed, global[1].trim());
|
|
125
|
+
|
|
126
|
+
const leading = selector.slice(0, selector.length - selector.trimStart().length);
|
|
127
|
+
const trailing = selector.slice(selector.trimEnd().length);
|
|
128
|
+
|
|
129
|
+
// The attribute goes on the last compound of the selector, so
|
|
130
|
+
// `.card .title` scopes the element actually being styled rather than
|
|
131
|
+
// its ancestor. Combinators are left where they are.
|
|
132
|
+
const parts = trimmed.split(/(\s+|\s*[>+~]\s*)/);
|
|
133
|
+
|
|
134
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
135
|
+
if (!parts[i].trim() || /^[>+~\s]+$/.test(parts[i])) continue;
|
|
136
|
+
parts[i] = attachAttribute(parts[i], attr);
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return leading + parts.join('') + trailing;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Placed before any pseudo-element or pseudo-class: `.a[attr]:hover` is
|
|
144
|
+
// what was meant, and `.a:hover[attr]` would not match a browser's
|
|
145
|
+
// reading of it.
|
|
146
|
+
function attachAttribute(compound, attr) {
|
|
147
|
+
const pseudo = compound.search(/::?[a-z-]/i);
|
|
148
|
+
if (pseudo === -1) return compound + attr;
|
|
149
|
+
|
|
150
|
+
return compound.slice(0, pseudo) + attr + compound.slice(pseudo);
|
|
151
|
+
}
|
package/core/index.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Types for `azoxjs`.
|
|
2
|
+
//
|
|
3
|
+
// The whole reactivity surface, plus the compiler and renderer, so this
|
|
4
|
+
// entry point and `azoxjs/reactivity` offer the same thing.
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
signal,
|
|
8
|
+
effect,
|
|
9
|
+
computed,
|
|
10
|
+
dispose,
|
|
11
|
+
untracked,
|
|
12
|
+
onMount,
|
|
13
|
+
onCleanup,
|
|
14
|
+
adopt,
|
|
15
|
+
type Signal,
|
|
16
|
+
type ReadonlySignal,
|
|
17
|
+
type EffectHandle,
|
|
18
|
+
type AdoptCursor,
|
|
19
|
+
} from './reactivity/signal.js';
|
|
20
|
+
|
|
21
|
+
export { parseAzox, compileToModule } from './compiler/index.js';
|
|
22
|
+
|
|
23
|
+
// Imported as well as re-exported: a type-only re-export names the type
|
|
24
|
+
// for consumers but does not put it in scope here, and renderToHtml
|
|
25
|
+
// below refers to it.
|
|
26
|
+
import type { AzoxAst } from './compiler/index.js';
|
|
27
|
+
export type { AzoxAst, CompileOptions } from './compiler/index.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Renders a parsed file to HTML, evaluating its expressions once.
|
|
31
|
+
*
|
|
32
|
+
* `scope` holds the bindings the markup reads. `modules` carries what
|
|
33
|
+
* the file's imports brought in, which the build resolves.
|
|
34
|
+
*/
|
|
35
|
+
export declare function renderToHtml(
|
|
36
|
+
ast: AzoxAst,
|
|
37
|
+
scope: Record<string, unknown>,
|
|
38
|
+
modules?: Record<string, unknown>
|
|
39
|
+
): string;
|
|
40
|
+
|
|
41
|
+
/** The version this copy of Azox reports. */
|
|
42
|
+
export declare const VERSION: string;
|
|
43
|
+
export declare const TAGLINE: string;
|
package/core/index.js
CHANGED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Types for `azoxjs/reactivity`.
|
|
2
|
+
//
|
|
3
|
+
// Written by hand rather than generated: the runtime carries no JSDoc,
|
|
4
|
+
// so `tsc --declaration` produces `signal(initialValue: any)`, which
|
|
5
|
+
// type-checks nothing. These describe what the implementation actually
|
|
6
|
+
// guarantees.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A reactive value. Call it to read — and, inside an `effect`, to
|
|
10
|
+
* subscribe to it.
|
|
11
|
+
*/
|
|
12
|
+
export interface Signal<T> {
|
|
13
|
+
(): T;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Replaces the value, or derives it from the current one.
|
|
17
|
+
*
|
|
18
|
+
* Setting a value equal to the current one (`===`) notifies nobody.
|
|
19
|
+
*
|
|
20
|
+
* A function is treated as an updater, so a signal *holding* a
|
|
21
|
+
* function cannot be replaced by passing it directly — pass
|
|
22
|
+
* `() => theFunction` instead.
|
|
23
|
+
*/
|
|
24
|
+
set(next: T | ((previous: T) => T)): void;
|
|
25
|
+
|
|
26
|
+
/** Reads without subscribing, even inside an effect. */
|
|
27
|
+
peek(): T;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A read-only reactive value. `computed` returns one of these. */
|
|
31
|
+
export interface ReadonlySignal<T> {
|
|
32
|
+
(): T;
|
|
33
|
+
peek(): T;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The handle `effect` returns. Its only public use is `dispose`; its
|
|
38
|
+
* internals are not part of the API and may change.
|
|
39
|
+
*/
|
|
40
|
+
export interface EffectHandle {
|
|
41
|
+
(): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Creates a reactive value. */
|
|
45
|
+
export declare function signal<T>(initialValue: T): Signal<T>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Runs `fn` now, and again whenever a signal it read changes.
|
|
49
|
+
*
|
|
50
|
+
* Effects created while another is running belong to it, and are
|
|
51
|
+
* disposed with it. Use `untracked` to opt out of that ownership.
|
|
52
|
+
*/
|
|
53
|
+
export declare function effect(fn: () => void): EffectHandle;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A value derived from other signals, recomputed when they change.
|
|
57
|
+
*
|
|
58
|
+
* Read-only by design. The runtime object does carry `set`, but the
|
|
59
|
+
* deriving effect overwrites whatever you assign on its next run, so
|
|
60
|
+
* it is not offered here.
|
|
61
|
+
*/
|
|
62
|
+
export declare function computed<T>(fn: () => T): ReadonlySignal<T>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Runs `fn` with no effect considered active, so anything it creates
|
|
66
|
+
* is owned by nobody and survives the caller re-running.
|
|
67
|
+
*/
|
|
68
|
+
export declare function untracked<T>(fn: () => T): T;
|
|
69
|
+
|
|
70
|
+
/** Stops an effect and everything it created. Ignores null. */
|
|
71
|
+
export declare function dispose(handle: EffectHandle | null | undefined): void;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Runs once the DOM this scope built is in the document.
|
|
75
|
+
*
|
|
76
|
+
* A function returned from `fn` becomes the scope's cleanup, so setup
|
|
77
|
+
* and teardown can stay together.
|
|
78
|
+
*/
|
|
79
|
+
export declare function onMount(fn: () => void | (() => void)): void;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Registers work to undo when the surrounding scope goes away: a row
|
|
83
|
+
* leaving a keyed list, or a branch of an `<if>` no longer taken.
|
|
84
|
+
*
|
|
85
|
+
* Called outside any scope it does nothing, which is the case at the
|
|
86
|
+
* top level of a page — nothing ever removes it.
|
|
87
|
+
*/
|
|
88
|
+
export declare function onCleanup(fn: () => void): void;
|
|
89
|
+
|
|
90
|
+
/** A cursor over server-rendered nodes, used by hydration. */
|
|
91
|
+
export interface AdoptCursor {
|
|
92
|
+
/**
|
|
93
|
+
* The next node if it matches — a tag name, null for text, or
|
|
94
|
+
* '#comment' for a control-flow marker.
|
|
95
|
+
*/
|
|
96
|
+
next(expect: string | null): Node | null;
|
|
97
|
+
/** Removes anything the module did not claim. */
|
|
98
|
+
done(): void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Walks a parent's existing children so hydration can bind to them
|
|
103
|
+
* rather than replacing them. Returns a cursor that yields null on any
|
|
104
|
+
* mismatch, at which point the caller creates the node instead.
|
|
105
|
+
*/
|
|
106
|
+
export declare function adopt(parent: Node | null): AdoptCursor;
|
|
@@ -181,3 +181,64 @@ export function computed(fn) {
|
|
|
181
181
|
effect(() => derived.set(fn()));
|
|
182
182
|
return derived;
|
|
183
183
|
}
|
|
184
|
+
|
|
185
|
+
/* ---------- hydration ---------- */
|
|
186
|
+
|
|
187
|
+
// A cursor over server-rendered nodes, so hydration can bind to what is
|
|
188
|
+
// already on the page instead of replacing it. Replacing destroys the
|
|
189
|
+
// reader's focus, caret and anything they had expanded.
|
|
190
|
+
//
|
|
191
|
+
// `next` returns the node the compiled module expected, or null when the
|
|
192
|
+
// markup does not match — a stale cache, an edited page, a host without
|
|
193
|
+
// a walkable DOM. The module then creates that node as it always did, so
|
|
194
|
+
// a mismatch costs the work we already do rather than a broken page.
|
|
195
|
+
export function adopt(parent) {
|
|
196
|
+
// A minimal DOM stub — the compiler's own tests use one — has no
|
|
197
|
+
// childNodes to walk. Creating is then the only option.
|
|
198
|
+
if (!parent || typeof parent !== 'object' || !parent.firstChild) {
|
|
199
|
+
return { next: () => null, done: () => {} };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Snapshotted rather than walked through `nextSibling`. The caller
|
|
203
|
+
// appends each node it adopts, and appending a node that is already a
|
|
204
|
+
// child detaches and re-attaches it — which rewrites `nextSibling` for
|
|
205
|
+
// whatever preceded it. A lazy walk therefore ends up pointing at a
|
|
206
|
+
// node it already handed out, and the sweep below deletes the live
|
|
207
|
+
// page. Measured: every child adopted in the right order, and every
|
|
208
|
+
// one removed a moment later. The list is fixed before any of that can
|
|
209
|
+
// happen, so reordering cannot move the cursor.
|
|
210
|
+
const kids = [];
|
|
211
|
+
for (let child = parent.firstChild; child; child = child.nextSibling) kids.push(child);
|
|
212
|
+
let i = 0;
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
// `expect` is a tag name for an element, null for a text node, or
|
|
216
|
+
// '#comment' for one of the markers a control-flow block anchors on.
|
|
217
|
+
// A block's markers are the nodes it later inserts and removes
|
|
218
|
+
// against, so adopting the region between them means adopting the
|
|
219
|
+
// pair itself first.
|
|
220
|
+
next(expect) {
|
|
221
|
+
const current = kids[i];
|
|
222
|
+
if (!current) return null;
|
|
223
|
+
|
|
224
|
+
const type = current.nodeType;
|
|
225
|
+
const matches =
|
|
226
|
+
expect === null
|
|
227
|
+
? type === 3
|
|
228
|
+
: expect === '#comment'
|
|
229
|
+
? type === 8
|
|
230
|
+
: type !== 3 && type !== 8 && current.nodeName?.toLowerCase() === expect;
|
|
231
|
+
|
|
232
|
+
if (!matches) return null;
|
|
233
|
+
|
|
234
|
+
i++;
|
|
235
|
+
return current;
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
// Anything the server sent that the module did not claim is stale
|
|
239
|
+
// and has to go, or it would linger below the adopted nodes.
|
|
240
|
+
done() {
|
|
241
|
+
for (; i < kids.length; i++) kids[i].remove?.();
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
@@ -102,7 +102,7 @@ function renderScope(node, scope, modules = {}) {
|
|
|
102
102
|
// so the body sees it the same way the compiled version does.
|
|
103
103
|
function renderEach(node, scope, outer, modules = {}) {
|
|
104
104
|
const items = evalExpr(node.expr, scope);
|
|
105
|
-
if (items === null || items === undefined) return '';
|
|
105
|
+
if (items === null || items === undefined) return wrapBlock('');
|
|
106
106
|
|
|
107
107
|
if (typeof items[Symbol.iterator] !== 'function') {
|
|
108
108
|
throw new BuildError(
|
|
@@ -121,12 +121,23 @@ function renderEach(node, scope, outer, modules = {}) {
|
|
|
121
121
|
index++;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
return html;
|
|
124
|
+
return wrapBlock(html);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
function renderIf(node, scope, outer, modules = {}) {
|
|
128
128
|
const branch = evalExpr(node.expr, scope) ? node.then : node.otherwise;
|
|
129
|
-
|
|
129
|
+
const inner = branch.map((child) => renderNode(child, scope, outer, modules)).join('');
|
|
130
|
+
|
|
131
|
+
return wrapBlock(inner);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The same start/end pair the compiler builds, so hydration can adopt
|
|
135
|
+
// the server's nodes instead of discarding them. Labelled, because an
|
|
136
|
+
// empty comment cannot be told apart from its neighbour.
|
|
137
|
+
//
|
|
138
|
+
// See docs/hydration.md.
|
|
139
|
+
function wrapBlock(html) {
|
|
140
|
+
return `<!--[-->${html}<!--]-->`;
|
|
130
141
|
}
|
|
131
142
|
|
|
132
143
|
// Reads an attribute expression. A bound one names a signal, so it is
|
|
@@ -44,18 +44,25 @@ export function serverOnCleanup() {}
|
|
|
44
44
|
export function declaredNames(script) {
|
|
45
45
|
const names = new Set();
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
// Only what the script declares at its top level. A name declared
|
|
48
|
+
// inside a callback — `const timer` within onMount, say — is a local
|
|
49
|
+
// of that function, so returning it from the outer scope throws
|
|
50
|
+
// "timer is not defined" and takes the whole page down.
|
|
51
|
+
const topLevel = topLevelSource(script);
|
|
52
|
+
|
|
53
|
+
for (const match of topLevel.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g)) {
|
|
48
54
|
names.add(match[1]);
|
|
49
55
|
}
|
|
50
|
-
for (const match of
|
|
56
|
+
for (const match of topLevel.matchAll(/function\s+([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
51
57
|
names.add(match[1]);
|
|
52
58
|
}
|
|
53
|
-
for (const match of
|
|
59
|
+
for (const match of topLevel.matchAll(/class\s+([A-Za-z_$][\w$]*)/g)) {
|
|
54
60
|
names.add(match[1]);
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
// Destructured declarations: const { a, b: c, d = 1 } = …
|
|
58
|
-
|
|
64
|
+
//
|
|
65
|
+
for (const match of topLevel.matchAll(/(?:const|let|var)\s*\{([^}]*)\}\s*=/g)) {
|
|
59
66
|
for (const part of match[1].split(',')) {
|
|
60
67
|
const name = part.split(':').pop().split('=')[0].trim();
|
|
61
68
|
if (/^[A-Za-z_$][\w$]*$/.test(name)) names.add(name);
|
|
@@ -65,6 +72,97 @@ export function declaredNames(script) {
|
|
|
65
72
|
return [...names];
|
|
66
73
|
}
|
|
67
74
|
|
|
75
|
+
// Blanks out everything nested inside braces, brackets or parentheses,
|
|
76
|
+
// leaving the top-level text with its offsets intact — so a declaration
|
|
77
|
+
// inside a function body is no longer visible to the patterns above.
|
|
78
|
+
//
|
|
79
|
+
// Strings, template literals and comments are blanked too: a brace in
|
|
80
|
+
// one of them would otherwise throw the depth count off.
|
|
81
|
+
function topLevelSource(script) {
|
|
82
|
+
let out = '';
|
|
83
|
+
let depth = 0;
|
|
84
|
+
let i = 0;
|
|
85
|
+
|
|
86
|
+
while (i < script.length) {
|
|
87
|
+
const c = script[i];
|
|
88
|
+
const next = script[i + 1];
|
|
89
|
+
|
|
90
|
+
// Comments.
|
|
91
|
+
if (c === '/' && next === '/') {
|
|
92
|
+
const end = script.indexOf('\n', i);
|
|
93
|
+
const stop = end === -1 ? script.length : end;
|
|
94
|
+
out += ' '.repeat(stop - i);
|
|
95
|
+
i = stop;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (c === '/' && next === '*') {
|
|
99
|
+
const end = script.indexOf('*/', i + 2);
|
|
100
|
+
const stop = end === -1 ? script.length : end + 2;
|
|
101
|
+
out += ' '.repeat(stop - i);
|
|
102
|
+
i = stop;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Strings and template literals: skipped whole, so braces inside
|
|
107
|
+
// them do not count. A template's ${...} is skipped with it, which
|
|
108
|
+
// is fine — nothing is declared at the top level in there.
|
|
109
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
110
|
+
const quote = c;
|
|
111
|
+
let j = i + 1;
|
|
112
|
+
while (j < script.length) {
|
|
113
|
+
if (script[j] === '\\') j += 2;
|
|
114
|
+
else if (script[j] === quote) break;
|
|
115
|
+
else j++;
|
|
116
|
+
}
|
|
117
|
+
const stop = Math.min(j + 1, script.length);
|
|
118
|
+
out += ' '.repeat(stop - i);
|
|
119
|
+
i = stop;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The delimiter itself is kept when it opens at the top level, so
|
|
124
|
+
// `function f(` and `const { a } =` still match; what is nested
|
|
125
|
+
// inside is blanked.
|
|
126
|
+
//
|
|
127
|
+
// The exception is a destructuring pattern — `const { a, b } = …` —
|
|
128
|
+
// whose names are the thing being declared, so its contents are
|
|
129
|
+
// kept even though they sit one level in.
|
|
130
|
+
if (c === '{' || c === '(' || c === '[') {
|
|
131
|
+
const destructuring = c === '{' && depth === 0 && /(?:const|let|var)\s*$/.test(out);
|
|
132
|
+
|
|
133
|
+
out += depth === 0 ? c : ' ';
|
|
134
|
+
depth++;
|
|
135
|
+
|
|
136
|
+
if (destructuring) {
|
|
137
|
+
const close = script.indexOf('}', i + 1);
|
|
138
|
+
if (close !== -1) {
|
|
139
|
+
out += script.slice(i + 1, close + 1);
|
|
140
|
+
depth--;
|
|
141
|
+
i = close + 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
i++;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (c === '}' || c === ')' || c === ']') {
|
|
151
|
+
depth = Math.max(0, depth - 1);
|
|
152
|
+
out += depth === 0 ? c : ' ';
|
|
153
|
+
i++;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Newlines are kept at any depth so line-anchored patterns still
|
|
158
|
+
// behave; everything else nested is blanked.
|
|
159
|
+
out += depth === 0 || c === '\n' ? c : ' ';
|
|
160
|
+
i++;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
68
166
|
// Evaluates a script body and returns its declarations. `params` and
|
|
69
167
|
// `args` pass a component's props in as arguments.
|
|
70
168
|
//
|