anyvali 0.3.1

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 (204) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +370 -0
  3. package/VERSION +1 -0
  4. package/dist/errors.d.ts +6 -0
  5. package/dist/errors.d.ts.map +1 -0
  6. package/dist/errors.js +12 -0
  7. package/dist/errors.js.map +1 -0
  8. package/dist/format/validators.d.ts +2 -0
  9. package/dist/format/validators.d.ts.map +1 -0
  10. package/dist/format/validators.js +57 -0
  11. package/dist/format/validators.js.map +1 -0
  12. package/dist/forms/index.d.ts +57 -0
  13. package/dist/forms/index.d.ts.map +1 -0
  14. package/dist/forms/index.js +586 -0
  15. package/dist/forms/index.js.map +1 -0
  16. package/dist/index.d.ts +93 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +156 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/infer.d.ts +8 -0
  21. package/dist/infer.d.ts.map +1 -0
  22. package/dist/infer.js +2 -0
  23. package/dist/infer.js.map +1 -0
  24. package/dist/interchange/document.d.ts +5 -0
  25. package/dist/interchange/document.d.ts.map +1 -0
  26. package/dist/interchange/document.js +12 -0
  27. package/dist/interchange/document.js.map +1 -0
  28. package/dist/interchange/exporter.d.ts +7 -0
  29. package/dist/interchange/exporter.d.ts.map +1 -0
  30. package/dist/interchange/exporter.js +7 -0
  31. package/dist/interchange/exporter.js.map +1 -0
  32. package/dist/interchange/importer.d.ts +4 -0
  33. package/dist/interchange/importer.d.ts.map +1 -0
  34. package/dist/interchange/importer.js +229 -0
  35. package/dist/interchange/importer.js.map +1 -0
  36. package/dist/issue-codes.d.ts +19 -0
  37. package/dist/issue-codes.d.ts.map +1 -0
  38. package/dist/issue-codes.js +18 -0
  39. package/dist/issue-codes.js.map +1 -0
  40. package/dist/parse/coerce.d.ts +16 -0
  41. package/dist/parse/coerce.d.ts.map +1 -0
  42. package/dist/parse/coerce.js +115 -0
  43. package/dist/parse/coerce.js.map +1 -0
  44. package/dist/parse/defaults.d.ts +7 -0
  45. package/dist/parse/defaults.d.ts.map +1 -0
  46. package/dist/parse/defaults.js +12 -0
  47. package/dist/parse/defaults.js.map +1 -0
  48. package/dist/parse/parser.d.ts +11 -0
  49. package/dist/parse/parser.d.ts.map +1 -0
  50. package/dist/parse/parser.js +13 -0
  51. package/dist/parse/parser.js.map +1 -0
  52. package/dist/schemas/any.d.ts +7 -0
  53. package/dist/schemas/any.d.ts.map +1 -0
  54. package/dist/schemas/any.js +12 -0
  55. package/dist/schemas/any.js.map +1 -0
  56. package/dist/schemas/array.d.ts +13 -0
  57. package/dist/schemas/array.d.ts.map +1 -0
  58. package/dist/schemas/array.js +73 -0
  59. package/dist/schemas/array.js.map +1 -0
  60. package/dist/schemas/base.d.ts +37 -0
  61. package/dist/schemas/base.d.ts.map +1 -0
  62. package/dist/schemas/base.js +285 -0
  63. package/dist/schemas/base.js.map +1 -0
  64. package/dist/schemas/bool.d.ts +8 -0
  65. package/dist/schemas/bool.d.ts.map +1 -0
  66. package/dist/schemas/bool.js +27 -0
  67. package/dist/schemas/bool.js.map +1 -0
  68. package/dist/schemas/enum.d.ts +9 -0
  69. package/dist/schemas/enum.d.ts.map +1 -0
  70. package/dist/schemas/enum.js +31 -0
  71. package/dist/schemas/enum.js.map +1 -0
  72. package/dist/schemas/index.d.ts +21 -0
  73. package/dist/schemas/index.d.ts.map +1 -0
  74. package/dist/schemas/index.js +21 -0
  75. package/dist/schemas/index.js.map +1 -0
  76. package/dist/schemas/int.d.ts +32 -0
  77. package/dist/schemas/int.d.ts.map +1 -0
  78. package/dist/schemas/int.js +108 -0
  79. package/dist/schemas/int.js.map +1 -0
  80. package/dist/schemas/intersection.d.ts +16 -0
  81. package/dist/schemas/intersection.d.ts.map +1 -0
  82. package/dist/schemas/intersection.js +58 -0
  83. package/dist/schemas/intersection.js.map +1 -0
  84. package/dist/schemas/literal.d.ts +11 -0
  85. package/dist/schemas/literal.d.ts.map +1 -0
  86. package/dist/schemas/literal.js +28 -0
  87. package/dist/schemas/literal.js.map +1 -0
  88. package/dist/schemas/never.d.ts +7 -0
  89. package/dist/schemas/never.d.ts.map +1 -0
  90. package/dist/schemas/never.js +19 -0
  91. package/dist/schemas/never.js.map +1 -0
  92. package/dist/schemas/null.d.ts +7 -0
  93. package/dist/schemas/null.d.ts.map +1 -0
  94. package/dist/schemas/null.js +24 -0
  95. package/dist/schemas/null.js.map +1 -0
  96. package/dist/schemas/nullable.d.ts +10 -0
  97. package/dist/schemas/nullable.d.ts.map +1 -0
  98. package/dist/schemas/nullable.js +29 -0
  99. package/dist/schemas/nullable.js.map +1 -0
  100. package/dist/schemas/number.d.ts +27 -0
  101. package/dist/schemas/number.d.ts.map +1 -0
  102. package/dist/schemas/number.js +134 -0
  103. package/dist/schemas/number.js.map +1 -0
  104. package/dist/schemas/object.d.ts +28 -0
  105. package/dist/schemas/object.d.ts.map +1 -0
  106. package/dist/schemas/object.js +153 -0
  107. package/dist/schemas/object.js.map +1 -0
  108. package/dist/schemas/optional.d.ts +11 -0
  109. package/dist/schemas/optional.d.ts.map +1 -0
  110. package/dist/schemas/optional.js +39 -0
  111. package/dist/schemas/optional.js.map +1 -0
  112. package/dist/schemas/record.d.ts +9 -0
  113. package/dist/schemas/record.d.ts.map +1 -0
  114. package/dist/schemas/record.js +45 -0
  115. package/dist/schemas/record.js.map +1 -0
  116. package/dist/schemas/ref.d.ts +10 -0
  117. package/dist/schemas/ref.d.ts.map +1 -0
  118. package/dist/schemas/ref.js +30 -0
  119. package/dist/schemas/ref.js.map +1 -0
  120. package/dist/schemas/string.d.ts +29 -0
  121. package/dist/schemas/string.d.ts.map +1 -0
  122. package/dist/schemas/string.js +181 -0
  123. package/dist/schemas/string.js.map +1 -0
  124. package/dist/schemas/tuple.d.ts +14 -0
  125. package/dist/schemas/tuple.d.ts.map +1 -0
  126. package/dist/schemas/tuple.js +59 -0
  127. package/dist/schemas/tuple.js.map +1 -0
  128. package/dist/schemas/union.d.ts +9 -0
  129. package/dist/schemas/union.d.ts.map +1 -0
  130. package/dist/schemas/union.js +45 -0
  131. package/dist/schemas/union.js.map +1 -0
  132. package/dist/schemas/unknown.d.ts +7 -0
  133. package/dist/schemas/unknown.d.ts.map +1 -0
  134. package/dist/schemas/unknown.js +12 -0
  135. package/dist/schemas/unknown.js.map +1 -0
  136. package/dist/types.d.ts +132 -0
  137. package/dist/types.d.ts.map +1 -0
  138. package/dist/types.js +3 -0
  139. package/dist/types.js.map +1 -0
  140. package/dist/util.d.ts +6 -0
  141. package/dist/util.d.ts.map +1 -0
  142. package/dist/util.js +12 -0
  143. package/dist/util.js.map +1 -0
  144. package/package.json +41 -0
  145. package/sdk/js/CHANGELOG.md +13 -0
  146. package/src/errors.ts +17 -0
  147. package/src/format/validators.ts +71 -0
  148. package/src/forms/index.ts +789 -0
  149. package/src/index.ts +285 -0
  150. package/src/infer.ts +12 -0
  151. package/src/interchange/document.ts +18 -0
  152. package/src/interchange/exporter.ts +12 -0
  153. package/src/interchange/importer.ts +285 -0
  154. package/src/issue-codes.ts +19 -0
  155. package/src/parse/coerce.ts +133 -0
  156. package/src/parse/defaults.ts +15 -0
  157. package/src/parse/parser.ts +19 -0
  158. package/src/schemas/any.ts +14 -0
  159. package/src/schemas/array.ts +83 -0
  160. package/src/schemas/base.ts +322 -0
  161. package/src/schemas/bool.ts +30 -0
  162. package/src/schemas/enum.ts +37 -0
  163. package/src/schemas/index.ts +30 -0
  164. package/src/schemas/int.ts +129 -0
  165. package/src/schemas/intersection.ts +81 -0
  166. package/src/schemas/literal.ts +34 -0
  167. package/src/schemas/never.ts +21 -0
  168. package/src/schemas/null.ts +26 -0
  169. package/src/schemas/nullable.ts +36 -0
  170. package/src/schemas/number.ts +151 -0
  171. package/src/schemas/object.ts +203 -0
  172. package/src/schemas/optional.ts +49 -0
  173. package/src/schemas/record.ts +55 -0
  174. package/src/schemas/ref.ts +35 -0
  175. package/src/schemas/string.ts +192 -0
  176. package/src/schemas/tuple.ts +74 -0
  177. package/src/schemas/union.ts +53 -0
  178. package/src/schemas/unknown.ts +14 -0
  179. package/src/types.ts +239 -0
  180. package/src/util.ts +9 -0
  181. package/tests/conformance/runner.test.ts +28 -0
  182. package/tests/conformance/runner.ts +137 -0
  183. package/tests/forms.test.ts +146 -0
  184. package/tests/unit/coerce.test.ts +136 -0
  185. package/tests/unit/collections.test.ts +99 -0
  186. package/tests/unit/composition.test.ts +80 -0
  187. package/tests/unit/date-format.test.ts +18 -0
  188. package/tests/unit/default-mutation.test.ts +32 -0
  189. package/tests/unit/defaults.test.ts +49 -0
  190. package/tests/unit/errors.test.ts +53 -0
  191. package/tests/unit/export.test.ts +270 -0
  192. package/tests/unit/inference.test.ts +306 -0
  193. package/tests/unit/interchange.test.ts +191 -0
  194. package/tests/unit/number.test.ts +195 -0
  195. package/tests/unit/object.test.ts +208 -0
  196. package/tests/unit/parser.test.ts +151 -0
  197. package/tests/unit/primitives.test.ts +111 -0
  198. package/tests/unit/security-recursion.test.ts +105 -0
  199. package/tests/unit/security.test.ts +945 -0
  200. package/tests/unit/shared-ref-falsepos.test.ts +33 -0
  201. package/tests/unit/string-pattern-redos.test.ts +46 -0
  202. package/tests/unit/string.test.ts +147 -0
  203. package/tsconfig.json +21 -0
  204. package/vitest.config.ts +7 -0
@@ -0,0 +1,789 @@
1
+ import { exportSchema } from "../interchange/exporter.js";
2
+ import { BaseSchema } from "../schemas/base.js";
3
+ import type {
4
+ AnyValiDocument,
5
+ ArraySchemaNode,
6
+ NumericSchemaNode,
7
+ ObjectSchemaNode,
8
+ ParseResult,
9
+ SchemaNode,
10
+ StringSchemaNode,
11
+ ValidationIssue,
12
+ } from "../types.js";
13
+ import { importSchema } from "../interchange/importer.js";
14
+
15
+ export type FormSchemaSource = AnyValiDocument | BaseSchema;
16
+ export type ValidationTrigger = "input" | "change" | "blur" | "submit";
17
+
18
+ export interface InitFormOptions {
19
+ schema: FormSchemaSource;
20
+ validateOn?: ValidationTrigger[];
21
+ nativeValidation?: boolean;
22
+ htmx?: boolean;
23
+ reportValidity?: boolean;
24
+ }
25
+
26
+ export interface FormController {
27
+ form: HTMLFormElement;
28
+ document: AnyValiDocument;
29
+ validate(): boolean;
30
+ getValues(): unknown;
31
+ getResult(): ParseResult<unknown>;
32
+ destroy(): void;
33
+ }
34
+
35
+ export interface CreateFormBindingsOptions {
36
+ schema: FormSchemaSource;
37
+ errorIdPrefix?: string;
38
+ }
39
+
40
+ export interface HtmxConfig {
41
+ get?: string;
42
+ post?: string;
43
+ put?: string;
44
+ patch?: string;
45
+ delete?: string;
46
+ target?: string;
47
+ swap?: string;
48
+ trigger?: string;
49
+ select?: string;
50
+ validate?: boolean;
51
+ confirm?: string;
52
+ include?: string;
53
+ encoding?: string;
54
+ ext?: string;
55
+ indicator?: string;
56
+ pushUrl?: string;
57
+ replaceUrl?: string;
58
+ }
59
+
60
+ type FormControl =
61
+ | HTMLInputElement
62
+ | HTMLSelectElement
63
+ | HTMLTextAreaElement;
64
+
65
+ type FieldSegments = Array<string | number>;
66
+
67
+ interface ResolvedFieldSchema {
68
+ path: FieldSegments;
69
+ canonicalName: string;
70
+ required: boolean;
71
+ nullable: boolean;
72
+ node: SchemaNode;
73
+ }
74
+
75
+ interface UnwrappedNode {
76
+ node: SchemaNode;
77
+ optional: boolean;
78
+ nullable: boolean;
79
+ }
80
+
81
+ export function createFormBindings(options: CreateFormBindingsOptions) {
82
+ const doc = normalizeSchemaSource(options.schema);
83
+ const errorIdPrefix = options.errorIdPrefix ?? "anyvali";
84
+
85
+ return {
86
+ field(path: string, attrs: Record<string, unknown> = {}) {
87
+ return {
88
+ ...getFieldAttributes(doc, path, errorIdPrefix),
89
+ ...attrs,
90
+ };
91
+ },
92
+
93
+ errorSlot(path: string, attrs: Record<string, unknown> = {}) {
94
+ return {
95
+ id: errorIdForPath(path, errorIdPrefix),
96
+ "data-anyvali-error-for": canonicalizePath(path),
97
+ "aria-live": "polite",
98
+ ...attrs,
99
+ };
100
+ },
101
+
102
+ htmx(config: HtmxConfig) {
103
+ const attrs: Record<string, string> = {};
104
+ for (const [method, attr] of [
105
+ ["get", "hx-get"],
106
+ ["post", "hx-post"],
107
+ ["put", "hx-put"],
108
+ ["patch", "hx-patch"],
109
+ ["delete", "hx-delete"],
110
+ ] as const) {
111
+ const value = config[method];
112
+ if (value) {
113
+ attrs[attr] = value;
114
+ }
115
+ }
116
+
117
+ if (config.target) attrs["hx-target"] = config.target;
118
+ if (config.swap) attrs["hx-swap"] = config.swap;
119
+ if (config.trigger) attrs["hx-trigger"] = config.trigger;
120
+ if (config.select) attrs["hx-select"] = config.select;
121
+ if (config.confirm) attrs["hx-confirm"] = config.confirm;
122
+ if (config.include) attrs["hx-include"] = config.include;
123
+ if (config.encoding) attrs["hx-encoding"] = config.encoding;
124
+ if (config.ext) attrs["hx-ext"] = config.ext;
125
+ if (config.indicator) attrs["hx-indicator"] = config.indicator;
126
+ if (config.pushUrl) attrs["hx-push-url"] = config.pushUrl;
127
+ if (config.replaceUrl) attrs["hx-replace-url"] = config.replaceUrl;
128
+ if (config.validate !== undefined) {
129
+ attrs["hx-validate"] = String(config.validate);
130
+ }
131
+
132
+ return attrs;
133
+ },
134
+
135
+ init(target: string | HTMLFormElement, initOptions: Omit<InitFormOptions, "schema"> = {}) {
136
+ return initForm(target, {
137
+ schema: doc,
138
+ ...initOptions,
139
+ });
140
+ },
141
+ };
142
+ }
143
+
144
+ export function initForm(
145
+ target: string | HTMLFormElement,
146
+ options: InitFormOptions
147
+ ): FormController {
148
+ const form = resolveForm(target);
149
+ const doc = normalizeSchemaSource(options.schema);
150
+ const schema = importSchema(doc);
151
+ const validateOn = new Set(options.validateOn ?? ["blur", "submit"]);
152
+ const nativeValidation = options.nativeValidation ?? true;
153
+ const reportValidity = options.reportValidity ?? true;
154
+ const useHtmx = options.htmx ?? true;
155
+
156
+ if (nativeValidation) {
157
+ applyNativeConstraints(form, doc);
158
+ }
159
+
160
+ const listeners: Array<{
161
+ target: EventTarget;
162
+ event: string;
163
+ handler: EventListener;
164
+ options?: boolean | AddEventListenerOptions;
165
+ }> = [];
166
+
167
+ const addListener = (
168
+ eventTarget: EventTarget,
169
+ event: string,
170
+ handler: EventListener,
171
+ options?: boolean | AddEventListenerOptions
172
+ ) => {
173
+ eventTarget.addEventListener(event, handler, options);
174
+ listeners.push({ target: eventTarget, event, handler, options });
175
+ };
176
+
177
+ const validateField = (fieldName: string, shouldReport = false) => {
178
+ clearFieldState(form, fieldName);
179
+ const result = schema.safeParse(readFormValues(form, doc));
180
+ if (!result.success) {
181
+ const fieldPath = parseFieldPath(fieldName);
182
+ const issue = firstIssueForField(result.issues, fieldPath);
183
+ if (issue) {
184
+ applyFieldIssue(form, fieldName, issue);
185
+ if (shouldReport) {
186
+ firstControlForField(form, fieldName)?.reportValidity?.();
187
+ }
188
+ return false;
189
+ }
190
+ }
191
+
192
+ if (shouldReport) {
193
+ firstControlForField(form, fieldName)?.reportValidity?.();
194
+ }
195
+ return true;
196
+ };
197
+
198
+ const validateFormState = (shouldReport = false) => {
199
+ clearAllFieldState(form);
200
+ const result = schema.safeParse(readFormValues(form, doc));
201
+ if (!result.success) {
202
+ const fieldNames = fieldNamesForForm(form);
203
+ for (const fieldName of fieldNames) {
204
+ const issue = firstIssueForField(result.issues, parseFieldPath(fieldName));
205
+ if (issue) {
206
+ applyFieldIssue(form, fieldName, issue);
207
+ }
208
+ }
209
+ if (shouldReport) {
210
+ form.reportValidity?.();
211
+ }
212
+ return false;
213
+ }
214
+ return true;
215
+ };
216
+
217
+ if (validateOn.has("input")) {
218
+ addListener(form, "input", (event) => {
219
+ const control = event.target as FormControl | null;
220
+ if (control?.name) {
221
+ validateField(control.name, false);
222
+ }
223
+ });
224
+ }
225
+
226
+ if (validateOn.has("change")) {
227
+ addListener(form, "change", (event) => {
228
+ const control = event.target as FormControl | null;
229
+ if (control?.name) {
230
+ validateField(control.name, false);
231
+ }
232
+ });
233
+ }
234
+
235
+ if (validateOn.has("blur")) {
236
+ addListener(form, "blur", (event) => {
237
+ const control = event.target as FormControl | null;
238
+ if (control?.name) {
239
+ validateField(control.name, reportValidity);
240
+ }
241
+ }, true);
242
+ }
243
+
244
+ addListener(form, "submit", (event) => {
245
+ if (!validateFormState(validateOn.has("submit") && reportValidity)) {
246
+ event.preventDefault();
247
+ event.stopPropagation();
248
+ }
249
+ });
250
+
251
+ if (useHtmx) {
252
+ const htmx = (globalThis as { htmx?: { config?: Record<string, unknown> } }).htmx;
253
+ if (htmx?.config && reportValidity) {
254
+ htmx.config.reportValidityOfForms = true;
255
+ }
256
+
257
+ addListener(form, "htmx:validation:validate", () => {
258
+ validateFormState(false);
259
+ });
260
+ }
261
+
262
+ return {
263
+ form,
264
+ document: doc,
265
+ validate() {
266
+ return validateFormState(false);
267
+ },
268
+ getValues() {
269
+ return readFormValues(form, doc);
270
+ },
271
+ getResult() {
272
+ return schema.safeParse(readFormValues(form, doc));
273
+ },
274
+ destroy() {
275
+ for (const entry of listeners) {
276
+ entry.target.removeEventListener(entry.event, entry.handler, entry.options);
277
+ }
278
+ },
279
+ };
280
+ }
281
+
282
+ export function getFieldAttributes(
283
+ schema: FormSchemaSource,
284
+ path: string,
285
+ errorIdPrefix = "anyvali"
286
+ ): Record<string, unknown> {
287
+ const doc = normalizeSchemaSource(schema);
288
+ const resolved = resolveFieldSchema(doc, path);
289
+ const attrs: Record<string, unknown> = {
290
+ name: path,
291
+ "data-anyvali-path": canonicalizePath(path),
292
+ "aria-describedby": errorIdForPath(path, errorIdPrefix),
293
+ };
294
+
295
+ if (!resolved) {
296
+ return attrs;
297
+ }
298
+
299
+ const { node } = resolved;
300
+ const unwrapped = unwrapNode(resolveRefNode(doc, node));
301
+ const effective = unwrapped.node;
302
+
303
+ if (resolved.required && effective.kind !== "bool") {
304
+ attrs.required = true;
305
+ }
306
+
307
+ if (effective.kind === "string") {
308
+ applyStringAttributes(attrs, effective);
309
+ } else if (isNumericNode(effective)) {
310
+ applyNumericAttributes(attrs, effective);
311
+ } else if (effective.kind === "bool") {
312
+ attrs.type = "checkbox";
313
+ } else if (effective.kind === "array") {
314
+ applyArrayAttributes(attrs, effective, resolved.required);
315
+ }
316
+
317
+ return attrs;
318
+ }
319
+
320
+ function normalizeSchemaSource(schema: FormSchemaSource): AnyValiDocument {
321
+ if (schema instanceof BaseSchema) {
322
+ return exportSchema(schema);
323
+ }
324
+ return schema;
325
+ }
326
+
327
+ function resolveForm(target: string | HTMLFormElement): HTMLFormElement {
328
+ if (typeof target !== "string") {
329
+ return target;
330
+ }
331
+
332
+ const element = document.querySelector(target);
333
+ if (!(element instanceof HTMLFormElement)) {
334
+ throw new Error(`Form not found for selector: ${target}`);
335
+ }
336
+ return element;
337
+ }
338
+
339
+ function applyNativeConstraints(form: HTMLFormElement, doc: AnyValiDocument) {
340
+ for (const fieldName of fieldNamesForForm(form)) {
341
+ const attrs = getFieldAttributes(doc, fieldName);
342
+ const controls = controlsForField(form, fieldName);
343
+ for (const control of controls) {
344
+ for (const [key, value] of Object.entries(attrs)) {
345
+ if (key === "name" || key === "data-anyvali-path" || key === "aria-describedby") {
346
+ setControlAttribute(control, key, value);
347
+ continue;
348
+ }
349
+
350
+ if (value === undefined || value === null) {
351
+ continue;
352
+ }
353
+
354
+ if (key === "type" && control instanceof HTMLInputElement) {
355
+ if (!control.hasAttribute("type") || control.type === "text") {
356
+ control.type = String(value);
357
+ }
358
+ continue;
359
+ }
360
+
361
+ if (key === "required") {
362
+ if (!control.hasAttribute("required")) {
363
+ control.required = Boolean(value);
364
+ }
365
+ continue;
366
+ }
367
+
368
+ if (!control.hasAttribute(key)) {
369
+ setControlAttribute(control, key, value);
370
+ }
371
+ }
372
+ }
373
+ }
374
+ }
375
+
376
+ function setControlAttribute(
377
+ control: FormControl,
378
+ key: string,
379
+ value: unknown
380
+ ) {
381
+ if (typeof value === "boolean") {
382
+ if (value) {
383
+ control.setAttribute(key, "");
384
+ } else {
385
+ control.removeAttribute(key);
386
+ }
387
+ return;
388
+ }
389
+
390
+ control.setAttribute(key, String(value));
391
+ }
392
+
393
+ function readFormValues(form: HTMLFormElement, doc: AnyValiDocument): unknown {
394
+ const root: Record<string, unknown> = {};
395
+ for (const fieldName of fieldNamesForForm(form)) {
396
+ const controls = controlsForField(form, fieldName);
397
+ if (controls.length === 0) continue;
398
+
399
+ const resolved = resolveFieldSchema(doc, fieldName);
400
+ const value = readFieldValue(controls, resolved?.node);
401
+ if (value === undefined) continue;
402
+
403
+ setPathValue(root, parseFieldPath(fieldName), value);
404
+ }
405
+ return root;
406
+ }
407
+
408
+ function readFieldValue(
409
+ controls: FormControl[],
410
+ node?: SchemaNode
411
+ ): unknown {
412
+ const first = controls[0];
413
+ if (!first) return undefined;
414
+
415
+ const effectiveNode = node ? unwrapNode(node).node : undefined;
416
+
417
+ if (first instanceof HTMLSelectElement && first.multiple) {
418
+ return Array.from(first.selectedOptions).map((option) => option.value);
419
+ }
420
+
421
+ if (first instanceof HTMLInputElement && first.type === "radio") {
422
+ const checked = controls.find(
423
+ (control): control is HTMLInputElement =>
424
+ control instanceof HTMLInputElement && control.checked
425
+ );
426
+ return checked?.value;
427
+ }
428
+
429
+ if (
430
+ controls.every(
431
+ (control) => control instanceof HTMLInputElement && control.type === "checkbox"
432
+ )
433
+ ) {
434
+ if (effectiveNode?.kind === "array") {
435
+ return controls
436
+ .filter(
437
+ (control): control is HTMLInputElement =>
438
+ control instanceof HTMLInputElement && control.checked
439
+ )
440
+ .map((control) => control.value);
441
+ }
442
+ const checkbox = first as HTMLInputElement;
443
+ return checkbox.checked;
444
+ }
445
+
446
+ if (first instanceof HTMLInputElement && isNumericLikeControl(first, effectiveNode)) {
447
+ if (first.value === "") return undefined;
448
+ return first.valueAsNumber;
449
+ }
450
+
451
+ const raw = first.value;
452
+ return raw === "" ? undefined : raw;
453
+ }
454
+
455
+ function isNumericLikeControl(
456
+ control: HTMLInputElement,
457
+ node?: SchemaNode
458
+ ) {
459
+ if (!node) return control.type === "number";
460
+ const effectiveNode = unwrapNode(node).node;
461
+ return control.type === "number" || isNumericNode(effectiveNode);
462
+ }
463
+
464
+ function validateIssueMessage(issue: ValidationIssue) {
465
+ return issue.message || "Invalid value";
466
+ }
467
+
468
+ function applyFieldIssue(
469
+ form: HTMLFormElement,
470
+ fieldName: string,
471
+ issue: ValidationIssue
472
+ ) {
473
+ const message = validateIssueMessage(issue);
474
+ for (const control of controlsForField(form, fieldName)) {
475
+ control.setCustomValidity(message);
476
+ control.setAttribute("aria-invalid", "true");
477
+ control.setAttribute("data-anyvali-invalid", "true");
478
+ }
479
+
480
+ for (const slot of errorSlotsForField(form, fieldName)) {
481
+ slot.textContent = message;
482
+ slot.removeAttribute("hidden");
483
+ }
484
+ }
485
+
486
+ function clearFieldState(form: HTMLFormElement, fieldName: string) {
487
+ for (const control of controlsForField(form, fieldName)) {
488
+ control.setCustomValidity("");
489
+ control.removeAttribute("aria-invalid");
490
+ control.removeAttribute("data-anyvali-invalid");
491
+ }
492
+
493
+ for (const slot of errorSlotsForField(form, fieldName)) {
494
+ slot.textContent = "";
495
+ slot.setAttribute("hidden", "");
496
+ }
497
+ }
498
+
499
+ function clearAllFieldState(form: HTMLFormElement) {
500
+ for (const fieldName of fieldNamesForForm(form)) {
501
+ clearFieldState(form, fieldName);
502
+ }
503
+ }
504
+
505
+ function controlsForField(form: HTMLFormElement, fieldName: string): FormControl[] {
506
+ return Array.from(
507
+ form.querySelectorAll<FormControl>(`[name="${cssEscape(fieldName)}"]`)
508
+ );
509
+ }
510
+
511
+ function firstControlForField(form: HTMLFormElement, fieldName: string) {
512
+ return controlsForField(form, fieldName)[0];
513
+ }
514
+
515
+ function errorSlotsForField(form: HTMLFormElement, fieldName: string) {
516
+ const canonical = canonicalizePath(fieldName);
517
+ return Array.from(
518
+ form.querySelectorAll<HTMLElement>(
519
+ `[data-anyvali-error-for="${cssEscape(canonical)}"]`
520
+ )
521
+ );
522
+ }
523
+
524
+ function fieldNamesForForm(form: HTMLFormElement) {
525
+ return Array.from(
526
+ new Set(
527
+ Array.from(form.elements)
528
+ .filter((element): element is FormControl => {
529
+ return (
530
+ element instanceof HTMLInputElement ||
531
+ element instanceof HTMLSelectElement ||
532
+ element instanceof HTMLTextAreaElement
533
+ );
534
+ })
535
+ .map((element) => element.name)
536
+ .filter(Boolean)
537
+ )
538
+ );
539
+ }
540
+
541
+ function resolveFieldSchema(
542
+ doc: AnyValiDocument,
543
+ path: string
544
+ ): ResolvedFieldSchema | null {
545
+ const segments = parseFieldPath(path);
546
+ let current: SchemaNode | undefined = doc.root;
547
+ let required = true;
548
+ let nullable = false;
549
+
550
+ for (const segment of segments) {
551
+ if (!current) {
552
+ return null;
553
+ }
554
+
555
+ const unwrapped = unwrapNode(resolveRefNode(doc, current));
556
+ current = unwrapped.node;
557
+ nullable = nullable || unwrapped.nullable;
558
+
559
+ if (current.kind === "object" && typeof segment === "string") {
560
+ const objectNode = current as ObjectSchemaNode;
561
+ const propertyNode = objectNode.properties[segment];
562
+ if (!propertyNode) {
563
+ return null;
564
+ }
565
+ const propertyUnwrapped = unwrapNode(resolveRefNode(doc, propertyNode));
566
+ required = required && objectNode.required.includes(segment) && !propertyUnwrapped.optional;
567
+ nullable = nullable || propertyUnwrapped.nullable;
568
+ current = propertyUnwrapped.node;
569
+ continue;
570
+ }
571
+
572
+ if (current.kind === "record" && typeof segment === "string") {
573
+ current = resolveRefNode(doc, current.valueSchema);
574
+ continue;
575
+ }
576
+
577
+ if (current.kind === "array") {
578
+ current = resolveRefNode(doc, current.items);
579
+ continue;
580
+ }
581
+
582
+ return null;
583
+ }
584
+
585
+ if (!current) {
586
+ return null;
587
+ }
588
+
589
+ const unwrapped = unwrapNode(resolveRefNode(doc, current));
590
+ return {
591
+ path: segments,
592
+ canonicalName: formatPath(segments),
593
+ required: required && !unwrapped.optional,
594
+ nullable: nullable || unwrapped.nullable,
595
+ node: unwrapped.node,
596
+ };
597
+ }
598
+
599
+ function resolveRefNode(doc: AnyValiDocument, node: SchemaNode): SchemaNode {
600
+ let current = node;
601
+ const seen = new Set<string>();
602
+
603
+ while (current.kind === "ref") {
604
+ const name = current.ref.replace(/^#\/definitions\//, "");
605
+ if (seen.has(name)) {
606
+ break;
607
+ }
608
+ seen.add(name);
609
+ const resolved = doc.definitions[name];
610
+ if (!resolved) {
611
+ break;
612
+ }
613
+ current = resolved;
614
+ }
615
+
616
+ return current;
617
+ }
618
+
619
+ function unwrapNode(node: SchemaNode): UnwrappedNode {
620
+ let current = node;
621
+ let optional = false;
622
+ let nullable = false;
623
+
624
+ while (current.kind === "optional" || current.kind === "nullable") {
625
+ if (current.kind === "optional") {
626
+ optional = true;
627
+ current = current.inner;
628
+ } else {
629
+ nullable = true;
630
+ current = current.inner;
631
+ }
632
+ }
633
+
634
+ return { node: current, optional, nullable };
635
+ }
636
+
637
+ function applyStringAttributes(
638
+ attrs: Record<string, unknown>,
639
+ node: StringSchemaNode
640
+ ) {
641
+ if (attrs.type === undefined) {
642
+ const type = htmlTypeForStringFormat(node.format);
643
+ if (type) {
644
+ attrs.type = type;
645
+ }
646
+ }
647
+ if (node.minLength !== undefined) attrs.minLength = node.minLength;
648
+ if (node.maxLength !== undefined) attrs.maxLength = node.maxLength;
649
+ if (node.pattern !== undefined) attrs.pattern = node.pattern;
650
+ }
651
+
652
+ function applyNumericAttributes(
653
+ attrs: Record<string, unknown>,
654
+ node: NumericSchemaNode
655
+ ) {
656
+ if (attrs.type === undefined) attrs.type = "number";
657
+ if (node.min !== undefined) attrs.min = node.min;
658
+ if (node.max !== undefined) attrs.max = node.max;
659
+ if (node.multipleOf !== undefined) {
660
+ attrs.step = node.multipleOf;
661
+ } else if (isIntegerKind(node.kind)) {
662
+ attrs.step = 1;
663
+ }
664
+ attrs.inputMode = isIntegerKind(node.kind) ? "numeric" : "decimal";
665
+ }
666
+
667
+ function applyArrayAttributes(
668
+ attrs: Record<string, unknown>,
669
+ node: ArraySchemaNode,
670
+ required: boolean
671
+ ) {
672
+ if (required || (node.minItems ?? 0) > 0) {
673
+ attrs.required = true;
674
+ }
675
+ }
676
+
677
+ function htmlTypeForStringFormat(format: StringSchemaNode["format"]) {
678
+ switch (format) {
679
+ case "email":
680
+ return "email";
681
+ case "url":
682
+ return "url";
683
+ case "date":
684
+ return "date";
685
+ case "date-time":
686
+ return "datetime-local";
687
+ default:
688
+ return undefined;
689
+ }
690
+ }
691
+
692
+ function firstIssueForField(
693
+ issues: ValidationIssue[],
694
+ fieldPath: FieldSegments
695
+ ) {
696
+ return issues.find((issue) => isIssueForField(issue, fieldPath));
697
+ }
698
+
699
+ function isIssueForField(issue: ValidationIssue, fieldPath: FieldSegments) {
700
+ if (issue.path.length < fieldPath.length) {
701
+ return false;
702
+ }
703
+ return fieldPath.every((segment, index) => issue.path[index] === segment);
704
+ }
705
+
706
+ function parseFieldPath(path: string): FieldSegments {
707
+ const segments: FieldSegments = [];
708
+ const matcher = /([^[.\]]+)|\[(.*?)\]/g;
709
+ for (const match of path.matchAll(matcher)) {
710
+ const token = match[1] ?? match[2];
711
+ if (!token) continue;
712
+ if (/^\d+$/.test(token)) {
713
+ segments.push(Number(token));
714
+ } else {
715
+ segments.push(token);
716
+ }
717
+ }
718
+ return segments;
719
+ }
720
+
721
+ function canonicalizePath(path: string) {
722
+ return formatPath(parseFieldPath(path));
723
+ }
724
+
725
+ function formatPath(path: FieldSegments) {
726
+ return path
727
+ .map((segment, index) => {
728
+ if (typeof segment === "number") {
729
+ return `[${segment}]`;
730
+ }
731
+ return index === 0 ? segment : `.${segment}`;
732
+ })
733
+ .join("");
734
+ }
735
+
736
+ function setPathValue(root: Record<string, unknown>, path: FieldSegments, value: unknown) {
737
+ if (path.length === 0) return;
738
+
739
+ let current: Record<string, unknown> = root;
740
+ for (let index = 0; index < path.length; index++) {
741
+ const segment = path[index];
742
+ const isLast = index === path.length - 1;
743
+ const key = String(segment);
744
+
745
+ if (isLast) {
746
+ current[key] = value;
747
+ return;
748
+ }
749
+
750
+ const existing = current[key];
751
+ if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
752
+ current[key] = {};
753
+ }
754
+ current = current[key] as Record<string, unknown>;
755
+ }
756
+ }
757
+
758
+ function errorIdForPath(path: string, prefix: string) {
759
+ return `${prefix}-error-${canonicalizePath(path).replace(/[^a-zA-Z0-9_-]+/g, "-")}`;
760
+ }
761
+
762
+ function cssEscape(value: string) {
763
+ const cssApi = (globalThis as { CSS?: { escape?(input: string): string } }).CSS;
764
+ if (cssApi?.escape) {
765
+ return cssApi.escape(value);
766
+ }
767
+ return value.replace(/["\\]/g, "\\$&");
768
+ }
769
+
770
+ function isNumericNode(node: SchemaNode): node is NumericSchemaNode {
771
+ return [
772
+ "number",
773
+ "int",
774
+ "float32",
775
+ "float64",
776
+ "int8",
777
+ "int16",
778
+ "int32",
779
+ "int64",
780
+ "uint8",
781
+ "uint16",
782
+ "uint32",
783
+ "uint64",
784
+ ].includes(node.kind);
785
+ }
786
+
787
+ function isIntegerKind(kind: NumericSchemaNode["kind"]) {
788
+ return kind.startsWith("int") || kind.startsWith("uint");
789
+ }