@valancex/common 0.0.0-stage → 0.11.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 CHANGED
@@ -1,3 +1,44 @@
1
- # Temporary Holding Version
1
+ # @valancex/common
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ What an application's `src/main.ts` imports (`defineApp`, `component`, `field`), and the runtime a built application runs. It describes the application; the [`vlx` command](../cli) builds, serves and checks what it describes.
4
+
5
+ ```console
6
+ $ vlx init my-app --web # a project that already depends on this package
7
+ ```
8
+
9
+ ```ts
10
+ // src/main.ts
11
+ import { component, defineApp, field } from "@valancex/common";
12
+ import { Schema } from "effect";
13
+ import layout from "./layout.mprx";
14
+ import themeToggle from "./theme-toggle.js";
15
+
16
+ export default defineApp({
17
+ layout: component(layout),
18
+ components: [themeToggle],
19
+ state: { theme: field(Schema.Literal("light", "dark"), "light") },
20
+ commands: (command) => ({
21
+ toggleTheme: command((state) => ({ ...state, theme: state.theme === "light" ? "dark" : "light" })),
22
+ }),
23
+ });
24
+ ```
25
+
26
+ | Export | Is |
27
+ |---|---|
28
+ | `defineApp({ layout?, components?, override?, state?, commands? })` | The application: its shell, its components, its state and the commands that change it. It starts nothing. |
29
+ | `component(template, { props?, events? })` | A component: a `.mprx` template and, optionally, the interface it states (Effect Schemas). Which components its template uses is read from the template. |
30
+ | `field(schema, initial)` | One field of the state: its type and its default, together. |
31
+
32
+ `Schema` is [Effect's](https://effect.website/docs/schema/introduction/); import it from `effect`. This package types against it and does not re-export it.
33
+
34
+ | Entry | Is |
35
+ |---|---|
36
+ | `@valancex/common` | The three exports above. No Node, bundler or terminal import: `src/main.ts` is loaded in the browser as well as at build time. |
37
+ | `@valancex/common/client`, `/server` | The browser and server halves of a built application: `start` takes over the server's HTML, `createRenderer` turns a path into a document. The files `vlx` generates import these; you do not. |
38
+ | `@valancex/common/internal` | What the build tools share with the runtime (content model, tag table, the scope a layout reads). For `@valancex/cli`, not for applications. |
39
+
40
+ A built or deployed application depends on this package and `effect`; `@valancex/cli` is only needed to develop it. This package depends on `@valancex/valance`, `@valancex/mesh-runtime` and `@valancex/port-web`.
41
+
42
+ Documentation: [`@valancex/cli`](../cli/README.md) (the project layout, components, state and commands).
43
+
44
+ Pre-1.0: the API may change between minor releases; every change is in the [release notes](../../docs/releases/).
package/dist/app.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ import * as Valance from "@valancex/valance";
2
+ import type { Plugin } from "@valancex/valance/web/plugin";
3
+ import { Schema } from "effect";
4
+ import type { DefinedApp, PageState } from "./index.js";
5
+ import type { Content } from "./model.js";
6
+ export type AppState = PageState & Record<string, unknown>;
7
+ /** What the browser needs to know about the built page to keep the document's head in step. */
8
+ export interface Built {
9
+ /** The address of the page script. */
10
+ readonly script: string;
11
+ /** Stylesheet addresses, in order. Empty in development, where the script brings its styles. */
12
+ readonly styles: ReadonlyArray<string>;
13
+ }
14
+ /** A mistake in `src/main.ts`, reported with what to change. */
15
+ export declare class AppDefinitionError extends Error {
16
+ constructor(message: string);
17
+ }
18
+ /** Checks the author's definition against the names the framework owns. */
19
+ export declare const checkDefinition: (author: DefinedApp | undefined) => void;
20
+ /** The names `src/main.ts` adds: state fields (readable in the layout) and commands (runnable from it). */
21
+ export declare const ownNames: (author: DefinedApp | undefined) => {
22
+ readonly state: ReadonlyArray<string>;
23
+ readonly commands: ReadonlyArray<string>;
24
+ };
25
+ /** The scope schema a layout is compiled against: the framework's names and the author's state fields. */
26
+ export declare const scopeSchema: (author: DefinedApp | undefined) => Schema.Schema<any, any, never>;
27
+ export declare const stateSchema: (author: DefinedApp | undefined) => Schema.Schema<any, any, never>;
28
+ /** The command table: the framework's `go`, then the author's. */
29
+ export declare const commandTable: (author: DefinedApp | undefined) => (state: Parameters<typeof Valance.command>[0]) => Valance.CommandTable<any, Valance.Ambient>;
30
+ export declare const initialState: (author: DefinedApp | undefined) => AppState;
31
+ export declare const application: (content: Content, program: Valance.Program, author?: DefinedApp) => Valance.ApplicationDefinition<AppState, any, Valance.Ambient, "layout">;
32
+ /** The state a request for `pathname` starts from. */
33
+ export declare const stateFor: (content: Content, pathname: string, author?: DefinedApp) => AppState;
34
+ /** The plugin that makes the application a website: its tags, its URL policy, and its document head. */
35
+ export declare const plugin: (content: Content, built: Built) => Plugin<AppState>;
package/dist/app.js ADDED
@@ -0,0 +1,96 @@
1
+ // The application the framework assembles from the three things an author supplies: the content (built from `content/`), the program (built from the templates), and optionally
2
+ // `defineApp`'s state and commands. This is one Valance application (`Valance.define`): the page is its state, going to a page is its `go` command, and the layout is its one view.
3
+ // The server renders it and the browser runs it, from the same function.
4
+ import * as Valance from "@valancex/valance";
5
+ import { Schema } from "effect";
6
+ import { documentTitle, frame, FrameScope, pageAt, RESERVED } from "./scope.js";
7
+ import { primitives } from "./primitives.js";
8
+ import { normalize, routes } from "./routes.js";
9
+ import { badDefaults, stateOf } from "./state.js";
10
+ const PageSchema = Schema.Struct({ page: Schema.String });
11
+ /** A mistake in `src/main.ts`, reported with what to change. */
12
+ export class AppDefinitionError extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = "AppDefinitionError";
16
+ }
17
+ }
18
+ const authorFields = (author) => Object.keys(stateOf(author)?.schema.fields ?? {});
19
+ /** Checks the author's definition against the names the framework owns. */
20
+ export const checkDefinition = (author) => {
21
+ for (const name of authorFields(author)) {
22
+ if (RESERVED.includes(name)) {
23
+ throw new AppDefinitionError(`src/main.ts: the state field "${name}" is a name the layout already reads from the framework (${RESERVED.join(", ")}); choose another`);
24
+ }
25
+ if (name === "page") {
26
+ throw new AppDefinitionError('src/main.ts: the state field "page" is the framework\'s (which page is shown); choose another');
27
+ }
28
+ }
29
+ for (const name of badDefaults(author)) {
30
+ throw new AppDefinitionError(`src/main.ts: the default of the state field "${name}" is not a value of its type`);
31
+ }
32
+ // Building the table reads no state (a command reads it when it runs), so it can be built here, with nothing, to see its names.
33
+ if ("go" in (author?.commands?.(Valance.command({})) ?? {})) {
34
+ throw new AppDefinitionError('src/main.ts: "go" is the framework\'s command (it shows a page); choose another name');
35
+ }
36
+ };
37
+ /** The names `src/main.ts` adds: state fields (readable in the layout) and commands (runnable from it). */
38
+ export const ownNames = (author) => ({
39
+ state: authorFields(author),
40
+ commands: Object.keys(author?.commands?.(Valance.command({})) ?? {}),
41
+ });
42
+ /** The scope schema a layout is compiled against: the framework's names and the author's state fields. */
43
+ export const scopeSchema = (author) => {
44
+ const state = stateOf(author);
45
+ return state === undefined ? FrameScope : Schema.extend(FrameScope, state.schema);
46
+ };
47
+ export const stateSchema = (author) => {
48
+ const state = stateOf(author);
49
+ return state === undefined ? PageSchema : Schema.extend(PageSchema, state.schema);
50
+ };
51
+ /** The command table: the framework's `go`, then the author's. */
52
+ export const commandTable = (author) => (state) => {
53
+ const command = Valance.command(state);
54
+ const own = author?.commands?.(command) ?? {};
55
+ if ("go" in own) {
56
+ throw new AppDefinitionError('src/main.ts: "go" is the framework\'s command (it shows a page); choose another name');
57
+ }
58
+ return { go: command(Schema.Struct({ id: Schema.String }), ({ id }, current) => ({ ...current, page: id })), ...own };
59
+ };
60
+ export const initialState = (author) => ({ page: "/", ...stateOf(author)?.initial });
61
+ export const application = (content, program, author) => {
62
+ checkDefinition(author);
63
+ return Valance.define({
64
+ name: content.site.name,
65
+ state: { schema: stateSchema(author), initial: initialState(author) },
66
+ views: {
67
+ layout: {
68
+ program,
69
+ scope: (state) => {
70
+ const { page, ...own } = state;
71
+ return { ...frame(content, page), ...own };
72
+ },
73
+ },
74
+ },
75
+ view: () => "layout",
76
+ commands: commandTable(author),
77
+ });
78
+ };
79
+ /** The state a request for `pathname` starts from. */
80
+ export const stateFor = (content, pathname, author) => ({ ...initialState(author), page: normalize(pathname, content.site.base) });
81
+ /** The plugin that makes the application a website: its tags, its URL policy, and its document head. */
82
+ export const plugin = (content, built) => ({
83
+ name: "valance-app",
84
+ primitives,
85
+ routes: routes(content),
86
+ head: (state) => {
87
+ const page = pageAt(content, state.page);
88
+ const shown = page ?? { description: "" };
89
+ const canonical = content.site.url === "" || page === undefined ? [] : [{ rel: "canonical", href: `${content.site.url}${content.site.base}${page.path.replace(/^\//, "")}` }];
90
+ return {
91
+ title: documentTitle(content, page),
92
+ meta: shown.description === "" ? [] : [{ name: "description", content: shown.description }],
93
+ links: [...built.styles.map((href) => ({ rel: "stylesheet", href })), ...canonical],
94
+ };
95
+ },
96
+ });
@@ -0,0 +1,9 @@
1
+ import type * as Valance from "@valancex/valance";
2
+ import type { DefinedApp } from "./index.js";
3
+ import type { Content } from "./model.js";
4
+ export interface ClientParts {
5
+ readonly content: Content;
6
+ readonly program: Valance.Program;
7
+ readonly author?: DefinedApp | undefined;
8
+ }
9
+ export declare const start: ({ content, program, author }: ClientParts) => Promise<void>;
package/dist/client.js ADDED
@@ -0,0 +1,29 @@
1
+ // The browser half, for the generated entry (`.valance/client.ts`): start the MESH runtime and run the application, taking over the HTML the server wrote. An author never imports this.
2
+ import { init } from "@valancex/mesh-runtime";
3
+ import wasmUrl from "@valancex/mesh-runtime/mesh-runtime.wasm?url";
4
+ import * as Web from "@valancex/valance/web";
5
+ import { application, plugin } from "./app.js";
6
+ export const start = async ({ content, program, author }) => {
7
+ const container = document.getElementById("app");
8
+ try {
9
+ const boot = JSON.parse(document.getElementById("valance-boot").textContent);
10
+ await init(wasmUrl);
11
+ const host = await Web.run(application(content, program, author), {
12
+ container,
13
+ present: "hydrate",
14
+ state: boot.state,
15
+ plugins: [plugin(content, boot.built)],
16
+ shutdown: { grace: "250 millis" },
17
+ });
18
+ container.dataset["valance"] = "running"; // readiness marker for whoever drives the page
19
+ addEventListener("pagehide", (event) => {
20
+ if (!event.persisted) {
21
+ void host.stop();
22
+ }
23
+ });
24
+ }
25
+ catch (error) {
26
+ container.dataset["valance"] = "failed";
27
+ console.error("the application did not start", error); // the server's HTML stays as drawn
28
+ }
29
+ };
@@ -0,0 +1,89 @@
1
+ /// <reference path="../types/mprx.d.ts" preserve="true" />
2
+ import type * as Valance from "@valancex/valance";
3
+ import { Schema } from "effect";
4
+ /** The part of the application's state the framework owns: which page is shown. A command's `state` always has it. */
5
+ export interface PageState {
6
+ readonly page: string;
7
+ }
8
+ export declare const FIELD: unique symbol;
9
+ /** A state field: its type and its default, declared together. */
10
+ export interface Field<A = unknown> {
11
+ readonly [FIELD]: true;
12
+ readonly schema: Schema.Schema<A, any, never>;
13
+ readonly initial: A;
14
+ }
15
+ /**
16
+ * 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
17
+ * checked against the type (by TypeScript, and again when the application is built).
18
+ */
19
+ export declare const field: <A, I = A>(schema: Schema.Schema<A, I, never>, initial: NoInfer<A>) => Field<A>;
20
+ /** Fields declared one by one. */
21
+ export type Fields = Readonly<Record<string, Field<any>>>;
22
+ /** A schema built by hand with a separate initial value: for hydration and for schemas shared with other code. */
23
+ export interface HandBuilt<F extends Schema.Struct.Fields = Schema.Struct.Fields> {
24
+ readonly schema: Schema.Struct<F>;
25
+ readonly initial: Schema.Struct.Type<F>;
26
+ }
27
+ export type StateInput = Fields | HandBuilt<any>;
28
+ /** The type of the state a definition declares, beyond the page. */
29
+ export type StateOf<S> = S extends HandBuilt<infer F> ? Schema.Struct.Type<F> : S extends Fields ? {
30
+ readonly [K in keyof S]: S[K] extends Field<infer A> ? A : never;
31
+ } : {};
32
+ /**
33
+ * 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
34
+ * project's `tsconfig.json`); without that, templates are untyped (`string`).
35
+ */
36
+ export interface Template<P extends string = string, E extends string = string> {
37
+ readonly mprx: string;
38
+ readonly "~props"?: P;
39
+ readonly "~events"?: E;
40
+ }
41
+ export declare const COMPONENT: unique symbol;
42
+ /** 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. */
43
+ export interface Component {
44
+ readonly [COMPONENT]: true;
45
+ readonly template: Template;
46
+ /** 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. */
47
+ readonly props?: Readonly<Record<string, Schema.Schema<any, any, never>>>;
48
+ readonly events?: Readonly<Record<string, Schema.Schema<any, any, never>>>;
49
+ }
50
+ export interface Contract<P extends string, E extends string> {
51
+ /**
52
+ * 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
53
+ * inferred; with either, the interface is exactly what is written (an omitted side is none).
54
+ */
55
+ readonly props?: {
56
+ readonly [K in P]: Schema.Schema<any, any, never>;
57
+ };
58
+ /** The events the template raises, each with its payload's schema; `Schema.Void` is no payload. */
59
+ readonly events?: {
60
+ readonly [K in E]: Schema.Schema<any, any, never>;
61
+ };
62
+ }
63
+ /**
64
+ * 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
65
+ * 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.
66
+ */
67
+ export declare function component<P extends string = string, E extends string = string>(template: Template<P, E>, contract?: Contract<NoInfer<P>, NoInfer<E>>): Component;
68
+ export interface AppDefinition<S extends StateInput = {}> {
69
+ /** 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. */
70
+ readonly layout?: Component;
71
+ /** 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. */
72
+ readonly components?: ReadonlyArray<Component>;
73
+ /** Built-in components (`heading`, `runs`, …) you replace with your own template of the same name. */
74
+ readonly override?: ReadonlyArray<Component>;
75
+ /**
76
+ * 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
77
+ * hydration and shared schemas.
78
+ */
79
+ readonly state?: S;
80
+ /** The commands a layout can run. `command` makes one: `command((state) => nextState)` or `command(InputSchema, (input, state) => nextState)`. */
81
+ readonly commands?: (command: Valance.CommandMaker<PageState & StateOf<S>>) => Valance.CommandTable<any, Valance.Ambient>;
82
+ }
83
+ /** The brand that says a value came from `defineApp`. */
84
+ export declare const APP: unique symbol;
85
+ export interface DefinedApp extends AppDefinition<any> {
86
+ readonly [APP]: true;
87
+ }
88
+ /** Declares the application's component tree, state and commands. It starts nothing; the framework runs it. */
89
+ 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 });
@@ -0,0 +1,6 @@
1
+ export * from "./app.js";
2
+ export * from "./model.js";
3
+ export * from "./primitives.js";
4
+ export * from "./routes.js";
5
+ export * from "./scope.js";
6
+ export * from "./state.js";
@@ -0,0 +1,8 @@
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";
@@ -0,0 +1,64 @@
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
+ }
58
+ export interface Content {
59
+ readonly site: SiteInfo;
60
+ /** In navigation order. */
61
+ readonly pages: ReadonlyArray<Page>;
62
+ /** `content/404.md` when the author wrote one. */
63
+ readonly notFound?: Page;
64
+ }
package/dist/model.js ADDED
@@ -0,0 +1,3 @@
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
+ export {};
@@ -0,0 +1,2 @@
1
+ import * as Web from "@valancex/valance/web";
2
+ export declare const primitives: Web.WebPrimitives;
@@ -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
+ };
@@ -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,34 @@
1
+ import { pageAt } from "./scope.js";
2
+ /** 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
+ export const normalize = (pathname, base) => {
4
+ const inside = pathname === base.replace(/\/$/, "") ? "/" : pathname.startsWith(base) ? `/${pathname.slice(base.length)}` : pathname;
5
+ const trimmed = inside.replace(/\/+$/, "");
6
+ return trimmed === "" ? "/" : trimmed;
7
+ };
8
+ export const urlOf = (content, path) => `${content.site.base}${path.replace(/^\//, "")}`;
9
+ export const routes = (content) => ({
10
+ urlOf: ({ page }) => urlOf(content, page),
11
+ stateOf: (url) => ({ id: normalize(url.pathname, content.site.base) }),
12
+ navigate: "go",
13
+ known: (pathname) => pageAt(content, normalize(pathname, content.site.base)) !== undefined,
14
+ paths: () => content.pages.map((page) => urlOf(content, page.path)),
15
+ // Static first: every page is rendered once at build time.
16
+ delivery: () => "static",
17
+ onNavigated: ({ kind, url }) => {
18
+ // 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" || typeof window === "undefined") {
20
+ return;
21
+ }
22
+ // The application's state has no fragment, so the address bar lost it: put it back.
23
+ if (url.hash !== "") {
24
+ window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
25
+ }
26
+ const target = url.hash === "" ? undefined : document.getElementById(decodeURIComponent(url.hash.slice(1)));
27
+ if (target === null || target === undefined) {
28
+ window.scrollTo(0, 0);
29
+ }
30
+ else {
31
+ target.scrollIntoView();
32
+ }
33
+ },
34
+ });
@@ -0,0 +1,77 @@
1
+ import { Schema } from "effect";
2
+ import type { Content, Page } from "./model.js";
3
+ export declare const FrameScope: Schema.Struct<{
4
+ /** The application's name (`valance.json`). */
5
+ site: typeof Schema.String;
6
+ /** The address of the first page, under the base. */
7
+ home: typeof Schema.String;
8
+ /** The current page's title, and its description. */
9
+ title: typeof Schema.String;
10
+ description: typeof Schema.String;
11
+ /** False on the page shown for an address that is not a page. */
12
+ found: typeof Schema.Boolean;
13
+ /** 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`). */
14
+ sections: Schema.Array$<Schema.Struct<{
15
+ id: typeof Schema.String;
16
+ title: typeof Schema.String;
17
+ items: Schema.Array$<Schema.Struct<{
18
+ id: typeof Schema.String;
19
+ href: typeof Schema.String;
20
+ label: typeof Schema.String;
21
+ current: typeof Schema.String;
22
+ }>>;
23
+ }>>;
24
+ /** The `##` and `###` headings of the current page; `hasToc` is false when there are none. */
25
+ hasToc: typeof Schema.Boolean;
26
+ toc: Schema.Array$<Schema.Struct<{
27
+ id: typeof Schema.String;
28
+ href: typeof Schema.String;
29
+ label: typeof Schema.String;
30
+ level: typeof Schema.Number;
31
+ }>>;
32
+ /** The page's body. Draw it with `<blocks items={blocks} />`, or draw each block yourself. */
33
+ blocks: Schema.Array$<Schema.Struct<{
34
+ id: typeof Schema.String;
35
+ kind: typeof Schema.String;
36
+ level: typeof Schema.Number;
37
+ anchor: typeof Schema.String;
38
+ spans: Schema.Array$<Schema.Struct<{
39
+ id: typeof Schema.String;
40
+ kind: typeof Schema.String;
41
+ text: typeof Schema.String;
42
+ href: typeof Schema.String;
43
+ }>>;
44
+ items: Schema.Array$<Schema.Struct<{
45
+ id: typeof Schema.String;
46
+ spans: Schema.Array$<Schema.Struct<{
47
+ id: typeof Schema.String;
48
+ kind: typeof Schema.String;
49
+ text: typeof Schema.String;
50
+ href: typeof Schema.String;
51
+ }>>;
52
+ }>>;
53
+ ordered: typeof Schema.Boolean;
54
+ lang: typeof Schema.String;
55
+ text: typeof Schema.String;
56
+ src: typeof Schema.String;
57
+ alt: typeof Schema.String;
58
+ }>>;
59
+ /** Neighbours in navigation order; `href` is empty when there is none. */
60
+ prev: Schema.Struct<{
61
+ href: typeof Schema.String;
62
+ label: typeof Schema.String;
63
+ }>;
64
+ next: Schema.Struct<{
65
+ href: typeof Schema.String;
66
+ label: typeof Schema.String;
67
+ }>;
68
+ }>;
69
+ /** The names a layout may read from the framework; an application's own state may not reuse them. */
70
+ export declare const RESERVED: ReadonlyArray<string>;
71
+ export declare const sectionLabel: (dir: string) => string;
72
+ /** The page shown for an address that is not a page: `content/404.md` if the author wrote one. */
73
+ export declare const notFoundPage: (content: Content) => Page;
74
+ export declare const pageAt: (content: Content, path: string) => Page | undefined;
75
+ /** The text of the document's `<title>` for a page. */
76
+ export declare const documentTitle: (content: Content, page: Page | undefined) => string;
77
+ export declare const frame: (content: Content, path: string) => typeof FrameScope.Type;
package/dist/scope.js ADDED
@@ -0,0 +1,129 @@
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
+ /** The page's body. Draw it with `<blocks items={blocks} />`, or draw each block yourself. */
40
+ blocks: Schema.Array(BlockView),
41
+ /** Neighbours in navigation order; `href` is empty when there is none. */
42
+ prev: Ref,
43
+ next: Ref,
44
+ });
45
+ /** The names a layout may read from the framework; an application's own state may not reuse them. */
46
+ export const RESERVED = Object.keys(FrameScope.fields);
47
+ const NONE = { href: "", label: "" };
48
+ const view = (blocks) => blocks;
49
+ const withBase = (base, path) => `${base}${path.replace(/^\//, "")}`;
50
+ export const sectionLabel = (dir) => dir.replace(/[-_]+/g, " ").replace(/^./, (first) => first.toUpperCase());
51
+ /** The page shown for an address that is not a page: `content/404.md` if the author wrote one. */
52
+ export const notFoundPage = (content) => content.notFound ?? {
53
+ path: "/404",
54
+ file: "",
55
+ title: "Page not found",
56
+ description: "",
57
+ order: 0,
58
+ section: "",
59
+ outline: [],
60
+ blocks: [
61
+ {
62
+ id: "b0",
63
+ kind: "heading",
64
+ level: 1,
65
+ anchor: "page-not-found",
66
+ spans: [{ id: "b0.0", kind: "text", text: "Page not found", href: "" }],
67
+ items: [],
68
+ ordered: false,
69
+ lang: "",
70
+ text: "",
71
+ src: "",
72
+ alt: "",
73
+ },
74
+ {
75
+ id: "b1",
76
+ kind: "paragraph",
77
+ level: 0,
78
+ anchor: "",
79
+ spans: [
80
+ { id: "b1.0", kind: "text", text: "There is no page at this address.", href: "" },
81
+ { id: "b1.1", kind: "text", text: " ", href: "" },
82
+ { id: "b1.2", kind: "link", text: "Go to the start.", href: withBase(content.site.base, "/") },
83
+ ],
84
+ items: [],
85
+ ordered: false,
86
+ lang: "",
87
+ text: "",
88
+ src: "",
89
+ alt: "",
90
+ },
91
+ ],
92
+ };
93
+ export const pageAt = (content, path) => content.pages.find((page) => page.path === path);
94
+ /** The text of the document's `<title>` for a page. */
95
+ export const documentTitle = (content, page) => page === undefined
96
+ ? `${notFoundPage(content).title} · ${content.site.name}`
97
+ : page.path === "/"
98
+ ? page.title === content.site.name
99
+ ? page.title
100
+ : `${page.title} · ${content.site.name}`
101
+ : `${page.title} · ${content.site.name}`;
102
+ export const frame = (content, path) => {
103
+ const page = pageAt(content, path);
104
+ const shown = page ?? notFoundPage(content);
105
+ const at = page === undefined ? -1 : content.pages.indexOf(page);
106
+ const ref = (to) => (to === undefined ? NONE : { href: withBase(content.site.base, to.path), label: to.title });
107
+ const groups = [];
108
+ for (const each of content.pages) {
109
+ let group = groups.find((candidate) => candidate.id === each.section);
110
+ if (group === undefined) {
111
+ group = { id: each.section, title: each.section === "" ? "" : sectionLabel(each.section), items: [] };
112
+ groups.push(group);
113
+ }
114
+ group.items.push({ id: each.path, href: withBase(content.site.base, each.path), label: each.title, current: each.path === path ? "page" : "false" });
115
+ }
116
+ return {
117
+ site: content.site.name,
118
+ home: withBase(content.site.base, "/"),
119
+ title: shown.title,
120
+ description: shown.description,
121
+ found: page !== undefined,
122
+ sections: groups,
123
+ hasToc: shown.outline.length > 0,
124
+ toc: shown.outline.map((heading) => ({ id: heading.id, href: `#${heading.anchor}`, label: heading.label, level: heading.level })),
125
+ blocks: view(shown.blocks),
126
+ prev: ref(content.pages[at - 1]),
127
+ next: ref(at < 0 ? undefined : content.pages[at + 1]),
128
+ };
129
+ };
@@ -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)], 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();
@@ -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>;
package/dist/state.js ADDED
@@ -0,0 +1,33 @@
1
+ // The application's state beyond the page, however it was declared: field by field (`field(type, default)`), or as a hand-built schema with a separate initial value. Both become one thing here.
2
+ // No Node and no Vite: the browser entry loads this too.
3
+ import { Schema } from "effect";
4
+ import { FIELD } from "./index.js";
5
+ const isField = (value) => typeof value === "object" && value !== null && FIELD in value;
6
+ /** The state a definition declares, or `undefined` for none. */
7
+ export const stateOf = (author) => {
8
+ const state = author?.state;
9
+ if (state === undefined) {
10
+ return undefined;
11
+ }
12
+ const entries = Object.entries(state);
13
+ // A hand-built state is `{ schema, initial }` where `schema` is a Schema; a field named `schema` is a Field.
14
+ if (!entries.some(([, value]) => isField(value)) && "schema" in state && Schema.isSchema(state["schema"])) {
15
+ return { schema: state["schema"], initial: (state["initial"] ?? {}), declared: false };
16
+ }
17
+ const fields = entries.filter((entry) => isField(entry[1]));
18
+ return {
19
+ schema: Schema.Struct(Object.fromEntries(fields.map(([name, each]) => [name, each.schema]))),
20
+ initial: Object.fromEntries(fields.map(([name, each]) => [name, each.initial])),
21
+ declared: true,
22
+ };
23
+ };
24
+ /** The fields whose default is not a value of the field's own type (only for a state declared field by field). */
25
+ export const badDefaults = (author) => {
26
+ const state = stateOf(author);
27
+ if (state === undefined || !state.declared) {
28
+ return [];
29
+ }
30
+ return Object.entries(state.schema.fields)
31
+ .filter(([name, schema]) => !Schema.is(schema)(state.initial[name]))
32
+ .map(([name]) => name);
33
+ };
package/package.json CHANGED
@@ -1,6 +1,73 @@
1
1
  {
2
2
  "name": "@valancex/common",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.11.0",
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
+ "keywords": [
6
+ "valance",
7
+ "defineApp",
8
+ "component"
9
+ ],
10
+ "homepage": "https://github.com/ValanceX/Valance/tree/main/packages/common#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/ValanceX/Valance/issues"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/ValanceX/Valance.git",
17
+ "directory": "packages/common"
18
+ },
19
+ "license": "MIT",
20
+ "author": "Jack Miller <jackmiller0899@gmail.com>",
21
+ "type": "module",
22
+ "engines": {
23
+ "node": ">=22"
24
+ },
25
+ "sideEffects": false,
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ },
31
+ "./client": {
32
+ "types": "./dist/client.d.ts",
33
+ "default": "./dist/client.js"
34
+ },
35
+ "./server": {
36
+ "types": "./dist/server.d.ts",
37
+ "default": "./dist/server.js"
38
+ },
39
+ "./internal": {
40
+ "types": "./dist/internal.d.ts",
41
+ "default": "./dist/internal.js"
42
+ },
43
+ "./package.json": "./package.json"
44
+ },
45
+ "types": "./dist/index.d.ts",
46
+ "files": [
47
+ "dist",
48
+ "types"
49
+ ],
50
+ "publishConfig": {
51
+ "access": "public",
52
+ "provenance": true
53
+ },
54
+ "dependencies": {
55
+ "@valancex/mesh-runtime": "^0.11.0",
56
+ "@valancex/port-web": "^0.4.0",
57
+ "@valancex/valance": "^0.11.0"
58
+ },
59
+ "peerDependencies": {
60
+ "effect": "^3.10.0"
61
+ },
62
+ "devDependencies": {
63
+ "@types/node": "^22.0.0",
64
+ "effect": "^3.10.0",
65
+ "typescript": "^5.6.0",
66
+ "vitest": "^3.2.0"
67
+ },
68
+ "scripts": {
69
+ "build": "tsc -p tsconfig.json",
70
+ "typecheck": "tsc -p tsconfig.typecheck.json",
71
+ "test": "vitest run"
72
+ }
6
73
  }
@@ -0,0 +1,5 @@
1
+ // What importing a template file gives the author's TypeScript: the file's address (see `Template` in src/index.ts, which this is structurally). The bundler (vite.ts) makes the import.
2
+ declare module "*.mprx" {
3
+ const template: { readonly mprx: string };
4
+ export default template;
5
+ }