@form-engine-ts/core 2.2.0 → 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
@@ -44,6 +44,12 @@ option, text, serialized-byte, allowed-type, and locale constraints. `allowedLoc
44
44
  locales, `maxLocales` limits their unique total, and contradictory required/allowed locale policies are reported.
45
45
  Required locales cover every source text that exists on the form, its fields, options, and pages.
46
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.
52
+
47
53
  Translation callbacks receive `nodeMetadata` and `existingTranslationMetadata` separately. The deprecated `metadata`
48
54
  slot property remains an alias for `nodeMetadata` during migration.
49
55
 
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,31 +652,38 @@ function validatePolicy(schema, policy, issues) {
554
652
  }
555
653
  }
556
654
  }
557
- const registeredLocales = [
558
- .../* @__PURE__ */ new Set([
559
- ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
560
- ...schema.supportedLocales ?? []
561
- ])
562
- ];
563
- if (policy.allowedLocales !== void 0) {
564
- if (schema.defaultLocale !== void 0 && !policy.allowedLocales.includes(schema.defaultLocale)) {
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) ?? []) {
565
663
  issue(
566
664
  issues,
567
- "defaultLocale",
568
- "disallowed_locale",
569
- `Locale ${schema.defaultLocale} is not allowed by the form policy.`
665
+ path,
666
+ "unregistered_translation_locale",
667
+ `Translation locale ${locale} is not registered by defaultLocale or supportedLocales.`
570
668
  );
571
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"]);
572
674
  schema.supportedLocales?.forEach((locale, index) => {
573
- if (!policy.allowedLocales?.includes(locale)) {
574
- issue(
575
- issues,
576
- `supportedLocales[${index}]`,
577
- "disallowed_locale",
578
- `Locale ${locale} is not allowed by the form policy.`
579
- );
580
- }
675
+ pathsByLocale.set(locale, [...pathsByLocale.get(locale) ?? [], `supportedLocales[${index}]`]);
581
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
+ }
582
687
  for (const locale of policy.requiredLocales ?? []) {
583
688
  if (!policy.allowedLocales.includes(locale)) {
584
689
  issue(
@@ -590,7 +695,7 @@ function validatePolicy(schema, policy, issues) {
590
695
  }
591
696
  }
592
697
  }
593
- if (policy.maxLocales !== void 0 && registeredLocales.length > policy.maxLocales) {
698
+ if (policy.maxLocales !== void 0 && collectedLocales.allUniqueLocales.size > policy.maxLocales) {
594
699
  issue(issues, "supportedLocales", "max_locales_exceeded", `At most ${policy.maxLocales} locales are allowed.`);
595
700
  }
596
701
  for (const locale of policy.requiredLocales ?? []) addRequiredTranslationIssues(schema, locale, issues);
@@ -1493,6 +1598,18 @@ function resolveLocalizedSchema(schema, targetLocale) {
1493
1598
  async function populateSchemaTranslations(schema, targetLocales, adapter, options = {}) {
1494
1599
  assertValidFormSchema(schema);
1495
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
+ }
1496
1613
  const updatedSlots = [];
1497
1614
  const skippedSlots = [];
1498
1615
  let result = schema;
@@ -1550,6 +1667,7 @@ async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocal
1550
1667
  calculateFieldVisibility,
1551
1668
  calculateNumericSummary,
1552
1669
  calculatePageVisibility,
1670
+ collectSchemaLocales,
1553
1671
  createSubmission,
1554
1672
  dispatchWebhook,
1555
1673
  escapeCsvCell,
package/dist/index.d.cts CHANGED
@@ -268,6 +268,17 @@ declare function dispatchWebhook<T>(event: FormEvent<T>, config: WebhookConfig,
268
268
  */
269
269
  declare function transformFieldType(field: FormField, nextType: QuestionType): FormField;
270
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
+
271
282
  type SchemaStructureIssueType = "dangling_condition_reference" | "duplicate_question_id" | "duplicate_choice_id" | "self_condition_reference" | "cyclic_condition_reference";
272
283
  interface SchemaStructureIssue {
273
284
  readonly type: SchemaStructureIssueType;
@@ -307,6 +318,8 @@ interface PopulateTranslationOptions {
307
318
  readonly overwrite?: "missing-only" | "all";
308
319
  readonly shouldOverwrite?: (slot: TranslationSlot) => boolean;
309
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">;
310
323
  }
311
324
  interface TranslationReport {
312
325
  readonly updatedSlots: readonly TranslationSlot[];
@@ -328,4 +341,4 @@ declare function calculatePageVisibility(schema: FormSchema, currentAnswers: Rea
328
341
  declare function calculateFieldVisibility(schema: FormSchema, currentAnswers: Readonly<Record<string, unknown>>): Readonly<Record<string, boolean>>;
329
342
  declare function selectVisibleAnswers(schema: FormSchema, currentAnswers: FormValues): FormValues;
330
343
 
331
- 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
@@ -268,6 +268,17 @@ declare function dispatchWebhook<T>(event: FormEvent<T>, config: WebhookConfig,
268
268
  */
269
269
  declare function transformFieldType(field: FormField, nextType: QuestionType): FormField;
270
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
+
271
282
  type SchemaStructureIssueType = "dangling_condition_reference" | "duplicate_question_id" | "duplicate_choice_id" | "self_condition_reference" | "cyclic_condition_reference";
272
283
  interface SchemaStructureIssue {
273
284
  readonly type: SchemaStructureIssueType;
@@ -307,6 +318,8 @@ interface PopulateTranslationOptions {
307
318
  readonly overwrite?: "missing-only" | "all";
308
319
  readonly shouldOverwrite?: (slot: TranslationSlot) => boolean;
309
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">;
310
323
  }
311
324
  interface TranslationReport {
312
325
  readonly updatedSlots: readonly TranslationSlot[];
@@ -328,4 +341,4 @@ declare function calculatePageVisibility(schema: FormSchema, currentAnswers: Rea
328
341
  declare function calculateFieldVisibility(schema: FormSchema, currentAnswers: Readonly<Record<string, unknown>>): Readonly<Record<string, boolean>>;
329
342
  declare function selectVisibleAnswers(schema: FormSchema, currentAnswers: FormValues): FormValues;
330
343
 
331
- 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,31 +603,38 @@ function validatePolicy(schema, policy, issues) {
506
603
  }
507
604
  }
508
605
  }
509
- const registeredLocales = [
510
- .../* @__PURE__ */ new Set([
511
- ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
512
- ...schema.supportedLocales ?? []
513
- ])
514
- ];
515
- if (policy.allowedLocales !== void 0) {
516
- if (schema.defaultLocale !== void 0 && !policy.allowedLocales.includes(schema.defaultLocale)) {
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) ?? []) {
517
614
  issue(
518
615
  issues,
519
- "defaultLocale",
520
- "disallowed_locale",
521
- `Locale ${schema.defaultLocale} is not allowed by the form policy.`
616
+ path,
617
+ "unregistered_translation_locale",
618
+ `Translation locale ${locale} is not registered by defaultLocale or supportedLocales.`
522
619
  );
523
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"]);
524
625
  schema.supportedLocales?.forEach((locale, index) => {
525
- if (!policy.allowedLocales?.includes(locale)) {
526
- issue(
527
- issues,
528
- `supportedLocales[${index}]`,
529
- "disallowed_locale",
530
- `Locale ${locale} is not allowed by the form policy.`
531
- );
532
- }
626
+ pathsByLocale.set(locale, [...pathsByLocale.get(locale) ?? [], `supportedLocales[${index}]`]);
533
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
+ }
534
638
  for (const locale of policy.requiredLocales ?? []) {
535
639
  if (!policy.allowedLocales.includes(locale)) {
536
640
  issue(
@@ -542,7 +646,7 @@ function validatePolicy(schema, policy, issues) {
542
646
  }
543
647
  }
544
648
  }
545
- if (policy.maxLocales !== void 0 && registeredLocales.length > policy.maxLocales) {
649
+ if (policy.maxLocales !== void 0 && collectedLocales.allUniqueLocales.size > policy.maxLocales) {
546
650
  issue(issues, "supportedLocales", "max_locales_exceeded", `At most ${policy.maxLocales} locales are allowed.`);
547
651
  }
548
652
  for (const locale of policy.requiredLocales ?? []) addRequiredTranslationIssues(schema, locale, issues);
@@ -1445,6 +1549,18 @@ function resolveLocalizedSchema(schema, targetLocale) {
1445
1549
  async function populateSchemaTranslations(schema, targetLocales, adapter, options = {}) {
1446
1550
  assertValidFormSchema(schema);
1447
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
+ }
1448
1564
  const updatedSlots = [];
1449
1565
  const skippedSlots = [];
1450
1566
  let result = schema;
@@ -1501,6 +1617,7 @@ export {
1501
1617
  calculateFieldVisibility,
1502
1618
  calculateNumericSummary,
1503
1619
  calculatePageVisibility,
1620
+ collectSchemaLocales,
1504
1621
  createSubmission,
1505
1622
  dispatchWebhook,
1506
1623
  escapeCsvCell,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/core",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },