@marktiderman/genesis-forms 0.2.0 → 0.3.1

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 +113 -13
  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 +1235 -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 +1193 -0
  17. package/dist/destinations.js.map +1 -0
  18. package/dist/{engine-Coc-gQIB.d.ts → engine-CPLtKVew.d.cts} +2 -1
  19. package/dist/{engine-Bj8-mMVb.d.cts → engine-DOrPgFDw.d.ts} +2 -1
  20. package/dist/index.cjs +322 -24
  21. package/dist/index.cjs.map +1 -1
  22. package/dist/index.d.cts +102 -5
  23. package/dist/index.d.ts +102 -5
  24. package/dist/index.js +311 -25
  25. package/dist/index.js.map +1 -1
  26. package/dist/server.cjs +249 -22
  27. package/dist/server.cjs.map +1 -1
  28. package/dist/server.d.cts +3 -2
  29. package/dist/server.d.ts +3 -2
  30. package/dist/server.js +249 -22
  31. package/dist/server.js.map +1 -1
  32. package/dist/testing.cjs +253 -22
  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 +253 -22
  37. package/dist/testing.js.map +1 -1
  38. package/dist/{validate-BbackAjB.d.cts → types-BBKBQ8sp.d.cts} +2 -29
  39. package/dist/{validate-BbackAjB.d.ts → types-BBKBQ8sp.d.ts} +2 -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,4 +1,5 @@
1
- import { J as Json, f as FormEvent, e as FieldValidator, a as FormDefinition } from './validate-BbackAjB.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. */
@@ -9,7 +10,7 @@ interface SubmissionInput {
9
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
+ /** Values for `source: "server"` inputs, resolved by the server (account id, location, user id…). */
13
14
  context?: Record<string, Json> | undefined;
14
15
  /** `false` checks a draft: formats only, nothing required. Default true. */
15
16
  complete?: boolean | undefined;
package/dist/server.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { J as Json, f as FormEvent, e as FieldValidator, a as FormDefinition } from './validate-BbackAjB.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. */
@@ -9,7 +10,7 @@ interface SubmissionInput {
9
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
+ /** Values for `source: "server"` inputs, resolved by the server (account id, location, user id…). */
13
14
  context?: Record<string, Json> | undefined;
14
15
  /** `false` checks a draft: formats only, nothing required. Default true. */
15
16
  complete?: boolean | undefined;
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) {
@@ -608,7 +790,7 @@ function shown(v) {
608
790
  text ??= typeof v === "bigint" ? `${v}n` : typeof v;
609
791
  return text.length > 60 ? `${text.slice(0, 57)}...` : text;
610
792
  }
611
- var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
793
+ var isObject2 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
612
794
  var isInt = (v) => typeof v === "number" && Number.isInteger(v);
613
795
  var LITERAL_TYPE = {
614
796
  "short-text": "string",
@@ -619,7 +801,8 @@ var LITERAL_TYPE = {
619
801
  nps: "number",
620
802
  "single-choice": "string",
621
803
  "multi-choice": "string",
622
- "yes-no": "boolean"
804
+ "yes-no": "boolean",
805
+ list: "string"
623
806
  };
624
807
  var ORDERING = /* @__PURE__ */ new Set(["lt", "lte", "gt", "gte"]);
625
808
  var OPERATORS = /* @__PURE__ */ new Set([
@@ -661,7 +844,48 @@ function parseForm(input) {
661
844
  }
662
845
  return true;
663
846
  };
664
- 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)) {
665
889
  issue("", "A form definition must be an object.");
666
890
  return fail();
667
891
  }
@@ -670,19 +894,19 @@ function parseForm(input) {
670
894
  text(input.title, "title");
671
895
  optionalText(input.description, "description");
672
896
  if (input.layout !== void 0) {
673
- if (!isObject(input.layout)) issue("layout", "Must be an object.");
897
+ if (!isObject2(input.layout)) issue("layout", "Must be an object.");
674
898
  else if (input.layout.mode !== "focus" && input.layout.mode !== "classic") {
675
899
  issue("layout.mode", "Must be focus or classic.");
676
900
  }
677
901
  }
678
902
  if (input.settings !== void 0) {
679
- if (!isObject(input.settings)) issue("settings", "Must be an object.");
903
+ if (!isObject2(input.settings)) issue("settings", "Must be an object.");
680
904
  else {
681
905
  optionalBoolean(input.settings.anonymous, "settings.anonymous");
682
906
  optionalBoolean(input.settings.clearHiddenValues, "settings.clearHiddenValues");
683
907
  }
684
908
  }
685
- if (input.meta !== void 0 && (!isObject(input.meta) || !isJson(input.meta))) {
909
+ if (input.meta !== void 0 && (!isObject2(input.meta) || !isJson(input.meta))) {
686
910
  issue("meta", "Must be a JSON object.");
687
911
  }
688
912
  if (!Array.isArray(input.blocks)) {
@@ -697,7 +921,7 @@ function parseForm(input) {
697
921
  const choices = /* @__PURE__ */ new Map();
698
922
  input.blocks.forEach((block, i) => {
699
923
  const path = `blocks[${i}]`;
700
- if (!isObject(block) || typeof block.id !== "string" || !block.id) {
924
+ if (!isObject2(block) || typeof block.id !== "string" || !block.id) {
701
925
  issue(path, "Every block needs a string id.");
702
926
  return;
703
927
  }
@@ -718,23 +942,23 @@ function parseForm(input) {
718
942
  issue(`${path}.type`, `Unknown field type "${String(block.type)}".`);
719
943
  }
720
944
  if (block.props !== void 0) {
721
- 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.");
722
946
  else if (block.props.options !== void 0) {
723
947
  const options = block.props.options;
724
948
  if (!Array.isArray(options)) issue(`${path}.props.options`, "Must be an array.");
725
949
  else {
726
950
  options.forEach((o, j) => {
727
951
  const at = `${path}.props.options[${j}]`;
728
- 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.");
729
953
  else optionalText(o.label, `${at}.label`);
730
954
  });
731
955
  choices.set(
732
956
  block.id,
733
- new Set(options.flatMap((o) => isObject(o) && typeof o.value === "string" ? [o.value] : []))
957
+ new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
734
958
  );
735
959
  }
736
960
  }
737
- const { max, maxLength: maxLength2 } = isObject(block.props) ? block.props : {};
961
+ const { max, maxLength: maxLength2 } = isObject2(block.props) ? block.props : {};
738
962
  if (block.type === "rating" && max !== void 0 && !(isInt(max) && max >= 2 && max <= 10)) {
739
963
  issue(`${path}.props.max`, "Must be a whole number from 2 to 10.");
740
964
  }
@@ -742,6 +966,9 @@ function parseForm(input) {
742
966
  issue(`${path}.props.maxLength`, "Must be a whole number above 0.");
743
967
  }
744
968
  }
969
+ if (typeof block.type === "string" && (block.props === void 0 || isObject2(block.props))) {
970
+ structuredProps(block.type, block.props ?? {}, `${path}.props`);
971
+ }
745
972
  if (validKey) {
746
973
  if (keys.has(block.key)) issue(`${path}.key`, `Duplicate key "${String(block.key)}".`);
747
974
  keys.set(block.key, block.id);
@@ -760,7 +987,7 @@ function parseForm(input) {
760
987
  const hiddenTypes = /* @__PURE__ */ new Map();
761
988
  if (input.hidden !== void 0 && !Array.isArray(input.hidden)) issue("hidden", "Must be an array.");
762
989
  (Array.isArray(input.hidden) ? input.hidden : []).forEach((h, i) => {
763
- if (!isObject(h) || typeof h.name !== "string" || !h.name) {
990
+ if (!isObject2(h) || typeof h.name !== "string" || !h.name) {
764
991
  issue(`hidden[${i}]`, "Each hidden input needs a name.");
765
992
  return;
766
993
  }
@@ -781,7 +1008,7 @@ function parseForm(input) {
781
1008
  if (input.endings !== void 0 && !Array.isArray(input.endings)) issue("endings", "Must be an array.");
782
1009
  (Array.isArray(input.endings) ? input.endings : []).forEach((e, i) => {
783
1010
  const path = `endings[${i}]`;
784
- if (!isObject(e)) {
1011
+ if (!isObject2(e)) {
785
1012
  issue(path, "Each ending must be an object with an id and a title.");
786
1013
  return;
787
1014
  }
@@ -793,7 +1020,7 @@ function parseForm(input) {
793
1020
  });
794
1021
  const checkOperand = (o, path, reads) => {
795
1022
  if (o === void 0) return;
796
- 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.");
797
1024
  if ("field" in o) {
798
1025
  if (typeof o.field !== "string" || !fieldTypes.has(o.field)) return issue(path, `No field "${String(o.field)}".`);
799
1026
  reads.push(o.field);
@@ -806,7 +1033,7 @@ function parseForm(input) {
806
1033
  }
807
1034
  };
808
1035
  const checkCondition = (c, path, reads, depth = 0) => {
809
- 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.");
810
1037
  if (depth > MAX_CONDITION_DEPTH) return issue(path, "The condition is nested too deeply.");
811
1038
  if ("all" in c || "any" in c) {
812
1039
  const group = "all" in c ? "all" : "any";
@@ -815,7 +1042,7 @@ function parseForm(input) {
815
1042
  return list.forEach((x, i) => checkCondition(x, `${path}.${group}[${i}]`, reads, depth + 1));
816
1043
  }
817
1044
  if ("not" in c) return checkCondition(c.not, `${path}.not`, reads, depth + 1);
818
- if (typeof c.op !== "string" || !isObject(c.left)) {
1045
+ if (typeof c.op !== "string" || !isObject2(c.left)) {
819
1046
  return issue(path, "A comparison needs an `op` and a `left` operand.");
820
1047
  }
821
1048
  if (!OPERATORS.has(c.op)) issue(`${path}.op`, `Unknown operator "${c.op}".`);
@@ -831,7 +1058,7 @@ function parseForm(input) {
831
1058
  };
832
1059
  const checkLiterals = (op, left, right, path) => {
833
1060
  if (op === "answered" || op === "empty" || !OPERATORS.has(op)) return;
834
- const literal = isObject(right) && "value" in right;
1061
+ const literal = isObject2(right) && "value" in right;
835
1062
  if (right !== void 0 && !literal) return;
836
1063
  let what = "the left side";
837
1064
  let type;
@@ -870,7 +1097,7 @@ function parseForm(input) {
870
1097
  if (input.logic !== void 0 && !Array.isArray(input.logic)) issue("logic", "Must be an array.");
871
1098
  (Array.isArray(input.logic) ? input.logic : []).forEach((rule, i) => {
872
1099
  const path = `logic[${i}]`;
873
- if (!isObject(rule) || typeof rule.id !== "string" || !isObject(rule.when)) {
1100
+ if (!isObject2(rule) || typeof rule.id !== "string" || !isObject2(rule.when)) {
874
1101
  issue(path, "Each rule needs an id and a `when` condition.");
875
1102
  return;
876
1103
  }
@@ -889,7 +1116,7 @@ function parseForm(input) {
889
1116
  at = rule.from;
890
1117
  inclusive = true;
891
1118
  }
892
- if (isObject(rule.to)) {
1119
+ if (isObject2(rule.to)) {
893
1120
  if (typeof rule.to.ending !== "string" || !endingIds.has(rule.to.ending)) {
894
1121
  issue(`${path}.to`, `No ending "${String(rule.to.ending)}".`);
895
1122
  }
@@ -921,7 +1148,7 @@ function parseForm(input) {
921
1148
  }
922
1149
  });
923
1150
  input.blocks.forEach((block, i) => {
924
- if (!isObject(block)) return;
1151
+ if (!isObject2(block)) return;
925
1152
  const raw = block.kind === "field" ? [block.label, block.description] : block.kind === "content" ? [block.text] : [];
926
1153
  const texts = raw.filter((t) => typeof t === "string");
927
1154
  for (const t of texts) {
@@ -941,7 +1168,7 @@ function parseForm(input) {
941
1168
  });
942
1169
  const extra = [["description", input.description]];
943
1170
  (Array.isArray(input.endings) ? input.endings : []).forEach((e, i) => {
944
- if (isObject(e)) extra.push([`endings[${i}]`, e.title], [`endings[${i}]`, e.description]);
1171
+ if (isObject2(e)) extra.push([`endings[${i}]`, e.title], [`endings[${i}]`, e.description]);
945
1172
  });
946
1173
  for (const [path, t] of extra) {
947
1174
  if (typeof t !== "string") continue;