@jarenjs/forms 0.9.2 → 0.34.0

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/model.js CHANGED
@@ -10,10 +10,18 @@
10
10
  * validation (see validate.js).
11
11
  */
12
12
 
13
+ import { isJsonObject } from '@jarenjs/core/object';
14
+ import { createWeakCache } from '@jarenjs/core/cache';
13
15
  import {
14
- compileJSONPointer,
15
- JSONPOINTER_NOTHING,
16
- } from '@jarenjs/json/pointer';
16
+ NUMERIC_CONSTRAINTS, STRING_CONSTRAINTS,
17
+ ARRAY_CONSTRAINTS, OBJECT_CONSTRAINTS,
18
+ } from '@jarenjs/core/schema';
19
+
20
+ import { encodeJSONPointerSegment } from '@jarenjs/json/pointer';
21
+ import {
22
+ collectSameDocumentAnchors,
23
+ resolveSameDocumentRef,
24
+ } from '@jarenjs/validate/normalize';
17
25
 
18
26
  import {
19
27
  getFormatInfo,
@@ -25,14 +33,16 @@ const DEFAULT_MAX_DEPTH = 24;
25
33
  * @typedef {object} FormField
26
34
  * @property {string} pointer - JSON pointer into the DATA (e.g. '/user/name')
27
35
  * @property {string} key - Property name (or '-' for an array item template)
28
- * @property {string} label - Human friendly label (schema title or humanized key)
36
+ * @property {string} msgid - The field's message-id base: `x-msgid` annotation or the pointer (the root field's base is the empty pointer '')
37
+ * @property {string} label - Human friendly label (schema title or humanized key), through the `t` hook
29
38
  * @property {string|undefined} description
30
39
  * @property {object} schema - The resolved subschema for this field
31
40
  * @property {string} kind - 'string'|'number'|'integer'|'boolean'|'enum'|'const'|'object'|'array'|'unknown'
32
- * @property {string} control - Suggested control: 'text'|'email'|'url'|'password'|'textarea'|'number'|'checkbox'|'select'|'date'|'color'|'json'
41
+ * @property {string} control - Suggested control: 'text'|'email'|'url'|'password'|'textarea'|'number'|'checkbox'|'select'|'date'|'datetime-local'|'time'|'color'|'json'
33
42
  * @property {boolean} required - Whether the parent object requires this property
34
43
  * @property {boolean} readOnly
35
44
  * @property {Array<any>|null} enumValues - Options for a select control
45
+ * @property {Array<string>|null} enumLabels - Display labels parallel to enumValues (oneOf const/title idiom, through the `t` hook)
36
46
  * @property {any} constValue - Fixed value when the schema is a const
37
47
  * @property {any} defaultValue
38
48
  * @property {string|undefined} placeholder
@@ -43,8 +53,22 @@ const DEFAULT_MAX_DEPTH = 24;
43
53
  * @property {Array<FormField>|null} tuple - Fixed prefix fields for tuple arrays
44
54
  */
45
55
 
56
+ /**
57
+ * The static-text translation hook: receives a role-qualified message id
58
+ * (`<base>#label`, `<base>#description`, `<base>#placeholder`,
59
+ * `<base>#enum/<value>`) and the schema-derived fallback text; returns
60
+ * the text to display.
61
+ * @typedef {(msgid: string, fallback: string|undefined, params?: object) => string|undefined} TranslateHook
62
+ */
63
+
64
+ /** @type {TranslateHook} The zero-cost identity hook. */
65
+ const identityT = (msgid, fallback) => fallback;
66
+
46
67
  /**
47
68
  * Convert 'firstName' / 'first_name' / 'first-name' to 'First Name'.
69
+ * Latin-script-oriented (word splitting on case/underscore/hyphen and
70
+ * ASCII capitalization); the `t` hook of buildFormModel is the override
71
+ * point for anything it mangles.
48
72
  * @param {string} key
49
73
  * @returns {string}
50
74
  */
@@ -59,24 +83,38 @@ export function humanizeKey(key) {
59
83
  }
60
84
 
61
85
  /**
62
- * Resolve a local JSON pointer ('#/$defs/foo') inside the root document
63
- * through the shared @jarenjs/json pointer walk (RFC 6901: the URI
64
- * fragment percent-decodes to the pointer text).
86
+ * Anchor maps per root schema, computed once and held exactly as long
87
+ * as the root object itself (`collectSameDocumentAnchors` walks the
88
+ * whole document — per-field recomputation would be quadratic).
89
+ */
90
+ const ANCHOR_MAPS = createWeakCache();
91
+
92
+ /**
93
+ * Resolve a same-document `$ref` — `#` (the root), `#/pointer`, or
94
+ * `#anchor` — with the validator's own exported resolver, so a form
95
+ * derives its model from exactly the schema the validator would
96
+ * enforce. (The previous private re-implementation resolved only
97
+ * `#/pointer`, silently rendering `$ref: "#"` and `#anchor` fields as
98
+ * `unknown`.) External refs stay unresolvable by design.
65
99
  * @param {string} ref
66
100
  * @param {object} rootSchema
67
101
  * @returns {object|boolean|null} The referenced schema or null when unresolvable
68
102
  */
69
103
  function resolveLocalRef(ref, rootSchema) {
70
- if (typeof ref !== 'string' || !ref.startsWith('#/')) return null;
71
- let pointer = ref.slice(1);
104
+ if (typeof ref !== 'string' || !ref.startsWith('#')) return null;
105
+ let fragment = ref;
72
106
  try {
73
- if (pointer.indexOf('%') >= 0) pointer = decodeURIComponent(pointer);
74
- const target = compileJSONPointer(pointer)(rootSchema);
75
- return target === JSONPOINTER_NOTHING ? null : target;
107
+ if (fragment.indexOf('%') >= 0) fragment = decodeURIComponent(fragment);
76
108
  }
77
- catch (e) {
109
+ catch (_e) {
78
110
  return null; // malformed fragment: same 'unresolvable' answer as a missing target
79
111
  }
112
+ const anchors = (rootSchema !== null && typeof rootSchema === 'object')
113
+ ? ANCHOR_MAPS.getOrCreate(rootSchema, collectSameDocumentAnchors)
114
+ : undefined;
115
+ const target = resolveSameDocumentRef(fragment, rootSchema,
116
+ /** @type {Map<string, object>|undefined} */ (anchors));
117
+ return target === undefined ? null : target;
80
118
  }
81
119
 
82
120
  /**
@@ -98,7 +136,7 @@ export function resolveSchema(schema, rootSchema, depth = 0) {
98
136
  if (target != null && typeof target === 'object') {
99
137
  const deref = resolveSchema(target, rootSchema, depth + 1);
100
138
  // 2019-09+: siblings apply together with the referenced schema
101
- const { $ref, ...siblings } = schema;
139
+ const { $ref: _$ref, ...siblings } = schema;
102
140
  resolved = (deref && typeof deref === 'object')
103
141
  ? { ...deref, ...siblings }
104
142
  : deref;
@@ -130,6 +168,21 @@ export function resolveSchema(schema, rootSchema, depth = 0) {
130
168
  return resolved;
131
169
  }
132
170
 
171
+ /**
172
+ * The `oneOf: [{const, title}, ...]` idiom: every branch an object with a
173
+ * `const`. Returns the branches, or null when the idiom does not apply.
174
+ * @param {object} schema
175
+ * @returns {Array<{const: any, title?: string}>|null}
176
+ */
177
+ function getOneOfConstBranches(schema) {
178
+ if (!Array.isArray(schema.oneOf) || schema.oneOf.length === 0) return null;
179
+ for (const branch of schema.oneOf) {
180
+ if (branch == null || typeof branch !== 'object' || Array.isArray(branch)
181
+ || branch.const === undefined) return null;
182
+ }
183
+ return schema.oneOf;
184
+ }
185
+
133
186
  /**
134
187
  * Derive the field kind from a resolved schema.
135
188
  * @param {object|boolean} schema
@@ -139,6 +192,8 @@ export function getFieldKind(schema) {
139
192
  if (schema == null || typeof schema !== 'object') return 'unknown';
140
193
  if (schema.const !== undefined) return 'const';
141
194
  if (Array.isArray(schema.enum)) return 'enum';
195
+ // The oneOf const/title idiom is an enum with per-option labels
196
+ if (getOneOfConstBranches(schema) !== null) return 'enum';
142
197
 
143
198
  let type = schema.type;
144
199
  if (Array.isArray(type)) {
@@ -195,14 +250,21 @@ function getControl(kind, schema) {
195
250
  * @param {object} schema
196
251
  * @returns {object}
197
252
  */
253
+ /** The form-relevant constraint keywords: the shared groups plus the
254
+ * date bounds — constraints like any other; without them a date control
255
+ * has no min/max to offer and the user only learns the range by
256
+ * submitting. */
257
+ const FORM_CONSTRAINTS = [
258
+ ...STRING_CONSTRAINTS,
259
+ ...NUMERIC_CONSTRAINTS,
260
+ 'formatMinimum', 'formatMaximum', 'formatExclusiveMinimum', 'formatExclusiveMaximum',
261
+ ...ARRAY_CONSTRAINTS,
262
+ ...OBJECT_CONSTRAINTS,
263
+ ];
264
+
198
265
  function getConstraints(schema) {
199
266
  const c = {};
200
- for (const key of [
201
- 'minLength', 'maxLength', 'pattern', 'format',
202
- 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf',
203
- 'minItems', 'maxItems', 'uniqueItems',
204
- 'minProperties', 'maxProperties',
205
- ]) {
267
+ for (const key of FORM_CONSTRAINTS) {
206
268
  if (schema[key] !== undefined) c[key] = schema[key];
207
269
  }
208
270
  return c;
@@ -216,36 +278,59 @@ function getConstraints(schema) {
216
278
  * @param {string} key - Property name or '-' for an item template
217
279
  * @param {boolean} required
218
280
  * @param {number} depth
281
+ * @param {TranslateHook} t - The static-text translation hook
219
282
  * @returns {FormField}
220
283
  */
221
- function buildField(rawSchema, rootSchema, pointer, key, required, depth) {
284
+ function buildField(rawSchema, rootSchema, pointer, key, required, depth, t) {
222
285
  const schema = resolveSchema(rawSchema, rootSchema, depth);
223
286
  const effective = (schema != null && typeof schema === 'object') ? schema : {};
224
287
  const kind = getFieldKind(schema);
225
288
  const control = getControl(kind, effective);
226
289
  const formatInfo = getFormatInfo(effective.format);
227
290
 
291
+ // The message-id base for static text: the x-msgid annotation, or the
292
+ // data pointer (the root field's base is the empty pointer '').
293
+ const base = typeof effective['x-msgid'] === 'string' ? effective['x-msgid'] : pointer;
294
+
295
+ const oneOfBranches = kind === 'enum' ? getOneOfConstBranches(effective) : null;
296
+ const enumValues = kind === 'enum'
297
+ ? (Array.isArray(effective.enum)
298
+ ? effective.enum
299
+ : oneOfBranches.map((branch) => branch.const))
300
+ : null;
301
+ const enumLabels = enumValues !== null
302
+ ? enumValues.map((value, i) => t(
303
+ `${base}#enum/${String(value)}`,
304
+ oneOfBranches !== null && typeof oneOfBranches[i].title === 'string'
305
+ ? oneOfBranches[i].title
306
+ : String(value)))
307
+ : null;
308
+
309
+ const placeholder = effective.examples?.[0] !== undefined
310
+ ? String(effective.examples[0])
311
+ : formatInfo?.placeholder;
312
+
228
313
  /** @type {FormField} */
229
314
  const field = {
230
315
  pointer,
231
316
  key,
232
- label: effective.title || humanizeKey(key),
233
- description: effective.description,
317
+ msgid: base,
318
+ label: t(`${base}#label`, effective.title || humanizeKey(key)),
319
+ description: t(`${base}#description`, effective.description),
234
320
  schema: effective,
235
321
  kind,
236
322
  control,
237
323
  required,
238
324
  readOnly: effective.readOnly === true,
239
- enumValues: kind === 'enum' ? effective.enum : null,
325
+ enumValues,
326
+ enumLabels,
240
327
  constValue: kind === 'const' ? effective.const : undefined,
241
328
  defaultValue: effective.default,
242
- placeholder: effective.examples?.[0] !== undefined
243
- ? String(effective.examples[0])
244
- : formatInfo?.placeholder,
329
+ placeholder: t(`${base}#placeholder`, placeholder),
245
330
  constraints: getConstraints(effective),
246
331
  // The raw `x-form` annotation only - compiling its query documents is
247
332
  // rules.js territory, so model building stays query-engine-free.
248
- rules: isRulesObject(effective['x-form']) ? effective['x-form'] : null,
333
+ rules: isJsonObject(effective['x-form']) ? effective['x-form'] : null,
249
334
  children: null,
250
335
  item: null,
251
336
  tuple: null,
@@ -259,7 +344,7 @@ function buildField(rawSchema, rootSchema, pointer, key, required, depth) {
259
344
  buildField(
260
345
  propSchema, rootSchema,
261
346
  `${pointer}/${escapePointerKey(name)}`, name,
262
- requiredSet.has(name), depth + 1));
347
+ requiredSet.has(name), depth + 1, t));
263
348
  }
264
349
 
265
350
  if (kind === 'array') {
@@ -268,41 +353,48 @@ function buildField(rawSchema, rootSchema, pointer, key, required, depth) {
268
353
  : (Array.isArray(effective.items) ? effective.items : null);
269
354
  if (prefix) {
270
355
  field.tuple = prefix.map((itemSchema, i) =>
271
- buildField(itemSchema, rootSchema, `${pointer}/${i}`, String(i), false, depth + 1));
356
+ buildField(itemSchema, rootSchema, `${pointer}/${i}`, String(i), false, depth + 1, t));
272
357
  const rest = Array.isArray(effective.items) ? effective.additionalItems : effective.items;
273
358
  if (rest != null && typeof rest === 'object') {
274
- field.item = buildField(rest, rootSchema, `${pointer}/-`, '-', false, depth + 1);
359
+ field.item = buildField(rest, rootSchema, `${pointer}/-`, '-', false, depth + 1, t);
275
360
  }
276
361
  }
277
362
  else if (effective.items != null && typeof effective.items === 'object') {
278
- field.item = buildField(effective.items, rootSchema, `${pointer}/-`, '-', false, depth + 1);
363
+ field.item = buildField(effective.items, rootSchema, `${pointer}/-`, '-', false, depth + 1, t);
279
364
  }
280
365
  else {
281
- field.item = buildField({}, rootSchema, `${pointer}/-`, '-', false, depth + 1);
366
+ field.item = buildField({}, rootSchema, `${pointer}/-`, '-', false, depth + 1, t);
282
367
  }
283
368
  }
284
369
 
285
370
  return field;
286
371
  }
287
372
 
288
- function isRulesObject(value) {
289
- return value != null && typeof value === 'object' && !Array.isArray(value);
290
- }
291
-
292
373
  /**
293
374
  * Encode a property name as an RFC 6901 reference token (`~` -> `~0`,
294
- * `/` -> `~1`), the write-side inverse of the shared parse.
375
+ * `/` -> `~1`), the write-side inverse of the shared parse. An alias of
376
+ * `encodeJSONPointerSegment` from `@jarenjs/json/pointer`, kept for
377
+ * compatibility.
295
378
  * @param {string} key
296
379
  * @returns {string}
297
380
  */
298
381
  export function escapePointerKey(key) {
299
- return String(key).replace(/~/g, '~0').replace(/\//g, '~1');
382
+ return encodeJSONPointerSegment(key);
300
383
  }
301
384
 
302
385
  /**
303
386
  * Build the form model for a JSON schema.
304
387
  *
388
+ * Static text (labels, descriptions, placeholders, enum option labels) is
389
+ * resolved ONCE here, at model-build time - the right place for
390
+ * translation. The optional `t` hook receives role-qualified message ids
391
+ * built from each field's base (`x-msgid` annotation or data pointer):
392
+ * `<base>#label`, `<base>#description`, `<base>#placeholder`,
393
+ * `<base>#enum/<String(value)>` - and the schema-derived fallback text.
394
+ *
305
395
  * @param {object|boolean} schema - The root JSON schema
396
+ * @param {object} [options]
397
+ * @param {TranslateHook} [options.t] - Static-text translation hook, default the zero-cost identity `(id, fb) => fb`
306
398
  * @returns {FormField} The root field descriptor (kind 'object' for object schemas)
307
399
  * @example
308
400
  * const model = buildFormModel({
@@ -311,8 +403,14 @@ export function escapePointerKey(key) {
311
403
  * required: ['email'],
312
404
  * });
313
405
  * model.children[0].control; // 'email'
406
+ * @example
407
+ * // static-text i18n
408
+ * const nlModel = buildFormModel(schema, {
409
+ * t: (msgid, fallback) => staticTextNl[msgid] ?? fallback,
410
+ * });
314
411
  */
315
- export function buildFormModel(schema) {
412
+ export function buildFormModel(schema, options = undefined) {
316
413
  const rootSchema = (schema != null && typeof schema === 'object') ? schema : {};
317
- return buildField(schema, rootSchema, '', '', false, 0);
414
+ const t = options != null && typeof options.t === 'function' ? options.t : identityT;
415
+ return buildField(schema, rootSchema, '', '', false, 0, t);
318
416
  }