@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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -9
  3. package/dist/add.d.ts +9 -1
  4. package/dist/add.js +68 -5
  5. package/dist/archive.d.ts +17 -0
  6. package/dist/archive.js +111 -0
  7. package/dist/args.js +2 -2
  8. package/dist/build.d.ts +3 -0
  9. package/dist/build.js +98 -29
  10. package/dist/check.js +2 -2
  11. package/dist/cli.js +59 -16
  12. package/dist/compile.d.ts +13 -4
  13. package/dist/compile.js +46 -19
  14. package/dist/config.d.ts +14 -0
  15. package/dist/config.js +51 -4
  16. package/dist/content.js +2 -1
  17. package/dist/dev-desktop.d.ts +8 -0
  18. package/dist/dev-desktop.js +114 -0
  19. package/dist/dev.d.ts +3 -0
  20. package/dist/dev.js +41 -6
  21. package/dist/emit.d.ts +7 -4
  22. package/dist/emit.js +105 -27
  23. package/dist/init.js +14 -4
  24. package/dist/markdown.js +2 -1
  25. package/dist/package.d.ts +18 -0
  26. package/dist/package.js +141 -0
  27. package/dist/project.js +1 -1
  28. package/dist/security.d.ts +4 -0
  29. package/dist/security.js +35 -0
  30. package/dist/targets.d.ts +9 -1
  31. package/dist/targets.js +28 -1
  32. package/dist/ui/init.js +4 -4
  33. package/dist/ui/run.d.ts +3 -1
  34. package/dist/ui/run.js +5 -3
  35. package/dist/vite.d.ts +3 -2
  36. package/dist/vite.js +3 -3
  37. package/package.json +7 -3
  38. package/schemas/valance.desktop.schema.json +17 -0
  39. package/schemas/valance.schema.json +22 -4
  40. package/schemas/valance.web.schema.json +46 -4
  41. package/templates/desktop/README.md +14 -0
  42. package/templates/desktop/content/404.md +7 -0
  43. package/templates/desktop/content/guide/getting-started.md +15 -0
  44. package/templates/desktop/content/index.md +14 -0
  45. package/templates/desktop/gitignore +4 -0
  46. package/templates/desktop/src/layout.mprx +23 -0
  47. package/templates/desktop/src/main.ts +15 -0
  48. package/templates/desktop/tsconfig.json +18 -0
  49. package/templates/multi/README.md +13 -0
  50. package/templates/multi/content/404.md +7 -0
  51. package/templates/multi/content/guide/getting-started.md +15 -0
  52. package/templates/multi/content/index.md +14 -0
  53. package/templates/multi/src/desktop/layout.mprx +21 -0
  54. package/templates/multi/src/main.ts +23 -0
  55. package/templates/multi/src/web/layout.mprx +27 -0
  56. package/templates/multi/src/web/styles.css +28 -0
  57. package/templates/multi/src/web/theme-toggle.mprx +1 -0
  58. package/templates/multi/src/web/theme-toggle.ts +10 -0
  59. package/templates/web/content/guide/getting-started.md +1 -1
  60. package/views/block.mprx +1 -1
  61. package/views-desktop/block.mprx +3 -0
  62. package/views-desktop/blocks.mprx +3 -0
  63. package/views-desktop/inline.mprx +3 -0
  64. package/views-desktop/layout.mprx +20 -0
  65. package/views-desktop/list-block.mprx +3 -0
  66. package/views-desktop/list-items.mprx +3 -0
  67. package/views-desktop/runs.mprx +3 -0
  68. /package/views/{heading.mprx → heading-block.mprx} +0 -0
package/dist/config.js CHANGED
@@ -4,8 +4,10 @@
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";
10
+ export const DESKTOP_FILE = "valance.desktop.json";
9
11
  /** The only version of both files there is. */
10
12
  export const CONFIG_VERSION = 1;
11
13
  const read = (root, file) => {
@@ -60,6 +62,35 @@ const url = (value) => {
60
62
  return "must be an absolute address such as https://example.com";
61
63
  }
62
64
  };
65
+ const title = (value) => typeof value === "string" && value.includes("{page}") && value.length <= 120 && !/[{}]/.test(value.replaceAll("{page}", "").replaceAll("{site}", ""))
66
+ ? undefined
67
+ : 'must contain "{page}" and may use "{site}", for example "{page} · {site}" (at most 120 characters)';
68
+ const flag = (value) => (typeof value === "boolean" ? undefined : "must be true or false");
69
+ const api = (value) => {
70
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
71
+ return 'must be an object such as { "base": "/api" }';
72
+ }
73
+ const given = value;
74
+ const unknown = Object.keys(given).filter((key) => key !== "base" && key !== "proxy");
75
+ if (unknown.length > 0) {
76
+ return `has unknown settings ${unknown.map((key) => `"${key}"`).join(", ")} (known: base, proxy)`;
77
+ }
78
+ if (typeof given["base"] !== "string" || !/^(\/([\w.~-]+\/?)*|https:\/\/[^\s/]+(\/[^\s]*)?)$/.test(given["base"]) || given["base"].endsWith("//")) {
79
+ return '"base" must be a path on the site ("/api") or an absolute https address ("https://api.example.com")';
80
+ }
81
+ if (given["proxy"] !== undefined) {
82
+ try {
83
+ const target = new URL(String(given["proxy"]));
84
+ if (!target.protocol.startsWith("http") || !given["base"].startsWith("/")) {
85
+ return '"proxy" is an http(s) address that a path "base" is forwarded to while you run vlx dev';
86
+ }
87
+ }
88
+ catch {
89
+ return '"proxy" must be an absolute address such as http://localhost:3000';
90
+ }
91
+ }
92
+ return undefined;
93
+ };
63
94
  /** Reads and validates the project's configuration. Every problem in both files is reported together. */
64
95
  export const loadConfig = (root) => {
65
96
  const project = read(root, PROJECT_FILE);
@@ -67,17 +98,33 @@ export const loadConfig = (root) => {
67
98
  throw new AppError([diagnostic(PROJECT_FILE, 0, "config-missing", `there is no ${PROJECT_FILE} in ${root}`)]);
68
99
  }
69
100
  const web = read(root, WEB_FILE);
101
+ const desktop = read(root, DESKTOP_FILE);
70
102
  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 }, [])),
103
+ ...(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"]),
105
+ ...(web === undefined ? [] : check(WEB_FILE, web.text, web.value, { $schema: schema, version, base, url, headers: flag, announce: flag, api }, [])),
73
106
  ];
74
107
  if (problems.length > 0) {
75
108
  throw new AppError(problems);
76
109
  }
77
110
  const given = (web?.value ?? {});
78
- return { name: project.value.name, web: { base: given.base ?? "/", url: given.url ?? "" } };
111
+ const named = project.value;
112
+ return {
113
+ name: named.name,
114
+ targets: desktop === undefined ? ["web"] : web === undefined ? ["desktop"] : ["web", "desktop"],
115
+ title: named.title ?? DEFAULT_TITLE,
116
+ web: { base: given.base ?? "/", url: given.url ?? "", headers: given.headers ?? true, announce: given.announce ?? true },
117
+ api: { base: given.api?.base ?? "", proxy: given.api?.proxy ?? "" },
118
+ };
79
119
  };
80
- export const siteInfo = (config) => ({ name: config.name, base: config.web.base, url: config.web.url });
120
+ export const siteInfo = (config) => ({
121
+ name: config.name,
122
+ base: config.web.base,
123
+ url: config.web.url,
124
+ title: config.title,
125
+ api: config.api.base,
126
+ announce: config.web.announce,
127
+ });
81
128
  /**
82
129
  * 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
130
  * 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;
@@ -0,0 +1,8 @@
1
+ import type { Dev, DevOptions } from "./dev.js";
2
+ /** Whether a changed file (relative to the project) is one the application is made of. */
3
+ export declare const mattersToTheEngine: (file: string) => boolean;
4
+ export interface DesktopDev extends Dev {
5
+ /** Resolves when the window is closed by the person (not by a rebuild). */
6
+ readonly closed: Promise<void>;
7
+ }
8
+ export declare const devDesktop: (root: string, options?: DevOptions) => Promise<DesktopDev>;
@@ -0,0 +1,114 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { watch } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { build } from "./build.js";
7
+ import { AppError, format } from "./diagnostics.js";
8
+ /** How long files must be quiet before a change is built (ms). */
9
+ const QUIET = 150;
10
+ /** Whether a changed file (relative to the project) is one the application is made of. */
11
+ export const mattersToTheEngine = (file) => {
12
+ const name = file.split("\\").join("/");
13
+ return /^(content|src|public)\//.test(name) || /^valance(\.\w+)?\.json$/.test(name);
14
+ };
15
+ export const devDesktop = async (root, options = {}) => {
16
+ const log = options.log ?? ((line) => void console.log(line));
17
+ const program = process.env["VLX_WINDOW"] ?? "vlx-window";
18
+ let window;
19
+ let stopping = false;
20
+ let working = Promise.resolve();
21
+ let timer;
22
+ let leave = () => undefined;
23
+ const closed = new Promise((resolve) => {
24
+ leave = resolve;
25
+ });
26
+ const reloadDir = mkdtempSync(join(tmpdir(), "vlx-reload-"));
27
+ const reloadFile = join(reloadDir, "reload");
28
+ let reloads = 0;
29
+ writeFileSync(reloadFile, "0");
30
+ /** Tells a running window the engine was rebuilt: a new modification time on the file it watches. */
31
+ const touch = () => {
32
+ reloads += 1;
33
+ writeFileSync(reloadFile, String(reloads));
34
+ };
35
+ const stopWindow = async () => {
36
+ const running = window;
37
+ window = undefined;
38
+ if (running !== undefined && running.exitCode === null) {
39
+ const exited = new Promise((resolve) => running.once("exit", () => resolve()));
40
+ running.kill();
41
+ await exited;
42
+ }
43
+ };
44
+ const openWindow = (engine) => {
45
+ const child = spawn(program, [], { stdio: "inherit", env: { ...process.env, VLX_ENGINE: `${process.execPath} ${engine}`, VLX_RELOAD: reloadFile } });
46
+ window = child;
47
+ child.once("error", (error) => {
48
+ window = undefined;
49
+ log(error.code === "ENOENT"
50
+ ? `vlx: no window program "${program}": install it with \`cargo install --git https://github.com/ValanceX/Port valancex-port-desktop --bin vlx-window\`, or name it in VLX_WINDOW`
51
+ : `vlx: the window could not start: ${error.message}`);
52
+ });
53
+ child.once("exit", () => {
54
+ if (window === child && !stopping) {
55
+ window = undefined;
56
+ log("vlx: the window was closed");
57
+ leave();
58
+ }
59
+ });
60
+ };
61
+ const rebuild = async () => {
62
+ try {
63
+ const built = await build(root, { targets: ["desktop"] });
64
+ options.onStatus?.([]);
65
+ if (stopping) {
66
+ return;
67
+ }
68
+ if (window !== undefined && window.exitCode === null) {
69
+ touch();
70
+ log("vlx: built; the window restarts the engine");
71
+ }
72
+ else {
73
+ openWindow(join(built.dir, "desktop", "engine.mjs"));
74
+ log("vlx: built; the window is open");
75
+ }
76
+ }
77
+ catch (error) {
78
+ if (error instanceof AppError) {
79
+ options.onStatus?.(error.diagnostics);
80
+ error.diagnostics.forEach((each) => log(format(each)));
81
+ }
82
+ else {
83
+ log(`vlx: the build failed: ${error instanceof Error ? error.message : String(error)}`);
84
+ }
85
+ }
86
+ };
87
+ const queue = () => {
88
+ working = working.then(rebuild);
89
+ };
90
+ const watcher = watch(root, { recursive: true }, (_event, file) => {
91
+ if (file !== null && mattersToTheEngine(file)) {
92
+ clearTimeout(timer);
93
+ timer = setTimeout(queue, QUIET);
94
+ }
95
+ });
96
+ queue();
97
+ await working;
98
+ return {
99
+ url: "window",
100
+ closed,
101
+ settled: async () => {
102
+ await working;
103
+ },
104
+ close: async () => {
105
+ stopping = true;
106
+ clearTimeout(timer);
107
+ watcher.close();
108
+ await working;
109
+ await stopWindow();
110
+ rmSync(reloadDir, { recursive: true, force: true });
111
+ leave();
112
+ },
113
+ };
114
+ };
package/dist/dev.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import type { Diagnostic } from "./diagnostics.js";
2
+ import type { TargetId } from "./targets.js";
2
3
  export interface DevOptions {
4
+ /** Which target to serve, for a project of more than one. */
5
+ readonly target?: TargetId;
3
6
  readonly port?: number;
4
7
  readonly host?: string;
5
8
  /** Called with each report (a rebuild, a mistake); defaults to the console. */
package/dist/dev.js CHANGED
@@ -3,6 +3,7 @@ import { relative, sep } from "node:path";
3
3
  import { loadAuthor } from "./author.js";
4
4
  import { loadConfig, PROJECT_FILE, WEB_FILE } from "./config.js";
5
5
  import { AppError, format } from "./diagnostics.js";
6
+ import { devDesktop } from "./dev-desktop.js";
6
7
  import { generateContent, generateEntries, generateProgram } from "./emit.js";
7
8
  import { GENERATED_DIR, OUTPUT_DIR } from "./project.js";
8
9
  import { baseConfig, SERVER_ENTRY } from "./vite.js";
@@ -50,11 +51,18 @@ const start = async (root, config, options, restart) => {
50
51
  });
51
52
  const failed = new Map();
52
53
  let pending = Promise.resolve();
53
- generateEntries(root);
54
+ generateEntries(root, config);
54
55
  const base = baseConfig(root, config);
55
56
  const server = await createServer({
56
57
  ...base,
57
- server: { port: options.port ?? 5173, host: options.host ?? "127.0.0.1", strictPort: false, fs: { allow: [root] } },
58
+ server: {
59
+ port: options.port ?? 5173,
60
+ host: options.host ?? "127.0.0.1",
61
+ strictPort: false,
62
+ fs: { allow: [root] },
63
+ // The backend, reached through the page's own origin: no CORS settings to write (valance.web.json `api`).
64
+ ...(config.api.proxy === "" || !config.api.base.startsWith("/") ? {} : { proxy: { [config.api.base.replace(/\/$/, "")]: { target: config.api.proxy, changeOrigin: true } } }),
65
+ },
58
66
  plugins: [
59
67
  ...(base.plugins ?? []),
60
68
  {
@@ -107,13 +115,13 @@ const start = async (root, config, options, restart) => {
107
115
  };
108
116
  const wasFailing = failed.size > 0;
109
117
  if (stages.includes("entries")) {
110
- generateEntries(root);
118
+ generateEntries(root, config);
111
119
  }
112
120
  if (stages.includes("content")) {
113
121
  await run("content", () => generateContent(root, config));
114
122
  }
115
123
  if (stages.includes("program")) {
116
- await run("templates", async () => generateProgram(root, await loadAuthor(server, root)));
124
+ await run("templates", async () => generateProgram(root, await loadAuthor(server, root), config, ["web"]));
117
125
  }
118
126
  const open = [...failed.values()].flat();
119
127
  options.onStatus?.(open);
@@ -169,7 +177,7 @@ const start = async (root, config, options, restart) => {
169
177
  // First build: a mistake here is reported like any other and the server still starts, so the overlay can show it.
170
178
  for (const [name, work] of [
171
179
  ["content", () => generateContent(root, config)],
172
- ["templates", async () => generateProgram(root, await loadAuthor(server, root))],
180
+ ["templates", async () => generateProgram(root, await loadAuthor(server, root), config, ["web"])],
173
181
  ]) {
174
182
  try {
175
183
  await work();
@@ -199,6 +207,33 @@ const start = async (root, config, options, restart) => {
199
207
  };
200
208
  /** Starts the development server for the project at `root`. */
201
209
  export const dev = async (root, options = {}) => {
210
+ const config = loadConfig(root);
211
+ const target = options.target ?? (config.targets.length === 1 ? config.targets[0] : undefined);
212
+ if (target !== undefined && !config.targets.includes(target)) {
213
+ throw new AppError([
214
+ { file: "valance.json", line: 0, column: 0, code: "dev-target", message: `this project is not built for ${target} (its targets: ${config.targets.join(", ")})` },
215
+ ]);
216
+ }
217
+ // A project of more than one target, and none named: serve the web and open the desktop's window, each as it would be alone.
218
+ if (target === undefined) {
219
+ const web = await dev(root, { ...options, target: "web" });
220
+ const desktop = await devDesktop(root, options);
221
+ return {
222
+ url: web.url,
223
+ closed: desktop.closed,
224
+ settled: async () => {
225
+ await web.settled();
226
+ await desktop.settled();
227
+ },
228
+ close: async () => {
229
+ await desktop.close();
230
+ await web.close();
231
+ },
232
+ };
233
+ }
234
+ if (target === "desktop") {
235
+ return devDesktop(root, options);
236
+ }
202
237
  let current;
203
238
  const log = options.log ??
204
239
  ((line) => {
@@ -237,7 +272,7 @@ export const dev = async (root, options = {}) => {
237
272
  }, 500);
238
273
  timer.unref();
239
274
  };
240
- const config = loadConfig(root);
275
+ // (the configuration is read above)
241
276
  current = await start(root, config, options, restart);
242
277
  return {
243
278
  get url() {
package/dist/emit.d.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  import type { Config } from "./config.js";
2
+ import type { TargetId } from "./targets.js";
2
3
  import type { DefinedApp } from "@valancex/common";
3
4
  import type { Content } from "@valancex/common/internal";
4
5
  export declare const hasEntry: (root: string) => boolean;
5
6
  export declare const writeContent: (root: string, content: Content) => boolean;
6
7
  /** The content stage alone: it does not need the author's code. */
7
8
  export declare const generateContent: (root: string, config: Config) => Content;
8
- /** The templates stage: needs the author's definition (the manifest derives from its state and commands). */
9
- export declare const generateProgram: (root: string, author: DefinedApp | undefined) => Promise<ReadonlyArray<string>>;
9
+ /** The file a target's compiled program is in: `program.json` for a project of one target (as it always was), `program.<target>.json` for one of several. */
10
+ export declare const programFile: (config: Config | undefined, id: TargetId) => string;
11
+ /** The templates stage: needs the author's definition (the manifest derives from its state and commands). One program per target; the state, the commands and the content are shared. */
12
+ export declare const generateProgram: (root: string, author: DefinedApp | undefined, config?: Config, only?: ReadonlyArray<TargetId>) => Promise<ReadonlyArray<string>>;
10
13
  /** The declaration of one template's type: its props and events as the names TypeScript checks a contract's keys against. */
11
14
  export declare const templateType: (props: ReadonlyArray<string>, events: ReadonlyArray<string>) => string;
12
- /** The entries; rewritten when `src/main.ts` or a stylesheet is added or removed. */
13
- export declare const generateEntries: (root: string) => boolean;
15
+ /** The entries; rewritten when `src/main.ts` or a stylesheet is added or removed. Each target has its own: the web's two (`client.ts`, `server.ts`), the desktop's one (`desktop.ts`). */
16
+ export declare const generateEntries: (root: string, config?: Config) => boolean;
package/dist/emit.js CHANGED
@@ -17,30 +17,67 @@ import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
17
17
  import { join, relative, sep } from "node:path";
18
18
  import { compileViews, PACKAGE_DIR, SOURCE_DIR } from "./compile.js";
19
19
  import { readContent } from "./content.js";
20
+ import { targetOf } from "./targets.js";
21
+ import { forTarget } from "@valancex/common/internal";
22
+ import { AppError } from "./diagnostics.js";
20
23
  import { ENTRY_FILE, ensureGeneratedDir, GENERATED_DIR, generated, writeIfChanged } from "./project.js";
21
24
  import { buildWiring } from "./wiring.js";
25
+ /** The layout of the portable core on the web: `column` and `row` are custom elements, and this is what they are (`@valancex/common`'s web vocabulary). It comes first, so a project's own styles win. */
26
+ const CORE_CSS = `/* Generated by \`valance\`. The web's realization of the portable core's layout components. */
27
+ vlx-column {
28
+ display: flex;
29
+ flex-direction: column;
30
+ gap: 0.5rem;
31
+ }
32
+
33
+ vlx-row {
34
+ display: flex;
35
+ flex-direction: row;
36
+ align-items: center;
37
+ gap: 0.5rem;
38
+ }
39
+ `;
40
+ /** The stylesheets the web loads: every one under `src/` except those in another target's directory (`src/desktop/`). */
22
41
  const stylesheets = (root) => existsSync(join(root, SOURCE_DIR))
23
42
  ? readdirSync(join(root, SOURCE_DIR), { recursive: true, encoding: "utf8" })
24
- .filter((file) => file.endsWith(".css"))
43
+ .map((file) => file.split(sep).join("/"))
44
+ .filter((file) => file.endsWith(".css") && !file.startsWith("desktop/"))
25
45
  .sort()
26
46
  .map((file) => `${SOURCE_DIR}/${file}`)
27
47
  : [];
28
48
  export const hasEntry = (root) => existsSync(join(root, ENTRY_FILE));
29
- const clientSource = (root) => `// Generated by \`valance\`. Do not edit: it is rewritten whenever the project changes.
49
+ const clientSource = (root, config) => `// Generated by \`valance\`. Do not edit: it is rewritten whenever the project changes.
30
50
  // @ts-nocheck
31
51
  import { start } from "@valancex/common/client";
32
52
  import content from "./content.json";
33
- import program from "./program.json";
53
+ import program from "./${programFile(config, "web")}";
54
+ import "./core.css";
34
55
  ${hasEntry(root) ? `import author from "../${ENTRY_FILE}";\n` : "const author = undefined;\n"}${stylesheets(root)
35
56
  .map((file) => `import "../${file}";\n`)
36
57
  .join("")}
37
58
  await start({ content, program, author });
38
59
  `;
39
- const serverSource = (root) => `// Generated by \`valance\`. Do not edit: it is rewritten whenever the project changes.
60
+ const desktopSource = (root, config) => `// Generated by \`valance\`. Do not edit: it is rewritten whenever the project changes.
61
+ // @ts-nocheck
62
+ import { start } from "@valancex/common/desktop";
63
+ import { readFileSync } from "node:fs";
64
+ import { fileURLToPath } from "node:url";
65
+ import content from "./content.json";
66
+ import program from "./${programFile(config, "desktop")}";
67
+ ${hasEntry(root) ? `import author from "../${ENTRY_FILE}";\n` : "const author = undefined;\n"}
68
+ // the MESH runtime's module sits beside the built engine (vlx build copies it there)
69
+ const wasm = new Uint8Array(readFileSync(new URL("./mesh-runtime.wasm", import.meta.url)));
70
+ // (and the project's public/ files, which images are drawn from)
71
+ const assets = fileURLToPath(new URL("./public/", import.meta.url));
72
+ await start({ content, program, author, wasm, assets });
73
+ // the window is closed: nothing the application left open keeps the engine alive
74
+ process.exit(0);
75
+ `;
76
+ const serverSource = (root, config) => `// Generated by \`valance\`. Do not edit: it is rewritten whenever the project changes.
40
77
  // @ts-nocheck
41
78
  import { createRenderer, pathsOf } from "@valancex/common/server";
42
79
  import content from "./content.json";
43
- import program from "./program.json";
80
+ import program from "./${programFile(config, "web")}";
44
81
  ${hasEntry(root) ? `import author from "../${ENTRY_FILE}";\n` : "const author = undefined;\n"}
45
82
  export const render = createRenderer({ content, program, author });
46
83
  export const paths = () => pathsOf(content);
@@ -54,20 +91,50 @@ export const generateContent = (root, config) => {
54
91
  writeContent(root, content);
55
92
  return content;
56
93
  };
57
- /** The templates stage: needs the author's definition (the manifest derives from its state and commands). */
58
- export const generateProgram = async (root, author) => {
94
+ /** The file a target's compiled program is in: `program.json` for a project of one target (as it always was), `program.<target>.json` for one of several. */
95
+ export const programFile = (config, id) => ((config?.targets.length ?? 1) > 1 ? `program.${id}.json` : "program.json");
96
+ const manifestFile = (config, id) => ((config?.targets.length ?? 1) > 1 ? `manifest.${id}.json` : "manifest.json");
97
+ const wiringFile = (config, id) => ((config?.targets.length ?? 1) > 1 ? `wiring.${id}.json` : "wiring.json");
98
+ /** The templates stage: needs the author's definition (the manifest derives from its state and commands). One program per target; the state, the commands and the content are shared. */
99
+ export const generateProgram = async (root, author, config, only) => {
59
100
  ensureGeneratedDir(root);
60
- const compiled = await compileViews(root, author);
61
- writeIfChanged(generated(root, "program.json"), `${JSON.stringify(compiled.program)}\n`);
62
- writeIfChanged(generated(root, "manifest.json"), `${JSON.stringify(compiled.manifest, null, 2)}\n`);
63
- // The one file an editor reads to know the project: where the manifest is, and which file is the template of which component. The conventions stay here, not copied into every editor.
64
- writeIfChanged(generated(root, "editor.json"), `${JSON.stringify({ version: 1, model: `${GENERATED_DIR}/manifest.json`, components: compiled.files, wiring: `${GENERATED_DIR}/wiring.json` }, null, 2)}\n`);
65
- writeTemplateTypes(root, compiled);
66
- // Where every name came from and what uses it, at both ends, for the editor. The framework's own names point into the README that ships with the package.
101
+ const targets = (config?.targets ?? ["web"]).filter((id) => only === undefined || only.includes(id));
102
+ const compiledAll = [];
103
+ const problems = [];
104
+ for (const id of targets) {
105
+ const target = targetOf(id);
106
+ try {
107
+ compiledAll.push({ id, compiled: await compileViews(root, forTarget(author, id), target.vocabulary, target.views) });
108
+ }
109
+ catch (error) {
110
+ if (!(error instanceof AppError)) {
111
+ throw error;
112
+ }
113
+ problems.push(...error.diagnostics);
114
+ }
115
+ }
116
+ if (problems.length > 0) {
117
+ throw new AppError(problems);
118
+ }
67
119
  const readme = join(PACKAGE_DIR, "README.md");
68
120
  const docs = existsSync(readme) ? { file: relative(root, readme).split(sep).join("/"), lines: readFileSync(readme, "utf8").split("\n") } : undefined;
69
- writeIfChanged(generated(root, "wiring.json"), `${JSON.stringify(buildWiring(root, author, compiled, docs), null, 2)}\n`);
70
- return compiled.components;
121
+ const editor = [];
122
+ for (const { id, compiled } of compiledAll) {
123
+ writeIfChanged(generated(root, programFile(config, id)), `${JSON.stringify(compiled.program)}\n`);
124
+ writeIfChanged(generated(root, manifestFile(config, id)), `${JSON.stringify(compiled.manifest, null, 2)}\n`);
125
+ // Where every name came from and what uses it, at both ends, for the editor. The framework's own names point into the README that ships with the package.
126
+ writeIfChanged(generated(root, wiringFile(config, id)), `${JSON.stringify(buildWiring(root, forTarget(author, id), compiled, docs), null, 2)}\n`);
127
+ editor.push({ id, model: `${GENERATED_DIR}/${manifestFile(config, id)}`, wiring: `${GENERATED_DIR}/${wiringFile(config, id)}`, components: compiled.files });
128
+ }
129
+ // The one file an editor reads to know the project: where the manifest is, and which file is the template of which component. The conventions stay here, not copied into every editor. For a project
130
+ // of several targets the first one is what the editor reads (`model`, `wiring`), and `targets` lists each.
131
+ const first = editor[0];
132
+ // Only a pass over every target writes it (a pass over one, as `vlx dev --web` is, would leave the others out of it).
133
+ if (first !== undefined && editor.length === (config?.targets ?? ["web"]).length) {
134
+ writeIfChanged(generated(root, "editor.json"), `${JSON.stringify({ version: 1, model: first.model, components: first.components, wiring: first.wiring, ...(editor.length > 1 ? { targets: Object.fromEntries(editor.map((each) => [each.id, { model: each.model, wiring: each.wiring, components: each.components }])) } : {}) }, null, 2)}\n`);
135
+ }
136
+ writeTemplateTypes(root, compiledAll.map((each) => each.compiled));
137
+ return [...new Set(compiledAll.flatMap((each) => each.compiled.components))];
71
138
  };
72
139
  const union = (names) => (names.length === 0 ? "never" : names.map((name) => JSON.stringify(name)).join(" | "));
73
140
  /** The declaration of one template's type: its props and events as the names TypeScript checks a contract's keys against. */
@@ -81,15 +148,16 @@ export default template;
81
148
  */
82
149
  const typePath = (file) => `types/${file.replace(new RegExp(`^${SOURCE_DIR}/`), "").replace(/\.mprx$/, ".d.mprx.ts")}`;
83
150
  /** Writes the type of each of the author's templates, and removes the type of one that is gone. */
84
- const writeTemplateTypes = (root, compiled) => {
85
- const components = compiled.manifest
86
- .components;
151
+ const writeTemplateTypes = (root, all) => {
87
152
  const written = new Set();
88
- for (const template of compiled.templates.filter((each) => !each.builtIn)) {
89
- const entry = components[template.component];
90
- const path = typePath(template.file);
91
- written.add(path);
92
- writeIfChanged(generated(root, path), templateType(Object.keys(entry?.props ?? {}), Object.keys(entry?.events ?? {})));
153
+ for (const compiled of all) {
154
+ const components = compiled.manifest.components;
155
+ for (const template of compiled.templates.filter((each) => !each.builtIn)) {
156
+ const entry = components[template.component];
157
+ const path = typePath(template.file);
158
+ written.add(path);
159
+ writeIfChanged(generated(root, path), templateType(Object.keys(entry?.props ?? {}), Object.keys(entry?.events ?? {})));
160
+ }
93
161
  }
94
162
  const dir = generated(root, "types");
95
163
  if (existsSync(dir)) {
@@ -100,8 +168,18 @@ const writeTemplateTypes = (root, compiled) => {
100
168
  }
101
169
  }
102
170
  };
103
- /** The entries; rewritten when `src/main.ts` or a stylesheet is added or removed. */
104
- export const generateEntries = (root) => {
171
+ /** The entries; rewritten when `src/main.ts` or a stylesheet is added or removed. Each target has its own: the web's two (`client.ts`, `server.ts`), the desktop's one (`desktop.ts`). */
172
+ export const generateEntries = (root, config) => {
105
173
  ensureGeneratedDir(root);
106
- return [writeIfChanged(generated(root, "client.ts"), clientSource(root)), writeIfChanged(generated(root, "server.ts"), serverSource(root))].some(Boolean);
174
+ const targets = config?.targets ?? ["web"];
175
+ return [
176
+ ...(targets.includes("web")
177
+ ? [
178
+ writeIfChanged(generated(root, "core.css"), CORE_CSS),
179
+ writeIfChanged(generated(root, "client.ts"), clientSource(root, config)),
180
+ writeIfChanged(generated(root, "server.ts"), serverSource(root, config)),
181
+ ]
182
+ : []),
183
+ ...(targets.includes("desktop") ? [writeIfChanged(generated(root, "desktop.ts"), desktopSource(root, config))] : []),
184
+ ].some(Boolean);
107
185
  };
package/dist/init.js CHANGED
@@ -4,7 +4,7 @@ import { spawn } from "node:child_process";
4
4
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
- import { PROJECT_FILE, WEB_FILE } from "./config.js";
7
+ import { DESKTOP_FILE, PROJECT_FILE, WEB_FILE } from "./config.js";
8
8
  export const PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
9
9
  const PACKAGE = new URL("../", import.meta.url);
10
10
  const TEMPLATES = new URL("../templates/", import.meta.url);
@@ -84,6 +84,7 @@ export const plan = (answers) => {
84
84
  const out = new Map();
85
85
  const title = titleOf(answers.name);
86
86
  const web = answers.targets.includes("web");
87
+ const desktop = answers.targets.includes("desktop");
87
88
  const hasBase = answers.web.base !== "" && answers.web.base !== "/";
88
89
  const settings = [hasBase ? `base ${answers.web.base}` : "", answers.web.url !== "" ? `url ${answers.web.url}` : ""].filter((each) => each !== "");
89
90
  const tokens = {
@@ -92,14 +93,20 @@ export const plan = (answers) => {
92
93
  web_note: web && settings.length > 0 ? `, and \`${WEB_FILE}\` says where the web site is served (${settings.join(", ")})` : "",
93
94
  };
94
95
  const fill = (text) => text.replace(/\{\{(\w+)\}\}/g, (match, key) => tokens[key] ?? match);
95
- if (web) {
96
- const root = new URL("web/", TEMPLATES);
96
+ // A project for more than one target keeps what they share (the content, the state and the commands) and gives each its own templates (`src/web/`, `src/desktop/`): the "multi" template, on
97
+ // top of the web's files that are not templates.
98
+ const layers = web && desktop ? ["web", "multi"] : web ? ["web"] : desktop ? ["desktop"] : [];
99
+ for (const target of layers) {
100
+ const root = new URL(`${target}/`, TEMPLATES);
97
101
  for (const file of files(root)) {
102
+ if (target === "web" && layers.includes("multi") && (file.startsWith("src/") || file.startsWith("content/") || file === "README.md")) {
103
+ continue;
104
+ }
98
105
  out.set(written(file), fill(readFileSync(new URL(file, root), "utf8")));
99
106
  }
100
107
  }
101
108
  out.set(PROJECT_FILE, json({ $schema: "./node_modules/@valancex/cli/schemas/valance.schema.json", version: 1, name: title }));
102
- if (web && (hasBase || answers.web.url !== "")) {
109
+ if (web && (hasBase || answers.web.url !== "" || desktop)) {
103
110
  out.set(WEB_FILE, json({
104
111
  $schema: "./node_modules/@valancex/cli/schemas/valance.web.schema.json",
105
112
  version: 1,
@@ -107,6 +114,9 @@ export const plan = (answers) => {
107
114
  ...(answers.web.url !== "" ? { url: answers.web.url } : {}),
108
115
  }));
109
116
  }
117
+ if (desktop) {
118
+ out.set(DESKTOP_FILE, json({ $schema: "./node_modules/@valancex/cli/schemas/valance.desktop.schema.json", version: 1 }));
119
+ }
110
120
  out.set("package.json", json({
111
121
  name: packageName(basename(answers.dir)),
112
122
  version: "0.0.0",
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,18 @@
1
+ export declare const PLATFORMS: readonly ["linux-x64", "win32-x64"];
2
+ export type Platform = (typeof PLATFORMS)[number];
3
+ export interface PackageOptions {
4
+ /** The platform the package is for; by default the one this runs on. */
5
+ readonly platform?: string;
6
+ /** The Desktop PORT's window program for that platform (`vlx-window`); by default `VLX_WINDOW`, else `vlx-window` on the PATH. */
7
+ readonly window?: string;
8
+ /** A `node` binary of the same version for that platform; by default the one running this, which is only right for the platform this runs on. */
9
+ readonly node?: string;
10
+ /** Also write the package as one file: a `.tar.gz` for Linux, a `.zip` for Windows, with its SHA-256 beside it. */
11
+ readonly archive?: boolean;
12
+ }
13
+ export interface Packaged {
14
+ readonly dir: string;
15
+ readonly files: ReadonlyArray<string>;
16
+ readonly notes: ReadonlyArray<string>;
17
+ }
18
+ export declare const packageDesktop: (root: string, options?: PackageOptions) => Promise<Packaged>;