webspresso 0.0.108 → 0.0.110

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 (184) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -3
  3. package/bin/commands/add-esbuild.js +7 -3
  4. package/bin/commands/add-tailwind.js +9 -5
  5. package/bin/commands/build.js +20 -4
  6. package/bin/commands/dev.js +57 -7
  7. package/bin/commands/doctor.js +42 -1
  8. package/bin/commands/favicon-generate.js +10 -3
  9. package/bin/commands/new.js +8 -3
  10. package/bin/commands/start.js +3 -1
  11. package/bin/utils/add-command.js +16 -0
  12. package/core/app.js +45 -3
  13. package/core/auth/authenticate.js +3 -3
  14. package/core/auth/hash.js +167 -43
  15. package/core/auth/index.js +13 -1
  16. package/core/auth/jwt.js +21 -2
  17. package/core/auth/manager.js +63 -14
  18. package/core/auth/middleware.js +4 -2
  19. package/core/auth/oauth.js +5 -1
  20. package/core/auth/tokens.js +7 -2
  21. package/core/auth/totp.js +42 -6
  22. package/core/auth/user-agent.js +2 -0
  23. package/core/client/adapter/datastar.js +1401 -0
  24. package/core/client/api.js +1337 -0
  25. package/core/client/controller.js +1706 -0
  26. package/core/client/debug.js +367 -0
  27. package/core/client/devtool.js +1134 -0
  28. package/core/client/forms.js +1164 -0
  29. package/core/client/fragments.js +894 -0
  30. package/core/client/index.d.ts +59 -0
  31. package/core/client/index.js +391 -0
  32. package/core/client/lite.js +175 -0
  33. package/core/client/server-fragments.js +868 -0
  34. package/core/client/storage.js +339 -0
  35. package/core/client/utils.js +281 -0
  36. package/core/compression/middleware.js +277 -26
  37. package/core/context/app-context.js +25 -3
  38. package/core/context/request.js +32 -4
  39. package/core/errors/middleware.js +14 -0
  40. package/core/events/dispatcher.js +33 -0
  41. package/core/forms/flash.js +136 -102
  42. package/core/health/middleware.js +36 -6
  43. package/core/http/pipeline.js +45 -6
  44. package/core/index.js +25 -7
  45. package/core/kernel/view.js +41 -10
  46. package/core/metrics/middleware.js +30 -6
  47. package/core/metrics/registry.js +13 -5
  48. package/core/orm/cache/fingerprint.js +32 -11
  49. package/core/orm/cache/layer.js +19 -2
  50. package/core/orm/hidden.js +145 -0
  51. package/core/orm/model.js +48 -2
  52. package/core/orm/query-builder.js +343 -6
  53. package/core/orm/query-filters.js +14 -2
  54. package/core/orm/repository.js +64 -13
  55. package/core/orm/schema-helpers.js +9 -6
  56. package/core/orm/transaction.js +50 -6
  57. package/core/orm/types.js +4 -0
  58. package/core/queue/adapters/database.js +184 -2
  59. package/core/queue/adapters/memory.js +153 -5
  60. package/core/queue/adapters/redis.js +59 -4
  61. package/core/queue/job.js +14 -1
  62. package/core/queue/manager.js +115 -0
  63. package/core/resilience/circuit-breaker.js +27 -8
  64. package/core/services/builtins/exchange.js +174 -7
  65. package/core/services/builtins/file-manager.js +36 -0
  66. package/core/services/builtins/mail.js +27 -0
  67. package/core/services/builtins/queue.js +144 -0
  68. package/core/services/builtins/security.js +118 -0
  69. package/core/services/builtins/webhook.js +110 -0
  70. package/core/services/executor.js +38 -16
  71. package/core/services/index.js +4 -1
  72. package/core/sse/stream.js +28 -5
  73. package/core/utils/timeout.js +8 -1
  74. package/core/validation/compiler.js +10 -7
  75. package/index.d.ts +1320 -74
  76. package/index.js +128 -8
  77. package/package.json +75 -27
  78. package/plugins/admin-panel/client/parts/08-record-list.js +717 -70
  79. package/plugins/admin-panel/index.js +19 -8
  80. package/plugins/csrf/index.js +3 -3
  81. package/plugins/data-exchange/export.js +214 -0
  82. package/plugins/data-exchange/import.js +48 -5
  83. package/plugins/data-exchange/index.js +70 -28
  84. package/plugins/data-exchange/preview.js +266 -0
  85. package/plugins/data-exchange/template.js +242 -0
  86. package/plugins/email/admin-component.js +783 -141
  87. package/plugins/email/api-handlers.js +51 -1
  88. package/plugins/email/index.js +5 -0
  89. package/plugins/email/service.js +48 -0
  90. package/plugins/file-manager/admin-component.js +1546 -70
  91. package/plugins/file-manager/api-handlers.js +166 -1
  92. package/plugins/file-manager/core.js +598 -8
  93. package/plugins/file-manager/index.js +8 -0
  94. package/plugins/index.js +7 -0
  95. package/plugins/mcp/auth.js +3 -3
  96. package/plugins/oauth/index.js +36 -3
  97. package/plugins/queue/admin-component.js +1092 -0
  98. package/plugins/queue/api-handlers.js +248 -0
  99. package/plugins/queue/index.js +69 -0
  100. package/plugins/rest-resources/index.js +7 -2
  101. package/plugins/security/admin-component.js +689 -0
  102. package/plugins/security/api-handlers.js +169 -0
  103. package/plugins/security/audit.js +153 -0
  104. package/plugins/security/bruteforce.js +244 -0
  105. package/plugins/security/firewall.js +459 -0
  106. package/plugins/security/headers.js +214 -0
  107. package/plugins/security/honeypot.js +234 -0
  108. package/plugins/security/incident-recorder.js +137 -0
  109. package/plugins/security/index.js +211 -0
  110. package/plugins/seo-checker/analyzer.js +20 -7
  111. package/plugins/upload/index.js +36 -0
  112. package/plugins/webhooks/admin-component.js +702 -175
  113. package/plugins/webhooks/api-handlers.js +75 -1
  114. package/plugins/webhooks/dispatcher.js +290 -11
  115. package/plugins/webhooks/index.js +37 -2
  116. package/plugins/webhooks/sender.js +56 -0
  117. package/plugins/xlsx/engine.js +19 -4
  118. package/src/api/api-loader.js +19 -1
  119. package/src/bundle/index.js +443 -17
  120. package/src/client-runtime/bootstrap-swup.js +9 -0
  121. package/src/client-runtime/mount.js +15 -20
  122. package/src/client-runtime/resolve.js +3 -2
  123. package/src/dev/api-typegen.js +1300 -0
  124. package/src/dev/banner.js +10 -1
  125. package/src/dev/colors.js +1 -1
  126. package/src/dev/index.js +7 -1
  127. package/src/dev/typegen.js +173 -17
  128. package/src/dev-reload/index.js +1 -1
  129. package/src/discovery/file-route-parser.js +17 -3
  130. package/src/discovery/scan-routes.js +4 -1
  131. package/src/file-router.js +253 -69
  132. package/src/helpers.js +46 -26
  133. package/src/njk-frontmatter.js +30 -4
  134. package/src/routing/cascading.js +26 -8
  135. package/src/routing/mount-discovered-routes.js +76 -0
  136. package/src/server.js +231 -70
  137. package/views/partials/webspresso-client-runtime.njk +1 -9
  138. package/.agents/ADMIN.md +0 -426
  139. package/.agents/AGENTS.md +0 -96
  140. package/.agents/ARCHITECTURE.md +0 -163
  141. package/.agents/AUTH.md +0 -581
  142. package/.agents/CACHE.md +0 -148
  143. package/.agents/CLI.md +0 -649
  144. package/.agents/COMPRESSION.md +0 -59
  145. package/.agents/CONTEXT.md +0 -152
  146. package/.agents/CONVENTIONS.md +0 -50
  147. package/.agents/EARLY_HINTS.md +0 -120
  148. package/.agents/EMAIL.md +0 -223
  149. package/.agents/ERRORS.md +0 -260
  150. package/.agents/EVENTS.md +0 -179
  151. package/.agents/FLAGS.md +0 -118
  152. package/.agents/HEALTH.md +0 -118
  153. package/.agents/LIMITS.md +0 -109
  154. package/.agents/LOGGING.md +0 -93
  155. package/.agents/MCP.md +0 -103
  156. package/.agents/METRICS.md +0 -127
  157. package/.agents/MIGRATION_JOBS.md +0 -95
  158. package/.agents/ORM.md +0 -700
  159. package/.agents/PLUGINS.md +0 -729
  160. package/.agents/QUEUE.md +0 -290
  161. package/.agents/RATE_LIMIT.md +0 -147
  162. package/.agents/REALTIME.md +0 -213
  163. package/.agents/RESILIENCE.md +0 -235
  164. package/.agents/ROUTING.md +0 -605
  165. package/.agents/SCALE_OUT.md +0 -109
  166. package/.agents/SECRETS.md +0 -117
  167. package/.agents/SERVICES.md +0 -341
  168. package/.agents/SHUTDOWN.md +0 -74
  169. package/.agents/STREAMING.md +0 -108
  170. package/.agents/WORKFLOW.md +0 -27
  171. package/.agents/internal/DOCUMENTATION_REVIEW.md +0 -485
  172. package/.agents/internal/TECHNICAL_REVIEW.md +0 -428
  173. package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +0 -122
  174. package/.agents/skills/webspresso-framework/SKILL.md +0 -89
  175. package/.agents/skills/webspresso-orm-and-database/SKILL.md +0 -127
  176. package/.agents/skills/webspresso-routing-and-api/SKILL.md +0 -130
  177. package/.agents/skills/webspresso-services/SKILL.md +0 -134
  178. package/.cursorrules +0 -14
  179. package/AGENTS.md +0 -203
  180. package/CLAUDE.md +0 -54
  181. package/core/kernel/run-demo.js +0 -58
  182. package/plugins/admin-panel/styles.js +0 -9
  183. package/plugins/data-exchange/export-xlsx.js +0 -119
  184. package/src/client-runtime/bootstrap-alpine-swup.js +0 -34
@@ -0,0 +1,1164 @@
1
+ 'use strict';
2
+
3
+ const { defaultStorage } = require('./storage');
4
+
5
+ /**
6
+ * Webspresso Native HTML Form Layer & Typed Form Handle
7
+ *
8
+ * Principles:
9
+ * - Built on native HTML <form action="/api/..." method="post" data-w-form="name"> semantics
10
+ * - Zero client JS required for standard form submission, validation error display, and fragment invalidation
11
+ * - Explicit, deterministic form-to-payload conversion rules
12
+ * - Starts with native constraint validation (`form.reportValidity()`); server validation remains authoritative
13
+ * - Accessible field error binding (`aria-invalid`, `aria-describedby`) and focus on first invalid field
14
+ * - Separation of `reset()` (restores initial default values) vs `clear()` (empties all controls)
15
+ * - Mutation success vs refresh failure separation: a refresh failure never puts a succeeded form into validation error state
16
+ */
17
+
18
+ /**
19
+ * Validate that a redirect URL uses a safe protocol (http/https) or relative path
20
+ * to prevent DOM-based XSS (e.g. javascript: URIs) and protocol injection.
21
+ *
22
+ * @param {string} target
23
+ * @returns {boolean}
24
+ */
25
+ function isSafeRedirectUrl(target) {
26
+ if (!target || typeof target !== 'string') return false;
27
+ const trimmed = target.trim();
28
+ if (!trimmed) return false;
29
+
30
+ // Block control characters
31
+ if (/[\u0000-\u001F\u007F-\u009F]/.test(trimmed)) return false;
32
+
33
+ // Block protocol-relative URLs (//example.com) and backslash traversal (/\ or \\)
34
+ if (trimmed.startsWith('//') || trimmed.startsWith('/\\') || trimmed.startsWith('\\')) {
35
+ return false;
36
+ }
37
+
38
+ // Relative path starting with single slash (e.g. /dashboard)
39
+ if (trimmed.startsWith('/')) {
40
+ return true;
41
+ }
42
+
43
+ // Absolute URL: MUST explicitly parse as http: or https:
44
+ try {
45
+ const parsed = new URL(trimmed);
46
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ let errorIdSeq = 0;
53
+
54
+ /**
55
+ * Check if a form control is disabled (either directly or via an ancestor <fieldset disabled>).
56
+ * Note: Per HTML spec, controls inside the first <legend> of a disabled <fieldset> remain enabled.
57
+ *
58
+ * @param {Element} el
59
+ * @returns {boolean}
60
+ */
61
+ function isControlDisabled(el) {
62
+ if (!el) return true;
63
+ if (el.disabled) return true;
64
+ let parent = el.parentElement;
65
+ while (parent) {
66
+ if (parent.tagName === 'FIELDSET' && parent.disabled) {
67
+ // Check if el is inside the first <legend> child of this fieldset
68
+ let firstLegend = null;
69
+ for (let i = 0; i < parent.children.length; i++) {
70
+ if (parent.children[i].tagName === 'LEGEND') {
71
+ firstLegend = parent.children[i];
72
+ break;
73
+ }
74
+ }
75
+ if (!firstLegend || !firstLegend.contains(el)) {
76
+ return true;
77
+ }
78
+ }
79
+ parent = parent.parentElement;
80
+ }
81
+ return false;
82
+ }
83
+
84
+ /**
85
+ * Coerce a single raw string value according to control type and `data-w-*` hints.
86
+ *
87
+ * @param {Element} el
88
+ * @param {string} rawValue
89
+ * @returns {unknown}
90
+ */
91
+ function coerceScalarControlValue(el, rawValue) {
92
+ const inputType = ((el.getAttribute && el.getAttribute('type')) || el.type || 'text').toLowerCase();
93
+ const dataType = ((el.getAttribute && el.getAttribute('data-w-type')) || '').toLowerCase();
94
+ const emptyMode = ((el.getAttribute && el.getAttribute('data-w-empty')) || '').toLowerCase();
95
+
96
+ if (rawValue === '') {
97
+ if (emptyMode === 'null') return null;
98
+ if (emptyMode === 'undefined' || emptyMode === 'omit') return undefined;
99
+ if (inputType === 'number' || inputType === 'range' || dataType === 'number') {
100
+ return null;
101
+ }
102
+ return '';
103
+ }
104
+
105
+ if (inputType === 'number' || inputType === 'range' || dataType === 'number') {
106
+ const num = Number(rawValue);
107
+ return Number.isNaN(num) ? rawValue : num;
108
+ }
109
+
110
+ if (dataType === 'boolean') {
111
+ if (rawValue === 'true' || rawValue === '1' || rawValue === 'on') return true;
112
+ if (rawValue === 'false' || rawValue === '0' || rawValue === 'off') return false;
113
+ return Boolean(rawValue);
114
+ }
115
+
116
+ return rawValue;
117
+ }
118
+
119
+ /**
120
+ * Parse a `<form>` element into a structured payload and transport descriptor.
121
+ *
122
+ * Explicit Coercion Rules:
123
+ * 1. `disabled` controls (including inside disabled `<fieldset>`) are excluded.
124
+ * 2. `submitter`: if a submit button with `name` triggered the submit and is not disabled, its `name`/`value` is included.
125
+ * 3. `checkbox`:
126
+ * - Single checkbox for a given `name`:
127
+ * - Boolean checkbox (no custom `value` attribute, or `value="true"|"on"`, or `data-w-type="boolean"`): `true` when checked, `false` when unchecked.
128
+ * - Custom value checkbox (e.g. `value="yes"`): `"yes"` when checked, `null` (or `data-w-unchecked` value) when unchecked.
129
+ * - Multiple checkboxes sharing the same `name` (or `name="tags[]"`): `Array` of checked values (`[]` when none checked).
130
+ * 4. `number` / `range`: coerced to `number` (`null` when empty, or `undefined` with `data-w-empty="undefined"`).
131
+ * 5. `empty string`: preserved as `""` for text controls unless `data-w-empty="null|undefined"` is specified.
132
+ * 6. `repeated field names`: collected into an `Array` in DOM order. Trailing `[]` in `name="tags[]"` is normalized to `tags`.
133
+ * 7. `select[multiple]`: always returns an `Array` of selected option values (`[]` when none selected).
134
+ * 8. `file`:
135
+ * - Empty file input (`files.length === 0` or single 0-byte unnamed file): omitted (`undefined`).
136
+ * - `input[ type="file" multiple ]`: `File[]`.
137
+ * - Single `input[type="file"]`: `File`.
138
+ * - When any non-empty `File` is present or `form.enctype === 'multipart/form-data'`, `isMultipart` is `true` and `formData` is populated.
139
+ *
140
+ * @param {HTMLFormElement} formEl
141
+ * @param {HTMLElement|null} [submitter]
142
+ * @returns {{ data: Record<string, unknown>, formData: FormData|null, isMultipart: boolean }}
143
+ */
144
+ function parseFormElement(formEl, submitterOrOptions = null) {
145
+ if (!formEl || typeof formEl.querySelectorAll !== 'function') {
146
+ return { data: {}, values: {}, formData: null, isMultipart: false };
147
+ }
148
+
149
+ const submitter =
150
+ submitterOrOptions &&
151
+ typeof submitterOrOptions === 'object' &&
152
+ !submitterOrOptions.nodeType &&
153
+ !('tagName' in submitterOrOptions) &&
154
+ 'submitter' in submitterOrOptions
155
+ ? submitterOrOptions.submitter
156
+ : submitterOrOptions;
157
+
158
+ const rawControls = Array.from(
159
+ formEl.querySelectorAll('input, select, textarea')
160
+ );
161
+
162
+ // Filter out disabled controls and button/submit/reset inputs (handled via submitter)
163
+ const controls = rawControls.filter((el) => {
164
+ if (isControlDisabled(el)) return false;
165
+ const rawName = el.getAttribute('name');
166
+ if (!rawName || !rawName.trim()) return false;
167
+ const type = (el.type || '').toLowerCase();
168
+ if (type === 'submit' || type === 'button' || type === 'reset' || type === 'image') {
169
+ return false;
170
+ }
171
+ return true;
172
+ });
173
+
174
+ // Count occurrences per normalized field name and per checkbox/radio group
175
+ const nameCounts = new Map();
176
+ const checkboxCounts = new Map();
177
+ const radioNames = new Set();
178
+
179
+ function normalizeFieldName(rawName) {
180
+ const trimmed = String(rawName || '').trim();
181
+ return trimmed.endsWith('[]') ? trimmed.slice(0, -2) : trimmed;
182
+ }
183
+
184
+ for (const el of controls) {
185
+ const rawName = el.getAttribute('name').trim();
186
+ const normName = normalizeFieldName(rawName);
187
+ const isExplicitArray = rawName.endsWith('[]');
188
+ const type = (el.type || '').toLowerCase();
189
+
190
+ if (type === 'checkbox') {
191
+ checkboxCounts.set(normName, (checkboxCounts.get(normName) || 0) + (isExplicitArray ? 2 : 1));
192
+ } else if (type === 'radio') {
193
+ radioNames.add(normName);
194
+ } else {
195
+ nameCounts.set(normName, (nameCounts.get(normName) || 0) + (isExplicitArray ? 2 : 1));
196
+ }
197
+ }
198
+
199
+ const data = {};
200
+ let hasBinaryFile = false;
201
+
202
+ function appendValue(normName, val, forceArray) {
203
+ if (val === undefined) return;
204
+ if (forceArray) {
205
+ if (!Array.isArray(data[normName])) {
206
+ data[normName] = [];
207
+ }
208
+ data[normName].push(val);
209
+ } else if (normName in data) {
210
+ if (!Array.isArray(data[normName])) {
211
+ data[normName] = [data[normName]];
212
+ }
213
+ data[normName].push(val);
214
+ } else {
215
+ data[normName] = val;
216
+ }
217
+ }
218
+
219
+ // Initialize multi-checkbox groups to [] and radio groups to null if none checked
220
+ for (const [normName, count] of checkboxCounts.entries()) {
221
+ if (count > 1) {
222
+ data[normName] = [];
223
+ }
224
+ }
225
+ for (const normName of radioNames) {
226
+ if (!(normName in data)) {
227
+ data[normName] = null;
228
+ }
229
+ }
230
+
231
+ for (const el of controls) {
232
+ const rawName = el.getAttribute('name').trim();
233
+ const normName = normalizeFieldName(rawName);
234
+ const isExplicitArray = rawName.endsWith('[]');
235
+ const tag = el.tagName;
236
+ const type = (el.type || '').toLowerCase();
237
+
238
+ if (tag === 'SELECT' && el.multiple) {
239
+ const selectedValues = [];
240
+ const options = Array.from(el.options || []);
241
+ for (const opt of options) {
242
+ if (opt.selected && !opt.disabled) {
243
+ selectedValues.push(coerceScalarControlValue(el, opt.value));
244
+ }
245
+ }
246
+ data[normName] = selectedValues;
247
+ continue;
248
+ }
249
+
250
+ if (type === 'checkbox') {
251
+ const totalCheckboxes = checkboxCounts.get(normName) || 1;
252
+ if (totalCheckboxes > 1 || isExplicitArray) {
253
+ if (el.checked) {
254
+ const valAttr = el.getAttribute('value');
255
+ const rawVal = valAttr !== null ? valAttr : 'on';
256
+ data[normName].push(coerceScalarControlValue(el, rawVal));
257
+ }
258
+ } else {
259
+ const valAttr = el.getAttribute('value');
260
+ const dataType = (el.getAttribute('data-w-type') || '').toLowerCase();
261
+ const isBooleanCheckbox =
262
+ dataType === 'boolean' ||
263
+ valAttr === null ||
264
+ valAttr === '' ||
265
+ valAttr === 'on' ||
266
+ valAttr === 'true';
267
+
268
+ if (isBooleanCheckbox) {
269
+ data[normName] = Boolean(el.checked);
270
+ } else if (el.checked) {
271
+ data[normName] = coerceScalarControlValue(el, valAttr);
272
+ } else {
273
+ const uncheckedAttr = el.getAttribute('data-w-unchecked');
274
+ data[normName] = uncheckedAttr !== null ? uncheckedAttr : null;
275
+ }
276
+ }
277
+ continue;
278
+ }
279
+
280
+ if (type === 'radio') {
281
+ if (el.checked) {
282
+ data[normName] = coerceScalarControlValue(el, el.value);
283
+ }
284
+ continue;
285
+ }
286
+
287
+ if (type === 'file') {
288
+ const files = el.files ? Array.from(el.files).filter((f) => f && (f.size > 0 || f.name)) : [];
289
+ if (files.length > 0) {
290
+ hasBinaryFile = true;
291
+ if (el.multiple || isExplicitArray || (nameCounts.get(normName) || 0) > 1) {
292
+ appendValue(normName, files.length === 1 && !el.multiple ? files[0] : files, !el.multiple);
293
+ if (el.multiple) {
294
+ data[normName] = files;
295
+ }
296
+ } else {
297
+ data[normName] = files[0];
298
+ }
299
+ }
300
+ continue;
301
+ }
302
+
303
+ const totalForName = nameCounts.get(normName) || 1;
304
+ const coerced = coerceScalarControlValue(el, el.value !== undefined ? String(el.value) : '');
305
+ appendValue(normName, coerced, totalForName > 1 || isExplicitArray);
306
+ }
307
+
308
+ // Include submitter name/value if provided and enabled
309
+ if (submitter && !isControlDisabled(submitter)) {
310
+ const subName = submitter.getAttribute ? submitter.getAttribute('name') : submitter.name;
311
+ if (subName && String(subName).trim()) {
312
+ const normSubName = normalizeFieldName(subName);
313
+ const subValue =
314
+ (submitter.getAttribute && submitter.getAttribute('value')) ??
315
+ submitter.value ??
316
+ '';
317
+ data[normSubName] = coerceScalarControlValue(submitter, String(subValue));
318
+ }
319
+ }
320
+
321
+ const enctype = ((formEl.getAttribute && formEl.getAttribute('enctype')) || formEl.enctype || '').toLowerCase();
322
+ const isMultipart = hasBinaryFile || enctype === 'multipart/form-data';
323
+
324
+ let formData = null;
325
+ if (isMultipart && typeof FormData !== 'undefined') {
326
+ formData = new FormData();
327
+ for (const [key, val] of Object.entries(data)) {
328
+ if (val === undefined) continue;
329
+ if (Array.isArray(val)) {
330
+ for (const item of val) {
331
+ if (item === undefined) continue;
332
+ if (typeof Blob !== 'undefined' && item instanceof Blob) {
333
+ formData.append(key, item);
334
+ } else if (item !== null) {
335
+ formData.append(key, String(item));
336
+ }
337
+ }
338
+ } else if (typeof Blob !== 'undefined' && val instanceof Blob) {
339
+ formData.append(key, val);
340
+ } else if (val === null) {
341
+ formData.append(key, '');
342
+ } else {
343
+ formData.append(key, String(val));
344
+ }
345
+ }
346
+ }
347
+
348
+ return { data, values: data, formData, isMultipart };
349
+ }
350
+
351
+ /**
352
+ * Snapshot initial default values on a form so `reset()` restores them accurately
353
+ * and `dirty` can compare current values against initial state.
354
+ */
355
+ function captureInitialFormSnapshot(formEl) {
356
+ const snapshot = new Map();
357
+ if (!formEl || typeof formEl.querySelectorAll !== 'function') return snapshot;
358
+ const controls = formEl.querySelectorAll('input, select, textarea');
359
+ for (let i = 0; i < controls.length; i++) {
360
+ const el = controls[i];
361
+ const type = (el.type || '').toLowerCase();
362
+ if (type === 'checkbox' || type === 'radio') {
363
+ snapshot.set(el, { checked: Boolean(el.checked) });
364
+ } else if (el.tagName === 'SELECT') {
365
+ const selectedIndices = [];
366
+ const opts = el.options || [];
367
+ for (let j = 0; j < opts.length; j++) {
368
+ if (opts[j].selected) selectedIndices.push(j);
369
+ }
370
+ snapshot.set(el, { selectedIndices, value: el.value });
371
+ } else if (type !== 'file') {
372
+ snapshot.set(el, { value: el.value !== undefined ? String(el.value) : '' });
373
+ }
374
+ }
375
+ return snapshot;
376
+ }
377
+
378
+ /**
379
+ * Check if any control in `formEl` differs from its captured initial snapshot.
380
+ */
381
+ function computeFormDirty(formEl, initialSnapshot) {
382
+ if (!formEl || typeof formEl.querySelectorAll !== 'function') return false;
383
+ const controls = formEl.querySelectorAll('input, select, textarea');
384
+ for (let i = 0; i < controls.length; i++) {
385
+ const el = controls[i];
386
+ const type = (el.type || '').toLowerCase();
387
+ if (type === 'submit' || type === 'button' || type === 'reset' || type === 'hidden') continue;
388
+ const init = initialSnapshot.get(el);
389
+ if (type === 'checkbox' || type === 'radio') {
390
+ const expected = init ? init.checked : Boolean(el.defaultChecked);
391
+ if (Boolean(el.checked) !== expected) return true;
392
+ } else if (el.tagName === 'SELECT') {
393
+ if (init) {
394
+ if (el.value !== init.value) return true;
395
+ }
396
+ } else if (type === 'file') {
397
+ if (el.files && el.files.length > 0) return true;
398
+ } else {
399
+ const expected = init ? init.value : (el.defaultValue || '');
400
+ if (String(el.value || '') !== expected) return true;
401
+ }
402
+ }
403
+ return false;
404
+ }
405
+
406
+ /**
407
+ * Create a Form Handle managing a `<form>` element's lifecycle, validation errors,
408
+ * accessibility attributes, pending lock, and submission.
409
+ *
410
+ * @param {HTMLFormElement} formEl
411
+ * @param {object} options
412
+ * @param {object} options.api - Typed API client instance
413
+ * @param {object} [options.datastar] - Datastar adapter for reactive signals
414
+ * @param {(url: string) => void} [options.navigate] - Navigation handler for explicit redirects
415
+ * @param {object} [options.debug] - Client debugger
416
+ */
417
+ function createFormHandle(formEl, options = {}) {
418
+ if (formEl && formEl.__wFormHandle) {
419
+ return formEl.__wFormHandle;
420
+ }
421
+
422
+ const api = options.api;
423
+ const debug = options.debug || null;
424
+ const formName =
425
+ (formEl && formEl.getAttribute && formEl.getAttribute('data-w-form')) ||
426
+ (formEl && formEl.getAttribute && formEl.getAttribute('name')) ||
427
+ 'form';
428
+
429
+ let initialSnapshot = captureInitialFormSnapshot(formEl);
430
+ let pending = false;
431
+ let dirty = false;
432
+ let fieldErrors = {};
433
+ let formError = null;
434
+ let lastData = null;
435
+
436
+ const successListeners = new Set();
437
+ const errorListeners = new Set();
438
+ const stateListeners = new Set();
439
+
440
+ // Form draft persistence setup (sessionStorage by default, or localStorage)
441
+ const rawDraftAttr = formEl
442
+ ? formEl.getAttribute('data-w-persist-draft') || formEl.getAttribute('data-w-draft')
443
+ : null;
444
+ const isDraftEnabled =
445
+ options.persistDraft !== undefined
446
+ ? Boolean(options.persistDraft)
447
+ : rawDraftAttr !== null && rawDraftAttr !== 'false';
448
+
449
+ let draftStorageType = 'session';
450
+ if (typeof options.persistDraft === 'string') {
451
+ draftStorageType = options.persistDraft.toLowerCase() === 'local' ? 'local' : 'session';
452
+ } else if (rawDraftAttr && rawDraftAttr.toLowerCase() === 'local') {
453
+ draftStorageType = 'local';
454
+ }
455
+
456
+ const draftKey =
457
+ options.draftKey ||
458
+ (formEl && (formEl.getAttribute('data-w-draft-key') || formEl.getAttribute('data-w-persist-key'))) ||
459
+ `form_draft:${formName || 'anon'}`;
460
+
461
+ const draftStorage =
462
+ (options.storage && options.storage[draftStorageType]) ||
463
+ (draftStorageType === 'local' ? defaultStorage.local : defaultStorage.session);
464
+
465
+ function extractDraftValues(el) {
466
+ if (!el || typeof el.querySelectorAll !== 'function') return {};
467
+ const { data } = parseFormElement(el);
468
+ const safeData = {};
469
+ for (const [key, val] of Object.entries(data)) {
470
+ const ctrl = el.querySelector(`[name="${key}"], [name="${key}[]"]`);
471
+ if (!ctrl) {
472
+ safeData[key] = val;
473
+ continue;
474
+ }
475
+ const type = (ctrl.type || '').toLowerCase();
476
+ if (type === 'password' || type === 'file') continue;
477
+ if (ctrl.hasAttribute('data-w-no-draft') || ctrl.getAttribute('data-w-persist') === 'false') continue;
478
+ safeData[key] = val;
479
+ }
480
+ return safeData;
481
+ }
482
+
483
+ function saveDraft() {
484
+ if (!formEl || !isDraftEnabled) return false;
485
+ const draftPayload = extractDraftValues(formEl);
486
+ return draftStorage.set(draftKey, draftPayload);
487
+ }
488
+
489
+ function restoreDraft() {
490
+ if (!formEl || !isDraftEnabled) return false;
491
+ const saved = draftStorage.get(draftKey, null);
492
+ if (saved && typeof saved === 'object' && Object.keys(saved).length > 0) {
493
+ fill(saved, { baseline: false });
494
+ if (typeof formEl.dispatchEvent === 'function') {
495
+ const CustomEventCtor =
496
+ (formEl.ownerDocument && formEl.ownerDocument.defaultView && formEl.ownerDocument.defaultView.CustomEvent) ||
497
+ (typeof CustomEvent !== 'undefined' ? CustomEvent : null);
498
+ if (CustomEventCtor) {
499
+ formEl.dispatchEvent(
500
+ new CustomEventCtor('w-form:draft-restored', {
501
+ bubbles: true,
502
+ cancelable: true,
503
+ detail: { form: handle, draft: saved, key: draftKey },
504
+ })
505
+ );
506
+ }
507
+ }
508
+ return true;
509
+ }
510
+ return false;
511
+ }
512
+
513
+ function clearDraft() {
514
+ if (!isDraftEnabled) return false;
515
+ return draftStorage.remove(draftKey);
516
+ }
517
+
518
+ function hasDraft() {
519
+ if (!isDraftEnabled) return false;
520
+ return draftStorage.has(draftKey);
521
+ }
522
+
523
+ function notifyStateChange() {
524
+ for (const fn of stateListeners) {
525
+ try {
526
+ fn(handle);
527
+ } catch (_e) {
528
+ // Ignore
529
+ }
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Render field errors and form-level error into the DOM with accessible ARIA bindings.
535
+ * Optionally focuses the first invalid control.
536
+ */
537
+ function syncErrorsToDom(focusFirstInvalid = false) {
538
+ if (!formEl || typeof formEl.querySelectorAll !== 'function') return;
539
+
540
+ // 1. Clear previous error elements and aria-invalid states
541
+ const errorSpans = formEl.querySelectorAll('[data-w-error]');
542
+ for (let i = 0; i < errorSpans.length; i++) {
543
+ const span = errorSpans[i];
544
+ const fieldName = span.getAttribute('data-w-error');
545
+ const msg = fieldName && fieldErrors[fieldName] ? String(fieldErrors[fieldName]) : '';
546
+ span.textContent = msg;
547
+ if (msg) {
548
+ span.removeAttribute('hidden');
549
+ if (!span.id) {
550
+ span.id = `w-err-${formName}-${fieldName}-${++errorIdSeq}`;
551
+ }
552
+ if (!span.getAttribute('role')) {
553
+ span.setAttribute('role', 'alert');
554
+ }
555
+ } else {
556
+ span.setAttribute('hidden', '');
557
+ }
558
+ }
559
+
560
+ // 2. Bind aria-invalid and aria-describedby on form controls, and find first invalid control
561
+ let firstInvalidControl = null;
562
+ const controls = formEl.querySelectorAll('input, select, textarea');
563
+ for (let i = 0; i < controls.length; i++) {
564
+ const ctrl = controls[i];
565
+ const rawName = ctrl.getAttribute('name');
566
+ if (!rawName) continue;
567
+ const normName = rawName.endsWith('[]') ? rawName.slice(0, -2) : rawName;
568
+ const errMsg = fieldErrors[normName] || fieldErrors[rawName];
569
+
570
+ if (errMsg) {
571
+ ctrl.setAttribute('aria-invalid', 'true');
572
+ const errSpan =
573
+ formEl.querySelector(`[data-w-error="${normName}"]`) ||
574
+ formEl.querySelector(`[data-w-error="${rawName}"]`);
575
+ if (errSpan && errSpan.id) {
576
+ ctrl.setAttribute('aria-describedby', errSpan.id);
577
+ }
578
+ if (!firstInvalidControl && typeof ctrl.focus === 'function' && !isControlDisabled(ctrl)) {
579
+ firstInvalidControl = ctrl;
580
+ }
581
+ } else {
582
+ ctrl.removeAttribute('aria-invalid');
583
+ const desc = ctrl.getAttribute('aria-describedby');
584
+ if (desc && desc.startsWith('w-err-')) {
585
+ ctrl.removeAttribute('aria-describedby');
586
+ }
587
+ }
588
+ }
589
+
590
+ // 3. Form-level error element `[data-w-form-error]`
591
+ const formErrorEl = formEl.querySelector('[data-w-form-error]');
592
+ if (formErrorEl) {
593
+ if (formError) {
594
+ formErrorEl.textContent = String(formError);
595
+ formErrorEl.removeAttribute('hidden');
596
+ if (!formErrorEl.getAttribute('role')) {
597
+ formErrorEl.setAttribute('role', 'alert');
598
+ }
599
+ } else {
600
+ formErrorEl.textContent = '';
601
+ formErrorEl.setAttribute('hidden', '');
602
+ }
603
+ }
604
+
605
+ if (focusFirstInvalid && firstInvalidControl) {
606
+ try {
607
+ firstInvalidControl.focus();
608
+ } catch (_e) {
609
+ // Ignore focus errors in headless environments
610
+ }
611
+ }
612
+ }
613
+
614
+ /**
615
+ * Toggle pending state on the form and submit buttons to prevent double submissions.
616
+ */
617
+ function setPendingState(nextPending, activeSubmitter = null) {
618
+ pending = Boolean(nextPending);
619
+ if (!formEl) return;
620
+
621
+ if (pending) {
622
+ formEl.setAttribute('aria-busy', 'true');
623
+ formEl.setAttribute('data-w-pending', 'true');
624
+ } else {
625
+ formEl.removeAttribute('aria-busy');
626
+ formEl.removeAttribute('data-w-pending');
627
+ }
628
+
629
+ const submitButtons = formEl.querySelectorAll
630
+ ? formEl.querySelectorAll('button[type="submit"], button:not([type]), input[type="submit"]')
631
+ : [];
632
+ for (let i = 0; i < submitButtons.length; i++) {
633
+ const btn = submitButtons[i];
634
+ if (btn.hasAttribute('data-w-disabled')) continue; // Managed by controller binding
635
+ if (pending) {
636
+ btn.__wWasDisabled = Boolean(btn.disabled);
637
+ btn.disabled = true;
638
+ } else if (!btn.__wWasDisabled) {
639
+ btn.disabled = false;
640
+ }
641
+ }
642
+ if (activeSubmitter && !activeSubmitter.hasAttribute('data-w-disabled')) {
643
+ activeSubmitter.disabled = pending;
644
+ }
645
+ notifyStateChange();
646
+ }
647
+
648
+ /**
649
+ * Reset form controls back to their initial default values (NOT emptying non-empty defaults).
650
+ */
651
+ function reset() {
652
+ if (!formEl) return;
653
+ if (typeof formEl.reset === 'function') {
654
+ formEl.reset();
655
+ }
656
+ // Restore exact captured initial snapshot
657
+ for (const [el, init] of initialSnapshot.entries()) {
658
+ const type = (el.type || '').toLowerCase();
659
+ if (type === 'checkbox' || type === 'radio') {
660
+ el.checked = init.checked;
661
+ } else if (el.tagName === 'SELECT') {
662
+ const opts = el.options || [];
663
+ for (let j = 0; j < opts.length; j++) {
664
+ opts[j].selected = init.selectedIndices.includes(j);
665
+ }
666
+ } else if (type !== 'file') {
667
+ el.value = init.value;
668
+ }
669
+ }
670
+ dirty = false;
671
+ fieldErrors = {};
672
+ formError = null;
673
+ syncErrorsToDom(false);
674
+ notifyStateChange();
675
+ }
676
+
677
+ /**
678
+ * Completely clear all user-editable controls to empty values (distinct from `reset()`).
679
+ */
680
+ function clear() {
681
+ if (!formEl || typeof formEl.querySelectorAll !== 'function') return;
682
+ const controls = formEl.querySelectorAll('input, select, textarea');
683
+ for (let i = 0; i < controls.length; i++) {
684
+ const el = controls[i];
685
+ const type = (el.type || '').toLowerCase();
686
+ if (type === 'submit' || type === 'button' || type === 'reset' || type === 'hidden') continue;
687
+ if (type === 'checkbox' || type === 'radio') {
688
+ el.checked = false;
689
+ } else if (el.tagName === 'SELECT') {
690
+ const opts = el.options || [];
691
+ for (let j = 0; j < opts.length; j++) {
692
+ opts[j].selected = false;
693
+ }
694
+ if (!el.multiple && opts.length > 0) {
695
+ el.value = '';
696
+ }
697
+ } else {
698
+ el.value = '';
699
+ }
700
+ }
701
+ dirty = computeFormDirty(formEl, initialSnapshot);
702
+ fieldErrors = {};
703
+ formError = null;
704
+ syncErrorsToDom(false);
705
+ notifyStateChange();
706
+ }
707
+
708
+ /**
709
+ * Programmatically fill form fields.
710
+ *
711
+ * @param {Record<string, unknown>} values - Object containing field name -> value mappings
712
+ * @param {object} [options]
713
+ * @param {boolean} [options.baseline=false] - If true, treats the filled values as the new baseline
714
+ * snapshot, clears `dirty`, and clears previous validation errors.
715
+ */
716
+ function fill(values = {}, options = {}) {
717
+ if (!formEl || typeof formEl.querySelectorAll !== 'function' || !values || typeof values !== 'object') {
718
+ return handle;
719
+ }
720
+
721
+ const isBaseline = Boolean(options.baseline);
722
+
723
+ for (const [key, val] of Object.entries(values)) {
724
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
725
+
726
+ const normKey = key.endsWith('[]') ? key.slice(0, -2) : key;
727
+ const matchingControls = Array.from(
728
+ formEl.querySelectorAll(`[name="${key}"], [name="${normKey}"], [name="${normKey}[]"]`)
729
+ );
730
+
731
+ for (const ctrl of matchingControls) {
732
+ if (!ctrl || ctrl.tagName === 'BUTTON') continue;
733
+ const type = (ctrl.type || '').toLowerCase();
734
+ if (type === 'file') continue; // Browser security prevents setting file input value
735
+
736
+ if (type === 'checkbox') {
737
+ if (Array.isArray(val)) {
738
+ ctrl.checked = val.map(String).includes(String(ctrl.value));
739
+ } else {
740
+ ctrl.checked = Boolean(val);
741
+ }
742
+ } else if (type === 'radio') {
743
+ ctrl.checked = String(val) === String(ctrl.value);
744
+ } else if (ctrl.tagName === 'SELECT') {
745
+ if (ctrl.multiple) {
746
+ const arr = Array.isArray(val) ? val.map(String) : [String(val)];
747
+ const opts = ctrl.options || [];
748
+ for (let j = 0; j < opts.length; j++) {
749
+ opts[j].selected = arr.includes(String(opts[j].value));
750
+ }
751
+ } else {
752
+ ctrl.value = val === null || val === undefined ? '' : String(val);
753
+ }
754
+ } else {
755
+ ctrl.value = val === null || val === undefined ? '' : String(val);
756
+ }
757
+
758
+ // Clear error for this field
759
+ delete fieldErrors[normKey];
760
+ delete fieldErrors[key];
761
+ }
762
+ }
763
+
764
+ if (isBaseline) {
765
+ initialSnapshot = captureInitialFormSnapshot(formEl);
766
+ dirty = false;
767
+ fieldErrors = {};
768
+ formError = null;
769
+ syncErrorsToDom(false);
770
+ } else {
771
+ dirty = computeFormDirty(formEl, initialSnapshot);
772
+ syncErrorsToDom(false);
773
+ }
774
+
775
+ notifyStateChange();
776
+ return handle;
777
+ }
778
+
779
+ function setAction(url) {
780
+ if (formEl && url) {
781
+ formEl.setAttribute('action', String(url));
782
+ }
783
+ return handle;
784
+ }
785
+
786
+ function setRouteParams(params = {}) {
787
+ if (!formEl || !params) return handle;
788
+ let action = formEl.getAttribute('action') || '';
789
+ for (const [k, v] of Object.entries(params)) {
790
+ action = action.replace(new RegExp(`\\[${k}\\]`, 'g'), encodeURIComponent(String(v)));
791
+ action = action.replace(new RegExp(`:${k}\\b`, 'g'), encodeURIComponent(String(v)));
792
+ }
793
+ formEl.setAttribute('action', action);
794
+ return handle;
795
+ }
796
+
797
+ let _manuallyDisabled = false;
798
+ function setDisabled(val) {
799
+ _manuallyDisabled = Boolean(val);
800
+ if (!formEl) return handle;
801
+ if (_manuallyDisabled) {
802
+ if (formEl.setAttribute) {
803
+ formEl.setAttribute('aria-disabled', 'true');
804
+ formEl.setAttribute('disabled', '');
805
+ }
806
+ } else {
807
+ if (formEl.removeAttribute) {
808
+ formEl.removeAttribute('aria-disabled');
809
+ formEl.removeAttribute('disabled');
810
+ }
811
+ }
812
+ const controls = formEl.querySelectorAll ? formEl.querySelectorAll('input, select, textarea, button') : [];
813
+ for (let i = 0; i < controls.length; i++) {
814
+ controls[i].disabled = _manuallyDisabled;
815
+ }
816
+ notifyStateChange();
817
+ return handle;
818
+ }
819
+
820
+ /**
821
+ * Execute form submission.
822
+ */
823
+ async function submit(eventOrOptions = null) {
824
+ let event = null;
825
+ let submitOptions = {};
826
+
827
+ if (eventOrOptions && typeof eventOrOptions.preventDefault === 'function') {
828
+ event = eventOrOptions;
829
+ event.preventDefault();
830
+ } else if (eventOrOptions && typeof eventOrOptions === 'object') {
831
+ submitOptions = eventOrOptions;
832
+ }
833
+
834
+ // Prevent double submit while pending
835
+ if (pending) {
836
+ return { submitted: false, reason: 'pending' };
837
+ }
838
+
839
+ // 1. Native HTML5 constraint validation first
840
+ if (
841
+ submitOptions.validate !== false &&
842
+ formEl &&
843
+ !formEl.noValidate &&
844
+ typeof formEl.reportValidity === 'function'
845
+ ) {
846
+ const isValid = formEl.reportValidity();
847
+ if (!isValid) {
848
+ return { submitted: false, reason: 'native-constraint-invalid' };
849
+ }
850
+ }
851
+
852
+ const submitter =
853
+ submitOptions.submitter || (event && event.submitter ? event.submitter : null);
854
+ const actionUrl =
855
+ submitOptions.action ||
856
+ (submitter && submitter.getAttribute && submitter.getAttribute('formaction')) ||
857
+ (formEl && formEl.getAttribute && formEl.getAttribute('action')) ||
858
+ (typeof window !== 'undefined' && window.location ? window.location.pathname : '/');
859
+ const rawMethod =
860
+ submitOptions.method ||
861
+ (submitter && submitter.getAttribute && submitter.getAttribute('formmethod')) ||
862
+ (formEl && formEl.getAttribute && formEl.getAttribute('method')) ||
863
+ 'POST';
864
+ const method = String(rawMethod).toUpperCase();
865
+
866
+ const { data: payloadData, formData, isMultipart } = parseFormElement(formEl, submitter);
867
+
868
+ // Clear previous errors before request starts
869
+ fieldErrors = {};
870
+ formError = null;
871
+ syncErrorsToDom(false);
872
+ setPendingState(true, submitter);
873
+
874
+ try {
875
+ let responseData;
876
+ const reqKey = `form:${formName}:${method}:${actionUrl}`;
877
+ if (method === 'GET') {
878
+ responseData = await api.get(actionUrl, payloadData, {
879
+ key: reqKey,
880
+ policy: 'latest',
881
+ });
882
+ } else {
883
+ const bodyToSend = isMultipart && formData ? formData : payloadData;
884
+ responseData = await api.request(method, actionUrl, bodyToSend, {
885
+ key: reqKey,
886
+ policy: 'drop',
887
+ });
888
+ }
889
+
890
+ lastData = responseData;
891
+ dirty = false;
892
+ fieldErrors = {};
893
+ formError = null;
894
+ syncErrorsToDom(false);
895
+
896
+ // Explicit reset / clear behavior on success
897
+ const resetAttr = formEl ? formEl.getAttribute('data-w-reset') : null;
898
+ const clearAttr = formEl ? formEl.getAttribute('data-w-clear') : null;
899
+ const shouldReset =
900
+ submitOptions.resetOnSuccess === true ||
901
+ options.resetOnSuccess === true ||
902
+ resetAttr === '' ||
903
+ resetAttr === 'true' ||
904
+ resetAttr === '1';
905
+ const shouldClear =
906
+ submitOptions.clearOnSuccess === true ||
907
+ options.clearOnSuccess === true ||
908
+ clearAttr === '' ||
909
+ clearAttr === 'true' ||
910
+ clearAttr === '1';
911
+
912
+ if (shouldClear) {
913
+ clear();
914
+ } else if (shouldReset) {
915
+ reset();
916
+ } else {
917
+ // Current state becomes the new clean baseline after successful save
918
+ initialSnapshot = captureInitialFormSnapshot(formEl);
919
+ }
920
+
921
+ if (isDraftEnabled) {
922
+ clearDraft();
923
+ }
924
+
925
+ // Invoke registered success hooks. Note: Any background fragment refresh failure
926
+ // is handled by the fragment coordinator and NEVER puts this form into validation error state!
927
+ for (const listener of successListeners) {
928
+ await listener(responseData, { values: payloadData, form: handle });
929
+ }
930
+
931
+ // Explicit redirect behavior
932
+ const redirectTarget =
933
+ submitOptions.redirectOnSuccess ||
934
+ (formEl && formEl.getAttribute('data-w-redirect')) ||
935
+ null;
936
+ if (redirectTarget && isSafeRedirectUrl(redirectTarget)) {
937
+ if (typeof options.navigate === 'function') {
938
+ options.navigate(redirectTarget);
939
+ } else if (typeof window !== 'undefined' && window.location) {
940
+ window.location.assign(redirectTarget);
941
+ }
942
+ }
943
+
944
+ let refreshOk = true;
945
+ let refreshError = null;
946
+ if (responseData && typeof responseData === 'object' && responseData.__refreshPromise) {
947
+ const refreshOutcome = await responseData.__refreshPromise;
948
+ if (refreshOutcome && refreshOutcome.ok === false) {
949
+ refreshOk = false;
950
+ refreshError = refreshOutcome.error || null;
951
+ }
952
+ }
953
+
954
+ return { ok: true, submitted: true, data: responseData, refreshOk, refreshError };
955
+ } catch (err) {
956
+ if (err && err.kind === 'validation') {
957
+ fieldErrors = { ...(err.fieldErrors || {}) };
958
+ formError = err.formError || (Object.keys(fieldErrors).length === 0 ? err.message : null);
959
+ syncErrorsToDom(true);
960
+ } else if (err && err.isCanceled) {
961
+ // Canceled or dropped duplicate submit — do not overwrite form with error
962
+ } else {
963
+ formError = err && err.message ? err.message : 'Form submission failed.';
964
+ syncErrorsToDom(false);
965
+ }
966
+
967
+ for (const listener of errorListeners) {
968
+ try {
969
+ await listener(err, { values: payloadData, form: handle });
970
+ } catch (_e) {
971
+ // Ignore
972
+ }
973
+ }
974
+
975
+ if (submitOptions.throwOnError) {
976
+ throw err;
977
+ }
978
+ return { ok: false, submitted: false, error: err };
979
+ } finally {
980
+ setPendingState(false, submitter);
981
+ }
982
+ }
983
+
984
+ // Listen to native submit and input/change events on `formEl`
985
+ let draftDebounceTimer = null;
986
+ const onDomInput = (evt) => {
987
+ dirty = computeFormDirty(formEl, initialSnapshot);
988
+ const target = evt && evt.target;
989
+ if (target && target.getAttribute) {
990
+ const rawName = target.getAttribute('name');
991
+ if (rawName) {
992
+ const norm = rawName.endsWith('[]') ? rawName.slice(0, -2) : rawName;
993
+ if (fieldErrors[norm] || fieldErrors[rawName]) {
994
+ delete fieldErrors[norm];
995
+ delete fieldErrors[rawName];
996
+ syncErrorsToDom(false);
997
+ }
998
+ }
999
+ }
1000
+ if (isDraftEnabled) {
1001
+ if (draftDebounceTimer) clearTimeout(draftDebounceTimer);
1002
+ draftDebounceTimer = setTimeout(() => {
1003
+ saveDraft();
1004
+ }, 150);
1005
+ }
1006
+ notifyStateChange();
1007
+ };
1008
+
1009
+ const onDomSubmit = (evt) => {
1010
+ submit(evt);
1011
+ };
1012
+
1013
+ const onDomReset = () => {
1014
+ setTimeout(() => {
1015
+ dirty = false;
1016
+ fieldErrors = {};
1017
+ formError = null;
1018
+ syncErrorsToDom(false);
1019
+ notifyStateChange();
1020
+ }, 0);
1021
+ };
1022
+
1023
+ if (formEl && typeof formEl.addEventListener === 'function') {
1024
+ formEl.addEventListener('input', onDomInput);
1025
+ formEl.addEventListener('change', onDomInput);
1026
+ formEl.addEventListener('submit', onDomSubmit);
1027
+ formEl.addEventListener('reset', onDomReset);
1028
+ }
1029
+
1030
+ // Initialize hidden state on empty error placeholders
1031
+ syncErrorsToDom(false);
1032
+
1033
+ let handle = null;
1034
+
1035
+ handle = {
1036
+ name: formName,
1037
+ el: formEl,
1038
+ get pending() {
1039
+ return pending;
1040
+ },
1041
+ get dirty() {
1042
+ dirty = computeFormDirty(formEl, initialSnapshot);
1043
+ return dirty;
1044
+ },
1045
+ get values() {
1046
+ return parseFormElement(formEl).data;
1047
+ },
1048
+ get errors() {
1049
+ return { ...fieldErrors };
1050
+ },
1051
+ get fieldErrors() {
1052
+ return { ...fieldErrors };
1053
+ },
1054
+ get formError() {
1055
+ return formError;
1056
+ },
1057
+ get lastData() {
1058
+ return lastData;
1059
+ },
1060
+ get disabled() {
1061
+ return _manuallyDisabled;
1062
+ },
1063
+ set disabled(val) {
1064
+ setDisabled(val);
1065
+ },
1066
+ read(submitter) {
1067
+ return parseFormElement(formEl, submitter).data;
1068
+ },
1069
+ getValues(submitter) {
1070
+ return parseFormElement(formEl, submitter).data;
1071
+ },
1072
+ fill,
1073
+ saveDraft,
1074
+ restoreDraft,
1075
+ clearDraft,
1076
+ get hasDraft() {
1077
+ return hasDraft();
1078
+ },
1079
+ setAction,
1080
+ setRouteParams,
1081
+ setDisabled,
1082
+ setFieldErrors(nextFieldErrors = {}, focusFirst = true) {
1083
+ fieldErrors = { ...nextFieldErrors };
1084
+ syncErrorsToDom(focusFirst);
1085
+ notifyStateChange();
1086
+ },
1087
+ setFormError(message) {
1088
+ formError = message ? String(message) : null;
1089
+ syncErrorsToDom(false);
1090
+ notifyStateChange();
1091
+ },
1092
+ clearErrors() {
1093
+ fieldErrors = {};
1094
+ formError = null;
1095
+ syncErrorsToDom(false);
1096
+ notifyStateChange();
1097
+ },
1098
+ reset,
1099
+ clear,
1100
+ submit,
1101
+ onSuccess(fn) {
1102
+ if (typeof fn === 'function') {
1103
+ successListeners.add(fn);
1104
+ return () => successListeners.delete(fn);
1105
+ }
1106
+ return () => {};
1107
+ },
1108
+ onError(fn) {
1109
+ if (typeof fn === 'function') {
1110
+ errorListeners.add(fn);
1111
+ return () => errorListeners.delete(fn);
1112
+ }
1113
+ return () => {};
1114
+ },
1115
+ subscribe(fn) {
1116
+ if (typeof fn === 'function') {
1117
+ stateListeners.add(fn);
1118
+ return () => stateListeners.delete(fn);
1119
+ }
1120
+ return () => {};
1121
+ },
1122
+ destroy() {
1123
+ if (formEl && typeof formEl.removeEventListener === 'function') {
1124
+ formEl.removeEventListener('input', onDomInput);
1125
+ formEl.removeEventListener('change', onDomInput);
1126
+ formEl.removeEventListener('submit', onDomSubmit);
1127
+ formEl.removeEventListener('reset', onDomReset);
1128
+ }
1129
+ if (formEl) {
1130
+ delete formEl.__wFormHandle;
1131
+ }
1132
+ successListeners.clear();
1133
+ errorListeners.clear();
1134
+ stateListeners.clear();
1135
+ },
1136
+ };
1137
+
1138
+ if (formEl) {
1139
+ Object.defineProperty(formEl, '__wFormHandle', {
1140
+ value: handle,
1141
+ configurable: true,
1142
+ enumerable: false,
1143
+ });
1144
+ }
1145
+
1146
+ if (debug) {
1147
+ debug.recordEvent('form:bind', { name: formName });
1148
+ }
1149
+
1150
+ if (isDraftEnabled) {
1151
+ restoreDraft();
1152
+ }
1153
+
1154
+ return handle;
1155
+ }
1156
+
1157
+ module.exports = {
1158
+ parseFormElement,
1159
+ isControlDisabled,
1160
+ coerceScalarControlValue,
1161
+ createFormHandle,
1162
+ isSafeRedirectUrl,
1163
+ };
1164
+