@form-engine-ts/core 1.0.0 → 2.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 CHANGED
@@ -23,17 +23,24 @@ __export(index_exports, {
23
23
  aggregateResponses: () => aggregateResponses,
24
24
  assertValidFormSchema: () => assertValidFormSchema,
25
25
  calculateChoiceDistribution: () => calculateChoiceDistribution,
26
+ calculateCrossTabulation: () => calculateCrossTabulation,
26
27
  calculateFieldVisibility: () => calculateFieldVisibility,
27
28
  calculateNumericSummary: () => calculateNumericSummary,
29
+ calculatePageVisibility: () => calculatePageVisibility,
28
30
  createSubmission: () => createSubmission,
31
+ dispatchWebhook: () => dispatchWebhook,
29
32
  escapeCsvCell: () => escapeCsvCell,
30
33
  exportResponsesToCsv: () => exportResponsesToCsv,
34
+ isDisplayConditionSatisfied: () => isDisplayConditionSatisfied,
31
35
  isQuestionVisible: () => isQuestionVisible,
36
+ populateSchemaTranslations: () => populateSchemaTranslations,
32
37
  resolveFormTranslation: () => resolveFormTranslation,
38
+ resolveLocalizedSchema: () => resolveLocalizedSchema,
33
39
  sanitizeSchema: () => sanitizeSchema,
34
40
  selectVisibleAnswers: () => selectVisibleAnswers,
35
41
  validateAnswers: () => validateAnswers,
36
42
  validateFormSchema: () => validateFormSchema,
43
+ validatePageAnswers: () => validatePageAnswers,
37
44
  validateSchemaStructure: () => validateSchemaStructure
38
45
  });
39
46
  module.exports = __toCommonJS(index_exports);
@@ -126,17 +133,46 @@ function validateSchemaStructure(schema) {
126
133
  function sanitizeSchema(schema) {
127
134
  const existingQuestionIds = new Set(schema.fields.map((field) => field.id));
128
135
  const cyclic = cyclicQuestionIds(schema.fields);
129
- return {
136
+ const sanitizedFields = schema.fields.map((field) => {
137
+ const sourceId = field.displayCondition?.questionId;
138
+ if (sourceId === void 0 || existingQuestionIds.has(sourceId) && sourceId !== field.id && !cyclic.has(field.id)) {
139
+ return field;
140
+ }
141
+ const { displayCondition: _displayCondition, ...sanitized } = field;
142
+ return sanitized;
143
+ });
144
+ const base = {
130
145
  ...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
- })
146
+ fields: sanitizedFields
139
147
  };
148
+ if (schema.pages === void 0) return base;
149
+ const assigned = /* @__PURE__ */ new Set();
150
+ const pages = schema.pages.map((page) => ({
151
+ ...page,
152
+ questionIds: page.questionIds.filter((id) => {
153
+ if (!existingQuestionIds.has(id) || assigned.has(id)) return false;
154
+ assigned.add(id);
155
+ return true;
156
+ })
157
+ })).filter((page) => page.questionIds.length > 0);
158
+ if (pages.length === 0) {
159
+ const { pages: _pages, ...singlePage } = base;
160
+ return singlePage;
161
+ }
162
+ const unassigned = schema.fields.map((field) => field.id).filter((id) => !assigned.has(id));
163
+ const completePages = pages.map(
164
+ (page, index) => index === pages.length - 1 ? { ...page, questionIds: [...page.questionIds, ...unassigned] } : page
165
+ );
166
+ const pageIndexByQuestion = new Map(
167
+ completePages.flatMap((page, pageIndex) => page.questionIds.map((id) => [id, pageIndex]))
168
+ );
169
+ const safePages = completePages.map((page, pageIndex) => {
170
+ const sourceIndex = page.displayCondition === void 0 ? void 0 : pageIndexByQuestion.get(page.displayCondition.questionId);
171
+ if (page.displayCondition === void 0 || sourceIndex !== void 0 && sourceIndex < pageIndex) return page;
172
+ const { displayCondition: _displayCondition, ...safePage } = page;
173
+ return safePage;
174
+ });
175
+ return { ...base, pages: safePages };
140
176
  }
141
177
 
142
178
  // src/schema.ts
@@ -151,6 +187,80 @@ function isNonEmptyString(value) {
151
187
  function issue(issues, path, code, message) {
152
188
  issues.push({ path, code, message });
153
189
  }
190
+ function validateJsonValue(value, path, issues, ancestors = /* @__PURE__ */ new Set()) {
191
+ if (value === null || typeof value === "string" || typeof value === "boolean") return;
192
+ if (typeof value === "number") {
193
+ if (!Number.isFinite(value)) issue(issues, path, "invalid_metadata", "Metadata numbers must be finite.");
194
+ return;
195
+ }
196
+ if (typeof value !== "object") {
197
+ issue(issues, path, "invalid_metadata", "Expected JSON-serializable metadata.");
198
+ return;
199
+ }
200
+ if (ancestors.has(value)) {
201
+ issue(issues, path, "invalid_metadata", "Metadata must not contain cycles.");
202
+ return;
203
+ }
204
+ const nextAncestors = new Set(ancestors).add(value);
205
+ if (Array.isArray(value)) {
206
+ value.forEach((item, index) => {
207
+ validateJsonValue(item, `${path}[${index}]`, issues, nextAncestors);
208
+ });
209
+ return;
210
+ }
211
+ const prototype = Object.getPrototypeOf(value);
212
+ if (prototype !== Object.prototype && prototype !== null) {
213
+ issue(issues, path, "invalid_metadata", "Metadata objects must be plain JSON objects.");
214
+ return;
215
+ }
216
+ for (const [key, item] of Object.entries(value)) {
217
+ validateJsonValue(item, `${path}.${key}`, issues, nextAncestors);
218
+ }
219
+ }
220
+ function validateExtensibleNode(value, path, issues) {
221
+ for (const property of ["metadata", "translationMetadata"]) {
222
+ const candidate = value[property];
223
+ if (candidate === void 0) continue;
224
+ if (!isRecord(candidate)) {
225
+ issue(
226
+ issues,
227
+ path.length === 0 ? property : `${path}.${property}`,
228
+ "invalid_metadata",
229
+ "Expected a metadata object."
230
+ );
231
+ continue;
232
+ }
233
+ validateJsonValue(candidate, path.length === 0 ? property : `${path}.${property}`, issues);
234
+ }
235
+ }
236
+ function validateLocalizedTextMap(value, path, issues) {
237
+ if (!isRecord(value)) {
238
+ issue(issues, path, "invalid_translations", "Expected a locale-to-translation object.");
239
+ return;
240
+ }
241
+ for (const [locale, translation] of Object.entries(value)) {
242
+ if (!isNonEmptyString(locale) || !isRecord(translation)) {
243
+ issue(issues, `${path}.${locale}`, "invalid_translation", "Expected a translation object.");
244
+ continue;
245
+ }
246
+ for (const key of ["title", "description", "completionMessage"]) {
247
+ if (translation[key] !== void 0 && !isNonEmptyString(translation[key])) {
248
+ issue(issues, `${path}.${locale}.${key}`, "invalid_translation", "Expected non-empty translated text.");
249
+ }
250
+ }
251
+ }
252
+ }
253
+ function validateOptionTranslations(value, path, issues) {
254
+ if (!isRecord(value)) {
255
+ issue(issues, path, "invalid_translations", "Expected a locale-to-label object.");
256
+ return;
257
+ }
258
+ for (const [locale, label] of Object.entries(value)) {
259
+ if (!isNonEmptyString(locale) || !isNonEmptyString(label)) {
260
+ issue(issues, `${path}.${locale}`, "invalid_translation", "Expected a non-empty translated label.");
261
+ }
262
+ }
263
+ }
154
264
  function rejectLegacyProperties(value, path, properties, issues) {
155
265
  for (const property of properties) {
156
266
  if (Object.hasOwn(value, property)) {
@@ -184,6 +294,7 @@ function validateOptions(value, path, issues) {
184
294
  return;
185
295
  }
186
296
  rejectLegacyProperties(option, optionPath, ["value", "labelKey"], issues);
297
+ validateExtensibleNode(option, optionPath, issues);
187
298
  if (!isNonEmptyString(option.id)) {
188
299
  issue(issues, `${optionPath}.id`, "invalid_option_id", "Expected a non-empty option ID.");
189
300
  } else if (seen.has(option.id)) {
@@ -194,6 +305,8 @@ function validateOptions(value, path, issues) {
194
305
  if (!isNonEmptyString(option.label)) {
195
306
  issue(issues, `${optionPath}.label`, "invalid_label", "Expected a non-empty option label.");
196
307
  }
308
+ if (option.translations !== void 0)
309
+ validateOptionTranslations(option.translations, `${optionPath}.translations`, issues);
197
310
  });
198
311
  return true;
199
312
  }
@@ -227,6 +340,7 @@ function validateField(value, path, issues) {
227
340
  return false;
228
341
  }
229
342
  rejectLegacyProperties(value, path, ["titleKey", "labelKey", "helpTextKey", "descriptionKey"], issues);
343
+ validateExtensibleNode(value, path, issues);
230
344
  if (!isNonEmptyString(value.id)) issue(issues, `${path}.id`, "invalid_id", "Expected a non-empty ID.");
231
345
  if (!isNonEmptyString(value.title)) {
232
346
  issue(issues, `${path}.title`, "invalid_title", "Expected a non-empty question title.");
@@ -243,6 +357,7 @@ function validateField(value, path, issues) {
243
357
  if (value.displayCondition !== void 0) {
244
358
  validateDisplayCondition(value.displayCondition, `${path}.displayCondition`, issues);
245
359
  }
360
+ if (value.translations !== void 0) validateLocalizedTextMap(value.translations, `${path}.translations`, issues);
246
361
  if (typeof value.type !== "string" || !FIELD_TYPES.has(value.type)) {
247
362
  issue(issues, `${path}.type`, "invalid_field_type", "Unsupported field type.");
248
363
  return false;
@@ -311,6 +426,7 @@ function validateFormSchema(input) {
311
426
  return { valid: false, issues: [{ path: "$", code: "invalid_schema", message: "Expected a schema object." }] };
312
427
  }
313
428
  rejectLegacyProperties(input, "", ["titleKey", "descriptionKey"], issues);
429
+ validateExtensibleNode(input, "", issues);
314
430
  if (!isNonEmptyString(input.id)) issue(issues, "id", "invalid_id", "Expected a non-empty ID.");
315
431
  if (!Number.isInteger(input.version) || input.version < 1) {
316
432
  issue(issues, "version", "invalid_version", "Expected a positive integer version.");
@@ -318,16 +434,36 @@ function validateFormSchema(input) {
318
434
  if (!isNonEmptyString(input.title)) {
319
435
  issue(issues, "title", "invalid_title", "Expected a non-empty form title.");
320
436
  }
321
- for (const key of ["description", "submitLabelKey"]) {
437
+ for (const key of ["description", "completionMessage", "submitLabelKey"]) {
322
438
  if (input[key] !== void 0 && !isNonEmptyString(input[key])) {
323
439
  issue(
324
440
  issues,
325
441
  key,
326
- key === "description" ? "invalid_description" : "invalid_translation_key",
327
- key === "description" ? "Expected a non-empty form description." : "Expected a translation key."
442
+ key === "submitLabelKey" ? "invalid_translation_key" : "invalid_description",
443
+ key === "submitLabelKey" ? "Expected a translation key." : "Expected non-empty form text."
328
444
  );
329
445
  }
330
446
  }
447
+ if (input.defaultLocale !== void 0 && !isNonEmptyString(input.defaultLocale)) {
448
+ issue(issues, "defaultLocale", "invalid_locale", "Expected a non-empty default locale.");
449
+ }
450
+ if (input.supportedLocales !== void 0) {
451
+ if (!Array.isArray(input.supportedLocales) || input.supportedLocales.length === 0) {
452
+ issue(issues, "supportedLocales", "invalid_locales", "Expected at least one supported locale.");
453
+ } else {
454
+ const locales = /* @__PURE__ */ new Set();
455
+ input.supportedLocales.forEach((locale, index) => {
456
+ if (!isNonEmptyString(locale)) {
457
+ issue(issues, `supportedLocales[${index}]`, "invalid_locale", "Expected a non-empty locale.");
458
+ } else if (locales.has(locale)) {
459
+ issue(issues, `supportedLocales[${index}]`, "duplicate_locale", "Locales must be unique.");
460
+ } else {
461
+ locales.add(locale);
462
+ }
463
+ });
464
+ }
465
+ }
466
+ if (input.translations !== void 0) validateLocalizedTextMap(input.translations, "translations", issues);
331
467
  if (!Array.isArray(input.fields) || input.fields.length === 0) {
332
468
  issue(issues, "fields", "invalid_fields", "Expected at least one field.");
333
469
  } else {
@@ -354,6 +490,81 @@ function validateFormSchema(input) {
354
490
  const code = structuralIssue.type === "dangling_condition_reference" ? "unknown_condition_source" : structuralIssue.type === "self_condition_reference" ? "self_condition" : "condition_cycle";
355
491
  issue(issues, `fields[${fieldIndex}].displayCondition`, code, structuralIssue.message);
356
492
  }
493
+ if (input.pages !== void 0) {
494
+ if (!Array.isArray(input.pages) || input.pages.length === 0) {
495
+ issue(issues, "pages", "invalid_pages", "Expected at least one page when pages is defined.");
496
+ } else {
497
+ const pageIds = /* @__PURE__ */ new Set();
498
+ const assigned = /* @__PURE__ */ new Map();
499
+ input.pages.forEach((page, pageIndex) => {
500
+ const pagePath = `pages[${pageIndex}]`;
501
+ if (!isRecord(page)) {
502
+ issue(issues, pagePath, "invalid_page", "Expected a page object.");
503
+ return;
504
+ }
505
+ validateExtensibleNode(page, pagePath, issues);
506
+ if (!isNonEmptyString(page.id)) {
507
+ issue(issues, `${pagePath}.id`, "invalid_page_id", "Expected a non-empty page ID.");
508
+ } else if (pageIds.has(page.id)) {
509
+ issue(issues, `${pagePath}.id`, "duplicate_page", "Page IDs must be unique.");
510
+ } else {
511
+ pageIds.add(page.id);
512
+ }
513
+ for (const key of ["title", "description"]) {
514
+ if (page[key] !== void 0 && !isNonEmptyString(page[key])) {
515
+ issue(issues, `${pagePath}.${key}`, "invalid_page_text", "Expected non-empty page text.");
516
+ }
517
+ }
518
+ if (page.translations !== void 0) {
519
+ validateLocalizedTextMap(page.translations, `${pagePath}.translations`, issues);
520
+ }
521
+ if (page.displayCondition !== void 0) {
522
+ validateDisplayCondition(page.displayCondition, `${pagePath}.displayCondition`, issues);
523
+ }
524
+ if (!Array.isArray(page.questionIds) || page.questionIds.length === 0) {
525
+ issue(issues, `${pagePath}.questionIds`, "invalid_page_questions", "Expected at least one question ID.");
526
+ return;
527
+ }
528
+ page.questionIds.forEach((questionId, questionIndex) => {
529
+ const questionPath = `${pagePath}.questionIds[${questionIndex}]`;
530
+ if (!isNonEmptyString(questionId)) {
531
+ issue(issues, questionPath, "invalid_question_reference", "Expected a question ID.");
532
+ } else if (!ids.has(questionId)) {
533
+ issue(issues, questionPath, "unknown_page_question", "Page references an unknown question.");
534
+ } else if (assigned.has(questionId)) {
535
+ issue(issues, questionPath, "duplicate_page_question", "A question may belong to only one page.");
536
+ } else {
537
+ assigned.set(questionId, pageIndex);
538
+ }
539
+ });
540
+ });
541
+ for (const fieldId of ids) {
542
+ if (!assigned.has(fieldId))
543
+ issue(issues, "pages", "unassigned_page_question", `Question ${fieldId} has no page.`);
544
+ }
545
+ input.pages.forEach((page, pageIndex) => {
546
+ if (!isRecord(page) || !isRecord(page.displayCondition)) return;
547
+ const sourceId = page.displayCondition.questionId;
548
+ if (typeof sourceId !== "string") return;
549
+ const sourcePageIndex = assigned.get(sourceId);
550
+ if (sourcePageIndex === void 0) {
551
+ issue(
552
+ issues,
553
+ `pages[${pageIndex}].displayCondition.questionId`,
554
+ "unknown_page_condition_source",
555
+ "Page condition references an unknown question."
556
+ );
557
+ } else if (sourcePageIndex >= pageIndex) {
558
+ issue(
559
+ issues,
560
+ `pages[${pageIndex}].displayCondition.questionId`,
561
+ "forward_page_condition",
562
+ "Page conditions must reference a question on an earlier page."
563
+ );
564
+ }
565
+ });
566
+ }
567
+ }
357
568
  }
358
569
  return issues.length === 0 ? { valid: true, value: input, issues: [] } : { valid: false, issues };
359
570
  }
@@ -381,7 +592,9 @@ function valuesEqual(left, right) {
381
592
  return typeof left === "string" && typeof right === "string" ? normalizeString(left) === normalizeString(right) : left === right;
382
593
  }
383
594
  function isQuestionVisible(question, currentAnswers) {
384
- const condition = question.displayCondition;
595
+ return isDisplayConditionSatisfied(question.displayCondition, currentAnswers);
596
+ }
597
+ function isDisplayConditionSatisfied(condition, currentAnswers) {
385
598
  if (condition === void 0) return true;
386
599
  const answer = currentAnswers[condition.questionId];
387
600
  if (isEmpty(answer)) return false;
@@ -394,7 +607,7 @@ function isQuestionVisible(question, currentAnswers) {
394
607
  }
395
608
  return Array.isArray(answer) && answer.some((item) => valuesEqual(item, condition.value));
396
609
  }
397
- function calculateFieldVisibility(schema, currentAnswers) {
610
+ function calculateBaseFieldVisibility(schema, currentAnswers) {
398
611
  const fields = new Map(schema.fields.map((field) => [field.id, field]));
399
612
  const resolved = /* @__PURE__ */ new Map();
400
613
  const resolving = /* @__PURE__ */ new Set();
@@ -412,7 +625,34 @@ function calculateFieldVisibility(schema, currentAnswers) {
412
625
  return visible;
413
626
  };
414
627
  for (const field of schema.fields) resolve(field);
415
- return Object.freeze(Object.fromEntries(resolved));
628
+ return Object.fromEntries(resolved);
629
+ }
630
+ function calculatePageVisibility(schema, currentAnswers) {
631
+ if (schema.pages === void 0 || schema.pages.length === 0) return {};
632
+ const baseFieldVisibility = calculateBaseFieldVisibility(schema, currentAnswers);
633
+ const pageByQuestion = new Map(schema.pages.flatMap((page) => page.questionIds.map((id) => [id, page.id])));
634
+ const pageVisibility = {};
635
+ for (const page of schema.pages) {
636
+ const sourceId = page.displayCondition?.questionId;
637
+ const sourcePageId = sourceId === void 0 ? void 0 : pageByQuestion.get(sourceId);
638
+ const sourceVisible = sourceId === void 0 || baseFieldVisibility[sourceId] === true && sourcePageId !== void 0 && pageVisibility[sourcePageId] === true;
639
+ pageVisibility[page.id] = sourceVisible && isDisplayConditionSatisfied(page.displayCondition, currentAnswers);
640
+ }
641
+ return Object.freeze(pageVisibility);
642
+ }
643
+ function calculateFieldVisibility(schema, currentAnswers) {
644
+ const baseVisibility = calculateBaseFieldVisibility(schema, currentAnswers);
645
+ if (schema.pages === void 0 || schema.pages.length === 0) return Object.freeze(baseVisibility);
646
+ const pageVisibility = calculatePageVisibility(schema, currentAnswers);
647
+ const pageByQuestion = new Map(schema.pages.flatMap((page) => page.questionIds.map((id) => [id, page.id])));
648
+ return Object.freeze(
649
+ Object.fromEntries(
650
+ schema.fields.map((field) => {
651
+ const pageId = pageByQuestion.get(field.id);
652
+ return [field.id, baseVisibility[field.id] === true && pageId !== void 0 && pageVisibility[pageId] === true];
653
+ })
654
+ )
655
+ );
416
656
  }
417
657
  function selectVisibleAnswers(schema, currentAnswers) {
418
658
  const visibility = calculateFieldVisibility(schema, currentAnswers);
@@ -446,6 +686,23 @@ function calculateNumericSummary(responses, questionId) {
446
686
  max: numbers.length === 0 ? null : Math.max(...numbers)
447
687
  };
448
688
  }
689
+ function calculateCrossTabulation(responses, rowQuestionId, colQuestionId) {
690
+ const matrix = {};
691
+ const rowTotals = {};
692
+ const colTotals = {};
693
+ let grandTotal = 0;
694
+ for (const response of responses) {
695
+ const row = response.values[rowQuestionId];
696
+ const col = response.values[colQuestionId];
697
+ if (typeof row !== "string" || row.length === 0 || typeof col !== "string" || col.length === 0) continue;
698
+ matrix[row] ??= {};
699
+ matrix[row][col] = (matrix[row][col] ?? 0) + 1;
700
+ rowTotals[row] = (rowTotals[row] ?? 0) + 1;
701
+ colTotals[col] = (colTotals[col] ?? 0) + 1;
702
+ grandTotal += 1;
703
+ }
704
+ return { rowQuestionId, colQuestionId, matrix, rowTotals, colTotals, grandTotal };
705
+ }
449
706
  function valueIsValid(field, value) {
450
707
  if (value === void 0 || value === "") return false;
451
708
  if (field.type === "text" || field.type === "textarea") {
@@ -537,15 +794,20 @@ function aggregateResponses(schema, submissions) {
537
794
  questions: schema.fields.map((field) => aggregateField(schema, field, submissions))
538
795
  };
539
796
  }
540
- function escapeCsvCell(value) {
797
+ function escapeCsvCell(value, neutralizeFormulas = true) {
541
798
  if (value === null || value === void 0) return "";
542
- const stringValue = String(value);
799
+ let stringValue = String(value);
800
+ if (neutralizeFormulas && typeof value === "string") {
801
+ const trimmed = stringValue.trimStart();
802
+ if (trimmed.length > 0 && ["=", "+", "-", "@"].includes(trimmed[0] ?? "")) {
803
+ stringValue = `'${stringValue}`;
804
+ }
805
+ }
543
806
  return /[",\r\n]/.test(stringValue) ? `"${stringValue.replaceAll('"', '""')}"` : stringValue;
544
807
  }
545
808
  function serializeValue(value) {
546
809
  if (value === void 0) return "";
547
- if (Array.isArray(value)) return JSON.stringify(value);
548
- return String(value);
810
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value);
549
811
  }
550
812
  function exportResponsesToCsv(schema, responses, options = {}) {
551
813
  assertValidFormSchema(schema);
@@ -566,10 +828,52 @@ function exportResponsesToCsv(schema, responses, options = {}) {
566
828
  ];
567
829
  })
568
830
  ];
569
- const csv = rows.map((row) => row.map((cell) => escapeCsvCell(cell)).join(",")).join("\r\n");
831
+ const neutralizeFormulas = options.neutralizeFormulas ?? true;
832
+ const csv = rows.map((row) => row.map((cell) => escapeCsvCell(cell, neutralizeFormulas)).join(",")).join("\r\n");
570
833
  return options.withBom ?? true ? `\uFEFF${csv}` : csv;
571
834
  }
572
835
 
836
+ // src/events.ts
837
+ function bytesToHex(bytes) {
838
+ return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
839
+ }
840
+ async function signPayload(payload, secret) {
841
+ if (globalThis.crypto?.subtle === void 0) throw new Error("Web Crypto is unavailable for webhook signing.");
842
+ const encoder = new TextEncoder();
843
+ const key = await globalThis.crypto.subtle.importKey(
844
+ "raw",
845
+ encoder.encode(secret),
846
+ { name: "HMAC", hash: "SHA-256" },
847
+ false,
848
+ ["sign"]
849
+ );
850
+ return bytesToHex(await globalThis.crypto.subtle.sign("HMAC", key, encoder.encode(payload)));
851
+ }
852
+ async function dispatchWebhook(event, config, fetchImpl = globalThis.fetch) {
853
+ const body = JSON.stringify(event);
854
+ const controller = new AbortController();
855
+ const timeoutMs = config.timeoutMs ?? 5e3;
856
+ const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs);
857
+ try {
858
+ const headers = { "content-type": "application/json", ...config.headers };
859
+ if (config.secret !== void 0) {
860
+ headers["X-Form-Engine-Signature"] = await signPayload(body, config.secret);
861
+ }
862
+ const response = await fetchImpl(config.url, {
863
+ method: "POST",
864
+ headers,
865
+ body,
866
+ signal: controller.signal
867
+ });
868
+ return response.ok ? { success: true, status: response.status } : { success: false, status: response.status, error: `Webhook returned HTTP ${response.status}.` };
869
+ } catch (cause) {
870
+ const error = controller.signal.aborted ? `Webhook request timed out after ${timeoutMs}ms.` : cause instanceof Error ? cause.message : String(cause);
871
+ return { success: false, error };
872
+ } finally {
873
+ globalThis.clearTimeout(timeout);
874
+ }
875
+ }
876
+
573
877
  // src/validation.ts
574
878
  var DEFAULT_MESSAGES = {
575
879
  required: "validation.required",
@@ -696,6 +1000,18 @@ function validateAnswers(schema, values) {
696
1000
  }
697
1001
  return issues.length === 0 ? { valid: true, issues: [] } : { valid: false, issues };
698
1002
  }
1003
+ function validatePageAnswers(schema, pageIndex, values) {
1004
+ if (schema.pages === void 0 || schema.pages.length === 0) return validateAnswers(schema, values);
1005
+ const page = schema.pages[pageIndex];
1006
+ if (page === void 0) return { valid: true, issues: [] };
1007
+ const targetIds = new Set(page.questionIds);
1008
+ const visibility = calculateFieldVisibility(schema, values);
1009
+ const issues = [];
1010
+ for (const field of schema.fields) {
1011
+ if (targetIds.has(field.id) && visibility[field.id] === true) validateField2(field, values[field.id], issues);
1012
+ }
1013
+ return issues.length === 0 ? { valid: true, issues: [] } : { valid: false, issues };
1014
+ }
699
1015
 
700
1016
  // src/submission.ts
701
1017
  function cloneValues(values) {
@@ -723,68 +1039,281 @@ function createSubmission(schema, values, options) {
723
1039
  formVersion: schema.version,
724
1040
  locale: options.locale,
725
1041
  values: Object.freeze(cloneValues(visibleValues)),
726
- submittedAt: options.submittedAt
1042
+ submittedAt: options.submittedAt,
1043
+ ...options.metadata === void 0 ? {} : { metadata: Object.freeze({ ...options.metadata }) },
1044
+ ...options.translationMetadata === void 0 ? {} : { translationMetadata: Object.freeze({ ...options.translationMetadata }) }
727
1045
  });
728
1046
  }
729
1047
 
730
1048
  // 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;
1049
+ function mergeLocalizedText(translations, locale, property, value) {
1050
+ return { ...translations, [locale]: { ...translations?.[locale], [property]: value } };
1051
+ }
1052
+ function withTranslationMetadata(node, locale, property, metadata) {
1053
+ if (metadata === void 0) return node;
1054
+ return {
1055
+ ...node,
1056
+ translationMetadata: {
1057
+ ...node.translationMetadata,
1058
+ [locale]: {
1059
+ ...node.translationMetadata?.[locale],
1060
+ [property]: metadata
1061
+ }
1062
+ }
750
1063
  };
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 }
1064
+ }
1065
+ function createSlot(kind, nodeId, property, locale, sourceText, existingText, metadata) {
1066
+ return {
1067
+ kind,
1068
+ nodeId,
1069
+ property,
1070
+ locale,
1071
+ sourceText,
1072
+ ...existingText === void 0 ? {} : { existingText },
1073
+ ...metadata === void 0 ? {} : { metadata }
1074
+ };
1075
+ }
1076
+ function translationSlots(schema, locale) {
1077
+ const descriptors = [];
1078
+ const addFormSlot = (property, sourceText) => {
1079
+ const slot = createSlot(
1080
+ "form",
1081
+ schema.id,
1082
+ property,
1083
+ locale,
1084
+ sourceText,
1085
+ schema.translations?.[locale]?.[property],
1086
+ schema.metadata
1087
+ );
1088
+ descriptors.push({
1089
+ slot,
1090
+ apply: (current, value, metadata) => withTranslationMetadata(
1091
+ { ...current, translations: mergeLocalizedText(current.translations, locale, property, value) },
1092
+ locale,
1093
+ property,
1094
+ metadata
1095
+ )
1096
+ });
1097
+ };
1098
+ addFormSlot("title", schema.title);
1099
+ if (schema.description !== void 0) addFormSlot("description", schema.description);
1100
+ if (schema.completionMessage !== void 0) addFormSlot("completionMessage", schema.completionMessage);
1101
+ schema.fields.forEach((field, fieldIndex) => {
1102
+ const addFieldSlot = (property, sourceText) => {
1103
+ const slot = createSlot(
1104
+ "field",
1105
+ field.id,
1106
+ property,
1107
+ locale,
1108
+ sourceText,
1109
+ field.translations?.[locale]?.[property],
1110
+ field.metadata
1111
+ );
1112
+ descriptors.push({
1113
+ slot,
1114
+ apply: (current, value, metadata) => ({
1115
+ ...current,
1116
+ fields: current.fields.map(
1117
+ (candidate, index) => index === fieldIndex ? withTranslationMetadata(
1118
+ {
1119
+ ...candidate,
1120
+ translations: mergeLocalizedText(candidate.translations, locale, property, value)
1121
+ },
1122
+ locale,
1123
+ property,
1124
+ metadata
1125
+ ) : candidate
1126
+ )
1127
+ })
1128
+ });
1129
+ };
1130
+ addFieldSlot("title", field.title);
1131
+ if (field.description !== void 0) addFieldSlot("description", field.description);
1132
+ if ("options" in field) {
1133
+ field.options.forEach((option, optionIndex) => {
1134
+ const slot = createSlot(
1135
+ "option",
1136
+ option.id,
1137
+ "label",
1138
+ locale,
1139
+ option.label,
1140
+ option.translations?.[locale],
1141
+ option.metadata
1142
+ );
1143
+ descriptors.push({
1144
+ slot,
1145
+ apply: (current, value, metadata) => ({
1146
+ ...current,
1147
+ fields: current.fields.map((candidate, candidateIndex) => {
1148
+ if (candidateIndex !== fieldIndex || !("options" in candidate)) return candidate;
1149
+ return {
1150
+ ...candidate,
1151
+ options: candidate.options.map(
1152
+ (candidateOption, candidateOptionIndex) => candidateOptionIndex === optionIndex ? withTranslationMetadata(
1153
+ {
1154
+ ...candidateOption,
1155
+ translations: { ...candidateOption.translations, [locale]: value }
1156
+ },
1157
+ locale,
1158
+ "label",
1159
+ metadata
1160
+ ) : candidateOption
1161
+ )
1162
+ };
1163
+ })
1164
+ })
1165
+ });
1166
+ });
1167
+ }
1168
+ });
1169
+ schema.pages?.forEach((page, pageIndex) => {
1170
+ const addPageSlot = (property, sourceText) => {
1171
+ const slot = createSlot(
1172
+ "page",
1173
+ page.id,
1174
+ property,
1175
+ locale,
1176
+ sourceText,
1177
+ page.translations?.[locale]?.[property],
1178
+ page.metadata
1179
+ );
1180
+ descriptors.push({
1181
+ slot,
1182
+ apply: (current, value, metadata) => ({
1183
+ ...current,
1184
+ ...current.pages === void 0 ? {} : {
1185
+ pages: current.pages.map(
1186
+ (candidate, index) => index === pageIndex ? withTranslationMetadata(
1187
+ {
1188
+ ...candidate,
1189
+ translations: mergeLocalizedText(candidate.translations, locale, property, value)
1190
+ },
1191
+ locale,
1192
+ property,
1193
+ metadata
1194
+ ) : candidate
1195
+ )
1196
+ }
1197
+ })
1198
+ });
760
1199
  };
761
- if (!("options" in field)) return base;
762
- return { ...base, options: field.options.map((option) => ({ ...option, label: next() })) };
1200
+ if (page.title !== void 0) addPageSlot("title", page.title);
1201
+ if (page.description !== void 0) addPageSlot("description", page.description);
763
1202
  });
764
- const translatedSchema = {
1203
+ return descriptors;
1204
+ }
1205
+ function resolveLocalizedSchema(schema, targetLocale) {
1206
+ if (targetLocale.length === 0 || targetLocale === schema.defaultLocale) return schema;
1207
+ const formTranslation = schema.translations?.[targetLocale];
1208
+ const completionMessage = formTranslation?.completionMessage ?? schema.completionMessage;
1209
+ return {
765
1210
  ...schema,
766
- title,
767
- ...description === void 0 ? {} : { description },
768
- fields
1211
+ title: formTranslation?.title ?? schema.title,
1212
+ ...(formTranslation?.description ?? schema.description) === void 0 ? {} : { description: formTranslation?.description ?? schema.description },
1213
+ ...completionMessage === void 0 ? {} : { completionMessage },
1214
+ fields: schema.fields.map((field) => {
1215
+ const translation = field.translations?.[targetLocale];
1216
+ const localized = {
1217
+ ...field,
1218
+ title: translation?.title ?? field.title,
1219
+ ...(translation?.description ?? field.description) === void 0 ? {} : { description: translation?.description ?? field.description }
1220
+ };
1221
+ if (!("options" in field)) return localized;
1222
+ return {
1223
+ ...localized,
1224
+ options: field.options.map((option) => ({
1225
+ ...option,
1226
+ label: option.translations?.[targetLocale] ?? option.label
1227
+ }))
1228
+ };
1229
+ }),
1230
+ ...schema.pages === void 0 ? {} : {
1231
+ pages: schema.pages.map((page) => {
1232
+ const translation = page.translations?.[targetLocale];
1233
+ const title = translation?.title ?? page.title;
1234
+ const description = translation?.description ?? page.description;
1235
+ return {
1236
+ ...page,
1237
+ ...title === void 0 ? {} : { title },
1238
+ ...description === void 0 ? {} : { description }
1239
+ };
1240
+ })
1241
+ }
769
1242
  };
770
- assertValidFormSchema(translatedSchema);
771
- return translatedSchema;
1243
+ }
1244
+ async function populateSchemaTranslations(schema, targetLocales, adapter, options = {}) {
1245
+ assertValidFormSchema(schema);
1246
+ const locales = [...new Set(targetLocales.filter((locale) => locale.length > 0 && locale !== schema.defaultLocale))];
1247
+ const updatedSlots = [];
1248
+ const skippedSlots = [];
1249
+ let result = schema;
1250
+ for (const locale of locales) {
1251
+ const descriptors = translationSlots(schema, locale);
1252
+ const selected = [];
1253
+ for (const descriptor of descriptors) {
1254
+ const shouldTranslate = options.shouldOverwrite?.(descriptor.slot) ?? (options.overwrite === "all" || descriptor.slot.existingText === void 0);
1255
+ if (shouldTranslate) selected.push(descriptor);
1256
+ else skippedSlots.push(descriptor.slot);
1257
+ }
1258
+ if (selected.length === 0) continue;
1259
+ const translated = await adapter.translateBatch(
1260
+ selected.map((descriptor) => descriptor.slot.sourceText),
1261
+ locale,
1262
+ schema.defaultLocale
1263
+ );
1264
+ if (translated.length !== selected.length) {
1265
+ throw new Error(`Translation adapter returned ${translated.length} texts for ${selected.length} inputs.`);
1266
+ }
1267
+ selected.forEach((descriptor, index) => {
1268
+ const translatedText = translated[index];
1269
+ if (translatedText === void 0) throw new Error("Translation adapter returned an unexpected result.");
1270
+ const metadata = options.createMetadata?.(descriptor.slot, translatedText);
1271
+ result = descriptor.apply(result, translatedText, metadata);
1272
+ updatedSlots.push(descriptor.slot);
1273
+ });
1274
+ }
1275
+ const supportedLocales = [
1276
+ .../* @__PURE__ */ new Set([
1277
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
1278
+ ...schema.supportedLocales ?? [],
1279
+ ...locales
1280
+ ])
1281
+ ];
1282
+ if (supportedLocales.length > 0) result = { ...result, supportedLocales };
1283
+ assertValidFormSchema(result);
1284
+ return { schema: result, report: { updatedSlots, skippedSlots } };
1285
+ }
1286
+ async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocale) {
1287
+ const populated = await populateSchemaTranslations(
1288
+ sourceLocale === void 0 ? schema : { ...schema, defaultLocale: sourceLocale },
1289
+ [targetLocale],
1290
+ adapter,
1291
+ { overwrite: "all" }
1292
+ );
1293
+ return resolveLocalizedSchema(populated.schema, targetLocale);
772
1294
  }
773
1295
  // Annotate the CommonJS export names for ESM import in node:
774
1296
  0 && (module.exports = {
775
1297
  aggregateResponses,
776
1298
  assertValidFormSchema,
777
1299
  calculateChoiceDistribution,
1300
+ calculateCrossTabulation,
778
1301
  calculateFieldVisibility,
779
1302
  calculateNumericSummary,
1303
+ calculatePageVisibility,
780
1304
  createSubmission,
1305
+ dispatchWebhook,
781
1306
  escapeCsvCell,
782
1307
  exportResponsesToCsv,
1308
+ isDisplayConditionSatisfied,
783
1309
  isQuestionVisible,
1310
+ populateSchemaTranslations,
784
1311
  resolveFormTranslation,
1312
+ resolveLocalizedSchema,
785
1313
  sanitizeSchema,
786
1314
  selectVisibleAnswers,
787
1315
  validateAnswers,
788
1316
  validateFormSchema,
1317
+ validatePageAnswers,
789
1318
  validateSchemaStructure
790
1319
  });