@valancex/common 0.12.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jack Miller
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/app.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import * as Valance from "@valancex/valance";
2
- import type { Plugin } from "@valancex/valance/web/plugin";
3
2
  import { Schema } from "effect";
4
3
  import type { DefinedApp, PageState } from "./index.js";
5
4
  import type { Content } from "./model.js";
5
+ import { pageAt } from "./scope.js";
6
6
  export type AppState = PageState & Record<string, unknown>;
7
7
  /** What the browser needs to know about the built page to keep the document's head in step. */
8
8
  export interface Built {
@@ -26,10 +26,13 @@ export declare const ownNames: (author: DefinedApp | undefined) => {
26
26
  export declare const scopeSchema: (author: DefinedApp | undefined) => Schema.Schema<any, any, never>;
27
27
  export declare const stateSchema: (author: DefinedApp | undefined) => Schema.Schema<any, any, never>;
28
28
  /** The command table: the framework's `go`, then the author's. */
29
- export declare const commandTable: (author: DefinedApp | undefined) => (state: Parameters<typeof Valance.command>[0]) => Valance.CommandTable<any, Valance.Ambient>;
29
+ export declare const commandTable: (author: DefinedApp | undefined, known?: (page: string) => boolean) => (state: Parameters<typeof Valance.command>[0]) => Valance.CommandTable<any, Valance.Ambient>;
30
30
  export declare const initialState: (author: DefinedApp | undefined) => AppState;
31
31
  export declare const application: (content: Content, program: Valance.Program, author?: DefinedApp) => Valance.ApplicationDefinition<AppState, any, Valance.Ambient, "layout">;
32
32
  /** The state a request for `pathname` starts from. */
33
33
  export declare const stateFor: (content: Content, pathname: string, author?: DefinedApp) => AppState;
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, author?: DefinedApp) => Plugin<AppState>;
34
+ /**
35
+ * 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),
36
+ * and the failure is reported by its name only.
37
+ */
38
+ export declare const titleFor: (content: Content, page: ReturnType<typeof pageAt>, state: AppState, author: DefinedApp | undefined) => string;
package/dist/app.js CHANGED
@@ -2,12 +2,22 @@
2
2
  // `defineApp`'s state and commands. This is one Valance application (`Valance.define`): the page is its state, going to a page is its `go` command, and the layout is its one view.
3
3
  // The server renders it and the browser runs it, from the same function.
4
4
  import * as Valance from "@valancex/valance";
5
- import { Schema } from "effect";
5
+ import { History, permit } from "@valancex/router";
6
+ import * as Nexus from "@valancex/nexus";
7
+ import { Effect, Schema, Stream } from "effect";
6
8
  import { documentTitle, frame, FrameScope, pageAt, RESERVED } from "./scope.js";
7
- import { primitives } from "./primitives.js";
8
- import { normalize, routes } from "./routes.js";
9
+ import { normalize } from "./routes.js";
10
+ import { derivedOf } from "./index.js";
11
+ import { Navigation } from "./navigation.js";
12
+ import * as StoreOps from "./store.js";
9
13
  import { badDefaults, stateOf } from "./state.js";
10
- const PageSchema = Schema.Struct({ page: Schema.String, viewport: Schema.Literal("compact", "medium", "expanded") });
14
+ const PageSchema = Schema.Struct({
15
+ page: Schema.String,
16
+ trail: Schema.Array(Schema.String),
17
+ cursor: Schema.Number,
18
+ viewport: Schema.Literal("compact", "medium", "expanded"),
19
+ colorScheme: Schema.Literal("light", "dark"),
20
+ });
11
21
  /** A mistake in `src/main.ts`, reported with what to change. */
12
22
  export class AppDefinitionError extends Error {
13
23
  constructor(message) {
@@ -15,15 +25,34 @@ export class AppDefinitionError extends Error {
15
25
  this.name = "AppDefinitionError";
16
26
  }
17
27
  }
18
- const authorFields = (author) => Object.keys(stateOf(author)?.schema.fields ?? {});
28
+ const authorFields = (author) => [...Object.keys(stateOf(author)?.schema.fields ?? {}), ...Object.keys(derivedOf(author))];
29
+ /** What the author keeps between runs: state fields, and `"navigation"` (the list of pages visited, where the engine keeps it). */
30
+ const persisted = (author) => author?.persist ?? [];
31
+ /** The state fields among them. */
32
+ const persistedFields = (author) => persisted(author).filter((name) => name !== "navigation");
33
+ /** The key the persisted fields are stored under. */
34
+ const STORE_KEY = "state";
35
+ /** The derived values for a state, by name. */
36
+ const derivedValues = (author, state) => Object.fromEntries(Object.entries(derivedOf(author)).map(([name, each]) => [name, each.from(state)]));
19
37
  /** Checks the author's definition against the names the framework owns. */
20
38
  export const checkDefinition = (author) => {
21
39
  for (const name of authorFields(author)) {
22
40
  if (RESERVED.includes(name)) {
23
41
  throw new AppDefinitionError(`src/main.ts: the state field "${name}" is a name the layout already reads from the framework (${RESERVED.join(", ")}); choose another`);
24
42
  }
25
- if (name === "page") {
26
- throw new AppDefinitionError('src/main.ts: the state field "page" is the framework\'s (which page is shown); choose another');
43
+ if (name === "page" || name === "trail" || name === "cursor") {
44
+ throw new AppDefinitionError(`src/main.ts: the state field "${name}" is the framework's (which page is shown, and the pages visited); choose another`);
45
+ }
46
+ }
47
+ const fields = Object.keys(stateOf(author)?.schema.fields ?? {});
48
+ for (const name of persistedFields(author)) {
49
+ if (!fields.includes(name)) {
50
+ throw new AppDefinitionError(`src/main.ts: \`persist\` names "${name}", which is not a state field${fields.length === 0 ? " (the state has none)" : ` (the fields: ${fields.join(", ")}; "navigation" keeps the pages visited)`}`);
51
+ }
52
+ }
53
+ for (const name of Object.keys(derivedOf(author))) {
54
+ if (fields.includes(name)) {
55
+ throw new AppDefinitionError(`src/main.ts: "${name}" is both a state field and a derived value; a name is one or the other`);
27
56
  }
28
57
  }
29
58
  for (const name of badDefaults(author)) {
@@ -33,7 +62,11 @@ export const checkDefinition = (author) => {
33
62
  const own = author?.commands?.(Valance.command({})) ?? {};
34
63
  for (const [name, what] of [
35
64
  ["go", "shows a page"],
65
+ ["back", "goes to the page before this one in the trail"],
66
+ ["forward", "goes to the page after this one in the trail"],
36
67
  ["resize", "tells the layout how much room the page has"],
68
+ ["colorScheme", "tells the layout which colors the system asks for"],
69
+ ["persist", "keeps the fields named in `persist` between runs"],
37
70
  ]) {
38
71
  if (name in own) {
39
72
  throw new AppDefinitionError(`src/main.ts: "${name}" is the framework's command (it ${what}); choose another name`);
@@ -48,29 +81,134 @@ export const ownNames = (author) => ({
48
81
  /** The scope schema a layout is compiled against: the framework's names and the author's state fields. */
49
82
  export const scopeSchema = (author) => {
50
83
  const state = stateOf(author);
51
- return state === undefined ? FrameScope : Schema.extend(FrameScope, state.schema);
84
+ const derived = Object.entries(derivedOf(author));
85
+ const base = state === undefined ? FrameScope : Schema.extend(FrameScope, state.schema);
86
+ // A derived value is a name the layout reads, with the type it was given.
87
+ return derived.length === 0
88
+ ? base
89
+ : Schema.extend(base, Schema.Struct(Object.fromEntries(derived.map(([name, each]) => [name, each.schema]))));
52
90
  };
53
91
  export const stateSchema = (author) => {
54
92
  const state = stateOf(author);
55
93
  return state === undefined ? PageSchema : Schema.extend(PageSchema, state.schema);
56
94
  };
95
+ /**
96
+ * The command the application starts with when the author names fields in `persist`: load them from the platform's store, then save them whenever they change. A platform with no store
97
+ * ends it at once (the application runs without persistence); stored data that no longer fits is ignored, and the next change replaces it. It never ends otherwise: it belongs to the
98
+ * application and ends with it.
99
+ */
100
+ const keepBetweenRuns = (author, state, command, known) => {
101
+ const names = persistedFields(author);
102
+ const navigation = persisted(author).includes("navigation");
103
+ const fields = stateOf(author)?.schema.fields ?? {};
104
+ const schema = Schema.Struct({
105
+ ...Object.fromEntries(names.map((name) => [name, fields[name]])),
106
+ ...(navigation ? { navigation: Schema.Unknown } : {}),
107
+ });
108
+ const update = (f) => state.update(f);
109
+ const pick = (current, keepList) => ({
110
+ ...Object.fromEntries(names.map((name) => [name, current[name]])),
111
+ ...(keepList ? { navigation: { entries: current["trail"], index: current["cursor"] } } : {}),
112
+ });
113
+ return command.effect(() => Effect.gen(function* () {
114
+ // does this platform leave the list to the engine? (a page's is the browser's, and the address is the truth there)
115
+ const engineKeepsList = navigation
116
+ ? yield* Nexus.Capability.require(Navigation).pipe(Effect.as(true), Effect.catchAll(() => Effect.succeed(false)))
117
+ : false;
118
+ // what a previous run kept, if the platform has a store and what is kept still fits
119
+ const stored = yield* StoreOps.get(STORE_KEY, schema).pipe(Effect.map((value) => ({ store: true, value })), Effect.catchTag("CapabilityUnavailableError", () => Effect.succeed({ store: false, value: undefined })), Effect.catchAll(() => Effect.succeed({ store: true, value: undefined })));
120
+ if (!stored.store) {
121
+ return;
122
+ }
123
+ if (stored.value !== undefined) {
124
+ const { navigation: list, ...kept } = stored.value;
125
+ yield* update((current) => {
126
+ const next = { ...current, ...kept };
127
+ if (!engineKeepsList) {
128
+ return Effect.succeed(next);
129
+ }
130
+ // the pages visited: those that are still pages of the site, and the first page when none is
131
+ const restored = History.restore(list, (entry) => typeof entry === "string" && known(entry), "/");
132
+ return Effect.succeed({ ...next, trail: restored.entries, cursor: restored.index, page: History.current(restored) });
133
+ });
134
+ }
135
+ let last = JSON.stringify(pick((yield* state.get), engineKeepsList));
136
+ yield* Stream.runForEach(state.values, (current) => {
137
+ const picked = pick(current, engineKeepsList);
138
+ const text = JSON.stringify(picked);
139
+ if (text === last) {
140
+ return Effect.void;
141
+ }
142
+ last = text;
143
+ return StoreOps.set(STORE_KEY, schema, picked).pipe(Effect.ignore);
144
+ });
145
+ }));
146
+ };
147
+ const listOf = (current) => ({ entries: current.trail, index: current.cursor });
148
+ const withList = (current, list) => ({ ...current, trail: [...list.entries], cursor: list.index, page: History.current(list) });
149
+ const routeOf = (page) => ({ name: page, params: {}, query: {} });
150
+ /**
151
+ * `go`, `back` and `forward`: moves along the list of pages (`History`, from the router), each first asking the author's `guard` if there is one. Going to a page puts it after the one shown
152
+ * and drops the pages that were ahead; Back and Forward move along the list, for a target with no browser history of its own. Without a guard they are plain transitions.
153
+ */
154
+ const navigationCommands = (author, state, command) => {
155
+ const guard = author?.guard;
156
+ const go = (current, id) => (id === current.page ? current : withList(current, History.push(listOf(current), id)));
157
+ const traverse = (current, distance) => withList(current, History.go(listOf(current), distance));
158
+ const handle = state;
159
+ /** Asks the guard about a move to `to`; the page to go to instead when it redirects, `undefined` when it stops the move. */
160
+ const asked = (current, to, kind) => guard === undefined || to === current.page
161
+ ? Effect.succeed(to)
162
+ : permit(guard, { from: routeOf(current.page), to: routeOf(to), state: current, kind, data: {} }).pipe(Effect.map((answer) => (answer === true ? to : answer === false ? undefined : answer.name)));
163
+ const guarded = (target, kind, apply) => Effect.gen(function* () {
164
+ const current = yield* handle.get;
165
+ const to = target(current);
166
+ const allowed = to === undefined ? undefined : yield* asked(current, to, kind);
167
+ if (allowed !== undefined) {
168
+ // a redirect is a page to go to, not a place in the list: it is added after the page shown
169
+ yield* handle.update((latest) => Effect.succeed(allowed === to ? apply(latest, allowed) : go(latest, allowed)));
170
+ }
171
+ });
172
+ if (guard === undefined) {
173
+ return {
174
+ go: command(Schema.Struct({ id: Schema.String }), ({ id }, current) => go(current, id)),
175
+ back: command((current) => traverse(current, -1)),
176
+ forward: command((current) => traverse(current, 1)),
177
+ };
178
+ }
179
+ return {
180
+ go: command.effect(Schema.Struct({ id: Schema.String }), ({ id }) => guarded(() => id, "programmatic", (latest, to) => go(latest, to))),
181
+ back: command.effect(() => guarded((current) => (History.canGo(listOf(current), -1) ? current.trail[current.cursor - 1] : undefined), "history", (latest) => traverse(latest, -1))),
182
+ forward: command.effect(() => guarded((current) => (History.canGo(listOf(current), 1) ? current.trail[current.cursor + 1] : undefined), "history", (latest) => traverse(latest, 1))),
183
+ };
184
+ };
57
185
  /** The command table: the framework's `go`, then the author's. */
58
- export const commandTable = (author) => (state) => {
186
+ export const commandTable = (author, known = () => true) => (state) => {
59
187
  const command = Valance.command(state);
60
188
  const own = author?.commands?.(command) ?? {};
61
- for (const name of ["go", "resize"]) {
189
+ for (const name of ["go", "back", "forward", "resize", "colorScheme", "persist"]) {
62
190
  if (name in own) {
63
191
  throw new AppDefinitionError(`src/main.ts: "${name}" is the framework's command; choose another name`);
64
192
  }
65
193
  }
66
194
  return {
67
- go: command(Schema.Struct({ id: Schema.String }), ({ id }, current) => ({ ...current, page: id })),
195
+ ...(persisted(author).length === 0 ? {} : { persist: keepBetweenRuns(author, state, command, known) }),
196
+ ...navigationCommands(author, state, command),
68
197
  // The window changed size: the browser tells the application, which tells the layout (`viewport`).
69
198
  resize: command(Schema.Struct({ viewport: Schema.Literal("compact", "medium", "expanded") }), ({ viewport }, current) => ({ ...current, viewport })),
199
+ // The person's system changed colors: the page or the window tells the application, which tells the layout (`colorScheme`).
200
+ colorScheme: command(Schema.Struct({ scheme: Schema.Literal("light", "dark") }), ({ scheme }, current) => ({ ...current, colorScheme: scheme })),
70
201
  ...own,
71
202
  };
72
203
  };
73
- export const initialState = (author) => ({ page: "/", viewport: "expanded", ...stateOf(author)?.initial });
204
+ export const initialState = (author) => ({
205
+ page: "/",
206
+ trail: ["/"],
207
+ cursor: 0,
208
+ viewport: "expanded",
209
+ colorScheme: "light",
210
+ ...stateOf(author)?.initial,
211
+ });
74
212
  export const application = (content, program, author) => {
75
213
  checkDefinition(author);
76
214
  return Valance.define({
@@ -81,21 +219,29 @@ export const application = (content, program, author) => {
81
219
  program,
82
220
  scope: (state) => {
83
221
  const { page, ...own } = state;
84
- return { ...frame(content, page, state.viewport), ...own };
222
+ return {
223
+ ...frame(content, page, state.viewport, state.colorScheme, { canGoBack: state.cursor > 0, canGoForward: state.cursor < state.trail.length - 1 }),
224
+ ...own,
225
+ ...derivedValues(author, state),
226
+ };
85
227
  },
86
228
  },
87
229
  },
88
230
  view: () => "layout",
89
- commands: commandTable(author),
231
+ commands: commandTable(author, (page) => pageAt(content, page) !== undefined),
232
+ ...(persisted(author).length === 0 ? {} : { start: "persist" }),
90
233
  });
91
234
  };
92
235
  /** The state a request for `pathname` starts from. */
93
- export const stateFor = (content, pathname, author) => ({ ...initialState(author), page: normalize(pathname, content.site.base) });
236
+ export const stateFor = (content, pathname, author) => {
237
+ const page = normalize(pathname, content.site.base);
238
+ return { ...initialState(author), page, trail: [page], cursor: 0 };
239
+ };
94
240
  /**
95
241
  * 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
242
  * and the failure is reported by its name only.
97
243
  */
98
- const titleFor = (content, page, state, author) => {
244
+ export const titleFor = (content, page, state, author) => {
99
245
  let parts = {};
100
246
  try {
101
247
  const made = author?.title?.(state, { path: state.page, title: page?.title ?? "" });
@@ -106,19 +252,3 @@ const titleFor = (content, page, state, author) => {
106
252
  }
107
253
  return documentTitle(content, page, parts);
108
254
  };
109
- /** The plugin that makes the application a website: its tags, its URL policy, and its document head. */
110
- export const plugin = (content, built, author) => ({
111
- name: "valance-app",
112
- primitives,
113
- routes: routes(content),
114
- head: (state) => {
115
- const page = pageAt(content, state.page);
116
- const shown = page ?? { description: "" };
117
- const canonical = content.site.url === "" || page === undefined ? [] : [{ rel: "canonical", href: `${content.site.url}${content.site.base}${page.path.replace(/^\//, "")}` }];
118
- return {
119
- title: titleFor(content, page, state, author),
120
- meta: shown.description === "" ? [] : [{ name: "description", content: shown.description }],
121
- links: [...built.styles.map((href) => ({ rel: "stylesheet", href })), ...canonical],
122
- };
123
- },
124
- });
package/dist/client.js CHANGED
@@ -1,11 +1,14 @@
1
1
  // The browser half, for the generated entry (`.valance/client.ts`): start the MESH runtime and run the application, taking over the HTML the server wrote. An author never imports this.
2
+ import { webStore } from "./store.js";
2
3
  import { init } from "@valancex/mesh-runtime";
3
4
  import wasmUrl from "@valancex/mesh-runtime/mesh-runtime.wasm?url";
4
5
  import * as Web from "@valancex/valance/web";
5
6
  import { Effect } from "effect";
6
7
  import { webPlatform } from "./http.js";
8
+ import { watchColorScheme } from "./color-scheme.js";
7
9
  import { watchViewport } from "./viewport.js";
8
- import { application, plugin } from "./app.js";
10
+ import { application } from "./app.js";
11
+ import { plugin } from "./web-app.js";
9
12
  export const start = async ({ content, program, author }) => {
10
13
  const container = document.getElementById("app");
11
14
  try {
@@ -17,10 +20,14 @@ export const start = async ({ content, program, author }) => {
17
20
  state: boot.state,
18
21
  plugins: [plugin(content, boot.built, author)],
19
22
  // 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 ?? [] }),
23
+ platform: webPlatform({ base: content.site.api, interceptors: author?.http?.interceptors ?? [], store: webStore(content.site.name) }),
21
24
  shutdown: { grace: "250 millis" },
22
25
  });
23
26
  // The server rendered for a wide window; tell the application how wide this one is (and keep telling it).
27
+ const unwatchScheme = watchColorScheme(window, (scheme) => {
28
+ container.dataset["colorScheme"] = scheme; // for a stylesheet or a test; the layout reads the state's own `colorScheme`
29
+ void Effect.runPromise(host.handle.invoke("app/colorScheme", [{ value: scheme }])).catch(() => undefined);
30
+ });
24
31
  const unwatch = watchViewport(window, (viewport) => {
25
32
  container.dataset["viewport"] = viewport; // for a stylesheet or a test; the layout reads the state's own `viewport`
26
33
  void Effect.runPromise(host.handle.invoke("app/resize", [{ value: viewport }])).catch(() => undefined);
@@ -29,6 +36,7 @@ export const start = async ({ content, program, author }) => {
29
36
  addEventListener("pagehide", (event) => {
30
37
  if (!event.persisted) {
31
38
  unwatch();
39
+ unwatchScheme();
32
40
  void host.stop();
33
41
  }
34
42
  });
@@ -0,0 +1,12 @@
1
+ import type { ColorScheme } from "./index.js";
2
+ interface Query {
3
+ readonly matches: boolean;
4
+ addEventListener(type: "change", listener: () => void): void;
5
+ removeEventListener(type: "change", listener: () => void): void;
6
+ }
7
+ export interface SchemeWindow {
8
+ matchMedia(query: string): Query;
9
+ }
10
+ /** Calls `changed` with the current scheme now, and again whenever it changes. Returns the function that stops watching. */
11
+ export declare const watchColorScheme: (win: SchemeWindow, changed: (scheme: ColorScheme) => void) => (() => void);
12
+ export {};
@@ -0,0 +1,15 @@
1
+ /** Calls `changed` with the current scheme now, and again whenever it changes. Returns the function that stops watching. */
2
+ export const watchColorScheme = (win, changed) => {
3
+ const dark = win.matchMedia("(prefers-color-scheme: dark)");
4
+ let last;
5
+ const check = () => {
6
+ const now = dark.matches ? "dark" : "light";
7
+ if (now !== last) {
8
+ last = now;
9
+ changed(now);
10
+ }
11
+ };
12
+ dark.addEventListener("change", check);
13
+ check();
14
+ return () => dark.removeEventListener("change", check);
15
+ };
@@ -0,0 +1,22 @@
1
+ import type { RenderTree } from "@valancex/mesh-runtime";
2
+ import type * as Valance from "@valancex/valance";
3
+ import type { DefinedApp } from "./index.js";
4
+ import type { Content } from "./model.js";
5
+ export interface DesktopParts {
6
+ readonly content: Content;
7
+ readonly program: Valance.Program;
8
+ readonly author?: DefinedApp | undefined;
9
+ /** Where the project's `public/` files are on this machine: an image whose address is a file there is drawn from it. Absent: images are drawn as their descriptions. */
10
+ readonly assets?: string | undefined;
11
+ /** The MESH runtime's module, when the host reads it (the generated entry reads the copy beside the engine). */
12
+ readonly wasm?: Uint8Array | undefined;
13
+ }
14
+ /** Runs the application on stdin and stdout until the window closes. */
15
+ /**
16
+ * An image's address as the window can use it. Content says `/logo.png`, an address on the site; the window draws files it is given by absolute path, so an address that names a file under
17
+ * `assets` becomes that file's path, and anything else (an address on the network, a path that leaves `assets`, a file that is not there) is left as it is, and drawn as its description.
18
+ */
19
+ export declare const assetPath: (assets: string, src: string) => string | undefined;
20
+ /** The tree with each image's address turned into the file it names, where there is one. */
21
+ export declare const withAssets: (tree: RenderTree, assets: string) => RenderTree;
22
+ export declare const start: ({ content, program, author, wasm, assets }: DesktopParts) => Promise<void>;
@@ -0,0 +1,71 @@
1
+ // The desktop half, for the generated entry (`.valance/desktop.ts`): run the application as the engine of a native window. An author never imports this. It is what `./client` is to a page: the
2
+ // content, the compiled program and the author's definition, run; the window itself is the Desktop PORT's, a separate program that starts this one.
3
+ import * as Native from "@valancex/valance/native";
4
+ import { init } from "@valancex/mesh-runtime";
5
+ import { Effect } from "effect";
6
+ import { statSync } from "node:fs";
7
+ import { resolve, sep } from "node:path";
8
+ import { application, titleFor } from "./app.js";
9
+ import { Open } from "./open.js";
10
+ import { systemOpen } from "./open-system.js";
11
+ import { platformOf } from "./platform.js";
12
+ import { Navigation } from "./navigation.js";
13
+ import { Store } from "./store.js";
14
+ import { fileStore, userDataDir } from "./store-file.js";
15
+ import { routes } from "./routes.js";
16
+ import { pageAt } from "./scope.js";
17
+ /** Whether a link's address is one of the application's own pages (it starts with `/`, once). An address anywhere else is the `Open` capability's. */
18
+ const onSite = (href) => href.startsWith("/") && !href.startsWith("//");
19
+ /** Runs the application on stdin and stdout until the window closes. */
20
+ /**
21
+ * An image's address as the window can use it. Content says `/logo.png`, an address on the site; the window draws files it is given by absolute path, so an address that names a file under
22
+ * `assets` becomes that file's path, and anything else (an address on the network, a path that leaves `assets`, a file that is not there) is left as it is, and drawn as its description.
23
+ */
24
+ export const assetPath = (assets, src) => {
25
+ if (!src.startsWith("/") || src.startsWith("//")) {
26
+ return undefined;
27
+ }
28
+ const file = resolve(assets, `.${decodeURI(src).split("?")[0].split("#")[0]}`);
29
+ return file.startsWith(resolve(assets) + sep) && statSync(file, { throwIfNoEntry: false })?.isFile() === true ? file : undefined;
30
+ };
31
+ /** The tree with each image's address turned into the file it names, where there is one. */
32
+ export const withAssets = (tree, assets) => {
33
+ const walk = (node) => {
34
+ if (node.type !== "node") {
35
+ return node;
36
+ }
37
+ const src = node.component === "image" ? node.props?.["src"] : undefined;
38
+ const file = typeof src === "string" ? assetPath(assets, src) : undefined;
39
+ return { ...node, ...(file === undefined ? {} : { props: { ...node.props, src: file } }), children: (node.children ?? []).map(walk) };
40
+ };
41
+ return { ...tree, root: walk(tree.root) };
42
+ };
43
+ export const start = async ({ content, program, author, wasm, assets }) => {
44
+ if (wasm !== undefined) {
45
+ await init(wasm);
46
+ }
47
+ const table = routes(content);
48
+ const opener = systemOpen();
49
+ await Native.run(application(content, program, author), {
50
+ // The window follows the system's colors unless the application asks otherwise (`appearance` in `defineApp`), and the application learns the system's (`colorScheme`).
51
+ appearanceOf: (state) => author?.appearance?.(state) ?? "system",
52
+ onScheme: (scheme, handle) => handle.invoke("app/colorScheme", [{ value: scheme }]),
53
+ // Back and Forward, as the window's keys and mouse buttons ask for them: the framework's own commands, which the author's `guard` may refuse.
54
+ onNavigate: (direction, handle) => handle.invoke(direction === "back" ? "app/back" : "app/forward", []),
55
+ // What the desktop supplies: a store, in the user's data directory for the application.
56
+ platform: platformOf([
57
+ [Store, fileStore(userDataDir(content.site.name))],
58
+ [Open, opener],
59
+ // a window has no history of its own: the engine keeps the list of pages, and may keep it between runs (`persist: ["navigation"]`)
60
+ [Navigation, { owner: "engine" }],
61
+ ]),
62
+ ...(assets === undefined ? {} : { present: (tree) => withAssets(tree, assets) }),
63
+ // The window's title follows the page, in the project's format (`valance.json`), or whatever the author's `title` function says.
64
+ titleOf: (state) => titleFor(content, pageAt(content, state.page), state, author),
65
+ // A link is an address, and navigation is the application's own `go` command, as it is for a page.
66
+ onLink: (href, handle) => onSite(href)
67
+ ? handle.invoke("app/go", [{ value: table.stateOf(new URL(href, "http://app.invalid/")).id }])
68
+ : // an address elsewhere goes to the person's own program for it, when it is one a link may open; anything else is dropped
69
+ opener.open(href).pipe(Effect.catchAll((error) => Effect.sync(() => console.error(`a link was not opened: ${error.message}`)))),
70
+ });
71
+ };
package/dist/http.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as Nexus from "@valancex/nexus";
2
2
  import { Effect, Schema } from "effect";
3
+ import type { StoreClient } from "./store.js";
3
4
  import type { HttpError, Interceptor, Method, TransportRequest } from "./transport.js";
4
5
  export type { HttpError, Interceptor, Method, Transport, TransportRequest, TransportResponse } from "./transport.js";
5
6
  /** One request: where, what is sent, and what the answer must be. */
@@ -62,4 +63,6 @@ export declare const refreshOn401: (refresh: () => Promise<void>) => Interceptor
62
63
  export declare const retryGet: (times: number) => Interceptor;
63
64
  export declare const webHttp: (options: WebHttpOptions) => HttpClient;
64
65
  /** 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;
66
+ export declare const webPlatform: (options: WebHttpOptions & {
67
+ readonly store?: StoreClient;
68
+ }) => Nexus.Application.Platform;
package/dist/http.js CHANGED
@@ -13,6 +13,8 @@
13
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
14
  import * as Nexus from "@valancex/nexus";
15
15
  import { Effect, FiberRef, Schema } from "effect";
16
+ import { Open, webOpen } from "./open.js";
17
+ import { Store } from "./store.js";
16
18
  /** The capability: ask for it in a command (`Http.request`), and the platform says whether it has one. */
17
19
  export const Http = Nexus.Capability.define("valance/http");
18
20
  /** Makes a request through the platform's client. Requires the `Http` capability. */
@@ -185,4 +187,8 @@ export const webHttp = (options) => {
185
187
  };
186
188
  };
187
189
  /** 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) }]]));
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
+ ]));