tova 0.1.1 → 0.2.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.
package/bin/tova.js CHANGED
@@ -1,19 +1,24 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { resolve, basename, dirname, join, relative } from 'path';
4
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, copyFileSync, watch as fsWatch } from 'fs';
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, copyFileSync, rmSync, watch as fsWatch } from 'fs';
5
5
  import { spawn } from 'child_process';
6
6
  import { createHash } from 'crypto';
7
7
  import { createRequire } from 'module';
8
8
  import { Lexer } from '../src/lexer/lexer.js';
9
9
  import { Parser } from '../src/parser/parser.js';
10
10
  import { Analyzer } from '../src/analyzer/analyzer.js';
11
+ import { Program } from '../src/parser/ast.js';
11
12
  import { CodeGenerator } from '../src/codegen/codegen.js';
12
13
  import { richError, formatDiagnostics, DiagnosticFormatter } from '../src/diagnostics/formatter.js';
13
- import { getFullStdlib, BUILTINS, PROPAGATE } from '../src/stdlib/inline.js';
14
+ import { getFullStdlib, buildSelectiveStdlib, BUILTIN_NAMES, PROPAGATE } from '../src/stdlib/inline.js';
14
15
  import { Formatter } from '../src/formatter/formatter.js';
15
16
  import { REACTIVITY_SOURCE, RPC_SOURCE, ROUTER_SOURCE } from '../src/runtime/embedded.js';
16
17
  import '../src/runtime/string-proto.js';
18
+ import { resolveConfig } from '../src/config/resolve.js';
19
+ import { writePackageJson } from '../src/config/package-json.js';
20
+ import { addToSection, removeFromSection } from '../src/config/edit-toml.js';
21
+ import { stringifyTOML } from '../src/config/toml.js';
17
22
 
18
23
  const require = createRequire(import.meta.url);
19
24
  const { version: VERSION } = require('../package.json');
@@ -23,6 +28,7 @@ const HELP = `
23
28
  ║ ║ ║ ║ ╠╣
24
29
  ╩═╝╚═╝╩═╝╩ v${VERSION}
25
30
 
31
+ Created by Enoch Kujem Abassey
26
32
  A modern full-stack language that transpiles to JavaScript
27
33
 
28
34
  Usage:
@@ -31,10 +37,13 @@ Usage:
31
37
  Commands:
32
38
  run <file> Compile and execute a .tova file
33
39
  build [dir] Compile .tova files to JavaScript (default: current dir)
34
- dev Start development server with file watching
40
+ dev Start development server with live reload
41
+ new <name> Create a new Tova project
42
+ install Install npm dependencies from tova.toml
43
+ add <pkg> Add an npm dependency (--dev for dev dependency)
44
+ remove <pkg> Remove an npm dependency
35
45
  repl Start interactive Tova REPL
36
46
  lsp Start Language Server Protocol server
37
- new <name> Create a new Tova project
38
47
  fmt <file> Format a .tova file (--check to verify only)
39
48
  test [dir] Run test blocks in .tova files (--filter, --watch)
40
49
  migrate:create <name> Create a new migration file
@@ -48,6 +57,7 @@ Options:
48
57
  --production Production build (minify, bundle, hash)
49
58
  --watch Watch for file changes
50
59
  --debug Show verbose error output
60
+ --strict Enable strict type checking
51
61
  `;
52
62
 
53
63
  async function main() {
@@ -65,9 +75,10 @@ async function main() {
65
75
 
66
76
  const command = args[0];
67
77
 
78
+ const isStrict = args.includes('--strict');
68
79
  switch (command) {
69
80
  case 'run':
70
- await runFile(args[1]);
81
+ await runFile(args.filter(a => a !== '--strict')[1], { strict: isStrict });
71
82
  break;
72
83
  case 'build':
73
84
  buildProject(args.slice(1));
@@ -84,6 +95,15 @@ async function main() {
84
95
  case 'new':
85
96
  newProject(args[1]);
86
97
  break;
98
+ case 'install':
99
+ await installDeps();
100
+ break;
101
+ case 'add':
102
+ await addDep(args.slice(1));
103
+ break;
104
+ case 'remove':
105
+ await removeDep(args[1]);
106
+ break;
87
107
  case 'fmt':
88
108
  formatFile(args.slice(1));
89
109
  break;
@@ -101,7 +121,7 @@ async function main() {
101
121
  break;
102
122
  default:
103
123
  if (command.endsWith('.tova')) {
104
- await runFile(command);
124
+ await runFile(command, { strict: isStrict });
105
125
  } else {
106
126
  console.error(`Unknown command: ${command}`);
107
127
  console.log(HELP);
@@ -112,14 +132,14 @@ async function main() {
112
132
 
113
133
  // ─── Compile a .tova source string ───────────────────────────
114
134
 
115
- function compileTova(source, filename) {
135
+ function compileTova(source, filename, options = {}) {
116
136
  const lexer = new Lexer(source, filename);
117
137
  const tokens = lexer.tokenize();
118
138
 
119
139
  const parser = new Parser(tokens, filename);
120
140
  const ast = parser.parse();
121
141
 
122
- const analyzer = new Analyzer(ast, filename);
142
+ const analyzer = new Analyzer(ast, filename, { strict: options.strict || false });
123
143
  const { warnings } = analyzer.analyze();
124
144
 
125
145
  if (warnings.length > 0) {
@@ -299,11 +319,25 @@ function findTovaFiles(dir) {
299
319
 
300
320
  // ─── Run ────────────────────────────────────────────────────
301
321
 
302
- async function runFile(filePath) {
322
+ async function runFile(filePath, options = {}) {
303
323
  if (!filePath) {
304
- console.error('Error: No file specified');
305
- console.error('Usage: tova run <file.tova>');
306
- process.exit(1);
324
+ // If tova.toml exists, try to find a main file in the entry directory
325
+ const config = resolveConfig(process.cwd());
326
+ if (config._source === 'tova.toml') {
327
+ const entryDir = resolve(config.project.entry || '.');
328
+ for (const name of ['main.tova', 'app.tova']) {
329
+ const candidate = join(entryDir, name);
330
+ if (existsSync(candidate)) {
331
+ filePath = candidate;
332
+ break;
333
+ }
334
+ }
335
+ }
336
+ if (!filePath) {
337
+ console.error('Error: No file specified');
338
+ console.error('Usage: tova run <file.tova>');
339
+ process.exit(1);
340
+ }
307
341
  }
308
342
 
309
343
  const resolved = resolve(filePath);
@@ -315,7 +349,7 @@ async function runFile(filePath) {
315
349
  const source = readFileSync(resolved, 'utf-8');
316
350
 
317
351
  try {
318
- const output = compileTova(source, filePath);
352
+ const output = compileTova(source, filePath, { strict: options.strict });
319
353
 
320
354
  // Execute the generated JavaScript (with stdlib)
321
355
  const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
@@ -335,10 +369,13 @@ async function runFile(filePath) {
335
369
  // ─── Build ──────────────────────────────────────────────────
336
370
 
337
371
  async function buildProject(args) {
372
+ const config = resolveConfig(process.cwd());
338
373
  const isProduction = args.includes('--production');
339
- const srcDir = resolve(args.filter(a => !a.startsWith('--'))[0] || '.');
374
+ const buildStrict = args.includes('--strict');
375
+ const explicitSrc = args.filter(a => !a.startsWith('--'))[0];
376
+ const srcDir = resolve(explicitSrc || config.project.entry || '.');
340
377
  const outIdx = args.indexOf('--output');
341
- const outDir = resolve(outIdx >= 0 ? args[outIdx + 1] : '.tova-out');
378
+ const outDir = resolve(outIdx >= 0 ? args[outIdx + 1] : (config.build.output || '.tova-out'));
342
379
 
343
380
  // Production build uses a separate optimized pipeline
344
381
  if (isProduction) {
@@ -364,22 +401,33 @@ async function buildProject(args) {
364
401
 
365
402
  let errorCount = 0;
366
403
  compilationCache.clear();
367
- for (const file of tovaFiles) {
368
- const rel = relative(srcDir, file);
404
+
405
+ // Group files by directory for multi-file merging
406
+ const dirGroups = groupFilesByDirectory(tovaFiles);
407
+
408
+ for (const [dir, files] of dirGroups) {
409
+ const dirName = basename(dir) === '.' ? 'app' : basename(dir);
410
+ const relDir = relative(srcDir, dir) || '.';
369
411
  try {
370
- const source = readFileSync(file, 'utf-8');
371
- const output = compileWithImports(source, file, srcDir);
372
- const baseName = basename(file, '.tova');
412
+ const result = mergeDirectory(dir, srcDir, { strict: buildStrict });
413
+ if (!result) continue;
414
+
415
+ const { output, single } = result;
416
+ // Use single-file basename for single-file dirs, directory name for multi-file
417
+ const outBaseName = single ? basename(files[0], '.tova') : dirName;
418
+ const relLabel = single ? relative(srcDir, files[0]) : `${relDir}/ (${files.length} files merged)`;
373
419
 
374
- // Generate source map if mappings available
420
+ // Helper to generate source maps
375
421
  const generateSourceMap = (code, jsFile) => {
376
422
  if (output.sourceMappings && output.sourceMappings.length > 0) {
377
- const smb = new SourceMapBuilder(rel);
423
+ const sourceFile = single ? relative(srcDir, files[0]) : relDir;
424
+ const smb = new SourceMapBuilder(sourceFile, output._sourceFiles);
378
425
  for (const m of output.sourceMappings) {
379
- smb.addMapping(m.sourceLine, m.sourceCol, m.outputLine, m.outputCol);
426
+ smb.addMapping(m.sourceLine, m.sourceCol, m.outputLine, m.outputCol, m.sourceFile);
380
427
  }
428
+ const sourceContent = single ? readFileSync(files[0], 'utf-8') : null;
381
429
  const mapFile = jsFile + '.map';
382
- writeFileSync(mapFile, smb.generate(source));
430
+ writeFileSync(mapFile, smb.generate(sourceContent, output._sourceContents));
383
431
  return code + `\n//# sourceMappingURL=${basename(mapFile)}`;
384
432
  }
385
433
  return code;
@@ -387,32 +435,32 @@ async function buildProject(args) {
387
435
 
388
436
  // Write shared
389
437
  if (output.shared && output.shared.trim()) {
390
- const sharedPath = join(outDir, `${baseName}.shared.js`);
438
+ const sharedPath = join(outDir, `${outBaseName}.shared.js`);
391
439
  writeFileSync(sharedPath, generateSourceMap(output.shared, sharedPath));
392
- console.log(` ✓ ${rel} → ${relative('.', sharedPath)}`);
440
+ console.log(` ✓ ${relLabel} → ${relative('.', sharedPath)}`);
393
441
  }
394
442
 
395
443
  // Write default server
396
444
  if (output.server) {
397
- const serverPath = join(outDir, `${baseName}.server.js`);
445
+ const serverPath = join(outDir, `${outBaseName}.server.js`);
398
446
  writeFileSync(serverPath, generateSourceMap(output.server, serverPath));
399
- console.log(` ✓ ${rel} → ${relative('.', serverPath)}`);
447
+ console.log(` ✓ ${relLabel} → ${relative('.', serverPath)}`);
400
448
  }
401
449
 
402
450
  // Write default client
403
451
  if (output.client) {
404
- const clientPath = join(outDir, `${baseName}.client.js`);
452
+ const clientPath = join(outDir, `${outBaseName}.client.js`);
405
453
  writeFileSync(clientPath, generateSourceMap(output.client, clientPath));
406
- console.log(` ✓ ${rel} → ${relative('.', clientPath)}`);
454
+ console.log(` ✓ ${relLabel} → ${relative('.', clientPath)}`);
407
455
  }
408
456
 
409
457
  // Write named server blocks (multi-block)
410
458
  if (output.multiBlock && output.servers) {
411
459
  for (const [name, code] of Object.entries(output.servers)) {
412
- if (name === 'default') continue; // already written above
413
- const path = join(outDir, `${baseName}.server.${name}.js`);
460
+ if (name === 'default') continue;
461
+ const path = join(outDir, `${outBaseName}.server.${name}.js`);
414
462
  writeFileSync(path, code);
415
- console.log(` ✓ ${rel} → ${relative('.', path)} [server:${name}]`);
463
+ console.log(` ✓ ${relLabel} → ${relative('.', path)} [server:${name}]`);
416
464
  }
417
465
  }
418
466
 
@@ -420,26 +468,31 @@ async function buildProject(args) {
420
468
  if (output.multiBlock && output.clients) {
421
469
  for (const [name, code] of Object.entries(output.clients)) {
422
470
  if (name === 'default') continue;
423
- const path = join(outDir, `${baseName}.client.${name}.js`);
471
+ const path = join(outDir, `${outBaseName}.client.${name}.js`);
424
472
  writeFileSync(path, code);
425
- console.log(` ✓ ${rel} → ${relative('.', path)} [client:${name}]`);
473
+ console.log(` ✓ ${relLabel} → ${relative('.', path)} [client:${name}]`);
426
474
  }
427
475
  }
428
476
  } catch (err) {
429
- console.error(` ✗ ${rel}: ${err.message}`);
477
+ console.error(` ✗ ${relDir}: ${err.message}`);
430
478
  errorCount++;
431
479
  }
432
480
  }
433
481
 
434
- console.log(`\n Build complete. ${tovaFiles.length - errorCount}/${tovaFiles.length} succeeded.\n`);
482
+ const dirCount = dirGroups.size;
483
+ console.log(`\n Build complete. ${dirCount - errorCount}/${dirCount} directory group(s) succeeded.\n`);
435
484
  if (errorCount > 0) process.exit(1);
436
485
  }
437
486
 
438
487
  // ─── Dev Server ─────────────────────────────────────────────
439
488
 
440
489
  async function devServer(args) {
441
- const srcDir = resolve(args[0] || '.');
442
- const basePort = parseInt(args.find((_, i, a) => a[i - 1] === '--port') || '3000');
490
+ const config = resolveConfig(process.cwd());
491
+ const explicitSrc = args.filter(a => !a.startsWith('--'))[0];
492
+ const srcDir = resolve(explicitSrc || config.project.entry || '.');
493
+ const explicitPort = args.find((_, i, a) => a[i - 1] === '--port');
494
+ const basePort = parseInt(explicitPort || config.dev.port || '3000');
495
+ const buildStrict = args.includes('--strict');
443
496
 
444
497
  const tovaFiles = findFiles(srcDir, '.tova');
445
498
  if (tovaFiles.length === 0) {
@@ -447,6 +500,8 @@ async function devServer(args) {
447
500
  process.exit(1);
448
501
  }
449
502
 
503
+ const reloadPort = basePort + 100;
504
+
450
505
  console.log(`\n Tova dev server starting...\n`);
451
506
 
452
507
  // Compile all files
@@ -463,63 +518,75 @@ async function devServer(args) {
463
518
  const serverFiles = [];
464
519
  let hasClient = false;
465
520
 
466
- for (const file of tovaFiles) {
521
+ // Clear import caches for fresh compilation
522
+ compilationCache.clear();
523
+ compilationInProgress.clear();
524
+ moduleExports.clear();
525
+
526
+ // Compile via directory merging
527
+ const dirGroups = groupFilesByDirectory(tovaFiles);
528
+ let clientHTML = '';
529
+
530
+ // Pass 1: Merge each directory, write shared/client outputs, collect clientHTML
531
+ const dirResults = [];
532
+ for (const [dir, files] of dirGroups) {
533
+ const dirName = basename(dir) === '.' ? 'app' : basename(dir);
467
534
  try {
468
- const source = readFileSync(file, 'utf-8');
469
- const output = compileTova(source, file);
470
- const baseName = basename(file, '.tova');
535
+ const result = mergeDirectory(dir, srcDir, { strict: buildStrict });
536
+ if (!result) continue;
537
+
538
+ const { output, single } = result;
539
+ const outBaseName = single ? basename(files[0], '.tova') : dirName;
540
+ dirResults.push({ dir, output, outBaseName, single, files });
471
541
 
472
542
  if (output.shared && output.shared.trim()) {
473
- writeFileSync(join(outDir, `${baseName}.shared.js`), output.shared);
543
+ writeFileSync(join(outDir, `${outBaseName}.shared.js`), output.shared);
474
544
  }
475
545
 
476
- // Default client (generate HTML first so server can embed it)
477
- let clientHTML = '';
478
546
  if (output.client) {
479
- const p = join(outDir, `${baseName}.client.js`);
547
+ const p = join(outDir, `${outBaseName}.client.js`);
480
548
  writeFileSync(p, output.client);
481
- clientHTML = generateDevHTML(output.client);
549
+ clientHTML = await generateDevHTML(output.client, srcDir, reloadPort);
482
550
  writeFileSync(join(outDir, 'index.html'), clientHTML);
483
551
  hasClient = true;
484
552
  }
553
+ } catch (err) {
554
+ console.error(` ✗ ${relative(srcDir, dir)}: ${err.message}`);
555
+ }
556
+ }
485
557
 
486
- // Default server (inject client HTML for serving at /)
487
- if (output.server) {
488
- let serverCode = output.server;
489
- if (clientHTML) {
490
- // Inject __clientHTML constant before the request handler
491
- const htmlConst = `const __clientHTML = ${JSON.stringify(clientHTML)};\n`;
492
- serverCode = htmlConst + serverCode;
493
- }
494
- const p = join(outDir, `${baseName}.server.js`);
495
- writeFileSync(p, serverCode);
496
- serverFiles.push({ path: p, name: 'default', baseName });
558
+ // Pass 2: Write server files with clientHTML injected
559
+ for (const { output, outBaseName } of dirResults) {
560
+ if (output.server) {
561
+ let serverCode = output.server;
562
+ if (clientHTML) {
563
+ const htmlConst = `const __clientHTML = ${JSON.stringify(clientHTML)};\n`;
564
+ serverCode = htmlConst + serverCode;
497
565
  }
566
+ const p = join(outDir, `${outBaseName}.server.js`);
567
+ writeFileSync(p, serverCode);
568
+ serverFiles.push({ path: p, name: 'default', baseName: outBaseName });
569
+ }
498
570
 
499
- // Named server blocks
500
- if (output.multiBlock && output.servers) {
501
- for (const [name, code] of Object.entries(output.servers)) {
502
- if (name === 'default') continue;
503
- const p = join(outDir, `${baseName}.server.${name}.js`);
504
- writeFileSync(p, code);
505
- serverFiles.push({ path: p, name, baseName });
506
- }
571
+ if (output.multiBlock && output.servers) {
572
+ for (const [name, code] of Object.entries(output.servers)) {
573
+ if (name === 'default') continue;
574
+ const p = join(outDir, `${outBaseName}.server.${name}.js`);
575
+ writeFileSync(p, code);
576
+ serverFiles.push({ path: p, name, baseName: outBaseName });
507
577
  }
578
+ }
508
579
 
509
- // Named client blocks
510
- if (output.multiBlock && output.clients) {
511
- for (const [name, code] of Object.entries(output.clients)) {
512
- if (name === 'default') continue;
513
- const p = join(outDir, `${baseName}.client.${name}.js`);
514
- writeFileSync(p, code);
515
- }
580
+ if (output.multiBlock && output.clients) {
581
+ for (const [name, code] of Object.entries(output.clients)) {
582
+ if (name === 'default') continue;
583
+ const p = join(outDir, `${outBaseName}.client.${name}.js`);
584
+ writeFileSync(p, code);
516
585
  }
517
- } catch (err) {
518
- console.error(` ✗ ${relative(srcDir, file)}: ${err.message}`);
519
586
  }
520
587
  }
521
588
 
522
- console.log(` ✓ Compiled ${tovaFiles.length} file(s)`);
589
+ console.log(` ✓ Compiled ${tovaFiles.length} file(s) from ${dirGroups.size} directory group(s)`);
523
590
  console.log(` ✓ Output: ${relative('.', outDir)}/`);
524
591
 
525
592
  // Orchestrate: spawn each server block as a separate Bun process
@@ -559,6 +626,49 @@ async function devServer(args) {
559
626
  console.log(` ✓ Client: ${relative('.', outDir)}/index.html`);
560
627
  }
561
628
 
629
+ // Start live-reload SSE server
630
+ const reloadClients = new Set();
631
+ const reloadServer = Bun.serve({
632
+ port: reloadPort,
633
+ fetch(req) {
634
+ const url = new URL(req.url);
635
+ if (url.pathname === '/__tova_reload') {
636
+ const stream = new ReadableStream({
637
+ start(controller) {
638
+ const client = { controller };
639
+ reloadClients.add(client);
640
+ // Send heartbeat to keep connection alive
641
+ const heartbeat = setInterval(() => {
642
+ try { controller.enqueue(new TextEncoder().encode(': heartbeat\n\n')); } catch { clearInterval(heartbeat); }
643
+ }, 15000);
644
+ req.signal.addEventListener('abort', () => {
645
+ clearInterval(heartbeat);
646
+ reloadClients.delete(client);
647
+ });
648
+ },
649
+ });
650
+ return new Response(stream, {
651
+ headers: {
652
+ 'Content-Type': 'text/event-stream',
653
+ 'Cache-Control': 'no-cache',
654
+ 'Connection': 'keep-alive',
655
+ 'Access-Control-Allow-Origin': '*',
656
+ },
657
+ });
658
+ }
659
+ return new Response('Not Found', { status: 404 });
660
+ },
661
+ });
662
+
663
+ function notifyReload() {
664
+ const msg = new TextEncoder().encode('data: reload\n\n');
665
+ for (const client of reloadClients) {
666
+ try { client.controller.enqueue(msg); } catch { reloadClients.delete(client); }
667
+ }
668
+ }
669
+
670
+ console.log(` ✓ Live reload on port ${reloadPort}`);
671
+
562
672
  // Start file watcher for auto-rebuild
563
673
  const watcher = startWatcher(srcDir, async () => {
564
674
  console.log(' Rebuilding...');
@@ -566,30 +676,50 @@ async function devServer(args) {
566
676
  // Recompile first — keep old processes alive until success
567
677
  const currentFiles = findFiles(srcDir, '.tova');
568
678
  const newServerFiles = [];
679
+
680
+ // Clear import caches for fresh compilation
681
+ compilationCache.clear();
682
+ compilationInProgress.clear();
683
+ moduleExports.clear();
684
+
569
685
  try {
570
- for (const file of currentFiles) {
571
- const source = readFileSync(file, 'utf-8');
572
- const output = compileTova(source, file);
573
- const baseName = basename(file, '.tova');
686
+ // Merge each directory group, collect client HTML
687
+ const rebuildDirGroups = groupFilesByDirectory(currentFiles);
688
+ let rebuildClientHTML = '';
689
+
690
+ for (const [dir, files] of rebuildDirGroups) {
691
+ const dirName = basename(dir) === '.' ? 'app' : basename(dir);
692
+ const result = mergeDirectory(dir, srcDir, { strict: buildStrict });
693
+ if (!result) continue;
694
+
695
+ const { output, single } = result;
696
+ const outBaseName = single ? basename(files[0], '.tova') : dirName;
574
697
 
575
698
  if (output.shared && output.shared.trim()) {
576
- writeFileSync(join(outDir, `${baseName}.shared.js`), output.shared);
699
+ writeFileSync(join(outDir, `${outBaseName}.shared.js`), output.shared);
577
700
  }
578
701
  if (output.client) {
579
- writeFileSync(join(outDir, `${baseName}.client.js`), output.client);
580
- const html = generateDevHTML(output.client);
581
- writeFileSync(join(outDir, 'index.html'), html);
702
+ writeFileSync(join(outDir, `${outBaseName}.client.js`), output.client);
703
+ rebuildClientHTML = await generateDevHTML(output.client, srcDir, reloadPort);
704
+ writeFileSync(join(outDir, 'index.html'), rebuildClientHTML);
582
705
  }
583
706
  if (output.server) {
584
707
  let serverCode = output.server;
585
- if (output.client) {
586
- const html = generateDevHTML(output.client);
587
- serverCode = `const __clientHTML = ${JSON.stringify(html)};\n` + serverCode;
708
+ if (rebuildClientHTML) {
709
+ serverCode = `const __clientHTML = ${JSON.stringify(rebuildClientHTML)};\n` + serverCode;
588
710
  }
589
- const p = join(outDir, `${baseName}.server.js`);
711
+ const p = join(outDir, `${outBaseName}.server.js`);
590
712
  writeFileSync(p, serverCode);
591
713
  newServerFiles.push(p);
592
714
  }
715
+ if (output.multiBlock && output.servers) {
716
+ for (const [name, code] of Object.entries(output.servers)) {
717
+ if (name === 'default') continue;
718
+ const p = join(outDir, `${outBaseName}.server.${name}.js`);
719
+ writeFileSync(p, code);
720
+ newServerFiles.push(p);
721
+ }
722
+ }
593
723
  }
594
724
  } catch (err) {
595
725
  console.error(` ✗ Rebuild failed: ${err.message}`);
@@ -620,6 +750,7 @@ async function devServer(args) {
620
750
  rebuildPortOffset++;
621
751
  }
622
752
  console.log(' ✓ Rebuild complete');
753
+ notifyReload();
623
754
  });
624
755
 
625
756
  console.log(`\n Watching for changes. Press Ctrl+C to stop\n`);
@@ -628,6 +759,7 @@ async function devServer(args) {
628
759
  process.on('SIGINT', () => {
629
760
  console.log('\n Shutting down...');
630
761
  watcher.close();
762
+ reloadServer.stop();
631
763
  for (const p of processes) {
632
764
  p.child.kill('SIGTERM');
633
765
  }
@@ -638,14 +770,50 @@ async function devServer(args) {
638
770
  await new Promise(() => {});
639
771
  }
640
772
 
641
- function generateDevHTML(clientCode) {
773
+ async function generateDevHTML(clientCode, srcDir, reloadPort = 0) {
774
+ const liveReloadScript = reloadPort ? `
775
+ <script>
776
+ (function() {
777
+ var es = new EventSource("http://localhost:${reloadPort}/__tova_reload");
778
+ es.onmessage = function(e) { if (e.data === "reload") window.location.reload(); };
779
+ es.onerror = function() { setTimeout(function() { window.location.reload(); }, 1000); };
780
+ })();
781
+ </script>` : '';
782
+
783
+ // Check if client code uses npm packages — if so, bundle with Bun.build
784
+ if (srcDir && hasNpmImports(clientCode)) {
785
+ const bundled = await bundleClientCode(clientCode, srcDir);
786
+ return `<!DOCTYPE html>
787
+ <html lang="en">
788
+ <head>
789
+ <meta charset="UTF-8">
790
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
791
+ <title>Tova App</title>
792
+ <script src="https://cdn.tailwindcss.com"></script>
793
+ <style>
794
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
795
+ body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; color: #1a1a1a; }
796
+ ul { list-style: none; }
797
+ .done { text-decoration: line-through; opacity: 0.5; }
798
+ </style>
799
+ </head>
800
+ <body>
801
+ <div id="app"></div>
802
+ <script type="module">
803
+ ${bundled}
804
+ </script>${liveReloadScript}
805
+ </body>
806
+ </html>`;
807
+ }
808
+
809
+ // Original path: no npm imports — inline runtime, no bundling overhead
642
810
  // Use embedded runtime sources (no disk reads needed)
643
811
  const inlineReactivity = REACTIVITY_SOURCE.replace(/^export /gm, '');
644
812
  const inlineRpc = RPC_SOURCE.replace(/^export /gm, '');
645
813
 
646
- // Strip import lines from client code (we inline the runtime instead)
814
+ // Strip all import lines from client code (we inline the runtime instead)
647
815
  const inlineClient = clientCode
648
- .replace(/^import\s+\{[^}]+\}\s+from\s+'[^']+';?\s*$/gm, '')
816
+ .replace(/^\s*import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]+['"];?\s*$/gm, '')
649
817
  .trim();
650
818
 
651
819
  return `<!DOCTYPE html>
@@ -654,20 +822,10 @@ function generateDevHTML(clientCode) {
654
822
  <meta charset="UTF-8">
655
823
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
656
824
  <title>Tova App</title>
825
+ <script src="https://cdn.tailwindcss.com"></script>
657
826
  <style>
658
827
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
659
- body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; color: #1a1a1a; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; }
660
- #app { max-width: 520px; margin: 0 auto; padding: 2rem 1rem; }
661
- .app { background: white; border-radius: 16px; padding: 2rem; box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
662
- header { text-align: center; margin-bottom: 1.5rem; }
663
- h1 { font-size: 2rem; margin-bottom: 0.25rem; color: #333; }
664
- h2 { font-size: 1.2rem; margin-bottom: 0.75rem; color: #555; }
665
- .subtitle { font-size: 0.9rem; color: #888; letter-spacing: 0.1em; text-transform: uppercase; }
666
- button { cursor: pointer; padding: 0.5rem 1rem; border: 1px solid #ddd; border-radius: 8px; background: white; font-size: 0.9rem; transition: all 0.15s; }
667
- button:hover { background: #f0f0f0; transform: translateY(-1px); }
668
- button:active { transform: translateY(0); }
669
- input[type="text"] { padding: 0.6rem 0.75rem; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 0.9rem; width: 100%; outline: none; transition: border-color 0.2s; }
670
- input[type="text"]:focus { border-color: #667eea; }
828
+ body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; color: #1a1a1a; }
671
829
  ul { list-style: none; }
672
830
  .done { text-decoration: line-through; opacity: 0.5; }
673
831
  .timer-section { text-align: center; padding: 1.5rem; margin-bottom: 1.5rem; background: #f8f9ff; border-radius: 12px; }
@@ -705,7 +863,7 @@ ${inlineRpc}
705
863
 
706
864
  // ── App ──
707
865
  ${inlineClient}
708
- </script>
866
+ </script>${liveReloadScript}
709
867
  </body>
710
868
  </html>`;
711
869
  }
@@ -730,19 +888,34 @@ function newProject(name) {
730
888
  mkdirSync(projectDir, { recursive: true });
731
889
  mkdirSync(join(projectDir, 'src'));
732
890
 
733
- // package.json
734
- writeFileSync(join(projectDir, 'package.json'), JSON.stringify({
735
- name,
736
- version: '0.1.0',
737
- type: 'module',
738
- scripts: {
739
- dev: 'tova dev src',
740
- build: 'tova build src',
891
+ // tova.toml
892
+ const tomlContent = stringifyTOML({
893
+ project: {
894
+ name,
895
+ version: '0.1.0',
896
+ description: 'A full-stack Tova application',
897
+ entry: 'src',
741
898
  },
742
- dependencies: {
743
- 'tova': '^0.1.0',
899
+ build: {
900
+ output: '.tova-out',
744
901
  },
745
- }, null, 2) + '\n');
902
+ dev: {
903
+ port: 3000,
904
+ },
905
+ dependencies: {},
906
+ npm: {},
907
+ });
908
+ writeFileSync(join(projectDir, 'tova.toml'), tomlContent);
909
+
910
+ // .gitignore
911
+ writeFileSync(join(projectDir, '.gitignore'), `node_modules/
912
+ .tova-out/
913
+ package.json
914
+ bun.lock
915
+ *.db
916
+ *.db-shm
917
+ *.db-wal
918
+ `);
746
919
 
747
920
  // Main app file
748
921
  writeFileSync(join(projectDir, 'src', 'app.tova'), `// ${name} — Built with Tova
@@ -786,24 +959,146 @@ Built with [Tova](https://github.com/tova-lang/tova-lang) — a modern full-stac
786
959
  ## Development
787
960
 
788
961
  \`\`\`bash
789
- bun install
790
- bun run dev
962
+ tova install
963
+ tova dev
791
964
  \`\`\`
792
965
 
793
966
  ## Build
794
967
 
795
968
  \`\`\`bash
796
- bun run build
969
+ tova build
970
+ \`\`\`
971
+
972
+ ## Add npm packages
973
+
974
+ \`\`\`bash
975
+ tova add htmx
976
+ tova add prettier --dev
797
977
  \`\`\`
798
978
  `);
799
979
 
800
- console.log(` ✓ Created ${name}/package.json`);
980
+ console.log(` ✓ Created ${name}/tova.toml`);
981
+ console.log(` ✓ Created ${name}/.gitignore`);
801
982
  console.log(` ✓ Created ${name}/src/app.tova`);
802
983
  console.log(` ✓ Created ${name}/README.md`);
803
984
  console.log(`\n Get started:\n`);
804
985
  console.log(` cd ${name}`);
805
- console.log(` bun install`);
806
- console.log(` bun run dev\n`);
986
+ console.log(` tova install`);
987
+ console.log(` tova dev\n`);
988
+ }
989
+
990
+ // ─── Package Management ─────────────────────────────────────
991
+
992
+ async function installDeps() {
993
+ const cwd = process.cwd();
994
+ const config = resolveConfig(cwd);
995
+
996
+ if (config._source !== 'tova.toml') {
997
+ // No tova.toml — just run bun install as normal
998
+ console.log(' No tova.toml found, running bun install...\n');
999
+ const proc = spawn('bun', ['install'], { stdio: 'inherit', cwd });
1000
+ const code = await new Promise(res => proc.on('close', res));
1001
+ process.exit(code);
1002
+ return;
1003
+ }
1004
+
1005
+ // Generate shadow package.json from tova.toml
1006
+ const wrote = writePackageJson(config, cwd);
1007
+ if (wrote) {
1008
+ console.log(' Generated package.json from tova.toml');
1009
+ const proc = spawn('bun', ['install'], { stdio: 'inherit', cwd });
1010
+ const code = await new Promise(res => proc.on('close', res));
1011
+ process.exit(code);
1012
+ } else {
1013
+ console.log(' No npm dependencies in tova.toml. Nothing to install.\n');
1014
+ }
1015
+ }
1016
+
1017
+ async function addDep(args) {
1018
+ const isDev = args.includes('--dev');
1019
+ const pkg = args.find(a => !a.startsWith('--'));
1020
+
1021
+ if (!pkg) {
1022
+ console.error('Error: No package specified');
1023
+ console.error('Usage: tova add <package> [--dev]');
1024
+ process.exit(1);
1025
+ }
1026
+
1027
+ const cwd = process.cwd();
1028
+ const tomlPath = join(cwd, 'tova.toml');
1029
+
1030
+ if (!existsSync(tomlPath)) {
1031
+ console.error('Error: No tova.toml found in current directory');
1032
+ console.error('Run `tova new <name>` to create a new project, or create tova.toml manually.');
1033
+ process.exit(1);
1034
+ }
1035
+
1036
+ // Parse package name and version
1037
+ let name = pkg;
1038
+ let version = 'latest';
1039
+ if (pkg.includes('@') && !pkg.startsWith('@')) {
1040
+ const atIdx = pkg.lastIndexOf('@');
1041
+ name = pkg.slice(0, atIdx);
1042
+ version = pkg.slice(atIdx + 1);
1043
+ } else if (pkg.startsWith('@') && pkg.includes('@', 1)) {
1044
+ // Scoped package with version: @scope/name@version
1045
+ const atIdx = pkg.lastIndexOf('@');
1046
+ name = pkg.slice(0, atIdx);
1047
+ version = pkg.slice(atIdx + 1);
1048
+ }
1049
+
1050
+ // If version is 'latest', resolve it via npm registry
1051
+ if (version === 'latest') {
1052
+ try {
1053
+ const proc = spawn('npm', ['view', name, 'version'], { stdio: ['pipe', 'pipe', 'pipe'] });
1054
+ let out = '';
1055
+ proc.stdout.on('data', d => out += d);
1056
+ const code = await new Promise(res => proc.on('close', res));
1057
+ if (code === 0 && out.trim()) {
1058
+ version = `^${out.trim()}`;
1059
+ } else {
1060
+ version = '*';
1061
+ }
1062
+ } catch {
1063
+ version = '*';
1064
+ }
1065
+ }
1066
+
1067
+ const section = isDev ? 'npm.dev' : 'npm';
1068
+ addToSection(tomlPath, section, name, version);
1069
+
1070
+ console.log(` Added ${name}@${version} to [${section}] in tova.toml`);
1071
+
1072
+ // Run install
1073
+ await installDeps();
1074
+ }
1075
+
1076
+ async function removeDep(pkg) {
1077
+ if (!pkg) {
1078
+ console.error('Error: No package specified');
1079
+ console.error('Usage: tova remove <package>');
1080
+ process.exit(1);
1081
+ }
1082
+
1083
+ const cwd = process.cwd();
1084
+ const tomlPath = join(cwd, 'tova.toml');
1085
+
1086
+ if (!existsSync(tomlPath)) {
1087
+ console.error('Error: No tova.toml found in current directory');
1088
+ process.exit(1);
1089
+ }
1090
+
1091
+ // Try removing from [npm] first, then [npm.dev]
1092
+ const removed = removeFromSection(tomlPath, 'npm', pkg) ||
1093
+ removeFromSection(tomlPath, 'npm.dev', pkg);
1094
+
1095
+ if (removed) {
1096
+ console.log(` Removed ${pkg} from tova.toml`);
1097
+ await installDeps();
1098
+ } else {
1099
+ console.error(` Package '${pkg}' not found in tova.toml`);
1100
+ process.exit(1);
1101
+ }
807
1102
  }
808
1103
 
809
1104
  // ─── Migrations ─────────────────────────────────────────────
@@ -1026,10 +1321,74 @@ async function migrateStatus(args) {
1026
1321
 
1027
1322
  function getStdlibForRuntime() {
1028
1323
  return getFullStdlib(); // Full stdlib for REPL
1029
- }
1030
- function getRunStdlib() { // Excludes RESULT_OPTION (emitted by codegen)
1031
- return `${BUILTINS}
1032
- ${PROPAGATE}`;
1324
+ }
1325
+ function getRunStdlib() { // Only PROPAGATE codegen tree-shakes stdlib into output.shared
1326
+ return PROPAGATE;
1327
+ }
1328
+
1329
+ // ─── npm Interop Utilities ───────────────────────────────────
1330
+
1331
+ function hasNpmImports(code) {
1332
+ // Match import statements with bare specifiers (not relative paths or runtime imports)
1333
+ const importRegex = /^\s*import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"]([^'"]+)['"];?\s*$/gm;
1334
+ let match;
1335
+ while ((match = importRegex.exec(code)) !== null) {
1336
+ const source = match[1];
1337
+ // Skip relative imports and runtime imports
1338
+ if (source.startsWith('./') || source.startsWith('../') || source.startsWith('/') || source.startsWith('./runtime/')) {
1339
+ continue;
1340
+ }
1341
+ return true;
1342
+ }
1343
+ return false;
1344
+ }
1345
+
1346
+ async function bundleClientCode(clientCode, srcDir) {
1347
+ const tmpDir = join(srcDir, '.tova-out', '.tmp-bundle');
1348
+ try {
1349
+ mkdirSync(tmpDir, { recursive: true });
1350
+ mkdirSync(join(tmpDir, 'runtime'), { recursive: true });
1351
+
1352
+ // Write runtime files so Bun.build can resolve ./runtime/ imports
1353
+ writeFileSync(join(tmpDir, 'runtime', 'reactivity.js'), REACTIVITY_SOURCE);
1354
+ writeFileSync(join(tmpDir, 'runtime', 'rpc.js'), RPC_SOURCE);
1355
+ writeFileSync(join(tmpDir, 'runtime', 'router.js'), ROUTER_SOURCE);
1356
+
1357
+ // Write client code as entrypoint
1358
+ const entryPath = join(tmpDir, '__entry.js');
1359
+ writeFileSync(entryPath, clientCode);
1360
+
1361
+ const result = await Bun.build({
1362
+ entrypoints: [entryPath],
1363
+ bundle: true,
1364
+ format: 'esm',
1365
+ target: 'browser',
1366
+ });
1367
+
1368
+ if (!result.success) {
1369
+ const errors = result.logs.filter(l => l.level === 'error').map(l => l.message);
1370
+ // Check for missing package errors and provide actionable message
1371
+ const missingPkgs = errors
1372
+ .map(e => {
1373
+ const m = e.match(/Could not resolve ["']([^"']+)["']/);
1374
+ return m ? m[1] : null;
1375
+ })
1376
+ .filter(Boolean);
1377
+ if (missingPkgs.length > 0) {
1378
+ throw new Error(`Missing npm packages in client block. Run: bun install ${missingPkgs.join(' ')}`);
1379
+ }
1380
+ throw new Error(`Client bundling failed:\n${errors.join('\n')}`);
1381
+ }
1382
+
1383
+ // Read the bundled output
1384
+ const bundled = await result.outputs[0].text();
1385
+ return bundled;
1386
+ } finally {
1387
+ // Clean up temp files
1388
+ try {
1389
+ rmSync(tmpDir, { recursive: true, force: true });
1390
+ } catch {}
1391
+ }
1033
1392
  }
1034
1393
 
1035
1394
  // ─── LSP Server ──────────────────────────────────────────────
@@ -1054,14 +1413,8 @@ async function startRepl() {
1054
1413
 
1055
1414
  const context = {};
1056
1415
  const stdlib = getStdlibForRuntime();
1057
- // Initialize stdlib in context dynamically extract all function names
1058
- const fnNameRegex = /\bfunction\s+([a-zA-Z_]\w*)/g;
1059
- const stdlibNames = [];
1060
- let fnMatch;
1061
- while ((fnMatch = fnNameRegex.exec(stdlib)) !== null) stdlibNames.push(fnMatch[1]);
1062
- // Also include const bindings (Ok, Err, Some, None, __propagate)
1063
- const constRegex = /\bconst\s+([a-zA-Z_]\w*)/g;
1064
- while ((fnMatch = constRegex.exec(stdlib)) !== null) stdlibNames.push(fnMatch[1]);
1416
+ // Use authoritative BUILTIN_NAMES + runtime names (Ok, Err, Some, None, __propagate)
1417
+ const stdlibNames = [...BUILTIN_NAMES, 'Ok', 'Err', 'Some', 'None', '__propagate'];
1065
1418
  const initFn = new Function(stdlib + '\nObject.assign(this, {' + stdlibNames.join(',') + '});');
1066
1419
  initFn.call(context);
1067
1420
 
@@ -1136,8 +1489,12 @@ async function startRepl() {
1136
1489
  evalCode = allButLast + (allButLast ? '\n' : '') + `return (${returnExpr});`;
1137
1490
  }
1138
1491
  try {
1139
- const keys = Object.keys(context);
1140
- const destructure = keys.length > 0 ? `const {${keys.join(',')}} = __ctx;` : '';
1492
+ // Extract function/const names from compiled code to avoid shadowing conflicts
1493
+ const declaredInCode = new Set();
1494
+ for (const m of evalCode.matchAll(/\bfunction\s+([a-zA-Z_]\w*)/g)) declaredInCode.add(m[1]);
1495
+ for (const m of evalCode.matchAll(/\bconst\s+([a-zA-Z_]\w*)/g)) declaredInCode.add(m[1]);
1496
+ const ctxKeys = Object.keys(context).filter(k => !declaredInCode.has(k));
1497
+ const destructure = ctxKeys.length > 0 ? `const {${ctxKeys.join(',')}} = __ctx;` : '';
1141
1498
  const fn = new Function('__ctx', `${destructure}\n${evalCode}`);
1142
1499
  const result = fn(context);
1143
1500
  if (result !== undefined) {
@@ -1145,8 +1502,11 @@ async function startRepl() {
1145
1502
  }
1146
1503
  } catch (e) {
1147
1504
  // If return-wrapping fails, fall back to plain execution
1148
- const keys = Object.keys(context);
1149
- const destructure = keys.length > 0 ? `const {${keys.join(',')}} = __ctx;` : '';
1505
+ const declaredInCode = new Set();
1506
+ for (const m of code.matchAll(/\bfunction\s+([a-zA-Z_]\w*)/g)) declaredInCode.add(m[1]);
1507
+ for (const m of code.matchAll(/\bconst\s+([a-zA-Z_]\w*)/g)) declaredInCode.add(m[1]);
1508
+ const ctxKeys = Object.keys(context).filter(k => !declaredInCode.has(k));
1509
+ const destructure = ctxKeys.length > 0 ? `const {${ctxKeys.join(',')}} = __ctx;` : '';
1150
1510
  const fn = new Function('__ctx', `${destructure}\n${code}`);
1151
1511
  fn(context);
1152
1512
  }
@@ -1191,27 +1551,43 @@ function startWatcher(srcDir, callback) {
1191
1551
  // ─── Source Map Support ──────────────────────────────────────
1192
1552
 
1193
1553
  class SourceMapBuilder {
1194
- constructor(sourceFile) {
1554
+ constructor(sourceFile, sourceFiles = null) {
1195
1555
  this.sourceFile = sourceFile;
1556
+ // Multi-source support: array of all source files for merged output
1557
+ this.sourceFiles = sourceFiles || [sourceFile];
1558
+ this._sourceIndex = new Map();
1559
+ for (let i = 0; i < this.sourceFiles.length; i++) {
1560
+ this._sourceIndex.set(this.sourceFiles[i], i);
1561
+ }
1196
1562
  this.mappings = [];
1197
1563
  this.outputLine = 0;
1198
1564
  this.outputCol = 0;
1199
1565
  }
1200
1566
 
1201
- addMapping(sourceLine, sourceCol, outputLine, outputCol) {
1202
- this.mappings.push({ sourceLine, sourceCol, outputLine, outputCol });
1567
+ addMapping(sourceLine, sourceCol, outputLine, outputCol, sourceFile = null) {
1568
+ const srcIdx = sourceFile ? (this._sourceIndex.get(sourceFile) || 0) : 0;
1569
+ this.mappings.push({ sourceLine, sourceCol, outputLine, outputCol, sourceIdx: srcIdx });
1203
1570
  }
1204
1571
 
1205
1572
  // Generate a VLQ-encoded source map
1206
- generate(sourceContent) {
1207
- const sources = [this.sourceFile];
1573
+ generate(sourceContent, sourceContentsMap = null) {
1574
+ const sources = this.sourceFiles;
1208
1575
  const names = [];
1209
1576
 
1577
+ // Build sourcesContent array for multi-source
1578
+ let sourcesContent;
1579
+ if (sourceContentsMap && sourceContentsMap instanceof Map) {
1580
+ sourcesContent = sources.map(s => sourceContentsMap.get(s) || null);
1581
+ } else if (sourceContent) {
1582
+ sourcesContent = [sourceContent];
1583
+ }
1584
+
1210
1585
  // Sort mappings by output position
1211
1586
  this.mappings.sort((a, b) => a.outputLine - b.outputLine || a.outputCol - b.outputCol);
1212
1587
 
1213
1588
  // Encode mappings using VLQ
1214
1589
  let prevOutputCol = 0;
1590
+ let prevSourceIdx = 0;
1215
1591
  let prevSourceLine = 0;
1216
1592
  let prevSourceCol = 0;
1217
1593
  let currentOutputLine = 0;
@@ -1229,22 +1605,24 @@ class SourceMapBuilder {
1229
1605
 
1230
1606
  const segment = [];
1231
1607
  segment.push(this._vlqEncode(m.outputCol - prevOutputCol));
1232
- segment.push(this._vlqEncode(0)); // source index (always 0)
1608
+ segment.push(this._vlqEncode(m.sourceIdx - prevSourceIdx));
1233
1609
  segment.push(this._vlqEncode(m.sourceLine - prevSourceLine));
1234
1610
  segment.push(this._vlqEncode(m.sourceCol - prevSourceCol));
1235
1611
 
1236
1612
  currentLine.push(segment.join(''));
1237
1613
  prevOutputCol = m.outputCol;
1614
+ prevSourceIdx = m.sourceIdx;
1238
1615
  prevSourceLine = m.sourceLine;
1239
1616
  prevSourceCol = m.sourceCol;
1240
1617
  }
1241
1618
  lines.push(currentLine.join(','));
1242
1619
 
1620
+ const outFile = typeof this.sourceFile === 'string' ? this.sourceFile.replace('.tova', '.js') : 'merged.js';
1243
1621
  return JSON.stringify({
1244
1622
  version: 3,
1245
- file: this.sourceFile.replace('.tova', '.js'),
1623
+ file: outFile,
1246
1624
  sources,
1247
- sourcesContent: sourceContent ? [sourceContent] : undefined,
1625
+ sourcesContent: sourcesContent || undefined,
1248
1626
  names,
1249
1627
  mappings: lines.join(';'),
1250
1628
  });
@@ -1263,8 +1641,8 @@ class SourceMapBuilder {
1263
1641
  return encoded;
1264
1642
  }
1265
1643
 
1266
- toDataURL(sourceContent) {
1267
- const mapJson = this.generate(sourceContent);
1644
+ toDataURL(sourceContent, sourceContentsMap = null) {
1645
+ const mapJson = this.generate(sourceContent, sourceContentsMap);
1268
1646
  const base64 = Buffer.from(mapJson).toString('base64');
1269
1647
  return `//# sourceMappingURL=data:application/json;base64,${base64}`;
1270
1648
  }
@@ -1317,11 +1695,22 @@ async function productionBuild(srcDir, outDir) {
1317
1695
 
1318
1696
  // Write client bundle
1319
1697
  if (allClientCode.trim()) {
1320
- const reactivityCode = REACTIVITY_SOURCE.replace(/^export /gm, '');
1321
- const rpcCode = RPC_SOURCE.replace(/^export /gm, '');
1698
+ const fullClientModule = allSharedCode + '\n' + allClientCode;
1322
1699
 
1323
- const clientBundle = reactivityCode + '\n' + rpcCode + '\n' + allSharedCode + '\n' +
1324
- allClientCode.replace(/^import\s+\{[^}]+\}\s+from\s+'[^']+';?\s*$/gm, '').trim();
1700
+ let clientBundle;
1701
+ let useModule = false;
1702
+
1703
+ if (hasNpmImports(fullClientModule)) {
1704
+ // npm imports detected — bundle with Bun.build to resolve bare specifiers
1705
+ clientBundle = await bundleClientCode(fullClientModule, srcDir);
1706
+ useModule = true;
1707
+ } else {
1708
+ // No npm imports — inline runtime, strip all imports
1709
+ const reactivityCode = REACTIVITY_SOURCE.replace(/^export /gm, '');
1710
+ const rpcCode = RPC_SOURCE.replace(/^export /gm, '');
1711
+ clientBundle = reactivityCode + '\n' + rpcCode + '\n' + allSharedCode + '\n' +
1712
+ allClientCode.replace(/^\s*import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]+['"];?\s*$/gm, '').trim();
1713
+ }
1325
1714
 
1326
1715
  const hash = hashCode(clientBundle);
1327
1716
  const clientPath = join(outDir, `client.${hash}.js`);
@@ -1329,6 +1718,9 @@ async function productionBuild(srcDir, outDir) {
1329
1718
  console.log(` client.${hash}.js`);
1330
1719
 
1331
1720
  // Generate production HTML
1721
+ const scriptTag = useModule
1722
+ ? `<script type="module" src="client.${hash}.js"></script>`
1723
+ : `<script src="client.${hash}.js"></script>`;
1332
1724
  const html = `<!DOCTYPE html>
1333
1725
  <html lang="en">
1334
1726
  <head>
@@ -1338,7 +1730,7 @@ async function productionBuild(srcDir, outDir) {
1338
1730
  </head>
1339
1731
  <body>
1340
1732
  <div id="app"></div>
1341
- <script src="client.${hash}.js"></script>
1733
+ ${scriptTag}
1342
1734
  </body>
1343
1735
  </html>`;
1344
1736
  writeFileSync(join(outDir, 'index.html'), html);
@@ -1378,7 +1770,8 @@ const moduleExports = new Map();
1378
1770
  function collectExports(ast, filename) {
1379
1771
  const publicExports = new Set();
1380
1772
  const allNames = new Set();
1381
- for (const node of ast.body) {
1773
+
1774
+ function collectFromNode(node) {
1382
1775
  if (node.type === 'FunctionDeclaration') {
1383
1776
  allNames.add(node.name);
1384
1777
  if (node.isPublic) publicExports.add(node.name);
@@ -1421,6 +1814,19 @@ function collectExports(ast, filename) {
1421
1814
  }
1422
1815
  if (node.type === 'ImplDeclaration') { /* impl doesn't export a name */ }
1423
1816
  }
1817
+
1818
+ for (const node of ast.body) {
1819
+ // Also collect exports from inside shared/server/client blocks
1820
+ if (node.type === 'SharedBlock' || node.type === 'ServerBlock' || node.type === 'ClientBlock') {
1821
+ if (node.body) {
1822
+ for (const inner of node.body) {
1823
+ collectFromNode(inner);
1824
+ }
1825
+ }
1826
+ continue;
1827
+ }
1828
+ collectFromNode(node);
1829
+ }
1424
1830
  moduleExports.set(filename, { publicExports, allNames });
1425
1831
  return { publicExports, allNames };
1426
1832
  }
@@ -1509,6 +1915,257 @@ function compileWithImports(source, filename, srcDir) {
1509
1915
  }
1510
1916
  }
1511
1917
 
1918
+ // ─── Multi-file Directory Merging ────────────────────────────
1919
+
1920
+ function validateMergedAST(mergedBlocks, sourceFiles) {
1921
+ const errors = [];
1922
+
1923
+ function addDup(kind, name, loc1, loc2) {
1924
+ const f1 = loc1.source || 'unknown';
1925
+ const f2 = loc2.source || 'unknown';
1926
+ errors.push(
1927
+ `Duplicate ${kind} '${name}'\n` +
1928
+ ` → first defined in ${basename(f1)}:${loc1.line}\n` +
1929
+ ` → also defined in ${basename(f2)}:${loc2.line}`
1930
+ );
1931
+ }
1932
+
1933
+ // Check client blocks — top-level declarations only
1934
+ const clientDecls = { component: new Map(), state: new Map(), computed: new Map(), store: new Map(), fn: new Map() };
1935
+ for (const block of mergedBlocks.clientBlocks) {
1936
+ for (const stmt of block.body) {
1937
+ const loc = stmt.loc || block.loc;
1938
+ if (stmt.type === 'ComponentDeclaration') {
1939
+ if (clientDecls.component.has(stmt.name)) addDup('component', stmt.name, clientDecls.component.get(stmt.name), loc);
1940
+ else clientDecls.component.set(stmt.name, loc);
1941
+ } else if (stmt.type === 'StateDeclaration') {
1942
+ const name = stmt.name || (stmt.targets && stmt.targets[0]);
1943
+ if (name) {
1944
+ if (clientDecls.state.has(name)) addDup('state', name, clientDecls.state.get(name), loc);
1945
+ else clientDecls.state.set(name, loc);
1946
+ }
1947
+ } else if (stmt.type === 'ComputedDeclaration') {
1948
+ const name = stmt.name;
1949
+ if (name) {
1950
+ if (clientDecls.computed.has(name)) addDup('computed', name, clientDecls.computed.get(name), loc);
1951
+ else clientDecls.computed.set(name, loc);
1952
+ }
1953
+ } else if (stmt.type === 'StoreDeclaration') {
1954
+ if (clientDecls.store.has(stmt.name)) addDup('store', stmt.name, clientDecls.store.get(stmt.name), loc);
1955
+ else clientDecls.store.set(stmt.name, loc);
1956
+ } else if (stmt.type === 'FunctionDeclaration') {
1957
+ if (clientDecls.fn.has(stmt.name)) addDup('function', stmt.name, clientDecls.fn.get(stmt.name), loc);
1958
+ else clientDecls.fn.set(stmt.name, loc);
1959
+ }
1960
+ }
1961
+ }
1962
+
1963
+ // Check server blocks — group by block name, check within same-name groups
1964
+ const serverGrouped = new Map();
1965
+ for (const block of mergedBlocks.serverBlocks) {
1966
+ const key = block.name || null;
1967
+ if (!serverGrouped.has(key)) serverGrouped.set(key, []);
1968
+ serverGrouped.get(key).push(block);
1969
+ }
1970
+
1971
+ for (const [, blocks] of serverGrouped) {
1972
+ const serverDecls = { fn: new Map(), model: new Map(), route: new Map() };
1973
+ const singletons = new Map(); // db, cors, auth, session, etc.
1974
+ const SINGLETON_TYPES = {
1975
+ 'DbDeclaration': 'db', 'CorsDeclaration': 'cors', 'AuthDeclaration': 'auth',
1976
+ 'SessionDeclaration': 'session', 'CompressionDeclaration': 'compression',
1977
+ 'TlsDeclaration': 'tls', 'UploadDeclaration': 'upload', 'RateLimitDeclaration': 'rate_limit',
1978
+ };
1979
+
1980
+ for (const block of blocks) {
1981
+ const walkBody = (stmts) => {
1982
+ for (const stmt of stmts) {
1983
+ const loc = stmt.loc || block.loc;
1984
+ if (stmt.type === 'FunctionDeclaration') {
1985
+ if (serverDecls.fn.has(stmt.name)) addDup('server function', stmt.name, serverDecls.fn.get(stmt.name), loc);
1986
+ else serverDecls.fn.set(stmt.name, loc);
1987
+ } else if (stmt.type === 'ModelDeclaration') {
1988
+ if (serverDecls.model.has(stmt.name)) addDup('model', stmt.name, serverDecls.model.get(stmt.name), loc);
1989
+ else serverDecls.model.set(stmt.name, loc);
1990
+ } else if (stmt.type === 'RouteDeclaration') {
1991
+ const routeKey = `${stmt.method} ${stmt.path}`;
1992
+ if (serverDecls.route.has(routeKey)) addDup('route', routeKey, serverDecls.route.get(routeKey), loc);
1993
+ else serverDecls.route.set(routeKey, loc);
1994
+ } else if (SINGLETON_TYPES[stmt.type]) {
1995
+ const sName = SINGLETON_TYPES[stmt.type];
1996
+ if (singletons.has(sName)) addDup('server config', sName, singletons.get(sName), loc);
1997
+ else singletons.set(sName, loc);
1998
+ } else if (stmt.type === 'RouteGroupDeclaration') {
1999
+ walkBody(stmt.body);
2000
+ }
2001
+ }
2002
+ };
2003
+ walkBody(block.body);
2004
+ }
2005
+ }
2006
+
2007
+ // Check shared blocks
2008
+ const sharedDecls = { type: new Map(), fn: new Map(), interface: new Map() };
2009
+ for (const block of mergedBlocks.sharedBlocks) {
2010
+ for (const stmt of block.body) {
2011
+ const loc = stmt.loc || block.loc;
2012
+ if (stmt.type === 'TypeDeclaration') {
2013
+ if (sharedDecls.type.has(stmt.name)) addDup('type', stmt.name, sharedDecls.type.get(stmt.name), loc);
2014
+ else sharedDecls.type.set(stmt.name, loc);
2015
+ } else if (stmt.type === 'FunctionDeclaration') {
2016
+ if (sharedDecls.fn.has(stmt.name)) addDup('shared function', stmt.name, sharedDecls.fn.get(stmt.name), loc);
2017
+ else sharedDecls.fn.set(stmt.name, loc);
2018
+ } else if (stmt.type === 'InterfaceDeclaration' || stmt.type === 'TraitDeclaration') {
2019
+ if (sharedDecls.interface.has(stmt.name)) addDup('interface/trait', stmt.name, sharedDecls.interface.get(stmt.name), loc);
2020
+ else sharedDecls.interface.set(stmt.name, loc);
2021
+ }
2022
+ }
2023
+ }
2024
+
2025
+ if (errors.length > 0) {
2026
+ throw new Error('Merge validation failed:\n\n' + errors.join('\n\n'));
2027
+ }
2028
+ }
2029
+
2030
+ function mergeDirectory(dir, srcDir, options = {}) {
2031
+ // Find all .tova files in this directory only (non-recursive)
2032
+ const entries = readdirSync(dir);
2033
+ const tovaFiles = entries
2034
+ .filter(e => e.endsWith('.tova') && !e.startsWith('.'))
2035
+ .map(e => join(dir, e))
2036
+ .filter(f => statSync(f).isFile())
2037
+ .sort();
2038
+
2039
+ if (tovaFiles.length === 0) return null;
2040
+ if (tovaFiles.length === 1) {
2041
+ // Single file — use existing per-file compilation
2042
+ const file = tovaFiles[0];
2043
+ const source = readFileSync(file, 'utf-8');
2044
+ return { output: compileWithImports(source, file, srcDir), files: [file], single: true };
2045
+ }
2046
+
2047
+ // Parse all files in the directory
2048
+ const parsedFiles = [];
2049
+ for (const file of tovaFiles) {
2050
+ const source = readFileSync(file, 'utf-8');
2051
+ const lexer = new Lexer(source, file);
2052
+ const tokens = lexer.tokenize();
2053
+ const parser = new Parser(tokens, file);
2054
+ const ast = parser.parse();
2055
+
2056
+ // Collect exports for cross-file import validation
2057
+ collectExports(ast, file);
2058
+
2059
+ // Resolve cross-directory .tova imports (same logic as compileWithImports)
2060
+ for (const node of ast.body) {
2061
+ if ((node.type === 'ImportDeclaration' || node.type === 'ImportDefault' || node.type === 'ImportWildcard') && node.source.endsWith('.tova')) {
2062
+ const importPath = resolve(dirname(file), node.source);
2063
+ // Only process imports from OTHER directories (same-dir files are merged)
2064
+ if (dirname(importPath) !== dir) {
2065
+ if (compilationInProgress.has(importPath)) {
2066
+ throw new Error(`Circular import detected: ${file} → ${importPath}`);
2067
+ } else if (existsSync(importPath) && !compilationCache.has(importPath)) {
2068
+ const importSource = readFileSync(importPath, 'utf-8');
2069
+ compileWithImports(importSource, importPath, srcDir);
2070
+ }
2071
+ // Validate imported names
2072
+ if (node.type === 'ImportDeclaration' && moduleExports.has(importPath)) {
2073
+ const { publicExports, allNames } = moduleExports.get(importPath);
2074
+ for (const spec of node.specifiers) {
2075
+ if (!publicExports.has(spec.imported)) {
2076
+ if (allNames.has(spec.imported)) {
2077
+ throw new Error(`'${spec.imported}' is private in module '${node.source}'. Add 'pub' to export it.`);
2078
+ } else {
2079
+ throw new Error(`Module '${node.source}' does not export '${spec.imported}'`);
2080
+ }
2081
+ }
2082
+ }
2083
+ }
2084
+ // Rewrite to .js
2085
+ node.source = node.source.replace('.tova', '.shared.js');
2086
+ } else {
2087
+ // Same-directory import — remove it since files are merged
2088
+ node._removed = true;
2089
+ }
2090
+ }
2091
+ }
2092
+
2093
+ parsedFiles.push({ file, source, ast });
2094
+ }
2095
+
2096
+ // Merge ASTs: collect blocks from all files, tagged with source file
2097
+ const mergedBody = [];
2098
+ const sharedBlocks = [];
2099
+ const serverBlocks = [];
2100
+ const clientBlocks = [];
2101
+
2102
+ for (const { file, ast } of parsedFiles) {
2103
+ for (const node of ast.body) {
2104
+ // Skip removed same-directory imports
2105
+ if (node._removed) continue;
2106
+
2107
+ // Tag node with source file for source maps and error reporting
2108
+ if (node.loc) node.loc.source = file;
2109
+ else node.loc = { line: 1, column: 0, source: file };
2110
+
2111
+ // Tag children too
2112
+ if (node.body && Array.isArray(node.body)) {
2113
+ for (const child of node.body) {
2114
+ if (child.loc) child.loc.source = file;
2115
+ else child.loc = { line: 1, column: 0, source: file };
2116
+ }
2117
+ }
2118
+
2119
+ if (node.type === 'SharedBlock') sharedBlocks.push(node);
2120
+ else if (node.type === 'ServerBlock') serverBlocks.push(node);
2121
+ else if (node.type === 'ClientBlock') clientBlocks.push(node);
2122
+
2123
+ mergedBody.push(node);
2124
+ }
2125
+ }
2126
+
2127
+ // Validate for duplicate declarations across files
2128
+ validateMergedAST({ sharedBlocks, serverBlocks, clientBlocks }, tovaFiles);
2129
+
2130
+ // Build merged Program AST
2131
+ const mergedAST = new Program(mergedBody);
2132
+
2133
+ // Run analyzer on merged AST
2134
+ const analyzer = new Analyzer(mergedAST, dir);
2135
+ const { warnings } = analyzer.analyze();
2136
+
2137
+ if (warnings.length > 0) {
2138
+ for (const w of warnings) {
2139
+ console.warn(` Warning: ${w.message} (line ${w.line})`);
2140
+ }
2141
+ }
2142
+
2143
+ // Run codegen on merged AST
2144
+ const codegen = new CodeGenerator(mergedAST, dir);
2145
+ const output = codegen.generate();
2146
+
2147
+ // Collect source content from all files for source maps
2148
+ const sourceContents = new Map();
2149
+ for (const { file, source } of parsedFiles) {
2150
+ sourceContents.set(file, source);
2151
+ }
2152
+ output._sourceContents = sourceContents;
2153
+ output._sourceFiles = tovaFiles;
2154
+
2155
+ return { output, files: tovaFiles, single: false };
2156
+ }
2157
+
2158
+ // Group .tova files by their parent directory
2159
+ function groupFilesByDirectory(files) {
2160
+ const groups = new Map();
2161
+ for (const file of files) {
2162
+ const dir = dirname(file);
2163
+ if (!groups.has(dir)) groups.set(dir, []);
2164
+ groups.get(dir).push(file);
2165
+ }
2166
+ return groups;
2167
+ }
2168
+
1512
2169
  function findFiles(dir, ext) {
1513
2170
  const results = [];
1514
2171
  if (!existsSync(dir)) return results;