@form-engine-ts/core 2.0.0 → 2.1.1

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
@@ -38,11 +38,20 @@ Forms, pages, fields, options, and submissions accept JSON-only `metadata` and p
38
38
  `translationMetadata`. These extension values survive sanitization, localization, submission creation, and storage
39
39
  round-trips. `completionMessage` is localized with the rest of the form text.
40
40
 
41
+ `transformFieldType` changes a question's type without discarding source text, translations, conditions, or extension
42
+ metadata. `validateFormSchema(schema, { policy })` applies the framework-independent `FormPolicy`, including field,
43
+ option, text, serialized-byte, allowed-type, and required-locale constraints. Required locales cover every source text
44
+ that exists on the form, its fields, options, and pages.
45
+
46
+ Translation callbacks receive `nodeMetadata` and `existingTranslationMetadata` separately. The deprecated `metadata`
47
+ slot property remains an alias for `nodeMetadata` during migration.
48
+
41
49
  `calculateCrossTabulation` builds a two-question frequency matrix from submissions. `dispatchWebhook` posts typed
42
50
  `response.submitted` or `schema.updated` events with timeout handling, custom headers, and optional HMAC-SHA256 signing.
43
51
 
44
52
  CSV export neutralizes string cells whose first non-whitespace character is `=`, `+`, `-`, or `@`. This is enabled by
45
53
  default; trusted callers can pass `{ neutralizeFormulas: false }`. RFC 4180 quoting and the UTF-8 BOM remain unchanged.
54
+ The columns are exactly `submissionId`, `submittedAt`, `locale`, followed by one column per field in schema order.
46
55
 
47
56
  Storage adapters share inclusive ISO 8601 submission-range filtering:
48
57
 
package/dist/index.cjs CHANGED
@@ -38,6 +38,7 @@ __export(index_exports, {
38
38
  resolveLocalizedSchema: () => resolveLocalizedSchema,
39
39
  sanitizeSchema: () => sanitizeSchema,
40
40
  selectVisibleAnswers: () => selectVisibleAnswers,
41
+ transformFieldType: () => transformFieldType,
41
42
  validateAnswers: () => validateAnswers,
42
43
  validateFormSchema: () => validateFormSchema,
43
44
  validatePageAnswers: () => validatePageAnswers,
@@ -420,7 +421,151 @@ function validateField(value, path, issues) {
420
421
  }
421
422
  return true;
422
423
  }
423
- function validateFormSchema(input) {
424
+ function collectSchemaText(schema) {
425
+ const entries = [{ path: "title", value: schema.title }];
426
+ if (schema.description !== void 0) entries.push({ path: "description", value: schema.description });
427
+ if (schema.completionMessage !== void 0)
428
+ entries.push({ path: "completionMessage", value: schema.completionMessage });
429
+ for (const [locale, translation] of Object.entries(schema.translations ?? {})) {
430
+ for (const property of ["title", "description", "completionMessage"]) {
431
+ const value = translation[property];
432
+ if (value !== void 0) entries.push({ path: `translations.${locale}.${property}`, value });
433
+ }
434
+ }
435
+ schema.fields.forEach((field, fieldIndex) => {
436
+ entries.push({ path: `fields[${fieldIndex}].title`, value: field.title });
437
+ if (field.description !== void 0)
438
+ entries.push({ path: `fields[${fieldIndex}].description`, value: field.description });
439
+ for (const [locale, translation] of Object.entries(field.translations ?? {})) {
440
+ for (const property of ["title", "description"]) {
441
+ const value = translation[property];
442
+ if (value !== void 0)
443
+ entries.push({ path: `fields[${fieldIndex}].translations.${locale}.${property}`, value });
444
+ }
445
+ }
446
+ if (!("options" in field)) return;
447
+ field.options.forEach((option, optionIndex) => {
448
+ entries.push({ path: `fields[${fieldIndex}].options[${optionIndex}].label`, value: option.label });
449
+ for (const [locale, value] of Object.entries(option.translations ?? {})) {
450
+ entries.push({ path: `fields[${fieldIndex}].options[${optionIndex}].translations.${locale}`, value });
451
+ }
452
+ });
453
+ });
454
+ schema.pages?.forEach((page, pageIndex) => {
455
+ if (page.title !== void 0) entries.push({ path: `pages[${pageIndex}].title`, value: page.title });
456
+ if (page.description !== void 0)
457
+ entries.push({ path: `pages[${pageIndex}].description`, value: page.description });
458
+ for (const [locale, translation] of Object.entries(page.translations ?? {})) {
459
+ for (const property of ["title", "description"]) {
460
+ const value = translation[property];
461
+ if (value !== void 0)
462
+ entries.push({ path: `pages[${pageIndex}].translations.${locale}.${property}`, value });
463
+ }
464
+ }
465
+ });
466
+ return entries;
467
+ }
468
+ function addRequiredTranslationIssues(schema, locale, issues) {
469
+ if (!(schema.supportedLocales ?? []).includes(locale)) {
470
+ issue(issues, "supportedLocales", "required_locale_missing", `Required locale ${locale} is missing.`);
471
+ }
472
+ if (locale === schema.defaultLocale) return;
473
+ const required = [
474
+ { path: `translations.${locale}.title`, value: schema.translations?.[locale]?.title }
475
+ ];
476
+ if (schema.description !== void 0)
477
+ required.push({ path: `translations.${locale}.description`, value: schema.translations?.[locale]?.description });
478
+ if (schema.completionMessage !== void 0) {
479
+ required.push({
480
+ path: `translations.${locale}.completionMessage`,
481
+ value: schema.translations?.[locale]?.completionMessage
482
+ });
483
+ }
484
+ schema.fields.forEach((field, fieldIndex) => {
485
+ required.push({
486
+ path: `fields[${fieldIndex}].translations.${locale}.title`,
487
+ value: field.translations?.[locale]?.title
488
+ });
489
+ if (field.description !== void 0) {
490
+ required.push({
491
+ path: `fields[${fieldIndex}].translations.${locale}.description`,
492
+ value: field.translations?.[locale]?.description
493
+ });
494
+ }
495
+ if (!("options" in field)) return;
496
+ field.options.forEach((option, optionIndex) => {
497
+ required.push({
498
+ path: `fields[${fieldIndex}].options[${optionIndex}].translations.${locale}`,
499
+ value: option.translations?.[locale]
500
+ });
501
+ });
502
+ });
503
+ schema.pages?.forEach((page, pageIndex) => {
504
+ if (page.title !== void 0) {
505
+ required.push({
506
+ path: `pages[${pageIndex}].translations.${locale}.title`,
507
+ value: page.translations?.[locale]?.title
508
+ });
509
+ }
510
+ if (page.description !== void 0) {
511
+ required.push({
512
+ path: `pages[${pageIndex}].translations.${locale}.description`,
513
+ value: page.translations?.[locale]?.description
514
+ });
515
+ }
516
+ });
517
+ for (const translation of required) {
518
+ if (translation.value === void 0 || translation.value.trim().length === 0) {
519
+ issue(
520
+ issues,
521
+ translation.path,
522
+ "required_translation_missing",
523
+ `A translation for required locale ${locale} is missing.`
524
+ );
525
+ }
526
+ }
527
+ }
528
+ function validatePolicy(schema, policy, issues) {
529
+ if (policy.maxFields !== void 0 && schema.fields.length > policy.maxFields) {
530
+ issue(issues, "fields", "max_fields_exceeded", `At most ${policy.maxFields} fields are allowed.`);
531
+ }
532
+ schema.fields.forEach((field, fieldIndex) => {
533
+ if (policy.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(field.type)) {
534
+ issue(issues, `fields[${fieldIndex}].type`, "disallowed_field_type", `Field type ${field.type} is not allowed.`);
535
+ }
536
+ if (policy.maxOptionsPerField !== void 0 && "options" in field && field.options.length > policy.maxOptionsPerField) {
537
+ issue(
538
+ issues,
539
+ `fields[${fieldIndex}].options`,
540
+ "max_options_exceeded",
541
+ `At most ${policy.maxOptionsPerField} options are allowed.`
542
+ );
543
+ }
544
+ });
545
+ if (policy.maxTextLength !== void 0) {
546
+ for (const entry of collectSchemaText(schema)) {
547
+ if (entry.value.length > policy.maxTextLength) {
548
+ issue(
549
+ issues,
550
+ entry.path,
551
+ "max_text_length_exceeded",
552
+ `Text must be at most ${policy.maxTextLength} characters.`
553
+ );
554
+ }
555
+ }
556
+ }
557
+ for (const locale of policy.requiredLocales ?? []) addRequiredTranslationIssues(schema, locale, issues);
558
+ if (policy.maxSchemaBytes !== void 0) {
559
+ try {
560
+ const byteLength = new TextEncoder().encode(JSON.stringify(schema)).byteLength;
561
+ if (byteLength > policy.maxSchemaBytes) {
562
+ issue(issues, "$", "max_schema_bytes_exceeded", `Schema must be at most ${policy.maxSchemaBytes} bytes.`);
563
+ }
564
+ } catch {
565
+ }
566
+ }
567
+ }
568
+ function validateFormSchema(input, options = {}) {
424
569
  const issues = [];
425
570
  if (!isRecord(input)) {
426
571
  return { valid: false, issues: [{ path: "$", code: "invalid_schema", message: "Expected a schema object." }] };
@@ -566,6 +711,14 @@ function validateFormSchema(input) {
566
711
  }
567
712
  }
568
713
  }
714
+ if (Array.isArray(input.fields)) {
715
+ const policyIssues = [];
716
+ try {
717
+ validatePolicy(input, options.policy ?? {}, policyIssues);
718
+ issues.push(...policyIssues);
719
+ } catch {
720
+ }
721
+ }
569
722
  return issues.length === 0 ? { valid: true, value: input, issues: [] } : { valid: false, issues };
570
723
  }
571
724
  function assertValidFormSchema(input) {
@@ -874,6 +1027,58 @@ async function dispatchWebhook(event, config, fetchImpl = globalThis.fetch) {
874
1027
  }
875
1028
  }
876
1029
 
1030
+ // src/field.ts
1031
+ var DEFAULT_OPTION = { id: "option-1", label: "Option 1" };
1032
+ function transformFieldType(field, nextType) {
1033
+ const common = {
1034
+ id: field.id,
1035
+ title: field.title,
1036
+ required: field.required,
1037
+ ...field.description === void 0 ? {} : { description: field.description },
1038
+ ...field.translationKey === void 0 ? {} : { translationKey: field.translationKey },
1039
+ ...field.messages === void 0 ? {} : { messages: field.messages },
1040
+ ...field.displayCondition === void 0 ? {} : { displayCondition: field.displayCondition },
1041
+ ...field.translations === void 0 ? {} : { translations: field.translations },
1042
+ ...field.metadata === void 0 ? {} : { metadata: field.metadata },
1043
+ ...field.translationMetadata === void 0 ? {} : { translationMetadata: field.translationMetadata }
1044
+ };
1045
+ if (nextType === "text" || nextType === "textarea") {
1046
+ const textProperties = field.type === "text" || field.type === "textarea" ? {
1047
+ ...field.placeholderKey === void 0 ? {} : { placeholderKey: field.placeholderKey },
1048
+ ...field.minLength === void 0 ? {} : { minLength: field.minLength },
1049
+ ...field.maxLength === void 0 ? {} : { maxLength: field.maxLength },
1050
+ ...field.pattern === void 0 ? {} : { pattern: field.pattern }
1051
+ } : {};
1052
+ return { ...common, ...textProperties, type: nextType };
1053
+ }
1054
+ if (nextType === "number") {
1055
+ const numberProperties = field.type === "number" ? {
1056
+ ...field.placeholderKey === void 0 ? {} : { placeholderKey: field.placeholderKey },
1057
+ ...field.min === void 0 ? {} : { min: field.min },
1058
+ ...field.max === void 0 ? {} : { max: field.max },
1059
+ ...field.step === void 0 ? {} : { step: field.step }
1060
+ } : {};
1061
+ return { ...common, ...numberProperties, type: nextType };
1062
+ }
1063
+ if (nextType === "rating") {
1064
+ const ratingProperties = field.type === "rating" ? {
1065
+ ...field.min === void 0 ? {} : { min: field.min },
1066
+ ...field.max === void 0 ? {} : { max: field.max }
1067
+ } : { min: 1, max: 5 };
1068
+ return { ...common, ...ratingProperties, type: nextType };
1069
+ }
1070
+ if (nextType === "checkbox") return { ...common, type: nextType };
1071
+ const options = "options" in field && field.options.length > 0 ? field.options : [DEFAULT_OPTION];
1072
+ if (nextType === "multi-select") {
1073
+ const selectionProperties = field.type === "multi-select" ? {
1074
+ ...field.minSelections === void 0 ? {} : { minSelections: field.minSelections },
1075
+ ...field.maxSelections === void 0 ? {} : { maxSelections: field.maxSelections }
1076
+ } : {};
1077
+ return { ...common, ...selectionProperties, type: nextType, options };
1078
+ }
1079
+ return { ...common, type: nextType, options };
1080
+ }
1081
+
877
1082
  // src/validation.ts
878
1083
  var DEFAULT_MESSAGES = {
879
1084
  required: "validation.required",
@@ -1062,7 +1267,7 @@ function withTranslationMetadata(node, locale, property, metadata) {
1062
1267
  }
1063
1268
  };
1064
1269
  }
1065
- function createSlot(kind, nodeId, property, locale, sourceText, existingText, metadata) {
1270
+ function createSlot(kind, nodeId, property, locale, sourceText, existingText, nodeMetadata, existingTranslationMetadata) {
1066
1271
  return {
1067
1272
  kind,
1068
1273
  nodeId,
@@ -1070,7 +1275,8 @@ function createSlot(kind, nodeId, property, locale, sourceText, existingText, me
1070
1275
  locale,
1071
1276
  sourceText,
1072
1277
  ...existingText === void 0 ? {} : { existingText },
1073
- ...metadata === void 0 ? {} : { metadata }
1278
+ ...nodeMetadata === void 0 ? {} : { nodeMetadata, metadata: nodeMetadata },
1279
+ ...existingTranslationMetadata === void 0 ? {} : { existingTranslationMetadata }
1074
1280
  };
1075
1281
  }
1076
1282
  function translationSlots(schema, locale) {
@@ -1083,7 +1289,8 @@ function translationSlots(schema, locale) {
1083
1289
  locale,
1084
1290
  sourceText,
1085
1291
  schema.translations?.[locale]?.[property],
1086
- schema.metadata
1292
+ schema.metadata,
1293
+ schema.translationMetadata?.[locale]?.[property]
1087
1294
  );
1088
1295
  descriptors.push({
1089
1296
  slot,
@@ -1107,7 +1314,8 @@ function translationSlots(schema, locale) {
1107
1314
  locale,
1108
1315
  sourceText,
1109
1316
  field.translations?.[locale]?.[property],
1110
- field.metadata
1317
+ field.metadata,
1318
+ field.translationMetadata?.[locale]?.[property]
1111
1319
  );
1112
1320
  descriptors.push({
1113
1321
  slot,
@@ -1138,7 +1346,8 @@ function translationSlots(schema, locale) {
1138
1346
  locale,
1139
1347
  option.label,
1140
1348
  option.translations?.[locale],
1141
- option.metadata
1349
+ option.metadata,
1350
+ option.translationMetadata?.[locale]?.label
1142
1351
  );
1143
1352
  descriptors.push({
1144
1353
  slot,
@@ -1175,7 +1384,8 @@ function translationSlots(schema, locale) {
1175
1384
  locale,
1176
1385
  sourceText,
1177
1386
  page.translations?.[locale]?.[property],
1178
- page.metadata
1387
+ page.metadata,
1388
+ page.translationMetadata?.[locale]?.[property]
1179
1389
  );
1180
1390
  descriptors.push({
1181
1391
  slot,
@@ -1312,6 +1522,7 @@ async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocal
1312
1522
  resolveLocalizedSchema,
1313
1523
  sanitizeSchema,
1314
1524
  selectVisibleAnswers,
1525
+ transformFieldType,
1315
1526
  validateAnswers,
1316
1527
  validateFormSchema,
1317
1528
  validatePageAnswers,
package/dist/index.d.cts CHANGED
@@ -1,4 +1,12 @@
1
1
  type FieldType = "text" | "textarea" | "number" | "rating" | "select" | "multi-select" | "checkbox" | "radio";
2
+ interface FormPolicy {
3
+ readonly allowedFieldTypes?: readonly FieldType[];
4
+ readonly maxFields?: number;
5
+ readonly maxOptionsPerField?: number;
6
+ readonly requiredLocales?: readonly string[];
7
+ readonly maxTextLength?: number;
8
+ readonly maxSchemaBytes?: number;
9
+ }
2
10
  type ConditionOperator = "equals" | "not_equals" | "contains" | "not_empty";
3
11
  type ConditionValue = string | number | boolean;
4
12
  type JsonValue = string | number | boolean | null | readonly JsonValue[] | {
@@ -252,6 +260,12 @@ interface WebhookDispatchResult {
252
260
  }
253
261
  declare function dispatchWebhook<T>(event: FormEvent<T>, config: WebhookConfig, fetchImpl?: typeof fetch): Promise<WebhookDispatchResult>;
254
262
 
263
+ /**
264
+ * Changes only the type-specific shape of a field. Authoring content and extension
265
+ * data are deliberately retained so UI adapters cannot accidentally discard them.
266
+ */
267
+ declare function transformFieldType(field: FormField, nextType: QuestionType): FormField;
268
+
255
269
  type SchemaStructureIssueType = "dangling_condition_reference" | "duplicate_question_id" | "duplicate_choice_id" | "self_condition_reference" | "cyclic_condition_reference";
256
270
  interface SchemaStructureIssue {
257
271
  readonly type: SchemaStructureIssueType;
@@ -262,7 +276,10 @@ interface SchemaStructureIssue {
262
276
  declare function validateSchemaStructure(schema: FormSchema): SchemaStructureIssue[];
263
277
  declare function sanitizeSchema(schema: FormSchema): FormSchema;
264
278
 
265
- declare function validateFormSchema(input: unknown): SchemaValidationResult;
279
+ interface ValidateFormSchemaOptions {
280
+ readonly policy?: FormPolicy;
281
+ }
282
+ declare function validateFormSchema(input: unknown, options?: ValidateFormSchemaOptions): SchemaValidationResult;
266
283
  declare function assertValidFormSchema(input: unknown): asserts input is FormSchema;
267
284
 
268
285
  interface CreateSubmissionOptions extends ExtensibleNode {
@@ -279,6 +296,9 @@ interface TranslationSlot {
279
296
  readonly locale: string;
280
297
  readonly sourceText: string;
281
298
  readonly existingText?: string;
299
+ readonly nodeMetadata?: Readonly<Record<string, JsonValue>>;
300
+ readonly existingTranslationMetadata?: Readonly<Record<string, JsonValue>>;
301
+ /** @deprecated Use nodeMetadata instead. */
282
302
  readonly metadata?: Readonly<Record<string, JsonValue>>;
283
303
  }
284
304
  interface PopulateTranslationOptions {
@@ -306,4 +326,4 @@ declare function calculatePageVisibility(schema: FormSchema, currentAnswers: Rea
306
326
  declare function calculateFieldVisibility(schema: FormSchema, currentAnswers: Readonly<Record<string, unknown>>): Readonly<Record<string, boolean>>;
307
327
  declare function selectVisibleAnswers(schema: FormSchema, currentAnswers: FormValues): FormValues;
308
328
 
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 };
329
+ 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 FormPolicy, 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 ValidateFormSchemaOptions, 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, transformFieldType, validateAnswers, validateFormSchema, validatePageAnswers, validateSchemaStructure };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,12 @@
1
1
  type FieldType = "text" | "textarea" | "number" | "rating" | "select" | "multi-select" | "checkbox" | "radio";
2
+ interface FormPolicy {
3
+ readonly allowedFieldTypes?: readonly FieldType[];
4
+ readonly maxFields?: number;
5
+ readonly maxOptionsPerField?: number;
6
+ readonly requiredLocales?: readonly string[];
7
+ readonly maxTextLength?: number;
8
+ readonly maxSchemaBytes?: number;
9
+ }
2
10
  type ConditionOperator = "equals" | "not_equals" | "contains" | "not_empty";
3
11
  type ConditionValue = string | number | boolean;
4
12
  type JsonValue = string | number | boolean | null | readonly JsonValue[] | {
@@ -252,6 +260,12 @@ interface WebhookDispatchResult {
252
260
  }
253
261
  declare function dispatchWebhook<T>(event: FormEvent<T>, config: WebhookConfig, fetchImpl?: typeof fetch): Promise<WebhookDispatchResult>;
254
262
 
263
+ /**
264
+ * Changes only the type-specific shape of a field. Authoring content and extension
265
+ * data are deliberately retained so UI adapters cannot accidentally discard them.
266
+ */
267
+ declare function transformFieldType(field: FormField, nextType: QuestionType): FormField;
268
+
255
269
  type SchemaStructureIssueType = "dangling_condition_reference" | "duplicate_question_id" | "duplicate_choice_id" | "self_condition_reference" | "cyclic_condition_reference";
256
270
  interface SchemaStructureIssue {
257
271
  readonly type: SchemaStructureIssueType;
@@ -262,7 +276,10 @@ interface SchemaStructureIssue {
262
276
  declare function validateSchemaStructure(schema: FormSchema): SchemaStructureIssue[];
263
277
  declare function sanitizeSchema(schema: FormSchema): FormSchema;
264
278
 
265
- declare function validateFormSchema(input: unknown): SchemaValidationResult;
279
+ interface ValidateFormSchemaOptions {
280
+ readonly policy?: FormPolicy;
281
+ }
282
+ declare function validateFormSchema(input: unknown, options?: ValidateFormSchemaOptions): SchemaValidationResult;
266
283
  declare function assertValidFormSchema(input: unknown): asserts input is FormSchema;
267
284
 
268
285
  interface CreateSubmissionOptions extends ExtensibleNode {
@@ -279,6 +296,9 @@ interface TranslationSlot {
279
296
  readonly locale: string;
280
297
  readonly sourceText: string;
281
298
  readonly existingText?: string;
299
+ readonly nodeMetadata?: Readonly<Record<string, JsonValue>>;
300
+ readonly existingTranslationMetadata?: Readonly<Record<string, JsonValue>>;
301
+ /** @deprecated Use nodeMetadata instead. */
282
302
  readonly metadata?: Readonly<Record<string, JsonValue>>;
283
303
  }
284
304
  interface PopulateTranslationOptions {
@@ -306,4 +326,4 @@ declare function calculatePageVisibility(schema: FormSchema, currentAnswers: Rea
306
326
  declare function calculateFieldVisibility(schema: FormSchema, currentAnswers: Readonly<Record<string, unknown>>): Readonly<Record<string, boolean>>;
307
327
  declare function selectVisibleAnswers(schema: FormSchema, currentAnswers: FormValues): FormValues;
308
328
 
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 };
329
+ 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 FormPolicy, 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 ValidateFormSchemaOptions, 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, transformFieldType, validateAnswers, validateFormSchema, validatePageAnswers, validateSchemaStructure };
package/dist/index.js CHANGED
@@ -373,7 +373,151 @@ function validateField(value, path, issues) {
373
373
  }
374
374
  return true;
375
375
  }
376
- 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 = {}) {
377
521
  const issues = [];
378
522
  if (!isRecord(input)) {
379
523
  return { valid: false, issues: [{ path: "$", code: "invalid_schema", message: "Expected a schema object." }] };
@@ -519,6 +663,14 @@ function validateFormSchema(input) {
519
663
  }
520
664
  }
521
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
+ }
522
674
  return issues.length === 0 ? { valid: true, value: input, issues: [] } : { valid: false, issues };
523
675
  }
524
676
  function assertValidFormSchema(input) {
@@ -827,6 +979,58 @@ async function dispatchWebhook(event, config, fetchImpl = globalThis.fetch) {
827
979
  }
828
980
  }
829
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
+
830
1034
  // src/validation.ts
831
1035
  var DEFAULT_MESSAGES = {
832
1036
  required: "validation.required",
@@ -1015,7 +1219,7 @@ function withTranslationMetadata(node, locale, property, metadata) {
1015
1219
  }
1016
1220
  };
1017
1221
  }
1018
- function createSlot(kind, nodeId, property, locale, sourceText, existingText, metadata) {
1222
+ function createSlot(kind, nodeId, property, locale, sourceText, existingText, nodeMetadata, existingTranslationMetadata) {
1019
1223
  return {
1020
1224
  kind,
1021
1225
  nodeId,
@@ -1023,7 +1227,8 @@ function createSlot(kind, nodeId, property, locale, sourceText, existingText, me
1023
1227
  locale,
1024
1228
  sourceText,
1025
1229
  ...existingText === void 0 ? {} : { existingText },
1026
- ...metadata === void 0 ? {} : { metadata }
1230
+ ...nodeMetadata === void 0 ? {} : { nodeMetadata, metadata: nodeMetadata },
1231
+ ...existingTranslationMetadata === void 0 ? {} : { existingTranslationMetadata }
1027
1232
  };
1028
1233
  }
1029
1234
  function translationSlots(schema, locale) {
@@ -1036,7 +1241,8 @@ function translationSlots(schema, locale) {
1036
1241
  locale,
1037
1242
  sourceText,
1038
1243
  schema.translations?.[locale]?.[property],
1039
- schema.metadata
1244
+ schema.metadata,
1245
+ schema.translationMetadata?.[locale]?.[property]
1040
1246
  );
1041
1247
  descriptors.push({
1042
1248
  slot,
@@ -1060,7 +1266,8 @@ function translationSlots(schema, locale) {
1060
1266
  locale,
1061
1267
  sourceText,
1062
1268
  field.translations?.[locale]?.[property],
1063
- field.metadata
1269
+ field.metadata,
1270
+ field.translationMetadata?.[locale]?.[property]
1064
1271
  );
1065
1272
  descriptors.push({
1066
1273
  slot,
@@ -1091,7 +1298,8 @@ function translationSlots(schema, locale) {
1091
1298
  locale,
1092
1299
  option.label,
1093
1300
  option.translations?.[locale],
1094
- option.metadata
1301
+ option.metadata,
1302
+ option.translationMetadata?.[locale]?.label
1095
1303
  );
1096
1304
  descriptors.push({
1097
1305
  slot,
@@ -1128,7 +1336,8 @@ function translationSlots(schema, locale) {
1128
1336
  locale,
1129
1337
  sourceText,
1130
1338
  page.translations?.[locale]?.[property],
1131
- page.metadata
1339
+ page.metadata,
1340
+ page.translationMetadata?.[locale]?.[property]
1132
1341
  );
1133
1342
  descriptors.push({
1134
1343
  slot,
@@ -1264,6 +1473,7 @@ export {
1264
1473
  resolveLocalizedSchema,
1265
1474
  sanitizeSchema,
1266
1475
  selectVisibleAnswers,
1476
+ transformFieldType,
1267
1477
  validateAnswers,
1268
1478
  validateFormSchema,
1269
1479
  validatePageAnswers,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/core",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },