webspresso 0.0.112 → 0.0.114

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 (42) hide show
  1. package/README.md +26 -0
  2. package/bin/commands/admin-password.js +1 -7
  3. package/bin/commands/api.js +1 -7
  4. package/bin/commands/new.js +1 -7
  5. package/bin/commands/page.js +1 -7
  6. package/bin/commands/seed.js +1 -7
  7. package/bin/commands/service.js +1 -7
  8. package/bin/utils/prompt.js +17 -0
  9. package/core/cache/tagged-lru.js +9 -3
  10. package/core/client/adapter/datastar.js +602 -26
  11. package/core/client/api.js +151 -7
  12. package/core/client/controller.js +172 -11
  13. package/core/client/devtool.js +59 -33
  14. package/core/client/forms.js +479 -29
  15. package/core/client/fragments.js +456 -27
  16. package/core/client/index.js +18 -1
  17. package/core/client/server-fragments.js +125 -40
  18. package/core/client/utils.js +72 -17
  19. package/core/console/commands.js +26 -5
  20. package/core/console/repl.js +7 -1
  21. package/core/http/pipeline.js +82 -1
  22. package/core/openapi/build-from-api-routes.js +5 -5
  23. package/core/orm/json-fields.js +7 -6
  24. package/core/orm/schema-helpers.js +18 -15
  25. package/core/orm/utils/nanoid.js +7 -8
  26. package/core/plugins/plugin-manager.js +155 -1
  27. package/core/services/validator.js +12 -1
  28. package/core/validation/apply.js +3 -0
  29. package/core/validation/index.js +25 -0
  30. package/core/validation/zod-introspect.js +187 -0
  31. package/index.d.ts +113 -1
  32. package/package.json +12 -11
  33. package/plugins/mcp/providers/services.js +2 -2
  34. package/src/api/define-api.js +2 -0
  35. package/src/api/validation-i18n.js +6 -2
  36. package/src/bundle/index.js +140 -6
  37. package/src/dev/api-typegen.js +79 -22
  38. package/src/dev/typegen.js +61 -35
  39. package/src/dev-reload/index.js +44 -2
  40. package/src/file-router.js +24 -6
  41. package/src/routing/mount-discovered-routes.js +24 -6
  42. package/src/server.js +59 -3
@@ -23,33 +23,48 @@ const { isArray, setElementBusyState } = require('./utils');
23
23
  * @param {string} target
24
24
  * @returns {boolean}
25
25
  */
26
- function isSafeRedirectUrl(target) {
27
- if (!target || typeof target !== 'string') return false;
26
+ function resolveSafeRedirectUrl(target) {
27
+ if (!target || typeof target !== 'string') return null;
28
28
  const trimmed = target.trim();
29
- if (!trimmed) return false;
29
+ if (!trimmed) return null;
30
30
 
31
31
  // Block control characters
32
- if (/[\u0000-\u001F\u007F-\u009F]/.test(trimmed)) return false;
32
+ if (/[\u0000-\u001F\u007F-\u009F]/.test(trimmed)) return null;
33
33
 
34
34
  // Block protocol-relative URLs (//example.com) and backslash traversal (/\ or \\)
35
35
  if (trimmed.startsWith('//') || trimmed.startsWith('/\\') || trimmed.startsWith('\\')) {
36
- return false;
36
+ return null;
37
37
  }
38
38
 
39
39
  // Relative path starting with single slash (e.g. /dashboard)
40
40
  if (trimmed.startsWith('/')) {
41
- return true;
41
+ try {
42
+ const parsed = new URL(trimmed, 'http://localhost');
43
+ if (parsed.origin !== 'http://localhost' || !parsed.pathname.startsWith('/') || parsed.pathname.startsWith('//')) {
44
+ return null;
45
+ }
46
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
47
+ } catch {
48
+ return null;
49
+ }
42
50
  }
43
51
 
44
52
  // Absolute URL: MUST explicitly parse as http: or https:
45
53
  try {
46
54
  const parsed = new URL(trimmed);
47
- return parsed.protocol === 'http:' || parsed.protocol === 'https:';
55
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
56
+ return null;
57
+ }
58
+ return parsed.href;
48
59
  } catch {
49
- return false;
60
+ return null;
50
61
  }
51
62
  }
52
63
 
64
+ function isSafeRedirectUrl(target) {
65
+ return resolveSafeRedirectUrl(target) !== null;
66
+ }
67
+
53
68
  let errorIdSeq = 0;
54
69
 
55
70
  /**
@@ -435,6 +450,7 @@ function createFormHandle(formEl, options = {}) {
435
450
  let baselineValues = parseFormElement(formEl).data;
436
451
  let pending = false;
437
452
  let dirty = false;
453
+ let submitted = false;
438
454
  let inputRevision = 0;
439
455
  let fieldErrors = {};
440
456
  let formError = null;
@@ -443,6 +459,86 @@ function createFormHandle(formEl, options = {}) {
443
459
  let draftDebounceTimer = null;
444
460
  let resetTimer = null;
445
461
 
462
+ const touchedFields = new Set();
463
+ const errorDisplayMode =
464
+ options.errorDisplay === 'touched' ||
465
+ (formEl && formEl.getAttribute && formEl.getAttribute('data-w-error-mode') === 'touched')
466
+ ? 'touched'
467
+ : 'always';
468
+
469
+ const validationSeqByField = new Map();
470
+ const validationTimersByField = new Map();
471
+ const validationAbortByField = new Map();
472
+ let validationEpoch = 0;
473
+
474
+ let activeSubmitAbort = null;
475
+ let uploadEntries = [];
476
+ const previewUrlsByField = new Map();
477
+
478
+ function cancelFieldValidation(fieldName) {
479
+ if (fieldName) {
480
+ validationSeqByField.set(fieldName, (validationSeqByField.get(fieldName) || 0) + 1);
481
+ const t = validationTimersByField.get(fieldName);
482
+ if (t) {
483
+ clearTimeout(t);
484
+ validationTimersByField.delete(fieldName);
485
+ }
486
+ const ac = validationAbortByField.get(fieldName);
487
+ if (ac) {
488
+ try {
489
+ ac.abort();
490
+ } catch (_e) {}
491
+ validationAbortByField.delete(fieldName);
492
+ }
493
+ return;
494
+ }
495
+ validationEpoch += 1;
496
+ for (const t of validationTimersByField.values()) clearTimeout(t);
497
+ validationTimersByField.clear();
498
+ for (const ac of validationAbortByField.values()) {
499
+ try {
500
+ ac.abort();
501
+ } catch (_e) {}
502
+ }
503
+ validationAbortByField.clear();
504
+ }
505
+
506
+ function revokePreviewUrls(fieldName) {
507
+ const revokeUrl = (u) => {
508
+ if (typeof URL !== 'undefined' && typeof URL.revokeObjectURL === 'function') {
509
+ try {
510
+ URL.revokeObjectURL(u);
511
+ } catch (_e) {}
512
+ }
513
+ };
514
+ if (fieldName) {
515
+ const set = previewUrlsByField.get(fieldName);
516
+ if (set) {
517
+ for (const u of set) revokeUrl(u);
518
+ set.clear();
519
+ previewUrlsByField.delete(fieldName);
520
+ }
521
+ return;
522
+ }
523
+ for (const set of previewUrlsByField.values()) {
524
+ for (const u of set) revokeUrl(u);
525
+ set.clear();
526
+ }
527
+ previewUrlsByField.clear();
528
+ }
529
+
530
+ function createPreviewUrl(file, fieldName = '_default') {
531
+ if (!file || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') return null;
532
+ const url = URL.createObjectURL(file);
533
+ let set = previewUrlsByField.get(fieldName);
534
+ if (!set) {
535
+ set = new Set();
536
+ previewUrlsByField.set(fieldName, set);
537
+ }
538
+ set.add(url);
539
+ return url;
540
+ }
541
+
446
542
  const beforeSubmitListeners = new Set();
447
543
  const successListeners = new Set();
448
544
  const errorListeners = new Set();
@@ -591,6 +687,12 @@ function createFormHandle(formEl, options = {}) {
591
687
  }
592
688
  }
593
689
 
690
+ function isFieldErrorVisible(fieldName) {
691
+ if (errorDisplayMode !== 'touched') return true;
692
+ if (submitted) return true;
693
+ return touchedFields.has(fieldName);
694
+ }
695
+
594
696
  /**
595
697
  * Render field errors and form-level error into the DOM with accessible ARIA bindings.
596
698
  * Optionally focuses the first invalid control.
@@ -603,7 +705,11 @@ function createFormHandle(formEl, options = {}) {
603
705
  for (let i = 0; i < errorSpans.length; i++) {
604
706
  const span = errorSpans[i];
605
707
  const fieldName = span.getAttribute('data-w-error');
606
- const msg = fieldName && fieldErrors[fieldName] ? String(fieldErrors[fieldName]) : '';
708
+ const normName = fieldName && fieldName.endsWith('[]') ? fieldName.slice(0, -2) : fieldName;
709
+ const rawMsg = fieldName && (fieldErrors[fieldName] || (normName && fieldErrors[normName]))
710
+ ? String(fieldErrors[fieldName] || fieldErrors[normName])
711
+ : '';
712
+ const msg = rawMsg && (isFieldErrorVisible(fieldName) || (normName && isFieldErrorVisible(normName))) ? rawMsg : '';
607
713
  span.textContent = msg;
608
714
  if (msg) {
609
715
  span.removeAttribute('hidden');
@@ -626,7 +732,8 @@ function createFormHandle(formEl, options = {}) {
626
732
  const rawName = ctrl.getAttribute('name');
627
733
  if (!rawName) continue;
628
734
  const normName = rawName.endsWith('[]') ? rawName.slice(0, -2) : rawName;
629
- const errMsg = fieldErrors[normName] || fieldErrors[rawName];
735
+ const rawErrMsg = fieldErrors[normName] || fieldErrors[rawName];
736
+ const errMsg = rawErrMsg && (isFieldErrorVisible(normName) || isFieldErrorVisible(rawName)) ? rawErrMsg : '';
630
737
 
631
738
  if (errMsg) {
632
739
  ctrl.setAttribute('aria-invalid', 'true');
@@ -711,6 +818,8 @@ function createFormHandle(formEl, options = {}) {
711
818
  */
712
819
  function reset() {
713
820
  cancelDraftDebounce();
821
+ cancelFieldValidation();
822
+ revokePreviewUrls();
714
823
  if (!formEl) return;
715
824
  if (typeof formEl.reset === 'function') {
716
825
  formEl.reset();
@@ -730,6 +839,8 @@ function createFormHandle(formEl, options = {}) {
730
839
  }
731
840
  }
732
841
  dirty = false;
842
+ submitted = false;
843
+ touchedFields.clear();
733
844
  fieldErrors = {};
734
845
  formError = null;
735
846
  syncErrorsToDom(false);
@@ -741,6 +852,8 @@ function createFormHandle(formEl, options = {}) {
741
852
  */
742
853
  function clear() {
743
854
  cancelDraftDebounce();
855
+ cancelFieldValidation();
856
+ revokePreviewUrls();
744
857
  if (!formEl || typeof formEl.querySelectorAll !== 'function') return;
745
858
  const controls = formEl.querySelectorAll('input, select, textarea');
746
859
  for (let i = 0; i < controls.length; i++) {
@@ -762,6 +875,8 @@ function createFormHandle(formEl, options = {}) {
762
875
  }
763
876
  }
764
877
  dirty = computeFormDirty(formEl, initialSnapshot);
878
+ submitted = false;
879
+ touchedFields.clear();
765
880
  fieldErrors = {};
766
881
  formError = null;
767
882
  syncErrorsToDom(false);
@@ -829,9 +944,12 @@ function createFormHandle(formEl, options = {}) {
829
944
  }
830
945
 
831
946
  if (isBaseline) {
947
+ cancelFieldValidation();
832
948
  initialSnapshot = captureInitialFormSnapshot(formEl);
833
949
  baselineValues = parseFormElement(formEl).data;
834
950
  dirty = false;
951
+ submitted = false;
952
+ touchedFields.clear();
835
953
  fieldErrors = {};
836
954
  formError = null;
837
955
  lastError = null;
@@ -950,13 +1068,47 @@ function createFormHandle(formEl, options = {}) {
950
1068
  }
951
1069
  }
952
1070
 
953
- // Clear previous errors before request starts
1071
+ // Clear previous errors before request starts, mark submitted = true
1072
+ cancelFieldValidation();
1073
+ submitted = true;
954
1074
  fieldErrors = {};
955
1075
  formError = null;
956
1076
  lastError = null;
957
1077
  syncErrorsToDom(false);
1078
+ const submitRevision = inputRevision;
1079
+ const submittedSnapshot = captureInitialFormSnapshot(formEl);
958
1080
  setPendingState(true, submitter);
959
1081
 
1082
+ // Track per-file upload state if multipart
1083
+ const submitAbortController = new AbortController();
1084
+ activeSubmitAbort = submitAbortController;
1085
+ if (isMultipart && formEl && typeof formEl.querySelectorAll === 'function') {
1086
+ const nextUploads = [];
1087
+ const fileInputs = formEl.querySelectorAll('input[type="file"]');
1088
+ for (let i = 0; i < fileInputs.length; i++) {
1089
+ const fi = fileInputs[i];
1090
+ const fName = fi.getAttribute('name') || 'file';
1091
+ const files = fi.files || [];
1092
+ for (let j = 0; j < files.length; j++) {
1093
+ const f = files[j];
1094
+ nextUploads.push({
1095
+ field: fName,
1096
+ name: f.name || 'file',
1097
+ size: typeof f.size === 'number' ? f.size : 0,
1098
+ status: 'uploading',
1099
+ loaded: 0,
1100
+ total: typeof f.size === 'number' ? f.size : 0,
1101
+ percent: null,
1102
+ error: null,
1103
+ });
1104
+ }
1105
+ }
1106
+ uploadEntries = nextUploads;
1107
+ if (uploadEntries.length > 0) notifyStateChange();
1108
+ } else {
1109
+ uploadEntries = [];
1110
+ }
1111
+
960
1112
  try {
961
1113
  let responseData;
962
1114
  const reqKey = `form:${formName}:${method}:${actionUrl}`;
@@ -964,17 +1116,39 @@ function createFormHandle(formEl, options = {}) {
964
1116
  responseData = await api.get(actionUrl, payloadData, {
965
1117
  key: reqKey,
966
1118
  policy: 'latest',
1119
+ signal: submitAbortController.signal,
967
1120
  });
968
1121
  } else {
969
1122
  const bodyToSend = isMultipart && formData ? formData : payloadData;
970
- responseData = await api.request(method, actionUrl, bodyToSend, {
1123
+ const reqOpts = {
971
1124
  key: reqKey,
972
1125
  policy: 'drop',
973
- });
1126
+ signal: submitAbortController.signal,
1127
+ };
1128
+ if (isMultipart && uploadEntries.length > 0) {
1129
+ reqOpts.onUploadProgress = (prog) => {
1130
+ for (let i = 0; i < uploadEntries.length; i++) {
1131
+ uploadEntries[i].loaded = prog.loaded;
1132
+ uploadEntries[i].total = prog.total || uploadEntries[i].total;
1133
+ uploadEntries[i].percent = prog.percent;
1134
+ }
1135
+ if (typeof submitOptions.onUploadProgress === 'function') {
1136
+ submitOptions.onUploadProgress(prog, uploadEntries);
1137
+ }
1138
+ notifyStateChange();
1139
+ };
1140
+ }
1141
+ responseData = await api.request(method, actionUrl, bodyToSend, reqOpts);
1142
+ }
1143
+
1144
+ if (uploadEntries.length > 0) {
1145
+ for (let i = 0; i < uploadEntries.length; i++) {
1146
+ uploadEntries[i].status = 'done';
1147
+ if (uploadEntries[i].percent !== null) uploadEntries[i].percent = 100;
1148
+ }
974
1149
  }
975
1150
 
976
1151
  lastData = responseData;
977
- dirty = false;
978
1152
  fieldErrors = {};
979
1153
  formError = null;
980
1154
  syncErrorsToDom(false);
@@ -995,18 +1169,51 @@ function createFormHandle(formEl, options = {}) {
995
1169
  clearAttr === 'true' ||
996
1170
  clearAttr === '1';
997
1171
 
998
- if (shouldClear) {
999
- clear();
1000
- } else if (shouldReset) {
1001
- reset();
1002
- } else {
1003
- // Current state becomes the new clean baseline after successful save
1004
- initialSnapshot = captureInitialFormSnapshot(formEl);
1005
- baselineValues = parseFormElement(formEl).data;
1006
- }
1172
+ const editedDuringSubmit = inputRevision !== submitRevision;
1173
+
1174
+ if (!editedDuringSubmit) {
1175
+ dirty = false;
1176
+ if (shouldClear) {
1177
+ clear();
1178
+ } else if (shouldReset) {
1179
+ reset();
1180
+ } else {
1181
+ // Current state becomes the new clean baseline after successful save
1182
+ initialSnapshot = captureInitialFormSnapshot(formEl);
1183
+ baselineValues = parseFormElement(formEl).data;
1184
+ }
1007
1185
 
1008
- if (isDraftEnabled) {
1009
- clearDraft();
1186
+ if (isDraftEnabled) {
1187
+ clearDraft();
1188
+ }
1189
+ } else {
1190
+ // User edited form controls while submit was in flight:
1191
+ // Never wipe out the new unsubmitted edits via reset() or clear().
1192
+ // Update baseline to what was actually committed on the server (or empty baseline if clearOnSuccess).
1193
+ if (shouldClear) {
1194
+ const clearedSnapshot = new Map();
1195
+ for (const [ctrl, snap] of submittedSnapshot.entries()) {
1196
+ clearedSnapshot.set(ctrl, {
1197
+ ...snap,
1198
+ checked: false,
1199
+ value: '',
1200
+ selectedValues: [],
1201
+ });
1202
+ }
1203
+ initialSnapshot = clearedSnapshot;
1204
+ baselineValues = {};
1205
+ } else if (!shouldReset) {
1206
+ initialSnapshot = submittedSnapshot;
1207
+ baselineValues = payloadData && typeof payloadData === 'object' ? { ...payloadData } : {};
1208
+ }
1209
+ dirty = computeFormDirty(formEl, initialSnapshot);
1210
+ if (isDraftEnabled) {
1211
+ if (dirty) {
1212
+ saveDraft();
1213
+ } else {
1214
+ clearDraft();
1215
+ }
1216
+ }
1010
1217
  }
1011
1218
 
1012
1219
  // Invoke registered success hooks. Note: Any background fragment refresh failure
@@ -1025,11 +1232,12 @@ function createFormHandle(formEl, options = {}) {
1025
1232
  }
1026
1233
 
1027
1234
  // Explicit redirect behavior
1028
- const redirectTarget =
1235
+ const rawRedirectTarget =
1029
1236
  submitOptions.redirectOnSuccess ||
1030
1237
  (formEl && formEl.getAttribute('data-w-redirect')) ||
1031
1238
  null;
1032
- if (redirectTarget && isSafeRedirectUrl(redirectTarget)) {
1239
+ const redirectTarget = resolveSafeRedirectUrl(rawRedirectTarget);
1240
+ if (redirectTarget) {
1033
1241
  if (typeof options.navigate === 'function') {
1034
1242
  options.navigate(redirectTarget);
1035
1243
  } else if (typeof window !== 'undefined' && window.location) {
@@ -1050,6 +1258,15 @@ function createFormHandle(formEl, options = {}) {
1050
1258
  return { ok: true, submitted: true, data: responseData, refreshOk, refreshError, hookError };
1051
1259
  } catch (err) {
1052
1260
  lastError = err || null;
1261
+ if (uploadEntries.length > 0) {
1262
+ const isCanceled = Boolean(err && err.isCanceled);
1263
+ for (let i = 0; i < uploadEntries.length; i++) {
1264
+ if (uploadEntries[i].status === 'uploading') {
1265
+ uploadEntries[i].status = isCanceled ? 'canceled' : 'error';
1266
+ uploadEntries[i].error = isCanceled ? null : (err && err.message ? err.message : 'Upload failed');
1267
+ }
1268
+ }
1269
+ }
1053
1270
  if (err && err.kind === 'validation') {
1054
1271
  fieldErrors = { ...(err.fieldErrors || {}) };
1055
1272
  formError = err.formError || (Object.keys(fieldErrors).length === 0 ? err.message : null);
@@ -1072,26 +1289,182 @@ function createFormHandle(formEl, options = {}) {
1072
1289
  if (submitOptions.throwOnError) {
1073
1290
  throw err;
1074
1291
  }
1075
- return { ok: false, submitted: false, error: err };
1292
+ return {
1293
+ ok: false,
1294
+ submitted: false,
1295
+ outcomeUnknown: Boolean(err && err.outcomeUnknown),
1296
+ outcome: (err && err.outcome) || 'failed',
1297
+ error: err,
1298
+ };
1076
1299
  } finally {
1300
+ if (activeSubmitAbort === submitAbortController) {
1301
+ activeSubmitAbort = null;
1302
+ }
1077
1303
  setPendingState(false, submitter);
1078
1304
  }
1079
1305
  }
1080
1306
 
1081
- // Listen to native submit and input/change events on `formEl`
1307
+ function parseFieldValidateConfig(ctrl) {
1308
+ const ctrlAttr = ctrl && ctrl.getAttribute ? ctrl.getAttribute('data-w-validate') : null;
1309
+ const formAttr = formEl && formEl.getAttribute ? formEl.getAttribute('data-w-validate') : null;
1310
+ const raw = ctrlAttr !== null ? ctrlAttr : formAttr;
1311
+ if (!raw || raw === 'false') return null;
1312
+ const parts = String(raw).split(/[\s,|]+/).map((s) => s.trim().toLowerCase()).filter(Boolean);
1313
+ const onBlur = parts.includes('blur') || parts.includes('true') || raw === '';
1314
+ let debounceMs = null;
1315
+ for (const p of parts) {
1316
+ if (p.startsWith('debounce:')) {
1317
+ const ms = Number(p.slice('debounce:'.length).replace(/ms$/i, ''));
1318
+ debounceMs = Number.isFinite(ms) && ms >= 0 ? ms : 300;
1319
+ } else if (p === 'debounce') {
1320
+ debounceMs = 300;
1321
+ }
1322
+ }
1323
+ return { onBlur, debounceMs };
1324
+ }
1325
+
1326
+ async function validateField(fieldName, vOpts = {}) {
1327
+ if (!formEl || !api || !fieldName) return { ok: true, valid: true, errors: {} };
1328
+ const norm = fieldName.endsWith('[]') ? fieldName.slice(0, -2) : fieldName;
1329
+
1330
+ const epoch = validationEpoch;
1331
+ const seq = (validationSeqByField.get(norm) || 0) + 1;
1332
+ validationSeqByField.set(norm, seq);
1333
+
1334
+ const prevAbort = validationAbortByField.get(norm);
1335
+ if (prevAbort) {
1336
+ try {
1337
+ prevAbort.abort();
1338
+ } catch (_e) {}
1339
+ }
1340
+ const ac = new AbortController();
1341
+ validationAbortByField.set(norm, ac);
1342
+
1343
+ const validateUrl =
1344
+ vOpts.url ||
1345
+ (formEl.getAttribute && (formEl.getAttribute('data-w-validate-url') || formEl.getAttribute('action'))) ||
1346
+ (typeof window !== 'undefined' && window.location ? window.location.pathname : '/');
1347
+ const method = String(vOpts.method || (formEl.getAttribute && formEl.getAttribute('method')) || 'POST').toUpperCase();
1348
+
1349
+ const { data: allValues } = parseFormElement(formEl);
1350
+ const ctrl = formEl.querySelector ? (formEl.querySelector(`[name="${norm}"]`) || formEl.querySelector(`[name="${fieldName}"]`)) : null;
1351
+ const contextAttr = ctrl && ctrl.getAttribute ? ctrl.getAttribute('data-w-validate-context') : null;
1352
+
1353
+ let payload = { [norm]: allValues[norm] };
1354
+ if (vOpts.contextFields === '*' || contextAttr === '*') {
1355
+ payload = { ...allValues };
1356
+ } else {
1357
+ const ctxList = isArray(vOpts.contextFields)
1358
+ ? vOpts.contextFields
1359
+ : typeof contextAttr === 'string' && contextAttr.trim()
1360
+ ? contextAttr.split(',').map((s) => s.trim()).filter(Boolean)
1361
+ : [];
1362
+ for (const cf of ctxList) {
1363
+ if (Object.prototype.hasOwnProperty.call(allValues, cf)) {
1364
+ payload[cf] = allValues[cf];
1365
+ }
1366
+ }
1367
+ }
1368
+
1369
+ try {
1370
+ const resp = await api.request(method, validateUrl, payload, {
1371
+ headers: { 'X-Webspresso-Validate-Fields': norm },
1372
+ signal: ac.signal,
1373
+ });
1374
+
1375
+ if (epoch !== validationEpoch || seq !== validationSeqByField.get(norm)) {
1376
+ return { ok: true, stale: true, valid: true, errors: {} };
1377
+ }
1378
+
1379
+ const respErrors = resp && resp.errors && typeof resp.errors === 'object' ? resp.errors : {};
1380
+ if (respErrors[norm]) {
1381
+ fieldErrors[norm] = String(respErrors[norm]);
1382
+ } else {
1383
+ delete fieldErrors[norm];
1384
+ }
1385
+ syncErrorsToDom(false);
1386
+ notifyStateChange();
1387
+ return {
1388
+ ok: true,
1389
+ valid: !fieldErrors[norm],
1390
+ errors: fieldErrors[norm] ? { [norm]: fieldErrors[norm] } : {},
1391
+ };
1392
+ } catch (err) {
1393
+ if (epoch !== validationEpoch || seq !== validationSeqByField.get(norm) || (err && err.isCanceled)) {
1394
+ return { ok: true, stale: true, canceled: Boolean(err && err.isCanceled), errors: {} };
1395
+ }
1396
+ // If server returned a 400/422 validation envelope, extract only this field's error
1397
+ if (err && err.kind === 'validation' && err.fieldErrors) {
1398
+ if (err.fieldErrors[norm]) {
1399
+ fieldErrors[norm] = String(err.fieldErrors[norm]);
1400
+ } else {
1401
+ delete fieldErrors[norm];
1402
+ }
1403
+ syncErrorsToDom(false);
1404
+ notifyStateChange();
1405
+ return { ok: true, valid: !fieldErrors[norm], errors: fieldErrors[norm] ? { [norm]: fieldErrors[norm] } : {} };
1406
+ }
1407
+ // Network or 5xx error: NEVER mark the field as invalid!
1408
+ if (typeof formEl.dispatchEvent === 'function' && typeof CustomEvent !== 'undefined') {
1409
+ formEl.dispatchEvent(
1410
+ new CustomEvent('w-form:validation-error', {
1411
+ bubbles: true,
1412
+ detail: { field: norm, error: err },
1413
+ })
1414
+ );
1415
+ }
1416
+ return { ok: false, networkError: true, error: err };
1417
+ } finally {
1418
+ if (validationAbortByField.get(norm) === ac) {
1419
+ validationAbortByField.delete(norm);
1420
+ }
1421
+ }
1422
+ }
1423
+
1424
+ function cancelUpload() {
1425
+ if (activeSubmitAbort) {
1426
+ try {
1427
+ activeSubmitAbort.abort();
1428
+ } catch (_e) {}
1429
+ activeSubmitAbort = null;
1430
+ return true;
1431
+ }
1432
+ return false;
1433
+ }
1434
+
1435
+ // Listen to native submit, focusout, and input/change events on `formEl`
1082
1436
  const onDomInput = (evt) => {
1083
1437
  inputRevision += 1;
1084
1438
  dirty = computeFormDirty(formEl, initialSnapshot);
1085
1439
  const target = evt && evt.target;
1086
1440
  if (target && target.getAttribute) {
1441
+ const type = (target.type || '').toLowerCase();
1087
1442
  const rawName = target.getAttribute('name');
1443
+ if (type === 'file' && evt.type === 'change' && rawName) {
1444
+ revokePreviewUrls(rawName);
1445
+ }
1088
1446
  if (rawName) {
1089
1447
  const norm = rawName.endsWith('[]') ? rawName.slice(0, -2) : rawName;
1448
+ if (evt.type === 'change' && (type === 'checkbox' || type === 'radio' || target.tagName === 'SELECT')) {
1449
+ touchedFields.add(norm);
1450
+ }
1090
1451
  if (fieldErrors[norm] || fieldErrors[rawName]) {
1091
1452
  delete fieldErrors[norm];
1092
1453
  delete fieldErrors[rawName];
1093
1454
  syncErrorsToDom(false);
1094
1455
  }
1456
+ if (evt.type === 'input') {
1457
+ const vCfg = parseFieldValidateConfig(target);
1458
+ if (vCfg && vCfg.debounceMs !== null) {
1459
+ const prevTimer = validationTimersByField.get(norm);
1460
+ if (prevTimer) clearTimeout(prevTimer);
1461
+ const t = setTimeout(() => {
1462
+ validationTimersByField.delete(norm);
1463
+ validateField(norm).catch(() => {});
1464
+ }, vCfg.debounceMs);
1465
+ validationTimersByField.set(norm, t);
1466
+ }
1467
+ }
1095
1468
  }
1096
1469
  }
1097
1470
  if (isDraftEnabled) {
@@ -1104,16 +1477,40 @@ function createFormHandle(formEl, options = {}) {
1104
1477
  notifyStateChange();
1105
1478
  };
1106
1479
 
1480
+ const onDomFocusOut = (evt) => {
1481
+ const target = evt && evt.target;
1482
+ if (!target || !target.getAttribute) return;
1483
+ const rawName = target.getAttribute('name');
1484
+ if (!rawName) return;
1485
+ const type = (target.type || '').toLowerCase();
1486
+ if (type === 'submit' || type === 'button' || type === 'reset' || type === 'hidden') return;
1487
+ const norm = rawName.endsWith('[]') ? rawName.slice(0, -2) : rawName;
1488
+ const wasTouched = touchedFields.has(norm);
1489
+ touchedFields.add(norm);
1490
+ if (!wasTouched) {
1491
+ syncErrorsToDom(false);
1492
+ notifyStateChange();
1493
+ }
1494
+ const vCfg = parseFieldValidateConfig(target);
1495
+ if (vCfg && vCfg.onBlur) {
1496
+ validateField(norm).catch(() => {});
1497
+ }
1498
+ };
1499
+
1107
1500
  const onDomSubmit = (evt) => {
1108
1501
  submit(evt);
1109
1502
  };
1110
1503
 
1111
1504
  const onDomReset = () => {
1112
1505
  cancelDraftDebounce();
1506
+ cancelFieldValidation();
1507
+ revokePreviewUrls();
1113
1508
  if (resetTimer) clearTimeout(resetTimer);
1114
1509
  resetTimer = setTimeout(() => {
1115
1510
  resetTimer = null;
1116
1511
  dirty = false;
1512
+ submitted = false;
1513
+ touchedFields.clear();
1117
1514
  fieldErrors = {};
1118
1515
  formError = null;
1119
1516
  syncErrorsToDom(false);
@@ -1124,6 +1521,7 @@ function createFormHandle(formEl, options = {}) {
1124
1521
  if (formEl && typeof formEl.addEventListener === 'function') {
1125
1522
  formEl.addEventListener('input', onDomInput);
1126
1523
  formEl.addEventListener('change', onDomInput);
1524
+ formEl.addEventListener('focusout', onDomFocusOut);
1127
1525
  formEl.addEventListener('submit', onDomSubmit);
1128
1526
  formEl.addEventListener('reset', onDomReset);
1129
1527
  }
@@ -1149,6 +1547,54 @@ function createFormHandle(formEl, options = {}) {
1149
1547
  dirty = computeFormDirty(formEl, initialSnapshot);
1150
1548
  return dirty;
1151
1549
  },
1550
+ get touched() {
1551
+ return touchedFields.size > 0;
1552
+ },
1553
+ get touchedFields() {
1554
+ return Array.from(touchedFields);
1555
+ },
1556
+ get submitted() {
1557
+ return submitted;
1558
+ },
1559
+ isTouched(fieldName) {
1560
+ if (!fieldName) return touchedFields.size > 0;
1561
+ const norm = fieldName.endsWith('[]') ? fieldName.slice(0, -2) : fieldName;
1562
+ return touchedFields.has(norm) || touchedFields.has(fieldName);
1563
+ },
1564
+ touch(fieldName) {
1565
+ if (fieldName) {
1566
+ const norm = fieldName.endsWith('[]') ? fieldName.slice(0, -2) : fieldName;
1567
+ touchedFields.add(norm);
1568
+ } else if (formEl && typeof formEl.querySelectorAll === 'function') {
1569
+ const controls = formEl.querySelectorAll('input, select, textarea');
1570
+ for (let i = 0; i < controls.length; i++) {
1571
+ const n = controls[i].getAttribute && controls[i].getAttribute('name');
1572
+ if (n) touchedFields.add(n.endsWith('[]') ? n.slice(0, -2) : n);
1573
+ }
1574
+ }
1575
+ syncErrorsToDom(false);
1576
+ notifyStateChange();
1577
+ return handle;
1578
+ },
1579
+ untouch(fieldName) {
1580
+ if (fieldName) {
1581
+ const norm = fieldName.endsWith('[]') ? fieldName.slice(0, -2) : fieldName;
1582
+ touchedFields.delete(norm);
1583
+ touchedFields.delete(fieldName);
1584
+ } else {
1585
+ touchedFields.clear();
1586
+ }
1587
+ syncErrorsToDom(false);
1588
+ notifyStateChange();
1589
+ return handle;
1590
+ },
1591
+ validateField,
1592
+ get uploads() {
1593
+ return uploadEntries.map((u) => ({ ...u }));
1594
+ },
1595
+ cancelUpload,
1596
+ createPreviewUrl,
1597
+ revokePreviewUrls,
1152
1598
  get inputRevision() {
1153
1599
  return inputRevision;
1154
1600
  },
@@ -1249,6 +1695,9 @@ function createFormHandle(formEl, options = {}) {
1249
1695
  },
1250
1696
  destroy() {
1251
1697
  cancelDraftDebounce();
1698
+ cancelFieldValidation();
1699
+ cancelUpload();
1700
+ revokePreviewUrls();
1252
1701
  if (resetTimer) {
1253
1702
  clearTimeout(resetTimer);
1254
1703
  resetTimer = null;
@@ -1256,6 +1705,7 @@ function createFormHandle(formEl, options = {}) {
1256
1705
  if (formEl && typeof formEl.removeEventListener === 'function') {
1257
1706
  formEl.removeEventListener('input', onDomInput);
1258
1707
  formEl.removeEventListener('change', onDomInput);
1708
+ formEl.removeEventListener('focusout', onDomFocusOut);
1259
1709
  formEl.removeEventListener('submit', onDomSubmit);
1260
1710
  formEl.removeEventListener('reset', onDomReset);
1261
1711
  }