@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.
- package/LICENSE +21 -0
- package/README.md +2 -1
- package/dist/announce.d.ts +25 -0
- package/dist/announce.js +39 -0
- package/dist/app.d.ts +7 -4
- package/dist/app.js +192 -34
- package/dist/client.js +21 -2
- package/dist/color-scheme.d.ts +12 -0
- package/dist/color-scheme.js +15 -0
- package/dist/desktop.d.ts +22 -0
- package/dist/desktop.js +71 -0
- package/dist/http.d.ts +68 -0
- package/dist/http.js +194 -0
- package/dist/index.d.ts +114 -0
- package/dist/index.js +4 -0
- package/dist/internal.d.ts +7 -0
- package/dist/internal.js +7 -0
- package/dist/model.d.ts +8 -0
- package/dist/model.js +2 -1
- package/dist/navigation.d.ts +6 -0
- package/dist/navigation.js +5 -0
- package/dist/open-system.d.ts +12 -0
- package/dist/open-system.js +31 -0
- package/dist/open.d.ts +23 -0
- package/dist/open.js +47 -0
- package/dist/platform.d.ts +5 -0
- package/dist/platform.js +4 -0
- package/dist/primitives.js +26 -1
- package/dist/routes.js +13 -1
- package/dist/scope.d.ts +18 -3
- package/dist/scope.js +23 -9
- package/dist/server.js +3 -2
- package/dist/store-file.d.ts +4 -0
- package/dist/store-file.js +49 -0
- package/dist/store.d.ts +40 -0
- package/dist/store.js +60 -0
- package/dist/targets.d.ts +8 -0
- package/dist/targets.js +22 -0
- package/dist/transport.d.ts +37 -0
- package/dist/transport.js +1 -0
- package/dist/viewport.d.ts +15 -0
- package/dist/viewport.js +24 -0
- package/dist/vocabulary.d.ts +30 -0
- package/dist/vocabulary.js +28 -0
- package/dist/web-app.d.ts +6 -0
- package/dist/web-app.js +20 -0
- package/package.json +17 -7
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/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;
|
package/dist/announce.js
ADDED
|
@@ -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
|
@@ -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
|
-
/**
|
|
35
|
-
|
|
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 {
|
|
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 {
|
|
8
|
-
import {
|
|
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({
|
|
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,23 +25,52 @@ 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(
|
|
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)) {
|
|
30
59
|
throw new AppDefinitionError(`src/main.ts: the default of the state field "${name}" is not a value of its type`);
|
|
31
60
|
}
|
|
32
61
|
// 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
|
-
|
|
34
|
-
|
|
62
|
+
const own = author?.commands?.(Valance.command({})) ?? {};
|
|
63
|
+
for (const [name, what] of [
|
|
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"],
|
|
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"],
|
|
70
|
+
]) {
|
|
71
|
+
if (name in own) {
|
|
72
|
+
throw new AppDefinitionError(`src/main.ts: "${name}" is the framework's command (it ${what}); choose another name`);
|
|
73
|
+
}
|
|
35
74
|
}
|
|
36
75
|
};
|
|
37
76
|
/** The names `src/main.ts` adds: state fields (readable in the layout) and commands (runnable from it). */
|
|
@@ -42,22 +81,134 @@ export const ownNames = (author) => ({
|
|
|
42
81
|
/** The scope schema a layout is compiled against: the framework's names and the author's state fields. */
|
|
43
82
|
export const scopeSchema = (author) => {
|
|
44
83
|
const state = stateOf(author);
|
|
45
|
-
|
|
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]))));
|
|
46
90
|
};
|
|
47
91
|
export const stateSchema = (author) => {
|
|
48
92
|
const state = stateOf(author);
|
|
49
93
|
return state === undefined ? PageSchema : Schema.extend(PageSchema, state.schema);
|
|
50
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
|
+
};
|
|
51
185
|
/** The command table: the framework's `go`, then the author's. */
|
|
52
|
-
export const commandTable = (author) => (state) => {
|
|
186
|
+
export const commandTable = (author, known = () => true) => (state) => {
|
|
53
187
|
const command = Valance.command(state);
|
|
54
188
|
const own = author?.commands?.(command) ?? {};
|
|
55
|
-
|
|
56
|
-
|
|
189
|
+
for (const name of ["go", "back", "forward", "resize", "colorScheme", "persist"]) {
|
|
190
|
+
if (name in own) {
|
|
191
|
+
throw new AppDefinitionError(`src/main.ts: "${name}" is the framework's command; choose another name`);
|
|
192
|
+
}
|
|
57
193
|
}
|
|
58
|
-
return {
|
|
194
|
+
return {
|
|
195
|
+
...(persisted(author).length === 0 ? {} : { persist: keepBetweenRuns(author, state, command, known) }),
|
|
196
|
+
...navigationCommands(author, state, command),
|
|
197
|
+
// The window changed size: the browser tells the application, which tells the layout (`viewport`).
|
|
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 })),
|
|
201
|
+
...own,
|
|
202
|
+
};
|
|
59
203
|
};
|
|
60
|
-
export const initialState = (author) => ({
|
|
204
|
+
export const initialState = (author) => ({
|
|
205
|
+
page: "/",
|
|
206
|
+
trail: ["/"],
|
|
207
|
+
cursor: 0,
|
|
208
|
+
viewport: "expanded",
|
|
209
|
+
colorScheme: "light",
|
|
210
|
+
...stateOf(author)?.initial,
|
|
211
|
+
});
|
|
61
212
|
export const application = (content, program, author) => {
|
|
62
213
|
checkDefinition(author);
|
|
63
214
|
return Valance.define({
|
|
@@ -68,29 +219,36 @@ export const application = (content, program, author) => {
|
|
|
68
219
|
program,
|
|
69
220
|
scope: (state) => {
|
|
70
221
|
const { page, ...own } = state;
|
|
71
|
-
return {
|
|
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
|
+
};
|
|
72
227
|
},
|
|
73
228
|
},
|
|
74
229
|
},
|
|
75
230
|
view: () => "layout",
|
|
76
|
-
commands: commandTable(author),
|
|
231
|
+
commands: commandTable(author, (page) => pageAt(content, page) !== undefined),
|
|
232
|
+
...(persisted(author).length === 0 ? {} : { start: "persist" }),
|
|
77
233
|
});
|
|
78
234
|
};
|
|
79
235
|
/** The state a request for `pathname` starts from. */
|
|
80
|
-
export const stateFor = (content, pathname, author) =>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
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
|
+
};
|
|
240
|
+
/**
|
|
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),
|
|
242
|
+
* and the failure is reported by its name only.
|
|
243
|
+
*/
|
|
244
|
+
export const titleFor = (content, page, state, author) => {
|
|
245
|
+
let parts = {};
|
|
246
|
+
try {
|
|
247
|
+
const made = author?.title?.(state, { path: state.page, title: page?.title ?? "" });
|
|
248
|
+
parts = made === undefined ? {} : typeof made === "string" ? { page: made } : made;
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
console.error(`the title function in src/main.ts failed (${error instanceof Error ? error.name : "error"}); the default title is used`);
|
|
252
|
+
}
|
|
253
|
+
return documentTitle(content, page, parts);
|
|
254
|
+
};
|
package/dist/client.js
CHANGED
|
@@ -1,8 +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
|
-
import {
|
|
6
|
+
import { Effect } from "effect";
|
|
7
|
+
import { webPlatform } from "./http.js";
|
|
8
|
+
import { watchColorScheme } from "./color-scheme.js";
|
|
9
|
+
import { watchViewport } from "./viewport.js";
|
|
10
|
+
import { application } from "./app.js";
|
|
11
|
+
import { plugin } from "./web-app.js";
|
|
6
12
|
export const start = async ({ content, program, author }) => {
|
|
7
13
|
const container = document.getElementById("app");
|
|
8
14
|
try {
|
|
@@ -12,12 +18,25 @@ export const start = async ({ content, program, author }) => {
|
|
|
12
18
|
container,
|
|
13
19
|
present: "hydrate",
|
|
14
20
|
state: boot.state,
|
|
15
|
-
plugins: [plugin(content, boot.built)],
|
|
21
|
+
plugins: [plugin(content, boot.built, author)],
|
|
22
|
+
// What the application's commands may ask the page for: the backend (`Http`), at `api.base` of valance.web.json.
|
|
23
|
+
platform: webPlatform({ base: content.site.api, interceptors: author?.http?.interceptors ?? [], store: webStore(content.site.name) }),
|
|
16
24
|
shutdown: { grace: "250 millis" },
|
|
17
25
|
});
|
|
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
|
+
});
|
|
31
|
+
const unwatch = watchViewport(window, (viewport) => {
|
|
32
|
+
container.dataset["viewport"] = viewport; // for a stylesheet or a test; the layout reads the state's own `viewport`
|
|
33
|
+
void Effect.runPromise(host.handle.invoke("app/resize", [{ value: viewport }])).catch(() => undefined);
|
|
34
|
+
});
|
|
18
35
|
container.dataset["valance"] = "running"; // readiness marker for whoever drives the page
|
|
19
36
|
addEventListener("pagehide", (event) => {
|
|
20
37
|
if (!event.persisted) {
|
|
38
|
+
unwatch();
|
|
39
|
+
unwatchScheme();
|
|
21
40
|
void host.stop();
|
|
22
41
|
}
|
|
23
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>;
|
package/dist/desktop.js
ADDED
|
@@ -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
|
+
};
|