effect-atom-svelte 0.3.0 → 0.4.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.
Files changed (41) hide show
  1. package/README.md +4 -1
  2. package/dist/Form.d.ts +605 -0
  3. package/dist/Form.js +67 -0
  4. package/dist/Hooks.svelte.d.ts +54 -0
  5. package/dist/Hooks.svelte.js +98 -64
  6. package/dist/HydrationBoundary.svelte +4 -5
  7. package/dist/Inspector.js +49 -32
  8. package/dist/RegistryContext.js +3 -1
  9. package/dist/ScopedAtom.js +15 -7
  10. package/dist/SvelteKit.d.ts +46 -12
  11. package/dist/SvelteKit.js +130 -18
  12. package/dist/index.d.ts +9 -0
  13. package/dist/index.js +9 -0
  14. package/dist/internal/awaitWrite.d.ts +11 -0
  15. package/dist/internal/awaitWrite.js +27 -0
  16. package/dist/internal/boundaries.js +3 -1
  17. package/dist/internal/form/array.d.ts +21 -0
  18. package/dist/internal/form/array.js +57 -0
  19. package/dist/internal/form/constraints.d.ts +13 -0
  20. package/dist/internal/form/constraints.js +166 -0
  21. package/dist/internal/form/engine.d.ts +56 -0
  22. package/dist/internal/form/engine.js +842 -0
  23. package/dist/internal/form/engineOf.d.ts +26 -0
  24. package/dist/internal/form/engineOf.js +43 -0
  25. package/dist/internal/form/field.d.ts +30 -0
  26. package/dist/internal/form/field.js +68 -0
  27. package/dist/internal/form/group.d.ts +13 -0
  28. package/dist/internal/form/group.js +20 -0
  29. package/dist/internal/form/instance.d.ts +41 -0
  30. package/dist/internal/form/instance.js +135 -0
  31. package/dist/internal/form/issues.d.ts +23 -0
  32. package/dist/internal/form/issues.js +144 -0
  33. package/dist/internal/form/paths.d.ts +47 -0
  34. package/dist/internal/form/paths.js +175 -0
  35. package/dist/internal/form/types.d.ts +80 -0
  36. package/dist/internal/form/types.js +1 -0
  37. package/dist/internal/form/validationError.d.ts +13 -0
  38. package/dist/internal/form/validationError.js +6 -0
  39. package/dist/internal/hydration.js +3 -1
  40. package/dist/internal/nodeInternals.js +44 -11
  41. package/package.json +12 -10
@@ -1,6 +1,4 @@
1
1
  import { createContext } from "svelte";
2
- // A const and a type of the same name, as in Effect's modules.
3
- /* oxlint-disable eslint/no-redeclare */
4
2
  /**
5
3
  * Marks a value as a `ScopedAtom`: every scoped atom has this key, set to this same string, as
6
4
  * Effect's own types carry a type id.
@@ -37,17 +35,27 @@ export const TypeId = "~effect-atom-svelte/ScopedAtom";
37
35
  */
38
36
  export const make = (f, options) => {
39
37
  const [get, set, has] = createContext();
40
- const provide = (...args) => set(args.length === 0
41
- ? f()
42
- : f(args[0]));
38
+ const provide = (...args) => {
39
+ if (args.length === 1) {
40
+ return set(f(args[0]));
41
+ }
42
+ // SAFETY: `provide`'s type allows no input only when `Input` is never or includes undefined, so
43
+ // `f` is the factory without input or one whose input is optional.
44
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the union of factories can't be narrowed by the argument count
45
+ return set(f());
46
+ };
43
47
  return {
44
48
  [TypeId]: TypeId,
49
+ // SAFETY: `provide` takes no input or one, which every branch of the conditional type accepts;
50
+ // TypeScript can't pick a branch while `Input` is generic.
51
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the conditional type can't be narrowed for a generic `Input`
45
52
  provide: provide,
46
53
  use: () => {
47
54
  if (!has()) {
48
55
  const name = options?.name;
49
- const which = name ? `ScopedAtom "${name}"` : "ScopedAtom";
50
- const call = name ? `${name}.provide()` : "its provide()";
56
+ const named = name !== undefined && name !== "";
57
+ const which = named ? `ScopedAtom "${name}"` : "ScopedAtom";
58
+ const call = named ? `${name}.provide()` : "its provide()";
51
59
  throw new Error(`${which} used outside of the component that provides it. Call ${call} in a parent component's script.`);
52
60
  }
53
61
  return get();
@@ -1,15 +1,3 @@
1
- /**
2
- * SvelteKit `handleError` hooks that keep an Effect error's `_tag`, so a `<svelte:boundary>`
3
- * `failed` snippet can tell typed errors apart. Import them from `effect-atom-svelte/sveltekit`.
4
- *
5
- * The module does not import SvelteKit: the hooks take the fields of SvelteKit's input they read
6
- * and return the fields they set, so they type-check against `HandleClientError` and
7
- * `HandleServerError` once `App.Error` declares `tag?: string`. In SvelteKit 2, whose `App.Error`
8
- * requires a message, they are typed to return one when there is no `kind`, as they then always
9
- * set one.
10
- *
11
- * @since 0.1.0
12
- */
13
1
  /**
14
2
  * The part of SvelteKit's `handleError` input that the hooks read. In SvelteKit 3, `kind` is
15
3
  * `"unknown"` for errors thrown by your code, `"app"` for `error(...)`, `"framework"` for
@@ -101,3 +89,49 @@ export declare function handleClientError(input: CaughtError): EffectErrorBody |
101
89
  */
102
90
  export declare function handleServerError(input: CaughtErrorWithoutKind): EffectErrorBodyWithMessage;
103
91
  export declare function handleServerError(input: CaughtError): EffectErrorBody | undefined;
92
+ /**
93
+ * Options for `useUnsavedChangesGuard`.
94
+ *
95
+ * @stability unstable
96
+ * @since 0.4.0
97
+ * @category models
98
+ */
99
+ export interface UnsavedChangesGuardOptions {
100
+ /**
101
+ * Asks whether to leave and lose the changes, resolving `true` to leave. Called for links,
102
+ * `goto`, form navigations and the back and forward buttons. Closing the tab, reloading and
103
+ * typing a URL show the browser's own dialog instead, as a page can't show its own then.
104
+ */
105
+ readonly confirm: () => boolean | Promise<boolean>;
106
+ }
107
+ /**
108
+ * Asks before the user navigates away from a page whose form is dirty, using SvelteKit's
109
+ * `beforeNavigate`. Call it while a component initializes, with any object that has `dirty`, such
110
+ * as a form instance from `useForm`.
111
+ *
112
+ * While the form is dirty, a navigation is cancelled and `confirm` asked; on yes it is retried, and
113
+ * the retry goes through. Closing the tab, reloading or typing a URL show the browser's own dialog
114
+ * instead. SvelteKit doesn't call `beforeNavigate` for back and forward over shallow history entries
115
+ * (from `pushState` or a hash change) or for links to a hash on the same page, so those aren't
116
+ * guarded.
117
+ *
118
+ * **Example** (Asking before leaving an edit page)
119
+ *
120
+ * ```ts
121
+ * import { useForm } from "effect-atom-svelte";
122
+ * import { useUnsavedChangesGuard } from "effect-atom-svelte/sveltekit";
123
+ *
124
+ * const form = useForm(EditProject, { id, initialValues, submit });
125
+ * useUnsavedChangesGuard(form, {
126
+ * confirm: () => window.confirm("Leave without saving your changes?"),
127
+ * });
128
+ * ```
129
+ *
130
+ * @see {@link https://atom.jarrednorris.dev/forms | Forms}
131
+ * @stability unstable
132
+ * @since 0.4.0
133
+ * @category forms
134
+ */
135
+ export declare const useUnsavedChangesGuard: (form: {
136
+ readonly dirty: boolean;
137
+ }, options: UnsavedChangesGuardOptions) => void;
package/dist/SvelteKit.js CHANGED
@@ -1,21 +1,26 @@
1
1
  /**
2
- * SvelteKit `handleError` hooks that keep an Effect error's `_tag`, so a `<svelte:boundary>`
3
- * `failed` snippet can tell typed errors apart. Import them from `effect-atom-svelte/sveltekit`.
2
+ * SvelteKit helpers. Import them from `effect-atom-svelte/sveltekit`.
4
3
  *
5
- * The module does not import SvelteKit: the hooks take the fields of SvelteKit's input they read
6
- * and return the fields they set, so they type-check against `HandleClientError` and
7
- * `HandleServerError` once `App.Error` declares `tag?: string`. In SvelteKit 2, whose `App.Error`
8
- * requires a message, they are typed to return one when there is no `kind`, as they then always
9
- * set one.
4
+ * `handleClientError` and `handleServerError` are `handleError` hooks that keep an Effect error's
5
+ * `_tag`, so a `<svelte:boundary>` `failed` snippet can tell typed errors apart. They take the
6
+ * fields of SvelteKit's input they read and return the fields they set, so they type-check against
7
+ * `HandleClientError` and `HandleServerError` once `App.Error` declares `tag?: string`. In
8
+ * SvelteKit 2, whose `App.Error` requires a message, they are typed to return one when there is no
9
+ * `kind`, as they then always set one.
10
+ *
11
+ * `useUnsavedChangesGuard` asks before leaving a page with a dirty form.
10
12
  *
11
13
  * @since 0.1.0
12
14
  */
13
- const tagOf = (error) => typeof error === "object" &&
14
- error !== null &&
15
- "_tag" in error &&
16
- typeof error._tag === "string"
17
- ? error._tag
18
- : undefined;
15
+ /// <reference types="@sveltejs/kit" />
16
+ import { beforeNavigate, goto } from "$app/navigation";
17
+ import { Effect } from "effect";
18
+ const tagOf = (error) => {
19
+ if (typeof error !== "object" || error === null || !("_tag" in error)) {
20
+ return undefined;
21
+ }
22
+ return typeof error._tag === "string" ? error._tag : undefined;
23
+ };
19
24
  const messageOf = (error) => {
20
25
  if (error instanceof Error) {
21
26
  return error.message;
@@ -28,10 +33,16 @@ const messageOf = (error) => {
28
33
  const fallbackMessage = (input) => input.kind === undefined ? input.message : undefined;
29
34
  // Leaves out empty fields, so SvelteKit's defaults apply, and returns nothing when both are empty.
30
35
  const body = (message, tag) => {
31
- if (!(message || tag)) {
32
- return undefined;
36
+ const fields = {};
37
+ if (message !== undefined && message !== "") {
38
+ fields.message = message;
39
+ }
40
+ if (tag !== undefined && tag !== "") {
41
+ fields.tag = tag;
33
42
  }
34
- return { ...(message ? { message } : {}), ...(tag ? { tag } : {}) };
43
+ return fields.message === undefined && fields.tag === undefined
44
+ ? undefined
45
+ : fields;
35
46
  };
36
47
  export function handleClientError(input) {
37
48
  // Destructured here, not in the parameter list, so the API reference shows a named parameter.
@@ -40,8 +51,9 @@ export function handleClientError(input) {
40
51
  return undefined;
41
52
  }
42
53
  console.error(error);
43
- // `||`, not `??`: an error with an empty message gets SvelteKit 2's instead.
44
- return body(messageOf(error) || fallbackMessage(input), tagOf(error));
54
+ // An error with an empty message gets SvelteKit 2's instead.
55
+ const message = messageOf(error);
56
+ return body(message === undefined || message === "" ? fallbackMessage(input) : message, tagOf(error));
45
57
  }
46
58
  export function handleServerError(input) {
47
59
  const { error, issues, kind = "unknown" } = input;
@@ -55,3 +67,103 @@ export function handleServerError(input) {
55
67
  console.error(error);
56
68
  return body(fallbackMessage(input), tagOf(error));
57
69
  }
70
+ // Waits for the next popstate, or a second, should none come.
71
+ const nextPopState = () => Effect.runPromise(Effect.callback((resume) => {
72
+ const done = () => {
73
+ resume(Effect.undefined);
74
+ };
75
+ addEventListener("popstate", done, { once: true });
76
+ return Effect.sync(() => {
77
+ removeEventListener("popstate", done);
78
+ });
79
+ }).pipe(Effect.timeoutOption("1 second")));
80
+ // How long a "yes" lets the retried navigation through. The retry reaches beforeNavigate at once;
81
+ // a navigation that never does, such as a link to a download, doesn't leave a bypass behind.
82
+ const bypassFor = 2000;
83
+ /**
84
+ * Asks before the user navigates away from a page whose form is dirty, using SvelteKit's
85
+ * `beforeNavigate`. Call it while a component initializes, with any object that has `dirty`, such
86
+ * as a form instance from `useForm`.
87
+ *
88
+ * While the form is dirty, a navigation is cancelled and `confirm` asked; on yes it is retried, and
89
+ * the retry goes through. Closing the tab, reloading or typing a URL show the browser's own dialog
90
+ * instead. SvelteKit doesn't call `beforeNavigate` for back and forward over shallow history entries
91
+ * (from `pushState` or a hash change) or for links to a hash on the same page, so those aren't
92
+ * guarded.
93
+ *
94
+ * **Example** (Asking before leaving an edit page)
95
+ *
96
+ * ```ts
97
+ * import { useForm } from "effect-atom-svelte";
98
+ * import { useUnsavedChangesGuard } from "effect-atom-svelte/sveltekit";
99
+ *
100
+ * const form = useForm(EditProject, { id, initialValues, submit });
101
+ * useUnsavedChangesGuard(form, {
102
+ * confirm: () => window.confirm("Leave without saving your changes?"),
103
+ * });
104
+ * ```
105
+ *
106
+ * @see {@link https://atom.jarrednorris.dev/forms | Forms}
107
+ * @stability unstable
108
+ * @since 0.4.0
109
+ * @category forms
110
+ */
111
+ export const useUnsavedChangesGuard = (form, options) => {
112
+ // The retry of a navigation the user agreed to: where it goes, whether it unloads the page, and
113
+ // until when to expect it.
114
+ let retry;
115
+ beforeNavigate((navigation) => {
116
+ // Let the retry through, once. Any other navigation is asked about as usual.
117
+ if (retry !== undefined &&
118
+ // oxlint-disable-next-line effecttsgo/global-date -- SvelteKit calls beforeNavigate synchronously, outside any Effect program
119
+ retry.until > Date.now() &&
120
+ (navigation.type === "leave"
121
+ ? retry.unloads
122
+ : navigation.to?.url.href === retry.href)) {
123
+ retry = undefined;
124
+ return;
125
+ }
126
+ if (!form.dirty) {
127
+ return;
128
+ }
129
+ // Closing the tab, reloading or typing a URL: cancelling shows the browser's dialog, and no
130
+ // code of ours can run before the page goes.
131
+ navigation.cancel();
132
+ const { to } = navigation;
133
+ if (navigation.type === "leave" || to === null) {
134
+ return;
135
+ }
136
+ // Cancelling back or forward makes SvelteKit undo the move with history.go, which finishes with
137
+ // a popstate. A retry before then would be lost to it, so the retry waits for it.
138
+ const undone = navigation.type === "popstate" ? nextPopState() : undefined;
139
+ // oxlint-disable-next-line effecttsgo/async-function -- awaits the app's confirm, which may return a Promise, from SvelteKit's beforeNavigate, outside any Effect program
140
+ void (async () => {
141
+ if (!(await options.confirm())) {
142
+ return;
143
+ }
144
+ retry = {
145
+ href: to.url.href,
146
+ unloads: navigation.type !== "popstate" && navigation.willUnload,
147
+ // oxlint-disable-next-line effecttsgo/global-date -- compared with Date.now in beforeNavigate, outside any Effect program
148
+ until: Date.now() + bypassFor,
149
+ };
150
+ if (navigation.type === "popstate") {
151
+ await undone;
152
+ history.go(navigation.delta);
153
+ }
154
+ else if (navigation.willUnload) {
155
+ // An external link, or one SvelteKit hands to the browser: goto can't follow it. The page
156
+ // unloading fires a "leave", which the bypass lets through.
157
+ location.href = to.url.href;
158
+ }
159
+ else {
160
+ // SvelteKit 3 warns in dev whenever the `replaceState` key is there, even false, so it is
161
+ // passed only when true.
162
+ await goto(to.url, to.url.href === location.href
163
+ ? // oxlint-disable-next-line typescript/no-deprecated -- the peer range includes SvelteKit 2, whose goto has no `replace`, only `replaceState`
164
+ { replaceState: true }
165
+ : {});
166
+ }
167
+ })();
168
+ });
169
+ };
package/dist/index.d.ts CHANGED
@@ -40,6 +40,15 @@ export { default as RegistryProvider } from "./RegistryProvider.svelte";
40
40
  * @category scoped atoms
41
41
  */
42
42
  export * as ScopedAtom from "./ScopedAtom.ts";
43
+ /**
44
+ * Forms driven by one Effect Schema: `Form.make` defines one, `useForm` creates an instance in a
45
+ * component.
46
+ *
47
+ * @stability unstable
48
+ * @since 0.4.0
49
+ * @category forms
50
+ */
51
+ export * as Form from "./Form.ts";
43
52
  /**
44
53
  * Effect Atom's modules, so an app can import everything from one place.
45
54
  *
package/dist/index.js CHANGED
@@ -40,6 +40,15 @@ export { default as RegistryProvider } from "./RegistryProvider.svelte";
40
40
  * @category scoped atoms
41
41
  */
42
42
  export * as ScopedAtom from "./ScopedAtom.js";
43
+ /**
44
+ * Forms driven by one Effect Schema: `Form.make` defines one, `useForm` creates an instance in a
45
+ * component.
46
+ *
47
+ * @stability unstable
48
+ * @since 0.4.0
49
+ * @category forms
50
+ */
51
+ export * as Form from "./Form.js";
43
52
  /**
44
53
  * Effect Atom's modules, so an app can import everything from one place.
45
54
  *
@@ -0,0 +1,11 @@
1
+ import { Exit } from "effect";
2
+ import type { Cause } from "effect";
3
+ import { AsyncResult } from "effect/reactivity";
4
+ import type { Atom, AtomRegistry } from "effect/reactivity";
5
+ /**
6
+ * A promise-mode setter's wait: as `awaitResult` with `suspendOnWaiting`, but a change to a result
7
+ * from before the call, which is what `Atom.Reset` from another setter leaves (an idle `Initial`,
8
+ * or an `Atom.fn`'s `initialValue`), ends it as interrupted. The reset interrupts the call, so
9
+ * otherwise the wait would hang until the next call, or settle with the initial value.
10
+ */
11
+ export declare const awaitWrite: <A, E>(registry: AtomRegistry.AtomRegistry, atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>, start: number, signal: AbortSignal) => Promise<Exit.Exit<A, E | Cause.NoSuchElementError>>;
@@ -0,0 +1,27 @@
1
+ // Waiting for a write to an async atom to settle, for useAtomSet's promise modes and a form's
2
+ // submit. Internal: not exported from the package.
3
+ import { Effect, Exit } from "effect";
4
+ import { AsyncResult } from "effect/reactivity";
5
+ /**
6
+ * A promise-mode setter's wait: as `awaitResult` with `suspendOnWaiting`, but a change to a result
7
+ * from before the call, which is what `Atom.Reset` from another setter leaves (an idle `Initial`,
8
+ * or an `Atom.fn`'s `initialValue`), ends it as interrupted. The reset interrupts the call, so
9
+ * otherwise the wait would hang until the next call, or settle with the initial value.
10
+ */
11
+ export const awaitWrite = (registry, atom, start, signal) => Effect.runPromiseExit(Effect.callback((resume) => {
12
+ const first = registry.get(atom);
13
+ if (first._tag !== "Initial" && !first.waiting) {
14
+ resume(AsyncResult.toExit(first));
15
+ return;
16
+ }
17
+ const cancel = registry.subscribe(atom, (result) => {
18
+ if (result.waiting) {
19
+ return;
20
+ }
21
+ cancel();
22
+ const reset = result._tag === "Initial" ||
23
+ (result._tag === "Success" && result.timestamp < start);
24
+ resume(reset ? Exit.interrupt() : AsyncResult.toExit(result));
25
+ });
26
+ return Effect.sync(cancel);
27
+ }), { signal });
@@ -5,4 +5,6 @@
5
5
  */
6
6
  export const queuedBy = new WeakMap();
7
7
  /** Whether a live boundary brought a value for the key: it sends that value to the browser itself. */
8
- export const queuedByBoundary = (registry, key) => queuedBy.get(registry)?.has(key) === true;
8
+ export const queuedByBoundary = (registry, key) =>
9
+ // oxlint-disable-next-line sonarjs/no-empty-collection -- HydrationBoundary.svelte fills it; this module only declares it
10
+ queuedBy.get(registry)?.has(key) === true;
@@ -0,0 +1,21 @@
1
+ import type { Engine } from "./engine.ts";
2
+ import type { FieldImpl } from "./field.ts";
3
+ import type { GroupImpl } from "./group.ts";
4
+ import type { IndexPath, KeyPath } from "./paths.ts";
5
+ export declare class ArrayImpl {
6
+ #private;
7
+ readonly keyPath: KeyPath;
8
+ constructor(engine: Engine, keyPath: KeyPath);
9
+ get path(): IndexPath;
10
+ get items(): readonly {
11
+ readonly key: number;
12
+ readonly index: number;
13
+ readonly fields: GroupImpl;
14
+ readonly field: FieldImpl;
15
+ }[];
16
+ get error(): string | undefined;
17
+ append(item: unknown): void;
18
+ remove(index: number): void;
19
+ move(from: number, to: number): void;
20
+ replace(index: number, item: unknown): void;
21
+ }
@@ -0,0 +1,57 @@
1
+ import { pathId } from "./paths.js";
2
+ export class ArrayImpl {
3
+ #engine;
4
+ keyPath;
5
+ constructor(engine, keyPath) {
6
+ this.#engine = engine;
7
+ this.keyPath = keyPath;
8
+ }
9
+ get path() {
10
+ return this.#engine.indexPath(this.keyPath) ?? [];
11
+ }
12
+ get items() {
13
+ const keys = this.#engine.state.keys.get(pathId(this.keyPath)) ?? [];
14
+ return keys.map((key, index) => ({
15
+ field: this.#engine.field([...this.keyPath, key]),
16
+ fields: this.#engine.group([...this.keyPath, key]),
17
+ index,
18
+ key,
19
+ }));
20
+ }
21
+ get error() {
22
+ return this.#engine.errorAt(this.keyPath);
23
+ }
24
+ append(item) {
25
+ this.#engine.updateArray(this.keyPath, (items, keys) => {
26
+ items.push(item);
27
+ keys.push(this.#engine.newKey());
28
+ });
29
+ }
30
+ remove(index) {
31
+ this.#engine.updateArray(this.keyPath, (items, keys) => {
32
+ if (index >= 0 && index < items.length) {
33
+ items.splice(index, 1);
34
+ keys.splice(index, 1);
35
+ }
36
+ });
37
+ }
38
+ move(from, to) {
39
+ this.#engine.updateArray(this.keyPath, (items, keys) => {
40
+ const inRange = (index) => index >= 0 && index < items.length;
41
+ if (!(inRange(from) && inRange(to)) || from === to) {
42
+ return;
43
+ }
44
+ items.splice(to, 0, ...items.splice(from, 1));
45
+ keys.splice(to, 0, ...keys.splice(from, 1));
46
+ });
47
+ }
48
+ replace(index, item) {
49
+ this.#engine.updateArray(this.keyPath, (items, keys) => {
50
+ if (index >= 0 && index < items.length) {
51
+ items[index] = item;
52
+ // A new item: the old one's errors and touched state don't carry over.
53
+ keys[index] = this.#engine.newKey();
54
+ }
55
+ });
56
+ }
57
+ }
@@ -0,0 +1,13 @@
1
+ import { SchemaAST } from "effect";
2
+ import type { IndexPath } from "./paths.ts";
3
+ /** The attributes `field.constraints` can set. */
4
+ export interface Constraints {
5
+ readonly required?: boolean | undefined;
6
+ readonly minlength?: number | undefined;
7
+ readonly maxlength?: number | undefined;
8
+ readonly min?: number | undefined;
9
+ readonly max?: number | undefined;
10
+ readonly pattern?: string | undefined;
11
+ }
12
+ /** The HTML constraints for the field at `path` in `schema`'s AST. */
13
+ export declare const constraintsAt: (root: SchemaAST.AST, path: IndexPath) => Constraints;
@@ -0,0 +1,166 @@
1
+ import { SchemaAST } from "effect";
2
+ const isJsonArray = (json) => Array.isArray(json);
3
+ const objectPayload = (payload) => typeof payload === "object" && payload !== null && !isJsonArray(payload)
4
+ ? payload
5
+ : undefined;
6
+ const finite = (payload, key) => {
7
+ const value = payload?.[key];
8
+ return typeof value === "number" && Number.isFinite(value)
9
+ ? value
10
+ : undefined;
11
+ };
12
+ // An HTML pattern is compiled with the v flag and must match the whole value, while a Schema
13
+ // pattern matches anywhere in it: only one anchored at both ends, with no flags but u or v, means
14
+ // the same to both.
15
+ const sameAsHtmlPattern = (source, flags) => {
16
+ if (flags !== undefined &&
17
+ !(typeof flags === "string" && ["", "u", "v"].includes(flags))) {
18
+ return false;
19
+ }
20
+ if (!(source.startsWith("^") && source.endsWith("$"))) {
21
+ return false;
22
+ }
23
+ // `^a|b$` is anchored only at its ends: the browser wraps it as `^(?:^a|b$)$`.
24
+ return !source.includes("|") && !source.endsWith("\\$");
25
+ };
26
+ // Effect's built-in checks say what they are in a `representation` annotation (`Schema.ts`, for
27
+ // example `isMinLength`). Only the ones that map exactly onto an HTML attribute are read; exclusive
28
+ // bounds and custom filters are left out.
29
+ const readers = {
30
+ "effect/schema/isBetween": (payload) => ({
31
+ max: payload?.exclusiveMaximum === true
32
+ ? undefined
33
+ : finite(payload, "maximum"),
34
+ min: payload?.exclusiveMinimum === true
35
+ ? undefined
36
+ : finite(payload, "minimum"),
37
+ }),
38
+ "effect/schema/isBetweenLength": (payload) => ({
39
+ maxlength: finite(payload, "maximum"),
40
+ minlength: finite(payload, "minimum"),
41
+ }),
42
+ "effect/schema/isGreaterThanOrEqualTo": (payload) => ({
43
+ min: finite(payload, "minimum"),
44
+ }),
45
+ "effect/schema/isLessThanOrEqualTo": (payload) => ({
46
+ max: finite(payload, "maximum"),
47
+ }),
48
+ "effect/schema/isMaxLength": (payload) => ({
49
+ maxlength: finite(payload, "maxLength"),
50
+ }),
51
+ "effect/schema/isMinLength": (payload) => ({
52
+ minlength: finite(payload, "minLength"),
53
+ }),
54
+ "effect/schema/isPattern": (payload) => {
55
+ const source = payload?.source;
56
+ return typeof source === "string" &&
57
+ sameAsHtmlPattern(source, payload?.flags)
58
+ ? { pattern: source }
59
+ : {};
60
+ },
61
+ };
62
+ /** Copies `from`'s defined constraints into `into`, in `from`'s order. */
63
+ const merge = (into, from) => {
64
+ Object.assign(into, Object.fromEntries(Object.entries(from).filter(([, value]) => value !== undefined)));
65
+ };
66
+ const readCheck = (check, into) => {
67
+ if (check._tag === "FilterGroup") {
68
+ for (const inner of check.checks) {
69
+ readCheck(inner, into);
70
+ }
71
+ return;
72
+ }
73
+ const representation = check.annotations?.representation;
74
+ if (representation === undefined) {
75
+ return;
76
+ }
77
+ const reader = readers[representation.id];
78
+ if (reader !== undefined) {
79
+ merge(into, reader(objectPayload(representation.payload)));
80
+ }
81
+ };
82
+ // The node's checks, then the checks along its encoding chain (NumberFromString keeps a range check
83
+ // on the decoded Number and a NonEmpty check on the encoded String).
84
+ const readNode = (ast, into) => {
85
+ for (const check of ast.checks ?? []) {
86
+ readCheck(check, into);
87
+ }
88
+ for (const link of ast.encoding ?? []) {
89
+ readNode(link.to, into);
90
+ }
91
+ };
92
+ // Steps into declarations (a Schema.Class wraps its Struct) and suspensions, to a node with fields.
93
+ const unwrap = (ast) => {
94
+ if (SchemaAST.isSuspend(ast)) {
95
+ return unwrap(ast.thunk());
96
+ }
97
+ if (SchemaAST.isDeclaration(ast)) {
98
+ const [inner] = ast.typeParameters;
99
+ if (inner !== undefined) {
100
+ return unwrap(inner);
101
+ }
102
+ const last = ast.encoding?.at(-1)?.to;
103
+ return last === undefined ? ast : unwrap(last);
104
+ }
105
+ return ast;
106
+ };
107
+ // The field at one step below `ast`, with whether its key is optional. In a union, the first member
108
+ // that has the step.
109
+ const step = (ast, segment) => {
110
+ const node = unwrap(ast);
111
+ if (SchemaAST.isUnion(node)) {
112
+ for (const member of node.types) {
113
+ const found = step(member, segment);
114
+ if (found !== undefined) {
115
+ return found;
116
+ }
117
+ }
118
+ return undefined;
119
+ }
120
+ if (SchemaAST.isObjects(node) && typeof segment === "string") {
121
+ const property = node.propertySignatures.find((candidate) => candidate.name === segment);
122
+ return property === undefined
123
+ ? undefined
124
+ : { ast: property.type, optional: SchemaAST.isOptional(property.type) };
125
+ }
126
+ if (SchemaAST.isArrays(node) && typeof segment === "number") {
127
+ const element = node.elements[segment] ?? node.rest[0];
128
+ return element === undefined
129
+ ? undefined
130
+ : { ast: element, optional: SchemaAST.isOptional(element) };
131
+ }
132
+ return undefined;
133
+ };
134
+ // `optional(X)` is an optional key holding `X | undefined`; the constraints are X's.
135
+ const withoutUndefined = (ast) => {
136
+ if (!SchemaAST.isUnion(ast)) {
137
+ return ast;
138
+ }
139
+ const types = ast.types.filter((member) => member._tag !== "Undefined");
140
+ return types.length === 1 && types[0] !== undefined ? types[0] : ast;
141
+ };
142
+ /** The HTML constraints for the field at `path` in `schema`'s AST. */
143
+ export const constraintsAt = (root, path) => {
144
+ let found = {
145
+ ast: root,
146
+ optional: false,
147
+ };
148
+ for (const segment of path) {
149
+ found &&= step(found.ast, segment);
150
+ }
151
+ if (found === undefined) {
152
+ return {};
153
+ }
154
+ const { ast, optional } = found;
155
+ const into = {};
156
+ readNode(withoutUndefined(ast), into);
157
+ // `required` means "not empty" to a browser, so it is set only where the Schema rejects an empty
158
+ // string: a required key alone allows "", and a checkbox's boolean is always present.
159
+ const { minlength } = into;
160
+ const out = {};
161
+ if (!optional && typeof minlength === "number" && minlength >= 1) {
162
+ out.required = true;
163
+ }
164
+ merge(out, into);
165
+ return out;
166
+ };