@jarenjs/forms 0.9.2 → 0.34.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,7 +61,7 @@ const result = validate(data); // { valid, errors: [{ instancePath, keyword, mes
61
61
  | `pointer` | JSON pointer into the data (`/user/name`) |
62
62
  | `label` | `title` or a humanized property name (`firstName` → "First Name") |
63
63
  | `kind` | `string` `number` `integer` `boolean` `enum` `const` `object` `array` |
64
- | `control` | Rendering hint: `text` `email` `url` `password` `textarea` `number` `checkbox` `select` `date` `color` `json` |
64
+ | `control` | Rendering hint: `text` `email` `url` `password` `textarea` `number` `checkbox` `select` `date` `datetime-local` `time` `color` `json` |
65
65
  | `required` | Whether the parent object requires this property |
66
66
  | `constraints` | `minLength`/`maxLength`/`pattern`/`format`/`minimum`/`maximum`/`multipleOf`/`minItems`/... |
67
67
  | `rules` | The raw `x-form` rules annotation, if any (see below) |
@@ -71,6 +71,8 @@ const result = validate(data); // { valid, errors: [{ instancePath, keyword, mes
71
71
 
72
72
  Field kinds are inferred from structural keywords when `type` is absent, and `format` maps to input controls and placeholders through the same registry the preemptive validation uses (`getFormatInfo`).
73
73
 
74
+ Which date formats get a **native** control is decided by the offset, not by convenience. HTML's `datetime-local` and `time` inputs cannot produce one, and RFC 3339 requires one — binding them to `date-time`/`time` would make the control emit values its own schema rejects, so those stay text inputs. The `iso-date-time`/`iso-time` formats leave the offset optional and are exactly what those inputs spell, so they map losslessly. `formatMinimum`/`formatMaximum` reach the field as constraints and become the control's `min`/`max`, so the picker itself refuses an out-of-range date; HTML has no exclusive date bounds, so `formatExclusive*` stays a submit-time check.
75
+
74
76
  ## Layer 1 — preemptive per-field validation
75
77
 
76
78
  `validateField(field, value)` returns `[{ keyword, message }]` using `@jarenjs/core` directly:
@@ -170,9 +172,9 @@ The same constraint can be spelled twice — `x-form.assert` for keystroke feedb
170
172
  ```javascript
171
173
  import { formRulesToQueryAssertions } from '@jarenjs/forms';
172
174
 
173
- // pure schema-to-schema transform: every x-form.assert is copied into a
174
- // root-level $query (joined to an existing one through allOf), with
175
- // value/pointer rebound to the field's location
175
+ // pure schema-to-schema transform: every x-form.assert becomes its own
176
+ // allOf branch { $query, errorMessage } on the root, with value/pointer
177
+ // rebound to the field's location and the rule's message carried along
176
178
  const submitSchema = formRulesToQueryAssertions(schema);
177
179
 
178
180
  import { JarenValidator } from '@jarenjs/validate'; // app-side
@@ -180,17 +182,212 @@ const validate = new JarenValidator().compile(submitSchema);
180
182
  validate({ company: 'ACME', vatId: '' }); // false - the vatId assert, now authoritative
181
183
  ```
182
184
 
183
- Item-template asserts quantify with `$every` over the actual elements. Two divergences from the keystroke path are inherent to the copy: on submit an absent field binds `$value` to the empty sequence (not `null`), and `$pointer` for template elements stays the template pointer (element indexes are a render-time notion).
185
+ Each branch's `errorMessage` carries the rule's `message` (inline string
186
+ or `$msgid` form) with `params` merged over `{ pointer: <field pointer> }`
187
+ — so every submit-time `$query` failure names its owning field in
188
+ `params.pointer`, letting the UI place root-level `$query` errors onto
189
+ fields, and renders the **same text** as the keystroke path (see below).
190
+
191
+ **One rule, one meaning.** Whatever the keystroke evaluation says about a
192
+ document, submit says too — the copy is not allowed to change what an author
193
+ wrote. Three things enforce that, and each is a place the naive copy went
194
+ wrong:
195
+
196
+ - an **absent field binds `null`**, not the empty sequence, so `$ne`/`$eq`
197
+ cannot mean opposite things on the two sides (the binding is wrapped in
198
+ `$default`);
199
+ - an **item-template assert quantifies over the ELEMENTS**, not over the
200
+ selected leaf values — quantifying over leaves silently skips an element
201
+ that lacks the member, where the keystroke path evaluates it with `null`;
202
+ - an assert on a field with a `visible` rule is **guarded by it**, holding
203
+ vacuously while the field is hidden — which is what the keystroke path
204
+ already does, since `buildFormViewModel` drops hidden nodes and their
205
+ errors never render or count.
206
+
207
+ One divergence remains, and is inherent: `$pointer` for template elements
208
+ stays the template pointer, because element indexes are a render-time notion.
209
+
210
+ A hidden field's *value* is a separate question, and the answer is explicit:
211
+ values are kept while editing — a field that reappears must not have
212
+ forgotten what the operator typed — and dropped only at the submit boundary,
213
+ by a caller who asks:
214
+
215
+ ```javascript
216
+ import { pruneHiddenValues } from '@jarenjs/forms';
217
+
218
+ const submitted = pruneHiddenValues(compiledRules, session.data);
219
+ ```
220
+
221
+ Visibility is evaluated once, against the incoming document. Hidden array
222
+ elements are removed and their siblings renumber, which is right for a
223
+ document being sent — and means the returned pointers no longer line up with
224
+ the ones the view model rendered.
225
+
226
+ ## Messages & i18n
227
+
228
+ Every `FieldError` is structured: `{ keyword, params, msgid, message }`.
229
+ The `msgid` is `form/<keyword>` (field checks) or `x-form/assert` /
230
+ the author's `$msgid` (rules); `message` is rendered eagerly —
231
+ failure-only, cheap — through a **catalog** (see
232
+ [ERROR-MESSAGES.md](../validate/docs/ERROR-MESSAGES.md) for the shared
233
+ contract). `validateField`, `validateAllFields` and `evaluateFormRules`
234
+ take an optional compiled catalog, default English:
235
+
236
+ ```javascript
237
+ import { validateAllFields, compileMessageCatalog } from '@jarenjs/forms';
238
+ import { nl } from '@jarenjs/locales';
239
+
240
+ const catalog = compileMessageCatalog(nl);
241
+ const errors = validateAllFields(model, data, catalog);
242
+ // errors['/name'][0].message === 'Dit veld is verplicht'
243
+ ```
244
+
245
+ ### MessageSpec in `x-form.message`
246
+
247
+ A rule's `message` may be a plain string (backward compatible — an inline
248
+ template, `{pointer}` etc. interpolated) or a `$msgid` spec resolving
249
+ through the catalog:
250
+
251
+ ```javascript
252
+ { "x-form": {
253
+ "assert": { "$or": [{ "$eq": ["$.company", ""] }, { "$ne": ["$.vatId", ""] }] },
254
+ "message": { "$msgid": "checkout.vat-required",
255
+ "message": "A VAT id is required for companies" } } }
256
+ ```
257
+
258
+ ### One rule, one message — keystroke and submit
259
+
260
+ The same rule renders the **identical string** per keystroke
261
+ (`evaluateFormRules`) and at submit (the transformed schema's `$query`
262
+ failure through the validator), in every locale:
263
+
264
+ ```javascript
265
+ const compiled = compileFormRules(buildFormModel(schema));
266
+ evaluateFormRules(compiled, data, catalog); // keystroke: Dutch text
267
+
268
+ const validate = new JarenValidator({ collectErrors: true })
269
+ .compile(formRulesToQueryAssertions(schema)); // app-side
270
+ const result = validate(data);
271
+ localizeErrors(result.errors, catalog); // submit: the same Dutch text
272
+ ```
273
+
274
+ ### Static text — the l10n surface everyone forgets
275
+
276
+ Labels, descriptions, placeholders and enum option labels resolve once at
277
+ model-build time; `buildFormModel` takes a `t` hook (default: identity)
278
+ receiving role-qualified message ids built from each field's base — its
279
+ `x-msgid` annotation or its data pointer:
280
+
281
+ ```javascript
282
+ const staticNl = {
283
+ '/firstName#label': 'Voornaam',
284
+ 'account.country#label': 'Land',
285
+ 'account.country#enum/nl': 'Nederland',
286
+ };
287
+ const model = buildFormModel(schema, {
288
+ t: (msgid, fallback) => staticNl[msgid] ?? fallback,
289
+ });
290
+ ```
291
+
292
+ Enum option labels come from the JSON Schema idiom
293
+ `oneOf: [{ "const": "nl", "title": "Netherlands" }, ...]` (treated as an
294
+ enum with per-option titles) or `String(value)`, each through
295
+ `t('<base>#enum/<value>', fallback)`; the labels land on
296
+ `field.enumLabels`, parallel to `field.enumValues`.
184
297
 
185
298
  ## Data helpers
186
299
 
187
- Form data keeps plain JSON semantics — an untouched field is *absent*, not an empty string. Pointers parse and read through the [`@jarenjs/json`](../json) compiled pointer engine (RFC 6901, one implementation repo-wide); reads hit a compiled-getter cache and allocate nothing:
300
+ Form data keeps plain JSON semantics — an untouched field is *absent*, not an empty string. Pointers parse, read AND write through the [`@jarenjs/json`](../json) engines (RFC 6901, one implementation repo-wide): reads hit a compiled-getter cache and allocate nothing, and writes run on the same copy-on-write kernel as the patch module (`compileJSONPointerSetter` with `parents: 'create'`), so untouched siblings are shared by reference on every keystroke — which feeds the JSLT memo and the view patcher's reference-equality fast path downstream:
188
301
 
189
302
  - `createInitialData(model)` — defaults and `const` values filled in, everything else absent
190
303
  - `parseFieldInput(field, raw)` — input coercion (`''` → undefined, numeric strings → numbers, enum options → typed values)
191
304
  - `getValueAtPointer` / `setValueAtPointer` / `appendItem` / `removeItemAt` — immutable updates addressed by JSON pointer
192
305
  - `createItemValue(field.item)` — starter value for a new array item
193
306
 
307
+ ## The view model — one render tree per instant
308
+
309
+ `buildFormViewModel(model, data, options)` composes everything above — the field tree, the current data, per-field validation and `x-form` rule state — into **one plain-JSON render tree**: the "computed view" layer this README promised. Each node carries `pointer`, `label`, `control`, `value` (`x-form.computed` wins, `null` when absent), precomputed select `options` (with `selected`), localized `errors`, `enabled`, and the write discipline flags (`element`: array elements must be written with RFC 6902 `replace`, since `add` inserts; `removable`; `addValue` from `createItemValue`). Rule-hidden fields are *excluded* — a renderer cannot leak hidden data by accident. Array item templates expand per data element with concrete pointers (`/lines/2/amount`), matching the pointer keys of `evaluateFormRules` and `validateAllFields`.
310
+
311
+ ```javascript
312
+ const model = buildFormModel(schema);
313
+ const rules = compileFormRules(model);
314
+ const tree = buildFormViewModel(model, data, { rules, validateFields: true, catalog });
315
+ // tree.children[0] -> { pointer: '/email', control: 'email', value: 'a@b',
316
+ // errors: ['Must be a valid email'], ... }
317
+ ```
318
+
319
+ Render it with anything — a React component walking the tree, or **no framework at all**: the standard form rules of [`@jarenjs/app`](../app) are a shipped JSLT rule set that dispatches over exactly this shape and produces [`@jarenjs/view`](../view) vnodes, closing the loop from JSON Schema to live DOM without a single hand-written render function.
320
+
321
+ Two node members exist because a **DOM control's value is a string** and not
322
+ every field's value is: each select option carries `key`, its value as JSON
323
+ text, and a `json`-control node carries `json`, its value as indented JSON
324
+ text. A renderer puts those in the control and hands them back verbatim;
325
+ `@jarenjs/app`'s `formEventFields()` decodes them. That keeps a `enum: [1, 2]`
326
+ selection a number instead of `"2"`.
327
+
328
+ ### Re-evaluating only what changed
329
+
330
+ Rules compile once and run per keystroke. On a wide form most of that work is
331
+ wasted — a keystroke in one field cannot change what a rule reading two other
332
+ fields concludes — so pass a **memo** and only the reachable rules re-run:
333
+
334
+ ```javascript
335
+ import { createRuleMemo } from '@jarenjs/forms';
336
+
337
+ const memo = createRuleMemo(); // one per form session
338
+ const tree = buildFormViewModel(model, data, { rules, memo, catalog });
339
+ ```
340
+
341
+ Each rule's dependencies are derived at compile time from the root-anchored
342
+ paths in its query documents (plus its own location). That over-approximates
343
+ every read, and soundly: the only way into the document is such a path, and a
344
+ `$let`/`$for` variable can only hold what one of them produced.
345
+
346
+ The memo diffs the previous document against the new one — reference-equal
347
+ subtrees are skipped whole, so an immutable edit costs O(change). A host that
348
+ already knows what it wrote can skip even that with `memo.touch(pointer)`,
349
+ which is a promise as much as an optimization: touch one pointer while
350
+ changing another and the rules you did not name keep stale results.
351
+
352
+ Measured on a 200-rule form (one `visible` + one `assert` each):
353
+
354
+ | tick | cost |
355
+ |---|---|
356
+ | full evaluation | ~54 µs |
357
+ | memo, one field changed (diffed) | ~13 µs |
358
+ | memo, one field changed (declared) | ~4.7 µs |
359
+ | memo, a field every rule reads | ~58 µs |
360
+
361
+ The last row is the honest one: when a change reaches every rule there is
362
+ nothing to skip, and the memo's bookkeeping makes it slightly *slower* than
363
+ evaluating straight through. It pays when a form is wide and its rules are
364
+ mostly local — which is what a large form usually is, and exactly when the
365
+ full evaluation starts to hurt.
366
+
367
+ ### The form session — submit/draft lifecycle around one document
368
+
369
+ Pass `options.session` and every node additionally carries `id` (a stable, **injective** accessible element id derived from the pointer — distinct pointers can never collide, because every character outside `[A-Za-z0-9]` (`-` and `_` themselves included) is escaped as `_<codepoint>_` *before* the segments are joined on `-`, so a member name containing the separator or an escape cannot forge another pointer's id: `/a/b` → `<prefix>--f-a-b`, `/a-b` → `<prefix>--f-a_45_b`. Member ids carry the `f-` marker so the `<prefix>--root` sentinel is disjoint from every member, a member literally named `root` included. Write the same escaping in your `<label for=…>` if you build an id by hand), `describedBy` (the id of the node's error text, or `null`), `dirty` (presence-aware deep compare against `session.initial` — adding or removing a member counts even when both sides read back `null`), `touched`, and `serverErrors` (kept distinct from client `errors`). The root gains a `session` summary:
370
+
371
+ ```javascript
372
+ const tree = buildFormViewModel(model, data, {
373
+ rules, validateFields: true,
374
+ session: {
375
+ initial, // the baseline document
376
+ touched: ['/email'], // pointers the operator visited
377
+ submitted: true,
378
+ submitStatus: 'pending', // echoed verbatim
379
+ requestId: 'req-7', // the in-flight submit's identity
380
+ serverErrors: { '/email': ['Al in gebruik.'] },
381
+ idPrefix: 'customer', // ids become customer--…
382
+ },
383
+ });
384
+ tree.session;
385
+ // { dirty, dirtyPaths, submitted, submitStatus, requestId,
386
+ // errorCount, serverErrorCount }
387
+ ```
388
+
389
+ `session.dirty`/`session.dirtyPaths` are the **navigation-guard authority**: they come from a full JSON diff of `initial` against the current data — independent of what is rendered — so removed array tails, members removed or added (including explicit `null`), and values retained under rule-hidden fields all count, each contributing its pointer. The diff walks **own** keys only (`Object.hasOwn`), so hostile-but-legal member names like `constructor` or a JSON-parsed `__proto__` diff as data, never through the prototype chain, and it RFC 6901-encodes every member name as it builds the pointer (`~` → `~0`, `/` → `~1`, the same `encodeJSONPointerSegment` the model, rule and validation walks use) — so a key containing a slash or a tilde cannot collide with a nested path, and the entries of `dirtyPaths` feed straight back into `getValueAtPointer`. To keep the authority unconditional, a root-level `x-form` `visible` rule is rejected by `compileFormRules` with a `TypeError` — hiding the whole form would null the render tree *and* its summary; gate whole-form visibility at the mount boundary instead. The per-node `dirty`/`errors` members remain the render-layer, visible-only summary. Without `session`, the tree is byte-identical to the sessionless shape.
390
+
194
391
  ## Development
195
392
 
196
- Unit tests live in `test/forms/` at the repository root. See the repository [README](../../README.md) for the full Jaren documentation, and the [ROADMAP](../../ROADMAP.md) for planned forms work (rule dependency memoization, hidden-field pruning on submit, computed views through JSLT).
393
+ Unit tests live in `test/forms/` at the repository root. See the repository [README](../../README.md) for the full Jaren documentation, and the [ROADMAP](../../docs/ROADMAP.md) for planned forms work (rule dependency memoization, hidden-field pruning on submit, computed views through JSLT).
@@ -15,10 +15,11 @@ export declare function parsePointer(pointer: string): string[];
15
15
  export declare function getValueAtPointer(data: any, pointer: string): any;
16
16
  /**
17
17
  * Return a copy of `data` with the value at `pointer` replaced.
18
- * Setting `undefined` REMOVES the property (array items become undefined
19
- * holes only when explicitly set; use removeItemAt to delete them).
20
- * Missing intermediate containers are created (objects for name segments,
21
- * arrays for numeric segments).
18
+ * Setting `undefined` REMOVES the location (deleting something that does
19
+ * not exist is a no-op returning `data` unchanged). Missing intermediate
20
+ * containers are created (objects for name segments, arrays for numeric
21
+ * segments), and untouched siblings are shared by reference — the same
22
+ * copy-on-write engine as `@jarenjs/json`'s patch and write modules.
22
23
  * @param {any} data
23
24
  * @param {string} pointer
24
25
  * @param {any} value
@@ -43,6 +44,24 @@ export declare function removeItemAt(data: any, pointer: string, index: number):
43
44
  * @returns {any}
44
45
  */
45
46
  export declare function appendItem(data: any, pointer: string, value: any): any;
47
+ /**
48
+ * Every pointer whose value differs between two documents, in
49
+ * document order.
50
+ *
51
+ * Membership is significant: an added or removed member (or array tail
52
+ * slot) contributes its pointer even when both sides read back as
53
+ * `null` through a pointer lookup. Reference-equal subtrees are skipped
54
+ * whole, so over copy-on-write edits — which is how every writer in
55
+ * this package produces its next document — the walk costs O(change),
56
+ * not O(document).
57
+ *
58
+ * Two callers rely on it: the session's navigation-guard evidence
59
+ * (`dirtyPaths`) and the rule memo's invalidation set.
60
+ * @param {any} previous
61
+ * @param {any} current
62
+ * @returns {string[]}
63
+ */
64
+ export declare function changedPointers(previous: any, current: any): string[];
46
65
  /**
47
66
  * Create initial data for a form model: schema defaults and const values
48
67
  * are filled in, everything else stays absent.
@@ -0,0 +1,30 @@
1
+ export type RuleDependencies = string[];
2
+ /**
3
+ * The dependency set of a rule: pointer prefixes, or `ALL_POINTERS` for
4
+ * "any change matters" (a query that reads the whole document).
5
+ * @typedef {string[]} RuleDependencies
6
+ */
7
+ /** A rule that reads the root depends on everything. */
8
+ export declare const ALL_POINTERS: string[];
9
+ /**
10
+ * Collect the data dependencies of one rule query document.
11
+ * @param {any} doc - The authored rule document
12
+ * @returns {RuleDependencies} Pointer prefixes, deduplicated
13
+ */
14
+ export declare function queryDependencies(doc: any): RuleDependencies;
15
+ /**
16
+ * Merge dependency sets, collapsing to {@link ALL_POINTERS} when any of
17
+ * them reads the root and dropping prefixes another already covers.
18
+ * @param {RuleDependencies[]} sets
19
+ * @returns {RuleDependencies}
20
+ */
21
+ export declare function mergeDependencies(sets: RuleDependencies[]): RuleDependencies;
22
+ /**
23
+ * Whether a change at `changed` can affect a rule depending on `dep`.
24
+ * True in BOTH nesting directions: a change inside a dependency
25
+ * (`/a` vs `/a/b`) alters what the rule reads, and a change ABOVE one
26
+ * (`/a/b` vs `/a`) can replace the container it reads through.
27
+ * @param {string} dep @param {string} changed
28
+ * @returns {boolean}
29
+ */
30
+ export declare function dependencyTouched(dep: string, changed: string): boolean;
@@ -11,7 +11,9 @@
11
11
  * JSON pointer through the @jarenjs/json compiled pointer engine.
12
12
  */
13
13
  export { buildFormModel, resolveSchema, getFieldKind, humanizeKey, escapePointerKey, } from './model.js';
14
- export { compileFormRules, evaluateFormRules, formRulesToQueryAssertions, } from './rules.js';
14
+ export { compileFormRules, evaluateFormRules, formRulesToQueryAssertions, pruneHiddenValues, createRuleMemo, } from './rules.js';
15
15
  export { validateField, validateAllFields, } from './validate.js';
16
+ export { formsMessagesEn, formChromeLabels, compileMessageTemplate, compileMessageCatalog, } from './messages.js';
16
17
  export { createInitialData, createItemValue, parseFieldInput, parsePointer, getValueAtPointer, setValueAtPointer, appendItem, removeItemAt, } from './data.js';
17
18
  export { FORM_FORMATS, getFormatInfo, } from './formats.js';
19
+ export { buildFormViewModel, } from './viewmodel.js';
@@ -0,0 +1,36 @@
1
+ export { compileMessageTemplate, compileMessageCatalog, } from '@jarenjs/core/message';
2
+ /**
3
+ * The built-in English forms catalog. Key set = exactly the `form/*` keys
4
+ * `validateField` emits, plus `x-form/assert` (the rules default). The
5
+ * strings are byte-identical to the historical inline template literals.
6
+ * @type {Record<string, string | ((params: object, error?: object) => string)>}
7
+ */
8
+ export declare const formsMessagesEn: Record<string, string | ((params: object, error?: object) => string)>;
9
+ /** The compiled built-in English catalog (module-level singleton). */
10
+ export declare const formsMessages: Readonly<Record<string, (params: object, error?: object) => string>>;
11
+ /**
12
+ * Resolve a message key through a caller catalog with built-in English
13
+ * fallback and render it.
14
+ * @param {Readonly<Record<string, (params: object, error?: object) => string>>|undefined} catalog - A compiled catalog, or undefined for English
15
+ * @param {string} msgid - The message key
16
+ * @param {object} params - The structured params
17
+ * @returns {string} The rendered message
18
+ */
19
+ export declare function renderFormsMessage(catalog: Readonly<Record<string, (params: object, error?: object) => string>> | undefined, msgid: string, params: object): string;
20
+ /**
21
+ * The localized chrome strings a form renderer needs: the accessible
22
+ * names of the array add/remove buttons.
23
+ *
24
+ * The stylesheet that renders a form is plain JSON built once, so it
25
+ * cannot look anything up at render time — the host resolves these and
26
+ * hands them to `createFormView`. Kept next to the error catalog on
27
+ * purpose: one keyspace, one parity gate across every locale pack.
28
+ * @param {Readonly<Record<string, (params: object, error?: object) => string>>} [catalog] - A compiled catalog, or undefined for English
29
+ * @returns {{addItem: string, removeItem: string}}
30
+ * @example
31
+ * createFormView({ labels: formChromeLabels(catalogs[locale]) });
32
+ */
33
+ export declare function formChromeLabels(catalog?: Readonly<Record<string, (params: object, error?: object) => string>>): {
34
+ addItem: string;
35
+ removeItem: string;
36
+ };
@@ -8,7 +8,11 @@ export type FormField = {
8
8
  */
9
9
  key: string;
10
10
  /**
11
- * - Human friendly label (schema title or humanized key)
11
+ * - The field's message-id base: `x-msgid` annotation or the pointer (the root field's base is the empty pointer '')
12
+ */
13
+ msgid: string;
14
+ /**
15
+ * - Human friendly label (schema title or humanized key), through the `t` hook
12
16
  */
13
17
  label: string;
14
18
  description: string | undefined;
@@ -21,7 +25,7 @@ export type FormField = {
21
25
  */
22
26
  kind: string;
23
27
  /**
24
- * - Suggested control: 'text'|'email'|'url'|'password'|'textarea'|'number'|'checkbox'|'select'|'date'|'color'|'json'
28
+ * - Suggested control: 'text'|'email'|'url'|'password'|'textarea'|'number'|'checkbox'|'select'|'date'|'datetime-local'|'time'|'color'|'json'
25
29
  */
26
30
  control: string;
27
31
  /**
@@ -33,6 +37,10 @@ export type FormField = {
33
37
  * - Options for a select control
34
38
  */
35
39
  enumValues: Array<any> | null;
40
+ /**
41
+ * - Display labels parallel to enumValues (oneOf const/title idiom, through the `t` hook)
42
+ */
43
+ enumLabels: Array<string> | null;
36
44
  /**
37
45
  * - Fixed value when the schema is a const
38
46
  */
@@ -60,29 +68,12 @@ export type FormField = {
60
68
  */
61
69
  tuple: Array<FormField> | null;
62
70
  };
63
- /**
64
- * @typedef {object} FormField
65
- * @property {string} pointer - JSON pointer into the DATA (e.g. '/user/name')
66
- * @property {string} key - Property name (or '-' for an array item template)
67
- * @property {string} label - Human friendly label (schema title or humanized key)
68
- * @property {string|undefined} description
69
- * @property {object} schema - The resolved subschema for this field
70
- * @property {string} kind - 'string'|'number'|'integer'|'boolean'|'enum'|'const'|'object'|'array'|'unknown'
71
- * @property {string} control - Suggested control: 'text'|'email'|'url'|'password'|'textarea'|'number'|'checkbox'|'select'|'date'|'color'|'json'
72
- * @property {boolean} required - Whether the parent object requires this property
73
- * @property {boolean} readOnly
74
- * @property {Array<any>|null} enumValues - Options for a select control
75
- * @property {any} constValue - Fixed value when the schema is a const
76
- * @property {any} defaultValue
77
- * @property {string|undefined} placeholder
78
- * @property {object} constraints - minLength/maxLength/pattern/minimum/... extracted for the UI
79
- * @property {object|null} rules - The raw `x-form` rules annotation, if any (see rules.js)
80
- * @property {Array<FormField>|null} children - Child fields for object kinds
81
- * @property {FormField|null} item - Template field for array items
82
- * @property {Array<FormField>|null} tuple - Fixed prefix fields for tuple arrays
83
- */
71
+ export type TranslateHook = (msgid: string, fallback: string | undefined, params?: object) => string | undefined;
84
72
  /**
85
73
  * Convert 'firstName' / 'first_name' / 'first-name' to 'First Name'.
74
+ * Latin-script-oriented (word splitting on case/underscore/hyphen and
75
+ * ASCII capitalization); the `t` hook of buildFormModel is the override
76
+ * point for anything it mangles.
86
77
  * @param {string} key
87
78
  * @returns {string}
88
79
  */
@@ -104,7 +95,9 @@ export declare function resolveSchema(schema: object | boolean, rootSchema: obje
104
95
  export declare function getFieldKind(schema: object | boolean): string;
105
96
  /**
106
97
  * Encode a property name as an RFC 6901 reference token (`~` -> `~0`,
107
- * `/` -> `~1`), the write-side inverse of the shared parse.
98
+ * `/` -> `~1`), the write-side inverse of the shared parse. An alias of
99
+ * `encodeJSONPointerSegment` from `@jarenjs/json/pointer`, kept for
100
+ * compatibility.
108
101
  * @param {string} key
109
102
  * @returns {string}
110
103
  */
@@ -112,7 +105,16 @@ export declare function escapePointerKey(key: string): string;
112
105
  /**
113
106
  * Build the form model for a JSON schema.
114
107
  *
108
+ * Static text (labels, descriptions, placeholders, enum option labels) is
109
+ * resolved ONCE here, at model-build time - the right place for
110
+ * translation. The optional `t` hook receives role-qualified message ids
111
+ * built from each field's base (`x-msgid` annotation or data pointer):
112
+ * `<base>#label`, `<base>#description`, `<base>#placeholder`,
113
+ * `<base>#enum/<String(value)>` - and the schema-derived fallback text.
114
+ *
115
115
  * @param {object|boolean} schema - The root JSON schema
116
+ * @param {object} [options]
117
+ * @param {TranslateHook} [options.t] - Static-text translation hook, default the zero-cost identity `(id, fb) => fb`
116
118
  * @returns {FormField} The root field descriptor (kind 'object' for object schemas)
117
119
  * @example
118
120
  * const model = buildFormModel({
@@ -121,5 +123,12 @@ export declare function escapePointerKey(key: string): string;
121
123
  * required: ['email'],
122
124
  * });
123
125
  * model.children[0].control; // 'email'
126
+ * @example
127
+ * // static-text i18n
128
+ * const nlModel = buildFormModel(schema, {
129
+ * t: (msgid, fallback) => staticTextNl[msgid] ?? fallback,
130
+ * });
124
131
  */
125
- export declare function buildFormModel(schema: object | boolean): FormField;
132
+ export declare function buildFormModel(schema: object | boolean, options?: {
133
+ t?: TranslateHook;
134
+ }): FormField;