@marktiderman/genesis-forms 0.0.0-stage → 0.1.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 (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/conformance/after-class-back-after-a-jump.scenario.json +217 -0
  4. package/conformance/after-class-contact-yes-completed.scenario.json +230 -0
  5. package/conformance/after-class-contact-yes-requires-name-and-email.scenario.json +218 -0
  6. package/conformance/after-class-happy-path.scenario.json +237 -0
  7. package/conformance/after-class-invalid-email-rejected.scenario.json +227 -0
  8. package/conformance/after-class-low-rating-skips-went-well.scenario.json +223 -0
  9. package/conformance/after-class-server-drops-unknown-and-hidden-answers.scenario.json +236 -0
  10. package/conformance/after-class-server-ignores-a-client-sent-coach.scenario.json +219 -0
  11. package/conformance/contact-details-anonymous-form-skips-identifying-fields.scenario.json +125 -0
  12. package/conformance/contact-details-named-form-asks-for-identifying-fields.scenario.json +129 -0
  13. package/conformance/monthly-pulse-default-ending.scenario.json +148 -0
  14. package/conformance/monthly-pulse-promoter-ending-via-jump.scenario.json +128 -0
  15. package/dist/engine-Cp5t2_Am.d.ts +75 -0
  16. package/dist/engine-LsPRTzt3.d.cts +75 -0
  17. package/dist/index.cjs +1205 -0
  18. package/dist/index.cjs.map +1 -0
  19. package/dist/index.d.cts +374 -0
  20. package/dist/index.d.ts +374 -0
  21. package/dist/index.js +1174 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/server.cjs +988 -0
  24. package/dist/server.cjs.map +1 -0
  25. package/dist/server.d.cts +52 -0
  26. package/dist/server.d.ts +52 -0
  27. package/dist/server.js +984 -0
  28. package/dist/server.js.map +1 -0
  29. package/dist/testing.cjs +1299 -0
  30. package/dist/testing.cjs.map +1 -0
  31. package/dist/testing.d.cts +129 -0
  32. package/dist/testing.d.ts +129 -0
  33. package/dist/testing.js +1293 -0
  34. package/dist/testing.js.map +1 -0
  35. package/dist/validate-j8Z188mF.d.cts +175 -0
  36. package/dist/validate-j8Z188mF.d.ts +175 -0
  37. package/package.json +79 -3
@@ -0,0 +1,988 @@
1
+ 'use strict';
2
+
3
+ // src/fields.ts
4
+ var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
5
+ var globalScope = globalThis;
6
+ var registry = globalScope[REGISTRY] ??= /* @__PURE__ */ new Map();
7
+ function registerFieldType(manifest) {
8
+ if (!manifest.type) throw new Error("registerFieldType: a field type needs a `type`.");
9
+ const value = manifest.value;
10
+ if (!value.json && (typeof value.serialize !== "function" || typeof value.deserialize !== "function")) {
11
+ throw new Error(
12
+ `registerFieldType: "${manifest.type}" stores a non-JSON value, so it must provide both serialize and deserialize.`
13
+ );
14
+ }
15
+ registry.set(manifest.type, manifest);
16
+ }
17
+ function getFieldType(type) {
18
+ return registry.get(type);
19
+ }
20
+ function isEmptyValue(value, field) {
21
+ const manifest = field ? registry.get(field.type) : void 0;
22
+ if (manifest?.isEmpty) return manifest.isEmpty(value);
23
+ if (value === void 0 || value === null) return true;
24
+ if (typeof value === "string") return value.trim() === "";
25
+ if (Array.isArray(value)) return value.length === 0;
26
+ return false;
27
+ }
28
+ function normalizeValue(value, field) {
29
+ const manifest = registry.get(field.type);
30
+ return manifest?.normalize ? manifest.normalize(value, field) : value;
31
+ }
32
+ function summarizeValue(value, field) {
33
+ if (isEmptyValue(value, field)) return "";
34
+ const manifest = registry.get(field.type);
35
+ if (manifest?.summarize) return manifest.summarize(value, field);
36
+ if (Array.isArray(value)) return value.join(", ");
37
+ return String(value);
38
+ }
39
+ function choiceOptions(field) {
40
+ const raw = field.props?.options;
41
+ if (!Array.isArray(raw)) return [];
42
+ return raw.flatMap(
43
+ (o) => o && typeof o === "object" && !Array.isArray(o) && typeof o.value === "string" ? [{ value: o.value, label: typeof o.label === "string" ? o.label : void 0 }] : []
44
+ );
45
+ }
46
+ function optionLabel(field, value) {
47
+ const option = choiceOptions(field).find((o) => o.value === value);
48
+ return option?.label ?? value;
49
+ }
50
+ function intRange(field, min, maxDefault) {
51
+ const max = typeof field.props?.max === "number" ? field.props.max : maxDefault;
52
+ return { min, max };
53
+ }
54
+ var numberOps = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
55
+ var textOps = ["eq", "neq", "contains", "answered", "empty"];
56
+ function maxLength(field, fallback) {
57
+ return typeof field.props?.maxLength === "number" ? field.props.maxLength : fallback;
58
+ }
59
+ function textValidate(fallback) {
60
+ return (value, field) => {
61
+ if (typeof value !== "string") return "Enter text.";
62
+ const limit = maxLength(field, fallback);
63
+ return value.length > limit ? `Keep it under ${limit} characters.` : null;
64
+ };
65
+ }
66
+ var MAX_EMAIL_LENGTH = 254;
67
+ function isEmailAddress(value) {
68
+ if (value.length > MAX_EMAIL_LENGTH || /\s/.test(value)) return false;
69
+ const at = value.indexOf("@");
70
+ if (at < 1 || value.indexOf("@", at + 1) !== -1) return false;
71
+ const domain = value.slice(at + 1);
72
+ return domain.slice(1, -1).includes(".");
73
+ }
74
+ var MAX_PHONE_LENGTH = 32;
75
+ function isPhoneNumber(value) {
76
+ if (value.length > MAX_PHONE_LENGTH) return false;
77
+ let digits = 0;
78
+ for (let i = 0; i < value.length; i++) {
79
+ const c = value[i];
80
+ if (c >= "0" && c <= "9") digits++;
81
+ else if (c === "+") {
82
+ if (i !== 0) return false;
83
+ } else if (c !== " " && c !== "-" && c !== "(" && c !== ")" && c !== ".") return false;
84
+ }
85
+ return digits >= 7 && digits <= 15;
86
+ }
87
+ var builtins = [
88
+ {
89
+ type: "short-text",
90
+ label: "Short text",
91
+ category: "text",
92
+ value: { json: true },
93
+ operators: textOps,
94
+ a11y: { semantics: "control" },
95
+ validate: textValidate(500)
96
+ },
97
+ {
98
+ type: "long-text",
99
+ label: "Long text",
100
+ category: "text",
101
+ value: { json: true },
102
+ operators: textOps,
103
+ a11y: { semantics: "control" },
104
+ validate: textValidate(5e3)
105
+ },
106
+ {
107
+ type: "email",
108
+ label: "Email",
109
+ category: "text",
110
+ value: { json: true },
111
+ operators: textOps,
112
+ a11y: { semantics: "control", autocomplete: "email", inputMode: "email" },
113
+ validate: (value) => typeof value === "string" && isEmailAddress(value.trim()) ? null : "Enter an email address like name@example.com.",
114
+ normalize: (value) => typeof value === "string" ? value.trim() : value
115
+ },
116
+ {
117
+ type: "phone",
118
+ label: "Phone",
119
+ category: "text",
120
+ value: { json: true },
121
+ operators: ["eq", "neq", "answered", "empty"],
122
+ a11y: { semantics: "control", autocomplete: "tel", inputMode: "tel" },
123
+ validate: (value) => typeof value === "string" && isPhoneNumber(value.trim()) ? null : "Enter a phone number with 7 to 15 digits.",
124
+ normalize: (value) => typeof value === "string" ? value.trim() : value,
125
+ summarize: (value) => String(value)
126
+ },
127
+ {
128
+ type: "rating",
129
+ label: "Rating",
130
+ category: "scale",
131
+ value: { json: true },
132
+ operators: numberOps,
133
+ a11y: { semantics: "group" },
134
+ validate: (value, field) => {
135
+ const { min, max } = intRange(field, 1, 5);
136
+ return Number.isInteger(value) && value >= min && value <= max ? null : `Choose a rating from ${min} to ${max}.`;
137
+ },
138
+ summarize: (value, field) => `${value}/${intRange(field, 1, 5).max}`
139
+ },
140
+ {
141
+ type: "nps",
142
+ label: "Net Promoter Score",
143
+ category: "scale",
144
+ value: { json: true },
145
+ operators: numberOps,
146
+ a11y: { semantics: "group" },
147
+ validate: (value) => Number.isInteger(value) && value >= 0 && value <= 10 ? null : "Choose a score from 0 to 10.",
148
+ summarize: (value) => `${value}/10`
149
+ },
150
+ {
151
+ type: "single-choice",
152
+ label: "Single choice",
153
+ category: "choice",
154
+ value: { json: true },
155
+ operators: ["eq", "neq", "in", "answered", "empty"],
156
+ a11y: { semantics: "group" },
157
+ validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
158
+ summarize: (value, field) => optionLabel(field, String(value))
159
+ },
160
+ {
161
+ type: "multi-choice",
162
+ label: "Multiple choice",
163
+ category: "choice",
164
+ value: { json: true },
165
+ operators: ["contains", "answered", "empty"],
166
+ a11y: { semantics: "group" },
167
+ validate: (value, field) => {
168
+ const allowed = new Set(choiceOptions(field).map((o) => o.value));
169
+ return Array.isArray(value) && value.length <= allowed.size && new Set(value).size === value.length && value.every((v) => typeof v === "string" && allowed.has(v)) ? null : "Choose from the options.";
170
+ },
171
+ summarize: (value, field) => Array.isArray(value) ? value.map((v) => optionLabel(field, String(v))).join(", ") : String(value)
172
+ },
173
+ {
174
+ type: "yes-no",
175
+ label: "Yes / no",
176
+ category: "choice",
177
+ value: { json: true },
178
+ operators: ["eq", "neq", "answered", "empty"],
179
+ a11y: { semantics: "group" },
180
+ validate: (value) => typeof value === "boolean" ? null : "Choose yes or no.",
181
+ summarize: (value) => value ? "Yes" : "No"
182
+ }
183
+ ];
184
+ for (const manifest of builtins) registerFieldType(manifest);
185
+
186
+ // src/events.ts
187
+ function isPlainObject(value) {
188
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
189
+ const proto = Object.getPrototypeOf(value);
190
+ return proto === Object.prototype || proto === null;
191
+ }
192
+ function isJson(value) {
193
+ const stack = [value];
194
+ while (stack.length) {
195
+ const v = stack.pop();
196
+ if (v === null || typeof v === "string" || typeof v === "boolean") continue;
197
+ if (typeof v === "number") {
198
+ if (!Number.isFinite(v)) return false;
199
+ continue;
200
+ }
201
+ if (Array.isArray(v)) {
202
+ for (let i = 0; i < v.length; i++) stack.push(v[i]);
203
+ continue;
204
+ }
205
+ if (!isPlainObject(v)) return false;
206
+ for (const key of Object.keys(v)) stack.push(v[key]);
207
+ }
208
+ return true;
209
+ }
210
+ var EVENT_TYPES = /* @__PURE__ */ new Set(["answer", "next", "back", "submit"]);
211
+ function isFormEvent(value) {
212
+ if (!isPlainObject(value) || typeof value.type !== "string" || !EVENT_TYPES.has(value.type)) return false;
213
+ if (value.at !== void 0 && !(typeof value.at === "number" && Number.isFinite(value.at))) return false;
214
+ if (value.type !== "answer") return true;
215
+ return typeof value.field === "string" && (value.value === void 0 || isJson(value.value)) && (value.source === void 0 || value.source === "prefill");
216
+ }
217
+ function isUnknownEvent(value) {
218
+ return isPlainObject(value) && typeof value.type === "string" && !EVENT_TYPES.has(value.type);
219
+ }
220
+ function compactEvents(events) {
221
+ const out = [];
222
+ for (const event of events) {
223
+ const last = out[out.length - 1];
224
+ if (event.type === "answer" && last?.type === "answer" && last.field === event.field) {
225
+ out[out.length - 1] = event;
226
+ } else out.push(event);
227
+ }
228
+ return out;
229
+ }
230
+
231
+ // src/hidden.ts
232
+ var MAX_HIDDEN_STRING_LENGTH = 1e3;
233
+ function coerceHiddenValue(value, type) {
234
+ if (typeof value === "string" && value.length > MAX_HIDDEN_STRING_LENGTH) return void 0;
235
+ if (type === "string") {
236
+ if (typeof value === "string") return value;
237
+ if (typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) return String(value);
238
+ return void 0;
239
+ }
240
+ if (type === "number") {
241
+ if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
242
+ if (typeof value !== "string" || value.trim() === "") return void 0;
243
+ const n = Number(value);
244
+ return Number.isFinite(n) ? n : void 0;
245
+ }
246
+ if (typeof value === "boolean") return value;
247
+ if (value === "true") return true;
248
+ if (value === "false") return false;
249
+ return void 0;
250
+ }
251
+ function coerceHidden(definition, raw, options = {}) {
252
+ const declared = new Map((definition.hidden ?? []).map((h) => [h.name, h]));
253
+ const values = {};
254
+ const ignored = [];
255
+ if (!isPlainObject(raw)) return { values, ignored };
256
+ for (const name of Object.keys(raw)) {
257
+ const value = raw[name];
258
+ if (value === void 0) continue;
259
+ const spec = declared.get(name);
260
+ if (!spec || options.sources && !options.sources.includes(spec.source)) {
261
+ ignored.push(name);
262
+ continue;
263
+ }
264
+ const coerced = coerceHiddenValue(value, spec.type);
265
+ if (coerced === void 0) ignored.push(name);
266
+ else Object.defineProperty(values, name, { value: coerced, enumerable: true, writable: true, configurable: true });
267
+ }
268
+ return { values, ignored };
269
+ }
270
+
271
+ // src/logic.ts
272
+ function own(record, key) {
273
+ return Object.prototype.hasOwnProperty.call(record, key) ? record[key] : void 0;
274
+ }
275
+ function resolve(operand, scope) {
276
+ if ("field" in operand) return own(scope.values, operand.field);
277
+ if ("hidden" in operand) return own(scope.hidden, operand.hidden);
278
+ return operand.value;
279
+ }
280
+ function deepEqual(a, b) {
281
+ if (a === b) return true;
282
+ if (Array.isArray(a) && Array.isArray(b)) {
283
+ return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
284
+ }
285
+ return false;
286
+ }
287
+ function evaluate(condition, scope) {
288
+ if ("all" in condition) return condition.all.every((c) => evaluate(c, scope));
289
+ if ("any" in condition) return condition.any.some((c) => evaluate(c, scope));
290
+ if ("not" in condition) return !evaluate(condition.not, scope);
291
+ const left = resolve(condition.left, scope);
292
+ const right = condition.right ? resolve(condition.right, scope) : void 0;
293
+ const field = "field" in condition.left ? scope.fields.get(condition.left.field) : void 0;
294
+ const unanswered = isEmptyValue(left, field);
295
+ switch (condition.op) {
296
+ case "answered":
297
+ return !unanswered;
298
+ case "empty":
299
+ return unanswered;
300
+ case "eq":
301
+ return !unanswered && deepEqual(left, right);
302
+ case "neq":
303
+ return unanswered || !deepEqual(left, right);
304
+ case "lt":
305
+ case "lte":
306
+ case "gt":
307
+ case "gte": {
308
+ if (typeof left !== "number" || typeof right !== "number") return false;
309
+ if (condition.op === "lt") return left < right;
310
+ if (condition.op === "lte") return left <= right;
311
+ if (condition.op === "gt") return left > right;
312
+ return left >= right;
313
+ }
314
+ case "in":
315
+ return !unanswered && Array.isArray(right) && right.some((r) => deepEqual(r, left));
316
+ case "contains":
317
+ if (unanswered) return false;
318
+ if (Array.isArray(left)) return left.some((l) => deepEqual(l, right));
319
+ return typeof left === "string" && typeof right === "string" && left.toLowerCase().includes(right.toLowerCase());
320
+ }
321
+ }
322
+ function steps(def) {
323
+ return def.blocks.filter((b) => b.kind !== "page-break");
324
+ }
325
+ function visibilityRules(def, blockId) {
326
+ return (def.logic ?? []).filter(
327
+ (r) => (r.effect === "show" || r.effect === "hide") && r.target === blockId
328
+ );
329
+ }
330
+ function withheld(def, field) {
331
+ return def.settings?.anonymous === true && field?.identifying === true;
332
+ }
333
+ function rulesAllow(def, blockId, scope) {
334
+ if (withheld(def, scope.fields.get(blockId))) return false;
335
+ const rules = visibilityRules(def, blockId);
336
+ if (rules.some((r) => r.effect === "hide" && evaluate(r.when, scope))) return false;
337
+ const shows = rules.filter((r) => r.effect === "show");
338
+ return shows.length === 0 || shows.some((r) => evaluate(r.when, scope));
339
+ }
340
+ function walkPath(def, scope) {
341
+ const list = steps(def);
342
+ const index = new Map(list.map((b, i2) => [b.id, i2]));
343
+ const position = new Map(def.blocks.map((b, i2) => [b.id, i2]));
344
+ const jumps = (def.logic ?? []).filter(
345
+ (r) => r.effect === "jump"
346
+ );
347
+ const defaultEnding = def.endings?.find((e) => e.id === "default")?.id ?? def.endings?.[0]?.id ?? null;
348
+ const placed = /* @__PURE__ */ Object.create(null);
349
+ const local = { values: placed, hidden: scope.hidden, fields: scope.fields };
350
+ const path = [];
351
+ const nextVisible = (from) => {
352
+ for (let i2 = from; i2 < list.length; i2++) if (rulesAllow(def, list[i2].id, local)) return i2;
353
+ return list.length;
354
+ };
355
+ let ending = defaultEnding;
356
+ let i = nextVisible(0);
357
+ while (i < list.length) {
358
+ const block = list[i];
359
+ path.push(block.id);
360
+ if (block.kind === "field") placed[block.id] = own(scope.values, block.id);
361
+ const jump = jumps.find((r) => r.from === block.id && evaluate(r.when, local));
362
+ if (jump) {
363
+ if (typeof jump.to === "object") {
364
+ ending = jump.to.ending;
365
+ break;
366
+ }
367
+ const target = index.get(jump.to);
368
+ if (target !== void 0 && target > i) {
369
+ i = nextVisible(target);
370
+ continue;
371
+ }
372
+ }
373
+ i = nextVisible(i + 1);
374
+ }
375
+ const scopeAt = (blockId, inclusive = false) => {
376
+ const at = position.get(blockId) ?? -1;
377
+ const values = /* @__PURE__ */ Object.create(null);
378
+ for (const id of path) {
379
+ const p = position.get(id);
380
+ if (p > at || p === at && !inclusive) break;
381
+ if (id in placed) values[id] = placed[id];
382
+ }
383
+ return { values, hidden: scope.hidden, fields: scope.fields };
384
+ };
385
+ return { path, ending, scopeAt };
386
+ }
387
+ function computePath(def, scope) {
388
+ const { path, ending } = walkPath(def, scope);
389
+ return { path, ending };
390
+ }
391
+ function fieldMap(def) {
392
+ const map = /* @__PURE__ */ new Map();
393
+ for (const block of def.blocks) if (block.kind === "field") map.set(block.id, block);
394
+ return map;
395
+ }
396
+
397
+ // src/recall.ts
398
+ var TOKEN = /\{\{\s*(field|hidden):([A-Za-z0-9_-]+)\s*(?:\|([^}]*))?\}\}/g;
399
+ function recall(text, scope) {
400
+ return text.replace(TOKEN, (_match, kind, name, fallback) => {
401
+ if (kind === "field") {
402
+ const field = scope.fieldsByKey.get(name);
403
+ const out2 = field ? summarizeValue(own(scope.values, field.id), field) : "";
404
+ return out2 || (fallback ?? "").trim();
405
+ }
406
+ const value = own(scope.hidden, name);
407
+ const out = value === void 0 || value === null ? "" : String(value);
408
+ return out || (fallback ?? "").trim();
409
+ });
410
+ }
411
+ function recallRefs(text) {
412
+ return [...text.matchAll(TOKEN)].map((m) => ({ kind: m[1], name: m[2] }));
413
+ }
414
+
415
+ // src/validate.ts
416
+ var REQUIRED_MESSAGE = "This question needs an answer.";
417
+ function runCustom(validator, value) {
418
+ if (typeof validator === "function") return validator(value);
419
+ const result = validator["~standard"].validate(value);
420
+ if (result instanceof Promise) {
421
+ throw new Error("Async validators are not supported in the engine yet; run them on the server.");
422
+ }
423
+ const issues = result.issues;
424
+ return issues?.length ? issues[0].message : null;
425
+ }
426
+ function validateField(field, value, options = {}) {
427
+ if (isEmptyValue(value, field)) {
428
+ return field.required && options.requireAnswers !== false ? REQUIRED_MESSAGE : null;
429
+ }
430
+ const manifest = getFieldType(field.type);
431
+ if (!manifest) return `Unknown field type "${field.type}".`;
432
+ const builtIn = manifest.validate?.(value, field) ?? null;
433
+ if (builtIn) return builtIn;
434
+ return options.custom ? runCustom(options.custom, value) : null;
435
+ }
436
+
437
+ // src/engine.ts
438
+ function createForm(definition, options = {}) {
439
+ return build(definition, options).engine;
440
+ }
441
+ function build(definition, options, replaying = false) {
442
+ const fields = fieldMap(definition);
443
+ const byKey = new Map([...fields.values()].map((f) => [f.key, f]));
444
+ const blocks = new Map(definition.blocks.map((b) => [b.id, b]));
445
+ const clock = definition.settings?.anonymous === true ? () => void 0 : options.clock ?? (() => Date.now());
446
+ const listeners = /* @__PURE__ */ new Set();
447
+ let batch = replaying;
448
+ const scopeOf = (values, hidden) => ({
449
+ values,
450
+ hidden,
451
+ fields
452
+ });
453
+ const declared = new Set((definition.hidden ?? []).map((h) => h.name));
454
+ const initialHidden = {};
455
+ for (const [name, value] of Object.entries(options.hidden ?? {})) {
456
+ if (!declared.has(name)) initialHidden[name] = value;
457
+ }
458
+ Object.assign(initialHidden, coerceHidden(definition, options.hidden).values);
459
+ const first = computePath(definition, scopeOf({}, initialHidden));
460
+ let state = {
461
+ values: {},
462
+ hidden: initialHidden,
463
+ path: first.path,
464
+ current: first.path[0] ?? null,
465
+ history: [],
466
+ errors: {},
467
+ status: "in_progress",
468
+ ending: null,
469
+ events: []
470
+ };
471
+ const emit = (next) => {
472
+ state = next;
473
+ if (batch) return;
474
+ for (const l of listeners) l();
475
+ };
476
+ const log = (event) => {
477
+ if (!batch) return [...state.events, event];
478
+ state.events.push(event);
479
+ return state.events;
480
+ };
481
+ const stamp = (event) => {
482
+ const at = clock();
483
+ return at === void 0 ? event : { ...event, at };
484
+ };
485
+ const isVisibleIn = (s, id) => s.path.includes(id);
486
+ const errorsFor = (s, ids) => {
487
+ const errors = {};
488
+ for (const id of ids) {
489
+ const field = fields.get(id);
490
+ if (!field || !isVisibleIn(s, id)) continue;
491
+ const message = validateField(field, own(s.values, id), { custom: options.validators?.[id] });
492
+ if (message) errors[id] = message;
493
+ }
494
+ return errors;
495
+ };
496
+ const answer = (id, value, event) => {
497
+ if (state.status === "completed" || own(state.values, id) === value) return;
498
+ const values = { ...state.values, [id]: value };
499
+ const { path } = computePath(definition, scopeOf(values, state.hidden));
500
+ const errors = { ...state.errors };
501
+ delete errors[id];
502
+ emit({ ...state, values, path, errors, events: log(event) });
503
+ };
504
+ const engine = {
505
+ definition,
506
+ getState: () => state,
507
+ subscribe(listener) {
508
+ listeners.add(listener);
509
+ return () => listeners.delete(listener);
510
+ },
511
+ idOf(key) {
512
+ const field = byKey.get(key);
513
+ if (!field) throw new Error(`No field with key "${key}".`);
514
+ return field.id;
515
+ },
516
+ field: (id) => fields.get(id),
517
+ block: (id) => blocks.get(id),
518
+ isVisible: (id) => isVisibleIn(state, id),
519
+ setValue(id, value) {
520
+ if (!fields.has(id)) throw new Error(`No field "${id}".`);
521
+ answer(id, value, stamp({ type: "answer", field: id, value }));
522
+ },
523
+ next() {
524
+ if (!state.current || state.status === "completed") return false;
525
+ const errors = errorsFor(state, [state.current]);
526
+ const events = log(stamp({ type: "next" }));
527
+ if (Object.keys(errors).length) {
528
+ emit({ ...state, errors: { ...state.errors, ...errors }, events });
529
+ return false;
530
+ }
531
+ const at = state.path.indexOf(state.current);
532
+ const target = state.path[at + 1];
533
+ if (!target) {
534
+ emit({ ...state, events });
535
+ return true;
536
+ }
537
+ emit({ ...state, current: target, history: [...state.history, state.current], events });
538
+ return true;
539
+ },
540
+ back() {
541
+ if (state.status === "completed") return;
542
+ const history = [...state.history];
543
+ let previous = history.pop();
544
+ while (previous && !state.path.includes(previous)) previous = history.pop();
545
+ if (!previous) return;
546
+ emit({ ...state, current: previous, history, events: log(stamp({ type: "back" })) });
547
+ },
548
+ submit() {
549
+ if (state.status === "completed") return false;
550
+ const errors = errorsFor(state, state.path);
551
+ const events = log(stamp({ type: "submit" }));
552
+ if (Object.keys(errors).length) {
553
+ const firstError = state.path.find((id) => own(errors, id)) ?? state.current;
554
+ emit({ ...state, errors, current: firstError, events });
555
+ return false;
556
+ }
557
+ const { ending } = computePath(definition, scopeOf(state.values, state.hidden));
558
+ emit({ ...state, errors: {}, status: "completed", ending, events });
559
+ return true;
560
+ },
561
+ text: (template) => recall(template, { fieldsByKey: byKey, values: state.values, hidden: state.hidden }),
562
+ answers() {
563
+ const out = {};
564
+ for (const id of state.path) {
565
+ const field = fields.get(id);
566
+ const value = own(state.values, id);
567
+ if (field && value !== void 0) out[id] = normalizeValue(value, field);
568
+ }
569
+ return out;
570
+ }
571
+ };
572
+ if (options.initialValues) {
573
+ for (const [id, value] of Object.entries(options.initialValues)) {
574
+ if (!fields.has(id) || value === void 0) continue;
575
+ answer(id, value, stamp({ type: "answer", field: id, value, source: "prefill" }));
576
+ }
577
+ }
578
+ const apply = (event) => {
579
+ if (event.type === "answer") {
580
+ if (fields.has(event.field)) {
581
+ const { at: _at, ...logged } = event;
582
+ answer(event.field, event.value, logged);
583
+ }
584
+ } else if (event.type === "next") engine.next();
585
+ else if (event.type === "back") engine.back();
586
+ else if (event.type === "submit") engine.submit();
587
+ };
588
+ const finish = () => {
589
+ batch = false;
590
+ state = { ...state, events: [...state.events] };
591
+ };
592
+ return { engine, apply, finish };
593
+ }
594
+ function replay(definition, events, options = {}) {
595
+ const { engine, apply, finish } = build(definition, { ...options, initialValues: void 0, clock: () => void 0 }, true);
596
+ for (const event of events) apply(event);
597
+ finish();
598
+ return engine;
599
+ }
600
+
601
+ // src/parse.ts
602
+ var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
603
+ var OPERATORS = /* @__PURE__ */ new Set([
604
+ "eq",
605
+ "neq",
606
+ "lt",
607
+ "lte",
608
+ "gt",
609
+ "gte",
610
+ "in",
611
+ "contains",
612
+ "answered",
613
+ "empty"
614
+ ]);
615
+ var RESERVED = new Set(Object.getOwnPropertyNames(Object.prototype));
616
+ var MAX_CONDITION_DEPTH = 32;
617
+ function parseForm(input) {
618
+ const issues = [];
619
+ const warnings = [];
620
+ const fail = () => ({ ok: false, issues, warnings });
621
+ const issue = (path, message) => void issues.push({ path, message });
622
+ const text = (v, path, nonEmpty = false) => {
623
+ if (typeof v !== "string" || nonEmpty && !v) issue(path, nonEmpty ? "Required." : "Required text.");
624
+ };
625
+ const optionalText = (v, path) => {
626
+ if (v !== void 0 && typeof v !== "string") issue(path, "Must be text.");
627
+ };
628
+ const optionalBoolean = (v, path) => {
629
+ if (v !== void 0 && typeof v !== "boolean") issue(path, "Must be true or false.");
630
+ };
631
+ const name = (v, path, what) => {
632
+ if (typeof v !== "string" || !v) {
633
+ issue(path, `${what} must be a non-empty string.`);
634
+ return false;
635
+ }
636
+ if (RESERVED.has(v)) {
637
+ issue(path, `"${v}" is reserved and cannot be used as ${what.toLowerCase()}.`);
638
+ return false;
639
+ }
640
+ return true;
641
+ };
642
+ if (!isObject(input)) {
643
+ issue("", "A form definition must be an object.");
644
+ return fail();
645
+ }
646
+ if (input.schemaVersion !== 1) issue("schemaVersion", "Unsupported schemaVersion; expected 1.");
647
+ text(input.id, "id", true);
648
+ text(input.title, "title");
649
+ optionalText(input.description, "description");
650
+ if (input.layout !== void 0) {
651
+ if (!isObject(input.layout)) issue("layout", "Must be an object.");
652
+ else if (input.layout.mode !== "focus" && input.layout.mode !== "classic") {
653
+ issue("layout.mode", "Must be focus or classic.");
654
+ }
655
+ }
656
+ if (input.settings !== void 0) {
657
+ if (!isObject(input.settings)) issue("settings", "Must be an object.");
658
+ else {
659
+ optionalBoolean(input.settings.anonymous, "settings.anonymous");
660
+ optionalBoolean(input.settings.clearHiddenValues, "settings.clearHiddenValues");
661
+ }
662
+ }
663
+ if (input.meta !== void 0 && (!isObject(input.meta) || !isJson(input.meta))) {
664
+ issue("meta", "Must be a JSON object.");
665
+ }
666
+ if (!Array.isArray(input.blocks)) {
667
+ issue("blocks", "Must be an array.");
668
+ return fail();
669
+ }
670
+ const ids = /* @__PURE__ */ new Set();
671
+ const pageBreaks = /* @__PURE__ */ new Set();
672
+ const keys = /* @__PURE__ */ new Map();
673
+ const fieldTypes = /* @__PURE__ */ new Map();
674
+ const order = /* @__PURE__ */ new Map();
675
+ input.blocks.forEach((block, i) => {
676
+ const path = `blocks[${i}]`;
677
+ if (!isObject(block) || typeof block.id !== "string" || !block.id) {
678
+ issue(path, "Every block needs a string id.");
679
+ return;
680
+ }
681
+ if (!name(block.id, `${path}.id`, "A block id")) return;
682
+ if (ids.has(block.id)) issue(`${path}.id`, `Duplicate id "${block.id}".`);
683
+ ids.add(block.id);
684
+ if (block.kind === "page-break") pageBreaks.add(block.id);
685
+ order.set(block.id, i);
686
+ if (block.kind === "field") {
687
+ text(block.label, `${path}.label`);
688
+ optionalText(block.description, `${path}.description`);
689
+ optionalText(block.placeholder, `${path}.placeholder`);
690
+ optionalBoolean(block.required, `${path}.required`);
691
+ optionalBoolean(block.sensitive, `${path}.sensitive`);
692
+ optionalBoolean(block.identifying, `${path}.identifying`);
693
+ const validKey = name(block.key, `${path}.key`, "A field key");
694
+ if (typeof block.type !== "string" || !getFieldType(block.type)) {
695
+ issue(`${path}.type`, `Unknown field type "${String(block.type)}".`);
696
+ }
697
+ if (block.props !== void 0) {
698
+ if (!isObject(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
699
+ else if (block.props.options !== void 0) {
700
+ const options = block.props.options;
701
+ if (!Array.isArray(options)) issue(`${path}.props.options`, "Must be an array.");
702
+ else {
703
+ options.forEach((o, j) => {
704
+ const at = `${path}.props.options[${j}]`;
705
+ if (!isObject(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
706
+ else optionalText(o.label, `${at}.label`);
707
+ });
708
+ }
709
+ }
710
+ }
711
+ if (validKey) {
712
+ if (keys.has(block.key)) issue(`${path}.key`, `Duplicate key "${String(block.key)}".`);
713
+ keys.set(block.key, block.id);
714
+ }
715
+ fieldTypes.set(block.id, typeof block.type === "string" ? block.type : "");
716
+ } else if (block.kind === "content") {
717
+ if (block.type !== "heading" && block.type !== "text") issue(`${path}.type`, "Must be heading or text.");
718
+ text(block.text, `${path}.text`);
719
+ } else if (block.kind === "page-break") {
720
+ optionalText(block.title, `${path}.title`);
721
+ } else {
722
+ issue(`${path}.kind`, "Unknown block kind.");
723
+ }
724
+ });
725
+ const hiddenNames = /* @__PURE__ */ new Set();
726
+ if (input.hidden !== void 0 && !Array.isArray(input.hidden)) issue("hidden", "Must be an array.");
727
+ (Array.isArray(input.hidden) ? input.hidden : []).forEach((h, i) => {
728
+ if (!isObject(h) || typeof h.name !== "string" || !h.name) {
729
+ issue(`hidden[${i}]`, "Each hidden input needs a name.");
730
+ return;
731
+ }
732
+ if (!name(h.name, `hidden[${i}].name`, "A hidden input name")) return;
733
+ if (hiddenNames.has(h.name)) issue(`hidden[${i}].name`, `Duplicate hidden input "${h.name}".`);
734
+ if (typeof h.source !== "string" || !["url", "host", "server"].includes(h.source)) {
735
+ issue(`hidden[${i}].source`, "Must be url, host or server.");
736
+ }
737
+ if (typeof h.type !== "string" || !["string", "number", "boolean"].includes(h.type)) {
738
+ issue(`hidden[${i}].type`, "Must be string, number or boolean.");
739
+ }
740
+ optionalBoolean(h.sensitive, `hidden[${i}].sensitive`);
741
+ hiddenNames.add(h.name);
742
+ });
743
+ const endingIds = /* @__PURE__ */ new Set();
744
+ if (input.endings !== void 0 && !Array.isArray(input.endings)) issue("endings", "Must be an array.");
745
+ (Array.isArray(input.endings) ? input.endings : []).forEach((e, i) => {
746
+ const path = `endings[${i}]`;
747
+ if (!isObject(e)) {
748
+ issue(path, "Each ending must be an object with an id and a title.");
749
+ return;
750
+ }
751
+ text(e.title, `${path}.title`);
752
+ optionalText(e.description, `${path}.description`);
753
+ if (!name(e.id, `${path}.id`, "An ending id")) return;
754
+ if (endingIds.has(e.id)) issue(`${path}.id`, `Duplicate ending id "${e.id}".`);
755
+ endingIds.add(e.id);
756
+ });
757
+ const checkOperand = (o, path, reads) => {
758
+ if (o === void 0) return;
759
+ if (!isObject(o)) return issue(path, "An operand must be an object.");
760
+ if ("field" in o) {
761
+ if (typeof o.field !== "string" || !fieldTypes.has(o.field)) return issue(path, `No field "${String(o.field)}".`);
762
+ reads.push(o.field);
763
+ } else if ("hidden" in o) {
764
+ if (typeof o.hidden !== "string" || !hiddenNames.has(o.hidden)) {
765
+ issue(path, `No hidden input "${String(o.hidden)}".`);
766
+ }
767
+ } else if (!("value" in o) || !isJson(o.value)) {
768
+ issue(path, "An operand needs a `field`, a `hidden` input or a JSON `value`.");
769
+ }
770
+ };
771
+ const checkCondition = (c, path, reads, depth = 0) => {
772
+ if (!isObject(c)) return issue(path, "A condition must be an object.");
773
+ if (depth > MAX_CONDITION_DEPTH) return issue(path, "The condition is nested too deeply.");
774
+ if ("all" in c || "any" in c) {
775
+ const group = "all" in c ? "all" : "any";
776
+ const list = c[group];
777
+ if (!Array.isArray(list)) return issue(path, `\`${group}\` must be an array.`);
778
+ return list.forEach((x, i) => checkCondition(x, `${path}.${group}[${i}]`, reads, depth + 1));
779
+ }
780
+ if ("not" in c) return checkCondition(c.not, `${path}.not`, reads, depth + 1);
781
+ if (typeof c.op !== "string" || !isObject(c.left)) {
782
+ return issue(path, "A comparison needs an `op` and a `left` operand.");
783
+ }
784
+ if (!OPERATORS.has(c.op)) issue(`${path}.op`, `Unknown operator "${c.op}".`);
785
+ checkOperand(c.left, `${path}.left`, reads);
786
+ checkOperand(c.right, `${path}.right`, reads);
787
+ if (typeof c.left.field === "string") {
788
+ const manifest = getFieldType(fieldTypes.get(c.left.field) ?? "");
789
+ if (manifest && OPERATORS.has(c.op) && !manifest.operators.includes(c.op)) {
790
+ issue(`${path}.op`, `"${c.op}" does not apply to a ${manifest.type} field.`);
791
+ }
792
+ }
793
+ };
794
+ const keyOf = (id) => [...keys].find(([, v]) => v === id)?.[0] ?? id;
795
+ const ruleIds = /* @__PURE__ */ new Set();
796
+ if (input.logic !== void 0 && !Array.isArray(input.logic)) issue("logic", "Must be an array.");
797
+ (Array.isArray(input.logic) ? input.logic : []).forEach((rule, i) => {
798
+ const path = `logic[${i}]`;
799
+ if (!isObject(rule) || typeof rule.id !== "string" || !isObject(rule.when)) {
800
+ issue(path, "Each rule needs an id and a `when` condition.");
801
+ return;
802
+ }
803
+ if (ruleIds.has(rule.id)) issue(`${path}.id`, `Duplicate rule id "${rule.id}".`);
804
+ ruleIds.add(rule.id);
805
+ const reads = [];
806
+ checkCondition(rule.when, `${path}.when`, reads);
807
+ let at;
808
+ let inclusive = false;
809
+ if (rule.effect === "jump") {
810
+ if (typeof rule.from !== "string" || !ids.has(rule.from)) {
811
+ issue(`${path}.from`, `No block "${String(rule.from)}".`);
812
+ } else if (pageBreaks.has(rule.from)) {
813
+ issue(`${path}.from`, "A jump can't start at a page break; use the block before it.");
814
+ } else {
815
+ at = rule.from;
816
+ inclusive = true;
817
+ }
818
+ if (isObject(rule.to)) {
819
+ if (typeof rule.to.ending !== "string" || !endingIds.has(rule.to.ending)) {
820
+ issue(`${path}.to`, `No ending "${String(rule.to.ending)}".`);
821
+ }
822
+ } else if (typeof rule.to !== "string" || !ids.has(rule.to)) {
823
+ issue(`${path}.to`, `No block "${String(rule.to)}".`);
824
+ } else if (pageBreaks.has(rule.to)) {
825
+ issue(`${path}.to`, "A jump can't land on a page break; jump to the first block after it.");
826
+ } else if (at !== void 0 && (order.get(rule.to) ?? 0) <= (order.get(at) ?? 0)) {
827
+ warnings.push({ path: `${path}.to`, message: "Jumps only go forward; this one is ignored." });
828
+ }
829
+ } else if (rule.effect === "show" || rule.effect === "hide") {
830
+ if (typeof rule.target !== "string" || !ids.has(rule.target)) {
831
+ issue(`${path}.target`, `No block "${String(rule.target)}".`);
832
+ } else at = rule.target;
833
+ } else {
834
+ issue(`${path}.effect`, "Must be show, hide or jump.");
835
+ }
836
+ if (at !== void 0) {
837
+ const limit = order.get(at) ?? 0;
838
+ for (const field of new Set(reads)) {
839
+ const p = order.get(field) ?? 0;
840
+ if (p > limit || p === limit && !inclusive) {
841
+ warnings.push({
842
+ path: `${path}.when`,
843
+ message: `Reads "${keyOf(field)}", which does not come before "${keyOf(at)}"; it always reads as unanswered.`
844
+ });
845
+ }
846
+ }
847
+ }
848
+ });
849
+ input.blocks.forEach((block, i) => {
850
+ if (!isObject(block)) return;
851
+ const raw = block.kind === "field" ? [block.label, block.description] : block.kind === "content" ? [block.text] : [];
852
+ const texts = raw.filter((t) => typeof t === "string");
853
+ for (const t of texts) {
854
+ for (const ref of recallRefs(t)) {
855
+ if (ref.kind === "hidden" && !hiddenNames.has(ref.name)) {
856
+ issue(`blocks[${i}]`, `Recalls unknown hidden input "${ref.name}".`);
857
+ }
858
+ if (ref.kind === "field") {
859
+ const target = keys.get(ref.name);
860
+ if (!target) issue(`blocks[${i}]`, `Recalls unknown field "${ref.name}".`);
861
+ else if ((order.get(target) ?? 0) >= i) {
862
+ warnings.push({ path: `blocks[${i}]`, message: `Recalls "${ref.name}", which comes later.` });
863
+ }
864
+ }
865
+ }
866
+ }
867
+ });
868
+ return issues.length ? fail() : { ok: true, form: input, warnings };
869
+ }
870
+
871
+ // src/server.ts
872
+ var MAX_EVENTS = 1e4;
873
+ var MALFORMED = { ok: false, errors: { "": "Malformed submission." } };
874
+ var parsedDefinitions = /* @__PURE__ */ new WeakMap();
875
+ function checkDefinition(definition) {
876
+ if (typeof definition === "object" && definition !== null && parsedDefinitions.has(definition)) return null;
877
+ const parsed = parseForm(definition);
878
+ if (!parsed.ok) {
879
+ const list = parsed.issues.map((i) => i.path ? `${i.path}: ${i.message}` : i.message).join("; ");
880
+ return `Invalid form definition: ${list}`;
881
+ }
882
+ parsedDefinitions.set(definition, true);
883
+ return null;
884
+ }
885
+ function wellFormed(input) {
886
+ if (!isPlainObject(input)) return null;
887
+ const { answers, events, hidden } = input;
888
+ const record = (v) => v === void 0 || isPlainObject(v) && Object.keys(v).every((k) => v[k] === void 0 || isJson(v[k]));
889
+ if (!record(answers) || !record(hidden)) return null;
890
+ if (events === void 0) return { answers, hidden };
891
+ if (!Array.isArray(events)) return null;
892
+ const known = [];
893
+ for (const e of events) {
894
+ if (isFormEvent(e)) known.push(e);
895
+ else if (!isUnknownEvent(e)) return null;
896
+ }
897
+ return { answers, events: known, hidden };
898
+ }
899
+ function validateSubmission(definition, submission, options = {}) {
900
+ const invalid = checkDefinition(definition);
901
+ if (invalid) return { ok: false, errors: { "": invalid } };
902
+ const input = wellFormed(submission);
903
+ if (!input) return MALFORMED;
904
+ const ignored = [];
905
+ const client = coerceHidden(definition, input.hidden, { sources: ["url", "host"] });
906
+ for (const name of client.ignored) ignored.push(`hidden.${name}`);
907
+ const hidden = client.values;
908
+ const context = coerceHidden(definition, options.context, { sources: ["server"] }).values;
909
+ const fields = fieldMap(definition);
910
+ const dropped = /* @__PURE__ */ new Set();
911
+ const accept = (id) => {
912
+ if (!fields.has(id)) return void ignored.push(id);
913
+ if (!withheld(definition, fields.get(id))) return true;
914
+ if (!dropped.has(id)) ignored.push(id);
915
+ dropped.add(id);
916
+ return false;
917
+ };
918
+ const engineHidden = { ...hidden, ...context };
919
+ let engine;
920
+ if (input.events) {
921
+ const events = compactEvents(input.events);
922
+ const max = options.maxEvents ?? MAX_EVENTS;
923
+ if (events.length > max) {
924
+ return { ok: false, errors: { "": `Too many events: ${events.length} after compaction; the limit is ${max}.` } };
925
+ }
926
+ const kept = events.filter((e) => e.type !== "answer" || accept(e.field) !== false);
927
+ engine = replay(definition, kept, { hidden: engineHidden, validators: options.validators });
928
+ } else {
929
+ const answers2 = {};
930
+ for (const id of Object.keys(input.answers ?? {})) {
931
+ const value = own(input.answers, id);
932
+ if (accept(id) && value !== void 0) answers2[id] = value;
933
+ }
934
+ engine = createForm(definition, {
935
+ initialValues: answers2,
936
+ hidden: engineHidden,
937
+ validators: options.validators,
938
+ clock: () => void 0
939
+ });
940
+ }
941
+ const complete = options.complete !== false;
942
+ if (complete) {
943
+ if (engine.getState().status !== "completed" && !engine.submit()) {
944
+ return { ok: false, errors: engine.getState().errors };
945
+ }
946
+ } else {
947
+ const errors = {};
948
+ for (const [id, value] of Object.entries(engine.answers())) {
949
+ const message = validateField(fields.get(id), value, {
950
+ requireAnswers: false,
951
+ custom: options.validators?.[id]
952
+ });
953
+ if (message) errors[id] = message;
954
+ }
955
+ if (Object.keys(errors).length) return { ok: false, errors };
956
+ }
957
+ const state = engine.getState();
958
+ const answers = engine.answers();
959
+ if (definition.settings?.clearHiddenValues === false) {
960
+ for (const [id, field] of fields) {
961
+ if (withheld(definition, field)) continue;
962
+ const value = own(state.values, id);
963
+ if (own(answers, id) !== void 0 || value === void 0 || isEmptyValue(value, field)) continue;
964
+ const message = validateField(field, value, { requireAnswers: false, custom: options.validators?.[id] });
965
+ if (message === null) answers[id] = normalizeValue(value, field);
966
+ }
967
+ }
968
+ return {
969
+ ok: true,
970
+ submission: {
971
+ status: complete ? "completed" : "draft",
972
+ answers,
973
+ hidden,
974
+ context,
975
+ ending: complete ? state.ending : null,
976
+ ignored
977
+ }
978
+ };
979
+ }
980
+ function publicDefinition(definition) {
981
+ return JSON.parse(JSON.stringify(definition));
982
+ }
983
+
984
+ exports.MAX_EVENTS = MAX_EVENTS;
985
+ exports.publicDefinition = publicDefinition;
986
+ exports.validateSubmission = validateSubmission;
987
+ //# sourceMappingURL=server.cjs.map
988
+ //# sourceMappingURL=server.cjs.map