@jarenjs/forms 0.9.2 → 0.34.0
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/README.md +204 -7
- package/dist/types/data.d.ts +23 -4
- package/dist/types/deps.d.ts +30 -0
- package/dist/types/index.d.ts +3 -1
- package/dist/types/messages.d.ts +36 -0
- package/dist/types/model.d.ts +34 -25
- package/dist/types/rules.d.ts +138 -26
- package/dist/types/validate.d.ts +15 -4
- package/dist/types/viewmodel.d.ts +373 -0
- package/package.json +6 -5
- package/src/data.js +133 -46
- package/src/deps.js +179 -0
- package/src/formats.js +35 -10
- package/src/index.js +13 -0
- package/src/messages.js +106 -0
- package/src/model.js +140 -42
- package/src/rules.js +454 -55
- package/src/validate.js +79 -59
- package/src/viewmodel.js +419 -0
package/src/validate.js
CHANGED
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
* remains authoritative for cross-field rules (required combinations,
|
|
11
11
|
* dependencies, unevaluatedProperties, ...). Cross-field feedback per
|
|
12
12
|
* keystroke is rules.js territory (the `x-form` annotation).
|
|
13
|
+
*
|
|
14
|
+
* Every failure is structured: a stable `msgid`
|
|
15
|
+
* (`form/<keyword>`) plus raw `params`, with `message` rendered eagerly -
|
|
16
|
+
* failure-only, cheap - through a catalog (messages.js), so consumers can
|
|
17
|
+
* re-render in another locale from `msgid` + `params`.
|
|
13
18
|
*/
|
|
14
19
|
|
|
15
20
|
import {
|
|
@@ -21,35 +26,59 @@ import {
|
|
|
21
26
|
equalsDeep,
|
|
22
27
|
isUniqueDeepArray,
|
|
23
28
|
} from '@jarenjs/core/object';
|
|
29
|
+
import { createBoundedCache } from '@jarenjs/core/cache';
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
encodeJSONPointerSegment,
|
|
33
|
+
} from '@jarenjs/json/pointer';
|
|
24
34
|
|
|
25
35
|
import {
|
|
26
36
|
getFormatInfo,
|
|
27
37
|
} from './formats.js';
|
|
28
38
|
|
|
39
|
+
import {
|
|
40
|
+
renderFormsMessage,
|
|
41
|
+
} from './messages.js';
|
|
42
|
+
|
|
29
43
|
/**
|
|
30
44
|
* @typedef {object} FieldError
|
|
31
45
|
* @property {string} keyword - The JSON Schema keyword that failed
|
|
32
|
-
* @property {
|
|
46
|
+
* @property {object} params - Structured, keyword-specific parameters
|
|
47
|
+
* @property {string} msgid - Stable message key (`form/<keyword>` or `x-form/assert`)
|
|
48
|
+
* @property {string} message - Human readable message (rendered through a catalog)
|
|
33
49
|
*/
|
|
34
50
|
|
|
35
|
-
|
|
51
|
+
// Bounded LRU (`@jarenjs/core/cache`) replacing the old flush-all-at-500
|
|
52
|
+
// variant; `null` is the cached "invalid pattern" verdict (the cache's
|
|
53
|
+
// miss sentinel is `undefined`, so a negative result is a real entry).
|
|
54
|
+
const regexCache = createBoundedCache(500);
|
|
36
55
|
function getPattern(source) {
|
|
37
|
-
|
|
38
|
-
if (regex === undefined) {
|
|
56
|
+
return regexCache.getOrCreate(source, (src) => {
|
|
39
57
|
try {
|
|
40
|
-
|
|
58
|
+
return createRegExp(src);
|
|
41
59
|
}
|
|
42
|
-
catch (
|
|
43
|
-
|
|
60
|
+
catch (_e) {
|
|
61
|
+
return null;
|
|
44
62
|
}
|
|
45
|
-
|
|
46
|
-
regexCache.set(source, regex);
|
|
47
|
-
}
|
|
48
|
-
return regex;
|
|
63
|
+
});
|
|
49
64
|
}
|
|
50
65
|
|
|
51
|
-
|
|
52
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Push one structured field error, rendering its message through the
|
|
68
|
+
* catalog (built-in English fallback). Failure-only path.
|
|
69
|
+
* @param {FieldError[]} errors - The output array
|
|
70
|
+
* @param {Readonly<Record<string, (params: object, error?: object) => string>>|undefined} catalog - Compiled catalog or undefined for English
|
|
71
|
+
* @param {string} keyword - The failed keyword
|
|
72
|
+
* @param {object} params - The structured params
|
|
73
|
+
*/
|
|
74
|
+
function pushError(errors, catalog, keyword, params) {
|
|
75
|
+
const msgid = `form/${keyword}`;
|
|
76
|
+
errors.push({
|
|
77
|
+
keyword,
|
|
78
|
+
params,
|
|
79
|
+
msgid,
|
|
80
|
+
message: renderFormsMessage(catalog, msgid, params),
|
|
81
|
+
});
|
|
53
82
|
}
|
|
54
83
|
|
|
55
84
|
/**
|
|
@@ -57,12 +86,14 @@ function formatValue(value) {
|
|
|
57
86
|
*
|
|
58
87
|
* @param {import('./model.js').FormField} field - Field from buildFormModel
|
|
59
88
|
* @param {any} value - The TYPED value (see parseFieldInput); undefined = absent
|
|
89
|
+
* @param {Readonly<Record<string, (params: object, error?: object) => string>>} [catalog] - Optional compiled message catalog (see messages.js), default English
|
|
60
90
|
* @returns {FieldError[]} Empty when the value passes every per-field check
|
|
61
91
|
* @example
|
|
62
92
|
* const errors = validateField(emailField, 'not-an-email');
|
|
63
|
-
* // [{ keyword: 'format',
|
|
93
|
+
* // [{ keyword: 'format', params: { format: 'email' },
|
|
94
|
+
* // msgid: 'form/format', message: 'Must be a valid email' }]
|
|
64
95
|
*/
|
|
65
|
-
export function validateField(field, value) {
|
|
96
|
+
export function validateField(field, value, catalog = undefined) {
|
|
66
97
|
/** @type {FieldError[]} */
|
|
67
98
|
const errors = [];
|
|
68
99
|
if (field == null) return errors;
|
|
@@ -70,7 +101,7 @@ export function validateField(field, value) {
|
|
|
70
101
|
// Absent value: only `required` applies
|
|
71
102
|
if (value === undefined || value === null) {
|
|
72
103
|
if (field.required && field.kind !== 'boolean') {
|
|
73
|
-
errors
|
|
104
|
+
pushError(errors, catalog, 'required', {});
|
|
74
105
|
}
|
|
75
106
|
return errors;
|
|
76
107
|
}
|
|
@@ -80,24 +111,21 @@ export function validateField(field, value) {
|
|
|
80
111
|
switch (field.kind) {
|
|
81
112
|
case 'const': {
|
|
82
113
|
if (!equalsDeep(value, field.constValue)) {
|
|
83
|
-
errors
|
|
114
|
+
pushError(errors, catalog, 'const', { constValue: field.constValue });
|
|
84
115
|
}
|
|
85
116
|
return errors;
|
|
86
117
|
}
|
|
87
118
|
|
|
88
119
|
case 'enum': {
|
|
89
120
|
if (!field.enumValues?.some((option) => equalsDeep(value, option))) {
|
|
90
|
-
errors.
|
|
91
|
-
keyword: 'enum',
|
|
92
|
-
message: `Must be one of: ${field.enumValues?.map(formatValue).join(', ')}`,
|
|
93
|
-
});
|
|
121
|
+
pushError(errors, catalog, 'enum', { enumValues: field.enumValues });
|
|
94
122
|
}
|
|
95
123
|
return errors;
|
|
96
124
|
}
|
|
97
125
|
|
|
98
126
|
case 'string': {
|
|
99
127
|
if (typeof value !== 'string') {
|
|
100
|
-
errors
|
|
128
|
+
pushError(errors, catalog, 'type', { type: 'string' });
|
|
101
129
|
return errors;
|
|
102
130
|
}
|
|
103
131
|
let len = -1;
|
|
@@ -105,27 +133,21 @@ export function validateField(field, value) {
|
|
|
105
133
|
len = getStringLength(value, true); // grapheme-aware, like the validator
|
|
106
134
|
}
|
|
107
135
|
if (c.minLength !== undefined && len < c.minLength) {
|
|
108
|
-
errors.
|
|
109
|
-
keyword: 'minLength',
|
|
110
|
-
message: `Must be at least ${c.minLength} character${c.minLength === 1 ? '' : 's'} (currently ${len})`,
|
|
111
|
-
});
|
|
136
|
+
pushError(errors, catalog, 'minLength', { limit: c.minLength, len });
|
|
112
137
|
}
|
|
113
138
|
if (c.maxLength !== undefined && len > c.maxLength) {
|
|
114
|
-
errors.
|
|
115
|
-
keyword: 'maxLength',
|
|
116
|
-
message: `Must be at most ${c.maxLength} character${c.maxLength === 1 ? '' : 's'} (currently ${len})`,
|
|
117
|
-
});
|
|
139
|
+
pushError(errors, catalog, 'maxLength', { limit: c.maxLength, len });
|
|
118
140
|
}
|
|
119
141
|
if (c.pattern !== undefined) {
|
|
120
142
|
const regex = getPattern(c.pattern);
|
|
121
143
|
if (regex != null && !regex.test(value)) {
|
|
122
|
-
errors
|
|
144
|
+
pushError(errors, catalog, 'pattern', { pattern: c.pattern });
|
|
123
145
|
}
|
|
124
146
|
}
|
|
125
147
|
if (c.format !== undefined && value !== '') {
|
|
126
148
|
const info = getFormatInfo(c.format);
|
|
127
149
|
if (info != null && !info.test(value)) {
|
|
128
|
-
errors
|
|
150
|
+
pushError(errors, catalog, 'format', { format: c.format });
|
|
129
151
|
}
|
|
130
152
|
}
|
|
131
153
|
return errors;
|
|
@@ -135,28 +157,28 @@ export function validateField(field, value) {
|
|
|
135
157
|
case 'integer': {
|
|
136
158
|
const num = typeof value === 'number' ? value : Number(value);
|
|
137
159
|
if (typeof value === 'boolean' || Number.isNaN(num)) {
|
|
138
|
-
errors
|
|
160
|
+
pushError(errors, catalog, 'type', { type: 'number' });
|
|
139
161
|
return errors;
|
|
140
162
|
}
|
|
141
163
|
if (field.kind === 'integer' && !Number.isInteger(num)) {
|
|
142
|
-
errors
|
|
164
|
+
pushError(errors, catalog, 'type', { type: 'integer' });
|
|
143
165
|
}
|
|
144
166
|
if (c.minimum !== undefined && num < c.minimum) {
|
|
145
|
-
errors
|
|
167
|
+
pushError(errors, catalog, 'minimum', { limit: c.minimum });
|
|
146
168
|
}
|
|
147
169
|
if (c.maximum !== undefined && num > c.maximum) {
|
|
148
|
-
errors
|
|
170
|
+
pushError(errors, catalog, 'maximum', { limit: c.maximum });
|
|
149
171
|
}
|
|
150
172
|
if (c.exclusiveMinimum !== undefined && num <= c.exclusiveMinimum) {
|
|
151
|
-
errors
|
|
173
|
+
pushError(errors, catalog, 'exclusiveMinimum', { limit: c.exclusiveMinimum });
|
|
152
174
|
}
|
|
153
175
|
if (c.exclusiveMaximum !== undefined && num >= c.exclusiveMaximum) {
|
|
154
|
-
errors
|
|
176
|
+
pushError(errors, catalog, 'exclusiveMaximum', { limit: c.exclusiveMaximum });
|
|
155
177
|
}
|
|
156
178
|
if (c.multipleOf !== undefined) {
|
|
157
179
|
const quotient = num / c.multipleOf;
|
|
158
180
|
if (Math.abs(quotient - Math.round(quotient)) >= 1e-6) {
|
|
159
|
-
errors
|
|
181
|
+
pushError(errors, catalog, 'multipleOf', { multipleOf: c.multipleOf });
|
|
160
182
|
}
|
|
161
183
|
}
|
|
162
184
|
return errors;
|
|
@@ -164,45 +186,39 @@ export function validateField(field, value) {
|
|
|
164
186
|
|
|
165
187
|
case 'boolean': {
|
|
166
188
|
if (typeof value !== 'boolean') {
|
|
167
|
-
errors
|
|
189
|
+
pushError(errors, catalog, 'type', { type: 'boolean' });
|
|
168
190
|
}
|
|
169
191
|
return errors;
|
|
170
192
|
}
|
|
171
193
|
|
|
172
194
|
case 'array': {
|
|
173
195
|
if (!Array.isArray(value)) {
|
|
174
|
-
errors
|
|
196
|
+
pushError(errors, catalog, 'type', { type: 'array' });
|
|
175
197
|
return errors;
|
|
176
198
|
}
|
|
177
199
|
if (c.minItems !== undefined && value.length < c.minItems) {
|
|
178
|
-
errors.
|
|
179
|
-
keyword: 'minItems',
|
|
180
|
-
message: `Must have at least ${c.minItems} item${c.minItems === 1 ? '' : 's'}`,
|
|
181
|
-
});
|
|
200
|
+
pushError(errors, catalog, 'minItems', { limit: c.minItems });
|
|
182
201
|
}
|
|
183
202
|
if (c.maxItems !== undefined && value.length > c.maxItems) {
|
|
184
|
-
errors.
|
|
185
|
-
keyword: 'maxItems',
|
|
186
|
-
message: `Must have at most ${c.maxItems} item${c.maxItems === 1 ? '' : 's'}`,
|
|
187
|
-
});
|
|
203
|
+
pushError(errors, catalog, 'maxItems', { limit: c.maxItems });
|
|
188
204
|
}
|
|
189
205
|
if (c.uniqueItems === true && !isUniqueDeepArray(value)) {
|
|
190
|
-
errors
|
|
206
|
+
pushError(errors, catalog, 'uniqueItems', {});
|
|
191
207
|
}
|
|
192
208
|
return errors;
|
|
193
209
|
}
|
|
194
210
|
|
|
195
211
|
case 'object': {
|
|
196
212
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
197
|
-
errors
|
|
213
|
+
pushError(errors, catalog, 'type', { type: 'object' });
|
|
198
214
|
return errors;
|
|
199
215
|
}
|
|
200
216
|
const size = Object.keys(value).length;
|
|
201
217
|
if (c.minProperties !== undefined && size < c.minProperties) {
|
|
202
|
-
errors
|
|
218
|
+
pushError(errors, catalog, 'minProperties', { limit: c.minProperties });
|
|
203
219
|
}
|
|
204
220
|
if (c.maxProperties !== undefined && size > c.maxProperties) {
|
|
205
|
-
errors
|
|
221
|
+
pushError(errors, catalog, 'maxProperties', { limit: c.maxProperties });
|
|
206
222
|
}
|
|
207
223
|
return errors;
|
|
208
224
|
}
|
|
@@ -217,28 +233,32 @@ export function validateField(field, value) {
|
|
|
217
233
|
* Returns a map of data-pointer -> FieldError[] for fields that fail.
|
|
218
234
|
* @param {import('./model.js').FormField} model - Root field from buildFormModel
|
|
219
235
|
* @param {any} data - Current form data
|
|
236
|
+
* @param {Readonly<Record<string, (params: object, error?: object) => string>>} [catalog] - Optional compiled message catalog, default English
|
|
220
237
|
* @returns {Record<string, FieldError[]>}
|
|
221
238
|
*/
|
|
222
|
-
export function validateAllFields(model, data) {
|
|
239
|
+
export function validateAllFields(model, data, catalog = undefined) {
|
|
223
240
|
/** @type {Record<string, FieldError[]>} */
|
|
224
241
|
const result = {};
|
|
225
|
-
walkFields(model, data, '', result);
|
|
242
|
+
walkFields(model, data, '', result, catalog);
|
|
226
243
|
return result;
|
|
227
244
|
}
|
|
228
245
|
|
|
229
|
-
function walkFields(field, value, pointer, result) {
|
|
230
|
-
const errors = validateField(field, value);
|
|
246
|
+
function walkFields(field, value, pointer, result, catalog) {
|
|
247
|
+
const errors = validateField(field, value, catalog);
|
|
231
248
|
if (errors.length > 0) result[pointer] = errors;
|
|
232
249
|
|
|
233
250
|
if (field.kind === 'object' && field.children && value != null && typeof value === 'object') {
|
|
234
251
|
for (const child of field.children) {
|
|
235
|
-
|
|
252
|
+
// RFC 6901-encoded, the walk convention shared with the model
|
|
253
|
+
// and rule pointers — a member name containing '/' or '~' stays
|
|
254
|
+
// addressable and never collides with a nested path
|
|
255
|
+
walkFields(child, value[child.key], `${pointer}/${encodeJSONPointerSegment(child.key)}`, result, catalog);
|
|
236
256
|
}
|
|
237
257
|
}
|
|
238
258
|
else if (field.kind === 'array' && Array.isArray(value)) {
|
|
239
259
|
for (let i = 0; i < value.length; i++) {
|
|
240
260
|
const itemField = field.tuple?.[i] ?? field.item;
|
|
241
|
-
if (itemField) walkFields(itemField, value[i], `${pointer}/${i}`, result);
|
|
261
|
+
if (itemField) walkFields(itemField, value[i], `${pointer}/${i}`, result, catalog);
|
|
242
262
|
}
|
|
243
263
|
}
|
|
244
264
|
}
|