@valancex/cli 0.13.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/README.md +33 -6
- package/dist/budgets.json +175 -0
- package/dist/build.d.ts +8 -0
- package/dist/build.js +33 -7
- package/dist/cli.js +75 -4
- package/dist/compile.js +83 -12
- package/dist/config.d.ts +10 -0
- package/dist/config.js +30 -1
- package/dist/contract.d.ts +18 -1
- package/dist/contract.js +85 -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 +40 -17
- package/dist/package.js +3 -2
- 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 +35 -0
- package/dist/theme.js +44 -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 +1 -1
- package/package.json +13 -8
- package/schemas/valance.schema.json +71 -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/compile.js
CHANGED
|
@@ -8,12 +8,13 @@ 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
15
|
import { namesOf, webVocabulary, WHOLE } from "@valancex/common/internal";
|
|
16
|
-
import {
|
|
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";
|
|
@@ -69,16 +70,29 @@ export const templatesOf = (root, author, vocabulary = webVocabulary, views = BU
|
|
|
69
70
|
for (const each of overrides) {
|
|
70
71
|
const name = nameOf(each.template.mprx);
|
|
71
72
|
if (!builtInNames.has(name) && name !== ROOT) {
|
|
72
|
-
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`);
|
|
73
91
|
}
|
|
74
92
|
}
|
|
75
93
|
const files = new Map();
|
|
76
94
|
for (const node of declared) {
|
|
77
95
|
const place = at(node.file);
|
|
78
|
-
if (files.has(node.name) && files.get(node.name) !== node.file) {
|
|
79
|
-
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`);
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
82
96
|
if (namesOf(vocabulary).includes(node.name)) {
|
|
83
97
|
problem(place, "template-primitive", `<${node.name}> is already a tag the framework draws as an element; name the file something else`);
|
|
84
98
|
continue;
|
|
@@ -98,7 +112,7 @@ export const templatesOf = (root, author, vocabulary = webVocabulary, views = BU
|
|
|
98
112
|
const claimed = new Set(files.values());
|
|
99
113
|
const whole = (author?.[WHOLE] ?? author);
|
|
100
114
|
for (const parts of [whole, ...Object.values(whole?.targets ?? {})]) {
|
|
101
|
-
for (const node of
|
|
115
|
+
for (const node of declaredOf(parts?.layout, parts?.override ?? [], parts?.components ?? [])) {
|
|
102
116
|
claimed.add(node.file);
|
|
103
117
|
}
|
|
104
118
|
}
|
|
@@ -143,8 +157,19 @@ export const manifestOf = (author, vocabulary = webVocabulary) => {
|
|
|
143
157
|
if (!("tags" in vocabulary)) {
|
|
144
158
|
return { ...derived, components: { ...vocabulary.components, ...derived.components } };
|
|
145
159
|
}
|
|
146
|
-
//
|
|
160
|
+
// What the vocabulary declares of a tag's props and events (the specification's) settles what the table alone derived.
|
|
147
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.
|
|
148
173
|
for (const [tag, names] of Object.entries(vocabulary.optional ?? {})) {
|
|
149
174
|
const entry = components[tag];
|
|
150
175
|
if (entry !== undefined) {
|
|
@@ -188,8 +213,10 @@ const modelOf = (root, author, tree, vocabulary) => {
|
|
|
188
213
|
const { entry, problems: bad } = entryOf(node.interface);
|
|
189
214
|
const site = declarations.get(node.file);
|
|
190
215
|
for (const each of bad) {
|
|
191
|
-
const place = site?.member(each.kind, each.name) ?? site?.call ?? site?.imported ?? { file: ENTRY_FILE, line: 1, column: 1 };
|
|
192
|
-
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));
|
|
193
220
|
}
|
|
194
221
|
entries[node.name] = entry;
|
|
195
222
|
}
|
|
@@ -198,6 +225,32 @@ const modelOf = (root, author, tree, vocabulary) => {
|
|
|
198
225
|
}
|
|
199
226
|
return { ...derived, components: { ...derived.components, ...entries } };
|
|
200
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
|
+
};
|
|
201
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. */
|
|
202
255
|
export const compileViews = async (root, author, vocabulary = webVocabulary, views = BUILT_IN) => {
|
|
203
256
|
const { all, tree } = templatesOf(root, author, vocabulary, views);
|
|
@@ -215,12 +268,30 @@ export const compileViews = async (root, author, vocabulary = webVocabulary, vie
|
|
|
215
268
|
const found = [
|
|
216
269
|
...(result.components ?? []).flatMap((entry) => entry.diagnostics.diagnostics
|
|
217
270
|
.filter((each) => each.severity === "error")
|
|
218
|
-
.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))),
|
|
219
274
|
...(result.assembly?.diagnostics ?? []).map((each) => diagnostic(each.path ?? "program", each.span?.start.line ?? 0, each.code, each.message, each.span?.start.column ?? 0)),
|
|
220
275
|
];
|
|
221
276
|
throw new AppError(found.map((each) => relocate(each, root)));
|
|
222
277
|
}
|
|
223
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
|
+
}
|
|
224
295
|
const enriched = program.model;
|
|
225
296
|
const manifestOfProgram = typeof enriched === "string" ? JSON.parse(enriched) : enriched;
|
|
226
297
|
return {
|
package/dist/config.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
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";
|
|
@@ -23,6 +24,15 @@ export interface Config {
|
|
|
23
24
|
readonly base: string;
|
|
24
25
|
readonly proxy: string;
|
|
25
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
|
+
};
|
|
26
36
|
}
|
|
27
37
|
/** Reads and validates the project's configuration. Every problem in both files is reported together. */
|
|
28
38
|
export declare const loadConfig: (root: string) => Config;
|
package/dist/config.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
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";
|
|
@@ -91,6 +92,32 @@ const api = (value) => {
|
|
|
91
92
|
}
|
|
92
93
|
return undefined;
|
|
93
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
|
+
};
|
|
94
121
|
/** Reads and validates the project's configuration. Every problem in both files is reported together. */
|
|
95
122
|
export const loadConfig = (root) => {
|
|
96
123
|
const project = read(root, PROJECT_FILE);
|
|
@@ -101,7 +128,7 @@ export const loadConfig = (root) => {
|
|
|
101
128
|
const desktop = read(root, DESKTOP_FILE);
|
|
102
129
|
const problems = [
|
|
103
130
|
...(desktop === undefined ? [] : check(DESKTOP_FILE, desktop.text, desktop.value, { $schema: schema, version }, [])),
|
|
104
|
-
...check(PROJECT_FILE, project.text, project.value, { $schema: schema, version, name: text, title }, ["name"]),
|
|
131
|
+
...check(PROJECT_FILE, project.text, project.value, { $schema: schema, version, name: text, title, record, budgets: budgetProblems }, ["name"]),
|
|
105
132
|
...(web === undefined ? [] : check(WEB_FILE, web.text, web.value, { $schema: schema, version, base, url, headers: flag, announce: flag, api }, [])),
|
|
106
133
|
];
|
|
107
134
|
if (problems.length > 0) {
|
|
@@ -115,6 +142,8 @@ export const loadConfig = (root) => {
|
|
|
115
142
|
title: named.title ?? DEFAULT_TITLE,
|
|
116
143
|
web: { base: given.base ?? "/", url: given.url ?? "", headers: given.headers ?? true, announce: given.announce ?? true },
|
|
117
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 ?? [] } },
|
|
118
147
|
};
|
|
119
148
|
};
|
|
120
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
|
};
|
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,35 @@ 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 { themeOf } from "./theme.js";
|
|
24
25
|
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
|
|
26
|
+
/** 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
|
|
27
|
+
* 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
28
|
const CORE_CSS = `/* Generated by \`valance\`. The web's realization of the portable core's layout components. */
|
|
27
|
-
vlx-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
@layer vlx-library, vlx-theme, vlx-app;
|
|
30
|
+
|
|
31
|
+
@layer vlx-library {
|
|
32
|
+
vlx-column {
|
|
33
|
+
display: flex;
|
|
34
|
+
flex-direction: column;
|
|
35
|
+
gap: 0.5rem;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
vlx-row {
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-direction: row;
|
|
41
|
+
align-items: center;
|
|
42
|
+
gap: 0.5rem;
|
|
43
|
+
}
|
|
32
44
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
gap: 0.5rem;
|
|
45
|
+
/* 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. */
|
|
46
|
+
[data-vlx="starting"] :is(button, input, select, textarea, summary, [role="button"], label) {
|
|
47
|
+
cursor: progress;
|
|
48
|
+
}
|
|
38
49
|
}
|
|
39
50
|
`;
|
|
51
|
+
/** 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. */
|
|
52
|
+
const themeStylesheet = (root) => themeOf(root).css ?? "/* The theme has a problem: run `vlx check`. */\n";
|
|
40
53
|
/** The stylesheets the web loads: every one under `src/` except those in another target's directory (`src/desktop/`). */
|
|
41
54
|
const stylesheets = (root) => existsSync(join(root, SOURCE_DIR))
|
|
42
55
|
? readdirSync(join(root, SOURCE_DIR), { recursive: true, encoding: "utf8" })
|
|
@@ -46,20 +59,22 @@ const stylesheets = (root) => existsSync(join(root, SOURCE_DIR))
|
|
|
46
59
|
.map((file) => `${SOURCE_DIR}/${file}`)
|
|
47
60
|
: [];
|
|
48
61
|
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.
|
|
62
|
+
const clientSource = (root, config, record = false) => `// Generated by \`valance\`. Do not edit: it is rewritten whenever the project changes.
|
|
50
63
|
// @ts-nocheck
|
|
51
64
|
import { start } from "@valancex/common/client";
|
|
52
65
|
import content from "./content.json";
|
|
53
66
|
import program from "./${programFile(config, "web")}";
|
|
67
|
+
import "./theme.css";
|
|
54
68
|
import "./core.css";
|
|
55
69
|
${hasEntry(root) ? `import author from "../${ENTRY_FILE}";\n` : "const author = undefined;\n"}${stylesheets(root)
|
|
56
70
|
.map((file) => `import "../${file}";\n`)
|
|
57
71
|
.join("")}
|
|
58
|
-
await start({ content, program, author });
|
|
72
|
+
${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
73
|
`;
|
|
60
74
|
const desktopSource = (root, config) => `// Generated by \`valance\`. Do not edit: it is rewritten whenever the project changes.
|
|
61
75
|
// @ts-nocheck
|
|
62
76
|
import { start } from "@valancex/common/desktop";
|
|
77
|
+
import theme from "./theme.desktop.json";
|
|
63
78
|
import { readFileSync } from "node:fs";
|
|
64
79
|
import { fileURLToPath } from "node:url";
|
|
65
80
|
import content from "./content.json";
|
|
@@ -69,7 +84,7 @@ ${hasEntry(root) ? `import author from "../${ENTRY_FILE}";\n` : "const author =
|
|
|
69
84
|
const wasm = new Uint8Array(readFileSync(new URL("./mesh-runtime.wasm", import.meta.url)));
|
|
70
85
|
// (and the project's public/ files, which images are drawn from)
|
|
71
86
|
const assets = fileURLToPath(new URL("./public/", import.meta.url));
|
|
72
|
-
await start({ content, program, author, wasm, assets });
|
|
87
|
+
await start({ content, program, author, wasm, assets, theme: theme ?? undefined });
|
|
73
88
|
// the window is closed: nothing the application left open keeps the engine alive
|
|
74
89
|
process.exit(0);
|
|
75
90
|
`;
|
|
@@ -113,6 +128,8 @@ export const generateProgram = async (root, author, config, only) => {
|
|
|
113
128
|
problems.push(...error.diagnostics);
|
|
114
129
|
}
|
|
115
130
|
}
|
|
131
|
+
// 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
|
|
132
|
+
problems.push(...(themeOf(root).custom ? themeOf(root).diagnostics : []));
|
|
116
133
|
if (problems.length > 0) {
|
|
117
134
|
throw new AppError(problems);
|
|
118
135
|
}
|
|
@@ -169,17 +186,23 @@ const writeTemplateTypes = (root, all) => {
|
|
|
169
186
|
}
|
|
170
187
|
};
|
|
171
188
|
/** 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) => {
|
|
189
|
+
export const generateEntries = (root, config, options) => {
|
|
173
190
|
ensureGeneratedDir(root);
|
|
174
191
|
const targets = config?.targets ?? ["web"];
|
|
175
192
|
return [
|
|
176
193
|
...(targets.includes("web")
|
|
177
194
|
? [
|
|
178
195
|
writeIfChanged(generated(root, "core.css"), CORE_CSS),
|
|
179
|
-
writeIfChanged(generated(root, "
|
|
196
|
+
writeIfChanged(generated(root, "theme.css"), themeStylesheet(root)),
|
|
197
|
+
writeIfChanged(generated(root, "client.ts"), clientSource(root, config, options?.record === true)),
|
|
180
198
|
writeIfChanged(generated(root, "server.ts"), serverSource(root, config)),
|
|
181
199
|
]
|
|
182
200
|
: []),
|
|
183
|
-
...(targets.includes("desktop")
|
|
201
|
+
...(targets.includes("desktop")
|
|
202
|
+
? [
|
|
203
|
+
writeIfChanged(generated(root, "theme.desktop.json"), `${JSON.stringify(themeOf(root).desktop ?? null)}\n`),
|
|
204
|
+
writeIfChanged(generated(root, "desktop.ts"), desktopSource(root, config)),
|
|
205
|
+
]
|
|
206
|
+
: []),
|
|
184
207
|
].some(Boolean);
|
|
185
208
|
};
|