@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.js CHANGED
@@ -86,17 +86,46 @@ function validateSchemaStructure(schema) {
86
86
  function sanitizeSchema(schema) {
87
87
  const existingQuestionIds = new Set(schema.fields.map((field) => field.id));
88
88
  const cyclic = cyclicQuestionIds(schema.fields);
89
- return {
89
+ const sanitizedFields = schema.fields.map((field) => {
90
+ const sourceId = field.displayCondition?.questionId;
91
+ if (sourceId === void 0 || existingQuestionIds.has(sourceId) && sourceId !== field.id && !cyclic.has(field.id)) {
92
+ return field;
93
+ }
94
+ const { displayCondition: _displayCondition, ...sanitized } = field;
95
+ return sanitized;
96
+ });
97
+ const base = {
90
98
  ...schema,
91
- fields: schema.fields.map((field) => {
92
- const sourceId = field.displayCondition?.questionId;
93
- if (sourceId === void 0 || existingQuestionIds.has(sourceId) && sourceId !== field.id && !cyclic.has(field.id)) {
94
- return field;
95
- }
96
- const { displayCondition: _displayCondition, ...sanitized } = field;
97
- return sanitized;
98
- })
99
+ fields: sanitizedFields
99
100
  };
101
+ if (schema.pages === void 0) return base;
102
+ const assigned = /* @__PURE__ */ new Set();
103
+ const pages = schema.pages.map((page) => ({
104
+ ...page,
105
+ questionIds: page.questionIds.filter((id) => {
106
+ if (!existingQuestionIds.has(id) || assigned.has(id)) return false;
107
+ assigned.add(id);
108
+ return true;
109
+ })
110
+ })).filter((page) => page.questionIds.length > 0);
111
+ if (pages.length === 0) {
112
+ const { pages: _pages, ...singlePage } = base;
113
+ return singlePage;
114
+ }
115
+ const unassigned = schema.fields.map((field) => field.id).filter((id) => !assigned.has(id));
116
+ const completePages = pages.map(
117
+ (page, index) => index === pages.length - 1 ? { ...page, questionIds: [...page.questionIds, ...unassigned] } : page
118
+ );
119
+ const pageIndexByQuestion = new Map(
120
+ completePages.flatMap((page, pageIndex) => page.questionIds.map((id) => [id, pageIndex]))
121
+ );
122
+ const safePages = completePages.map((page, pageIndex) => {
123
+ const sourceIndex = page.displayCondition === void 0 ? void 0 : pageIndexByQuestion.get(page.displayCondition.questionId);
124
+ if (page.displayCondition === void 0 || sourceIndex !== void 0 && sourceIndex < pageIndex) return page;
125
+ const { displayCondition: _displayCondition, ...safePage } = page;
126
+ return safePage;
127
+ });
128
+ return { ...base, pages: safePages };
100
129
  }
101
130
 
102
131
  // src/schema.ts
@@ -111,6 +140,80 @@ function isNonEmptyString(value) {
111
140
  function issue(issues, path, code, message) {
112
141
  issues.push({ path, code, message });
113
142
  }
143
+ function validateJsonValue(value, path, issues, ancestors = /* @__PURE__ */ new Set()) {
144
+ if (value === null || typeof value === "string" || typeof value === "boolean") return;
145
+ if (typeof value === "number") {
146
+ if (!Number.isFinite(value)) issue(issues, path, "invalid_metadata", "Metadata numbers must be finite.");
147
+ return;
148
+ }
149
+ if (typeof value !== "object") {
150
+ issue(issues, path, "invalid_metadata", "Expected JSON-serializable metadata.");
151
+ return;
152
+ }
153
+ if (ancestors.has(value)) {
154
+ issue(issues, path, "invalid_metadata", "Metadata must not contain cycles.");
155
+ return;
156
+ }
157
+ const nextAncestors = new Set(ancestors).add(value);
158
+ if (Array.isArray(value)) {
159
+ value.forEach((item, index) => {
160
+ validateJsonValue(item, `${path}[${index}]`, issues, nextAncestors);
161
+ });
162
+ return;
163
+ }
164
+ const prototype = Object.getPrototypeOf(value);
165
+ if (prototype !== Object.prototype && prototype !== null) {
166
+ issue(issues, path, "invalid_metadata", "Metadata objects must be plain JSON objects.");
167
+ return;
168
+ }
169
+ for (const [key, item] of Object.entries(value)) {
170
+ validateJsonValue(item, `${path}.${key}`, issues, nextAncestors);
171
+ }
172
+ }
173
+ function validateExtensibleNode(value, path, issues) {
174
+ for (const property of ["metadata", "translationMetadata"]) {
175
+ const candidate = value[property];
176
+ if (candidate === void 0) continue;
177
+ if (!isRecord(candidate)) {
178
+ issue(
179
+ issues,
180
+ path.length === 0 ? property : `${path}.${property}`,
181
+ "invalid_metadata",
182
+ "Expected a metadata object."
183
+ );
184
+ continue;
185
+ }
186
+ validateJsonValue(candidate, path.length === 0 ? property : `${path}.${property}`, issues);
187
+ }
188
+ }
189
+ function validateLocalizedTextMap(value, path, issues) {
190
+ if (!isRecord(value)) {
191
+ issue(issues, path, "invalid_translations", "Expected a locale-to-translation object.");
192
+ return;
193
+ }
194
+ for (const [locale, translation] of Object.entries(value)) {
195
+ if (!isNonEmptyString(locale) || !isRecord(translation)) {
196
+ issue(issues, `${path}.${locale}`, "invalid_translation", "Expected a translation object.");
197
+ continue;
198
+ }
199
+ for (const key of ["title", "description", "completionMessage"]) {
200
+ if (translation[key] !== void 0 && !isNonEmptyString(translation[key])) {
201
+ issue(issues, `${path}.${locale}.${key}`, "invalid_translation", "Expected non-empty translated text.");
202
+ }
203
+ }
204
+ }
205
+ }
206
+ function validateOptionTranslations(value, path, issues) {
207
+ if (!isRecord(value)) {
208
+ issue(issues, path, "invalid_translations", "Expected a locale-to-label object.");
209
+ return;
210
+ }
211
+ for (const [locale, label] of Object.entries(value)) {
212
+ if (!isNonEmptyString(locale) || !isNonEmptyString(label)) {
213
+ issue(issues, `${path}.${locale}`, "invalid_translation", "Expected a non-empty translated label.");
214
+ }
215
+ }
216
+ }
114
217
  function rejectLegacyProperties(value, path, properties, issues) {
115
218
  for (const property of properties) {
116
219
  if (Object.hasOwn(value, property)) {
@@ -144,6 +247,7 @@ function validateOptions(value, path, issues) {
144
247
  return;
145
248
  }
146
249
  rejectLegacyProperties(option, optionPath, ["value", "labelKey"], issues);
250
+ validateExtensibleNode(option, optionPath, issues);
147
251
  if (!isNonEmptyString(option.id)) {
148
252
  issue(issues, `${optionPath}.id`, "invalid_option_id", "Expected a non-empty option ID.");
149
253
  } else if (seen.has(option.id)) {
@@ -154,6 +258,8 @@ function validateOptions(value, path, issues) {
154
258
  if (!isNonEmptyString(option.label)) {
155
259
  issue(issues, `${optionPath}.label`, "invalid_label", "Expected a non-empty option label.");
156
260
  }
261
+ if (option.translations !== void 0)
262
+ validateOptionTranslations(option.translations, `${optionPath}.translations`, issues);
157
263
  });
158
264
  return true;
159
265
  }
@@ -187,6 +293,7 @@ function validateField(value, path, issues) {
187
293
  return false;
188
294
  }
189
295
  rejectLegacyProperties(value, path, ["titleKey", "labelKey", "helpTextKey", "descriptionKey"], issues);
296
+ validateExtensibleNode(value, path, issues);
190
297
  if (!isNonEmptyString(value.id)) issue(issues, `${path}.id`, "invalid_id", "Expected a non-empty ID.");
191
298
  if (!isNonEmptyString(value.title)) {
192
299
  issue(issues, `${path}.title`, "invalid_title", "Expected a non-empty question title.");
@@ -203,6 +310,7 @@ function validateField(value, path, issues) {
203
310
  if (value.displayCondition !== void 0) {
204
311
  validateDisplayCondition(value.displayCondition, `${path}.displayCondition`, issues);
205
312
  }
313
+ if (value.translations !== void 0) validateLocalizedTextMap(value.translations, `${path}.translations`, issues);
206
314
  if (typeof value.type !== "string" || !FIELD_TYPES.has(value.type)) {
207
315
  issue(issues, `${path}.type`, "invalid_field_type", "Unsupported field type.");
208
316
  return false;
@@ -271,6 +379,7 @@ function validateFormSchema(input) {
271
379
  return { valid: false, issues: [{ path: "$", code: "invalid_schema", message: "Expected a schema object." }] };
272
380
  }
273
381
  rejectLegacyProperties(input, "", ["titleKey", "descriptionKey"], issues);
382
+ validateExtensibleNode(input, "", issues);
274
383
  if (!isNonEmptyString(input.id)) issue(issues, "id", "invalid_id", "Expected a non-empty ID.");
275
384
  if (!Number.isInteger(input.version) || input.version < 1) {
276
385
  issue(issues, "version", "invalid_version", "Expected a positive integer version.");
@@ -278,16 +387,36 @@ function validateFormSchema(input) {
278
387
  if (!isNonEmptyString(input.title)) {
279
388
  issue(issues, "title", "invalid_title", "Expected a non-empty form title.");
280
389
  }
281
- for (const key of ["description", "submitLabelKey"]) {
390
+ for (const key of ["description", "completionMessage", "submitLabelKey"]) {
282
391
  if (input[key] !== void 0 && !isNonEmptyString(input[key])) {
283
392
  issue(
284
393
  issues,
285
394
  key,
286
- key === "description" ? "invalid_description" : "invalid_translation_key",
287
- key === "description" ? "Expected a non-empty form description." : "Expected a translation key."
395
+ key === "submitLabelKey" ? "invalid_translation_key" : "invalid_description",
396
+ key === "submitLabelKey" ? "Expected a translation key." : "Expected non-empty form text."
288
397
  );
289
398
  }
290
399
  }
400
+ if (input.defaultLocale !== void 0 && !isNonEmptyString(input.defaultLocale)) {
401
+ issue(issues, "defaultLocale", "invalid_locale", "Expected a non-empty default locale.");
402
+ }
403
+ if (input.supportedLocales !== void 0) {
404
+ if (!Array.isArray(input.supportedLocales) || input.supportedLocales.length === 0) {
405
+ issue(issues, "supportedLocales", "invalid_locales", "Expected at least one supported locale.");
406
+ } else {
407
+ const locales = /* @__PURE__ */ new Set();
408
+ input.supportedLocales.forEach((locale, index) => {
409
+ if (!isNonEmptyString(locale)) {
410
+ issue(issues, `supportedLocales[${index}]`, "invalid_locale", "Expected a non-empty locale.");
411
+ } else if (locales.has(locale)) {
412
+ issue(issues, `supportedLocales[${index}]`, "duplicate_locale", "Locales must be unique.");
413
+ } else {
414
+ locales.add(locale);
415
+ }
416
+ });
417
+ }
418
+ }
419
+ if (input.translations !== void 0) validateLocalizedTextMap(input.translations, "translations", issues);
291
420
  if (!Array.isArray(input.fields) || input.fields.length === 0) {
292
421
  issue(issues, "fields", "invalid_fields", "Expected at least one field.");
293
422
  } else {
@@ -314,6 +443,81 @@ function validateFormSchema(input) {
314
443
  const code = structuralIssue.type === "dangling_condition_reference" ? "unknown_condition_source" : structuralIssue.type === "self_condition_reference" ? "self_condition" : "condition_cycle";
315
444
  issue(issues, `fields[${fieldIndex}].displayCondition`, code, structuralIssue.message);
316
445
  }
446
+ if (input.pages !== void 0) {
447
+ if (!Array.isArray(input.pages) || input.pages.length === 0) {
448
+ issue(issues, "pages", "invalid_pages", "Expected at least one page when pages is defined.");
449
+ } else {
450
+ const pageIds = /* @__PURE__ */ new Set();
451
+ const assigned = /* @__PURE__ */ new Map();
452
+ input.pages.forEach((page, pageIndex) => {
453
+ const pagePath = `pages[${pageIndex}]`;
454
+ if (!isRecord(page)) {
455
+ issue(issues, pagePath, "invalid_page", "Expected a page object.");
456
+ return;
457
+ }
458
+ validateExtensibleNode(page, pagePath, issues);
459
+ if (!isNonEmptyString(page.id)) {
460
+ issue(issues, `${pagePath}.id`, "invalid_page_id", "Expected a non-empty page ID.");
461
+ } else if (pageIds.has(page.id)) {
462
+ issue(issues, `${pagePath}.id`, "duplicate_page", "Page IDs must be unique.");
463
+ } else {
464
+ pageIds.add(page.id);
465
+ }
466
+ for (const key of ["title", "description"]) {
467
+ if (page[key] !== void 0 && !isNonEmptyString(page[key])) {
468
+ issue(issues, `${pagePath}.${key}`, "invalid_page_text", "Expected non-empty page text.");
469
+ }
470
+ }
471
+ if (page.translations !== void 0) {
472
+ validateLocalizedTextMap(page.translations, `${pagePath}.translations`, issues);
473
+ }
474
+ if (page.displayCondition !== void 0) {
475
+ validateDisplayCondition(page.displayCondition, `${pagePath}.displayCondition`, issues);
476
+ }
477
+ if (!Array.isArray(page.questionIds) || page.questionIds.length === 0) {
478
+ issue(issues, `${pagePath}.questionIds`, "invalid_page_questions", "Expected at least one question ID.");
479
+ return;
480
+ }
481
+ page.questionIds.forEach((questionId, questionIndex) => {
482
+ const questionPath = `${pagePath}.questionIds[${questionIndex}]`;
483
+ if (!isNonEmptyString(questionId)) {
484
+ issue(issues, questionPath, "invalid_question_reference", "Expected a question ID.");
485
+ } else if (!ids.has(questionId)) {
486
+ issue(issues, questionPath, "unknown_page_question", "Page references an unknown question.");
487
+ } else if (assigned.has(questionId)) {
488
+ issue(issues, questionPath, "duplicate_page_question", "A question may belong to only one page.");
489
+ } else {
490
+ assigned.set(questionId, pageIndex);
491
+ }
492
+ });
493
+ });
494
+ for (const fieldId of ids) {
495
+ if (!assigned.has(fieldId))
496
+ issue(issues, "pages", "unassigned_page_question", `Question ${fieldId} has no page.`);
497
+ }
498
+ input.pages.forEach((page, pageIndex) => {
499
+ if (!isRecord(page) || !isRecord(page.displayCondition)) return;
500
+ const sourceId = page.displayCondition.questionId;
501
+ if (typeof sourceId !== "string") return;
502
+ const sourcePageIndex = assigned.get(sourceId);
503
+ if (sourcePageIndex === void 0) {
504
+ issue(
505
+ issues,
506
+ `pages[${pageIndex}].displayCondition.questionId`,
507
+ "unknown_page_condition_source",
508
+ "Page condition references an unknown question."
509
+ );
510
+ } else if (sourcePageIndex >= pageIndex) {
511
+ issue(
512
+ issues,
513
+ `pages[${pageIndex}].displayCondition.questionId`,
514
+ "forward_page_condition",
515
+ "Page conditions must reference a question on an earlier page."
516
+ );
517
+ }
518
+ });
519
+ }
520
+ }
317
521
  }
318
522
  return issues.length === 0 ? { valid: true, value: input, issues: [] } : { valid: false, issues };
319
523
  }
@@ -341,7 +545,9 @@ function valuesEqual(left, right) {
341
545
  return typeof left === "string" && typeof right === "string" ? normalizeString(left) === normalizeString(right) : left === right;
342
546
  }
343
547
  function isQuestionVisible(question, currentAnswers) {
344
- const condition = question.displayCondition;
548
+ return isDisplayConditionSatisfied(question.displayCondition, currentAnswers);
549
+ }
550
+ function isDisplayConditionSatisfied(condition, currentAnswers) {
345
551
  if (condition === void 0) return true;
346
552
  const answer = currentAnswers[condition.questionId];
347
553
  if (isEmpty(answer)) return false;
@@ -354,7 +560,7 @@ function isQuestionVisible(question, currentAnswers) {
354
560
  }
355
561
  return Array.isArray(answer) && answer.some((item) => valuesEqual(item, condition.value));
356
562
  }
357
- function calculateFieldVisibility(schema, currentAnswers) {
563
+ function calculateBaseFieldVisibility(schema, currentAnswers) {
358
564
  const fields = new Map(schema.fields.map((field) => [field.id, field]));
359
565
  const resolved = /* @__PURE__ */ new Map();
360
566
  const resolving = /* @__PURE__ */ new Set();
@@ -372,7 +578,34 @@ function calculateFieldVisibility(schema, currentAnswers) {
372
578
  return visible;
373
579
  };
374
580
  for (const field of schema.fields) resolve(field);
375
- return Object.freeze(Object.fromEntries(resolved));
581
+ return Object.fromEntries(resolved);
582
+ }
583
+ function calculatePageVisibility(schema, currentAnswers) {
584
+ if (schema.pages === void 0 || schema.pages.length === 0) return {};
585
+ const baseFieldVisibility = calculateBaseFieldVisibility(schema, currentAnswers);
586
+ const pageByQuestion = new Map(schema.pages.flatMap((page) => page.questionIds.map((id) => [id, page.id])));
587
+ const pageVisibility = {};
588
+ for (const page of schema.pages) {
589
+ const sourceId = page.displayCondition?.questionId;
590
+ const sourcePageId = sourceId === void 0 ? void 0 : pageByQuestion.get(sourceId);
591
+ const sourceVisible = sourceId === void 0 || baseFieldVisibility[sourceId] === true && sourcePageId !== void 0 && pageVisibility[sourcePageId] === true;
592
+ pageVisibility[page.id] = sourceVisible && isDisplayConditionSatisfied(page.displayCondition, currentAnswers);
593
+ }
594
+ return Object.freeze(pageVisibility);
595
+ }
596
+ function calculateFieldVisibility(schema, currentAnswers) {
597
+ const baseVisibility = calculateBaseFieldVisibility(schema, currentAnswers);
598
+ if (schema.pages === void 0 || schema.pages.length === 0) return Object.freeze(baseVisibility);
599
+ const pageVisibility = calculatePageVisibility(schema, currentAnswers);
600
+ const pageByQuestion = new Map(schema.pages.flatMap((page) => page.questionIds.map((id) => [id, page.id])));
601
+ return Object.freeze(
602
+ Object.fromEntries(
603
+ schema.fields.map((field) => {
604
+ const pageId = pageByQuestion.get(field.id);
605
+ return [field.id, baseVisibility[field.id] === true && pageId !== void 0 && pageVisibility[pageId] === true];
606
+ })
607
+ )
608
+ );
376
609
  }
377
610
  function selectVisibleAnswers(schema, currentAnswers) {
378
611
  const visibility = calculateFieldVisibility(schema, currentAnswers);
@@ -406,6 +639,23 @@ function calculateNumericSummary(responses, questionId) {
406
639
  max: numbers.length === 0 ? null : Math.max(...numbers)
407
640
  };
408
641
  }
642
+ function calculateCrossTabulation(responses, rowQuestionId, colQuestionId) {
643
+ const matrix = {};
644
+ const rowTotals = {};
645
+ const colTotals = {};
646
+ let grandTotal = 0;
647
+ for (const response of responses) {
648
+ const row = response.values[rowQuestionId];
649
+ const col = response.values[colQuestionId];
650
+ if (typeof row !== "string" || row.length === 0 || typeof col !== "string" || col.length === 0) continue;
651
+ matrix[row] ??= {};
652
+ matrix[row][col] = (matrix[row][col] ?? 0) + 1;
653
+ rowTotals[row] = (rowTotals[row] ?? 0) + 1;
654
+ colTotals[col] = (colTotals[col] ?? 0) + 1;
655
+ grandTotal += 1;
656
+ }
657
+ return { rowQuestionId, colQuestionId, matrix, rowTotals, colTotals, grandTotal };
658
+ }
409
659
  function valueIsValid(field, value) {
410
660
  if (value === void 0 || value === "") return false;
411
661
  if (field.type === "text" || field.type === "textarea") {
@@ -497,15 +747,20 @@ function aggregateResponses(schema, submissions) {
497
747
  questions: schema.fields.map((field) => aggregateField(schema, field, submissions))
498
748
  };
499
749
  }
500
- function escapeCsvCell(value) {
750
+ function escapeCsvCell(value, neutralizeFormulas = true) {
501
751
  if (value === null || value === void 0) return "";
502
- const stringValue = String(value);
752
+ let stringValue = String(value);
753
+ if (neutralizeFormulas && typeof value === "string") {
754
+ const trimmed = stringValue.trimStart();
755
+ if (trimmed.length > 0 && ["=", "+", "-", "@"].includes(trimmed[0] ?? "")) {
756
+ stringValue = `'${stringValue}`;
757
+ }
758
+ }
503
759
  return /[",\r\n]/.test(stringValue) ? `"${stringValue.replaceAll('"', '""')}"` : stringValue;
504
760
  }
505
761
  function serializeValue(value) {
506
762
  if (value === void 0) return "";
507
- if (Array.isArray(value)) return JSON.stringify(value);
508
- return String(value);
763
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value);
509
764
  }
510
765
  function exportResponsesToCsv(schema, responses, options = {}) {
511
766
  assertValidFormSchema(schema);
@@ -526,10 +781,52 @@ function exportResponsesToCsv(schema, responses, options = {}) {
526
781
  ];
527
782
  })
528
783
  ];
529
- const csv = rows.map((row) => row.map((cell) => escapeCsvCell(cell)).join(",")).join("\r\n");
784
+ const neutralizeFormulas = options.neutralizeFormulas ?? true;
785
+ const csv = rows.map((row) => row.map((cell) => escapeCsvCell(cell, neutralizeFormulas)).join(",")).join("\r\n");
530
786
  return options.withBom ?? true ? `\uFEFF${csv}` : csv;
531
787
  }
532
788
 
789
+ // src/events.ts
790
+ function bytesToHex(bytes) {
791
+ return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
792
+ }
793
+ async function signPayload(payload, secret) {
794
+ if (globalThis.crypto?.subtle === void 0) throw new Error("Web Crypto is unavailable for webhook signing.");
795
+ const encoder = new TextEncoder();
796
+ const key = await globalThis.crypto.subtle.importKey(
797
+ "raw",
798
+ encoder.encode(secret),
799
+ { name: "HMAC", hash: "SHA-256" },
800
+ false,
801
+ ["sign"]
802
+ );
803
+ return bytesToHex(await globalThis.crypto.subtle.sign("HMAC", key, encoder.encode(payload)));
804
+ }
805
+ async function dispatchWebhook(event, config, fetchImpl = globalThis.fetch) {
806
+ const body = JSON.stringify(event);
807
+ const controller = new AbortController();
808
+ const timeoutMs = config.timeoutMs ?? 5e3;
809
+ const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs);
810
+ try {
811
+ const headers = { "content-type": "application/json", ...config.headers };
812
+ if (config.secret !== void 0) {
813
+ headers["X-Form-Engine-Signature"] = await signPayload(body, config.secret);
814
+ }
815
+ const response = await fetchImpl(config.url, {
816
+ method: "POST",
817
+ headers,
818
+ body,
819
+ signal: controller.signal
820
+ });
821
+ return response.ok ? { success: true, status: response.status } : { success: false, status: response.status, error: `Webhook returned HTTP ${response.status}.` };
822
+ } catch (cause) {
823
+ const error = controller.signal.aborted ? `Webhook request timed out after ${timeoutMs}ms.` : cause instanceof Error ? cause.message : String(cause);
824
+ return { success: false, error };
825
+ } finally {
826
+ globalThis.clearTimeout(timeout);
827
+ }
828
+ }
829
+
533
830
  // src/validation.ts
534
831
  var DEFAULT_MESSAGES = {
535
832
  required: "validation.required",
@@ -656,6 +953,18 @@ function validateAnswers(schema, values) {
656
953
  }
657
954
  return issues.length === 0 ? { valid: true, issues: [] } : { valid: false, issues };
658
955
  }
956
+ function validatePageAnswers(schema, pageIndex, values) {
957
+ if (schema.pages === void 0 || schema.pages.length === 0) return validateAnswers(schema, values);
958
+ const page = schema.pages[pageIndex];
959
+ if (page === void 0) return { valid: true, issues: [] };
960
+ const targetIds = new Set(page.questionIds);
961
+ const visibility = calculateFieldVisibility(schema, values);
962
+ const issues = [];
963
+ for (const field of schema.fields) {
964
+ if (targetIds.has(field.id) && visibility[field.id] === true) validateField2(field, values[field.id], issues);
965
+ }
966
+ return issues.length === 0 ? { valid: true, issues: [] } : { valid: false, issues };
967
+ }
659
968
 
660
969
  // src/submission.ts
661
970
  function cloneValues(values) {
@@ -683,67 +992,280 @@ function createSubmission(schema, values, options) {
683
992
  formVersion: schema.version,
684
993
  locale: options.locale,
685
994
  values: Object.freeze(cloneValues(visibleValues)),
686
- submittedAt: options.submittedAt
995
+ submittedAt: options.submittedAt,
996
+ ...options.metadata === void 0 ? {} : { metadata: Object.freeze({ ...options.metadata }) },
997
+ ...options.translationMetadata === void 0 ? {} : { translationMetadata: Object.freeze({ ...options.translationMetadata }) }
687
998
  });
688
999
  }
689
1000
 
690
1001
  // src/translation.ts
691
- async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocale) {
692
- assertValidFormSchema(schema);
693
- const texts = [schema.title];
694
- if (schema.description !== void 0) texts.push(schema.description);
695
- for (const field of schema.fields) {
696
- texts.push(field.title);
697
- if (field.description !== void 0) texts.push(field.description);
698
- if ("options" in field) texts.push(...field.options.map((option) => option.label));
699
- }
700
- const translated = await adapter.translateBatch(texts, targetLocale, sourceLocale);
701
- if (translated.length !== texts.length) {
702
- throw new Error(`Translation adapter returned ${translated.length} texts for ${texts.length} inputs.`);
703
- }
704
- let index = 0;
705
- const next = () => {
706
- const value = translated[index];
707
- index += 1;
708
- if (value === void 0) throw new Error("Translation adapter returned an incomplete result.");
709
- return value;
1002
+ function mergeLocalizedText(translations, locale, property, value) {
1003
+ return { ...translations, [locale]: { ...translations?.[locale], [property]: value } };
1004
+ }
1005
+ function withTranslationMetadata(node, locale, property, metadata) {
1006
+ if (metadata === void 0) return node;
1007
+ return {
1008
+ ...node,
1009
+ translationMetadata: {
1010
+ ...node.translationMetadata,
1011
+ [locale]: {
1012
+ ...node.translationMetadata?.[locale],
1013
+ [property]: metadata
1014
+ }
1015
+ }
710
1016
  };
711
- const title = next();
712
- const description = schema.description === void 0 ? void 0 : next();
713
- const fields = schema.fields.map((field) => {
714
- const translatedTitle = next();
715
- const translatedDescription = field.description === void 0 ? void 0 : next();
716
- const base = {
717
- ...field,
718
- title: translatedTitle,
719
- ...translatedDescription === void 0 ? {} : { description: translatedDescription }
1017
+ }
1018
+ function createSlot(kind, nodeId, property, locale, sourceText, existingText, metadata) {
1019
+ return {
1020
+ kind,
1021
+ nodeId,
1022
+ property,
1023
+ locale,
1024
+ sourceText,
1025
+ ...existingText === void 0 ? {} : { existingText },
1026
+ ...metadata === void 0 ? {} : { metadata }
1027
+ };
1028
+ }
1029
+ function translationSlots(schema, locale) {
1030
+ const descriptors = [];
1031
+ const addFormSlot = (property, sourceText) => {
1032
+ const slot = createSlot(
1033
+ "form",
1034
+ schema.id,
1035
+ property,
1036
+ locale,
1037
+ sourceText,
1038
+ schema.translations?.[locale]?.[property],
1039
+ schema.metadata
1040
+ );
1041
+ descriptors.push({
1042
+ slot,
1043
+ apply: (current, value, metadata) => withTranslationMetadata(
1044
+ { ...current, translations: mergeLocalizedText(current.translations, locale, property, value) },
1045
+ locale,
1046
+ property,
1047
+ metadata
1048
+ )
1049
+ });
1050
+ };
1051
+ addFormSlot("title", schema.title);
1052
+ if (schema.description !== void 0) addFormSlot("description", schema.description);
1053
+ if (schema.completionMessage !== void 0) addFormSlot("completionMessage", schema.completionMessage);
1054
+ schema.fields.forEach((field, fieldIndex) => {
1055
+ const addFieldSlot = (property, sourceText) => {
1056
+ const slot = createSlot(
1057
+ "field",
1058
+ field.id,
1059
+ property,
1060
+ locale,
1061
+ sourceText,
1062
+ field.translations?.[locale]?.[property],
1063
+ field.metadata
1064
+ );
1065
+ descriptors.push({
1066
+ slot,
1067
+ apply: (current, value, metadata) => ({
1068
+ ...current,
1069
+ fields: current.fields.map(
1070
+ (candidate, index) => index === fieldIndex ? withTranslationMetadata(
1071
+ {
1072
+ ...candidate,
1073
+ translations: mergeLocalizedText(candidate.translations, locale, property, value)
1074
+ },
1075
+ locale,
1076
+ property,
1077
+ metadata
1078
+ ) : candidate
1079
+ )
1080
+ })
1081
+ });
1082
+ };
1083
+ addFieldSlot("title", field.title);
1084
+ if (field.description !== void 0) addFieldSlot("description", field.description);
1085
+ if ("options" in field) {
1086
+ field.options.forEach((option, optionIndex) => {
1087
+ const slot = createSlot(
1088
+ "option",
1089
+ option.id,
1090
+ "label",
1091
+ locale,
1092
+ option.label,
1093
+ option.translations?.[locale],
1094
+ option.metadata
1095
+ );
1096
+ descriptors.push({
1097
+ slot,
1098
+ apply: (current, value, metadata) => ({
1099
+ ...current,
1100
+ fields: current.fields.map((candidate, candidateIndex) => {
1101
+ if (candidateIndex !== fieldIndex || !("options" in candidate)) return candidate;
1102
+ return {
1103
+ ...candidate,
1104
+ options: candidate.options.map(
1105
+ (candidateOption, candidateOptionIndex) => candidateOptionIndex === optionIndex ? withTranslationMetadata(
1106
+ {
1107
+ ...candidateOption,
1108
+ translations: { ...candidateOption.translations, [locale]: value }
1109
+ },
1110
+ locale,
1111
+ "label",
1112
+ metadata
1113
+ ) : candidateOption
1114
+ )
1115
+ };
1116
+ })
1117
+ })
1118
+ });
1119
+ });
1120
+ }
1121
+ });
1122
+ schema.pages?.forEach((page, pageIndex) => {
1123
+ const addPageSlot = (property, sourceText) => {
1124
+ const slot = createSlot(
1125
+ "page",
1126
+ page.id,
1127
+ property,
1128
+ locale,
1129
+ sourceText,
1130
+ page.translations?.[locale]?.[property],
1131
+ page.metadata
1132
+ );
1133
+ descriptors.push({
1134
+ slot,
1135
+ apply: (current, value, metadata) => ({
1136
+ ...current,
1137
+ ...current.pages === void 0 ? {} : {
1138
+ pages: current.pages.map(
1139
+ (candidate, index) => index === pageIndex ? withTranslationMetadata(
1140
+ {
1141
+ ...candidate,
1142
+ translations: mergeLocalizedText(candidate.translations, locale, property, value)
1143
+ },
1144
+ locale,
1145
+ property,
1146
+ metadata
1147
+ ) : candidate
1148
+ )
1149
+ }
1150
+ })
1151
+ });
720
1152
  };
721
- if (!("options" in field)) return base;
722
- return { ...base, options: field.options.map((option) => ({ ...option, label: next() })) };
1153
+ if (page.title !== void 0) addPageSlot("title", page.title);
1154
+ if (page.description !== void 0) addPageSlot("description", page.description);
723
1155
  });
724
- const translatedSchema = {
1156
+ return descriptors;
1157
+ }
1158
+ function resolveLocalizedSchema(schema, targetLocale) {
1159
+ if (targetLocale.length === 0 || targetLocale === schema.defaultLocale) return schema;
1160
+ const formTranslation = schema.translations?.[targetLocale];
1161
+ const completionMessage = formTranslation?.completionMessage ?? schema.completionMessage;
1162
+ return {
725
1163
  ...schema,
726
- title,
727
- ...description === void 0 ? {} : { description },
728
- fields
1164
+ title: formTranslation?.title ?? schema.title,
1165
+ ...(formTranslation?.description ?? schema.description) === void 0 ? {} : { description: formTranslation?.description ?? schema.description },
1166
+ ...completionMessage === void 0 ? {} : { completionMessage },
1167
+ fields: schema.fields.map((field) => {
1168
+ const translation = field.translations?.[targetLocale];
1169
+ const localized = {
1170
+ ...field,
1171
+ title: translation?.title ?? field.title,
1172
+ ...(translation?.description ?? field.description) === void 0 ? {} : { description: translation?.description ?? field.description }
1173
+ };
1174
+ if (!("options" in field)) return localized;
1175
+ return {
1176
+ ...localized,
1177
+ options: field.options.map((option) => ({
1178
+ ...option,
1179
+ label: option.translations?.[targetLocale] ?? option.label
1180
+ }))
1181
+ };
1182
+ }),
1183
+ ...schema.pages === void 0 ? {} : {
1184
+ pages: schema.pages.map((page) => {
1185
+ const translation = page.translations?.[targetLocale];
1186
+ const title = translation?.title ?? page.title;
1187
+ const description = translation?.description ?? page.description;
1188
+ return {
1189
+ ...page,
1190
+ ...title === void 0 ? {} : { title },
1191
+ ...description === void 0 ? {} : { description }
1192
+ };
1193
+ })
1194
+ }
729
1195
  };
730
- assertValidFormSchema(translatedSchema);
731
- return translatedSchema;
1196
+ }
1197
+ async function populateSchemaTranslations(schema, targetLocales, adapter, options = {}) {
1198
+ assertValidFormSchema(schema);
1199
+ const locales = [...new Set(targetLocales.filter((locale) => locale.length > 0 && locale !== schema.defaultLocale))];
1200
+ const updatedSlots = [];
1201
+ const skippedSlots = [];
1202
+ let result = schema;
1203
+ for (const locale of locales) {
1204
+ const descriptors = translationSlots(schema, locale);
1205
+ const selected = [];
1206
+ for (const descriptor of descriptors) {
1207
+ const shouldTranslate = options.shouldOverwrite?.(descriptor.slot) ?? (options.overwrite === "all" || descriptor.slot.existingText === void 0);
1208
+ if (shouldTranslate) selected.push(descriptor);
1209
+ else skippedSlots.push(descriptor.slot);
1210
+ }
1211
+ if (selected.length === 0) continue;
1212
+ const translated = await adapter.translateBatch(
1213
+ selected.map((descriptor) => descriptor.slot.sourceText),
1214
+ locale,
1215
+ schema.defaultLocale
1216
+ );
1217
+ if (translated.length !== selected.length) {
1218
+ throw new Error(`Translation adapter returned ${translated.length} texts for ${selected.length} inputs.`);
1219
+ }
1220
+ selected.forEach((descriptor, index) => {
1221
+ const translatedText = translated[index];
1222
+ if (translatedText === void 0) throw new Error("Translation adapter returned an unexpected result.");
1223
+ const metadata = options.createMetadata?.(descriptor.slot, translatedText);
1224
+ result = descriptor.apply(result, translatedText, metadata);
1225
+ updatedSlots.push(descriptor.slot);
1226
+ });
1227
+ }
1228
+ const supportedLocales = [
1229
+ .../* @__PURE__ */ new Set([
1230
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
1231
+ ...schema.supportedLocales ?? [],
1232
+ ...locales
1233
+ ])
1234
+ ];
1235
+ if (supportedLocales.length > 0) result = { ...result, supportedLocales };
1236
+ assertValidFormSchema(result);
1237
+ return { schema: result, report: { updatedSlots, skippedSlots } };
1238
+ }
1239
+ async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocale) {
1240
+ const populated = await populateSchemaTranslations(
1241
+ sourceLocale === void 0 ? schema : { ...schema, defaultLocale: sourceLocale },
1242
+ [targetLocale],
1243
+ adapter,
1244
+ { overwrite: "all" }
1245
+ );
1246
+ return resolveLocalizedSchema(populated.schema, targetLocale);
732
1247
  }
733
1248
  export {
734
1249
  aggregateResponses,
735
1250
  assertValidFormSchema,
736
1251
  calculateChoiceDistribution,
1252
+ calculateCrossTabulation,
737
1253
  calculateFieldVisibility,
738
1254
  calculateNumericSummary,
1255
+ calculatePageVisibility,
739
1256
  createSubmission,
1257
+ dispatchWebhook,
740
1258
  escapeCsvCell,
741
1259
  exportResponsesToCsv,
1260
+ isDisplayConditionSatisfied,
742
1261
  isQuestionVisible,
1262
+ populateSchemaTranslations,
743
1263
  resolveFormTranslation,
1264
+ resolveLocalizedSchema,
744
1265
  sanitizeSchema,
745
1266
  selectVisibleAnswers,
746
1267
  validateAnswers,
747
1268
  validateFormSchema,
1269
+ validatePageAnswers,
748
1270
  validateSchemaStructure
749
1271
  };