@valancex/common 0.0.0-stage → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -2
- package/dist/announce.d.ts +25 -0
- package/dist/announce.js +39 -0
- package/dist/app.d.ts +35 -0
- package/dist/app.js +124 -0
- package/dist/client.d.ts +9 -0
- package/dist/client.js +40 -0
- package/dist/http.d.ts +65 -0
- package/dist/http.js +188 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.js +24 -0
- package/dist/internal.d.ts +7 -0
- package/dist/internal.js +9 -0
- package/dist/model.d.ts +72 -0
- package/dist/model.js +4 -0
- package/dist/primitives.d.ts +2 -0
- package/dist/primitives.js +41 -0
- package/dist/routes.d.ts +9 -0
- package/dist/routes.js +46 -0
- package/dist/scope.d.ts +83 -0
- package/dist/scope.js +134 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +22 -0
- package/dist/state.d.ts +13 -0
- package/dist/state.js +33 -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 +9 -0
- package/dist/vocabulary.js +3 -0
- package/package.json +75 -3
- package/types/mprx.d.ts +5 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/// <reference path="../types/mprx.d.ts" preserve="true" />
|
|
2
|
+
import type * as Valance from "@valancex/valance";
|
|
3
|
+
import { Schema } from "effect";
|
|
4
|
+
import type { Interceptor } from "./transport.js";
|
|
5
|
+
/** The part of the application's state the framework owns: which page is shown. A command's `state` always has it. */
|
|
6
|
+
export interface PageState {
|
|
7
|
+
readonly page: string;
|
|
8
|
+
/** How much room the page has: `compact` (under 600 px wide: a phone), `medium` (600 to 839) or `expanded` (840 and up). The framework keeps it current as the window changes; a layout reads it as `viewport`. */
|
|
9
|
+
readonly viewport: Viewport;
|
|
10
|
+
}
|
|
11
|
+
/** Window size classes, the ones Material Design and iOS size classes agree on closely enough to design for. */
|
|
12
|
+
export type Viewport = "compact" | "medium" | "expanded";
|
|
13
|
+
export declare const FIELD: unique symbol;
|
|
14
|
+
/** A state field: its type and its default, declared together. */
|
|
15
|
+
export interface Field<A = unknown> {
|
|
16
|
+
readonly [FIELD]: true;
|
|
17
|
+
readonly schema: Schema.Schema<A, any, never>;
|
|
18
|
+
readonly initial: A;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 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
|
+
* checked against the type (by TypeScript, and again when the application is built).
|
|
23
|
+
*/
|
|
24
|
+
export declare const field: <A, I = A>(schema: Schema.Schema<A, I, never>, initial: NoInfer<A>) => Field<A>;
|
|
25
|
+
/** Fields declared one by one. */
|
|
26
|
+
export type Fields = Readonly<Record<string, Field<any>>>;
|
|
27
|
+
/** A schema built by hand with a separate initial value: for hydration and for schemas shared with other code. */
|
|
28
|
+
export interface HandBuilt<F extends Schema.Struct.Fields = Schema.Struct.Fields> {
|
|
29
|
+
readonly schema: Schema.Struct<F>;
|
|
30
|
+
readonly initial: Schema.Struct.Type<F>;
|
|
31
|
+
}
|
|
32
|
+
export type StateInput = Fields | HandBuilt<any>;
|
|
33
|
+
/** The type of the state a definition declares, beyond the page. */
|
|
34
|
+
export type StateOf<S> = S extends HandBuilt<infer F> ? Schema.Struct.Type<F> : S extends Fields ? {
|
|
35
|
+
readonly [K in keyof S]: S[K] extends Field<infer A> ? A : never;
|
|
36
|
+
} : {};
|
|
37
|
+
/**
|
|
38
|
+
* What importing a template file gives you: its address. `P` and `E` are the names of its props and events when the toolchain has generated them (`.valance/templates.d.ts`, included by the
|
|
39
|
+
* project's `tsconfig.json`); without that, templates are untyped (`string`).
|
|
40
|
+
*/
|
|
41
|
+
export interface Template<P extends string = string, E extends string = string> {
|
|
42
|
+
readonly mprx: string;
|
|
43
|
+
readonly "~props"?: P;
|
|
44
|
+
readonly "~events"?: E;
|
|
45
|
+
}
|
|
46
|
+
export declare const COMPONENT: unique symbol;
|
|
47
|
+
/** One component: a template, and the interface it declares if it declares one. Which components its template uses is read from the template; nothing is written twice. */
|
|
48
|
+
export interface Component {
|
|
49
|
+
readonly [COMPONENT]: true;
|
|
50
|
+
readonly template: Template;
|
|
51
|
+
/** Present when the component declares its props (an omitted side of a declared interface is none). Absent: the contract is inferred from the template and its uses. */
|
|
52
|
+
readonly props?: Readonly<Record<string, Schema.Schema<any, any, never>>>;
|
|
53
|
+
readonly events?: Readonly<Record<string, Schema.Schema<any, any, never>>>;
|
|
54
|
+
}
|
|
55
|
+
export interface Contract<P extends string, E extends string> {
|
|
56
|
+
/**
|
|
57
|
+
* The inputs the template reads, each with its type. Optional when the schema admits `undefined`. A prop has no default: the model has none. With neither `props` nor `events` the contract is
|
|
58
|
+
* inferred; with either, the interface is exactly what is written (an omitted side is none).
|
|
59
|
+
*/
|
|
60
|
+
readonly props?: {
|
|
61
|
+
readonly [K in P]: Schema.Schema<any, any, never>;
|
|
62
|
+
};
|
|
63
|
+
/** The events the template raises, each with its payload's schema; `Schema.Void` is no payload. */
|
|
64
|
+
readonly events?: {
|
|
65
|
+
readonly [K in E]: Schema.Schema<any, any, never>;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Defines a component: its template and an optional contract. Register it once, in `defineApp`'s `components`; the templates that write its tag use it, and nothing about its interface is
|
|
70
|
+
* written again there. Where the toolchain has generated the template's type, a contract that disagrees with the template's keys is a compile-time error.
|
|
71
|
+
*/
|
|
72
|
+
export declare function component<P extends string = string, E extends string = string>(template: Template<P, E>, contract?: Contract<NoInfer<P>, NoInfer<E>>): Component;
|
|
73
|
+
/**
|
|
74
|
+
* What the document's title is made of. `page` is the page's own part (default: its title, from the Markdown front matter), `app` the application's name (default: the site's name), `status` what is
|
|
75
|
+
* going on right now and is not part of any page (an unread count, "Saving…"): it is written first, in parentheses. The project's format in \`valance.json\` (`"title": "{page} · {site}"`) puts them
|
|
76
|
+
* together. A target that is not a web page uses the same three parts for its own surfaces (a window title, a navigation bar, a badge). All text: control and direction-override characters are removed
|
|
77
|
+
* and the length is limited, so a title built from user data cannot reorder or hide what surrounds it.
|
|
78
|
+
*/
|
|
79
|
+
export interface TitleParts {
|
|
80
|
+
readonly page?: string;
|
|
81
|
+
readonly app?: string;
|
|
82
|
+
readonly status?: string;
|
|
83
|
+
}
|
|
84
|
+
export interface AppDefinition<S extends StateInput = {}> {
|
|
85
|
+
/** The page's shell. Omit it to use the built-in layout. Its template must be named `layout.mprx`, and it has no props or events: its inputs are the state and the commands. */
|
|
86
|
+
readonly layout?: Component;
|
|
87
|
+
/** 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. */
|
|
88
|
+
readonly components?: ReadonlyArray<Component>;
|
|
89
|
+
/** Built-in components (`heading`, `runs`, …) you replace with your own template of the same name. */
|
|
90
|
+
readonly override?: ReadonlyArray<Component>;
|
|
91
|
+
/**
|
|
92
|
+
* Application state beyond the page: `{ theme: field(Schema.Literal("light", "dark"), "light") }`. Its fields become names a layout can read. A hand-built `{ schema, initial }` is also accepted, for
|
|
93
|
+
* hydration and shared schemas.
|
|
94
|
+
*/
|
|
95
|
+
readonly state?: S;
|
|
96
|
+
/**
|
|
97
|
+
* The document's title for a state: `(state, page) => { status: unread > 0 ? String(unread) : undefined }`. A string is the page part. Absent: the page's title and the site's name. It runs for
|
|
98
|
+
* every committed state, on the server for the state it renders and in the browser after each one; it should be cheap and must not throw (a throw is logged and the title stays).
|
|
99
|
+
*/
|
|
100
|
+
readonly title?: (state: PageState & StateOf<S>, page: {
|
|
101
|
+
readonly path: string;
|
|
102
|
+
readonly title: string;
|
|
103
|
+
}) => TitleParts | string;
|
|
104
|
+
/**
|
|
105
|
+
* What the application sends to its backend, for every request: `http: { interceptors: [bearer(base, token), refreshOn401(refresh), retryGet(2)] }`, outermost first. Interceptors
|
|
106
|
+
* (`@valancex/common/http`) all have one shape, `(next) => next`, so they chain; a narrower scope is `withInterceptors(effect, ...)` around a command's work, or `interceptors` on one request.
|
|
107
|
+
* Used by the page; the server makes no requests.
|
|
108
|
+
*/
|
|
109
|
+
readonly http?: {
|
|
110
|
+
readonly interceptors?: ReadonlyArray<Interceptor>;
|
|
111
|
+
};
|
|
112
|
+
/** The commands a layout can run. `command` makes one: `command((state) => nextState)` or `command(InputSchema, (input, state) => nextState)`. */
|
|
113
|
+
readonly commands?: (command: Valance.CommandMaker<PageState & StateOf<S>>) => Valance.CommandTable<any, Valance.Ambient>;
|
|
114
|
+
}
|
|
115
|
+
/** The brand that says a value came from `defineApp`. */
|
|
116
|
+
export declare const APP: unique symbol;
|
|
117
|
+
export interface DefinedApp extends AppDefinition<any> {
|
|
118
|
+
readonly [APP]: true;
|
|
119
|
+
}
|
|
120
|
+
/** Declares the application's component tree, state and commands. It starts nothing; the framework runs it. */
|
|
121
|
+
export declare const defineApp: <S extends StateInput = {}>(definition: AppDefinition<S>) => DefinedApp;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// ---- state -------------------------------------------------------------------------------------------------------------------------------------------------
|
|
2
|
+
export const FIELD = Symbol.for("valancex.common.field");
|
|
3
|
+
/**
|
|
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
|
+
* checked against the type (by TypeScript, and again when the application is built).
|
|
6
|
+
*/
|
|
7
|
+
export const field = (schema, initial) => ({ [FIELD]: true, schema: schema, initial });
|
|
8
|
+
export const COMPONENT = Symbol.for("valancex.common.component");
|
|
9
|
+
/**
|
|
10
|
+
* Defines a component: its template and an optional contract. Register it once, in `defineApp`'s `components`; the templates that write its tag use it, and nothing about its interface is
|
|
11
|
+
* written again there. Where the toolchain has generated the template's type, a contract that disagrees with the template's keys is a compile-time error.
|
|
12
|
+
*/
|
|
13
|
+
export function component(template, contract = {}) {
|
|
14
|
+
return {
|
|
15
|
+
[COMPONENT]: true,
|
|
16
|
+
template,
|
|
17
|
+
...(contract.props === undefined ? {} : { props: contract.props }),
|
|
18
|
+
...(contract.events === undefined ? {} : { events: contract.events }),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** The brand that says a value came from `defineApp`. */
|
|
22
|
+
export const APP = Symbol.for("valancex.common");
|
|
23
|
+
/** Declares the application's component tree, state and commands. It starts nothing; the framework runs it. */
|
|
24
|
+
export const defineApp = (definition) => ({ ...definition, [APP]: true });
|
package/dist/internal.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
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
|
+
// entries run them, so there is one definition of each. Not for applications: an application's code is `@valancex/common` (the entry point).
|
|
3
|
+
export * from "./app.js";
|
|
4
|
+
export * from "./model.js";
|
|
5
|
+
export * from "./primitives.js";
|
|
6
|
+
export * from "./routes.js";
|
|
7
|
+
export * from "./scope.js";
|
|
8
|
+
export * from "./state.js";
|
|
9
|
+
export * from "./vocabulary.js";
|
package/dist/model.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** A run of inline text. `href` is empty unless `kind` is `link`. */
|
|
2
|
+
export interface Span {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly kind: "text" | "strong" | "emphasis" | "code" | "link";
|
|
5
|
+
readonly text: string;
|
|
6
|
+
readonly href: string;
|
|
7
|
+
}
|
|
8
|
+
export interface Item {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly spans: ReadonlyArray<Span>;
|
|
11
|
+
}
|
|
12
|
+
/** What a layout draws a page's body from. Kinds: heading, paragraph, list, code, quote, image. */
|
|
13
|
+
export interface Block {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly kind: "heading" | "paragraph" | "list" | "code" | "quote" | "image";
|
|
16
|
+
/** heading: 1 to 3. */
|
|
17
|
+
readonly level: number;
|
|
18
|
+
/** heading: the id on the page. */
|
|
19
|
+
readonly anchor: string;
|
|
20
|
+
readonly spans: ReadonlyArray<Span>;
|
|
21
|
+
/** list: the items. */
|
|
22
|
+
readonly items: ReadonlyArray<Item>;
|
|
23
|
+
readonly ordered: boolean;
|
|
24
|
+
/** code: the language, and the source. */
|
|
25
|
+
readonly lang: string;
|
|
26
|
+
readonly text: string;
|
|
27
|
+
/** image: the address under the site's base, and its description. */
|
|
28
|
+
readonly src: string;
|
|
29
|
+
readonly alt: string;
|
|
30
|
+
}
|
|
31
|
+
export interface Heading {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly label: string;
|
|
34
|
+
readonly anchor: string;
|
|
35
|
+
readonly level: number;
|
|
36
|
+
}
|
|
37
|
+
export interface Page {
|
|
38
|
+
/** Its path in the site: `/` or `/guide/getting-started`. The identity of the page. */
|
|
39
|
+
readonly path: string;
|
|
40
|
+
/** The file it came from, relative to the project. */
|
|
41
|
+
readonly file: string;
|
|
42
|
+
readonly title: string;
|
|
43
|
+
readonly description: string;
|
|
44
|
+
/** Position among its siblings in navigation. */
|
|
45
|
+
readonly order: number;
|
|
46
|
+
/** The navigation group: the page's directory, or empty at the top level. */
|
|
47
|
+
readonly section: string;
|
|
48
|
+
readonly blocks: ReadonlyArray<Block>;
|
|
49
|
+
readonly outline: ReadonlyArray<Heading>;
|
|
50
|
+
}
|
|
51
|
+
export interface SiteInfo {
|
|
52
|
+
readonly name: string;
|
|
53
|
+
/** Every address starts with this: `/` or `/something/`. */
|
|
54
|
+
readonly base: string;
|
|
55
|
+
/** Where the site is published (no trailing slash), or empty. */
|
|
56
|
+
readonly url: string;
|
|
57
|
+
/** How a document's title is written from a page's title and the site's name: `{page}` and `{site}`. */
|
|
58
|
+
readonly title: string;
|
|
59
|
+
/** The address the application's backend requests go to (a path on the site's origin, or an absolute https address), or empty when it has none. */
|
|
60
|
+
readonly api: string;
|
|
61
|
+
/** Tell assistive technology about a page change made in place (the title, and focus at the content). */
|
|
62
|
+
readonly announce: boolean;
|
|
63
|
+
}
|
|
64
|
+
/** The title format when the project does not say: the page, then the site. */
|
|
65
|
+
export declare const DEFAULT_TITLE = "{page} \u00B7 {site}";
|
|
66
|
+
export interface Content {
|
|
67
|
+
readonly site: SiteInfo;
|
|
68
|
+
/** In navigation order. */
|
|
69
|
+
readonly pages: ReadonlyArray<Page>;
|
|
70
|
+
/** `content/404.md` when the author wrote one. */
|
|
71
|
+
readonly notFound?: Page;
|
|
72
|
+
}
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// The content model: what the build produces from `content/` and what the application and its layout consume. Plain JSON-able data. A MESH scope is flat, so a block is one record whose
|
|
2
|
+
// unused fields are empty (`kind` says which are meaningful); the empty values are explicit so a template never reads something missing.
|
|
3
|
+
/** The title format when the project does not say: the page, then the site. */
|
|
4
|
+
export const DEFAULT_TITLE = "{page} · {site}";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The tags a layout or component may use, and the element each becomes. This is the vocabulary of the application model: a template says `<nav>`, `<link>`, `<para>`, and this table says
|
|
2
|
+
// what they are on the web. It is also what the MESH manifest is derived from, so a tag that is not here is a compile error that names it.
|
|
3
|
+
//
|
|
4
|
+
// Styling hooks are attributes: `class` for a part with a role of its own, `theme` (the `data-theme` attribute) for a variant, `label` (`aria-label`) and `current` (`aria-current`) where
|
|
5
|
+
// the state is an accessibility fact too. A stylesheet is written against those and the elements.
|
|
6
|
+
import * as Web from "@valancex/valance/web";
|
|
7
|
+
const klass = Web.attribute("class");
|
|
8
|
+
const id = Web.attribute("id");
|
|
9
|
+
const label = Web.attribute("aria-label");
|
|
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
|
+
// table is many narrow tags rather than a few wide ones: a tag carries only what its uses always have.
|
|
12
|
+
export const primitives = {
|
|
13
|
+
shell: { element: "div", props: { class: klass, theme: Web.attribute("data-theme") } },
|
|
14
|
+
div: { element: "div", props: { class: klass } },
|
|
15
|
+
header: { element: "header", props: { class: klass } },
|
|
16
|
+
footer: { element: "footer", props: { class: klass } },
|
|
17
|
+
main: { element: "main", props: { id, tabindex: Web.attribute("tabindex") } },
|
|
18
|
+
nav: { element: "nav", props: { class: klass, label } },
|
|
19
|
+
aside: { element: "aside", props: { class: klass } },
|
|
20
|
+
section: { element: "section" },
|
|
21
|
+
article: { element: "article" },
|
|
22
|
+
h1: { element: "h1", props: { id } },
|
|
23
|
+
h2: { element: "h2", props: { id } },
|
|
24
|
+
h3: { element: "h3", props: { id } },
|
|
25
|
+
para: { element: "p", props: { class: klass } },
|
|
26
|
+
quote: { element: "blockquote", props: { class: klass } },
|
|
27
|
+
list: { element: "ul", props: { class: klass } },
|
|
28
|
+
olist: { element: "ol", props: { class: klass } },
|
|
29
|
+
item: { element: "li" },
|
|
30
|
+
pre: { element: "pre", props: { class: klass, tabindex: Web.attribute("tabindex") } },
|
|
31
|
+
code: { element: "code" },
|
|
32
|
+
strong: { element: "strong" },
|
|
33
|
+
em: { element: "em" },
|
|
34
|
+
span: { element: "span", props: { class: klass } },
|
|
35
|
+
image: { element: "img", props: { class: klass, src: Web.attribute("src"), alt: Web.attribute("alt"), loading: Web.attribute("loading") } },
|
|
36
|
+
/** A link inside text. */
|
|
37
|
+
a: { element: "a", props: { href: Web.attribute("href") } },
|
|
38
|
+
/** 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
|
+
link: { element: "a", props: { href: Web.attribute("href"), class: klass, current: Web.attribute("aria-current") } },
|
|
40
|
+
button: { element: "button", props: { class: klass, pressed: Web.attribute("aria-pressed") }, events: { click: { type: "click" } } },
|
|
41
|
+
};
|
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RouteTable } from "@valancex/valance/web/plugin";
|
|
2
|
+
import type { Content } from "./model.js";
|
|
3
|
+
export interface PageState {
|
|
4
|
+
readonly page: string;
|
|
5
|
+
}
|
|
6
|
+
/** 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). */
|
|
7
|
+
export declare const normalize: (pathname: string, base: string) => string;
|
|
8
|
+
export declare const urlOf: (content: Content, path: string) => string;
|
|
9
|
+
export declare const routes: (content: Content) => RouteTable<PageState>;
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { announcePage } from "./announce.js";
|
|
2
|
+
import { pageAt } from "./scope.js";
|
|
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). */
|
|
4
|
+
export const normalize = (pathname, base) => {
|
|
5
|
+
const inside = pathname === base.replace(/\/$/, "") ? "/" : pathname.startsWith(base) ? `/${pathname.slice(base.length)}` : pathname;
|
|
6
|
+
const trimmed = inside.replace(/\/+$/, "");
|
|
7
|
+
return trimmed === "" ? "/" : trimmed;
|
|
8
|
+
};
|
|
9
|
+
export const urlOf = (content, path) => `${content.site.base}${path.replace(/^\//, "")}`;
|
|
10
|
+
export const routes = (content) => ({
|
|
11
|
+
urlOf: ({ page }) => urlOf(content, page),
|
|
12
|
+
stateOf: (url) => ({ id: normalize(url.pathname, content.site.base) }),
|
|
13
|
+
navigate: "go",
|
|
14
|
+
known: (pathname) => pageAt(content, normalize(pathname, content.site.base)) !== undefined,
|
|
15
|
+
paths: () => content.pages.map((page) => urlOf(content, page.path)),
|
|
16
|
+
// Static first: every page is rendered once at build time.
|
|
17
|
+
delivery: () => "static",
|
|
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
|
+
};
|
|
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.
|
|
29
|
+
if (kind !== "link") {
|
|
30
|
+
announce();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
// The application's state has no fragment, so the address bar lost it: put it back.
|
|
34
|
+
if (url.hash !== "") {
|
|
35
|
+
window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
|
|
36
|
+
}
|
|
37
|
+
const target = url.hash === "" ? undefined : document.getElementById(decodeURIComponent(url.hash.slice(1)));
|
|
38
|
+
if (target === null || target === undefined) {
|
|
39
|
+
window.scrollTo(0, 0);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
target.scrollIntoView();
|
|
43
|
+
}
|
|
44
|
+
announce();
|
|
45
|
+
},
|
|
46
|
+
});
|
package/dist/scope.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import type { TitleParts, Viewport } from "./index.js";
|
|
3
|
+
import type { Content, Page } from "./model.js";
|
|
4
|
+
export declare const FrameScope: Schema.Struct<{
|
|
5
|
+
/** The application's name (`valance.json`). */
|
|
6
|
+
site: typeof Schema.String;
|
|
7
|
+
/** The address of the first page, under the base. */
|
|
8
|
+
home: typeof Schema.String;
|
|
9
|
+
/** The current page's title, and its description. */
|
|
10
|
+
title: typeof Schema.String;
|
|
11
|
+
description: typeof Schema.String;
|
|
12
|
+
/** False on the page shown for an address that is not a page. */
|
|
13
|
+
found: typeof Schema.Boolean;
|
|
14
|
+
/** Pages grouped by directory. A top-level page is in a group with no title. `current` is `"page"` for the page being shown and `"false"` otherwise (the value of `aria-current`). */
|
|
15
|
+
sections: Schema.Array$<Schema.Struct<{
|
|
16
|
+
id: typeof Schema.String;
|
|
17
|
+
title: typeof Schema.String;
|
|
18
|
+
items: Schema.Array$<Schema.Struct<{
|
|
19
|
+
id: typeof Schema.String;
|
|
20
|
+
href: typeof Schema.String;
|
|
21
|
+
label: typeof Schema.String;
|
|
22
|
+
current: typeof Schema.String;
|
|
23
|
+
}>>;
|
|
24
|
+
}>>;
|
|
25
|
+
/** The `##` and `###` headings of the current page; `hasToc` is false when there are none. */
|
|
26
|
+
hasToc: typeof Schema.Boolean;
|
|
27
|
+
toc: Schema.Array$<Schema.Struct<{
|
|
28
|
+
id: typeof Schema.String;
|
|
29
|
+
href: typeof Schema.String;
|
|
30
|
+
label: typeof Schema.String;
|
|
31
|
+
level: typeof Schema.Number;
|
|
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 page's body. Draw it with `<blocks items={blocks} />`, or draw each block yourself. */
|
|
36
|
+
blocks: Schema.Array$<Schema.Struct<{
|
|
37
|
+
id: typeof Schema.String;
|
|
38
|
+
kind: typeof Schema.String;
|
|
39
|
+
level: typeof Schema.Number;
|
|
40
|
+
anchor: typeof Schema.String;
|
|
41
|
+
spans: Schema.Array$<Schema.Struct<{
|
|
42
|
+
id: typeof Schema.String;
|
|
43
|
+
kind: typeof Schema.String;
|
|
44
|
+
text: typeof Schema.String;
|
|
45
|
+
href: typeof Schema.String;
|
|
46
|
+
}>>;
|
|
47
|
+
items: Schema.Array$<Schema.Struct<{
|
|
48
|
+
id: typeof Schema.String;
|
|
49
|
+
spans: Schema.Array$<Schema.Struct<{
|
|
50
|
+
id: typeof Schema.String;
|
|
51
|
+
kind: typeof Schema.String;
|
|
52
|
+
text: typeof Schema.String;
|
|
53
|
+
href: typeof Schema.String;
|
|
54
|
+
}>>;
|
|
55
|
+
}>>;
|
|
56
|
+
ordered: typeof Schema.Boolean;
|
|
57
|
+
lang: typeof Schema.String;
|
|
58
|
+
text: typeof Schema.String;
|
|
59
|
+
src: typeof Schema.String;
|
|
60
|
+
alt: typeof Schema.String;
|
|
61
|
+
}>>;
|
|
62
|
+
/** Neighbours in navigation order; `href` is empty when there is none. */
|
|
63
|
+
prev: Schema.Struct<{
|
|
64
|
+
href: typeof Schema.String;
|
|
65
|
+
label: typeof Schema.String;
|
|
66
|
+
}>;
|
|
67
|
+
next: Schema.Struct<{
|
|
68
|
+
href: typeof Schema.String;
|
|
69
|
+
label: typeof Schema.String;
|
|
70
|
+
}>;
|
|
71
|
+
}>;
|
|
72
|
+
/** The names a layout may read from the framework; an application's own state may not reuse them. */
|
|
73
|
+
export declare const RESERVED: ReadonlyArray<string>;
|
|
74
|
+
export declare const sectionLabel: (dir: string) => string;
|
|
75
|
+
/** The page shown for an address that is not a page: `content/404.md` if the author wrote one. */
|
|
76
|
+
export declare const notFoundPage: (content: Content) => Page;
|
|
77
|
+
export declare const pageAt: (content: Content, path: string) => Page | undefined;
|
|
78
|
+
/**
|
|
79
|
+
* The text of the document's `<title>` for a page: the project's format (`{page} · {site}`) with the parts filled in, and the status first, in parentheses. A page whose title is the site's name
|
|
80
|
+
* (the home page, usually) is that name alone. The author's `title` function may replace the parts; the caller cleans the result (control and direction-override characters out, length limited).
|
|
81
|
+
*/
|
|
82
|
+
export declare const documentTitle: (content: Content, page: Page | undefined, parts?: TitleParts) => string;
|
|
83
|
+
export declare const frame: (content: Content, path: string, viewport?: Viewport) => typeof FrameScope.Type;
|
package/dist/scope.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// What a layout reads. A MESH scope is flat and typed, so this is the one place that says what exists; `frame` fills it from the content and the current page. The author's own state fields
|
|
2
|
+
// are added to it by name (see ./app.ts), so a layout reads `theme` if the application's state has a `theme`.
|
|
3
|
+
import { Schema } from "effect";
|
|
4
|
+
const SpanView = Schema.Struct({ id: Schema.String, kind: Schema.String, text: Schema.String, href: Schema.String });
|
|
5
|
+
const ItemView = Schema.Struct({ id: Schema.String, spans: Schema.Array(SpanView) });
|
|
6
|
+
const BlockView = Schema.Struct({
|
|
7
|
+
id: Schema.String,
|
|
8
|
+
kind: Schema.String,
|
|
9
|
+
level: Schema.Number,
|
|
10
|
+
anchor: Schema.String,
|
|
11
|
+
spans: Schema.Array(SpanView),
|
|
12
|
+
items: Schema.Array(ItemView),
|
|
13
|
+
ordered: Schema.Boolean,
|
|
14
|
+
lang: Schema.String,
|
|
15
|
+
text: Schema.String,
|
|
16
|
+
src: Schema.String,
|
|
17
|
+
alt: Schema.String,
|
|
18
|
+
});
|
|
19
|
+
const Ref = Schema.Struct({ href: Schema.String, label: Schema.String });
|
|
20
|
+
export const FrameScope = Schema.Struct({
|
|
21
|
+
/** The application's name (`valance.json`). */
|
|
22
|
+
site: Schema.String,
|
|
23
|
+
/** The address of the first page, under the base. */
|
|
24
|
+
home: Schema.String,
|
|
25
|
+
/** The current page's title, and its description. */
|
|
26
|
+
title: Schema.String,
|
|
27
|
+
description: Schema.String,
|
|
28
|
+
/** False on the page shown for an address that is not a page. */
|
|
29
|
+
found: Schema.Boolean,
|
|
30
|
+
/** Pages grouped by directory. A top-level page is in a group with no title. `current` is `"page"` for the page being shown and `"false"` otherwise (the value of `aria-current`). */
|
|
31
|
+
sections: Schema.Array(Schema.Struct({
|
|
32
|
+
id: Schema.String,
|
|
33
|
+
title: Schema.String,
|
|
34
|
+
items: Schema.Array(Schema.Struct({ id: Schema.String, href: Schema.String, label: Schema.String, current: Schema.String })),
|
|
35
|
+
})),
|
|
36
|
+
/** The `##` and `###` headings of the current page; `hasToc` is false when there are none. */
|
|
37
|
+
hasToc: Schema.Boolean,
|
|
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 page's body. Draw it with `<blocks items={blocks} />`, or draw each block yourself. */
|
|
42
|
+
blocks: Schema.Array(BlockView),
|
|
43
|
+
/** Neighbours in navigation order; `href` is empty when there is none. */
|
|
44
|
+
prev: Ref,
|
|
45
|
+
next: Ref,
|
|
46
|
+
});
|
|
47
|
+
/** The names a layout may read from the framework; an application's own state may not reuse them. */
|
|
48
|
+
export const RESERVED = Object.keys(FrameScope.fields);
|
|
49
|
+
const NONE = { href: "", label: "" };
|
|
50
|
+
const view = (blocks) => blocks;
|
|
51
|
+
const withBase = (base, path) => `${base}${path.replace(/^\//, "")}`;
|
|
52
|
+
export const sectionLabel = (dir) => dir.replace(/[-_]+/g, " ").replace(/^./, (first) => first.toUpperCase());
|
|
53
|
+
/** The page shown for an address that is not a page: `content/404.md` if the author wrote one. */
|
|
54
|
+
export const notFoundPage = (content) => content.notFound ?? {
|
|
55
|
+
path: "/404",
|
|
56
|
+
file: "",
|
|
57
|
+
title: "Page not found",
|
|
58
|
+
description: "",
|
|
59
|
+
order: 0,
|
|
60
|
+
section: "",
|
|
61
|
+
outline: [],
|
|
62
|
+
blocks: [
|
|
63
|
+
{
|
|
64
|
+
id: "b0",
|
|
65
|
+
kind: "heading",
|
|
66
|
+
level: 1,
|
|
67
|
+
anchor: "page-not-found",
|
|
68
|
+
spans: [{ id: "b0.0", kind: "text", text: "Page not found", href: "" }],
|
|
69
|
+
items: [],
|
|
70
|
+
ordered: false,
|
|
71
|
+
lang: "",
|
|
72
|
+
text: "",
|
|
73
|
+
src: "",
|
|
74
|
+
alt: "",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "b1",
|
|
78
|
+
kind: "paragraph",
|
|
79
|
+
level: 0,
|
|
80
|
+
anchor: "",
|
|
81
|
+
spans: [
|
|
82
|
+
{ id: "b1.0", kind: "text", text: "There is no page at this address.", href: "" },
|
|
83
|
+
{ id: "b1.1", kind: "text", text: " ", href: "" },
|
|
84
|
+
{ id: "b1.2", kind: "link", text: "Go to the start.", href: withBase(content.site.base, "/") },
|
|
85
|
+
],
|
|
86
|
+
items: [],
|
|
87
|
+
ordered: false,
|
|
88
|
+
lang: "",
|
|
89
|
+
text: "",
|
|
90
|
+
src: "",
|
|
91
|
+
alt: "",
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
};
|
|
95
|
+
export const pageAt = (content, path) => content.pages.find((page) => page.path === path);
|
|
96
|
+
/**
|
|
97
|
+
* The text of the document's `<title>` for a page: the project's format (`{page} · {site}`) with the parts filled in, and the status first, in parentheses. A page whose title is the site's name
|
|
98
|
+
* (the home page, usually) is that name alone. The author's `title` function may replace the parts; the caller cleans the result (control and direction-override characters out, length limited).
|
|
99
|
+
*/
|
|
100
|
+
export const documentTitle = (content, page, parts = {}) => {
|
|
101
|
+
const shown = parts.page ?? (page === undefined ? notFoundPage(content).title : page.title);
|
|
102
|
+
const app = parts.app ?? content.site.name;
|
|
103
|
+
const body = shown === app ? shown : content.site.title.replaceAll("{page}", shown).replaceAll("{site}", app);
|
|
104
|
+
return parts.status === undefined || parts.status === "" ? body : `(${parts.status}) ${body}`;
|
|
105
|
+
};
|
|
106
|
+
export const frame = (content, path, viewport = "expanded") => {
|
|
107
|
+
const page = pageAt(content, path);
|
|
108
|
+
const shown = page ?? notFoundPage(content);
|
|
109
|
+
const at = page === undefined ? -1 : content.pages.indexOf(page);
|
|
110
|
+
const ref = (to) => (to === undefined ? NONE : { href: withBase(content.site.base, to.path), label: to.title });
|
|
111
|
+
const groups = [];
|
|
112
|
+
for (const each of content.pages) {
|
|
113
|
+
let group = groups.find((candidate) => candidate.id === each.section);
|
|
114
|
+
if (group === undefined) {
|
|
115
|
+
group = { id: each.section, title: each.section === "" ? "" : sectionLabel(each.section), items: [] };
|
|
116
|
+
groups.push(group);
|
|
117
|
+
}
|
|
118
|
+
group.items.push({ id: each.path, href: withBase(content.site.base, each.path), label: each.title, current: each.path === path ? "page" : "false" });
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
site: content.site.name,
|
|
122
|
+
viewport,
|
|
123
|
+
home: withBase(content.site.base, "/"),
|
|
124
|
+
title: shown.title,
|
|
125
|
+
description: shown.description,
|
|
126
|
+
found: page !== undefined,
|
|
127
|
+
sections: groups,
|
|
128
|
+
hasToc: shown.outline.length > 0,
|
|
129
|
+
toc: shown.outline.map((heading) => ({ id: heading.id, href: `#${heading.anchor}`, label: heading.label, level: heading.level })),
|
|
130
|
+
blocks: view(shown.blocks),
|
|
131
|
+
prev: ref(content.pages[at - 1]),
|
|
132
|
+
next: ref(at < 0 ? undefined : content.pages[at + 1]),
|
|
133
|
+
};
|
|
134
|
+
};
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type * as Valance from "@valancex/valance";
|
|
2
|
+
import type { Built } from "./app.js";
|
|
3
|
+
import type { DefinedApp } from "./index.js";
|
|
4
|
+
import type { Content } from "./model.js";
|
|
5
|
+
import { routes } from "./routes.js";
|
|
6
|
+
export type { Built };
|
|
7
|
+
export { routes };
|
|
8
|
+
export interface ServerParts {
|
|
9
|
+
readonly content: Content;
|
|
10
|
+
readonly program: Valance.Program;
|
|
11
|
+
readonly author?: DefinedApp | undefined;
|
|
12
|
+
}
|
|
13
|
+
export interface Rendered {
|
|
14
|
+
/** 200 for a page, 404 for an address that is not one (rendered as the not-found page). */
|
|
15
|
+
readonly status: number;
|
|
16
|
+
readonly html: string;
|
|
17
|
+
}
|
|
18
|
+
export declare const createRenderer: ({ content, program, author }: ServerParts) => (pathname: string, built: Built) => Promise<Rendered>;
|
|
19
|
+
/** The pathnames a static export writes, under the base. */
|
|
20
|
+
export declare const pathsOf: (content: Content) => ReadonlyArray<string>;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { renderHead } from "@valancex/valance/web/plugin";
|
|
2
|
+
import { renderToHtml } from "@valancex/valance/web/server";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
import { application, plugin, stateFor } from "./app.js";
|
|
5
|
+
import { routes } from "./routes.js";
|
|
6
|
+
export { routes };
|
|
7
|
+
const safe = (json) => json.replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
|
|
8
|
+
export const createRenderer = ({ content, program, author }) => {
|
|
9
|
+
const app = application(content, program, author);
|
|
10
|
+
const table = routes(content);
|
|
11
|
+
return async (pathname, built) => {
|
|
12
|
+
const state = stateFor(content, pathname, author);
|
|
13
|
+
const served = await Effect.runPromise(renderToHtml(app, { plugins: [plugin(content, built, author)], state }));
|
|
14
|
+
const boot = safe(JSON.stringify({ state: served.state, built }));
|
|
15
|
+
return {
|
|
16
|
+
status: table.known(pathname) ? 200 : 404,
|
|
17
|
+
html: `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${renderHead(served.head)}</head><body><div id="app">${served.html}</div><script id="valance-boot" type="application/json">${boot}</script><script type="module" src="${built.script}"></script></body></html>`,
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
/** The pathnames a static export writes, under the base. */
|
|
22
|
+
export const pathsOf = (content) => routes(content).paths();
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import type { DefinedApp } from "./index.js";
|
|
3
|
+
export interface State {
|
|
4
|
+
/** The struct of the state's fields. */
|
|
5
|
+
readonly schema: Schema.Struct<Schema.Struct.Fields>;
|
|
6
|
+
readonly initial: Readonly<Record<string, unknown>>;
|
|
7
|
+
/** Whether it was declared field by field (so each default can be checked against its own type). */
|
|
8
|
+
readonly declared: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** The state a definition declares, or `undefined` for none. */
|
|
11
|
+
export declare const stateOf: (author: DefinedApp | undefined) => State | undefined;
|
|
12
|
+
/** The fields whose default is not a value of the field's own type (only for a state declared field by field). */
|
|
13
|
+
export declare const badDefaults: (author: DefinedApp | undefined) => ReadonlyArray<string>;
|