@marktiderman/genesis-forms 0.3.1 → 0.4.1

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
@@ -1,3 +1,498 @@
1
+ // src/formula.ts
2
+ var FORMULA_FUNCTIONS = ["sum", "avg", "min", "max"];
3
+ var MAX_FORMULA_LENGTH = 2e3;
4
+ var MAX_DEPTH = 64;
5
+ var OP_ALIASES = { "\u2212": "-", "\xD7": "*", "\xF7": "/", "\u2260": "!=", "\u2264": "<=", "\u2265": ">=", "=": "==" };
6
+ var TWO_CHAR = /* @__PURE__ */ new Set(["==", "!=", "<=", ">="]);
7
+ var ONE_CHAR = /* @__PURE__ */ new Set(["+", "-", "*", "/", "<", ">", "(", ")", "?", ":", ",", ".", "=", "\u2212", "\xD7", "\xF7", "\u2260", "\u2264", "\u2265"]);
8
+ var COMPARISONS = /* @__PURE__ */ new Set(["==", "!=", "<", "<=", ">", ">="]);
9
+ var FormulaError = class extends Error {
10
+ constructor(message, at) {
11
+ super(message);
12
+ this.at = at;
13
+ }
14
+ at;
15
+ };
16
+ var isIdStart = (c) => c >= "a" && c <= "z" || c >= "A" && c <= "Z" || c === "_";
17
+ var isIdPart = (c) => isIdStart(c) || c >= "0" && c <= "9";
18
+ var isDigit = (c) => c >= "0" && c <= "9";
19
+ function tokenize(source) {
20
+ const tokens = [];
21
+ let i = 0;
22
+ while (i < source.length) {
23
+ const c = source[i];
24
+ if (c === " " || c === " " || c === "\n" || c === "\r") {
25
+ i++;
26
+ continue;
27
+ }
28
+ const at = i;
29
+ if (isDigit(c) || c === "." && isDigit(source[i + 1] ?? "")) {
30
+ while (isDigit(source[i] ?? "")) i++;
31
+ if (source[i] === "." && isDigit(source[i + 1] ?? "")) {
32
+ i++;
33
+ while (isDigit(source[i] ?? "")) i++;
34
+ }
35
+ if (isIdStart(source[i] ?? "")) throw new FormulaError("Write a number in digits only, like 1000000.", at);
36
+ const v = Number(source.slice(at, i));
37
+ if (!Number.isFinite(v)) throw new FormulaError("This number is too large.", at);
38
+ tokens.push({ t: "num", v, at });
39
+ continue;
40
+ }
41
+ if (isIdStart(c)) {
42
+ while (isIdPart(source[i] ?? "")) i++;
43
+ tokens.push({ t: "id", v: source.slice(at, i), at });
44
+ continue;
45
+ }
46
+ if (c === '"' || c === "'" || c === "\u201C") {
47
+ const close = c === "\u201C" ? "\u201D" : c;
48
+ const end = source.indexOf(close, i + 1);
49
+ if (end === -1) throw new FormulaError("This text has no closing quote.", at);
50
+ tokens.push({ t: "str", v: source.slice(i + 1, end), at });
51
+ i = end + 1;
52
+ continue;
53
+ }
54
+ const two = source.slice(i, i + 2);
55
+ if (TWO_CHAR.has(two)) {
56
+ tokens.push({ t: "op", v: two, at });
57
+ i += 2;
58
+ continue;
59
+ }
60
+ if (ONE_CHAR.has(c)) {
61
+ tokens.push({ t: "op", v: OP_ALIASES[c] ?? c, at });
62
+ i++;
63
+ continue;
64
+ }
65
+ throw new FormulaError(`"${c}" can't be used in a formula.`, at);
66
+ }
67
+ tokens.push({ t: "end", at: source.length });
68
+ return tokens;
69
+ }
70
+ function parseFormula(source) {
71
+ if (typeof source !== "string" || source.trim() === "") return { ok: false, message: "Write a formula.", at: 0 };
72
+ if (source.length > MAX_FORMULA_LENGTH) {
73
+ return { ok: false, message: `Keep the formula under ${MAX_FORMULA_LENGTH} characters.`, at: MAX_FORMULA_LENGTH };
74
+ }
75
+ let tokens;
76
+ try {
77
+ tokens = tokenize(source);
78
+ } catch (e) {
79
+ const err = e;
80
+ return { ok: false, message: err.message, at: err.at };
81
+ }
82
+ let pos = 0;
83
+ const refs = [];
84
+ const peek = () => tokens[pos];
85
+ const isOp = (v) => {
86
+ const tok = peek();
87
+ return tok.t === "op" && tok.v === v;
88
+ };
89
+ const expect = (v, message) => {
90
+ if (!isOp(v)) throw new FormulaError(message, peek().at);
91
+ pos++;
92
+ };
93
+ const describe = (tok) => tok.t === "end" ? "the formula ends there" : `found "${String(tok.v)}"`;
94
+ const guard = (depth) => {
95
+ if (depth > MAX_DEPTH) throw new FormulaError("The formula is nested too deeply.", peek().at);
96
+ };
97
+ const ternary = (depth) => {
98
+ guard(depth);
99
+ const test = comparison(depth);
100
+ if (!isOp("?")) return test;
101
+ pos++;
102
+ const then = ternary(depth + 1);
103
+ expect(":", "A `?` needs a `:` and the other value: `test ? a : b`.");
104
+ const otherwise = ternary(depth + 1);
105
+ return { kind: "if", test, then, else: otherwise };
106
+ };
107
+ const comparison = (depth) => {
108
+ const left = additive(depth);
109
+ const tok = peek();
110
+ if (tok.t !== "op" || !COMPARISONS.has(tok.v)) return left;
111
+ pos++;
112
+ const right = additive(depth);
113
+ const next = peek();
114
+ if (next.t === "op" && COMPARISONS.has(next.v)) {
115
+ throw new FormulaError("Compare two values at a time; combine comparisons with `? :`.", next.at);
116
+ }
117
+ return { kind: "binary", op: tok.v, left, right };
118
+ };
119
+ const additive = (depth) => {
120
+ let left = multiplicative(depth);
121
+ for (let tok = peek(); tok.t === "op" && (tok.v === "+" || tok.v === "-"); tok = peek()) {
122
+ pos++;
123
+ left = { kind: "binary", op: tok.v, left, right: multiplicative(depth) };
124
+ }
125
+ return left;
126
+ };
127
+ const multiplicative = (depth) => {
128
+ let left = unary(depth);
129
+ for (let tok = peek(); tok.t === "op" && (tok.v === "*" || tok.v === "/"); tok = peek()) {
130
+ pos++;
131
+ left = { kind: "binary", op: tok.v, left, right: unary(depth) };
132
+ }
133
+ return left;
134
+ };
135
+ const unary = (depth) => {
136
+ guard(depth);
137
+ if (isOp("-")) {
138
+ pos++;
139
+ return { kind: "unary", op: "-", arg: unary(depth + 1) };
140
+ }
141
+ if (isOp("+")) {
142
+ pos++;
143
+ return unary(depth + 1);
144
+ }
145
+ return primary(depth);
146
+ };
147
+ const primary = (depth) => {
148
+ const tok = peek();
149
+ if (tok.t === "num") {
150
+ pos++;
151
+ return { kind: "number", value: tok.v };
152
+ }
153
+ if (tok.t === "str") {
154
+ pos++;
155
+ return { kind: "text", value: tok.v };
156
+ }
157
+ if (tok.t === "op" && tok.v === "(") {
158
+ pos++;
159
+ const inner = ternary(depth + 1);
160
+ expect(")", "A `(` has no matching `)`.");
161
+ return inner;
162
+ }
163
+ if (tok.t === "id") {
164
+ pos++;
165
+ if (isOp("(")) {
166
+ if (!FORMULA_FUNCTIONS.includes(tok.v)) {
167
+ throw new FormulaError(`There is no function "${tok.v}"; use ${FORMULA_FUNCTIONS.join(", ")}.`, tok.at);
168
+ }
169
+ pos++;
170
+ const args = [];
171
+ if (!isOp(")")) {
172
+ args.push(ternary(depth + 1));
173
+ while (isOp(",")) {
174
+ pos++;
175
+ args.push(ternary(depth + 1));
176
+ }
177
+ }
178
+ expect(")", `"${tok.v}(" has no matching ")".`);
179
+ if (!args.length) throw new FormulaError(`${tok.v}() needs something to work on.`, tok.at);
180
+ return { kind: "call", fn: tok.v, args };
181
+ }
182
+ if (isOp(".")) {
183
+ pos++;
184
+ const column = peek();
185
+ if (column.t !== "id") throw new FormulaError(`Name a column after "${tok.v}.".`, column.at);
186
+ pos++;
187
+ refs.push({ key: tok.v, column: column.v });
188
+ return { kind: "ref", key: tok.v, column: column.v, at: tok.at };
189
+ }
190
+ refs.push({ key: tok.v });
191
+ return { kind: "ref", key: tok.v, at: tok.at };
192
+ }
193
+ throw new FormulaError(`Expected a number, an answer or "(", but ${describe(tok)}.`, tok.at);
194
+ };
195
+ try {
196
+ const ast = ternary(0);
197
+ const rest = peek();
198
+ if (rest.t !== "end") throw new FormulaError(`Expected an operator such as + or *, but ${describe(rest)}.`, rest.at);
199
+ return { ok: true, ast, refs };
200
+ } catch (e) {
201
+ if (e instanceof FormulaError) return { ok: false, message: e.message, at: e.at };
202
+ return { ok: false, message: "The formula is nested too deeply.", at: 0 };
203
+ }
204
+ }
205
+ var NUMERIC = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/;
206
+ function formulaNumber(v) {
207
+ if (typeof v === "number") return Number.isFinite(v) ? v : null;
208
+ if (typeof v === "boolean") return v ? 1 : 0;
209
+ if (typeof v === "string") {
210
+ const t = v.trim().replace(/,/g, "");
211
+ return t.length <= 64 && NUMERIC.test(t) ? finite(Number(t)) : null;
212
+ }
213
+ return null;
214
+ }
215
+ var finite = (n) => Number.isFinite(n) ? n : null;
216
+ var scalar = (v) => Array.isArray(v) ? null : v;
217
+ function truthy(v) {
218
+ if (v === null) return false;
219
+ if (typeof v === "boolean") return v;
220
+ if (typeof v === "number") return v !== 0;
221
+ if (typeof v === "string") return v.trim() !== "";
222
+ return v.length > 0;
223
+ }
224
+ function numbersIn(values, out = []) {
225
+ for (const v of values) {
226
+ if (Array.isArray(v)) numbersIn(v, out);
227
+ else {
228
+ const n = formulaNumber(v);
229
+ if (n !== null) out.push(n);
230
+ }
231
+ }
232
+ return out;
233
+ }
234
+ var listTotals = /* @__PURE__ */ new WeakMap();
235
+ var ownLists = /* @__PURE__ */ new WeakSet();
236
+ function ownFormulaList(list) {
237
+ Object.freeze(list);
238
+ ownLists.add(list);
239
+ return list;
240
+ }
241
+ function totalsOf(list) {
242
+ const own2 = ownLists.has(list);
243
+ let t = own2 ? listTotals.get(list) : void 0;
244
+ if (t) return t;
245
+ const nums = numbersIn(list);
246
+ t = { sum: 0, count: nums.length, min: Infinity, max: -Infinity };
247
+ for (const n of nums) {
248
+ t.sum += n;
249
+ if (n < t.min) t.min = n;
250
+ if (n > t.max) t.max = n;
251
+ }
252
+ if (own2) listTotals.set(list, t);
253
+ return t;
254
+ }
255
+ function compare(op, a, b) {
256
+ const blank2 = (v) => v === null || Array.isArray(v) || typeof v === "string" && v.trim() === "";
257
+ if (blank2(a) || blank2(b)) return op === "!=";
258
+ const x = formulaNumber(a);
259
+ const y = formulaNumber(b);
260
+ if (x !== null && y !== null) {
261
+ if (op === "==") return x === y;
262
+ if (op === "!=") return x !== y;
263
+ if (op === "<") return x < y;
264
+ if (op === "<=") return x <= y;
265
+ if (op === ">") return x > y;
266
+ return x >= y;
267
+ }
268
+ if (op === "==") return String(a) === String(b);
269
+ if (op === "!=") return String(a) !== String(b);
270
+ return false;
271
+ }
272
+ function evaluateFormula(ast, read) {
273
+ const run = (node) => {
274
+ switch (node.kind) {
275
+ case "number":
276
+ case "text":
277
+ return node.value;
278
+ case "ref":
279
+ return read(node);
280
+ case "unary": {
281
+ const n = formulaNumber(scalar(run(node.arg)));
282
+ return n === null ? null : -n;
283
+ }
284
+ case "if":
285
+ return truthy(run(node.test)) ? run(node.then) : run(node.else);
286
+ case "call": {
287
+ let total = 0;
288
+ let count = 0;
289
+ let min = Infinity;
290
+ let max = -Infinity;
291
+ for (const arg of node.args) {
292
+ const v = run(arg);
293
+ if (Array.isArray(v)) {
294
+ const t = totalsOf(v);
295
+ total += t.sum;
296
+ count += t.count;
297
+ if (t.min < min) min = t.min;
298
+ if (t.max > max) max = t.max;
299
+ continue;
300
+ }
301
+ const n = formulaNumber(v);
302
+ if (n === null) continue;
303
+ total += n;
304
+ count++;
305
+ if (n < min) min = n;
306
+ if (n > max) max = n;
307
+ }
308
+ if (node.fn === "sum") return finite(total);
309
+ if (!count) return null;
310
+ if (node.fn === "avg") return finite(total / count);
311
+ return node.fn === "min" ? min : max;
312
+ }
313
+ case "binary": {
314
+ const a = run(node.left);
315
+ const b = run(node.right);
316
+ if (node.op === "+" || node.op === "-" || node.op === "*" || node.op === "/") {
317
+ const x = formulaNumber(scalar(a));
318
+ const y = formulaNumber(scalar(b));
319
+ if (x === null || y === null) return null;
320
+ if (node.op === "+") return finite(x + y);
321
+ if (node.op === "-") return finite(x - y);
322
+ if (node.op === "*") return finite(x * y);
323
+ return y === 0 ? null : finite(x / y);
324
+ }
325
+ return compare(node.op, a, b);
326
+ }
327
+ }
328
+ };
329
+ try {
330
+ return run(ast);
331
+ } catch {
332
+ return null;
333
+ }
334
+ }
335
+
336
+ // src/calculated.ts
337
+ var CALCULATED_TYPE = "calculated";
338
+ var MAX_FORM_FORMULA_LENGTH = 2e4;
339
+ var MAX_CALCULATED_DECIMALS = 10;
340
+ function calculatedProps(field) {
341
+ const p = field.props ?? {};
342
+ const out = { formula: typeof p.formula === "string" ? p.formula : "" };
343
+ if (p.format === "number" || p.format === "percent" || p.format === "text") out.format = p.format;
344
+ if (typeof p.decimals === "number" && Number.isInteger(p.decimals) && p.decimals >= 0 && p.decimals <= MAX_CALCULATED_DECIMALS) {
345
+ out.decimals = p.decimals;
346
+ }
347
+ return out;
348
+ }
349
+ var isCalculated = (field) => field?.type === CALCULATED_TYPE;
350
+ var round = (n, decimals) => {
351
+ if (decimals === void 0) return n;
352
+ const r = Number(n.toFixed(decimals));
353
+ return Object.is(r, -0) ? 0 : r;
354
+ };
355
+ function calculatedValue(result, field) {
356
+ const { format, decimals } = calculatedProps(field);
357
+ if (result === null || Array.isArray(result)) return void 0;
358
+ if (format === "number" || format === "percent") {
359
+ const n = formulaNumber(result);
360
+ return n === null ? void 0 : round(n, decimals);
361
+ }
362
+ if (format === "text") {
363
+ if (typeof result === "number") return decimals === void 0 ? String(result) : result.toFixed(decimals);
364
+ return typeof result === "boolean" ? result ? "Yes" : "No" : result;
365
+ }
366
+ return typeof result === "number" ? round(result, decimals) : result;
367
+ }
368
+ function formatCalculated(value, field) {
369
+ if (value === void 0 || value === null) return "";
370
+ const { format, decimals } = calculatedProps(field);
371
+ const fixed = (n) => decimals === void 0 ? String(Number(n.toFixed(6))) : n.toFixed(decimals);
372
+ if (typeof value === "number") return format === "percent" ? `${fixed(value * 100)}%` : fixed(value);
373
+ if (typeof value === "boolean") return value ? "Yes" : "No";
374
+ return typeof value === "string" ? value : "";
375
+ }
376
+ var MAX_FORMULA_ITEMS = 1e4;
377
+ var MAX_SETTLE_ITEMS = 5e4;
378
+ var extract = (value, column) => column !== void 0 ? value.flatMap((row) => {
379
+ if (!row || typeof row !== "object" || Array.isArray(row)) return [];
380
+ const cell = row[column];
381
+ return typeof cell === "string" || typeof cell === "number" ? [cell] : [];
382
+ }) : value.flatMap((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? [v] : []);
383
+ var extracted = /* @__PURE__ */ new WeakMap();
384
+ function cachedList(value, column) {
385
+ let byColumn = extracted.get(value);
386
+ if (!byColumn) extracted.set(value, byColumn = /* @__PURE__ */ new Map());
387
+ const key = column ?? "";
388
+ let list = byColumn.get(key);
389
+ if (!list) byColumn.set(key, list = ownFormulaList(extract(value, column)));
390
+ return list;
391
+ }
392
+ function readAnswer(value, column) {
393
+ if (Array.isArray(value)) return cachedList(value, column);
394
+ return column !== void 0 ? null : formulaInput(value);
395
+ }
396
+ function formulaInput(value, column) {
397
+ if (value === void 0 || value === null) return null;
398
+ if (Array.isArray(value) && value.length > MAX_FORMULA_ITEMS) return null;
399
+ if (Array.isArray(value)) return extract(value, void 0);
400
+ return typeof value === "object" ? null : value;
401
+ }
402
+ function refNodes(node, out = []) {
403
+ if (node.kind === "ref") out.push(node);
404
+ else if (node.kind === "unary") refNodes(node.arg, out);
405
+ else if (node.kind === "binary") refNodes(node.left, out), refNodes(node.right, out);
406
+ else if (node.kind === "if") refNodes(node.test, out), refNodes(node.then, out), refNodes(node.else, out);
407
+ else if (node.kind === "call") for (const a of node.args) refNodes(a, out);
408
+ return out;
409
+ }
410
+ var UNREAD = /* @__PURE__ */ Symbol("unread");
411
+ var OVER = /* @__PURE__ */ Symbol("over");
412
+ function calculator(definition) {
413
+ const compiled = [];
414
+ const idByKey = /* @__PURE__ */ new Map();
415
+ for (const block of definition.blocks) {
416
+ if (block.kind !== "field") continue;
417
+ idByKey.set(block.key, block.id);
418
+ }
419
+ for (const block of definition.blocks) {
420
+ if (block.kind !== "field" || !isCalculated(block)) continue;
421
+ const parsed = parseFormula(calculatedProps(block).formula);
422
+ const reads = [];
423
+ const slots = /* @__PURE__ */ new Map();
424
+ const byKey = /* @__PURE__ */ new Map();
425
+ for (const ref of parsed.ok ? refNodes(parsed.ast) : []) {
426
+ const id = idByKey.get(ref.key);
427
+ const key = `${id ?? ""}\0${ref.column ?? ""}`;
428
+ let slot = byKey.get(key);
429
+ if (slot === void 0) {
430
+ slot = reads.push({ id, column: ref.column, key }) - 1;
431
+ byKey.set(key, slot);
432
+ }
433
+ slots.set(ref, slot);
434
+ }
435
+ compiled.push({ field: block, ast: parsed.ok ? parsed.ast : null, reads, slots, last: null });
436
+ }
437
+ const withCalculated = (values, path) => {
438
+ if (!compiled.length) return values;
439
+ const visible = new Set(path);
440
+ let next = values;
441
+ let budget = MAX_SETTLE_ITEMS;
442
+ const within = /* @__PURE__ */ new Map();
443
+ const input = ({ id, key }) => {
444
+ if (id === void 0 || !visible.has(id) || !Object.prototype.hasOwnProperty.call(next, id)) return UNREAD;
445
+ const value = next[id];
446
+ if (!Array.isArray(value)) return value;
447
+ let ok = within.get(key);
448
+ if (ok === void 0) {
449
+ ok = value.length <= MAX_FORMULA_ITEMS && value.length <= budget;
450
+ if (ok) budget -= value.length;
451
+ within.set(key, ok);
452
+ }
453
+ return ok ? value : OVER;
454
+ };
455
+ for (const entry of compiled) {
456
+ const { field, ast } = entry;
457
+ const inputs = entry.reads.map(input);
458
+ let value;
459
+ if (entry.last && entry.last.inputs.length === inputs.length && entry.last.inputs.every((v, i) => v === inputs[i])) {
460
+ value = entry.last.value;
461
+ } else {
462
+ const resolved = new Array(inputs.length);
463
+ const read = (ref) => {
464
+ const slot = entry.slots.get(ref);
465
+ if (slot === void 0) return null;
466
+ let v = resolved[slot];
467
+ if (v === void 0) {
468
+ const raw = inputs[slot];
469
+ v = resolved[slot] = raw === UNREAD ? null : readAnswer(raw, entry.reads[slot].column);
470
+ }
471
+ return v;
472
+ };
473
+ value = ast && !inputs.includes(OVER) ? calculatedValue(evaluateFormula(ast, read), field) : void 0;
474
+ entry.last = { inputs, value };
475
+ }
476
+ const current = Object.prototype.hasOwnProperty.call(next, field.id) ? next[field.id] : void 0;
477
+ if (current !== value) next = { ...next, [field.id]: value };
478
+ }
479
+ return next;
480
+ };
481
+ return { active: compiled.length > 0, withCalculated };
482
+ }
483
+ var numberOps = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
484
+ var calculatedFieldType = {
485
+ type: CALCULATED_TYPE,
486
+ label: "Calculated",
487
+ category: "advanced",
488
+ value: { json: true },
489
+ operators: numberOps,
490
+ a11y: { semantics: "control" },
491
+ // The engine works the value out; any number, text or true / false it made is valid.
492
+ validate: (value) => typeof value === "number" || typeof value === "string" || typeof value === "boolean" ? null : "This value is worked out from other answers.",
493
+ summarize: (value, field) => formatCalculated(value, field)
494
+ };
495
+
1
496
  // src/structured.ts
2
497
  var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
3
498
  function intProp(field, name, fallback, floor = 1) {
@@ -231,7 +726,7 @@ function intRange(field, min, maxDefault) {
231
726
  const max = typeof field.props?.max === "number" ? field.props.max : maxDefault;
232
727
  return { min, max };
233
728
  }
234
- var numberOps = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
729
+ var numberOps2 = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
235
730
  var textOps = ["eq", "neq", "contains", "answered", "empty"];
236
731
  function maxLength(field, fallback) {
237
732
  return typeof field.props?.maxLength === "number" ? field.props.maxLength : fallback;
@@ -309,23 +804,30 @@ var builtins = [
309
804
  label: "Rating",
310
805
  category: "scale",
311
806
  value: { json: true },
312
- operators: numberOps,
807
+ operators: numberOps2,
313
808
  a11y: { semantics: "group" },
314
809
  validate: (value, field) => {
315
810
  const { min, max } = intRange(field, 1, 5);
316
811
  return Number.isInteger(value) && value >= min && value <= max ? null : `Choose a rating from ${min} to ${max}.`;
317
812
  },
318
- summarize: (value, field) => `${value}/${intRange(field, 1, 5).max}`
813
+ summarize: (value, field) => {
814
+ const { max } = intRange(field, 1, 5);
815
+ const end = scaleEndLabel(field, value, 1, max);
816
+ return end ? `${value}/${max} (${end})` : `${value}/${max}`;
817
+ }
319
818
  },
320
819
  {
321
820
  type: "nps",
322
821
  label: "Net Promoter Score",
323
822
  category: "scale",
324
823
  value: { json: true },
325
- operators: numberOps,
824
+ operators: numberOps2,
326
825
  a11y: { semantics: "group" },
327
826
  validate: (value) => Number.isInteger(value) && value >= 0 && value <= 10 ? null : "Choose a score from 0 to 10.",
328
- summarize: (value) => `${value}/10`
827
+ summarize: (value, field) => {
828
+ const end = scaleEndLabel(field, value, 0, 10);
829
+ return end ? `${value}/10 (${end})` : `${value}/10`;
830
+ }
329
831
  },
330
832
  {
331
833
  type: "single-choice",
@@ -359,9 +861,122 @@ var builtins = [
359
861
  a11y: { semantics: "group" },
360
862
  validate: (value) => typeof value === "boolean" ? null : "Choose yes or no.",
361
863
  summarize: (value) => value ? "Yes" : "No"
864
+ },
865
+ {
866
+ type: "dropdown",
867
+ label: "Dropdown",
868
+ category: "choice",
869
+ value: { json: true },
870
+ operators: ["eq", "neq", "in", "answered", "empty"],
871
+ a11y: { semantics: "control" },
872
+ validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
873
+ summarize: (value, field) => optionLabel(field, String(value))
874
+ },
875
+ {
876
+ type: "number",
877
+ label: "Number",
878
+ category: "text",
879
+ value: { json: true },
880
+ operators: numberOps2,
881
+ a11y: { semantics: "control", inputMode: "decimal" },
882
+ validate: (value, field) => {
883
+ if (typeof value !== "number" || !Number.isFinite(value)) return "Enter a number.";
884
+ const { min, max, step } = numberLimits(field);
885
+ if (min !== void 0 && max !== void 0 && (value < min || value > max)) {
886
+ return `Enter a number from ${min} to ${max}.`;
887
+ }
888
+ if (min !== void 0 && value < min) return `Enter ${min} or more.`;
889
+ if (max !== void 0 && value > max) return `Enter ${max} or less.`;
890
+ if (step !== void 0 && !onStep(value, min ?? 0, step)) {
891
+ return step === 1 && Number.isInteger(min ?? 0) ? "Enter a whole number." : `Enter a number in steps of ${step}.`;
892
+ }
893
+ return null;
894
+ },
895
+ summarize: (value) => String(value),
896
+ samples: (field) => {
897
+ const { min, max } = numberLimits(field);
898
+ return [min, max, 0, 1].filter((v) => v !== void 0);
899
+ }
900
+ },
901
+ {
902
+ type: "date",
903
+ label: "Date",
904
+ category: "date",
905
+ value: { json: true },
906
+ operators: ["eq", "neq", "answered", "empty"],
907
+ a11y: { semantics: "control" },
908
+ validate: (value, field) => {
909
+ if (typeof value !== "string" || !isIsoDate(value.trim())) return "Enter a date like 2026-10-22.";
910
+ const date = value.trim();
911
+ const { min, max } = dateLimits(field);
912
+ if (min && max && (date < min || date > max)) return `Choose a date from ${min} to ${max}.`;
913
+ if (min && date < min) return `Choose ${min} or later.`;
914
+ if (max && date > max) return `Choose ${max} or earlier.`;
915
+ return null;
916
+ },
917
+ normalize: (value) => typeof value === "string" ? value.trim() : value,
918
+ samples: (field) => {
919
+ const { min, max } = dateLimits(field);
920
+ return [min ?? max ?? "2000-01-01"];
921
+ }
922
+ },
923
+ {
924
+ type: "consent",
925
+ label: "Consent checkbox",
926
+ category: "choice",
927
+ value: { json: true },
928
+ operators: ["answered", "empty"],
929
+ a11y: { semantics: "group" },
930
+ // Unchecked is unanswered, so a required consent must be checked.
931
+ isEmpty: (value) => value === void 0 || value === null || value === false,
932
+ validate: (value) => value === true ? null : "Check the box to agree.",
933
+ summarize: () => "Agreed",
934
+ samples: () => [true]
362
935
  }
363
936
  ];
364
- for (const manifest of [...builtins, ...structuredFieldTypes]) registerFieldType(manifest);
937
+ function scaleLabels(field) {
938
+ const out = {};
939
+ const low = field.props?.lowLabel;
940
+ const high = field.props?.highLabel;
941
+ if (typeof low === "string" && low.trim()) out.low = low.trim();
942
+ if (typeof high === "string" && high.trim()) out.high = high.trim();
943
+ return out;
944
+ }
945
+ function scaleEndLabel(field, value, min, max) {
946
+ const { low, high } = scaleLabels(field);
947
+ return value === min ? low : value === max ? high : void 0;
948
+ }
949
+ var finite2 = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
950
+ function numberLimits(field) {
951
+ const out = {};
952
+ const min = finite2(field.props?.min);
953
+ const max = finite2(field.props?.max);
954
+ const step = finite2(field.props?.step);
955
+ if (min !== void 0) out.min = min;
956
+ if (max !== void 0) out.max = max;
957
+ if (step !== void 0 && step > 0) out.step = step;
958
+ return out;
959
+ }
960
+ function onStep(value, base, step) {
961
+ const n = (value - base) / step;
962
+ return Math.abs(n - Math.round(n)) < 1e-9 * Math.max(1, Math.abs(n));
963
+ }
964
+ function isIsoDate(value) {
965
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
966
+ const [y, m, d] = value.split("-").map(Number);
967
+ if (y < 1 || m < 1 || m > 12 || d < 1) return false;
968
+ const days = [31, y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
969
+ return d <= days[m - 1];
970
+ }
971
+ function dateLimits(field) {
972
+ const out = {};
973
+ const min = field.props?.min;
974
+ const max = field.props?.max;
975
+ if (typeof min === "string" && isIsoDate(min)) out.min = min;
976
+ if (typeof max === "string" && isIsoDate(max)) out.max = max;
977
+ return out;
978
+ }
979
+ for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType]) registerFieldType(manifest);
365
980
 
366
981
  // src/events.ts
367
982
  function isPlainObject(value) {
@@ -606,7 +1221,7 @@ function runCustom(validator, value) {
606
1221
  }
607
1222
  function validateField(field, value, options = {}) {
608
1223
  if (isEmptyValue(value, field)) {
609
- return field.required && options.requireAnswers !== false ? REQUIRED_MESSAGE : null;
1224
+ return field.required && field.type !== CALCULATED_TYPE && options.requireAnswers !== false ? REQUIRED_MESSAGE : null;
610
1225
  }
611
1226
  const manifest = getFieldType(field.type);
612
1227
  if (!manifest) return `Unknown field type "${field.type}".`;
@@ -637,9 +1252,21 @@ function build(definition, options, replaying = false) {
637
1252
  if (!declared.has(name)) initialHidden[name] = value;
638
1253
  }
639
1254
  Object.assign(initialHidden, coerceHidden(definition, options.hidden).values);
640
- const first = computePath(definition, scopeOf({}, initialHidden));
1255
+ const calc = calculator(definition);
1256
+ const settle = (values, hidden) => {
1257
+ let { path } = computePath(definition, scopeOf(values, hidden));
1258
+ if (!calc.active) return { values, path };
1259
+ for (let round2 = 0; round2 < 4; round2++) {
1260
+ const next = calc.withCalculated(values, path);
1261
+ if (next === values) break;
1262
+ values = next;
1263
+ path = computePath(definition, scopeOf(values, hidden)).path;
1264
+ }
1265
+ return { values, path };
1266
+ };
1267
+ const first = settle({}, initialHidden);
641
1268
  let state = {
642
- values: {},
1269
+ values: first.values,
643
1270
  hidden: initialHidden,
644
1271
  path: first.path,
645
1272
  current: first.path[0] ?? null,
@@ -674,12 +1301,25 @@ function build(definition, options, replaying = false) {
674
1301
  }
675
1302
  return errors;
676
1303
  };
1304
+ let unsettled = false;
1305
+ const settleNow = () => {
1306
+ if (!unsettled) return;
1307
+ unsettled = false;
1308
+ const { values, path } = settle(state.values, state.hidden);
1309
+ state = { ...state, values, path };
1310
+ };
677
1311
  const answer = (id, value, event) => {
678
- if (state.status === "completed" || own(state.values, id) === value) return;
679
- const values = { ...state.values, [id]: value };
680
- const { path } = computePath(definition, scopeOf(values, state.hidden));
1312
+ if (state.status === "completed" || own(state.values, id) === value || isCalculated(fields.get(id))) return;
681
1313
  const errors = { ...state.errors };
682
1314
  delete errors[id];
1315
+ if (batch) {
1316
+ state.values = { ...state.values, [id]: value };
1317
+ state.errors = errors;
1318
+ log(event);
1319
+ unsettled = true;
1320
+ return;
1321
+ }
1322
+ const { values, path } = settle({ ...state.values, [id]: value }, state.hidden);
683
1323
  emit({ ...state, values, path, errors, events: log(event) });
684
1324
  };
685
1325
  const engine = {
@@ -751,10 +1391,14 @@ function build(definition, options, replaying = false) {
751
1391
  }
752
1392
  };
753
1393
  if (options.initialValues) {
1394
+ const wasBatch = batch;
1395
+ batch = true;
754
1396
  for (const [id, value] of Object.entries(options.initialValues)) {
755
1397
  if (!fields.has(id) || value === void 0) continue;
756
1398
  answer(id, value, stamp({ type: "answer", field: id, value, source: "prefill" }));
757
1399
  }
1400
+ settleNow();
1401
+ batch = wasBatch;
758
1402
  }
759
1403
  const apply = (event) => {
760
1404
  if (event.type === "answer") {
@@ -762,11 +1406,19 @@ function build(definition, options, replaying = false) {
762
1406
  const { at: _at, ...logged } = event;
763
1407
  answer(event.field, event.value, logged);
764
1408
  }
765
- } else if (event.type === "next") engine.next();
766
- else if (event.type === "back") engine.back();
767
- else if (event.type === "submit") engine.submit();
1409
+ } else if (event.type === "next") {
1410
+ settleNow();
1411
+ engine.next();
1412
+ } else if (event.type === "back") {
1413
+ settleNow();
1414
+ engine.back();
1415
+ } else if (event.type === "submit") {
1416
+ settleNow();
1417
+ engine.submit();
1418
+ }
768
1419
  };
769
1420
  const finish = () => {
1421
+ settleNow();
770
1422
  batch = false;
771
1423
  state = { ...state, events: [...state.events] };
772
1424
  };
@@ -802,8 +1454,15 @@ var LITERAL_TYPE = {
802
1454
  "single-choice": "string",
803
1455
  "multi-choice": "string",
804
1456
  "yes-no": "boolean",
805
- list: "string"
1457
+ list: "string",
1458
+ dropdown: "string",
1459
+ number: "number",
1460
+ date: "string",
1461
+ consent: "boolean",
1462
+ // A calculated field holds a number unless its format says text; see `literalType`.
1463
+ calculated: "number"
806
1464
  };
1465
+ var CHOICE_TYPES = /* @__PURE__ */ new Set(["single-choice", "multi-choice", "dropdown"]);
807
1466
  var ORDERING = /* @__PURE__ */ new Set(["lt", "lte", "gt", "gte"]);
808
1467
  var OPERATORS = /* @__PURE__ */ new Set([
809
1468
  "eq",
@@ -883,7 +1542,44 @@ function parseForm(input) {
883
1542
  } else if (type === "text-highlights") {
884
1543
  optionalText(props.text, `${path}.text`);
885
1544
  optionalInt(props.maxHighlights, `${path}.maxHighlights`, 1);
1545
+ } else if (type === CALCULATED_TYPE) {
1546
+ if (props.format !== void 0 && props.format !== "number" && props.format !== "percent" && props.format !== "text") {
1547
+ issue(`${path}.format`, "Must be number, percent or text.");
1548
+ }
1549
+ if (props.decimals !== void 0 && !(isInt(props.decimals) && props.decimals >= 0 && props.decimals <= MAX_CALCULATED_DECIMALS)) {
1550
+ issue(`${path}.decimals`, `Must be a whole number from 0 to ${MAX_CALCULATED_DECIMALS}.`);
1551
+ }
1552
+ }
1553
+ };
1554
+ const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
1555
+ const fieldProps = (type, props, path) => {
1556
+ if (type === "rating" || type === "nps") {
1557
+ optionalText(props.lowLabel, `${path}.lowLabel`);
1558
+ optionalText(props.highLabel, `${path}.highLabel`);
1559
+ }
1560
+ if (type === "rating" && props.display !== void 0 && props.display !== "numbers" && props.display !== "stars") {
1561
+ issue(`${path}.display`, "Must be numbers or stars.");
1562
+ }
1563
+ if (type === "number") {
1564
+ for (const k of ["min", "max", "step"]) {
1565
+ if (props[k] !== void 0 && !isFiniteNumber(props[k])) issue(`${path}.${k}`, "Must be a number.");
1566
+ }
1567
+ if (isFiniteNumber(props.step) && props.step <= 0) issue(`${path}.step`, "Must be above 0.");
1568
+ if (isFiniteNumber(props.min) && isFiniteNumber(props.max) && props.min > props.max) {
1569
+ issue(`${path}.min`, "Can't be more than the largest number.");
1570
+ }
1571
+ }
1572
+ if (type === "date") {
1573
+ for (const k of ["min", "max"]) {
1574
+ if (props[k] !== void 0 && !(typeof props[k] === "string" && isIsoDate(props[k]))) {
1575
+ issue(`${path}.${k}`, "Must be a date like 2026-10-22.");
1576
+ }
1577
+ }
1578
+ if (typeof props.min === "string" && typeof props.max === "string" && isIsoDate(props.min) && isIsoDate(props.max) && props.min > props.max) {
1579
+ issue(`${path}.min`, "Can't be after the latest date.");
1580
+ }
886
1581
  }
1582
+ if (type === "consent") optionalText(props.text, `${path}.text`);
887
1583
  };
888
1584
  if (!isObject2(input)) {
889
1585
  issue("", "A form definition must be an object.");
@@ -918,7 +1614,9 @@ function parseForm(input) {
918
1614
  const keys = /* @__PURE__ */ new Map();
919
1615
  const fieldTypes = /* @__PURE__ */ new Map();
920
1616
  const order = /* @__PURE__ */ new Map();
1617
+ const tableColumnKeys = /* @__PURE__ */ new Map();
921
1618
  const choices = /* @__PURE__ */ new Map();
1619
+ const calculatedLiteral = /* @__PURE__ */ new Map();
922
1620
  input.blocks.forEach((block, i) => {
923
1621
  const path = `blocks[${i}]`;
924
1622
  if (!isObject2(block) || typeof block.id !== "string" || !block.id) {
@@ -968,12 +1666,24 @@ function parseForm(input) {
968
1666
  }
969
1667
  if (typeof block.type === "string" && (block.props === void 0 || isObject2(block.props))) {
970
1668
  structuredProps(block.type, block.props ?? {}, `${path}.props`);
1669
+ fieldProps(block.type, block.props ?? {}, `${path}.props`);
971
1670
  }
972
1671
  if (validKey) {
973
1672
  if (keys.has(block.key)) issue(`${path}.key`, `Duplicate key "${String(block.key)}".`);
974
1673
  keys.set(block.key, block.id);
975
1674
  }
976
1675
  fieldTypes.set(block.id, typeof block.type === "string" ? block.type : "");
1676
+ if (block.type === CALCULATED_TYPE) {
1677
+ const format = isObject2(block.props) ? block.props.format : void 0;
1678
+ if (format === "text") calculatedLiteral.set(block.id, "string");
1679
+ else if (format !== "number" && format !== "percent") calculatedLiteral.set(block.id, void 0);
1680
+ }
1681
+ if (block.type === "table" && isObject2(block.props) && Array.isArray(block.props.columns)) {
1682
+ tableColumnKeys.set(
1683
+ block.id,
1684
+ new Set(block.props.columns.flatMap((c) => isObject2(c) && typeof c.key === "string" ? [c.key] : []))
1685
+ );
1686
+ }
977
1687
  } else if (block.kind === "content") {
978
1688
  if (block.type !== "heading" && block.type !== "text") issue(`${path}.type`, "Must be heading or text.");
979
1689
  text(block.text, `${path}.text`);
@@ -983,6 +1693,44 @@ function parseForm(input) {
983
1693
  issue(`${path}.kind`, "Unknown block kind.");
984
1694
  }
985
1695
  });
1696
+ let formulaChars = 0;
1697
+ input.blocks.forEach((block, i) => {
1698
+ if (!isObject2(block) || block.kind !== "field" || block.type !== CALCULATED_TYPE) return;
1699
+ const path = `blocks[${i}].props.formula`;
1700
+ const source = isObject2(block.props) && typeof block.props.formula === "string" ? block.props.formula : "";
1701
+ formulaChars += source.length;
1702
+ if (formulaChars > MAX_FORM_FORMULA_LENGTH && formulaChars - source.length <= MAX_FORM_FORMULA_LENGTH) {
1703
+ issue(path, `The form's formulas together are over ${MAX_FORM_FORMULA_LENGTH} characters; shorten some, or work part out once in an earlier calculated field.`);
1704
+ }
1705
+ if (block.required === true) {
1706
+ warnings.push({ path: `blocks[${i}].required`, message: "A calculated field is worked out, not answered, so it is never required." });
1707
+ }
1708
+ const formula = isObject2(block.props) ? block.props.formula : void 0;
1709
+ if (typeof formula !== "string" || !formula.trim()) return issue(path, "A calculated field needs a formula.");
1710
+ const parsed = parseFormula(formula);
1711
+ if (!parsed.ok) return issue(path, `${parsed.message} (at character ${parsed.at + 1})`);
1712
+ const reported = /* @__PURE__ */ new Set();
1713
+ for (const ref of parsed.refs) {
1714
+ const name2 = ref.column === void 0 ? ref.key : `${ref.key}.${ref.column}`;
1715
+ if (reported.has(name2)) continue;
1716
+ reported.add(name2);
1717
+ const target = keys.get(ref.key);
1718
+ const targetType = target === void 0 ? void 0 : fieldTypes.get(target);
1719
+ const at = target === void 0 ? void 0 : order.get(target);
1720
+ if (target === void 0 || targetType === void 0) issue(path, `The formula uses "${ref.key}", which is not the answer name of a question in this form.`);
1721
+ else if (target === block.id) issue(path, "The formula uses this field's own value.");
1722
+ else if (targetType === CALCULATED_TYPE && (at ?? 0) > i) {
1723
+ issue(path, `The formula uses "${ref.key}", a calculated field that comes after this one; move it earlier.`);
1724
+ } else if (targetType === "table") {
1725
+ const columns = tableColumnKeys.get(target);
1726
+ if (ref.column === void 0) issue(path, `"${ref.key}" is a table: name one of its columns, like ${ref.key}.${[...columns ?? []][0] ?? "column"}.`);
1727
+ else if (columns && !columns.has(ref.column)) issue(path, `The table "${ref.key}" has no column "${ref.column}".`);
1728
+ } else if (ref.column !== void 0) issue(path, `"${ref.key}" is not a table, so it has no column "${ref.column}".`);
1729
+ else if (targetType === "media-notes" || targetType === "text-highlights") {
1730
+ warnings.push({ path, message: `"${ref.key}" holds notes, not numbers; the formula reads it as blank.` });
1731
+ }
1732
+ }
1733
+ });
986
1734
  const hiddenNames = /* @__PURE__ */ new Set();
987
1735
  const hiddenTypes = /* @__PURE__ */ new Map();
988
1736
  if (input.hidden !== void 0 && !Array.isArray(input.hidden)) issue("hidden", "Must be an array.");
@@ -1066,8 +1814,8 @@ function parseForm(input) {
1066
1814
  if (typeof left.field === "string" && fieldTypes.has(left.field)) {
1067
1815
  const fieldType = fieldTypes.get(left.field);
1068
1816
  what = `"${keyOf(left.field)}"`;
1069
- type = LITERAL_TYPE[fieldType];
1070
- if (fieldType === "single-choice" || fieldType === "multi-choice") options = choices.get(left.field);
1817
+ type = calculatedLiteral.has(left.field) ? calculatedLiteral.get(left.field) : LITERAL_TYPE[fieldType];
1818
+ if (CHOICE_TYPES.has(fieldType)) options = choices.get(left.field);
1071
1819
  } else if (typeof left.hidden === "string" && hiddenTypes.has(left.hidden)) {
1072
1820
  what = `hidden input "${left.hidden}"`;
1073
1821
  type = hiddenTypes.get(left.hidden);
@@ -1222,6 +1970,7 @@ function validateSubmission(definition, submission, options = {}) {
1222
1970
  const fields = fieldMap(definition);
1223
1971
  const accept = (id) => {
1224
1972
  if (!fields.has(id)) return void ignored.add(id);
1973
+ if (isCalculated(fields.get(id))) return false;
1225
1974
  if (!withheld(definition, fields.get(id))) return true;
1226
1975
  ignored.add(id);
1227
1976
  return false;