@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/README.md CHANGED
@@ -24,4 +24,29 @@ const result = validateAnswers(schema, { name: "Ada" });
24
24
  if (!result.valid) console.error(result.issues);
25
25
  ```
26
26
 
27
+ ## Multi-step, localization, analytics, and events
28
+
29
+ Add `pages` to partition every field into an accessible wizard and use `validatePageAnswers(schema, pageIndex, values)`
30
+ for step-scoped validation. Schemas without `pages` remain single-page forms.
31
+
32
+ Store authoring-time translations on forms, fields, options, and pages. `resolveLocalizedSchema` applies them synchronously,
33
+ while `populateSchemaTranslations` fills them through an injected `AsyncTranslationAdapter`.
34
+
35
+ `calculateCrossTabulation` builds a two-question frequency matrix from submissions. `dispatchWebhook` posts typed
36
+ `response.submitted` or `schema.updated` events with timeout handling, custom headers, and optional HMAC-SHA256 signing.
37
+
38
+ Storage adapters share inclusive ISO 8601 submission-range filtering:
39
+
40
+ ```ts
41
+ import type { SubmissionQueryOptions } from "@form-engine-ts/core";
42
+
43
+ const range: SubmissionQueryOptions = {
44
+ since: "2026-01-01T00:00:00.000Z",
45
+ until: "2026-01-31T23:59:59.999Z"
46
+ };
47
+ const submissions = await storage.listSubmissions("contact", 1, range);
48
+ ```
49
+
50
+ Results are ordered by `submittedAt`, then submission ID. Both boundaries are inclusive.
51
+
27
52
  See the [project documentation](https://github.com/nitta-a/form-engine-ts#readme) for the complete schema and API guide.
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,34 @@ function isNonEmptyString(value) {
151
187
  function issue(issues, path, code, message) {
152
188
  issues.push({ path, code, message });
153
189
  }
190
+ function validateLocalizedTextMap(value, path, issues) {
191
+ if (!isRecord(value)) {
192
+ issue(issues, path, "invalid_translations", "Expected a locale-to-translation object.");
193
+ return;
194
+ }
195
+ for (const [locale, translation] of Object.entries(value)) {
196
+ if (!isNonEmptyString(locale) || !isRecord(translation)) {
197
+ issue(issues, `${path}.${locale}`, "invalid_translation", "Expected a translation object.");
198
+ continue;
199
+ }
200
+ for (const key of ["title", "description"]) {
201
+ if (translation[key] !== void 0 && !isNonEmptyString(translation[key])) {
202
+ issue(issues, `${path}.${locale}.${key}`, "invalid_translation", "Expected non-empty translated text.");
203
+ }
204
+ }
205
+ }
206
+ }
207
+ function validateOptionTranslations(value, path, issues) {
208
+ if (!isRecord(value)) {
209
+ issue(issues, path, "invalid_translations", "Expected a locale-to-label object.");
210
+ return;
211
+ }
212
+ for (const [locale, label] of Object.entries(value)) {
213
+ if (!isNonEmptyString(locale) || !isNonEmptyString(label)) {
214
+ issue(issues, `${path}.${locale}`, "invalid_translation", "Expected a non-empty translated label.");
215
+ }
216
+ }
217
+ }
154
218
  function rejectLegacyProperties(value, path, properties, issues) {
155
219
  for (const property of properties) {
156
220
  if (Object.hasOwn(value, property)) {
@@ -194,6 +258,8 @@ function validateOptions(value, path, issues) {
194
258
  if (!isNonEmptyString(option.label)) {
195
259
  issue(issues, `${optionPath}.label`, "invalid_label", "Expected a non-empty option label.");
196
260
  }
261
+ if (option.translations !== void 0)
262
+ validateOptionTranslations(option.translations, `${optionPath}.translations`, issues);
197
263
  });
198
264
  return true;
199
265
  }
@@ -243,6 +309,7 @@ function validateField(value, path, issues) {
243
309
  if (value.displayCondition !== void 0) {
244
310
  validateDisplayCondition(value.displayCondition, `${path}.displayCondition`, issues);
245
311
  }
312
+ if (value.translations !== void 0) validateLocalizedTextMap(value.translations, `${path}.translations`, issues);
246
313
  if (typeof value.type !== "string" || !FIELD_TYPES.has(value.type)) {
247
314
  issue(issues, `${path}.type`, "invalid_field_type", "Unsupported field type.");
248
315
  return false;
@@ -328,6 +395,26 @@ function validateFormSchema(input) {
328
395
  );
329
396
  }
330
397
  }
398
+ if (input.defaultLocale !== void 0 && !isNonEmptyString(input.defaultLocale)) {
399
+ issue(issues, "defaultLocale", "invalid_locale", "Expected a non-empty default locale.");
400
+ }
401
+ if (input.supportedLocales !== void 0) {
402
+ if (!Array.isArray(input.supportedLocales) || input.supportedLocales.length === 0) {
403
+ issue(issues, "supportedLocales", "invalid_locales", "Expected at least one supported locale.");
404
+ } else {
405
+ const locales = /* @__PURE__ */ new Set();
406
+ input.supportedLocales.forEach((locale, index) => {
407
+ if (!isNonEmptyString(locale)) {
408
+ issue(issues, `supportedLocales[${index}]`, "invalid_locale", "Expected a non-empty locale.");
409
+ } else if (locales.has(locale)) {
410
+ issue(issues, `supportedLocales[${index}]`, "duplicate_locale", "Locales must be unique.");
411
+ } else {
412
+ locales.add(locale);
413
+ }
414
+ });
415
+ }
416
+ }
417
+ if (input.translations !== void 0) validateLocalizedTextMap(input.translations, "translations", issues);
331
418
  if (!Array.isArray(input.fields) || input.fields.length === 0) {
332
419
  issue(issues, "fields", "invalid_fields", "Expected at least one field.");
333
420
  } else {
@@ -354,6 +441,80 @@ function validateFormSchema(input) {
354
441
  const code = structuralIssue.type === "dangling_condition_reference" ? "unknown_condition_source" : structuralIssue.type === "self_condition_reference" ? "self_condition" : "condition_cycle";
355
442
  issue(issues, `fields[${fieldIndex}].displayCondition`, code, structuralIssue.message);
356
443
  }
444
+ if (input.pages !== void 0) {
445
+ if (!Array.isArray(input.pages) || input.pages.length === 0) {
446
+ issue(issues, "pages", "invalid_pages", "Expected at least one page when pages is defined.");
447
+ } else {
448
+ const pageIds = /* @__PURE__ */ new Set();
449
+ const assigned = /* @__PURE__ */ new Map();
450
+ input.pages.forEach((page, pageIndex) => {
451
+ const pagePath = `pages[${pageIndex}]`;
452
+ if (!isRecord(page)) {
453
+ issue(issues, pagePath, "invalid_page", "Expected a page object.");
454
+ return;
455
+ }
456
+ if (!isNonEmptyString(page.id)) {
457
+ issue(issues, `${pagePath}.id`, "invalid_page_id", "Expected a non-empty page ID.");
458
+ } else if (pageIds.has(page.id)) {
459
+ issue(issues, `${pagePath}.id`, "duplicate_page", "Page IDs must be unique.");
460
+ } else {
461
+ pageIds.add(page.id);
462
+ }
463
+ for (const key of ["title", "description"]) {
464
+ if (page[key] !== void 0 && !isNonEmptyString(page[key])) {
465
+ issue(issues, `${pagePath}.${key}`, "invalid_page_text", "Expected non-empty page text.");
466
+ }
467
+ }
468
+ if (page.translations !== void 0) {
469
+ validateLocalizedTextMap(page.translations, `${pagePath}.translations`, issues);
470
+ }
471
+ if (page.displayCondition !== void 0) {
472
+ validateDisplayCondition(page.displayCondition, `${pagePath}.displayCondition`, issues);
473
+ }
474
+ if (!Array.isArray(page.questionIds) || page.questionIds.length === 0) {
475
+ issue(issues, `${pagePath}.questionIds`, "invalid_page_questions", "Expected at least one question ID.");
476
+ return;
477
+ }
478
+ page.questionIds.forEach((questionId, questionIndex) => {
479
+ const questionPath = `${pagePath}.questionIds[${questionIndex}]`;
480
+ if (!isNonEmptyString(questionId)) {
481
+ issue(issues, questionPath, "invalid_question_reference", "Expected a question ID.");
482
+ } else if (!ids.has(questionId)) {
483
+ issue(issues, questionPath, "unknown_page_question", "Page references an unknown question.");
484
+ } else if (assigned.has(questionId)) {
485
+ issue(issues, questionPath, "duplicate_page_question", "A question may belong to only one page.");
486
+ } else {
487
+ assigned.set(questionId, pageIndex);
488
+ }
489
+ });
490
+ });
491
+ for (const fieldId of ids) {
492
+ if (!assigned.has(fieldId))
493
+ issue(issues, "pages", "unassigned_page_question", `Question ${fieldId} has no page.`);
494
+ }
495
+ input.pages.forEach((page, pageIndex) => {
496
+ if (!isRecord(page) || !isRecord(page.displayCondition)) return;
497
+ const sourceId = page.displayCondition.questionId;
498
+ if (typeof sourceId !== "string") return;
499
+ const sourcePageIndex = assigned.get(sourceId);
500
+ if (sourcePageIndex === void 0) {
501
+ issue(
502
+ issues,
503
+ `pages[${pageIndex}].displayCondition.questionId`,
504
+ "unknown_page_condition_source",
505
+ "Page condition references an unknown question."
506
+ );
507
+ } else if (sourcePageIndex >= pageIndex) {
508
+ issue(
509
+ issues,
510
+ `pages[${pageIndex}].displayCondition.questionId`,
511
+ "forward_page_condition",
512
+ "Page conditions must reference a question on an earlier page."
513
+ );
514
+ }
515
+ });
516
+ }
517
+ }
357
518
  }
358
519
  return issues.length === 0 ? { valid: true, value: input, issues: [] } : { valid: false, issues };
359
520
  }
@@ -381,7 +542,9 @@ function valuesEqual(left, right) {
381
542
  return typeof left === "string" && typeof right === "string" ? normalizeString(left) === normalizeString(right) : left === right;
382
543
  }
383
544
  function isQuestionVisible(question, currentAnswers) {
384
- const condition = question.displayCondition;
545
+ return isDisplayConditionSatisfied(question.displayCondition, currentAnswers);
546
+ }
547
+ function isDisplayConditionSatisfied(condition, currentAnswers) {
385
548
  if (condition === void 0) return true;
386
549
  const answer = currentAnswers[condition.questionId];
387
550
  if (isEmpty(answer)) return false;
@@ -394,7 +557,7 @@ function isQuestionVisible(question, currentAnswers) {
394
557
  }
395
558
  return Array.isArray(answer) && answer.some((item) => valuesEqual(item, condition.value));
396
559
  }
397
- function calculateFieldVisibility(schema, currentAnswers) {
560
+ function calculateBaseFieldVisibility(schema, currentAnswers) {
398
561
  const fields = new Map(schema.fields.map((field) => [field.id, field]));
399
562
  const resolved = /* @__PURE__ */ new Map();
400
563
  const resolving = /* @__PURE__ */ new Set();
@@ -412,7 +575,34 @@ function calculateFieldVisibility(schema, currentAnswers) {
412
575
  return visible;
413
576
  };
414
577
  for (const field of schema.fields) resolve(field);
415
- return Object.freeze(Object.fromEntries(resolved));
578
+ return Object.fromEntries(resolved);
579
+ }
580
+ function calculatePageVisibility(schema, currentAnswers) {
581
+ if (schema.pages === void 0 || schema.pages.length === 0) return {};
582
+ const baseFieldVisibility = calculateBaseFieldVisibility(schema, currentAnswers);
583
+ const pageByQuestion = new Map(schema.pages.flatMap((page) => page.questionIds.map((id) => [id, page.id])));
584
+ const pageVisibility = {};
585
+ for (const page of schema.pages) {
586
+ const sourceId = page.displayCondition?.questionId;
587
+ const sourcePageId = sourceId === void 0 ? void 0 : pageByQuestion.get(sourceId);
588
+ const sourceVisible = sourceId === void 0 || baseFieldVisibility[sourceId] === true && sourcePageId !== void 0 && pageVisibility[sourcePageId] === true;
589
+ pageVisibility[page.id] = sourceVisible && isDisplayConditionSatisfied(page.displayCondition, currentAnswers);
590
+ }
591
+ return Object.freeze(pageVisibility);
592
+ }
593
+ function calculateFieldVisibility(schema, currentAnswers) {
594
+ const baseVisibility = calculateBaseFieldVisibility(schema, currentAnswers);
595
+ if (schema.pages === void 0 || schema.pages.length === 0) return Object.freeze(baseVisibility);
596
+ const pageVisibility = calculatePageVisibility(schema, currentAnswers);
597
+ const pageByQuestion = new Map(schema.pages.flatMap((page) => page.questionIds.map((id) => [id, page.id])));
598
+ return Object.freeze(
599
+ Object.fromEntries(
600
+ schema.fields.map((field) => {
601
+ const pageId = pageByQuestion.get(field.id);
602
+ return [field.id, baseVisibility[field.id] === true && pageId !== void 0 && pageVisibility[pageId] === true];
603
+ })
604
+ )
605
+ );
416
606
  }
417
607
  function selectVisibleAnswers(schema, currentAnswers) {
418
608
  const visibility = calculateFieldVisibility(schema, currentAnswers);
@@ -446,6 +636,23 @@ function calculateNumericSummary(responses, questionId) {
446
636
  max: numbers.length === 0 ? null : Math.max(...numbers)
447
637
  };
448
638
  }
639
+ function calculateCrossTabulation(responses, rowQuestionId, colQuestionId) {
640
+ const matrix = {};
641
+ const rowTotals = {};
642
+ const colTotals = {};
643
+ let grandTotal = 0;
644
+ for (const response of responses) {
645
+ const row = response.values[rowQuestionId];
646
+ const col = response.values[colQuestionId];
647
+ if (typeof row !== "string" || row.length === 0 || typeof col !== "string" || col.length === 0) continue;
648
+ matrix[row] ??= {};
649
+ matrix[row][col] = (matrix[row][col] ?? 0) + 1;
650
+ rowTotals[row] = (rowTotals[row] ?? 0) + 1;
651
+ colTotals[col] = (colTotals[col] ?? 0) + 1;
652
+ grandTotal += 1;
653
+ }
654
+ return { rowQuestionId, colQuestionId, matrix, rowTotals, colTotals, grandTotal };
655
+ }
449
656
  function valueIsValid(field, value) {
450
657
  if (value === void 0 || value === "") return false;
451
658
  if (field.type === "text" || field.type === "textarea") {
@@ -570,6 +777,47 @@ function exportResponsesToCsv(schema, responses, options = {}) {
570
777
  return options.withBom ?? true ? `\uFEFF${csv}` : csv;
571
778
  }
572
779
 
780
+ // src/events.ts
781
+ function bytesToHex(bytes) {
782
+ return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
783
+ }
784
+ async function signPayload(payload, secret) {
785
+ if (globalThis.crypto?.subtle === void 0) throw new Error("Web Crypto is unavailable for webhook signing.");
786
+ const encoder = new TextEncoder();
787
+ const key = await globalThis.crypto.subtle.importKey(
788
+ "raw",
789
+ encoder.encode(secret),
790
+ { name: "HMAC", hash: "SHA-256" },
791
+ false,
792
+ ["sign"]
793
+ );
794
+ return bytesToHex(await globalThis.crypto.subtle.sign("HMAC", key, encoder.encode(payload)));
795
+ }
796
+ async function dispatchWebhook(event, config, fetchImpl = globalThis.fetch) {
797
+ const body = JSON.stringify(event);
798
+ const controller = new AbortController();
799
+ const timeoutMs = config.timeoutMs ?? 5e3;
800
+ const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs);
801
+ try {
802
+ const headers = { "content-type": "application/json", ...config.headers };
803
+ if (config.secret !== void 0) {
804
+ headers["X-Form-Engine-Signature"] = await signPayload(body, config.secret);
805
+ }
806
+ const response = await fetchImpl(config.url, {
807
+ method: "POST",
808
+ headers,
809
+ body,
810
+ signal: controller.signal
811
+ });
812
+ return response.ok ? { success: true, status: response.status } : { success: false, status: response.status, error: `Webhook returned HTTP ${response.status}.` };
813
+ } catch (cause) {
814
+ const error = controller.signal.aborted ? `Webhook request timed out after ${timeoutMs}ms.` : cause instanceof Error ? cause.message : String(cause);
815
+ return { success: false, error };
816
+ } finally {
817
+ globalThis.clearTimeout(timeout);
818
+ }
819
+ }
820
+
573
821
  // src/validation.ts
574
822
  var DEFAULT_MESSAGES = {
575
823
  required: "validation.required",
@@ -696,6 +944,18 @@ function validateAnswers(schema, values) {
696
944
  }
697
945
  return issues.length === 0 ? { valid: true, issues: [] } : { valid: false, issues };
698
946
  }
947
+ function validatePageAnswers(schema, pageIndex, values) {
948
+ if (schema.pages === void 0 || schema.pages.length === 0) return validateAnswers(schema, values);
949
+ const page = schema.pages[pageIndex];
950
+ if (page === void 0) return { valid: true, issues: [] };
951
+ const targetIds = new Set(page.questionIds);
952
+ const visibility = calculateFieldVisibility(schema, values);
953
+ const issues = [];
954
+ for (const field of schema.fields) {
955
+ if (targetIds.has(field.id) && visibility[field.id] === true) validateField2(field, values[field.id], issues);
956
+ }
957
+ return issues.length === 0 ? { valid: true, issues: [] } : { valid: false, issues };
958
+ }
699
959
 
700
960
  // src/submission.ts
701
961
  function cloneValues(values) {
@@ -728,63 +988,200 @@ function createSubmission(schema, values, options) {
728
988
  }
729
989
 
730
990
  // src/translation.ts
731
- async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocale) {
732
- assertValidFormSchema(schema);
733
- const texts = [schema.title];
734
- if (schema.description !== void 0) texts.push(schema.description);
735
- for (const field of schema.fields) {
736
- texts.push(field.title);
737
- if (field.description !== void 0) texts.push(field.description);
738
- if ("options" in field) texts.push(...field.options.map((option) => option.label));
739
- }
740
- const translated = await adapter.translateBatch(texts, targetLocale, sourceLocale);
741
- if (translated.length !== texts.length) {
742
- throw new Error(`Translation adapter returned ${translated.length} texts for ${texts.length} inputs.`);
743
- }
744
- let index = 0;
745
- const next = () => {
746
- const value = translated[index];
747
- index += 1;
748
- if (value === void 0) throw new Error("Translation adapter returned an incomplete result.");
749
- return value;
750
- };
751
- const title = next();
752
- const description = schema.description === void 0 ? void 0 : next();
753
- const fields = schema.fields.map((field) => {
754
- const translatedTitle = next();
755
- const translatedDescription = field.description === void 0 ? void 0 : next();
756
- const base = {
757
- ...field,
758
- title: translatedTitle,
759
- ...translatedDescription === void 0 ? {} : { description: translatedDescription }
760
- };
761
- if (!("options" in field)) return base;
762
- return { ...base, options: field.options.map((option) => ({ ...option, label: next() })) };
991
+ function mergeLocalizedText(translations, locale, key, value) {
992
+ return { ...translations, [locale]: { ...translations?.[locale], [key]: value } };
993
+ }
994
+ function translationSlots(schema) {
995
+ const slots = [
996
+ {
997
+ text: schema.title,
998
+ apply: (current, value, locale) => ({
999
+ ...current,
1000
+ translations: mergeLocalizedText(current.translations, locale, "title", value)
1001
+ })
1002
+ }
1003
+ ];
1004
+ if (schema.description !== void 0) {
1005
+ slots.push({
1006
+ text: schema.description,
1007
+ apply: (current, value, locale) => ({
1008
+ ...current,
1009
+ translations: mergeLocalizedText(current.translations, locale, "description", value)
1010
+ })
1011
+ });
1012
+ }
1013
+ schema.fields.forEach((field, fieldIndex) => {
1014
+ slots.push({
1015
+ text: field.title,
1016
+ apply: (current, value, locale) => ({
1017
+ ...current,
1018
+ fields: current.fields.map(
1019
+ (item, index) => index === fieldIndex ? { ...item, translations: mergeLocalizedText(item.translations, locale, "title", value) } : item
1020
+ )
1021
+ })
1022
+ });
1023
+ if (field.description !== void 0) {
1024
+ slots.push({
1025
+ text: field.description,
1026
+ apply: (current, value, locale) => ({
1027
+ ...current,
1028
+ fields: current.fields.map(
1029
+ (item, index) => index === fieldIndex ? {
1030
+ ...item,
1031
+ translations: mergeLocalizedText(item.translations, locale, "description", value)
1032
+ } : item
1033
+ )
1034
+ })
1035
+ });
1036
+ }
1037
+ if ("options" in field) {
1038
+ field.options.forEach((option, optionIndex) => {
1039
+ slots.push({
1040
+ text: option.label,
1041
+ apply: (current, value, locale) => ({
1042
+ ...current,
1043
+ fields: current.fields.map((item, index) => {
1044
+ if (index !== fieldIndex || !("options" in item)) return item;
1045
+ return {
1046
+ ...item,
1047
+ options: item.options.map(
1048
+ (candidate, candidateIndex) => candidateIndex === optionIndex ? { ...candidate, translations: { ...candidate.translations, [locale]: value } } : candidate
1049
+ )
1050
+ };
1051
+ })
1052
+ })
1053
+ });
1054
+ });
1055
+ }
1056
+ });
1057
+ schema.pages?.forEach((page, pageIndex) => {
1058
+ if (page.title !== void 0) {
1059
+ slots.push({
1060
+ text: page.title,
1061
+ apply: (current, value, locale) => ({
1062
+ ...current,
1063
+ ...current.pages === void 0 ? {} : {
1064
+ pages: current.pages.map(
1065
+ (item, index) => index === pageIndex ? { ...item, translations: mergeLocalizedText(item.translations, locale, "title", value) } : item
1066
+ )
1067
+ }
1068
+ })
1069
+ });
1070
+ }
1071
+ if (page.description !== void 0) {
1072
+ slots.push({
1073
+ text: page.description,
1074
+ apply: (current, value, locale) => ({
1075
+ ...current,
1076
+ ...current.pages === void 0 ? {} : {
1077
+ pages: current.pages.map(
1078
+ (item, index) => index === pageIndex ? { ...item, translations: mergeLocalizedText(item.translations, locale, "description", value) } : item
1079
+ )
1080
+ }
1081
+ })
1082
+ });
1083
+ }
763
1084
  });
764
- const translatedSchema = {
1085
+ return slots;
1086
+ }
1087
+ function resolveLocalizedSchema(schema, targetLocale) {
1088
+ if (targetLocale.length === 0 || targetLocale === schema.defaultLocale) return schema;
1089
+ const formTranslation = schema.translations?.[targetLocale];
1090
+ return {
765
1091
  ...schema,
766
- title,
767
- ...description === void 0 ? {} : { description },
768
- fields
1092
+ title: formTranslation?.title ?? schema.title,
1093
+ ...(formTranslation?.description ?? schema.description) === void 0 ? {} : { description: formTranslation?.description ?? schema.description },
1094
+ fields: schema.fields.map((field) => {
1095
+ const translation = field.translations?.[targetLocale];
1096
+ const localized = {
1097
+ ...field,
1098
+ title: translation?.title ?? field.title,
1099
+ ...(translation?.description ?? field.description) === void 0 ? {} : { description: translation?.description ?? field.description }
1100
+ };
1101
+ if (!("options" in field)) return localized;
1102
+ return {
1103
+ ...localized,
1104
+ options: field.options.map((option) => ({
1105
+ ...option,
1106
+ label: option.translations?.[targetLocale] ?? option.label
1107
+ }))
1108
+ };
1109
+ }),
1110
+ ...schema.pages === void 0 ? {} : {
1111
+ pages: schema.pages.map((page) => {
1112
+ const translation = page.translations?.[targetLocale];
1113
+ const title = translation?.title ?? page.title;
1114
+ const description = translation?.description ?? page.description;
1115
+ return {
1116
+ ...page,
1117
+ ...title === void 0 ? {} : { title },
1118
+ ...description === void 0 ? {} : { description }
1119
+ };
1120
+ })
1121
+ }
769
1122
  };
770
- assertValidFormSchema(translatedSchema);
771
- return translatedSchema;
1123
+ }
1124
+ async function populateSchemaTranslations(schema, targetLocales, adapter) {
1125
+ assertValidFormSchema(schema);
1126
+ const slots = translationSlots(schema);
1127
+ const locales = [...new Set(targetLocales.filter((locale) => locale.length > 0 && locale !== schema.defaultLocale))];
1128
+ let result = schema;
1129
+ for (const locale of locales) {
1130
+ const translated = await adapter.translateBatch(
1131
+ slots.map((slot) => slot.text),
1132
+ locale,
1133
+ schema.defaultLocale
1134
+ );
1135
+ if (translated.length !== slots.length) {
1136
+ throw new Error(`Translation adapter returned ${translated.length} texts for ${slots.length} inputs.`);
1137
+ }
1138
+ translated.forEach((value, index) => {
1139
+ const slot = slots[index];
1140
+ if (slot === void 0) throw new Error("Translation adapter returned an unexpected result.");
1141
+ result = slot.apply(result, value, locale);
1142
+ });
1143
+ }
1144
+ const supportedLocales = [
1145
+ .../* @__PURE__ */ new Set([
1146
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
1147
+ ...schema.supportedLocales ?? [],
1148
+ ...locales
1149
+ ])
1150
+ ];
1151
+ result = supportedLocales.length === 0 ? result : { ...result, supportedLocales };
1152
+ assertValidFormSchema(result);
1153
+ return result;
1154
+ }
1155
+ async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocale) {
1156
+ const populated = await populateSchemaTranslations(
1157
+ sourceLocale === void 0 ? schema : { ...schema, defaultLocale: sourceLocale },
1158
+ [targetLocale],
1159
+ adapter
1160
+ );
1161
+ return resolveLocalizedSchema(populated, targetLocale);
772
1162
  }
773
1163
  // Annotate the CommonJS export names for ESM import in node:
774
1164
  0 && (module.exports = {
775
1165
  aggregateResponses,
776
1166
  assertValidFormSchema,
777
1167
  calculateChoiceDistribution,
1168
+ calculateCrossTabulation,
778
1169
  calculateFieldVisibility,
779
1170
  calculateNumericSummary,
1171
+ calculatePageVisibility,
780
1172
  createSubmission,
1173
+ dispatchWebhook,
781
1174
  escapeCsvCell,
782
1175
  exportResponsesToCsv,
1176
+ isDisplayConditionSatisfied,
783
1177
  isQuestionVisible,
1178
+ populateSchemaTranslations,
784
1179
  resolveFormTranslation,
1180
+ resolveLocalizedSchema,
785
1181
  sanitizeSchema,
786
1182
  selectVisibleAnswers,
787
1183
  validateAnswers,
788
1184
  validateFormSchema,
1185
+ validatePageAnswers,
789
1186
  validateSchemaStructure
790
1187
  });