@form-engine-ts/core 1.0.0 → 1.1.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,34 @@ function isNonEmptyString(value) {
111
140
  function issue(issues, path, code, message) {
112
141
  issues.push({ path, code, message });
113
142
  }
143
+ function validateLocalizedTextMap(value, path, issues) {
144
+ if (!isRecord(value)) {
145
+ issue(issues, path, "invalid_translations", "Expected a locale-to-translation object.");
146
+ return;
147
+ }
148
+ for (const [locale, translation] of Object.entries(value)) {
149
+ if (!isNonEmptyString(locale) || !isRecord(translation)) {
150
+ issue(issues, `${path}.${locale}`, "invalid_translation", "Expected a translation object.");
151
+ continue;
152
+ }
153
+ for (const key of ["title", "description"]) {
154
+ if (translation[key] !== void 0 && !isNonEmptyString(translation[key])) {
155
+ issue(issues, `${path}.${locale}.${key}`, "invalid_translation", "Expected non-empty translated text.");
156
+ }
157
+ }
158
+ }
159
+ }
160
+ function validateOptionTranslations(value, path, issues) {
161
+ if (!isRecord(value)) {
162
+ issue(issues, path, "invalid_translations", "Expected a locale-to-label object.");
163
+ return;
164
+ }
165
+ for (const [locale, label] of Object.entries(value)) {
166
+ if (!isNonEmptyString(locale) || !isNonEmptyString(label)) {
167
+ issue(issues, `${path}.${locale}`, "invalid_translation", "Expected a non-empty translated label.");
168
+ }
169
+ }
170
+ }
114
171
  function rejectLegacyProperties(value, path, properties, issues) {
115
172
  for (const property of properties) {
116
173
  if (Object.hasOwn(value, property)) {
@@ -154,6 +211,8 @@ function validateOptions(value, path, issues) {
154
211
  if (!isNonEmptyString(option.label)) {
155
212
  issue(issues, `${optionPath}.label`, "invalid_label", "Expected a non-empty option label.");
156
213
  }
214
+ if (option.translations !== void 0)
215
+ validateOptionTranslations(option.translations, `${optionPath}.translations`, issues);
157
216
  });
158
217
  return true;
159
218
  }
@@ -203,6 +262,7 @@ function validateField(value, path, issues) {
203
262
  if (value.displayCondition !== void 0) {
204
263
  validateDisplayCondition(value.displayCondition, `${path}.displayCondition`, issues);
205
264
  }
265
+ if (value.translations !== void 0) validateLocalizedTextMap(value.translations, `${path}.translations`, issues);
206
266
  if (typeof value.type !== "string" || !FIELD_TYPES.has(value.type)) {
207
267
  issue(issues, `${path}.type`, "invalid_field_type", "Unsupported field type.");
208
268
  return false;
@@ -288,6 +348,26 @@ function validateFormSchema(input) {
288
348
  );
289
349
  }
290
350
  }
351
+ if (input.defaultLocale !== void 0 && !isNonEmptyString(input.defaultLocale)) {
352
+ issue(issues, "defaultLocale", "invalid_locale", "Expected a non-empty default locale.");
353
+ }
354
+ if (input.supportedLocales !== void 0) {
355
+ if (!Array.isArray(input.supportedLocales) || input.supportedLocales.length === 0) {
356
+ issue(issues, "supportedLocales", "invalid_locales", "Expected at least one supported locale.");
357
+ } else {
358
+ const locales = /* @__PURE__ */ new Set();
359
+ input.supportedLocales.forEach((locale, index) => {
360
+ if (!isNonEmptyString(locale)) {
361
+ issue(issues, `supportedLocales[${index}]`, "invalid_locale", "Expected a non-empty locale.");
362
+ } else if (locales.has(locale)) {
363
+ issue(issues, `supportedLocales[${index}]`, "duplicate_locale", "Locales must be unique.");
364
+ } else {
365
+ locales.add(locale);
366
+ }
367
+ });
368
+ }
369
+ }
370
+ if (input.translations !== void 0) validateLocalizedTextMap(input.translations, "translations", issues);
291
371
  if (!Array.isArray(input.fields) || input.fields.length === 0) {
292
372
  issue(issues, "fields", "invalid_fields", "Expected at least one field.");
293
373
  } else {
@@ -314,6 +394,80 @@ function validateFormSchema(input) {
314
394
  const code = structuralIssue.type === "dangling_condition_reference" ? "unknown_condition_source" : structuralIssue.type === "self_condition_reference" ? "self_condition" : "condition_cycle";
315
395
  issue(issues, `fields[${fieldIndex}].displayCondition`, code, structuralIssue.message);
316
396
  }
397
+ if (input.pages !== void 0) {
398
+ if (!Array.isArray(input.pages) || input.pages.length === 0) {
399
+ issue(issues, "pages", "invalid_pages", "Expected at least one page when pages is defined.");
400
+ } else {
401
+ const pageIds = /* @__PURE__ */ new Set();
402
+ const assigned = /* @__PURE__ */ new Map();
403
+ input.pages.forEach((page, pageIndex) => {
404
+ const pagePath = `pages[${pageIndex}]`;
405
+ if (!isRecord(page)) {
406
+ issue(issues, pagePath, "invalid_page", "Expected a page object.");
407
+ return;
408
+ }
409
+ if (!isNonEmptyString(page.id)) {
410
+ issue(issues, `${pagePath}.id`, "invalid_page_id", "Expected a non-empty page ID.");
411
+ } else if (pageIds.has(page.id)) {
412
+ issue(issues, `${pagePath}.id`, "duplicate_page", "Page IDs must be unique.");
413
+ } else {
414
+ pageIds.add(page.id);
415
+ }
416
+ for (const key of ["title", "description"]) {
417
+ if (page[key] !== void 0 && !isNonEmptyString(page[key])) {
418
+ issue(issues, `${pagePath}.${key}`, "invalid_page_text", "Expected non-empty page text.");
419
+ }
420
+ }
421
+ if (page.translations !== void 0) {
422
+ validateLocalizedTextMap(page.translations, `${pagePath}.translations`, issues);
423
+ }
424
+ if (page.displayCondition !== void 0) {
425
+ validateDisplayCondition(page.displayCondition, `${pagePath}.displayCondition`, issues);
426
+ }
427
+ if (!Array.isArray(page.questionIds) || page.questionIds.length === 0) {
428
+ issue(issues, `${pagePath}.questionIds`, "invalid_page_questions", "Expected at least one question ID.");
429
+ return;
430
+ }
431
+ page.questionIds.forEach((questionId, questionIndex) => {
432
+ const questionPath = `${pagePath}.questionIds[${questionIndex}]`;
433
+ if (!isNonEmptyString(questionId)) {
434
+ issue(issues, questionPath, "invalid_question_reference", "Expected a question ID.");
435
+ } else if (!ids.has(questionId)) {
436
+ issue(issues, questionPath, "unknown_page_question", "Page references an unknown question.");
437
+ } else if (assigned.has(questionId)) {
438
+ issue(issues, questionPath, "duplicate_page_question", "A question may belong to only one page.");
439
+ } else {
440
+ assigned.set(questionId, pageIndex);
441
+ }
442
+ });
443
+ });
444
+ for (const fieldId of ids) {
445
+ if (!assigned.has(fieldId))
446
+ issue(issues, "pages", "unassigned_page_question", `Question ${fieldId} has no page.`);
447
+ }
448
+ input.pages.forEach((page, pageIndex) => {
449
+ if (!isRecord(page) || !isRecord(page.displayCondition)) return;
450
+ const sourceId = page.displayCondition.questionId;
451
+ if (typeof sourceId !== "string") return;
452
+ const sourcePageIndex = assigned.get(sourceId);
453
+ if (sourcePageIndex === void 0) {
454
+ issue(
455
+ issues,
456
+ `pages[${pageIndex}].displayCondition.questionId`,
457
+ "unknown_page_condition_source",
458
+ "Page condition references an unknown question."
459
+ );
460
+ } else if (sourcePageIndex >= pageIndex) {
461
+ issue(
462
+ issues,
463
+ `pages[${pageIndex}].displayCondition.questionId`,
464
+ "forward_page_condition",
465
+ "Page conditions must reference a question on an earlier page."
466
+ );
467
+ }
468
+ });
469
+ }
470
+ }
317
471
  }
318
472
  return issues.length === 0 ? { valid: true, value: input, issues: [] } : { valid: false, issues };
319
473
  }
@@ -341,7 +495,9 @@ function valuesEqual(left, right) {
341
495
  return typeof left === "string" && typeof right === "string" ? normalizeString(left) === normalizeString(right) : left === right;
342
496
  }
343
497
  function isQuestionVisible(question, currentAnswers) {
344
- const condition = question.displayCondition;
498
+ return isDisplayConditionSatisfied(question.displayCondition, currentAnswers);
499
+ }
500
+ function isDisplayConditionSatisfied(condition, currentAnswers) {
345
501
  if (condition === void 0) return true;
346
502
  const answer = currentAnswers[condition.questionId];
347
503
  if (isEmpty(answer)) return false;
@@ -354,7 +510,7 @@ function isQuestionVisible(question, currentAnswers) {
354
510
  }
355
511
  return Array.isArray(answer) && answer.some((item) => valuesEqual(item, condition.value));
356
512
  }
357
- function calculateFieldVisibility(schema, currentAnswers) {
513
+ function calculateBaseFieldVisibility(schema, currentAnswers) {
358
514
  const fields = new Map(schema.fields.map((field) => [field.id, field]));
359
515
  const resolved = /* @__PURE__ */ new Map();
360
516
  const resolving = /* @__PURE__ */ new Set();
@@ -372,7 +528,34 @@ function calculateFieldVisibility(schema, currentAnswers) {
372
528
  return visible;
373
529
  };
374
530
  for (const field of schema.fields) resolve(field);
375
- return Object.freeze(Object.fromEntries(resolved));
531
+ return Object.fromEntries(resolved);
532
+ }
533
+ function calculatePageVisibility(schema, currentAnswers) {
534
+ if (schema.pages === void 0 || schema.pages.length === 0) return {};
535
+ const baseFieldVisibility = calculateBaseFieldVisibility(schema, currentAnswers);
536
+ const pageByQuestion = new Map(schema.pages.flatMap((page) => page.questionIds.map((id) => [id, page.id])));
537
+ const pageVisibility = {};
538
+ for (const page of schema.pages) {
539
+ const sourceId = page.displayCondition?.questionId;
540
+ const sourcePageId = sourceId === void 0 ? void 0 : pageByQuestion.get(sourceId);
541
+ const sourceVisible = sourceId === void 0 || baseFieldVisibility[sourceId] === true && sourcePageId !== void 0 && pageVisibility[sourcePageId] === true;
542
+ pageVisibility[page.id] = sourceVisible && isDisplayConditionSatisfied(page.displayCondition, currentAnswers);
543
+ }
544
+ return Object.freeze(pageVisibility);
545
+ }
546
+ function calculateFieldVisibility(schema, currentAnswers) {
547
+ const baseVisibility = calculateBaseFieldVisibility(schema, currentAnswers);
548
+ if (schema.pages === void 0 || schema.pages.length === 0) return Object.freeze(baseVisibility);
549
+ const pageVisibility = calculatePageVisibility(schema, currentAnswers);
550
+ const pageByQuestion = new Map(schema.pages.flatMap((page) => page.questionIds.map((id) => [id, page.id])));
551
+ return Object.freeze(
552
+ Object.fromEntries(
553
+ schema.fields.map((field) => {
554
+ const pageId = pageByQuestion.get(field.id);
555
+ return [field.id, baseVisibility[field.id] === true && pageId !== void 0 && pageVisibility[pageId] === true];
556
+ })
557
+ )
558
+ );
376
559
  }
377
560
  function selectVisibleAnswers(schema, currentAnswers) {
378
561
  const visibility = calculateFieldVisibility(schema, currentAnswers);
@@ -406,6 +589,23 @@ function calculateNumericSummary(responses, questionId) {
406
589
  max: numbers.length === 0 ? null : Math.max(...numbers)
407
590
  };
408
591
  }
592
+ function calculateCrossTabulation(responses, rowQuestionId, colQuestionId) {
593
+ const matrix = {};
594
+ const rowTotals = {};
595
+ const colTotals = {};
596
+ let grandTotal = 0;
597
+ for (const response of responses) {
598
+ const row = response.values[rowQuestionId];
599
+ const col = response.values[colQuestionId];
600
+ if (typeof row !== "string" || row.length === 0 || typeof col !== "string" || col.length === 0) continue;
601
+ matrix[row] ??= {};
602
+ matrix[row][col] = (matrix[row][col] ?? 0) + 1;
603
+ rowTotals[row] = (rowTotals[row] ?? 0) + 1;
604
+ colTotals[col] = (colTotals[col] ?? 0) + 1;
605
+ grandTotal += 1;
606
+ }
607
+ return { rowQuestionId, colQuestionId, matrix, rowTotals, colTotals, grandTotal };
608
+ }
409
609
  function valueIsValid(field, value) {
410
610
  if (value === void 0 || value === "") return false;
411
611
  if (field.type === "text" || field.type === "textarea") {
@@ -530,6 +730,47 @@ function exportResponsesToCsv(schema, responses, options = {}) {
530
730
  return options.withBom ?? true ? `\uFEFF${csv}` : csv;
531
731
  }
532
732
 
733
+ // src/events.ts
734
+ function bytesToHex(bytes) {
735
+ return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
736
+ }
737
+ async function signPayload(payload, secret) {
738
+ if (globalThis.crypto?.subtle === void 0) throw new Error("Web Crypto is unavailable for webhook signing.");
739
+ const encoder = new TextEncoder();
740
+ const key = await globalThis.crypto.subtle.importKey(
741
+ "raw",
742
+ encoder.encode(secret),
743
+ { name: "HMAC", hash: "SHA-256" },
744
+ false,
745
+ ["sign"]
746
+ );
747
+ return bytesToHex(await globalThis.crypto.subtle.sign("HMAC", key, encoder.encode(payload)));
748
+ }
749
+ async function dispatchWebhook(event, config, fetchImpl = globalThis.fetch) {
750
+ const body = JSON.stringify(event);
751
+ const controller = new AbortController();
752
+ const timeoutMs = config.timeoutMs ?? 5e3;
753
+ const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs);
754
+ try {
755
+ const headers = { "content-type": "application/json", ...config.headers };
756
+ if (config.secret !== void 0) {
757
+ headers["X-Form-Engine-Signature"] = await signPayload(body, config.secret);
758
+ }
759
+ const response = await fetchImpl(config.url, {
760
+ method: "POST",
761
+ headers,
762
+ body,
763
+ signal: controller.signal
764
+ });
765
+ return response.ok ? { success: true, status: response.status } : { success: false, status: response.status, error: `Webhook returned HTTP ${response.status}.` };
766
+ } catch (cause) {
767
+ const error = controller.signal.aborted ? `Webhook request timed out after ${timeoutMs}ms.` : cause instanceof Error ? cause.message : String(cause);
768
+ return { success: false, error };
769
+ } finally {
770
+ globalThis.clearTimeout(timeout);
771
+ }
772
+ }
773
+
533
774
  // src/validation.ts
534
775
  var DEFAULT_MESSAGES = {
535
776
  required: "validation.required",
@@ -656,6 +897,18 @@ function validateAnswers(schema, values) {
656
897
  }
657
898
  return issues.length === 0 ? { valid: true, issues: [] } : { valid: false, issues };
658
899
  }
900
+ function validatePageAnswers(schema, pageIndex, values) {
901
+ if (schema.pages === void 0 || schema.pages.length === 0) return validateAnswers(schema, values);
902
+ const page = schema.pages[pageIndex];
903
+ if (page === void 0) return { valid: true, issues: [] };
904
+ const targetIds = new Set(page.questionIds);
905
+ const visibility = calculateFieldVisibility(schema, values);
906
+ const issues = [];
907
+ for (const field of schema.fields) {
908
+ if (targetIds.has(field.id) && visibility[field.id] === true) validateField2(field, values[field.id], issues);
909
+ }
910
+ return issues.length === 0 ? { valid: true, issues: [] } : { valid: false, issues };
911
+ }
659
912
 
660
913
  // src/submission.ts
661
914
  function cloneValues(values) {
@@ -688,62 +941,199 @@ function createSubmission(schema, values, options) {
688
941
  }
689
942
 
690
943
  // 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;
710
- };
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 }
720
- };
721
- if (!("options" in field)) return base;
722
- return { ...base, options: field.options.map((option) => ({ ...option, label: next() })) };
944
+ function mergeLocalizedText(translations, locale, key, value) {
945
+ return { ...translations, [locale]: { ...translations?.[locale], [key]: value } };
946
+ }
947
+ function translationSlots(schema) {
948
+ const slots = [
949
+ {
950
+ text: schema.title,
951
+ apply: (current, value, locale) => ({
952
+ ...current,
953
+ translations: mergeLocalizedText(current.translations, locale, "title", value)
954
+ })
955
+ }
956
+ ];
957
+ if (schema.description !== void 0) {
958
+ slots.push({
959
+ text: schema.description,
960
+ apply: (current, value, locale) => ({
961
+ ...current,
962
+ translations: mergeLocalizedText(current.translations, locale, "description", value)
963
+ })
964
+ });
965
+ }
966
+ schema.fields.forEach((field, fieldIndex) => {
967
+ slots.push({
968
+ text: field.title,
969
+ apply: (current, value, locale) => ({
970
+ ...current,
971
+ fields: current.fields.map(
972
+ (item, index) => index === fieldIndex ? { ...item, translations: mergeLocalizedText(item.translations, locale, "title", value) } : item
973
+ )
974
+ })
975
+ });
976
+ if (field.description !== void 0) {
977
+ slots.push({
978
+ text: field.description,
979
+ apply: (current, value, locale) => ({
980
+ ...current,
981
+ fields: current.fields.map(
982
+ (item, index) => index === fieldIndex ? {
983
+ ...item,
984
+ translations: mergeLocalizedText(item.translations, locale, "description", value)
985
+ } : item
986
+ )
987
+ })
988
+ });
989
+ }
990
+ if ("options" in field) {
991
+ field.options.forEach((option, optionIndex) => {
992
+ slots.push({
993
+ text: option.label,
994
+ apply: (current, value, locale) => ({
995
+ ...current,
996
+ fields: current.fields.map((item, index) => {
997
+ if (index !== fieldIndex || !("options" in item)) return item;
998
+ return {
999
+ ...item,
1000
+ options: item.options.map(
1001
+ (candidate, candidateIndex) => candidateIndex === optionIndex ? { ...candidate, translations: { ...candidate.translations, [locale]: value } } : candidate
1002
+ )
1003
+ };
1004
+ })
1005
+ })
1006
+ });
1007
+ });
1008
+ }
1009
+ });
1010
+ schema.pages?.forEach((page, pageIndex) => {
1011
+ if (page.title !== void 0) {
1012
+ slots.push({
1013
+ text: page.title,
1014
+ apply: (current, value, locale) => ({
1015
+ ...current,
1016
+ ...current.pages === void 0 ? {} : {
1017
+ pages: current.pages.map(
1018
+ (item, index) => index === pageIndex ? { ...item, translations: mergeLocalizedText(item.translations, locale, "title", value) } : item
1019
+ )
1020
+ }
1021
+ })
1022
+ });
1023
+ }
1024
+ if (page.description !== void 0) {
1025
+ slots.push({
1026
+ text: page.description,
1027
+ apply: (current, value, locale) => ({
1028
+ ...current,
1029
+ ...current.pages === void 0 ? {} : {
1030
+ pages: current.pages.map(
1031
+ (item, index) => index === pageIndex ? { ...item, translations: mergeLocalizedText(item.translations, locale, "description", value) } : item
1032
+ )
1033
+ }
1034
+ })
1035
+ });
1036
+ }
723
1037
  });
724
- const translatedSchema = {
1038
+ return slots;
1039
+ }
1040
+ function resolveLocalizedSchema(schema, targetLocale) {
1041
+ if (targetLocale.length === 0 || targetLocale === schema.defaultLocale) return schema;
1042
+ const formTranslation = schema.translations?.[targetLocale];
1043
+ return {
725
1044
  ...schema,
726
- title,
727
- ...description === void 0 ? {} : { description },
728
- fields
1045
+ title: formTranslation?.title ?? schema.title,
1046
+ ...(formTranslation?.description ?? schema.description) === void 0 ? {} : { description: formTranslation?.description ?? schema.description },
1047
+ fields: schema.fields.map((field) => {
1048
+ const translation = field.translations?.[targetLocale];
1049
+ const localized = {
1050
+ ...field,
1051
+ title: translation?.title ?? field.title,
1052
+ ...(translation?.description ?? field.description) === void 0 ? {} : { description: translation?.description ?? field.description }
1053
+ };
1054
+ if (!("options" in field)) return localized;
1055
+ return {
1056
+ ...localized,
1057
+ options: field.options.map((option) => ({
1058
+ ...option,
1059
+ label: option.translations?.[targetLocale] ?? option.label
1060
+ }))
1061
+ };
1062
+ }),
1063
+ ...schema.pages === void 0 ? {} : {
1064
+ pages: schema.pages.map((page) => {
1065
+ const translation = page.translations?.[targetLocale];
1066
+ const title = translation?.title ?? page.title;
1067
+ const description = translation?.description ?? page.description;
1068
+ return {
1069
+ ...page,
1070
+ ...title === void 0 ? {} : { title },
1071
+ ...description === void 0 ? {} : { description }
1072
+ };
1073
+ })
1074
+ }
729
1075
  };
730
- assertValidFormSchema(translatedSchema);
731
- return translatedSchema;
1076
+ }
1077
+ async function populateSchemaTranslations(schema, targetLocales, adapter) {
1078
+ assertValidFormSchema(schema);
1079
+ const slots = translationSlots(schema);
1080
+ const locales = [...new Set(targetLocales.filter((locale) => locale.length > 0 && locale !== schema.defaultLocale))];
1081
+ let result = schema;
1082
+ for (const locale of locales) {
1083
+ const translated = await adapter.translateBatch(
1084
+ slots.map((slot) => slot.text),
1085
+ locale,
1086
+ schema.defaultLocale
1087
+ );
1088
+ if (translated.length !== slots.length) {
1089
+ throw new Error(`Translation adapter returned ${translated.length} texts for ${slots.length} inputs.`);
1090
+ }
1091
+ translated.forEach((value, index) => {
1092
+ const slot = slots[index];
1093
+ if (slot === void 0) throw new Error("Translation adapter returned an unexpected result.");
1094
+ result = slot.apply(result, value, locale);
1095
+ });
1096
+ }
1097
+ const supportedLocales = [
1098
+ .../* @__PURE__ */ new Set([
1099
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
1100
+ ...schema.supportedLocales ?? [],
1101
+ ...locales
1102
+ ])
1103
+ ];
1104
+ result = supportedLocales.length === 0 ? result : { ...result, supportedLocales };
1105
+ assertValidFormSchema(result);
1106
+ return result;
1107
+ }
1108
+ async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocale) {
1109
+ const populated = await populateSchemaTranslations(
1110
+ sourceLocale === void 0 ? schema : { ...schema, defaultLocale: sourceLocale },
1111
+ [targetLocale],
1112
+ adapter
1113
+ );
1114
+ return resolveLocalizedSchema(populated, targetLocale);
732
1115
  }
733
1116
  export {
734
1117
  aggregateResponses,
735
1118
  assertValidFormSchema,
736
1119
  calculateChoiceDistribution,
1120
+ calculateCrossTabulation,
737
1121
  calculateFieldVisibility,
738
1122
  calculateNumericSummary,
1123
+ calculatePageVisibility,
739
1124
  createSubmission,
1125
+ dispatchWebhook,
740
1126
  escapeCsvCell,
741
1127
  exportResponsesToCsv,
1128
+ isDisplayConditionSatisfied,
742
1129
  isQuestionVisible,
1130
+ populateSchemaTranslations,
743
1131
  resolveFormTranslation,
1132
+ resolveLocalizedSchema,
744
1133
  sanitizeSchema,
745
1134
  selectVisibleAnswers,
746
1135
  validateAnswers,
747
1136
  validateFormSchema,
1137
+ validatePageAnswers,
748
1138
  validateSchemaStructure
749
1139
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/core",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },