@valancex/common 0.13.0 → 0.15.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/dist/app.d.ts +4 -0
- package/dist/app.js +21 -1
- package/dist/client.d.ts +4 -1
- package/dist/client.js +10 -2
- package/dist/desktop.d.ts +4 -2
- package/dist/desktop.js +2 -1
- package/dist/early-replay.d.ts +57 -0
- package/dist/early-replay.js +75 -0
- package/dist/early.d.ts +8 -0
- package/dist/early.js +30 -0
- package/dist/form.d.ts +165 -0
- package/dist/form.js +388 -0
- package/dist/index.d.ts +97 -4
- package/dist/index.js +64 -0
- package/dist/internal.d.ts +1 -0
- package/dist/internal.js +1 -0
- package/dist/primitives.d.ts +21 -0
- package/dist/primitives.js +792 -56
- package/dist/record.d.ts +4 -0
- package/dist/record.js +14 -0
- package/dist/server.js +2 -1
- package/dist/vocabulary.d.ts +24 -0
- package/dist/vocabulary.js +5 -8
- package/package.json +10 -6
package/dist/app.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface Built {
|
|
|
10
10
|
readonly script: string;
|
|
11
11
|
/** Stylesheet addresses, in order. Empty in development, where the script brings its styles. */
|
|
12
12
|
readonly styles: ReadonlyArray<string>;
|
|
13
|
+
/** The address of the MESH runtime's WebAssembly module in a production build, which the head starts compiling at once (W11). Absent in development. */
|
|
14
|
+
readonly wasm?: string | undefined;
|
|
13
15
|
}
|
|
14
16
|
/** A mistake in `src/main.ts`, reported with what to change. */
|
|
15
17
|
export declare class AppDefinitionError extends Error {
|
|
@@ -28,6 +30,8 @@ export declare const stateSchema: (author: DefinedApp | undefined) => Schema.Sch
|
|
|
28
30
|
/** The command table: the framework's `go`, then the author's. */
|
|
29
31
|
export declare const commandTable: (author: DefinedApp | undefined, known?: (page: string) => boolean) => (state: Parameters<typeof Valance.command>[0]) => Valance.CommandTable<any, Valance.Ambient>;
|
|
30
32
|
export declare const initialState: (author: DefinedApp | undefined) => AppState;
|
|
33
|
+
/** The units of the components the author registered, by the tag each is written as (its template's file name without `.mprx`). */
|
|
34
|
+
export declare const unitsOf: (author: DefinedApp | undefined) => Readonly<Record<string, Valance.Unit>>;
|
|
31
35
|
export declare const application: (content: Content, program: Valance.Program, author?: DefinedApp) => Valance.ApplicationDefinition<AppState, any, Valance.Ambient, "layout">;
|
|
32
36
|
/** The state a request for `pathname` starts from. */
|
|
33
37
|
export declare const stateFor: (content: Content, pathname: string, author?: DefinedApp) => AppState;
|
package/dist/app.js
CHANGED
|
@@ -7,7 +7,7 @@ import * as Nexus from "@valancex/nexus";
|
|
|
7
7
|
import { Effect, Schema, Stream } from "effect";
|
|
8
8
|
import { documentTitle, frame, FrameScope, pageAt, RESERVED } from "./scope.js";
|
|
9
9
|
import { normalize } from "./routes.js";
|
|
10
|
-
import { derivedOf } from "./index.js";
|
|
10
|
+
import { derivedOf, isFormField } from "./index.js";
|
|
11
11
|
import { Navigation } from "./navigation.js";
|
|
12
12
|
import * as StoreOps from "./store.js";
|
|
13
13
|
import { badDefaults, stateOf } from "./state.js";
|
|
@@ -44,6 +44,11 @@ export const checkDefinition = (author) => {
|
|
|
44
44
|
throw new AppDefinitionError(`src/main.ts: the state field "${name}" is the framework's (which page is shown, and the pages visited); choose another`);
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
+
for (const [tag, id] of Object.entries(author?.resolve ?? {})) {
|
|
48
|
+
if (typeof id !== "string" || !/^(@[^/\s]+\/)?[^/\s]+\/[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(id) || id.split("/").at(-1) !== tag) {
|
|
49
|
+
throw new AppDefinitionError(`src/main.ts: \`resolve\` maps a tag to the qualified id of the component it means, \`${tag}: "package/${tag}"\`; got ${JSON.stringify(id)} for "${tag}"`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
47
52
|
const fields = Object.keys(stateOf(author)?.schema.fields ?? {});
|
|
48
53
|
for (const name of persistedFields(author)) {
|
|
49
54
|
if (!fields.includes(name)) {
|
|
@@ -209,8 +214,18 @@ export const initialState = (author) => ({
|
|
|
209
214
|
colorScheme: "light",
|
|
210
215
|
...stateOf(author)?.initial,
|
|
211
216
|
});
|
|
217
|
+
/** The units of the components the author registered, by the tag each is written as (its template's file name without `.mprx`). */
|
|
218
|
+
export const unitsOf = (author) => {
|
|
219
|
+
const parts = [author, author?.targets?.web, author?.targets?.desktop];
|
|
220
|
+
const components = parts.flatMap((part) => [...(part?.layout === undefined ? [] : [part.layout]), ...(part?.components ?? []), ...(part?.override ?? [])]);
|
|
221
|
+
return Object.fromEntries(components.flatMap((component) => (component.unit === undefined ? [] : [[component.template.mprx.replace(/^.*[\\/]/, "").replace(/\.mprx$/, ""), component.unit]])));
|
|
222
|
+
};
|
|
223
|
+
/** The names of the state fields that hold a form. */
|
|
224
|
+
const formsOf = (author) => Object.entries((author?.state ?? {})).flatMap(([name, value]) => (isFormField(value) ? [name] : []));
|
|
212
225
|
export const application = (content, program, author) => {
|
|
213
226
|
checkDefinition(author);
|
|
227
|
+
const units = unitsOf(author);
|
|
228
|
+
const forms = formsOf(author);
|
|
214
229
|
return Valance.define({
|
|
215
230
|
name: content.site.name,
|
|
216
231
|
state: { schema: stateSchema(author), initial: initialState(author) },
|
|
@@ -230,6 +245,11 @@ export const application = (content, program, author) => {
|
|
|
230
245
|
view: () => "layout",
|
|
231
246
|
commands: commandTable(author, (page) => pageAt(content, page) !== undefined),
|
|
232
247
|
...(persisted(author).length === 0 ? {} : { start: "persist" }),
|
|
248
|
+
...(Object.keys(units).length === 0 ? {} : { units }),
|
|
249
|
+
// A form takes the person to its first field in error each time a submit is tried (Bet 9, I13): nothing for the author to write.
|
|
250
|
+
...(forms.length === 0
|
|
251
|
+
? {}
|
|
252
|
+
: { focusFirstInvalid: (state) => forms.reduce((count, name) => count + (state[name]?.attempts ?? 0), 0) }),
|
|
233
253
|
});
|
|
234
254
|
};
|
|
235
255
|
/** The state a request for `pathname` starts from. */
|
package/dist/client.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type * as Valance from "@valancex/valance";
|
|
2
|
+
import type { SessionRecorder } from "@valancex/valance/replay";
|
|
2
3
|
import type { DefinedApp } from "./index.js";
|
|
3
4
|
import type { Content } from "./model.js";
|
|
4
5
|
export interface ClientParts {
|
|
5
6
|
readonly content: Content;
|
|
6
7
|
readonly program: Valance.Program;
|
|
7
8
|
readonly author?: DefinedApp | undefined;
|
|
9
|
+
/** A session recorder (`vlx dev --record` writes the entry that passes one): a development feature. */
|
|
10
|
+
readonly record?: SessionRecorder | undefined;
|
|
8
11
|
}
|
|
9
|
-
export declare const start: ({ content, program, author }: ClientParts) => Promise<void>;
|
|
12
|
+
export declare const start: ({ content, program, author, record }: ClientParts) => Promise<void>;
|
package/dist/client.js
CHANGED
|
@@ -7,13 +7,16 @@ import { Effect } from "effect";
|
|
|
7
7
|
import { webPlatform } from "./http.js";
|
|
8
8
|
import { watchColorScheme } from "./color-scheme.js";
|
|
9
9
|
import { watchViewport } from "./viewport.js";
|
|
10
|
+
import { earlyModule, finishStarting, replayEarly, settleOf, snapEarly } from "./early-replay.js";
|
|
10
11
|
import { application } from "./app.js";
|
|
11
12
|
import { plugin } from "./web-app.js";
|
|
12
|
-
export const start = async ({ content, program, author }) => {
|
|
13
|
+
export const start = async ({ content, program, author, record }) => {
|
|
13
14
|
const container = document.getElementById("app");
|
|
14
15
|
try {
|
|
15
16
|
const boot = JSON.parse(document.getElementById("valance-boot").textContent);
|
|
16
|
-
|
|
17
|
+
// The module the head's shell compiled while this script downloaded (W11), else the runtime fetches it itself.
|
|
18
|
+
await init((await earlyModule()) ?? wasmUrl);
|
|
19
|
+
snapEarly();
|
|
17
20
|
const host = await Web.run(application(content, program, author), {
|
|
18
21
|
container,
|
|
19
22
|
present: "hydrate",
|
|
@@ -22,6 +25,7 @@ export const start = async ({ content, program, author }) => {
|
|
|
22
25
|
// What the application's commands may ask the page for: the backend (`Http`), at `api.base` of valance.web.json.
|
|
23
26
|
platform: webPlatform({ base: content.site.api, interceptors: author?.http?.interceptors ?? [], store: webStore(content.site.name) }),
|
|
24
27
|
shutdown: { grace: "250 millis" },
|
|
28
|
+
...(record === undefined ? {} : { record }),
|
|
25
29
|
});
|
|
26
30
|
// The server rendered for a wide window; tell the application how wide this one is (and keep telling it).
|
|
27
31
|
const unwatchScheme = watchColorScheme(window, (scheme) => {
|
|
@@ -32,6 +36,9 @@ export const start = async ({ content, program, author }) => {
|
|
|
32
36
|
container.dataset["viewport"] = viewport; // for a stylesheet or a test; the layout reads the state's own `viewport`
|
|
33
37
|
void Effect.runPromise(host.handle.invoke("app/resize", [{ value: viewport }])).catch(() => undefined);
|
|
34
38
|
});
|
|
39
|
+
// What a person did before the application was ready (Bet 14): once, in order, at the same nodes, only if hydration kept them.
|
|
40
|
+
await replayEarly(container, host.mounted.hydration?.adopted !== false, settleOf(host.mounted));
|
|
41
|
+
finishStarting(container);
|
|
35
42
|
container.dataset["valance"] = "running"; // readiness marker for whoever drives the page
|
|
36
43
|
addEventListener("pagehide", (event) => {
|
|
37
44
|
if (!event.persisted) {
|
|
@@ -42,6 +49,7 @@ export const start = async ({ content, program, author }) => {
|
|
|
42
49
|
});
|
|
43
50
|
}
|
|
44
51
|
catch (error) {
|
|
52
|
+
finishStarting(container);
|
|
45
53
|
container.dataset["valance"] = "failed";
|
|
46
54
|
console.error("the application did not start", error); // the server's HTML stays as drawn
|
|
47
55
|
}
|
package/dist/desktop.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RenderTree } from "@valancex/mesh-runtime";
|
|
1
|
+
import type { BoundaryValue, RenderTree } from "@valancex/mesh-runtime";
|
|
2
2
|
import type * as Valance from "@valancex/valance";
|
|
3
3
|
import type { DefinedApp } from "./index.js";
|
|
4
4
|
import type { Content } from "./model.js";
|
|
@@ -8,6 +8,8 @@ export interface DesktopParts {
|
|
|
8
8
|
readonly author?: DefinedApp | undefined;
|
|
9
9
|
/** Where the project's `public/` files are on this machine: an image whose address is a file there is drawn from it. Absent: images are drawn as their descriptions. */
|
|
10
10
|
readonly assets?: string | undefined;
|
|
11
|
+
/** The theme, resolved for the window (`@valancex/style`'s `resolveForDesktop`): data the engine does not read, given to the window once. Absent: the window keeps its own look. */
|
|
12
|
+
readonly theme?: BoundaryValue | undefined;
|
|
11
13
|
/** The MESH runtime's module, when the host reads it (the generated entry reads the copy beside the engine). */
|
|
12
14
|
readonly wasm?: Uint8Array | undefined;
|
|
13
15
|
}
|
|
@@ -19,4 +21,4 @@ export interface DesktopParts {
|
|
|
19
21
|
export declare const assetPath: (assets: string, src: string) => string | undefined;
|
|
20
22
|
/** The tree with each image's address turned into the file it names, where there is one. */
|
|
21
23
|
export declare const withAssets: (tree: RenderTree, assets: string) => RenderTree;
|
|
22
|
-
export declare const start: ({ content, program, author, wasm, assets }: DesktopParts) => Promise<void>;
|
|
24
|
+
export declare const start: ({ content, program, author, wasm, assets, theme }: DesktopParts) => Promise<void>;
|
package/dist/desktop.js
CHANGED
|
@@ -40,7 +40,7 @@ export const withAssets = (tree, assets) => {
|
|
|
40
40
|
};
|
|
41
41
|
return { ...tree, root: walk(tree.root) };
|
|
42
42
|
};
|
|
43
|
-
export const start = async ({ content, program, author, wasm, assets }) => {
|
|
43
|
+
export const start = async ({ content, program, author, wasm, assets, theme }) => {
|
|
44
44
|
if (wasm !== undefined) {
|
|
45
45
|
await init(wasm);
|
|
46
46
|
}
|
|
@@ -49,6 +49,7 @@ export const start = async ({ content, program, author, wasm, assets }) => {
|
|
|
49
49
|
await Native.run(application(content, program, author), {
|
|
50
50
|
// The window follows the system's colors unless the application asks otherwise (`appearance` in `defineApp`), and the application learns the system's (`colorScheme`).
|
|
51
51
|
appearanceOf: (state) => author?.appearance?.(state) ?? "system",
|
|
52
|
+
...(theme === undefined ? {} : { theme }),
|
|
52
53
|
onScheme: (scheme, handle) => handle.invoke("app/colorScheme", [{ value: scheme }]),
|
|
53
54
|
// Back and Forward, as the window's keys and mouse buttons ask for them: the framework's own commands, which the author's `guard` may refuse.
|
|
54
55
|
onNavigate: (direction, handle) => handle.invoke(direction === "back" ? "app/back" : "app/forward", []),
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
/** At most this many events are replayed, and none older than this when it happens (H3). */
|
|
3
|
+
export declare const EARLY_LIMIT = 20;
|
|
4
|
+
export declare const EARLY_MAX_AGE_MS = 10000;
|
|
5
|
+
interface Entry {
|
|
6
|
+
readonly k: "click" | "key";
|
|
7
|
+
readonly n: HTMLElement;
|
|
8
|
+
readonly x?: string;
|
|
9
|
+
readonly t: number;
|
|
10
|
+
}
|
|
11
|
+
interface Shell {
|
|
12
|
+
q: Entry[];
|
|
13
|
+
busy: HTMLElement[];
|
|
14
|
+
on: number;
|
|
15
|
+
wasm: Promise<WebAssembly.Module | void> | 0;
|
|
16
|
+
snap?: () => void;
|
|
17
|
+
fields?: Array<{
|
|
18
|
+
readonly n: HTMLInputElement | HTMLTextAreaElement;
|
|
19
|
+
readonly x: string;
|
|
20
|
+
}>;
|
|
21
|
+
focus?: {
|
|
22
|
+
readonly n: HTMLInputElement | HTMLTextAreaElement;
|
|
23
|
+
readonly s: number | null;
|
|
24
|
+
readonly e: number | null;
|
|
25
|
+
} | 0;
|
|
26
|
+
/** What the replay did, for whoever drives the page. */
|
|
27
|
+
done?: {
|
|
28
|
+
readonly replayed: number;
|
|
29
|
+
readonly dropped: number;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
declare global {
|
|
33
|
+
interface Window {
|
|
34
|
+
__vlx?: Shell;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** The shell the page's head started, if it did (a page served by something other than `vlx` has none). */
|
|
38
|
+
export declare const shell: () => Shell | undefined;
|
|
39
|
+
/** Reads the fields whose text differs from the server's, and the caret, just before hydration writes the application's values over them (H4, H10). */
|
|
40
|
+
export declare const snapEarly: () => void;
|
|
41
|
+
/** The WebAssembly module the shell compiled while the script downloaded, if it did and it worked; else nothing, and the runtime loads the module itself. */
|
|
42
|
+
export declare const earlyModule: () => Promise<WebAssembly.Module | undefined>;
|
|
43
|
+
export interface Replayed {
|
|
44
|
+
readonly replayed: number;
|
|
45
|
+
readonly dropped: number;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Replays what the shell kept, once. `adopted` is whether hydration kept the server's nodes: if it drew afresh the nodes are gone and nothing is replayed (H3). `settle` waits for the commands
|
|
49
|
+
* the last dispatch started.
|
|
50
|
+
*/
|
|
51
|
+
export declare const replayEarly: (container: HTMLElement, adopted: boolean, settle: () => Promise<void>, now?: () => number) => Promise<Replayed>;
|
|
52
|
+
/** The page is ready, or has failed: it is no longer starting. */
|
|
53
|
+
export declare const finishStarting: (container: HTMLElement) => void;
|
|
54
|
+
export declare const settleOf: (mounted: {
|
|
55
|
+
readonly settled: Effect.Effect<void>;
|
|
56
|
+
}) => (() => Promise<void>);
|
|
57
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// What the page does with what the early-input shell kept (`early.ts`; Bet 14, decisions H3, H4, H6, H10): after hydration has adopted the server's nodes, in order and one at a time, waiting for each
|
|
2
|
+
// command to settle. It dispatches ordinary events at the same nodes, so the PORT and the application see what they would have seen had the application been ready; neither knows. Development
|
|
3
|
+
// and production alike.
|
|
4
|
+
import { Effect } from "effect";
|
|
5
|
+
/** At most this many events are replayed, and none older than this when it happens (H3). */
|
|
6
|
+
export const EARLY_LIMIT = 20;
|
|
7
|
+
export const EARLY_MAX_AGE_MS = 10_000;
|
|
8
|
+
/** The shell the page's head started, if it did (a page served by something other than `vlx` has none). */
|
|
9
|
+
export const shell = () => window.__vlx;
|
|
10
|
+
/** Reads the fields whose text differs from the server's, and the caret, just before hydration writes the application's values over them (H4, H10). */
|
|
11
|
+
export const snapEarly = () => window.__vlx?.snap?.();
|
|
12
|
+
/** The WebAssembly module the shell compiled while the script downloaded, if it did and it worked; else nothing, and the runtime loads the module itself. */
|
|
13
|
+
export const earlyModule = async () => {
|
|
14
|
+
const compiled = await window.__vlx?.wasm;
|
|
15
|
+
return compiled instanceof WebAssembly.Module ? compiled : undefined;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Replays what the shell kept, once. `adopted` is whether hydration kept the server's nodes: if it drew afresh the nodes are gone and nothing is replayed (H3). `settle` waits for the commands
|
|
19
|
+
* the last dispatch started.
|
|
20
|
+
*/
|
|
21
|
+
export const replayEarly = async (container, adopted, settle, now = Date.now) => {
|
|
22
|
+
const shell = window.__vlx;
|
|
23
|
+
if (shell === undefined) {
|
|
24
|
+
return { replayed: 0, dropped: 0 };
|
|
25
|
+
}
|
|
26
|
+
const queue = shell.q.splice(0);
|
|
27
|
+
const fields = shell.fields ?? [];
|
|
28
|
+
const alive = (node) => adopted && node.isConnected && container.contains(node);
|
|
29
|
+
let replayed = 0;
|
|
30
|
+
let dropped = 0;
|
|
31
|
+
shell.on = 0; // from here the page is listening itself
|
|
32
|
+
// The text first (H4): a field's value is part of what the next tap acts on.
|
|
33
|
+
for (const { n, x } of fields) {
|
|
34
|
+
if (!alive(n)) {
|
|
35
|
+
dropped += 1;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
n.value = x;
|
|
39
|
+
n.dispatchEvent(new Event("input", { bubbles: true }));
|
|
40
|
+
await settle();
|
|
41
|
+
replayed += 1;
|
|
42
|
+
}
|
|
43
|
+
for (const entry of queue.slice(0, EARLY_LIMIT)) {
|
|
44
|
+
if (!alive(entry.n) || now() - entry.t > EARLY_MAX_AGE_MS) {
|
|
45
|
+
dropped += 1;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (entry.k === "click") {
|
|
49
|
+
entry.n.click();
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
entry.n.dispatchEvent(new KeyboardEvent("keydown", { key: entry.x ?? "", bubbles: true, cancelable: true }));
|
|
53
|
+
}
|
|
54
|
+
await settle();
|
|
55
|
+
replayed += 1;
|
|
56
|
+
}
|
|
57
|
+
dropped += Math.max(0, queue.length - EARLY_LIMIT);
|
|
58
|
+
const focus = shell.focus;
|
|
59
|
+
if (focus !== undefined && focus !== 0 && alive(focus.n)) {
|
|
60
|
+
focus.n.focus();
|
|
61
|
+
if (focus.s !== null && focus.e !== null) {
|
|
62
|
+
focus.n.setSelectionRange(focus.s, focus.e);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
shell.busy.splice(0).forEach((node) => node.removeAttribute("aria-busy"));
|
|
66
|
+
shell.fields = [];
|
|
67
|
+
shell.done = { replayed, dropped };
|
|
68
|
+
return { replayed, dropped };
|
|
69
|
+
};
|
|
70
|
+
/** The page is ready, or has failed: it is no longer starting. */
|
|
71
|
+
export const finishStarting = (container) => {
|
|
72
|
+
delete container.dataset["vlx"];
|
|
73
|
+
window.__vlx?.busy.splice(0).forEach((node) => node.removeAttribute("aria-busy"));
|
|
74
|
+
};
|
|
75
|
+
export const settleOf = (mounted) => () => Effect.runPromise(mounted.settled);
|
package/dist/early.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The shell. Hand-minified; `earlyShellLimit` is its size budget (a test holds it). */
|
|
2
|
+
export declare const earlyShell: string;
|
|
3
|
+
/** The most bytes the shell may have: it is on the critical path of every page (Bet 3, `budgets.json`). */
|
|
4
|
+
export declare const earlyShellLimit = 1500;
|
|
5
|
+
/** The markup the server writes in the head: the shell, and, when the page's WebAssembly runtime is at `wasm`, the link the shell compiles it from. */
|
|
6
|
+
export declare const earlyHead: (built: {
|
|
7
|
+
readonly wasm?: string | undefined;
|
|
8
|
+
}) => string;
|
package/dist/early.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The early-input shell (Bet 14; decisions H2 to H5, H9 to H11): a few hundred bytes of script written into every page's <head>, before the page's own script has arrived. For the second or so
|
|
2
|
+
// between the server's HTML being painted and the application starting, a tap on a control does nothing, because nothing is listening yet (measured: 7 of 9 taps in that dead zone were lost). The
|
|
3
|
+
// shell listens from the first byte and keeps what a person did, so the client can do it once the application is ready (`replayEarly`, below):
|
|
4
|
+
//
|
|
5
|
+
// - a trusted click (a tap, a key press on a focused control) on a control inside the page, in order, at most 20 and none older than 10 seconds when they are replayed; never a link (the browser
|
|
6
|
+
// already navigates it) and never a control marked `earlyInput="drop"` (that one is dropped and shows the busy state: "Pay" and "Like" are both buttons, H9);
|
|
7
|
+
// - the keys Enter and Escape in a text field (H11);
|
|
8
|
+
// - the text of every field whose value differs from the server's, and the caret of the focused one (H4, H10): read once, when hydration begins.
|
|
9
|
+
//
|
|
10
|
+
// It also starts compiling the WebAssembly runtime as the document is parsed (`compileStreaming`, W11), when the page names it in `<link id="vlx-wasm">`, so the compile overlaps the download of the
|
|
11
|
+
// page's script.
|
|
12
|
+
//
|
|
13
|
+
// The shell is the same bytes in every page, so a Content-Security-Policy can allow it by hash (`vlx build` computes it and writes it into `_headers`) without `'unsafe-inline'`. It decides nothing and has no configuration: PORTs do not
|
|
14
|
+
// know it exists (H6), and replay is the composer's feature, done by dispatching ordinary events at the adopted nodes.
|
|
15
|
+
/** The shell. Hand-minified; `earlyShellLimit` is its size budget (a test holds it). */
|
|
16
|
+
export const earlyShell = `(function(d,w){var v=w.__vlx={q:[],busy:[],on:1,wasm:0},l=d.getElementById("vlx-wasm"),T="#app input:not([type=checkbox]):not([type=radio]),#app textarea";` +
|
|
17
|
+
`if(l&&w.WebAssembly&&WebAssembly.compileStreaming)v.wasm=WebAssembly.compileStreaming(fetch(l.href)).catch(function(){});` +
|
|
18
|
+
`function on(t,f){d.addEventListener(t,function(e){v.on&&e.isTrusted&&f(e)},!0)}` +
|
|
19
|
+
`function add(k,n,x){if(v.q.length<20)v.q.push({k:k,n:n,x:x,t:Date.now()})}` +
|
|
20
|
+
`on("click",function(e){var t=e.target.closest&&e.target.closest("a[href],button,[role=button],input,select,textarea,summary,label");` +
|
|
21
|
+
`if(t&&t.tagName=="LABEL")t=t.control;if(!t||t.closest("a[href]")||!t.closest("#app"))return;` +
|
|
22
|
+
`if(t.closest("[data-early-input=drop]")){t.setAttribute("aria-busy","true");v.busy.push(t);return}add("click",t)});` +
|
|
23
|
+
`on("keydown",function(e){var t=e.target;(e.key=="Enter"||e.key=="Escape")&&t.matches&&t.matches(T)&&add("key",t,e.key)});` +
|
|
24
|
+
`v.snap=function(){var f=[],a=d.activeElement;d.querySelectorAll(T).forEach(function(n){n.value!=n.defaultValue&&f.push({n:n,x:n.value})});` +
|
|
25
|
+
`v.fields=f;v.focus=a&&a.matches&&a.matches(T)?{n:a,s:a.selectionStart,e:a.selectionEnd}:0}` +
|
|
26
|
+
`})(document,window);`;
|
|
27
|
+
/** The most bytes the shell may have: it is on the critical path of every page (Bet 3, `budgets.json`). */
|
|
28
|
+
export const earlyShellLimit = 1500;
|
|
29
|
+
/** The markup the server writes in the head: the shell, and, when the page's WebAssembly runtime is at `wasm`, the link the shell compiles it from. */
|
|
30
|
+
export const earlyHead = (built) => `${built.wasm === undefined ? "" : `<link id="vlx-wasm" rel="preload" as="fetch" type="application/wasm" crossorigin href="${built.wasm}">`}<script>${earlyShell}</script>`;
|
package/dist/form.d.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import type { Derived, DeriveMaker, Field } from "./index.js";
|
|
3
|
+
/** A mistake in the schema a form is made from, said at the declaration and naming the field. */
|
|
4
|
+
export declare class FormError extends Error {
|
|
5
|
+
constructor(message: string);
|
|
6
|
+
}
|
|
7
|
+
/** What a field is, for choosing how to draw it: a line of text, a secret one, a number the person types as text, a yes or no, one of a closed set. */
|
|
8
|
+
export type Kind = "text" | "password" | "number" | "boolean" | "choice";
|
|
9
|
+
/** One field of the schema, as a screen needs to know it. Static: it is the same for every state. */
|
|
10
|
+
export interface Descriptor {
|
|
11
|
+
/** Where the field is in the schema, joined with dots for a nested record: `address.zip`. */
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly path: ReadonlyArray<string>;
|
|
14
|
+
/** The label: the schema's `title` annotation. */
|
|
15
|
+
readonly title: string;
|
|
16
|
+
readonly kind: Kind;
|
|
17
|
+
/** Whether a value must be given. A text field that is left empty is absent when the field is optional. */
|
|
18
|
+
readonly required: boolean;
|
|
19
|
+
/** The values of a closed set, in the schema's order. */
|
|
20
|
+
readonly choices: ReadonlyArray<string>;
|
|
21
|
+
/** The constraints the schema's refinements declare, as JSON Schema keywords: `minLength`, `maximum`, `pattern`, … */
|
|
22
|
+
readonly constraints: Readonly<Record<string, unknown>>;
|
|
23
|
+
/** Whether the value is a secret: it is never in a draft, and the default message never repeats it. */
|
|
24
|
+
readonly redacted: boolean;
|
|
25
|
+
/** Whether the schema gives its own message for the field's constraints. */
|
|
26
|
+
readonly hasMessage: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** What the form keeps: all of it plain data, so it can be a field of the application's state. */
|
|
29
|
+
export interface State {
|
|
30
|
+
/** The text each field holds (a yes or no for a boolean one), by field name. */
|
|
31
|
+
readonly values: Readonly<Record<string, string | boolean>>;
|
|
32
|
+
/** The fields the person has left. */
|
|
33
|
+
readonly touched: Readonly<Record<string, true>>;
|
|
34
|
+
/** How many times a submit has been tried, or a server has answered one with messages: the count a host watches to take the person to the first field in error (`ApplicationDefinition.focusFirstInvalid`). */
|
|
35
|
+
readonly attempts: number;
|
|
36
|
+
/** The messages a server gave, by field name; a field's is gone when it is edited. */
|
|
37
|
+
readonly remote: Readonly<Record<string, string>>;
|
|
38
|
+
}
|
|
39
|
+
/** What a template reads about one field. */
|
|
40
|
+
export interface FieldView {
|
|
41
|
+
readonly name: string;
|
|
42
|
+
readonly title: string;
|
|
43
|
+
readonly kind: Kind;
|
|
44
|
+
/** The `kind` of a `textfield` that holds it: `password` for a secret, `text` otherwise. */
|
|
45
|
+
readonly input: string;
|
|
46
|
+
readonly required: boolean;
|
|
47
|
+
readonly choices: ReadonlyArray<string>;
|
|
48
|
+
readonly value: string;
|
|
49
|
+
readonly checked: boolean;
|
|
50
|
+
/** Whether an error is shown for the field now. */
|
|
51
|
+
readonly invalid: boolean;
|
|
52
|
+
/** The message to show, empty when none is shown. */
|
|
53
|
+
readonly error: string;
|
|
54
|
+
/** The address of the element that shows the message, always drawn (empty when there is no message): what the control's `describedby` names. */
|
|
55
|
+
readonly errorId: string;
|
|
56
|
+
readonly touched: boolean;
|
|
57
|
+
readonly dirty: boolean;
|
|
58
|
+
}
|
|
59
|
+
/** What a template reads about the form. */
|
|
60
|
+
export interface View {
|
|
61
|
+
readonly fields: ReadonlyArray<FieldView>;
|
|
62
|
+
/** Whether the schema accepts what is typed (whatever is shown). */
|
|
63
|
+
readonly valid: boolean;
|
|
64
|
+
/** After a failed submit: how many fields need attention, for a live region. Empty otherwise. */
|
|
65
|
+
readonly message: string;
|
|
66
|
+
/** The first field with an error shown: where a failed submit takes the person. Empty when none. */
|
|
67
|
+
readonly first: string;
|
|
68
|
+
readonly submitted: boolean;
|
|
69
|
+
}
|
|
70
|
+
/** The type of `View`, for the derived value a template reads. */
|
|
71
|
+
export declare const ViewSchema: Schema.Schema<View>;
|
|
72
|
+
/** Messages by field name that a server (or any check the client cannot make) gives: a command that fails with this has the form show them as if the schema had said so. */
|
|
73
|
+
export interface FieldErrors {
|
|
74
|
+
readonly _tag: "FieldErrors";
|
|
75
|
+
readonly errors: Readonly<Record<string, string>>;
|
|
76
|
+
}
|
|
77
|
+
export declare const fieldErrors: (errors: Readonly<Record<string, string>>) => FieldErrors;
|
|
78
|
+
/** What a translation function is told about a leaf of the schema's own report, for a field that has no `message` of its own. */
|
|
79
|
+
export interface Issue {
|
|
80
|
+
readonly path: ReadonlyArray<string>;
|
|
81
|
+
/** Which check failed: the schema id of a refinement (`minLength`, `int`, …) or the kind of the report (`Type`, `Missing`, …). */
|
|
82
|
+
readonly code: string;
|
|
83
|
+
/** The check's parameters, as JSON Schema keywords: `{ minLength: 2 }`. */
|
|
84
|
+
readonly params: Readonly<Record<string, unknown>>;
|
|
85
|
+
/** What was typed. Absent for a secret field. */
|
|
86
|
+
readonly actual: unknown;
|
|
87
|
+
}
|
|
88
|
+
export interface Options<I> {
|
|
89
|
+
/** The text each field starts with. Absent: empty text, and `false` for a boolean. */
|
|
90
|
+
readonly initial?: Partial<I>;
|
|
91
|
+
/** The words for a field that has no `message` annotation. Absent: the schema's own text, which is for developers. */
|
|
92
|
+
readonly message?: (issue: Issue) => string | undefined;
|
|
93
|
+
}
|
|
94
|
+
/** The result of trying to submit. */
|
|
95
|
+
export type Submitted<A> = {
|
|
96
|
+
readonly ok: true;
|
|
97
|
+
readonly value: A;
|
|
98
|
+
readonly state: State;
|
|
99
|
+
} | {
|
|
100
|
+
readonly ok: false;
|
|
101
|
+
readonly state: State;
|
|
102
|
+
};
|
|
103
|
+
/** A form made from a schema. */
|
|
104
|
+
export interface Form<A, I> {
|
|
105
|
+
/** The fields, in the schema's order. */
|
|
106
|
+
readonly fields: ReadonlyArray<Descriptor>;
|
|
107
|
+
/** The state a form starts in. */
|
|
108
|
+
readonly initial: State;
|
|
109
|
+
/** The schema, as the Standard Schema interface (`~standard`), for tools that take that. */
|
|
110
|
+
readonly standard: unknown;
|
|
111
|
+
/** Things in the schema worth a word to its author: a constraint with no message of its own. Empty when there are none. */
|
|
112
|
+
readonly warnings: ReadonlyArray<string>;
|
|
113
|
+
/** The form as a field of the application's state. */
|
|
114
|
+
readonly field: () => Field<State>;
|
|
115
|
+
/** What a template reads, for a state. */
|
|
116
|
+
readonly view: {
|
|
117
|
+
(state: State): View;
|
|
118
|
+
/** As a derived value of the application: `signup.view(derive, (state) => state.signup)`. */
|
|
119
|
+
<S>(derive: DeriveMaker<S>, from: (state: S) => State): Derived<View>;
|
|
120
|
+
};
|
|
121
|
+
readonly edit: (state: State, name: string, value: string) => State;
|
|
122
|
+
readonly check: (state: State, name: string, value: boolean) => State;
|
|
123
|
+
readonly blur: (state: State, name: string) => State;
|
|
124
|
+
/** Tries to submit: the decoded value when the schema accepts what is typed, and in any case the state with every field touched. */
|
|
125
|
+
readonly submit: (state: State) => Submitted<A>;
|
|
126
|
+
/** The messages of a failed command, shown on their fields. */
|
|
127
|
+
readonly reject: (state: State, errors: FieldErrors) => State;
|
|
128
|
+
/** What was typed, without secrets, to keep and give back: `initial` takes it. */
|
|
129
|
+
readonly draft: (state: State) => Draft;
|
|
130
|
+
/** The state a draft makes. */
|
|
131
|
+
readonly restore: (draft: Draft) => State;
|
|
132
|
+
/** The commands of the form on the application's state: `edit(name, text)`, `check(name, yes)`, `blur(name)` and `submit()`. */
|
|
133
|
+
readonly commands: <S>(command: CommandMaker<S>, how: Where<S, A>) => Commands;
|
|
134
|
+
/** The encoded type, for a caller that wants to name it. */
|
|
135
|
+
readonly encoded?: I;
|
|
136
|
+
}
|
|
137
|
+
/** What was typed, serializable. Never has a secret. */
|
|
138
|
+
export interface Draft {
|
|
139
|
+
/** Which schema it is for: a draft of another shape is not restored. */
|
|
140
|
+
readonly version: string;
|
|
141
|
+
readonly values: Readonly<Record<string, string | boolean>>;
|
|
142
|
+
}
|
|
143
|
+
/** The commands a form adds to the application's table. */
|
|
144
|
+
export interface Commands {
|
|
145
|
+
readonly edit: unknown;
|
|
146
|
+
readonly check: unknown;
|
|
147
|
+
readonly blur: unknown;
|
|
148
|
+
readonly submit: unknown;
|
|
149
|
+
}
|
|
150
|
+
/** Where the form is in the application's state, and what a submit that the schema accepts does. */
|
|
151
|
+
export interface Where<S, A> {
|
|
152
|
+
/** The state field that holds the form (`state: { signup: signup.field() }` is `"signup"`): the usual way to say where it is. */
|
|
153
|
+
readonly field?: string;
|
|
154
|
+
/** Where the form is, when it is not simply a field of the state. */
|
|
155
|
+
readonly get?: (state: S) => State;
|
|
156
|
+
readonly set?: (state: S, form: State) => S;
|
|
157
|
+
/** The application's state after a submit that the schema accepted, from the decoded value. The form keeps what was typed. */
|
|
158
|
+
readonly submitted: (state: S, value: A) => S;
|
|
159
|
+
}
|
|
160
|
+
/** The part of `Valance.CommandMaker` the form uses, so this file imports no more of Valance than that. */
|
|
161
|
+
export interface CommandMaker<S> {
|
|
162
|
+
<I>(input: Schema.Schema<I, any, never>, transition: (input: I, current: S) => S): unknown;
|
|
163
|
+
(transition: (current: S) => S): unknown;
|
|
164
|
+
}
|
|
165
|
+
export declare function make<A, I>(schema: Schema.Schema<A, I, never>, options?: Options<I>): Form<A, I>;
|