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.
@@ -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(ast, sourcePath, resolver, seen = new Set(), heads = null) {
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
- const markup = expand(ast.markup, ast, sourcePath, resolver, seen, hoisted, collected);
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
+ }
@@ -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
@@ -11,6 +11,7 @@ export {
11
11
  untracked,
12
12
  onMount,
13
13
  onCleanup,
14
+ adopt,
14
15
  } from './reactivity/signal.js';
15
16
  export { parseAzox } from './compiler/parser.js';
16
17
  export { compileToModule } from './compiler/compileToJs.js';
@@ -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
- return branch.map((child) => renderNode(child, scope, outer, modules)).join('');
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
- for (const match of script.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g)) {
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 script.matchAll(/function\s+([A-Za-z_$][\w$]*)\s*\(/g)) {
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 script.matchAll(/class\s+([A-Za-z_$][\w$]*)/g)) {
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
- for (const match of script.matchAll(/(?:const|let|var)\s*\{([^}]*)\}\s*=/g)) {
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
  //