@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/server.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;
@@ -424,11 +463,11 @@ function calculator(definition, work) {
424
463
  for (const block of definition.blocks) {
425
464
  if (block.kind !== "field" || !isCalculated(block)) continue;
426
465
  const props = calculatedProps(block);
427
- const parsed = parseFormula(props.formula);
466
+ const parsed2 = parseFormula(props.formula);
428
467
  const reads = [];
429
468
  const slots = /* @__PURE__ */ new Map();
430
469
  const byKey = /* @__PURE__ */ new Map();
431
- for (const ref of parsed.ok ? refNodes(parsed.ast) : []) {
470
+ for (const ref of parsed2.ok ? refNodes(parsed2.ast) : []) {
432
471
  const id = idByKey.get(ref.key);
433
472
  const key = `${id ?? ""}\0${ref.column ?? ""}`;
434
473
  let slot = byKey.get(key);
@@ -438,7 +477,7 @@ function calculator(definition, work) {
438
477
  }
439
478
  slots.set(ref, slot);
440
479
  }
441
- compiled.push({ field: block, props, ast: parsed.ok ? parsed.ast : null, size: parsed.ok ? formulaSize(parsed.ast) : 0, reads, slots, last: null });
480
+ compiled.push({ field: block, props, ast: parsed2.ok ? parsed2.ast : null, size: parsed2.ok ? formulaSize(parsed2.ast) : 0, reads, slots, last: null });
442
481
  }
443
482
  const extracted = /* @__PURE__ */ new WeakMap();
444
483
  const cachedList = (value, column) => {
@@ -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),
@@ -778,6 +867,22 @@ var photoFieldType = {
778
867
  value: { json: true },
779
868
  operators: ["answered", "empty"],
780
869
  a11y: { semantics: "group" },
870
+ checkProps: (props, check) => {
871
+ if (props.maxFiles !== void 0 && !(isWholeNumber(props.maxFiles) && props.maxFiles >= 1 && props.maxFiles <= MAX_PHOTO_FILES)) {
872
+ check.issue("maxFiles", `Must be a whole number from 1 to ${MAX_PHOTO_FILES}.`);
873
+ }
874
+ if (props.maxSizeMb !== void 0 && !(isWholeNumber(props.maxSizeMb) && props.maxSizeMb >= 1 && props.maxSizeMb <= MAX_PHOTO_SIZE_MB)) {
875
+ check.issue("maxSizeMb", `Must be a whole number from 1 to ${MAX_PHOTO_SIZE_MB}.`);
876
+ }
877
+ if (props.formats !== void 0) {
878
+ const formats = props.formats;
879
+ if (!Array.isArray(formats) || formats.length === 0 || !formats.every(isPhotoFormat)) {
880
+ check.issue("formats", "Must be a list of one or more of jpeg, png, heic and webp.");
881
+ } else if (new Set(formats).size !== formats.length) {
882
+ check.issue("formats", "Name each format once.");
883
+ }
884
+ }
885
+ },
781
886
  isEmpty: (value) => value === void 0 || value === null || Array.isArray(value) && value.length === 0,
782
887
  validate: (value, field) => {
783
888
  if (!Array.isArray(value) || !value.every(isPhotoRef)) return "Add a photo.";
@@ -795,22 +900,59 @@ var photoFieldType = {
795
900
  };
796
901
 
797
902
  // src/fields.ts
798
- var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
903
+ var VALIDATOR_FAILED_MESSAGE = "This answer couldn't be checked.";
904
+ var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types@2");
905
+ var BUILT_IN = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/built-in-field-types@2");
799
906
  var globalScope = globalThis;
800
907
  var registry = globalScope[REGISTRY] ??= /* @__PURE__ */ new Map();
801
- function registerFieldType(manifest) {
802
- if (!manifest.type) throw new Error("registerFieldType: a field type needs a `type`.");
803
- const value = manifest.value;
804
- if (!value.json && (typeof value.serialize !== "function" || typeof value.deserialize !== "function")) {
805
- throw new Error(
806
- `registerFieldType: "${manifest.type}" stores a non-JSON value, so it must provide both serialize and deserialize.`
807
- );
908
+ var builtIn = globalScope[BUILT_IN] ??= /* @__PURE__ */ new Set();
909
+ var freezeRecord = (v) => v && typeof v === "object" && !Array.isArray(v) ? Object.freeze({ ...v }) : v;
910
+ function frozen(manifest) {
911
+ const copy = { ...manifest };
912
+ for (const key of ["choice", "a11y", "value"]) if (key in copy) copy[key] = freezeRecord(copy[key]);
913
+ if (Array.isArray(copy.operators)) {
914
+ const operators = Object.freeze([...copy.operators]);
915
+ Object.defineProperty(copy, "operators", { get: () => [...operators], enumerable: true });
808
916
  }
809
- registry.set(manifest.type, manifest);
917
+ return Object.freeze(copy);
918
+ }
919
+ function registerBuiltIn(manifest) {
920
+ builtIn.add(manifest.type);
921
+ if (!registry.has(manifest.type)) registry.set(manifest.type, frozen(manifest));
810
922
  }
811
923
  function getFieldType(type) {
812
924
  return registry.get(type);
813
925
  }
926
+ function choiceKind(type) {
927
+ return registry.get(type)?.choice;
928
+ }
929
+ function isChoiceField(block) {
930
+ return !!block && block.kind === "field" && typeof block.type === "string" && choiceKind(block.type) !== void 0;
931
+ }
932
+ function literalType(type, props) {
933
+ const literal = registry.get(type)?.literal;
934
+ if (typeof literal !== "function") return literal;
935
+ try {
936
+ return literal(props);
937
+ } catch {
938
+ return void 0;
939
+ }
940
+ }
941
+ var CONTENT_REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/content-types@1");
942
+ var contentScope = globalThis;
943
+ var contentRegistry = contentScope[CONTENT_REGISTRY] ??= /* @__PURE__ */ new Map();
944
+ function getContentType(type) {
945
+ return contentRegistry.get(type);
946
+ }
947
+ function listContentTypes() {
948
+ return [...contentRegistry.values()];
949
+ }
950
+ for (const manifest of [
951
+ { type: "heading", label: "Heading" },
952
+ { type: "text", label: "Text" }
953
+ ]) {
954
+ if (!contentRegistry.has(manifest.type)) contentRegistry.set(manifest.type, frozen(manifest));
955
+ }
814
956
  function isEmptyValue(value, field) {
815
957
  const manifest = field ? registry.get(field.type) : void 0;
816
958
  if (manifest?.isEmpty) return manifest.isEmpty(value);
@@ -993,6 +1135,18 @@ function isPhoneNumber(value) {
993
1135
  }
994
1136
  return digits >= 7 && digits <= 15;
995
1137
  }
1138
+ var oneOptionValidate = (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.";
1139
+ var oneOptionSummary = (value, field) => optionLabel(field, String(value));
1140
+ var optionsValidate = (value, field) => {
1141
+ const allowed = new Set(choiceOptions(field).map((o) => o.value));
1142
+ const others = Array.isArray(value) ? value.filter((v) => typeof v === "string" && !allowed.has(v)) : [];
1143
+ 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.";
1144
+ };
1145
+ var optionsSummary = (value, field) => Array.isArray(value) ? value.map((v) => optionLabel(field, String(v))).join(", ") : String(value);
1146
+ var scaleProps = (props, check) => {
1147
+ check.optionalText(props.lowLabel, "lowLabel");
1148
+ check.optionalText(props.highLabel, "highLabel");
1149
+ };
996
1150
  var builtins = [
997
1151
  {
998
1152
  type: "short-text",
@@ -1001,6 +1155,7 @@ var builtins = [
1001
1155
  value: { json: true },
1002
1156
  operators: textOps,
1003
1157
  a11y: { semantics: "control" },
1158
+ literal: "string",
1004
1159
  validate: textValidate(500)
1005
1160
  },
1006
1161
  {
@@ -1010,6 +1165,7 @@ var builtins = [
1010
1165
  value: { json: true },
1011
1166
  operators: textOps,
1012
1167
  a11y: { semantics: "control" },
1168
+ literal: "string",
1013
1169
  validate: textValidate(5e3)
1014
1170
  },
1015
1171
  {
@@ -1019,6 +1175,7 @@ var builtins = [
1019
1175
  value: { json: true },
1020
1176
  operators: textOps,
1021
1177
  a11y: { semantics: "control", autocomplete: "email", inputMode: "email" },
1178
+ literal: "string",
1022
1179
  validate: (value) => typeof value === "string" && isEmailAddress(value.trim()) ? null : "Enter an email address like name@example.com.",
1023
1180
  normalize: (value) => typeof value === "string" ? value.trim() : value
1024
1181
  },
@@ -1029,6 +1186,7 @@ var builtins = [
1029
1186
  value: { json: true },
1030
1187
  operators: ["eq", "neq", "answered", "empty"],
1031
1188
  a11y: { semantics: "control", autocomplete: "tel", inputMode: "tel" },
1189
+ literal: "string",
1032
1190
  validate: (value) => typeof value === "string" && isPhoneNumber(value.trim()) ? null : "Enter a phone number with 7 to 15 digits.",
1033
1191
  normalize: (value) => typeof value === "string" ? value.trim() : value,
1034
1192
  summarize: (value) => String(value)
@@ -1040,6 +1198,13 @@ var builtins = [
1040
1198
  value: { json: true },
1041
1199
  operators: numberOps2,
1042
1200
  a11y: { semantics: "group" },
1201
+ literal: "number",
1202
+ checkProps: (props, check) => {
1203
+ scaleProps(props, check);
1204
+ if (props.display !== void 0 && props.display !== "numbers" && props.display !== "stars") {
1205
+ check.issue("display", "Must be numbers or stars.");
1206
+ }
1207
+ },
1043
1208
  validate: (value, field) => {
1044
1209
  const { min, max } = intRange(field, 1, 5);
1045
1210
  return Number.isInteger(value) && value >= min && value <= max ? null : `Choose a rating from ${min} to ${max}.`;
@@ -1057,6 +1222,8 @@ var builtins = [
1057
1222
  value: { json: true },
1058
1223
  operators: numberOps2,
1059
1224
  a11y: { semantics: "group" },
1225
+ literal: "number",
1226
+ checkProps: scaleProps,
1060
1227
  validate: (value) => Number.isInteger(value) && value >= 0 && value <= 10 ? null : "Choose a score from 0 to 10.",
1061
1228
  summarize: (value, field) => {
1062
1229
  const end = scaleEndLabel(field, value, 0, 10);
@@ -1070,8 +1237,10 @@ var builtins = [
1070
1237
  value: { json: true },
1071
1238
  operators: ["eq", "neq", "in", "answered", "empty"],
1072
1239
  a11y: { semantics: "group" },
1073
- validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
1074
- summarize: (value, field) => optionLabel(field, String(value))
1240
+ literal: "string",
1241
+ choice: { multiple: false },
1242
+ validate: oneOptionValidate,
1243
+ summarize: oneOptionSummary
1075
1244
  },
1076
1245
  {
1077
1246
  type: "multi-choice",
@@ -1080,12 +1249,10 @@ var builtins = [
1080
1249
  value: { json: true },
1081
1250
  operators: ["contains", "answered", "empty"],
1082
1251
  a11y: { semantics: "group" },
1083
- validate: (value, field) => {
1084
- const allowed = new Set(choiceOptions(field).map((o) => o.value));
1085
- const others = Array.isArray(value) ? value.filter((v) => typeof v === "string" && !allowed.has(v)) : [];
1086
- 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.";
1087
- },
1088
- summarize: (value, field) => Array.isArray(value) ? value.map((v) => optionLabel(field, String(v))).join(", ") : String(value)
1252
+ literal: "string",
1253
+ choice: { multiple: true },
1254
+ validate: optionsValidate,
1255
+ summarize: optionsSummary
1089
1256
  },
1090
1257
  {
1091
1258
  type: "yes-no",
@@ -1094,6 +1261,7 @@ var builtins = [
1094
1261
  value: { json: true },
1095
1262
  operators: ["eq", "neq", "answered", "empty"],
1096
1263
  a11y: { semantics: "group" },
1264
+ literal: "boolean",
1097
1265
  validate: (value) => typeof value === "boolean" ? null : "Choose yes or no.",
1098
1266
  summarize: (value) => value ? "Yes" : "No"
1099
1267
  },
@@ -1104,8 +1272,10 @@ var builtins = [
1104
1272
  value: { json: true },
1105
1273
  operators: ["eq", "neq", "in", "answered", "empty"],
1106
1274
  a11y: { semantics: "control" },
1107
- validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
1108
- summarize: (value, field) => optionLabel(field, String(value))
1275
+ literal: "string",
1276
+ choice: { multiple: false },
1277
+ validate: oneOptionValidate,
1278
+ summarize: oneOptionSummary
1109
1279
  },
1110
1280
  {
1111
1281
  type: "number",
@@ -1114,6 +1284,16 @@ var builtins = [
1114
1284
  value: { json: true },
1115
1285
  operators: numberOps2,
1116
1286
  a11y: { semantics: "control", inputMode: "decimal" },
1287
+ literal: "number",
1288
+ checkProps: (props, check) => {
1289
+ for (const k of ["min", "max", "step"]) {
1290
+ if (props[k] !== void 0 && !isFiniteNumber(props[k])) check.issue(k, "Must be a number.");
1291
+ }
1292
+ if (isFiniteNumber(props.step) && props.step <= 0) check.issue("step", "Must be above 0.");
1293
+ if (isFiniteNumber(props.min) && isFiniteNumber(props.max) && props.min > props.max) {
1294
+ check.issue("min", "Can't be more than the largest number.");
1295
+ }
1296
+ },
1117
1297
  validate: (value, field) => {
1118
1298
  if (typeof value !== "number" || !Number.isFinite(value)) return "Enter a number.";
1119
1299
  const { min, max, step } = numberLimits(field);
@@ -1140,6 +1320,17 @@ var builtins = [
1140
1320
  value: { json: true },
1141
1321
  operators: ["eq", "neq", "answered", "empty"],
1142
1322
  a11y: { semantics: "control" },
1323
+ literal: "string",
1324
+ checkProps: (props, check) => {
1325
+ for (const k of ["min", "max"]) {
1326
+ if (props[k] !== void 0 && !(typeof props[k] === "string" && isIsoDate(props[k]))) {
1327
+ check.issue(k, "Must be a date like 2026-10-22.");
1328
+ }
1329
+ }
1330
+ if (typeof props.min === "string" && typeof props.max === "string" && isIsoDate(props.min) && isIsoDate(props.max) && props.min > props.max) {
1331
+ check.issue("min", "Can't be after the latest date.");
1332
+ }
1333
+ },
1143
1334
  validate: (value, field) => {
1144
1335
  if (typeof value !== "string" || !isIsoDate(value.trim())) return "Enter a date like 2026-10-22.";
1145
1336
  const date = value.trim();
@@ -1162,6 +1353,8 @@ var builtins = [
1162
1353
  value: { json: true },
1163
1354
  operators: ["answered", "empty"],
1164
1355
  a11y: { semantics: "group" },
1356
+ literal: "boolean",
1357
+ checkProps: (props, check) => check.optionalText(props.text, "text"),
1165
1358
  // Unchecked is unanswered, so a required consent must be checked.
1166
1359
  isEmpty: (value) => value === void 0 || value === null || value === false,
1167
1360
  validate: (value) => value === true ? null : "Check the box to agree.",
@@ -1211,7 +1404,7 @@ function dateLimits(field) {
1211
1404
  if (typeof max === "string" && isIsoDate(max)) out.max = max;
1212
1405
  return out;
1213
1406
  }
1214
- for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType, photoFieldType]) registerFieldType(manifest);
1407
+ for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType, photoFieldType]) registerBuiltIn(manifest);
1215
1408
 
1216
1409
  // src/events.ts
1217
1410
  function isPlainObject(value) {
@@ -1428,8 +1621,8 @@ function fieldMap(def) {
1428
1621
  }
1429
1622
 
1430
1623
  // src/options.ts
1431
- var CHOICE_FIELD_TYPES = /* @__PURE__ */ new Set(["single-choice", "multi-choice", "dropdown"]);
1432
- var isChoice = (block) => !!block && block.kind === "field" && CHOICE_FIELD_TYPES.has(block.type);
1624
+ var isChoice = isChoiceField;
1625
+ var isMultiple = (field) => choiceKind(field.type)?.multiple === true;
1433
1626
  function put(record, key, value) {
1434
1627
  Object.defineProperty(record, key, { value, enumerable: true, writable: true, configurable: true });
1435
1628
  }
@@ -1510,7 +1703,7 @@ function isPrefillParam(param) {
1510
1703
  return typeof param === "string" && param.length <= MAX_PREFILL_PARAM_LENGTH && /^[A-Za-z0-9_.-]+$/.test(param);
1511
1704
  }
1512
1705
  function prefillSetting(field) {
1513
- if (!CHOICE_FIELD_TYPES.has(field.type)) return void 0;
1706
+ if (!choiceKind(field.type)) return void 0;
1514
1707
  const raw = field.props?.prefill;
1515
1708
  if (!raw || typeof raw !== "object" || Array.isArray(raw) || !isPrefillParam(raw.param)) return void 0;
1516
1709
  return { param: raw.param, hideWhenPrefilled: raw.hideWhenPrefilled === true };
@@ -1538,7 +1731,7 @@ function prefillFromLink(definition, params, values = {}) {
1538
1731
  if (!raw.length) continue;
1539
1732
  const shown2 = new Set(visibleOptions(definition, block, seen).map((o) => o.value));
1540
1733
  let value;
1541
- if (block.type === "multi-choice") {
1734
+ if (isMultiple(block)) {
1542
1735
  const picked = [];
1543
1736
  for (const r of raw) {
1544
1737
  for (const v of shown2.has(r) ? [r] : r.split(",").map((s) => s.trim())) {
@@ -1581,6 +1774,21 @@ function snapshotOf(byValue, value) {
1581
1774
  function answerSnapshots(definition, answers) {
1582
1775
  const out = {};
1583
1776
  for (const block of definition.blocks) {
1777
+ if (block.kind !== "field") continue;
1778
+ const manifest = getFieldType(block.type);
1779
+ const custom = manifest?.choice ? void 0 : manifest?.snapshot;
1780
+ if (custom) {
1781
+ const value2 = own(answers, block.id);
1782
+ if (isEmptyValue(value2, block)) continue;
1783
+ let snapshot2;
1784
+ try {
1785
+ snapshot2 = custom(value2, block);
1786
+ } catch {
1787
+ snapshot2 = void 0;
1788
+ }
1789
+ if (snapshot2 !== void 0) put(out, block.id, snapshot2);
1790
+ continue;
1791
+ }
1584
1792
  if (!isChoice(block) || !optionsSource(block)) continue;
1585
1793
  const value = own(answers, block.id);
1586
1794
  if (isEmptyValue(value, block)) continue;
@@ -1598,6 +1806,22 @@ function answersFromSnapshots(definition, snapshots) {
1598
1806
  const answers = {};
1599
1807
  const missing = {};
1600
1808
  for (const block of definition.blocks) {
1809
+ if (block.kind !== "field") continue;
1810
+ const manifest = getFieldType(block.type);
1811
+ const custom = manifest?.choice ? void 0 : manifest?.fromSnapshot;
1812
+ if (custom) {
1813
+ const raw2 = own(snapshots, block.id);
1814
+ if (raw2 === void 0) continue;
1815
+ let restored;
1816
+ try {
1817
+ restored = custom(raw2, block);
1818
+ } catch {
1819
+ restored = void 0;
1820
+ }
1821
+ if (restored?.value !== void 0) put(answers, block.id, restored.value);
1822
+ if (Array.isArray(restored?.missing) && restored.missing.length) put(missing, block.id, restored.missing);
1823
+ continue;
1824
+ }
1601
1825
  if (!isChoice(block)) continue;
1602
1826
  const raw = own(snapshots, block.id);
1603
1827
  const list = (Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw]).flatMap((s) => {
@@ -1620,7 +1844,7 @@ function answersFromSnapshots(definition, snapshots) {
1620
1844
  if (value === void 0) lost.push(s);
1621
1845
  else if (!found.includes(value)) found.push(value);
1622
1846
  }
1623
- if (found.length) put(answers, block.id, block.type === "multi-choice" ? found : found[0]);
1847
+ if (found.length) put(answers, block.id, isMultiple(block) ? found : found[0]);
1624
1848
  if (lost.length) put(missing, block.id, lost);
1625
1849
  }
1626
1850
  return { answers, missing };
@@ -1661,8 +1885,13 @@ function validateField(field, value, options = {}) {
1661
1885
  }
1662
1886
  const manifest = getFieldType(field.type);
1663
1887
  if (!manifest) return `Unknown field type "${field.type}".`;
1664
- const builtIn = manifest.validate?.(value, field) ?? null;
1665
- if (builtIn) return builtIn;
1888
+ let builtIn2;
1889
+ try {
1890
+ builtIn2 = manifest.validate?.(value, field) ?? null;
1891
+ } catch {
1892
+ builtIn2 = VALIDATOR_FAILED_MESSAGE;
1893
+ }
1894
+ if (builtIn2) return builtIn2;
1666
1895
  return options.custom ? runCustom(options.custom, value) : null;
1667
1896
  }
1668
1897
 
@@ -1964,25 +2193,6 @@ function shown(v) {
1964
2193
  }
1965
2194
  var isObject2 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
1966
2195
  var isInt = (v) => typeof v === "number" && Number.isInteger(v);
1967
- var LITERAL_TYPE = {
1968
- "short-text": "string",
1969
- "long-text": "string",
1970
- email: "string",
1971
- phone: "string",
1972
- rating: "number",
1973
- nps: "number",
1974
- "single-choice": "string",
1975
- "multi-choice": "string",
1976
- "yes-no": "boolean",
1977
- list: "string",
1978
- dropdown: "string",
1979
- number: "number",
1980
- date: "string",
1981
- consent: "boolean",
1982
- // A calculated field holds a number unless its format says text; see `literalType`.
1983
- calculated: "number"
1984
- };
1985
- var CHOICE_TYPES = /* @__PURE__ */ new Set(["single-choice", "multi-choice", "dropdown"]);
1986
2196
  var ORDERING = /* @__PURE__ */ new Set(["lt", "lte", "gt", "gte"]);
1987
2197
  var OPERATORS = /* @__PURE__ */ new Set([
1988
2198
  "eq",
@@ -1996,8 +2206,11 @@ var OPERATORS = /* @__PURE__ */ new Set([
1996
2206
  "answered",
1997
2207
  "empty"
1998
2208
  ]);
1999
- var RESERVED = new Set(Object.getOwnPropertyNames(Object.prototype));
2000
2209
  var MAX_CONDITION_DEPTH = 32;
2210
+ function contentKinds() {
2211
+ const names = listContentTypes().map((c) => c.type);
2212
+ return names.length > 1 ? `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}` : names[0] ?? "a registered content type";
2213
+ }
2001
2214
  function parseForm(input) {
2002
2215
  const issues = [];
2003
2216
  const warnings = [];
@@ -2027,7 +2240,7 @@ function parseForm(input) {
2027
2240
  }
2028
2241
  const fits = (x) => typeof x === "string" && x.length <= OPTION_LIMITS.attributeValue;
2029
2242
  for (const [name2, v] of Object.entries(attributes)) {
2030
- if (!name2.trim() || name2.length > OPTION_LIMITS.attributeName || RESERVED.has(name2)) {
2243
+ if (!name2.trim() || name2.length > OPTION_LIMITS.attributeName || RESERVED_NAMES.has(name2)) {
2031
2244
  issue(`${at}.attributes`, `${shown(name2)} isn't a usable attribute name (at most ${OPTION_LIMITS.attributeName} characters).`);
2032
2245
  } else if (!fits(v) && !(Array.isArray(v) && v.length <= OPTION_LIMITS.attributeItems && v.every(fits))) {
2033
2246
  issue(
@@ -2042,104 +2255,18 @@ function parseForm(input) {
2042
2255
  issue(path, `${what} must be a non-empty string.`);
2043
2256
  return false;
2044
2257
  }
2045
- if (RESERVED.has(v)) {
2258
+ if (RESERVED_NAMES.has(v)) {
2046
2259
  issue(path, `"${v}" is reserved and cannot be used as ${what.toLowerCase()}.`);
2047
2260
  return false;
2048
2261
  }
2049
2262
  return true;
2050
2263
  };
2051
- const optionalInt = (v, path, floor) => {
2052
- if (v !== void 0 && !(isInt(v) && v >= floor)) issue(path, `Must be a whole number of ${floor} or more.`);
2053
- };
2054
- const ordered = (low, high, path, what) => {
2055
- if (isInt(low) && isInt(high) && low > high) issue(path, `Can't be more than the ${what}.`);
2056
- };
2057
- const structuredProps = (type, props, path) => {
2058
- if (type === "list") {
2059
- optionalInt(props.min, `${path}.min`, 0);
2060
- optionalInt(props.max, `${path}.max`, 1);
2061
- optionalInt(props.itemMaxLength, `${path}.itemMaxLength`, 1);
2062
- optionalText(props.placeholder, `${path}.placeholder`);
2063
- ordered(props.min, props.max ?? 10, `${path}.min`, "most items");
2064
- } else if (type === "table") {
2065
- const columns = props.columns;
2066
- if (!Array.isArray(columns) || columns.length < 1 || columns.length > MAX_TABLE_COLUMNS) {
2067
- issue(`${path}.columns`, `Must be a list of 1 to ${MAX_TABLE_COLUMNS} columns.`);
2068
- } else {
2069
- const seen = /* @__PURE__ */ new Set();
2070
- columns.forEach((c, j) => {
2071
- const at = `${path}.columns[${j}]`;
2072
- if (!isObject2(c)) return issue(at, "Each column needs a key and a label.");
2073
- if (name(c.key, `${at}.key`, "A column key")) {
2074
- if (seen.has(c.key)) issue(`${at}.key`, `Duplicate column key "${c.key}".`);
2075
- seen.add(c.key);
2076
- }
2077
- text(c.label, `${at}.label`);
2078
- optionalBoolean(c.long, `${at}.long`);
2079
- });
2080
- }
2081
- optionalInt(props.minRows, `${path}.minRows`, 0);
2082
- optionalInt(props.maxRows, `${path}.maxRows`, 1);
2083
- ordered(props.minRows, props.maxRows ?? 3, `${path}.minRows`, "most rows");
2084
- } else if (type === "media-notes") {
2085
- optionalInt(props.maxNotes, `${path}.maxNotes`, 1);
2086
- optionalInt(props.textMaxLength, `${path}.textMaxLength`, 1);
2087
- } else if (type === "text-highlights") {
2088
- optionalText(props.text, `${path}.text`);
2089
- optionalInt(props.maxHighlights, `${path}.maxHighlights`, 1);
2090
- } else if (type === CALCULATED_TYPE) {
2091
- if (props.format !== void 0 && props.format !== "number" && props.format !== "percent" && props.format !== "text") {
2092
- issue(`${path}.format`, "Must be number, percent or text.");
2093
- }
2094
- if (props.decimals !== void 0 && !(isInt(props.decimals) && props.decimals >= 0 && props.decimals <= MAX_CALCULATED_DECIMALS)) {
2095
- issue(`${path}.decimals`, `Must be a whole number from 0 to ${MAX_CALCULATED_DECIMALS}.`);
2096
- }
2097
- }
2098
- };
2099
- const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
2100
- const fieldProps = (type, props, path) => {
2101
- if (type === "rating" || type === "nps") {
2102
- optionalText(props.lowLabel, `${path}.lowLabel`);
2103
- optionalText(props.highLabel, `${path}.highLabel`);
2104
- }
2105
- if (type === "rating" && props.display !== void 0 && props.display !== "numbers" && props.display !== "stars") {
2106
- issue(`${path}.display`, "Must be numbers or stars.");
2107
- }
2108
- if (type === "number") {
2109
- for (const k of ["min", "max", "step"]) {
2110
- if (props[k] !== void 0 && !isFiniteNumber(props[k])) issue(`${path}.${k}`, "Must be a number.");
2111
- }
2112
- if (isFiniteNumber(props.step) && props.step <= 0) issue(`${path}.step`, "Must be above 0.");
2113
- if (isFiniteNumber(props.min) && isFiniteNumber(props.max) && props.min > props.max) {
2114
- issue(`${path}.min`, "Can't be more than the largest number.");
2115
- }
2116
- }
2117
- if (type === "date") {
2118
- for (const k of ["min", "max"]) {
2119
- if (props[k] !== void 0 && !(typeof props[k] === "string" && isIsoDate(props[k]))) {
2120
- issue(`${path}.${k}`, "Must be a date like 2026-10-22.");
2121
- }
2122
- }
2123
- if (typeof props.min === "string" && typeof props.max === "string" && isIsoDate(props.min) && isIsoDate(props.max) && props.min > props.max) {
2124
- issue(`${path}.min`, "Can't be after the latest date.");
2125
- }
2126
- }
2127
- if (type === "consent") optionalText(props.text, `${path}.text`);
2128
- if (type === PHOTO_TYPE) {
2129
- if (props.maxFiles !== void 0 && !(isInt(props.maxFiles) && props.maxFiles >= 1 && props.maxFiles <= MAX_PHOTO_FILES)) {
2130
- issue(`${path}.maxFiles`, `Must be a whole number from 1 to ${MAX_PHOTO_FILES}.`);
2131
- }
2132
- if (props.maxSizeMb !== void 0 && !(isInt(props.maxSizeMb) && props.maxSizeMb >= 1 && props.maxSizeMb <= MAX_PHOTO_SIZE_MB)) {
2133
- issue(`${path}.maxSizeMb`, `Must be a whole number from 1 to ${MAX_PHOTO_SIZE_MB}.`);
2134
- }
2135
- if (props.formats !== void 0) {
2136
- const formats = props.formats;
2137
- if (!Array.isArray(formats) || formats.length === 0 || !formats.every(isPhotoFormat)) {
2138
- issue(`${path}.formats`, "Must be a list of one or more of jpeg, png, heic and webp.");
2139
- } else if (new Set(formats).size !== formats.length) {
2140
- issue(`${path}.formats`, "Name each format once.");
2141
- }
2142
- }
2264
+ const kindProps = (check, props, path) => {
2265
+ if (!check) return;
2266
+ try {
2267
+ check(props, propsChecker(path, issue));
2268
+ } catch {
2269
+ issue(path, "These settings couldn't be checked.");
2143
2270
  }
2144
2271
  };
2145
2272
  if (!isObject2(input)) {
@@ -2178,7 +2305,7 @@ function parseForm(input) {
2178
2305
  const order = /* @__PURE__ */ new Map();
2179
2306
  const tableColumnKeys = /* @__PURE__ */ new Map();
2180
2307
  const choices = /* @__PURE__ */ new Map();
2181
- const calculatedLiteral = /* @__PURE__ */ new Map();
2308
+ const literals = /* @__PURE__ */ new Map();
2182
2309
  input.blocks.forEach((block, i) => {
2183
2310
  const path = `blocks[${i}]`;
2184
2311
  if (!isObject2(block) || typeof block.id !== "string" || !block.id) {
@@ -2206,7 +2333,7 @@ function parseForm(input) {
2206
2333
  if (from !== void 0) {
2207
2334
  if (!isObject2(from) || from.source !== "host" || typeof from.key !== "string" || !from.key) {
2208
2335
  issue(`${path}.props.optionsFrom`, 'Must be { source: "host", key } with a non-empty key.');
2209
- } else if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2336
+ } else if (typeof block.type === "string" && !choiceKind(block.type)) {
2210
2337
  warnings.push({ path: `${path}.props.optionsFrom`, message: "Only single choice, multiple choice and dropdown questions take options from the app." });
2211
2338
  }
2212
2339
  const filter = isObject2(from) ? from.filter : void 0;
@@ -2228,7 +2355,7 @@ function parseForm(input) {
2228
2355
  );
2229
2356
  } else {
2230
2357
  optionalBoolean(prefill.hideWhenPrefilled, `${path}.props.prefill.hideWhenPrefilled`);
2231
- if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2358
+ if (typeof block.type === "string" && !choiceKind(block.type)) {
2232
2359
  warnings.push({ path: `${path}.props.prefill`, message: "Only single choice, multiple choice and dropdown questions are filled in from a link." });
2233
2360
  }
2234
2361
  }
@@ -2259,7 +2386,7 @@ function parseForm(input) {
2259
2386
  if (other !== void 0) {
2260
2387
  if (other !== true && other !== false && !(isObject2(other) && (other.label === void 0 || typeof other.label === "string"))) {
2261
2388
  issue(`${path}.props.other`, "Must be true, false or { label }.");
2262
- } else if (other !== false && typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2389
+ } else if (other !== false && typeof block.type === "string" && !choiceKind(block.type)) {
2263
2390
  warnings.push({ path: `${path}.props.other`, message: 'Only single choice, multiple choice and dropdown questions take an "Other" answer.' });
2264
2391
  }
2265
2392
  }
@@ -2272,19 +2399,14 @@ function parseForm(input) {
2272
2399
  }
2273
2400
  }
2274
2401
  if (typeof block.type === "string" && (block.props === void 0 || isObject2(block.props))) {
2275
- structuredProps(block.type, block.props ?? {}, `${path}.props`);
2276
- fieldProps(block.type, block.props ?? {}, `${path}.props`);
2402
+ kindProps(getFieldType(block.type)?.checkProps, block.props ?? {}, `${path}.props`);
2277
2403
  }
2278
2404
  if (validKey) {
2279
2405
  if (keys.has(block.key)) issue(`${path}.key`, `Duplicate key "${String(block.key)}".`);
2280
2406
  keys.set(block.key, block.id);
2281
2407
  }
2282
2408
  fieldTypes.set(block.id, typeof block.type === "string" ? block.type : "");
2283
- if (block.type === CALCULATED_TYPE) {
2284
- const format = isObject2(block.props) ? block.props.format : void 0;
2285
- if (format === "text") calculatedLiteral.set(block.id, "string");
2286
- else if (format !== "number" && format !== "percent") calculatedLiteral.set(block.id, void 0);
2287
- }
2409
+ literals.set(block.id, typeof block.type === "string" ? literalType(block.type, isObject2(block.props) ? block.props : {}) : void 0);
2288
2410
  if (block.type === "table" && isObject2(block.props) && Array.isArray(block.props.columns)) {
2289
2411
  tableColumnKeys.set(
2290
2412
  block.id,
@@ -2292,8 +2414,13 @@ function parseForm(input) {
2292
2414
  );
2293
2415
  }
2294
2416
  } else if (block.kind === "content") {
2295
- if (block.type !== "heading" && block.type !== "text") issue(`${path}.type`, "Must be heading or text.");
2417
+ const content = typeof block.type === "string" ? getContentType(block.type) : void 0;
2418
+ if (!content) issue(`${path}.type`, `Must be ${contentKinds()}.`);
2296
2419
  text(block.text, `${path}.text`);
2420
+ if (content?.checkProps) {
2421
+ if (block.props === void 0 || isObject2(block.props) && isJson(block.props)) kindProps(content.checkProps, block.props ?? {}, `${path}.props`);
2422
+ else issue(`${path}.props`, "Must be a JSON object.");
2423
+ }
2297
2424
  } else if (block.kind === "page-break") {
2298
2425
  optionalText(block.title, `${path}.title`);
2299
2426
  } else {
@@ -2314,10 +2441,10 @@ function parseForm(input) {
2314
2441
  }
2315
2442
  const formula = isObject2(block.props) ? block.props.formula : void 0;
2316
2443
  if (typeof formula !== "string" || !formula.trim()) return issue(path, "A calculated field needs a formula.");
2317
- const parsed = parseFormula(formula);
2318
- if (!parsed.ok) return issue(path, `${parsed.message} (at character ${parsed.at + 1})`);
2444
+ const parsed2 = parseFormula(formula);
2445
+ if (!parsed2.ok) return issue(path, `${parsed2.message} (at character ${parsed2.at + 1})`);
2319
2446
  const reported = /* @__PURE__ */ new Set();
2320
- for (const ref of parsed.refs) {
2447
+ for (const ref of parsed2.refs) {
2321
2448
  const name2 = ref.column === void 0 ? ref.key : `${ref.key}.${ref.column}`;
2322
2449
  if (reported.has(name2)) continue;
2323
2450
  reported.add(name2);
@@ -2423,8 +2550,8 @@ function parseForm(input) {
2423
2550
  if (typeof left.field === "string" && fieldTypes.has(left.field)) {
2424
2551
  const fieldType = fieldTypes.get(left.field);
2425
2552
  what = `"${keyOf(left.field)}"`;
2426
- type = calculatedLiteral.has(left.field) ? calculatedLiteral.get(left.field) : LITERAL_TYPE[fieldType];
2427
- if (CHOICE_TYPES.has(fieldType)) options = choices.get(left.field);
2553
+ type = literals.get(left.field);
2554
+ if (choiceKind(fieldType)) options = choices.get(left.field);
2428
2555
  } else if (typeof left.hidden === "string" && hiddenTypes.has(left.hidden)) {
2429
2556
  what = `hidden input "${left.hidden}"`;
2430
2557
  type = hiddenTypes.get(left.hidden);
@@ -2538,6 +2665,40 @@ function parseForm(input) {
2538
2665
  return issues.length ? fail() : { ok: true, form: input, warnings };
2539
2666
  }
2540
2667
 
2668
+ // src/merge.ts
2669
+ function put2(record, key, value) {
2670
+ Object.defineProperty(record, key, { value, enumerable: true, writable: true, configurable: true });
2671
+ }
2672
+ function mergeResponses(definition, base, patch, options = {}) {
2673
+ const def = withHostOptions(definition, options.hostOptions);
2674
+ const fields = fieldMap(def);
2675
+ const keepable = (field) => !!field && !isCalculated(field) && !withheld(def, field);
2676
+ let values = /* @__PURE__ */ Object.create(null);
2677
+ const record = (r) => r && typeof r === "object" && !Array.isArray(r) ? r : {};
2678
+ const from = record(base);
2679
+ const changes = record(patch);
2680
+ for (const [id, field] of fields) {
2681
+ if (!keepable(field)) continue;
2682
+ const changed = Object.prototype.hasOwnProperty.call(changes, id);
2683
+ const value = changed ? own(changes, id) : own(from, id);
2684
+ if (value === void 0 || value === null || isEmptyValue(value, field)) continue;
2685
+ values[id] = value;
2686
+ }
2687
+ for (const id of Object.keys(values)) values = clearFilteredAnswers(def, values, id);
2688
+ const initial = {};
2689
+ for (const id of Object.keys(values)) if (values[id] !== void 0) put2(initial, id, values[id]);
2690
+ const state = createForm(def, { initialValues: initial, hidden: options.hidden, clock: () => void 0 }).getState();
2691
+ const keepOffPath = (options.offPath ?? (def.settings?.clearHiddenValues === false ? "keep" : "drop")) === "keep";
2692
+ const onPath = new Set(state.path);
2693
+ const out = {};
2694
+ for (const id of fields.keys()) {
2695
+ if (!keepOffPath && !onPath.has(id)) continue;
2696
+ const value = own(initial, id);
2697
+ if (value !== void 0) put2(out, id, value);
2698
+ }
2699
+ return out;
2700
+ }
2701
+
2541
2702
  // src/edit-link.ts
2542
2703
  var EDIT_LINK_TTL_SECONDS = 30 * 24 * 60 * 60;
2543
2704
  var MIN_EDIT_SECRET_BYTES = 32;
@@ -2642,19 +2803,29 @@ function withEditLinkHeaders(request, response, options = {}) {
2642
2803
  }
2643
2804
 
2644
2805
  // src/rate-limit.ts
2645
- var DEFAULT_TRUSTED_IP_HEADERS = ["x-vercel-forwarded-for", "x-forwarded-for", "x-real-ip"];
2806
+ var DEFAULT_TRUSTED_IP_HEADERS = ["x-vercel-forwarded-for", "cf-connecting-ip"];
2646
2807
  var RATE_LIMIT_DEFAULTS = {
2647
2808
  submit: { limit: 60, windowSeconds: 600 },
2648
2809
  progress: { limit: 900, windowSeconds: 600 },
2649
2810
  upload: { limit: 120, windowSeconds: 600 }
2650
2811
  };
2651
- function clientIp(headers, trusted = DEFAULT_TRUSTED_IP_HEADERS) {
2812
+ function clientIp(headers, options = DEFAULT_TRUSTED_IP_HEADERS) {
2813
+ const { headers: trusted = DEFAULT_TRUSTED_IP_HEADERS, forwardedForHops: hops } = isHeaderList(options) ? { headers: options } : options;
2652
2814
  for (const name of trusted) {
2653
2815
  const first = headers.get(name)?.split(",")[0]?.trim();
2654
2816
  if (first) return first;
2655
2817
  }
2818
+ if (hops !== void 0) {
2819
+ if (!Number.isInteger(hops) || hops < 1) throw new RangeError("forwardedForHops must be a whole number of at least 1");
2820
+ const entries = (headers.get("x-forwarded-for") ?? "").split(",").map((entry2) => entry2.trim());
2821
+ const entry = entries.length >= hops ? entries[entries.length - hops] : void 0;
2822
+ if (entry) return entry;
2823
+ }
2656
2824
  return "unknown";
2657
2825
  }
2826
+ function isHeaderList(options) {
2827
+ return Array.isArray(options);
2828
+ }
2658
2829
  function addressPrefix(ip) {
2659
2830
  const bare = ip.replace(/^\[|\](:\d+)?$/g, "").split("%")[0].toLowerCase();
2660
2831
  if (!bare.includes(":")) return bare;
@@ -2814,9 +2985,9 @@ function createUploadHandler(options) {
2814
2985
  if (text === null) return json({ error: say.tooLong }, 413);
2815
2986
  let body;
2816
2987
  try {
2817
- const parsed = JSON.parse(text);
2818
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
2819
- body = parsed;
2988
+ const parsed2 = JSON.parse(text);
2989
+ if (!parsed2 || typeof parsed2 !== "object" || Array.isArray(parsed2)) throw new Error("not an object");
2990
+ body = parsed2;
2820
2991
  } catch {
2821
2992
  return json({ error: say.notJson }, 400);
2822
2993
  }
@@ -2872,7 +3043,10 @@ function createUploadHandler(options) {
2872
3043
  const key = await rateLimitKey({
2873
3044
  kind: "upload",
2874
3045
  scope: form.scope,
2875
- ip: clientIp(request.headers, limit.trustedIpHeaders ?? DEFAULT_TRUSTED_IP_HEADERS),
3046
+ ip: clientIp(request.headers, {
3047
+ headers: limit.trustedIpHeaders ?? DEFAULT_TRUSTED_IP_HEADERS,
3048
+ forwardedForHops: limit.forwardedForHops
3049
+ }),
2876
3050
  secret: limit.secret
2877
3051
  });
2878
3052
  const allowed = await allow(
@@ -2909,6 +3083,216 @@ function createUploadHandler(options) {
2909
3083
  };
2910
3084
  }
2911
3085
 
3086
+ // src/hash.ts
3087
+ function canonicalJson(value) {
3088
+ return JSON.stringify(sortKeys(value));
3089
+ }
3090
+ function sortKeys(value) {
3091
+ if (Array.isArray(value)) return value.map(sortKeys);
3092
+ if (value && typeof value === "object") {
3093
+ const out = {};
3094
+ for (const key of Object.keys(value).sort()) {
3095
+ const v = value[key];
3096
+ if (v !== void 0) out[key] = sortKeys(v);
3097
+ }
3098
+ return out;
3099
+ }
3100
+ return value;
3101
+ }
3102
+ function fingerprint(value) {
3103
+ const str = canonicalJson(value);
3104
+ let h1 = 3735928559;
3105
+ let h2 = 1103547991;
3106
+ for (let i = 0; i < str.length; i++) {
3107
+ const ch = str.charCodeAt(i);
3108
+ h1 = Math.imul(h1 ^ ch, 2654435761);
3109
+ h2 = Math.imul(h2 ^ ch, 1597334677);
3110
+ }
3111
+ h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507) ^ Math.imul(h2 ^ h2 >>> 13, 3266489909);
3112
+ h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507) ^ Math.imul(h1 ^ h1 >>> 13, 3266489909);
3113
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16).padStart(14, "0");
3114
+ }
3115
+
3116
+ // src/submit-protocol.ts
3117
+ var IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
3118
+ function isIdempotencyKey(value) {
3119
+ return typeof value === "string" && IDEMPOTENCY_KEY_PATTERN.test(value);
3120
+ }
3121
+ function submitOutcome(result) {
3122
+ if (!isSubmitResult(result)) return "retry";
3123
+ if (result.ok || result.reason === "duplicate") return "delivered";
3124
+ if (result.reason === "invalid" || result.reason === "conflict" || result.reason === "closed") return "rejected";
3125
+ return "retry";
3126
+ }
3127
+ function isSubmitResult(value) {
3128
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
3129
+ const v = value;
3130
+ const stored = typeof v.id === "string" && v.id !== "" && typeof v.receivedAt === "string";
3131
+ if (v.ok === true) return stored;
3132
+ if (v.ok !== false) return false;
3133
+ switch (v.reason) {
3134
+ case "duplicate":
3135
+ return stored;
3136
+ case "invalid":
3137
+ return !!v.errors && typeof v.errors === "object" && !Array.isArray(v.errors);
3138
+ case "rate_limited":
3139
+ return v.retryAfterSeconds === void 0 || typeof v.retryAfterSeconds === "number" && Number.isFinite(v.retryAfterSeconds);
3140
+ case "conflict":
3141
+ case "closed":
3142
+ case "store_failed":
3143
+ return true;
3144
+ default:
3145
+ return false;
3146
+ }
3147
+ }
3148
+
3149
+ // src/submit.ts
3150
+ var INVALID_KEY = {
3151
+ ok: false,
3152
+ reason: "invalid",
3153
+ errors: { "": "Missing or malformed idempotency key: 16 to 128 letters, digits, - or _." }
3154
+ };
3155
+ var MALFORMED = { ok: false, reason: "invalid", errors: { "": "Malformed submission." } };
3156
+ var CONFLICT = { ok: false, reason: "conflict" };
3157
+ var parsed = /* @__PURE__ */ new WeakMap();
3158
+ function assertPublishable(definition) {
3159
+ if (typeof definition === "object" && definition !== null && parsed.has(definition)) return;
3160
+ const result = parseForm(definition);
3161
+ if (!result.ok) {
3162
+ throw new Error(`submitResponse: the published definition is invalid: ${result.issues.map((i) => i.message).join("; ")}`);
3163
+ }
3164
+ parsed.set(definition, true);
3165
+ }
3166
+ async function sha256Hex(text) {
3167
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
3168
+ let hex = "";
3169
+ for (const b of digest) hex += b.toString(16).padStart(2, "0");
3170
+ return hex;
3171
+ }
3172
+ function hashInput(input) {
3173
+ return sha256Hex(canonicalJson({ answers: input.answers, events: input.events, hidden: input.hidden }));
3174
+ }
3175
+ function isoOrNull(ms) {
3176
+ if (typeof ms !== "number" || !Number.isFinite(ms)) return null;
3177
+ const date = new Date(ms);
3178
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
3179
+ }
3180
+ function isRef(ref) {
3181
+ if (!ref || typeof ref !== "object") return false;
3182
+ const r = ref;
3183
+ return typeof r.id === "string" && r.id !== "" && typeof r.receivedAt === "string" && typeof r.inputHash === "string" && r.inputHash !== "";
3184
+ }
3185
+ function duplicateOf(ref, inputHash, log, formId) {
3186
+ if (!isRef(ref)) {
3187
+ log(`the store answered a duplicate for form ${formId} without its id, receivedAt and inputHash`);
3188
+ return { ok: false, reason: "store_failed" };
3189
+ }
3190
+ if (ref.inputHash !== inputHash) return CONFLICT;
3191
+ return { ok: false, reason: "duplicate", id: ref.id, receivedAt: ref.receivedAt };
3192
+ }
3193
+ var NO_OWNER = { attemptHash: "" };
3194
+ function submissionOptions(options) {
3195
+ const {
3196
+ definition: _definition,
3197
+ store: _store,
3198
+ formId: _formId,
3199
+ version: _version,
3200
+ open: _open,
3201
+ rateLimit: _rateLimit,
3202
+ uploads: _uploads,
3203
+ now: _now,
3204
+ log: _log,
3205
+ ...rest
3206
+ } = options;
3207
+ return rest;
3208
+ }
3209
+ async function submitResponse(input, options) {
3210
+ const { definition, store } = options;
3211
+ assertPublishable(definition);
3212
+ if (uploadFields(definition).length > 0 && !options.uploads) {
3213
+ throw new Error("submitResponse: this form has upload fields; pass `uploads` (the attempt and the upload store).");
3214
+ }
3215
+ const log = options.log ?? ((m) => console.error(`[submit] ${m}`));
3216
+ if (!input || typeof input !== "object" || Array.isArray(input)) return MALFORMED;
3217
+ if (!isIdempotencyKey(input.idempotencyKey)) return INVALID_KEY;
3218
+ const formId = options.formId ?? definition.id;
3219
+ const key = input.idempotencyKey;
3220
+ let hash;
3221
+ const inputHash = () => hash ??= hashInput(input);
3222
+ let existing;
3223
+ try {
3224
+ existing = await store.find(formId, key);
3225
+ } catch (e) {
3226
+ log(`find failed for form ${formId}: ${e instanceof Error ? e.message : String(e)}`);
3227
+ return { ok: false, reason: "store_failed" };
3228
+ }
3229
+ if (existing) return duplicateOf(existing, await inputHash(), log, formId);
3230
+ if (options.open === false) return { ok: false, reason: "closed" };
3231
+ if (options.rateLimit) {
3232
+ let answer = true;
3233
+ try {
3234
+ answer = await options.rateLimit();
3235
+ } catch (e) {
3236
+ log(`the rate limit could not be checked; letting the submission through: ${e instanceof Error ? e.message : String(e)}`);
3237
+ }
3238
+ if (answer === false) return { ok: false, reason: "rate_limited" };
3239
+ if (answer !== true && answer && typeof answer === "object") {
3240
+ const after = answer.retryAfterSeconds;
3241
+ return Number.isFinite(after) && after >= 0 ? { ok: false, reason: "rate_limited", retryAfterSeconds: Math.ceil(after) } : { ok: false, reason: "rate_limited" };
3242
+ }
3243
+ }
3244
+ const checked = validateSubmission(
3245
+ definition,
3246
+ { answers: input.answers, events: input.events, hidden: input.hidden },
3247
+ { ...submissionOptions(options), complete: true }
3248
+ );
3249
+ if (!checked.ok) return { ok: false, reason: "invalid", errors: checked.errors };
3250
+ const submission = checked.submission;
3251
+ let answers = submission.answers;
3252
+ if (options.uploads) {
3253
+ try {
3254
+ const owner = options.uploads.owner ?? (typeof input.attemptId === "string" && input.attemptId !== "" ? { attemptHash: await attemptHash(input.attemptId) } : (
3255
+ // No attempt: no upload can be this visit's, so any file named is refused.
3256
+ NO_OWNER
3257
+ ));
3258
+ const files = await checkUploads(definition, answers, { formId, owner, store: options.uploads.store });
3259
+ if (!files.ok) return { ok: false, reason: "invalid", errors: files.errors };
3260
+ answers = files.answers;
3261
+ } catch (e) {
3262
+ log(`upload check failed for form ${formId}: ${e instanceof Error ? e.message : String(e)}`);
3263
+ return { ok: false, reason: "store_failed" };
3264
+ }
3265
+ }
3266
+ const record = {
3267
+ formId,
3268
+ idempotencyKey: key,
3269
+ inputHash: await inputHash(),
3270
+ definitionVersion: options.version ?? fingerprint(definition),
3271
+ receivedAt: (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
3272
+ clientSubmittedAt: isoOrNull(input.clientSubmittedAt),
3273
+ answers,
3274
+ hidden: submission.hidden,
3275
+ context: submission.context,
3276
+ ending: submission.ending,
3277
+ ignored: submission.ignored,
3278
+ snapshots: answerSnapshots(withHostOptions(definition, options.hostOptions), answers),
3279
+ uploadIds: uploadFields(definition).flatMap((field) => photoRefs(answers[field.id]).map((ref) => ref.id))
3280
+ };
3281
+ let saved;
3282
+ try {
3283
+ saved = await store.save(record);
3284
+ } catch (e) {
3285
+ log(`save failed for form ${formId}: ${e instanceof Error ? e.message : String(e)}`);
3286
+ return { ok: false, reason: "store_failed" };
3287
+ }
3288
+ if (saved && typeof saved === "object" && "duplicate" in saved) return duplicateOf(saved.duplicate, record.inputHash, log, formId);
3289
+ if (!saved || typeof saved.id !== "string" || saved.id === "") {
3290
+ log(`save for form ${formId} answered without an id`);
3291
+ return { ok: false, reason: "store_failed" };
3292
+ }
3293
+ return { ok: true, id: saved.id, receivedAt: record.receivedAt };
3294
+ }
3295
+
2912
3296
  // src/server.ts
2913
3297
  var MAX_EVENTS = 1e4;
2914
3298
  var MAX_SUBMISSION_VALUES = 2e5;
@@ -2946,13 +3330,13 @@ function submissionValues(input, limit) {
2946
3330
  } else count += countValues(input.answers, limit - count);
2947
3331
  return count;
2948
3332
  }
2949
- var MALFORMED = { ok: false, errors: { "": "Malformed submission." } };
3333
+ var MALFORMED2 = { ok: false, errors: { "": "Malformed submission." } };
2950
3334
  var parsedDefinitions = /* @__PURE__ */ new WeakMap();
2951
3335
  function checkDefinition(definition) {
2952
3336
  if (typeof definition === "object" && definition !== null && parsedDefinitions.has(definition)) return null;
2953
- const parsed = parseForm(definition);
2954
- if (!parsed.ok) {
2955
- const list = parsed.issues.map((i) => i.path ? `${i.path}: ${i.message}` : i.message).join("; ");
3337
+ const parsed2 = parseForm(definition);
3338
+ if (!parsed2.ok) {
3339
+ const list = parsed2.issues.map((i) => i.path ? `${i.path}: ${i.message}` : i.message).join("; ");
2956
3340
  return `Invalid form definition: ${list}`;
2957
3341
  }
2958
3342
  parsedDefinitions.set(definition, true);
@@ -2976,7 +3360,7 @@ function validateSubmission(published, submission, options = {}) {
2976
3360
  const invalid = checkDefinition(published);
2977
3361
  if (invalid) return { ok: false, errors: { "": invalid } };
2978
3362
  const definition = withHostOptions(published, options.hostOptions);
2979
- if (!isPlainObject(submission)) return MALFORMED;
3363
+ if (!isPlainObject(submission)) return MALFORMED2;
2980
3364
  const maxEvents = options.maxEvents ?? MAX_EVENTS;
2981
3365
  let sent = submission;
2982
3366
  if (Array.isArray(submission.events)) {
@@ -2994,7 +3378,7 @@ function validateSubmission(published, submission, options = {}) {
2994
3378
  };
2995
3379
  }
2996
3380
  const input = wellFormed(sent);
2997
- if (!input) return MALFORMED;
3381
+ if (!input) return MALFORMED2;
2998
3382
  const ignored = /* @__PURE__ */ new Set();
2999
3383
  const client = coerceHidden(definition, input.hidden, { sources: ["url", "host"] });
3000
3384
  for (const name of client.ignored) ignored.add(`hidden.${name}`);
@@ -3093,6 +3477,6 @@ function publicDefinition(definition) {
3093
3477
  return JSON.parse(JSON.stringify(definition));
3094
3478
  }
3095
3479
 
3096
- export { DEFAULT_TRUSTED_IP_HEADERS, EDIT_LINK_PARAM, EDIT_LINK_TTL_SECONDS, MAX_EVENTS, MAX_REPLAY_WORK, MAX_SUBMISSION_VALUES, MAX_UPLOAD_REQUEST_BYTES, MIN_EDIT_SECRET_BYTES, NOT_UPLOADED_MESSAGE, RATE_LIMIT_DEFAULTS, RAW_EVENTS_PER_EVENT, UPLOAD_MESSAGES, addressPrefix, allow, answerSnapshots, answersFromSnapshots, attemptHash, checkUploads, clientIp, createEditSecretReader, createUploadHandler, editLinkUrl, isEditLinkRequest, isUsableEditSecret, publicDefinition, rateLimitKey, signEditToken, uploadFields, uploadRules, validateSubmission, verifyEditToken, withEditLinkHeaders, withEditToken, withHostOptions };
3480
+ export { DEFAULT_TRUSTED_IP_HEADERS, EDIT_LINK_PARAM, EDIT_LINK_TTL_SECONDS, IDEMPOTENCY_KEY_PATTERN, MAX_EVENTS, MAX_REPLAY_WORK, MAX_SUBMISSION_VALUES, MAX_UPLOAD_REQUEST_BYTES, MIN_EDIT_SECRET_BYTES, NOT_UPLOADED_MESSAGE, RATE_LIMIT_DEFAULTS, RAW_EVENTS_PER_EVENT, UPLOAD_MESSAGES, addressPrefix, allow, answerSnapshots, answersFromSnapshots, attemptHash, checkUploads, clientIp, createEditSecretReader, createUploadHandler, editLinkUrl, isEditLinkRequest, isIdempotencyKey, isSubmitResult, isUsableEditSecret, mergeResponses, publicDefinition, rateLimitKey, signEditToken, submitOutcome, submitResponse, uploadFields, uploadRules, validateSubmission, verifyEditToken, withEditLinkHeaders, withEditToken, withHostOptions };
3097
3481
  //# sourceMappingURL=server.js.map
3098
3482
  //# sourceMappingURL=server.js.map