tova 0.1.5 → 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 +259 -31
- package/package.json +7 -2
- package/src/codegen/client-codegen.js +16 -5
- package/src/codegen/codegen.js +31 -1
- package/src/config/edit-toml.js +100 -0
- package/src/config/package-json.js +52 -0
- package/src/config/resolve.js +100 -0
- package/src/config/toml.js +209 -0
package/bin/tova.js
CHANGED
|
@@ -15,6 +15,10 @@ import { getFullStdlib, buildSelectiveStdlib, BUILTIN_NAMES, PROPAGATE } from '.
|
|
|
15
15
|
import { Formatter } from '../src/formatter/formatter.js';
|
|
16
16
|
import { REACTIVITY_SOURCE, RPC_SOURCE, ROUTER_SOURCE } from '../src/runtime/embedded.js';
|
|
17
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';
|
|
18
22
|
|
|
19
23
|
const require = createRequire(import.meta.url);
|
|
20
24
|
const { version: VERSION } = require('../package.json');
|
|
@@ -33,10 +37,13 @@ Usage:
|
|
|
33
37
|
Commands:
|
|
34
38
|
run <file> Compile and execute a .tova file
|
|
35
39
|
build [dir] Compile .tova files to JavaScript (default: current dir)
|
|
36
|
-
dev Start development server with
|
|
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
|
|
37
45
|
repl Start interactive Tova REPL
|
|
38
46
|
lsp Start Language Server Protocol server
|
|
39
|
-
new <name> Create a new Tova project
|
|
40
47
|
fmt <file> Format a .tova file (--check to verify only)
|
|
41
48
|
test [dir] Run test blocks in .tova files (--filter, --watch)
|
|
42
49
|
migrate:create <name> Create a new migration file
|
|
@@ -88,6 +95,15 @@ async function main() {
|
|
|
88
95
|
case 'new':
|
|
89
96
|
newProject(args[1]);
|
|
90
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;
|
|
91
107
|
case 'fmt':
|
|
92
108
|
formatFile(args.slice(1));
|
|
93
109
|
break;
|
|
@@ -305,9 +321,23 @@ function findTovaFiles(dir) {
|
|
|
305
321
|
|
|
306
322
|
async function runFile(filePath, options = {}) {
|
|
307
323
|
if (!filePath) {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
+
}
|
|
311
341
|
}
|
|
312
342
|
|
|
313
343
|
const resolved = resolve(filePath);
|
|
@@ -339,11 +369,13 @@ async function runFile(filePath, options = {}) {
|
|
|
339
369
|
// ─── Build ──────────────────────────────────────────────────
|
|
340
370
|
|
|
341
371
|
async function buildProject(args) {
|
|
372
|
+
const config = resolveConfig(process.cwd());
|
|
342
373
|
const isProduction = args.includes('--production');
|
|
343
374
|
const buildStrict = args.includes('--strict');
|
|
344
|
-
const
|
|
375
|
+
const explicitSrc = args.filter(a => !a.startsWith('--'))[0];
|
|
376
|
+
const srcDir = resolve(explicitSrc || config.project.entry || '.');
|
|
345
377
|
const outIdx = args.indexOf('--output');
|
|
346
|
-
const outDir = resolve(outIdx >= 0 ? args[outIdx + 1] : '.tova-out');
|
|
378
|
+
const outDir = resolve(outIdx >= 0 ? args[outIdx + 1] : (config.build.output || '.tova-out'));
|
|
347
379
|
|
|
348
380
|
// Production build uses a separate optimized pipeline
|
|
349
381
|
if (isProduction) {
|
|
@@ -455,8 +487,11 @@ async function buildProject(args) {
|
|
|
455
487
|
// ─── Dev Server ─────────────────────────────────────────────
|
|
456
488
|
|
|
457
489
|
async function devServer(args) {
|
|
458
|
-
const
|
|
459
|
-
const
|
|
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');
|
|
460
495
|
const buildStrict = args.includes('--strict');
|
|
461
496
|
|
|
462
497
|
const tovaFiles = findFiles(srcDir, '.tova');
|
|
@@ -465,6 +500,8 @@ async function devServer(args) {
|
|
|
465
500
|
process.exit(1);
|
|
466
501
|
}
|
|
467
502
|
|
|
503
|
+
const reloadPort = basePort + 100;
|
|
504
|
+
|
|
468
505
|
console.log(`\n Tova dev server starting...\n`);
|
|
469
506
|
|
|
470
507
|
// Compile all files
|
|
@@ -509,7 +546,7 @@ async function devServer(args) {
|
|
|
509
546
|
if (output.client) {
|
|
510
547
|
const p = join(outDir, `${outBaseName}.client.js`);
|
|
511
548
|
writeFileSync(p, output.client);
|
|
512
|
-
clientHTML = await generateDevHTML(output.client, srcDir);
|
|
549
|
+
clientHTML = await generateDevHTML(output.client, srcDir, reloadPort);
|
|
513
550
|
writeFileSync(join(outDir, 'index.html'), clientHTML);
|
|
514
551
|
hasClient = true;
|
|
515
552
|
}
|
|
@@ -589,6 +626,49 @@ async function devServer(args) {
|
|
|
589
626
|
console.log(` ✓ Client: ${relative('.', outDir)}/index.html`);
|
|
590
627
|
}
|
|
591
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
|
+
|
|
592
672
|
// Start file watcher for auto-rebuild
|
|
593
673
|
const watcher = startWatcher(srcDir, async () => {
|
|
594
674
|
console.log(' Rebuilding...');
|
|
@@ -620,7 +700,7 @@ async function devServer(args) {
|
|
|
620
700
|
}
|
|
621
701
|
if (output.client) {
|
|
622
702
|
writeFileSync(join(outDir, `${outBaseName}.client.js`), output.client);
|
|
623
|
-
rebuildClientHTML = await generateDevHTML(output.client, srcDir);
|
|
703
|
+
rebuildClientHTML = await generateDevHTML(output.client, srcDir, reloadPort);
|
|
624
704
|
writeFileSync(join(outDir, 'index.html'), rebuildClientHTML);
|
|
625
705
|
}
|
|
626
706
|
if (output.server) {
|
|
@@ -670,6 +750,7 @@ async function devServer(args) {
|
|
|
670
750
|
rebuildPortOffset++;
|
|
671
751
|
}
|
|
672
752
|
console.log(' ✓ Rebuild complete');
|
|
753
|
+
notifyReload();
|
|
673
754
|
});
|
|
674
755
|
|
|
675
756
|
console.log(`\n Watching for changes. Press Ctrl+C to stop\n`);
|
|
@@ -678,6 +759,7 @@ async function devServer(args) {
|
|
|
678
759
|
process.on('SIGINT', () => {
|
|
679
760
|
console.log('\n Shutting down...');
|
|
680
761
|
watcher.close();
|
|
762
|
+
reloadServer.stop();
|
|
681
763
|
for (const p of processes) {
|
|
682
764
|
p.child.kill('SIGTERM');
|
|
683
765
|
}
|
|
@@ -688,7 +770,16 @@ async function devServer(args) {
|
|
|
688
770
|
await new Promise(() => {});
|
|
689
771
|
}
|
|
690
772
|
|
|
691
|
-
async function generateDevHTML(clientCode, srcDir) {
|
|
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
|
+
|
|
692
783
|
// Check if client code uses npm packages — if so, bundle with Bun.build
|
|
693
784
|
if (srcDir && hasNpmImports(clientCode)) {
|
|
694
785
|
const bundled = await bundleClientCode(clientCode, srcDir);
|
|
@@ -710,7 +801,7 @@ async function generateDevHTML(clientCode, srcDir) {
|
|
|
710
801
|
<div id="app"></div>
|
|
711
802
|
<script type="module">
|
|
712
803
|
${bundled}
|
|
713
|
-
</script
|
|
804
|
+
</script>${liveReloadScript}
|
|
714
805
|
</body>
|
|
715
806
|
</html>`;
|
|
716
807
|
}
|
|
@@ -772,7 +863,7 @@ ${inlineRpc}
|
|
|
772
863
|
|
|
773
864
|
// ── App ──
|
|
774
865
|
${inlineClient}
|
|
775
|
-
</script
|
|
866
|
+
</script>${liveReloadScript}
|
|
776
867
|
</body>
|
|
777
868
|
</html>`;
|
|
778
869
|
}
|
|
@@ -797,19 +888,34 @@ function newProject(name) {
|
|
|
797
888
|
mkdirSync(projectDir, { recursive: true });
|
|
798
889
|
mkdirSync(join(projectDir, 'src'));
|
|
799
890
|
|
|
800
|
-
//
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
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',
|
|
808
898
|
},
|
|
809
|
-
|
|
810
|
-
|
|
899
|
+
build: {
|
|
900
|
+
output: '.tova-out',
|
|
811
901
|
},
|
|
812
|
-
|
|
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
|
+
`);
|
|
813
919
|
|
|
814
920
|
// Main app file
|
|
815
921
|
writeFileSync(join(projectDir, 'src', 'app.tova'), `// ${name} — Built with Tova
|
|
@@ -853,24 +959,146 @@ Built with [Tova](https://github.com/tova-lang/tova-lang) — a modern full-stac
|
|
|
853
959
|
## Development
|
|
854
960
|
|
|
855
961
|
\`\`\`bash
|
|
856
|
-
|
|
857
|
-
|
|
962
|
+
tova install
|
|
963
|
+
tova dev
|
|
858
964
|
\`\`\`
|
|
859
965
|
|
|
860
966
|
## Build
|
|
861
967
|
|
|
862
968
|
\`\`\`bash
|
|
863
|
-
|
|
969
|
+
tova build
|
|
970
|
+
\`\`\`
|
|
971
|
+
|
|
972
|
+
## Add npm packages
|
|
973
|
+
|
|
974
|
+
\`\`\`bash
|
|
975
|
+
tova add htmx
|
|
976
|
+
tova add prettier --dev
|
|
864
977
|
\`\`\`
|
|
865
978
|
`);
|
|
866
979
|
|
|
867
|
-
console.log(` ✓ Created ${name}/
|
|
980
|
+
console.log(` ✓ Created ${name}/tova.toml`);
|
|
981
|
+
console.log(` ✓ Created ${name}/.gitignore`);
|
|
868
982
|
console.log(` ✓ Created ${name}/src/app.tova`);
|
|
869
983
|
console.log(` ✓ Created ${name}/README.md`);
|
|
870
984
|
console.log(`\n Get started:\n`);
|
|
871
985
|
console.log(` cd ${name}`);
|
|
872
|
-
console.log(`
|
|
873
|
-
console.log(`
|
|
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
|
+
}
|
|
874
1102
|
}
|
|
875
1103
|
|
|
876
1104
|
// ─── Migrations ─────────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tova",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Tova — a modern programming language that transpiles to JavaScript, unifying frontend and backend",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -34,6 +34,11 @@
|
|
|
34
34
|
"url": "https://github.com/tova-lang/tova-lang/issues"
|
|
35
35
|
},
|
|
36
36
|
"author": "Enoch Kujem Abassey",
|
|
37
|
-
"keywords": [
|
|
37
|
+
"keywords": [
|
|
38
|
+
"language",
|
|
39
|
+
"transpiler",
|
|
40
|
+
"fullstack",
|
|
41
|
+
"javascript"
|
|
42
|
+
],
|
|
38
43
|
"license": "MIT"
|
|
39
44
|
}
|
|
@@ -172,7 +172,8 @@ export class ClientCodegen extends BaseCodegen {
|
|
|
172
172
|
return `${asyncPrefix}(${params}) => ${this.genExpression(node.body)}`;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
generate(clientBlocks, sharedCode) {
|
|
175
|
+
generate(clientBlocks, sharedCode, sharedBuiltins = null) {
|
|
176
|
+
this._sharedBuiltins = sharedBuiltins || new Set();
|
|
176
177
|
const lines = [];
|
|
177
178
|
|
|
178
179
|
// Runtime imports
|
|
@@ -209,9 +210,10 @@ export class ClientCodegen extends BaseCodegen {
|
|
|
209
210
|
lines.push('');
|
|
210
211
|
}
|
|
211
212
|
|
|
212
|
-
// Stdlib
|
|
213
|
+
// Stdlib placeholder — filled after all client code is generated so tree-shaking sees all usages
|
|
214
|
+
const stdlibPlaceholderIdx = lines.length;
|
|
213
215
|
lines.push('// ── Stdlib ──');
|
|
214
|
-
lines.push(
|
|
216
|
+
lines.push('__STDLIB_PLACEHOLDER__');
|
|
215
217
|
lines.push('');
|
|
216
218
|
|
|
217
219
|
// Server RPC proxy
|
|
@@ -350,6 +352,9 @@ export class ClientCodegen extends BaseCodegen {
|
|
|
350
352
|
lines.push('});');
|
|
351
353
|
}
|
|
352
354
|
|
|
355
|
+
// Replace stdlib placeholder now that all client code has been generated
|
|
356
|
+
lines[stdlibPlaceholderIdx + 1] = this.getStdlibCore();
|
|
357
|
+
|
|
353
358
|
return lines.join('\n');
|
|
354
359
|
}
|
|
355
360
|
|
|
@@ -901,8 +906,14 @@ export class ClientCodegen extends BaseCodegen {
|
|
|
901
906
|
|
|
902
907
|
getStdlibCore() {
|
|
903
908
|
const parts = [];
|
|
904
|
-
// Only include used
|
|
905
|
-
const
|
|
909
|
+
// Only include builtins used in client blocks that aren't already in shared code
|
|
910
|
+
const clientOnly = new Set();
|
|
911
|
+
for (const name of this._usedBuiltins) {
|
|
912
|
+
if (!this._sharedBuiltins || !this._sharedBuiltins.has(name)) {
|
|
913
|
+
clientOnly.add(name);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
const selectiveStdlib = buildSelectiveStdlib(clientOnly);
|
|
906
917
|
if (selectiveStdlib) parts.push(selectiveStdlib);
|
|
907
918
|
// Include Result/Option if Ok/Err/Some/None are used
|
|
908
919
|
if (this._needsResultOption) parts.push(RESULT_OPTION);
|
package/src/codegen/codegen.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { SharedCodegen } from './shared-codegen.js';
|
|
6
6
|
import { ServerCodegen } from './server-codegen.js';
|
|
7
7
|
import { ClientCodegen } from './client-codegen.js';
|
|
8
|
+
import { BUILTIN_NAMES } from '../stdlib/inline.js';
|
|
8
9
|
|
|
9
10
|
export class CodeGenerator {
|
|
10
11
|
constructor(ast, filename = '<stdin>') {
|
|
@@ -48,6 +49,10 @@ export class CodeGenerator {
|
|
|
48
49
|
// All shared blocks (regardless of name) are merged into one shared output
|
|
49
50
|
const sharedCode = sharedBlocks.map(b => sharedGen.generate(b)).join('\n');
|
|
50
51
|
const topLevelCode = topLevel.map(s => sharedGen.generateStatement(s)).join('\n');
|
|
52
|
+
|
|
53
|
+
// Pre-scan server/client blocks for builtin usage so shared stdlib includes them
|
|
54
|
+
this._scanBlocksForBuiltins([...serverBlocks, ...clientBlocks], sharedGen._usedBuiltins);
|
|
55
|
+
|
|
51
56
|
const helpers = sharedGen.generateHelpers();
|
|
52
57
|
|
|
53
58
|
// Generate data block code (sources, pipelines, validators, refresh)
|
|
@@ -105,7 +110,7 @@ export class CodeGenerator {
|
|
|
105
110
|
for (const [name, blocks] of clientGroups) {
|
|
106
111
|
const gen = new ClientCodegen();
|
|
107
112
|
const key = name || 'default';
|
|
108
|
-
clients[key] = gen.generate(blocks, combinedShared);
|
|
113
|
+
clients[key] = gen.generate(blocks, combinedShared, sharedGen._usedBuiltins);
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
// Generate tests if test blocks exist
|
|
@@ -152,6 +157,31 @@ export class CodeGenerator {
|
|
|
152
157
|
return result;
|
|
153
158
|
}
|
|
154
159
|
|
|
160
|
+
// Walk AST nodes to find builtin function calls/identifiers
|
|
161
|
+
_scanBlocksForBuiltins(blocks, targetSet) {
|
|
162
|
+
const walk = (node) => {
|
|
163
|
+
if (!node || typeof node !== 'object') return;
|
|
164
|
+
if (node.type === 'Identifier' && BUILTIN_NAMES.has(node.name)) {
|
|
165
|
+
targetSet.add(node.name);
|
|
166
|
+
}
|
|
167
|
+
if (node.type === 'CallExpression' && node.callee && node.callee.type === 'Identifier' && BUILTIN_NAMES.has(node.callee.name)) {
|
|
168
|
+
targetSet.add(node.callee.name);
|
|
169
|
+
}
|
|
170
|
+
for (const key of Object.keys(node)) {
|
|
171
|
+
if (key === 'loc' || key === 'type') continue;
|
|
172
|
+
const val = node[key];
|
|
173
|
+
if (Array.isArray(val)) {
|
|
174
|
+
for (const item of val) {
|
|
175
|
+
if (item && typeof item === 'object') walk(item);
|
|
176
|
+
}
|
|
177
|
+
} else if (val && typeof val === 'object' && val.type) {
|
|
178
|
+
walk(val);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
for (const block of blocks) walk(block);
|
|
183
|
+
}
|
|
184
|
+
|
|
155
185
|
_genDataBlock(node, gen) {
|
|
156
186
|
const lines = [];
|
|
157
187
|
lines.push('// ── Data Block ──');
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Line-level TOML editing for tova add / tova remove.
|
|
2
|
+
// Operates on raw text to preserve formatting, comments, and whitespace.
|
|
3
|
+
|
|
4
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
5
|
+
|
|
6
|
+
export function addToSection(filePath, section, key, value) {
|
|
7
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
8
|
+
const lines = content.split('\n');
|
|
9
|
+
const entry = `${key} = "${value}"`;
|
|
10
|
+
|
|
11
|
+
// Find the section header
|
|
12
|
+
const sectionIdx = findSectionIndex(lines, section);
|
|
13
|
+
|
|
14
|
+
if (sectionIdx === -1) {
|
|
15
|
+
// Section doesn't exist — append it at end of file
|
|
16
|
+
const newLines = [...lines];
|
|
17
|
+
// Ensure blank line before new section
|
|
18
|
+
if (newLines.length > 0 && newLines[newLines.length - 1].trim() !== '') {
|
|
19
|
+
newLines.push('');
|
|
20
|
+
}
|
|
21
|
+
newLines.push(`[${section}]`);
|
|
22
|
+
newLines.push(entry);
|
|
23
|
+
writeFileSync(filePath, newLines.join('\n'));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Find the end of this section (next section header or EOF)
|
|
28
|
+
const endIdx = findSectionEnd(lines, sectionIdx);
|
|
29
|
+
|
|
30
|
+
// Check if key already exists in this section — update it
|
|
31
|
+
for (let i = sectionIdx + 1; i < endIdx; i++) {
|
|
32
|
+
const line = lines[i].trim();
|
|
33
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
34
|
+
const eqIdx = line.indexOf('=');
|
|
35
|
+
if (eqIdx !== -1) {
|
|
36
|
+
const existingKey = line.slice(0, eqIdx).trim();
|
|
37
|
+
if (existingKey === key) {
|
|
38
|
+
lines[i] = entry;
|
|
39
|
+
writeFileSync(filePath, lines.join('\n'));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Key doesn't exist — insert after last non-blank line in section
|
|
46
|
+
let insertIdx = sectionIdx + 1;
|
|
47
|
+
for (let i = endIdx - 1; i > sectionIdx; i--) {
|
|
48
|
+
if (lines[i].trim() !== '') {
|
|
49
|
+
insertIdx = i + 1;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
lines.splice(insertIdx, 0, entry);
|
|
54
|
+
writeFileSync(filePath, lines.join('\n'));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function removeFromSection(filePath, section, key) {
|
|
58
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
59
|
+
const lines = content.split('\n');
|
|
60
|
+
|
|
61
|
+
const sectionIdx = findSectionIndex(lines, section);
|
|
62
|
+
if (sectionIdx === -1) return false;
|
|
63
|
+
|
|
64
|
+
const endIdx = findSectionEnd(lines, sectionIdx);
|
|
65
|
+
|
|
66
|
+
for (let i = sectionIdx + 1; i < endIdx; i++) {
|
|
67
|
+
const line = lines[i].trim();
|
|
68
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
69
|
+
const eqIdx = line.indexOf('=');
|
|
70
|
+
if (eqIdx !== -1) {
|
|
71
|
+
const existingKey = line.slice(0, eqIdx).trim();
|
|
72
|
+
if (existingKey === key) {
|
|
73
|
+
lines.splice(i, 1);
|
|
74
|
+
writeFileSync(filePath, lines.join('\n'));
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function findSectionIndex(lines, section) {
|
|
84
|
+
const pattern = `[${section}]`;
|
|
85
|
+
for (let i = 0; i < lines.length; i++) {
|
|
86
|
+
const line = lines[i].trim();
|
|
87
|
+
if (line === pattern) return i;
|
|
88
|
+
}
|
|
89
|
+
return -1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function findSectionEnd(lines, sectionIdx) {
|
|
93
|
+
for (let i = sectionIdx + 1; i < lines.length; i++) {
|
|
94
|
+
const line = lines[i].trim();
|
|
95
|
+
if (line.startsWith('[') && !line.startsWith('[[')) {
|
|
96
|
+
return i;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return lines.length;
|
|
100
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Generate a shadow package.json from tova.toml config.
|
|
2
|
+
|
|
3
|
+
import { writeFileSync, existsSync, readFileSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
|
|
6
|
+
const MARKER = '// Auto-generated from tova.toml. Do not edit.';
|
|
7
|
+
|
|
8
|
+
export function generatePackageJson(config, cwd) {
|
|
9
|
+
const npmProd = config.npm?.prod || {};
|
|
10
|
+
const npmDev = config.npm?.dev || {};
|
|
11
|
+
|
|
12
|
+
const hasNpmDeps = Object.keys(npmProd).length > 0 || Object.keys(npmDev).length > 0;
|
|
13
|
+
if (!hasNpmDeps) return null;
|
|
14
|
+
|
|
15
|
+
const pkg = {
|
|
16
|
+
'//': MARKER,
|
|
17
|
+
name: config.project.name,
|
|
18
|
+
version: config.project.version,
|
|
19
|
+
private: true,
|
|
20
|
+
type: 'module',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
if (Object.keys(npmProd).length > 0) {
|
|
24
|
+
pkg.dependencies = npmProd;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (Object.keys(npmDev).length > 0) {
|
|
28
|
+
pkg.devDependencies = npmDev;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return pkg;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function writePackageJson(config, cwd) {
|
|
35
|
+
const pkg = generatePackageJson(config, cwd);
|
|
36
|
+
if (!pkg) return false;
|
|
37
|
+
|
|
38
|
+
const pkgPath = join(cwd, 'package.json');
|
|
39
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isGeneratedPackageJson(cwd) {
|
|
44
|
+
const pkgPath = join(cwd, 'package.json');
|
|
45
|
+
if (!existsSync(pkgPath)) return false;
|
|
46
|
+
try {
|
|
47
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
48
|
+
return pkg['//'] === MARKER;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Config resolution: reads tova.toml → falls back to package.json → defaults.
|
|
2
|
+
|
|
3
|
+
import { readFileSync, existsSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { parseTOML } from './toml.js';
|
|
6
|
+
|
|
7
|
+
const DEFAULTS = {
|
|
8
|
+
project: {
|
|
9
|
+
name: 'tova-app',
|
|
10
|
+
version: '0.1.0',
|
|
11
|
+
description: '',
|
|
12
|
+
entry: 'src',
|
|
13
|
+
},
|
|
14
|
+
build: {
|
|
15
|
+
output: '.tova-out',
|
|
16
|
+
},
|
|
17
|
+
dev: {
|
|
18
|
+
port: 3000,
|
|
19
|
+
},
|
|
20
|
+
dependencies: {},
|
|
21
|
+
npm: {},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function resolveConfig(cwd) {
|
|
25
|
+
const tomlPath = join(cwd, 'tova.toml');
|
|
26
|
+
const pkgPath = join(cwd, 'package.json');
|
|
27
|
+
|
|
28
|
+
// Try tova.toml first
|
|
29
|
+
if (existsSync(tomlPath)) {
|
|
30
|
+
const raw = readFileSync(tomlPath, 'utf-8');
|
|
31
|
+
const parsed = parseTOML(raw);
|
|
32
|
+
return normalizeConfig(parsed, 'tova.toml');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Fall back to package.json
|
|
36
|
+
if (existsSync(pkgPath)) {
|
|
37
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
38
|
+
return configFromPackageJson(pkg);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Return defaults
|
|
42
|
+
return { ...DEFAULTS, _source: 'defaults' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeConfig(parsed, source) {
|
|
46
|
+
const config = {
|
|
47
|
+
project: {
|
|
48
|
+
name: parsed.project?.name || DEFAULTS.project.name,
|
|
49
|
+
version: parsed.project?.version || DEFAULTS.project.version,
|
|
50
|
+
description: parsed.project?.description || DEFAULTS.project.description,
|
|
51
|
+
entry: parsed.project?.entry || DEFAULTS.project.entry,
|
|
52
|
+
},
|
|
53
|
+
build: {
|
|
54
|
+
output: parsed.build?.output || DEFAULTS.build.output,
|
|
55
|
+
},
|
|
56
|
+
dev: {
|
|
57
|
+
port: parsed.dev?.port ?? DEFAULTS.dev.port,
|
|
58
|
+
},
|
|
59
|
+
dependencies: parsed.dependencies || {},
|
|
60
|
+
npm: {},
|
|
61
|
+
_source: source,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Collect npm deps: top-level from [npm], dev deps from [npm.dev]
|
|
65
|
+
if (parsed.npm) {
|
|
66
|
+
for (const [key, value] of Object.entries(parsed.npm)) {
|
|
67
|
+
if (key === 'dev' && typeof value === 'object' && !Array.isArray(value)) {
|
|
68
|
+
config.npm.dev = value;
|
|
69
|
+
} else if (typeof value === 'string') {
|
|
70
|
+
if (!config.npm.prod) config.npm.prod = {};
|
|
71
|
+
config.npm.prod[key] = value;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return config;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function configFromPackageJson(pkg) {
|
|
80
|
+
return {
|
|
81
|
+
project: {
|
|
82
|
+
name: pkg.name || DEFAULTS.project.name,
|
|
83
|
+
version: pkg.version || DEFAULTS.project.version,
|
|
84
|
+
description: pkg.description || DEFAULTS.project.description,
|
|
85
|
+
entry: DEFAULTS.project.entry,
|
|
86
|
+
},
|
|
87
|
+
build: {
|
|
88
|
+
output: DEFAULTS.build.output,
|
|
89
|
+
},
|
|
90
|
+
dev: {
|
|
91
|
+
port: DEFAULTS.dev.port,
|
|
92
|
+
},
|
|
93
|
+
dependencies: {},
|
|
94
|
+
npm: {
|
|
95
|
+
prod: pkg.dependencies || {},
|
|
96
|
+
dev: pkg.devDependencies || {},
|
|
97
|
+
},
|
|
98
|
+
_source: 'package.json',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Minimal TOML parser for tova.toml project manifests.
|
|
2
|
+
// Handles: sections ([name], [a.b]), strings, numbers, booleans, simple arrays.
|
|
3
|
+
|
|
4
|
+
export function parseTOML(input) {
|
|
5
|
+
const result = {};
|
|
6
|
+
let current = result;
|
|
7
|
+
const lines = input.split('\n');
|
|
8
|
+
|
|
9
|
+
for (let i = 0; i < lines.length; i++) {
|
|
10
|
+
const raw = lines[i];
|
|
11
|
+
const line = raw.trim();
|
|
12
|
+
|
|
13
|
+
// Skip empty lines and comments
|
|
14
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
15
|
+
|
|
16
|
+
// Section header: [section] or [section.subsection]
|
|
17
|
+
if (line.startsWith('[') && !line.startsWith('[[')) {
|
|
18
|
+
const close = line.indexOf(']');
|
|
19
|
+
if (close === -1) {
|
|
20
|
+
throw new Error(`TOML parse error on line ${i + 1}: unclosed section header`);
|
|
21
|
+
}
|
|
22
|
+
const sectionPath = line.slice(1, close).trim();
|
|
23
|
+
if (!sectionPath) {
|
|
24
|
+
throw new Error(`TOML parse error on line ${i + 1}: empty section name`);
|
|
25
|
+
}
|
|
26
|
+
current = result;
|
|
27
|
+
for (const part of sectionPath.split('.')) {
|
|
28
|
+
const key = part.trim();
|
|
29
|
+
if (!current[key]) current[key] = {};
|
|
30
|
+
current = current[key];
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Key = value
|
|
36
|
+
const eqIdx = line.indexOf('=');
|
|
37
|
+
if (eqIdx === -1) continue; // skip lines without =
|
|
38
|
+
|
|
39
|
+
const key = line.slice(0, eqIdx).trim();
|
|
40
|
+
const rawValue = line.slice(eqIdx + 1).trim();
|
|
41
|
+
|
|
42
|
+
current[key] = parseValue(rawValue, i + 1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseValue(raw, lineNum) {
|
|
49
|
+
if (raw === '') {
|
|
50
|
+
throw new Error(`TOML parse error on line ${lineNum}: missing value`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Strip inline comment (not inside quotes)
|
|
54
|
+
const stripped = stripInlineComment(raw);
|
|
55
|
+
|
|
56
|
+
// Boolean
|
|
57
|
+
if (stripped === 'true') return true;
|
|
58
|
+
if (stripped === 'false') return false;
|
|
59
|
+
|
|
60
|
+
// Quoted string (double or single)
|
|
61
|
+
if ((stripped.startsWith('"') && stripped.endsWith('"')) ||
|
|
62
|
+
(stripped.startsWith("'") && stripped.endsWith("'"))) {
|
|
63
|
+
return parseString(stripped);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Array
|
|
67
|
+
if (stripped.startsWith('[')) {
|
|
68
|
+
return parseArray(stripped, lineNum);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Number (integer or float)
|
|
72
|
+
if (/^-?\d+(\.\d+)?$/.test(stripped)) {
|
|
73
|
+
return stripped.includes('.') ? parseFloat(stripped) : parseInt(stripped, 10);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Bare value (treat as string for compat with version ranges like ^2.0.0)
|
|
77
|
+
return stripped;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function stripInlineComment(raw) {
|
|
81
|
+
// Find # that's not inside a quoted string
|
|
82
|
+
let inStr = null;
|
|
83
|
+
for (let i = 0; i < raw.length; i++) {
|
|
84
|
+
const ch = raw[i];
|
|
85
|
+
if (inStr) {
|
|
86
|
+
if (ch === '\\') { i++; continue; }
|
|
87
|
+
if (ch === inStr) inStr = null;
|
|
88
|
+
} else {
|
|
89
|
+
if (ch === '"' || ch === "'") { inStr = ch; continue; }
|
|
90
|
+
if (ch === '#') return raw.slice(0, i).trim();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return raw;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseString(raw) {
|
|
97
|
+
const quote = raw[0];
|
|
98
|
+
const inner = raw.slice(1, -1);
|
|
99
|
+
if (quote === '"') {
|
|
100
|
+
// Handle escape sequences
|
|
101
|
+
return inner
|
|
102
|
+
.replace(/\\n/g, '\n')
|
|
103
|
+
.replace(/\\t/g, '\t')
|
|
104
|
+
.replace(/\\r/g, '\r')
|
|
105
|
+
.replace(/\\\\/g, '\\')
|
|
106
|
+
.replace(/\\"/g, '"');
|
|
107
|
+
}
|
|
108
|
+
// Single-quoted: literal string, no escapes
|
|
109
|
+
return inner;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseArray(raw, lineNum) {
|
|
113
|
+
// Simple single-line array: [val1, val2, ...]
|
|
114
|
+
if (!raw.endsWith(']')) {
|
|
115
|
+
throw new Error(`TOML parse error on line ${lineNum}: unclosed array`);
|
|
116
|
+
}
|
|
117
|
+
const inner = raw.slice(1, -1).trim();
|
|
118
|
+
if (inner === '') return [];
|
|
119
|
+
|
|
120
|
+
const items = [];
|
|
121
|
+
let current = '';
|
|
122
|
+
let depth = 0;
|
|
123
|
+
let inStr = null;
|
|
124
|
+
|
|
125
|
+
for (let i = 0; i < inner.length; i++) {
|
|
126
|
+
const ch = inner[i];
|
|
127
|
+
if (inStr) {
|
|
128
|
+
current += ch;
|
|
129
|
+
if (ch === '\\') { current += inner[++i] || ''; continue; }
|
|
130
|
+
if (ch === inStr) inStr = null;
|
|
131
|
+
} else {
|
|
132
|
+
if (ch === '"' || ch === "'") { inStr = ch; current += ch; continue; }
|
|
133
|
+
if (ch === '[') { depth++; current += ch; continue; }
|
|
134
|
+
if (ch === ']') { depth--; current += ch; continue; }
|
|
135
|
+
if (ch === ',' && depth === 0) {
|
|
136
|
+
const val = current.trim();
|
|
137
|
+
if (val !== '') items.push(parseValue(val, lineNum));
|
|
138
|
+
current = '';
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
current += ch;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const last = current.trim();
|
|
145
|
+
if (last !== '') items.push(parseValue(last, lineNum));
|
|
146
|
+
|
|
147
|
+
return items;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function stringifyTOML(obj, _prefix = '') {
|
|
151
|
+
const lines = [];
|
|
152
|
+
const sections = [];
|
|
153
|
+
|
|
154
|
+
// Write top-level key-value pairs first
|
|
155
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
156
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
157
|
+
sections.push([key, value]);
|
|
158
|
+
} else {
|
|
159
|
+
lines.push(`${key} = ${formatValue(value)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Write sections
|
|
164
|
+
for (const [key, value] of sections) {
|
|
165
|
+
const sectionKey = _prefix ? `${_prefix}.${key}` : key;
|
|
166
|
+
const { topLevel, nested } = splitObject(value);
|
|
167
|
+
|
|
168
|
+
if (lines.length > 0 || sections.indexOf([key, value]) > 0) {
|
|
169
|
+
lines.push('');
|
|
170
|
+
}
|
|
171
|
+
lines.push(`[${sectionKey}]`);
|
|
172
|
+
|
|
173
|
+
for (const [k, v] of Object.entries(topLevel)) {
|
|
174
|
+
lines.push(`${k} = ${formatValue(v)}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const [k, v] of Object.entries(nested)) {
|
|
178
|
+
const nestedKey = `${sectionKey}.${k}`;
|
|
179
|
+
lines.push('');
|
|
180
|
+
lines.push(`[${nestedKey}]`);
|
|
181
|
+
for (const [nk, nv] of Object.entries(v)) {
|
|
182
|
+
lines.push(`${nk} = ${formatValue(nv)}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return lines.join('\n') + '\n';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function splitObject(obj) {
|
|
191
|
+
const topLevel = {};
|
|
192
|
+
const nested = {};
|
|
193
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
194
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
195
|
+
nested[key] = value;
|
|
196
|
+
} else {
|
|
197
|
+
topLevel[key] = value;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return { topLevel, nested };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function formatValue(value) {
|
|
204
|
+
if (typeof value === 'string') return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
205
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
206
|
+
if (typeof value === 'number') return String(value);
|
|
207
|
+
if (Array.isArray(value)) return `[${value.map(formatValue).join(', ')}]`;
|
|
208
|
+
return String(value);
|
|
209
|
+
}
|