@form-engine-ts/core 1.0.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/index.cjs ADDED
@@ -0,0 +1,790 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ aggregateResponses: () => aggregateResponses,
24
+ assertValidFormSchema: () => assertValidFormSchema,
25
+ calculateChoiceDistribution: () => calculateChoiceDistribution,
26
+ calculateFieldVisibility: () => calculateFieldVisibility,
27
+ calculateNumericSummary: () => calculateNumericSummary,
28
+ createSubmission: () => createSubmission,
29
+ escapeCsvCell: () => escapeCsvCell,
30
+ exportResponsesToCsv: () => exportResponsesToCsv,
31
+ isQuestionVisible: () => isQuestionVisible,
32
+ resolveFormTranslation: () => resolveFormTranslation,
33
+ sanitizeSchema: () => sanitizeSchema,
34
+ selectVisibleAnswers: () => selectVisibleAnswers,
35
+ validateAnswers: () => validateAnswers,
36
+ validateFormSchema: () => validateFormSchema,
37
+ validateSchemaStructure: () => validateSchemaStructure
38
+ });
39
+ module.exports = __toCommonJS(index_exports);
40
+
41
+ // src/sanitization.ts
42
+ function cyclicQuestionIds(fields) {
43
+ const firstById = /* @__PURE__ */ new Map();
44
+ for (const field of fields) {
45
+ if (!firstById.has(field.id)) firstById.set(field.id, field);
46
+ }
47
+ const cyclic = /* @__PURE__ */ new Set();
48
+ const resolved = /* @__PURE__ */ new Set();
49
+ for (const startId of firstById.keys()) {
50
+ if (resolved.has(startId)) continue;
51
+ const path = [];
52
+ const pathIndex = /* @__PURE__ */ new Map();
53
+ let currentId = startId;
54
+ while (currentId !== void 0 && firstById.has(currentId) && !resolved.has(currentId)) {
55
+ const existingIndex = pathIndex.get(currentId);
56
+ if (existingIndex !== void 0) {
57
+ for (const id of path.slice(existingIndex)) cyclic.add(id);
58
+ break;
59
+ }
60
+ pathIndex.set(currentId, path.length);
61
+ path.push(currentId);
62
+ const field = firstById.get(currentId);
63
+ const sourceId = field?.displayCondition?.questionId;
64
+ currentId = sourceId === currentId ? void 0 : sourceId;
65
+ }
66
+ for (const id of path) resolved.add(id);
67
+ }
68
+ return cyclic;
69
+ }
70
+ function validateSchemaStructure(schema) {
71
+ const issues = [];
72
+ const questionIds = /* @__PURE__ */ new Set();
73
+ for (const field of schema.fields) {
74
+ if (questionIds.has(field.id)) {
75
+ issues.push({
76
+ type: "duplicate_question_id",
77
+ questionId: field.id,
78
+ message: `Question ID "${field.id}" is duplicated.`
79
+ });
80
+ } else {
81
+ questionIds.add(field.id);
82
+ }
83
+ if (!("options" in field) || !Array.isArray(field.options)) continue;
84
+ const choiceIds = /* @__PURE__ */ new Set();
85
+ for (const option of field.options) {
86
+ if (choiceIds.has(option.id)) {
87
+ issues.push({
88
+ type: "duplicate_choice_id",
89
+ questionId: field.id,
90
+ choiceId: option.id,
91
+ message: `Choice ID "${option.id}" is duplicated in question "${field.id}".`
92
+ });
93
+ } else {
94
+ choiceIds.add(option.id);
95
+ }
96
+ }
97
+ }
98
+ for (const field of schema.fields) {
99
+ const sourceId = field.displayCondition?.questionId;
100
+ if (sourceId === void 0) continue;
101
+ if (sourceId === field.id) {
102
+ issues.push({
103
+ type: "self_condition_reference",
104
+ questionId: field.id,
105
+ message: `Question "${field.id}" cannot depend on itself.`
106
+ });
107
+ } else if (!questionIds.has(sourceId)) {
108
+ issues.push({
109
+ type: "dangling_condition_reference",
110
+ questionId: field.id,
111
+ message: `Question "${field.id}" references missing question "${sourceId}".`
112
+ });
113
+ }
114
+ }
115
+ const cyclic = cyclicQuestionIds(schema.fields);
116
+ for (const field of schema.fields) {
117
+ if (!cyclic.has(field.id)) continue;
118
+ issues.push({
119
+ type: "cyclic_condition_reference",
120
+ questionId: field.id,
121
+ message: `Question "${field.id}" participates in a display-condition cycle.`
122
+ });
123
+ }
124
+ return issues;
125
+ }
126
+ function sanitizeSchema(schema) {
127
+ const existingQuestionIds = new Set(schema.fields.map((field) => field.id));
128
+ const cyclic = cyclicQuestionIds(schema.fields);
129
+ return {
130
+ ...schema,
131
+ fields: schema.fields.map((field) => {
132
+ const sourceId = field.displayCondition?.questionId;
133
+ if (sourceId === void 0 || existingQuestionIds.has(sourceId) && sourceId !== field.id && !cyclic.has(field.id)) {
134
+ return field;
135
+ }
136
+ const { displayCondition: _displayCondition, ...sanitized } = field;
137
+ return sanitized;
138
+ })
139
+ };
140
+ }
141
+
142
+ // src/schema.ts
143
+ var FIELD_TYPES = /* @__PURE__ */ new Set(["text", "textarea", "number", "rating", "select", "multi-select", "checkbox", "radio"]);
144
+ var CONDITION_OPERATORS = /* @__PURE__ */ new Set(["equals", "not_equals", "contains", "not_empty"]);
145
+ function isRecord(value) {
146
+ return typeof value === "object" && value !== null && !Array.isArray(value);
147
+ }
148
+ function isNonEmptyString(value) {
149
+ return typeof value === "string" && value.trim().length > 0;
150
+ }
151
+ function issue(issues, path, code, message) {
152
+ issues.push({ path, code, message });
153
+ }
154
+ function rejectLegacyProperties(value, path, properties, issues) {
155
+ for (const property of properties) {
156
+ if (Object.hasOwn(value, property)) {
157
+ issue(
158
+ issues,
159
+ path.length === 0 ? property : `${path}.${property}`,
160
+ "legacy_property",
161
+ `${property} is not supported by the natural-language schema.`
162
+ );
163
+ }
164
+ }
165
+ }
166
+ function validateOptionalNonNegativeInteger(value, path, issues) {
167
+ if (value === void 0) return true;
168
+ if (!Number.isInteger(value) || value < 0) {
169
+ issue(issues, path, "invalid_bound", "Expected a non-negative integer.");
170
+ return false;
171
+ }
172
+ return true;
173
+ }
174
+ function validateOptions(value, path, issues) {
175
+ if (!Array.isArray(value) || value.length === 0) {
176
+ issue(issues, path, "invalid_options", "Expected at least one option.");
177
+ return false;
178
+ }
179
+ const seen = /* @__PURE__ */ new Set();
180
+ value.forEach((option, index) => {
181
+ const optionPath = `${path}[${index}]`;
182
+ if (!isRecord(option)) {
183
+ issue(issues, optionPath, "invalid_option", "Expected an option object.");
184
+ return;
185
+ }
186
+ rejectLegacyProperties(option, optionPath, ["value", "labelKey"], issues);
187
+ if (!isNonEmptyString(option.id)) {
188
+ issue(issues, `${optionPath}.id`, "invalid_option_id", "Expected a non-empty option ID.");
189
+ } else if (seen.has(option.id)) {
190
+ issue(issues, `${optionPath}.id`, "duplicate_option", "Option IDs must be unique.");
191
+ } else {
192
+ seen.add(option.id);
193
+ }
194
+ if (!isNonEmptyString(option.label)) {
195
+ issue(issues, `${optionPath}.label`, "invalid_label", "Expected a non-empty option label.");
196
+ }
197
+ });
198
+ return true;
199
+ }
200
+ function validateDisplayCondition(value, path, issues) {
201
+ if (!isRecord(value)) {
202
+ issue(issues, path, "invalid_condition", "Expected a display condition object.");
203
+ return false;
204
+ }
205
+ if (!isNonEmptyString(value.questionId)) {
206
+ issue(issues, `${path}.questionId`, "invalid_condition_source", "Expected a question ID.");
207
+ }
208
+ if (typeof value.operator !== "string" || !CONDITION_OPERATORS.has(value.operator)) {
209
+ issue(issues, `${path}.operator`, "invalid_condition_operator", "Unsupported condition operator.");
210
+ return false;
211
+ }
212
+ const hasValue = Object.hasOwn(value, "value") && value.value !== void 0;
213
+ if (value.operator === "not_empty") {
214
+ if (hasValue) issue(issues, `${path}.value`, "unexpected_condition_value", "not_empty does not accept a value.");
215
+ } else if (!hasValue) {
216
+ issue(issues, `${path}.value`, "missing_condition_value", `${value.operator} requires a value.`);
217
+ } else if (!["string", "number", "boolean"].includes(typeof value.value)) {
218
+ issue(issues, `${path}.value`, "invalid_condition_value", "Expected a string, number, or boolean.");
219
+ } else if (typeof value.value === "number" && !Number.isFinite(value.value)) {
220
+ issue(issues, `${path}.value`, "invalid_condition_value", "Expected a finite number.");
221
+ }
222
+ return true;
223
+ }
224
+ function validateField(value, path, issues) {
225
+ if (!isRecord(value)) {
226
+ issue(issues, path, "invalid_field", "Expected a field object.");
227
+ return false;
228
+ }
229
+ rejectLegacyProperties(value, path, ["titleKey", "labelKey", "helpTextKey", "descriptionKey"], issues);
230
+ if (!isNonEmptyString(value.id)) issue(issues, `${path}.id`, "invalid_id", "Expected a non-empty ID.");
231
+ if (!isNonEmptyString(value.title)) {
232
+ issue(issues, `${path}.title`, "invalid_title", "Expected a non-empty question title.");
233
+ }
234
+ if (value.description !== void 0 && !isNonEmptyString(value.description)) {
235
+ issue(issues, `${path}.description`, "invalid_description", "Expected a non-empty question description.");
236
+ }
237
+ if (value.translationKey !== void 0 && !isNonEmptyString(value.translationKey)) {
238
+ issue(issues, `${path}.translationKey`, "invalid_translation_key", "Expected a translation key.");
239
+ }
240
+ if (typeof value.required !== "boolean") {
241
+ issue(issues, `${path}.required`, "invalid_required", "Expected a boolean.");
242
+ }
243
+ if (value.displayCondition !== void 0) {
244
+ validateDisplayCondition(value.displayCondition, `${path}.displayCondition`, issues);
245
+ }
246
+ if (typeof value.type !== "string" || !FIELD_TYPES.has(value.type)) {
247
+ issue(issues, `${path}.type`, "invalid_field_type", "Unsupported field type.");
248
+ return false;
249
+ }
250
+ if (value.type === "text" || value.type === "textarea") {
251
+ const minValid = validateOptionalNonNegativeInteger(value.minLength, `${path}.minLength`, issues);
252
+ const maxValid = validateOptionalNonNegativeInteger(value.maxLength, `${path}.maxLength`, issues);
253
+ if (minValid && maxValid && typeof value.minLength === "number" && typeof value.maxLength === "number" && value.minLength > value.maxLength) {
254
+ issue(issues, path, "contradictory_bounds", "minLength cannot exceed maxLength.");
255
+ }
256
+ if (value.pattern !== void 0) {
257
+ if (typeof value.pattern !== "string") {
258
+ issue(issues, `${path}.pattern`, "invalid_pattern", "Expected a regular expression string.");
259
+ } else {
260
+ try {
261
+ new RegExp(value.pattern);
262
+ } catch {
263
+ issue(issues, `${path}.pattern`, "invalid_pattern", "Regular expression is invalid.");
264
+ }
265
+ }
266
+ }
267
+ }
268
+ if (value.type === "number") {
269
+ for (const key of ["min", "max", "step"]) {
270
+ const bound = value[key];
271
+ if (bound !== void 0 && (typeof bound !== "number" || !Number.isFinite(bound))) {
272
+ issue(issues, `${path}.${key}`, "invalid_bound", "Expected a finite number.");
273
+ }
274
+ }
275
+ if (typeof value.step === "number" && value.step <= 0) {
276
+ issue(issues, `${path}.step`, "invalid_step", "step must be greater than zero.");
277
+ }
278
+ if (typeof value.min === "number" && typeof value.max === "number" && value.min > value.max) {
279
+ issue(issues, path, "contradictory_bounds", "min cannot exceed max.");
280
+ }
281
+ }
282
+ if (value.type === "rating") {
283
+ for (const key of ["min", "max"]) {
284
+ const bound = value[key];
285
+ if (bound !== void 0 && (!Number.isInteger(bound) || !Number.isFinite(bound))) {
286
+ issue(issues, `${path}.${key}`, "invalid_bound", "Expected a finite integer.");
287
+ }
288
+ }
289
+ const min = typeof value.min === "number" ? value.min : 1;
290
+ const max = typeof value.max === "number" ? value.max : 5;
291
+ if (min > max) issue(issues, path, "contradictory_bounds", "min cannot exceed max.");
292
+ }
293
+ if (value.type === "select" || value.type === "radio" || value.type === "multi-select") {
294
+ validateOptions(value.options, `${path}.options`, issues);
295
+ }
296
+ if (value.type === "multi-select") {
297
+ const minValid = validateOptionalNonNegativeInteger(value.minSelections, `${path}.minSelections`, issues);
298
+ const maxValid = validateOptionalNonNegativeInteger(value.maxSelections, `${path}.maxSelections`, issues);
299
+ if (minValid && maxValid && typeof value.minSelections === "number" && typeof value.maxSelections === "number" && value.minSelections > value.maxSelections) {
300
+ issue(issues, path, "contradictory_bounds", "minSelections cannot exceed maxSelections.");
301
+ }
302
+ if (Array.isArray(value.options) && typeof value.maxSelections === "number" && value.maxSelections > value.options.length) {
303
+ issue(issues, `${path}.maxSelections`, "invalid_bound", "maxSelections cannot exceed option count.");
304
+ }
305
+ }
306
+ return true;
307
+ }
308
+ function validateFormSchema(input) {
309
+ const issues = [];
310
+ if (!isRecord(input)) {
311
+ return { valid: false, issues: [{ path: "$", code: "invalid_schema", message: "Expected a schema object." }] };
312
+ }
313
+ rejectLegacyProperties(input, "", ["titleKey", "descriptionKey"], issues);
314
+ if (!isNonEmptyString(input.id)) issue(issues, "id", "invalid_id", "Expected a non-empty ID.");
315
+ if (!Number.isInteger(input.version) || input.version < 1) {
316
+ issue(issues, "version", "invalid_version", "Expected a positive integer version.");
317
+ }
318
+ if (!isNonEmptyString(input.title)) {
319
+ issue(issues, "title", "invalid_title", "Expected a non-empty form title.");
320
+ }
321
+ for (const key of ["description", "submitLabelKey"]) {
322
+ if (input[key] !== void 0 && !isNonEmptyString(input[key])) {
323
+ issue(
324
+ issues,
325
+ key,
326
+ key === "description" ? "invalid_description" : "invalid_translation_key",
327
+ key === "description" ? "Expected a non-empty form description." : "Expected a translation key."
328
+ );
329
+ }
330
+ }
331
+ if (!Array.isArray(input.fields) || input.fields.length === 0) {
332
+ issue(issues, "fields", "invalid_fields", "Expected at least one field.");
333
+ } else {
334
+ const inputFields = input.fields;
335
+ const ids = /* @__PURE__ */ new Set();
336
+ inputFields.forEach((field, index) => {
337
+ validateField(field, `fields[${index}]`, issues);
338
+ if (isRecord(field) && isNonEmptyString(field.id)) {
339
+ if (ids.has(field.id)) issue(issues, `fields[${index}].id`, "duplicate_field", "Field IDs must be unique.");
340
+ ids.add(field.id);
341
+ }
342
+ });
343
+ const structuralSchema = {
344
+ id: typeof input.id === "string" ? input.id : "invalid",
345
+ version: typeof input.version === "number" ? input.version : 1,
346
+ title: typeof input.title === "string" ? input.title : "invalid",
347
+ fields: inputFields.filter((field) => isRecord(field) && isNonEmptyString(field.id))
348
+ };
349
+ for (const structuralIssue of validateSchemaStructure(structuralSchema)) {
350
+ if (structuralIssue.type !== "dangling_condition_reference" && structuralIssue.type !== "self_condition_reference" && structuralIssue.type !== "cyclic_condition_reference") {
351
+ continue;
352
+ }
353
+ const fieldIndex = inputFields.findIndex((field) => isRecord(field) && field.id === structuralIssue.questionId);
354
+ const code = structuralIssue.type === "dangling_condition_reference" ? "unknown_condition_source" : structuralIssue.type === "self_condition_reference" ? "self_condition" : "condition_cycle";
355
+ issue(issues, `fields[${fieldIndex}].displayCondition`, code, structuralIssue.message);
356
+ }
357
+ }
358
+ return issues.length === 0 ? { valid: true, value: input, issues: [] } : { valid: false, issues };
359
+ }
360
+ function assertValidFormSchema(input) {
361
+ const result = validateFormSchema(input);
362
+ if (!result.valid) {
363
+ throw new TypeError(
364
+ `Invalid form schema: ${result.issues.map((item) => `${item.path}: ${item.message}`).join("; ")}`
365
+ );
366
+ }
367
+ }
368
+
369
+ // src/visibility.ts
370
+ function isEmpty(value) {
371
+ if (value === void 0 || value === null) return true;
372
+ if (typeof value === "string") return value.trim().length === 0;
373
+ if (Array.isArray(value)) return value.length === 0;
374
+ if (typeof value === "number") return !Number.isFinite(value);
375
+ return false;
376
+ }
377
+ function normalizeString(value) {
378
+ return value.trim().normalize("NFKC").toLowerCase();
379
+ }
380
+ function valuesEqual(left, right) {
381
+ return typeof left === "string" && typeof right === "string" ? normalizeString(left) === normalizeString(right) : left === right;
382
+ }
383
+ function isQuestionVisible(question, currentAnswers) {
384
+ const condition = question.displayCondition;
385
+ if (condition === void 0) return true;
386
+ const answer = currentAnswers[condition.questionId];
387
+ if (isEmpty(answer)) return false;
388
+ if (condition.operator === "not_empty") return true;
389
+ if (condition.value === void 0) return false;
390
+ if (condition.operator === "equals") return valuesEqual(answer, condition.value);
391
+ if (condition.operator === "not_equals") return !valuesEqual(answer, condition.value);
392
+ if (typeof answer === "string" && typeof condition.value === "string") {
393
+ return normalizeString(answer).includes(normalizeString(condition.value));
394
+ }
395
+ return Array.isArray(answer) && answer.some((item) => valuesEqual(item, condition.value));
396
+ }
397
+ function calculateFieldVisibility(schema, currentAnswers) {
398
+ const fields = new Map(schema.fields.map((field) => [field.id, field]));
399
+ const resolved = /* @__PURE__ */ new Map();
400
+ const resolving = /* @__PURE__ */ new Set();
401
+ const resolve = (field) => {
402
+ const existing = resolved.get(field.id);
403
+ if (existing !== void 0) return existing;
404
+ if (resolving.has(field.id)) return false;
405
+ resolving.add(field.id);
406
+ const sourceId = field.displayCondition?.questionId;
407
+ const source = sourceId === void 0 ? void 0 : fields.get(sourceId);
408
+ const sourceVisible = source === void 0 ? sourceId === void 0 : resolve(source);
409
+ const visible = sourceVisible && isQuestionVisible(field, currentAnswers);
410
+ resolving.delete(field.id);
411
+ resolved.set(field.id, visible);
412
+ return visible;
413
+ };
414
+ for (const field of schema.fields) resolve(field);
415
+ return Object.freeze(Object.fromEntries(resolved));
416
+ }
417
+ function selectVisibleAnswers(schema, currentAnswers) {
418
+ const visibility = calculateFieldVisibility(schema, currentAnswers);
419
+ return Object.fromEntries(
420
+ schema.fields.filter((field) => visibility[field.id] === true && Object.hasOwn(currentAnswers, field.id)).map((field) => [field.id, currentAnswers[field.id]])
421
+ );
422
+ }
423
+
424
+ // src/analytics.ts
425
+ function percentage(count, total) {
426
+ return total === 0 ? 0 : count / total * 100;
427
+ }
428
+ function calculateChoiceDistribution(responses, questionId) {
429
+ const counts = /* @__PURE__ */ new Map();
430
+ for (const response of responses) {
431
+ const value = response.values[questionId];
432
+ const selections = new Set(Array.isArray(value) ? value : typeof value === "string" && value !== "" ? [value] : []);
433
+ for (const selection of selections) counts.set(selection, (counts.get(selection) ?? 0) + 1);
434
+ }
435
+ return Object.fromEntries(
436
+ [...counts].map(([value, count]) => [value, { count, percentage: percentage(count, responses.length) }])
437
+ );
438
+ }
439
+ function calculateNumericSummary(responses, questionId) {
440
+ const numbers = responses.map((response) => response.values[questionId]).filter((value) => typeof value === "number" && Number.isFinite(value));
441
+ const total = numbers.reduce((sum, value) => sum + value, 0);
442
+ return {
443
+ total,
444
+ average: numbers.length === 0 ? null : total / numbers.length,
445
+ min: numbers.length === 0 ? null : Math.min(...numbers),
446
+ max: numbers.length === 0 ? null : Math.max(...numbers)
447
+ };
448
+ }
449
+ function valueIsValid(field, value) {
450
+ if (value === void 0 || value === "") return false;
451
+ if (field.type === "text" || field.type === "textarea") {
452
+ if (typeof value !== "string") return false;
453
+ const normalized = value.trim();
454
+ if (normalized.length === 0) return false;
455
+ if (field.minLength !== void 0 && normalized.length < field.minLength) return false;
456
+ if (field.maxLength !== void 0 && normalized.length > field.maxLength) return false;
457
+ return field.pattern === void 0 || new RegExp(field.pattern).test(normalized);
458
+ }
459
+ if (field.type === "number" || field.type === "rating") {
460
+ if (typeof value !== "number" || !Number.isFinite(value)) return false;
461
+ const min = field.type === "rating" ? field.min ?? 1 : field.min;
462
+ const max = field.type === "rating" ? field.max ?? 5 : field.max;
463
+ if (min !== void 0 && value < min || max !== void 0 && value > max) return false;
464
+ if (field.type === "rating") return Number.isInteger(value);
465
+ if (field.step === void 0) return true;
466
+ const quotient = (value - (field.min ?? 0)) / field.step;
467
+ return Math.abs(quotient - Math.round(quotient)) <= 1e-9;
468
+ }
469
+ if (field.type === "checkbox") return typeof value === "boolean";
470
+ if (!("options" in field)) return false;
471
+ const allowed = new Set(field.options.map((option) => option.id));
472
+ if (field.type === "multi-select") {
473
+ return Array.isArray(value) && value.length > 0 && new Set(value).size === value.length && value.every((item) => allowed.has(item)) && (field.minSelections === void 0 || value.length >= field.minSelections) && (field.maxSelections === void 0 || value.length <= field.maxSelections);
474
+ }
475
+ return typeof value === "string" && allowed.has(value);
476
+ }
477
+ function aggregateField(schema, field, submissions) {
478
+ const values = submissions.map((submission) => {
479
+ const visibility = calculateFieldVisibility(schema, submission.values);
480
+ const value = submission.values[field.id];
481
+ return visibility[field.id] === true && valueIsValid(field, value) ? value : void 0;
482
+ });
483
+ const answeredCount = values.filter((value) => value !== void 0).length;
484
+ const base = { fieldId: field.id, answeredCount, unansweredCount: submissions.length - answeredCount };
485
+ if (field.type === "text" || field.type === "textarea") return { ...base, kind: field.type };
486
+ if (field.type === "number" || field.type === "rating") {
487
+ const numbers = values.filter((value) => typeof value === "number");
488
+ const total = numbers.reduce((sum, value) => sum + value, 0);
489
+ return {
490
+ ...base,
491
+ kind: field.type,
492
+ minimum: numbers.length === 0 ? null : Math.min(...numbers),
493
+ maximum: numbers.length === 0 ? null : Math.max(...numbers),
494
+ average: numbers.length === 0 ? null : total / numbers.length,
495
+ total
496
+ };
497
+ }
498
+ if (field.type === "checkbox") {
499
+ const trueCount = values.filter((value) => value === true).length;
500
+ const falseCount = values.filter((value) => value === false).length;
501
+ return {
502
+ ...base,
503
+ kind: "checkbox",
504
+ trueCount,
505
+ falseCount,
506
+ truePercentageOfSubmissions: percentage(trueCount, submissions.length),
507
+ falsePercentageOfSubmissions: percentage(falseCount, submissions.length)
508
+ };
509
+ }
510
+ if (!("options" in field)) throw new TypeError(`Field ${field.id} cannot be aggregated.`);
511
+ const optionCounts = new Map(field.options.map((option) => [option.id, 0]));
512
+ for (const value of values) {
513
+ const selections = Array.isArray(value) ? value : typeof value === "string" ? [value] : [];
514
+ for (const selection of selections) optionCounts.set(selection, (optionCounts.get(selection) ?? 0) + 1);
515
+ }
516
+ const aggregate = {
517
+ ...base,
518
+ kind: field.type,
519
+ options: field.options.map((option) => {
520
+ const count = optionCounts.get(option.id) ?? 0;
521
+ return { id: option.id, count, percentageOfSubmissions: percentage(count, submissions.length) };
522
+ })
523
+ };
524
+ return aggregate;
525
+ }
526
+ function aggregateResponses(schema, submissions) {
527
+ assertValidFormSchema(schema);
528
+ for (const submission of submissions) {
529
+ if (submission.formId !== schema.id || submission.formVersion !== schema.version) {
530
+ throw new TypeError(`Submission ${submission.id} does not match ${schema.id}@${schema.version}.`);
531
+ }
532
+ }
533
+ return {
534
+ formId: schema.id,
535
+ formVersion: schema.version,
536
+ submissionCount: submissions.length,
537
+ questions: schema.fields.map((field) => aggregateField(schema, field, submissions))
538
+ };
539
+ }
540
+ function escapeCsvCell(value) {
541
+ if (value === null || value === void 0) return "";
542
+ const stringValue = String(value);
543
+ return /[",\r\n]/.test(stringValue) ? `"${stringValue.replaceAll('"', '""')}"` : stringValue;
544
+ }
545
+ function serializeValue(value) {
546
+ if (value === void 0) return "";
547
+ if (Array.isArray(value)) return JSON.stringify(value);
548
+ return String(value);
549
+ }
550
+ function exportResponsesToCsv(schema, responses, options = {}) {
551
+ assertValidFormSchema(schema);
552
+ for (const response of responses) {
553
+ if (response.formId !== schema.id || response.formVersion !== schema.version) {
554
+ throw new TypeError(`Submission ${response.id} does not match ${schema.id}@${schema.version}.`);
555
+ }
556
+ }
557
+ const rows = [
558
+ ["submissionId", "submittedAt", "locale", ...schema.fields.map((field) => field.id)],
559
+ ...responses.map((response) => {
560
+ const visible = selectVisibleAnswers(schema, response.values);
561
+ return [
562
+ response.id,
563
+ response.submittedAt,
564
+ response.locale,
565
+ ...schema.fields.map((field) => serializeValue(visible[field.id]))
566
+ ];
567
+ })
568
+ ];
569
+ const csv = rows.map((row) => row.map((cell) => escapeCsvCell(cell)).join(",")).join("\r\n");
570
+ return options.withBom ?? true ? `\uFEFF${csv}` : csv;
571
+ }
572
+
573
+ // src/validation.ts
574
+ var DEFAULT_MESSAGES = {
575
+ required: "validation.required",
576
+ invalid_type: "validation.invalidType",
577
+ min_length: "validation.minLength",
578
+ max_length: "validation.maxLength",
579
+ pattern: "validation.pattern",
580
+ min: "validation.min",
581
+ max: "validation.max",
582
+ step: "validation.step",
583
+ invalid_option: "validation.invalidOption",
584
+ min_selections: "validation.minSelections",
585
+ max_selections: "validation.maxSelections",
586
+ unknown_field: "validation.unknownField"
587
+ };
588
+ function addIssue(issues, field, code, params = {}) {
589
+ issues.push({
590
+ fieldId: field.id,
591
+ code,
592
+ messageKey: field.messages?.[code] ?? DEFAULT_MESSAGES[code],
593
+ params
594
+ });
595
+ }
596
+ function isEmpty2(field, value) {
597
+ if (value === void 0 || value === "") return true;
598
+ if (field.type === "checkbox") return value !== true;
599
+ if (field.type === "multi-select") return Array.isArray(value) && value.length === 0;
600
+ return false;
601
+ }
602
+ function validateField2(field, value, issues) {
603
+ if (isEmpty2(field, value)) {
604
+ if (field.required) addIssue(issues, field, "required");
605
+ return;
606
+ }
607
+ if (field.type === "text" || field.type === "textarea") {
608
+ if (typeof value !== "string") {
609
+ addIssue(issues, field, "invalid_type");
610
+ return;
611
+ }
612
+ const normalized = value.trim();
613
+ if (field.required && normalized.length === 0) {
614
+ addIssue(issues, field, "required");
615
+ return;
616
+ }
617
+ if (field.minLength !== void 0 && normalized.length < field.minLength) {
618
+ addIssue(issues, field, "min_length", { min: field.minLength });
619
+ }
620
+ if (field.maxLength !== void 0 && normalized.length > field.maxLength) {
621
+ addIssue(issues, field, "max_length", { max: field.maxLength });
622
+ }
623
+ if (field.pattern !== void 0 && !new RegExp(field.pattern).test(normalized)) {
624
+ addIssue(issues, field, "pattern");
625
+ }
626
+ return;
627
+ }
628
+ if (field.type === "number" || field.type === "rating") {
629
+ if (typeof value !== "number" || !Number.isFinite(value)) {
630
+ addIssue(issues, field, "invalid_type");
631
+ return;
632
+ }
633
+ const min = field.type === "rating" ? field.min ?? 1 : field.min;
634
+ const max = field.type === "rating" ? field.max ?? 5 : field.max;
635
+ if (min !== void 0 && value < min) addIssue(issues, field, "min", { min });
636
+ if (max !== void 0 && value > max) addIssue(issues, field, "max", { max });
637
+ if (field.type === "rating" && !Number.isInteger(value)) {
638
+ addIssue(issues, field, "step", { step: 1 });
639
+ } else if (field.type === "number" && field.step !== void 0) {
640
+ const origin = field.min ?? 0;
641
+ const quotient = (value - origin) / field.step;
642
+ if (Math.abs(quotient - Math.round(quotient)) > 1e-9) {
643
+ addIssue(issues, field, "step", { step: field.step });
644
+ }
645
+ }
646
+ return;
647
+ }
648
+ if (field.type === "checkbox") {
649
+ if (typeof value !== "boolean") addIssue(issues, field, "invalid_type");
650
+ return;
651
+ }
652
+ if (!("options" in field)) {
653
+ addIssue(issues, field, "invalid_type");
654
+ return;
655
+ }
656
+ const allowed = new Set(field.options.map((option) => option.id));
657
+ if (field.type === "multi-select") {
658
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
659
+ addIssue(issues, field, "invalid_type");
660
+ return;
661
+ }
662
+ const unique = new Set(value);
663
+ if (unique.size !== value.length || value.some((item) => !allowed.has(item))) {
664
+ addIssue(issues, field, "invalid_option");
665
+ }
666
+ if (field.minSelections !== void 0 && value.length < field.minSelections) {
667
+ addIssue(issues, field, "min_selections", { min: field.minSelections });
668
+ }
669
+ if (field.maxSelections !== void 0 && value.length > field.maxSelections) {
670
+ addIssue(issues, field, "max_selections", { max: field.maxSelections });
671
+ }
672
+ return;
673
+ }
674
+ if (typeof value !== "string") {
675
+ addIssue(issues, field, "invalid_type");
676
+ } else if (!allowed.has(value)) {
677
+ addIssue(issues, field, "invalid_option");
678
+ }
679
+ }
680
+ function validateAnswers(schema, values) {
681
+ const issues = [];
682
+ const fields = new Map(schema.fields.map((field) => [field.id, field]));
683
+ for (const key of Object.keys(values)) {
684
+ if (!fields.has(key)) {
685
+ issues.push({
686
+ fieldId: key,
687
+ code: "unknown_field",
688
+ messageKey: DEFAULT_MESSAGES.unknown_field,
689
+ params: {}
690
+ });
691
+ }
692
+ }
693
+ const visibility = calculateFieldVisibility(schema, values);
694
+ for (const field of schema.fields) {
695
+ if (visibility[field.id] === true) validateField2(field, values[field.id], issues);
696
+ }
697
+ return issues.length === 0 ? { valid: true, issues: [] } : { valid: false, issues };
698
+ }
699
+
700
+ // src/submission.ts
701
+ function cloneValues(values) {
702
+ return Object.fromEntries(
703
+ Object.entries(values).map(([key, value]) => [key, Array.isArray(value) ? Object.freeze([...value]) : value])
704
+ );
705
+ }
706
+ function createSubmission(schema, values, options) {
707
+ assertValidFormSchema(schema);
708
+ if (options.id.trim().length === 0) throw new TypeError("Submission ID must not be empty.");
709
+ if (options.locale.trim().length === 0) throw new TypeError("Submission locale must not be empty.");
710
+ const result = validateAnswers(schema, values);
711
+ if (!result.valid) {
712
+ throw new TypeError(
713
+ `Invalid form answers: ${result.issues.map((item) => `${item.fieldId}:${item.code}`).join(", ")}`
714
+ );
715
+ }
716
+ if (options.submittedAt.trim().length === 0 || !Number.isFinite(Date.parse(options.submittedAt))) {
717
+ throw new TypeError("submittedAt must be a valid date string.");
718
+ }
719
+ const visibleValues = selectVisibleAnswers(schema, values);
720
+ return Object.freeze({
721
+ id: options.id,
722
+ formId: schema.id,
723
+ formVersion: schema.version,
724
+ locale: options.locale,
725
+ values: Object.freeze(cloneValues(visibleValues)),
726
+ submittedAt: options.submittedAt
727
+ });
728
+ }
729
+
730
+ // src/translation.ts
731
+ async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocale) {
732
+ assertValidFormSchema(schema);
733
+ const texts = [schema.title];
734
+ if (schema.description !== void 0) texts.push(schema.description);
735
+ for (const field of schema.fields) {
736
+ texts.push(field.title);
737
+ if (field.description !== void 0) texts.push(field.description);
738
+ if ("options" in field) texts.push(...field.options.map((option) => option.label));
739
+ }
740
+ const translated = await adapter.translateBatch(texts, targetLocale, sourceLocale);
741
+ if (translated.length !== texts.length) {
742
+ throw new Error(`Translation adapter returned ${translated.length} texts for ${texts.length} inputs.`);
743
+ }
744
+ let index = 0;
745
+ const next = () => {
746
+ const value = translated[index];
747
+ index += 1;
748
+ if (value === void 0) throw new Error("Translation adapter returned an incomplete result.");
749
+ return value;
750
+ };
751
+ const title = next();
752
+ const description = schema.description === void 0 ? void 0 : next();
753
+ const fields = schema.fields.map((field) => {
754
+ const translatedTitle = next();
755
+ const translatedDescription = field.description === void 0 ? void 0 : next();
756
+ const base = {
757
+ ...field,
758
+ title: translatedTitle,
759
+ ...translatedDescription === void 0 ? {} : { description: translatedDescription }
760
+ };
761
+ if (!("options" in field)) return base;
762
+ return { ...base, options: field.options.map((option) => ({ ...option, label: next() })) };
763
+ });
764
+ const translatedSchema = {
765
+ ...schema,
766
+ title,
767
+ ...description === void 0 ? {} : { description },
768
+ fields
769
+ };
770
+ assertValidFormSchema(translatedSchema);
771
+ return translatedSchema;
772
+ }
773
+ // Annotate the CommonJS export names for ESM import in node:
774
+ 0 && (module.exports = {
775
+ aggregateResponses,
776
+ assertValidFormSchema,
777
+ calculateChoiceDistribution,
778
+ calculateFieldVisibility,
779
+ calculateNumericSummary,
780
+ createSubmission,
781
+ escapeCsvCell,
782
+ exportResponsesToCsv,
783
+ isQuestionVisible,
784
+ resolveFormTranslation,
785
+ sanitizeSchema,
786
+ selectVisibleAnswers,
787
+ validateAnswers,
788
+ validateFormSchema,
789
+ validateSchemaStructure
790
+ });