@jarenjs/forms 0.9.2
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/LICENSE +21 -0
- package/README.md +196 -0
- package/dist/types/data.d.ts +67 -0
- package/dist/types/formats.d.ts +22 -0
- package/dist/types/index.d.ts +17 -0
- package/dist/types/model.d.ts +125 -0
- package/dist/types/rules.d.ts +131 -0
- package/dist/types/validate.d.ts +29 -0
- package/package.json +54 -0
- package/src/data.js +223 -0
- package/src/formats.js +120 -0
- package/src/index.js +49 -0
- package/src/model.js +318 -0
- package/src/rules.js +404 -0
- package/src/validate.js +244 -0
package/src/model.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Form model builder: turns a JSON Schema into a tree of field descriptors
|
|
5
|
+
* that a UI layer (React, vanilla DOM, ...) can render as a form.
|
|
6
|
+
*
|
|
7
|
+
* The builder resolves local `$ref`s (`#/$defs/...`, `#/definitions/...`),
|
|
8
|
+
* shallowly merges `allOf` branches, and annotates every field with the
|
|
9
|
+
* constraints and rendering hints needed for preemptive per-field
|
|
10
|
+
* validation (see validate.js).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
compileJSONPointer,
|
|
15
|
+
JSONPOINTER_NOTHING,
|
|
16
|
+
} from '@jarenjs/json/pointer';
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
getFormatInfo,
|
|
20
|
+
} from './formats.js';
|
|
21
|
+
|
|
22
|
+
const DEFAULT_MAX_DEPTH = 24;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {object} FormField
|
|
26
|
+
* @property {string} pointer - JSON pointer into the DATA (e.g. '/user/name')
|
|
27
|
+
* @property {string} key - Property name (or '-' for an array item template)
|
|
28
|
+
* @property {string} label - Human friendly label (schema title or humanized key)
|
|
29
|
+
* @property {string|undefined} description
|
|
30
|
+
* @property {object} schema - The resolved subschema for this field
|
|
31
|
+
* @property {string} kind - 'string'|'number'|'integer'|'boolean'|'enum'|'const'|'object'|'array'|'unknown'
|
|
32
|
+
* @property {string} control - Suggested control: 'text'|'email'|'url'|'password'|'textarea'|'number'|'checkbox'|'select'|'date'|'color'|'json'
|
|
33
|
+
* @property {boolean} required - Whether the parent object requires this property
|
|
34
|
+
* @property {boolean} readOnly
|
|
35
|
+
* @property {Array<any>|null} enumValues - Options for a select control
|
|
36
|
+
* @property {any} constValue - Fixed value when the schema is a const
|
|
37
|
+
* @property {any} defaultValue
|
|
38
|
+
* @property {string|undefined} placeholder
|
|
39
|
+
* @property {object} constraints - minLength/maxLength/pattern/minimum/... extracted for the UI
|
|
40
|
+
* @property {object|null} rules - The raw `x-form` rules annotation, if any (see rules.js)
|
|
41
|
+
* @property {Array<FormField>|null} children - Child fields for object kinds
|
|
42
|
+
* @property {FormField|null} item - Template field for array items
|
|
43
|
+
* @property {Array<FormField>|null} tuple - Fixed prefix fields for tuple arrays
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Convert 'firstName' / 'first_name' / 'first-name' to 'First Name'.
|
|
48
|
+
* @param {string} key
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function humanizeKey(key) {
|
|
52
|
+
if (typeof key !== 'string' || key.length === 0) return String(key);
|
|
53
|
+
return key
|
|
54
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
55
|
+
.replace(/[_-]+/g, ' ')
|
|
56
|
+
.replace(/\s+/g, ' ')
|
|
57
|
+
.trim()
|
|
58
|
+
.replace(/(^|\s)\S/g, (c) => c.toUpperCase());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve a local JSON pointer ('#/$defs/foo') inside the root document
|
|
63
|
+
* through the shared @jarenjs/json pointer walk (RFC 6901: the URI
|
|
64
|
+
* fragment percent-decodes to the pointer text).
|
|
65
|
+
* @param {string} ref
|
|
66
|
+
* @param {object} rootSchema
|
|
67
|
+
* @returns {object|boolean|null} The referenced schema or null when unresolvable
|
|
68
|
+
*/
|
|
69
|
+
function resolveLocalRef(ref, rootSchema) {
|
|
70
|
+
if (typeof ref !== 'string' || !ref.startsWith('#/')) return null;
|
|
71
|
+
let pointer = ref.slice(1);
|
|
72
|
+
try {
|
|
73
|
+
if (pointer.indexOf('%') >= 0) pointer = decodeURIComponent(pointer);
|
|
74
|
+
const target = compileJSONPointer(pointer)(rootSchema);
|
|
75
|
+
return target === JSONPOINTER_NOTHING ? null : target;
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
return null; // malformed fragment: same 'unresolvable' answer as a missing target
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve local $refs and shallowly merge allOf branches into a single
|
|
84
|
+
* effective schema object for form purposes.
|
|
85
|
+
* @param {object|boolean} schema
|
|
86
|
+
* @param {object} rootSchema
|
|
87
|
+
* @param {number} depth
|
|
88
|
+
* @returns {object|boolean}
|
|
89
|
+
*/
|
|
90
|
+
export function resolveSchema(schema, rootSchema, depth = 0) {
|
|
91
|
+
if (depth > DEFAULT_MAX_DEPTH) return schema;
|
|
92
|
+
if (schema == null || typeof schema !== 'object' || Array.isArray(schema)) return schema;
|
|
93
|
+
|
|
94
|
+
let resolved = schema;
|
|
95
|
+
|
|
96
|
+
if (typeof schema.$ref === 'string') {
|
|
97
|
+
const target = resolveLocalRef(schema.$ref, rootSchema);
|
|
98
|
+
if (target != null && typeof target === 'object') {
|
|
99
|
+
const deref = resolveSchema(target, rootSchema, depth + 1);
|
|
100
|
+
// 2019-09+: siblings apply together with the referenced schema
|
|
101
|
+
const { $ref, ...siblings } = schema;
|
|
102
|
+
resolved = (deref && typeof deref === 'object')
|
|
103
|
+
? { ...deref, ...siblings }
|
|
104
|
+
: deref;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (resolved && typeof resolved === 'object' && Array.isArray(resolved.allOf)) {
|
|
109
|
+
const merged = { ...resolved };
|
|
110
|
+
delete merged.allOf;
|
|
111
|
+
const requiredSets = merged.required ? [merged.required] : [];
|
|
112
|
+
for (const branch of resolved.allOf) {
|
|
113
|
+
const sub = resolveSchema(branch, rootSchema, depth + 1);
|
|
114
|
+
if (sub == null || typeof sub !== 'object') continue;
|
|
115
|
+
if (sub.properties) {
|
|
116
|
+
merged.properties = { ...sub.properties, ...(merged.properties || {}) };
|
|
117
|
+
}
|
|
118
|
+
if (Array.isArray(sub.required)) requiredSets.push(sub.required);
|
|
119
|
+
for (const key of Object.keys(sub)) {
|
|
120
|
+
if (key === 'properties' || key === 'required' || key === 'allOf') continue;
|
|
121
|
+
if (merged[key] === undefined) merged[key] = sub[key];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (requiredSets.length > 0) {
|
|
125
|
+
merged.required = [...new Set(requiredSets.flat())];
|
|
126
|
+
}
|
|
127
|
+
resolved = merged;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return resolved;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Derive the field kind from a resolved schema.
|
|
135
|
+
* @param {object|boolean} schema
|
|
136
|
+
* @returns {string}
|
|
137
|
+
*/
|
|
138
|
+
export function getFieldKind(schema) {
|
|
139
|
+
if (schema == null || typeof schema !== 'object') return 'unknown';
|
|
140
|
+
if (schema.const !== undefined) return 'const';
|
|
141
|
+
if (Array.isArray(schema.enum)) return 'enum';
|
|
142
|
+
|
|
143
|
+
let type = schema.type;
|
|
144
|
+
if (Array.isArray(type)) {
|
|
145
|
+
// Pick the first non-null type for rendering purposes
|
|
146
|
+
type = type.find((t) => t !== 'null') ?? type[0];
|
|
147
|
+
}
|
|
148
|
+
switch (type) {
|
|
149
|
+
case 'string': return 'string';
|
|
150
|
+
case 'number': return 'number';
|
|
151
|
+
case 'integer': return 'integer';
|
|
152
|
+
case 'boolean': return 'boolean';
|
|
153
|
+
case 'object': return 'object';
|
|
154
|
+
case 'array': return 'array';
|
|
155
|
+
default: break;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Infer from structural keywords when type is absent
|
|
159
|
+
if (schema.properties || schema.patternProperties || schema.additionalProperties !== undefined) return 'object';
|
|
160
|
+
if (schema.items !== undefined || schema.prefixItems !== undefined) return 'array';
|
|
161
|
+
if (schema.minLength !== undefined || schema.maxLength !== undefined || schema.pattern !== undefined || schema.format !== undefined) return 'string';
|
|
162
|
+
if (schema.minimum !== undefined || schema.maximum !== undefined || schema.multipleOf !== undefined) return 'number';
|
|
163
|
+
return 'unknown';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Derive the suggested UI control for a field.
|
|
168
|
+
* @param {string} kind
|
|
169
|
+
* @param {object} schema
|
|
170
|
+
* @returns {string}
|
|
171
|
+
*/
|
|
172
|
+
function getControl(kind, schema) {
|
|
173
|
+
switch (kind) {
|
|
174
|
+
case 'const': return 'const';
|
|
175
|
+
case 'enum': return 'select';
|
|
176
|
+
case 'boolean': return 'checkbox';
|
|
177
|
+
case 'number':
|
|
178
|
+
case 'integer': return 'number';
|
|
179
|
+
case 'object': return 'object';
|
|
180
|
+
case 'array': return 'array';
|
|
181
|
+
case 'string': {
|
|
182
|
+
const info = getFormatInfo(schema.format);
|
|
183
|
+
if (info) return info.control;
|
|
184
|
+
if (schema.contentEncoding === 'base64' || schema.contentMediaType) return 'textarea';
|
|
185
|
+
const max = schema.maxLength;
|
|
186
|
+
if (max !== undefined && max > 120) return 'textarea';
|
|
187
|
+
return 'text';
|
|
188
|
+
}
|
|
189
|
+
default: return 'json';
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Extract the constraint set the UI and the preemptive field validation use.
|
|
195
|
+
* @param {object} schema
|
|
196
|
+
* @returns {object}
|
|
197
|
+
*/
|
|
198
|
+
function getConstraints(schema) {
|
|
199
|
+
const c = {};
|
|
200
|
+
for (const key of [
|
|
201
|
+
'minLength', 'maxLength', 'pattern', 'format',
|
|
202
|
+
'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf',
|
|
203
|
+
'minItems', 'maxItems', 'uniqueItems',
|
|
204
|
+
'minProperties', 'maxProperties',
|
|
205
|
+
]) {
|
|
206
|
+
if (schema[key] !== undefined) c[key] = schema[key];
|
|
207
|
+
}
|
|
208
|
+
return c;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Build a single field descriptor.
|
|
213
|
+
* @param {object|boolean} rawSchema - The (possibly unresolved) subschema
|
|
214
|
+
* @param {object} rootSchema - The root schema document for $ref resolution
|
|
215
|
+
* @param {string} pointer - JSON pointer into the data
|
|
216
|
+
* @param {string} key - Property name or '-' for an item template
|
|
217
|
+
* @param {boolean} required
|
|
218
|
+
* @param {number} depth
|
|
219
|
+
* @returns {FormField}
|
|
220
|
+
*/
|
|
221
|
+
function buildField(rawSchema, rootSchema, pointer, key, required, depth) {
|
|
222
|
+
const schema = resolveSchema(rawSchema, rootSchema, depth);
|
|
223
|
+
const effective = (schema != null && typeof schema === 'object') ? schema : {};
|
|
224
|
+
const kind = getFieldKind(schema);
|
|
225
|
+
const control = getControl(kind, effective);
|
|
226
|
+
const formatInfo = getFormatInfo(effective.format);
|
|
227
|
+
|
|
228
|
+
/** @type {FormField} */
|
|
229
|
+
const field = {
|
|
230
|
+
pointer,
|
|
231
|
+
key,
|
|
232
|
+
label: effective.title || humanizeKey(key),
|
|
233
|
+
description: effective.description,
|
|
234
|
+
schema: effective,
|
|
235
|
+
kind,
|
|
236
|
+
control,
|
|
237
|
+
required,
|
|
238
|
+
readOnly: effective.readOnly === true,
|
|
239
|
+
enumValues: kind === 'enum' ? effective.enum : null,
|
|
240
|
+
constValue: kind === 'const' ? effective.const : undefined,
|
|
241
|
+
defaultValue: effective.default,
|
|
242
|
+
placeholder: effective.examples?.[0] !== undefined
|
|
243
|
+
? String(effective.examples[0])
|
|
244
|
+
: formatInfo?.placeholder,
|
|
245
|
+
constraints: getConstraints(effective),
|
|
246
|
+
// The raw `x-form` annotation only - compiling its query documents is
|
|
247
|
+
// rules.js territory, so model building stays query-engine-free.
|
|
248
|
+
rules: isRulesObject(effective['x-form']) ? effective['x-form'] : null,
|
|
249
|
+
children: null,
|
|
250
|
+
item: null,
|
|
251
|
+
tuple: null,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
if (depth >= DEFAULT_MAX_DEPTH) return field;
|
|
255
|
+
|
|
256
|
+
if (kind === 'object' && effective.properties) {
|
|
257
|
+
const requiredSet = new Set(Array.isArray(effective.required) ? effective.required : []);
|
|
258
|
+
field.children = Object.entries(effective.properties).map(([name, propSchema]) =>
|
|
259
|
+
buildField(
|
|
260
|
+
propSchema, rootSchema,
|
|
261
|
+
`${pointer}/${escapePointerKey(name)}`, name,
|
|
262
|
+
requiredSet.has(name), depth + 1));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (kind === 'array') {
|
|
266
|
+
const prefix = Array.isArray(effective.prefixItems)
|
|
267
|
+
? effective.prefixItems
|
|
268
|
+
: (Array.isArray(effective.items) ? effective.items : null);
|
|
269
|
+
if (prefix) {
|
|
270
|
+
field.tuple = prefix.map((itemSchema, i) =>
|
|
271
|
+
buildField(itemSchema, rootSchema, `${pointer}/${i}`, String(i), false, depth + 1));
|
|
272
|
+
const rest = Array.isArray(effective.items) ? effective.additionalItems : effective.items;
|
|
273
|
+
if (rest != null && typeof rest === 'object') {
|
|
274
|
+
field.item = buildField(rest, rootSchema, `${pointer}/-`, '-', false, depth + 1);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
else if (effective.items != null && typeof effective.items === 'object') {
|
|
278
|
+
field.item = buildField(effective.items, rootSchema, `${pointer}/-`, '-', false, depth + 1);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
field.item = buildField({}, rootSchema, `${pointer}/-`, '-', false, depth + 1);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return field;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function isRulesObject(value) {
|
|
289
|
+
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Encode a property name as an RFC 6901 reference token (`~` -> `~0`,
|
|
294
|
+
* `/` -> `~1`), the write-side inverse of the shared parse.
|
|
295
|
+
* @param {string} key
|
|
296
|
+
* @returns {string}
|
|
297
|
+
*/
|
|
298
|
+
export function escapePointerKey(key) {
|
|
299
|
+
return String(key).replace(/~/g, '~0').replace(/\//g, '~1');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Build the form model for a JSON schema.
|
|
304
|
+
*
|
|
305
|
+
* @param {object|boolean} schema - The root JSON schema
|
|
306
|
+
* @returns {FormField} The root field descriptor (kind 'object' for object schemas)
|
|
307
|
+
* @example
|
|
308
|
+
* const model = buildFormModel({
|
|
309
|
+
* type: 'object',
|
|
310
|
+
* properties: { email: { type: 'string', format: 'email' } },
|
|
311
|
+
* required: ['email'],
|
|
312
|
+
* });
|
|
313
|
+
* model.children[0].control; // 'email'
|
|
314
|
+
*/
|
|
315
|
+
export function buildFormModel(schema) {
|
|
316
|
+
const rootSchema = (schema != null && typeof schema === 'object') ? schema : {};
|
|
317
|
+
return buildField(schema, rootSchema, '', '', false, 0);
|
|
318
|
+
}
|