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.
- package/bin/tova.js +1790 -142
- package/package.json +14 -2
- package/src/analyzer/analyzer.js +529 -11
- package/src/analyzer/browser-analyzer.js +74 -8
- package/src/analyzer/scope.js +7 -0
- package/src/analyzer/server-analyzer.js +33 -1
- package/src/codegen/auth-codegen.js +1068 -0
- package/src/codegen/base-codegen.js +137 -13
- package/src/codegen/browser-codegen.js +764 -26
- package/src/codegen/codegen.js +82 -8
- package/src/codegen/security-codegen.js +8 -7
- package/src/codegen/server-codegen.js +146 -17
- package/src/codegen/theme-codegen.js +69 -0
- package/src/config/module-path.js +34 -2
- package/src/config/resolve.js +9 -0
- package/src/config/toml.js +13 -1
- package/src/deploy/provision.js +6 -2
- package/src/diagnostics/security-scorecard.js +111 -0
- package/src/lexer/lexer.js +20 -5
- package/src/parser/animate-ast.js +45 -0
- package/src/parser/ast.js +23 -0
- package/src/parser/auth-ast.js +38 -0
- package/src/parser/auth-parser.js +129 -0
- package/src/parser/browser-ast.js +19 -1
- package/src/parser/browser-parser.js +221 -4
- package/src/parser/parser.js +21 -2
- package/src/parser/theme-ast.js +29 -0
- package/src/parser/theme-parser.js +70 -0
- package/src/registry/plugins/auth-plugin.js +24 -0
- package/src/registry/plugins/theme-plugin.js +20 -0
- package/src/registry/register-all.js +4 -0
- package/src/runtime/charts.js +547 -0
- package/src/runtime/embedded.js +7 -3
- package/src/runtime/reactivity.js +60 -0
- package/src/runtime/router.js +703 -295
- package/src/runtime/rpc.js +15 -1
- package/src/runtime/table.js +606 -33
- package/src/stdlib/inline.js +330 -7
- package/src/stdlib/string.js +84 -2
- package/src/stdlib/validation.js +1 -1
- 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
|
|
57
|
-
add <pkg> Add
|
|
58
|
-
remove <pkg> Remove
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1228
|
-
|
|
1229
|
-
writeFileSync(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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 +
|
|
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
|
-
|
|
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
|
-
<
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
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
|
-
<
|
|
1636
|
-
|
|
1637
|
-
|
|
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
|
-
</
|
|
2840
|
+
</div>
|
|
2841
|
+
</div>
|
|
2842
|
+
}
|
|
1641
2843
|
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
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
|
-
|
|
1649
|
-
|
|
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
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
>
|
|
1659
|
-
|
|
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
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
2875
|
+
</aside>
|
|
2876
|
+
<div class="flex-1 min-w-0">
|
|
2877
|
+
<Outlet />
|
|
1669
2878
|
</div>
|
|
2879
|
+
</div>
|
|
2880
|
+
</div>
|
|
2881
|
+
}
|
|
1670
2882
|
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
<
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
<
|
|
1683
|
-
icon="🎨"
|
|
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
|
-
|
|
1690
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
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'),
|
|
3286
|
+
writeFileSync(join(projectDir, 'tova.toml'), tomlContent);
|
|
1847
3287
|
createdFiles.push('tova.toml');
|
|
1848
3288
|
|
|
1849
3289
|
// .gitignore
|
|
1850
|
-
|
|
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
|
-
|
|
1862
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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] =
|
|
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(
|
|
3653
|
+
const tags = await listRemoteTags(resolvedPkg);
|
|
2151
3654
|
if (tags.length === 0) {
|
|
2152
|
-
console.error(` No version tags found for ${
|
|
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', `"${
|
|
2160
|
-
console.log(` Added ${
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|