@marktiderman/genesis-forms 0.8.0 → 0.9.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 (36) hide show
  1. package/README.md +299 -10
  2. package/dist/destinations.cjs +202 -39
  3. package/dist/destinations.cjs.map +1 -1
  4. package/dist/destinations.d.cts +67 -21
  5. package/dist/destinations.d.ts +67 -21
  6. package/dist/destinations.js +202 -40
  7. package/dist/destinations.js.map +1 -1
  8. package/dist/{engine-Bj23YJlB.d.cts → engine-BaHPC2-1.d.cts} +2 -2
  9. package/dist/{engine-CzinTJph.d.ts → engine-Da99Tv-E.d.ts} +2 -2
  10. package/dist/index.cjs +996 -167
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +288 -11
  13. package/dist/index.d.ts +288 -11
  14. package/dist/index.js +975 -168
  15. package/dist/index.js.map +1 -1
  16. package/dist/server.cjs +563 -173
  17. package/dist/server.cjs.map +1 -1
  18. package/dist/server.d.cts +3 -474
  19. package/dist/server.d.ts +3 -474
  20. package/dist/server.js +558 -174
  21. package/dist/server.js.map +1 -1
  22. package/dist/submit-protocol-CFi-0Qd0.d.ts +840 -0
  23. package/dist/submit-protocol-D0cOckmK.d.cts +840 -0
  24. package/dist/testing.cjs +250 -154
  25. package/dist/testing.cjs.map +1 -1
  26. package/dist/testing.d.cts +4 -4
  27. package/dist/testing.d.ts +4 -4
  28. package/dist/testing.js +250 -154
  29. package/dist/testing.js.map +1 -1
  30. package/dist/{types-jqDKcmP4.d.cts → types-C0pUfew3.d.cts} +7 -1
  31. package/dist/{types-jqDKcmP4.d.ts → types-C0pUfew3.d.ts} +7 -1
  32. package/dist/{validate-DvfQHYnu.d.ts → validate-DpmBssFk.d.ts} +258 -140
  33. package/dist/{validate-irIPubxc.d.cts → validate-U5LoW3eR.d.cts} +258 -140
  34. package/package.json +2 -2
  35. package/dist/upload-protocol-BOe018kL.d.cts +0 -122
  36. package/dist/upload-protocol-BUO74WkS.d.ts +0 -122
package/dist/testing.js CHANGED
@@ -1,3 +1,42 @@
1
+ // src/props-check.ts
2
+ var RESERVED_NAMES = new Set(Object.getOwnPropertyNames(Object.prototype));
3
+ var isPlainRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
4
+ var isWholeNumber = (v) => typeof v === "number" && Number.isInteger(v);
5
+ var isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
6
+ function propsChecker(prefix, report) {
7
+ const at = (path) => path ? `${prefix}.${path}` : prefix;
8
+ const issue = (path, message) => report(at(path), message);
9
+ return {
10
+ issue,
11
+ text(v, path, nonEmpty = false) {
12
+ if (typeof v !== "string" || nonEmpty && !v) issue(path, nonEmpty ? "Required." : "Required text.");
13
+ },
14
+ optionalText(v, path) {
15
+ if (v !== void 0 && typeof v !== "string") issue(path, "Must be text.");
16
+ },
17
+ optionalBoolean(v, path) {
18
+ if (v !== void 0 && typeof v !== "boolean") issue(path, "Must be true or false.");
19
+ },
20
+ optionalInt(v, path, floor) {
21
+ if (v !== void 0 && !(isWholeNumber(v) && v >= floor)) issue(path, `Must be a whole number of ${floor} or more.`);
22
+ },
23
+ ordered(low, high, path, what) {
24
+ if (isWholeNumber(low) && isWholeNumber(high) && low > high) issue(path, `Can't be more than the ${what}.`);
25
+ },
26
+ name(v, path, what) {
27
+ if (typeof v !== "string" || !v) {
28
+ issue(path, `${what} must be a non-empty string.`);
29
+ return false;
30
+ }
31
+ if (RESERVED_NAMES.has(v)) {
32
+ issue(path, `"${v}" is reserved and cannot be used as ${what.toLowerCase()}.`);
33
+ return false;
34
+ }
35
+ return true;
36
+ }
37
+ };
38
+ }
39
+
1
40
  // src/formula.ts
2
41
  var FORMULA_FUNCTIONS = ["sum", "avg", "min", "max"];
3
42
  var MAX_FORMULA_LENGTH = 2e3;
@@ -532,6 +571,17 @@ var calculatedFieldType = {
532
571
  value: { json: true },
533
572
  operators: numberOps,
534
573
  a11y: { semantics: "control" },
574
+ // A number, unless the format says text; without a format it can hold either, so it isn't known.
575
+ literal: (props) => props.format === "text" ? "string" : props.format === "number" || props.format === "percent" ? "number" : void 0,
576
+ // The formula itself is checked once every key is known (`parseForm`).
577
+ checkProps: (props, check) => {
578
+ if (props.format !== void 0 && props.format !== "number" && props.format !== "percent" && props.format !== "text") {
579
+ check.issue("format", "Must be number, percent or text.");
580
+ }
581
+ if (props.decimals !== void 0 && !(isWholeNumber(props.decimals) && props.decimals >= 0 && props.decimals <= MAX_CALCULATED_DECIMALS)) {
582
+ check.issue("decimals", `Must be a whole number from 0 to ${MAX_CALCULATED_DECIMALS}.`);
583
+ }
584
+ },
535
585
  // The engine works the value out; any number, text or true / false it made is valid.
536
586
  validate: (value) => typeof value === "number" || typeof value === "string" || typeof value === "boolean" ? null : "This value is worked out from other answers.",
537
587
  summarize: (value, field) => formatCalculated(value, field)
@@ -589,12 +639,42 @@ var MAX_HIGHLIGHT_NOTE_LENGTH = 1e3;
589
639
  var MAX_HIGHLIGHT_QUOTE_LENGTH = 2e3;
590
640
  var HIGHLIGHT_NOTE_MESSAGE = "Add a note to each highlight.";
591
641
  var sourceText = (field) => typeof field.props?.text === "string" && field.props.text ? field.props.text : void 0;
642
+ var listProps = (props, check) => {
643
+ check.optionalInt(props.min, "min", 0);
644
+ check.optionalInt(props.max, "max", 1);
645
+ check.optionalInt(props.itemMaxLength, "itemMaxLength", 1);
646
+ check.optionalText(props.placeholder, "placeholder");
647
+ check.ordered(props.min, props.max ?? 10, "min", "most items");
648
+ };
649
+ var tableProps = (props, check) => {
650
+ const columns = props.columns;
651
+ if (!Array.isArray(columns) || columns.length < 1 || columns.length > MAX_TABLE_COLUMNS) {
652
+ check.issue("columns", `Must be a list of 1 to ${MAX_TABLE_COLUMNS} columns.`);
653
+ } else {
654
+ const seen = /* @__PURE__ */ new Set();
655
+ columns.forEach((c, j) => {
656
+ const at = `columns[${j}]`;
657
+ if (!isPlainRecord(c)) return check.issue(at, "Each column needs a key and a label.");
658
+ if (check.name(c.key, `${at}.key`, "A column key")) {
659
+ if (seen.has(c.key)) check.issue(`${at}.key`, `Duplicate column key "${c.key}".`);
660
+ seen.add(c.key);
661
+ }
662
+ check.text(c.label, `${at}.label`);
663
+ check.optionalBoolean(c.long, `${at}.long`);
664
+ });
665
+ }
666
+ check.optionalInt(props.minRows, "minRows", 0);
667
+ check.optionalInt(props.maxRows, "maxRows", 1);
668
+ check.ordered(props.minRows, props.maxRows ?? 3, "minRows", "most rows");
669
+ };
592
670
  var structuredFieldTypes = [
593
671
  {
594
672
  type: "list",
595
673
  label: "List",
596
674
  category: "text",
597
675
  value: { json: true },
676
+ literal: "string",
677
+ checkProps: listProps,
598
678
  // logic.ts `contains` on an array is exact item equality, same as multi-choice, so it is offered.
599
679
  operators: ["contains", "answered", "empty"],
600
680
  a11y: { semantics: "group" },
@@ -620,6 +700,7 @@ var structuredFieldTypes = [
620
700
  label: "Table",
621
701
  category: "advanced",
622
702
  value: { json: true },
703
+ checkProps: tableProps,
623
704
  operators: ["answered", "empty"],
624
705
  a11y: { semantics: "group" },
625
706
  isEmpty: (value) => emptyList(value, (r) => isObject(r) && Object.values(r).every(blankText)),
@@ -663,6 +744,10 @@ var structuredFieldTypes = [
663
744
  label: "Notes at moments",
664
745
  category: "media",
665
746
  value: { json: true },
747
+ checkProps: (props, check) => {
748
+ check.optionalInt(props.maxNotes, "maxNotes", 1);
749
+ check.optionalInt(props.textMaxLength, "textMaxLength", 1);
750
+ },
666
751
  operators: ["answered", "empty"],
667
752
  a11y: { semantics: "group" },
668
753
  isEmpty: (value) => emptyList(value, (n) => isObject(n) && blankText(n.text)),
@@ -686,6 +771,10 @@ var structuredFieldTypes = [
686
771
  label: "Highlights",
687
772
  category: "media",
688
773
  value: { json: true },
774
+ checkProps: (props, check) => {
775
+ check.optionalText(props.text, "text");
776
+ check.optionalInt(props.maxHighlights, "maxHighlights", 1);
777
+ },
689
778
  operators: ["answered", "empty"],
690
779
  a11y: { semantics: "group" },
691
780
  isEmpty: (value) => emptyList(value, () => false),
@@ -777,6 +866,22 @@ var photoFieldType = {
777
866
  value: { json: true },
778
867
  operators: ["answered", "empty"],
779
868
  a11y: { semantics: "group" },
869
+ checkProps: (props, check) => {
870
+ if (props.maxFiles !== void 0 && !(isWholeNumber(props.maxFiles) && props.maxFiles >= 1 && props.maxFiles <= MAX_PHOTO_FILES)) {
871
+ check.issue("maxFiles", `Must be a whole number from 1 to ${MAX_PHOTO_FILES}.`);
872
+ }
873
+ if (props.maxSizeMb !== void 0 && !(isWholeNumber(props.maxSizeMb) && props.maxSizeMb >= 1 && props.maxSizeMb <= MAX_PHOTO_SIZE_MB)) {
874
+ check.issue("maxSizeMb", `Must be a whole number from 1 to ${MAX_PHOTO_SIZE_MB}.`);
875
+ }
876
+ if (props.formats !== void 0) {
877
+ const formats = props.formats;
878
+ if (!Array.isArray(formats) || formats.length === 0 || !formats.every(isPhotoFormat)) {
879
+ check.issue("formats", "Must be a list of one or more of jpeg, png, heic and webp.");
880
+ } else if (new Set(formats).size !== formats.length) {
881
+ check.issue("formats", "Name each format once.");
882
+ }
883
+ }
884
+ },
780
885
  isEmpty: (value) => value === void 0 || value === null || Array.isArray(value) && value.length === 0,
781
886
  validate: (value, field) => {
782
887
  if (!Array.isArray(value) || !value.every(isPhotoRef)) return "Add a photo.";
@@ -794,22 +899,59 @@ var photoFieldType = {
794
899
  };
795
900
 
796
901
  // src/fields.ts
797
- var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
902
+ var VALIDATOR_FAILED_MESSAGE = "This answer couldn't be checked.";
903
+ var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types@2");
904
+ var BUILT_IN = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/built-in-field-types@2");
798
905
  var globalScope = globalThis;
799
906
  var registry = globalScope[REGISTRY] ??= /* @__PURE__ */ new Map();
800
- function registerFieldType(manifest) {
801
- if (!manifest.type) throw new Error("registerFieldType: a field type needs a `type`.");
802
- const value = manifest.value;
803
- if (!value.json && (typeof value.serialize !== "function" || typeof value.deserialize !== "function")) {
804
- throw new Error(
805
- `registerFieldType: "${manifest.type}" stores a non-JSON value, so it must provide both serialize and deserialize.`
806
- );
907
+ var builtIn = globalScope[BUILT_IN] ??= /* @__PURE__ */ new Set();
908
+ var freezeRecord = (v) => v && typeof v === "object" && !Array.isArray(v) ? Object.freeze({ ...v }) : v;
909
+ function frozen(manifest) {
910
+ const copy = { ...manifest };
911
+ for (const key of ["choice", "a11y", "value"]) if (key in copy) copy[key] = freezeRecord(copy[key]);
912
+ if (Array.isArray(copy.operators)) {
913
+ const operators = Object.freeze([...copy.operators]);
914
+ Object.defineProperty(copy, "operators", { get: () => [...operators], enumerable: true });
807
915
  }
808
- registry.set(manifest.type, manifest);
916
+ return Object.freeze(copy);
917
+ }
918
+ function registerBuiltIn(manifest) {
919
+ builtIn.add(manifest.type);
920
+ if (!registry.has(manifest.type)) registry.set(manifest.type, frozen(manifest));
809
921
  }
810
922
  function getFieldType(type) {
811
923
  return registry.get(type);
812
924
  }
925
+ function choiceKind(type) {
926
+ return registry.get(type)?.choice;
927
+ }
928
+ function isChoiceField(block) {
929
+ return !!block && block.kind === "field" && typeof block.type === "string" && choiceKind(block.type) !== void 0;
930
+ }
931
+ function literalType(type, props) {
932
+ const literal = registry.get(type)?.literal;
933
+ if (typeof literal !== "function") return literal;
934
+ try {
935
+ return literal(props);
936
+ } catch {
937
+ return void 0;
938
+ }
939
+ }
940
+ var CONTENT_REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/content-types@1");
941
+ var contentScope = globalThis;
942
+ var contentRegistry = contentScope[CONTENT_REGISTRY] ??= /* @__PURE__ */ new Map();
943
+ function getContentType(type) {
944
+ return contentRegistry.get(type);
945
+ }
946
+ function listContentTypes() {
947
+ return [...contentRegistry.values()];
948
+ }
949
+ for (const manifest of [
950
+ { type: "heading", label: "Heading" },
951
+ { type: "text", label: "Text" }
952
+ ]) {
953
+ if (!contentRegistry.has(manifest.type)) contentRegistry.set(manifest.type, frozen(manifest));
954
+ }
813
955
  function isEmptyValue(value, field) {
814
956
  const manifest = field ? registry.get(field.type) : void 0;
815
957
  if (manifest?.isEmpty) return manifest.isEmpty(value);
@@ -992,6 +1134,18 @@ function isPhoneNumber(value) {
992
1134
  }
993
1135
  return digits >= 7 && digits <= 15;
994
1136
  }
1137
+ var oneOptionValidate = (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.";
1138
+ var oneOptionSummary = (value, field) => optionLabel(field, String(value));
1139
+ var optionsValidate = (value, field) => {
1140
+ const allowed = new Set(choiceOptions(field).map((o) => o.value));
1141
+ const others = Array.isArray(value) ? value.filter((v) => typeof v === "string" && !allowed.has(v)) : [];
1142
+ return Array.isArray(value) && new Set(value).size === value.length && value.every((v) => typeof v === "string") && (others.length === 0 || others.length === 1 && typedOther(field, others[0])) ? null : "Choose from the options.";
1143
+ };
1144
+ var optionsSummary = (value, field) => Array.isArray(value) ? value.map((v) => optionLabel(field, String(v))).join(", ") : String(value);
1145
+ var scaleProps = (props, check) => {
1146
+ check.optionalText(props.lowLabel, "lowLabel");
1147
+ check.optionalText(props.highLabel, "highLabel");
1148
+ };
995
1149
  var builtins = [
996
1150
  {
997
1151
  type: "short-text",
@@ -1000,6 +1154,7 @@ var builtins = [
1000
1154
  value: { json: true },
1001
1155
  operators: textOps,
1002
1156
  a11y: { semantics: "control" },
1157
+ literal: "string",
1003
1158
  validate: textValidate(500)
1004
1159
  },
1005
1160
  {
@@ -1009,6 +1164,7 @@ var builtins = [
1009
1164
  value: { json: true },
1010
1165
  operators: textOps,
1011
1166
  a11y: { semantics: "control" },
1167
+ literal: "string",
1012
1168
  validate: textValidate(5e3)
1013
1169
  },
1014
1170
  {
@@ -1018,6 +1174,7 @@ var builtins = [
1018
1174
  value: { json: true },
1019
1175
  operators: textOps,
1020
1176
  a11y: { semantics: "control", autocomplete: "email", inputMode: "email" },
1177
+ literal: "string",
1021
1178
  validate: (value) => typeof value === "string" && isEmailAddress(value.trim()) ? null : "Enter an email address like name@example.com.",
1022
1179
  normalize: (value) => typeof value === "string" ? value.trim() : value
1023
1180
  },
@@ -1028,6 +1185,7 @@ var builtins = [
1028
1185
  value: { json: true },
1029
1186
  operators: ["eq", "neq", "answered", "empty"],
1030
1187
  a11y: { semantics: "control", autocomplete: "tel", inputMode: "tel" },
1188
+ literal: "string",
1031
1189
  validate: (value) => typeof value === "string" && isPhoneNumber(value.trim()) ? null : "Enter a phone number with 7 to 15 digits.",
1032
1190
  normalize: (value) => typeof value === "string" ? value.trim() : value,
1033
1191
  summarize: (value) => String(value)
@@ -1039,6 +1197,13 @@ var builtins = [
1039
1197
  value: { json: true },
1040
1198
  operators: numberOps2,
1041
1199
  a11y: { semantics: "group" },
1200
+ literal: "number",
1201
+ checkProps: (props, check) => {
1202
+ scaleProps(props, check);
1203
+ if (props.display !== void 0 && props.display !== "numbers" && props.display !== "stars") {
1204
+ check.issue("display", "Must be numbers or stars.");
1205
+ }
1206
+ },
1042
1207
  validate: (value, field) => {
1043
1208
  const { min, max } = intRange(field, 1, 5);
1044
1209
  return Number.isInteger(value) && value >= min && value <= max ? null : `Choose a rating from ${min} to ${max}.`;
@@ -1056,6 +1221,8 @@ var builtins = [
1056
1221
  value: { json: true },
1057
1222
  operators: numberOps2,
1058
1223
  a11y: { semantics: "group" },
1224
+ literal: "number",
1225
+ checkProps: scaleProps,
1059
1226
  validate: (value) => Number.isInteger(value) && value >= 0 && value <= 10 ? null : "Choose a score from 0 to 10.",
1060
1227
  summarize: (value, field) => {
1061
1228
  const end = scaleEndLabel(field, value, 0, 10);
@@ -1069,8 +1236,10 @@ var builtins = [
1069
1236
  value: { json: true },
1070
1237
  operators: ["eq", "neq", "in", "answered", "empty"],
1071
1238
  a11y: { semantics: "group" },
1072
- validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
1073
- summarize: (value, field) => optionLabel(field, String(value))
1239
+ literal: "string",
1240
+ choice: { multiple: false },
1241
+ validate: oneOptionValidate,
1242
+ summarize: oneOptionSummary
1074
1243
  },
1075
1244
  {
1076
1245
  type: "multi-choice",
@@ -1079,12 +1248,10 @@ var builtins = [
1079
1248
  value: { json: true },
1080
1249
  operators: ["contains", "answered", "empty"],
1081
1250
  a11y: { semantics: "group" },
1082
- validate: (value, field) => {
1083
- const allowed = new Set(choiceOptions(field).map((o) => o.value));
1084
- const others = Array.isArray(value) ? value.filter((v) => typeof v === "string" && !allowed.has(v)) : [];
1085
- return Array.isArray(value) && new Set(value).size === value.length && value.every((v) => typeof v === "string") && (others.length === 0 || others.length === 1 && typedOther(field, others[0])) ? null : "Choose from the options.";
1086
- },
1087
- summarize: (value, field) => Array.isArray(value) ? value.map((v) => optionLabel(field, String(v))).join(", ") : String(value)
1251
+ literal: "string",
1252
+ choice: { multiple: true },
1253
+ validate: optionsValidate,
1254
+ summarize: optionsSummary
1088
1255
  },
1089
1256
  {
1090
1257
  type: "yes-no",
@@ -1093,6 +1260,7 @@ var builtins = [
1093
1260
  value: { json: true },
1094
1261
  operators: ["eq", "neq", "answered", "empty"],
1095
1262
  a11y: { semantics: "group" },
1263
+ literal: "boolean",
1096
1264
  validate: (value) => typeof value === "boolean" ? null : "Choose yes or no.",
1097
1265
  summarize: (value) => value ? "Yes" : "No"
1098
1266
  },
@@ -1103,8 +1271,10 @@ var builtins = [
1103
1271
  value: { json: true },
1104
1272
  operators: ["eq", "neq", "in", "answered", "empty"],
1105
1273
  a11y: { semantics: "control" },
1106
- validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
1107
- summarize: (value, field) => optionLabel(field, String(value))
1274
+ literal: "string",
1275
+ choice: { multiple: false },
1276
+ validate: oneOptionValidate,
1277
+ summarize: oneOptionSummary
1108
1278
  },
1109
1279
  {
1110
1280
  type: "number",
@@ -1113,6 +1283,16 @@ var builtins = [
1113
1283
  value: { json: true },
1114
1284
  operators: numberOps2,
1115
1285
  a11y: { semantics: "control", inputMode: "decimal" },
1286
+ literal: "number",
1287
+ checkProps: (props, check) => {
1288
+ for (const k of ["min", "max", "step"]) {
1289
+ if (props[k] !== void 0 && !isFiniteNumber(props[k])) check.issue(k, "Must be a number.");
1290
+ }
1291
+ if (isFiniteNumber(props.step) && props.step <= 0) check.issue("step", "Must be above 0.");
1292
+ if (isFiniteNumber(props.min) && isFiniteNumber(props.max) && props.min > props.max) {
1293
+ check.issue("min", "Can't be more than the largest number.");
1294
+ }
1295
+ },
1116
1296
  validate: (value, field) => {
1117
1297
  if (typeof value !== "number" || !Number.isFinite(value)) return "Enter a number.";
1118
1298
  const { min, max, step } = numberLimits(field);
@@ -1139,6 +1319,17 @@ var builtins = [
1139
1319
  value: { json: true },
1140
1320
  operators: ["eq", "neq", "answered", "empty"],
1141
1321
  a11y: { semantics: "control" },
1322
+ literal: "string",
1323
+ checkProps: (props, check) => {
1324
+ for (const k of ["min", "max"]) {
1325
+ if (props[k] !== void 0 && !(typeof props[k] === "string" && isIsoDate(props[k]))) {
1326
+ check.issue(k, "Must be a date like 2026-10-22.");
1327
+ }
1328
+ }
1329
+ if (typeof props.min === "string" && typeof props.max === "string" && isIsoDate(props.min) && isIsoDate(props.max) && props.min > props.max) {
1330
+ check.issue("min", "Can't be after the latest date.");
1331
+ }
1332
+ },
1142
1333
  validate: (value, field) => {
1143
1334
  if (typeof value !== "string" || !isIsoDate(value.trim())) return "Enter a date like 2026-10-22.";
1144
1335
  const date = value.trim();
@@ -1161,6 +1352,8 @@ var builtins = [
1161
1352
  value: { json: true },
1162
1353
  operators: ["answered", "empty"],
1163
1354
  a11y: { semantics: "group" },
1355
+ literal: "boolean",
1356
+ checkProps: (props, check) => check.optionalText(props.text, "text"),
1164
1357
  // Unchecked is unanswered, so a required consent must be checked.
1165
1358
  isEmpty: (value) => value === void 0 || value === null || value === false,
1166
1359
  validate: (value) => value === true ? null : "Check the box to agree.",
@@ -1210,7 +1403,7 @@ function dateLimits(field) {
1210
1403
  if (typeof max === "string" && isIsoDate(max)) out.max = max;
1211
1404
  return out;
1212
1405
  }
1213
- for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType, photoFieldType]) registerFieldType(manifest);
1406
+ for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType, photoFieldType]) registerBuiltIn(manifest);
1214
1407
 
1215
1408
  // src/events.ts
1216
1409
  function isPlainObject(value) {
@@ -1427,8 +1620,8 @@ function fieldMap(def) {
1427
1620
  }
1428
1621
 
1429
1622
  // src/options.ts
1430
- var CHOICE_FIELD_TYPES = /* @__PURE__ */ new Set(["single-choice", "multi-choice", "dropdown"]);
1431
- var isChoice = (block) => !!block && block.kind === "field" && CHOICE_FIELD_TYPES.has(block.type);
1623
+ var isChoice = isChoiceField;
1624
+ var isMultiple = (field) => choiceKind(field.type)?.multiple === true;
1432
1625
  function put(record, key, value) {
1433
1626
  Object.defineProperty(record, key, { value, enumerable: true, writable: true, configurable: true });
1434
1627
  }
@@ -1506,7 +1699,7 @@ function isPrefillParam(param) {
1506
1699
  return typeof param === "string" && param.length <= MAX_PREFILL_PARAM_LENGTH && /^[A-Za-z0-9_.-]+$/.test(param);
1507
1700
  }
1508
1701
  function prefillSetting(field) {
1509
- if (!CHOICE_FIELD_TYPES.has(field.type)) return void 0;
1702
+ if (!choiceKind(field.type)) return void 0;
1510
1703
  const raw = field.props?.prefill;
1511
1704
  if (!raw || typeof raw !== "object" || Array.isArray(raw) || !isPrefillParam(raw.param)) return void 0;
1512
1705
  return { param: raw.param, hideWhenPrefilled: raw.hideWhenPrefilled === true };
@@ -1534,7 +1727,7 @@ function prefillFromLink(definition, params, values = {}) {
1534
1727
  if (!raw.length) continue;
1535
1728
  const shown2 = new Set(visibleOptions(definition, block, seen).map((o) => o.value));
1536
1729
  let value;
1537
- if (block.type === "multi-choice") {
1730
+ if (isMultiple(block)) {
1538
1731
  const picked = [];
1539
1732
  for (const r of raw) {
1540
1733
  for (const v of shown2.has(r) ? [r] : r.split(",").map((s) => s.trim())) {
@@ -1605,8 +1798,13 @@ function validateField(field, value, options = {}) {
1605
1798
  }
1606
1799
  const manifest = getFieldType(field.type);
1607
1800
  if (!manifest) return `Unknown field type "${field.type}".`;
1608
- const builtIn = manifest.validate?.(value, field) ?? null;
1609
- if (builtIn) return builtIn;
1801
+ let builtIn2;
1802
+ try {
1803
+ builtIn2 = manifest.validate?.(value, field) ?? null;
1804
+ } catch {
1805
+ builtIn2 = VALIDATOR_FAILED_MESSAGE;
1806
+ }
1807
+ if (builtIn2) return builtIn2;
1610
1808
  return options.custom ? runCustom(options.custom, value) : null;
1611
1809
  }
1612
1810
 
@@ -2090,25 +2288,6 @@ function shown(v) {
2090
2288
  }
2091
2289
  var isObject2 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
2092
2290
  var isInt = (v) => typeof v === "number" && Number.isInteger(v);
2093
- var LITERAL_TYPE = {
2094
- "short-text": "string",
2095
- "long-text": "string",
2096
- email: "string",
2097
- phone: "string",
2098
- rating: "number",
2099
- nps: "number",
2100
- "single-choice": "string",
2101
- "multi-choice": "string",
2102
- "yes-no": "boolean",
2103
- list: "string",
2104
- dropdown: "string",
2105
- number: "number",
2106
- date: "string",
2107
- consent: "boolean",
2108
- // A calculated field holds a number unless its format says text; see `literalType`.
2109
- calculated: "number"
2110
- };
2111
- var CHOICE_TYPES = /* @__PURE__ */ new Set(["single-choice", "multi-choice", "dropdown"]);
2112
2291
  var ORDERING = /* @__PURE__ */ new Set(["lt", "lte", "gt", "gte"]);
2113
2292
  var OPERATORS = /* @__PURE__ */ new Set([
2114
2293
  "eq",
@@ -2122,8 +2301,11 @@ var OPERATORS = /* @__PURE__ */ new Set([
2122
2301
  "answered",
2123
2302
  "empty"
2124
2303
  ]);
2125
- var RESERVED = new Set(Object.getOwnPropertyNames(Object.prototype));
2126
2304
  var MAX_CONDITION_DEPTH = 32;
2305
+ function contentKinds() {
2306
+ const names = listContentTypes().map((c) => c.type);
2307
+ return names.length > 1 ? `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}` : names[0] ?? "a registered content type";
2308
+ }
2127
2309
  function parseForm(input) {
2128
2310
  const issues = [];
2129
2311
  const warnings = [];
@@ -2153,7 +2335,7 @@ function parseForm(input) {
2153
2335
  }
2154
2336
  const fits = (x) => typeof x === "string" && x.length <= OPTION_LIMITS.attributeValue;
2155
2337
  for (const [name2, v] of Object.entries(attributes)) {
2156
- if (!name2.trim() || name2.length > OPTION_LIMITS.attributeName || RESERVED.has(name2)) {
2338
+ if (!name2.trim() || name2.length > OPTION_LIMITS.attributeName || RESERVED_NAMES.has(name2)) {
2157
2339
  issue(`${at}.attributes`, `${shown(name2)} isn't a usable attribute name (at most ${OPTION_LIMITS.attributeName} characters).`);
2158
2340
  } else if (!fits(v) && !(Array.isArray(v) && v.length <= OPTION_LIMITS.attributeItems && v.every(fits))) {
2159
2341
  issue(
@@ -2168,104 +2350,18 @@ function parseForm(input) {
2168
2350
  issue(path, `${what} must be a non-empty string.`);
2169
2351
  return false;
2170
2352
  }
2171
- if (RESERVED.has(v)) {
2353
+ if (RESERVED_NAMES.has(v)) {
2172
2354
  issue(path, `"${v}" is reserved and cannot be used as ${what.toLowerCase()}.`);
2173
2355
  return false;
2174
2356
  }
2175
2357
  return true;
2176
2358
  };
2177
- const optionalInt = (v, path, floor) => {
2178
- if (v !== void 0 && !(isInt(v) && v >= floor)) issue(path, `Must be a whole number of ${floor} or more.`);
2179
- };
2180
- const ordered = (low, high, path, what) => {
2181
- if (isInt(low) && isInt(high) && low > high) issue(path, `Can't be more than the ${what}.`);
2182
- };
2183
- const structuredProps = (type, props, path) => {
2184
- if (type === "list") {
2185
- optionalInt(props.min, `${path}.min`, 0);
2186
- optionalInt(props.max, `${path}.max`, 1);
2187
- optionalInt(props.itemMaxLength, `${path}.itemMaxLength`, 1);
2188
- optionalText(props.placeholder, `${path}.placeholder`);
2189
- ordered(props.min, props.max ?? 10, `${path}.min`, "most items");
2190
- } else if (type === "table") {
2191
- const columns = props.columns;
2192
- if (!Array.isArray(columns) || columns.length < 1 || columns.length > MAX_TABLE_COLUMNS) {
2193
- issue(`${path}.columns`, `Must be a list of 1 to ${MAX_TABLE_COLUMNS} columns.`);
2194
- } else {
2195
- const seen = /* @__PURE__ */ new Set();
2196
- columns.forEach((c, j) => {
2197
- const at = `${path}.columns[${j}]`;
2198
- if (!isObject2(c)) return issue(at, "Each column needs a key and a label.");
2199
- if (name(c.key, `${at}.key`, "A column key")) {
2200
- if (seen.has(c.key)) issue(`${at}.key`, `Duplicate column key "${c.key}".`);
2201
- seen.add(c.key);
2202
- }
2203
- text(c.label, `${at}.label`);
2204
- optionalBoolean(c.long, `${at}.long`);
2205
- });
2206
- }
2207
- optionalInt(props.minRows, `${path}.minRows`, 0);
2208
- optionalInt(props.maxRows, `${path}.maxRows`, 1);
2209
- ordered(props.minRows, props.maxRows ?? 3, `${path}.minRows`, "most rows");
2210
- } else if (type === "media-notes") {
2211
- optionalInt(props.maxNotes, `${path}.maxNotes`, 1);
2212
- optionalInt(props.textMaxLength, `${path}.textMaxLength`, 1);
2213
- } else if (type === "text-highlights") {
2214
- optionalText(props.text, `${path}.text`);
2215
- optionalInt(props.maxHighlights, `${path}.maxHighlights`, 1);
2216
- } else if (type === CALCULATED_TYPE) {
2217
- if (props.format !== void 0 && props.format !== "number" && props.format !== "percent" && props.format !== "text") {
2218
- issue(`${path}.format`, "Must be number, percent or text.");
2219
- }
2220
- if (props.decimals !== void 0 && !(isInt(props.decimals) && props.decimals >= 0 && props.decimals <= MAX_CALCULATED_DECIMALS)) {
2221
- issue(`${path}.decimals`, `Must be a whole number from 0 to ${MAX_CALCULATED_DECIMALS}.`);
2222
- }
2223
- }
2224
- };
2225
- const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
2226
- const fieldProps = (type, props, path) => {
2227
- if (type === "rating" || type === "nps") {
2228
- optionalText(props.lowLabel, `${path}.lowLabel`);
2229
- optionalText(props.highLabel, `${path}.highLabel`);
2230
- }
2231
- if (type === "rating" && props.display !== void 0 && props.display !== "numbers" && props.display !== "stars") {
2232
- issue(`${path}.display`, "Must be numbers or stars.");
2233
- }
2234
- if (type === "number") {
2235
- for (const k of ["min", "max", "step"]) {
2236
- if (props[k] !== void 0 && !isFiniteNumber(props[k])) issue(`${path}.${k}`, "Must be a number.");
2237
- }
2238
- if (isFiniteNumber(props.step) && props.step <= 0) issue(`${path}.step`, "Must be above 0.");
2239
- if (isFiniteNumber(props.min) && isFiniteNumber(props.max) && props.min > props.max) {
2240
- issue(`${path}.min`, "Can't be more than the largest number.");
2241
- }
2242
- }
2243
- if (type === "date") {
2244
- for (const k of ["min", "max"]) {
2245
- if (props[k] !== void 0 && !(typeof props[k] === "string" && isIsoDate(props[k]))) {
2246
- issue(`${path}.${k}`, "Must be a date like 2026-10-22.");
2247
- }
2248
- }
2249
- if (typeof props.min === "string" && typeof props.max === "string" && isIsoDate(props.min) && isIsoDate(props.max) && props.min > props.max) {
2250
- issue(`${path}.min`, "Can't be after the latest date.");
2251
- }
2252
- }
2253
- if (type === "consent") optionalText(props.text, `${path}.text`);
2254
- if (type === PHOTO_TYPE) {
2255
- if (props.maxFiles !== void 0 && !(isInt(props.maxFiles) && props.maxFiles >= 1 && props.maxFiles <= MAX_PHOTO_FILES)) {
2256
- issue(`${path}.maxFiles`, `Must be a whole number from 1 to ${MAX_PHOTO_FILES}.`);
2257
- }
2258
- if (props.maxSizeMb !== void 0 && !(isInt(props.maxSizeMb) && props.maxSizeMb >= 1 && props.maxSizeMb <= MAX_PHOTO_SIZE_MB)) {
2259
- issue(`${path}.maxSizeMb`, `Must be a whole number from 1 to ${MAX_PHOTO_SIZE_MB}.`);
2260
- }
2261
- if (props.formats !== void 0) {
2262
- const formats = props.formats;
2263
- if (!Array.isArray(formats) || formats.length === 0 || !formats.every(isPhotoFormat)) {
2264
- issue(`${path}.formats`, "Must be a list of one or more of jpeg, png, heic and webp.");
2265
- } else if (new Set(formats).size !== formats.length) {
2266
- issue(`${path}.formats`, "Name each format once.");
2267
- }
2268
- }
2359
+ const kindProps = (check, props, path) => {
2360
+ if (!check) return;
2361
+ try {
2362
+ check(props, propsChecker(path, issue));
2363
+ } catch {
2364
+ issue(path, "These settings couldn't be checked.");
2269
2365
  }
2270
2366
  };
2271
2367
  if (!isObject2(input)) {
@@ -2304,7 +2400,7 @@ function parseForm(input) {
2304
2400
  const order = /* @__PURE__ */ new Map();
2305
2401
  const tableColumnKeys = /* @__PURE__ */ new Map();
2306
2402
  const choices = /* @__PURE__ */ new Map();
2307
- const calculatedLiteral = /* @__PURE__ */ new Map();
2403
+ const literals = /* @__PURE__ */ new Map();
2308
2404
  input.blocks.forEach((block, i) => {
2309
2405
  const path = `blocks[${i}]`;
2310
2406
  if (!isObject2(block) || typeof block.id !== "string" || !block.id) {
@@ -2332,7 +2428,7 @@ function parseForm(input) {
2332
2428
  if (from !== void 0) {
2333
2429
  if (!isObject2(from) || from.source !== "host" || typeof from.key !== "string" || !from.key) {
2334
2430
  issue(`${path}.props.optionsFrom`, 'Must be { source: "host", key } with a non-empty key.');
2335
- } else if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2431
+ } else if (typeof block.type === "string" && !choiceKind(block.type)) {
2336
2432
  warnings.push({ path: `${path}.props.optionsFrom`, message: "Only single choice, multiple choice and dropdown questions take options from the app." });
2337
2433
  }
2338
2434
  const filter = isObject2(from) ? from.filter : void 0;
@@ -2354,7 +2450,7 @@ function parseForm(input) {
2354
2450
  );
2355
2451
  } else {
2356
2452
  optionalBoolean(prefill.hideWhenPrefilled, `${path}.props.prefill.hideWhenPrefilled`);
2357
- if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2453
+ if (typeof block.type === "string" && !choiceKind(block.type)) {
2358
2454
  warnings.push({ path: `${path}.props.prefill`, message: "Only single choice, multiple choice and dropdown questions are filled in from a link." });
2359
2455
  }
2360
2456
  }
@@ -2385,7 +2481,7 @@ function parseForm(input) {
2385
2481
  if (other !== void 0) {
2386
2482
  if (other !== true && other !== false && !(isObject2(other) && (other.label === void 0 || typeof other.label === "string"))) {
2387
2483
  issue(`${path}.props.other`, "Must be true, false or { label }.");
2388
- } else if (other !== false && typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2484
+ } else if (other !== false && typeof block.type === "string" && !choiceKind(block.type)) {
2389
2485
  warnings.push({ path: `${path}.props.other`, message: 'Only single choice, multiple choice and dropdown questions take an "Other" answer.' });
2390
2486
  }
2391
2487
  }
@@ -2398,19 +2494,14 @@ function parseForm(input) {
2398
2494
  }
2399
2495
  }
2400
2496
  if (typeof block.type === "string" && (block.props === void 0 || isObject2(block.props))) {
2401
- structuredProps(block.type, block.props ?? {}, `${path}.props`);
2402
- fieldProps(block.type, block.props ?? {}, `${path}.props`);
2497
+ kindProps(getFieldType(block.type)?.checkProps, block.props ?? {}, `${path}.props`);
2403
2498
  }
2404
2499
  if (validKey) {
2405
2500
  if (keys.has(block.key)) issue(`${path}.key`, `Duplicate key "${String(block.key)}".`);
2406
2501
  keys.set(block.key, block.id);
2407
2502
  }
2408
2503
  fieldTypes.set(block.id, typeof block.type === "string" ? block.type : "");
2409
- if (block.type === CALCULATED_TYPE) {
2410
- const format = isObject2(block.props) ? block.props.format : void 0;
2411
- if (format === "text") calculatedLiteral.set(block.id, "string");
2412
- else if (format !== "number" && format !== "percent") calculatedLiteral.set(block.id, void 0);
2413
- }
2504
+ literals.set(block.id, typeof block.type === "string" ? literalType(block.type, isObject2(block.props) ? block.props : {}) : void 0);
2414
2505
  if (block.type === "table" && isObject2(block.props) && Array.isArray(block.props.columns)) {
2415
2506
  tableColumnKeys.set(
2416
2507
  block.id,
@@ -2418,8 +2509,13 @@ function parseForm(input) {
2418
2509
  );
2419
2510
  }
2420
2511
  } else if (block.kind === "content") {
2421
- if (block.type !== "heading" && block.type !== "text") issue(`${path}.type`, "Must be heading or text.");
2512
+ const content = typeof block.type === "string" ? getContentType(block.type) : void 0;
2513
+ if (!content) issue(`${path}.type`, `Must be ${contentKinds()}.`);
2422
2514
  text(block.text, `${path}.text`);
2515
+ if (content?.checkProps) {
2516
+ if (block.props === void 0 || isObject2(block.props) && isJson(block.props)) kindProps(content.checkProps, block.props ?? {}, `${path}.props`);
2517
+ else issue(`${path}.props`, "Must be a JSON object.");
2518
+ }
2423
2519
  } else if (block.kind === "page-break") {
2424
2520
  optionalText(block.title, `${path}.title`);
2425
2521
  } else {
@@ -2549,8 +2645,8 @@ function parseForm(input) {
2549
2645
  if (typeof left.field === "string" && fieldTypes.has(left.field)) {
2550
2646
  const fieldType = fieldTypes.get(left.field);
2551
2647
  what = `"${keyOf(left.field)}"`;
2552
- type = calculatedLiteral.has(left.field) ? calculatedLiteral.get(left.field) : LITERAL_TYPE[fieldType];
2553
- if (CHOICE_TYPES.has(fieldType)) options = choices.get(left.field);
2648
+ type = literals.get(left.field);
2649
+ if (choiceKind(fieldType)) options = choices.get(left.field);
2554
2650
  } else if (typeof left.hidden === "string" && hiddenTypes.has(left.hidden)) {
2555
2651
  what = `hidden input "${left.hidden}"`;
2556
2652
  type = hiddenTypes.get(left.hidden);