@form-engine-ts/core 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/dist/index.cjs +447 -50
- package/dist/index.d.cts +60 -2
- package/dist/index.d.ts +60 -2
- package/dist/index.js +440 -50
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,4 +24,29 @@ const result = validateAnswers(schema, { name: "Ada" });
|
|
|
24
24
|
if (!result.valid) console.error(result.issues);
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
## Multi-step, localization, analytics, and events
|
|
28
|
+
|
|
29
|
+
Add `pages` to partition every field into an accessible wizard and use `validatePageAnswers(schema, pageIndex, values)`
|
|
30
|
+
for step-scoped validation. Schemas without `pages` remain single-page forms.
|
|
31
|
+
|
|
32
|
+
Store authoring-time translations on forms, fields, options, and pages. `resolveLocalizedSchema` applies them synchronously,
|
|
33
|
+
while `populateSchemaTranslations` fills them through an injected `AsyncTranslationAdapter`.
|
|
34
|
+
|
|
35
|
+
`calculateCrossTabulation` builds a two-question frequency matrix from submissions. `dispatchWebhook` posts typed
|
|
36
|
+
`response.submitted` or `schema.updated` events with timeout handling, custom headers, and optional HMAC-SHA256 signing.
|
|
37
|
+
|
|
38
|
+
Storage adapters share inclusive ISO 8601 submission-range filtering:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import type { SubmissionQueryOptions } from "@form-engine-ts/core";
|
|
42
|
+
|
|
43
|
+
const range: SubmissionQueryOptions = {
|
|
44
|
+
since: "2026-01-01T00:00:00.000Z",
|
|
45
|
+
until: "2026-01-31T23:59:59.999Z"
|
|
46
|
+
};
|
|
47
|
+
const submissions = await storage.listSubmissions("contact", 1, range);
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Results are ordered by `submittedAt`, then submission ID. Both boundaries are inclusive.
|
|
51
|
+
|
|
27
52
|
See the [project documentation](https://github.com/nitta-a/form-engine-ts#readme) for the complete schema and API guide.
|
package/dist/index.cjs
CHANGED
|
@@ -23,17 +23,24 @@ __export(index_exports, {
|
|
|
23
23
|
aggregateResponses: () => aggregateResponses,
|
|
24
24
|
assertValidFormSchema: () => assertValidFormSchema,
|
|
25
25
|
calculateChoiceDistribution: () => calculateChoiceDistribution,
|
|
26
|
+
calculateCrossTabulation: () => calculateCrossTabulation,
|
|
26
27
|
calculateFieldVisibility: () => calculateFieldVisibility,
|
|
27
28
|
calculateNumericSummary: () => calculateNumericSummary,
|
|
29
|
+
calculatePageVisibility: () => calculatePageVisibility,
|
|
28
30
|
createSubmission: () => createSubmission,
|
|
31
|
+
dispatchWebhook: () => dispatchWebhook,
|
|
29
32
|
escapeCsvCell: () => escapeCsvCell,
|
|
30
33
|
exportResponsesToCsv: () => exportResponsesToCsv,
|
|
34
|
+
isDisplayConditionSatisfied: () => isDisplayConditionSatisfied,
|
|
31
35
|
isQuestionVisible: () => isQuestionVisible,
|
|
36
|
+
populateSchemaTranslations: () => populateSchemaTranslations,
|
|
32
37
|
resolveFormTranslation: () => resolveFormTranslation,
|
|
38
|
+
resolveLocalizedSchema: () => resolveLocalizedSchema,
|
|
33
39
|
sanitizeSchema: () => sanitizeSchema,
|
|
34
40
|
selectVisibleAnswers: () => selectVisibleAnswers,
|
|
35
41
|
validateAnswers: () => validateAnswers,
|
|
36
42
|
validateFormSchema: () => validateFormSchema,
|
|
43
|
+
validatePageAnswers: () => validatePageAnswers,
|
|
37
44
|
validateSchemaStructure: () => validateSchemaStructure
|
|
38
45
|
});
|
|
39
46
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -126,17 +133,46 @@ function validateSchemaStructure(schema) {
|
|
|
126
133
|
function sanitizeSchema(schema) {
|
|
127
134
|
const existingQuestionIds = new Set(schema.fields.map((field) => field.id));
|
|
128
135
|
const cyclic = cyclicQuestionIds(schema.fields);
|
|
129
|
-
|
|
136
|
+
const sanitizedFields = schema.fields.map((field) => {
|
|
137
|
+
const sourceId = field.displayCondition?.questionId;
|
|
138
|
+
if (sourceId === void 0 || existingQuestionIds.has(sourceId) && sourceId !== field.id && !cyclic.has(field.id)) {
|
|
139
|
+
return field;
|
|
140
|
+
}
|
|
141
|
+
const { displayCondition: _displayCondition, ...sanitized } = field;
|
|
142
|
+
return sanitized;
|
|
143
|
+
});
|
|
144
|
+
const base = {
|
|
130
145
|
...schema,
|
|
131
|
-
fields:
|
|
132
|
-
const sourceId = field.displayCondition?.questionId;
|
|
133
|
-
if (sourceId === void 0 || existingQuestionIds.has(sourceId) && sourceId !== field.id && !cyclic.has(field.id)) {
|
|
134
|
-
return field;
|
|
135
|
-
}
|
|
136
|
-
const { displayCondition: _displayCondition, ...sanitized } = field;
|
|
137
|
-
return sanitized;
|
|
138
|
-
})
|
|
146
|
+
fields: sanitizedFields
|
|
139
147
|
};
|
|
148
|
+
if (schema.pages === void 0) return base;
|
|
149
|
+
const assigned = /* @__PURE__ */ new Set();
|
|
150
|
+
const pages = schema.pages.map((page) => ({
|
|
151
|
+
...page,
|
|
152
|
+
questionIds: page.questionIds.filter((id) => {
|
|
153
|
+
if (!existingQuestionIds.has(id) || assigned.has(id)) return false;
|
|
154
|
+
assigned.add(id);
|
|
155
|
+
return true;
|
|
156
|
+
})
|
|
157
|
+
})).filter((page) => page.questionIds.length > 0);
|
|
158
|
+
if (pages.length === 0) {
|
|
159
|
+
const { pages: _pages, ...singlePage } = base;
|
|
160
|
+
return singlePage;
|
|
161
|
+
}
|
|
162
|
+
const unassigned = schema.fields.map((field) => field.id).filter((id) => !assigned.has(id));
|
|
163
|
+
const completePages = pages.map(
|
|
164
|
+
(page, index) => index === pages.length - 1 ? { ...page, questionIds: [...page.questionIds, ...unassigned] } : page
|
|
165
|
+
);
|
|
166
|
+
const pageIndexByQuestion = new Map(
|
|
167
|
+
completePages.flatMap((page, pageIndex) => page.questionIds.map((id) => [id, pageIndex]))
|
|
168
|
+
);
|
|
169
|
+
const safePages = completePages.map((page, pageIndex) => {
|
|
170
|
+
const sourceIndex = page.displayCondition === void 0 ? void 0 : pageIndexByQuestion.get(page.displayCondition.questionId);
|
|
171
|
+
if (page.displayCondition === void 0 || sourceIndex !== void 0 && sourceIndex < pageIndex) return page;
|
|
172
|
+
const { displayCondition: _displayCondition, ...safePage } = page;
|
|
173
|
+
return safePage;
|
|
174
|
+
});
|
|
175
|
+
return { ...base, pages: safePages };
|
|
140
176
|
}
|
|
141
177
|
|
|
142
178
|
// src/schema.ts
|
|
@@ -151,6 +187,34 @@ function isNonEmptyString(value) {
|
|
|
151
187
|
function issue(issues, path, code, message) {
|
|
152
188
|
issues.push({ path, code, message });
|
|
153
189
|
}
|
|
190
|
+
function validateLocalizedTextMap(value, path, issues) {
|
|
191
|
+
if (!isRecord(value)) {
|
|
192
|
+
issue(issues, path, "invalid_translations", "Expected a locale-to-translation object.");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
for (const [locale, translation] of Object.entries(value)) {
|
|
196
|
+
if (!isNonEmptyString(locale) || !isRecord(translation)) {
|
|
197
|
+
issue(issues, `${path}.${locale}`, "invalid_translation", "Expected a translation object.");
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
for (const key of ["title", "description"]) {
|
|
201
|
+
if (translation[key] !== void 0 && !isNonEmptyString(translation[key])) {
|
|
202
|
+
issue(issues, `${path}.${locale}.${key}`, "invalid_translation", "Expected non-empty translated text.");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function validateOptionTranslations(value, path, issues) {
|
|
208
|
+
if (!isRecord(value)) {
|
|
209
|
+
issue(issues, path, "invalid_translations", "Expected a locale-to-label object.");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
for (const [locale, label] of Object.entries(value)) {
|
|
213
|
+
if (!isNonEmptyString(locale) || !isNonEmptyString(label)) {
|
|
214
|
+
issue(issues, `${path}.${locale}`, "invalid_translation", "Expected a non-empty translated label.");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
154
218
|
function rejectLegacyProperties(value, path, properties, issues) {
|
|
155
219
|
for (const property of properties) {
|
|
156
220
|
if (Object.hasOwn(value, property)) {
|
|
@@ -194,6 +258,8 @@ function validateOptions(value, path, issues) {
|
|
|
194
258
|
if (!isNonEmptyString(option.label)) {
|
|
195
259
|
issue(issues, `${optionPath}.label`, "invalid_label", "Expected a non-empty option label.");
|
|
196
260
|
}
|
|
261
|
+
if (option.translations !== void 0)
|
|
262
|
+
validateOptionTranslations(option.translations, `${optionPath}.translations`, issues);
|
|
197
263
|
});
|
|
198
264
|
return true;
|
|
199
265
|
}
|
|
@@ -243,6 +309,7 @@ function validateField(value, path, issues) {
|
|
|
243
309
|
if (value.displayCondition !== void 0) {
|
|
244
310
|
validateDisplayCondition(value.displayCondition, `${path}.displayCondition`, issues);
|
|
245
311
|
}
|
|
312
|
+
if (value.translations !== void 0) validateLocalizedTextMap(value.translations, `${path}.translations`, issues);
|
|
246
313
|
if (typeof value.type !== "string" || !FIELD_TYPES.has(value.type)) {
|
|
247
314
|
issue(issues, `${path}.type`, "invalid_field_type", "Unsupported field type.");
|
|
248
315
|
return false;
|
|
@@ -328,6 +395,26 @@ function validateFormSchema(input) {
|
|
|
328
395
|
);
|
|
329
396
|
}
|
|
330
397
|
}
|
|
398
|
+
if (input.defaultLocale !== void 0 && !isNonEmptyString(input.defaultLocale)) {
|
|
399
|
+
issue(issues, "defaultLocale", "invalid_locale", "Expected a non-empty default locale.");
|
|
400
|
+
}
|
|
401
|
+
if (input.supportedLocales !== void 0) {
|
|
402
|
+
if (!Array.isArray(input.supportedLocales) || input.supportedLocales.length === 0) {
|
|
403
|
+
issue(issues, "supportedLocales", "invalid_locales", "Expected at least one supported locale.");
|
|
404
|
+
} else {
|
|
405
|
+
const locales = /* @__PURE__ */ new Set();
|
|
406
|
+
input.supportedLocales.forEach((locale, index) => {
|
|
407
|
+
if (!isNonEmptyString(locale)) {
|
|
408
|
+
issue(issues, `supportedLocales[${index}]`, "invalid_locale", "Expected a non-empty locale.");
|
|
409
|
+
} else if (locales.has(locale)) {
|
|
410
|
+
issue(issues, `supportedLocales[${index}]`, "duplicate_locale", "Locales must be unique.");
|
|
411
|
+
} else {
|
|
412
|
+
locales.add(locale);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (input.translations !== void 0) validateLocalizedTextMap(input.translations, "translations", issues);
|
|
331
418
|
if (!Array.isArray(input.fields) || input.fields.length === 0) {
|
|
332
419
|
issue(issues, "fields", "invalid_fields", "Expected at least one field.");
|
|
333
420
|
} else {
|
|
@@ -354,6 +441,80 @@ function validateFormSchema(input) {
|
|
|
354
441
|
const code = structuralIssue.type === "dangling_condition_reference" ? "unknown_condition_source" : structuralIssue.type === "self_condition_reference" ? "self_condition" : "condition_cycle";
|
|
355
442
|
issue(issues, `fields[${fieldIndex}].displayCondition`, code, structuralIssue.message);
|
|
356
443
|
}
|
|
444
|
+
if (input.pages !== void 0) {
|
|
445
|
+
if (!Array.isArray(input.pages) || input.pages.length === 0) {
|
|
446
|
+
issue(issues, "pages", "invalid_pages", "Expected at least one page when pages is defined.");
|
|
447
|
+
} else {
|
|
448
|
+
const pageIds = /* @__PURE__ */ new Set();
|
|
449
|
+
const assigned = /* @__PURE__ */ new Map();
|
|
450
|
+
input.pages.forEach((page, pageIndex) => {
|
|
451
|
+
const pagePath = `pages[${pageIndex}]`;
|
|
452
|
+
if (!isRecord(page)) {
|
|
453
|
+
issue(issues, pagePath, "invalid_page", "Expected a page object.");
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (!isNonEmptyString(page.id)) {
|
|
457
|
+
issue(issues, `${pagePath}.id`, "invalid_page_id", "Expected a non-empty page ID.");
|
|
458
|
+
} else if (pageIds.has(page.id)) {
|
|
459
|
+
issue(issues, `${pagePath}.id`, "duplicate_page", "Page IDs must be unique.");
|
|
460
|
+
} else {
|
|
461
|
+
pageIds.add(page.id);
|
|
462
|
+
}
|
|
463
|
+
for (const key of ["title", "description"]) {
|
|
464
|
+
if (page[key] !== void 0 && !isNonEmptyString(page[key])) {
|
|
465
|
+
issue(issues, `${pagePath}.${key}`, "invalid_page_text", "Expected non-empty page text.");
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (page.translations !== void 0) {
|
|
469
|
+
validateLocalizedTextMap(page.translations, `${pagePath}.translations`, issues);
|
|
470
|
+
}
|
|
471
|
+
if (page.displayCondition !== void 0) {
|
|
472
|
+
validateDisplayCondition(page.displayCondition, `${pagePath}.displayCondition`, issues);
|
|
473
|
+
}
|
|
474
|
+
if (!Array.isArray(page.questionIds) || page.questionIds.length === 0) {
|
|
475
|
+
issue(issues, `${pagePath}.questionIds`, "invalid_page_questions", "Expected at least one question ID.");
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
page.questionIds.forEach((questionId, questionIndex) => {
|
|
479
|
+
const questionPath = `${pagePath}.questionIds[${questionIndex}]`;
|
|
480
|
+
if (!isNonEmptyString(questionId)) {
|
|
481
|
+
issue(issues, questionPath, "invalid_question_reference", "Expected a question ID.");
|
|
482
|
+
} else if (!ids.has(questionId)) {
|
|
483
|
+
issue(issues, questionPath, "unknown_page_question", "Page references an unknown question.");
|
|
484
|
+
} else if (assigned.has(questionId)) {
|
|
485
|
+
issue(issues, questionPath, "duplicate_page_question", "A question may belong to only one page.");
|
|
486
|
+
} else {
|
|
487
|
+
assigned.set(questionId, pageIndex);
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
for (const fieldId of ids) {
|
|
492
|
+
if (!assigned.has(fieldId))
|
|
493
|
+
issue(issues, "pages", "unassigned_page_question", `Question ${fieldId} has no page.`);
|
|
494
|
+
}
|
|
495
|
+
input.pages.forEach((page, pageIndex) => {
|
|
496
|
+
if (!isRecord(page) || !isRecord(page.displayCondition)) return;
|
|
497
|
+
const sourceId = page.displayCondition.questionId;
|
|
498
|
+
if (typeof sourceId !== "string") return;
|
|
499
|
+
const sourcePageIndex = assigned.get(sourceId);
|
|
500
|
+
if (sourcePageIndex === void 0) {
|
|
501
|
+
issue(
|
|
502
|
+
issues,
|
|
503
|
+
`pages[${pageIndex}].displayCondition.questionId`,
|
|
504
|
+
"unknown_page_condition_source",
|
|
505
|
+
"Page condition references an unknown question."
|
|
506
|
+
);
|
|
507
|
+
} else if (sourcePageIndex >= pageIndex) {
|
|
508
|
+
issue(
|
|
509
|
+
issues,
|
|
510
|
+
`pages[${pageIndex}].displayCondition.questionId`,
|
|
511
|
+
"forward_page_condition",
|
|
512
|
+
"Page conditions must reference a question on an earlier page."
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
}
|
|
357
518
|
}
|
|
358
519
|
return issues.length === 0 ? { valid: true, value: input, issues: [] } : { valid: false, issues };
|
|
359
520
|
}
|
|
@@ -381,7 +542,9 @@ function valuesEqual(left, right) {
|
|
|
381
542
|
return typeof left === "string" && typeof right === "string" ? normalizeString(left) === normalizeString(right) : left === right;
|
|
382
543
|
}
|
|
383
544
|
function isQuestionVisible(question, currentAnswers) {
|
|
384
|
-
|
|
545
|
+
return isDisplayConditionSatisfied(question.displayCondition, currentAnswers);
|
|
546
|
+
}
|
|
547
|
+
function isDisplayConditionSatisfied(condition, currentAnswers) {
|
|
385
548
|
if (condition === void 0) return true;
|
|
386
549
|
const answer = currentAnswers[condition.questionId];
|
|
387
550
|
if (isEmpty(answer)) return false;
|
|
@@ -394,7 +557,7 @@ function isQuestionVisible(question, currentAnswers) {
|
|
|
394
557
|
}
|
|
395
558
|
return Array.isArray(answer) && answer.some((item) => valuesEqual(item, condition.value));
|
|
396
559
|
}
|
|
397
|
-
function
|
|
560
|
+
function calculateBaseFieldVisibility(schema, currentAnswers) {
|
|
398
561
|
const fields = new Map(schema.fields.map((field) => [field.id, field]));
|
|
399
562
|
const resolved = /* @__PURE__ */ new Map();
|
|
400
563
|
const resolving = /* @__PURE__ */ new Set();
|
|
@@ -412,7 +575,34 @@ function calculateFieldVisibility(schema, currentAnswers) {
|
|
|
412
575
|
return visible;
|
|
413
576
|
};
|
|
414
577
|
for (const field of schema.fields) resolve(field);
|
|
415
|
-
return Object.
|
|
578
|
+
return Object.fromEntries(resolved);
|
|
579
|
+
}
|
|
580
|
+
function calculatePageVisibility(schema, currentAnswers) {
|
|
581
|
+
if (schema.pages === void 0 || schema.pages.length === 0) return {};
|
|
582
|
+
const baseFieldVisibility = calculateBaseFieldVisibility(schema, currentAnswers);
|
|
583
|
+
const pageByQuestion = new Map(schema.pages.flatMap((page) => page.questionIds.map((id) => [id, page.id])));
|
|
584
|
+
const pageVisibility = {};
|
|
585
|
+
for (const page of schema.pages) {
|
|
586
|
+
const sourceId = page.displayCondition?.questionId;
|
|
587
|
+
const sourcePageId = sourceId === void 0 ? void 0 : pageByQuestion.get(sourceId);
|
|
588
|
+
const sourceVisible = sourceId === void 0 || baseFieldVisibility[sourceId] === true && sourcePageId !== void 0 && pageVisibility[sourcePageId] === true;
|
|
589
|
+
pageVisibility[page.id] = sourceVisible && isDisplayConditionSatisfied(page.displayCondition, currentAnswers);
|
|
590
|
+
}
|
|
591
|
+
return Object.freeze(pageVisibility);
|
|
592
|
+
}
|
|
593
|
+
function calculateFieldVisibility(schema, currentAnswers) {
|
|
594
|
+
const baseVisibility = calculateBaseFieldVisibility(schema, currentAnswers);
|
|
595
|
+
if (schema.pages === void 0 || schema.pages.length === 0) return Object.freeze(baseVisibility);
|
|
596
|
+
const pageVisibility = calculatePageVisibility(schema, currentAnswers);
|
|
597
|
+
const pageByQuestion = new Map(schema.pages.flatMap((page) => page.questionIds.map((id) => [id, page.id])));
|
|
598
|
+
return Object.freeze(
|
|
599
|
+
Object.fromEntries(
|
|
600
|
+
schema.fields.map((field) => {
|
|
601
|
+
const pageId = pageByQuestion.get(field.id);
|
|
602
|
+
return [field.id, baseVisibility[field.id] === true && pageId !== void 0 && pageVisibility[pageId] === true];
|
|
603
|
+
})
|
|
604
|
+
)
|
|
605
|
+
);
|
|
416
606
|
}
|
|
417
607
|
function selectVisibleAnswers(schema, currentAnswers) {
|
|
418
608
|
const visibility = calculateFieldVisibility(schema, currentAnswers);
|
|
@@ -446,6 +636,23 @@ function calculateNumericSummary(responses, questionId) {
|
|
|
446
636
|
max: numbers.length === 0 ? null : Math.max(...numbers)
|
|
447
637
|
};
|
|
448
638
|
}
|
|
639
|
+
function calculateCrossTabulation(responses, rowQuestionId, colQuestionId) {
|
|
640
|
+
const matrix = {};
|
|
641
|
+
const rowTotals = {};
|
|
642
|
+
const colTotals = {};
|
|
643
|
+
let grandTotal = 0;
|
|
644
|
+
for (const response of responses) {
|
|
645
|
+
const row = response.values[rowQuestionId];
|
|
646
|
+
const col = response.values[colQuestionId];
|
|
647
|
+
if (typeof row !== "string" || row.length === 0 || typeof col !== "string" || col.length === 0) continue;
|
|
648
|
+
matrix[row] ??= {};
|
|
649
|
+
matrix[row][col] = (matrix[row][col] ?? 0) + 1;
|
|
650
|
+
rowTotals[row] = (rowTotals[row] ?? 0) + 1;
|
|
651
|
+
colTotals[col] = (colTotals[col] ?? 0) + 1;
|
|
652
|
+
grandTotal += 1;
|
|
653
|
+
}
|
|
654
|
+
return { rowQuestionId, colQuestionId, matrix, rowTotals, colTotals, grandTotal };
|
|
655
|
+
}
|
|
449
656
|
function valueIsValid(field, value) {
|
|
450
657
|
if (value === void 0 || value === "") return false;
|
|
451
658
|
if (field.type === "text" || field.type === "textarea") {
|
|
@@ -570,6 +777,47 @@ function exportResponsesToCsv(schema, responses, options = {}) {
|
|
|
570
777
|
return options.withBom ?? true ? `\uFEFF${csv}` : csv;
|
|
571
778
|
}
|
|
572
779
|
|
|
780
|
+
// src/events.ts
|
|
781
|
+
function bytesToHex(bytes) {
|
|
782
|
+
return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
783
|
+
}
|
|
784
|
+
async function signPayload(payload, secret) {
|
|
785
|
+
if (globalThis.crypto?.subtle === void 0) throw new Error("Web Crypto is unavailable for webhook signing.");
|
|
786
|
+
const encoder = new TextEncoder();
|
|
787
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
788
|
+
"raw",
|
|
789
|
+
encoder.encode(secret),
|
|
790
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
791
|
+
false,
|
|
792
|
+
["sign"]
|
|
793
|
+
);
|
|
794
|
+
return bytesToHex(await globalThis.crypto.subtle.sign("HMAC", key, encoder.encode(payload)));
|
|
795
|
+
}
|
|
796
|
+
async function dispatchWebhook(event, config, fetchImpl = globalThis.fetch) {
|
|
797
|
+
const body = JSON.stringify(event);
|
|
798
|
+
const controller = new AbortController();
|
|
799
|
+
const timeoutMs = config.timeoutMs ?? 5e3;
|
|
800
|
+
const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs);
|
|
801
|
+
try {
|
|
802
|
+
const headers = { "content-type": "application/json", ...config.headers };
|
|
803
|
+
if (config.secret !== void 0) {
|
|
804
|
+
headers["X-Form-Engine-Signature"] = await signPayload(body, config.secret);
|
|
805
|
+
}
|
|
806
|
+
const response = await fetchImpl(config.url, {
|
|
807
|
+
method: "POST",
|
|
808
|
+
headers,
|
|
809
|
+
body,
|
|
810
|
+
signal: controller.signal
|
|
811
|
+
});
|
|
812
|
+
return response.ok ? { success: true, status: response.status } : { success: false, status: response.status, error: `Webhook returned HTTP ${response.status}.` };
|
|
813
|
+
} catch (cause) {
|
|
814
|
+
const error = controller.signal.aborted ? `Webhook request timed out after ${timeoutMs}ms.` : cause instanceof Error ? cause.message : String(cause);
|
|
815
|
+
return { success: false, error };
|
|
816
|
+
} finally {
|
|
817
|
+
globalThis.clearTimeout(timeout);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
573
821
|
// src/validation.ts
|
|
574
822
|
var DEFAULT_MESSAGES = {
|
|
575
823
|
required: "validation.required",
|
|
@@ -696,6 +944,18 @@ function validateAnswers(schema, values) {
|
|
|
696
944
|
}
|
|
697
945
|
return issues.length === 0 ? { valid: true, issues: [] } : { valid: false, issues };
|
|
698
946
|
}
|
|
947
|
+
function validatePageAnswers(schema, pageIndex, values) {
|
|
948
|
+
if (schema.pages === void 0 || schema.pages.length === 0) return validateAnswers(schema, values);
|
|
949
|
+
const page = schema.pages[pageIndex];
|
|
950
|
+
if (page === void 0) return { valid: true, issues: [] };
|
|
951
|
+
const targetIds = new Set(page.questionIds);
|
|
952
|
+
const visibility = calculateFieldVisibility(schema, values);
|
|
953
|
+
const issues = [];
|
|
954
|
+
for (const field of schema.fields) {
|
|
955
|
+
if (targetIds.has(field.id) && visibility[field.id] === true) validateField2(field, values[field.id], issues);
|
|
956
|
+
}
|
|
957
|
+
return issues.length === 0 ? { valid: true, issues: [] } : { valid: false, issues };
|
|
958
|
+
}
|
|
699
959
|
|
|
700
960
|
// src/submission.ts
|
|
701
961
|
function cloneValues(values) {
|
|
@@ -728,63 +988,200 @@ function createSubmission(schema, values, options) {
|
|
|
728
988
|
}
|
|
729
989
|
|
|
730
990
|
// src/translation.ts
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
991
|
+
function mergeLocalizedText(translations, locale, key, value) {
|
|
992
|
+
return { ...translations, [locale]: { ...translations?.[locale], [key]: value } };
|
|
993
|
+
}
|
|
994
|
+
function translationSlots(schema) {
|
|
995
|
+
const slots = [
|
|
996
|
+
{
|
|
997
|
+
text: schema.title,
|
|
998
|
+
apply: (current, value, locale) => ({
|
|
999
|
+
...current,
|
|
1000
|
+
translations: mergeLocalizedText(current.translations, locale, "title", value)
|
|
1001
|
+
})
|
|
1002
|
+
}
|
|
1003
|
+
];
|
|
1004
|
+
if (schema.description !== void 0) {
|
|
1005
|
+
slots.push({
|
|
1006
|
+
text: schema.description,
|
|
1007
|
+
apply: (current, value, locale) => ({
|
|
1008
|
+
...current,
|
|
1009
|
+
translations: mergeLocalizedText(current.translations, locale, "description", value)
|
|
1010
|
+
})
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
schema.fields.forEach((field, fieldIndex) => {
|
|
1014
|
+
slots.push({
|
|
1015
|
+
text: field.title,
|
|
1016
|
+
apply: (current, value, locale) => ({
|
|
1017
|
+
...current,
|
|
1018
|
+
fields: current.fields.map(
|
|
1019
|
+
(item, index) => index === fieldIndex ? { ...item, translations: mergeLocalizedText(item.translations, locale, "title", value) } : item
|
|
1020
|
+
)
|
|
1021
|
+
})
|
|
1022
|
+
});
|
|
1023
|
+
if (field.description !== void 0) {
|
|
1024
|
+
slots.push({
|
|
1025
|
+
text: field.description,
|
|
1026
|
+
apply: (current, value, locale) => ({
|
|
1027
|
+
...current,
|
|
1028
|
+
fields: current.fields.map(
|
|
1029
|
+
(item, index) => index === fieldIndex ? {
|
|
1030
|
+
...item,
|
|
1031
|
+
translations: mergeLocalizedText(item.translations, locale, "description", value)
|
|
1032
|
+
} : item
|
|
1033
|
+
)
|
|
1034
|
+
})
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
if ("options" in field) {
|
|
1038
|
+
field.options.forEach((option, optionIndex) => {
|
|
1039
|
+
slots.push({
|
|
1040
|
+
text: option.label,
|
|
1041
|
+
apply: (current, value, locale) => ({
|
|
1042
|
+
...current,
|
|
1043
|
+
fields: current.fields.map((item, index) => {
|
|
1044
|
+
if (index !== fieldIndex || !("options" in item)) return item;
|
|
1045
|
+
return {
|
|
1046
|
+
...item,
|
|
1047
|
+
options: item.options.map(
|
|
1048
|
+
(candidate, candidateIndex) => candidateIndex === optionIndex ? { ...candidate, translations: { ...candidate.translations, [locale]: value } } : candidate
|
|
1049
|
+
)
|
|
1050
|
+
};
|
|
1051
|
+
})
|
|
1052
|
+
})
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
schema.pages?.forEach((page, pageIndex) => {
|
|
1058
|
+
if (page.title !== void 0) {
|
|
1059
|
+
slots.push({
|
|
1060
|
+
text: page.title,
|
|
1061
|
+
apply: (current, value, locale) => ({
|
|
1062
|
+
...current,
|
|
1063
|
+
...current.pages === void 0 ? {} : {
|
|
1064
|
+
pages: current.pages.map(
|
|
1065
|
+
(item, index) => index === pageIndex ? { ...item, translations: mergeLocalizedText(item.translations, locale, "title", value) } : item
|
|
1066
|
+
)
|
|
1067
|
+
}
|
|
1068
|
+
})
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
if (page.description !== void 0) {
|
|
1072
|
+
slots.push({
|
|
1073
|
+
text: page.description,
|
|
1074
|
+
apply: (current, value, locale) => ({
|
|
1075
|
+
...current,
|
|
1076
|
+
...current.pages === void 0 ? {} : {
|
|
1077
|
+
pages: current.pages.map(
|
|
1078
|
+
(item, index) => index === pageIndex ? { ...item, translations: mergeLocalizedText(item.translations, locale, "description", value) } : item
|
|
1079
|
+
)
|
|
1080
|
+
}
|
|
1081
|
+
})
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
763
1084
|
});
|
|
764
|
-
|
|
1085
|
+
return slots;
|
|
1086
|
+
}
|
|
1087
|
+
function resolveLocalizedSchema(schema, targetLocale) {
|
|
1088
|
+
if (targetLocale.length === 0 || targetLocale === schema.defaultLocale) return schema;
|
|
1089
|
+
const formTranslation = schema.translations?.[targetLocale];
|
|
1090
|
+
return {
|
|
765
1091
|
...schema,
|
|
766
|
-
title,
|
|
767
|
-
...description === void 0 ? {} : { description },
|
|
768
|
-
fields
|
|
1092
|
+
title: formTranslation?.title ?? schema.title,
|
|
1093
|
+
...(formTranslation?.description ?? schema.description) === void 0 ? {} : { description: formTranslation?.description ?? schema.description },
|
|
1094
|
+
fields: schema.fields.map((field) => {
|
|
1095
|
+
const translation = field.translations?.[targetLocale];
|
|
1096
|
+
const localized = {
|
|
1097
|
+
...field,
|
|
1098
|
+
title: translation?.title ?? field.title,
|
|
1099
|
+
...(translation?.description ?? field.description) === void 0 ? {} : { description: translation?.description ?? field.description }
|
|
1100
|
+
};
|
|
1101
|
+
if (!("options" in field)) return localized;
|
|
1102
|
+
return {
|
|
1103
|
+
...localized,
|
|
1104
|
+
options: field.options.map((option) => ({
|
|
1105
|
+
...option,
|
|
1106
|
+
label: option.translations?.[targetLocale] ?? option.label
|
|
1107
|
+
}))
|
|
1108
|
+
};
|
|
1109
|
+
}),
|
|
1110
|
+
...schema.pages === void 0 ? {} : {
|
|
1111
|
+
pages: schema.pages.map((page) => {
|
|
1112
|
+
const translation = page.translations?.[targetLocale];
|
|
1113
|
+
const title = translation?.title ?? page.title;
|
|
1114
|
+
const description = translation?.description ?? page.description;
|
|
1115
|
+
return {
|
|
1116
|
+
...page,
|
|
1117
|
+
...title === void 0 ? {} : { title },
|
|
1118
|
+
...description === void 0 ? {} : { description }
|
|
1119
|
+
};
|
|
1120
|
+
})
|
|
1121
|
+
}
|
|
769
1122
|
};
|
|
770
|
-
|
|
771
|
-
|
|
1123
|
+
}
|
|
1124
|
+
async function populateSchemaTranslations(schema, targetLocales, adapter) {
|
|
1125
|
+
assertValidFormSchema(schema);
|
|
1126
|
+
const slots = translationSlots(schema);
|
|
1127
|
+
const locales = [...new Set(targetLocales.filter((locale) => locale.length > 0 && locale !== schema.defaultLocale))];
|
|
1128
|
+
let result = schema;
|
|
1129
|
+
for (const locale of locales) {
|
|
1130
|
+
const translated = await adapter.translateBatch(
|
|
1131
|
+
slots.map((slot) => slot.text),
|
|
1132
|
+
locale,
|
|
1133
|
+
schema.defaultLocale
|
|
1134
|
+
);
|
|
1135
|
+
if (translated.length !== slots.length) {
|
|
1136
|
+
throw new Error(`Translation adapter returned ${translated.length} texts for ${slots.length} inputs.`);
|
|
1137
|
+
}
|
|
1138
|
+
translated.forEach((value, index) => {
|
|
1139
|
+
const slot = slots[index];
|
|
1140
|
+
if (slot === void 0) throw new Error("Translation adapter returned an unexpected result.");
|
|
1141
|
+
result = slot.apply(result, value, locale);
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
const supportedLocales = [
|
|
1145
|
+
.../* @__PURE__ */ new Set([
|
|
1146
|
+
...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
|
|
1147
|
+
...schema.supportedLocales ?? [],
|
|
1148
|
+
...locales
|
|
1149
|
+
])
|
|
1150
|
+
];
|
|
1151
|
+
result = supportedLocales.length === 0 ? result : { ...result, supportedLocales };
|
|
1152
|
+
assertValidFormSchema(result);
|
|
1153
|
+
return result;
|
|
1154
|
+
}
|
|
1155
|
+
async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocale) {
|
|
1156
|
+
const populated = await populateSchemaTranslations(
|
|
1157
|
+
sourceLocale === void 0 ? schema : { ...schema, defaultLocale: sourceLocale },
|
|
1158
|
+
[targetLocale],
|
|
1159
|
+
adapter
|
|
1160
|
+
);
|
|
1161
|
+
return resolveLocalizedSchema(populated, targetLocale);
|
|
772
1162
|
}
|
|
773
1163
|
// Annotate the CommonJS export names for ESM import in node:
|
|
774
1164
|
0 && (module.exports = {
|
|
775
1165
|
aggregateResponses,
|
|
776
1166
|
assertValidFormSchema,
|
|
777
1167
|
calculateChoiceDistribution,
|
|
1168
|
+
calculateCrossTabulation,
|
|
778
1169
|
calculateFieldVisibility,
|
|
779
1170
|
calculateNumericSummary,
|
|
1171
|
+
calculatePageVisibility,
|
|
780
1172
|
createSubmission,
|
|
1173
|
+
dispatchWebhook,
|
|
781
1174
|
escapeCsvCell,
|
|
782
1175
|
exportResponsesToCsv,
|
|
1176
|
+
isDisplayConditionSatisfied,
|
|
783
1177
|
isQuestionVisible,
|
|
1178
|
+
populateSchemaTranslations,
|
|
784
1179
|
resolveFormTranslation,
|
|
1180
|
+
resolveLocalizedSchema,
|
|
785
1181
|
sanitizeSchema,
|
|
786
1182
|
selectVisibleAnswers,
|
|
787
1183
|
validateAnswers,
|
|
788
1184
|
validateFormSchema,
|
|
1185
|
+
validatePageAnswers,
|
|
789
1186
|
validateSchemaStructure
|
|
790
1187
|
});
|