tova 0.8.2 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tova.js +1128 -137
- package/package.json +14 -2
- package/src/analyzer/analyzer.js +405 -9
- package/src/analyzer/browser-analyzer.js +56 -8
- package/src/analyzer/scope.js +7 -0
- package/src/analyzer/server-analyzer.js +33 -1
- package/src/codegen/base-codegen.js +137 -13
- package/src/codegen/browser-codegen.js +725 -20
- package/src/codegen/codegen.js +67 -5
- package/src/codegen/server-codegen.js +54 -6
- package/src/codegen/theme-codegen.js +69 -0
- package/src/config/module-path.js +34 -2
- package/src/config/resolve.js +9 -0
- package/src/config/toml.js +13 -1
- package/src/deploy/provision.js +6 -2
- package/src/diagnostics/security-scorecard.js +111 -0
- package/src/lexer/lexer.js +18 -3
- package/src/parser/animate-ast.js +45 -0
- package/src/parser/ast.js +15 -0
- package/src/parser/browser-ast.js +19 -1
- package/src/parser/browser-parser.js +221 -4
- package/src/parser/parser.js +21 -2
- package/src/parser/theme-ast.js +29 -0
- package/src/parser/theme-parser.js +70 -0
- package/src/registry/plugins/theme-plugin.js +20 -0
- package/src/registry/register-all.js +2 -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 +330 -7
- package/src/stdlib/string.js +84 -2
- package/src/stdlib/validation.js +1 -1
- package/src/version.js +1 -1
package/src/codegen/codegen.js
CHANGED
|
@@ -42,6 +42,12 @@ function getDeployCodegen() {
|
|
|
42
42
|
return _DeployCodegen;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
let _ThemeCodegen;
|
|
46
|
+
function getThemeCodegen() {
|
|
47
|
+
if (!_ThemeCodegen) _ThemeCodegen = _require('./theme-codegen.js').ThemeCodegen;
|
|
48
|
+
return _ThemeCodegen;
|
|
49
|
+
}
|
|
50
|
+
|
|
45
51
|
export class CodeGenerator {
|
|
46
52
|
constructor(ast, filename = '<stdin>', options = {}) {
|
|
47
53
|
this.ast = ast;
|
|
@@ -66,6 +72,11 @@ export class CodeGenerator {
|
|
|
66
72
|
const topLevel = [];
|
|
67
73
|
|
|
68
74
|
for (const node of this.ast.body) {
|
|
75
|
+
// pub component declarations at top level are module-level exports, not browser block children
|
|
76
|
+
if (node.type === 'ComponentDeclaration' && node.isPublic) {
|
|
77
|
+
topLevel.push(node);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
69
80
|
const plugin = BlockRegistry.getByAstType(node.type);
|
|
70
81
|
if (plugin) {
|
|
71
82
|
if (!blocksByType.has(plugin.name)) blocksByType.set(plugin.name, []);
|
|
@@ -94,20 +105,66 @@ export class CodeGenerator {
|
|
|
94
105
|
const securityBlocks = getBlocks('security');
|
|
95
106
|
const edgeBlocks = getBlocks('edge');
|
|
96
107
|
const deployBlocks = getBlocks('deploy');
|
|
108
|
+
const themeBlocks = getBlocks('theme');
|
|
97
109
|
|
|
98
110
|
// Detect module mode: no blocks, only top-level statements
|
|
99
111
|
const hasAnyBlocks = BlockRegistry.all().some(p => getBlocks(p.name).length > 0);
|
|
100
112
|
const isModule = !hasAnyBlocks && topLevel.length > 0;
|
|
101
113
|
|
|
102
114
|
if (isModule) {
|
|
115
|
+
// Separate pub component declarations from other top-level nodes
|
|
116
|
+
const componentNodes = topLevel.filter(n => n.type === 'ComponentDeclaration');
|
|
117
|
+
const otherNodes = topLevel.filter(n => n.type !== 'ComponentDeclaration');
|
|
118
|
+
|
|
103
119
|
const moduleGen = new SharedCodegen();
|
|
104
120
|
moduleGen._sourceMapsEnabled = this._sourceMaps;
|
|
105
121
|
moduleGen.setSourceFile(this.filename);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
122
|
+
|
|
123
|
+
// Generate non-component top-level code
|
|
124
|
+
let moduleCode = '';
|
|
125
|
+
if (otherNodes.length > 0) {
|
|
126
|
+
const fakeBlock = { type: 'BlockStatement', body: otherNodes };
|
|
127
|
+
moduleCode = moduleGen.genBlockStatements(fakeBlock);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Generate pub component code using BrowserCodegen
|
|
131
|
+
let componentCode = '';
|
|
132
|
+
if (componentNodes.length > 0) {
|
|
133
|
+
const BrowserCodegen = getBrowserCodegen();
|
|
134
|
+
const compGen = new BrowserCodegen();
|
|
135
|
+
compGen._sourceMapsEnabled = this._sourceMaps;
|
|
136
|
+
compGen.setSourceFile(this.filename);
|
|
137
|
+
|
|
138
|
+
// Sort: parent components first, then compound (child) components
|
|
139
|
+
const sortedComponents = [...componentNodes].sort((a, b) => {
|
|
140
|
+
const aIsChild = a.parent ? 1 : 0;
|
|
141
|
+
const bIsChild = b.parent ? 1 : 0;
|
|
142
|
+
return aIsChild - bIsChild;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const compParts = [];
|
|
146
|
+
for (const comp of sortedComponents) {
|
|
147
|
+
if (comp.parent) {
|
|
148
|
+
// Compound component — assign as property on parent
|
|
149
|
+
compParts.push(`${comp.parent}.${comp.child} = ${compGen.generateComponent(comp)};`);
|
|
150
|
+
} else {
|
|
151
|
+
const exportPrefix = comp.isPublic ? 'export ' : '';
|
|
152
|
+
compParts.push(exportPrefix + compGen.generateComponent(comp));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
componentCode = compParts.join('\n\n');
|
|
156
|
+
}
|
|
157
|
+
|
|
109
158
|
const helpers = moduleGen.generateHelpers();
|
|
110
|
-
const
|
|
159
|
+
const parts = [helpers];
|
|
160
|
+
|
|
161
|
+
// Add runtime imports for component DOM/reactivity functions
|
|
162
|
+
if (componentNodes.length > 0) {
|
|
163
|
+
parts.unshift(`import { createSignal, createEffect, createComputed, batch, onMount, onUnmount, onCleanup, tova_el, tova_fragment, tova_keyed, tova_inject_css, createRef, createContext, provide, inject } from './runtime/reactivity.js';`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
parts.push(moduleCode, componentCode);
|
|
167
|
+
const combined = parts.filter(s => s.trim()).join('\n').trim();
|
|
111
168
|
return {
|
|
112
169
|
shared: combined,
|
|
113
170
|
server: '',
|
|
@@ -171,6 +228,11 @@ export class CodeGenerator {
|
|
|
171
228
|
? getSecurityCodegen().mergeSecurityBlocks(securityBlocks)
|
|
172
229
|
: null;
|
|
173
230
|
|
|
231
|
+
// Merge theme blocks into a single config
|
|
232
|
+
const themeConfig = themeBlocks.length > 0
|
|
233
|
+
? getThemeCodegen().mergeThemeBlocks(themeBlocks)
|
|
234
|
+
: null;
|
|
235
|
+
|
|
174
236
|
// Generate server outputs (one per named group)
|
|
175
237
|
const servers = {};
|
|
176
238
|
for (const [name, blocks] of serverGroups) {
|
|
@@ -222,7 +284,7 @@ export class CodeGenerator {
|
|
|
222
284
|
const gen = new (getBrowserCodegen())();
|
|
223
285
|
gen._sourceMapsEnabled = this._sourceMaps;
|
|
224
286
|
const key = name || 'default';
|
|
225
|
-
browsers[key] = gen.generate(blocks, combinedShared, sharedGen._usedBuiltins, securityConfig, typeValidatorsMap);
|
|
287
|
+
browsers[key] = gen.generate(blocks, combinedShared, sharedGen._usedBuiltins, securityConfig, typeValidatorsMap, themeConfig);
|
|
226
288
|
}
|
|
227
289
|
|
|
228
290
|
// Generate edge outputs (one per named group)
|
|
@@ -1052,6 +1052,22 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
1052
1052
|
// ════════════════════════════════════════════════════════════
|
|
1053
1053
|
// 8b. Security Headers — OWASP recommended
|
|
1054
1054
|
// ════════════════════════════════════════════════════════════
|
|
1055
|
+
// Always emit base security headers (even in fast mode)
|
|
1056
|
+
lines.push('// ── Base Security Headers (always) ──');
|
|
1057
|
+
lines.push('const __baseSecurityHeaders = Object.freeze({');
|
|
1058
|
+
lines.push(' "X-Content-Type-Options": "nosniff",');
|
|
1059
|
+
lines.push(' "X-Frame-Options": "DENY",');
|
|
1060
|
+
lines.push(' "X-XSS-Protection": "0",');
|
|
1061
|
+
lines.push(' "Referrer-Policy": "strict-origin-when-cross-origin",');
|
|
1062
|
+
lines.push('});');
|
|
1063
|
+
lines.push('function __applyBaseHeaders(res) {');
|
|
1064
|
+
lines.push(' if (!res) return res;');
|
|
1065
|
+
lines.push(' const h = new Headers(res.headers);');
|
|
1066
|
+
lines.push(' for (const [k, v] of Object.entries(__baseSecurityHeaders)) h.set(k, v);');
|
|
1067
|
+
lines.push(' return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });');
|
|
1068
|
+
lines.push('}');
|
|
1069
|
+
lines.push('');
|
|
1070
|
+
|
|
1055
1071
|
if (!isFastMode) {
|
|
1056
1072
|
lines.push('// ── Security Headers ──');
|
|
1057
1073
|
lines.push('const __securityHeaders = Object.freeze({');
|
|
@@ -2365,7 +2381,12 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
2365
2381
|
const rlWindow = rateLimitDec.args[1] ? this.genExpression(rateLimitDec.args[1]) : '60';
|
|
2366
2382
|
lines.push(` const __rlIp = __getClientIp(req);`);
|
|
2367
2383
|
lines.push(` const __rlRoute = __checkRateLimit(\`route:${path}:\${__rlIp}\`, ${rlMax}, ${rlWindow});`);
|
|
2368
|
-
lines.push(` if (__rlRoute.limited)
|
|
2384
|
+
lines.push(` if (__rlRoute.limited) {`);
|
|
2385
|
+
if (securityFragments && securityFragments.auditCode) {
|
|
2386
|
+
lines.push(` __auditLog("rate_limit:exceeded", { method: req.method, path: __pathname }, { id: null });`);
|
|
2387
|
+
}
|
|
2388
|
+
lines.push(` return __errorResponse(429, "RATE_LIMITED", "Too Many Requests", null, { "Retry-After": String(__rlRoute.retryAfter) });`);
|
|
2389
|
+
lines.push(` }`);
|
|
2369
2390
|
}
|
|
2370
2391
|
|
|
2371
2392
|
// Upload decorator — parse multipart body, validate file field
|
|
@@ -3133,8 +3154,8 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
3133
3154
|
}
|
|
3134
3155
|
}
|
|
3135
3156
|
|
|
3136
|
-
// Client HTML fallback
|
|
3137
|
-
lines.push(' if (
|
|
3157
|
+
// Client HTML fallback — serve SPA shell for any non-API route so client router handles 404s
|
|
3158
|
+
lines.push(' if (typeof __clientHTML !== "undefined") {');
|
|
3138
3159
|
lines.push(' return new Response(__clientHTML, { status: 200, headers: { "Content-Type": "text/html" } });');
|
|
3139
3160
|
lines.push(' }');
|
|
3140
3161
|
lines.push(' return new Response("Not Found", { status: 404 });');
|
|
@@ -3217,6 +3238,9 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
3217
3238
|
lines.push(' const __clientIp = __getClientIp(req);');
|
|
3218
3239
|
lines.push(' const __rl = __checkRateLimit(__clientIp, __rateLimitMax, __rateLimitWindow);');
|
|
3219
3240
|
lines.push(' if (__rl.limited) {');
|
|
3241
|
+
if (securityFragments && securityFragments.auditCode) {
|
|
3242
|
+
lines.push(' __auditLog("rate_limit:exceeded", { method: req.method, path: __pathname, ip: __clientIp }, { id: null });');
|
|
3243
|
+
}
|
|
3220
3244
|
lines.push(' return __errorResponse(429, "RATE_LIMITED", "Too Many Requests", null, { ...__cors, "Retry-After": String(__rl.retryAfter) });');
|
|
3221
3245
|
lines.push(' }');
|
|
3222
3246
|
}
|
|
@@ -3266,6 +3290,10 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
3266
3290
|
lines.push(' // Security block: route protection');
|
|
3267
3291
|
if (authConfig) {
|
|
3268
3292
|
lines.push(' const __secUser = await __authenticate(req);');
|
|
3293
|
+
if (securityFragments && securityFragments.auditCode) {
|
|
3294
|
+
lines.push(' if (__secUser) __auditLog("auth:success", { method: req.method, path: __pathname }, __secUser);');
|
|
3295
|
+
lines.push(' else if (req.headers.get("Authorization")) __auditLog("auth:failure", { method: req.method, path: __pathname, reason: "invalid_token" }, { id: null });');
|
|
3296
|
+
}
|
|
3269
3297
|
} else {
|
|
3270
3298
|
lines.push(' const __secUser = null;');
|
|
3271
3299
|
}
|
|
@@ -3273,17 +3301,31 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
3273
3301
|
lines.push(' if (!__protection.allowed) {');
|
|
3274
3302
|
lines.push(' const __statusCode = __secUser ? 403 : 401;');
|
|
3275
3303
|
lines.push(' const __errorCode = __secUser ? "FORBIDDEN" : "AUTH_REQUIRED";');
|
|
3304
|
+
if (securityFragments && securityFragments.auditCode) {
|
|
3305
|
+
lines.push(' __auditLog("auth:denied", { method: req.method, path: __pathname }, __secUser || { id: null });');
|
|
3306
|
+
}
|
|
3276
3307
|
lines.push(' return __errorResponse(__statusCode, __errorCode, __protection.reason, null, __cors);');
|
|
3277
3308
|
lines.push(' }');
|
|
3278
3309
|
lines.push(' if (__protection.rateLimit && __protection.rateLimit.max) {');
|
|
3279
3310
|
lines.push(' const __protectIp = __getClientIp(req);');
|
|
3280
3311
|
lines.push(' const __protectRl = __checkRateLimit("protect:" + __pathname + ":" + __protectIp, __protection.rateLimit.max, __protection.rateLimit.window || 60);');
|
|
3281
3312
|
lines.push(' if (__protectRl.limited) {');
|
|
3313
|
+
if (securityFragments && securityFragments.auditCode) {
|
|
3314
|
+
lines.push(' __auditLog("rate_limit:exceeded", { method: req.method, path: __pathname, ip: __protectIp }, { id: null });');
|
|
3315
|
+
}
|
|
3282
3316
|
lines.push(' return __errorResponse(429, "RATE_LIMITED", "Too Many Requests", null, { ...__cors, "Retry-After": String(__protectRl.retryAfter) });');
|
|
3283
3317
|
lines.push(' }');
|
|
3284
3318
|
lines.push(' }');
|
|
3285
3319
|
}
|
|
3286
3320
|
|
|
3321
|
+
// Audit logging for auth (when no protection block but auth+audit configured)
|
|
3322
|
+
if (!(securityFragments && securityFragments.protectCode) && authConfig && securityFragments && securityFragments.auditCode) {
|
|
3323
|
+
lines.push(' // Audit: auth logging');
|
|
3324
|
+
lines.push(' const __secUser = await __authenticate(req);');
|
|
3325
|
+
lines.push(' if (__secUser) __auditLog("auth:success", { method: req.method, path: __pathname }, __secUser);');
|
|
3326
|
+
lines.push(' else if (req.headers.get("Authorization")) __auditLog("auth:failure", { method: req.method, path: __pathname, reason: "invalid_token" }, { id: null });');
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3287
3329
|
// Idempotency key check
|
|
3288
3330
|
lines.push(' const __idempotencyKey = req.headers.get("Idempotency-Key");');
|
|
3289
3331
|
lines.push(' if (__idempotencyKey && req.method !== "GET" && req.method !== "HEAD" && req.method !== "OPTIONS") {');
|
|
@@ -3422,8 +3464,8 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
3422
3464
|
lines.push(' }');
|
|
3423
3465
|
lines.push(' }');
|
|
3424
3466
|
|
|
3425
|
-
// Serve client HTML
|
|
3426
|
-
lines.push(' if (
|
|
3467
|
+
// Serve client HTML — SPA fallback for any non-API route so client router handles 404s
|
|
3468
|
+
lines.push(' if (typeof __clientHTML !== "undefined") {');
|
|
3427
3469
|
lines.push(' return new Response(__clientHTML, { status: 200, headers: { "Content-Type": "text/html", ...(__cors) } });');
|
|
3428
3470
|
lines.push(' }');
|
|
3429
3471
|
lines.push(' const __notFound = __errorResponse(404, "NOT_FOUND", "Not Found", null, __cors);');
|
|
@@ -3490,8 +3532,14 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
3490
3532
|
lines.push(' if (!res) return res;');
|
|
3491
3533
|
lines.push(' return __compressResponse(req, res);');
|
|
3492
3534
|
lines.push('};');
|
|
3535
|
+
} else if (isFastMode) {
|
|
3536
|
+
// Fast mode: wrap with base security headers
|
|
3537
|
+
lines.push('const __secureFetch = async (req) => {');
|
|
3538
|
+
lines.push(' const res = await __handleRequest(req);');
|
|
3539
|
+
lines.push(' return __applyBaseHeaders(res);');
|
|
3540
|
+
lines.push('};');
|
|
3493
3541
|
}
|
|
3494
|
-
const fetchHandler =
|
|
3542
|
+
const fetchHandler = !isFastMode ? '__idempotentFetch' : (compressionConfig ? '__idempotentFetch' : '__secureFetch');
|
|
3495
3543
|
lines.push(`const __server = Bun.serve({`);
|
|
3496
3544
|
lines.push(` port: __port,`);
|
|
3497
3545
|
lines.push(` maxRequestBodySize: __maxBodySize,`);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Theme codegen: converts ThemeBlock AST to CSS custom properties
|
|
2
|
+
|
|
3
|
+
const PX_SECTIONS = new Set(['spacing', 'radius']);
|
|
4
|
+
const PX_FONT_PREFIXES = ['size.'];
|
|
5
|
+
|
|
6
|
+
const CATEGORY_MAP = {
|
|
7
|
+
colors: 'color',
|
|
8
|
+
spacing: 'spacing',
|
|
9
|
+
radius: 'radius',
|
|
10
|
+
shadow: 'shadow',
|
|
11
|
+
font: 'font',
|
|
12
|
+
breakpoints: 'breakpoint',
|
|
13
|
+
transition: 'transition',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export class ThemeCodegen {
|
|
17
|
+
static mergeThemeBlocks(themeBlocks) {
|
|
18
|
+
const sections = new Map();
|
|
19
|
+
const darkOverrides = [];
|
|
20
|
+
for (const block of themeBlocks) {
|
|
21
|
+
for (const section of block.sections) {
|
|
22
|
+
if (!sections.has(section.name)) sections.set(section.name, []);
|
|
23
|
+
sections.get(section.name).push(...section.tokens);
|
|
24
|
+
}
|
|
25
|
+
darkOverrides.push(...block.darkOverrides);
|
|
26
|
+
}
|
|
27
|
+
return { sections, darkOverrides };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static generateCSS(themeConfig) {
|
|
31
|
+
const { sections, darkOverrides } = themeConfig;
|
|
32
|
+
const rootProps = [];
|
|
33
|
+
const darkProps = [];
|
|
34
|
+
|
|
35
|
+
for (const [sectionName, tokens] of sections) {
|
|
36
|
+
const prefix = CATEGORY_MAP[sectionName] || sectionName;
|
|
37
|
+
for (const token of tokens) {
|
|
38
|
+
const cssName = `--tova-${prefix}-${token.name.replace(/\./g, '-')}`;
|
|
39
|
+
const cssValue = ThemeCodegen._formatValue(sectionName, token.name, token.value);
|
|
40
|
+
rootProps.push(` ${cssName}: ${cssValue};`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const override of darkOverrides) {
|
|
45
|
+
const dotIdx = override.name.indexOf('.');
|
|
46
|
+
const sectionName = override.name.slice(0, dotIdx);
|
|
47
|
+
const tokenName = override.name.slice(dotIdx + 1);
|
|
48
|
+
const prefix = CATEGORY_MAP[sectionName] || sectionName;
|
|
49
|
+
const cssName = `--tova-${prefix}-${tokenName.replace(/\./g, '-')}`;
|
|
50
|
+
const cssValue = ThemeCodegen._formatValue(sectionName, tokenName, override.value);
|
|
51
|
+
darkProps.push(` ${cssName}: ${cssValue};`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let css = `:root {\n${rootProps.join('\n')}\n}`;
|
|
55
|
+
if (darkProps.length > 0) {
|
|
56
|
+
css += `\n@media (prefers-color-scheme: dark) {\n :root {\n${darkProps.join('\n')}\n }\n}`;
|
|
57
|
+
}
|
|
58
|
+
return css;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static _formatValue(sectionName, tokenName, value) {
|
|
62
|
+
if (typeof value === 'number') {
|
|
63
|
+
if (PX_SECTIONS.has(sectionName)) return value + 'px';
|
|
64
|
+
if (sectionName === 'font' && PX_FONT_PREFIXES.some(p => tokenName.startsWith(p))) return value + 'px';
|
|
65
|
+
return String(value);
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -2,19 +2,51 @@
|
|
|
2
2
|
// Determines whether an import is a Tova module (vs npm/relative),
|
|
3
3
|
// parses module paths into components, and converts them to git URLs.
|
|
4
4
|
|
|
5
|
+
// Blessed first-party packages: tova/X → github.com/tova-lang/X
|
|
6
|
+
export const BLESSED_PACKAGES = {
|
|
7
|
+
fp: 'github.com/tova-lang/fp',
|
|
8
|
+
validate: 'github.com/tova-lang/validate',
|
|
9
|
+
encoding: 'github.com/tova-lang/encoding',
|
|
10
|
+
test: 'github.com/tova-lang/test',
|
|
11
|
+
retry: 'github.com/tova-lang/retry',
|
|
12
|
+
template: 'github.com/tova-lang/template',
|
|
13
|
+
data: 'github.com/tova-lang/data',
|
|
14
|
+
stats: 'github.com/tova-lang/stats',
|
|
15
|
+
plot: 'github.com/tova-lang/plot',
|
|
16
|
+
ml: 'github.com/tova-lang/ml',
|
|
17
|
+
ui: 'github.com/tova-lang/ui',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function expandBlessedPackage(source) {
|
|
21
|
+
if (!source || !source.startsWith('tova/')) return null;
|
|
22
|
+
const rest = source.slice(5);
|
|
23
|
+
const name = rest.split('/')[0];
|
|
24
|
+
if (BLESSED_PACKAGES[name]) return BLESSED_PACKAGES[name] + (rest.includes('/') ? '/' + rest.slice(name.length + 1) : '');
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
5
28
|
export function isTovModule(source) {
|
|
6
29
|
if (!source || source.startsWith('.') || source.startsWith('/') || source.startsWith('@') || source.includes(':')) {
|
|
7
30
|
return false;
|
|
8
31
|
}
|
|
32
|
+
// Check blessed packages first
|
|
33
|
+
if (source.startsWith('tova/')) {
|
|
34
|
+
const name = source.slice(5).split('/')[0];
|
|
35
|
+
return !!BLESSED_PACKAGES[name];
|
|
36
|
+
}
|
|
9
37
|
const firstSegment = source.split('/')[0];
|
|
10
38
|
return firstSegment.includes('.');
|
|
11
39
|
}
|
|
12
40
|
|
|
13
41
|
export function parseModulePath(source) {
|
|
14
|
-
|
|
42
|
+
// Expand blessed packages: tova/data → github.com/tova-lang/data
|
|
43
|
+
const expanded = expandBlessedPackage(source);
|
|
44
|
+
const actual = expanded || source;
|
|
45
|
+
|
|
46
|
+
if (!expanded && !isTovModule(actual)) {
|
|
15
47
|
throw new Error(`Invalid Tova module path: "${source}"`);
|
|
16
48
|
}
|
|
17
|
-
const parts =
|
|
49
|
+
const parts = actual.split('/');
|
|
18
50
|
if (parts.length < 3) {
|
|
19
51
|
throw new Error(`Invalid Tova module path: "${source}" — expected at least host/owner/repo`);
|
|
20
52
|
}
|
package/src/config/resolve.js
CHANGED
|
@@ -14,6 +14,9 @@ const DEFAULTS = {
|
|
|
14
14
|
build: {
|
|
15
15
|
output: '.tova-out',
|
|
16
16
|
},
|
|
17
|
+
deploy: {
|
|
18
|
+
base: '/',
|
|
19
|
+
},
|
|
17
20
|
dev: {
|
|
18
21
|
port: 3000,
|
|
19
22
|
},
|
|
@@ -53,6 +56,9 @@ function normalizeConfig(parsed, source) {
|
|
|
53
56
|
build: {
|
|
54
57
|
output: parsed.build?.output || DEFAULTS.build.output,
|
|
55
58
|
},
|
|
59
|
+
deploy: {
|
|
60
|
+
base: parsed.deploy?.base || DEFAULTS.deploy.base,
|
|
61
|
+
},
|
|
56
62
|
dev: {
|
|
57
63
|
port: parsed.dev?.port ?? DEFAULTS.dev.port,
|
|
58
64
|
},
|
|
@@ -104,6 +110,9 @@ function configFromPackageJson(pkg) {
|
|
|
104
110
|
build: {
|
|
105
111
|
output: DEFAULTS.build.output,
|
|
106
112
|
},
|
|
113
|
+
deploy: {
|
|
114
|
+
base: '/',
|
|
115
|
+
},
|
|
107
116
|
dev: {
|
|
108
117
|
port: DEFAULTS.dev.port,
|
|
109
118
|
},
|
package/src/config/toml.js
CHANGED
|
@@ -37,7 +37,19 @@ export function parseTOML(input) {
|
|
|
37
37
|
if (eqIdx === -1) continue; // skip lines without =
|
|
38
38
|
|
|
39
39
|
const key = line.slice(0, eqIdx).trim();
|
|
40
|
-
|
|
40
|
+
let rawValue = line.slice(eqIdx + 1).trim();
|
|
41
|
+
|
|
42
|
+
// Multi-line array: starts with [ but doesn't end with ]
|
|
43
|
+
if (rawValue.startsWith('[') && !rawValue.endsWith(']')) {
|
|
44
|
+
// Collect continuation lines until we find the closing ]
|
|
45
|
+
while (i + 1 < lines.length) {
|
|
46
|
+
i++;
|
|
47
|
+
const contLine = lines[i].trim();
|
|
48
|
+
if (contLine === '' || contLine.startsWith('#')) continue;
|
|
49
|
+
rawValue += ' ' + contLine;
|
|
50
|
+
if (contLine.includes(']')) break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
41
53
|
|
|
42
54
|
current[key] = parseValue(rawValue, i + 1);
|
|
43
55
|
}
|
package/src/deploy/provision.js
CHANGED
|
@@ -138,6 +138,8 @@ export function generateProvisionScript(manifest) {
|
|
|
138
138
|
domain: manifest.domain,
|
|
139
139
|
instances: manifest.instances || 1,
|
|
140
140
|
health: manifest.health,
|
|
141
|
+
health_interval: manifest.health_interval,
|
|
142
|
+
health_timeout: manifest.health_timeout,
|
|
141
143
|
hasWebSocket: manifest.hasWebSocket,
|
|
142
144
|
});
|
|
143
145
|
lines.push(`cat > /etc/caddy/Caddyfile <<'CADDY_EOF'`);
|
|
@@ -257,6 +259,8 @@ export function generateCaddyConfig(appName, opts = {}) {
|
|
|
257
259
|
const domain = opts.domain || 'localhost';
|
|
258
260
|
const instances = opts.instances || 1;
|
|
259
261
|
const health = opts.health || '/healthz';
|
|
262
|
+
const healthInterval = opts.health_interval || 30;
|
|
263
|
+
const healthTimeout = opts.health_timeout || 5;
|
|
260
264
|
const hasWebSocket = opts.hasWebSocket || false;
|
|
261
265
|
|
|
262
266
|
const lines = [];
|
|
@@ -277,8 +281,8 @@ export function generateCaddyConfig(appName, opts = {}) {
|
|
|
277
281
|
|
|
278
282
|
// Health check
|
|
279
283
|
lines.push(` health_uri ${health}`);
|
|
280
|
-
lines.push(
|
|
281
|
-
lines.push(
|
|
284
|
+
lines.push(` health_interval ${healthInterval}s`);
|
|
285
|
+
lines.push(` health_timeout ${healthTimeout}s`);
|
|
282
286
|
|
|
283
287
|
lines.push(' }');
|
|
284
288
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security Scorecard — post-compilation security posture summary.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function generateSecurityScorecard(securityConfig, warnings, hasServer, hasEdge) {
|
|
6
|
+
if (!hasServer && !hasEdge) return null;
|
|
7
|
+
|
|
8
|
+
const items = [];
|
|
9
|
+
let score = 10;
|
|
10
|
+
|
|
11
|
+
const warningCodes = new Set((warnings || []).map(w => w.code).filter(Boolean));
|
|
12
|
+
|
|
13
|
+
if (!securityConfig) {
|
|
14
|
+
items.push({ pass: false, label: 'No security block configured', deduction: 3 });
|
|
15
|
+
score -= 3;
|
|
16
|
+
items.push({ pass: false, label: 'No auth configured', deduction: 2 });
|
|
17
|
+
score -= 2;
|
|
18
|
+
items.push({ pass: false, label: 'No CSRF protection', deduction: 1 });
|
|
19
|
+
score -= 1;
|
|
20
|
+
items.push({ pass: false, label: 'No rate limiting', deduction: 1 });
|
|
21
|
+
score -= 1;
|
|
22
|
+
items.push({ pass: false, label: 'No CSP configured', deduction: 1 });
|
|
23
|
+
score -= 1;
|
|
24
|
+
items.push({ pass: false, label: 'No audit logging', deduction: 1 });
|
|
25
|
+
score -= 1;
|
|
26
|
+
return { score: Math.max(0, score), items, format: () => formatScorecard(Math.max(0, score), items) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Auth
|
|
30
|
+
if (securityConfig.auth) {
|
|
31
|
+
const isCookie = securityConfig.auth.storage === 'cookie';
|
|
32
|
+
const authType = securityConfig.auth.authType || 'JWT';
|
|
33
|
+
if (isCookie) {
|
|
34
|
+
items.push({ pass: true, label: `${authType} auth with HttpOnly cookies` });
|
|
35
|
+
} else {
|
|
36
|
+
items.push({ pass: true, label: `${authType} auth configured` });
|
|
37
|
+
}
|
|
38
|
+
if (warningCodes.has('W_LOCALSTORAGE_TOKEN')) {
|
|
39
|
+
items.push({ pass: false, label: 'Auth tokens in localStorage (XSS vulnerable)', deduction: 1 });
|
|
40
|
+
score -= 1;
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
items.push({ pass: false, label: 'No auth configured', deduction: 2 });
|
|
44
|
+
score -= 2;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// CSRF
|
|
48
|
+
if (securityConfig.csrf && securityConfig.csrf.enabled === false) {
|
|
49
|
+
items.push({ pass: false, label: 'CSRF protection disabled', deduction: 1 });
|
|
50
|
+
score -= 1;
|
|
51
|
+
} else if (securityConfig.auth) {
|
|
52
|
+
items.push({ pass: true, label: 'CSRF enabled with session binding' });
|
|
53
|
+
} else {
|
|
54
|
+
items.push({ pass: false, label: 'No CSRF protection', deduction: 1 });
|
|
55
|
+
score -= 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Rate limiting
|
|
59
|
+
if (securityConfig.rateLimit) {
|
|
60
|
+
items.push({ pass: true, label: 'Rate limiting configured' });
|
|
61
|
+
} else if (securityConfig.auth) {
|
|
62
|
+
items.push({ pass: false, label: 'No rate limiting (auth without brute-force protection)', deduction: 1 });
|
|
63
|
+
score -= 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// CSP
|
|
67
|
+
if (securityConfig.csp) {
|
|
68
|
+
items.push({ pass: true, label: 'Content Security Policy configured' });
|
|
69
|
+
} else {
|
|
70
|
+
items.push({ pass: false, label: 'No CSP configured', deduction: 1 });
|
|
71
|
+
score -= 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// CORS wildcard
|
|
75
|
+
if (warningCodes.has('W_CORS_WILDCARD')) {
|
|
76
|
+
items.push({ pass: false, label: 'CORS allows wildcard origins', deduction: 1 });
|
|
77
|
+
score -= 1;
|
|
78
|
+
} else if (securityConfig.cors) {
|
|
79
|
+
items.push({ pass: true, label: 'CORS restricted to specific origins' });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Hardcoded secret
|
|
83
|
+
if (warningCodes.has('W_HARDCODED_SECRET')) {
|
|
84
|
+
items.push({ pass: false, label: 'Auth secret hardcoded in source', deduction: 1 });
|
|
85
|
+
score -= 1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Audit logging
|
|
89
|
+
if (securityConfig.audit) {
|
|
90
|
+
items.push({ pass: true, label: 'Audit logging configured' });
|
|
91
|
+
} else {
|
|
92
|
+
items.push({ pass: false, label: 'No audit logging', deduction: 1 });
|
|
93
|
+
score -= 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
score = Math.max(0, score);
|
|
97
|
+
return { score, items, format: () => formatScorecard(score, items) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatScorecard(score, items) {
|
|
101
|
+
const lines = [];
|
|
102
|
+
lines.push(`\x1b[1mSecurity: ${score}/10\x1b[0m`);
|
|
103
|
+
for (const item of items) {
|
|
104
|
+
if (item.pass) {
|
|
105
|
+
lines.push(` \x1b[32m[pass]\x1b[0m ${item.label}`);
|
|
106
|
+
} else {
|
|
107
|
+
lines.push(` \x1b[33m[warn]\x1b[0m ${item.label} (-${item.deduction})`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return lines.join('\n');
|
|
111
|
+
}
|
package/src/lexer/lexer.js
CHANGED
|
@@ -899,15 +899,30 @@ export class Lexer {
|
|
|
899
899
|
return;
|
|
900
900
|
}
|
|
901
901
|
|
|
902
|
-
// Special case: "style {" → read raw CSS block
|
|
902
|
+
// Special case: "style {" or "style(...) {" → read raw CSS block
|
|
903
903
|
if (value === 'style') {
|
|
904
904
|
const savedPos = this.pos;
|
|
905
905
|
const savedLine = this.line;
|
|
906
906
|
const savedCol = this.column;
|
|
907
|
-
// Skip whitespace (including newlines) to check for {
|
|
907
|
+
// Skip whitespace (including newlines) to check for ( or {
|
|
908
908
|
while (this.pos < this.length && (this.isWhitespace(this.peek()) || this.peek() === '\n')) {
|
|
909
909
|
this.advance();
|
|
910
910
|
}
|
|
911
|
+
// Check for style(...) config
|
|
912
|
+
let configPrefix = '';
|
|
913
|
+
if (this.peek() === '(') {
|
|
914
|
+
this.advance(); // skip (
|
|
915
|
+
let configStr = '';
|
|
916
|
+
while (this.pos < this.length && this.peek() !== ')') {
|
|
917
|
+
configStr += this.advance();
|
|
918
|
+
}
|
|
919
|
+
if (this.pos < this.length) this.advance(); // skip )
|
|
920
|
+
configPrefix = '__CONFIG:' + configStr.trim() + '__';
|
|
921
|
+
// Skip whitespace after )
|
|
922
|
+
while (this.pos < this.length && (this.isWhitespace(this.peek()) || this.peek() === '\n')) {
|
|
923
|
+
this.advance();
|
|
924
|
+
}
|
|
925
|
+
}
|
|
911
926
|
if (this.peek() === '{') {
|
|
912
927
|
this.advance(); // skip {
|
|
913
928
|
let depth = 1;
|
|
@@ -924,7 +939,7 @@ export class Lexer {
|
|
|
924
939
|
if (depth > 0) {
|
|
925
940
|
this.error('Unterminated style block');
|
|
926
941
|
}
|
|
927
|
-
this.tokens.push(new Token(TokenType.STYLE_BLOCK, css.trim(), startLine, startCol));
|
|
942
|
+
this.tokens.push(new Token(TokenType.STYLE_BLOCK, configPrefix + css.trim(), startLine, startCol));
|
|
928
943
|
return;
|
|
929
944
|
}
|
|
930
945
|
// Not a style block — restore position
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Animate-specific AST Node definitions for the Tova language
|
|
2
|
+
// Extracted for lazy loading — only loaded when animate { } blocks are used.
|
|
3
|
+
|
|
4
|
+
// ============================================================
|
|
5
|
+
// Animate-specific nodes
|
|
6
|
+
// ============================================================
|
|
7
|
+
|
|
8
|
+
export class AnimateDeclaration {
|
|
9
|
+
constructor(name, enter, exit, duration, easing, stagger, stay, loc) {
|
|
10
|
+
this.type = 'AnimateDeclaration';
|
|
11
|
+
this.name = name; // string — animation name, e.g. "fadeIn"
|
|
12
|
+
this.enter = enter; // AnimatePrimitive|AnimateSequence|AnimateParallel|null
|
|
13
|
+
this.exit = exit; // AnimatePrimitive|AnimateSequence|AnimateParallel|null
|
|
14
|
+
this.duration = duration; // number|null — duration in ms
|
|
15
|
+
this.easing = easing; // string|null — CSS easing function
|
|
16
|
+
this.stagger = stagger; // number|null — stagger delay in ms
|
|
17
|
+
this.stay = stay; // number|null — auto-dismiss delay in ms
|
|
18
|
+
this.loc = loc;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class AnimatePrimitive {
|
|
23
|
+
constructor(name, params, loc) {
|
|
24
|
+
this.type = 'AnimatePrimitive';
|
|
25
|
+
this.name = name; // 'fade'|'slide'|'scale'|'rotate'|'blur'
|
|
26
|
+
this.params = params; // object e.g. {from: 0, to: 1, y: 20}
|
|
27
|
+
this.loc = loc;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class AnimateSequence {
|
|
32
|
+
constructor(children, loc) {
|
|
33
|
+
this.type = 'AnimateSequence';
|
|
34
|
+
this.children = children; // AnimatePrimitive[] or AnimateParallel[]
|
|
35
|
+
this.loc = loc;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class AnimateParallel {
|
|
40
|
+
constructor(children, loc) {
|
|
41
|
+
this.type = 'AnimateParallel';
|
|
42
|
+
this.children = children; // AnimatePrimitive[]
|
|
43
|
+
this.loc = loc;
|
|
44
|
+
}
|
|
45
|
+
}
|