@marktiderman/genesis-forms 0.1.0 → 0.2.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.d.cts CHANGED
@@ -1,32 +1,32 @@
1
- import { J as Json, f as FormEvent, e as FieldValidator, a as FormDefinition } from './validate-j8Z188mF.cjs';
1
+ import { J as Json, f as FormEvent, e as FieldValidator, a as FormDefinition } from './validate-BbackAjB.cjs';
2
2
 
3
3
  interface SubmissionInput {
4
4
  /** Answers keyed by field id. Ignored when `events` is given. */
5
- answers?: Record<string, Json>;
5
+ answers?: Record<string, Json> | undefined;
6
6
  /** The respondent's event log. Replayed instead of trusting `answers`. */
7
- events?: FormEvent[];
7
+ events?: FormEvent[] | undefined;
8
8
  /** URL and host inputs sent by the client. Server inputs here are ignored. */
9
- hidden?: Record<string, Json>;
9
+ hidden?: Record<string, Json> | undefined;
10
10
  }
11
11
  interface SubmissionOptions {
12
12
  /** Values for `source: "server"` inputs, resolved by the server (class, coach, user id…). */
13
- context?: Record<string, Json>;
13
+ context?: Record<string, Json> | undefined;
14
14
  /** `false` checks a draft: formats only, nothing required. Default true. */
15
- complete?: boolean;
16
- validators?: Record<string, FieldValidator>;
15
+ complete?: boolean | undefined;
16
+ validators?: Record<string, FieldValidator> | undefined;
17
17
  /** Most events accepted, counted after `compactEvents`. Default 10 000. */
18
- maxEvents?: number;
18
+ maxEvents?: number | undefined;
19
19
  }
20
20
  interface CheckedSubmission {
21
21
  status: "draft" | "completed";
22
22
  /** Visible answers only, keyed by field id (kept hidden answers too, with `clearHiddenValues: false`). */
23
23
  answers: Record<string, Json>;
24
- /** Untrusted URL and host inputs, coerced to their declared types. */
24
+ /** Untrusted URL and host inputs, coerced to their declared types. Identifying ones are left out on anonymous forms. */
25
25
  hidden: Record<string, Json>;
26
- /** Trusted server inputs. */
26
+ /** Trusted server inputs. Identifying ones are left out on anonymous forms. */
27
27
  context: Record<string, Json>;
28
28
  ending: string | null;
29
- /** Client-sent keys that were dropped: unknown fields, server inputs, undeclared hidden names. */
29
+ /** Client-sent keys that were dropped, each once: unknown fields, server inputs, undeclared or withheld hidden names. */
30
30
  ignored: string[];
31
31
  }
32
32
  type SubmissionResult = {
package/dist/server.d.ts CHANGED
@@ -1,32 +1,32 @@
1
- import { J as Json, f as FormEvent, e as FieldValidator, a as FormDefinition } from './validate-j8Z188mF.js';
1
+ import { J as Json, f as FormEvent, e as FieldValidator, a as FormDefinition } from './validate-BbackAjB.js';
2
2
 
3
3
  interface SubmissionInput {
4
4
  /** Answers keyed by field id. Ignored when `events` is given. */
5
- answers?: Record<string, Json>;
5
+ answers?: Record<string, Json> | undefined;
6
6
  /** The respondent's event log. Replayed instead of trusting `answers`. */
7
- events?: FormEvent[];
7
+ events?: FormEvent[] | undefined;
8
8
  /** URL and host inputs sent by the client. Server inputs here are ignored. */
9
- hidden?: Record<string, Json>;
9
+ hidden?: Record<string, Json> | undefined;
10
10
  }
11
11
  interface SubmissionOptions {
12
12
  /** Values for `source: "server"` inputs, resolved by the server (class, coach, user id…). */
13
- context?: Record<string, Json>;
13
+ context?: Record<string, Json> | undefined;
14
14
  /** `false` checks a draft: formats only, nothing required. Default true. */
15
- complete?: boolean;
16
- validators?: Record<string, FieldValidator>;
15
+ complete?: boolean | undefined;
16
+ validators?: Record<string, FieldValidator> | undefined;
17
17
  /** Most events accepted, counted after `compactEvents`. Default 10 000. */
18
- maxEvents?: number;
18
+ maxEvents?: number | undefined;
19
19
  }
20
20
  interface CheckedSubmission {
21
21
  status: "draft" | "completed";
22
22
  /** Visible answers only, keyed by field id (kept hidden answers too, with `clearHiddenValues: false`). */
23
23
  answers: Record<string, Json>;
24
- /** Untrusted URL and host inputs, coerced to their declared types. */
24
+ /** Untrusted URL and host inputs, coerced to their declared types. Identifying ones are left out on anonymous forms. */
25
25
  hidden: Record<string, Json>;
26
- /** Trusted server inputs. */
26
+ /** Trusted server inputs. Identifying ones are left out on anonymous forms. */
27
27
  context: Record<string, Json>;
28
28
  ending: string | null;
29
- /** Client-sent keys that were dropped: unknown fields, server inputs, undeclared hidden names. */
29
+ /** Client-sent keys that were dropped, each once: unknown fields, server inputs, undeclared or withheld hidden names. */
30
30
  ignored: string[];
31
31
  }
32
32
  type SubmissionResult = {
package/dist/server.js CHANGED
@@ -228,6 +228,7 @@ function compactEvents(events) {
228
228
 
229
229
  // src/hidden.ts
230
230
  var MAX_HIDDEN_STRING_LENGTH = 1e3;
231
+ var DECIMAL = /^-?\d+(\.\d+)?$/;
231
232
  function coerceHiddenValue(value, type) {
232
233
  if (typeof value === "string" && value.length > MAX_HIDDEN_STRING_LENGTH) return void 0;
233
234
  if (type === "string") {
@@ -237,7 +238,7 @@ function coerceHiddenValue(value, type) {
237
238
  }
238
239
  if (type === "number") {
239
240
  if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
240
- if (typeof value !== "string" || value.trim() === "") return void 0;
241
+ if (typeof value !== "string" || !DECIMAL.test(value)) return void 0;
241
242
  const n = Number(value);
242
243
  return Number.isFinite(n) ? n : void 0;
243
244
  }
@@ -562,7 +563,7 @@ function build(definition, options, replaying = false) {
562
563
  for (const id of state.path) {
563
564
  const field = fields.get(id);
564
565
  const value = own(state.values, id);
565
- if (field && value !== void 0) out[id] = normalizeValue(value, field);
566
+ if (field && !isEmptyValue(value, field)) out[id] = normalizeValue(value, field);
566
567
  }
567
568
  return out;
568
569
  }
@@ -597,7 +598,30 @@ function replay(definition, events, options = {}) {
597
598
  }
598
599
 
599
600
  // src/parse.ts
601
+ function shown(v) {
602
+ let text;
603
+ try {
604
+ text = JSON.stringify(v);
605
+ } catch {
606
+ text = void 0;
607
+ }
608
+ text ??= typeof v === "bigint" ? `${v}n` : typeof v;
609
+ return text.length > 60 ? `${text.slice(0, 57)}...` : text;
610
+ }
600
611
  var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
612
+ var isInt = (v) => typeof v === "number" && Number.isInteger(v);
613
+ var LITERAL_TYPE = {
614
+ "short-text": "string",
615
+ "long-text": "string",
616
+ email: "string",
617
+ phone: "string",
618
+ rating: "number",
619
+ nps: "number",
620
+ "single-choice": "string",
621
+ "multi-choice": "string",
622
+ "yes-no": "boolean"
623
+ };
624
+ var ORDERING = /* @__PURE__ */ new Set(["lt", "lte", "gt", "gte"]);
601
625
  var OPERATORS = /* @__PURE__ */ new Set([
602
626
  "eq",
603
627
  "neq",
@@ -670,6 +694,7 @@ function parseForm(input) {
670
694
  const keys = /* @__PURE__ */ new Map();
671
695
  const fieldTypes = /* @__PURE__ */ new Map();
672
696
  const order = /* @__PURE__ */ new Map();
697
+ const choices = /* @__PURE__ */ new Map();
673
698
  input.blocks.forEach((block, i) => {
674
699
  const path = `blocks[${i}]`;
675
700
  if (!isObject(block) || typeof block.id !== "string" || !block.id) {
@@ -703,8 +728,19 @@ function parseForm(input) {
703
728
  if (!isObject(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
704
729
  else optionalText(o.label, `${at}.label`);
705
730
  });
731
+ choices.set(
732
+ block.id,
733
+ new Set(options.flatMap((o) => isObject(o) && typeof o.value === "string" ? [o.value] : []))
734
+ );
706
735
  }
707
736
  }
737
+ const { max, maxLength: maxLength2 } = isObject(block.props) ? block.props : {};
738
+ if (block.type === "rating" && max !== void 0 && !(isInt(max) && max >= 2 && max <= 10)) {
739
+ issue(`${path}.props.max`, "Must be a whole number from 2 to 10.");
740
+ }
741
+ if (maxLength2 !== void 0 && !(isInt(maxLength2) && maxLength2 > 0)) {
742
+ issue(`${path}.props.maxLength`, "Must be a whole number above 0.");
743
+ }
708
744
  }
709
745
  if (validKey) {
710
746
  if (keys.has(block.key)) issue(`${path}.key`, `Duplicate key "${String(block.key)}".`);
@@ -721,6 +757,7 @@ function parseForm(input) {
721
757
  }
722
758
  });
723
759
  const hiddenNames = /* @__PURE__ */ new Set();
760
+ const hiddenTypes = /* @__PURE__ */ new Map();
724
761
  if (input.hidden !== void 0 && !Array.isArray(input.hidden)) issue("hidden", "Must be an array.");
725
762
  (Array.isArray(input.hidden) ? input.hidden : []).forEach((h, i) => {
726
763
  if (!isObject(h) || typeof h.name !== "string" || !h.name) {
@@ -736,7 +773,9 @@ function parseForm(input) {
736
773
  issue(`hidden[${i}].type`, "Must be string, number or boolean.");
737
774
  }
738
775
  optionalBoolean(h.sensitive, `hidden[${i}].sensitive`);
776
+ optionalBoolean(h.identifying, `hidden[${i}].identifying`);
739
777
  hiddenNames.add(h.name);
778
+ if (typeof h.type === "string") hiddenTypes.set(h.name, h.type);
740
779
  });
741
780
  const endingIds = /* @__PURE__ */ new Set();
742
781
  if (input.endings !== void 0 && !Array.isArray(input.endings)) issue("endings", "Must be an array.");
@@ -785,8 +824,45 @@ function parseForm(input) {
785
824
  if (typeof c.left.field === "string") {
786
825
  const manifest = getFieldType(fieldTypes.get(c.left.field) ?? "");
787
826
  if (manifest && OPERATORS.has(c.op) && !manifest.operators.includes(c.op)) {
788
- issue(`${path}.op`, `"${c.op}" does not apply to a ${manifest.type} field.`);
827
+ return issue(`${path}.op`, `"${c.op}" does not apply to a ${manifest.type} field.`);
828
+ }
829
+ }
830
+ checkLiterals(c.op, c.left, c.right, path);
831
+ };
832
+ const checkLiterals = (op, left, right, path) => {
833
+ if (op === "answered" || op === "empty" || !OPERATORS.has(op)) return;
834
+ const literal = isObject(right) && "value" in right;
835
+ if (right !== void 0 && !literal) return;
836
+ let what = "the left side";
837
+ let type;
838
+ let options;
839
+ if (typeof left.field === "string" && fieldTypes.has(left.field)) {
840
+ const fieldType = fieldTypes.get(left.field);
841
+ what = `"${keyOf(left.field)}"`;
842
+ type = LITERAL_TYPE[fieldType];
843
+ if (fieldType === "single-choice" || fieldType === "multi-choice") options = choices.get(left.field);
844
+ } else if (typeof left.hidden === "string" && hiddenTypes.has(left.hidden)) {
845
+ what = `hidden input "${left.hidden}"`;
846
+ type = hiddenTypes.get(left.hidden);
847
+ }
848
+ const outcome = op === "neq" ? "always true" : "never true";
849
+ let values;
850
+ if (op === "in") {
851
+ if (!literal || !Array.isArray(right.value)) {
852
+ return void warnings.push({ path: `${path}.right`, message: "`in` needs a list of values; it never matches." });
853
+ }
854
+ values = right.value;
855
+ } else if (!literal) return;
856
+ else values = [right.value];
857
+ if (ORDERING.has(op)) {
858
+ if (type !== void 0 && type !== "number") {
859
+ return void warnings.push({ path: `${path}.op`, message: `"${op}" compares numbers, but ${what} holds a ${type}; it is never true.` });
789
860
  }
861
+ type = "number";
862
+ }
863
+ for (const v of values) {
864
+ const message = type !== void 0 && typeof v !== type ? `${what} holds a ${type}, but ${shown(v)} is not one; this comparison is ${outcome}.` : options && typeof v === "string" && !options.has(v) ? `${shown(v)} is not an option of ${what}; this comparison is ${outcome}.` : null;
865
+ if (message) warnings.push({ path: `${path}.right`, message });
790
866
  }
791
867
  };
792
868
  const keyOf = (id) => [...keys].find(([, v]) => v === id)?.[0] ?? id;
@@ -863,6 +939,18 @@ function parseForm(input) {
863
939
  }
864
940
  }
865
941
  });
942
+ const extra = [["description", input.description]];
943
+ (Array.isArray(input.endings) ? input.endings : []).forEach((e, i) => {
944
+ if (isObject(e)) extra.push([`endings[${i}]`, e.title], [`endings[${i}]`, e.description]);
945
+ });
946
+ for (const [path, t] of extra) {
947
+ if (typeof t !== "string") continue;
948
+ for (const ref of recallRefs(t)) {
949
+ if (ref.kind === "hidden" ? !hiddenNames.has(ref.name) : !keys.has(ref.name)) {
950
+ warnings.push({ path, message: `Recalls unknown ${ref.kind === "hidden" ? "hidden input" : "field"} "${ref.name}"; it shows its fallback text.` });
951
+ }
952
+ }
953
+ }
866
954
  return issues.length ? fail() : { ok: true, form: input, warnings };
867
955
  }
868
956
 
@@ -899,18 +987,16 @@ function validateSubmission(definition, submission, options = {}) {
899
987
  if (invalid) return { ok: false, errors: { "": invalid } };
900
988
  const input = wellFormed(submission);
901
989
  if (!input) return MALFORMED;
902
- const ignored = [];
990
+ const ignored = /* @__PURE__ */ new Set();
903
991
  const client = coerceHidden(definition, input.hidden, { sources: ["url", "host"] });
904
- for (const name of client.ignored) ignored.push(`hidden.${name}`);
992
+ for (const name of client.ignored) ignored.add(`hidden.${name}`);
905
993
  const hidden = client.values;
906
994
  const context = coerceHidden(definition, options.context, { sources: ["server"] }).values;
907
995
  const fields = fieldMap(definition);
908
- const dropped = /* @__PURE__ */ new Set();
909
996
  const accept = (id) => {
910
- if (!fields.has(id)) return void ignored.push(id);
997
+ if (!fields.has(id)) return void ignored.add(id);
911
998
  if (!withheld(definition, fields.get(id))) return true;
912
- if (!dropped.has(id)) ignored.push(id);
913
- dropped.add(id);
999
+ ignored.add(id);
914
1000
  return false;
915
1001
  };
916
1002
  const engineHidden = { ...hidden, ...context };
@@ -963,15 +1049,25 @@ function validateSubmission(definition, submission, options = {}) {
963
1049
  if (message === null) answers[id] = normalizeValue(value, field);
964
1050
  }
965
1051
  }
1052
+ const store = (values, report) => {
1053
+ if (definition.settings?.anonymous !== true) return values;
1054
+ const identifying = new Set((definition.hidden ?? []).filter((h) => h.identifying === true).map((h) => h.name));
1055
+ const kept = {};
1056
+ for (const [name, value] of Object.entries(values)) {
1057
+ if (!identifying.has(name)) kept[name] = value;
1058
+ else if (report) ignored.add(`hidden.${name}`);
1059
+ }
1060
+ return kept;
1061
+ };
966
1062
  return {
967
1063
  ok: true,
968
1064
  submission: {
969
1065
  status: complete ? "completed" : "draft",
970
1066
  answers,
971
- hidden,
972
- context,
1067
+ hidden: store(hidden, true),
1068
+ context: store(context, false),
973
1069
  ending: complete ? state.ending : null,
974
- ignored
1070
+ ignored: [...ignored]
975
1071
  }
976
1072
  };
977
1073
  }