@chr33s/solarflare 0.0.2

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 (47) hide show
  1. package/package.json +52 -0
  2. package/readme.md +183 -0
  3. package/src/ast.ts +316 -0
  4. package/src/build.bundle-client.ts +404 -0
  5. package/src/build.bundle-server.ts +131 -0
  6. package/src/build.bundle.ts +48 -0
  7. package/src/build.emit-manifests.ts +25 -0
  8. package/src/build.hmr-entry.ts +88 -0
  9. package/src/build.scan.ts +182 -0
  10. package/src/build.ts +227 -0
  11. package/src/build.validate.ts +63 -0
  12. package/src/client.hmr.ts +78 -0
  13. package/src/client.styles.ts +68 -0
  14. package/src/client.ts +190 -0
  15. package/src/codemod.ts +688 -0
  16. package/src/console-forward.ts +254 -0
  17. package/src/critical-css.ts +103 -0
  18. package/src/devtools-json.ts +52 -0
  19. package/src/diff-dom-streaming.ts +406 -0
  20. package/src/early-flush.ts +125 -0
  21. package/src/early-hints.ts +83 -0
  22. package/src/fetch.ts +44 -0
  23. package/src/fs.ts +11 -0
  24. package/src/head.ts +876 -0
  25. package/src/hmr.ts +647 -0
  26. package/src/hydration.ts +238 -0
  27. package/src/manifest.runtime.ts +25 -0
  28. package/src/manifest.ts +23 -0
  29. package/src/paths.ts +96 -0
  30. package/src/render-priority.ts +69 -0
  31. package/src/route-cache.ts +163 -0
  32. package/src/router-deferred.ts +85 -0
  33. package/src/router-stream.ts +65 -0
  34. package/src/router.ts +535 -0
  35. package/src/runtime.ts +32 -0
  36. package/src/serialize.ts +38 -0
  37. package/src/server.hmr.ts +67 -0
  38. package/src/server.styles.ts +42 -0
  39. package/src/server.ts +480 -0
  40. package/src/solarflare.d.ts +101 -0
  41. package/src/speculation-rules.ts +171 -0
  42. package/src/store.ts +78 -0
  43. package/src/stream-assets.ts +135 -0
  44. package/src/stylesheets.ts +222 -0
  45. package/src/worker.config.ts +243 -0
  46. package/src/worker.ts +542 -0
  47. package/tsconfig.json +21 -0
package/src/codemod.ts ADDED
@@ -0,0 +1,688 @@
1
+ /**
2
+ * Codemod to transform:
3
+ * react -> preact
4
+ * react-router (framework mode) -> solarflare
5
+ * remix v2 (@remix-run/*) -> solarflare
6
+ */
7
+
8
+ import * as fs from "fs";
9
+ import { Project, SourceFile, SyntaxKind, Node } from "ts-morph";
10
+
11
+ interface RouteModule {
12
+ loader?: string;
13
+ action?: string;
14
+ component?: string;
15
+ meta?: string;
16
+ hasDefault?: boolean;
17
+ }
18
+
19
+ /** React → Preact import mapping */
20
+ const REACT_TO_PREACT_IMPORTS: Record<string, string> = {
21
+ react: "preact",
22
+ "react-dom": "preact",
23
+ "react-dom/client": "preact",
24
+ "react/jsx-runtime": "preact/jsx-runtime",
25
+ "react/jsx-dev-runtime": "preact/jsx-dev-runtime",
26
+ };
27
+
28
+ /** Remix v2 modules to filter/transform */
29
+ const REMIX_MODULES = [
30
+ "@remix-run/react",
31
+ "@remix-run/node",
32
+ "@remix-run/cloudflare",
33
+ "@remix-run/deno",
34
+ "@remix-run/server-runtime",
35
+ "@remix-run/router",
36
+ ] as const;
37
+
38
+ /** React hooks → Preact hooks mapping */
39
+ const REACT_HOOKS_TO_PREACT: Record<string, { source: string; name: string }> = {
40
+ useState: { source: "preact/hooks", name: "useState" },
41
+ useEffect: { source: "preact/hooks", name: "useEffect" },
42
+ useContext: { source: "preact/hooks", name: "useContext" },
43
+ useReducer: { source: "preact/hooks", name: "useReducer" },
44
+ useCallback: { source: "preact/hooks", name: "useCallback" },
45
+ useMemo: { source: "preact/hooks", name: "useMemo" },
46
+ useRef: { source: "preact/hooks", name: "useRef" },
47
+ useImperativeHandle: {
48
+ source: "preact/hooks",
49
+ name: "useImperativeHandle",
50
+ },
51
+ useLayoutEffect: { source: "preact/hooks", name: "useLayoutEffect" },
52
+ useDebugValue: { source: "preact/hooks", name: "useDebugValue" },
53
+ useId: { source: "preact/hooks", name: "useId" },
54
+ };
55
+
56
+ /** React types → Preact types mapping */
57
+ const REACT_TYPES_TO_PREACT: Record<string, { source: string; name: string }> = {
58
+ ReactNode: { source: "preact", name: "ComponentChildren" },
59
+ ReactElement: { source: "preact", name: "VNode" },
60
+ FC: { source: "preact", name: "FunctionComponent" },
61
+ FunctionComponent: { source: "preact", name: "FunctionComponent" },
62
+ Component: { source: "preact", name: "Component" },
63
+ ComponentType: { source: "preact", name: "ComponentType" },
64
+ JSX: { source: "preact", name: "JSX" },
65
+ ReactPortal: { source: "preact", name: "VNode" },
66
+ RefObject: { source: "preact", name: "Ref" },
67
+ };
68
+
69
+ interface TransformOptions {
70
+ dry?: boolean;
71
+ }
72
+
73
+ /** Transform source code directly (for testing) */
74
+ export function transformSource(source: string, filePath = "test.tsx") {
75
+ const project = new Project({ useInMemoryFileSystem: true });
76
+ const sourceFile = project.createSourceFile(filePath, source);
77
+
78
+ transformReactToPreact(sourceFile);
79
+
80
+ if (!filePath.includes("/routes/") && !filePath.includes("/app/")) {
81
+ return sourceFile.getFullText();
82
+ }
83
+
84
+ const routeModule = analyzeRouteModule(sourceFile);
85
+
86
+ if (filePath.endsWith("routes.ts") || filePath.endsWith("routes.tsx")) {
87
+ return transformRoutesConfig(sourceFile);
88
+ }
89
+
90
+ if (routeModule.loader || routeModule.action || routeModule.component) {
91
+ return sourceFile.getFullText();
92
+ }
93
+
94
+ return sourceFile.getFullText();
95
+ }
96
+
97
+ /** Main transformer function (reads from disk) */
98
+ export function transformer(filePath: string, options: TransformOptions = {}) {
99
+ if (filePath.includes("node_modules")) {
100
+ return null;
101
+ }
102
+
103
+ const project = new Project({ useInMemoryFileSystem: true });
104
+ const fileContent = fs.readFileSync(filePath, "utf-8");
105
+ const sourceFile = project.createSourceFile(filePath, fileContent);
106
+
107
+ transformReactToPreact(sourceFile);
108
+
109
+ if (!filePath.includes("/routes/") && !filePath.includes("/app/")) {
110
+ return sourceFile.getFullText();
111
+ }
112
+
113
+ const routeModule = analyzeRouteModule(sourceFile);
114
+
115
+ if (filePath.endsWith("routes.ts") || filePath.endsWith("routes.tsx")) {
116
+ return transformRoutesConfig(sourceFile);
117
+ }
118
+
119
+ if (routeModule.loader || routeModule.action || routeModule.component) {
120
+ return transformRouteModule(sourceFile, filePath, routeModule, options);
121
+ }
122
+
123
+ return sourceFile.getFullText();
124
+ }
125
+
126
+ /** Check if module is a Remix package */
127
+ function isRemixModule(module: string) {
128
+ return REMIX_MODULES.some((m) => module === m || module.startsWith(`${m}/`));
129
+ }
130
+
131
+ /** Transform React imports to Preact equivalents */
132
+ function transformReactToPreact(sourceFile: SourceFile) {
133
+ const hooksToAdd: string[] = [];
134
+ const importsToRemove: number[] = [];
135
+ const imports = sourceFile.getImportDeclarations();
136
+
137
+ for (let i = 0; i < imports.length; i++) {
138
+ const importDecl = imports[i];
139
+ const moduleSpecifier = importDecl.getModuleSpecifierValue();
140
+
141
+ if (REACT_TO_PREACT_IMPORTS[moduleSpecifier]) {
142
+ const newSource = REACT_TO_PREACT_IMPORTS[moduleSpecifier];
143
+ const namedImports = importDecl.getNamedImports();
144
+ const defaultImport = importDecl.getDefaultImport();
145
+
146
+ const hookImports: string[] = [];
147
+ const otherImports: string[] = [];
148
+
149
+ for (const named of namedImports) {
150
+ const importName = named.getName();
151
+ const alias = named.getAliasNode()?.getText();
152
+
153
+ if (REACT_HOOKS_TO_PREACT[importName]) {
154
+ hookImports.push(alias ? `${importName} as ${alias}` : importName);
155
+ hooksToAdd.push(...hookImports);
156
+ } else if (REACT_TYPES_TO_PREACT[importName]) {
157
+ const preactType = REACT_TYPES_TO_PREACT[importName];
158
+ otherImports.push(
159
+ alias || importName !== preactType.name
160
+ ? `${preactType.name} as ${alias || importName}`
161
+ : preactType.name,
162
+ );
163
+ } else {
164
+ otherImports.push(alias ? `${importName} as ${alias}` : importName);
165
+ }
166
+ }
167
+
168
+ if (otherImports.length > 0 || defaultImport) {
169
+ importDecl.setModuleSpecifier(newSource);
170
+ if (namedImports.length > 0) {
171
+ importDecl.removeNamedImports();
172
+ if (otherImports.length > 0) {
173
+ importDecl.addNamedImports(otherImports);
174
+ }
175
+ }
176
+ } else {
177
+ importsToRemove.push(i);
178
+ }
179
+ }
180
+
181
+ if (moduleSpecifier === "react" && importDecl.isTypeOnly()) {
182
+ const namedImports = importDecl.getNamedImports();
183
+ const transformedImports: string[] = [];
184
+
185
+ for (const named of namedImports) {
186
+ const importName = named.getName();
187
+ const alias = named.getAliasNode()?.getText();
188
+
189
+ if (REACT_TYPES_TO_PREACT[importName]) {
190
+ const preactType = REACT_TYPES_TO_PREACT[importName];
191
+ transformedImports.push(
192
+ alias || importName !== preactType.name
193
+ ? `${preactType.name} as ${alias || importName}`
194
+ : preactType.name,
195
+ );
196
+ } else {
197
+ transformedImports.push(alias ? `${importName} as ${alias}` : importName);
198
+ }
199
+ }
200
+
201
+ importDecl.setModuleSpecifier("preact");
202
+ importDecl.removeNamedImports();
203
+ importDecl.addNamedImports(transformedImports);
204
+ }
205
+
206
+ // Handle Remix v2 imports (@remix-run/*)
207
+ if (isRemixModule(moduleSpecifier)) {
208
+ importsToRemove.push(i);
209
+ }
210
+ }
211
+
212
+ for (const idx of importsToRemove.reverse()) {
213
+ imports[idx].remove();
214
+ }
215
+
216
+ if (hooksToAdd.length > 0) {
217
+ const uniqueHooks = [...new Set(hooksToAdd)];
218
+ sourceFile.addImportDeclaration({
219
+ moduleSpecifier: "preact/hooks",
220
+ namedImports: uniqueHooks,
221
+ });
222
+ }
223
+
224
+ const hasJsxFragment = sourceFile.getDescendantsOfKind(SyntaxKind.JsxFragment).length > 0;
225
+
226
+ if (hasJsxFragment) {
227
+ const preactImport = sourceFile.getImportDeclaration(
228
+ (d) => d.getModuleSpecifierValue() === "preact",
229
+ );
230
+ const hasFragment = preactImport?.getNamedImports().some((n) => n.getName() === "Fragment");
231
+
232
+ if (!hasFragment) {
233
+ if (preactImport) {
234
+ preactImport.addNamedImport("Fragment");
235
+ } else {
236
+ sourceFile.insertImportDeclaration(0, {
237
+ moduleSpecifier: "preact",
238
+ namedImports: ["Fragment"],
239
+ });
240
+ }
241
+ }
242
+ }
243
+ }
244
+
245
+ /** Analyze a React Router route module to extract exports */
246
+ function analyzeRouteModule(sourceFile: SourceFile) {
247
+ const module: RouteModule = {};
248
+
249
+ for (const exportDecl of sourceFile.getExportedDeclarations()) {
250
+ const [name, declarations] = exportDecl;
251
+
252
+ for (const decl of declarations) {
253
+ if (Node.isFunctionDeclaration(decl)) {
254
+ const funcName = decl.getName();
255
+ if (funcName === "loader") {
256
+ module.loader = decl.getFullText();
257
+ } else if (funcName === "action") {
258
+ module.action = decl.getFullText();
259
+ } else if (funcName === "meta") {
260
+ module.meta = decl.getFullText();
261
+ }
262
+ }
263
+ }
264
+
265
+ if (name === "default") {
266
+ module.hasDefault = true;
267
+ module.component = declarations[0]?.getFullText();
268
+ }
269
+ }
270
+
271
+ return module;
272
+ }
273
+
274
+ /** Transform a React Router route module into Solarflare server + client files */
275
+ function transformRouteModule(
276
+ sourceFile: SourceFile,
277
+ filePath: string,
278
+ routeModule: RouteModule,
279
+ options: TransformOptions,
280
+ ) {
281
+ const baseName = filePath.replace(/\.(tsx|ts|jsx|js)$/, "");
282
+
283
+ if (routeModule.loader || routeModule.action) {
284
+ const serverFile = generateServerFile(sourceFile, routeModule);
285
+ const serverPath = `${baseName}.server.tsx`;
286
+
287
+ if (options.dry) {
288
+ console.log(`Would create: ${serverPath}`);
289
+ } else {
290
+ fs.writeFileSync(serverPath, serverFile);
291
+ console.log(`✓ Created: ${serverPath}`);
292
+ }
293
+ }
294
+
295
+ if (routeModule.component) {
296
+ const clientFile = generateClientFile(sourceFile, routeModule);
297
+ const clientPath = `${baseName}.client.tsx`;
298
+
299
+ if (options.dry) {
300
+ console.log(`Would create: ${clientPath}`);
301
+ } else {
302
+ fs.writeFileSync(clientPath, clientFile);
303
+ console.log(`✓ Created: ${clientPath}`);
304
+ }
305
+ }
306
+
307
+ return `// This file has been split into .server.tsx and .client.tsx for Solarflare`;
308
+ }
309
+
310
+ /** Generate Solarflare server handler from React Router loader/action */
311
+ function generateServerFile(sourceFile: SourceFile, routeModule: RouteModule) {
312
+ const imports: string[] = [];
313
+ const serverCode: string[] = [];
314
+
315
+ for (const importDecl of sourceFile.getImportDeclarations()) {
316
+ const source = importDecl.getModuleSpecifierValue();
317
+ if (
318
+ !source.includes("react-router") &&
319
+ !isRemixModule(source) &&
320
+ !source.includes("react") &&
321
+ !source.includes("react-dom")
322
+ ) {
323
+ imports.push(importDecl.getFullText().trim());
324
+ }
325
+ }
326
+
327
+ serverCode.push(`// Solarflare server handler`);
328
+ serverCode.push(`// Converted from React Router loader/action\n`);
329
+
330
+ if (imports.length > 0) {
331
+ serverCode.push(...imports);
332
+ serverCode.push("");
333
+ }
334
+
335
+ serverCode.push(
336
+ `export default async function server(request: Request, params: Record<string, string>) {`,
337
+ );
338
+ serverCode.push(` const method = request.method;`);
339
+ serverCode.push("");
340
+
341
+ if (routeModule.action) {
342
+ serverCode.push(` // Handle POST/PUT/DELETE (converted from action)`);
343
+ serverCode.push(` if (method !== 'GET') {`);
344
+ serverCode.push(` ${transformActionToServerHandler(routeModule.action)}`);
345
+ serverCode.push(` }`);
346
+ serverCode.push("");
347
+ }
348
+
349
+ if (routeModule.loader) {
350
+ serverCode.push(` // Handle GET (converted from loader)`);
351
+ serverCode.push(` ${transformLoaderToServerHandler(routeModule.loader)}`);
352
+ } else {
353
+ serverCode.push(` return {};`);
354
+ }
355
+
356
+ serverCode.push(`}`);
357
+
358
+ return serverCode.join("\n");
359
+ }
360
+
361
+ /** Transform React Router/Remix loader to Solarflare server handler */
362
+ function transformLoaderToServerHandler(loaderCode: string) {
363
+ let body = loaderCode
364
+ .replace(/export (async )?function loader\s*\([^)]*\)\s*{/, "")
365
+ .replace(/}$/, "")
366
+ .trim();
367
+
368
+ body = body
369
+ .replace(/import\s+{\s*json\s*}/g, "// json() not needed in Solarflare")
370
+ .replace(/return\s+json\(/g, "return ")
371
+ .replace(/\);\s*$/gm, ";")
372
+ // React Router v7 patterns
373
+ .replace(
374
+ /{\s*params\s*}:\s*Route\.LoaderArgs/g,
375
+ "request: Request, params: Record<string, string>",
376
+ )
377
+ .replace(
378
+ /{\s*request,?\s*params\s*}:\s*Route\.LoaderArgs/g,
379
+ "request: Request, params: Record<string, string>",
380
+ )
381
+ // Remix v2 patterns
382
+ .replace(
383
+ /{\s*params\s*}:\s*LoaderFunctionArgs/g,
384
+ "request: Request, params: Record<string, string>",
385
+ )
386
+ .replace(
387
+ /{\s*request,?\s*params\s*}:\s*LoaderFunctionArgs/g,
388
+ "request: Request, params: Record<string, string>",
389
+ )
390
+ .replace(
391
+ /{\s*params,?\s*request\s*}:\s*LoaderFunctionArgs/g,
392
+ "request: Request, params: Record<string, string>",
393
+ )
394
+ // Handle defer() -> streaming props
395
+ .replace(/return\s+defer\(/g, "return ");
396
+
397
+ return body;
398
+ }
399
+
400
+ /** Transform React Router/Remix action to Solarflare server handler */
401
+ function transformActionToServerHandler(actionCode: string) {
402
+ let body = actionCode
403
+ .replace(/export (async )?function action\s*\([^)]*\)\s*{/, "")
404
+ .replace(/}$/, "")
405
+ .trim();
406
+
407
+ body = body
408
+ .replace(
409
+ /const\s+formData\s*=\s*await\s+request\.formData\(\)/g,
410
+ "const formData = await request.formData()",
411
+ )
412
+ .replace(/return\s+json\(/g, "return ")
413
+ .replace(
414
+ /return\s+redirect\(/g,
415
+ "return new Response(null, { status: 302, headers: { Location: ",
416
+ )
417
+ .replace(
418
+ /return\s+redirectDocument\(/g,
419
+ "return new Response(null, { status: 302, headers: { Location: ",
420
+ )
421
+ .replace(/\);\s*$/gm, " } });");
422
+
423
+ return body;
424
+ }
425
+
426
+ /** Generate Solarflare client component from React Router component */
427
+ function generateClientFile(sourceFile: SourceFile, routeModule: RouteModule) {
428
+ const imports: string[] = [];
429
+ const clientCode: string[] = [];
430
+
431
+ clientCode.push(`// Solarflare client component`);
432
+ clientCode.push(`// Converted from React Router route component\n`);
433
+
434
+ for (const importDecl of sourceFile.getImportDeclarations()) {
435
+ const source = importDecl.getModuleSpecifierValue();
436
+ let importCode = importDecl.getFullText().trim();
437
+
438
+ if (source.includes("react") && !source.includes("react-router")) {
439
+ importCode = transformReactImportToPreact(importCode, source);
440
+ }
441
+
442
+ if (source.includes("react-router")) {
443
+ importCode = transformReactRouterImports(importCode);
444
+ }
445
+
446
+ // Handle Remix imports
447
+ if (isRemixModule(source)) {
448
+ importCode = transformReactRouterImports(importCode);
449
+ }
450
+
451
+ if (
452
+ importCode &&
453
+ !importCode.includes("LoaderArgs") &&
454
+ !importCode.includes("ActionArgs") &&
455
+ !importCode.includes("ComponentProps") &&
456
+ !importCode.includes("LoaderFunctionArgs") &&
457
+ !importCode.includes("ActionFunctionArgs") &&
458
+ !importCode.includes("MetaFunction") &&
459
+ !importCode.includes("LinksFunction")
460
+ ) {
461
+ imports.push(importCode);
462
+ }
463
+ }
464
+
465
+ const hasPreactImport = imports.some(
466
+ (imp) => imp.includes("from 'preact'") || imp.includes('from "preact"'),
467
+ );
468
+ if (!hasPreactImport) {
469
+ imports.unshift(`import type { VNode } from 'preact';`);
470
+ }
471
+
472
+ if (imports.length > 0) {
473
+ clientCode.push(...imports);
474
+ clientCode.push("");
475
+ }
476
+
477
+ if (routeModule.component) {
478
+ let componentCode = routeModule.component
479
+ .replace(/: React\.ReactNode/g, ": VNode")
480
+ .replace(/: ReactNode/g, ": VNode")
481
+ .replace(/: React\.ReactElement/g, ": VNode")
482
+ .replace(/: ReactElement/g, ": VNode")
483
+ .replace(/React\.FC</g, "FunctionComponent<")
484
+ .replace(/: React\.FC/g, ": FunctionComponent")
485
+ .replace(/: FC</g, ": FunctionComponent<")
486
+ // React Router v7 patterns
487
+ .replace(
488
+ /export default function (\w+)\s*\(\s*{\s*loaderData\s*}:\s*Route\.ComponentProps\s*\)/g,
489
+ "export default function $1(props: any)",
490
+ )
491
+ // Remix v2 patterns - useLoaderData hook
492
+ .replace(/const\s+(\w+)\s*=\s*useLoaderData<[^>]*>\(\)/g, "const $1 = props")
493
+ .replace(/const\s+(\w+)\s*=\s*useLoaderData\(\)/g, "const $1 = props")
494
+ .replace(/useLoaderData<[^>]*>\(\)/g, "props")
495
+ .replace(/useLoaderData\(\)/g, "props")
496
+ // Remix v2 patterns - useActionData hook
497
+ .replace(/const\s+(\w+)\s*=\s*useActionData<[^>]*>\(\)/g, "const $1 = props.actionData")
498
+ .replace(/const\s+(\w+)\s*=\s*useActionData\(\)/g, "const $1 = props.actionData")
499
+ .replace(/useActionData<[^>]*>\(\)/g, "props.actionData")
500
+ .replace(/useActionData\(\)/g, "props.actionData")
501
+ // Remix v2 patterns - useFetcher
502
+ .replace(/const\s+(\w+)\s*=\s*useFetcher<[^>]*>\(\)/g, "/* TODO: migrate $1 fetcher */")
503
+ .replace(/const\s+(\w+)\s*=\s*useFetcher\(\)/g, "/* TODO: migrate $1 fetcher */")
504
+ // Form component transformations
505
+ .replace(/<Form /g, "<form ")
506
+ .replace(/<\/Form>/g, "</form>")
507
+ .replace(/import\s*{\s*Form\s*}\s*from\s*["']react-router["'];?\s*/g, "")
508
+ .replace(/import\s*{\s*Form\s*}\s*from\s*["']@remix-run\/react["'];?\s*/g, "")
509
+ // Link/NavLink transformations
510
+ .replace(/<Link\s+to=/g, "<a href=")
511
+ .replace(/<\/Link>/g, "</a>")
512
+ .replace(/<NavLink\s+to=/g, "<a href=")
513
+ .replace(/<\/NavLink>/g, "</a>");
514
+
515
+ componentCode = componentCode
516
+ .replace(
517
+ /const\s+navigate\s*=\s*useNavigate\(\)/g,
518
+ "/* use navigate from @chr33s/solarflare/client */",
519
+ )
520
+ .replace(/navigate\(/g, "navigate(");
521
+
522
+ clientCode.push(componentCode);
523
+ }
524
+
525
+ return clientCode.join("\n");
526
+ }
527
+
528
+ /** Transform a single React import statement to Preact */
529
+ function transformReactImportToPreact(importStatement: string, source: string) {
530
+ if (source === "react") {
531
+ return importStatement.replace(/from ['"]react['"]/, "from 'preact'");
532
+ }
533
+
534
+ if (source === "react-dom" || source === "react-dom/client") {
535
+ return importStatement.replace(/from ['"]react-dom(\/client)?['"]/, "from 'preact'");
536
+ }
537
+
538
+ return importStatement;
539
+ }
540
+
541
+ /** Transform React Router/Remix imports to Solarflare equivalents */
542
+ function transformReactRouterImports(importStatement: string) {
543
+ if (importStatement.includes("useNavigate") || importStatement.includes("Link")) {
544
+ return `import { navigate } from '@chr33s/solarflare/client';`;
545
+ }
546
+
547
+ if (importStatement.includes("Outlet")) {
548
+ return `/* Outlet not needed - use { children } prop in layouts */`;
549
+ }
550
+
551
+ // Remix-specific components that need transformation
552
+ if (importStatement.includes("Form")) {
553
+ return `/* Form -> use native <form> with action props */`;
554
+ }
555
+
556
+ if (importStatement.includes("NavLink")) {
557
+ return `import { navigate } from '@chr33s/solarflare/client';`;
558
+ }
559
+
560
+ // Meta/Links/Scripts are handled by Solarflare's head management
561
+ if (
562
+ importStatement.includes("Meta") ||
563
+ importStatement.includes("Links") ||
564
+ importStatement.includes("Scripts")
565
+ ) {
566
+ return `/* Meta/Links/Scripts -> use Solarflare head management */`;
567
+ }
568
+
569
+ return "";
570
+ }
571
+
572
+ /** Transform routes.ts config to Solarflare file structure */
573
+ function transformRoutesConfig(sourceFile: SourceFile) {
574
+ const output: string[] = [];
575
+
576
+ output.push(`// Solarflare uses file-based routing`);
577
+ output.push(`// Convert your routes.ts configuration to the following file structure:`);
578
+ output.push(`//`);
579
+ output.push(`// src/`);
580
+ output.push(`// index.server.tsx # Root route (path: "/")`);
581
+ output.push(`// index.client.tsx`);
582
+ output.push(`// _layout.tsx # Root layout`);
583
+ output.push(`// about.server.tsx # /about route`);
584
+ output.push(`// about.client.tsx`);
585
+ output.push(`// blog/`);
586
+ output.push(`// _layout.tsx # Blog layout`);
587
+ output.push(`// $slug.server.tsx # /blog/:slug (dynamic param)`);
588
+ output.push(`// $slug.client.tsx`);
589
+ output.push(`// api.server.ts # API endpoint (no .client needed)`);
590
+ output.push(`//`);
591
+ output.push(`// Naming conventions:`);
592
+ output.push(`// index.* → directory root (/)`);
593
+ output.push(`// $param → :param (dynamic segment)`);
594
+ output.push(`// _layout.tsx → layout wrapper`);
595
+ output.push(`// _* → private (not routed)`);
596
+ output.push(`// *.server.tsx → server handler`);
597
+ output.push(`// *.client.tsx → client component`);
598
+ output.push("");
599
+
600
+ const defaultExport = sourceFile.getDefaultExportSymbol();
601
+ if (defaultExport) {
602
+ const declarations = defaultExport.getDeclarations();
603
+ for (const decl of declarations) {
604
+ if (Node.isExportAssignment(decl)) {
605
+ const expr = decl.getExpression();
606
+ if (Node.isArrayLiteralExpression(expr)) {
607
+ output.push("// Routes found in your config:");
608
+ extractRouteStructure(expr, output, "");
609
+ }
610
+ }
611
+ }
612
+ }
613
+
614
+ return output.join("\n");
615
+ }
616
+
617
+ /** Extract route structure from React Router config */
618
+ function extractRouteStructure(node: Node, output: string[], indent: string) {
619
+ if (Node.isArrayLiteralExpression(node)) {
620
+ for (const element of node.getElements()) {
621
+ if (Node.isCallExpression(element)) {
622
+ const callee = element.getExpression();
623
+ if (Node.isIdentifier(callee)) {
624
+ const calleeName = callee.getText();
625
+ const args = element.getArguments();
626
+
627
+ if (calleeName === "route" && args.length >= 2) {
628
+ const pathArg = args[0];
629
+ const fileArg = args[1];
630
+ const routePath = Node.isStringLiteral(pathArg) ? pathArg.getLiteralValue() : "?";
631
+ const file = Node.isStringLiteral(fileArg) ? fileArg.getLiteralValue() : "?";
632
+ output.push(`${indent}// Route: ${routePath} → convert ${file} to file-based routing`);
633
+ } else if (calleeName === "index" && args.length >= 1) {
634
+ const fileArg = args[0];
635
+ const file = Node.isStringLiteral(fileArg) ? fileArg.getLiteralValue() : "?";
636
+ output.push(
637
+ `${indent}// Index route → convert ${file} to index.server.tsx + index.client.tsx`,
638
+ );
639
+ } else if (calleeName === "layout" && args.length >= 1) {
640
+ const fileArg = args[0];
641
+ const file = Node.isStringLiteral(fileArg) ? fileArg.getLiteralValue() : "?";
642
+ output.push(`${indent}// Layout → convert ${file} to _layout.tsx`);
643
+ }
644
+ }
645
+ }
646
+ }
647
+ }
648
+ }
649
+
650
+ /** Recursively collect files from a path (file or directory) */
651
+ function collectFiles(inputPath: string) {
652
+ const stat = fs.statSync(inputPath);
653
+ if (stat.isFile()) {
654
+ return [inputPath];
655
+ }
656
+ if (stat.isDirectory()) {
657
+ const files: string[] = [];
658
+ for (const entry of fs.readdirSync(inputPath, { withFileTypes: true })) {
659
+ if (entry.name.startsWith(".")) continue;
660
+ const fullPath = `${inputPath}/${entry.name}`;
661
+ if (entry.isDirectory()) {
662
+ files.push(...collectFiles(fullPath));
663
+ } else if (/\.(tsx?|jsx?)$/.test(entry.name)) {
664
+ files.push(fullPath);
665
+ }
666
+ }
667
+ return files;
668
+ }
669
+ return [];
670
+ }
671
+
672
+ /** CLI entry point */
673
+ export function codemod(paths: string[], options: TransformOptions = {}) {
674
+ const files = paths.flatMap(collectFiles);
675
+ for (const filePath of files) {
676
+ try {
677
+ const result = transformer(filePath, options);
678
+ if (result !== null) {
679
+ if (!options.dry) {
680
+ fs.writeFileSync(filePath, result);
681
+ }
682
+ console.log(`✓ Transformed: ${filePath}`);
683
+ }
684
+ } catch (error) {
685
+ console.error(`✗ Error processing ${filePath}:`, error);
686
+ }
687
+ }
688
+ }