@form-engine-ts/core 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,11 +30,20 @@ Add `pages` to partition every field into an accessible wizard and use `validate
30
30
  for step-scoped validation. Schemas without `pages` remain single-page forms.
31
31
 
32
32
  Store authoring-time translations on forms, fields, options, and pages. `resolveLocalizedSchema` applies them synchronously,
33
- while `populateSchemaTranslations` fills them through an injected `AsyncTranslationAdapter`.
33
+ while `populateSchemaTranslations` fills them through an injected `AsyncTranslationAdapter`. Population defaults to
34
+ `overwrite: "missing-only"`, accepts per-slot `shouldOverwrite` and `createMetadata` callbacks, and returns
35
+ `{ schema, report }` with updated and skipped translation slots.
36
+
37
+ Forms, pages, fields, options, and submissions accept JSON-only `metadata` and per-locale/property
38
+ `translationMetadata`. These extension values survive sanitization, localization, submission creation, and storage
39
+ round-trips. `completionMessage` is localized with the rest of the form text.
34
40
 
35
41
  `calculateCrossTabulation` builds a two-question frequency matrix from submissions. `dispatchWebhook` posts typed
36
42
  `response.submitted` or `schema.updated` events with timeout handling, custom headers, and optional HMAC-SHA256 signing.
37
43
 
44
+ CSV export neutralizes string cells whose first non-whitespace character is `=`, `+`, `-`, or `@`. This is enabled by
45
+ default; trusted callers can pass `{ neutralizeFormulas: false }`. RFC 4180 quoting and the UTF-8 BOM remain unchanged.
46
+
38
47
  Storage adapters share inclusive ISO 8601 submission-range filtering:
39
48
 
40
49
  ```ts
package/dist/index.cjs CHANGED
@@ -187,6 +187,52 @@ function isNonEmptyString(value) {
187
187
  function issue(issues, path, code, message) {
188
188
  issues.push({ path, code, message });
189
189
  }
190
+ function validateJsonValue(value, path, issues, ancestors = /* @__PURE__ */ new Set()) {
191
+ if (value === null || typeof value === "string" || typeof value === "boolean") return;
192
+ if (typeof value === "number") {
193
+ if (!Number.isFinite(value)) issue(issues, path, "invalid_metadata", "Metadata numbers must be finite.");
194
+ return;
195
+ }
196
+ if (typeof value !== "object") {
197
+ issue(issues, path, "invalid_metadata", "Expected JSON-serializable metadata.");
198
+ return;
199
+ }
200
+ if (ancestors.has(value)) {
201
+ issue(issues, path, "invalid_metadata", "Metadata must not contain cycles.");
202
+ return;
203
+ }
204
+ const nextAncestors = new Set(ancestors).add(value);
205
+ if (Array.isArray(value)) {
206
+ value.forEach((item, index) => {
207
+ validateJsonValue(item, `${path}[${index}]`, issues, nextAncestors);
208
+ });
209
+ return;
210
+ }
211
+ const prototype = Object.getPrototypeOf(value);
212
+ if (prototype !== Object.prototype && prototype !== null) {
213
+ issue(issues, path, "invalid_metadata", "Metadata objects must be plain JSON objects.");
214
+ return;
215
+ }
216
+ for (const [key, item] of Object.entries(value)) {
217
+ validateJsonValue(item, `${path}.${key}`, issues, nextAncestors);
218
+ }
219
+ }
220
+ function validateExtensibleNode(value, path, issues) {
221
+ for (const property of ["metadata", "translationMetadata"]) {
222
+ const candidate = value[property];
223
+ if (candidate === void 0) continue;
224
+ if (!isRecord(candidate)) {
225
+ issue(
226
+ issues,
227
+ path.length === 0 ? property : `${path}.${property}`,
228
+ "invalid_metadata",
229
+ "Expected a metadata object."
230
+ );
231
+ continue;
232
+ }
233
+ validateJsonValue(candidate, path.length === 0 ? property : `${path}.${property}`, issues);
234
+ }
235
+ }
190
236
  function validateLocalizedTextMap(value, path, issues) {
191
237
  if (!isRecord(value)) {
192
238
  issue(issues, path, "invalid_translations", "Expected a locale-to-translation object.");
@@ -197,7 +243,7 @@ function validateLocalizedTextMap(value, path, issues) {
197
243
  issue(issues, `${path}.${locale}`, "invalid_translation", "Expected a translation object.");
198
244
  continue;
199
245
  }
200
- for (const key of ["title", "description"]) {
246
+ for (const key of ["title", "description", "completionMessage"]) {
201
247
  if (translation[key] !== void 0 && !isNonEmptyString(translation[key])) {
202
248
  issue(issues, `${path}.${locale}.${key}`, "invalid_translation", "Expected non-empty translated text.");
203
249
  }
@@ -248,6 +294,7 @@ function validateOptions(value, path, issues) {
248
294
  return;
249
295
  }
250
296
  rejectLegacyProperties(option, optionPath, ["value", "labelKey"], issues);
297
+ validateExtensibleNode(option, optionPath, issues);
251
298
  if (!isNonEmptyString(option.id)) {
252
299
  issue(issues, `${optionPath}.id`, "invalid_option_id", "Expected a non-empty option ID.");
253
300
  } else if (seen.has(option.id)) {
@@ -293,6 +340,7 @@ function validateField(value, path, issues) {
293
340
  return false;
294
341
  }
295
342
  rejectLegacyProperties(value, path, ["titleKey", "labelKey", "helpTextKey", "descriptionKey"], issues);
343
+ validateExtensibleNode(value, path, issues);
296
344
  if (!isNonEmptyString(value.id)) issue(issues, `${path}.id`, "invalid_id", "Expected a non-empty ID.");
297
345
  if (!isNonEmptyString(value.title)) {
298
346
  issue(issues, `${path}.title`, "invalid_title", "Expected a non-empty question title.");
@@ -378,6 +426,7 @@ function validateFormSchema(input) {
378
426
  return { valid: false, issues: [{ path: "$", code: "invalid_schema", message: "Expected a schema object." }] };
379
427
  }
380
428
  rejectLegacyProperties(input, "", ["titleKey", "descriptionKey"], issues);
429
+ validateExtensibleNode(input, "", issues);
381
430
  if (!isNonEmptyString(input.id)) issue(issues, "id", "invalid_id", "Expected a non-empty ID.");
382
431
  if (!Number.isInteger(input.version) || input.version < 1) {
383
432
  issue(issues, "version", "invalid_version", "Expected a positive integer version.");
@@ -385,13 +434,13 @@ function validateFormSchema(input) {
385
434
  if (!isNonEmptyString(input.title)) {
386
435
  issue(issues, "title", "invalid_title", "Expected a non-empty form title.");
387
436
  }
388
- for (const key of ["description", "submitLabelKey"]) {
437
+ for (const key of ["description", "completionMessage", "submitLabelKey"]) {
389
438
  if (input[key] !== void 0 && !isNonEmptyString(input[key])) {
390
439
  issue(
391
440
  issues,
392
441
  key,
393
- key === "description" ? "invalid_description" : "invalid_translation_key",
394
- key === "description" ? "Expected a non-empty form description." : "Expected a translation key."
442
+ key === "submitLabelKey" ? "invalid_translation_key" : "invalid_description",
443
+ key === "submitLabelKey" ? "Expected a translation key." : "Expected non-empty form text."
395
444
  );
396
445
  }
397
446
  }
@@ -453,6 +502,7 @@ function validateFormSchema(input) {
453
502
  issue(issues, pagePath, "invalid_page", "Expected a page object.");
454
503
  return;
455
504
  }
505
+ validateExtensibleNode(page, pagePath, issues);
456
506
  if (!isNonEmptyString(page.id)) {
457
507
  issue(issues, `${pagePath}.id`, "invalid_page_id", "Expected a non-empty page ID.");
458
508
  } else if (pageIds.has(page.id)) {
@@ -744,15 +794,20 @@ function aggregateResponses(schema, submissions) {
744
794
  questions: schema.fields.map((field) => aggregateField(schema, field, submissions))
745
795
  };
746
796
  }
747
- function escapeCsvCell(value) {
797
+ function escapeCsvCell(value, neutralizeFormulas = true) {
748
798
  if (value === null || value === void 0) return "";
749
- const stringValue = String(value);
799
+ let stringValue = String(value);
800
+ if (neutralizeFormulas && typeof value === "string") {
801
+ const trimmed = stringValue.trimStart();
802
+ if (trimmed.length > 0 && ["=", "+", "-", "@"].includes(trimmed[0] ?? "")) {
803
+ stringValue = `'${stringValue}`;
804
+ }
805
+ }
750
806
  return /[",\r\n]/.test(stringValue) ? `"${stringValue.replaceAll('"', '""')}"` : stringValue;
751
807
  }
752
808
  function serializeValue(value) {
753
809
  if (value === void 0) return "";
754
- if (Array.isArray(value)) return JSON.stringify(value);
755
- return String(value);
810
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value);
756
811
  }
757
812
  function exportResponsesToCsv(schema, responses, options = {}) {
758
813
  assertValidFormSchema(schema);
@@ -773,7 +828,8 @@ function exportResponsesToCsv(schema, responses, options = {}) {
773
828
  ];
774
829
  })
775
830
  ];
776
- const csv = rows.map((row) => row.map((cell) => escapeCsvCell(cell)).join(",")).join("\r\n");
831
+ const neutralizeFormulas = options.neutralizeFormulas ?? true;
832
+ const csv = rows.map((row) => row.map((cell) => escapeCsvCell(cell, neutralizeFormulas)).join(",")).join("\r\n");
777
833
  return options.withBom ?? true ? `\uFEFF${csv}` : csv;
778
834
  }
779
835
 
@@ -983,69 +1039,125 @@ function createSubmission(schema, values, options) {
983
1039
  formVersion: schema.version,
984
1040
  locale: options.locale,
985
1041
  values: Object.freeze(cloneValues(visibleValues)),
986
- submittedAt: options.submittedAt
1042
+ submittedAt: options.submittedAt,
1043
+ ...options.metadata === void 0 ? {} : { metadata: Object.freeze({ ...options.metadata }) },
1044
+ ...options.translationMetadata === void 0 ? {} : { translationMetadata: Object.freeze({ ...options.translationMetadata }) }
987
1045
  });
988
1046
  }
989
1047
 
990
1048
  // src/translation.ts
991
- function mergeLocalizedText(translations, locale, key, value) {
992
- return { ...translations, [locale]: { ...translations?.[locale], [key]: value } };
1049
+ function mergeLocalizedText(translations, locale, property, value) {
1050
+ return { ...translations, [locale]: { ...translations?.[locale], [property]: value } };
993
1051
  }
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
- })
1052
+ function withTranslationMetadata(node, locale, property, metadata) {
1053
+ if (metadata === void 0) return node;
1054
+ return {
1055
+ ...node,
1056
+ translationMetadata: {
1057
+ ...node.translationMetadata,
1058
+ [locale]: {
1059
+ ...node.translationMetadata?.[locale],
1060
+ [property]: metadata
1061
+ }
1002
1062
  }
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
- })
1063
+ };
1064
+ }
1065
+ function createSlot(kind, nodeId, property, locale, sourceText, existingText, metadata) {
1066
+ return {
1067
+ kind,
1068
+ nodeId,
1069
+ property,
1070
+ locale,
1071
+ sourceText,
1072
+ ...existingText === void 0 ? {} : { existingText },
1073
+ ...metadata === void 0 ? {} : { metadata }
1074
+ };
1075
+ }
1076
+ function translationSlots(schema, locale) {
1077
+ const descriptors = [];
1078
+ const addFormSlot = (property, sourceText) => {
1079
+ const slot = createSlot(
1080
+ "form",
1081
+ schema.id,
1082
+ property,
1083
+ locale,
1084
+ sourceText,
1085
+ schema.translations?.[locale]?.[property],
1086
+ schema.metadata
1087
+ );
1088
+ descriptors.push({
1089
+ slot,
1090
+ apply: (current, value, metadata) => withTranslationMetadata(
1091
+ { ...current, translations: mergeLocalizedText(current.translations, locale, property, value) },
1092
+ locale,
1093
+ property,
1094
+ metadata
1095
+ )
1011
1096
  });
1012
- }
1097
+ };
1098
+ addFormSlot("title", schema.title);
1099
+ if (schema.description !== void 0) addFormSlot("description", schema.description);
1100
+ if (schema.completionMessage !== void 0) addFormSlot("completionMessage", schema.completionMessage);
1013
1101
  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) => ({
1102
+ const addFieldSlot = (property, sourceText) => {
1103
+ const slot = createSlot(
1104
+ "field",
1105
+ field.id,
1106
+ property,
1107
+ locale,
1108
+ sourceText,
1109
+ field.translations?.[locale]?.[property],
1110
+ field.metadata
1111
+ );
1112
+ descriptors.push({
1113
+ slot,
1114
+ apply: (current, value, metadata) => ({
1027
1115
  ...current,
1028
1116
  fields: current.fields.map(
1029
- (item, index) => index === fieldIndex ? {
1030
- ...item,
1031
- translations: mergeLocalizedText(item.translations, locale, "description", value)
1032
- } : item
1117
+ (candidate, index) => index === fieldIndex ? withTranslationMetadata(
1118
+ {
1119
+ ...candidate,
1120
+ translations: mergeLocalizedText(candidate.translations, locale, property, value)
1121
+ },
1122
+ locale,
1123
+ property,
1124
+ metadata
1125
+ ) : candidate
1033
1126
  )
1034
1127
  })
1035
1128
  });
1036
- }
1129
+ };
1130
+ addFieldSlot("title", field.title);
1131
+ if (field.description !== void 0) addFieldSlot("description", field.description);
1037
1132
  if ("options" in field) {
1038
1133
  field.options.forEach((option, optionIndex) => {
1039
- slots.push({
1040
- text: option.label,
1041
- apply: (current, value, locale) => ({
1134
+ const slot = createSlot(
1135
+ "option",
1136
+ option.id,
1137
+ "label",
1138
+ locale,
1139
+ option.label,
1140
+ option.translations?.[locale],
1141
+ option.metadata
1142
+ );
1143
+ descriptors.push({
1144
+ slot,
1145
+ apply: (current, value, metadata) => ({
1042
1146
  ...current,
1043
- fields: current.fields.map((item, index) => {
1044
- if (index !== fieldIndex || !("options" in item)) return item;
1147
+ fields: current.fields.map((candidate, candidateIndex) => {
1148
+ if (candidateIndex !== fieldIndex || !("options" in candidate)) return candidate;
1045
1149
  return {
1046
- ...item,
1047
- options: item.options.map(
1048
- (candidate, candidateIndex) => candidateIndex === optionIndex ? { ...candidate, translations: { ...candidate.translations, [locale]: value } } : candidate
1150
+ ...candidate,
1151
+ options: candidate.options.map(
1152
+ (candidateOption, candidateOptionIndex) => candidateOptionIndex === optionIndex ? withTranslationMetadata(
1153
+ {
1154
+ ...candidateOption,
1155
+ translations: { ...candidateOption.translations, [locale]: value }
1156
+ },
1157
+ locale,
1158
+ "label",
1159
+ metadata
1160
+ ) : candidateOption
1049
1161
  )
1050
1162
  };
1051
1163
  })
@@ -1055,42 +1167,50 @@ function translationSlots(schema) {
1055
1167
  }
1056
1168
  });
1057
1169
  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) => ({
1170
+ const addPageSlot = (property, sourceText) => {
1171
+ const slot = createSlot(
1172
+ "page",
1173
+ page.id,
1174
+ property,
1175
+ locale,
1176
+ sourceText,
1177
+ page.translations?.[locale]?.[property],
1178
+ page.metadata
1179
+ );
1180
+ descriptors.push({
1181
+ slot,
1182
+ apply: (current, value, metadata) => ({
1075
1183
  ...current,
1076
1184
  ...current.pages === void 0 ? {} : {
1077
1185
  pages: current.pages.map(
1078
- (item, index) => index === pageIndex ? { ...item, translations: mergeLocalizedText(item.translations, locale, "description", value) } : item
1186
+ (candidate, index) => index === pageIndex ? withTranslationMetadata(
1187
+ {
1188
+ ...candidate,
1189
+ translations: mergeLocalizedText(candidate.translations, locale, property, value)
1190
+ },
1191
+ locale,
1192
+ property,
1193
+ metadata
1194
+ ) : candidate
1079
1195
  )
1080
1196
  }
1081
1197
  })
1082
1198
  });
1083
- }
1199
+ };
1200
+ if (page.title !== void 0) addPageSlot("title", page.title);
1201
+ if (page.description !== void 0) addPageSlot("description", page.description);
1084
1202
  });
1085
- return slots;
1203
+ return descriptors;
1086
1204
  }
1087
1205
  function resolveLocalizedSchema(schema, targetLocale) {
1088
1206
  if (targetLocale.length === 0 || targetLocale === schema.defaultLocale) return schema;
1089
1207
  const formTranslation = schema.translations?.[targetLocale];
1208
+ const completionMessage = formTranslation?.completionMessage ?? schema.completionMessage;
1090
1209
  return {
1091
1210
  ...schema,
1092
1211
  title: formTranslation?.title ?? schema.title,
1093
1212
  ...(formTranslation?.description ?? schema.description) === void 0 ? {} : { description: formTranslation?.description ?? schema.description },
1213
+ ...completionMessage === void 0 ? {} : { completionMessage },
1094
1214
  fields: schema.fields.map((field) => {
1095
1215
  const translation = field.translations?.[targetLocale];
1096
1216
  const localized = {
@@ -1121,24 +1241,35 @@ function resolveLocalizedSchema(schema, targetLocale) {
1121
1241
  }
1122
1242
  };
1123
1243
  }
1124
- async function populateSchemaTranslations(schema, targetLocales, adapter) {
1244
+ async function populateSchemaTranslations(schema, targetLocales, adapter, options = {}) {
1125
1245
  assertValidFormSchema(schema);
1126
- const slots = translationSlots(schema);
1127
1246
  const locales = [...new Set(targetLocales.filter((locale) => locale.length > 0 && locale !== schema.defaultLocale))];
1247
+ const updatedSlots = [];
1248
+ const skippedSlots = [];
1128
1249
  let result = schema;
1129
1250
  for (const locale of locales) {
1251
+ const descriptors = translationSlots(schema, locale);
1252
+ const selected = [];
1253
+ for (const descriptor of descriptors) {
1254
+ const shouldTranslate = options.shouldOverwrite?.(descriptor.slot) ?? (options.overwrite === "all" || descriptor.slot.existingText === void 0);
1255
+ if (shouldTranslate) selected.push(descriptor);
1256
+ else skippedSlots.push(descriptor.slot);
1257
+ }
1258
+ if (selected.length === 0) continue;
1130
1259
  const translated = await adapter.translateBatch(
1131
- slots.map((slot) => slot.text),
1260
+ selected.map((descriptor) => descriptor.slot.sourceText),
1132
1261
  locale,
1133
1262
  schema.defaultLocale
1134
1263
  );
1135
- if (translated.length !== slots.length) {
1136
- throw new Error(`Translation adapter returned ${translated.length} texts for ${slots.length} inputs.`);
1264
+ if (translated.length !== selected.length) {
1265
+ throw new Error(`Translation adapter returned ${translated.length} texts for ${selected.length} inputs.`);
1137
1266
  }
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);
1267
+ selected.forEach((descriptor, index) => {
1268
+ const translatedText = translated[index];
1269
+ if (translatedText === void 0) throw new Error("Translation adapter returned an unexpected result.");
1270
+ const metadata = options.createMetadata?.(descriptor.slot, translatedText);
1271
+ result = descriptor.apply(result, translatedText, metadata);
1272
+ updatedSlots.push(descriptor.slot);
1142
1273
  });
1143
1274
  }
1144
1275
  const supportedLocales = [
@@ -1148,17 +1279,18 @@ async function populateSchemaTranslations(schema, targetLocales, adapter) {
1148
1279
  ...locales
1149
1280
  ])
1150
1281
  ];
1151
- result = supportedLocales.length === 0 ? result : { ...result, supportedLocales };
1282
+ if (supportedLocales.length > 0) result = { ...result, supportedLocales };
1152
1283
  assertValidFormSchema(result);
1153
- return result;
1284
+ return { schema: result, report: { updatedSlots, skippedSlots } };
1154
1285
  }
1155
1286
  async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocale) {
1156
1287
  const populated = await populateSchemaTranslations(
1157
1288
  sourceLocale === void 0 ? schema : { ...schema, defaultLocale: sourceLocale },
1158
1289
  [targetLocale],
1159
- adapter
1290
+ adapter,
1291
+ { overwrite: "all" }
1160
1292
  );
1161
- return resolveLocalizedSchema(populated, targetLocale);
1293
+ return resolveLocalizedSchema(populated.schema, targetLocale);
1162
1294
  }
1163
1295
  // Annotate the CommonJS export names for ESM import in node:
1164
1296
  0 && (module.exports = {
package/dist/index.d.cts CHANGED
@@ -1,6 +1,15 @@
1
1
  type FieldType = "text" | "textarea" | "number" | "rating" | "select" | "multi-select" | "checkbox" | "radio";
2
2
  type ConditionOperator = "equals" | "not_equals" | "contains" | "not_empty";
3
3
  type ConditionValue = string | number | boolean;
4
+ type JsonValue = string | number | boolean | null | readonly JsonValue[] | {
5
+ readonly [key: string]: JsonValue;
6
+ };
7
+ /** Arbitrary, JSON-serializable data preserved by every form-engine operation. */
8
+ interface ExtensibleNode {
9
+ readonly metadata?: Readonly<Record<string, JsonValue>>;
10
+ /** Locale -> translated property -> metadata created for that translation. */
11
+ readonly translationMetadata?: Readonly<Record<string, Readonly<Record<string, Readonly<Record<string, JsonValue>>>>>>;
12
+ }
4
13
  interface DisplayCondition {
5
14
  readonly questionId: string;
6
15
  readonly operator: ConditionOperator;
@@ -9,15 +18,16 @@ interface DisplayCondition {
9
18
  interface LocalizedText {
10
19
  readonly title?: string;
11
20
  readonly description?: string;
21
+ readonly completionMessage?: string;
12
22
  }
13
23
  type SchemaTranslations = Readonly<Record<string, LocalizedText>>;
14
24
  type ValidationCode = "required" | "invalid_type" | "min_length" | "max_length" | "pattern" | "min" | "max" | "step" | "invalid_option" | "min_selections" | "max_selections" | "unknown_field";
15
- interface FieldOption {
25
+ interface FieldOption extends ExtensibleNode {
16
26
  readonly id: string;
17
27
  readonly label: string;
18
28
  readonly translations?: Readonly<Record<string, string>>;
19
29
  }
20
- interface BaseField {
30
+ interface BaseField extends ExtensibleNode {
21
31
  readonly id: string;
22
32
  readonly type: FieldType;
23
33
  readonly title: string;
@@ -61,7 +71,7 @@ interface CheckboxField extends BaseField {
61
71
  readonly type: "checkbox";
62
72
  }
63
73
  type FormField = TextField | NumberField | RatingField | SelectField | MultiSelectField | CheckboxField;
64
- interface FormPage {
74
+ interface FormPage extends ExtensibleNode {
65
75
  readonly id: string;
66
76
  readonly title?: string;
67
77
  readonly description?: string;
@@ -69,11 +79,12 @@ interface FormPage {
69
79
  readonly displayCondition?: DisplayCondition;
70
80
  readonly translations?: SchemaTranslations;
71
81
  }
72
- interface FormSchema {
82
+ interface FormSchema extends ExtensibleNode {
73
83
  readonly id: string;
74
84
  readonly version: number;
75
85
  readonly title: string;
76
86
  readonly description?: string;
87
+ readonly completionMessage?: string;
77
88
  readonly submitLabelKey?: string;
78
89
  readonly defaultLocale?: string;
79
90
  readonly supportedLocales?: readonly string[];
@@ -102,6 +113,7 @@ interface ValidationIssue {
102
113
  readonly messageKey: string;
103
114
  readonly params: Readonly<Record<string, string | number>>;
104
115
  }
116
+ type ValidationError = ValidationIssue;
105
117
  type AnswerValidationResult = {
106
118
  readonly valid: true;
107
119
  readonly issues: readonly [];
@@ -109,7 +121,7 @@ type AnswerValidationResult = {
109
121
  readonly valid: false;
110
122
  readonly issues: readonly ValidationIssue[];
111
123
  };
112
- interface FormSubmission {
124
+ interface FormSubmission extends ExtensibleNode {
113
125
  readonly id: string;
114
126
  readonly formId: string;
115
127
  readonly formVersion: number;
@@ -182,7 +194,13 @@ interface FormAnalytics {
182
194
  type Question = FormField;
183
195
  type QuestionType = FieldType;
184
196
  type ChoiceOption = FieldOption;
185
- type FormResponse = FormSubmission;
197
+ interface FormResponse extends ExtensibleNode {
198
+ readonly responseId: string;
199
+ readonly formId: string;
200
+ readonly sourceLocale?: string;
201
+ readonly answers: Readonly<Record<string, unknown>>;
202
+ readonly submittedAt: string;
203
+ }
186
204
  interface CrossTabulationResult {
187
205
  readonly rowQuestionId: string;
188
206
  readonly colQuestionId: string;
@@ -206,9 +224,10 @@ declare function calculateChoiceDistribution(responses: readonly FormSubmission[
206
224
  declare function calculateNumericSummary(responses: readonly FormSubmission[], questionId: string): NumericSummary;
207
225
  declare function calculateCrossTabulation(responses: readonly FormSubmission[], rowQuestionId: string, colQuestionId: string): CrossTabulationResult;
208
226
  declare function aggregateResponses(schema: FormSchema, submissions: readonly FormSubmission[]): FormAnalytics;
209
- declare function escapeCsvCell(value: string | number | null | undefined): string;
227
+ declare function escapeCsvCell(value: string | number | boolean | null | undefined, neutralizeFormulas?: boolean): string;
210
228
  interface CsvExportOptions {
211
229
  readonly withBom?: boolean;
230
+ readonly neutralizeFormulas?: boolean;
212
231
  }
213
232
  declare function exportResponsesToCsv(schema: FormSchema, responses: readonly FormSubmission[], options?: CsvExportOptions): string;
214
233
 
@@ -246,15 +265,36 @@ declare function sanitizeSchema(schema: FormSchema): FormSchema;
246
265
  declare function validateFormSchema(input: unknown): SchemaValidationResult;
247
266
  declare function assertValidFormSchema(input: unknown): asserts input is FormSchema;
248
267
 
249
- interface CreateSubmissionOptions {
268
+ interface CreateSubmissionOptions extends ExtensibleNode {
250
269
  readonly id: string;
251
270
  readonly locale: string;
252
271
  readonly submittedAt: string;
253
272
  }
254
273
  declare function createSubmission(schema: FormSchema, values: FormValues, options: CreateSubmissionOptions): FormSubmission;
255
274
 
275
+ interface TranslationSlot {
276
+ readonly kind: "form" | "page" | "field" | "option";
277
+ readonly nodeId: string;
278
+ readonly property: "title" | "description" | "label" | "completionMessage";
279
+ readonly locale: string;
280
+ readonly sourceText: string;
281
+ readonly existingText?: string;
282
+ readonly metadata?: Readonly<Record<string, JsonValue>>;
283
+ }
284
+ interface PopulateTranslationOptions {
285
+ readonly overwrite?: "missing-only" | "all";
286
+ readonly shouldOverwrite?: (slot: TranslationSlot) => boolean;
287
+ readonly createMetadata?: (slot: TranslationSlot, translatedText: string) => Readonly<Record<string, JsonValue>>;
288
+ }
289
+ interface TranslationReport {
290
+ readonly updatedSlots: readonly TranslationSlot[];
291
+ readonly skippedSlots: readonly TranslationSlot[];
292
+ }
256
293
  declare function resolveLocalizedSchema(schema: FormSchema, targetLocale: string): FormSchema;
257
- declare function populateSchemaTranslations(schema: FormSchema, targetLocales: readonly string[], adapter: AsyncTranslationAdapter): Promise<FormSchema>;
294
+ declare function populateSchemaTranslations(schema: FormSchema, targetLocales: readonly string[], adapter: AsyncTranslationAdapter, options?: PopulateTranslationOptions): Promise<{
295
+ readonly schema: FormSchema;
296
+ readonly report: TranslationReport;
297
+ }>;
258
298
  declare function resolveFormTranslation(schema: FormSchema, adapter: AsyncTranslationAdapter, targetLocale: string, sourceLocale?: string): Promise<FormSchema>;
259
299
 
260
300
  declare function validateAnswers(schema: FormSchema, values: FormValues): AnswerValidationResult;
@@ -266,4 +306,4 @@ declare function calculatePageVisibility(schema: FormSchema, currentAnswers: Rea
266
306
  declare function calculateFieldVisibility(schema: FormSchema, currentAnswers: Readonly<Record<string, unknown>>): Readonly<Record<string, boolean>>;
267
307
  declare function selectVisibleAnswers(schema: FormSchema, currentAnswers: FormValues): FormValues;
268
308
 
269
- export { type AnswerValidationResult, type AsyncTranslationAdapter, type BaseField, type CheckboxField, type CheckboxQuestionAggregate, type ChoiceDistributionEntry, type ChoiceOption, type ChoiceQuestionAggregate, type ConditionOperator, type ConditionValue, type CreateSubmissionOptions, type CrossTabulationResult, type CsvExportOptions, type DisplayCondition, type FieldOption, type FieldType, type FormAnalytics, type FormEvent, type FormEventType, type FormField, type FormPage, type FormResponse, type FormSchema, type FormStorageAdapter, type FormSubmission, type FormValue, type FormValues, type LocalizedText, type MultiSelectField, type NumberField, type NumberQuestionAggregate, type NumericSummary, type OptionAggregate, type Question, type QuestionAggregate, type QuestionType, type RatingField, type SchemaIssue, type SchemaStructureIssue, type SchemaStructureIssueType, type SchemaTranslations, type SchemaValidationResult, type SelectField, type StorageAdapter, type SubmissionQueryOptions, type TextField, type TextQuestionAggregate, type TranslationAdapter, type ValidationCode, type ValidationIssue, type WebhookConfig, type WebhookDispatchResult, aggregateResponses, assertValidFormSchema, calculateChoiceDistribution, calculateCrossTabulation, calculateFieldVisibility, calculateNumericSummary, calculatePageVisibility, createSubmission, dispatchWebhook, escapeCsvCell, exportResponsesToCsv, isDisplayConditionSatisfied, isQuestionVisible, populateSchemaTranslations, resolveFormTranslation, resolveLocalizedSchema, sanitizeSchema, selectVisibleAnswers, validateAnswers, validateFormSchema, validatePageAnswers, validateSchemaStructure };
309
+ export { type AnswerValidationResult, type AsyncTranslationAdapter, type BaseField, type CheckboxField, type CheckboxQuestionAggregate, type ChoiceDistributionEntry, type ChoiceOption, type ChoiceQuestionAggregate, type ConditionOperator, type ConditionValue, type CreateSubmissionOptions, type CrossTabulationResult, type CsvExportOptions, type DisplayCondition, type ExtensibleNode, type FieldOption, type FieldType, type FormAnalytics, type FormEvent, type FormEventType, type FormField, type FormPage, type FormResponse, type FormSchema, type FormStorageAdapter, type FormSubmission, type FormValue, type FormValues, type JsonValue, type LocalizedText, type MultiSelectField, type NumberField, type NumberQuestionAggregate, type NumericSummary, type OptionAggregate, type PopulateTranslationOptions, type Question, type QuestionAggregate, type QuestionType, type RatingField, type SchemaIssue, type SchemaStructureIssue, type SchemaStructureIssueType, type SchemaTranslations, type SchemaValidationResult, type SelectField, type StorageAdapter, type SubmissionQueryOptions, type TextField, type TextQuestionAggregate, type TranslationAdapter, type TranslationReport, type TranslationSlot, type ValidationCode, type ValidationError, type ValidationIssue, type WebhookConfig, type WebhookDispatchResult, aggregateResponses, assertValidFormSchema, calculateChoiceDistribution, calculateCrossTabulation, calculateFieldVisibility, calculateNumericSummary, calculatePageVisibility, createSubmission, dispatchWebhook, escapeCsvCell, exportResponsesToCsv, isDisplayConditionSatisfied, isQuestionVisible, populateSchemaTranslations, resolveFormTranslation, resolveLocalizedSchema, sanitizeSchema, selectVisibleAnswers, validateAnswers, validateFormSchema, validatePageAnswers, validateSchemaStructure };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  type FieldType = "text" | "textarea" | "number" | "rating" | "select" | "multi-select" | "checkbox" | "radio";
2
2
  type ConditionOperator = "equals" | "not_equals" | "contains" | "not_empty";
3
3
  type ConditionValue = string | number | boolean;
4
+ type JsonValue = string | number | boolean | null | readonly JsonValue[] | {
5
+ readonly [key: string]: JsonValue;
6
+ };
7
+ /** Arbitrary, JSON-serializable data preserved by every form-engine operation. */
8
+ interface ExtensibleNode {
9
+ readonly metadata?: Readonly<Record<string, JsonValue>>;
10
+ /** Locale -> translated property -> metadata created for that translation. */
11
+ readonly translationMetadata?: Readonly<Record<string, Readonly<Record<string, Readonly<Record<string, JsonValue>>>>>>;
12
+ }
4
13
  interface DisplayCondition {
5
14
  readonly questionId: string;
6
15
  readonly operator: ConditionOperator;
@@ -9,15 +18,16 @@ interface DisplayCondition {
9
18
  interface LocalizedText {
10
19
  readonly title?: string;
11
20
  readonly description?: string;
21
+ readonly completionMessage?: string;
12
22
  }
13
23
  type SchemaTranslations = Readonly<Record<string, LocalizedText>>;
14
24
  type ValidationCode = "required" | "invalid_type" | "min_length" | "max_length" | "pattern" | "min" | "max" | "step" | "invalid_option" | "min_selections" | "max_selections" | "unknown_field";
15
- interface FieldOption {
25
+ interface FieldOption extends ExtensibleNode {
16
26
  readonly id: string;
17
27
  readonly label: string;
18
28
  readonly translations?: Readonly<Record<string, string>>;
19
29
  }
20
- interface BaseField {
30
+ interface BaseField extends ExtensibleNode {
21
31
  readonly id: string;
22
32
  readonly type: FieldType;
23
33
  readonly title: string;
@@ -61,7 +71,7 @@ interface CheckboxField extends BaseField {
61
71
  readonly type: "checkbox";
62
72
  }
63
73
  type FormField = TextField | NumberField | RatingField | SelectField | MultiSelectField | CheckboxField;
64
- interface FormPage {
74
+ interface FormPage extends ExtensibleNode {
65
75
  readonly id: string;
66
76
  readonly title?: string;
67
77
  readonly description?: string;
@@ -69,11 +79,12 @@ interface FormPage {
69
79
  readonly displayCondition?: DisplayCondition;
70
80
  readonly translations?: SchemaTranslations;
71
81
  }
72
- interface FormSchema {
82
+ interface FormSchema extends ExtensibleNode {
73
83
  readonly id: string;
74
84
  readonly version: number;
75
85
  readonly title: string;
76
86
  readonly description?: string;
87
+ readonly completionMessage?: string;
77
88
  readonly submitLabelKey?: string;
78
89
  readonly defaultLocale?: string;
79
90
  readonly supportedLocales?: readonly string[];
@@ -102,6 +113,7 @@ interface ValidationIssue {
102
113
  readonly messageKey: string;
103
114
  readonly params: Readonly<Record<string, string | number>>;
104
115
  }
116
+ type ValidationError = ValidationIssue;
105
117
  type AnswerValidationResult = {
106
118
  readonly valid: true;
107
119
  readonly issues: readonly [];
@@ -109,7 +121,7 @@ type AnswerValidationResult = {
109
121
  readonly valid: false;
110
122
  readonly issues: readonly ValidationIssue[];
111
123
  };
112
- interface FormSubmission {
124
+ interface FormSubmission extends ExtensibleNode {
113
125
  readonly id: string;
114
126
  readonly formId: string;
115
127
  readonly formVersion: number;
@@ -182,7 +194,13 @@ interface FormAnalytics {
182
194
  type Question = FormField;
183
195
  type QuestionType = FieldType;
184
196
  type ChoiceOption = FieldOption;
185
- type FormResponse = FormSubmission;
197
+ interface FormResponse extends ExtensibleNode {
198
+ readonly responseId: string;
199
+ readonly formId: string;
200
+ readonly sourceLocale?: string;
201
+ readonly answers: Readonly<Record<string, unknown>>;
202
+ readonly submittedAt: string;
203
+ }
186
204
  interface CrossTabulationResult {
187
205
  readonly rowQuestionId: string;
188
206
  readonly colQuestionId: string;
@@ -206,9 +224,10 @@ declare function calculateChoiceDistribution(responses: readonly FormSubmission[
206
224
  declare function calculateNumericSummary(responses: readonly FormSubmission[], questionId: string): NumericSummary;
207
225
  declare function calculateCrossTabulation(responses: readonly FormSubmission[], rowQuestionId: string, colQuestionId: string): CrossTabulationResult;
208
226
  declare function aggregateResponses(schema: FormSchema, submissions: readonly FormSubmission[]): FormAnalytics;
209
- declare function escapeCsvCell(value: string | number | null | undefined): string;
227
+ declare function escapeCsvCell(value: string | number | boolean | null | undefined, neutralizeFormulas?: boolean): string;
210
228
  interface CsvExportOptions {
211
229
  readonly withBom?: boolean;
230
+ readonly neutralizeFormulas?: boolean;
212
231
  }
213
232
  declare function exportResponsesToCsv(schema: FormSchema, responses: readonly FormSubmission[], options?: CsvExportOptions): string;
214
233
 
@@ -246,15 +265,36 @@ declare function sanitizeSchema(schema: FormSchema): FormSchema;
246
265
  declare function validateFormSchema(input: unknown): SchemaValidationResult;
247
266
  declare function assertValidFormSchema(input: unknown): asserts input is FormSchema;
248
267
 
249
- interface CreateSubmissionOptions {
268
+ interface CreateSubmissionOptions extends ExtensibleNode {
250
269
  readonly id: string;
251
270
  readonly locale: string;
252
271
  readonly submittedAt: string;
253
272
  }
254
273
  declare function createSubmission(schema: FormSchema, values: FormValues, options: CreateSubmissionOptions): FormSubmission;
255
274
 
275
+ interface TranslationSlot {
276
+ readonly kind: "form" | "page" | "field" | "option";
277
+ readonly nodeId: string;
278
+ readonly property: "title" | "description" | "label" | "completionMessage";
279
+ readonly locale: string;
280
+ readonly sourceText: string;
281
+ readonly existingText?: string;
282
+ readonly metadata?: Readonly<Record<string, JsonValue>>;
283
+ }
284
+ interface PopulateTranslationOptions {
285
+ readonly overwrite?: "missing-only" | "all";
286
+ readonly shouldOverwrite?: (slot: TranslationSlot) => boolean;
287
+ readonly createMetadata?: (slot: TranslationSlot, translatedText: string) => Readonly<Record<string, JsonValue>>;
288
+ }
289
+ interface TranslationReport {
290
+ readonly updatedSlots: readonly TranslationSlot[];
291
+ readonly skippedSlots: readonly TranslationSlot[];
292
+ }
256
293
  declare function resolveLocalizedSchema(schema: FormSchema, targetLocale: string): FormSchema;
257
- declare function populateSchemaTranslations(schema: FormSchema, targetLocales: readonly string[], adapter: AsyncTranslationAdapter): Promise<FormSchema>;
294
+ declare function populateSchemaTranslations(schema: FormSchema, targetLocales: readonly string[], adapter: AsyncTranslationAdapter, options?: PopulateTranslationOptions): Promise<{
295
+ readonly schema: FormSchema;
296
+ readonly report: TranslationReport;
297
+ }>;
258
298
  declare function resolveFormTranslation(schema: FormSchema, adapter: AsyncTranslationAdapter, targetLocale: string, sourceLocale?: string): Promise<FormSchema>;
259
299
 
260
300
  declare function validateAnswers(schema: FormSchema, values: FormValues): AnswerValidationResult;
@@ -266,4 +306,4 @@ declare function calculatePageVisibility(schema: FormSchema, currentAnswers: Rea
266
306
  declare function calculateFieldVisibility(schema: FormSchema, currentAnswers: Readonly<Record<string, unknown>>): Readonly<Record<string, boolean>>;
267
307
  declare function selectVisibleAnswers(schema: FormSchema, currentAnswers: FormValues): FormValues;
268
308
 
269
- export { type AnswerValidationResult, type AsyncTranslationAdapter, type BaseField, type CheckboxField, type CheckboxQuestionAggregate, type ChoiceDistributionEntry, type ChoiceOption, type ChoiceQuestionAggregate, type ConditionOperator, type ConditionValue, type CreateSubmissionOptions, type CrossTabulationResult, type CsvExportOptions, type DisplayCondition, type FieldOption, type FieldType, type FormAnalytics, type FormEvent, type FormEventType, type FormField, type FormPage, type FormResponse, type FormSchema, type FormStorageAdapter, type FormSubmission, type FormValue, type FormValues, type LocalizedText, type MultiSelectField, type NumberField, type NumberQuestionAggregate, type NumericSummary, type OptionAggregate, type Question, type QuestionAggregate, type QuestionType, type RatingField, type SchemaIssue, type SchemaStructureIssue, type SchemaStructureIssueType, type SchemaTranslations, type SchemaValidationResult, type SelectField, type StorageAdapter, type SubmissionQueryOptions, type TextField, type TextQuestionAggregate, type TranslationAdapter, type ValidationCode, type ValidationIssue, type WebhookConfig, type WebhookDispatchResult, aggregateResponses, assertValidFormSchema, calculateChoiceDistribution, calculateCrossTabulation, calculateFieldVisibility, calculateNumericSummary, calculatePageVisibility, createSubmission, dispatchWebhook, escapeCsvCell, exportResponsesToCsv, isDisplayConditionSatisfied, isQuestionVisible, populateSchemaTranslations, resolveFormTranslation, resolveLocalizedSchema, sanitizeSchema, selectVisibleAnswers, validateAnswers, validateFormSchema, validatePageAnswers, validateSchemaStructure };
309
+ export { type AnswerValidationResult, type AsyncTranslationAdapter, type BaseField, type CheckboxField, type CheckboxQuestionAggregate, type ChoiceDistributionEntry, type ChoiceOption, type ChoiceQuestionAggregate, type ConditionOperator, type ConditionValue, type CreateSubmissionOptions, type CrossTabulationResult, type CsvExportOptions, type DisplayCondition, type ExtensibleNode, type FieldOption, type FieldType, type FormAnalytics, type FormEvent, type FormEventType, type FormField, type FormPage, type FormResponse, type FormSchema, type FormStorageAdapter, type FormSubmission, type FormValue, type FormValues, type JsonValue, type LocalizedText, type MultiSelectField, type NumberField, type NumberQuestionAggregate, type NumericSummary, type OptionAggregate, type PopulateTranslationOptions, type Question, type QuestionAggregate, type QuestionType, type RatingField, type SchemaIssue, type SchemaStructureIssue, type SchemaStructureIssueType, type SchemaTranslations, type SchemaValidationResult, type SelectField, type StorageAdapter, type SubmissionQueryOptions, type TextField, type TextQuestionAggregate, type TranslationAdapter, type TranslationReport, type TranslationSlot, type ValidationCode, type ValidationError, type ValidationIssue, type WebhookConfig, type WebhookDispatchResult, aggregateResponses, assertValidFormSchema, calculateChoiceDistribution, calculateCrossTabulation, calculateFieldVisibility, calculateNumericSummary, calculatePageVisibility, createSubmission, dispatchWebhook, escapeCsvCell, exportResponsesToCsv, isDisplayConditionSatisfied, isQuestionVisible, populateSchemaTranslations, resolveFormTranslation, resolveLocalizedSchema, sanitizeSchema, selectVisibleAnswers, validateAnswers, validateFormSchema, validatePageAnswers, validateSchemaStructure };
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.");
@@ -331,6 +379,7 @@ function validateFormSchema(input) {
331
379
  return { valid: false, issues: [{ path: "$", code: "invalid_schema", message: "Expected a schema object." }] };
332
380
  }
333
381
  rejectLegacyProperties(input, "", ["titleKey", "descriptionKey"], issues);
382
+ validateExtensibleNode(input, "", issues);
334
383
  if (!isNonEmptyString(input.id)) issue(issues, "id", "invalid_id", "Expected a non-empty ID.");
335
384
  if (!Number.isInteger(input.version) || input.version < 1) {
336
385
  issue(issues, "version", "invalid_version", "Expected a positive integer version.");
@@ -338,13 +387,13 @@ function validateFormSchema(input) {
338
387
  if (!isNonEmptyString(input.title)) {
339
388
  issue(issues, "title", "invalid_title", "Expected a non-empty form title.");
340
389
  }
341
- for (const key of ["description", "submitLabelKey"]) {
390
+ for (const key of ["description", "completionMessage", "submitLabelKey"]) {
342
391
  if (input[key] !== void 0 && !isNonEmptyString(input[key])) {
343
392
  issue(
344
393
  issues,
345
394
  key,
346
- key === "description" ? "invalid_description" : "invalid_translation_key",
347
- key === "description" ? "Expected a non-empty form description." : "Expected a translation key."
395
+ key === "submitLabelKey" ? "invalid_translation_key" : "invalid_description",
396
+ key === "submitLabelKey" ? "Expected a translation key." : "Expected non-empty form text."
348
397
  );
349
398
  }
350
399
  }
@@ -406,6 +455,7 @@ function validateFormSchema(input) {
406
455
  issue(issues, pagePath, "invalid_page", "Expected a page object.");
407
456
  return;
408
457
  }
458
+ validateExtensibleNode(page, pagePath, issues);
409
459
  if (!isNonEmptyString(page.id)) {
410
460
  issue(issues, `${pagePath}.id`, "invalid_page_id", "Expected a non-empty page ID.");
411
461
  } else if (pageIds.has(page.id)) {
@@ -697,15 +747,20 @@ function aggregateResponses(schema, submissions) {
697
747
  questions: schema.fields.map((field) => aggregateField(schema, field, submissions))
698
748
  };
699
749
  }
700
- function escapeCsvCell(value) {
750
+ function escapeCsvCell(value, neutralizeFormulas = true) {
701
751
  if (value === null || value === void 0) return "";
702
- const stringValue = String(value);
752
+ let stringValue = String(value);
753
+ if (neutralizeFormulas && typeof value === "string") {
754
+ const trimmed = stringValue.trimStart();
755
+ if (trimmed.length > 0 && ["=", "+", "-", "@"].includes(trimmed[0] ?? "")) {
756
+ stringValue = `'${stringValue}`;
757
+ }
758
+ }
703
759
  return /[",\r\n]/.test(stringValue) ? `"${stringValue.replaceAll('"', '""')}"` : stringValue;
704
760
  }
705
761
  function serializeValue(value) {
706
762
  if (value === void 0) return "";
707
- if (Array.isArray(value)) return JSON.stringify(value);
708
- return String(value);
763
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value);
709
764
  }
710
765
  function exportResponsesToCsv(schema, responses, options = {}) {
711
766
  assertValidFormSchema(schema);
@@ -726,7 +781,8 @@ function exportResponsesToCsv(schema, responses, options = {}) {
726
781
  ];
727
782
  })
728
783
  ];
729
- const csv = rows.map((row) => row.map((cell) => escapeCsvCell(cell)).join(",")).join("\r\n");
784
+ const neutralizeFormulas = options.neutralizeFormulas ?? true;
785
+ const csv = rows.map((row) => row.map((cell) => escapeCsvCell(cell, neutralizeFormulas)).join(",")).join("\r\n");
730
786
  return options.withBom ?? true ? `\uFEFF${csv}` : csv;
731
787
  }
732
788
 
@@ -936,69 +992,125 @@ function createSubmission(schema, values, options) {
936
992
  formVersion: schema.version,
937
993
  locale: options.locale,
938
994
  values: Object.freeze(cloneValues(visibleValues)),
939
- submittedAt: options.submittedAt
995
+ submittedAt: options.submittedAt,
996
+ ...options.metadata === void 0 ? {} : { metadata: Object.freeze({ ...options.metadata }) },
997
+ ...options.translationMetadata === void 0 ? {} : { translationMetadata: Object.freeze({ ...options.translationMetadata }) }
940
998
  });
941
999
  }
942
1000
 
943
1001
  // src/translation.ts
944
- function mergeLocalizedText(translations, locale, key, value) {
945
- return { ...translations, [locale]: { ...translations?.[locale], [key]: value } };
1002
+ function mergeLocalizedText(translations, locale, property, value) {
1003
+ return { ...translations, [locale]: { ...translations?.[locale], [property]: value } };
946
1004
  }
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
- })
1005
+ function withTranslationMetadata(node, locale, property, metadata) {
1006
+ if (metadata === void 0) return node;
1007
+ return {
1008
+ ...node,
1009
+ translationMetadata: {
1010
+ ...node.translationMetadata,
1011
+ [locale]: {
1012
+ ...node.translationMetadata?.[locale],
1013
+ [property]: metadata
1014
+ }
955
1015
  }
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
- })
1016
+ };
1017
+ }
1018
+ function createSlot(kind, nodeId, property, locale, sourceText, existingText, metadata) {
1019
+ return {
1020
+ kind,
1021
+ nodeId,
1022
+ property,
1023
+ locale,
1024
+ sourceText,
1025
+ ...existingText === void 0 ? {} : { existingText },
1026
+ ...metadata === void 0 ? {} : { metadata }
1027
+ };
1028
+ }
1029
+ function translationSlots(schema, locale) {
1030
+ const descriptors = [];
1031
+ const addFormSlot = (property, sourceText) => {
1032
+ const slot = createSlot(
1033
+ "form",
1034
+ schema.id,
1035
+ property,
1036
+ locale,
1037
+ sourceText,
1038
+ schema.translations?.[locale]?.[property],
1039
+ schema.metadata
1040
+ );
1041
+ descriptors.push({
1042
+ slot,
1043
+ apply: (current, value, metadata) => withTranslationMetadata(
1044
+ { ...current, translations: mergeLocalizedText(current.translations, locale, property, value) },
1045
+ locale,
1046
+ property,
1047
+ metadata
1048
+ )
964
1049
  });
965
- }
1050
+ };
1051
+ addFormSlot("title", schema.title);
1052
+ if (schema.description !== void 0) addFormSlot("description", schema.description);
1053
+ if (schema.completionMessage !== void 0) addFormSlot("completionMessage", schema.completionMessage);
966
1054
  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) => ({
1055
+ const addFieldSlot = (property, sourceText) => {
1056
+ const slot = createSlot(
1057
+ "field",
1058
+ field.id,
1059
+ property,
1060
+ locale,
1061
+ sourceText,
1062
+ field.translations?.[locale]?.[property],
1063
+ field.metadata
1064
+ );
1065
+ descriptors.push({
1066
+ slot,
1067
+ apply: (current, value, metadata) => ({
980
1068
  ...current,
981
1069
  fields: current.fields.map(
982
- (item, index) => index === fieldIndex ? {
983
- ...item,
984
- translations: mergeLocalizedText(item.translations, locale, "description", value)
985
- } : item
1070
+ (candidate, index) => index === fieldIndex ? withTranslationMetadata(
1071
+ {
1072
+ ...candidate,
1073
+ translations: mergeLocalizedText(candidate.translations, locale, property, value)
1074
+ },
1075
+ locale,
1076
+ property,
1077
+ metadata
1078
+ ) : candidate
986
1079
  )
987
1080
  })
988
1081
  });
989
- }
1082
+ };
1083
+ addFieldSlot("title", field.title);
1084
+ if (field.description !== void 0) addFieldSlot("description", field.description);
990
1085
  if ("options" in field) {
991
1086
  field.options.forEach((option, optionIndex) => {
992
- slots.push({
993
- text: option.label,
994
- apply: (current, value, locale) => ({
1087
+ const slot = createSlot(
1088
+ "option",
1089
+ option.id,
1090
+ "label",
1091
+ locale,
1092
+ option.label,
1093
+ option.translations?.[locale],
1094
+ option.metadata
1095
+ );
1096
+ descriptors.push({
1097
+ slot,
1098
+ apply: (current, value, metadata) => ({
995
1099
  ...current,
996
- fields: current.fields.map((item, index) => {
997
- if (index !== fieldIndex || !("options" in item)) return item;
1100
+ fields: current.fields.map((candidate, candidateIndex) => {
1101
+ if (candidateIndex !== fieldIndex || !("options" in candidate)) return candidate;
998
1102
  return {
999
- ...item,
1000
- options: item.options.map(
1001
- (candidate, candidateIndex) => candidateIndex === optionIndex ? { ...candidate, translations: { ...candidate.translations, [locale]: value } } : candidate
1103
+ ...candidate,
1104
+ options: candidate.options.map(
1105
+ (candidateOption, candidateOptionIndex) => candidateOptionIndex === optionIndex ? withTranslationMetadata(
1106
+ {
1107
+ ...candidateOption,
1108
+ translations: { ...candidateOption.translations, [locale]: value }
1109
+ },
1110
+ locale,
1111
+ "label",
1112
+ metadata
1113
+ ) : candidateOption
1002
1114
  )
1003
1115
  };
1004
1116
  })
@@ -1008,42 +1120,50 @@ function translationSlots(schema) {
1008
1120
  }
1009
1121
  });
1010
1122
  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) => ({
1123
+ const addPageSlot = (property, sourceText) => {
1124
+ const slot = createSlot(
1125
+ "page",
1126
+ page.id,
1127
+ property,
1128
+ locale,
1129
+ sourceText,
1130
+ page.translations?.[locale]?.[property],
1131
+ page.metadata
1132
+ );
1133
+ descriptors.push({
1134
+ slot,
1135
+ apply: (current, value, metadata) => ({
1028
1136
  ...current,
1029
1137
  ...current.pages === void 0 ? {} : {
1030
1138
  pages: current.pages.map(
1031
- (item, index) => index === pageIndex ? { ...item, translations: mergeLocalizedText(item.translations, locale, "description", value) } : item
1139
+ (candidate, index) => index === pageIndex ? withTranslationMetadata(
1140
+ {
1141
+ ...candidate,
1142
+ translations: mergeLocalizedText(candidate.translations, locale, property, value)
1143
+ },
1144
+ locale,
1145
+ property,
1146
+ metadata
1147
+ ) : candidate
1032
1148
  )
1033
1149
  }
1034
1150
  })
1035
1151
  });
1036
- }
1152
+ };
1153
+ if (page.title !== void 0) addPageSlot("title", page.title);
1154
+ if (page.description !== void 0) addPageSlot("description", page.description);
1037
1155
  });
1038
- return slots;
1156
+ return descriptors;
1039
1157
  }
1040
1158
  function resolveLocalizedSchema(schema, targetLocale) {
1041
1159
  if (targetLocale.length === 0 || targetLocale === schema.defaultLocale) return schema;
1042
1160
  const formTranslation = schema.translations?.[targetLocale];
1161
+ const completionMessage = formTranslation?.completionMessage ?? schema.completionMessage;
1043
1162
  return {
1044
1163
  ...schema,
1045
1164
  title: formTranslation?.title ?? schema.title,
1046
1165
  ...(formTranslation?.description ?? schema.description) === void 0 ? {} : { description: formTranslation?.description ?? schema.description },
1166
+ ...completionMessage === void 0 ? {} : { completionMessage },
1047
1167
  fields: schema.fields.map((field) => {
1048
1168
  const translation = field.translations?.[targetLocale];
1049
1169
  const localized = {
@@ -1074,24 +1194,35 @@ function resolveLocalizedSchema(schema, targetLocale) {
1074
1194
  }
1075
1195
  };
1076
1196
  }
1077
- async function populateSchemaTranslations(schema, targetLocales, adapter) {
1197
+ async function populateSchemaTranslations(schema, targetLocales, adapter, options = {}) {
1078
1198
  assertValidFormSchema(schema);
1079
- const slots = translationSlots(schema);
1080
1199
  const locales = [...new Set(targetLocales.filter((locale) => locale.length > 0 && locale !== schema.defaultLocale))];
1200
+ const updatedSlots = [];
1201
+ const skippedSlots = [];
1081
1202
  let result = schema;
1082
1203
  for (const locale of locales) {
1204
+ const descriptors = translationSlots(schema, locale);
1205
+ const selected = [];
1206
+ for (const descriptor of descriptors) {
1207
+ const shouldTranslate = options.shouldOverwrite?.(descriptor.slot) ?? (options.overwrite === "all" || descriptor.slot.existingText === void 0);
1208
+ if (shouldTranslate) selected.push(descriptor);
1209
+ else skippedSlots.push(descriptor.slot);
1210
+ }
1211
+ if (selected.length === 0) continue;
1083
1212
  const translated = await adapter.translateBatch(
1084
- slots.map((slot) => slot.text),
1213
+ selected.map((descriptor) => descriptor.slot.sourceText),
1085
1214
  locale,
1086
1215
  schema.defaultLocale
1087
1216
  );
1088
- if (translated.length !== slots.length) {
1089
- throw new Error(`Translation adapter returned ${translated.length} texts for ${slots.length} inputs.`);
1217
+ if (translated.length !== selected.length) {
1218
+ throw new Error(`Translation adapter returned ${translated.length} texts for ${selected.length} inputs.`);
1090
1219
  }
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);
1220
+ selected.forEach((descriptor, index) => {
1221
+ const translatedText = translated[index];
1222
+ if (translatedText === void 0) throw new Error("Translation adapter returned an unexpected result.");
1223
+ const metadata = options.createMetadata?.(descriptor.slot, translatedText);
1224
+ result = descriptor.apply(result, translatedText, metadata);
1225
+ updatedSlots.push(descriptor.slot);
1095
1226
  });
1096
1227
  }
1097
1228
  const supportedLocales = [
@@ -1101,17 +1232,18 @@ async function populateSchemaTranslations(schema, targetLocales, adapter) {
1101
1232
  ...locales
1102
1233
  ])
1103
1234
  ];
1104
- result = supportedLocales.length === 0 ? result : { ...result, supportedLocales };
1235
+ if (supportedLocales.length > 0) result = { ...result, supportedLocales };
1105
1236
  assertValidFormSchema(result);
1106
- return result;
1237
+ return { schema: result, report: { updatedSlots, skippedSlots } };
1107
1238
  }
1108
1239
  async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocale) {
1109
1240
  const populated = await populateSchemaTranslations(
1110
1241
  sourceLocale === void 0 ? schema : { ...schema, defaultLocale: sourceLocale },
1111
1242
  [targetLocale],
1112
- adapter
1243
+ adapter,
1244
+ { overwrite: "all" }
1113
1245
  );
1114
- return resolveLocalizedSchema(populated, targetLocale);
1246
+ return resolveLocalizedSchema(populated.schema, targetLocale);
1115
1247
  }
1116
1248
  export {
1117
1249
  aggregateResponses,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/core",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },