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.
- package/CHANGELOG.md +44 -0
- package/README.md +370 -0
- package/VERSION +1 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +12 -0
- package/dist/errors.js.map +1 -0
- package/dist/format/validators.d.ts +2 -0
- package/dist/format/validators.d.ts.map +1 -0
- package/dist/format/validators.js +57 -0
- package/dist/format/validators.js.map +1 -0
- package/dist/forms/index.d.ts +57 -0
- package/dist/forms/index.d.ts.map +1 -0
- package/dist/forms/index.js +586 -0
- package/dist/forms/index.js.map +1 -0
- package/dist/index.d.ts +93 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +156 -0
- package/dist/index.js.map +1 -0
- package/dist/infer.d.ts +8 -0
- package/dist/infer.d.ts.map +1 -0
- package/dist/infer.js +2 -0
- package/dist/infer.js.map +1 -0
- package/dist/interchange/document.d.ts +5 -0
- package/dist/interchange/document.d.ts.map +1 -0
- package/dist/interchange/document.js +12 -0
- package/dist/interchange/document.js.map +1 -0
- package/dist/interchange/exporter.d.ts +7 -0
- package/dist/interchange/exporter.d.ts.map +1 -0
- package/dist/interchange/exporter.js +7 -0
- package/dist/interchange/exporter.js.map +1 -0
- package/dist/interchange/importer.d.ts +4 -0
- package/dist/interchange/importer.d.ts.map +1 -0
- package/dist/interchange/importer.js +229 -0
- package/dist/interchange/importer.js.map +1 -0
- package/dist/issue-codes.d.ts +19 -0
- package/dist/issue-codes.d.ts.map +1 -0
- package/dist/issue-codes.js +18 -0
- package/dist/issue-codes.js.map +1 -0
- package/dist/parse/coerce.d.ts +16 -0
- package/dist/parse/coerce.d.ts.map +1 -0
- package/dist/parse/coerce.js +115 -0
- package/dist/parse/coerce.js.map +1 -0
- package/dist/parse/defaults.d.ts +7 -0
- package/dist/parse/defaults.d.ts.map +1 -0
- package/dist/parse/defaults.js +12 -0
- package/dist/parse/defaults.js.map +1 -0
- package/dist/parse/parser.d.ts +11 -0
- package/dist/parse/parser.d.ts.map +1 -0
- package/dist/parse/parser.js +13 -0
- package/dist/parse/parser.js.map +1 -0
- package/dist/schemas/any.d.ts +7 -0
- package/dist/schemas/any.d.ts.map +1 -0
- package/dist/schemas/any.js +12 -0
- package/dist/schemas/any.js.map +1 -0
- package/dist/schemas/array.d.ts +13 -0
- package/dist/schemas/array.d.ts.map +1 -0
- package/dist/schemas/array.js +73 -0
- package/dist/schemas/array.js.map +1 -0
- package/dist/schemas/base.d.ts +37 -0
- package/dist/schemas/base.d.ts.map +1 -0
- package/dist/schemas/base.js +285 -0
- package/dist/schemas/base.js.map +1 -0
- package/dist/schemas/bool.d.ts +8 -0
- package/dist/schemas/bool.d.ts.map +1 -0
- package/dist/schemas/bool.js +27 -0
- package/dist/schemas/bool.js.map +1 -0
- package/dist/schemas/enum.d.ts +9 -0
- package/dist/schemas/enum.d.ts.map +1 -0
- package/dist/schemas/enum.js +31 -0
- package/dist/schemas/enum.js.map +1 -0
- package/dist/schemas/index.d.ts +21 -0
- package/dist/schemas/index.d.ts.map +1 -0
- package/dist/schemas/index.js +21 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/schemas/int.d.ts +32 -0
- package/dist/schemas/int.d.ts.map +1 -0
- package/dist/schemas/int.js +108 -0
- package/dist/schemas/int.js.map +1 -0
- package/dist/schemas/intersection.d.ts +16 -0
- package/dist/schemas/intersection.d.ts.map +1 -0
- package/dist/schemas/intersection.js +58 -0
- package/dist/schemas/intersection.js.map +1 -0
- package/dist/schemas/literal.d.ts +11 -0
- package/dist/schemas/literal.d.ts.map +1 -0
- package/dist/schemas/literal.js +28 -0
- package/dist/schemas/literal.js.map +1 -0
- package/dist/schemas/never.d.ts +7 -0
- package/dist/schemas/never.d.ts.map +1 -0
- package/dist/schemas/never.js +19 -0
- package/dist/schemas/never.js.map +1 -0
- package/dist/schemas/null.d.ts +7 -0
- package/dist/schemas/null.d.ts.map +1 -0
- package/dist/schemas/null.js +24 -0
- package/dist/schemas/null.js.map +1 -0
- package/dist/schemas/nullable.d.ts +10 -0
- package/dist/schemas/nullable.d.ts.map +1 -0
- package/dist/schemas/nullable.js +29 -0
- package/dist/schemas/nullable.js.map +1 -0
- package/dist/schemas/number.d.ts +27 -0
- package/dist/schemas/number.d.ts.map +1 -0
- package/dist/schemas/number.js +134 -0
- package/dist/schemas/number.js.map +1 -0
- package/dist/schemas/object.d.ts +28 -0
- package/dist/schemas/object.d.ts.map +1 -0
- package/dist/schemas/object.js +153 -0
- package/dist/schemas/object.js.map +1 -0
- package/dist/schemas/optional.d.ts +11 -0
- package/dist/schemas/optional.d.ts.map +1 -0
- package/dist/schemas/optional.js +39 -0
- package/dist/schemas/optional.js.map +1 -0
- package/dist/schemas/record.d.ts +9 -0
- package/dist/schemas/record.d.ts.map +1 -0
- package/dist/schemas/record.js +45 -0
- package/dist/schemas/record.js.map +1 -0
- package/dist/schemas/ref.d.ts +10 -0
- package/dist/schemas/ref.d.ts.map +1 -0
- package/dist/schemas/ref.js +30 -0
- package/dist/schemas/ref.js.map +1 -0
- package/dist/schemas/string.d.ts +29 -0
- package/dist/schemas/string.d.ts.map +1 -0
- package/dist/schemas/string.js +181 -0
- package/dist/schemas/string.js.map +1 -0
- package/dist/schemas/tuple.d.ts +14 -0
- package/dist/schemas/tuple.d.ts.map +1 -0
- package/dist/schemas/tuple.js +59 -0
- package/dist/schemas/tuple.js.map +1 -0
- package/dist/schemas/union.d.ts +9 -0
- package/dist/schemas/union.d.ts.map +1 -0
- package/dist/schemas/union.js +45 -0
- package/dist/schemas/union.js.map +1 -0
- package/dist/schemas/unknown.d.ts +7 -0
- package/dist/schemas/unknown.d.ts.map +1 -0
- package/dist/schemas/unknown.js +12 -0
- package/dist/schemas/unknown.js.map +1 -0
- package/dist/types.d.ts +132 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/util.d.ts +6 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +12 -0
- package/dist/util.js.map +1 -0
- package/package.json +41 -0
- package/sdk/js/CHANGELOG.md +13 -0
- package/src/errors.ts +17 -0
- package/src/format/validators.ts +71 -0
- package/src/forms/index.ts +789 -0
- package/src/index.ts +285 -0
- package/src/infer.ts +12 -0
- package/src/interchange/document.ts +18 -0
- package/src/interchange/exporter.ts +12 -0
- package/src/interchange/importer.ts +285 -0
- package/src/issue-codes.ts +19 -0
- package/src/parse/coerce.ts +133 -0
- package/src/parse/defaults.ts +15 -0
- package/src/parse/parser.ts +19 -0
- package/src/schemas/any.ts +14 -0
- package/src/schemas/array.ts +83 -0
- package/src/schemas/base.ts +322 -0
- package/src/schemas/bool.ts +30 -0
- package/src/schemas/enum.ts +37 -0
- package/src/schemas/index.ts +30 -0
- package/src/schemas/int.ts +129 -0
- package/src/schemas/intersection.ts +81 -0
- package/src/schemas/literal.ts +34 -0
- package/src/schemas/never.ts +21 -0
- package/src/schemas/null.ts +26 -0
- package/src/schemas/nullable.ts +36 -0
- package/src/schemas/number.ts +151 -0
- package/src/schemas/object.ts +203 -0
- package/src/schemas/optional.ts +49 -0
- package/src/schemas/record.ts +55 -0
- package/src/schemas/ref.ts +35 -0
- package/src/schemas/string.ts +192 -0
- package/src/schemas/tuple.ts +74 -0
- package/src/schemas/union.ts +53 -0
- package/src/schemas/unknown.ts +14 -0
- package/src/types.ts +239 -0
- package/src/util.ts +9 -0
- package/tests/conformance/runner.test.ts +28 -0
- package/tests/conformance/runner.ts +137 -0
- package/tests/forms.test.ts +146 -0
- package/tests/unit/coerce.test.ts +136 -0
- package/tests/unit/collections.test.ts +99 -0
- package/tests/unit/composition.test.ts +80 -0
- package/tests/unit/date-format.test.ts +18 -0
- package/tests/unit/default-mutation.test.ts +32 -0
- package/tests/unit/defaults.test.ts +49 -0
- package/tests/unit/errors.test.ts +53 -0
- package/tests/unit/export.test.ts +270 -0
- package/tests/unit/inference.test.ts +306 -0
- package/tests/unit/interchange.test.ts +191 -0
- package/tests/unit/number.test.ts +195 -0
- package/tests/unit/object.test.ts +208 -0
- package/tests/unit/parser.test.ts +151 -0
- package/tests/unit/primitives.test.ts +111 -0
- package/tests/unit/security-recursion.test.ts +105 -0
- package/tests/unit/security.test.ts +945 -0
- package/tests/unit/shared-ref-falsepos.test.ts +33 -0
- package/tests/unit/string-pattern-redos.test.ts +46 -0
- package/tests/unit/string.test.ts +147 -0
- package/tsconfig.json +21 -0
- 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
|
+
}
|