@valancex/cli 0.13.0 → 0.15.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 (50) hide show
  1. package/README.md +33 -6
  2. package/dist/budgets.json +175 -0
  3. package/dist/build.d.ts +8 -0
  4. package/dist/build.js +38 -9
  5. package/dist/check.js +6 -1
  6. package/dist/cli.js +164 -6
  7. package/dist/compile.d.ts +8 -0
  8. package/dist/compile.js +135 -16
  9. package/dist/config.d.ts +12 -0
  10. package/dist/config.js +34 -1
  11. package/dist/contract.d.ts +18 -1
  12. package/dist/contract.js +92 -3
  13. package/dist/dev.d.ts +5 -0
  14. package/dist/dev.js +60 -26
  15. package/dist/emit.d.ts +3 -1
  16. package/dist/emit.js +56 -17
  17. package/dist/libraries.d.ts +41 -0
  18. package/dist/libraries.js +169 -0
  19. package/dist/library.d.ts +32 -0
  20. package/dist/library.js +333 -0
  21. package/dist/package.js +8 -4
  22. package/dist/recording-guard.d.ts +5 -0
  23. package/dist/recording-guard.js +28 -0
  24. package/dist/replay.d.ts +15 -0
  25. package/dist/replay.js +59 -0
  26. package/dist/rules.d.ts +23 -0
  27. package/dist/rules.js +123 -0
  28. package/dist/runtime-guard.d.ts +11 -0
  29. package/dist/runtime-guard.js +62 -0
  30. package/dist/security.d.ts +2 -0
  31. package/dist/security.js +8 -1
  32. package/dist/size.d.ts +54 -0
  33. package/dist/size.js +153 -0
  34. package/dist/theme.d.ts +41 -0
  35. package/dist/theme.js +84 -0
  36. package/dist/tree.d.ts +14 -2
  37. package/dist/tree.js +16 -1
  38. package/dist/ui/report.js +1 -1
  39. package/dist/ui/run.d.ts +1 -0
  40. package/dist/ui/run.js +13 -0
  41. package/dist/vite.js +17 -3
  42. package/package.json +13 -8
  43. package/schemas/valance.schema.json +76 -0
  44. package/templates/desktop/gitignore +3 -0
  45. package/templates/multi/src/web/layout.mprx +2 -2
  46. package/templates/web/gitignore +3 -0
  47. package/templates/web/src/layout.mprx +2 -2
  48. package/views/block.mprx +1 -1
  49. package/views/layout.mprx +2 -2
  50. package/views/list-items.mprx +1 -1
package/dist/cli.js CHANGED
@@ -4,11 +4,13 @@
4
4
  // vlx dev [--port N] [--host H] serve the project and rebuild it as it changes
5
5
  // vlx build write the site to dist/web/
6
6
  // vlx check [--json] find mistakes in authored files without building
7
+ // vlx theme check [--json] the theme: its tokens, recipes, contrast and what each target applies
7
8
  // vlx generate page|component <name> add a page, or a component registered in src/main.ts (alias: g)
8
9
  // vlx sync bring .valance/ (generated types, manifest, entries) up to date; runs by itself after install and generate
9
10
  // vlx help [command] what a command does
10
11
  //
11
12
  // In a terminal, `init`, `dev`, `build` and `check` draw an interface (Ink, `ui/`). Anywhere else (a pipe, CI, `--json`, `--plain`) they print the plain text they always have.
13
+ import { resolve } from "node:path";
12
14
  import { readFileSync } from "node:fs";
13
15
  import { pathToFileURL } from "node:url";
14
16
  import { INIT_HELP, parseInit } from "./args.js";
@@ -16,19 +18,26 @@ import { build } from "./build.js";
16
18
  import { packageDesktop } from "./package.js";
17
19
  import { generateComponent, generatePage, generateTarget, GenerateError, KINDS } from "./add.js";
18
20
  import { check } from "./check.js";
21
+ import { themeReport } from "./theme.js";
19
22
  import { findRoot } from "./config.js";
20
23
  import { localCli } from "./delegate.js";
21
24
  import { dev } from "./dev.js";
25
+ import { defaultRecording, replayFile } from "./replay.js";
22
26
  import { AppError, format } from "./diagnostics.js";
23
- import { detectPackageManager, directoryFor, directoryProblem, execute, nameProblem, runCommand, titleOf } from "./init.js";
27
+ import { detectPackageManager, directoryFor, directoryProblem, execute, install as installDependencies, nameProblem, PACKAGE_MANAGERS, runCommand, titleOf } from "./init.js";
28
+ import { buildLibrary, checkLibrary, scaffoldLibrary } from "./library.js";
24
29
  import { DEFAULT_TARGETS, TARGETS } from "./targets.js";
25
30
  import { canAsk, canDraw } from "./tty.js";
26
31
  const HELP = `vlx <command>
27
32
 
28
33
  init [name] [--web] make a new application
29
- dev [--port N] [--host H] serve it and rebuild as it changes
34
+ dev [--port N] [--host H] serve it and rebuild as it changes (--record [file]: write a session log)
35
+ replay <log> [--route R] [--allow-user-data] play a session log against this project and find where it differs
30
36
  build write the site to dist/web/
31
37
  check [--json] find mistakes in your files without building
38
+ theme check [--json] check the theme: tokens, recipes, contrast, and what each target applies
39
+ init library <name> make a component library: a workspace with the package and a playground
40
+ library build|check build a library's dist/ (JavaScript, declarations, templates, styles); check the package
32
41
  generate page|component <name> add a page, or a component (alias: g)
33
42
  generate target web|desktop add a target to the project
34
43
  package --desktop the desktop target as one executable beside its window (unsigned)
@@ -51,14 +60,40 @@ const GENERATE_HELP = `vlx generate <page|component|target> <name> (alias: vlx
51
60
 
52
61
  Nothing is overwritten: a name that is taken is refused.
53
62
  `;
63
+ const LIBRARY_HELP = `vlx library <build|check> [--json] [--app <dir>]
64
+
65
+ Run in a library's package directory (the one with the "valance" export condition; \`vlx init library\` makes one).
66
+
67
+ vlx library build dist/: the TypeScript compiled to JavaScript with declarations (the components' contracts are the
68
+ types component() gives them), and every .mprx and .css under src/ copied beside it. Templates ship
69
+ as source: the application's build compiles them. Needs typescript installed in the library.
70
+ vlx library check the package as a consumer's build needs it: the "valance" condition on exports["."] and the file it
71
+ names, no other entry a tool without the condition would load, a peer dependency on
72
+ @valancex/common, stylesheets that exist and "sideEffects" that keeps them; then the playground
73
+ (--app <dir>, default ../../playground, ./playground or ../playground), an application that depends
74
+ on this package: it must build every target the package declares and write every template the
75
+ package ships, and its own \`vlx check\` problems are reported with the library's.
76
+
77
+ Exits 0 when there is nothing to say, 1 when there is. --json prints the diagnostics.
78
+ `;
79
+ const INIT_LIBRARY_HELP = `vlx init library <name> [--pm <npm|pnpm|yarn|bun>] [--no-install] [--cli <spec>]
80
+
81
+ Make a component library: a workspace with packages/<name> (a sample component, written with the portable core, its contract,
82
+ a tsconfig and a package.json that is marked as a library) and playground/ (an application that depends on it and builds
83
+ the web and the desktop). <name> is the package name (acme-ui, @acme/ui); the directory is the name's last part.
84
+ Then: cd <dir>, install, \`vlx library build\` in packages/<name>, \`vlx library check\`, and the playground's \`vlx dev\`.
85
+ `;
54
86
  const COMMAND_HELP = {
55
- init: INIT_HELP,
87
+ init: `${INIT_HELP}\n${INIT_LIBRARY_HELP}`,
88
+ library: LIBRARY_HELP,
56
89
  generate: GENERATE_HELP,
57
90
  g: GENERATE_HELP,
58
- dev: "vlx dev [--web|--desktop] [--port N] [--host H]\n\nServe the project (the web is served, a desktop project opens its window; a project of more than one target does both, or the one named) and rebuild it as files change. A mistake leaves the last good build serving and shows in the terminal and in the page.\n",
91
+ replay: "vlx replay <log> [--route changes,update,render] [--json] [--allow-user-data]\n\nPlay a session log (written by vlx dev --record, or one of MESH's conformance logs) against this project's templates by the three routes of the equivalence law, and find the first line where one differs from the log: a tree, an event's outcome, a program or manifest that is not the one recorded. It runs no command and reads no clock. Exits 0 when every route gave what the log recorded, 1 when one did not, 2 when the log cannot be read. A log that holds user data (every recording does: its header says containsUserData) is refused unless you pass --allow-user-data: a divergence prints what was typed and shown.\n",
92
+ dev: "vlx dev [--web|--desktop] [--port N] [--host H] [--record [file]]\n\nServe the project (the web is served, a desktop project opens its window; a project of more than one target does both, or the one named) and rebuild it as files change. A mistake leaves the last good build serving and shows in the terminal and in the page. --record writes the web page's session as a log (default .valance/sessions/<time>.jsonl) for vlx replay: development only, never uploaded, and it holds what the person typed.\n",
59
93
  build: "vlx build [--web] [--desktop]\n\nBuild every target the project has (or the ones named). The web: the site, in dist/web/; any host that serves files can serve it. The desktop: the engine, in dist/desktop/.\n",
60
94
  sync: "vlx sync\n\nBring .valance/ up to date: the generated types for your templates (so your editor and tsc agree with them), the manifest and the entries. You rarely run it: a new project does it after install (the `prepare` script), `vlx generate` does it after it writes, and dev, build and check do it as they run. It never fails: mistakes in your files are reported by vlx check.\n",
61
95
  package: "vlx package --desktop [--platform linux-x64|win32-x64] [--window <path>] [--node <path>] [--archive]\n\nBuild the desktop target and package it: the engine as one executable (Node's single executable application, with the MESH runtime inside) beside the Desktop PORT's window program, in dist/package/<platform>/. --window is the window program for that platform (default: VLX_WINDOW, else vlx-window on the PATH; build it from the PORT repository). For another platform, --node is a node binary of this version for it. --archive also writes the package as one file, a .tar.gz (Linux) or a .zip (Windows), with its SHA-256 beside it. The result is unsigned.\n",
96
+ theme: "vlx theme check [--json]\n\nCheck the theme: the project's `theme/theme.json` (its tokens by mode, in the Design Tokens format, and its recipes), or the default theme when it has none. Reports a token that does not resolve, a recipe that names a part, state, variant, property or token that does not exist, a removed focus ring, the contrast of every pair the theme declares (WCAG 2.2), and what the web and the desktop apply of it. --json prints the diagnostics, the contrast and the coverage.\n",
62
97
  check: "vlx check [--json]\n\nFind mistakes in your files without building. --json prints them as { file, line, column, code, message } for editors and CI.\n",
63
98
  };
64
99
  /** Refreshes `.valance/` and says so in a line. Best effort: a mistake in the project is `check`'s to report, and a project with no `valance.json` (the install ran before init finished) is left alone. */
@@ -79,6 +114,16 @@ const option = (args, name) => {
79
114
  const at = args.indexOf(name);
80
115
  return at < 0 ? undefined : args[at + 1];
81
116
  };
117
+ /** `--record` alone, `--record file` or `--record=file`: the file, `""` for the default, nothing when it is not there. A word after it that is another flag is not its file. */
118
+ const recordOption = (args) => {
119
+ const joined = args.find((each) => each.startsWith("--record="));
120
+ if (joined !== undefined) {
121
+ return joined.slice("--record=".length);
122
+ }
123
+ const at = args.indexOf("--record");
124
+ const next = at < 0 ? undefined : args[at + 1];
125
+ return at < 0 ? undefined : next === undefined || next.startsWith("-") ? "" : next;
126
+ };
82
127
  const version = () => JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
83
128
  const runInit = async (args, cwd) => {
84
129
  if (args.name !== undefined) {
@@ -124,6 +169,34 @@ const runInit = async (args, cwd) => {
124
169
  console.log(`vlx init: next: cd ${answers.dir}${outcome.installed ? "" : ` && ${answers.packageManager} install`} && ${runCommand(answers.packageManager)} dev`);
125
170
  return 0;
126
171
  };
172
+ const runInitLibrary = async (args, cwd) => {
173
+ const positional = args.filter((each, index) => !each.startsWith("-") && !["--pm", "--cli"].includes(args[index - 1] ?? ""));
174
+ const name = positional[0];
175
+ const manager = option(args, "--pm");
176
+ if (name === undefined || !/^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(name)) {
177
+ console.error(`vlx init library: name the package: vlx init library acme-ui (lowercase letters, digits, - . _; or @scope/name)\n\n${INIT_LIBRARY_HELP}`);
178
+ return 2;
179
+ }
180
+ if (manager !== undefined && !PACKAGE_MANAGERS.includes(manager)) {
181
+ console.error(`vlx init library: --pm is one of ${PACKAGE_MANAGERS.join(", ")}`);
182
+ return 2;
183
+ }
184
+ const dir = directoryFor(name.replace(/^@[^/]+\//, ""), cwd);
185
+ const problem = directoryProblem(dir);
186
+ if (problem !== undefined) {
187
+ console.error(`vlx init library: ${problem}`);
188
+ return 1;
189
+ }
190
+ const answers = { name, dir, packageManager: manager ?? detectPackageManager(), ...(option(args, "--cli") === undefined ? {} : { cli: option(args, "--cli") }) };
191
+ const files = scaffoldLibrary(answers);
192
+ console.log(`vlx init library: created ${name} in ${dir} (${files.length} files)`);
193
+ if (!args.includes("--no-install")) {
194
+ const ran = await installDependencies(answers);
195
+ console.log(ran.ok ? "vlx init library: installed" : `vlx init library: the dependencies were not installed (${answers.packageManager} install failed); run it yourself in ${dir}`);
196
+ }
197
+ console.log(`vlx init library: next: cd ${dir}/packages/${name.replace(/^@[^/]+\//, "")} && vlx library build && vlx library check`);
198
+ return 0;
199
+ };
127
200
  export const main = async (args, cwd = process.cwd()) => {
128
201
  const [command, ...rest] = args;
129
202
  // `vlx dev --help` says what dev does and runs nothing (`init` and `generate` answer for themselves below).
@@ -137,7 +210,16 @@ export const main = async (args, cwd = process.cwd()) => {
137
210
  return 0;
138
211
  }
139
212
  // A global `vlx` runs the project's own (see `delegate.ts`).
140
- if (command === "dev" || command === "build" || command === "package" || command === "check" || command === "sync" || command === "generate" || command === "g") {
213
+ if (command === "dev" ||
214
+ command === "replay" ||
215
+ command === "build" ||
216
+ command === "package" ||
217
+ command === "check" ||
218
+ command === "theme" ||
219
+ command === "library" ||
220
+ command === "sync" ||
221
+ command === "generate" ||
222
+ command === "g") {
141
223
  const local = localCli(cwd);
142
224
  if (local !== undefined) {
143
225
  const theirs = (await import(pathToFileURL(local).href));
@@ -152,6 +234,9 @@ export const main = async (args, cwd = process.cwd()) => {
152
234
  console.log(INIT_HELP);
153
235
  return 0;
154
236
  }
237
+ if (rest[0] === "library") {
238
+ return await runInitLibrary(rest.slice(1), cwd);
239
+ }
155
240
  const parsed = parseInit(rest);
156
241
  if (typeof parsed === "string") {
157
242
  console.error(`vlx init: ${parsed}\n\n${INIT_HELP}`);
@@ -168,9 +253,12 @@ export const main = async (args, cwd = process.cwd()) => {
168
253
  return 2;
169
254
  }
170
255
  const target = picked[0];
256
+ const asked = recordOption(rest);
257
+ const record = asked === undefined ? undefined : asked === "" ? defaultRecording(findRoot(cwd)) : resolve(cwd, asked);
171
258
  if (draws) {
172
259
  const { drawDev } = await import("./ui/run.js");
173
260
  await drawDev(findRoot(cwd), {
261
+ ...(record === undefined ? {} : { record }),
174
262
  ...(target === undefined ? {} : { target }),
175
263
  ...(port === undefined ? {} : { port: Number(port) }),
176
264
  ...(host === undefined ? {} : { host }),
@@ -178,6 +266,7 @@ export const main = async (args, cwd = process.cwd()) => {
178
266
  return 0;
179
267
  }
180
268
  const running = await dev(findRoot(cwd), {
269
+ ...(record === undefined ? {} : { record }),
181
270
  ...(target === undefined ? {} : { target }),
182
271
  ...(port === undefined ? {} : { port: Number(port) }),
183
272
  ...(host === undefined ? {} : { host }),
@@ -192,6 +281,21 @@ export const main = async (args, cwd = process.cwd()) => {
192
281
  await running.close();
193
282
  return 0;
194
283
  }
284
+ case "replay": {
285
+ const file = rest.find((each, index) => !each.startsWith("-") && rest[index - 1] !== "--route");
286
+ if (file === undefined) {
287
+ console.error("vlx replay: name a log: vlx replay <log> [--route changes,update,render] [--json]");
288
+ return 2;
289
+ }
290
+ const routes = option(rest, "--route");
291
+ const replayed = await replayFile(findRoot(cwd), resolve(cwd, file), {
292
+ ...(routes === undefined ? {} : { routes: routes.split(",") }),
293
+ json: rest.includes("--json"),
294
+ allowUserData: rest.includes("--allow-user-data"),
295
+ });
296
+ (replayed.code === 0 ? console.log : console.error)(replayed.output);
297
+ return replayed.code;
298
+ }
195
299
  case "build": {
196
300
  if (draws) {
197
301
  const { drawBuild } = await import("./ui/run.js");
@@ -200,7 +304,7 @@ export const main = async (args, cwd = process.cwd()) => {
200
304
  }
201
305
  const started = Date.now();
202
306
  const flagged = targetFlags(rest);
203
- const built = await build(findRoot(cwd), flagged.length === 0 ? {} : { targets: flagged });
307
+ const built = await build(findRoot(cwd), { ...(flagged.length === 0 ? {} : { targets: flagged }), onReport: (report) => report.forEach((line) => console.log(line)) });
204
308
  console.log(`vlx build: ${built.pages.length} pages, ${built.files.length} files → ${built.dir} (${((Date.now() - started) / 1000).toFixed(1)}s)`);
205
309
  return 0;
206
310
  }
@@ -242,6 +346,60 @@ export const main = async (args, cwd = process.cwd()) => {
242
346
  }
243
347
  return result.diagnostics.length === 0 ? 0 : 1;
244
348
  }
349
+ case "library": {
350
+ const sub = rest[0];
351
+ if (sub !== "build" && sub !== "check") {
352
+ console.error("vlx library: say what: vlx library build | vlx library check [--json] [--app <dir>]");
353
+ return 2;
354
+ }
355
+ const dir = cwd;
356
+ const json = rest.includes("--json");
357
+ const given = option(rest, "--app");
358
+ const report = (diagnostics) => {
359
+ if (json) {
360
+ console.log(JSON.stringify({ diagnostics }, null, 2));
361
+ }
362
+ else {
363
+ diagnostics.forEach((each) => console.error(`${each.file}${each.line > 0 ? `:${each.line}` : ""} ${each.code}: ${each.message}`));
364
+ console.log(diagnostics.length === 0 ? `vlx library ${sub}: no problems` : `vlx library ${sub}: ${diagnostics.length} problem${diagnostics.length === 1 ? "" : "s"}`);
365
+ }
366
+ return diagnostics.length === 0 ? 0 : 1;
367
+ };
368
+ if (sub === "build") {
369
+ const built = buildLibrary(dir);
370
+ if (!json && built.diagnostics.length === 0) {
371
+ console.log(`vlx library build: dist/ written; ${built.copied.length} template and stylesheet file${built.copied.length === 1 ? "" : "s"} copied as source`);
372
+ }
373
+ return report(built.diagnostics);
374
+ }
375
+ return report(await checkLibrary(dir, given === undefined ? {} : { app: resolve(cwd, given) }));
376
+ }
377
+ case "theme": {
378
+ if (rest[0] !== "check") {
379
+ console.error("vlx theme: say what: vlx theme check [--json]");
380
+ return 2;
381
+ }
382
+ const report = themeReport(findRoot(cwd));
383
+ if (rest.includes("--json")) {
384
+ console.log(JSON.stringify({ theme: report.name, custom: report.custom, diagnostics: report.diagnostics, contrast: report.contrast, coverage: report.coverage }, null, 2));
385
+ return report.diagnostics.length === 0 ? 0 : 1;
386
+ }
387
+ console.log(`vlx theme check: ${report.custom ? "the project's theme" : "the default theme"} "${report.name}" (modes: ${report.modes.join(", ")})`);
388
+ for (const each of report.contrast) {
389
+ console.log(` contrast ${each.mode.padEnd(9)} ${each.text} on ${each.on} ${each.ratio.toFixed(2)}:1 (min ${each.min}) ${each.passes ? "ok" : "BELOW"}`);
390
+ }
391
+ for (const each of report.coverage) {
392
+ console.log(` ${each.target}: ${each.styled.length} of ${each.styled.length + each.unstyled.length} primitives of the portable core styled${each.unstyled.length === 0 ? "" : ` (not styled: ${each.unstyled.join(", ")})`}`);
393
+ for (const note of each.notApplied) {
394
+ console.log(` not applied: ${note.primitive} ${note.kind} ${note.name} (${note.support})`);
395
+ }
396
+ }
397
+ report.diagnostics.forEach((each) => {
398
+ console.error(format(each));
399
+ });
400
+ console.log(report.diagnostics.length === 0 ? "vlx theme check: no problems" : `vlx theme check: ${report.diagnostics.length} problem${report.diagnostics.length === 1 ? "" : "s"}`);
401
+ return report.diagnostics.length === 0 ? 0 : 1;
402
+ }
245
403
  case "sync": {
246
404
  const synced = await sync(cwd);
247
405
  console.log(synced);
package/dist/compile.d.ts CHANGED
@@ -17,8 +17,16 @@ interface Template {
17
17
  readonly source: string;
18
18
  readonly builtIn: boolean;
19
19
  }
20
+ /** A library component left out of a target's program because its library does not support the target (K5): the tag is reported at each use, once, where the application can act. */
21
+ export interface Excluded {
22
+ readonly id: string;
23
+ readonly library: string;
24
+ readonly supports: ReadonlyArray<string>;
25
+ }
20
26
  export interface Templates {
21
27
  readonly all: ReadonlyArray<Template>;
28
+ /** The library components this target leaves out, by tag. */
29
+ readonly excluded: ReadonlyMap<string, Excluded>;
22
30
  /** The declared tree, in `src/main.ts`'s own words. */
23
31
  readonly tree: ReadonlyArray<Node>;
24
32
  }
package/dist/compile.js CHANGED
@@ -8,12 +8,14 @@ import { basename, join, relative, sep } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { commandTable, scopeSchema } from "@valancex/common/internal";
10
10
  import { AppError, diagnostic } from "./diagnostics.js";
11
- import { declarationsOf, linked, nameOf, treeOf } from "./tree.js";
11
+ import { declarationsOf, declaredOf, linked, nameOf, treeOf } from "./tree.js";
12
12
  import { tagsOf } from "./outline.js";
13
13
  import { entryOf } from "./contract.js";
14
+ import { idOf, libraryOf, librariesOf } from "./libraries.js";
14
15
  import { locateKey } from "./wiring.js";
15
16
  import { namesOf, webVocabulary, WHOLE } from "@valancex/common/internal";
16
- import { ENTRY_FILE } from "./project.js";
17
+ import { checkReferences, checkTemplate } from "./rules.js";
18
+ import { ENTRY_FILE, GENERATED_DIR } from "./project.js";
17
19
  export const SOURCE_DIR = "src";
18
20
  /** The component the page is drawn by. */
19
21
  export const ROOT = "layout";
@@ -47,7 +49,26 @@ export const templatesOf = (root, author, vocabulary = webVocabulary, views = BU
47
49
  }
48
50
  const layout = author?.layout;
49
51
  const overrides = author?.override ?? [];
50
- const declared = treeOf(layout, overrides, author?.components ?? []);
52
+ const { libraries } = librariesOf(root);
53
+ const resolved = author?.resolve ?? {};
54
+ const overridden = new Set(overrides.map((each) => nameOf(each.template.mprx)));
55
+ const excluded = new Map();
56
+ const idFor = (component) => idOf(libraries, root, component.template.mprx, nameOf(component.template.mprx));
57
+ // The components of this target's program. A library's component is left out when its library does not support this target (K5: one diagnostic at the use, not several inside the library), when the
58
+ // application's own template of that name replaces it (`override`), and when `resolve` chose another of that name (K14).
59
+ const components = (author?.components ?? []).filter((each) => {
60
+ const name = nameOf(each.template.mprx);
61
+ const library = libraryOf(libraries, root, each.template.mprx);
62
+ if (library === undefined) {
63
+ return true;
64
+ }
65
+ if (!library.targets.includes(vocabulary.target)) {
66
+ excluded.set(name, { id: idFor(each), library: library.name, supports: library.targets });
67
+ return false;
68
+ }
69
+ return !overridden.has(name) && (resolved[name] === undefined || resolved[name] === idFor(each));
70
+ });
71
+ const declared = treeOf(layout, overrides, components);
51
72
  const declarations = declarationsOf(root);
52
73
  const problems = [];
53
74
  /** Where a template's component is defined: its `component(…)` call, else where it is imported, else the top of the entry file. */
@@ -68,17 +89,46 @@ export const templatesOf = (root, author, vocabulary = webVocabulary, views = BU
68
89
  const builtInNames = new Set(found.keys());
69
90
  for (const each of overrides) {
70
91
  const name = nameOf(each.template.mprx);
71
- if (!builtInNames.has(name) && name !== ROOT) {
72
- problem(at(each.template.mprx), "override-unknown", `<${name}> is not a built-in component, so there is nothing to override; list it in the \`uses\` of the component that uses it`);
92
+ if (!builtInNames.has(name) &&
93
+ name !== ROOT &&
94
+ !(author?.components ?? []).some((other) => nameOf(other.template.mprx) === name && libraryOf(libraries, root, other.template.mprx) !== undefined)) {
95
+ problem(at(each.template.mprx), "override-unknown", `<${name}> is not a built-in component and no library component in \`components\` has that name, so there is nothing to override; register it in \`components\` instead`);
96
+ }
97
+ }
98
+ for (const [tag, id] of Object.entries(resolved)) {
99
+ if (!(author?.components ?? []).some((each) => idFor(each) === id)) {
100
+ const named = (author?.components ?? []).filter((each) => nameOf(each.template.mprx) === tag).map(idFor);
101
+ problem({ file: ENTRY_FILE, line: 1, column: 1 }, "resolve-unknown", `\`resolve\` says <${tag}> is ${id}, but no component in \`components\` has that id${named.length === 0 ? ` (nothing named ${tag} is listed)` : ` (listed with that name: ${named.join(", ")})`}`);
102
+ }
103
+ }
104
+ // A name is one component. Two files of one name, from anywhere in the definition (two libraries', or a library's and the application's), are an error: the first listed must not win silently
105
+ // (decision K14). A component that is not listed in `override` and is named like a built-in is the same collision, with the built-in.
106
+ // A library's component is named by its qualified id (`acme-ui/disclosure`) in a diagnostic and in `resolve`; the application's own, by the bare name.
107
+ const listed = declaredOf(layout, overrides, components);
108
+ const firstOf = new Map();
109
+ const idOfFile = (file, name) => idOf(libraries, root, file, name);
110
+ for (const each of listed) {
111
+ const first = firstOf.get(each.name);
112
+ const id = idOfFile(each.file, each.name);
113
+ if (first === undefined) {
114
+ firstOf.set(each.name, { file: each.file, id });
115
+ if (each.role === "component" &&
116
+ builtInNames.has(each.name) &&
117
+ resolved[each.name] !== id &&
118
+ !listed.some((other) => other.role !== "component" && other.name === each.name)) {
119
+ problem(at(each.file), "template-builtin", `${id} is <${each.name}>, which is also a built-in component: list your own template of that name in \`override\` to replace the built-in, or choose this one with \`resolve: { ${each.name}: "${id}" }\``);
120
+ }
121
+ }
122
+ else if (first.file !== each.file) {
123
+ const libraryMade = id !== each.name || first.id !== each.name;
124
+ problem(at(each.file), "template-duplicate", libraryMade
125
+ ? `${id === each.name ? each.file : id} and ${first.id === each.name ? first.file : first.id} are both <${each.name}>: a component is named by its file, so the name must be unique; choose one with \`resolve: { ${each.name}: "${id === each.name ? first.id : id}" }\` in defineApp, or replace both with your own template of that name in \`override\``
126
+ : `${each.file} is also <${each.name}>, like ${first.file}: a component is named by its file, so the name must be unique; rename one of the files`);
73
127
  }
74
128
  }
75
129
  const files = new Map();
76
130
  for (const node of declared) {
77
131
  const place = at(node.file);
78
- if (files.has(node.name) && files.get(node.name) !== node.file) {
79
- problem(place, "template-duplicate", `${files.get(node.name)} is also <${node.name}>: a component is named by its file, so the name must be unique`);
80
- continue;
81
- }
82
132
  if (namesOf(vocabulary).includes(node.name)) {
83
133
  problem(place, "template-primitive", `<${node.name}> is already a tag the framework draws as an element; name the file something else`);
84
134
  continue;
@@ -98,7 +148,7 @@ export const templatesOf = (root, author, vocabulary = webVocabulary, views = BU
98
148
  const claimed = new Set(files.values());
99
149
  const whole = (author?.[WHOLE] ?? author);
100
150
  for (const parts of [whole, ...Object.values(whole?.targets ?? {})]) {
101
- for (const node of treeOf(parts?.layout, parts?.override ?? [], parts?.components ?? [])) {
151
+ for (const node of declaredOf(parts?.layout, parts?.override ?? [], parts?.components ?? [])) {
102
152
  claimed.add(node.file);
103
153
  }
104
154
  }
@@ -113,7 +163,7 @@ export const templatesOf = (root, author, vocabulary = webVocabulary, views = BU
113
163
  if (problems.length > 0) {
114
164
  throw new AppError(problems);
115
165
  }
116
- return { all: [...found.values()], tree };
166
+ return { all: [...found.values()], tree, excluded };
117
167
  };
118
168
  /** The templates the root reaches, following the tags each uses. A template nothing uses is not part of the program (the compiler refuses one). */
119
169
  const reachable = (all) => {
@@ -143,8 +193,19 @@ export const manifestOf = (author, vocabulary = webVocabulary) => {
143
193
  if (!("tags" in vocabulary)) {
144
194
  return { ...derived, components: { ...vocabulary.components, ...derived.components } };
145
195
  }
146
- // The props the vocabulary says a template may leave out.
196
+ // What the vocabulary declares of a tag's props and events (the specification's) settles what the table alone derived.
147
197
  const components = { ...derived.components };
198
+ for (const [tag, declared] of Object.entries(vocabulary.declared ?? {})) {
199
+ const entry = components[tag];
200
+ if (entry !== undefined) {
201
+ components[tag] = {
202
+ ...entry,
203
+ props: Object.fromEntries(Object.entries(entry.props).map(([name, prop]) => [name, declared.props[name] === undefined ? prop : { ...prop, ...declared.props[name] }])),
204
+ events: Object.fromEntries(Object.entries(entry.events).map(([name, event]) => [name, declared.events[name] === undefined ? event : { ...event, ...declared.events[name] }])),
205
+ };
206
+ }
207
+ }
208
+ // The props the vocabulary says a template may leave out.
148
209
  for (const [tag, names] of Object.entries(vocabulary.optional ?? {})) {
149
210
  const entry = components[tag];
150
211
  if (entry !== undefined) {
@@ -188,8 +249,10 @@ const modelOf = (root, author, tree, vocabulary) => {
188
249
  const { entry, problems: bad } = entryOf(node.interface);
189
250
  const site = declarations.get(node.file);
190
251
  for (const each of bad) {
191
- const place = site?.member(each.kind, each.name) ?? site?.call ?? site?.imported ?? { file: ENTRY_FILE, line: 1, column: 1 };
192
- problems.push(diagnostic(place.file, place.line, "component-schema", `${node.name}'s ${each.kind === "props" ? "prop" : "event"} "${each.name}" has no type the model can hold: ${each.reason}`, place.column));
252
+ const place = site?.member(each.kind === "unit" ? "props" : each.kind, each.name) ?? site?.call ?? site?.imported ?? { file: ENTRY_FILE, line: 1, column: 1 };
253
+ problems.push(diagnostic(place.file, place.line, "component-schema", each.message === undefined
254
+ ? `${node.name}'s ${each.kind === "props" ? "prop" : each.kind === "unit" ? "unit field or command" : "event"} "${each.name}" has no type the model can hold: ${each.reason}`
255
+ : `${node.name}: ${each.message}`, place.column));
193
256
  }
194
257
  entries[node.name] = entry;
195
258
  }
@@ -198,9 +261,35 @@ const modelOf = (root, author, tree, vocabulary) => {
198
261
  }
199
262
  return { ...derived, components: { ...derived.components, ...entries } };
200
263
  };
264
+ /** The anchors of the content's headings: the ids the built-in views give them (the generated `content.json`, which the content stage wrote before this one). */
265
+ const anchorsOf = (root) => {
266
+ try {
267
+ const content = JSON.parse(readFileSync(join(root, GENERATED_DIR, "content.json"), "utf8"));
268
+ return new Set((content.pages ?? []).flatMap((page) => (page.blocks ?? []).flatMap((block) => (block.anchor === undefined ? [] : [block.anchor]))));
269
+ }
270
+ catch {
271
+ return new Set();
272
+ }
273
+ };
274
+ /** A tag the web renamed (decision P7: `para` is `paragraph`): said where a template still writes the old one. */
275
+ const renamedHint = (vocabulary, used, at) => {
276
+ // a prop the web renamed (decision I29: a popover's `kind` is `mode`): the diagnostic names the tag and the prop
277
+ const prop = /component "([^"]+)" has no prop "([^"]+)"/.exec(at.message ?? "");
278
+ const renamedProp = prop === null || !("tags" in vocabulary) ? undefined : vocabulary.renamedProps?.[prop[1]]?.[prop[2]];
279
+ if (renamedProp !== undefined) {
280
+ return ` (\`${prop[2]}\` on \`${prop[1]}\` is now \`${renamedProp}\`)`;
281
+ }
282
+ const renamed = "tags" in vocabulary ? vocabulary.renamed : undefined;
283
+ const line = used.find((template) => template.path === at.path)?.source.split("\n")[at.span.start.line - 1];
284
+ if (renamed === undefined || line === undefined) {
285
+ return "";
286
+ }
287
+ const tag = /<\/?([A-Za-z][\w-]*)/.exec(line.slice(Math.max(0, at.span.start.column - 2)))?.[1];
288
+ return tag !== undefined && Object.hasOwn(renamed, tag) ? ` (\`${tag}\` is now \`${renamed[tag]}\`)` : "";
289
+ };
201
290
  /** Compiles the application's templates against a target's vocabulary (the web's by default). Fails with every diagnostic, each pointing into the file it is about. */
202
291
  export const compileViews = async (root, author, vocabulary = webVocabulary, views = BUILT_IN) => {
203
- const { all, tree } = templatesOf(root, author, vocabulary, views);
292
+ const { all, tree, excluded } = templatesOf(root, author, vocabulary, views);
204
293
  const used = reachable(all);
205
294
  const model = modelOf(root, author, tree, vocabulary);
206
295
  if (!used.some((template) => template.component === ROOT)) {
@@ -215,12 +304,42 @@ export const compileViews = async (root, author, vocabulary = webVocabulary, vie
215
304
  const found = [
216
305
  ...(result.components ?? []).flatMap((entry) => entry.diagnostics.diagnostics
217
306
  .filter((each) => each.severity === "error")
218
- .map((each) => diagnostic(each.path, each.span.start.line, each.code, each.message + (each.suggestions.length > 0 ? ` (did you mean ${each.suggestions.map((suggestion) => `"${suggestion.replacement}"`).join(" or ")}?)` : ""), each.span.start.column))),
307
+ .map((each) => {
308
+ // a library component this target leaves out (K5): one sentence at the use, in place of the compiler's "unknown component"
309
+ const left = each.code === "unknown-component" ? excluded.get(/component "([^"]+)"/.exec(each.message)?.[1] ?? "") : undefined;
310
+ return left === undefined
311
+ ? each
312
+ : {
313
+ ...each,
314
+ code: "unsupported-target",
315
+ message: `<${each.message.match(/component "([^"]+)"/)[1]}> is ${left.id}, and ${left.library} supports ${left.supports.join(" and ")} only; this project builds for ${vocabulary.target}. Use a component that ${vocabulary.target} has, or build ${left.library} for ${vocabulary.target}`,
316
+ suggestions: [],
317
+ };
318
+ })
319
+ .map((each) => diagnostic(each.path, each.span.start.line, each.code, each.message +
320
+ (each.suggestions.length > 0 ? ` (did you mean ${each.suggestions.map((suggestion) => `"${suggestion.replacement}"`).join(" or ")}?)` : "") +
321
+ renamedHint(vocabulary, used, each), each.span.start.column))),
219
322
  ...(result.assembly?.diagnostics ?? []).map((each) => diagnostic(each.path ?? "program", each.span?.start.line ?? 0, each.code, each.message, each.span?.start.column ?? 0)),
220
323
  ];
221
324
  throw new AppError(found.map((each) => relocate(each, root)));
222
325
  }
223
326
  const program = result.program;
327
+ const rules = "tags" in vocabulary ? vocabulary.rules : undefined;
328
+ if (rules !== undefined) {
329
+ const texts = program.templates;
330
+ const known = new Set(namesOf(vocabulary));
331
+ const broken = used.flatMap((template, index) => checkTemplate(template.path, template.source, JSON.parse(texts[index] ?? "{}"), rules, known, tree.find((node) => node.name === template.component)?.interface?.name));
332
+ const compiled = used.map((template, index) => ({
333
+ file: template.path,
334
+ source: template.source,
335
+ template: JSON.parse(texts[index] ?? "{}"),
336
+ builtIn: template.builtIn,
337
+ }));
338
+ broken.push(...checkReferences(compiled, anchorsOf(root)));
339
+ if (broken.length > 0) {
340
+ throw new AppError(broken.map((each) => relocate(each, root)));
341
+ }
342
+ }
224
343
  const enriched = program.model;
225
344
  const manifestOfProgram = typeof enriched === "string" ? JSON.parse(enriched) : enriched;
226
345
  return {
package/dist/config.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Budget } from "./size.js";
1
2
  import type { SiteInfo } from "@valancex/common/internal";
2
3
  export declare const PROJECT_FILE = "valance.json";
3
4
  export declare const WEB_FILE = "valance.web.json";
@@ -23,6 +24,17 @@ export interface Config {
23
24
  readonly base: string;
24
25
  readonly proxy: string;
25
26
  };
27
+ /** The size budgets a production build is held to (Tier 2): the project's own, or none, in which case the framework's start-up budgets apply (W14). Never checked by `vlx dev`. */
28
+ readonly budgets: ReadonlyArray<Budget> | undefined;
29
+ /** What `vlx dev --record` leaves out of a session log (Tier 2; R9, R14): nothing, unless the project says. */
30
+ /** The theme package the project uses instead of the default theme (Tier 2; K12): an installed package whose `valance` field is `{ "kind": "theme" }`. Absent: the `theme/` directory, else the default. */
31
+ readonly theme: string | undefined;
32
+ readonly record: {
33
+ readonly redact: {
34
+ readonly scope: ReadonlyArray<ReadonlyArray<string>>;
35
+ readonly commands: ReadonlyArray<string>;
36
+ };
37
+ };
26
38
  }
27
39
  /** Reads and validates the project's configuration. Every problem in both files is reported together. */
28
40
  export declare const loadConfig: (root: string) => Config;
package/dist/config.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // `schemas/` describe the same files for editors; this is the check the tools make.
4
4
  import { existsSync, readFileSync, realpathSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
+ import { budgetProblems } from "./size.js";
6
7
  import { AppError, diagnostic, lineOf } from "./diagnostics.js";
7
8
  import { DEFAULT_TITLE } from "@valancex/common/internal";
8
9
  export const PROJECT_FILE = "valance.json";
@@ -65,6 +66,9 @@ const url = (value) => {
65
66
  const title = (value) => typeof value === "string" && value.includes("{page}") && value.length <= 120 && !/[{}]/.test(value.replaceAll("{page}", "").replaceAll("{site}", ""))
66
67
  ? undefined
67
68
  : 'must contain "{page}" and may use "{site}", for example "{page} · {site}" (at most 120 characters)';
69
+ const themePackage = (value) => typeof value === "string" && /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value)
70
+ ? undefined
71
+ : 'must be the name of an installed theme package, such as "acme-theme" or "@acme/theme"';
68
72
  const flag = (value) => (typeof value === "boolean" ? undefined : "must be true or false");
69
73
  const api = (value) => {
70
74
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
@@ -91,6 +95,32 @@ const api = (value) => {
91
95
  }
92
96
  return undefined;
93
97
  };
98
+ const record = (value) => {
99
+ const redact = value?.redact;
100
+ if (typeof value !== "object" ||
101
+ value === null ||
102
+ Array.isArray(value) ||
103
+ Object.keys(value).some((key) => key !== "redact") ||
104
+ typeof redact !== "object" ||
105
+ redact === null ||
106
+ Array.isArray(redact)) {
107
+ return 'must be an object such as { "redact": { "scope": [["password"]], "commands": ["login/type"] } }';
108
+ }
109
+ const { scope, commands, ...others } = redact;
110
+ if (Object.keys(others).length > 0) {
111
+ return `"redact" has unknown settings ${Object.keys(others)
112
+ .map((key) => `"${key}"`)
113
+ .join(", ")} (known: scope, commands)`;
114
+ }
115
+ if (scope !== undefined &&
116
+ (!Array.isArray(scope) || scope.some((path) => !Array.isArray(path) || path.length === 0 || path.some((step) => typeof step !== "string" || step === "")))) {
117
+ return '"redact.scope" must be a list of paths, each a non-empty list of field names ("*" is any list index or field): [["password"], ["people", "*", "pin"]]';
118
+ }
119
+ if (commands !== undefined && (!Array.isArray(commands) || commands.some((name) => typeof name !== "string" || !/^[\w-]+\/[\w-]+$/.test(name)))) {
120
+ return '"redact.commands" must be a list of "component/name" commands whose parameters are not recorded: ["login/type"]';
121
+ }
122
+ return undefined;
123
+ };
94
124
  /** Reads and validates the project's configuration. Every problem in both files is reported together. */
95
125
  export const loadConfig = (root) => {
96
126
  const project = read(root, PROJECT_FILE);
@@ -101,7 +131,7 @@ export const loadConfig = (root) => {
101
131
  const desktop = read(root, DESKTOP_FILE);
102
132
  const problems = [
103
133
  ...(desktop === undefined ? [] : check(DESKTOP_FILE, desktop.text, desktop.value, { $schema: schema, version }, [])),
104
- ...check(PROJECT_FILE, project.text, project.value, { $schema: schema, version, name: text, title }, ["name"]),
134
+ ...check(PROJECT_FILE, project.text, project.value, { $schema: schema, version, name: text, title, record, budgets: budgetProblems, theme: themePackage }, ["name"]),
105
135
  ...(web === undefined ? [] : check(WEB_FILE, web.text, web.value, { $schema: schema, version, base, url, headers: flag, announce: flag, api }, [])),
106
136
  ];
107
137
  if (problems.length > 0) {
@@ -115,6 +145,9 @@ export const loadConfig = (root) => {
115
145
  title: named.title ?? DEFAULT_TITLE,
116
146
  web: { base: given.base ?? "/", url: given.url ?? "", headers: given.headers ?? true, announce: given.announce ?? true },
117
147
  api: { base: given.api?.base ?? "", proxy: given.api?.proxy ?? "" },
148
+ budgets: named.budgets,
149
+ theme: named.theme,
150
+ record: { redact: { scope: named.record?.redact.scope ?? [], commands: named.record?.redact.commands ?? [] } },
118
151
  };
119
152
  };
120
153
  export const siteInfo = (config) => ({