@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.d.cts CHANGED
@@ -1,32 +1,33 @@
1
- import { J as Json, f as FormEvent, e as FieldValidator, a as FormDefinition } from './validate-j8Z188mF.cjs';
1
+ import { J as Json, e as FormEvent, F as FormDefinition } from './types-BBKBQ8sp.cjs';
2
+ import { F as FieldValidator } from './validate-BXXqi0fq.cjs';
2
3
 
3
4
  interface SubmissionInput {
4
5
  /** Answers keyed by field id. Ignored when `events` is given. */
5
- answers?: Record<string, Json>;
6
+ answers?: Record<string, Json> | undefined;
6
7
  /** The respondent's event log. Replayed instead of trusting `answers`. */
7
- events?: FormEvent[];
8
+ events?: FormEvent[] | undefined;
8
9
  /** URL and host inputs sent by the client. Server inputs here are ignored. */
9
- hidden?: Record<string, Json>;
10
+ hidden?: Record<string, Json> | undefined;
10
11
  }
11
12
  interface SubmissionOptions {
12
- /** Values for `source: "server"` inputs, resolved by the server (class, coach, user id…). */
13
- context?: Record<string, Json>;
13
+ /** Values for `source: "server"` inputs, resolved by the server (account id, location, user id…). */
14
+ context?: Record<string, Json> | undefined;
14
15
  /** `false` checks a draft: formats only, nothing required. Default true. */
15
- complete?: boolean;
16
- validators?: Record<string, FieldValidator>;
16
+ complete?: boolean | undefined;
17
+ validators?: Record<string, FieldValidator> | undefined;
17
18
  /** Most events accepted, counted after `compactEvents`. Default 10 000. */
18
- maxEvents?: number;
19
+ maxEvents?: number | undefined;
19
20
  }
20
21
  interface CheckedSubmission {
21
22
  status: "draft" | "completed";
22
23
  /** Visible answers only, keyed by field id (kept hidden answers too, with `clearHiddenValues: false`). */
23
24
  answers: Record<string, Json>;
24
- /** Untrusted URL and host inputs, coerced to their declared types. */
25
+ /** Untrusted URL and host inputs, coerced to their declared types. Identifying ones are left out on anonymous forms. */
25
26
  hidden: Record<string, Json>;
26
- /** Trusted server inputs. */
27
+ /** Trusted server inputs. Identifying ones are left out on anonymous forms. */
27
28
  context: Record<string, Json>;
28
29
  ending: string | null;
29
- /** Client-sent keys that were dropped: unknown fields, server inputs, undeclared hidden names. */
30
+ /** Client-sent keys that were dropped, each once: unknown fields, server inputs, undeclared or withheld hidden names. */
30
31
  ignored: string[];
31
32
  }
32
33
  type SubmissionResult = {
package/dist/server.d.ts CHANGED
@@ -1,32 +1,33 @@
1
- import { J as Json, f as FormEvent, e as FieldValidator, a as FormDefinition } from './validate-j8Z188mF.js';
1
+ import { J as Json, e as FormEvent, F as FormDefinition } from './types-BBKBQ8sp.js';
2
+ import { F as FieldValidator } from './validate-BjNwF1UE.js';
2
3
 
3
4
  interface SubmissionInput {
4
5
  /** Answers keyed by field id. Ignored when `events` is given. */
5
- answers?: Record<string, Json>;
6
+ answers?: Record<string, Json> | undefined;
6
7
  /** The respondent's event log. Replayed instead of trusting `answers`. */
7
- events?: FormEvent[];
8
+ events?: FormEvent[] | undefined;
8
9
  /** URL and host inputs sent by the client. Server inputs here are ignored. */
9
- hidden?: Record<string, Json>;
10
+ hidden?: Record<string, Json> | undefined;
10
11
  }
11
12
  interface SubmissionOptions {
12
- /** Values for `source: "server"` inputs, resolved by the server (class, coach, user id…). */
13
- context?: Record<string, Json>;
13
+ /** Values for `source: "server"` inputs, resolved by the server (account id, location, user id…). */
14
+ context?: Record<string, Json> | undefined;
14
15
  /** `false` checks a draft: formats only, nothing required. Default true. */
15
- complete?: boolean;
16
- validators?: Record<string, FieldValidator>;
16
+ complete?: boolean | undefined;
17
+ validators?: Record<string, FieldValidator> | undefined;
17
18
  /** Most events accepted, counted after `compactEvents`. Default 10 000. */
18
- maxEvents?: number;
19
+ maxEvents?: number | undefined;
19
20
  }
20
21
  interface CheckedSubmission {
21
22
  status: "draft" | "completed";
22
23
  /** Visible answers only, keyed by field id (kept hidden answers too, with `clearHiddenValues: false`). */
23
24
  answers: Record<string, Json>;
24
- /** Untrusted URL and host inputs, coerced to their declared types. */
25
+ /** Untrusted URL and host inputs, coerced to their declared types. Identifying ones are left out on anonymous forms. */
25
26
  hidden: Record<string, Json>;
26
- /** Trusted server inputs. */
27
+ /** Trusted server inputs. Identifying ones are left out on anonymous forms. */
27
28
  context: Record<string, Json>;
28
29
  ending: string | null;
29
- /** Client-sent keys that were dropped: unknown fields, server inputs, undeclared hidden names. */
30
+ /** Client-sent keys that were dropped, each once: unknown fields, server inputs, undeclared or withheld hidden names. */
30
31
  ignored: string[];
31
32
  }
32
33
  type SubmissionResult = {
package/dist/server.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
  }
@@ -597,7 +780,31 @@ function replay(definition, events, options = {}) {
597
780
  }
598
781
 
599
782
  // src/parse.ts
600
- var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
783
+ function shown(v) {
784
+ let text;
785
+ try {
786
+ text = JSON.stringify(v);
787
+ } catch {
788
+ text = void 0;
789
+ }
790
+ text ??= typeof v === "bigint" ? `${v}n` : typeof v;
791
+ return text.length > 60 ? `${text.slice(0, 57)}...` : text;
792
+ }
793
+ var isObject2 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
794
+ var isInt = (v) => typeof v === "number" && Number.isInteger(v);
795
+ var LITERAL_TYPE = {
796
+ "short-text": "string",
797
+ "long-text": "string",
798
+ email: "string",
799
+ phone: "string",
800
+ rating: "number",
801
+ nps: "number",
802
+ "single-choice": "string",
803
+ "multi-choice": "string",
804
+ "yes-no": "boolean",
805
+ list: "string"
806
+ };
807
+ var ORDERING = /* @__PURE__ */ new Set(["lt", "lte", "gt", "gte"]);
601
808
  var OPERATORS = /* @__PURE__ */ new Set([
602
809
  "eq",
603
810
  "neq",
@@ -637,7 +844,48 @@ function parseForm(input) {
637
844
  }
638
845
  return true;
639
846
  };
640
- if (!isObject(input)) {
847
+ const optionalInt = (v, path, floor) => {
848
+ if (v !== void 0 && !(isInt(v) && v >= floor)) issue(path, `Must be a whole number of ${floor} or more.`);
849
+ };
850
+ const ordered = (low, high, path, what) => {
851
+ if (isInt(low) && isInt(high) && low > high) issue(path, `Can't be more than the ${what}.`);
852
+ };
853
+ const structuredProps = (type, props, path) => {
854
+ if (type === "list") {
855
+ optionalInt(props.min, `${path}.min`, 0);
856
+ optionalInt(props.max, `${path}.max`, 1);
857
+ optionalInt(props.itemMaxLength, `${path}.itemMaxLength`, 1);
858
+ optionalText(props.placeholder, `${path}.placeholder`);
859
+ ordered(props.min, props.max ?? 10, `${path}.min`, "most items");
860
+ } else if (type === "table") {
861
+ const columns = props.columns;
862
+ if (!Array.isArray(columns) || columns.length < 1 || columns.length > MAX_TABLE_COLUMNS) {
863
+ issue(`${path}.columns`, `Must be a list of 1 to ${MAX_TABLE_COLUMNS} columns.`);
864
+ } else {
865
+ const seen = /* @__PURE__ */ new Set();
866
+ columns.forEach((c, j) => {
867
+ const at = `${path}.columns[${j}]`;
868
+ if (!isObject2(c)) return issue(at, "Each column needs a key and a label.");
869
+ if (name(c.key, `${at}.key`, "A column key")) {
870
+ if (seen.has(c.key)) issue(`${at}.key`, `Duplicate column key "${c.key}".`);
871
+ seen.add(c.key);
872
+ }
873
+ text(c.label, `${at}.label`);
874
+ optionalBoolean(c.long, `${at}.long`);
875
+ });
876
+ }
877
+ optionalInt(props.minRows, `${path}.minRows`, 0);
878
+ optionalInt(props.maxRows, `${path}.maxRows`, 1);
879
+ ordered(props.minRows, props.maxRows ?? 3, `${path}.minRows`, "most rows");
880
+ } else if (type === "media-notes") {
881
+ optionalInt(props.maxNotes, `${path}.maxNotes`, 1);
882
+ optionalInt(props.textMaxLength, `${path}.textMaxLength`, 1);
883
+ } else if (type === "text-highlights") {
884
+ optionalText(props.text, `${path}.text`);
885
+ optionalInt(props.maxHighlights, `${path}.maxHighlights`, 1);
886
+ }
887
+ };
888
+ if (!isObject2(input)) {
641
889
  issue("", "A form definition must be an object.");
642
890
  return fail();
643
891
  }
@@ -646,19 +894,19 @@ function parseForm(input) {
646
894
  text(input.title, "title");
647
895
  optionalText(input.description, "description");
648
896
  if (input.layout !== void 0) {
649
- if (!isObject(input.layout)) issue("layout", "Must be an object.");
897
+ if (!isObject2(input.layout)) issue("layout", "Must be an object.");
650
898
  else if (input.layout.mode !== "focus" && input.layout.mode !== "classic") {
651
899
  issue("layout.mode", "Must be focus or classic.");
652
900
  }
653
901
  }
654
902
  if (input.settings !== void 0) {
655
- if (!isObject(input.settings)) issue("settings", "Must be an object.");
903
+ if (!isObject2(input.settings)) issue("settings", "Must be an object.");
656
904
  else {
657
905
  optionalBoolean(input.settings.anonymous, "settings.anonymous");
658
906
  optionalBoolean(input.settings.clearHiddenValues, "settings.clearHiddenValues");
659
907
  }
660
908
  }
661
- if (input.meta !== void 0 && (!isObject(input.meta) || !isJson(input.meta))) {
909
+ if (input.meta !== void 0 && (!isObject2(input.meta) || !isJson(input.meta))) {
662
910
  issue("meta", "Must be a JSON object.");
663
911
  }
664
912
  if (!Array.isArray(input.blocks)) {
@@ -670,9 +918,10 @@ function parseForm(input) {
670
918
  const keys = /* @__PURE__ */ new Map();
671
919
  const fieldTypes = /* @__PURE__ */ new Map();
672
920
  const order = /* @__PURE__ */ new Map();
921
+ const choices = /* @__PURE__ */ new Map();
673
922
  input.blocks.forEach((block, i) => {
674
923
  const path = `blocks[${i}]`;
675
- if (!isObject(block) || typeof block.id !== "string" || !block.id) {
924
+ if (!isObject2(block) || typeof block.id !== "string" || !block.id) {
676
925
  issue(path, "Every block needs a string id.");
677
926
  return;
678
927
  }
@@ -693,18 +942,30 @@ function parseForm(input) {
693
942
  issue(`${path}.type`, `Unknown field type "${String(block.type)}".`);
694
943
  }
695
944
  if (block.props !== void 0) {
696
- if (!isObject(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
945
+ if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
697
946
  else if (block.props.options !== void 0) {
698
947
  const options = block.props.options;
699
948
  if (!Array.isArray(options)) issue(`${path}.props.options`, "Must be an array.");
700
949
  else {
701
950
  options.forEach((o, j) => {
702
951
  const at = `${path}.props.options[${j}]`;
703
- if (!isObject(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
952
+ if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
704
953
  else optionalText(o.label, `${at}.label`);
705
954
  });
955
+ choices.set(
956
+ block.id,
957
+ new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
958
+ );
706
959
  }
707
960
  }
961
+ const { max, maxLength: maxLength2 } = isObject2(block.props) ? block.props : {};
962
+ if (block.type === "rating" && max !== void 0 && !(isInt(max) && max >= 2 && max <= 10)) {
963
+ issue(`${path}.props.max`, "Must be a whole number from 2 to 10.");
964
+ }
965
+ if (maxLength2 !== void 0 && !(isInt(maxLength2) && maxLength2 > 0)) {
966
+ issue(`${path}.props.maxLength`, "Must be a whole number above 0.");
967
+ }
968
+ if (isObject2(block.props) && typeof block.type === "string") structuredProps(block.type, block.props, `${path}.props`);
708
969
  }
709
970
  if (validKey) {
710
971
  if (keys.has(block.key)) issue(`${path}.key`, `Duplicate key "${String(block.key)}".`);
@@ -721,9 +982,10 @@ function parseForm(input) {
721
982
  }
722
983
  });
723
984
  const hiddenNames = /* @__PURE__ */ new Set();
985
+ const hiddenTypes = /* @__PURE__ */ new Map();
724
986
  if (input.hidden !== void 0 && !Array.isArray(input.hidden)) issue("hidden", "Must be an array.");
725
987
  (Array.isArray(input.hidden) ? input.hidden : []).forEach((h, i) => {
726
- if (!isObject(h) || typeof h.name !== "string" || !h.name) {
988
+ if (!isObject2(h) || typeof h.name !== "string" || !h.name) {
727
989
  issue(`hidden[${i}]`, "Each hidden input needs a name.");
728
990
  return;
729
991
  }
@@ -736,13 +998,15 @@ function parseForm(input) {
736
998
  issue(`hidden[${i}].type`, "Must be string, number or boolean.");
737
999
  }
738
1000
  optionalBoolean(h.sensitive, `hidden[${i}].sensitive`);
1001
+ optionalBoolean(h.identifying, `hidden[${i}].identifying`);
739
1002
  hiddenNames.add(h.name);
1003
+ if (typeof h.type === "string") hiddenTypes.set(h.name, h.type);
740
1004
  });
741
1005
  const endingIds = /* @__PURE__ */ new Set();
742
1006
  if (input.endings !== void 0 && !Array.isArray(input.endings)) issue("endings", "Must be an array.");
743
1007
  (Array.isArray(input.endings) ? input.endings : []).forEach((e, i) => {
744
1008
  const path = `endings[${i}]`;
745
- if (!isObject(e)) {
1009
+ if (!isObject2(e)) {
746
1010
  issue(path, "Each ending must be an object with an id and a title.");
747
1011
  return;
748
1012
  }
@@ -754,7 +1018,7 @@ function parseForm(input) {
754
1018
  });
755
1019
  const checkOperand = (o, path, reads) => {
756
1020
  if (o === void 0) return;
757
- if (!isObject(o)) return issue(path, "An operand must be an object.");
1021
+ if (!isObject2(o)) return issue(path, "An operand must be an object.");
758
1022
  if ("field" in o) {
759
1023
  if (typeof o.field !== "string" || !fieldTypes.has(o.field)) return issue(path, `No field "${String(o.field)}".`);
760
1024
  reads.push(o.field);
@@ -767,7 +1031,7 @@ function parseForm(input) {
767
1031
  }
768
1032
  };
769
1033
  const checkCondition = (c, path, reads, depth = 0) => {
770
- if (!isObject(c)) return issue(path, "A condition must be an object.");
1034
+ if (!isObject2(c)) return issue(path, "A condition must be an object.");
771
1035
  if (depth > MAX_CONDITION_DEPTH) return issue(path, "The condition is nested too deeply.");
772
1036
  if ("all" in c || "any" in c) {
773
1037
  const group = "all" in c ? "all" : "any";
@@ -776,7 +1040,7 @@ function parseForm(input) {
776
1040
  return list.forEach((x, i) => checkCondition(x, `${path}.${group}[${i}]`, reads, depth + 1));
777
1041
  }
778
1042
  if ("not" in c) return checkCondition(c.not, `${path}.not`, reads, depth + 1);
779
- if (typeof c.op !== "string" || !isObject(c.left)) {
1043
+ if (typeof c.op !== "string" || !isObject2(c.left)) {
780
1044
  return issue(path, "A comparison needs an `op` and a `left` operand.");
781
1045
  }
782
1046
  if (!OPERATORS.has(c.op)) issue(`${path}.op`, `Unknown operator "${c.op}".`);
@@ -785,16 +1049,53 @@ function parseForm(input) {
785
1049
  if (typeof c.left.field === "string") {
786
1050
  const manifest = getFieldType(fieldTypes.get(c.left.field) ?? "");
787
1051
  if (manifest && OPERATORS.has(c.op) && !manifest.operators.includes(c.op)) {
788
- issue(`${path}.op`, `"${c.op}" does not apply to a ${manifest.type} field.`);
1052
+ return issue(`${path}.op`, `"${c.op}" does not apply to a ${manifest.type} field.`);
789
1053
  }
790
1054
  }
1055
+ checkLiterals(c.op, c.left, c.right, path);
1056
+ };
1057
+ const checkLiterals = (op, left, right, path) => {
1058
+ if (op === "answered" || op === "empty" || !OPERATORS.has(op)) return;
1059
+ const literal = isObject2(right) && "value" in right;
1060
+ if (right !== void 0 && !literal) return;
1061
+ let what = "the left side";
1062
+ let type;
1063
+ let options;
1064
+ if (typeof left.field === "string" && fieldTypes.has(left.field)) {
1065
+ const fieldType = fieldTypes.get(left.field);
1066
+ what = `"${keyOf(left.field)}"`;
1067
+ type = LITERAL_TYPE[fieldType];
1068
+ if (fieldType === "single-choice" || fieldType === "multi-choice") options = choices.get(left.field);
1069
+ } else if (typeof left.hidden === "string" && hiddenTypes.has(left.hidden)) {
1070
+ what = `hidden input "${left.hidden}"`;
1071
+ type = hiddenTypes.get(left.hidden);
1072
+ }
1073
+ const outcome = op === "neq" ? "always true" : "never true";
1074
+ let values;
1075
+ if (op === "in") {
1076
+ if (!literal || !Array.isArray(right.value)) {
1077
+ return void warnings.push({ path: `${path}.right`, message: "`in` needs a list of values; it never matches." });
1078
+ }
1079
+ values = right.value;
1080
+ } else if (!literal) return;
1081
+ else values = [right.value];
1082
+ if (ORDERING.has(op)) {
1083
+ if (type !== void 0 && type !== "number") {
1084
+ return void warnings.push({ path: `${path}.op`, message: `"${op}" compares numbers, but ${what} holds a ${type}; it is never true.` });
1085
+ }
1086
+ type = "number";
1087
+ }
1088
+ for (const v of values) {
1089
+ 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;
1090
+ if (message) warnings.push({ path: `${path}.right`, message });
1091
+ }
791
1092
  };
792
1093
  const keyOf = (id) => [...keys].find(([, v]) => v === id)?.[0] ?? id;
793
1094
  const ruleIds = /* @__PURE__ */ new Set();
794
1095
  if (input.logic !== void 0 && !Array.isArray(input.logic)) issue("logic", "Must be an array.");
795
1096
  (Array.isArray(input.logic) ? input.logic : []).forEach((rule, i) => {
796
1097
  const path = `logic[${i}]`;
797
- if (!isObject(rule) || typeof rule.id !== "string" || !isObject(rule.when)) {
1098
+ if (!isObject2(rule) || typeof rule.id !== "string" || !isObject2(rule.when)) {
798
1099
  issue(path, "Each rule needs an id and a `when` condition.");
799
1100
  return;
800
1101
  }
@@ -813,7 +1114,7 @@ function parseForm(input) {
813
1114
  at = rule.from;
814
1115
  inclusive = true;
815
1116
  }
816
- if (isObject(rule.to)) {
1117
+ if (isObject2(rule.to)) {
817
1118
  if (typeof rule.to.ending !== "string" || !endingIds.has(rule.to.ending)) {
818
1119
  issue(`${path}.to`, `No ending "${String(rule.to.ending)}".`);
819
1120
  }
@@ -845,7 +1146,7 @@ function parseForm(input) {
845
1146
  }
846
1147
  });
847
1148
  input.blocks.forEach((block, i) => {
848
- if (!isObject(block)) return;
1149
+ if (!isObject2(block)) return;
849
1150
  const raw = block.kind === "field" ? [block.label, block.description] : block.kind === "content" ? [block.text] : [];
850
1151
  const texts = raw.filter((t) => typeof t === "string");
851
1152
  for (const t of texts) {
@@ -863,6 +1164,18 @@ function parseForm(input) {
863
1164
  }
864
1165
  }
865
1166
  });
1167
+ const extra = [["description", input.description]];
1168
+ (Array.isArray(input.endings) ? input.endings : []).forEach((e, i) => {
1169
+ if (isObject2(e)) extra.push([`endings[${i}]`, e.title], [`endings[${i}]`, e.description]);
1170
+ });
1171
+ for (const [path, t] of extra) {
1172
+ if (typeof t !== "string") continue;
1173
+ for (const ref of recallRefs(t)) {
1174
+ if (ref.kind === "hidden" ? !hiddenNames.has(ref.name) : !keys.has(ref.name)) {
1175
+ warnings.push({ path, message: `Recalls unknown ${ref.kind === "hidden" ? "hidden input" : "field"} "${ref.name}"; it shows its fallback text.` });
1176
+ }
1177
+ }
1178
+ }
866
1179
  return issues.length ? fail() : { ok: true, form: input, warnings };
867
1180
  }
868
1181
 
@@ -899,18 +1212,16 @@ function validateSubmission(definition, submission, options = {}) {
899
1212
  if (invalid) return { ok: false, errors: { "": invalid } };
900
1213
  const input = wellFormed(submission);
901
1214
  if (!input) return MALFORMED;
902
- const ignored = [];
1215
+ const ignored = /* @__PURE__ */ new Set();
903
1216
  const client = coerceHidden(definition, input.hidden, { sources: ["url", "host"] });
904
- for (const name of client.ignored) ignored.push(`hidden.${name}`);
1217
+ for (const name of client.ignored) ignored.add(`hidden.${name}`);
905
1218
  const hidden = client.values;
906
1219
  const context = coerceHidden(definition, options.context, { sources: ["server"] }).values;
907
1220
  const fields = fieldMap(definition);
908
- const dropped = /* @__PURE__ */ new Set();
909
1221
  const accept = (id) => {
910
- if (!fields.has(id)) return void ignored.push(id);
1222
+ if (!fields.has(id)) return void ignored.add(id);
911
1223
  if (!withheld(definition, fields.get(id))) return true;
912
- if (!dropped.has(id)) ignored.push(id);
913
- dropped.add(id);
1224
+ ignored.add(id);
914
1225
  return false;
915
1226
  };
916
1227
  const engineHidden = { ...hidden, ...context };
@@ -963,15 +1274,25 @@ function validateSubmission(definition, submission, options = {}) {
963
1274
  if (message === null) answers[id] = normalizeValue(value, field);
964
1275
  }
965
1276
  }
1277
+ const store = (values, report) => {
1278
+ if (definition.settings?.anonymous !== true) return values;
1279
+ const identifying = new Set((definition.hidden ?? []).filter((h) => h.identifying === true).map((h) => h.name));
1280
+ const kept = {};
1281
+ for (const [name, value] of Object.entries(values)) {
1282
+ if (!identifying.has(name)) kept[name] = value;
1283
+ else if (report) ignored.add(`hidden.${name}`);
1284
+ }
1285
+ return kept;
1286
+ };
966
1287
  return {
967
1288
  ok: true,
968
1289
  submission: {
969
1290
  status: complete ? "completed" : "draft",
970
1291
  answers,
971
- hidden,
972
- context,
1292
+ hidden: store(hidden, true),
1293
+ context: store(context, false),
973
1294
  ending: complete ? state.ending : null,
974
- ignored
1295
+ ignored: [...ignored]
975
1296
  }
976
1297
  };
977
1298
  }