@valancex/cli 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +111 -9
- package/dist/add.d.ts +9 -1
- package/dist/add.js +68 -5
- package/dist/archive.d.ts +17 -0
- package/dist/archive.js +111 -0
- package/dist/args.js +2 -2
- package/dist/build.d.ts +3 -0
- package/dist/build.js +98 -29
- package/dist/check.js +2 -2
- package/dist/cli.js +59 -16
- package/dist/compile.d.ts +13 -4
- package/dist/compile.js +46 -19
- package/dist/config.d.ts +14 -0
- package/dist/config.js +51 -4
- package/dist/content.js +2 -1
- package/dist/dev-desktop.d.ts +8 -0
- package/dist/dev-desktop.js +114 -0
- package/dist/dev.d.ts +3 -0
- package/dist/dev.js +41 -6
- package/dist/emit.d.ts +7 -4
- package/dist/emit.js +105 -27
- package/dist/init.js +14 -4
- package/dist/markdown.js +2 -1
- package/dist/package.d.ts +18 -0
- package/dist/package.js +141 -0
- package/dist/project.js +1 -1
- package/dist/security.d.ts +4 -0
- package/dist/security.js +35 -0
- package/dist/targets.d.ts +9 -1
- package/dist/targets.js +28 -1
- package/dist/ui/init.js +4 -4
- package/dist/ui/run.d.ts +3 -1
- package/dist/ui/run.js +5 -3
- package/dist/vite.d.ts +3 -2
- package/dist/vite.js +3 -3
- package/package.json +7 -3
- package/schemas/valance.desktop.schema.json +17 -0
- package/schemas/valance.schema.json +22 -4
- package/schemas/valance.web.schema.json +46 -4
- package/templates/desktop/README.md +14 -0
- package/templates/desktop/content/404.md +7 -0
- package/templates/desktop/content/guide/getting-started.md +15 -0
- package/templates/desktop/content/index.md +14 -0
- package/templates/desktop/gitignore +4 -0
- package/templates/desktop/src/layout.mprx +23 -0
- package/templates/desktop/src/main.ts +15 -0
- package/templates/desktop/tsconfig.json +18 -0
- package/templates/multi/README.md +13 -0
- package/templates/multi/content/404.md +7 -0
- package/templates/multi/content/guide/getting-started.md +15 -0
- package/templates/multi/content/index.md +14 -0
- package/templates/multi/src/desktop/layout.mprx +21 -0
- package/templates/multi/src/main.ts +23 -0
- package/templates/multi/src/web/layout.mprx +27 -0
- package/templates/multi/src/web/styles.css +28 -0
- package/templates/multi/src/web/theme-toggle.mprx +1 -0
- package/templates/multi/src/web/theme-toggle.ts +10 -0
- package/templates/web/content/guide/getting-started.md +1 -1
- package/views/block.mprx +1 -1
- package/views-desktop/block.mprx +3 -0
- package/views-desktop/blocks.mprx +3 -0
- package/views-desktop/inline.mprx +3 -0
- package/views-desktop/layout.mprx +20 -0
- package/views-desktop/list-block.mprx +3 -0
- package/views-desktop/list-items.mprx +3 -0
- package/views-desktop/runs.mprx +3 -0
- /package/views/{heading.mprx → heading-block.mprx} +0 -0
package/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
|
|
|
@@ -98,8 +108,8 @@ That is a complete application. It uses a built-in layout. Everything below is h
|
|
|
98
108
|
|
|
99
109
|
| Path | What it is | Edited by |
|
|
100
110
|
|---|---|---|
|
|
101
|
-
| `valance.json` | The project: its `name
|
|
102
|
-
| `valance.web.json` | The web target: `base` (where it is served, default `/`)
|
|
111
|
+
| `valance.json` | The project: its `name`, and optionally the `title` format (see [The document's title](#the-documents-title)). | you |
|
|
112
|
+
| `valance.web.json` | The web target: `base` (where it is served, default `/`), `url` (where it is published; adds canonical links and `sitemap.xml`), `api` (the backend: [below](#talking-to-a-backend)), `headers` (write the [security headers](#security-headers), default on) and `announce` (tell assistive technology about page changes, default on). Optional. | you |
|
|
103
113
|
| `content/**/*.md` | The pages. A file's path is its address. | you |
|
|
104
114
|
| `public/**` | Files served as they are (`public/logo.svg` is `/logo.svg`). | you |
|
|
105
115
|
| `src/layout.mprx` | The shell around every page. Optional: without a `layout` in `src/main.ts` a default ships. | you |
|
|
@@ -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
|
|
|
@@ -141,6 +151,7 @@ Templates are MPRX. Conditional and repeated parts are blocks: `{#if cond}…{:e
|
|
|
141
151
|
| `hasToc`, `toc` | The `##`/`###` headings of this page: `[{ id, href, label, level }]` |
|
|
142
152
|
| `blocks` | The page body. `<blocks items={blocks} />` draws it |
|
|
143
153
|
| `prev`, `next` | `{ href, label }`, empty `href` when there is none |
|
|
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. |
|
|
144
155
|
|
|
145
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).
|
|
146
157
|
|
|
@@ -175,7 +186,7 @@ export default component(template, {
|
|
|
175
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`.
|
|
176
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`).
|
|
177
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.
|
|
178
|
-
- `override: [component(runs, { props: { spans: … } })]` replaces a built-in (`runs`, `heading`, …) with your template of the same name.
|
|
189
|
+
- `override: [component(runs, { props: { spans: … } })]` replaces a built-in (`runs`, `heading-block`, …) with your template of the same name.
|
|
179
190
|
- `layout` is optional; without it the built-in layout draws the page. Its template must be named `layout.mprx`.
|
|
180
191
|
|
|
181
192
|
### Children and slots
|
|
@@ -228,6 +239,97 @@ export default defineApp({
|
|
|
228
239
|
|
|
229
240
|
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.
|
|
230
241
|
|
|
242
|
+
### Values worked out, kept, and moved between
|
|
243
|
+
|
|
244
|
+
`defineApp` has more than state and commands. All of it is the same on every target:
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
export default defineApp({
|
|
248
|
+
state: { items: field(Schema.Array(Item), []), theme: field(Schema.Literal("system", "light", "dark"), "system") },
|
|
249
|
+
// Worked out from the state, with their types, for a template to read: `{summary}`.
|
|
250
|
+
derived: (derive) => ({ summary: derive(Schema.String, (state) => `${state.items.length} items`) }),
|
|
251
|
+
// Kept between runs through the platform's `Store` (the page's storage; a file in the user's data directory on the desktop).
|
|
252
|
+
// "navigation" keeps the pages visited, where the engine keeps them (a window; a page's list is the browser's).
|
|
253
|
+
persist: ["items", "theme", "navigation"],
|
|
254
|
+
// What the window asks to look like (`"light"`, `"dark"` or `"system"`); the layout reads `colorScheme`, the system's.
|
|
255
|
+
appearance: (state) => state.theme,
|
|
256
|
+
// Asked before `go`, `back` and `forward` (the router's `Guard`): `false` stops the move, `redirect(page)` sends it elsewhere.
|
|
257
|
+
guard: ({ to, kind }) => to.name !== "/private",
|
|
258
|
+
});
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
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`.
|
|
262
|
+
|
|
263
|
+
## The document's title
|
|
264
|
+
|
|
265
|
+
A page's title is its front-matter `title` (else its first heading), written with the project's format in `valance.json`:
|
|
266
|
+
|
|
267
|
+
```json
|
|
268
|
+
{ "name": "Notes", "title": "{page} · {site}" }
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`{page}` is the page's part and `{site}` the name; the format must contain `{page}`, and a page whose title is the site's name is that name alone. The application can say its own parts from its state:
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
export default defineApp({
|
|
275
|
+
state: { unread: field(Schema.Number, 0) },
|
|
276
|
+
title: (state, page) => ({ status: state.unread > 0 ? String(state.unread) : undefined }), // "(3) Inbox · Notes"
|
|
277
|
+
});
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
`title` returns `{ page?, app?, status? }` (a string is the page part). `status` is what is going on right now and belongs to no page (an unread count, "Saving…"); it is written first, in parentheses. A target that is not a web page uses the same three parts for its own surfaces (a window title, a navigation bar, a badge). The function runs for every committed state, on the server for the one it renders and in the browser after each; if it throws, the default title is used and the failure is logged by name.
|
|
281
|
+
|
|
282
|
+
Every title and meta is **plain text** by the time it reaches the document: control characters and the bidirectional override and isolate characters (U+202A to U+202E, U+2066 to U+2069) are removed (they let a string reorder or hide what is around it in a tab or a history list), whitespace is collapsed, and a title is cut at 200 characters. A title is written only when it changed. After a page changes in place, a polite live region says the new title and focus moves to `#content` (when your layout's `<main id="content" tabindex="-1">` is focusable, as the scaffold's is), as a page load would; a link to a `#heading` is left to the browser. Turn it off with `"announce": false` in `valance.web.json`.
|
|
283
|
+
|
|
284
|
+
## Talking to a backend
|
|
285
|
+
|
|
286
|
+
NEXUS and VALANCE hold no HTTP: what an application needs from where it runs is a **capability**, which the platform supplies. `@valancex/common/http` is that contract for HTTP, and the web page supplies it with `fetch`.
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
import { defineApp, field } from "@valancex/common";
|
|
290
|
+
import { request } from "@valancex/common/http";
|
|
291
|
+
import { Schema } from "effect";
|
|
292
|
+
|
|
293
|
+
const Inbox = Schema.Struct({ unread: Schema.Number });
|
|
294
|
+
|
|
295
|
+
export default defineApp({
|
|
296
|
+
state: { unread: field(Schema.Number, 0), failed: field(Schema.String, "") },
|
|
297
|
+
commands: (command) => ({
|
|
298
|
+
refresh: command.waiting(Schema.Struct({}), {
|
|
299
|
+
work: () => request({ path: "/inbox", response: Inbox }),
|
|
300
|
+
wanted: () => true,
|
|
301
|
+
settle: (outcome, _input, state) => (outcome.ok ? { ...state, unread: outcome.value.unread, failed: "" } : { ...state, failed: "could not load" }),
|
|
302
|
+
}),
|
|
303
|
+
}),
|
|
304
|
+
});
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Where the requests go is `api` in `valance.web.json`:
|
|
308
|
+
|
|
309
|
+
```json
|
|
310
|
+
{ "api": { "base": "/api", "proxy": "http://localhost:3000" } }
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
`base` is a path on the site's own origin (the default is the origin's root) or an absolute `https` address; `proxy` is for `vlx dev`: requests under a path `base` are forwarded to it, so the browser sees one origin and the backend needs no CORS settings.
|
|
314
|
+
|
|
315
|
+
What the client does, so a command does not have to: the path must start with a single `/` (a path built from data cannot send the request elsewhere: `//host`, a scheme, a backslash or `..` is `Unsafe`); it sends and accepts JSON and refuses any other answer type (an HTML error page is `Decode`, never state); the answer is **decoded by the Schema you give** before it reaches the command; it is bounded in time (15 s, `Timeout`) and size (`TooLarge`); it is cancelled when the application ends (`Aborted`); same-origin credentials only, and an `api.base` on another origin gets none unless the application asks; unsafe methods carry `x-requested-with: valance`, which a plain cross-site form cannot send. A failure is one of `Offline`, `Status(code)`, `Decode`, `Timeout`, `Aborted`, `TooLarge` or `Unsafe` and **never carries the answer, the address or a header**, which can hold a secret.
|
|
316
|
+
|
|
317
|
+
A static site cannot keep a secret: nothing in `src/` or `valance.web.json` should be one. For sign-in on the web, prefer a same-origin session cookie (`HttpOnly`, `Secure`, `SameSite=Lax` or `Strict`, set by the backend) to a token in `localStorage`, which any script that gets past the policy can read. A desktop or mobile target supplies the same `Http` capability with the platform's own client and a keychain-backed token; no command changes. Data fetched while building (`vlx build`) is not part of the model, and server rendering of a command that waits is not either.
|
|
318
|
+
|
|
319
|
+
## Security headers
|
|
320
|
+
|
|
321
|
+
`vlx build` writes `_headers` beside the site (the file Netlify and Cloudflare Pages read; other hosts take the same lines):
|
|
322
|
+
|
|
323
|
+
```
|
|
324
|
+
Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; object-src 'none'
|
|
325
|
+
X-Content-Type-Options: nosniff
|
|
326
|
+
Referrer-Policy: strict-origin-when-cross-origin
|
|
327
|
+
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
|
|
328
|
+
Cross-Origin-Opener-Policy: same-origin
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
The page's runtime is WebAssembly, so a strict policy needs **`'wasm-unsafe-eval'`** in `script-src` and nothing more permissive: no `'unsafe-inline'`, no `'unsafe-eval'`. Without it the server's HTML stays and the application never starts (`data-valance="failed"`). `connect-src` adds the origin of an absolute `api.base`. The site's own test runs under this exact policy with no violation. If you serve the site with your own policy, start from this one. Turn the file off with `"headers": false`. `vlx dev` does not send it (the bundler's development client needs inline scripts).
|
|
332
|
+
|
|
231
333
|
## What `vlx dev` does when you save
|
|
232
334
|
|
|
233
335
|
| You change | It does |
|
|
@@ -237,7 +339,7 @@ A state field is its type and its default in one declaration. Hand-building `sta
|
|
|
237
339
|
| `src/**/*.css` | updates the style in the open page, no reload |
|
|
238
340
|
| `public/**` | reloads |
|
|
239
341
|
| a new or removed stylesheet or `src/main.ts` | rewrites the entry, reloads |
|
|
240
|
-
| `valance.json`, `valance.web.json` | restarts the server (the name and the
|
|
342
|
+
| `valance.json`, `valance.web.json` | restarts the server (the name, the base and the proxy are fixed at startup) |
|
|
241
343
|
|
|
242
344
|
A mistake leaves the last good build serving and appears in the terminal and as an overlay in the page, as `file:line:column code: message`. Fixing it reloads the page. There is no hot swap of the compiled templates yet: a reload is the correct, reliable update, and the browser keeps its address.
|
|
243
345
|
|
|
@@ -249,7 +351,7 @@ A mistake leaves the last good build serving and appears in the terminal and as
|
|
|
249
351
|
$ pnpm add -D @valancex/mesh-lsp
|
|
250
352
|
```
|
|
251
353
|
|
|
252
|
-
The VS Code extension in [`editors/vscode`](../../editors/vscode)
|
|
354
|
+
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)).
|
|
253
355
|
|
|
254
356
|
## Diagnostics
|
|
255
357
|
|
|
@@ -259,6 +361,6 @@ Every mistake the tools can attribute to a file has one shape: `{ file, line, co
|
|
|
259
361
|
|
|
260
362
|
- 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).
|
|
261
363
|
- The Markdown dialect is a subset; no syntax highlighting, tables, callouts, tabs, search, versions or languages (the verifier has these as stand-ins).
|
|
262
|
-
- The tag table is fixed. An application cannot declare new primitives.
|
|
263
|
-
-
|
|
364
|
+
- 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.
|
|
365
|
+
- 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.
|
|
264
366
|
- Updates are reloads, not hot swaps (except styles).
|
package/dist/add.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
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;
|
package/dist/archive.js
ADDED
|
@@ -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
|
|
8
|
-
-y, --yes answer every question with its default (
|
|
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
|
package/dist/build.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TargetId } from "./targets.js";
|
|
1
2
|
export interface Built {
|
|
2
3
|
readonly dir: string;
|
|
3
4
|
readonly files: ReadonlyArray<string>;
|
|
@@ -10,5 +11,7 @@ export type BuildStage = "configuration" | "content" | "templates" | "bundle" |
|
|
|
10
11
|
export interface BuildOptions {
|
|
11
12
|
/** Called as each stage begins. */
|
|
12
13
|
readonly onStage?: (stage: BuildStage) => void;
|
|
14
|
+
/** Which of the project's targets to build; all of them by default. */
|
|
15
|
+
readonly targets?: ReadonlyArray<TargetId>;
|
|
13
16
|
}
|
|
14
17
|
export declare const build: (root: string, options?: BuildOptions) => Promise<Built>;
|