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
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import { BaseCodegen } from './base-codegen.js';
|
|
2
2
|
import { getBrowserStdlib, buildSelectiveStdlib, RESULT_OPTION, PROPAGATE } from '../stdlib/inline.js';
|
|
3
3
|
import { SecurityCodegen } from './security-codegen.js';
|
|
4
|
+
import { ThemeCodegen } from './theme-codegen.js';
|
|
4
5
|
import { generateValidatorFn, generateFieldSignals, generateFieldAccessor, generateGroupCode, generateArrayCode, generateAsyncValidatorEffect } from './form-codegen.js';
|
|
5
6
|
|
|
7
|
+
// JS reserved words that cannot be used as variable names
|
|
8
|
+
const JS_RESERVED = new Set([
|
|
9
|
+
'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default',
|
|
10
|
+
'delete', 'do', 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for',
|
|
11
|
+
'function', 'if', 'import', 'in', 'instanceof', 'new', 'null', 'return', 'super',
|
|
12
|
+
'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with',
|
|
13
|
+
'yield', 'let', 'static', 'implements', 'interface', 'package', 'private',
|
|
14
|
+
'protected', 'public', 'await', 'async'
|
|
15
|
+
]);
|
|
16
|
+
|
|
6
17
|
export class BrowserCodegen extends BaseCodegen {
|
|
7
18
|
constructor() {
|
|
8
19
|
super();
|
|
@@ -11,6 +22,7 @@ export class BrowserCodegen extends BaseCodegen {
|
|
|
11
22
|
this.componentNames = new Set(); // Track component names for JSX
|
|
12
23
|
this.storeNames = new Set(); // Track store names
|
|
13
24
|
this.formNames = new Set(); // Track form names
|
|
25
|
+
this._paramRenames = new Map(); // Track JS reserved word renames for component params
|
|
14
26
|
this._asyncContext = false; // When true, server.xxx() calls emit `await`
|
|
15
27
|
this._rpcCache = new WeakMap(); // Memoize _containsRPC() results
|
|
16
28
|
this._signalCache = new WeakMap(); // Memoize _exprReadsSignal() results
|
|
@@ -107,7 +119,8 @@ export class BrowserCodegen extends BaseCodegen {
|
|
|
107
119
|
genExpression(node) {
|
|
108
120
|
if (node && node.type === 'Identifier' &&
|
|
109
121
|
(this.stateNames.has(node.name) || this.computedNames.has(node.name))) {
|
|
110
|
-
|
|
122
|
+
const safeName = this._paramRenames.get(node.name) || node.name;
|
|
123
|
+
return `${safeName}()`;
|
|
111
124
|
}
|
|
112
125
|
return super.genExpression(node);
|
|
113
126
|
}
|
|
@@ -187,15 +200,16 @@ export class BrowserCodegen extends BaseCodegen {
|
|
|
187
200
|
return `${asyncPrefix}(${params}) => ${this.genExpression(node.body)}`;
|
|
188
201
|
}
|
|
189
202
|
|
|
190
|
-
generate(browserBlocks, sharedCode, sharedBuiltins = null, securityConfig = null, typeValidatorsMap = null) {
|
|
203
|
+
generate(browserBlocks, sharedCode, sharedBuiltins = null, securityConfig = null, typeValidatorsMap = null, themeConfig = null) {
|
|
191
204
|
this._sharedBuiltins = sharedBuiltins || new Set();
|
|
192
205
|
this._typeValidators = typeValidatorsMap || {};
|
|
206
|
+
this._themeConfig = themeConfig;
|
|
193
207
|
const lines = [];
|
|
194
208
|
|
|
195
209
|
// Runtime imports
|
|
196
|
-
lines.push(`import { createSignal, createEffect, createComputed, mount, hydrate, tova_el, tova_fragment, tova_keyed, tova_transition, tova_inject_css, batch, onMount, onUnmount, onCleanup, onBeforeUpdate, createRef, createContext, provide, inject, createErrorBoundary, ErrorBoundary, ErrorInfo, createRoot, watch, untrack, Dynamic, Portal, lazy, Suspense, Head, createResource, __tova_action, TransitionGroup, createForm, configureCSP } from './runtime/reactivity.js';`);
|
|
210
|
+
lines.push(`import { createSignal, createEffect, createComputed, mount, hydrate, tova_el, tova_fragment, tova_keyed, tova_transition, tova_inject_css, batch, onMount, onUnmount, onCleanup, onBeforeUpdate, createRef, createContext, provide, inject, createErrorBoundary, ErrorBoundary, ErrorInfo, createRoot, watch, untrack, Dynamic, Portal, lazy, Suspense, Head, createResource, __tova_action, TransitionGroup, createForm, configureCSP, __tova_load_font } from './runtime/reactivity.js';`);
|
|
197
211
|
lines.push(`import { rpc, configureRPC, addRPCInterceptor, setCSRFToken } from './runtime/rpc.js';`);
|
|
198
|
-
lines.push(`import { navigate, getCurrentRoute, getParams, getPath, getQuery, defineRoutes, onRouteChange, beforeNavigate, afterNavigate, Router, Outlet, Link, Redirect } from './runtime/router.js';`);
|
|
212
|
+
lines.push(`import { createRouter, lazy, resetRouter, navigate, getCurrentRoute, getParams, getPath, getQuery, getMeta, defineRoutes, onRouteChange, beforeNavigate, afterNavigate, getRouter, Router, Outlet, Link, Redirect } from './runtime/router.js';`);
|
|
199
213
|
|
|
200
214
|
// Hoist import lines from shared code to the top of the module
|
|
201
215
|
let sharedRest = sharedCode;
|
|
@@ -233,6 +247,14 @@ export class BrowserCodegen extends BaseCodegen {
|
|
|
233
247
|
lines.push('__STDLIB_PLACEHOLDER__');
|
|
234
248
|
lines.push('');
|
|
235
249
|
|
|
250
|
+
// Theme CSS custom properties
|
|
251
|
+
if (themeConfig) {
|
|
252
|
+
const themeCSS = ThemeCodegen.generateCSS(themeConfig);
|
|
253
|
+
lines.push('// ── Theme ──');
|
|
254
|
+
lines.push(`tova_inject_css("__tova_theme", ${JSON.stringify(themeCSS)});`);
|
|
255
|
+
lines.push('');
|
|
256
|
+
}
|
|
257
|
+
|
|
236
258
|
// Server RPC proxy
|
|
237
259
|
lines.push('// ── Server RPC Proxy ──');
|
|
238
260
|
lines.push('const server = new Proxy({}, {');
|
|
@@ -425,6 +447,14 @@ export class BrowserCodegen extends BaseCodegen {
|
|
|
425
447
|
return p.join('');
|
|
426
448
|
}
|
|
427
449
|
|
|
450
|
+
// Resolve $token syntax in CSS: $category.name.sub -> var(--tova-category-name-sub)
|
|
451
|
+
_resolveTokens(css) {
|
|
452
|
+
return css.replace(/\$(\w+)\.([\w.]+)/g, (match, category, name) => {
|
|
453
|
+
const cssName = name.replace(/\./g, '-');
|
|
454
|
+
return `var(--tova-${category}-${cssName})`;
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
428
458
|
// Generate a scope hash from component name + CSS content (for CSS scoping)
|
|
429
459
|
// Uses FNV-1a for better distribution and 8-char output to reduce collision risk.
|
|
430
460
|
_genScopeId(name, css) {
|
|
@@ -552,6 +582,453 @@ export class BrowserCodegen extends BaseCodegen {
|
|
|
552
582
|
return s + scopeAttr;
|
|
553
583
|
}
|
|
554
584
|
|
|
585
|
+
static DEFAULT_BREAKPOINTS = { mobile: 0, tablet: 768, desktop: 1024, wide: 1440 };
|
|
586
|
+
|
|
587
|
+
_getBreakpoints() {
|
|
588
|
+
if (this._themeConfig && this._themeConfig.sections) {
|
|
589
|
+
const bpSection = this._themeConfig.sections.get('breakpoints');
|
|
590
|
+
if (bpSection) {
|
|
591
|
+
const result = {};
|
|
592
|
+
for (const token of bpSection) {
|
|
593
|
+
result[token.name] = token.value;
|
|
594
|
+
}
|
|
595
|
+
return result;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return BrowserCodegen.DEFAULT_BREAKPOINTS;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Auto-inject @media (prefers-reduced-motion: reduce) when CSS uses transition or animation
|
|
602
|
+
_generateReducedMotion(scopedCSS, scopeAttr) {
|
|
603
|
+
const hasTransition = /\btransition\s*:|\btransition-/.test(scopedCSS);
|
|
604
|
+
const hasAnimation = /\banimation\s*:|\banimation-/.test(scopedCSS);
|
|
605
|
+
if (!hasTransition && !hasAnimation) return scopedCSS;
|
|
606
|
+
let rules = '';
|
|
607
|
+
if (hasTransition) rules += ' transition-duration: 0.01ms !important;';
|
|
608
|
+
if (hasAnimation) rules += ' animation-duration: 0.01ms !important; animation-iteration-count: 1 !important;';
|
|
609
|
+
return scopedCSS + ` @media (prefers-reduced-motion: reduce) { ${scopeAttr} {${rules} } }`;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ── Animate @keyframes generation ──────────────────────────
|
|
613
|
+
|
|
614
|
+
// Main entry: generates @keyframes CSS from an AnimateDeclaration AST node.
|
|
615
|
+
// Returns { css, enterName, exitName, duration, easing, stagger }
|
|
616
|
+
_generateAnimateKeyframes(animDecl, scopeId) {
|
|
617
|
+
const enterName = `__tova_${scopeId}_${animDecl.name}_enter`;
|
|
618
|
+
const exitName = `__tova_${scopeId}_${animDecl.name}_exit`;
|
|
619
|
+
const duration = animDecl.duration || 300;
|
|
620
|
+
const easing = animDecl.easing || 'ease';
|
|
621
|
+
const stagger = animDecl.stagger || 0;
|
|
622
|
+
|
|
623
|
+
let css = '';
|
|
624
|
+
|
|
625
|
+
if (animDecl.enter) {
|
|
626
|
+
css += this._compositionToKeyframes(enterName, animDecl.enter);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (animDecl.exit) {
|
|
630
|
+
css += this._compositionToKeyframes(exitName, animDecl.exit);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return { css, enterName, exitName, duration, easing, stagger, hasExit: !!animDecl.exit };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Dispatches to the correct keyframe generator based on AST node type
|
|
637
|
+
_compositionToKeyframes(name, node) {
|
|
638
|
+
if (node.type === 'AnimatePrimitive') {
|
|
639
|
+
const kf = this._primitiveToKeyframes(node);
|
|
640
|
+
return `@keyframes ${name} { from { ${kf.from} } to { ${kf.to} } } `;
|
|
641
|
+
}
|
|
642
|
+
if (node.type === 'AnimateParallel') {
|
|
643
|
+
const kf = this._parallelToKeyframes(node.children);
|
|
644
|
+
return `@keyframes ${name} { from { ${kf.from} } to { ${kf.to} } } `;
|
|
645
|
+
}
|
|
646
|
+
if (node.type === 'AnimateSequence') {
|
|
647
|
+
return this._sequenceToKeyframes(name, node.children);
|
|
648
|
+
}
|
|
649
|
+
return '';
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Converts a single AnimatePrimitive to { from: "css", to: "css" }
|
|
653
|
+
_primitiveToKeyframes(prim) {
|
|
654
|
+
const p = prim.params;
|
|
655
|
+
switch (prim.name) {
|
|
656
|
+
case 'fade':
|
|
657
|
+
return {
|
|
658
|
+
from: `opacity: ${p.from !== undefined ? p.from : 0};`,
|
|
659
|
+
to: `opacity: ${p.to !== undefined ? p.to : 1};`,
|
|
660
|
+
fromProps: { opacity: `${p.from !== undefined ? p.from : 0}` },
|
|
661
|
+
toProps: { opacity: `${p.to !== undefined ? p.to : 1}` },
|
|
662
|
+
};
|
|
663
|
+
case 'slide': {
|
|
664
|
+
let fromTransform, toTransform;
|
|
665
|
+
if (p.x !== undefined && p.y !== undefined) {
|
|
666
|
+
fromTransform = `translate(${p.x}px, ${p.y}px)`;
|
|
667
|
+
toTransform = `translate(${p.to !== undefined ? p.to : 0}px, ${p.to !== undefined ? p.to : 0}px)`;
|
|
668
|
+
} else if (p.x !== undefined) {
|
|
669
|
+
fromTransform = `translateX(${p.x}px)`;
|
|
670
|
+
toTransform = `translateX(${p.to !== undefined ? p.to : 0}px)`;
|
|
671
|
+
} else {
|
|
672
|
+
fromTransform = `translateY(${p.y !== undefined ? p.y : 0}px)`;
|
|
673
|
+
toTransform = `translateY(${p.to !== undefined ? p.to : 0}px)`;
|
|
674
|
+
}
|
|
675
|
+
return {
|
|
676
|
+
from: `transform: ${fromTransform};`,
|
|
677
|
+
to: `transform: ${toTransform};`,
|
|
678
|
+
fromProps: { transform: fromTransform },
|
|
679
|
+
toProps: { transform: toTransform },
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
case 'scale':
|
|
683
|
+
return {
|
|
684
|
+
from: `transform: scale(${p.from !== undefined ? p.from : 1});`,
|
|
685
|
+
to: `transform: scale(${p.to !== undefined ? p.to : 1});`,
|
|
686
|
+
fromProps: { transform: `scale(${p.from !== undefined ? p.from : 1})` },
|
|
687
|
+
toProps: { transform: `scale(${p.to !== undefined ? p.to : 1})` },
|
|
688
|
+
};
|
|
689
|
+
case 'rotate':
|
|
690
|
+
return {
|
|
691
|
+
from: `transform: rotate(${p.from !== undefined ? p.from : 0}deg);`,
|
|
692
|
+
to: `transform: rotate(${p.to !== undefined ? p.to : 0}deg);`,
|
|
693
|
+
fromProps: { transform: `rotate(${p.from !== undefined ? p.from : 0}deg)` },
|
|
694
|
+
toProps: { transform: `rotate(${p.to !== undefined ? p.to : 0}deg)` },
|
|
695
|
+
};
|
|
696
|
+
case 'blur':
|
|
697
|
+
return {
|
|
698
|
+
from: `filter: blur(${p.from !== undefined ? p.from : 0}px);`,
|
|
699
|
+
to: `filter: blur(${p.to !== undefined ? p.to : 0}px);`,
|
|
700
|
+
fromProps: { filter: `blur(${p.from !== undefined ? p.from : 0}px)` },
|
|
701
|
+
toProps: { filter: `blur(${p.to !== undefined ? p.to : 0}px)` },
|
|
702
|
+
};
|
|
703
|
+
default:
|
|
704
|
+
return { from: '', to: '', fromProps: {}, toProps: {} };
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Merges multiple primitives into a single from/to block (parallel composition)
|
|
709
|
+
_parallelToKeyframes(children) {
|
|
710
|
+
const fromProps = {};
|
|
711
|
+
const toProps = {};
|
|
712
|
+
const fromTransforms = [];
|
|
713
|
+
const toTransforms = [];
|
|
714
|
+
|
|
715
|
+
for (const child of children) {
|
|
716
|
+
const kf = this._primitiveToKeyframes(child);
|
|
717
|
+
for (const [prop, val] of Object.entries(kf.fromProps || {})) {
|
|
718
|
+
if (prop === 'transform') {
|
|
719
|
+
fromTransforms.push(val);
|
|
720
|
+
} else {
|
|
721
|
+
fromProps[prop] = val;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
for (const [prop, val] of Object.entries(kf.toProps || {})) {
|
|
725
|
+
if (prop === 'transform') {
|
|
726
|
+
toTransforms.push(val);
|
|
727
|
+
} else {
|
|
728
|
+
toProps[prop] = val;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (fromTransforms.length > 0) fromProps.transform = fromTransforms.join(' ');
|
|
734
|
+
if (toTransforms.length > 0) toProps.transform = toTransforms.join(' ');
|
|
735
|
+
|
|
736
|
+
const fromCSS = Object.entries(fromProps).map(([k, v]) => `${k}: ${v};`).join(' ');
|
|
737
|
+
const toCSS = Object.entries(toProps).map(([k, v]) => `${k}: ${v};`).join(' ');
|
|
738
|
+
|
|
739
|
+
return { from: fromCSS, to: toCSS };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Generates percentage-based keyframes for sequential composition
|
|
743
|
+
_sequenceToKeyframes(name, children) {
|
|
744
|
+
const n = children.length;
|
|
745
|
+
const stops = []; // Array of { percent, props }
|
|
746
|
+
|
|
747
|
+
for (let idx = 0; idx < n; idx++) {
|
|
748
|
+
const child = children[idx];
|
|
749
|
+
let kf;
|
|
750
|
+
if (child.type === 'AnimateParallel') {
|
|
751
|
+
const merged = this._parallelToKeyframes(child.children);
|
|
752
|
+
// Parse from/to CSS into props
|
|
753
|
+
kf = { fromProps: this._parseCSSProps(merged.from), toProps: this._parseCSSProps(merged.to) };
|
|
754
|
+
} else {
|
|
755
|
+
kf = this._primitiveToKeyframes(child);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const startPct = Math.floor((idx / n) * 100);
|
|
759
|
+
const endPct = idx === n - 1 ? 100 : Math.floor(((idx + 1) / n) * 100);
|
|
760
|
+
|
|
761
|
+
if (idx === 0) {
|
|
762
|
+
// First child: add from props at 0%
|
|
763
|
+
stops.push({ percent: startPct, props: { ...(kf.fromProps || {}) } });
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// At the boundary between this child and the next:
|
|
767
|
+
// merge this child's "to" props with the next child's "from" props
|
|
768
|
+
if (idx < n - 1) {
|
|
769
|
+
const nextChild = children[idx + 1];
|
|
770
|
+
let nextKf;
|
|
771
|
+
if (nextChild.type === 'AnimateParallel') {
|
|
772
|
+
const merged = this._parallelToKeyframes(nextChild.children);
|
|
773
|
+
nextKf = { fromProps: this._parseCSSProps(merged.from), toProps: this._parseCSSProps(merged.to) };
|
|
774
|
+
} else {
|
|
775
|
+
nextKf = this._primitiveToKeyframes(nextChild);
|
|
776
|
+
}
|
|
777
|
+
stops.push({ percent: endPct, props: { ...(kf.toProps || {}), ...(nextKf.fromProps || {}) } });
|
|
778
|
+
} else {
|
|
779
|
+
// Last child: add to props at 100%
|
|
780
|
+
stops.push({ percent: endPct, props: { ...(kf.toProps || {}) } });
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
let css = `@keyframes ${name} { `;
|
|
785
|
+
for (const stop of stops) {
|
|
786
|
+
const propsCSS = Object.entries(stop.props).map(([k, v]) => `${k}: ${v};`).join(' ');
|
|
787
|
+
css += `${stop.percent}% { ${propsCSS} } `;
|
|
788
|
+
}
|
|
789
|
+
css += '} ';
|
|
790
|
+
|
|
791
|
+
return css;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Helper: parse "opacity: 0; transform: scale(0.8);" into { opacity: "0", transform: "scale(0.8)" }
|
|
795
|
+
_parseCSSProps(cssString) {
|
|
796
|
+
const props = {};
|
|
797
|
+
if (!cssString) return props;
|
|
798
|
+
const parts = cssString.split(';').filter(Boolean);
|
|
799
|
+
for (const part of parts) {
|
|
800
|
+
const colonIdx = part.indexOf(':');
|
|
801
|
+
if (colonIdx > 0) {
|
|
802
|
+
const key = part.slice(0, colonIdx).trim();
|
|
803
|
+
const val = part.slice(colonIdx + 1).trim();
|
|
804
|
+
props[key] = val;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return props;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Check if any JSX nodes in a tree use animate directives with stagger
|
|
811
|
+
_jsxTreeHasStagger(nodes) {
|
|
812
|
+
if (!this._currentAnimateDecls) return false;
|
|
813
|
+
for (const node of nodes) {
|
|
814
|
+
if (node.type === 'JSXElement') {
|
|
815
|
+
for (const attr of (node.attributes || [])) {
|
|
816
|
+
if (attr.name && attr.name.startsWith('animate:')) {
|
|
817
|
+
const animName = attr.name.slice(8);
|
|
818
|
+
const info = this._currentAnimateDecls[animName];
|
|
819
|
+
if (info && info.stagger) return true;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
if (node.children && this._jsxTreeHasStagger(node.children)) return true;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
_extractResponsive(css) {
|
|
829
|
+
// Find responsive { ... } block in raw CSS and extract it
|
|
830
|
+
const responsiveMatch = css.match(/responsive\s*\{/);
|
|
831
|
+
if (!responsiveMatch) return { baseCss: css, responsiveBlocks: [] };
|
|
832
|
+
|
|
833
|
+
const startIdx = responsiveMatch.index;
|
|
834
|
+
let i = startIdx + responsiveMatch[0].length;
|
|
835
|
+
let depth = 1;
|
|
836
|
+
|
|
837
|
+
while (i < css.length && depth > 0) {
|
|
838
|
+
if (css[i] === '{') depth++;
|
|
839
|
+
else if (css[i] === '}') depth--;
|
|
840
|
+
i++;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const responsiveContent = css.slice(startIdx + responsiveMatch[0].length, i - 1);
|
|
844
|
+
const baseCss = css.slice(0, startIdx) + css.slice(i);
|
|
845
|
+
|
|
846
|
+
// Parse breakpoint blocks: "tablet { .box { color: blue; } }"
|
|
847
|
+
const responsiveBlocks = [];
|
|
848
|
+
let pos = 0;
|
|
849
|
+
while (pos < responsiveContent.length) {
|
|
850
|
+
// Skip whitespace
|
|
851
|
+
while (pos < responsiveContent.length && /\s/.test(responsiveContent[pos])) pos++;
|
|
852
|
+
if (pos >= responsiveContent.length) break;
|
|
853
|
+
|
|
854
|
+
// Read breakpoint name
|
|
855
|
+
let name = '';
|
|
856
|
+
while (pos < responsiveContent.length && /\w/.test(responsiveContent[pos])) {
|
|
857
|
+
name += responsiveContent[pos];
|
|
858
|
+
pos++;
|
|
859
|
+
}
|
|
860
|
+
if (!name) break;
|
|
861
|
+
|
|
862
|
+
// Skip whitespace
|
|
863
|
+
while (pos < responsiveContent.length && /\s/.test(responsiveContent[pos])) pos++;
|
|
864
|
+
|
|
865
|
+
// Expect {
|
|
866
|
+
if (responsiveContent[pos] !== '{') break;
|
|
867
|
+
pos++; // skip {
|
|
868
|
+
|
|
869
|
+
// Collect content until matching }
|
|
870
|
+
let bpDepth = 1;
|
|
871
|
+
let bpCss = '';
|
|
872
|
+
while (pos < responsiveContent.length && bpDepth > 0) {
|
|
873
|
+
if (responsiveContent[pos] === '{') bpDepth++;
|
|
874
|
+
else if (responsiveContent[pos] === '}') {
|
|
875
|
+
bpDepth--;
|
|
876
|
+
if (bpDepth === 0) { pos++; break; }
|
|
877
|
+
}
|
|
878
|
+
bpCss += responsiveContent[pos];
|
|
879
|
+
pos++;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
responsiveBlocks.push({ name: name.trim(), css: bpCss.trim() });
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
return { baseCss, responsiveBlocks };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Extract variant(propName) { ... } blocks from raw CSS.
|
|
889
|
+
// Returns { baseCss, variants: [{propNames: [...], entries: [...]}] }
|
|
890
|
+
_extractVariants(css) {
|
|
891
|
+
const variants = [];
|
|
892
|
+
let baseCss = css;
|
|
893
|
+
|
|
894
|
+
// Repeatedly find and extract variant(...) { ... } blocks
|
|
895
|
+
const variantRe = /variant\s*\(([^)]+)\)\s*\{/g;
|
|
896
|
+
let match;
|
|
897
|
+
// Collect all matches first (with their positions in original css)
|
|
898
|
+
const matches = [];
|
|
899
|
+
while ((match = variantRe.exec(baseCss)) !== null) {
|
|
900
|
+
matches.push({ index: match.index, fullMatch: match[0], propStr: match[1].trim() });
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Process in reverse order to maintain correct indices when removing
|
|
904
|
+
for (let m = matches.length - 1; m >= 0; m--) {
|
|
905
|
+
const { index: startIdx, fullMatch, propStr } = matches[m];
|
|
906
|
+
// Parse prop names (single or compound with +)
|
|
907
|
+
const propNames = propStr.split(/\s*\+\s*/).map(s => s.trim()).filter(Boolean);
|
|
908
|
+
|
|
909
|
+
// Find matching closing brace using brace counting
|
|
910
|
+
let i = startIdx + fullMatch.length;
|
|
911
|
+
let depth = 1;
|
|
912
|
+
while (i < baseCss.length && depth > 0) {
|
|
913
|
+
if (baseCss[i] === '{') depth++;
|
|
914
|
+
else if (baseCss[i] === '}') depth--;
|
|
915
|
+
i++;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const content = baseCss.slice(startIdx + fullMatch.length, i - 1);
|
|
919
|
+
const entries = this._parseVariantEntries(content, propNames);
|
|
920
|
+
variants.unshift({ propNames, entries });
|
|
921
|
+
|
|
922
|
+
// Remove the variant block from baseCss
|
|
923
|
+
baseCss = baseCss.slice(0, startIdx) + baseCss.slice(i);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
return { baseCss, variants };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Parse entries inside a variant block content string.
|
|
930
|
+
// Handles: "primary { ... }", "primary:hover { ... }", "primary + lg { ... }"
|
|
931
|
+
_parseVariantEntries(content, propNames) {
|
|
932
|
+
const entries = [];
|
|
933
|
+
let pos = 0;
|
|
934
|
+
|
|
935
|
+
while (pos < content.length) {
|
|
936
|
+
// Skip whitespace
|
|
937
|
+
while (pos < content.length && /\s/.test(content[pos])) pos++;
|
|
938
|
+
if (pos >= content.length) break;
|
|
939
|
+
|
|
940
|
+
// Read entry name (may include pseudo like "primary:hover" or compound "primary + lg")
|
|
941
|
+
let nameStr = '';
|
|
942
|
+
// Read until we hit an opening brace
|
|
943
|
+
while (pos < content.length && content[pos] !== '{') {
|
|
944
|
+
nameStr += content[pos];
|
|
945
|
+
pos++;
|
|
946
|
+
}
|
|
947
|
+
nameStr = nameStr.trim();
|
|
948
|
+
if (!nameStr || pos >= content.length) break;
|
|
949
|
+
|
|
950
|
+
// Skip {
|
|
951
|
+
pos++;
|
|
952
|
+
|
|
953
|
+
// Collect CSS content until matching }
|
|
954
|
+
let entryDepth = 1;
|
|
955
|
+
let entryCss = '';
|
|
956
|
+
while (pos < content.length && entryDepth > 0) {
|
|
957
|
+
if (content[pos] === '{') entryDepth++;
|
|
958
|
+
else if (content[pos] === '}') {
|
|
959
|
+
entryDepth--;
|
|
960
|
+
if (entryDepth === 0) { pos++; break; }
|
|
961
|
+
}
|
|
962
|
+
entryCss += content[pos];
|
|
963
|
+
pos++;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Parse the name to extract values and pseudo-selector
|
|
967
|
+
// Compound: "primary + lg" -> values: ['primary', 'lg'], pseudo: null
|
|
968
|
+
// Pseudo: "primary:hover" -> values: ['primary'], pseudo: ':hover'
|
|
969
|
+
// Compound+pseudo: "primary + lg:hover" -> values: ['primary', 'lg'], pseudo: ':hover'
|
|
970
|
+
if (propNames.length > 1) {
|
|
971
|
+
// Compound variant -- split by +
|
|
972
|
+
const parts = nameStr.split(/\s*\+\s*/);
|
|
973
|
+
const values = [];
|
|
974
|
+
let pseudo = null;
|
|
975
|
+
for (let pi = 0; pi < parts.length; pi++) {
|
|
976
|
+
let part = parts[pi].trim();
|
|
977
|
+
// Check for pseudo on the last part
|
|
978
|
+
const colonIdx = part.indexOf(':');
|
|
979
|
+
if (colonIdx > 0) {
|
|
980
|
+
pseudo = part.slice(colonIdx);
|
|
981
|
+
part = part.slice(0, colonIdx);
|
|
982
|
+
}
|
|
983
|
+
values.push(part);
|
|
984
|
+
}
|
|
985
|
+
entries.push({ values, pseudo, css: entryCss.trim() });
|
|
986
|
+
} else {
|
|
987
|
+
// Single prop -- check for pseudo
|
|
988
|
+
let pseudo = null;
|
|
989
|
+
let valueName = nameStr;
|
|
990
|
+
const colonIdx = nameStr.indexOf(':');
|
|
991
|
+
if (colonIdx > 0) {
|
|
992
|
+
pseudo = nameStr.slice(colonIdx);
|
|
993
|
+
valueName = nameStr.slice(0, colonIdx);
|
|
994
|
+
}
|
|
995
|
+
entries.push({ values: [valueName], pseudo, css: entryCss.trim() });
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
return entries;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// Generate scoped CSS for variant entries.
|
|
1003
|
+
// Single prop: .btn--propName-value[scopeAttr] { css }
|
|
1004
|
+
// Compound: .btn--prop1-value1.btn--prop2-value2[scopeAttr] { css }
|
|
1005
|
+
// Pseudo: .btn--propName-value[scopeAttr]:pseudo { css }
|
|
1006
|
+
_generateVariantCSS(variants, baseClass, scopeAttr) {
|
|
1007
|
+
const parts = [];
|
|
1008
|
+
for (const variant of variants) {
|
|
1009
|
+
const { propNames, entries } = variant;
|
|
1010
|
+
for (const entry of entries) {
|
|
1011
|
+
const { values, pseudo, css } = entry;
|
|
1012
|
+
let selector;
|
|
1013
|
+
if (propNames.length === 1) {
|
|
1014
|
+
// Single prop variant
|
|
1015
|
+
selector = `.${baseClass}--${propNames[0]}-${values[0]}${scopeAttr}`;
|
|
1016
|
+
} else {
|
|
1017
|
+
// Compound variant -- chain class selectors
|
|
1018
|
+
const classParts = propNames.map((prop, i) =>
|
|
1019
|
+
`.${baseClass}--${prop}-${values[i]}`
|
|
1020
|
+
);
|
|
1021
|
+
selector = classParts.join('') + scopeAttr;
|
|
1022
|
+
}
|
|
1023
|
+
if (pseudo) {
|
|
1024
|
+
selector += pseudo;
|
|
1025
|
+
}
|
|
1026
|
+
parts.push(`${selector} { ${css} }`);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
return parts.join(' ');
|
|
1030
|
+
}
|
|
1031
|
+
|
|
555
1032
|
generateComponent(comp) {
|
|
556
1033
|
const hasParams = comp.params.length > 0;
|
|
557
1034
|
const paramStr = hasParams ? '__props' : '';
|
|
@@ -560,28 +1037,40 @@ export class BrowserCodegen extends BaseCodegen {
|
|
|
560
1037
|
const savedState = new Set(this.stateNames);
|
|
561
1038
|
const savedComputed = new Set(this.computedNames);
|
|
562
1039
|
|
|
1040
|
+
// For compound components (e.g. Dialog.Title), use concatenated name (DialogTitle) as function name
|
|
1041
|
+
const funcName = comp.child ? (comp.parent + comp.child) : comp.name;
|
|
1042
|
+
|
|
563
1043
|
const p = [];
|
|
564
|
-
p.push(`function ${
|
|
1044
|
+
p.push(`function ${funcName}(${paramStr}) {\n`);
|
|
565
1045
|
this.indent++;
|
|
566
1046
|
|
|
567
1047
|
// Generate reactive prop accessors — each prop is accessed through __props getter
|
|
568
1048
|
// This ensures parent signal changes propagate reactively to the child
|
|
1049
|
+
const savedRenames = new Map(this._paramRenames);
|
|
569
1050
|
if (hasParams) {
|
|
570
1051
|
for (const param of comp.params) {
|
|
571
|
-
|
|
1052
|
+
const propName = param.name;
|
|
1053
|
+
const safeName = JS_RESERVED.has(propName) ? `_${propName}` : propName;
|
|
1054
|
+
if (safeName !== propName) {
|
|
1055
|
+
this._paramRenames.set(propName, safeName);
|
|
1056
|
+
}
|
|
1057
|
+
this.computedNames.add(propName);
|
|
572
1058
|
const def = param.default || param.defaultValue;
|
|
1059
|
+
const propAccess = safeName !== propName ? `__props["${propName}"]` : `__props.${propName}`;
|
|
573
1060
|
if (def) {
|
|
574
1061
|
const defaultExpr = this.genExpression(def);
|
|
575
|
-
p.push(`${this.i()}const ${
|
|
1062
|
+
p.push(`${this.i()}const ${safeName} = () => ${propAccess} !== undefined ? ${propAccess} : ${defaultExpr};\n`);
|
|
576
1063
|
} else {
|
|
577
|
-
p.push(`${this.i()}const ${
|
|
1064
|
+
p.push(`${this.i()}const ${safeName} = () => ${propAccess};\n`);
|
|
578
1065
|
}
|
|
579
1066
|
}
|
|
580
1067
|
}
|
|
581
1068
|
|
|
582
|
-
// Separate JSX elements, style blocks, and statements
|
|
1069
|
+
// Separate JSX elements, style blocks, animate declarations, font declarations, and statements
|
|
583
1070
|
const jsxElements = [];
|
|
584
1071
|
const styleBlocks = [];
|
|
1072
|
+
const animateDecls = [];
|
|
1073
|
+
const fontDecls = [];
|
|
585
1074
|
const bodyItems = [];
|
|
586
1075
|
|
|
587
1076
|
for (const node of comp.body) {
|
|
@@ -589,6 +1078,10 @@ export class BrowserCodegen extends BaseCodegen {
|
|
|
589
1078
|
jsxElements.push(node);
|
|
590
1079
|
} else if (node.type === 'ComponentStyleBlock') {
|
|
591
1080
|
styleBlocks.push(node);
|
|
1081
|
+
} else if (node.type === 'AnimateDeclaration') {
|
|
1082
|
+
animateDecls.push(node);
|
|
1083
|
+
} else if (node.type === 'FontDeclaration') {
|
|
1084
|
+
fontDecls.push(node);
|
|
592
1085
|
} else {
|
|
593
1086
|
bodyItems.push(node);
|
|
594
1087
|
}
|
|
@@ -596,14 +1089,105 @@ export class BrowserCodegen extends BaseCodegen {
|
|
|
596
1089
|
|
|
597
1090
|
// Set up scoped CSS if style blocks exist
|
|
598
1091
|
const savedScopeId = this._currentScopeId;
|
|
1092
|
+
const savedVariants = this._currentVariants;
|
|
1093
|
+
this._currentVariants = null;
|
|
599
1094
|
if (styleBlocks.length > 0) {
|
|
600
1095
|
const rawCSS = styleBlocks.map(s => s.css).join('\n');
|
|
601
|
-
const
|
|
1096
|
+
const resolvedCSS = this._resolveTokens(rawCSS);
|
|
1097
|
+
const scopeId = this._genScopeId(comp.name, rawCSS); // Use rawCSS for hash stability
|
|
602
1098
|
this._currentScopeId = scopeId;
|
|
603
|
-
const
|
|
1099
|
+
const scopeAttr = `[data-tova-${scopeId}]`;
|
|
1100
|
+
|
|
1101
|
+
// Extract variant blocks before responsive and scoping
|
|
1102
|
+
const { baseCss: variantBaseCss, variants } = this._extractVariants(resolvedCSS);
|
|
1103
|
+
if (variants.length > 0) {
|
|
1104
|
+
// Detect baseClass from the first CSS class selector in the base CSS
|
|
1105
|
+
const classMatch = variantBaseCss.match(/\.([a-zA-Z_][\w-]*)\s*\{/);
|
|
1106
|
+
const baseClass = classMatch ? classMatch[1] : comp.name.toLowerCase();
|
|
1107
|
+
this._currentVariants = { variants, baseClass };
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// Extract responsive blocks before scoping
|
|
1111
|
+
const { baseCss, responsiveBlocks } = this._extractResponsive(variantBaseCss);
|
|
1112
|
+
let scopedCSS = this._scopeCSS(baseCss, scopeAttr);
|
|
1113
|
+
|
|
1114
|
+
// Append variant CSS (already scoped via _generateVariantCSS)
|
|
1115
|
+
if (variants.length > 0) {
|
|
1116
|
+
const baseClass = this._currentVariants.baseClass;
|
|
1117
|
+
const variantCSS = this._generateVariantCSS(variants, baseClass, scopeAttr);
|
|
1118
|
+
if (variantCSS) {
|
|
1119
|
+
scopedCSS += ' ' + variantCSS;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Append responsive media queries with scoped selectors
|
|
1124
|
+
if (responsiveBlocks.length > 0) {
|
|
1125
|
+
const breakpoints = this._getBreakpoints();
|
|
1126
|
+
const sorted = [...responsiveBlocks].sort((a, b) => (breakpoints[a.name] || 0) - (breakpoints[b.name] || 0));
|
|
1127
|
+
for (const bp of sorted) {
|
|
1128
|
+
const bpValue = breakpoints[bp.name] !== undefined ? breakpoints[bp.name] : 0;
|
|
1129
|
+
const scopedBpCSS = this._scopeCSS(bp.css, scopeAttr);
|
|
1130
|
+
if (bpValue === 0) {
|
|
1131
|
+
scopedCSS += ' ' + scopedBpCSS;
|
|
1132
|
+
} else {
|
|
1133
|
+
scopedCSS += ` @media (min-width: ${bpValue}px) { ${scopedBpCSS} }`;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// Auto-inject prefers-reduced-motion unless opted out with style(motion: full)
|
|
1139
|
+
const motionFull = styleBlocks.some(s => s.config && s.config.motion === 'full');
|
|
1140
|
+
if (!motionFull) {
|
|
1141
|
+
scopedCSS = this._generateReducedMotion(scopedCSS, scopeAttr);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
604
1144
|
p.push(`${this.i()}tova_inject_css(${JSON.stringify(scopeId)}, ${JSON.stringify(scopedCSS)});\n`);
|
|
605
1145
|
}
|
|
606
1146
|
|
|
1147
|
+
// Generate @keyframes CSS from animate declarations
|
|
1148
|
+
const savedAnimateDecls = this._currentAnimateDecls;
|
|
1149
|
+
this._currentAnimateDecls = null;
|
|
1150
|
+
if (animateDecls.length > 0) {
|
|
1151
|
+
// Ensure a scope ID exists for keyframe name uniqueness
|
|
1152
|
+
if (!this._currentScopeId) {
|
|
1153
|
+
this._currentScopeId = this._genScopeId(comp.name, 'animate');
|
|
1154
|
+
}
|
|
1155
|
+
const scopeId = this._currentScopeId;
|
|
1156
|
+
const animateMap = {};
|
|
1157
|
+
let keyframeCSS = '';
|
|
1158
|
+
for (const animDecl of animateDecls) {
|
|
1159
|
+
const result = this._generateAnimateKeyframes(animDecl, scopeId);
|
|
1160
|
+
keyframeCSS += result.css;
|
|
1161
|
+
animateMap[animDecl.name] = result;
|
|
1162
|
+
}
|
|
1163
|
+
this._currentAnimateDecls = animateMap;
|
|
1164
|
+
if (keyframeCSS) {
|
|
1165
|
+
const animScopeId = `${scopeId}_anim`;
|
|
1166
|
+
p.push(`${this.i()}tova_inject_css(${JSON.stringify(animScopeId)}, ${JSON.stringify(keyframeCSS)});\n`);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// Generate font loading calls
|
|
1171
|
+
if (fontDecls.length > 0) {
|
|
1172
|
+
for (const fontDecl of fontDecls) {
|
|
1173
|
+
const isRemote = fontDecl.source.startsWith('http') || fontDecl.source.startsWith('//');
|
|
1174
|
+
if (isRemote) {
|
|
1175
|
+
// Remote font: use __tova_load_font runtime function
|
|
1176
|
+
p.push(`${this.i()}__tova_load_font(${JSON.stringify(fontDecl.name)}, ${JSON.stringify(fontDecl.source)});\n`);
|
|
1177
|
+
} else {
|
|
1178
|
+
// Local font: generate @font-face CSS and inject via tova_inject_css
|
|
1179
|
+
const config = fontDecl.config || {};
|
|
1180
|
+
const display = config.display || 'swap';
|
|
1181
|
+
let fontFaceCSS = `@font-face { font-family: "${fontDecl.name}"; src: url("${fontDecl.source}");`;
|
|
1182
|
+
if (config.weight) fontFaceCSS += ` font-weight: ${config.weight};`;
|
|
1183
|
+
if (config.style) fontFaceCSS += ` font-style: ${config.style};`;
|
|
1184
|
+
fontFaceCSS += ` font-display: ${display}; }`;
|
|
1185
|
+
const fontScopeId = `__tova_font_${fontDecl.name}`;
|
|
1186
|
+
p.push(`${this.i()}tova_inject_css(${JSON.stringify(fontScopeId)}, ${JSON.stringify(fontFaceCSS)});\n`);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
607
1191
|
// Generate body items in order (state, computed, effect, other statements)
|
|
608
1192
|
for (const node of bodyItems) {
|
|
609
1193
|
if (node.type === 'StateDeclaration') {
|
|
@@ -641,10 +1225,13 @@ export class BrowserCodegen extends BaseCodegen {
|
|
|
641
1225
|
this.indent--;
|
|
642
1226
|
p.push(`}`);
|
|
643
1227
|
|
|
644
|
-
// Restore scoped names and
|
|
1228
|
+
// Restore scoped names, scope id, variants, animate decls, and param renames
|
|
645
1229
|
this.stateNames = savedState;
|
|
646
1230
|
this.computedNames = savedComputed;
|
|
1231
|
+
this._paramRenames = savedRenames;
|
|
647
1232
|
this._currentScopeId = savedScopeId;
|
|
1233
|
+
this._currentVariants = savedVariants;
|
|
1234
|
+
this._currentAnimateDecls = savedAnimateDecls;
|
|
648
1235
|
|
|
649
1236
|
return p.join('');
|
|
650
1237
|
}
|
|
@@ -1223,6 +1810,13 @@ export class BrowserCodegen extends BaseCodegen {
|
|
|
1223
1810
|
// Conditional class: class:active={cond}
|
|
1224
1811
|
const className = attr.name.slice(6);
|
|
1225
1812
|
classDirectives.push({ className, condition: this.genExpression(attr.value), node: attr.value });
|
|
1813
|
+
} else if (attr.name.startsWith('animate:')) {
|
|
1814
|
+
// animate:fadeIn or animate:fadeIn={visible} — CSS animation directive
|
|
1815
|
+
const animName = attr.name.slice(8);
|
|
1816
|
+
const isConditional = attr.value.type !== 'BooleanLiteral';
|
|
1817
|
+
const condExpr = isConditional ? this.genExpression(attr.value) : null;
|
|
1818
|
+
if (!node._animateDirectives) node._animateDirectives = [];
|
|
1819
|
+
node._animateDirectives.push({ name: animName, conditional: isConditional, condExpr });
|
|
1226
1820
|
} else if (attr.name.startsWith('use:')) {
|
|
1227
1821
|
// use:action directive: use:tooltip={params}
|
|
1228
1822
|
const actionName = attr.name.slice(4);
|
|
@@ -1321,6 +1915,44 @@ export class BrowserCodegen extends BaseCodegen {
|
|
|
1321
1915
|
attrs.className = isReactive ? `() => ${classExpr}` : classExpr;
|
|
1322
1916
|
}
|
|
1323
1917
|
|
|
1918
|
+
// Inject variant classes into className when component has variant() styles
|
|
1919
|
+
if (this._currentVariants && attrs.className && !isComponent) {
|
|
1920
|
+
const { variants, baseClass } = this._currentVariants;
|
|
1921
|
+
// Build variant class suffix expressions for each variant prop
|
|
1922
|
+
const variantParts = [];
|
|
1923
|
+
for (const variant of variants) {
|
|
1924
|
+
if (variant.propNames.length === 1) {
|
|
1925
|
+
const prop = variant.propNames[0];
|
|
1926
|
+
variantParts.push(`(${prop}() ? " ${baseClass}--${prop}-" + ${prop}() : "")`);
|
|
1927
|
+
} else {
|
|
1928
|
+
// Compound variants use multiple props -- add all prop classes
|
|
1929
|
+
for (const prop of variant.propNames) {
|
|
1930
|
+
variantParts.push(`(${prop}() ? " ${baseClass}--${prop}-" + ${prop}() : "")`);
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
// Deduplicate prop-based parts (same prop may appear in multiple variant blocks)
|
|
1935
|
+
const seen = new Set();
|
|
1936
|
+
const uniqueParts = [];
|
|
1937
|
+
for (const part of variantParts) {
|
|
1938
|
+
if (!seen.has(part)) {
|
|
1939
|
+
seen.add(part);
|
|
1940
|
+
uniqueParts.push(part);
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
if (uniqueParts.length > 0) {
|
|
1944
|
+
const currentClass = attrs.className;
|
|
1945
|
+
// If already reactive (starts with () =>), unwrap
|
|
1946
|
+
let baseExpr;
|
|
1947
|
+
if (typeof currentClass === 'string' && currentClass.startsWith('() => ')) {
|
|
1948
|
+
baseExpr = currentClass.slice(6);
|
|
1949
|
+
} else {
|
|
1950
|
+
baseExpr = currentClass;
|
|
1951
|
+
}
|
|
1952
|
+
attrs.className = `() => ${baseExpr} + ${uniqueParts.join(' + ')}`;
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1324
1956
|
// Merge show directive with style (show toggles display:none)
|
|
1325
1957
|
if (node._showDirective) {
|
|
1326
1958
|
const { expr: displayExpr, reactive } = node._showDirective;
|
|
@@ -1344,8 +1976,77 @@ export class BrowserCodegen extends BaseCodegen {
|
|
|
1344
1976
|
attrs[`"data-tova-${this._currentScopeId}"`] = '""';
|
|
1345
1977
|
}
|
|
1346
1978
|
|
|
1979
|
+
// Apply animate: directives — add CSS animation property
|
|
1980
|
+
if (node._animateDirectives && node._animateDirectives.length > 0 && this._currentAnimateDecls) {
|
|
1981
|
+
const animParts = [];
|
|
1982
|
+
let hasStagger = false;
|
|
1983
|
+
let staggerValue = 0;
|
|
1984
|
+
|
|
1985
|
+
for (const dir of node._animateDirectives) {
|
|
1986
|
+
const animInfo = this._currentAnimateDecls[dir.name];
|
|
1987
|
+
if (!animInfo) continue;
|
|
1988
|
+
|
|
1989
|
+
const animStr = `${animInfo.enterName} ${animInfo.duration}ms ${animInfo.easing} both`;
|
|
1990
|
+
|
|
1991
|
+
if (dir.conditional) {
|
|
1992
|
+
// Conditional: only apply when expression is truthy
|
|
1993
|
+
animParts.push({ str: animStr, conditional: true, condExpr: dir.condExpr });
|
|
1994
|
+
} else {
|
|
1995
|
+
animParts.push({ str: animStr, conditional: false });
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
if (animInfo.stagger) {
|
|
1999
|
+
hasStagger = true;
|
|
2000
|
+
staggerValue = animInfo.stagger;
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
if (animParts.length > 0) {
|
|
2005
|
+
// Build animation style
|
|
2006
|
+
const allUnconditional = animParts.every(a => !a.conditional);
|
|
2007
|
+
if (allUnconditional) {
|
|
2008
|
+
const animValue = animParts.map(a => a.str).join(', ');
|
|
2009
|
+
// Merge with existing style
|
|
2010
|
+
if (attrs.style) {
|
|
2011
|
+
const existing = attrs.style;
|
|
2012
|
+
attrs.style = `Object.assign({}, ${existing}, { animation: "${animValue}" })`;
|
|
2013
|
+
} else {
|
|
2014
|
+
attrs.style = `{ animation: "${animValue}" }`;
|
|
2015
|
+
}
|
|
2016
|
+
} else {
|
|
2017
|
+
// Conditional animations — use reactive style
|
|
2018
|
+
const parts = animParts.map(a =>
|
|
2019
|
+
a.conditional ? `(${a.condExpr} ? "${a.str}" : "")` : `"${a.str}"`
|
|
2020
|
+
);
|
|
2021
|
+
const animExpr = `[${parts.join(', ')}].filter(Boolean).join(", ")`;
|
|
2022
|
+
if (attrs.style) {
|
|
2023
|
+
const existing = attrs.style;
|
|
2024
|
+
attrs.style = `() => Object.assign({}, ${existing}, { animation: ${animExpr} })`;
|
|
2025
|
+
} else {
|
|
2026
|
+
attrs.style = `() => ({ animation: ${animExpr} })`;
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
if (hasStagger) {
|
|
2031
|
+
// Add animationDelay using a data attribute + parent-index pattern
|
|
2032
|
+
// For stagger, inject a computed delay based on sibling index
|
|
2033
|
+
if (attrs.style && typeof attrs.style === 'string' && attrs.style.startsWith('() =>')) {
|
|
2034
|
+
const inner = attrs.style.slice(6);
|
|
2035
|
+
attrs.style = `() => Object.assign({}, ${inner}, { animationDelay: (__tova_idx * ${staggerValue}) + "ms" })`;
|
|
2036
|
+
} else if (attrs.style) {
|
|
2037
|
+
attrs.style = `Object.assign({}, ${attrs.style}, { animationDelay: (__tova_idx * ${staggerValue}) + "ms" })`;
|
|
2038
|
+
} else {
|
|
2039
|
+
attrs.style = `{ animationDelay: (__tova_idx * ${staggerValue}) + "ms" }`;
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
|
|
1347
2045
|
const propParts = [];
|
|
1348
2046
|
const memoizedProps = []; // Computed memoization for complex expressions
|
|
2047
|
+
// Helper: quote property keys that contain hyphens (aria-*, data-*, stroke-*, etc.)
|
|
2048
|
+
const _needsQuote = (k) => k.includes('-') && !k.startsWith('"');
|
|
2049
|
+
const _propKey = (k) => _needsQuote(k) ? `"${k}"` : k;
|
|
1349
2050
|
for (const [key, val] of Object.entries(attrs)) {
|
|
1350
2051
|
// For component props, convert reactive () => wrappers to JS getter syntax
|
|
1351
2052
|
// so the prop stays reactive through the __props access pattern
|
|
@@ -1355,14 +2056,14 @@ export class BrowserCodegen extends BaseCodegen {
|
|
|
1355
2056
|
// Complex expressions: memoize with createComputed
|
|
1356
2057
|
const isSimple = /^[a-zA-Z_$]\w*\(\)$/.test(rawExpr);
|
|
1357
2058
|
if (isSimple) {
|
|
1358
|
-
propParts.push(`get ${key}() { return ${rawExpr}; }`);
|
|
2059
|
+
propParts.push(`get ${_propKey(key)}() { return ${rawExpr}; }`);
|
|
1359
2060
|
} else {
|
|
1360
|
-
const memoName = `__memo_${key}`;
|
|
2061
|
+
const memoName = `__memo_${key.replace(/-/g, '_')}`;
|
|
1361
2062
|
memoizedProps.push(`const ${memoName} = createComputed(() => ${rawExpr})`);
|
|
1362
|
-
propParts.push(`get ${key}() { return ${memoName}(); }`);
|
|
2063
|
+
propParts.push(`get ${_propKey(key)}() { return ${memoName}(); }`);
|
|
1363
2064
|
}
|
|
1364
2065
|
} else {
|
|
1365
|
-
propParts.push(`${key}: ${val}`);
|
|
2066
|
+
propParts.push(`${_propKey(key)}: ${val}`);
|
|
1366
2067
|
}
|
|
1367
2068
|
}
|
|
1368
2069
|
for (const [event, handler] of Object.entries(events)) {
|
|
@@ -1591,18 +2292,22 @@ export class BrowserCodegen extends BaseCodegen {
|
|
|
1591
2292
|
const needsReactive = this._exprReadsSignal(node.iterable);
|
|
1592
2293
|
const wrap = needsReactive ? '() => ' : '';
|
|
1593
2294
|
|
|
2295
|
+
// Include __tova_idx in map callback when stagger animations are used on child elements
|
|
2296
|
+
const needsIdx = this._jsxTreeHasStagger(node.body);
|
|
2297
|
+
const idxParam = needsIdx ? ', __tova_idx' : '';
|
|
2298
|
+
|
|
1594
2299
|
if (node.keyExpr) {
|
|
1595
2300
|
const keyExpr = this.genExpression(node.keyExpr);
|
|
1596
2301
|
if (children.length === 1) {
|
|
1597
|
-
return `${wrap}${iterable}.map((${varName}) => tova_keyed(${keyExpr}, ${children[0]}))`;
|
|
2302
|
+
return `${wrap}${iterable}.map((${varName}${idxParam}) => tova_keyed(${keyExpr}, ${children[0]}))`;
|
|
1598
2303
|
}
|
|
1599
|
-
return `${wrap}${iterable}.map((${varName}) => tova_keyed(${keyExpr}, tova_fragment([${children.join(', ')}])))`;
|
|
2304
|
+
return `${wrap}${iterable}.map((${varName}${idxParam}) => tova_keyed(${keyExpr}, tova_fragment([${children.join(', ')}])))`;
|
|
1600
2305
|
}
|
|
1601
2306
|
|
|
1602
2307
|
if (children.length === 1) {
|
|
1603
|
-
return `${wrap}${iterable}.map((${varName}) => ${children[0]})`;
|
|
2308
|
+
return `${wrap}${iterable}.map((${varName}${idxParam}) => ${children[0]})`;
|
|
1604
2309
|
}
|
|
1605
|
-
return `${wrap}${iterable}.map((${varName}) => tova_fragment([${children.join(', ')}]))`;
|
|
2310
|
+
return `${wrap}${iterable}.map((${varName}${idxParam}) => tova_fragment([${children.join(', ')}]))`;
|
|
1606
2311
|
}
|
|
1607
2312
|
|
|
1608
2313
|
genJSXIf(node) {
|