@holo-js/forms 0.1.3 → 0.1.5

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/dist/client.mjs DELETED
@@ -1,527 +0,0 @@
1
- import {
2
- FormContractError,
3
- createFailedSubmission,
4
- createSuccessfulSubmission,
5
- formsSecurityInternals,
6
- validate
7
- } from "./chunk-663LOLKH.mjs";
8
-
9
- // src/client.ts
10
- import { createErrorBag } from "@holo-js/validation";
11
-
12
- // src/client-security.ts
13
- var securityClientModulePromise;
14
- async function importSecurityClientModule() {
15
- if (process.env.VITEST) {
16
- return await import(
17
- /* @vite-ignore */
18
- "@holo-js/security/client"
19
- );
20
- }
21
- return await import("@holo-js/security/client");
22
- }
23
- async function loadSecurityClientModule() {
24
- const runtime = globalThis;
25
- if (runtime.__holoFormsSecurityClientModule__) {
26
- return runtime.__holoFormsSecurityClientModule__;
27
- }
28
- securityClientModulePromise ??= (runtime.__holoFormsSecurityClientImport__ ? runtime.__holoFormsSecurityClientImport__() : importSecurityClientModule()).then((module) => module).catch(async (error) => {
29
- securityClientModulePromise = void 0;
30
- if (formsSecurityInternals.isMissingOptionalPackageError(error)) {
31
- throw formsSecurityInternals.createMissingSecurityPackageError();
32
- }
33
- throw error;
34
- });
35
- return await securityClientModulePromise;
36
- }
37
- async function getClientCsrfField() {
38
- const runtime = globalThis;
39
- if (!runtime.document || typeof runtime.document.cookie !== "string") {
40
- throw new FormContractError(
41
- "[@holo-js/forms] useForm({ csrf: true }) requires a browser-like document.cookie environment."
42
- );
43
- }
44
- const security = await loadSecurityClientModule();
45
- const config = security.getSecurityClientConfig().csrf;
46
- const value = formsSecurityInternals.parseCookieHeader(runtime.document.cookie)[config.cookie];
47
- if (!value) {
48
- throw new FormContractError(
49
- `[@holo-js/forms] Missing CSRF cookie "${config.cookie}" required by useForm({ csrf: true }).`
50
- );
51
- }
52
- return Object.freeze({
53
- name: config.field,
54
- value
55
- });
56
- }
57
-
58
- // src/client.ts
59
- function isPlainObject(value) {
60
- return !!value && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date) && !(value instanceof Blob);
61
- }
62
- function isSchemaFieldLike(value) {
63
- return !!value && typeof value === "object" && value.kind === "field" && !!value.definition && typeof value.definition === "object";
64
- }
65
- function areEqual(left, right) {
66
- if (Object.is(left, right)) {
67
- return true;
68
- }
69
- if (left instanceof Date && right instanceof Date) {
70
- return left.getTime() === right.getTime();
71
- }
72
- if (left instanceof Blob || right instanceof Blob) {
73
- return false;
74
- }
75
- if (Array.isArray(left) && Array.isArray(right)) {
76
- return left.length === right.length && left.every((value, index) => areEqual(value, right[index]));
77
- }
78
- if (isPlainObject(left) && isPlainObject(right)) {
79
- const leftKeys = Object.keys(left);
80
- const rightKeys = Object.keys(right);
81
- return leftKeys.length === rightKeys.length && leftKeys.every((key) => key in right && areEqual(left[key], right[key]));
82
- }
83
- return false;
84
- }
85
- function cloneValue(value) {
86
- if (Array.isArray(value)) {
87
- return value.map((item) => cloneValue(item));
88
- }
89
- if (value instanceof Date) {
90
- return new Date(value.getTime());
91
- }
92
- if (isPlainObject(value)) {
93
- return Object.fromEntries(
94
- Object.entries(value).map(([key, entry]) => [key, cloneValue(entry)])
95
- );
96
- }
97
- return value;
98
- }
99
- function isLeafValue(value) {
100
- return Array.isArray(value) || value instanceof Date || value instanceof Blob || isSchemaFieldLike(value) || !isPlainObject(value);
101
- }
102
- function normalizeObject(value) {
103
- return isPlainObject(value) ? cloneValue(value) : {};
104
- }
105
- function mergeValues(base, override) {
106
- if (!override || !isPlainObject(override)) {
107
- return cloneValue(base);
108
- }
109
- const output = cloneValue(base);
110
- for (const [key, value] of Object.entries(override)) {
111
- if (isPlainObject(value) && isPlainObject(output[key])) {
112
- output[key] = mergeValues(output[key], value);
113
- continue;
114
- }
115
- output[key] = cloneValue(value);
116
- }
117
- return output;
118
- }
119
- function splitPath(path) {
120
- return path.split(".").map((part) => part.trim()).filter(Boolean);
121
- }
122
- function getValueAtPath(root, path) {
123
- let cursor = root;
124
- for (const part of splitPath(path)) {
125
- if (!isPlainObject(cursor) && !Array.isArray(cursor)) {
126
- return void 0;
127
- }
128
- cursor = cursor[part];
129
- }
130
- return cursor;
131
- }
132
- function setValueAtPath(root, path, value) {
133
- const parts = splitPath(path);
134
- let cursor = root;
135
- for (const [index, part] of parts.entries()) {
136
- const last = index === parts.length - 1;
137
- const nextPart = parts[index + 1];
138
- if (Array.isArray(cursor)) {
139
- const offset = Number(part);
140
- if (!Number.isInteger(offset) || offset < 0) {
141
- return;
142
- }
143
- if (last) {
144
- cursor[offset] = value;
145
- return;
146
- }
147
- const existing2 = cursor[offset];
148
- if (!isPlainObject(existing2) && !Array.isArray(existing2)) {
149
- cursor[offset] = /^\d+$/.test(nextPart) ? [] : {};
150
- }
151
- cursor = cursor[offset];
152
- continue;
153
- }
154
- if (last) {
155
- cursor[part] = value;
156
- return;
157
- }
158
- const existing = cursor[part];
159
- if (!isPlainObject(existing) && !Array.isArray(existing)) {
160
- cursor[part] = /^\d+$/.test(nextPart) ? [] : {};
161
- }
162
- cursor = cursor[part];
163
- }
164
- }
165
- function flattenLeafPaths(value, prefix = "") {
166
- if (isLeafValue(value)) {
167
- return [prefix].filter(Boolean);
168
- }
169
- return Object.entries(value).flatMap(([key, nested]) => {
170
- const next = prefix ? `${prefix}.${key}` : key;
171
- return flattenLeafPaths(nested, next);
172
- });
173
- }
174
- function buildFormData(value, path = "", formData = new FormData()) {
175
- if (typeof value === "undefined") {
176
- return formData;
177
- }
178
- if (value instanceof Date) {
179
- formData.append(path, value.toISOString());
180
- return formData;
181
- }
182
- if (value instanceof Blob) {
183
- formData.append(path, value);
184
- return formData;
185
- }
186
- if (Array.isArray(value)) {
187
- for (const item of value) {
188
- buildFormData(item, `${path}[]`, formData);
189
- }
190
- return formData;
191
- }
192
- if (isPlainObject(value)) {
193
- for (const [key, nested] of Object.entries(value)) {
194
- const next = path ? `${path}.${key}` : key;
195
- buildFormData(nested, next, formData);
196
- }
197
- return formData;
198
- }
199
- formData.append(path, String(value));
200
- return formData;
201
- }
202
- function createTypedErrorBag(flattenedErrors) {
203
- return createErrorBag(flattenedErrors);
204
- }
205
- function notifyListeners(state) {
206
- for (const listener of state.listeners) {
207
- listener();
208
- }
209
- }
210
- function collectErrorsForPath(flattenedErrors, path) {
211
- const direct = flattenedErrors[path] ?? [];
212
- const prefix = `${path}.`;
213
- const nested = Object.entries(flattenedErrors).filter(([key]) => key.startsWith(prefix)).flatMap(([, messages]) => messages);
214
- return direct.length > 0 || nested.length > 0 ? [...direct, ...nested] : [];
215
- }
216
- function buildFieldsTree(state, schemaDefinition, source, validateOn, prefix = "") {
217
- if (isLeafValue(source)) {
218
- const path = prefix;
219
- return Object.freeze({
220
- get value() {
221
- return getValueAtPath(state.values, path);
222
- },
223
- set value(next) {
224
- void this.set(next);
225
- },
226
- get errors() {
227
- return collectErrorsForPath(state.flattenedErrors, path);
228
- },
229
- get touched() {
230
- return state.touched.has(path);
231
- },
232
- get dirty() {
233
- return state.dirty.has(path);
234
- },
235
- async set(value) {
236
- setValueAtPath(state.values, path, value);
237
- state.touched.add(path);
238
- if (!areEqual(getValueAtPath(state.initialValues, path), value)) {
239
- state.dirty.add(path);
240
- } else {
241
- state.dirty.delete(path);
242
- }
243
- if (validateOn === "change") {
244
- const submission = await validate(state.values, schemaDefinition);
245
- state.flattenedErrors = submission.errors.flatten();
246
- state.lastSubmission = submission.serialize();
247
- }
248
- notifyListeners(state);
249
- },
250
- async onInput(value) {
251
- await this.set(value);
252
- },
253
- async onBlur() {
254
- state.touched.add(path);
255
- if (validateOn === "blur") {
256
- const submission = await validate(state.values, schemaDefinition);
257
- state.flattenedErrors = submission.errors.flatten();
258
- state.lastSubmission = submission.serialize();
259
- }
260
- notifyListeners(state);
261
- },
262
- async validate() {
263
- const submission = await validate(state.values, schemaDefinition);
264
- state.flattenedErrors = submission.errors.flatten();
265
- notifyListeners(state);
266
- return state.flattenedErrors[path] ?? [];
267
- }
268
- });
269
- }
270
- const entries = Object.entries(source).map(([key, value]) => {
271
- const next = prefix ? `${prefix}.${key}` : key;
272
- return [key, buildFieldsTree(state, schemaDefinition, value, validateOn, next)];
273
- });
274
- return Object.freeze(Object.fromEntries(entries));
275
- }
276
- function isSubmissionResult(value) {
277
- return "valid" in value && "errors" in value && "values" in value && !!value.errors && typeof value.errors === "object" && "flatten" in value.errors && typeof value.errors.flatten === "function";
278
- }
279
- function isSerializedSubmission(value) {
280
- return "submitted" in value && "errors" in value && "values" in value && !("ok" in value);
281
- }
282
- function normalizeSubmissionLike(schemaDefinition, values, result) {
283
- if (isSubmissionResult(result)) {
284
- return result;
285
- }
286
- if ("ok" in result && result.ok === false && "errors" in result && "values" in result) {
287
- return {
288
- ...result,
289
- submitted: true
290
- };
291
- }
292
- if (isSerializedSubmission(result)) {
293
- if (result.valid) {
294
- return createSuccessfulSubmission(schemaDefinition, result.values);
295
- }
296
- return createFailedSubmission(schemaDefinition, result.values, result.errors);
297
- }
298
- if ("ok" in result && result.ok === true) {
299
- return result;
300
- }
301
- return createSuccessfulSubmission(schemaDefinition, values);
302
- }
303
- function appendQueryString(action, formData) {
304
- const [rawPath = "", hash = ""] = action.split("#", 2);
305
- const [pathname = "", search = ""] = rawPath.split("?", 2);
306
- const params = new URLSearchParams(search);
307
- for (const [key, value] of formData.entries()) {
308
- params.append(key, typeof value === "string" ? value : value.name);
309
- }
310
- const query = params.toString();
311
- const suffix = hash ? `#${hash}` : "";
312
- return query ? `${pathname}?${query}${suffix}` : `${pathname}${suffix}`;
313
- }
314
- function isJsonResponse(response) {
315
- const contentType = response.headers?.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
316
- return contentType === "application/json" || contentType?.endsWith("+json") === true;
317
- }
318
- async function normalizeFetchResponse(response, fallbackValues) {
319
- if (response.status === 204 || response.status === 205) {
320
- return {
321
- ok: true,
322
- status: response.status,
323
- data: void 0
324
- };
325
- }
326
- if (isJsonResponse(response)) {
327
- return await response.json();
328
- }
329
- if (response.ok) {
330
- return {
331
- ok: true,
332
- status: response.status,
333
- data: void 0
334
- };
335
- }
336
- try {
337
- return await response.json();
338
- } catch {
339
- return {
340
- ok: false,
341
- status: response.status,
342
- valid: false,
343
- values: fallbackValues,
344
- errors: {}
345
- };
346
- }
347
- }
348
- async function defaultSubmitter(context) {
349
- if (typeof fetch !== "function" || !context.action) {
350
- return {
351
- ok: true,
352
- status: 200,
353
- data: context.values
354
- };
355
- }
356
- const method = context.method.toUpperCase();
357
- if (method === "GET" || method === "HEAD") {
358
- const response2 = await fetch(appendQueryString(context.action, context.formData), {
359
- method
360
- });
361
- if (method === "HEAD" || response2.status === 204 || response2.status === 205) {
362
- return {
363
- ok: true,
364
- status: response2.status,
365
- data: void 0
366
- };
367
- }
368
- return await normalizeFetchResponse(response2, context.values);
369
- }
370
- const response = await fetch(context.action, {
371
- method,
372
- body: context.formData
373
- });
374
- return await normalizeFetchResponse(response, context.values);
375
- }
376
- function isSafeMethod(method) {
377
- const normalized = method.trim().toUpperCase();
378
- return normalized === "GET" || normalized === "HEAD" || normalized === "OPTIONS" || normalized === "TRACE";
379
- }
380
- function useForm(schemaDefinition, options = {}) {
381
- const initialValues = mergeValues(
382
- normalizeObject(options.initialState?.values),
383
- options.initialValues
384
- );
385
- const state = {
386
- values: cloneValue(initialValues),
387
- initialValues: cloneValue(initialValues),
388
- flattenedErrors: { ...options.initialState?.errors ?? {} },
389
- touched: /* @__PURE__ */ new Set(),
390
- dirty: /* @__PURE__ */ new Set(),
391
- submitting: false,
392
- lastSubmission: options.initialState,
393
- listeners: /* @__PURE__ */ new Set()
394
- };
395
- const validateOn = options.validateOn ?? "submit";
396
- const fieldPaths = flattenLeafPaths(schemaDefinition.fields);
397
- const fields = buildFieldsTree(
398
- state,
399
- schemaDefinition,
400
- schemaDefinition.fields,
401
- validateOn
402
- );
403
- async function runValidation() {
404
- const submission = await validate(state.values, schemaDefinition);
405
- state.flattenedErrors = submission.errors.flatten();
406
- state.lastSubmission = submission.serialize();
407
- notifyListeners(state);
408
- return submission;
409
- }
410
- return Object.freeze({
411
- fields,
412
- get values() {
413
- return state.values;
414
- },
415
- get errors() {
416
- return createTypedErrorBag(state.flattenedErrors);
417
- },
418
- get submitting() {
419
- return state.submitting;
420
- },
421
- get valid() {
422
- return Object.keys(state.flattenedErrors).length === 0;
423
- },
424
- get lastSubmission() {
425
- return state.lastSubmission;
426
- },
427
- subscribe(listener) {
428
- state.listeners.add(listener);
429
- return () => {
430
- state.listeners.delete(listener);
431
- };
432
- },
433
- async validate() {
434
- return runValidation();
435
- },
436
- async validateField(path) {
437
- const submission = await runValidation();
438
- return submission.errors.get(path);
439
- },
440
- async submit() {
441
- state.submitting = true;
442
- notifyListeners(state);
443
- try {
444
- const local = await runValidation();
445
- if (!local.valid) {
446
- return local;
447
- }
448
- const submitter = options.submitter ?? defaultSubmitter;
449
- const method = options.method ?? "POST";
450
- const formData = buildFormData(state.values);
451
- if (options.csrf === true && !isSafeMethod(method)) {
452
- const csrfField = await getClientCsrfField();
453
- formData.set(csrfField.name, csrfField.value);
454
- }
455
- const response = await submitter({
456
- action: options.action,
457
- method,
458
- values: state.values,
459
- formData
460
- });
461
- return this.applyServerState(response);
462
- } finally {
463
- state.submitting = false;
464
- notifyListeners(state);
465
- }
466
- },
467
- reset(values) {
468
- const next = mergeValues(state.initialValues, values);
469
- state.values = cloneValue(next);
470
- state.initialValues = cloneValue(next);
471
- state.flattenedErrors = {};
472
- state.touched.clear();
473
- state.dirty.clear();
474
- state.lastSubmission = void 0;
475
- notifyListeners(state);
476
- },
477
- async setValue(path, value) {
478
- setValueAtPath(state.values, path, value);
479
- state.touched.add(path);
480
- if (!areEqual(getValueAtPath(state.initialValues, path), value)) {
481
- state.dirty.add(path);
482
- } else {
483
- state.dirty.delete(path);
484
- }
485
- if (validateOn === "change" && fieldPaths.includes(path)) {
486
- const submission = await runValidation();
487
- state.flattenedErrors = submission.errors.flatten();
488
- return;
489
- }
490
- notifyListeners(state);
491
- },
492
- applyServerState(result) {
493
- const normalized = normalizeSubmissionLike(
494
- schemaDefinition,
495
- state.values,
496
- result
497
- );
498
- if ("ok" in normalized && normalized.ok === true) {
499
- state.lastSubmission = normalized;
500
- state.flattenedErrors = {};
501
- notifyListeners(state);
502
- return normalized;
503
- }
504
- if ("ok" in normalized && normalized.ok === false) {
505
- state.values = mergeValues(state.values, normalized.values);
506
- state.flattenedErrors = normalized.errors;
507
- state.lastSubmission = {
508
- valid: false,
509
- submitted: true,
510
- values: normalized.values,
511
- errors: normalized.errors
512
- };
513
- notifyListeners(state);
514
- return normalized;
515
- }
516
- const normalizedSubmission = normalized;
517
- state.values = mergeValues(state.values, normalizedSubmission.values);
518
- state.flattenedErrors = normalizedSubmission.errors.flatten();
519
- state.lastSubmission = normalizedSubmission.serialize();
520
- notifyListeners(state);
521
- return normalizedSubmission;
522
- }
523
- });
524
- }
525
- export {
526
- useForm
527
- };