@valancex/cli 0.11.0 → 0.12.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
@@ -98,8 +98,8 @@ That is a complete application. It uses a built-in layout. Everything below is h
98
98
 
99
99
  | Path | What it is | Edited by |
100
100
  |---|---|---|
101
- | `valance.json` | The project: its `name`. | you |
102
- | `valance.web.json` | The web target: `base` (where it is served, default `/`) and `url` (where it is published; adds canonical links and `sitemap.xml`). Optional. | you |
101
+ | `valance.json` | The project: its `name`, and optionally the `title` format (see [The document's title](#the-documents-title)). | you |
102
+ | `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
103
  | `content/**/*.md` | The pages. A file's path is its address. | you |
104
104
  | `public/**` | Files served as they are (`public/logo.svg` is `/logo.svg`). | you |
105
105
  | `src/layout.mprx` | The shell around every page. Optional: without a `layout` in `src/main.ts` a default ships. | you |
@@ -141,6 +141,7 @@ Templates are MPRX. Conditional and repeated parts are blocks: `{#if cond}…{:e
141
141
  | `hasToc`, `toc` | The `##`/`###` headings of this page: `[{ id, href, label, level }]` |
142
142
  | `blocks` | The page body. `<blocks items={blocks} />` draws it |
143
143
  | `prev`, `next` | `{ href, label }`, empty `href` when there is none |
144
+ | `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
145
 
145
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).
146
147
 
@@ -228,6 +229,76 @@ export default defineApp({
228
229
 
229
230
  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
231
 
232
+ ## The document's title
233
+
234
+ A page's title is its front-matter `title` (else its first heading), written with the project's format in `valance.json`:
235
+
236
+ ```json
237
+ { "name": "Notes", "title": "{page} · {site}" }
238
+ ```
239
+
240
+ `{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:
241
+
242
+ ```ts
243
+ export default defineApp({
244
+ state: { unread: field(Schema.Number, 0) },
245
+ title: (state, page) => ({ status: state.unread > 0 ? String(state.unread) : undefined }), // "(3) Inbox · Notes"
246
+ });
247
+ ```
248
+
249
+ `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.
250
+
251
+ 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`.
252
+
253
+ ## Talking to a backend
254
+
255
+ 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`.
256
+
257
+ ```ts
258
+ import { defineApp, field } from "@valancex/common";
259
+ import { request } from "@valancex/common/http";
260
+ import { Schema } from "effect";
261
+
262
+ const Inbox = Schema.Struct({ unread: Schema.Number });
263
+
264
+ export default defineApp({
265
+ state: { unread: field(Schema.Number, 0), failed: field(Schema.String, "") },
266
+ commands: (command) => ({
267
+ refresh: command.waiting(Schema.Struct({}), {
268
+ work: () => request({ path: "/inbox", response: Inbox }),
269
+ wanted: () => true,
270
+ settle: (outcome, _input, state) => (outcome.ok ? { ...state, unread: outcome.value.unread, failed: "" } : { ...state, failed: "could not load" }),
271
+ }),
272
+ }),
273
+ });
274
+ ```
275
+
276
+ Where the requests go is `api` in `valance.web.json`:
277
+
278
+ ```json
279
+ { "api": { "base": "/api", "proxy": "http://localhost:3000" } }
280
+ ```
281
+
282
+ `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.
283
+
284
+ 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.
285
+
286
+ 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.
287
+
288
+ ## Security headers
289
+
290
+ `vlx build` writes `_headers` beside the site (the file Netlify and Cloudflare Pages read; other hosts take the same lines):
291
+
292
+ ```
293
+ 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'
294
+ X-Content-Type-Options: nosniff
295
+ Referrer-Policy: strict-origin-when-cross-origin
296
+ Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
297
+ Cross-Origin-Opener-Policy: same-origin
298
+ ```
299
+
300
+ 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).
301
+
231
302
  ## What `vlx dev` does when you save
232
303
 
233
304
  | You change | It does |
@@ -237,7 +308,7 @@ A state field is its type and its default in one declaration. Hand-building `sta
237
308
  | `src/**/*.css` | updates the style in the open page, no reload |
238
309
  | `public/**` | reloads |
239
310
  | 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 base are fixed at startup) |
311
+ | `valance.json`, `valance.web.json` | restarts the server (the name, the base and the proxy are fixed at startup) |
241
312
 
242
313
  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
314
 
@@ -259,6 +330,6 @@ Every mistake the tools can attribute to a file has one shape: `{ file, line, co
259
330
 
260
331
  - 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
332
  - 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.
333
+ - 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.
263
334
  - Only the web target exists, delivered as a static site. No server rendering command, no `preview` command.
264
335
  - Updates are reloads, not hot swaps (except styles).
package/dist/build.js CHANGED
@@ -5,6 +5,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
6
  import { loadAuthor } from "./author.js";
7
7
  import { loadConfig } from "./config.js";
8
+ import { headersFile } from "./security.js";
8
9
  import { AppError } from "./diagnostics.js";
9
10
  import { generateContent, generateEntries, generateProgram } from "./emit.js";
10
11
  import { OUTPUT_DIR } from "./project.js";
@@ -62,6 +63,10 @@ export const build = async (root, options = {}) => {
62
63
  if (config.web.url !== "") {
63
64
  write("sitemap.xml", sitemap(content));
64
65
  }
66
+ // The headers to serve it with: the page is only as safe as the host makes it (src/security.ts).
67
+ if (config.web.headers) {
68
+ write("_headers", headersFile(config));
69
+ }
65
70
  return { dir: dist, files, pages };
66
71
  }
67
72
  finally {
package/dist/compile.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type * as Valance from "@valancex/valance";
2
2
  import type { DefinedApp } from "@valancex/common";
3
3
  import type { Node } from "./tree.js";
4
+ import type { Vocabulary } from "@valancex/common/internal";
4
5
  export declare const SOURCE_DIR = "src";
5
6
  /** The component the page is drawn by. */
6
7
  export declare const ROOT = "layout";
@@ -23,8 +24,8 @@ export interface Templates {
23
24
  * Built-in templates, then the author's. The author's are exactly the ones `src/main.ts` declares: `layout`, `components` and `override`. A file under `src/` that is not declared is an error, not
24
25
  * a guess; a registered component that no template writes is allowed (it is not part of the program until one does). Which components a template uses is read from the template. A name that is a built-in's replaces it only through `override`.
25
26
  */
26
- export declare const templatesOf: (root: string, author: DefinedApp | undefined) => Templates;
27
- export declare const manifestOf: (author: DefinedApp | undefined) => import("@valancex/valance/web/build").Manifest;
27
+ export declare const templatesOf: (root: string, author: DefinedApp | undefined, vocabulary?: Vocabulary) => Templates;
28
+ export declare const manifestOf: (author: DefinedApp | undefined, vocabulary?: Vocabulary) => import("@valancex/valance/web/build").Manifest;
28
29
  export interface Compiled {
29
30
  readonly program: Valance.Program;
30
31
  /**
@@ -46,6 +47,6 @@ export interface Compiled {
46
47
  readonly builtIn: boolean;
47
48
  }>;
48
49
  }
49
- /** Compiles the application's templates. Fails with every diagnostic, each pointing into the file it is about. */
50
- export declare const compileViews: (root: string, author: DefinedApp | undefined) => Promise<Compiled>;
50
+ /** Compiles the application's templates against a target's vocabulary (the web's by default). Fails with every diagnostic, each pointing into the file it is about. */
51
+ export declare const compileViews: (root: string, author: DefinedApp | undefined, vocabulary?: Vocabulary) => Promise<Compiled>;
51
52
  export {};
package/dist/compile.js CHANGED
@@ -12,7 +12,7 @@ import { declarationsOf, linked, nameOf, treeOf } from "./tree.js";
12
12
  import { tagsOf } from "./outline.js";
13
13
  import { entryOf } from "./contract.js";
14
14
  import { locateKey } from "./wiring.js";
15
- import { primitives } from "@valancex/common/internal";
15
+ import { webVocabulary } from "@valancex/common/internal";
16
16
  import { ENTRY_FILE } from "./project.js";
17
17
  export const SOURCE_DIR = "src";
18
18
  /** The component the page is drawn by. */
@@ -37,7 +37,7 @@ const entryText = (root) => {
37
37
  * Built-in templates, then the author's. The author's are exactly the ones `src/main.ts` declares: `layout`, `components` and `override`. A file under `src/` that is not declared is an error, not
38
38
  * a guess; a registered component that no template writes is allowed (it is not part of the program until one does). Which components a template uses is read from the template. A name that is a built-in's replaces it only through `override`.
39
39
  */
40
- export const templatesOf = (root, author) => {
40
+ export const templatesOf = (root, author, vocabulary = webVocabulary) => {
41
41
  const found = new Map();
42
42
  for (const file of mprxIn(BUILT_IN)) {
43
43
  const component = basename(file, ".mprx");
@@ -77,7 +77,7 @@ export const templatesOf = (root, author) => {
77
77
  problem(place, "template-duplicate", `${files.get(node.name)} is also <${node.name}>: a component is named by its file, so the name must be unique`);
78
78
  continue;
79
79
  }
80
- if (node.name in primitives) {
80
+ if (node.name in vocabulary.tags) {
81
81
  problem(place, "template-primitive", `<${node.name}> is already a tag the framework draws as an element; name the file something else`);
82
82
  continue;
83
83
  }
@@ -124,8 +124,8 @@ const reachable = (all) => {
124
124
  }
125
125
  return all.filter((template) => seen.has(template.component));
126
126
  };
127
- export const manifestOf = (author) => manifest({
128
- primitives,
127
+ export const manifestOf = (author, vocabulary = webVocabulary) => manifest({
128
+ primitives: vocabulary.tags,
129
129
  scopes: { [ROOT]: scopeSchema(author) },
130
130
  commands: commandTable(author),
131
131
  });
@@ -136,12 +136,12 @@ const isManifestError = (error) => error instanceof Error && error._tag === "Man
136
136
  * uses. MESH checks every use against it (an undeclared prop, a missing one, a wrong type, an undeclared event in a handler), at the use or in the template. A schema with no model type is a
137
137
  * diagnostic at the declaration, not a crash.
138
138
  */
139
- const modelOf = (root, author, tree) => {
139
+ const modelOf = (root, author, tree, vocabulary) => {
140
140
  const problems = [];
141
141
  const mainText = entryText(root);
142
142
  let derived;
143
143
  try {
144
- derived = manifestOf(author);
144
+ derived = manifestOf(author, vocabulary);
145
145
  }
146
146
  catch (error) {
147
147
  if (!isManifestError(error)) {
@@ -171,11 +171,11 @@ const modelOf = (root, author, tree) => {
171
171
  }
172
172
  return { ...derived, components: { ...derived.components, ...entries } };
173
173
  };
174
- /** Compiles the application's templates. Fails with every diagnostic, each pointing into the file it is about. */
175
- export const compileViews = async (root, author) => {
176
- const { all, tree } = templatesOf(root, author);
174
+ /** Compiles the application's templates against a target's vocabulary (the web's by default). Fails with every diagnostic, each pointing into the file it is about. */
175
+ export const compileViews = async (root, author, vocabulary = webVocabulary) => {
176
+ const { all, tree } = templatesOf(root, author, vocabulary);
177
177
  const used = reachable(all);
178
- const model = modelOf(root, author, tree);
178
+ const model = modelOf(root, author, tree, vocabulary);
179
179
  if (!used.some((template) => template.component === ROOT)) {
180
180
  throw new AppError([diagnostic(`${SOURCE_DIR}/${ROOT}.mprx`, 0, "layout-missing", "there is no layout")]);
181
181
  }
package/dist/config.d.ts CHANGED
@@ -5,9 +5,20 @@ export declare const WEB_FILE = "valance.web.json";
5
5
  export declare const CONFIG_VERSION = 1;
6
6
  export interface Config {
7
7
  readonly name: string;
8
+ /** How the document's title is written from a page's title and the site's name: `{page}` and `{site}`. */
9
+ readonly title: string;
8
10
  readonly web: {
9
11
  readonly base: string;
10
12
  readonly url: string;
13
+ /** Write the security headers file beside the built site. */
14
+ readonly headers: boolean;
15
+ /** Announce a page change made in place to assistive technology. */
16
+ readonly announce: boolean;
17
+ };
18
+ /** The application's backend: the address its requests are made to (a path on the site's own origin, or an absolute https address), and, for `vlx dev`, where a path is proxied to. */
19
+ readonly api: {
20
+ readonly base: string;
21
+ readonly proxy: string;
11
22
  };
12
23
  }
13
24
  /** Reads and validates the project's configuration. Every problem in both files is reported together. */
package/dist/config.js CHANGED
@@ -4,6 +4,7 @@
4
4
  import { existsSync, readFileSync, realpathSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
6
  import { AppError, diagnostic, lineOf } from "./diagnostics.js";
7
+ import { DEFAULT_TITLE } from "@valancex/common/internal";
7
8
  export const PROJECT_FILE = "valance.json";
8
9
  export const WEB_FILE = "valance.web.json";
9
10
  /** The only version of both files there is. */
@@ -60,6 +61,35 @@ const url = (value) => {
60
61
  return "must be an absolute address such as https://example.com";
61
62
  }
62
63
  };
64
+ const title = (value) => typeof value === "string" && value.includes("{page}") && value.length <= 120 && !/[{}]/.test(value.replaceAll("{page}", "").replaceAll("{site}", ""))
65
+ ? undefined
66
+ : 'must contain "{page}" and may use "{site}", for example "{page} · {site}" (at most 120 characters)';
67
+ const flag = (value) => (typeof value === "boolean" ? undefined : "must be true or false");
68
+ const api = (value) => {
69
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
70
+ return 'must be an object such as { "base": "/api" }';
71
+ }
72
+ const given = value;
73
+ const unknown = Object.keys(given).filter((key) => key !== "base" && key !== "proxy");
74
+ if (unknown.length > 0) {
75
+ return `has unknown settings ${unknown.map((key) => `"${key}"`).join(", ")} (known: base, proxy)`;
76
+ }
77
+ if (typeof given["base"] !== "string" || !/^(\/([\w.~-]+\/?)*|https:\/\/[^\s/]+(\/[^\s]*)?)$/.test(given["base"]) || given["base"].endsWith("//")) {
78
+ return '"base" must be a path on the site ("/api") or an absolute https address ("https://api.example.com")';
79
+ }
80
+ if (given["proxy"] !== undefined) {
81
+ try {
82
+ const target = new URL(String(given["proxy"]));
83
+ if (!target.protocol.startsWith("http") || !given["base"].startsWith("/")) {
84
+ return '"proxy" is an http(s) address that a path "base" is forwarded to while you run vlx dev';
85
+ }
86
+ }
87
+ catch {
88
+ return '"proxy" must be an absolute address such as http://localhost:3000';
89
+ }
90
+ }
91
+ return undefined;
92
+ };
63
93
  /** Reads and validates the project's configuration. Every problem in both files is reported together. */
64
94
  export const loadConfig = (root) => {
65
95
  const project = read(root, PROJECT_FILE);
@@ -68,16 +98,29 @@ export const loadConfig = (root) => {
68
98
  }
69
99
  const web = read(root, WEB_FILE);
70
100
  const problems = [
71
- ...check(PROJECT_FILE, project.text, project.value, { $schema: schema, version, name: text }, ["name"]),
72
- ...(web === undefined ? [] : check(WEB_FILE, web.text, web.value, { $schema: schema, version, base, url }, [])),
101
+ ...check(PROJECT_FILE, project.text, project.value, { $schema: schema, version, name: text, title }, ["name"]),
102
+ ...(web === undefined ? [] : check(WEB_FILE, web.text, web.value, { $schema: schema, version, base, url, headers: flag, announce: flag, api }, [])),
73
103
  ];
74
104
  if (problems.length > 0) {
75
105
  throw new AppError(problems);
76
106
  }
77
107
  const given = (web?.value ?? {});
78
- return { name: project.value.name, web: { base: given.base ?? "/", url: given.url ?? "" } };
108
+ const named = project.value;
109
+ return {
110
+ name: named.name,
111
+ title: named.title ?? DEFAULT_TITLE,
112
+ web: { base: given.base ?? "/", url: given.url ?? "", headers: given.headers ?? true, announce: given.announce ?? true },
113
+ api: { base: given.api?.base ?? "", proxy: given.api?.proxy ?? "" },
114
+ };
79
115
  };
80
- export const siteInfo = (config) => ({ name: config.name, base: config.web.base, url: config.web.url });
116
+ export const siteInfo = (config) => ({
117
+ name: config.name,
118
+ base: config.web.base,
119
+ url: config.web.url,
120
+ title: config.title,
121
+ api: config.api.base,
122
+ announce: config.web.announce,
123
+ });
81
124
  /**
82
125
  * The nearest directory at or above `from` that holds a `valance.json`, as its real path. The bundler names a file by its real path, so a project reached through a symlink, or by a short
83
126
  * Windows name (`RUNNER~1`), must be called the same thing here or a file is not the file the bundler means.
package/dist/content.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // discovery, front matter, Markdown, navigation order, and a check of every link and image against the pages and `public/` that exist.
3
3
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
4
4
  import { join, posix } from "node:path";
5
+ import { siteInfo } from "./config.js";
5
6
  import { AppError, diagnostic } from "./diagnostics.js";
6
7
  import { parseMarkdown, plain } from "./markdown.js";
7
8
  export const CONTENT_DIR = "content";
@@ -188,6 +189,6 @@ export const readContent = (root, config) => {
188
189
  throw new AppError(problems);
189
190
  }
190
191
  const ordered = [...pages].sort((a, b) => (a.section === "" ? 0 : 1) - (b.section === "" ? 0 : 1) || a.section.localeCompare(b.section) || a.order - b.order || a.path.localeCompare(b.path));
191
- return { site: { name: config.name, base: config.web.base, url: config.web.url }, pages: ordered, ...(fallback === undefined ? {} : { notFound: fallback }) };
192
+ return { site: siteInfo(config), pages: ordered, ...(fallback === undefined ? {} : { notFound: fallback }) };
192
193
  };
193
194
  export const sectionTitle = titleCase;
package/dist/dev.js CHANGED
@@ -54,7 +54,14 @@ const start = async (root, config, options, restart) => {
54
54
  const base = baseConfig(root, config);
55
55
  const server = await createServer({
56
56
  ...base,
57
- server: { port: options.port ?? 5173, host: options.host ?? "127.0.0.1", strictPort: false, fs: { allow: [root] } },
57
+ server: {
58
+ port: options.port ?? 5173,
59
+ host: options.host ?? "127.0.0.1",
60
+ strictPort: false,
61
+ fs: { allow: [root] },
62
+ // The backend, reached through the page's own origin: no CORS settings to write (valance.web.json `api`).
63
+ ...(config.api.proxy === "" || !config.api.base.startsWith("/") ? {} : { proxy: { [config.api.base.replace(/\/$/, "")]: { target: config.api.proxy, changeOrigin: true } } }),
64
+ },
58
65
  plugins: [
59
66
  ...(base.plugins ?? []),
60
67
  {
package/dist/markdown.js CHANGED
@@ -5,7 +5,8 @@ export const slug = (text) => text
5
5
  .replace(/[̀-ͯ]/g, "")
6
6
  .replace(/[^a-z0-9]+/g, "-")
7
7
  .replace(/^-+|-+$/g, "");
8
- const MARKS = /\*\*([^*]+)\*\*|`([^`]+)`|!\[[^\]]*\]\([^)\s]*\)|\[([^\]]+)\]\(([^)\s]+)\)|\*([^*\s][^*]*)\*|(?<![\w])_([^_\s][^_]*)_(?![\w])/g;
8
+ // A link's address may hold one level of balanced parentheses (`/a_(b)`, `javascript:alert(1)`), so it is read whole: a check that names it quotes all of it.
9
+ const MARKS = /\*\*([^*]+)\*\*|`([^`]+)`|!\[[^\]]*\]\((?:[^()\s]|\([^()\s]*\))*\)|\[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)|\*([^*\s][^*]*)\*|(?<![\w])_([^_\s][^_]*)_(?![\w])/g;
9
10
  const empty = { level: 0, anchor: "", spans: [], items: [], ordered: false, lang: "", text: "", src: "", alt: "" };
10
11
  export const plain = (spans) => spans.map((span) => span.text).join("");
11
12
  /**
@@ -0,0 +1,4 @@
1
+ import type { Config } from "./config.js";
2
+ export declare const contentSecurityPolicy: (config: Config) => string;
3
+ /** The header lines, for every path under the site's base. */
4
+ export declare const headersFile: (config: Config) => string;
@@ -0,0 +1,35 @@
1
+ /** The origin of the backend when `api.base` is an absolute address (the page may connect to it), else nothing: a path is the page's own origin. */
2
+ const apiOrigin = (config) => {
3
+ try {
4
+ return new URL(config.api.base).origin;
5
+ }
6
+ catch {
7
+ return undefined;
8
+ }
9
+ };
10
+ export const contentSecurityPolicy = (config) => {
11
+ const origin = apiOrigin(config);
12
+ return [
13
+ "default-src 'self'",
14
+ "script-src 'self' 'wasm-unsafe-eval'",
15
+ "style-src 'self'",
16
+ "img-src 'self' data:",
17
+ `connect-src 'self'${origin === undefined ? "" : ` ${origin}`}`,
18
+ "font-src 'self'",
19
+ "base-uri 'none'",
20
+ "form-action 'self'",
21
+ "frame-ancestors 'none'",
22
+ "object-src 'none'",
23
+ ].join("; ");
24
+ };
25
+ /** The header lines, for every path under the site's base. */
26
+ export const headersFile = (config) => {
27
+ const lines = [
28
+ `Content-Security-Policy: ${contentSecurityPolicy(config)}`,
29
+ "X-Content-Type-Options: nosniff",
30
+ "Referrer-Policy: strict-origin-when-cross-origin",
31
+ "Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()",
32
+ "Cross-Origin-Opener-Policy: same-origin",
33
+ ];
34
+ return `# Written by vlx build. Serve these headers with the site (Netlify and Cloudflare Pages read this file; copy the lines to any other host's configuration).\n# Turn it off with "headers": false in valance.web.json.\n${config.web.base}*\n${lines.map((line) => ` ${line}`).join("\n")}\n`;
35
+ };
package/dist/targets.d.ts CHANGED
@@ -1,8 +1,11 @@
1
+ import type { Vocabulary } from "@valancex/common/internal";
1
2
  export interface Target {
2
3
  /** The flag (`--web`) and the name in `valance.<id>.json`. */
3
4
  readonly id: "web";
4
5
  readonly label: string;
5
6
  readonly hint: string;
7
+ /** The tags its templates may write. A project builds against its target's vocabulary. */
8
+ readonly vocabulary: Vocabulary;
6
9
  }
7
10
  export declare const TARGETS: ReadonlyArray<Target>;
8
11
  export type TargetId = Target["id"];
package/dist/targets.js CHANGED
@@ -1,2 +1,7 @@
1
- export const TARGETS = [{ id: "web", label: "Web", hint: "a website: Markdown pages and templates, built to a static site (settings in valance.web.json)" }];
1
+ // The targets an application can be developed for. Today there is one, the web; the wizard, `vlx init` and the flags are written against this list, so a target that arrives later is one more entry
2
+ // here (and its template under `templates/`), not a change to them.
3
+ import { webVocabulary } from "@valancex/common/internal";
4
+ export const TARGETS = [
5
+ { id: "web", label: "Web", hint: "a website: Markdown pages and templates, built to a static site (settings in valance.web.json)", vocabulary: webVocabulary },
6
+ ];
2
7
  export const isTarget = (name) => TARGETS.some((each) => each.id === name);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valancex/cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "vlx, the VALANCE command: make an application (vlx init), serve it as you edit (vlx dev), build it to a static site (vlx build), check it (vlx check). Content, layout, components and styles in, a website out.",
5
5
  "keywords": [
6
6
  "valance",
@@ -53,12 +53,13 @@
53
53
  "ink": "7.1.1",
54
54
  "react": "19.2.8",
55
55
  "vite": "7.3.6",
56
- "@valancex/common": "^0.11.0",
57
- "@valancex/valance": "^0.11.0"
56
+ "@valancex/common": "^0.12.0",
57
+ "@valancex/valance": "^0.12.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^22.0.0",
61
61
  "@types/react": "19.2.18",
62
+ "@valancex/mesh-runtime": "^0.11.0",
62
63
  "ink-testing-library": "4.0.0",
63
64
  "playwright": "1.56.1",
64
65
  "typescript": "^5.6.0",
@@ -5,10 +5,28 @@
5
5
  "description": "The project: what the application is called. Settings for a target are in valance.<target>.json (valance.web.json for the web).",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
- "required": ["name"],
8
+ "required": [
9
+ "name"
10
+ ],
9
11
  "properties": {
10
- "$schema": { "type": "string" },
11
- "version": { "const": 1, "description": "The version of this file's format." },
12
- "name": { "type": "string", "minLength": 1, "description": "The application's name: the site's title in navigation and in every page's <title>." }
12
+ "$schema": {
13
+ "type": "string"
14
+ },
15
+ "version": {
16
+ "const": 1,
17
+ "description": "The version of this file's format."
18
+ },
19
+ "name": {
20
+ "type": "string",
21
+ "minLength": 1,
22
+ "description": "The application's name: the site's title in navigation and in every page's <title>."
23
+ },
24
+ "title": {
25
+ "type": "string",
26
+ "default": "{page} \u00b7 {site}",
27
+ "pattern": "^[^{}]*(\\{page\\}|\\{site\\})[^{}]*(\\{page\\}|\\{site\\}|[^{}])*$",
28
+ "maxLength": 120,
29
+ "description": "How a document's title is written: \"{page}\" is the page's title and \"{site}\" the name above. Must contain \"{page}\". A page whose title is the site's name is that name alone."
30
+ }
13
31
  }
14
32
  }
@@ -6,9 +6,51 @@
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "properties": {
9
- "$schema": { "type": "string" },
10
- "version": { "const": 1, "description": "The version of this file's format." },
11
- "base": { "type": "string", "pattern": "^/([\\w.~-]+/)*$", "default": "/", "description": "The path the site is served under: \"/\" or \"/docs/\". Every address the build writes is under it." },
12
- "url": { "type": "string", "pattern": "^https?://.*[^/]$", "description": "Where the site is published, without a trailing slash. With it, pages get canonical links and the build writes sitemap.xml." }
9
+ "$schema": {
10
+ "type": "string"
11
+ },
12
+ "version": {
13
+ "const": 1,
14
+ "description": "The version of this file's format."
15
+ },
16
+ "base": {
17
+ "type": "string",
18
+ "pattern": "^/([\\w.~-]+/)*$",
19
+ "default": "/",
20
+ "description": "The path the site is served under: \"/\" or \"/docs/\". Every address the build writes is under it."
21
+ },
22
+ "url": {
23
+ "type": "string",
24
+ "pattern": "^https?://.*[^/]$",
25
+ "description": "Where the site is published, without a trailing slash. With it, pages get canonical links and the build writes sitemap.xml."
26
+ },
27
+ "headers": {
28
+ "type": "boolean",
29
+ "default": true,
30
+ "description": "Write the recommended security headers (a strict Content-Security-Policy and others) as _headers beside the built site."
31
+ },
32
+ "announce": {
33
+ "type": "boolean",
34
+ "default": true,
35
+ "description": "After the application changes page in place, tell assistive technology: say the new title in a live region and focus the main content."
36
+ },
37
+ "api": {
38
+ "type": "object",
39
+ "additionalProperties": false,
40
+ "required": [
41
+ "base"
42
+ ],
43
+ "description": "The application's backend.",
44
+ "properties": {
45
+ "base": {
46
+ "type": "string",
47
+ "description": "Where its requests go: a path on the site's own origin (\"/api\") or an absolute https address (\"https://api.example.com\", which the Content-Security-Policy then allows)."
48
+ },
49
+ "proxy": {
50
+ "type": "string",
51
+ "description": "For vlx dev: forward requests under a path \"base\" to this address (\"http://localhost:3000\"), so the browser sees one origin and no CORS settings are needed."
52
+ }
53
+ }
54
+ }
13
55
  }
14
56
  }