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/README.md +525 -0
- package/fields/action.js +63 -0
- package/fields/base.js +213 -0
- package/fields/confirm.js +46 -0
- package/fields/constant.js +41 -0
- package/fields/file.js +129 -0
- package/fields/list.js +158 -0
- package/fields/multiselect.js +97 -0
- package/fields/number.js +60 -0
- package/fields/radio.js +32 -0
- package/fields/section.js +71 -0
- package/fields/select.js +46 -0
- package/fields/text.js +35 -0
- package/fields/textarea.js +43 -0
- package/form.js +1028 -0
- package/harden.js +173 -0
- package/index.js +86 -0
- package/package.json +52 -1
- package/prompt.js +107 -0
- package/schema.js +620 -0
- package/structural.js +62 -0
- package/theme.js +86 -0
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 }
|