@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.js CHANGED
@@ -140,6 +140,52 @@ function isNonEmptyString(value) {
140
140
  function issue(issues, path, code, message) {
141
141
  issues.push({ path, code, message });
142
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
+ }
143
189
  function validateLocalizedTextMap(value, path, issues) {
144
190
  if (!isRecord(value)) {
145
191
  issue(issues, path, "invalid_translations", "Expected a locale-to-translation object.");
@@ -150,7 +196,7 @@ function validateLocalizedTextMap(value, path, issues) {
150
196
  issue(issues, `${path}.${locale}`, "invalid_translation", "Expected a translation object.");
151
197
  continue;
152
198
  }
153
- for (const key of ["title", "description"]) {
199
+ for (const key of ["title", "description", "completionMessage"]) {
154
200
  if (translation[key] !== void 0 && !isNonEmptyString(translation[key])) {
155
201
  issue(issues, `${path}.${locale}.${key}`, "invalid_translation", "Expected non-empty translated text.");
156
202
  }
@@ -201,6 +247,7 @@ function validateOptions(value, path, issues) {
201
247
  return;
202
248
  }
203
249
  rejectLegacyProperties(option, optionPath, ["value", "labelKey"], issues);
250
+ validateExtensibleNode(option, optionPath, issues);
204
251
  if (!isNonEmptyString(option.id)) {
205
252
  issue(issues, `${optionPath}.id`, "invalid_option_id", "Expected a non-empty option ID.");
206
253
  } else if (seen.has(option.id)) {
@@ -246,6 +293,7 @@ function validateField(value, path, issues) {
246
293
  return false;
247
294
  }
248
295
  rejectLegacyProperties(value, path, ["titleKey", "labelKey", "helpTextKey", "descriptionKey"], issues);
296
+ validateExtensibleNode(value, path, issues);
249
297
  if (!isNonEmptyString(value.id)) issue(issues, `${path}.id`, "invalid_id", "Expected a non-empty ID.");
250
298
  if (!isNonEmptyString(value.title)) {
251
299
  issue(issues, `${path}.title`, "invalid_title", "Expected a non-empty question title.");
@@ -325,12 +373,157 @@ function validateField(value, path, issues) {
325
373
  }
326
374
  return true;
327
375
  }
328
- function validateFormSchema(input) {
376
+ function collectSchemaText(schema) {
377
+ const entries = [{ path: "title", value: schema.title }];
378
+ if (schema.description !== void 0) entries.push({ path: "description", value: schema.description });
379
+ if (schema.completionMessage !== void 0)
380
+ entries.push({ path: "completionMessage", value: schema.completionMessage });
381
+ for (const [locale, translation] of Object.entries(schema.translations ?? {})) {
382
+ for (const property of ["title", "description", "completionMessage"]) {
383
+ const value = translation[property];
384
+ if (value !== void 0) entries.push({ path: `translations.${locale}.${property}`, value });
385
+ }
386
+ }
387
+ schema.fields.forEach((field, fieldIndex) => {
388
+ entries.push({ path: `fields[${fieldIndex}].title`, value: field.title });
389
+ if (field.description !== void 0)
390
+ entries.push({ path: `fields[${fieldIndex}].description`, value: field.description });
391
+ for (const [locale, translation] of Object.entries(field.translations ?? {})) {
392
+ for (const property of ["title", "description"]) {
393
+ const value = translation[property];
394
+ if (value !== void 0)
395
+ entries.push({ path: `fields[${fieldIndex}].translations.${locale}.${property}`, value });
396
+ }
397
+ }
398
+ if (!("options" in field)) return;
399
+ field.options.forEach((option, optionIndex) => {
400
+ entries.push({ path: `fields[${fieldIndex}].options[${optionIndex}].label`, value: option.label });
401
+ for (const [locale, value] of Object.entries(option.translations ?? {})) {
402
+ entries.push({ path: `fields[${fieldIndex}].options[${optionIndex}].translations.${locale}`, value });
403
+ }
404
+ });
405
+ });
406
+ schema.pages?.forEach((page, pageIndex) => {
407
+ if (page.title !== void 0) entries.push({ path: `pages[${pageIndex}].title`, value: page.title });
408
+ if (page.description !== void 0)
409
+ entries.push({ path: `pages[${pageIndex}].description`, value: page.description });
410
+ for (const [locale, translation] of Object.entries(page.translations ?? {})) {
411
+ for (const property of ["title", "description"]) {
412
+ const value = translation[property];
413
+ if (value !== void 0)
414
+ entries.push({ path: `pages[${pageIndex}].translations.${locale}.${property}`, value });
415
+ }
416
+ }
417
+ });
418
+ return entries;
419
+ }
420
+ function addRequiredTranslationIssues(schema, locale, issues) {
421
+ if (!(schema.supportedLocales ?? []).includes(locale)) {
422
+ issue(issues, "supportedLocales", "required_locale_missing", `Required locale ${locale} is missing.`);
423
+ }
424
+ if (locale === schema.defaultLocale) return;
425
+ const required = [
426
+ { path: `translations.${locale}.title`, value: schema.translations?.[locale]?.title }
427
+ ];
428
+ if (schema.description !== void 0)
429
+ required.push({ path: `translations.${locale}.description`, value: schema.translations?.[locale]?.description });
430
+ if (schema.completionMessage !== void 0) {
431
+ required.push({
432
+ path: `translations.${locale}.completionMessage`,
433
+ value: schema.translations?.[locale]?.completionMessage
434
+ });
435
+ }
436
+ schema.fields.forEach((field, fieldIndex) => {
437
+ required.push({
438
+ path: `fields[${fieldIndex}].translations.${locale}.title`,
439
+ value: field.translations?.[locale]?.title
440
+ });
441
+ if (field.description !== void 0) {
442
+ required.push({
443
+ path: `fields[${fieldIndex}].translations.${locale}.description`,
444
+ value: field.translations?.[locale]?.description
445
+ });
446
+ }
447
+ if (!("options" in field)) return;
448
+ field.options.forEach((option, optionIndex) => {
449
+ required.push({
450
+ path: `fields[${fieldIndex}].options[${optionIndex}].translations.${locale}`,
451
+ value: option.translations?.[locale]
452
+ });
453
+ });
454
+ });
455
+ schema.pages?.forEach((page, pageIndex) => {
456
+ if (page.title !== void 0) {
457
+ required.push({
458
+ path: `pages[${pageIndex}].translations.${locale}.title`,
459
+ value: page.translations?.[locale]?.title
460
+ });
461
+ }
462
+ if (page.description !== void 0) {
463
+ required.push({
464
+ path: `pages[${pageIndex}].translations.${locale}.description`,
465
+ value: page.translations?.[locale]?.description
466
+ });
467
+ }
468
+ });
469
+ for (const translation of required) {
470
+ if (translation.value === void 0 || translation.value.trim().length === 0) {
471
+ issue(
472
+ issues,
473
+ translation.path,
474
+ "required_translation_missing",
475
+ `A translation for required locale ${locale} is missing.`
476
+ );
477
+ }
478
+ }
479
+ }
480
+ function validatePolicy(schema, policy, issues) {
481
+ if (policy.maxFields !== void 0 && schema.fields.length > policy.maxFields) {
482
+ issue(issues, "fields", "max_fields_exceeded", `At most ${policy.maxFields} fields are allowed.`);
483
+ }
484
+ schema.fields.forEach((field, fieldIndex) => {
485
+ if (policy.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(field.type)) {
486
+ issue(issues, `fields[${fieldIndex}].type`, "disallowed_field_type", `Field type ${field.type} is not allowed.`);
487
+ }
488
+ if (policy.maxOptionsPerField !== void 0 && "options" in field && field.options.length > policy.maxOptionsPerField) {
489
+ issue(
490
+ issues,
491
+ `fields[${fieldIndex}].options`,
492
+ "max_options_exceeded",
493
+ `At most ${policy.maxOptionsPerField} options are allowed.`
494
+ );
495
+ }
496
+ });
497
+ if (policy.maxTextLength !== void 0) {
498
+ for (const entry of collectSchemaText(schema)) {
499
+ if (entry.value.length > policy.maxTextLength) {
500
+ issue(
501
+ issues,
502
+ entry.path,
503
+ "max_text_length_exceeded",
504
+ `Text must be at most ${policy.maxTextLength} characters.`
505
+ );
506
+ }
507
+ }
508
+ }
509
+ for (const locale of policy.requiredLocales ?? []) addRequiredTranslationIssues(schema, locale, issues);
510
+ if (policy.maxSchemaBytes !== void 0) {
511
+ try {
512
+ const byteLength = new TextEncoder().encode(JSON.stringify(schema)).byteLength;
513
+ if (byteLength > policy.maxSchemaBytes) {
514
+ issue(issues, "$", "max_schema_bytes_exceeded", `Schema must be at most ${policy.maxSchemaBytes} bytes.`);
515
+ }
516
+ } catch {
517
+ }
518
+ }
519
+ }
520
+ function validateFormSchema(input, options = {}) {
329
521
  const issues = [];
330
522
  if (!isRecord(input)) {
331
523
  return { valid: false, issues: [{ path: "$", code: "invalid_schema", message: "Expected a schema object." }] };
332
524
  }
333
525
  rejectLegacyProperties(input, "", ["titleKey", "descriptionKey"], issues);
526
+ validateExtensibleNode(input, "", issues);
334
527
  if (!isNonEmptyString(input.id)) issue(issues, "id", "invalid_id", "Expected a non-empty ID.");
335
528
  if (!Number.isInteger(input.version) || input.version < 1) {
336
529
  issue(issues, "version", "invalid_version", "Expected a positive integer version.");
@@ -338,13 +531,13 @@ function validateFormSchema(input) {
338
531
  if (!isNonEmptyString(input.title)) {
339
532
  issue(issues, "title", "invalid_title", "Expected a non-empty form title.");
340
533
  }
341
- for (const key of ["description", "submitLabelKey"]) {
534
+ for (const key of ["description", "completionMessage", "submitLabelKey"]) {
342
535
  if (input[key] !== void 0 && !isNonEmptyString(input[key])) {
343
536
  issue(
344
537
  issues,
345
538
  key,
346
- key === "description" ? "invalid_description" : "invalid_translation_key",
347
- key === "description" ? "Expected a non-empty form description." : "Expected a translation key."
539
+ key === "submitLabelKey" ? "invalid_translation_key" : "invalid_description",
540
+ key === "submitLabelKey" ? "Expected a translation key." : "Expected non-empty form text."
348
541
  );
349
542
  }
350
543
  }
@@ -406,6 +599,7 @@ function validateFormSchema(input) {
406
599
  issue(issues, pagePath, "invalid_page", "Expected a page object.");
407
600
  return;
408
601
  }
602
+ validateExtensibleNode(page, pagePath, issues);
409
603
  if (!isNonEmptyString(page.id)) {
410
604
  issue(issues, `${pagePath}.id`, "invalid_page_id", "Expected a non-empty page ID.");
411
605
  } else if (pageIds.has(page.id)) {
@@ -469,6 +663,14 @@ function validateFormSchema(input) {
469
663
  }
470
664
  }
471
665
  }
666
+ if (Array.isArray(input.fields)) {
667
+ const policyIssues = [];
668
+ try {
669
+ validatePolicy(input, options.policy ?? {}, policyIssues);
670
+ issues.push(...policyIssues);
671
+ } catch {
672
+ }
673
+ }
472
674
  return issues.length === 0 ? { valid: true, value: input, issues: [] } : { valid: false, issues };
473
675
  }
474
676
  function assertValidFormSchema(input) {
@@ -697,15 +899,20 @@ function aggregateResponses(schema, submissions) {
697
899
  questions: schema.fields.map((field) => aggregateField(schema, field, submissions))
698
900
  };
699
901
  }
700
- function escapeCsvCell(value) {
902
+ function escapeCsvCell(value, neutralizeFormulas = true) {
701
903
  if (value === null || value === void 0) return "";
702
- const stringValue = String(value);
904
+ let stringValue = String(value);
905
+ if (neutralizeFormulas && typeof value === "string") {
906
+ const trimmed = stringValue.trimStart();
907
+ if (trimmed.length > 0 && ["=", "+", "-", "@"].includes(trimmed[0] ?? "")) {
908
+ stringValue = `'${stringValue}`;
909
+ }
910
+ }
703
911
  return /[",\r\n]/.test(stringValue) ? `"${stringValue.replaceAll('"', '""')}"` : stringValue;
704
912
  }
705
913
  function serializeValue(value) {
706
914
  if (value === void 0) return "";
707
- if (Array.isArray(value)) return JSON.stringify(value);
708
- return String(value);
915
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value);
709
916
  }
710
917
  function exportResponsesToCsv(schema, responses, options = {}) {
711
918
  assertValidFormSchema(schema);
@@ -726,7 +933,8 @@ function exportResponsesToCsv(schema, responses, options = {}) {
726
933
  ];
727
934
  })
728
935
  ];
729
- const csv = rows.map((row) => row.map((cell) => escapeCsvCell(cell)).join(",")).join("\r\n");
936
+ const neutralizeFormulas = options.neutralizeFormulas ?? true;
937
+ const csv = rows.map((row) => row.map((cell) => escapeCsvCell(cell, neutralizeFormulas)).join(",")).join("\r\n");
730
938
  return options.withBom ?? true ? `\uFEFF${csv}` : csv;
731
939
  }
732
940
 
@@ -771,6 +979,58 @@ async function dispatchWebhook(event, config, fetchImpl = globalThis.fetch) {
771
979
  }
772
980
  }
773
981
 
982
+ // src/field.ts
983
+ var DEFAULT_OPTION = { id: "option-1", label: "Option 1" };
984
+ function transformFieldType(field, nextType) {
985
+ const common = {
986
+ id: field.id,
987
+ title: field.title,
988
+ required: field.required,
989
+ ...field.description === void 0 ? {} : { description: field.description },
990
+ ...field.translationKey === void 0 ? {} : { translationKey: field.translationKey },
991
+ ...field.messages === void 0 ? {} : { messages: field.messages },
992
+ ...field.displayCondition === void 0 ? {} : { displayCondition: field.displayCondition },
993
+ ...field.translations === void 0 ? {} : { translations: field.translations },
994
+ ...field.metadata === void 0 ? {} : { metadata: field.metadata },
995
+ ...field.translationMetadata === void 0 ? {} : { translationMetadata: field.translationMetadata }
996
+ };
997
+ if (nextType === "text" || nextType === "textarea") {
998
+ const textProperties = field.type === "text" || field.type === "textarea" ? {
999
+ ...field.placeholderKey === void 0 ? {} : { placeholderKey: field.placeholderKey },
1000
+ ...field.minLength === void 0 ? {} : { minLength: field.minLength },
1001
+ ...field.maxLength === void 0 ? {} : { maxLength: field.maxLength },
1002
+ ...field.pattern === void 0 ? {} : { pattern: field.pattern }
1003
+ } : {};
1004
+ return { ...common, ...textProperties, type: nextType };
1005
+ }
1006
+ if (nextType === "number") {
1007
+ const numberProperties = field.type === "number" ? {
1008
+ ...field.placeholderKey === void 0 ? {} : { placeholderKey: field.placeholderKey },
1009
+ ...field.min === void 0 ? {} : { min: field.min },
1010
+ ...field.max === void 0 ? {} : { max: field.max },
1011
+ ...field.step === void 0 ? {} : { step: field.step }
1012
+ } : {};
1013
+ return { ...common, ...numberProperties, type: nextType };
1014
+ }
1015
+ if (nextType === "rating") {
1016
+ const ratingProperties = field.type === "rating" ? {
1017
+ ...field.min === void 0 ? {} : { min: field.min },
1018
+ ...field.max === void 0 ? {} : { max: field.max }
1019
+ } : { min: 1, max: 5 };
1020
+ return { ...common, ...ratingProperties, type: nextType };
1021
+ }
1022
+ if (nextType === "checkbox") return { ...common, type: nextType };
1023
+ const options = "options" in field && field.options.length > 0 ? field.options : [DEFAULT_OPTION];
1024
+ if (nextType === "multi-select") {
1025
+ const selectionProperties = field.type === "multi-select" ? {
1026
+ ...field.minSelections === void 0 ? {} : { minSelections: field.minSelections },
1027
+ ...field.maxSelections === void 0 ? {} : { maxSelections: field.maxSelections }
1028
+ } : {};
1029
+ return { ...common, ...selectionProperties, type: nextType, options };
1030
+ }
1031
+ return { ...common, type: nextType, options };
1032
+ }
1033
+
774
1034
  // src/validation.ts
775
1035
  var DEFAULT_MESSAGES = {
776
1036
  required: "validation.required",
@@ -936,69 +1196,129 @@ function createSubmission(schema, values, options) {
936
1196
  formVersion: schema.version,
937
1197
  locale: options.locale,
938
1198
  values: Object.freeze(cloneValues(visibleValues)),
939
- submittedAt: options.submittedAt
1199
+ submittedAt: options.submittedAt,
1200
+ ...options.metadata === void 0 ? {} : { metadata: Object.freeze({ ...options.metadata }) },
1201
+ ...options.translationMetadata === void 0 ? {} : { translationMetadata: Object.freeze({ ...options.translationMetadata }) }
940
1202
  });
941
1203
  }
942
1204
 
943
1205
  // src/translation.ts
944
- function mergeLocalizedText(translations, locale, key, value) {
945
- return { ...translations, [locale]: { ...translations?.[locale], [key]: value } };
1206
+ function mergeLocalizedText(translations, locale, property, value) {
1207
+ return { ...translations, [locale]: { ...translations?.[locale], [property]: value } };
946
1208
  }
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
- })
1209
+ function withTranslationMetadata(node, locale, property, metadata) {
1210
+ if (metadata === void 0) return node;
1211
+ return {
1212
+ ...node,
1213
+ translationMetadata: {
1214
+ ...node.translationMetadata,
1215
+ [locale]: {
1216
+ ...node.translationMetadata?.[locale],
1217
+ [property]: metadata
1218
+ }
955
1219
  }
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
- })
1220
+ };
1221
+ }
1222
+ function createSlot(kind, nodeId, property, locale, sourceText, existingText, nodeMetadata, existingTranslationMetadata) {
1223
+ return {
1224
+ kind,
1225
+ nodeId,
1226
+ property,
1227
+ locale,
1228
+ sourceText,
1229
+ ...existingText === void 0 ? {} : { existingText },
1230
+ ...nodeMetadata === void 0 ? {} : { nodeMetadata, metadata: nodeMetadata },
1231
+ ...existingTranslationMetadata === void 0 ? {} : { existingTranslationMetadata }
1232
+ };
1233
+ }
1234
+ function translationSlots(schema, locale) {
1235
+ const descriptors = [];
1236
+ const addFormSlot = (property, sourceText) => {
1237
+ const slot = createSlot(
1238
+ "form",
1239
+ schema.id,
1240
+ property,
1241
+ locale,
1242
+ sourceText,
1243
+ schema.translations?.[locale]?.[property],
1244
+ schema.metadata,
1245
+ schema.translationMetadata?.[locale]?.[property]
1246
+ );
1247
+ descriptors.push({
1248
+ slot,
1249
+ apply: (current, value, metadata) => withTranslationMetadata(
1250
+ { ...current, translations: mergeLocalizedText(current.translations, locale, property, value) },
1251
+ locale,
1252
+ property,
1253
+ metadata
1254
+ )
964
1255
  });
965
- }
1256
+ };
1257
+ addFormSlot("title", schema.title);
1258
+ if (schema.description !== void 0) addFormSlot("description", schema.description);
1259
+ if (schema.completionMessage !== void 0) addFormSlot("completionMessage", schema.completionMessage);
966
1260
  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) => ({
1261
+ const addFieldSlot = (property, sourceText) => {
1262
+ const slot = createSlot(
1263
+ "field",
1264
+ field.id,
1265
+ property,
1266
+ locale,
1267
+ sourceText,
1268
+ field.translations?.[locale]?.[property],
1269
+ field.metadata,
1270
+ field.translationMetadata?.[locale]?.[property]
1271
+ );
1272
+ descriptors.push({
1273
+ slot,
1274
+ apply: (current, value, metadata) => ({
980
1275
  ...current,
981
1276
  fields: current.fields.map(
982
- (item, index) => index === fieldIndex ? {
983
- ...item,
984
- translations: mergeLocalizedText(item.translations, locale, "description", value)
985
- } : item
1277
+ (candidate, index) => index === fieldIndex ? withTranslationMetadata(
1278
+ {
1279
+ ...candidate,
1280
+ translations: mergeLocalizedText(candidate.translations, locale, property, value)
1281
+ },
1282
+ locale,
1283
+ property,
1284
+ metadata
1285
+ ) : candidate
986
1286
  )
987
1287
  })
988
1288
  });
989
- }
1289
+ };
1290
+ addFieldSlot("title", field.title);
1291
+ if (field.description !== void 0) addFieldSlot("description", field.description);
990
1292
  if ("options" in field) {
991
1293
  field.options.forEach((option, optionIndex) => {
992
- slots.push({
993
- text: option.label,
994
- apply: (current, value, locale) => ({
1294
+ const slot = createSlot(
1295
+ "option",
1296
+ option.id,
1297
+ "label",
1298
+ locale,
1299
+ option.label,
1300
+ option.translations?.[locale],
1301
+ option.metadata,
1302
+ option.translationMetadata?.[locale]?.label
1303
+ );
1304
+ descriptors.push({
1305
+ slot,
1306
+ apply: (current, value, metadata) => ({
995
1307
  ...current,
996
- fields: current.fields.map((item, index) => {
997
- if (index !== fieldIndex || !("options" in item)) return item;
1308
+ fields: current.fields.map((candidate, candidateIndex) => {
1309
+ if (candidateIndex !== fieldIndex || !("options" in candidate)) return candidate;
998
1310
  return {
999
- ...item,
1000
- options: item.options.map(
1001
- (candidate, candidateIndex) => candidateIndex === optionIndex ? { ...candidate, translations: { ...candidate.translations, [locale]: value } } : candidate
1311
+ ...candidate,
1312
+ options: candidate.options.map(
1313
+ (candidateOption, candidateOptionIndex) => candidateOptionIndex === optionIndex ? withTranslationMetadata(
1314
+ {
1315
+ ...candidateOption,
1316
+ translations: { ...candidateOption.translations, [locale]: value }
1317
+ },
1318
+ locale,
1319
+ "label",
1320
+ metadata
1321
+ ) : candidateOption
1002
1322
  )
1003
1323
  };
1004
1324
  })
@@ -1008,42 +1328,51 @@ function translationSlots(schema) {
1008
1328
  }
1009
1329
  });
1010
1330
  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) => ({
1331
+ const addPageSlot = (property, sourceText) => {
1332
+ const slot = createSlot(
1333
+ "page",
1334
+ page.id,
1335
+ property,
1336
+ locale,
1337
+ sourceText,
1338
+ page.translations?.[locale]?.[property],
1339
+ page.metadata,
1340
+ page.translationMetadata?.[locale]?.[property]
1341
+ );
1342
+ descriptors.push({
1343
+ slot,
1344
+ apply: (current, value, metadata) => ({
1028
1345
  ...current,
1029
1346
  ...current.pages === void 0 ? {} : {
1030
1347
  pages: current.pages.map(
1031
- (item, index) => index === pageIndex ? { ...item, translations: mergeLocalizedText(item.translations, locale, "description", value) } : item
1348
+ (candidate, index) => index === pageIndex ? withTranslationMetadata(
1349
+ {
1350
+ ...candidate,
1351
+ translations: mergeLocalizedText(candidate.translations, locale, property, value)
1352
+ },
1353
+ locale,
1354
+ property,
1355
+ metadata
1356
+ ) : candidate
1032
1357
  )
1033
1358
  }
1034
1359
  })
1035
1360
  });
1036
- }
1361
+ };
1362
+ if (page.title !== void 0) addPageSlot("title", page.title);
1363
+ if (page.description !== void 0) addPageSlot("description", page.description);
1037
1364
  });
1038
- return slots;
1365
+ return descriptors;
1039
1366
  }
1040
1367
  function resolveLocalizedSchema(schema, targetLocale) {
1041
1368
  if (targetLocale.length === 0 || targetLocale === schema.defaultLocale) return schema;
1042
1369
  const formTranslation = schema.translations?.[targetLocale];
1370
+ const completionMessage = formTranslation?.completionMessage ?? schema.completionMessage;
1043
1371
  return {
1044
1372
  ...schema,
1045
1373
  title: formTranslation?.title ?? schema.title,
1046
1374
  ...(formTranslation?.description ?? schema.description) === void 0 ? {} : { description: formTranslation?.description ?? schema.description },
1375
+ ...completionMessage === void 0 ? {} : { completionMessage },
1047
1376
  fields: schema.fields.map((field) => {
1048
1377
  const translation = field.translations?.[targetLocale];
1049
1378
  const localized = {
@@ -1074,24 +1403,35 @@ function resolveLocalizedSchema(schema, targetLocale) {
1074
1403
  }
1075
1404
  };
1076
1405
  }
1077
- async function populateSchemaTranslations(schema, targetLocales, adapter) {
1406
+ async function populateSchemaTranslations(schema, targetLocales, adapter, options = {}) {
1078
1407
  assertValidFormSchema(schema);
1079
- const slots = translationSlots(schema);
1080
1408
  const locales = [...new Set(targetLocales.filter((locale) => locale.length > 0 && locale !== schema.defaultLocale))];
1409
+ const updatedSlots = [];
1410
+ const skippedSlots = [];
1081
1411
  let result = schema;
1082
1412
  for (const locale of locales) {
1413
+ const descriptors = translationSlots(schema, locale);
1414
+ const selected = [];
1415
+ for (const descriptor of descriptors) {
1416
+ const shouldTranslate = options.shouldOverwrite?.(descriptor.slot) ?? (options.overwrite === "all" || descriptor.slot.existingText === void 0);
1417
+ if (shouldTranslate) selected.push(descriptor);
1418
+ else skippedSlots.push(descriptor.slot);
1419
+ }
1420
+ if (selected.length === 0) continue;
1083
1421
  const translated = await adapter.translateBatch(
1084
- slots.map((slot) => slot.text),
1422
+ selected.map((descriptor) => descriptor.slot.sourceText),
1085
1423
  locale,
1086
1424
  schema.defaultLocale
1087
1425
  );
1088
- if (translated.length !== slots.length) {
1089
- throw new Error(`Translation adapter returned ${translated.length} texts for ${slots.length} inputs.`);
1426
+ if (translated.length !== selected.length) {
1427
+ throw new Error(`Translation adapter returned ${translated.length} texts for ${selected.length} inputs.`);
1090
1428
  }
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);
1429
+ selected.forEach((descriptor, index) => {
1430
+ const translatedText = translated[index];
1431
+ if (translatedText === void 0) throw new Error("Translation adapter returned an unexpected result.");
1432
+ const metadata = options.createMetadata?.(descriptor.slot, translatedText);
1433
+ result = descriptor.apply(result, translatedText, metadata);
1434
+ updatedSlots.push(descriptor.slot);
1095
1435
  });
1096
1436
  }
1097
1437
  const supportedLocales = [
@@ -1101,17 +1441,18 @@ async function populateSchemaTranslations(schema, targetLocales, adapter) {
1101
1441
  ...locales
1102
1442
  ])
1103
1443
  ];
1104
- result = supportedLocales.length === 0 ? result : { ...result, supportedLocales };
1444
+ if (supportedLocales.length > 0) result = { ...result, supportedLocales };
1105
1445
  assertValidFormSchema(result);
1106
- return result;
1446
+ return { schema: result, report: { updatedSlots, skippedSlots } };
1107
1447
  }
1108
1448
  async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocale) {
1109
1449
  const populated = await populateSchemaTranslations(
1110
1450
  sourceLocale === void 0 ? schema : { ...schema, defaultLocale: sourceLocale },
1111
1451
  [targetLocale],
1112
- adapter
1452
+ adapter,
1453
+ { overwrite: "all" }
1113
1454
  );
1114
- return resolveLocalizedSchema(populated, targetLocale);
1455
+ return resolveLocalizedSchema(populated.schema, targetLocale);
1115
1456
  }
1116
1457
  export {
1117
1458
  aggregateResponses,
@@ -1132,6 +1473,7 @@ export {
1132
1473
  resolveLocalizedSchema,
1133
1474
  sanitizeSchema,
1134
1475
  selectVisibleAnswers,
1476
+ transformFieldType,
1135
1477
  validateAnswers,
1136
1478
  validateFormSchema,
1137
1479
  validatePageAnswers,