@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 CHANGED
@@ -153,7 +153,7 @@ Templates are MPRX. Conditional and repeated parts are blocks: `{#if cond}…{:e
153
153
  | `prev`, `next` | `{ href, label }`, empty `href` when there is none |
154
154
  | `viewport` | How much room the page has: `"compact"` (under 600 px), `"medium"` (600 to 839) or `"expanded"` (840 and up), kept current as the window changes. `{#if viewport == "compact"}` chooses markup for a phone. The server renders `expanded`; the browser corrects it as soon as it starts. |
155
155
 
156
- Tags you may use: `shell` (`class`, `theme`), `div`, `header`, `footer`, `nav` (`class`, `label`), `aside`, `section`, `article`, `main` (`id`, `tabindex`), `h1`–`h3` (`id`), `para`, `quote`, `list`, `olist`, `item`, `pre`, `code`, `strong`, `em`, `span`, `image`, `a` (`href`), `link` (`href`, `class`, `current`), `button` (`class`, `pressed`, `on.click`), plus your components and the built-ins (`blocks`, `block`, `heading`, `list-block`, `list-items`, `runs`, `inline`). A prop a tag declares must be written wherever the tag is used; that is why there are many narrow tags rather than a few wide ones. The attribute each prop becomes is in [`src/primitives.ts`](src/primitives.ts).
156
+ Tags you may use: `shell` (`class`, `theme`), `div`, `header`, `footer`, `nav` (`class`, `label`), `aside`, `section`, `article`, `main` (`id`, `tabindex`), `h1`–`h3` (`id`), `paragraph`, `blockquote`, `list`, `olist`, `listitem`, `pre`, `code`, `strong`, `em`, `span`, `image`, `a` (`href`), `link` (`href`, `class`, `current`), `button` (`class`, `pressed`, `on.click`), `toggle`, `checkbox`, `textfield`, `radiogroup`, `radio`, `label`, `status`, `alert` (the full list, with roles and names, is [the primitives](../../docs/use/primitives.md)), plus your components and the built-ins (`blocks`, `block`, `heading`, `list-block`, `list-items`, `runs`, `inline`). The tags `para`, `item` and `quote` are now `paragraph`, `listitem` and `blockquote` (the ARIA names, which the Desktop already used); a template that writes the old one is told the new one. A prop a tag declares must be written wherever the tag is used; that is why there are many narrow tags rather than a few wide ones. The attribute each prop becomes is in [`src/primitives.ts`](src/primitives.ts).
157
157
 
158
158
  ## The components
159
159
 
@@ -173,20 +173,22 @@ export default defineApp({
173
173
 
174
174
  ```ts
175
175
  // src/theme-toggle.ts: the component states its own interface, where it is defined
176
- import { component } from "@valancex/common";
176
+ import { component, field } from "@valancex/common";
177
177
  import { Schema } from "effect";
178
178
  import template from "./theme-toggle.mprx";
179
179
 
180
180
  export default component(template, {
181
- props: { theme: Schema.Literal("light", "dark") },
181
+ props: { theme: field(Schema.Literal("light", "dark"), "light") },
182
182
  events: { toggle: Schema.Void },
183
183
  });
184
184
  ```
185
185
 
186
186
  - `import x from "./x.mprx"` names a template file; `component(x, …)` defines it. The tag is the file's name (`theme-toggle.mprx` is `<theme-toggle>`). Register it once, in `components`.
187
187
  - **Which components a template uses is read from the template.** Write `<theme-toggle … />` in the layout (or in another component) and it is used; there is no list of uses to keep in step. A registered component no template writes yet is not part of the program, and is not an error. A file under `src/` that is not registered is an error (`template-unused`).
188
- - **The contract.** `props` maps a prop name to its Effect Schema and `events` an event name to its payload Schema (`Schema.Void` is no payload). Declare either and the contract is exactly what you wrote (an omitted side is none), overriding inference; declare neither and MESH infers it from the template and where it is used. Schemas are converted by the same rules as state; a prop is optional when its schema admits `undefined`, and props have no defaults. A schema with no MESH type is a diagnostic at the declaration (`component-schema`). MESH reports an undeclared or missing prop, a prop of the wrong type and a handler for an undeclared event at the use. The layout takes no contract (`layout-contract`). That declared interface is where an editor takes you from `on.toggle=` or `theme=` at a use, and where *Find All References* starts.
188
+ - **The contract.** `props` maps a prop name to its Effect Schema and `events` an event name to its payload Schema (`Schema.Void` is no payload). Declare either and the contract is exactly what you wrote (an omitted side is none), overriding inference; declare neither and MESH infers it from the template and where it is used. Schemas are converted by the same rules as state; a prop is optional when its schema admits `undefined`. A **string-literal union is a closed set**: `Schema.Literal("info", "danger")` becomes the MESH type `"info" | "danger"`, and MESH reports any other literal written for the prop at the use (`unknown-variant`, with a did-you-mean when one is close); a number or boolean literal keeps its primitive type. A prop gets a **default** with `field(schema, value)` (`props: { tone: field(Schema.Literal("info", "danger"), "info") }`): a use of the component that leaves the prop out gets the value, the prop may then be left out, and TypeScript refuses a default that is not a member. A default is for a component with a template; on a primitive it is ignored. A schema with no MESH type, for example a union of a string literal and a number, or a default that is not a value of the prop's type, is a diagnostic at the declaration (`component-schema`). Both need MESH 0.14 or later (`@valancex/mesh-compiler` `^0.14.0`, which the packages depend on). MESH reports an undeclared or missing prop, a prop of the wrong type and a handler for an undeclared event at the use. The layout takes no contract (`layout-contract`). That declared interface is where an editor takes you from `on.toggle=` or `theme=` at a use, and where *Find All References* starts.
189
189
  - `override: [component(runs, { props: { spans: … } })]` replaces a built-in (`runs`, `heading-block`, …) with your template of the same name.
190
+ - **One MESH runtime.** `vlx build` (never `vlx dev`) stops with `runtime-duplicate` when the packages resolve more than one copy of `@valancex/mesh-runtime`: each copy is a 765 KB WebAssembly module (171 KB of brotli) that the site would ship and the browser fetch. The message names each copy, its version and who uses it, and says to add an `overrides` entry (npm; `pnpm.overrides`, or `resolutions` with yarn) so there is one. `@valancex/common`, `@valancex/valance` and `@valancex/nexus` accept the same range (`^0.12.0`) so a plain install has one.
191
+ - **One name is one component.** Two registered files with the same name (for example `a/disclosure.mprx` and `b/disclosure.mprx`, whether yours or from two libraries) are `template-duplicate`, and a registered component named like a built-in is `template-builtin` unless it is in `override`: neither silently replaces the other, and the first one listed does not win. Rename one of the files; qualified ids for a library's components (`acme-ui/disclosure`, decision K14) are not built yet. `override` is only for built-ins: for any other name it is `override-unknown`, and the component belongs in `components`.
190
192
  - `layout` is optional; without it the built-in layout draws the page. Its template must be named `layout.mprx`.
191
193
 
192
194
  ### Children and slots
@@ -202,13 +204,38 @@ A component places the content its caller writes inside it with `<mesh-slot />`,
202
204
 
203
205
  <!-- where it is used -->
204
206
  <card>
205
- <mesh-fill slot="head"><para class="title">Title</para></mesh-fill>
206
- <para class="body">The body goes to the unnamed slot.</para>
207
+ <mesh-fill slot="head"><paragraph class="title">Title</paragraph></mesh-fill>
208
+ <paragraph class="body">The body goes to the unnamed slot.</paragraph>
207
209
  </card>
208
210
  ```
209
211
 
210
212
  The children are the caller's: `{x}` in them is the caller's `x`, never the card's. `vlx generate component` writes the single, unnamed slot for you. The rules (where a slot may be, what a fill is, the errors) are in MESH's [templates manual](https://github.com/ValanceX/Mesh/blob/main/docs/manual/templates.md#children-and-the-slot).
211
213
 
214
+ ### Snippets as props
215
+
216
+ A prop can be a piece of template (MESH 0.14). Declare it with `snippet(…)`, the types of its parameters in order, and read it in the component's template with `{@render name(args)}`:
217
+
218
+ ```
219
+ // src/panel.ts and src/item-list.ts
220
+ component(panel, { props: { title: Schema.String, children: snippet() } });
221
+ component(itemList, { props: { items: Schema.Array(Item), row: snippet(Item, Schema.String) } });
222
+ ```
223
+
224
+ ```
225
+ <!-- src/panel.mprx -->
226
+ <section><strong>{title}</strong>{@render children()}</section>
227
+
228
+ <!-- where they are used: loose content is `children`; a {#snippet} that is a direct child is the prop of its name -->
229
+ <panel title="Totals"><span class="count">{count}</span></panel>
230
+ <item-list items={items} label="row">{#snippet row(item, tag)}<span class="row">{tag}:{item.name}</span>{/snippet}</item-list>
231
+ ```
232
+
233
+ - `children` is reserved for the content between the tags: it must be `snippet()` (no parameters), and `children={…}` with content is `children-conflict`. A snippet is not data: it has no `field` default, is never optional, and is not a record field or an event's payload.
234
+ - A snippet reads the scope of the template that **wrote** it (`count` above is the layout's, not the panel's), so a node that renders one is redrawn when that scope changes though its own props did not.
235
+ - The arity and the types are checked: `snippet-arity-mismatch` in the composite's template when `{@render row(item)}` passes too few, and a member the parameter does not have is reported where the snippet is written.
236
+ - **Optional.** `snippet(…).optional()` (and `children: snippet().optional()`) is one an occurrence may leave out: it is `snippet<…>?` in the model, and an absent one draws nothing. Rendering it is `possibly-absent-snippet` unless it is inside the first branch of an `{#if has(name)}` (`&&` allowed), so `{#if has(header)}{@render header()}{/if}` is how a template uses one.
237
+ - Slots (`mesh-slot`, `mesh-fill`) keep working for a component that does not declare `children`.
238
+
212
239
  ### Typed template imports
213
240
 
214
241
  `vlx sync` (and `check`, `dev` and `build`) write `.valance/types/<template>.d.mprx.ts`, so a component's declaration is checked against the names its template uses: `component(template, { props: { thme: … } })` is a compile-time error. This needs two options in your `tsconfig.json`:
@@ -0,0 +1,175 @@
1
+ {
2
+ "version": 1,
3
+ "about": "The ecosystem's budgets, as data (Bet 3; decisions W1 to W24). `project` is what `vlx build` holds an application to when its valance.json sets none (W14). `framework` is what the release check holds the reference applications and the runtime to (scripts/check-budgets.mjs, over the output of scripts/measure-footprint.mjs). Brotli bytes, because that is what travels (W2).",
4
+ "rule": "A ceiling is the baseline plus 5 percent, rounded up to the next 100 bytes (the next 10 under 1,000), then ratcheted down (W3). A ceiling above that needs `analysis`: the path of a size analysis that says why (W23a), and a decision row. The check refuses a raised ceiling without one.",
5
+ "project": {
6
+ "budgets": [
7
+ {
8
+ "type": "initial",
9
+ "warning": 105200,
10
+ "error": 210400
11
+ },
12
+ {
13
+ "type": "runtime",
14
+ "error": 192900
15
+ }
16
+ ],
17
+ "note": "W14: the framework's own start-up ceilings. `initial` warns above the largest reference application's JavaScript and CSS ceiling (docs-app) and fails at twice that; `runtime` is the WebAssembly runtime, the same for every application, so a growth of it fails the build of whoever upgrades."
18
+ },
19
+ "framework": {
20
+ "baseline": "docs/direction/baselines/2026-10-24-wp8-before.md",
21
+ "ceilings": [
22
+ {
23
+ "id": "counter.javascript",
24
+ "what": "Counter application, JavaScript, brotli",
25
+ "path": "counter.deduped.js.brotli",
26
+ "unit": "bytes",
27
+ "baseline": 93722,
28
+ "ceiling": 98500,
29
+ "previousCeiling": 92400,
30
+ "analysis": "docs/direction/evidence/measurement/size-2026-10-24.md"
31
+ },
32
+ {
33
+ "id": "counter.css",
34
+ "what": "Counter application, CSS, brotli",
35
+ "path": "counter.deduped.css.brotli",
36
+ "unit": "bytes",
37
+ "baseline": 1053,
38
+ "ceiling": 1200
39
+ },
40
+ {
41
+ "id": "runtime.wasm",
42
+ "what": "The WebAssembly runtime, brotli",
43
+ "path": "counter.deduped.wasm.brotli",
44
+ "unit": "bytes",
45
+ "baseline": 183629,
46
+ "ceiling": 192900,
47
+ "previousCeiling": 179400,
48
+ "analysis": "docs/direction/evidence/measurement/size-2026-10-24.md"
49
+ },
50
+ {
51
+ "id": "counter.transferred",
52
+ "what": "Counter application, everything the browser transfers, brotli",
53
+ "path": "browser.deduped.noThrottling.bytesTransferredBrotli",
54
+ "unit": "bytes",
55
+ "baseline": 279594,
56
+ "ceiling": 293600,
57
+ "previousCeiling": 274100,
58
+ "analysis": "docs/direction/evidence/measurement/size-2026-10-24.md"
59
+ },
60
+ {
61
+ "id": "docsApp.javascript",
62
+ "what": "docs-app, JavaScript, brotli",
63
+ "path": "docsApp.js.brotli",
64
+ "unit": "bytes",
65
+ "baseline": 98520,
66
+ "ceiling": 103500,
67
+ "previousCeiling": 97400,
68
+ "analysis": "docs/direction/evidence/measurement/size-2026-10-24.md"
69
+ },
70
+ {
71
+ "id": "docsApp.css",
72
+ "what": "docs-app, CSS, brotli",
73
+ "path": "docsApp.css.brotli",
74
+ "unit": "bytes",
75
+ "baseline": 1542,
76
+ "ceiling": 1700
77
+ },
78
+ {
79
+ "id": "docsApp.perPage",
80
+ "what": "Client JavaScript each added page costs, raw",
81
+ "path": "docsApp.perAddedPage.jsRawPerPage",
82
+ "unit": "bytes",
83
+ "baseline": 2471.5,
84
+ "ceiling": 2600,
85
+ "previousCeiling": 2600
86
+ },
87
+ {
88
+ "id": "counter.heap",
89
+ "what": "Counter, JavaScript heap after 200 updates",
90
+ "path": "browser.deduped.noThrottling.afterUpdates.jsHeapUsedBytes",
91
+ "unit": "bytes",
92
+ "baseline": 4575160,
93
+ "ceiling": 4630800,
94
+ "previousCeiling": 4630800,
95
+ "note": "W24: the 1,000-update heap gate stays off"
96
+ },
97
+ {
98
+ "id": "counter.wasmMemory",
99
+ "what": "Counter, WebAssembly memory after 200 updates",
100
+ "path": "browser.deduped.noThrottling.afterUpdates.wasmMemoryBytes",
101
+ "unit": "bytes",
102
+ "baseline": 1310720,
103
+ "ceiling": 1376300,
104
+ "previousCeiling": 4885800,
105
+ "note": "ratcheted down: W21 fixed the growth, the memory is flat"
106
+ },
107
+ {
108
+ "id": "docsApp.wasmMemory",
109
+ "what": "docs-app, WebAssembly memory after 200 updates",
110
+ "path": "browser.docsApp.noThrottling.afterUpdates.wasmMemoryBytes",
111
+ "unit": "bytes",
112
+ "baseline": 2293760,
113
+ "ceiling": 2408500
114
+ }
115
+ ],
116
+ "exact": [
117
+ {
118
+ "id": "wire.patch",
119
+ "what": "Bytes a PORT is sent for one changed item of 1,000 rows, as patches",
120
+ "path": "wire.1000.patchBytes",
121
+ "unit": "bytes",
122
+ "max": 202
123
+ }
124
+ ],
125
+ "ratios": [
126
+ {
127
+ "id": "counter.interactiveAt",
128
+ "what": "Counter, time until a click is handled, unthrottled",
129
+ "path": "browser.deduped.noThrottling.interactiveAtMs",
130
+ "max": 2
131
+ },
132
+ {
133
+ "id": "counter.interactiveAt4x",
134
+ "what": "Counter, time until a click is handled, 4x CPU",
135
+ "path": "browser.deduped.cpu4x.interactiveAtMs",
136
+ "max": 2
137
+ },
138
+ {
139
+ "id": "counter.firstPaint",
140
+ "what": "Counter, first paint, unthrottled",
141
+ "path": "browser.deduped.noThrottling.firstPaintMs",
142
+ "max": 2
143
+ },
144
+ {
145
+ "id": "update.10000",
146
+ "what": "`update`, 10,000 items, one changed",
147
+ "path": "update.10000.updateMs",
148
+ "max": 1.5
149
+ },
150
+ {
151
+ "id": "updateFromChanges.10000",
152
+ "what": "`update` from changes, 10,000 items",
153
+ "path": "update.10000.updateFromChangesMs",
154
+ "max": 1.5
155
+ }
156
+ ],
157
+ "known": [
158
+ {
159
+ "id": "wire.wholeTree",
160
+ "what": "Bytes a PORT is sent for one changed item of 1,000 rows, whole tree",
161
+ "path": "wire.1000.wholeTreeBytes",
162
+ "reason": "the Desktop protocol sends the whole tree on every update; patches are Bet 3's own work item, not WP8",
163
+ "owner": "unassigned"
164
+ },
165
+ {
166
+ "id": "installability",
167
+ "what": "The newest published @valancex set, with the release being measured, installs together",
168
+ "path": "newestInstallsTogether.installs",
169
+ "expect": true,
170
+ "reason": "Was red from MESH 0.16 until VALANCE 0.14: NEXUS 0.14 and PORT 0.12 stopped their MESH peer range at 0.15. With --tree the release candidate's own tarballs stand in for the published VALANCE packages, so a release is held to installing beside the newest of the others before it is published (a published VALANCE, the previous release, would gate a release on its predecessor)",
171
+ "owner": "WP8"
172
+ }
173
+ ]
174
+ }
175
+ }
package/dist/build.d.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  import type { TargetId } from "./targets.js";
2
+ import type { BudgetResult, SizeReport } from "./size.js";
2
3
  export interface Built {
3
4
  readonly dir: string;
4
5
  readonly files: ReadonlyArray<string>;
5
6
  readonly pages: ReadonlyArray<string>;
7
+ /** The web build's start-up and after-start sets, measured, and each budget it was held to (a production build only). */
8
+ readonly size?: SizeReport;
9
+ readonly budgets?: ReadonlyArray<BudgetResult>;
10
+ /** The size report as the lines a terminal prints. */
11
+ readonly report?: ReadonlyArray<string>;
6
12
  }
7
13
  /** The file a pathname is written to in the site: `/` → `index.html`, `/guide/x` → `guide/x/index.html`, relative to the base. */
8
14
  export declare const fileOf: (pathname: string, base: string) => string;
@@ -13,5 +19,7 @@ export interface BuildOptions {
13
19
  readonly onStage?: (stage: BuildStage) => void;
14
20
  /** Which of the project's targets to build; all of them by default. */
15
21
  readonly targets?: ReadonlyArray<TargetId>;
22
+ /** Called with the size report as soon as the bundle is measured, before a budget error stops the build (a terminal prints it). */
23
+ readonly onReport?: (lines: ReadonlyArray<string>) => void;
16
24
  }
17
25
  export declare const build: (root: string, options?: BuildOptions) => Promise<Built>;
package/dist/build.js CHANGED
@@ -10,6 +10,8 @@ import { headersFile } from "./security.js";
10
10
  import { AppError } from "./diagnostics.js";
11
11
  import { generateContent, generateEntries, generateProgram } from "./emit.js";
12
12
  import { OUTPUT_DIR } from "./project.js";
13
+ import { check, DEFAULT_BUDGETS, failure, lines, measure } from "./size.js";
14
+ import { assertOneRuntime } from "./runtime-guard.js";
13
15
  import { baseConfig, createLoader, SERVER_ENTRY, templates } from "./vite.js";
14
16
  import { GENERATED_DIR } from "./project.js";
15
17
  /** The file a pathname is written to in the site: `/` → `index.html`, `/guide/x` → `guide/x/index.html`, relative to the base. */
@@ -36,13 +38,14 @@ const buildDesktop = async (root) => {
36
38
  plugins: [templates(root)],
37
39
  cacheDir: join(root, GENERATED_DIR, "cache"),
38
40
  ssr: { noExternal: true, target: "node" },
41
+ esbuild: { keepNames: true },
39
42
  build: {
40
43
  ssr: join(root, GENERATED_DIR, "desktop.ts"),
41
44
  outDir: out,
42
45
  emptyOutDir: true,
43
46
  target: "node22",
44
- minify: false,
45
- chunkSizeWarningLimit: 4000,
47
+ // W10: the engine is minified (half the bytes; start-up measured no worse, 2026-10-24). Names are kept: errors and their `_tag` are read by name.
48
+ minify: "esbuild",
46
49
  rollupOptions: {
47
50
  output: {
48
51
  format: "es",
@@ -61,7 +64,7 @@ const buildDesktop = async (root) => {
61
64
  return { dir: out, files: ["desktop/engine.mjs", "desktop/mesh-runtime.wasm"], pages: [] };
62
65
  };
63
66
  /** The web build: the browser bundle into `out`, then one document per page. The loader is the one the templates were compiled with. */
64
- const buildWeb = async (root, config, loader, content, out, prefix, stage) => {
67
+ const buildWeb = async (root, config, loader, content, out, prefix, stage, onReport) => {
65
68
  // The browser bundle first: it empties its directory, and the documents are written into it after.
66
69
  stage("bundle");
67
70
  await bundle({ ...baseConfig(root, config, out), mode: "production" });
@@ -74,7 +77,22 @@ const buildWeb = async (root, config, loader, content, out, prefix, stage) => {
74
77
  throw new AppError([{ file: out, line: 0, column: 0, code: "build-entry", message: "the bundle has no entry chunk" }]);
75
78
  }
76
79
  rmSync(join(dist, ".vite"), { recursive: true, force: true }); // the bundler's bookkeeping is not part of the site
77
- const built = { script: `${config.web.base}${entry.file}`, styles: (entry.css ?? []).map((file) => `${config.web.base}${file}`) };
80
+ // The WebAssembly runtime is in the start-up set (H7): the head starts compiling it while the page's script downloads (W11).
81
+ const wasm = Object.values(manifest).find((chunk) => chunk.file.endsWith(".wasm"))?.file;
82
+ const built = {
83
+ script: `${config.web.base}${entry.file}`,
84
+ styles: (entry.css ?? []).map((file) => `${config.web.base}${file}`),
85
+ ...(wasm === undefined ? {} : { wasm: `${config.web.base}${wasm}` }),
86
+ };
87
+ // The size report: the start-up set and the after-start set, held to the project's budgets or the framework's (W9, W14). Production only.
88
+ const size = measure(dist, manifest, wasm);
89
+ const budgets = check(size, config.budgets ?? DEFAULT_BUDGETS);
90
+ const report = lines(size, budgets);
91
+ onReport(report);
92
+ const over = failure(budgets);
93
+ if (over !== undefined) {
94
+ throw over;
95
+ }
78
96
  const files = [];
79
97
  const write = (relative, text) => {
80
98
  mkdirSync(dirname(join(dist, relative)), { recursive: true });
@@ -95,7 +113,7 @@ const buildWeb = async (root, config, loader, content, out, prefix, stage) => {
95
113
  if (config.web.headers) {
96
114
  write("_headers", headersFile(config));
97
115
  }
98
- return { dir: dist, files: files.map((file) => `${prefix}${file}`), pages };
116
+ return { dir: dist, files: files.map((file) => `${prefix}${file}`), pages, size, budgets, report };
99
117
  }
100
118
  };
101
119
  export const build = async (root, options = {}) => {
@@ -115,6 +133,8 @@ export const build = async (root, options = {}) => {
115
133
  },
116
134
  ]);
117
135
  }
136
+ // Production only (`vlx dev` does not build through here): one copy of the MESH runtime, or the site ships its WebAssembly module more than once (W20).
137
+ assertOneRuntime(root);
118
138
  generateEntries(root, config);
119
139
  stage("content");
120
140
  const content = generateContent(root, config);
@@ -125,7 +145,7 @@ export const build = async (root, options = {}) => {
125
145
  await generateProgram(root, await loadAuthor(loader, root), config, selected);
126
146
  // Each target writes to a directory of its own, whatever else the project has: dist/web/ and dist/desktop/.
127
147
  if (selected.includes("web")) {
128
- built.push(await buildWeb(root, config, loader, content, `${OUTPUT_DIR}/web`, "web/", stage));
148
+ built.push(await buildWeb(root, config, loader, content, `${OUTPUT_DIR}/web`, "web/", stage, options.onReport ?? (() => undefined)));
129
149
  }
130
150
  if (selected.includes("desktop")) {
131
151
  stage("bundle");
@@ -135,5 +155,11 @@ export const build = async (root, options = {}) => {
135
155
  finally {
136
156
  await loader.close();
137
157
  }
138
- return { dir: join(root, OUTPUT_DIR), files: built.flatMap((each) => each.files), pages: built.flatMap((each) => each.pages) };
158
+ const web = built.find((each) => each.size !== undefined);
159
+ return {
160
+ dir: join(root, OUTPUT_DIR),
161
+ files: built.flatMap((each) => each.files),
162
+ pages: built.flatMap((each) => each.pages),
163
+ ...(web?.size === undefined ? {} : { size: web.size, budgets: web.budgets, report: web.report }),
164
+ };
139
165
  };
package/dist/cli.js CHANGED
@@ -4,11 +4,13 @@
4
4
  // vlx dev [--port N] [--host H] serve the project and rebuild it as it changes
5
5
  // vlx build write the site to dist/web/
6
6
  // vlx check [--json] find mistakes in authored files without building
7
+ // vlx theme check [--json] the theme: its tokens, recipes, contrast and what each target applies
7
8
  // vlx generate page|component <name> add a page, or a component registered in src/main.ts (alias: g)
8
9
  // vlx sync bring .valance/ (generated types, manifest, entries) up to date; runs by itself after install and generate
9
10
  // vlx help [command] what a command does
10
11
  //
11
12
  // In a terminal, `init`, `dev`, `build` and `check` draw an interface (Ink, `ui/`). Anywhere else (a pipe, CI, `--json`, `--plain`) they print the plain text they always have.
13
+ import { resolve } from "node:path";
12
14
  import { readFileSync } from "node:fs";
13
15
  import { pathToFileURL } from "node:url";
14
16
  import { INIT_HELP, parseInit } from "./args.js";
@@ -16,9 +18,11 @@ import { build } from "./build.js";
16
18
  import { packageDesktop } from "./package.js";
17
19
  import { generateComponent, generatePage, generateTarget, GenerateError, KINDS } from "./add.js";
18
20
  import { check } from "./check.js";
21
+ import { themeReport } from "./theme.js";
19
22
  import { findRoot } from "./config.js";
20
23
  import { localCli } from "./delegate.js";
21
24
  import { dev } from "./dev.js";
25
+ import { defaultRecording, replayFile } from "./replay.js";
22
26
  import { AppError, format } from "./diagnostics.js";
23
27
  import { detectPackageManager, directoryFor, directoryProblem, execute, nameProblem, runCommand, titleOf } from "./init.js";
24
28
  import { DEFAULT_TARGETS, TARGETS } from "./targets.js";
@@ -26,9 +30,11 @@ import { canAsk, canDraw } from "./tty.js";
26
30
  const HELP = `vlx <command>
27
31
 
28
32
  init [name] [--web] make a new application
29
- dev [--port N] [--host H] serve it and rebuild as it changes
33
+ dev [--port N] [--host H] serve it and rebuild as it changes (--record [file]: write a session log)
34
+ replay <log> [--route R] [--allow-user-data] play a session log against this project and find where it differs
30
35
  build write the site to dist/web/
31
36
  check [--json] find mistakes in your files without building
37
+ theme check [--json] check the theme: tokens, recipes, contrast, and what each target applies
32
38
  generate page|component <name> add a page, or a component (alias: g)
33
39
  generate target web|desktop add a target to the project
34
40
  package --desktop the desktop target as one executable beside its window (unsigned)
@@ -55,10 +61,12 @@ const COMMAND_HELP = {
55
61
  init: INIT_HELP,
56
62
  generate: GENERATE_HELP,
57
63
  g: GENERATE_HELP,
58
- dev: "vlx dev [--web|--desktop] [--port N] [--host H]\n\nServe the project (the web is served, a desktop project opens its window; a project of more than one target does both, or the one named) and rebuild it as files change. A mistake leaves the last good build serving and shows in the terminal and in the page.\n",
64
+ replay: "vlx replay <log> [--route changes,update,render] [--json] [--allow-user-data]\n\nPlay a session log (written by vlx dev --record, or one of MESH's conformance logs) against this project's templates by the three routes of the equivalence law, and find the first line where one differs from the log: a tree, an event's outcome, a program or manifest that is not the one recorded. It runs no command and reads no clock. Exits 0 when every route gave what the log recorded, 1 when one did not, 2 when the log cannot be read. A log that holds user data (every recording does: its header says containsUserData) is refused unless you pass --allow-user-data: a divergence prints what was typed and shown.\n",
65
+ dev: "vlx dev [--web|--desktop] [--port N] [--host H] [--record [file]]\n\nServe the project (the web is served, a desktop project opens its window; a project of more than one target does both, or the one named) and rebuild it as files change. A mistake leaves the last good build serving and shows in the terminal and in the page. --record writes the web page's session as a log (default .valance/sessions/<time>.jsonl) for vlx replay: development only, never uploaded, and it holds what the person typed.\n",
59
66
  build: "vlx build [--web] [--desktop]\n\nBuild every target the project has (or the ones named). The web: the site, in dist/web/; any host that serves files can serve it. The desktop: the engine, in dist/desktop/.\n",
60
67
  sync: "vlx sync\n\nBring .valance/ up to date: the generated types for your templates (so your editor and tsc agree with them), the manifest and the entries. You rarely run it: a new project does it after install (the `prepare` script), `vlx generate` does it after it writes, and dev, build and check do it as they run. It never fails: mistakes in your files are reported by vlx check.\n",
61
68
  package: "vlx package --desktop [--platform linux-x64|win32-x64] [--window <path>] [--node <path>] [--archive]\n\nBuild the desktop target and package it: the engine as one executable (Node's single executable application, with the MESH runtime inside) beside the Desktop PORT's window program, in dist/package/<platform>/. --window is the window program for that platform (default: VLX_WINDOW, else vlx-window on the PATH; build it from the PORT repository). For another platform, --node is a node binary of this version for it. --archive also writes the package as one file, a .tar.gz (Linux) or a .zip (Windows), with its SHA-256 beside it. The result is unsigned.\n",
69
+ theme: "vlx theme check [--json]\n\nCheck the theme: the project's `theme/theme.json` (its tokens by mode, in the Design Tokens format, and its recipes), or the default theme when it has none. Reports a token that does not resolve, a recipe that names a part, state, variant, property or token that does not exist, a removed focus ring, the contrast of every pair the theme declares (WCAG 2.2), and what the web and the desktop apply of it. --json prints the diagnostics, the contrast and the coverage.\n",
62
70
  check: "vlx check [--json]\n\nFind mistakes in your files without building. --json prints them as { file, line, column, code, message } for editors and CI.\n",
63
71
  };
64
72
  /** Refreshes `.valance/` and says so in a line. Best effort: a mistake in the project is `check`'s to report, and a project with no `valance.json` (the install ran before init finished) is left alone. */
@@ -79,6 +87,16 @@ const option = (args, name) => {
79
87
  const at = args.indexOf(name);
80
88
  return at < 0 ? undefined : args[at + 1];
81
89
  };
90
+ /** `--record` alone, `--record file` or `--record=file`: the file, `""` for the default, nothing when it is not there. A word after it that is another flag is not its file. */
91
+ const recordOption = (args) => {
92
+ const joined = args.find((each) => each.startsWith("--record="));
93
+ if (joined !== undefined) {
94
+ return joined.slice("--record=".length);
95
+ }
96
+ const at = args.indexOf("--record");
97
+ const next = at < 0 ? undefined : args[at + 1];
98
+ return at < 0 ? undefined : next === undefined || next.startsWith("-") ? "" : next;
99
+ };
82
100
  const version = () => JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
83
101
  const runInit = async (args, cwd) => {
84
102
  if (args.name !== undefined) {
@@ -137,7 +155,15 @@ export const main = async (args, cwd = process.cwd()) => {
137
155
  return 0;
138
156
  }
139
157
  // A global `vlx` runs the project's own (see `delegate.ts`).
140
- if (command === "dev" || command === "build" || command === "package" || command === "check" || command === "sync" || command === "generate" || command === "g") {
158
+ if (command === "dev" ||
159
+ command === "replay" ||
160
+ command === "build" ||
161
+ command === "package" ||
162
+ command === "check" ||
163
+ command === "theme" ||
164
+ command === "sync" ||
165
+ command === "generate" ||
166
+ command === "g") {
141
167
  const local = localCli(cwd);
142
168
  if (local !== undefined) {
143
169
  const theirs = (await import(pathToFileURL(local).href));
@@ -168,9 +194,12 @@ export const main = async (args, cwd = process.cwd()) => {
168
194
  return 2;
169
195
  }
170
196
  const target = picked[0];
197
+ const asked = recordOption(rest);
198
+ const record = asked === undefined ? undefined : asked === "" ? defaultRecording(findRoot(cwd)) : resolve(cwd, asked);
171
199
  if (draws) {
172
200
  const { drawDev } = await import("./ui/run.js");
173
201
  await drawDev(findRoot(cwd), {
202
+ ...(record === undefined ? {} : { record }),
174
203
  ...(target === undefined ? {} : { target }),
175
204
  ...(port === undefined ? {} : { port: Number(port) }),
176
205
  ...(host === undefined ? {} : { host }),
@@ -178,6 +207,7 @@ export const main = async (args, cwd = process.cwd()) => {
178
207
  return 0;
179
208
  }
180
209
  const running = await dev(findRoot(cwd), {
210
+ ...(record === undefined ? {} : { record }),
181
211
  ...(target === undefined ? {} : { target }),
182
212
  ...(port === undefined ? {} : { port: Number(port) }),
183
213
  ...(host === undefined ? {} : { host }),
@@ -192,6 +222,21 @@ export const main = async (args, cwd = process.cwd()) => {
192
222
  await running.close();
193
223
  return 0;
194
224
  }
225
+ case "replay": {
226
+ const file = rest.find((each, index) => !each.startsWith("-") && rest[index - 1] !== "--route");
227
+ if (file === undefined) {
228
+ console.error("vlx replay: name a log: vlx replay <log> [--route changes,update,render] [--json]");
229
+ return 2;
230
+ }
231
+ const routes = option(rest, "--route");
232
+ const replayed = await replayFile(findRoot(cwd), resolve(cwd, file), {
233
+ ...(routes === undefined ? {} : { routes: routes.split(",") }),
234
+ json: rest.includes("--json"),
235
+ allowUserData: rest.includes("--allow-user-data"),
236
+ });
237
+ (replayed.code === 0 ? console.log : console.error)(replayed.output);
238
+ return replayed.code;
239
+ }
195
240
  case "build": {
196
241
  if (draws) {
197
242
  const { drawBuild } = await import("./ui/run.js");
@@ -200,7 +245,7 @@ export const main = async (args, cwd = process.cwd()) => {
200
245
  }
201
246
  const started = Date.now();
202
247
  const flagged = targetFlags(rest);
203
- const built = await build(findRoot(cwd), flagged.length === 0 ? {} : { targets: flagged });
248
+ const built = await build(findRoot(cwd), { ...(flagged.length === 0 ? {} : { targets: flagged }), onReport: (report) => report.forEach((line) => console.log(line)) });
204
249
  console.log(`vlx build: ${built.pages.length} pages, ${built.files.length} files → ${built.dir} (${((Date.now() - started) / 1000).toFixed(1)}s)`);
205
250
  return 0;
206
251
  }
@@ -242,6 +287,32 @@ export const main = async (args, cwd = process.cwd()) => {
242
287
  }
243
288
  return result.diagnostics.length === 0 ? 0 : 1;
244
289
  }
290
+ case "theme": {
291
+ if (rest[0] !== "check") {
292
+ console.error("vlx theme: say what: vlx theme check [--json]");
293
+ return 2;
294
+ }
295
+ const report = themeReport(findRoot(cwd));
296
+ if (rest.includes("--json")) {
297
+ console.log(JSON.stringify({ theme: report.name, custom: report.custom, diagnostics: report.diagnostics, contrast: report.contrast, coverage: report.coverage }, null, 2));
298
+ return report.diagnostics.length === 0 ? 0 : 1;
299
+ }
300
+ console.log(`vlx theme check: ${report.custom ? "the project's theme" : "the default theme"} "${report.name}" (modes: ${report.modes.join(", ")})`);
301
+ for (const each of report.contrast) {
302
+ console.log(` contrast ${each.mode.padEnd(9)} ${each.text} on ${each.on} ${each.ratio.toFixed(2)}:1 (min ${each.min}) ${each.passes ? "ok" : "BELOW"}`);
303
+ }
304
+ for (const each of report.coverage) {
305
+ console.log(` ${each.target}: ${each.styled.length} of ${each.styled.length + each.unstyled.length} primitives of the portable core styled${each.unstyled.length === 0 ? "" : ` (not styled: ${each.unstyled.join(", ")})`}`);
306
+ for (const note of each.notApplied) {
307
+ console.log(` not applied: ${note.primitive} ${note.kind} ${note.name} (${note.support})`);
308
+ }
309
+ }
310
+ report.diagnostics.forEach((each) => {
311
+ console.error(format(each));
312
+ });
313
+ console.log(report.diagnostics.length === 0 ? "vlx theme check: no problems" : `vlx theme check: ${report.diagnostics.length} problem${report.diagnostics.length === 1 ? "" : "s"}`);
314
+ return report.diagnostics.length === 0 ? 0 : 1;
315
+ }
245
316
  case "sync": {
246
317
  const synced = await sync(cwd);
247
318
  console.log(synced);