tova 0.10.3 → 0.11.12
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/bin/tova.js +93 -6604
- package/package.json +1 -1
- package/src/analyzer/analyzer.js +3 -812
- package/src/analyzer/auth-analyzer.js +123 -0
- package/src/analyzer/cli-analyzer.js +91 -0
- package/src/analyzer/concurrency-analyzer.js +126 -0
- package/src/analyzer/edge-analyzer.js +215 -0
- package/src/analyzer/security-analyzer.js +297 -0
- package/src/cli/build.js +1044 -0
- package/src/cli/check.js +154 -0
- package/src/cli/compile.js +815 -0
- package/src/cli/completions.js +192 -0
- package/src/cli/deploy.js +15 -0
- package/src/cli/dev.js +612 -0
- package/src/cli/doctor.js +130 -0
- package/src/cli/format.js +52 -0
- package/src/cli/info.js +72 -0
- package/src/cli/migrate.js +386 -0
- package/src/cli/new.js +1435 -0
- package/src/cli/package.js +452 -0
- package/src/cli/repl.js +437 -0
- package/src/cli/run.js +137 -0
- package/src/cli/test.js +255 -0
- package/src/cli/upgrade.js +261 -0
- package/src/cli/utils.js +190 -0
- package/src/codegen/codegen.js +51 -17
- package/src/registry/plugins/auth-plugin.js +13 -2
- package/src/registry/plugins/cli-plugin.js +13 -2
- package/src/registry/plugins/concurrency-plugin.js +4 -0
- package/src/registry/plugins/edge-plugin.js +13 -2
- package/src/registry/plugins/security-plugin.js +13 -2
- package/src/version.js +1 -1
package/src/cli/build.js
ADDED
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
// src/cli/build.js — Build pipeline commands
|
|
2
|
+
import { resolve, basename, dirname, join, relative, extname } from 'path';
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, rmSync, chmodSync } from 'fs';
|
|
4
|
+
import { createHash as _cryptoHash } from 'crypto';
|
|
5
|
+
import { watch as fsWatch } from 'fs';
|
|
6
|
+
import { mergeDirectory, compileTova, fixImportPaths, compileWithImports, groupFilesByDirectory, compilationCache, moduleTypeCache, collectExports, injectRouterImport, generateFileBasedRoutes, invalidateFile } from './compile.js';
|
|
7
|
+
import { color, findFiles, getRunStdlib, hasNpmImports, bundleClientCode, _formatBytes, _hasBun } from './utils.js';
|
|
8
|
+
import { resolveConfig } from '../config/resolve.js';
|
|
9
|
+
import { REACTIVITY_SOURCE, RPC_SOURCE, ROUTER_SOURCE, DEVTOOLS_SOURCE, SSR_SOURCE, TESTING_SOURCE } from '../runtime/embedded.js';
|
|
10
|
+
import { generateSecurityScorecard } from '../diagnostics/security-scorecard.js';
|
|
11
|
+
import { buildSelectiveStdlib, BUILTIN_NAMES, PROPAGATE, NATIVE_INIT } from '../stdlib/inline.js';
|
|
12
|
+
|
|
13
|
+
async function buildProject(args) {
|
|
14
|
+
const config = resolveConfig(process.cwd());
|
|
15
|
+
const isProduction = args.includes('--production');
|
|
16
|
+
const isStatic = args.includes('--static');
|
|
17
|
+
const buildStrict = args.includes('--strict');
|
|
18
|
+
const buildStrictSecurity = args.includes('--strict-security');
|
|
19
|
+
const isVerbose = args.includes('--verbose');
|
|
20
|
+
const isQuiet = args.includes('--quiet');
|
|
21
|
+
const isWatch = args.includes('--watch');
|
|
22
|
+
const binaryIdx = args.indexOf('--binary');
|
|
23
|
+
const binaryName = binaryIdx >= 0 ? args[binaryIdx + 1] : null;
|
|
24
|
+
const explicitSrc = args.filter(a => !a.startsWith('--') && a !== binaryName)[0];
|
|
25
|
+
const srcDir = resolve(explicitSrc || config.project.entry || '.');
|
|
26
|
+
const outIdx = args.indexOf('--output');
|
|
27
|
+
const outDir = resolve(outIdx >= 0 ? args[outIdx + 1] : (config.build.output || '.tova-out'));
|
|
28
|
+
|
|
29
|
+
// Binary compilation: compile to single standalone executable
|
|
30
|
+
if (binaryName) {
|
|
31
|
+
return await binaryBuild(srcDir, binaryName, outDir);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Production build uses a separate optimized pipeline
|
|
35
|
+
if (isProduction) {
|
|
36
|
+
return await productionBuild(srcDir, outDir, isStatic);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const tovaFiles = findFiles(srcDir, '.tova');
|
|
40
|
+
if (tovaFiles.length === 0) {
|
|
41
|
+
console.error('No .tova files found');
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
mkdirSync(outDir, { recursive: true });
|
|
46
|
+
|
|
47
|
+
// Write embedded runtime files to output directory
|
|
48
|
+
const runtimeDest = join(outDir, 'runtime');
|
|
49
|
+
mkdirSync(runtimeDest, { recursive: true });
|
|
50
|
+
writeFileSync(join(runtimeDest, 'reactivity.js'), REACTIVITY_SOURCE);
|
|
51
|
+
writeFileSync(join(runtimeDest, 'rpc.js'), RPC_SOURCE);
|
|
52
|
+
writeFileSync(join(runtimeDest, 'router.js'), ROUTER_SOURCE);
|
|
53
|
+
writeFileSync(join(runtimeDest, 'devtools.js'), DEVTOOLS_SOURCE);
|
|
54
|
+
writeFileSync(join(runtimeDest, 'ssr.js'), SSR_SOURCE);
|
|
55
|
+
writeFileSync(join(runtimeDest, 'testing.js'), TESTING_SOURCE);
|
|
56
|
+
|
|
57
|
+
if (!isQuiet) console.log(`\n Building ${tovaFiles.length} file(s)...\n`);
|
|
58
|
+
|
|
59
|
+
let errorCount = 0;
|
|
60
|
+
const buildStart = Date.now();
|
|
61
|
+
compilationCache.clear(); moduleTypeCache.clear();
|
|
62
|
+
|
|
63
|
+
// Load incremental build cache
|
|
64
|
+
const noCache = args.includes('--no-cache');
|
|
65
|
+
const buildCache = new BuildCache(join(outDir, '.cache'));
|
|
66
|
+
if (!noCache) buildCache.load();
|
|
67
|
+
let skippedCount = 0;
|
|
68
|
+
|
|
69
|
+
// Group files by directory for multi-file merging
|
|
70
|
+
const dirGroups = groupFilesByDirectory(tovaFiles);
|
|
71
|
+
let _scorecardData = null; // Collect security info for scorecard
|
|
72
|
+
|
|
73
|
+
for (const [dir, files] of dirGroups) {
|
|
74
|
+
const dirName = basename(dir) === '.' ? 'app' : basename(dir);
|
|
75
|
+
const relDir = relative(srcDir, dir) || '.';
|
|
76
|
+
const groupStart = Date.now();
|
|
77
|
+
try {
|
|
78
|
+
// Check incremental cache: skip if all files in this group are unchanged
|
|
79
|
+
if (!noCache) {
|
|
80
|
+
if (files.length === 1) {
|
|
81
|
+
const absFile = files[0];
|
|
82
|
+
const sourceContent = readFileSync(absFile, 'utf-8');
|
|
83
|
+
if (buildCache.isUpToDate(absFile, sourceContent)) {
|
|
84
|
+
const cached = buildCache.getCached(absFile);
|
|
85
|
+
if (cached) {
|
|
86
|
+
skippedCount++;
|
|
87
|
+
if (isVerbose && !isQuiet) {
|
|
88
|
+
console.log(` ○ ${relative(srcDir, absFile)} (cached)`);
|
|
89
|
+
}
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
const dirKey = `dir:${dir}`;
|
|
95
|
+
if (buildCache.isGroupUpToDate(dirKey, files)) {
|
|
96
|
+
const cached = buildCache.getCached(dirKey);
|
|
97
|
+
if (cached) {
|
|
98
|
+
skippedCount++;
|
|
99
|
+
if (isVerbose && !isQuiet) {
|
|
100
|
+
console.log(` ○ ${relative(srcDir, dir)}/ (${files.length} files, cached)`);
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const result = mergeDirectory(dir, srcDir, { strict: buildStrict, strictSecurity: buildStrictSecurity });
|
|
109
|
+
if (!result) continue;
|
|
110
|
+
|
|
111
|
+
const { output, single, warnings: buildWarnings, securityConfig, hasServer, hasEdge } = result;
|
|
112
|
+
if ((hasServer || hasEdge) && !_scorecardData) {
|
|
113
|
+
_scorecardData = { securityConfig, warnings: buildWarnings || [], hasServer, hasEdge };
|
|
114
|
+
}
|
|
115
|
+
// Preserve relative directory structure in output (e.g., src/lib/math.tova → lib/math.js)
|
|
116
|
+
const outBaseName = single
|
|
117
|
+
? relative(srcDir, files[0]).replace(/\.tova$/, '').replace(/\\/g, '/')
|
|
118
|
+
: (relDir === '.' ? dirName : relDir + '/' + dirName);
|
|
119
|
+
const relLabel = single ? relative(srcDir, files[0]) : `${relDir}/ (${files.length} files merged)`;
|
|
120
|
+
const elapsed = Date.now() - groupStart;
|
|
121
|
+
const timing = isVerbose ? ` (${elapsed}ms)` : '';
|
|
122
|
+
|
|
123
|
+
// Helper to generate source maps
|
|
124
|
+
const generateSourceMap = (code, jsFile) => {
|
|
125
|
+
if (output.sourceMappings && output.sourceMappings.length > 0) {
|
|
126
|
+
const sourceFile = single ? relative(srcDir, files[0]) : relDir;
|
|
127
|
+
const smb = new SourceMapBuilder(sourceFile, output._sourceFiles);
|
|
128
|
+
for (const m of output.sourceMappings) {
|
|
129
|
+
smb.addMapping(m.sourceLine, m.sourceCol, m.outputLine, m.outputCol, m.sourceFile);
|
|
130
|
+
}
|
|
131
|
+
const sourceContent = single ? readFileSync(files[0], 'utf-8') : null;
|
|
132
|
+
const mapFile = jsFile + '.map';
|
|
133
|
+
writeFileSync(mapFile, smb.generate(sourceContent, output._sourceContents));
|
|
134
|
+
return code + `\n//# sourceMappingURL=${basename(mapFile)}`;
|
|
135
|
+
}
|
|
136
|
+
return code;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Ensure output subdirectory exists for nested paths (e.g., lib/math.js)
|
|
140
|
+
const outSubDir = dirname(join(outDir, outBaseName));
|
|
141
|
+
if (outSubDir !== outDir) mkdirSync(outSubDir, { recursive: true });
|
|
142
|
+
|
|
143
|
+
// CLI files: write single executable <name>.js with shebang
|
|
144
|
+
if (output.isCli) {
|
|
145
|
+
if (output.cli && output.cli.trim()) {
|
|
146
|
+
const cliPath = join(outDir, `${outBaseName}.js`);
|
|
147
|
+
const shebang = '#!/usr/bin/env node\n';
|
|
148
|
+
writeFileSync(cliPath, shebang + output.cli);
|
|
149
|
+
try { chmodSync(cliPath, 0o755); } catch (e) { /* ignore on Windows */ }
|
|
150
|
+
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', cliPath)} [cli]${timing}`);
|
|
151
|
+
}
|
|
152
|
+
if (!noCache) {
|
|
153
|
+
const outputPaths = {};
|
|
154
|
+
if (output.cli && output.cli.trim()) outputPaths.cli = join(outDir, `${outBaseName}.js`);
|
|
155
|
+
if (single) {
|
|
156
|
+
const absFile = files[0];
|
|
157
|
+
const sourceContent = readFileSync(absFile, 'utf-8');
|
|
158
|
+
buildCache.set(absFile, sourceContent, outputPaths);
|
|
159
|
+
} else {
|
|
160
|
+
buildCache.setGroup(`dir:${dir}`, files, outputPaths);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Module files: write single <name>.js (not .shared.js)
|
|
165
|
+
else if (output.isModule) {
|
|
166
|
+
if (output.shared && output.shared.trim()) {
|
|
167
|
+
const modulePath = join(outDir, `${outBaseName}.js`);
|
|
168
|
+
writeFileSync(modulePath, fixImportPaths(generateSourceMap(output.shared, modulePath), modulePath, outDir));
|
|
169
|
+
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', modulePath)}${timing}`);
|
|
170
|
+
}
|
|
171
|
+
// Update incremental build cache
|
|
172
|
+
if (!noCache) {
|
|
173
|
+
const outputPaths = {};
|
|
174
|
+
if (output.shared && output.shared.trim()) outputPaths.shared = join(outDir, `${outBaseName}.js`);
|
|
175
|
+
if (single) {
|
|
176
|
+
const absFile = files[0];
|
|
177
|
+
const sourceContent = readFileSync(absFile, 'utf-8');
|
|
178
|
+
buildCache.set(absFile, sourceContent, outputPaths);
|
|
179
|
+
} else {
|
|
180
|
+
buildCache.setGroup(`dir:${dir}`, files, outputPaths);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
// Write shared
|
|
185
|
+
if (output.shared && output.shared.trim()) {
|
|
186
|
+
const sharedPath = join(outDir, `${outBaseName}.shared.js`);
|
|
187
|
+
writeFileSync(sharedPath, fixImportPaths(generateSourceMap(output.shared, sharedPath), sharedPath, outDir));
|
|
188
|
+
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', sharedPath)}${timing}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Write default server
|
|
192
|
+
if (output.server) {
|
|
193
|
+
const serverPath = join(outDir, `${outBaseName}.server.js`);
|
|
194
|
+
writeFileSync(serverPath, fixImportPaths(generateSourceMap(output.server, serverPath), serverPath, outDir));
|
|
195
|
+
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', serverPath)}${timing}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Write default browser
|
|
199
|
+
if (output.browser) {
|
|
200
|
+
const browserPath = join(outDir, `${outBaseName}.browser.js`);
|
|
201
|
+
// Pass srcDir for file-based routing injection (only for root-level browser output)
|
|
202
|
+
const browserSrcDir = (relDir === '.' || relDir === '') ? srcDir : undefined;
|
|
203
|
+
writeFileSync(browserPath, fixImportPaths(generateSourceMap(output.browser, browserPath), browserPath, outDir, browserSrcDir));
|
|
204
|
+
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', browserPath)}${timing}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Write default edge
|
|
208
|
+
if (output.edge) {
|
|
209
|
+
const edgePath = join(outDir, `${outBaseName}.edge.js`);
|
|
210
|
+
writeFileSync(edgePath, fixImportPaths(generateSourceMap(output.edge, edgePath), edgePath, outDir));
|
|
211
|
+
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', edgePath)} [edge]${timing}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Write named server blocks (multi-block)
|
|
215
|
+
if (output.multiBlock && output.servers) {
|
|
216
|
+
for (const [name, code] of Object.entries(output.servers)) {
|
|
217
|
+
if (name === 'default') continue;
|
|
218
|
+
const path = join(outDir, `${outBaseName}.server.${name}.js`);
|
|
219
|
+
writeFileSync(path, fixImportPaths(code, path, outDir));
|
|
220
|
+
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [server:${name}]${timing}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Write named edge blocks (multi-block)
|
|
225
|
+
if (output.multiBlock && output.edges) {
|
|
226
|
+
for (const [name, code] of Object.entries(output.edges)) {
|
|
227
|
+
if (name === 'default') continue;
|
|
228
|
+
const path = join(outDir, `${outBaseName}.edge.${name}.js`);
|
|
229
|
+
writeFileSync(path, fixImportPaths(code, path, outDir));
|
|
230
|
+
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [edge:${name}]${timing}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Write named browser blocks (multi-block)
|
|
235
|
+
if (output.multiBlock && output.browsers) {
|
|
236
|
+
for (const [name, code] of Object.entries(output.browsers)) {
|
|
237
|
+
if (name === 'default') continue;
|
|
238
|
+
const path = join(outDir, `${outBaseName}.browser.${name}.js`);
|
|
239
|
+
writeFileSync(path, fixImportPaths(code, path, outDir));
|
|
240
|
+
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [browser:${name}]${timing}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Update incremental build cache
|
|
245
|
+
if (!noCache) {
|
|
246
|
+
const outputPaths = {};
|
|
247
|
+
if (output.shared && output.shared.trim()) outputPaths.shared = join(outDir, `${outBaseName}.shared.js`);
|
|
248
|
+
if (output.server) outputPaths.server = join(outDir, `${outBaseName}.server.js`);
|
|
249
|
+
if (output.browser) outputPaths.browser = join(outDir, `${outBaseName}.browser.js`);
|
|
250
|
+
if (single) {
|
|
251
|
+
const absFile = files[0];
|
|
252
|
+
const sourceContent = readFileSync(absFile, 'utf-8');
|
|
253
|
+
buildCache.set(absFile, sourceContent, outputPaths);
|
|
254
|
+
} else {
|
|
255
|
+
buildCache.setGroup(`dir:${dir}`, files, outputPaths);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} catch (err) {
|
|
260
|
+
console.error(` ✗ ${relDir}: ${err.message}`);
|
|
261
|
+
errorCount++;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Save incremental build cache and prune stale entries
|
|
266
|
+
if (!noCache) {
|
|
267
|
+
const dirKeys = [...dirGroups.keys()].map(d => `dir:${d}`);
|
|
268
|
+
buildCache.prune(tovaFiles, dirKeys);
|
|
269
|
+
buildCache.save();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const dirCount = dirGroups.size;
|
|
273
|
+
const totalElapsed = Date.now() - buildStart;
|
|
274
|
+
if (!isQuiet) {
|
|
275
|
+
const timingStr = isVerbose ? ` in ${totalElapsed}ms` : '';
|
|
276
|
+
const cachedStr = skippedCount > 0 ? ` (${skippedCount} cached)` : '';
|
|
277
|
+
console.log(`\n Build complete. ${dirCount - errorCount}/${dirCount} directory group(s) succeeded${cachedStr}${timingStr}.\n`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Security scorecard (shown with --verbose or --strict-security, suppressed with --quiet)
|
|
281
|
+
if ((isVerbose || buildStrictSecurity) && !isQuiet && _scorecardData) {
|
|
282
|
+
const scorecard = generateSecurityScorecard(
|
|
283
|
+
_scorecardData.securityConfig,
|
|
284
|
+
_scorecardData.warnings,
|
|
285
|
+
_scorecardData.hasServer,
|
|
286
|
+
_scorecardData.hasEdge
|
|
287
|
+
);
|
|
288
|
+
if (scorecard) console.log(scorecard.format());
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (errorCount > 0 && !isWatch) process.exit(1);
|
|
292
|
+
|
|
293
|
+
// Watch mode for build command
|
|
294
|
+
if (isWatch) {
|
|
295
|
+
console.log(' Watching for changes...\n');
|
|
296
|
+
let debounceTimer = null;
|
|
297
|
+
const watcher = fsWatch(srcDir, { recursive: true }, (event, filename) => {
|
|
298
|
+
if (!filename || !filename.endsWith('.tova')) return;
|
|
299
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
300
|
+
debounceTimer = setTimeout(async () => {
|
|
301
|
+
const changedPath = resolve(srcDir, filename);
|
|
302
|
+
invalidateFile(changedPath);
|
|
303
|
+
if (!isQuiet) console.log(` Rebuilding (${filename} changed)...`);
|
|
304
|
+
try {
|
|
305
|
+
await buildProject(args.filter(a => a !== '--watch'));
|
|
306
|
+
} catch (err) {
|
|
307
|
+
// Continue watching even on error
|
|
308
|
+
}
|
|
309
|
+
if (!isQuiet) console.log(' Watching for changes...\n');
|
|
310
|
+
}, 100);
|
|
311
|
+
});
|
|
312
|
+
// Keep process alive
|
|
313
|
+
await new Promise(() => {});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function cleanBuild(args) {
|
|
318
|
+
const config = resolveConfig(process.cwd());
|
|
319
|
+
const outDir = resolve(config.build?.output || '.tova-out');
|
|
320
|
+
|
|
321
|
+
if (existsSync(outDir)) {
|
|
322
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
323
|
+
console.log(` Cleaned ${relative('.', outDir)}/`);
|
|
324
|
+
} else {
|
|
325
|
+
console.log(` Nothing to clean (${relative('.', outDir)}/ does not exist)`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
class SourceMapBuilder {
|
|
330
|
+
constructor(sourceFile, sourceFiles = null) {
|
|
331
|
+
this.sourceFile = sourceFile;
|
|
332
|
+
// Multi-source support: array of all source files for merged output
|
|
333
|
+
this.sourceFiles = sourceFiles || [sourceFile];
|
|
334
|
+
this._sourceIndex = new Map();
|
|
335
|
+
for (let i = 0; i < this.sourceFiles.length; i++) {
|
|
336
|
+
this._sourceIndex.set(this.sourceFiles[i], i);
|
|
337
|
+
}
|
|
338
|
+
this.mappings = [];
|
|
339
|
+
this.outputLine = 0;
|
|
340
|
+
this.outputCol = 0;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
addMapping(sourceLine, sourceCol, outputLine, outputCol, sourceFile = null) {
|
|
344
|
+
const srcIdx = sourceFile ? (this._sourceIndex.get(sourceFile) || 0) : 0;
|
|
345
|
+
this.mappings.push({ sourceLine, sourceCol, outputLine, outputCol, sourceIdx: srcIdx });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Generate a VLQ-encoded source map
|
|
349
|
+
generate(sourceContent, sourceContentsMap = null) {
|
|
350
|
+
const sources = this.sourceFiles;
|
|
351
|
+
const names = [];
|
|
352
|
+
|
|
353
|
+
// Build sourcesContent array for multi-source
|
|
354
|
+
let sourcesContent;
|
|
355
|
+
if (sourceContentsMap && sourceContentsMap instanceof Map) {
|
|
356
|
+
sourcesContent = sources.map(s => sourceContentsMap.get(s) || null);
|
|
357
|
+
} else if (sourceContent) {
|
|
358
|
+
sourcesContent = [sourceContent];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Sort mappings by output position
|
|
362
|
+
this.mappings.sort((a, b) => a.outputLine - b.outputLine || a.outputCol - b.outputCol);
|
|
363
|
+
|
|
364
|
+
// Encode mappings using VLQ
|
|
365
|
+
let prevOutputCol = 0;
|
|
366
|
+
let prevSourceIdx = 0;
|
|
367
|
+
let prevSourceLine = 0;
|
|
368
|
+
let prevSourceCol = 0;
|
|
369
|
+
let currentOutputLine = 0;
|
|
370
|
+
const lines = [];
|
|
371
|
+
let currentLine = [];
|
|
372
|
+
|
|
373
|
+
for (const m of this.mappings) {
|
|
374
|
+
// Fill empty lines
|
|
375
|
+
while (currentOutputLine < m.outputLine) {
|
|
376
|
+
lines.push(currentLine.join(','));
|
|
377
|
+
currentLine = [];
|
|
378
|
+
currentOutputLine++;
|
|
379
|
+
prevOutputCol = 0;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const segment = [];
|
|
383
|
+
segment.push(this._vlqEncode(m.outputCol - prevOutputCol));
|
|
384
|
+
segment.push(this._vlqEncode(m.sourceIdx - prevSourceIdx));
|
|
385
|
+
segment.push(this._vlqEncode(m.sourceLine - prevSourceLine));
|
|
386
|
+
segment.push(this._vlqEncode(m.sourceCol - prevSourceCol));
|
|
387
|
+
|
|
388
|
+
currentLine.push(segment.join(''));
|
|
389
|
+
prevOutputCol = m.outputCol;
|
|
390
|
+
prevSourceIdx = m.sourceIdx;
|
|
391
|
+
prevSourceLine = m.sourceLine;
|
|
392
|
+
prevSourceCol = m.sourceCol;
|
|
393
|
+
}
|
|
394
|
+
lines.push(currentLine.join(','));
|
|
395
|
+
|
|
396
|
+
const outFile = typeof this.sourceFile === 'string' ? this.sourceFile.replace('.tova', '.js') : 'merged.js';
|
|
397
|
+
return JSON.stringify({
|
|
398
|
+
version: 3,
|
|
399
|
+
file: outFile,
|
|
400
|
+
sources,
|
|
401
|
+
sourcesContent: sourcesContent || undefined,
|
|
402
|
+
names,
|
|
403
|
+
mappings: lines.join(';'),
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
_vlqEncode(value) {
|
|
408
|
+
let vlq = value < 0 ? ((-value) << 1) + 1 : value << 1;
|
|
409
|
+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
410
|
+
let encoded = '';
|
|
411
|
+
do {
|
|
412
|
+
let digit = vlq & 0x1f;
|
|
413
|
+
vlq >>= 5;
|
|
414
|
+
if (vlq > 0) digit |= 0x20;
|
|
415
|
+
encoded += chars[digit];
|
|
416
|
+
} while (vlq > 0);
|
|
417
|
+
return encoded;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
toDataURL(sourceContent, sourceContentsMap = null) {
|
|
421
|
+
const mapJson = this.generate(sourceContent, sourceContentsMap);
|
|
422
|
+
const base64 = Buffer.from(mapJson).toString('base64');
|
|
423
|
+
return `//# sourceMappingURL=data:application/json;base64,${base64}`;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function binaryBuild(srcDir, outputName, outDir) {
|
|
428
|
+
const tovaFiles = findFiles(srcDir, '.tova');
|
|
429
|
+
if (tovaFiles.length === 0) {
|
|
430
|
+
console.error('No .tova files found');
|
|
431
|
+
process.exit(1);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const tmpDir = join(outDir, '.tova-binary-tmp');
|
|
435
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
436
|
+
|
|
437
|
+
console.log(`\n Compiling to binary: ${outputName}\n`);
|
|
438
|
+
|
|
439
|
+
// Step 1: Compile all .tova files to JS
|
|
440
|
+
const sharedParts = [];
|
|
441
|
+
const serverParts = [];
|
|
442
|
+
const browserParts = [];
|
|
443
|
+
|
|
444
|
+
for (const file of tovaFiles) {
|
|
445
|
+
try {
|
|
446
|
+
const source = readFileSync(file, 'utf-8');
|
|
447
|
+
const output = compileTova(source, file);
|
|
448
|
+
if (output.shared) sharedParts.push(output.shared);
|
|
449
|
+
if (output.server) serverParts.push(output.server);
|
|
450
|
+
if (output.browser) browserParts.push(output.browser);
|
|
451
|
+
} catch (err) {
|
|
452
|
+
console.error(` Error in ${relative(srcDir, file)}: ${err.message}`);
|
|
453
|
+
process.exit(1);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Step 2: Bundle into a single JS file
|
|
458
|
+
const stdlib = getRunStdlib();
|
|
459
|
+
const allShared = sharedParts.join('\n');
|
|
460
|
+
const allServer = serverParts.join('\n');
|
|
461
|
+
|
|
462
|
+
let bundledCode;
|
|
463
|
+
if (allServer.trim()) {
|
|
464
|
+
// Server app
|
|
465
|
+
bundledCode = stdlib + '\n' + allShared + '\n' + allServer;
|
|
466
|
+
} else {
|
|
467
|
+
// Script/shared-only app
|
|
468
|
+
bundledCode = stdlib + '\n' + allShared;
|
|
469
|
+
// Auto-call main() if it exists
|
|
470
|
+
if (/\bfunction\s+main\s*\(/.test(bundledCode)) {
|
|
471
|
+
bundledCode += '\nconst __tova_exit = await main(process.argv.slice(2)); if (typeof __tova_exit === "number") process.exitCode = __tova_exit;\n';
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Strip import/export statements (everything is inlined)
|
|
476
|
+
bundledCode = bundledCode.replace(/^export /gm, '');
|
|
477
|
+
bundledCode = bundledCode.replace(/^\s*import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]+['"];?\s*$/gm, '');
|
|
478
|
+
|
|
479
|
+
const entryPath = join(tmpDir, 'entry.js');
|
|
480
|
+
writeFileSync(entryPath, bundledCode);
|
|
481
|
+
console.log(` Compiled ${tovaFiles.length} file(s) to JS`);
|
|
482
|
+
|
|
483
|
+
// Step 3: Use Bun to compile to standalone binary
|
|
484
|
+
const outputPath = resolve(outputName);
|
|
485
|
+
try {
|
|
486
|
+
const { execFileSync } = await import('child_process');
|
|
487
|
+
execFileSync('bun', ['build', '--compile', entryPath, '--outfile', outputPath], {
|
|
488
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
489
|
+
env: process.env,
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// Get file size
|
|
493
|
+
const stat = statSync(outputPath);
|
|
494
|
+
const sizeMB = (stat.size / (1024 * 1024)).toFixed(1);
|
|
495
|
+
console.log(` Created binary: ${outputPath} (${sizeMB}MB)`);
|
|
496
|
+
} finally {
|
|
497
|
+
// Clean up temp files
|
|
498
|
+
try {
|
|
499
|
+
rmSync(tmpDir, { recursive: true });
|
|
500
|
+
} catch {}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const displayPath = outputPath.startsWith(process.cwd()) ? './' + relative(process.cwd(), outputPath) : outputPath;
|
|
504
|
+
console.log(`\n Done! Run with: ${displayPath}\n`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function productionBuild(srcDir, outDir, isStatic = false) {
|
|
508
|
+
const config = resolveConfig(process.cwd());
|
|
509
|
+
const basePath = config.deploy?.base || '/';
|
|
510
|
+
const base = basePath.endsWith('/') ? basePath : basePath + '/';
|
|
511
|
+
|
|
512
|
+
const tovaFiles = findFiles(srcDir, '.tova');
|
|
513
|
+
if (tovaFiles.length === 0) {
|
|
514
|
+
console.error('No .tova files found');
|
|
515
|
+
process.exit(1);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
mkdirSync(outDir, { recursive: true });
|
|
519
|
+
|
|
520
|
+
console.log(`\n Production build...\n`);
|
|
521
|
+
|
|
522
|
+
const browserParts = [];
|
|
523
|
+
const serverParts = [];
|
|
524
|
+
const sharedParts = [];
|
|
525
|
+
let cssContent = '';
|
|
526
|
+
|
|
527
|
+
for (const file of tovaFiles) {
|
|
528
|
+
try {
|
|
529
|
+
const source = readFileSync(file, 'utf-8');
|
|
530
|
+
const output = compileWithImports(source, file, srcDir);
|
|
531
|
+
|
|
532
|
+
if (output.shared) sharedParts.push(output.shared);
|
|
533
|
+
if (output.server) serverParts.push(output.server);
|
|
534
|
+
if (output.browser) browserParts.push(output.browser);
|
|
535
|
+
} catch (err) {
|
|
536
|
+
console.error(` Error in ${relative(srcDir, file)}: ${err.message}`);
|
|
537
|
+
process.exit(1);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const allClientCode = browserParts.join('\n');
|
|
542
|
+
const allServerCode = serverParts.join('\n');
|
|
543
|
+
const allSharedCode = sharedParts.join('\n');
|
|
544
|
+
|
|
545
|
+
// Generate content hash for cache busting
|
|
546
|
+
const hashCode = (s) => {
|
|
547
|
+
if (_hasBun) return Bun.hash(s).toString(16).slice(0, 12);
|
|
548
|
+
return _cryptoHash('md5').update(s).digest('hex').slice(0, 12);
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
// Write server bundle
|
|
552
|
+
if (allServerCode.trim()) {
|
|
553
|
+
const stdlib = getRunStdlib();
|
|
554
|
+
const serverBundle = stdlib + '\n' + allSharedCode + '\n' + allServerCode;
|
|
555
|
+
const hash = hashCode(serverBundle);
|
|
556
|
+
const serverPath = join(outDir, `server.${hash}.js`);
|
|
557
|
+
writeFileSync(serverPath, serverBundle);
|
|
558
|
+
console.log(` server.${hash}.js`);
|
|
559
|
+
|
|
560
|
+
// Write stable server.js entrypoint for Docker/deployment
|
|
561
|
+
writeFileSync(join(outDir, 'server.js'), `import "./server.${hash}.js";\n`);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Write script bundle for plain scripts (no server/client blocks)
|
|
565
|
+
if (!allServerCode.trim() && !allClientCode.trim() && allSharedCode.trim()) {
|
|
566
|
+
const stdlib = getRunStdlib();
|
|
567
|
+
const scriptBundle = stdlib + '\n' + allSharedCode;
|
|
568
|
+
const hash = hashCode(scriptBundle);
|
|
569
|
+
const scriptPath = join(outDir, `script.${hash}.js`);
|
|
570
|
+
writeFileSync(scriptPath, scriptBundle);
|
|
571
|
+
console.log(` script.${hash}.js`);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Write client bundle
|
|
575
|
+
if (allClientCode.trim()) {
|
|
576
|
+
const fullClientModule = allSharedCode + '\n' + allClientCode;
|
|
577
|
+
|
|
578
|
+
let clientBundle;
|
|
579
|
+
let useModule = false;
|
|
580
|
+
|
|
581
|
+
if (hasNpmImports(fullClientModule)) {
|
|
582
|
+
// npm imports detected — bundle with Bun.build to resolve bare specifiers
|
|
583
|
+
clientBundle = await bundleClientCode(fullClientModule, srcDir);
|
|
584
|
+
useModule = true;
|
|
585
|
+
} else {
|
|
586
|
+
// No npm imports — inline runtime, strip all imports
|
|
587
|
+
const reactivityCode = REACTIVITY_SOURCE.replace(/^export /gm, '');
|
|
588
|
+
const rpcCode = RPC_SOURCE.replace(/^export /gm, '');
|
|
589
|
+
const usesRouter = /\b(defineRoutes|Router|getPath|getQuery|getParams|getCurrentRoute|navigate|onRouteChange|beforeNavigate|afterNavigate|Outlet|Link|Redirect)\b/.test(allClientCode);
|
|
590
|
+
const routerCode = usesRouter ? ROUTER_SOURCE.replace(/^export /gm, '').replace(/^\s*import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]+['"];?\s*$/gm, '') : '';
|
|
591
|
+
clientBundle = reactivityCode + '\n' + rpcCode + '\n' + (routerCode ? routerCode + '\n' : '') + allSharedCode + '\n' +
|
|
592
|
+
allClientCode.replace(/^\s*import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]+['"];?\s*$/gm, '').trim();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const hash = hashCode(clientBundle);
|
|
596
|
+
const clientPath = join(outDir, `client.${hash}.js`);
|
|
597
|
+
writeFileSync(clientPath, clientBundle);
|
|
598
|
+
console.log(` client.${hash}.js`);
|
|
599
|
+
|
|
600
|
+
// Generate production HTML
|
|
601
|
+
const scriptTag = useModule
|
|
602
|
+
? `<script type="module" src="${base}.tova-out/client.${hash}.js"></script>`
|
|
603
|
+
: `<script src="${base}.tova-out/client.${hash}.js"></script>`;
|
|
604
|
+
const html = `<!DOCTYPE html>
|
|
605
|
+
<html lang="en">
|
|
606
|
+
<head>
|
|
607
|
+
<meta charset="UTF-8">
|
|
608
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
609
|
+
<title>Tova App</title>
|
|
610
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
611
|
+
<style>
|
|
612
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
613
|
+
body { font-family: system-ui, -apple-system, sans-serif; }
|
|
614
|
+
</style>
|
|
615
|
+
</head>
|
|
616
|
+
<body>
|
|
617
|
+
<div id="app"></div>
|
|
618
|
+
${scriptTag}
|
|
619
|
+
</body>
|
|
620
|
+
</html>`;
|
|
621
|
+
writeFileSync(join(outDir, 'index.html'), html);
|
|
622
|
+
console.log(` index.html`);
|
|
623
|
+
|
|
624
|
+
// SPA fallback files for various static hosts
|
|
625
|
+
writeFileSync(join(outDir, '404.html'), html);
|
|
626
|
+
console.log(` 404.html (GitHub Pages SPA fallback)`);
|
|
627
|
+
writeFileSync(join(outDir, '200.html'), html);
|
|
628
|
+
console.log(` 200.html (Surge SPA fallback)`);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Minify all JS bundles using Bun's built-in transpiler
|
|
632
|
+
const jsFiles = readdirSync(outDir).filter(f => f.endsWith('.js') && !f.endsWith('.min.js'));
|
|
633
|
+
let minified = 0;
|
|
634
|
+
for (const f of jsFiles) {
|
|
635
|
+
const filePath = join(outDir, f);
|
|
636
|
+
const minPath = join(outDir, f.replace('.js', '.min.js'));
|
|
637
|
+
try {
|
|
638
|
+
// Use Bun.Transpiler for minification without bundling (preserves imports)
|
|
639
|
+
const source = readFileSync(filePath, 'utf-8');
|
|
640
|
+
const transpiler = new Bun.Transpiler({ minifyWhitespace: true, trimUnusedImports: true });
|
|
641
|
+
const minCode = transpiler.transformSync(source);
|
|
642
|
+
writeFileSync(minPath, minCode);
|
|
643
|
+
const originalSize = Buffer.byteLength(source);
|
|
644
|
+
const minSize = Buffer.byteLength(minCode);
|
|
645
|
+
const ratio = ((1 - minSize / originalSize) * 100).toFixed(0);
|
|
646
|
+
console.log(` ${f.replace('.js', '.min.js')} (${_formatBytes(minSize)}, ${ratio}% smaller)`);
|
|
647
|
+
minified++;
|
|
648
|
+
} catch {
|
|
649
|
+
// Bun.build not available — fall back to simple whitespace stripping
|
|
650
|
+
try {
|
|
651
|
+
const source = readFileSync(filePath, 'utf-8');
|
|
652
|
+
const stripped = _simpleMinify(source);
|
|
653
|
+
writeFileSync(minPath, stripped);
|
|
654
|
+
const originalSize = Buffer.byteLength(source);
|
|
655
|
+
const minSize = Buffer.byteLength(stripped);
|
|
656
|
+
const ratio = ((1 - minSize / originalSize) * 100).toFixed(0);
|
|
657
|
+
console.log(` ${f.replace('.js', '.min.js')} (${_formatBytes(minSize)}, ${ratio}% smaller)`);
|
|
658
|
+
minified++;
|
|
659
|
+
} catch {
|
|
660
|
+
// Skip files that can't be minified
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Rewrite min entrypoints to import minified hashed files
|
|
666
|
+
for (const f of ['server.min.js', 'script.min.js']) {
|
|
667
|
+
const minEntry = join(outDir, f);
|
|
668
|
+
try {
|
|
669
|
+
const content = readFileSync(minEntry, 'utf-8');
|
|
670
|
+
const rewritten = content.replace(/\.js(["'])/g, '.min.js$1');
|
|
671
|
+
writeFileSync(minEntry, rewritten);
|
|
672
|
+
} catch {}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (minified === 0 && jsFiles.length > 0) {
|
|
676
|
+
console.log(' (minification skipped — Bun.build unavailable)');
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Static generation: pre-render each route to its own HTML file
|
|
680
|
+
if (isStatic && allClientCode.trim()) {
|
|
681
|
+
console.log(`\n Static generation...\n`);
|
|
682
|
+
|
|
683
|
+
const routePaths = extractRoutePaths(allClientCode);
|
|
684
|
+
if (routePaths.length > 0) {
|
|
685
|
+
// Read the generated index.html to use as the shell for all routes
|
|
686
|
+
const shellHtml = readFileSync(join(outDir, 'index.html'), 'utf-8');
|
|
687
|
+
for (const routePath of routePaths) {
|
|
688
|
+
const htmlPath = routePath === '/'
|
|
689
|
+
? join(outDir, 'index.html')
|
|
690
|
+
: join(outDir, routePath.replace(/^\//, ''), 'index.html');
|
|
691
|
+
|
|
692
|
+
mkdirSync(dirname(htmlPath), { recursive: true });
|
|
693
|
+
writeFileSync(htmlPath, shellHtml);
|
|
694
|
+
const relPath = relative(outDir, htmlPath);
|
|
695
|
+
console.log(` ${relPath}`);
|
|
696
|
+
}
|
|
697
|
+
console.log(`\n Pre-rendered ${routePaths.length} route(s)`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
console.log(`\n Production build complete.\n`);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function extractRoutePaths(code) {
|
|
705
|
+
// Support both defineRoutes({...}) and createRouter({ routes: {...} })
|
|
706
|
+
let match = code.match(/defineRoutes\s*\(\s*\{([^}]+)\}\s*\)/);
|
|
707
|
+
if (!match) {
|
|
708
|
+
match = code.match(/routes\s*:\s*\{([^}]+)\}/);
|
|
709
|
+
}
|
|
710
|
+
if (!match) return [];
|
|
711
|
+
|
|
712
|
+
const paths = [];
|
|
713
|
+
const entries = match[1].matchAll(/"([^"]+)"\s*:/g);
|
|
714
|
+
for (const entry of entries) {
|
|
715
|
+
const path = entry[1];
|
|
716
|
+
if (path === '404' || path === '*') continue;
|
|
717
|
+
if (path.includes(':')) continue;
|
|
718
|
+
paths.push(path);
|
|
719
|
+
}
|
|
720
|
+
return paths;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Fallback JS minifier — string/regex-aware, no AST required
|
|
724
|
+
function _simpleMinify(code) {
|
|
725
|
+
// Phase 1: Strip comments while respecting strings and regexes
|
|
726
|
+
let stripped = '';
|
|
727
|
+
let i = 0;
|
|
728
|
+
const len = code.length;
|
|
729
|
+
while (i < len) {
|
|
730
|
+
const ch = code[i];
|
|
731
|
+
// String literals — pass through unchanged
|
|
732
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
733
|
+
const quote = ch;
|
|
734
|
+
stripped += ch;
|
|
735
|
+
i++;
|
|
736
|
+
while (i < len) {
|
|
737
|
+
const c = code[i];
|
|
738
|
+
stripped += c;
|
|
739
|
+
if (c === '\\') { i++; if (i < len) { stripped += code[i]; i++; } continue; }
|
|
740
|
+
if (quote === '`' && c === '$' && code[i + 1] === '{') {
|
|
741
|
+
// Template literal expression — track brace depth
|
|
742
|
+
stripped += code[++i]; // '{'
|
|
743
|
+
i++;
|
|
744
|
+
let depth = 1;
|
|
745
|
+
while (i < len && depth > 0) {
|
|
746
|
+
const tc = code[i];
|
|
747
|
+
if (tc === '{') depth++;
|
|
748
|
+
else if (tc === '}') depth--;
|
|
749
|
+
if (depth > 0) { stripped += tc; i++; }
|
|
750
|
+
}
|
|
751
|
+
if (i < len) { stripped += code[i]; i++; } // closing '}'
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
if (c === quote) { i++; break; }
|
|
755
|
+
i++;
|
|
756
|
+
}
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
// Single-line comment
|
|
760
|
+
if (ch === '/' && code[i + 1] === '/') {
|
|
761
|
+
while (i < len && code[i] !== '\n') i++;
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
// Multi-line comment
|
|
765
|
+
if (ch === '/' && code[i + 1] === '*') {
|
|
766
|
+
i += 2;
|
|
767
|
+
while (i < len - 1 && !(code[i] === '*' && code[i + 1] === '/')) i++;
|
|
768
|
+
i += 2;
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
stripped += ch;
|
|
772
|
+
i++;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Phase 2: Process line by line
|
|
776
|
+
const lines = stripped.split('\n');
|
|
777
|
+
const out = [];
|
|
778
|
+
for (let j = 0; j < lines.length; j++) {
|
|
779
|
+
let line = lines[j].trim();
|
|
780
|
+
if (!line) continue; // remove blank lines
|
|
781
|
+
// Strip console.log/debug/warn/info statements (production only)
|
|
782
|
+
if (/^\s*console\.(log|debug|warn|info)\s*\(/.test(line)) {
|
|
783
|
+
// Simple balanced-paren check for single-line console calls
|
|
784
|
+
let parens = 0, inStr = false, sq = '';
|
|
785
|
+
for (let k = 0; k < line.length; k++) {
|
|
786
|
+
const c = line[k];
|
|
787
|
+
if (inStr) { if (c === '\\') k++; else if (c === sq) inStr = false; continue; }
|
|
788
|
+
if (c === '"' || c === "'" || c === '`') { inStr = true; sq = c; continue; }
|
|
789
|
+
if (c === '(') parens++;
|
|
790
|
+
if (c === ')') { parens--; if (parens === 0) break; }
|
|
791
|
+
}
|
|
792
|
+
if (parens === 0) continue; // balanced — safe to strip
|
|
793
|
+
}
|
|
794
|
+
out.push(line);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Phase 3: Collapse whitespace — protect string literals with placeholders
|
|
798
|
+
let joined = out.join('\n');
|
|
799
|
+
const strings = [];
|
|
800
|
+
// Extract all string literals into placeholders so regexes don't mangle them
|
|
801
|
+
let prot = '';
|
|
802
|
+
let pi = 0;
|
|
803
|
+
const plen = joined.length;
|
|
804
|
+
while (pi < plen) {
|
|
805
|
+
const pc = joined[pi];
|
|
806
|
+
if (pc === '"' || pc === "'" || pc === '`') {
|
|
807
|
+
const q = pc;
|
|
808
|
+
let s = pc;
|
|
809
|
+
pi++;
|
|
810
|
+
while (pi < plen) {
|
|
811
|
+
const c = joined[pi];
|
|
812
|
+
s += c;
|
|
813
|
+
if (c === '\\') { pi++; if (pi < plen) { s += joined[pi]; pi++; } continue; }
|
|
814
|
+
if (q === '`' && c === '$' && joined[pi + 1] === '{') {
|
|
815
|
+
s += joined[++pi]; pi++;
|
|
816
|
+
let d = 1;
|
|
817
|
+
while (pi < plen && d > 0) {
|
|
818
|
+
const tc = joined[pi];
|
|
819
|
+
if (tc === '{') d++; else if (tc === '}') d--;
|
|
820
|
+
if (d > 0) { s += tc; pi++; }
|
|
821
|
+
}
|
|
822
|
+
if (pi < plen) { s += joined[pi]; pi++; }
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
if (c === q) { pi++; break; }
|
|
826
|
+
pi++;
|
|
827
|
+
}
|
|
828
|
+
strings.push(s);
|
|
829
|
+
prot += `\x01${strings.length - 1}\x01`;
|
|
830
|
+
} else {
|
|
831
|
+
prot += pc;
|
|
832
|
+
pi++;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Collapse runs of spaces/tabs to single space
|
|
837
|
+
prot = prot.replace(/[ \t]+/g, ' ');
|
|
838
|
+
// Remove spaces around braces, brackets, parens, semicolons, commas
|
|
839
|
+
prot = prot.replace(/ ?([{}[\]();,]) ?/g, '$1');
|
|
840
|
+
// Restore space after keywords that need it
|
|
841
|
+
prot = prot.replace(/\b(return|const|let|var|if|else|for|while|do|switch|case|throw|new|typeof|instanceof|in|of|yield|await|export|import|from|function|class|extends|async|delete|void)\b(?=[^\s;,})\]])/g, '$1 ');
|
|
842
|
+
// Add space after colon in object literals (key: value)
|
|
843
|
+
prot = prot.replace(/([a-zA-Z0-9_$]):([^\s}])/g, '$1: $2');
|
|
844
|
+
|
|
845
|
+
// Restore string literals
|
|
846
|
+
let result = prot.replace(/\x01(\d+)\x01/g, (_, idx) => strings[idx]);
|
|
847
|
+
|
|
848
|
+
// Phase 4: Dead function elimination — remove unused top-level functions
|
|
849
|
+
result = _eliminateDeadFunctions(result);
|
|
850
|
+
|
|
851
|
+
return result;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Remove top-level function declarations that are never reachable from non-function code.
|
|
855
|
+
// Uses reachability analysis to handle mutual recursion (foo<->bar both dead).
|
|
856
|
+
function _eliminateDeadFunctions(code) {
|
|
857
|
+
const funcDeclRe = /^function\s+([\w$]+)\s*\(/gm;
|
|
858
|
+
const allDecls = []; // [{ name, start, end }]
|
|
859
|
+
let m;
|
|
860
|
+
|
|
861
|
+
// First pass: find all top-level function declarations and their full extent
|
|
862
|
+
while ((m = funcDeclRe.exec(code)) !== null) {
|
|
863
|
+
const name = m[1];
|
|
864
|
+
const start = m.index;
|
|
865
|
+
let depth = 0, i = start, inStr = false, strCh = '', foundOpen = false;
|
|
866
|
+
while (i < code.length) {
|
|
867
|
+
const ch = code[i];
|
|
868
|
+
if (inStr) { if (ch === '\\') { i += 2; continue; } if (ch === strCh) inStr = false; i++; continue; }
|
|
869
|
+
if (ch === '"' || ch === "'" || ch === '`') { inStr = true; strCh = ch; i++; continue; }
|
|
870
|
+
if (ch === '{') { depth++; foundOpen = true; }
|
|
871
|
+
else if (ch === '}') { depth--; if (foundOpen && depth === 0) { i++; break; } }
|
|
872
|
+
i++;
|
|
873
|
+
}
|
|
874
|
+
allDecls.push({ name, start, end: i });
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (allDecls.length === 0) return code;
|
|
878
|
+
|
|
879
|
+
// Build a set of all declared function names
|
|
880
|
+
const declaredNames = new Set(allDecls.map(d => d.name));
|
|
881
|
+
|
|
882
|
+
// Helper: find which declared names are referenced in a text region
|
|
883
|
+
function findRefs(text) {
|
|
884
|
+
const refs = new Set();
|
|
885
|
+
for (const name of declaredNames) {
|
|
886
|
+
const escaped = name.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
887
|
+
if (new RegExp('\\b' + escaped + '\\b').test(text)) refs.add(name);
|
|
888
|
+
}
|
|
889
|
+
return refs;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Build "root" code — everything outside function declarations
|
|
893
|
+
const sortedDecls = [...allDecls].sort((a, b) => a.start - b.start);
|
|
894
|
+
let rootCode = '';
|
|
895
|
+
let pos = 0;
|
|
896
|
+
for (const decl of sortedDecls) {
|
|
897
|
+
rootCode += code.slice(pos, decl.start);
|
|
898
|
+
pos = decl.end;
|
|
899
|
+
}
|
|
900
|
+
rootCode += code.slice(pos);
|
|
901
|
+
|
|
902
|
+
// Find which functions are directly reachable from root code
|
|
903
|
+
const rootRefs = findRefs(rootCode);
|
|
904
|
+
|
|
905
|
+
// Build dependency graph: for each function, which other declared functions does it reference?
|
|
906
|
+
const deps = new Map();
|
|
907
|
+
for (const decl of allDecls) {
|
|
908
|
+
const body = code.slice(decl.start, decl.end);
|
|
909
|
+
const bodyRefs = findRefs(body);
|
|
910
|
+
bodyRefs.delete(decl.name); // ignore self-references
|
|
911
|
+
deps.set(decl.name, bodyRefs);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// BFS from root refs to find all transitively reachable functions
|
|
915
|
+
const reachable = new Set();
|
|
916
|
+
const queue = [...rootRefs];
|
|
917
|
+
while (queue.length > 0) {
|
|
918
|
+
const name = queue.pop();
|
|
919
|
+
if (reachable.has(name)) continue;
|
|
920
|
+
reachable.add(name);
|
|
921
|
+
const fnDeps = deps.get(name);
|
|
922
|
+
if (fnDeps) for (const dep of fnDeps) queue.push(dep);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Remove unreachable functions
|
|
926
|
+
const toRemove = allDecls.filter(d => !reachable.has(d.name));
|
|
927
|
+
if (toRemove.length === 0) return code;
|
|
928
|
+
|
|
929
|
+
toRemove.sort((a, b) => b.start - a.start);
|
|
930
|
+
let result = code;
|
|
931
|
+
for (const { start, end } of toRemove) {
|
|
932
|
+
let removeEnd = end;
|
|
933
|
+
while (removeEnd < result.length && (result[removeEnd] === '\n' || result[removeEnd] === '\r')) removeEnd++;
|
|
934
|
+
result = result.slice(0, start) + result.slice(removeEnd);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
return result;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
class BuildCache {
|
|
941
|
+
constructor(cacheDir) {
|
|
942
|
+
this._cacheDir = cacheDir;
|
|
943
|
+
this._manifest = null; // { files: { [absPath]: { hash, outputs: {...} } } }
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
_manifestPath() {
|
|
947
|
+
return join(this._cacheDir, 'manifest.json');
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
_hashContent(content) {
|
|
951
|
+
if (typeof Bun !== 'undefined' && Bun.hash) return Bun.hash(content).toString(16);
|
|
952
|
+
return _cryptoHash('md5').update(content).digest('hex');
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
load() {
|
|
956
|
+
try {
|
|
957
|
+
if (existsSync(this._manifestPath())) {
|
|
958
|
+
this._manifest = JSON.parse(readFileSync(this._manifestPath(), 'utf-8'));
|
|
959
|
+
}
|
|
960
|
+
} catch {
|
|
961
|
+
this._manifest = null;
|
|
962
|
+
}
|
|
963
|
+
if (!this._manifest) this._manifest = { files: {} };
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
save() {
|
|
967
|
+
mkdirSync(this._cacheDir, { recursive: true });
|
|
968
|
+
writeFileSync(this._manifestPath(), JSON.stringify(this._manifest, null, 2));
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Check if a source file is unchanged since last build
|
|
972
|
+
isUpToDate(absPath, sourceContent) {
|
|
973
|
+
if (!this._manifest) return false;
|
|
974
|
+
const entry = this._manifest.files[absPath];
|
|
975
|
+
if (!entry) return false;
|
|
976
|
+
return entry.hash === this._hashContent(sourceContent);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Check if a multi-file group (directory) is unchanged since last build
|
|
980
|
+
isGroupUpToDate(dirKey, files) {
|
|
981
|
+
if (!this._manifest) return false;
|
|
982
|
+
const entry = this._manifest.files[dirKey];
|
|
983
|
+
if (!entry) return false;
|
|
984
|
+
return entry.hash === this._hashGroup(files);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// Hash multiple files together for group caching
|
|
988
|
+
_hashGroup(files) {
|
|
989
|
+
let combined = '';
|
|
990
|
+
for (const f of files.slice().sort()) {
|
|
991
|
+
combined += f + readFileSync(f, 'utf-8');
|
|
992
|
+
}
|
|
993
|
+
if (typeof Bun !== 'undefined' && Bun.hash) return Bun.hash(combined).toString(16);
|
|
994
|
+
return _cryptoHash('md5').update(combined).digest('hex');
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Store compiled output for a multi-file group
|
|
998
|
+
setGroup(dirKey, files, outputs) {
|
|
999
|
+
if (!this._manifest) this._manifest = { files: {} };
|
|
1000
|
+
this._manifest.files[dirKey] = {
|
|
1001
|
+
hash: this._hashGroup(files),
|
|
1002
|
+
outputs,
|
|
1003
|
+
timestamp: Date.now(),
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Get cached compiled output for a source file
|
|
1008
|
+
getCached(absPath) {
|
|
1009
|
+
if (!this._manifest) return null;
|
|
1010
|
+
const entry = this._manifest.files[absPath];
|
|
1011
|
+
if (!entry || !entry.outputs) return null;
|
|
1012
|
+
// Verify cached output files still exist on disk
|
|
1013
|
+
for (const outFile of Object.values(entry.outputs)) {
|
|
1014
|
+
if (outFile && !existsSync(outFile)) return null;
|
|
1015
|
+
}
|
|
1016
|
+
return entry.outputs;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// Store compiled output for a source file
|
|
1020
|
+
set(absPath, sourceContent, outputs) {
|
|
1021
|
+
if (!this._manifest) this._manifest = { files: {} };
|
|
1022
|
+
this._manifest.files[absPath] = {
|
|
1023
|
+
hash: this._hashContent(sourceContent),
|
|
1024
|
+
outputs,
|
|
1025
|
+
timestamp: Date.now(),
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Remove stale entries for files/dirs that no longer exist
|
|
1030
|
+
prune(existingFiles, existingDirs) {
|
|
1031
|
+
if (!this._manifest) return;
|
|
1032
|
+
const existingSet = new Set(existingFiles);
|
|
1033
|
+
const dirSet = existingDirs ? new Set(existingDirs) : null;
|
|
1034
|
+
for (const key of Object.keys(this._manifest.files)) {
|
|
1035
|
+
if (key.startsWith('dir:')) {
|
|
1036
|
+
if (dirSet && !dirSet.has(key)) delete this._manifest.files[key];
|
|
1037
|
+
} else if (!existingSet.has(key)) {
|
|
1038
|
+
delete this._manifest.files[key];
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
export { buildProject, cleanBuild };
|