@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/README.md +8 -4
- package/dist/{engine-LsPRTzt3.d.cts → engine-Bj8-mMVb.d.cts} +6 -6
- package/dist/{engine-Cp5t2_Am.d.ts → engine-Coc-gQIB.d.ts} +6 -6
- package/dist/index.cjs +95 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +30 -23
- package/dist/index.d.ts +30 -23
- package/dist/index.js +95 -5
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +108 -12
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +11 -11
- package/dist/server.d.ts +11 -11
- package/dist/server.js +108 -12
- package/dist/server.js.map +1 -1
- package/dist/testing.cjs +108 -12
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +3 -3
- package/dist/testing.d.ts +3 -3
- package/dist/testing.js +108 -12
- package/dist/testing.js.map +1 -1
- package/dist/{validate-j8Z188mF.d.cts → validate-BbackAjB.d.cts} +8 -1
- package/dist/{validate-j8Z188mF.d.ts → validate-BbackAjB.d.ts} +8 -1
- package/package.json +3 -3
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,
|
|
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.
|
|
56
|
-
marked `identifying` are
|
|
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-
|
|
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-
|
|
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" ||
|
|
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
|
|
1094
|
+
if (field2 && !isEmptyValue(value, field2)) out[id] = normalizeValue(value, field2);
|
|
1005
1095
|
}
|
|
1006
1096
|
return out;
|
|
1007
1097
|
}
|