@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.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,150 @@ 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
+ var MAX_PREFILL_PARAM_LENGTH = 64;
1507
+ function isPrefillParam(param) {
1508
+ return typeof param === "string" && param.length <= MAX_PREFILL_PARAM_LENGTH && /^[A-Za-z0-9_.-]+$/.test(param);
1509
+ }
1510
+ function prefillSetting(field) {
1511
+ if (!CHOICE_FIELD_TYPES.has(field.type)) return void 0;
1512
+ const raw = field.props?.prefill;
1513
+ if (!raw || typeof raw !== "object" || Array.isArray(raw) || !isPrefillParam(raw.param)) return void 0;
1514
+ return { param: raw.param, hideWhenPrefilled: raw.hideWhenPrefilled === true };
1515
+ }
1516
+ function paramValues(params, name) {
1517
+ let raw;
1518
+ if (typeof params.getAll === "function") {
1519
+ raw = params.getAll(name);
1520
+ } else if (Object.prototype.hasOwnProperty.call(params, name)) {
1521
+ raw = params[name];
1522
+ }
1523
+ const list = Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw];
1524
+ return list.filter((v) => typeof v === "string" && v !== "" && v.length <= MAX_HIDDEN_STRING_LENGTH);
1525
+ }
1526
+ function prefillFromLink(definition, params, values = {}) {
1527
+ const out = {};
1528
+ if (!params || typeof params !== "object") return out;
1529
+ const seen = {};
1530
+ for (const id of Object.keys(values)) put(seen, id, values[id]);
1531
+ for (const block of definition.blocks) {
1532
+ if (!isChoice(block)) continue;
1533
+ const setting = prefillSetting(block);
1534
+ if (!setting || own(seen, block.id) !== void 0) continue;
1535
+ const raw = paramValues(params, setting.param);
1536
+ if (!raw.length) continue;
1537
+ const shown2 = new Set(visibleOptions(definition, block, seen).map((o) => o.value));
1538
+ let value;
1539
+ if (block.type === "multi-choice") {
1540
+ const picked = [];
1541
+ for (const r of raw) {
1542
+ for (const v of shown2.has(r) ? [r] : r.split(",").map((s) => s.trim())) {
1543
+ if (shown2.has(v) && !picked.includes(v)) picked.push(v);
1544
+ }
1545
+ }
1546
+ if (picked.length) value = picked;
1547
+ } else value = raw.find((v) => shown2.has(v));
1548
+ if (value === void 0) continue;
1549
+ put(out, block.id, value);
1550
+ put(seen, block.id, value);
1551
+ }
1552
+ return out;
1553
+ }
1554
+ function hiddenByPrefill(definition, state) {
1555
+ const hideable = /* @__PURE__ */ new Set();
1556
+ for (const b of definition.blocks) {
1557
+ if (isChoice(b) && prefillSetting(b)?.hideWhenPrefilled) hideable.add(b.id);
1558
+ }
1559
+ if (!hideable.size) return [];
1560
+ const prefilled = /* @__PURE__ */ new Set();
1561
+ for (const e of state.events) {
1562
+ if (e.type !== "answer" || !hideable.has(e.field)) continue;
1563
+ if (e.source === "prefill") prefilled.add(e.field);
1564
+ else prefilled.delete(e.field);
1565
+ }
1566
+ if (!prefilled.size) return [];
1567
+ const fields = new Map(definition.blocks.map((b) => [b.id, b]));
1568
+ return state.path.filter((id) => {
1569
+ if (!prefilled.has(id) || own(state.errors, id) !== void 0) return false;
1570
+ const field = fields.get(id);
1571
+ return !isEmptyValue(own(state.values, id), field);
1572
+ });
1573
+ }
1574
+
1241
1575
  // src/recall.ts
1242
1576
  var TOKEN = /\{\{\s*(field|hidden):([A-Za-z0-9_-]+)\s*(?:\|([^}]*))?\}\}/g;
1243
1577
  function recall(text, scope) {
@@ -1291,7 +1625,8 @@ function readsAny(node, ids) {
1291
1625
  function createForm(definition, options = {}) {
1292
1626
  return build(definition, options).engine;
1293
1627
  }
1294
- function build(definition, options, replaying = false, budget) {
1628
+ function build(given, options, replaying = false, budget) {
1629
+ const definition = withHostOptions(given, options.hostOptions);
1295
1630
  const fields = fieldMap(definition);
1296
1631
  const byKey = new Map([...fields.values()].map((f) => [f.key, f]));
1297
1632
  const blocks = new Map(definition.blocks.map((b) => [b.id, b]));
@@ -1337,6 +1672,7 @@ function build(definition, options, replaying = false, budget) {
1337
1672
  path: first.path,
1338
1673
  current: first.path[0] ?? null,
1339
1674
  history: [],
1675
+ furthest: first.path[0] ?? null,
1340
1676
  errors: {},
1341
1677
  status: "in_progress",
1342
1678
  ending: null,
@@ -1357,16 +1693,34 @@ function build(definition, options, replaying = false, budget) {
1357
1693
  return at === void 0 ? event : { ...event, at };
1358
1694
  };
1359
1695
  const isVisibleIn = (s, id) => s.path.includes(id);
1696
+ const filters = hasOptionFilters(definition);
1697
+ const clearFiltered = (values, id) => filters ? clearFilteredAnswers(definition, values, id) : values;
1360
1698
  const errorsFor = (s, ids) => {
1361
1699
  const errors = {};
1700
+ let shown2;
1362
1701
  for (const id of ids) {
1363
1702
  const field = fields.get(id);
1364
1703
  if (!field || !isVisibleIn(s, id)) continue;
1365
- const message = validateField(field, own(s.values, id), { custom: options.validators?.[id] });
1704
+ const value = own(s.values, id);
1705
+ let message = validateField(field, value, { custom: options.validators?.[id] });
1706
+ if (!message && filters) message = filteredChoiceError(definition, field, value, shown2 ??= answersOnPath(s));
1366
1707
  if (message) errors[id] = message;
1367
1708
  }
1368
1709
  return errors;
1369
1710
  };
1711
+ const skipped = (s) => new Set(hiddenByPrefill(definition, s));
1712
+ const shownFrom = (s, from, skip) => {
1713
+ for (let i = from; i < s.path.length; i++) if (!skip.has(s.path[i])) return s.path[i];
1714
+ return void 0;
1715
+ };
1716
+ const showCurrent = () => {
1717
+ if (!state.current) return;
1718
+ const skip = skipped(state);
1719
+ if (!skip.has(state.current)) return;
1720
+ const at = state.path.indexOf(state.current);
1721
+ const shown2 = at < 0 ? void 0 : shownFrom(state, at, skip);
1722
+ if (shown2) state = { ...state, current: shown2, furthest: state.furthest === state.current ? shown2 : state.furthest };
1723
+ };
1370
1724
  let unsettled = false;
1371
1725
  let formulasStale = false;
1372
1726
  const settleNow = (needValues = true) => {
@@ -1395,13 +1749,13 @@ function build(definition, options, replaying = false, budget) {
1395
1749
  const errors = { ...state.errors };
1396
1750
  delete errors[id];
1397
1751
  if (batch) {
1398
- state.values = { ...state.values, [id]: value };
1752
+ state.values = clearFiltered({ ...state.values, [id]: value }, id);
1399
1753
  state.errors = errors;
1400
1754
  log(event);
1401
1755
  unsettled = true;
1402
1756
  return;
1403
1757
  }
1404
- const { values, path } = settle({ ...state.values, [id]: value }, state.hidden);
1758
+ const { values, path } = settle(clearFiltered({ ...state.values, [id]: value }, id), state.hidden);
1405
1759
  emit({ ...state, values, path, errors, events: log(event) });
1406
1760
  };
1407
1761
  const engine = {
@@ -1432,19 +1786,21 @@ function build(definition, options, replaying = false, budget) {
1432
1786
  return false;
1433
1787
  }
1434
1788
  const at = state.path.indexOf(state.current);
1435
- const target = state.path[at + 1];
1789
+ const target = shownFrom(state, at + 1, skipped(state));
1436
1790
  if (!target) {
1437
1791
  emit({ ...state, events });
1438
1792
  return true;
1439
1793
  }
1440
- emit({ ...state, current: target, history: [...state.history, state.current], events });
1794
+ const furthest = state.furthest && state.path.indexOf(state.furthest) >= state.path.indexOf(target) ? state.furthest : target;
1795
+ emit({ ...state, current: target, history: [...state.history, state.current], furthest, events });
1441
1796
  return true;
1442
1797
  },
1443
1798
  back() {
1444
1799
  if (state.status === "completed") return;
1445
1800
  const history = [...state.history];
1801
+ const skip = skipped(state);
1446
1802
  let previous = history.pop();
1447
- while (previous && !state.path.includes(previous)) previous = history.pop();
1803
+ while (previous && (!state.path.includes(previous) || skip.has(previous))) previous = history.pop();
1448
1804
  if (!previous) return;
1449
1805
  emit({ ...state, current: previous, history, events: log(stamp({ type: "back" })) });
1450
1806
  },
@@ -1472,15 +1828,27 @@ function build(definition, options, replaying = false, budget) {
1472
1828
  return out;
1473
1829
  }
1474
1830
  };
1475
- if (options.initialValues) {
1831
+ const fromLink = options.linkParams ? prefillFromLink(definition, options.linkParams, options.initialValues) : {};
1832
+ const initialValues = Object.keys(fromLink).length ? { ...fromLink, ...options.initialValues } : options.initialValues;
1833
+ if (initialValues) {
1476
1834
  const wasBatch = batch;
1477
1835
  batch = true;
1478
- for (const [id, value] of Object.entries(options.initialValues)) {
1836
+ for (const [id, value] of Object.entries(initialValues)) {
1479
1837
  if (!fields.has(id) || value === void 0) continue;
1480
1838
  answer(id, value, stamp({ type: "answer", field: id, value, source: "prefill" }));
1481
1839
  }
1482
1840
  settleNow();
1483
1841
  batch = wasBatch;
1842
+ showCurrent();
1843
+ }
1844
+ const resumeAt = options.resumeAt;
1845
+ if (resumeAt && !replaying && state.path.includes(resumeAt)) {
1846
+ for (let i = 0; i < state.path.length && state.current !== resumeAt; i++) {
1847
+ if (!engine.next()) {
1848
+ state = { ...state, errors: {} };
1849
+ break;
1850
+ }
1851
+ }
1484
1852
  }
1485
1853
  const apply = (event) => {
1486
1854
  if (event.type === "answer") {
@@ -1490,18 +1858,22 @@ function build(definition, options, replaying = false, budget) {
1490
1858
  }
1491
1859
  } else if (event.type === "next") {
1492
1860
  settleNow(false);
1861
+ showCurrent();
1493
1862
  if (formulasStale && isCalculated(fields.get(state.current ?? ""))) settleNow(true);
1494
1863
  engine.next();
1495
1864
  } else if (event.type === "back") {
1496
1865
  settleNow(false);
1866
+ showCurrent();
1497
1867
  engine.back();
1498
1868
  } else if (event.type === "submit") {
1499
1869
  settleNow();
1870
+ showCurrent();
1500
1871
  engine.submit();
1501
1872
  }
1502
1873
  };
1503
1874
  const finish = () => {
1504
1875
  settleNow();
1876
+ showCurrent();
1505
1877
  batch = false;
1506
1878
  state = { ...state, events: [...state.events] };
1507
1879
  };
@@ -1512,7 +1884,12 @@ function replay(definition, events, options = {}) {
1512
1884
  }
1513
1885
  function replayWithin(definition, events, options, maxWork) {
1514
1886
  const budget = maxWork === void 0 ? void 0 : { used: 0, max: maxWork };
1515
- const { engine, apply, finish } = build(definition, { ...options, initialValues: void 0, clock: () => void 0 }, true, budget);
1887
+ const { engine, apply, finish } = build(
1888
+ definition,
1889
+ { ...options, initialValues: void 0, linkParams: void 0, clock: () => void 0 },
1890
+ true,
1891
+ budget
1892
+ );
1516
1893
  try {
1517
1894
  for (const event of events) apply(event);
1518
1895
  finish();
@@ -1763,6 +2140,31 @@ function parseForm(input) {
1763
2140
  const optionalBoolean = (v, path) => {
1764
2141
  if (v !== void 0 && typeof v !== "boolean") issue(path, "Must be true or false.");
1765
2142
  };
2143
+ const optionDetails = (o, at) => {
2144
+ if (o.description !== void 0 && (typeof o.description !== "string" || o.description.length > OPTION_LIMITS.description)) {
2145
+ issue(`${at}.description`, `Must be text of at most ${OPTION_LIMITS.description} characters.`);
2146
+ }
2147
+ if (o.image !== void 0 && !isHttpsUrl(o.image)) {
2148
+ issue(`${at}.image`, `Must be an https:// link of at most ${OPTION_LIMITS.image} characters.`);
2149
+ }
2150
+ if (o.attributes === void 0) return;
2151
+ const attributes = o.attributes;
2152
+ if (!isObject2(attributes) || Object.keys(attributes).length > OPTION_LIMITS.attributes) {
2153
+ issue(`${at}.attributes`, `Must be an object of at most ${OPTION_LIMITS.attributes} attributes.`);
2154
+ return;
2155
+ }
2156
+ const fits = (x) => typeof x === "string" && x.length <= OPTION_LIMITS.attributeValue;
2157
+ for (const [name2, v] of Object.entries(attributes)) {
2158
+ if (!name2.trim() || name2.length > OPTION_LIMITS.attributeName || RESERVED.has(name2)) {
2159
+ issue(`${at}.attributes`, `${shown(name2)} isn't a usable attribute name (at most ${OPTION_LIMITS.attributeName} characters).`);
2160
+ } else if (!fits(v) && !(Array.isArray(v) && v.length <= OPTION_LIMITS.attributeItems && v.every(fits))) {
2161
+ issue(
2162
+ `${at}.attributes.${name2}`,
2163
+ `Must be text, or a list of at most ${OPTION_LIMITS.attributeItems} texts, each at most ${OPTION_LIMITS.attributeValue} characters.`
2164
+ );
2165
+ }
2166
+ }
2167
+ };
1766
2168
  const name = (v, path, what) => {
1767
2169
  if (typeof v !== "string" || !v) {
1768
2170
  issue(path, `${what} must be a non-empty string.`);
@@ -1851,6 +2253,22 @@ function parseForm(input) {
1851
2253
  }
1852
2254
  }
1853
2255
  if (type === "consent") optionalText(props.text, `${path}.text`);
2256
+ if (type === PHOTO_TYPE) {
2257
+ if (props.maxFiles !== void 0 && !(isInt(props.maxFiles) && props.maxFiles >= 1 && props.maxFiles <= MAX_PHOTO_FILES)) {
2258
+ issue(`${path}.maxFiles`, `Must be a whole number from 1 to ${MAX_PHOTO_FILES}.`);
2259
+ }
2260
+ if (props.maxSizeMb !== void 0 && !(isInt(props.maxSizeMb) && props.maxSizeMb >= 1 && props.maxSizeMb <= MAX_PHOTO_SIZE_MB)) {
2261
+ issue(`${path}.maxSizeMb`, `Must be a whole number from 1 to ${MAX_PHOTO_SIZE_MB}.`);
2262
+ }
2263
+ if (props.formats !== void 0) {
2264
+ const formats = props.formats;
2265
+ if (!Array.isArray(formats) || formats.length === 0 || !formats.every(isPhotoFormat)) {
2266
+ issue(`${path}.formats`, "Must be a list of one or more of jpeg, png, heic and webp.");
2267
+ } else if (new Set(formats).size !== formats.length) {
2268
+ issue(`${path}.formats`, "Name each format once.");
2269
+ }
2270
+ }
2271
+ }
1854
2272
  };
1855
2273
  if (!isObject2(input)) {
1856
2274
  issue("", "A form definition must be an object.");
@@ -1871,6 +2289,7 @@ function parseForm(input) {
1871
2289
  else {
1872
2290
  optionalBoolean(input.settings.anonymous, "settings.anonymous");
1873
2291
  optionalBoolean(input.settings.clearHiddenValues, "settings.clearHiddenValues");
2292
+ optionalBoolean(input.settings.saveProgress, "settings.saveProgress");
1874
2293
  }
1875
2294
  }
1876
2295
  if (input.meta !== void 0 && (!isObject2(input.meta) || !isJson(input.meta))) {
@@ -1911,6 +2330,38 @@ function parseForm(input) {
1911
2330
  issue(`${path}.type`, `Unknown field type "${String(block.type)}".`);
1912
2331
  }
1913
2332
  if (block.props !== void 0) {
2333
+ const from = isObject2(block.props) ? block.props.optionsFrom : void 0;
2334
+ if (from !== void 0) {
2335
+ if (!isObject2(from) || from.source !== "host" || typeof from.key !== "string" || !from.key) {
2336
+ issue(`${path}.props.optionsFrom`, 'Must be { source: "host", key } with a non-empty key.');
2337
+ } else if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2338
+ warnings.push({ path: `${path}.props.optionsFrom`, message: "Only single choice, multiple choice and dropdown questions take options from the app." });
2339
+ }
2340
+ const filter = isObject2(from) ? from.filter : void 0;
2341
+ if (filter !== void 0) {
2342
+ const at = `${path}.props.optionsFrom.filter`;
2343
+ if (!isObject2(filter) || typeof filter.attribute !== "string" || !filter.attribute || filter.attribute.length > OPTION_LIMITS.attributeName || typeof filter.field !== "string" || !filter.field) {
2344
+ issue(at, `Must be { attribute, field }: an attribute name (at most ${OPTION_LIMITS.attributeName} characters) and an earlier question's key.`);
2345
+ } else if (!keys.has(filter.field)) {
2346
+ issue(`${at}.field`, `"${filter.field}" must be the key of an earlier question.`);
2347
+ }
2348
+ }
2349
+ }
2350
+ const prefill = isObject2(block.props) ? block.props.prefill : void 0;
2351
+ if (prefill !== void 0) {
2352
+ if (!isObject2(prefill) || !isPrefillParam(prefill.param)) {
2353
+ issue(
2354
+ `${path}.props.prefill`,
2355
+ `Must be { param, hideWhenPrefilled? }: a link parameter of letters, digits, "_", "-" or "." (at most ${MAX_PREFILL_PARAM_LENGTH} characters).`
2356
+ );
2357
+ } else {
2358
+ optionalBoolean(prefill.hideWhenPrefilled, `${path}.props.prefill.hideWhenPrefilled`);
2359
+ if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2360
+ warnings.push({ path: `${path}.props.prefill`, message: "Only single choice, multiple choice and dropdown questions are filled in from a link." });
2361
+ }
2362
+ }
2363
+ }
2364
+ if (isObject2(block.props)) optionalBoolean(block.props.searchable, `${path}.props.searchable`);
1914
2365
  if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
1915
2366
  else if (block.props.options !== void 0) {
1916
2367
  const options = block.props.options;
@@ -1919,12 +2370,25 @@ function parseForm(input) {
1919
2370
  options.forEach((o, j) => {
1920
2371
  const at = `${path}.props.options[${j}]`;
1921
2372
  if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
1922
- else optionalText(o.label, `${at}.label`);
2373
+ else {
2374
+ optionalText(o.label, `${at}.label`);
2375
+ optionDetails(o, at);
2376
+ }
1923
2377
  });
1924
- choices.set(
1925
- block.id,
1926
- new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
1927
- );
2378
+ if (from === void 0) {
2379
+ choices.set(
2380
+ block.id,
2381
+ new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
2382
+ );
2383
+ }
2384
+ }
2385
+ }
2386
+ const other = isObject2(block.props) ? block.props.other : void 0;
2387
+ if (other !== void 0) {
2388
+ if (other !== true && other !== false && !(isObject2(other) && (other.label === void 0 || typeof other.label === "string"))) {
2389
+ issue(`${path}.props.other`, "Must be true, false or { label }.");
2390
+ } else if (other !== false && typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2391
+ warnings.push({ path: `${path}.props.other`, message: 'Only single choice, multiple choice and dropdown questions take an "Other" answer.' });
1928
2392
  }
1929
2393
  }
1930
2394
  const { max, maxLength: maxLength2 } = isObject2(block.props) ? block.props : {};
@@ -1999,6 +2463,8 @@ function parseForm(input) {
1999
2463
  } else if (ref.column !== void 0) issue(path, `"${ref.key}" is not a table, so it has no column "${ref.column}".`);
2000
2464
  else if (targetType === "media-notes" || targetType === "text-highlights") {
2001
2465
  warnings.push({ path, message: `"${ref.key}" holds notes, not numbers; the formula reads it as blank.` });
2466
+ } else if (targetType === PHOTO_TYPE) {
2467
+ warnings.push({ path, message: `"${ref.key}" holds photos, not numbers; the formula reads it as blank.` });
2002
2468
  }
2003
2469
  }
2004
2470
  });
@@ -2263,9 +2729,10 @@ function wellFormed(input) {
2263
2729
  }
2264
2730
  return { answers, events: known, hidden };
2265
2731
  }
2266
- function validateSubmission(definition, submission, options = {}) {
2267
- const invalid = checkDefinition(definition);
2732
+ function validateSubmission(published, submission, options = {}) {
2733
+ const invalid = checkDefinition(published);
2268
2734
  if (invalid) return { ok: false, errors: { "": invalid } };
2735
+ const definition = withHostOptions(published, options.hostOptions);
2269
2736
  if (!isPlainObject(submission)) return MALFORMED;
2270
2737
  const maxEvents = options.maxEvents ?? MAX_EVENTS;
2271
2738
  let sent = submission;
@@ -2336,11 +2803,12 @@ function validateSubmission(definition, submission, options = {}) {
2336
2803
  }
2337
2804
  } else {
2338
2805
  const errors = {};
2339
- for (const [id, value] of Object.entries(engine.answers())) {
2806
+ const kept = engine.answers();
2807
+ for (const [id, value] of Object.entries(kept)) {
2340
2808
  const message = validateField(fields.get(id), value, {
2341
2809
  requireAnswers: false,
2342
2810
  custom: options.validators?.[id]
2343
- });
2811
+ }) ?? filteredChoiceError(definition, fields.get(id), value, kept);
2344
2812
  if (message) errors[id] = message;
2345
2813
  }
2346
2814
  if (Object.keys(errors).length) return { ok: false, errors };