@marktiderman/genesis-forms 0.4.2 → 0.6.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.
package/dist/server.cjs CHANGED
@@ -721,6 +721,80 @@ var structuredFieldTypes = [
721
721
  }
722
722
  ];
723
723
 
724
+ // src/photo.ts
725
+ var PHOTO_TYPE = "photo";
726
+ var PHOTO_FORMATS = {
727
+ jpeg: ["image/jpeg"],
728
+ png: ["image/png"],
729
+ heic: ["image/heic", "image/heif"],
730
+ webp: ["image/webp"]
731
+ };
732
+ var FORMAT_NAMES = { jpeg: "JPEG", png: "PNG", heic: "HEIC", webp: "WebP" };
733
+ var ALL_FORMATS = Object.keys(PHOTO_FORMATS);
734
+ var DEFAULT_PHOTO_FILES = 3;
735
+ var MAX_PHOTO_FILES = 5;
736
+ var DEFAULT_PHOTO_SIZE_MB = 10;
737
+ var MAX_PHOTO_SIZE_MB = 25;
738
+ var MAX_PHOTO_ID_LENGTH = 200;
739
+ var MAX_PHOTO_NAME_LENGTH = 255;
740
+ var isPhotoFormat = (v) => typeof v === "string" && Object.prototype.hasOwnProperty.call(PHOTO_FORMATS, v);
741
+ var wholeIn = (v, min, max) => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max;
742
+ function photoLimits(field) {
743
+ const maxFiles = wholeIn(field.props?.maxFiles, 1, MAX_PHOTO_FILES) ? field.props.maxFiles : DEFAULT_PHOTO_FILES;
744
+ const maxSizeMb = wholeIn(field.props?.maxSizeMb, 1, MAX_PHOTO_SIZE_MB) ? field.props.maxSizeMb : DEFAULT_PHOTO_SIZE_MB;
745
+ const raw = field.props?.formats;
746
+ const picked = Array.isArray(raw) ? ALL_FORMATS.filter((f) => raw.includes(f)) : [];
747
+ const formats = picked.length ? picked : [...ALL_FORMATS];
748
+ return {
749
+ maxFiles,
750
+ maxSizeMb,
751
+ maxBytes: maxSizeMb * 1024 * 1024,
752
+ formats,
753
+ mimeTypes: formats.flatMap((f) => PHOTO_FORMATS[f])
754
+ };
755
+ }
756
+ function isPhotoRef(v) {
757
+ if (!v || typeof v !== "object" || Array.isArray(v)) return false;
758
+ const r = v;
759
+ return typeof r.id === "string" && r.id.length > 0 && r.id.length <= MAX_PHOTO_ID_LENGTH && typeof r.name === "string" && r.name.length <= MAX_PHOTO_NAME_LENGTH && typeof r.type === "string" && typeof r.size === "number" && Number.isInteger(r.size) && r.size > 0;
760
+ }
761
+ function photoRefs(value) {
762
+ if (!Array.isArray(value)) return [];
763
+ const refs = [];
764
+ for (const r of value) if (isPhotoRef(r)) refs.push({ id: r.id, name: r.name, type: r.type, size: r.size });
765
+ return refs;
766
+ }
767
+ var plural2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
768
+ function photoFormatList(formats) {
769
+ const names = formats.map((f) => FORMAT_NAMES[f]);
770
+ return names.length > 1 ? `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}` : names[0] ?? "";
771
+ }
772
+ var photoTypeMessage = (field) => `Use a ${photoFormatList(photoLimits(field).formats)} photo.`;
773
+ var photoSizeMessage = (field) => `Each photo must be ${photoLimits(field).maxSizeMb} MB or smaller.`;
774
+ var photoCountMessage = (field) => `Add at most ${plural2(photoLimits(field).maxFiles, "photo")}.`;
775
+ var photoFieldType = {
776
+ type: PHOTO_TYPE,
777
+ label: "Photo",
778
+ category: "media",
779
+ value: { json: true },
780
+ operators: ["answered", "empty"],
781
+ a11y: { semantics: "group" },
782
+ isEmpty: (value) => value === void 0 || value === null || Array.isArray(value) && value.length === 0,
783
+ validate: (value, field) => {
784
+ if (!Array.isArray(value) || !value.every(isPhotoRef)) return "Add a photo.";
785
+ const limits = photoLimits(field);
786
+ if (value.length > limits.maxFiles) return photoCountMessage(field);
787
+ const refs = value;
788
+ if (new Set(refs.map((r) => r.id)).size !== refs.length) return "Each photo can be added once.";
789
+ if (refs.some((r) => !limits.mimeTypes.includes(r.type.toLowerCase()))) return photoTypeMessage(field);
790
+ if (refs.some((r) => r.size > limits.maxBytes)) return photoSizeMessage(field);
791
+ return null;
792
+ },
793
+ normalize: (value) => Array.isArray(value) && value.every(isPhotoRef) ? photoRefs(value) : value,
794
+ summarize: (value) => photoRefs(value).map((r) => r.name || "Photo").join(", "),
795
+ samples: () => [[{ id: "sample-photo", name: "photo.jpg", type: "image/jpeg", size: 1024 }]]
796
+ };
797
+
724
798
  // src/fields.ts
725
799
  var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
726
800
  var globalScope = globalThis;
@@ -757,12 +831,127 @@ function summarizeValue(value, field) {
757
831
  if (Array.isArray(value)) return value.join(", ");
758
832
  return String(value);
759
833
  }
834
+ var OPTION_LIMITS = {
835
+ /** Characters in a description. */
836
+ description: 500,
837
+ /** Characters in an image URL. */
838
+ image: 2048,
839
+ /** Attributes on one option. */
840
+ attributes: 20,
841
+ /** Characters in an attribute's name. */
842
+ attributeName: 64,
843
+ /** Characters in one attribute value. */
844
+ attributeValue: 200,
845
+ /** Values in one attribute's list. */
846
+ attributeItems: 50
847
+ };
848
+ function isHttpsUrl(url) {
849
+ return typeof url === "string" && url.length <= OPTION_LIMITS.image && /^https:\/\/[^\s/?#@\\]+(?:[/?#][^\s]*)?$/i.test(url) && // No control characters anywhere (a line break would end a CSV row or an HTML attribute).
850
+ ![...url].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127);
851
+ }
852
+ function attributeName(name) {
853
+ return name.trim() !== "" && name.length <= OPTION_LIMITS.attributeName && !(name in Object.prototype);
854
+ }
855
+ function readAttributes(raw) {
856
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
857
+ const out = {};
858
+ let count = 0;
859
+ for (const name of Object.keys(raw)) {
860
+ if (count >= OPTION_LIMITS.attributes) break;
861
+ if (!attributeName(name)) continue;
862
+ const v = raw[name];
863
+ const fits = (x) => typeof x === "string" && x.length <= OPTION_LIMITS.attributeValue;
864
+ if (fits(v)) out[name] = v;
865
+ else if (Array.isArray(v) && v.length <= OPTION_LIMITS.attributeItems && v.every(fits)) out[name] = [...v];
866
+ else continue;
867
+ count++;
868
+ }
869
+ return count ? out : void 0;
870
+ }
871
+ function readOption(raw) {
872
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
873
+ const o = raw;
874
+ if (typeof o.value !== "string") return null;
875
+ const out = { value: o.value };
876
+ if (typeof o.label === "string") out.label = o.label;
877
+ if (typeof o.description === "string" && o.description.trim() && o.description.length <= OPTION_LIMITS.description) {
878
+ out.description = o.description;
879
+ }
880
+ if (isHttpsUrl(o.image)) out.image = o.image;
881
+ const attributes = readAttributes(o.attributes);
882
+ if (attributes) out.attributes = attributes;
883
+ return out;
884
+ }
885
+ function optionJson(option) {
886
+ const out = { value: option.value };
887
+ if (option.label !== void 0) out.label = option.label;
888
+ if (option.description !== void 0) out.description = option.description;
889
+ if (option.image !== void 0) out.image = option.image;
890
+ if (option.attributes !== void 0) out.attributes = { ...option.attributes };
891
+ return out;
892
+ }
893
+ var OTHER_MAX_LENGTH = 200;
894
+ function otherOption(field) {
895
+ const other = field.props?.other;
896
+ if (other === true) return { label: "Other" };
897
+ if (other && typeof other === "object" && !Array.isArray(other)) {
898
+ const label = typeof other.label === "string" && other.label.trim() ? other.label.trim() : "Other";
899
+ return { label };
900
+ }
901
+ return null;
902
+ }
903
+ function isOtherValue(field, value) {
904
+ return otherOption(field) !== null && typeof value === "string" && !choiceOptions(field).some((o) => o.value === value);
905
+ }
906
+ var typedOther = (field, value) => isOtherValue(field, value) && value.trim() !== "" && value.length <= OTHER_MAX_LENGTH;
760
907
  function choiceOptions(field) {
761
908
  const raw = field.props?.options;
762
909
  if (!Array.isArray(raw)) return [];
763
- return raw.flatMap(
764
- (o) => o && typeof o === "object" && !Array.isArray(o) && typeof o.value === "string" ? [{ value: o.value, label: typeof o.label === "string" ? o.label : void 0 }] : []
765
- );
910
+ return raw.flatMap((o) => {
911
+ const option = readOption(o);
912
+ return option ? [option] : [];
913
+ });
914
+ }
915
+ function optionsSource(field) {
916
+ const raw = field.props?.optionsFrom;
917
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
918
+ if (raw.source !== "host" || typeof raw.key !== "string" || !raw.key) return void 0;
919
+ const out = { source: "host", key: raw.key };
920
+ const filter = raw.filter;
921
+ if (filter && typeof filter === "object" && !Array.isArray(filter) && typeof filter.attribute === "string" && filter.attribute && typeof filter.field === "string" && filter.field) {
922
+ out.filter = { attribute: filter.attribute, field: filter.field };
923
+ }
924
+ return out;
925
+ }
926
+ function hostList(hostOptions, key) {
927
+ if (!hostOptions || !Object.prototype.hasOwnProperty.call(hostOptions, key)) return [];
928
+ const list = hostOptions[key];
929
+ if (!Array.isArray(list)) return [];
930
+ const seen = /* @__PURE__ */ new Set();
931
+ const out = [];
932
+ for (const o of list) {
933
+ const option = readOption(o);
934
+ if (!option || !option.value || seen.has(option.value)) continue;
935
+ seen.add(option.value);
936
+ out.push(option);
937
+ }
938
+ return out;
939
+ }
940
+ function withHostOptions(definition, hostOptions) {
941
+ let changed = false;
942
+ const blocks = definition.blocks.map((block) => {
943
+ if (block.kind !== "field") return block;
944
+ const from = optionsSource(block);
945
+ if (!from) return block;
946
+ const own2 = choiceOptions(block);
947
+ const taken = new Set(own2.map((o) => o.value));
948
+ const added = hostList(hostOptions, from.key).filter((o) => !taken.has(o.value));
949
+ if (!added.length) return block;
950
+ changed = true;
951
+ const options = [...own2, ...added].map(optionJson);
952
+ return { ...block, props: { ...block.props, options } };
953
+ });
954
+ return changed ? { ...definition, blocks } : definition;
766
955
  }
767
956
  function optionLabel(field, value) {
768
957
  const option = choiceOptions(field).find((o) => o.value === value);
@@ -882,7 +1071,7 @@ var builtins = [
882
1071
  value: { json: true },
883
1072
  operators: ["eq", "neq", "in", "answered", "empty"],
884
1073
  a11y: { semantics: "group" },
885
- validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
1074
+ validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
886
1075
  summarize: (value, field) => optionLabel(field, String(value))
887
1076
  },
888
1077
  {
@@ -894,7 +1083,8 @@ var builtins = [
894
1083
  a11y: { semantics: "group" },
895
1084
  validate: (value, field) => {
896
1085
  const allowed = new Set(choiceOptions(field).map((o) => o.value));
897
- return Array.isArray(value) && value.length <= allowed.size && new Set(value).size === value.length && value.every((v) => typeof v === "string" && allowed.has(v)) ? null : "Choose from the options.";
1086
+ const others = Array.isArray(value) ? value.filter((v) => typeof v === "string" && !allowed.has(v)) : [];
1087
+ return Array.isArray(value) && new Set(value).size === value.length && value.every((v) => typeof v === "string") && (others.length === 0 || others.length === 1 && typedOther(field, others[0])) ? null : "Choose from the options.";
898
1088
  },
899
1089
  summarize: (value, field) => Array.isArray(value) ? value.map((v) => optionLabel(field, String(v))).join(", ") : String(value)
900
1090
  },
@@ -915,7 +1105,7 @@ var builtins = [
915
1105
  value: { json: true },
916
1106
  operators: ["eq", "neq", "in", "answered", "empty"],
917
1107
  a11y: { semantics: "control" },
918
- validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
1108
+ validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
919
1109
  summarize: (value, field) => optionLabel(field, String(value))
920
1110
  },
921
1111
  {
@@ -1022,7 +1212,7 @@ function dateLimits(field) {
1022
1212
  if (typeof max === "string" && isIsoDate(max)) out.max = max;
1023
1213
  return out;
1024
1214
  }
1025
- for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType]) registerFieldType(manifest);
1215
+ for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType, photoFieldType]) registerFieldType(manifest);
1026
1216
 
1027
1217
  // src/events.ts
1028
1218
  function isPlainObject(value) {
@@ -1238,6 +1428,205 @@ function fieldMap(def) {
1238
1428
  return map;
1239
1429
  }
1240
1430
 
1431
+ // src/options.ts
1432
+ var CHOICE_FIELD_TYPES = /* @__PURE__ */ new Set(["single-choice", "multi-choice", "dropdown"]);
1433
+ var isChoice = (block) => !!block && block.kind === "field" && CHOICE_FIELD_TYPES.has(block.type);
1434
+ function put(record, key, value) {
1435
+ Object.defineProperty(record, key, { value, enumerable: true, writable: true, configurable: true });
1436
+ }
1437
+ function optionFilter(field) {
1438
+ return optionsSource(field)?.filter;
1439
+ }
1440
+ function answerValues(answer) {
1441
+ const one = (v) => typeof v === "string" ? [v] : typeof v === "number" || typeof v === "boolean" ? [String(v)] : [];
1442
+ if (answer === void 0 || answer === null) return [];
1443
+ return Array.isArray(answer) ? answer.flatMap(one) : one(answer);
1444
+ }
1445
+ function optionMatches(option, attribute, answer) {
1446
+ const have = option.attributes && Object.prototype.hasOwnProperty.call(option.attributes, attribute) ? option.attributes[attribute] : void 0;
1447
+ if (have === void 0) return false;
1448
+ const wanted = answerValues(answer);
1449
+ return (Array.isArray(have) ? have : [have]).some((v) => wanted.includes(v));
1450
+ }
1451
+ function filterSource(definition, key) {
1452
+ return definition.blocks.find((b) => b.kind === "field" && b.key === key);
1453
+ }
1454
+ function visibleOptions(definition, field, values) {
1455
+ const options = choiceOptions(field);
1456
+ const filter = optionFilter(field);
1457
+ if (!filter) return options;
1458
+ const source = filterSource(definition, filter.field);
1459
+ if (!source || source.id === field.id) return options;
1460
+ const answer = own(values, source.id);
1461
+ if (isEmptyValue(answer, source)) return options;
1462
+ return options.filter((o) => optionMatches(o, filter.attribute, answer));
1463
+ }
1464
+ function answersOnPath(state) {
1465
+ const out = /* @__PURE__ */ Object.create(null);
1466
+ for (const id of state.path) out[id] = own(state.values, id);
1467
+ return out;
1468
+ }
1469
+ var OUTSIDE_FILTER = "Choose one of the options.";
1470
+ function filteredChoiceError(definition, field, value, values) {
1471
+ if (!optionFilter(field) || isEmptyValue(value, field)) return null;
1472
+ const all = new Set(choiceOptions(field).map((o) => o.value));
1473
+ const shown2 = new Set(visibleOptions(definition, field, values).map((o) => o.value));
1474
+ const picked = answerValues(value);
1475
+ return picked.some((v) => all.has(v) && !shown2.has(v)) ? OUTSIDE_FILTER : null;
1476
+ }
1477
+ function hasOptionFilters(definition) {
1478
+ return definition.blocks.some((b) => isChoice(b) && optionFilter(b) !== void 0);
1479
+ }
1480
+ function clearFilteredAnswers(definition, values, changedId) {
1481
+ const changed = definition.blocks.find((b) => b.kind === "field" && b.id === changedId);
1482
+ if (!changed) return values;
1483
+ const keys = /* @__PURE__ */ new Set([changed.key]);
1484
+ let out = values;
1485
+ for (const block of definition.blocks) {
1486
+ if (!isChoice(block)) continue;
1487
+ const filter = optionFilter(block);
1488
+ if (!filter || !keys.has(filter.field)) continue;
1489
+ const value = own(out, block.id);
1490
+ if (value === void 0 || isEmptyValue(value, block)) continue;
1491
+ const all = new Set(choiceOptions(block).map((o) => o.value));
1492
+ const shown2 = new Set(visibleOptions(definition, block, out).map((o) => o.value));
1493
+ const keep = (v) => typeof v !== "string" || !all.has(v) || shown2.has(v);
1494
+ let next = value;
1495
+ if (Array.isArray(value)) {
1496
+ const kept = value.filter(keep);
1497
+ if (kept.length !== value.length) next = kept.length ? kept : void 0;
1498
+ } else if (!keep(value)) next = void 0;
1499
+ if (next === value) continue;
1500
+ if (out === values) out = { ...values };
1501
+ put(out, block.id, next);
1502
+ keys.add(block.key);
1503
+ }
1504
+ return out;
1505
+ }
1506
+ function fold(text) {
1507
+ return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
1508
+ }
1509
+ var MAX_PREFILL_PARAM_LENGTH = 64;
1510
+ function isPrefillParam(param) {
1511
+ return typeof param === "string" && param.length <= MAX_PREFILL_PARAM_LENGTH && /^[A-Za-z0-9_.-]+$/.test(param);
1512
+ }
1513
+ function prefillSetting(field) {
1514
+ if (!CHOICE_FIELD_TYPES.has(field.type)) return void 0;
1515
+ const raw = field.props?.prefill;
1516
+ if (!raw || typeof raw !== "object" || Array.isArray(raw) || !isPrefillParam(raw.param)) return void 0;
1517
+ return { param: raw.param, hideWhenPrefilled: raw.hideWhenPrefilled === true };
1518
+ }
1519
+ function paramValues(params, name) {
1520
+ let raw;
1521
+ if (typeof params.getAll === "function") {
1522
+ raw = params.getAll(name);
1523
+ } else if (Object.prototype.hasOwnProperty.call(params, name)) {
1524
+ raw = params[name];
1525
+ }
1526
+ const list = Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw];
1527
+ return list.filter((v) => typeof v === "string" && v !== "" && v.length <= MAX_HIDDEN_STRING_LENGTH);
1528
+ }
1529
+ function prefillFromLink(definition, params, values = {}) {
1530
+ const out = {};
1531
+ if (!params || typeof params !== "object") return out;
1532
+ const seen = {};
1533
+ for (const id of Object.keys(values)) put(seen, id, values[id]);
1534
+ for (const block of definition.blocks) {
1535
+ if (!isChoice(block)) continue;
1536
+ const setting = prefillSetting(block);
1537
+ if (!setting || own(seen, block.id) !== void 0) continue;
1538
+ const raw = paramValues(params, setting.param);
1539
+ if (!raw.length) continue;
1540
+ const shown2 = new Set(visibleOptions(definition, block, seen).map((o) => o.value));
1541
+ let value;
1542
+ if (block.type === "multi-choice") {
1543
+ const picked = [];
1544
+ for (const r of raw) {
1545
+ for (const v of shown2.has(r) ? [r] : r.split(",").map((s) => s.trim())) {
1546
+ if (shown2.has(v) && !picked.includes(v)) picked.push(v);
1547
+ }
1548
+ }
1549
+ if (picked.length) value = picked;
1550
+ } else value = raw.find((v) => shown2.has(v));
1551
+ if (value === void 0) continue;
1552
+ put(out, block.id, value);
1553
+ put(seen, block.id, value);
1554
+ }
1555
+ return out;
1556
+ }
1557
+ function hiddenByPrefill(definition, state) {
1558
+ const hideable = /* @__PURE__ */ new Set();
1559
+ for (const b of definition.blocks) {
1560
+ if (isChoice(b) && prefillSetting(b)?.hideWhenPrefilled) hideable.add(b.id);
1561
+ }
1562
+ if (!hideable.size) return [];
1563
+ const prefilled = /* @__PURE__ */ new Set();
1564
+ for (const e of state.events) {
1565
+ if (e.type !== "answer" || !hideable.has(e.field)) continue;
1566
+ if (e.source === "prefill") prefilled.add(e.field);
1567
+ else prefilled.delete(e.field);
1568
+ }
1569
+ if (!prefilled.size) return [];
1570
+ const fields = new Map(definition.blocks.map((b) => [b.id, b]));
1571
+ return state.path.filter((id) => {
1572
+ if (!prefilled.has(id) || own(state.errors, id) !== void 0) return false;
1573
+ const field = fields.get(id);
1574
+ return !isEmptyValue(own(state.values, id), field);
1575
+ });
1576
+ }
1577
+ function snapshotOf(byValue, value) {
1578
+ const option = byValue.get(value);
1579
+ if (option) return { value, label: option.label ?? value };
1580
+ return { value, label: value, other: true };
1581
+ }
1582
+ function answerSnapshots(definition, answers) {
1583
+ const out = {};
1584
+ for (const block of definition.blocks) {
1585
+ if (!isChoice(block) || !optionsSource(block)) continue;
1586
+ const value = own(answers, block.id);
1587
+ if (isEmptyValue(value, block)) continue;
1588
+ const byValue = new Map(choiceOptions(block).map((o) => [o.value, o]));
1589
+ let snapshot;
1590
+ if (Array.isArray(value)) {
1591
+ const items = value.filter((v) => typeof v === "string").map((v) => snapshotOf(byValue, v));
1592
+ if (items.length) snapshot = items;
1593
+ } else if (typeof value === "string") snapshot = snapshotOf(byValue, value);
1594
+ if (snapshot) put(out, block.id, snapshot);
1595
+ }
1596
+ return out;
1597
+ }
1598
+ function answersFromSnapshots(definition, snapshots) {
1599
+ const answers = {};
1600
+ const missing = {};
1601
+ for (const block of definition.blocks) {
1602
+ if (!isChoice(block)) continue;
1603
+ const raw = own(snapshots, block.id);
1604
+ const list = (Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw]).flatMap((s) => {
1605
+ const o = readOption(s);
1606
+ if (!o) return [];
1607
+ const label = o.label ?? o.value;
1608
+ return [s.other === true ? { value: o.value, label, other: true } : { value: o.value, label }];
1609
+ });
1610
+ if (!list.length) continue;
1611
+ const options = choiceOptions(block);
1612
+ const byValue = new Set(options.map((o) => o.value));
1613
+ const allowsOther = block.props?.other !== void 0 && block.props?.other !== false;
1614
+ const found = [];
1615
+ const lost = [];
1616
+ for (const s of list) {
1617
+ let value;
1618
+ if (byValue.has(s.value)) value = s.value;
1619
+ else if (s.other) value = allowsOther ? s.value : void 0;
1620
+ else value = options.find((o) => fold(o.label ?? o.value) === fold(s.label))?.value;
1621
+ if (value === void 0) lost.push(s);
1622
+ else if (!found.includes(value)) found.push(value);
1623
+ }
1624
+ if (found.length) put(answers, block.id, block.type === "multi-choice" ? found : found[0]);
1625
+ if (lost.length) put(missing, block.id, lost);
1626
+ }
1627
+ return { answers, missing };
1628
+ }
1629
+
1241
1630
  // src/recall.ts
1242
1631
  var TOKEN = /\{\{\s*(field|hidden):([A-Za-z0-9_-]+)\s*(?:\|([^}]*))?\}\}/g;
1243
1632
  function recall(text, scope) {
@@ -1291,7 +1680,8 @@ function readsAny(node, ids) {
1291
1680
  function createForm(definition, options = {}) {
1292
1681
  return build(definition, options).engine;
1293
1682
  }
1294
- function build(definition, options, replaying = false, budget) {
1683
+ function build(given, options, replaying = false, budget) {
1684
+ const definition = withHostOptions(given, options.hostOptions);
1295
1685
  const fields = fieldMap(definition);
1296
1686
  const byKey = new Map([...fields.values()].map((f) => [f.key, f]));
1297
1687
  const blocks = new Map(definition.blocks.map((b) => [b.id, b]));
@@ -1337,6 +1727,7 @@ function build(definition, options, replaying = false, budget) {
1337
1727
  path: first.path,
1338
1728
  current: first.path[0] ?? null,
1339
1729
  history: [],
1730
+ furthest: first.path[0] ?? null,
1340
1731
  errors: {},
1341
1732
  status: "in_progress",
1342
1733
  ending: null,
@@ -1357,16 +1748,34 @@ function build(definition, options, replaying = false, budget) {
1357
1748
  return at === void 0 ? event : { ...event, at };
1358
1749
  };
1359
1750
  const isVisibleIn = (s, id) => s.path.includes(id);
1751
+ const filters = hasOptionFilters(definition);
1752
+ const clearFiltered = (values, id) => filters ? clearFilteredAnswers(definition, values, id) : values;
1360
1753
  const errorsFor = (s, ids) => {
1361
1754
  const errors = {};
1755
+ let shown2;
1362
1756
  for (const id of ids) {
1363
1757
  const field = fields.get(id);
1364
1758
  if (!field || !isVisibleIn(s, id)) continue;
1365
- const message = validateField(field, own(s.values, id), { custom: options.validators?.[id] });
1759
+ const value = own(s.values, id);
1760
+ let message = validateField(field, value, { custom: options.validators?.[id] });
1761
+ if (!message && filters) message = filteredChoiceError(definition, field, value, shown2 ??= answersOnPath(s));
1366
1762
  if (message) errors[id] = message;
1367
1763
  }
1368
1764
  return errors;
1369
1765
  };
1766
+ const skipped = (s) => new Set(hiddenByPrefill(definition, s));
1767
+ const shownFrom = (s, from, skip) => {
1768
+ for (let i = from; i < s.path.length; i++) if (!skip.has(s.path[i])) return s.path[i];
1769
+ return void 0;
1770
+ };
1771
+ const showCurrent = () => {
1772
+ if (!state.current) return;
1773
+ const skip = skipped(state);
1774
+ if (!skip.has(state.current)) return;
1775
+ const at = state.path.indexOf(state.current);
1776
+ const shown2 = at < 0 ? void 0 : shownFrom(state, at, skip);
1777
+ if (shown2) state = { ...state, current: shown2, furthest: state.furthest === state.current ? shown2 : state.furthest };
1778
+ };
1370
1779
  let unsettled = false;
1371
1780
  let formulasStale = false;
1372
1781
  const settleNow = (needValues = true) => {
@@ -1395,13 +1804,13 @@ function build(definition, options, replaying = false, budget) {
1395
1804
  const errors = { ...state.errors };
1396
1805
  delete errors[id];
1397
1806
  if (batch) {
1398
- state.values = { ...state.values, [id]: value };
1807
+ state.values = clearFiltered({ ...state.values, [id]: value }, id);
1399
1808
  state.errors = errors;
1400
1809
  log(event);
1401
1810
  unsettled = true;
1402
1811
  return;
1403
1812
  }
1404
- const { values, path } = settle({ ...state.values, [id]: value }, state.hidden);
1813
+ const { values, path } = settle(clearFiltered({ ...state.values, [id]: value }, id), state.hidden);
1405
1814
  emit({ ...state, values, path, errors, events: log(event) });
1406
1815
  };
1407
1816
  const engine = {
@@ -1432,19 +1841,21 @@ function build(definition, options, replaying = false, budget) {
1432
1841
  return false;
1433
1842
  }
1434
1843
  const at = state.path.indexOf(state.current);
1435
- const target = state.path[at + 1];
1844
+ const target = shownFrom(state, at + 1, skipped(state));
1436
1845
  if (!target) {
1437
1846
  emit({ ...state, events });
1438
1847
  return true;
1439
1848
  }
1440
- emit({ ...state, current: target, history: [...state.history, state.current], events });
1849
+ const furthest = state.furthest && state.path.indexOf(state.furthest) >= state.path.indexOf(target) ? state.furthest : target;
1850
+ emit({ ...state, current: target, history: [...state.history, state.current], furthest, events });
1441
1851
  return true;
1442
1852
  },
1443
1853
  back() {
1444
1854
  if (state.status === "completed") return;
1445
1855
  const history = [...state.history];
1856
+ const skip = skipped(state);
1446
1857
  let previous = history.pop();
1447
- while (previous && !state.path.includes(previous)) previous = history.pop();
1858
+ while (previous && (!state.path.includes(previous) || skip.has(previous))) previous = history.pop();
1448
1859
  if (!previous) return;
1449
1860
  emit({ ...state, current: previous, history, events: log(stamp({ type: "back" })) });
1450
1861
  },
@@ -1472,15 +1883,27 @@ function build(definition, options, replaying = false, budget) {
1472
1883
  return out;
1473
1884
  }
1474
1885
  };
1475
- if (options.initialValues) {
1886
+ const fromLink = options.linkParams ? prefillFromLink(definition, options.linkParams, options.initialValues) : {};
1887
+ const initialValues = Object.keys(fromLink).length ? { ...fromLink, ...options.initialValues } : options.initialValues;
1888
+ if (initialValues) {
1476
1889
  const wasBatch = batch;
1477
1890
  batch = true;
1478
- for (const [id, value] of Object.entries(options.initialValues)) {
1891
+ for (const [id, value] of Object.entries(initialValues)) {
1479
1892
  if (!fields.has(id) || value === void 0) continue;
1480
1893
  answer(id, value, stamp({ type: "answer", field: id, value, source: "prefill" }));
1481
1894
  }
1482
1895
  settleNow();
1483
1896
  batch = wasBatch;
1897
+ showCurrent();
1898
+ }
1899
+ const resumeAt = options.resumeAt;
1900
+ if (resumeAt && !replaying && state.path.includes(resumeAt)) {
1901
+ for (let i = 0; i < state.path.length && state.current !== resumeAt; i++) {
1902
+ if (!engine.next()) {
1903
+ state = { ...state, errors: {} };
1904
+ break;
1905
+ }
1906
+ }
1484
1907
  }
1485
1908
  const apply = (event) => {
1486
1909
  if (event.type === "answer") {
@@ -1490,18 +1913,22 @@ function build(definition, options, replaying = false, budget) {
1490
1913
  }
1491
1914
  } else if (event.type === "next") {
1492
1915
  settleNow(false);
1916
+ showCurrent();
1493
1917
  if (formulasStale && isCalculated(fields.get(state.current ?? ""))) settleNow(true);
1494
1918
  engine.next();
1495
1919
  } else if (event.type === "back") {
1496
1920
  settleNow(false);
1921
+ showCurrent();
1497
1922
  engine.back();
1498
1923
  } else if (event.type === "submit") {
1499
1924
  settleNow();
1925
+ showCurrent();
1500
1926
  engine.submit();
1501
1927
  }
1502
1928
  };
1503
1929
  const finish = () => {
1504
1930
  settleNow();
1931
+ showCurrent();
1505
1932
  batch = false;
1506
1933
  state = { ...state, events: [...state.events] };
1507
1934
  };
@@ -1509,7 +1936,12 @@ function build(definition, options, replaying = false, budget) {
1509
1936
  }
1510
1937
  function replayWithin(definition, events, options, maxWork) {
1511
1938
  const budget = maxWork === void 0 ? void 0 : { used: 0, max: maxWork };
1512
- const { engine, apply, finish } = build(definition, { ...options, initialValues: void 0, clock: () => void 0 }, true, budget);
1939
+ const { engine, apply, finish } = build(
1940
+ definition,
1941
+ { ...options, initialValues: void 0, linkParams: void 0, clock: () => void 0 },
1942
+ true,
1943
+ budget
1944
+ );
1513
1945
  try {
1514
1946
  for (const event of events) apply(event);
1515
1947
  finish();
@@ -1581,6 +2013,31 @@ function parseForm(input) {
1581
2013
  const optionalBoolean = (v, path) => {
1582
2014
  if (v !== void 0 && typeof v !== "boolean") issue(path, "Must be true or false.");
1583
2015
  };
2016
+ const optionDetails = (o, at) => {
2017
+ if (o.description !== void 0 && (typeof o.description !== "string" || o.description.length > OPTION_LIMITS.description)) {
2018
+ issue(`${at}.description`, `Must be text of at most ${OPTION_LIMITS.description} characters.`);
2019
+ }
2020
+ if (o.image !== void 0 && !isHttpsUrl(o.image)) {
2021
+ issue(`${at}.image`, `Must be an https:// link of at most ${OPTION_LIMITS.image} characters.`);
2022
+ }
2023
+ if (o.attributes === void 0) return;
2024
+ const attributes = o.attributes;
2025
+ if (!isObject2(attributes) || Object.keys(attributes).length > OPTION_LIMITS.attributes) {
2026
+ issue(`${at}.attributes`, `Must be an object of at most ${OPTION_LIMITS.attributes} attributes.`);
2027
+ return;
2028
+ }
2029
+ const fits = (x) => typeof x === "string" && x.length <= OPTION_LIMITS.attributeValue;
2030
+ for (const [name2, v] of Object.entries(attributes)) {
2031
+ if (!name2.trim() || name2.length > OPTION_LIMITS.attributeName || RESERVED.has(name2)) {
2032
+ issue(`${at}.attributes`, `${shown(name2)} isn't a usable attribute name (at most ${OPTION_LIMITS.attributeName} characters).`);
2033
+ } else if (!fits(v) && !(Array.isArray(v) && v.length <= OPTION_LIMITS.attributeItems && v.every(fits))) {
2034
+ issue(
2035
+ `${at}.attributes.${name2}`,
2036
+ `Must be text, or a list of at most ${OPTION_LIMITS.attributeItems} texts, each at most ${OPTION_LIMITS.attributeValue} characters.`
2037
+ );
2038
+ }
2039
+ }
2040
+ };
1584
2041
  const name = (v, path, what) => {
1585
2042
  if (typeof v !== "string" || !v) {
1586
2043
  issue(path, `${what} must be a non-empty string.`);
@@ -1669,6 +2126,22 @@ function parseForm(input) {
1669
2126
  }
1670
2127
  }
1671
2128
  if (type === "consent") optionalText(props.text, `${path}.text`);
2129
+ if (type === PHOTO_TYPE) {
2130
+ if (props.maxFiles !== void 0 && !(isInt(props.maxFiles) && props.maxFiles >= 1 && props.maxFiles <= MAX_PHOTO_FILES)) {
2131
+ issue(`${path}.maxFiles`, `Must be a whole number from 1 to ${MAX_PHOTO_FILES}.`);
2132
+ }
2133
+ if (props.maxSizeMb !== void 0 && !(isInt(props.maxSizeMb) && props.maxSizeMb >= 1 && props.maxSizeMb <= MAX_PHOTO_SIZE_MB)) {
2134
+ issue(`${path}.maxSizeMb`, `Must be a whole number from 1 to ${MAX_PHOTO_SIZE_MB}.`);
2135
+ }
2136
+ if (props.formats !== void 0) {
2137
+ const formats = props.formats;
2138
+ if (!Array.isArray(formats) || formats.length === 0 || !formats.every(isPhotoFormat)) {
2139
+ issue(`${path}.formats`, "Must be a list of one or more of jpeg, png, heic and webp.");
2140
+ } else if (new Set(formats).size !== formats.length) {
2141
+ issue(`${path}.formats`, "Name each format once.");
2142
+ }
2143
+ }
2144
+ }
1672
2145
  };
1673
2146
  if (!isObject2(input)) {
1674
2147
  issue("", "A form definition must be an object.");
@@ -1689,6 +2162,7 @@ function parseForm(input) {
1689
2162
  else {
1690
2163
  optionalBoolean(input.settings.anonymous, "settings.anonymous");
1691
2164
  optionalBoolean(input.settings.clearHiddenValues, "settings.clearHiddenValues");
2165
+ optionalBoolean(input.settings.saveProgress, "settings.saveProgress");
1692
2166
  }
1693
2167
  }
1694
2168
  if (input.meta !== void 0 && (!isObject2(input.meta) || !isJson(input.meta))) {
@@ -1729,6 +2203,38 @@ function parseForm(input) {
1729
2203
  issue(`${path}.type`, `Unknown field type "${String(block.type)}".`);
1730
2204
  }
1731
2205
  if (block.props !== void 0) {
2206
+ const from = isObject2(block.props) ? block.props.optionsFrom : void 0;
2207
+ if (from !== void 0) {
2208
+ if (!isObject2(from) || from.source !== "host" || typeof from.key !== "string" || !from.key) {
2209
+ issue(`${path}.props.optionsFrom`, 'Must be { source: "host", key } with a non-empty key.');
2210
+ } else if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2211
+ warnings.push({ path: `${path}.props.optionsFrom`, message: "Only single choice, multiple choice and dropdown questions take options from the app." });
2212
+ }
2213
+ const filter = isObject2(from) ? from.filter : void 0;
2214
+ if (filter !== void 0) {
2215
+ const at = `${path}.props.optionsFrom.filter`;
2216
+ if (!isObject2(filter) || typeof filter.attribute !== "string" || !filter.attribute || filter.attribute.length > OPTION_LIMITS.attributeName || typeof filter.field !== "string" || !filter.field) {
2217
+ issue(at, `Must be { attribute, field }: an attribute name (at most ${OPTION_LIMITS.attributeName} characters) and an earlier question's key.`);
2218
+ } else if (!keys.has(filter.field)) {
2219
+ issue(`${at}.field`, `"${filter.field}" must be the key of an earlier question.`);
2220
+ }
2221
+ }
2222
+ }
2223
+ const prefill = isObject2(block.props) ? block.props.prefill : void 0;
2224
+ if (prefill !== void 0) {
2225
+ if (!isObject2(prefill) || !isPrefillParam(prefill.param)) {
2226
+ issue(
2227
+ `${path}.props.prefill`,
2228
+ `Must be { param, hideWhenPrefilled? }: a link parameter of letters, digits, "_", "-" or "." (at most ${MAX_PREFILL_PARAM_LENGTH} characters).`
2229
+ );
2230
+ } else {
2231
+ optionalBoolean(prefill.hideWhenPrefilled, `${path}.props.prefill.hideWhenPrefilled`);
2232
+ if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2233
+ warnings.push({ path: `${path}.props.prefill`, message: "Only single choice, multiple choice and dropdown questions are filled in from a link." });
2234
+ }
2235
+ }
2236
+ }
2237
+ if (isObject2(block.props)) optionalBoolean(block.props.searchable, `${path}.props.searchable`);
1732
2238
  if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
1733
2239
  else if (block.props.options !== void 0) {
1734
2240
  const options = block.props.options;
@@ -1737,12 +2243,25 @@ function parseForm(input) {
1737
2243
  options.forEach((o, j) => {
1738
2244
  const at = `${path}.props.options[${j}]`;
1739
2245
  if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
1740
- else optionalText(o.label, `${at}.label`);
2246
+ else {
2247
+ optionalText(o.label, `${at}.label`);
2248
+ optionDetails(o, at);
2249
+ }
1741
2250
  });
1742
- choices.set(
1743
- block.id,
1744
- new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
1745
- );
2251
+ if (from === void 0) {
2252
+ choices.set(
2253
+ block.id,
2254
+ new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
2255
+ );
2256
+ }
2257
+ }
2258
+ }
2259
+ const other = isObject2(block.props) ? block.props.other : void 0;
2260
+ if (other !== void 0) {
2261
+ if (other !== true && other !== false && !(isObject2(other) && (other.label === void 0 || typeof other.label === "string"))) {
2262
+ issue(`${path}.props.other`, "Must be true, false or { label }.");
2263
+ } else if (other !== false && typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2264
+ warnings.push({ path: `${path}.props.other`, message: 'Only single choice, multiple choice and dropdown questions take an "Other" answer.' });
1746
2265
  }
1747
2266
  }
1748
2267
  const { max, maxLength: maxLength2 } = isObject2(block.props) ? block.props : {};
@@ -1817,6 +2336,8 @@ function parseForm(input) {
1817
2336
  } else if (ref.column !== void 0) issue(path, `"${ref.key}" is not a table, so it has no column "${ref.column}".`);
1818
2337
  else if (targetType === "media-notes" || targetType === "text-highlights") {
1819
2338
  warnings.push({ path, message: `"${ref.key}" holds notes, not numbers; the formula reads it as blank.` });
2339
+ } else if (targetType === PHOTO_TYPE) {
2340
+ warnings.push({ path, message: `"${ref.key}" holds photos, not numbers; the formula reads it as blank.` });
1820
2341
  }
1821
2342
  }
1822
2343
  });
@@ -2018,6 +2539,158 @@ function parseForm(input) {
2018
2539
  return issues.length ? fail() : { ok: true, form: input, warnings };
2019
2540
  }
2020
2541
 
2542
+ // src/edit-link.ts
2543
+ var EDIT_LINK_TTL_SECONDS = 30 * 24 * 60 * 60;
2544
+ var MIN_EDIT_SECRET_BYTES = 32;
2545
+ var EDIT_LINK_PARAM = "edit";
2546
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2547
+ var NONCE = /^[A-Za-z0-9_-]{16,64}$/;
2548
+ var EXPIRES = /^[1-9][0-9]{0,11}$/;
2549
+ var SIG = /^[A-Za-z0-9_-]{43}$/;
2550
+ var DOMAIN = "edit-link:v2:";
2551
+ var MAX_TOKEN_LENGTH = 300;
2552
+ var encoder = new TextEncoder();
2553
+ function toBase64Url(bytes) {
2554
+ let binary = "";
2555
+ for (const b of bytes) binary += String.fromCharCode(b);
2556
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2557
+ }
2558
+ function fromBase64Url(text) {
2559
+ const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
2560
+ const bytes = new Uint8Array(binary.length);
2561
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
2562
+ return bytes;
2563
+ }
2564
+ function isUsableEditSecret(secret) {
2565
+ return typeof secret === "string" && secret.trim().length > 0 && encoder.encode(secret).length >= MIN_EDIT_SECRET_BYTES;
2566
+ }
2567
+ function hmacKey(secret, usage) {
2568
+ return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [usage]);
2569
+ }
2570
+ var payload = (id, nonce, expiresAt) => encoder.encode(`${DOMAIN}${id}.${nonce}.${expiresAt}`);
2571
+ async function signEditToken(link, secret, options = {}) {
2572
+ if (!isUsableEditSecret(secret) || !UUID.test(link.id) || !NONCE.test(link.nonce)) return null;
2573
+ const ttl = options.ttlSeconds ?? EDIT_LINK_TTL_SECONDS;
2574
+ if (!Number.isFinite(ttl) || ttl <= 0) return null;
2575
+ const id = link.id.toLowerCase();
2576
+ const expiresAt = Math.floor((options.now ?? Date.now()) / 1e3) + Math.floor(ttl);
2577
+ const sig = await crypto.subtle.sign("HMAC", await hmacKey(secret, "sign"), payload(id, link.nonce, expiresAt));
2578
+ return `${id}.${link.nonce}.${expiresAt}.${toBase64Url(new Uint8Array(sig))}`;
2579
+ }
2580
+ async function verifyEditToken(token, secret, options = {}) {
2581
+ if (!isUsableEditSecret(secret) || typeof token !== "string" || token.length > MAX_TOKEN_LENGTH) return null;
2582
+ const parts = token.split(".");
2583
+ if (parts.length !== 4) return null;
2584
+ const [id, nonce, expires, sig] = parts;
2585
+ if (!UUID.test(id) || id !== id.toLowerCase() || !NONCE.test(nonce) || !EXPIRES.test(expires)) return null;
2586
+ if (!SIG.test(sig)) return null;
2587
+ const expiresAt = Number(expires);
2588
+ const now = Math.floor((options.now ?? Date.now()) / 1e3);
2589
+ const ttl = options.ttlSeconds ?? EDIT_LINK_TTL_SECONDS;
2590
+ if (expiresAt <= now || expiresAt > now + ttl) return null;
2591
+ let bytes;
2592
+ try {
2593
+ bytes = fromBase64Url(sig);
2594
+ } catch {
2595
+ return null;
2596
+ }
2597
+ const ok = await crypto.subtle.verify("HMAC", await hmacKey(secret, "verify"), bytes, payload(id, nonce, expiresAt));
2598
+ return ok ? { id, nonce, expiresAt } : null;
2599
+ }
2600
+ function withEditToken(formUrl, token, param = EDIT_LINK_PARAM) {
2601
+ return `${formUrl}${formUrl.includes("?") ? "&" : "?"}${param}=${encodeURIComponent(token)}`;
2602
+ }
2603
+ async function editLinkUrl(formUrl, link, secret, options = {}) {
2604
+ const token = await signEditToken(link, secret, options);
2605
+ return token ? withEditToken(formUrl, token, options.param) : null;
2606
+ }
2607
+ function createEditSecretReader(options) {
2608
+ const { variable } = options;
2609
+ let warned = false;
2610
+ const read = (env, log) => {
2611
+ const secret = env[variable];
2612
+ if (isUsableEditSecret(secret)) return secret;
2613
+ if (!warned) {
2614
+ warned = true;
2615
+ (log ?? options.log ?? ((m) => console.warn(m)))(
2616
+ `${variable} is ${secret ? `shorter than ${MIN_EDIT_SECRET_BYTES} bytes` : "not set"}: edit links are off.`
2617
+ );
2618
+ }
2619
+ return null;
2620
+ };
2621
+ return Object.assign(read, {
2622
+ reset: () => {
2623
+ warned = false;
2624
+ }
2625
+ });
2626
+ }
2627
+ function isEditLinkRequest(url, options = {}) {
2628
+ const isFormPage = options.isFormPage ?? (() => true);
2629
+ return isFormPage(url) && url.searchParams.has(options.param ?? EDIT_LINK_PARAM);
2630
+ }
2631
+ function withEditLinkHeaders(request, response, options = {}) {
2632
+ let url;
2633
+ try {
2634
+ url = new URL(request.url);
2635
+ } catch {
2636
+ return response;
2637
+ }
2638
+ if (!isEditLinkRequest(url, options)) return response;
2639
+ const headers = new Headers(response.headers);
2640
+ headers.set("referrer-policy", "no-referrer");
2641
+ headers.set("cache-control", "private, no-store");
2642
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
2643
+ }
2644
+
2645
+ // src/rate-limit.ts
2646
+ var DEFAULT_TRUSTED_IP_HEADERS = ["x-vercel-forwarded-for", "x-forwarded-for", "x-real-ip"];
2647
+ var RATE_LIMIT_DEFAULTS = {
2648
+ submit: { limit: 60, windowSeconds: 600 },
2649
+ progress: { limit: 900, windowSeconds: 600 },
2650
+ upload: { limit: 120, windowSeconds: 600 }
2651
+ };
2652
+ function clientIp(headers, trusted = DEFAULT_TRUSTED_IP_HEADERS) {
2653
+ for (const name of trusted) {
2654
+ const first = headers.get(name)?.split(",")[0]?.trim();
2655
+ if (first) return first;
2656
+ }
2657
+ return "unknown";
2658
+ }
2659
+ function addressPrefix(ip) {
2660
+ const bare = ip.replace(/^\[|\](:\d+)?$/g, "").split("%")[0].toLowerCase();
2661
+ if (!bare.includes(":")) return bare;
2662
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(bare);
2663
+ if (mapped) return mapped[1];
2664
+ const [head = "", tail = ""] = bare.split("::");
2665
+ const left = head ? head.split(":") : [];
2666
+ const right = bare.includes("::") && tail ? tail.split(":") : [];
2667
+ const groups = bare.includes("::") ? [...left, ...Array(Math.max(0, 8 - left.length - right.length)).fill("0"), ...right] : left;
2668
+ return `${groups.slice(0, 4).map((g) => (g || "0").replace(/^0+(?=.)/, "")).join(":")}::/64`;
2669
+ }
2670
+ async function rateLimitKey(input) {
2671
+ const encoder2 = new TextEncoder();
2672
+ const key = await crypto.subtle.importKey("raw", encoder2.encode(input.secret), { name: "HMAC", hash: "SHA-256" }, false, [
2673
+ "sign"
2674
+ ]);
2675
+ const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder2.encode(addressPrefix(input.ip))));
2676
+ let hex = "";
2677
+ for (const b of mac) hex += b.toString(16).padStart(2, "0");
2678
+ return `${input.kind}:${input.scope}:${hex}`;
2679
+ }
2680
+ async function allow(store, check, options = {}) {
2681
+ let allowed;
2682
+ try {
2683
+ allowed = await store.hit(check.key, check.limit, check.windowSeconds);
2684
+ } catch {
2685
+ allowed = null;
2686
+ }
2687
+ if (allowed !== null) return allowed;
2688
+ (options.log ?? ((m) => console.warn(m)))(
2689
+ options.message ?? "The rate limit could not be checked; letting the request through."
2690
+ );
2691
+ return true;
2692
+ }
2693
+
2021
2694
  // src/server.ts
2022
2695
  var MAX_EVENTS = 1e4;
2023
2696
  var MAX_SUBMISSION_VALUES = 2e5;
@@ -2081,9 +2754,10 @@ function wellFormed(input) {
2081
2754
  }
2082
2755
  return { answers, events: known, hidden };
2083
2756
  }
2084
- function validateSubmission(definition, submission, options = {}) {
2085
- const invalid = checkDefinition(definition);
2757
+ function validateSubmission(published, submission, options = {}) {
2758
+ const invalid = checkDefinition(published);
2086
2759
  if (invalid) return { ok: false, errors: { "": invalid } };
2760
+ const definition = withHostOptions(published, options.hostOptions);
2087
2761
  if (!isPlainObject(submission)) return MALFORMED;
2088
2762
  const maxEvents = options.maxEvents ?? MAX_EVENTS;
2089
2763
  let sent = submission;
@@ -2154,11 +2828,12 @@ function validateSubmission(definition, submission, options = {}) {
2154
2828
  }
2155
2829
  } else {
2156
2830
  const errors = {};
2157
- for (const [id, value] of Object.entries(engine.answers())) {
2831
+ const kept = engine.answers();
2832
+ for (const [id, value] of Object.entries(kept)) {
2158
2833
  const message = validateField(fields.get(id), value, {
2159
2834
  requireAnswers: false,
2160
2835
  custom: options.validators?.[id]
2161
- });
2836
+ }) ?? filteredChoiceError(definition, fields.get(id), value, kept);
2162
2837
  if (message) errors[id] = message;
2163
2838
  }
2164
2839
  if (Object.keys(errors).length) return { ok: false, errors };
@@ -2200,11 +2875,31 @@ function publicDefinition(definition) {
2200
2875
  return JSON.parse(JSON.stringify(definition));
2201
2876
  }
2202
2877
 
2878
+ exports.DEFAULT_TRUSTED_IP_HEADERS = DEFAULT_TRUSTED_IP_HEADERS;
2879
+ exports.EDIT_LINK_PARAM = EDIT_LINK_PARAM;
2880
+ exports.EDIT_LINK_TTL_SECONDS = EDIT_LINK_TTL_SECONDS;
2203
2881
  exports.MAX_EVENTS = MAX_EVENTS;
2204
2882
  exports.MAX_REPLAY_WORK = MAX_REPLAY_WORK;
2205
2883
  exports.MAX_SUBMISSION_VALUES = MAX_SUBMISSION_VALUES;
2884
+ exports.MIN_EDIT_SECRET_BYTES = MIN_EDIT_SECRET_BYTES;
2885
+ exports.RATE_LIMIT_DEFAULTS = RATE_LIMIT_DEFAULTS;
2206
2886
  exports.RAW_EVENTS_PER_EVENT = RAW_EVENTS_PER_EVENT;
2887
+ exports.addressPrefix = addressPrefix;
2888
+ exports.allow = allow;
2889
+ exports.answerSnapshots = answerSnapshots;
2890
+ exports.answersFromSnapshots = answersFromSnapshots;
2891
+ exports.clientIp = clientIp;
2892
+ exports.createEditSecretReader = createEditSecretReader;
2893
+ exports.editLinkUrl = editLinkUrl;
2894
+ exports.isEditLinkRequest = isEditLinkRequest;
2895
+ exports.isUsableEditSecret = isUsableEditSecret;
2207
2896
  exports.publicDefinition = publicDefinition;
2897
+ exports.rateLimitKey = rateLimitKey;
2898
+ exports.signEditToken = signEditToken;
2208
2899
  exports.validateSubmission = validateSubmission;
2900
+ exports.verifyEditToken = verifyEditToken;
2901
+ exports.withEditLinkHeaders = withEditLinkHeaders;
2902
+ exports.withEditToken = withEditToken;
2903
+ exports.withHostOptions = withHostOptions;
2209
2904
  //# sourceMappingURL=server.cjs.map
2210
2905
  //# sourceMappingURL=server.cjs.map