tova 0.5.1 → 0.7.0

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
@@ -3,7 +3,7 @@
3
3
  import { resolve, basename, dirname, join, relative } from 'path';
4
4
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, copyFileSync, rmSync, chmodSync, renameSync, watch as fsWatch } from 'fs';
5
5
  import { spawn } from 'child_process';
6
- // Bun.hash used instead of crypto.createHash for faster hashing
6
+ import { createHash as _cryptoHash } from 'crypto';
7
7
  import { Lexer } from '../src/lexer/lexer.js';
8
8
  import { Parser } from '../src/parser/parser.js';
9
9
  import { Analyzer } from '../src/analyzer/analyzer.js';
@@ -628,7 +628,7 @@ async function runFile(filePath, options = {}) {
628
628
  }
629
629
  }
630
630
 
631
- let code = stdlib + '\n' + depCode + (output.shared || '') + '\n' + (output.server || output.client || '');
631
+ let code = stdlib + '\n' + depCode + (output.shared || '') + '\n' + (output.server || output.browser || '');
632
632
  // Strip 'export ' keywords — not valid inside AsyncFunction (used in tova build only)
633
633
  code = code.replace(/^export /gm, '');
634
634
  // Strip import lines for local modules (already inlined above)
@@ -833,11 +833,18 @@ async function buildProject(args) {
833
833
  if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', serverPath)}${timing}`);
834
834
  }
835
835
 
836
- // Write default client
837
- if (output.client) {
838
- const clientPath = join(outDir, `${outBaseName}.client.js`);
839
- writeFileSync(clientPath, generateSourceMap(output.client, clientPath));
840
- if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', clientPath)}${timing}`);
836
+ // Write default browser
837
+ if (output.browser) {
838
+ const browserPath = join(outDir, `${outBaseName}.browser.js`);
839
+ writeFileSync(browserPath, generateSourceMap(output.browser, browserPath));
840
+ if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', browserPath)}${timing}`);
841
+ }
842
+
843
+ // Write default edge
844
+ if (output.edge) {
845
+ const edgePath = join(outDir, `${outBaseName}.edge.js`);
846
+ writeFileSync(edgePath, generateSourceMap(output.edge, edgePath));
847
+ if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', edgePath)} [edge]${timing}`);
841
848
  }
842
849
 
843
850
  // Write named server blocks (multi-block)
@@ -850,13 +857,23 @@ async function buildProject(args) {
850
857
  }
851
858
  }
852
859
 
853
- // Write named client blocks (multi-block)
854
- if (output.multiBlock && output.clients) {
855
- for (const [name, code] of Object.entries(output.clients)) {
860
+ // Write named edge blocks (multi-block)
861
+ if (output.multiBlock && output.edges) {
862
+ for (const [name, code] of Object.entries(output.edges)) {
863
+ if (name === 'default') continue;
864
+ const path = join(outDir, `${outBaseName}.edge.${name}.js`);
865
+ writeFileSync(path, code);
866
+ if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [edge:${name}]${timing}`);
867
+ }
868
+ }
869
+
870
+ // Write named browser blocks (multi-block)
871
+ if (output.multiBlock && output.browsers) {
872
+ for (const [name, code] of Object.entries(output.browsers)) {
856
873
  if (name === 'default') continue;
857
- const path = join(outDir, `${outBaseName}.client.${name}.js`);
874
+ const path = join(outDir, `${outBaseName}.browser.${name}.js`);
858
875
  writeFileSync(path, code);
859
- if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [client:${name}]${timing}`);
876
+ if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [browser:${name}]${timing}`);
860
877
  }
861
878
  }
862
879
 
@@ -865,7 +882,7 @@ async function buildProject(args) {
865
882
  const outputPaths = {};
866
883
  if (output.shared && output.shared.trim()) outputPaths.shared = join(outDir, `${outBaseName}.shared.js`);
867
884
  if (output.server) outputPaths.server = join(outDir, `${outBaseName}.server.js`);
868
- if (output.client) outputPaths.client = join(outDir, `${outBaseName}.client.js`);
885
+ if (output.browser) outputPaths.browser = join(outDir, `${outBaseName}.browser.js`);
869
886
  if (single) {
870
887
  const absFile = files[0];
871
888
  const sourceContent = readFileSync(absFile, 'utf-8');
@@ -1116,10 +1133,10 @@ async function devServer(args) {
1116
1133
  writeFileSync(join(outDir, `${outBaseName}.shared.js`), output.shared);
1117
1134
  }
1118
1135
 
1119
- if (output.client) {
1120
- const p = join(outDir, `${outBaseName}.client.js`);
1121
- writeFileSync(p, output.client);
1122
- clientHTML = await generateDevHTML(output.client, srcDir, actualReloadPort);
1136
+ if (output.browser) {
1137
+ const p = join(outDir, `${outBaseName}.browser.js`);
1138
+ writeFileSync(p, output.browser);
1139
+ clientHTML = await generateDevHTML(output.browser, srcDir, actualReloadPort);
1123
1140
  writeFileSync(join(outDir, 'index.html'), clientHTML);
1124
1141
  hasClient = true;
1125
1142
  }
@@ -1150,10 +1167,10 @@ async function devServer(args) {
1150
1167
  }
1151
1168
  }
1152
1169
 
1153
- if (output.multiBlock && output.clients) {
1154
- for (const [name, code] of Object.entries(output.clients)) {
1170
+ if (output.multiBlock && output.browsers) {
1171
+ for (const [name, code] of Object.entries(output.browsers)) {
1155
1172
  if (name === 'default') continue;
1156
- const p = join(outDir, `${outBaseName}.client.${name}.js`);
1173
+ const p = join(outDir, `${outBaseName}.browser.${name}.js`);
1157
1174
  writeFileSync(p, code);
1158
1175
  }
1159
1176
  }
@@ -1264,9 +1281,9 @@ async function devServer(args) {
1264
1281
  if (output.shared && output.shared.trim()) {
1265
1282
  writeFileSync(join(outDir, `${outBaseName}.shared.js`), output.shared);
1266
1283
  }
1267
- if (output.client) {
1268
- writeFileSync(join(outDir, `${outBaseName}.client.js`), output.client);
1269
- rebuildClientHTML = await generateDevHTML(output.client, srcDir, actualReloadPort);
1284
+ if (output.browser) {
1285
+ writeFileSync(join(outDir, `${outBaseName}.browser.js`), output.browser);
1286
+ rebuildClientHTML = await generateDevHTML(output.browser, srcDir, actualReloadPort);
1270
1287
  writeFileSync(join(outDir, 'index.html'), rebuildClientHTML);
1271
1288
  }
1272
1289
  if (output.server) {
@@ -2540,7 +2557,7 @@ async function startRepl() {
2540
2557
  'in', 'return', 'match', 'type', 'import', 'from', 'and', 'or', 'not',
2541
2558
  'try', 'catch', 'finally', 'break', 'continue', 'async', 'await',
2542
2559
  'guard', 'interface', 'derive', 'pub', 'impl', 'trait', 'defer',
2543
- 'yield', 'extern', 'is', 'with', 'as', 'export', 'server', 'client', 'shared',
2560
+ 'yield', 'extern', 'is', 'with', 'as', 'export', 'server', 'client', 'browser', 'shared',
2544
2561
  ]);
2545
2562
 
2546
2563
  const TYPE_NAMES = new Set([
@@ -3040,7 +3057,7 @@ async function binaryBuild(srcDir, outputName, outDir) {
3040
3057
  // Step 1: Compile all .tova files to JS
3041
3058
  const sharedParts = [];
3042
3059
  const serverParts = [];
3043
- const clientParts = [];
3060
+ const browserParts = [];
3044
3061
 
3045
3062
  for (const file of tovaFiles) {
3046
3063
  try {
@@ -3048,7 +3065,7 @@ async function binaryBuild(srcDir, outputName, outDir) {
3048
3065
  const output = compileTova(source, file);
3049
3066
  if (output.shared) sharedParts.push(output.shared);
3050
3067
  if (output.server) serverParts.push(output.server);
3051
- if (output.client) clientParts.push(output.client);
3068
+ if (output.browser) browserParts.push(output.browser);
3052
3069
  } catch (err) {
3053
3070
  console.error(` Error in ${relative(srcDir, file)}: ${err.message}`);
3054
3071
  process.exit(1);
@@ -3118,7 +3135,7 @@ async function productionBuild(srcDir, outDir) {
3118
3135
 
3119
3136
  console.log(`\n Production build...\n`);
3120
3137
 
3121
- const clientParts = [];
3138
+ const browserParts = [];
3122
3139
  const serverParts = [];
3123
3140
  const sharedParts = [];
3124
3141
  let cssContent = '';
@@ -3130,14 +3147,14 @@ async function productionBuild(srcDir, outDir) {
3130
3147
 
3131
3148
  if (output.shared) sharedParts.push(output.shared);
3132
3149
  if (output.server) serverParts.push(output.server);
3133
- if (output.client) clientParts.push(output.client);
3150
+ if (output.browser) browserParts.push(output.browser);
3134
3151
  } catch (err) {
3135
3152
  console.error(` Error in ${relative(srcDir, file)}: ${err.message}`);
3136
3153
  process.exit(1);
3137
3154
  }
3138
3155
  }
3139
3156
 
3140
- const allClientCode = clientParts.join('\n');
3157
+ const allClientCode = browserParts.join('\n');
3141
3158
  const allServerCode = serverParts.join('\n');
3142
3159
  const allSharedCode = sharedParts.join('\n');
3143
3160
 
@@ -3485,7 +3502,8 @@ class BuildCache {
3485
3502
  }
3486
3503
 
3487
3504
  _hashContent(content) {
3488
- return Bun.hash(content).toString(16);
3505
+ if (typeof Bun !== 'undefined' && Bun.hash) return Bun.hash(content).toString(16);
3506
+ return _cryptoHash('md5').update(content).digest('hex');
3489
3507
  }
3490
3508
 
3491
3509
  load() {
@@ -3526,7 +3544,8 @@ class BuildCache {
3526
3544
  for (const f of files.slice().sort()) {
3527
3545
  combined += f + readFileSync(f, 'utf-8');
3528
3546
  }
3529
- return Bun.hash(combined).toString(16);
3547
+ if (typeof Bun !== 'undefined' && Bun.hash) return Bun.hash(combined).toString(16);
3548
+ return _cryptoHash('md5').update(combined).digest('hex');
3530
3549
  }
3531
3550
 
3532
3551
  // Store compiled output for a multi-file group
@@ -3598,7 +3617,7 @@ function getCompiledExtension(tovaPath) {
3598
3617
  const lexer = new Lexer(src, tovaPath);
3599
3618
  const tokens = lexer.tokenize();
3600
3619
  // Check if any top-level token is a block keyword (shared/server/client/test/bench/data)
3601
- const BLOCK_KEYWORDS = new Set(['shared', 'server', 'client', 'test', 'bench', 'data']);
3620
+ const BLOCK_KEYWORDS = new Set(['shared', 'server', 'client', 'browser', 'test', 'bench', 'data']);
3602
3621
  let depth = 0;
3603
3622
  for (const tok of tokens) {
3604
3623
  if (tok.type === 'LBRACE') depth++;
@@ -3725,7 +3744,7 @@ function collectExports(ast, filename) {
3725
3744
 
3726
3745
  for (const node of ast.body) {
3727
3746
  // Also collect exports from inside shared/server/client blocks
3728
- if (node.type === 'SharedBlock' || node.type === 'ServerBlock' || node.type === 'ClientBlock') {
3747
+ if (node.type === 'SharedBlock' || node.type === 'ServerBlock' || node.type === 'BrowserBlock') {
3729
3748
  if (node.body) {
3730
3749
  for (const inner of node.body) {
3731
3750
  collectFromNode(inner);
@@ -3755,7 +3774,7 @@ function compileWithImports(source, filename, srcDir) {
3755
3774
  const ast = parser.parse();
3756
3775
 
3757
3776
  // Cache module type from AST (avoids regex heuristic on subsequent lookups)
3758
- const hasBlocks = ast.body.some(n => n.type === 'SharedBlock' || n.type === 'ServerBlock' || n.type === 'ClientBlock' || n.type === 'TestBlock' || n.type === 'BenchBlock' || n.type === 'DataBlock');
3777
+ const hasBlocks = ast.body.some(n => n.type === 'SharedBlock' || n.type === 'ServerBlock' || n.type === 'BrowserBlock' || n.type === 'TestBlock' || n.type === 'BenchBlock' || n.type === 'DataBlock');
3759
3778
  moduleTypeCache.set(filename, hasBlocks ? '.shared.js' : '.js');
3760
3779
 
3761
3780
  // Collect this module's exports for validation
@@ -3853,32 +3872,32 @@ function validateMergedAST(mergedBlocks, sourceFiles) {
3853
3872
  );
3854
3873
  }
3855
3874
 
3856
- // Check client blocks — top-level declarations only
3857
- const clientDecls = { component: new Map(), state: new Map(), computed: new Map(), store: new Map(), fn: new Map() };
3858
- for (const block of mergedBlocks.clientBlocks) {
3875
+ // Check browser blocks — top-level declarations only
3876
+ const browserDecls = { component: new Map(), state: new Map(), computed: new Map(), store: new Map(), fn: new Map() };
3877
+ for (const block of mergedBlocks.browserBlocks) {
3859
3878
  for (const stmt of block.body) {
3860
3879
  const loc = stmt.loc || block.loc;
3861
3880
  if (stmt.type === 'ComponentDeclaration') {
3862
- if (clientDecls.component.has(stmt.name)) addDup('component', stmt.name, clientDecls.component.get(stmt.name), loc);
3863
- else clientDecls.component.set(stmt.name, loc);
3881
+ if (browserDecls.component.has(stmt.name)) addDup('component', stmt.name, browserDecls.component.get(stmt.name), loc);
3882
+ else browserDecls.component.set(stmt.name, loc);
3864
3883
  } else if (stmt.type === 'StateDeclaration') {
3865
3884
  const name = stmt.name || (stmt.targets && stmt.targets[0]);
3866
3885
  if (name) {
3867
- if (clientDecls.state.has(name)) addDup('state', name, clientDecls.state.get(name), loc);
3868
- else clientDecls.state.set(name, loc);
3886
+ if (browserDecls.state.has(name)) addDup('state', name, browserDecls.state.get(name), loc);
3887
+ else browserDecls.state.set(name, loc);
3869
3888
  }
3870
3889
  } else if (stmt.type === 'ComputedDeclaration') {
3871
3890
  const name = stmt.name;
3872
3891
  if (name) {
3873
- if (clientDecls.computed.has(name)) addDup('computed', name, clientDecls.computed.get(name), loc);
3874
- else clientDecls.computed.set(name, loc);
3892
+ if (browserDecls.computed.has(name)) addDup('computed', name, browserDecls.computed.get(name), loc);
3893
+ else browserDecls.computed.set(name, loc);
3875
3894
  }
3876
3895
  } else if (stmt.type === 'StoreDeclaration') {
3877
- if (clientDecls.store.has(stmt.name)) addDup('store', stmt.name, clientDecls.store.get(stmt.name), loc);
3878
- else clientDecls.store.set(stmt.name, loc);
3896
+ if (browserDecls.store.has(stmt.name)) addDup('store', stmt.name, browserDecls.store.get(stmt.name), loc);
3897
+ else browserDecls.store.set(stmt.name, loc);
3879
3898
  } else if (stmt.type === 'FunctionDeclaration') {
3880
- if (clientDecls.fn.has(stmt.name)) addDup('function', stmt.name, clientDecls.fn.get(stmt.name), loc);
3881
- else clientDecls.fn.set(stmt.name, loc);
3899
+ if (browserDecls.fn.has(stmt.name)) addDup('function', stmt.name, browserDecls.fn.get(stmt.name), loc);
3900
+ else browserDecls.fn.set(stmt.name, loc);
3882
3901
  }
3883
3902
  }
3884
3903
  }
@@ -4022,7 +4041,7 @@ function mergeDirectory(dir, srcDir, options = {}) {
4022
4041
  const mergedBody = [];
4023
4042
  const sharedBlocks = [];
4024
4043
  const serverBlocks = [];
4025
- const clientBlocks = [];
4044
+ const browserBlocks = [];
4026
4045
 
4027
4046
  for (const { file, ast } of parsedFiles) {
4028
4047
  for (const node of ast.body) {
@@ -4043,14 +4062,14 @@ function mergeDirectory(dir, srcDir, options = {}) {
4043
4062
 
4044
4063
  if (node.type === 'SharedBlock') sharedBlocks.push(node);
4045
4064
  else if (node.type === 'ServerBlock') serverBlocks.push(node);
4046
- else if (node.type === 'ClientBlock') clientBlocks.push(node);
4065
+ else if (node.type === 'BrowserBlock') browserBlocks.push(node);
4047
4066
 
4048
4067
  mergedBody.push(node);
4049
4068
  }
4050
4069
  }
4051
4070
 
4052
4071
  // Validate for duplicate declarations across files
4053
- validateMergedAST({ sharedBlocks, serverBlocks, clientBlocks }, tovaFiles);
4072
+ validateMergedAST({ sharedBlocks, serverBlocks, browserBlocks }, tovaFiles);
4054
4073
 
4055
4074
  // Build merged Program AST
4056
4075
  const mergedAST = new Program(mergedBody);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tova",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
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",
@@ -36,7 +36,12 @@
36
36
  "url": "https://github.com/tova-lang/tova-lang/issues"
37
37
  },
38
38
  "author": "Enoch Kujem Abassey",
39
- "keywords": ["language", "transpiler", "fullstack", "javascript"],
39
+ "keywords": [
40
+ "language",
41
+ "transpiler",
42
+ "fullstack",
43
+ "javascript"
44
+ ],
40
45
  "license": "MIT",
41
46
  "devDependencies": {
42
47
  "@codemirror/autocomplete": "^6.20.0",
@@ -97,7 +97,7 @@ function levenshtein(a, b) {
97
97
 
98
98
  const _TOVA_RUNTIME = new Set([
99
99
  'Ok', 'Err', 'Some', 'None', 'Result', 'Option',
100
- 'db', 'server', 'client', 'shared',
100
+ 'db', 'server', 'browser', 'client', 'shared',
101
101
  ]);
102
102
 
103
103
  // Pre-built static candidate set for Levenshtein suggestions (N1 optimization)
@@ -776,9 +776,9 @@ export class Analyzer {
776
776
  return this[methodName](node);
777
777
  }
778
778
 
779
- _visitClientNode(node) {
780
- // Ensure client analyzer is installed (may be called from visitExpression for JSX)
781
- const plugin = BlockRegistry.get('client');
779
+ _visitBrowserNode(node) {
780
+ // Ensure browser analyzer is installed (may be called from visitExpression for JSX)
781
+ const plugin = BlockRegistry.get('browser');
782
782
  return plugin.analyzer.visit(this, node);
783
783
  }
784
784
 
@@ -917,7 +917,7 @@ export class Analyzer {
917
917
  return;
918
918
  case 'JSXElement':
919
919
  case 'JSXFragment':
920
- return this._visitClientNode(node);
920
+ return this._visitBrowserNode(node);
921
921
  // Column expressions (for table operations) — no semantic analysis needed
922
922
  case 'ColumnExpression':
923
923
  return;
@@ -1049,6 +1049,214 @@ export class Analyzer {
1049
1049
  }
1050
1050
  }
1051
1051
 
1052
+ visitEdgeBlock(node) {
1053
+ const validTargets = new Set(['cloudflare', 'deno', 'vercel', 'lambda', 'bun']);
1054
+ const validConfigKeys = new Set(['target']);
1055
+ const bindingNames = new Set();
1056
+
1057
+ // Binding support matrix per target
1058
+ const BINDING_SUPPORT = {
1059
+ cloudflare: { kv: true, sql: true, storage: true, queue: true },
1060
+ deno: { kv: true, sql: false, storage: false, queue: false },
1061
+ vercel: { kv: false, sql: false, storage: false, queue: false },
1062
+ lambda: { kv: false, sql: false, storage: false, queue: false },
1063
+ bun: { kv: false, sql: true, storage: false, queue: false },
1064
+ };
1065
+
1066
+ // Targets that support schedule/consume/middleware
1067
+ const SCHEDULE_TARGETS = new Set(['cloudflare', 'deno']);
1068
+ const CONSUME_TARGETS = new Set(['cloudflare']);
1069
+
1070
+ // Determine target from config fields
1071
+ let target = 'cloudflare';
1072
+ for (const stmt of node.body) {
1073
+ if (stmt.type === 'EdgeConfigField' && stmt.key === 'target' && stmt.value.type === 'StringLiteral') {
1074
+ target = stmt.value.value;
1075
+ }
1076
+ }
1077
+
1078
+ this.pushScope('edge');
1079
+
1080
+ let kvCount = 0;
1081
+ const queueNames = new Set();
1082
+ const consumers = [];
1083
+
1084
+ for (const stmt of node.body) {
1085
+ // Validate config fields
1086
+ if (stmt.type === 'EdgeConfigField') {
1087
+ if (!validConfigKeys.has(stmt.key)) {
1088
+ this.warnings.push({
1089
+ message: `Unknown edge config key '${stmt.key}' — valid keys are: ${[...validConfigKeys].join(', ')}`,
1090
+ loc: stmt.loc,
1091
+ code: 'W_UNKNOWN_EDGE_CONFIG',
1092
+ });
1093
+ }
1094
+ if (stmt.key === 'target' && stmt.value.type === 'StringLiteral') {
1095
+ if (!validTargets.has(stmt.value.value)) {
1096
+ this.warnings.push({
1097
+ message: `Unknown edge target '${stmt.value.value}' — valid targets are: ${[...validTargets].join(', ')}`,
1098
+ loc: stmt.loc,
1099
+ code: 'W_UNKNOWN_EDGE_TARGET',
1100
+ });
1101
+ }
1102
+ }
1103
+ continue;
1104
+ }
1105
+
1106
+ // Check for duplicate binding names
1107
+ if (stmt.type === 'EdgeKVDeclaration' || stmt.type === 'EdgeSQLDeclaration' ||
1108
+ stmt.type === 'EdgeStorageDeclaration' || stmt.type === 'EdgeQueueDeclaration') {
1109
+ if (bindingNames.has(stmt.name)) {
1110
+ this.warnings.push({
1111
+ message: `Duplicate edge binding '${stmt.name}'`,
1112
+ loc: stmt.loc,
1113
+ code: 'W_DUPLICATE_EDGE_BINDING',
1114
+ });
1115
+ }
1116
+ bindingNames.add(stmt.name);
1117
+ }
1118
+
1119
+ // Track queue names for consume validation
1120
+ if (stmt.type === 'EdgeQueueDeclaration') {
1121
+ queueNames.add(stmt.name);
1122
+ }
1123
+
1124
+ // Check for duplicate env/secret names
1125
+ if (stmt.type === 'EdgeEnvDeclaration' || stmt.type === 'EdgeSecretDeclaration') {
1126
+ if (bindingNames.has(stmt.name)) {
1127
+ this.warnings.push({
1128
+ message: `Duplicate edge binding '${stmt.name}'`,
1129
+ loc: stmt.loc,
1130
+ code: 'W_DUPLICATE_EDGE_BINDING',
1131
+ });
1132
+ }
1133
+ bindingNames.add(stmt.name);
1134
+ }
1135
+
1136
+ // Unsupported binding warnings (per target)
1137
+ const support = BINDING_SUPPORT[target] || BINDING_SUPPORT.cloudflare;
1138
+ if (stmt.type === 'EdgeKVDeclaration' && !support.kv) {
1139
+ this.warnings.push({
1140
+ message: `KV binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
1141
+ loc: stmt.loc,
1142
+ code: 'W_UNSUPPORTED_KV',
1143
+ });
1144
+ }
1145
+ if (stmt.type === 'EdgeSQLDeclaration' && !support.sql) {
1146
+ this.warnings.push({
1147
+ message: `SQL binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
1148
+ loc: stmt.loc,
1149
+ code: 'W_UNSUPPORTED_SQL',
1150
+ });
1151
+ }
1152
+ if (stmt.type === 'EdgeStorageDeclaration' && !support.storage) {
1153
+ this.warnings.push({
1154
+ message: `Storage binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
1155
+ loc: stmt.loc,
1156
+ code: 'W_UNSUPPORTED_STORAGE',
1157
+ });
1158
+ }
1159
+ if (stmt.type === 'EdgeQueueDeclaration' && !support.queue) {
1160
+ this.warnings.push({
1161
+ message: `Queue binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
1162
+ loc: stmt.loc,
1163
+ code: 'W_UNSUPPORTED_QUEUE',
1164
+ });
1165
+ }
1166
+
1167
+ // Deno multi-KV warning
1168
+ if (stmt.type === 'EdgeKVDeclaration') {
1169
+ kvCount++;
1170
+ if (kvCount > 1 && target === 'deno') {
1171
+ this.warnings.push({
1172
+ message: `Deno Deploy supports only one KV store — '${stmt.name}' will share the same store as the first KV binding`,
1173
+ loc: stmt.loc,
1174
+ code: 'W_DENO_MULTI_KV',
1175
+ });
1176
+ }
1177
+ }
1178
+
1179
+ // Validate schedule cron expressions + target support
1180
+ if (stmt.type === 'EdgeScheduleDeclaration') {
1181
+ const parts = stmt.cron.split(/\s+/);
1182
+ if (parts.length < 5 || parts.length > 6) {
1183
+ this.warnings.push({
1184
+ message: `Invalid cron expression '${stmt.cron}' — expected 5 or 6 space-separated fields`,
1185
+ loc: stmt.loc,
1186
+ code: 'W_INVALID_CRON',
1187
+ });
1188
+ }
1189
+ if (!SCHEDULE_TARGETS.has(target)) {
1190
+ this.warnings.push({
1191
+ message: `Scheduled tasks are not supported on target '${target}' — schedule '${stmt.name}' will be ignored. Supported targets: ${[...SCHEDULE_TARGETS].join(', ')}`,
1192
+ loc: stmt.loc,
1193
+ code: 'W_UNSUPPORTED_SCHEDULE',
1194
+ });
1195
+ }
1196
+ }
1197
+
1198
+ // Collect consume declarations for post-loop validation
1199
+ if (stmt.type === 'EdgeConsumeDeclaration') {
1200
+ consumers.push(stmt);
1201
+ if (!CONSUME_TARGETS.has(target)) {
1202
+ this.warnings.push({
1203
+ message: `Queue consumers are not supported on target '${target}' — consume '${stmt.queue}' will be ignored. Supported targets: ${[...CONSUME_TARGETS].join(', ')}`,
1204
+ loc: stmt.loc,
1205
+ code: 'W_UNSUPPORTED_CONSUME',
1206
+ });
1207
+ }
1208
+ }
1209
+
1210
+ // Visit child nodes — edge-specific types are noop in the registry,
1211
+ // so explicitly visit bodies that contain statements
1212
+ if (stmt.type === 'EdgeScheduleDeclaration' && stmt.body) {
1213
+ for (const s of stmt.body.body || []) this.visitNode(s);
1214
+ } else if (stmt.type === 'FunctionDeclaration' || stmt.type === 'RouteDeclaration') {
1215
+ this.visitNode(stmt);
1216
+ }
1217
+ }
1218
+
1219
+ // Post-loop: validate consume references a declared queue
1220
+ for (const consumer of consumers) {
1221
+ if (!queueNames.has(consumer.queue)) {
1222
+ this.warnings.push({
1223
+ message: `consume '${consumer.queue}' references undeclared queue binding — add 'queue ${consumer.queue}' to the edge block`,
1224
+ loc: consumer.loc,
1225
+ code: 'W_CONSUME_UNKNOWN_QUEUE',
1226
+ });
1227
+ }
1228
+ }
1229
+
1230
+ // Warn if edge block has no route or schedule handlers
1231
+ const hasRoutes = node.body.some(s => s.type === 'RouteDeclaration');
1232
+ const hasSchedules = node.body.some(s => s.type === 'EdgeScheduleDeclaration');
1233
+ const hasConsumers = consumers.length > 0;
1234
+ if (!hasRoutes && !hasSchedules && !hasConsumers) {
1235
+ this.warnings.push({
1236
+ message: 'edge block has no routes, schedules, or consumers — it will produce no handlers',
1237
+ loc: node.loc,
1238
+ code: 'W_EDGE_NO_HANDLERS',
1239
+ });
1240
+ }
1241
+
1242
+ this.popScope();
1243
+ }
1244
+
1245
+ _validateEdgeCrossBlock() {
1246
+ const edgeBlocks = this.ast.body.filter(n => n.type === 'EdgeBlock');
1247
+ if (edgeBlocks.length === 0) return;
1248
+
1249
+ // Warn if edge + cli coexist (cli takes over with earlyReturn)
1250
+ const hasCli = this.ast.body.some(n => n.type === 'CliBlock');
1251
+ if (hasCli) {
1252
+ this.warnings.push({
1253
+ message: 'edge {} and cli {} blocks in the same file — cli produces a standalone executable, edge block will be ignored',
1254
+ loc: edgeBlocks[0].loc,
1255
+ code: 'W_EDGE_WITH_CLI',
1256
+ });
1257
+ }
1258
+ }
1259
+
1052
1260
  _validateSecurityCrossBlock() {
1053
1261
  // Collect ALL security declarations across ALL security blocks in the AST
1054
1262
  const allRoles = new Set();
@@ -1287,7 +1495,7 @@ export class Analyzer {
1287
1495
  }
1288
1496
  }
1289
1497
 
1290
- // visitClientBlock and other client visitors are in client-analyzer.js (lazy-loaded)
1498
+ // visitBrowserBlock and other browser visitors are in browser-analyzer.js (lazy-loaded)
1291
1499
 
1292
1500
  visitSharedBlock(node) {
1293
1501
  const prevScope = this.currentScope;
@@ -1987,7 +2195,7 @@ export class Analyzer {
1987
2195
  this.visitExpression(node.value);
1988
2196
  }
1989
2197
 
1990
- // Client-specific visitors (visitState, visitComputed, etc.) are in client-analyzer.js (lazy-loaded)
2198
+ // Browser-specific visitors (visitState, visitComputed, etc.) are in browser-analyzer.js (lazy-loaded)
1991
2199
 
1992
2200
  visitTestBlock(node) {
1993
2201
  const prevScope = this.currentScope;
@@ -2352,7 +2560,7 @@ export class Analyzer {
2352
2560
  candidates.push([node.name, typeVariants]);
2353
2561
  }
2354
2562
  }
2355
- if (node.type === 'SharedBlock' || node.type === 'ServerBlock' || node.type === 'ClientBlock') {
2563
+ if (node.type === 'SharedBlock' || node.type === 'ServerBlock' || node.type === 'BrowserBlock') {
2356
2564
  this._collectTypeCandidates(node.body, coveredVariants, candidates);
2357
2565
  }
2358
2566
  }
@@ -2447,7 +2655,7 @@ export class Analyzer {
2447
2655
  }
2448
2656
  }
2449
2657
 
2450
- // visitJSXElement, visitJSXFragment, visitJSXFor, visitJSXIf are in client-analyzer.js (lazy-loaded)
2658
+ // visitJSXElement, visitJSXFragment, visitJSXFor, visitJSXIf are in browser-analyzer.js (lazy-loaded)
2451
2659
 
2452
2660
  // ─── New feature visitors ─────────────────────────────────
2453
2661
 
@@ -1,15 +1,18 @@
1
- // Client-specific analyzer methods for the Tova language
2
- // Extracted from analyzer.js for lazy loading — only loaded when client { } blocks are encountered.
1
+ // Browser-specific analyzer methods for the Tova language
2
+ // Extracted from analyzer.js for lazy loading — only loaded when browser { } blocks are encountered.
3
3
 
4
4
  import { Symbol } from './scope.js';
5
+ import { installFormAnalyzer } from './form-analyzer.js';
5
6
 
6
- export function installClientAnalyzer(AnalyzerClass) {
7
- if (AnalyzerClass.prototype._clientAnalyzerInstalled) return;
8
- AnalyzerClass.prototype._clientAnalyzerInstalled = true;
7
+ export function installBrowserAnalyzer(AnalyzerClass) {
8
+ if (AnalyzerClass.prototype._browserAnalyzerInstalled) return;
9
+ AnalyzerClass.prototype._browserAnalyzerInstalled = true;
9
10
 
10
- AnalyzerClass.prototype.visitClientBlock = function(node) {
11
+ installFormAnalyzer(AnalyzerClass);
12
+
13
+ AnalyzerClass.prototype.visitBrowserBlock = function(node) {
11
14
  const prevScope = this.currentScope;
12
- this.currentScope = this.currentScope.child('client');
15
+ this.currentScope = this.currentScope.child('browser');
13
16
  try {
14
17
  for (const stmt of node.body) {
15
18
  this.visitNode(stmt);
@@ -21,8 +24,8 @@ export function installClientAnalyzer(AnalyzerClass) {
21
24
 
22
25
  AnalyzerClass.prototype.visitStateDeclaration = function(node) {
23
26
  const ctx = this.currentScope.getContext();
24
- if (ctx !== 'client') {
25
- this.error(`'state' can only be used inside a client block`, node.loc, "move this inside a client { } block", { code: 'E302' });
27
+ if (ctx !== 'browser') {
28
+ this.error(`'state' can only be used inside a browser block`, node.loc, "move this inside a browser { } block", { code: 'E302' });
26
29
  }
27
30
  try {
28
31
  this.currentScope.define(node.name,
@@ -35,8 +38,8 @@ export function installClientAnalyzer(AnalyzerClass) {
35
38
 
36
39
  AnalyzerClass.prototype.visitComputedDeclaration = function(node) {
37
40
  const ctx = this.currentScope.getContext();
38
- if (ctx !== 'client') {
39
- this.error(`'computed' can only be used inside a client block`, node.loc, "move this inside a client { } block", { code: 'E302' });
41
+ if (ctx !== 'browser') {
42
+ this.error(`'computed' can only be used inside a browser block`, node.loc, "move this inside a browser { } block", { code: 'E302' });
40
43
  }
41
44
  try {
42
45
  this.currentScope.define(node.name,
@@ -49,16 +52,16 @@ export function installClientAnalyzer(AnalyzerClass) {
49
52
 
50
53
  AnalyzerClass.prototype.visitEffectDeclaration = function(node) {
51
54
  const ctx = this.currentScope.getContext();
52
- if (ctx !== 'client') {
53
- this.error(`'effect' can only be used inside a client block`, node.loc, "move this inside a client { } block", { code: 'E302' });
55
+ if (ctx !== 'browser') {
56
+ this.error(`'effect' can only be used inside a browser block`, node.loc, "move this inside a browser { } block", { code: 'E302' });
54
57
  }
55
58
  this.visitNode(node.body);
56
59
  };
57
60
 
58
61
  AnalyzerClass.prototype.visitComponentDeclaration = function(node) {
59
62
  const ctx = this.currentScope.getContext();
60
- if (ctx !== 'client') {
61
- this.error(`'component' can only be used inside a client block`, node.loc, "move this inside a client { } block", { code: 'E302' });
63
+ if (ctx !== 'browser') {
64
+ this.error(`'component' can only be used inside a browser block`, node.loc, "move this inside a browser { } block", { code: 'E302' });
62
65
  }
63
66
  this._checkNamingConvention(node.name, 'component', node.loc);
64
67
  try {
@@ -89,8 +92,8 @@ export function installClientAnalyzer(AnalyzerClass) {
89
92
 
90
93
  AnalyzerClass.prototype.visitStoreDeclaration = function(node) {
91
94
  const ctx = this.currentScope.getContext();
92
- if (ctx !== 'client') {
93
- this.error(`'store' can only be used inside a client block`, node.loc, "move this inside a client { } block", { code: 'E302' });
95
+ if (ctx !== 'browser') {
96
+ this.error(`'store' can only be used inside a browser block`, node.loc, "move this inside a browser { } block", { code: 'E302' });
94
97
  }
95
98
  this._checkNamingConvention(node.name, 'store', node.loc);
96
99
  try {