@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/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
+ }