@valancex/cli 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +69 -11
- package/dist/add.d.ts +9 -1
- package/dist/add.js +68 -5
- package/dist/archive.d.ts +17 -0
- package/dist/archive.js +111 -0
- package/dist/args.js +2 -2
- package/dist/budgets.json +175 -0
- package/dist/build.d.ts +11 -0
- package/dist/build.js +120 -30
- package/dist/check.js +2 -2
- package/dist/cli.js +131 -17
- package/dist/compile.d.ts +11 -3
- package/dist/compile.js +123 -25
- package/dist/config.d.ts +13 -0
- package/dist/config.js +34 -1
- package/dist/contract.d.ts +18 -1
- package/dist/contract.js +85 -3
- package/dist/dev-desktop.d.ts +8 -0
- package/dist/dev-desktop.js +114 -0
- package/dist/dev.d.ts +8 -0
- package/dist/dev.js +91 -29
- package/dist/emit.d.ts +9 -4
- package/dist/emit.js +129 -28
- package/dist/init.js +14 -4
- package/dist/package.d.ts +18 -0
- package/dist/package.js +142 -0
- package/dist/project.js +1 -1
- 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/targets.d.ts +6 -1
- package/dist/targets.js +26 -4
- package/dist/theme.d.ts +35 -0
- package/dist/theme.js +44 -0
- package/dist/tree.d.ts +14 -2
- package/dist/tree.js +16 -1
- package/dist/ui/init.js +4 -4
- package/dist/ui/report.js +1 -1
- package/dist/ui/run.d.ts +4 -1
- package/dist/ui/run.js +18 -3
- package/dist/vite.d.ts +3 -2
- package/dist/vite.js +3 -3
- package/package.json +15 -7
- package/schemas/valance.desktop.schema.json +17 -0
- package/schemas/valance.schema.json +71 -0
- package/templates/desktop/README.md +14 -0
- package/templates/desktop/content/404.md +7 -0
- package/templates/desktop/content/guide/getting-started.md +15 -0
- package/templates/desktop/content/index.md +14 -0
- package/templates/desktop/gitignore +7 -0
- package/templates/desktop/src/layout.mprx +23 -0
- package/templates/desktop/src/main.ts +15 -0
- package/templates/desktop/tsconfig.json +18 -0
- package/templates/multi/README.md +13 -0
- package/templates/multi/content/404.md +7 -0
- package/templates/multi/content/guide/getting-started.md +15 -0
- package/templates/multi/content/index.md +14 -0
- package/templates/multi/src/desktop/layout.mprx +21 -0
- package/templates/multi/src/main.ts +23 -0
- package/templates/multi/src/web/layout.mprx +27 -0
- package/templates/multi/src/web/styles.css +28 -0
- package/templates/multi/src/web/theme-toggle.mprx +1 -0
- package/templates/multi/src/web/theme-toggle.ts +10 -0
- package/templates/web/content/guide/getting-started.md +1 -1
- 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/views-desktop/block.mprx +3 -0
- package/views-desktop/blocks.mprx +3 -0
- package/views-desktop/inline.mprx +3 -0
- package/views-desktop/layout.mprx +20 -0
- package/views-desktop/list-block.mprx +3 -0
- package/views-desktop/list-items.mprx +3 -0
- package/views-desktop/runs.mprx +3 -0
- /package/views/{heading.mprx → heading-block.mprx} +0 -0
package/dist/compile.js
CHANGED
|
@@ -8,16 +8,19 @@ import { basename, join, relative, sep } from "node:path";
|
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { commandTable, scopeSchema } from "@valancex/common/internal";
|
|
10
10
|
import { AppError, diagnostic } from "./diagnostics.js";
|
|
11
|
-
import { declarationsOf, linked, nameOf, treeOf } from "./tree.js";
|
|
11
|
+
import { declarationsOf, declaredOf, linked, nameOf, treeOf } from "./tree.js";
|
|
12
12
|
import { tagsOf } from "./outline.js";
|
|
13
13
|
import { entryOf } from "./contract.js";
|
|
14
14
|
import { locateKey } from "./wiring.js";
|
|
15
|
-
import { webVocabulary } from "@valancex/common/internal";
|
|
16
|
-
import {
|
|
15
|
+
import { namesOf, webVocabulary, WHOLE } from "@valancex/common/internal";
|
|
16
|
+
import { checkReferences, checkTemplate } from "./rules.js";
|
|
17
|
+
import { ENTRY_FILE, GENERATED_DIR } from "./project.js";
|
|
17
18
|
export const SOURCE_DIR = "src";
|
|
18
19
|
/** The component the page is drawn by. */
|
|
19
20
|
export const ROOT = "layout";
|
|
20
21
|
export const BUILT_IN = fileURLToPath(new URL("../views/", import.meta.url));
|
|
22
|
+
/** The desktop target's built-in views: the same components as the web's, written in the desktop vocabulary. */
|
|
23
|
+
export const BUILT_IN_DESKTOP = fileURLToPath(new URL("../views-desktop/", import.meta.url));
|
|
21
24
|
/** The package directory: where its README, which documents the framework's own names, is. */
|
|
22
25
|
export const PACKAGE_DIR = fileURLToPath(new URL("../", import.meta.url));
|
|
23
26
|
const mprxIn = (dir) => existsSync(dir)
|
|
@@ -37,11 +40,11 @@ const entryText = (root) => {
|
|
|
37
40
|
* Built-in templates, then the author's. The author's are exactly the ones `src/main.ts` declares: `layout`, `components` and `override`. A file under `src/` that is not declared is an error, not
|
|
38
41
|
* a guess; a registered component that no template writes is allowed (it is not part of the program until one does). Which components a template uses is read from the template. A name that is a built-in's replaces it only through `override`.
|
|
39
42
|
*/
|
|
40
|
-
export const templatesOf = (root, author, vocabulary = webVocabulary) => {
|
|
43
|
+
export const templatesOf = (root, author, vocabulary = webVocabulary, views = BUILT_IN) => {
|
|
41
44
|
const found = new Map();
|
|
42
|
-
for (const file of mprxIn(
|
|
45
|
+
for (const file of mprxIn(views)) {
|
|
43
46
|
const component = basename(file, ".mprx");
|
|
44
|
-
found.set(component, { component, path: `@valancex/cli
|
|
47
|
+
found.set(component, { component, path: `@valancex/cli/${basename(views)}/${file}`, source: readFileSync(join(views, file), "utf8"), builtIn: true });
|
|
45
48
|
}
|
|
46
49
|
const layout = author?.layout;
|
|
47
50
|
const overrides = author?.override ?? [];
|
|
@@ -67,17 +70,30 @@ export const templatesOf = (root, author, vocabulary = webVocabulary) => {
|
|
|
67
70
|
for (const each of overrides) {
|
|
68
71
|
const name = nameOf(each.template.mprx);
|
|
69
72
|
if (!builtInNames.has(name) && name !== ROOT) {
|
|
70
|
-
problem(at(each.template.mprx), "override-unknown", `<${name}> is not a built-in component, so there is nothing to override;
|
|
73
|
+
problem(at(each.template.mprx), "override-unknown", `<${name}> is not a built-in component, so there is nothing to override; register it in \`components\` instead`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// A name is one component. Two files of one name, from anywhere in the definition (two libraries', or a library's and the application's), are an error: the first listed must not win silently
|
|
77
|
+
// (decision K14). A component that is not listed in `override` and is named like a built-in is the same collision, with the built-in.
|
|
78
|
+
// TODO(K14): qualified ids (`acme-ui/disclosure`) will let a library's component be named and overridden without renaming a file; until then the way out of a collision is to rename one.
|
|
79
|
+
const listed = declaredOf(layout, overrides, author?.components ?? []);
|
|
80
|
+
const firstOf = new Map();
|
|
81
|
+
for (const each of listed) {
|
|
82
|
+
const first = firstOf.get(each.name);
|
|
83
|
+
if (first === undefined) {
|
|
84
|
+
firstOf.set(each.name, each.file);
|
|
85
|
+
if (each.role === "component" && builtInNames.has(each.name) && !listed.some((other) => other.role !== "component" && other.name === each.name)) {
|
|
86
|
+
problem(at(each.file), "template-builtin", `${each.file} is <${each.name}>, which is also a built-in component: list it in \`override\` to replace the built-in, or rename the file so both can be used`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else if (first !== each.file) {
|
|
90
|
+
problem(at(each.file), "template-duplicate", `${each.file} is also <${each.name}>, like ${first}: a component is named by its file, so the name must be unique; rename one of the files`);
|
|
71
91
|
}
|
|
72
92
|
}
|
|
73
93
|
const files = new Map();
|
|
74
94
|
for (const node of declared) {
|
|
75
95
|
const place = at(node.file);
|
|
76
|
-
if (
|
|
77
|
-
problem(place, "template-duplicate", `${files.get(node.name)} is also <${node.name}>: a component is named by its file, so the name must be unique`);
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
if (node.name in vocabulary.tags) {
|
|
96
|
+
if (namesOf(vocabulary).includes(node.name)) {
|
|
81
97
|
problem(place, "template-primitive", `<${node.name}> is already a tag the framework draws as an element; name the file something else`);
|
|
82
98
|
continue;
|
|
83
99
|
}
|
|
@@ -92,10 +108,17 @@ export const templatesOf = (root, author, vocabulary = webVocabulary) => {
|
|
|
92
108
|
files.set(node.name, node.file);
|
|
93
109
|
found.set(node.name, { component: node.name, path: node.file, source: readFileSync(join(root, node.file), "utf8"), builtIn: false });
|
|
94
110
|
}
|
|
95
|
-
// Every template under src/ must be somewhere in
|
|
111
|
+
// Every template under src/ must be somewhere in a tree: this target's, or another target's (a project for more than one target keeps each one's templates side by side).
|
|
112
|
+
const claimed = new Set(files.values());
|
|
113
|
+
const whole = (author?.[WHOLE] ?? author);
|
|
114
|
+
for (const parts of [whole, ...Object.values(whole?.targets ?? {})]) {
|
|
115
|
+
for (const node of declaredOf(parts?.layout, parts?.override ?? [], parts?.components ?? [])) {
|
|
116
|
+
claimed.add(node.file);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
96
119
|
for (const file of mprxIn(join(root, SOURCE_DIR))) {
|
|
97
120
|
const path = `${SOURCE_DIR}/${file}`;
|
|
98
|
-
if (!
|
|
121
|
+
if (!claimed.has(path)) {
|
|
99
122
|
problems.push(diagnostic(path, 0, "template-unused", `${path} is not in the component tree: import it in ${ENTRY_FILE} and ${basename(file) === `${ROOT}.mprx` ? "pass it as `layout`" : "list it in `components`"}`));
|
|
100
123
|
}
|
|
101
124
|
}
|
|
@@ -124,11 +147,40 @@ const reachable = (all) => {
|
|
|
124
147
|
}
|
|
125
148
|
return all.filter((template) => seen.has(template.component));
|
|
126
149
|
};
|
|
127
|
-
export const manifestOf = (author, vocabulary = webVocabulary) =>
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
150
|
+
export const manifestOf = (author, vocabulary = webVocabulary) => {
|
|
151
|
+
const derived = manifest({
|
|
152
|
+
// A web vocabulary is a table the manifest is derived from; any other is a manifest already, and its components are merged in as they are.
|
|
153
|
+
primitives: "tags" in vocabulary ? vocabulary.tags : {},
|
|
154
|
+
scopes: { [ROOT]: scopeSchema(author) },
|
|
155
|
+
commands: commandTable(author),
|
|
156
|
+
});
|
|
157
|
+
if (!("tags" in vocabulary)) {
|
|
158
|
+
return { ...derived, components: { ...vocabulary.components, ...derived.components } };
|
|
159
|
+
}
|
|
160
|
+
// What the vocabulary declares of a tag's props and events (the specification's) settles what the table alone derived.
|
|
161
|
+
const components = { ...derived.components };
|
|
162
|
+
for (const [tag, declared] of Object.entries(vocabulary.declared ?? {})) {
|
|
163
|
+
const entry = components[tag];
|
|
164
|
+
if (entry !== undefined) {
|
|
165
|
+
components[tag] = {
|
|
166
|
+
...entry,
|
|
167
|
+
props: Object.fromEntries(Object.entries(entry.props).map(([name, prop]) => [name, declared.props[name] === undefined ? prop : { ...prop, ...declared.props[name] }])),
|
|
168
|
+
events: Object.fromEntries(Object.entries(entry.events).map(([name, event]) => [name, declared.events[name] === undefined ? event : { ...event, ...declared.events[name] }])),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// The props the vocabulary says a template may leave out.
|
|
173
|
+
for (const [tag, names] of Object.entries(vocabulary.optional ?? {})) {
|
|
174
|
+
const entry = components[tag];
|
|
175
|
+
if (entry !== undefined) {
|
|
176
|
+
components[tag] = {
|
|
177
|
+
...entry,
|
|
178
|
+
props: Object.fromEntries(Object.entries(entry.props).map(([name, prop]) => [name, names.includes(name) ? { ...prop, required: false } : prop])),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { ...derived, components };
|
|
183
|
+
};
|
|
132
184
|
const isManifestError = (error) => error instanceof Error && error._tag === "ManifestError";
|
|
133
185
|
/**
|
|
134
186
|
* The model the templates are checked against: the derived one (the tags, the layout's scope, the commands), and an entry for each component that declares its interface. A declared interface is
|
|
@@ -161,8 +213,10 @@ const modelOf = (root, author, tree, vocabulary) => {
|
|
|
161
213
|
const { entry, problems: bad } = entryOf(node.interface);
|
|
162
214
|
const site = declarations.get(node.file);
|
|
163
215
|
for (const each of bad) {
|
|
164
|
-
const place = site?.member(each.kind, each.name) ?? site?.call ?? site?.imported ?? { file: ENTRY_FILE, line: 1, column: 1 };
|
|
165
|
-
problems.push(diagnostic(place.file, place.line, "component-schema",
|
|
216
|
+
const place = site?.member(each.kind === "unit" ? "props" : each.kind, each.name) ?? site?.call ?? site?.imported ?? { file: ENTRY_FILE, line: 1, column: 1 };
|
|
217
|
+
problems.push(diagnostic(place.file, place.line, "component-schema", each.message === undefined
|
|
218
|
+
? `${node.name}'s ${each.kind === "props" ? "prop" : each.kind === "unit" ? "unit field or command" : "event"} "${each.name}" has no type the model can hold: ${each.reason}`
|
|
219
|
+
: `${node.name}: ${each.message}`, place.column));
|
|
166
220
|
}
|
|
167
221
|
entries[node.name] = entry;
|
|
168
222
|
}
|
|
@@ -171,9 +225,35 @@ const modelOf = (root, author, tree, vocabulary) => {
|
|
|
171
225
|
}
|
|
172
226
|
return { ...derived, components: { ...derived.components, ...entries } };
|
|
173
227
|
};
|
|
228
|
+
/** The anchors of the content's headings: the ids the built-in views give them (the generated `content.json`, which the content stage wrote before this one). */
|
|
229
|
+
const anchorsOf = (root) => {
|
|
230
|
+
try {
|
|
231
|
+
const content = JSON.parse(readFileSync(join(root, GENERATED_DIR, "content.json"), "utf8"));
|
|
232
|
+
return new Set((content.pages ?? []).flatMap((page) => (page.blocks ?? []).flatMap((block) => (block.anchor === undefined ? [] : [block.anchor]))));
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return new Set();
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
/** A tag the web renamed (decision P7: `para` is `paragraph`): said where a template still writes the old one. */
|
|
239
|
+
const renamedHint = (vocabulary, used, at) => {
|
|
240
|
+
// a prop the web renamed (decision I29: a popover's `kind` is `mode`): the diagnostic names the tag and the prop
|
|
241
|
+
const prop = /component "([^"]+)" has no prop "([^"]+)"/.exec(at.message ?? "");
|
|
242
|
+
const renamedProp = prop === null || !("tags" in vocabulary) ? undefined : vocabulary.renamedProps?.[prop[1]]?.[prop[2]];
|
|
243
|
+
if (renamedProp !== undefined) {
|
|
244
|
+
return ` (\`${prop[2]}\` on \`${prop[1]}\` is now \`${renamedProp}\`)`;
|
|
245
|
+
}
|
|
246
|
+
const renamed = "tags" in vocabulary ? vocabulary.renamed : undefined;
|
|
247
|
+
const line = used.find((template) => template.path === at.path)?.source.split("\n")[at.span.start.line - 1];
|
|
248
|
+
if (renamed === undefined || line === undefined) {
|
|
249
|
+
return "";
|
|
250
|
+
}
|
|
251
|
+
const tag = /<\/?([A-Za-z][\w-]*)/.exec(line.slice(Math.max(0, at.span.start.column - 2)))?.[1];
|
|
252
|
+
return tag !== undefined && Object.hasOwn(renamed, tag) ? ` (\`${tag}\` is now \`${renamed[tag]}\`)` : "";
|
|
253
|
+
};
|
|
174
254
|
/** Compiles the application's templates against a target's vocabulary (the web's by default). Fails with every diagnostic, each pointing into the file it is about. */
|
|
175
|
-
export const compileViews = async (root, author, vocabulary = webVocabulary) => {
|
|
176
|
-
const { all, tree } = templatesOf(root, author, vocabulary);
|
|
255
|
+
export const compileViews = async (root, author, vocabulary = webVocabulary, views = BUILT_IN) => {
|
|
256
|
+
const { all, tree } = templatesOf(root, author, vocabulary, views);
|
|
177
257
|
const used = reachable(all);
|
|
178
258
|
const model = modelOf(root, author, tree, vocabulary);
|
|
179
259
|
if (!used.some((template) => template.component === ROOT)) {
|
|
@@ -188,12 +268,30 @@ export const compileViews = async (root, author, vocabulary = webVocabulary) =>
|
|
|
188
268
|
const found = [
|
|
189
269
|
...(result.components ?? []).flatMap((entry) => entry.diagnostics.diagnostics
|
|
190
270
|
.filter((each) => each.severity === "error")
|
|
191
|
-
.map((each) => diagnostic(each.path, each.span.start.line, each.code, each.message +
|
|
271
|
+
.map((each) => diagnostic(each.path, each.span.start.line, each.code, each.message +
|
|
272
|
+
(each.suggestions.length > 0 ? ` (did you mean ${each.suggestions.map((suggestion) => `"${suggestion.replacement}"`).join(" or ")}?)` : "") +
|
|
273
|
+
renamedHint(vocabulary, used, each), each.span.start.column))),
|
|
192
274
|
...(result.assembly?.diagnostics ?? []).map((each) => diagnostic(each.path ?? "program", each.span?.start.line ?? 0, each.code, each.message, each.span?.start.column ?? 0)),
|
|
193
275
|
];
|
|
194
276
|
throw new AppError(found.map((each) => relocate(each, root)));
|
|
195
277
|
}
|
|
196
278
|
const program = result.program;
|
|
279
|
+
const rules = "tags" in vocabulary ? vocabulary.rules : undefined;
|
|
280
|
+
if (rules !== undefined) {
|
|
281
|
+
const texts = program.templates;
|
|
282
|
+
const known = new Set(namesOf(vocabulary));
|
|
283
|
+
const broken = used.flatMap((template, index) => checkTemplate(template.path, template.source, JSON.parse(texts[index] ?? "{}"), rules, known, tree.find((node) => node.name === template.component)?.interface?.name));
|
|
284
|
+
const compiled = used.map((template, index) => ({
|
|
285
|
+
file: template.path,
|
|
286
|
+
source: template.source,
|
|
287
|
+
template: JSON.parse(texts[index] ?? "{}"),
|
|
288
|
+
builtIn: template.builtIn,
|
|
289
|
+
}));
|
|
290
|
+
broken.push(...checkReferences(compiled, anchorsOf(root)));
|
|
291
|
+
if (broken.length > 0) {
|
|
292
|
+
throw new AppError(broken.map((each) => relocate(each, root)));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
197
295
|
const enriched = program.model;
|
|
198
296
|
const manifestOfProgram = typeof enriched === "string" ? JSON.parse(enriched) : enriched;
|
|
199
297
|
return {
|
|
@@ -205,7 +303,7 @@ export const compileViews = async (root, author, vocabulary = webVocabulary) =>
|
|
|
205
303
|
templates: used.map((template) => ({
|
|
206
304
|
component: template.component,
|
|
207
305
|
file: template.builtIn
|
|
208
|
-
? relative(root, join(
|
|
306
|
+
? relative(root, join(views, `${template.component}.mprx`))
|
|
209
307
|
.split(sep)
|
|
210
308
|
.join("/")
|
|
211
309
|
: template.path,
|
package/dist/config.d.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import type { Budget } from "./size.js";
|
|
1
2
|
import type { SiteInfo } from "@valancex/common/internal";
|
|
2
3
|
export declare const PROJECT_FILE = "valance.json";
|
|
3
4
|
export declare const WEB_FILE = "valance.web.json";
|
|
5
|
+
export declare const DESKTOP_FILE = "valance.desktop.json";
|
|
4
6
|
/** The only version of both files there is. */
|
|
5
7
|
export declare const CONFIG_VERSION = 1;
|
|
6
8
|
export interface Config {
|
|
7
9
|
readonly name: string;
|
|
10
|
+
/** The targets the project is built for: each one whose settings file it has (`valance.web.json`, `valance.desktop.json`), else the web alone. One application, one build per target. */
|
|
11
|
+
readonly targets: ReadonlyArray<"web" | "desktop">;
|
|
8
12
|
/** How the document's title is written from a page's title and the site's name: `{page}` and `{site}`. */
|
|
9
13
|
readonly title: string;
|
|
10
14
|
readonly web: {
|
|
@@ -20,6 +24,15 @@ export interface Config {
|
|
|
20
24
|
readonly base: string;
|
|
21
25
|
readonly proxy: string;
|
|
22
26
|
};
|
|
27
|
+
/** The size budgets a production build is held to (Tier 2): the project's own, or none, in which case the framework's start-up budgets apply (W14). Never checked by `vlx dev`. */
|
|
28
|
+
readonly budgets: ReadonlyArray<Budget> | undefined;
|
|
29
|
+
/** What `vlx dev --record` leaves out of a session log (Tier 2; R9, R14): nothing, unless the project says. */
|
|
30
|
+
readonly record: {
|
|
31
|
+
readonly redact: {
|
|
32
|
+
readonly scope: ReadonlyArray<ReadonlyArray<string>>;
|
|
33
|
+
readonly commands: ReadonlyArray<string>;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
23
36
|
}
|
|
24
37
|
/** Reads and validates the project's configuration. Every problem in both files is reported together. */
|
|
25
38
|
export declare const loadConfig: (root: string) => Config;
|
package/dist/config.js
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
// `schemas/` describe the same files for editors; this is the check the tools make.
|
|
4
4
|
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
|
+
import { budgetProblems } from "./size.js";
|
|
6
7
|
import { AppError, diagnostic, lineOf } from "./diagnostics.js";
|
|
7
8
|
import { DEFAULT_TITLE } from "@valancex/common/internal";
|
|
8
9
|
export const PROJECT_FILE = "valance.json";
|
|
9
10
|
export const WEB_FILE = "valance.web.json";
|
|
11
|
+
export const DESKTOP_FILE = "valance.desktop.json";
|
|
10
12
|
/** The only version of both files there is. */
|
|
11
13
|
export const CONFIG_VERSION = 1;
|
|
12
14
|
const read = (root, file) => {
|
|
@@ -90,6 +92,32 @@ const api = (value) => {
|
|
|
90
92
|
}
|
|
91
93
|
return undefined;
|
|
92
94
|
};
|
|
95
|
+
const record = (value) => {
|
|
96
|
+
const redact = value?.redact;
|
|
97
|
+
if (typeof value !== "object" ||
|
|
98
|
+
value === null ||
|
|
99
|
+
Array.isArray(value) ||
|
|
100
|
+
Object.keys(value).some((key) => key !== "redact") ||
|
|
101
|
+
typeof redact !== "object" ||
|
|
102
|
+
redact === null ||
|
|
103
|
+
Array.isArray(redact)) {
|
|
104
|
+
return 'must be an object such as { "redact": { "scope": [["password"]], "commands": ["login/type"] } }';
|
|
105
|
+
}
|
|
106
|
+
const { scope, commands, ...others } = redact;
|
|
107
|
+
if (Object.keys(others).length > 0) {
|
|
108
|
+
return `"redact" has unknown settings ${Object.keys(others)
|
|
109
|
+
.map((key) => `"${key}"`)
|
|
110
|
+
.join(", ")} (known: scope, commands)`;
|
|
111
|
+
}
|
|
112
|
+
if (scope !== undefined &&
|
|
113
|
+
(!Array.isArray(scope) || scope.some((path) => !Array.isArray(path) || path.length === 0 || path.some((step) => typeof step !== "string" || step === "")))) {
|
|
114
|
+
return '"redact.scope" must be a list of paths, each a non-empty list of field names ("*" is any list index or field): [["password"], ["people", "*", "pin"]]';
|
|
115
|
+
}
|
|
116
|
+
if (commands !== undefined && (!Array.isArray(commands) || commands.some((name) => typeof name !== "string" || !/^[\w-]+\/[\w-]+$/.test(name)))) {
|
|
117
|
+
return '"redact.commands" must be a list of "component/name" commands whose parameters are not recorded: ["login/type"]';
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
};
|
|
93
121
|
/** Reads and validates the project's configuration. Every problem in both files is reported together. */
|
|
94
122
|
export const loadConfig = (root) => {
|
|
95
123
|
const project = read(root, PROJECT_FILE);
|
|
@@ -97,8 +125,10 @@ export const loadConfig = (root) => {
|
|
|
97
125
|
throw new AppError([diagnostic(PROJECT_FILE, 0, "config-missing", `there is no ${PROJECT_FILE} in ${root}`)]);
|
|
98
126
|
}
|
|
99
127
|
const web = read(root, WEB_FILE);
|
|
128
|
+
const desktop = read(root, DESKTOP_FILE);
|
|
100
129
|
const problems = [
|
|
101
|
-
...check(
|
|
130
|
+
...(desktop === undefined ? [] : check(DESKTOP_FILE, desktop.text, desktop.value, { $schema: schema, version }, [])),
|
|
131
|
+
...check(PROJECT_FILE, project.text, project.value, { $schema: schema, version, name: text, title, record, budgets: budgetProblems }, ["name"]),
|
|
102
132
|
...(web === undefined ? [] : check(WEB_FILE, web.text, web.value, { $schema: schema, version, base, url, headers: flag, announce: flag, api }, [])),
|
|
103
133
|
];
|
|
104
134
|
if (problems.length > 0) {
|
|
@@ -108,9 +138,12 @@ export const loadConfig = (root) => {
|
|
|
108
138
|
const named = project.value;
|
|
109
139
|
return {
|
|
110
140
|
name: named.name,
|
|
141
|
+
targets: desktop === undefined ? ["web"] : web === undefined ? ["desktop"] : ["web", "desktop"],
|
|
111
142
|
title: named.title ?? DEFAULT_TITLE,
|
|
112
143
|
web: { base: given.base ?? "/", url: given.url ?? "", headers: given.headers ?? true, announce: given.announce ?? true },
|
|
113
144
|
api: { base: given.api?.base ?? "", proxy: given.api?.proxy ?? "" },
|
|
145
|
+
budgets: named.budgets,
|
|
146
|
+
record: { redact: { scope: named.record?.redact.scope ?? [], commands: named.record?.redact.commands ?? [] } },
|
|
114
147
|
};
|
|
115
148
|
};
|
|
116
149
|
export const siteInfo = (config) => ({
|
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,58 @@ 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
|
+
if (declared.name !== undefined) {
|
|
75
|
+
const named = props[declared.name];
|
|
76
|
+
if (named === undefined && !problems.some((each) => each.name === declared.name)) {
|
|
77
|
+
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` });
|
|
78
|
+
}
|
|
79
|
+
else if (named !== undefined && (named.type.kind !== "string" || !named.required)) {
|
|
80
|
+
problems.push({
|
|
81
|
+
kind: "props",
|
|
82
|
+
name: declared.name,
|
|
83
|
+
reason: "",
|
|
84
|
+
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`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { entry: { props, events, commands: {}, scope, ...(unit === undefined ? {} : { unit }) }, problems };
|
|
89
|
+
};
|
|
90
|
+
/** 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. */
|
|
91
|
+
const unitEntry = (declared, props, problems) => {
|
|
92
|
+
const state = {};
|
|
93
|
+
const commands = {};
|
|
94
|
+
for (const [name, field] of Object.entries(declared.fields)) {
|
|
95
|
+
try {
|
|
96
|
+
const type = typeOf(field.schema);
|
|
97
|
+
const controllable = declared.controllable.includes(name);
|
|
98
|
+
if (!Schema.is(field.schema)(field.initial)) {
|
|
99
|
+
throw new Error("its default is not a value of its type");
|
|
100
|
+
}
|
|
101
|
+
if (controllable && props[name] === undefined) {
|
|
102
|
+
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");
|
|
103
|
+
}
|
|
104
|
+
state[name] = {
|
|
105
|
+
type: type.kind === "optional" ? type.type : type,
|
|
106
|
+
default: Schema.encodeSync(field.schema)(field.initial),
|
|
107
|
+
...(controllable ? { controllable: true } : {}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
problems.push({ kind: "unit", name, reason: reasonOf(error) });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const [name, command] of Object.entries(declared.commands)) {
|
|
115
|
+
try {
|
|
116
|
+
const parameters = Object.entries(command.input?.fields ?? {}).map(([parameter, schema]) => {
|
|
117
|
+
const type = typeOf(schema);
|
|
118
|
+
return { name: parameter, type: type.kind === "optional" ? type.type : type };
|
|
119
|
+
});
|
|
120
|
+
commands[name] = { parameters };
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
problems.push({ kind: "unit", name, reason: reasonOf(error) });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { state, commands };
|
|
45
127
|
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Dev, DevOptions } from "./dev.js";
|
|
2
|
+
/** Whether a changed file (relative to the project) is one the application is made of. */
|
|
3
|
+
export declare const mattersToTheEngine: (file: string) => boolean;
|
|
4
|
+
export interface DesktopDev extends Dev {
|
|
5
|
+
/** Resolves when the window is closed by the person (not by a rebuild). */
|
|
6
|
+
readonly closed: Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export declare const devDesktop: (root: string, options?: DevOptions) => Promise<DesktopDev>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { watch } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { build } from "./build.js";
|
|
7
|
+
import { AppError, format } from "./diagnostics.js";
|
|
8
|
+
/** How long files must be quiet before a change is built (ms). */
|
|
9
|
+
const QUIET = 150;
|
|
10
|
+
/** Whether a changed file (relative to the project) is one the application is made of. */
|
|
11
|
+
export const mattersToTheEngine = (file) => {
|
|
12
|
+
const name = file.split("\\").join("/");
|
|
13
|
+
return /^(content|src|public)\//.test(name) || /^valance(\.\w+)?\.json$/.test(name);
|
|
14
|
+
};
|
|
15
|
+
export const devDesktop = async (root, options = {}) => {
|
|
16
|
+
const log = options.log ?? ((line) => void console.log(line));
|
|
17
|
+
const program = process.env["VLX_WINDOW"] ?? "vlx-window";
|
|
18
|
+
let window;
|
|
19
|
+
let stopping = false;
|
|
20
|
+
let working = Promise.resolve();
|
|
21
|
+
let timer;
|
|
22
|
+
let leave = () => undefined;
|
|
23
|
+
const closed = new Promise((resolve) => {
|
|
24
|
+
leave = resolve;
|
|
25
|
+
});
|
|
26
|
+
const reloadDir = mkdtempSync(join(tmpdir(), "vlx-reload-"));
|
|
27
|
+
const reloadFile = join(reloadDir, "reload");
|
|
28
|
+
let reloads = 0;
|
|
29
|
+
writeFileSync(reloadFile, "0");
|
|
30
|
+
/** Tells a running window the engine was rebuilt: a new modification time on the file it watches. */
|
|
31
|
+
const touch = () => {
|
|
32
|
+
reloads += 1;
|
|
33
|
+
writeFileSync(reloadFile, String(reloads));
|
|
34
|
+
};
|
|
35
|
+
const stopWindow = async () => {
|
|
36
|
+
const running = window;
|
|
37
|
+
window = undefined;
|
|
38
|
+
if (running !== undefined && running.exitCode === null) {
|
|
39
|
+
const exited = new Promise((resolve) => running.once("exit", () => resolve()));
|
|
40
|
+
running.kill();
|
|
41
|
+
await exited;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const openWindow = (engine) => {
|
|
45
|
+
const child = spawn(program, [], { stdio: "inherit", env: { ...process.env, VLX_ENGINE: `${process.execPath} ${engine}`, VLX_RELOAD: reloadFile } });
|
|
46
|
+
window = child;
|
|
47
|
+
child.once("error", (error) => {
|
|
48
|
+
window = undefined;
|
|
49
|
+
log(error.code === "ENOENT"
|
|
50
|
+
? `vlx: no window program "${program}": install it with \`cargo install --git https://github.com/ValanceX/Port valancex-port-desktop --bin vlx-window\`, or name it in VLX_WINDOW`
|
|
51
|
+
: `vlx: the window could not start: ${error.message}`);
|
|
52
|
+
});
|
|
53
|
+
child.once("exit", () => {
|
|
54
|
+
if (window === child && !stopping) {
|
|
55
|
+
window = undefined;
|
|
56
|
+
log("vlx: the window was closed");
|
|
57
|
+
leave();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
const rebuild = async () => {
|
|
62
|
+
try {
|
|
63
|
+
const built = await build(root, { targets: ["desktop"] });
|
|
64
|
+
options.onStatus?.([]);
|
|
65
|
+
if (stopping) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (window !== undefined && window.exitCode === null) {
|
|
69
|
+
touch();
|
|
70
|
+
log("vlx: built; the window restarts the engine");
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
openWindow(join(built.dir, "desktop", "engine.mjs"));
|
|
74
|
+
log("vlx: built; the window is open");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error instanceof AppError) {
|
|
79
|
+
options.onStatus?.(error.diagnostics);
|
|
80
|
+
error.diagnostics.forEach((each) => log(format(each)));
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
log(`vlx: the build failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const queue = () => {
|
|
88
|
+
working = working.then(rebuild);
|
|
89
|
+
};
|
|
90
|
+
const watcher = watch(root, { recursive: true }, (_event, file) => {
|
|
91
|
+
if (file !== null && mattersToTheEngine(file)) {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
timer = setTimeout(queue, QUIET);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
queue();
|
|
97
|
+
await working;
|
|
98
|
+
return {
|
|
99
|
+
url: "window",
|
|
100
|
+
closed,
|
|
101
|
+
settled: async () => {
|
|
102
|
+
await working;
|
|
103
|
+
},
|
|
104
|
+
close: async () => {
|
|
105
|
+
stopping = true;
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
watcher.close();
|
|
108
|
+
await working;
|
|
109
|
+
await stopWindow();
|
|
110
|
+
rmSync(reloadDir, { recursive: true, force: true });
|
|
111
|
+
leave();
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
};
|
package/dist/dev.d.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import type { Diagnostic } from "./diagnostics.js";
|
|
2
|
+
import type { TargetId } from "./targets.js";
|
|
2
3
|
export interface DevOptions {
|
|
4
|
+
/** Which target to serve, for a project of more than one. */
|
|
5
|
+
readonly target?: TargetId;
|
|
3
6
|
readonly port?: number;
|
|
4
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;
|
|
5
13
|
/** Called with each report (a rebuild, a mistake); defaults to the console. */
|
|
6
14
|
readonly log?: (line: string) => void;
|
|
7
15
|
/** Called with every mistake still open, after the first build and after each rebuild: empty when the project is sound. */
|