@sigil-dev/compiler 0.9.2 → 0.9.4

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.
Files changed (40) hide show
  1. package/README.md +162 -162
  2. package/index.ts +4 -4
  3. package/jsr.json +8 -8
  4. package/package.json +8 -8
  5. package/src/babel/handlers/dom/derived.ts +62 -62
  6. package/src/babel/handlers/dom/effect.ts +15 -15
  7. package/src/babel/handlers/dom/globals.ts +105 -105
  8. package/src/babel/handlers/dom/state.ts +107 -104
  9. package/src/babel/handlers/ssr/derived.ts +27 -27
  10. package/src/babel/handlers/ssr/state.ts +11 -11
  11. package/src/babel/index.ts +599 -589
  12. package/src/babel/jsx/anchor-mount.ts +385 -389
  13. package/src/babel/jsx/children.ts +124 -124
  14. package/src/babel/jsx/element.ts +850 -831
  15. package/src/babel/jsx/fragment.ts +282 -282
  16. package/src/babel/jsx/index.ts +2 -2
  17. package/src/babel/jsx/keyed-list.ts +309 -278
  18. package/src/babel/jsx/ssr.ts +324 -319
  19. package/src/babel/jsx/text-node.ts +192 -192
  20. package/src/babel/jsx/utils.ts +179 -179
  21. package/src/babel/util/bind.ts +139 -139
  22. package/src/babel/util/css.ts +151 -151
  23. package/src/babel/util/dead-code.ts +28 -28
  24. package/src/babel/util/helpers.ts +46 -46
  25. package/src/babel/util/magic.ts +11 -11
  26. package/src/bun-plugin.ts +92 -90
  27. package/src/vite/index.ts +58 -58
  28. package/test/async-components.test.ts +24 -24
  29. package/test/components.test.ts +129 -129
  30. package/test/containsSignal.test.ts +288 -288
  31. package/test/fragments.test.ts +90 -90
  32. package/test/helpers/transform.ts +45 -48
  33. package/test/hydration.test.ts +147 -147
  34. package/test/jsx.test.ts +225 -197
  35. package/test/keyed-lists.test.ts +126 -88
  36. package/test/reactivity.test.ts +225 -225
  37. package/test/scoped-css.test.ts +129 -129
  38. package/test/ssr.test.ts +130 -130
  39. package/test/tree-shaking.test.ts +78 -78
  40. package/tsconfig.json +30 -30
@@ -1,589 +1,599 @@
1
- import { type PluginObject, types as t, template } from "@babel/core";
2
- import { handleDerived } from "./handlers/dom/derived.ts";
3
- import { handleEffect } from "./handlers/dom/effect.ts";
4
- import { handleState } from "./handlers/dom/state.ts";
5
- import { handleDerivedSSR } from "./handlers/ssr/derived.ts";
6
- import { handleStateSSR } from "./handlers/ssr/state.ts";
7
- import { processElement, processFragment } from "./jsx/index.ts";
8
- import { processElementSSR, processFragmentSSR } from "./jsx/ssr.ts";
9
- import { warnDeadReactivity } from "./util/dead-code.ts";
10
- import { getMacro } from "./util/helpers.ts";
11
- import { MAGIC } from "./util/magic.ts";
12
-
13
- const SSR_HELPERS = template.statements.ast(`
14
- const __SAFE = Symbol.for('sigil.safe');
15
- class __H {
16
- constructor(v) { this.v = v; this[__SAFE] = true; }
17
- toString() { return this.v; }
18
- }
19
- const __h = (v) => {
20
- if (v?.[__SAFE]) return v;
21
- if (Array.isArray(v)) return new __H(v.map(x => x?.[__SAFE] ? x.v : __e(x)).join(''));
22
- return new __H(String(v ?? ''));
23
- };
24
- const __e = (v) => {
25
- if (v?.[__SAFE]) return v.v;
26
- if (Array.isArray(v)) return v.map(x => x?.[__SAFE] ? x.v : __e(x)).join('');
27
- if (v === false || v === null || v === undefined) return '';
28
- return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
29
- };
30
- `);
31
- /**
32
- * Recursively convert key={expr} to data-key={expr} in JSX tree.
33
- * Needed for SSR output so hydration can map items by key.
34
- */
35
- function convertKeyToDataKey(node: t.JSXElement): void {
36
- for (let i = 0; i < node.openingElement.attributes.length; i++) {
37
- const attr = node.openingElement.attributes[i];
38
- if (
39
- t.isJSXAttribute(attr) &&
40
- t.isJSXIdentifier(attr.name) &&
41
- attr.name.name === "key"
42
- ) {
43
- node.openingElement.attributes[i] = t.jsxAttribute(
44
- t.jsxIdentifier("data-key"),
45
- attr.value,
46
- );
47
- }
48
- }
49
- for (const child of node.children) {
50
- if (t.isJSXElement(child)) {
51
- convertKeyToDataKey(child);
52
- } else if (
53
- t.isJSXExpressionContainer(child) &&
54
- t.isJSXElement(child.expression)
55
- ) {
56
- convertKeyToDataKey(child.expression);
57
- }
58
- }
59
- }
60
-
61
- interface SigilOptions {
62
- hash?: string;
63
- mode?: "dom" | "ssr" | "hydrate";
64
- dev?: boolean
65
- }
66
-
67
- export default function sigilPlugin(): PluginObject {
68
- const signals = new Set<string>();
69
- const storeSignals = new Set<string>();
70
- const asyncFunctions = new Map<string, any>();
71
-
72
- let scopedHash: string | undefined;
73
- let isSSR = false;
74
- let isHydrate = false;
75
- let didTransform = false;
76
-
77
- let isDev = false;
78
- let filename = "";
79
-
80
- let asyncDefaultPath: any = null;
81
- let asyncDefaultRef: string | null = null;
82
- let hasLoadingExport = false;
83
- let hasErrorExport = false;
84
-
85
- return {
86
- name: "sigil",
87
- visitor: {
88
- Program: {
89
- enter(path, state) {
90
- signals.clear();
91
- storeSignals.clear();
92
- const opts = (state.opts || {}) as SigilOptions;
93
- scopedHash = opts.hash;
94
- didTransform = false;
95
- isSSR = opts.mode === "ssr";
96
- isHydrate = opts.mode === "hydrate";
97
- isDev = opts.dev ?? false;
98
- filename = state.filename ?? "";
99
- asyncDefaultPath = null;
100
- hasLoadingExport = false;
101
- hasErrorExport = false;
102
- asyncDefaultRef = null;
103
- asyncFunctions.clear();
104
-
105
- if (isSSR) {
106
- path.unshiftContainer("body", SSR_HELPERS);
107
- return;
108
- }
109
- },
110
- exit(path, state) {
111
- warnDeadReactivity(path, signals, storeSignals, state.filename);
112
-
113
- if (asyncDefaultPath && !isSSR) {
114
- let fnNode: t.FunctionDeclaration;
115
-
116
- if (asyncDefaultPath.isExportDefaultDeclaration()) {
117
- // export default async function Page() {}
118
- fnNode = asyncDefaultPath.get("declaration").node as t.FunctionDeclaration;
119
- } else {
120
- // async function Page() {} + export default Page
121
- fnNode = asyncDefaultPath.node as t.FunctionDeclaration;
122
- }
123
-
124
- const originalName = fnNode.id?.name ?? "Component";
125
- const privateName = `__${originalName}`;
126
- const propsId = t.identifier("__props");
127
-
128
- const privateFn = t.functionDeclaration(
129
- t.identifier(privateName),
130
- fnNode.params,
131
- fnNode.body,
132
- false,
133
- true,
134
- );
135
-
136
- const wrapperExport = t.exportDefaultDeclaration(
137
- t.functionDeclaration(
138
- t.identifier(originalName),
139
- [propsId],
140
- t.blockStatement([
141
- t.returnStatement(
142
- t.callExpression(t.identifier("__asyncBoundary"), [
143
- t.arrowFunctionExpression(
144
- [],
145
- t.callExpression(t.identifier(privateName), [propsId]),
146
- ),
147
- hasLoadingExport ? t.identifier("loading") : t.identifier("undefined"),
148
- hasErrorExport ? t.identifier("error") : t.identifier("undefined"),
149
- ]),
150
- ),
151
- ]),
152
- ),
153
- );
154
-
155
- asyncDefaultPath.replaceWithMultiple([privateFn, wrapperExport]);
156
- didTransform = true;
157
- }
158
-
159
- // Only inject runtime import if this file actually used sigil transforms
160
- if (!didTransform || isSSR) return;
161
-
162
- const requiredSpecifiers = [
163
- "createSignal",
164
- "createRawSignal",
165
- "createEffect",
166
- "createMemo",
167
- "reconcile",
168
- "claim",
169
- "claimText",
170
- "claimComment",
171
- "hydrateKeyedList",
172
- "insert",
173
- "getHydrationNodes",
174
- "pushHydrationNodes",
175
- "popHydrationNodes",
176
- "snapshot",
177
- "tracking",
178
- "createEffectPre",
179
- "createInspectEffect",
180
- "withEffectScope",
181
- ...(asyncDefaultPath ? ["__asyncBoundary"] : []),
182
- ];
183
- const runtimeImport = path.node.body.find(
184
- (n): n is t.ImportDeclaration =>
185
- t.isImportDeclaration(n) &&
186
- n.source.value === "@sigil-dev/runtime",
187
- );
188
-
189
- if (runtimeImport) {
190
- // console.log(
191
- // "[sigil exit] found existing import, specifier count:",
192
- // runtimeImport.specifiers.length,
193
- // );
194
- const existingNames = new Set(
195
- runtimeImport.specifiers
196
- .filter(t.isImportSpecifier)
197
- .map((s) =>
198
- t.isIdentifier(s.imported)
199
- ? s.imported.name
200
- : (s.imported as t.StringLiteral).value,
201
- ),
202
- );
203
- for (const name of requiredSpecifiers) {
204
- if (!existingNames.has(name)) {
205
- runtimeImport.specifiers.push(
206
- t.importSpecifier(t.identifier(name), t.identifier(name)),
207
- );
208
- }
209
- }
210
- } else {
211
- path.unshiftContainer(
212
- "body",
213
- t.importDeclaration(
214
- requiredSpecifiers.map((name) =>
215
- t.importSpecifier(t.identifier(name), t.identifier(name)),
216
- ),
217
- t.stringLiteral("@sigil-dev/runtime"),
218
- ),
219
- );
220
- }
221
- },
222
- },
223
- VariableDeclaration(path) {
224
- for (const declarator of path.get("declarations")) {
225
- const macro = getMacro(declarator);
226
- if (!macro) continue;
227
- didTransform = true;
228
- const { name, init } = macro;
229
- const varName = t.isIdentifier(declarator.node.id)
230
- ? declarator.node.id.name
231
- : null;
232
-
233
- if (name === "$state" || name === "$store" || name === "$state.raw") {
234
- if (name === "$state.raw") {
235
- if (!isSSR) {
236
- const args = init.node.arguments;
237
- init.replaceWith(
238
- t.callExpression(t.identifier(MAGIC.createRawSignal), args),
239
- );
240
- }
241
- continue;
242
- }
243
- isSSR
244
- ? handleStateSSR(path, declarator, init, signals)
245
- : handleState(path, declarator, init, signals, isDev, filename); // ← add
246
- if (name === "$store" && varName) {
247
- storeSignals.add(varName);
248
- }
249
- } else if (name === "$derived") {
250
- isSSR
251
- ? handleDerivedSSR(path, declarator, init, signals)
252
- : handleDerived(path, declarator, init, signals);
253
- }
254
- }
255
- },
256
- ExpressionStatement(path) {
257
- if (isSSR) {
258
- // drop $effect, $inspect, and $debug entirely in SSR
259
- const expr = path.get("expression");
260
- if (expr.isCallExpression()) {
261
- const callee = expr.get("callee");
262
- if (
263
- callee.isIdentifier() &&
264
- (callee.node.name === "$effect" ||
265
- callee.node.name === "$inspect" ||
266
- callee.node.name === "$debug")
267
- ) {
268
- didTransform = true;
269
- path.remove();
270
- }
271
- }
272
- return;
273
- }
274
- handleEffect(path);
275
- // Handle $debug(expr) expression statements
276
- const expr = path.get("expression");
277
- if (expr.isCallExpression()) {
278
- const callee = expr.get("callee");
279
- if (callee.isIdentifier({ name: "$debug" })) {
280
- didTransform = true;
281
- path.replaceWith(
282
- t.ifStatement(
283
- t.memberExpression(
284
- t.memberExpression(
285
- t.memberExpression(
286
- t.identifier("import"),
287
- t.identifier("meta"),
288
- ),
289
- t.identifier("env"),
290
- ),
291
- t.identifier("DEV"),
292
- ),
293
- t.blockStatement([
294
- t.debuggerStatement(),
295
- t.expressionStatement(
296
- t.callExpression(
297
- t.memberExpression(
298
- t.identifier("console"),
299
- t.identifier("log"),
300
- ),
301
- expr.node.arguments,
302
- ),
303
- ),
304
- ]),
305
- ),
306
- );
307
- }
308
- }
309
- },
310
- CallExpression(path) {
311
- if (isSSR) return;
312
- const callee = path.get("callee");
313
-
314
- // Helper: wrap signal identifiers in call expressions
315
- const wrapSignal = (expr: t.Expression): t.Expression => {
316
- if (t.isIdentifier(expr) && signals.has(expr.name)) {
317
- return t.callExpression(expr, []);
318
- }
319
- return expr;
320
- };
321
-
322
- // Handle member expression callees: $state.snapshot(x), $effect.tracking(), $effect.pre(fn)
323
- if (callee.isMemberExpression()) {
324
- const object = callee.get("object");
325
- const property = callee.get("property");
326
- if (object.isIdentifier() && property.isIdentifier()) {
327
- const obj = object.node.name;
328
- const prop = property.node.name;
329
-
330
- if (obj === "$state" && prop === "snapshot") {
331
- didTransform = true;
332
- path.get("callee").replaceWith(t.identifier(MAGIC.snapshot));
333
- return;
334
- }
335
-
336
- if (obj === "$effect" && prop === "tracking") {
337
- didTransform = true;
338
- path.get("callee").replaceWith(t.identifier(MAGIC.tracking));
339
- return;
340
- }
341
-
342
- if (obj === "$effect" && prop === "pre") {
343
- didTransform = true;
344
- path
345
- .get("callee")
346
- .replaceWith(t.identifier(MAGIC.createEffectPre));
347
- return;
348
- }
349
- }
350
- }
351
-
352
- // Handle $inspect(count).with(cb) chain
353
- if (callee.isMemberExpression()) {
354
- const property = callee.get("property");
355
- if (property.isIdentifier({ name: "with" })) {
356
- const object = callee.get("object");
357
- if (object.isCallExpression()) {
358
- const innerCallee = object.get("callee");
359
- if (innerCallee.isIdentifier({ name: "$inspect" })) {
360
- didTransform = true;
361
- //@ts-expect-error Bizzare but it works if you read LSP
362
- const args = object.node.arguments.map(wrapSignal);
363
- const cb = path.node.arguments[0];
364
- path.replaceWith(
365
- t.callExpression(t.identifier(MAGIC.createInspectEffect), [
366
- t.arrowFunctionExpression([], t.arrayExpression(args)),
367
- //@ts-expect-error Bizzare but it works if you read LSP
368
- cb,
369
- ]),
370
- );
371
- return;
372
- }
373
- }
374
- }
375
- }
376
-
377
- // Handle standalone $debug(...) — not as expression statement
378
- if (callee.isIdentifier({ name: "$debug" })) {
379
- didTransform = true;
380
- path.replaceWith(
381
- t.ifStatement(
382
- t.memberExpression(
383
- t.memberExpression(
384
- t.memberExpression(
385
- t.identifier("import"),
386
- t.identifier("meta"),
387
- ),
388
- t.identifier("env"),
389
- ),
390
- t.identifier("DEV"),
391
- ),
392
- t.blockStatement([
393
- t.debuggerStatement(),
394
- t.expressionStatement(
395
- t.callExpression(
396
- t.memberExpression(
397
- t.identifier("console"),
398
- t.identifier("log"),
399
- ),
400
- path.node.arguments,
401
- ),
402
- ),
403
- ]),
404
- ),
405
- );
406
- return;
407
- }
408
-
409
- // Handle standalone $inspect(...)
410
- if (callee.isIdentifier({ name: "$inspect" })) {
411
- didTransform = true;
412
- //@ts-expect-error Bizzare but it works if you read LSP
413
- const args = path.node.arguments.map(wrapSignal);
414
- path.replaceWith(
415
- t.callExpression(t.identifier(MAGIC.createInspectEffect), [
416
- t.arrowFunctionExpression([], t.arrayExpression(args)),
417
- ]),
418
- );
419
- return;
420
- }
421
- },
422
- JSXElement(path) {
423
- didTransform = true;
424
- // console.log(
425
- // "[sigil] JSXElement visitor fired, parent:",
426
- // path.parentPath?.type,
427
- // "isSSR:", isSSR
428
- // );
429
- if (path.parentPath?.isJSXElement() || path.parentPath?.isJSXFragment())
430
- return;
431
- if (isSSR) {
432
- try {
433
- convertKeyToDataKey(path.node);
434
- path.replaceWith(
435
- t.callExpression(t.identifier("__h"), [
436
- processElementSSR(path.node, signals),
437
- ]),
438
- );
439
- } catch (e) {
440
- console.error("[sigil] processElementSSR threw:", path.toString());
441
- console.error(e);
442
- throw e;
443
- }
444
- return;
445
- }
446
-
447
- const statements: t.Statement[] = [];
448
- const genId = (() => {
449
- let i = 0;
450
- return () => `_el${i++}`;
451
- })();
452
-
453
- const root = processElement(
454
- path.node,
455
- statements,
456
- genId,
457
- signals,
458
- scopedHash,
459
- isHydrate,
460
- isHydrate ? "__nodes" : undefined,
461
- );
462
-
463
- const body: t.Statement[] = [];
464
-
465
- if (isHydrate) {
466
- // DECLARE __nodes locally — every IIFE owns its own context
467
- body.push(
468
- t.variableDeclaration("const", [
469
- t.variableDeclarator(
470
- t.identifier("__nodes"),
471
- t.callExpression(t.identifier("getHydrationNodes"), []),
472
- ),
473
- ]),
474
- );
475
- }
476
-
477
- body.push(...statements);
478
- body.push(t.returnStatement(t.identifier(root)));
479
-
480
- path.replaceWith(
481
- t.callExpression(
482
- t.arrowFunctionExpression([], t.blockStatement(body)),
483
- [],
484
- ),
485
- );
486
- },
487
- JSXFragment(path) {
488
- didTransform = true;
489
- if (path.parentPath?.isJSXElement() || path.parentPath?.isJSXFragment())
490
- return;
491
- if (isSSR) {
492
- path.replaceWith(
493
- t.callExpression(t.identifier("__h"), [
494
- processFragmentSSR(path.node, signals),
495
- ]),
496
- );
497
- return;
498
- }
499
-
500
- const statements: t.Statement[] = [];
501
- const genId = (() => {
502
- let i = 0;
503
- return () => `_el${i++}`;
504
- })();
505
-
506
- const root = processFragment(
507
- path.node,
508
- statements,
509
- genId,
510
- signals,
511
- scopedHash,
512
- isHydrate,
513
- isHydrate ? "__nodes" : undefined,
514
- );
515
-
516
- const body: t.Statement[] = [];
517
-
518
- if (isHydrate) {
519
- // DECLARE __nodes locally — every IIFE owns its own context
520
- body.push(
521
- t.variableDeclaration("const", [
522
- t.variableDeclarator(
523
- t.identifier("__nodes"),
524
- t.callExpression(t.identifier("getHydrationNodes"), []),
525
- ),
526
- ]),
527
- );
528
- }
529
-
530
- body.push(...statements);
531
- body.push(t.returnStatement(t.identifier(root)));
532
-
533
- path.replaceWith(
534
- t.callExpression(
535
- t.arrowFunctionExpression([], t.blockStatement(body)),
536
- [],
537
- ),
538
- );
539
- },
540
- FunctionDeclaration(path) {
541
- if (isSSR) return;
542
- const node = path.node;
543
- if (node.async && node.id) {
544
- asyncFunctions.set(node.id.name, path);
545
- }
546
- },
547
-
548
- ExportDefaultDeclaration(path) {
549
- const decl = path.get("declaration");
550
-
551
- // export default async function Page() {}
552
- if (
553
- (decl.isFunctionDeclaration() || decl.isFunctionExpression()) &&
554
- (decl.node as any).async &&
555
- !isSSR
556
- ) {
557
- asyncDefaultPath = path;
558
- return;
559
- }
560
-
561
- // export default Page
562
- if (decl.isIdentifier() && !isSSR) {
563
- const name = (decl.node as t.Identifier).name;
564
- if (asyncFunctions.has(name)) {
565
- asyncDefaultPath = asyncFunctions.get(name);
566
- asyncDefaultRef = name;
567
- }
568
- }
569
- },
570
-
571
- ExportNamedDeclaration(path) {
572
- const decl = path.get("declaration");
573
- if (decl.isFunctionDeclaration()) {
574
- const name = (decl.node as t.FunctionDeclaration).id?.name;
575
- if (name === "loading") hasLoadingExport = true;
576
- if (name === "error") hasErrorExport = true;
577
- }
578
- if (decl.isVariableDeclaration()) {
579
- for (const d of decl.node.declarations) {
580
- if (t.isIdentifier(d.id)) {
581
- if (d.id.name === "loading") hasLoadingExport = true;
582
- if (d.id.name === "error") hasErrorExport = true;
583
- }
584
- }
585
- }
586
- },
587
- },
588
- };
589
- }
1
+ import { type PluginObject, types as t, template } from "@babel/core";
2
+ import { handleDerived } from "./handlers/dom/derived.ts";
3
+ import { handleEffect } from "./handlers/dom/effect.ts";
4
+ import { handleState } from "./handlers/dom/state.ts";
5
+ import { handleDerivedSSR } from "./handlers/ssr/derived.ts";
6
+ import { handleStateSSR } from "./handlers/ssr/state.ts";
7
+ import { processElement, processFragment } from "./jsx/index.ts";
8
+ import { processElementSSR, processFragmentSSR } from "./jsx/ssr.ts";
9
+ import { warnDeadReactivity } from "./util/dead-code.ts";
10
+ import { getMacro } from "./util/helpers.ts";
11
+ import { MAGIC } from "./util/magic.ts";
12
+
13
+ const SSR_HELPERS = template.statements.ast(`
14
+ const __SAFE = Symbol.for('sigil.safe');
15
+ class __H {
16
+ constructor(v) { this.v = v; this[__SAFE] = true; }
17
+ toString() { return this.v; }
18
+ }
19
+ const __h = (v) => {
20
+ if (v?.[__SAFE]) return v;
21
+ if (Array.isArray(v)) return new __H(v.map(x => x?.[__SAFE] ? x.v : __e(x)).join(''));
22
+ return new __H(String(v ?? ''));
23
+ };
24
+ const __e = (v) => {
25
+ if (v?.[__SAFE]) return v.v;
26
+ if (Array.isArray(v)) return v.map(x => x?.[__SAFE] ? x.v : __e(x)).join('');
27
+ if (v === false || v === null || v === undefined) return '';
28
+ return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
29
+ };
30
+ `);
31
+ /**
32
+ * Recursively convert key={expr} to data-key={expr} in JSX tree.
33
+ * Needed for SSR output so hydration can map items by key.
34
+ */
35
+ function convertKeyToDataKey(node: t.JSXElement): void {
36
+ for (let i = 0; i < node.openingElement.attributes.length; i++) {
37
+ const attr = node.openingElement.attributes[i];
38
+ if (
39
+ t.isJSXAttribute(attr) &&
40
+ t.isJSXIdentifier(attr.name) &&
41
+ attr.name.name === "key"
42
+ ) {
43
+ node.openingElement.attributes[i] = t.jsxAttribute(
44
+ t.jsxIdentifier("data-key"),
45
+ attr.value,
46
+ );
47
+ }
48
+ }
49
+ for (const child of node.children) {
50
+ if (t.isJSXElement(child)) {
51
+ convertKeyToDataKey(child);
52
+ } else if (
53
+ t.isJSXExpressionContainer(child) &&
54
+ t.isJSXElement(child.expression)
55
+ ) {
56
+ convertKeyToDataKey(child.expression);
57
+ }
58
+ }
59
+ }
60
+
61
+ interface SigilOptions {
62
+ hash?: string;
63
+ mode?: "dom" | "ssr" | "hydrate";
64
+ dev?: boolean;
65
+ }
66
+
67
+ export default function sigilPlugin(): PluginObject {
68
+ const signals = new Set<string>();
69
+ const storeSignals = new Set<string>();
70
+ const asyncFunctions = new Map<string, any>();
71
+
72
+ let scopedHash: string | undefined;
73
+ let isSSR = false;
74
+ let isHydrate = false;
75
+ let didTransform = false;
76
+
77
+ let isDev = false;
78
+ let filename = "";
79
+
80
+ let asyncDefaultPath: any = null;
81
+ let _asyncDefaultRef: string | null = null;
82
+ let hasLoadingExport = false;
83
+ let hasErrorExport = false;
84
+
85
+ return {
86
+ name: "sigil",
87
+ visitor: {
88
+ Program: {
89
+ enter(path, state) {
90
+ signals.clear();
91
+ storeSignals.clear();
92
+ const opts = (state.opts || {}) as SigilOptions;
93
+ scopedHash = opts.hash;
94
+ didTransform = false;
95
+ isSSR = opts.mode === "ssr";
96
+ isHydrate = opts.mode === "hydrate";
97
+ isDev = opts.dev ?? false;
98
+ filename = state.filename ?? "";
99
+ asyncDefaultPath = null;
100
+ hasLoadingExport = false;
101
+ hasErrorExport = false;
102
+ _asyncDefaultRef = null;
103
+ asyncFunctions.clear();
104
+
105
+ if (isSSR) {
106
+ path.unshiftContainer("body", SSR_HELPERS);
107
+ return;
108
+ }
109
+ },
110
+ exit(path, state) {
111
+ warnDeadReactivity(path, signals, storeSignals, state.filename);
112
+
113
+ if (asyncDefaultPath && !isSSR) {
114
+ let fnNode: t.FunctionDeclaration;
115
+
116
+ if (asyncDefaultPath.isExportDefaultDeclaration()) {
117
+ // export default async function Page() {}
118
+ fnNode = asyncDefaultPath.get("declaration")
119
+ .node as t.FunctionDeclaration;
120
+ } else {
121
+ // async function Page() {} + export default Page
122
+ fnNode = asyncDefaultPath.node as t.FunctionDeclaration;
123
+ }
124
+
125
+ const originalName = fnNode.id?.name ?? "Component";
126
+ const privateName = `__${originalName}`;
127
+ const propsId = t.identifier("__props");
128
+
129
+ const privateFn = t.functionDeclaration(
130
+ t.identifier(privateName),
131
+ fnNode.params,
132
+ fnNode.body,
133
+ false,
134
+ true,
135
+ );
136
+
137
+ const wrapperExport = t.exportDefaultDeclaration(
138
+ t.functionDeclaration(
139
+ t.identifier(originalName),
140
+ [propsId],
141
+ t.blockStatement([
142
+ t.returnStatement(
143
+ t.callExpression(t.identifier("__asyncBoundary"), [
144
+ t.arrowFunctionExpression(
145
+ [],
146
+ t.callExpression(t.identifier(privateName), [propsId]),
147
+ ),
148
+ hasLoadingExport
149
+ ? t.identifier("loading")
150
+ : t.identifier("undefined"),
151
+ hasErrorExport
152
+ ? t.identifier("error")
153
+ : t.identifier("undefined"),
154
+ ]),
155
+ ),
156
+ ]),
157
+ ),
158
+ );
159
+
160
+ asyncDefaultPath.replaceWithMultiple([privateFn, wrapperExport]);
161
+ didTransform = true;
162
+ }
163
+
164
+ // Only inject runtime import if this file actually used sigil transforms
165
+ if (!didTransform || isSSR) return;
166
+
167
+ const requiredSpecifiers = [
168
+ "createSignal",
169
+ "createRawSignal",
170
+ "createEffect",
171
+ "createMemo",
172
+ "reconcile",
173
+ "claim",
174
+ "claimText",
175
+ "claimComment",
176
+ "hydrateKeyedList",
177
+ "insert",
178
+ "applyDirective",
179
+ "getHydrationNodes",
180
+ "pushHydrationNodes",
181
+ "popHydrationNodes",
182
+ "snapshot",
183
+ "tracking",
184
+ "untrack",
185
+ "createRoot",
186
+ "createEffectPre",
187
+ "createInspectEffect",
188
+ "withEffectScope",
189
+ "mountTransition",
190
+ "removeAnimated",
191
+ ...(asyncDefaultPath ? ["__asyncBoundary"] : []),
192
+ ];
193
+ const runtimeImport = path.node.body.find(
194
+ (n): n is t.ImportDeclaration =>
195
+ t.isImportDeclaration(n) &&
196
+ n.source.value === "@sigil-dev/runtime",
197
+ );
198
+
199
+ if (runtimeImport) {
200
+ // console.log(
201
+ // "[sigil exit] found existing import, specifier count:",
202
+ // runtimeImport.specifiers.length,
203
+ // );
204
+ const existingNames = new Set(
205
+ runtimeImport.specifiers
206
+ .filter(t.isImportSpecifier)
207
+ .map((s) =>
208
+ t.isIdentifier(s.imported)
209
+ ? s.imported.name
210
+ : (s.imported as t.StringLiteral).value,
211
+ ),
212
+ );
213
+ for (const name of requiredSpecifiers) {
214
+ if (!existingNames.has(name)) {
215
+ runtimeImport.specifiers.push(
216
+ t.importSpecifier(t.identifier(name), t.identifier(name)),
217
+ );
218
+ }
219
+ }
220
+ } else {
221
+ path.unshiftContainer(
222
+ "body",
223
+ t.importDeclaration(
224
+ requiredSpecifiers.map((name) =>
225
+ t.importSpecifier(t.identifier(name), t.identifier(name)),
226
+ ),
227
+ t.stringLiteral("@sigil-dev/runtime"),
228
+ ),
229
+ );
230
+ }
231
+ },
232
+ },
233
+ VariableDeclaration(path) {
234
+ for (const declarator of path.get("declarations")) {
235
+ const macro = getMacro(declarator);
236
+ if (!macro) continue;
237
+ didTransform = true;
238
+ const { name, init } = macro;
239
+ const varName = t.isIdentifier(declarator.node.id)
240
+ ? declarator.node.id.name
241
+ : null;
242
+
243
+ if (name === "$state" || name === "$store" || name === "$state.raw") {
244
+ if (name === "$state.raw") {
245
+ if (!isSSR) {
246
+ const args = init.node.arguments;
247
+ init.replaceWith(
248
+ t.callExpression(t.identifier(MAGIC.createRawSignal), args),
249
+ );
250
+ }
251
+ continue;
252
+ }
253
+ isSSR
254
+ ? handleStateSSR(path, declarator, init, signals)
255
+ : handleState(path, declarator, init, signals, isDev, filename); // ← add
256
+ if (name === "$store" && varName) {
257
+ storeSignals.add(varName);
258
+ }
259
+ } else if (name === "$derived") {
260
+ isSSR
261
+ ? handleDerivedSSR(path, declarator, init, signals)
262
+ : handleDerived(path, declarator, init, signals);
263
+ }
264
+ }
265
+ },
266
+ ExpressionStatement(path) {
267
+ if (isSSR) {
268
+ // drop $effect, $inspect, and $debug entirely in SSR
269
+ const expr = path.get("expression");
270
+ if (expr.isCallExpression()) {
271
+ const callee = expr.get("callee");
272
+ if (
273
+ callee.isIdentifier() &&
274
+ (callee.node.name === "$effect" ||
275
+ callee.node.name === "$inspect" ||
276
+ callee.node.name === "$debug")
277
+ ) {
278
+ didTransform = true;
279
+ path.remove();
280
+ }
281
+ }
282
+ return;
283
+ }
284
+ handleEffect(path);
285
+ // Handle $debug(expr) expression statements
286
+ const expr = path.get("expression");
287
+ if (expr.isCallExpression()) {
288
+ const callee = expr.get("callee");
289
+ if (callee.isIdentifier({ name: "$debug" })) {
290
+ didTransform = true;
291
+ path.replaceWith(
292
+ t.ifStatement(
293
+ t.memberExpression(
294
+ t.memberExpression(
295
+ t.memberExpression(
296
+ t.identifier("import"),
297
+ t.identifier("meta"),
298
+ ),
299
+ t.identifier("env"),
300
+ ),
301
+ t.identifier("DEV"),
302
+ ),
303
+ t.blockStatement([
304
+ t.debuggerStatement(),
305
+ t.expressionStatement(
306
+ t.callExpression(
307
+ t.memberExpression(
308
+ t.identifier("console"),
309
+ t.identifier("log"),
310
+ ),
311
+ expr.node.arguments,
312
+ ),
313
+ ),
314
+ ]),
315
+ ),
316
+ );
317
+ }
318
+ }
319
+ },
320
+ CallExpression(path) {
321
+ if (isSSR) return;
322
+ const callee = path.get("callee");
323
+
324
+ // Helper: wrap signal identifiers in call expressions
325
+ const wrapSignal = (expr: t.Expression): t.Expression => {
326
+ if (t.isIdentifier(expr) && signals.has(expr.name)) {
327
+ return t.callExpression(expr, []);
328
+ }
329
+ return expr;
330
+ };
331
+
332
+ // Handle member expression callees: $state.snapshot(x), $effect.tracking(), $effect.pre(fn)
333
+ if (callee.isMemberExpression()) {
334
+ const object = callee.get("object");
335
+ const property = callee.get("property");
336
+ if (object.isIdentifier() && property.isIdentifier()) {
337
+ const obj = object.node.name;
338
+ const prop = property.node.name;
339
+
340
+ if (obj === "$state" && prop === "snapshot") {
341
+ didTransform = true;
342
+ path.get("callee").replaceWith(t.identifier(MAGIC.snapshot));
343
+ return;
344
+ }
345
+
346
+ if (obj === "$effect" && prop === "tracking") {
347
+ didTransform = true;
348
+ path.get("callee").replaceWith(t.identifier(MAGIC.tracking));
349
+ return;
350
+ }
351
+
352
+ if (obj === "$effect" && prop === "pre") {
353
+ didTransform = true;
354
+ path
355
+ .get("callee")
356
+ .replaceWith(t.identifier(MAGIC.createEffectPre));
357
+ return;
358
+ }
359
+ }
360
+ }
361
+
362
+ // Handle $inspect(count).with(cb) chain
363
+ if (callee.isMemberExpression()) {
364
+ const property = callee.get("property");
365
+ if (property.isIdentifier({ name: "with" })) {
366
+ const object = callee.get("object");
367
+ if (object.isCallExpression()) {
368
+ const innerCallee = object.get("callee");
369
+ if (innerCallee.isIdentifier({ name: "$inspect" })) {
370
+ didTransform = true;
371
+ //@ts-expect-error Bizzare but it works if you read LSP
372
+ const args = object.node.arguments.map(wrapSignal);
373
+ const cb = path.node.arguments[0];
374
+ path.replaceWith(
375
+ t.callExpression(t.identifier(MAGIC.createInspectEffect), [
376
+ t.arrowFunctionExpression([], t.arrayExpression(args)),
377
+ //@ts-expect-error Bizzare but it works if you read LSP
378
+ cb,
379
+ ]),
380
+ );
381
+ return;
382
+ }
383
+ }
384
+ }
385
+ }
386
+
387
+ // Handle standalone $debug(...) — not as expression statement
388
+ if (callee.isIdentifier({ name: "$debug" })) {
389
+ didTransform = true;
390
+ path.replaceWith(
391
+ t.ifStatement(
392
+ t.memberExpression(
393
+ t.memberExpression(
394
+ t.memberExpression(
395
+ t.identifier("import"),
396
+ t.identifier("meta"),
397
+ ),
398
+ t.identifier("env"),
399
+ ),
400
+ t.identifier("DEV"),
401
+ ),
402
+ t.blockStatement([
403
+ t.debuggerStatement(),
404
+ t.expressionStatement(
405
+ t.callExpression(
406
+ t.memberExpression(
407
+ t.identifier("console"),
408
+ t.identifier("log"),
409
+ ),
410
+ path.node.arguments,
411
+ ),
412
+ ),
413
+ ]),
414
+ ),
415
+ );
416
+ return;
417
+ }
418
+
419
+ // Handle standalone $inspect(...)
420
+ if (callee.isIdentifier({ name: "$inspect" })) {
421
+ didTransform = true;
422
+ //@ts-expect-error Bizzare but it works if you read LSP
423
+ const args = path.node.arguments.map(wrapSignal);
424
+ path.replaceWith(
425
+ t.callExpression(t.identifier(MAGIC.createInspectEffect), [
426
+ t.arrowFunctionExpression([], t.arrayExpression(args)),
427
+ ]),
428
+ );
429
+ return;
430
+ }
431
+ },
432
+ JSXElement(path) {
433
+ didTransform = true;
434
+ // console.log(
435
+ // "[sigil] JSXElement visitor fired, parent:",
436
+ // path.parentPath?.type,
437
+ // "isSSR:", isSSR
438
+ // );
439
+ if (path.parentPath?.isJSXElement() || path.parentPath?.isJSXFragment())
440
+ return;
441
+ if (isSSR) {
442
+ try {
443
+ convertKeyToDataKey(path.node);
444
+ path.replaceWith(
445
+ t.callExpression(t.identifier("__h"), [
446
+ processElementSSR(path.node, signals),
447
+ ]),
448
+ );
449
+ } catch (e) {
450
+ console.error("[sigil] processElementSSR threw:", path.toString());
451
+ console.error(e);
452
+ throw e;
453
+ }
454
+ return;
455
+ }
456
+
457
+ const statements: t.Statement[] = [];
458
+ const genId = (() => {
459
+ let i = 0;
460
+ return () => `_el${i++}`;
461
+ })();
462
+
463
+ const root = processElement(
464
+ path.node,
465
+ statements,
466
+ genId,
467
+ signals,
468
+ scopedHash,
469
+ isHydrate,
470
+ isHydrate ? "__nodes" : undefined,
471
+ );
472
+
473
+ const body: t.Statement[] = [];
474
+
475
+ if (isHydrate) {
476
+ // DECLARE __nodes locally — every IIFE owns its own context
477
+ body.push(
478
+ t.variableDeclaration("const", [
479
+ t.variableDeclarator(
480
+ t.identifier("__nodes"),
481
+ t.callExpression(t.identifier("getHydrationNodes"), []),
482
+ ),
483
+ ]),
484
+ );
485
+ }
486
+
487
+ body.push(...statements);
488
+ body.push(t.returnStatement(t.identifier(root)));
489
+
490
+ path.replaceWith(
491
+ t.callExpression(
492
+ t.arrowFunctionExpression([], t.blockStatement(body)),
493
+ [],
494
+ ),
495
+ );
496
+ },
497
+ JSXFragment(path) {
498
+ didTransform = true;
499
+ if (path.parentPath?.isJSXElement() || path.parentPath?.isJSXFragment())
500
+ return;
501
+ if (isSSR) {
502
+ path.replaceWith(
503
+ t.callExpression(t.identifier("__h"), [
504
+ processFragmentSSR(path.node, signals),
505
+ ]),
506
+ );
507
+ return;
508
+ }
509
+
510
+ const statements: t.Statement[] = [];
511
+ const genId = (() => {
512
+ let i = 0;
513
+ return () => `_el${i++}`;
514
+ })();
515
+
516
+ const root = processFragment(
517
+ path.node,
518
+ statements,
519
+ genId,
520
+ signals,
521
+ scopedHash,
522
+ isHydrate,
523
+ isHydrate ? "__nodes" : undefined,
524
+ );
525
+
526
+ const body: t.Statement[] = [];
527
+
528
+ if (isHydrate) {
529
+ // DECLARE __nodes locally — every IIFE owns its own context
530
+ body.push(
531
+ t.variableDeclaration("const", [
532
+ t.variableDeclarator(
533
+ t.identifier("__nodes"),
534
+ t.callExpression(t.identifier("getHydrationNodes"), []),
535
+ ),
536
+ ]),
537
+ );
538
+ }
539
+
540
+ body.push(...statements);
541
+ body.push(t.returnStatement(t.identifier(root)));
542
+
543
+ path.replaceWith(
544
+ t.callExpression(
545
+ t.arrowFunctionExpression([], t.blockStatement(body)),
546
+ [],
547
+ ),
548
+ );
549
+ },
550
+ FunctionDeclaration(path) {
551
+ if (isSSR) return;
552
+ const node = path.node;
553
+ if (node.async && node.id) {
554
+ asyncFunctions.set(node.id.name, path);
555
+ }
556
+ },
557
+
558
+ ExportDefaultDeclaration(path) {
559
+ const decl = path.get("declaration");
560
+
561
+ // export default async function Page() {}
562
+ if (
563
+ (decl.isFunctionDeclaration() || decl.isFunctionExpression()) &&
564
+ (decl.node as any).async &&
565
+ !isSSR
566
+ ) {
567
+ asyncDefaultPath = path;
568
+ return;
569
+ }
570
+
571
+ // export default Page
572
+ if (decl.isIdentifier() && !isSSR) {
573
+ const name = (decl.node as t.Identifier).name;
574
+ if (asyncFunctions.has(name)) {
575
+ asyncDefaultPath = asyncFunctions.get(name);
576
+ _asyncDefaultRef = name;
577
+ }
578
+ }
579
+ },
580
+
581
+ ExportNamedDeclaration(path) {
582
+ const decl = path.get("declaration");
583
+ if (decl.isFunctionDeclaration()) {
584
+ const name = (decl.node as t.FunctionDeclaration).id?.name;
585
+ if (name === "loading") hasLoadingExport = true;
586
+ if (name === "error") hasErrorExport = true;
587
+ }
588
+ if (decl.isVariableDeclaration()) {
589
+ for (const d of decl.node.declarations) {
590
+ if (t.isIdentifier(d.id)) {
591
+ if (d.id.name === "loading") hasLoadingExport = true;
592
+ if (d.id.name === "error") hasErrorExport = true;
593
+ }
594
+ }
595
+ }
596
+ },
597
+ },
598
+ };
599
+ }