@jarenjs/forms 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +196 -0
- package/dist/types/data.d.ts +67 -0
- package/dist/types/formats.d.ts +22 -0
- package/dist/types/index.d.ts +17 -0
- package/dist/types/model.d.ts +125 -0
- package/dist/types/rules.d.ts +131 -0
- package/dist/types/validate.d.ts +29 -0
- package/package.json +54 -0
- package/src/data.js +223 -0
- package/src/formats.js +120 -0
- package/src/index.js +49 -0
- package/src/model.js +318 -0
- package/src/rules.js +404 -0
- package/src/validate.js +244 -0
package/src/data.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Immutable data helpers for form values, addressed by JSON pointer.
|
|
5
|
+
*
|
|
6
|
+
* Form data follows JSON semantics: a field that was never filled in is
|
|
7
|
+
* ABSENT (undefined), not an empty string - so `required` and default
|
|
8
|
+
* handling behave exactly like they will on the wire.
|
|
9
|
+
*
|
|
10
|
+
* Pointer parsing and reading go through the @jarenjs/json compiled
|
|
11
|
+
* pointer engine (one pointer implementation in the whole repo); field
|
|
12
|
+
* pointers are stable for a model's lifetime, so compiled getters are
|
|
13
|
+
* cached by pointer string and reads are allocation-free.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
parseJSONPointer,
|
|
18
|
+
compileJSONPointer,
|
|
19
|
+
JSONPOINTER_NOTHING,
|
|
20
|
+
} from '@jarenjs/json/pointer';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Split a JSON pointer into decoded segments per RFC 6901. '' -> [].
|
|
24
|
+
* @param {string} pointer
|
|
25
|
+
* @returns {string[]}
|
|
26
|
+
* @throws {import('@jarenjs/json/pointer').JSONPointerSyntaxError}
|
|
27
|
+
* When the pointer violates the RFC 6901 grammar
|
|
28
|
+
*/
|
|
29
|
+
export function parsePointer(pointer) {
|
|
30
|
+
if (pointer == null) return [];
|
|
31
|
+
return parseJSONPointer(pointer);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const getterCache = new Map();
|
|
35
|
+
const GETTER_CACHE_LIMIT = 512;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compiled getter for a pointer string, cached FIFO (the same pattern as
|
|
39
|
+
* the query engine's string cache): form field pointers are a small,
|
|
40
|
+
* stable set, so every keystroke after the first hits the cache.
|
|
41
|
+
* @param {string} pointer
|
|
42
|
+
* @returns {(root: any) => any}
|
|
43
|
+
*/
|
|
44
|
+
function getPointerGetter(pointer) {
|
|
45
|
+
let getter = getterCache.get(pointer);
|
|
46
|
+
if (getter === undefined) {
|
|
47
|
+
getter = compileJSONPointer(pointer);
|
|
48
|
+
if (getterCache.size >= GETTER_CACHE_LIMIT)
|
|
49
|
+
getterCache.delete(getterCache.keys().next().value);
|
|
50
|
+
getterCache.set(pointer, getter);
|
|
51
|
+
}
|
|
52
|
+
return getter;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read the value at a JSON pointer.
|
|
57
|
+
* @param {any} data
|
|
58
|
+
* @param {string} pointer - e.g. '/user/address/0/street'
|
|
59
|
+
* @returns {any} The value, or undefined when the path does not exist
|
|
60
|
+
*/
|
|
61
|
+
export function getValueAtPointer(data, pointer) {
|
|
62
|
+
const value = getPointerGetter(pointer)(data);
|
|
63
|
+
return value === JSONPOINTER_NOTHING ? undefined : value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
//#region roadmap
|
|
67
|
+
// Immutable write ops (set/append/remove by pointer) are a @jarenjs/json
|
|
68
|
+
// roadmap item (compiled setters beside the compiled getters, feeding the
|
|
69
|
+
// JSON Patch work). Until that lands they live here; parsing already goes
|
|
70
|
+
// through the shared parseJSONPointer above.
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Return a copy of `data` with the value at `pointer` replaced.
|
|
74
|
+
* Setting `undefined` REMOVES the property (array items become undefined
|
|
75
|
+
* holes only when explicitly set; use removeItemAt to delete them).
|
|
76
|
+
* Missing intermediate containers are created (objects for name segments,
|
|
77
|
+
* arrays for numeric segments).
|
|
78
|
+
* @param {any} data
|
|
79
|
+
* @param {string} pointer
|
|
80
|
+
* @param {any} value
|
|
81
|
+
* @returns {any} The new root value
|
|
82
|
+
*/
|
|
83
|
+
export function setValueAtPointer(data, pointer, value) {
|
|
84
|
+
const keys = parsePointer(pointer);
|
|
85
|
+
if (keys.length === 0) return value;
|
|
86
|
+
|
|
87
|
+
const root = cloneContainer(data, keys[0]);
|
|
88
|
+
let current = root;
|
|
89
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
90
|
+
const key = keys[i];
|
|
91
|
+
current[key] = cloneContainer(current[key], keys[i + 1]);
|
|
92
|
+
current = current[key];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const last = keys[keys.length - 1];
|
|
96
|
+
if (value === undefined && !Array.isArray(current)) {
|
|
97
|
+
delete current[last];
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
current[last] = value;
|
|
101
|
+
}
|
|
102
|
+
return root;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function cloneContainer(value, nextKey) {
|
|
106
|
+
if (Array.isArray(value)) return value.slice();
|
|
107
|
+
if (value != null && typeof value === 'object') return { ...value };
|
|
108
|
+
return /^\d+$/.test(String(nextKey)) ? [] : {};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Return a copy of `data` with the item at `index` removed from the array
|
|
113
|
+
* at `pointer`.
|
|
114
|
+
* @param {any} data
|
|
115
|
+
* @param {string} pointer - Pointer to the ARRAY
|
|
116
|
+
* @param {number} index
|
|
117
|
+
* @returns {any}
|
|
118
|
+
*/
|
|
119
|
+
export function removeItemAt(data, pointer, index) {
|
|
120
|
+
const arr = getValueAtPointer(data, pointer);
|
|
121
|
+
if (!Array.isArray(arr)) return data;
|
|
122
|
+
const next = arr.slice();
|
|
123
|
+
next.splice(index, 1);
|
|
124
|
+
return setValueAtPointer(data, pointer, next);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Return a copy of `data` with `value` appended to the array at `pointer`
|
|
129
|
+
* (the array is created when absent).
|
|
130
|
+
* @param {any} data
|
|
131
|
+
* @param {string} pointer - Pointer to the ARRAY
|
|
132
|
+
* @param {any} value
|
|
133
|
+
* @returns {any}
|
|
134
|
+
*/
|
|
135
|
+
export function appendItem(data, pointer, value) {
|
|
136
|
+
const arr = getValueAtPointer(data, pointer);
|
|
137
|
+
const next = Array.isArray(arr) ? [...arr, value] : [value];
|
|
138
|
+
return setValueAtPointer(data, pointer, next);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
//#endregion
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Create initial data for a form model: schema defaults and const values
|
|
145
|
+
* are filled in, everything else stays absent.
|
|
146
|
+
* @param {import('./model.js').FormField} field - A field from buildFormModel
|
|
147
|
+
* @returns {any}
|
|
148
|
+
*/
|
|
149
|
+
export function createInitialData(field) {
|
|
150
|
+
if (field == null) return undefined;
|
|
151
|
+
if (field.defaultValue !== undefined) return field.defaultValue;
|
|
152
|
+
if (field.constValue !== undefined) return field.constValue;
|
|
153
|
+
|
|
154
|
+
if (field.kind === 'object') {
|
|
155
|
+
const obj = {};
|
|
156
|
+
if (field.children) {
|
|
157
|
+
for (const child of field.children) {
|
|
158
|
+
const value = createInitialData(child);
|
|
159
|
+
if (value !== undefined) obj[child.key] = value;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return obj;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (field.kind === 'array') {
|
|
166
|
+
if (field.tuple) {
|
|
167
|
+
const items = field.tuple.map((item) => createInitialData(item));
|
|
168
|
+
while (items.length > 0 && items[items.length - 1] === undefined) items.pop();
|
|
169
|
+
return items;
|
|
170
|
+
}
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Create a sensible starter value for one array item of the given field.
|
|
179
|
+
* @param {import('./model.js').FormField} itemField
|
|
180
|
+
* @returns {any}
|
|
181
|
+
*/
|
|
182
|
+
export function createItemValue(itemField) {
|
|
183
|
+
const initial = createInitialData(itemField);
|
|
184
|
+
if (initial !== undefined) return initial;
|
|
185
|
+
switch (itemField?.kind) {
|
|
186
|
+
case 'string': return '';
|
|
187
|
+
case 'number':
|
|
188
|
+
case 'integer': return 0;
|
|
189
|
+
case 'boolean': return false;
|
|
190
|
+
case 'enum': return itemField.enumValues?.[0];
|
|
191
|
+
default: return initial;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Coerce a raw input string (what an HTML input yields) into the typed
|
|
197
|
+
* value for a field. An empty string means "absent" (undefined) so that
|
|
198
|
+
* required/optional semantics stay correct.
|
|
199
|
+
* @param {import('./model.js').FormField} field
|
|
200
|
+
* @param {any} raw - Raw input value (string, or boolean for checkboxes)
|
|
201
|
+
* @returns {any}
|
|
202
|
+
*/
|
|
203
|
+
export function parseFieldInput(field, raw) {
|
|
204
|
+
if (field.kind === 'boolean') return !!raw;
|
|
205
|
+
if (raw === '' || raw == null) return undefined;
|
|
206
|
+
|
|
207
|
+
switch (field.kind) {
|
|
208
|
+
case 'number':
|
|
209
|
+
case 'integer': {
|
|
210
|
+
const num = Number(raw);
|
|
211
|
+
// Keep the raw string when it is not numeric, so validation can
|
|
212
|
+
// report a type error instead of silently swallowing the input.
|
|
213
|
+
return Number.isNaN(num) ? raw : num;
|
|
214
|
+
}
|
|
215
|
+
case 'enum': {
|
|
216
|
+
// Map the selected option string back to the typed enum value
|
|
217
|
+
const match = field.enumValues?.find((v) => String(v) === String(raw));
|
|
218
|
+
return match !== undefined ? match : raw;
|
|
219
|
+
}
|
|
220
|
+
default:
|
|
221
|
+
return raw;
|
|
222
|
+
}
|
|
223
|
+
}
|
package/src/formats.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Format registry for form fields.
|
|
5
|
+
*
|
|
6
|
+
* The test predicates come from the CANONICAL format registry in
|
|
7
|
+
* `@jarenjs/formats` (`formatTesters`) - the same name -> tester bindings
|
|
8
|
+
* the schema validator's format compilers wrap - so the preemptive
|
|
9
|
+
* per-keystroke validation accepts exactly what the authoritative
|
|
10
|
+
* validation accepts. This module only adds what is genuinely
|
|
11
|
+
* form-specific: rendering hints (which HTML input control fits and a
|
|
12
|
+
* sensible placeholder) plus a few control-hint-only convenience formats.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
formatTesters,
|
|
17
|
+
numberFormatTesters,
|
|
18
|
+
} from '@jarenjs/formats';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} FormatInfo
|
|
22
|
+
* @property {(value: string) => boolean} test - Synchronous validity test
|
|
23
|
+
* @property {string} control - Suggested HTML input control
|
|
24
|
+
* @property {string} [placeholder] - Suggested placeholder text
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Rendering hints per format name. Formats without an entry render as a
|
|
29
|
+
* plain text input (number-group formats as a number input).
|
|
30
|
+
* @type {Record<string, { control?: string, placeholder?: string }>}
|
|
31
|
+
*/
|
|
32
|
+
const FORM_HINTS = {
|
|
33
|
+
// -- date and time (RFC 3339 requires a timezone offset, which the HTML
|
|
34
|
+
// time / datetime-local inputs cannot produce; those stay text inputs)
|
|
35
|
+
'date': { control: 'date', placeholder: '2024-01-15' },
|
|
36
|
+
'time': { placeholder: '13:45:30Z' },
|
|
37
|
+
'date-time': { placeholder: '2024-01-15T13:45:30Z' },
|
|
38
|
+
'iso-date-time': { placeholder: '2024-01-15T13:45:30' },
|
|
39
|
+
'iso-time': { placeholder: '13:45:30' },
|
|
40
|
+
'duration': { placeholder: 'P3DT4H' },
|
|
41
|
+
|
|
42
|
+
// -- email
|
|
43
|
+
'email': { control: 'email', placeholder: 'user@example.com' },
|
|
44
|
+
'email--full': { control: 'email', placeholder: 'user@example.com' },
|
|
45
|
+
'idn-email': { control: 'email', placeholder: 'user@example.com' },
|
|
46
|
+
|
|
47
|
+
// -- hosts and addresses
|
|
48
|
+
'hostname': { placeholder: 'example.com' },
|
|
49
|
+
'idn-hostname': { placeholder: 'example.com' },
|
|
50
|
+
'ipv4': { placeholder: '192.168.0.1' },
|
|
51
|
+
'ipv6': { placeholder: '::1' },
|
|
52
|
+
'mac': { placeholder: '00:1B:44:11:3A:B7' },
|
|
53
|
+
|
|
54
|
+
// -- uris
|
|
55
|
+
'uri': { control: 'url', placeholder: 'https://example.com/path' },
|
|
56
|
+
'uri--full': { control: 'url', placeholder: 'https://example.com/path' },
|
|
57
|
+
'uri-reference': { placeholder: '/relative/path' },
|
|
58
|
+
'uri-reference--full': { placeholder: '/relative/path' },
|
|
59
|
+
'uri-template': { placeholder: '/users/{id}' },
|
|
60
|
+
'url': { control: 'url', placeholder: 'https://example.com' },
|
|
61
|
+
'url--full': { control: 'url', placeholder: 'https://example.com' },
|
|
62
|
+
'iri': { control: 'url', placeholder: 'https://example.com/päth' },
|
|
63
|
+
'iri-reference': { placeholder: '/relative/päth' },
|
|
64
|
+
|
|
65
|
+
// -- identifiers
|
|
66
|
+
'uuid': { placeholder: '123e4567-e89b-12d3-a456-426614174000' },
|
|
67
|
+
'guid': { placeholder: '123e4567-e89b-12d3-a456-426614174000' },
|
|
68
|
+
'identifier': { placeholder: 'my_identifier' },
|
|
69
|
+
'html-identifier': { placeholder: 'my-element-id' },
|
|
70
|
+
'css-identifier': { placeholder: 'my-class-name' },
|
|
71
|
+
|
|
72
|
+
// -- json addressing
|
|
73
|
+
'json-pointer': { placeholder: '/path/to/value' },
|
|
74
|
+
'json-pointer-uri-fragment': { placeholder: '#/path/to/value' },
|
|
75
|
+
'relative-json-pointer': { placeholder: '1/sibling' },
|
|
76
|
+
'json-path': { placeholder: '$.store.book[0].title' },
|
|
77
|
+
|
|
78
|
+
// -- misc
|
|
79
|
+
'regex': { placeholder: '^[a-z]+$' },
|
|
80
|
+
'base64': { control: 'textarea', placeholder: 'SGVsbG8=' },
|
|
81
|
+
'byte': { control: 'textarea', placeholder: 'SGVsbG8=' },
|
|
82
|
+
'alpha': { placeholder: 'alpha' },
|
|
83
|
+
'numeric': { placeholder: '0123' },
|
|
84
|
+
'alphanumeric': { placeholder: 'abc123' },
|
|
85
|
+
'hexadecimal': { placeholder: 'deadbeef' },
|
|
86
|
+
'uppercase': { placeholder: 'ABC' },
|
|
87
|
+
'lowercase': { placeholder: 'abc' },
|
|
88
|
+
'color': { control: 'color', placeholder: '#ff8800' },
|
|
89
|
+
'isbn10': { placeholder: '0-306-40615-2' },
|
|
90
|
+
'isbn13': { placeholder: '978-3-16-148410-0' },
|
|
91
|
+
'iban': { placeholder: 'NL91ABNA0417164300' },
|
|
92
|
+
'country2': { placeholder: 'NL' },
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
function acceptAnything() {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @type {Record<string, FormatInfo>} */
|
|
100
|
+
export const FORM_FORMATS = {};
|
|
101
|
+
for (const [name, test] of Object.entries(formatTesters)) {
|
|
102
|
+
const control = name in numberFormatTesters ? 'number' : 'text';
|
|
103
|
+
FORM_FORMATS[name] = { test, control, ...FORM_HINTS[name] };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// -- convenience (non-standard, control hints only; unknown to the
|
|
107
|
+
// validator's registries, so they never assert anything)
|
|
108
|
+
FORM_FORMATS['password'] = { test: acceptAnything, control: 'password' };
|
|
109
|
+
FORM_FORMATS['textarea'] = { test: acceptAnything, control: 'textarea' };
|
|
110
|
+
FORM_FORMATS['multiline'] = { test: acceptAnything, control: 'textarea' };
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Look up the format info for a JSON Schema format name.
|
|
114
|
+
* @param {string|undefined} format - The format name
|
|
115
|
+
* @returns {FormatInfo|null} The format info, or null when unknown
|
|
116
|
+
*/
|
|
117
|
+
export function getFormatInfo(format) {
|
|
118
|
+
if (typeof format !== 'string') return null;
|
|
119
|
+
return FORM_FORMATS[format] || null;
|
|
120
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @jarenjs/forms - framework-agnostic form model generation for JSON Schema.
|
|
5
|
+
*
|
|
6
|
+
* buildFormModel(schema) turns a schema into a renderable field tree;
|
|
7
|
+
* validateField gives immediate per-field feedback powered by @jarenjs/core
|
|
8
|
+
* primitives, and the `x-form` rules (rules.js) add cross-field behavior -
|
|
9
|
+
* visibility, enablement, computed values, preemptive assertions - as
|
|
10
|
+
* compiled Jaren JSON Queries, before the complete compiled schema
|
|
11
|
+
* validation runs (app-wired; forms never imports the validator). The
|
|
12
|
+
* data helpers keep form values in plain JSON semantics, addressed by
|
|
13
|
+
* JSON pointer through the @jarenjs/json compiled pointer engine.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
buildFormModel,
|
|
18
|
+
resolveSchema,
|
|
19
|
+
getFieldKind,
|
|
20
|
+
humanizeKey,
|
|
21
|
+
escapePointerKey,
|
|
22
|
+
} from './model.js';
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
compileFormRules,
|
|
26
|
+
evaluateFormRules,
|
|
27
|
+
formRulesToQueryAssertions,
|
|
28
|
+
} from './rules.js';
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
validateField,
|
|
32
|
+
validateAllFields,
|
|
33
|
+
} from './validate.js';
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
createInitialData,
|
|
37
|
+
createItemValue,
|
|
38
|
+
parseFieldInput,
|
|
39
|
+
parsePointer,
|
|
40
|
+
getValueAtPointer,
|
|
41
|
+
setValueAtPointer,
|
|
42
|
+
appendItem,
|
|
43
|
+
removeItemAt,
|
|
44
|
+
} from './data.js';
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
FORM_FORMATS,
|
|
48
|
+
getFormatInfo,
|
|
49
|
+
} from './formats.js';
|