@pyreon/compiler 0.5.5 → 0.5.7

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.
@@ -1,1253 +1,148 @@
1
- import ts from "typescript";
2
- import * as fs from "node:fs";
3
- import * as path from "node:path";
4
-
5
- //#region src/jsx.ts
1
+ //#region src/jsx.d.ts
6
2
  /**
7
- * JSX transform — wraps dynamic JSX expressions in `() =>` so the Pyreon runtime
8
- * receives reactive getters instead of eagerly-evaluated snapshot values.
9
- *
10
- * Rules:
11
- * - `<div>{expr}</div>` → `<div>{() => expr}</div>` (child)
12
- * - `<div class={expr}>` → `<div class={() => expr}>` (prop)
13
- * - `<button onClick={fn}>` → unchanged (event handler)
14
- * - `<div>{() => expr}</div>` → unchanged (already wrapped)
15
- * - `<div>{"literal"}</div>` → unchanged (static)
16
- *
17
- * Static VNode hoisting:
18
- * - Fully static JSX in expression containers is hoisted to module scope:
19
- * `{<span>Hello</span>}` → `const _$h0 = <span>Hello</span>` + `{_$h0}`
20
- * - Hoisted nodes are created ONCE at module initialisation, not per-instance.
21
- * - A JSX node is static if: all props are string literals / booleans / static
22
- * values, and all children are text nodes or other static JSX nodes.
23
- *
24
- * Template emission:
25
- * - JSX element trees with ≥ 2 DOM elements (no components, no spread attrs)
26
- * are compiled to `_tpl(html, bindFn)` calls instead of nested `h()` calls.
27
- * - The HTML string is parsed once via <template>.innerHTML, then cloneNode(true)
28
- * for each instance (~5-10x faster than sequential createElement calls).
29
- * - Static attributes are baked into the HTML string; dynamic attributes and
30
- * text content use renderEffect in the bind function.
31
- *
32
- * Implementation: TypeScript parser for positions + magic-string replacements.
33
- * No extra runtime dependencies — `typescript` is already in devDependencies.
34
- *
35
- * Known limitation (v0): expressions inside *nested* JSX within a child
36
- * expression container are not individually wrapped. They are still reactive
37
- * because the outer wrapper re-evaluates the whole subtree, just at a coarser
38
- * granularity. Fine-grained nested wrapping is planned for a future pass.
39
- */
40
-
41
- function transformJSX(code, filename = "input.tsx") {
42
- const scriptKind = filename.endsWith(".tsx") || filename.endsWith(".jsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TSX;
43
- const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, scriptKind);
44
- const replacements = [];
45
- const warnings = [];
46
- function warn(node, message, warnCode) {
47
- const {
48
- line,
49
- character
50
- } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
51
- warnings.push({
52
- message,
53
- line: line + 1,
54
- column: character,
55
- code: warnCode
56
- });
57
- }
58
- const hoists = [];
59
- let hoistIdx = 0;
60
- let needsTplImport = false;
61
- let needsBindTextImportGlobal = false;
62
- let needsBindDirectImportGlobal = false;
63
- let needsBindImportGlobal = false;
64
- /**
65
- * If `node` is a fully-static JSX element/fragment, register a module-scope
66
- * hoist for it and return the generated variable name. Otherwise return null.
67
- */
68
- function maybeHoist(node) {
69
- if ((ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) && isStaticJSXNode(node)) {
70
- const name = `_$h${hoistIdx++}`;
71
- const text = code.slice(node.getStart(sf), node.getEnd());
72
- hoists.push({
73
- name,
74
- text
75
- });
76
- return name;
77
- }
78
- return null;
79
- }
80
- function wrap(expr) {
81
- const start = expr.getStart(sf);
82
- const end = expr.getEnd();
83
- replacements.push({
84
- start,
85
- end,
86
- text: `() => ${code.slice(start, end)}`
87
- });
88
- }
89
- /** Try to hoist or wrap an expression, pushing a replacement if needed. */
90
- function hoistOrWrap(expr) {
91
- const hoistName = maybeHoist(expr);
92
- if (hoistName) replacements.push({
93
- start: expr.getStart(sf),
94
- end: expr.getEnd(),
95
- text: hoistName
96
- });else if (shouldWrap(expr)) wrap(expr);
97
- }
98
- /** Try to emit a template for a JsxElement. Returns true if handled. */
99
- function tryTemplateEmit(node) {
100
- if (templateElementCount(node) < 1) return false;
101
- const tplCall = buildTemplateCall(node);
102
- if (!tplCall) return false;
103
- const start = node.getStart(sf);
104
- const end = node.getEnd();
105
- const parent = node.parent;
106
- const needsBraces = parent && (ts.isJsxElement(parent) || ts.isJsxFragment(parent));
107
- replacements.push({
108
- start,
109
- end,
110
- text: needsBraces ? `{${tplCall}}` : tplCall
111
- });
112
- needsTplImport = true;
113
- return true;
114
- }
115
- /** Emit warnings for common JSX mistakes (e.g. <For> without by). */
116
- function checkForWarnings(node) {
117
- const opening = ts.isJsxElement(node) ? node.openingElement : node;
118
- if ((ts.isIdentifier(opening.tagName) ? opening.tagName.text : "") !== "For") return;
119
- if (!opening.attributes.properties.some(p => ts.isJsxAttribute(p) && ts.isIdentifier(p.name) && p.name.text === "by")) warn(opening.tagName, `<For> without a "by" prop will use index-based diffing, which is slower and may cause bugs with stateful children. Add by={(item) => item.id} for efficient keyed reconciliation.`, "missing-key-on-for");
120
- }
121
- /** Handle a JSX attribute node — wrap or hoist its value if needed. */
122
- function handleJsxAttribute(node) {
123
- const name = ts.isIdentifier(node.name) ? node.name.text : "";
124
- const openingEl = node.parent.parent;
125
- const tagName = ts.isIdentifier(openingEl.tagName) ? openingEl.tagName.text : "";
126
- if (tagName.length > 0 && tagName.charAt(0) !== tagName.charAt(0).toLowerCase()) return;
127
- if (SKIP_PROPS.has(name) || EVENT_RE.test(name)) return;
128
- if (!node.initializer || !ts.isJsxExpression(node.initializer)) return;
129
- const expr = node.initializer.expression;
130
- if (expr) hoistOrWrap(expr);
131
- }
132
- /** Handle a JSX expression in child position — wrap, hoist, or recurse. */
133
- function handleJsxExpression(node) {
134
- const expr = node.expression;
135
- if (!expr) return;
136
- const hoistName = maybeHoist(expr);
137
- if (hoistName) {
138
- replacements.push({
139
- start: expr.getStart(sf),
140
- end: expr.getEnd(),
141
- text: hoistName
142
- });
143
- return;
144
- }
145
- if (shouldWrap(expr)) {
146
- wrap(expr);
147
- return;
148
- }
149
- ts.forEachChild(expr, walk);
150
- }
151
- function walk(node) {
152
- if (ts.isJsxElement(node) && tryTemplateEmit(node)) return;
153
- if (ts.isJsxSelfClosingElement(node) || ts.isJsxElement(node)) checkForWarnings(node);
154
- if (ts.isJsxAttribute(node)) {
155
- handleJsxAttribute(node);
156
- return;
157
- }
158
- if (ts.isJsxExpression(node)) {
159
- handleJsxExpression(node);
160
- return;
161
- }
162
- ts.forEachChild(node, walk);
163
- }
164
- walk(sf);
165
- if (replacements.length === 0 && hoists.length === 0) return {
166
- code,
167
- warnings
168
- };
169
- replacements.sort((a, b) => a.start - b.start);
170
- const parts = [];
171
- let lastPos = 0;
172
- for (const r of replacements) {
173
- parts.push(code.slice(lastPos, r.start));
174
- parts.push(r.text);
175
- lastPos = r.end;
176
- }
177
- parts.push(code.slice(lastPos));
178
- let result = parts.join("");
179
- if (hoists.length > 0) result = hoists.map(h => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join("") + result;
180
- if (needsTplImport) {
181
- const runtimeDomImports = ["_tpl"];
182
- if (needsBindDirectImportGlobal) runtimeDomImports.push("_bindDirect");
183
- if (needsBindTextImportGlobal) runtimeDomImports.push("_bindText");
184
- const reactivityImports = needsBindImportGlobal ? `\nimport { _bind } from "@pyreon/reactivity";` : "";
185
- result = `import { ${runtimeDomImports.join(", ")} } from "@pyreon/runtime-dom";${reactivityImports}\n` + result;
186
- }
187
- return {
188
- code: result,
189
- usesTemplates: needsTplImport,
190
- warnings
191
- };
192
- /** Check if a single attribute would prevent template emission. */
193
- function hasBailAttr(node) {
194
- for (const attr of jsxAttrs(node)) {
195
- if (ts.isJsxSpreadAttribute(attr)) return true;
196
- if (ts.isJsxAttribute(attr) && ts.isIdentifier(attr.name) && attr.name.text === "key") return true;
197
- }
198
- return false;
199
- }
200
- /**
201
- * Count template-eligible elements for a single JSX child.
202
- * Returns 0 for skippable children, -1 for bail, positive for element count.
203
- */
204
- function countChildForTemplate(child) {
205
- if (ts.isJsxText(child)) return 0;
206
- if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) return templateElementCount(child);
207
- if (ts.isJsxExpression(child)) {
208
- if (!child.expression) return 0;
209
- return containsJSXInExpr(child.expression) ? -1 : 0;
210
- }
211
- if (ts.isJsxFragment(child)) return templateFragmentCount(child);
212
- return -1;
213
- }
214
- /**
215
- * Count DOM elements in a JSX subtree. Returns -1 if the tree is not
216
- * eligible for template emission.
217
- */
218
- function templateElementCount(node) {
219
- const tag = jsxTagName(node);
220
- if (!tag || !isLowerCase(tag)) return -1;
221
- if (hasBailAttr(node)) return -1;
222
- if (!ts.isJsxElement(node)) return 1;
223
- let count = 1;
224
- for (const child of node.children) {
225
- const c = countChildForTemplate(child);
226
- if (c === -1) return -1;
227
- count += c;
228
- }
229
- return count;
230
- }
231
- /** Count template-eligible elements inside a fragment. */
232
- function templateFragmentCount(frag) {
233
- let count = 0;
234
- for (const child of frag.children) {
235
- const c = countChildForTemplate(child);
236
- if (c === -1) return -1;
237
- count += c;
238
- }
239
- return count;
240
- }
241
- /**
242
- * Build the complete `_tpl("html", (__root) => { ... })` call string
243
- * for a template-eligible JSX element tree. Returns null if codegen fails.
244
- */
245
- function buildTemplateCall(node) {
246
- const bindLines = [];
247
- const disposerNames = [];
248
- let varIdx = 0;
249
- let dispIdx = 0;
250
- const reactiveBindExprs = [];
251
- let needsBindTextImport = false;
252
- let needsBindDirectImport = false;
253
- function nextVar() {
254
- return `__e${varIdx++}`;
255
- }
256
- function nextDisp() {
257
- const name = `__d${dispIdx++}`;
258
- disposerNames.push(name);
259
- return name;
260
- }
261
- function nextTextVar() {
262
- return `__t${varIdx++}`;
263
- }
264
- /** Resolve the variable name for an element given its accessor path. */
265
- function resolveElementVar(accessor, hasDynamic) {
266
- if (accessor === "__root") return "__root";
267
- if (hasDynamic) {
268
- const v = nextVar();
269
- bindLines.push(`const ${v} = ${accessor}`);
270
- return v;
271
- }
272
- return accessor;
273
- }
274
- /** Emit bind line for a ref attribute. */
275
- function emitRef(attr, varName) {
276
- if (!attr.initializer || !ts.isJsxExpression(attr.initializer)) return;
277
- if (!attr.initializer.expression) return;
278
- bindLines.push(`${sliceExpr(attr.initializer.expression)}.current = ${varName}`);
279
- }
280
- /** Emit event handler bind line — delegated (expando) or addEventListener. */
281
- function emitEventListener(attr, attrName, varName) {
282
- const eventName = (attrName[2] ?? "").toLowerCase() + attrName.slice(3);
283
- if (!attr.initializer || !ts.isJsxExpression(attr.initializer)) return;
284
- if (!attr.initializer.expression) return;
285
- const handler = sliceExpr(attr.initializer.expression);
286
- if (DELEGATED_EVENTS.has(eventName)) bindLines.push(`${varName}.__ev_${eventName} = ${handler}`);else bindLines.push(`${varName}.addEventListener("${eventName}", ${handler})`);
287
- }
288
- /** Return HTML string for a static attribute expression, or null if not static. */
289
- function staticAttrToHtml(exprNode, htmlAttrName) {
290
- if (!isStatic(exprNode)) return null;
291
- if (ts.isStringLiteral(exprNode)) return ` ${htmlAttrName}="${escapeHtmlAttr(exprNode.text)}"`;
292
- if (ts.isNumericLiteral(exprNode)) return ` ${htmlAttrName}="${exprNode.text}"`;
293
- if (exprNode.kind === ts.SyntaxKind.TrueKeyword) return ` ${htmlAttrName}`;
294
- return "";
295
- }
296
- /**
297
- * Try to extract a direct signal reference from an expression.
298
- * Returns the callee text (e.g. "count" or "row.label") if the expression
299
- * is a single call with no arguments, otherwise null.
300
- */
301
- function tryDirectSignalRef(exprNode) {
302
- let inner = exprNode;
303
- if (ts.isArrowFunction(inner) && !ts.isBlock(inner.body)) inner = inner.body;
304
- if (!ts.isCallExpression(inner)) return null;
305
- if (inner.arguments.length > 0) return null;
306
- const callee = inner.expression;
307
- if (ts.isIdentifier(callee) || ts.isPropertyAccessExpression(callee)) return sliceExpr(callee);
308
- return null;
309
- }
310
- /** Unwrap a reactive accessor expression for use inside _bind(). */
311
- function unwrapAccessor(exprNode) {
312
- if (ts.isArrowFunction(exprNode) && !ts.isBlock(exprNode.body)) return {
313
- expr: sliceExpr(exprNode.body),
314
- isReactive: true
315
- };
316
- if (ts.isArrowFunction(exprNode) || ts.isFunctionExpression(exprNode)) return {
317
- expr: `(${sliceExpr(exprNode)})()`,
318
- isReactive: true
319
- };
320
- return {
321
- expr: sliceExpr(exprNode),
322
- isReactive: containsCall(exprNode)
323
- };
324
- }
325
- /** Build a setter expression for an attribute. */
326
- function attrSetter(htmlAttrName, varName, expr) {
327
- return htmlAttrName === "class" ? `${varName}.className = ${expr}` : `${varName}.setAttribute("${htmlAttrName}", ${expr})`;
328
- }
329
- /** Emit bind line for a dynamic (non-static) attribute. */
330
- function emitDynamicAttr(_expr, exprNode, htmlAttrName, varName) {
331
- const {
332
- expr,
333
- isReactive
334
- } = unwrapAccessor(exprNode);
335
- if (!isReactive) {
336
- bindLines.push(attrSetter(htmlAttrName, varName, expr));
337
- return;
338
- }
339
- const directRef = tryDirectSignalRef(exprNode);
340
- if (directRef) {
341
- needsBindDirectImport = true;
342
- const d = nextDisp();
343
- const updater = htmlAttrName === "class" ? `(v) => { ${varName}.className = v == null ? "" : String(v) }` : `(v) => { ${varName}.setAttribute("${htmlAttrName}", v == null ? "" : String(v)) }`;
344
- bindLines.push(`const ${d} = _bindDirect(${directRef}, ${updater})`);
345
- return;
346
- }
347
- reactiveBindExprs.push(attrSetter(htmlAttrName, varName, expr));
348
- }
349
- /** Emit bind line or HTML for an expression attribute value. */
350
- function emitAttrExpression(exprNode, htmlAttrName, varName) {
351
- const staticHtml = staticAttrToHtml(exprNode, htmlAttrName);
352
- if (staticHtml !== null) return staticHtml;
353
- emitDynamicAttr(sliceExpr(exprNode), exprNode, htmlAttrName, varName);
354
- return "";
355
- }
356
- /** Emit side-effects for special attrs (ref, event). Returns true if handled. */
357
- function tryEmitSpecialAttr(attr, attrName, varName) {
358
- if (attrName === "ref") {
359
- emitRef(attr, varName);
360
- return true;
361
- }
362
- if (EVENT_RE.test(attrName)) {
363
- emitEventListener(attr, attrName, varName);
364
- return true;
365
- }
366
- return false;
367
- }
368
- /** Convert an attribute initializer to HTML. Returns empty string for side-effect-only attrs. */
369
- function attrInitializerToHtml(attr, htmlAttrName, varName) {
370
- if (!attr.initializer) return ` ${htmlAttrName}`;
371
- if (ts.isStringLiteral(attr.initializer)) return ` ${htmlAttrName}="${escapeHtmlAttr(attr.initializer.text)}"`;
372
- if (ts.isJsxExpression(attr.initializer) && attr.initializer.expression) return emitAttrExpression(attr.initializer.expression, htmlAttrName, varName);
373
- return "";
374
- }
375
- /** Process a single attribute, returning HTML to append. */
376
- function processOneAttr(attr, varName) {
377
- if (!ts.isJsxAttribute(attr)) return "";
378
- const attrName = ts.isIdentifier(attr.name) ? attr.name.text : "";
379
- if (attrName === "key") return "";
380
- if (tryEmitSpecialAttr(attr, attrName, varName)) return "";
381
- return attrInitializerToHtml(attr, JSX_TO_HTML_ATTR[attrName] ?? attrName, varName);
382
- }
383
- /** Process all attributes on an element, returning the HTML attribute string. */
384
- function processAttrs(el, varName) {
385
- let htmlAttrs = "";
386
- for (const attr of jsxAttrs(el)) htmlAttrs += processOneAttr(attr, varName);
387
- return htmlAttrs;
388
- }
389
- /** Emit bind lines for a reactive text expression child. */
390
- function emitReactiveTextChild(expr, exprNode, varName, parentRef, childNodeIdx, needsPlaceholder) {
391
- const tVar = nextTextVar();
392
- bindLines.push(`const ${tVar} = document.createTextNode("")`);
393
- if (needsPlaceholder) bindLines.push(`${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`);else bindLines.push(`${varName}.appendChild(${tVar})`);
394
- const directRef = tryDirectSignalRef(exprNode);
395
- if (directRef) {
396
- needsBindTextImport = true;
397
- const d = nextDisp();
398
- bindLines.push(`const ${d} = _bindText(${directRef}, ${tVar})`);
399
- } else reactiveBindExprs.push(`${tVar}.data = ${expr}`);
400
- return needsPlaceholder ? "<!>" : "";
401
- }
402
- /** Emit bind lines for a static text expression child. */
403
- function emitStaticTextChild(expr, varName, parentRef, childNodeIdx, needsPlaceholder) {
404
- if (needsPlaceholder) {
405
- const tVar = nextTextVar();
406
- bindLines.push(`const ${tVar} = document.createTextNode(${expr})`);
407
- bindLines.push(`${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`);
408
- return "<!>";
409
- }
410
- bindLines.push(`${varName}.textContent = ${expr}`);
411
- return "";
412
- }
413
- /** Process a single flat child, returning the HTML contribution or null on failure. */
414
- function processOneChild(child, varName, parentRef, useMixed, useMultiExpr, childNodeIdx) {
415
- if (child.kind === "text") return escapeHtmlText(child.text);
416
- if (child.kind === "element") {
417
- const childAccessor = useMixed ? `${parentRef}.childNodes[${childNodeIdx}]` : `${parentRef}.children[${child.elemIdx}]`;
418
- return processElement(child.node, childAccessor);
419
- }
420
- const needsPlaceholder = useMixed || useMultiExpr;
421
- const {
422
- expr,
423
- isReactive
424
- } = unwrapAccessor(child.expression);
425
- if (isReactive) return emitReactiveTextChild(expr, child.expression, varName, parentRef, childNodeIdx, needsPlaceholder);
426
- return emitStaticTextChild(expr, varName, parentRef, childNodeIdx, needsPlaceholder);
427
- }
428
- /** Process children of a JsxElement, returning the children HTML. */
429
- function processChildren(el, varName, accessor) {
430
- const flatChildren = flattenChildren(el.children);
431
- const {
432
- useMixed,
433
- useMultiExpr
434
- } = analyzeChildren(flatChildren);
435
- const parentRef = accessor === "__root" ? "__root" : varName;
436
- let html = "";
437
- let childNodeIdx = 0;
438
- for (const child of flatChildren) {
439
- const childHtml = processOneChild(child, varName, parentRef, useMixed, useMultiExpr, childNodeIdx);
440
- if (childHtml === null) return null;
441
- html += childHtml;
442
- childNodeIdx++;
443
- }
444
- return html;
445
- }
446
- /** Process a single DOM element for template emission. Returns the HTML string or null. */
447
- function processElement(el, accessor) {
448
- const tag = jsxTagName(el);
449
- if (!tag) return null;
450
- const varName = resolveElementVar(accessor, elementHasDynamic(el));
451
- let html = `<${tag}${processAttrs(el, varName)}>`;
452
- if (ts.isJsxElement(el)) {
453
- const childHtml = processChildren(el, varName, accessor);
454
- if (childHtml === null) return null;
455
- html += childHtml;
456
- }
457
- if (!VOID_ELEMENTS.has(tag)) html += `</${tag}>`;
458
- return html;
459
- }
460
- const html = processElement(node, "__root");
461
- if (html === null) return null;
462
- if (needsBindTextImport) needsBindTextImportGlobal = true;
463
- if (needsBindDirectImport) needsBindDirectImportGlobal = true;
464
- const escaped = html.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
465
- if (reactiveBindExprs.length > 0) {
466
- needsBindImportGlobal = true;
467
- const combinedName = nextDisp();
468
- const combinedBody = reactiveBindExprs.join("; ");
469
- bindLines.push(`const ${combinedName} = _bind(() => { ${combinedBody} })`);
470
- }
471
- if (bindLines.length === 0 && disposerNames.length === 0) return `_tpl("${escaped}", () => null)`;
472
- let body = bindLines.map(l => ` ${l}`).join("\n");
473
- if (disposerNames.length > 0) body += `\n return () => { ${disposerNames.map(d => `${d}()`).join("; ")} }`;else body += "\n return null";
474
- return `_tpl("${escaped}", (__root) => {\n${body}\n})`;
475
- }
476
- /** Classify a single JSX child into a FlatChild descriptor. */
477
- function classifyJsxChild(child, out, elemIdxRef, recurse) {
478
- if (ts.isJsxText(child)) {
479
- const trimmed = child.text.replace(/\n\s*/g, "").trim();
480
- if (trimmed) out.push({
481
- kind: "text",
482
- text: trimmed
483
- });
484
- return;
485
- }
486
- if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) {
487
- out.push({
488
- kind: "element",
489
- node: child,
490
- elemIdx: elemIdxRef.value++
491
- });
492
- return;
493
- }
494
- if (ts.isJsxExpression(child)) {
495
- if (child.expression) out.push({
496
- kind: "expression",
497
- expression: child.expression
498
- });
499
- return;
500
- }
501
- if (ts.isJsxFragment(child)) recurse(child.children);
502
- }
503
- /**
504
- * Flatten JSX children, inlining fragment children and stripping whitespace-only text.
505
- * Returns a flat array of child descriptors with element indices pre-computed.
506
- */
507
- function flattenChildren(children) {
508
- const flatList = [];
509
- const elemIdxRef = {
510
- value: 0
511
- };
512
- function addChildren(kids) {
513
- for (const child of kids) classifyJsxChild(child, flatList, elemIdxRef, addChildren);
514
- }
515
- addChildren(children);
516
- return flatList;
517
- }
518
- /** Analyze flat children to determine indexing strategy. */
519
- function analyzeChildren(flatChildren) {
520
- const hasElem = flatChildren.some(c => c.kind === "element");
521
- const hasNonElem = flatChildren.some(c => c.kind !== "element");
522
- const exprCount = flatChildren.filter(c => c.kind === "expression").length;
523
- return {
524
- useMixed: hasElem && hasNonElem,
525
- useMultiExpr: exprCount > 1
526
- };
527
- }
528
- /** Check if a single attribute is dynamic (has ref, event, or non-static expression). */
529
- function attrIsDynamic(attr) {
530
- if (!ts.isJsxAttribute(attr)) return false;
531
- const name = ts.isIdentifier(attr.name) ? attr.name.text : "";
532
- if (name === "ref") return true;
533
- if (EVENT_RE.test(name)) return true;
534
- if (!attr.initializer || !ts.isJsxExpression(attr.initializer)) return false;
535
- const expr = attr.initializer.expression;
536
- return expr ? !isStatic(expr) : false;
537
- }
538
- /** Check if an element has any dynamic attributes, events, ref, or expression children */
539
- function elementHasDynamic(node) {
540
- if (jsxAttrs(node).some(attrIsDynamic)) return true;
541
- if (ts.isJsxElement(node)) return node.children.some(c => ts.isJsxExpression(c) && c.expression !== void 0);
542
- return false;
543
- }
544
- /** Slice expression source from the original code */
545
- function sliceExpr(expr) {
546
- return code.slice(expr.getStart(sf), expr.getEnd());
547
- }
548
- /** Get tag name string */
549
- function jsxTagName(node) {
550
- const tag = ts.isJsxElement(node) ? node.openingElement.tagName : node.tagName;
551
- return ts.isIdentifier(tag) ? tag.text : "";
552
- }
553
- /** Get attribute list */
554
- function jsxAttrs(node) {
555
- return ts.isJsxElement(node) ? node.openingElement.attributes.properties : node.attributes.properties;
556
- }
557
- }
558
- function isLowerCase(s) {
559
- return s.length > 0 && s[0] === s[0]?.toLowerCase();
560
- }
561
- /** Check if an expression subtree contains JSX nodes */
562
- function containsJSXInExpr(node) {
563
- if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) return true;
564
- return ts.forEachChild(node, containsJSXInExpr) ?? false;
565
- }
566
- function escapeHtmlAttr(s) {
567
- return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
568
- }
569
- function escapeHtmlText(s) {
570
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;");
571
- }
572
- function isStaticJSXNode(node) {
573
- if (ts.isJsxSelfClosingElement(node)) return isStaticAttrs(node.attributes);
574
- if (ts.isJsxFragment(node)) return node.children.every(isStaticChild);
575
- return isStaticAttrs(node.openingElement.attributes) && node.children.every(isStaticChild);
576
- }
577
- function isStaticAttrs(attrs) {
578
- return attrs.properties.every(prop => {
579
- if (!ts.isJsxAttribute(prop)) return false;
580
- if (!prop.initializer) return true;
581
- if (ts.isStringLiteral(prop.initializer)) return true;
582
- const expr = prop.initializer.expression;
583
- return expr ? isStatic(expr) : true;
584
- });
585
- }
586
- function isStaticChild(child) {
587
- if (ts.isJsxText(child)) return true;
588
- if (ts.isJsxSelfClosingElement(child)) return isStaticJSXNode(child);
589
- if (ts.isJsxElement(child)) return isStaticJSXNode(child);
590
- if (ts.isJsxFragment(child)) return isStaticJSXNode(child);
591
- const expr = child.expression;
592
- return expr ? isStatic(expr) : true;
593
- }
594
- function isStatic(node) {
595
- return ts.isStringLiteral(node) || ts.isNumericLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword || node.kind === ts.SyntaxKind.NullKeyword || node.kind === ts.SyntaxKind.UndefinedKeyword;
596
- }
597
- function shouldWrap(node) {
598
- if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return false;
599
- if (isStatic(node)) return false;
600
- return containsCall(node);
601
- }
602
- function containsCall(node) {
603
- if (ts.isCallExpression(node)) return true;
604
- if (ts.isTaggedTemplateExpression(node)) return true;
605
- if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return false;
606
- return ts.forEachChild(node, containsCall) ?? false;
607
- }
608
-
3
+ * JSX transform — wraps dynamic JSX expressions in `() =>` so the Pyreon runtime
4
+ * receives reactive getters instead of eagerly-evaluated snapshot values.
5
+ *
6
+ * Rules:
7
+ * - `<div>{expr}</div>` → `<div>{() => expr}</div>` (child)
8
+ * - `<div class={expr}>` → `<div class={() => expr}>` (prop)
9
+ * - `<button onClick={fn}>` → unchanged (event handler)
10
+ * - `<div>{() => expr}</div>` → unchanged (already wrapped)
11
+ * - `<div>{"literal"}</div>` → unchanged (static)
12
+ *
13
+ * Static VNode hoisting:
14
+ * - Fully static JSX in expression containers is hoisted to module scope:
15
+ * `{<span>Hello</span>}` → `const _$h0 = <span>Hello</span>` + `{_$h0}`
16
+ * - Hoisted nodes are created ONCE at module initialisation, not per-instance.
17
+ * - A JSX node is static if: all props are string literals / booleans / static
18
+ * values, and all children are text nodes or other static JSX nodes.
19
+ *
20
+ * Template emission:
21
+ * - JSX element trees with ≥ 2 DOM elements (no components, no spread attrs)
22
+ * are compiled to `_tpl(html, bindFn)` calls instead of nested `h()` calls.
23
+ * - The HTML string is parsed once via <template>.innerHTML, then cloneNode(true)
24
+ * for each instance (~5-10x faster than sequential createElement calls).
25
+ * - Static attributes are baked into the HTML string; dynamic attributes and
26
+ * text content use renderEffect in the bind function.
27
+ *
28
+ * Implementation: TypeScript parser for positions + magic-string replacements.
29
+ * No extra runtime dependencies — `typescript` is already in devDependencies.
30
+ *
31
+ * Known limitation (v0): expressions inside *nested* JSX within a child
32
+ * expression container are not individually wrapped. They are still reactive
33
+ * because the outer wrapper re-evaluates the whole subtree, just at a coarser
34
+ * granularity. Fine-grained nested wrapping is planned for a future pass.
35
+ */
36
+ interface CompilerWarning {
37
+ /** Warning message */
38
+ message: string;
39
+ /** Source file line number (1-based) */
40
+ line: number;
41
+ /** Source file column number (0-based) */
42
+ column: number;
43
+ /** Warning code for filtering */
44
+ code: "signal-call-in-jsx" | "missing-key-on-for" | "signal-in-static-prop";
45
+ }
46
+ interface TransformResult {
47
+ /** Transformed source code (JSX preserved, only expression containers modified) */
48
+ code: string;
49
+ /** Whether the output uses _tpl/_re template helpers (needs auto-import) */
50
+ usesTemplates?: boolean;
51
+ /** Compiler warnings for common mistakes */
52
+ warnings: CompilerWarning[];
53
+ }
54
+ declare function transformJSX(code: string, filename?: string): TransformResult;
609
55
  //#endregion
610
- //#region src/project-scanner.ts
56
+ //#region src/project-scanner.d.ts
611
57
  /**
612
- * Project scanner — extracts route, component, and island information from source files.
613
- */
614
- function generateContext(cwd) {
615
- const files = collectSourceFiles(cwd);
616
- return {
617
- framework: "pyreon",
618
- version: readVersion(cwd),
619
- generatedAt: (/* @__PURE__ */new Date()).toISOString(),
620
- routes: extractRoutes(files, cwd),
621
- components: extractComponents(files, cwd),
622
- islands: extractIslands(files, cwd)
623
- };
624
- }
625
- function collectSourceFiles(cwd) {
626
- const results = [];
627
- const extensions = new Set([".tsx", ".jsx", ".ts", ".js"]);
628
- const ignoreDirs = new Set(["node_modules", "dist", "lib", ".pyreon", ".git", "build"]);
629
- function walk(dir) {
630
- let entries;
631
- try {
632
- entries = fs.readdirSync(dir, {
633
- withFileTypes: true
634
- });
635
- } catch {
636
- return;
637
- }
638
- for (const entry of entries) {
639
- if (entry.name.startsWith(".") && entry.isDirectory()) continue;
640
- if (ignoreDirs.has(entry.name) && entry.isDirectory()) continue;
641
- const fullPath = path.join(dir, entry.name);
642
- if (entry.isDirectory()) walk(fullPath);else if (entry.isFile() && extensions.has(path.extname(entry.name))) results.push(fullPath);
643
- }
644
- }
645
- walk(cwd);
646
- return results;
647
- }
648
- function extractRoutes(files, _cwd) {
649
- const routes = [];
650
- for (const file of files) {
651
- let code;
652
- try {
653
- code = fs.readFileSync(file, "utf-8");
654
- } catch {
655
- continue;
656
- }
657
- const routeArrayRe = /(?:createRouter\s*\(\s*\[|(?:const|let)\s+routes\s*(?::\s*RouteRecord\[\])?\s*=\s*\[)([\s\S]*?)\]/g;
658
- let match;
659
- for (match = routeArrayRe.exec(code); match; match = routeArrayRe.exec(code)) {
660
- const block = match[1] ?? "";
661
- const routeObjRe = /path\s*:\s*["']([^"']+)["']/g;
662
- let routeMatch;
663
- for (routeMatch = routeObjRe.exec(block); routeMatch; routeMatch = routeObjRe.exec(block)) {
664
- const routePath = routeMatch[1] ?? "";
665
- const surroundingStart = Math.max(0, routeMatch.index - 50);
666
- const surroundingEnd = Math.min(block.length, routeMatch.index + 200);
667
- const surrounding = block.slice(surroundingStart, surroundingEnd);
668
- routes.push({
669
- path: routePath,
670
- name: surrounding.match(/name\s*:\s*["']([^"']+)["']/)?.[1],
671
- hasLoader: /loader\s*:/.test(surrounding),
672
- hasGuard: /beforeEnter\s*:|beforeLeave\s*:/.test(surrounding),
673
- params: extractParams(routePath)
674
- });
675
- }
676
- }
677
- }
678
- return routes;
679
- }
680
- function extractComponents(files, cwd) {
681
- const components = [];
682
- for (const file of files) {
683
- let code;
684
- try {
685
- code = fs.readFileSync(file, "utf-8");
686
- } catch {
687
- continue;
688
- }
689
- const componentRe = /(?:export\s+)?(?:const|function)\s+([A-Z]\w*)\s*(?::\s*ComponentFn<[^>]+>\s*)?=?\s*\(?(?:\s*\{?\s*([^)]*?)\s*\}?\s*)?\)?\s*(?:=>|{)/g;
690
- let match;
691
- for (match = componentRe.exec(code); match; match = componentRe.exec(code)) {
692
- const name = match[1] ?? "Unknown";
693
- const props = (match[2] ?? "").split(/[,;]/).map(p => p.trim().replace(/[{}]/g, "").trim().split(":")[0]?.split("=")[0]?.trim() ?? "").filter(p => p && p !== "props");
694
- const bodyStart = match.index + match[0].length;
695
- const body = code.slice(bodyStart, Math.min(code.length, bodyStart + 2e3));
696
- const signalNames = [];
697
- const signalRe = /(?:const|let)\s+(\w+)\s*=\s*signal\s*[<(]/g;
698
- let sigMatch;
699
- for (sigMatch = signalRe.exec(body); sigMatch; sigMatch = signalRe.exec(body)) if (sigMatch[1]) signalNames.push(sigMatch[1]);
700
- components.push({
701
- name,
702
- file: path.relative(cwd, file),
703
- hasSignals: signalNames.length > 0,
704
- signalNames,
705
- props
706
- });
707
- }
708
- }
709
- return components;
710
- }
711
- function extractIslands(files, cwd) {
712
- const islands = [];
713
- for (const file of files) {
714
- let code;
715
- try {
716
- code = fs.readFileSync(file, "utf-8");
717
- } catch {
718
- continue;
719
- }
720
- const islandRe = /island\s*\(\s*\(\)\s*=>\s*import\(.+?\)\s*,\s*\{[^}]*name\s*:\s*["']([^"']+)["'][^}]*?(?:hydrate\s*:\s*["']([^"']+)["'])?[^}]*\}/g;
721
- let match;
722
- for (match = islandRe.exec(code); match; match = islandRe.exec(code)) if (match[1]) islands.push({
723
- name: match[1],
724
- file: path.relative(cwd, file),
725
- hydrate: match[2] ?? "load"
726
- });
727
- }
728
- return islands;
729
- }
730
- function extractParams(routePath) {
731
- const params = [];
732
- const paramRe = /:(\w+)\??/g;
733
- let match;
734
- for (match = paramRe.exec(routePath); match; match = paramRe.exec(routePath)) if (match[1]) params.push(match[1]);
735
- return params;
736
- }
737
- function readVersion(cwd) {
738
- try {
739
- const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf-8"));
740
- const deps = {
741
- ...pkg.dependencies,
742
- ...pkg.devDependencies
743
- };
744
- for (const [name, ver] of Object.entries(deps)) if (name.startsWith("@pyreon/") && typeof ver === "string") return ver.replace(/^[\^~]/, "");
745
- return pkg.version || "unknown";
746
- } catch {
747
- return "unknown";
748
- }
749
- }
750
-
58
+ * Project scanner — extracts route, component, and island information from source files.
59
+ */
60
+ interface RouteInfo {
61
+ path: string;
62
+ name?: string | undefined;
63
+ component?: string | undefined;
64
+ hasLoader: boolean;
65
+ hasGuard: boolean;
66
+ params: string[];
67
+ }
68
+ interface ComponentInfo {
69
+ name: string;
70
+ file: string;
71
+ hasSignals: boolean;
72
+ signalNames: string[];
73
+ props: string[];
74
+ }
75
+ interface IslandInfo {
76
+ name: string;
77
+ file: string;
78
+ hydrate: string;
79
+ }
80
+ interface ProjectContext {
81
+ framework: "pyreon";
82
+ version: string;
83
+ generatedAt: string;
84
+ routes: RouteInfo[];
85
+ components: ComponentInfo[];
86
+ islands: IslandInfo[];
87
+ }
88
+ declare function generateContext(cwd: string): ProjectContext;
751
89
  //#endregion
752
- //#region src/react-intercept.ts
90
+ //#region src/react-intercept.d.ts
753
91
  /**
754
- * React Pattern Interceptor — detects React/Vue patterns in code and provides
755
- * structured diagnostics with exact fix suggestions for AI-assisted migration.
756
- *
757
- * Two modes:
758
- * - `detectReactPatterns(code)` — returns diagnostics only (non-destructive)
759
- * - `migrateReactCode(code)` — applies auto-fixes and returns transformed code
760
- *
761
- * Designed for three consumers:
762
- * 1. Compiler pre-pass (warnings during build)
763
- * 2. CLI `pyreon doctor` (project-wide scanning)
764
- * 3. MCP server `migrate_react` / `validate` tools (AI agent integration)
765
- */
766
- /** React import sources Pyreon equivalents */
767
-
768
- function detectGetNodeText(ctx, node) {
769
- return ctx.code.slice(node.getStart(ctx.sf), node.getEnd());
770
- }
771
- function detectDiag(ctx, node, diagCode, message, current, suggested, fixable) {
772
- const {
773
- line,
774
- character
775
- } = ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf));
776
- ctx.diagnostics.push({
777
- code: diagCode,
778
- message,
779
- line: line + 1,
780
- column: character,
781
- current: current.trim(),
782
- suggested: suggested.trim(),
783
- fixable
784
- });
785
- }
786
- function detectImportDeclaration(ctx, node) {
787
- if (!node.moduleSpecifier) return;
788
- const source = node.moduleSpecifier.text;
789
- const pyreonSource = IMPORT_REWRITES[source];
790
- if (pyreonSource !== void 0) {
791
- if (node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) for (const spec of node.importClause.namedBindings.elements) ctx.reactImportedHooks.add(spec.name.text);
792
- detectDiag(ctx, node, source.startsWith("react-router") ? "react-router-import" : source.startsWith("react-dom") ? "react-dom-import" : "react-import", `Import from '${source}' is a React package. Use Pyreon equivalent.`, detectGetNodeText(ctx, node), pyreonSource ? `import { ... } from "${pyreonSource}"` : "Remove this import — not needed in Pyreon", true);
793
- }
794
- }
795
- function detectUseState(ctx, node) {
796
- const parent = node.parent;
797
- if (ts.isVariableDeclaration(parent) && parent.name && ts.isArrayBindingPattern(parent.name) && parent.name.elements.length >= 1) {
798
- const firstEl = parent.name.elements[0];
799
- const valueName = firstEl && ts.isBindingElement(firstEl) ? firstEl.name.text : "value";
800
- const initArg = node.arguments[0] ? detectGetNodeText(ctx, node.arguments[0]) : "undefined";
801
- detectDiag(ctx, node, "use-state", `useState is a React API. In Pyreon, use signal(). Read: ${valueName}(), Write: ${valueName}.set(x)`, detectGetNodeText(ctx, parent), `${valueName} = signal(${initArg})`, true);
802
- } else detectDiag(ctx, node, "use-state", "useState is a React API. In Pyreon, use signal().", detectGetNodeText(ctx, node), "signal(initialValue)", true);
803
- }
804
- function callbackHasCleanup(callbackArg) {
805
- if (!ts.isArrowFunction(callbackArg) && !ts.isFunctionExpression(callbackArg)) return false;
806
- const body = callbackArg.body;
807
- if (!ts.isBlock(body)) return false;
808
- for (const stmt of body.statements) if (ts.isReturnStatement(stmt) && stmt.expression) return true;
809
- return false;
810
- }
811
- function detectUseEffect(ctx, node) {
812
- const hookName = node.expression.text;
813
- const depsArg = node.arguments[1];
814
- const callbackArg = node.arguments[0];
815
- if (depsArg && ts.isArrayLiteralExpression(depsArg) && depsArg.elements.length === 0) {
816
- const hasCleanup = callbackArg ? callbackHasCleanup(callbackArg) : false;
817
- detectDiag(ctx, node, "use-effect-mount", `${hookName} with empty deps [] means "run once on mount". Use onMount() in Pyreon.`, detectGetNodeText(ctx, node), hasCleanup ? "onMount(() => {\n // setup...\n return () => { /* cleanup */ }\n})" : "onMount(() => {\n // setup...\n return undefined\n})", true);
818
- } else if (depsArg && ts.isArrayLiteralExpression(depsArg)) detectDiag(ctx, node, "use-effect-deps", `${hookName} with dependency array. In Pyreon, effect() auto-tracks dependencies — no array needed.`, detectGetNodeText(ctx, node), "effect(() => {\n // reads are auto-tracked\n})", true);else if (!depsArg) detectDiag(ctx, node, "use-effect-no-deps", `${hookName} with no dependency array. In Pyreon, use effect() — it auto-tracks signal reads.`, detectGetNodeText(ctx, node), "effect(() => {\n // runs when accessed signals change\n})", true);
819
- }
820
- function detectUseMemo(ctx, node) {
821
- const computeFn = node.arguments[0];
822
- const computeText = computeFn ? detectGetNodeText(ctx, computeFn) : "() => value";
823
- detectDiag(ctx, node, "use-memo", "useMemo is a React API. In Pyreon, use computed() — dependencies auto-tracked.", detectGetNodeText(ctx, node), `computed(${computeText})`, true);
824
- }
825
- function detectUseCallback(ctx, node) {
826
- const callbackFn = node.arguments[0];
827
- const callbackText = callbackFn ? detectGetNodeText(ctx, callbackFn) : "() => {}";
828
- detectDiag(ctx, node, "use-callback", "useCallback is not needed in Pyreon. Components run once, so closures never go stale. Use a plain function.", detectGetNodeText(ctx, node), callbackText, true);
829
- }
830
- function detectUseRef(ctx, node) {
831
- const arg = node.arguments[0];
832
- if (arg && (arg.kind === ts.SyntaxKind.NullKeyword || ts.isIdentifier(arg) && arg.text === "undefined")) detectDiag(ctx, node, "use-ref-dom", "useRef(null) for DOM refs. In Pyreon, use createRef() from @pyreon/core.", detectGetNodeText(ctx, node), "createRef()", true);else {
833
- const initText = arg ? detectGetNodeText(ctx, arg) : "undefined";
834
- detectDiag(ctx, node, "use-ref-box", "useRef for mutable values. In Pyreon, use signal() — it works the same way but is reactive.", detectGetNodeText(ctx, node), `signal(${initText})`, true);
835
- }
836
- }
837
- function detectUseReducer(ctx, node) {
838
- detectDiag(ctx, node, "use-reducer", "useReducer is a React API. In Pyreon, use signal() with update() for reducer patterns.", detectGetNodeText(ctx, node), "const state = signal(initialState)\nconst dispatch = (action) => state.update(s => reducer(s, action))", false);
839
- }
840
- function isCallToReactDot(callee, methodName) {
841
- return ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) && callee.expression.text === "React" && callee.name.text === methodName;
842
- }
843
- function detectMemoWrapper(ctx, node) {
844
- const callee = node.expression;
845
- if (ts.isIdentifier(callee) && callee.text === "memo" || isCallToReactDot(callee, "memo")) {
846
- const inner = node.arguments[0];
847
- const innerText = inner ? detectGetNodeText(ctx, inner) : "Component";
848
- detectDiag(ctx, node, "memo-wrapper", "memo() is not needed in Pyreon. Components run once — only signals trigger updates, not re-renders.", detectGetNodeText(ctx, node), innerText, true);
849
- }
850
- }
851
- function detectForwardRef(ctx, node) {
852
- const callee = node.expression;
853
- if (ts.isIdentifier(callee) && callee.text === "forwardRef" || isCallToReactDot(callee, "forwardRef")) detectDiag(ctx, node, "forward-ref", "forwardRef is not needed in Pyreon. Pass ref as a regular prop.", detectGetNodeText(ctx, node), "// Just pass ref as a prop:\nconst MyInput = (props) => <input ref={props.ref} />", true);
854
- }
855
- function detectJsxAttributes(ctx, node) {
856
- const attrName = node.name.text;
857
- if (attrName in JSX_ATTR_REWRITES) {
858
- const htmlAttr = JSX_ATTR_REWRITES[attrName];
859
- detectDiag(ctx, node, attrName === "className" ? "class-name-prop" : "html-for-prop", `'${attrName}' is a React JSX attribute. Use '${htmlAttr}' in Pyreon (standard HTML).`, detectGetNodeText(ctx, node), detectGetNodeText(ctx, node).replace(attrName, htmlAttr), true);
860
- }
861
- if (attrName === "onChange") {
862
- const jsxElement = findParentJsxElement(node);
863
- if (jsxElement) {
864
- const tagName = getJsxTagName(jsxElement);
865
- if (tagName === "input" || tagName === "textarea" || tagName === "select") detectDiag(ctx, node, "on-change-input", `onChange on <${tagName}> fires on blur in Pyreon (native DOM behavior). For keypress-by-keypress updates, use onInput.`, detectGetNodeText(ctx, node), detectGetNodeText(ctx, node).replace("onChange", "onInput"), true);
866
- }
867
- }
868
- if (attrName === "dangerouslySetInnerHTML") detectDiag(ctx, node, "dangerously-set-inner-html", "dangerouslySetInnerHTML is React-specific. Use innerHTML prop in Pyreon.", detectGetNodeText(ctx, node), "innerHTML={htmlString}", true);
869
- }
870
- function detectDotValueSignal(ctx, node) {
871
- const varName = node.expression.text;
872
- const parent = node.parent;
873
- if (ts.isBinaryExpression(parent) && parent.left === node) detectDiag(ctx, node, "dot-value-signal", `'${varName}.value' looks like a Vue ref pattern. Pyreon signals are callable functions. Use ${varName}.set(x) to write.`, detectGetNodeText(ctx, parent), `${varName}.set(${detectGetNodeText(ctx, parent.right)})`, false);
874
- }
875
- function detectArrayMapJsx(ctx, node) {
876
- const parent = node.parent;
877
- if (ts.isJsxExpression(parent)) {
878
- const arrayExpr = detectGetNodeText(ctx, node.expression.expression);
879
- const mapCallback = node.arguments[0];
880
- const mapCallbackText = mapCallback ? detectGetNodeText(ctx, mapCallback) : "item => <li>{item}</li>";
881
- detectDiag(ctx, node, "array-map-jsx", "Array.map() in JSX is not reactive in Pyreon. Use <For> for efficient keyed list rendering.", detectGetNodeText(ctx, node), `<For each={${arrayExpr}} by={item => item.id}>\n {${mapCallbackText}}\n</For>`, false);
882
- }
883
- }
884
- function isCallToHook(node, hookName) {
885
- return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === hookName;
886
- }
887
- function isCallToEffectHook(node) {
888
- return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && (node.expression.text === "useEffect" || node.expression.text === "useLayoutEffect");
889
- }
890
- function isMapCallExpression(node) {
891
- return ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.name) && node.expression.name.text === "map";
892
- }
893
- function isDotValueAccess(node) {
894
- return ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.name) && node.name.text === "value" && ts.isIdentifier(node.expression);
895
- }
896
- function detectVisitNode(ctx, node) {
897
- if (ts.isImportDeclaration(node)) detectImportDeclaration(ctx, node);
898
- if (isCallToHook(node, "useState")) detectUseState(ctx, node);
899
- if (isCallToEffectHook(node)) detectUseEffect(ctx, node);
900
- if (isCallToHook(node, "useMemo")) detectUseMemo(ctx, node);
901
- if (isCallToHook(node, "useCallback")) detectUseCallback(ctx, node);
902
- if (isCallToHook(node, "useRef")) detectUseRef(ctx, node);
903
- if (isCallToHook(node, "useReducer")) detectUseReducer(ctx, node);
904
- if (ts.isCallExpression(node)) detectMemoWrapper(ctx, node);
905
- if (ts.isCallExpression(node)) detectForwardRef(ctx, node);
906
- if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name)) detectJsxAttributes(ctx, node);
907
- if (isDotValueAccess(node)) detectDotValueSignal(ctx, node);
908
- if (isMapCallExpression(node)) detectArrayMapJsx(ctx, node);
909
- }
910
- function detectVisit(ctx, node) {
911
- ts.forEachChild(node, child => {
912
- detectVisitNode(ctx, child);
913
- detectVisit(ctx, child);
914
- });
915
- }
916
- function detectReactPatterns(code, filename = "input.tsx") {
917
- const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
918
- const ctx = {
919
- sf,
920
- code,
921
- diagnostics: [],
922
- reactImportedHooks: /* @__PURE__ */new Set()
923
- };
924
- detectVisit(ctx, sf);
925
- return ctx.diagnostics;
926
- }
927
- function migrateAddImport(ctx, source, specifier) {
928
- if (!source || !specifier) return;
929
- let specs = ctx.pyreonImports.get(source);
930
- if (!specs) {
931
- specs = /* @__PURE__ */new Set();
932
- ctx.pyreonImports.set(source, specs);
933
- }
934
- specs.add(specifier);
935
- }
936
- function migrateReplace(ctx, node, text) {
937
- ctx.replacements.push({
938
- start: node.getStart(ctx.sf),
939
- end: node.getEnd(),
940
- text
941
- });
942
- }
943
- function migrateGetNodeText(ctx, node) {
944
- return ctx.code.slice(node.getStart(ctx.sf), node.getEnd());
945
- }
946
- function migrateGetLine(ctx, node) {
947
- return ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf)).line + 1;
948
- }
949
- function migrateImportDeclaration(ctx, node) {
950
- if (!node.moduleSpecifier) return;
951
- if (!(node.moduleSpecifier.text in IMPORT_REWRITES)) return;
952
- if (node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) for (const spec of node.importClause.namedBindings.elements) {
953
- const rewrite = SPECIFIER_REWRITES[spec.name.text];
954
- if (rewrite) {
955
- if (rewrite.name) migrateAddImport(ctx, rewrite.from, rewrite.name);
956
- ctx.specifierRewrites.set(spec, rewrite);
957
- }
958
- }
959
- ctx.importsToRemove.add(node);
960
- }
961
- function migrateUseState(ctx, node) {
962
- const parent = node.parent;
963
- if (ts.isVariableDeclaration(parent) && parent.name && ts.isArrayBindingPattern(parent.name) && parent.name.elements.length >= 1) {
964
- const firstEl = parent.name.elements[0];
965
- const valueName = firstEl && ts.isBindingElement(firstEl) ? firstEl.name.text : "value";
966
- const initArg = node.arguments[0] ? migrateGetNodeText(ctx, node.arguments[0]) : "undefined";
967
- const declStart = parent.getStart(ctx.sf);
968
- const declEnd = parent.getEnd();
969
- ctx.replacements.push({
970
- start: declStart,
971
- end: declEnd,
972
- text: `${valueName} = signal(${initArg})`
973
- });
974
- migrateAddImport(ctx, "@pyreon/reactivity", "signal");
975
- ctx.changes.push({
976
- type: "replace",
977
- line: migrateGetLine(ctx, node),
978
- description: `useState → signal: ${valueName}`
979
- });
980
- }
981
- }
982
- function migrateUseEffect(ctx, node) {
983
- const depsArg = node.arguments[1];
984
- const callbackArg = node.arguments[0];
985
- const hookName = node.expression.text;
986
- if (depsArg && ts.isArrayLiteralExpression(depsArg) && depsArg.elements.length === 0 && callbackArg) {
987
- migrateReplace(ctx, node, `onMount(${migrateGetNodeText(ctx, callbackArg)})`);
988
- migrateAddImport(ctx, "@pyreon/core", "onMount");
989
- ctx.changes.push({
990
- type: "replace",
991
- line: migrateGetLine(ctx, node),
992
- description: `${hookName}(fn, []) → onMount(fn)`
993
- });
994
- } else if (callbackArg) {
995
- migrateReplace(ctx, node, `effect(${migrateGetNodeText(ctx, callbackArg)})`);
996
- migrateAddImport(ctx, "@pyreon/reactivity", "effect");
997
- ctx.changes.push({
998
- type: "replace",
999
- line: migrateGetLine(ctx, node),
1000
- description: `${hookName} → effect (auto-tracks deps)`
1001
- });
1002
- }
1003
- }
1004
- function migrateUseMemo(ctx, node) {
1005
- const computeFn = node.arguments[0];
1006
- if (computeFn) {
1007
- migrateReplace(ctx, node, `computed(${migrateGetNodeText(ctx, computeFn)})`);
1008
- migrateAddImport(ctx, "@pyreon/reactivity", "computed");
1009
- ctx.changes.push({
1010
- type: "replace",
1011
- line: migrateGetLine(ctx, node),
1012
- description: "useMemo → computed (auto-tracks deps)"
1013
- });
1014
- }
1015
- }
1016
- function migrateUseCallback(ctx, node) {
1017
- const callbackFn = node.arguments[0];
1018
- if (callbackFn) {
1019
- migrateReplace(ctx, node, migrateGetNodeText(ctx, callbackFn));
1020
- ctx.changes.push({
1021
- type: "replace",
1022
- line: migrateGetLine(ctx, node),
1023
- description: "useCallback → plain function (not needed in Pyreon)"
1024
- });
1025
- }
1026
- }
1027
- function migrateUseRef(ctx, node) {
1028
- const arg = node.arguments[0];
1029
- if (arg && (arg.kind === ts.SyntaxKind.NullKeyword || ts.isIdentifier(arg) && arg.text === "undefined") || !arg) {
1030
- migrateReplace(ctx, node, "createRef()");
1031
- migrateAddImport(ctx, "@pyreon/core", "createRef");
1032
- ctx.changes.push({
1033
- type: "replace",
1034
- line: migrateGetLine(ctx, node),
1035
- description: "useRef(null) → createRef()"
1036
- });
1037
- } else {
1038
- migrateReplace(ctx, node, `signal(${migrateGetNodeText(ctx, arg)})`);
1039
- migrateAddImport(ctx, "@pyreon/reactivity", "signal");
1040
- ctx.changes.push({
1041
- type: "replace",
1042
- line: migrateGetLine(ctx, node),
1043
- description: "useRef(value) → signal(value)"
1044
- });
1045
- }
1046
- }
1047
- function migrateMemoWrapper(ctx, node) {
1048
- const callee = node.expression;
1049
- if ((ts.isIdentifier(callee) && callee.text === "memo" || isCallToReactDot(callee, "memo")) && node.arguments[0]) {
1050
- migrateReplace(ctx, node, migrateGetNodeText(ctx, node.arguments[0]));
1051
- ctx.changes.push({
1052
- type: "remove",
1053
- line: migrateGetLine(ctx, node),
1054
- description: "Removed memo() wrapper (not needed in Pyreon)"
1055
- });
1056
- }
1057
- }
1058
- function migrateForwardRef(ctx, node) {
1059
- const callee = node.expression;
1060
- if ((ts.isIdentifier(callee) && callee.text === "forwardRef" || isCallToReactDot(callee, "forwardRef")) && node.arguments[0]) {
1061
- migrateReplace(ctx, node, migrateGetNodeText(ctx, node.arguments[0]));
1062
- ctx.changes.push({
1063
- type: "remove",
1064
- line: migrateGetLine(ctx, node),
1065
- description: "Removed forwardRef wrapper (pass ref as normal prop in Pyreon)"
1066
- });
1067
- }
1068
- }
1069
- function migrateJsxAttributes(ctx, node) {
1070
- const attrName = node.name.text;
1071
- if (attrName in JSX_ATTR_REWRITES) {
1072
- const htmlAttr = JSX_ATTR_REWRITES[attrName];
1073
- ctx.replacements.push({
1074
- start: node.name.getStart(ctx.sf),
1075
- end: node.name.getEnd(),
1076
- text: htmlAttr
1077
- });
1078
- ctx.changes.push({
1079
- type: "replace",
1080
- line: migrateGetLine(ctx, node),
1081
- description: `${attrName} → ${htmlAttr}`
1082
- });
1083
- }
1084
- if (attrName === "onChange") {
1085
- const jsxElement = findParentJsxElement(node);
1086
- if (jsxElement) {
1087
- const tagName = getJsxTagName(jsxElement);
1088
- if (tagName === "input" || tagName === "textarea" || tagName === "select") {
1089
- ctx.replacements.push({
1090
- start: node.name.getStart(ctx.sf),
1091
- end: node.name.getEnd(),
1092
- text: "onInput"
1093
- });
1094
- ctx.changes.push({
1095
- type: "replace",
1096
- line: migrateGetLine(ctx, node),
1097
- description: `onChange on <${tagName}> → onInput (native DOM events)`
1098
- });
1099
- }
1100
- }
1101
- }
1102
- if (attrName === "dangerouslySetInnerHTML") migrateDangerouslySetInnerHTML(ctx, node);
1103
- }
1104
- function migrateDangerouslySetInnerHTML(ctx, node) {
1105
- if (!node.initializer || !ts.isJsxExpression(node.initializer) || !node.initializer.expression) return;
1106
- const expr = node.initializer.expression;
1107
- if (!ts.isObjectLiteralExpression(expr)) return;
1108
- const htmlProp = expr.properties.find(p => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === "__html");
1109
- if (htmlProp) {
1110
- migrateReplace(ctx, node, `innerHTML={${migrateGetNodeText(ctx, htmlProp.initializer)}}`);
1111
- ctx.changes.push({
1112
- type: "replace",
1113
- line: migrateGetLine(ctx, node),
1114
- description: "dangerouslySetInnerHTML → innerHTML"
1115
- });
1116
- }
1117
- }
1118
- function applyReplacements(code, ctx) {
1119
- for (const imp of ctx.importsToRemove) {
1120
- ctx.replacements.push({
1121
- start: imp.getStart(ctx.sf),
1122
- end: imp.getEnd(),
1123
- text: ""
1124
- });
1125
- ctx.changes.push({
1126
- type: "remove",
1127
- line: ctx.sf.getLineAndCharacterOfPosition(imp.getStart(ctx.sf)).line + 1,
1128
- description: "Removed React import"
1129
- });
1130
- }
1131
- ctx.replacements.sort((a, b) => b.start - a.start);
1132
- const applied = /* @__PURE__ */new Set();
1133
- const deduped = [];
1134
- for (const r of ctx.replacements) {
1135
- const key = `${r.start}:${r.end}`;
1136
- let overlaps = false;
1137
- for (const d of deduped) if (r.start < d.end && r.end > d.start) {
1138
- overlaps = true;
1139
- break;
1140
- }
1141
- if (!overlaps && !applied.has(key)) {
1142
- applied.add(key);
1143
- deduped.push(r);
1144
- }
1145
- }
1146
- deduped.sort((a, b) => a.start - b.start);
1147
- const parts = [];
1148
- let lastPos = 0;
1149
- for (const r of deduped) {
1150
- parts.push(code.slice(lastPos, r.start));
1151
- parts.push(r.text);
1152
- lastPos = r.end;
1153
- }
1154
- parts.push(code.slice(lastPos));
1155
- return parts.join("");
1156
- }
1157
- function insertPyreonImports(code, pyreonImports) {
1158
- if (pyreonImports.size === 0) return code;
1159
- const importLines = [];
1160
- const sorted = [...pyreonImports.entries()].sort(([a], [b]) => a.localeCompare(b));
1161
- for (const [source, specs] of sorted) {
1162
- const specList = [...specs].sort().join(", ");
1163
- importLines.push(`import { ${specList} } from "${source}"`);
1164
- }
1165
- const importBlock = importLines.join("\n");
1166
- const lastImportEnd = findLastImportEnd(code);
1167
- if (lastImportEnd > 0) return `${code.slice(0, lastImportEnd)}\n${importBlock}${code.slice(lastImportEnd)}`;
1168
- return `${importBlock}\n\n${code}`;
1169
- }
1170
- function migrateVisitNode(ctx, node) {
1171
- if (ts.isImportDeclaration(node)) migrateImportDeclaration(ctx, node);
1172
- if (isCallToHook(node, "useState")) migrateUseState(ctx, node);
1173
- if (isCallToEffectHook(node)) migrateUseEffect(ctx, node);
1174
- if (isCallToHook(node, "useMemo")) migrateUseMemo(ctx, node);
1175
- if (isCallToHook(node, "useCallback")) migrateUseCallback(ctx, node);
1176
- if (isCallToHook(node, "useRef")) migrateUseRef(ctx, node);
1177
- if (ts.isCallExpression(node)) migrateMemoWrapper(ctx, node);
1178
- if (ts.isCallExpression(node)) migrateForwardRef(ctx, node);
1179
- if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name)) migrateJsxAttributes(ctx, node);
1180
- }
1181
- function migrateVisit(ctx, node) {
1182
- ts.forEachChild(node, child => {
1183
- migrateVisitNode(ctx, child);
1184
- migrateVisit(ctx, child);
1185
- });
1186
- }
1187
- function migrateReactCode(code, filename = "input.tsx") {
1188
- const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
1189
- const diagnostics = detectReactPatterns(code, filename);
1190
- const ctx = {
1191
- sf,
1192
- code,
1193
- replacements: [],
1194
- changes: [],
1195
- pyreonImports: /* @__PURE__ */new Map(),
1196
- importsToRemove: /* @__PURE__ */new Set(),
1197
- specifierRewrites: /* @__PURE__ */new Map()
1198
- };
1199
- migrateVisit(ctx, sf);
1200
- let result = applyReplacements(code, ctx);
1201
- result = insertPyreonImports(result, ctx.pyreonImports);
1202
- result = result.replace(/\n{3,}/g, "\n\n");
1203
- return {
1204
- code: result,
1205
- diagnostics,
1206
- changes: ctx.changes
1207
- };
1208
- }
1209
- function findParentJsxElement(node) {
1210
- let current = node.parent;
1211
- while (current) {
1212
- if (ts.isJsxOpeningElement(current) || ts.isJsxSelfClosingElement(current)) return current;
1213
- if (ts.isJsxElement(current)) return current.openingElement;
1214
- if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) return null;
1215
- current = current.parent;
1216
- }
1217
- return null;
1218
- }
1219
- function getJsxTagName(node) {
1220
- const tagName = node.tagName;
1221
- if (ts.isIdentifier(tagName)) return tagName.text;
1222
- return "";
1223
- }
1224
- function findLastImportEnd(code) {
1225
- const importRe = /^import\s.+$/gm;
1226
- let lastEnd = 0;
1227
- let match;
1228
- while (true) {
1229
- match = importRe.exec(code);
1230
- if (!match) break;
1231
- lastEnd = match.index + match[0].length;
1232
- }
1233
- return lastEnd;
1234
- }
92
+ * React Pattern Interceptor — detects React/Vue patterns in code and provides
93
+ * structured diagnostics with exact fix suggestions for AI-assisted migration.
94
+ *
95
+ * Two modes:
96
+ * - `detectReactPatterns(code)` — returns diagnostics only (non-destructive)
97
+ * - `migrateReactCode(code)` — applies auto-fixes and returns transformed code
98
+ *
99
+ * Designed for three consumers:
100
+ * 1. Compiler pre-pass (warnings during build)
101
+ * 2. CLI `pyreon doctor` (project-wide scanning)
102
+ * 3. MCP server `migrate_react` / `validate` tools (AI agent integration)
103
+ */
104
+ type ReactDiagnosticCode = "react-import" | "react-dom-import" | "react-router-import" | "use-state" | "use-effect-mount" | "use-effect-deps" | "use-effect-no-deps" | "use-memo" | "use-callback" | "use-ref-dom" | "use-ref-box" | "use-reducer" | "use-layout-effect" | "memo-wrapper" | "forward-ref" | "class-name-prop" | "html-for-prop" | "on-change-input" | "dangerously-set-inner-html" | "dot-value-signal" | "array-map-jsx" | "key-on-for-child" | "create-context-import" | "use-context-import";
105
+ interface ReactDiagnostic {
106
+ /** Machine-readable code for filtering and programmatic handling */
107
+ code: ReactDiagnosticCode;
108
+ /** Human-readable message explaining the issue */
109
+ message: string;
110
+ /** 1-based line number */
111
+ line: number;
112
+ /** 0-based column */
113
+ column: number;
114
+ /** The code as written */
115
+ current: string;
116
+ /** The suggested Pyreon equivalent */
117
+ suggested: string;
118
+ /** Whether migrateReactCode can auto-fix this */
119
+ fixable: boolean;
120
+ }
121
+ interface MigrationChange {
122
+ type: "replace" | "remove" | "add";
123
+ line: number;
124
+ description: string;
125
+ }
126
+ interface MigrationResult {
127
+ /** Transformed source code */
128
+ code: string;
129
+ /** All detected patterns (including unfixable ones) */
130
+ diagnostics: ReactDiagnostic[];
131
+ /** Description of changes applied */
132
+ changes: MigrationChange[];
133
+ }
134
+ declare function detectReactPatterns(code: string, filename?: string): ReactDiagnostic[];
135
+ declare function migrateReactCode(code: string, filename?: string): MigrationResult;
1235
136
  /** Fast regex check — returns true if code likely contains React patterns worth analyzing */
1236
- function hasReactPatterns(code) {
1237
- return /\bfrom\s+['"]react/.test(code) || /\bfrom\s+['"]react-dom/.test(code) || /\bfrom\s+['"]react-router/.test(code) || /\buseState\s*[<(]/.test(code) || /\buseEffect\s*\(/.test(code) || /\buseMemo\s*\(/.test(code) || /\buseCallback\s*\(/.test(code) || /\buseRef\s*[<(]/.test(code) || /\buseReducer\s*[<(]/.test(code) || /\bReact\.memo\b/.test(code) || /\bforwardRef\s*[<(]/.test(code) || /\bclassName[=\s]/.test(code) || /\bhtmlFor[=\s]/.test(code) || /\.value\s*=/.test(code);
137
+ declare function hasReactPatterns(code: string): boolean;
138
+ interface ErrorDiagnosis {
139
+ cause: string;
140
+ fix: string;
141
+ fixCode?: string | undefined;
142
+ related?: string | undefined;
1238
143
  }
1239
144
  /** Diagnose an error message and return structured fix information */
1240
- function diagnoseError(error) {
1241
- for (const {
1242
- pattern,
1243
- diagnose
1244
- } of ERROR_PATTERNS) {
1245
- const match = error.match(pattern);
1246
- if (match) return diagnose(match);
1247
- }
1248
- return null;
1249
- }
1250
-
145
+ declare function diagnoseError(error: string): ErrorDiagnosis | null;
1251
146
  //#endregion
1252
- export { detectReactPatterns, diagnoseError, generateContext, hasReactPatterns, migrateReactCode, transformJSX };
1253
- //# sourceMappingURL=index.d.ts.map
147
+ export { type CompilerWarning, type ComponentInfo, type ErrorDiagnosis, type IslandInfo, type MigrationChange, type MigrationResult, type ProjectContext, type ReactDiagnostic, type ReactDiagnosticCode, type RouteInfo, type TransformResult, detectReactPatterns, diagnoseError, generateContext, hasReactPatterns, migrateReactCode, transformJSX };
148
+ //# sourceMappingURL=index2.d.ts.map