tova 0.5.1 → 0.8.2

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 (60) hide show
  1. package/bin/tova.js +261 -60
  2. package/package.json +1 -1
  3. package/src/analyzer/analyzer.js +351 -11
  4. package/src/analyzer/{client-analyzer.js → browser-analyzer.js} +20 -17
  5. package/src/analyzer/deploy-analyzer.js +44 -0
  6. package/src/analyzer/form-analyzer.js +113 -0
  7. package/src/analyzer/scope.js +2 -2
  8. package/src/codegen/base-codegen.js +1160 -10
  9. package/src/codegen/{client-codegen.js → browser-codegen.js} +444 -5
  10. package/src/codegen/codegen.js +119 -28
  11. package/src/codegen/deploy-codegen.js +49 -0
  12. package/src/codegen/edge-codegen.js +1351 -0
  13. package/src/codegen/form-codegen.js +553 -0
  14. package/src/codegen/security-codegen.js +5 -5
  15. package/src/codegen/server-codegen.js +88 -7
  16. package/src/codegen/shared-codegen.js +5 -0
  17. package/src/codegen/wasm-codegen.js +6 -0
  18. package/src/config/edit-toml.js +6 -2
  19. package/src/config/git-resolver.js +128 -0
  20. package/src/config/lock-file.js +57 -0
  21. package/src/config/module-cache.js +58 -0
  22. package/src/config/module-entry.js +37 -0
  23. package/src/config/module-path.js +31 -0
  24. package/src/config/pkg-errors.js +62 -0
  25. package/src/config/resolve.js +17 -0
  26. package/src/config/resolver.js +139 -0
  27. package/src/config/search.js +28 -0
  28. package/src/config/semver.js +72 -0
  29. package/src/config/toml.js +48 -5
  30. package/src/deploy/deploy.js +217 -0
  31. package/src/deploy/infer.js +218 -0
  32. package/src/deploy/provision.js +311 -0
  33. package/src/diagnostics/error-codes.js +1 -1
  34. package/src/docs/generator.js +1 -1
  35. package/src/formatter/formatter.js +4 -4
  36. package/src/lexer/tokens.js +12 -2
  37. package/src/lsp/server.js +483 -1
  38. package/src/parser/ast.js +60 -5
  39. package/src/parser/{client-ast.js → browser-ast.js} +3 -3
  40. package/src/parser/{client-parser.js → browser-parser.js} +42 -15
  41. package/src/parser/concurrency-ast.js +15 -0
  42. package/src/parser/concurrency-parser.js +236 -0
  43. package/src/parser/deploy-ast.js +37 -0
  44. package/src/parser/deploy-parser.js +132 -0
  45. package/src/parser/edge-ast.js +83 -0
  46. package/src/parser/edge-parser.js +262 -0
  47. package/src/parser/form-ast.js +80 -0
  48. package/src/parser/form-parser.js +206 -0
  49. package/src/parser/parser.js +82 -14
  50. package/src/parser/select-ast.js +39 -0
  51. package/src/registry/plugins/browser-plugin.js +30 -0
  52. package/src/registry/plugins/concurrency-plugin.js +32 -0
  53. package/src/registry/plugins/deploy-plugin.js +33 -0
  54. package/src/registry/plugins/edge-plugin.js +32 -0
  55. package/src/registry/register-all.js +8 -2
  56. package/src/runtime/ssr.js +2 -2
  57. package/src/stdlib/inline.js +38 -6
  58. package/src/stdlib/runtime-bridge.js +152 -0
  59. package/src/version.js +1 -1
  60. package/src/registry/plugins/client-plugin.js +0 -30
@@ -1,14 +1,16 @@
1
1
  import { BaseCodegen } from './base-codegen.js';
2
- import { getClientStdlib, buildSelectiveStdlib, RESULT_OPTION, PROPAGATE } from '../stdlib/inline.js';
2
+ import { getBrowserStdlib, buildSelectiveStdlib, RESULT_OPTION, PROPAGATE } from '../stdlib/inline.js';
3
3
  import { SecurityCodegen } from './security-codegen.js';
4
+ import { generateValidatorFn, generateFieldSignals, generateFieldAccessor, generateGroupCode, generateArrayCode, generateAsyncValidatorEffect } from './form-codegen.js';
4
5
 
5
- export class ClientCodegen extends BaseCodegen {
6
+ export class BrowserCodegen extends BaseCodegen {
6
7
  constructor() {
7
8
  super();
8
9
  this.stateNames = new Set(); // Track state variable names for setter transforms
9
10
  this.computedNames = new Set(); // Track computed variable names for getter transforms
10
11
  this.componentNames = new Set(); // Track component names for JSX
11
12
  this.storeNames = new Set(); // Track store names
13
+ this.formNames = new Set(); // Track form names
12
14
  this._asyncContext = false; // When true, server.xxx() calls emit `await`
13
15
  this._rpcCache = new WeakMap(); // Memoize _containsRPC() results
14
16
  this._signalCache = new WeakMap(); // Memoize _exprReadsSignal() results
@@ -185,8 +187,9 @@ export class ClientCodegen extends BaseCodegen {
185
187
  return `${asyncPrefix}(${params}) => ${this.genExpression(node.body)}`;
186
188
  }
187
189
 
188
- generate(clientBlocks, sharedCode, sharedBuiltins = null, securityConfig = null) {
190
+ generate(browserBlocks, sharedCode, sharedBuiltins = null, securityConfig = null, typeValidatorsMap = null) {
189
191
  this._sharedBuiltins = sharedBuiltins || new Set();
192
+ this._typeValidators = typeValidatorsMap || {};
190
193
  const lines = [];
191
194
 
192
195
  // Runtime imports
@@ -242,7 +245,7 @@ export class ClientCodegen extends BaseCodegen {
242
245
  // Security block: auth token injection and role helpers
243
246
  if (securityConfig) {
244
247
  const secGen = new SecurityCodegen();
245
- const clientSecurity = secGen.generateClientSecurity(securityConfig);
248
+ const clientSecurity = secGen.generateBrowserSecurity(securityConfig);
246
249
  if (clientSecurity.trim()) {
247
250
  lines.push(clientSecurity);
248
251
  lines.push('');
@@ -254,10 +257,11 @@ export class ClientCodegen extends BaseCodegen {
254
257
  const effects = [];
255
258
  const components = [];
256
259
  const stores = [];
260
+ const forms = [];
257
261
  const imports = [];
258
262
  const other = [];
259
263
 
260
- for (const block of clientBlocks) {
264
+ for (const block of browserBlocks) {
261
265
  for (const stmt of block.body) {
262
266
  switch (stmt.type) {
263
267
  case 'StateDeclaration': states.push(stmt); break;
@@ -265,6 +269,7 @@ export class ClientCodegen extends BaseCodegen {
265
269
  case 'EffectDeclaration': effects.push(stmt); break;
266
270
  case 'ComponentDeclaration': components.push(stmt); break;
267
271
  case 'StoreDeclaration': stores.push(stmt); break;
272
+ case 'FormDeclaration': forms.push(stmt); break;
268
273
  case 'ImportDeclaration': imports.push(stmt); break;
269
274
  case 'ImportDefault': imports.push(stmt); break;
270
275
  case 'ImportWildcard': imports.push(stmt); break;
@@ -302,6 +307,11 @@ export class ClientCodegen extends BaseCodegen {
302
307
  this.storeNames.add(store.name);
303
308
  }
304
309
 
310
+ // Register form names
311
+ for (const form of forms) {
312
+ this.formNames.add(form.name);
313
+ }
314
+
305
315
  // Generate state signals
306
316
  if (states.length > 0) {
307
317
  lines.push('// ── Reactive State ──');
@@ -331,6 +341,15 @@ export class ClientCodegen extends BaseCodegen {
331
341
  }
332
342
  }
333
343
 
344
+ // Generate forms
345
+ if (forms.length > 0) {
346
+ lines.push('// ── Forms ──');
347
+ for (const form of forms) {
348
+ lines.push(this.generateForm(form));
349
+ lines.push('');
350
+ }
351
+ }
352
+
334
353
  // Generate other statements
335
354
  for (const stmt of other) {
336
355
  lines.push(this.generateStatement(stmt));
@@ -600,6 +619,12 @@ export class ClientCodegen extends BaseCodegen {
600
619
  const effectCode = this._generateEffect(node.body);
601
620
  this.indent--;
602
621
  p.push(`${this.i()}${effectCode}\n`);
622
+ } else if (node.type === 'FormDeclaration') {
623
+ this.formNames.add(node.name);
624
+ p.push(`${this.i()}${this.generateForm(node)}\n`);
625
+ } else if (node.type === 'StoreDeclaration') {
626
+ this.storeNames.add(node.name);
627
+ p.push(`${this.i()}${this.generateStore(node)}\n`);
603
628
  } else {
604
629
  p.push(this.generateStatement(node) + '\n');
605
630
  }
@@ -697,6 +722,312 @@ export class ClientCodegen extends BaseCodegen {
697
722
  return p.join('');
698
723
  }
699
724
 
725
+ generateForm(form) {
726
+ // Save/restore state and computed names so form-internal names don't leak
727
+ const savedState = new Set(this.stateNames);
728
+ const savedComputed = new Set(this.computedNames);
729
+ const savedFormNames = new Set(this.formNames);
730
+
731
+ const genExpr = (node) => this.genExpression(node);
732
+ const fields = form.fields || [];
733
+ const groups = form.groups || [];
734
+ const arrays = form.arrays || [];
735
+ const fieldNames = fields.map(f => f.name);
736
+
737
+ // Build merged validators map: field name -> validators array
738
+ // If form has a type annotation, inherit validators from the type definition.
739
+ // Form-level validators override type-level for the same validator name;
740
+ // additional type validators (with different names) are appended.
741
+ const mergedValidatorsMap = {};
742
+ for (const field of fields) {
743
+ mergedValidatorsMap[field.name] = [...(field.validators || [])];
744
+ }
745
+ if (form.typeAnnotation && this._typeValidators) {
746
+ const typeName = form.typeAnnotation.name;
747
+ const typeInfo = this._typeValidators[typeName];
748
+ if (typeInfo) {
749
+ for (const typeField of typeInfo.fields) {
750
+ if (mergedValidatorsMap[typeField.name] !== undefined) {
751
+ const existingNames = new Set(mergedValidatorsMap[typeField.name].map(v => v.name));
752
+ for (const tv of typeField.validators) {
753
+ if (!existingNames.has(tv.name)) {
754
+ mergedValidatorsMap[typeField.name].push(tv);
755
+ }
756
+ }
757
+ }
758
+ }
759
+ }
760
+ }
761
+
762
+ const p = [];
763
+ p.push(`const ${form.name} = (() => {\n`);
764
+ this.indent++;
765
+
766
+ // Field signals (top-level fields)
767
+ for (const field of fields) {
768
+ const init = field.initialValue ? this.genExpression(field.initialValue) : 'null';
769
+ p.push(generateFieldSignals(field.name, init, this.i()));
770
+ }
771
+ if (fields.length > 0) p.push('\n');
772
+
773
+ // Validator functions (top-level fields) — use merged validators
774
+ for (const field of fields) {
775
+ p.push(generateValidatorFn(field.name, mergedValidatorsMap[field.name] || [], genExpr, this.i()));
776
+ }
777
+ if (fields.length > 0) p.push('\n');
778
+
779
+ // Field accessors (top-level fields)
780
+ for (const field of fields) {
781
+ p.push(generateFieldAccessor(field.name, this.i()));
782
+ }
783
+ if (fields.length > 0) p.push('\n');
784
+
785
+ // Generate groups (signals, validators, accessors, group accessors)
786
+ const allGroupPrefixedNames = []; // Collects ALL prefixed field names from groups
787
+ const conditionalGroups = []; // Tracks conditional groups for form-level isValid
788
+ const groupNames = [];
789
+ const formFieldNameSet = new Set(fieldNames); // For condition expression resolution
790
+ for (const group of groups) {
791
+ p.push(generateGroupCode(group, '', genExpr, this.i(), allGroupPrefixedNames, null, conditionalGroups, formFieldNameSet));
792
+ p.push('\n');
793
+ groupNames.push(group.name);
794
+ }
795
+
796
+ // Generate arrays (signal-backed item lists)
797
+ const arrayNames = [];
798
+ for (const arr of arrays) {
799
+ p.push(generateArrayCode(arr, genExpr, this.i()));
800
+ p.push('\n');
801
+ arrayNames.push({ name: arr.name, fields: (arr.fields || []).map(f => f.name) });
802
+ }
803
+
804
+ // Cross-field re-validation effects (e.g., matches() validator) — use merged validators
805
+ for (const field of fields) {
806
+ const validators = mergedValidatorsMap[field.name] || [];
807
+ for (const v of validators) {
808
+ if (v.name === 'matches') {
809
+ const sourceField = v.args[0] && (v.args[0].name || v.args[0]);
810
+ const depField = field.name;
811
+ if (sourceField) {
812
+ p.push(`${this.i()}createEffect(() => {\n`);
813
+ p.push(`${this.i()} __${sourceField}_value();\n`);
814
+ p.push(`${this.i()} if (__${depField}_touched()) {\n`);
815
+ p.push(`${this.i()} const e = __validate_${depField}(__${depField}_value());\n`);
816
+ p.push(`${this.i()} __set_${depField}_error(e);\n`);
817
+ p.push(`${this.i()} }\n`);
818
+ p.push(`${this.i()}});\n`);
819
+ }
820
+ }
821
+ }
822
+ }
823
+
824
+ // Async validator effects (debounced, versioned) — use merged validators
825
+ for (const field of fields) {
826
+ const validators = mergedValidatorsMap[field.name] || [];
827
+ for (const v of validators) {
828
+ if (v.isAsync && v.name === 'validate') {
829
+ p.push(generateAsyncValidatorEffect(
830
+ field.name, v, (expr) => this.genExpression(expr), this.i()
831
+ ));
832
+ p.push('\n');
833
+ }
834
+ }
835
+ }
836
+
837
+ // Form-level signals — include both top-level fields and group-prefixed fields
838
+ const allFieldNamesForValid = [...fieldNames, ...allGroupPrefixedNames];
839
+ const isValidParts = allFieldNamesForValid.map(n => `__${n}_error() === null`);
840
+ // Include array items in isValid: every item in every array must be valid
841
+ const arrayIsValidParts = arrayNames.map(a => `__${a.name}().every(i => i.isValid)`);
842
+ const allIsValidParts = [...isValidParts, ...arrayIsValidParts];
843
+ const isValidExpr = allIsValidParts.length > 0 ? allIsValidParts.join(' && ') : 'true';
844
+ p.push(`${this.i()}const isValid = createComputed(() => ${isValidExpr});\n`);
845
+
846
+ const isDirtyParts = allFieldNamesForValid.map(n => `__${n}_value() !== __${n}_initial`);
847
+ // Include arrays in isDirty: any items added = dirty
848
+ const arrayIsDirtyParts = arrayNames.map(a => `__${a.name}().length > 0`);
849
+ const allIsDirtyParts = [...isDirtyParts, ...arrayIsDirtyParts];
850
+ const isDirtyExpr = allIsDirtyParts.length > 0 ? allIsDirtyParts.join(' || ') : 'false';
851
+ p.push(`${this.i()}const isDirty = createComputed(() => ${isDirtyExpr});\n`);
852
+
853
+ p.push(`${this.i()}const [__submitting, __set_submitting] = createSignal(false);\n`);
854
+ p.push(`${this.i()}const [__submitError, __set_submitError] = createSignal(null);\n`);
855
+ p.push(`${this.i()}const [__submitCount, __set_submitCount] = createSignal(0);\n`);
856
+ p.push('\n');
857
+
858
+ // Wizard steps (only when form has steps block)
859
+ const hasSteps = form.steps && form.steps.steps && form.steps.steps.length > 0;
860
+ if (hasSteps) {
861
+ const fieldNameSet = new Set(fieldNames);
862
+ const groupNameSet = new Set(groupNames);
863
+ const arrayNameSet = new Set(arrayNames.map(a => a.name));
864
+
865
+ p.push(`${this.i()}const [__currentStep, __set_currentStep] = createSignal(0);\n`);
866
+ p.push(`${this.i()}const __steps = [\n`);
867
+ this.indent++;
868
+ for (const step of form.steps.steps) {
869
+ const validateParts = step.members.map(member => {
870
+ if (fieldNameSet.has(member)) return `${member}.validate()`;
871
+ if (groupNameSet.has(member)) return `${member}.isValid`;
872
+ if (arrayNameSet.has(member)) return `${member}.items.every(i => i.isValid)`;
873
+ // Default to field validate
874
+ return `${member}.validate()`;
875
+ });
876
+ const validateExpr = validateParts.length === 1
877
+ ? validateParts[0]
878
+ : validateParts.join(' && ');
879
+ p.push(`${this.i()}{ label: ${JSON.stringify(step.label)}, validate: () => ${validateExpr} },\n`);
880
+ }
881
+ this.indent--;
882
+ p.push(`${this.i()}];\n`);
883
+
884
+ p.push(`${this.i()}const canNext = createComputed(() => {\n`);
885
+ this.indent++;
886
+ p.push(`${this.i()}const step = __steps[__currentStep()];\n`);
887
+ p.push(`${this.i()}return step ? step.validate() : false;\n`);
888
+ this.indent--;
889
+ p.push(`${this.i()}});\n`);
890
+
891
+ p.push(`${this.i()}const canPrev = createComputed(() => __currentStep() > 0);\n`);
892
+ p.push(`${this.i()}const progress = createComputed(() => (__currentStep() + 1) / __steps.length);\n`);
893
+ p.push('\n');
894
+
895
+ p.push(`${this.i()}function next() {\n`);
896
+ this.indent++;
897
+ p.push(`${this.i()}if (canNext()) __set_currentStep(__tova_p => __tova_p + 1);\n`);
898
+ this.indent--;
899
+ p.push(`${this.i()}}\n`);
900
+
901
+ p.push(`${this.i()}function prev() {\n`);
902
+ this.indent++;
903
+ p.push(`${this.i()}if (canPrev()) __set_currentStep(__tova_p => __tova_p - 1);\n`);
904
+ this.indent--;
905
+ p.push(`${this.i()}}\n`);
906
+ p.push('\n');
907
+ }
908
+
909
+ // Reset function — include both top-level and group-prefixed fields + arrays
910
+ p.push(`${this.i()}function reset() {\n`);
911
+ this.indent++;
912
+ for (const name of fieldNames) {
913
+ p.push(`${this.i()}${name}.reset();\n`);
914
+ }
915
+ for (const name of allGroupPrefixedNames) {
916
+ p.push(`${this.i()}${name}.reset();\n`);
917
+ }
918
+ for (const arr of arrayNames) {
919
+ p.push(`${this.i()}__set_${arr.name}([]); __${arr.name}_nextId = 0;\n`);
920
+ }
921
+ p.push(`${this.i()}__set_submitError(null);\n`);
922
+ this.indent--;
923
+ p.push(`${this.i()}}\n\n`);
924
+
925
+ // Submit function
926
+ p.push(`${this.i()}async function submit(e) {\n`);
927
+ this.indent++;
928
+ p.push(`${this.i()}if (e && e.preventDefault) e.preventDefault();\n`);
929
+
930
+ // Touch all fields to show errors (top-level + group fields)
931
+ for (const name of fieldNames) {
932
+ p.push(`${this.i()}${name}.blur();\n`);
933
+ }
934
+ for (const name of allGroupPrefixedNames) {
935
+ p.push(`${this.i()}${name}.blur();\n`);
936
+ }
937
+ // Touch all array item fields to show errors
938
+ for (const arr of arrayNames) {
939
+ const blurCalls = arr.fields.map(f => `i.${f}.blur()`).join('; ');
940
+ p.push(`${this.i()}__${arr.name}().forEach(i => { ${blurCalls}; });\n`);
941
+ }
942
+
943
+ p.push(`${this.i()}if (!(isValid())) return;\n`);
944
+ p.push(`${this.i()}__set_submitting(true);\n`);
945
+ p.push(`${this.i()}__set_submitError(null);\n`);
946
+ p.push(`${this.i()}__set_submitCount(__tova_p => __tova_p + 1);\n`);
947
+ p.push(`${this.i()}try {\n`);
948
+ this.indent++;
949
+
950
+ // Generate the on submit body
951
+ if (form.onSubmit) {
952
+ p.push(this.genBlockStatements(form.onSubmit));
953
+ p.push('\n');
954
+ }
955
+
956
+ this.indent--;
957
+ p.push(`${this.i()}} catch (err) {\n`);
958
+ this.indent++;
959
+ p.push(`${this.i()}__set_submitError(err.message || String(err));\n`);
960
+ this.indent--;
961
+ p.push(`${this.i()}} finally {\n`);
962
+ this.indent++;
963
+ p.push(`${this.i()}__set_submitting(false);\n`);
964
+ this.indent--;
965
+ p.push(`${this.i()}}\n`);
966
+ this.indent--;
967
+ p.push(`${this.i()}}\n\n`);
968
+
969
+ // Return object
970
+ p.push(`${this.i()}return {\n`);
971
+ this.indent++;
972
+
973
+ // Top-level field accessors
974
+ for (const name of fieldNames) {
975
+ p.push(`${this.i()}${name},\n`);
976
+ }
977
+
978
+ // Group accessors
979
+ for (const name of groupNames) {
980
+ p.push(`${this.i()}${name},\n`);
981
+ }
982
+
983
+ // Array accessors
984
+ for (const arr of arrayNames) {
985
+ p.push(`${this.i()}${arr.name},\n`);
986
+ }
987
+
988
+ // Values getter — includes top-level fields, group values, and array values
989
+ const topLevelValuesEntries = fieldNames.map(n => `${n}: __${n}_value()`);
990
+ const groupValuesEntries = groupNames.map(n => `${n}: ${n}.values`);
991
+ const arrayValuesEntries = arrayNames.map(a => `${a.name}: __${a.name}().map(i => i.values)`);
992
+ const allValuesEntries = [...topLevelValuesEntries, ...groupValuesEntries, ...arrayValuesEntries];
993
+ const valuesObj = allValuesEntries.join(', ');
994
+ p.push(`${this.i()}get values() { return { ${valuesObj} }; },\n`);
995
+
996
+ // Form-level getters
997
+ p.push(`${this.i()}get isValid() { return isValid(); },\n`);
998
+ p.push(`${this.i()}get isDirty() { return isDirty(); },\n`);
999
+ p.push(`${this.i()}submit,\n`);
1000
+ p.push(`${this.i()}reset,\n`);
1001
+ p.push(`${this.i()}get submitting() { return __submitting(); },\n`);
1002
+ p.push(`${this.i()}get submitError() { return __submitError(); },\n`);
1003
+ p.push(`${this.i()}get submitCount() { return __submitCount(); },\n`);
1004
+ p.push(`${this.i()}setError: (msg) => __set_submitError(msg),\n`);
1005
+
1006
+ // Wizard step properties in return object
1007
+ if (hasSteps) {
1008
+ p.push(`${this.i()}get currentStep() { return __currentStep(); },\n`);
1009
+ p.push(`${this.i()}next,\n`);
1010
+ p.push(`${this.i()}prev,\n`);
1011
+ p.push(`${this.i()}get canNext() { return canNext(); },\n`);
1012
+ p.push(`${this.i()}get canPrev() { return canPrev(); },\n`);
1013
+ p.push(`${this.i()}get progress() { return progress(); },\n`);
1014
+ p.push(`${this.i()}get steps() { return __steps; },\n`);
1015
+ }
1016
+
1017
+ this.indent--;
1018
+ p.push(`${this.i()}};\n`);
1019
+
1020
+ this.indent--;
1021
+ p.push(`${this.i()}})();`);
1022
+
1023
+ // Restore state/computed/form names
1024
+ this.stateNames = savedState;
1025
+ this.computedNames = savedComputed;
1026
+ this.formNames = savedFormNames;
1027
+
1028
+ return p.join('');
1029
+ }
1030
+
700
1031
  // Check if an AST expression references any signal/computed name (memoized)
701
1032
  _exprReadsSignal(node) {
702
1033
  if (!node) return false;
@@ -799,6 +1130,16 @@ export class ClientCodegen extends BaseCodegen {
799
1130
  return `(__props.children || '')`;
800
1131
  }
801
1132
 
1133
+ // ── <ErrorMessage /> built-in component (compiler-time transform) ──
1134
+ if (node.tag === 'ErrorMessage') {
1135
+ return this._genErrorMessage(node, null);
1136
+ }
1137
+
1138
+ // ── <FormField field={expr}> built-in component (compiler-time transform) ──
1139
+ if (node.tag === 'FormField') {
1140
+ return this._genFormField(node);
1141
+ }
1142
+
802
1143
  const isComponent = node.tag[0] === node.tag[0].toUpperCase() && /^[A-Z]/.test(node.tag);
803
1144
 
804
1145
  // Attributes
@@ -828,6 +1169,10 @@ export class ClientCodegen extends BaseCodegen {
828
1169
  const valueExpr = isNumeric ? 'Number(e.target.value)' : 'e.target.value';
829
1170
  events[eventName] = `(e) => { set${capitalize(exprName)}(${valueExpr}); }`;
830
1171
  }
1172
+ } else if (attr.name === 'bind:form') {
1173
+ // Form binding: bind:form={login} → onSubmit wires to form controller's submit()
1174
+ const formName = this.genExpression(attr.value);
1175
+ events.submit = `(e) => ${formName}.submit(e)`;
831
1176
  } else if (attr.name === 'bind:checked') {
832
1177
  // Two-way binding: bind:checked={flag} → reactive checked + onChange
833
1178
  const expr = this.genExpression(attr.value);
@@ -1119,6 +1464,100 @@ export class ClientCodegen extends BaseCodegen {
1119
1464
  return result;
1120
1465
  }
1121
1466
 
1467
+ // ── <ErrorMessage /> compiler transform ──
1468
+ // Standalone: <ErrorMessage field={form.email} /> or <ErrorMessage form={login} />
1469
+ // Inside FormField: <ErrorMessage /> (inherits parent field expression)
1470
+ _genErrorMessage(node, parentFieldExpr) {
1471
+ const fieldAttr = node.attributes.find(a => a.name === 'field');
1472
+ const formAttr = node.attributes.find(a => a.name === 'form');
1473
+
1474
+ if (formAttr) {
1475
+ // Form-level error: show form.submitError
1476
+ const formExpr = this.genExpression(formAttr.value);
1477
+ return `() => ${formExpr}.submitError ? tova_el("span", {className: "form-error"}, [() => ${formExpr}.submitError]) : null`;
1478
+ }
1479
+
1480
+ // Determine field expression: explicit attr or inherited from parent FormField
1481
+ let fieldExpr;
1482
+ if (fieldAttr) {
1483
+ fieldExpr = this.genExpression(fieldAttr.value);
1484
+ } else if (parentFieldExpr) {
1485
+ fieldExpr = parentFieldExpr;
1486
+ } else {
1487
+ // No field info — render nothing
1488
+ return 'null';
1489
+ }
1490
+
1491
+ return `() => ${fieldExpr}.touched && ${fieldExpr}.error ? tova_el("span", {className: "form-error"}, [() => ${fieldExpr}.error]) : null`;
1492
+ }
1493
+
1494
+ // ── <FormField field={expr}> compiler transform ──
1495
+ // Generates a wrapper div.form-field with auto-wired input/select/textarea children
1496
+ _genFormField(node) {
1497
+ const fieldAttr = node.attributes.find(a => a.name === 'field');
1498
+ if (!fieldAttr) {
1499
+ // No field attribute — fall through to normal div
1500
+ const children = (node.children || []).map(c => this.genJSX(c)).join(', ');
1501
+ return `tova_el("div", {className: "form-field"}, [${children}])`;
1502
+ }
1503
+
1504
+ const fieldExpr = this.genExpression(fieldAttr.value);
1505
+ const inputTags = new Set(['input', 'select', 'textarea']);
1506
+
1507
+ const childParts = [];
1508
+ for (const child of (node.children || [])) {
1509
+ if (child.type === 'JSXElement' && inputTags.has(child.tag)) {
1510
+ // Auto-wire input/select/textarea to the field
1511
+ childParts.push(this._genFormFieldInput(child, fieldExpr));
1512
+ } else if (child.type === 'JSXElement' && child.tag === 'ErrorMessage') {
1513
+ // Replace <ErrorMessage /> with conditional error display using parent field
1514
+ childParts.push(this._genErrorMessage(child, fieldExpr));
1515
+ } else {
1516
+ // Other children: generate normally
1517
+ childParts.push(this.genJSX(child));
1518
+ }
1519
+ }
1520
+
1521
+ return `tova_el("div", {className: "form-field"}, [${childParts.join(', ')}])`;
1522
+ }
1523
+
1524
+ // Generate an input/select/textarea element with field bindings injected
1525
+ _genFormFieldInput(node, fieldExpr) {
1526
+ // Process existing attributes normally, then inject field bindings
1527
+ const attrs = {};
1528
+ const events = {};
1529
+
1530
+ for (const attr of node.attributes) {
1531
+ if (attr.name === 'class') {
1532
+ attrs.className = this.genExpression(attr.value);
1533
+ } else if (attr.name.startsWith('on:')) {
1534
+ events[attr.name.slice(3)] = this.genExpression(attr.value);
1535
+ } else {
1536
+ attrs[attr.name] = this.genExpression(attr.value);
1537
+ }
1538
+ }
1539
+
1540
+ // Inject field bindings
1541
+ attrs.value = `() => ${fieldExpr}.value`;
1542
+ events.input = `(e) => ${fieldExpr}.set(e.target.value)`;
1543
+ events.blur = `() => ${fieldExpr}.blur()`;
1544
+
1545
+ const propParts = [];
1546
+ for (const [key, val] of Object.entries(attrs)) {
1547
+ propParts.push(`${key}: ${val}`);
1548
+ }
1549
+ for (const [event, handler] of Object.entries(events)) {
1550
+ propParts.push(`on${event[0].toUpperCase() + event.slice(1)}: ${handler}`);
1551
+ }
1552
+
1553
+ const tag = JSON.stringify(node.tag);
1554
+ if (node.selfClosing || !node.children || node.children.length === 0) {
1555
+ return `tova_el(${tag}, {${propParts.join(', ')}})`;
1556
+ }
1557
+ const children = node.children.map(c => this.genJSX(c)).join(', ');
1558
+ return `tova_el(${tag}, {${propParts.join(', ')}}, [${children}])`;
1559
+ }
1560
+
1122
1561
  genJSXText(node) {
1123
1562
  if (node.value.type === 'StringLiteral') {
1124
1563
  return JSON.stringify(node.value.value);