@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/testing.js CHANGED
@@ -719,6 +719,80 @@ var structuredFieldTypes = [
719
719
  }
720
720
  ];
721
721
 
722
+ // src/photo.ts
723
+ var PHOTO_TYPE = "photo";
724
+ var PHOTO_FORMATS = {
725
+ jpeg: ["image/jpeg"],
726
+ png: ["image/png"],
727
+ heic: ["image/heic", "image/heif"],
728
+ webp: ["image/webp"]
729
+ };
730
+ var FORMAT_NAMES = { jpeg: "JPEG", png: "PNG", heic: "HEIC", webp: "WebP" };
731
+ var ALL_FORMATS = Object.keys(PHOTO_FORMATS);
732
+ var DEFAULT_PHOTO_FILES = 3;
733
+ var MAX_PHOTO_FILES = 5;
734
+ var DEFAULT_PHOTO_SIZE_MB = 10;
735
+ var MAX_PHOTO_SIZE_MB = 25;
736
+ var MAX_PHOTO_ID_LENGTH = 200;
737
+ var MAX_PHOTO_NAME_LENGTH = 255;
738
+ var isPhotoFormat = (v) => typeof v === "string" && Object.prototype.hasOwnProperty.call(PHOTO_FORMATS, v);
739
+ var wholeIn = (v, min, max) => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max;
740
+ function photoLimits(field) {
741
+ const maxFiles = wholeIn(field.props?.maxFiles, 1, MAX_PHOTO_FILES) ? field.props.maxFiles : DEFAULT_PHOTO_FILES;
742
+ const maxSizeMb = wholeIn(field.props?.maxSizeMb, 1, MAX_PHOTO_SIZE_MB) ? field.props.maxSizeMb : DEFAULT_PHOTO_SIZE_MB;
743
+ const raw = field.props?.formats;
744
+ const picked = Array.isArray(raw) ? ALL_FORMATS.filter((f) => raw.includes(f)) : [];
745
+ const formats = picked.length ? picked : [...ALL_FORMATS];
746
+ return {
747
+ maxFiles,
748
+ maxSizeMb,
749
+ maxBytes: maxSizeMb * 1024 * 1024,
750
+ formats,
751
+ mimeTypes: formats.flatMap((f) => PHOTO_FORMATS[f])
752
+ };
753
+ }
754
+ function isPhotoRef(v) {
755
+ if (!v || typeof v !== "object" || Array.isArray(v)) return false;
756
+ const r = v;
757
+ 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;
758
+ }
759
+ function photoRefs(value) {
760
+ if (!Array.isArray(value)) return [];
761
+ const refs = [];
762
+ for (const r of value) if (isPhotoRef(r)) refs.push({ id: r.id, name: r.name, type: r.type, size: r.size });
763
+ return refs;
764
+ }
765
+ var plural2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
766
+ function photoFormatList(formats) {
767
+ const names = formats.map((f) => FORMAT_NAMES[f]);
768
+ return names.length > 1 ? `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}` : names[0] ?? "";
769
+ }
770
+ var photoTypeMessage = (field) => `Use a ${photoFormatList(photoLimits(field).formats)} photo.`;
771
+ var photoSizeMessage = (field) => `Each photo must be ${photoLimits(field).maxSizeMb} MB or smaller.`;
772
+ var photoCountMessage = (field) => `Add at most ${plural2(photoLimits(field).maxFiles, "photo")}.`;
773
+ var photoFieldType = {
774
+ type: PHOTO_TYPE,
775
+ label: "Photo",
776
+ category: "media",
777
+ value: { json: true },
778
+ operators: ["answered", "empty"],
779
+ a11y: { semantics: "group" },
780
+ isEmpty: (value) => value === void 0 || value === null || Array.isArray(value) && value.length === 0,
781
+ validate: (value, field) => {
782
+ if (!Array.isArray(value) || !value.every(isPhotoRef)) return "Add a photo.";
783
+ const limits = photoLimits(field);
784
+ if (value.length > limits.maxFiles) return photoCountMessage(field);
785
+ const refs = value;
786
+ if (new Set(refs.map((r) => r.id)).size !== refs.length) return "Each photo can be added once.";
787
+ if (refs.some((r) => !limits.mimeTypes.includes(r.type.toLowerCase()))) return photoTypeMessage(field);
788
+ if (refs.some((r) => r.size > limits.maxBytes)) return photoSizeMessage(field);
789
+ return null;
790
+ },
791
+ normalize: (value) => Array.isArray(value) && value.every(isPhotoRef) ? photoRefs(value) : value,
792
+ summarize: (value) => photoRefs(value).map((r) => r.name || "Photo").join(", "),
793
+ samples: () => [[{ id: "sample-photo", name: "photo.jpg", type: "image/jpeg", size: 1024 }]]
794
+ };
795
+
722
796
  // src/fields.ts
723
797
  var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
724
798
  var globalScope = globalThis;
@@ -755,12 +829,127 @@ function summarizeValue(value, field) {
755
829
  if (Array.isArray(value)) return value.join(", ");
756
830
  return String(value);
757
831
  }
832
+ var OPTION_LIMITS = {
833
+ /** Characters in a description. */
834
+ description: 500,
835
+ /** Characters in an image URL. */
836
+ image: 2048,
837
+ /** Attributes on one option. */
838
+ attributes: 20,
839
+ /** Characters in an attribute's name. */
840
+ attributeName: 64,
841
+ /** Characters in one attribute value. */
842
+ attributeValue: 200,
843
+ /** Values in one attribute's list. */
844
+ attributeItems: 50
845
+ };
846
+ function isHttpsUrl(url) {
847
+ 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).
848
+ ![...url].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127);
849
+ }
850
+ function attributeName(name) {
851
+ return name.trim() !== "" && name.length <= OPTION_LIMITS.attributeName && !(name in Object.prototype);
852
+ }
853
+ function readAttributes(raw) {
854
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
855
+ const out = {};
856
+ let count = 0;
857
+ for (const name of Object.keys(raw)) {
858
+ if (count >= OPTION_LIMITS.attributes) break;
859
+ if (!attributeName(name)) continue;
860
+ const v = raw[name];
861
+ const fits = (x) => typeof x === "string" && x.length <= OPTION_LIMITS.attributeValue;
862
+ if (fits(v)) out[name] = v;
863
+ else if (Array.isArray(v) && v.length <= OPTION_LIMITS.attributeItems && v.every(fits)) out[name] = [...v];
864
+ else continue;
865
+ count++;
866
+ }
867
+ return count ? out : void 0;
868
+ }
869
+ function readOption(raw) {
870
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
871
+ const o = raw;
872
+ if (typeof o.value !== "string") return null;
873
+ const out = { value: o.value };
874
+ if (typeof o.label === "string") out.label = o.label;
875
+ if (typeof o.description === "string" && o.description.trim() && o.description.length <= OPTION_LIMITS.description) {
876
+ out.description = o.description;
877
+ }
878
+ if (isHttpsUrl(o.image)) out.image = o.image;
879
+ const attributes = readAttributes(o.attributes);
880
+ if (attributes) out.attributes = attributes;
881
+ return out;
882
+ }
883
+ function optionJson(option) {
884
+ const out = { value: option.value };
885
+ if (option.label !== void 0) out.label = option.label;
886
+ if (option.description !== void 0) out.description = option.description;
887
+ if (option.image !== void 0) out.image = option.image;
888
+ if (option.attributes !== void 0) out.attributes = { ...option.attributes };
889
+ return out;
890
+ }
891
+ var OTHER_MAX_LENGTH = 200;
892
+ function otherOption(field) {
893
+ const other = field.props?.other;
894
+ if (other === true) return { label: "Other" };
895
+ if (other && typeof other === "object" && !Array.isArray(other)) {
896
+ const label = typeof other.label === "string" && other.label.trim() ? other.label.trim() : "Other";
897
+ return { label };
898
+ }
899
+ return null;
900
+ }
901
+ function isOtherValue(field, value) {
902
+ return otherOption(field) !== null && typeof value === "string" && !choiceOptions(field).some((o) => o.value === value);
903
+ }
904
+ var typedOther = (field, value) => isOtherValue(field, value) && value.trim() !== "" && value.length <= OTHER_MAX_LENGTH;
758
905
  function choiceOptions(field) {
759
906
  const raw = field.props?.options;
760
907
  if (!Array.isArray(raw)) return [];
761
- return raw.flatMap(
762
- (o) => o && typeof o === "object" && !Array.isArray(o) && typeof o.value === "string" ? [{ value: o.value, label: typeof o.label === "string" ? o.label : void 0 }] : []
763
- );
908
+ return raw.flatMap((o) => {
909
+ const option = readOption(o);
910
+ return option ? [option] : [];
911
+ });
912
+ }
913
+ function optionsSource(field) {
914
+ const raw = field.props?.optionsFrom;
915
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
916
+ if (raw.source !== "host" || typeof raw.key !== "string" || !raw.key) return void 0;
917
+ const out = { source: "host", key: raw.key };
918
+ const filter = raw.filter;
919
+ if (filter && typeof filter === "object" && !Array.isArray(filter) && typeof filter.attribute === "string" && filter.attribute && typeof filter.field === "string" && filter.field) {
920
+ out.filter = { attribute: filter.attribute, field: filter.field };
921
+ }
922
+ return out;
923
+ }
924
+ function hostList(hostOptions, key) {
925
+ if (!hostOptions || !Object.prototype.hasOwnProperty.call(hostOptions, key)) return [];
926
+ const list = hostOptions[key];
927
+ if (!Array.isArray(list)) return [];
928
+ const seen = /* @__PURE__ */ new Set();
929
+ const out = [];
930
+ for (const o of list) {
931
+ const option = readOption(o);
932
+ if (!option || !option.value || seen.has(option.value)) continue;
933
+ seen.add(option.value);
934
+ out.push(option);
935
+ }
936
+ return out;
937
+ }
938
+ function withHostOptions(definition, hostOptions) {
939
+ let changed = false;
940
+ const blocks = definition.blocks.map((block) => {
941
+ if (block.kind !== "field") return block;
942
+ const from = optionsSource(block);
943
+ if (!from) return block;
944
+ const own2 = choiceOptions(block);
945
+ const taken = new Set(own2.map((o) => o.value));
946
+ const added = hostList(hostOptions, from.key).filter((o) => !taken.has(o.value));
947
+ if (!added.length) return block;
948
+ changed = true;
949
+ const options = [...own2, ...added].map(optionJson);
950
+ return { ...block, props: { ...block.props, options } };
951
+ });
952
+ return changed ? { ...definition, blocks } : definition;
764
953
  }
765
954
  function optionLabel(field, value) {
766
955
  const option = choiceOptions(field).find((o) => o.value === value);
@@ -880,7 +1069,7 @@ var builtins = [
880
1069
  value: { json: true },
881
1070
  operators: ["eq", "neq", "in", "answered", "empty"],
882
1071
  a11y: { semantics: "group" },
883
- validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
1072
+ validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
884
1073
  summarize: (value, field) => optionLabel(field, String(value))
885
1074
  },
886
1075
  {
@@ -892,7 +1081,8 @@ var builtins = [
892
1081
  a11y: { semantics: "group" },
893
1082
  validate: (value, field) => {
894
1083
  const allowed = new Set(choiceOptions(field).map((o) => o.value));
895
- 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.";
1084
+ const others = Array.isArray(value) ? value.filter((v) => typeof v === "string" && !allowed.has(v)) : [];
1085
+ return Array.isArray(value) && new Set(value).size === value.length && value.every((v) => typeof v === "string") && (others.length === 0 || others.length === 1 && typedOther(field, others[0])) ? null : "Choose from the options.";
896
1086
  },
897
1087
  summarize: (value, field) => Array.isArray(value) ? value.map((v) => optionLabel(field, String(v))).join(", ") : String(value)
898
1088
  },
@@ -913,7 +1103,7 @@ var builtins = [
913
1103
  value: { json: true },
914
1104
  operators: ["eq", "neq", "in", "answered", "empty"],
915
1105
  a11y: { semantics: "control" },
916
- validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
1106
+ validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
917
1107
  summarize: (value, field) => optionLabel(field, String(value))
918
1108
  },
919
1109
  {
@@ -1020,7 +1210,7 @@ function dateLimits(field) {
1020
1210
  if (typeof max === "string" && isIsoDate(max)) out.max = max;
1021
1211
  return out;
1022
1212
  }
1023
- for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType]) registerFieldType(manifest);
1213
+ for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType, photoFieldType]) registerFieldType(manifest);
1024
1214
 
1025
1215
  // src/events.ts
1026
1216
  function isPlainObject(value) {
@@ -1236,6 +1426,150 @@ function fieldMap(def) {
1236
1426
  return map;
1237
1427
  }
1238
1428
 
1429
+ // src/options.ts
1430
+ var CHOICE_FIELD_TYPES = /* @__PURE__ */ new Set(["single-choice", "multi-choice", "dropdown"]);
1431
+ var isChoice = (block) => !!block && block.kind === "field" && CHOICE_FIELD_TYPES.has(block.type);
1432
+ function put(record, key, value) {
1433
+ Object.defineProperty(record, key, { value, enumerable: true, writable: true, configurable: true });
1434
+ }
1435
+ function optionFilter(field) {
1436
+ return optionsSource(field)?.filter;
1437
+ }
1438
+ function answerValues(answer) {
1439
+ const one = (v) => typeof v === "string" ? [v] : typeof v === "number" || typeof v === "boolean" ? [String(v)] : [];
1440
+ if (answer === void 0 || answer === null) return [];
1441
+ return Array.isArray(answer) ? answer.flatMap(one) : one(answer);
1442
+ }
1443
+ function optionMatches(option, attribute, answer) {
1444
+ const have = option.attributes && Object.prototype.hasOwnProperty.call(option.attributes, attribute) ? option.attributes[attribute] : void 0;
1445
+ if (have === void 0) return false;
1446
+ const wanted = answerValues(answer);
1447
+ return (Array.isArray(have) ? have : [have]).some((v) => wanted.includes(v));
1448
+ }
1449
+ function filterSource(definition, key) {
1450
+ return definition.blocks.find((b) => b.kind === "field" && b.key === key);
1451
+ }
1452
+ function visibleOptions(definition, field, values) {
1453
+ const options = choiceOptions(field);
1454
+ const filter = optionFilter(field);
1455
+ if (!filter) return options;
1456
+ const source = filterSource(definition, filter.field);
1457
+ if (!source || source.id === field.id) return options;
1458
+ const answer = own(values, source.id);
1459
+ if (isEmptyValue(answer, source)) return options;
1460
+ return options.filter((o) => optionMatches(o, filter.attribute, answer));
1461
+ }
1462
+ function answersOnPath(state) {
1463
+ const out = /* @__PURE__ */ Object.create(null);
1464
+ for (const id of state.path) out[id] = own(state.values, id);
1465
+ return out;
1466
+ }
1467
+ var OUTSIDE_FILTER = "Choose one of the options.";
1468
+ function filteredChoiceError(definition, field, value, values) {
1469
+ if (!optionFilter(field) || isEmptyValue(value, field)) return null;
1470
+ const all = new Set(choiceOptions(field).map((o) => o.value));
1471
+ const shown2 = new Set(visibleOptions(definition, field, values).map((o) => o.value));
1472
+ const picked = answerValues(value);
1473
+ return picked.some((v) => all.has(v) && !shown2.has(v)) ? OUTSIDE_FILTER : null;
1474
+ }
1475
+ function hasOptionFilters(definition) {
1476
+ return definition.blocks.some((b) => isChoice(b) && optionFilter(b) !== void 0);
1477
+ }
1478
+ function clearFilteredAnswers(definition, values, changedId) {
1479
+ const changed = definition.blocks.find((b) => b.kind === "field" && b.id === changedId);
1480
+ if (!changed) return values;
1481
+ const keys = /* @__PURE__ */ new Set([changed.key]);
1482
+ let out = values;
1483
+ for (const block of definition.blocks) {
1484
+ if (!isChoice(block)) continue;
1485
+ const filter = optionFilter(block);
1486
+ if (!filter || !keys.has(filter.field)) continue;
1487
+ const value = own(out, block.id);
1488
+ if (value === void 0 || isEmptyValue(value, block)) continue;
1489
+ const all = new Set(choiceOptions(block).map((o) => o.value));
1490
+ const shown2 = new Set(visibleOptions(definition, block, out).map((o) => o.value));
1491
+ const keep = (v) => typeof v !== "string" || !all.has(v) || shown2.has(v);
1492
+ let next = value;
1493
+ if (Array.isArray(value)) {
1494
+ const kept = value.filter(keep);
1495
+ if (kept.length !== value.length) next = kept.length ? kept : void 0;
1496
+ } else if (!keep(value)) next = void 0;
1497
+ if (next === value) continue;
1498
+ if (out === values) out = { ...values };
1499
+ put(out, block.id, next);
1500
+ keys.add(block.key);
1501
+ }
1502
+ return out;
1503
+ }
1504
+ var MAX_PREFILL_PARAM_LENGTH = 64;
1505
+ function isPrefillParam(param) {
1506
+ return typeof param === "string" && param.length <= MAX_PREFILL_PARAM_LENGTH && /^[A-Za-z0-9_.-]+$/.test(param);
1507
+ }
1508
+ function prefillSetting(field) {
1509
+ if (!CHOICE_FIELD_TYPES.has(field.type)) return void 0;
1510
+ const raw = field.props?.prefill;
1511
+ if (!raw || typeof raw !== "object" || Array.isArray(raw) || !isPrefillParam(raw.param)) return void 0;
1512
+ return { param: raw.param, hideWhenPrefilled: raw.hideWhenPrefilled === true };
1513
+ }
1514
+ function paramValues(params, name) {
1515
+ let raw;
1516
+ if (typeof params.getAll === "function") {
1517
+ raw = params.getAll(name);
1518
+ } else if (Object.prototype.hasOwnProperty.call(params, name)) {
1519
+ raw = params[name];
1520
+ }
1521
+ const list = Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw];
1522
+ return list.filter((v) => typeof v === "string" && v !== "" && v.length <= MAX_HIDDEN_STRING_LENGTH);
1523
+ }
1524
+ function prefillFromLink(definition, params, values = {}) {
1525
+ const out = {};
1526
+ if (!params || typeof params !== "object") return out;
1527
+ const seen = {};
1528
+ for (const id of Object.keys(values)) put(seen, id, values[id]);
1529
+ for (const block of definition.blocks) {
1530
+ if (!isChoice(block)) continue;
1531
+ const setting = prefillSetting(block);
1532
+ if (!setting || own(seen, block.id) !== void 0) continue;
1533
+ const raw = paramValues(params, setting.param);
1534
+ if (!raw.length) continue;
1535
+ const shown2 = new Set(visibleOptions(definition, block, seen).map((o) => o.value));
1536
+ let value;
1537
+ if (block.type === "multi-choice") {
1538
+ const picked = [];
1539
+ for (const r of raw) {
1540
+ for (const v of shown2.has(r) ? [r] : r.split(",").map((s) => s.trim())) {
1541
+ if (shown2.has(v) && !picked.includes(v)) picked.push(v);
1542
+ }
1543
+ }
1544
+ if (picked.length) value = picked;
1545
+ } else value = raw.find((v) => shown2.has(v));
1546
+ if (value === void 0) continue;
1547
+ put(out, block.id, value);
1548
+ put(seen, block.id, value);
1549
+ }
1550
+ return out;
1551
+ }
1552
+ function hiddenByPrefill(definition, state) {
1553
+ const hideable = /* @__PURE__ */ new Set();
1554
+ for (const b of definition.blocks) {
1555
+ if (isChoice(b) && prefillSetting(b)?.hideWhenPrefilled) hideable.add(b.id);
1556
+ }
1557
+ if (!hideable.size) return [];
1558
+ const prefilled = /* @__PURE__ */ new Set();
1559
+ for (const e of state.events) {
1560
+ if (e.type !== "answer" || !hideable.has(e.field)) continue;
1561
+ if (e.source === "prefill") prefilled.add(e.field);
1562
+ else prefilled.delete(e.field);
1563
+ }
1564
+ if (!prefilled.size) return [];
1565
+ const fields = new Map(definition.blocks.map((b) => [b.id, b]));
1566
+ return state.path.filter((id) => {
1567
+ if (!prefilled.has(id) || own(state.errors, id) !== void 0) return false;
1568
+ const field = fields.get(id);
1569
+ return !isEmptyValue(own(state.values, id), field);
1570
+ });
1571
+ }
1572
+
1239
1573
  // src/recall.ts
1240
1574
  var TOKEN = /\{\{\s*(field|hidden):([A-Za-z0-9_-]+)\s*(?:\|([^}]*))?\}\}/g;
1241
1575
  function recall(text, scope) {
@@ -1289,7 +1623,8 @@ function readsAny(node, ids) {
1289
1623
  function createForm(definition, options = {}) {
1290
1624
  return build(definition, options).engine;
1291
1625
  }
1292
- function build(definition, options, replaying = false, budget) {
1626
+ function build(given, options, replaying = false, budget) {
1627
+ const definition = withHostOptions(given, options.hostOptions);
1293
1628
  const fields = fieldMap(definition);
1294
1629
  const byKey = new Map([...fields.values()].map((f) => [f.key, f]));
1295
1630
  const blocks = new Map(definition.blocks.map((b) => [b.id, b]));
@@ -1335,6 +1670,7 @@ function build(definition, options, replaying = false, budget) {
1335
1670
  path: first.path,
1336
1671
  current: first.path[0] ?? null,
1337
1672
  history: [],
1673
+ furthest: first.path[0] ?? null,
1338
1674
  errors: {},
1339
1675
  status: "in_progress",
1340
1676
  ending: null,
@@ -1355,16 +1691,34 @@ function build(definition, options, replaying = false, budget) {
1355
1691
  return at === void 0 ? event : { ...event, at };
1356
1692
  };
1357
1693
  const isVisibleIn = (s, id) => s.path.includes(id);
1694
+ const filters = hasOptionFilters(definition);
1695
+ const clearFiltered = (values, id) => filters ? clearFilteredAnswers(definition, values, id) : values;
1358
1696
  const errorsFor = (s, ids) => {
1359
1697
  const errors = {};
1698
+ let shown2;
1360
1699
  for (const id of ids) {
1361
1700
  const field = fields.get(id);
1362
1701
  if (!field || !isVisibleIn(s, id)) continue;
1363
- const message = validateField(field, own(s.values, id), { custom: options.validators?.[id] });
1702
+ const value = own(s.values, id);
1703
+ let message = validateField(field, value, { custom: options.validators?.[id] });
1704
+ if (!message && filters) message = filteredChoiceError(definition, field, value, shown2 ??= answersOnPath(s));
1364
1705
  if (message) errors[id] = message;
1365
1706
  }
1366
1707
  return errors;
1367
1708
  };
1709
+ const skipped = (s) => new Set(hiddenByPrefill(definition, s));
1710
+ const shownFrom = (s, from, skip) => {
1711
+ for (let i = from; i < s.path.length; i++) if (!skip.has(s.path[i])) return s.path[i];
1712
+ return void 0;
1713
+ };
1714
+ const showCurrent = () => {
1715
+ if (!state.current) return;
1716
+ const skip = skipped(state);
1717
+ if (!skip.has(state.current)) return;
1718
+ const at = state.path.indexOf(state.current);
1719
+ const shown2 = at < 0 ? void 0 : shownFrom(state, at, skip);
1720
+ if (shown2) state = { ...state, current: shown2, furthest: state.furthest === state.current ? shown2 : state.furthest };
1721
+ };
1368
1722
  let unsettled = false;
1369
1723
  let formulasStale = false;
1370
1724
  const settleNow = (needValues = true) => {
@@ -1393,13 +1747,13 @@ function build(definition, options, replaying = false, budget) {
1393
1747
  const errors = { ...state.errors };
1394
1748
  delete errors[id];
1395
1749
  if (batch) {
1396
- state.values = { ...state.values, [id]: value };
1750
+ state.values = clearFiltered({ ...state.values, [id]: value }, id);
1397
1751
  state.errors = errors;
1398
1752
  log(event);
1399
1753
  unsettled = true;
1400
1754
  return;
1401
1755
  }
1402
- const { values, path } = settle({ ...state.values, [id]: value }, state.hidden);
1756
+ const { values, path } = settle(clearFiltered({ ...state.values, [id]: value }, id), state.hidden);
1403
1757
  emit({ ...state, values, path, errors, events: log(event) });
1404
1758
  };
1405
1759
  const engine = {
@@ -1430,19 +1784,21 @@ function build(definition, options, replaying = false, budget) {
1430
1784
  return false;
1431
1785
  }
1432
1786
  const at = state.path.indexOf(state.current);
1433
- const target = state.path[at + 1];
1787
+ const target = shownFrom(state, at + 1, skipped(state));
1434
1788
  if (!target) {
1435
1789
  emit({ ...state, events });
1436
1790
  return true;
1437
1791
  }
1438
- emit({ ...state, current: target, history: [...state.history, state.current], events });
1792
+ const furthest = state.furthest && state.path.indexOf(state.furthest) >= state.path.indexOf(target) ? state.furthest : target;
1793
+ emit({ ...state, current: target, history: [...state.history, state.current], furthest, events });
1439
1794
  return true;
1440
1795
  },
1441
1796
  back() {
1442
1797
  if (state.status === "completed") return;
1443
1798
  const history = [...state.history];
1799
+ const skip = skipped(state);
1444
1800
  let previous = history.pop();
1445
- while (previous && !state.path.includes(previous)) previous = history.pop();
1801
+ while (previous && (!state.path.includes(previous) || skip.has(previous))) previous = history.pop();
1446
1802
  if (!previous) return;
1447
1803
  emit({ ...state, current: previous, history, events: log(stamp({ type: "back" })) });
1448
1804
  },
@@ -1470,15 +1826,27 @@ function build(definition, options, replaying = false, budget) {
1470
1826
  return out;
1471
1827
  }
1472
1828
  };
1473
- if (options.initialValues) {
1829
+ const fromLink = options.linkParams ? prefillFromLink(definition, options.linkParams, options.initialValues) : {};
1830
+ const initialValues = Object.keys(fromLink).length ? { ...fromLink, ...options.initialValues } : options.initialValues;
1831
+ if (initialValues) {
1474
1832
  const wasBatch = batch;
1475
1833
  batch = true;
1476
- for (const [id, value] of Object.entries(options.initialValues)) {
1834
+ for (const [id, value] of Object.entries(initialValues)) {
1477
1835
  if (!fields.has(id) || value === void 0) continue;
1478
1836
  answer(id, value, stamp({ type: "answer", field: id, value, source: "prefill" }));
1479
1837
  }
1480
1838
  settleNow();
1481
1839
  batch = wasBatch;
1840
+ showCurrent();
1841
+ }
1842
+ const resumeAt = options.resumeAt;
1843
+ if (resumeAt && !replaying && state.path.includes(resumeAt)) {
1844
+ for (let i = 0; i < state.path.length && state.current !== resumeAt; i++) {
1845
+ if (!engine.next()) {
1846
+ state = { ...state, errors: {} };
1847
+ break;
1848
+ }
1849
+ }
1482
1850
  }
1483
1851
  const apply = (event) => {
1484
1852
  if (event.type === "answer") {
@@ -1488,18 +1856,22 @@ function build(definition, options, replaying = false, budget) {
1488
1856
  }
1489
1857
  } else if (event.type === "next") {
1490
1858
  settleNow(false);
1859
+ showCurrent();
1491
1860
  if (formulasStale && isCalculated(fields.get(state.current ?? ""))) settleNow(true);
1492
1861
  engine.next();
1493
1862
  } else if (event.type === "back") {
1494
1863
  settleNow(false);
1864
+ showCurrent();
1495
1865
  engine.back();
1496
1866
  } else if (event.type === "submit") {
1497
1867
  settleNow();
1868
+ showCurrent();
1498
1869
  engine.submit();
1499
1870
  }
1500
1871
  };
1501
1872
  const finish = () => {
1502
1873
  settleNow();
1874
+ showCurrent();
1503
1875
  batch = false;
1504
1876
  state = { ...state, events: [...state.events] };
1505
1877
  };
@@ -1510,7 +1882,12 @@ function replay(definition, events, options = {}) {
1510
1882
  }
1511
1883
  function replayWithin(definition, events, options, maxWork) {
1512
1884
  const budget = maxWork === void 0 ? void 0 : { used: 0, max: maxWork };
1513
- const { engine, apply, finish } = build(definition, { ...options, initialValues: void 0, clock: () => void 0 }, true, budget);
1885
+ const { engine, apply, finish } = build(
1886
+ definition,
1887
+ { ...options, initialValues: void 0, linkParams: void 0, clock: () => void 0 },
1888
+ true,
1889
+ budget
1890
+ );
1514
1891
  try {
1515
1892
  for (const event of events) apply(event);
1516
1893
  finish();
@@ -1761,6 +2138,31 @@ function parseForm(input) {
1761
2138
  const optionalBoolean = (v, path) => {
1762
2139
  if (v !== void 0 && typeof v !== "boolean") issue(path, "Must be true or false.");
1763
2140
  };
2141
+ const optionDetails = (o, at) => {
2142
+ if (o.description !== void 0 && (typeof o.description !== "string" || o.description.length > OPTION_LIMITS.description)) {
2143
+ issue(`${at}.description`, `Must be text of at most ${OPTION_LIMITS.description} characters.`);
2144
+ }
2145
+ if (o.image !== void 0 && !isHttpsUrl(o.image)) {
2146
+ issue(`${at}.image`, `Must be an https:// link of at most ${OPTION_LIMITS.image} characters.`);
2147
+ }
2148
+ if (o.attributes === void 0) return;
2149
+ const attributes = o.attributes;
2150
+ if (!isObject2(attributes) || Object.keys(attributes).length > OPTION_LIMITS.attributes) {
2151
+ issue(`${at}.attributes`, `Must be an object of at most ${OPTION_LIMITS.attributes} attributes.`);
2152
+ return;
2153
+ }
2154
+ const fits = (x) => typeof x === "string" && x.length <= OPTION_LIMITS.attributeValue;
2155
+ for (const [name2, v] of Object.entries(attributes)) {
2156
+ if (!name2.trim() || name2.length > OPTION_LIMITS.attributeName || RESERVED.has(name2)) {
2157
+ issue(`${at}.attributes`, `${shown(name2)} isn't a usable attribute name (at most ${OPTION_LIMITS.attributeName} characters).`);
2158
+ } else if (!fits(v) && !(Array.isArray(v) && v.length <= OPTION_LIMITS.attributeItems && v.every(fits))) {
2159
+ issue(
2160
+ `${at}.attributes.${name2}`,
2161
+ `Must be text, or a list of at most ${OPTION_LIMITS.attributeItems} texts, each at most ${OPTION_LIMITS.attributeValue} characters.`
2162
+ );
2163
+ }
2164
+ }
2165
+ };
1764
2166
  const name = (v, path, what) => {
1765
2167
  if (typeof v !== "string" || !v) {
1766
2168
  issue(path, `${what} must be a non-empty string.`);
@@ -1849,6 +2251,22 @@ function parseForm(input) {
1849
2251
  }
1850
2252
  }
1851
2253
  if (type === "consent") optionalText(props.text, `${path}.text`);
2254
+ if (type === PHOTO_TYPE) {
2255
+ if (props.maxFiles !== void 0 && !(isInt(props.maxFiles) && props.maxFiles >= 1 && props.maxFiles <= MAX_PHOTO_FILES)) {
2256
+ issue(`${path}.maxFiles`, `Must be a whole number from 1 to ${MAX_PHOTO_FILES}.`);
2257
+ }
2258
+ if (props.maxSizeMb !== void 0 && !(isInt(props.maxSizeMb) && props.maxSizeMb >= 1 && props.maxSizeMb <= MAX_PHOTO_SIZE_MB)) {
2259
+ issue(`${path}.maxSizeMb`, `Must be a whole number from 1 to ${MAX_PHOTO_SIZE_MB}.`);
2260
+ }
2261
+ if (props.formats !== void 0) {
2262
+ const formats = props.formats;
2263
+ if (!Array.isArray(formats) || formats.length === 0 || !formats.every(isPhotoFormat)) {
2264
+ issue(`${path}.formats`, "Must be a list of one or more of jpeg, png, heic and webp.");
2265
+ } else if (new Set(formats).size !== formats.length) {
2266
+ issue(`${path}.formats`, "Name each format once.");
2267
+ }
2268
+ }
2269
+ }
1852
2270
  };
1853
2271
  if (!isObject2(input)) {
1854
2272
  issue("", "A form definition must be an object.");
@@ -1869,6 +2287,7 @@ function parseForm(input) {
1869
2287
  else {
1870
2288
  optionalBoolean(input.settings.anonymous, "settings.anonymous");
1871
2289
  optionalBoolean(input.settings.clearHiddenValues, "settings.clearHiddenValues");
2290
+ optionalBoolean(input.settings.saveProgress, "settings.saveProgress");
1872
2291
  }
1873
2292
  }
1874
2293
  if (input.meta !== void 0 && (!isObject2(input.meta) || !isJson(input.meta))) {
@@ -1909,6 +2328,38 @@ function parseForm(input) {
1909
2328
  issue(`${path}.type`, `Unknown field type "${String(block.type)}".`);
1910
2329
  }
1911
2330
  if (block.props !== void 0) {
2331
+ const from = isObject2(block.props) ? block.props.optionsFrom : void 0;
2332
+ if (from !== void 0) {
2333
+ if (!isObject2(from) || from.source !== "host" || typeof from.key !== "string" || !from.key) {
2334
+ issue(`${path}.props.optionsFrom`, 'Must be { source: "host", key } with a non-empty key.');
2335
+ } else if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2336
+ warnings.push({ path: `${path}.props.optionsFrom`, message: "Only single choice, multiple choice and dropdown questions take options from the app." });
2337
+ }
2338
+ const filter = isObject2(from) ? from.filter : void 0;
2339
+ if (filter !== void 0) {
2340
+ const at = `${path}.props.optionsFrom.filter`;
2341
+ if (!isObject2(filter) || typeof filter.attribute !== "string" || !filter.attribute || filter.attribute.length > OPTION_LIMITS.attributeName || typeof filter.field !== "string" || !filter.field) {
2342
+ issue(at, `Must be { attribute, field }: an attribute name (at most ${OPTION_LIMITS.attributeName} characters) and an earlier question's key.`);
2343
+ } else if (!keys.has(filter.field)) {
2344
+ issue(`${at}.field`, `"${filter.field}" must be the key of an earlier question.`);
2345
+ }
2346
+ }
2347
+ }
2348
+ const prefill = isObject2(block.props) ? block.props.prefill : void 0;
2349
+ if (prefill !== void 0) {
2350
+ if (!isObject2(prefill) || !isPrefillParam(prefill.param)) {
2351
+ issue(
2352
+ `${path}.props.prefill`,
2353
+ `Must be { param, hideWhenPrefilled? }: a link parameter of letters, digits, "_", "-" or "." (at most ${MAX_PREFILL_PARAM_LENGTH} characters).`
2354
+ );
2355
+ } else {
2356
+ optionalBoolean(prefill.hideWhenPrefilled, `${path}.props.prefill.hideWhenPrefilled`);
2357
+ if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2358
+ warnings.push({ path: `${path}.props.prefill`, message: "Only single choice, multiple choice and dropdown questions are filled in from a link." });
2359
+ }
2360
+ }
2361
+ }
2362
+ if (isObject2(block.props)) optionalBoolean(block.props.searchable, `${path}.props.searchable`);
1912
2363
  if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
1913
2364
  else if (block.props.options !== void 0) {
1914
2365
  const options = block.props.options;
@@ -1917,12 +2368,25 @@ function parseForm(input) {
1917
2368
  options.forEach((o, j) => {
1918
2369
  const at = `${path}.props.options[${j}]`;
1919
2370
  if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
1920
- else optionalText(o.label, `${at}.label`);
2371
+ else {
2372
+ optionalText(o.label, `${at}.label`);
2373
+ optionDetails(o, at);
2374
+ }
1921
2375
  });
1922
- choices.set(
1923
- block.id,
1924
- new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
1925
- );
2376
+ if (from === void 0) {
2377
+ choices.set(
2378
+ block.id,
2379
+ new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
2380
+ );
2381
+ }
2382
+ }
2383
+ }
2384
+ const other = isObject2(block.props) ? block.props.other : void 0;
2385
+ if (other !== void 0) {
2386
+ if (other !== true && other !== false && !(isObject2(other) && (other.label === void 0 || typeof other.label === "string"))) {
2387
+ issue(`${path}.props.other`, "Must be true, false or { label }.");
2388
+ } else if (other !== false && typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2389
+ warnings.push({ path: `${path}.props.other`, message: 'Only single choice, multiple choice and dropdown questions take an "Other" answer.' });
1926
2390
  }
1927
2391
  }
1928
2392
  const { max, maxLength: maxLength2 } = isObject2(block.props) ? block.props : {};
@@ -1997,6 +2461,8 @@ function parseForm(input) {
1997
2461
  } else if (ref.column !== void 0) issue(path, `"${ref.key}" is not a table, so it has no column "${ref.column}".`);
1998
2462
  else if (targetType === "media-notes" || targetType === "text-highlights") {
1999
2463
  warnings.push({ path, message: `"${ref.key}" holds notes, not numbers; the formula reads it as blank.` });
2464
+ } else if (targetType === PHOTO_TYPE) {
2465
+ warnings.push({ path, message: `"${ref.key}" holds photos, not numbers; the formula reads it as blank.` });
2000
2466
  }
2001
2467
  }
2002
2468
  });
@@ -2261,9 +2727,10 @@ function wellFormed(input) {
2261
2727
  }
2262
2728
  return { answers, events: known, hidden };
2263
2729
  }
2264
- function validateSubmission(definition, submission, options = {}) {
2265
- const invalid = checkDefinition(definition);
2730
+ function validateSubmission(published, submission, options = {}) {
2731
+ const invalid = checkDefinition(published);
2266
2732
  if (invalid) return { ok: false, errors: { "": invalid } };
2733
+ const definition = withHostOptions(published, options.hostOptions);
2267
2734
  if (!isPlainObject(submission)) return MALFORMED;
2268
2735
  const maxEvents = options.maxEvents ?? MAX_EVENTS;
2269
2736
  let sent = submission;
@@ -2334,11 +2801,12 @@ function validateSubmission(definition, submission, options = {}) {
2334
2801
  }
2335
2802
  } else {
2336
2803
  const errors = {};
2337
- for (const [id, value] of Object.entries(engine.answers())) {
2804
+ const kept = engine.answers();
2805
+ for (const [id, value] of Object.entries(kept)) {
2338
2806
  const message = validateField(fields.get(id), value, {
2339
2807
  requireAnswers: false,
2340
2808
  custom: options.validators?.[id]
2341
- });
2809
+ }) ?? filteredChoiceError(definition, fields.get(id), value, kept);
2342
2810
  if (message) errors[id] = message;
2343
2811
  }
2344
2812
  if (Object.keys(errors).length) return { ok: false, errors };