@xoong/forms 0.0.0-stage → 0.0.35

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Duong Tran Xuan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,30 @@
1
- # Temporary Holding Version
1
+ # @xoong/forms
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Forms for xoong: a draft model with per-field state, validation from the OpenAPI request schema, your own rules, payload normalisation and dirty tracking, bound to plain `<form xo-submit>` markup.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @xoong/forms
9
+ ```
10
+
11
+ ## Use
12
+
13
+ ```html
14
+ <xo-view get="getPerson" p-id="1">
15
+ <form xo-submit="updatePerson">
16
+ <input name="name"> <input name="email" type="email">
17
+ <button>Save</button>
18
+ </form>
19
+ </xo-view>
20
+ ```
21
+ Importing `@xoong/ui` (or `@xoong/forms` itself) turns every `<form xo-submit>` into a form with a model: `form.model`, `$form.dirty`, server errors by field.
22
+
23
+ ## More
24
+
25
+ * Documentation and demos: https://github.com/fpt-duongtx/xoong#readme
26
+ * Source: https://github.com/fpt-duongtx/xoong/tree/main/packages/forms
27
+ * All packages share one version number and are released together: https://github.com/fpt-duongtx/xoong/releases
28
+ * xoong is a working prototype (0.0.x): the HTML syntax is still Draft.
29
+
30
+ MIT licence.
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "@xoong/forms",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.35",
4
+ "description": "xoong forms: a typed draft model with per-path state, validation rules taken from the OpenAPI request schema, custom rules, payload normalisation and dirty tracking. Zero dependencies besides @xoong/core and @xoong/data.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./src/index.d.ts",
10
+ "default": "./src/index.js"
11
+ },
12
+ "./model": "./src/form-model.js",
13
+ "./schema": "./src/schema.js"
14
+ },
15
+ "sideEffects": true,
16
+ "files": [
17
+ "src"
18
+ ],
19
+ "keywords": [
20
+ "xoong",
21
+ "forms",
22
+ "validation",
23
+ "openapi"
24
+ ],
25
+ "license": "MIT",
26
+ "author": "Duong Tran Xuan <duongtx1505@icloud.com>",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/fpt-duongtx/xoong.git",
30
+ "directory": "packages/forms"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/fpt-duongtx/xoong/issues"
34
+ },
35
+ "homepage": "https://github.com/fpt-duongtx/xoong#readme",
36
+ "dependencies": {
37
+ "@xoong/core": "0.0.35",
38
+ "@xoong/data": "0.0.35"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "engines": {
44
+ "node": ">=22.12"
45
+ }
46
+ }
@@ -0,0 +1,265 @@
1
+ // @ts-check
2
+ /**
3
+ * The binder (ADR-0009): attaches a form model to a <form> element. Controls are found by their `name` path; the model is the
4
+ * single source of truth and the DOM is a view of it.
5
+ *
6
+ * DOM -> model one delegated `input` / `change` listener per form (a form of 800 fields has two listeners, not 800)
7
+ * model -> DOM one small effect per control: its value, its error, and attributes completed from the schema
8
+ * errors shown on blur (touched) and on submit; `aria-invalid`, `aria-describedby`, a message element; the model decides,
9
+ * never the browser (the form gets `novalidate`, so a hidden invalid control cannot block submit)
10
+ *
11
+ * Used by `xo-submit` (packages/data/src/view.js) for every operation that has a request schema.
12
+ */
13
+ import { effect, untracked } from '@xoong/core/signals';
14
+ import { createForm } from './form-model.js';
15
+ import { parsePath, nodeAt, isRequired, defaults } from './schema.js';
16
+
17
+ /** @typedef {import('./schema.js').Node} Node */
18
+
19
+ const CONTROL = 'input,select,textarea';
20
+
21
+ /** Is it a custom control (an `XoControl`, or any form-associated element with a value)? */
22
+ const isCustom = (el) => el.localName.includes('-') && /** @type {any} */ (el.constructor).formAssociated === true;
23
+ const isControl = (el) => (el.matches?.(CONTROL) && el.type !== 'submit' && el.type !== 'button' && el.type !== 'reset' && el.type !== 'file') || isCustom(el);
24
+
25
+ /** ISO UTC string -> value of an <input type="datetime-local"> (local time, minutes). */
26
+ export function isoToLocal(iso) {
27
+ const d = new Date(iso);
28
+ if (Number.isNaN(d.getTime())) return '';
29
+ const p = (n) => String(n).padStart(2, '0');
30
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
31
+ }
32
+ /** Value of a datetime-local input (local time) -> ISO UTC string. */
33
+ export function localToIso(local) {
34
+ const d = new Date(local);
35
+ return local === '' || Number.isNaN(d.getTime()) ? '' : d.toISOString();
36
+ }
37
+
38
+ /** The text a control shows for a model value. */
39
+ const show = (v) => (v === undefined || v === null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v));
40
+
41
+ /**
42
+ * @param {HTMLFormElement} form
43
+ * @param {{ schema: Node, record?: () => any, hadRecord?: boolean }} options
44
+ * `record`: the record being edited (called inside an effect, so a live update refreshes the model); none for a create form.
45
+ */
46
+ export function attach(form, { schema, record, hadRecord = !!record }) {
47
+ form.setAttribute('novalidate', ''); // the model validates; the browser must not block on controls it cannot focus
48
+ const model = createForm({ schema, value: record?.() });
49
+ /** @type {Map<Element, { name: string, stops: (() => void)[] }>} */
50
+ const bound = new Map();
51
+ let writing = /** @type {Element | null} */ (null);
52
+ let errorSeq = 0;
53
+
54
+ // ---- controls ---------------------------------------------------------------------------------------------------------
55
+
56
+ /** Fill in only what the author left out; an attribute in the HTML always wins. */
57
+ function complete(el, node, name) {
58
+ const set = (attr, value) => { if (value !== undefined && value !== null && value !== false && !el.hasAttribute(attr)) el.setAttribute(attr, value === true ? '' : String(value)); };
59
+ if (!node) return;
60
+ if (el.tagName === 'INPUT') {
61
+ if (!el.hasAttribute('type')) {
62
+ const t = node.t === 'boolean' ? 'checkbox' : node.t === 'integer' || node.t === 'number' ? 'number'
63
+ : node.format === 'email' ? 'email' : node.format === 'uri' || node.format === 'url' ? 'url' : node.format === 'password' ? 'password'
64
+ : node.format === 'date' ? 'date' : node.format === 'date-time' ? 'datetime-local' : 'text';
65
+ el.setAttribute('type', t);
66
+ }
67
+ if (node.t === 'integer') set('step', 1); else if (node.t === 'number') set('step', node.multipleOf ?? 'any');
68
+ set('min', node.minimum ?? (node.exclusiveMinimum !== undefined ? node.exclusiveMinimum : undefined));
69
+ set('max', node.maximum ?? (node.exclusiveMaximum !== undefined ? node.exclusiveMaximum : undefined));
70
+ }
71
+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
72
+ set('minlength', node.minLength); set('maxlength', node.maxLength);
73
+ if (node.pattern && el.tagName === 'INPUT') set('pattern', node.pattern);
74
+ set('placeholder', node.placeholder);
75
+ }
76
+ if (el.tagName === 'SELECT' && node.enum && !el.options.length) {
77
+ const option = (text) => { const o = document.createElement('option'); o.value = o.textContent = text; return o; };
78
+ if (!isRequired(schema, parsePath(name), model.value)) el.append(option(''));
79
+ for (const v of node.enum) el.append(option(String(v)));
80
+ }
81
+ if (node.readOnly) set('readonly', true);
82
+ if (isRequired(schema, parsePath(name), model.value) && node.t !== 'boolean') set('required', true);
83
+ if (isCustom(el)) { // an open registry of widgets: any XoControl gets its schema node, its hints and the form model
84
+ const hints = { ...(node.hints ?? {}), label: node.title, hint: node.description, placeholder: node.placeholder, order: node.order, group: node.group };
85
+ Object.assign(el, { schema: node, hints, xoForm: model });
86
+ }
87
+ }
88
+
89
+ function bind(el) {
90
+ const name = el.getAttribute('name');
91
+ if (!name) return;
92
+ const prev = bound.get(el);
93
+ if (prev) { if (prev.name === name) return; unbind(el); }
94
+ const parts = parsePath(name);
95
+ const node = nodeAt(schema, parts, model.value);
96
+ complete(el, node, name);
97
+ const field = model.field(name);
98
+ const stops = [];
99
+ // model -> DOM
100
+ stops.push(effect(() => {
101
+ const v = field.value;
102
+ const clean = !field.dirty; // the model has no change here: what is written is the baseline (a record on open, a refresh, a saved value)
103
+ if (writing === el) return; // this control is where the change came from: do not rewrite what the person is typing
104
+ const at = nodeAt(schema, parts, model.value);
105
+ untracked(() => { write(el, v, at); if (clean && isCustom(el)) /** @type {any} */ (el).markPristine?.(); }); // so the control's own `dirty` agrees with the form's; only the model drives this effect: whatever the control reads or sets inside (its own value, its validity) must not subscribe it
106
+ }));
107
+ // errors -> DOM, only once the field has been visited (or submit touched everything)
108
+ stops.push(effect(() => { const message = field.touched ? field.error : ''; untracked(() => showError(el, message)); }));
109
+ bound.set(el, { name, stops });
110
+ }
111
+
112
+ function unbind(el) {
113
+ const b = bound.get(el);
114
+ if (!b) return;
115
+ b.stops.forEach((s) => s());
116
+ bound.delete(el);
117
+ showError(el, '');
118
+ }
119
+
120
+ /** Model value -> control. */
121
+ function write(el, v, node) {
122
+ // the control's own value is read untracked: only the model drives this write (a control that sets its value and then fires `input` must not be reverted before the model hears)
123
+ if (isCustom(el)) { if (JSON.stringify(untracked(() => el.value)) !== JSON.stringify(v ?? '')) el.setValue(v ?? ''); return; }
124
+ if (el.type === 'checkbox') { el.checked = v === true; return; }
125
+ if (el.type === 'radio') { el.checked = show(v) === el.value; return; }
126
+ if (el.tagName === 'SELECT' && el.multiple) { const set = new Set(Array.isArray(v) ? v.map(String) : []); for (const o of el.options) o.selected = set.has(o.value); return; }
127
+ const text = el.type === 'datetime-local' ? (typeof v === 'string' ? isoToLocal(v) : '') : show(v);
128
+ if (el.value !== text) el.value = text;
129
+ void node;
130
+ }
131
+
132
+ /** The message of a field, as the browser's own validity, `aria-invalid` and a visible note. */
133
+ function showError(el, message) {
134
+ const note = /** @type {any} */ (el)._xoNote;
135
+ if (!message) {
136
+ note?.remove();
137
+ /** @type {any} */ (el)._xoNote = undefined;
138
+ el.removeAttribute?.('aria-invalid');
139
+ const ids = (el.getAttribute?.('aria-describedby') ?? '').split(' ').filter((i) => i && i !== note?.id);
140
+ if (ids.length) el.setAttribute('aria-describedby', ids.join(' ')); else el.removeAttribute?.('aria-describedby');
141
+ el.setCustomValidity?.('');
142
+ return;
143
+ }
144
+ let n = note;
145
+ if (!n) {
146
+ n = document.createElement('small');
147
+ n.className = 'xo-error';
148
+ n.dataset.xoError = '';
149
+ n.id = `xo-err-${++errorSeq}-${Math.random().toString(36).slice(2, 6)}`;
150
+ el.after(n);
151
+ /** @type {any} */ (el)._xoNote = n;
152
+ el.setAttribute('aria-describedby', [el.getAttribute('aria-describedby'), n.id].filter(Boolean).join(' '));
153
+ }
154
+ n.textContent = message;
155
+ el.setAttribute('aria-invalid', 'true');
156
+ el.setCustomValidity?.(message);
157
+ }
158
+
159
+ // ---- DOM -> model ---------------------------------------------------------------------------------------------------------
160
+
161
+ const target = (e) => /** @type {Element | null} */ (e.target)?.closest?.('[name]');
162
+
163
+ function onInput(e) {
164
+ const el = target(e);
165
+ if (!el || !isControl(el)) return;
166
+ const name = el.getAttribute('name') ?? '';
167
+ writing = el;
168
+ try {
169
+ if (isCustom(el)) model.set(name, /** @type {any} */ (el).value, { coerced: true });
170
+ else if (el.type === 'checkbox') model.set(name, /** @type {HTMLInputElement} */ (el).checked);
171
+ else if (el.type === 'radio') { if (/** @type {HTMLInputElement} */ (el).checked) model.set(name, /** @type {HTMLInputElement} */ (el).value); }
172
+ else if (el.tagName === 'SELECT' && /** @type {HTMLSelectElement} */ (el).multiple) model.set(name, [...(/** @type {HTMLSelectElement} */ (el)).selectedOptions].map((o) => o.value), { coerced: true });
173
+ else if (el.type === 'datetime-local') model.set(name, localToIso(/** @type {HTMLInputElement} */ (el).value), { coerced: false });
174
+ else model.set(name, /** @type {HTMLInputElement} */ (el).value);
175
+ } finally { writing = null; }
176
+ }
177
+
178
+ function onBlur(e) {
179
+ const el = target(e);
180
+ if (el && isControl(el) && el.getAttribute('name')) model.touch(/** @type {string} */ (el.getAttribute('name')));
181
+ }
182
+
183
+ form.addEventListener('input', onInput);
184
+ form.addEventListener('change', onInput);
185
+ form.addEventListener('focusout', onBlur);
186
+
187
+ // Controls that appear later (xo-each rows, a revealed tab) and controls whose name changes (reordered rows).
188
+ const waiting = new Set();
189
+ const scan = (root) => {
190
+ for (const el of root.querySelectorAll?.('[name]') ?? []) { if (isControl(el)) bind(el); else waitFor(el); }
191
+ if (root.matches?.('[name]')) { if (isControl(root)) bind(root); else waitFor(root); }
192
+ };
193
+ /** A custom element used before its class is defined is a plain element for now: look again when the browser has upgraded it. */
194
+ const waitFor = (/** @type {Element} */ el) => {
195
+ const tag = el.localName;
196
+ if (!tag.includes('-') || waiting.has(tag) || typeof customElements === 'undefined' || customElements.get(tag)) return;
197
+ waiting.add(tag);
198
+ customElements.whenDefined(tag).then(() => { waiting.delete(tag); if (form.isConnected) scan(form); });
199
+ };
200
+ scan(form);
201
+ const observer = typeof MutationObserver !== 'undefined' ? new MutationObserver((records) => {
202
+ for (const r of records) {
203
+ if (r.type === 'attributes') { const el = /** @type {Element} */ (r.target); if (isControl(el)) { const b = bound.get(el); if (b && b.name !== el.getAttribute('name')) bind(el); else if (!b) bind(el); } continue; }
204
+ for (const n of r.removedNodes) if (n.nodeType === 1) { for (const el of [...bound.keys()]) if (!form.contains(el)) unbind(el); }
205
+ for (const n of r.addedNodes) if (n.nodeType === 1) scan(/** @type {Element} */ (n));
206
+ }
207
+ }) : null;
208
+ observer?.observe(form, { childList: true, subtree: true, attributes: true, attributeFilter: ['name'] });
209
+
210
+ // A dirty form announces itself (a router asks before the page is left), and says when it is clean again.
211
+ const stopArrays = model.onArrayChange((detail) => form.dispatchEvent(new CustomEvent('xo-array-change', { bubbles: true, detail }))); // the add, remove and move buttons, a drag, or code
212
+ const stopDirty = effect(() => form.dispatchEvent(new CustomEvent('xo-dirty', { bubbles: true, detail: { dirty: model.dirty } })));
213
+
214
+ // ---- the record underneath: a live update refreshes a pristine form, a dirty one keeps its draft ----------------------------
215
+ const stopRecord = record ? effect(() => { const r = record(); if (r !== undefined) model.refresh(r); }) : () => {};
216
+
217
+ // ---- submit ------------------------------------------------------------------------------------------------------------------
218
+
219
+ const controlFor = (path) => [...bound.entries()].find(([, b]) => parsePath(b.name).join('\u0000') === parsePath(path).join('\u0000'))?.[0];
220
+
221
+ /** The first invalid path in the order controls appear on the page (hidden or not rendered ones come after, in model order). */
222
+ function firstInvalid(errors) {
223
+ const paths = Object.keys(errors);
224
+ const ordered = [...form.querySelectorAll('[name]')].map((el) => el.getAttribute('name') ?? '').filter(Boolean);
225
+ return ordered.find((n) => paths.some((p) => parsePath(p).join('\u0000') === parsePath(n).join('\u0000'))) ?? paths[0];
226
+ }
227
+
228
+ /**
229
+ * The submit pipeline: touch all, validate, and when invalid ask the app to reveal the field (`xo-reveal`, cancelable) before focusing it.
230
+ * @param {(body: any) => Promise<any>} send
231
+ */
232
+ async function submit(send) {
233
+ const outcome = await model.submit(send, { after: hadRecord ? undefined : (form.hasAttribute('xo-keep') ? () => model.markPristine() : () => { model.markPristine(defaults(schema) ?? {}); model.reset(); }) }); // a sent form starts empty again: untouched, with no old errors
234
+ if (outcome.ok) return outcome;
235
+ const path = firstInvalid(outcome.errors);
236
+ const ev = new CustomEvent('xo-reveal', { detail: { path }, bubbles: true, cancelable: true });
237
+ const handled = !form.dispatchEvent(ev); // the app answers by switching a tab or selecting a block
238
+ if (handled) await new Promise((r) => setTimeout(r, 30)); // let that render
239
+ const el = controlFor(path);
240
+ if (el) (/** @type {any} */ (el).focusTarget ?? el).focus?.();
241
+ return { ...outcome, first: path };
242
+ }
243
+
244
+ function detach() {
245
+ form.removeEventListener('input', onInput);
246
+ form.removeEventListener('change', onInput);
247
+ form.removeEventListener('focusout', onBlur);
248
+ observer?.disconnect();
249
+ stopRecord();
250
+ stopDirty();
251
+ stopArrays();
252
+ form.dispatchEvent(new CustomEvent('xo-dirty', { bubbles: true, detail: { dirty: false } }));
253
+ for (const el of [...bound.keys()]) unbind(el);
254
+ }
255
+
256
+ // `form.rules` and `form.serialize` are set on the element, as the docs say
257
+ Object.defineProperties(form, {
258
+ rules: { get: () => model.rules, set: (r) => { model.rules = r; }, configurable: true },
259
+ serialize: { get: () => model.serialize, set: (fn) => { model.serialize = fn; }, configurable: true },
260
+ model: { value: model, configurable: true },
261
+ });
262
+
263
+ form.dispatchEvent(new CustomEvent('xo-form-ready', { bubbles: true, detail: { model } })); // `form.model` exists from here: a child control that needs it listens for this
264
+ return { model, submit, detach, rescan: () => scan(form) };
265
+ }
@@ -0,0 +1,362 @@
1
+ // @ts-check
2
+ /**
3
+ * The form model (ADR-0009): a typed draft of a record, the baseline it came from, and derived state per path. No DOM here: the
4
+ * binder in form-dom.js connects it to a <form>, and tests drive it directly.
5
+ *
6
+ * const form = createForm({ schema, value: record, rules: { slug: (v, { record }) => taken(v) ? 'Already used' : '' } });
7
+ * form.set('title.en', 'Hello'); // a control's raw value, coerced by the schema
8
+ * form.field('title.en').error // reactive: read it inside an effect or a template
9
+ * form.dirty, form.valid, form.errors['title.vi'], form.touched['title.vi'], form.filled['title.vi']
10
+ * await form.submit((body) => client.mutate('updateArticle', { id }, body));
11
+ *
12
+ * Per-path state lives in one signal per path that has been asked for, updated only when its own value changes, so an edit in one
13
+ * field wakes the readers of that field (and its ancestors), not the other few hundred.
14
+ */
15
+ import { signal, batch } from '@xoong/core/signals';
16
+ import { parsePath, pathKey, getAt, setAt, nodeAt, variant, coerce, defaults, validate } from './schema.js';
17
+
18
+ /** @typedef {import('./schema.js').Node} Node */
19
+
20
+ const clone = (v) => (v === undefined ? undefined : structuredClone(v));
21
+ /** Equality for "dirty": null and absent are the same (a cleared optional field is not a change from a record that never had it). */
22
+ const strip = (v) => {
23
+ if (Array.isArray(v)) return v.map(strip);
24
+ if (v && typeof v === 'object') { const o = {}; for (const [k, x] of Object.entries(v)) if (x !== null && x !== undefined) o[k] = strip(x); return o; }
25
+ return v;
26
+ };
27
+ const same = (a, b) => JSON.stringify(strip(a)) === JSON.stringify(strip(b));
28
+ const nothing = (v) => v === undefined || v === null || v === '';
29
+
30
+ /**
31
+ * What is sent, and what "dirty" compares: the draft reduced to the request schema.
32
+ * - only properties of the schema are kept (ids, timestamps and other server-owned fields of the record drop out; read-only too);
33
+ * - an empty value follows `emptyAs` ("omit" by default, or "null"; an untouched field counts as empty); an explicit null is kept for a nullable field;
34
+ * a nullable property the schema lists as required is sent as null when empty (the key must be present);
35
+ * - `pruneEmpty` on an object removes its empty members, and an object left empty follows its own `emptyAs`;
36
+ * - a node of type `any` is kept as it is.
37
+ * @param {Node | undefined} node @param {any} v
38
+ */
39
+ export function normalize(node, v) {
40
+ if (!node || node.t === 'any') return v;
41
+ if (v === undefined) return node.emptyAs === 'null' && node.t !== 'object' ? null : undefined; // a field never touched is still empty, and empty follows emptyAs (an object that was never created sends nothing)
42
+ const asEmpty = () => (node.emptyAs === 'null' ? null : undefined);
43
+ if (v === null) return node.nullable ? null : asEmpty();
44
+ if (node.t === 'union') { const picked = variant(node, v); return picked ? normalize(picked, v) : undefined; }
45
+ if (node.t === 'object') {
46
+ if (typeof v !== 'object' || Array.isArray(v)) return v;
47
+ const out = {};
48
+ for (const [k, child] of Object.entries(node.props ?? {})) {
49
+ if (child.readOnly) continue;
50
+ let n = normalize(child, v[k]);
51
+ if (n === undefined && child.nullable && (node.required ?? []).includes(k)) n = null; // the key must be there, and null is its "empty"
52
+ if (n !== undefined && !(node.pruneEmpty && (n === null ? !child.nullable : nothing(n) || (typeof n === 'object' && !Array.isArray(n) && !Object.keys(n).length)))) out[k] = n;
53
+ }
54
+ if (node.pruneEmpty && !Object.keys(out).length) return node.nullable && node.emptyAs !== 'omit' ? null : asEmpty();
55
+ return out;
56
+ }
57
+ if (node.t === 'array') return Array.isArray(v) ? v.map((item) => normalize(node.items, item)) : v;
58
+ if (v === '') return asEmpty();
59
+ return v;
60
+ }
61
+
62
+ /** Is there something in the value after normalisation? (A filled-in field, for "missing translation" markers.) */
63
+ function isFilled(node, v) {
64
+ const n = normalize(node, v);
65
+ if (n === undefined || n === null || n === '') return false;
66
+ if (Array.isArray(n)) return n.length > 0;
67
+ if (typeof n === 'object') return Object.keys(n).length > 0;
68
+ return true;
69
+ }
70
+
71
+ /**
72
+ * @param {{ schema?: Node, value?: any, rules?: Record<string, Function>, serialize?: (draft: any, body: any) => any }} [options]
73
+ * `schema`: the request body node; `value`: the record to edit (the baseline); `rules`: custom rules by path; `serialize`: the escape hatch.
74
+ */
75
+ export function createForm({ schema, value, rules = {}, serialize: serializeFn } = {}) {
76
+ let baseline = clone(value ?? defaults(schema) ?? {});
77
+ let draft = clone(baseline);
78
+ /** @type {Record<string, string>} */ let errors = {};
79
+ /** @type {Record<string, string>} */ let serverErrors = {};
80
+ /** @type {Set<(change: { path: string, op: 'add' | 'remove' | 'move', from?: number, to?: number }) => void>} */ const arrayListeners = new Set();
81
+ const arrayChanged = (/** @type {any[]} */ parent, op, from, to) => { const change = { path: pathKey(parent), op, from, to }; for (const fn of arrayListeners) fn(change); };
82
+ /** @type {Record<string, string>} */ let asyncErrors = {};
83
+ const asyncToken = new Map();
84
+ const touchedSet = new Set();
85
+ /** @type {Map<string, { parts: (string|number)[], value: any, error: any, touched: any, dirty: any, filled: any }>} */
86
+ const fields = new Map();
87
+ const state = { value: signal(draft), dirty: signal(false), valid: signal(true), submitting: signal(false), pending: signal(0), stale: signal(false) };
88
+ let currentRules = rules;
89
+
90
+ // ---- rules ----------------------------------------------------------------------------------------------------------------
91
+
92
+ /** The concrete paths a rule key applies to; `items[].title` covers every item. */
93
+ function expand(key) {
94
+ if (key === '*') return [[]];
95
+ let out = [[]];
96
+ for (const seg of key.replace(/\[\]/g, '.[]').split('.').filter(Boolean)) {
97
+ const next = [];
98
+ for (const base of out) {
99
+ if (seg === '[]') { const arr = getAt(draft, base); if (Array.isArray(arr)) arr.forEach((_, i) => next.push([...base, i])); } else next.push([...base, ...parsePath(seg)]);
100
+ }
101
+ out = next;
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /** Rule keys known to return promises (learned the first time they run): they are re-run only for the path that changed, never on every publish. */
107
+ const asyncRules = new Set();
108
+
109
+ /**
110
+ * Run the custom rules. Synchronous ones always (they are cheap); asynchronous ones only for the paths in `only` (or all of them for
111
+ * `'all'`), because running them again is a network call. Returns the synchronous messages; promises are collected in `collect`.
112
+ * @param {Set<string> | 'all' | undefined} only @param {Promise<any>[]} [collect]
113
+ */
114
+ function runRules(only, collect) {
115
+ /** @type {Record<string, string>} */ const sync = {};
116
+ for (const [key, fn] of Object.entries(currentRules)) {
117
+ for (const parts of expand(key)) {
118
+ const k = pathKey(parts);
119
+ if (asyncRules.has(key) && !(only === 'all' || (only && (key === '*' || only.has(k))))) continue;
120
+ let result;
121
+ try { result = key === '*' ? fn(draft) : fn(getAt(draft, parts), { record: draft, path: k, form }); } catch (e) { result = String(e?.message ?? e); }
122
+ if (result && typeof result.then === 'function') { asyncRules.add(key); const p = settle(k, key === '*', result); collect?.push(p); }
123
+ else if (key === '*') { if (result && typeof result === 'object') Object.assign(sync, result); } else if (result) sync[k] = String(result);
124
+ }
125
+ }
126
+ return sync;
127
+ }
128
+
129
+ /** An async rule: the field is pending until it answers; an answer for an older value is ignored. */
130
+ function settle(k, tree, promise) {
131
+ const token = (asyncToken.get(k) ?? 0) + 1;
132
+ asyncToken.set(k, token);
133
+ state.pending.value += 1;
134
+ return promise.then((r) => {
135
+ if (asyncToken.get(k) !== token) return;
136
+ if (tree && r && typeof r === 'object') Object.assign(asyncErrors, r);
137
+ else if (r) asyncErrors[k] = String(r); else delete asyncErrors[k];
138
+ }, (e) => { if (asyncToken.get(k) === token) asyncErrors[k] = String(e?.message ?? e); })
139
+ .finally(() => { state.pending.value -= 1; publish(runRules(undefined)); });
140
+ }
141
+
142
+ // ---- derived state --------------------------------------------------------------------------------------------------------
143
+
144
+ /** Schema errors first, then custom rules, then async answers, then what the server said. */
145
+ function compute(syncRules) {
146
+ return { ...serverErrors, ...asyncErrors, ...syncRules, ...validate(schema, draft) };
147
+ }
148
+
149
+ function dirtyAt(parts) {
150
+ const node = nodeAt(schema, parts, draft);
151
+ return !same(normalize(node, getAt(draft, parts)), normalize(node, getAt(baseline, parts)));
152
+ }
153
+
154
+ /** Recompute everything derived and push it to the signals whose value actually changed. */
155
+ function publish(syncRules = runRules(undefined)) {
156
+ errors = compute(syncRules);
157
+ batch(() => {
158
+ state.value.value = draft;
159
+ state.dirty.value = !same(normalize(schema, draft), normalize(schema, baseline));
160
+ state.valid.value = !Object.keys(errors).length && state.pending.peek() === 0;
161
+ for (const [k, f] of fields) {
162
+ f.value.value = getAt(draft, f.parts);
163
+ f.error.value = errors[k] ?? '';
164
+ f.touched.value = touchedSet.has(k);
165
+ f.dirty.value = dirtyAt(f.parts);
166
+ f.filled.value = isFilled(nodeAt(schema, f.parts, draft), getAt(draft, f.parts));
167
+ }
168
+ });
169
+ }
170
+
171
+ /** After items of an array moved: touched paths follow their item, and per-path messages of that array are dropped (they are recomputed). */
172
+ function remap(parent, to) {
173
+ const prefix = pathKey(parent);
174
+ const re = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\[(\\d+)\\](.*)$`);
175
+ const touched = [...touchedSet];
176
+ for (const k of touched) {
177
+ const m = re.exec(k);
178
+ if (!m) continue;
179
+ touchedSet.delete(k);
180
+ const n = to(Number(m[1]));
181
+ if (n >= 0) touchedSet.add(`${prefix}[${n}]${m[2]}`);
182
+ }
183
+ for (const map of [serverErrors, asyncErrors]) for (const k of Object.keys(map)) if (re.test(k)) delete map[k];
184
+ }
185
+
186
+ /** @param {string | (string|number)[]} path */
187
+ function field(path) {
188
+ const parts = typeof path === 'string' ? parsePath(path) : path;
189
+ const k = pathKey(parts);
190
+ let f = fields.get(k);
191
+ if (!f) {
192
+ const node = nodeAt(schema, parts, draft);
193
+ f = { parts, value: signal(getAt(draft, parts)), error: signal(errors[k] ?? ''), touched: signal(touchedSet.has(k)), dirty: signal(dirtyAt(parts)), filled: signal(isFilled(node, getAt(draft, parts))) };
194
+ fields.set(k, f);
195
+ }
196
+ const s = f;
197
+ return {
198
+ path: k,
199
+ get node() { return nodeAt(schema, parts, draft); },
200
+ get value() { return s.value.value; },
201
+ get error() { return s.error.value; },
202
+ get touched() { return s.touched.value; },
203
+ get dirty() { return s.dirty.value; },
204
+ get filled() { return s.filled.value; },
205
+ get valid() { return s.error.value === ''; },
206
+ };
207
+ }
208
+
209
+ /** `form.errors['title.vi']` style readers: each read tracks only that path. */
210
+ const view = (prop, listKeys) => new Proxy({}, {
211
+ get: (_, key) => (typeof key === 'string' && !key.startsWith('$') && key !== 'then' && key !== 'toJSON' ? field(key)[prop] : undefined),
212
+ has: (_, key) => typeof key === 'string' && !!field(key)[prop],
213
+ ownKeys: () => (listKeys ? listKeys() : []),
214
+ getOwnPropertyDescriptor: (_, key) => (typeof key === 'string' && listKeys?.().includes(key) ? { enumerable: true, configurable: true, value: field(key)[prop] } : undefined),
215
+ });
216
+
217
+ // ---- the model -------------------------------------------------------------------------------------------------------------
218
+
219
+ const form = {
220
+ /** The record being edited, as a signal-backed value: `form.value` (read inside an effect to track it). */
221
+ get value() { return state.value.value; },
222
+ get baseline() { return baseline; },
223
+ get dirty() { return state.dirty.value; },
224
+ get valid() { return state.valid.value; },
225
+ get submitting() { return state.submitting.value; },
226
+ get pending() { return state.pending.value > 0; },
227
+ /** True when the record changed underneath a dirty form (a live update): the draft is kept. */
228
+ get stale() { return state.stale.value; },
229
+ get rules() { return currentRules; },
230
+ set rules(r) { currentRules = r ?? {}; publish(); },
231
+ get serialize() { return serializeFn; },
232
+ set serialize(fn) { serializeFn = fn; },
233
+ field,
234
+ errors: view('error', () => (state.value.value, Object.keys(errors))),
235
+ touched: view('touched', () => [...touchedSet]),
236
+ filled: view('filled', undefined),
237
+ dirtyAt: view('dirty', undefined),
238
+
239
+ /** Read the draft at a path. */
240
+ get(path) { return getAt(draft, parsePath(path)); },
241
+
242
+ /** A control's raw value (string or boolean) written into the draft, coerced to the schema's type; clears the server error of that path. */
243
+ set(path, raw, { coerced = false } = {}) {
244
+ const parts = parsePath(path);
245
+ const k = pathKey(parts);
246
+ const next = coerced ? raw : coerce(nodeAt(schema, parts, draft), raw);
247
+ draft = setAt(draft, parts, next);
248
+ delete serverErrors[k];
249
+ delete asyncErrors[k];
250
+ publish(runRules(new Set([k])));
251
+ return next;
252
+ },
253
+
254
+ /**
255
+ * Add an item to the array at `path`. `value` is the new item; for a union item only `{ [key]: variant }` is needed, the variant's defaults are filled in.
256
+ * With no value a string item is '' and an object item starts from its schema defaults. Returns the new item's index.
257
+ */
258
+ append(path, initial) {
259
+ const parts = parsePath(path);
260
+ const node = nodeAt(schema, parts, draft);
261
+ const items = node?.items;
262
+ const list = getAt(draft, parts);
263
+ const current = Array.isArray(list) ? list : [];
264
+ let item = initial;
265
+ if (items?.t === 'union' && item && typeof item === 'object') item = { ...defaults(variant(items, item)), ...item };
266
+ else if (item === undefined) item = items?.t === 'string' ? '' : defaults(items) ?? (items?.t === 'object' ? {} : undefined);
267
+ draft = setAt(draft, parts, [...current, item]);
268
+ publish(runRules(undefined));
269
+ arrayChanged(parts, 'add', undefined, current.length);
270
+ return current.length;
271
+ },
272
+
273
+ /** Remove the array item at `path` (`blocks[2]`): the rows after it move up, and touched/error state follows them. */
274
+ removeAt(path) {
275
+ const parts = parsePath(path);
276
+ const index = parts.at(-1);
277
+ const parent = parts.slice(0, -1);
278
+ const list = getAt(draft, parent);
279
+ if (!Array.isArray(list) || typeof index !== 'number' || index >= list.length) return;
280
+ draft = setAt(draft, parent, list.filter((_, i) => i !== index));
281
+ remap(parent, (i) => (i === index ? -1 : i > index ? i - 1 : i));
282
+ publish(runRules(undefined));
283
+ arrayChanged(parent, 'remove', index, undefined);
284
+ },
285
+
286
+ /** Move the array item at `path` by `delta` places (-1 up, +1 down); a move past either end does nothing. */
287
+ moveItem(path, delta) {
288
+ const parts = parsePath(path);
289
+ const from = parts.at(-1);
290
+ const parent = parts.slice(0, -1);
291
+ const list = getAt(draft, parent);
292
+ if (!Array.isArray(list) || typeof from !== 'number') return;
293
+ const to = from + delta;
294
+ if (to < 0 || to >= list.length || to === from) return;
295
+ const next = [...list];
296
+ const [item] = next.splice(from, 1);
297
+ next.splice(to, 0, item);
298
+ draft = setAt(draft, parent, next);
299
+ remap(parent, (i) => (i === from ? to : from < to ? (i > from && i <= to ? i - 1 : i) : (i >= to && i < from ? i + 1 : i)));
300
+ publish(runRules(undefined));
301
+ arrayChanged(parent, 'move', from, to);
302
+ },
303
+
304
+ /** Be told when an array gains, loses or reorders an item, from any source (buttons, a drag, code): `{ path, op, from, to }`. Returns the stop function. */
305
+ onArrayChange(fn) { arrayListeners.add(fn); return () => arrayListeners.delete(fn); },
306
+
307
+ touch(path) { touchedSet.add(pathKey(parsePath(path))); publish(); },
308
+ /** Mark every field and every field with an error as touched (what submit does, so all errors show). */
309
+ touchAll() { for (const k of fields.keys()) touchedSet.add(k); for (const k of Object.keys(errors)) touchedSet.add(k); publish(); },
310
+
311
+ /** Server field errors, by path ({ 'title.en': 'taken' }); each clears when that field is edited. */
312
+ setServerErrors(map) { serverErrors = Object.fromEntries(Object.entries(map ?? {}).map(([k, v]) => [pathKey(parsePath(k)), v])); publish(); },
313
+
314
+ /** Back to the baseline, untouched. */
315
+ reset() { draft = clone(baseline); touchedSet.clear(); serverErrors = {}; asyncErrors = {}; state.stale.value = false; publish(); },
316
+ /** The saved record becomes the baseline (after a successful save). The draft is replaced when a record is given. */
317
+ markPristine(record) { if (record !== undefined) { baseline = clone(record); draft = clone(record); } else baseline = clone(draft); state.stale.value = false; publish(); },
318
+ /** The record changed in the store: a pristine form follows it, a dirty one keeps the draft and becomes stale. */
319
+ refresh(record) {
320
+ if (same(record, baseline)) return;
321
+ if (!state.dirty.peek()) { baseline = clone(record); draft = clone(record); publish(); } else state.stale.value = true;
322
+ },
323
+
324
+ /** The body that would be sent now. */
325
+ body() {
326
+ const body = normalize(schema, draft);
327
+ return serializeFn ? serializeFn(draft, body) : body;
328
+ },
329
+
330
+ /** Run everything, wait for async rules, and return all errors by path (empty when valid). */
331
+ async validate() {
332
+ const collected = [];
333
+ publish(runRules('all', collected));
334
+ await Promise.allSettled(collected);
335
+ publish();
336
+ return { ...errors };
337
+ },
338
+
339
+ /**
340
+ * Submit: touch everything, validate (async rules included), and when valid call `send(body)`. A thrown error with `fields` is shown on
341
+ * those paths. Resolves `{ ok: true, result }` or `{ ok: false, errors, first }` (the first invalid path, in schema order).
342
+ */
343
+ async submit(send, { after } = {}) {
344
+ form.touchAll();
345
+ const found = await form.validate();
346
+ const paths = Object.keys(found);
347
+ if (paths.length) return { ok: false, errors: found, first: paths[0] };
348
+ state.submitting.value = true;
349
+ try {
350
+ const result = await send(form.body());
351
+ if (after) after(result); else form.markPristine(result && typeof result === 'object' && !Array.isArray(result) ? result : undefined);
352
+ return { ok: true, result };
353
+ } catch (e) {
354
+ if (e && typeof e === 'object' && /** @type {any} */ (e).fields) form.setServerErrors(/** @type {any} */ (e).fields);
355
+ throw e;
356
+ } finally { state.submitting.value = false; }
357
+ },
358
+ };
359
+
360
+ publish(runRules('all'));
361
+ return form;
362
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,71 @@
1
+ // Types for @xoong/forms. The runtime is plain JavaScript; this file describes the form model and what the package exports.
2
+
3
+ /** A node of the request schema (`bodySchema` in operations.json). */
4
+ export interface SchemaNode {
5
+ t: 'string' | 'integer' | 'number' | 'boolean' | 'object' | 'array' | 'union' | 'entity' | 'any' | string;
6
+ nullable?: boolean; required?: string[]; props?: Record<string, SchemaNode>; items?: SchemaNode;
7
+ enum?: unknown[]; format?: string; pattern?: string; minLength?: number; maxLength?: number; minimum?: number; maximum?: number; minItems?: number; maxItems?: number;
8
+ default?: unknown; readOnly?: boolean; title?: string; description?: string; widget?: string; emptyAs?: 'null' | 'omit'; pruneEmpty?: boolean;
9
+ [key: string]: unknown;
10
+ }
11
+
12
+ /** A rule returns '' when the value is fine, or the message (it may be asynchronous). `'*'` gets the whole record and returns messages by path. */
13
+ export type Rule = (value: any, context: { record: any }) => string | undefined | Promise<string | undefined>;
14
+ export type Rules = Record<string, Rule | ((record: any) => Record<string, string> | Promise<Record<string, string>>)>;
15
+
16
+ export interface ArrayChange { path: string; op: 'add' | 'remove' | 'move'; from?: number; to?: number }
17
+
18
+ /** The draft of one form. `errors`, `touched`, `filled` and `dirtyAt` are read by path: `model.errors['title.en']`. */
19
+ export interface FormModel {
20
+ readonly value: any;
21
+ readonly baseline: any;
22
+ readonly dirty: boolean;
23
+ readonly valid: boolean;
24
+ readonly submitting: boolean;
25
+ readonly pending: boolean;
26
+ readonly stale: boolean;
27
+ rules: Rules;
28
+ serialize: ((draft: any, body: any) => any) | undefined;
29
+ readonly errors: Record<string, string>;
30
+ readonly touched: Record<string, boolean>;
31
+ readonly filled: Record<string, boolean>;
32
+ readonly dirtyAt: Record<string, boolean>;
33
+ field(path: string): any;
34
+ get(path: string): any;
35
+ set(path: string, raw: unknown, options?: { coerced?: boolean }): unknown;
36
+ append(path: string, initial?: unknown): number;
37
+ removeAt(path: string): void;
38
+ moveItem(path: string, delta: number): void;
39
+ onArrayChange(fn: (change: ArrayChange) => void): () => void;
40
+ touch(path: string): void;
41
+ touchAll(): void;
42
+ setServerErrors(map: Record<string, string> | undefined): void;
43
+ reset(): void;
44
+ markPristine(record?: any): void;
45
+ refresh(record: any): void;
46
+ body(): any;
47
+ submit(send: (body: any) => Promise<any>, options?: { after?: () => void }): Promise<{ ok: boolean; result?: any; errors?: Record<string, string> }>;
48
+ [key: string]: any;
49
+ }
50
+
51
+ export function createForm(options?: { schema?: SchemaNode; value?: any; rules?: Rules; serialize?: (draft: any, body: any) => any }): FormModel;
52
+ /** What is sent: the draft reduced to the request schema (empty values follow `emptyAs`, nullable required properties become null). */
53
+ export function normalize(node: SchemaNode | undefined, value: any): any;
54
+ /** Attach a model to a `<form>`: the binder that `<form xo-submit>` uses. */
55
+ export function attach(form: HTMLFormElement, options: { schema: SchemaNode; record?: () => any; hadRecord?: boolean }): { model: FormModel; submit(send: (body: any) => Promise<any>): Promise<any>; detach(): void; rescan(): void };
56
+ export function isoToLocal(iso: string): string;
57
+ export function localToIso(local: string): string;
58
+
59
+ export function parsePath(path: string): (string | number)[];
60
+ export function pathKey(parts: (string | number)[]): string;
61
+ export function getAt(value: any, parts: (string | number)[]): any;
62
+ export function setAt(value: any, parts: (string | number)[], next: unknown): any;
63
+ export function nodeAt(node: SchemaNode | undefined, parts: (string | number)[], value?: any): SchemaNode | undefined;
64
+ export function variant(node: SchemaNode | undefined, value: any): SchemaNode | undefined;
65
+ export function coerce(node: SchemaNode | undefined, raw: unknown): unknown;
66
+ export function defaults(node: SchemaNode | undefined): unknown;
67
+ export function validate(node: SchemaNode, value: any, at?: (string | number)[]): Record<string, string>;
68
+ export function checkValue(node: SchemaNode, value: unknown): string;
69
+ export function setMessages(partial: Record<string, (params: Record<string, any>) => string>): void;
70
+ export function resetMessages(): void;
71
+ export function message(code: string, params?: Record<string, any>): string;
package/src/index.js ADDED
@@ -0,0 +1,8 @@
1
+ // @xoong/forms: importing it makes every <form xo-submit> use the schema-driven model (see form-dom.js once it is registered).
2
+ export { createForm, normalize } from './form-model.js';
3
+ export { attach, isoToLocal, localToIso } from './form-dom.js';
4
+
5
+ import { setFormEngine } from '@xoong/data/view';
6
+ import { attach as attachForm } from './form-dom.js';
7
+ setFormEngine({ attach: attachForm }); // from now on, every xo-submit form with a request schema uses the form model
8
+ export { parsePath, pathKey, getAt, setAt, nodeAt, variant, coerce, defaults, validate, checkValue, setMessages, resetMessages, message } from './schema.js';
package/src/schema.js ADDED
@@ -0,0 +1,250 @@
1
+ // @ts-check
2
+ /**
3
+ * The request schema model behind forms (ADR-0009). Pure functions, no DOM, no signals: everything here is testable in plain Node.
4
+ *
5
+ * A schema node is the JSON that `xoong sync` writes into `operations.json` as `bodySchema`:
6
+ * { t: 'string', format?, enum?, minLength?, maxLength?, pattern?, nullable?, readOnly?, default?, title?, description?, widget? }
7
+ * { t: 'integer' | 'number', minimum?, maximum?, exclusiveMinimum?, exclusiveMaximum?, multipleOf?, ... }
8
+ * { t: 'boolean' }
9
+ * { t: 'object', props: { name: node }, required?: string[] }
10
+ * { t: 'array', items: node, minItems?, maxItems?, uniqueItems? }
11
+ * { t: 'union', key: 'type', map: { value: node } } a oneOf told apart by a property
12
+ * { t: 'any' } anything we cannot describe: accepted as it is
13
+ */
14
+
15
+ import { text } from '@xoong/core';
16
+
17
+ /** @typedef {{ t: string, [k: string]: any }} Node */
18
+
19
+ // ---- paths ----------------------------------------------------------------------------------------------------------------
20
+
21
+ /** "items[2].title" and "items.2.title" are the same path: ['items', 2, 'title']. */
22
+ export function parsePath(path) {
23
+ const parts = [];
24
+ for (const raw of String(path).replace(/\[(\d+)\]/g, '.$1').split('.')) {
25
+ if (raw === '') continue;
26
+ parts.push(/^\d+$/.test(raw) ? Number(raw) : raw);
27
+ }
28
+ return parts;
29
+ }
30
+
31
+ /** The canonical string of a path: ['items', 2, 'title'] -> "items[2].title". */
32
+ export function pathKey(parts) {
33
+ return parts.map((p, i) => (typeof p === 'number' ? `[${p}]` : i ? `.${p}` : p)).join('');
34
+ }
35
+
36
+ /** Read a value at a path (undefined when any step is missing). */
37
+ export function getAt(value, parts) {
38
+ let v = value;
39
+ for (const p of parts) { if (v == null || typeof v !== 'object') return undefined; v = v[p]; }
40
+ return v;
41
+ }
42
+
43
+ /** A copy of `value` with `next` written at the path (creating objects and arrays on the way; `undefined` deletes the key). */
44
+ export function setAt(value, parts, next) {
45
+ if (!parts.length) return next;
46
+ const [head, ...rest] = parts;
47
+ const base = value != null && typeof value === 'object' ? value : typeof head === 'number' ? [] : {};
48
+ const copy = Array.isArray(base) ? [...base] : { ...base };
49
+ const child = setAt(copy[head], rest, next);
50
+ if (child === undefined && !rest.length) { if (Array.isArray(copy)) copy[/** @type {number} */ (head)] = undefined; else delete copy[head]; } else copy[head] = child;
51
+ return copy;
52
+ }
53
+
54
+ // ---- walking the schema ------------------------------------------------------------------------------------------------------
55
+
56
+ /**
57
+ * The schema node for a path, or undefined. For a union the variant is chosen from `value` (the record at that level).
58
+ * @param {Node | undefined} node @param {(string|number)[]} parts @param {any} [value]
59
+ */
60
+ export function nodeAt(node, parts, value) {
61
+ let cur = node;
62
+ let val = value;
63
+ for (const p of parts) {
64
+ cur = variant(cur, val);
65
+ if (!cur) return undefined;
66
+ if (cur.t === 'object') cur = cur.props?.[p];
67
+ else if (cur.t === 'array' && typeof p === 'number') cur = cur.items;
68
+ else return undefined;
69
+ val = val != null && typeof val === 'object' ? val[p] : undefined;
70
+ }
71
+ return cur;
72
+ }
73
+
74
+ /** The concrete node of a union for a value (or the node itself). */
75
+ export function variant(node, value) {
76
+ if (!node || node.t !== 'union') return node;
77
+ const tag = value != null && typeof value === 'object' ? value[node.key] : undefined;
78
+ return tag !== undefined ? node.map?.[tag] : undefined;
79
+ }
80
+
81
+ // ---- values ---------------------------------------------------------------------------------------------------------------
82
+
83
+ /**
84
+ * A control's raw value (a string, or a boolean for a checkbox) as the typed value the schema wants.
85
+ * Empty text is "nothing entered": null for a nullable field, otherwise undefined (the key is left out).
86
+ * @param {Node | undefined} node @param {any} raw
87
+ */
88
+ export function coerce(node, raw) {
89
+ if (!node) return raw;
90
+ if (node.t === 'boolean') return raw === true || raw === 'true' || raw === 'on';
91
+ if (raw === '' || raw === undefined || raw === null) return node.nullable || raw === null ? null : undefined;
92
+ if (node.t === 'integer' || node.t === 'number') { const n = Number(raw); return Number.isNaN(n) ? raw : n; } // a bad number stays text and fails validation
93
+ return raw;
94
+ }
95
+
96
+ /** The value a new record starts from: explicit defaults, and `{}` for required nested objects. */
97
+ export function defaults(node) {
98
+ if (!node) return undefined;
99
+ if (node.default !== undefined) return structuredClone(node.default);
100
+ if (node.t === 'object') {
101
+ const out = {};
102
+ for (const [k, child] of Object.entries(node.props ?? {})) {
103
+ const d = defaults(child);
104
+ if (d !== undefined && (child.default !== undefined || (node.required ?? []).includes(k))) out[k] = d;
105
+ }
106
+ return out;
107
+ }
108
+ if (node.t === 'array' && (node.minItems ?? 0) > 0) return [];
109
+ return undefined;
110
+ }
111
+
112
+ // ---- messages -------------------------------------------------------------------------------------------------------------
113
+
114
+ /** The default (English) messages. Replace any of them, for any language, with `setMessages({ required: () => '...' })`. */
115
+ const catalogue = {
116
+ required: () => text('xoong.validation.required', 'Please fill in this field.'),
117
+ type: ({ type }) => (type === 'integer' ? text('xoong.validation.type.integer', 'Enter a whole number.') : type === 'number' ? text('xoong.validation.type.number', 'Enter a number.') : text('xoong.validation.type.other', 'This value is not valid.')),
118
+ minimum: ({ min }) => text('xoong.validation.minimum', 'Must be at least {min}.', { min }),
119
+ maximum: ({ max }) => text('xoong.validation.maximum', 'Must be at most {max}.', { max }),
120
+ exclusiveMinimum: ({ min }) => text('xoong.validation.exclusiveMinimum', 'Must be greater than {min}.', { min }),
121
+ exclusiveMaximum: ({ max }) => text('xoong.validation.exclusiveMaximum', 'Must be less than {max}.', { max }),
122
+ multipleOf: ({ step }) => text('xoong.validation.multipleOf', 'Must be a multiple of {step}.', { step }),
123
+ minLength: ({ min }) => text('xoong.validation.minLength', 'Use at least {min, plural, one {# character} other {# characters}}.', { min }),
124
+ maxLength: ({ max }) => text('xoong.validation.maxLength', 'Use at most {max, plural, one {# character} other {# characters}}.', { max }),
125
+ pattern: () => text('xoong.validation.pattern', 'This does not match the expected format.'),
126
+ format: ({ format }) => ({
127
+ email: text('xoong.validation.format.email', 'Enter a valid email address.'),
128
+ uri: text('xoong.validation.format.uri', 'Enter a valid URL.'),
129
+ url: text('xoong.validation.format.uri', 'Enter a valid URL.'),
130
+ uuid: text('xoong.validation.format.uuid', 'Enter a valid ID.'),
131
+ date: text('xoong.validation.format.date', 'Enter a valid date.'),
132
+ 'date-time': text('xoong.validation.format.dateTime', 'Enter a valid date and time.'),
133
+ }[format] ?? text('xoong.validation.type.other', 'This value is not valid.')),
134
+ enum: () => text('xoong.validation.enum', 'Choose one of the listed values.'),
135
+ minItems: ({ min }) => text('xoong.validation.minItems', 'Add at least {min}.', { min }),
136
+ maxItems: ({ max }) => text('xoong.validation.maxItems', 'Add at most {max}.', { max }),
137
+ uniqueItems: () => text('xoong.validation.uniqueItems', 'Items must be different from each other.'),
138
+ variant: () => text('xoong.validation.variant', 'Choose a type.'),
139
+ };
140
+ /** @type {Record<string, (params: any) => string>} */
141
+ let messages = { ...catalogue };
142
+
143
+ /** Replace some messages (for another language, or your own wording). Each receives its parameters, for example `{ min: 3 }`. */
144
+ export function setMessages(partial) { messages = { ...messages, ...partial }; }
145
+ /** Back to the built-in English messages. */
146
+ export function resetMessages() { messages = { ...catalogue }; }
147
+ /** The message for a rule code. */
148
+ export function message(code, params = {}) { return (messages[code] ?? catalogue.type)(params); }
149
+
150
+ // ---- validation -----------------------------------------------------------------------------------------------------------
151
+
152
+ const FORMATS = {
153
+ email: (s) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s),
154
+ uri: (s) => { try { new URL(s); return true; } catch { return false; } },
155
+ url: (s) => { try { new URL(s); return true; } catch { return false; } },
156
+ uuid: (s) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s),
157
+ date: (s) => /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(s)),
158
+ 'date-time': (s) => /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(s) && !Number.isNaN(Date.parse(s)),
159
+ };
160
+
161
+ const isEmpty = (v) => v === undefined || v === null || v === '';
162
+
163
+ /**
164
+ * The first rule a single value breaks, as a message, or '' when it is fine. `required` is decided by the parent (see validate).
165
+ * @param {Node} node @param {any} value
166
+ */
167
+ export function checkValue(node, value) {
168
+ if (value === undefined || value === null) return '';
169
+ switch (node.t) {
170
+ case 'string': {
171
+ if (typeof value !== 'string') return message('type', { type: 'string' });
172
+ if (node.enum && !node.enum.includes(value)) return message('enum');
173
+ if (node.minLength != null && value.length < node.minLength) return message('minLength', { min: node.minLength });
174
+ if (node.maxLength != null && value.length > node.maxLength) return message('maxLength', { max: node.maxLength });
175
+ if (node.pattern) { try { if (!new RegExp(node.pattern, 'u').test(value)) return message('pattern', { pattern: node.pattern }); } catch { /* a pattern JavaScript cannot read: do not block the form */ } }
176
+ if (node.format && FORMATS[node.format] && !FORMATS[node.format](value)) return message('format', { format: node.format });
177
+ return '';
178
+ }
179
+ case 'integer':
180
+ case 'number': {
181
+ if (typeof value !== 'number' || Number.isNaN(value) || (node.t === 'integer' && !Number.isInteger(value))) return message('type', { type: node.t });
182
+ if (node.enum && !node.enum.includes(value)) return message('enum');
183
+ if (node.minimum != null && value < node.minimum) return message('minimum', { min: node.minimum });
184
+ if (node.maximum != null && value > node.maximum) return message('maximum', { max: node.maximum });
185
+ if (node.exclusiveMinimum != null && value <= node.exclusiveMinimum) return message('exclusiveMinimum', { min: node.exclusiveMinimum });
186
+ if (node.exclusiveMaximum != null && value >= node.exclusiveMaximum) return message('exclusiveMaximum', { max: node.exclusiveMaximum });
187
+ if (node.multipleOf) { const q = value / node.multipleOf; if (Math.abs(q - Math.round(q)) > 1e-9) return message('multipleOf', { step: node.multipleOf }); }
188
+ return '';
189
+ }
190
+ default:
191
+ return '';
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Every error in a record, by canonical path: { 'title.en': 'message', 'tags[1]': 'message' }. Empty object when valid.
197
+ * Read-only properties are not checked (the server owns them). Unknown properties are accepted.
198
+ * @param {Node | undefined} node @param {any} value @param {(string|number)[]} [at]
199
+ * @returns {Record<string, string>}
200
+ */
201
+ export function validate(node, value, at = []) {
202
+ /** @type {Record<string, string>} */
203
+ const errors = {};
204
+ walk(node, value, at, errors);
205
+ return errors;
206
+ }
207
+
208
+ function walk(node, value, at, errors) {
209
+ if (!node || node.readOnly) return;
210
+ const key = pathKey(at);
211
+ if (node.t === 'union') {
212
+ const picked = variant(node, value);
213
+ if (!picked) { if (value != null) errors[pathKey([...at, node.key])] = message('variant'); return; }
214
+ walk(picked, value, at, errors);
215
+ return;
216
+ }
217
+ if (node.t === 'object') {
218
+ if (value == null) return;
219
+ if (typeof value !== 'object' || Array.isArray(value)) { errors[key] = message('type', { type: 'object' }); return; }
220
+ for (const [name, child] of Object.entries(node.props ?? {})) {
221
+ const v = value[name];
222
+ if ((node.required ?? []).includes(name) && !child.readOnly && isEmpty(v)) {
223
+ if (child.nullable) continue; // a nullable field may be left empty (it is sent as null): "required" only means the key is present
224
+ errors[pathKey([...at, name])] = message('required');
225
+ continue;
226
+ }
227
+ walk(child, v, [...at, name], errors);
228
+ }
229
+ return;
230
+ }
231
+ if (node.t === 'array') {
232
+ if (value == null) return;
233
+ if (!Array.isArray(value)) { errors[key] = message('type', { type: 'array' }); return; }
234
+ if (node.minItems != null && value.length < node.minItems) errors[key] = message('minItems', { min: node.minItems });
235
+ else if (node.maxItems != null && value.length > node.maxItems) errors[key] = message('maxItems', { max: node.maxItems });
236
+ else if (node.uniqueItems && new Set(value.map((x) => JSON.stringify(x))).size !== value.length) errors[key] = message('uniqueItems');
237
+ value.forEach((item, i) => walk(node.items, item, [...at, i], errors));
238
+ return;
239
+ }
240
+ const problem = checkValue(node, value);
241
+ if (problem) errors[key] = problem;
242
+ }
243
+
244
+ /** Is a path required? (Its parent object lists it as required.) */
245
+ export function isRequired(root, parts, value) {
246
+ if (!parts.length) return false;
247
+ const parent = nodeAt(root, parts.slice(0, -1), value);
248
+ const p = variant(parent, getAt(value, parts.slice(0, -1)));
249
+ return !!p && p.t === 'object' && (p.required ?? []).includes(parts.at(-1)) && !p.props?.[parts.at(-1)]?.nullable; // a nullable field may be left empty
250
+ }