@valancex/common 0.11.0 → 0.13.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2 -1
  3. package/dist/announce.d.ts +25 -0
  4. package/dist/announce.js +39 -0
  5. package/dist/app.d.ts +7 -4
  6. package/dist/app.js +192 -34
  7. package/dist/client.js +21 -2
  8. package/dist/color-scheme.d.ts +12 -0
  9. package/dist/color-scheme.js +15 -0
  10. package/dist/desktop.d.ts +22 -0
  11. package/dist/desktop.js +71 -0
  12. package/dist/http.d.ts +68 -0
  13. package/dist/http.js +194 -0
  14. package/dist/index.d.ts +114 -0
  15. package/dist/index.js +4 -0
  16. package/dist/internal.d.ts +7 -0
  17. package/dist/internal.js +7 -0
  18. package/dist/model.d.ts +8 -0
  19. package/dist/model.js +2 -1
  20. package/dist/navigation.d.ts +6 -0
  21. package/dist/navigation.js +5 -0
  22. package/dist/open-system.d.ts +12 -0
  23. package/dist/open-system.js +31 -0
  24. package/dist/open.d.ts +23 -0
  25. package/dist/open.js +47 -0
  26. package/dist/platform.d.ts +5 -0
  27. package/dist/platform.js +4 -0
  28. package/dist/primitives.js +26 -1
  29. package/dist/routes.js +13 -1
  30. package/dist/scope.d.ts +18 -3
  31. package/dist/scope.js +23 -9
  32. package/dist/server.js +3 -2
  33. package/dist/store-file.d.ts +4 -0
  34. package/dist/store-file.js +49 -0
  35. package/dist/store.d.ts +40 -0
  36. package/dist/store.js +60 -0
  37. package/dist/targets.d.ts +8 -0
  38. package/dist/targets.js +22 -0
  39. package/dist/transport.d.ts +37 -0
  40. package/dist/transport.js +1 -0
  41. package/dist/viewport.d.ts +15 -0
  42. package/dist/viewport.js +24 -0
  43. package/dist/vocabulary.d.ts +30 -0
  44. package/dist/vocabulary.js +28 -0
  45. package/dist/web-app.d.ts +6 -0
  46. package/dist/web-app.js +20 -0
  47. package/package.json +17 -7
package/dist/http.d.ts ADDED
@@ -0,0 +1,68 @@
1
+ import * as Nexus from "@valancex/nexus";
2
+ import { Effect, Schema } from "effect";
3
+ import type { StoreClient } from "./store.js";
4
+ import type { HttpError, Interceptor, Method, TransportRequest } from "./transport.js";
5
+ export type { HttpError, Interceptor, Method, Transport, TransportRequest, TransportResponse } from "./transport.js";
6
+ /** One request: where, what is sent, and what the answer must be. */
7
+ export interface RequestSpec<A> {
8
+ readonly method?: Method;
9
+ /** On the API's base: starts with `/` (not `//`), no scheme, no backslash. Anything else is `Unsafe`: a path built from data cannot send the request somewhere else. */
10
+ readonly path: string;
11
+ /** Appended to the address, encoded. `undefined` values are left out. */
12
+ readonly query?: Readonly<Record<string, string | number | boolean | undefined>>;
13
+ /** Sent as JSON. */
14
+ readonly body?: unknown;
15
+ /** What the answer must be. The decoded value is what the command receives; an answer that is not this is `Decode`, and nothing else gets through. */
16
+ readonly response: Schema.Schema<A, any, never>;
17
+ /** Milliseconds before the request is given up (`Timeout`), for each attempt. Default 15 000. */
18
+ readonly timeoutMs?: number;
19
+ /** Interceptors for this request alone: the innermost, the closest to the wire (see `Interceptor`). */
20
+ readonly interceptors?: ReadonlyArray<Interceptor>;
21
+ }
22
+ export interface HttpClient {
23
+ readonly request: <A>(spec: RequestSpec<A>) => Effect.Effect<A, HttpError>;
24
+ }
25
+ /** The capability: ask for it in a command (`Http.request`), and the platform says whether it has one. */
26
+ export declare const Http: Nexus.Capability.Capability<HttpClient>;
27
+ /** Makes a request through the platform's client. Requires the `Http` capability. */
28
+ export declare const request: <A>(spec: RequestSpec<A>) => Effect.Effect<A, HttpError | Nexus.Capability.CapabilityUnavailableError, Nexus.Capability.EnvironmentShape>;
29
+ export interface WebHttpOptions {
30
+ /** `api.base` in `valance.web.json`: `""` (the site's own origin), a path (`/api`) or an absolute https address. */
31
+ readonly base: string;
32
+ /** Default `globalThis.fetch`. Tests pass their own. */
33
+ readonly fetch?: typeof fetch;
34
+ /** Send cookies to an `api.base` on another origin. Default off: a cross-origin backend gets no ambient credentials unless the application says so. */
35
+ readonly credentials?: "same-origin" | "include";
36
+ /** The largest answer read, in characters. Default 5 000 000. */
37
+ readonly maxResponse?: number;
38
+ /** The application's interceptors, outermost first: the ones that hold for every request (`defineApp({ http: { interceptors } })`). Scoped ones are added by `withInterceptors` and `RequestSpec.interceptors`. */
39
+ readonly interceptors?: ReadonlyArray<Interceptor>;
40
+ }
41
+ /** The address for `path` under `base`, or why it cannot be one. */
42
+ export declare const addressOf: (base: string, path: string, query: RequestSpec<unknown>["query"]) => {
43
+ readonly url: string;
44
+ } | {
45
+ readonly unsafe: string;
46
+ };
47
+ /** Several interceptors as one, the first listed outermost: `chain(a, b)(t)` is `a(b(t))`. */
48
+ export declare const chain: (...interceptors: ReadonlyArray<Interceptor>) => Interceptor;
49
+ /** An interceptor that applies only to the requests `applies` accepts; every other request goes straight to the next. This is how a scope narrows: by address, by method, by anything the request says. */
50
+ export declare const only: (applies: (request: TransportRequest) => boolean, interceptor: Interceptor) => Interceptor;
51
+ /**
52
+ * Runs `effect` with `interceptors` added for the requests it makes: a scope narrower than the application's. A scope inside another is inside it (the outer ones see the request first and the
53
+ * answer last). It ends with the effect; a request made outside it is not touched.
54
+ *
55
+ * work: () => withInterceptors(Http.request({ path: "/admin/users", response: Users }), bearer(...adminToken))
56
+ */
57
+ export declare const withInterceptors: <A, E, R>(effect: Effect.Effect<A, E, R>, ...interceptors: ReadonlyArray<Interceptor>) => Effect.Effect<A, E, R>;
58
+ /** Adds `Authorization: Bearer <token>` to requests for the API's origin and to no other: a credential never follows a request somewhere else. `base` is `api.base`. */
59
+ export declare const bearer: (base: string, token: () => string | undefined | Promise<string | undefined>) => Interceptor;
60
+ /** On a 401, runs `refresh` once however many requests failed together, then sends each of them once more. A second 401 is the answer. */
61
+ export declare const refreshOn401: (refresh: () => Promise<void>) => Interceptor;
62
+ /** Sends a GET again, up to `times` more, when it did not connect or the server failed (5xx). Never another method: a repeated POST is a second order. */
63
+ export declare const retryGet: (times: number) => Interceptor;
64
+ export declare const webHttp: (options: WebHttpOptions) => HttpClient;
65
+ /** The platform for a web page: the `Http` capability, made from `api.base`. Merged with the application's own by `Web.run`. */
66
+ export declare const webPlatform: (options: WebHttpOptions & {
67
+ readonly store?: StoreClient;
68
+ }) => Nexus.Application.Platform;
package/dist/http.js ADDED
@@ -0,0 +1,194 @@
1
+ // Talking to the application's backend. NEXUS and VALANCE hold no HTTP (their rule): what an application needs from where it runs is a *capability*, a typed contract the application states and the
2
+ // platform supplies. This is that contract for HTTP, and the web's implementation of it. A desktop or mobile platform supplies the same contract with its own client (the OS's networking, a
3
+ // keychain for the token), and no command changes.
4
+ //
5
+ // const load = command.waiting(Schema.Struct({}), {
6
+ // begin: (_, s) => ({ ...s, inbox: { status: "loading" } }),
7
+ // work: () => Http.request({ path: "/inbox", response: Inbox }), // Effect<Inbox, HttpError, the Http capability>
8
+ // wanted: (_, s) => s.inbox.status === "loading",
9
+ // settle: (outcome, _, s) => ({ ...s, inbox: outcome.ok ? { status: "ready", ...outcome.value } : { status: "failed", why: describe(outcome.error) } }),
10
+ // });
11
+ //
12
+ // The defaults are the safe ones: a request goes to the application's own origin (or the one `api.base` names), carries no credentials to another origin, sends and accepts JSON, is
13
+ // bounded in time and size, and its response is *decoded by a Schema* before it can reach state. A failure says what kind it was and never what it carried.
14
+ import * as Nexus from "@valancex/nexus";
15
+ import { Effect, FiberRef, Schema } from "effect";
16
+ import { Open, webOpen } from "./open.js";
17
+ import { Store } from "./store.js";
18
+ /** The capability: ask for it in a command (`Http.request`), and the platform says whether it has one. */
19
+ export const Http = Nexus.Capability.define("valance/http");
20
+ /** Makes a request through the platform's client. Requires the `Http` capability. */
21
+ export const request = (spec) => Effect.flatMap(Nexus.Capability.require(Http), (client) => client.request(spec));
22
+ const UNSAFE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
23
+ const DEFAULT_TIMEOUT = 15_000;
24
+ const DEFAULT_MAX = 5_000_000;
25
+ /** The address for `path` under `base`, or why it cannot be one. */
26
+ export const addressOf = (base, path, query) => {
27
+ // oxlint-disable-next-line no-control-regex -- refusing control characters in a path is the point
28
+ if (!path.startsWith("/") || path.startsWith("//") || path.includes("\\") || /[\u0000-\u001f]/.test(path) || /^\/[^/?#]*:\/\//.test(path)) {
29
+ return { unsafe: 'a path starts with a single "/" and holds no scheme, backslash or control character' };
30
+ }
31
+ if (path.split(/[/?#]/).includes("..")) {
32
+ return { unsafe: 'a path does not climb out of the API ("..")' };
33
+ }
34
+ const search = new URLSearchParams();
35
+ for (const [name, value] of Object.entries(query ?? {})) {
36
+ if (value !== undefined) {
37
+ search.append(name, String(value));
38
+ }
39
+ }
40
+ const tail = search.size === 0 ? "" : `${path.includes("?") ? "&" : "?"}${search.toString()}`;
41
+ return { url: `${base.replace(/\/+$/, "")}${path}${tail}` };
42
+ };
43
+ // ---- interceptors ------------------------------------------------------------------------------------------------------------------------------------------
44
+ //
45
+ // One shape for all of them. A `Transport` takes the request the client built and checked, and gives the answer before the client decodes it. An `Interceptor` takes the next `Transport` and gives
46
+ // a `Transport`: so every interceptor has the same input and the same output, they chain by plain composition, and the only thing that differs between two is what they do. The client's own
47
+ // checks come first (the path, the method, the body) and last (the answer's status, type and Schema), so no interceptor can skip them. Failures are `HttpError`s, which carry no address, header or body.
48
+ /** Several interceptors as one, the first listed outermost: `chain(a, b)(t)` is `a(b(t))`. */
49
+ export const chain = (...interceptors) => (next) => interceptors.reduceRight((inner, intercept) => intercept(inner), next);
50
+ /** An interceptor that applies only to the requests `applies` accepts; every other request goes straight to the next. This is how a scope narrows: by address, by method, by anything the request says. */
51
+ export const only = (applies, interceptor) => (next) => {
52
+ const inner = interceptor(next);
53
+ return (request) => (applies(request) ? inner(request) : next(request));
54
+ };
55
+ /** The interceptors in force for the requests made inside `withInterceptors`. The client reads it for each request, so a scope reaches every request its effect makes, however deep. */
56
+ const scope = FiberRef.unsafeMake([]);
57
+ /**
58
+ * Runs `effect` with `interceptors` added for the requests it makes: a scope narrower than the application's. A scope inside another is inside it (the outer ones see the request first and the
59
+ * answer last). It ends with the effect; a request made outside it is not touched.
60
+ *
61
+ * work: () => withInterceptors(Http.request({ path: "/admin/users", response: Users }), bearer(...adminToken))
62
+ */
63
+ export const withInterceptors = (effect, ...interceptors) => Effect.locallyWith(effect, scope, (current) => [...current, ...interceptors]);
64
+ /** Whether the request goes to the origin the application's API is on (the base's, or the page's for a relative base). */
65
+ const sameApi = (base, url) => {
66
+ const origin = (value) => {
67
+ try {
68
+ return new URL(value, "http://page.invalid").origin;
69
+ }
70
+ catch {
71
+ return "";
72
+ }
73
+ };
74
+ return origin(url) !== "" && origin(url) === origin(base === "" ? "/" : base);
75
+ };
76
+ /** Adds `Authorization: Bearer <token>` to requests for the API's origin and to no other: a credential never follows a request somewhere else. `base` is `api.base`. */
77
+ export const bearer = (base, token) => (next) => (request) => !sameApi(base, request.url)
78
+ ? next(request)
79
+ : Effect.flatMap(Effect.promise(async () => token()), (value) => next(value === undefined ? request : { ...request, headers: { ...request.headers, authorization: `Bearer ${value}` } }));
80
+ /** On a 401, runs `refresh` once however many requests failed together, then sends each of them once more. A second 401 is the answer. */
81
+ export const refreshOn401 = (refresh) => {
82
+ let refreshing;
83
+ return (next) => (request) => Effect.flatMap(next(request), (first) => first.status !== 401
84
+ ? Effect.succeed(first)
85
+ : Effect.flatMap(Effect.tryPromise({
86
+ try: async () => {
87
+ refreshing ??= refresh().finally(() => void (refreshing = undefined));
88
+ return refreshing;
89
+ },
90
+ catch: () => ({ _tag: "Offline" }),
91
+ }), () => next(request)));
92
+ };
93
+ /** Sends a GET again, up to `times` more, when it did not connect or the server failed (5xx). Never another method: a repeated POST is a second order. */
94
+ export const retryGet = (times) => (next) => (request) => {
95
+ const attempt = (n) => request.method !== "GET"
96
+ ? next(request)
97
+ : next(request).pipe(Effect.flatMap((response) => (response.status >= 500 && n < times ? attempt(n + 1) : Effect.succeed(response))), Effect.catchTag("Offline", (error) => (n < times ? attempt(n + 1) : Effect.fail(error))));
98
+ return attempt(0);
99
+ };
100
+ // ---- the web's implementation ------------------------------------------------------------------------------------------------------------------------------------
101
+ /** The last transport: `fetch`, with the timeout, the size bound and the safe defaults. It returns any status; the client judges it after the interceptors have seen it. */
102
+ const fetchTransport = (options, crossOrigin) => {
103
+ const send = options.fetch ?? ((...args) => globalThis.fetch(...args));
104
+ return (request) => Effect.tryPromise({
105
+ try: async (signal) => {
106
+ const controller = new AbortController();
107
+ const timer = setTimeout(() => controller.abort("timeout"), request.timeoutMs);
108
+ const stop = () => controller.abort("aborted");
109
+ signal.addEventListener("abort", stop, { once: true });
110
+ try {
111
+ const response = await send(request.url, {
112
+ method: request.method,
113
+ headers: { ...request.headers },
114
+ ...(request.body === undefined ? {} : { body: request.body }),
115
+ credentials: crossOrigin ? (options.credentials ?? "omit") : "same-origin",
116
+ redirect: "follow",
117
+ referrerPolicy: "strict-origin-when-cross-origin",
118
+ cache: "no-store",
119
+ signal: controller.signal,
120
+ });
121
+ const headers = {};
122
+ response.headers.forEach((value, name) => void (headers[name.toLowerCase()] = value));
123
+ const text = response.status === 204 ? "" : await response.text();
124
+ if (text.length > (options.maxResponse ?? DEFAULT_MAX)) {
125
+ throw { _tag: "TooLarge" };
126
+ }
127
+ return { status: response.status, headers, text };
128
+ }
129
+ catch (error) {
130
+ // A fetch that was aborted rejects with the abort's reason, whatever that was: what we passed says who stopped it.
131
+ if (controller.signal.aborted) {
132
+ throw { _tag: controller.signal.reason === "timeout" ? "Timeout" : "Aborted" };
133
+ }
134
+ throw error;
135
+ }
136
+ finally {
137
+ clearTimeout(timer);
138
+ signal.removeEventListener("abort", stop);
139
+ if (controller.signal.aborted === false) {
140
+ controller.abort("done");
141
+ }
142
+ }
143
+ },
144
+ catch: (error) => (typeof error === "object" && error !== null && "_tag" in error ? error : { _tag: "Offline" }),
145
+ });
146
+ };
147
+ export const webHttp = (options) => {
148
+ const crossOrigin = /^https?:\/\//i.test(options.base);
149
+ const wire = fetchTransport(options, crossOrigin);
150
+ const global = chain(...(options.interceptors ?? []));
151
+ return {
152
+ request: (spec) => Effect.gen(function* () {
153
+ const method = spec.method ?? "GET";
154
+ const address = addressOf(options.base, spec.path, spec.query);
155
+ if ("unsafe" in address) {
156
+ return yield* Effect.fail({ _tag: "Unsafe", reason: address.unsafe });
157
+ }
158
+ const body = spec.body === undefined ? undefined : JSON.stringify(spec.body);
159
+ if (method === "GET" && body !== undefined) {
160
+ return yield* Effect.fail({ _tag: "Unsafe", reason: "a GET request has no body" });
161
+ }
162
+ const request = {
163
+ method,
164
+ url: address.url,
165
+ headers: {
166
+ accept: "application/json",
167
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
168
+ // A header a plain cross-site form cannot send: with the backend checking it (or SameSite cookies), a forged request from another page has nothing to ride on.
169
+ ...(UNSAFE_METHODS.has(method) ? { "x-requested-with": "valance" } : {}),
170
+ },
171
+ ...(body === undefined ? {} : { body }),
172
+ timeoutMs: spec.timeoutMs ?? DEFAULT_TIMEOUT,
173
+ };
174
+ // Whatever the interceptors did to the request, it goes where the API is: the address is checked again as the wire gets it.
175
+ const guarded = (final) => sameApi(options.base, final.url) ? wire(final) : Effect.fail({ _tag: "Unsafe", reason: "an interceptor sent the request outside the API's origin" });
176
+ const scoped = yield* FiberRef.get(scope);
177
+ const response = yield* chain(global, ...scoped, ...(spec.interceptors ?? []))(guarded)(request);
178
+ if (response.status < 200 || response.status >= 300) {
179
+ return yield* Effect.fail({ _tag: "Status", status: response.status });
180
+ }
181
+ if (response.status !== 204 && !/^application\/(?:[\w.+-]+\+)?json\b/i.test(response.headers["content-type"] ?? "")) {
182
+ return yield* Effect.fail({ _tag: "Decode" }); // an HTML error page or a proxy's reply is not the API's answer
183
+ }
184
+ const json = yield* Effect.try({ try: () => (response.text === "" ? undefined : JSON.parse(response.text)), catch: () => ({ _tag: "Decode" }) });
185
+ return yield* Schema.decodeUnknown(spec.response)(json).pipe(Effect.mapError(() => ({ _tag: "Decode" })));
186
+ }),
187
+ };
188
+ };
189
+ /** The platform for a web page: the `Http` capability, made from `api.base`. Merged with the application's own by `Web.run`. */
190
+ export const webPlatform = (options) => Nexus.Capability.EnvironmentLive(new Map([
191
+ [Http.id, { _tag: "Available", implementation: webHttp(options) }],
192
+ [Open.id, { _tag: "Available", implementation: webOpen() }],
193
+ ...(options.store === undefined ? [] : [[Store.id, { _tag: "Available", implementation: options.store }]]),
194
+ ]));
package/dist/index.d.ts CHANGED
@@ -1,10 +1,25 @@
1
1
  /// <reference path="../types/mprx.d.ts" preserve="true" />
2
2
  import type * as Valance from "@valancex/valance";
3
3
  import { Schema } from "effect";
4
+ import type { Interceptor } from "./transport.js";
4
5
  /** The part of the application's state the framework owns: which page is shown. A command's `state` always has it. */
5
6
  export interface PageState {
6
7
  readonly page: string;
8
+ /** How much room the page has: `compact` (under 600 px wide: a phone), `medium` (600 to 839) or `expanded` (840 and up). The framework keeps it current as the window changes; a layout reads it as `viewport`. */
9
+ readonly viewport: Viewport;
10
+ /** The pages visited, in order, for `back` and `forward`: `go` adds the page it goes to after the one shown (and drops any that were ahead). */
11
+ readonly trail: ReadonlyArray<string>;
12
+ /** Where in `trail` the page shown is. */
13
+ readonly cursor: number;
14
+ /** The colors the person's system asks for: `light` or `dark`. The framework keeps it current (the page's `prefers-color-scheme`, the window's system theme); a layout reads it as `colorScheme`. The server renders `light`; the page corrects it as it starts. */
15
+ readonly colorScheme: ColorScheme;
7
16
  }
17
+ /** The system's color preference. */
18
+ export type ColorScheme = "light" | "dark";
19
+ /** What an application asks its window to look like: a scheme, or `system` for the person's own. */
20
+ export type Appearance = ColorScheme | "system";
21
+ /** Window size classes, the ones Material Design and iOS size classes agree on closely enough to design for. */
22
+ export type Viewport = "compact" | "medium" | "expanded";
8
23
  export declare const FIELD: unique symbol;
9
24
  /** A state field: its type and its default, declared together. */
10
25
  export interface Field<A = unknown> {
@@ -16,6 +31,15 @@ export interface Field<A = unknown> {
16
31
  * Declares one field of the application's state with its type and its default in one place. The framework builds the state's schema and its initial value from the fields; the default is
17
32
  * checked against the type (by TypeScript, and again when the application is built).
18
33
  */
34
+ export declare const DERIVED: unique symbol;
35
+ /** A value worked out from the state, and the type it has. A template reads it by name, like a state field, and no command changes it. */
36
+ export interface Derived<A = unknown> {
37
+ readonly [DERIVED]: true;
38
+ readonly schema: Schema.Schema<A, any, never>;
39
+ readonly from: (state: any) => A;
40
+ }
41
+ /** What `derived` is given: `derive(type, (state) => value)`. The state is the application's, as a command sees it. */
42
+ export type DeriveMaker<S> = <A, I = A>(schema: Schema.Schema<A, I, never>, from: (state: S) => A) => Derived<A>;
19
43
  export declare const field: <A, I = A>(schema: Schema.Schema<A, I, never>, initial: NoInfer<A>) => Field<A>;
20
44
  /** Fields declared one by one. */
21
45
  export type Fields = Readonly<Record<string, Field<any>>>;
@@ -65,7 +89,33 @@ export interface Contract<P extends string, E extends string> {
65
89
  * written again there. Where the toolchain has generated the template's type, a contract that disagrees with the template's keys is a compile-time error.
66
90
  */
67
91
  export declare function component<P extends string = string, E extends string = string>(template: Template<P, E>, contract?: Contract<NoInfer<P>, NoInfer<E>>): Component;
92
+ /**
93
+ * What the document's title is made of. `page` is the page's own part (default: its title, from the Markdown front matter), `app` the application's name (default: the site's name), `status` what is
94
+ * going on right now and is not part of any page (an unread count, "Saving…"): it is written first, in parentheses. The project's format in \`valance.json\` (`"title": "{page} · {site}"`) puts them
95
+ * together. A target that is not a web page uses the same three parts for its own surfaces (a window title, a navigation bar, a badge). All text: control and direction-override characters are removed
96
+ * and the length is limited, so a title built from user data cannot reorder or hide what surrounds it.
97
+ */
98
+ export interface TitleParts {
99
+ readonly page?: string;
100
+ readonly app?: string;
101
+ readonly status?: string;
102
+ }
103
+ /** What differs between targets: the templates. Everything else in the definition (state, commands, title, http) is the application's, and the same on every target. */
104
+ export interface TargetParts {
105
+ readonly layout?: Component;
106
+ readonly components?: ReadonlyArray<Component>;
107
+ readonly override?: ReadonlyArray<Component>;
108
+ }
68
109
  export interface AppDefinition<S extends StateInput = {}> {
110
+ /**
111
+ * The templates of one target, for a project with more than one (`valance.web.json` and `valance.desktop.json`): `targets: { web: { layout, components }, desktop: { layout } }`. A target's
112
+ * `layout` replaces the top-level one, and its `components` and `override` are added to it. A target's templates are compiled against that target's vocabulary only; the state, the commands and
113
+ * the content are the one application's.
114
+ */
115
+ readonly targets?: {
116
+ readonly web?: TargetParts;
117
+ readonly desktop?: TargetParts;
118
+ };
69
119
  /** The page's shell. Omit it to use the built-in layout. Its template must be named `layout.mprx`, and it has no props or events: its inputs are the state and the commands. */
70
120
  readonly layout?: Component;
71
121
  /** Every component of the application besides the layout and the overrides. A template uses one by writing its tag; one that no template writes yet is not part of the program. */
@@ -77,13 +127,77 @@ export interface AppDefinition<S extends StateInput = {}> {
77
127
  * hydration and shared schemas.
78
128
  */
79
129
  readonly state?: S;
130
+ /**
131
+ * The document's title for a state: `(state, page) => { status: unread > 0 ? String(unread) : undefined }`. A string is the page part. Absent: the page's title and the site's name. It runs for
132
+ * every committed state, on the server for the state it renders and in the browser after each one; it should be cheap and must not throw (a throw is logged and the title stays).
133
+ */
134
+ readonly title?: (state: PageState & StateOf<S>, page: {
135
+ readonly path: string;
136
+ readonly title: string;
137
+ }) => TitleParts | string;
138
+ /**
139
+ * What the application sends to its backend, for every request: `http: { interceptors: [bearer(base, token), refreshOn401(refresh), retryGet(2)] }`, outermost first. Interceptors
140
+ * (`@valancex/common/http`) all have one shape, `(next) => next`, so they chain; a narrower scope is `withInterceptors(effect, ...)` around a command's work, or `interceptors` on one request.
141
+ * Used by the page; the server makes no requests.
142
+ */
143
+ readonly http?: {
144
+ readonly interceptors?: ReadonlyArray<Interceptor>;
145
+ };
146
+ /**
147
+ * The appearance this application asks of its window, for a state: `appearance: (state) => state.theme` (`"light"`, `"dark"` or `"system"`). A page draws its own colors, so it is the desktop
148
+ * window's: the window follows the system's theme unless this says otherwise. Absent: the system's.
149
+ */
150
+ readonly appearance?: (state: PageState & StateOf<S>) => Appearance;
151
+ /**
152
+ * The state fields that outlive a run: `persist: ["items", "theme"]`. They are loaded when the application starts and saved when they change, through the platform's `Store` (the page's
153
+ * storage on the web, a file in the user's data directory on the desktop), under the application's name. What was stored and no longer fits a field's type is ignored, not loaded. Where a
154
+ * platform has no store the application runs as it would without this. A field not listed is not kept (a `navigation` flag on one target should not follow the person to another).
155
+ */
156
+ readonly persist?: ReadonlyArray<Extract<keyof StateOf<S>, string> | "navigation">;
157
+ /**
158
+ * A guard for moving between pages by `go`, `back` and `forward`: the router's `Guard`, asked before the move. It is told where the application is going from and to (a route named for the
159
+ * page, with no parameters), the state, and why: `kind` is `"programmatic"` for `go` and `"history"` for `back` and `forward`. `true` lets it through, `false` stops it (nothing changes),
160
+ * a `redirect(...)` sends it to another page. A guard that throws or fails stops the move. Without one every move is allowed. A page's own Back and Forward are the browser's: the router
161
+ * guards those as `"popstate"`.
162
+ */
163
+ readonly guard?: NavigationGuard;
164
+ /**
165
+ * Values worked out from the state, for a template to read: `derived: (derive) => ({ open: derive(Schema.Number, (state) => state.items.filter((item) => !item.done).length) })`. Each has its
166
+ * type, as a state field does. They are computed from the state each time it changes, on every target, and a command cannot change one (change the state they come from). The function must
167
+ * be pure and must not throw.
168
+ */
169
+ readonly derived?: (derive: DeriveMaker<PageState & StateOf<S>>) => {
170
+ readonly [name: string]: Derived<any>;
171
+ };
80
172
  /** The commands a layout can run. `command` makes one: `command((state) => nextState)` or `command(InputSchema, (input, state) => nextState)`. */
81
173
  readonly commands?: (command: Valance.CommandMaker<PageState & StateOf<S>>) => Valance.CommandTable<any, Valance.Ambient>;
82
174
  }
175
+ /** What a navigation guard is told; the router's `GuardContext`, spelled out so the author's entry needs nothing but VALANCE and effect. */
176
+ export interface NavigationContext {
177
+ readonly from: {
178
+ readonly name: string;
179
+ readonly params: Readonly<Record<string, unknown>>;
180
+ readonly query: Readonly<Record<string, unknown>>;
181
+ } | undefined;
182
+ readonly to: {
183
+ readonly name: string;
184
+ readonly params: Readonly<Record<string, unknown>>;
185
+ readonly query: Readonly<Record<string, unknown>>;
186
+ };
187
+ readonly state: unknown;
188
+ readonly kind: "link" | "popstate" | "programmatic" | "history" | "initial" | "close";
189
+ readonly data: Readonly<Record<string, unknown>>;
190
+ }
191
+ /** `true` allows, `false` stops, and `{ _tag: "Redirect", name }` (the router's `redirect(page)`) goes to another page; it may answer now, later or as an Effect. */
192
+ export type NavigationGuard = (context: NavigationContext) => unknown;
83
193
  /** The brand that says a value came from `defineApp`. */
84
194
  export declare const APP: unique symbol;
85
195
  export interface DefinedApp extends AppDefinition<any> {
86
196
  readonly [APP]: true;
87
197
  }
88
198
  /** Declares the application's component tree, state and commands. It starts nothing; the framework runs it. */
199
+ /** What `derived` makes, for the toolchain: the function is called with the one `derive`. */
200
+ export declare const derivedOf: (author: {
201
+ readonly derived?: AppDefinition<any>["derived"];
202
+ } | undefined) => Readonly<Record<string, Derived<any>>>;
89
203
  export declare const defineApp: <S extends StateInput = {}>(definition: AppDefinition<S>) => DefinedApp;
package/dist/index.js CHANGED
@@ -4,6 +4,8 @@ export const FIELD = Symbol.for("valancex.common.field");
4
4
  * Declares one field of the application's state with its type and its default in one place. The framework builds the state's schema and its initial value from the fields; the default is
5
5
  * checked against the type (by TypeScript, and again when the application is built).
6
6
  */
7
+ export const DERIVED = Symbol.for("valancex.common.derived");
8
+ const derive = (schema, from) => ({ [DERIVED]: true, schema: schema, from });
7
9
  export const field = (schema, initial) => ({ [FIELD]: true, schema: schema, initial });
8
10
  export const COMPONENT = Symbol.for("valancex.common.component");
9
11
  /**
@@ -21,4 +23,6 @@ export function component(template, contract = {}) {
21
23
  /** The brand that says a value came from `defineApp`. */
22
24
  export const APP = Symbol.for("valancex.common");
23
25
  /** Declares the application's component tree, state and commands. It starts nothing; the framework runs it. */
26
+ /** What `derived` makes, for the toolchain: the function is called with the one `derive`. */
27
+ export const derivedOf = (author) => author?.derived?.(derive) ?? {};
24
28
  export const defineApp = (definition) => ({ ...definition, [APP]: true });
@@ -1,6 +1,13 @@
1
1
  export * from "./app.js";
2
+ export * from "./web-app.js";
2
3
  export * from "./model.js";
3
4
  export * from "./primitives.js";
4
5
  export * from "./routes.js";
5
6
  export * from "./scope.js";
6
7
  export * from "./state.js";
8
+ export * from "./vocabulary.js";
9
+ export * from "./targets.js";
10
+ export * from "./store.js";
11
+ export * from "./open.js";
12
+ export * from "./navigation.js";
13
+ export * from "./platform.js";
package/dist/internal.js CHANGED
@@ -1,8 +1,15 @@
1
1
  // What the build tools share with the runtime: the content model, the tag table, the scope a layout reads, the state and the application they make. `@valancex/cli` builds with these and the generated
2
2
  // entries run them, so there is one definition of each. Not for applications: an application's code is `@valancex/common` (the entry point).
3
3
  export * from "./app.js";
4
+ export * from "./web-app.js";
4
5
  export * from "./model.js";
5
6
  export * from "./primitives.js";
6
7
  export * from "./routes.js";
7
8
  export * from "./scope.js";
8
9
  export * from "./state.js";
10
+ export * from "./vocabulary.js";
11
+ export * from "./targets.js";
12
+ export * from "./store.js";
13
+ export * from "./open.js";
14
+ export * from "./navigation.js";
15
+ export * from "./platform.js";
package/dist/model.d.ts CHANGED
@@ -54,7 +54,15 @@ export interface SiteInfo {
54
54
  readonly base: string;
55
55
  /** Where the site is published (no trailing slash), or empty. */
56
56
  readonly url: string;
57
+ /** How a document's title is written from a page's title and the site's name: `{page}` and `{site}`. */
58
+ readonly title: string;
59
+ /** The address the application's backend requests go to (a path on the site's origin, or an absolute https address), or empty when it has none. */
60
+ readonly api: string;
61
+ /** Tell assistive technology about a page change made in place (the title, and focus at the content). */
62
+ readonly announce: boolean;
57
63
  }
64
+ /** The title format when the project does not say: the page, then the site. */
65
+ export declare const DEFAULT_TITLE = "{page} \u00B7 {site}";
58
66
  export interface Content {
59
67
  readonly site: SiteInfo;
60
68
  /** In navigation order. */
package/dist/model.js CHANGED
@@ -1,3 +1,4 @@
1
1
  // The content model: what the build produces from `content/` and what the application and its layout consume. Plain JSON-able data. A MESH scope is flat, so a block is one record whose
2
2
  // unused fields are empty (`kind` says which are meaningful); the empty values are explicit so a template never reads something missing.
3
- export {};
3
+ /** The title format when the project does not say: the page, then the site. */
4
+ export const DEFAULT_TITLE = "{page} · {site}";
@@ -0,0 +1,6 @@
1
+ import * as Nexus from "@valancex/nexus";
2
+ /** The platform has no history of its own: the engine keeps the list (`trail` and `cursor`). */
3
+ export interface NavigationOwner {
4
+ readonly owner: "engine";
5
+ }
6
+ export declare const Navigation: Nexus.Capability.Capability<NavigationOwner>;
@@ -0,0 +1,5 @@
1
+ // Who keeps the list of places the person has been (docs/application-model/NAVIGATION.md). A page has the browser's: its session history holds the list, restores it, and moves along it with the
2
+ // person's Back and Forward. A window has none, so the engine keeps the list in its state; a platform that supplies this capability is one of those, and only there does `persist: ["navigation"]`
3
+ // do anything (restoring a stored page on the web would argue with the address, which is the truth there).
4
+ import * as Nexus from "@valancex/nexus";
5
+ export const Navigation = Nexus.Capability.define("valance/navigation");
@@ -0,0 +1,12 @@
1
+ import type { OpenClient } from "./open.js";
2
+ /** The program and arguments that open `address` on `platform`. */
3
+ export declare const openerFor: (address: string, platform?: NodeJS.Platform) => {
4
+ readonly program: string;
5
+ readonly args: ReadonlyArray<string>;
6
+ };
7
+ type Spawn = (program: string, args: ReadonlyArray<string>) => {
8
+ readonly on: (event: "error", listener: (error: Error) => void) => unknown;
9
+ readonly unref: () => void;
10
+ };
11
+ export declare const systemOpen: (spawn?: Spawn, platform?: NodeJS.Platform) => OpenClient;
12
+ export {};
@@ -0,0 +1,31 @@
1
+ // The desktop's opener: the system's own, given the address as an argument (never through a shell, so the address is data and nothing else). Node only.
2
+ import { Effect } from "effect";
3
+ import { spawn as nodeSpawn } from "node:child_process";
4
+ import { OpenError, refusal } from "./open.js";
5
+ /** The program and arguments that open `address` on `platform`. */
6
+ export const openerFor = (address, platform = process.platform) => platform === "win32"
7
+ ? { program: "rundll32", args: ["url.dll,FileProtocolHandler", address] }
8
+ : platform === "darwin"
9
+ ? { program: "open", args: [address] }
10
+ : { program: "xdg-open", args: [address] };
11
+ export const systemOpen = (spawn = (program, args) => nodeSpawn(program, [...args], { detached: true, stdio: "ignore" }), platform = process.platform) => ({
12
+ open: (address) => {
13
+ const reason = refusal(address);
14
+ if (reason !== undefined) {
15
+ return Effect.fail(new OpenError("Refused", reason));
16
+ }
17
+ const { program, args } = openerFor(address, platform);
18
+ return Effect.async((resume) => {
19
+ try {
20
+ const child = spawn(program, args);
21
+ child.on("error", () => resume(Effect.fail(new OpenError("Unavailable", `${program} could not be started`))));
22
+ child.unref();
23
+ // handed over: the opener is the system's, and does not report back to a program that asked
24
+ setTimeout(() => resume(Effect.void), 0);
25
+ }
26
+ catch {
27
+ resume(Effect.fail(new OpenError("Unavailable", `${program} could not be started`)));
28
+ }
29
+ });
30
+ },
31
+ });
package/dist/open.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import * as Nexus from "@valancex/nexus";
2
+ import { Effect } from "effect";
3
+ export declare class OpenError extends Error {
4
+ readonly kind: "Refused" | "Unavailable";
5
+ readonly _tag = "OpenError";
6
+ constructor(kind: "Refused" | "Unavailable", message: string);
7
+ }
8
+ export interface OpenClient {
9
+ /** Opens `address` in the program the platform keeps for it. Resolves when it was handed over, not when anything finished. */
10
+ readonly open: (address: string) => Effect.Effect<void, OpenError>;
11
+ }
12
+ /** The capability: ask for it in a command (`Open.open`), and the platform says whether it has one. */
13
+ export declare const Open: Nexus.Capability.Capability<OpenClient>;
14
+ /** The schemes an address may have to be opened. */
15
+ export declare const OPENABLE: readonly ["https:", "http:", "mailto:"];
16
+ /** Why `address` may not be opened, or `undefined` when it may: an absolute address of an openable scheme, without a control character, of a sensible length. */
17
+ export declare const refusal: (address: string) => string | undefined;
18
+ /** Opens `address` through the platform's opener. Requires the `Open` capability. */
19
+ export declare const open: (address: string) => Effect.Effect<void, OpenError | Nexus.Capability.CapabilityUnavailableError, Nexus.Capability.EnvironmentShape>;
20
+ /** The page's opener: a new tab, with no link back to this page. */
21
+ export declare const webOpen: (win?: {
22
+ readonly open: (url: string, target: string, features: string) => unknown;
23
+ }) => OpenClient;
package/dist/open.js ADDED
@@ -0,0 +1,47 @@
1
+ // Sending a person to an address outside the application: a web page in their browser, a mail in their mail program. NEXUS and VALANCE hold no such thing (their rule): it is a capability the
2
+ // platform supplies. The web's is a new tab; the desktop's is the system's own opener (`./open-system`). Only the addresses a person expects a link to open are opened: a script, a file or a
3
+ // program's own scheme is refused, whatever text asked.
4
+ import * as Nexus from "@valancex/nexus";
5
+ import { Effect } from "effect";
6
+ export class OpenError extends Error {
7
+ kind;
8
+ _tag = "OpenError";
9
+ constructor(kind, message) {
10
+ super(message);
11
+ this.kind = kind;
12
+ this.name = "OpenError";
13
+ }
14
+ }
15
+ /** The capability: ask for it in a command (`Open.open`), and the platform says whether it has one. */
16
+ export const Open = Nexus.Capability.define("valance/open");
17
+ /** The schemes an address may have to be opened. */
18
+ export const OPENABLE = ["https:", "http:", "mailto:"];
19
+ /** Why `address` may not be opened, or `undefined` when it may: an absolute address of an openable scheme, without a control character, of a sensible length. */
20
+ export const refusal = (address) => {
21
+ // oxlint-disable-next-line no-control-regex -- a control character in an address is how one is smuggled into a command line
22
+ if (address.length === 0 || address.length > 2048 || /[\u0000-\u001f\u007f\s]/.test(address)) {
23
+ return "an address is one line of text, at most 2048 characters";
24
+ }
25
+ try {
26
+ const url = new URL(address);
27
+ return OPENABLE.includes(url.protocol) ? undefined : `a ${url.protocol} address is not opened (only ${OPENABLE.join(", ")})`;
28
+ }
29
+ catch {
30
+ return "not an absolute address";
31
+ }
32
+ };
33
+ /** Opens `address` through the platform's opener. Requires the `Open` capability. */
34
+ export const open = (address) => Effect.flatMap(Nexus.Capability.require(Open), (client) => client.open(address));
35
+ /** The page's opener: a new tab, with no link back to this page. */
36
+ export const webOpen = (win) => ({
37
+ open: (address) => {
38
+ const reason = refusal(address);
39
+ if (reason !== undefined) {
40
+ return Effect.fail(new OpenError("Refused", reason));
41
+ }
42
+ const target = win ?? globalThis.window;
43
+ return target === undefined
44
+ ? Effect.fail(new OpenError("Unavailable", "this page cannot open a tab"))
45
+ : Effect.try({ try: () => void target.open(address, "_blank", "noopener,noreferrer"), catch: () => new OpenError("Unavailable", "the page's tab was blocked") });
46
+ },
47
+ });
@@ -0,0 +1,5 @@
1
+ import * as Nexus from "@valancex/nexus";
2
+ /** A platform that supplies each `[capability, implementation]` given. */
3
+ export declare const platformOf: (capabilities: ReadonlyArray<readonly [{
4
+ readonly id: string;
5
+ }, unknown]>) => Nexus.Application.Platform;