@jarenjs/validate 0.8.4 → 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.
Files changed (53) hide show
  1. package/ARCHITECTURE.md +1131 -0
  2. package/LICENSE +21 -0
  3. package/README.md +796 -2
  4. package/dist/types/array.d.ts +2 -0
  5. package/dist/types/bigint.d.ts +1 -0
  6. package/dist/types/combine.d.ts +1 -0
  7. package/dist/types/condition.d.ts +1 -0
  8. package/dist/types/content.d.ts +3 -0
  9. package/dist/types/data.d.ts +7 -0
  10. package/dist/types/dollar-data.d.ts +11 -0
  11. package/dist/types/dynamic-ref.d.ts +44 -0
  12. package/dist/types/enum.d.ts +1 -0
  13. package/dist/types/format.d.ts +21 -0
  14. package/dist/types/index.d.ts +972 -0
  15. package/dist/types/messages.d.ts +142 -0
  16. package/dist/types/normalize.d.ts +107 -0
  17. package/dist/types/number.d.ts +1 -0
  18. package/dist/types/object.d.ts +3 -0
  19. package/dist/types/query-keyword.d.ts +19 -0
  20. package/dist/types/query.d.ts +29 -0
  21. package/dist/types/schema.d.ts +1 -0
  22. package/dist/types/string.d.ts +1 -0
  23. package/dist/types/tools.d.ts +109 -0
  24. package/dist/types/traverse.d.ts +32 -0
  25. package/dist/types/unevaluated.d.ts +12 -0
  26. package/docs/ERROR-MESSAGES.md +251 -0
  27. package/package.json +37 -7
  28. package/src/array.js +610 -0
  29. package/src/bigint.js +108 -0
  30. package/src/combine.js +276 -0
  31. package/src/condition.js +129 -0
  32. package/src/content.js +83 -0
  33. package/src/data.js +101 -0
  34. package/src/dollar-data.js +212 -0
  35. package/src/dynamic-ref.js +121 -0
  36. package/src/enum.js +147 -0
  37. package/src/format.js +108 -0
  38. package/src/index.js +1896 -0
  39. package/src/messages.js +497 -0
  40. package/src/normalize.js +585 -0
  41. package/src/number.js +169 -0
  42. package/src/object.js +848 -0
  43. package/src/query-keyword.js +99 -0
  44. package/src/query.js +85 -0
  45. package/src/schema.js +690 -0
  46. package/src/string.js +164 -0
  47. package/src/tools.js +397 -0
  48. package/src/traverse.js +442 -0
  49. package/src/unevaluated.js +173 -0
  50. package/dist/index.js +0 -1998
  51. package/dist/index.js.map +0 -7
  52. package/dist/index.min.js +0 -2
  53. package/dist/index.min.js.map +0 -7
@@ -0,0 +1,585 @@
1
+ //#region schema-driven normalization
2
+ // Normalization is a SEPARATE pass from validation, compiled from the same
3
+ // schema. `@jarenjs/validate`'s compiled validators are pure predicates and
4
+ // stay that way: they never apply a `default`, never coerce, never trim and
5
+ // never strip. This module is where a consumer that needs Zod-style
6
+ // normalized *output* gets it, without the validator growing a side effect.
7
+ //
8
+ // Two properties are load-bearing:
9
+ //
10
+ // 1. The input is never mutated. Ajv's `useDefaults`/`coerceTypes` write
11
+ // into the document they were handed; this compiles to a copy-on-write
12
+ // walk instead, so `normalize(input)` returns a new value and `input`
13
+ // is exactly as it was. A caller can hand the same document to two
14
+ // normalizers, or keep it as an audit record, without defensive copying.
15
+ // 2. Untouched subtrees keep their identity. A node whose whole subtree is
16
+ // unchanged is returned by reference, and a document that needs no
17
+ // change at all returns the input reference itself (`normalize(x) === x`).
18
+ // That makes a no-op cheap and lets downstream memoization by identity
19
+ // keep working.
20
+ //
21
+ // House style: the same two-stage shape as the validator. `compileNormalizer`
22
+ // walks the schema once and specializes one closure per node; the returned
23
+ // function just runs closures. No `eval`, no `new Function`, no dependencies
24
+ // outside `@jarenjs/*`.
25
+
26
+ import {
27
+ cloneJson,
28
+ isJsonContainer,
29
+ isJsonObject,
30
+ setObjectMember,
31
+ } from '@jarenjs/core/object';
32
+
33
+ import { parseJSONPointer } from '@jarenjs/json';
34
+
35
+ const hasOwn = Object.hasOwn;
36
+
37
+ // The JSON number grammar (RFC 8259 section 6). A string is coerced to a
38
+ // number only when it is exactly a JSON number - so '1e5' and '-0.5' coerce
39
+ // while '0x10', '1_000', 'Infinity', ' ' and '' stay strings and fail
40
+ // validation with a type error the caller can report.
41
+ const RE_JSON_NUMBER = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?$/;
42
+
43
+ /**
44
+ * A compiled normalizer. `In` is what the caller accepts, `Out` what the
45
+ * normalized value is; they differ whenever defaults are materialized, which
46
+ * is exactly the distinction a contract wrapper needs to expose.
47
+ * @template [In=unknown]
48
+ * @template [Out=In]
49
+ * @typedef {(data: In) => Out} Normalizer
50
+ */
51
+
52
+ /**
53
+ * Options for {@link compileNormalizer}. Every normalization is off by
54
+ * default: each one changes the meaning of the caller's data, so each is a
55
+ * decision the caller makes rather than inherits. With no options the
56
+ * compiled normalizer is the identity.
57
+ * Three of the four also accept a **predicate** `(schemaNode) => boolean`
58
+ * instead of a boolean, evaluated once per node at compile time. That is how
59
+ * a consumer turns a whole-schema switch into a per-field decision — "trim
60
+ * these string fields, leave those alone" — without the option becoming a
61
+ * blunt instrument and without any runtime cost.
62
+ * @typedef {object} NormalizeOptions
63
+ * @property {boolean|((schemaNode: Record<string, unknown>) => boolean)} [useDefaults=false] - Materialize `default` for absent object properties, recursively
64
+ * @property {boolean|'all'} [removeAdditional=false] - Strip unknown properties: `true` only where `additionalProperties: false`, `'all'` wherever an object shape is declared
65
+ * @property {boolean|((schemaNode: Record<string, unknown>) => boolean)} [coerceTypes=false] - Convert a value to the node's declared scalar `type` when it is convertible
66
+ * @property {boolean|((schemaNode: Record<string, unknown>) => boolean)} [trimStrings=false] - Trim leading/trailing whitespace from strings, before coercion
67
+ */
68
+
69
+ /**
70
+ * Convert `value` to `type` when the conversion is unambiguous, otherwise
71
+ * return it unchanged so validation reports the type error.
72
+ *
73
+ * The table is deliberately conservative - it exists to decode transport
74
+ * encodings (query strings, form fields, environment variables, CSV cells)
75
+ * where everything arrives as a string, not to paper over wrong data. Ajv
76
+ * additionally maps `null`/`0`/`1`/`''` across types; those conversions lose
77
+ * the difference between "absent", "empty" and "false", so they are not
78
+ * reproduced here.
79
+ * @param {any} value - The value to convert
80
+ * @param {string} type - The declared JSON Schema scalar type
81
+ * @returns {any} The converted value, or `value` when no conversion applies
82
+ */
83
+ function coerceToType(value, type) {
84
+ switch (type) {
85
+ case 'number':
86
+ case 'integer': {
87
+ if (typeof value === 'number') return value;
88
+ if (typeof value !== 'string' || !RE_JSON_NUMBER.test(value)) return value;
89
+ const num = Number(value);
90
+ if (!Number.isFinite(num)) return value;
91
+ if (type === 'integer' && !Number.isInteger(num)) return value;
92
+ return num;
93
+ }
94
+ case 'boolean':
95
+ if (typeof value === 'boolean') return value;
96
+ if (value === 'true') return true;
97
+ if (value === 'false') return false;
98
+ return value;
99
+ case 'string':
100
+ if (typeof value === 'string') return value;
101
+ if (typeof value === 'boolean') return String(value);
102
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
103
+ return value;
104
+ case 'null':
105
+ return value === 'null' ? null : value;
106
+ default:
107
+ return value;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Collect the `$anchor` declarations of one schema document: a map from
113
+ * anchor name to the schema node that declares it. Exported because
114
+ * `@jarenjs/emit` resolves the same references when it derives types, and two
115
+ * walks with different scope rules would make a generated type disagree with
116
+ * this normalizer — the one defect class that package must not have.
117
+ *
118
+ * The scope is the same-document scope the rest of this module uses: a
119
+ * subtree that declares its own `$id` is an embedded resource with its own
120
+ * anchor scope, so it is not descended. First declaration wins, which keeps
121
+ * the map deterministic for a document that (invalidly) repeats a name.
122
+ * @param {object|boolean} root - The root schema of the document
123
+ * @returns {Map<string, object>} anchor name -> schema node
124
+ */
125
+ export function collectSameDocumentAnchors(root) {
126
+ /** @type {Map<string, object>} */
127
+ const anchors = new Map();
128
+ if (!isJsonObject(root)) return anchors;
129
+ const seen = new Set();
130
+ /** @param {any} node @param {boolean} isRoot */
131
+ const walk = (node, isRoot) => {
132
+ if (!isJsonContainer(node) || seen.has(node)) return;
133
+ seen.add(node);
134
+ if (Array.isArray(node)) {
135
+ for (let i = 0; i < node.length; i++) walk(node[i], false);
136
+ return;
137
+ }
138
+ if (!isRoot && typeof node.$id === 'string') return;
139
+ if (typeof node.$anchor === 'string' && !anchors.has(node.$anchor))
140
+ anchors.set(node.$anchor, node);
141
+ const keys = Object.getOwnPropertyNames(node);
142
+ for (let i = 0; i < keys.length; i++) walk(node[keys[i]], false);
143
+ };
144
+ walk(root, true);
145
+ return anchors;
146
+ }
147
+
148
+ /**
149
+ * Resolve a same-document `$ref` — `#`, `#/` followed by a JSON Pointer, or
150
+ * `#name` for a plain `$anchor` — to the schema it addresses. Refs into other
151
+ * documents are not followed: a normalizer compiles one schema, and reaching
152
+ * a registered sibling would mean owning the whole resolution scope that
153
+ * `compile` owns. Exported for `@jarenjs/emit`, which must resolve references
154
+ * with exactly these rules when it derives the accepted/normalized variants.
155
+ * @param {string} ref - The reference
156
+ * @param {object|boolean} root - The root schema being compiled
157
+ * @param {Map<string, object>} [anchors] - The document's anchor map, from
158
+ * {@link collectSameDocumentAnchors}; omit to skip anchor resolution
159
+ * @returns {object|boolean|undefined} The addressed schema, or undefined
160
+ */
161
+ export function resolveSameDocumentRef(ref, root, anchors) {
162
+ if (ref === '#') return root;
163
+ if (!ref.startsWith('#')) return undefined;
164
+ if (!ref.startsWith('#/'))
165
+ return anchors === undefined ? undefined : anchors.get(ref.slice(1));
166
+ let node = root;
167
+ let tokens;
168
+ try {
169
+ tokens = parseJSONPointer(ref.slice(1));
170
+ }
171
+ catch (_e) {
172
+ return undefined;
173
+ }
174
+ for (let i = 0; i < tokens.length; i++) {
175
+ if (!isJsonContainer(node)) return undefined;
176
+ node = node[tokens[i]];
177
+ }
178
+ return node;
179
+ }
180
+
181
+ /**
182
+ * Resolve a per-node normalization switch at COMPILE time. Exported because
183
+ * `@jarenjs/emit` has to answer the same question when it derives the accepted
184
+ * and normalized type variants: two implementations of this rule would drift,
185
+ * and a type that disagrees with the normalizer is worse than no type. `true` turns the
186
+ * behavior on everywhere, `false` nowhere, and a predicate decides per schema
187
+ * node — which is how a consumer expresses "trim these 34 string fields, not
188
+ * the other 185" without the option becoming a whole-schema blunt instrument.
189
+ * Because it runs during compilation, a predicate costs nothing at runtime.
190
+ * @param {boolean|((node: Record<string, unknown>) => boolean)|undefined} option
191
+ * @param {object} node - The schema node the switch applies to
192
+ * @returns {boolean}
193
+ */
194
+ export function resolveNormalizeSwitch(option, node) {
195
+ if (option === true) return true;
196
+ if (typeof option === 'function') return option(node) === true;
197
+ return false;
198
+ }
199
+
200
+ /** Compile a regular expression, tolerating patterns this engine rejects. */
201
+ function compilePattern(source) {
202
+ try {
203
+ return new RegExp(source, 'u');
204
+ }
205
+ catch (_e) {
206
+ try {
207
+ return new RegExp(source);
208
+ }
209
+ catch (_e2) {
210
+ return null;
211
+ }
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Compose a list of node steps into one function, skipping the empty case.
217
+ * @param {Function[]} steps - The steps to run in order
218
+ * @returns {Function|null} The composed step, or null when there is none
219
+ */
220
+ function composeSteps(steps) {
221
+ if (steps.length === 0) return null;
222
+ if (steps.length === 1) return steps[0];
223
+ return function runSteps(value) {
224
+ let out = value;
225
+ for (let i = 0; i < steps.length; i++)
226
+ out = steps[i](out);
227
+ return out;
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Build the object step: strip unknown members, normalize known ones, then
233
+ * materialize defaults. That order is what makes the result stable - a
234
+ * default is never stripped, and a stripped member never gets normalized.
235
+ * @param {object} node - The schema node
236
+ * @param {object} ctx - The compile context
237
+ * @returns {Function|null} The step, or null when this node needs none
238
+ */
239
+ function buildObjectStep(node, ctx) {
240
+ const options = ctx.options;
241
+ const properties = isJsonObject(node.properties) ? node.properties : null;
242
+ const patternProperties = isJsonObject(node.patternProperties) ? node.patternProperties : null;
243
+ const additional = node.additionalProperties;
244
+
245
+ // Every declared name is recorded, including one whose subschema needs no
246
+ // work: "declared but unchanged" and "not declared at all" are the same
247
+ // lookup result otherwise, and stripping would delete a known member.
248
+ // A null value means declared, nothing to do.
249
+ const propertySteps = new Map();
250
+ const defaults = [];
251
+ let hasStep = false;
252
+ if (properties !== null) {
253
+ const keys = Object.getOwnPropertyNames(properties);
254
+ for (let i = 0; i < keys.length; i++) {
255
+ const key = keys[i];
256
+ const sub = properties[key];
257
+ const step = compileNode(sub, ctx);
258
+ propertySteps.set(key, step);
259
+ if (step !== null) hasStep = true;
260
+ // The step is stored with the default so a materialized container is
261
+ // normalized by the same schema an explicitly supplied one would be;
262
+ // otherwise a defaulted `{ port: '8080' }` keeps its string where a
263
+ // provided one is coerced.
264
+ if (isJsonObject(sub) && sub.default !== undefined
265
+ && resolveNormalizeSwitch(options.useDefaults, sub))
266
+ defaults.push(key, sub.default, step);
267
+ }
268
+ }
269
+
270
+ // Same for patterns: a pattern that matches makes a member known, whether
271
+ // or not its subschema normalizes anything.
272
+ const patternSteps = [];
273
+ if (patternProperties !== null) {
274
+ const sources = Object.getOwnPropertyNames(patternProperties);
275
+ for (let i = 0; i < sources.length; i++) {
276
+ const regexp = compilePattern(sources[i]);
277
+ if (regexp === null) continue;
278
+ const step = compileNode(patternProperties[sources[i]], ctx);
279
+ patternSteps.push(regexp, step);
280
+ if (step !== null) hasStep = true;
281
+ }
282
+ }
283
+
284
+ const additionalStep = additional === undefined || typeof additional === 'boolean'
285
+ ? null
286
+ : compileNode(additional, ctx);
287
+
288
+ // `true` strips only where the schema says the members are forbidden;
289
+ // 'all' additionally strips members a declared shape simply does not
290
+ // mention. Neither strips an object whose shape is undeclared - there,
291
+ // every member is legitimately "additional".
292
+ const declaresShape = properties !== null || patternProperties !== null;
293
+ const strip = ctx.allowStrip && options.removeAdditional !== false
294
+ && (additional === false || (options.removeAdditional === 'all' && declaresShape));
295
+
296
+ const defaultCount = defaults.length;
297
+ const patternCount = patternSteps.length;
298
+ if (!hasStep && additionalStep === null && !strip && defaultCount === 0)
299
+ return null;
300
+
301
+ return function normalizeObject(value) {
302
+ if (!isJsonObject(value)) return value;
303
+ let out = value;
304
+ const keys = Object.keys(value);
305
+ for (let i = 0; i < keys.length; i++) {
306
+ const key = keys[i];
307
+ const current = value[key];
308
+ let next = current;
309
+ // JSON Schema applies EVERY applicable subschema to a member, so a
310
+ // member covered by `properties` and by one or more `patternProperties`
311
+ // is normalized by all of them in turn. `additionalProperties` applies
312
+ // only when nothing else did.
313
+ // undefined = not declared; null = declared with nothing to do.
314
+ const named = propertySteps.get(key);
315
+ let covered = named !== undefined;
316
+ if (named != null) next = named(next);
317
+ for (let p = 0; p < patternCount; p += 2) {
318
+ if (!patternSteps[p].test(key)) continue;
319
+ covered = true;
320
+ const patternStep = patternSteps[p + 1];
321
+ if (patternStep !== null) next = patternStep(next);
322
+ }
323
+ if (!covered) {
324
+ if (strip) {
325
+ if (out === value) out = { ...value };
326
+ delete out[key];
327
+ continue;
328
+ }
329
+ if (additionalStep !== null) next = additionalStep(next);
330
+ }
331
+ if (next !== current) {
332
+ if (out === value) out = { ...value };
333
+ setObjectMember(out, key, next);
334
+ }
335
+ }
336
+ for (let i = 0; i < defaultCount; i += 3) {
337
+ const key = defaults[i];
338
+ if (hasOwn(value, key)) continue;
339
+ if (out === value) out = { ...value };
340
+ // Each instance gets its own copy: a container default shared across
341
+ // normalized documents would let a mutation of one leak into all. The
342
+ // copy then runs through the property's own step, so a materialized
343
+ // default is shaped exactly like a supplied value.
344
+ const step = defaults[i + 2];
345
+ const materialized = cloneJson(defaults[i + 1]);
346
+ setObjectMember(out, key, step === null ? materialized : step(materialized));
347
+ }
348
+ return out;
349
+ };
350
+ }
351
+
352
+ /**
353
+ * Build the array step. Both tuple spellings are handled: draft 2020-12's
354
+ * `prefixItems` + `items`, and draft-07's array-valued `items` +
355
+ * `additionalItems`.
356
+ * @param {object} node - The schema node
357
+ * @param {object} ctx - The compile context
358
+ * @returns {Function|null} The step, or null when this node needs none
359
+ */
360
+ function buildArrayStep(node, ctx) {
361
+ const itemsIsTuple = Array.isArray(node.items);
362
+ const prefixSource = itemsIsTuple ? node.items : node.prefixItems;
363
+ const restSource = itemsIsTuple ? node.additionalItems : node.items;
364
+
365
+ let prefixSteps = null;
366
+ if (Array.isArray(prefixSource)) {
367
+ prefixSteps = new Array(prefixSource.length);
368
+ let used = false;
369
+ for (let i = 0; i < prefixSource.length; i++) {
370
+ prefixSteps[i] = compileNode(prefixSource[i], ctx);
371
+ if (prefixSteps[i] !== null) used = true;
372
+ }
373
+ if (!used) prefixSteps = null;
374
+ }
375
+
376
+ const restStep = restSource === undefined || typeof restSource === 'boolean'
377
+ ? null
378
+ : compileNode(restSource, ctx);
379
+
380
+ if (prefixSteps === null && restStep === null) return null;
381
+ const prefixLength = prefixSteps === null ? 0 : prefixSteps.length;
382
+
383
+ return function normalizeArray(value) {
384
+ if (!Array.isArray(value)) return value;
385
+ let out = value;
386
+ for (let i = 0; i < value.length; i++) {
387
+ const step = i < prefixLength ? prefixSteps[i] : restStep;
388
+ if (step === null) continue;
389
+ const current = value[i];
390
+ const next = step(current);
391
+ if (next !== current) {
392
+ if (out === value) out = value.slice();
393
+ out[i] = next;
394
+ }
395
+ }
396
+ return out;
397
+ };
398
+ }
399
+
400
+ /**
401
+ * Build the scalar step: trim, then coerce. Trimming runs first so that a
402
+ * padded transport value (`' 42 '` for a numeric field) reaches coercion in
403
+ * the shape the grammar accepts.
404
+ *
405
+ * Trimming applies to every string the walk reaches, not only to nodes typed
406
+ * `string`, because the value that needs trimming is frequently the one
407
+ * declared as a number.
408
+ * @param {object} node - The schema node
409
+ * @param {object} ctx - The compile context
410
+ * @returns {Function|null} The step, or null when this node needs none
411
+ */
412
+ function buildScalarStep(node, ctx) {
413
+ const options = ctx.options;
414
+ const trim = resolveNormalizeSwitch(options.trimStrings, node);
415
+ // A union `type` gives no single conversion target, so coercion is skipped
416
+ // rather than guessed.
417
+ const coerceTo = resolveNormalizeSwitch(options.coerceTypes, node) && typeof node.type === 'string'
418
+ ? node.type
419
+ : null;
420
+ if (!trim && coerceTo === null) return null;
421
+
422
+ if (coerceTo === null) {
423
+ return function normalizeTrim(value) {
424
+ return typeof value === 'string' ? value.trim() : value;
425
+ };
426
+ }
427
+ if (!trim) {
428
+ return function normalizeCoerce(value) {
429
+ return coerceToType(value, coerceTo);
430
+ };
431
+ }
432
+ return function normalizeTrimCoerce(value) {
433
+ return coerceToType(typeof value === 'string' ? value.trim() : value, coerceTo);
434
+ };
435
+ }
436
+
437
+ /**
438
+ * Compile one schema node into a normalizer step, or null when the node
439
+ * cannot change anything. Recursive schemas are handled by publishing a
440
+ * deferring placeholder into the memo before the node is built, so a `$ref`
441
+ * back to an ancestor resolves to a function that is complete by the time it
442
+ * is called.
443
+ * @param {object|boolean} node - The schema node
444
+ * @param {object} ctx - The compile context
445
+ * @returns {Function|null} The step, or null
446
+ */
447
+ function compileNode(node, ctx) {
448
+ if (!isJsonObject(node)) return null;
449
+
450
+ const memo = ctx.allowStrip ? ctx.memoStrip : ctx.memoNoStrip;
451
+ const cached = memo.get(node);
452
+ if (cached !== undefined) return cached;
453
+
454
+ let built = null;
455
+ const deferred = function normalizeDeferred(value) {
456
+ return built === null ? value : built(value);
457
+ };
458
+ memo.set(node, deferred);
459
+
460
+ const steps = [];
461
+
462
+ if (typeof node.$ref === 'string') {
463
+ const target = resolveSameDocumentRef(node.$ref, ctx.root, ctx.anchors);
464
+ // A ref this module cannot follow is left alone rather than guessed at;
465
+ // validation still resolves it through the full ref machinery.
466
+ if (target !== undefined && target !== node) {
467
+ const step = compileNode(target, ctx);
468
+ if (step !== null) steps.push(step);
469
+ }
470
+ }
471
+
472
+ // `allOf` branches all apply, so they compose. Stripping is disabled for
473
+ // the whole subtree underneath: a branch declares only its own share of
474
+ // the object, so what looks "additional" to it may be exactly what a
475
+ // sibling branch declares. Composition-shaped schemas therefore keep their
476
+ // members; only defaults, coercion and trimming flow through.
477
+ if (Array.isArray(node.allOf)) {
478
+ const branchCtx = ctx.allowStrip ? { ...ctx, allowStrip: false } : ctx;
479
+ for (let i = 0; i < node.allOf.length; i++) {
480
+ const step = compileNode(node.allOf[i], branchCtx);
481
+ if (step !== null) steps.push(step);
482
+ }
483
+ }
484
+
485
+ const objectStep = buildObjectStep(node, ctx);
486
+ if (objectStep !== null) steps.push(objectStep);
487
+
488
+ const arrayStep = buildArrayStep(node, ctx);
489
+ if (arrayStep !== null) steps.push(arrayStep);
490
+
491
+ const scalarStep = buildScalarStep(node, ctx);
492
+ if (scalarStep !== null) steps.push(scalarStep);
493
+
494
+ built = composeSteps(steps);
495
+ memo.set(node, built);
496
+ // Callers that resolved this node while it was being built hold `deferred`;
497
+ // everyone else gets the direct function.
498
+ return built === null ? null : deferred;
499
+ }
500
+
501
+ /**
502
+ * Compile a JSON Schema into a normalizer: a function that returns a
503
+ * normalized copy of its input, leaving the input untouched.
504
+ *
505
+ * Validation is unaffected and unchanged - normalize first, then hand the
506
+ * result to a compiled validator:
507
+ *
508
+ * ```javascript
509
+ * const normalize = compileNormalizer(schema, { useDefaults: true, trimStrings: true });
510
+ * const validate = new JarenValidator({ collectErrors: true }).compile(schema);
511
+ * const shaped = normalize(input);
512
+ * const result = validate(shaped);
513
+ * ```
514
+ *
515
+ * **What is normalized.** `properties`, `patternProperties`,
516
+ * `additionalProperties`, `items`/`prefixItems`/`additionalItems`, same-document
517
+ * `$ref` (`#`, `#/pointer` and plain `#anchor` forms), and `allOf` (composed,
518
+ * with stripping disabled inside it).
519
+ *
520
+ * **What is not, and why.** `anyOf`, `oneOf`, `if`/`then`/`else` and `not`
521
+ * are not descended: which branch applies is only known after validating,
522
+ * and normalizing under a branch can change which branch validates. Nothing
523
+ * arbitrary runs either - there is no transform hook, because an arbitrary
524
+ * transform is application code, not schema semantics, and belongs on the
525
+ * caller's side of the boundary.
526
+ * @template [In=unknown]
527
+ * @template [Out=In]
528
+ * @param {object|boolean} schema - The schema to compile
529
+ * @param {NormalizeOptions} [options] - Which normalizations to apply
530
+ * @returns {Normalizer<In, Out>} The compiled normalizer
531
+ * @example
532
+ * const normalize = compileNormalizer({
533
+ * type: 'object',
534
+ * properties: {
535
+ * name: { type: 'string' },
536
+ * port: { type: 'integer', default: 8080 },
537
+ * },
538
+ * additionalProperties: false,
539
+ * }, { useDefaults: true, removeAdditional: true, coerceTypes: true, trimStrings: true });
540
+ *
541
+ * const input = { name: ' jaren ', port: '9000', stray: 1 };
542
+ * normalize(input); // { name: 'jaren', port: 9000 }
543
+ * input; // { name: ' jaren ', port: '9000', stray: 1 } - untouched
544
+ */
545
+ export function compileNormalizer(schema, options = {}) {
546
+ const resolved = {
547
+ useDefaults: options.useDefaults,
548
+ removeAdditional: options.removeAdditional === 'all'
549
+ ? 'all'
550
+ : options.removeAdditional === true,
551
+ coerceTypes: options.coerceTypes,
552
+ trimStrings: options.trimStrings,
553
+ };
554
+
555
+ const ctx = {
556
+ options: resolved,
557
+ root: schema,
558
+ anchors: collectSameDocumentAnchors(schema),
559
+ memoStrip: new Map(),
560
+ memoNoStrip: new Map(),
561
+ allowStrip: true,
562
+ };
563
+
564
+ const step = compileNode(schema, ctx);
565
+
566
+ // A root `default` answers the "the whole document was absent" case, which
567
+ // no member walk can reach.
568
+ const rootDefault = isJsonObject(schema) && schema.default !== undefined
569
+ && resolveNormalizeSwitch(resolved.useDefaults, schema)
570
+ ? schema.default
571
+ : undefined;
572
+
573
+ if (step === null && rootDefault === undefined)
574
+ return function normalizeIdentity(data) { return data; };
575
+
576
+ if (rootDefault === undefined) {
577
+ return function normalize(data) { return step(data); };
578
+ }
579
+ return function normalizeWithRootDefault(data) {
580
+ if (data === undefined) return cloneJson(rootDefault);
581
+ return step === null ? data : step(data);
582
+ };
583
+ }
584
+
585
+ //#endregion