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