bare-tui-form 0.0.0 → 0.0.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/schema.js ADDED
@@ -0,0 +1,586 @@
1
+ // schema — build a form from a JSON Schema.
2
+ //
3
+ // This is a thin mapping layer: it turns a JSON Schema into the same plain
4
+ // { type, name, … } field definitions that form.create already understands (via
5
+ // fromDef), so the form engine itself is untouched. The goal is to track a
6
+ // useful subset of what react-jsonschema-form supports, starting reasonable:
7
+ //
8
+ // object schema → a form, one field per property
9
+ // nested object → a sub-section: a required object is an always-
10
+ // included subform; an optional one is gated by a
11
+ // checkbox the user toggles (see form.js flatten)
12
+ // oneOf / anyOf → a variant selector (object branches) or, when the
13
+ // branches are all const/enum scalars, a plain select
14
+ // if / then / else → conditional fields that go live based on a guard
15
+ // over sibling controllers (the discriminator pattern)
16
+ // const → a fixed, non-interactive value (e.g. a discriminator)
17
+ // string / number / → text / number / confirm fields
18
+ // integer / boolean
19
+ // enum → select (single choice)
20
+ // array + items.enum → multiselect (choose many)
21
+ // array + items: object → a repeatable subform the user grows/shrinks
22
+ // (minItems / maxItems) (each entry is the item object's fields)
23
+ // required[] → required fields
24
+ // title / description → labels and help text (form- and field-level)
25
+ // default → initial values
26
+ // enumNames → option labels
27
+ // minLength / maxLength / → string validation (maxLength caps input)
28
+ // pattern / format
29
+ // minimum / maximum → number bounds
30
+ //
31
+ // Not yet: arrays of primitives or free-form items, nested arrays, allOf (schema
32
+ // merge), $ref. Unsupported shapes throw a clear error rather than silently
33
+ // producing the wrong form.
34
+ //
35
+ // SECURITY: a JSON Schema is frequently untrusted input — produced by an LLM or
36
+ // received from a peer — and this function is the boundary where it becomes
37
+ // regexes and terminal output. So it's hardened (see harden.js): strings are
38
+ // stripped of terminal-control characters, `pattern` regexes are screened for
39
+ // ReDoS and bounded, dangerous property names are refused, numeric constraints
40
+ // are validated, and sizes are capped. The posture is secure-by-default; a
41
+ // caller who trusts the source passes { trusted: true } and/or { limits } to
42
+ // relax it, and anything dropped is surfaced on the returned form's `warnings`.
43
+ const { create } = require('./form')
44
+ const harden = require('./harden')
45
+
46
+ // format → validator. Extend as needed.
47
+ const FORMATS = {
48
+ email: (v) => (/^\S+@\S+\.\S+$/.test(v) ? null : 'must be a valid email'),
49
+ uri: (v) => (/^[a-zA-Z][\w+.-]*:\/\/\S+$/.test(v) ? null : 'must be a valid URI')
50
+ }
51
+
52
+ // Build a Form from a JSON Schema. An object schema becomes a multi-field form;
53
+ // a primitive schema becomes a single-field form (keyed by opts.name or 'value').
54
+ //
55
+ // Pass opts.formData to rehydrate existing values (like react-jsonschema-form's
56
+ // formData). It overrides schema `default`s; properties it omits keep their
57
+ // default. For a single primitive schema, formData is the scalar itself.
58
+ //
59
+ // opts.trusted (default false): when true, regex `pattern`s are compiled as-is
60
+ // rather than screened for ReDoS. opts.limits overrides the size caps. Anything
61
+ // the hardening drops or clamps is reported on the returned form's `warnings`.
62
+ //
63
+ // opts.uiSchema: a react-jsonschema-form-style uiSchema (a parallel tree keyed
64
+ // by property name) for presentation — see applyUiPresentation/applyWidget for
65
+ // the supported subset. opts.widgets: a { name → fieldDefFactory } registry that
66
+ // ui:widget can select for custom field rendering. uiSchema strings are
67
+ // untrusted and pass through cleanText like everything else.
68
+ function fromSchema(schema, opts = {}) {
69
+ if (!schema || typeof schema !== 'object') {
70
+ throw new Error('fromSchema: schema must be a JSON Schema object')
71
+ }
72
+
73
+ const ui = opts.uiSchema || {}
74
+ const ctx = {
75
+ limits: { ...harden.DEFAULT_LIMITS, ...(opts.limits || {}) },
76
+ trusted: opts.trusted === true,
77
+ warnings: [],
78
+ fieldCount: 0, // total leaf fields built so far (bounded by limits.maxFields)
79
+ widgets: opts.widgets || {} // custom ui:widget name → field-def factory
80
+ }
81
+
82
+ let f
83
+ if (typeOf(schema) === 'object' || schema.properties) {
84
+ f = create({
85
+ title: harden.cleanText(schema.title || opts.title || '', ctx.limits.maxTextLength),
86
+ description: harden.cleanText(schema.description || '', ctx.limits.maxTextLength),
87
+ theme: opts.theme,
88
+ fields: fieldsFromObject(schema, ctx, 0, ui)
89
+ })
90
+ if (opts.formData) f.setValues(cleanData(opts.formData, ctx, 0))
91
+ } else {
92
+ const name = opts.name || 'value'
93
+ harden.assertSafeKey(name)
94
+ f = create({
95
+ title: harden.cleanText(opts.title || '', ctx.limits.maxTextLength),
96
+ theme: opts.theme,
97
+ fields: [fieldFromProperty(name, schema, false, ctx, 0, ui)]
98
+ })
99
+ if (opts.formData !== undefined) {
100
+ f.setValues({ [name]: cleanScalar(opts.formData, ctx, 0) })
101
+ }
102
+ }
103
+
104
+ f.warnings = ctx.warnings
105
+ return f
106
+ }
107
+
108
+ // The field definitions for an object schema's properties (in uiSchema `ui:order`
109
+ // if given, else insertion order), plus any if/then/else as a trailing
110
+ // conditional def. `depth` is the object-nesting level (for the recursion cap);
111
+ // `ui` is the uiSchema node for this object.
112
+ function fieldsFromObject(schema, ctx, depth, ui = {}) {
113
+ const props = schema.properties || {}
114
+ const required = new Set(schema.required || [])
115
+ const names = orderNames(Object.keys(props), ui['ui:order'])
116
+ const defs = names.map((name) => {
117
+ harden.assertSafeKey(name)
118
+ return fieldFromProperty(name, props[name], required.has(name), ctx, depth, childUi(ui, name))
119
+ })
120
+ const cond = conditionalDef(schema, ctx, depth, ui)
121
+ if (cond) defs.push(cond)
122
+ return defs
123
+ }
124
+
125
+ // Reorder property names per a uiSchema `ui:order` array. Only names that exist
126
+ // are honored (so it can't inject keys); '*' expands to the remaining names in
127
+ // their original order. A non-array order leaves the order untouched.
128
+ function orderNames(names, order) {
129
+ if (!Array.isArray(order)) return names
130
+ const set = new Set(names)
131
+ const rest = names.filter((n) => !order.includes(n))
132
+ if (order.includes('*')) {
133
+ const out = []
134
+ for (const n of order) {
135
+ if (n === '*') out.push(...rest)
136
+ else if (set.has(n)) out.push(n)
137
+ }
138
+ return out
139
+ }
140
+ return [...order.filter((n) => set.has(n)), ...rest]
141
+ }
142
+
143
+ // The uiSchema node for a child property (an object, never the prototype).
144
+ function childUi(ui, name) {
145
+ if (!ui || harden.FORBIDDEN_KEYS.has(name)) return {}
146
+ const node = ui[name]
147
+ return node && typeof node === 'object' ? node : {}
148
+ }
149
+
150
+ // Read a uiSchema option, honoring both forms RJSF allows:
151
+ // { 'ui:widget': 'x' } ≡ { 'ui:options': { widget: 'x' } }
152
+ function uiGet(ui, key) {
153
+ if (!ui) return undefined
154
+ const direct = ui['ui:' + key]
155
+ if (direct !== undefined) return direct
156
+ const opts = ui['ui:options']
157
+ return opts && typeof opts === 'object' ? opts[key] : undefined
158
+ }
159
+
160
+ // Apply the presentation-only uiSchema options to a finished leaf def.
161
+ function applyUiPresentation(def, ui, ctx) {
162
+ const max = ctx.limits.maxTextLength
163
+ const help = uiGet(ui, 'help')
164
+ if (typeof help === 'string') def.help = harden.cleanText(help, max)
165
+ const placeholder = uiGet(ui, 'placeholder')
166
+ if (typeof placeholder === 'string') def.placeholder = harden.cleanText(placeholder, max)
167
+ if (uiGet(ui, 'label') === false) def.hideLabel = true
168
+ if (uiGet(ui, 'autofocus')) def.autofocus = true
169
+ if (uiGet(ui, 'readonly') || uiGet(ui, 'disabled')) def.readonly = true
170
+ if (def.type === 'text' && uiGet(ui, 'inputType') === 'password') def.echoMode = 'password'
171
+ return def
172
+ }
173
+
174
+ // Apply a ui:widget override to a leaf def: switch the control (textarea,
175
+ // password, radio, hidden), defer to a registered custom widget, or warn and
176
+ // keep the default. Built-in names that already match our default (select,
177
+ // checkbox, checkboxes, updown, range) are no-ops.
178
+ function applyWidget(def, ui, ctx, name, prop) {
179
+ const widget = uiGet(ui, 'widget')
180
+ if (widget === undefined || widget === null || widget === '') return def
181
+ if (typeof widget !== 'string') {
182
+ ctx.warnings.push(`"${name}": ui:widget must be a string`)
183
+ return def
184
+ }
185
+ switch (widget) {
186
+ case 'hidden':
187
+ def.hidden = true
188
+ return def
189
+ case 'textarea': {
190
+ if (def.type !== 'text') return widgetMismatch(def, ctx, name, widget, 'string')
191
+ def.type = 'textarea'
192
+ const rows = harden.safeNumber(uiGet(ui, 'rows'))
193
+ if (rows) def.height = Math.min(rows, 100)
194
+ return def
195
+ }
196
+ case 'password':
197
+ if (def.type !== 'text') return widgetMismatch(def, ctx, name, widget, 'string')
198
+ def.echoMode = 'password'
199
+ return def
200
+ case 'radio':
201
+ if (def.type !== 'select') return widgetMismatch(def, ctx, name, widget, 'enum')
202
+ def.type = 'radio' // radio takes the same options/selected as select
203
+ return def
204
+ case 'select':
205
+ case 'checkbox':
206
+ case 'checkboxes':
207
+ case 'updown':
208
+ case 'range':
209
+ return def // already our default control for that type
210
+ default: {
211
+ const factory = ctx.widgets[widget]
212
+ if (typeof factory === 'function') {
213
+ const custom = factory({
214
+ name,
215
+ label: def.label,
216
+ description: def.description,
217
+ required: def.required,
218
+ value: def.value,
219
+ schema: prop
220
+ })
221
+ if (custom && typeof custom === 'object') {
222
+ if (!custom.name) custom.name = name
223
+ return custom
224
+ }
225
+ ctx.warnings.push(`"${name}": custom widget "${widget}" returned no field def`)
226
+ return def
227
+ }
228
+ ctx.warnings.push(`"${name}": unknown ui:widget "${widget}" (using the default)`)
229
+ return def
230
+ }
231
+ }
232
+ }
233
+
234
+ function widgetMismatch(def, ctx, name, widget, expected) {
235
+ ctx.warnings.push(`"${name}": ui:widget "${widget}" needs a ${expected} field (ignored)`)
236
+ return def
237
+ }
238
+
239
+ // Count a leaf field against the global cap (throws when exceeded).
240
+ function countLeaf(ctx) {
241
+ if (++ctx.fieldCount > ctx.limits.maxFields) {
242
+ throw new Error(`fromSchema: more than ${ctx.limits.maxFields} fields (over the limit)`)
243
+ }
244
+ }
245
+
246
+ function assertDepth(name, ctx, depth) {
247
+ if (depth + 1 > ctx.limits.maxDepth) {
248
+ throw new Error(
249
+ `fromSchema: "${name}" nests deeper than the limit of ${ctx.limits.maxDepth} levels`
250
+ )
251
+ }
252
+ }
253
+
254
+ // Map one property schema to a field definition. Objects, oneOf/anyOf variants,
255
+ // and conditionals recurse into groups; everything else is a leaf field counted
256
+ // against the global cap. `ui` is the uiSchema node for this property.
257
+ function fieldFromProperty(name, prop, required, ctx, depth, ui = {}) {
258
+ if (!prop || typeof prop !== 'object') {
259
+ throw new Error(`fromSchema: property "${name}" must be a schema object`)
260
+ }
261
+ const max = ctx.limits.maxTextLength
262
+ // uiSchema ui:title / ui:description override the schema's own.
263
+ const uiTitle = uiGet(ui, 'title')
264
+ const uiDesc = uiGet(ui, 'description')
265
+ const base = {
266
+ name,
267
+ label: harden.cleanText(typeof uiTitle === 'string' ? uiTitle : prop.title || name, max),
268
+ description: harden.cleanText(
269
+ typeof uiDesc === 'string' ? uiDesc : prop.description || '',
270
+ max
271
+ ),
272
+ required
273
+ }
274
+ // Presentation applied to whatever leaf def we end up with.
275
+ const leaf = (def) => applyUiPresentation(applyWidget(def, ui, ctx, name, prop), ui, ctx)
276
+
277
+ // const → a fixed, non-interactive value (e.g. a oneOf branch discriminator).
278
+ if (prop.const !== undefined) {
279
+ countLeaf(ctx)
280
+ const value = typeof prop.const === 'string' ? harden.cleanText(prop.const, max) : prop.const
281
+ return leaf({ name, label: base.label, description: base.description, type: 'constant', value })
282
+ }
283
+
284
+ // oneOf/anyOf → a selector. All-scalar branches collapse to a plain select;
285
+ // object branches become a variant whose chosen branch's fields go live.
286
+ const branches = prop.oneOf || prop.anyOf
287
+ if (Array.isArray(branches)) return variantOrSelect(name, base, branches, prop, ctx, depth)
288
+
289
+ // array → a multiselect (items.enum) or a repeatable object subform (items is
290
+ // an object). Handled before the leaf count: a multiselect is one leaf, but an
291
+ // array-of-objects' leaves are counted inside its itemFields.
292
+ if (typeOf(prop) === 'array' && !Array.isArray(prop.enum)) {
293
+ return arrayProperty(name, base, prop, ctx, depth, ui)
294
+ }
295
+
296
+ // Nested object → a sub-section (recurse). A non-required object becomes an
297
+ // optional section the user gates with a checkbox; a required one is an
298
+ // always-included subform. `enum` short-circuits below to a leaf select even
299
+ // when the declared type is 'object', so guard against that here.
300
+ if (typeOf(prop) === 'object' && !Array.isArray(prop.enum)) {
301
+ assertDepth(name, ctx, depth)
302
+ return {
303
+ name,
304
+ type: 'object',
305
+ title: base.label,
306
+ description: base.description,
307
+ optional: !required,
308
+ fields: fieldsFromObject(prop, ctx, depth + 1, ui)
309
+ }
310
+ }
311
+
312
+ // From here on it's a leaf field — count it against the global cap.
313
+ countLeaf(ctx)
314
+
315
+ // enum → a single-choice select, regardless of the underlying type.
316
+ if (Array.isArray(prop.enum)) {
317
+ const selected = prop.default !== undefined ? prop.enum.indexOf(prop.default) : -1
318
+ return leaf({
319
+ ...base,
320
+ type: 'select',
321
+ options: optionsFromEnum(prop.enum, prop.enumNames, ctx, name),
322
+ selected
323
+ })
324
+ }
325
+
326
+ switch (typeOf(prop)) {
327
+ case 'string':
328
+ return leaf({
329
+ ...base,
330
+ type: 'text',
331
+ value: harden.cleanText(prop.default ?? '', max),
332
+ charLimit: charLimitFor(prop, ctx),
333
+ validate: stringValidator(prop, ctx, name)
334
+ })
335
+ case 'number':
336
+ case 'integer':
337
+ return leaf({
338
+ ...base,
339
+ type: 'number',
340
+ value: harden.safeNumber(prop.default),
341
+ min: harden.safeNumber(prop.minimum),
342
+ max: harden.safeNumber(prop.maximum),
343
+ integer: typeOf(prop) === 'integer'
344
+ })
345
+ case 'boolean':
346
+ // A checkbox always carries a value, so `required` (which would force
347
+ // `true`) is dropped — matching JSON Schema, where false is valid.
348
+ return leaf({
349
+ name,
350
+ label: base.label,
351
+ description: base.description,
352
+ type: 'confirm',
353
+ value: !!prop.default
354
+ })
355
+ default:
356
+ // Untyped/unknown → a plain text field.
357
+ return leaf({ ...base, type: 'text', value: harden.cleanText(prop.default ?? '', max) })
358
+ }
359
+ }
360
+
361
+ // oneOf/anyOf. Branches that are all scalars (const/enum, no properties) collapse
362
+ // to a plain single-choice select; branches that are all objects become a
363
+ // variant whose selected branch's fields go live. Mixed branches are refused.
364
+ // (anyOf is rendered the same as oneOf — pick one — which matches RJSF's UI.)
365
+ function variantOrSelect(name, base, branches, prop, ctx, depth) {
366
+ if (branches.length > ctx.limits.maxBranches) {
367
+ throw new Error(
368
+ `fromSchema: "${name}" has ${branches.length} branches, over the limit of ${ctx.limits.maxBranches}`
369
+ )
370
+ }
371
+ const scalar = (b) => b && typeof b === 'object' && !b.properties && isScalarBranch(b)
372
+ const object = (b) => b && typeof b === 'object' && !!b.properties
373
+
374
+ if (branches.every(scalar)) {
375
+ countLeaf(ctx)
376
+ const values = branches.map(scalarValue)
377
+ const selected = prop.default !== undefined ? values.indexOf(prop.default) : -1
378
+ return {
379
+ ...base,
380
+ type: 'select',
381
+ options: branches.map((b, i) => ({
382
+ label: harden.cleanText(String(b.title ?? values[i]), ctx.limits.maxTextLength),
383
+ value: values[i]
384
+ })),
385
+ selected
386
+ }
387
+ }
388
+
389
+ if (branches.every(object)) {
390
+ assertDepth(name, ctx, depth)
391
+ return {
392
+ name,
393
+ type: 'variant',
394
+ title: base.label,
395
+ description: base.description,
396
+ selectorLabel: base.label,
397
+ branches: branches.map((b, i) => ({
398
+ id: i,
399
+ title: harden.cleanText(b.title || `Option ${i + 1}`, ctx.limits.maxTextLength),
400
+ fields: fieldsFromObject(b, ctx, depth + 1)
401
+ }))
402
+ }
403
+ }
404
+
405
+ throw new Error(
406
+ `fromSchema: "${name}" oneOf/anyOf must be all object branches or all const/enum scalars`
407
+ )
408
+ }
409
+
410
+ function isScalarBranch(b) {
411
+ return b.const !== undefined || Array.isArray(b.enum)
412
+ }
413
+
414
+ function scalarValue(b) {
415
+ if (b.const !== undefined) return b.const
416
+ return Array.isArray(b.enum) ? b.enum[0] : undefined
417
+ }
418
+
419
+ // if/then/else on an object → a conditional def whose then/else fields go live
420
+ // based on a guard over sibling controllers. Supported `if` shape (the common
421
+ // discriminator): { properties: { <name>: { const } | { enum: [...] } } }. The
422
+ // guard matches when every named property holds one of its allowed values.
423
+ function conditionalDef(schema, ctx, depth, ui = {}) {
424
+ if (!schema.if || (!schema.then && !schema.else)) return null
425
+ const ifProps = (schema.if && schema.if.properties) || {}
426
+ const when = Object.keys(ifProps).map((propName) => {
427
+ harden.assertSafeKey(propName)
428
+ const cond = ifProps[propName] || {}
429
+ const allowed =
430
+ cond.const !== undefined ? [cond.const] : Array.isArray(cond.enum) ? cond.enum : []
431
+ return { name: propName, allowed: new Set(allowed) }
432
+ })
433
+ if (when.length === 0) return null
434
+ return {
435
+ type: 'conditional',
436
+ when,
437
+ then: schema.then ? fieldsFromObject(withoutNested(schema.then), ctx, depth, ui) : [],
438
+ else: schema.else ? fieldsFromObject(withoutNested(schema.else), ctx, depth, ui) : []
439
+ }
440
+ }
441
+
442
+ // then/else subschemas contribute their `properties` (and `required`) at the
443
+ // same object level; only those two keys are read here.
444
+ function withoutNested(sub) {
445
+ return { properties: sub.properties || {}, required: sub.required || [] }
446
+ }
447
+
448
+ // array → multiselect (items.enum) or a repeatable object subform (items is an
449
+ // object). Other array shapes (primitive items, nested arrays) are refused.
450
+ // `ui` supplies item presentation (ui.items) and add/remove gating.
451
+ function arrayProperty(name, base, prop, ctx, depth, ui = {}) {
452
+ const items = prop.items || {}
453
+
454
+ if (Array.isArray(items.enum)) {
455
+ countLeaf(ctx)
456
+ const def = Array.isArray(prop.default) ? prop.default.slice(0, ctx.limits.maxEnum) : []
457
+ return {
458
+ ...base,
459
+ type: 'multiselect',
460
+ options: optionsFromEnum(items.enum, items.enumNames, ctx, name),
461
+ value: def.map((v) => cleanScalar(v, ctx, 0))
462
+ }
463
+ }
464
+
465
+ if (items && typeof items === 'object' && (items.properties || typeOf(items) === 'object')) {
466
+ if (ctx.inArray) {
467
+ throw new Error(`fromSchema: nested arrays ("${name}") are not supported yet`)
468
+ }
469
+ assertDepth(name, ctx, depth)
470
+ ctx.inArray = true
471
+ const itemFields = fieldsFromObject(items, ctx, depth + 1, childUi(ui, 'items'))
472
+ ctx.inArray = false
473
+ const cap = ctx.limits.maxArrayItems
474
+ const maxItems = Math.min(harden.safeNumber(prop.maxItems) ?? cap, cap)
475
+ const minItems = Math.min(Math.max(harden.safeNumber(prop.minItems) ?? 0, 0), maxItems)
476
+ return {
477
+ name,
478
+ type: 'array',
479
+ title: base.label,
480
+ description: base.description,
481
+ itemTitle: harden.cleanText(items.title || 'Item', ctx.limits.maxTextLength),
482
+ addLabel: '+ Add',
483
+ itemFields,
484
+ minItems,
485
+ maxItems,
486
+ addable: uiGet(ui, 'addable') !== false,
487
+ removable: uiGet(ui, 'removable') !== false
488
+ }
489
+ }
490
+
491
+ throw new Error(
492
+ `fromSchema: array "${name}" needs items.enum or object items (other arrays are not supported yet)`
493
+ )
494
+ }
495
+
496
+ function optionsFromEnum(values, names, ctx, name) {
497
+ if (values.length > ctx.limits.maxEnum) {
498
+ throw new Error(
499
+ `fromSchema: enum on "${name}" has ${values.length} entries, over the limit of ${ctx.limits.maxEnum}`
500
+ )
501
+ }
502
+ return values.map((value, i) => ({
503
+ label: harden.cleanText(
504
+ String(names && names[i] !== undefined ? names[i] : value),
505
+ ctx.limits.maxTextLength
506
+ ),
507
+ value // the value is returned as data, not rendered, so it's left intact
508
+ }))
509
+ }
510
+
511
+ // Cap a text field's input length. An explicit, finite maxLength wins (clamped
512
+ // to the ceiling); otherwise unbounded (0) — the renderer/validators are still
513
+ // safe because pattern testing is itself length-bounded.
514
+ function charLimitFor(prop, ctx) {
515
+ const n = harden.safeNumber(prop.maxLength)
516
+ if (n === undefined || n <= 0) return 0
517
+ return Math.min(n, ctx.limits.maxStringLength)
518
+ }
519
+
520
+ // Combine the string constraints into one sync validator (or undefined).
521
+ function stringValidator(prop, ctx, name) {
522
+ const checks = []
523
+
524
+ const minLength = harden.safeNumber(prop.minLength)
525
+ if (minLength !== undefined) {
526
+ checks.push((v) =>
527
+ v.length >= minLength
528
+ ? null
529
+ : `must be at least ${minLength} character${minLength === 1 ? '' : 's'}`
530
+ )
531
+ }
532
+
533
+ if (prop.pattern !== undefined) {
534
+ const compiled = harden.compilePattern(prop.pattern, ctx)
535
+ if (compiled.test) {
536
+ checks.push((v) => (compiled.test(v) ? null : 'invalid format'))
537
+ } else {
538
+ ctx.warnings.push(`"${name}": ${compiled.reason}`)
539
+ }
540
+ }
541
+
542
+ if (prop.format && FORMATS[prop.format]) checks.push(FORMATS[prop.format])
543
+
544
+ if (checks.length === 0) return undefined
545
+ return (v) => {
546
+ for (const check of checks) {
547
+ const err = check(v)
548
+ if (err) return err
549
+ }
550
+ return null
551
+ }
552
+ }
553
+
554
+ // Sanitize an untrusted rehydration value: strip control chars from strings (and
555
+ // from string elements of arrays), recurse into nested-section objects, and
556
+ // leave numbers/booleans/null as-is. Array length and recursion depth are both
557
+ // bounded so a huge or deeply-nested formData can't exhaust memory or the stack.
558
+ function cleanData(formData, ctx, depth) {
559
+ if (!formData || typeof formData !== 'object') return formData
560
+ if (depth > ctx.limits.maxDepth) return {} // too deep to match any field anyway
561
+ const out = {}
562
+ for (const key of Object.keys(formData)) {
563
+ if (harden.FORBIDDEN_KEYS.has(key)) continue // never carry a pollution key through
564
+ out[key] = cleanScalar(formData[key], ctx, depth + 1)
565
+ }
566
+ return out
567
+ }
568
+
569
+ function cleanScalar(v, ctx, depth) {
570
+ if (typeof v === 'string') return harden.cleanText(v, ctx.limits.maxTextLength)
571
+ if (Array.isArray(v)) {
572
+ return v.slice(0, ctx.limits.maxEnum).map((x) => cleanScalar(x, ctx, depth))
573
+ }
574
+ if (v && typeof v === 'object') return cleanData(v, ctx, depth) // nested section
575
+ return v
576
+ }
577
+
578
+ // JSON Schema allows `type` to be an array (e.g. ['string','null']); pick the
579
+ // first meaningful one.
580
+ function typeOf(schema) {
581
+ const t = schema.type
582
+ if (Array.isArray(t)) return t.find((x) => x !== 'null')
583
+ return t
584
+ }
585
+
586
+ module.exports = { fromSchema, fieldsFromObject, fieldFromProperty }
package/structural.js ADDED
@@ -0,0 +1,62 @@
1
+ // Structural field helpers for hand-built forms: nested object sections and
2
+ // repeatable arrays. These return the same plain definition objects that
3
+ // fromSchema emits (the form flattens them), so a hand-written form has the same
4
+ // structural vocabulary as a schema-built one — and you can mix field instances,
5
+ // leaf defs, and these freely in one `fields` array.
6
+ //
7
+ // form.create({ fields: [
8
+ // form.text({ name: 'name', autofocus: true }),
9
+ // form.group({ name: 'address', title: 'Address', fields: [
10
+ // form.text({ name: 'street' }), form.text({ name: 'city' })
11
+ // ]}),
12
+ // form.array({ name: 'tags', minItems: 1, removable: false, fields: [
13
+ // { type: 'text', name: 'label' } // item fields are DEFS (see below)
14
+ // ]})
15
+ // ]})
16
+ const { Field } = require('./fields/base')
17
+
18
+ // A nested object section. `optional: true` makes it a checkbox-gated section;
19
+ // otherwise it's an always-included subform. `fields` may be field instances or
20
+ // defs — a group is rendered once, so instances are fine here.
21
+ function group(opts = {}) {
22
+ return {
23
+ type: 'object',
24
+ name: opts.name,
25
+ title: opts.title || opts.name,
26
+ description: opts.description || '',
27
+ optional: !!opts.optional,
28
+ fields: opts.fields || []
29
+ }
30
+ }
31
+
32
+ // A repeatable object subform (add/remove entries). `fields` are the *template*
33
+ // for each entry, so they MUST be plain defs ({ type, name, … }), not field
34
+ // instances — every entry needs its own instance, and a shared instance would
35
+ // bleed values between rows. (fromSchema always passes defs; this guards the
36
+ // hand-built path with a clear error.)
37
+ function array(opts = {}) {
38
+ const itemFields = opts.fields || opts.itemFields || []
39
+ for (const it of itemFields) {
40
+ if (it instanceof Field) {
41
+ throw new Error(
42
+ 'form.array: item fields must be plain defs (e.g. { type: "text", name }), not field ' +
43
+ 'instances — each entry needs its own, so a shared instance would bleed values'
44
+ )
45
+ }
46
+ }
47
+ return {
48
+ type: 'array',
49
+ name: opts.name,
50
+ title: opts.title || opts.name,
51
+ description: opts.description || '',
52
+ itemTitle: opts.itemTitle || 'Item',
53
+ addLabel: opts.addLabel || '+ Add',
54
+ itemFields,
55
+ minItems: opts.minItems || 0,
56
+ maxItems: opts.maxItems || 100,
57
+ addable: opts.addable !== false,
58
+ removable: opts.removable !== false
59
+ }
60
+ }
61
+
62
+ module.exports = { group, array }