@marktiderman/genesis-forms 0.1.1 → 0.3.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 (42) hide show
  1. package/README.md +121 -17
  2. package/conformance/{after-class-back-after-a-jump.scenario.json → event-feedback-back-after-a-jump.scenario.json} +21 -21
  3. package/conformance/{after-class-contact-yes-completed.scenario.json → event-feedback-contact-yes-completed.scenario.json} +21 -21
  4. package/conformance/{after-class-contact-yes-requires-name-and-email.scenario.json → event-feedback-contact-yes-requires-name-and-email.scenario.json} +19 -19
  5. package/conformance/{after-class-happy-path.scenario.json → event-feedback-happy-path.scenario.json} +24 -24
  6. package/conformance/{after-class-invalid-email-rejected.scenario.json → event-feedback-invalid-email-rejected.scenario.json} +19 -19
  7. package/conformance/{after-class-low-rating-skips-went-well.scenario.json → event-feedback-low-rating-skips-went-well.scenario.json} +21 -21
  8. package/conformance/{after-class-server-drops-unknown-and-hidden-answers.scenario.json → event-feedback-server-drops-unknown-and-hidden-answers.scenario.json} +22 -22
  9. package/conformance/{after-class-server-ignores-a-client-sent-coach.scenario.json → event-feedback-server-ignores-a-client-sent-owner.scenario.json} +24 -24
  10. package/conformance/{monthly-pulse-default-ending.scenario.json → monthly-check-in-default-ending.scenario.json} +6 -6
  11. package/conformance/{monthly-pulse-promoter-ending-via-jump.scenario.json → monthly-check-in-promoter-ending-via-jump.scenario.json} +6 -6
  12. package/dist/destinations.cjs +1211 -0
  13. package/dist/destinations.cjs.map +1 -0
  14. package/dist/destinations.d.cts +465 -0
  15. package/dist/destinations.d.ts +465 -0
  16. package/dist/destinations.js +1169 -0
  17. package/dist/destinations.js.map +1 -0
  18. package/dist/{engine-LsPRTzt3.d.cts → engine-CPLtKVew.d.cts} +7 -6
  19. package/dist/{engine-Cp5t2_Am.d.ts → engine-DOrPgFDw.d.ts} +7 -6
  20. package/dist/index.cjs +410 -24
  21. package/dist/index.cjs.map +1 -1
  22. package/dist/index.d.cts +122 -18
  23. package/dist/index.d.ts +122 -18
  24. package/dist/index.js +399 -25
  25. package/dist/index.js.map +1 -1
  26. package/dist/server.cjs +350 -29
  27. package/dist/server.cjs.map +1 -1
  28. package/dist/server.d.cts +13 -12
  29. package/dist/server.d.ts +13 -12
  30. package/dist/server.js +350 -29
  31. package/dist/server.js.map +1 -1
  32. package/dist/testing.cjs +354 -29
  33. package/dist/testing.cjs.map +1 -1
  34. package/dist/testing.d.cts +4 -3
  35. package/dist/testing.d.ts +4 -3
  36. package/dist/testing.js +354 -29
  37. package/dist/testing.js.map +1 -1
  38. package/dist/{validate-j8Z188mF.d.cts → types-BBKBQ8sp.d.cts} +9 -29
  39. package/dist/{validate-j8Z188mF.d.ts → types-BBKBQ8sp.d.ts} +9 -29
  40. package/dist/validate-BXXqi0fq.d.cts +30 -0
  41. package/dist/validate-BjNwF1UE.d.ts +30 -0
  42. package/package.json +14 -2
@@ -1,6 +1,7 @@
1
- import { a as FormState, F as FormEngine, C as CreateFormOptions } from './engine-LsPRTzt3.cjs';
2
- export { d as compactEvents } from './engine-LsPRTzt3.cjs';
3
- import { J as Json, a as FormDefinition, f as FormEvent } from './validate-j8Z188mF.cjs';
1
+ import { a as FormState, F as FormEngine, C as CreateFormOptions } from './engine-CPLtKVew.cjs';
2
+ export { d as compactEvents } from './engine-CPLtKVew.cjs';
3
+ import { J as Json, F as FormDefinition, e as FormEvent } from './types-BBKBQ8sp.cjs';
4
+ import './validate-BXXqi0fq.cjs';
4
5
 
5
6
  interface CoverageOptions {
6
7
  /** Stop after this many explored states and report `truncated`. Default 5000. */
package/dist/testing.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { a as FormState, F as FormEngine, C as CreateFormOptions } from './engine-Cp5t2_Am.js';
2
- export { d as compactEvents } from './engine-Cp5t2_Am.js';
3
- import { J as Json, a as FormDefinition, f as FormEvent } from './validate-j8Z188mF.js';
1
+ import { a as FormState, F as FormEngine, C as CreateFormOptions } from './engine-DOrPgFDw.js';
2
+ export { d as compactEvents } from './engine-DOrPgFDw.js';
3
+ import { J as Json, F as FormDefinition, e as FormEvent } from './types-BBKBQ8sp.js';
4
+ import './validate-BjNwF1UE.js';
4
5
 
5
6
  interface CoverageOptions {
6
7
  /** Stop after this many explored states and report `truncated`. Default 5000. */
package/dist/testing.js CHANGED
@@ -1,3 +1,185 @@
1
+ // src/structured.ts
2
+ var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
3
+ function intProp(field, name, fallback, floor = 1) {
4
+ const v = field.props?.[name];
5
+ return typeof v === "number" && Number.isInteger(v) && v >= floor ? v : fallback;
6
+ }
7
+ function optionalIntProp(field, name, floor = 0) {
8
+ const v = field.props?.[name];
9
+ return typeof v === "number" && Number.isInteger(v) && v >= floor ? v : void 0;
10
+ }
11
+ var blank = (v) => typeof v !== "string" || v.trim() === "";
12
+ var blankText = (v) => typeof v === "string" && v.trim() === "";
13
+ var emptyList = (value, isBlank) => value === void 0 || value === null || Array.isArray(value) && value.every(isBlank);
14
+ var plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
15
+ function listLimits(field) {
16
+ return { min: optionalIntProp(field, "min") ?? 0, max: intProp(field, "max", 10), itemMaxLength: intProp(field, "itemMaxLength", 500) };
17
+ }
18
+ var listItems = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string" && v.trim() !== "") : [];
19
+ var MAX_TABLE_COLUMNS = 6;
20
+ var MAX_TABLE_CELL_LENGTH = 2e3;
21
+ function tableColumns(field) {
22
+ const raw = field.props?.columns;
23
+ if (!Array.isArray(raw)) return [];
24
+ const seen = /* @__PURE__ */ new Set();
25
+ const out = [];
26
+ for (const c of raw) {
27
+ if (!isObject(c) || typeof c.key !== "string" || !c.key || seen.has(c.key)) continue;
28
+ seen.add(c.key);
29
+ const column = { key: c.key, label: typeof c.label === "string" ? c.label : c.key };
30
+ if (c.long === true) column.long = true;
31
+ out.push(column);
32
+ }
33
+ return out;
34
+ }
35
+ function tableLimits(field) {
36
+ return { minRows: optionalIntProp(field, "minRows") ?? 0, maxRows: intProp(field, "maxRows", 3) };
37
+ }
38
+ var filledRows = (value) => Array.isArray(value) ? value.filter((r) => isObject(r) && Object.values(r).some((c) => !blank(c))) : [];
39
+ var MAX_TIMESTAMP_SECONDS = 86400;
40
+ function formatTimestamp(seconds) {
41
+ const total = Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds) : 0;
42
+ const h = Math.floor(total / 3600);
43
+ const m = Math.floor(total % 3600 / 60);
44
+ const s = String(total % 60).padStart(2, "0");
45
+ return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
46
+ }
47
+ var isTimestamp = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= MAX_TIMESTAMP_SECONDS;
48
+ var notesWithText = (value) => Array.isArray(value) ? value.filter((n) => isObject(n) && !blank(n.text)) : [];
49
+ var MAX_HIGHLIGHT_NOTE_LENGTH = 1e3;
50
+ var MAX_HIGHLIGHT_QUOTE_LENGTH = 2e3;
51
+ var HIGHLIGHT_NOTE_MESSAGE = "Add a note to each highlight.";
52
+ var sourceText = (field) => typeof field.props?.text === "string" && field.props.text ? field.props.text : void 0;
53
+ var structuredFieldTypes = [
54
+ {
55
+ type: "list",
56
+ label: "List",
57
+ category: "text",
58
+ value: { json: true },
59
+ // logic.ts `contains` on an array is exact item equality, same as multi-choice, so it is offered.
60
+ operators: ["contains", "answered", "empty"],
61
+ a11y: { semantics: "group" },
62
+ isEmpty: (value) => emptyList(value, blankText),
63
+ validate: (value, field) => {
64
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) return "Enter a list of answers.";
65
+ const { min, max, itemMaxLength } = listLimits(field);
66
+ if (value.some((v) => v.trim().length > itemMaxLength)) return `Keep each item under ${itemMaxLength} characters.`;
67
+ const count = listItems(value).length;
68
+ if (count < min) return `Add at least ${plural(min, "item")}.`;
69
+ if (count > max) return `Add at most ${plural(max, "item")}.`;
70
+ return null;
71
+ },
72
+ normalize: (value) => Array.isArray(value) ? listItems(value).map((v) => v.trim()) : value,
73
+ summarize: (value) => listItems(value).map((v) => v.trim()).join("; "),
74
+ samples: (field) => {
75
+ const { min, max } = listLimits(field);
76
+ return [Array.from({ length: Math.min(Math.max(1, min), max) }, (_, i) => `Item ${i + 1}`)];
77
+ }
78
+ },
79
+ {
80
+ type: "table",
81
+ label: "Table",
82
+ category: "advanced",
83
+ value: { json: true },
84
+ operators: ["answered", "empty"],
85
+ a11y: { semantics: "group" },
86
+ isEmpty: (value) => emptyList(value, (r) => isObject(r) && Object.values(r).every(blankText)),
87
+ validate: (value, field) => {
88
+ const columns = tableColumns(field);
89
+ if (!columns.length) return "This question has no columns.";
90
+ if (!Array.isArray(value) || !value.every(isObject)) return "Fill in the rows.";
91
+ const keys = new Set(columns.map((c) => c.key));
92
+ for (const row of value) {
93
+ for (const [key, cell] of Object.entries(row)) {
94
+ if (!keys.has(key)) return "An answer names a column this question doesn't have.";
95
+ if (typeof cell !== "string") return "Fill in the rows with text.";
96
+ if (cell.trim().length > MAX_TABLE_CELL_LENGTH) return `Keep each answer under ${MAX_TABLE_CELL_LENGTH} characters.`;
97
+ }
98
+ }
99
+ const { minRows, maxRows } = tableLimits(field);
100
+ const count = filledRows(value).length;
101
+ if (count < minRows) return `Fill in at least ${plural(minRows, "row")}.`;
102
+ if (count > maxRows) return `Fill in at most ${plural(maxRows, "row")}.`;
103
+ return null;
104
+ },
105
+ normalize: (value) => Array.isArray(value) ? filledRows(value).map((row) => {
106
+ const out = {};
107
+ for (const [key, cell] of Object.entries(row)) if (typeof cell === "string") out[key] = cell.trim();
108
+ return out;
109
+ }) : value,
110
+ summarize: (value, field) => {
111
+ const columns = tableColumns(field);
112
+ return filledRows(value).map((row) => columns.filter((c) => !blank(row[c.key])).map((c) => `${c.label}: ${row[c.key].trim()}`).join(" \xB7 ")).join(" / ");
113
+ },
114
+ samples: (field) => {
115
+ const columns = tableColumns(field);
116
+ if (!columns.length) return [];
117
+ const { minRows, maxRows } = tableLimits(field);
118
+ const row = Object.fromEntries(columns.map((c) => [c.key, "sample"]));
119
+ return [Array.from({ length: Math.min(Math.max(1, minRows), maxRows) }, () => ({ ...row }))];
120
+ }
121
+ },
122
+ {
123
+ type: "media-notes",
124
+ label: "Notes at moments",
125
+ category: "media",
126
+ value: { json: true },
127
+ operators: ["answered", "empty"],
128
+ a11y: { semantics: "group" },
129
+ isEmpty: (value) => emptyList(value, (n) => isObject(n) && blankText(n.text)),
130
+ validate: (value, field) => {
131
+ if (!Array.isArray(value)) return "Add notes.";
132
+ const textMaxLength = intProp(field, "textMaxLength", 1e3);
133
+ for (const note of value) {
134
+ if (!isObject(note) || typeof note.text !== "string") return "Add notes.";
135
+ if (!isTimestamp(note.at)) return "Each note needs a time.";
136
+ if (note.text.trim().length > textMaxLength) return `Keep each note under ${textMaxLength} characters.`;
137
+ }
138
+ const maxNotes = intProp(field, "maxNotes", 20);
139
+ return notesWithText(value).length > maxNotes ? `Add at most ${plural(maxNotes, "note")}.` : null;
140
+ },
141
+ normalize: (value) => Array.isArray(value) ? notesWithText(value).map((n) => ({ at: n.at, text: n.text.trim() })).sort((a, b) => a.at - b.at) : value,
142
+ summarize: (value) => notesWithText(value).map((n) => ({ at: typeof n.at === "number" ? n.at : 0, text: n.text.trim() })).sort((a, b) => a.at - b.at).map((n) => `${formatTimestamp(n.at)} ${n.text}`).join("; "),
143
+ samples: () => [[{ at: 0, text: "sample" }]]
144
+ },
145
+ {
146
+ type: "text-highlights",
147
+ label: "Highlights",
148
+ category: "media",
149
+ value: { json: true },
150
+ operators: ["answered", "empty"],
151
+ a11y: { semantics: "group" },
152
+ isEmpty: (value) => emptyList(value, () => false),
153
+ validate: (value, field) => {
154
+ if (!Array.isArray(value)) return "Highlight a passage.";
155
+ const maxHighlights = intProp(field, "maxHighlights", 20);
156
+ if (value.length > maxHighlights) return `Add at most ${plural(maxHighlights, "highlight")}.`;
157
+ const text = sourceText(field);
158
+ for (const h of value) {
159
+ if (!isObject(h) || !Number.isInteger(h.start) || !Number.isInteger(h.end) || typeof h.quote !== "string" || typeof h.note !== "string") {
160
+ return "Highlight a passage of the text.";
161
+ }
162
+ const start = h.start;
163
+ const end = h.end;
164
+ if (start < 0 || end <= start || h.quote.length !== end - start) return "Highlight a passage of the text.";
165
+ if (end - start > MAX_HIGHLIGHT_QUOTE_LENGTH) return `Highlight under ${MAX_HIGHLIGHT_QUOTE_LENGTH} characters at a time.`;
166
+ if (text !== void 0 && (end > text.length || text.slice(start, end) !== h.quote)) return "A highlight doesn't match the text.";
167
+ if (h.note.trim() === "") return HIGHLIGHT_NOTE_MESSAGE;
168
+ if (h.note.trim().length > MAX_HIGHLIGHT_NOTE_LENGTH) return `Keep each note under ${MAX_HIGHLIGHT_NOTE_LENGTH} characters.`;
169
+ }
170
+ return null;
171
+ },
172
+ normalize: (value) => Array.isArray(value) ? value.filter(isObject).map((h) => ({ start: h.start, end: h.end, quote: h.quote, note: String(h.note).trim() })).sort((a, b) => a.start - b.start || a.end - b.end) : value,
173
+ summarize: (value) => Array.isArray(value) ? value.filter(isObject).map((h) => `"${String(h.quote)}" \u2014 ${String(h.note).trim()}`).join("; ") : "",
174
+ samples: (field) => {
175
+ const text = sourceText(field);
176
+ if (text === void 0) return [[{ start: 0, end: 6, quote: "sample", note: "sample" }]];
177
+ const end = Math.min(text.length, 20);
178
+ return [[{ start: 0, end, quote: text.slice(0, end), note: "sample" }]];
179
+ }
180
+ }
181
+ ];
182
+
1
183
  // src/fields.ts
2
184
  var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
3
185
  var globalScope = globalThis;
@@ -179,7 +361,7 @@ var builtins = [
179
361
  summarize: (value) => value ? "Yes" : "No"
180
362
  }
181
363
  ];
182
- for (const manifest of builtins) registerFieldType(manifest);
364
+ for (const manifest of [...builtins, ...structuredFieldTypes]) registerFieldType(manifest);
183
365
 
184
366
  // src/events.ts
185
367
  function isPlainObject(value) {
@@ -228,6 +410,7 @@ function compactEvents(events) {
228
410
 
229
411
  // src/hidden.ts
230
412
  var MAX_HIDDEN_STRING_LENGTH = 1e3;
413
+ var DECIMAL = /^-?\d+(\.\d+)?$/;
231
414
  function coerceHiddenValue(value, type) {
232
415
  if (typeof value === "string" && value.length > MAX_HIDDEN_STRING_LENGTH) return void 0;
233
416
  if (type === "string") {
@@ -237,7 +420,7 @@ function coerceHiddenValue(value, type) {
237
420
  }
238
421
  if (type === "number") {
239
422
  if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
240
- if (typeof value !== "string" || value.trim() === "") return void 0;
423
+ if (typeof value !== "string" || !DECIMAL.test(value)) return void 0;
241
424
  const n = Number(value);
242
425
  return Number.isFinite(n) ? n : void 0;
243
426
  }
@@ -562,7 +745,7 @@ function build(definition, options, replaying = false) {
562
745
  for (const id of state.path) {
563
746
  const field = fields.get(id);
564
747
  const value = own(state.values, id);
565
- if (field && value !== void 0) out[id] = normalizeValue(value, field);
748
+ if (field && !isEmptyValue(value, field)) out[id] = normalizeValue(value, field);
566
749
  }
567
750
  return out;
568
751
  }
@@ -667,6 +850,10 @@ function domainOf(field, extra, maxOptions) {
667
850
  extra = extra.map((v) => typeof v === "string" ? [v] : v);
668
851
  break;
669
852
  }
853
+ case "list":
854
+ base = getFieldType(field.type)?.samples?.(field) ?? [];
855
+ extra = extra.map((v) => typeof v === "string" ? [v] : v);
856
+ break;
670
857
  case "email":
671
858
  base = ["ada@example.com"];
672
859
  break;
@@ -770,7 +957,31 @@ function coverage(form, opts = {}) {
770
957
  }
771
958
 
772
959
  // src/parse.ts
773
- var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
960
+ function shown(v) {
961
+ let text;
962
+ try {
963
+ text = JSON.stringify(v);
964
+ } catch {
965
+ text = void 0;
966
+ }
967
+ text ??= typeof v === "bigint" ? `${v}n` : typeof v;
968
+ return text.length > 60 ? `${text.slice(0, 57)}...` : text;
969
+ }
970
+ var isObject2 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
971
+ var isInt = (v) => typeof v === "number" && Number.isInteger(v);
972
+ var LITERAL_TYPE = {
973
+ "short-text": "string",
974
+ "long-text": "string",
975
+ email: "string",
976
+ phone: "string",
977
+ rating: "number",
978
+ nps: "number",
979
+ "single-choice": "string",
980
+ "multi-choice": "string",
981
+ "yes-no": "boolean",
982
+ list: "string"
983
+ };
984
+ var ORDERING = /* @__PURE__ */ new Set(["lt", "lte", "gt", "gte"]);
774
985
  var OPERATORS = /* @__PURE__ */ new Set([
775
986
  "eq",
776
987
  "neq",
@@ -810,7 +1021,48 @@ function parseForm(input) {
810
1021
  }
811
1022
  return true;
812
1023
  };
813
- if (!isObject(input)) {
1024
+ const optionalInt = (v, path, floor) => {
1025
+ if (v !== void 0 && !(isInt(v) && v >= floor)) issue(path, `Must be a whole number of ${floor} or more.`);
1026
+ };
1027
+ const ordered = (low, high, path, what) => {
1028
+ if (isInt(low) && isInt(high) && low > high) issue(path, `Can't be more than the ${what}.`);
1029
+ };
1030
+ const structuredProps = (type, props, path) => {
1031
+ if (type === "list") {
1032
+ optionalInt(props.min, `${path}.min`, 0);
1033
+ optionalInt(props.max, `${path}.max`, 1);
1034
+ optionalInt(props.itemMaxLength, `${path}.itemMaxLength`, 1);
1035
+ optionalText(props.placeholder, `${path}.placeholder`);
1036
+ ordered(props.min, props.max ?? 10, `${path}.min`, "most items");
1037
+ } else if (type === "table") {
1038
+ const columns = props.columns;
1039
+ if (!Array.isArray(columns) || columns.length < 1 || columns.length > MAX_TABLE_COLUMNS) {
1040
+ issue(`${path}.columns`, `Must be a list of 1 to ${MAX_TABLE_COLUMNS} columns.`);
1041
+ } else {
1042
+ const seen = /* @__PURE__ */ new Set();
1043
+ columns.forEach((c, j) => {
1044
+ const at = `${path}.columns[${j}]`;
1045
+ if (!isObject2(c)) return issue(at, "Each column needs a key and a label.");
1046
+ if (name(c.key, `${at}.key`, "A column key")) {
1047
+ if (seen.has(c.key)) issue(`${at}.key`, `Duplicate column key "${c.key}".`);
1048
+ seen.add(c.key);
1049
+ }
1050
+ text(c.label, `${at}.label`);
1051
+ optionalBoolean(c.long, `${at}.long`);
1052
+ });
1053
+ }
1054
+ optionalInt(props.minRows, `${path}.minRows`, 0);
1055
+ optionalInt(props.maxRows, `${path}.maxRows`, 1);
1056
+ ordered(props.minRows, props.maxRows ?? 3, `${path}.minRows`, "most rows");
1057
+ } else if (type === "media-notes") {
1058
+ optionalInt(props.maxNotes, `${path}.maxNotes`, 1);
1059
+ optionalInt(props.textMaxLength, `${path}.textMaxLength`, 1);
1060
+ } else if (type === "text-highlights") {
1061
+ optionalText(props.text, `${path}.text`);
1062
+ optionalInt(props.maxHighlights, `${path}.maxHighlights`, 1);
1063
+ }
1064
+ };
1065
+ if (!isObject2(input)) {
814
1066
  issue("", "A form definition must be an object.");
815
1067
  return fail();
816
1068
  }
@@ -819,19 +1071,19 @@ function parseForm(input) {
819
1071
  text(input.title, "title");
820
1072
  optionalText(input.description, "description");
821
1073
  if (input.layout !== void 0) {
822
- if (!isObject(input.layout)) issue("layout", "Must be an object.");
1074
+ if (!isObject2(input.layout)) issue("layout", "Must be an object.");
823
1075
  else if (input.layout.mode !== "focus" && input.layout.mode !== "classic") {
824
1076
  issue("layout.mode", "Must be focus or classic.");
825
1077
  }
826
1078
  }
827
1079
  if (input.settings !== void 0) {
828
- if (!isObject(input.settings)) issue("settings", "Must be an object.");
1080
+ if (!isObject2(input.settings)) issue("settings", "Must be an object.");
829
1081
  else {
830
1082
  optionalBoolean(input.settings.anonymous, "settings.anonymous");
831
1083
  optionalBoolean(input.settings.clearHiddenValues, "settings.clearHiddenValues");
832
1084
  }
833
1085
  }
834
- if (input.meta !== void 0 && (!isObject(input.meta) || !isJson(input.meta))) {
1086
+ if (input.meta !== void 0 && (!isObject2(input.meta) || !isJson(input.meta))) {
835
1087
  issue("meta", "Must be a JSON object.");
836
1088
  }
837
1089
  if (!Array.isArray(input.blocks)) {
@@ -843,9 +1095,10 @@ function parseForm(input) {
843
1095
  const keys = /* @__PURE__ */ new Map();
844
1096
  const fieldTypes = /* @__PURE__ */ new Map();
845
1097
  const order = /* @__PURE__ */ new Map();
1098
+ const choices = /* @__PURE__ */ new Map();
846
1099
  input.blocks.forEach((block, i) => {
847
1100
  const path = `blocks[${i}]`;
848
- if (!isObject(block) || typeof block.id !== "string" || !block.id) {
1101
+ if (!isObject2(block) || typeof block.id !== "string" || !block.id) {
849
1102
  issue(path, "Every block needs a string id.");
850
1103
  return;
851
1104
  }
@@ -866,18 +1119,30 @@ function parseForm(input) {
866
1119
  issue(`${path}.type`, `Unknown field type "${String(block.type)}".`);
867
1120
  }
868
1121
  if (block.props !== void 0) {
869
- if (!isObject(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
1122
+ if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
870
1123
  else if (block.props.options !== void 0) {
871
1124
  const options = block.props.options;
872
1125
  if (!Array.isArray(options)) issue(`${path}.props.options`, "Must be an array.");
873
1126
  else {
874
1127
  options.forEach((o, j) => {
875
1128
  const at = `${path}.props.options[${j}]`;
876
- if (!isObject(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
1129
+ if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
877
1130
  else optionalText(o.label, `${at}.label`);
878
1131
  });
1132
+ choices.set(
1133
+ block.id,
1134
+ new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
1135
+ );
879
1136
  }
880
1137
  }
1138
+ const { max, maxLength: maxLength2 } = isObject2(block.props) ? block.props : {};
1139
+ if (block.type === "rating" && max !== void 0 && !(isInt(max) && max >= 2 && max <= 10)) {
1140
+ issue(`${path}.props.max`, "Must be a whole number from 2 to 10.");
1141
+ }
1142
+ if (maxLength2 !== void 0 && !(isInt(maxLength2) && maxLength2 > 0)) {
1143
+ issue(`${path}.props.maxLength`, "Must be a whole number above 0.");
1144
+ }
1145
+ if (isObject2(block.props) && typeof block.type === "string") structuredProps(block.type, block.props, `${path}.props`);
881
1146
  }
882
1147
  if (validKey) {
883
1148
  if (keys.has(block.key)) issue(`${path}.key`, `Duplicate key "${String(block.key)}".`);
@@ -894,9 +1159,10 @@ function parseForm(input) {
894
1159
  }
895
1160
  });
896
1161
  const hiddenNames = /* @__PURE__ */ new Set();
1162
+ const hiddenTypes = /* @__PURE__ */ new Map();
897
1163
  if (input.hidden !== void 0 && !Array.isArray(input.hidden)) issue("hidden", "Must be an array.");
898
1164
  (Array.isArray(input.hidden) ? input.hidden : []).forEach((h, i) => {
899
- if (!isObject(h) || typeof h.name !== "string" || !h.name) {
1165
+ if (!isObject2(h) || typeof h.name !== "string" || !h.name) {
900
1166
  issue(`hidden[${i}]`, "Each hidden input needs a name.");
901
1167
  return;
902
1168
  }
@@ -909,13 +1175,15 @@ function parseForm(input) {
909
1175
  issue(`hidden[${i}].type`, "Must be string, number or boolean.");
910
1176
  }
911
1177
  optionalBoolean(h.sensitive, `hidden[${i}].sensitive`);
1178
+ optionalBoolean(h.identifying, `hidden[${i}].identifying`);
912
1179
  hiddenNames.add(h.name);
1180
+ if (typeof h.type === "string") hiddenTypes.set(h.name, h.type);
913
1181
  });
914
1182
  const endingIds = /* @__PURE__ */ new Set();
915
1183
  if (input.endings !== void 0 && !Array.isArray(input.endings)) issue("endings", "Must be an array.");
916
1184
  (Array.isArray(input.endings) ? input.endings : []).forEach((e, i) => {
917
1185
  const path = `endings[${i}]`;
918
- if (!isObject(e)) {
1186
+ if (!isObject2(e)) {
919
1187
  issue(path, "Each ending must be an object with an id and a title.");
920
1188
  return;
921
1189
  }
@@ -927,7 +1195,7 @@ function parseForm(input) {
927
1195
  });
928
1196
  const checkOperand = (o, path, reads) => {
929
1197
  if (o === void 0) return;
930
- if (!isObject(o)) return issue(path, "An operand must be an object.");
1198
+ if (!isObject2(o)) return issue(path, "An operand must be an object.");
931
1199
  if ("field" in o) {
932
1200
  if (typeof o.field !== "string" || !fieldTypes.has(o.field)) return issue(path, `No field "${String(o.field)}".`);
933
1201
  reads.push(o.field);
@@ -940,7 +1208,7 @@ function parseForm(input) {
940
1208
  }
941
1209
  };
942
1210
  const checkCondition = (c, path, reads, depth = 0) => {
943
- if (!isObject(c)) return issue(path, "A condition must be an object.");
1211
+ if (!isObject2(c)) return issue(path, "A condition must be an object.");
944
1212
  if (depth > MAX_CONDITION_DEPTH) return issue(path, "The condition is nested too deeply.");
945
1213
  if ("all" in c || "any" in c) {
946
1214
  const group = "all" in c ? "all" : "any";
@@ -949,7 +1217,7 @@ function parseForm(input) {
949
1217
  return list.forEach((x, i) => checkCondition(x, `${path}.${group}[${i}]`, reads, depth + 1));
950
1218
  }
951
1219
  if ("not" in c) return checkCondition(c.not, `${path}.not`, reads, depth + 1);
952
- if (typeof c.op !== "string" || !isObject(c.left)) {
1220
+ if (typeof c.op !== "string" || !isObject2(c.left)) {
953
1221
  return issue(path, "A comparison needs an `op` and a `left` operand.");
954
1222
  }
955
1223
  if (!OPERATORS.has(c.op)) issue(`${path}.op`, `Unknown operator "${c.op}".`);
@@ -958,8 +1226,45 @@ function parseForm(input) {
958
1226
  if (typeof c.left.field === "string") {
959
1227
  const manifest = getFieldType(fieldTypes.get(c.left.field) ?? "");
960
1228
  if (manifest && OPERATORS.has(c.op) && !manifest.operators.includes(c.op)) {
961
- issue(`${path}.op`, `"${c.op}" does not apply to a ${manifest.type} field.`);
1229
+ return issue(`${path}.op`, `"${c.op}" does not apply to a ${manifest.type} field.`);
1230
+ }
1231
+ }
1232
+ checkLiterals(c.op, c.left, c.right, path);
1233
+ };
1234
+ const checkLiterals = (op, left, right, path) => {
1235
+ if (op === "answered" || op === "empty" || !OPERATORS.has(op)) return;
1236
+ const literal = isObject2(right) && "value" in right;
1237
+ if (right !== void 0 && !literal) return;
1238
+ let what = "the left side";
1239
+ let type;
1240
+ let options;
1241
+ if (typeof left.field === "string" && fieldTypes.has(left.field)) {
1242
+ const fieldType = fieldTypes.get(left.field);
1243
+ what = `"${keyOf(left.field)}"`;
1244
+ type = LITERAL_TYPE[fieldType];
1245
+ if (fieldType === "single-choice" || fieldType === "multi-choice") options = choices.get(left.field);
1246
+ } else if (typeof left.hidden === "string" && hiddenTypes.has(left.hidden)) {
1247
+ what = `hidden input "${left.hidden}"`;
1248
+ type = hiddenTypes.get(left.hidden);
1249
+ }
1250
+ const outcome = op === "neq" ? "always true" : "never true";
1251
+ let values;
1252
+ if (op === "in") {
1253
+ if (!literal || !Array.isArray(right.value)) {
1254
+ return void warnings.push({ path: `${path}.right`, message: "`in` needs a list of values; it never matches." });
962
1255
  }
1256
+ values = right.value;
1257
+ } else if (!literal) return;
1258
+ else values = [right.value];
1259
+ if (ORDERING.has(op)) {
1260
+ if (type !== void 0 && type !== "number") {
1261
+ return void warnings.push({ path: `${path}.op`, message: `"${op}" compares numbers, but ${what} holds a ${type}; it is never true.` });
1262
+ }
1263
+ type = "number";
1264
+ }
1265
+ for (const v of values) {
1266
+ const message = type !== void 0 && typeof v !== type ? `${what} holds a ${type}, but ${shown(v)} is not one; this comparison is ${outcome}.` : options && typeof v === "string" && !options.has(v) ? `${shown(v)} is not an option of ${what}; this comparison is ${outcome}.` : null;
1267
+ if (message) warnings.push({ path: `${path}.right`, message });
963
1268
  }
964
1269
  };
965
1270
  const keyOf = (id) => [...keys].find(([, v]) => v === id)?.[0] ?? id;
@@ -967,7 +1272,7 @@ function parseForm(input) {
967
1272
  if (input.logic !== void 0 && !Array.isArray(input.logic)) issue("logic", "Must be an array.");
968
1273
  (Array.isArray(input.logic) ? input.logic : []).forEach((rule, i) => {
969
1274
  const path = `logic[${i}]`;
970
- if (!isObject(rule) || typeof rule.id !== "string" || !isObject(rule.when)) {
1275
+ if (!isObject2(rule) || typeof rule.id !== "string" || !isObject2(rule.when)) {
971
1276
  issue(path, "Each rule needs an id and a `when` condition.");
972
1277
  return;
973
1278
  }
@@ -986,7 +1291,7 @@ function parseForm(input) {
986
1291
  at = rule.from;
987
1292
  inclusive = true;
988
1293
  }
989
- if (isObject(rule.to)) {
1294
+ if (isObject2(rule.to)) {
990
1295
  if (typeof rule.to.ending !== "string" || !endingIds.has(rule.to.ending)) {
991
1296
  issue(`${path}.to`, `No ending "${String(rule.to.ending)}".`);
992
1297
  }
@@ -1018,7 +1323,7 @@ function parseForm(input) {
1018
1323
  }
1019
1324
  });
1020
1325
  input.blocks.forEach((block, i) => {
1021
- if (!isObject(block)) return;
1326
+ if (!isObject2(block)) return;
1022
1327
  const raw = block.kind === "field" ? [block.label, block.description] : block.kind === "content" ? [block.text] : [];
1023
1328
  const texts = raw.filter((t) => typeof t === "string");
1024
1329
  for (const t of texts) {
@@ -1036,6 +1341,18 @@ function parseForm(input) {
1036
1341
  }
1037
1342
  }
1038
1343
  });
1344
+ const extra = [["description", input.description]];
1345
+ (Array.isArray(input.endings) ? input.endings : []).forEach((e, i) => {
1346
+ if (isObject2(e)) extra.push([`endings[${i}]`, e.title], [`endings[${i}]`, e.description]);
1347
+ });
1348
+ for (const [path, t] of extra) {
1349
+ if (typeof t !== "string") continue;
1350
+ for (const ref of recallRefs(t)) {
1351
+ if (ref.kind === "hidden" ? !hiddenNames.has(ref.name) : !keys.has(ref.name)) {
1352
+ warnings.push({ path, message: `Recalls unknown ${ref.kind === "hidden" ? "hidden input" : "field"} "${ref.name}"; it shows its fallback text.` });
1353
+ }
1354
+ }
1355
+ }
1039
1356
  return issues.length ? fail() : { ok: true, form: input, warnings };
1040
1357
  }
1041
1358
 
@@ -1072,18 +1389,16 @@ function validateSubmission(definition, submission, options = {}) {
1072
1389
  if (invalid) return { ok: false, errors: { "": invalid } };
1073
1390
  const input = wellFormed(submission);
1074
1391
  if (!input) return MALFORMED;
1075
- const ignored = [];
1392
+ const ignored = /* @__PURE__ */ new Set();
1076
1393
  const client = coerceHidden(definition, input.hidden, { sources: ["url", "host"] });
1077
- for (const name of client.ignored) ignored.push(`hidden.${name}`);
1394
+ for (const name of client.ignored) ignored.add(`hidden.${name}`);
1078
1395
  const hidden = client.values;
1079
1396
  const context = coerceHidden(definition, options.context, { sources: ["server"] }).values;
1080
1397
  const fields = fieldMap(definition);
1081
- const dropped = /* @__PURE__ */ new Set();
1082
1398
  const accept = (id) => {
1083
- if (!fields.has(id)) return void ignored.push(id);
1399
+ if (!fields.has(id)) return void ignored.add(id);
1084
1400
  if (!withheld(definition, fields.get(id))) return true;
1085
- if (!dropped.has(id)) ignored.push(id);
1086
- dropped.add(id);
1401
+ ignored.add(id);
1087
1402
  return false;
1088
1403
  };
1089
1404
  const engineHidden = { ...hidden, ...context };
@@ -1136,15 +1451,25 @@ function validateSubmission(definition, submission, options = {}) {
1136
1451
  if (message === null) answers[id] = normalizeValue(value, field);
1137
1452
  }
1138
1453
  }
1454
+ const store = (values, report) => {
1455
+ if (definition.settings?.anonymous !== true) return values;
1456
+ const identifying = new Set((definition.hidden ?? []).filter((h) => h.identifying === true).map((h) => h.name));
1457
+ const kept = {};
1458
+ for (const [name, value] of Object.entries(values)) {
1459
+ if (!identifying.has(name)) kept[name] = value;
1460
+ else if (report) ignored.add(`hidden.${name}`);
1461
+ }
1462
+ return kept;
1463
+ };
1139
1464
  return {
1140
1465
  ok: true,
1141
1466
  submission: {
1142
1467
  status: complete ? "completed" : "draft",
1143
1468
  answers,
1144
- hidden,
1145
- context,
1469
+ hidden: store(hidden, true),
1470
+ context: store(context, false),
1146
1471
  ending: complete ? state.ending : null,
1147
- ignored
1472
+ ignored: [...ignored]
1148
1473
  }
1149
1474
  };
1150
1475
  }