@bluprynt/forms-core 1.0.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/dist/index.mjs ADDED
@@ -0,0 +1,2321 @@
1
+ import Ajv2020 from "ajv/dist/2020";
2
+ //#region src/date-utils.ts
3
+ const RELATIVE_DATE_RE = /^([+-])(\d+)([dwmy])$/;
4
+ /**
5
+ * Type guard that checks whether a value is a relative date expression.
6
+ *
7
+ * Relative date expressions follow the pattern `[+-]<amount><unit>` where
8
+ * `unit` is one of `d` (days), `w` (weeks), `m` (months), or `y` (years).
9
+ *
10
+ * @param value - The value to test.
11
+ * @returns `true` if `value` is a string matching the relative date pattern.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * isRelativeDate("+7d") // true (7 days from now)
16
+ * isRelativeDate("-1m") // true (1 month ago)
17
+ * isRelativeDate("2024-01-01") // false (absolute date)
18
+ * isRelativeDate(42) // false (not a string)
19
+ * ```
20
+ */
21
+ const isRelativeDate = (value) => typeof value === "string" && RELATIVE_DATE_RE.test(value);
22
+ /**
23
+ * Resolves a relative date expression into an absolute ISO-8601 date string.
24
+ *
25
+ * Supported units:
26
+ * - `d` -- days
27
+ * - `w` -- weeks (7 days)
28
+ * - `m` -- months
29
+ * - `y` -- years
30
+ *
31
+ * Arithmetic is performed in UTC. If the input does not match the relative
32
+ * date pattern, it is returned unchanged.
33
+ *
34
+ * @param relative - A relative date expression (e.g. `"+7d"`, `"-3m"`).
35
+ * @param now - Reference date for the calculation. Defaults to `new Date()`.
36
+ * @returns An ISO-8601 date-time string, or the original string if it is not
37
+ * a valid relative expression.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * const base = new Date("2024-06-15T00:00:00Z");
42
+ * resolveRelativeDate("+7d", base) // "2024-06-22T00:00:00.000Z"
43
+ * resolveRelativeDate("-1m", base) // "2024-05-15T00:00:00.000Z"
44
+ * resolveRelativeDate("+1y", base) // "2025-06-15T00:00:00.000Z"
45
+ * ```
46
+ */
47
+ const resolveRelativeDate = (relative, now) => {
48
+ const match = RELATIVE_DATE_RE.exec(relative);
49
+ if (!match) return relative;
50
+ const sign = match[1] === "+" ? 1 : -1;
51
+ const amount = Number(match[2]) * sign;
52
+ const unit = match[3];
53
+ const result = new Date(now.getTime());
54
+ switch (unit) {
55
+ case "d":
56
+ result.setUTCDate(result.getUTCDate() + amount);
57
+ break;
58
+ case "w":
59
+ result.setUTCDate(result.getUTCDate() + amount * 7);
60
+ break;
61
+ case "m":
62
+ result.setUTCMonth(result.getUTCMonth() + amount);
63
+ break;
64
+ case "y":
65
+ result.setUTCFullYear(result.getUTCFullYear() + amount);
66
+ break;
67
+ }
68
+ return result.toISOString();
69
+ };
70
+ //#endregion
71
+ //#region src/condition-evaluator.ts
72
+ /**
73
+ * Evaluates condition trees against form state.
74
+ *
75
+ * Supports three kinds of conditions:
76
+ * - **Simple** ({@link SimpleCondition}): compares a single field's value
77
+ * using one of the supported operators (`set`, `notset`, `eq`, `ne`, `lt`,
78
+ * `gt`, `lte`, `gte`, `in`, `notin`).
79
+ * - **Compound AND**: `{ and: [...] }` -- all child conditions must be true.
80
+ * - **Compound OR**: `{ or: [...] }` -- at least one child condition must be true.
81
+ *
82
+ * **Hidden-field rule**: when a `visibilityMap` is provided and the
83
+ * referenced field is hidden (`false`), the condition evaluates as if the
84
+ * field has no value. This means `notset` returns `true` and all other
85
+ * operators return `false`.
86
+ *
87
+ * **Date handling**: condition values that are relative date expressions
88
+ * (e.g. `"+7d"`) are resolved against `ctx.now` before comparison.
89
+ */
90
+ var ConditionEvaluator = class {
91
+ /**
92
+ * Evaluates a condition tree against the current form state.
93
+ *
94
+ * @param condition - The condition to evaluate (simple or compound).
95
+ * @param ctx - Evaluation context containing form values and optional
96
+ * visibility/date overrides.
97
+ * @returns `true` if the condition is satisfied, `false` otherwise.
98
+ */
99
+ evalCondition(condition, ctx) {
100
+ if ("and" in condition) return condition.and.every((c) => this.evalCondition(c, ctx));
101
+ if ("or" in condition) return condition.or.some((c) => this.evalCondition(c, ctx));
102
+ return this.evalSimple(condition, ctx);
103
+ }
104
+ evalSimple(cond, ctx) {
105
+ if (ctx.visibilityMap && ctx.visibilityMap.get(cond.field) === false) return cond.op === "notset";
106
+ const fieldValue = ctx.values[String(cond.field)];
107
+ switch (cond.op) {
108
+ case "set": return fieldValue !== null && fieldValue !== void 0 && fieldValue !== "";
109
+ case "notset": return fieldValue === null || fieldValue === void 0 || fieldValue === "";
110
+ case "eq": return fieldValue === this.resolveIfDate(cond.value, ctx.now);
111
+ case "ne": return fieldValue !== this.resolveIfDate(cond.value, ctx.now);
112
+ case "lt": return this.compareTo(fieldValue, cond.value, ctx.now) < 0;
113
+ case "gt": return this.compareTo(fieldValue, cond.value, ctx.now) > 0;
114
+ case "lte": return this.compareTo(fieldValue, cond.value, ctx.now) <= 0;
115
+ case "gte": return this.compareTo(fieldValue, cond.value, ctx.now) >= 0;
116
+ case "in": return Array.isArray(cond.value) && cond.value.includes(fieldValue);
117
+ case "notin": return Array.isArray(cond.value) && !cond.value.includes(fieldValue);
118
+ default: return false;
119
+ }
120
+ }
121
+ resolveIfDate(value, now) {
122
+ if (isRelativeDate(value)) return resolveRelativeDate(value, now);
123
+ return value;
124
+ }
125
+ compareTo(a, b, now) {
126
+ const resolvedB = this.resolveIfDate(b, now);
127
+ if (typeof a === "number" && typeof resolvedB === "number") return a - resolvedB;
128
+ if (typeof a === "string" && typeof resolvedB === "string") {
129
+ const ta = Date.parse(a);
130
+ const tb = Date.parse(resolvedB);
131
+ if (!Number.isNaN(ta) && !Number.isNaN(tb)) return ta - tb;
132
+ if (a < resolvedB) return -1;
133
+ if (a > resolvedB) return 1;
134
+ return 0;
135
+ }
136
+ return NaN;
137
+ }
138
+ };
139
+ //#endregion
140
+ //#region src/dependency-graph.ts
141
+ const DfsVisitState = {
142
+ Unvisited: 0,
143
+ InProgress: 1,
144
+ Completed: 2
145
+ };
146
+ /**
147
+ * Manages the condition dependency graph for form fields.
148
+ *
149
+ * Built from the field registry during engine preparation. Provides:
150
+ * - Forward dependency graph (`graph`): answers "if field X changes, which
151
+ * items need to re-evaluate their visibility?"
152
+ * - Topological ordering (`topologicalOrder`): guarantees that when computing
153
+ * visibility, every item is evaluated after the fields it depends on.
154
+ * - Affected-ids lookup (`getAffectedIds`): returns all transitively
155
+ * affected item ids when a field value changes (lazily cached).
156
+ *
157
+ * Static methods (`extractFieldRefs`, `detectCycle`) can be used before
158
+ * constructing an instance, e.g. during semantic validation.
159
+ */
160
+ var DependencyGraph = class DependencyGraph {
161
+ /**
162
+ * Forward adjacencyMap map: key is a field id, value is the set of item ids
163
+ * whose conditions reference that field.
164
+ */
165
+ graph;
166
+ /**
167
+ * Item ids in topological order. Dependencies come before dependents.
168
+ */
169
+ topologicalOrder;
170
+ registry;
171
+ affectedCache = /* @__PURE__ */ new Map();
172
+ /**
173
+ * @param registry - The engine's field registry (built during preparation).
174
+ */
175
+ constructor(registry) {
176
+ this.registry = registry;
177
+ this.graph = this.buildGraph();
178
+ this.topologicalOrder = this.buildTopologicalOrder();
179
+ }
180
+ /**
181
+ * Extracts the set of field ids referenced by a condition tree.
182
+ *
183
+ * Recursively walks compound conditions (`and`/`or`) and collects the
184
+ * `field` property from every leaf {@link SimpleCondition}.
185
+ *
186
+ * @param condition - A simple or compound condition.
187
+ * @returns Set of all unique field ids that appear in the condition.
188
+ */
189
+ static extractFieldRefs(condition) {
190
+ const refs = /* @__PURE__ */ new Set();
191
+ DependencyGraph.collectRefs(condition, refs);
192
+ return refs;
193
+ }
194
+ /**
195
+ * Detects circular dependencies in the condition graph.
196
+ *
197
+ * Uses DFS-based cycle detection (white/gray/black coloring). If a cycle
198
+ * is found, the function reconstructs and returns a human-readable path
199
+ * string (e.g. `"1 -> 2 -> 3 -> 1"`).
200
+ *
201
+ * @param registry - The engine's field registry.
202
+ * @returns An array of field ids forming the cycle, or `undefined` if no cycle exists.
203
+ */
204
+ static detectCycle(registry) {
205
+ const allIds = new Set(registry.keys());
206
+ const adjacencyMap = /* @__PURE__ */ new Map();
207
+ for (const [id, entry] of registry) {
208
+ if (!entry.condition) continue;
209
+ const refs = DependencyGraph.extractFieldRefs(entry.condition);
210
+ for (const ref of refs) {
211
+ if (!allIds.has(ref)) continue;
212
+ let targets = adjacencyMap.get(ref);
213
+ if (!targets) {
214
+ targets = /* @__PURE__ */ new Set();
215
+ adjacencyMap.set(ref, targets);
216
+ }
217
+ targets.add(id);
218
+ }
219
+ }
220
+ const nodes = /* @__PURE__ */ new Map();
221
+ const parent = /* @__PURE__ */ new Map();
222
+ for (const id of allIds) nodes.set(id, DfsVisitState.Unvisited);
223
+ for (const id of allIds) if (nodes.get(id) === DfsVisitState.Unvisited) {
224
+ const cyclePath = DependencyGraph.dfs(id, adjacencyMap, nodes, parent, allIds);
225
+ if (cyclePath) return cyclePath;
226
+ }
227
+ }
228
+ /**
229
+ * Returns the set of item ids whose visibility could change when the given
230
+ * field's value changes.
231
+ *
232
+ * Performs a transitive expansion of the forward dependency graph starting
233
+ * from the field's direct dependents. Results are memoized for subsequent
234
+ * calls with the same `fieldId`.
235
+ *
236
+ * @param fieldId - Id of the field whose value changed.
237
+ * @returns Set of all transitively affected item ids. Empty set if no items
238
+ * depend on `fieldId`.
239
+ */
240
+ getAffectedIds(fieldId) {
241
+ const cached = this.affectedCache.get(fieldId);
242
+ if (cached) return cached;
243
+ const directDeps = this.graph.get(fieldId);
244
+ if (!directDeps || directDeps.size === 0) {
245
+ const empty = /* @__PURE__ */ new Set();
246
+ this.affectedCache.set(fieldId, empty);
247
+ return empty;
248
+ }
249
+ const expanded = this.expandTransitiveDependencies(directDeps);
250
+ this.affectedCache.set(fieldId, expanded);
251
+ return expanded;
252
+ }
253
+ static collectRefs(condition, refs) {
254
+ if ("and" in condition) for (const c of condition.and) DependencyGraph.collectRefs(c, refs);
255
+ else if ("or" in condition) for (const c of condition.or) DependencyGraph.collectRefs(c, refs);
256
+ else refs.add(condition.field);
257
+ }
258
+ static dfs(node, adjacencyMap, nodes, parent, allIds) {
259
+ nodes.set(node, DfsVisitState.InProgress);
260
+ const neighbors = adjacencyMap.get(node);
261
+ if (neighbors) for (const next of neighbors) {
262
+ if (!allIds.has(next)) continue;
263
+ if (nodes.get(next) === DfsVisitState.InProgress) return DependencyGraph.reconstructCycle(next, node, parent);
264
+ if (nodes.get(next) === DfsVisitState.Completed) continue;
265
+ parent.set(next, node);
266
+ const cycle = DependencyGraph.dfs(next, adjacencyMap, nodes, parent, allIds);
267
+ if (cycle) return cycle;
268
+ }
269
+ nodes.set(node, DfsVisitState.Completed);
270
+ }
271
+ static reconstructCycle(cycleStart, cycleEnd, parent) {
272
+ const path = [cycleStart];
273
+ let current = cycleEnd;
274
+ while (current !== cycleStart) {
275
+ path.push(current);
276
+ const next = parent.get(current);
277
+ if (next === void 0) break;
278
+ current = next;
279
+ }
280
+ path.push(cycleStart);
281
+ return path.reverse();
282
+ }
283
+ /**
284
+ * Builds the forward dependency graph from the registry.
285
+ */
286
+ buildGraph() {
287
+ const graph = /* @__PURE__ */ new Map();
288
+ for (const [id, entry] of this.registry) {
289
+ if (!entry.condition) continue;
290
+ const refs = DependencyGraph.extractFieldRefs(entry.condition);
291
+ for (const ref of refs) {
292
+ let deps = graph.get(ref);
293
+ if (!deps) {
294
+ deps = /* @__PURE__ */ new Set();
295
+ graph.set(ref, deps);
296
+ }
297
+ deps.add(id);
298
+ }
299
+ }
300
+ return graph;
301
+ }
302
+ /**
303
+ * Produces a topological ordering using Kahn's algorithm.
304
+ */
305
+ buildTopologicalOrder() {
306
+ const allIds = new Set(this.registry.keys());
307
+ const inDegree = /* @__PURE__ */ new Map();
308
+ const adjacencyMap = /* @__PURE__ */ new Map();
309
+ for (const id of allIds) inDegree.set(id, 0);
310
+ for (const [id, entry] of this.registry) {
311
+ if (!entry.condition) continue;
312
+ const refs = DependencyGraph.extractFieldRefs(entry.condition);
313
+ for (const ref of refs) {
314
+ if (!allIds.has(ref)) continue;
315
+ let targets = adjacencyMap.get(ref);
316
+ if (!targets) {
317
+ targets = /* @__PURE__ */ new Set();
318
+ adjacencyMap.set(ref, targets);
319
+ }
320
+ if (!targets.has(id)) {
321
+ targets.add(id);
322
+ inDegree.set(id, (inDegree.get(id) ?? 0) + 1);
323
+ }
324
+ }
325
+ }
326
+ for (const [id, entry] of this.registry) {
327
+ if (entry.parentId === void 0) continue;
328
+ if (!allIds.has(entry.parentId)) continue;
329
+ let targets = adjacencyMap.get(entry.parentId);
330
+ if (!targets) {
331
+ targets = /* @__PURE__ */ new Set();
332
+ adjacencyMap.set(entry.parentId, targets);
333
+ }
334
+ if (!targets.has(id)) {
335
+ targets.add(id);
336
+ inDegree.set(id, (inDegree.get(id) ?? 0) + 1);
337
+ }
338
+ }
339
+ const queue = [];
340
+ for (const [id, deg] of inDegree) if (deg === 0) queue.push(id);
341
+ const sorted = [];
342
+ while (queue.length > 0) {
343
+ const current = queue.shift();
344
+ if (current === void 0) break;
345
+ sorted.push(current);
346
+ const targets = adjacencyMap.get(current);
347
+ if (targets) for (const target of targets) {
348
+ const newDeg = (inDegree.get(target) ?? 1) - 1;
349
+ inDegree.set(target, newDeg);
350
+ if (newDeg === 0) queue.push(target);
351
+ }
352
+ }
353
+ if (sorted.length !== allIds.size) {
354
+ const cyclePath = [...allIds].filter((id) => !sorted.includes(id)).join(" -> ");
355
+ return sorted.length === 0 ? [] : (() => {
356
+ throw cyclePath;
357
+ })();
358
+ }
359
+ return sorted;
360
+ }
361
+ /**
362
+ * Expands a set of item ids to include all transitive dependents via BFS.
363
+ */
364
+ expandTransitiveDependencies(startIds) {
365
+ const result = /* @__PURE__ */ new Set();
366
+ const queue = [...startIds];
367
+ while (queue.length > 0) {
368
+ const id = queue.shift();
369
+ if (id === void 0) break;
370
+ if (result.has(id)) continue;
371
+ result.add(id);
372
+ const deps = this.graph.get(id);
373
+ if (deps) {
374
+ for (const dep of deps) if (!result.has(dep)) queue.push(dep);
375
+ }
376
+ }
377
+ return result;
378
+ }
379
+ };
380
+ //#endregion
381
+ //#region src/validators/array-validator.ts
382
+ var ArrayValidator = class {
383
+ validate(ctx) {
384
+ const { fieldId, value, now } = ctx;
385
+ const { item, validateField } = ctx;
386
+ const validation = ctx.validation;
387
+ const errors = [];
388
+ if (value === null || value === void 0) return errors;
389
+ if (!Array.isArray(value)) {
390
+ errors.push({
391
+ fieldId,
392
+ rule: "TYPE",
393
+ message: "Must be an array",
394
+ params: { expectedType: "array" }
395
+ });
396
+ return errors;
397
+ }
398
+ if (validation?.minItems !== void 0 && value.length < validation.minItems) errors.push({
399
+ fieldId,
400
+ rule: "MIN_ITEMS",
401
+ message: `Must have at least ${validation.minItems} items`,
402
+ params: {
403
+ minItems: validation.minItems,
404
+ actual: value.length
405
+ }
406
+ });
407
+ if (validation?.maxItems !== void 0 && value.length > validation.maxItems) errors.push({
408
+ fieldId,
409
+ rule: "MAX_ITEMS",
410
+ message: `Must have at most ${validation.maxItems} items`,
411
+ params: {
412
+ maxItems: validation.maxItems,
413
+ actual: value.length
414
+ }
415
+ });
416
+ if (item) for (let i = 0; i < value.length; i++) {
417
+ const fakeEntry = {
418
+ id: fieldId,
419
+ type: item.type,
420
+ condition: void 0,
421
+ validation: item.validation,
422
+ parentId: void 0,
423
+ options: item.options,
424
+ item: void 0,
425
+ label: item.label,
426
+ title: void 0
427
+ };
428
+ const itemErrors = validateField(fieldId, value[i], fakeEntry, now);
429
+ errors.push(...itemErrors.map((err) => ({
430
+ ...err,
431
+ itemIndex: i
432
+ })));
433
+ }
434
+ return errors;
435
+ }
436
+ };
437
+ //#endregion
438
+ //#region src/validators/boolean-validator.ts
439
+ var BooleanValidator = class {
440
+ validate(ctx) {
441
+ const { fieldId, value } = ctx;
442
+ const validation = ctx.validation;
443
+ const errors = [];
444
+ if (validation?.required && value !== true && value !== false) errors.push({
445
+ fieldId,
446
+ rule: "REQUIRED",
447
+ message: "Value is required"
448
+ });
449
+ return errors;
450
+ }
451
+ };
452
+ //#endregion
453
+ //#region src/validators/date-validator.ts
454
+ var DateValidator = class {
455
+ validate(ctx) {
456
+ const { fieldId, value, now } = ctx;
457
+ const validation = ctx.validation;
458
+ const errors = [];
459
+ const isEmpty = value === null || value === void 0 || value === "";
460
+ if (validation?.required && isEmpty) {
461
+ errors.push({
462
+ fieldId,
463
+ rule: "REQUIRED",
464
+ message: "Value is required"
465
+ });
466
+ return errors;
467
+ }
468
+ if (isEmpty) return errors;
469
+ if (typeof value !== "string") {
470
+ errors.push({
471
+ fieldId,
472
+ rule: "TYPE",
473
+ message: "Must be a valid date",
474
+ params: { expectedType: "date" }
475
+ });
476
+ return errors;
477
+ }
478
+ const timestamp = Date.parse(value);
479
+ if (Number.isNaN(timestamp)) {
480
+ errors.push({
481
+ fieldId,
482
+ rule: "INVALID_DATE",
483
+ message: "Must be a valid date"
484
+ });
485
+ return errors;
486
+ }
487
+ if (validation?.minDate !== void 0) {
488
+ const minResolved = isRelativeDate(validation.minDate) ? resolveRelativeDate(validation.minDate, now) : validation.minDate;
489
+ if (timestamp < Date.parse(minResolved)) errors.push({
490
+ fieldId,
491
+ rule: "MIN_DATE",
492
+ message: `Must be on or after ${minResolved}`,
493
+ params: { minDate: minResolved }
494
+ });
495
+ }
496
+ if (validation?.maxDate !== void 0) {
497
+ const maxResolved = isRelativeDate(validation.maxDate) ? resolveRelativeDate(validation.maxDate, now) : validation.maxDate;
498
+ if (timestamp > Date.parse(maxResolved)) errors.push({
499
+ fieldId,
500
+ rule: "MAX_DATE",
501
+ message: `Must be on or before ${maxResolved}`,
502
+ params: { maxDate: maxResolved }
503
+ });
504
+ }
505
+ return errors;
506
+ }
507
+ };
508
+ //#endregion
509
+ //#region src/validators/file-validator.ts
510
+ var FileValidator = class {
511
+ validate(ctx) {
512
+ const { fieldId, value } = ctx;
513
+ const validation = ctx.validation;
514
+ const errors = [];
515
+ const isEmpty = value === null || value === void 0;
516
+ if (validation?.required && isEmpty) {
517
+ errors.push({
518
+ fieldId,
519
+ rule: "REQUIRED",
520
+ message: "Value is required"
521
+ });
522
+ return errors;
523
+ }
524
+ if (isEmpty) return errors;
525
+ if (typeof value !== "object" || typeof value.name !== "string" || typeof value.mimeType !== "string" || typeof value.size !== "number" || typeof value.url !== "string") errors.push({
526
+ fieldId,
527
+ rule: "TYPE",
528
+ message: "Must be a valid file object",
529
+ params: { expectedType: "file" }
530
+ });
531
+ return errors;
532
+ }
533
+ };
534
+ //#endregion
535
+ //#region src/validators/number-validator.ts
536
+ var NumberValidator = class {
537
+ validate(ctx) {
538
+ const { fieldId, value } = ctx;
539
+ const validation = ctx.validation;
540
+ const errors = [];
541
+ const isEmpty = value === null || value === void 0;
542
+ if (validation?.required && isEmpty) {
543
+ errors.push({
544
+ fieldId,
545
+ rule: "REQUIRED",
546
+ message: "Value is required"
547
+ });
548
+ return errors;
549
+ }
550
+ if (isEmpty) return errors;
551
+ if (typeof value !== "number") {
552
+ errors.push({
553
+ fieldId,
554
+ rule: "TYPE",
555
+ message: "Must be a number",
556
+ params: { expectedType: "number" }
557
+ });
558
+ return errors;
559
+ }
560
+ if (validation?.min !== void 0 && value < validation.min) errors.push({
561
+ fieldId,
562
+ rule: "MIN",
563
+ message: `Must be at least ${validation.min}`,
564
+ params: {
565
+ min: validation.min,
566
+ actual: value
567
+ }
568
+ });
569
+ if (validation?.max !== void 0 && value > validation.max) errors.push({
570
+ fieldId,
571
+ rule: "MAX",
572
+ message: `Must be at most ${validation.max}`,
573
+ params: {
574
+ max: validation.max,
575
+ actual: value
576
+ }
577
+ });
578
+ return errors;
579
+ }
580
+ };
581
+ //#endregion
582
+ //#region src/validators/select-validator.ts
583
+ var SelectValidator = class {
584
+ validate(ctx) {
585
+ const { fieldId, value } = ctx;
586
+ const validation = ctx.validation;
587
+ const options = ctx.options;
588
+ const errors = [];
589
+ const isEmpty = value === null || value === void 0;
590
+ if (validation?.required && isEmpty) {
591
+ errors.push({
592
+ fieldId,
593
+ rule: "REQUIRED",
594
+ message: "Value is required"
595
+ });
596
+ return errors;
597
+ }
598
+ if (isEmpty) return errors;
599
+ if (options && !options.some((opt) => opt.value === value)) errors.push({
600
+ fieldId,
601
+ rule: "INVALID_OPTION",
602
+ message: "Value is not a valid option"
603
+ });
604
+ return errors;
605
+ }
606
+ };
607
+ //#endregion
608
+ //#region src/validators/string-validator.ts
609
+ var StringValidator = class {
610
+ validate(ctx) {
611
+ const { fieldId, value } = ctx;
612
+ const validation = ctx.validation;
613
+ const errors = [];
614
+ const isEmpty = value === null || value === void 0 || value === "";
615
+ if (validation?.required && isEmpty) {
616
+ errors.push({
617
+ fieldId,
618
+ rule: "REQUIRED",
619
+ message: "Value is required"
620
+ });
621
+ return errors;
622
+ }
623
+ if (isEmpty) return errors;
624
+ if (typeof value !== "string") {
625
+ errors.push({
626
+ fieldId,
627
+ rule: "TYPE",
628
+ message: "Must be a string",
629
+ params: { expectedType: "string" }
630
+ });
631
+ return errors;
632
+ }
633
+ if (validation?.minLength !== void 0 && value.length < validation.minLength) errors.push({
634
+ fieldId,
635
+ rule: "MIN_LENGTH",
636
+ message: `Must be at least ${validation.minLength} characters`,
637
+ params: {
638
+ minLength: validation.minLength,
639
+ actual: value.length
640
+ }
641
+ });
642
+ if (validation?.maxLength !== void 0 && value.length > validation.maxLength) errors.push({
643
+ fieldId,
644
+ rule: "MAX_LENGTH",
645
+ message: `Must be at most ${validation.maxLength} characters`,
646
+ params: {
647
+ maxLength: validation.maxLength,
648
+ actual: value.length
649
+ }
650
+ });
651
+ if (validation?.pattern !== void 0) {
652
+ if (!new RegExp(validation.pattern).test(value)) errors.push({
653
+ fieldId,
654
+ rule: "PATTERN",
655
+ message: validation.patternMessage ?? "Value does not match the required pattern"
656
+ });
657
+ }
658
+ return errors;
659
+ }
660
+ };
661
+ //#endregion
662
+ //#region src/field-validator.ts
663
+ /**
664
+ * Validates form values against the schema's validation rules.
665
+ *
666
+ * **Which fields are validated:**
667
+ * - Only fields (not sections) are validated.
668
+ * - Hidden fields (those with `visibilityMap.get(id) === false`) are skipped
669
+ * entirely -- they produce no errors regardless of their value.
670
+ *
671
+ * **How each field type is validated:**
672
+ * - `string` -- `required`, `minLength`, `maxLength`, `pattern`.
673
+ * - `number` -- `required`, `min`, `max`.
674
+ * - `boolean` -- `required` (must be explicitly `true` or `false`).
675
+ * - `date` -- `required`, `minDate`, `maxDate`. Relative date boundaries
676
+ * are resolved against `now`.
677
+ * - `select` -- `required`, plus the value must be one of the defined options.
678
+ * - `array` -- `minItems`, `maxItems`, plus each item is validated
679
+ * individually according to the array's {@link ArrayItemDef}. Item-level
680
+ * errors carry an `itemIndex`.
681
+ *
682
+ * For all types, if `required` fails, no further rules are checked for that
683
+ * field (early return). If the value is empty/absent and `required` is not
684
+ * set, no errors are produced.
685
+ */
686
+ var FieldValidator = class {
687
+ registry;
688
+ validators;
689
+ /**
690
+ * @param registry - The engine's field registry.
691
+ */
692
+ constructor(registry) {
693
+ this.registry = registry;
694
+ this.validators = {
695
+ string: new StringValidator(),
696
+ number: new NumberValidator(),
697
+ boolean: new BooleanValidator(),
698
+ date: new DateValidator(),
699
+ select: new SelectValidator(),
700
+ array: new ArrayValidator(),
701
+ file: new FileValidator()
702
+ };
703
+ }
704
+ /**
705
+ * Validates form values against the schema's validation rules.
706
+ *
707
+ * @param values - The form values to validate, keyed by stringified field id.
708
+ * @param visibilityMap - Pre-computed visibility map for all items.
709
+ * @param now - Reference date for resolving relative date expressions.
710
+ * Defaults to `new Date()`.
711
+ * @returns A {@link FormValidationResult} with `valid: true` when no errors
712
+ * exist, or `valid: false` with a populated `fieldErrors` map.
713
+ */
714
+ validate(values, visibilityMap, now = /* @__PURE__ */ new Date()) {
715
+ const fieldErrors = /* @__PURE__ */ new Map();
716
+ for (const [id, entry] of this.registry) {
717
+ if (entry.type === "section") continue;
718
+ if (visibilityMap.get(id) === false) continue;
719
+ const value = values[String(id)];
720
+ const errors = this.validateField(id, value, entry, now);
721
+ if (errors.length > 0) fieldErrors.set(id, errors);
722
+ }
723
+ return {
724
+ valid: fieldErrors.size === 0,
725
+ fieldErrors
726
+ };
727
+ }
728
+ validateField(fieldId, value, entry, now) {
729
+ const validator = this.validators[entry.type];
730
+ if (!validator) return [];
731
+ if (entry.type === "array") {
732
+ const ctx = {
733
+ fieldId,
734
+ value,
735
+ validation: entry.validation,
736
+ now,
737
+ item: entry.item,
738
+ validateField: this.validateField.bind(this)
739
+ };
740
+ return validator.validate(ctx);
741
+ }
742
+ return validator.validate({
743
+ fieldId,
744
+ value,
745
+ validation: entry.validation,
746
+ now,
747
+ options: entry.options
748
+ });
749
+ }
750
+ };
751
+ //#endregion
752
+ //#region src/form-definition-editor.ts
753
+ /**
754
+ * Mutable editor for building and modifying a {@link FormDefinition}.
755
+ *
756
+ * Operates directly on the definition tree. All mutating methods return
757
+ * `this` for fluent chaining.
758
+ *
759
+ * @example
760
+ * ```ts
761
+ * const editor = new FormDefinitionEditor({
762
+ * id: 'my-form', version: '1.0.0', title: 'My Form', content: [],
763
+ * })
764
+ * editor
765
+ * .addField({ type: 'string', label: 'Name', validation: { required: true } })
766
+ * .addSection({ type: 'section', title: 'Details' })
767
+ * .addField({ type: 'number', label: 'Age' }, 2) // into section id=2
768
+ *
769
+ * const definition = editor.toJSON()
770
+ * ```
771
+ */
772
+ var FormDefinitionEditor = class {
773
+ definition;
774
+ constructor(definition) {
775
+ this.definition = JSON.parse(JSON.stringify(definition));
776
+ }
777
+ setTitle(title) {
778
+ this.definition.title = title;
779
+ return this;
780
+ }
781
+ setDescription(description) {
782
+ if (description === void 0) delete this.definition.description;
783
+ else this.definition.description = description;
784
+ return this;
785
+ }
786
+ setVersion(version) {
787
+ this.definition.version = version;
788
+ return this;
789
+ }
790
+ setId(id) {
791
+ this.definition.id = id;
792
+ return this;
793
+ }
794
+ /**
795
+ * Returns the next available numeric id (max existing + 1).
796
+ */
797
+ nextId() {
798
+ let max = 0;
799
+ this.walkAll(this.definition.content, (item) => {
800
+ if (item.id > max) max = item.id;
801
+ });
802
+ return max + 1;
803
+ }
804
+ /**
805
+ * Adds a field to the form.
806
+ *
807
+ * @param descriptor - Field properties. `id` is auto-assigned if omitted.
808
+ * @param parentId - Section id to add into. `undefined` for top-level.
809
+ * @param index - Position within the parent's content array. Appends if omitted.
810
+ * @returns `this` for chaining.
811
+ * @throws If `parentId` references a non-existent or non-section item, or if the id already exists.
812
+ */
813
+ addField(descriptor, parentId, index) {
814
+ const id = descriptor.id ?? this.nextId();
815
+ this.assertIdAvailable(id);
816
+ const field = {
817
+ ...descriptor,
818
+ id
819
+ };
820
+ this.insertItem(field, parentId, index);
821
+ return this;
822
+ }
823
+ /**
824
+ * Adds a section to the form.
825
+ *
826
+ * @param descriptor - Section properties. `id` is auto-assigned if omitted.
827
+ * @param parentId - Parent section id. `undefined` for top-level.
828
+ * @param index - Position within the parent's content array. Appends if omitted.
829
+ * @returns `this` for chaining.
830
+ * @throws If `parentId` references a non-existent or non-section item, or if the id already exists.
831
+ */
832
+ addSection(descriptor, parentId, index) {
833
+ const id = descriptor.id ?? this.nextId();
834
+ this.assertIdAvailable(id);
835
+ const section = {
836
+ ...descriptor,
837
+ id,
838
+ content: descriptor.content ?? []
839
+ };
840
+ this.insertItem(section, parentId, index);
841
+ return this;
842
+ }
843
+ /**
844
+ * Updates properties of an existing field.
845
+ *
846
+ * Cannot change `id` or `type`. Use {@link removeItem} + {@link addField}
847
+ * to change the type.
848
+ */
849
+ updateField(id, updates) {
850
+ const item = this.findItem(id);
851
+ if (!item) throw new Error(`Item with id ${id} not found`);
852
+ if (item.type === "section") throw new Error(`Item ${id} is a section, not a field`);
853
+ Object.assign(item, updates);
854
+ return this;
855
+ }
856
+ /**
857
+ * Updates properties of an existing section.
858
+ *
859
+ * Cannot change `id`, `type`, or `content` directly. Use add/remove methods
860
+ * for content manipulation.
861
+ */
862
+ updateSection(id, updates) {
863
+ const item = this.findItem(id);
864
+ if (!item) throw new Error(`Item with id ${id} not found`);
865
+ if (item.type !== "section") throw new Error(`Item ${id} is not a section`);
866
+ Object.assign(item, updates);
867
+ return this;
868
+ }
869
+ /**
870
+ * Removes a field or section (and all its descendants) by id.
871
+ *
872
+ * @returns `this` for chaining.
873
+ * @throws If the id is not found.
874
+ */
875
+ removeItem(id) {
876
+ if (!this.removeFromContent(this.definition.content, id)) throw new Error(`Item with id ${id} not found`);
877
+ return this;
878
+ }
879
+ /**
880
+ * Moves an item to a new parent and/or position.
881
+ *
882
+ * @param id - Id of the item to move.
883
+ * @param targetParentId - Destination section id, or `undefined` for top-level.
884
+ * @param index - Position in the target content array. Appends if omitted.
885
+ */
886
+ moveItem(id, targetParentId, index) {
887
+ const item = this.findItem(id);
888
+ if (!item) throw new Error(`Item with id ${id} not found`);
889
+ if (targetParentId !== void 0 && item.type === "section") {
890
+ if (targetParentId === id) throw new Error("Cannot move a section into itself");
891
+ if (this.collectDescendantIds(item).has(targetParentId)) throw new Error("Cannot move a section into its own descendant");
892
+ }
893
+ const clone = JSON.parse(JSON.stringify(item));
894
+ this.removeFromContent(this.definition.content, id);
895
+ this.insertItem(clone, targetParentId, index);
896
+ return this;
897
+ }
898
+ /**
899
+ * Returns a flat list of all content items (fields + sections) with parent info.
900
+ */
901
+ listAll() {
902
+ const result = [];
903
+ this.walkAllWithParent(this.definition.content, void 0, (item, parentId) => {
904
+ result.push({
905
+ id: item.id,
906
+ type: item.type,
907
+ label: item.type !== "section" ? item.label : void 0,
908
+ title: item.type === "section" ? item.title : void 0,
909
+ parentId
910
+ });
911
+ });
912
+ return result;
913
+ }
914
+ /**
915
+ * Returns a flat list of all fields (excludes sections).
916
+ */
917
+ listFields() {
918
+ return this.listAll().filter((i) => i.type !== "section");
919
+ }
920
+ /**
921
+ * Returns a flat list of all sections.
922
+ */
923
+ listSections() {
924
+ return this.listAll().filter((i) => i.type === "section");
925
+ }
926
+ /**
927
+ * Returns the content item with the given id, or `undefined` if not found.
928
+ */
929
+ getItem(id) {
930
+ return this.findItem(id) ?? void 0;
931
+ }
932
+ /**
933
+ * Sets or clears the validation rules for a field.
934
+ */
935
+ setValidation(id, validation) {
936
+ const item = this.findItem(id);
937
+ if (!item) throw new Error(`Item with id ${id} not found`);
938
+ if (item.type === "section") throw new Error("Sections do not have validation");
939
+ const field = item;
940
+ if (validation === void 0) delete field.validation;
941
+ else field.validation = validation;
942
+ return this;
943
+ }
944
+ /**
945
+ * Sets or clears the visibility condition for a field or section.
946
+ */
947
+ setCondition(id, condition) {
948
+ const item = this.findItem(id);
949
+ if (!item) throw new Error(`Item with id ${id} not found`);
950
+ if (condition === void 0) delete item.condition;
951
+ else item.condition = condition;
952
+ return this;
953
+ }
954
+ /**
955
+ * Sets the select options for a `select` field.
956
+ */
957
+ setOptions(id, options) {
958
+ const item = this.findItem(id);
959
+ if (!item) throw new Error(`Item with id ${id} not found`);
960
+ if (item.type !== "select") throw new Error(`Field ${id} is not a select field`);
961
+ const field = item;
962
+ field.options = options;
963
+ return this;
964
+ }
965
+ /**
966
+ * Sets the item definition for an `array` field.
967
+ */
968
+ setArrayItem(id, itemDef) {
969
+ const item = this.findItem(id);
970
+ if (!item) throw new Error(`Item with id ${id} not found`);
971
+ if (item.type !== "array") throw new Error(`Field ${id} is not an array field`);
972
+ const field = item;
973
+ field.item = itemDef;
974
+ return this;
975
+ }
976
+ /**
977
+ * Sets the label for a field.
978
+ */
979
+ setLabel(id, label) {
980
+ const item = this.findItem(id);
981
+ if (!item) throw new Error(`Item with id ${id} not found`);
982
+ if (item.type === "section") throw new Error("Sections use title, not label");
983
+ const field = item;
984
+ field.label = label;
985
+ return this;
986
+ }
987
+ /**
988
+ * Sets the description for a field or section.
989
+ */
990
+ setFieldDescription(id, description) {
991
+ const item = this.findItem(id);
992
+ if (!item) throw new Error(`Item with id ${id} not found`);
993
+ if (description === void 0) delete item.description;
994
+ else item.description = description;
995
+ return this;
996
+ }
997
+ /**
998
+ * Returns a deep clone of the current form definition.
999
+ */
1000
+ toJSON() {
1001
+ return JSON.parse(JSON.stringify(this.definition));
1002
+ }
1003
+ findItem(id) {
1004
+ let found;
1005
+ this.walkAll(this.definition.content, (item) => {
1006
+ if (item.id === id) found = item;
1007
+ });
1008
+ return found;
1009
+ }
1010
+ assertIdAvailable(id) {
1011
+ if (this.findItem(id)) throw new Error(`Item with id ${id} already exists`);
1012
+ }
1013
+ insertItem(item, parentId, index) {
1014
+ const target = this.getTargetContent(parentId);
1015
+ if (index !== void 0 && index >= 0 && index < target.length) target.splice(index, 0, item);
1016
+ else target.push(item);
1017
+ }
1018
+ getTargetContent(parentId) {
1019
+ if (parentId === void 0) return this.definition.content;
1020
+ const parent = this.findItem(parentId);
1021
+ if (!parent) throw new Error(`Parent section with id ${parentId} not found`);
1022
+ if (parent.type !== "section") throw new Error(`Item ${parentId} is not a section`);
1023
+ return parent.content;
1024
+ }
1025
+ removeFromContent(content, id) {
1026
+ const idx = content.findIndex((item) => item.id === id);
1027
+ if (idx !== -1) {
1028
+ content.splice(idx, 1);
1029
+ return true;
1030
+ }
1031
+ for (const item of content) if (item.type === "section") {
1032
+ if (this.removeFromContent(item.content, id)) return true;
1033
+ }
1034
+ return false;
1035
+ }
1036
+ walkAll(content, fn) {
1037
+ for (const item of content) {
1038
+ fn(item);
1039
+ if (item.type === "section") this.walkAll(item.content, fn);
1040
+ }
1041
+ }
1042
+ walkAllWithParent(content, parentId, fn) {
1043
+ for (const item of content) {
1044
+ fn(item, parentId);
1045
+ if (item.type === "section") this.walkAllWithParent(item.content, item.id, fn);
1046
+ }
1047
+ }
1048
+ collectDescendantIds(section) {
1049
+ const ids = /* @__PURE__ */ new Set();
1050
+ this.walkAll(section.content, (item) => ids.add(item.id));
1051
+ return ids;
1052
+ }
1053
+ };
1054
+ //#endregion
1055
+ //#region src/form-definition-validator.ts
1056
+ const validateFn = new Ajv2020({ allErrors: true }).compile({
1057
+ $schema: "https://json-schema.org/draft/2020-12/schema",
1058
+ title: "Bluprynt Form Definition",
1059
+ type: "object",
1060
+ required: [
1061
+ "id",
1062
+ "version",
1063
+ "title",
1064
+ "content"
1065
+ ],
1066
+ additionalProperties: false,
1067
+ properties: {
1068
+ "id": { "type": "string" },
1069
+ "version": {
1070
+ "type": "string",
1071
+ "pattern": "^\\d+\\.\\d+\\.\\d+$"
1072
+ },
1073
+ "title": { "type": "string" },
1074
+ "description": { "type": "string" },
1075
+ "content": {
1076
+ "type": "array",
1077
+ "minItems": 1,
1078
+ "items": { "$ref": "#/$defs/contentItem" }
1079
+ }
1080
+ },
1081
+ $defs: {
1082
+ "conditionNoValue": {
1083
+ "type": "object",
1084
+ "required": ["field", "op"],
1085
+ "properties": {
1086
+ "field": {
1087
+ "type": "integer",
1088
+ "minimum": 1
1089
+ },
1090
+ "op": {
1091
+ "type": "string",
1092
+ "enum": ["set", "notset"]
1093
+ }
1094
+ },
1095
+ "additionalProperties": false
1096
+ },
1097
+ "conditionWithValue": {
1098
+ "type": "object",
1099
+ "required": [
1100
+ "field",
1101
+ "op",
1102
+ "value"
1103
+ ],
1104
+ "properties": {
1105
+ "field": {
1106
+ "type": "integer",
1107
+ "minimum": 1
1108
+ },
1109
+ "op": {
1110
+ "type": "string",
1111
+ "enum": [
1112
+ "eq",
1113
+ "ne",
1114
+ "lt",
1115
+ "gt",
1116
+ "lte",
1117
+ "gte"
1118
+ ]
1119
+ },
1120
+ "value": {}
1121
+ },
1122
+ "additionalProperties": false
1123
+ },
1124
+ "conditionWithArrayValue": {
1125
+ "type": "object",
1126
+ "required": [
1127
+ "field",
1128
+ "op",
1129
+ "value"
1130
+ ],
1131
+ "properties": {
1132
+ "field": {
1133
+ "type": "integer",
1134
+ "minimum": 1
1135
+ },
1136
+ "op": {
1137
+ "type": "string",
1138
+ "enum": ["in", "notin"]
1139
+ },
1140
+ "value": { "type": "array" }
1141
+ },
1142
+ "additionalProperties": false
1143
+ },
1144
+ "simpleCondition": { "oneOf": [
1145
+ { "$ref": "#/$defs/conditionNoValue" },
1146
+ { "$ref": "#/$defs/conditionWithValue" },
1147
+ { "$ref": "#/$defs/conditionWithArrayValue" }
1148
+ ] },
1149
+ "condition": { "oneOf": [
1150
+ { "$ref": "#/$defs/simpleCondition" },
1151
+ {
1152
+ "type": "object",
1153
+ "required": ["and"],
1154
+ "properties": { "and": {
1155
+ "type": "array",
1156
+ "minItems": 1,
1157
+ "items": { "$ref": "#/$defs/condition" }
1158
+ } },
1159
+ "additionalProperties": false
1160
+ },
1161
+ {
1162
+ "type": "object",
1163
+ "required": ["or"],
1164
+ "properties": { "or": {
1165
+ "type": "array",
1166
+ "minItems": 1,
1167
+ "items": { "$ref": "#/$defs/condition" }
1168
+ } },
1169
+ "additionalProperties": false
1170
+ }
1171
+ ] },
1172
+ "stringValidation": {
1173
+ "type": "object",
1174
+ "properties": {
1175
+ "required": { "type": "boolean" },
1176
+ "minLength": {
1177
+ "type": "integer",
1178
+ "minimum": 0
1179
+ },
1180
+ "maxLength": {
1181
+ "type": "integer",
1182
+ "minimum": 1
1183
+ },
1184
+ "pattern": { "type": "string" },
1185
+ "patternMessage": { "type": "string" }
1186
+ },
1187
+ "additionalProperties": false
1188
+ },
1189
+ "numberValidation": {
1190
+ "type": "object",
1191
+ "properties": {
1192
+ "required": { "type": "boolean" },
1193
+ "min": { "type": "number" },
1194
+ "max": { "type": "number" }
1195
+ },
1196
+ "additionalProperties": false
1197
+ },
1198
+ "booleanValidation": {
1199
+ "type": "object",
1200
+ "properties": { "required": { "type": "boolean" } },
1201
+ "additionalProperties": false
1202
+ },
1203
+ "dateValidation": {
1204
+ "type": "object",
1205
+ "properties": {
1206
+ "required": { "type": "boolean" },
1207
+ "minDate": {
1208
+ "type": "string",
1209
+ "pattern": "^([+-]\\d+[dwmy]|\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z)$"
1210
+ },
1211
+ "maxDate": {
1212
+ "type": "string",
1213
+ "pattern": "^([+-]\\d+[dwmy]|\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z)$"
1214
+ }
1215
+ },
1216
+ "additionalProperties": false
1217
+ },
1218
+ "selectValidation": {
1219
+ "type": "object",
1220
+ "properties": { "required": { "type": "boolean" } },
1221
+ "additionalProperties": false
1222
+ },
1223
+ "arrayValidation": {
1224
+ "type": "object",
1225
+ "properties": {
1226
+ "minItems": {
1227
+ "type": "integer",
1228
+ "minimum": 0
1229
+ },
1230
+ "maxItems": {
1231
+ "type": "integer",
1232
+ "minimum": 1
1233
+ }
1234
+ },
1235
+ "additionalProperties": false
1236
+ },
1237
+ "fileValidation": {
1238
+ "type": "object",
1239
+ "properties": { "required": { "type": "boolean" } },
1240
+ "additionalProperties": false
1241
+ },
1242
+ "selectOption": {
1243
+ "type": "object",
1244
+ "required": ["value", "label"],
1245
+ "properties": {
1246
+ "value": { "oneOf": [{ "type": "string" }, { "type": "number" }] },
1247
+ "label": { "type": "string" }
1248
+ },
1249
+ "additionalProperties": false
1250
+ },
1251
+ "arrayItemString": {
1252
+ "type": "object",
1253
+ "required": ["type", "label"],
1254
+ "properties": {
1255
+ "type": { "const": "string" },
1256
+ "label": { "type": "string" },
1257
+ "description": { "type": "string" },
1258
+ "validation": { "$ref": "#/$defs/stringValidation" }
1259
+ },
1260
+ "additionalProperties": false
1261
+ },
1262
+ "arrayItemNumber": {
1263
+ "type": "object",
1264
+ "required": ["type", "label"],
1265
+ "properties": {
1266
+ "type": { "const": "number" },
1267
+ "label": { "type": "string" },
1268
+ "description": { "type": "string" },
1269
+ "validation": { "$ref": "#/$defs/numberValidation" }
1270
+ },
1271
+ "additionalProperties": false
1272
+ },
1273
+ "arrayItemBoolean": {
1274
+ "type": "object",
1275
+ "required": ["type", "label"],
1276
+ "properties": {
1277
+ "type": { "const": "boolean" },
1278
+ "label": { "type": "string" },
1279
+ "description": { "type": "string" },
1280
+ "validation": { "$ref": "#/$defs/booleanValidation" }
1281
+ },
1282
+ "additionalProperties": false
1283
+ },
1284
+ "arrayItemDate": {
1285
+ "type": "object",
1286
+ "required": ["type", "label"],
1287
+ "properties": {
1288
+ "type": { "const": "date" },
1289
+ "label": { "type": "string" },
1290
+ "description": { "type": "string" },
1291
+ "validation": { "$ref": "#/$defs/dateValidation" }
1292
+ },
1293
+ "additionalProperties": false
1294
+ },
1295
+ "arrayItemSelect": {
1296
+ "type": "object",
1297
+ "required": [
1298
+ "type",
1299
+ "label",
1300
+ "options"
1301
+ ],
1302
+ "properties": {
1303
+ "type": { "const": "select" },
1304
+ "label": { "type": "string" },
1305
+ "description": { "type": "string" },
1306
+ "options": {
1307
+ "type": "array",
1308
+ "minItems": 1,
1309
+ "items": { "$ref": "#/$defs/selectOption" }
1310
+ },
1311
+ "validation": { "$ref": "#/$defs/selectValidation" }
1312
+ },
1313
+ "additionalProperties": false
1314
+ },
1315
+ "arrayItemFile": {
1316
+ "type": "object",
1317
+ "required": ["type", "label"],
1318
+ "properties": {
1319
+ "type": { "const": "file" },
1320
+ "label": { "type": "string" },
1321
+ "description": { "type": "string" },
1322
+ "validation": { "$ref": "#/$defs/fileValidation" }
1323
+ },
1324
+ "additionalProperties": false
1325
+ },
1326
+ "arrayItem": { "oneOf": [
1327
+ { "$ref": "#/$defs/arrayItemString" },
1328
+ { "$ref": "#/$defs/arrayItemNumber" },
1329
+ { "$ref": "#/$defs/arrayItemBoolean" },
1330
+ { "$ref": "#/$defs/arrayItemDate" },
1331
+ { "$ref": "#/$defs/arrayItemSelect" },
1332
+ { "$ref": "#/$defs/arrayItemFile" }
1333
+ ] },
1334
+ "stringField": {
1335
+ "type": "object",
1336
+ "required": [
1337
+ "id",
1338
+ "type",
1339
+ "label"
1340
+ ],
1341
+ "properties": {
1342
+ "id": {
1343
+ "type": "integer",
1344
+ "minimum": 1
1345
+ },
1346
+ "type": { "const": "string" },
1347
+ "label": { "type": "string" },
1348
+ "description": { "type": "string" },
1349
+ "condition": { "$ref": "#/$defs/condition" },
1350
+ "validation": { "$ref": "#/$defs/stringValidation" }
1351
+ },
1352
+ "additionalProperties": false
1353
+ },
1354
+ "numberField": {
1355
+ "type": "object",
1356
+ "required": [
1357
+ "id",
1358
+ "type",
1359
+ "label"
1360
+ ],
1361
+ "properties": {
1362
+ "id": {
1363
+ "type": "integer",
1364
+ "minimum": 1
1365
+ },
1366
+ "type": { "const": "number" },
1367
+ "label": { "type": "string" },
1368
+ "description": { "type": "string" },
1369
+ "condition": { "$ref": "#/$defs/condition" },
1370
+ "validation": { "$ref": "#/$defs/numberValidation" }
1371
+ },
1372
+ "additionalProperties": false
1373
+ },
1374
+ "booleanField": {
1375
+ "type": "object",
1376
+ "required": [
1377
+ "id",
1378
+ "type",
1379
+ "label"
1380
+ ],
1381
+ "properties": {
1382
+ "id": {
1383
+ "type": "integer",
1384
+ "minimum": 1
1385
+ },
1386
+ "type": { "const": "boolean" },
1387
+ "label": { "type": "string" },
1388
+ "description": { "type": "string" },
1389
+ "condition": { "$ref": "#/$defs/condition" },
1390
+ "validation": { "$ref": "#/$defs/booleanValidation" }
1391
+ },
1392
+ "additionalProperties": false
1393
+ },
1394
+ "dateField": {
1395
+ "type": "object",
1396
+ "required": [
1397
+ "id",
1398
+ "type",
1399
+ "label"
1400
+ ],
1401
+ "properties": {
1402
+ "id": {
1403
+ "type": "integer",
1404
+ "minimum": 1
1405
+ },
1406
+ "type": { "const": "date" },
1407
+ "label": { "type": "string" },
1408
+ "description": { "type": "string" },
1409
+ "condition": { "$ref": "#/$defs/condition" },
1410
+ "validation": { "$ref": "#/$defs/dateValidation" }
1411
+ },
1412
+ "additionalProperties": false
1413
+ },
1414
+ "selectField": {
1415
+ "type": "object",
1416
+ "required": [
1417
+ "id",
1418
+ "type",
1419
+ "label",
1420
+ "options"
1421
+ ],
1422
+ "properties": {
1423
+ "id": {
1424
+ "type": "integer",
1425
+ "minimum": 1
1426
+ },
1427
+ "type": { "const": "select" },
1428
+ "label": { "type": "string" },
1429
+ "description": { "type": "string" },
1430
+ "condition": { "$ref": "#/$defs/condition" },
1431
+ "options": {
1432
+ "type": "array",
1433
+ "minItems": 1,
1434
+ "items": { "$ref": "#/$defs/selectOption" }
1435
+ },
1436
+ "validation": { "$ref": "#/$defs/selectValidation" }
1437
+ },
1438
+ "additionalProperties": false
1439
+ },
1440
+ "arrayField": {
1441
+ "type": "object",
1442
+ "required": [
1443
+ "id",
1444
+ "type",
1445
+ "label",
1446
+ "item"
1447
+ ],
1448
+ "properties": {
1449
+ "id": {
1450
+ "type": "integer",
1451
+ "minimum": 1
1452
+ },
1453
+ "type": { "const": "array" },
1454
+ "label": { "type": "string" },
1455
+ "description": { "type": "string" },
1456
+ "condition": { "$ref": "#/$defs/condition" },
1457
+ "item": { "$ref": "#/$defs/arrayItem" },
1458
+ "validation": { "$ref": "#/$defs/arrayValidation" }
1459
+ },
1460
+ "additionalProperties": false
1461
+ },
1462
+ "fileField": {
1463
+ "type": "object",
1464
+ "required": [
1465
+ "id",
1466
+ "type",
1467
+ "label"
1468
+ ],
1469
+ "properties": {
1470
+ "id": {
1471
+ "type": "integer",
1472
+ "minimum": 1
1473
+ },
1474
+ "type": { "const": "file" },
1475
+ "label": { "type": "string" },
1476
+ "description": { "type": "string" },
1477
+ "condition": { "$ref": "#/$defs/condition" },
1478
+ "validation": { "$ref": "#/$defs/fileValidation" }
1479
+ },
1480
+ "additionalProperties": false
1481
+ },
1482
+ "fieldItem": { "oneOf": [
1483
+ { "$ref": "#/$defs/stringField" },
1484
+ { "$ref": "#/$defs/numberField" },
1485
+ { "$ref": "#/$defs/booleanField" },
1486
+ { "$ref": "#/$defs/dateField" },
1487
+ { "$ref": "#/$defs/selectField" },
1488
+ { "$ref": "#/$defs/arrayField" },
1489
+ { "$ref": "#/$defs/fileField" }
1490
+ ] },
1491
+ "section": {
1492
+ "type": "object",
1493
+ "required": [
1494
+ "id",
1495
+ "type",
1496
+ "title",
1497
+ "content"
1498
+ ],
1499
+ "properties": {
1500
+ "id": {
1501
+ "type": "integer",
1502
+ "minimum": 1
1503
+ },
1504
+ "type": { "const": "section" },
1505
+ "title": { "type": "string" },
1506
+ "description": { "type": "string" },
1507
+ "condition": { "$ref": "#/$defs/condition" },
1508
+ "content": {
1509
+ "type": "array",
1510
+ "minItems": 1,
1511
+ "items": { "$ref": "#/$defs/contentItem" }
1512
+ }
1513
+ },
1514
+ "additionalProperties": false
1515
+ },
1516
+ "contentItem": { "oneOf": [{ "$ref": "#/$defs/fieldItem" }, { "$ref": "#/$defs/section" }] }
1517
+ }
1518
+ });
1519
+ /**
1520
+ * Validates form definitions at both the structural (JSON Schema) and
1521
+ * semantic levels.
1522
+ *
1523
+ * Used by {@link FormEngine} during construction before building the engine.
1524
+ *
1525
+ * ### Schema validation (`validateSchema`)
1526
+ * Validates raw input against the form definition JSON Schema. Returns
1527
+ * `SCHEMA_INVALID` issues for every violation found.
1528
+ *
1529
+ * ### Semantic validation (`validate`)
1530
+ * Checks for logical issues that go beyond JSON schema validity:
1531
+ * 1. **Duplicate IDs** (`DUPLICATE_ID`) -- every content item id must be unique.
1532
+ * 2. **Nesting depth** (`NESTING_DEPTH`) -- sections may not be nested more
1533
+ * than 3 levels deep.
1534
+ * 3. **Unknown field references** (`UNKNOWN_FIELD_REF`) -- conditions must
1535
+ * only reference field ids that exist in the registry.
1536
+ * 4. **Condition references section** (`CONDITION_REFS_SECTION`) -- conditions
1537
+ * must not reference section ids, because sections have no values.
1538
+ * 5. **Constraint contradictions** (`INVALID_MIN_MAX`) -- e.g. `minLength > maxLength`,
1539
+ * `min > max`, `minDate > maxDate` (absolute dates only), `minItems > maxItems`.
1540
+ * 6. **Invalid regex** (`INVALID_REGEX`) -- string field `pattern` values must
1541
+ * be valid regular expressions.
1542
+ */
1543
+ var FormDefinitionValidator = class {
1544
+ /**
1545
+ * Validates raw input against the form definition JSON schema.
1546
+ *
1547
+ * @param input - The raw input to validate.
1548
+ * @returns Array of `SCHEMA_INVALID` issues. Empty when the input conforms to the schema.
1549
+ */
1550
+ validateSchema(input) {
1551
+ if (validateFn(input)) return [];
1552
+ return (validateFn.errors ?? []).map((err) => {
1553
+ const path = err.instancePath || "/";
1554
+ const message = err.message ?? "Unknown error";
1555
+ if (err.keyword === "additionalProperties") return {
1556
+ code: "SCHEMA_INVALID",
1557
+ message: `${path}: ${message}: '${err.params.additionalProperty}'`
1558
+ };
1559
+ return {
1560
+ code: "SCHEMA_INVALID",
1561
+ message: `${path}: ${message}`
1562
+ };
1563
+ });
1564
+ }
1565
+ /**
1566
+ * Validates a form definition semantically.
1567
+ *
1568
+ * @param definition - The form definition to validate.
1569
+ * @param registry - The flattened field registry built from the definition.
1570
+ * @returns Array of issues found. Empty if the definition is semantically valid.
1571
+ */
1572
+ validate(definition, registry) {
1573
+ const issues = [];
1574
+ this.checkDuplicateIds(definition.content, issues);
1575
+ this.checkNestingDepth(definition.content, 0, issues);
1576
+ this.checkConditionRefs(registry, issues);
1577
+ this.checkConditionRefsSection(registry, issues);
1578
+ this.checkConstraintContradictions(registry, issues);
1579
+ this.checkInvalidRegex(registry, issues);
1580
+ return issues;
1581
+ }
1582
+ checkDuplicateIds(content, issues) {
1583
+ const seen = /* @__PURE__ */ new Set();
1584
+ this.walkItems(content, (item) => {
1585
+ if (seen.has(item.id)) issues.push({
1586
+ code: "DUPLICATE_ID",
1587
+ message: `Duplicate id: ${item.id}`,
1588
+ itemId: item.id
1589
+ });
1590
+ else seen.add(item.id);
1591
+ });
1592
+ }
1593
+ checkNestingDepth(content, depth, issues) {
1594
+ for (const item of content) if (item.type === "section") if (depth >= 3) issues.push({
1595
+ code: "NESTING_DEPTH",
1596
+ message: `Section nesting exceeds maximum depth of 3: ${item.id}`,
1597
+ itemId: item.id
1598
+ });
1599
+ else this.checkNestingDepth(item.content, depth + 1, issues);
1600
+ }
1601
+ checkConditionRefs(registry, issues) {
1602
+ for (const [id, entry] of registry) {
1603
+ if (!entry.condition) continue;
1604
+ const refs = DependencyGraph.extractFieldRefs(entry.condition);
1605
+ for (const ref of refs) if (!registry.has(ref)) issues.push({
1606
+ code: "UNKNOWN_FIELD_REF",
1607
+ message: `Condition references unknown field: ${ref} (in item ${id})`,
1608
+ itemId: id
1609
+ });
1610
+ }
1611
+ }
1612
+ checkConditionRefsSection(registry, issues) {
1613
+ for (const [id, entry] of registry) {
1614
+ if (!entry.condition) continue;
1615
+ const refs = DependencyGraph.extractFieldRefs(entry.condition);
1616
+ for (const ref of refs) {
1617
+ const refEntry = registry.get(ref);
1618
+ if (refEntry && refEntry.type === "section") issues.push({
1619
+ code: "CONDITION_REFS_SECTION",
1620
+ message: `Condition references section ${ref}, which has no value (in item ${id})`,
1621
+ itemId: id
1622
+ });
1623
+ }
1624
+ }
1625
+ }
1626
+ checkConstraintContradictions(registry, issues) {
1627
+ for (const [id, entry] of registry) {
1628
+ if (!entry.validation) continue;
1629
+ switch (entry.type) {
1630
+ case "string": {
1631
+ const v = entry.validation;
1632
+ if (v.minLength !== void 0 && v.maxLength !== void 0 && v.maxLength < v.minLength) issues.push({
1633
+ code: "INVALID_MIN_MAX",
1634
+ message: `maxLength must be >= minLength for field ${id}`,
1635
+ itemId: id
1636
+ });
1637
+ break;
1638
+ }
1639
+ case "number": {
1640
+ const v = entry.validation;
1641
+ if (v.min !== void 0 && v.max !== void 0 && v.max < v.min) issues.push({
1642
+ code: "INVALID_MIN_MAX",
1643
+ message: `max must be >= min for field ${id}`,
1644
+ itemId: id
1645
+ });
1646
+ break;
1647
+ }
1648
+ case "date": {
1649
+ const v = entry.validation;
1650
+ if (v.minDate !== void 0 && v.maxDate !== void 0) {
1651
+ const minIsAbsolute = !isRelativeDate(v.minDate);
1652
+ const maxIsAbsolute = !isRelativeDate(v.maxDate);
1653
+ if (minIsAbsolute && maxIsAbsolute) {
1654
+ if (Date.parse(v.maxDate) < Date.parse(v.minDate)) issues.push({
1655
+ code: "INVALID_MIN_MAX",
1656
+ message: `maxDate must be >= minDate for field ${id}`,
1657
+ itemId: id
1658
+ });
1659
+ }
1660
+ }
1661
+ break;
1662
+ }
1663
+ case "array": {
1664
+ const v = entry.validation;
1665
+ if (v.minItems !== void 0 && v.maxItems !== void 0 && v.maxItems < v.minItems) issues.push({
1666
+ code: "INVALID_MIN_MAX",
1667
+ message: `maxItems must be >= minItems for field ${id}`,
1668
+ itemId: id
1669
+ });
1670
+ break;
1671
+ }
1672
+ }
1673
+ }
1674
+ }
1675
+ checkInvalidRegex(registry, issues) {
1676
+ for (const [id, entry] of registry) {
1677
+ if (entry.type !== "string" || !entry.validation) continue;
1678
+ const v = entry.validation;
1679
+ if (v.pattern === void 0) continue;
1680
+ try {
1681
+ new RegExp(v.pattern);
1682
+ } catch (e) {
1683
+ const msg = e instanceof Error ? e.message : String(e);
1684
+ issues.push({
1685
+ code: "INVALID_REGEX",
1686
+ message: `Invalid regex pattern for field ${id}: ${msg}`,
1687
+ itemId: id
1688
+ });
1689
+ }
1690
+ }
1691
+ }
1692
+ walkItems(content, fn) {
1693
+ for (const item of content) {
1694
+ fn(item);
1695
+ if (item.type === "section") this.walkItems(item.content, fn);
1696
+ }
1697
+ }
1698
+ };
1699
+ //#endregion
1700
+ //#region src/types/errors.ts
1701
+ /**
1702
+ * Error thrown when form definition or document validation fails.
1703
+ *
1704
+ * Inspect {@link errors} for structured programmatic access to all
1705
+ * validation issues.
1706
+ *
1707
+ * @example
1708
+ * ```ts
1709
+ * try {
1710
+ * const engine = new FormEngine(definition);
1711
+ * } catch (err) {
1712
+ * if (err instanceof DocumentError) {
1713
+ * for (const e of err.errors) {
1714
+ * console.log(e.code, e.message);
1715
+ * }
1716
+ * }
1717
+ * }
1718
+ * ```
1719
+ */
1720
+ var DocumentError = class extends Error {
1721
+ /** Structured list of all validation errors. */
1722
+ errors;
1723
+ /**
1724
+ * @param errors - One or more validation errors that caused the error.
1725
+ */
1726
+ constructor(errors) {
1727
+ const summary = errors.map((e) => e.message).join("; ");
1728
+ super(`Document validation failed: ${summary}`);
1729
+ this.name = "DocumentError";
1730
+ this.errors = errors;
1731
+ }
1732
+ };
1733
+ //#endregion
1734
+ //#region src/visibility-resolver.ts
1735
+ /**
1736
+ * Computes field and section visibility for a form.
1737
+ *
1738
+ * Provides two modes of visibility computation:
1739
+ * - **Single-item** (`isVisible`): evaluates one item's condition plus its
1740
+ * parent chain. Does not use the hidden-field rule.
1741
+ * - **Bulk** (`getVisibilityMap`): evaluates all items in topological order
1742
+ * with the hidden-field rule applied (references to hidden fields are
1743
+ * treated as "not set").
1744
+ */
1745
+ var VisibilityResolver = class {
1746
+ registry;
1747
+ conditionEvaluator;
1748
+ topologicalOrder;
1749
+ /**
1750
+ * @param registry - The engine's field registry.
1751
+ * @param conditionEvaluator - Evaluator for condition trees.
1752
+ * @param topologicalOrder - Item ids in topological order (from {@link DependencyGraph}).
1753
+ */
1754
+ constructor(registry, conditionEvaluator, topologicalOrder) {
1755
+ this.registry = registry;
1756
+ this.conditionEvaluator = conditionEvaluator;
1757
+ this.topologicalOrder = topologicalOrder;
1758
+ }
1759
+ /**
1760
+ * Determines whether a single field or section is visible.
1761
+ *
1762
+ * Evaluation logic:
1763
+ * 1. If the item has its own condition, evaluate it. If `false`, the item is hidden.
1764
+ * 2. If the item has a parent section, recursively check parent visibility.
1765
+ * An item is hidden whenever any ancestor is hidden.
1766
+ * 3. Items without conditions and without hidden parents are visible.
1767
+ *
1768
+ * Unlike {@link getVisibilityMap}, this method does not use the
1769
+ * pre-computed visibility map and does not apply the hidden-field rule.
1770
+ * Use it for one-off visibility checks; prefer `getVisibilityMap` when
1771
+ * evaluating many items at once.
1772
+ *
1773
+ * @param id - Numeric id of the field or section to check.
1774
+ * @param values - Current form values.
1775
+ * @param now - Reference date for relative date expressions.
1776
+ * @returns `true` if the item should be displayed.
1777
+ */
1778
+ isVisible(id, values, now) {
1779
+ const entry = this.registry.get(id);
1780
+ if (!entry) return false;
1781
+ if (entry.condition) {
1782
+ if (!this.conditionEvaluator.evalCondition(entry.condition, {
1783
+ values,
1784
+ now
1785
+ })) return false;
1786
+ }
1787
+ if (entry.parentId !== void 0) return this.isVisible(entry.parentId, values, now);
1788
+ return true;
1789
+ }
1790
+ /**
1791
+ * Computes visibility for all fields and sections in a single pass.
1792
+ *
1793
+ * Iterates in topological order so that every item is evaluated after the
1794
+ * fields its condition depends on. This enables the **hidden-field rule**:
1795
+ * if a condition references a field that has already been determined hidden,
1796
+ * that field is treated as "not set".
1797
+ *
1798
+ * Cascading parent visibility is also enforced -- if a parent section is
1799
+ * hidden, all its children are immediately marked hidden without evaluating
1800
+ * their own conditions.
1801
+ *
1802
+ * @param values - Current form values.
1803
+ * @param now - Reference date for relative date expressions.
1804
+ * @returns Map from item id to visibility boolean (`true` = visible).
1805
+ */
1806
+ getVisibilityMap(values, now) {
1807
+ const result = /* @__PURE__ */ new Map();
1808
+ for (const id of this.topologicalOrder) {
1809
+ const entry = this.registry.get(id);
1810
+ if (!entry) {
1811
+ result.set(id, false);
1812
+ continue;
1813
+ }
1814
+ if (entry.parentId !== void 0 && result.get(entry.parentId) === false) {
1815
+ result.set(id, false);
1816
+ continue;
1817
+ }
1818
+ if (entry.condition) {
1819
+ const visible = this.conditionEvaluator.evalCondition(entry.condition, {
1820
+ values,
1821
+ visibilityMap: result,
1822
+ now
1823
+ });
1824
+ result.set(id, visible);
1825
+ } else result.set(id, true);
1826
+ }
1827
+ return result;
1828
+ }
1829
+ };
1830
+ //#endregion
1831
+ //#region src/form-engine.ts
1832
+ /**
1833
+ * The runtime form engine.
1834
+ *
1835
+ * Created by passing a {@link FormDefinition} to the constructor. The
1836
+ * construction lifecycle is:
1837
+ *
1838
+ * 1. **Build field registry** -- walks the definition tree depth-first,
1839
+ * creating a flat {@link FieldEntry} for every field and section while
1840
+ * recording document-order ids in `contentOrder`.
1841
+ * 2. **Semantic validation** -- checks for duplicate ids, excessive nesting,
1842
+ * unknown/invalid condition references, constraint contradictions, and
1843
+ * invalid regex patterns.
1844
+ * 3. **Cycle detection** -- verifies that condition dependencies form a DAG
1845
+ * (no circular references).
1846
+ * 4. **Error reporting** -- if any issues were found in steps 2-3, throws a
1847
+ * {@link DocumentError} containing all issues.
1848
+ * 5. **Build dependency graph** -- creates a forward adjacency map so the
1849
+ * engine can quickly determine which items are affected when a field
1850
+ * value changes.
1851
+ * 6. **Topological sort** -- orders all items so that dependencies are
1852
+ * evaluated before dependents (used by `getVisibilityMap`).
1853
+ * 7. **Assemble components** -- creates internal {@link ConditionEvaluator},
1854
+ * {@link VisibilityResolver}, and {@link FieldValidator} instances.
1855
+ *
1856
+ * @example
1857
+ * ```ts
1858
+ * const engine = new FormEngine(myFormDefinition);
1859
+ * const visibility = engine.getVisibilityMap(formValues);
1860
+ * const result = engine.validate(formValues);
1861
+ * ```
1862
+ */
1863
+ var FormEngine = class FormEngine {
1864
+ registry;
1865
+ depGraph;
1866
+ visibilityResolver;
1867
+ fieldValidator;
1868
+ definition;
1869
+ formId;
1870
+ formVersion;
1871
+ /**
1872
+ * Ordered list of all content item ids in depth-first document order.
1873
+ * Matches the order in which items appear in the form definition.
1874
+ */
1875
+ contentOrder;
1876
+ /**
1877
+ * Compiles a {@link FormDefinition} into a ready-to-use engine.
1878
+ *
1879
+ * @param definition - A complete form definition to compile.
1880
+ * @throws {DocumentError} If the definition contains semantic issues
1881
+ * or circular condition dependencies.
1882
+ */
1883
+ constructor(definition) {
1884
+ const definitionValidator = new FormDefinitionValidator();
1885
+ const schemaIssues = definitionValidator.validateSchema(definition);
1886
+ if (schemaIssues.length > 0) throw new DocumentError(schemaIssues);
1887
+ const registry = /* @__PURE__ */ new Map();
1888
+ const contentOrder = [];
1889
+ FormEngine.walkContent(definition.content, void 0, registry, contentOrder);
1890
+ const issues = definitionValidator.validate(definition, registry);
1891
+ const cyclePath = DependencyGraph.detectCycle(registry);
1892
+ if (cyclePath) issues.push({
1893
+ code: "CIRCULAR_DEPENDENCY",
1894
+ message: `Circular condition dependency detected: ${cyclePath.join(" -> ")}`
1895
+ });
1896
+ if (issues.length > 0) throw new DocumentError(issues);
1897
+ this.depGraph = new DependencyGraph(registry);
1898
+ this.visibilityResolver = new VisibilityResolver(registry, new ConditionEvaluator(), this.depGraph.topologicalOrder);
1899
+ this.fieldValidator = new FieldValidator(registry);
1900
+ this.registry = registry;
1901
+ this.contentOrder = contentOrder;
1902
+ this.definition = definition;
1903
+ this.formId = definition.id;
1904
+ this.formVersion = definition.version;
1905
+ }
1906
+ /**
1907
+ * Creates a {@link FormDocument} pre-populated with the form schema's
1908
+ * id and version.
1909
+ *
1910
+ * @param values - Optional initial field values. Defaults to an empty object.
1911
+ * @returns A new form document ready for use with engine methods.
1912
+ */
1913
+ createFormDocument(values) {
1914
+ return {
1915
+ form: {
1916
+ id: this.formId,
1917
+ version: this.formVersion,
1918
+ submittedAt: (/* @__PURE__ */ new Date()).toISOString()
1919
+ },
1920
+ values: values ?? {}
1921
+ };
1922
+ }
1923
+ /**
1924
+ * Serializes the form definition and document into a single {@link FormSnapshot}.
1925
+ *
1926
+ * The snapshot contains the original {@link FormDefinition} used to construct
1927
+ * the engine and the provided {@link FormDocument}. No validation is performed;
1928
+ * call {@link validate} separately if needed.
1929
+ *
1930
+ * @param doc - The form document to include in the snapshot.
1931
+ * @returns A snapshot containing both the definition and the document.
1932
+ */
1933
+ dumpDocument(doc) {
1934
+ return {
1935
+ definition: this.definition,
1936
+ document: doc
1937
+ };
1938
+ }
1939
+ /**
1940
+ * Loads a {@link FormDocument} from a previously created {@link FormSnapshot}.
1941
+ *
1942
+ * Verifies that the snapshot's form definition matches the engine's
1943
+ * compiled definition by comparing id and version. Throws a
1944
+ * {@link DocumentError} if there is a mismatch.
1945
+ *
1946
+ * @param snapshot - A snapshot previously produced by {@link dumpDocument}.
1947
+ * @returns The form document from the snapshot.
1948
+ * @throws {DocumentError} If the snapshot's definition id or version
1949
+ * does not match the engine's.
1950
+ */
1951
+ loadDocument(snapshot) {
1952
+ const errors = [];
1953
+ if (snapshot.definition.id !== this.formId) errors.push({
1954
+ code: "FORM_ID_MISMATCH",
1955
+ message: `Snapshot form id "${snapshot.definition.id}" does not match expected "${this.formId}"`,
1956
+ params: {
1957
+ expected: this.formId,
1958
+ actual: snapshot.definition.id
1959
+ }
1960
+ });
1961
+ if (snapshot.definition.version !== this.formVersion) errors.push({
1962
+ code: "FORM_VERSION_MISMATCH",
1963
+ message: `Snapshot form version "${snapshot.definition.version}" does not match expected "${this.formVersion}"`,
1964
+ params: {
1965
+ expected: this.formVersion,
1966
+ actual: snapshot.definition.version
1967
+ }
1968
+ });
1969
+ if (errors.length > 0) throw new DocumentError(errors);
1970
+ return snapshot.document;
1971
+ }
1972
+ /**
1973
+ * Determines whether a field or section is visible given the current form document.
1974
+ *
1975
+ * Evaluates the item's own condition and walks up the parent chain --
1976
+ * an item is hidden if any ancestor is hidden.
1977
+ *
1978
+ * @param id - Numeric id of the field or section.
1979
+ * @param doc - Current form document.
1980
+ * @returns `true` if the item should be displayed, `false` otherwise.
1981
+ */
1982
+ isVisible(id, doc) {
1983
+ return this.visibilityResolver.isVisible(id, doc.values, FormEngine.parseNow(doc));
1984
+ }
1985
+ /**
1986
+ * Computes visibility for every field and section in topological order.
1987
+ *
1988
+ * The resulting map is keyed by item id. Items whose conditions depend on
1989
+ * other items are evaluated after their dependencies, ensuring correct
1990
+ * cascading visibility (e.g. a hidden parent hides all children).
1991
+ *
1992
+ * @param doc - Current form document.
1993
+ * @returns Map from item id to visibility boolean.
1994
+ */
1995
+ getVisibilityMap(doc) {
1996
+ return this.visibilityResolver.getVisibilityMap(doc.values, FormEngine.parseNow(doc));
1997
+ }
1998
+ /**
1999
+ * Returns the set of item ids whose visibility may change when the
2000
+ * specified field's value changes.
2001
+ *
2002
+ * Includes transitive dependents -- if field A controls field B, and
2003
+ * field B controls field C, changing A returns `{B, C}`.
2004
+ * Results are cached for the lifetime of the engine.
2005
+ *
2006
+ * @param fieldId - Id of the field that changed.
2007
+ * @returns Set of affected item ids (does not include `fieldId` itself unless
2008
+ * it is part of a dependency chain).
2009
+ */
2010
+ getAffectedIds(fieldId) {
2011
+ return this.depGraph.getAffectedIds(fieldId);
2012
+ }
2013
+ /**
2014
+ * Validates form values against the schema's validation rules.
2015
+ *
2016
+ * Only visible fields are validated -- hidden fields are skipped entirely.
2017
+ * Sections are never validated directly. For array fields, each item is
2018
+ * validated individually according to the array's item definition.
2019
+ *
2020
+ * The reference time for relative date validation is derived from
2021
+ * `doc.form.submittedAt`. If that value is missing or unparseable, a
2022
+ * document-level error is reported and `new Date()` is used as fallback.
2023
+ *
2024
+ * @param doc - Current form document to validate.
2025
+ * @returns Validation result with a `valid` flag and a `fieldErrors` map.
2026
+ */
2027
+ validate(doc) {
2028
+ const documentErrors = [];
2029
+ if (doc.form.id !== this.formId) documentErrors.push({
2030
+ code: "FORM_ID_MISMATCH",
2031
+ message: `Document form id "${doc.form.id}" does not match expected "${this.formId}"`,
2032
+ params: {
2033
+ expected: this.formId,
2034
+ actual: doc.form.id
2035
+ }
2036
+ });
2037
+ if (doc.form.version !== this.formVersion) documentErrors.push({
2038
+ code: "FORM_VERSION_MISMATCH",
2039
+ message: `Document form version "${doc.form.version}" does not match expected "${this.formVersion}"`,
2040
+ params: {
2041
+ expected: this.formVersion,
2042
+ actual: doc.form.version
2043
+ }
2044
+ });
2045
+ let now;
2046
+ if (!doc.form.submittedAt) {
2047
+ documentErrors.push({
2048
+ code: "FORM_SUBMITTED_AT_MISSING",
2049
+ message: "Document form submittedAt is missing"
2050
+ });
2051
+ now = /* @__PURE__ */ new Date();
2052
+ } else {
2053
+ const parsedSubmittedAt = new Date(doc.form.submittedAt);
2054
+ if (Number.isNaN(parsedSubmittedAt.getTime())) {
2055
+ documentErrors.push({
2056
+ code: "FORM_SUBMITTED_AT_INVALID",
2057
+ message: `Document form submittedAt "${doc.form.submittedAt}" is not a valid date`,
2058
+ params: { actual: doc.form.submittedAt }
2059
+ });
2060
+ now = /* @__PURE__ */ new Date();
2061
+ } else now = parsedSubmittedAt;
2062
+ }
2063
+ if (documentErrors.length > 0) return {
2064
+ valid: false,
2065
+ fieldErrors: /* @__PURE__ */ new Map(),
2066
+ documentErrors
2067
+ };
2068
+ const visibilityMap = this.visibilityResolver.getVisibilityMap(doc.values, now);
2069
+ return this.fieldValidator.validate(doc.values, visibilityMap, now);
2070
+ }
2071
+ /**
2072
+ * Retrieves the internal {@link FieldEntry} for a given id.
2073
+ *
2074
+ * @param id - Numeric id of the field or section.
2075
+ * @returns The field entry, or `undefined` if the id is not in the registry.
2076
+ */
2077
+ getFieldDef(id) {
2078
+ return this.registry.get(id);
2079
+ }
2080
+ static parseNow(doc) {
2081
+ if (doc.form.submittedAt) {
2082
+ const parsed = new Date(doc.form.submittedAt);
2083
+ if (!Number.isNaN(parsed.getTime())) return parsed;
2084
+ }
2085
+ return /* @__PURE__ */ new Date();
2086
+ }
2087
+ static walkContent(content, parentId, registry, contentOrder) {
2088
+ for (const item of content) {
2089
+ const entry = {
2090
+ id: item.id,
2091
+ type: item.type,
2092
+ condition: item.condition,
2093
+ validation: item.type !== "section" ? item.validation : void 0,
2094
+ parentId,
2095
+ options: item.type === "select" ? item.options : void 0,
2096
+ item: item.type === "array" ? item.item : void 0,
2097
+ label: item.type !== "section" ? item.label : void 0,
2098
+ title: item.type === "section" ? item.title : void 0
2099
+ };
2100
+ registry.set(item.id, entry);
2101
+ contentOrder.push(item.id);
2102
+ if (item.type === "section") FormEngine.walkContent(item.content, item.id, registry, contentOrder);
2103
+ }
2104
+ }
2105
+ };
2106
+ //#endregion
2107
+ //#region src/form-values-editor.ts
2108
+ /**
2109
+ * Mutable editor for building and modifying form values against a {@link FormDefinition}.
2110
+ *
2111
+ * Wraps a {@link FormEngine} and a mutable {@link FormDocument}. All mutating
2112
+ * methods return `this` for fluent chaining.
2113
+ *
2114
+ * @example
2115
+ * ```ts
2116
+ * const editor = new FormValuesEditor(definition)
2117
+ * editor
2118
+ * .setFieldValue(1, 'Alice')
2119
+ * .setFieldValue(2, 30)
2120
+ * .setSubmittedAt('2025-01-01T00:00:00Z')
2121
+ *
2122
+ * const result = editor.validate()
2123
+ * const doc = editor.toJSON()
2124
+ * ```
2125
+ */
2126
+ var FormValuesEditor = class {
2127
+ engine;
2128
+ doc;
2129
+ /**
2130
+ * Creates a new editor for the given form definition.
2131
+ *
2132
+ * @param definition - The form definition to edit values against.
2133
+ * @param doc - An existing document to pre-populate. Deep-cloned internally.
2134
+ * When omitted a blank document is created via {@link FormEngine.createFormDocument}.
2135
+ */
2136
+ constructor(definition, doc) {
2137
+ this.engine = new FormEngine(definition);
2138
+ this.doc = doc ? JSON.parse(JSON.stringify(doc)) : this.engine.createFormDocument();
2139
+ }
2140
+ /**
2141
+ * Returns the current value of a field.
2142
+ *
2143
+ * @param fieldId - Numeric id of the field.
2144
+ * @returns The field value, or `undefined` if not set.
2145
+ */
2146
+ getFieldValue(fieldId) {
2147
+ return this.doc.values[String(fieldId)];
2148
+ }
2149
+ /**
2150
+ * Sets the value of a field.
2151
+ *
2152
+ * @param fieldId - Numeric id of the field.
2153
+ * @param value - The value to set.
2154
+ * @returns `this` for chaining.
2155
+ * @throws If `fieldId` is unknown or references a section.
2156
+ */
2157
+ setFieldValue(fieldId, value) {
2158
+ this.assertField(fieldId);
2159
+ this.doc.values[String(fieldId)] = value;
2160
+ return this;
2161
+ }
2162
+ /**
2163
+ * Removes the value of a field.
2164
+ *
2165
+ * @param fieldId - Numeric id of the field.
2166
+ * @returns `this` for chaining.
2167
+ */
2168
+ clearFieldValue(fieldId) {
2169
+ delete this.doc.values[String(fieldId)];
2170
+ return this;
2171
+ }
2172
+ /**
2173
+ * Appends an item to an array field.
2174
+ *
2175
+ * If the field currently has no value, it is initialized to an empty array
2176
+ * before appending.
2177
+ *
2178
+ * @param fieldId - Numeric id of the array field.
2179
+ * @param value - The value to append. Defaults to `undefined`.
2180
+ * @returns `this` for chaining.
2181
+ * @throws If `fieldId` is not an array field.
2182
+ */
2183
+ addArrayItem(fieldId, value) {
2184
+ this.getOrInitArray(fieldId).push(value);
2185
+ return this;
2186
+ }
2187
+ /**
2188
+ * Removes an item from an array field by index.
2189
+ *
2190
+ * @param fieldId - Numeric id of the array field.
2191
+ * @param index - Zero-based index of the item to remove.
2192
+ * @returns `this` for chaining.
2193
+ * @throws If `fieldId` is not an array field or the index is out of bounds.
2194
+ */
2195
+ removeArrayItem(fieldId, index) {
2196
+ const arr = this.assertArray(fieldId);
2197
+ if (index < 0 || index >= arr.length) throw new Error(`Index ${index} is out of bounds for array field ${fieldId} (length ${arr.length})`);
2198
+ arr.splice(index, 1);
2199
+ return this;
2200
+ }
2201
+ /**
2202
+ * Moves an item within an array field from one index to another.
2203
+ *
2204
+ * @param fieldId - Numeric id of the array field.
2205
+ * @param fromIndex - Current zero-based index of the item.
2206
+ * @param toIndex - Target zero-based index.
2207
+ * @returns `this` for chaining.
2208
+ * @throws If `fieldId` is not an array field or either index is out of bounds.
2209
+ */
2210
+ moveArrayItem(fieldId, fromIndex, toIndex) {
2211
+ const arr = this.assertArray(fieldId);
2212
+ if (fromIndex < 0 || fromIndex >= arr.length) throw new Error(`fromIndex ${fromIndex} is out of bounds for array field ${fieldId} (length ${arr.length})`);
2213
+ if (toIndex < 0 || toIndex >= arr.length) throw new Error(`toIndex ${toIndex} is out of bounds for array field ${fieldId} (length ${arr.length})`);
2214
+ const [item] = arr.splice(fromIndex, 1);
2215
+ arr.splice(toIndex, 0, item);
2216
+ return this;
2217
+ }
2218
+ /**
2219
+ * Sets the value of an item at a specific index in an array field.
2220
+ *
2221
+ * @param fieldId - Numeric id of the array field.
2222
+ * @param index - Zero-based index of the item to set.
2223
+ * @param value - The new value for the item.
2224
+ * @returns `this` for chaining.
2225
+ * @throws If `fieldId` is not an array field or the index is out of bounds.
2226
+ */
2227
+ setArrayItem(fieldId, index, value) {
2228
+ const arr = this.assertArray(fieldId);
2229
+ if (index < 0 || index >= arr.length) throw new Error(`Index ${index} is out of bounds for array field ${fieldId} (length ${arr.length})`);
2230
+ arr[index] = value;
2231
+ return this;
2232
+ }
2233
+ /**
2234
+ * Sets the `submittedAt` timestamp on the document.
2235
+ *
2236
+ * @param submittedAt - ISO 8601 timestamp string.
2237
+ * @returns `this` for chaining.
2238
+ */
2239
+ setSubmittedAt(submittedAt) {
2240
+ this.doc.form.submittedAt = submittedAt;
2241
+ return this;
2242
+ }
2243
+ /**
2244
+ * Validates the current document against the form definition.
2245
+ *
2246
+ * Delegates to {@link FormEngine.validate}.
2247
+ *
2248
+ * @returns The validation result.
2249
+ */
2250
+ validate() {
2251
+ return this.engine.validate(this.doc);
2252
+ }
2253
+ /**
2254
+ * Computes visibility for every field and section.
2255
+ *
2256
+ * Delegates to {@link FormEngine.getVisibilityMap}.
2257
+ *
2258
+ * @returns Map from item id to visibility boolean.
2259
+ */
2260
+ getVisibilityMap() {
2261
+ return this.engine.getVisibilityMap(this.doc);
2262
+ }
2263
+ /**
2264
+ * Determines whether a field or section is visible given current values.
2265
+ *
2266
+ * Delegates to {@link FormEngine.isVisible}.
2267
+ *
2268
+ * @param id - Numeric id of the field or section.
2269
+ * @returns `true` if the item should be displayed.
2270
+ */
2271
+ isVisible(id) {
2272
+ return this.engine.isVisible(id, this.doc);
2273
+ }
2274
+ /**
2275
+ * Returns a deep clone of the current form document.
2276
+ *
2277
+ * @returns A new serializable {@link FormDocument} instance.
2278
+ */
2279
+ toJSON() {
2280
+ return JSON.parse(JSON.stringify(this.doc));
2281
+ }
2282
+ /**
2283
+ * Asserts that `fieldId` exists in the registry and is not a section.
2284
+ */
2285
+ assertField(fieldId) {
2286
+ const entry = this.engine.getFieldDef(fieldId);
2287
+ if (!entry) throw new Error(`Field with id ${fieldId} not found`);
2288
+ if (entry.type === "section") throw new Error(`Item ${fieldId} is a section, not a field`);
2289
+ }
2290
+ /**
2291
+ * Asserts that `fieldId` is an array field and returns the current array value.
2292
+ * Throws if the field is not an array type or the current value is not an array.
2293
+ */
2294
+ assertArray(fieldId) {
2295
+ const entry = this.engine.getFieldDef(fieldId);
2296
+ if (!entry) throw new Error(`Field with id ${fieldId} not found`);
2297
+ if (entry.type !== "array") throw new Error(`Field ${fieldId} is not an array field`);
2298
+ const val = this.doc.values[String(fieldId)];
2299
+ if (!Array.isArray(val)) throw new Error(`Field ${fieldId} does not currently hold an array value`);
2300
+ return val;
2301
+ }
2302
+ /**
2303
+ * Returns the array value for `fieldId`, initializing to `[]` if not yet set.
2304
+ */
2305
+ getOrInitArray(fieldId) {
2306
+ const entry = this.engine.getFieldDef(fieldId);
2307
+ if (!entry) throw new Error(`Field with id ${fieldId} not found`);
2308
+ if (entry.type !== "array") throw new Error(`Field ${fieldId} is not an array field`);
2309
+ const key = String(fieldId);
2310
+ let val = this.doc.values[key];
2311
+ if (!Array.isArray(val)) {
2312
+ val = [];
2313
+ this.doc.values[key] = val;
2314
+ }
2315
+ return val;
2316
+ }
2317
+ };
2318
+ //#endregion
2319
+ export { ConditionEvaluator, DependencyGraph, DocumentError, FieldValidator, FormDefinitionEditor, FormDefinitionValidator, FormEngine, FormValuesEditor, VisibilityResolver };
2320
+
2321
+ //# sourceMappingURL=index.mjs.map