@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/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 {string} message - Human readable message
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
- const regexCache = new Map();
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
- let regex = regexCache.get(source);
38
- if (regex === undefined) {
56
+ return regexCache.getOrCreate(source, (src) => {
39
57
  try {
40
- regex = createRegExp(source);
58
+ return createRegExp(src);
41
59
  }
42
- catch (e) {
43
- regex = null;
60
+ catch (_e) {
61
+ return null;
44
62
  }
45
- if (regexCache.size > 500) regexCache.clear();
46
- regexCache.set(source, regex);
47
- }
48
- return regex;
63
+ });
49
64
  }
50
65
 
51
- function formatValue(value) {
52
- return typeof value === 'string' ? `"${value}"` : JSON.stringify(value);
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', message: 'Must be a valid email' }]
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.push({ keyword: 'required', message: 'This field is required' });
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.push({ keyword: 'const', message: `Must be ${formatValue(field.constValue)}` });
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.push({
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.push({ keyword: 'type', message: 'Must be a string' });
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.push({
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.push({
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.push({ keyword: 'pattern', message: `Must match pattern ${c.pattern}` });
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.push({ keyword: 'format', message: `Must be a valid ${c.format}` });
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.push({ keyword: 'type', message: 'Must be a number' });
160
+ pushError(errors, catalog, 'type', { type: 'number' });
139
161
  return errors;
140
162
  }
141
163
  if (field.kind === 'integer' && !Number.isInteger(num)) {
142
- errors.push({ keyword: 'type', message: 'Must be an integer' });
164
+ pushError(errors, catalog, 'type', { type: 'integer' });
143
165
  }
144
166
  if (c.minimum !== undefined && num < c.minimum) {
145
- errors.push({ keyword: 'minimum', message: `Must be at least ${c.minimum}` });
167
+ pushError(errors, catalog, 'minimum', { limit: c.minimum });
146
168
  }
147
169
  if (c.maximum !== undefined && num > c.maximum) {
148
- errors.push({ keyword: 'maximum', message: `Must be at most ${c.maximum}` });
170
+ pushError(errors, catalog, 'maximum', { limit: c.maximum });
149
171
  }
150
172
  if (c.exclusiveMinimum !== undefined && num <= c.exclusiveMinimum) {
151
- errors.push({ keyword: 'exclusiveMinimum', message: `Must be greater than ${c.exclusiveMinimum}` });
173
+ pushError(errors, catalog, 'exclusiveMinimum', { limit: c.exclusiveMinimum });
152
174
  }
153
175
  if (c.exclusiveMaximum !== undefined && num >= c.exclusiveMaximum) {
154
- errors.push({ keyword: 'exclusiveMaximum', message: `Must be less than ${c.exclusiveMaximum}` });
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.push({ keyword: 'multipleOf', message: `Must be a multiple of ${c.multipleOf}` });
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.push({ keyword: 'type', message: 'Must be a boolean' });
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.push({ keyword: 'type', message: 'Must be an array' });
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.push({
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.push({
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.push({ keyword: 'uniqueItems', message: 'Items must be unique' });
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.push({ keyword: 'type', message: 'Must be an object' });
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.push({ keyword: 'minProperties', message: `Must have at least ${c.minProperties} properties` });
218
+ pushError(errors, catalog, 'minProperties', { limit: c.minProperties });
203
219
  }
204
220
  if (c.maxProperties !== undefined && size > c.maxProperties) {
205
- errors.push({ keyword: 'maxProperties', message: `Must have at most ${c.maxProperties} properties` });
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
- walkFields(child, value[child.key], `${pointer}/${child.key}`, result);
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
  }