@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/server.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
  }
@@ -599,7 +782,31 @@ function replay(definition, events, options = {}) {
599
782
  }
600
783
 
601
784
  // src/parse.ts
602
- var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
785
+ function shown(v) {
786
+ let text;
787
+ try {
788
+ text = JSON.stringify(v);
789
+ } catch {
790
+ text = void 0;
791
+ }
792
+ text ??= typeof v === "bigint" ? `${v}n` : typeof v;
793
+ return text.length > 60 ? `${text.slice(0, 57)}...` : text;
794
+ }
795
+ var isObject2 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
796
+ var isInt = (v) => typeof v === "number" && Number.isInteger(v);
797
+ var LITERAL_TYPE = {
798
+ "short-text": "string",
799
+ "long-text": "string",
800
+ email: "string",
801
+ phone: "string",
802
+ rating: "number",
803
+ nps: "number",
804
+ "single-choice": "string",
805
+ "multi-choice": "string",
806
+ "yes-no": "boolean",
807
+ list: "string"
808
+ };
809
+ var ORDERING = /* @__PURE__ */ new Set(["lt", "lte", "gt", "gte"]);
603
810
  var OPERATORS = /* @__PURE__ */ new Set([
604
811
  "eq",
605
812
  "neq",
@@ -639,7 +846,48 @@ function parseForm(input) {
639
846
  }
640
847
  return true;
641
848
  };
642
- if (!isObject(input)) {
849
+ const optionalInt = (v, path, floor) => {
850
+ if (v !== void 0 && !(isInt(v) && v >= floor)) issue(path, `Must be a whole number of ${floor} or more.`);
851
+ };
852
+ const ordered = (low, high, path, what) => {
853
+ if (isInt(low) && isInt(high) && low > high) issue(path, `Can't be more than the ${what}.`);
854
+ };
855
+ const structuredProps = (type, props, path) => {
856
+ if (type === "list") {
857
+ optionalInt(props.min, `${path}.min`, 0);
858
+ optionalInt(props.max, `${path}.max`, 1);
859
+ optionalInt(props.itemMaxLength, `${path}.itemMaxLength`, 1);
860
+ optionalText(props.placeholder, `${path}.placeholder`);
861
+ ordered(props.min, props.max ?? 10, `${path}.min`, "most items");
862
+ } else if (type === "table") {
863
+ const columns = props.columns;
864
+ if (!Array.isArray(columns) || columns.length < 1 || columns.length > MAX_TABLE_COLUMNS) {
865
+ issue(`${path}.columns`, `Must be a list of 1 to ${MAX_TABLE_COLUMNS} columns.`);
866
+ } else {
867
+ const seen = /* @__PURE__ */ new Set();
868
+ columns.forEach((c, j) => {
869
+ const at = `${path}.columns[${j}]`;
870
+ if (!isObject2(c)) return issue(at, "Each column needs a key and a label.");
871
+ if (name(c.key, `${at}.key`, "A column key")) {
872
+ if (seen.has(c.key)) issue(`${at}.key`, `Duplicate column key "${c.key}".`);
873
+ seen.add(c.key);
874
+ }
875
+ text(c.label, `${at}.label`);
876
+ optionalBoolean(c.long, `${at}.long`);
877
+ });
878
+ }
879
+ optionalInt(props.minRows, `${path}.minRows`, 0);
880
+ optionalInt(props.maxRows, `${path}.maxRows`, 1);
881
+ ordered(props.minRows, props.maxRows ?? 3, `${path}.minRows`, "most rows");
882
+ } else if (type === "media-notes") {
883
+ optionalInt(props.maxNotes, `${path}.maxNotes`, 1);
884
+ optionalInt(props.textMaxLength, `${path}.textMaxLength`, 1);
885
+ } else if (type === "text-highlights") {
886
+ optionalText(props.text, `${path}.text`);
887
+ optionalInt(props.maxHighlights, `${path}.maxHighlights`, 1);
888
+ }
889
+ };
890
+ if (!isObject2(input)) {
643
891
  issue("", "A form definition must be an object.");
644
892
  return fail();
645
893
  }
@@ -648,19 +896,19 @@ function parseForm(input) {
648
896
  text(input.title, "title");
649
897
  optionalText(input.description, "description");
650
898
  if (input.layout !== void 0) {
651
- if (!isObject(input.layout)) issue("layout", "Must be an object.");
899
+ if (!isObject2(input.layout)) issue("layout", "Must be an object.");
652
900
  else if (input.layout.mode !== "focus" && input.layout.mode !== "classic") {
653
901
  issue("layout.mode", "Must be focus or classic.");
654
902
  }
655
903
  }
656
904
  if (input.settings !== void 0) {
657
- if (!isObject(input.settings)) issue("settings", "Must be an object.");
905
+ if (!isObject2(input.settings)) issue("settings", "Must be an object.");
658
906
  else {
659
907
  optionalBoolean(input.settings.anonymous, "settings.anonymous");
660
908
  optionalBoolean(input.settings.clearHiddenValues, "settings.clearHiddenValues");
661
909
  }
662
910
  }
663
- if (input.meta !== void 0 && (!isObject(input.meta) || !isJson(input.meta))) {
911
+ if (input.meta !== void 0 && (!isObject2(input.meta) || !isJson(input.meta))) {
664
912
  issue("meta", "Must be a JSON object.");
665
913
  }
666
914
  if (!Array.isArray(input.blocks)) {
@@ -672,9 +920,10 @@ function parseForm(input) {
672
920
  const keys = /* @__PURE__ */ new Map();
673
921
  const fieldTypes = /* @__PURE__ */ new Map();
674
922
  const order = /* @__PURE__ */ new Map();
923
+ const choices = /* @__PURE__ */ new Map();
675
924
  input.blocks.forEach((block, i) => {
676
925
  const path = `blocks[${i}]`;
677
- if (!isObject(block) || typeof block.id !== "string" || !block.id) {
926
+ if (!isObject2(block) || typeof block.id !== "string" || !block.id) {
678
927
  issue(path, "Every block needs a string id.");
679
928
  return;
680
929
  }
@@ -695,18 +944,30 @@ function parseForm(input) {
695
944
  issue(`${path}.type`, `Unknown field type "${String(block.type)}".`);
696
945
  }
697
946
  if (block.props !== void 0) {
698
- if (!isObject(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
947
+ if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
699
948
  else if (block.props.options !== void 0) {
700
949
  const options = block.props.options;
701
950
  if (!Array.isArray(options)) issue(`${path}.props.options`, "Must be an array.");
702
951
  else {
703
952
  options.forEach((o, j) => {
704
953
  const at = `${path}.props.options[${j}]`;
705
- if (!isObject(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
954
+ if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
706
955
  else optionalText(o.label, `${at}.label`);
707
956
  });
957
+ choices.set(
958
+ block.id,
959
+ new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
960
+ );
708
961
  }
709
962
  }
963
+ const { max, maxLength: maxLength2 } = isObject2(block.props) ? block.props : {};
964
+ if (block.type === "rating" && max !== void 0 && !(isInt(max) && max >= 2 && max <= 10)) {
965
+ issue(`${path}.props.max`, "Must be a whole number from 2 to 10.");
966
+ }
967
+ if (maxLength2 !== void 0 && !(isInt(maxLength2) && maxLength2 > 0)) {
968
+ issue(`${path}.props.maxLength`, "Must be a whole number above 0.");
969
+ }
970
+ if (isObject2(block.props) && typeof block.type === "string") structuredProps(block.type, block.props, `${path}.props`);
710
971
  }
711
972
  if (validKey) {
712
973
  if (keys.has(block.key)) issue(`${path}.key`, `Duplicate key "${String(block.key)}".`);
@@ -723,9 +984,10 @@ function parseForm(input) {
723
984
  }
724
985
  });
725
986
  const hiddenNames = /* @__PURE__ */ new Set();
987
+ const hiddenTypes = /* @__PURE__ */ new Map();
726
988
  if (input.hidden !== void 0 && !Array.isArray(input.hidden)) issue("hidden", "Must be an array.");
727
989
  (Array.isArray(input.hidden) ? input.hidden : []).forEach((h, i) => {
728
- if (!isObject(h) || typeof h.name !== "string" || !h.name) {
990
+ if (!isObject2(h) || typeof h.name !== "string" || !h.name) {
729
991
  issue(`hidden[${i}]`, "Each hidden input needs a name.");
730
992
  return;
731
993
  }
@@ -738,13 +1000,15 @@ function parseForm(input) {
738
1000
  issue(`hidden[${i}].type`, "Must be string, number or boolean.");
739
1001
  }
740
1002
  optionalBoolean(h.sensitive, `hidden[${i}].sensitive`);
1003
+ optionalBoolean(h.identifying, `hidden[${i}].identifying`);
741
1004
  hiddenNames.add(h.name);
1005
+ if (typeof h.type === "string") hiddenTypes.set(h.name, h.type);
742
1006
  });
743
1007
  const endingIds = /* @__PURE__ */ new Set();
744
1008
  if (input.endings !== void 0 && !Array.isArray(input.endings)) issue("endings", "Must be an array.");
745
1009
  (Array.isArray(input.endings) ? input.endings : []).forEach((e, i) => {
746
1010
  const path = `endings[${i}]`;
747
- if (!isObject(e)) {
1011
+ if (!isObject2(e)) {
748
1012
  issue(path, "Each ending must be an object with an id and a title.");
749
1013
  return;
750
1014
  }
@@ -756,7 +1020,7 @@ function parseForm(input) {
756
1020
  });
757
1021
  const checkOperand = (o, path, reads) => {
758
1022
  if (o === void 0) return;
759
- if (!isObject(o)) return issue(path, "An operand must be an object.");
1023
+ if (!isObject2(o)) return issue(path, "An operand must be an object.");
760
1024
  if ("field" in o) {
761
1025
  if (typeof o.field !== "string" || !fieldTypes.has(o.field)) return issue(path, `No field "${String(o.field)}".`);
762
1026
  reads.push(o.field);
@@ -769,7 +1033,7 @@ function parseForm(input) {
769
1033
  }
770
1034
  };
771
1035
  const checkCondition = (c, path, reads, depth = 0) => {
772
- if (!isObject(c)) return issue(path, "A condition must be an object.");
1036
+ if (!isObject2(c)) return issue(path, "A condition must be an object.");
773
1037
  if (depth > MAX_CONDITION_DEPTH) return issue(path, "The condition is nested too deeply.");
774
1038
  if ("all" in c || "any" in c) {
775
1039
  const group = "all" in c ? "all" : "any";
@@ -778,7 +1042,7 @@ function parseForm(input) {
778
1042
  return list.forEach((x, i) => checkCondition(x, `${path}.${group}[${i}]`, reads, depth + 1));
779
1043
  }
780
1044
  if ("not" in c) return checkCondition(c.not, `${path}.not`, reads, depth + 1);
781
- if (typeof c.op !== "string" || !isObject(c.left)) {
1045
+ if (typeof c.op !== "string" || !isObject2(c.left)) {
782
1046
  return issue(path, "A comparison needs an `op` and a `left` operand.");
783
1047
  }
784
1048
  if (!OPERATORS.has(c.op)) issue(`${path}.op`, `Unknown operator "${c.op}".`);
@@ -787,16 +1051,53 @@ function parseForm(input) {
787
1051
  if (typeof c.left.field === "string") {
788
1052
  const manifest = getFieldType(fieldTypes.get(c.left.field) ?? "");
789
1053
  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.`);
1054
+ return issue(`${path}.op`, `"${c.op}" does not apply to a ${manifest.type} field.`);
791
1055
  }
792
1056
  }
1057
+ checkLiterals(c.op, c.left, c.right, path);
1058
+ };
1059
+ const checkLiterals = (op, left, right, path) => {
1060
+ if (op === "answered" || op === "empty" || !OPERATORS.has(op)) return;
1061
+ const literal = isObject2(right) && "value" in right;
1062
+ if (right !== void 0 && !literal) return;
1063
+ let what = "the left side";
1064
+ let type;
1065
+ let options;
1066
+ if (typeof left.field === "string" && fieldTypes.has(left.field)) {
1067
+ const fieldType = fieldTypes.get(left.field);
1068
+ what = `"${keyOf(left.field)}"`;
1069
+ type = LITERAL_TYPE[fieldType];
1070
+ if (fieldType === "single-choice" || fieldType === "multi-choice") options = choices.get(left.field);
1071
+ } else if (typeof left.hidden === "string" && hiddenTypes.has(left.hidden)) {
1072
+ what = `hidden input "${left.hidden}"`;
1073
+ type = hiddenTypes.get(left.hidden);
1074
+ }
1075
+ const outcome = op === "neq" ? "always true" : "never true";
1076
+ let values;
1077
+ if (op === "in") {
1078
+ if (!literal || !Array.isArray(right.value)) {
1079
+ return void warnings.push({ path: `${path}.right`, message: "`in` needs a list of values; it never matches." });
1080
+ }
1081
+ values = right.value;
1082
+ } else if (!literal) return;
1083
+ else values = [right.value];
1084
+ if (ORDERING.has(op)) {
1085
+ if (type !== void 0 && type !== "number") {
1086
+ return void warnings.push({ path: `${path}.op`, message: `"${op}" compares numbers, but ${what} holds a ${type}; it is never true.` });
1087
+ }
1088
+ type = "number";
1089
+ }
1090
+ for (const v of values) {
1091
+ 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;
1092
+ if (message) warnings.push({ path: `${path}.right`, message });
1093
+ }
793
1094
  };
794
1095
  const keyOf = (id) => [...keys].find(([, v]) => v === id)?.[0] ?? id;
795
1096
  const ruleIds = /* @__PURE__ */ new Set();
796
1097
  if (input.logic !== void 0 && !Array.isArray(input.logic)) issue("logic", "Must be an array.");
797
1098
  (Array.isArray(input.logic) ? input.logic : []).forEach((rule, i) => {
798
1099
  const path = `logic[${i}]`;
799
- if (!isObject(rule) || typeof rule.id !== "string" || !isObject(rule.when)) {
1100
+ if (!isObject2(rule) || typeof rule.id !== "string" || !isObject2(rule.when)) {
800
1101
  issue(path, "Each rule needs an id and a `when` condition.");
801
1102
  return;
802
1103
  }
@@ -815,7 +1116,7 @@ function parseForm(input) {
815
1116
  at = rule.from;
816
1117
  inclusive = true;
817
1118
  }
818
- if (isObject(rule.to)) {
1119
+ if (isObject2(rule.to)) {
819
1120
  if (typeof rule.to.ending !== "string" || !endingIds.has(rule.to.ending)) {
820
1121
  issue(`${path}.to`, `No ending "${String(rule.to.ending)}".`);
821
1122
  }
@@ -847,7 +1148,7 @@ function parseForm(input) {
847
1148
  }
848
1149
  });
849
1150
  input.blocks.forEach((block, i) => {
850
- if (!isObject(block)) return;
1151
+ if (!isObject2(block)) return;
851
1152
  const raw = block.kind === "field" ? [block.label, block.description] : block.kind === "content" ? [block.text] : [];
852
1153
  const texts = raw.filter((t) => typeof t === "string");
853
1154
  for (const t of texts) {
@@ -865,6 +1166,18 @@ function parseForm(input) {
865
1166
  }
866
1167
  }
867
1168
  });
1169
+ const extra = [["description", input.description]];
1170
+ (Array.isArray(input.endings) ? input.endings : []).forEach((e, i) => {
1171
+ if (isObject2(e)) extra.push([`endings[${i}]`, e.title], [`endings[${i}]`, e.description]);
1172
+ });
1173
+ for (const [path, t] of extra) {
1174
+ if (typeof t !== "string") continue;
1175
+ for (const ref of recallRefs(t)) {
1176
+ if (ref.kind === "hidden" ? !hiddenNames.has(ref.name) : !keys.has(ref.name)) {
1177
+ warnings.push({ path, message: `Recalls unknown ${ref.kind === "hidden" ? "hidden input" : "field"} "${ref.name}"; it shows its fallback text.` });
1178
+ }
1179
+ }
1180
+ }
868
1181
  return issues.length ? fail() : { ok: true, form: input, warnings };
869
1182
  }
870
1183
 
@@ -901,18 +1214,16 @@ function validateSubmission(definition, submission, options = {}) {
901
1214
  if (invalid) return { ok: false, errors: { "": invalid } };
902
1215
  const input = wellFormed(submission);
903
1216
  if (!input) return MALFORMED;
904
- const ignored = [];
1217
+ const ignored = /* @__PURE__ */ new Set();
905
1218
  const client = coerceHidden(definition, input.hidden, { sources: ["url", "host"] });
906
- for (const name of client.ignored) ignored.push(`hidden.${name}`);
1219
+ for (const name of client.ignored) ignored.add(`hidden.${name}`);
907
1220
  const hidden = client.values;
908
1221
  const context = coerceHidden(definition, options.context, { sources: ["server"] }).values;
909
1222
  const fields = fieldMap(definition);
910
- const dropped = /* @__PURE__ */ new Set();
911
1223
  const accept = (id) => {
912
- if (!fields.has(id)) return void ignored.push(id);
1224
+ if (!fields.has(id)) return void ignored.add(id);
913
1225
  if (!withheld(definition, fields.get(id))) return true;
914
- if (!dropped.has(id)) ignored.push(id);
915
- dropped.add(id);
1226
+ ignored.add(id);
916
1227
  return false;
917
1228
  };
918
1229
  const engineHidden = { ...hidden, ...context };
@@ -965,15 +1276,25 @@ function validateSubmission(definition, submission, options = {}) {
965
1276
  if (message === null) answers[id] = normalizeValue(value, field);
966
1277
  }
967
1278
  }
1279
+ const store = (values, report) => {
1280
+ if (definition.settings?.anonymous !== true) return values;
1281
+ const identifying = new Set((definition.hidden ?? []).filter((h) => h.identifying === true).map((h) => h.name));
1282
+ const kept = {};
1283
+ for (const [name, value] of Object.entries(values)) {
1284
+ if (!identifying.has(name)) kept[name] = value;
1285
+ else if (report) ignored.add(`hidden.${name}`);
1286
+ }
1287
+ return kept;
1288
+ };
968
1289
  return {
969
1290
  ok: true,
970
1291
  submission: {
971
1292
  status: complete ? "completed" : "draft",
972
1293
  answers,
973
- hidden,
974
- context,
1294
+ hidden: store(hidden, true),
1295
+ context: store(context, false),
975
1296
  ending: complete ? state.ending : null,
976
- ignored
1297
+ ignored: [...ignored]
977
1298
  }
978
1299
  };
979
1300
  }