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