preact-alchemy 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Alec Larson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,551 @@
1
+ // src/transform.ts
2
+ import { parse } from "meriyah";
3
+ import { walk } from "zimmerframe";
4
+ import MagicString from "magic-string";
5
+ var Scope = class {
6
+ parent;
7
+ bindings = /* @__PURE__ */ new Map();
8
+ constructor(parent) {
9
+ this.parent = parent;
10
+ }
11
+ add(name, reactive) {
12
+ this.bindings.set(name, { reactive });
13
+ }
14
+ resolve(name) {
15
+ for (let scope = this; scope; scope = scope.parent) {
16
+ const binding = scope.bindings.get(name);
17
+ if (binding) return binding;
18
+ }
19
+ return null;
20
+ }
21
+ };
22
+ var SIGNAL_BASE = "__alchemy_signal";
23
+ var visitors = {
24
+ BlockStatement(node, context) {
25
+ const { state, next } = context;
26
+ const blockScope = new Scope(state.scope);
27
+ collectBlockBindings(
28
+ node.body,
29
+ blockScope,
30
+ state.functionScope,
31
+ isReactiveScope(state)
32
+ );
33
+ next({ ...state, scope: blockScope });
34
+ },
35
+ SwitchStatement(node, context) {
36
+ const { state, next } = context;
37
+ const switchScope = new Scope(state.scope);
38
+ collectSwitchBindings(
39
+ node.cases,
40
+ switchScope,
41
+ state.functionScope,
42
+ isReactiveScope(state)
43
+ );
44
+ next({ ...state, scope: switchScope });
45
+ },
46
+ ForStatement: enterLoop,
47
+ ForInStatement: enterLoop,
48
+ ForOfStatement: enterLoop,
49
+ WhileStatement: enterLoop,
50
+ DoWhileStatement: enterLoop,
51
+ FunctionDeclaration: enterFunction,
52
+ FunctionExpression: enterFunction,
53
+ ArrowFunctionExpression: enterFunction,
54
+ CatchClause(node, context) {
55
+ const { state, next } = context;
56
+ const catchScope = new Scope(state.scope);
57
+ if (node.param) {
58
+ const { names } = collectPatternIdentifiers(node.param);
59
+ for (const name of names) {
60
+ catchScope.add(name, false);
61
+ }
62
+ }
63
+ next({ ...state, scope: catchScope });
64
+ },
65
+ VariableDeclaration: handleVariableDeclaration,
66
+ Property: handleProperty,
67
+ Identifier: handleIdentifier
68
+ };
69
+ function transform(code, id) {
70
+ const cleanId = id ? id.split("?")[0] : void 0;
71
+ const isTypeScriptFile = !!cleanId && /\.[cm]?tsx?$/.test(cleanId);
72
+ let ast;
73
+ try {
74
+ ast = parse(code, {
75
+ sourceType: "module",
76
+ jsx: true,
77
+ ranges: true
78
+ });
79
+ } catch (error) {
80
+ if (isTypeScriptFile) {
81
+ warn(cleanId, "TypeScript syntax is not supported; skipping file");
82
+ } else {
83
+ warn(
84
+ cleanId,
85
+ `Failed to parse${cleanId ? ` ${cleanId}` : ""}; skipping file`
86
+ );
87
+ }
88
+ return { code };
89
+ }
90
+ const alchemicalFunctions = findAlchemicalFunctions(ast);
91
+ if (alchemicalFunctions.length === 0) return { code };
92
+ const s = new MagicString(code);
93
+ const usedNames = collectAllBindingNames(ast);
94
+ const signalAlias = uniqueAlias(SIGNAL_BASE, usedNames);
95
+ const alchemicalSet = new Set(alchemicalFunctions);
96
+ let usedSignal = false;
97
+ const markSignalUsed = () => {
98
+ usedSignal = true;
99
+ };
100
+ for (const fn of alchemicalFunctions) {
101
+ const directive = getDirectiveStatement(fn);
102
+ if (directive && hasRange(directive)) {
103
+ s.remove(directive.start, directive.end);
104
+ }
105
+ transformAlchemicalFunction(fn, {
106
+ scope: new Scope(),
107
+ functionScope: new Scope(),
108
+ functionDepth: 0,
109
+ loopDepth: 0,
110
+ signalAlias,
111
+ s,
112
+ code,
113
+ alchemicalSet,
114
+ markSignalUsed,
115
+ warn: (message) => warn(cleanId, message)
116
+ });
117
+ }
118
+ if (usedSignal) {
119
+ injectSignalImport(s, code, signalAlias);
120
+ }
121
+ if (!s.hasChanged()) return { code };
122
+ return {
123
+ code: s.toString(),
124
+ map: s.generateMap({ hires: true, source: cleanId })
125
+ };
126
+ }
127
+ function enterLoop(node, context) {
128
+ const { state, next } = context;
129
+ const loopScope = new Scope(state.scope);
130
+ const loopState = {
131
+ ...state,
132
+ scope: loopScope,
133
+ loopDepth: state.loopDepth + 1
134
+ };
135
+ if (node.type === "ForStatement" || node.type === "ForInStatement" || node.type === "ForOfStatement") {
136
+ collectLoopBindings(
137
+ node,
138
+ loopScope,
139
+ state.functionScope,
140
+ isReactiveScope(loopState)
141
+ );
142
+ }
143
+ next(loopState);
144
+ }
145
+ function enterFunction(node, context) {
146
+ const { state, next } = context;
147
+ if (state.alchemicalSet.has(node)) return;
148
+ const functionScope = new Scope(state.scope);
149
+ addFunctionBindings(node, functionScope);
150
+ next({
151
+ ...state,
152
+ scope: functionScope,
153
+ functionScope,
154
+ functionDepth: state.functionDepth + 1,
155
+ loopDepth: 0
156
+ });
157
+ }
158
+ function handleVariableDeclaration(node, context) {
159
+ const { state, next, path } = context;
160
+ if (node.kind !== "let" || !isReactiveScope(state)) {
161
+ next();
162
+ return;
163
+ }
164
+ const assignments = [];
165
+ let hasAssignmentInsert = false;
166
+ for (const declarator of node.declarations) {
167
+ const id = declarator.id;
168
+ const init = declarator.init;
169
+ if (id.type === "Identifier") {
170
+ if (init && hasRange(init)) {
171
+ state.s.appendLeft(init.start, `${state.signalAlias}(`);
172
+ state.s.appendRight(init.end, `)`);
173
+ } else if (hasRange(id)) {
174
+ state.s.appendRight(id.end, ` = ${state.signalAlias}(undefined)`);
175
+ }
176
+ state.markSignalUsed();
177
+ } else {
178
+ const { names, supported } = collectPatternIdentifiers(id);
179
+ if (!supported) {
180
+ state.warn(`Unsupported destructuring pattern; skipping transform`);
181
+ continue;
182
+ }
183
+ for (const name of names) {
184
+ assignments.push(`${name} = ${state.signalAlias}(${name});`);
185
+ }
186
+ if (names.length > 0) {
187
+ state.markSignalUsed();
188
+ hasAssignmentInsert = true;
189
+ }
190
+ }
191
+ }
192
+ const parent = path[path.length - 1];
193
+ if (hasAssignmentInsert && !isForParent(parent) && hasRange(node)) {
194
+ const indent = getIndent(state.code, node.start);
195
+ state.s.appendRight(
196
+ node.end,
197
+ `
198
+ ${indent}${assignments.join(`
199
+ ${indent}`)}`
200
+ );
201
+ }
202
+ next();
203
+ }
204
+ function handleProperty(node, context) {
205
+ const { state, next, path } = context;
206
+ const parent = path[path.length - 1];
207
+ if (!parent || parent.type !== "ObjectExpression") {
208
+ next();
209
+ return;
210
+ }
211
+ if (node.kind !== "init" || !node.shorthand) {
212
+ next();
213
+ return;
214
+ }
215
+ const key = node.key;
216
+ const value = node.value;
217
+ if (key.type !== "Identifier" || value.type !== "Identifier") return;
218
+ const binding = state.scope.resolve(value.name);
219
+ if (!binding || !binding.reactive) return;
220
+ if (!hasRange(node)) return;
221
+ if (isReactiveScope(state)) {
222
+ state.s.overwrite(
223
+ node.start,
224
+ node.end,
225
+ `get ${value.name}() { return ${value.name}.value; }`
226
+ );
227
+ } else {
228
+ state.s.overwrite(
229
+ node.start,
230
+ node.end,
231
+ `${value.name}: ${value.name}.value`
232
+ );
233
+ }
234
+ }
235
+ function handleIdentifier(node, context) {
236
+ const { state, path } = context;
237
+ const parent = path[path.length - 1];
238
+ if (!parent) return;
239
+ if (!isReferenceIdentifier(node, parent, path)) return;
240
+ const binding = state.scope.resolve(node.name);
241
+ if (!binding || !binding.reactive) return;
242
+ if (parent.type === "MemberExpression" && parent.object === node) {
243
+ if (!parent.computed && parent.property.type === "Identifier" && parent.property.name === "value") {
244
+ return;
245
+ }
246
+ }
247
+ if (!hasRange(node)) return;
248
+ state.s.appendLeft(node.end, ".value");
249
+ }
250
+ function findAlchemicalFunctions(ast) {
251
+ const functions = [];
252
+ const maybeAdd = (node) => {
253
+ if (!node.body || node.body.type !== "BlockStatement") return;
254
+ const directive = getDirectiveStatement(node);
255
+ if (directive) functions.push(node);
256
+ };
257
+ walk(ast, null, {
258
+ FunctionDeclaration(node) {
259
+ maybeAdd(node);
260
+ },
261
+ FunctionExpression(node) {
262
+ maybeAdd(node);
263
+ },
264
+ ArrowFunctionExpression(node) {
265
+ maybeAdd(node);
266
+ }
267
+ });
268
+ return functions;
269
+ }
270
+ function getDirectiveStatement(fn) {
271
+ if (!fn.body || fn.body.type !== "BlockStatement") return null;
272
+ const first = fn.body.body[0];
273
+ if (!first || first.type !== "ExpressionStatement") return null;
274
+ const expression = first.expression;
275
+ if (!expression || expression.type !== "Literal") return null;
276
+ if (expression.value !== "use alchemy") return null;
277
+ return first;
278
+ }
279
+ function transformAlchemicalFunction(fn, state) {
280
+ if (!fn.body || fn.body.type !== "BlockStatement") return;
281
+ const functionScope = new Scope(state.scope);
282
+ const bodyScope = new Scope(functionScope);
283
+ addFunctionBindings(fn, functionScope);
284
+ const rootState = {
285
+ ...state,
286
+ scope: bodyScope,
287
+ functionScope,
288
+ functionDepth: 0,
289
+ loopDepth: 0
290
+ };
291
+ collectBlockBindings(
292
+ fn.body.body,
293
+ bodyScope,
294
+ functionScope,
295
+ isReactiveScope(rootState)
296
+ );
297
+ for (const statement of fn.body.body) {
298
+ walk(statement, rootState, visitors);
299
+ }
300
+ }
301
+ function isForParent(node) {
302
+ if (!node) return false;
303
+ return node.type === "ForStatement" || node.type === "ForInStatement" || node.type === "ForOfStatement";
304
+ }
305
+ function isReferenceIdentifier(node, parent, path) {
306
+ const grandparent = path[path.length - 2];
307
+ if (parent.type === "VariableDeclarator" && parent.id === node) return false;
308
+ if ((parent.type === "FunctionDeclaration" || parent.type === "FunctionExpression") && parent.id === node) {
309
+ return false;
310
+ }
311
+ if (parent.type === "ClassDeclaration" && parent.id === node) return false;
312
+ if (parent.type === "ClassExpression" && parent.id === node) return false;
313
+ if (parent.type === "ImportSpecifier" || parent.type === "ImportDefaultSpecifier" || parent.type === "ImportNamespaceSpecifier") {
314
+ return false;
315
+ }
316
+ if (parent.type === "ExportSpecifier") return false;
317
+ if (parent.type === "Property" && parent.shorthand && parent.value === node)
318
+ return false;
319
+ if (parent.type === "Property" && parent.key === node && !parent.computed)
320
+ return false;
321
+ if (parent.type === "PropertyDefinition" && parent.key === node && !parent.computed)
322
+ return false;
323
+ if (parent.type === "MemberExpression" && parent.property === node && !parent.computed)
324
+ return false;
325
+ if (parent.type === "MethodDefinition" && parent.key === node && !parent.computed)
326
+ return false;
327
+ if (parent.type === "LabeledStatement" && parent.label === node) return false;
328
+ if ((parent.type === "BreakStatement" || parent.type === "ContinueStatement") && parent.label === node) {
329
+ return false;
330
+ }
331
+ if (parent.type === "CatchClause" && parent.param === node) return false;
332
+ if (parent.type === "AssignmentPattern" && parent.left === node) return false;
333
+ if (parent.type === "RestElement" && parent.argument === node) return false;
334
+ if (parent.type === "ArrayPattern" || parent.type === "ObjectPattern")
335
+ return false;
336
+ if (parent.type === "Property" && grandparent?.type === "ObjectPattern") {
337
+ if (parent.value === node) return false;
338
+ if (parent.key === node && !parent.computed) return false;
339
+ }
340
+ if (parent.type === "FunctionExpression" || parent.type === "ArrowFunctionExpression" || parent.type === "FunctionDeclaration") {
341
+ const params = parent.params ?? [];
342
+ if (params.includes(node)) return false;
343
+ }
344
+ return true;
345
+ }
346
+ function collectAllBindingNames(ast) {
347
+ const names = /* @__PURE__ */ new Set();
348
+ const addPattern = (pattern) => {
349
+ const { names: collected } = collectPatternIdentifiers(pattern);
350
+ for (const name of collected) names.add(name);
351
+ };
352
+ walk(ast, null, {
353
+ VariableDeclarator(node) {
354
+ addPattern(node.id);
355
+ },
356
+ FunctionDeclaration(node) {
357
+ if (node.id) names.add(node.id.name);
358
+ for (const param of node.params ?? []) addPattern(param);
359
+ },
360
+ FunctionExpression(node) {
361
+ if (node.id) names.add(node.id.name);
362
+ for (const param of node.params ?? []) addPattern(param);
363
+ },
364
+ ArrowFunctionExpression(node) {
365
+ for (const param of node.params ?? []) addPattern(param);
366
+ },
367
+ ClassDeclaration(node) {
368
+ if (node.id) names.add(node.id.name);
369
+ },
370
+ ImportSpecifier(node) {
371
+ if (node.local) names.add(node.local.name);
372
+ },
373
+ ImportDefaultSpecifier(node) {
374
+ if (node.local) names.add(node.local.name);
375
+ },
376
+ ImportNamespaceSpecifier(node) {
377
+ if (node.local) names.add(node.local.name);
378
+ },
379
+ CatchClause(node) {
380
+ if (node.param) addPattern(node.param);
381
+ }
382
+ });
383
+ return names;
384
+ }
385
+ function collectBlockBindings(statements, scope, functionScope, reactiveAllowed) {
386
+ for (const statement of statements) {
387
+ collectBindingsInStatement(statement, scope, functionScope, reactiveAllowed);
388
+ }
389
+ }
390
+ function collectSwitchBindings(cases, scope, functionScope, reactiveAllowed) {
391
+ for (const switchCase of cases) {
392
+ for (const statement of switchCase.consequent) {
393
+ collectBindingsInStatement(
394
+ statement,
395
+ scope,
396
+ functionScope,
397
+ reactiveAllowed
398
+ );
399
+ }
400
+ }
401
+ }
402
+ function collectLoopBindings(node, scope, functionScope, reactiveAllowed) {
403
+ const declaration = node.type === "ForStatement" ? node.init : node.left;
404
+ if (declaration && declaration.type === "VariableDeclaration") {
405
+ collectBindingsFromDeclaration(
406
+ declaration,
407
+ scope,
408
+ functionScope,
409
+ reactiveAllowed
410
+ );
411
+ }
412
+ }
413
+ function collectBindingsInStatement(statement, scope, functionScope, reactiveAllowed) {
414
+ if (statement.type === "VariableDeclaration") {
415
+ collectBindingsFromDeclaration(
416
+ statement,
417
+ scope,
418
+ functionScope,
419
+ reactiveAllowed
420
+ );
421
+ return;
422
+ }
423
+ if (statement.type === "FunctionDeclaration") {
424
+ if (statement.id) scope.add(statement.id.name, false);
425
+ return;
426
+ }
427
+ if (statement.type === "ClassDeclaration") {
428
+ if (statement.id) scope.add(statement.id.name, false);
429
+ return;
430
+ }
431
+ if (statement.type === "ExportNamedDeclaration" && statement.declaration) {
432
+ collectBindingsInStatement(
433
+ statement.declaration,
434
+ scope,
435
+ functionScope,
436
+ reactiveAllowed
437
+ );
438
+ }
439
+ if (statement.type === "ExportDefaultDeclaration" && statement.declaration) {
440
+ const declaration = statement.declaration;
441
+ if ((declaration.type === "FunctionDeclaration" || declaration.type === "ClassDeclaration") && declaration.id) {
442
+ scope.add(declaration.id.name, false);
443
+ }
444
+ }
445
+ }
446
+ function collectBindingsFromDeclaration(declaration, scope, functionScope, reactiveAllowed) {
447
+ const isVar = declaration.kind === "var";
448
+ const isLet = declaration.kind === "let";
449
+ for (const declarator of declaration.declarations) {
450
+ const { names, supported } = collectPatternIdentifiers(declarator.id);
451
+ const reactive = isLet && reactiveAllowed && supported;
452
+ const targetScope = isVar ? functionScope : scope;
453
+ for (const name of names) {
454
+ targetScope.add(name, reactive);
455
+ }
456
+ }
457
+ }
458
+ function addFunctionBindings(node, scope) {
459
+ if (node.type !== "ArrowFunctionExpression" && node.id) {
460
+ scope.add(node.id.name, false);
461
+ }
462
+ for (const param of node.params ?? []) {
463
+ const { names } = collectPatternIdentifiers(param);
464
+ for (const name of names) {
465
+ scope.add(name, false);
466
+ }
467
+ }
468
+ }
469
+ function collectPatternIdentifiers(pattern) {
470
+ const names = [];
471
+ let supported = true;
472
+ const visit = (node) => {
473
+ if (!node) return;
474
+ switch (node.type) {
475
+ case "Identifier":
476
+ names.push(node.name);
477
+ return;
478
+ case "ObjectPattern":
479
+ for (const property of node.properties ?? []) {
480
+ if (property.type === "Property") {
481
+ visit(property.value);
482
+ } else if (property.type === "RestElement") {
483
+ visit(property.argument);
484
+ } else {
485
+ supported = false;
486
+ }
487
+ }
488
+ return;
489
+ case "ArrayPattern":
490
+ for (const element of node.elements ?? []) {
491
+ if (element) visit(element);
492
+ }
493
+ return;
494
+ case "AssignmentPattern":
495
+ visit(node.left);
496
+ return;
497
+ case "RestElement":
498
+ visit(node.argument);
499
+ return;
500
+ default:
501
+ supported = false;
502
+ }
503
+ };
504
+ visit(pattern);
505
+ return { names, supported };
506
+ }
507
+ function uniqueAlias(base, used) {
508
+ if (!used.has(base)) return base;
509
+ let i = 1;
510
+ while (used.has(`${base}_${i}`)) i += 1;
511
+ return `${base}_${i}`;
512
+ }
513
+ function injectSignalImport(s, code, alias) {
514
+ const statement = `import { signal as ${alias} } from "@preact/signals";
515
+ `;
516
+ if (code.startsWith("#!")) {
517
+ const lineEnd = code.indexOf("\n");
518
+ if (lineEnd === -1) {
519
+ s.appendRight(code.length, `
520
+ ${statement}`);
521
+ } else {
522
+ s.appendLeft(lineEnd + 1, statement);
523
+ }
524
+ return;
525
+ }
526
+ s.prepend(statement);
527
+ }
528
+ function getIndent(code, pos) {
529
+ let i = pos;
530
+ while (i > 0 && code[i - 1] !== "\n" && code[i - 1] !== "\r") i -= 1;
531
+ let indent = "";
532
+ while (i < code.length && (code[i] === " " || code[i] === " ")) {
533
+ indent += code[i];
534
+ i += 1;
535
+ }
536
+ return indent;
537
+ }
538
+ function isReactiveScope(state) {
539
+ return state.functionDepth === 0 && state.loopDepth === 0;
540
+ }
541
+ function hasRange(node) {
542
+ return !!node && typeof node.start === "number" && typeof node.end === "number";
543
+ }
544
+ function warn(id, message) {
545
+ const prefix = id ? `[preact-alchemy] ${id}:` : "[preact-alchemy]";
546
+ console.warn(`${prefix} ${message}`);
547
+ }
548
+
549
+ export {
550
+ transform
551
+ };
@@ -0,0 +1,9 @@
1
+ import { SourceMap } from 'magic-string';
2
+
3
+ type TransformResult = {
4
+ code: string;
5
+ map?: SourceMap;
6
+ };
7
+ declare function transform(code: string, id?: string): TransformResult;
8
+
9
+ export { transform };
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ import {
2
+ transform
3
+ } from "./chunk-JSNOUUYJ.js";
4
+ export {
5
+ transform
6
+ };
package/dist/vite.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { Plugin } from 'vite';
2
+
3
+ declare function preactAlchemy(): Plugin;
4
+
5
+ export { preactAlchemy as default };
package/dist/vite.js ADDED
@@ -0,0 +1,25 @@
1
+ import {
2
+ transform
3
+ } from "./chunk-JSNOUUYJ.js";
4
+
5
+ // src/vite.ts
6
+ var defaultInclude = /\.(?:mjs|cjs|js|jsx|mts|cts|ts|tsx)$/;
7
+ function preactAlchemy() {
8
+ return {
9
+ name: "preact-alchemy",
10
+ enforce: "post",
11
+ transform(code, id) {
12
+ if (id.startsWith("\0")) return null;
13
+ const cleanId = id.split("?")[0];
14
+ if (!defaultInclude.test(cleanId)) return null;
15
+ if (cleanId.includes("node_modules")) return null;
16
+ if (!code.includes("use alchemy")) return null;
17
+ const result = transform(code, cleanId);
18
+ if (result.code === code) return null;
19
+ return { code: result.code, map: result.map ?? null };
20
+ }
21
+ };
22
+ }
23
+ export {
24
+ preactAlchemy as default
25
+ };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "preact-alchemy",
3
+ "type": "module",
4
+ "version": "1.0.0",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./dist/index.d.ts",
8
+ "default": "./dist/index.js"
9
+ },
10
+ "./vite": {
11
+ "types": "./dist/vite.d.ts",
12
+ "default": "./dist/vite.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "license": "MIT",
19
+ "author": "Alec Larson",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/aleclarson/preact-alchemy.git"
23
+ },
24
+ "prettier": "@alloc/prettier-config",
25
+ "dependencies": {
26
+ "magic-string": "^0.30.21",
27
+ "meriyah": "^7.0.0",
28
+ "zimmerframe": "^1.1.4"
29
+ },
30
+ "peerDependencies": {
31
+ "@preact/signals": "*",
32
+ "preact": "*"
33
+ },
34
+ "devDependencies": {
35
+ "@alloc/prettier-config": "^1.0.0",
36
+ "@preact/signals": "^2.5.1",
37
+ "@types/node": "^25.0.3",
38
+ "preact": "^10.28.0",
39
+ "prettier": "^3.7.4",
40
+ "radashi": "^12.7.1",
41
+ "rimraf": "^6.1.2",
42
+ "tsc-lint": "^0.1.9",
43
+ "tsup": "^8.5.1",
44
+ "typescript": "^5.9.3",
45
+ "vite": "^7.3.0",
46
+ "vitest": "^4.0.16"
47
+ },
48
+ "scripts": {
49
+ "dev": "rimraf dist && tsup --sourcemap --watch",
50
+ "build": "rimraf dist && tsup",
51
+ "format": "prettier --write .",
52
+ "lint": "tsc-lint",
53
+ "test": "vitest"
54
+ }
55
+ }
package/readme.md ADDED
@@ -0,0 +1,153 @@
1
+ # preact-alchemy
2
+
3
+ A tiny transform that turns `let` bindings into @preact/signals when you opt in with a "use alchemy" directive.
4
+ It lets you write normal JS and get reactive updates without manual `.value` plumbing.
5
+
6
+ ## Install
7
+
8
+ ```sh
9
+ pnpm add preact-alchemy
10
+ ```
11
+
12
+ ## Quick start
13
+
14
+ Add the directive as the first statement inside a function body:
15
+
16
+ ```js
17
+ function counter() {
18
+ 'use alchemy'
19
+ let count = 0
20
+ count += 1
21
+ return count
22
+ }
23
+ ```
24
+
25
+ This is rewritten to:
26
+
27
+ ```js
28
+ import { signal as __alchemy_signal } from '@preact/signals'
29
+ function counter() {
30
+ let count = __alchemy_signal(0)
31
+ count.value += 1
32
+ return count.value
33
+ }
34
+ ```
35
+
36
+ ## How it works
37
+
38
+ When a function body starts with the string literal directive `"use alchemy"`, the transform:
39
+
40
+ - Rewrites `let` declarations in that function into signals.
41
+ - Rewrites reads/writes of those bindings to use `.value`.
42
+ - Projects object literal shorthands that reference reactive bindings.
43
+ - Injects `import { signal as __alchemy_signal } from "@preact/signals";` when needed.
44
+
45
+ ### Scope rules
46
+
47
+ Only `let` **declarations** are restricted: `let` declarations inside loops or nested functions are
48
+ _not_ converted into signals. Reads and writes of a reactive binding remain reactive anywhere it is
49
+ in scope, including inside loops and nested functions.
50
+
51
+ ```js
52
+ function demo() {
53
+ 'use alchemy'
54
+ let count = 0 // becomes a signal
55
+
56
+ for (let i = 0; i < 2; i++) {
57
+ count++ // reactive read/write
58
+ let local = 0 // NOT converted
59
+ }
60
+
61
+ function inner() {
62
+ count++ // reactive read/write
63
+ let local = 0 // NOT converted
64
+ }
65
+ }
66
+ ```
67
+
68
+ ### Object literal projection
69
+
70
+ Shorthand properties referencing a reactive binding are rewritten so you don’t leak signals by accident:
71
+
72
+ ```js
73
+ return { count }
74
+ ```
75
+
76
+ - At the top level: becomes a getter so reads stay reactive.
77
+ - Inside loops/nested functions: becomes an eager value.
78
+
79
+ ```js
80
+ return {
81
+ get count() {
82
+ return count.value
83
+ },
84
+ }
85
+ // or
86
+ return { count: count.value }
87
+ ```
88
+
89
+ ### Destructuring
90
+
91
+ Destructuring `let` declarations are supported. Each extracted name becomes a signal:
92
+
93
+ ```js
94
+ let { a, b } = obj
95
+ // ->
96
+ let { a, b } = obj
97
+ a = __alchemy_signal(a)
98
+ b = __alchemy_signal(b)
99
+ ```
100
+
101
+ ## Vite plugin
102
+
103
+ Use the built-in Vite plugin for zero-config integration:
104
+
105
+ ```ts
106
+ // vite.config.ts
107
+ import { defineConfig } from 'vite'
108
+ import preactAlchemy from 'preact-alchemy/vite'
109
+
110
+ export default defineConfig({
111
+ plugins: [preactAlchemy()],
112
+ })
113
+ ```
114
+
115
+ The plugin:
116
+
117
+ - Runs on `.js/.jsx/.mjs/.cjs/.ts/.tsx/.mts/.cts` files, after Vite compiles
118
+ TypeScript to JavaScript.
119
+ - Skips files in `node_modules`.
120
+ - Only transforms files that include the `"use alchemy"` directive.
121
+
122
+ ## Programmatic API
123
+
124
+ ```ts
125
+ import { transform } from 'preact-alchemy'
126
+
127
+ const result = transform(code, id)
128
+ console.log(result.code)
129
+ console.log(result.map) // Source map if changes were made
130
+ ```
131
+
132
+ ### `transform(code, id?)`
133
+
134
+ - `code`: string input source.
135
+ - `id`: optional file name for warnings and source maps.
136
+ - Returns `{ code, map? }`.
137
+
138
+ ## Limitations
139
+
140
+ - JavaScript + JSX only. TypeScript/Flow syntax is **not** supported and is
141
+ skipped with a warning. TypeScript is supported only after compilation to
142
+ JavaScript (for example, via the Vite plugin).
143
+ - Only `let` declarations are converted to signals. `const` and `var` are left untouched.
144
+ - The directive must be the **first statement** in the function body.
145
+
146
+ ## Tips
147
+
148
+ - You can keep normal JS semantics and sprinkle reactivity only where needed.
149
+ - If you already use a `__alchemy_signal` variable, the import is auto-aliased.
150
+
151
+ ## License
152
+
153
+ MIT