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