webspresso 0.0.109 → 0.0.111
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/client/adapter/datastar.js +248 -51
- package/core/client/api.js +328 -22
- package/core/client/controller.js +921 -38
- package/core/client/debug.js +12 -12
- package/core/client/devtool.js +1239 -0
- package/core/client/forms.js +184 -21
- package/core/client/fragments.js +566 -72
- package/core/client/host.js +365 -0
- package/core/client/index.d.ts +37 -0
- package/core/client/index.js +190 -15
- package/core/client/lite.js +112 -4
- package/core/client/server-fragments.js +111 -24
- package/core/client/services.js +588 -0
- package/core/client/storage.js +339 -0
- package/core/client/utils.js +324 -1
- package/core/index.js +4 -0
- package/index.d.ts +523 -39
- package/index.js +7 -0
- package/package.json +7 -8
- package/src/bundle/index.js +175 -14
- package/src/client-runtime/bootstrap-swup.js +9 -0
- package/src/client-runtime/mount.js +6 -18
- package/src/client-runtime/resolve.js +3 -2
- package/src/dev/banner.js +4 -1
- package/src/dev-reload/index.js +29 -1
- package/src/file-router.js +12 -8
- package/src/routing/mount-discovered-routes.js +9 -2
- package/src/server.js +42 -24
- package/views/partials/webspresso-client-runtime.njk +1 -9
- package/src/client-runtime/bootstrap-alpine-swup.js +0 -34
package/core/client/forms.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { defaultStorage } = require('./storage');
|
|
4
|
+
const { isArray, setElementBusyState } = require('./utils');
|
|
5
|
+
|
|
3
6
|
/**
|
|
4
7
|
* Webspresso Native HTML Form Layer & Typed Form Handle
|
|
5
8
|
*
|
|
@@ -13,6 +16,40 @@
|
|
|
13
16
|
* - Mutation success vs refresh failure separation: a refresh failure never puts a succeeded form into validation error state
|
|
14
17
|
*/
|
|
15
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Validate that a redirect URL uses a safe protocol (http/https) or relative path
|
|
21
|
+
* to prevent DOM-based XSS (e.g. javascript: URIs) and protocol injection.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} target
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
function isSafeRedirectUrl(target) {
|
|
27
|
+
if (!target || typeof target !== 'string') return false;
|
|
28
|
+
const trimmed = target.trim();
|
|
29
|
+
if (!trimmed) return false;
|
|
30
|
+
|
|
31
|
+
// Block control characters
|
|
32
|
+
if (/[\u0000-\u001F\u007F-\u009F]/.test(trimmed)) return false;
|
|
33
|
+
|
|
34
|
+
// Block protocol-relative URLs (//example.com) and backslash traversal (/\ or \\)
|
|
35
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('/\\') || trimmed.startsWith('\\')) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Relative path starting with single slash (e.g. /dashboard)
|
|
40
|
+
if (trimmed.startsWith('/')) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Absolute URL: MUST explicitly parse as http: or https:
|
|
45
|
+
try {
|
|
46
|
+
const parsed = new URL(trimmed);
|
|
47
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
16
53
|
let errorIdSeq = 0;
|
|
17
54
|
|
|
18
55
|
/**
|
|
@@ -166,12 +203,12 @@ function parseFormElement(formEl, submitterOrOptions = null) {
|
|
|
166
203
|
function appendValue(normName, val, forceArray) {
|
|
167
204
|
if (val === undefined) return;
|
|
168
205
|
if (forceArray) {
|
|
169
|
-
if (!
|
|
206
|
+
if (!isArray(data[normName])) {
|
|
170
207
|
data[normName] = [];
|
|
171
208
|
}
|
|
172
209
|
data[normName].push(val);
|
|
173
210
|
} else if (normName in data) {
|
|
174
|
-
if (!
|
|
211
|
+
if (!isArray(data[normName])) {
|
|
175
212
|
data[normName] = [data[normName]];
|
|
176
213
|
}
|
|
177
214
|
data[normName].push(val);
|
|
@@ -290,7 +327,7 @@ function parseFormElement(formEl, submitterOrOptions = null) {
|
|
|
290
327
|
formData = new FormData();
|
|
291
328
|
for (const [key, val] of Object.entries(data)) {
|
|
292
329
|
if (val === undefined) continue;
|
|
293
|
-
if (
|
|
330
|
+
if (isArray(val)) {
|
|
294
331
|
for (const item of val) {
|
|
295
332
|
if (item === undefined) continue;
|
|
296
333
|
if (typeof Blob !== 'undefined' && item instanceof Blob) {
|
|
@@ -380,11 +417,15 @@ function computeFormDirty(formEl, initialSnapshot) {
|
|
|
380
417
|
*/
|
|
381
418
|
function createFormHandle(formEl, options = {}) {
|
|
382
419
|
if (formEl && formEl.__wFormHandle) {
|
|
420
|
+
if (options.services && typeof formEl.__wFormHandle.__setServices === 'function') {
|
|
421
|
+
formEl.__wFormHandle.__setServices(options.services);
|
|
422
|
+
}
|
|
383
423
|
return formEl.__wFormHandle;
|
|
384
424
|
}
|
|
385
425
|
|
|
386
426
|
const api = options.api;
|
|
387
427
|
const debug = options.debug || null;
|
|
428
|
+
let services = options.services || null;
|
|
388
429
|
const formName =
|
|
389
430
|
(formEl && formEl.getAttribute && formEl.getAttribute('data-w-form')) ||
|
|
390
431
|
(formEl && formEl.getAttribute && formEl.getAttribute('name')) ||
|
|
@@ -401,6 +442,89 @@ function createFormHandle(formEl, options = {}) {
|
|
|
401
442
|
const errorListeners = new Set();
|
|
402
443
|
const stateListeners = new Set();
|
|
403
444
|
|
|
445
|
+
// Form draft persistence setup (sessionStorage by default, or localStorage)
|
|
446
|
+
const rawDraftAttr = formEl
|
|
447
|
+
? formEl.getAttribute('data-w-persist-draft') || formEl.getAttribute('data-w-draft')
|
|
448
|
+
: null;
|
|
449
|
+
const isDraftEnabled =
|
|
450
|
+
options.persistDraft !== undefined
|
|
451
|
+
? Boolean(options.persistDraft)
|
|
452
|
+
: rawDraftAttr !== null && rawDraftAttr !== 'false';
|
|
453
|
+
|
|
454
|
+
let draftStorageType = 'session';
|
|
455
|
+
if (typeof options.persistDraft === 'string') {
|
|
456
|
+
draftStorageType = options.persistDraft.toLowerCase() === 'local' ? 'local' : 'session';
|
|
457
|
+
} else if (rawDraftAttr && rawDraftAttr.toLowerCase() === 'local') {
|
|
458
|
+
draftStorageType = 'local';
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const draftKey =
|
|
462
|
+
options.draftKey ||
|
|
463
|
+
(formEl && (formEl.getAttribute('data-w-draft-key') || formEl.getAttribute('data-w-persist-key'))) ||
|
|
464
|
+
`form_draft:${formName || 'anon'}`;
|
|
465
|
+
|
|
466
|
+
const draftStorage =
|
|
467
|
+
(options.storage && options.storage[draftStorageType]) ||
|
|
468
|
+
(draftStorageType === 'local' ? defaultStorage.local : defaultStorage.session);
|
|
469
|
+
|
|
470
|
+
function extractDraftValues(el) {
|
|
471
|
+
if (!el || typeof el.querySelectorAll !== 'function') return {};
|
|
472
|
+
const { data } = parseFormElement(el);
|
|
473
|
+
const safeData = {};
|
|
474
|
+
for (const [key, val] of Object.entries(data)) {
|
|
475
|
+
const ctrl = el.querySelector(`[name="${key}"], [name="${key}[]"]`);
|
|
476
|
+
if (!ctrl) {
|
|
477
|
+
safeData[key] = val;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
const type = (ctrl.type || '').toLowerCase();
|
|
481
|
+
if (type === 'password' || type === 'file') continue;
|
|
482
|
+
if (ctrl.hasAttribute('data-w-no-draft') || ctrl.getAttribute('data-w-persist') === 'false') continue;
|
|
483
|
+
safeData[key] = val;
|
|
484
|
+
}
|
|
485
|
+
return safeData;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function saveDraft() {
|
|
489
|
+
if (!formEl || !isDraftEnabled) return false;
|
|
490
|
+
const draftPayload = extractDraftValues(formEl);
|
|
491
|
+
return draftStorage.set(draftKey, draftPayload);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function restoreDraft() {
|
|
495
|
+
if (!formEl || !isDraftEnabled) return false;
|
|
496
|
+
const saved = draftStorage.get(draftKey, null);
|
|
497
|
+
if (saved && typeof saved === 'object' && Object.keys(saved).length > 0) {
|
|
498
|
+
fill(saved, { baseline: false });
|
|
499
|
+
if (typeof formEl.dispatchEvent === 'function') {
|
|
500
|
+
const CustomEventCtor =
|
|
501
|
+
(formEl.ownerDocument && formEl.ownerDocument.defaultView && formEl.ownerDocument.defaultView.CustomEvent) ||
|
|
502
|
+
(typeof CustomEvent !== 'undefined' ? CustomEvent : null);
|
|
503
|
+
if (CustomEventCtor) {
|
|
504
|
+
formEl.dispatchEvent(
|
|
505
|
+
new CustomEventCtor('w-form:draft-restored', {
|
|
506
|
+
bubbles: true,
|
|
507
|
+
cancelable: true,
|
|
508
|
+
detail: { form: handle, draft: saved, key: draftKey },
|
|
509
|
+
})
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function clearDraft() {
|
|
519
|
+
if (!isDraftEnabled) return false;
|
|
520
|
+
return draftStorage.remove(draftKey);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function hasDraft() {
|
|
524
|
+
if (!isDraftEnabled) return false;
|
|
525
|
+
return draftStorage.has(draftKey);
|
|
526
|
+
}
|
|
527
|
+
|
|
404
528
|
function notifyStateChange() {
|
|
405
529
|
for (const fn of stateListeners) {
|
|
406
530
|
try {
|
|
@@ -496,32 +620,32 @@ function createFormHandle(formEl, options = {}) {
|
|
|
496
620
|
* Toggle pending state on the form and submit buttons to prevent double submissions.
|
|
497
621
|
*/
|
|
498
622
|
function setPendingState(nextPending, activeSubmitter = null) {
|
|
499
|
-
|
|
623
|
+
const next = Boolean(nextPending);
|
|
624
|
+
if (pending === next) return;
|
|
625
|
+
pending = next;
|
|
500
626
|
if (!formEl) return;
|
|
501
627
|
|
|
502
628
|
if (pending) {
|
|
503
629
|
formEl.setAttribute('aria-busy', 'true');
|
|
504
630
|
formEl.setAttribute('data-w-pending', 'true');
|
|
631
|
+
formEl.setAttribute('data-w-busy', 'true');
|
|
505
632
|
} else {
|
|
506
633
|
formEl.removeAttribute('aria-busy');
|
|
507
634
|
formEl.removeAttribute('data-w-pending');
|
|
635
|
+
formEl.removeAttribute('data-w-busy');
|
|
508
636
|
}
|
|
509
637
|
|
|
510
638
|
const submitButtons = formEl.querySelectorAll
|
|
511
639
|
? formEl.querySelectorAll('button[type="submit"], button:not([type]), input[type="submit"]')
|
|
512
640
|
: [];
|
|
641
|
+
let submitterHandled = false;
|
|
513
642
|
for (let i = 0; i < submitButtons.length; i++) {
|
|
514
643
|
const btn = submitButtons[i];
|
|
515
|
-
if (btn
|
|
516
|
-
|
|
517
|
-
btn.__wWasDisabled = Boolean(btn.disabled);
|
|
518
|
-
btn.disabled = true;
|
|
519
|
-
} else if (!btn.__wWasDisabled) {
|
|
520
|
-
btn.disabled = false;
|
|
521
|
-
}
|
|
644
|
+
if (btn === activeSubmitter) submitterHandled = true;
|
|
645
|
+
setElementBusyState(btn, pending);
|
|
522
646
|
}
|
|
523
|
-
if (activeSubmitter && !
|
|
524
|
-
activeSubmitter
|
|
647
|
+
if (activeSubmitter && !submitterHandled) {
|
|
648
|
+
setElementBusyState(activeSubmitter, pending);
|
|
525
649
|
}
|
|
526
650
|
notifyStateChange();
|
|
527
651
|
}
|
|
@@ -615,7 +739,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
615
739
|
if (type === 'file') continue; // Browser security prevents setting file input value
|
|
616
740
|
|
|
617
741
|
if (type === 'checkbox') {
|
|
618
|
-
if (
|
|
742
|
+
if (isArray(val)) {
|
|
619
743
|
ctrl.checked = val.map(String).includes(String(ctrl.value));
|
|
620
744
|
} else {
|
|
621
745
|
ctrl.checked = Boolean(val);
|
|
@@ -624,7 +748,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
624
748
|
ctrl.checked = String(val) === String(ctrl.value);
|
|
625
749
|
} else if (ctrl.tagName === 'SELECT') {
|
|
626
750
|
if (ctrl.multiple) {
|
|
627
|
-
const arr =
|
|
751
|
+
const arr = isArray(val) ? val.map(String) : [String(val)];
|
|
628
752
|
const opts = ctrl.options || [];
|
|
629
753
|
for (let j = 0; j < opts.length; j++) {
|
|
630
754
|
opts[j].selected = arr.includes(String(opts[j].value));
|
|
@@ -799,10 +923,23 @@ function createFormHandle(formEl, options = {}) {
|
|
|
799
923
|
initialSnapshot = captureInitialFormSnapshot(formEl);
|
|
800
924
|
}
|
|
801
925
|
|
|
926
|
+
if (isDraftEnabled) {
|
|
927
|
+
clearDraft();
|
|
928
|
+
}
|
|
929
|
+
|
|
802
930
|
// Invoke registered success hooks. Note: Any background fragment refresh failure
|
|
803
|
-
//
|
|
931
|
+
// or side-effect service error in onSuccess NEVER puts a succeeded form into validation error
|
|
932
|
+
// state or makes the completed mutation appear rolled back!
|
|
933
|
+
let hookError = null;
|
|
804
934
|
for (const listener of successListeners) {
|
|
805
|
-
|
|
935
|
+
try {
|
|
936
|
+
await listener(responseData, { values: payloadData, form: handle, services });
|
|
937
|
+
} catch (listenerErr) {
|
|
938
|
+
if (!hookError) hookError = listenerErr;
|
|
939
|
+
if (debug && typeof debug.trackCleanupError === 'function') {
|
|
940
|
+
debug.trackCleanupError(`form_${formName}`, formName, 'form:onSuccess', listenerErr);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
806
943
|
}
|
|
807
944
|
|
|
808
945
|
// Explicit redirect behavior
|
|
@@ -810,7 +947,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
810
947
|
submitOptions.redirectOnSuccess ||
|
|
811
948
|
(formEl && formEl.getAttribute('data-w-redirect')) ||
|
|
812
949
|
null;
|
|
813
|
-
if (redirectTarget) {
|
|
950
|
+
if (redirectTarget && isSafeRedirectUrl(redirectTarget)) {
|
|
814
951
|
if (typeof options.navigate === 'function') {
|
|
815
952
|
options.navigate(redirectTarget);
|
|
816
953
|
} else if (typeof window !== 'undefined' && window.location) {
|
|
@@ -828,7 +965,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
828
965
|
}
|
|
829
966
|
}
|
|
830
967
|
|
|
831
|
-
return { ok: true, submitted: true, data: responseData, refreshOk, refreshError };
|
|
968
|
+
return { ok: true, submitted: true, data: responseData, refreshOk, refreshError, hookError };
|
|
832
969
|
} catch (err) {
|
|
833
970
|
if (err && err.kind === 'validation') {
|
|
834
971
|
fieldErrors = { ...(err.fieldErrors || {}) };
|
|
@@ -843,7 +980,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
843
980
|
|
|
844
981
|
for (const listener of errorListeners) {
|
|
845
982
|
try {
|
|
846
|
-
await listener(err, { values: payloadData, form: handle });
|
|
983
|
+
await listener(err, { values: payloadData, form: handle, services });
|
|
847
984
|
} catch (_e) {
|
|
848
985
|
// Ignore
|
|
849
986
|
}
|
|
@@ -859,6 +996,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
859
996
|
}
|
|
860
997
|
|
|
861
998
|
// Listen to native submit and input/change events on `formEl`
|
|
999
|
+
let draftDebounceTimer = null;
|
|
862
1000
|
const onDomInput = (evt) => {
|
|
863
1001
|
dirty = computeFormDirty(formEl, initialSnapshot);
|
|
864
1002
|
const target = evt && evt.target;
|
|
@@ -873,6 +1011,12 @@ function createFormHandle(formEl, options = {}) {
|
|
|
873
1011
|
}
|
|
874
1012
|
}
|
|
875
1013
|
}
|
|
1014
|
+
if (isDraftEnabled) {
|
|
1015
|
+
if (draftDebounceTimer) clearTimeout(draftDebounceTimer);
|
|
1016
|
+
draftDebounceTimer = setTimeout(() => {
|
|
1017
|
+
saveDraft();
|
|
1018
|
+
}, 150);
|
|
1019
|
+
}
|
|
876
1020
|
notifyStateChange();
|
|
877
1021
|
};
|
|
878
1022
|
|
|
@@ -900,9 +1044,17 @@ function createFormHandle(formEl, options = {}) {
|
|
|
900
1044
|
// Initialize hidden state on empty error placeholders
|
|
901
1045
|
syncErrorsToDom(false);
|
|
902
1046
|
|
|
903
|
-
|
|
1047
|
+
let handle = null;
|
|
1048
|
+
|
|
1049
|
+
handle = {
|
|
904
1050
|
name: formName,
|
|
905
1051
|
el: formEl,
|
|
1052
|
+
get services() {
|
|
1053
|
+
return services;
|
|
1054
|
+
},
|
|
1055
|
+
__setServices(nextServices) {
|
|
1056
|
+
if (nextServices) services = nextServices;
|
|
1057
|
+
},
|
|
906
1058
|
get pending() {
|
|
907
1059
|
return pending;
|
|
908
1060
|
},
|
|
@@ -938,6 +1090,12 @@ function createFormHandle(formEl, options = {}) {
|
|
|
938
1090
|
return parseFormElement(formEl, submitter).data;
|
|
939
1091
|
},
|
|
940
1092
|
fill,
|
|
1093
|
+
saveDraft,
|
|
1094
|
+
restoreDraft,
|
|
1095
|
+
clearDraft,
|
|
1096
|
+
get hasDraft() {
|
|
1097
|
+
return hasDraft();
|
|
1098
|
+
},
|
|
941
1099
|
setAction,
|
|
942
1100
|
setRouteParams,
|
|
943
1101
|
setDisabled,
|
|
@@ -1009,6 +1167,10 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1009
1167
|
debug.recordEvent('form:bind', { name: formName });
|
|
1010
1168
|
}
|
|
1011
1169
|
|
|
1170
|
+
if (isDraftEnabled) {
|
|
1171
|
+
restoreDraft();
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1012
1174
|
return handle;
|
|
1013
1175
|
}
|
|
1014
1176
|
|
|
@@ -1017,5 +1179,6 @@ module.exports = {
|
|
|
1017
1179
|
isControlDisabled,
|
|
1018
1180
|
coerceScalarControlValue,
|
|
1019
1181
|
createFormHandle,
|
|
1182
|
+
isSafeRedirectUrl,
|
|
1020
1183
|
};
|
|
1021
1184
|
|