@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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +69 -11
  3. package/dist/add.d.ts +9 -1
  4. package/dist/add.js +68 -5
  5. package/dist/archive.d.ts +17 -0
  6. package/dist/archive.js +111 -0
  7. package/dist/args.js +2 -2
  8. package/dist/budgets.json +175 -0
  9. package/dist/build.d.ts +11 -0
  10. package/dist/build.js +120 -30
  11. package/dist/check.js +2 -2
  12. package/dist/cli.js +131 -17
  13. package/dist/compile.d.ts +11 -3
  14. package/dist/compile.js +123 -25
  15. package/dist/config.d.ts +13 -0
  16. package/dist/config.js +34 -1
  17. package/dist/contract.d.ts +18 -1
  18. package/dist/contract.js +85 -3
  19. package/dist/dev-desktop.d.ts +8 -0
  20. package/dist/dev-desktop.js +114 -0
  21. package/dist/dev.d.ts +8 -0
  22. package/dist/dev.js +91 -29
  23. package/dist/emit.d.ts +9 -4
  24. package/dist/emit.js +129 -28
  25. package/dist/init.js +14 -4
  26. package/dist/package.d.ts +18 -0
  27. package/dist/package.js +142 -0
  28. package/dist/project.js +1 -1
  29. package/dist/recording-guard.d.ts +5 -0
  30. package/dist/recording-guard.js +28 -0
  31. package/dist/replay.d.ts +15 -0
  32. package/dist/replay.js +59 -0
  33. package/dist/rules.d.ts +23 -0
  34. package/dist/rules.js +123 -0
  35. package/dist/runtime-guard.d.ts +11 -0
  36. package/dist/runtime-guard.js +62 -0
  37. package/dist/security.d.ts +2 -0
  38. package/dist/security.js +8 -1
  39. package/dist/size.d.ts +54 -0
  40. package/dist/size.js +153 -0
  41. package/dist/targets.d.ts +6 -1
  42. package/dist/targets.js +26 -4
  43. package/dist/theme.d.ts +35 -0
  44. package/dist/theme.js +44 -0
  45. package/dist/tree.d.ts +14 -2
  46. package/dist/tree.js +16 -1
  47. package/dist/ui/init.js +4 -4
  48. package/dist/ui/report.js +1 -1
  49. package/dist/ui/run.d.ts +4 -1
  50. package/dist/ui/run.js +18 -3
  51. package/dist/vite.d.ts +3 -2
  52. package/dist/vite.js +3 -3
  53. package/package.json +15 -7
  54. package/schemas/valance.desktop.schema.json +17 -0
  55. package/schemas/valance.schema.json +71 -0
  56. package/templates/desktop/README.md +14 -0
  57. package/templates/desktop/content/404.md +7 -0
  58. package/templates/desktop/content/guide/getting-started.md +15 -0
  59. package/templates/desktop/content/index.md +14 -0
  60. package/templates/desktop/gitignore +7 -0
  61. package/templates/desktop/src/layout.mprx +23 -0
  62. package/templates/desktop/src/main.ts +15 -0
  63. package/templates/desktop/tsconfig.json +18 -0
  64. package/templates/multi/README.md +13 -0
  65. package/templates/multi/content/404.md +7 -0
  66. package/templates/multi/content/guide/getting-started.md +15 -0
  67. package/templates/multi/content/index.md +14 -0
  68. package/templates/multi/src/desktop/layout.mprx +21 -0
  69. package/templates/multi/src/main.ts +23 -0
  70. package/templates/multi/src/web/layout.mprx +27 -0
  71. package/templates/multi/src/web/styles.css +28 -0
  72. package/templates/multi/src/web/theme-toggle.mprx +1 -0
  73. package/templates/multi/src/web/theme-toggle.ts +10 -0
  74. package/templates/web/content/guide/getting-started.md +1 -1
  75. package/templates/web/gitignore +3 -0
  76. package/templates/web/src/layout.mprx +2 -2
  77. package/views/block.mprx +1 -1
  78. package/views/layout.mprx +2 -2
  79. package/views/list-items.mprx +1 -1
  80. package/views-desktop/block.mprx +3 -0
  81. package/views-desktop/blocks.mprx +3 -0
  82. package/views-desktop/inline.mprx +3 -0
  83. package/views-desktop/layout.mprx +20 -0
  84. package/views-desktop/list-block.mprx +3 -0
  85. package/views-desktop/list-items.mprx +3 -0
  86. package/views-desktop/runs.mprx +3 -0
  87. /package/views/{heading.mprx → heading-block.mprx} +0 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jack Miller
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -66,6 +66,16 @@ Then it writes the project (the same files as `examples/docs-app`: a layout, a c
66
66
 
67
67
  Without a terminal (a pipe, CI) it cannot ask, so it needs a name and a target: `vlx init my-app --web`.
68
68
 
69
+ ## A desktop application: `vlx init --desktop`
70
+
71
+ A project has the targets whose settings file it has; `vlx init my-app --web --desktop` makes both, and `defineApp`'s `targets` gives each its templates (how it was designed and what it found: `docs/application-model/MULTI-TARGET.md` and `MULTI-TARGET-PLAN.md`). `vlx init my-app --desktop` makes the same project as for the web (Markdown in `content/`, a layout, state and commands in `src/main.ts`) with `valance.desktop.json` in place of `valance.web.json`. Its templates are written against the Desktop PORT's vocabulary (`column`, `row`, `heading`, `text`, `button`, `checkbox`, `textfield`, and the content layer: `paragraph`, `list`, `listitem`, `blockquote`, `emphasis`, …), not the web's tags: `<div>` in a desktop project is an error from `vlx check`.
72
+
73
+ `vlx build` writes the engine to `dist/desktop/`: `engine.mjs` (everything bundled; run it with Node) and `mesh-runtime.wasm` beside it. The engine speaks the host protocol on stdin and stdout; the window that runs it is the Desktop PORT's (`valancex-port-desktop`). `vlx package --desktop` packs the engine into one executable (Node's single executable application, with the MESH runtime inside) and sets it beside the window program (`--window <path>`, or `VLX_WINDOW`, or `vlx-window` on the PATH) in `dist/package/<platform>/`. `--archive` also writes the package as one reproducible file, a `.tar.gz` (Linux) or a `.zip` (Windows), with its SHA-256 beside it. **Unsigned**: signing and notarization are not done by `vlx`. For another platform, `--platform win32-x64 --node <that platform's node> --window <that platform's window>`. `vlx dev` builds the engine, opens the Desktop PORT's window on it (the `vlx-window` program, installed with `cargo install --git https://github.com/ValanceX/Port valancex-port-desktop --bin vlx-window`, found on the PATH or at `VLX_WINDOW`), and when a file changes it builds again and tells the running window to start the engine again in place (the window keeps its position and size). Closing the window ends `vlx dev`.
74
+
75
+ ## Adding a target: `vlx generate target desktop`
76
+
77
+ A web project becomes a project of two targets without moving anything: `vlx generate target desktop` writes `valance.desktop.json` (and `valance.web.json`, which a project needs once it has another target's), a layout of its own in `src/desktop/`, and registers it in `targets` in `src/main.ts`. The layout the project has stays the default for a target with none of its own. **The site moves**: a project of one web target builds to `dist/`, one of several to `dist/<target>/` (`dist/web/`, `dist/desktop/`), so a deploy step that serves `dist/` is pointed at `dist/web/` when the second target is added (the command says so). See `docs/application-model/MULTI-TARGET.md`.
78
+
69
79
  ## Adding to an application: `vlx generate`
70
80
 
71
81
  ```console
@@ -90,7 +100,7 @@ Try it locally: [`docs/application-model/TRY-IT.md`](../../docs/application-mode
90
100
  and a `package.json` that depends on `@valancex/cli` and has `"dev": "vlx dev"`, `"build": "vlx build"`.
91
101
  2. Add pages: **`content/index.md`** is the first page (`# Notes` and some text). Every other `.md` file under `content/` is a page.
92
102
  3. `pnpm dev`. Open the address it prints. Edit a Markdown file and save: the page reloads with your change.
93
- 4. `pnpm build`. The site is in `dist/`; any host that serves files can serve it.
103
+ 4. `pnpm build`. The site is in `dist/web/`; any host that serves files can serve it.
94
104
 
95
105
  That is a complete application. It uses a built-in layout. Everything below is how you take over a part of it.
96
106
 
@@ -107,7 +117,7 @@ That is a complete application. It uses a built-in layout. Everything below is h
107
117
  | `src/**/*.css` | Styles. Every stylesheet is loaded. | you |
108
118
  | `src/main.ts` | Optional until you own a template. `defineApp`: the `layout`, your `components`, state of your own and the commands that change it. | you |
109
119
  | `.valance/` | Generated: content, compiled templates, the entries, and for editors `manifest.json` (what a template may use) and `editor.json` (which file is which component). Git-ignored by a `.gitignore` inside it. | the tools |
110
- | `dist/` | The built site. | the tools |
120
+ | `dist/web/` | The built site. | the tools |
111
121
 
112
122
  Settings are validated, unknown keys are errors, and editors can use the JSON Schemas in `schemas/` (`"$schema": "./node_modules/@valancex/cli/schemas/valance.schema.json"`).
113
123
 
@@ -143,7 +153,7 @@ Templates are MPRX. Conditional and repeated parts are blocks: `{#if cond}…{:e
143
153
  | `prev`, `next` | `{ href, label }`, empty `href` when there is none |
144
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. |
145
155
 
146
- 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).
147
157
 
148
158
  ## The components
149
159
 
@@ -163,20 +173,22 @@ export default defineApp({
163
173
 
164
174
  ```ts
165
175
  // src/theme-toggle.ts: the component states its own interface, where it is defined
166
- import { component } from "@valancex/common";
176
+ import { component, field } from "@valancex/common";
167
177
  import { Schema } from "effect";
168
178
  import template from "./theme-toggle.mprx";
169
179
 
170
180
  export default component(template, {
171
- props: { theme: Schema.Literal("light", "dark") },
181
+ props: { theme: field(Schema.Literal("light", "dark"), "light") },
172
182
  events: { toggle: Schema.Void },
173
183
  });
174
184
  ```
175
185
 
176
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`.
177
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`).
178
- - **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.
179
- - `override: [component(runs, { props: { spans: … } })]` replaces a built-in (`runs`, `heading`, …) with your template of the same name.
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
+ - `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`.
180
192
  - `layout` is optional; without it the built-in layout draws the page. Its template must be named `layout.mprx`.
181
193
 
182
194
  ### Children and slots
@@ -192,13 +204,38 @@ A component places the content its caller writes inside it with `<mesh-slot />`,
192
204
 
193
205
  <!-- where it is used -->
194
206
  <card>
195
- <mesh-fill slot="head"><para class="title">Title</para></mesh-fill>
196
- <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>
197
209
  </card>
198
210
  ```
199
211
 
200
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).
201
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
+
202
239
  ### Typed template imports
203
240
 
204
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`:
@@ -229,6 +266,27 @@ export default defineApp({
229
266
 
230
267
  A state field is its type and its default in one declaration. Hand-building `state: { schema, initial }` is still accepted; giving both forms is the same definition error as a clash. `theme` is now a name the layout reads (`<shell theme={theme}>`) and `toggleTheme()` a command it runs (`on.click={toggleTheme()}`). A component raises an event (`on.click={toggle()}`, declared in `events`) and its user binds it (`<theme-toggle on.toggle={toggleTheme()} />`). A state field may not reuse a framework name (`site`, `title`, `blocks`…), and `go` is the framework's command. Both are errors that say so.
231
268
 
269
+ ### Values worked out, kept, and moved between
270
+
271
+ `defineApp` has more than state and commands. All of it is the same on every target:
272
+
273
+ ```ts
274
+ export default defineApp({
275
+ state: { items: field(Schema.Array(Item), []), theme: field(Schema.Literal("system", "light", "dark"), "system") },
276
+ // Worked out from the state, with their types, for a template to read: `{summary}`.
277
+ derived: (derive) => ({ summary: derive(Schema.String, (state) => `${state.items.length} items`) }),
278
+ // Kept between runs through the platform's `Store` (the page's storage; a file in the user's data directory on the desktop).
279
+ // "navigation" keeps the pages visited, where the engine keeps them (a window; a page's list is the browser's).
280
+ persist: ["items", "theme", "navigation"],
281
+ // What the window asks to look like (`"light"`, `"dark"` or `"system"`); the layout reads `colorScheme`, the system's.
282
+ appearance: (state) => state.theme,
283
+ // Asked before `go`, `back` and `forward` (the router's `Guard`): `false` stops the move, `redirect(page)` sends it elsewhere.
284
+ guard: ({ to, kind }) => to.name !== "/private",
285
+ });
286
+ ```
287
+
288
+ The framework's own names are `page`, `trail`, `cursor`, `viewport`, `colorScheme`, `canGoBack` and `canGoForward` (state a layout reads) and the commands `go`, `back`, `forward`, `resize`, `colorScheme` and `persist`; an application's own state and commands cannot take them. A text field reports `change`, `submit` (Enter) and `cancel` (Escape). `Store` and `Open` (send a person to an address outside the application: a new tab, or the system's opener) are capabilities in `@valancex/common`. How navigation is modelled across a page, a window and a phone: `docs/application-model/NAVIGATION.md`.
289
+
232
290
  ## The document's title
233
291
 
234
292
  A page's title is its front-matter `title` (else its first heading), written with the project's format in `valance.json`:
@@ -320,7 +378,7 @@ A mistake leaves the last good build serving and appears in the terminal and as
320
378
  $ pnpm add -D @valancex/mesh-lsp
321
379
  ```
322
380
 
323
- The VS Code extension in [`editors/vscode`](../../editors/vscode) (not published yet) starts it, highlights `.mprx`, and shows `vlx check` mistakes in Markdown, config and `main.ts`. Neovim and Helix can use the same two files ([MESH's editor guide](https://github.com/ValanceX/Mesh/blob/main/docs/guides/editor-setup.md)).
381
+ The VS Code extension (**VALANCE**, in [`editors/vscode`](../../editors/vscode)) starts it, highlights `.mprx`, and shows `vlx check` mistakes in Markdown, config and `main.ts`. Neovim and Helix can use the same two files ([MESH's editor guide](https://github.com/ValanceX/Mesh/blob/main/docs/guides/editor-setup.md)).
324
382
 
325
383
  ## Diagnostics
326
384
 
@@ -331,5 +389,5 @@ Every mistake the tools can attribute to a file has one shape: `{ file, line, co
331
389
  - The whole content model ships in the page bundle. Fine for tens of pages; a per-page payload is the known answer for hundreds (the documentation verifier does it).
332
390
  - The Markdown dialect is a subset; no syntax highlighting, tables, callouts, tabs, search, versions or languages (the verifier has these as stand-ins).
333
391
  - The tag table is fixed, and it is the web's: `class`, `theme`, `tabindex`, `label`, `current`, `pressed`, `id` and `loading` are web hooks (a build against a vocabulary without them rejects the built-in views). An application cannot declare new primitives.
334
- - Only the web target exists, delivered as a static site. No server rendering command, no `preview` command.
392
+ - The web target is delivered as a static site, with no server rendering command and no `preview` command; the desktop target needs the Desktop PORT's window built from its source (it is not yet published). A third target (a phone) does not exist.
335
393
  - Updates are reloads, not hot swaps (except styles).
package/dist/add.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export declare const KINDS: readonly ["page", "component"];
1
+ import type { TargetId } from "./targets.js";
2
+ export declare const KINDS: readonly ["page", "component", "target"];
2
3
  export type Kind = (typeof KINDS)[number];
3
4
  export interface Generated {
4
5
  /** Files written, relative to the project. */
@@ -18,3 +19,10 @@ export declare const componentProblem: (name: string, root?: string) => string |
18
19
  /** `main.ts` with `ident` added to `components`, or `undefined` when its definition is not written in the plain form this can edit. */
19
20
  export declare const register: (text: string, ident: string, name: string) => string | undefined;
20
21
  export declare const generateComponent: (root: string, name: string) => Generated;
22
+ /**
23
+ * `main.ts` with a `targets` entry for `id`: its layout, from `src/<id>/layout.mprx`. `undefined` when the definition is not written in the plain form this can edit (or already has `targets`).
24
+ * The layout the project already has stays the default for every target that has none of its own, so nothing is moved.
25
+ */
26
+ export declare const registerTarget: (text: string, id: TargetId) => string | undefined;
27
+ /** Adds a target to the project: its settings file, and a starting layout of its own in `src/<id>/`, registered in `src/main.ts`. Nothing the project has is moved or changed but `src/main.ts`. */
28
+ export declare const generateTarget: (root: string, given: string) => Generated;
package/dist/add.js CHANGED
@@ -2,10 +2,12 @@
2
2
  // Nothing here overwrites a file: a name that is taken is refused.
3
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
- import { BUILT_IN, ROOT, SOURCE_DIR } from "./compile.js";
5
+ import { BUILT_IN, BUILT_IN_DESKTOP, ROOT, SOURCE_DIR } from "./compile.js";
6
+ import { DESKTOP_FILE, loadConfig, WEB_FILE } from "./config.js";
7
+ import { isTarget } from "./targets.js";
6
8
  import { ENTRY_FILE } from "./project.js";
7
9
  import { primitives } from "@valancex/common/internal";
8
- export const KINDS = ["page", "component"];
10
+ export const KINDS = ["page", "component", "target"];
9
11
  export class GenerateError extends Error {
10
12
  }
11
13
  const words = (name) => name.replace(/[-_]+/g, " ").replace(/^./, (first) => first.toUpperCase());
@@ -40,9 +42,7 @@ export const generatePage = (root, given, title) => {
40
42
  return { created: [file], changed: [], notes: [] };
41
43
  };
42
44
  // ---- a component -------------------------------------------------------------------------------------------------------------------------------------------
43
- const builtIns = () => new Set(readdirSync(BUILT_IN)
44
- .filter((file) => file.endsWith(".mprx"))
45
- .map((file) => file.slice(0, -".mprx".length)));
45
+ const builtIns = () => new Set([...readdirSync(BUILT_IN), ...readdirSync(BUILT_IN_DESKTOP)].filter((file) => file.endsWith(".mprx")).map((file) => file.slice(0, -".mprx".length)));
46
46
  export const componentProblem = (name, root) => {
47
47
  if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name)) {
48
48
  return `"${name}" cannot be a tag: use lowercase words joined by - (callout, post-card)`;
@@ -141,3 +141,66 @@ export const generateComponent = (root, name) => {
141
141
  notes.push(`use it by writing <${name}></${name}> in a template`);
142
142
  return { created, changed, notes };
143
143
  };
144
+ // ---- a target ----------------------------------------------------------------------------------------------------------------------------------------------
145
+ /**
146
+ * `main.ts` with a `targets` entry for `id`: its layout, from `src/<id>/layout.mprx`. `undefined` when the definition is not written in the plain form this can edit (or already has `targets`).
147
+ * The layout the project already has stays the default for every target that has none of its own, so nothing is moved.
148
+ */
149
+ export const registerTarget = (text, id) => {
150
+ const define = /defineApp\(\s*\{/.exec(text);
151
+ if (define === null || /\btargets\s*:/.test(text)) {
152
+ return undefined;
153
+ }
154
+ const ident = `${id}Layout`;
155
+ const at = define.index + define[0].length;
156
+ let edited = `${text.slice(0, at)}\n targets: { ${id}: { layout: component(${ident}) } },${text.slice(at)}`;
157
+ edited = withImport(edited, `import ${ident} from "./${id}/layout.mprx";`);
158
+ return /import\s*\{[^}]*\bcomponent\b[^}]*\}\s*from\s*"@valancex\/common"/.test(edited) ? edited : withImport(edited, 'import { component } from "@valancex/common";');
159
+ };
160
+ /** Adds a target to the project: its settings file, and a starting layout of its own in `src/<id>/`, registered in `src/main.ts`. Nothing the project has is moved or changed but `src/main.ts`. */
161
+ export const generateTarget = (root, given) => {
162
+ if (!isTarget(given)) {
163
+ throw new GenerateError(`"${given}" is not a target: the targets are web and desktop (vlx generate target desktop)`);
164
+ }
165
+ const id = given;
166
+ const config = loadConfig(root);
167
+ if (config.targets.includes(id)) {
168
+ throw new GenerateError(`this project already has the ${id} target`);
169
+ }
170
+ const settings = id === "web" ? WEB_FILE : DESKTOP_FILE;
171
+ const layout = `${SOURCE_DIR}/${id}/layout.mprx`;
172
+ const entry = join(root, ENTRY_FILE);
173
+ if (existsSync(join(root, layout))) {
174
+ throw new GenerateError(`${layout} already exists`);
175
+ }
176
+ const marker = (target) => `${JSON.stringify({ $schema: `./node_modules/@valancex/cli/schemas/valance.${target}.schema.json`, version: 1 }, null, 2)}\n`;
177
+ const created = [];
178
+ // A project is for the web when it has no settings file at all; once it has another target's, the web needs its own to stay a target.
179
+ if (id === "desktop" && !existsSync(join(root, WEB_FILE))) {
180
+ write(root, WEB_FILE, marker("web"));
181
+ created.push(WEB_FILE);
182
+ }
183
+ write(root, settings, marker(id));
184
+ created.push(settings);
185
+ // The framework's own layout for the target is the starting point: the page's shell, the sections and the previous and next pages.
186
+ write(root, layout, readFileSync(join(id === "web" ? BUILT_IN : BUILT_IN_DESKTOP, "layout.mprx"), "utf8"));
187
+ created.push(layout);
188
+ const changed = [];
189
+ const notes = [];
190
+ if (!existsSync(entry)) {
191
+ write(root, ENTRY_FILE, `import { component, defineApp } from "@valancex/common";\nimport ${id}Layout from "./${id}/layout.mprx";\n\nexport default defineApp({\n targets: { ${id}: { layout: component(${id}Layout) } },\n});\n`);
192
+ created.push(ENTRY_FILE);
193
+ }
194
+ else {
195
+ const edited = registerTarget(readFileSync(entry, "utf8"), id);
196
+ if (edited === undefined) {
197
+ notes.push(`add it to ${ENTRY_FILE} yourself: import ${id}Layout from "./${id}/layout.mprx"; and give defineApp \`targets: { ${id}: { layout: component(${id}Layout) } }\``);
198
+ }
199
+ else {
200
+ writeFileSync(entry, edited);
201
+ changed.push(ENTRY_FILE);
202
+ }
203
+ }
204
+ notes.push(`the ${id} target is drawn with its own vocabulary: write its templates in ${SOURCE_DIR}/${id}/, and the state, the commands and the content are shared`);
205
+ return { created, changed, notes };
206
+ };
@@ -0,0 +1,17 @@
1
+ export interface Entry {
2
+ /** Path inside the archive, `/` separated, no leading slash. */
3
+ readonly path: string;
4
+ readonly data: Buffer;
5
+ /** Unix permission bits (0o755 for a program, 0o644 for data). */
6
+ readonly mode: number;
7
+ }
8
+ export declare const crc32: (data: Uint8Array) => number;
9
+ /** A gzipped tar of `entries`, in the order given. */
10
+ export declare const tarGz: (entries: ReadonlyArray<Entry>) => Buffer;
11
+ /** A zip of `entries` (deflated), with each entry's Unix mode kept so a program stays executable where the unzipper honors it. */
12
+ export declare const zip: (entries: ReadonlyArray<Entry>) => Buffer;
13
+ /** `sha256sum`'s format: the hex digest, two spaces, the file name; one line per file. */
14
+ export declare const checksums: (files: ReadonlyArray<{
15
+ readonly name: string;
16
+ readonly data: Buffer;
17
+ }>) => string;
@@ -0,0 +1,111 @@
1
+ // Archives for `vlx package --archive`: a .tar.gz (Linux) and a .zip (Windows), written here with Node's own zlib so nothing has to be installed, and written the same every time (no clock, no owner)
2
+ // so the same files make the same bytes and a checksum means something.
3
+ import { createHash } from "node:crypto";
4
+ import { deflateRawSync, gzipSync } from "node:zlib";
5
+ /** CRC-32 (the zip checksum), here and not `zlib.crc32`: that is Node 22.2 and later, and `vlx` also runs under the Node an editor ships (VS Code 1.90 has 20.9). */
6
+ const TABLE = Uint32Array.from({ length: 256 }, (_, n) => {
7
+ let value = n;
8
+ for (let bit = 0; bit < 8; bit += 1) {
9
+ value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
10
+ }
11
+ return value >>> 0;
12
+ });
13
+ export const crc32 = (data) => {
14
+ let crc = 0xffffffff;
15
+ for (const byte of data) {
16
+ crc = TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
17
+ }
18
+ return (crc ^ 0xffffffff) >>> 0;
19
+ };
20
+ const octal = (value, width) => value.toString(8).padStart(width - 1, "0") + "\0";
21
+ /** One ustar header (512 bytes). A path longer than 100 bytes is split at a `/` into prefix and name, as ustar does. */
22
+ const tarHeader = (path, size, mode) => {
23
+ const bytes = Buffer.byteLength(path);
24
+ let name = path;
25
+ let prefix = "";
26
+ if (bytes > 100) {
27
+ const cut = path.lastIndexOf("/", path.length - 1);
28
+ const split = path.slice(0, cut);
29
+ const rest = path.slice(cut + 1);
30
+ if (cut < 0 || Buffer.byteLength(rest) > 100 || Buffer.byteLength(split) > 155) {
31
+ throw new Error(`the path is too long for an archive: ${path}`);
32
+ }
33
+ prefix = split;
34
+ name = rest;
35
+ }
36
+ const header = Buffer.alloc(512);
37
+ header.write(name, 0, "utf8");
38
+ header.write(octal(mode & 0o777, 8), 100, "ascii");
39
+ header.write(octal(0, 8), 108, "ascii"); // uid
40
+ header.write(octal(0, 8), 116, "ascii"); // gid
41
+ header.write(octal(size, 12), 124, "ascii");
42
+ header.write(octal(0, 12), 136, "ascii"); // mtime: the epoch, so the same files are the same bytes
43
+ header.write(" ", 148, "ascii"); // the checksum is summed over this field as spaces
44
+ header.write("0", 156, "ascii"); // a regular file
45
+ header.write("ustar\0" + "00", 257, "ascii");
46
+ header.write(prefix, 345, "utf8");
47
+ const sum = header.reduce((total, byte) => total + byte, 0);
48
+ header.write(octal(sum, 7) + " ", 148, "ascii");
49
+ return header;
50
+ };
51
+ /** A gzipped tar of `entries`, in the order given. */
52
+ export const tarGz = (entries) => {
53
+ const parts = [];
54
+ for (const entry of entries) {
55
+ parts.push(tarHeader(entry.path, entry.data.length, entry.mode), entry.data, Buffer.alloc((512 - (entry.data.length % 512)) % 512));
56
+ }
57
+ parts.push(Buffer.alloc(1024)); // two empty blocks end an archive
58
+ return gzipSync(Buffer.concat(parts), { level: 9, mtime: 0 });
59
+ };
60
+ const DOS_TIME = 0; // 00:00:00
61
+ const DOS_DATE = (1 << 5) | 1; // 1980-01-01, the earliest a zip can say
62
+ /** A zip of `entries` (deflated), with each entry's Unix mode kept so a program stays executable where the unzipper honors it. */
63
+ export const zip = (entries) => {
64
+ const local = [];
65
+ const central = [];
66
+ let offset = 0;
67
+ for (const entry of entries) {
68
+ const name = Buffer.from(entry.path, "utf8");
69
+ const packed = deflateRawSync(entry.data, { level: 9 });
70
+ const crc = crc32(entry.data);
71
+ const head = Buffer.alloc(30);
72
+ head.writeUInt32LE(0x04034b50, 0);
73
+ head.writeUInt16LE(20, 4); // version needed
74
+ head.writeUInt16LE(0x0800, 6); // names are UTF-8
75
+ head.writeUInt16LE(8, 8); // deflate
76
+ head.writeUInt16LE(DOS_TIME, 10);
77
+ head.writeUInt16LE(DOS_DATE, 12);
78
+ head.writeUInt32LE(crc, 14);
79
+ head.writeUInt32LE(packed.length, 18);
80
+ head.writeUInt32LE(entry.data.length, 22);
81
+ head.writeUInt16LE(name.length, 26);
82
+ head.writeUInt16LE(0, 28);
83
+ local.push(head, name, packed);
84
+ const entryCentral = Buffer.alloc(46);
85
+ entryCentral.writeUInt32LE(0x02014b50, 0);
86
+ entryCentral.writeUInt16LE((3 << 8) | 20, 4); // made by Unix, version 2.0
87
+ entryCentral.writeUInt16LE(20, 6);
88
+ entryCentral.writeUInt16LE(0x0800, 8);
89
+ entryCentral.writeUInt16LE(8, 10);
90
+ entryCentral.writeUInt16LE(DOS_TIME, 12);
91
+ entryCentral.writeUInt16LE(DOS_DATE, 14);
92
+ entryCentral.writeUInt32LE(crc, 16);
93
+ entryCentral.writeUInt32LE(packed.length, 20);
94
+ entryCentral.writeUInt32LE(entry.data.length, 24);
95
+ entryCentral.writeUInt16LE(name.length, 28);
96
+ entryCentral.writeUInt32LE(((0o100000 | (entry.mode & 0o777)) << 16) >>> 0, 38); // external attributes: a Unix regular file and its mode
97
+ entryCentral.writeUInt32LE(offset, 42);
98
+ central.push(entryCentral, name);
99
+ offset += head.length + name.length + packed.length;
100
+ }
101
+ const directory = Buffer.concat(central);
102
+ const end = Buffer.alloc(22);
103
+ end.writeUInt32LE(0x06054b50, 0);
104
+ end.writeUInt16LE(entries.length, 8);
105
+ end.writeUInt16LE(entries.length, 10);
106
+ end.writeUInt32LE(directory.length, 12);
107
+ end.writeUInt32LE(offset, 16);
108
+ return Buffer.concat([...local, directory, end]);
109
+ };
110
+ /** `sha256sum`'s format: the hex digest, two spaces, the file name; one line per file. */
111
+ export const checksums = (files) => files.map((file) => `${createHash("sha256").update(file.data).digest("hex")} ${file.name}\n`).join("");
package/dist/args.js CHANGED
@@ -4,8 +4,8 @@ export const INIT_HELP = `vlx init [app-name] [options]
4
4
 
5
5
  Make a new application. Without options it asks what it needs to know.
6
6
 
7
- --web develop for the web (a flag per target: ${TARGETS.map((each) => `--${each.id}`).join(", ")})
8
- -y, --yes answer every question with its default (all targets)
7
+ --web, --desktop the targets: the web, a desktop window, or both (a flag per target: ${TARGETS.map((each) => `--${each.id}`).join(", ")})
8
+ -y, --yes answer every question with its default (the web)
9
9
  --pm <npm|pnpm|yarn|bun> the package manager (default: the one that ran this, else npm)
10
10
  --no-install do not install the dependencies
11
11
  --no-git do not run git init
@@ -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
+ }