@usefillo/core 0.8.0 → 0.10.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 +18 -13
- package/dist/index.d.ts +287 -6
- package/dist/index.js +224 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://fillo.so">
|
|
3
|
+
<img src="https://fillo.so/brand/readme-banner.png" alt="Fillo — forms inside your product, with your UI." />
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://fillo.so/docs">Docs</a> ·
|
|
9
|
+
<a href="https://fillo.so/guides">Guides</a> ·
|
|
10
|
+
<a href="https://fillo.so/examples">Examples</a> ·
|
|
11
|
+
<a href="https://fillo.so/changelog">Changelog</a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<a href="https://www.npmjs.com/package/@usefillo/core"><img src="https://img.shields.io/npm/v/@usefillo/core" alt="npm version" /></a>
|
|
16
|
+
<img src="https://img.shields.io/npm/l/@usefillo/core" alt="MIT license" />
|
|
17
|
+
</p>
|
|
2
18
|
|
|
3
19
|
The framework-agnostic core of [Fillo](https://fillo.so) — forms that render **inside your own product**, with your UI, no iframe.
|
|
4
20
|
|
|
5
|
-
### 📚 Full documentation → **[fillo.so/docs](https://fillo.so/docs)**
|
|
6
|
-
|
|
7
21
|
This package holds the shared foundation: the form schema, validation, the conditional-logic engine, response prefill/piping, and a JS client for provider-aware browser-direct uploads, with resume support where the storage provider offers it. It has **zero framework dependencies**.
|
|
8
22
|
|
|
9
23
|
Most apps don't install this directly — you install a renderer (**[@usefillo/react](https://www.npmjs.com/package/@usefillo/react)** or **[@usefillo/dom](https://www.npmjs.com/package/@usefillo/dom)**), which re-exports everything here you need for embedding. Reach for `@usefillo/core` when you're building your own renderer or working with forms on the server.
|
|
@@ -28,9 +42,7 @@ const form = defineForm({
|
|
|
28
42
|
|
|
29
43
|
Published renderer embeds can fetch by `formId` without a key. Use a client object for custom API origins, uploads, or submissions from your own renderer; add a publishable key only when syncing `defineForm()` schemas from code.
|
|
30
44
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
Key exports: `createClient` / `FilloClient`, `defineForm`, `validateResponse`, `validateField`, `visibleBlocks`, and the schema types (`FormSchema`, `Field`, `FieldKind`, `FormTheme`, `ResponseData`, …).
|
|
45
|
+
The full export surface — client methods, schema types, validation, conditional logic, appearance and localization contracts — is documented in the [API reference](https://fillo.so/docs/reference).
|
|
34
46
|
|
|
35
47
|
## Links
|
|
36
48
|
|
|
@@ -38,10 +50,3 @@ Key exports: `createClient` / `FilloClient`, `defineForm`, `validateResponse`, `
|
|
|
38
50
|
- **Website:** [fillo.so](https://fillo.so)
|
|
39
51
|
|
|
40
52
|
MIT licensed.
|
|
41
|
-
|
|
42
|
-
## Additional authoring surface
|
|
43
|
-
|
|
44
|
-
Framework-neutral pieces the renderers share: `defineForm` / `schemaFromJsx`
|
|
45
|
-
(the JSX compiler), `when()` condition builder, the `FilloAppearance` styling
|
|
46
|
-
contract (slots + data-attribute names), `FilloStrings` localization defaults,
|
|
47
|
-
and `createClient({ baseUrl })` for staging/self-hosted targets.
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,18 @@ interface Condition {
|
|
|
8
8
|
op: ConditionOp;
|
|
9
9
|
value?: string | number | boolean;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* A single conditional page-flow rule (P1 logic depth). When every condition in
|
|
13
|
+
* `when` matches (AND — the same evaluator as `visibleIf`), navigation leaves
|
|
14
|
+
* the current page for `to`: another page's id, or the literal `"end"` to finish
|
|
15
|
+
* the form early. An empty `when` is an unconditional jump. Rules are evaluated
|
|
16
|
+
* top-to-bottom; the first match wins. See {@link FormPage.next}.
|
|
17
|
+
*/
|
|
18
|
+
interface JumpRule {
|
|
19
|
+
when: Condition[];
|
|
20
|
+
/** Target page id, or "end" to finish the form. */
|
|
21
|
+
to: string | "end";
|
|
22
|
+
}
|
|
11
23
|
type FieldKind = "short_text" | "long_text" | "email" | "url" | "phone" | "number" | "select" | "multi_select" | "dropdown" | "checkbox" | "rating" | "linear_scale" | "ranking" | "matrix" | "signature" | "date" | "file_upload" | "hidden" | "custom";
|
|
12
24
|
type ContentKind = "heading" | "paragraph" | "divider";
|
|
13
25
|
type BlockKind = FieldKind | ContentKind;
|
|
@@ -151,6 +163,10 @@ interface FormPage {
|
|
|
151
163
|
id: string;
|
|
152
164
|
title?: string;
|
|
153
165
|
blocks: Block[];
|
|
166
|
+
/** Conditional page flow (P1 logic depth). Rules are evaluated top-to-bottom;
|
|
167
|
+
* the first whose conditions all match decides the next step. No rule matches
|
|
168
|
+
* → default linear next page. Absent → today's linear behavior. */
|
|
169
|
+
next?: JumpRule[];
|
|
154
170
|
}
|
|
155
171
|
/**
|
|
156
172
|
* Limit repeat responses. Absent = no limit (submit as often as you like).
|
|
@@ -185,6 +201,22 @@ interface ResponseLimit {
|
|
|
185
201
|
*/
|
|
186
202
|
onRepeat: "keep" | "update";
|
|
187
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* Per-form submission-trust policy. Absent = accept everything (today's
|
|
206
|
+
* behavior). See docs/roadmap/07-submission-trust.md.
|
|
207
|
+
*/
|
|
208
|
+
interface TrustPolicy {
|
|
209
|
+
/** What to do with a submission whose respondent is NOT HMAC-verified.
|
|
210
|
+
* "allow" (default) accepts it normally; "quarantine" stores it withheld
|
|
211
|
+
* from every downstream consumer until an owner releases it. Declared-agent
|
|
212
|
+
* classes come in a later phase. */
|
|
213
|
+
unverified?: "allow" | "quarantine";
|
|
214
|
+
/** Require a human-verification challenge before accepting a submission.
|
|
215
|
+
* "off" (default) = no challenge; "turnstile" = a Cloudflare Turnstile widget
|
|
216
|
+
* that the SDK renders and the server verifies. Provider-agnostic on purpose.
|
|
217
|
+
* Independent of `unverified` — a form can require both. */
|
|
218
|
+
challenge?: "off" | "turnstile";
|
|
219
|
+
}
|
|
188
220
|
interface FormSettings {
|
|
189
221
|
/**
|
|
190
222
|
* Default "button": respondents submit with the footer button. "auto" hides
|
|
@@ -200,6 +232,9 @@ interface FormSettings {
|
|
|
200
232
|
/** Limit repeat responses (who counts as the same responder, and what a
|
|
201
233
|
* repeat does). Absent = no limit. See {@link ResponseLimit}. */
|
|
202
234
|
responseLimit?: ResponseLimit;
|
|
235
|
+
/** Per-form submission-trust policy. Absent = accept everything (today's
|
|
236
|
+
* behavior). See docs/roadmap/07-submission-trust.md. */
|
|
237
|
+
trust?: TrustPolicy;
|
|
203
238
|
/** Notify this address on every submission. */
|
|
204
239
|
notifyEmail?: string;
|
|
205
240
|
/** Send respondents a receipt (to the first answered email field). */
|
|
@@ -351,6 +386,13 @@ interface UploadSession {
|
|
|
351
386
|
file?: FileValue;
|
|
352
387
|
}
|
|
353
388
|
|
|
389
|
+
/**
|
|
390
|
+
* All conditions must hold (AND); no conditions = true. THE shared evaluator —
|
|
391
|
+
* both block visibility (`visibleIf`) and page jumps (`JumpRule.when`) run
|
|
392
|
+
* through this over the SAME whole-form fixpoint resolver, so a jump gated by a
|
|
393
|
+
* logic-hidden controller field behaves identically to a visibility rule.
|
|
394
|
+
*/
|
|
395
|
+
declare function conditionsMet(conds: Condition[], resolve: (fieldId: string) => FieldValue): boolean;
|
|
354
396
|
/** All conditions must hold (AND). No conditions = visible. */
|
|
355
397
|
declare function isBlockVisible(block: Block, data: ResponseData): boolean;
|
|
356
398
|
declare function visibleBlocks(page: FormPage, data: ResponseData): Block[];
|
|
@@ -378,8 +420,57 @@ declare function allFields(form: FormSchema): Field[];
|
|
|
378
420
|
* per-visitor key and the server's per-person dedup so both scope identically.
|
|
379
421
|
*/
|
|
380
422
|
declare function responseScopeValue(settings: FormSettings, data: ResponseData): string | null;
|
|
381
|
-
/** Fields currently visible given the response data
|
|
423
|
+
/** Fields currently visible given the response data. */
|
|
382
424
|
declare function visibleFields(form: FormSchema, data: ResponseData): Field[];
|
|
425
|
+
/** Where navigation goes when leaving a page. */
|
|
426
|
+
type NextPage = {
|
|
427
|
+
to: string;
|
|
428
|
+
} | {
|
|
429
|
+
end: true;
|
|
430
|
+
} | {
|
|
431
|
+
linear: true;
|
|
432
|
+
};
|
|
433
|
+
/**
|
|
434
|
+
* Evaluate a page's `next` jump rules top-to-bottom against the current data;
|
|
435
|
+
* the first whose conditions all match decides the step ("end" → finish the
|
|
436
|
+
* form). No rule (or no `next`) → the default linear next page. THE single
|
|
437
|
+
* function the client renderer, the server validator, and the funnel share so
|
|
438
|
+
* they always agree on flow.
|
|
439
|
+
*/
|
|
440
|
+
declare function resolveNextPage(form: FormSchema, currentPageId: string, data: ResponseData): NextPage;
|
|
441
|
+
/**
|
|
442
|
+
* The ORDERED list of reachable page ids for the given data: walk from
|
|
443
|
+
* `pages[0]` following `resolveNextPage` (linear → next index; jump → target id;
|
|
444
|
+
* end → stop). A rule that points backward could loop, so stop on the first
|
|
445
|
+
* revisit and cap the walk at `pages.length` steps. THE single ordered engine
|
|
446
|
+
* the client renderer, the server validator, and navigation all agree through —
|
|
447
|
+
* a no-jump form yields `[pages[0].id, …, pages[N].id]`, so everything built on
|
|
448
|
+
* it reduces to today's linear behavior. {@link reachablePageIds} is the set of
|
|
449
|
+
* this exact walk (one walk, no divergence).
|
|
450
|
+
*/
|
|
451
|
+
declare function reachablePageSequence(form: FormSchema, data: ResponseData): string[];
|
|
452
|
+
/**
|
|
453
|
+
* The page ids reachable for the given data. The unordered set of
|
|
454
|
+
* {@link reachablePageSequence} — one shared walk keeps the ordered navigation
|
|
455
|
+
* and the reachability the validator uses provably in agreement. THE function
|
|
456
|
+
* the client renderer and server validator both use to decide which pages/fields
|
|
457
|
+
* are in play.
|
|
458
|
+
*/
|
|
459
|
+
declare function reachablePageIds(form: FormSchema, data: ResponseData): Set<string>;
|
|
460
|
+
/**
|
|
461
|
+
* Whether `pageId` is terminal for the given data — pressing the footer button
|
|
462
|
+
* there submits rather than advancing. True when a matched jump rule resolves to
|
|
463
|
+
* "end", or when the page is the LAST element of the reachable sequence (the
|
|
464
|
+
* last reachable page, including a cycle broken at its revisit, so a backward
|
|
465
|
+
* jump can never loop — the pre-revisit page becomes terminal and Submit
|
|
466
|
+
* appears). For a no-jump form this is exactly "the last page".
|
|
467
|
+
*/
|
|
468
|
+
declare function isTerminalPage(form: FormSchema, pageId: string, data: ResponseData): boolean;
|
|
469
|
+
/** Reachable field ids: fields on reachable pages, intersected with visibility. */
|
|
470
|
+
declare function reachableFieldIds(form: FormSchema, data: ResponseData): Set<string>;
|
|
471
|
+
/** Fields that are both reachable AND visible — the set that gets validated and
|
|
472
|
+
* kept on submit. Equals {@link visibleFields} for a form with no jumps. */
|
|
473
|
+
declare function reachableFields(form: FormSchema, data: ResponseData): Field[];
|
|
383
474
|
|
|
384
475
|
/** Validate a single answered value for a field. Returns an error message or null. */
|
|
385
476
|
declare function validateField(field: Field, value: FieldValue): string | null;
|
|
@@ -387,12 +478,18 @@ interface ValidationResult {
|
|
|
387
478
|
ok: boolean;
|
|
388
479
|
/** fieldId -> message for every failing field. */
|
|
389
480
|
errors: Record<string, string>;
|
|
390
|
-
/** Data trimmed to the fields that are visible (
|
|
481
|
+
/** Data trimmed to the fields that are reachable + visible (answers to fields
|
|
482
|
+
* hidden by logic OR on pages skipped by a jump/early-end are dropped). */
|
|
391
483
|
data: ResponseData;
|
|
392
484
|
}
|
|
393
485
|
/**
|
|
394
|
-
* Validate a full submission against the schema. Only currently-
|
|
395
|
-
* are validated and kept — answers to fields hidden by logic
|
|
486
|
+
* Validate a full submission against the schema. Only reachable + currently-
|
|
487
|
+
* visible fields are validated and kept — answers to fields hidden by logic, or
|
|
488
|
+
* on pages a page-jump/early-end skipped, are discarded. A legitimately
|
|
489
|
+
* early-ended submission therefore does NOT 422 on a skipped page's required
|
|
490
|
+
* field. Reachability is computed by the SAME shared engine the client renderer
|
|
491
|
+
* navigates with, so render and validate always agree. A form with no jumps has
|
|
492
|
+
* every page reachable, so this validates exactly as before.
|
|
396
493
|
*/
|
|
397
494
|
declare function validateResponse(form: FormSchema, data: ResponseData): ValidationResult;
|
|
398
495
|
|
|
@@ -409,6 +506,14 @@ declare const FILLO_SDK_VERSION: string;
|
|
|
409
506
|
* breaks are gated separately by FILLO_SCHEMA_VERSION, so this floor stays low.
|
|
410
507
|
*/
|
|
411
508
|
declare const FILLO_MIN_SDK_VERSION = "0.4.0";
|
|
509
|
+
/**
|
|
510
|
+
* The floor served INSTEAD of FILLO_MIN_SDK_VERSION for challenge-enabled
|
|
511
|
+
* forms: the first release that ships the Turnstile widget. An older SDK passes
|
|
512
|
+
* the base floor but renders no widget, so the server would reject its every
|
|
513
|
+
* submit — the raised floor makes it fail fast with the clear "update
|
|
514
|
+
* @usefillo/*" error instead of a form that silently can't submit.
|
|
515
|
+
*/
|
|
516
|
+
declare const FILLO_CHALLENGE_MIN_SDK_VERSION = "0.9.0";
|
|
412
517
|
declare function normalizeSettings(value: unknown): FormSettings;
|
|
413
518
|
interface SchemaValidationResult {
|
|
414
519
|
ok: boolean;
|
|
@@ -578,6 +683,18 @@ interface FilloClientOptions {
|
|
|
578
683
|
baseUrl?: string;
|
|
579
684
|
fetch?: typeof fetch;
|
|
580
685
|
}
|
|
686
|
+
/**
|
|
687
|
+
* Public human-verification challenge config the SDK needs to render a widget.
|
|
688
|
+
* Delivered as a TOP-LEVEL field on the form GET (never inside the schema): the
|
|
689
|
+
* schema's trust policy is server-only and stripped, but the widget needs the
|
|
690
|
+
* PUBLIC site key. Injected server-side from Fillo's env — the SECRET key never
|
|
691
|
+
* leaves the server. Absent = no challenge (render nothing, load no script).
|
|
692
|
+
*/
|
|
693
|
+
interface ChallengeConfig {
|
|
694
|
+
provider: "turnstile";
|
|
695
|
+
/** Cloudflare Turnstile PUBLIC site key. Safe to ship to the browser. */
|
|
696
|
+
siteKey: string;
|
|
697
|
+
}
|
|
581
698
|
interface PublishedForm {
|
|
582
699
|
id: string;
|
|
583
700
|
slug: string;
|
|
@@ -591,8 +708,28 @@ interface PublishedForm {
|
|
|
591
708
|
theme: FormTheme | null;
|
|
592
709
|
/** True when the server says the form cannot accept responses. */
|
|
593
710
|
closed?: boolean;
|
|
711
|
+
/**
|
|
712
|
+
* Whether a submission would be accepted right now. Absent on older servers
|
|
713
|
+
* — renderers must then keep today's behavior. When false, the default
|
|
714
|
+
* renderers show the not-open overlay instead of a fillable form.
|
|
715
|
+
*/
|
|
716
|
+
accepting?: boolean;
|
|
717
|
+
/**
|
|
718
|
+
* Companion to `accepting` — present only when it is false.
|
|
719
|
+
* `draft`: not published yet; `expired`/`capped`: the unclaimed preview
|
|
720
|
+
* workspace hit its time window or response cap. Storage reasons may be
|
|
721
|
+
* returned for drafts or by older servers; published upload readiness is
|
|
722
|
+
* represented independently by `uploadsAvailable`.
|
|
723
|
+
*/
|
|
724
|
+
acceptingReason?: "draft" | "expired" | "capped" | "storage_required" | "storage_full";
|
|
725
|
+
/** Whether new file uploads can start right now. Absent on older servers is
|
|
726
|
+
* treated as available; this never changes whether ordinary answers submit. */
|
|
727
|
+
uploadsAvailable?: boolean;
|
|
594
728
|
/** Workspace branding state — absent means show the badge (default). */
|
|
595
729
|
branding?: FormBranding;
|
|
730
|
+
/** Human-verification challenge to render before submit, when the form
|
|
731
|
+
* requires one. Absent = no challenge. Carries only the PUBLIC site key. */
|
|
732
|
+
challenge?: ChallengeConfig;
|
|
596
733
|
}
|
|
597
734
|
type SubmitResult = {
|
|
598
735
|
ok: true;
|
|
@@ -638,6 +775,13 @@ interface SubmitMeta {
|
|
|
638
775
|
* stable user/account id.
|
|
639
776
|
*/
|
|
640
777
|
respondent?: FilloRespondent;
|
|
778
|
+
/**
|
|
779
|
+
* Human-verification challenge token (e.g. from the Cloudflare Turnstile
|
|
780
|
+
* widget). Present only when the form requires a challenge; the server
|
|
781
|
+
* verifies it and rejects the submit if it is missing or invalid. Never a
|
|
782
|
+
* secret — it is a single-use, server-verifiable proof of the widget solve.
|
|
783
|
+
*/
|
|
784
|
+
challengeToken?: string;
|
|
641
785
|
}
|
|
642
786
|
/**
|
|
643
787
|
* The host app's account context for the person filling the form. Passed as
|
|
@@ -726,10 +870,32 @@ interface SyncFormResult {
|
|
|
726
870
|
formId: string;
|
|
727
871
|
slug: string;
|
|
728
872
|
branding?: FormBranding;
|
|
873
|
+
/** Human-verification challenge to render before submit, when the LIVE form
|
|
874
|
+
* requires one (staged changes don't gate until published). Absent = no
|
|
875
|
+
* challenge. Carries only the PUBLIC site key. */
|
|
876
|
+
challenge?: ChallengeConfig;
|
|
729
877
|
/** Lifecycle on newer servers: a draft can't accept public responses yet. */
|
|
730
878
|
status?: "draft" | "published";
|
|
731
879
|
/** Changes were staged as a draft for a human to publish. */
|
|
732
880
|
staged?: boolean;
|
|
881
|
+
/**
|
|
882
|
+
* Whether a submission would be accepted right now. Absent on older servers
|
|
883
|
+
* — renderers must then keep today's behavior. When false, the default
|
|
884
|
+
* renderers show the not-open overlay instead of a fillable form.
|
|
885
|
+
*/
|
|
886
|
+
accepting?: boolean;
|
|
887
|
+
/**
|
|
888
|
+
* Companion to `accepting` — present only when it is false.
|
|
889
|
+
* `draft`: not published yet; `expired`/`capped`: the unclaimed preview
|
|
890
|
+
* workspace hit its time window or response cap; `storage_required`: the
|
|
891
|
+
* form needs a connected storage destination before it can go live;
|
|
892
|
+
* `storage_full`: Fillo's temporary upload allowance is exhausted.
|
|
893
|
+
*/
|
|
894
|
+
acceptingReason?: "draft" | "expired" | "capped" | "storage_required" | "storage_full";
|
|
895
|
+
/** Whether new file uploads can start right now. This is independent from
|
|
896
|
+
* response acceptance so a completed file can still be submitted after the
|
|
897
|
+
* workspace reaches its upload cap. Absent on older servers means available. */
|
|
898
|
+
uploadsAvailable?: boolean;
|
|
733
899
|
/**
|
|
734
900
|
* Server-authoritative live snapshot. Present when the incoming code schema
|
|
735
901
|
* is not the version respondents may submit against yet.
|
|
@@ -741,7 +907,23 @@ interface SyncFormResult {
|
|
|
741
907
|
code: string;
|
|
742
908
|
message: string;
|
|
743
909
|
};
|
|
910
|
+
/**
|
|
911
|
+
* Human-readable storage heads-up for the form owner. This can be advisory
|
|
912
|
+
* while uploads and responses remain available; never use it to gate UI.
|
|
913
|
+
*/
|
|
744
914
|
warning?: string;
|
|
915
|
+
/**
|
|
916
|
+
* Machine-readable owner advisory for `warning`. Hard unavailability uses
|
|
917
|
+
* `"storage_required"`; transit threshold advisories use distinct codes.
|
|
918
|
+
* Point the human at `warningUrl`; use `uploadsAvailable`, not this field,
|
|
919
|
+
* to gate new file controls.
|
|
920
|
+
*/
|
|
921
|
+
warningCode?: string;
|
|
922
|
+
/**
|
|
923
|
+
* Absolute dashboard URL where a human connects a storage destination.
|
|
924
|
+
* Present whenever `warningCode` is.
|
|
925
|
+
*/
|
|
926
|
+
warningUrl?: string;
|
|
745
927
|
}
|
|
746
928
|
declare class FilloClient {
|
|
747
929
|
/** Server origin this client targets, normalized (no trailing slash). */
|
|
@@ -939,6 +1121,15 @@ interface FormControllerOptions {
|
|
|
939
1121
|
* runs; failure sets `submitError` and the respondent can retry.
|
|
940
1122
|
*/
|
|
941
1123
|
resolveFormId?: () => Promise<string>;
|
|
1124
|
+
/**
|
|
1125
|
+
* Surface the REAL {@link resolveFormId} failure (message + machine code)
|
|
1126
|
+
* in `submitError` instead of the respondent-safe "This form is
|
|
1127
|
+
* unavailable." fallback. Dev chrome only: renderers set it from the same
|
|
1128
|
+
* gate as their other developer surfaces (preview prop / dev environment),
|
|
1129
|
+
* so production visitors never see integration details such as keys,
|
|
1130
|
+
* origins, or deployment commands.
|
|
1131
|
+
*/
|
|
1132
|
+
verboseResolutionErrors?: boolean;
|
|
942
1133
|
/**
|
|
943
1134
|
* Host-app account context (identify()): who is filling this form, by your
|
|
944
1135
|
* own user id. Sent with the submission and recorded as an unverified
|
|
@@ -946,6 +1137,24 @@ interface FormControllerOptions {
|
|
|
946
1137
|
* the respondent can do.
|
|
947
1138
|
*/
|
|
948
1139
|
respondent?: FilloRespondent;
|
|
1140
|
+
/**
|
|
1141
|
+
* True when the form requires a human-verification challenge (Turnstile).
|
|
1142
|
+
* When set, submit refuses to send until a token is available and always
|
|
1143
|
+
* attaches the token from {@link getChallengeToken}. The server is the real
|
|
1144
|
+
* gate — this only avoids firing a submit the server would reject.
|
|
1145
|
+
*/
|
|
1146
|
+
challengeRequired?: boolean;
|
|
1147
|
+
/**
|
|
1148
|
+
* Read the current challenge token (from the rendered widget) at submit time.
|
|
1149
|
+
* Returns undefined until the challenge is solved. Read lazily so an expired
|
|
1150
|
+
* token that was refreshed just before submit is picked up fresh.
|
|
1151
|
+
*/
|
|
1152
|
+
getChallengeToken?: () => string | undefined;
|
|
1153
|
+
/**
|
|
1154
|
+
* The server rejected the submission's challenge (stale/replayed/invalid
|
|
1155
|
+
* token). The renderer resets the widget so the human can solve a fresh one.
|
|
1156
|
+
*/
|
|
1157
|
+
onChallengeFailed?: () => void;
|
|
949
1158
|
}
|
|
950
1159
|
interface FormControllerState {
|
|
951
1160
|
data: ResponseData;
|
|
@@ -1064,6 +1273,8 @@ interface AutoSubmitContext {
|
|
|
1064
1273
|
form: FormSchema;
|
|
1065
1274
|
data: ResponseData;
|
|
1066
1275
|
status: FormStatus;
|
|
1276
|
+
/** Terminal-aware last-page flag from the controller. Advisory here —
|
|
1277
|
+
* shouldAutoSubmit re-derives terminal from the RESULTING data (see below). */
|
|
1067
1278
|
isLastPage: boolean;
|
|
1068
1279
|
uploading: boolean;
|
|
1069
1280
|
}
|
|
@@ -1075,7 +1286,7 @@ declare function shouldAutoSubmit(field: Field, value: FieldValue, ctx: AutoSubm
|
|
|
1075
1286
|
* (Tailwind `data-[invalid]:…`) can react to it. Slot names and attribute
|
|
1076
1287
|
* names are public API — renames are breaking; additions are minors.
|
|
1077
1288
|
*/
|
|
1078
|
-
declare const FILLO_SLOTS: readonly ["root", "header", "title", "description", "pageTitle", "progress", "progressFill", "blocks", "field", "label", "fieldDescription", "control", "options", "option", "optionLabel", "error", "footer", "button", "success", "resume"];
|
|
1289
|
+
declare const FILLO_SLOTS: readonly ["root", "header", "title", "description", "pageTitle", "progress", "progressFill", "blocks", "field", "label", "fieldDescription", "control", "options", "option", "optionLabel", "error", "footer", "button", "success", "resume", "turnstile"];
|
|
1079
1290
|
type FilloSlot = (typeof FILLO_SLOTS)[number];
|
|
1080
1291
|
/** data-* names emitted alongside the stable fillo-* classes. */
|
|
1081
1292
|
declare const FILLO_DATA_ATTRS: {
|
|
@@ -1199,6 +1410,14 @@ interface FilloStrings {
|
|
|
1199
1410
|
successMessage: string;
|
|
1200
1411
|
closed: string;
|
|
1201
1412
|
notLive: string;
|
|
1413
|
+
/** Title of the not-open overlay card (a draft/storage-blocked form rendered
|
|
1414
|
+
* in production shows the real form blurred beneath it). */
|
|
1415
|
+
notOpenTitle: string;
|
|
1416
|
+
/** Body of the not-open overlay card. */
|
|
1417
|
+
notOpenBody: string;
|
|
1418
|
+
/** Title of the closed-flavor overlay card (expired/capped workspace);
|
|
1419
|
+
* the body reuses `closed`. */
|
|
1420
|
+
closedTitle: string;
|
|
1202
1421
|
/** Fallback when a submit fails without a server message. */
|
|
1203
1422
|
submitFailed: string;
|
|
1204
1423
|
loadFailedNotFound: string;
|
|
@@ -1211,6 +1430,8 @@ interface FilloStrings {
|
|
|
1211
1430
|
editNotice: string;
|
|
1212
1431
|
/** The discard action next to the resume notice. */
|
|
1213
1432
|
resumeStartOver: string;
|
|
1433
|
+
/** Shown when the human-verification widget can't load (script blocked). */
|
|
1434
|
+
challengeUnavailable: string;
|
|
1214
1435
|
}
|
|
1215
1436
|
declare const DEFAULT_STRINGS: FilloStrings;
|
|
1216
1437
|
/**
|
|
@@ -1231,6 +1452,8 @@ interface FilloFieldStrings {
|
|
|
1231
1452
|
uploadRetry: string;
|
|
1232
1453
|
/** Dropzone copy when uploads can't run (no client / preview). */
|
|
1233
1454
|
uploadsDisabled: string;
|
|
1455
|
+
/** Dropzone copy when the server temporarily refuses new file sessions. */
|
|
1456
|
+
uploadsUnavailable: string;
|
|
1234
1457
|
/** An upload attempt failed with no actionable server message. */
|
|
1235
1458
|
uploadFailed: string;
|
|
1236
1459
|
/** Dropzone call to action; `multiple` is true when several files are allowed. */
|
|
@@ -1258,10 +1481,28 @@ interface SyncedForm {
|
|
|
1258
1481
|
formId: string;
|
|
1259
1482
|
slug: string;
|
|
1260
1483
|
branding?: FormBranding;
|
|
1484
|
+
/** Human-verification challenge to render before submit, when the LIVE form
|
|
1485
|
+
* requires one (staged changes don't gate until published). Absent = no
|
|
1486
|
+
* challenge. Carries only the PUBLIC site key. */
|
|
1487
|
+
challenge?: ChallengeConfig;
|
|
1261
1488
|
/** Lifecycle on newer servers: a draft can't accept public responses yet. */
|
|
1262
1489
|
status?: "draft" | "published";
|
|
1263
1490
|
/** Changes were staged as a draft for a human to publish. */
|
|
1264
1491
|
staged?: boolean;
|
|
1492
|
+
/** Whether a submission would be accepted right now. Absent on older
|
|
1493
|
+
* servers — renderers must then keep the status-based behavior. */
|
|
1494
|
+
accepting?: boolean;
|
|
1495
|
+
/**
|
|
1496
|
+
* Companion to `accepting` — present only when it is false. `draft`: not
|
|
1497
|
+
* published yet; `expired`/`capped`: the unclaimed preview workspace hit its
|
|
1498
|
+
* time window or response cap; `storage_required`: the form needs a
|
|
1499
|
+
* connected storage destination before it can go live; `storage_full`:
|
|
1500
|
+
* Fillo's temporary upload allowance is exhausted.
|
|
1501
|
+
*/
|
|
1502
|
+
acceptingReason?: "draft" | "expired" | "capped" | "storage_required" | "storage_full";
|
|
1503
|
+
/** Whether new file uploads can start right now. Independent from response
|
|
1504
|
+
* acceptance; absent on older servers means available. */
|
|
1505
|
+
uploadsAvailable?: boolean;
|
|
1265
1506
|
/** Server-authoritative live snapshot when local code is not live yet. */
|
|
1266
1507
|
resolvedSchema?: FormSchema;
|
|
1267
1508
|
resolvedTheme?: FormTheme | null;
|
|
@@ -1271,6 +1512,15 @@ interface SyncedForm {
|
|
|
1271
1512
|
message: string;
|
|
1272
1513
|
};
|
|
1273
1514
|
warning?: string;
|
|
1515
|
+
/**
|
|
1516
|
+
* Machine-readable owner advisory for `warning`. Hard unavailability uses
|
|
1517
|
+
* `"storage_required"`; advisory thresholds use distinct codes. Use
|
|
1518
|
+
* `uploadsAvailable`, not this field, to decide whether an upload may start.
|
|
1519
|
+
*/
|
|
1520
|
+
warningCode?: string;
|
|
1521
|
+
/** Absolute dashboard URL where a human connects a storage destination.
|
|
1522
|
+
* Present whenever `warningCode` is. */
|
|
1523
|
+
warningUrl?: string;
|
|
1274
1524
|
}
|
|
1275
1525
|
/**
|
|
1276
1526
|
* A form whose structure lives in user code. Development and explicit
|
|
@@ -1386,6 +1636,37 @@ interface WhenBuilder {
|
|
|
1386
1636
|
}
|
|
1387
1637
|
declare function when(fieldId: string): WhenBuilder;
|
|
1388
1638
|
|
|
1639
|
+
/**
|
|
1640
|
+
* The build-time half of the dev check: `NODE_ENV` alone, no hostname
|
|
1641
|
+
* inspection. It evaluates identically on the server and in the browser for
|
|
1642
|
+
* the same bundle, which is what SSR hydration needs — the server has no
|
|
1643
|
+
* `window`, so a hostname-aware check would disagree with the client's first
|
|
1644
|
+
* paint. Renderers use this as the server/hydration snapshot and upgrade to
|
|
1645
|
+
* {@link isLikelyDevEnv} after hydration.
|
|
1646
|
+
*/
|
|
1647
|
+
declare function isBuildTimeDevEnv(): boolean;
|
|
1648
|
+
/**
|
|
1649
|
+
* Whether this runtime looks like local development, so renderers can show
|
|
1650
|
+
* actionable dev surfaces (draft banner, missing-client warning, local schema
|
|
1651
|
+
* render) instead of the deliberately quiet production states.
|
|
1652
|
+
*
|
|
1653
|
+
* The build-time `NODE_ENV` signal alone misses real local setups: the
|
|
1654
|
+
* standalone `<script>` bundle has no `process` at all (so it always read as
|
|
1655
|
+
* production), `vite preview` / `next start` serve a production build on
|
|
1656
|
+
* localhost, and some bundlers never define `NODE_ENV`. So a browser whose
|
|
1657
|
+
* hostname is localhost/loopback also counts as development. Real
|
|
1658
|
+
* deployments keep production semantics because they serve from real
|
|
1659
|
+
* hostnames — see {@link isLocalHostname} for why mDNS `*.local` names and
|
|
1660
|
+
* private-LAN addresses are deliberately excluded.
|
|
1661
|
+
*
|
|
1662
|
+
* SSR-safe to CALL (with no `window`, only the `NODE_ENV` check applies), but
|
|
1663
|
+
* NOT hydration-safe for render output: the server pass can't see the page
|
|
1664
|
+
* hostname, so under `next start` on localhost it disagrees with the client.
|
|
1665
|
+
* Render paths should hydrate from {@link isBuildTimeDevEnv} and upgrade to
|
|
1666
|
+
* this check after hydration (see the React renderer's useIsDevEnv()).
|
|
1667
|
+
*/
|
|
1668
|
+
declare function isLikelyDevEnv(): boolean;
|
|
1669
|
+
|
|
1389
1670
|
/**
|
|
1390
1671
|
* Build initial response data from URL query parameters — Tally-style
|
|
1391
1672
|
* prefilling. Hidden fields read their configured paramName; every other
|
|
@@ -1430,4 +1711,4 @@ declare class Sha1 {
|
|
|
1430
1711
|
}
|
|
1431
1712
|
declare const sha1Base64: (bytes: Uint8Array) => string;
|
|
1432
1713
|
|
|
1433
|
-
export { type AutoSubmitContext, BLOCK_KIND_META, type BaseField, type Block, type BlockKind, CONTENT_KINDS, type CheckboxField, type ChoiceField, type CodeForm, type Condition, type ConditionOp, type ContentBlock, type ContentKind, type CreatedDraft, type CustomField, DEFAULT_FIELD_STRINGS, DEFAULT_STRINGS, DRAFT_KINDS, type DateField, type DividerBlock, FILLO_DATA_ATTRS, FILLO_MIN_SDK_VERSION, FILLO_SCHEMA_VERSION, FILLO_SDK_VERSION, FILLO_SLOTS, FILLO_THEME_VARS, type Field, type FieldKind, type FieldSpec, type FieldValue, type FileUploadField, type FileValue, type FilloAppearance, FilloClient, type FilloClientOptions, FilloError, type FilloFieldStrings, FilloJsxError, type FilloRendererStrings, type FilloRespondent, type FilloSlot, type FilloStrings, type FormBranding, type FormController, type FormControllerOptions, type FormControllerState, type FormDraftSpec, type FormPage, type FormSchema, type FormSettings, type FormStatus, type FormTheme, type HeadingBlock, type HiddenField, JSX_BLOCK_COMPONENTS, JSX_BLOCK_SPECS, type JsonValue, type JsxFormMeta, type LinearScaleField, type MatrixField, type NumberField, PHONE_COUNTRIES, PHONE_POPOVER_VIEWPORT_GAP, type ParagraphBlock, type ParsedPhone, type PhoneCountry, type PhoneField, type PhonePopoverPlacement, type ProvisionWorkspaceResult, type PublishedForm, REQUIRED_FIELD_MESSAGE, type RankingField, type RatingField, type ResponseData, type ResponseDraft, type ResponseLimit, type SchemaValidationResult, type SelectOption, Sha1, type SignatureField, type SlotClass, type SlotState, type SubmitMeta, type SubmitResult, type SyncFormResult, type SyncedForm, type TextField, type UploadFileOptions, type UploadProgress, type UploadSession, type UploadStatus, type UploadTransport, type ValidationResult, type WhenBuilder, allFields, assembleForm, codeFormFromJsx, contentHash, countryByDialCode, countryByIso, countryByTimeZone, createBlock, createClient, createEmptyForm, createFormController, createId, defineForm, digitsOnly, flagEmoji, formSchemasEqual, formatAnswer, formatNational, isAutoSubmitBlock, isBlockVisible, isCodeForm, isField, isFilloError, isPossiblePhone, needsExplicitSubmit, normalizeFormSchema, normalizeFormTheme, normalizeSettings, parsePhone, pipeBlock, positionPhonePopover, prefillFromParams, provisionWorkspace, resolveSlotClass, resolveStrings, resolveText, responseScopeValue, schemaFromJsx, sha1Base64, shouldAutoSubmit, slotClass, syncCodeForm, toE164, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields, visiblePageBlocks, when };
|
|
1714
|
+
export { type AutoSubmitContext, BLOCK_KIND_META, type BaseField, type Block, type BlockKind, CONTENT_KINDS, type ChallengeConfig, type CheckboxField, type ChoiceField, type CodeForm, type Condition, type ConditionOp, type ContentBlock, type ContentKind, type CreatedDraft, type CustomField, DEFAULT_FIELD_STRINGS, DEFAULT_STRINGS, DRAFT_KINDS, type DateField, type DividerBlock, FILLO_CHALLENGE_MIN_SDK_VERSION, FILLO_DATA_ATTRS, FILLO_MIN_SDK_VERSION, FILLO_SCHEMA_VERSION, FILLO_SDK_VERSION, FILLO_SLOTS, FILLO_THEME_VARS, type Field, type FieldKind, type FieldSpec, type FieldValue, type FileUploadField, type FileValue, type FilloAppearance, FilloClient, type FilloClientOptions, FilloError, type FilloFieldStrings, FilloJsxError, type FilloRendererStrings, type FilloRespondent, type FilloSlot, type FilloStrings, type FormBranding, type FormController, type FormControllerOptions, type FormControllerState, type FormDraftSpec, type FormPage, type FormSchema, type FormSettings, type FormStatus, type FormTheme, type HeadingBlock, type HiddenField, JSX_BLOCK_COMPONENTS, JSX_BLOCK_SPECS, type JsonValue, type JsxFormMeta, type JumpRule, type LinearScaleField, type MatrixField, type NextPage, type NumberField, PHONE_COUNTRIES, PHONE_POPOVER_VIEWPORT_GAP, type ParagraphBlock, type ParsedPhone, type PhoneCountry, type PhoneField, type PhonePopoverPlacement, type ProvisionWorkspaceResult, type PublishedForm, REQUIRED_FIELD_MESSAGE, type RankingField, type RatingField, type ResponseData, type ResponseDraft, type ResponseLimit, type SchemaValidationResult, type SelectOption, Sha1, type SignatureField, type SlotClass, type SlotState, type SubmitMeta, type SubmitResult, type SyncFormResult, type SyncedForm, type TextField, type TrustPolicy, type UploadFileOptions, type UploadProgress, type UploadSession, type UploadStatus, type UploadTransport, type ValidationResult, type WhenBuilder, allFields, assembleForm, codeFormFromJsx, conditionsMet, contentHash, countryByDialCode, countryByIso, countryByTimeZone, createBlock, createClient, createEmptyForm, createFormController, createId, defineForm, digitsOnly, flagEmoji, formSchemasEqual, formatAnswer, formatNational, isAutoSubmitBlock, isBlockVisible, isBuildTimeDevEnv, isCodeForm, isField, isFilloError, isLikelyDevEnv, isPossiblePhone, isTerminalPage, needsExplicitSubmit, normalizeFormSchema, normalizeFormTheme, normalizeSettings, parsePhone, pipeBlock, positionPhonePopover, prefillFromParams, provisionWorkspace, reachableFieldIds, reachableFields, reachablePageIds, reachablePageSequence, resolveNextPage, resolveSlotClass, resolveStrings, resolveText, responseScopeValue, schemaFromJsx, sha1Base64, shouldAutoSubmit, slotClass, syncCodeForm, toE164, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields, visiblePageBlocks, when };
|
package/dist/index.js
CHANGED
|
@@ -54,9 +54,13 @@ function evalCondition(cond, resolve) {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
+
function conditionsMet(conds, resolve) {
|
|
58
|
+
if (conds.length === 0) return true;
|
|
59
|
+
return conds.every((cond) => evalCondition(cond, resolve));
|
|
60
|
+
}
|
|
57
61
|
function blockVisibleWith(block, resolve) {
|
|
58
62
|
if (!block.visibleIf || block.visibleIf.length === 0) return true;
|
|
59
|
-
return block.visibleIf
|
|
63
|
+
return conditionsMet(block.visibleIf, resolve);
|
|
60
64
|
}
|
|
61
65
|
function makeResolver(data, scoped, visible) {
|
|
62
66
|
return (fieldId) => scoped.has(fieldId) && !visible.has(fieldId) ? void 0 : data[fieldId];
|
|
@@ -108,6 +112,70 @@ function visibleFields(form, data) {
|
|
|
108
112
|
const visible = visibleFieldIds(fields, data);
|
|
109
113
|
return fields.filter((f) => visible.has(f.id));
|
|
110
114
|
}
|
|
115
|
+
function formResolver(form, data) {
|
|
116
|
+
const fields = allFields(form);
|
|
117
|
+
const scoped = new Set(fields.map((f) => f.id));
|
|
118
|
+
return makeResolver(data, scoped, visibleFieldIds(fields, data));
|
|
119
|
+
}
|
|
120
|
+
function resolveNextPage(form, currentPageId, data) {
|
|
121
|
+
const page = form.pages.find((p) => p.id === currentPageId);
|
|
122
|
+
const rules = page?.next;
|
|
123
|
+
if (!rules || rules.length === 0) return { linear: true };
|
|
124
|
+
const resolve = formResolver(form, data);
|
|
125
|
+
for (const rule of rules) {
|
|
126
|
+
if (conditionsMet(rule.when, resolve)) {
|
|
127
|
+
return rule.to === "end" ? { end: true } : { to: rule.to };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { linear: true };
|
|
131
|
+
}
|
|
132
|
+
function reachablePageSequence(form, data) {
|
|
133
|
+
const seq = [];
|
|
134
|
+
if (form.pages.length === 0) return seq;
|
|
135
|
+
const seen = /* @__PURE__ */ new Set();
|
|
136
|
+
let index = 0;
|
|
137
|
+
for (let steps = 0; steps <= form.pages.length; steps++) {
|
|
138
|
+
const page = form.pages[index];
|
|
139
|
+
if (!page || seen.has(page.id)) break;
|
|
140
|
+
seen.add(page.id);
|
|
141
|
+
seq.push(page.id);
|
|
142
|
+
const nav = resolveNextPage(form, page.id, data);
|
|
143
|
+
if ("end" in nav) break;
|
|
144
|
+
if ("to" in nav) {
|
|
145
|
+
const target = form.pages.findIndex((p) => p.id === nav.to);
|
|
146
|
+
if (target < 0) break;
|
|
147
|
+
index = target;
|
|
148
|
+
} else {
|
|
149
|
+
index += 1;
|
|
150
|
+
if (index >= form.pages.length) break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return seq;
|
|
154
|
+
}
|
|
155
|
+
function reachablePageIds(form, data) {
|
|
156
|
+
return new Set(reachablePageSequence(form, data));
|
|
157
|
+
}
|
|
158
|
+
function isTerminalPage(form, pageId, data) {
|
|
159
|
+
if ("end" in resolveNextPage(form, pageId, data)) return true;
|
|
160
|
+
const seq = reachablePageSequence(form, data);
|
|
161
|
+
return seq.length > 0 && seq[seq.length - 1] === pageId;
|
|
162
|
+
}
|
|
163
|
+
function reachableFieldIds(form, data) {
|
|
164
|
+
const reachablePages = reachablePageIds(form, data);
|
|
165
|
+
const visible = visibleFieldIds(allFields(form), data);
|
|
166
|
+
const ids = /* @__PURE__ */ new Set();
|
|
167
|
+
for (const page of form.pages) {
|
|
168
|
+
if (!reachablePages.has(page.id)) continue;
|
|
169
|
+
for (const block of page.blocks) {
|
|
170
|
+
if (isField(block) && visible.has(block.id)) ids.add(block.id);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return ids;
|
|
174
|
+
}
|
|
175
|
+
function reachableFields(form, data) {
|
|
176
|
+
const ids = reachableFieldIds(form, data);
|
|
177
|
+
return allFields(form).filter((f) => ids.has(f.id));
|
|
178
|
+
}
|
|
111
179
|
|
|
112
180
|
// src/validation.ts
|
|
113
181
|
import * as z from "zod/mini";
|
|
@@ -433,6 +501,9 @@ var DEFAULT_STRINGS = {
|
|
|
433
501
|
successMessage: "Your response has been recorded.",
|
|
434
502
|
closed: "This form is no longer accepting responses.",
|
|
435
503
|
notLive: "This form isn't live yet.",
|
|
504
|
+
notOpenTitle: "Not open just yet",
|
|
505
|
+
notOpenBody: "This form is still being set up. Check back soon.",
|
|
506
|
+
closedTitle: "Responses are closed",
|
|
436
507
|
submitFailed: "This form can't submit right now. Please try again in a moment.",
|
|
437
508
|
loadFailedNotFound: "Form not found \u2014 check the form id and that it's published.",
|
|
438
509
|
loadFailedNetwork: "Couldn't reach the server \u2014 check your connection or CORS.",
|
|
@@ -440,7 +511,8 @@ var DEFAULT_STRINGS = {
|
|
|
440
511
|
renderFailed: "This form could not be rendered.",
|
|
441
512
|
resumeNotice: "Picked up where you left off.",
|
|
442
513
|
editNotice: "You're updating your earlier response.",
|
|
443
|
-
resumeStartOver: "Start over"
|
|
514
|
+
resumeStartOver: "Start over",
|
|
515
|
+
challengeUnavailable: "The verification check couldn't load. Refresh the page, or check that challenges.cloudflare.com isn't blocked."
|
|
444
516
|
};
|
|
445
517
|
var DEFAULT_FIELD_STRINGS = {
|
|
446
518
|
required: REQUIRED_FIELD_MESSAGE,
|
|
@@ -448,6 +520,7 @@ var DEFAULT_FIELD_STRINGS = {
|
|
|
448
520
|
alreadyAnswered: "You've already answered this form.",
|
|
449
521
|
uploadRetry: "Retry",
|
|
450
522
|
uploadsDisabled: "Uploads are disabled in preview",
|
|
523
|
+
uploadsUnavailable: "Uploads are temporarily unavailable",
|
|
451
524
|
uploadFailed: "Upload failed \u2014 try again",
|
|
452
525
|
dropzoneTitle: (multiple) => `Drop ${multiple ? "files" : "a file"} here or click to browse`,
|
|
453
526
|
dropzoneHint: (maxMb) => `Up to ${maxMb} MB per file`,
|
|
@@ -617,7 +690,7 @@ function validateField(field2, value) {
|
|
|
617
690
|
}
|
|
618
691
|
}
|
|
619
692
|
function validateResponse(form, data) {
|
|
620
|
-
const fields =
|
|
693
|
+
const fields = reachableFields(form, data);
|
|
621
694
|
const errors = {};
|
|
622
695
|
const cleaned = {};
|
|
623
696
|
for (const field2 of fields) {
|
|
@@ -688,7 +761,11 @@ var blockSchema = z2.looseObject({
|
|
|
688
761
|
var pageSchema = z2.object({
|
|
689
762
|
id: idSchema,
|
|
690
763
|
title: z2.optional(z2.string().check(z2.maxLength(500))),
|
|
691
|
-
blocks: z2.array(blockSchema).check(z2.maxLength(500))
|
|
764
|
+
blocks: z2.array(blockSchema).check(z2.maxLength(500)),
|
|
765
|
+
// Optional conditional page flow. Kept as unknown here (this schema is strict,
|
|
766
|
+
// so it would otherwise be dropped at parse) and normalized by hand below once
|
|
767
|
+
// the page-id set is known — a jump to a missing page is dropped, never kept.
|
|
768
|
+
next: z2.optional(z2.unknown())
|
|
692
769
|
});
|
|
693
770
|
var schemaShape = z2.object({
|
|
694
771
|
version: z2.literal(1),
|
|
@@ -699,8 +776,9 @@ var schemaShape = z2.object({
|
|
|
699
776
|
});
|
|
700
777
|
var MAX_SCHEMA_VERSION = 1;
|
|
701
778
|
var FILLO_SCHEMA_VERSION = 1;
|
|
702
|
-
var FILLO_SDK_VERSION = true ? "0.
|
|
779
|
+
var FILLO_SDK_VERSION = true ? "0.10.0" : "0.0.0-dev";
|
|
703
780
|
var FILLO_MIN_SDK_VERSION = "0.4.0";
|
|
781
|
+
var FILLO_CHALLENGE_MIN_SDK_VERSION = "0.9.0";
|
|
704
782
|
function str(value, max, fallback = "") {
|
|
705
783
|
return typeof value === "string" ? value.trim().slice(0, max) : fallback;
|
|
706
784
|
}
|
|
@@ -755,6 +833,23 @@ function conditions(value) {
|
|
|
755
833
|
});
|
|
756
834
|
return normalized.length ? normalized : void 0;
|
|
757
835
|
}
|
|
836
|
+
function jumpRules(value, pageIds, sourceId, allowedFieldIds) {
|
|
837
|
+
if (!Array.isArray(value)) return void 0;
|
|
838
|
+
const rules = value.slice(0, 50).flatMap((item) => {
|
|
839
|
+
if (!item || typeof item !== "object") return [];
|
|
840
|
+
const rec = item;
|
|
841
|
+
const to = typeof rec.to === "string" ? rec.to : void 0;
|
|
842
|
+
if (!to || to !== "end" && !pageIds.has(to)) return [];
|
|
843
|
+
if (to === sourceId) return [];
|
|
844
|
+
const rawWhen = rec.when;
|
|
845
|
+
if (rawWhen !== void 0 && !Array.isArray(rawWhen)) return [];
|
|
846
|
+
const when2 = conditions(rawWhen);
|
|
847
|
+
if (Array.isArray(rawWhen) && rawWhen.length > 0 && when2 === void 0) return [];
|
|
848
|
+
if (when2 && when2.some((c) => !allowedFieldIds.has(c.fieldId))) return [];
|
|
849
|
+
return [{ when: when2 ?? [], to }];
|
|
850
|
+
});
|
|
851
|
+
return rules.length ? rules : void 0;
|
|
852
|
+
}
|
|
758
853
|
function baseBlock(rec) {
|
|
759
854
|
return {
|
|
760
855
|
id: str(rec.id, 128),
|
|
@@ -798,6 +893,17 @@ function normalizeResponseLimit(value) {
|
|
|
798
893
|
onRepeat
|
|
799
894
|
};
|
|
800
895
|
}
|
|
896
|
+
function normalizeTrust(value) {
|
|
897
|
+
if (!value || typeof value !== "object") return void 0;
|
|
898
|
+
const rec = value;
|
|
899
|
+
const unverified = rec.unverified === "allow" || rec.unverified === "quarantine" ? rec.unverified : void 0;
|
|
900
|
+
const challenge = rec.challenge === "turnstile" ? "turnstile" : void 0;
|
|
901
|
+
if (!unverified && !challenge) return void 0;
|
|
902
|
+
return {
|
|
903
|
+
...unverified ? { unverified } : {},
|
|
904
|
+
...challenge ? { challenge } : {}
|
|
905
|
+
};
|
|
906
|
+
}
|
|
801
907
|
function normalizeSettings(value) {
|
|
802
908
|
const rec = value && typeof value === "object" ? value : {};
|
|
803
909
|
const submitMode = rec.submitMode === "button" || rec.submitMode === "auto" ? rec.submitMode : void 0;
|
|
@@ -809,6 +915,7 @@ function normalizeSettings(value) {
|
|
|
809
915
|
redirectUrl: normalizeUrl(rec.redirectUrl),
|
|
810
916
|
showProgress: bool(rec.showProgress),
|
|
811
917
|
responseLimit: normalizeResponseLimit(rec.responseLimit),
|
|
918
|
+
trust: normalizeTrust(rec.trust),
|
|
812
919
|
notifyEmail: z2.email().check(z2.maxLength(254)).safeParse(rec.notifyEmail).success ? rec.notifyEmail : void 0,
|
|
813
920
|
sendReceipt: bool(rec.sendReceipt),
|
|
814
921
|
saveProgress: bool(rec.saveProgress),
|
|
@@ -948,15 +1055,24 @@ function normalizeFormSchema(input) {
|
|
|
948
1055
|
if (parsed.data.version > MAX_SCHEMA_VERSION) {
|
|
949
1056
|
return { ok: false, error: `Unsupported schema version: ${parsed.data.version}` };
|
|
950
1057
|
}
|
|
951
|
-
const
|
|
1058
|
+
const rebuilt = parsed.data.pages.map((page) => {
|
|
952
1059
|
const blocks = page.blocks.flatMap((raw) => {
|
|
953
1060
|
const block = normalizeBlock(raw);
|
|
954
1061
|
return block ? [block] : [];
|
|
955
1062
|
});
|
|
1063
|
+
return { id: str(page.id, 128), title: optionalStr(page.title, 500), blocks, next: page.next };
|
|
1064
|
+
});
|
|
1065
|
+
const targetIds = new Set(rebuilt.map((p) => p.id));
|
|
1066
|
+
const priorFieldIds = /* @__PURE__ */ new Set();
|
|
1067
|
+
const pages = rebuilt.map((page) => {
|
|
1068
|
+
for (const block of page.blocks) {
|
|
1069
|
+
if (isField(block)) priorFieldIds.add(block.id);
|
|
1070
|
+
}
|
|
956
1071
|
return cleanObject({
|
|
957
|
-
id:
|
|
958
|
-
title:
|
|
959
|
-
blocks
|
|
1072
|
+
id: page.id,
|
|
1073
|
+
title: page.title,
|
|
1074
|
+
blocks: page.blocks,
|
|
1075
|
+
next: jumpRules(page.next, targetIds, page.id, new Set(priorFieldIds))
|
|
960
1076
|
});
|
|
961
1077
|
});
|
|
962
1078
|
const pageIds = /* @__PURE__ */ new Set();
|
|
@@ -2353,13 +2469,18 @@ function isDraftGone(err) {
|
|
|
2353
2469
|
return isFilloError(err) && (err.status === 401 || err.status === 403 || err.status === 404);
|
|
2354
2470
|
}
|
|
2355
2471
|
var DRAFT_DEBOUNCE_MS = 1500;
|
|
2472
|
+
var CHALLENGE_INCOMPLETE_MESSAGE = "Please complete the verification check, then submit.";
|
|
2473
|
+
var CHALLENGE_RETRY_MESSAGE = "That verification didn't go through. Please complete the check again and resubmit.";
|
|
2356
2474
|
function submitFailureMessage(err) {
|
|
2357
2475
|
if (isFilloError(err) && err.status && err.status > 0 && err.message) return err.message;
|
|
2358
2476
|
return "Couldn't reach the server \u2014 check your connection and try again. If this keeps happening, a browser extension, firewall, or the site's Content-Security-Policy may be blocking the request.";
|
|
2359
2477
|
}
|
|
2360
|
-
function syncResolutionFailureMessage(err) {
|
|
2478
|
+
function syncResolutionFailureMessage(err, verbose) {
|
|
2361
2479
|
if (isFilloError(err)) {
|
|
2362
2480
|
const status = err.status ?? 0;
|
|
2481
|
+
if (verbose && status > 0 && err.message) {
|
|
2482
|
+
return err.code ? `${err.message} (${err.code})` : err.message;
|
|
2483
|
+
}
|
|
2363
2484
|
const definitive = status > 0 && status < 500 && status !== 408 && status !== 429;
|
|
2364
2485
|
if (definitive) return "This form is unavailable.";
|
|
2365
2486
|
}
|
|
@@ -2458,7 +2579,10 @@ function createFormController(options2) {
|
|
|
2458
2579
|
// keeps it — never a field whose answer submit would silently drop.
|
|
2459
2580
|
blocks: visiblePageBlocks(form, page, data),
|
|
2460
2581
|
isFirstPage: clamped === 0,
|
|
2461
|
-
|
|
2582
|
+
// Terminal-aware: true when the current page is the last reachable page OR
|
|
2583
|
+
// a matched jump rule ends the form here — so the footer reads Submit and
|
|
2584
|
+
// its handler submits. Same shared engine the validator reaches with.
|
|
2585
|
+
isLastPage: isTerminalPage(form, page.id, data),
|
|
2462
2586
|
uploading: uploadingFields.size > 0,
|
|
2463
2587
|
submitError,
|
|
2464
2588
|
restoredSubmission,
|
|
@@ -2659,6 +2783,20 @@ function createFormController(options2) {
|
|
|
2659
2783
|
else uploadingFields.delete(fieldId);
|
|
2660
2784
|
notify();
|
|
2661
2785
|
}
|
|
2786
|
+
function currentSeqPosition(seq) {
|
|
2787
|
+
if (seq.length === 0) return -1;
|
|
2788
|
+
const pageCount = form.pages.length;
|
|
2789
|
+
const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
|
|
2790
|
+
const currentId = form.pages[clamped]?.id;
|
|
2791
|
+
const exact = currentId === void 0 ? -1 : seq.indexOf(currentId);
|
|
2792
|
+
if (exact >= 0) return exact;
|
|
2793
|
+
let fallback = 0;
|
|
2794
|
+
for (let i = 0; i < seq.length; i++) {
|
|
2795
|
+
const idx = form.pages.findIndex((p) => p.id === seq[i]);
|
|
2796
|
+
if (idx >= 0 && idx <= clamped) fallback = i;
|
|
2797
|
+
}
|
|
2798
|
+
return fallback;
|
|
2799
|
+
}
|
|
2662
2800
|
function next() {
|
|
2663
2801
|
const pageCount = form.pages.length;
|
|
2664
2802
|
const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
|
|
@@ -2670,14 +2808,31 @@ function createFormController(options2) {
|
|
|
2670
2808
|
return;
|
|
2671
2809
|
}
|
|
2672
2810
|
}
|
|
2673
|
-
|
|
2811
|
+
const current = form.pages[clamped];
|
|
2812
|
+
const seq = reachablePageSequence(form, data);
|
|
2813
|
+
const pos = currentSeqPosition(seq);
|
|
2814
|
+
const terminal = (current ? isTerminalPage(form, current.id, data) : true) || pos < 0 || pos + 1 >= seq.length;
|
|
2815
|
+
if (terminal) {
|
|
2816
|
+
void submit();
|
|
2817
|
+
return;
|
|
2818
|
+
}
|
|
2819
|
+
const nextIndex = form.pages.findIndex((p) => p.id === seq[pos + 1]);
|
|
2820
|
+
pageIndex = nextIndex >= 0 ? nextIndex : Math.min(clamped + 1, pageCount - 1);
|
|
2674
2821
|
if (sessionId && client) client.reportProgress(sessionId, { furthestPage: pageIndex });
|
|
2675
2822
|
notify();
|
|
2676
2823
|
checkpointDraft();
|
|
2677
2824
|
}
|
|
2678
2825
|
function back() {
|
|
2679
2826
|
errors = {};
|
|
2680
|
-
|
|
2827
|
+
const seq = reachablePageSequence(form, data);
|
|
2828
|
+
const pos = currentSeqPosition(seq);
|
|
2829
|
+
if (pos > 0) {
|
|
2830
|
+
const prevIndex = form.pages.findIndex((p) => p.id === seq[pos - 1]);
|
|
2831
|
+
pageIndex = prevIndex >= 0 ? prevIndex : Math.max(Math.min(pageIndex, form.pages.length - 1) - 1, 0);
|
|
2832
|
+
} else if (seq.length > 0) {
|
|
2833
|
+
const firstIndex = form.pages.findIndex((p) => p.id === seq[0]);
|
|
2834
|
+
pageIndex = firstIndex >= 0 ? firstIndex : 0;
|
|
2835
|
+
}
|
|
2681
2836
|
notify();
|
|
2682
2837
|
checkpointDraft();
|
|
2683
2838
|
}
|
|
@@ -2701,7 +2856,10 @@ function createFormController(options2) {
|
|
|
2701
2856
|
formId = await options2.resolveFormId();
|
|
2702
2857
|
} catch (err) {
|
|
2703
2858
|
status = "idle";
|
|
2704
|
-
submitError = syncResolutionFailureMessage(
|
|
2859
|
+
submitError = syncResolutionFailureMessage(
|
|
2860
|
+
err,
|
|
2861
|
+
options2.verboseResolutionErrors === true
|
|
2862
|
+
);
|
|
2705
2863
|
notify();
|
|
2706
2864
|
throw err;
|
|
2707
2865
|
}
|
|
@@ -2710,6 +2868,13 @@ function createFormController(options2) {
|
|
|
2710
2868
|
const visitorKey = form.settings.responseLimit?.by === "browser" ? ensureSubmissionKey(visitorSubmissionKeyId(form, formId, result.data)) : void 0;
|
|
2711
2869
|
const submissionKey = visitorKey ?? idempotencyKey;
|
|
2712
2870
|
const submitDraft = form.settings.saveProgress ? draftRef ?? readDraftRef(formId) : null;
|
|
2871
|
+
const challengeToken = options2.challengeRequired ? options2.getChallengeToken?.() : void 0;
|
|
2872
|
+
if (options2.challengeRequired && !challengeToken) {
|
|
2873
|
+
status = "idle";
|
|
2874
|
+
submitError = CHALLENGE_INCOMPLETE_MESSAGE;
|
|
2875
|
+
notify();
|
|
2876
|
+
return;
|
|
2877
|
+
}
|
|
2713
2878
|
status = "submitting";
|
|
2714
2879
|
submitError = void 0;
|
|
2715
2880
|
notify();
|
|
@@ -2721,10 +2886,17 @@ function createFormController(options2) {
|
|
|
2721
2886
|
surface: options2.surface ?? "headless",
|
|
2722
2887
|
submissionKey,
|
|
2723
2888
|
draft: submitDraft ? { id: submitDraft.id, token: submitDraft.token } : void 0,
|
|
2724
|
-
respondent
|
|
2889
|
+
respondent,
|
|
2890
|
+
challengeToken
|
|
2725
2891
|
});
|
|
2726
2892
|
} catch (err) {
|
|
2727
2893
|
status = "idle";
|
|
2894
|
+
if (isFilloError(err) && err.code === "challenge_failed") {
|
|
2895
|
+
submitError = CHALLENGE_RETRY_MESSAGE;
|
|
2896
|
+
options2.onChallengeFailed?.();
|
|
2897
|
+
notify();
|
|
2898
|
+
return;
|
|
2899
|
+
}
|
|
2728
2900
|
submitError = submitFailureMessage(err);
|
|
2729
2901
|
notify();
|
|
2730
2902
|
throw err;
|
|
@@ -2833,7 +3005,7 @@ function needsExplicitSubmit(visible) {
|
|
|
2833
3005
|
}
|
|
2834
3006
|
function shouldAutoSubmit(field2, value, ctx) {
|
|
2835
3007
|
if (ctx.form.settings.submitMode !== "auto") return false;
|
|
2836
|
-
if (
|
|
3008
|
+
if (ctx.status !== "idle" || ctx.uploading) return false;
|
|
2837
3009
|
const hasDiscreteAnswer = (() => {
|
|
2838
3010
|
switch (field2.kind) {
|
|
2839
3011
|
case "select":
|
|
@@ -2851,7 +3023,9 @@ function shouldAutoSubmit(field2, value, ctx) {
|
|
|
2851
3023
|
if (!hasDiscreteAnswer) return false;
|
|
2852
3024
|
if (value === ctx.data[field2.id]) return false;
|
|
2853
3025
|
const nextData = { ...ctx.data, [field2.id]: value };
|
|
2854
|
-
const
|
|
3026
|
+
const page = ctx.form.pages.find((p) => p.blocks.some((b) => b.id === field2.id));
|
|
3027
|
+
if (!page || !isTerminalPage(ctx.form, page.id, nextData)) return false;
|
|
3028
|
+
const interactive = reachableFields(ctx.form, nextData).filter((f) => f.kind !== "hidden");
|
|
2855
3029
|
if (interactive.length !== 1 || interactive[0]?.id !== field2.id) return false;
|
|
2856
3030
|
return validateResponse(ctx.form, nextData).ok;
|
|
2857
3031
|
}
|
|
@@ -2877,7 +3051,8 @@ var FILLO_SLOTS = [
|
|
|
2877
3051
|
"footer",
|
|
2878
3052
|
"button",
|
|
2879
3053
|
"success",
|
|
2880
|
-
"resume"
|
|
3054
|
+
"resume",
|
|
3055
|
+
"turnstile"
|
|
2881
3056
|
];
|
|
2882
3057
|
var FILLO_DATA_ATTRS = {
|
|
2883
3058
|
/** Slot name, on every slot element: `data-fillo="control"`. */
|
|
@@ -2983,9 +3158,9 @@ function formSchemasEqual(left, right) {
|
|
|
2983
3158
|
return JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right));
|
|
2984
3159
|
}
|
|
2985
3160
|
var syncTtlMs = (status) => status === "published" ? 36e5 : 6e4;
|
|
2986
|
-
var isVolatileSync = (result) => Boolean(result.staged || result.resolvedSchema || result.syncError);
|
|
3161
|
+
var isVolatileSync = (result) => Boolean(result.staged || result.resolvedSchema || result.syncError || result.accepting === false);
|
|
2987
3162
|
function storageKey(client, handle) {
|
|
2988
|
-
return `fillo:sync:
|
|
3163
|
+
return `fillo:sync:v3:${client.baseUrl}|${client.key}|${handle}`;
|
|
2989
3164
|
}
|
|
2990
3165
|
function readStoredSync(client, handle, hash) {
|
|
2991
3166
|
try {
|
|
@@ -3002,7 +3177,8 @@ function readStoredSync(client, handle, hash) {
|
|
|
3002
3177
|
}
|
|
3003
3178
|
}
|
|
3004
3179
|
function writeStoredSync(client, handle, hash, r) {
|
|
3005
|
-
if (r.staged || r.resolvedSchema || r.syncError) return;
|
|
3180
|
+
if (r.staged || r.resolvedSchema || r.syncError || r.accepting === false) return;
|
|
3181
|
+
if (r.challenge) return;
|
|
3006
3182
|
try {
|
|
3007
3183
|
globalThis.localStorage?.setItem(
|
|
3008
3184
|
storageKey(client, handle),
|
|
@@ -3382,6 +3558,23 @@ function when(fieldId) {
|
|
|
3382
3558
|
};
|
|
3383
3559
|
}
|
|
3384
3560
|
|
|
3561
|
+
// src/dev-env.ts
|
|
3562
|
+
function isLocalHostname(hostname) {
|
|
3563
|
+
const host = hostname.toLowerCase();
|
|
3564
|
+
return host === "localhost" || host.endsWith(".localhost") || // Loopback ADDRESSES only — anchored so `127.0.0.1.example.com` stays a
|
|
3565
|
+
// real (production) hostname.
|
|
3566
|
+
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host) || host === "::1" || host === "[::1]" || host === "0.0.0.0";
|
|
3567
|
+
}
|
|
3568
|
+
function isBuildTimeDevEnv() {
|
|
3569
|
+
return typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
|
|
3570
|
+
}
|
|
3571
|
+
function isLikelyDevEnv() {
|
|
3572
|
+
if (isBuildTimeDevEnv()) return true;
|
|
3573
|
+
if (typeof window === "undefined") return false;
|
|
3574
|
+
const hostname = window.location?.hostname;
|
|
3575
|
+
return typeof hostname === "string" && isLocalHostname(hostname);
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3385
3578
|
// src/piping.ts
|
|
3386
3579
|
var TOKEN = /\{\{\s*([\w-]+)\s*\}\}/g;
|
|
3387
3580
|
function resolveText(text, data, form) {
|
|
@@ -3415,6 +3608,7 @@ export {
|
|
|
3415
3608
|
DEFAULT_FIELD_STRINGS,
|
|
3416
3609
|
DEFAULT_STRINGS,
|
|
3417
3610
|
DRAFT_KINDS,
|
|
3611
|
+
FILLO_CHALLENGE_MIN_SDK_VERSION,
|
|
3418
3612
|
FILLO_DATA_ATTRS,
|
|
3419
3613
|
FILLO_MIN_SDK_VERSION,
|
|
3420
3614
|
FILLO_SCHEMA_VERSION,
|
|
@@ -3433,6 +3627,7 @@ export {
|
|
|
3433
3627
|
allFields,
|
|
3434
3628
|
assembleForm,
|
|
3435
3629
|
codeFormFromJsx,
|
|
3630
|
+
conditionsMet,
|
|
3436
3631
|
contentHash,
|
|
3437
3632
|
countryByDialCode,
|
|
3438
3633
|
countryByIso,
|
|
@@ -3450,10 +3645,13 @@ export {
|
|
|
3450
3645
|
formatNational,
|
|
3451
3646
|
isAutoSubmitBlock,
|
|
3452
3647
|
isBlockVisible,
|
|
3648
|
+
isBuildTimeDevEnv,
|
|
3453
3649
|
isCodeForm,
|
|
3454
3650
|
isField,
|
|
3455
3651
|
isFilloError,
|
|
3652
|
+
isLikelyDevEnv,
|
|
3456
3653
|
isPossiblePhone,
|
|
3654
|
+
isTerminalPage,
|
|
3457
3655
|
needsExplicitSubmit,
|
|
3458
3656
|
normalizeFormSchema,
|
|
3459
3657
|
normalizeFormTheme,
|
|
@@ -3463,6 +3661,11 @@ export {
|
|
|
3463
3661
|
positionPhonePopover,
|
|
3464
3662
|
prefillFromParams,
|
|
3465
3663
|
provisionWorkspace,
|
|
3664
|
+
reachableFieldIds,
|
|
3665
|
+
reachableFields,
|
|
3666
|
+
reachablePageIds,
|
|
3667
|
+
reachablePageSequence,
|
|
3668
|
+
resolveNextPage,
|
|
3466
3669
|
resolveSlotClass,
|
|
3467
3670
|
resolveStrings,
|
|
3468
3671
|
resolveText,
|