tova 0.8.2 → 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.
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)
@@ -83,7 +85,9 @@ Options:
83
85
  --verbose Show detailed output during compilation
84
86
  --quiet Suppress non-error output
85
87
  --debug Show verbose error output
88
+ --static Pre-render routes to static HTML files (used with --production)
86
89
  --strict Enable strict type checking
90
+ --strict-security Promote security warnings to errors
87
91
  `;
88
92
 
89
93
  async function main() {
@@ -102,14 +106,15 @@ async function main() {
102
106
  const command = args[0];
103
107
 
104
108
  const isStrict = args.includes('--strict');
109
+ const isStrictSecurity = args.includes('--strict-security');
105
110
  switch (command) {
106
111
  case 'run': {
107
- const runArgs = args.filter(a => a !== '--strict');
112
+ const runArgs = args.filter(a => a !== '--strict' && a !== '--strict-security');
108
113
  const filePath = runArgs[1];
109
114
  const restArgs = runArgs.slice(2);
110
115
  const ddIdx = restArgs.indexOf('--');
111
116
  const scriptArgs = ddIdx !== -1 ? restArgs.slice(ddIdx + 1) : restArgs;
112
- await runFile(filePath, { strict: isStrict, scriptArgs });
117
+ await runFile(filePath, { strict: isStrict, strictSecurity: isStrictSecurity, scriptArgs });
113
118
  break;
114
119
  }
115
120
  case 'build':
@@ -280,10 +285,10 @@ async function main() {
280
285
  break;
281
286
  default:
282
287
  if (command.endsWith('.tova')) {
283
- const directArgs = args.filter(a => a !== '--strict').slice(1);
288
+ const directArgs = args.filter(a => a !== '--strict' && a !== '--strict-security').slice(1);
284
289
  const ddIdx = directArgs.indexOf('--');
285
290
  const scriptArgs = ddIdx !== -1 ? directArgs.slice(ddIdx + 1) : directArgs;
286
- await runFile(command, { strict: isStrict, scriptArgs });
291
+ await runFile(command, { strict: isStrict, strictSecurity: isStrictSecurity, scriptArgs });
287
292
  } else {
288
293
  console.error(`Unknown command: ${command}`);
289
294
  console.log(HELP);
@@ -301,7 +306,7 @@ function compileTova(source, filename, options = {}) {
301
306
  const parser = new Parser(tokens, filename);
302
307
  const ast = parser.parse();
303
308
 
304
- 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 });
305
310
  // Pre-define extra names in the analyzer scope (used by REPL for cross-line persistence)
306
311
  if (options.knownNames) {
307
312
  for (const name of options.knownNames) {
@@ -684,11 +689,12 @@ async function runFile(filePath, options = {}) {
684
689
  }
685
690
  const hasTovaImports = tovaImportPaths.length > 0;
686
691
 
687
- const output = compileTova(source, filePath, { strict: options.strict });
692
+ const output = compileTova(source, filePath, { strict: options.strict, strictSecurity: options.strictSecurity });
688
693
 
689
694
  // Execute the generated JavaScript (with stdlib)
690
695
  const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
691
696
  const stdlib = getRunStdlib();
697
+ const __tova_require = _createRequire(import.meta.url);
692
698
 
693
699
  // CLI mode: execute the cli code directly
694
700
  if (output.isCli) {
@@ -697,8 +703,8 @@ async function runFile(filePath, options = {}) {
697
703
  // Override process.argv for cli dispatch
698
704
  const scriptArgs = options.scriptArgs || [];
699
705
  code = `process.argv = ["node", ${JSON.stringify(resolved)}, ...${JSON.stringify(scriptArgs)}];\n` + code;
700
- const fn = new AsyncFunction('__tova_args', '__tova_filename', '__tova_dirname', code);
701
- 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);
702
708
  return;
703
709
  }
704
710
 
@@ -733,8 +739,8 @@ async function runFile(filePath, options = {}) {
733
739
  if (/\bfunction\s+main\s*\(/.test(code)) {
734
740
  code += '\nconst __tova_exit = await main(__tova_args); if (typeof __tova_exit === "number") process.exitCode = __tova_exit;\n';
735
741
  }
736
- const fn = new AsyncFunction('__tova_args', '__tova_filename', '__tova_dirname', code);
737
- 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);
738
744
  } catch (err) {
739
745
  console.error(richError(source, err, filePath));
740
746
  if (process.argv.includes('--debug') || process.env.DEBUG) {
@@ -744,12 +750,217 @@ async function runFile(filePath, options = {}) {
744
750
  }
745
751
  }
746
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
+
747
956
  // ─── Build ──────────────────────────────────────────────────
748
957
 
749
958
  async function buildProject(args) {
750
959
  const config = resolveConfig(process.cwd());
751
960
  const isProduction = args.includes('--production');
961
+ const isStatic = args.includes('--static');
752
962
  const buildStrict = args.includes('--strict');
963
+ const buildStrictSecurity = args.includes('--strict-security');
753
964
  const isVerbose = args.includes('--verbose');
754
965
  const isQuiet = args.includes('--quiet');
755
966
  const isWatch = args.includes('--watch');
@@ -767,7 +978,7 @@ async function buildProject(args) {
767
978
 
768
979
  // Production build uses a separate optimized pipeline
769
980
  if (isProduction) {
770
- return await productionBuild(srcDir, outDir);
981
+ return await productionBuild(srcDir, outDir, isStatic);
771
982
  }
772
983
 
773
984
  const tovaFiles = findFiles(srcDir, '.tova');
@@ -784,6 +995,9 @@ async function buildProject(args) {
784
995
  writeFileSync(join(runtimeDest, 'reactivity.js'), REACTIVITY_SOURCE);
785
996
  writeFileSync(join(runtimeDest, 'rpc.js'), RPC_SOURCE);
786
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);
787
1001
 
788
1002
  if (!isQuiet) console.log(`\n Building ${tovaFiles.length} file(s)...\n`);
789
1003
 
@@ -799,6 +1013,7 @@ async function buildProject(args) {
799
1013
 
800
1014
  // Group files by directory for multi-file merging
801
1015
  const dirGroups = groupFilesByDirectory(tovaFiles);
1016
+ let _scorecardData = null; // Collect security info for scorecard
802
1017
 
803
1018
  for (const [dir, files] of dirGroups) {
804
1019
  const dirName = basename(dir) === '.' ? 'app' : basename(dir);
@@ -835,10 +1050,13 @@ async function buildProject(args) {
835
1050
  }
836
1051
  }
837
1052
 
838
- const result = mergeDirectory(dir, srcDir, { strict: buildStrict });
1053
+ const result = mergeDirectory(dir, srcDir, { strict: buildStrict, strictSecurity: buildStrictSecurity });
839
1054
  if (!result) continue;
840
1055
 
841
- 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
+ }
842
1060
  // Preserve relative directory structure in output (e.g., src/lib/math.tova → lib/math.js)
843
1061
  const outBaseName = single
844
1062
  ? relative(srcDir, files[0]).replace(/\.tova$/, '').replace(/\\/g, '/')
@@ -892,7 +1110,7 @@ async function buildProject(args) {
892
1110
  else if (output.isModule) {
893
1111
  if (output.shared && output.shared.trim()) {
894
1112
  const modulePath = join(outDir, `${outBaseName}.js`);
895
- writeFileSync(modulePath, generateSourceMap(output.shared, modulePath));
1113
+ writeFileSync(modulePath, fixImportPaths(generateSourceMap(output.shared, modulePath), modulePath, outDir));
896
1114
  if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', modulePath)}${timing}`);
897
1115
  }
898
1116
  // Update incremental build cache
@@ -911,28 +1129,30 @@ async function buildProject(args) {
911
1129
  // Write shared
912
1130
  if (output.shared && output.shared.trim()) {
913
1131
  const sharedPath = join(outDir, `${outBaseName}.shared.js`);
914
- writeFileSync(sharedPath, generateSourceMap(output.shared, sharedPath));
1132
+ writeFileSync(sharedPath, fixImportPaths(generateSourceMap(output.shared, sharedPath), sharedPath, outDir));
915
1133
  if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', sharedPath)}${timing}`);
916
1134
  }
917
1135
 
918
1136
  // Write default server
919
1137
  if (output.server) {
920
1138
  const serverPath = join(outDir, `${outBaseName}.server.js`);
921
- writeFileSync(serverPath, generateSourceMap(output.server, serverPath));
1139
+ writeFileSync(serverPath, fixImportPaths(generateSourceMap(output.server, serverPath), serverPath, outDir));
922
1140
  if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', serverPath)}${timing}`);
923
1141
  }
924
1142
 
925
1143
  // Write default browser
926
1144
  if (output.browser) {
927
1145
  const browserPath = join(outDir, `${outBaseName}.browser.js`);
928
- 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));
929
1149
  if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', browserPath)}${timing}`);
930
1150
  }
931
1151
 
932
1152
  // Write default edge
933
1153
  if (output.edge) {
934
1154
  const edgePath = join(outDir, `${outBaseName}.edge.js`);
935
- writeFileSync(edgePath, generateSourceMap(output.edge, edgePath));
1155
+ writeFileSync(edgePath, fixImportPaths(generateSourceMap(output.edge, edgePath), edgePath, outDir));
936
1156
  if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', edgePath)} [edge]${timing}`);
937
1157
  }
938
1158
 
@@ -941,7 +1161,7 @@ async function buildProject(args) {
941
1161
  for (const [name, code] of Object.entries(output.servers)) {
942
1162
  if (name === 'default') continue;
943
1163
  const path = join(outDir, `${outBaseName}.server.${name}.js`);
944
- writeFileSync(path, code);
1164
+ writeFileSync(path, fixImportPaths(code, path, outDir));
945
1165
  if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [server:${name}]${timing}`);
946
1166
  }
947
1167
  }
@@ -951,7 +1171,7 @@ async function buildProject(args) {
951
1171
  for (const [name, code] of Object.entries(output.edges)) {
952
1172
  if (name === 'default') continue;
953
1173
  const path = join(outDir, `${outBaseName}.edge.${name}.js`);
954
- writeFileSync(path, code);
1174
+ writeFileSync(path, fixImportPaths(code, path, outDir));
955
1175
  if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [edge:${name}]${timing}`);
956
1176
  }
957
1177
  }
@@ -961,7 +1181,7 @@ async function buildProject(args) {
961
1181
  for (const [name, code] of Object.entries(output.browsers)) {
962
1182
  if (name === 'default') continue;
963
1183
  const path = join(outDir, `${outBaseName}.browser.${name}.js`);
964
- writeFileSync(path, code);
1184
+ writeFileSync(path, fixImportPaths(code, path, outDir));
965
1185
  if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [browser:${name}]${timing}`);
966
1186
  }
967
1187
  }
@@ -1001,6 +1221,18 @@ async function buildProject(args) {
1001
1221
  const cachedStr = skippedCount > 0 ? ` (${skippedCount} cached)` : '';
1002
1222
  console.log(`\n Build complete. ${dirCount - errorCount}/${dirCount} directory group(s) succeeded${cachedStr}${timingStr}.\n`);
1003
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
+
1004
1236
  if (errorCount > 0) process.exit(1);
1005
1237
 
1006
1238
  // Watch mode for build command
@@ -1027,6 +1259,7 @@ async function buildProject(args) {
1027
1259
 
1028
1260
  async function checkProject(args) {
1029
1261
  const checkStrict = args.includes('--strict');
1262
+ const checkStrictSecurity = args.includes('--strict-security');
1030
1263
  const isVerbose = args.includes('--verbose');
1031
1264
  const isQuiet = args.includes('--quiet');
1032
1265
 
@@ -1069,6 +1302,8 @@ async function checkProject(args) {
1069
1302
  let totalErrors = 0;
1070
1303
  let totalWarnings = 0;
1071
1304
  const seenCodes = new Set();
1305
+ let _checkScorecardData = null;
1306
+ const _allCheckWarnings = [];
1072
1307
 
1073
1308
  for (const file of tovaFiles) {
1074
1309
  const relPath = relative(srcDir, file);
@@ -1079,13 +1314,39 @@ async function checkProject(args) {
1079
1314
  const tokens = lexer.tokenize();
1080
1315
  const parser = new Parser(tokens, file);
1081
1316
  const ast = parser.parse();
1082
- const analyzer = new Analyzer(ast, file, { strict: checkStrict, tolerant: true });
1317
+ const analyzer = new Analyzer(ast, file, { strict: checkStrict, strictSecurity: checkStrictSecurity, tolerant: true });
1083
1318
  const result = analyzer.analyze();
1084
1319
 
1085
1320
  const errors = result.errors || [];
1086
1321
  const warnings = result.warnings || [];
1087
1322
  totalErrors += errors.length;
1088
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
+ }
1089
1350
 
1090
1351
  if (errors.length > 0 || warnings.length > 0) {
1091
1352
  const formatter = new DiagnosticFormatter(source, file);
@@ -1114,6 +1375,17 @@ async function checkProject(args) {
1114
1375
  }
1115
1376
  }
1116
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
+
1117
1389
  if (!isQuiet) {
1118
1390
  console.log(`\n ${tovaFiles.length} file${tovaFiles.length === 1 ? '' : 's'} checked, ${formatSummary(totalErrors, totalWarnings)}`);
1119
1391
  // Show explain hint for encountered error codes
@@ -1150,6 +1422,7 @@ async function devServer(args) {
1150
1422
  const explicitPort = args.find((_, i, a) => a[i - 1] === '--port');
1151
1423
  const basePort = parseInt(explicitPort || config.dev.port || '3000');
1152
1424
  const buildStrict = args.includes('--strict');
1425
+ const buildStrictSecurity = args.includes('--strict-security');
1153
1426
 
1154
1427
  const tovaFiles = findFiles(srcDir, '.tova');
1155
1428
  if (tovaFiles.length === 0) {
@@ -1193,6 +1466,9 @@ async function devServer(args) {
1193
1466
  writeFileSync(join(runtimeDest, 'reactivity.js'), REACTIVITY_SOURCE);
1194
1467
  writeFileSync(join(runtimeDest, 'rpc.js'), RPC_SOURCE);
1195
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);
1196
1472
 
1197
1473
  const serverFiles = [];
1198
1474
  let hasClient = false;
@@ -1208,25 +1484,40 @@ async function devServer(args) {
1208
1484
 
1209
1485
  // Pass 1: Merge each directory, write shared/client outputs, collect clientHTML
1210
1486
  const dirResults = [];
1487
+ const allSharedParts = [];
1488
+ let browserCode = '';
1211
1489
  for (const [dir, files] of dirGroups) {
1212
1490
  const dirName = basename(dir) === '.' ? 'app' : basename(dir);
1213
1491
  try {
1214
- const result = mergeDirectory(dir, srcDir, { strict: buildStrict });
1492
+ const result = mergeDirectory(dir, srcDir, { strict: buildStrict, strictSecurity: buildStrictSecurity });
1215
1493
  if (!result) continue;
1216
1494
 
1217
1495
  const { output, single } = result;
1218
- 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);
1219
1500
  dirResults.push({ dir, output, outBaseName, single, files });
1220
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
+
1221
1506
  if (output.shared && output.shared.trim()) {
1222
- 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);
1223
1513
  }
1224
1514
 
1225
1515
  if (output.browser) {
1226
1516
  const p = join(outDir, `${outBaseName}.browser.js`);
1227
- writeFileSync(p, output.browser);
1228
- clientHTML = await generateDevHTML(output.browser, srcDir, actualReloadPort);
1229
- 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;
1230
1521
  hasClient = true;
1231
1522
  }
1232
1523
  } catch (err) {
@@ -1234,6 +1525,16 @@ async function devServer(args) {
1234
1525
  }
1235
1526
  }
1236
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
+
1237
1538
  // Pass 2: Write server files with clientHTML injected
1238
1539
  for (const { output, outBaseName } of dirResults) {
1239
1540
  if (output.server) {
@@ -1305,6 +1606,75 @@ async function devServer(args) {
1305
1606
  console.log(` ✓ Client: ${relative('.', outDir)}/index.html`);
1306
1607
  }
1307
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
+
1308
1678
  function handleReloadFetch(req) {
1309
1679
  const url = new URL(req.url);
1310
1680
  if (url.pathname === '/__tova_reload') {
@@ -1358,22 +1728,39 @@ async function devServer(args) {
1358
1728
  // Merge each directory group, collect client HTML
1359
1729
  const rebuildDirGroups = groupFilesByDirectory(currentFiles);
1360
1730
  let rebuildClientHTML = '';
1731
+ const rebuildSharedParts = [];
1732
+ let rebuildBrowserCode = '';
1733
+ let rebuildHasClient = false;
1361
1734
 
1362
1735
  for (const [dir, files] of rebuildDirGroups) {
1363
1736
  const dirName = basename(dir) === '.' ? 'app' : basename(dir);
1364
- const result = mergeDirectory(dir, srcDir, { strict: buildStrict });
1737
+ const result = mergeDirectory(dir, srcDir, { strict: buildStrict, strictSecurity: buildStrictSecurity });
1365
1738
  if (!result) continue;
1366
1739
 
1367
1740
  const { output, single } = result;
1368
- 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 });
1369
1749
 
1370
1750
  if (output.shared && output.shared.trim()) {
1371
- 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);
1372
1756
  }
1373
1757
  if (output.browser) {
1374
- writeFileSync(join(outDir, `${outBaseName}.browser.js`), output.browser);
1375
- rebuildClientHTML = await generateDevHTML(output.browser, srcDir, actualReloadPort);
1376
- 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;
1377
1764
  }
1378
1765
  if (output.server) {
1379
1766
  let serverCode = output.server;
@@ -1381,7 +1768,7 @@ async function devServer(args) {
1381
1768
  serverCode = `const __clientHTML = ${JSON.stringify(rebuildClientHTML)};\n` + serverCode;
1382
1769
  }
1383
1770
  const p = join(outDir, `${outBaseName}.server.js`);
1384
- writeFileSync(p, serverCode);
1771
+ writeFileSync(p, fixImportPaths(serverCode, p, outDir));
1385
1772
  newServerFiles.push(p);
1386
1773
  }
1387
1774
  if (output.multiBlock && output.servers) {
@@ -1393,6 +1780,14 @@ async function devServer(args) {
1393
1780
  }
1394
1781
  }
1395
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
+ }
1396
1791
  } catch (err) {
1397
1792
  console.error(` ✗ Rebuild failed: ${err.message}`);
1398
1793
  return; // Keep old processes running
@@ -1510,6 +1905,10 @@ ${bundled}
1510
1905
  const inlineReactivity = REACTIVITY_SOURCE.replace(/^export /gm, '');
1511
1906
  const inlineRpc = RPC_SOURCE.replace(/^export /gm, '');
1512
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
+
1513
1912
  // Strip all import lines from client code (we inline the runtime instead)
1514
1913
  const inlineClient = clientCode
1515
1914
  .replace(/^\s*import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]+['"];?\s*$/gm, '')
@@ -1560,6 +1959,8 @@ ${inlineReactivity}
1560
1959
  // ── Tova Runtime: RPC ──
1561
1960
  ${inlineRpc}
1562
1961
 
1962
+ ${usesRouter ? '// ── Tova Runtime: Router ──\n' + inlineRouter : ''}
1963
+
1563
1964
  // ── App ──
1564
1965
  ${inlineClient}
1565
1966
  </script>${liveReloadScript}
@@ -1574,11 +1975,12 @@ ${inlineClient}
1574
1975
  const PROJECT_TEMPLATES = {
1575
1976
  fullstack: {
1576
1977
  label: 'Full-stack app',
1577
- description: 'server + client + shared blocks',
1978
+ description: 'server + browser + shared blocks',
1578
1979
  tomlDescription: 'A full-stack Tova application',
1579
1980
  entry: 'src',
1580
1981
  file: 'src/app.tova',
1581
1982
  content: name => `// ${name} — Built with Tova
1983
+ // Full-stack app: server RPC + client-side routing
1582
1984
 
1583
1985
  shared {
1584
1986
  type Message {
@@ -1595,7 +1997,7 @@ server {
1595
1997
  route GET "/api/message" => get_message
1596
1998
  }
1597
1999
 
1598
- client {
2000
+ browser {
1599
2001
  state message = ""
1600
2002
  state timestamp = ""
1601
2003
  state refreshing = false
@@ -1614,6 +2016,23 @@ client {
1614
2016
  refreshing = false
1615
2017
  }
1616
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 ──────────────────────────────────────────────
1617
2036
  component FeatureCard(icon, title, description) {
1618
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">
1619
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">
@@ -1624,72 +2043,416 @@ client {
1624
2043
  </div>
1625
2044
  }
1626
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
+
1627
2127
  component App {
1628
2128
  <div class="min-h-screen bg-gradient-to-br from-slate-50 via-white to-indigo-50">
1629
- <nav class="border-b border-gray-100 bg-white/80 backdrop-blur-sm sticky top-0 z-10">
1630
- <div class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
1631
- <div class="flex items-center gap-2">
1632
- <div class="w-8 h-8 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-lg"></div>
1633
- <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>
1634
2210
  </div>
1635
- <div class="flex items-center gap-4">
1636
- <a href="https://github.com/tova-lang/tova-lang" class="text-sm text-gray-500 hover:text-gray-900 transition-colors">"Docs"</a>
1637
- <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>
1638
2220
  </div>
2221
+ <span class="text-gray-400 text-sm">"View \u2192"</span>
1639
2222
  </div>
1640
- </nav>
2223
+ </div>
2224
+ </div>
2225
+ }
1641
2226
 
1642
- <main class="max-w-5xl mx-auto px-6">
1643
- <div class="py-20 text-center">
1644
- <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">
1645
- <span class="w-1.5 h-1.5 bg-indigo-500 rounded-full"></span>
1646
- "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}"
1647
2237
  </div>
1648
- <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>
1649
- <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>
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>
2241
+ </div>
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
+ }
1650
2247
 
1651
- <div class="inline-flex items-center gap-3 bg-white border border-gray-200 rounded-2xl p-2 shadow-sm">
1652
- <div class="bg-gradient-to-r from-indigo-500 to-purple-500 text-white px-5 py-2.5 rounded-xl font-medium">
1653
- "{message}"
1654
- </div>
1655
- <button
1656
- on:click={handle_refresh}
1657
- 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"
1658
- >
1659
- if refreshing {
1660
- "..."
1661
- } else {
1662
- "Refresh"
1663
- }
1664
- </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>
1665
2257
  </div>
1666
- if timestamp != "" {
1667
- <p class="text-xs text-gray-400 mt-3">"Last fetched at " "{timestamp}"</p>
1668
- }
2258
+ </aside>
2259
+ <div class="flex-1 min-w-0">
2260
+ <Outlet />
1669
2261
  </div>
2262
+ </div>
2263
+ </div>
2264
+ }
1670
2265
 
1671
- <div class="grid grid-cols-1 md:grid-cols-3 gap-5 pb-20">
1672
- <FeatureCard
1673
- icon="&#9881;"
1674
- title="Full-Stack"
1675
- description="Server and client in one file. Shared types, RPC calls, and reactive UI — all type-safe."
1676
- />
1677
- <FeatureCard
1678
- icon="&#9889;"
1679
- title="Fast Refresh"
1680
- description="Edit your code and see changes instantly. The dev server recompiles on save."
1681
- />
1682
- <FeatureCard
1683
- icon="&#127912;"
1684
- title="Tailwind Built-in"
1685
- description="Style with utility classes out of the box. No config or build step needed."
1686
- />
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>
1687
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>
2278
+ </div>
2279
+ </div>
2280
+ </div>
2281
+ }
1688
2282
 
1689
- <div class="border-t border-gray-100 py-8 text-center">
1690
- <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>
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>
1691
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 />
1692
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>
1693
2456
  </div>
1694
2457
  }
1695
2458
  }
@@ -1733,12 +2496,20 @@ print("Hello, {name}!")
1733
2496
  tomlDescription: 'A Tova library',
1734
2497
  entry: 'src',
1735
2498
  noEntry: true,
2499
+ isPackage: true,
1736
2500
  file: 'src/lib.tova',
1737
2501
  content: name => `// ${name} — A Tova library
2502
+ //
2503
+ // Usage:
2504
+ // import { greet } from "github.com/yourname/${name}"
1738
2505
 
1739
2506
  pub fn greet(name: String) -> String {
1740
2507
  "Hello, {name}!"
1741
2508
  }
2509
+
2510
+ pub fn version() -> String {
2511
+ "0.1.0"
2512
+ }
1742
2513
  `,
1743
2514
  nextSteps: name => ` cd ${name}\n tova build`,
1744
2515
  },
@@ -1753,7 +2524,7 @@ pub fn greet(name: String) -> String {
1753
2524
  },
1754
2525
  };
1755
2526
 
1756
- const TEMPLATE_ORDER = ['fullstack', 'api', 'script', 'library', 'blank'];
2527
+ const TEMPLATE_ORDER = ['fullstack', 'spa', 'site', 'api', 'script', 'library', 'blank'];
1757
2528
 
1758
2529
  async function newProject(rawArgs) {
1759
2530
  const name = rawArgs.find(a => !a.startsWith('-'));
@@ -1770,11 +2541,12 @@ async function newProject(rawArgs) {
1770
2541
 
1771
2542
  if (!name) {
1772
2543
  console.error(color.red('Error: No project name specified'));
1773
- 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]');
1774
2545
  process.exit(1);
1775
2546
  }
1776
2547
 
1777
2548
  const projectDir = resolve(name);
2549
+ const projectName = basename(projectDir);
1778
2550
  if (existsSync(projectDir)) {
1779
2551
  console.error(color.red(`Error: Directory '${name}' already exists`));
1780
2552
  process.exit(1);
@@ -1826,24 +2598,49 @@ async function newProject(rawArgs) {
1826
2598
  const createdFiles = [];
1827
2599
 
1828
2600
  // tova.toml
1829
- const tomlConfig = {
1830
- project: {
1831
- name,
1832
- version: '0.1.0',
1833
- description: template.tomlDescription,
1834
- },
1835
- build: {
1836
- output: '.tova-out',
1837
- },
1838
- };
1839
- if (!template.noEntry) {
1840
- tomlConfig.project.entry = template.entry;
1841
- }
1842
- if (templateName === 'fullstack' || templateName === 'api') {
1843
- tomlConfig.dev = { port: 3000 };
1844
- 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);
1845
2642
  }
1846
- writeFileSync(join(projectDir, 'tova.toml'), stringifyTOML(tomlConfig));
2643
+ writeFileSync(join(projectDir, 'tova.toml'), tomlContent);
1847
2644
  createdFiles.push('tova.toml');
1848
2645
 
1849
2646
  // .gitignore
@@ -1859,12 +2656,22 @@ bun.lock
1859
2656
 
1860
2657
  // Template source file
1861
2658
  if (template.file && template.content) {
1862
- writeFileSync(join(projectDir, template.file), template.content(name));
2659
+ writeFileSync(join(projectDir, template.file), template.content(projectName));
1863
2660
  createdFiles.push(template.file);
1864
2661
  }
1865
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
+
1866
2673
  // README
1867
- writeFileSync(join(projectDir, 'README.md'), `# ${name}
2674
+ let readmeContent = `# ${projectName}
1868
2675
 
1869
2676
  Built with [Tova](https://github.com/tova-lang/tova-lang) — a modern full-stack language.
1870
2677
 
@@ -1873,7 +2680,34 @@ Built with [Tova](https://github.com/tova-lang/tova-lang) — a modern full-stac
1873
2680
  \`\`\`bash
1874
2681
  ${template.nextSteps(name).trim()}
1875
2682
  \`\`\`
1876
- `);
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);
1877
2711
  createdFiles.push('README.md');
1878
2712
 
1879
2713
  // Print created files
@@ -1964,7 +2798,7 @@ server {
1964
2798
  route GET "/api/message" => get_message
1965
2799
  }
1966
2800
 
1967
- client {
2801
+ browser {
1968
2802
  state message = ""
1969
2803
 
1970
2804
  effect {
@@ -2003,8 +2837,16 @@ async function installDeps() {
2003
2837
 
2004
2838
  // Resolve Tova module dependencies (if any)
2005
2839
  const tovaDeps = config.dependencies || {};
2006
- const { isTovModule: _isTovMod } = await import('../src/config/module-path.js');
2007
- const tovModuleKeys = Object.keys(tovaDeps).filter(k => _isTovMod(k));
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));
2008
2850
 
2009
2851
  if (tovModuleKeys.length > 0) {
2010
2852
  const { resolveDependencies } = await import('../src/config/resolver.js');
@@ -2017,7 +2859,7 @@ async function installDeps() {
2017
2859
  const lock = readLockFile(cwd);
2018
2860
  const tovaModuleDeps = {};
2019
2861
  for (const k of tovModuleKeys) {
2020
- tovaModuleDeps[k] = tovaDeps[k];
2862
+ tovaModuleDeps[k] = expandedTovaDeps[k];
2021
2863
  }
2022
2864
 
2023
2865
  try {
@@ -2132,7 +2974,7 @@ async function addDep(args) {
2132
2974
  await installDeps();
2133
2975
  } else {
2134
2976
  // Tova native dependency
2135
- const { isTovModule: isTovMod } = await import('../src/config/module-path.js');
2977
+ const { isTovModule: isTovMod, expandBlessedPackage } = await import('../src/config/module-path.js');
2136
2978
 
2137
2979
  // Parse potential @version suffix
2138
2980
  let pkgName = actualPkg;
@@ -2143,21 +2985,25 @@ async function addDep(args) {
2143
2985
  pkgName = pkgName.slice(0, atIdx);
2144
2986
  }
2145
2987
 
2988
+ // Expand blessed package shorthand: tova/data → github.com/tova-lang/data
2989
+ const expandedPkg = expandBlessedPackage(pkgName);
2990
+ const resolvedPkg = expandedPkg || pkgName;
2991
+
2146
2992
  if (isTovMod(pkgName)) {
2147
2993
  // Tova module: fetch tags, pick version, add to [dependencies]
2148
2994
  const { listRemoteTags, pickLatestTag } = await import('../src/config/git-resolver.js');
2149
2995
  try {
2150
- const tags = await listRemoteTags(pkgName);
2996
+ const tags = await listRemoteTags(resolvedPkg);
2151
2997
  if (tags.length === 0) {
2152
- console.error(` No version tags found for ${pkgName}`);
2998
+ console.error(` No version tags found for ${resolvedPkg}`);
2153
2999
  process.exit(1);
2154
3000
  }
2155
3001
  if (!versionConstraint) {
2156
3002
  const latest = pickLatestTag(tags);
2157
3003
  versionConstraint = `^${latest.version}`;
2158
3004
  }
2159
- addToSection(tomlPath, 'dependencies', `"${pkgName}"`, versionConstraint);
2160
- console.log(` Added ${pkgName}@${versionConstraint} to [dependencies] in tova.toml`);
3005
+ addToSection(tomlPath, 'dependencies', `"${resolvedPkg}"`, versionConstraint);
3006
+ console.log(` Added ${resolvedPkg}@${versionConstraint} to [dependencies] in tova.toml`);
2161
3007
  await installDeps();
2162
3008
  } catch (err) {
2163
3009
  console.error(` Failed to add ${pkgName}: ${err.message}`);
@@ -3010,6 +3856,26 @@ async function startRepl() {
3010
3856
  const declaredInCode = new Set();
3011
3857
  for (const m of code.matchAll(/\bfunction\s+([a-zA-Z_]\w*)/g)) { declaredInCode.add(m[1]); userDefinedNames.add(m[1]); }
3012
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
+ }
3013
3879
  for (const m of code.matchAll(/\blet\s+([a-zA-Z_]\w*)/g)) {
3014
3880
  declaredInCode.add(m[1]);
3015
3881
  userDefinedNames.add(m[1]);
@@ -3306,7 +4172,11 @@ async function binaryBuild(srcDir, outputName, outDir) {
3306
4172
 
3307
4173
  // ─── Production Build ────────────────────────────────────────
3308
4174
 
3309
- 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
+
3310
4180
  const tovaFiles = findFiles(srcDir, '.tova');
3311
4181
  if (tovaFiles.length === 0) {
3312
4182
  console.error('No .tova files found');
@@ -3351,6 +4221,9 @@ async function productionBuild(srcDir, outDir) {
3351
4221
  const serverPath = join(outDir, `server.${hash}.js`);
3352
4222
  writeFileSync(serverPath, serverBundle);
3353
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`);
3354
4227
  }
3355
4228
 
3356
4229
  // Write script bundle for plain scripts (no server/client blocks)
@@ -3378,7 +4251,9 @@ async function productionBuild(srcDir, outDir) {
3378
4251
  // No npm imports — inline runtime, strip all imports
3379
4252
  const reactivityCode = REACTIVITY_SOURCE.replace(/^export /gm, '');
3380
4253
  const rpcCode = RPC_SOURCE.replace(/^export /gm, '');
3381
- 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' +
3382
4257
  allClientCode.replace(/^\s*import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]+['"];?\s*$/gm, '').trim();
3383
4258
  }
3384
4259
 
@@ -3389,14 +4264,19 @@ async function productionBuild(srcDir, outDir) {
3389
4264
 
3390
4265
  // Generate production HTML
3391
4266
  const scriptTag = useModule
3392
- ? `<script type="module" src="client.${hash}.js"></script>`
3393
- : `<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>`;
3394
4269
  const html = `<!DOCTYPE html>
3395
4270
  <html lang="en">
3396
4271
  <head>
3397
4272
  <meta charset="UTF-8">
3398
4273
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
3399
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>
3400
4280
  </head>
3401
4281
  <body>
3402
4282
  <div id="app"></div>
@@ -3405,6 +4285,12 @@ async function productionBuild(srcDir, outDir) {
3405
4285
  </html>`;
3406
4286
  writeFileSync(join(outDir, 'index.html'), html);
3407
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)`);
3408
4294
  }
3409
4295
 
3410
4296
  // Minify all JS bundles using Bun's built-in transpiler
@@ -3441,13 +4327,64 @@ async function productionBuild(srcDir, outDir) {
3441
4327
  }
3442
4328
  }
3443
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
+
3444
4340
  if (minified === 0 && jsFiles.length > 0) {
3445
4341
  console.log(' (minification skipped — Bun.build unavailable)');
3446
4342
  }
3447
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
+
3448
4366
  console.log(`\n Production build complete.\n`);
3449
4367
  }
3450
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
+
3451
4388
  // Fallback JS minifier — string/regex-aware, no AST required
3452
4389
  function _simpleMinify(code) {
3453
4390
  // Phase 1: Strip comments while respecting strings and regexes
@@ -3921,6 +4858,10 @@ function collectExports(ast, filename) {
3921
4858
  allNames.add(node.name);
3922
4859
  if (node.isPublic) publicExports.add(node.name);
3923
4860
  }
4861
+ if (node.type === 'ComponentDeclaration') {
4862
+ allNames.add(node.name);
4863
+ if (node.isPublic) publicExports.add(node.name);
4864
+ }
3924
4865
  if (node.type === 'ImplDeclaration') { /* impl doesn't export a name */ }
3925
4866
  }
3926
4867
 
@@ -3962,8 +4903,24 @@ function compileWithImports(source, filename, srcDir) {
3962
4903
  // Collect this module's exports for validation
3963
4904
  collectExports(ast, filename);
3964
4905
 
3965
- // Resolve .tova imports first
4906
+ // Resolve imports: tova: prefix, @/ prefix, then .tova files
3966
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
+ }
3967
4924
  if (node.type === 'ImportDeclaration' && node.source.endsWith('.tova')) {
3968
4925
  const importPath = resolve(dirname(filename), node.source);
3969
4926
  trackDependency(filename, importPath);
@@ -4180,8 +5137,22 @@ function mergeDirectory(dir, srcDir, options = {}) {
4180
5137
  // Collect exports for cross-file import validation
4181
5138
  collectExports(ast, file);
4182
5139
 
4183
- // Resolve cross-directory .tova imports (same logic as compileWithImports)
5140
+ // Resolve imports: tova: prefix, @/ prefix, then cross-directory .tova
4184
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
+ }
4185
5156
  if ((node.type === 'ImportDeclaration' || node.type === 'ImportDefault' || node.type === 'ImportWildcard') && node.source.endsWith('.tova')) {
4186
5157
  const importPath = resolve(dirname(file), node.source);
4187
5158
  // Only process imports from OTHER directories (same-dir files are merged)
@@ -4257,7 +5228,7 @@ function mergeDirectory(dir, srcDir, options = {}) {
4257
5228
  const mergedAST = new Program(mergedBody);
4258
5229
 
4259
5230
  // Run analyzer on merged AST
4260
- const analyzer = new Analyzer(mergedAST, dir);
5231
+ const analyzer = new Analyzer(mergedAST, dir, { strict: options.strict, strictSecurity: options.strictSecurity });
4261
5232
  const { warnings } = analyzer.analyze();
4262
5233
 
4263
5234
  if (warnings.length > 0) {
@@ -4278,7 +5249,27 @@ function mergeDirectory(dir, srcDir, options = {}) {
4278
5249
  output._sourceContents = sourceContents;
4279
5250
  output._sourceFiles = tovaFiles;
4280
5251
 
4281
- 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 };
4282
5273
  }
4283
5274
 
4284
5275
  // Group .tova files by their parent directory
@@ -4451,7 +5442,7 @@ function completionsCommand(shell) {
4451
5442
  'migrate:create', 'migrate:up', 'migrate:down', 'migrate:reset', 'migrate:fresh', 'migrate:status',
4452
5443
  ];
4453
5444
 
4454
- 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'];
4455
5446
 
4456
5447
  switch (shell) {
4457
5448
  case 'bash': {
@@ -4474,7 +5465,7 @@ _tova() {
4474
5465
  return 0
4475
5466
  ;;
4476
5467
  --template)
4477
- COMPREPLY=( $(compgen -W "fullstack api script library blank" -- "\${cur}") )
5468
+ COMPREPLY=( $(compgen -W "fullstack spa site api script library blank" -- "\${cur}") )
4478
5469
  return 0
4479
5470
  ;;
4480
5471
  completions)
@@ -4522,7 +5513,7 @@ ${commands.map(c => ` '${c}:${c} command'`).join('\n')}
4522
5513
  case $words[1] in
4523
5514
  new)
4524
5515
  _arguments \\
4525
- '--template[Project template]:template:(fullstack api script library blank)' \\
5516
+ '--template[Project template]:template:(fullstack spa site api script library blank)' \\
4526
5517
  '*:name:'
4527
5518
  ;;
4528
5519
  run|build|check|fmt|doc)
@@ -4614,7 +5605,7 @@ _tova "$@"
4614
5605
  script += `complete -c tova -l debug -d 'Debug output'\n`;
4615
5606
  script += `complete -c tova -l strict -d 'Strict type checking'\n`;
4616
5607
  script += `\n# Template completions for 'new'\n`;
4617
- 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`;
4618
5609
  script += `\n# Shell completions for 'completions'\n`;
4619
5610
  script += `complete -c tova -n '__fish_seen_subcommand_from completions' -xa 'bash zsh fish'\n`;
4620
5611