@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 +21 -0
- package/dist/app.d.ts +7 -4
- package/dist/app.js +162 -32
- package/dist/client.js +10 -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 +4 -1
- package/dist/http.js +7 -1
- package/dist/index.d.ts +82 -0
- package/dist/index.js +4 -0
- package/dist/internal.d.ts +6 -0
- package/dist/internal.js +6 -0
- 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/scope.d.ts +11 -2
- package/dist/scope.js +10 -1
- package/dist/server.js +2 -1
- 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/vocabulary.d.ts +23 -2
- package/dist/vocabulary.js +26 -1
- package/dist/web-app.d.ts +6 -0
- package/dist/web-app.js +20 -0
- package/package.json +8 -3
package/dist/index.d.ts
CHANGED
|
@@ -7,7 +7,17 @@ export interface PageState {
|
|
|
7
7
|
readonly page: string;
|
|
8
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
9
|
readonly viewport: Viewport;
|
|
10
|
+
/** The pages visited, in order, for `back` and `forward`: `go` adds the page it goes to after the one shown (and drops any that were ahead). */
|
|
11
|
+
readonly trail: ReadonlyArray<string>;
|
|
12
|
+
/** Where in `trail` the page shown is. */
|
|
13
|
+
readonly cursor: number;
|
|
14
|
+
/** The colors the person's system asks for: `light` or `dark`. The framework keeps it current (the page's `prefers-color-scheme`, the window's system theme); a layout reads it as `colorScheme`. The server renders `light`; the page corrects it as it starts. */
|
|
15
|
+
readonly colorScheme: ColorScheme;
|
|
10
16
|
}
|
|
17
|
+
/** The system's color preference. */
|
|
18
|
+
export type ColorScheme = "light" | "dark";
|
|
19
|
+
/** What an application asks its window to look like: a scheme, or `system` for the person's own. */
|
|
20
|
+
export type Appearance = ColorScheme | "system";
|
|
11
21
|
/** Window size classes, the ones Material Design and iOS size classes agree on closely enough to design for. */
|
|
12
22
|
export type Viewport = "compact" | "medium" | "expanded";
|
|
13
23
|
export declare const FIELD: unique symbol;
|
|
@@ -21,6 +31,15 @@ export interface Field<A = unknown> {
|
|
|
21
31
|
* Declares one field of the application's state with its type and its default in one place. The framework builds the state's schema and its initial value from the fields; the default is
|
|
22
32
|
* checked against the type (by TypeScript, and again when the application is built).
|
|
23
33
|
*/
|
|
34
|
+
export declare const DERIVED: unique symbol;
|
|
35
|
+
/** A value worked out from the state, and the type it has. A template reads it by name, like a state field, and no command changes it. */
|
|
36
|
+
export interface Derived<A = unknown> {
|
|
37
|
+
readonly [DERIVED]: true;
|
|
38
|
+
readonly schema: Schema.Schema<A, any, never>;
|
|
39
|
+
readonly from: (state: any) => A;
|
|
40
|
+
}
|
|
41
|
+
/** What `derived` is given: `derive(type, (state) => value)`. The state is the application's, as a command sees it. */
|
|
42
|
+
export type DeriveMaker<S> = <A, I = A>(schema: Schema.Schema<A, I, never>, from: (state: S) => A) => Derived<A>;
|
|
24
43
|
export declare const field: <A, I = A>(schema: Schema.Schema<A, I, never>, initial: NoInfer<A>) => Field<A>;
|
|
25
44
|
/** Fields declared one by one. */
|
|
26
45
|
export type Fields = Readonly<Record<string, Field<any>>>;
|
|
@@ -81,7 +100,22 @@ export interface TitleParts {
|
|
|
81
100
|
readonly app?: string;
|
|
82
101
|
readonly status?: string;
|
|
83
102
|
}
|
|
103
|
+
/** What differs between targets: the templates. Everything else in the definition (state, commands, title, http) is the application's, and the same on every target. */
|
|
104
|
+
export interface TargetParts {
|
|
105
|
+
readonly layout?: Component;
|
|
106
|
+
readonly components?: ReadonlyArray<Component>;
|
|
107
|
+
readonly override?: ReadonlyArray<Component>;
|
|
108
|
+
}
|
|
84
109
|
export interface AppDefinition<S extends StateInput = {}> {
|
|
110
|
+
/**
|
|
111
|
+
* The templates of one target, for a project with more than one (`valance.web.json` and `valance.desktop.json`): `targets: { web: { layout, components }, desktop: { layout } }`. A target's
|
|
112
|
+
* `layout` replaces the top-level one, and its `components` and `override` are added to it. A target's templates are compiled against that target's vocabulary only; the state, the commands and
|
|
113
|
+
* the content are the one application's.
|
|
114
|
+
*/
|
|
115
|
+
readonly targets?: {
|
|
116
|
+
readonly web?: TargetParts;
|
|
117
|
+
readonly desktop?: TargetParts;
|
|
118
|
+
};
|
|
85
119
|
/** The page's shell. Omit it to use the built-in layout. Its template must be named `layout.mprx`, and it has no props or events: its inputs are the state and the commands. */
|
|
86
120
|
readonly layout?: Component;
|
|
87
121
|
/** Every component of the application besides the layout and the overrides. A template uses one by writing its tag; one that no template writes yet is not part of the program. */
|
|
@@ -109,13 +143,61 @@ export interface AppDefinition<S extends StateInput = {}> {
|
|
|
109
143
|
readonly http?: {
|
|
110
144
|
readonly interceptors?: ReadonlyArray<Interceptor>;
|
|
111
145
|
};
|
|
146
|
+
/**
|
|
147
|
+
* The appearance this application asks of its window, for a state: `appearance: (state) => state.theme` (`"light"`, `"dark"` or `"system"`). A page draws its own colors, so it is the desktop
|
|
148
|
+
* window's: the window follows the system's theme unless this says otherwise. Absent: the system's.
|
|
149
|
+
*/
|
|
150
|
+
readonly appearance?: (state: PageState & StateOf<S>) => Appearance;
|
|
151
|
+
/**
|
|
152
|
+
* The state fields that outlive a run: `persist: ["items", "theme"]`. They are loaded when the application starts and saved when they change, through the platform's `Store` (the page's
|
|
153
|
+
* storage on the web, a file in the user's data directory on the desktop), under the application's name. What was stored and no longer fits a field's type is ignored, not loaded. Where a
|
|
154
|
+
* platform has no store the application runs as it would without this. A field not listed is not kept (a `navigation` flag on one target should not follow the person to another).
|
|
155
|
+
*/
|
|
156
|
+
readonly persist?: ReadonlyArray<Extract<keyof StateOf<S>, string> | "navigation">;
|
|
157
|
+
/**
|
|
158
|
+
* A guard for moving between pages by `go`, `back` and `forward`: the router's `Guard`, asked before the move. It is told where the application is going from and to (a route named for the
|
|
159
|
+
* page, with no parameters), the state, and why: `kind` is `"programmatic"` for `go` and `"history"` for `back` and `forward`. `true` lets it through, `false` stops it (nothing changes),
|
|
160
|
+
* a `redirect(...)` sends it to another page. A guard that throws or fails stops the move. Without one every move is allowed. A page's own Back and Forward are the browser's: the router
|
|
161
|
+
* guards those as `"popstate"`.
|
|
162
|
+
*/
|
|
163
|
+
readonly guard?: NavigationGuard;
|
|
164
|
+
/**
|
|
165
|
+
* Values worked out from the state, for a template to read: `derived: (derive) => ({ open: derive(Schema.Number, (state) => state.items.filter((item) => !item.done).length) })`. Each has its
|
|
166
|
+
* type, as a state field does. They are computed from the state each time it changes, on every target, and a command cannot change one (change the state they come from). The function must
|
|
167
|
+
* be pure and must not throw.
|
|
168
|
+
*/
|
|
169
|
+
readonly derived?: (derive: DeriveMaker<PageState & StateOf<S>>) => {
|
|
170
|
+
readonly [name: string]: Derived<any>;
|
|
171
|
+
};
|
|
112
172
|
/** The commands a layout can run. `command` makes one: `command((state) => nextState)` or `command(InputSchema, (input, state) => nextState)`. */
|
|
113
173
|
readonly commands?: (command: Valance.CommandMaker<PageState & StateOf<S>>) => Valance.CommandTable<any, Valance.Ambient>;
|
|
114
174
|
}
|
|
175
|
+
/** What a navigation guard is told; the router's `GuardContext`, spelled out so the author's entry needs nothing but VALANCE and effect. */
|
|
176
|
+
export interface NavigationContext {
|
|
177
|
+
readonly from: {
|
|
178
|
+
readonly name: string;
|
|
179
|
+
readonly params: Readonly<Record<string, unknown>>;
|
|
180
|
+
readonly query: Readonly<Record<string, unknown>>;
|
|
181
|
+
} | undefined;
|
|
182
|
+
readonly to: {
|
|
183
|
+
readonly name: string;
|
|
184
|
+
readonly params: Readonly<Record<string, unknown>>;
|
|
185
|
+
readonly query: Readonly<Record<string, unknown>>;
|
|
186
|
+
};
|
|
187
|
+
readonly state: unknown;
|
|
188
|
+
readonly kind: "link" | "popstate" | "programmatic" | "history" | "initial" | "close";
|
|
189
|
+
readonly data: Readonly<Record<string, unknown>>;
|
|
190
|
+
}
|
|
191
|
+
/** `true` allows, `false` stops, and `{ _tag: "Redirect", name }` (the router's `redirect(page)`) goes to another page; it may answer now, later or as an Effect. */
|
|
192
|
+
export type NavigationGuard = (context: NavigationContext) => unknown;
|
|
115
193
|
/** The brand that says a value came from `defineApp`. */
|
|
116
194
|
export declare const APP: unique symbol;
|
|
117
195
|
export interface DefinedApp extends AppDefinition<any> {
|
|
118
196
|
readonly [APP]: true;
|
|
119
197
|
}
|
|
120
198
|
/** Declares the application's component tree, state and commands. It starts nothing; the framework runs it. */
|
|
199
|
+
/** What `derived` makes, for the toolchain: the function is called with the one `derive`. */
|
|
200
|
+
export declare const derivedOf: (author: {
|
|
201
|
+
readonly derived?: AppDefinition<any>["derived"];
|
|
202
|
+
} | undefined) => Readonly<Record<string, Derived<any>>>;
|
|
121
203
|
export declare const defineApp: <S extends StateInput = {}>(definition: AppDefinition<S>) => DefinedApp;
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,8 @@ export const FIELD = Symbol.for("valancex.common.field");
|
|
|
4
4
|
* Declares one field of the application's state with its type and its default in one place. The framework builds the state's schema and its initial value from the fields; the default is
|
|
5
5
|
* checked against the type (by TypeScript, and again when the application is built).
|
|
6
6
|
*/
|
|
7
|
+
export const DERIVED = Symbol.for("valancex.common.derived");
|
|
8
|
+
const derive = (schema, from) => ({ [DERIVED]: true, schema: schema, from });
|
|
7
9
|
export const field = (schema, initial) => ({ [FIELD]: true, schema: schema, initial });
|
|
8
10
|
export const COMPONENT = Symbol.for("valancex.common.component");
|
|
9
11
|
/**
|
|
@@ -21,4 +23,6 @@ export function component(template, contract = {}) {
|
|
|
21
23
|
/** The brand that says a value came from `defineApp`. */
|
|
22
24
|
export const APP = Symbol.for("valancex.common");
|
|
23
25
|
/** Declares the application's component tree, state and commands. It starts nothing; the framework runs it. */
|
|
26
|
+
/** What `derived` makes, for the toolchain: the function is called with the one `derive`. */
|
|
27
|
+
export const derivedOf = (author) => author?.derived?.(derive) ?? {};
|
|
24
28
|
export const defineApp = (definition) => ({ ...definition, [APP]: true });
|
package/dist/internal.d.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
export * from "./app.js";
|
|
2
|
+
export * from "./web-app.js";
|
|
2
3
|
export * from "./model.js";
|
|
3
4
|
export * from "./primitives.js";
|
|
4
5
|
export * from "./routes.js";
|
|
5
6
|
export * from "./scope.js";
|
|
6
7
|
export * from "./state.js";
|
|
7
8
|
export * from "./vocabulary.js";
|
|
9
|
+
export * from "./targets.js";
|
|
10
|
+
export * from "./store.js";
|
|
11
|
+
export * from "./open.js";
|
|
12
|
+
export * from "./navigation.js";
|
|
13
|
+
export * from "./platform.js";
|
package/dist/internal.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
// What the build tools share with the runtime: the content model, the tag table, the scope a layout reads, the state and the application they make. `@valancex/cli` builds with these and the generated
|
|
2
2
|
// entries run them, so there is one definition of each. Not for applications: an application's code is `@valancex/common` (the entry point).
|
|
3
3
|
export * from "./app.js";
|
|
4
|
+
export * from "./web-app.js";
|
|
4
5
|
export * from "./model.js";
|
|
5
6
|
export * from "./primitives.js";
|
|
6
7
|
export * from "./routes.js";
|
|
7
8
|
export * from "./scope.js";
|
|
8
9
|
export * from "./state.js";
|
|
9
10
|
export * from "./vocabulary.js";
|
|
11
|
+
export * from "./targets.js";
|
|
12
|
+
export * from "./store.js";
|
|
13
|
+
export * from "./open.js";
|
|
14
|
+
export * from "./navigation.js";
|
|
15
|
+
export * from "./platform.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import * as Nexus from "@valancex/nexus";
|
|
2
|
+
/** The platform has no history of its own: the engine keeps the list (`trail` and `cursor`). */
|
|
3
|
+
export interface NavigationOwner {
|
|
4
|
+
readonly owner: "engine";
|
|
5
|
+
}
|
|
6
|
+
export declare const Navigation: Nexus.Capability.Capability<NavigationOwner>;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Who keeps the list of places the person has been (docs/application-model/NAVIGATION.md). A page has the browser's: its session history holds the list, restores it, and moves along it with the
|
|
2
|
+
// person's Back and Forward. A window has none, so the engine keeps the list in its state; a platform that supplies this capability is one of those, and only there does `persist: ["navigation"]`
|
|
3
|
+
// do anything (restoring a stored page on the web would argue with the address, which is the truth there).
|
|
4
|
+
import * as Nexus from "@valancex/nexus";
|
|
5
|
+
export const Navigation = Nexus.Capability.define("valance/navigation");
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { OpenClient } from "./open.js";
|
|
2
|
+
/** The program and arguments that open `address` on `platform`. */
|
|
3
|
+
export declare const openerFor: (address: string, platform?: NodeJS.Platform) => {
|
|
4
|
+
readonly program: string;
|
|
5
|
+
readonly args: ReadonlyArray<string>;
|
|
6
|
+
};
|
|
7
|
+
type Spawn = (program: string, args: ReadonlyArray<string>) => {
|
|
8
|
+
readonly on: (event: "error", listener: (error: Error) => void) => unknown;
|
|
9
|
+
readonly unref: () => void;
|
|
10
|
+
};
|
|
11
|
+
export declare const systemOpen: (spawn?: Spawn, platform?: NodeJS.Platform) => OpenClient;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The desktop's opener: the system's own, given the address as an argument (never through a shell, so the address is data and nothing else). Node only.
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
4
|
+
import { OpenError, refusal } from "./open.js";
|
|
5
|
+
/** The program and arguments that open `address` on `platform`. */
|
|
6
|
+
export const openerFor = (address, platform = process.platform) => platform === "win32"
|
|
7
|
+
? { program: "rundll32", args: ["url.dll,FileProtocolHandler", address] }
|
|
8
|
+
: platform === "darwin"
|
|
9
|
+
? { program: "open", args: [address] }
|
|
10
|
+
: { program: "xdg-open", args: [address] };
|
|
11
|
+
export const systemOpen = (spawn = (program, args) => nodeSpawn(program, [...args], { detached: true, stdio: "ignore" }), platform = process.platform) => ({
|
|
12
|
+
open: (address) => {
|
|
13
|
+
const reason = refusal(address);
|
|
14
|
+
if (reason !== undefined) {
|
|
15
|
+
return Effect.fail(new OpenError("Refused", reason));
|
|
16
|
+
}
|
|
17
|
+
const { program, args } = openerFor(address, platform);
|
|
18
|
+
return Effect.async((resume) => {
|
|
19
|
+
try {
|
|
20
|
+
const child = spawn(program, args);
|
|
21
|
+
child.on("error", () => resume(Effect.fail(new OpenError("Unavailable", `${program} could not be started`))));
|
|
22
|
+
child.unref();
|
|
23
|
+
// handed over: the opener is the system's, and does not report back to a program that asked
|
|
24
|
+
setTimeout(() => resume(Effect.void), 0);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
resume(Effect.fail(new OpenError("Unavailable", `${program} could not be started`)));
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
});
|
package/dist/open.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as Nexus from "@valancex/nexus";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
export declare class OpenError extends Error {
|
|
4
|
+
readonly kind: "Refused" | "Unavailable";
|
|
5
|
+
readonly _tag = "OpenError";
|
|
6
|
+
constructor(kind: "Refused" | "Unavailable", message: string);
|
|
7
|
+
}
|
|
8
|
+
export interface OpenClient {
|
|
9
|
+
/** Opens `address` in the program the platform keeps for it. Resolves when it was handed over, not when anything finished. */
|
|
10
|
+
readonly open: (address: string) => Effect.Effect<void, OpenError>;
|
|
11
|
+
}
|
|
12
|
+
/** The capability: ask for it in a command (`Open.open`), and the platform says whether it has one. */
|
|
13
|
+
export declare const Open: Nexus.Capability.Capability<OpenClient>;
|
|
14
|
+
/** The schemes an address may have to be opened. */
|
|
15
|
+
export declare const OPENABLE: readonly ["https:", "http:", "mailto:"];
|
|
16
|
+
/** Why `address` may not be opened, or `undefined` when it may: an absolute address of an openable scheme, without a control character, of a sensible length. */
|
|
17
|
+
export declare const refusal: (address: string) => string | undefined;
|
|
18
|
+
/** Opens `address` through the platform's opener. Requires the `Open` capability. */
|
|
19
|
+
export declare const open: (address: string) => Effect.Effect<void, OpenError | Nexus.Capability.CapabilityUnavailableError, Nexus.Capability.EnvironmentShape>;
|
|
20
|
+
/** The page's opener: a new tab, with no link back to this page. */
|
|
21
|
+
export declare const webOpen: (win?: {
|
|
22
|
+
readonly open: (url: string, target: string, features: string) => unknown;
|
|
23
|
+
}) => OpenClient;
|
package/dist/open.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Sending a person to an address outside the application: a web page in their browser, a mail in their mail program. NEXUS and VALANCE hold no such thing (their rule): it is a capability the
|
|
2
|
+
// platform supplies. The web's is a new tab; the desktop's is the system's own opener (`./open-system`). Only the addresses a person expects a link to open are opened: a script, a file or a
|
|
3
|
+
// program's own scheme is refused, whatever text asked.
|
|
4
|
+
import * as Nexus from "@valancex/nexus";
|
|
5
|
+
import { Effect } from "effect";
|
|
6
|
+
export class OpenError extends Error {
|
|
7
|
+
kind;
|
|
8
|
+
_tag = "OpenError";
|
|
9
|
+
constructor(kind, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.kind = kind;
|
|
12
|
+
this.name = "OpenError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** The capability: ask for it in a command (`Open.open`), and the platform says whether it has one. */
|
|
16
|
+
export const Open = Nexus.Capability.define("valance/open");
|
|
17
|
+
/** The schemes an address may have to be opened. */
|
|
18
|
+
export const OPENABLE = ["https:", "http:", "mailto:"];
|
|
19
|
+
/** Why `address` may not be opened, or `undefined` when it may: an absolute address of an openable scheme, without a control character, of a sensible length. */
|
|
20
|
+
export const refusal = (address) => {
|
|
21
|
+
// oxlint-disable-next-line no-control-regex -- a control character in an address is how one is smuggled into a command line
|
|
22
|
+
if (address.length === 0 || address.length > 2048 || /[\u0000-\u001f\u007f\s]/.test(address)) {
|
|
23
|
+
return "an address is one line of text, at most 2048 characters";
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(address);
|
|
27
|
+
return OPENABLE.includes(url.protocol) ? undefined : `a ${url.protocol} address is not opened (only ${OPENABLE.join(", ")})`;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return "not an absolute address";
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
/** Opens `address` through the platform's opener. Requires the `Open` capability. */
|
|
34
|
+
export const open = (address) => Effect.flatMap(Nexus.Capability.require(Open), (client) => client.open(address));
|
|
35
|
+
/** The page's opener: a new tab, with no link back to this page. */
|
|
36
|
+
export const webOpen = (win) => ({
|
|
37
|
+
open: (address) => {
|
|
38
|
+
const reason = refusal(address);
|
|
39
|
+
if (reason !== undefined) {
|
|
40
|
+
return Effect.fail(new OpenError("Refused", reason));
|
|
41
|
+
}
|
|
42
|
+
const target = win ?? globalThis.window;
|
|
43
|
+
return target === undefined
|
|
44
|
+
? Effect.fail(new OpenError("Unavailable", "this page cannot open a tab"))
|
|
45
|
+
: Effect.try({ try: () => void target.open(address, "_blank", "noopener,noreferrer"), catch: () => new OpenError("Unavailable", "the page's tab was blocked") });
|
|
46
|
+
},
|
|
47
|
+
});
|
package/dist/platform.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// What a platform supplies the application: its capabilities, by contract. `Web.run` and `Native.run` each take one; the web's is `webPlatform` (`./http`), the desktop's is made in `./desktop`.
|
|
2
|
+
import * as Nexus from "@valancex/nexus";
|
|
3
|
+
/** A platform that supplies each `[capability, implementation]` given. */
|
|
4
|
+
export const platformOf = (capabilities) => Nexus.Capability.EnvironmentLive(new Map(capabilities.map(([capability, implementation]) => [capability.id, { _tag: "Available", implementation }])));
|
package/dist/primitives.js
CHANGED
|
@@ -10,6 +10,12 @@ const label = Web.attribute("aria-label");
|
|
|
10
10
|
// A prop a tag declares is a prop every use of it must write (docs/application-model/HANDOFF.md, finding D1: MESH derives "required" from the realization and has no optional prop). So the
|
|
11
11
|
// table is many narrow tags rather than a few wide ones: a tag carries only what its uses always have.
|
|
12
12
|
export const primitives = {
|
|
13
|
+
// The portable core (the same names, props, events and payloads as the Desktop PORT's vocabulary: a component written with only these runs on both targets). `column` and `row` are custom
|
|
14
|
+
// elements, because this table cannot give a `div` a fixed attribute to tell them apart; `vlx` ships their layout (`core.css`). `heading` is an `h2` with its level as `aria-level`.
|
|
15
|
+
column: { element: "vlx-column" },
|
|
16
|
+
row: { element: "vlx-row" },
|
|
17
|
+
heading: { element: "h2", props: { level: Web.attribute("aria-level") } },
|
|
18
|
+
text: { element: "span" },
|
|
13
19
|
shell: { element: "div", props: { class: klass, theme: Web.attribute("data-theme") } },
|
|
14
20
|
div: { element: "div", props: { class: klass } },
|
|
15
21
|
header: { element: "header", props: { class: klass } },
|
|
@@ -37,5 +43,24 @@ export const primitives = {
|
|
|
37
43
|
a: { element: "a", props: { href: Web.attribute("href") } },
|
|
38
44
|
/** A link that is part of the page's furniture (navigation, a brand): it has a class, and says whether it is where you are (`aria-current`: "page" or "false"). */
|
|
39
45
|
link: { element: "a", props: { href: Web.attribute("href"), class: klass, current: Web.attribute("aria-current") } },
|
|
40
|
-
|
|
46
|
+
/** A box to tick, reporting `change` with the new value (a boolean). It is an `<input type="checkbox">` (a constant attribute of the primitive), and `checked` is live and server-rendered (`controlledBoolean`). */
|
|
47
|
+
checkbox: {
|
|
48
|
+
element: "input",
|
|
49
|
+
attributes: { type: "checkbox" },
|
|
50
|
+
props: { checked: Web.controlledBoolean("checked"), label },
|
|
51
|
+
events: { change: Web.event("change", { kind: "boolean", of: (_event, element) => element.checked }) },
|
|
52
|
+
},
|
|
53
|
+
/** A text field, reporting `change` with the new text (a string); the field shows what the state says. */
|
|
54
|
+
textfield: {
|
|
55
|
+
element: "input",
|
|
56
|
+
props: { value: Web.controlled("value"), placeholder: Web.attribute("placeholder"), label },
|
|
57
|
+
events: {
|
|
58
|
+
change: Web.event("input", { kind: "string", of: (_event, element) => element.value }),
|
|
59
|
+
// Enter: the person is done with what they typed
|
|
60
|
+
submit: Web.event("keydown", undefined, { key: "Enter" }),
|
|
61
|
+
// Escape: the person backs out of what they typed
|
|
62
|
+
cancel: Web.event("keydown", undefined, { key: "Escape" }),
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
button: { element: "button", props: { class: klass, pressed: Web.attribute("aria-pressed"), disabled: Web.booleanAttribute("disabled") }, events: { click: { type: "click" } } },
|
|
41
66
|
};
|
package/dist/scope.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
|
-
import type { TitleParts, Viewport } from "./index.js";
|
|
2
|
+
import type { ColorScheme, TitleParts, Viewport } from "./index.js";
|
|
3
3
|
import type { Content, Page } from "./model.js";
|
|
4
4
|
export declare const FrameScope: Schema.Struct<{
|
|
5
5
|
/** The application's name (`valance.json`). */
|
|
@@ -32,6 +32,12 @@ export declare const FrameScope: Schema.Struct<{
|
|
|
32
32
|
}>>;
|
|
33
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
34
|
viewport: Schema.Literal<["compact", "medium", "expanded"]>;
|
|
35
|
+
/** The colors the person's system asks for: `"light"` or `"dark"`. `theme={theme == "system" ? colorScheme : theme}` lets an application offer "follow the system" beside its own choices. The server renders `light`; the page corrects it after it starts. */
|
|
36
|
+
colorScheme: Schema.Literal<["light", "dark"]>;
|
|
37
|
+
/** Whether there is a page before this one in the trail (`back()` goes to it). `disabled={!canGoBack}` greys a Back button. */
|
|
38
|
+
canGoBack: typeof Schema.Boolean;
|
|
39
|
+
/** Whether there is a page after this one in the trail (`forward()` goes to it), as there is after a `back()`. */
|
|
40
|
+
canGoForward: typeof Schema.Boolean;
|
|
35
41
|
/** The page's body. Draw it with `<blocks items={blocks} />`, or draw each block yourself. */
|
|
36
42
|
blocks: Schema.Array$<Schema.Struct<{
|
|
37
43
|
id: typeof Schema.String;
|
|
@@ -80,4 +86,7 @@ export declare const pageAt: (content: Content, path: string) => Page | undefine
|
|
|
80
86
|
* (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
87
|
*/
|
|
82
88
|
export declare const documentTitle: (content: Content, page: Page | undefined, parts?: TitleParts) => string;
|
|
83
|
-
export declare const frame: (content: Content, path: string, viewport?: Viewport
|
|
89
|
+
export declare const frame: (content: Content, path: string, viewport?: Viewport, colorScheme?: ColorScheme, trail?: {
|
|
90
|
+
readonly canGoBack: boolean;
|
|
91
|
+
readonly canGoForward: boolean;
|
|
92
|
+
}) => typeof FrameScope.Type;
|
package/dist/scope.js
CHANGED
|
@@ -38,6 +38,12 @@ export const FrameScope = Schema.Struct({
|
|
|
38
38
|
toc: Schema.Array(Schema.Struct({ id: Schema.String, href: Schema.String, label: Schema.String, level: Schema.Number })),
|
|
39
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
40
|
viewport: Schema.Literal("compact", "medium", "expanded"),
|
|
41
|
+
/** The colors the person's system asks for: `"light"` or `"dark"`. `theme={theme == "system" ? colorScheme : theme}` lets an application offer "follow the system" beside its own choices. The server renders `light`; the page corrects it after it starts. */
|
|
42
|
+
colorScheme: Schema.Literal("light", "dark"),
|
|
43
|
+
/** Whether there is a page before this one in the trail (`back()` goes to it). `disabled={!canGoBack}` greys a Back button. */
|
|
44
|
+
canGoBack: Schema.Boolean,
|
|
45
|
+
/** Whether there is a page after this one in the trail (`forward()` goes to it), as there is after a `back()`. */
|
|
46
|
+
canGoForward: Schema.Boolean,
|
|
41
47
|
/** The page's body. Draw it with `<blocks items={blocks} />`, or draw each block yourself. */
|
|
42
48
|
blocks: Schema.Array(BlockView),
|
|
43
49
|
/** Neighbours in navigation order; `href` is empty when there is none. */
|
|
@@ -103,7 +109,7 @@ export const documentTitle = (content, page, parts = {}) => {
|
|
|
103
109
|
const body = shown === app ? shown : content.site.title.replaceAll("{page}", shown).replaceAll("{site}", app);
|
|
104
110
|
return parts.status === undefined || parts.status === "" ? body : `(${parts.status}) ${body}`;
|
|
105
111
|
};
|
|
106
|
-
export const frame = (content, path, viewport = "expanded") => {
|
|
112
|
+
export const frame = (content, path, viewport = "expanded", colorScheme = "light", trail = { canGoBack: false, canGoForward: false }) => {
|
|
107
113
|
const page = pageAt(content, path);
|
|
108
114
|
const shown = page ?? notFoundPage(content);
|
|
109
115
|
const at = page === undefined ? -1 : content.pages.indexOf(page);
|
|
@@ -120,6 +126,9 @@ export const frame = (content, path, viewport = "expanded") => {
|
|
|
120
126
|
return {
|
|
121
127
|
site: content.site.name,
|
|
122
128
|
viewport,
|
|
129
|
+
colorScheme,
|
|
130
|
+
canGoBack: trail.canGoBack,
|
|
131
|
+
canGoForward: trail.canGoForward,
|
|
123
132
|
home: withBase(content.site.base, "/"),
|
|
124
133
|
title: shown.title,
|
|
125
134
|
description: shown.description,
|
package/dist/server.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { renderHead } from "@valancex/valance/web/plugin";
|
|
2
2
|
import { renderToHtml } from "@valancex/valance/web/server";
|
|
3
3
|
import { Effect } from "effect";
|
|
4
|
-
import { application,
|
|
4
|
+
import { application, stateFor } from "./app.js";
|
|
5
|
+
import { plugin } from "./web-app.js";
|
|
5
6
|
import { routes } from "./routes.js";
|
|
6
7
|
export { routes };
|
|
7
8
|
const safe = (json) => json.replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { StoreClient } from "./store.js";
|
|
2
|
+
/** Where an application keeps its data on this machine: `$XDG_DATA_HOME` (else `~/.local/share`) on Linux, `%APPDATA%` on Windows, `~/Library/Application Support` on macOS, then the application's name. */
|
|
3
|
+
export declare const userDataDir: (name: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform) => string;
|
|
4
|
+
export declare const fileStore: (dir: string) => StoreClient;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The desktop's store: one JSON file per key in the user's data directory for the application. Node only (the page never loads this). A write goes to a temporary file and is renamed over the
|
|
2
|
+
// real one, so a crash leaves the old value or the new one, never half of one.
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { checkKey, decodeStored, encodeStored, MAX_STORED, StoreError } from "./store.js";
|
|
8
|
+
/** Where an application keeps its data on this machine: `$XDG_DATA_HOME` (else `~/.local/share`) on Linux, `%APPDATA%` on Windows, `~/Library/Application Support` on macOS, then the application's name. */
|
|
9
|
+
export const userDataDir = (name, env = process.env, platform = process.platform) => {
|
|
10
|
+
const app = name.replace(/[^\w.-]+/g, "-").replace(/^[.-]+/, "") || "app";
|
|
11
|
+
const base = platform === "win32"
|
|
12
|
+
? (env["APPDATA"] ?? join(homedir(), "AppData", "Roaming"))
|
|
13
|
+
: platform === "darwin"
|
|
14
|
+
? join(homedir(), "Library", "Application Support")
|
|
15
|
+
: (env["XDG_DATA_HOME"] ?? join(homedir(), ".local", "share"));
|
|
16
|
+
return join(base, app);
|
|
17
|
+
};
|
|
18
|
+
const unavailable = () => new StoreError("Unavailable", "the data directory refused");
|
|
19
|
+
export const fileStore = (dir) => {
|
|
20
|
+
const file = (key) => join(dir, `${key}.json`);
|
|
21
|
+
return {
|
|
22
|
+
get: (key, schema) => Effect.flatMap(checkKey(key), (safe) => Effect.flatMap(Effect.try({
|
|
23
|
+
try: () => {
|
|
24
|
+
try {
|
|
25
|
+
return readFileSync(file(safe), "utf8");
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error.code === "ENOENT") {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
catch: unavailable,
|
|
35
|
+
}), (text) => (text === null ? Effect.succeed(undefined) : decodeStored(text, schema)))),
|
|
36
|
+
set: (key, schema, value) => Effect.flatMap(checkKey(key), (safe) => Effect.flatMap(encodeStored(value, schema), (text) => text.length > MAX_STORED
|
|
37
|
+
? Effect.fail(new StoreError("Full", "the value is larger than a store holds"))
|
|
38
|
+
: Effect.try({
|
|
39
|
+
try: () => {
|
|
40
|
+
mkdirSync(dir, { recursive: true });
|
|
41
|
+
const temporary = `${file(safe)}.${process.pid}.tmp`;
|
|
42
|
+
writeFileSync(temporary, text);
|
|
43
|
+
renameSync(temporary, file(safe));
|
|
44
|
+
},
|
|
45
|
+
catch: unavailable,
|
|
46
|
+
}))),
|
|
47
|
+
remove: (key) => Effect.flatMap(checkKey(key), (safe) => Effect.try({ try: () => rmSync(file(safe), { force: true }), catch: unavailable })),
|
|
48
|
+
};
|
|
49
|
+
};
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as Nexus from "@valancex/nexus";
|
|
2
|
+
import { Effect, Schema } from "effect";
|
|
3
|
+
/** Why a store did not do what was asked, by kind. */
|
|
4
|
+
export declare class StoreError extends Error {
|
|
5
|
+
readonly kind: "Unavailable" | "Full" | "Decode" | "Unsafe";
|
|
6
|
+
readonly _tag = "StoreError";
|
|
7
|
+
constructor(kind: "Unavailable" | "Full" | "Decode" | "Unsafe", message: string);
|
|
8
|
+
}
|
|
9
|
+
export interface StoreClient {
|
|
10
|
+
/** The value stored under `key`, decoded by `schema`; `undefined` when nothing is stored. */
|
|
11
|
+
readonly get: <A>(key: string, schema: Schema.Schema<A, any, never>) => Effect.Effect<A | undefined, StoreError>;
|
|
12
|
+
/** Stores `value` under `key`, encoded by `schema`. */
|
|
13
|
+
readonly set: <A>(key: string, schema: Schema.Schema<A, any, never>, value: A) => Effect.Effect<void, StoreError>;
|
|
14
|
+
/** Forgets `key`. */
|
|
15
|
+
readonly remove: (key: string) => Effect.Effect<void, StoreError>;
|
|
16
|
+
}
|
|
17
|
+
/** The capability: ask for it in a command (`Store.get`), and the platform says whether it has one. */
|
|
18
|
+
export declare const Store: Nexus.Capability.Capability<StoreClient>;
|
|
19
|
+
type Needs = Nexus.Capability.EnvironmentShape;
|
|
20
|
+
type Failure = StoreError | Nexus.Capability.CapabilityUnavailableError;
|
|
21
|
+
export declare const get: <A>(key: string, schema: Schema.Schema<A, any, never>) => Effect.Effect<A | undefined, Failure, Needs>;
|
|
22
|
+
export declare const set: <A>(key: string, schema: Schema.Schema<A, any, never>, value: A) => Effect.Effect<void, Failure, Needs>;
|
|
23
|
+
export declare const remove: (key: string) => Effect.Effect<void, Failure, Needs>;
|
|
24
|
+
export declare const checkKey: (key: string) => Effect.Effect<string, StoreError>;
|
|
25
|
+
/** Decodes stored text with `schema`: text that is not JSON, or does not fit, is `Decode`. */
|
|
26
|
+
export declare const decodeStored: <A>(text: string, schema: Schema.Schema<A, any, never>) => Effect.Effect<A, StoreError>;
|
|
27
|
+
export declare const encodeStored: <A>(value: A, schema: Schema.Schema<A, any, never>) => Effect.Effect<string, StoreError>;
|
|
28
|
+
/** The part of `Storage` the web's store uses (so a test supplies its own). */
|
|
29
|
+
export interface TextStorage {
|
|
30
|
+
readonly getItem: (key: string) => string | null;
|
|
31
|
+
readonly setItem: (key: string, value: string) => void;
|
|
32
|
+
readonly removeItem: (key: string) => void;
|
|
33
|
+
}
|
|
34
|
+
/** The longest value written, in characters: a page's storage is small and shared. */
|
|
35
|
+
export declare const MAX_STORED = 1000000;
|
|
36
|
+
/** The web's store: the page's `localStorage`, under `valance:<namespace>:<key>`. Where storage is off (a private window, blocked site data) every call is `Unavailable`, and the application runs without it. */
|
|
37
|
+
export declare const webStore: (namespace: string, storage?: TextStorage) => StoreClient;
|
|
38
|
+
/** The platform that supplies `store` as the `Store` capability. */
|
|
39
|
+
export declare const storePlatform: (store: StoreClient) => Nexus.Application.Platform;
|
|
40
|
+
export {};
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// What an application keeps between runs. NEXUS and VALANCE hold no storage (their rule): what an application needs from where it runs is a *capability*, a typed contract the application states
|
|
2
|
+
// and the platform supplies. This is that contract for a small keyed store, and the web's implementation of it (the page's `localStorage`). The desktop's, a file in the user's data directory,
|
|
3
|
+
// is `./store-file` (it needs Node, so the page never loads it).
|
|
4
|
+
//
|
|
5
|
+
// const saved = yield* Store.get("items", Items); // Effect<readonly Item[] | undefined, StoreError, the Store capability>
|
|
6
|
+
// yield* Store.set("items", Items, next);
|
|
7
|
+
//
|
|
8
|
+
// A value goes in as JSON and comes out *decoded by the Schema the caller states*: what is stored by an older version of the application, or by anything else, and no longer fits, is not a
|
|
9
|
+
// value: it is `Decode`, and nothing wrong reaches state. A failure says what kind it was and never what it carried.
|
|
10
|
+
import * as Nexus from "@valancex/nexus";
|
|
11
|
+
import { Effect, Schema } from "effect";
|
|
12
|
+
/** Why a store did not do what was asked, by kind. */
|
|
13
|
+
export class StoreError extends Error {
|
|
14
|
+
kind;
|
|
15
|
+
_tag = "StoreError";
|
|
16
|
+
constructor(kind, message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.kind = kind;
|
|
19
|
+
this.name = "StoreError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** The capability: ask for it in a command (`Store.get`), and the platform says whether it has one. */
|
|
23
|
+
export const Store = Nexus.Capability.define("valance/store");
|
|
24
|
+
const client = Nexus.Capability.require(Store);
|
|
25
|
+
export const get = (key, schema) => Effect.flatMap(client, (store) => store.get(key, schema));
|
|
26
|
+
export const set = (key, schema, value) => Effect.flatMap(client, (store) => store.set(key, schema, value));
|
|
27
|
+
export const remove = (key) => Effect.flatMap(client, (store) => store.remove(key));
|
|
28
|
+
/** A key is a word: letters, digits, `.`, `_` and `-`, so it can never name anything but the entry it is. */
|
|
29
|
+
const SAFE_KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
30
|
+
export const checkKey = (key) => SAFE_KEY.test(key) ? Effect.succeed(key) : Effect.fail(new StoreError("Unsafe", `"${key}" cannot be a store key: use letters, digits, . _ - (at most 80 characters)`));
|
|
31
|
+
/** Decodes stored text with `schema`: text that is not JSON, or does not fit, is `Decode`. */
|
|
32
|
+
export const decodeStored = (text, schema) => Effect.try({ try: () => JSON.parse(text), catch: () => new StoreError("Decode", "what is stored is not JSON") }).pipe(Effect.flatMap((json) => Schema.decodeUnknown(schema)(json).pipe(Effect.mapError(() => new StoreError("Decode", "what is stored no longer fits its type")))));
|
|
33
|
+
export const encodeStored = (value, schema) => Schema.encode(schema)(value).pipe(Effect.mapError(() => new StoreError("Decode", "the value does not fit its type")), Effect.map((encoded) => JSON.stringify(encoded)));
|
|
34
|
+
/** The longest value written, in characters: a page's storage is small and shared. */
|
|
35
|
+
export const MAX_STORED = 1_000_000;
|
|
36
|
+
/** The web's store: the page's `localStorage`, under `valance:<namespace>:<key>`. Where storage is off (a private window, blocked site data) every call is `Unavailable`, and the application runs without it. */
|
|
37
|
+
export const webStore = (namespace, storage) => {
|
|
38
|
+
const prefix = `valance:${namespace}:`;
|
|
39
|
+
const area = () => Effect.try({
|
|
40
|
+
try: () => {
|
|
41
|
+
const found = storage ?? globalThis.localStorage;
|
|
42
|
+
if (found === undefined) {
|
|
43
|
+
throw new Error("no storage");
|
|
44
|
+
}
|
|
45
|
+
return found;
|
|
46
|
+
},
|
|
47
|
+
catch: () => new StoreError("Unavailable", "this page has no storage"),
|
|
48
|
+
});
|
|
49
|
+
const guarded = (run) => Effect.flatMap(area(), (found) => Effect.try({
|
|
50
|
+
try: () => run(found),
|
|
51
|
+
catch: (error) => new StoreError(error instanceof Error && error.name === "QuotaExceededError" ? "Full" : "Unavailable", "the page's storage refused"),
|
|
52
|
+
}));
|
|
53
|
+
return {
|
|
54
|
+
get: (key, schema) => Effect.flatMap(checkKey(key), (safe) => Effect.flatMap(guarded((found) => found.getItem(`${prefix}${safe}`)), (text) => (text === null ? Effect.succeed(undefined) : decodeStored(text, schema)))),
|
|
55
|
+
set: (key, schema, value) => Effect.flatMap(checkKey(key), (safe) => Effect.flatMap(encodeStored(value, schema), (text) => text.length > MAX_STORED ? Effect.fail(new StoreError("Full", "the value is larger than a store holds")) : guarded((found) => found.setItem(`${prefix}${safe}`, text)))),
|
|
56
|
+
remove: (key) => Effect.flatMap(checkKey(key), (safe) => guarded((found) => found.removeItem(`${prefix}${safe}`))),
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
/** The platform that supplies `store` as the `Store` capability. */
|
|
60
|
+
export const storePlatform = (store) => Nexus.Capability.EnvironmentLive(new Map([[Store.id, { _tag: "Available", implementation: store }]]));
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DefinedApp } from "./index.js";
|
|
2
|
+
export type TargetName = "web" | "desktop";
|
|
3
|
+
/** On a definition made by `forTarget`: the definition it was made from, so the toolchain can still see every target's templates. */
|
|
4
|
+
export declare const WHOLE: unique symbol;
|
|
5
|
+
/** The definition as `target` sees it: its own layout in place of the top-level one, its components and overrides added. */
|
|
6
|
+
export declare const forTarget: (author: DefinedApp | undefined, target: TargetName) => DefinedApp | undefined;
|
|
7
|
+
/** Every target the definition has templates for. */
|
|
8
|
+
export declare const targetsOf: (author: DefinedApp | undefined) => ReadonlyArray<TargetName>;
|
package/dist/targets.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** On a definition made by `forTarget`: the definition it was made from, so the toolchain can still see every target's templates. */
|
|
2
|
+
export const WHOLE = Symbol.for("valancex.common.whole");
|
|
3
|
+
/** The definition as `target` sees it: its own layout in place of the top-level one, its components and overrides added. */
|
|
4
|
+
export const forTarget = (author, target) => {
|
|
5
|
+
if (author === undefined) {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
const parts = author.targets?.[target];
|
|
9
|
+
if (parts === undefined) {
|
|
10
|
+
return author;
|
|
11
|
+
}
|
|
12
|
+
const layout = parts.layout ?? author.layout;
|
|
13
|
+
return {
|
|
14
|
+
...author,
|
|
15
|
+
...{ [WHOLE]: author },
|
|
16
|
+
...(layout === undefined ? {} : { layout }),
|
|
17
|
+
components: [...(author.components ?? []), ...(parts.components ?? [])],
|
|
18
|
+
override: [...(author.override ?? []), ...(parts.override ?? [])],
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
/** Every target the definition has templates for. */
|
|
22
|
+
export const targetsOf = (author) => Object.keys(author?.targets ?? {});
|