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.
Files changed (59) hide show
  1. package/bin/tova.js +1312 -139
  2. package/package.json +8 -1
  3. package/src/analyzer/analyzer.js +539 -11
  4. package/src/analyzer/browser-analyzer.js +56 -8
  5. package/src/analyzer/deploy-analyzer.js +44 -0
  6. package/src/analyzer/scope.js +7 -0
  7. package/src/analyzer/server-analyzer.js +33 -1
  8. package/src/codegen/base-codegen.js +1296 -23
  9. package/src/codegen/browser-codegen.js +725 -20
  10. package/src/codegen/codegen.js +87 -5
  11. package/src/codegen/deploy-codegen.js +49 -0
  12. package/src/codegen/server-codegen.js +54 -6
  13. package/src/codegen/shared-codegen.js +5 -0
  14. package/src/codegen/theme-codegen.js +69 -0
  15. package/src/codegen/wasm-codegen.js +6 -0
  16. package/src/config/edit-toml.js +6 -2
  17. package/src/config/git-resolver.js +128 -0
  18. package/src/config/lock-file.js +57 -0
  19. package/src/config/module-cache.js +58 -0
  20. package/src/config/module-entry.js +37 -0
  21. package/src/config/module-path.js +63 -0
  22. package/src/config/pkg-errors.js +62 -0
  23. package/src/config/resolve.js +26 -0
  24. package/src/config/resolver.js +139 -0
  25. package/src/config/search.js +28 -0
  26. package/src/config/semver.js +72 -0
  27. package/src/config/toml.js +61 -6
  28. package/src/deploy/deploy.js +217 -0
  29. package/src/deploy/infer.js +218 -0
  30. package/src/deploy/provision.js +315 -0
  31. package/src/diagnostics/security-scorecard.js +111 -0
  32. package/src/lexer/lexer.js +18 -3
  33. package/src/lsp/server.js +482 -0
  34. package/src/parser/animate-ast.js +45 -0
  35. package/src/parser/ast.js +39 -0
  36. package/src/parser/browser-ast.js +19 -1
  37. package/src/parser/browser-parser.js +221 -4
  38. package/src/parser/concurrency-ast.js +15 -0
  39. package/src/parser/concurrency-parser.js +236 -0
  40. package/src/parser/deploy-ast.js +37 -0
  41. package/src/parser/deploy-parser.js +132 -0
  42. package/src/parser/parser.js +42 -5
  43. package/src/parser/select-ast.js +39 -0
  44. package/src/parser/theme-ast.js +29 -0
  45. package/src/parser/theme-parser.js +70 -0
  46. package/src/registry/plugins/concurrency-plugin.js +32 -0
  47. package/src/registry/plugins/deploy-plugin.js +33 -0
  48. package/src/registry/plugins/theme-plugin.js +20 -0
  49. package/src/registry/register-all.js +6 -0
  50. package/src/runtime/charts.js +547 -0
  51. package/src/runtime/embedded.js +6 -2
  52. package/src/runtime/reactivity.js +60 -0
  53. package/src/runtime/router.js +703 -295
  54. package/src/runtime/table.js +606 -33
  55. package/src/stdlib/inline.js +365 -10
  56. package/src/stdlib/runtime-bridge.js +152 -0
  57. package/src/stdlib/string.js +84 -2
  58. package/src/stdlib/validation.js +1 -1
  59. 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
- return `${node.name}()`;
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 ${comp.name}(${paramStr}) {\n`);
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
- this.computedNames.add(param.name);
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 ${param.name} = () => __props.${param.name} !== undefined ? __props.${param.name} : ${defaultExpr};\n`);
1062
+ p.push(`${this.i()}const ${safeName} = () => ${propAccess} !== undefined ? ${propAccess} : ${defaultExpr};\n`);
576
1063
  } else {
577
- p.push(`${this.i()}const ${param.name} = () => __props.${param.name};\n`);
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 scopeId = this._genScopeId(comp.name, rawCSS);
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 scopedCSS = this._scopeCSS(rawCSS, `[data-tova-${scopeId}]`);
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 scope id
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) {