almostnode 0.2.5 → 0.2.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.
- package/LICENSE +1 -1
- package/dist/__sw__.js +25 -16
- package/dist/frameworks/code-transforms.d.ts +53 -0
- package/dist/frameworks/code-transforms.d.ts.map +1 -0
- package/dist/frameworks/next-dev-server.d.ts +80 -8
- package/dist/frameworks/next-dev-server.d.ts.map +1 -1
- package/dist/frameworks/tailwind-config-loader.d.ts +32 -0
- package/dist/frameworks/tailwind-config-loader.d.ts.map +1 -0
- package/dist/frameworks/vite-dev-server.d.ts +0 -4
- package/dist/frameworks/vite-dev-server.d.ts.map +1 -1
- package/dist/index.cjs +21775 -502
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +19949 -1058
- package/dist/index.mjs.map +1 -1
- package/dist/macaly-demo.d.ts +42 -0
- package/dist/macaly-demo.d.ts.map +1 -0
- package/package.json +6 -1
- package/src/convex-app-demo-entry.ts +2 -0
- package/src/frameworks/code-transforms.ts +577 -0
- package/src/frameworks/next-dev-server.ts +1583 -185
- package/src/frameworks/tailwind-config-loader.ts +206 -0
- package/src/frameworks/vite-dev-server.ts +2 -61
- package/src/macaly-demo.ts +172 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Macaly Demo - Load the REAL macaly-web repository into almostnode
|
|
3
|
+
* This tests almostnode's ability to run a real-world Next.js app
|
|
4
|
+
*/
|
|
5
|
+
import { VirtualFS } from './virtual-fs';
|
|
6
|
+
import { createRuntime } from './create-runtime';
|
|
7
|
+
import type { IRuntime } from './runtime-interface';
|
|
8
|
+
import { NextDevServer } from './frameworks/next-dev-server';
|
|
9
|
+
/**
|
|
10
|
+
* Files to load from the real macaly-web repository
|
|
11
|
+
* We'll populate this dynamically, but here's the structure we need
|
|
12
|
+
*/
|
|
13
|
+
export interface MacalyFiles {
|
|
14
|
+
[path: string]: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Load the real macaly-web project into VirtualFS
|
|
18
|
+
*/
|
|
19
|
+
export declare function loadMacalyProject(vfs: VirtualFS, files: MacalyFiles): void;
|
|
20
|
+
/**
|
|
21
|
+
* Initialize the Macaly demo with real files
|
|
22
|
+
*/
|
|
23
|
+
export declare function initMacalyDemo(outputElement: HTMLElement, files: MacalyFiles, options?: {
|
|
24
|
+
useWorker?: boolean;
|
|
25
|
+
}): Promise<{
|
|
26
|
+
vfs: VirtualFS;
|
|
27
|
+
runtime: IRuntime;
|
|
28
|
+
}>;
|
|
29
|
+
/**
|
|
30
|
+
* Start the Macaly dev server
|
|
31
|
+
*/
|
|
32
|
+
export declare function startMacalyDevServer(vfs: VirtualFS, options?: {
|
|
33
|
+
port?: number;
|
|
34
|
+
log?: (message: string) => void;
|
|
35
|
+
}): Promise<{
|
|
36
|
+
server: NextDevServer;
|
|
37
|
+
url: string;
|
|
38
|
+
stop: () => void;
|
|
39
|
+
}>;
|
|
40
|
+
export { VirtualFS, NextDevServer, createRuntime };
|
|
41
|
+
export type { IRuntime };
|
|
42
|
+
//# sourceMappingURL=macaly-demo.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"macaly-demo.d.ts","sourceRoot":"","sources":["../src/macaly-demo.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAG7D;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;CACxB;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,SAAS,EAAE,KAAK,EAAE,WAAW,GAAG,IAAI,CAqB1E;AAED;;GAEG;AACH,wBAAsB,cAAc,CAClC,aAAa,EAAE,WAAW,EAC1B,KAAK,EAAE,WAAW,EAClB,OAAO,GAAE;IAAE,SAAS,CAAC,EAAE,OAAO,CAAA;CAAO,GACpC,OAAO,CAAC;IAAE,GAAG,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,QAAQ,CAAA;CAAE,CAAC,CA0ChD;AAED;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,SAAS,EACd,OAAO,GAAE;IACP,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CAC5B,GACL,OAAO,CAAC;IACT,MAAM,EAAE,aAAa,CAAC;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,IAAI,CAAC;CAClB,CAAC,CA0DD;AAGD,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,aAAa,EAAE,CAAC;AACnD,YAAY,EAAE,QAAQ,EAAE,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "almostnode",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "Node.js in your browser. Just like that.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -84,9 +84,12 @@
|
|
|
84
84
|
"prepublishOnly": "npm run test:run && npm run build:publish"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
+
"acorn": "^8.15.0",
|
|
88
|
+
"acorn-jsx": "^5.3.2",
|
|
87
89
|
"brotli": "^1.3.3",
|
|
88
90
|
"brotli-wasm": "^3.0.1",
|
|
89
91
|
"comlink": "^4.4.2",
|
|
92
|
+
"css-tree": "^3.1.0",
|
|
90
93
|
"just-bash": "^2.7.0",
|
|
91
94
|
"pako": "^2.1.0",
|
|
92
95
|
"resolve.exports": "^2.0.3",
|
|
@@ -95,8 +98,10 @@
|
|
|
95
98
|
},
|
|
96
99
|
"devDependencies": {
|
|
97
100
|
"@playwright/test": "^1.58.0",
|
|
101
|
+
"@types/css-tree": "^2.3.11",
|
|
98
102
|
"@types/node": "^25.0.10",
|
|
99
103
|
"@types/pako": "^2.0.4",
|
|
104
|
+
"esbuild": "^0.27.2",
|
|
100
105
|
"jsdom": "^27.4.0",
|
|
101
106
|
"typescript": "^5.9.3",
|
|
102
107
|
"vite": "^5.4.0",
|
|
@@ -907,6 +907,8 @@ async function main() {
|
|
|
907
907
|
log('Creating Convex App project structure...');
|
|
908
908
|
createConvexAppProject(vfs);
|
|
909
909
|
log('Project files created', 'success');
|
|
910
|
+
log(' /convex/schema.ts');
|
|
911
|
+
log(' /convex/todos.ts');
|
|
910
912
|
|
|
911
913
|
// Expose VFS to window and build file tree
|
|
912
914
|
exposeVfsToWindow();
|
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared code transformation utilities.
|
|
3
|
+
* Extracted from next-dev-server.ts and vite-dev-server.ts to avoid duplication
|
|
4
|
+
* and enable AST-based replacements.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as acorn from 'acorn';
|
|
8
|
+
import * as csstree from 'css-tree';
|
|
9
|
+
import { simpleHash } from '../utils/hash';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Interface for file system operations needed by CSS module transforms.
|
|
13
|
+
*/
|
|
14
|
+
export interface CssModuleContext {
|
|
15
|
+
readFile: (path: string) => string;
|
|
16
|
+
exists: (path: string) => boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a relative path from a directory.
|
|
21
|
+
* Pure function — no dependencies.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveRelativePath(dir: string, relativePath: string): string {
|
|
24
|
+
const parts = dir.split('/').filter(Boolean);
|
|
25
|
+
const relParts = relativePath.split('/');
|
|
26
|
+
|
|
27
|
+
for (const part of relParts) {
|
|
28
|
+
if (part === '..') {
|
|
29
|
+
parts.pop();
|
|
30
|
+
} else if (part !== '.' && part !== '') {
|
|
31
|
+
parts.push(part);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return '/' + parts.join('/');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve a CSS module path relative to the current file context.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveCssModulePath(
|
|
42
|
+
cssPath: string,
|
|
43
|
+
currentFile: string | undefined,
|
|
44
|
+
ctx: CssModuleContext,
|
|
45
|
+
): string | null {
|
|
46
|
+
// If relative path and we have a current file, resolve relative to it
|
|
47
|
+
if (currentFile && (cssPath.startsWith('./') || cssPath.startsWith('../'))) {
|
|
48
|
+
const dir = currentFile.replace(/\/[^/]+$/, '');
|
|
49
|
+
const resolved = resolveRelativePath(dir, cssPath);
|
|
50
|
+
if (ctx.exists(resolved)) return resolved;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Try the path as-is first (absolute)
|
|
54
|
+
if (ctx.exists(cssPath)) return cssPath;
|
|
55
|
+
|
|
56
|
+
// Try with leading slash
|
|
57
|
+
const withSlash = '/' + cssPath.replace(/^\.\//, '');
|
|
58
|
+
if (ctx.exists(withSlash)) return withSlash;
|
|
59
|
+
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Generate replacement code for a CSS Module import.
|
|
65
|
+
* Parses the CSS file, extracts class names, generates scoped names,
|
|
66
|
+
* and injects the scoped CSS via a style tag.
|
|
67
|
+
*/
|
|
68
|
+
export function generateCssModuleReplacement(
|
|
69
|
+
varName: string,
|
|
70
|
+
cssPath: string,
|
|
71
|
+
currentFile: string | undefined,
|
|
72
|
+
ctx: CssModuleContext,
|
|
73
|
+
): string {
|
|
74
|
+
try {
|
|
75
|
+
const resolvedPath = resolveCssModulePath(cssPath, currentFile, ctx);
|
|
76
|
+
if (!resolvedPath) {
|
|
77
|
+
return `const ${varName} = {};`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const cssContent = ctx.readFile(resolvedPath);
|
|
81
|
+
const fileHash = simpleHash(resolvedPath + cssContent).slice(0, 6);
|
|
82
|
+
|
|
83
|
+
// Parse CSS into AST and extract class names
|
|
84
|
+
const classMap: Record<string, string> = {};
|
|
85
|
+
const ast = csstree.parse(cssContent);
|
|
86
|
+
|
|
87
|
+
// First pass: collect all class selector names
|
|
88
|
+
csstree.walk(ast, {
|
|
89
|
+
visit: 'ClassSelector',
|
|
90
|
+
enter(node: csstree.ClassSelector) {
|
|
91
|
+
if (!classMap[node.name]) {
|
|
92
|
+
classMap[node.name] = `${node.name}_${fileHash}`;
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Second pass: mutate class selectors to scoped names
|
|
98
|
+
csstree.walk(ast, {
|
|
99
|
+
visit: 'ClassSelector',
|
|
100
|
+
enter(node: csstree.ClassSelector) {
|
|
101
|
+
if (classMap[node.name]) {
|
|
102
|
+
node.name = classMap[node.name];
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Generate scoped CSS from the mutated AST
|
|
108
|
+
const scopedCss = csstree.generate(ast);
|
|
109
|
+
|
|
110
|
+
// Escape the CSS for embedding in JS
|
|
111
|
+
const escapedCss = scopedCss
|
|
112
|
+
.replace(/\\/g, '\\\\')
|
|
113
|
+
.replace(/`/g, '\\`')
|
|
114
|
+
.replace(/\$/g, '\\$');
|
|
115
|
+
|
|
116
|
+
// Generate inline code that injects styles and exports class map
|
|
117
|
+
const mapEntries = Object.entries(classMap)
|
|
118
|
+
.map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`)
|
|
119
|
+
.join(', ');
|
|
120
|
+
|
|
121
|
+
return `const ${varName} = {${mapEntries}};
|
|
122
|
+
(function() {
|
|
123
|
+
if (typeof document !== 'undefined') {
|
|
124
|
+
var id = ${JSON.stringify('cssmod-' + fileHash)};
|
|
125
|
+
if (!document.getElementById(id)) {
|
|
126
|
+
var s = document.createElement('style');
|
|
127
|
+
s.id = id;
|
|
128
|
+
s.textContent = \`${escapedCss}\`;
|
|
129
|
+
document.head.appendChild(s);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
})();`;
|
|
133
|
+
} catch {
|
|
134
|
+
return `const ${varName} = {};`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Strip CSS imports from code (they are loaded via <link> tags instead).
|
|
140
|
+
* CSS Module imports (*.module.css) are converted to inline objects with class name mappings.
|
|
141
|
+
* Regular CSS imports are stripped entirely.
|
|
142
|
+
*/
|
|
143
|
+
export function stripCssImports(
|
|
144
|
+
code: string,
|
|
145
|
+
currentFile: string | undefined,
|
|
146
|
+
ctx: CssModuleContext,
|
|
147
|
+
): string {
|
|
148
|
+
// First handle CSS Module imports: import styles from './Component.module.css'
|
|
149
|
+
code = code.replace(
|
|
150
|
+
/import\s+(\w+)\s+from\s+['"]([^'"]+\.module\.css)['"]\s*;?/g,
|
|
151
|
+
(_match, varName, cssPath) => {
|
|
152
|
+
return generateCssModuleReplacement(varName, cssPath, currentFile, ctx);
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Handle destructured CSS Module imports: import { foo, bar } from './Component.module.css'
|
|
157
|
+
code = code.replace(
|
|
158
|
+
/import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+\.module\.css)['"]\s*;?/g,
|
|
159
|
+
(_match, names, cssPath) => {
|
|
160
|
+
const varName = '__cssModule_' + simpleHash(cssPath);
|
|
161
|
+
const replacement = generateCssModuleReplacement(varName, cssPath, currentFile, ctx);
|
|
162
|
+
const namedExports = (names as string).split(',').map((n: string) => {
|
|
163
|
+
const trimmed = n.trim();
|
|
164
|
+
const parts = trimmed.split(/\s+as\s+/);
|
|
165
|
+
const key = parts[0].trim();
|
|
166
|
+
const alias = parts[1]?.trim() || key;
|
|
167
|
+
return `const ${alias} = ${varName}[${JSON.stringify(key)}];`;
|
|
168
|
+
}).join('\n');
|
|
169
|
+
return `${replacement}\n${namedExports}`;
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// Strip remaining plain CSS imports (non-module)
|
|
174
|
+
return code.replace(/import\s+['"][^'"]+\.css['"]\s*;?/g, '');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Explicit mappings for common packages (ensures correct esm.sh URLs)
|
|
178
|
+
const EXPLICIT_MAPPINGS: Record<string, string> = {
|
|
179
|
+
'react': 'https://esm.sh/react@18.2.0?dev',
|
|
180
|
+
'react/jsx-runtime': 'https://esm.sh/react@18.2.0&dev/jsx-runtime',
|
|
181
|
+
'react/jsx-dev-runtime': 'https://esm.sh/react@18.2.0&dev/jsx-dev-runtime',
|
|
182
|
+
'react-dom': 'https://esm.sh/react-dom@18.2.0?dev',
|
|
183
|
+
'react-dom/client': 'https://esm.sh/react-dom@18.2.0/client?dev',
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Packages that are local or have custom shims (NOT npm packages)
|
|
187
|
+
const LOCAL_PACKAGES = new Set([
|
|
188
|
+
'next/link', 'next/router', 'next/head', 'next/navigation',
|
|
189
|
+
'next/dynamic', 'next/image', 'next/script', 'next/font/google',
|
|
190
|
+
'next/font/local', 'convex/_generated/api',
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
/** Check if a package specifier is a bare npm import that should be redirected. */
|
|
194
|
+
function resolveNpmPackage(packageName: string): string | null {
|
|
195
|
+
// Skip relative, absolute, URL, and virtual paths
|
|
196
|
+
if (packageName.startsWith('.') || packageName.startsWith('/') ||
|
|
197
|
+
packageName.startsWith('http://') || packageName.startsWith('https://') ||
|
|
198
|
+
packageName.startsWith('/__virtual__')) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (EXPLICIT_MAPPINGS[packageName]) return EXPLICIT_MAPPINGS[packageName];
|
|
203
|
+
if (LOCAL_PACKAGES.has(packageName)) return null;
|
|
204
|
+
|
|
205
|
+
// Check if it's a subpath import of a local package
|
|
206
|
+
const basePkg = packageName.includes('/') ? packageName.split('/')[0] : packageName;
|
|
207
|
+
const isScoped = basePkg.startsWith('@');
|
|
208
|
+
const scopedBasePkg = isScoped && packageName.includes('/')
|
|
209
|
+
? packageName.split('/').slice(0, 2).join('/')
|
|
210
|
+
: basePkg;
|
|
211
|
+
|
|
212
|
+
if (LOCAL_PACKAGES.has(scopedBasePkg)) return null;
|
|
213
|
+
|
|
214
|
+
return `https://esm.sh/${packageName}?external=react`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Redirect bare npm package imports to esm.sh CDN.
|
|
219
|
+
* Uses acorn AST to precisely target import/export source strings,
|
|
220
|
+
* avoiding false matches inside comments or string literals.
|
|
221
|
+
* Falls back to regex if acorn fails.
|
|
222
|
+
*/
|
|
223
|
+
export function redirectNpmImports(code: string): string {
|
|
224
|
+
try {
|
|
225
|
+
return redirectNpmImportsAst(code);
|
|
226
|
+
} catch {
|
|
227
|
+
return redirectNpmImportsRegex(code);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function redirectNpmImportsAst(code: string): string {
|
|
232
|
+
const ast = acorn.parse(code, { ecmaVersion: 'latest', sourceType: 'module' });
|
|
233
|
+
|
|
234
|
+
// Collect source nodes that need redirecting: [start, end, newUrl]
|
|
235
|
+
const replacements: Array<[number, number, string]> = [];
|
|
236
|
+
|
|
237
|
+
function processSource(sourceNode: any) {
|
|
238
|
+
if (!sourceNode || sourceNode.type !== 'Literal') return;
|
|
239
|
+
const resolved = resolveNpmPackage(sourceNode.value);
|
|
240
|
+
if (resolved) {
|
|
241
|
+
// Replace the string literal (including quotes) — sourceNode.start/end include quotes
|
|
242
|
+
replacements.push([sourceNode.start, sourceNode.end, JSON.stringify(resolved)]);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const node of (ast as any).body) {
|
|
247
|
+
if (node.type === 'ImportDeclaration') {
|
|
248
|
+
processSource(node.source);
|
|
249
|
+
} else if (node.type === 'ExportNamedDeclaration' && node.source) {
|
|
250
|
+
processSource(node.source);
|
|
251
|
+
} else if (node.type === 'ExportAllDeclaration') {
|
|
252
|
+
processSource(node.source);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (replacements.length === 0) return code;
|
|
257
|
+
|
|
258
|
+
// Apply replacements from end to start
|
|
259
|
+
let result = code;
|
|
260
|
+
replacements.sort((a, b) => b[0] - a[0]);
|
|
261
|
+
for (const [start, end, replacement] of replacements) {
|
|
262
|
+
result = result.slice(0, start) + replacement + result.slice(end);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function redirectNpmImportsRegex(code: string): string {
|
|
269
|
+
const importPattern = /(from\s*['"])([^'"./][^'"]*?)(['"])/g;
|
|
270
|
+
return code.replace(importPattern, (match, prefix, packageName, suffix) => {
|
|
271
|
+
const resolved = resolveNpmPackage(packageName);
|
|
272
|
+
if (!resolved) return match;
|
|
273
|
+
return `${prefix}${resolved}${suffix}`;
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* ESM to CJS transform using acorn AST parsing.
|
|
279
|
+
* Used in non-browser (test) environments where esbuild is unavailable.
|
|
280
|
+
* Falls back to regex if acorn fails to parse.
|
|
281
|
+
*/
|
|
282
|
+
export function transformEsmToCjsSimple(code: string): string {
|
|
283
|
+
try {
|
|
284
|
+
return transformEsmToCjsAst(code);
|
|
285
|
+
} catch {
|
|
286
|
+
return transformEsmToCjsRegex(code);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** AST-based ESM→CJS transform using acorn. */
|
|
291
|
+
function transformEsmToCjsAst(code: string): string {
|
|
292
|
+
const ast = acorn.parse(code, { ecmaVersion: 'latest', sourceType: 'module' });
|
|
293
|
+
|
|
294
|
+
// Collect replacements as [start, end, replacement] sorted by start descending
|
|
295
|
+
const replacements: Array<[number, number, string]> = [];
|
|
296
|
+
|
|
297
|
+
for (const node of (ast as any).body) {
|
|
298
|
+
if (node.type === 'ImportDeclaration') {
|
|
299
|
+
const source = node.source.value;
|
|
300
|
+
const specs = node.specifiers;
|
|
301
|
+
|
|
302
|
+
if (specs.length === 0) {
|
|
303
|
+
// Side-effect import: import './polyfill'
|
|
304
|
+
replacements.push([node.start, node.end, `require(${JSON.stringify(source)})`]);
|
|
305
|
+
} else {
|
|
306
|
+
const defaultSpec = specs.find((s: any) => s.type === 'ImportDefaultSpecifier');
|
|
307
|
+
const nsSpec = specs.find((s: any) => s.type === 'ImportNamespaceSpecifier');
|
|
308
|
+
const namedSpecs = specs.filter((s: any) => s.type === 'ImportSpecifier');
|
|
309
|
+
|
|
310
|
+
const parts: string[] = [];
|
|
311
|
+
if (defaultSpec) {
|
|
312
|
+
parts.push(`const ${defaultSpec.local.name} = require(${JSON.stringify(source)})`);
|
|
313
|
+
}
|
|
314
|
+
if (nsSpec) {
|
|
315
|
+
parts.push(`const ${nsSpec.local.name} = require(${JSON.stringify(source)})`);
|
|
316
|
+
}
|
|
317
|
+
if (namedSpecs.length > 0) {
|
|
318
|
+
const bindings = namedSpecs.map((s: any) => {
|
|
319
|
+
if (s.imported.name === s.local.name) return s.local.name;
|
|
320
|
+
return `${s.imported.name}: ${s.local.name}`;
|
|
321
|
+
}).join(', ');
|
|
322
|
+
if (defaultSpec) {
|
|
323
|
+
// Mixed: import React, { useState } from 'react'
|
|
324
|
+
// Default already handled, just destructure from same require
|
|
325
|
+
parts.push(`const { ${bindings} } = require(${JSON.stringify(source)})`);
|
|
326
|
+
} else {
|
|
327
|
+
parts.push(`const { ${bindings} } = require(${JSON.stringify(source)})`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
replacements.push([node.start, node.end, parts.join(';\n')]);
|
|
331
|
+
}
|
|
332
|
+
} else if (node.type === 'ExportDefaultDeclaration') {
|
|
333
|
+
const decl = node.declaration;
|
|
334
|
+
if (decl.type === 'FunctionDeclaration') {
|
|
335
|
+
// export default function X() {} → module.exports = function X() {}
|
|
336
|
+
const funcCode = code.slice(decl.start, node.end);
|
|
337
|
+
replacements.push([node.start, node.end, `module.exports = ${funcCode}`]);
|
|
338
|
+
} else if (decl.type === 'ClassDeclaration') {
|
|
339
|
+
const classCode = code.slice(decl.start, node.end);
|
|
340
|
+
replacements.push([node.start, node.end, `module.exports = ${classCode}`]);
|
|
341
|
+
} else {
|
|
342
|
+
// export default <expression>
|
|
343
|
+
const exprCode = code.slice(decl.start, node.end);
|
|
344
|
+
replacements.push([node.start, node.end, `module.exports = ${exprCode}`]);
|
|
345
|
+
}
|
|
346
|
+
} else if (node.type === 'ExportNamedDeclaration') {
|
|
347
|
+
if (node.declaration) {
|
|
348
|
+
const decl = node.declaration;
|
|
349
|
+
if (decl.type === 'FunctionDeclaration') {
|
|
350
|
+
const name = decl.id.name;
|
|
351
|
+
const funcCode = code.slice(decl.start, node.end);
|
|
352
|
+
replacements.push([node.start, node.end, `exports.${name} = ${funcCode}`]);
|
|
353
|
+
} else if (decl.type === 'ClassDeclaration') {
|
|
354
|
+
const name = decl.id.name;
|
|
355
|
+
const classCode = code.slice(decl.start, node.end);
|
|
356
|
+
replacements.push([node.start, node.end, `exports.${name} = ${classCode}`]);
|
|
357
|
+
} else if (decl.type === 'VariableDeclaration') {
|
|
358
|
+
// export const X = ..., export let Y = ...
|
|
359
|
+
const parts: string[] = [];
|
|
360
|
+
for (const declarator of decl.declarations) {
|
|
361
|
+
const name = declarator.id.name;
|
|
362
|
+
const initCode = declarator.init ? code.slice(declarator.init.start, declarator.init.end) : 'undefined';
|
|
363
|
+
parts.push(`exports.${name} = ${initCode}`);
|
|
364
|
+
}
|
|
365
|
+
replacements.push([node.start, node.end, parts.join(';\n')]);
|
|
366
|
+
}
|
|
367
|
+
} else if (node.source) {
|
|
368
|
+
// Re-export: export { X } from './module'
|
|
369
|
+
const source = node.source.value;
|
|
370
|
+
const parts: string[] = [];
|
|
371
|
+
const tmpVar = `__reexport_${node.start}`;
|
|
372
|
+
parts.push(`const ${tmpVar} = require(${JSON.stringify(source)})`);
|
|
373
|
+
for (const spec of node.specifiers) {
|
|
374
|
+
parts.push(`exports.${spec.exported.name} = ${tmpVar}.${spec.local.name}`);
|
|
375
|
+
}
|
|
376
|
+
replacements.push([node.start, node.end, parts.join(';\n')]);
|
|
377
|
+
} else {
|
|
378
|
+
// Local re-export: export { foo, bar }
|
|
379
|
+
const parts: string[] = [];
|
|
380
|
+
for (const spec of node.specifiers) {
|
|
381
|
+
parts.push(`exports.${spec.exported.name} = ${spec.local.name}`);
|
|
382
|
+
}
|
|
383
|
+
replacements.push([node.start, node.end, parts.join(';\n')]);
|
|
384
|
+
}
|
|
385
|
+
} else if (node.type === 'ExportAllDeclaration') {
|
|
386
|
+
// export * from './helpers'
|
|
387
|
+
const source = node.source.value;
|
|
388
|
+
replacements.push([node.start, node.end, `Object.assign(exports, require(${JSON.stringify(source)}))`]);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Apply replacements from end to start to preserve positions
|
|
393
|
+
let result = code;
|
|
394
|
+
replacements.sort((a, b) => b[0] - a[0]);
|
|
395
|
+
for (const [start, end, replacement] of replacements) {
|
|
396
|
+
result = result.slice(0, start) + replacement + result.slice(end);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return result;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Regex-based ESM→CJS fallback for code acorn can't parse. */
|
|
403
|
+
function transformEsmToCjsRegex(code: string): string {
|
|
404
|
+
let transformed = code;
|
|
405
|
+
|
|
406
|
+
transformed = transformed.replace(
|
|
407
|
+
/import\s+(\w+)\s+from\s+['"]([^'"]+)['"]/g,
|
|
408
|
+
'const $1 = require("$2")',
|
|
409
|
+
);
|
|
410
|
+
transformed = transformed.replace(
|
|
411
|
+
/import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g,
|
|
412
|
+
'const {$1} = require("$2")',
|
|
413
|
+
);
|
|
414
|
+
transformed = transformed.replace(
|
|
415
|
+
/export\s+default\s+function\s+(\w+)/g,
|
|
416
|
+
'module.exports = function $1',
|
|
417
|
+
);
|
|
418
|
+
transformed = transformed.replace(
|
|
419
|
+
/export\s+default\s+function\s*\(/g,
|
|
420
|
+
'module.exports = function(',
|
|
421
|
+
);
|
|
422
|
+
transformed = transformed.replace(
|
|
423
|
+
/export\s+default\s+/g,
|
|
424
|
+
'module.exports = ',
|
|
425
|
+
);
|
|
426
|
+
transformed = transformed.replace(
|
|
427
|
+
/export\s+async\s+function\s+(\w+)/g,
|
|
428
|
+
'exports.$1 = async function $1',
|
|
429
|
+
);
|
|
430
|
+
transformed = transformed.replace(
|
|
431
|
+
/export\s+function\s+(\w+)/g,
|
|
432
|
+
'exports.$1 = function $1',
|
|
433
|
+
);
|
|
434
|
+
transformed = transformed.replace(
|
|
435
|
+
/export\s+const\s+(\w+)\s*=/g,
|
|
436
|
+
'exports.$1 =',
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
return transformed;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Add React Refresh registration to transformed code.
|
|
444
|
+
* This enables true HMR (state-preserving) for React components.
|
|
445
|
+
* Shared between NextDevServer and ViteDevServer.
|
|
446
|
+
*/
|
|
447
|
+
export function addReactRefresh(code: string, filename: string): string {
|
|
448
|
+
const components = detectReactComponents(code);
|
|
449
|
+
|
|
450
|
+
if (components.length === 0) {
|
|
451
|
+
return `// HMR Setup
|
|
452
|
+
import.meta.hot = window.__vite_hot_context__("${filename}");
|
|
453
|
+
|
|
454
|
+
${code}
|
|
455
|
+
|
|
456
|
+
// HMR Accept
|
|
457
|
+
if (import.meta.hot) {
|
|
458
|
+
import.meta.hot.accept();
|
|
459
|
+
}
|
|
460
|
+
`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const registrations = components
|
|
464
|
+
.map(name => ` $RefreshReg$(${name}, "${filename} ${name}");`)
|
|
465
|
+
.join('\n');
|
|
466
|
+
|
|
467
|
+
return `// HMR Setup
|
|
468
|
+
import.meta.hot = window.__vite_hot_context__("${filename}");
|
|
469
|
+
|
|
470
|
+
${code}
|
|
471
|
+
|
|
472
|
+
// React Refresh Registration
|
|
473
|
+
if (import.meta.hot) {
|
|
474
|
+
${registrations}
|
|
475
|
+
import.meta.hot.accept(() => {
|
|
476
|
+
if (window.$RefreshRuntime$) {
|
|
477
|
+
window.$RefreshRuntime$.performReactRefresh();
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function isUppercaseStart(name: string): boolean {
|
|
485
|
+
return name.length > 0 && name[0] >= 'A' && name[0] <= 'Z';
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Detect React components using acorn AST parsing.
|
|
490
|
+
* Components are top-level functions/arrows with uppercase names.
|
|
491
|
+
* Falls back to regex if acorn fails.
|
|
492
|
+
*/
|
|
493
|
+
function detectReactComponents(code: string): string[] {
|
|
494
|
+
try {
|
|
495
|
+
return detectReactComponentsAst(code);
|
|
496
|
+
} catch {
|
|
497
|
+
return detectReactComponentsRegex(code);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function detectReactComponentsAst(code: string): string[] {
|
|
502
|
+
const ast = acorn.parse(code, { ecmaVersion: 'latest', sourceType: 'module' });
|
|
503
|
+
const components: string[] = [];
|
|
504
|
+
|
|
505
|
+
for (const node of (ast as any).body) {
|
|
506
|
+
// function App() {} or async function App() {}
|
|
507
|
+
if (node.type === 'FunctionDeclaration' && node.id && isUppercaseStart(node.id.name)) {
|
|
508
|
+
if (!components.includes(node.id.name)) {
|
|
509
|
+
components.push(node.id.name);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// export default function App() {}
|
|
514
|
+
if (node.type === 'ExportDefaultDeclaration' &&
|
|
515
|
+
node.declaration?.type === 'FunctionDeclaration' &&
|
|
516
|
+
node.declaration.id && isUppercaseStart(node.declaration.id.name)) {
|
|
517
|
+
if (!components.includes(node.declaration.id.name)) {
|
|
518
|
+
components.push(node.declaration.id.name);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// export function App() {} or export async function App() {}
|
|
523
|
+
if (node.type === 'ExportNamedDeclaration' &&
|
|
524
|
+
node.declaration?.type === 'FunctionDeclaration' &&
|
|
525
|
+
node.declaration.id && isUppercaseStart(node.declaration.id.name)) {
|
|
526
|
+
if (!components.includes(node.declaration.id.name)) {
|
|
527
|
+
components.push(node.declaration.id.name);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// const App = () => {} or const App = function() {}
|
|
532
|
+
// Handles both plain and export: export const App = () => {}
|
|
533
|
+
const varDecl = node.type === 'VariableDeclaration' ? node
|
|
534
|
+
: (node.type === 'ExportNamedDeclaration' && node.declaration?.type === 'VariableDeclaration')
|
|
535
|
+
? node.declaration : null;
|
|
536
|
+
|
|
537
|
+
if (varDecl) {
|
|
538
|
+
for (const declarator of varDecl.declarations) {
|
|
539
|
+
if (declarator.id?.name && isUppercaseStart(declarator.id.name) && declarator.init) {
|
|
540
|
+
const initType = declarator.init.type;
|
|
541
|
+
// Only count as component if assigned a function/arrow, not a string/number/object
|
|
542
|
+
if (initType === 'ArrowFunctionExpression' ||
|
|
543
|
+
initType === 'FunctionExpression' ||
|
|
544
|
+
// React.memo(Component), React.forwardRef(...)
|
|
545
|
+
initType === 'CallExpression') {
|
|
546
|
+
if (!components.includes(declarator.id.name)) {
|
|
547
|
+
components.push(declarator.id.name);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return components;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function detectReactComponentsRegex(code: string): string[] {
|
|
559
|
+
const components: string[] = [];
|
|
560
|
+
|
|
561
|
+
const funcDeclRegex = /(?:^|\n)(?:export\s+)?(?:async\s+)?function\s+([A-Z][a-zA-Z0-9]*)\s*\(/g;
|
|
562
|
+
let match;
|
|
563
|
+
while ((match = funcDeclRegex.exec(code)) !== null) {
|
|
564
|
+
if (!components.includes(match[1])) {
|
|
565
|
+
components.push(match[1]);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const arrowRegex = /(?:^|\n)(?:export\s+)?(?:const|let|var)\s+([A-Z][a-zA-Z0-9]*)\s*=/g;
|
|
570
|
+
while ((match = arrowRegex.exec(code)) !== null) {
|
|
571
|
+
if (!components.includes(match[1])) {
|
|
572
|
+
components.push(match[1]);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return components;
|
|
577
|
+
}
|