@marktiderman/genesis-forms 0.5.0 → 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.js CHANGED
@@ -829,6 +829,65 @@ function summarizeValue(value, field) {
829
829
  if (Array.isArray(value)) return value.join(", ");
830
830
  return String(value);
831
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
+ }
832
891
  var OTHER_MAX_LENGTH = 200;
833
892
  function otherOption(field) {
834
893
  const other = field.props?.other;
@@ -846,14 +905,21 @@ var typedOther = (field, value) => isOtherValue(field, value) && value.trim() !=
846
905
  function choiceOptions(field) {
847
906
  const raw = field.props?.options;
848
907
  if (!Array.isArray(raw)) return [];
849
- return raw.flatMap(
850
- (o) => o && typeof o === "object" && !Array.isArray(o) && typeof o.value === "string" ? [{ value: o.value, label: typeof o.label === "string" ? o.label : void 0 }] : []
851
- );
908
+ return raw.flatMap((o) => {
909
+ const option = readOption(o);
910
+ return option ? [option] : [];
911
+ });
852
912
  }
853
913
  function optionsSource(field) {
854
914
  const raw = field.props?.optionsFrom;
855
915
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
856
- return raw.source === "host" && typeof raw.key === "string" && raw.key ? { source: "host", key: raw.key } : 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;
857
923
  }
858
924
  function hostList(hostOptions, key) {
859
925
  if (!hostOptions || !Object.prototype.hasOwnProperty.call(hostOptions, key)) return [];
@@ -862,11 +928,10 @@ function hostList(hostOptions, key) {
862
928
  const seen = /* @__PURE__ */ new Set();
863
929
  const out = [];
864
930
  for (const o of list) {
865
- if (!o || typeof o !== "object" || Array.isArray(o)) continue;
866
- const { value, label } = o;
867
- if (typeof value !== "string" || !value || seen.has(value)) continue;
868
- seen.add(value);
869
- out.push(typeof label === "string" ? { value, label } : { value });
931
+ const option = readOption(o);
932
+ if (!option || !option.value || seen.has(option.value)) continue;
933
+ seen.add(option.value);
934
+ out.push(option);
870
935
  }
871
936
  return out;
872
937
  }
@@ -881,7 +946,7 @@ function withHostOptions(definition, hostOptions) {
881
946
  const added = hostList(hostOptions, from.key).filter((o) => !taken.has(o.value));
882
947
  if (!added.length) return block;
883
948
  changed = true;
884
- const options = [...own2, ...added].map((o) => o.label === void 0 ? { value: o.value } : { value: o.value, label: o.label });
949
+ const options = [...own2, ...added].map(optionJson);
885
950
  return { ...block, props: { ...block.props, options } };
886
951
  });
887
952
  return changed ? { ...definition, blocks } : definition;
@@ -1361,6 +1426,205 @@ function fieldMap(def) {
1361
1426
  return map;
1362
1427
  }
1363
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
+ function fold(text) {
1505
+ return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
1506
+ }
1507
+ var MAX_PREFILL_PARAM_LENGTH = 64;
1508
+ function isPrefillParam(param) {
1509
+ return typeof param === "string" && param.length <= MAX_PREFILL_PARAM_LENGTH && /^[A-Za-z0-9_.-]+$/.test(param);
1510
+ }
1511
+ function prefillSetting(field) {
1512
+ if (!CHOICE_FIELD_TYPES.has(field.type)) return void 0;
1513
+ const raw = field.props?.prefill;
1514
+ if (!raw || typeof raw !== "object" || Array.isArray(raw) || !isPrefillParam(raw.param)) return void 0;
1515
+ return { param: raw.param, hideWhenPrefilled: raw.hideWhenPrefilled === true };
1516
+ }
1517
+ function paramValues(params, name) {
1518
+ let raw;
1519
+ if (typeof params.getAll === "function") {
1520
+ raw = params.getAll(name);
1521
+ } else if (Object.prototype.hasOwnProperty.call(params, name)) {
1522
+ raw = params[name];
1523
+ }
1524
+ const list = Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw];
1525
+ return list.filter((v) => typeof v === "string" && v !== "" && v.length <= MAX_HIDDEN_STRING_LENGTH);
1526
+ }
1527
+ function prefillFromLink(definition, params, values = {}) {
1528
+ const out = {};
1529
+ if (!params || typeof params !== "object") return out;
1530
+ const seen = {};
1531
+ for (const id of Object.keys(values)) put(seen, id, values[id]);
1532
+ for (const block of definition.blocks) {
1533
+ if (!isChoice(block)) continue;
1534
+ const setting = prefillSetting(block);
1535
+ if (!setting || own(seen, block.id) !== void 0) continue;
1536
+ const raw = paramValues(params, setting.param);
1537
+ if (!raw.length) continue;
1538
+ const shown2 = new Set(visibleOptions(definition, block, seen).map((o) => o.value));
1539
+ let value;
1540
+ if (block.type === "multi-choice") {
1541
+ const picked = [];
1542
+ for (const r of raw) {
1543
+ for (const v of shown2.has(r) ? [r] : r.split(",").map((s) => s.trim())) {
1544
+ if (shown2.has(v) && !picked.includes(v)) picked.push(v);
1545
+ }
1546
+ }
1547
+ if (picked.length) value = picked;
1548
+ } else value = raw.find((v) => shown2.has(v));
1549
+ if (value === void 0) continue;
1550
+ put(out, block.id, value);
1551
+ put(seen, block.id, value);
1552
+ }
1553
+ return out;
1554
+ }
1555
+ function hiddenByPrefill(definition, state) {
1556
+ const hideable = /* @__PURE__ */ new Set();
1557
+ for (const b of definition.blocks) {
1558
+ if (isChoice(b) && prefillSetting(b)?.hideWhenPrefilled) hideable.add(b.id);
1559
+ }
1560
+ if (!hideable.size) return [];
1561
+ const prefilled = /* @__PURE__ */ new Set();
1562
+ for (const e of state.events) {
1563
+ if (e.type !== "answer" || !hideable.has(e.field)) continue;
1564
+ if (e.source === "prefill") prefilled.add(e.field);
1565
+ else prefilled.delete(e.field);
1566
+ }
1567
+ if (!prefilled.size) return [];
1568
+ const fields = new Map(definition.blocks.map((b) => [b.id, b]));
1569
+ return state.path.filter((id) => {
1570
+ if (!prefilled.has(id) || own(state.errors, id) !== void 0) return false;
1571
+ const field = fields.get(id);
1572
+ return !isEmptyValue(own(state.values, id), field);
1573
+ });
1574
+ }
1575
+ function snapshotOf(byValue, value) {
1576
+ const option = byValue.get(value);
1577
+ if (option) return { value, label: option.label ?? value };
1578
+ return { value, label: value, other: true };
1579
+ }
1580
+ function answerSnapshots(definition, answers) {
1581
+ const out = {};
1582
+ for (const block of definition.blocks) {
1583
+ if (!isChoice(block) || !optionsSource(block)) continue;
1584
+ const value = own(answers, block.id);
1585
+ if (isEmptyValue(value, block)) continue;
1586
+ const byValue = new Map(choiceOptions(block).map((o) => [o.value, o]));
1587
+ let snapshot;
1588
+ if (Array.isArray(value)) {
1589
+ const items = value.filter((v) => typeof v === "string").map((v) => snapshotOf(byValue, v));
1590
+ if (items.length) snapshot = items;
1591
+ } else if (typeof value === "string") snapshot = snapshotOf(byValue, value);
1592
+ if (snapshot) put(out, block.id, snapshot);
1593
+ }
1594
+ return out;
1595
+ }
1596
+ function answersFromSnapshots(definition, snapshots) {
1597
+ const answers = {};
1598
+ const missing = {};
1599
+ for (const block of definition.blocks) {
1600
+ if (!isChoice(block)) continue;
1601
+ const raw = own(snapshots, block.id);
1602
+ const list = (Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw]).flatMap((s) => {
1603
+ const o = readOption(s);
1604
+ if (!o) return [];
1605
+ const label = o.label ?? o.value;
1606
+ return [s.other === true ? { value: o.value, label, other: true } : { value: o.value, label }];
1607
+ });
1608
+ if (!list.length) continue;
1609
+ const options = choiceOptions(block);
1610
+ const byValue = new Set(options.map((o) => o.value));
1611
+ const allowsOther = block.props?.other !== void 0 && block.props?.other !== false;
1612
+ const found = [];
1613
+ const lost = [];
1614
+ for (const s of list) {
1615
+ let value;
1616
+ if (byValue.has(s.value)) value = s.value;
1617
+ else if (s.other) value = allowsOther ? s.value : void 0;
1618
+ else value = options.find((o) => fold(o.label ?? o.value) === fold(s.label))?.value;
1619
+ if (value === void 0) lost.push(s);
1620
+ else if (!found.includes(value)) found.push(value);
1621
+ }
1622
+ if (found.length) put(answers, block.id, block.type === "multi-choice" ? found : found[0]);
1623
+ if (lost.length) put(missing, block.id, lost);
1624
+ }
1625
+ return { answers, missing };
1626
+ }
1627
+
1364
1628
  // src/recall.ts
1365
1629
  var TOKEN = /\{\{\s*(field|hidden):([A-Za-z0-9_-]+)\s*(?:\|([^}]*))?\}\}/g;
1366
1630
  function recall(text, scope) {
@@ -1482,16 +1746,34 @@ function build(given, options, replaying = false, budget) {
1482
1746
  return at === void 0 ? event : { ...event, at };
1483
1747
  };
1484
1748
  const isVisibleIn = (s, id) => s.path.includes(id);
1749
+ const filters = hasOptionFilters(definition);
1750
+ const clearFiltered = (values, id) => filters ? clearFilteredAnswers(definition, values, id) : values;
1485
1751
  const errorsFor = (s, ids) => {
1486
1752
  const errors = {};
1753
+ let shown2;
1487
1754
  for (const id of ids) {
1488
1755
  const field = fields.get(id);
1489
1756
  if (!field || !isVisibleIn(s, id)) continue;
1490
- const message = validateField(field, own(s.values, id), { custom: options.validators?.[id] });
1757
+ const value = own(s.values, id);
1758
+ let message = validateField(field, value, { custom: options.validators?.[id] });
1759
+ if (!message && filters) message = filteredChoiceError(definition, field, value, shown2 ??= answersOnPath(s));
1491
1760
  if (message) errors[id] = message;
1492
1761
  }
1493
1762
  return errors;
1494
1763
  };
1764
+ const skipped = (s) => new Set(hiddenByPrefill(definition, s));
1765
+ const shownFrom = (s, from, skip) => {
1766
+ for (let i = from; i < s.path.length; i++) if (!skip.has(s.path[i])) return s.path[i];
1767
+ return void 0;
1768
+ };
1769
+ const showCurrent = () => {
1770
+ if (!state.current) return;
1771
+ const skip = skipped(state);
1772
+ if (!skip.has(state.current)) return;
1773
+ const at = state.path.indexOf(state.current);
1774
+ const shown2 = at < 0 ? void 0 : shownFrom(state, at, skip);
1775
+ if (shown2) state = { ...state, current: shown2, furthest: state.furthest === state.current ? shown2 : state.furthest };
1776
+ };
1495
1777
  let unsettled = false;
1496
1778
  let formulasStale = false;
1497
1779
  const settleNow = (needValues = true) => {
@@ -1520,13 +1802,13 @@ function build(given, options, replaying = false, budget) {
1520
1802
  const errors = { ...state.errors };
1521
1803
  delete errors[id];
1522
1804
  if (batch) {
1523
- state.values = { ...state.values, [id]: value };
1805
+ state.values = clearFiltered({ ...state.values, [id]: value }, id);
1524
1806
  state.errors = errors;
1525
1807
  log(event);
1526
1808
  unsettled = true;
1527
1809
  return;
1528
1810
  }
1529
- const { values, path } = settle({ ...state.values, [id]: value }, state.hidden);
1811
+ const { values, path } = settle(clearFiltered({ ...state.values, [id]: value }, id), state.hidden);
1530
1812
  emit({ ...state, values, path, errors, events: log(event) });
1531
1813
  };
1532
1814
  const engine = {
@@ -1557,7 +1839,7 @@ function build(given, options, replaying = false, budget) {
1557
1839
  return false;
1558
1840
  }
1559
1841
  const at = state.path.indexOf(state.current);
1560
- const target = state.path[at + 1];
1842
+ const target = shownFrom(state, at + 1, skipped(state));
1561
1843
  if (!target) {
1562
1844
  emit({ ...state, events });
1563
1845
  return true;
@@ -1569,8 +1851,9 @@ function build(given, options, replaying = false, budget) {
1569
1851
  back() {
1570
1852
  if (state.status === "completed") return;
1571
1853
  const history = [...state.history];
1854
+ const skip = skipped(state);
1572
1855
  let previous = history.pop();
1573
- while (previous && !state.path.includes(previous)) previous = history.pop();
1856
+ while (previous && (!state.path.includes(previous) || skip.has(previous))) previous = history.pop();
1574
1857
  if (!previous) return;
1575
1858
  emit({ ...state, current: previous, history, events: log(stamp({ type: "back" })) });
1576
1859
  },
@@ -1598,15 +1881,18 @@ function build(given, options, replaying = false, budget) {
1598
1881
  return out;
1599
1882
  }
1600
1883
  };
1601
- if (options.initialValues) {
1884
+ const fromLink = options.linkParams ? prefillFromLink(definition, options.linkParams, options.initialValues) : {};
1885
+ const initialValues = Object.keys(fromLink).length ? { ...fromLink, ...options.initialValues } : options.initialValues;
1886
+ if (initialValues) {
1602
1887
  const wasBatch = batch;
1603
1888
  batch = true;
1604
- for (const [id, value] of Object.entries(options.initialValues)) {
1889
+ for (const [id, value] of Object.entries(initialValues)) {
1605
1890
  if (!fields.has(id) || value === void 0) continue;
1606
1891
  answer(id, value, stamp({ type: "answer", field: id, value, source: "prefill" }));
1607
1892
  }
1608
1893
  settleNow();
1609
1894
  batch = wasBatch;
1895
+ showCurrent();
1610
1896
  }
1611
1897
  const resumeAt = options.resumeAt;
1612
1898
  if (resumeAt && !replaying && state.path.includes(resumeAt)) {
@@ -1625,18 +1911,22 @@ function build(given, options, replaying = false, budget) {
1625
1911
  }
1626
1912
  } else if (event.type === "next") {
1627
1913
  settleNow(false);
1914
+ showCurrent();
1628
1915
  if (formulasStale && isCalculated(fields.get(state.current ?? ""))) settleNow(true);
1629
1916
  engine.next();
1630
1917
  } else if (event.type === "back") {
1631
1918
  settleNow(false);
1919
+ showCurrent();
1632
1920
  engine.back();
1633
1921
  } else if (event.type === "submit") {
1634
1922
  settleNow();
1923
+ showCurrent();
1635
1924
  engine.submit();
1636
1925
  }
1637
1926
  };
1638
1927
  const finish = () => {
1639
1928
  settleNow();
1929
+ showCurrent();
1640
1930
  batch = false;
1641
1931
  state = { ...state, events: [...state.events] };
1642
1932
  };
@@ -1644,7 +1934,12 @@ function build(given, options, replaying = false, budget) {
1644
1934
  }
1645
1935
  function replayWithin(definition, events, options, maxWork) {
1646
1936
  const budget = maxWork === void 0 ? void 0 : { used: 0, max: maxWork };
1647
- const { engine, apply, finish } = build(definition, { ...options, initialValues: void 0, clock: () => void 0 }, true, budget);
1937
+ const { engine, apply, finish } = build(
1938
+ definition,
1939
+ { ...options, initialValues: void 0, linkParams: void 0, clock: () => void 0 },
1940
+ true,
1941
+ budget
1942
+ );
1648
1943
  try {
1649
1944
  for (const event of events) apply(event);
1650
1945
  finish();
@@ -1716,6 +2011,31 @@ function parseForm(input) {
1716
2011
  const optionalBoolean = (v, path) => {
1717
2012
  if (v !== void 0 && typeof v !== "boolean") issue(path, "Must be true or false.");
1718
2013
  };
2014
+ const optionDetails = (o, at) => {
2015
+ if (o.description !== void 0 && (typeof o.description !== "string" || o.description.length > OPTION_LIMITS.description)) {
2016
+ issue(`${at}.description`, `Must be text of at most ${OPTION_LIMITS.description} characters.`);
2017
+ }
2018
+ if (o.image !== void 0 && !isHttpsUrl(o.image)) {
2019
+ issue(`${at}.image`, `Must be an https:// link of at most ${OPTION_LIMITS.image} characters.`);
2020
+ }
2021
+ if (o.attributes === void 0) return;
2022
+ const attributes = o.attributes;
2023
+ if (!isObject2(attributes) || Object.keys(attributes).length > OPTION_LIMITS.attributes) {
2024
+ issue(`${at}.attributes`, `Must be an object of at most ${OPTION_LIMITS.attributes} attributes.`);
2025
+ return;
2026
+ }
2027
+ const fits = (x) => typeof x === "string" && x.length <= OPTION_LIMITS.attributeValue;
2028
+ for (const [name2, v] of Object.entries(attributes)) {
2029
+ if (!name2.trim() || name2.length > OPTION_LIMITS.attributeName || RESERVED.has(name2)) {
2030
+ issue(`${at}.attributes`, `${shown(name2)} isn't a usable attribute name (at most ${OPTION_LIMITS.attributeName} characters).`);
2031
+ } else if (!fits(v) && !(Array.isArray(v) && v.length <= OPTION_LIMITS.attributeItems && v.every(fits))) {
2032
+ issue(
2033
+ `${at}.attributes.${name2}`,
2034
+ `Must be text, or a list of at most ${OPTION_LIMITS.attributeItems} texts, each at most ${OPTION_LIMITS.attributeValue} characters.`
2035
+ );
2036
+ }
2037
+ }
2038
+ };
1719
2039
  const name = (v, path, what) => {
1720
2040
  if (typeof v !== "string" || !v) {
1721
2041
  issue(path, `${what} must be a non-empty string.`);
@@ -1888,7 +2208,31 @@ function parseForm(input) {
1888
2208
  } else if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
1889
2209
  warnings.push({ path: `${path}.props.optionsFrom`, message: "Only single choice, multiple choice and dropdown questions take options from the app." });
1890
2210
  }
2211
+ const filter = isObject2(from) ? from.filter : void 0;
2212
+ if (filter !== void 0) {
2213
+ const at = `${path}.props.optionsFrom.filter`;
2214
+ if (!isObject2(filter) || typeof filter.attribute !== "string" || !filter.attribute || filter.attribute.length > OPTION_LIMITS.attributeName || typeof filter.field !== "string" || !filter.field) {
2215
+ issue(at, `Must be { attribute, field }: an attribute name (at most ${OPTION_LIMITS.attributeName} characters) and an earlier question's key.`);
2216
+ } else if (!keys.has(filter.field)) {
2217
+ issue(`${at}.field`, `"${filter.field}" must be the key of an earlier question.`);
2218
+ }
2219
+ }
1891
2220
  }
2221
+ const prefill = isObject2(block.props) ? block.props.prefill : void 0;
2222
+ if (prefill !== void 0) {
2223
+ if (!isObject2(prefill) || !isPrefillParam(prefill.param)) {
2224
+ issue(
2225
+ `${path}.props.prefill`,
2226
+ `Must be { param, hideWhenPrefilled? }: a link parameter of letters, digits, "_", "-" or "." (at most ${MAX_PREFILL_PARAM_LENGTH} characters).`
2227
+ );
2228
+ } else {
2229
+ optionalBoolean(prefill.hideWhenPrefilled, `${path}.props.prefill.hideWhenPrefilled`);
2230
+ if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2231
+ warnings.push({ path: `${path}.props.prefill`, message: "Only single choice, multiple choice and dropdown questions are filled in from a link." });
2232
+ }
2233
+ }
2234
+ }
2235
+ if (isObject2(block.props)) optionalBoolean(block.props.searchable, `${path}.props.searchable`);
1892
2236
  if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
1893
2237
  else if (block.props.options !== void 0) {
1894
2238
  const options = block.props.options;
@@ -1897,7 +2241,10 @@ function parseForm(input) {
1897
2241
  options.forEach((o, j) => {
1898
2242
  const at = `${path}.props.options[${j}]`;
1899
2243
  if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
1900
- else optionalText(o.label, `${at}.label`);
2244
+ else {
2245
+ optionalText(o.label, `${at}.label`);
2246
+ optionDetails(o, at);
2247
+ }
1901
2248
  });
1902
2249
  if (from === void 0) {
1903
2250
  choices.set(
@@ -2190,6 +2537,158 @@ function parseForm(input) {
2190
2537
  return issues.length ? fail() : { ok: true, form: input, warnings };
2191
2538
  }
2192
2539
 
2540
+ // src/edit-link.ts
2541
+ var EDIT_LINK_TTL_SECONDS = 30 * 24 * 60 * 60;
2542
+ var MIN_EDIT_SECRET_BYTES = 32;
2543
+ var EDIT_LINK_PARAM = "edit";
2544
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2545
+ var NONCE = /^[A-Za-z0-9_-]{16,64}$/;
2546
+ var EXPIRES = /^[1-9][0-9]{0,11}$/;
2547
+ var SIG = /^[A-Za-z0-9_-]{43}$/;
2548
+ var DOMAIN = "edit-link:v2:";
2549
+ var MAX_TOKEN_LENGTH = 300;
2550
+ var encoder = new TextEncoder();
2551
+ function toBase64Url(bytes) {
2552
+ let binary = "";
2553
+ for (const b of bytes) binary += String.fromCharCode(b);
2554
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2555
+ }
2556
+ function fromBase64Url(text) {
2557
+ const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
2558
+ const bytes = new Uint8Array(binary.length);
2559
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
2560
+ return bytes;
2561
+ }
2562
+ function isUsableEditSecret(secret) {
2563
+ return typeof secret === "string" && secret.trim().length > 0 && encoder.encode(secret).length >= MIN_EDIT_SECRET_BYTES;
2564
+ }
2565
+ function hmacKey(secret, usage) {
2566
+ return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [usage]);
2567
+ }
2568
+ var payload = (id, nonce, expiresAt) => encoder.encode(`${DOMAIN}${id}.${nonce}.${expiresAt}`);
2569
+ async function signEditToken(link, secret, options = {}) {
2570
+ if (!isUsableEditSecret(secret) || !UUID.test(link.id) || !NONCE.test(link.nonce)) return null;
2571
+ const ttl = options.ttlSeconds ?? EDIT_LINK_TTL_SECONDS;
2572
+ if (!Number.isFinite(ttl) || ttl <= 0) return null;
2573
+ const id = link.id.toLowerCase();
2574
+ const expiresAt = Math.floor((options.now ?? Date.now()) / 1e3) + Math.floor(ttl);
2575
+ const sig = await crypto.subtle.sign("HMAC", await hmacKey(secret, "sign"), payload(id, link.nonce, expiresAt));
2576
+ return `${id}.${link.nonce}.${expiresAt}.${toBase64Url(new Uint8Array(sig))}`;
2577
+ }
2578
+ async function verifyEditToken(token, secret, options = {}) {
2579
+ if (!isUsableEditSecret(secret) || typeof token !== "string" || token.length > MAX_TOKEN_LENGTH) return null;
2580
+ const parts = token.split(".");
2581
+ if (parts.length !== 4) return null;
2582
+ const [id, nonce, expires, sig] = parts;
2583
+ if (!UUID.test(id) || id !== id.toLowerCase() || !NONCE.test(nonce) || !EXPIRES.test(expires)) return null;
2584
+ if (!SIG.test(sig)) return null;
2585
+ const expiresAt = Number(expires);
2586
+ const now = Math.floor((options.now ?? Date.now()) / 1e3);
2587
+ const ttl = options.ttlSeconds ?? EDIT_LINK_TTL_SECONDS;
2588
+ if (expiresAt <= now || expiresAt > now + ttl) return null;
2589
+ let bytes;
2590
+ try {
2591
+ bytes = fromBase64Url(sig);
2592
+ } catch {
2593
+ return null;
2594
+ }
2595
+ const ok = await crypto.subtle.verify("HMAC", await hmacKey(secret, "verify"), bytes, payload(id, nonce, expiresAt));
2596
+ return ok ? { id, nonce, expiresAt } : null;
2597
+ }
2598
+ function withEditToken(formUrl, token, param = EDIT_LINK_PARAM) {
2599
+ return `${formUrl}${formUrl.includes("?") ? "&" : "?"}${param}=${encodeURIComponent(token)}`;
2600
+ }
2601
+ async function editLinkUrl(formUrl, link, secret, options = {}) {
2602
+ const token = await signEditToken(link, secret, options);
2603
+ return token ? withEditToken(formUrl, token, options.param) : null;
2604
+ }
2605
+ function createEditSecretReader(options) {
2606
+ const { variable } = options;
2607
+ let warned = false;
2608
+ const read = (env, log) => {
2609
+ const secret = env[variable];
2610
+ if (isUsableEditSecret(secret)) return secret;
2611
+ if (!warned) {
2612
+ warned = true;
2613
+ (log ?? options.log ?? ((m) => console.warn(m)))(
2614
+ `${variable} is ${secret ? `shorter than ${MIN_EDIT_SECRET_BYTES} bytes` : "not set"}: edit links are off.`
2615
+ );
2616
+ }
2617
+ return null;
2618
+ };
2619
+ return Object.assign(read, {
2620
+ reset: () => {
2621
+ warned = false;
2622
+ }
2623
+ });
2624
+ }
2625
+ function isEditLinkRequest(url, options = {}) {
2626
+ const isFormPage = options.isFormPage ?? (() => true);
2627
+ return isFormPage(url) && url.searchParams.has(options.param ?? EDIT_LINK_PARAM);
2628
+ }
2629
+ function withEditLinkHeaders(request, response, options = {}) {
2630
+ let url;
2631
+ try {
2632
+ url = new URL(request.url);
2633
+ } catch {
2634
+ return response;
2635
+ }
2636
+ if (!isEditLinkRequest(url, options)) return response;
2637
+ const headers = new Headers(response.headers);
2638
+ headers.set("referrer-policy", "no-referrer");
2639
+ headers.set("cache-control", "private, no-store");
2640
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
2641
+ }
2642
+
2643
+ // src/rate-limit.ts
2644
+ var DEFAULT_TRUSTED_IP_HEADERS = ["x-vercel-forwarded-for", "x-forwarded-for", "x-real-ip"];
2645
+ var RATE_LIMIT_DEFAULTS = {
2646
+ submit: { limit: 60, windowSeconds: 600 },
2647
+ progress: { limit: 900, windowSeconds: 600 },
2648
+ upload: { limit: 120, windowSeconds: 600 }
2649
+ };
2650
+ function clientIp(headers, trusted = DEFAULT_TRUSTED_IP_HEADERS) {
2651
+ for (const name of trusted) {
2652
+ const first = headers.get(name)?.split(",")[0]?.trim();
2653
+ if (first) return first;
2654
+ }
2655
+ return "unknown";
2656
+ }
2657
+ function addressPrefix(ip) {
2658
+ const bare = ip.replace(/^\[|\](:\d+)?$/g, "").split("%")[0].toLowerCase();
2659
+ if (!bare.includes(":")) return bare;
2660
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(bare);
2661
+ if (mapped) return mapped[1];
2662
+ const [head = "", tail = ""] = bare.split("::");
2663
+ const left = head ? head.split(":") : [];
2664
+ const right = bare.includes("::") && tail ? tail.split(":") : [];
2665
+ const groups = bare.includes("::") ? [...left, ...Array(Math.max(0, 8 - left.length - right.length)).fill("0"), ...right] : left;
2666
+ return `${groups.slice(0, 4).map((g) => (g || "0").replace(/^0+(?=.)/, "")).join(":")}::/64`;
2667
+ }
2668
+ async function rateLimitKey(input) {
2669
+ const encoder2 = new TextEncoder();
2670
+ const key = await crypto.subtle.importKey("raw", encoder2.encode(input.secret), { name: "HMAC", hash: "SHA-256" }, false, [
2671
+ "sign"
2672
+ ]);
2673
+ const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder2.encode(addressPrefix(input.ip))));
2674
+ let hex = "";
2675
+ for (const b of mac) hex += b.toString(16).padStart(2, "0");
2676
+ return `${input.kind}:${input.scope}:${hex}`;
2677
+ }
2678
+ async function allow(store, check, options = {}) {
2679
+ let allowed;
2680
+ try {
2681
+ allowed = await store.hit(check.key, check.limit, check.windowSeconds);
2682
+ } catch {
2683
+ allowed = null;
2684
+ }
2685
+ if (allowed !== null) return allowed;
2686
+ (options.log ?? ((m) => console.warn(m)))(
2687
+ options.message ?? "The rate limit could not be checked; letting the request through."
2688
+ );
2689
+ return true;
2690
+ }
2691
+
2193
2692
  // src/server.ts
2194
2693
  var MAX_EVENTS = 1e4;
2195
2694
  var MAX_SUBMISSION_VALUES = 2e5;
@@ -2327,11 +2826,12 @@ function validateSubmission(published, submission, options = {}) {
2327
2826
  }
2328
2827
  } else {
2329
2828
  const errors = {};
2330
- for (const [id, value] of Object.entries(engine.answers())) {
2829
+ const kept = engine.answers();
2830
+ for (const [id, value] of Object.entries(kept)) {
2331
2831
  const message = validateField(fields.get(id), value, {
2332
2832
  requireAnswers: false,
2333
2833
  custom: options.validators?.[id]
2334
- });
2834
+ }) ?? filteredChoiceError(definition, fields.get(id), value, kept);
2335
2835
  if (message) errors[id] = message;
2336
2836
  }
2337
2837
  if (Object.keys(errors).length) return { ok: false, errors };
@@ -2373,6 +2873,6 @@ function publicDefinition(definition) {
2373
2873
  return JSON.parse(JSON.stringify(definition));
2374
2874
  }
2375
2875
 
2376
- export { MAX_EVENTS, MAX_REPLAY_WORK, MAX_SUBMISSION_VALUES, RAW_EVENTS_PER_EVENT, publicDefinition, validateSubmission };
2876
+ export { DEFAULT_TRUSTED_IP_HEADERS, EDIT_LINK_PARAM, EDIT_LINK_TTL_SECONDS, MAX_EVENTS, MAX_REPLAY_WORK, MAX_SUBMISSION_VALUES, MIN_EDIT_SECRET_BYTES, RATE_LIMIT_DEFAULTS, RAW_EVENTS_PER_EVENT, addressPrefix, allow, answerSnapshots, answersFromSnapshots, clientIp, createEditSecretReader, editLinkUrl, isEditLinkRequest, isUsableEditSecret, publicDefinition, rateLimitKey, signEditToken, validateSubmission, verifyEditToken, withEditLinkHeaders, withEditToken, withHostOptions };
2377
2877
  //# sourceMappingURL=server.js.map
2378
2878
  //# sourceMappingURL=server.js.map