@marktiderman/genesis-forms 0.1.1 → 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/README.md CHANGED
@@ -45,15 +45,19 @@ const version = afterClass.hash(); // content fingerprint for publishing
45
45
  `eq`, `in` and `contains` and passes `neq`: `eq null` is never true for a cleared field.
46
46
  - **Hidden inputs are typed and sourced.** `url` and `host` inputs are untrusted; `server` inputs are
47
47
  never read from the client. `coerceHidden(definition, raw)` types them, in `createForm` and on the
48
- server alike: only string, number and boolean primitives, `""` is not a number, strings at most
49
- 1000 characters.
48
+ server alike: only string, number and boolean primitives, a number string must be a plain decimal
49
+ (`"12"`, `"-2.5"`; not `""`, `" 12 "` or `"0x1A"`), strings at most 1000 characters.
50
+ - **Empty answers are not stored.** `engine.answers()` and the server leave out blank text, `""` and
51
+ empty lists; a required field with one still fails as unanswered.
50
52
  - **Everything is in the event log.** `initialValues` are logged as leading `answer` events with
51
53
  `source: "prefill"`, so `replay` and the server see them.
52
54
  - **Anonymous forms withhold identifying fields.** `settings.anonymous` is a per-form admin
53
55
  setting, off by default. When it is true, fields marked `identifying: true` are treated as hidden
54
56
  (off the path, not validated, cleared on submit), the server drops client-sent values for them and
55
- lists them in `ignored`, and events carry no `at` timestamps whatever clock is passed. Fields not
56
- marked `identifying` are unaffected.
57
+ lists them in `ignored`, and events carry no `at` timestamps whatever clock is passed. Hidden
58
+ inputs marked `identifying: true` (`f.hidden.string({ source: "server", identifying: true })`) are
59
+ left out of the stored `hidden` and `context`; logic still reads them. Fields not marked
60
+ `identifying` are unaffected.
57
61
  - **Definitions are checked.** `defineForm` runs `parseForm` and throws with every issue;
58
62
  `validateSubmission` refuses a definition that does not parse. Ids, keys, hidden names and ending
59
63
  ids may not be `Object.prototype` names (`constructor`, `__proto__`…).
@@ -1,4 +1,4 @@
1
- import { f as FormEvent, J as Json, e as FieldValidator, a as FormDefinition, F as FieldBlock, B as Block } from './validate-j8Z188mF.cjs';
1
+ import { f as FormEvent, J as Json, e as FieldValidator, a as FormDefinition, F as FieldBlock, B as Block } from './validate-BbackAjB.cjs';
2
2
 
3
3
  /** Guards and helpers for event logs and other untrusted JSON. No dependencies. */
4
4
 
@@ -36,12 +36,12 @@ interface FormState {
36
36
  }
37
37
  interface CreateFormOptions {
38
38
  /** Prefilled answers by field id. Logged as leading `answer` events with `source: "prefill"`. */
39
- initialValues?: Record<string, Json>;
39
+ initialValues?: Record<string, Json> | undefined;
40
40
  /** Hidden inputs by name. Declared ones are typed with `coerceHidden`, as the server types them. */
41
- hidden?: Record<string, Json | undefined>;
42
- validators?: Record<string, FieldValidator>;
41
+ hidden?: Record<string, Json | undefined> | undefined;
42
+ validators?: Record<string, FieldValidator> | undefined;
43
43
  /** Timestamps on events. Pass `() => undefined` to record none (anonymous forms). */
44
- clock?: () => number | undefined;
44
+ clock?: (() => number | undefined) | undefined;
45
45
  }
46
46
  interface FormEngine {
47
47
  readonly definition: FormDefinition;
@@ -65,7 +65,7 @@ interface FormEngine {
65
65
  submit(): boolean;
66
66
  /** Text with `{{recall}}` tokens resolved. */
67
67
  text(template: string): string;
68
- /** Visible field values only, keyed by id: what a submit stores. */
68
+ /** Visible, non-empty field values only, keyed by id: what a submit stores. */
69
69
  answers(): Record<string, Json>;
70
70
  }
71
71
  declare function createForm(definition: FormDefinition, options?: CreateFormOptions): FormEngine;
@@ -1,4 +1,4 @@
1
- import { f as FormEvent, J as Json, e as FieldValidator, a as FormDefinition, F as FieldBlock, B as Block } from './validate-j8Z188mF.js';
1
+ import { f as FormEvent, J as Json, e as FieldValidator, a as FormDefinition, F as FieldBlock, B as Block } from './validate-BbackAjB.js';
2
2
 
3
3
  /** Guards and helpers for event logs and other untrusted JSON. No dependencies. */
4
4
 
@@ -36,12 +36,12 @@ interface FormState {
36
36
  }
37
37
  interface CreateFormOptions {
38
38
  /** Prefilled answers by field id. Logged as leading `answer` events with `source: "prefill"`. */
39
- initialValues?: Record<string, Json>;
39
+ initialValues?: Record<string, Json> | undefined;
40
40
  /** Hidden inputs by name. Declared ones are typed with `coerceHidden`, as the server types them. */
41
- hidden?: Record<string, Json | undefined>;
42
- validators?: Record<string, FieldValidator>;
41
+ hidden?: Record<string, Json | undefined> | undefined;
42
+ validators?: Record<string, FieldValidator> | undefined;
43
43
  /** Timestamps on events. Pass `() => undefined` to record none (anonymous forms). */
44
- clock?: () => number | undefined;
44
+ clock?: (() => number | undefined) | undefined;
45
45
  }
46
46
  interface FormEngine {
47
47
  readonly definition: FormDefinition;
@@ -65,7 +65,7 @@ interface FormEngine {
65
65
  submit(): boolean;
66
66
  /** Text with `{{recall}}` tokens resolved. */
67
67
  text(template: string): string;
68
- /** Visible field values only, keyed by id: what a submit stores. */
68
+ /** Visible, non-empty field values only, keyed by id: what a submit stores. */
69
69
  answers(): Record<string, Json>;
70
70
  }
71
71
  declare function createForm(definition: FormDefinition, options?: CreateFormOptions): FormEngine;
package/dist/index.cjs CHANGED
@@ -399,7 +399,30 @@ function recallRefs(text) {
399
399
  }
400
400
 
401
401
  // src/parse.ts
402
+ function shown(v) {
403
+ let text;
404
+ try {
405
+ text = JSON.stringify(v);
406
+ } catch {
407
+ text = void 0;
408
+ }
409
+ text ??= typeof v === "bigint" ? `${v}n` : typeof v;
410
+ return text.length > 60 ? `${text.slice(0, 57)}...` : text;
411
+ }
402
412
  var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
413
+ var isInt = (v) => typeof v === "number" && Number.isInteger(v);
414
+ var LITERAL_TYPE = {
415
+ "short-text": "string",
416
+ "long-text": "string",
417
+ email: "string",
418
+ phone: "string",
419
+ rating: "number",
420
+ nps: "number",
421
+ "single-choice": "string",
422
+ "multi-choice": "string",
423
+ "yes-no": "boolean"
424
+ };
425
+ var ORDERING = /* @__PURE__ */ new Set(["lt", "lte", "gt", "gte"]);
403
426
  var OPERATORS = /* @__PURE__ */ new Set([
404
427
  "eq",
405
428
  "neq",
@@ -472,6 +495,7 @@ function parseForm(input) {
472
495
  const keys = /* @__PURE__ */ new Map();
473
496
  const fieldTypes = /* @__PURE__ */ new Map();
474
497
  const order = /* @__PURE__ */ new Map();
498
+ const choices = /* @__PURE__ */ new Map();
475
499
  input.blocks.forEach((block, i) => {
476
500
  const path = `blocks[${i}]`;
477
501
  if (!isObject(block) || typeof block.id !== "string" || !block.id) {
@@ -505,8 +529,19 @@ function parseForm(input) {
505
529
  if (!isObject(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
506
530
  else optionalText(o.label, `${at}.label`);
507
531
  });
532
+ choices.set(
533
+ block.id,
534
+ new Set(options.flatMap((o) => isObject(o) && typeof o.value === "string" ? [o.value] : []))
535
+ );
508
536
  }
509
537
  }
538
+ const { max, maxLength: maxLength2 } = isObject(block.props) ? block.props : {};
539
+ if (block.type === "rating" && max !== void 0 && !(isInt(max) && max >= 2 && max <= 10)) {
540
+ issue(`${path}.props.max`, "Must be a whole number from 2 to 10.");
541
+ }
542
+ if (maxLength2 !== void 0 && !(isInt(maxLength2) && maxLength2 > 0)) {
543
+ issue(`${path}.props.maxLength`, "Must be a whole number above 0.");
544
+ }
510
545
  }
511
546
  if (validKey) {
512
547
  if (keys.has(block.key)) issue(`${path}.key`, `Duplicate key "${String(block.key)}".`);
@@ -523,6 +558,7 @@ function parseForm(input) {
523
558
  }
524
559
  });
525
560
  const hiddenNames = /* @__PURE__ */ new Set();
561
+ const hiddenTypes = /* @__PURE__ */ new Map();
526
562
  if (input.hidden !== void 0 && !Array.isArray(input.hidden)) issue("hidden", "Must be an array.");
527
563
  (Array.isArray(input.hidden) ? input.hidden : []).forEach((h, i) => {
528
564
  if (!isObject(h) || typeof h.name !== "string" || !h.name) {
@@ -538,7 +574,9 @@ function parseForm(input) {
538
574
  issue(`hidden[${i}].type`, "Must be string, number or boolean.");
539
575
  }
540
576
  optionalBoolean(h.sensitive, `hidden[${i}].sensitive`);
577
+ optionalBoolean(h.identifying, `hidden[${i}].identifying`);
541
578
  hiddenNames.add(h.name);
579
+ if (typeof h.type === "string") hiddenTypes.set(h.name, h.type);
542
580
  });
543
581
  const endingIds = /* @__PURE__ */ new Set();
544
582
  if (input.endings !== void 0 && !Array.isArray(input.endings)) issue("endings", "Must be an array.");
@@ -587,8 +625,45 @@ function parseForm(input) {
587
625
  if (typeof c.left.field === "string") {
588
626
  const manifest = getFieldType(fieldTypes.get(c.left.field) ?? "");
589
627
  if (manifest && OPERATORS.has(c.op) && !manifest.operators.includes(c.op)) {
590
- issue(`${path}.op`, `"${c.op}" does not apply to a ${manifest.type} field.`);
628
+ return issue(`${path}.op`, `"${c.op}" does not apply to a ${manifest.type} field.`);
629
+ }
630
+ }
631
+ checkLiterals(c.op, c.left, c.right, path);
632
+ };
633
+ const checkLiterals = (op, left, right, path) => {
634
+ if (op === "answered" || op === "empty" || !OPERATORS.has(op)) return;
635
+ const literal = isObject(right) && "value" in right;
636
+ if (right !== void 0 && !literal) return;
637
+ let what = "the left side";
638
+ let type;
639
+ let options;
640
+ if (typeof left.field === "string" && fieldTypes.has(left.field)) {
641
+ const fieldType = fieldTypes.get(left.field);
642
+ what = `"${keyOf(left.field)}"`;
643
+ type = LITERAL_TYPE[fieldType];
644
+ if (fieldType === "single-choice" || fieldType === "multi-choice") options = choices.get(left.field);
645
+ } else if (typeof left.hidden === "string" && hiddenTypes.has(left.hidden)) {
646
+ what = `hidden input "${left.hidden}"`;
647
+ type = hiddenTypes.get(left.hidden);
648
+ }
649
+ const outcome = op === "neq" ? "always true" : "never true";
650
+ let values;
651
+ if (op === "in") {
652
+ if (!literal || !Array.isArray(right.value)) {
653
+ return void warnings.push({ path: `${path}.right`, message: "`in` needs a list of values; it never matches." });
654
+ }
655
+ values = right.value;
656
+ } else if (!literal) return;
657
+ else values = [right.value];
658
+ if (ORDERING.has(op)) {
659
+ if (type !== void 0 && type !== "number") {
660
+ return void warnings.push({ path: `${path}.op`, message: `"${op}" compares numbers, but ${what} holds a ${type}; it is never true.` });
591
661
  }
662
+ type = "number";
663
+ }
664
+ for (const v of values) {
665
+ 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;
666
+ if (message) warnings.push({ path: `${path}.right`, message });
592
667
  }
593
668
  };
594
669
  const keyOf = (id) => [...keys].find(([, v]) => v === id)?.[0] ?? id;
@@ -665,6 +740,18 @@ function parseForm(input) {
665
740
  }
666
741
  }
667
742
  });
743
+ const extra = [["description", input.description]];
744
+ (Array.isArray(input.endings) ? input.endings : []).forEach((e, i) => {
745
+ if (isObject(e)) extra.push([`endings[${i}]`, e.title], [`endings[${i}]`, e.description]);
746
+ });
747
+ for (const [path, t] of extra) {
748
+ if (typeof t !== "string") continue;
749
+ for (const ref of recallRefs(t)) {
750
+ if (ref.kind === "hidden" ? !hiddenNames.has(ref.name) : !keys.has(ref.name)) {
751
+ warnings.push({ path, message: `Recalls unknown ${ref.kind === "hidden" ? "hidden input" : "field"} "${ref.name}"; it shows its fallback text.` });
752
+ }
753
+ }
754
+ }
668
755
  return issues.length ? fail() : { ok: true, form: input, warnings };
669
756
  }
670
757
 
@@ -691,7 +778,8 @@ var choiceProps = (options) => ({
691
778
  var hiddenSpec = (type, o) => ({
692
779
  type,
693
780
  source: o.source ?? "url",
694
- ...o.sensitive ? { sensitive: true } : {}
781
+ ...o.sensitive ? { sensitive: true } : {},
782
+ ...o.identifying ? { identifying: true } : {}
695
783
  });
696
784
  var f = {
697
785
  shortText: (key, o) => field("short-text", key, o, o.maxLength ? { maxLength: o.maxLength } : void 0),
@@ -786,7 +874,8 @@ function defineForm(input) {
786
874
  name,
787
875
  type: spec.type,
788
876
  source: spec.source,
789
- ...spec.sensitive ? { sensitive: true } : {}
877
+ ...spec.sensitive ? { sensitive: true } : {},
878
+ ...spec.identifying ? { identifying: true } : {}
790
879
  }));
791
880
  }
792
881
  if (input.logic?.length) definition.logic = input.logic.map((rule, i) => rule.build(resolve2, `r${i + 1}`));
@@ -811,6 +900,7 @@ ${list}`);
811
900
 
812
901
  // src/hidden.ts
813
902
  var MAX_HIDDEN_STRING_LENGTH = 1e3;
903
+ var DECIMAL = /^-?\d+(\.\d+)?$/;
814
904
  function coerceHiddenValue(value, type) {
815
905
  if (typeof value === "string" && value.length > MAX_HIDDEN_STRING_LENGTH) return void 0;
816
906
  if (type === "string") {
@@ -820,7 +910,7 @@ function coerceHiddenValue(value, type) {
820
910
  }
821
911
  if (type === "number") {
822
912
  if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
823
- if (typeof value !== "string" || value.trim() === "") return void 0;
913
+ if (typeof value !== "string" || !DECIMAL.test(value)) return void 0;
824
914
  const n = Number(value);
825
915
  return Number.isFinite(n) ? n : void 0;
826
916
  }
@@ -1001,7 +1091,7 @@ function build(definition, options, replaying = false) {
1001
1091
  for (const id of state.path) {
1002
1092
  const field2 = fields.get(id);
1003
1093
  const value = own(state.values, id);
1004
- if (field2 && value !== void 0) out[id] = normalizeValue(value, field2);
1094
+ if (field2 && !isEmptyValue(value, field2)) out[id] = normalizeValue(value, field2);
1005
1095
  }
1006
1096
  return out;
1007
1097
  }