@valancex/cli 0.13.0 → 0.15.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 +33 -6
- package/dist/budgets.json +175 -0
- package/dist/build.d.ts +8 -0
- package/dist/build.js +38 -9
- package/dist/check.js +6 -1
- package/dist/cli.js +164 -6
- package/dist/compile.d.ts +8 -0
- package/dist/compile.js +135 -16
- package/dist/config.d.ts +12 -0
- package/dist/config.js +34 -1
- package/dist/contract.d.ts +18 -1
- package/dist/contract.js +92 -3
- package/dist/dev.d.ts +5 -0
- package/dist/dev.js +60 -26
- package/dist/emit.d.ts +3 -1
- package/dist/emit.js +56 -17
- package/dist/libraries.d.ts +41 -0
- package/dist/libraries.js +169 -0
- package/dist/library.d.ts +32 -0
- package/dist/library.js +333 -0
- package/dist/package.js +8 -4
- package/dist/recording-guard.d.ts +5 -0
- package/dist/recording-guard.js +28 -0
- package/dist/replay.d.ts +15 -0
- package/dist/replay.js +59 -0
- package/dist/rules.d.ts +23 -0
- package/dist/rules.js +123 -0
- package/dist/runtime-guard.d.ts +11 -0
- package/dist/runtime-guard.js +62 -0
- package/dist/security.d.ts +2 -0
- package/dist/security.js +8 -1
- package/dist/size.d.ts +54 -0
- package/dist/size.js +153 -0
- package/dist/theme.d.ts +41 -0
- package/dist/theme.js +84 -0
- package/dist/tree.d.ts +14 -2
- package/dist/tree.js +16 -1
- package/dist/ui/report.js +1 -1
- package/dist/ui/run.d.ts +1 -0
- package/dist/ui/run.js +13 -0
- package/dist/vite.js +17 -3
- package/package.json +13 -8
- package/schemas/valance.schema.json +76 -0
- package/templates/desktop/gitignore +3 -0
- package/templates/multi/src/web/layout.mprx +2 -2
- package/templates/web/gitignore +3 -0
- package/templates/web/src/layout.mprx +2 -2
- package/views/block.mprx +1 -1
- package/views/layout.mprx +2 -2
- package/views/list-items.mprx +1 -1
package/dist/contract.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export interface Entry {
|
|
|
7
7
|
readonly props: Readonly<Record<string, {
|
|
8
8
|
readonly type: MeshType;
|
|
9
9
|
readonly required: boolean;
|
|
10
|
+
readonly default?: unknown;
|
|
10
11
|
}>>;
|
|
11
12
|
readonly events: Readonly<Record<string, {
|
|
12
13
|
readonly payload?: MeshType;
|
|
@@ -14,12 +15,28 @@ export interface Entry {
|
|
|
14
15
|
readonly commands: Readonly<Record<string, never>>;
|
|
15
16
|
/** Every prop is a name the template reads: the same name and type, absent when the prop may be. */
|
|
16
17
|
readonly scope: Readonly<Record<string, MeshType>>;
|
|
18
|
+
/** The state and commands of one occurrence, when the component has a unit: their names join the scope and the commands in the model. */
|
|
19
|
+
readonly unit?: {
|
|
20
|
+
readonly state: Readonly<Record<string, {
|
|
21
|
+
readonly type: MeshType;
|
|
22
|
+
readonly default: unknown;
|
|
23
|
+
readonly controllable?: boolean;
|
|
24
|
+
}>>;
|
|
25
|
+
readonly commands: Readonly<Record<string, {
|
|
26
|
+
readonly parameters: ReadonlyArray<{
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly type: MeshType;
|
|
29
|
+
}>;
|
|
30
|
+
}>>;
|
|
31
|
+
};
|
|
17
32
|
}
|
|
18
33
|
/** A schema with no model type, said at the declaration. */
|
|
19
34
|
export interface Problem {
|
|
20
|
-
readonly kind: "props" | "events";
|
|
35
|
+
readonly kind: "props" | "events" | "unit";
|
|
21
36
|
readonly name: string;
|
|
22
37
|
readonly reason: string;
|
|
38
|
+
/** The whole message, for a problem that is not a missing type. */
|
|
39
|
+
readonly message?: string;
|
|
23
40
|
}
|
|
24
41
|
/** What a schema is in the model: put in a struct, so the rules are state's. A field that may be `undefined` comes back optional. */
|
|
25
42
|
export declare const typeOf: (schema: SchemaOf) => MeshType;
|
package/dist/contract.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { manifest } from "@valancex/valance/web/build";
|
|
2
2
|
import { Schema } from "effect";
|
|
3
|
+
import { isField, isSnippet } from "@valancex/common";
|
|
3
4
|
/** What a schema is in the model: put in a struct, so the rules are state's. A field that may be `undefined` comes back optional. */
|
|
4
5
|
export const typeOf = (schema) => {
|
|
5
6
|
const derived = manifest({ primitives: {}, scopes: { contract: Schema.Struct({ value: schema }) }, commands: () => ({}) });
|
|
@@ -17,11 +18,39 @@ export const entryOf = (declared) => {
|
|
|
17
18
|
const props = {};
|
|
18
19
|
const scope = {};
|
|
19
20
|
const events = {};
|
|
20
|
-
for (const [name,
|
|
21
|
+
for (const [name, declaration] of Object.entries(declared.props)) {
|
|
21
22
|
try {
|
|
23
|
+
if (isSnippet(declaration)) {
|
|
24
|
+
// A piece of template: a prop of the component and a name its template reads (`{@render name(…)}`). It is not data, so it has no default and is never absent.
|
|
25
|
+
if (name === "children" && declaration.params.length > 0) {
|
|
26
|
+
throw new Error("`children` is the content between the component's tags, so it takes no parameters: write `snippet()`");
|
|
27
|
+
}
|
|
28
|
+
const params = declaration.params.map((schema) => {
|
|
29
|
+
const type = typeOf(schema);
|
|
30
|
+
return type.kind === "optional" ? type.type : type;
|
|
31
|
+
});
|
|
32
|
+
const type = { kind: "snippet", params };
|
|
33
|
+
// An optional one is `snippet<…>?` in the scope too: the template renders it only where `has(name)` says it is there.
|
|
34
|
+
props[name] = declaration.isOptional ? { type: { kind: "optional", type }, required: false } : { type, required: true };
|
|
35
|
+
scope[name] = declaration.isOptional ? { kind: "optional", type } : type;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (name === "children") {
|
|
39
|
+
throw new Error("`children` is reserved for the content between the component's tags: declare it `snippet()`");
|
|
40
|
+
}
|
|
41
|
+
const schema = isField(declaration) ? declaration.schema : declaration;
|
|
22
42
|
const type = typeOf(schema);
|
|
23
43
|
const optional = type.kind === "optional";
|
|
24
|
-
|
|
44
|
+
if (isField(declaration)) {
|
|
45
|
+
// A default is for a component that has a template (the model applies it to those only) and is written as the schema encodes it. The prop may then be left out.
|
|
46
|
+
if (!Schema.is(schema)(declaration.initial)) {
|
|
47
|
+
throw new Error("its default is not a value of its type");
|
|
48
|
+
}
|
|
49
|
+
props[name] = { type: optional ? type.type : type, required: false, default: Schema.encodeSync(schema)(declaration.initial) };
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
props[name] = { type: optional ? type.type : type, required: !optional };
|
|
53
|
+
}
|
|
25
54
|
scope[name] = type;
|
|
26
55
|
}
|
|
27
56
|
catch (error) {
|
|
@@ -41,5 +70,65 @@ export const entryOf = (declared) => {
|
|
|
41
70
|
problems.push({ kind: "events", name, reason: reasonOf(error) });
|
|
42
71
|
}
|
|
43
72
|
}
|
|
44
|
-
|
|
73
|
+
const unit = declared.unit === undefined ? undefined : unitEntry(declared.unit, props, problems);
|
|
74
|
+
// A controllable field is read in the template by the name of its prop, and the unit supplies that name (the prop's value when the caller writes it, else the unit's own); listing the prop in the scope
|
|
75
|
+
// as well is `manifest-unit-clash` in MESH, for a field the docs say to declare this way (found building a tabs component outside the repository).
|
|
76
|
+
for (const [name, each] of Object.entries(unit?.state ?? {})) {
|
|
77
|
+
if (each.controllable === true) {
|
|
78
|
+
delete scope[name];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (declared.name !== undefined) {
|
|
82
|
+
const named = props[declared.name];
|
|
83
|
+
if (named === undefined && !problems.some((each) => each.name === declared.name)) {
|
|
84
|
+
problems.push({ kind: "props", name: declared.name, reason: "", message: `"${declared.name}" is declared as the component's name (\`name\`) but is not one of its props` });
|
|
85
|
+
}
|
|
86
|
+
else if (named !== undefined && (named.type.kind !== "string" || !named.required)) {
|
|
87
|
+
problems.push({
|
|
88
|
+
kind: "props",
|
|
89
|
+
name: declared.name,
|
|
90
|
+
reason: "",
|
|
91
|
+
message: `the prop "${declared.name}" is the component's name (\`name\`), so it must be a string every use writes: \`Schema.String\`, with no default and not optional`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { entry: { props, events, commands: {}, scope, ...(unit === undefined ? {} : { unit }) }, problems };
|
|
96
|
+
};
|
|
97
|
+
/** The model's `unit` of a component: each state field with its type and default, each command with its parameters. A field whose schema has no model type is a problem at the declaration. */
|
|
98
|
+
const unitEntry = (declared, props, problems) => {
|
|
99
|
+
const state = {};
|
|
100
|
+
const commands = {};
|
|
101
|
+
for (const [name, field] of Object.entries(declared.fields)) {
|
|
102
|
+
try {
|
|
103
|
+
const type = typeOf(field.schema);
|
|
104
|
+
const controllable = declared.controllable.includes(name);
|
|
105
|
+
if (!Schema.is(field.schema)(field.initial)) {
|
|
106
|
+
throw new Error("its default is not a value of its type");
|
|
107
|
+
}
|
|
108
|
+
if (controllable && props[name] === undefined) {
|
|
109
|
+
throw new Error("it is `controllable`, so the component must declare a prop of the same name (optional, of the same type) for the caller to own it with");
|
|
110
|
+
}
|
|
111
|
+
state[name] = {
|
|
112
|
+
type: type.kind === "optional" ? type.type : type,
|
|
113
|
+
default: Schema.encodeSync(field.schema)(field.initial),
|
|
114
|
+
...(controllable ? { controllable: true } : {}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
problems.push({ kind: "unit", name, reason: reasonOf(error) });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const [name, command] of Object.entries(declared.commands)) {
|
|
122
|
+
try {
|
|
123
|
+
const parameters = Object.entries(command.input?.fields ?? {}).map(([parameter, schema]) => {
|
|
124
|
+
const type = typeOf(schema);
|
|
125
|
+
return { name: parameter, type: type.kind === "optional" ? type.type : type };
|
|
126
|
+
});
|
|
127
|
+
commands[name] = { parameters };
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
problems.push({ kind: "unit", name, reason: reasonOf(error) });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { state, commands };
|
|
45
134
|
};
|
package/dist/dev.d.ts
CHANGED
|
@@ -5,6 +5,11 @@ export interface DevOptions {
|
|
|
5
5
|
readonly target?: TargetId;
|
|
6
6
|
readonly port?: number;
|
|
7
7
|
readonly host?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Record the session (`--record`): the file the page's log is appended to (`@valancex/valance/replay`; read it with `vlx replay`). A development feature, never uploaded: a log holds
|
|
10
|
+
* what the person typed. The generated page entry that records is rewritten without it by the next run that does not ask for it.
|
|
11
|
+
*/
|
|
12
|
+
readonly record?: string;
|
|
8
13
|
/** Called with each report (a rebuild, a mistake); defaults to the console. */
|
|
9
14
|
readonly log?: (line: string) => void;
|
|
10
15
|
/** Called with every mistake still open, after the first build and after each rebuild: empty when the project is sound. */
|
package/dist/dev.js
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
|
+
// `vlx dev`: the project served from memory and from disk, rebuilt as its authored files change. Built on a Vite server for what Vite is good at (serving modules, style updates without a
|
|
2
|
+
// reload, the error overlay); everything else is this file's: each page is rendered on the server with the same code a build uses, so development shows what production will.
|
|
3
|
+
//
|
|
4
|
+
// What each change does:
|
|
5
|
+
//
|
|
6
|
+
// content/** the content is rebuilt; the browser reloads
|
|
7
|
+
// src/**/*.mprx, src/main.ts the templates are recompiled (the manifest derives from main.ts); the browser reloads
|
|
8
|
+
// src/**/*.css the style is replaced in the open page, no reload
|
|
9
|
+
// public/** the browser reloads
|
|
10
|
+
// adding or removing a stylesheet or src/main.ts the entries are rewritten; the browser reloads
|
|
11
|
+
// valance.json, valance.web.json the server restarts (the name and the base are fixed when it starts)
|
|
12
|
+
//
|
|
13
|
+
// A mistake in any of them leaves the last good build serving, and is shown in the terminal and as an overlay in the page, naming the file and line. Fixing it reloads the page.
|
|
14
|
+
import { recordingNotice } from "./recording-guard.js";
|
|
1
15
|
import { createServer } from "vite";
|
|
2
|
-
import {
|
|
16
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
17
|
+
import { dirname, relative, sep } from "node:path";
|
|
3
18
|
import { loadAuthor } from "./author.js";
|
|
4
19
|
import { loadConfig, PROJECT_FILE, WEB_FILE } from "./config.js";
|
|
5
20
|
import { AppError, format } from "./diagnostics.js";
|
|
@@ -51,7 +66,10 @@ const start = async (root, config, options, restart) => {
|
|
|
51
66
|
});
|
|
52
67
|
const failed = new Map();
|
|
53
68
|
let pending = Promise.resolve();
|
|
54
|
-
generateEntries(root, config);
|
|
69
|
+
generateEntries(root, config, { record: options.record !== undefined });
|
|
70
|
+
if (options.record !== undefined) {
|
|
71
|
+
recordingNotice(root, options.record, config).forEach(log);
|
|
72
|
+
}
|
|
55
73
|
const base = baseConfig(root, config);
|
|
56
74
|
const server = await createServer({
|
|
57
75
|
...base,
|
|
@@ -67,29 +85,45 @@ const start = async (root, config, options, restart) => {
|
|
|
67
85
|
...(base.plugins ?? []),
|
|
68
86
|
{
|
|
69
87
|
name: "valance",
|
|
70
|
-
configureServer: (vite) =>
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
88
|
+
configureServer: (vite) => {
|
|
89
|
+
if (options.record !== undefined) {
|
|
90
|
+
const file = options.record;
|
|
91
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
92
|
+
// the page's recorder posts each line of its log here, in order
|
|
93
|
+
vite.middlewares.use("/__vlx/record", (request, response) => {
|
|
94
|
+
const chunks = [];
|
|
95
|
+
request.on("data", (chunk) => chunks.push(chunk));
|
|
96
|
+
request.on("end", () => {
|
|
97
|
+
appendFileSync(file, `${Buffer.concat(chunks).toString("utf8")}\n`);
|
|
98
|
+
response.statusCode = 204;
|
|
99
|
+
response.end();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return () => {
|
|
104
|
+
// After Vite's own middleware: modules, styles, public files and the client have had their chance. What is left is a page.
|
|
105
|
+
vite.middlewares.use((request, response, next) => {
|
|
106
|
+
void (async () => {
|
|
107
|
+
// Vite has already removed the base from `request.url`; the application's addresses include it.
|
|
108
|
+
const url = new URL(request.originalUrl ?? request.url ?? "/", "http://localhost");
|
|
109
|
+
if ((request.method !== "GET" && request.method !== "HEAD") || !url.pathname.startsWith(config.web.base) || /\.[A-Za-z0-9]+$/.test(url.pathname)) {
|
|
110
|
+
next();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const { render } = (await vite.ssrLoadModule(SERVER_ENTRY));
|
|
115
|
+
const { status, html } = await render(url.pathname, { script: `${config.web.base}${GENERATED_DIR}/client.ts`, styles: [] });
|
|
116
|
+
response.statusCode = status;
|
|
117
|
+
response.setHeader("content-type", "text/html; charset=utf-8");
|
|
118
|
+
response.end(await vite.transformIndexHtml(url.pathname, html));
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
vite.ssrFixStacktrace(error);
|
|
122
|
+
next(error);
|
|
123
|
+
}
|
|
124
|
+
})();
|
|
125
|
+
});
|
|
126
|
+
};
|
|
93
127
|
},
|
|
94
128
|
},
|
|
95
129
|
],
|
|
@@ -115,7 +149,7 @@ const start = async (root, config, options, restart) => {
|
|
|
115
149
|
};
|
|
116
150
|
const wasFailing = failed.size > 0;
|
|
117
151
|
if (stages.includes("entries")) {
|
|
118
|
-
generateEntries(root, config);
|
|
152
|
+
generateEntries(root, config, { record: options.record !== undefined });
|
|
119
153
|
}
|
|
120
154
|
if (stages.includes("content")) {
|
|
121
155
|
await run("content", () => generateContent(root, config));
|
package/dist/emit.d.ts
CHANGED
|
@@ -13,4 +13,6 @@ export declare const generateProgram: (root: string, author: DefinedApp | undefi
|
|
|
13
13
|
/** The declaration of one template's type: its props and events as the names TypeScript checks a contract's keys against. */
|
|
14
14
|
export declare const templateType: (props: ReadonlyArray<string>, events: ReadonlyArray<string>) => string;
|
|
15
15
|
/** The entries; rewritten when `src/main.ts` or a stylesheet is added or removed. Each target has its own: the web's two (`client.ts`, `server.ts`), the desktop's one (`desktop.ts`). */
|
|
16
|
-
export declare const generateEntries: (root: string, config?: Config
|
|
16
|
+
export declare const generateEntries: (root: string, config?: Config, options?: {
|
|
17
|
+
readonly record?: boolean;
|
|
18
|
+
}) => boolean;
|
package/dist/emit.js
CHANGED
|
@@ -21,22 +21,48 @@ import { targetOf } from "./targets.js";
|
|
|
21
21
|
import { forTarget } from "@valancex/common/internal";
|
|
22
22
|
import { AppError } from "./diagnostics.js";
|
|
23
23
|
import { ENTRY_FILE, ensureGeneratedDir, GENERATED_DIR, generated, writeIfChanged } from "./project.js";
|
|
24
|
+
import { librariesOf } from "./libraries.js";
|
|
25
|
+
import { themeOf } from "./theme.js";
|
|
24
26
|
import { buildWiring } from "./wiring.js";
|
|
25
|
-
/** The layout of the portable core on the web: `column` and `row` are custom elements, and this is what they are (`@valancex/common`'s web vocabulary). It
|
|
27
|
+
/** The layout of the portable core on the web: `column` and `row` are custom elements, and this is what they are (`@valancex/common`'s web vocabulary). It is the library's layer, the first of the
|
|
28
|
+
* three (library, theme, application): a theme's gap replaces the default here, and a project's own styles, which are in no layer, win over both. */
|
|
26
29
|
const CORE_CSS = `/* Generated by \`valance\`. The web's realization of the portable core's layout components. */
|
|
27
|
-
vlx-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
@layer vlx-library, vlx-theme, vlx-app;
|
|
31
|
+
|
|
32
|
+
@layer vlx-library {
|
|
33
|
+
vlx-column {
|
|
34
|
+
display: flex;
|
|
35
|
+
flex-direction: column;
|
|
36
|
+
gap: 0.5rem;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
vlx-row {
|
|
40
|
+
display: flex;
|
|
41
|
+
flex-direction: row;
|
|
42
|
+
align-items: center;
|
|
43
|
+
gap: 0.5rem;
|
|
44
|
+
}
|
|
32
45
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
gap: 0.5rem;
|
|
46
|
+
/* Until the application has started (H5) a control the person can see does not yet do anything: say so, so they do not tap it again. */
|
|
47
|
+
[data-vlx="starting"] :is(button, input, select, textarea, summary, [role="button"], label) {
|
|
48
|
+
cursor: progress;
|
|
49
|
+
}
|
|
38
50
|
}
|
|
39
51
|
`;
|
|
52
|
+
/** The theme's stylesheet. A theme with a problem is reported by \`check\` (and refused by \`build\`); the page gets no theme meanwhile rather than a half of one. */
|
|
53
|
+
const themeStylesheet = (root) => themeOf(root).css ?? "/* The theme has a problem: run `vlx check`. */\n";
|
|
54
|
+
/**
|
|
55
|
+
* The stylesheets the libraries declare (K6), in the library layer: below the theme and the application, so a theme or the application's own rule wins without a specificity fight. The web's only (a
|
|
56
|
+
* window is drawn from the theme's resolved values). Absent when no library declares one.
|
|
57
|
+
*/
|
|
58
|
+
const libraryStylesheet = (root) => {
|
|
59
|
+
const parts = librariesOf(root).libraries.flatMap((library) => library.styles.map((file) => ({ library, file })));
|
|
60
|
+
return parts.length === 0
|
|
61
|
+
? undefined
|
|
62
|
+
: `/* Generated by \`valance\`: the stylesheets libraries declare, in the library layer. */\n@layer vlx-library, vlx-theme, vlx-app;\n${parts
|
|
63
|
+
.map(({ library, file }) => `/* ${library.name}: ${relative(library.dir, file).split(sep).join("/")} */\n@layer vlx-library {\n${readFileSync(file, "utf8")}\n}\n`)
|
|
64
|
+
.join("")}`;
|
|
65
|
+
};
|
|
40
66
|
/** The stylesheets the web loads: every one under `src/` except those in another target's directory (`src/desktop/`). */
|
|
41
67
|
const stylesheets = (root) => existsSync(join(root, SOURCE_DIR))
|
|
42
68
|
? readdirSync(join(root, SOURCE_DIR), { recursive: true, encoding: "utf8" })
|
|
@@ -46,20 +72,22 @@ const stylesheets = (root) => existsSync(join(root, SOURCE_DIR))
|
|
|
46
72
|
.map((file) => `${SOURCE_DIR}/${file}`)
|
|
47
73
|
: [];
|
|
48
74
|
export const hasEntry = (root) => existsSync(join(root, ENTRY_FILE));
|
|
49
|
-
const clientSource = (root, config) => `// Generated by \`valance\`. Do not edit: it is rewritten whenever the project changes.
|
|
75
|
+
const clientSource = (root, config, record = false) => `// Generated by \`valance\`. Do not edit: it is rewritten whenever the project changes.
|
|
50
76
|
// @ts-nocheck
|
|
51
77
|
import { start } from "@valancex/common/client";
|
|
52
78
|
import content from "./content.json";
|
|
53
79
|
import program from "./${programFile(config, "web")}";
|
|
80
|
+
${libraryStylesheet(root) === undefined ? "" : 'import "./library.css";\n'}import "./theme.css";
|
|
54
81
|
import "./core.css";
|
|
55
82
|
${hasEntry(root) ? `import author from "../${ENTRY_FILE}";\n` : "const author = undefined;\n"}${stylesheets(root)
|
|
56
83
|
.map((file) => `import "../${file}";\n`)
|
|
57
84
|
.join("")}
|
|
58
|
-
await start({ content, program, author });
|
|
85
|
+
${record ? `// \`vlx dev --record\`: development only. A build rewrites this entry without it.\nconst { recording } = await import("@valancex/common/record");\nconst record = recording(program, ${JSON.stringify(config?.record.redact ?? { scope: [], commands: [] })});\n` : ""}await start({ content, program, author${record ? ", record" : ""} });
|
|
59
86
|
`;
|
|
60
87
|
const desktopSource = (root, config) => `// Generated by \`valance\`. Do not edit: it is rewritten whenever the project changes.
|
|
61
88
|
// @ts-nocheck
|
|
62
89
|
import { start } from "@valancex/common/desktop";
|
|
90
|
+
import theme from "./theme.desktop.json";
|
|
63
91
|
import { readFileSync } from "node:fs";
|
|
64
92
|
import { fileURLToPath } from "node:url";
|
|
65
93
|
import content from "./content.json";
|
|
@@ -69,7 +97,7 @@ ${hasEntry(root) ? `import author from "../${ENTRY_FILE}";\n` : "const author =
|
|
|
69
97
|
const wasm = new Uint8Array(readFileSync(new URL("./mesh-runtime.wasm", import.meta.url)));
|
|
70
98
|
// (and the project's public/ files, which images are drawn from)
|
|
71
99
|
const assets = fileURLToPath(new URL("./public/", import.meta.url));
|
|
72
|
-
await start({ content, program, author, wasm, assets });
|
|
100
|
+
await start({ content, program, author, wasm, assets, theme: theme ?? undefined });
|
|
73
101
|
// the window is closed: nothing the application left open keeps the engine alive
|
|
74
102
|
process.exit(0);
|
|
75
103
|
`;
|
|
@@ -113,6 +141,10 @@ export const generateProgram = async (root, author, config, only) => {
|
|
|
113
141
|
problems.push(...error.diagnostics);
|
|
114
142
|
}
|
|
115
143
|
}
|
|
144
|
+
// a library whose marker is wrong is reported at its package.json, where its author can act (K5)
|
|
145
|
+
problems.push(...librariesOf(root).problems);
|
|
146
|
+
// a theme the project writes is checked with the templates: a mistake in it is as much the project's as one in a layout
|
|
147
|
+
problems.push(...(themeOf(root).custom ? themeOf(root).diagnostics : []));
|
|
116
148
|
if (problems.length > 0) {
|
|
117
149
|
throw new AppError(problems);
|
|
118
150
|
}
|
|
@@ -169,17 +201,24 @@ const writeTemplateTypes = (root, all) => {
|
|
|
169
201
|
}
|
|
170
202
|
};
|
|
171
203
|
/** The entries; rewritten when `src/main.ts` or a stylesheet is added or removed. Each target has its own: the web's two (`client.ts`, `server.ts`), the desktop's one (`desktop.ts`). */
|
|
172
|
-
export const generateEntries = (root, config) => {
|
|
204
|
+
export const generateEntries = (root, config, options) => {
|
|
173
205
|
ensureGeneratedDir(root);
|
|
174
206
|
const targets = config?.targets ?? ["web"];
|
|
175
207
|
return [
|
|
176
208
|
...(targets.includes("web")
|
|
177
209
|
? [
|
|
178
210
|
writeIfChanged(generated(root, "core.css"), CORE_CSS),
|
|
179
|
-
writeIfChanged(generated(root, "
|
|
211
|
+
writeIfChanged(generated(root, "theme.css"), themeStylesheet(root)),
|
|
212
|
+
writeIfChanged(generated(root, "library.css"), libraryStylesheet(root) ?? "/* No library declares a stylesheet. */\n"),
|
|
213
|
+
writeIfChanged(generated(root, "client.ts"), clientSource(root, config, options?.record === true)),
|
|
180
214
|
writeIfChanged(generated(root, "server.ts"), serverSource(root, config)),
|
|
181
215
|
]
|
|
182
216
|
: []),
|
|
183
|
-
...(targets.includes("desktop")
|
|
217
|
+
...(targets.includes("desktop")
|
|
218
|
+
? [
|
|
219
|
+
writeIfChanged(generated(root, "theme.desktop.json"), `${JSON.stringify(themeOf(root).desktop ?? null)}\n`),
|
|
220
|
+
writeIfChanged(generated(root, "desktop.ts"), desktopSource(root, config)),
|
|
221
|
+
]
|
|
222
|
+
: []),
|
|
184
223
|
].some(Boolean);
|
|
185
224
|
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Diagnostic } from "./diagnostics.js";
|
|
2
|
+
import type { TargetId } from "./targets.js";
|
|
3
|
+
/** The export condition that marks a package as a VALANCE library. */
|
|
4
|
+
export declare const CONDITION = "valance";
|
|
5
|
+
export declare const KINDS: readonly ["components", "theme"];
|
|
6
|
+
export type LibraryKind = (typeof KINDS)[number];
|
|
7
|
+
export interface Library {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly version: string;
|
|
10
|
+
/** The package's real directory (a symlink resolved). */
|
|
11
|
+
readonly dir: string;
|
|
12
|
+
readonly kind: LibraryKind;
|
|
13
|
+
/** The targets whose vocabularies the library's templates are written for. Absent in the field: every target. */
|
|
14
|
+
readonly targets: ReadonlyArray<TargetId>;
|
|
15
|
+
/** The stylesheets the library declares, as absolute paths of files that exist. */
|
|
16
|
+
readonly styles: ReadonlyArray<string>;
|
|
17
|
+
/** A theme's `theme.json`, as an absolute path (kind `theme`). */
|
|
18
|
+
readonly theme?: string;
|
|
19
|
+
}
|
|
20
|
+
/** Whether an `exports` value has the `valance` condition anywhere in it. */
|
|
21
|
+
export declare const hasCondition: (exports: unknown) => boolean;
|
|
22
|
+
/** The directory `name` is installed in for a project at `root`, looked for as Node looks for it: `node_modules/<name>` here and in each parent directory. */
|
|
23
|
+
export declare const installedAt: (root: string, name: string) => string | undefined;
|
|
24
|
+
/** What reading a project's dependencies found: the libraries, and what is wrong with their markers. */
|
|
25
|
+
export interface Found {
|
|
26
|
+
readonly libraries: ReadonlyArray<Library>;
|
|
27
|
+
readonly problems: ReadonlyArray<Diagnostic>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The libraries a project at `root` depends on: each dependency (of any kind) installed under `node_modules` whose `exports` has the `valance` condition. A package with the field and no condition is
|
|
31
|
+
* said so (the build keys on the condition, K11); a field that is wrong is a diagnostic about the package's `package.json`, where its author can act.
|
|
32
|
+
*/
|
|
33
|
+
export declare const librariesOf: (root: string) => Found;
|
|
34
|
+
/** The library a file belongs to (a template or a stylesheet, by absolute or project-relative path), if any. */
|
|
35
|
+
export declare const libraryOf: (libraries: ReadonlyArray<Library>, root: string, file: string) => Library | undefined;
|
|
36
|
+
/** The qualified id of a component: `package/name` for a library's, the bare name for the application's and the built-ins'. */
|
|
37
|
+
export declare const idOf: (libraries: ReadonlyArray<Library>, root: string, file: string, name: string) => string;
|
|
38
|
+
/** What the build tells Vite about marked packages: process them (`noExternal`), and resolve the `valance` condition. */
|
|
39
|
+
export declare const viteSettings: (libraries: ReadonlyArray<Library>) => {
|
|
40
|
+
readonly names: ReadonlyArray<string>;
|
|
41
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Libraries of components and themes (Bet 12; decisions K3 to K14). A package is a VALANCE library when its `exports` has the condition `valance`: that condition is the one thing the build keys on
|
|
2
|
+
// (K11). The package's top-level `valance` field is metadata only: what it holds (`components` or `theme`), the targets it supports, the stylesheets it declares. Templates ship as source and the
|
|
3
|
+
// consumer's build compiles them (K3), so the build must process a marked package instead of loading it in Node, which does not know `.mprx` or `.css`.
|
|
4
|
+
//
|
|
5
|
+
// This module reads the project's dependencies and says which are libraries. It does not load them: Vite does, with the settings `viteSettings` returns.
|
|
6
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
7
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
8
|
+
import { diagnostic } from "./diagnostics.js";
|
|
9
|
+
/** The targets a library can name. (Not imported from `targets.ts`, which imports the compiler, which imports this.) */
|
|
10
|
+
const TARGET_IDS = ["web", "desktop"];
|
|
11
|
+
const isTarget = (name) => TARGET_IDS.includes(name);
|
|
12
|
+
/** The export condition that marks a package as a VALANCE library. */
|
|
13
|
+
export const CONDITION = "valance";
|
|
14
|
+
export const KINDS = ["components", "theme"];
|
|
15
|
+
const read = (file) => {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
/** Whether an `exports` value has the `valance` condition anywhere in it. */
|
|
24
|
+
export const hasCondition = (exports) => typeof exports === "object" && exports !== null && !Array.isArray(exports) && Object.entries(exports).some(([key, value]) => key === CONDITION || hasCondition(value));
|
|
25
|
+
/** The directory `name` is installed in for a project at `root`, looked for as Node looks for it: `node_modules/<name>` here and in each parent directory. */
|
|
26
|
+
export const installedAt = (root, name) => {
|
|
27
|
+
for (let at = resolve(root);; at = dirname(at)) {
|
|
28
|
+
const candidate = join(at, "node_modules", ...name.split("/"));
|
|
29
|
+
if (existsSync(join(candidate, "package.json"))) {
|
|
30
|
+
return realpathSync(candidate);
|
|
31
|
+
}
|
|
32
|
+
if (dirname(at) === at) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const NONE = { libraries: [], problems: [] };
|
|
38
|
+
/** The metadata a library's `valance` field holds, checked. `file` names the package.json in a diagnostic. */
|
|
39
|
+
const fieldOf = (manifest, dir, file) => {
|
|
40
|
+
const problems = [];
|
|
41
|
+
const problem = (code, message) => void problems.push(diagnostic(file, 0, code, message));
|
|
42
|
+
const field = manifest.valance;
|
|
43
|
+
const given = typeof field === "object" && field !== null && !Array.isArray(field) ? field : undefined;
|
|
44
|
+
if (field !== undefined && given === undefined) {
|
|
45
|
+
problem("library-field", `"valance" in ${file} must be an object ({ "kind": "components", "targets": ["web", "desktop"] })`);
|
|
46
|
+
}
|
|
47
|
+
for (const key of Object.keys(given ?? {})) {
|
|
48
|
+
if (!["kind", "targets", "styles", "theme"].includes(key)) {
|
|
49
|
+
problem("library-field", `"valance.${key}" in ${file} is not a field of a library (kind, targets, styles, theme)`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const kind = given?.["kind"] ?? "components";
|
|
53
|
+
if (!KINDS.includes(kind)) {
|
|
54
|
+
problem("library-field", `"valance.kind" in ${file} is ${JSON.stringify(kind)}; it is one of ${KINDS.map((each) => `"${each}"`).join(", ")}`);
|
|
55
|
+
}
|
|
56
|
+
const listed = given?.["targets"];
|
|
57
|
+
const targets = [];
|
|
58
|
+
if (listed !== undefined && (!Array.isArray(listed) || listed.length === 0)) {
|
|
59
|
+
problem("library-field", `"valance.targets" in ${file} must be a non-empty list of targets`);
|
|
60
|
+
}
|
|
61
|
+
for (const each of Array.isArray(listed) ? listed : []) {
|
|
62
|
+
if (typeof each === "string" && isTarget(each)) {
|
|
63
|
+
targets.push(each);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
problem("library-field", `"valance.targets" in ${file} names ${JSON.stringify(each)}, which is not a target`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const styles = [];
|
|
70
|
+
const listedStyles = Array.isArray(given?.["styles"]) ? given["styles"] : [];
|
|
71
|
+
for (const each of listedStyles) {
|
|
72
|
+
const path = typeof each === "string" ? resolve(dir, each) : undefined;
|
|
73
|
+
if (path === undefined || !path.startsWith(dir + sep)) {
|
|
74
|
+
problem("library-field", `"valance.styles" in ${file} lists ${JSON.stringify(each)}: a stylesheet inside the package, as "./dist/x.css"`);
|
|
75
|
+
}
|
|
76
|
+
else if (!existsSync(path)) {
|
|
77
|
+
problem("library-styles", `"valance.styles" in ${file} lists ${String(each)}, which the package does not contain (did the library build run?)`);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
styles.push(path);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
let theme;
|
|
84
|
+
if (kind === "theme") {
|
|
85
|
+
const entry = given?.["theme"];
|
|
86
|
+
const path = typeof entry === "string" ? resolve(dir, entry) : undefined;
|
|
87
|
+
if (path === undefined || !path.startsWith(dir + sep)) {
|
|
88
|
+
problem("library-field", `a theme package says where its theme is: "valance": { "kind": "theme", "theme": "./theme/theme.json" } in ${file}`);
|
|
89
|
+
}
|
|
90
|
+
else if (!existsSync(path)) {
|
|
91
|
+
problem("library-field", `"valance.theme" in ${file} is ${String(entry)}, which the package does not contain`);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
theme = path;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
problems,
|
|
99
|
+
...(problems.length > 0
|
|
100
|
+
? {}
|
|
101
|
+
: {
|
|
102
|
+
library: {
|
|
103
|
+
name: manifest.name ?? "",
|
|
104
|
+
version: manifest.version ?? "0.0.0",
|
|
105
|
+
dir,
|
|
106
|
+
kind: kind,
|
|
107
|
+
targets: targets.length === 0 ? ["web", "desktop"] : targets,
|
|
108
|
+
styles,
|
|
109
|
+
...(theme === undefined ? {} : { theme }),
|
|
110
|
+
},
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* The libraries a project at `root` depends on: each dependency (of any kind) installed under `node_modules` whose `exports` has the `valance` condition. A package with the field and no condition is
|
|
116
|
+
* said so (the build keys on the condition, K11); a field that is wrong is a diagnostic about the package's `package.json`, where its author can act.
|
|
117
|
+
*/
|
|
118
|
+
export const librariesOf = (root) => {
|
|
119
|
+
const own = read(join(root, "package.json"));
|
|
120
|
+
if (own === undefined) {
|
|
121
|
+
return NONE;
|
|
122
|
+
}
|
|
123
|
+
const names = [...new Set([...Object.keys(own.dependencies ?? {}), ...Object.keys(own.devDependencies ?? {}), ...Object.keys(own.peerDependencies ?? {})])].sort();
|
|
124
|
+
const libraries = [];
|
|
125
|
+
const problems = [];
|
|
126
|
+
for (const name of names) {
|
|
127
|
+
const dir = installedAt(root, name);
|
|
128
|
+
const manifest = dir === undefined ? undefined : read(join(dir, "package.json"));
|
|
129
|
+
if (dir === undefined || manifest === undefined) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const marked = hasCondition(manifest.exports);
|
|
133
|
+
if (!marked) {
|
|
134
|
+
if (manifest.valance !== undefined && name !== "@valancex/valance") {
|
|
135
|
+
problems.push(diagnostic(`node_modules/${name}/package.json`, 0, "library-unmarked", `${name} has a "valance" field but no "valance" export condition, so the build does not process it: add ${JSON.stringify({ exports: { ".": { valance: "./dist/index.js" } } })} (the condition is what marks a library)`));
|
|
136
|
+
}
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const checked = fieldOf({ ...manifest, name }, dir, `node_modules/${name}/package.json`);
|
|
140
|
+
problems.push(...checked.problems);
|
|
141
|
+
if (checked.library !== undefined) {
|
|
142
|
+
libraries.push(checked.library);
|
|
143
|
+
// A library resolves its peers (`@valancex/common`, `effect`) from where it really is. One linked from outside the project (`npm install ../acme-ui`) finds none there (K7).
|
|
144
|
+
const missing = Object.keys(manifest.peerDependencies ?? {}).filter((peer) => manifest.peerDependenciesMeta?.[peer]?.optional !== true && installedAt(dir, peer) === undefined);
|
|
145
|
+
if (missing.length > 0) {
|
|
146
|
+
problems.push(diagnostic(`node_modules/${name}/package.json`, 0, "library-peers", `${name} cannot find its peer ${missing.map((each) => `${each}`).join(", ")} from ${dir}. A library linked from outside the project (\`npm install ../${name}\`) resolves its peers from its own directory: install it from a packed tarball (\`npm pack\`), or develop it in a workspace (\`vlx init library\`)`));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { libraries, problems };
|
|
151
|
+
};
|
|
152
|
+
/** The library a file belongs to (a template or a stylesheet, by absolute or project-relative path), if any. */
|
|
153
|
+
export const libraryOf = (libraries, root, file) => {
|
|
154
|
+
let real = resolve(root, file);
|
|
155
|
+
try {
|
|
156
|
+
real = realpathSync(real);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// a file that does not exist belongs to whoever its path says
|
|
160
|
+
}
|
|
161
|
+
return libraries.find((each) => real.startsWith(each.dir + sep));
|
|
162
|
+
};
|
|
163
|
+
/** The qualified id of a component: `package/name` for a library's, the bare name for the application's and the built-ins'. */
|
|
164
|
+
export const idOf = (libraries, root, file, name) => {
|
|
165
|
+
const library = libraryOf(libraries, root, file);
|
|
166
|
+
return library === undefined ? name : `${library.name}/${name}`;
|
|
167
|
+
};
|
|
168
|
+
/** What the build tells Vite about marked packages: process them (`noExternal`), and resolve the `valance` condition. */
|
|
169
|
+
export const viteSettings = (libraries) => ({ names: libraries.map((each) => each.name) });
|