@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/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/routes.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { announcePage } from "./announce.js";
|
|
1
2
|
import { pageAt } from "./scope.js";
|
|
2
3
|
/** An address as the application sees it: under the base removed, no trailing slash, `/` for the first page. An address outside the base is kept whole (it is not a page). */
|
|
3
4
|
export const normalize = (pathname, base) => {
|
|
@@ -15,8 +16,18 @@ export const routes = (content) => ({
|
|
|
15
16
|
// Static first: every page is rendered once at build time.
|
|
16
17
|
delivery: () => "static",
|
|
17
18
|
onNavigated: ({ kind, url }) => {
|
|
19
|
+
if (typeof window === "undefined") {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
// A person who cannot see the page change is told it did: the new title, and focus at the content. Not for a fragment (the browser moves there itself), and not when the project turned it off.
|
|
23
|
+
const announce = () => {
|
|
24
|
+
if (content.site.announce && url.hash === "") {
|
|
25
|
+
setTimeout(() => announcePage(document), 0); // after the application has presented the new page
|
|
26
|
+
}
|
|
27
|
+
};
|
|
18
28
|
// Land at the top of a new page, or at its heading when the address names one; Back and Forward are the browser's to restore.
|
|
19
|
-
if (kind !== "link"
|
|
29
|
+
if (kind !== "link") {
|
|
30
|
+
announce();
|
|
20
31
|
return;
|
|
21
32
|
}
|
|
22
33
|
// The application's state has no fragment, so the address bar lost it: put it back.
|
|
@@ -30,5 +41,6 @@ export const routes = (content) => ({
|
|
|
30
41
|
else {
|
|
31
42
|
target.scrollIntoView();
|
|
32
43
|
}
|
|
44
|
+
announce();
|
|
33
45
|
},
|
|
34
46
|
});
|
package/dist/scope.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
|
+
import type { ColorScheme, TitleParts, Viewport } from "./index.js";
|
|
2
3
|
import type { Content, Page } from "./model.js";
|
|
3
4
|
export declare const FrameScope: Schema.Struct<{
|
|
4
5
|
/** The application's name (`valance.json`). */
|
|
@@ -29,6 +30,14 @@ export declare const FrameScope: Schema.Struct<{
|
|
|
29
30
|
label: typeof Schema.String;
|
|
30
31
|
level: typeof Schema.Number;
|
|
31
32
|
}>>;
|
|
33
|
+
/** How much room the page has: `"compact"`, `"medium"` or `"expanded"` (see `Viewport`). `{#if viewport == "compact"}` chooses a layout for a phone. The server renders `expanded`; the browser corrects it after it starts. */
|
|
34
|
+
viewport: Schema.Literal<["compact", "medium", "expanded"]>;
|
|
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;
|
|
32
41
|
/** The page's body. Draw it with `<blocks items={blocks} />`, or draw each block yourself. */
|
|
33
42
|
blocks: Schema.Array$<Schema.Struct<{
|
|
34
43
|
id: typeof Schema.String;
|
|
@@ -72,6 +81,12 @@ export declare const sectionLabel: (dir: string) => string;
|
|
|
72
81
|
/** The page shown for an address that is not a page: `content/404.md` if the author wrote one. */
|
|
73
82
|
export declare const notFoundPage: (content: Content) => Page;
|
|
74
83
|
export declare const pageAt: (content: Content, path: string) => Page | undefined;
|
|
75
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
84
|
+
/**
|
|
85
|
+
* The text of the document's `<title>` for a page: the project's format (`{page} · {site}`) with the parts filled in, and the status first, in parentheses. A page whose title is the site's name
|
|
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).
|
|
87
|
+
*/
|
|
88
|
+
export declare const documentTitle: (content: Content, page: Page | undefined, parts?: TitleParts) => string;
|
|
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
|
@@ -36,6 +36,14 @@ export const FrameScope = Schema.Struct({
|
|
|
36
36
|
/** The `##` and `###` headings of the current page; `hasToc` is false when there are none. */
|
|
37
37
|
hasToc: Schema.Boolean,
|
|
38
38
|
toc: Schema.Array(Schema.Struct({ id: Schema.String, href: Schema.String, label: Schema.String, level: Schema.Number })),
|
|
39
|
+
/** How much room the page has: `"compact"`, `"medium"` or `"expanded"` (see `Viewport`). `{#if viewport == "compact"}` chooses a layout for a phone. The server renders `expanded`; the browser corrects it after it starts. */
|
|
40
|
+
viewport: Schema.Literal("compact", "medium", "expanded"),
|
|
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,
|
|
39
47
|
/** The page's body. Draw it with `<blocks items={blocks} />`, or draw each block yourself. */
|
|
40
48
|
blocks: Schema.Array(BlockView),
|
|
41
49
|
/** Neighbours in navigation order; `href` is empty when there is none. */
|
|
@@ -91,15 +99,17 @@ export const notFoundPage = (content) => content.notFound ?? {
|
|
|
91
99
|
],
|
|
92
100
|
};
|
|
93
101
|
export const pageAt = (content, path) => content.pages.find((page) => page.path === path);
|
|
94
|
-
/**
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
/**
|
|
103
|
+
* The text of the document's `<title>` for a page: the project's format (`{page} · {site}`) with the parts filled in, and the status first, in parentheses. A page whose title is the site's name
|
|
104
|
+
* (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).
|
|
105
|
+
*/
|
|
106
|
+
export const documentTitle = (content, page, parts = {}) => {
|
|
107
|
+
const shown = parts.page ?? (page === undefined ? notFoundPage(content).title : page.title);
|
|
108
|
+
const app = parts.app ?? content.site.name;
|
|
109
|
+
const body = shown === app ? shown : content.site.title.replaceAll("{page}", shown).replaceAll("{site}", app);
|
|
110
|
+
return parts.status === undefined || parts.status === "" ? body : `(${parts.status}) ${body}`;
|
|
111
|
+
};
|
|
112
|
+
export const frame = (content, path, viewport = "expanded", colorScheme = "light", trail = { canGoBack: false, canGoForward: false }) => {
|
|
103
113
|
const page = pageAt(content, path);
|
|
104
114
|
const shown = page ?? notFoundPage(content);
|
|
105
115
|
const at = page === undefined ? -1 : content.pages.indexOf(page);
|
|
@@ -115,6 +125,10 @@ export const frame = (content, path) => {
|
|
|
115
125
|
}
|
|
116
126
|
return {
|
|
117
127
|
site: content.site.name,
|
|
128
|
+
viewport,
|
|
129
|
+
colorScheme,
|
|
130
|
+
canGoBack: trail.canGoBack,
|
|
131
|
+
canGoForward: trail.canGoForward,
|
|
118
132
|
home: withBase(content.site.base, "/"),
|
|
119
133
|
title: shown.title,
|
|
120
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");
|
|
@@ -10,7 +11,7 @@ export const createRenderer = ({ content, program, author }) => {
|
|
|
10
11
|
const table = routes(content);
|
|
11
12
|
return async (pathname, built) => {
|
|
12
13
|
const state = stateFor(content, pathname, author);
|
|
13
|
-
const served = await Effect.runPromise(renderToHtml(app, { plugins: [plugin(content, built)], state }));
|
|
14
|
+
const served = await Effect.runPromise(renderToHtml(app, { plugins: [plugin(content, built, author)], state }));
|
|
14
15
|
const boot = safe(JSON.stringify({ state: served.state, built }));
|
|
15
16
|
return {
|
|
16
17
|
status: table.known(pathname) ? 200 : 404,
|
|
@@ -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 ?? {});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Effect } from "effect";
|
|
2
|
+
export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
3
|
+
/** Why a request did not give a value. None of them carries the response text, the address or a header: those can hold a secret. */
|
|
4
|
+
export type HttpError = {
|
|
5
|
+
readonly _tag: "Offline";
|
|
6
|
+
} | {
|
|
7
|
+
readonly _tag: "Status";
|
|
8
|
+
readonly status: number;
|
|
9
|
+
} | {
|
|
10
|
+
readonly _tag: "Decode";
|
|
11
|
+
} | {
|
|
12
|
+
readonly _tag: "Timeout";
|
|
13
|
+
} | {
|
|
14
|
+
readonly _tag: "Aborted";
|
|
15
|
+
} | {
|
|
16
|
+
readonly _tag: "TooLarge";
|
|
17
|
+
} | {
|
|
18
|
+
readonly _tag: "Unsafe";
|
|
19
|
+
readonly reason: string;
|
|
20
|
+
};
|
|
21
|
+
/** The request as it goes out: already checked, with its final address. Headers are lower-case. */
|
|
22
|
+
export interface TransportRequest {
|
|
23
|
+
readonly method: Method;
|
|
24
|
+
readonly url: string;
|
|
25
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
26
|
+
readonly body?: string;
|
|
27
|
+
readonly timeoutMs: number;
|
|
28
|
+
}
|
|
29
|
+
/** The answer as it came back, whatever its status. Headers are lower-case. `text` is empty for no content. */
|
|
30
|
+
export interface TransportResponse {
|
|
31
|
+
readonly status: number;
|
|
32
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
33
|
+
readonly text: string;
|
|
34
|
+
}
|
|
35
|
+
export type Transport = (request: TransportRequest) => Effect.Effect<TransportResponse, HttpError>;
|
|
36
|
+
/** Wraps the next transport: change the request on the way in, the answer on the way out, call `next` more than once (retry) or not at all (a cached answer). */
|
|
37
|
+
export type Interceptor = (next: Transport) => Transport;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Viewport } from "./index.js";
|
|
2
|
+
export declare const MEDIUM = 600;
|
|
3
|
+
export declare const EXPANDED = 840;
|
|
4
|
+
export declare const viewportOf: (width: number) => Viewport;
|
|
5
|
+
interface Query {
|
|
6
|
+
readonly matches: boolean;
|
|
7
|
+
addEventListener(type: "change", listener: () => void): void;
|
|
8
|
+
removeEventListener(type: "change", listener: () => void): void;
|
|
9
|
+
}
|
|
10
|
+
export interface MediaWindow {
|
|
11
|
+
matchMedia(query: string): Query;
|
|
12
|
+
}
|
|
13
|
+
/** Calls `changed` with the current class now, and again whenever it changes. Returns the function that stops watching. */
|
|
14
|
+
export declare const watchViewport: (win: MediaWindow, changed: (viewport: Viewport) => void) => (() => void);
|
|
15
|
+
export {};
|
package/dist/viewport.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const MEDIUM = 600;
|
|
2
|
+
export const EXPANDED = 840;
|
|
3
|
+
export const viewportOf = (width) => (width >= EXPANDED ? "expanded" : width >= MEDIUM ? "medium" : "compact");
|
|
4
|
+
/** Calls `changed` with the current class now, and again whenever it changes. Returns the function that stops watching. */
|
|
5
|
+
export const watchViewport = (win, changed) => {
|
|
6
|
+
const medium = win.matchMedia(`(min-width: ${MEDIUM}px)`);
|
|
7
|
+
const expanded = win.matchMedia(`(min-width: ${EXPANDED}px)`);
|
|
8
|
+
const current = () => (expanded.matches ? "expanded" : medium.matches ? "medium" : "compact");
|
|
9
|
+
let last;
|
|
10
|
+
const check = () => {
|
|
11
|
+
const now = current();
|
|
12
|
+
if (now !== last) {
|
|
13
|
+
last = now;
|
|
14
|
+
changed(now);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
medium.addEventListener("change", check);
|
|
18
|
+
expanded.addEventListener("change", check);
|
|
19
|
+
check();
|
|
20
|
+
return () => {
|
|
21
|
+
medium.removeEventListener("change", check);
|
|
22
|
+
expanded.removeEventListener("change", check);
|
|
23
|
+
};
|
|
24
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { WebPrimitives } from "@valancex/port-web";
|
|
2
|
+
/**
|
|
3
|
+
* A target's vocabulary has one of two forms. The web's is a table of PORT Web's realizations, from which the manifest is derived (`tags`). Any other target's is a MESH manifest, as data
|
|
4
|
+
* (`components`): the PORT ships it (the Desktop PORT's `vocabulary.json` and `content.json`), and the build reads it as it is.
|
|
5
|
+
*/
|
|
6
|
+
export interface WebVocabulary {
|
|
7
|
+
/** The target's id (`web`). */
|
|
8
|
+
readonly target: string;
|
|
9
|
+
/** The tags, each with its props and events. */
|
|
10
|
+
readonly tags: WebPrimitives;
|
|
11
|
+
/**
|
|
12
|
+
* The props a template may leave out, by tag. PORT Web's table says how a prop is realized, not whether it is required, so the manifest derived from it makes every prop required but a boolean
|
|
13
|
+
* one; this is the author-facing rule beside it. An absent prop is not in the render tree, and PORT Web realizes an absent prop as no attribute (or the element's own initial value).
|
|
14
|
+
*/
|
|
15
|
+
readonly optional?: Readonly<Record<string, ReadonlyArray<string>>>;
|
|
16
|
+
}
|
|
17
|
+
export interface ManifestVocabulary {
|
|
18
|
+
readonly target: string;
|
|
19
|
+
/** MESH manifest components: each with its props and events. */
|
|
20
|
+
readonly components: Readonly<Record<string, unknown>>;
|
|
21
|
+
}
|
|
22
|
+
export type Vocabulary = WebVocabulary | ManifestVocabulary;
|
|
23
|
+
/** The names a template may write as a primitive in this vocabulary. */
|
|
24
|
+
export declare const namesOf: (vocabulary: Vocabulary) => ReadonlyArray<string>;
|
|
25
|
+
/** A vocabulary that is a MESH manifest (or several: their components are merged, and a name may not be in two). */
|
|
26
|
+
export declare const fromManifests: (target: string, ...manifests: ReadonlyArray<{
|
|
27
|
+
readonly components: Readonly<Record<string, unknown>>;
|
|
28
|
+
}>) => ManifestVocabulary;
|
|
29
|
+
/** The web's vocabulary: HTML elements, with `class`, `theme`, `id` and `aria-*` as styling and accessibility hooks. */
|
|
30
|
+
export declare const webVocabulary: WebVocabulary;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { primitives } from "./primitives.js";
|
|
2
|
+
/** The names a template may write as a primitive in this vocabulary. */
|
|
3
|
+
export const namesOf = (vocabulary) => Object.keys("tags" in vocabulary ? vocabulary.tags : vocabulary.components);
|
|
4
|
+
/** A vocabulary that is a MESH manifest (or several: their components are merged, and a name may not be in two). */
|
|
5
|
+
export const fromManifests = (target, ...manifests) => {
|
|
6
|
+
const components = {};
|
|
7
|
+
for (const manifest of manifests) {
|
|
8
|
+
for (const [name, each] of Object.entries(manifest.components)) {
|
|
9
|
+
if (name in components) {
|
|
10
|
+
throw new Error(`the ${target} vocabulary declares "${name}" twice`);
|
|
11
|
+
}
|
|
12
|
+
components[name] = each;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return { target, components };
|
|
16
|
+
};
|
|
17
|
+
/** The web's vocabulary: HTML elements, with `class`, `theme`, `id` and `aria-*` as styling and accessibility hooks. */
|
|
18
|
+
export const webVocabulary = {
|
|
19
|
+
target: "web",
|
|
20
|
+
tags: primitives,
|
|
21
|
+
optional: {
|
|
22
|
+
// styling and accessibility hooks are the web's, and a template for both targets does not write them
|
|
23
|
+
button: ["class", "pressed"],
|
|
24
|
+
checkbox: ["label"],
|
|
25
|
+
textfield: ["label", "placeholder"],
|
|
26
|
+
heading: ["level"],
|
|
27
|
+
},
|
|
28
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Plugin } from "@valancex/valance/web/plugin";
|
|
2
|
+
import type { AppState, Built } from "./app.js";
|
|
3
|
+
import type { DefinedApp } from "./index.js";
|
|
4
|
+
import type { Content } from "./model.js";
|
|
5
|
+
/** The plugin that makes the application a website: its tags, its URL policy, and its document head. */
|
|
6
|
+
export declare const plugin: (content: Content, built: Built, author?: DefinedApp) => Plugin<AppState>;
|
package/dist/web-app.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { titleFor } from "./app.js";
|
|
2
|
+
import { pageAt } from "./scope.js";
|
|
3
|
+
import { primitives } from "./primitives.js";
|
|
4
|
+
import { routes } from "./routes.js";
|
|
5
|
+
/** The plugin that makes the application a website: its tags, its URL policy, and its document head. */
|
|
6
|
+
export const plugin = (content, built, author) => ({
|
|
7
|
+
name: "valance-app",
|
|
8
|
+
primitives,
|
|
9
|
+
routes: routes(content),
|
|
10
|
+
head: (state) => {
|
|
11
|
+
const page = pageAt(content, state.page);
|
|
12
|
+
const shown = page ?? { description: "" };
|
|
13
|
+
const canonical = content.site.url === "" || page === undefined ? [] : [{ rel: "canonical", href: `${content.site.url}${content.site.base}${page.path.replace(/^\//, "")}` }];
|
|
14
|
+
return {
|
|
15
|
+
title: titleFor(content, page, state, author),
|
|
16
|
+
meta: shown.description === "" ? [] : [{ name: "description", content: shown.description }],
|
|
17
|
+
links: [...built.styles.map((href) => ({ rel: "stylesheet", href })), ...canonical],
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@valancex/common",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "The author's side of a VALANCE application (defineApp, component, field) and the runtime the vlx build runs: the browser and server entries. What src/main.ts imports; @valancex/cli builds what it describes.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"valance",
|
|
@@ -32,15 +32,23 @@
|
|
|
32
32
|
"types": "./dist/client.d.ts",
|
|
33
33
|
"default": "./dist/client.js"
|
|
34
34
|
},
|
|
35
|
-
"./
|
|
36
|
-
"types": "./dist/
|
|
37
|
-
"default": "./dist/
|
|
35
|
+
"./desktop": {
|
|
36
|
+
"types": "./dist/desktop.d.ts",
|
|
37
|
+
"default": "./dist/desktop.js"
|
|
38
|
+
},
|
|
39
|
+
"./http": {
|
|
40
|
+
"types": "./dist/http.d.ts",
|
|
41
|
+
"default": "./dist/http.js"
|
|
38
42
|
},
|
|
39
43
|
"./internal": {
|
|
40
44
|
"types": "./dist/internal.d.ts",
|
|
41
45
|
"default": "./dist/internal.js"
|
|
42
46
|
},
|
|
43
|
-
"./package.json": "./package.json"
|
|
47
|
+
"./package.json": "./package.json",
|
|
48
|
+
"./server": {
|
|
49
|
+
"types": "./dist/server.d.ts",
|
|
50
|
+
"default": "./dist/server.js"
|
|
51
|
+
}
|
|
44
52
|
},
|
|
45
53
|
"types": "./dist/index.d.ts",
|
|
46
54
|
"files": [
|
|
@@ -53,8 +61,10 @@
|
|
|
53
61
|
},
|
|
54
62
|
"dependencies": {
|
|
55
63
|
"@valancex/mesh-runtime": "^0.11.0",
|
|
56
|
-
"@valancex/
|
|
57
|
-
"@valancex/
|
|
64
|
+
"@valancex/nexus": "^0.12.0",
|
|
65
|
+
"@valancex/port-web": "^0.6.1",
|
|
66
|
+
"@valancex/router": "^0.13.0",
|
|
67
|
+
"@valancex/valance": "^0.13.0"
|
|
58
68
|
},
|
|
59
69
|
"peerDependencies": {
|
|
60
70
|
"effect": "^3.10.0"
|