@valancex/common 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,7 +25,7 @@ export default defineApp({
25
25
 
26
26
  | Export | Is |
27
27
  |---|---|
28
- | `defineApp({ layout?, components?, override?, state?, commands? })` | The application: its shell, its components, its state and the commands that change it. It starts nothing. |
28
+ | `defineApp({ layout?, components?, override?, state?, title?, commands? })` | The application: its shell, its components, its state, the title it wants for a state, and the commands that change it. It starts nothing. |
29
29
  | `component(template, { props?, events? })` | A component: a `.mprx` template and, optionally, the interface it states (Effect Schemas). Which components its template uses is read from the template. |
30
30
  | `field(schema, initial)` | One field of the state: its type and its default, together. |
31
31
 
@@ -34,6 +34,7 @@ export default defineApp({
34
34
  | Entry | Is |
35
35
  |---|---|
36
36
  | `@valancex/common` | The three exports above. No Node, bundler or terminal import: `src/main.ts` is loaded in the browser as well as at build time. |
37
+ | `@valancex/common/http` | The `Http` capability (`request({ path, response })`), its error type and the web's implementation. Imported by the commands that call a backend; it holds no `fetch` until the page supplies one. |
37
38
  | `@valancex/common/client`, `/server` | The browser and server halves of a built application: `start` takes over the server's HTML, `createRenderer` turns a path into a document. The files `vlx` generates import these; you do not. |
38
39
  | `@valancex/common/internal` | What the build tools share with the runtime (content model, tag table, the scope a layout reads). For `@valancex/cli`, not for applications. |
39
40
 
@@ -0,0 +1,25 @@
1
+ export interface Node_ {
2
+ textContent: string | null;
3
+ setAttribute(name: string, value: string): void;
4
+ hasAttribute?(name: string): boolean;
5
+ readonly style?: {
6
+ setProperty(name: string, value: string): void;
7
+ };
8
+ focus?(options?: {
9
+ preventScroll?: boolean;
10
+ }): void;
11
+ }
12
+ export interface AnnounceDocument {
13
+ readonly title: string;
14
+ readonly body: {
15
+ append(node: never): void;
16
+ };
17
+ getElementById(id: string): Node_ | null;
18
+ querySelector(selector: string): Node_ | null;
19
+ createElement(tag: string): Node_;
20
+ }
21
+ export declare const ANNOUNCER_ID = "valance-announcer";
22
+ /** Says the document's title, and focuses `#content` (or `<main>`) when it has a `tabindex`. */
23
+ export declare const announcePage: (document: AnnounceDocument, options?: {
24
+ readonly focus: boolean;
25
+ }) => void;
@@ -0,0 +1,39 @@
1
+ export const ANNOUNCER_ID = "valance-announcer";
2
+ /**
3
+ * Visually hidden, still read out. Set through the style object, not a `style` attribute: a page served with the strict policy `vlx build` writes (`style-src 'self'`) refuses an inline style
4
+ * attribute, and setting properties on `element.style` is the one way it allows.
5
+ */
6
+ const HIDDEN = [
7
+ ["position", "absolute"],
8
+ ["width", "1px"],
9
+ ["height", "1px"],
10
+ ["margin", "-1px"],
11
+ ["padding", "0"],
12
+ ["overflow", "hidden"],
13
+ ["clip", "rect(0 0 0 0)"],
14
+ ["clip-path", "inset(50%)"],
15
+ ["white-space", "nowrap"],
16
+ ["border", "0"],
17
+ ];
18
+ /** Says the document's title, and focuses `#content` (or `<main>`) when it has a `tabindex`. */
19
+ export const announcePage = (document, options = { focus: true }) => {
20
+ let region = document.getElementById(ANNOUNCER_ID);
21
+ if (region === null) {
22
+ region = document.createElement("div");
23
+ region.setAttribute("id", ANNOUNCER_ID);
24
+ region.setAttribute("role", "status");
25
+ region.setAttribute("aria-live", "polite");
26
+ HIDDEN.forEach(([name, value]) => region.style?.setProperty(name, value));
27
+ document.body.append(region);
28
+ }
29
+ // A region that says the same words again is not announced again: clear it first.
30
+ region.textContent = "";
31
+ region.textContent = document.title;
32
+ if (options.focus) {
33
+ const main = document.getElementById("content") ?? document.querySelector("main");
34
+ // Only an element the author made focusable (a `tabindex`): making one focusable here would put an outline on the whole page.
35
+ if (main !== null && main.hasAttribute?.("tabindex") === true) {
36
+ main.focus?.({ preventScroll: true });
37
+ }
38
+ }
39
+ };
package/dist/app.d.ts CHANGED
@@ -32,4 +32,4 @@ export declare const application: (content: Content, program: Valance.Program, a
32
32
  /** The state a request for `pathname` starts from. */
33
33
  export declare const stateFor: (content: Content, pathname: string, author?: DefinedApp) => AppState;
34
34
  /** The plugin that makes the application a website: its tags, its URL policy, and its document head. */
35
- export declare const plugin: (content: Content, built: Built) => Plugin<AppState>;
35
+ export declare const plugin: (content: Content, built: Built, author?: DefinedApp) => Plugin<AppState>;
package/dist/app.js CHANGED
@@ -7,7 +7,7 @@ import { documentTitle, frame, FrameScope, pageAt, RESERVED } from "./scope.js";
7
7
  import { primitives } from "./primitives.js";
8
8
  import { normalize, routes } from "./routes.js";
9
9
  import { badDefaults, stateOf } from "./state.js";
10
- const PageSchema = Schema.Struct({ page: Schema.String });
10
+ const PageSchema = Schema.Struct({ page: Schema.String, viewport: Schema.Literal("compact", "medium", "expanded") });
11
11
  /** A mistake in `src/main.ts`, reported with what to change. */
12
12
  export class AppDefinitionError extends Error {
13
13
  constructor(message) {
@@ -30,8 +30,14 @@ export const checkDefinition = (author) => {
30
30
  throw new AppDefinitionError(`src/main.ts: the default of the state field "${name}" is not a value of its type`);
31
31
  }
32
32
  // Building the table reads no state (a command reads it when it runs), so it can be built here, with nothing, to see its names.
33
- if ("go" in (author?.commands?.(Valance.command({})) ?? {})) {
34
- throw new AppDefinitionError('src/main.ts: "go" is the framework\'s command (it shows a page); choose another name');
33
+ const own = author?.commands?.(Valance.command({})) ?? {};
34
+ for (const [name, what] of [
35
+ ["go", "shows a page"],
36
+ ["resize", "tells the layout how much room the page has"],
37
+ ]) {
38
+ if (name in own) {
39
+ throw new AppDefinitionError(`src/main.ts: "${name}" is the framework's command (it ${what}); choose another name`);
40
+ }
35
41
  }
36
42
  };
37
43
  /** The names `src/main.ts` adds: state fields (readable in the layout) and commands (runnable from it). */
@@ -52,12 +58,19 @@ export const stateSchema = (author) => {
52
58
  export const commandTable = (author) => (state) => {
53
59
  const command = Valance.command(state);
54
60
  const own = author?.commands?.(command) ?? {};
55
- if ("go" in own) {
56
- throw new AppDefinitionError('src/main.ts: "go" is the framework\'s command (it shows a page); choose another name');
61
+ for (const name of ["go", "resize"]) {
62
+ if (name in own) {
63
+ throw new AppDefinitionError(`src/main.ts: "${name}" is the framework's command; choose another name`);
64
+ }
57
65
  }
58
- return { go: command(Schema.Struct({ id: Schema.String }), ({ id }, current) => ({ ...current, page: id })), ...own };
66
+ return {
67
+ go: command(Schema.Struct({ id: Schema.String }), ({ id }, current) => ({ ...current, page: id })),
68
+ // The window changed size: the browser tells the application, which tells the layout (`viewport`).
69
+ resize: command(Schema.Struct({ viewport: Schema.Literal("compact", "medium", "expanded") }), ({ viewport }, current) => ({ ...current, viewport })),
70
+ ...own,
71
+ };
59
72
  };
60
- export const initialState = (author) => ({ page: "/", ...stateOf(author)?.initial });
73
+ export const initialState = (author) => ({ page: "/", viewport: "expanded", ...stateOf(author)?.initial });
61
74
  export const application = (content, program, author) => {
62
75
  checkDefinition(author);
63
76
  return Valance.define({
@@ -68,7 +81,7 @@ export const application = (content, program, author) => {
68
81
  program,
69
82
  scope: (state) => {
70
83
  const { page, ...own } = state;
71
- return { ...frame(content, page), ...own };
84
+ return { ...frame(content, page, state.viewport), ...own };
72
85
  },
73
86
  },
74
87
  },
@@ -78,8 +91,23 @@ export const application = (content, program, author) => {
78
91
  };
79
92
  /** The state a request for `pathname` starts from. */
80
93
  export const stateFor = (content, pathname, author) => ({ ...initialState(author), page: normalize(pathname, content.site.base) });
94
+ /**
95
+ * The title for a state: the page's and the site's name, or what the author's `title` function says. A function that throws falls back to the default (a title is never worth a failed render),
96
+ * and the failure is reported by its name only.
97
+ */
98
+ const titleFor = (content, page, state, author) => {
99
+ let parts = {};
100
+ try {
101
+ const made = author?.title?.(state, { path: state.page, title: page?.title ?? "" });
102
+ parts = made === undefined ? {} : typeof made === "string" ? { page: made } : made;
103
+ }
104
+ catch (error) {
105
+ console.error(`the title function in src/main.ts failed (${error instanceof Error ? error.name : "error"}); the default title is used`);
106
+ }
107
+ return documentTitle(content, page, parts);
108
+ };
81
109
  /** The plugin that makes the application a website: its tags, its URL policy, and its document head. */
82
- export const plugin = (content, built) => ({
110
+ export const plugin = (content, built, author) => ({
83
111
  name: "valance-app",
84
112
  primitives,
85
113
  routes: routes(content),
@@ -88,7 +116,7 @@ export const plugin = (content, built) => ({
88
116
  const shown = page ?? { description: "" };
89
117
  const canonical = content.site.url === "" || page === undefined ? [] : [{ rel: "canonical", href: `${content.site.url}${content.site.base}${page.path.replace(/^\//, "")}` }];
90
118
  return {
91
- title: documentTitle(content, page),
119
+ title: titleFor(content, page, state, author),
92
120
  meta: shown.description === "" ? [] : [{ name: "description", content: shown.description }],
93
121
  links: [...built.styles.map((href) => ({ rel: "stylesheet", href })), ...canonical],
94
122
  };
package/dist/client.js CHANGED
@@ -2,6 +2,9 @@
2
2
  import { init } from "@valancex/mesh-runtime";
3
3
  import wasmUrl from "@valancex/mesh-runtime/mesh-runtime.wasm?url";
4
4
  import * as Web from "@valancex/valance/web";
5
+ import { Effect } from "effect";
6
+ import { webPlatform } from "./http.js";
7
+ import { watchViewport } from "./viewport.js";
5
8
  import { application, plugin } from "./app.js";
6
9
  export const start = async ({ content, program, author }) => {
7
10
  const container = document.getElementById("app");
@@ -12,12 +15,20 @@ export const start = async ({ content, program, author }) => {
12
15
  container,
13
16
  present: "hydrate",
14
17
  state: boot.state,
15
- plugins: [plugin(content, boot.built)],
18
+ plugins: [plugin(content, boot.built, author)],
19
+ // What the application's commands may ask the page for: the backend (`Http`), at `api.base` of valance.web.json.
20
+ platform: webPlatform({ base: content.site.api, interceptors: author?.http?.interceptors ?? [] }),
16
21
  shutdown: { grace: "250 millis" },
17
22
  });
23
+ // The server rendered for a wide window; tell the application how wide this one is (and keep telling it).
24
+ const unwatch = watchViewport(window, (viewport) => {
25
+ container.dataset["viewport"] = viewport; // for a stylesheet or a test; the layout reads the state's own `viewport`
26
+ void Effect.runPromise(host.handle.invoke("app/resize", [{ value: viewport }])).catch(() => undefined);
27
+ });
18
28
  container.dataset["valance"] = "running"; // readiness marker for whoever drives the page
19
29
  addEventListener("pagehide", (event) => {
20
30
  if (!event.persisted) {
31
+ unwatch();
21
32
  void host.stop();
22
33
  }
23
34
  });
package/dist/http.d.ts ADDED
@@ -0,0 +1,65 @@
1
+ import * as Nexus from "@valancex/nexus";
2
+ import { Effect, Schema } from "effect";
3
+ import type { HttpError, Interceptor, Method, TransportRequest } from "./transport.js";
4
+ export type { HttpError, Interceptor, Method, Transport, TransportRequest, TransportResponse } from "./transport.js";
5
+ /** One request: where, what is sent, and what the answer must be. */
6
+ export interface RequestSpec<A> {
7
+ readonly method?: Method;
8
+ /** 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. */
9
+ readonly path: string;
10
+ /** Appended to the address, encoded. `undefined` values are left out. */
11
+ readonly query?: Readonly<Record<string, string | number | boolean | undefined>>;
12
+ /** Sent as JSON. */
13
+ readonly body?: unknown;
14
+ /** 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. */
15
+ readonly response: Schema.Schema<A, any, never>;
16
+ /** Milliseconds before the request is given up (`Timeout`), for each attempt. Default 15 000. */
17
+ readonly timeoutMs?: number;
18
+ /** Interceptors for this request alone: the innermost, the closest to the wire (see `Interceptor`). */
19
+ readonly interceptors?: ReadonlyArray<Interceptor>;
20
+ }
21
+ export interface HttpClient {
22
+ readonly request: <A>(spec: RequestSpec<A>) => Effect.Effect<A, HttpError>;
23
+ }
24
+ /** The capability: ask for it in a command (`Http.request`), and the platform says whether it has one. */
25
+ export declare const Http: Nexus.Capability.Capability<HttpClient>;
26
+ /** Makes a request through the platform's client. Requires the `Http` capability. */
27
+ export declare const request: <A>(spec: RequestSpec<A>) => Effect.Effect<A, HttpError | Nexus.Capability.CapabilityUnavailableError, Nexus.Capability.EnvironmentShape>;
28
+ export interface WebHttpOptions {
29
+ /** `api.base` in `valance.web.json`: `""` (the site's own origin), a path (`/api`) or an absolute https address. */
30
+ readonly base: string;
31
+ /** Default `globalThis.fetch`. Tests pass their own. */
32
+ readonly fetch?: typeof fetch;
33
+ /** Send cookies to an `api.base` on another origin. Default off: a cross-origin backend gets no ambient credentials unless the application says so. */
34
+ readonly credentials?: "same-origin" | "include";
35
+ /** The largest answer read, in characters. Default 5 000 000. */
36
+ readonly maxResponse?: number;
37
+ /** 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`. */
38
+ readonly interceptors?: ReadonlyArray<Interceptor>;
39
+ }
40
+ /** The address for `path` under `base`, or why it cannot be one. */
41
+ export declare const addressOf: (base: string, path: string, query: RequestSpec<unknown>["query"]) => {
42
+ readonly url: string;
43
+ } | {
44
+ readonly unsafe: string;
45
+ };
46
+ /** Several interceptors as one, the first listed outermost: `chain(a, b)(t)` is `a(b(t))`. */
47
+ export declare const chain: (...interceptors: ReadonlyArray<Interceptor>) => Interceptor;
48
+ /** 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. */
49
+ export declare const only: (applies: (request: TransportRequest) => boolean, interceptor: Interceptor) => Interceptor;
50
+ /**
51
+ * 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
52
+ * answer last). It ends with the effect; a request made outside it is not touched.
53
+ *
54
+ * work: () => withInterceptors(Http.request({ path: "/admin/users", response: Users }), bearer(...adminToken))
55
+ */
56
+ export declare const withInterceptors: <A, E, R>(effect: Effect.Effect<A, E, R>, ...interceptors: ReadonlyArray<Interceptor>) => Effect.Effect<A, E, R>;
57
+ /** 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`. */
58
+ export declare const bearer: (base: string, token: () => string | undefined | Promise<string | undefined>) => Interceptor;
59
+ /** On a 401, runs `refresh` once however many requests failed together, then sends each of them once more. A second 401 is the answer. */
60
+ export declare const refreshOn401: (refresh: () => Promise<void>) => Interceptor;
61
+ /** 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. */
62
+ export declare const retryGet: (times: number) => Interceptor;
63
+ export declare const webHttp: (options: WebHttpOptions) => HttpClient;
64
+ /** The platform for a web page: the `Http` capability, made from `api.base`. Merged with the application's own by `Web.run`. */
65
+ export declare const webPlatform: (options: WebHttpOptions) => Nexus.Application.Platform;
package/dist/http.js ADDED
@@ -0,0 +1,188 @@
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
+ /** The capability: ask for it in a command (`Http.request`), and the platform says whether it has one. */
17
+ export const Http = Nexus.Capability.define("valance/http");
18
+ /** Makes a request through the platform's client. Requires the `Http` capability. */
19
+ export const request = (spec) => Effect.flatMap(Nexus.Capability.require(Http), (client) => client.request(spec));
20
+ const UNSAFE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
21
+ const DEFAULT_TIMEOUT = 15_000;
22
+ const DEFAULT_MAX = 5_000_000;
23
+ /** The address for `path` under `base`, or why it cannot be one. */
24
+ export const addressOf = (base, path, query) => {
25
+ // oxlint-disable-next-line no-control-regex -- refusing control characters in a path is the point
26
+ if (!path.startsWith("/") || path.startsWith("//") || path.includes("\\") || /[\u0000-\u001f]/.test(path) || /^\/[^/?#]*:\/\//.test(path)) {
27
+ return { unsafe: 'a path starts with a single "/" and holds no scheme, backslash or control character' };
28
+ }
29
+ if (path.split(/[/?#]/).includes("..")) {
30
+ return { unsafe: 'a path does not climb out of the API ("..")' };
31
+ }
32
+ const search = new URLSearchParams();
33
+ for (const [name, value] of Object.entries(query ?? {})) {
34
+ if (value !== undefined) {
35
+ search.append(name, String(value));
36
+ }
37
+ }
38
+ const tail = search.size === 0 ? "" : `${path.includes("?") ? "&" : "?"}${search.toString()}`;
39
+ return { url: `${base.replace(/\/+$/, "")}${path}${tail}` };
40
+ };
41
+ // ---- interceptors ------------------------------------------------------------------------------------------------------------------------------------------
42
+ //
43
+ // 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
44
+ // 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
45
+ // 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.
46
+ /** Several interceptors as one, the first listed outermost: `chain(a, b)(t)` is `a(b(t))`. */
47
+ export const chain = (...interceptors) => (next) => interceptors.reduceRight((inner, intercept) => intercept(inner), next);
48
+ /** 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. */
49
+ export const only = (applies, interceptor) => (next) => {
50
+ const inner = interceptor(next);
51
+ return (request) => (applies(request) ? inner(request) : next(request));
52
+ };
53
+ /** 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. */
54
+ const scope = FiberRef.unsafeMake([]);
55
+ /**
56
+ * 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
57
+ * answer last). It ends with the effect; a request made outside it is not touched.
58
+ *
59
+ * work: () => withInterceptors(Http.request({ path: "/admin/users", response: Users }), bearer(...adminToken))
60
+ */
61
+ export const withInterceptors = (effect, ...interceptors) => Effect.locallyWith(effect, scope, (current) => [...current, ...interceptors]);
62
+ /** Whether the request goes to the origin the application's API is on (the base's, or the page's for a relative base). */
63
+ const sameApi = (base, url) => {
64
+ const origin = (value) => {
65
+ try {
66
+ return new URL(value, "http://page.invalid").origin;
67
+ }
68
+ catch {
69
+ return "";
70
+ }
71
+ };
72
+ return origin(url) !== "" && origin(url) === origin(base === "" ? "/" : base);
73
+ };
74
+ /** 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`. */
75
+ export const bearer = (base, token) => (next) => (request) => !sameApi(base, request.url)
76
+ ? next(request)
77
+ : Effect.flatMap(Effect.promise(async () => token()), (value) => next(value === undefined ? request : { ...request, headers: { ...request.headers, authorization: `Bearer ${value}` } }));
78
+ /** On a 401, runs `refresh` once however many requests failed together, then sends each of them once more. A second 401 is the answer. */
79
+ export const refreshOn401 = (refresh) => {
80
+ let refreshing;
81
+ return (next) => (request) => Effect.flatMap(next(request), (first) => first.status !== 401
82
+ ? Effect.succeed(first)
83
+ : Effect.flatMap(Effect.tryPromise({
84
+ try: async () => {
85
+ refreshing ??= refresh().finally(() => void (refreshing = undefined));
86
+ return refreshing;
87
+ },
88
+ catch: () => ({ _tag: "Offline" }),
89
+ }), () => next(request)));
90
+ };
91
+ /** 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. */
92
+ export const retryGet = (times) => (next) => (request) => {
93
+ const attempt = (n) => request.method !== "GET"
94
+ ? next(request)
95
+ : 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))));
96
+ return attempt(0);
97
+ };
98
+ // ---- the web's implementation ------------------------------------------------------------------------------------------------------------------------------------
99
+ /** 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. */
100
+ const fetchTransport = (options, crossOrigin) => {
101
+ const send = options.fetch ?? ((...args) => globalThis.fetch(...args));
102
+ return (request) => Effect.tryPromise({
103
+ try: async (signal) => {
104
+ const controller = new AbortController();
105
+ const timer = setTimeout(() => controller.abort("timeout"), request.timeoutMs);
106
+ const stop = () => controller.abort("aborted");
107
+ signal.addEventListener("abort", stop, { once: true });
108
+ try {
109
+ const response = await send(request.url, {
110
+ method: request.method,
111
+ headers: { ...request.headers },
112
+ ...(request.body === undefined ? {} : { body: request.body }),
113
+ credentials: crossOrigin ? (options.credentials ?? "omit") : "same-origin",
114
+ redirect: "follow",
115
+ referrerPolicy: "strict-origin-when-cross-origin",
116
+ cache: "no-store",
117
+ signal: controller.signal,
118
+ });
119
+ const headers = {};
120
+ response.headers.forEach((value, name) => void (headers[name.toLowerCase()] = value));
121
+ const text = response.status === 204 ? "" : await response.text();
122
+ if (text.length > (options.maxResponse ?? DEFAULT_MAX)) {
123
+ throw { _tag: "TooLarge" };
124
+ }
125
+ return { status: response.status, headers, text };
126
+ }
127
+ catch (error) {
128
+ // A fetch that was aborted rejects with the abort's reason, whatever that was: what we passed says who stopped it.
129
+ if (controller.signal.aborted) {
130
+ throw { _tag: controller.signal.reason === "timeout" ? "Timeout" : "Aborted" };
131
+ }
132
+ throw error;
133
+ }
134
+ finally {
135
+ clearTimeout(timer);
136
+ signal.removeEventListener("abort", stop);
137
+ if (controller.signal.aborted === false) {
138
+ controller.abort("done");
139
+ }
140
+ }
141
+ },
142
+ catch: (error) => (typeof error === "object" && error !== null && "_tag" in error ? error : { _tag: "Offline" }),
143
+ });
144
+ };
145
+ export const webHttp = (options) => {
146
+ const crossOrigin = /^https?:\/\//i.test(options.base);
147
+ const wire = fetchTransport(options, crossOrigin);
148
+ const global = chain(...(options.interceptors ?? []));
149
+ return {
150
+ request: (spec) => Effect.gen(function* () {
151
+ const method = spec.method ?? "GET";
152
+ const address = addressOf(options.base, spec.path, spec.query);
153
+ if ("unsafe" in address) {
154
+ return yield* Effect.fail({ _tag: "Unsafe", reason: address.unsafe });
155
+ }
156
+ const body = spec.body === undefined ? undefined : JSON.stringify(spec.body);
157
+ if (method === "GET" && body !== undefined) {
158
+ return yield* Effect.fail({ _tag: "Unsafe", reason: "a GET request has no body" });
159
+ }
160
+ const request = {
161
+ method,
162
+ url: address.url,
163
+ headers: {
164
+ accept: "application/json",
165
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
166
+ // 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.
167
+ ...(UNSAFE_METHODS.has(method) ? { "x-requested-with": "valance" } : {}),
168
+ },
169
+ ...(body === undefined ? {} : { body }),
170
+ timeoutMs: spec.timeoutMs ?? DEFAULT_TIMEOUT,
171
+ };
172
+ // Whatever the interceptors did to the request, it goes where the API is: the address is checked again as the wire gets it.
173
+ 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" });
174
+ const scoped = yield* FiberRef.get(scope);
175
+ const response = yield* chain(global, ...scoped, ...(spec.interceptors ?? []))(guarded)(request);
176
+ if (response.status < 200 || response.status >= 300) {
177
+ return yield* Effect.fail({ _tag: "Status", status: response.status });
178
+ }
179
+ if (response.status !== 204 && !/^application\/(?:[\w.+-]+\+)?json\b/i.test(response.headers["content-type"] ?? "")) {
180
+ return yield* Effect.fail({ _tag: "Decode" }); // an HTML error page or a proxy's reply is not the API's answer
181
+ }
182
+ const json = yield* Effect.try({ try: () => (response.text === "" ? undefined : JSON.parse(response.text)), catch: () => ({ _tag: "Decode" }) });
183
+ return yield* Schema.decodeUnknown(spec.response)(json).pipe(Effect.mapError(() => ({ _tag: "Decode" })));
184
+ }),
185
+ };
186
+ };
187
+ /** The platform for a web page: the `Http` capability, made from `api.base`. Merged with the application's own by `Web.run`. */
188
+ export const webPlatform = (options) => Nexus.Capability.EnvironmentLive(new Map([[Http.id, { _tag: "Available", implementation: webHttp(options) }]]));
package/dist/index.d.ts CHANGED
@@ -1,10 +1,15 @@
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;
7
10
  }
11
+ /** Window size classes, the ones Material Design and iOS size classes agree on closely enough to design for. */
12
+ export type Viewport = "compact" | "medium" | "expanded";
8
13
  export declare const FIELD: unique symbol;
9
14
  /** A state field: its type and its default, declared together. */
10
15
  export interface Field<A = unknown> {
@@ -65,6 +70,17 @@ export interface Contract<P extends string, E extends string> {
65
70
  * 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
71
  */
67
72
  export declare function component<P extends string = string, E extends string = string>(template: Template<P, E>, contract?: Contract<NoInfer<P>, NoInfer<E>>): Component;
73
+ /**
74
+ * 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
75
+ * 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
76
+ * 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
77
+ * and the length is limited, so a title built from user data cannot reorder or hide what surrounds it.
78
+ */
79
+ export interface TitleParts {
80
+ readonly page?: string;
81
+ readonly app?: string;
82
+ readonly status?: string;
83
+ }
68
84
  export interface AppDefinition<S extends StateInput = {}> {
69
85
  /** 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
86
  readonly layout?: Component;
@@ -77,6 +93,22 @@ export interface AppDefinition<S extends StateInput = {}> {
77
93
  * hydration and shared schemas.
78
94
  */
79
95
  readonly state?: S;
96
+ /**
97
+ * 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
98
+ * 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).
99
+ */
100
+ readonly title?: (state: PageState & StateOf<S>, page: {
101
+ readonly path: string;
102
+ readonly title: string;
103
+ }) => TitleParts | string;
104
+ /**
105
+ * What the application sends to its backend, for every request: `http: { interceptors: [bearer(base, token), refreshOn401(refresh), retryGet(2)] }`, outermost first. Interceptors
106
+ * (`@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.
107
+ * Used by the page; the server makes no requests.
108
+ */
109
+ readonly http?: {
110
+ readonly interceptors?: ReadonlyArray<Interceptor>;
111
+ };
80
112
  /** The commands a layout can run. `command` makes one: `command((state) => nextState)` or `command(InputSchema, (input, state) => nextState)`. */
81
113
  readonly commands?: (command: Valance.CommandMaker<PageState & StateOf<S>>) => Valance.CommandTable<any, Valance.Ambient>;
82
114
  }
@@ -4,3 +4,4 @@ export * from "./primitives.js";
4
4
  export * from "./routes.js";
5
5
  export * from "./scope.js";
6
6
  export * from "./state.js";
7
+ export * from "./vocabulary.js";
package/dist/internal.js CHANGED
@@ -6,3 +6,4 @@ export * from "./primitives.js";
6
6
  export * from "./routes.js";
7
7
  export * from "./scope.js";
8
8
  export * from "./state.js";
9
+ export * from "./vocabulary.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}";
package/dist/routes.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { announcePage } from "./announce.js";
1
2
  import { pageAt } from "./scope.js";
2
3
  /** An address as the application sees it: under the base removed, no trailing slash, `/` for the first page. An address outside the base is kept whole (it is not a page). */
3
4
  export const normalize = (pathname, base) => {
@@ -15,8 +16,18 @@ export const routes = (content) => ({
15
16
  // Static first: every page is rendered once at build time.
16
17
  delivery: () => "static",
17
18
  onNavigated: ({ kind, url }) => {
19
+ if (typeof window === "undefined") {
20
+ return;
21
+ }
22
+ // A person who cannot see the page change is told it did: the new title, and focus at the content. Not for a fragment (the browser moves there itself), and not when the project turned it off.
23
+ const announce = () => {
24
+ if (content.site.announce && url.hash === "") {
25
+ setTimeout(() => announcePage(document), 0); // after the application has presented the new page
26
+ }
27
+ };
18
28
  // Land at the top of a new page, or at its heading when the address names one; Back and Forward are the browser's to restore.
19
- if (kind !== "link" || typeof window === "undefined") {
29
+ if (kind !== "link") {
30
+ announce();
20
31
  return;
21
32
  }
22
33
  // The application's state has no fragment, so the address bar lost it: put it back.
@@ -30,5 +41,6 @@ export const routes = (content) => ({
30
41
  else {
31
42
  target.scrollIntoView();
32
43
  }
44
+ announce();
33
45
  },
34
46
  });
package/dist/scope.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Schema } from "effect";
2
+ import type { TitleParts, Viewport } from "./index.js";
2
3
  import type { Content, Page } from "./model.js";
3
4
  export declare const FrameScope: Schema.Struct<{
4
5
  /** The application's name (`valance.json`). */
@@ -29,6 +30,8 @@ export declare const FrameScope: Schema.Struct<{
29
30
  label: typeof Schema.String;
30
31
  level: typeof Schema.Number;
31
32
  }>>;
33
+ /** How much room the page has: `"compact"`, `"medium"` or `"expanded"` (see `Viewport`). `{#if viewport == "compact"}` chooses a layout for a phone. The server renders `expanded`; the browser corrects it after it starts. */
34
+ viewport: Schema.Literal<["compact", "medium", "expanded"]>;
32
35
  /** The page's body. Draw it with `<blocks items={blocks} />`, or draw each block yourself. */
33
36
  blocks: Schema.Array$<Schema.Struct<{
34
37
  id: typeof Schema.String;
@@ -72,6 +75,9 @@ export declare const sectionLabel: (dir: string) => string;
72
75
  /** The page shown for an address that is not a page: `content/404.md` if the author wrote one. */
73
76
  export declare const notFoundPage: (content: Content) => Page;
74
77
  export declare const pageAt: (content: Content, path: string) => Page | undefined;
75
- /** The text of the document's `<title>` for a page. */
76
- export declare const documentTitle: (content: Content, page: Page | undefined) => string;
77
- export declare const frame: (content: Content, path: string) => typeof FrameScope.Type;
78
+ /**
79
+ * The text of the document's `<title>` for a page: the project's format (`{page} · {site}`) with the parts filled in, and the status first, in parentheses. A page whose title is the site's name
80
+ * (the home page, usually) is that name alone. The author's `title` function may replace the parts; the caller cleans the result (control and direction-override characters out, length limited).
81
+ */
82
+ export declare const documentTitle: (content: Content, page: Page | undefined, parts?: TitleParts) => string;
83
+ export declare const frame: (content: Content, path: string, viewport?: Viewport) => typeof FrameScope.Type;
package/dist/scope.js CHANGED
@@ -36,6 +36,8 @@ export const FrameScope = Schema.Struct({
36
36
  /** The `##` and `###` headings of the current page; `hasToc` is false when there are none. */
37
37
  hasToc: Schema.Boolean,
38
38
  toc: Schema.Array(Schema.Struct({ id: Schema.String, href: Schema.String, label: Schema.String, level: Schema.Number })),
39
+ /** How much room the page has: `"compact"`, `"medium"` or `"expanded"` (see `Viewport`). `{#if viewport == "compact"}` chooses a layout for a phone. The server renders `expanded`; the browser corrects it after it starts. */
40
+ viewport: Schema.Literal("compact", "medium", "expanded"),
39
41
  /** The page's body. Draw it with `<blocks items={blocks} />`, or draw each block yourself. */
40
42
  blocks: Schema.Array(BlockView),
41
43
  /** Neighbours in navigation order; `href` is empty when there is none. */
@@ -91,15 +93,17 @@ export const notFoundPage = (content) => content.notFound ?? {
91
93
  ],
92
94
  };
93
95
  export const pageAt = (content, path) => content.pages.find((page) => page.path === path);
94
- /** The text of the document's `<title>` for a page. */
95
- export const documentTitle = (content, page) => page === undefined
96
- ? `${notFoundPage(content).title} · ${content.site.name}`
97
- : page.path === "/"
98
- ? page.title === content.site.name
99
- ? page.title
100
- : `${page.title} · ${content.site.name}`
101
- : `${page.title} · ${content.site.name}`;
102
- export const frame = (content, path) => {
96
+ /**
97
+ * The text of the document's `<title>` for a page: the project's format (`{page} · {site}`) with the parts filled in, and the status first, in parentheses. A page whose title is the site's name
98
+ * (the home page, usually) is that name alone. The author's `title` function may replace the parts; the caller cleans the result (control and direction-override characters out, length limited).
99
+ */
100
+ export const documentTitle = (content, page, parts = {}) => {
101
+ const shown = parts.page ?? (page === undefined ? notFoundPage(content).title : page.title);
102
+ const app = parts.app ?? content.site.name;
103
+ const body = shown === app ? shown : content.site.title.replaceAll("{page}", shown).replaceAll("{site}", app);
104
+ return parts.status === undefined || parts.status === "" ? body : `(${parts.status}) ${body}`;
105
+ };
106
+ export const frame = (content, path, viewport = "expanded") => {
103
107
  const page = pageAt(content, path);
104
108
  const shown = page ?? notFoundPage(content);
105
109
  const at = page === undefined ? -1 : content.pages.indexOf(page);
@@ -115,6 +119,7 @@ export const frame = (content, path) => {
115
119
  }
116
120
  return {
117
121
  site: content.site.name,
122
+ viewport,
118
123
  home: withBase(content.site.base, "/"),
119
124
  title: shown.title,
120
125
  description: shown.description,
package/dist/server.js CHANGED
@@ -10,7 +10,7 @@ export const createRenderer = ({ content, program, author }) => {
10
10
  const table = routes(content);
11
11
  return async (pathname, built) => {
12
12
  const state = stateFor(content, pathname, author);
13
- const served = await Effect.runPromise(renderToHtml(app, { plugins: [plugin(content, built)], state }));
13
+ const served = await Effect.runPromise(renderToHtml(app, { plugins: [plugin(content, built, author)], state }));
14
14
  const boot = safe(JSON.stringify({ state: served.state, built }));
15
15
  return {
16
16
  status: table.known(pathname) ? 200 : 404,
@@ -0,0 +1,37 @@
1
+ import type { Effect } from "effect";
2
+ export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
3
+ /** Why a request did not give a value. None of them carries the response text, the address or a header: those can hold a secret. */
4
+ export type HttpError = {
5
+ readonly _tag: "Offline";
6
+ } | {
7
+ readonly _tag: "Status";
8
+ readonly status: number;
9
+ } | {
10
+ readonly _tag: "Decode";
11
+ } | {
12
+ readonly _tag: "Timeout";
13
+ } | {
14
+ readonly _tag: "Aborted";
15
+ } | {
16
+ readonly _tag: "TooLarge";
17
+ } | {
18
+ readonly _tag: "Unsafe";
19
+ readonly reason: string;
20
+ };
21
+ /** The request as it goes out: already checked, with its final address. Headers are lower-case. */
22
+ export interface TransportRequest {
23
+ readonly method: Method;
24
+ readonly url: string;
25
+ readonly headers: Readonly<Record<string, string>>;
26
+ readonly body?: string;
27
+ readonly timeoutMs: number;
28
+ }
29
+ /** The answer as it came back, whatever its status. Headers are lower-case. `text` is empty for no content. */
30
+ export interface TransportResponse {
31
+ readonly status: number;
32
+ readonly headers: Readonly<Record<string, string>>;
33
+ readonly text: string;
34
+ }
35
+ export type Transport = (request: TransportRequest) => Effect.Effect<TransportResponse, HttpError>;
36
+ /** Wraps the next transport: change the request on the way in, the answer on the way out, call `next` more than once (retry) or not at all (a cached answer). */
37
+ export type Interceptor = (next: Transport) => Transport;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import type { Viewport } from "./index.js";
2
+ export declare const MEDIUM = 600;
3
+ export declare const EXPANDED = 840;
4
+ export declare const viewportOf: (width: number) => Viewport;
5
+ interface Query {
6
+ readonly matches: boolean;
7
+ addEventListener(type: "change", listener: () => void): void;
8
+ removeEventListener(type: "change", listener: () => void): void;
9
+ }
10
+ export interface MediaWindow {
11
+ matchMedia(query: string): Query;
12
+ }
13
+ /** Calls `changed` with the current class now, and again whenever it changes. Returns the function that stops watching. */
14
+ export declare const watchViewport: (win: MediaWindow, changed: (viewport: Viewport) => void) => (() => void);
15
+ export {};
@@ -0,0 +1,24 @@
1
+ export const MEDIUM = 600;
2
+ export const EXPANDED = 840;
3
+ export const viewportOf = (width) => (width >= EXPANDED ? "expanded" : width >= MEDIUM ? "medium" : "compact");
4
+ /** Calls `changed` with the current class now, and again whenever it changes. Returns the function that stops watching. */
5
+ export const watchViewport = (win, changed) => {
6
+ const medium = win.matchMedia(`(min-width: ${MEDIUM}px)`);
7
+ const expanded = win.matchMedia(`(min-width: ${EXPANDED}px)`);
8
+ const current = () => (expanded.matches ? "expanded" : medium.matches ? "medium" : "compact");
9
+ let last;
10
+ const check = () => {
11
+ const now = current();
12
+ if (now !== last) {
13
+ last = now;
14
+ changed(now);
15
+ }
16
+ };
17
+ medium.addEventListener("change", check);
18
+ expanded.addEventListener("change", check);
19
+ check();
20
+ return () => {
21
+ medium.removeEventListener("change", check);
22
+ expanded.removeEventListener("change", check);
23
+ };
24
+ };
@@ -0,0 +1,9 @@
1
+ import type { WebPrimitives } from "@valancex/port-web";
2
+ export interface Vocabulary {
3
+ /** The target's id (`web`). */
4
+ readonly target: string;
5
+ /** The tags, each with its props and events. */
6
+ readonly tags: WebPrimitives;
7
+ }
8
+ /** The web's vocabulary: HTML elements, with `class`, `theme`, `id` and `aria-*` as styling and accessibility hooks. */
9
+ export declare const webVocabulary: Vocabulary;
@@ -0,0 +1,3 @@
1
+ import { primitives } from "./primitives.js";
2
+ /** The web's vocabulary: HTML elements, with `class`, `theme`, `id` and `aria-*` as styling and accessibility hooks. */
3
+ export const webVocabulary = { target: "web", tags: primitives };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valancex/common",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "The author's side of a VALANCE application (defineApp, component, field) and the runtime the vlx build runs: the browser and server entries. What src/main.ts imports; @valancex/cli builds what it describes.",
5
5
  "keywords": [
6
6
  "valance",
@@ -32,15 +32,19 @@
32
32
  "types": "./dist/client.d.ts",
33
33
  "default": "./dist/client.js"
34
34
  },
35
- "./server": {
36
- "types": "./dist/server.d.ts",
37
- "default": "./dist/server.js"
35
+ "./http": {
36
+ "types": "./dist/http.d.ts",
37
+ "default": "./dist/http.js"
38
38
  },
39
39
  "./internal": {
40
40
  "types": "./dist/internal.d.ts",
41
41
  "default": "./dist/internal.js"
42
42
  },
43
- "./package.json": "./package.json"
43
+ "./package.json": "./package.json",
44
+ "./server": {
45
+ "types": "./dist/server.d.ts",
46
+ "default": "./dist/server.js"
47
+ }
44
48
  },
45
49
  "types": "./dist/index.d.ts",
46
50
  "files": [
@@ -53,8 +57,9 @@
53
57
  },
54
58
  "dependencies": {
55
59
  "@valancex/mesh-runtime": "^0.11.0",
60
+ "@valancex/nexus": "^0.12.0",
56
61
  "@valancex/port-web": "^0.4.0",
57
- "@valancex/valance": "^0.11.0"
62
+ "@valancex/valance": "^0.12.0"
58
63
  },
59
64
  "peerDependencies": {
60
65
  "effect": "^3.10.0"