@form-engine-ts/core 1.1.0 → 2.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.cjs CHANGED
@@ -38,6 +38,7 @@ __export(index_exports, {
38
38
  resolveLocalizedSchema: () => resolveLocalizedSchema,
39
39
  sanitizeSchema: () => sanitizeSchema,
40
40
  selectVisibleAnswers: () => selectVisibleAnswers,
41
+ transformFieldType: () => transformFieldType,
41
42
  validateAnswers: () => validateAnswers,
42
43
  validateFormSchema: () => validateFormSchema,
43
44
  validatePageAnswers: () => validatePageAnswers,
@@ -187,6 +188,52 @@ function isNonEmptyString(value) {
187
188
  function issue(issues, path, code, message) {
188
189
  issues.push({ path, code, message });
189
190
  }
191
+ function validateJsonValue(value, path, issues, ancestors = /* @__PURE__ */ new Set()) {
192
+ if (value === null || typeof value === "string" || typeof value === "boolean") return;
193
+ if (typeof value === "number") {
194
+ if (!Number.isFinite(value)) issue(issues, path, "invalid_metadata", "Metadata numbers must be finite.");
195
+ return;
196
+ }
197
+ if (typeof value !== "object") {
198
+ issue(issues, path, "invalid_metadata", "Expected JSON-serializable metadata.");
199
+ return;
200
+ }
201
+ if (ancestors.has(value)) {
202
+ issue(issues, path, "invalid_metadata", "Metadata must not contain cycles.");
203
+ return;
204
+ }
205
+ const nextAncestors = new Set(ancestors).add(value);
206
+ if (Array.isArray(value)) {
207
+ value.forEach((item, index) => {
208
+ validateJsonValue(item, `${path}[${index}]`, issues, nextAncestors);
209
+ });
210
+ return;
211
+ }
212
+ const prototype = Object.getPrototypeOf(value);
213
+ if (prototype !== Object.prototype && prototype !== null) {
214
+ issue(issues, path, "invalid_metadata", "Metadata objects must be plain JSON objects.");
215
+ return;
216
+ }
217
+ for (const [key, item] of Object.entries(value)) {
218
+ validateJsonValue(item, `${path}.${key}`, issues, nextAncestors);
219
+ }
220
+ }
221
+ function validateExtensibleNode(value, path, issues) {
222
+ for (const property of ["metadata", "translationMetadata"]) {
223
+ const candidate = value[property];
224
+ if (candidate === void 0) continue;
225
+ if (!isRecord(candidate)) {
226
+ issue(
227
+ issues,
228
+ path.length === 0 ? property : `${path}.${property}`,
229
+ "invalid_metadata",
230
+ "Expected a metadata object."
231
+ );
232
+ continue;
233
+ }
234
+ validateJsonValue(candidate, path.length === 0 ? property : `${path}.${property}`, issues);
235
+ }
236
+ }
190
237
  function validateLocalizedTextMap(value, path, issues) {
191
238
  if (!isRecord(value)) {
192
239
  issue(issues, path, "invalid_translations", "Expected a locale-to-translation object.");
@@ -197,7 +244,7 @@ function validateLocalizedTextMap(value, path, issues) {
197
244
  issue(issues, `${path}.${locale}`, "invalid_translation", "Expected a translation object.");
198
245
  continue;
199
246
  }
200
- for (const key of ["title", "description"]) {
247
+ for (const key of ["title", "description", "completionMessage"]) {
201
248
  if (translation[key] !== void 0 && !isNonEmptyString(translation[key])) {
202
249
  issue(issues, `${path}.${locale}.${key}`, "invalid_translation", "Expected non-empty translated text.");
203
250
  }
@@ -248,6 +295,7 @@ function validateOptions(value, path, issues) {
248
295
  return;
249
296
  }
250
297
  rejectLegacyProperties(option, optionPath, ["value", "labelKey"], issues);
298
+ validateExtensibleNode(option, optionPath, issues);
251
299
  if (!isNonEmptyString(option.id)) {
252
300
  issue(issues, `${optionPath}.id`, "invalid_option_id", "Expected a non-empty option ID.");
253
301
  } else if (seen.has(option.id)) {
@@ -293,6 +341,7 @@ function validateField(value, path, issues) {
293
341
  return false;
294
342
  }
295
343
  rejectLegacyProperties(value, path, ["titleKey", "labelKey", "helpTextKey", "descriptionKey"], issues);
344
+ validateExtensibleNode(value, path, issues);
296
345
  if (!isNonEmptyString(value.id)) issue(issues, `${path}.id`, "invalid_id", "Expected a non-empty ID.");
297
346
  if (!isNonEmptyString(value.title)) {
298
347
  issue(issues, `${path}.title`, "invalid_title", "Expected a non-empty question title.");
@@ -372,12 +421,157 @@ function validateField(value, path, issues) {
372
421
  }
373
422
  return true;
374
423
  }
375
- function validateFormSchema(input) {
424
+ function collectSchemaText(schema) {
425
+ const entries = [{ path: "title", value: schema.title }];
426
+ if (schema.description !== void 0) entries.push({ path: "description", value: schema.description });
427
+ if (schema.completionMessage !== void 0)
428
+ entries.push({ path: "completionMessage", value: schema.completionMessage });
429
+ for (const [locale, translation] of Object.entries(schema.translations ?? {})) {
430
+ for (const property of ["title", "description", "completionMessage"]) {
431
+ const value = translation[property];
432
+ if (value !== void 0) entries.push({ path: `translations.${locale}.${property}`, value });
433
+ }
434
+ }
435
+ schema.fields.forEach((field, fieldIndex) => {
436
+ entries.push({ path: `fields[${fieldIndex}].title`, value: field.title });
437
+ if (field.description !== void 0)
438
+ entries.push({ path: `fields[${fieldIndex}].description`, value: field.description });
439
+ for (const [locale, translation] of Object.entries(field.translations ?? {})) {
440
+ for (const property of ["title", "description"]) {
441
+ const value = translation[property];
442
+ if (value !== void 0)
443
+ entries.push({ path: `fields[${fieldIndex}].translations.${locale}.${property}`, value });
444
+ }
445
+ }
446
+ if (!("options" in field)) return;
447
+ field.options.forEach((option, optionIndex) => {
448
+ entries.push({ path: `fields[${fieldIndex}].options[${optionIndex}].label`, value: option.label });
449
+ for (const [locale, value] of Object.entries(option.translations ?? {})) {
450
+ entries.push({ path: `fields[${fieldIndex}].options[${optionIndex}].translations.${locale}`, value });
451
+ }
452
+ });
453
+ });
454
+ schema.pages?.forEach((page, pageIndex) => {
455
+ if (page.title !== void 0) entries.push({ path: `pages[${pageIndex}].title`, value: page.title });
456
+ if (page.description !== void 0)
457
+ entries.push({ path: `pages[${pageIndex}].description`, value: page.description });
458
+ for (const [locale, translation] of Object.entries(page.translations ?? {})) {
459
+ for (const property of ["title", "description"]) {
460
+ const value = translation[property];
461
+ if (value !== void 0)
462
+ entries.push({ path: `pages[${pageIndex}].translations.${locale}.${property}`, value });
463
+ }
464
+ }
465
+ });
466
+ return entries;
467
+ }
468
+ function addRequiredTranslationIssues(schema, locale, issues) {
469
+ if (!(schema.supportedLocales ?? []).includes(locale)) {
470
+ issue(issues, "supportedLocales", "required_locale_missing", `Required locale ${locale} is missing.`);
471
+ }
472
+ if (locale === schema.defaultLocale) return;
473
+ const required = [
474
+ { path: `translations.${locale}.title`, value: schema.translations?.[locale]?.title }
475
+ ];
476
+ if (schema.description !== void 0)
477
+ required.push({ path: `translations.${locale}.description`, value: schema.translations?.[locale]?.description });
478
+ if (schema.completionMessage !== void 0) {
479
+ required.push({
480
+ path: `translations.${locale}.completionMessage`,
481
+ value: schema.translations?.[locale]?.completionMessage
482
+ });
483
+ }
484
+ schema.fields.forEach((field, fieldIndex) => {
485
+ required.push({
486
+ path: `fields[${fieldIndex}].translations.${locale}.title`,
487
+ value: field.translations?.[locale]?.title
488
+ });
489
+ if (field.description !== void 0) {
490
+ required.push({
491
+ path: `fields[${fieldIndex}].translations.${locale}.description`,
492
+ value: field.translations?.[locale]?.description
493
+ });
494
+ }
495
+ if (!("options" in field)) return;
496
+ field.options.forEach((option, optionIndex) => {
497
+ required.push({
498
+ path: `fields[${fieldIndex}].options[${optionIndex}].translations.${locale}`,
499
+ value: option.translations?.[locale]
500
+ });
501
+ });
502
+ });
503
+ schema.pages?.forEach((page, pageIndex) => {
504
+ if (page.title !== void 0) {
505
+ required.push({
506
+ path: `pages[${pageIndex}].translations.${locale}.title`,
507
+ value: page.translations?.[locale]?.title
508
+ });
509
+ }
510
+ if (page.description !== void 0) {
511
+ required.push({
512
+ path: `pages[${pageIndex}].translations.${locale}.description`,
513
+ value: page.translations?.[locale]?.description
514
+ });
515
+ }
516
+ });
517
+ for (const translation of required) {
518
+ if (translation.value === void 0 || translation.value.trim().length === 0) {
519
+ issue(
520
+ issues,
521
+ translation.path,
522
+ "required_translation_missing",
523
+ `A translation for required locale ${locale} is missing.`
524
+ );
525
+ }
526
+ }
527
+ }
528
+ function validatePolicy(schema, policy, issues) {
529
+ if (policy.maxFields !== void 0 && schema.fields.length > policy.maxFields) {
530
+ issue(issues, "fields", "max_fields_exceeded", `At most ${policy.maxFields} fields are allowed.`);
531
+ }
532
+ schema.fields.forEach((field, fieldIndex) => {
533
+ if (policy.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(field.type)) {
534
+ issue(issues, `fields[${fieldIndex}].type`, "disallowed_field_type", `Field type ${field.type} is not allowed.`);
535
+ }
536
+ if (policy.maxOptionsPerField !== void 0 && "options" in field && field.options.length > policy.maxOptionsPerField) {
537
+ issue(
538
+ issues,
539
+ `fields[${fieldIndex}].options`,
540
+ "max_options_exceeded",
541
+ `At most ${policy.maxOptionsPerField} options are allowed.`
542
+ );
543
+ }
544
+ });
545
+ if (policy.maxTextLength !== void 0) {
546
+ for (const entry of collectSchemaText(schema)) {
547
+ if (entry.value.length > policy.maxTextLength) {
548
+ issue(
549
+ issues,
550
+ entry.path,
551
+ "max_text_length_exceeded",
552
+ `Text must be at most ${policy.maxTextLength} characters.`
553
+ );
554
+ }
555
+ }
556
+ }
557
+ for (const locale of policy.requiredLocales ?? []) addRequiredTranslationIssues(schema, locale, issues);
558
+ if (policy.maxSchemaBytes !== void 0) {
559
+ try {
560
+ const byteLength = new TextEncoder().encode(JSON.stringify(schema)).byteLength;
561
+ if (byteLength > policy.maxSchemaBytes) {
562
+ issue(issues, "$", "max_schema_bytes_exceeded", `Schema must be at most ${policy.maxSchemaBytes} bytes.`);
563
+ }
564
+ } catch {
565
+ }
566
+ }
567
+ }
568
+ function validateFormSchema(input, options = {}) {
376
569
  const issues = [];
377
570
  if (!isRecord(input)) {
378
571
  return { valid: false, issues: [{ path: "$", code: "invalid_schema", message: "Expected a schema object." }] };
379
572
  }
380
573
  rejectLegacyProperties(input, "", ["titleKey", "descriptionKey"], issues);
574
+ validateExtensibleNode(input, "", issues);
381
575
  if (!isNonEmptyString(input.id)) issue(issues, "id", "invalid_id", "Expected a non-empty ID.");
382
576
  if (!Number.isInteger(input.version) || input.version < 1) {
383
577
  issue(issues, "version", "invalid_version", "Expected a positive integer version.");
@@ -385,13 +579,13 @@ function validateFormSchema(input) {
385
579
  if (!isNonEmptyString(input.title)) {
386
580
  issue(issues, "title", "invalid_title", "Expected a non-empty form title.");
387
581
  }
388
- for (const key of ["description", "submitLabelKey"]) {
582
+ for (const key of ["description", "completionMessage", "submitLabelKey"]) {
389
583
  if (input[key] !== void 0 && !isNonEmptyString(input[key])) {
390
584
  issue(
391
585
  issues,
392
586
  key,
393
- key === "description" ? "invalid_description" : "invalid_translation_key",
394
- key === "description" ? "Expected a non-empty form description." : "Expected a translation key."
587
+ key === "submitLabelKey" ? "invalid_translation_key" : "invalid_description",
588
+ key === "submitLabelKey" ? "Expected a translation key." : "Expected non-empty form text."
395
589
  );
396
590
  }
397
591
  }
@@ -453,6 +647,7 @@ function validateFormSchema(input) {
453
647
  issue(issues, pagePath, "invalid_page", "Expected a page object.");
454
648
  return;
455
649
  }
650
+ validateExtensibleNode(page, pagePath, issues);
456
651
  if (!isNonEmptyString(page.id)) {
457
652
  issue(issues, `${pagePath}.id`, "invalid_page_id", "Expected a non-empty page ID.");
458
653
  } else if (pageIds.has(page.id)) {
@@ -516,6 +711,14 @@ function validateFormSchema(input) {
516
711
  }
517
712
  }
518
713
  }
714
+ if (Array.isArray(input.fields)) {
715
+ const policyIssues = [];
716
+ try {
717
+ validatePolicy(input, options.policy ?? {}, policyIssues);
718
+ issues.push(...policyIssues);
719
+ } catch {
720
+ }
721
+ }
519
722
  return issues.length === 0 ? { valid: true, value: input, issues: [] } : { valid: false, issues };
520
723
  }
521
724
  function assertValidFormSchema(input) {
@@ -744,15 +947,20 @@ function aggregateResponses(schema, submissions) {
744
947
  questions: schema.fields.map((field) => aggregateField(schema, field, submissions))
745
948
  };
746
949
  }
747
- function escapeCsvCell(value) {
950
+ function escapeCsvCell(value, neutralizeFormulas = true) {
748
951
  if (value === null || value === void 0) return "";
749
- const stringValue = String(value);
952
+ let stringValue = String(value);
953
+ if (neutralizeFormulas && typeof value === "string") {
954
+ const trimmed = stringValue.trimStart();
955
+ if (trimmed.length > 0 && ["=", "+", "-", "@"].includes(trimmed[0] ?? "")) {
956
+ stringValue = `'${stringValue}`;
957
+ }
958
+ }
750
959
  return /[",\r\n]/.test(stringValue) ? `"${stringValue.replaceAll('"', '""')}"` : stringValue;
751
960
  }
752
961
  function serializeValue(value) {
753
962
  if (value === void 0) return "";
754
- if (Array.isArray(value)) return JSON.stringify(value);
755
- return String(value);
963
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value);
756
964
  }
757
965
  function exportResponsesToCsv(schema, responses, options = {}) {
758
966
  assertValidFormSchema(schema);
@@ -773,7 +981,8 @@ function exportResponsesToCsv(schema, responses, options = {}) {
773
981
  ];
774
982
  })
775
983
  ];
776
- const csv = rows.map((row) => row.map((cell) => escapeCsvCell(cell)).join(",")).join("\r\n");
984
+ const neutralizeFormulas = options.neutralizeFormulas ?? true;
985
+ const csv = rows.map((row) => row.map((cell) => escapeCsvCell(cell, neutralizeFormulas)).join(",")).join("\r\n");
777
986
  return options.withBom ?? true ? `\uFEFF${csv}` : csv;
778
987
  }
779
988
 
@@ -818,6 +1027,58 @@ async function dispatchWebhook(event, config, fetchImpl = globalThis.fetch) {
818
1027
  }
819
1028
  }
820
1029
 
1030
+ // src/field.ts
1031
+ var DEFAULT_OPTION = { id: "option-1", label: "Option 1" };
1032
+ function transformFieldType(field, nextType) {
1033
+ const common = {
1034
+ id: field.id,
1035
+ title: field.title,
1036
+ required: field.required,
1037
+ ...field.description === void 0 ? {} : { description: field.description },
1038
+ ...field.translationKey === void 0 ? {} : { translationKey: field.translationKey },
1039
+ ...field.messages === void 0 ? {} : { messages: field.messages },
1040
+ ...field.displayCondition === void 0 ? {} : { displayCondition: field.displayCondition },
1041
+ ...field.translations === void 0 ? {} : { translations: field.translations },
1042
+ ...field.metadata === void 0 ? {} : { metadata: field.metadata },
1043
+ ...field.translationMetadata === void 0 ? {} : { translationMetadata: field.translationMetadata }
1044
+ };
1045
+ if (nextType === "text" || nextType === "textarea") {
1046
+ const textProperties = field.type === "text" || field.type === "textarea" ? {
1047
+ ...field.placeholderKey === void 0 ? {} : { placeholderKey: field.placeholderKey },
1048
+ ...field.minLength === void 0 ? {} : { minLength: field.minLength },
1049
+ ...field.maxLength === void 0 ? {} : { maxLength: field.maxLength },
1050
+ ...field.pattern === void 0 ? {} : { pattern: field.pattern }
1051
+ } : {};
1052
+ return { ...common, ...textProperties, type: nextType };
1053
+ }
1054
+ if (nextType === "number") {
1055
+ const numberProperties = field.type === "number" ? {
1056
+ ...field.placeholderKey === void 0 ? {} : { placeholderKey: field.placeholderKey },
1057
+ ...field.min === void 0 ? {} : { min: field.min },
1058
+ ...field.max === void 0 ? {} : { max: field.max },
1059
+ ...field.step === void 0 ? {} : { step: field.step }
1060
+ } : {};
1061
+ return { ...common, ...numberProperties, type: nextType };
1062
+ }
1063
+ if (nextType === "rating") {
1064
+ const ratingProperties = field.type === "rating" ? {
1065
+ ...field.min === void 0 ? {} : { min: field.min },
1066
+ ...field.max === void 0 ? {} : { max: field.max }
1067
+ } : { min: 1, max: 5 };
1068
+ return { ...common, ...ratingProperties, type: nextType };
1069
+ }
1070
+ if (nextType === "checkbox") return { ...common, type: nextType };
1071
+ const options = "options" in field && field.options.length > 0 ? field.options : [DEFAULT_OPTION];
1072
+ if (nextType === "multi-select") {
1073
+ const selectionProperties = field.type === "multi-select" ? {
1074
+ ...field.minSelections === void 0 ? {} : { minSelections: field.minSelections },
1075
+ ...field.maxSelections === void 0 ? {} : { maxSelections: field.maxSelections }
1076
+ } : {};
1077
+ return { ...common, ...selectionProperties, type: nextType, options };
1078
+ }
1079
+ return { ...common, type: nextType, options };
1080
+ }
1081
+
821
1082
  // src/validation.ts
822
1083
  var DEFAULT_MESSAGES = {
823
1084
  required: "validation.required",
@@ -983,69 +1244,129 @@ function createSubmission(schema, values, options) {
983
1244
  formVersion: schema.version,
984
1245
  locale: options.locale,
985
1246
  values: Object.freeze(cloneValues(visibleValues)),
986
- submittedAt: options.submittedAt
1247
+ submittedAt: options.submittedAt,
1248
+ ...options.metadata === void 0 ? {} : { metadata: Object.freeze({ ...options.metadata }) },
1249
+ ...options.translationMetadata === void 0 ? {} : { translationMetadata: Object.freeze({ ...options.translationMetadata }) }
987
1250
  });
988
1251
  }
989
1252
 
990
1253
  // src/translation.ts
991
- function mergeLocalizedText(translations, locale, key, value) {
992
- return { ...translations, [locale]: { ...translations?.[locale], [key]: value } };
1254
+ function mergeLocalizedText(translations, locale, property, value) {
1255
+ return { ...translations, [locale]: { ...translations?.[locale], [property]: value } };
993
1256
  }
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
- })
1257
+ function withTranslationMetadata(node, locale, property, metadata) {
1258
+ if (metadata === void 0) return node;
1259
+ return {
1260
+ ...node,
1261
+ translationMetadata: {
1262
+ ...node.translationMetadata,
1263
+ [locale]: {
1264
+ ...node.translationMetadata?.[locale],
1265
+ [property]: metadata
1266
+ }
1002
1267
  }
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
- })
1268
+ };
1269
+ }
1270
+ function createSlot(kind, nodeId, property, locale, sourceText, existingText, nodeMetadata, existingTranslationMetadata) {
1271
+ return {
1272
+ kind,
1273
+ nodeId,
1274
+ property,
1275
+ locale,
1276
+ sourceText,
1277
+ ...existingText === void 0 ? {} : { existingText },
1278
+ ...nodeMetadata === void 0 ? {} : { nodeMetadata, metadata: nodeMetadata },
1279
+ ...existingTranslationMetadata === void 0 ? {} : { existingTranslationMetadata }
1280
+ };
1281
+ }
1282
+ function translationSlots(schema, locale) {
1283
+ const descriptors = [];
1284
+ const addFormSlot = (property, sourceText) => {
1285
+ const slot = createSlot(
1286
+ "form",
1287
+ schema.id,
1288
+ property,
1289
+ locale,
1290
+ sourceText,
1291
+ schema.translations?.[locale]?.[property],
1292
+ schema.metadata,
1293
+ schema.translationMetadata?.[locale]?.[property]
1294
+ );
1295
+ descriptors.push({
1296
+ slot,
1297
+ apply: (current, value, metadata) => withTranslationMetadata(
1298
+ { ...current, translations: mergeLocalizedText(current.translations, locale, property, value) },
1299
+ locale,
1300
+ property,
1301
+ metadata
1302
+ )
1011
1303
  });
1012
- }
1304
+ };
1305
+ addFormSlot("title", schema.title);
1306
+ if (schema.description !== void 0) addFormSlot("description", schema.description);
1307
+ if (schema.completionMessage !== void 0) addFormSlot("completionMessage", schema.completionMessage);
1013
1308
  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) => ({
1309
+ const addFieldSlot = (property, sourceText) => {
1310
+ const slot = createSlot(
1311
+ "field",
1312
+ field.id,
1313
+ property,
1314
+ locale,
1315
+ sourceText,
1316
+ field.translations?.[locale]?.[property],
1317
+ field.metadata,
1318
+ field.translationMetadata?.[locale]?.[property]
1319
+ );
1320
+ descriptors.push({
1321
+ slot,
1322
+ apply: (current, value, metadata) => ({
1027
1323
  ...current,
1028
1324
  fields: current.fields.map(
1029
- (item, index) => index === fieldIndex ? {
1030
- ...item,
1031
- translations: mergeLocalizedText(item.translations, locale, "description", value)
1032
- } : item
1325
+ (candidate, index) => index === fieldIndex ? withTranslationMetadata(
1326
+ {
1327
+ ...candidate,
1328
+ translations: mergeLocalizedText(candidate.translations, locale, property, value)
1329
+ },
1330
+ locale,
1331
+ property,
1332
+ metadata
1333
+ ) : candidate
1033
1334
  )
1034
1335
  })
1035
1336
  });
1036
- }
1337
+ };
1338
+ addFieldSlot("title", field.title);
1339
+ if (field.description !== void 0) addFieldSlot("description", field.description);
1037
1340
  if ("options" in field) {
1038
1341
  field.options.forEach((option, optionIndex) => {
1039
- slots.push({
1040
- text: option.label,
1041
- apply: (current, value, locale) => ({
1342
+ const slot = createSlot(
1343
+ "option",
1344
+ option.id,
1345
+ "label",
1346
+ locale,
1347
+ option.label,
1348
+ option.translations?.[locale],
1349
+ option.metadata,
1350
+ option.translationMetadata?.[locale]?.label
1351
+ );
1352
+ descriptors.push({
1353
+ slot,
1354
+ apply: (current, value, metadata) => ({
1042
1355
  ...current,
1043
- fields: current.fields.map((item, index) => {
1044
- if (index !== fieldIndex || !("options" in item)) return item;
1356
+ fields: current.fields.map((candidate, candidateIndex) => {
1357
+ if (candidateIndex !== fieldIndex || !("options" in candidate)) return candidate;
1045
1358
  return {
1046
- ...item,
1047
- options: item.options.map(
1048
- (candidate, candidateIndex) => candidateIndex === optionIndex ? { ...candidate, translations: { ...candidate.translations, [locale]: value } } : candidate
1359
+ ...candidate,
1360
+ options: candidate.options.map(
1361
+ (candidateOption, candidateOptionIndex) => candidateOptionIndex === optionIndex ? withTranslationMetadata(
1362
+ {
1363
+ ...candidateOption,
1364
+ translations: { ...candidateOption.translations, [locale]: value }
1365
+ },
1366
+ locale,
1367
+ "label",
1368
+ metadata
1369
+ ) : candidateOption
1049
1370
  )
1050
1371
  };
1051
1372
  })
@@ -1055,42 +1376,51 @@ function translationSlots(schema) {
1055
1376
  }
1056
1377
  });
1057
1378
  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) => ({
1379
+ const addPageSlot = (property, sourceText) => {
1380
+ const slot = createSlot(
1381
+ "page",
1382
+ page.id,
1383
+ property,
1384
+ locale,
1385
+ sourceText,
1386
+ page.translations?.[locale]?.[property],
1387
+ page.metadata,
1388
+ page.translationMetadata?.[locale]?.[property]
1389
+ );
1390
+ descriptors.push({
1391
+ slot,
1392
+ apply: (current, value, metadata) => ({
1075
1393
  ...current,
1076
1394
  ...current.pages === void 0 ? {} : {
1077
1395
  pages: current.pages.map(
1078
- (item, index) => index === pageIndex ? { ...item, translations: mergeLocalizedText(item.translations, locale, "description", value) } : item
1396
+ (candidate, index) => index === pageIndex ? withTranslationMetadata(
1397
+ {
1398
+ ...candidate,
1399
+ translations: mergeLocalizedText(candidate.translations, locale, property, value)
1400
+ },
1401
+ locale,
1402
+ property,
1403
+ metadata
1404
+ ) : candidate
1079
1405
  )
1080
1406
  }
1081
1407
  })
1082
1408
  });
1083
- }
1409
+ };
1410
+ if (page.title !== void 0) addPageSlot("title", page.title);
1411
+ if (page.description !== void 0) addPageSlot("description", page.description);
1084
1412
  });
1085
- return slots;
1413
+ return descriptors;
1086
1414
  }
1087
1415
  function resolveLocalizedSchema(schema, targetLocale) {
1088
1416
  if (targetLocale.length === 0 || targetLocale === schema.defaultLocale) return schema;
1089
1417
  const formTranslation = schema.translations?.[targetLocale];
1418
+ const completionMessage = formTranslation?.completionMessage ?? schema.completionMessage;
1090
1419
  return {
1091
1420
  ...schema,
1092
1421
  title: formTranslation?.title ?? schema.title,
1093
1422
  ...(formTranslation?.description ?? schema.description) === void 0 ? {} : { description: formTranslation?.description ?? schema.description },
1423
+ ...completionMessage === void 0 ? {} : { completionMessage },
1094
1424
  fields: schema.fields.map((field) => {
1095
1425
  const translation = field.translations?.[targetLocale];
1096
1426
  const localized = {
@@ -1121,24 +1451,35 @@ function resolveLocalizedSchema(schema, targetLocale) {
1121
1451
  }
1122
1452
  };
1123
1453
  }
1124
- async function populateSchemaTranslations(schema, targetLocales, adapter) {
1454
+ async function populateSchemaTranslations(schema, targetLocales, adapter, options = {}) {
1125
1455
  assertValidFormSchema(schema);
1126
- const slots = translationSlots(schema);
1127
1456
  const locales = [...new Set(targetLocales.filter((locale) => locale.length > 0 && locale !== schema.defaultLocale))];
1457
+ const updatedSlots = [];
1458
+ const skippedSlots = [];
1128
1459
  let result = schema;
1129
1460
  for (const locale of locales) {
1461
+ const descriptors = translationSlots(schema, locale);
1462
+ const selected = [];
1463
+ for (const descriptor of descriptors) {
1464
+ const shouldTranslate = options.shouldOverwrite?.(descriptor.slot) ?? (options.overwrite === "all" || descriptor.slot.existingText === void 0);
1465
+ if (shouldTranslate) selected.push(descriptor);
1466
+ else skippedSlots.push(descriptor.slot);
1467
+ }
1468
+ if (selected.length === 0) continue;
1130
1469
  const translated = await adapter.translateBatch(
1131
- slots.map((slot) => slot.text),
1470
+ selected.map((descriptor) => descriptor.slot.sourceText),
1132
1471
  locale,
1133
1472
  schema.defaultLocale
1134
1473
  );
1135
- if (translated.length !== slots.length) {
1136
- throw new Error(`Translation adapter returned ${translated.length} texts for ${slots.length} inputs.`);
1474
+ if (translated.length !== selected.length) {
1475
+ throw new Error(`Translation adapter returned ${translated.length} texts for ${selected.length} inputs.`);
1137
1476
  }
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);
1477
+ selected.forEach((descriptor, index) => {
1478
+ const translatedText = translated[index];
1479
+ if (translatedText === void 0) throw new Error("Translation adapter returned an unexpected result.");
1480
+ const metadata = options.createMetadata?.(descriptor.slot, translatedText);
1481
+ result = descriptor.apply(result, translatedText, metadata);
1482
+ updatedSlots.push(descriptor.slot);
1142
1483
  });
1143
1484
  }
1144
1485
  const supportedLocales = [
@@ -1148,17 +1489,18 @@ async function populateSchemaTranslations(schema, targetLocales, adapter) {
1148
1489
  ...locales
1149
1490
  ])
1150
1491
  ];
1151
- result = supportedLocales.length === 0 ? result : { ...result, supportedLocales };
1492
+ if (supportedLocales.length > 0) result = { ...result, supportedLocales };
1152
1493
  assertValidFormSchema(result);
1153
- return result;
1494
+ return { schema: result, report: { updatedSlots, skippedSlots } };
1154
1495
  }
1155
1496
  async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocale) {
1156
1497
  const populated = await populateSchemaTranslations(
1157
1498
  sourceLocale === void 0 ? schema : { ...schema, defaultLocale: sourceLocale },
1158
1499
  [targetLocale],
1159
- adapter
1500
+ adapter,
1501
+ { overwrite: "all" }
1160
1502
  );
1161
- return resolveLocalizedSchema(populated, targetLocale);
1503
+ return resolveLocalizedSchema(populated.schema, targetLocale);
1162
1504
  }
1163
1505
  // Annotate the CommonJS export names for ESM import in node:
1164
1506
  0 && (module.exports = {
@@ -1180,6 +1522,7 @@ async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocal
1180
1522
  resolveLocalizedSchema,
1181
1523
  sanitizeSchema,
1182
1524
  selectVisibleAnswers,
1525
+ transformFieldType,
1183
1526
  validateAnswers,
1184
1527
  validateFormSchema,
1185
1528
  validatePageAnswers,