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