tova 0.8.2 → 0.9.5

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 (41) hide show
  1. package/bin/tova.js +1790 -142
  2. package/package.json +14 -2
  3. package/src/analyzer/analyzer.js +529 -11
  4. package/src/analyzer/browser-analyzer.js +74 -8
  5. package/src/analyzer/scope.js +7 -0
  6. package/src/analyzer/server-analyzer.js +33 -1
  7. package/src/codegen/auth-codegen.js +1068 -0
  8. package/src/codegen/base-codegen.js +137 -13
  9. package/src/codegen/browser-codegen.js +764 -26
  10. package/src/codegen/codegen.js +82 -8
  11. package/src/codegen/security-codegen.js +8 -7
  12. package/src/codegen/server-codegen.js +146 -17
  13. package/src/codegen/theme-codegen.js +69 -0
  14. package/src/config/module-path.js +34 -2
  15. package/src/config/resolve.js +9 -0
  16. package/src/config/toml.js +13 -1
  17. package/src/deploy/provision.js +6 -2
  18. package/src/diagnostics/security-scorecard.js +111 -0
  19. package/src/lexer/lexer.js +20 -5
  20. package/src/parser/animate-ast.js +45 -0
  21. package/src/parser/ast.js +23 -0
  22. package/src/parser/auth-ast.js +38 -0
  23. package/src/parser/auth-parser.js +129 -0
  24. package/src/parser/browser-ast.js +19 -1
  25. package/src/parser/browser-parser.js +221 -4
  26. package/src/parser/parser.js +21 -2
  27. package/src/parser/theme-ast.js +29 -0
  28. package/src/parser/theme-parser.js +70 -0
  29. package/src/registry/plugins/auth-plugin.js +24 -0
  30. package/src/registry/plugins/theme-plugin.js +20 -0
  31. package/src/registry/register-all.js +4 -0
  32. package/src/runtime/charts.js +547 -0
  33. package/src/runtime/embedded.js +7 -3
  34. package/src/runtime/reactivity.js +60 -0
  35. package/src/runtime/router.js +703 -295
  36. package/src/runtime/rpc.js +15 -1
  37. package/src/runtime/table.js +606 -33
  38. package/src/stdlib/inline.js +330 -7
  39. package/src/stdlib/string.js +84 -2
  40. package/src/stdlib/validation.js +1 -1
  41. 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)
@@ -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}
@@ -1571,14 +1972,629 @@ ${inlineClient}
1571
1972
 
1572
1973
  // ─── Template Definitions ────────────────────────────────────
1573
1974
 
1975
+ // ─── Auth template content (fullstack + auth) ───────────────────────
1976
+ function fullstackAuthContent(name) {
1977
+ return `// ${name} — Built with Tova
1978
+ // Full-stack app with authentication and security
1979
+
1980
+ shared {
1981
+ type User {
1982
+ id: String
1983
+ email: String
1984
+ role: String
1985
+ }
1986
+ }
1987
+
1988
+ security {
1989
+ cors {
1990
+ origins: ["http://localhost:3000"]
1991
+ methods: ["GET", "POST", "PUT", "DELETE"]
1992
+ credentials: true
1993
+ }
1994
+
1995
+ csrf {
1996
+ enabled: true
1997
+ }
1998
+
1999
+ rate_limit {
2000
+ window: 60
2001
+ max: 100
2002
+ }
2003
+
2004
+ csp {
2005
+ default_src: ["self"]
2006
+ script_src: ["self", "https://cdn.tailwindcss.com"]
2007
+ style_src: ["self", "unsafe-inline"]
2008
+ img_src: ["self", "data:", "https:"]
2009
+ connect_src: ["self"]
2010
+ }
2011
+ }
2012
+
2013
+ auth {
2014
+ secret: env("AUTH_SECRET")
2015
+ token_expires: 900
2016
+ refresh_expires: 604800
2017
+ storage: "cookie"
2018
+
2019
+ provider email {
2020
+ confirm_email: true
2021
+ password_min: 8
2022
+ max_attempts: 5
2023
+ lockout_duration: 900
2024
+ }
2025
+
2026
+ on signup fn(user) {
2027
+ print("New user signed up: " + user.email)
2028
+ }
2029
+
2030
+ on login fn(user) {
2031
+ print("User logged in: " + user.email)
2032
+ }
2033
+
2034
+ on logout fn(user) {
2035
+ print("User logged out: " + user.id)
2036
+ }
2037
+
2038
+ protected_route "/dashboard" { redirect: "/login" }
2039
+ protected_route "/dashboard/*" { redirect: "/login" }
2040
+ protected_route "/settings" { redirect: "/login" }
2041
+ }
2042
+
2043
+ server {
2044
+ fn get_message() {
2045
+ { text: "Hello from ${name}!", timestamp: Date.new().toLocaleTimeString() }
2046
+ }
2047
+
2048
+ route GET "/api/message" => get_message
2049
+ }
2050
+
2051
+ browser {
2052
+ state message = ""
2053
+ state timestamp = ""
2054
+ state refreshing = false
2055
+
2056
+ effect {
2057
+ result = server.get_message()
2058
+ message = result.text
2059
+ timestamp = result.timestamp
2060
+ }
2061
+
2062
+ fn handle_refresh() {
2063
+ refreshing = true
2064
+ result = server.get_message()
2065
+ message = result.text
2066
+ timestamp = result.timestamp
2067
+ refreshing = false
2068
+ }
2069
+
2070
+ // \u2500\u2500\u2500 Navigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2071
+ component NavBar {
2072
+ <nav class="border-b border-gray-200 bg-white shadow-sm sticky top-0 z-10">
2073
+ <div class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
2074
+ <Link href="/" class="flex items-center gap-2 no-underline">
2075
+ <div class="w-8 h-8 bg-gradient-to-br from-emerald-500 to-teal-600 rounded-lg flex items-center justify-center">
2076
+ <span class="text-white font-bold text-sm">"T"</span>
2077
+ </div>
2078
+ <span class="font-bold text-gray-900 text-lg">"${name}"</span>
2079
+ </Link>
2080
+ <div class="flex items-center gap-4">
2081
+ <Link href="/" exactActiveClass="text-emerald-600 font-semibold" class="text-sm font-medium text-gray-500 hover:text-gray-900 no-underline">"Home"</Link>
2082
+ if $isAuthenticated {
2083
+ <Link href="/dashboard" activeClass="text-emerald-600 font-semibold" class="text-sm font-medium text-gray-500 hover:text-gray-900 no-underline">"Dashboard"</Link>
2084
+ <div class="flex items-center gap-3 ml-2 pl-4 border-l border-gray-200">
2085
+ <span class="text-sm text-gray-500">
2086
+ if $currentUser != null {
2087
+ {$currentUser.email}
2088
+ }
2089
+ </span>
2090
+ <button
2091
+ on:click={fn() { logout() }}
2092
+ class="text-sm font-medium text-red-600 hover:text-red-700 bg-red-50 hover:bg-red-100 px-3 py-1.5 rounded-lg transition-colors"
2093
+ >
2094
+ "Sign Out"
2095
+ </button>
2096
+ </div>
2097
+ } else {
2098
+ <Link href="/login" class="text-sm font-medium text-gray-500 hover:text-gray-900 no-underline">"Login"</Link>
2099
+ <Link href="/signup" class="text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 px-4 py-1.5 rounded-lg no-underline transition-colors">"Sign Up"</Link>
2100
+ }
2101
+ </div>
2102
+ </div>
2103
+ </nav>
2104
+ }
2105
+
2106
+ // \u2500\u2500\u2500 Pages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2107
+ component FeatureCard(icon, title, description) {
2108
+ <div class="group relative bg-white rounded-2xl p-6 shadow-sm border border-gray-100 hover:shadow-lg hover:border-emerald-100 transition-all duration-300">
2109
+ <div class="w-10 h-10 bg-emerald-50 rounded-xl flex items-center justify-center text-lg mb-4 group-hover:bg-emerald-100 transition-colors">
2110
+ "{icon}"
2111
+ </div>
2112
+ <h3 class="font-semibold text-gray-900 mb-1">"{title}"</h3>
2113
+ <p class="text-sm text-gray-500 leading-relaxed">"{description}"</p>
2114
+ </div>
2115
+ }
2116
+
2117
+ component HomePage {
2118
+ <main class="max-w-5xl mx-auto px-6">
2119
+ <div class="py-20 text-center">
2120
+ <div class="inline-flex items-center gap-2 bg-emerald-50 text-emerald-700 text-sm font-medium px-4 py-1.5 rounded-full mb-6">
2121
+ <span class="w-1.5 h-1.5 bg-emerald-500 rounded-full animate-pulse"></span>
2122
+ "Secure by Default"
2123
+ </div>
2124
+ <h1 class="text-5xl font-bold text-gray-900 tracking-tight mb-4">"Welcome to " <span class="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">"${name}"</span></h1>
2125
+ <p class="text-xl text-gray-500 max-w-2xl mx-auto mb-10">"A full-stack app with authentication. Edit " <code class="text-sm bg-gray-100 text-emerald-600 px-2 py-1 rounded-md font-mono">"src/app.tova"</code> " to get started."</p>
2126
+
2127
+ if $isAuthenticated {
2128
+ <Link href="/dashboard" class="inline-block bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-6 py-3 rounded-xl no-underline transition-colors">
2129
+ "Go to Dashboard"
2130
+ </Link>
2131
+ } else {
2132
+ <div class="flex items-center justify-center gap-4">
2133
+ <Link href="/signup" class="inline-block bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-6 py-3 rounded-xl no-underline transition-colors">"Get Started"</Link>
2134
+ <Link href="/login" class="inline-block bg-white border border-gray-200 hover:border-gray-300 text-gray-700 font-medium px-6 py-3 rounded-xl no-underline transition-colors">"Sign In"</Link>
2135
+ </div>
2136
+ }
2137
+
2138
+ if timestamp != "" {
2139
+ <p class="text-xs text-gray-400 mt-6">"Server time: " "{timestamp}"</p>
2140
+ }
2141
+ </div>
2142
+
2143
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-5 pb-20">
2144
+ <FeatureCard
2145
+ icon="\u2699"
2146
+ title="Full-Stack"
2147
+ description="Server and client in one file. Shared types, RPC calls, and reactive UI."
2148
+ />
2149
+ <FeatureCard
2150
+ icon="\uD83D\uDD12"
2151
+ title="Auth Built-in"
2152
+ description="Email signup, login, password reset, and JWT sessions \u2014 secure by default."
2153
+ />
2154
+ <FeatureCard
2155
+ icon="\uD83D\uDEE1"
2156
+ title="Security Hardened"
2157
+ description="CORS, CSRF, CSP, rate limiting, brute-force lockout, and HttpOnly cookies."
2158
+ />
2159
+ </div>
2160
+ </main>
2161
+ }
2162
+
2163
+ // \u2500\u2500\u2500 Auth Pages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2164
+ component LoginPage {
2165
+ <main class="max-w-md mx-auto px-6 py-16">
2166
+ <h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">"Sign In"</h2>
2167
+ <div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
2168
+ <LoginForm />
2169
+ <div class="mt-6 text-center">
2170
+ <Link href="/forgot-password" class="text-sm text-emerald-600 hover:text-emerald-700 no-underline">"Forgot your password?"</Link>
2171
+ </div>
2172
+ <div class="mt-4 text-center">
2173
+ <span class="text-sm text-gray-500">"Don't have an account? "</span>
2174
+ <Link href="/signup" class="text-sm text-emerald-600 hover:text-emerald-700 font-medium no-underline">"Sign Up"</Link>
2175
+ </div>
2176
+ </div>
2177
+ </main>
2178
+ }
2179
+
2180
+ component SignupPage {
2181
+ <main class="max-w-md mx-auto px-6 py-16">
2182
+ <h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">"Create Account"</h2>
2183
+ <div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
2184
+ <SignupForm />
2185
+ <div class="mt-4 text-center">
2186
+ <span class="text-sm text-gray-500">"Already have an account? "</span>
2187
+ <Link href="/login" class="text-sm text-emerald-600 hover:text-emerald-700 font-medium no-underline">"Sign In"</Link>
2188
+ </div>
2189
+ </div>
2190
+ </main>
2191
+ }
2192
+
2193
+ component ForgotPasswordPage {
2194
+ <main class="max-w-md mx-auto px-6 py-16">
2195
+ <h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">"Reset Password"</h2>
2196
+ <div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
2197
+ <ForgotPasswordForm />
2198
+ <div class="mt-4 text-center">
2199
+ <Link href="/login" class="text-sm text-emerald-600 hover:text-emerald-700 no-underline">"Back to login"</Link>
2200
+ </div>
2201
+ </div>
2202
+ </main>
2203
+ }
2204
+
2205
+ // \u2500\u2500\u2500 Protected Pages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2206
+ component DashboardPage {
2207
+ <AuthGuard redirect="/login">
2208
+ <main class="max-w-5xl mx-auto px-6 py-8">
2209
+ <div class="mb-8">
2210
+ <h2 class="text-2xl font-bold text-gray-900">"Dashboard"</h2>
2211
+ <p class="text-gray-500">
2212
+ "Welcome back"
2213
+ if $currentUser != null {
2214
+ ", " "{$currentUser.email}"
2215
+ }
2216
+ </p>
2217
+ </div>
2218
+
2219
+ <div class="bg-white rounded-xl border border-gray-200 p-8">
2220
+ <div class="text-center">
2221
+ <div class="inline-flex items-center gap-3 bg-emerald-50 border border-emerald-200 rounded-2xl p-3">
2222
+ <div class="bg-gradient-to-r from-emerald-500 to-teal-500 text-white px-5 py-2.5 rounded-xl font-medium">
2223
+ "{message}"
2224
+ </div>
2225
+ <button
2226
+ on:click={handle_refresh}
2227
+ class="px-4 py-2.5 text-gray-500 hover:text-emerald-600 hover:bg-emerald-50 rounded-xl transition-all font-medium text-sm"
2228
+ >
2229
+ if refreshing { "..." } else { "Refresh" }
2230
+ </button>
2231
+ </div>
2232
+ if timestamp != "" {
2233
+ <p class="text-xs text-gray-400 mt-3">"Server time: " "{timestamp}"</p>
2234
+ }
2235
+ </div>
2236
+ </div>
2237
+ </main>
2238
+ </AuthGuard>
2239
+ }
2240
+
2241
+ component SettingsPage {
2242
+ <AuthGuard redirect="/login">
2243
+ <main class="max-w-2xl mx-auto px-6 py-8">
2244
+ <h2 class="text-2xl font-bold text-gray-900 mb-6">"Settings"</h2>
2245
+ <div class="bg-white rounded-xl border border-gray-200 p-6">
2246
+ <h3 class="font-semibold text-gray-900 mb-4">"Account"</h3>
2247
+ if $currentUser != null {
2248
+ <div class="space-y-3">
2249
+ <div>
2250
+ <span class="text-sm text-gray-500">"Email: "</span>
2251
+ <span class="text-sm font-medium text-gray-900">{$currentUser.email}</span>
2252
+ </div>
2253
+ <div>
2254
+ <span class="text-sm text-gray-500">"Role: "</span>
2255
+ <span class="text-sm font-medium text-gray-900">{$currentUser.role}</span>
2256
+ </div>
2257
+ </div>
2258
+ }
2259
+ </div>
2260
+ </main>
2261
+ </AuthGuard>
2262
+ }
2263
+
2264
+ component NotFoundPage {
2265
+ <div class="max-w-5xl mx-auto px-6 py-16 text-center">
2266
+ <h1 class="text-6xl font-bold text-gray-200 mb-4">"404"</h1>
2267
+ <p class="text-lg text-gray-500 mb-6">"Page not found"</p>
2268
+ <Link href="/" class="text-emerald-600 hover:text-emerald-700 font-medium no-underline">"Go home"</Link>
2269
+ </div>
2270
+ }
2271
+
2272
+ // \u2500\u2500\u2500 Router \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2273
+ createRouter({
2274
+ routes: {
2275
+ "/": HomePage,
2276
+ "/login": LoginPage,
2277
+ "/signup": SignupPage,
2278
+ "/forgot-password": ForgotPasswordPage,
2279
+ "/dashboard": DashboardPage,
2280
+ "/settings": SettingsPage,
2281
+ "404": NotFoundPage,
2282
+ },
2283
+ scroll: "auto",
2284
+ })
2285
+
2286
+ component App {
2287
+ <div class="min-h-screen bg-gradient-to-br from-slate-50 via-white to-emerald-50">
2288
+ <NavBar />
2289
+ <Router />
2290
+ <div class="border-t border-gray-100 py-8 text-center">
2291
+ <p class="text-sm text-gray-400">"Built with " <a href="https://github.com/tova-lang/tova-lang" class="text-emerald-500 hover:text-emerald-600 transition-colors">"Tova"</a></p>
2292
+ </div>
2293
+ </div>
2294
+ }
2295
+ }
2296
+ `;
2297
+ }
2298
+
2299
+ // ─── Auth template content (SPA + auth) ──────────────────────────
2300
+ function spaAuthContent(name) {
2301
+ return `// ${name} — Built with Tova
2302
+ // Single-page app with authentication, nested routes, and dynamic params
2303
+
2304
+ shared {
2305
+ type User {
2306
+ id: String
2307
+ email: String
2308
+ role: String
2309
+ }
2310
+ }
2311
+
2312
+ security {
2313
+ cors {
2314
+ origins: ["http://localhost:3000"]
2315
+ methods: ["GET", "POST", "PUT", "DELETE"]
2316
+ credentials: true
2317
+ }
2318
+
2319
+ csrf {
2320
+ enabled: true
2321
+ }
2322
+
2323
+ rate_limit {
2324
+ window: 60
2325
+ max: 100
2326
+ }
2327
+
2328
+ csp {
2329
+ default_src: ["self"]
2330
+ script_src: ["self", "https://cdn.tailwindcss.com"]
2331
+ style_src: ["self", "unsafe-inline"]
2332
+ img_src: ["self", "data:", "https:"]
2333
+ connect_src: ["self"]
2334
+ }
2335
+ }
2336
+
2337
+ auth {
2338
+ secret: env("AUTH_SECRET")
2339
+ token_expires: 900
2340
+ refresh_expires: 604800
2341
+ storage: "cookie"
2342
+
2343
+ provider email {
2344
+ confirm_email: true
2345
+ password_min: 8
2346
+ max_attempts: 5
2347
+ lockout_duration: 900
2348
+ }
2349
+
2350
+ on signup fn(user) {
2351
+ print("New user signed up: " + user.email)
2352
+ }
2353
+
2354
+ on login fn(user) {
2355
+ print("User logged in: " + user.email)
2356
+ }
2357
+
2358
+ on logout fn(user) {
2359
+ print("User logged out: " + user.id)
2360
+ }
2361
+
2362
+ protected_route "/dashboard" { redirect: "/login" }
2363
+ protected_route "/profile/*" { redirect: "/login" }
2364
+ }
2365
+
2366
+ server {
2367
+ // Auth endpoints (signup, login, logout, etc.) are generated automatically
2368
+ fn health_check() {
2369
+ { status: "ok" }
2370
+ }
2371
+
2372
+ route GET "/api/health" => health_check
2373
+ }
2374
+
2375
+ browser {
2376
+ // \u2500\u2500\u2500 Navigation bar with auth-aware links \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2377
+ component NavBar {
2378
+ <nav class="bg-white border-b border-gray-100 sticky top-0 z-10">
2379
+ <div class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
2380
+ <Link href="/" class="flex items-center gap-2 no-underline">
2381
+ <div class="w-8 h-8 bg-gradient-to-br from-emerald-500 to-teal-600 rounded-lg flex items-center justify-center">
2382
+ <span class="text-white font-bold text-sm">"T"</span>
2383
+ </div>
2384
+ <span class="font-bold text-gray-900 text-lg">"${name}"</span>
2385
+ </Link>
2386
+ <div class="flex items-center gap-4">
2387
+ <Link href="/" exactActiveClass="text-emerald-600 font-semibold" class="text-sm font-medium text-gray-500 hover:text-gray-900 no-underline">"Home"</Link>
2388
+ if $isAuthenticated {
2389
+ <Link href="/dashboard" activeClass="text-emerald-600 font-semibold" class="text-sm font-medium text-gray-500 hover:text-gray-900 no-underline">"Dashboard"</Link>
2390
+ <Link href="/profile" activeClass="text-emerald-600 font-semibold" class="text-sm font-medium text-gray-500 hover:text-gray-900 no-underline">"Profile"</Link>
2391
+ <div class="flex items-center gap-3 ml-2 pl-4 border-l border-gray-200">
2392
+ <span class="text-sm text-gray-500">
2393
+ if $currentUser != null {
2394
+ {$currentUser.email}
2395
+ }
2396
+ </span>
2397
+ <button
2398
+ on:click={fn() { logout() }}
2399
+ class="text-sm font-medium text-red-600 hover:text-red-700 bg-red-50 hover:bg-red-100 px-3 py-1.5 rounded-lg transition-colors"
2400
+ >
2401
+ "Sign Out"
2402
+ </button>
2403
+ </div>
2404
+ } else {
2405
+ <Link href="/login" class="text-sm font-medium text-gray-500 hover:text-gray-900 no-underline">"Login"</Link>
2406
+ <Link href="/signup" class="text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 px-4 py-1.5 rounded-lg no-underline transition-colors">"Sign Up"</Link>
2407
+ }
2408
+ </div>
2409
+ </div>
2410
+ </nav>
2411
+ }
2412
+
2413
+ // \u2500\u2500\u2500 Home page \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2414
+ component HomePage {
2415
+ <div class="max-w-5xl mx-auto px-6 py-16 text-center">
2416
+ <div class="inline-flex items-center gap-2 bg-emerald-50 text-emerald-700 text-sm font-medium px-4 py-1.5 rounded-full mb-6">
2417
+ <span class="w-1.5 h-1.5 bg-emerald-500 rounded-full animate-pulse"></span>
2418
+ "Secure SPA"
2419
+ </div>
2420
+ <h1 class="text-4xl font-bold text-gray-900 mb-4">"Welcome to " <span class="text-emerald-600">"${name}"</span></h1>
2421
+ <p class="text-lg text-gray-500 mb-8">"A single-page app with authentication, nested routes, and dynamic params."</p>
2422
+ if $isAuthenticated {
2423
+ <Link href="/dashboard" class="inline-block bg-emerald-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-emerald-700 transition-colors no-underline">"Go to Dashboard"</Link>
2424
+ } else {
2425
+ <div class="flex items-center justify-center gap-4">
2426
+ <Link href="/signup" class="inline-block bg-emerald-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-emerald-700 transition-colors no-underline">"Get Started"</Link>
2427
+ <Link href="/login" 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">"Sign In"</Link>
2428
+ </div>
2429
+ }
2430
+ </div>
2431
+ }
2432
+
2433
+ // \u2500\u2500\u2500 Auth Pages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2434
+ component LoginPage {
2435
+ <main class="max-w-md mx-auto px-6 py-16">
2436
+ <h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">"Sign In"</h2>
2437
+ <div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
2438
+ <LoginForm />
2439
+ <div class="mt-6 text-center">
2440
+ <Link href="/forgot-password" class="text-sm text-emerald-600 hover:text-emerald-700 no-underline">"Forgot your password?"</Link>
2441
+ </div>
2442
+ <div class="mt-4 text-center">
2443
+ <span class="text-sm text-gray-500">"Don't have an account? "</span>
2444
+ <Link href="/signup" class="text-sm text-emerald-600 hover:text-emerald-700 font-medium no-underline">"Sign Up"</Link>
2445
+ </div>
2446
+ </div>
2447
+ </main>
2448
+ }
2449
+
2450
+ component SignupPage {
2451
+ <main class="max-w-md mx-auto px-6 py-16">
2452
+ <h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">"Create Account"</h2>
2453
+ <div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
2454
+ <SignupForm />
2455
+ <div class="mt-4 text-center">
2456
+ <span class="text-sm text-gray-500">"Already have an account? "</span>
2457
+ <Link href="/login" class="text-sm text-emerald-600 hover:text-emerald-700 font-medium no-underline">"Sign In"</Link>
2458
+ </div>
2459
+ </div>
2460
+ </main>
2461
+ }
2462
+
2463
+ component ForgotPasswordPage {
2464
+ <main class="max-w-md mx-auto px-6 py-16">
2465
+ <h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">"Reset Password"</h2>
2466
+ <div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
2467
+ <ForgotPasswordForm />
2468
+ <div class="mt-4 text-center">
2469
+ <Link href="/login" class="text-sm text-emerald-600 hover:text-emerald-700 no-underline">"Back to login"</Link>
2470
+ </div>
2471
+ </div>
2472
+ </main>
2473
+ }
2474
+
2475
+ // \u2500\u2500\u2500 Dashboard (protected) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2476
+ component DashboardPage {
2477
+ <AuthGuard redirect="/login">
2478
+ <main class="max-w-5xl mx-auto px-6 py-8">
2479
+ <h2 class="text-2xl font-bold text-gray-900 mb-2">"Dashboard"</h2>
2480
+ <p class="text-gray-500 mb-8">
2481
+ "Welcome back"
2482
+ if $currentUser != null {
2483
+ ", " "{$currentUser.email}"
2484
+ }
2485
+ </p>
2486
+ <div class="bg-white rounded-xl border border-gray-200 p-6">
2487
+ <p class="text-gray-600">"This is a protected page. Only authenticated users can see this."</p>
2488
+ </div>
2489
+ </main>
2490
+ </AuthGuard>
2491
+ }
2492
+
2493
+ // \u2500\u2500\u2500 Profile layout with nested routes + Outlet \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2494
+ component ProfileLayout {
2495
+ <AuthGuard redirect="/login">
2496
+ <div class="max-w-5xl mx-auto px-6 py-8">
2497
+ <h2 class="text-2xl font-bold text-gray-900 mb-6">"Profile"</h2>
2498
+ <div class="flex gap-8">
2499
+ <aside class="w-48 flex-shrink-0">
2500
+ <div class="flex flex-col gap-1">
2501
+ <Link href="/profile/account" activeClass="bg-emerald-50 text-emerald-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>
2502
+ <Link href="/profile/security" activeClass="bg-emerald-50 text-emerald-700" class="block px-3 py-2 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-50 no-underline transition-colors">"Security"</Link>
2503
+ </div>
2504
+ </aside>
2505
+ <div class="flex-1 min-w-0">
2506
+ <Outlet />
2507
+ </div>
2508
+ </div>
2509
+ </div>
2510
+ </AuthGuard>
2511
+ }
2512
+
2513
+ component AccountSettings {
2514
+ <div class="bg-white rounded-xl border border-gray-200 p-6">
2515
+ <h3 class="text-lg font-semibold text-gray-900 mb-4">"Account Settings"</h3>
2516
+ if $currentUser != null {
2517
+ <div class="space-y-4">
2518
+ <div>
2519
+ <label class="block text-sm font-medium text-gray-700 mb-1">"Email"</label>
2520
+ <div class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm text-gray-900">{$currentUser.email}</div>
2521
+ </div>
2522
+ <div>
2523
+ <label class="block text-sm font-medium text-gray-700 mb-1">"Role"</label>
2524
+ <div class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm text-gray-900">{$currentUser.role}</div>
2525
+ </div>
2526
+ </div>
2527
+ }
2528
+ </div>
2529
+ }
2530
+
2531
+ component SecuritySettings {
2532
+ <div class="bg-white rounded-xl border border-gray-200 p-6">
2533
+ <h3 class="text-lg font-semibold text-gray-900 mb-4">"Security Settings"</h3>
2534
+ <p class="text-gray-600 mb-4">"Manage your password and security preferences."</p>
2535
+ <div class="pt-4 border-t border-gray-100">
2536
+ <Link href="/forgot-password" class="text-sm text-emerald-600 hover:text-emerald-700 font-medium no-underline">"Change Password"</Link>
2537
+ </div>
2538
+ </div>
2539
+ }
2540
+
2541
+ // \u2500\u2500\u2500 404 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2542
+ component NotFoundPage {
2543
+ <div class="max-w-5xl mx-auto px-6 py-16 text-center">
2544
+ <h1 class="text-6xl font-bold text-gray-200 mb-4">"404"</h1>
2545
+ <p class="text-lg text-gray-500 mb-6">"Page not found"</p>
2546
+ <Link href="/" class="text-emerald-600 hover:text-emerald-700 font-medium no-underline">"Go home"</Link>
2547
+ </div>
2548
+ }
2549
+
2550
+ // \u2500\u2500\u2500 Router \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2551
+ createRouter({
2552
+ routes: {
2553
+ "/": HomePage,
2554
+ "/login": LoginPage,
2555
+ "/signup": SignupPage,
2556
+ "/forgot-password": ForgotPasswordPage,
2557
+ "/dashboard": { component: DashboardPage, meta: { title: "Dashboard" } },
2558
+ "/profile": {
2559
+ component: ProfileLayout,
2560
+ children: {
2561
+ "/account": { component: AccountSettings, meta: { title: "Account" } },
2562
+ "/security": { component: SecuritySettings, meta: { title: "Security" } },
2563
+ },
2564
+ },
2565
+ "404": NotFoundPage,
2566
+ },
2567
+ scroll: "auto",
2568
+ })
2569
+
2570
+ // \u2500\u2500\u2500 Update document title from route meta \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2571
+ afterNavigate(fn(current) {
2572
+ if current.meta != undefined {
2573
+ if current.meta.title != undefined {
2574
+ document.title = "{current.meta.title} | ${name}"
2575
+ }
2576
+ }
2577
+ })
2578
+
2579
+ component App {
2580
+ <div class="min-h-screen bg-gray-50">
2581
+ <NavBar />
2582
+ <Router />
2583
+ </div>
2584
+ }
2585
+ }
2586
+ `;
2587
+ }
2588
+
1574
2589
  const PROJECT_TEMPLATES = {
1575
2590
  fullstack: {
1576
2591
  label: 'Full-stack app',
1577
- description: 'server + client + shared blocks',
2592
+ description: 'server + browser + shared blocks',
1578
2593
  tomlDescription: 'A full-stack Tova application',
1579
2594
  entry: 'src',
1580
2595
  file: 'src/app.tova',
1581
2596
  content: name => `// ${name} — Built with Tova
2597
+ // Full-stack app: server RPC + client-side routing
1582
2598
 
1583
2599
  shared {
1584
2600
  type Message {
@@ -1595,7 +2611,7 @@ server {
1595
2611
  route GET "/api/message" => get_message
1596
2612
  }
1597
2613
 
1598
- client {
2614
+ browser {
1599
2615
  state message = ""
1600
2616
  state timestamp = ""
1601
2617
  state refreshing = false
@@ -1614,6 +2630,23 @@ client {
1614
2630
  refreshing = false
1615
2631
  }
1616
2632
 
2633
+ // ─── Navigation ─────────────────────────────────────────
2634
+ component NavBar {
2635
+ <nav class="border-b border-gray-100 bg-white/80 backdrop-blur-sm sticky top-0 z-10">
2636
+ <div class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
2637
+ <Link href="/" class="flex items-center gap-2 no-underline">
2638
+ <div class="w-8 h-8 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-lg"></div>
2639
+ <span class="font-bold text-gray-900 text-lg">"${name}"</span>
2640
+ </Link>
2641
+ <div class="flex items-center gap-6">
2642
+ <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>
2643
+ <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>
2644
+ </div>
2645
+ </div>
2646
+ </nav>
2647
+ }
2648
+
2649
+ // ─── Pages ──────────────────────────────────────────────
1617
2650
  component FeatureCard(icon, title, description) {
1618
2651
  <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
2652
  <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 +2657,422 @@ client {
1624
2657
  </div>
1625
2658
  }
1626
2659
 
2660
+ component HomePage {
2661
+ <main class="max-w-5xl mx-auto px-6">
2662
+ <div class="py-20 text-center">
2663
+ <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">
2664
+ <span class="w-1.5 h-1.5 bg-indigo-500 rounded-full"></span>
2665
+ "Powered by Tova"
2666
+ </div>
2667
+ <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>
2668
+ <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>
2669
+
2670
+ <div class="inline-flex items-center gap-3 bg-white border border-gray-200 rounded-2xl p-2 shadow-sm">
2671
+ <div class="bg-gradient-to-r from-indigo-500 to-purple-500 text-white px-5 py-2.5 rounded-xl font-medium">
2672
+ "{message}"
2673
+ </div>
2674
+ <button
2675
+ on:click={handle_refresh}
2676
+ 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"
2677
+ >
2678
+ if refreshing {
2679
+ "..."
2680
+ } else {
2681
+ "Refresh"
2682
+ }
2683
+ </button>
2684
+ </div>
2685
+ if timestamp != "" {
2686
+ <p class="text-xs text-gray-400 mt-3">"Last fetched at " "{timestamp}"</p>
2687
+ }
2688
+ </div>
2689
+
2690
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-5 pb-20">
2691
+ <FeatureCard
2692
+ icon="\u2699"
2693
+ title="Full-Stack"
2694
+ description="Server and client in one file. Shared types, RPC calls, and reactive UI — all type-safe."
2695
+ />
2696
+ <FeatureCard
2697
+ icon="\u26A1"
2698
+ title="Fast Refresh"
2699
+ description="Edit your code and see changes instantly. The dev server recompiles on save."
2700
+ />
2701
+ <FeatureCard
2702
+ icon="\uD83C\uDFA8"
2703
+ title="Tailwind Built-in"
2704
+ description="Style with utility classes out of the box. No config or build step needed."
2705
+ />
2706
+ </div>
2707
+ </main>
2708
+ }
2709
+
2710
+ component AboutPage {
2711
+ <main class="max-w-5xl mx-auto px-6 py-12">
2712
+ <h2 class="text-3xl font-bold text-gray-900 mb-6">"About"</h2>
2713
+ <div class="bg-white rounded-xl border border-gray-200 p-8 space-y-4">
2714
+ <p class="text-gray-600 leading-relaxed">"${name} is a full-stack application built with Tova — a modern language that compiles to JavaScript."</p>
2715
+ <p class="text-gray-600 leading-relaxed">"It uses shared types between server and browser, server-side RPC, and client-side routing."</p>
2716
+ </div>
2717
+ <div class="mt-8">
2718
+ <Link href="/" class="text-indigo-600 hover:text-indigo-700 font-medium no-underline">"\u2190 Back to home"</Link>
2719
+ </div>
2720
+ </main>
2721
+ }
2722
+
2723
+ component NotFoundPage {
2724
+ <div class="max-w-5xl mx-auto px-6 py-16 text-center">
2725
+ <h1 class="text-6xl font-bold text-gray-200 mb-4">"404"</h1>
2726
+ <p class="text-lg text-gray-500 mb-6">"Page not found"</p>
2727
+ <Link href="/" class="text-indigo-600 hover:text-indigo-700 font-medium no-underline">"Go home"</Link>
2728
+ </div>
2729
+ }
2730
+
2731
+ // ─── Router setup ─────────────────────────────────────────
2732
+ createRouter({
2733
+ routes: {
2734
+ "/": HomePage,
2735
+ "/about": { component: AboutPage, meta: { title: "About" } },
2736
+ "404": NotFoundPage,
2737
+ },
2738
+ scroll: "auto",
2739
+ })
2740
+
1627
2741
  component App {
1628
2742
  <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>
2743
+ <NavBar />
2744
+ <Router />
2745
+ <div class="border-t border-gray-100 py-8 text-center">
2746
+ <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>
2747
+ </div>
2748
+ </div>
2749
+ }
2750
+ }
2751
+ `,
2752
+ nextSteps: name => ` cd ${name}\n tova dev`,
2753
+ hasAuthOption: true,
2754
+ authContent: fullstackAuthContent,
2755
+ authNextSteps: name => ` cd ${name}\n tova dev\n\n ${color.dim('Auth is ready! Sign up at')} ${color.cyan('http://localhost:3000/signup')}`,
2756
+ },
2757
+ spa: {
2758
+ label: 'Single-page app',
2759
+ description: 'browser-only app with routing',
2760
+ tomlDescription: 'A Tova single-page application',
2761
+ entry: 'src',
2762
+ file: 'src/app.tova',
2763
+ content: name => `// ${name} — Built with Tova
2764
+ // Demonstrates: createRouter, Link, Router, Outlet, navigate(),
2765
+ // dynamic :param routes, nested routes, route meta, 404 handling
2766
+
2767
+ browser {
2768
+ // ─── Navigation bar with active link highlighting ─────────
2769
+ component NavBar {
2770
+ <nav class="bg-white border-b border-gray-100 sticky top-0 z-10">
2771
+ <div class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
2772
+ <Link href="/" class="flex items-center gap-2 no-underline">
2773
+ <div class="w-8 h-8 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-lg"></div>
2774
+ <span class="font-bold text-gray-900 text-lg">"${name}"</span>
2775
+ </Link>
2776
+ <div class="flex items-center gap-6">
2777
+ <Link href="/" exactActiveClass="text-indigo-600 font-semibold" class="text-sm font-medium transition-colors text-gray-500 hover:text-gray-900">"Home"</Link>
2778
+ <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>
2779
+ <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>
2780
+ </div>
2781
+ </div>
2782
+ </nav>
2783
+ }
2784
+
2785
+ // ─── Home page ────────────────────────────────────────────
2786
+ component HomePage {
2787
+ <div class="max-w-5xl mx-auto px-6 py-16 text-center">
2788
+ <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">
2789
+ <span class="w-1.5 h-1.5 bg-indigo-500 rounded-full"></span>
2790
+ "Tova Router"
2791
+ </div>
2792
+ <h1 class="text-4xl font-bold text-gray-900 mb-4">"Welcome to " <span class="text-indigo-600">"${name}"</span></h1>
2793
+ <p class="text-lg text-gray-500 mb-8">"A single-page app with client-side routing. Click around to explore."</p>
2794
+ <div class="flex items-center justify-center gap-4">
2795
+ <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>
2796
+ <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>
2797
+ </div>
2798
+ </div>
2799
+ }
2800
+
2801
+ // ─── Users list (demonstrates programmatic navigation) ────
2802
+ fn go_to_user(uid) {
2803
+ navigate("/users/{uid}")
2804
+ }
2805
+
2806
+ component UsersPage {
2807
+ <div class="max-w-5xl mx-auto px-6 py-12">
2808
+ <h2 class="text-2xl font-bold text-gray-900 mb-6">"Users"</h2>
2809
+ <div class="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
2810
+ <div class="flex items-center justify-between p-4 hover:bg-gray-50 cursor-pointer transition-colors" on:click={fn() go_to_user("1")}>
2811
+ <div class="flex items-center gap-3">
2812
+ <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>
2813
+ <div>
2814
+ <p class="font-medium text-gray-900">"alice"</p>
2815
+ <p class="text-xs text-gray-500">"Admin"</p>
2816
+ </div>
2817
+ </div>
2818
+ <span class="text-gray-400 text-sm">"View \u2192"</span>
2819
+ </div>
2820
+ <div class="flex items-center justify-between p-4 hover:bg-gray-50 cursor-pointer transition-colors" on:click={fn() go_to_user("2")}>
2821
+ <div class="flex items-center gap-3">
2822
+ <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>
2823
+ <div>
2824
+ <p class="font-medium text-gray-900">"bob"</p>
2825
+ <p class="text-xs text-gray-500">"Editor"</p>
2826
+ </div>
1634
2827
  </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>
2828
+ <span class="text-gray-400 text-sm">"View \u2192"</span>
2829
+ </div>
2830
+ <div class="flex items-center justify-between p-4 hover:bg-gray-50 cursor-pointer transition-colors" on:click={fn() go_to_user("3")}>
2831
+ <div class="flex items-center gap-3">
2832
+ <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>
2833
+ <div>
2834
+ <p class="font-medium text-gray-900">"charlie"</p>
2835
+ <p class="text-xs text-gray-500">"Viewer"</p>
2836
+ </div>
1638
2837
  </div>
2838
+ <span class="text-gray-400 text-sm">"View \u2192"</span>
1639
2839
  </div>
1640
- </nav>
2840
+ </div>
2841
+ </div>
2842
+ }
1641
2843
 
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"
2844
+ // ─── User detail (demonstrates :id dynamic route param) ───
2845
+ component UserPage(id) {
2846
+ <div class="max-w-5xl mx-auto px-6 py-12">
2847
+ <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">
2848
+ "\u2190 Back to users"
2849
+ </button>
2850
+ <div class="bg-white rounded-xl border border-gray-200 p-8">
2851
+ <div class="flex items-center gap-4 mb-6">
2852
+ <div class="w-14 h-14 bg-indigo-100 text-indigo-600 rounded-full flex items-center justify-center font-bold text-xl">
2853
+ "#{id}"
2854
+ </div>
2855
+ <div>
2856
+ <h2 class="text-2xl font-bold text-gray-900">"User {id}"</h2>
2857
+ <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>
1647
2858
  </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>
2859
+ </div>
2860
+ <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>
2861
+ </div>
2862
+ </div>
2863
+ }
1650
2864
 
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>
2865
+ // ─── Settings layout with nested routes + Outlet ──────────
2866
+ component SettingsLayout {
2867
+ <div class="max-w-5xl mx-auto px-6 py-12">
2868
+ <h2 class="text-2xl font-bold text-gray-900 mb-6">"Settings"</h2>
2869
+ <div class="flex gap-8">
2870
+ <aside class="w-48 flex-shrink-0">
2871
+ <div class="flex flex-col gap-1">
2872
+ <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>
2873
+ <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
2874
  </div>
1666
- if timestamp != "" {
1667
- <p class="text-xs text-gray-400 mt-3">"Last fetched at " "{timestamp}"</p>
1668
- }
2875
+ </aside>
2876
+ <div class="flex-1 min-w-0">
2877
+ <Outlet />
1669
2878
  </div>
2879
+ </div>
2880
+ </div>
2881
+ }
1670
2882
 
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
- />
2883
+ component ProfileSettings {
2884
+ <div class="bg-white rounded-xl border border-gray-200 p-6">
2885
+ <h3 class="text-lg font-semibold text-gray-900 mb-4">"Profile Settings"</h3>
2886
+ <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>
2887
+ <div class="space-y-4">
2888
+ <div>
2889
+ <label class="block text-sm font-medium text-gray-700 mb-1">"Display Name"</label>
2890
+ <div class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm text-gray-900">"Alice"</div>
2891
+ </div>
2892
+ <div>
2893
+ <label class="block text-sm font-medium text-gray-700 mb-1">"Bio"</label>
2894
+ <div class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm text-gray-500">"Tova developer"</div>
1687
2895
  </div>
2896
+ </div>
2897
+ </div>
2898
+ }
1688
2899
 
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>
2900
+ component AccountSettings {
2901
+ <div class="bg-white rounded-xl border border-gray-200 p-6">
2902
+ <h3 class="text-lg font-semibold text-gray-900 mb-4">"Account Settings"</h3>
2903
+ <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>
2904
+ <div class="space-y-4">
2905
+ <div>
2906
+ <label class="block text-sm font-medium text-gray-700 mb-1">"Email"</label>
2907
+ <div class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm text-gray-900">"alice@example.com"</div>
2908
+ </div>
2909
+ <div class="pt-4 border-t border-gray-100">
2910
+ <button class="text-sm text-red-600 hover:text-red-700 font-medium cursor-pointer bg-transparent border-0">"Delete Account"</button>
1691
2911
  </div>
2912
+ </div>
2913
+ </div>
2914
+ }
2915
+
2916
+ // ─── 404 page ─────────────────────────────────────────────
2917
+ component NotFoundPage {
2918
+ <div class="max-w-5xl mx-auto px-6 py-16 text-center">
2919
+ <h1 class="text-6xl font-bold text-gray-200 mb-4">"404"</h1>
2920
+ <p class="text-lg text-gray-500 mb-6">"Page not found"</p>
2921
+ <Link href="/" class="text-indigo-600 hover:text-indigo-700 font-medium no-underline">"Go home"</Link>
2922
+ </div>
2923
+ }
2924
+
2925
+ // ─── Router setup ─────────────────────────────────────────
2926
+ createRouter({
2927
+ routes: {
2928
+ "/": HomePage,
2929
+ "/users": { component: UsersPage, meta: { title: "Users" } },
2930
+ "/users/:id": { component: UserPage, meta: { title: "User Detail" } },
2931
+ "/settings": {
2932
+ component: SettingsLayout,
2933
+ children: {
2934
+ "/profile": { component: ProfileSettings, meta: { title: "Profile" } },
2935
+ "/account": { component: AccountSettings, meta: { title: "Account" } },
2936
+ },
2937
+ },
2938
+ "404": NotFoundPage,
2939
+ },
2940
+ scroll: "auto",
2941
+ })
2942
+
2943
+ // ─── Update document title from route meta ────────────────
2944
+ afterNavigate(fn(current) {
2945
+ if current.meta != undefined {
2946
+ if current.meta.title != undefined {
2947
+ document.title = "{current.meta.title} | ${name}"
2948
+ }
2949
+ }
2950
+ })
2951
+
2952
+ component App {
2953
+ <div class="min-h-screen bg-gray-50">
2954
+ <NavBar />
2955
+ <Router />
2956
+ </div>
2957
+ }
2958
+ }
2959
+ `,
2960
+ nextSteps: name => ` cd ${name}\n tova dev`,
2961
+ hasAuthOption: true,
2962
+ authContent: spaAuthContent,
2963
+ authNextSteps: name => ` cd ${name}\n tova dev\n\n ${color.dim('Auth is ready! Sign up at')} ${color.cyan('http://localhost:3000/signup')}`,
2964
+ },
2965
+ site: {
2966
+ label: 'Static site',
2967
+ description: 'docs or marketing site with pages',
2968
+ tomlDescription: 'A Tova static site',
2969
+ entry: 'src',
2970
+ file: 'src/app.tova',
2971
+ extraFiles: [
2972
+ {
2973
+ path: 'src/pages/home.tova',
2974
+ content: name => `pub component HomePage {
2975
+ <div class="max-w-4xl mx-auto px-6 py-16">
2976
+ <h1 class="text-4xl font-bold text-gray-900 mb-4">"Welcome to ${name}"</h1>
2977
+ <p class="text-lg text-gray-600 mb-8">"A static site built with Tova. Fast, simple, and easy to deploy anywhere."</p>
2978
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
2979
+ <div class="bg-white rounded-xl border border-gray-200 p-6">
2980
+ <h3 class="font-semibold text-gray-900 mb-2">"Fast by default"</h3>
2981
+ <p class="text-gray-500 text-sm">"Client-side routing for smooth, instant navigation between pages."</p>
2982
+ </div>
2983
+ <div class="bg-white rounded-xl border border-gray-200 p-6">
2984
+ <h3 class="font-semibold text-gray-900 mb-2">"Deploy anywhere"</h3>
2985
+ <p class="text-gray-500 text-sm">"GitHub Pages, Netlify, Vercel, Firebase — works with any static host."</p>
2986
+ </div>
2987
+ </div>
2988
+ </div>
2989
+ }
2990
+ `,
2991
+ },
2992
+ {
2993
+ path: 'src/pages/docs.tova',
2994
+ content: name => `pub component DocsPage {
2995
+ <div class="max-w-4xl mx-auto px-6 py-12">
2996
+ <h1 class="text-3xl font-bold text-gray-900 mb-6">"Documentation"</h1>
2997
+ <div class="prose">
2998
+ <h2 class="text-xl font-semibold text-gray-900 mt-8 mb-3">"Getting Started"</h2>
2999
+ <p class="text-gray-600 mb-4">"Add your documentation content here. Each page is a Tova component with its own route."</p>
3000
+ <h2 class="text-xl font-semibold text-gray-900 mt-8 mb-3">"Adding Pages"</h2>
3001
+ <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>
3002
+ </div>
3003
+ </div>
3004
+ }
3005
+ `,
3006
+ },
3007
+ {
3008
+ path: 'src/pages/about.tova',
3009
+ content: name => `pub component AboutPage {
3010
+ <div class="max-w-4xl mx-auto px-6 py-12">
3011
+ <h1 class="text-3xl font-bold text-gray-900 mb-6">"About"</h1>
3012
+ <p class="text-gray-600">"This site was built with Tova — a modern programming language that compiles to JavaScript."</p>
3013
+ </div>
3014
+ }
3015
+ `,
3016
+ },
3017
+ ],
3018
+ content: name => `// ${name} — Built with Tova
3019
+ import { HomePage } from "./pages/home"
3020
+ import { DocsPage } from "./pages/docs"
3021
+ import { AboutPage } from "./pages/about"
3022
+
3023
+ browser {
3024
+ component SiteNav {
3025
+ <header class="bg-white border-b border-gray-100 sticky top-0 z-10">
3026
+ <div class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
3027
+ <Link href="/" class="flex items-center gap-2 no-underline">
3028
+ <div class="w-7 h-7 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-lg"></div>
3029
+ <span class="font-bold text-gray-900">"${name}"</span>
3030
+ </Link>
3031
+ <nav class="flex items-center gap-6">
3032
+ <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>
3033
+ <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>
3034
+ <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>
3035
+ </nav>
3036
+ </div>
3037
+ </header>
3038
+ }
3039
+
3040
+ component NotFoundPage {
3041
+ <div class="max-w-4xl mx-auto px-6 py-16 text-center">
3042
+ <h1 class="text-6xl font-bold text-gray-200 mb-4">"404"</h1>
3043
+ <p class="text-lg text-gray-500 mb-6">"Page not found"</p>
3044
+ <Link href="/" class="text-indigo-600 hover:text-indigo-700 font-medium no-underline">"Go home"</Link>
3045
+ </div>
3046
+ }
3047
+
3048
+ createRouter({
3049
+ routes: {
3050
+ "/": HomePage,
3051
+ "/docs": { component: DocsPage, meta: { title: "Documentation" } },
3052
+ "/about": { component: AboutPage, meta: { title: "About" } },
3053
+ "404": NotFoundPage,
3054
+ },
3055
+ scroll: "auto",
3056
+ })
3057
+
3058
+ // Update document title from route meta
3059
+ afterNavigate(fn(current) {
3060
+ if current.meta != undefined {
3061
+ if current.meta.title != undefined {
3062
+ document.title = "{current.meta.title} | ${name}"
3063
+ }
3064
+ }
3065
+ })
3066
+
3067
+ component App {
3068
+ <div class="min-h-screen bg-gray-50">
3069
+ <SiteNav />
3070
+ <main>
3071
+ <Router />
1692
3072
  </main>
3073
+ <footer class="border-t border-gray-100 py-8 text-center">
3074
+ <p class="text-sm text-gray-400">"Built with Tova"</p>
3075
+ </footer>
1693
3076
  </div>
1694
3077
  }
1695
3078
  }
@@ -1733,12 +3116,20 @@ print("Hello, {name}!")
1733
3116
  tomlDescription: 'A Tova library',
1734
3117
  entry: 'src',
1735
3118
  noEntry: true,
3119
+ isPackage: true,
1736
3120
  file: 'src/lib.tova',
1737
3121
  content: name => `// ${name} — A Tova library
3122
+ //
3123
+ // Usage:
3124
+ // import { greet } from "github.com/yourname/${name}"
1738
3125
 
1739
3126
  pub fn greet(name: String) -> String {
1740
3127
  "Hello, {name}!"
1741
3128
  }
3129
+
3130
+ pub fn version() -> String {
3131
+ "0.1.0"
3132
+ }
1742
3133
  `,
1743
3134
  nextSteps: name => ` cd ${name}\n tova build`,
1744
3135
  },
@@ -1753,7 +3144,7 @@ pub fn greet(name: String) -> String {
1753
3144
  },
1754
3145
  };
1755
3146
 
1756
- const TEMPLATE_ORDER = ['fullstack', 'api', 'script', 'library', 'blank'];
3147
+ const TEMPLATE_ORDER = ['fullstack', 'spa', 'site', 'api', 'script', 'library', 'blank'];
1757
3148
 
1758
3149
  async function newProject(rawArgs) {
1759
3150
  const name = rawArgs.find(a => !a.startsWith('-'));
@@ -1770,11 +3161,12 @@ async function newProject(rawArgs) {
1770
3161
 
1771
3162
  if (!name) {
1772
3163
  console.error(color.red('Error: No project name specified'));
1773
- console.error('Usage: tova new <project-name> [--template fullstack|api|script|library|blank]');
3164
+ console.error('Usage: tova new <project-name> [--template fullstack|spa|site|api|script|library|blank] [--auth]');
1774
3165
  process.exit(1);
1775
3166
  }
1776
3167
 
1777
3168
  const projectDir = resolve(name);
3169
+ const projectName = basename(projectDir);
1778
3170
  if (existsSync(projectDir)) {
1779
3171
  console.error(color.red(`Error: Directory '${name}' already exists`));
1780
3172
  process.exit(1);
@@ -1817,7 +3209,30 @@ async function newProject(rawArgs) {
1817
3209
  }
1818
3210
 
1819
3211
  const template = PROJECT_TEMPLATES[templateName];
1820
- console.log(`\n ${color.bold('Creating new Tova project:')} ${color.cyan(name)} ${color.dim(`(${template.label})`)}\n`);
3212
+ const authFlag = rawArgs.includes('--auth');
3213
+
3214
+ // Ask about auth if template supports it
3215
+ // Only prompt interactively when template was selected via picker (not --template flag)
3216
+ let withAuth = false;
3217
+ if (template.hasAuthOption) {
3218
+ if (authFlag) {
3219
+ withAuth = true;
3220
+ } else if (!templateFlag) {
3221
+ // Interactive mode — template was selected via picker, so ask about auth
3222
+ const { createInterface: createRl } = await import('readline');
3223
+ const rl2 = createRl({ input: process.stdin, output: process.stdout });
3224
+ const authAnswer = await new Promise(resolve => {
3225
+ rl2.question(` Include authentication? ${color.dim('[y/N]')}: `, ans => {
3226
+ rl2.close();
3227
+ resolve(ans.trim().toLowerCase());
3228
+ });
3229
+ });
3230
+ withAuth = authAnswer === 'y' || authAnswer === 'yes';
3231
+ }
3232
+ }
3233
+
3234
+ const templateLabel = withAuth ? `${template.label} + Auth` : template.label;
3235
+ console.log(`\n ${color.bold('Creating new Tova project:')} ${color.cyan(name)} ${color.dim(`(${templateLabel})`)}\n`);
1821
3236
 
1822
3237
  // Create directories
1823
3238
  mkdirSync(projectDir, { recursive: true });
@@ -1826,45 +3241,93 @@ async function newProject(rawArgs) {
1826
3241
  const createdFiles = [];
1827
3242
 
1828
3243
  // 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 = {};
3244
+ let tomlContent;
3245
+ if (template.isPackage) {
3246
+ // Library packages use [package] section per package management design
3247
+ tomlContent = [
3248
+ '[package]',
3249
+ `name = "github.com/yourname/${projectName}"`,
3250
+ `version = "0.1.0"`,
3251
+ `description = "${template.tomlDescription}"`,
3252
+ `license = "MIT"`,
3253
+ `exports = ["greet", "version"]`,
3254
+ '',
3255
+ '[build]',
3256
+ 'output = ".tova-out"',
3257
+ '',
3258
+ '[dependencies]',
3259
+ '',
3260
+ '[npm]',
3261
+ '',
3262
+ ].join('\n') + '\n';
3263
+ } else {
3264
+ const tomlConfig = {
3265
+ project: {
3266
+ name: projectName,
3267
+ version: '0.1.0',
3268
+ description: template.tomlDescription,
3269
+ },
3270
+ build: {
3271
+ output: '.tova-out',
3272
+ },
3273
+ };
3274
+ if (!template.noEntry) {
3275
+ tomlConfig.project.entry = template.entry;
3276
+ }
3277
+ if (templateName === 'fullstack' || templateName === 'api' || templateName === 'spa' || templateName === 'site') {
3278
+ tomlConfig.dev = { port: 3000 };
3279
+ tomlConfig.npm = {};
3280
+ }
3281
+ if (templateName === 'spa' || templateName === 'site') {
3282
+ tomlConfig.deploy = { base: '/' };
3283
+ }
3284
+ tomlContent = stringifyTOML(tomlConfig);
1845
3285
  }
1846
- writeFileSync(join(projectDir, 'tova.toml'), stringifyTOML(tomlConfig));
3286
+ writeFileSync(join(projectDir, 'tova.toml'), tomlContent);
1847
3287
  createdFiles.push('tova.toml');
1848
3288
 
1849
3289
  // .gitignore
1850
- writeFileSync(join(projectDir, '.gitignore'), `node_modules/
3290
+ let gitignoreContent = `node_modules/
1851
3291
  .tova-out/
1852
3292
  package.json
1853
3293
  bun.lock
1854
3294
  *.db
1855
3295
  *.db-shm
1856
3296
  *.db-wal
1857
- `);
3297
+ `;
3298
+ if (withAuth) gitignoreContent += `.env\n`;
3299
+ writeFileSync(join(projectDir, '.gitignore'), gitignoreContent);
1858
3300
  createdFiles.push('.gitignore');
1859
3301
 
1860
3302
  // Template source file
1861
- if (template.file && template.content) {
1862
- writeFileSync(join(projectDir, template.file), template.content(name));
3303
+ const contentFn = withAuth && template.authContent ? template.authContent : template.content;
3304
+ if (template.file && contentFn) {
3305
+ writeFileSync(join(projectDir, template.file), contentFn(projectName));
1863
3306
  createdFiles.push(template.file);
1864
3307
  }
1865
3308
 
3309
+ // Extra files (e.g., page components for site template)
3310
+ if (template.extraFiles) {
3311
+ for (const extra of template.extraFiles) {
3312
+ const extraPath = join(projectDir, extra.path);
3313
+ mkdirSync(dirname(extraPath), { recursive: true });
3314
+ writeFileSync(extraPath, extra.content(projectName));
3315
+ createdFiles.push(extra.path);
3316
+ }
3317
+ }
3318
+
3319
+ // Auth files (.env + .env.example)
3320
+ if (withAuth) {
3321
+ const { randomBytes } = await import('crypto');
3322
+ const authSecret = randomBytes(32).toString('hex');
3323
+ writeFileSync(join(projectDir, '.env'), `# Auto-generated for development \u2014 do not commit this file\nAUTH_SECRET=${authSecret}\n`);
3324
+ writeFileSync(join(projectDir, '.env.example'), `# Auth secret \u2014 used to sign JWT tokens\n# For production, generate a new one:\n# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"\nAUTH_SECRET=change-me-to-a-random-secret\n`);
3325
+ createdFiles.push('.env');
3326
+ createdFiles.push('.env.example');
3327
+ }
3328
+
1866
3329
  // README
1867
- writeFileSync(join(projectDir, 'README.md'), `# ${name}
3330
+ let readmeContent = `# ${projectName}
1868
3331
 
1869
3332
  Built with [Tova](https://github.com/tova-lang/tova-lang) — a modern full-stack language.
1870
3333
 
@@ -1873,7 +3336,34 @@ Built with [Tova](https://github.com/tova-lang/tova-lang) — a modern full-stac
1873
3336
  \`\`\`bash
1874
3337
  ${template.nextSteps(name).trim()}
1875
3338
  \`\`\`
1876
- `);
3339
+ `;
3340
+ if (template.isPackage) {
3341
+ readmeContent += `
3342
+ ## Usage
3343
+
3344
+ \`\`\`tova
3345
+ import { greet } from "github.com/yourname/${projectName}"
3346
+
3347
+ print(greet("world"))
3348
+ \`\`\`
3349
+
3350
+ ## Publishing
3351
+
3352
+ Tag a release and push — no registry needed:
3353
+
3354
+ \`\`\`bash
3355
+ git tag v0.1.0
3356
+ git push origin v0.1.0
3357
+ \`\`\`
3358
+
3359
+ Others can then add your package:
3360
+
3361
+ \`\`\`bash
3362
+ tova add github.com/yourname/${projectName}
3363
+ \`\`\`
3364
+ `;
3365
+ }
3366
+ writeFileSync(join(projectDir, 'README.md'), readmeContent);
1877
3367
  createdFiles.push('README.md');
1878
3368
 
1879
3369
  // Print created files
@@ -1890,7 +3380,8 @@ ${template.nextSteps(name).trim()}
1890
3380
  } catch {}
1891
3381
 
1892
3382
  console.log(`\n ${color.green('Done!')} Next steps:\n`);
1893
- console.log(color.cyan(template.nextSteps(name)));
3383
+ const nextStepsFn = withAuth && template.authNextSteps ? template.authNextSteps : template.nextSteps;
3384
+ console.log(nextStepsFn(name));
1894
3385
  console.log('');
1895
3386
  }
1896
3387
 
@@ -1964,7 +3455,7 @@ server {
1964
3455
  route GET "/api/message" => get_message
1965
3456
  }
1966
3457
 
1967
- client {
3458
+ browser {
1968
3459
  state message = ""
1969
3460
 
1970
3461
  effect {
@@ -2003,8 +3494,16 @@ async function installDeps() {
2003
3494
 
2004
3495
  // Resolve Tova module dependencies (if any)
2005
3496
  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));
3497
+ const { isTovModule: _isTovMod, expandBlessedPackage: _expandBlessed } = await import('../src/config/module-path.js');
3498
+
3499
+ // Expand blessed package shorthands (e.g., tova/data → github.com/tova-lang/data)
3500
+ const expandedTovaDeps = {};
3501
+ for (const [k, v] of Object.entries(tovaDeps)) {
3502
+ const expanded = _expandBlessed(k);
3503
+ expandedTovaDeps[expanded || k] = v;
3504
+ }
3505
+
3506
+ const tovModuleKeys = Object.keys(expandedTovaDeps).filter(k => _isTovMod(k));
2008
3507
 
2009
3508
  if (tovModuleKeys.length > 0) {
2010
3509
  const { resolveDependencies } = await import('../src/config/resolver.js');
@@ -2017,7 +3516,7 @@ async function installDeps() {
2017
3516
  const lock = readLockFile(cwd);
2018
3517
  const tovaModuleDeps = {};
2019
3518
  for (const k of tovModuleKeys) {
2020
- tovaModuleDeps[k] = tovaDeps[k];
3519
+ tovaModuleDeps[k] = expandedTovaDeps[k];
2021
3520
  }
2022
3521
 
2023
3522
  try {
@@ -2132,7 +3631,7 @@ async function addDep(args) {
2132
3631
  await installDeps();
2133
3632
  } else {
2134
3633
  // Tova native dependency
2135
- const { isTovModule: isTovMod } = await import('../src/config/module-path.js');
3634
+ const { isTovModule: isTovMod, expandBlessedPackage } = await import('../src/config/module-path.js');
2136
3635
 
2137
3636
  // Parse potential @version suffix
2138
3637
  let pkgName = actualPkg;
@@ -2143,21 +3642,25 @@ async function addDep(args) {
2143
3642
  pkgName = pkgName.slice(0, atIdx);
2144
3643
  }
2145
3644
 
3645
+ // Expand blessed package shorthand: tova/data → github.com/tova-lang/data
3646
+ const expandedPkg = expandBlessedPackage(pkgName);
3647
+ const resolvedPkg = expandedPkg || pkgName;
3648
+
2146
3649
  if (isTovMod(pkgName)) {
2147
3650
  // Tova module: fetch tags, pick version, add to [dependencies]
2148
3651
  const { listRemoteTags, pickLatestTag } = await import('../src/config/git-resolver.js');
2149
3652
  try {
2150
- const tags = await listRemoteTags(pkgName);
3653
+ const tags = await listRemoteTags(resolvedPkg);
2151
3654
  if (tags.length === 0) {
2152
- console.error(` No version tags found for ${pkgName}`);
3655
+ console.error(` No version tags found for ${resolvedPkg}`);
2153
3656
  process.exit(1);
2154
3657
  }
2155
3658
  if (!versionConstraint) {
2156
3659
  const latest = pickLatestTag(tags);
2157
3660
  versionConstraint = `^${latest.version}`;
2158
3661
  }
2159
- addToSection(tomlPath, 'dependencies', `"${pkgName}"`, versionConstraint);
2160
- console.log(` Added ${pkgName}@${versionConstraint} to [dependencies] in tova.toml`);
3662
+ addToSection(tomlPath, 'dependencies', `"${resolvedPkg}"`, versionConstraint);
3663
+ console.log(` Added ${resolvedPkg}@${versionConstraint} to [dependencies] in tova.toml`);
2161
3664
  await installDeps();
2162
3665
  } catch (err) {
2163
3666
  console.error(` Failed to add ${pkgName}: ${err.message}`);
@@ -3010,6 +4513,26 @@ async function startRepl() {
3010
4513
  const declaredInCode = new Set();
3011
4514
  for (const m of code.matchAll(/\bfunction\s+([a-zA-Z_]\w*)/g)) { declaredInCode.add(m[1]); userDefinedNames.add(m[1]); }
3012
4515
  for (const m of code.matchAll(/\bconst\s+([a-zA-Z_]\w*)/g)) { declaredInCode.add(m[1]); userDefinedNames.add(m[1]); }
4516
+ // Extract destructured names: const { a, b } = ... or const [ a, b ] = ...
4517
+ for (const m of code.matchAll(/\bconst\s+\{\s*([^}]+)\}/g)) {
4518
+ for (const part of m[1].split(',')) {
4519
+ const trimmed = part.trim();
4520
+ if (!trimmed) continue;
4521
+ // Handle renaming: "key: alias" or "key: alias = default" — extract the alias
4522
+ const colonMatch = trimmed.match(/^\w+\s*:\s*([a-zA-Z_]\w*)/);
4523
+ const name = colonMatch ? colonMatch[1] : trimmed.match(/^([a-zA-Z_]\w*)/)?.[1];
4524
+ if (name) { declaredInCode.add(name); userDefinedNames.add(name); }
4525
+ }
4526
+ }
4527
+ for (const m of code.matchAll(/\bconst\s+\[\s*([^\]]+)\]/g)) {
4528
+ for (const part of m[1].split(',')) {
4529
+ const trimmed = part.trim();
4530
+ if (!trimmed) continue;
4531
+ const name = trimmed.startsWith('...') ? trimmed.slice(3).trim() : trimmed;
4532
+ const id = name.match(/^([a-zA-Z_]\w*)/)?.[1];
4533
+ if (id) { declaredInCode.add(id); userDefinedNames.add(id); }
4534
+ }
4535
+ }
3013
4536
  for (const m of code.matchAll(/\blet\s+([a-zA-Z_]\w*)/g)) {
3014
4537
  declaredInCode.add(m[1]);
3015
4538
  userDefinedNames.add(m[1]);
@@ -3306,7 +4829,11 @@ async function binaryBuild(srcDir, outputName, outDir) {
3306
4829
 
3307
4830
  // ─── Production Build ────────────────────────────────────────
3308
4831
 
3309
- async function productionBuild(srcDir, outDir) {
4832
+ async function productionBuild(srcDir, outDir, isStatic = false) {
4833
+ const config = resolveConfig(process.cwd());
4834
+ const basePath = config.deploy?.base || '/';
4835
+ const base = basePath.endsWith('/') ? basePath : basePath + '/';
4836
+
3310
4837
  const tovaFiles = findFiles(srcDir, '.tova');
3311
4838
  if (tovaFiles.length === 0) {
3312
4839
  console.error('No .tova files found');
@@ -3351,6 +4878,9 @@ async function productionBuild(srcDir, outDir) {
3351
4878
  const serverPath = join(outDir, `server.${hash}.js`);
3352
4879
  writeFileSync(serverPath, serverBundle);
3353
4880
  console.log(` server.${hash}.js`);
4881
+
4882
+ // Write stable server.js entrypoint for Docker/deployment
4883
+ writeFileSync(join(outDir, 'server.js'), `import "./server.${hash}.js";\n`);
3354
4884
  }
3355
4885
 
3356
4886
  // Write script bundle for plain scripts (no server/client blocks)
@@ -3378,7 +4908,9 @@ async function productionBuild(srcDir, outDir) {
3378
4908
  // No npm imports — inline runtime, strip all imports
3379
4909
  const reactivityCode = REACTIVITY_SOURCE.replace(/^export /gm, '');
3380
4910
  const rpcCode = RPC_SOURCE.replace(/^export /gm, '');
3381
- clientBundle = reactivityCode + '\n' + rpcCode + '\n' + allSharedCode + '\n' +
4911
+ const usesRouter = /\b(defineRoutes|Router|getPath|getQuery|getParams|getCurrentRoute|navigate|onRouteChange|beforeNavigate|afterNavigate|Outlet|Link|Redirect)\b/.test(allClientCode);
4912
+ const routerCode = usesRouter ? ROUTER_SOURCE.replace(/^export /gm, '').replace(/^\s*import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]+['"];?\s*$/gm, '') : '';
4913
+ clientBundle = reactivityCode + '\n' + rpcCode + '\n' + (routerCode ? routerCode + '\n' : '') + allSharedCode + '\n' +
3382
4914
  allClientCode.replace(/^\s*import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]+['"];?\s*$/gm, '').trim();
3383
4915
  }
3384
4916
 
@@ -3389,14 +4921,19 @@ async function productionBuild(srcDir, outDir) {
3389
4921
 
3390
4922
  // Generate production HTML
3391
4923
  const scriptTag = useModule
3392
- ? `<script type="module" src="client.${hash}.js"></script>`
3393
- : `<script src="client.${hash}.js"></script>`;
4924
+ ? `<script type="module" src="${base}.tova-out/client.${hash}.js"></script>`
4925
+ : `<script src="${base}.tova-out/client.${hash}.js"></script>`;
3394
4926
  const html = `<!DOCTYPE html>
3395
4927
  <html lang="en">
3396
4928
  <head>
3397
4929
  <meta charset="UTF-8">
3398
4930
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
3399
4931
  <title>Tova App</title>
4932
+ <script src="https://cdn.tailwindcss.com"></script>
4933
+ <style>
4934
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
4935
+ body { font-family: system-ui, -apple-system, sans-serif; }
4936
+ </style>
3400
4937
  </head>
3401
4938
  <body>
3402
4939
  <div id="app"></div>
@@ -3405,6 +4942,12 @@ async function productionBuild(srcDir, outDir) {
3405
4942
  </html>`;
3406
4943
  writeFileSync(join(outDir, 'index.html'), html);
3407
4944
  console.log(` index.html`);
4945
+
4946
+ // SPA fallback files for various static hosts
4947
+ writeFileSync(join(outDir, '404.html'), html);
4948
+ console.log(` 404.html (GitHub Pages SPA fallback)`);
4949
+ writeFileSync(join(outDir, '200.html'), html);
4950
+ console.log(` 200.html (Surge SPA fallback)`);
3408
4951
  }
3409
4952
 
3410
4953
  // Minify all JS bundles using Bun's built-in transpiler
@@ -3441,13 +4984,64 @@ async function productionBuild(srcDir, outDir) {
3441
4984
  }
3442
4985
  }
3443
4986
 
4987
+ // Rewrite min entrypoints to import minified hashed files
4988
+ for (const f of ['server.min.js', 'script.min.js']) {
4989
+ const minEntry = join(outDir, f);
4990
+ try {
4991
+ const content = readFileSync(minEntry, 'utf-8');
4992
+ const rewritten = content.replace(/\.js(["'])/g, '.min.js$1');
4993
+ writeFileSync(minEntry, rewritten);
4994
+ } catch {}
4995
+ }
4996
+
3444
4997
  if (minified === 0 && jsFiles.length > 0) {
3445
4998
  console.log(' (minification skipped — Bun.build unavailable)');
3446
4999
  }
3447
5000
 
5001
+ // Static generation: pre-render each route to its own HTML file
5002
+ if (isStatic && allClientCode.trim()) {
5003
+ console.log(`\n Static generation...\n`);
5004
+
5005
+ const routePaths = extractRoutePaths(allClientCode);
5006
+ if (routePaths.length > 0) {
5007
+ // Read the generated index.html to use as the shell for all routes
5008
+ const shellHtml = readFileSync(join(outDir, 'index.html'), 'utf-8');
5009
+ for (const routePath of routePaths) {
5010
+ const htmlPath = routePath === '/'
5011
+ ? join(outDir, 'index.html')
5012
+ : join(outDir, routePath.replace(/^\//, ''), 'index.html');
5013
+
5014
+ mkdirSync(dirname(htmlPath), { recursive: true });
5015
+ writeFileSync(htmlPath, shellHtml);
5016
+ const relPath = relative(outDir, htmlPath);
5017
+ console.log(` ${relPath}`);
5018
+ }
5019
+ console.log(`\n Pre-rendered ${routePaths.length} route(s)`);
5020
+ }
5021
+ }
5022
+
3448
5023
  console.log(`\n Production build complete.\n`);
3449
5024
  }
3450
5025
 
5026
+ function extractRoutePaths(code) {
5027
+ // Support both defineRoutes({...}) and createRouter({ routes: {...} })
5028
+ let match = code.match(/defineRoutes\s*\(\s*\{([^}]+)\}\s*\)/);
5029
+ if (!match) {
5030
+ match = code.match(/routes\s*:\s*\{([^}]+)\}/);
5031
+ }
5032
+ if (!match) return [];
5033
+
5034
+ const paths = [];
5035
+ const entries = match[1].matchAll(/"([^"]+)"\s*:/g);
5036
+ for (const entry of entries) {
5037
+ const path = entry[1];
5038
+ if (path === '404' || path === '*') continue;
5039
+ if (path.includes(':')) continue;
5040
+ paths.push(path);
5041
+ }
5042
+ return paths;
5043
+ }
5044
+
3451
5045
  // Fallback JS minifier — string/regex-aware, no AST required
3452
5046
  function _simpleMinify(code) {
3453
5047
  // Phase 1: Strip comments while respecting strings and regexes
@@ -3921,6 +5515,10 @@ function collectExports(ast, filename) {
3921
5515
  allNames.add(node.name);
3922
5516
  if (node.isPublic) publicExports.add(node.name);
3923
5517
  }
5518
+ if (node.type === 'ComponentDeclaration') {
5519
+ allNames.add(node.name);
5520
+ if (node.isPublic) publicExports.add(node.name);
5521
+ }
3924
5522
  if (node.type === 'ImplDeclaration') { /* impl doesn't export a name */ }
3925
5523
  }
3926
5524
 
@@ -3962,8 +5560,24 @@ function compileWithImports(source, filename, srcDir) {
3962
5560
  // Collect this module's exports for validation
3963
5561
  collectExports(ast, filename);
3964
5562
 
3965
- // Resolve .tova imports first
5563
+ // Resolve imports: tova: prefix, @/ prefix, then .tova files
3966
5564
  for (const node of ast.body) {
5565
+ // Resolve tova: prefix imports to runtime modules
5566
+ if ((node.type === 'ImportDeclaration' || node.type === 'ImportDefault' || node.type === 'ImportWildcard') && node.source.startsWith('tova:')) {
5567
+ node.source = './runtime/' + node.source.slice(5) + '.js';
5568
+ continue;
5569
+ }
5570
+ // Resolve @/ prefix imports to project root
5571
+ if ((node.type === 'ImportDeclaration' || node.type === 'ImportDefault' || node.type === 'ImportWildcard') && node.source.startsWith('@/')) {
5572
+ const relPath = node.source.slice(2);
5573
+ let resolved = resolve(srcDir, relPath);
5574
+ if (!resolved.endsWith('.tova')) resolved += '.tova';
5575
+ const fromDir = dirname(filename);
5576
+ let rel = relative(fromDir, resolved);
5577
+ if (!rel.startsWith('.')) rel = './' + rel;
5578
+ node.source = rel;
5579
+ // Fall through to .tova import handling below
5580
+ }
3967
5581
  if (node.type === 'ImportDeclaration' && node.source.endsWith('.tova')) {
3968
5582
  const importPath = resolve(dirname(filename), node.source);
3969
5583
  trackDependency(filename, importPath);
@@ -4180,8 +5794,22 @@ function mergeDirectory(dir, srcDir, options = {}) {
4180
5794
  // Collect exports for cross-file import validation
4181
5795
  collectExports(ast, file);
4182
5796
 
4183
- // Resolve cross-directory .tova imports (same logic as compileWithImports)
5797
+ // Resolve imports: tova: prefix, @/ prefix, then cross-directory .tova
4184
5798
  for (const node of ast.body) {
5799
+ if ((node.type === 'ImportDeclaration' || node.type === 'ImportDefault' || node.type === 'ImportWildcard') && node.source.startsWith('tova:')) {
5800
+ node.source = './runtime/' + node.source.slice(5) + '.js';
5801
+ continue;
5802
+ }
5803
+ if ((node.type === 'ImportDeclaration' || node.type === 'ImportDefault' || node.type === 'ImportWildcard') && node.source.startsWith('@/')) {
5804
+ const relPath = node.source.slice(2);
5805
+ let resolved = resolve(srcDir, relPath);
5806
+ if (!resolved.endsWith('.tova')) resolved += '.tova';
5807
+ const fromDir = dirname(file);
5808
+ let rel = relative(fromDir, resolved);
5809
+ if (!rel.startsWith('.')) rel = './' + rel;
5810
+ node.source = rel;
5811
+ // Fall through to .tova import handling below
5812
+ }
4185
5813
  if ((node.type === 'ImportDeclaration' || node.type === 'ImportDefault' || node.type === 'ImportWildcard') && node.source.endsWith('.tova')) {
4186
5814
  const importPath = resolve(dirname(file), node.source);
4187
5815
  // Only process imports from OTHER directories (same-dir files are merged)
@@ -4257,7 +5885,7 @@ function mergeDirectory(dir, srcDir, options = {}) {
4257
5885
  const mergedAST = new Program(mergedBody);
4258
5886
 
4259
5887
  // Run analyzer on merged AST
4260
- const analyzer = new Analyzer(mergedAST, dir);
5888
+ const analyzer = new Analyzer(mergedAST, dir, { strict: options.strict, strictSecurity: options.strictSecurity });
4261
5889
  const { warnings } = analyzer.analyze();
4262
5890
 
4263
5891
  if (warnings.length > 0) {
@@ -4278,7 +5906,27 @@ function mergeDirectory(dir, srcDir, options = {}) {
4278
5906
  output._sourceContents = sourceContents;
4279
5907
  output._sourceFiles = tovaFiles;
4280
5908
 
4281
- return { output, files: tovaFiles, single: false };
5909
+ // Extract security info for scorecard
5910
+ const hasServer = mergedBody.some(n => n.type === 'ServerBlock');
5911
+ const hasEdge = mergedBody.some(n => n.type === 'EdgeBlock');
5912
+ const securityNode = mergedBody.find(n => n.type === 'SecurityBlock');
5913
+ let securityConfig = null;
5914
+ if (securityNode) {
5915
+ securityConfig = {};
5916
+ for (const child of securityNode.body || []) {
5917
+ if (child.type === 'AuthDeclaration') securityConfig.auth = { authType: child.authType || 'jwt', storage: child.config?.storage?.value };
5918
+ else if (child.type === 'CsrfDeclaration') securityConfig.csrf = { enabled: child.config?.enabled?.value !== false };
5919
+ else if (child.type === 'RateLimitDeclaration') securityConfig.rateLimit = { max: child.config?.max?.value };
5920
+ else if (child.type === 'CspDeclaration') securityConfig.csp = { default_src: true };
5921
+ else if (child.type === 'CorsDeclaration') {
5922
+ const origins = child.config?.origins;
5923
+ securityConfig.cors = { origins: origins ? (origins.elements || []).map(e => e.value) : [] };
5924
+ }
5925
+ else if (child.type === 'AuditDeclaration') securityConfig.audit = { events: ['auth'] };
5926
+ }
5927
+ }
5928
+
5929
+ return { output, files: tovaFiles, single: false, warnings, securityConfig, hasServer, hasEdge };
4282
5930
  }
4283
5931
 
4284
5932
  // Group .tova files by their parent directory
@@ -4451,7 +6099,7 @@ function completionsCommand(shell) {
4451
6099
  'migrate:create', 'migrate:up', 'migrate:down', 'migrate:reset', 'migrate:fresh', 'migrate:status',
4452
6100
  ];
4453
6101
 
4454
- const globalFlags = ['--help', '--version', '--output', '--production', '--watch', '--verbose', '--quiet', '--debug', '--strict'];
6102
+ const globalFlags = ['--help', '--version', '--output', '--production', '--watch', '--verbose', '--quiet', '--debug', '--strict', '--strict-security'];
4455
6103
 
4456
6104
  switch (shell) {
4457
6105
  case 'bash': {
@@ -4474,7 +6122,7 @@ _tova() {
4474
6122
  return 0
4475
6123
  ;;
4476
6124
  --template)
4477
- COMPREPLY=( $(compgen -W "fullstack api script library blank" -- "\${cur}") )
6125
+ COMPREPLY=( $(compgen -W "fullstack spa site api script library blank" -- "\${cur}") )
4478
6126
  return 0
4479
6127
  ;;
4480
6128
  completions)
@@ -4522,7 +6170,7 @@ ${commands.map(c => ` '${c}:${c} command'`).join('\n')}
4522
6170
  case $words[1] in
4523
6171
  new)
4524
6172
  _arguments \\
4525
- '--template[Project template]:template:(fullstack api script library blank)' \\
6173
+ '--template[Project template]:template:(fullstack spa site api script library blank)' \\
4526
6174
  '*:name:'
4527
6175
  ;;
4528
6176
  run|build|check|fmt|doc)
@@ -4614,7 +6262,7 @@ _tova "$@"
4614
6262
  script += `complete -c tova -l debug -d 'Debug output'\n`;
4615
6263
  script += `complete -c tova -l strict -d 'Strict type checking'\n`;
4616
6264
  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`;
6265
+ 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
6266
  script += `\n# Shell completions for 'completions'\n`;
4619
6267
  script += `complete -c tova -n '__fish_seen_subcommand_from completions' -xa 'bash zsh fish'\n`;
4620
6268