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.
@@ -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
- // Use genBlockStatements for pattern optimization (array fill detection, etc.)
107
- const fakeBlock = { type: 'BlockStatement', body: topLevel };
108
- const moduleCode = moduleGen.genBlockStatements(fakeBlock);
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 combined = [helpers, moduleCode].filter(s => s.trim()).join('\n').trim();
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) return __errorResponse(429, "RATE_LIMITED", "Too Many Requests", null, { "Retry-After": String(__rlRoute.retryAfter) });`);
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 (__pathname === "/" && typeof __clientHTML !== "undefined") {');
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 at root
3426
- lines.push(' if (__pathname === "/" && typeof __clientHTML !== "undefined") {');
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 = (!isFastMode || compressionConfig) ? '__idempotentFetch' : '__handleRequest';
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
- if (!isTovModule(source)) {
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 = source.split('/');
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
  }
@@ -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
  },
@@ -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
- const rawValue = line.slice(eqIdx + 1).trim();
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
  }
@@ -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(' health_interval 30s');
281
- lines.push(' health_timeout 5s');
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
+ }
@@ -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
+ }