@jarenjs/forms 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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';