tova 0.7.0 → 0.9.4

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.
Files changed (59) hide show
  1. package/bin/tova.js +1312 -139
  2. package/package.json +8 -1
  3. package/src/analyzer/analyzer.js +539 -11
  4. package/src/analyzer/browser-analyzer.js +56 -8
  5. package/src/analyzer/deploy-analyzer.js +44 -0
  6. package/src/analyzer/scope.js +7 -0
  7. package/src/analyzer/server-analyzer.js +33 -1
  8. package/src/codegen/base-codegen.js +1296 -23
  9. package/src/codegen/browser-codegen.js +725 -20
  10. package/src/codegen/codegen.js +87 -5
  11. package/src/codegen/deploy-codegen.js +49 -0
  12. package/src/codegen/server-codegen.js +54 -6
  13. package/src/codegen/shared-codegen.js +5 -0
  14. package/src/codegen/theme-codegen.js +69 -0
  15. package/src/codegen/wasm-codegen.js +6 -0
  16. package/src/config/edit-toml.js +6 -2
  17. package/src/config/git-resolver.js +128 -0
  18. package/src/config/lock-file.js +57 -0
  19. package/src/config/module-cache.js +58 -0
  20. package/src/config/module-entry.js +37 -0
  21. package/src/config/module-path.js +63 -0
  22. package/src/config/pkg-errors.js +62 -0
  23. package/src/config/resolve.js +26 -0
  24. package/src/config/resolver.js +139 -0
  25. package/src/config/search.js +28 -0
  26. package/src/config/semver.js +72 -0
  27. package/src/config/toml.js +61 -6
  28. package/src/deploy/deploy.js +217 -0
  29. package/src/deploy/infer.js +218 -0
  30. package/src/deploy/provision.js +315 -0
  31. package/src/diagnostics/security-scorecard.js +111 -0
  32. package/src/lexer/lexer.js +18 -3
  33. package/src/lsp/server.js +482 -0
  34. package/src/parser/animate-ast.js +45 -0
  35. package/src/parser/ast.js +39 -0
  36. package/src/parser/browser-ast.js +19 -1
  37. package/src/parser/browser-parser.js +221 -4
  38. package/src/parser/concurrency-ast.js +15 -0
  39. package/src/parser/concurrency-parser.js +236 -0
  40. package/src/parser/deploy-ast.js +37 -0
  41. package/src/parser/deploy-parser.js +132 -0
  42. package/src/parser/parser.js +42 -5
  43. package/src/parser/select-ast.js +39 -0
  44. package/src/parser/theme-ast.js +29 -0
  45. package/src/parser/theme-parser.js +70 -0
  46. package/src/registry/plugins/concurrency-plugin.js +32 -0
  47. package/src/registry/plugins/deploy-plugin.js +33 -0
  48. package/src/registry/plugins/theme-plugin.js +20 -0
  49. package/src/registry/register-all.js +6 -0
  50. package/src/runtime/charts.js +547 -0
  51. package/src/runtime/embedded.js +6 -2
  52. package/src/runtime/reactivity.js +60 -0
  53. package/src/runtime/router.js +703 -295
  54. package/src/runtime/table.js +606 -33
  55. package/src/stdlib/inline.js +365 -10
  56. package/src/stdlib/runtime-bridge.js +152 -0
  57. package/src/stdlib/string.js +84 -2
  58. package/src/stdlib/validation.js +1 -1
  59. package/src/version.js +1 -1
package/bin/tova.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { resolve, basename, dirname, join, relative } from 'path';
3
+ import { resolve, basename, dirname, join, relative, sep, extname } 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
6
  import { createHash as _cryptoHash } from 'crypto';
7
+ import { createRequire as _createRequire } from 'module';
7
8
  import { Lexer } from '../src/lexer/lexer.js';
8
9
  import { Parser } from '../src/parser/parser.js';
9
10
  import { Analyzer } from '../src/analyzer/analyzer.js';
@@ -14,9 +15,10 @@ import { richError, formatDiagnostics, DiagnosticFormatter, formatSummary } from
14
15
  import { getExplanation, lookupCode } from '../src/diagnostics/error-codes.js';
15
16
  import { getFullStdlib, buildSelectiveStdlib, BUILTIN_NAMES, PROPAGATE, NATIVE_INIT } from '../src/stdlib/inline.js';
16
17
  import { Formatter } from '../src/formatter/formatter.js';
17
- import { REACTIVITY_SOURCE, RPC_SOURCE, ROUTER_SOURCE } from '../src/runtime/embedded.js';
18
+ import { REACTIVITY_SOURCE, RPC_SOURCE, ROUTER_SOURCE, DEVTOOLS_SOURCE, SSR_SOURCE, TESTING_SOURCE } from '../src/runtime/embedded.js';
18
19
  import '../src/runtime/string-proto.js';
19
20
  import '../src/runtime/array-proto.js';
21
+ import { generateSecurityScorecard } from '../src/diagnostics/security-scorecard.js';
20
22
  import { resolveConfig } from '../src/config/resolve.js';
21
23
  import { writePackageJson } from '../src/config/package-json.js';
22
24
  import { addToSection, removeFromSection } from '../src/config/edit-toml.js';
@@ -52,10 +54,10 @@ Commands:
52
54
  check [dir] Type-check .tova files without generating code
53
55
  clean Delete .tova-out build artifacts
54
56
  dev Start development server with live reload
55
- new <name> Create a new Tova project (--template fullstack|api|script|library|blank)
56
- install Install npm dependencies from tova.toml
57
- add <pkg> Add an npm dependency (--dev for dev dependency)
58
- remove <pkg> Remove an npm dependency
57
+ new <name> Create a new Tova project (--template fullstack|spa|site|api|script|library|blank)
58
+ install Install dependencies from tova.toml
59
+ add <pkg> Add a dependency (npm:pkg for npm, github.com/user/repo for Tova)
60
+ remove <pkg> Remove a dependency
59
61
  repl Start interactive Tova REPL
60
62
  lsp Start Language Server Protocol server
61
63
  fmt <file> Format a .tova file (--check to verify only)
@@ -70,6 +72,8 @@ Commands:
70
72
  info Show Tova version, Bun version, project config, and installed dependencies
71
73
  doctor Check your development environment
72
74
  completions <sh> Generate shell completions (bash, zsh, fish)
75
+ deploy <env> Deploy to a server (--plan, --rollback, --logs, --status)
76
+ env <env> <cmd> Manage secrets (list, set KEY=value)
73
77
  explain <code> Show detailed explanation for an error/warning code (e.g., tova explain E202)
74
78
 
75
79
  Options:
@@ -81,7 +85,9 @@ Options:
81
85
  --verbose Show detailed output during compilation
82
86
  --quiet Suppress non-error output
83
87
  --debug Show verbose error output
88
+ --static Pre-render routes to static HTML files (used with --production)
84
89
  --strict Enable strict type checking
90
+ --strict-security Promote security warnings to errors
85
91
  `;
86
92
 
87
93
  async function main() {
@@ -100,14 +106,15 @@ async function main() {
100
106
  const command = args[0];
101
107
 
102
108
  const isStrict = args.includes('--strict');
109
+ const isStrictSecurity = args.includes('--strict-security');
103
110
  switch (command) {
104
111
  case 'run': {
105
- const runArgs = args.filter(a => a !== '--strict');
112
+ const runArgs = args.filter(a => a !== '--strict' && a !== '--strict-security');
106
113
  const filePath = runArgs[1];
107
114
  const restArgs = runArgs.slice(2);
108
115
  const ddIdx = restArgs.indexOf('--');
109
116
  const scriptArgs = ddIdx !== -1 ? restArgs.slice(ddIdx + 1) : restArgs;
110
- await runFile(filePath, { strict: isStrict, scriptArgs });
117
+ await runFile(filePath, { strict: isStrict, strictSecurity: isStrictSecurity, scriptArgs });
111
118
  break;
112
119
  }
113
120
  case 'build':
@@ -143,6 +150,74 @@ async function main() {
143
150
  case 'remove':
144
151
  await removeDep(args[1]);
145
152
  break;
153
+ case 'update': {
154
+ const updatePkg = args[1] || null;
155
+ const updateConfig = resolveConfig(process.cwd());
156
+ const updateDeps = updatePkg
157
+ ? { [updatePkg]: updateConfig.dependencies?.[updatePkg] || '*' }
158
+ : updateConfig.dependencies || {};
159
+
160
+ if (Object.keys(updateDeps).length === 0) {
161
+ console.log(' No Tova dependencies to update.');
162
+ break;
163
+ }
164
+
165
+ console.log(' Checking for updates...');
166
+ // Delete lock file to force fresh resolution
167
+ const lockPath = join(process.cwd(), 'tova.lock');
168
+ if (existsSync(lockPath)) {
169
+ rmSync(lockPath);
170
+ }
171
+ await installDeps();
172
+ break;
173
+ }
174
+ case 'cache': {
175
+ const subCmd = args[1] || 'list';
176
+ const { getCacheDir } = await import('../src/config/module-cache.js');
177
+ const cacheDir = getCacheDir();
178
+
179
+ if (subCmd === 'path') {
180
+ console.log(cacheDir);
181
+ } else if (subCmd === 'list') {
182
+ console.log(` Cache: ${cacheDir}\n`);
183
+ if (existsSync(cacheDir)) {
184
+ try {
185
+ const hosts = readdirSync(cacheDir).filter(h => !h.startsWith('.'));
186
+ let found = false;
187
+ for (const host of hosts) {
188
+ const hostPath = join(cacheDir, host);
189
+ if (!statSync(hostPath).isDirectory()) continue;
190
+ const owners = readdirSync(hostPath);
191
+ for (const owner of owners) {
192
+ const ownerPath = join(hostPath, owner);
193
+ if (!statSync(ownerPath).isDirectory()) continue;
194
+ const repos = readdirSync(ownerPath);
195
+ for (const repo of repos) {
196
+ const repoPath = join(ownerPath, repo);
197
+ if (!statSync(repoPath).isDirectory()) continue;
198
+ const versions = readdirSync(repoPath).filter(v => v.startsWith('v'));
199
+ console.log(` ${host}/${owner}/${repo}: ${versions.join(', ') || '(empty)'}`);
200
+ found = true;
201
+ }
202
+ }
203
+ }
204
+ if (!found) console.log(' (empty)');
205
+ } catch { console.log(' (empty)'); }
206
+ } else {
207
+ console.log(' (empty)');
208
+ }
209
+ } else if (subCmd === 'clean') {
210
+ if (existsSync(cacheDir)) {
211
+ rmSync(cacheDir, { recursive: true, force: true });
212
+ }
213
+ console.log(' Cache cleared.');
214
+ } else {
215
+ console.error(` Unknown cache subcommand: ${subCmd}`);
216
+ console.error(' Usage: tova cache [list|path|clean]');
217
+ process.exit(1);
218
+ }
219
+ break;
220
+ }
146
221
  case 'fmt':
147
222
  formatFile(args.slice(1));
148
223
  break;
@@ -173,6 +248,9 @@ async function main() {
173
248
  case 'migrate:status':
174
249
  await migrateStatus(args.slice(1));
175
250
  break;
251
+ case 'deploy':
252
+ await deployCommand(args.slice(1));
253
+ break;
176
254
  case 'explain': {
177
255
  const code = args[1];
178
256
  if (!code) {
@@ -207,10 +285,10 @@ async function main() {
207
285
  break;
208
286
  default:
209
287
  if (command.endsWith('.tova')) {
210
- const directArgs = args.filter(a => a !== '--strict').slice(1);
288
+ const directArgs = args.filter(a => a !== '--strict' && a !== '--strict-security').slice(1);
211
289
  const ddIdx = directArgs.indexOf('--');
212
290
  const scriptArgs = ddIdx !== -1 ? directArgs.slice(ddIdx + 1) : directArgs;
213
- await runFile(command, { strict: isStrict, scriptArgs });
291
+ await runFile(command, { strict: isStrict, strictSecurity: isStrictSecurity, scriptArgs });
214
292
  } else {
215
293
  console.error(`Unknown command: ${command}`);
216
294
  console.log(HELP);
@@ -228,7 +306,7 @@ function compileTova(source, filename, options = {}) {
228
306
  const parser = new Parser(tokens, filename);
229
307
  const ast = parser.parse();
230
308
 
231
- const analyzer = new Analyzer(ast, filename, { strict: options.strict || false });
309
+ const analyzer = new Analyzer(ast, filename, { strict: options.strict || false, strictSecurity: options.strictSecurity || false });
232
310
  // Pre-define extra names in the analyzer scope (used by REPL for cross-line persistence)
233
311
  if (options.knownNames) {
234
312
  for (const name of options.knownNames) {
@@ -546,6 +624,22 @@ function findTovaFiles(dir) {
546
624
  return files;
547
625
  }
548
626
 
627
+ // ─── Deploy ──────────────────────────────────────────────────
628
+
629
+ async function deployCommand(args) {
630
+ const { parseDeployArgs } = await import('../src/deploy/deploy.js');
631
+ const deployArgs = parseDeployArgs(args);
632
+
633
+ if (!deployArgs.envName && !deployArgs.list) {
634
+ console.error(color.red('Error: deploy requires an environment name (e.g., tova deploy prod)'));
635
+ process.exit(1);
636
+ }
637
+
638
+ // For now, just parse and build — full SSH deployment is wired in integration
639
+ console.log(color.cyan('Deploy feature is being implemented...'));
640
+ console.log('Parsed args:', deployArgs);
641
+ }
642
+
549
643
  // ─── Run ────────────────────────────────────────────────────
550
644
 
551
645
  async function runFile(filePath, options = {}) {
@@ -595,11 +689,12 @@ async function runFile(filePath, options = {}) {
595
689
  }
596
690
  const hasTovaImports = tovaImportPaths.length > 0;
597
691
 
598
- const output = compileTova(source, filePath, { strict: options.strict });
692
+ const output = compileTova(source, filePath, { strict: options.strict, strictSecurity: options.strictSecurity });
599
693
 
600
694
  // Execute the generated JavaScript (with stdlib)
601
695
  const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
602
696
  const stdlib = getRunStdlib();
697
+ const __tova_require = _createRequire(import.meta.url);
603
698
 
604
699
  // CLI mode: execute the cli code directly
605
700
  if (output.isCli) {
@@ -608,8 +703,8 @@ async function runFile(filePath, options = {}) {
608
703
  // Override process.argv for cli dispatch
609
704
  const scriptArgs = options.scriptArgs || [];
610
705
  code = `process.argv = ["node", ${JSON.stringify(resolved)}, ...${JSON.stringify(scriptArgs)}];\n` + code;
611
- const fn = new AsyncFunction('__tova_args', '__tova_filename', '__tova_dirname', code);
612
- await fn(scriptArgs, resolved, dirname(resolved));
706
+ const fn = new AsyncFunction('__tova_args', '__tova_filename', '__tova_dirname', 'require', code);
707
+ await fn(scriptArgs, resolved, dirname(resolved), __tova_require);
613
708
  return;
614
709
  }
615
710
 
@@ -644,8 +739,8 @@ async function runFile(filePath, options = {}) {
644
739
  if (/\bfunction\s+main\s*\(/.test(code)) {
645
740
  code += '\nconst __tova_exit = await main(__tova_args); if (typeof __tova_exit === "number") process.exitCode = __tova_exit;\n';
646
741
  }
647
- const fn = new AsyncFunction('__tova_args', '__tova_filename', '__tova_dirname', code);
648
- await fn(scriptArgs, resolved, dirname(resolved));
742
+ const fn = new AsyncFunction('__tova_args', '__tova_filename', '__tova_dirname', 'require', code);
743
+ await fn(scriptArgs, resolved, dirname(resolved), __tova_require);
649
744
  } catch (err) {
650
745
  console.error(richError(source, err, filePath));
651
746
  if (process.argv.includes('--debug') || process.env.DEBUG) {
@@ -655,12 +750,217 @@ async function runFile(filePath, options = {}) {
655
750
  }
656
751
  }
657
752
 
753
+ // ─── Import Path Fixup ──────────────────────────────────────
754
+
755
+ function fixImportPaths(code, outputFilePath, outDir, srcDir) {
756
+ const relPath = relative(outDir, outputFilePath);
757
+ const depth = dirname(relPath).split(sep).filter(p => p && p !== '.').length;
758
+
759
+ // Fix runtime imports: './runtime/X.js' → correct relative path based on depth
760
+ if (depth > 0) {
761
+ const prefix = '../'.repeat(depth);
762
+ for (const runtimeFile of ['reactivity.js', 'rpc.js', 'router.js', 'devtools.js', 'ssr.js', 'testing.js']) {
763
+ code = code.split("'./runtime/" + runtimeFile + "'").join("'" + prefix + "runtime/" + runtimeFile + "'");
764
+ code = code.split('"./runtime/' + runtimeFile + '"').join('"' + prefix + 'runtime/' + runtimeFile + '"');
765
+ }
766
+ }
767
+
768
+ // Add .js extension to relative imports that don't have one
769
+ code = code.replace(
770
+ /from\s+(['"])(\.[^'"]+)\1/g,
771
+ (match, quote, path) => {
772
+ if (path.endsWith('.js')) return match;
773
+ return 'from ' + quote + path + '.js' + quote;
774
+ }
775
+ );
776
+
777
+ // Inject missing router imports
778
+ code = injectRouterImport(code, depth);
779
+
780
+ // Fix duplicate identifiers between reactivity and router imports (e.g. 'lazy')
781
+ const reactivityMatch = code.match(/^import\s+\{([^}]+)\}\s+from\s+['"][^'"]*runtime\/reactivity[^'"]*['"]/m);
782
+ const routerMatch = code.match(/^(import\s+\{)([^}]+)(\}\s+from\s+['"][^'"]*runtime\/router[^'"]*['"])/m);
783
+ if (reactivityMatch && routerMatch) {
784
+ const reactivityNames = new Set(reactivityMatch[1].split(',').map(s => s.trim()));
785
+ const routerNames = routerMatch[2].split(',').map(s => s.trim());
786
+ const deduped = routerNames.filter(n => !reactivityNames.has(n));
787
+ if (deduped.length < routerNames.length) {
788
+ if (deduped.length === 0) {
789
+ // Remove the entire router import line if nothing left
790
+ code = code.replace(/^import\s+\{[^}]*\}\s+from\s+['"][^'"]*runtime\/router[^'"]*['"];?\s*\n?/m, '');
791
+ } else {
792
+ code = code.replace(routerMatch[0], routerMatch[1] + ' ' + deduped.join(', ') + ' ' + routerMatch[3]);
793
+ }
794
+ }
795
+ }
796
+
797
+ // Fix CodeBlock/code prop template literal interpolation: the compiler treats {identifier}
798
+ // inside string attributes as interpolation, generating `${identifier}` in template literals.
799
+ // For code example strings, these should be literal braces. Revert them.
800
+ code = code.replace(/code:\s*`([\s\S]*?)`/g, (match, content) => {
801
+ if (!content.includes('${')) return match;
802
+ const fixed = content.replace(/\$\{(\w+)\}/g, '{ $1 }');
803
+ // Convert template literal to regular string with escaped quotes and newlines
804
+ return 'code: "' + fixed.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"';
805
+ });
806
+
807
+ // File-based routing: inject routes if src/pages/ exists and no manual routes defined
808
+ if (srcDir && !(/\b(defineRoutes|createRouter)\s*\(/.test(code))) {
809
+ const fileRoutes = generateFileBasedRoutes(srcDir);
810
+ if (fileRoutes) {
811
+ // Convert Tova-style imports to JS imports with .js extensions
812
+ const jsRoutes = fileRoutes
813
+ .replace(/from\s+"([^"]+)"/g, (m, p) => 'from "' + p + '.js"');
814
+ // Inject before the last closing function or at the end
815
+ code = code + '\n// ── File-Based Routes (auto-generated from src/pages/) ──\n' + jsRoutes + '\n';
816
+ }
817
+ }
818
+
819
+ return code;
820
+ }
821
+
822
+ function injectRouterImport(code, depth) {
823
+ const routerFuncs = ['createRouter', 'lazy', 'resetRouter', 'getPath', 'navigate',
824
+ 'getCurrentRoute', 'getParams', 'getQuery', 'getMeta', 'getRouter',
825
+ 'defineRoutes', 'onRouteChange', 'Router', 'Link', 'Outlet', 'Redirect',
826
+ 'beforeNavigate', 'afterNavigate'];
827
+ const hasRouterImport = /runtime\/router/.test(code);
828
+ if (hasRouterImport) return code;
829
+
830
+ // Strip import lines before checking for router function usage to avoid false positives
831
+ const codeWithoutImports = code.replace(/^import\s+\{[^}]*\}\s+from\s+['"][^'"]*['"];?\s*$/gm, '');
832
+ const usedFuncs = routerFuncs.filter(fn => new RegExp('\\b' + fn + '\\b').test(codeWithoutImports));
833
+ if (usedFuncs.length === 0) return code;
834
+
835
+ const routerPath = depth === 0
836
+ ? './runtime/router.js'
837
+ : '../'.repeat(depth) + 'runtime/router.js';
838
+
839
+ const importLine = "import { " + usedFuncs.join(', ') + " } from '" + routerPath + "';\n";
840
+
841
+ // Insert after first import line, or at the start
842
+ const firstImportEnd = code.indexOf(';\n');
843
+ if (firstImportEnd !== -1 && code.trimStart().startsWith('import ')) {
844
+ return code.slice(0, firstImportEnd + 2) + importLine + code.slice(firstImportEnd + 2);
845
+ }
846
+ return importLine + code;
847
+ }
848
+
849
+ // ─── File-Based Routing ──────────────────────────────────────
850
+
851
+ function generateFileBasedRoutes(srcDir) {
852
+ const pagesDir = join(srcDir, 'pages');
853
+ if (!existsSync(pagesDir) || !statSync(pagesDir).isDirectory()) return null;
854
+
855
+ // Scan pages directory recursively
856
+ const routes = [];
857
+ let hasLayout = false;
858
+ let has404 = false;
859
+
860
+ function scanDir(dir, prefix) {
861
+ const entries = readdirSync(dir).sort();
862
+ for (const entry of entries) {
863
+ const fullPath = join(dir, entry);
864
+ const stat = statSync(fullPath);
865
+
866
+ if (stat.isDirectory()) {
867
+ // Check for layout in subdirectory
868
+ const subLayout = join(fullPath, '_layout.tova');
869
+ if (existsSync(subLayout)) {
870
+ const childRoutes = [];
871
+ scanDir(fullPath, prefix + '/' + entry);
872
+ // Layout routes handled via nested children
873
+ continue;
874
+ }
875
+ scanDir(fullPath, prefix + '/' + entry);
876
+ continue;
877
+ }
878
+
879
+ if (!entry.endsWith('.tova')) continue;
880
+ const name = entry.replace('.tova', '');
881
+
882
+ // Skip layout files (handled separately)
883
+ if (name === '_layout') {
884
+ if (prefix === '') hasLayout = true;
885
+ continue;
886
+ }
887
+
888
+ // 404 page
889
+ if (name === '404') {
890
+ has404 = true;
891
+ const relImport = './pages' + (prefix ? prefix + '/' : '/') + name;
892
+ routes.push({ path: '404', importPath: relImport, componentName: 'NotFoundPage__auto' });
893
+ continue;
894
+ }
895
+
896
+ // Determine route path
897
+ let routePath;
898
+ if (name === 'index') {
899
+ routePath = prefix || '/';
900
+ } else if (name.startsWith('[...') && name.endsWith(']')) {
901
+ // Catch-all: [...slug] → *
902
+ routePath = prefix + '/*';
903
+ } else if (name.startsWith('[[') && name.endsWith(']]')) {
904
+ // Optional param: [[id]] → /:id?
905
+ const paramName = name.slice(2, -2);
906
+ routePath = prefix + '/:' + paramName + '?';
907
+ } else if (name.startsWith('[') && name.endsWith(']')) {
908
+ // Dynamic param: [id] → /:id
909
+ const paramName = name.slice(1, -1);
910
+ routePath = prefix + '/:' + paramName;
911
+ } else {
912
+ routePath = prefix + '/' + name;
913
+ }
914
+
915
+ const relImport = './pages' + (prefix ? prefix + '/' : '/') + name;
916
+ // Generate safe component name from path
917
+ const safeName = name
918
+ .replace(/\[\.\.\.(\w+)\]/, 'CatchAll_$1')
919
+ .replace(/\[\[(\w+)\]\]/, 'Optional_$1')
920
+ .replace(/\[(\w+)\]/, 'Param_$1')
921
+ .replace(/[^a-zA-Z0-9_]/g, '_');
922
+ const componentName = '__Page_' + (prefix ? prefix.slice(1).replace(/\//g, '_') + '_' : '') + safeName;
923
+
924
+ routes.push({ path: routePath, importPath: relImport, componentName });
925
+ }
926
+ }
927
+
928
+ scanDir(pagesDir, '');
929
+
930
+ if (routes.length === 0) return null;
931
+
932
+ // Generate import statements and route map
933
+ const imports = routes.map(r =>
934
+ 'import { Page as ' + r.componentName + ' } from "' + r.importPath + '"'
935
+ ).join('\n');
936
+
937
+ const routeEntries = routes.map(r =>
938
+ ' "' + r.path + '": ' + r.componentName + ','
939
+ ).join('\n');
940
+
941
+ // Check for root layout
942
+ let layoutImport = '';
943
+ let layoutWrap = '';
944
+ if (hasLayout) {
945
+ layoutImport = '\nimport { Layout as __RootLayout } from "./pages/_layout"';
946
+ // With layout, wrap routes as children
947
+ // For now, just generate flat routes — layout support can be added later
948
+ }
949
+
950
+ const generated = imports + layoutImport + '\n\n' +
951
+ 'defineRoutes({\n' + routeEntries + '\n})';
952
+
953
+ return generated;
954
+ }
955
+
658
956
  // ─── Build ──────────────────────────────────────────────────
659
957
 
660
958
  async function buildProject(args) {
661
959
  const config = resolveConfig(process.cwd());
662
960
  const isProduction = args.includes('--production');
961
+ const isStatic = args.includes('--static');
663
962
  const buildStrict = args.includes('--strict');
963
+ const buildStrictSecurity = args.includes('--strict-security');
664
964
  const isVerbose = args.includes('--verbose');
665
965
  const isQuiet = args.includes('--quiet');
666
966
  const isWatch = args.includes('--watch');
@@ -678,7 +978,7 @@ async function buildProject(args) {
678
978
 
679
979
  // Production build uses a separate optimized pipeline
680
980
  if (isProduction) {
681
- return await productionBuild(srcDir, outDir);
981
+ return await productionBuild(srcDir, outDir, isStatic);
682
982
  }
683
983
 
684
984
  const tovaFiles = findFiles(srcDir, '.tova');
@@ -695,6 +995,9 @@ async function buildProject(args) {
695
995
  writeFileSync(join(runtimeDest, 'reactivity.js'), REACTIVITY_SOURCE);
696
996
  writeFileSync(join(runtimeDest, 'rpc.js'), RPC_SOURCE);
697
997
  writeFileSync(join(runtimeDest, 'router.js'), ROUTER_SOURCE);
998
+ writeFileSync(join(runtimeDest, 'devtools.js'), DEVTOOLS_SOURCE);
999
+ writeFileSync(join(runtimeDest, 'ssr.js'), SSR_SOURCE);
1000
+ writeFileSync(join(runtimeDest, 'testing.js'), TESTING_SOURCE);
698
1001
 
699
1002
  if (!isQuiet) console.log(`\n Building ${tovaFiles.length} file(s)...\n`);
700
1003
 
@@ -710,6 +1013,7 @@ async function buildProject(args) {
710
1013
 
711
1014
  // Group files by directory for multi-file merging
712
1015
  const dirGroups = groupFilesByDirectory(tovaFiles);
1016
+ let _scorecardData = null; // Collect security info for scorecard
713
1017
 
714
1018
  for (const [dir, files] of dirGroups) {
715
1019
  const dirName = basename(dir) === '.' ? 'app' : basename(dir);
@@ -746,10 +1050,13 @@ async function buildProject(args) {
746
1050
  }
747
1051
  }
748
1052
 
749
- const result = mergeDirectory(dir, srcDir, { strict: buildStrict });
1053
+ const result = mergeDirectory(dir, srcDir, { strict: buildStrict, strictSecurity: buildStrictSecurity });
750
1054
  if (!result) continue;
751
1055
 
752
- const { output, single } = result;
1056
+ const { output, single, warnings: buildWarnings, securityConfig, hasServer, hasEdge } = result;
1057
+ if ((hasServer || hasEdge) && !_scorecardData) {
1058
+ _scorecardData = { securityConfig, warnings: buildWarnings || [], hasServer, hasEdge };
1059
+ }
753
1060
  // Preserve relative directory structure in output (e.g., src/lib/math.tova → lib/math.js)
754
1061
  const outBaseName = single
755
1062
  ? relative(srcDir, files[0]).replace(/\.tova$/, '').replace(/\\/g, '/')
@@ -803,7 +1110,7 @@ async function buildProject(args) {
803
1110
  else if (output.isModule) {
804
1111
  if (output.shared && output.shared.trim()) {
805
1112
  const modulePath = join(outDir, `${outBaseName}.js`);
806
- writeFileSync(modulePath, generateSourceMap(output.shared, modulePath));
1113
+ writeFileSync(modulePath, fixImportPaths(generateSourceMap(output.shared, modulePath), modulePath, outDir));
807
1114
  if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', modulePath)}${timing}`);
808
1115
  }
809
1116
  // Update incremental build cache
@@ -822,28 +1129,30 @@ async function buildProject(args) {
822
1129
  // Write shared
823
1130
  if (output.shared && output.shared.trim()) {
824
1131
  const sharedPath = join(outDir, `${outBaseName}.shared.js`);
825
- writeFileSync(sharedPath, generateSourceMap(output.shared, sharedPath));
1132
+ writeFileSync(sharedPath, fixImportPaths(generateSourceMap(output.shared, sharedPath), sharedPath, outDir));
826
1133
  if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', sharedPath)}${timing}`);
827
1134
  }
828
1135
 
829
1136
  // Write default server
830
1137
  if (output.server) {
831
1138
  const serverPath = join(outDir, `${outBaseName}.server.js`);
832
- writeFileSync(serverPath, generateSourceMap(output.server, serverPath));
1139
+ writeFileSync(serverPath, fixImportPaths(generateSourceMap(output.server, serverPath), serverPath, outDir));
833
1140
  if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', serverPath)}${timing}`);
834
1141
  }
835
1142
 
836
1143
  // Write default browser
837
1144
  if (output.browser) {
838
1145
  const browserPath = join(outDir, `${outBaseName}.browser.js`);
839
- writeFileSync(browserPath, generateSourceMap(output.browser, browserPath));
1146
+ // Pass srcDir for file-based routing injection (only for root-level browser output)
1147
+ const browserSrcDir = (relDir === '.' || relDir === '') ? srcDir : undefined;
1148
+ writeFileSync(browserPath, fixImportPaths(generateSourceMap(output.browser, browserPath), browserPath, outDir, browserSrcDir));
840
1149
  if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', browserPath)}${timing}`);
841
1150
  }
842
1151
 
843
1152
  // Write default edge
844
1153
  if (output.edge) {
845
1154
  const edgePath = join(outDir, `${outBaseName}.edge.js`);
846
- writeFileSync(edgePath, generateSourceMap(output.edge, edgePath));
1155
+ writeFileSync(edgePath, fixImportPaths(generateSourceMap(output.edge, edgePath), edgePath, outDir));
847
1156
  if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', edgePath)} [edge]${timing}`);
848
1157
  }
849
1158
 
@@ -852,7 +1161,7 @@ async function buildProject(args) {
852
1161
  for (const [name, code] of Object.entries(output.servers)) {
853
1162
  if (name === 'default') continue;
854
1163
  const path = join(outDir, `${outBaseName}.server.${name}.js`);
855
- writeFileSync(path, code);
1164
+ writeFileSync(path, fixImportPaths(code, path, outDir));
856
1165
  if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [server:${name}]${timing}`);
857
1166
  }
858
1167
  }
@@ -862,7 +1171,7 @@ async function buildProject(args) {
862
1171
  for (const [name, code] of Object.entries(output.edges)) {
863
1172
  if (name === 'default') continue;
864
1173
  const path = join(outDir, `${outBaseName}.edge.${name}.js`);
865
- writeFileSync(path, code);
1174
+ writeFileSync(path, fixImportPaths(code, path, outDir));
866
1175
  if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [edge:${name}]${timing}`);
867
1176
  }
868
1177
  }
@@ -872,7 +1181,7 @@ async function buildProject(args) {
872
1181
  for (const [name, code] of Object.entries(output.browsers)) {
873
1182
  if (name === 'default') continue;
874
1183
  const path = join(outDir, `${outBaseName}.browser.${name}.js`);
875
- writeFileSync(path, code);
1184
+ writeFileSync(path, fixImportPaths(code, path, outDir));
876
1185
  if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [browser:${name}]${timing}`);
877
1186
  }
878
1187
  }
@@ -912,6 +1221,18 @@ async function buildProject(args) {
912
1221
  const cachedStr = skippedCount > 0 ? ` (${skippedCount} cached)` : '';
913
1222
  console.log(`\n Build complete. ${dirCount - errorCount}/${dirCount} directory group(s) succeeded${cachedStr}${timingStr}.\n`);
914
1223
  }
1224
+
1225
+ // Security scorecard (shown with --verbose or --strict-security, suppressed with --quiet)
1226
+ if ((isVerbose || buildStrictSecurity) && !isQuiet && _scorecardData) {
1227
+ const scorecard = generateSecurityScorecard(
1228
+ _scorecardData.securityConfig,
1229
+ _scorecardData.warnings,
1230
+ _scorecardData.hasServer,
1231
+ _scorecardData.hasEdge
1232
+ );
1233
+ if (scorecard) console.log(scorecard.format());
1234
+ }
1235
+
915
1236
  if (errorCount > 0) process.exit(1);
916
1237
 
917
1238
  // Watch mode for build command
@@ -938,6 +1259,7 @@ async function buildProject(args) {
938
1259
 
939
1260
  async function checkProject(args) {
940
1261
  const checkStrict = args.includes('--strict');
1262
+ const checkStrictSecurity = args.includes('--strict-security');
941
1263
  const isVerbose = args.includes('--verbose');
942
1264
  const isQuiet = args.includes('--quiet');
943
1265
 
@@ -980,6 +1302,8 @@ async function checkProject(args) {
980
1302
  let totalErrors = 0;
981
1303
  let totalWarnings = 0;
982
1304
  const seenCodes = new Set();
1305
+ let _checkScorecardData = null;
1306
+ const _allCheckWarnings = [];
983
1307
 
984
1308
  for (const file of tovaFiles) {
985
1309
  const relPath = relative(srcDir, file);
@@ -990,13 +1314,39 @@ async function checkProject(args) {
990
1314
  const tokens = lexer.tokenize();
991
1315
  const parser = new Parser(tokens, file);
992
1316
  const ast = parser.parse();
993
- const analyzer = new Analyzer(ast, file, { strict: checkStrict, tolerant: true });
1317
+ const analyzer = new Analyzer(ast, file, { strict: checkStrict, strictSecurity: checkStrictSecurity, tolerant: true });
994
1318
  const result = analyzer.analyze();
995
1319
 
996
1320
  const errors = result.errors || [];
997
1321
  const warnings = result.warnings || [];
998
1322
  totalErrors += errors.length;
999
1323
  totalWarnings += warnings.length;
1324
+ _allCheckWarnings.push(...warnings);
1325
+
1326
+ // Collect security info for scorecard
1327
+ if (!_checkScorecardData) {
1328
+ const hasServer = ast.body.some(n => n.type === 'ServerBlock');
1329
+ const hasEdge = ast.body.some(n => n.type === 'EdgeBlock');
1330
+ if (hasServer || hasEdge) {
1331
+ const secNode = ast.body.find(n => n.type === 'SecurityBlock');
1332
+ let secCfg = null;
1333
+ if (secNode) {
1334
+ secCfg = {};
1335
+ for (const child of secNode.body || []) {
1336
+ if (child.type === 'AuthDeclaration') secCfg.auth = { authType: child.authType || 'jwt', storage: child.config?.storage?.value };
1337
+ else if (child.type === 'CsrfDeclaration') secCfg.csrf = { enabled: child.config?.enabled?.value !== false };
1338
+ else if (child.type === 'RateLimitDeclaration') secCfg.rateLimit = { max: child.config?.max?.value };
1339
+ else if (child.type === 'CspDeclaration') secCfg.csp = { default_src: true };
1340
+ else if (child.type === 'CorsDeclaration') {
1341
+ const origins = child.config?.origins;
1342
+ secCfg.cors = { origins: origins ? (origins.elements || []).map(e => e.value) : [] };
1343
+ }
1344
+ else if (child.type === 'AuditDeclaration') secCfg.audit = { events: ['auth'] };
1345
+ }
1346
+ }
1347
+ _checkScorecardData = { securityConfig: secCfg, hasServer, hasEdge };
1348
+ }
1349
+ }
1000
1350
 
1001
1351
  if (errors.length > 0 || warnings.length > 0) {
1002
1352
  const formatter = new DiagnosticFormatter(source, file);
@@ -1025,6 +1375,17 @@ async function checkProject(args) {
1025
1375
  }
1026
1376
  }
1027
1377
 
1378
+ // Security scorecard (shown with --verbose or --strict-security, suppressed with --quiet)
1379
+ if ((isVerbose || checkStrictSecurity) && !isQuiet && _checkScorecardData) {
1380
+ const scorecard = generateSecurityScorecard(
1381
+ _checkScorecardData.securityConfig,
1382
+ _allCheckWarnings,
1383
+ _checkScorecardData.hasServer,
1384
+ _checkScorecardData.hasEdge
1385
+ );
1386
+ if (scorecard) console.log(scorecard.format());
1387
+ }
1388
+
1028
1389
  if (!isQuiet) {
1029
1390
  console.log(`\n ${tovaFiles.length} file${tovaFiles.length === 1 ? '' : 's'} checked, ${formatSummary(totalErrors, totalWarnings)}`);
1030
1391
  // Show explain hint for encountered error codes
@@ -1061,6 +1422,7 @@ async function devServer(args) {
1061
1422
  const explicitPort = args.find((_, i, a) => a[i - 1] === '--port');
1062
1423
  const basePort = parseInt(explicitPort || config.dev.port || '3000');
1063
1424
  const buildStrict = args.includes('--strict');
1425
+ const buildStrictSecurity = args.includes('--strict-security');
1064
1426
 
1065
1427
  const tovaFiles = findFiles(srcDir, '.tova');
1066
1428
  if (tovaFiles.length === 0) {
@@ -1104,6 +1466,9 @@ async function devServer(args) {
1104
1466
  writeFileSync(join(runtimeDest, 'reactivity.js'), REACTIVITY_SOURCE);
1105
1467
  writeFileSync(join(runtimeDest, 'rpc.js'), RPC_SOURCE);
1106
1468
  writeFileSync(join(runtimeDest, 'router.js'), ROUTER_SOURCE);
1469
+ writeFileSync(join(runtimeDest, 'devtools.js'), DEVTOOLS_SOURCE);
1470
+ writeFileSync(join(runtimeDest, 'ssr.js'), SSR_SOURCE);
1471
+ writeFileSync(join(runtimeDest, 'testing.js'), TESTING_SOURCE);
1107
1472
 
1108
1473
  const serverFiles = [];
1109
1474
  let hasClient = false;
@@ -1119,25 +1484,40 @@ async function devServer(args) {
1119
1484
 
1120
1485
  // Pass 1: Merge each directory, write shared/client outputs, collect clientHTML
1121
1486
  const dirResults = [];
1487
+ const allSharedParts = [];
1488
+ let browserCode = '';
1122
1489
  for (const [dir, files] of dirGroups) {
1123
1490
  const dirName = basename(dir) === '.' ? 'app' : basename(dir);
1124
1491
  try {
1125
- const result = mergeDirectory(dir, srcDir, { strict: buildStrict });
1492
+ const result = mergeDirectory(dir, srcDir, { strict: buildStrict, strictSecurity: buildStrictSecurity });
1126
1493
  if (!result) continue;
1127
1494
 
1128
1495
  const { output, single } = result;
1129
- const outBaseName = single ? basename(files[0], '.tova') : dirName;
1496
+ const relDir = relative(srcDir, dir);
1497
+ const outBaseName = single
1498
+ ? relative(srcDir, files[0]).replace(/\.tova$/, '').replace(/\\/g, '/')
1499
+ : (relDir === '.' ? dirName : relDir + '/' + dirName);
1130
1500
  dirResults.push({ dir, output, outBaseName, single, files });
1131
1501
 
1502
+ // Ensure output subdirectory exists for nested paths
1503
+ const outSubDir = dirname(join(outDir, outBaseName));
1504
+ if (outSubDir !== outDir) mkdirSync(outSubDir, { recursive: true });
1505
+
1132
1506
  if (output.shared && output.shared.trim()) {
1133
- writeFileSync(join(outDir, `${outBaseName}.shared.js`), output.shared);
1507
+ // Use .js (not .shared.js) for module files to match build output
1508
+ const ext = (output.isModule || (!output.browser && !output.server)) ? '.js' : '.shared.js';
1509
+ const sp = join(outDir, `${outBaseName}${ext}`);
1510
+ const fixedShared = fixImportPaths(output.shared, sp, outDir);
1511
+ writeFileSync(sp, fixedShared);
1512
+ allSharedParts.push(fixedShared);
1134
1513
  }
1135
1514
 
1136
1515
  if (output.browser) {
1137
1516
  const p = join(outDir, `${outBaseName}.browser.js`);
1138
- writeFileSync(p, output.browser);
1139
- clientHTML = await generateDevHTML(output.browser, srcDir, actualReloadPort);
1140
- writeFileSync(join(outDir, 'index.html'), clientHTML);
1517
+ const browserSrcDir = (relative(srcDir, dir) === '.' || relative(srcDir, dir) === '') ? srcDir : undefined;
1518
+ const fixedBrowser = fixImportPaths(output.browser, p, outDir, browserSrcDir);
1519
+ writeFileSync(p, fixedBrowser);
1520
+ browserCode = fixedBrowser;
1141
1521
  hasClient = true;
1142
1522
  }
1143
1523
  } catch (err) {
@@ -1145,6 +1525,16 @@ async function devServer(args) {
1145
1525
  }
1146
1526
  }
1147
1527
 
1528
+ // Generate dev HTML with all shared code prepended to browser code
1529
+ // Skip if the project has its own index.html (uses import maps or custom module loading)
1530
+ const hasCustomIndex = existsSync(join(process.cwd(), 'index.html'));
1531
+ if (hasClient && !hasCustomIndex) {
1532
+ const allSharedCode = allSharedParts.join('\n').replace(/^export /gm, '');
1533
+ const fullClientCode = allSharedCode ? allSharedCode + '\n' + browserCode : browserCode;
1534
+ clientHTML = await generateDevHTML(fullClientCode, srcDir, actualReloadPort);
1535
+ writeFileSync(join(outDir, 'index.html'), clientHTML);
1536
+ }
1537
+
1148
1538
  // Pass 2: Write server files with clientHTML injected
1149
1539
  for (const { output, outBaseName } of dirResults) {
1150
1540
  if (output.server) {
@@ -1216,6 +1606,75 @@ async function devServer(args) {
1216
1606
  console.log(` ✓ Client: ${relative('.', outDir)}/index.html`);
1217
1607
  }
1218
1608
 
1609
+ // If no server blocks were found but we have a client, start a static file server
1610
+ if (processes.length === 0 && hasClient) {
1611
+ const mimeTypes = {
1612
+ '.html': 'text/html',
1613
+ '.js': 'application/javascript',
1614
+ '.mjs': 'application/javascript',
1615
+ '.css': 'text/css',
1616
+ '.json': 'application/json',
1617
+ '.png': 'image/png',
1618
+ '.jpg': 'image/jpeg',
1619
+ '.gif': 'image/gif',
1620
+ '.svg': 'image/svg+xml',
1621
+ '.ico': 'image/x-icon',
1622
+ '.woff': 'font/woff',
1623
+ '.woff2': 'font/woff2',
1624
+ '.map': 'application/json',
1625
+ };
1626
+
1627
+ const staticServer = Bun.serve({
1628
+ port: basePort,
1629
+ async fetch(req) {
1630
+ const url = new URL(req.url);
1631
+ let pathname = url.pathname;
1632
+
1633
+ // Try to serve the file directly from outDir, srcDir, or project root
1634
+ const tryPaths = [
1635
+ join(outDir, pathname),
1636
+ join(srcDir, pathname),
1637
+ join(process.cwd(), pathname),
1638
+ ];
1639
+
1640
+ for (const filePath of tryPaths) {
1641
+ if (existsSync(filePath) && statSync(filePath).isFile()) {
1642
+ const ext = extname(filePath);
1643
+ const contentType = mimeTypes[ext] || 'application/octet-stream';
1644
+ const content = readFileSync(filePath);
1645
+ return new Response(content, {
1646
+ headers: {
1647
+ 'Content-Type': contentType,
1648
+ 'Cache-Control': 'no-cache',
1649
+ 'Access-Control-Allow-Origin': '*',
1650
+ },
1651
+ });
1652
+ }
1653
+ }
1654
+
1655
+ // SPA fallback: serve index.html for non-file routes
1656
+ const indexPath = join(outDir, 'index.html');
1657
+ if (existsSync(indexPath)) {
1658
+ return new Response(readFileSync(indexPath), {
1659
+ headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache' },
1660
+ });
1661
+ }
1662
+
1663
+ const rootIndex = join(process.cwd(), 'index.html');
1664
+ if (existsSync(rootIndex)) {
1665
+ return new Response(readFileSync(rootIndex), {
1666
+ headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache' },
1667
+ });
1668
+ }
1669
+
1670
+ return new Response('Not Found', { status: 404 });
1671
+ },
1672
+ });
1673
+
1674
+ console.log(`\n Static file server running:`);
1675
+ console.log(` → http://localhost:${basePort}`);
1676
+ }
1677
+
1219
1678
  function handleReloadFetch(req) {
1220
1679
  const url = new URL(req.url);
1221
1680
  if (url.pathname === '/__tova_reload') {
@@ -1269,22 +1728,39 @@ async function devServer(args) {
1269
1728
  // Merge each directory group, collect client HTML
1270
1729
  const rebuildDirGroups = groupFilesByDirectory(currentFiles);
1271
1730
  let rebuildClientHTML = '';
1731
+ const rebuildSharedParts = [];
1732
+ let rebuildBrowserCode = '';
1733
+ let rebuildHasClient = false;
1272
1734
 
1273
1735
  for (const [dir, files] of rebuildDirGroups) {
1274
1736
  const dirName = basename(dir) === '.' ? 'app' : basename(dir);
1275
- const result = mergeDirectory(dir, srcDir, { strict: buildStrict });
1737
+ const result = mergeDirectory(dir, srcDir, { strict: buildStrict, strictSecurity: buildStrictSecurity });
1276
1738
  if (!result) continue;
1277
1739
 
1278
1740
  const { output, single } = result;
1279
- const outBaseName = single ? basename(files[0], '.tova') : dirName;
1741
+ const relDir = relative(srcDir, dir);
1742
+ const outBaseName = single
1743
+ ? relative(srcDir, files[0]).replace(/\.tova$/, '').replace(/\\/g, '/')
1744
+ : (relDir === '.' ? dirName : relDir + '/' + dirName);
1745
+
1746
+ // Ensure output subdirectory exists for nested paths
1747
+ const outSubDir = dirname(join(outDir, outBaseName));
1748
+ if (outSubDir !== outDir) mkdirSync(outSubDir, { recursive: true });
1280
1749
 
1281
1750
  if (output.shared && output.shared.trim()) {
1282
- writeFileSync(join(outDir, `${outBaseName}.shared.js`), output.shared);
1751
+ const ext = (output.isModule || (!output.browser && !output.server)) ? '.js' : '.shared.js';
1752
+ const sp = join(outDir, `${outBaseName}${ext}`);
1753
+ const fixedShared = fixImportPaths(output.shared, sp, outDir);
1754
+ writeFileSync(sp, fixedShared);
1755
+ rebuildSharedParts.push(fixedShared);
1283
1756
  }
1284
1757
  if (output.browser) {
1285
- writeFileSync(join(outDir, `${outBaseName}.browser.js`), output.browser);
1286
- rebuildClientHTML = await generateDevHTML(output.browser, srcDir, actualReloadPort);
1287
- writeFileSync(join(outDir, 'index.html'), rebuildClientHTML);
1758
+ const p = join(outDir, `${outBaseName}.browser.js`);
1759
+ const browserSrcDir = (relative(srcDir, dir) === '.' || relative(srcDir, dir) === '') ? srcDir : undefined;
1760
+ const fixedBrowser = fixImportPaths(output.browser, p, outDir, browserSrcDir);
1761
+ writeFileSync(p, fixedBrowser);
1762
+ rebuildBrowserCode = fixedBrowser;
1763
+ rebuildHasClient = true;
1288
1764
  }
1289
1765
  if (output.server) {
1290
1766
  let serverCode = output.server;
@@ -1292,7 +1768,7 @@ async function devServer(args) {
1292
1768
  serverCode = `const __clientHTML = ${JSON.stringify(rebuildClientHTML)};\n` + serverCode;
1293
1769
  }
1294
1770
  const p = join(outDir, `${outBaseName}.server.js`);
1295
- writeFileSync(p, serverCode);
1771
+ writeFileSync(p, fixImportPaths(serverCode, p, outDir));
1296
1772
  newServerFiles.push(p);
1297
1773
  }
1298
1774
  if (output.multiBlock && output.servers) {
@@ -1304,6 +1780,14 @@ async function devServer(args) {
1304
1780
  }
1305
1781
  }
1306
1782
  }
1783
+
1784
+ // Generate dev HTML with all shared code prepended to browser code
1785
+ if (rebuildHasClient) {
1786
+ const rebuildAllShared = rebuildSharedParts.join('\n').replace(/^export /gm, '');
1787
+ const rebuildFullClient = rebuildAllShared ? rebuildAllShared + '\n' + rebuildBrowserCode : rebuildBrowserCode;
1788
+ rebuildClientHTML = await generateDevHTML(rebuildFullClient, srcDir, actualReloadPort);
1789
+ writeFileSync(join(outDir, 'index.html'), rebuildClientHTML);
1790
+ }
1307
1791
  } catch (err) {
1308
1792
  console.error(` ✗ Rebuild failed: ${err.message}`);
1309
1793
  return; // Keep old processes running
@@ -1421,6 +1905,10 @@ ${bundled}
1421
1905
  const inlineReactivity = REACTIVITY_SOURCE.replace(/^export /gm, '');
1422
1906
  const inlineRpc = RPC_SOURCE.replace(/^export /gm, '');
1423
1907
 
1908
+ // Detect if client code uses routing (defineRoutes, Router, getPath, navigate, etc.)
1909
+ const usesRouter = /\b(createRouter|lazy|resetRouter|defineRoutes|Router|getPath|getQuery|getParams|getCurrentRoute|getMeta|getRouter|navigate|onRouteChange|beforeNavigate|afterNavigate|Outlet|Link|Redirect)\b/.test(clientCode);
1910
+ const inlineRouter = usesRouter ? ROUTER_SOURCE.replace(/^export /gm, '').replace(/^\s*import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]+['"];?\s*$/gm, '') : '';
1911
+
1424
1912
  // Strip all import lines from client code (we inline the runtime instead)
1425
1913
  const inlineClient = clientCode
1426
1914
  .replace(/^\s*import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]+['"];?\s*$/gm, '')
@@ -1471,6 +1959,8 @@ ${inlineReactivity}
1471
1959
  // ── Tova Runtime: RPC ──
1472
1960
  ${inlineRpc}
1473
1961
 
1962
+ ${usesRouter ? '// ── Tova Runtime: Router ──\n' + inlineRouter : ''}
1963
+
1474
1964
  // ── App ──
1475
1965
  ${inlineClient}
1476
1966
  </script>${liveReloadScript}
@@ -1485,11 +1975,12 @@ ${inlineClient}
1485
1975
  const PROJECT_TEMPLATES = {
1486
1976
  fullstack: {
1487
1977
  label: 'Full-stack app',
1488
- description: 'server + client + shared blocks',
1978
+ description: 'server + browser + shared blocks',
1489
1979
  tomlDescription: 'A full-stack Tova application',
1490
1980
  entry: 'src',
1491
1981
  file: 'src/app.tova',
1492
1982
  content: name => `// ${name} — Built with Tova
1983
+ // Full-stack app: server RPC + client-side routing
1493
1984
 
1494
1985
  shared {
1495
1986
  type Message {
@@ -1506,7 +1997,7 @@ server {
1506
1997
  route GET "/api/message" => get_message
1507
1998
  }
1508
1999
 
1509
- client {
2000
+ browser {
1510
2001
  state message = ""
1511
2002
  state timestamp = ""
1512
2003
  state refreshing = false
@@ -1525,6 +2016,23 @@ client {
1525
2016
  refreshing = false
1526
2017
  }
1527
2018
 
2019
+ // ─── Navigation ─────────────────────────────────────────
2020
+ component NavBar {
2021
+ <nav class="border-b border-gray-100 bg-white/80 backdrop-blur-sm sticky top-0 z-10">
2022
+ <div class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
2023
+ <Link href="/" class="flex items-center gap-2 no-underline">
2024
+ <div class="w-8 h-8 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-lg"></div>
2025
+ <span class="font-bold text-gray-900 text-lg">"${name}"</span>
2026
+ </Link>
2027
+ <div class="flex items-center gap-6">
2028
+ <Link href="/" exactActiveClass="text-indigo-600 font-semibold" class="text-sm font-medium transition-colors text-gray-500 hover:text-gray-900 no-underline">"Home"</Link>
2029
+ <Link href="/about" activeClass="text-indigo-600 font-semibold" class="text-sm font-medium transition-colors text-gray-500 hover:text-gray-900 no-underline">"About"</Link>
2030
+ </div>
2031
+ </div>
2032
+ </nav>
2033
+ }
2034
+
2035
+ // ─── Pages ──────────────────────────────────────────────
1528
2036
  component FeatureCard(icon, title, description) {
1529
2037
  <div class="group relative bg-white rounded-2xl p-6 shadow-sm border border-gray-100 hover:shadow-lg hover:border-indigo-100 transition-all duration-300">
1530
2038
  <div class="w-10 h-10 bg-indigo-50 rounded-xl flex items-center justify-center text-lg mb-4 group-hover:bg-indigo-100 transition-colors">
@@ -1535,72 +2043,416 @@ client {
1535
2043
  </div>
1536
2044
  }
1537
2045
 
2046
+ component HomePage {
2047
+ <main class="max-w-5xl mx-auto px-6">
2048
+ <div class="py-20 text-center">
2049
+ <div class="inline-flex items-center gap-2 bg-indigo-50 text-indigo-700 text-sm font-medium px-4 py-1.5 rounded-full mb-6">
2050
+ <span class="w-1.5 h-1.5 bg-indigo-500 rounded-full"></span>
2051
+ "Powered by Tova"
2052
+ </div>
2053
+ <h1 class="text-5xl font-bold text-gray-900 tracking-tight mb-4">"Welcome to " <span class="bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">"${name}"</span></h1>
2054
+ <p class="text-xl text-gray-500 max-w-2xl mx-auto mb-10">"A modern full-stack app. Edit " <code class="text-sm bg-gray-100 text-indigo-600 px-2 py-1 rounded-md font-mono">"src/app.tova"</code> " to get started."</p>
2055
+
2056
+ <div class="inline-flex items-center gap-3 bg-white border border-gray-200 rounded-2xl p-2 shadow-sm">
2057
+ <div class="bg-gradient-to-r from-indigo-500 to-purple-500 text-white px-5 py-2.5 rounded-xl font-medium">
2058
+ "{message}"
2059
+ </div>
2060
+ <button
2061
+ on:click={handle_refresh}
2062
+ class="px-4 py-2.5 text-gray-500 hover:text-indigo-600 hover:bg-indigo-50 rounded-xl transition-all font-medium text-sm"
2063
+ >
2064
+ if refreshing {
2065
+ "..."
2066
+ } else {
2067
+ "Refresh"
2068
+ }
2069
+ </button>
2070
+ </div>
2071
+ if timestamp != "" {
2072
+ <p class="text-xs text-gray-400 mt-3">"Last fetched at " "{timestamp}"</p>
2073
+ }
2074
+ </div>
2075
+
2076
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-5 pb-20">
2077
+ <FeatureCard
2078
+ icon="\u2699"
2079
+ title="Full-Stack"
2080
+ description="Server and client in one file. Shared types, RPC calls, and reactive UI — all type-safe."
2081
+ />
2082
+ <FeatureCard
2083
+ icon="\u26A1"
2084
+ title="Fast Refresh"
2085
+ description="Edit your code and see changes instantly. The dev server recompiles on save."
2086
+ />
2087
+ <FeatureCard
2088
+ icon="\uD83C\uDFA8"
2089
+ title="Tailwind Built-in"
2090
+ description="Style with utility classes out of the box. No config or build step needed."
2091
+ />
2092
+ </div>
2093
+ </main>
2094
+ }
2095
+
2096
+ component AboutPage {
2097
+ <main class="max-w-5xl mx-auto px-6 py-12">
2098
+ <h2 class="text-3xl font-bold text-gray-900 mb-6">"About"</h2>
2099
+ <div class="bg-white rounded-xl border border-gray-200 p-8 space-y-4">
2100
+ <p class="text-gray-600 leading-relaxed">"${name} is a full-stack application built with Tova — a modern language that compiles to JavaScript."</p>
2101
+ <p class="text-gray-600 leading-relaxed">"It uses shared types between server and browser, server-side RPC, and client-side routing."</p>
2102
+ </div>
2103
+ <div class="mt-8">
2104
+ <Link href="/" class="text-indigo-600 hover:text-indigo-700 font-medium no-underline">"\u2190 Back to home"</Link>
2105
+ </div>
2106
+ </main>
2107
+ }
2108
+
2109
+ component NotFoundPage {
2110
+ <div class="max-w-5xl mx-auto px-6 py-16 text-center">
2111
+ <h1 class="text-6xl font-bold text-gray-200 mb-4">"404"</h1>
2112
+ <p class="text-lg text-gray-500 mb-6">"Page not found"</p>
2113
+ <Link href="/" class="text-indigo-600 hover:text-indigo-700 font-medium no-underline">"Go home"</Link>
2114
+ </div>
2115
+ }
2116
+
2117
+ // ─── Router setup ─────────────────────────────────────────
2118
+ createRouter({
2119
+ routes: {
2120
+ "/": HomePage,
2121
+ "/about": { component: AboutPage, meta: { title: "About" } },
2122
+ "404": NotFoundPage,
2123
+ },
2124
+ scroll: "auto",
2125
+ })
2126
+
1538
2127
  component App {
1539
2128
  <div class="min-h-screen bg-gradient-to-br from-slate-50 via-white to-indigo-50">
1540
- <nav class="border-b border-gray-100 bg-white/80 backdrop-blur-sm sticky top-0 z-10">
1541
- <div class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
1542
- <div class="flex items-center gap-2">
1543
- <div class="w-8 h-8 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-lg"></div>
1544
- <span class="font-bold text-gray-900 text-lg">"${name}"</span>
2129
+ <NavBar />
2130
+ <Router />
2131
+ <div class="border-t border-gray-100 py-8 text-center">
2132
+ <p class="text-sm text-gray-400">"Built with " <a href="https://github.com/tova-lang/tova-lang" class="text-indigo-500 hover:text-indigo-600 transition-colors">"Tova"</a></p>
2133
+ </div>
2134
+ </div>
2135
+ }
2136
+ }
2137
+ `,
2138
+ nextSteps: name => ` cd ${name}\n tova dev`,
2139
+ },
2140
+ spa: {
2141
+ label: 'Single-page app',
2142
+ description: 'browser-only app with routing',
2143
+ tomlDescription: 'A Tova single-page application',
2144
+ entry: 'src',
2145
+ file: 'src/app.tova',
2146
+ content: name => `// ${name} — Built with Tova
2147
+ // Demonstrates: createRouter, Link, Router, Outlet, navigate(),
2148
+ // dynamic :param routes, nested routes, route meta, 404 handling
2149
+
2150
+ browser {
2151
+ // ─── Navigation bar with active link highlighting ─────────
2152
+ component NavBar {
2153
+ <nav class="bg-white border-b border-gray-100 sticky top-0 z-10">
2154
+ <div class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
2155
+ <Link href="/" class="flex items-center gap-2 no-underline">
2156
+ <div class="w-8 h-8 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-lg"></div>
2157
+ <span class="font-bold text-gray-900 text-lg">"${name}"</span>
2158
+ </Link>
2159
+ <div class="flex items-center gap-6">
2160
+ <Link href="/" exactActiveClass="text-indigo-600 font-semibold" class="text-sm font-medium transition-colors text-gray-500 hover:text-gray-900">"Home"</Link>
2161
+ <Link href="/users" activeClass="text-indigo-600 font-semibold" class="text-sm font-medium transition-colors text-gray-500 hover:text-gray-900">"Users"</Link>
2162
+ <Link href="/settings" activeClass="text-indigo-600 font-semibold" class="text-sm font-medium transition-colors text-gray-500 hover:text-gray-900">"Settings"</Link>
2163
+ </div>
2164
+ </div>
2165
+ </nav>
2166
+ }
2167
+
2168
+ // ─── Home page ────────────────────────────────────────────
2169
+ component HomePage {
2170
+ <div class="max-w-5xl mx-auto px-6 py-16 text-center">
2171
+ <div class="inline-flex items-center gap-2 bg-indigo-50 text-indigo-700 text-sm font-medium px-4 py-1.5 rounded-full mb-6">
2172
+ <span class="w-1.5 h-1.5 bg-indigo-500 rounded-full"></span>
2173
+ "Tova Router"
2174
+ </div>
2175
+ <h1 class="text-4xl font-bold text-gray-900 mb-4">"Welcome to " <span class="text-indigo-600">"${name}"</span></h1>
2176
+ <p class="text-lg text-gray-500 mb-8">"A single-page app with client-side routing. Click around to explore."</p>
2177
+ <div class="flex items-center justify-center gap-4">
2178
+ <Link href="/users" class="inline-block bg-indigo-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-indigo-700 transition-colors no-underline">"Browse Users"</Link>
2179
+ <Link href="/settings/profile" class="inline-block bg-white text-gray-700 border border-gray-200 px-6 py-3 rounded-lg font-medium hover:bg-gray-50 transition-colors no-underline">"Settings"</Link>
2180
+ </div>
2181
+ </div>
2182
+ }
2183
+
2184
+ // ─── Users list (demonstrates programmatic navigation) ────
2185
+ fn go_to_user(uid) {
2186
+ navigate("/users/{uid}")
2187
+ }
2188
+
2189
+ component UsersPage {
2190
+ <div class="max-w-5xl mx-auto px-6 py-12">
2191
+ <h2 class="text-2xl font-bold text-gray-900 mb-6">"Users"</h2>
2192
+ <div class="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
2193
+ <div class="flex items-center justify-between p-4 hover:bg-gray-50 cursor-pointer transition-colors" on:click={fn() go_to_user("1")}>
2194
+ <div class="flex items-center gap-3">
2195
+ <div class="w-9 h-9 bg-indigo-100 text-indigo-600 rounded-full flex items-center justify-center font-semibold text-sm">"A"</div>
2196
+ <div>
2197
+ <p class="font-medium text-gray-900">"alice"</p>
2198
+ <p class="text-xs text-gray-500">"Admin"</p>
2199
+ </div>
2200
+ </div>
2201
+ <span class="text-gray-400 text-sm">"View \u2192"</span>
2202
+ </div>
2203
+ <div class="flex items-center justify-between p-4 hover:bg-gray-50 cursor-pointer transition-colors" on:click={fn() go_to_user("2")}>
2204
+ <div class="flex items-center gap-3">
2205
+ <div class="w-9 h-9 bg-green-100 text-green-600 rounded-full flex items-center justify-center font-semibold text-sm">"B"</div>
2206
+ <div>
2207
+ <p class="font-medium text-gray-900">"bob"</p>
2208
+ <p class="text-xs text-gray-500">"Editor"</p>
2209
+ </div>
1545
2210
  </div>
1546
- <div class="flex items-center gap-4">
1547
- <a href="https://github.com/tova-lang/tova-lang" class="text-sm text-gray-500 hover:text-gray-900 transition-colors">"Docs"</a>
1548
- <a href="https://github.com/tova-lang/tova-lang" class="text-sm bg-gray-900 text-white px-4 py-2 rounded-lg hover:bg-gray-800 transition-colors">"GitHub"</a>
2211
+ <span class="text-gray-400 text-sm">"View \u2192"</span>
2212
+ </div>
2213
+ <div class="flex items-center justify-between p-4 hover:bg-gray-50 cursor-pointer transition-colors" on:click={fn() go_to_user("3")}>
2214
+ <div class="flex items-center gap-3">
2215
+ <div class="w-9 h-9 bg-purple-100 text-purple-600 rounded-full flex items-center justify-center font-semibold text-sm">"C"</div>
2216
+ <div>
2217
+ <p class="font-medium text-gray-900">"charlie"</p>
2218
+ <p class="text-xs text-gray-500">"Viewer"</p>
2219
+ </div>
1549
2220
  </div>
2221
+ <span class="text-gray-400 text-sm">"View \u2192"</span>
1550
2222
  </div>
1551
- </nav>
2223
+ </div>
2224
+ </div>
2225
+ }
1552
2226
 
1553
- <main class="max-w-5xl mx-auto px-6">
1554
- <div class="py-20 text-center">
1555
- <div class="inline-flex items-center gap-2 bg-indigo-50 text-indigo-700 text-sm font-medium px-4 py-1.5 rounded-full mb-6">
1556
- <span class="w-1.5 h-1.5 bg-indigo-500 rounded-full"></span>
1557
- "Powered by Tova"
2227
+ // ─── User detail (demonstrates :id dynamic route param) ───
2228
+ component UserPage(id) {
2229
+ <div class="max-w-5xl mx-auto px-6 py-12">
2230
+ <button on:click={fn() navigate("/users")} class="text-sm text-indigo-600 hover:text-indigo-700 mb-6 inline-flex items-center gap-1 cursor-pointer bg-transparent border-0">
2231
+ "\u2190 Back to users"
2232
+ </button>
2233
+ <div class="bg-white rounded-xl border border-gray-200 p-8">
2234
+ <div class="flex items-center gap-4 mb-6">
2235
+ <div class="w-14 h-14 bg-indigo-100 text-indigo-600 rounded-full flex items-center justify-center font-bold text-xl">
2236
+ "#{id}"
2237
+ </div>
2238
+ <div>
2239
+ <h2 class="text-2xl font-bold text-gray-900">"User {id}"</h2>
2240
+ <span class="text-sm text-gray-500">"Dynamic route parameter: " <code class="bg-gray-100 text-indigo-600 px-1.5 py-0.5 rounded text-xs">":id = {id}"</code></span>
1558
2241
  </div>
1559
- <h1 class="text-5xl font-bold text-gray-900 tracking-tight mb-4">"Welcome to " <span class="bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">"${name}"</span></h1>
1560
- <p class="text-xl text-gray-500 max-w-2xl mx-auto mb-10">"A modern full-stack app. Edit " <code class="text-sm bg-gray-100 text-indigo-600 px-2 py-1 rounded-md font-mono">"src/app.tova"</code> " to get started."</p>
2242
+ </div>
2243
+ <p class="text-gray-600">"This page receives " <code class="bg-gray-100 text-indigo-600 px-1.5 py-0.5 rounded text-xs">"id"</code> " from the route " <code class="bg-gray-100 text-indigo-600 px-1.5 py-0.5 rounded text-xs">"/users/:id"</code> " pattern."</p>
2244
+ </div>
2245
+ </div>
2246
+ }
1561
2247
 
1562
- <div class="inline-flex items-center gap-3 bg-white border border-gray-200 rounded-2xl p-2 shadow-sm">
1563
- <div class="bg-gradient-to-r from-indigo-500 to-purple-500 text-white px-5 py-2.5 rounded-xl font-medium">
1564
- "{message}"
1565
- </div>
1566
- <button
1567
- on:click={handle_refresh}
1568
- class="px-4 py-2.5 text-gray-500 hover:text-indigo-600 hover:bg-indigo-50 rounded-xl transition-all font-medium text-sm"
1569
- >
1570
- if refreshing {
1571
- "..."
1572
- } else {
1573
- "Refresh"
1574
- }
1575
- </button>
2248
+ // ─── Settings layout with nested routes + Outlet ──────────
2249
+ component SettingsLayout {
2250
+ <div class="max-w-5xl mx-auto px-6 py-12">
2251
+ <h2 class="text-2xl font-bold text-gray-900 mb-6">"Settings"</h2>
2252
+ <div class="flex gap-8">
2253
+ <aside class="w-48 flex-shrink-0">
2254
+ <div class="flex flex-col gap-1">
2255
+ <Link href="/settings/profile" activeClass="bg-indigo-50 text-indigo-700" class="block px-3 py-2 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-50 no-underline transition-colors">"Profile"</Link>
2256
+ <Link href="/settings/account" activeClass="bg-indigo-50 text-indigo-700" class="block px-3 py-2 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-50 no-underline transition-colors">"Account"</Link>
1576
2257
  </div>
1577
- if timestamp != "" {
1578
- <p class="text-xs text-gray-400 mt-3">"Last fetched at " "{timestamp}"</p>
1579
- }
2258
+ </aside>
2259
+ <div class="flex-1 min-w-0">
2260
+ <Outlet />
1580
2261
  </div>
2262
+ </div>
2263
+ </div>
2264
+ }
1581
2265
 
1582
- <div class="grid grid-cols-1 md:grid-cols-3 gap-5 pb-20">
1583
- <FeatureCard
1584
- icon="&#9881;"
1585
- title="Full-Stack"
1586
- description="Server and client in one file. Shared types, RPC calls, and reactive UI — all type-safe."
1587
- />
1588
- <FeatureCard
1589
- icon="&#9889;"
1590
- title="Fast Refresh"
1591
- description="Edit your code and see changes instantly. The dev server recompiles on save."
1592
- />
1593
- <FeatureCard
1594
- icon="&#127912;"
1595
- title="Tailwind Built-in"
1596
- description="Style with utility classes out of the box. No config or build step needed."
1597
- />
2266
+ component ProfileSettings {
2267
+ <div class="bg-white rounded-xl border border-gray-200 p-6">
2268
+ <h3 class="text-lg font-semibold text-gray-900 mb-4">"Profile Settings"</h3>
2269
+ <p class="text-gray-600 mb-4">"This is a nested child route rendered via " <code class="bg-gray-100 text-indigo-600 px-1.5 py-0.5 rounded text-xs">"Outlet"</code> " inside SettingsLayout."</p>
2270
+ <div class="space-y-4">
2271
+ <div>
2272
+ <label class="block text-sm font-medium text-gray-700 mb-1">"Display Name"</label>
2273
+ <div class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm text-gray-900">"Alice"</div>
2274
+ </div>
2275
+ <div>
2276
+ <label class="block text-sm font-medium text-gray-700 mb-1">"Bio"</label>
2277
+ <div class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm text-gray-500">"Tova developer"</div>
1598
2278
  </div>
2279
+ </div>
2280
+ </div>
2281
+ }
1599
2282
 
1600
- <div class="border-t border-gray-100 py-8 text-center">
1601
- <p class="text-sm text-gray-400">"Built with " <a href="https://github.com/tova-lang/tova-lang" class="text-indigo-500 hover:text-indigo-600 transition-colors">"Tova"</a></p>
2283
+ component AccountSettings {
2284
+ <div class="bg-white rounded-xl border border-gray-200 p-6">
2285
+ <h3 class="text-lg font-semibold text-gray-900 mb-4">"Account Settings"</h3>
2286
+ <p class="text-gray-600 mb-4">"Another nested child of " <code class="bg-gray-100 text-indigo-600 px-1.5 py-0.5 rounded text-xs">"/settings"</code> "."</p>
2287
+ <div class="space-y-4">
2288
+ <div>
2289
+ <label class="block text-sm font-medium text-gray-700 mb-1">"Email"</label>
2290
+ <div class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm text-gray-900">"alice@example.com"</div>
1602
2291
  </div>
2292
+ <div class="pt-4 border-t border-gray-100">
2293
+ <button class="text-sm text-red-600 hover:text-red-700 font-medium cursor-pointer bg-transparent border-0">"Delete Account"</button>
2294
+ </div>
2295
+ </div>
2296
+ </div>
2297
+ }
2298
+
2299
+ // ─── 404 page ─────────────────────────────────────────────
2300
+ component NotFoundPage {
2301
+ <div class="max-w-5xl mx-auto px-6 py-16 text-center">
2302
+ <h1 class="text-6xl font-bold text-gray-200 mb-4">"404"</h1>
2303
+ <p class="text-lg text-gray-500 mb-6">"Page not found"</p>
2304
+ <Link href="/" class="text-indigo-600 hover:text-indigo-700 font-medium no-underline">"Go home"</Link>
2305
+ </div>
2306
+ }
2307
+
2308
+ // ─── Router setup ─────────────────────────────────────────
2309
+ createRouter({
2310
+ routes: {
2311
+ "/": HomePage,
2312
+ "/users": { component: UsersPage, meta: { title: "Users" } },
2313
+ "/users/:id": { component: UserPage, meta: { title: "User Detail" } },
2314
+ "/settings": {
2315
+ component: SettingsLayout,
2316
+ children: {
2317
+ "/profile": { component: ProfileSettings, meta: { title: "Profile" } },
2318
+ "/account": { component: AccountSettings, meta: { title: "Account" } },
2319
+ },
2320
+ },
2321
+ "404": NotFoundPage,
2322
+ },
2323
+ scroll: "auto",
2324
+ })
2325
+
2326
+ // ─── Update document title from route meta ────────────────
2327
+ afterNavigate(fn(current) {
2328
+ if current.meta != undefined {
2329
+ if current.meta.title != undefined {
2330
+ document.title = "{current.meta.title} | ${name}"
2331
+ }
2332
+ }
2333
+ })
2334
+
2335
+ component App {
2336
+ <div class="min-h-screen bg-gray-50">
2337
+ <NavBar />
2338
+ <Router />
2339
+ </div>
2340
+ }
2341
+ }
2342
+ `,
2343
+ nextSteps: name => ` cd ${name}\n tova dev`,
2344
+ },
2345
+ site: {
2346
+ label: 'Static site',
2347
+ description: 'docs or marketing site with pages',
2348
+ tomlDescription: 'A Tova static site',
2349
+ entry: 'src',
2350
+ file: 'src/app.tova',
2351
+ extraFiles: [
2352
+ {
2353
+ path: 'src/pages/home.tova',
2354
+ content: name => `pub component HomePage {
2355
+ <div class="max-w-4xl mx-auto px-6 py-16">
2356
+ <h1 class="text-4xl font-bold text-gray-900 mb-4">"Welcome to ${name}"</h1>
2357
+ <p class="text-lg text-gray-600 mb-8">"A static site built with Tova. Fast, simple, and easy to deploy anywhere."</p>
2358
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
2359
+ <div class="bg-white rounded-xl border border-gray-200 p-6">
2360
+ <h3 class="font-semibold text-gray-900 mb-2">"Fast by default"</h3>
2361
+ <p class="text-gray-500 text-sm">"Client-side routing for smooth, instant navigation between pages."</p>
2362
+ </div>
2363
+ <div class="bg-white rounded-xl border border-gray-200 p-6">
2364
+ <h3 class="font-semibold text-gray-900 mb-2">"Deploy anywhere"</h3>
2365
+ <p class="text-gray-500 text-sm">"GitHub Pages, Netlify, Vercel, Firebase — works with any static host."</p>
2366
+ </div>
2367
+ </div>
2368
+ </div>
2369
+ }
2370
+ `,
2371
+ },
2372
+ {
2373
+ path: 'src/pages/docs.tova',
2374
+ content: name => `pub component DocsPage {
2375
+ <div class="max-w-4xl mx-auto px-6 py-12">
2376
+ <h1 class="text-3xl font-bold text-gray-900 mb-6">"Documentation"</h1>
2377
+ <div class="prose">
2378
+ <h2 class="text-xl font-semibold text-gray-900 mt-8 mb-3">"Getting Started"</h2>
2379
+ <p class="text-gray-600 mb-4">"Add your documentation content here. Each page is a Tova component with its own route."</p>
2380
+ <h2 class="text-xl font-semibold text-gray-900 mt-8 mb-3">"Adding Pages"</h2>
2381
+ <p class="text-gray-600 mb-4">"Create a new file in " <code class="bg-gray-100 text-indigo-600 px-1.5 py-0.5 rounded text-sm">"src/pages/"</code> " and add a route in " <code class="bg-gray-100 text-indigo-600 px-1.5 py-0.5 rounded text-sm">"src/app.tova"</code> "."</p>
2382
+ </div>
2383
+ </div>
2384
+ }
2385
+ `,
2386
+ },
2387
+ {
2388
+ path: 'src/pages/about.tova',
2389
+ content: name => `pub component AboutPage {
2390
+ <div class="max-w-4xl mx-auto px-6 py-12">
2391
+ <h1 class="text-3xl font-bold text-gray-900 mb-6">"About"</h1>
2392
+ <p class="text-gray-600">"This site was built with Tova — a modern programming language that compiles to JavaScript."</p>
2393
+ </div>
2394
+ }
2395
+ `,
2396
+ },
2397
+ ],
2398
+ content: name => `// ${name} — Built with Tova
2399
+ import { HomePage } from "./pages/home"
2400
+ import { DocsPage } from "./pages/docs"
2401
+ import { AboutPage } from "./pages/about"
2402
+
2403
+ browser {
2404
+ component SiteNav {
2405
+ <header class="bg-white border-b border-gray-100 sticky top-0 z-10">
2406
+ <div class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
2407
+ <Link href="/" class="flex items-center gap-2 no-underline">
2408
+ <div class="w-7 h-7 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-lg"></div>
2409
+ <span class="font-bold text-gray-900">"${name}"</span>
2410
+ </Link>
2411
+ <nav class="flex items-center gap-6">
2412
+ <Link href="/" exactActiveClass="text-indigo-600 font-semibold" class="text-sm font-medium transition-colors no-underline text-gray-500 hover:text-gray-900">"Home"</Link>
2413
+ <Link href="/docs" activeClass="text-indigo-600 font-semibold" class="text-sm font-medium transition-colors no-underline text-gray-500 hover:text-gray-900">"Docs"</Link>
2414
+ <Link href="/about" activeClass="text-indigo-600 font-semibold" class="text-sm font-medium transition-colors no-underline text-gray-500 hover:text-gray-900">"About"</Link>
2415
+ </nav>
2416
+ </div>
2417
+ </header>
2418
+ }
2419
+
2420
+ component NotFoundPage {
2421
+ <div class="max-w-4xl mx-auto px-6 py-16 text-center">
2422
+ <h1 class="text-6xl font-bold text-gray-200 mb-4">"404"</h1>
2423
+ <p class="text-lg text-gray-500 mb-6">"Page not found"</p>
2424
+ <Link href="/" class="text-indigo-600 hover:text-indigo-700 font-medium no-underline">"Go home"</Link>
2425
+ </div>
2426
+ }
2427
+
2428
+ createRouter({
2429
+ routes: {
2430
+ "/": HomePage,
2431
+ "/docs": { component: DocsPage, meta: { title: "Documentation" } },
2432
+ "/about": { component: AboutPage, meta: { title: "About" } },
2433
+ "404": NotFoundPage,
2434
+ },
2435
+ scroll: "auto",
2436
+ })
2437
+
2438
+ // Update document title from route meta
2439
+ afterNavigate(fn(current) {
2440
+ if current.meta != undefined {
2441
+ if current.meta.title != undefined {
2442
+ document.title = "{current.meta.title} | ${name}"
2443
+ }
2444
+ }
2445
+ })
2446
+
2447
+ component App {
2448
+ <div class="min-h-screen bg-gray-50">
2449
+ <SiteNav />
2450
+ <main>
2451
+ <Router />
1603
2452
  </main>
2453
+ <footer class="border-t border-gray-100 py-8 text-center">
2454
+ <p class="text-sm text-gray-400">"Built with Tova"</p>
2455
+ </footer>
1604
2456
  </div>
1605
2457
  }
1606
2458
  }
@@ -1644,12 +2496,20 @@ print("Hello, {name}!")
1644
2496
  tomlDescription: 'A Tova library',
1645
2497
  entry: 'src',
1646
2498
  noEntry: true,
2499
+ isPackage: true,
1647
2500
  file: 'src/lib.tova',
1648
2501
  content: name => `// ${name} — A Tova library
2502
+ //
2503
+ // Usage:
2504
+ // import { greet } from "github.com/yourname/${name}"
1649
2505
 
1650
2506
  pub fn greet(name: String) -> String {
1651
2507
  "Hello, {name}!"
1652
2508
  }
2509
+
2510
+ pub fn version() -> String {
2511
+ "0.1.0"
2512
+ }
1653
2513
  `,
1654
2514
  nextSteps: name => ` cd ${name}\n tova build`,
1655
2515
  },
@@ -1664,7 +2524,7 @@ pub fn greet(name: String) -> String {
1664
2524
  },
1665
2525
  };
1666
2526
 
1667
- const TEMPLATE_ORDER = ['fullstack', 'api', 'script', 'library', 'blank'];
2527
+ const TEMPLATE_ORDER = ['fullstack', 'spa', 'site', 'api', 'script', 'library', 'blank'];
1668
2528
 
1669
2529
  async function newProject(rawArgs) {
1670
2530
  const name = rawArgs.find(a => !a.startsWith('-'));
@@ -1681,11 +2541,12 @@ async function newProject(rawArgs) {
1681
2541
 
1682
2542
  if (!name) {
1683
2543
  console.error(color.red('Error: No project name specified'));
1684
- console.error('Usage: tova new <project-name> [--template fullstack|api|script|library|blank]');
2544
+ console.error('Usage: tova new <project-name> [--template fullstack|spa|site|api|script|library|blank]');
1685
2545
  process.exit(1);
1686
2546
  }
1687
2547
 
1688
2548
  const projectDir = resolve(name);
2549
+ const projectName = basename(projectDir);
1689
2550
  if (existsSync(projectDir)) {
1690
2551
  console.error(color.red(`Error: Directory '${name}' already exists`));
1691
2552
  process.exit(1);
@@ -1737,24 +2598,49 @@ async function newProject(rawArgs) {
1737
2598
  const createdFiles = [];
1738
2599
 
1739
2600
  // tova.toml
1740
- const tomlConfig = {
1741
- project: {
1742
- name,
1743
- version: '0.1.0',
1744
- description: template.tomlDescription,
1745
- },
1746
- build: {
1747
- output: '.tova-out',
1748
- },
1749
- };
1750
- if (!template.noEntry) {
1751
- tomlConfig.project.entry = template.entry;
1752
- }
1753
- if (templateName === 'fullstack' || templateName === 'api') {
1754
- tomlConfig.dev = { port: 3000 };
1755
- tomlConfig.npm = {};
2601
+ let tomlContent;
2602
+ if (template.isPackage) {
2603
+ // Library packages use [package] section per package management design
2604
+ tomlContent = [
2605
+ '[package]',
2606
+ `name = "github.com/yourname/${projectName}"`,
2607
+ `version = "0.1.0"`,
2608
+ `description = "${template.tomlDescription}"`,
2609
+ `license = "MIT"`,
2610
+ `exports = ["greet", "version"]`,
2611
+ '',
2612
+ '[build]',
2613
+ 'output = ".tova-out"',
2614
+ '',
2615
+ '[dependencies]',
2616
+ '',
2617
+ '[npm]',
2618
+ '',
2619
+ ].join('\n') + '\n';
2620
+ } else {
2621
+ const tomlConfig = {
2622
+ project: {
2623
+ name: projectName,
2624
+ version: '0.1.0',
2625
+ description: template.tomlDescription,
2626
+ },
2627
+ build: {
2628
+ output: '.tova-out',
2629
+ },
2630
+ };
2631
+ if (!template.noEntry) {
2632
+ tomlConfig.project.entry = template.entry;
2633
+ }
2634
+ if (templateName === 'fullstack' || templateName === 'api' || templateName === 'spa' || templateName === 'site') {
2635
+ tomlConfig.dev = { port: 3000 };
2636
+ tomlConfig.npm = {};
2637
+ }
2638
+ if (templateName === 'spa' || templateName === 'site') {
2639
+ tomlConfig.deploy = { base: '/' };
2640
+ }
2641
+ tomlContent = stringifyTOML(tomlConfig);
1756
2642
  }
1757
- writeFileSync(join(projectDir, 'tova.toml'), stringifyTOML(tomlConfig));
2643
+ writeFileSync(join(projectDir, 'tova.toml'), tomlContent);
1758
2644
  createdFiles.push('tova.toml');
1759
2645
 
1760
2646
  // .gitignore
@@ -1770,12 +2656,22 @@ bun.lock
1770
2656
 
1771
2657
  // Template source file
1772
2658
  if (template.file && template.content) {
1773
- writeFileSync(join(projectDir, template.file), template.content(name));
2659
+ writeFileSync(join(projectDir, template.file), template.content(projectName));
1774
2660
  createdFiles.push(template.file);
1775
2661
  }
1776
2662
 
2663
+ // Extra files (e.g., page components for site template)
2664
+ if (template.extraFiles) {
2665
+ for (const extra of template.extraFiles) {
2666
+ const extraPath = join(projectDir, extra.path);
2667
+ mkdirSync(dirname(extraPath), { recursive: true });
2668
+ writeFileSync(extraPath, extra.content(projectName));
2669
+ createdFiles.push(extra.path);
2670
+ }
2671
+ }
2672
+
1777
2673
  // README
1778
- writeFileSync(join(projectDir, 'README.md'), `# ${name}
2674
+ let readmeContent = `# ${projectName}
1779
2675
 
1780
2676
  Built with [Tova](https://github.com/tova-lang/tova-lang) — a modern full-stack language.
1781
2677
 
@@ -1784,7 +2680,34 @@ Built with [Tova](https://github.com/tova-lang/tova-lang) — a modern full-stac
1784
2680
  \`\`\`bash
1785
2681
  ${template.nextSteps(name).trim()}
1786
2682
  \`\`\`
1787
- `);
2683
+ `;
2684
+ if (template.isPackage) {
2685
+ readmeContent += `
2686
+ ## Usage
2687
+
2688
+ \`\`\`tova
2689
+ import { greet } from "github.com/yourname/${projectName}"
2690
+
2691
+ print(greet("world"))
2692
+ \`\`\`
2693
+
2694
+ ## Publishing
2695
+
2696
+ Tag a release and push — no registry needed:
2697
+
2698
+ \`\`\`bash
2699
+ git tag v0.1.0
2700
+ git push origin v0.1.0
2701
+ \`\`\`
2702
+
2703
+ Others can then add your package:
2704
+
2705
+ \`\`\`bash
2706
+ tova add github.com/yourname/${projectName}
2707
+ \`\`\`
2708
+ `;
2709
+ }
2710
+ writeFileSync(join(projectDir, 'README.md'), readmeContent);
1788
2711
  createdFiles.push('README.md');
1789
2712
 
1790
2713
  // Print created files
@@ -1875,7 +2798,7 @@ server {
1875
2798
  route GET "/api/message" => get_message
1876
2799
  }
1877
2800
 
1878
- client {
2801
+ browser {
1879
2802
  state message = ""
1880
2803
 
1881
2804
  effect {
@@ -1912,6 +2835,70 @@ async function installDeps() {
1912
2835
  return;
1913
2836
  }
1914
2837
 
2838
+ // Resolve Tova module dependencies (if any)
2839
+ const tovaDeps = config.dependencies || {};
2840
+ const { isTovModule: _isTovMod, expandBlessedPackage: _expandBlessed } = await import('../src/config/module-path.js');
2841
+
2842
+ // Expand blessed package shorthands (e.g., tova/data → github.com/tova-lang/data)
2843
+ const expandedTovaDeps = {};
2844
+ for (const [k, v] of Object.entries(tovaDeps)) {
2845
+ const expanded = _expandBlessed(k);
2846
+ expandedTovaDeps[expanded || k] = v;
2847
+ }
2848
+
2849
+ const tovModuleKeys = Object.keys(expandedTovaDeps).filter(k => _isTovMod(k));
2850
+
2851
+ if (tovModuleKeys.length > 0) {
2852
+ const { resolveDependencies } = await import('../src/config/resolver.js');
2853
+ const { listRemoteTags, fetchModule, getCommitSha } = await import('../src/config/git-resolver.js');
2854
+ const { isVersionCached, getModuleCachePath } = await import('../src/config/module-cache.js');
2855
+ const { readLockFile, writeLockFile } = await import('../src/config/lock-file.js');
2856
+
2857
+ console.log(' Resolving Tova dependencies...');
2858
+
2859
+ const lock = readLockFile(cwd);
2860
+ const tovaModuleDeps = {};
2861
+ for (const k of tovModuleKeys) {
2862
+ tovaModuleDeps[k] = expandedTovaDeps[k];
2863
+ }
2864
+
2865
+ try {
2866
+ const { resolved, npmDeps } = await resolveDependencies(tovaModuleDeps, {
2867
+ getAvailableVersions: async (mod) => {
2868
+ if (lock?.modules?.[mod]) return [lock.modules[mod].version];
2869
+ const tags = await listRemoteTags(mod);
2870
+ return tags.map(t => t.version);
2871
+ },
2872
+ getModuleConfig: async (mod, version) => {
2873
+ if (!isVersionCached(mod, version)) {
2874
+ console.log(` Fetching ${mod}@v${version}...`);
2875
+ await fetchModule(mod, version);
2876
+ }
2877
+ const modPath = getModuleCachePath(mod, version);
2878
+ try {
2879
+ return resolveConfig(modPath);
2880
+ } catch { return null; }
2881
+ },
2882
+ getVersionSha: async (mod, version) => {
2883
+ if (lock?.modules?.[mod]?.sha) return lock.modules[mod].sha;
2884
+ return await getCommitSha(mod, version);
2885
+ },
2886
+ });
2887
+
2888
+ writeLockFile(cwd, resolved, npmDeps);
2889
+ console.log(` Resolved ${Object.keys(resolved).length} Tova module(s)`);
2890
+
2891
+ // Merge transitive npm deps into config for package.json generation
2892
+ if (Object.keys(npmDeps).length > 0) {
2893
+ if (!config.npm) config.npm = {};
2894
+ if (!config.npm.prod) config.npm.prod = {};
2895
+ Object.assign(config.npm.prod, npmDeps);
2896
+ }
2897
+ } catch (err) {
2898
+ console.error(` Failed to resolve Tova dependencies: ${err.message}`);
2899
+ }
2900
+ }
2901
+
1915
2902
  // Generate shadow package.json from tova.toml
1916
2903
  const wrote = writePackageJson(config, cwd);
1917
2904
  if (wrote) {
@@ -1920,7 +2907,9 @@ async function installDeps() {
1920
2907
  const code = await new Promise(res => proc.on('close', res));
1921
2908
  process.exit(code);
1922
2909
  } else {
1923
- console.log(' No npm dependencies in tova.toml. Nothing to install.\n');
2910
+ if (tovModuleKeys.length === 0) {
2911
+ console.log(' No npm dependencies in tova.toml. Nothing to install.\n');
2912
+ }
1924
2913
  }
1925
2914
  }
1926
2915
 
@@ -1985,21 +2974,60 @@ async function addDep(args) {
1985
2974
  await installDeps();
1986
2975
  } else {
1987
2976
  // Tova native dependency
1988
- let name = actualPkg;
1989
- let source = actualPkg;
2977
+ const { isTovModule: isTovMod, expandBlessedPackage } = await import('../src/config/module-path.js');
2978
+
2979
+ // Parse potential @version suffix
2980
+ let pkgName = actualPkg;
2981
+ let versionConstraint = null;
2982
+ if (pkgName.includes('@') && !pkgName.startsWith('@')) {
2983
+ const atIdx = pkgName.lastIndexOf('@');
2984
+ versionConstraint = pkgName.slice(atIdx + 1);
2985
+ pkgName = pkgName.slice(0, atIdx);
2986
+ }
2987
+
2988
+ // Expand blessed package shorthand: tova/data → github.com/tova-lang/data
2989
+ const expandedPkg = expandBlessedPackage(pkgName);
2990
+ const resolvedPkg = expandedPkg || pkgName;
2991
+
2992
+ if (isTovMod(pkgName)) {
2993
+ // Tova module: fetch tags, pick version, add to [dependencies]
2994
+ const { listRemoteTags, pickLatestTag } = await import('../src/config/git-resolver.js');
2995
+ try {
2996
+ const tags = await listRemoteTags(resolvedPkg);
2997
+ if (tags.length === 0) {
2998
+ console.error(` No version tags found for ${resolvedPkg}`);
2999
+ process.exit(1);
3000
+ }
3001
+ if (!versionConstraint) {
3002
+ const latest = pickLatestTag(tags);
3003
+ versionConstraint = `^${latest.version}`;
3004
+ }
3005
+ addToSection(tomlPath, 'dependencies', `"${resolvedPkg}"`, versionConstraint);
3006
+ console.log(` Added ${resolvedPkg}@${versionConstraint} to [dependencies] in tova.toml`);
3007
+ await installDeps();
3008
+ } catch (err) {
3009
+ console.error(` Failed to add ${pkgName}: ${err.message}`);
3010
+ process.exit(1);
3011
+ }
3012
+ return;
3013
+ }
3014
+
3015
+ // Local path or generic dependency
3016
+ let name = pkgName;
3017
+ let source = pkgName;
1990
3018
 
1991
3019
  // Detect source type
1992
- if (actualPkg.startsWith('file:') || actualPkg.startsWith('./') || actualPkg.startsWith('../') || actualPkg.startsWith('/')) {
3020
+ if (pkgName.startsWith('file:') || pkgName.startsWith('./') || pkgName.startsWith('../') || pkgName.startsWith('/')) {
1993
3021
  // Local path dependency
1994
- source = actualPkg.startsWith('file:') ? actualPkg : `file:${actualPkg}`;
1995
- name = basename(actualPkg.replace(/^file:/, ''));
1996
- } else if (actualPkg.startsWith('git:') || actualPkg.includes('github.com/') || actualPkg.includes('.git')) {
3022
+ source = pkgName.startsWith('file:') ? pkgName : `file:${pkgName}`;
3023
+ name = basename(pkgName.replace(/^file:/, ''));
3024
+ } else if (pkgName.startsWith('git:') || pkgName.includes('.git')) {
1997
3025
  // Git dependency
1998
- source = actualPkg.startsWith('git:') ? actualPkg : `git:${actualPkg}`;
1999
- name = basename(actualPkg.replace(/\.git$/, '').replace(/^git:/, ''));
3026
+ source = pkgName.startsWith('git:') ? pkgName : `git:${pkgName}`;
3027
+ name = basename(pkgName.replace(/\.git$/, '').replace(/^git:/, ''));
2000
3028
  } else {
2001
3029
  // Tova registry package (future: for now, just store the name)
2002
- source = `*`;
3030
+ source = versionConstraint || `*`;
2003
3031
  }
2004
3032
 
2005
3033
  addToSection(tomlPath, 'dependencies', name, source);
@@ -2828,6 +3856,26 @@ async function startRepl() {
2828
3856
  const declaredInCode = new Set();
2829
3857
  for (const m of code.matchAll(/\bfunction\s+([a-zA-Z_]\w*)/g)) { declaredInCode.add(m[1]); userDefinedNames.add(m[1]); }
2830
3858
  for (const m of code.matchAll(/\bconst\s+([a-zA-Z_]\w*)/g)) { declaredInCode.add(m[1]); userDefinedNames.add(m[1]); }
3859
+ // Extract destructured names: const { a, b } = ... or const [ a, b ] = ...
3860
+ for (const m of code.matchAll(/\bconst\s+\{\s*([^}]+)\}/g)) {
3861
+ for (const part of m[1].split(',')) {
3862
+ const trimmed = part.trim();
3863
+ if (!trimmed) continue;
3864
+ // Handle renaming: "key: alias" or "key: alias = default" — extract the alias
3865
+ const colonMatch = trimmed.match(/^\w+\s*:\s*([a-zA-Z_]\w*)/);
3866
+ const name = colonMatch ? colonMatch[1] : trimmed.match(/^([a-zA-Z_]\w*)/)?.[1];
3867
+ if (name) { declaredInCode.add(name); userDefinedNames.add(name); }
3868
+ }
3869
+ }
3870
+ for (const m of code.matchAll(/\bconst\s+\[\s*([^\]]+)\]/g)) {
3871
+ for (const part of m[1].split(',')) {
3872
+ const trimmed = part.trim();
3873
+ if (!trimmed) continue;
3874
+ const name = trimmed.startsWith('...') ? trimmed.slice(3).trim() : trimmed;
3875
+ const id = name.match(/^([a-zA-Z_]\w*)/)?.[1];
3876
+ if (id) { declaredInCode.add(id); userDefinedNames.add(id); }
3877
+ }
3878
+ }
2831
3879
  for (const m of code.matchAll(/\blet\s+([a-zA-Z_]\w*)/g)) {
2832
3880
  declaredInCode.add(m[1]);
2833
3881
  userDefinedNames.add(m[1]);
@@ -3124,7 +4172,11 @@ async function binaryBuild(srcDir, outputName, outDir) {
3124
4172
 
3125
4173
  // ─── Production Build ────────────────────────────────────────
3126
4174
 
3127
- async function productionBuild(srcDir, outDir) {
4175
+ async function productionBuild(srcDir, outDir, isStatic = false) {
4176
+ const config = resolveConfig(process.cwd());
4177
+ const basePath = config.deploy?.base || '/';
4178
+ const base = basePath.endsWith('/') ? basePath : basePath + '/';
4179
+
3128
4180
  const tovaFiles = findFiles(srcDir, '.tova');
3129
4181
  if (tovaFiles.length === 0) {
3130
4182
  console.error('No .tova files found');
@@ -3169,6 +4221,9 @@ async function productionBuild(srcDir, outDir) {
3169
4221
  const serverPath = join(outDir, `server.${hash}.js`);
3170
4222
  writeFileSync(serverPath, serverBundle);
3171
4223
  console.log(` server.${hash}.js`);
4224
+
4225
+ // Write stable server.js entrypoint for Docker/deployment
4226
+ writeFileSync(join(outDir, 'server.js'), `import "./server.${hash}.js";\n`);
3172
4227
  }
3173
4228
 
3174
4229
  // Write script bundle for plain scripts (no server/client blocks)
@@ -3196,7 +4251,9 @@ async function productionBuild(srcDir, outDir) {
3196
4251
  // No npm imports — inline runtime, strip all imports
3197
4252
  const reactivityCode = REACTIVITY_SOURCE.replace(/^export /gm, '');
3198
4253
  const rpcCode = RPC_SOURCE.replace(/^export /gm, '');
3199
- clientBundle = reactivityCode + '\n' + rpcCode + '\n' + allSharedCode + '\n' +
4254
+ const usesRouter = /\b(defineRoutes|Router|getPath|getQuery|getParams|getCurrentRoute|navigate|onRouteChange|beforeNavigate|afterNavigate|Outlet|Link|Redirect)\b/.test(allClientCode);
4255
+ const routerCode = usesRouter ? ROUTER_SOURCE.replace(/^export /gm, '').replace(/^\s*import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]+['"];?\s*$/gm, '') : '';
4256
+ clientBundle = reactivityCode + '\n' + rpcCode + '\n' + (routerCode ? routerCode + '\n' : '') + allSharedCode + '\n' +
3200
4257
  allClientCode.replace(/^\s*import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]+['"];?\s*$/gm, '').trim();
3201
4258
  }
3202
4259
 
@@ -3207,14 +4264,19 @@ async function productionBuild(srcDir, outDir) {
3207
4264
 
3208
4265
  // Generate production HTML
3209
4266
  const scriptTag = useModule
3210
- ? `<script type="module" src="client.${hash}.js"></script>`
3211
- : `<script src="client.${hash}.js"></script>`;
4267
+ ? `<script type="module" src="${base}.tova-out/client.${hash}.js"></script>`
4268
+ : `<script src="${base}.tova-out/client.${hash}.js"></script>`;
3212
4269
  const html = `<!DOCTYPE html>
3213
4270
  <html lang="en">
3214
4271
  <head>
3215
4272
  <meta charset="UTF-8">
3216
4273
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
3217
4274
  <title>Tova App</title>
4275
+ <script src="https://cdn.tailwindcss.com"></script>
4276
+ <style>
4277
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
4278
+ body { font-family: system-ui, -apple-system, sans-serif; }
4279
+ </style>
3218
4280
  </head>
3219
4281
  <body>
3220
4282
  <div id="app"></div>
@@ -3223,6 +4285,12 @@ async function productionBuild(srcDir, outDir) {
3223
4285
  </html>`;
3224
4286
  writeFileSync(join(outDir, 'index.html'), html);
3225
4287
  console.log(` index.html`);
4288
+
4289
+ // SPA fallback files for various static hosts
4290
+ writeFileSync(join(outDir, '404.html'), html);
4291
+ console.log(` 404.html (GitHub Pages SPA fallback)`);
4292
+ writeFileSync(join(outDir, '200.html'), html);
4293
+ console.log(` 200.html (Surge SPA fallback)`);
3226
4294
  }
3227
4295
 
3228
4296
  // Minify all JS bundles using Bun's built-in transpiler
@@ -3259,13 +4327,64 @@ async function productionBuild(srcDir, outDir) {
3259
4327
  }
3260
4328
  }
3261
4329
 
4330
+ // Rewrite min entrypoints to import minified hashed files
4331
+ for (const f of ['server.min.js', 'script.min.js']) {
4332
+ const minEntry = join(outDir, f);
4333
+ try {
4334
+ const content = readFileSync(minEntry, 'utf-8');
4335
+ const rewritten = content.replace(/\.js(["'])/g, '.min.js$1');
4336
+ writeFileSync(minEntry, rewritten);
4337
+ } catch {}
4338
+ }
4339
+
3262
4340
  if (minified === 0 && jsFiles.length > 0) {
3263
4341
  console.log(' (minification skipped — Bun.build unavailable)');
3264
4342
  }
3265
4343
 
4344
+ // Static generation: pre-render each route to its own HTML file
4345
+ if (isStatic && allClientCode.trim()) {
4346
+ console.log(`\n Static generation...\n`);
4347
+
4348
+ const routePaths = extractRoutePaths(allClientCode);
4349
+ if (routePaths.length > 0) {
4350
+ // Read the generated index.html to use as the shell for all routes
4351
+ const shellHtml = readFileSync(join(outDir, 'index.html'), 'utf-8');
4352
+ for (const routePath of routePaths) {
4353
+ const htmlPath = routePath === '/'
4354
+ ? join(outDir, 'index.html')
4355
+ : join(outDir, routePath.replace(/^\//, ''), 'index.html');
4356
+
4357
+ mkdirSync(dirname(htmlPath), { recursive: true });
4358
+ writeFileSync(htmlPath, shellHtml);
4359
+ const relPath = relative(outDir, htmlPath);
4360
+ console.log(` ${relPath}`);
4361
+ }
4362
+ console.log(`\n Pre-rendered ${routePaths.length} route(s)`);
4363
+ }
4364
+ }
4365
+
3266
4366
  console.log(`\n Production build complete.\n`);
3267
4367
  }
3268
4368
 
4369
+ function extractRoutePaths(code) {
4370
+ // Support both defineRoutes({...}) and createRouter({ routes: {...} })
4371
+ let match = code.match(/defineRoutes\s*\(\s*\{([^}]+)\}\s*\)/);
4372
+ if (!match) {
4373
+ match = code.match(/routes\s*:\s*\{([^}]+)\}/);
4374
+ }
4375
+ if (!match) return [];
4376
+
4377
+ const paths = [];
4378
+ const entries = match[1].matchAll(/"([^"]+)"\s*:/g);
4379
+ for (const entry of entries) {
4380
+ const path = entry[1];
4381
+ if (path === '404' || path === '*') continue;
4382
+ if (path.includes(':')) continue;
4383
+ paths.push(path);
4384
+ }
4385
+ return paths;
4386
+ }
4387
+
3269
4388
  // Fallback JS minifier — string/regex-aware, no AST required
3270
4389
  function _simpleMinify(code) {
3271
4390
  // Phase 1: Strip comments while respecting strings and regexes
@@ -3739,6 +4858,10 @@ function collectExports(ast, filename) {
3739
4858
  allNames.add(node.name);
3740
4859
  if (node.isPublic) publicExports.add(node.name);
3741
4860
  }
4861
+ if (node.type === 'ComponentDeclaration') {
4862
+ allNames.add(node.name);
4863
+ if (node.isPublic) publicExports.add(node.name);
4864
+ }
3742
4865
  if (node.type === 'ImplDeclaration') { /* impl doesn't export a name */ }
3743
4866
  }
3744
4867
 
@@ -3780,8 +4903,24 @@ function compileWithImports(source, filename, srcDir) {
3780
4903
  // Collect this module's exports for validation
3781
4904
  collectExports(ast, filename);
3782
4905
 
3783
- // Resolve .tova imports first
4906
+ // Resolve imports: tova: prefix, @/ prefix, then .tova files
3784
4907
  for (const node of ast.body) {
4908
+ // Resolve tova: prefix imports to runtime modules
4909
+ if ((node.type === 'ImportDeclaration' || node.type === 'ImportDefault' || node.type === 'ImportWildcard') && node.source.startsWith('tova:')) {
4910
+ node.source = './runtime/' + node.source.slice(5) + '.js';
4911
+ continue;
4912
+ }
4913
+ // Resolve @/ prefix imports to project root
4914
+ if ((node.type === 'ImportDeclaration' || node.type === 'ImportDefault' || node.type === 'ImportWildcard') && node.source.startsWith('@/')) {
4915
+ const relPath = node.source.slice(2);
4916
+ let resolved = resolve(srcDir, relPath);
4917
+ if (!resolved.endsWith('.tova')) resolved += '.tova';
4918
+ const fromDir = dirname(filename);
4919
+ let rel = relative(fromDir, resolved);
4920
+ if (!rel.startsWith('.')) rel = './' + rel;
4921
+ node.source = rel;
4922
+ // Fall through to .tova import handling below
4923
+ }
3785
4924
  if (node.type === 'ImportDeclaration' && node.source.endsWith('.tova')) {
3786
4925
  const importPath = resolve(dirname(filename), node.source);
3787
4926
  trackDependency(filename, importPath);
@@ -3998,8 +5137,22 @@ function mergeDirectory(dir, srcDir, options = {}) {
3998
5137
  // Collect exports for cross-file import validation
3999
5138
  collectExports(ast, file);
4000
5139
 
4001
- // Resolve cross-directory .tova imports (same logic as compileWithImports)
5140
+ // Resolve imports: tova: prefix, @/ prefix, then cross-directory .tova
4002
5141
  for (const node of ast.body) {
5142
+ if ((node.type === 'ImportDeclaration' || node.type === 'ImportDefault' || node.type === 'ImportWildcard') && node.source.startsWith('tova:')) {
5143
+ node.source = './runtime/' + node.source.slice(5) + '.js';
5144
+ continue;
5145
+ }
5146
+ if ((node.type === 'ImportDeclaration' || node.type === 'ImportDefault' || node.type === 'ImportWildcard') && node.source.startsWith('@/')) {
5147
+ const relPath = node.source.slice(2);
5148
+ let resolved = resolve(srcDir, relPath);
5149
+ if (!resolved.endsWith('.tova')) resolved += '.tova';
5150
+ const fromDir = dirname(file);
5151
+ let rel = relative(fromDir, resolved);
5152
+ if (!rel.startsWith('.')) rel = './' + rel;
5153
+ node.source = rel;
5154
+ // Fall through to .tova import handling below
5155
+ }
4003
5156
  if ((node.type === 'ImportDeclaration' || node.type === 'ImportDefault' || node.type === 'ImportWildcard') && node.source.endsWith('.tova')) {
4004
5157
  const importPath = resolve(dirname(file), node.source);
4005
5158
  // Only process imports from OTHER directories (same-dir files are merged)
@@ -4075,7 +5228,7 @@ function mergeDirectory(dir, srcDir, options = {}) {
4075
5228
  const mergedAST = new Program(mergedBody);
4076
5229
 
4077
5230
  // Run analyzer on merged AST
4078
- const analyzer = new Analyzer(mergedAST, dir);
5231
+ const analyzer = new Analyzer(mergedAST, dir, { strict: options.strict, strictSecurity: options.strictSecurity });
4079
5232
  const { warnings } = analyzer.analyze();
4080
5233
 
4081
5234
  if (warnings.length > 0) {
@@ -4096,7 +5249,27 @@ function mergeDirectory(dir, srcDir, options = {}) {
4096
5249
  output._sourceContents = sourceContents;
4097
5250
  output._sourceFiles = tovaFiles;
4098
5251
 
4099
- return { output, files: tovaFiles, single: false };
5252
+ // Extract security info for scorecard
5253
+ const hasServer = mergedBody.some(n => n.type === 'ServerBlock');
5254
+ const hasEdge = mergedBody.some(n => n.type === 'EdgeBlock');
5255
+ const securityNode = mergedBody.find(n => n.type === 'SecurityBlock');
5256
+ let securityConfig = null;
5257
+ if (securityNode) {
5258
+ securityConfig = {};
5259
+ for (const child of securityNode.body || []) {
5260
+ if (child.type === 'AuthDeclaration') securityConfig.auth = { authType: child.authType || 'jwt', storage: child.config?.storage?.value };
5261
+ else if (child.type === 'CsrfDeclaration') securityConfig.csrf = { enabled: child.config?.enabled?.value !== false };
5262
+ else if (child.type === 'RateLimitDeclaration') securityConfig.rateLimit = { max: child.config?.max?.value };
5263
+ else if (child.type === 'CspDeclaration') securityConfig.csp = { default_src: true };
5264
+ else if (child.type === 'CorsDeclaration') {
5265
+ const origins = child.config?.origins;
5266
+ securityConfig.cors = { origins: origins ? (origins.elements || []).map(e => e.value) : [] };
5267
+ }
5268
+ else if (child.type === 'AuditDeclaration') securityConfig.audit = { events: ['auth'] };
5269
+ }
5270
+ }
5271
+
5272
+ return { output, files: tovaFiles, single: false, warnings, securityConfig, hasServer, hasEdge };
4100
5273
  }
4101
5274
 
4102
5275
  // Group .tova files by their parent directory
@@ -4269,7 +5442,7 @@ function completionsCommand(shell) {
4269
5442
  'migrate:create', 'migrate:up', 'migrate:down', 'migrate:reset', 'migrate:fresh', 'migrate:status',
4270
5443
  ];
4271
5444
 
4272
- const globalFlags = ['--help', '--version', '--output', '--production', '--watch', '--verbose', '--quiet', '--debug', '--strict'];
5445
+ const globalFlags = ['--help', '--version', '--output', '--production', '--watch', '--verbose', '--quiet', '--debug', '--strict', '--strict-security'];
4273
5446
 
4274
5447
  switch (shell) {
4275
5448
  case 'bash': {
@@ -4292,7 +5465,7 @@ _tova() {
4292
5465
  return 0
4293
5466
  ;;
4294
5467
  --template)
4295
- COMPREPLY=( $(compgen -W "fullstack api script library blank" -- "\${cur}") )
5468
+ COMPREPLY=( $(compgen -W "fullstack spa site api script library blank" -- "\${cur}") )
4296
5469
  return 0
4297
5470
  ;;
4298
5471
  completions)
@@ -4340,7 +5513,7 @@ ${commands.map(c => ` '${c}:${c} command'`).join('\n')}
4340
5513
  case $words[1] in
4341
5514
  new)
4342
5515
  _arguments \\
4343
- '--template[Project template]:template:(fullstack api script library blank)' \\
5516
+ '--template[Project template]:template:(fullstack spa site api script library blank)' \\
4344
5517
  '*:name:'
4345
5518
  ;;
4346
5519
  run|build|check|fmt|doc)
@@ -4432,7 +5605,7 @@ _tova "$@"
4432
5605
  script += `complete -c tova -l debug -d 'Debug output'\n`;
4433
5606
  script += `complete -c tova -l strict -d 'Strict type checking'\n`;
4434
5607
  script += `\n# Template completions for 'new'\n`;
4435
- script += `complete -c tova -n '__fish_seen_subcommand_from new' -l template -d 'Project template' -xa 'fullstack api script library blank'\n`;
5608
+ script += `complete -c tova -n '__fish_seen_subcommand_from new' -l template -d 'Project template' -xa 'fullstack spa site api script library blank'\n`;
4436
5609
  script += `\n# Shell completions for 'completions'\n`;
4437
5610
  script += `complete -c tova -n '__fish_seen_subcommand_from completions' -xa 'bash zsh fish'\n`;
4438
5611