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