@form-engine-ts/core 2.1.1 → 2.3.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
@@ -40,8 +40,15 @@ round-trips. `completionMessage` is localized with the rest of the form text.
40
40
 
41
41
  `transformFieldType` changes a question's type without discarding source text, translations, conditions, or extension
42
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.
43
+ option, text, serialized-byte, allowed-type, and locale constraints. `allowedLocales` constrains the default and supported
44
+ locales, `maxLocales` limits their unique total, and contradictory required/allowed locale policies are reported.
45
+ Required locales cover every source text that exists on the form, its fields, options, and pages.
46
+
47
+ `collectSchemaLocales(schema)` scans registrations plus every form/page/field/option `translations` and
48
+ `translationMetadata` key. Validation reports unregistered translation locales and applies `allowedLocales` and
49
+ `maxLocales` to the complete collected set. `sanitizeSchema` purges unregistered locale content. Pass
50
+ `{ policy: { allowedLocales, maxLocales } }` to `populateSchemaTranslations` to reject inadmissible targets before the
51
+ translation adapter runs.
45
52
 
46
53
  Translation callbacks receive `nodeMetadata` and `existingTranslationMetadata` separately. The deprecated `metadata`
47
54
  slot property remains an alias for `nodeMetadata` during migration.
package/dist/index.cjs CHANGED
@@ -27,6 +27,7 @@ __export(index_exports, {
27
27
  calculateFieldVisibility: () => calculateFieldVisibility,
28
28
  calculateNumericSummary: () => calculateNumericSummary,
29
29
  calculatePageVisibility: () => calculatePageVisibility,
30
+ collectSchemaLocales: () => collectSchemaLocales,
30
31
  createSubmission: () => createSubmission,
31
32
  dispatchWebhook: () => dispatchWebhook,
32
33
  escapeCsvCell: () => escapeCsvCell,
@@ -46,7 +47,95 @@ __export(index_exports, {
46
47
  });
47
48
  module.exports = __toCommonJS(index_exports);
48
49
 
50
+ // src/policy.ts
51
+ function collectRecordKeys(value, path, pathsByLocale) {
52
+ for (const locale of Object.keys(value ?? {})) {
53
+ const paths = pathsByLocale.get(locale) ?? [];
54
+ paths.push(`${path}.${locale}`);
55
+ pathsByLocale.set(locale, paths);
56
+ }
57
+ }
58
+ function collectSchemaLocales(schema) {
59
+ const pathsByLocale = /* @__PURE__ */ new Map();
60
+ collectRecordKeys(schema.translations, "translations", pathsByLocale);
61
+ collectRecordKeys(schema.translationMetadata, "translationMetadata", pathsByLocale);
62
+ schema.fields.forEach((field, fieldIndex) => {
63
+ collectRecordKeys(field.translations, `fields[${fieldIndex}].translations`, pathsByLocale);
64
+ collectRecordKeys(field.translationMetadata, `fields[${fieldIndex}].translationMetadata`, pathsByLocale);
65
+ if (!("options" in field)) return;
66
+ field.options.forEach((option, optionIndex) => {
67
+ collectRecordKeys(
68
+ option.translations,
69
+ `fields[${fieldIndex}].options[${optionIndex}].translations`,
70
+ pathsByLocale
71
+ );
72
+ collectRecordKeys(
73
+ option.translationMetadata,
74
+ `fields[${fieldIndex}].options[${optionIndex}].translationMetadata`,
75
+ pathsByLocale
76
+ );
77
+ });
78
+ });
79
+ schema.pages?.forEach((page, pageIndex) => {
80
+ collectRecordKeys(page.translations, `pages[${pageIndex}].translations`, pathsByLocale);
81
+ collectRecordKeys(page.translationMetadata, `pages[${pageIndex}].translationMetadata`, pathsByLocale);
82
+ });
83
+ const translationLocales = new Set(pathsByLocale.keys());
84
+ const allUniqueLocales = /* @__PURE__ */ new Set([
85
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
86
+ ...schema.supportedLocales ?? [],
87
+ ...translationLocales
88
+ ]);
89
+ return {
90
+ ...schema.defaultLocale === void 0 ? {} : { defaultLocale: schema.defaultLocale },
91
+ supportedLocales: schema.supportedLocales ?? [],
92
+ translationLocales,
93
+ allUniqueLocales,
94
+ translationLocalePaths: pathsByLocale
95
+ };
96
+ }
97
+
49
98
  // src/sanitization.ts
99
+ function registeredEntries(value, registeredLocales) {
100
+ if (value === void 0) return void 0;
101
+ const entries = Object.entries(value).filter(([locale]) => registeredLocales.has(locale));
102
+ return entries.length === 0 ? void 0 : Object.fromEntries(entries);
103
+ }
104
+ function sanitizeNodeLocales(node, registeredLocales) {
105
+ const { translationMetadata: _translationMetadata, ...base } = node;
106
+ const translationMetadata = registeredEntries(node.translationMetadata, registeredLocales);
107
+ return {
108
+ ...base,
109
+ ...translationMetadata === void 0 ? {} : { translationMetadata }
110
+ };
111
+ }
112
+ function sanitizeOptionLocales(option, registeredLocales) {
113
+ const { translations: _translations, ...base } = sanitizeNodeLocales(option, registeredLocales);
114
+ const translations = registeredEntries(option.translations, registeredLocales);
115
+ return { ...base, ...translations === void 0 ? {} : { translations } };
116
+ }
117
+ function sanitizeFieldLocales(field, registeredLocales) {
118
+ const localizedNode = sanitizeNodeLocales(field, registeredLocales);
119
+ const { translations: _translations, ...base } = localizedNode;
120
+ const translations = registeredEntries(field.translations, registeredLocales);
121
+ const localized = {
122
+ ...base,
123
+ ...translations === void 0 ? {} : { translations }
124
+ };
125
+ if (!("options" in localizedNode)) return localized;
126
+ const { translations: _choiceTranslations, ...choiceBase } = localizedNode;
127
+ return {
128
+ ...choiceBase,
129
+ ...translations === void 0 ? {} : { translations },
130
+ options: localizedNode.options.map((option) => sanitizeOptionLocales(option, registeredLocales))
131
+ };
132
+ }
133
+ function sanitizePageLocales(page, registeredLocales) {
134
+ const localizedNode = sanitizeNodeLocales(page, registeredLocales);
135
+ const { translations: _translations, ...base } = localizedNode;
136
+ const translations = registeredEntries(page.translations, registeredLocales);
137
+ return { ...base, ...translations === void 0 ? {} : { translations } };
138
+ }
50
139
  function cyclicQuestionIds(fields) {
51
140
  const firstById = /* @__PURE__ */ new Map();
52
141
  for (const field of fields) {
@@ -133,8 +222,13 @@ function validateSchemaStructure(schema) {
133
222
  }
134
223
  function sanitizeSchema(schema) {
135
224
  const existingQuestionIds = new Set(schema.fields.map((field) => field.id));
225
+ const registeredLocales = /* @__PURE__ */ new Set([
226
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
227
+ ...schema.supportedLocales ?? []
228
+ ]);
136
229
  const cyclic = cyclicQuestionIds(schema.fields);
137
- const sanitizedFields = schema.fields.map((field) => {
230
+ const sanitizedFields = schema.fields.map((sourceField) => {
231
+ const field = sanitizeFieldLocales(sourceField, registeredLocales);
138
232
  const sourceId = field.displayCondition?.questionId;
139
233
  if (sourceId === void 0 || existingQuestionIds.has(sourceId) && sourceId !== field.id && !cyclic.has(field.id)) {
140
234
  return field;
@@ -142,15 +236,19 @@ function sanitizeSchema(schema) {
142
236
  const { displayCondition: _displayCondition, ...sanitized } = field;
143
237
  return sanitized;
144
238
  });
239
+ const localizedSchema = sanitizeNodeLocales(schema, registeredLocales);
240
+ const { translations: _translations, ...schemaWithoutLocaleContent } = localizedSchema;
241
+ const translations = registeredEntries(schema.translations, registeredLocales);
145
242
  const base = {
146
- ...schema,
243
+ ...schemaWithoutLocaleContent,
244
+ ...translations === void 0 ? {} : { translations },
147
245
  fields: sanitizedFields
148
246
  };
149
247
  if (schema.pages === void 0) return base;
150
248
  const assigned = /* @__PURE__ */ new Set();
151
- const pages = schema.pages.map((page) => ({
152
- ...page,
153
- questionIds: page.questionIds.filter((id) => {
249
+ const pages = schema.pages.map((sourcePage) => ({
250
+ ...sanitizePageLocales(sourcePage, registeredLocales),
251
+ questionIds: sourcePage.questionIds.filter((id) => {
154
252
  if (!existingQuestionIds.has(id) || assigned.has(id)) return false;
155
253
  assigned.add(id);
156
254
  return true;
@@ -554,6 +652,52 @@ function validatePolicy(schema, policy, issues) {
554
652
  }
555
653
  }
556
654
  }
655
+ const collectedLocales = collectSchemaLocales(schema);
656
+ const registeredLocales = /* @__PURE__ */ new Set([
657
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
658
+ ...schema.supportedLocales ?? []
659
+ ]);
660
+ for (const locale of collectedLocales.translationLocales) {
661
+ if (registeredLocales.has(locale)) continue;
662
+ for (const path of collectedLocales.translationLocalePaths.get(locale) ?? []) {
663
+ issue(
664
+ issues,
665
+ path,
666
+ "unregistered_translation_locale",
667
+ `Translation locale ${locale} is not registered by defaultLocale or supportedLocales.`
668
+ );
669
+ }
670
+ }
671
+ if (policy.allowedLocales !== void 0) {
672
+ const pathsByLocale = /* @__PURE__ */ new Map();
673
+ if (schema.defaultLocale !== void 0) pathsByLocale.set(schema.defaultLocale, ["defaultLocale"]);
674
+ schema.supportedLocales?.forEach((locale, index) => {
675
+ pathsByLocale.set(locale, [...pathsByLocale.get(locale) ?? [], `supportedLocales[${index}]`]);
676
+ });
677
+ for (const [locale, paths] of collectedLocales.translationLocalePaths) {
678
+ pathsByLocale.set(locale, [...pathsByLocale.get(locale) ?? [], ...paths]);
679
+ }
680
+ for (const [locale, paths] of pathsByLocale) {
681
+ if (!policy.allowedLocales.includes(locale)) {
682
+ for (const path of paths) {
683
+ issue(issues, path, "disallowed_locale", `Locale ${locale} is not allowed by the form policy.`);
684
+ }
685
+ }
686
+ }
687
+ for (const locale of policy.requiredLocales ?? []) {
688
+ if (!policy.allowedLocales.includes(locale)) {
689
+ issue(
690
+ issues,
691
+ "policy.requiredLocales",
692
+ "required_locale_not_allowed",
693
+ `Required locale ${locale} is not included in allowedLocales.`
694
+ );
695
+ }
696
+ }
697
+ }
698
+ if (policy.maxLocales !== void 0 && collectedLocales.allUniqueLocales.size > policy.maxLocales) {
699
+ issue(issues, "supportedLocales", "max_locales_exceeded", `At most ${policy.maxLocales} locales are allowed.`);
700
+ }
557
701
  for (const locale of policy.requiredLocales ?? []) addRequiredTranslationIssues(schema, locale, issues);
558
702
  if (policy.maxSchemaBytes !== void 0) {
559
703
  try {
@@ -1454,6 +1598,18 @@ function resolveLocalizedSchema(schema, targetLocale) {
1454
1598
  async function populateSchemaTranslations(schema, targetLocales, adapter, options = {}) {
1455
1599
  assertValidFormSchema(schema);
1456
1600
  const locales = [...new Set(targetLocales.filter((locale) => locale.length > 0 && locale !== schema.defaultLocale))];
1601
+ const allowedLocales = options.policy?.allowedLocales;
1602
+ const collectedLocales = collectSchemaLocales(schema);
1603
+ const disallowedLocale = [...collectedLocales.allUniqueLocales, ...locales].find(
1604
+ (locale) => allowedLocales !== void 0 && !allowedLocales.includes(locale)
1605
+ );
1606
+ if (disallowedLocale !== void 0) {
1607
+ throw new RangeError(`Translation locale ${disallowedLocale} is not allowed by the form policy.`);
1608
+ }
1609
+ const projectedLocales = /* @__PURE__ */ new Set([...collectedLocales.allUniqueLocales, ...locales]);
1610
+ if (options.policy?.maxLocales !== void 0 && projectedLocales.size > options.policy.maxLocales) {
1611
+ throw new RangeError(`At most ${options.policy.maxLocales} locales are allowed by the form policy.`);
1612
+ }
1457
1613
  const updatedSlots = [];
1458
1614
  const skippedSlots = [];
1459
1615
  let result = schema;
@@ -1511,6 +1667,7 @@ async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocal
1511
1667
  calculateFieldVisibility,
1512
1668
  calculateNumericSummary,
1513
1669
  calculatePageVisibility,
1670
+ collectSchemaLocales,
1514
1671
  createSubmission,
1515
1672
  dispatchWebhook,
1516
1673
  escapeCsvCell,
package/dist/index.d.cts CHANGED
@@ -4,6 +4,8 @@ interface FormPolicy {
4
4
  readonly maxFields?: number;
5
5
  readonly maxOptionsPerField?: number;
6
6
  readonly requiredLocales?: readonly string[];
7
+ readonly allowedLocales?: readonly string[];
8
+ readonly maxLocales?: number;
7
9
  readonly maxTextLength?: number;
8
10
  readonly maxSchemaBytes?: number;
9
11
  }
@@ -266,6 +268,17 @@ declare function dispatchWebhook<T>(event: FormEvent<T>, config: WebhookConfig,
266
268
  */
267
269
  declare function transformFieldType(field: FormField, nextType: QuestionType): FormField;
268
270
 
271
+ interface CollectedLocales {
272
+ readonly defaultLocale?: string;
273
+ readonly supportedLocales: readonly string[];
274
+ readonly translationLocales: ReadonlySet<string>;
275
+ readonly allUniqueLocales: ReadonlySet<string>;
276
+ /** Every translation or translation-metadata locale and the schema paths where it occurs. */
277
+ readonly translationLocalePaths: ReadonlyMap<string, readonly string[]>;
278
+ }
279
+ /** Collects locale registrations and every locale key used by translations or translation metadata. */
280
+ declare function collectSchemaLocales(schema: FormSchema): CollectedLocales;
281
+
269
282
  type SchemaStructureIssueType = "dangling_condition_reference" | "duplicate_question_id" | "duplicate_choice_id" | "self_condition_reference" | "cyclic_condition_reference";
270
283
  interface SchemaStructureIssue {
271
284
  readonly type: SchemaStructureIssueType;
@@ -305,6 +318,8 @@ interface PopulateTranslationOptions {
305
318
  readonly overwrite?: "missing-only" | "all";
306
319
  readonly shouldOverwrite?: (slot: TranslationSlot) => boolean;
307
320
  readonly createMetadata?: (slot: TranslationSlot, translatedText: string) => Readonly<Record<string, JsonValue>>;
321
+ /** Applies locale admission and count limits before the adapter is called. */
322
+ readonly policy?: Pick<FormPolicy, "allowedLocales" | "maxLocales">;
308
323
  }
309
324
  interface TranslationReport {
310
325
  readonly updatedSlots: readonly TranslationSlot[];
@@ -326,4 +341,4 @@ declare function calculatePageVisibility(schema: FormSchema, currentAnswers: Rea
326
341
  declare function calculateFieldVisibility(schema: FormSchema, currentAnswers: Readonly<Record<string, unknown>>): Readonly<Record<string, boolean>>;
327
342
  declare function selectVisibleAnswers(schema: FormSchema, currentAnswers: FormValues): FormValues;
328
343
 
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 };
344
+ export { type AnswerValidationResult, type AsyncTranslationAdapter, type BaseField, type CheckboxField, type CheckboxQuestionAggregate, type ChoiceDistributionEntry, type ChoiceOption, type ChoiceQuestionAggregate, type CollectedLocales, 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, collectSchemaLocales, createSubmission, dispatchWebhook, escapeCsvCell, exportResponsesToCsv, isDisplayConditionSatisfied, isQuestionVisible, populateSchemaTranslations, resolveFormTranslation, resolveLocalizedSchema, sanitizeSchema, selectVisibleAnswers, transformFieldType, validateAnswers, validateFormSchema, validatePageAnswers, validateSchemaStructure };
package/dist/index.d.ts CHANGED
@@ -4,6 +4,8 @@ interface FormPolicy {
4
4
  readonly maxFields?: number;
5
5
  readonly maxOptionsPerField?: number;
6
6
  readonly requiredLocales?: readonly string[];
7
+ readonly allowedLocales?: readonly string[];
8
+ readonly maxLocales?: number;
7
9
  readonly maxTextLength?: number;
8
10
  readonly maxSchemaBytes?: number;
9
11
  }
@@ -266,6 +268,17 @@ declare function dispatchWebhook<T>(event: FormEvent<T>, config: WebhookConfig,
266
268
  */
267
269
  declare function transformFieldType(field: FormField, nextType: QuestionType): FormField;
268
270
 
271
+ interface CollectedLocales {
272
+ readonly defaultLocale?: string;
273
+ readonly supportedLocales: readonly string[];
274
+ readonly translationLocales: ReadonlySet<string>;
275
+ readonly allUniqueLocales: ReadonlySet<string>;
276
+ /** Every translation or translation-metadata locale and the schema paths where it occurs. */
277
+ readonly translationLocalePaths: ReadonlyMap<string, readonly string[]>;
278
+ }
279
+ /** Collects locale registrations and every locale key used by translations or translation metadata. */
280
+ declare function collectSchemaLocales(schema: FormSchema): CollectedLocales;
281
+
269
282
  type SchemaStructureIssueType = "dangling_condition_reference" | "duplicate_question_id" | "duplicate_choice_id" | "self_condition_reference" | "cyclic_condition_reference";
270
283
  interface SchemaStructureIssue {
271
284
  readonly type: SchemaStructureIssueType;
@@ -305,6 +318,8 @@ interface PopulateTranslationOptions {
305
318
  readonly overwrite?: "missing-only" | "all";
306
319
  readonly shouldOverwrite?: (slot: TranslationSlot) => boolean;
307
320
  readonly createMetadata?: (slot: TranslationSlot, translatedText: string) => Readonly<Record<string, JsonValue>>;
321
+ /** Applies locale admission and count limits before the adapter is called. */
322
+ readonly policy?: Pick<FormPolicy, "allowedLocales" | "maxLocales">;
308
323
  }
309
324
  interface TranslationReport {
310
325
  readonly updatedSlots: readonly TranslationSlot[];
@@ -326,4 +341,4 @@ declare function calculatePageVisibility(schema: FormSchema, currentAnswers: Rea
326
341
  declare function calculateFieldVisibility(schema: FormSchema, currentAnswers: Readonly<Record<string, unknown>>): Readonly<Record<string, boolean>>;
327
342
  declare function selectVisibleAnswers(schema: FormSchema, currentAnswers: FormValues): FormValues;
328
343
 
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 };
344
+ export { type AnswerValidationResult, type AsyncTranslationAdapter, type BaseField, type CheckboxField, type CheckboxQuestionAggregate, type ChoiceDistributionEntry, type ChoiceOption, type ChoiceQuestionAggregate, type CollectedLocales, 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, collectSchemaLocales, createSubmission, dispatchWebhook, escapeCsvCell, exportResponsesToCsv, isDisplayConditionSatisfied, isQuestionVisible, populateSchemaTranslations, resolveFormTranslation, resolveLocalizedSchema, sanitizeSchema, selectVisibleAnswers, transformFieldType, validateAnswers, validateFormSchema, validatePageAnswers, validateSchemaStructure };
package/dist/index.js CHANGED
@@ -1,4 +1,92 @@
1
+ // src/policy.ts
2
+ function collectRecordKeys(value, path, pathsByLocale) {
3
+ for (const locale of Object.keys(value ?? {})) {
4
+ const paths = pathsByLocale.get(locale) ?? [];
5
+ paths.push(`${path}.${locale}`);
6
+ pathsByLocale.set(locale, paths);
7
+ }
8
+ }
9
+ function collectSchemaLocales(schema) {
10
+ const pathsByLocale = /* @__PURE__ */ new Map();
11
+ collectRecordKeys(schema.translations, "translations", pathsByLocale);
12
+ collectRecordKeys(schema.translationMetadata, "translationMetadata", pathsByLocale);
13
+ schema.fields.forEach((field, fieldIndex) => {
14
+ collectRecordKeys(field.translations, `fields[${fieldIndex}].translations`, pathsByLocale);
15
+ collectRecordKeys(field.translationMetadata, `fields[${fieldIndex}].translationMetadata`, pathsByLocale);
16
+ if (!("options" in field)) return;
17
+ field.options.forEach((option, optionIndex) => {
18
+ collectRecordKeys(
19
+ option.translations,
20
+ `fields[${fieldIndex}].options[${optionIndex}].translations`,
21
+ pathsByLocale
22
+ );
23
+ collectRecordKeys(
24
+ option.translationMetadata,
25
+ `fields[${fieldIndex}].options[${optionIndex}].translationMetadata`,
26
+ pathsByLocale
27
+ );
28
+ });
29
+ });
30
+ schema.pages?.forEach((page, pageIndex) => {
31
+ collectRecordKeys(page.translations, `pages[${pageIndex}].translations`, pathsByLocale);
32
+ collectRecordKeys(page.translationMetadata, `pages[${pageIndex}].translationMetadata`, pathsByLocale);
33
+ });
34
+ const translationLocales = new Set(pathsByLocale.keys());
35
+ const allUniqueLocales = /* @__PURE__ */ new Set([
36
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
37
+ ...schema.supportedLocales ?? [],
38
+ ...translationLocales
39
+ ]);
40
+ return {
41
+ ...schema.defaultLocale === void 0 ? {} : { defaultLocale: schema.defaultLocale },
42
+ supportedLocales: schema.supportedLocales ?? [],
43
+ translationLocales,
44
+ allUniqueLocales,
45
+ translationLocalePaths: pathsByLocale
46
+ };
47
+ }
48
+
1
49
  // src/sanitization.ts
50
+ function registeredEntries(value, registeredLocales) {
51
+ if (value === void 0) return void 0;
52
+ const entries = Object.entries(value).filter(([locale]) => registeredLocales.has(locale));
53
+ return entries.length === 0 ? void 0 : Object.fromEntries(entries);
54
+ }
55
+ function sanitizeNodeLocales(node, registeredLocales) {
56
+ const { translationMetadata: _translationMetadata, ...base } = node;
57
+ const translationMetadata = registeredEntries(node.translationMetadata, registeredLocales);
58
+ return {
59
+ ...base,
60
+ ...translationMetadata === void 0 ? {} : { translationMetadata }
61
+ };
62
+ }
63
+ function sanitizeOptionLocales(option, registeredLocales) {
64
+ const { translations: _translations, ...base } = sanitizeNodeLocales(option, registeredLocales);
65
+ const translations = registeredEntries(option.translations, registeredLocales);
66
+ return { ...base, ...translations === void 0 ? {} : { translations } };
67
+ }
68
+ function sanitizeFieldLocales(field, registeredLocales) {
69
+ const localizedNode = sanitizeNodeLocales(field, registeredLocales);
70
+ const { translations: _translations, ...base } = localizedNode;
71
+ const translations = registeredEntries(field.translations, registeredLocales);
72
+ const localized = {
73
+ ...base,
74
+ ...translations === void 0 ? {} : { translations }
75
+ };
76
+ if (!("options" in localizedNode)) return localized;
77
+ const { translations: _choiceTranslations, ...choiceBase } = localizedNode;
78
+ return {
79
+ ...choiceBase,
80
+ ...translations === void 0 ? {} : { translations },
81
+ options: localizedNode.options.map((option) => sanitizeOptionLocales(option, registeredLocales))
82
+ };
83
+ }
84
+ function sanitizePageLocales(page, registeredLocales) {
85
+ const localizedNode = sanitizeNodeLocales(page, registeredLocales);
86
+ const { translations: _translations, ...base } = localizedNode;
87
+ const translations = registeredEntries(page.translations, registeredLocales);
88
+ return { ...base, ...translations === void 0 ? {} : { translations } };
89
+ }
2
90
  function cyclicQuestionIds(fields) {
3
91
  const firstById = /* @__PURE__ */ new Map();
4
92
  for (const field of fields) {
@@ -85,8 +173,13 @@ function validateSchemaStructure(schema) {
85
173
  }
86
174
  function sanitizeSchema(schema) {
87
175
  const existingQuestionIds = new Set(schema.fields.map((field) => field.id));
176
+ const registeredLocales = /* @__PURE__ */ new Set([
177
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
178
+ ...schema.supportedLocales ?? []
179
+ ]);
88
180
  const cyclic = cyclicQuestionIds(schema.fields);
89
- const sanitizedFields = schema.fields.map((field) => {
181
+ const sanitizedFields = schema.fields.map((sourceField) => {
182
+ const field = sanitizeFieldLocales(sourceField, registeredLocales);
90
183
  const sourceId = field.displayCondition?.questionId;
91
184
  if (sourceId === void 0 || existingQuestionIds.has(sourceId) && sourceId !== field.id && !cyclic.has(field.id)) {
92
185
  return field;
@@ -94,15 +187,19 @@ function sanitizeSchema(schema) {
94
187
  const { displayCondition: _displayCondition, ...sanitized } = field;
95
188
  return sanitized;
96
189
  });
190
+ const localizedSchema = sanitizeNodeLocales(schema, registeredLocales);
191
+ const { translations: _translations, ...schemaWithoutLocaleContent } = localizedSchema;
192
+ const translations = registeredEntries(schema.translations, registeredLocales);
97
193
  const base = {
98
- ...schema,
194
+ ...schemaWithoutLocaleContent,
195
+ ...translations === void 0 ? {} : { translations },
99
196
  fields: sanitizedFields
100
197
  };
101
198
  if (schema.pages === void 0) return base;
102
199
  const assigned = /* @__PURE__ */ new Set();
103
- const pages = schema.pages.map((page) => ({
104
- ...page,
105
- questionIds: page.questionIds.filter((id) => {
200
+ const pages = schema.pages.map((sourcePage) => ({
201
+ ...sanitizePageLocales(sourcePage, registeredLocales),
202
+ questionIds: sourcePage.questionIds.filter((id) => {
106
203
  if (!existingQuestionIds.has(id) || assigned.has(id)) return false;
107
204
  assigned.add(id);
108
205
  return true;
@@ -506,6 +603,52 @@ function validatePolicy(schema, policy, issues) {
506
603
  }
507
604
  }
508
605
  }
606
+ const collectedLocales = collectSchemaLocales(schema);
607
+ const registeredLocales = /* @__PURE__ */ new Set([
608
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
609
+ ...schema.supportedLocales ?? []
610
+ ]);
611
+ for (const locale of collectedLocales.translationLocales) {
612
+ if (registeredLocales.has(locale)) continue;
613
+ for (const path of collectedLocales.translationLocalePaths.get(locale) ?? []) {
614
+ issue(
615
+ issues,
616
+ path,
617
+ "unregistered_translation_locale",
618
+ `Translation locale ${locale} is not registered by defaultLocale or supportedLocales.`
619
+ );
620
+ }
621
+ }
622
+ if (policy.allowedLocales !== void 0) {
623
+ const pathsByLocale = /* @__PURE__ */ new Map();
624
+ if (schema.defaultLocale !== void 0) pathsByLocale.set(schema.defaultLocale, ["defaultLocale"]);
625
+ schema.supportedLocales?.forEach((locale, index) => {
626
+ pathsByLocale.set(locale, [...pathsByLocale.get(locale) ?? [], `supportedLocales[${index}]`]);
627
+ });
628
+ for (const [locale, paths] of collectedLocales.translationLocalePaths) {
629
+ pathsByLocale.set(locale, [...pathsByLocale.get(locale) ?? [], ...paths]);
630
+ }
631
+ for (const [locale, paths] of pathsByLocale) {
632
+ if (!policy.allowedLocales.includes(locale)) {
633
+ for (const path of paths) {
634
+ issue(issues, path, "disallowed_locale", `Locale ${locale} is not allowed by the form policy.`);
635
+ }
636
+ }
637
+ }
638
+ for (const locale of policy.requiredLocales ?? []) {
639
+ if (!policy.allowedLocales.includes(locale)) {
640
+ issue(
641
+ issues,
642
+ "policy.requiredLocales",
643
+ "required_locale_not_allowed",
644
+ `Required locale ${locale} is not included in allowedLocales.`
645
+ );
646
+ }
647
+ }
648
+ }
649
+ if (policy.maxLocales !== void 0 && collectedLocales.allUniqueLocales.size > policy.maxLocales) {
650
+ issue(issues, "supportedLocales", "max_locales_exceeded", `At most ${policy.maxLocales} locales are allowed.`);
651
+ }
509
652
  for (const locale of policy.requiredLocales ?? []) addRequiredTranslationIssues(schema, locale, issues);
510
653
  if (policy.maxSchemaBytes !== void 0) {
511
654
  try {
@@ -1406,6 +1549,18 @@ function resolveLocalizedSchema(schema, targetLocale) {
1406
1549
  async function populateSchemaTranslations(schema, targetLocales, adapter, options = {}) {
1407
1550
  assertValidFormSchema(schema);
1408
1551
  const locales = [...new Set(targetLocales.filter((locale) => locale.length > 0 && locale !== schema.defaultLocale))];
1552
+ const allowedLocales = options.policy?.allowedLocales;
1553
+ const collectedLocales = collectSchemaLocales(schema);
1554
+ const disallowedLocale = [...collectedLocales.allUniqueLocales, ...locales].find(
1555
+ (locale) => allowedLocales !== void 0 && !allowedLocales.includes(locale)
1556
+ );
1557
+ if (disallowedLocale !== void 0) {
1558
+ throw new RangeError(`Translation locale ${disallowedLocale} is not allowed by the form policy.`);
1559
+ }
1560
+ const projectedLocales = /* @__PURE__ */ new Set([...collectedLocales.allUniqueLocales, ...locales]);
1561
+ if (options.policy?.maxLocales !== void 0 && projectedLocales.size > options.policy.maxLocales) {
1562
+ throw new RangeError(`At most ${options.policy.maxLocales} locales are allowed by the form policy.`);
1563
+ }
1409
1564
  const updatedSlots = [];
1410
1565
  const skippedSlots = [];
1411
1566
  let result = schema;
@@ -1462,6 +1617,7 @@ export {
1462
1617
  calculateFieldVisibility,
1463
1618
  calculateNumericSummary,
1464
1619
  calculatePageVisibility,
1620
+ collectSchemaLocales,
1465
1621
  createSubmission,
1466
1622
  dispatchWebhook,
1467
1623
  escapeCsvCell,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/core",
3
- "version": "2.1.1",
3
+ "version": "2.3.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },