@velumo/cli 0.1.0-beta.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 (38) hide show
  1. package/dist/browser-overview.d.ts +72 -0
  2. package/dist/browser-overview.d.ts.map +1 -0
  3. package/dist/browser-overview.js +431 -0
  4. package/dist/browser-overview.js.map +1 -0
  5. package/dist/browser-presenter.d.ts +63 -0
  6. package/dist/browser-presenter.d.ts.map +1 -0
  7. package/dist/browser-presenter.js +304 -0
  8. package/dist/browser-presenter.js.map +1 -0
  9. package/dist/browser-runtime.d.ts +43 -0
  10. package/dist/browser-runtime.d.ts.map +1 -0
  11. package/dist/browser-runtime.js +293 -0
  12. package/dist/browser-runtime.js.map +1 -0
  13. package/dist/browser-spatial.d.ts +42 -0
  14. package/dist/browser-spatial.d.ts.map +1 -0
  15. package/dist/browser-spatial.js +207 -0
  16. package/dist/browser-spatial.js.map +1 -0
  17. package/dist/browser-sync.d.ts +37 -0
  18. package/dist/browser-sync.d.ts.map +1 -0
  19. package/dist/browser-sync.js +77 -0
  20. package/dist/browser-sync.js.map +1 -0
  21. package/dist/browser-timer.d.ts +9 -0
  22. package/dist/browser-timer.d.ts.map +1 -0
  23. package/dist/browser-timer.js +13 -0
  24. package/dist/browser-timer.js.map +1 -0
  25. package/dist/index.d.ts +4 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +902 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/tsconfig.tsbuildinfo +1 -0
  30. package/package.json +42 -0
  31. package/src/browser-overview.ts +641 -0
  32. package/src/browser-presenter.ts +602 -0
  33. package/src/browser-runtime.ts +493 -0
  34. package/src/browser-spatial.ts +292 -0
  35. package/src/browser-sync.ts +151 -0
  36. package/src/browser-timer.ts +24 -0
  37. package/src/index.ts +1273 -0
  38. package/tsconfig.json +16 -0
package/src/index.ts ADDED
@@ -0,0 +1,1273 @@
1
+ #!/usr/bin/env node
2
+
3
+ import type { Dirent } from "node:fs";
4
+ import { realpathSync } from "node:fs";
5
+ import { createServer, type ServerResponse } from "node:http";
6
+ import {
7
+ copyFile,
8
+ mkdir,
9
+ readFile,
10
+ readdir,
11
+ stat,
12
+ writeFile,
13
+ } from "node:fs/promises";
14
+ import {
15
+ basename,
16
+ dirname,
17
+ extname,
18
+ isAbsolute,
19
+ relative,
20
+ resolve,
21
+ } from "node:path";
22
+ import { fileURLToPath, pathToFileURL } from "node:url";
23
+ import {
24
+ compileDeck,
25
+ compileMarkdownDeck,
26
+ loadConfig,
27
+ validateDeck,
28
+ } from "@velumo/compiler";
29
+ import type { Manifest } from "@velumo/core";
30
+ import { effectsCss } from "@velumo/effects";
31
+ import { defaultThemeCss } from "@velumo/renderer-dom";
32
+
33
+ export const cliPackageName = "@velumo/cli";
34
+
35
+ const cliDistDir = dirname(fileURLToPath(import.meta.url));
36
+ const runtimeDistDir = dirname(
37
+ fileURLToPath(import.meta.resolve("@velumo/runtime")),
38
+ );
39
+ const rendererDomDistDir = dirname(
40
+ fileURLToPath(import.meta.resolve("@velumo/renderer-dom")),
41
+ );
42
+ const effectsDistDir = dirname(
43
+ fileURLToPath(import.meta.resolve("@velumo/effects")),
44
+ );
45
+
46
+ const browserRuntimeAssets = {
47
+ "browser-overview.js": () => resolve(cliDistDir, "browser-overview.js"),
48
+ "browser-presenter.js": () => resolve(cliDistDir, "browser-presenter.js"),
49
+ "browser-runtime.js": () => resolve(cliDistDir, "browser-runtime.js"),
50
+ "browser-spatial.js": () => resolve(cliDistDir, "browser-spatial.js"),
51
+ "browser-sync.js": () => resolve(cliDistDir, "browser-sync.js"),
52
+ "browser-timer.js": () => resolve(cliDistDir, "browser-timer.js"),
53
+ "camera.js": () => resolve(runtimeDistDir, "camera.js"),
54
+ "background-renderer.js": () =>
55
+ resolve(rendererDomDistDir, "background-renderer.js"),
56
+ "camera-adapter.js": () => resolve(rendererDomDistDir, "camera-adapter.js"),
57
+ "dom-renderer.js": () => resolve(rendererDomDistDir, "dom-renderer.js"),
58
+ "slide-thumbnail.js": () => resolve(rendererDomDistDir, "slide-thumbnail.js"),
59
+ "spatial-renderer.js": () =>
60
+ resolve(rendererDomDistDir, "spatial-renderer.js"),
61
+ "controller.js": () => resolve(runtimeDistDir, "controller.js"),
62
+ "cue-dispatcher.js": () => resolve(runtimeDistDir, "cue-dispatcher.js"),
63
+ "action-dispatcher.js": () => resolve(runtimeDistDir, "action-dispatcher.js"),
64
+ "default-theme.js": () => resolve(rendererDomDistDir, "default-theme.js"),
65
+ "effects.js": () => fileURLToPath(import.meta.resolve("@velumo/effects")),
66
+ "effects-css.js": () => resolve(effectsDistDir, "effects-css.js"),
67
+ "controls.js": () => fileURLToPath(import.meta.resolve("@velumo/controls")),
68
+ "hash.js": () => resolve(runtimeDistDir, "hash.js"),
69
+ "html-sanitize.js": () => resolve(rendererDomDistDir, "html-sanitize.js"),
70
+ "layout-renderer.js": () => resolve(rendererDomDistDir, "layout-renderer.js"),
71
+ "notes.js": () => resolve(runtimeDistDir, "notes.js"),
72
+ "registry.js": () => resolve(runtimeDistDir, "registry.js"),
73
+ "runtime.js": () => fileURLToPath(import.meta.resolve("@velumo/runtime")),
74
+ "scene-player.js": () => resolve(runtimeDistDir, "scene-player.js"),
75
+ "spatial.js": () => resolve(runtimeDistDir, "spatial.js"),
76
+ "theme-bundle.js": () => resolve(runtimeDistDir, "theme-bundle.js"),
77
+ "renderer-dom.js": () =>
78
+ fileURLToPath(import.meta.resolve("@velumo/renderer-dom")),
79
+ "theme-fonts.js": () => resolve(rendererDomDistDir, "theme-fonts.js"),
80
+ "theme-tokens.js": () => resolve(rendererDomDistDir, "theme-tokens.js"),
81
+ "timeline.js": () => resolve(runtimeDistDir, "timeline.js"),
82
+ } as const;
83
+
84
+ type BrowserRuntimeAssetName = keyof typeof browserRuntimeAssets;
85
+ const browserRuntimeAssetNames = Object.keys(
86
+ browserRuntimeAssets,
87
+ ) as BrowserRuntimeAssetName[];
88
+ const defaultThemeCssAssetName = "default-theme.css";
89
+ const effectsCssAssetName = "effects.css";
90
+
91
+ function usage(): string {
92
+ return [
93
+ "Usage:",
94
+ " velumo validate <entry>",
95
+ " velumo create <name> [--template typescript|markdown] [--force]",
96
+ " velumo dev [--config <path>] [--entry <path>] [--port <port>] [--open]",
97
+ " velumo build [--config <path>] [--entry <path>] [--outDir <path>]",
98
+ " velumo preview [--dir <path>] [--port <port>] [--open]",
99
+ ].join("\n");
100
+ }
101
+
102
+ type CreateTemplate = "typescript" | "markdown";
103
+
104
+ interface CreateOptions {
105
+ readonly targetPath: string;
106
+ readonly template: CreateTemplate;
107
+ readonly force: boolean;
108
+ }
109
+
110
+ interface DevOptions {
111
+ readonly configPath?: string;
112
+ readonly entryPath?: string;
113
+ readonly port: number;
114
+ readonly open: boolean;
115
+ }
116
+
117
+ interface BuildOptions {
118
+ readonly configPath?: string;
119
+ readonly entryPath?: string;
120
+ readonly outDir?: string;
121
+ }
122
+
123
+ interface BuildProject {
124
+ readonly entryPath: string;
125
+ readonly outDir: string;
126
+ readonly projectRoot: string;
127
+ }
128
+
129
+ interface PreviewOptions {
130
+ readonly dir: string;
131
+ readonly port: number;
132
+ readonly open: boolean;
133
+ }
134
+
135
+ function createUsage(): string {
136
+ return "Usage: velumo create <name> [--template typescript|markdown] [--force]";
137
+ }
138
+
139
+ function sanitizePackageName(name: string): string {
140
+ const sanitized = name
141
+ .toLowerCase()
142
+ .replace(/[^a-z0-9._-]+/g, "-")
143
+ .replace(/^-+|-+$/g, "");
144
+
145
+ return sanitized.length === 0 ? "velumo-deck" : sanitized;
146
+ }
147
+
148
+ function parseCreateArgs(args: readonly string[]): CreateOptions | string {
149
+ const [name, ...rest] = args;
150
+
151
+ if (name === undefined) {
152
+ return createUsage();
153
+ }
154
+
155
+ let template: CreateTemplate = "typescript";
156
+ let force = false;
157
+
158
+ for (let index = 0; index < rest.length; index += 1) {
159
+ const arg = rest[index];
160
+
161
+ if (arg === "--force") {
162
+ force = true;
163
+ continue;
164
+ }
165
+
166
+ if (arg === "--template") {
167
+ const value = rest[index + 1];
168
+
169
+ if (value !== "typescript" && value !== "markdown") {
170
+ return `Invalid template: ${value ?? ""}`;
171
+ }
172
+
173
+ template = value;
174
+ index += 1;
175
+ continue;
176
+ }
177
+
178
+ return `Unknown create option: ${arg}`;
179
+ }
180
+
181
+ return {
182
+ targetPath: isAbsolute(name) ? name : resolve(process.cwd(), name),
183
+ template,
184
+ force,
185
+ };
186
+ }
187
+
188
+ async function directoryHasEntries(path: string): Promise<boolean> {
189
+ try {
190
+ return (await readdir(path)).length > 0;
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+
196
+ function resolveCliPath(path: string): string {
197
+ return isAbsolute(path) ? path : resolve(process.cwd(), path);
198
+ }
199
+
200
+ function deckEntryForTemplate(template: CreateTemplate): string {
201
+ return template === "markdown" ? "src/deck.md" : "src/deck.ts";
202
+ }
203
+
204
+ function packageJsonFor(targetPath: string): string {
205
+ return `${JSON.stringify(
206
+ {
207
+ name: sanitizePackageName(basename(targetPath)),
208
+ type: "module",
209
+ scripts: {
210
+ dev: "velumo dev",
211
+ build: "velumo build",
212
+ preview: "velumo preview",
213
+ },
214
+ dependencies: {
215
+ "@velumo/core": "0.0.0",
216
+ },
217
+ devDependencies: {
218
+ "@velumo/cli": "0.0.0",
219
+ },
220
+ },
221
+ null,
222
+ 2,
223
+ )}\n`;
224
+ }
225
+
226
+ function defaultDevPort(): number {
227
+ const devDefaults = parseDevArgs([]);
228
+ return typeof devDefaults === "string" ? 5173 : devDefaults.port;
229
+ }
230
+
231
+ function readmeFor(targetPath: string, template: CreateTemplate): string {
232
+ const name = sanitizePackageName(basename(targetPath));
233
+ const entry = deckEntryForTemplate(template);
234
+ const devPort = defaultDevPort();
235
+
236
+ return `# ${name}
237
+
238
+ A Velumo presentation.
239
+
240
+ ## Getting started
241
+
242
+ \`\`\`bash
243
+ npm install
244
+ \`\`\`
245
+
246
+ > Requires \`@velumo/core\` and \`@velumo/cli\` to be resolvable —
247
+ > either run this from inside the velumo monorepo workspace, or once
248
+ > those packages are published to a registry.
249
+
250
+ Edit your deck in \`${entry}\`.
251
+
252
+ ## Scripts
253
+
254
+ - \`npm run dev\` — start the local dev server at http://localhost:${devPort}
255
+ (opening a browser is manual — the dev server does not auto-open one)
256
+ - \`npm run build\` — build a static output to \`dist/\`
257
+ - \`npm run preview\` — serve the \`dist/\` build locally
258
+ `;
259
+ }
260
+
261
+ function configFor(template: CreateTemplate): string {
262
+ return `import { defineConfig } from "@velumo/core";
263
+
264
+ export default defineConfig({
265
+ entry: "${deckEntryForTemplate(template)}",
266
+ });
267
+ `;
268
+ }
269
+
270
+ function typescriptDeckTemplate(): string {
271
+ return `import { deck, markdown, slide } from "@velumo/core";
272
+
273
+ export default deck({
274
+ title: "Velumo Demo",
275
+ slides: [
276
+ slide("intro", {
277
+ layers: [markdown("# Velumo Demo\\n\\nStart forging your story.")],
278
+ }),
279
+ ],
280
+ });
281
+ `;
282
+ }
283
+
284
+ function markdownDeckTemplate(): string {
285
+ return `---
286
+ title: Velumo Demo
287
+ ---
288
+
289
+ # Velumo Demo
290
+
291
+ Start forging your story.
292
+ `;
293
+ }
294
+
295
+ async function writeGeneratedFiles(options: CreateOptions): Promise<void> {
296
+ await mkdir(resolve(options.targetPath, "src"), { recursive: true });
297
+ await mkdir(resolve(options.targetPath, "public", "assets"), {
298
+ recursive: true,
299
+ });
300
+
301
+ await writeFile(
302
+ resolve(options.targetPath, "package.json"),
303
+ packageJsonFor(options.targetPath),
304
+ );
305
+ await writeFile(
306
+ resolve(options.targetPath, "velumo.config.ts"),
307
+ configFor(options.template),
308
+ );
309
+ await writeFile(
310
+ resolve(options.targetPath, "README.md"),
311
+ readmeFor(options.targetPath, options.template),
312
+ );
313
+ await writeFile(
314
+ resolve(options.targetPath, "public", "assets", ".gitkeep"),
315
+ "",
316
+ );
317
+
318
+ if (options.template === "markdown") {
319
+ await writeFile(
320
+ resolve(options.targetPath, "src", "deck.md"),
321
+ markdownDeckTemplate(),
322
+ );
323
+ return;
324
+ }
325
+
326
+ await writeFile(
327
+ resolve(options.targetPath, "src", "deck.ts"),
328
+ typescriptDeckTemplate(),
329
+ );
330
+ }
331
+
332
+ async function runCreate(args: readonly string[]): Promise<number> {
333
+ const options = parseCreateArgs(args);
334
+
335
+ if (typeof options === "string") {
336
+ console.error(options);
337
+ return 1;
338
+ }
339
+
340
+ if (!options.force && (await directoryHasEntries(options.targetPath))) {
341
+ console.error(`Target directory already exists: ${options.targetPath}`);
342
+ return 1;
343
+ }
344
+
345
+ await writeGeneratedFiles(options);
346
+ console.log(`Created Velumo project at ${options.targetPath}`);
347
+ return 0;
348
+ }
349
+
350
+ function parseDevArgs(args: readonly string[]): DevOptions | string {
351
+ let configPath: string | undefined;
352
+ let entryPath: string | undefined;
353
+ let port = 5173;
354
+ let open = false;
355
+
356
+ for (let index = 0; index < args.length; index += 1) {
357
+ const arg = args[index];
358
+
359
+ if (arg === "--config") {
360
+ const value = args[index + 1];
361
+
362
+ if (value === undefined) {
363
+ return "Missing value for --config";
364
+ }
365
+
366
+ configPath = resolveCliPath(value);
367
+ index += 1;
368
+ continue;
369
+ }
370
+
371
+ if (arg === "--entry") {
372
+ const value = args[index + 1];
373
+
374
+ if (value === undefined) {
375
+ return "Missing value for --entry";
376
+ }
377
+
378
+ entryPath = resolveCliPath(value);
379
+ index += 1;
380
+ continue;
381
+ }
382
+
383
+ if (arg === "--port") {
384
+ const value = args[index + 1];
385
+ const parsedPort = Number(value);
386
+
387
+ if (
388
+ !Number.isInteger(parsedPort) ||
389
+ parsedPort < 1 ||
390
+ parsedPort > 65535
391
+ ) {
392
+ return `Invalid port: ${value ?? ""}`;
393
+ }
394
+
395
+ port = parsedPort;
396
+ index += 1;
397
+ continue;
398
+ }
399
+
400
+ if (arg === "--open") {
401
+ open = true;
402
+ continue;
403
+ }
404
+
405
+ return `Unknown dev option: ${arg}`;
406
+ }
407
+
408
+ return {
409
+ configPath,
410
+ entryPath,
411
+ port,
412
+ open,
413
+ };
414
+ }
415
+
416
+ function parseBuildArgs(args: readonly string[]): BuildOptions | string {
417
+ let configPath: string | undefined;
418
+ let entryPath: string | undefined;
419
+ let outDir: string | undefined;
420
+
421
+ for (let index = 0; index < args.length; index += 1) {
422
+ const arg = args[index];
423
+
424
+ if (arg === "--config") {
425
+ const value = args[index + 1];
426
+
427
+ if (value === undefined) {
428
+ return "Missing value for --config";
429
+ }
430
+
431
+ configPath = resolveCliPath(value);
432
+ index += 1;
433
+ continue;
434
+ }
435
+
436
+ if (arg === "--entry") {
437
+ const value = args[index + 1];
438
+
439
+ if (value === undefined) {
440
+ return "Missing value for --entry";
441
+ }
442
+
443
+ entryPath = resolveCliPath(value);
444
+ index += 1;
445
+ continue;
446
+ }
447
+
448
+ if (arg === "--outDir") {
449
+ const value = args[index + 1];
450
+
451
+ if (value === undefined) {
452
+ return "Missing value for --outDir";
453
+ }
454
+
455
+ outDir = resolveCliPath(value);
456
+ index += 1;
457
+ continue;
458
+ }
459
+
460
+ return `Unknown build option: ${arg}`;
461
+ }
462
+
463
+ return { configPath, entryPath, outDir };
464
+ }
465
+
466
+ function parsePreviewArgs(args: readonly string[]): PreviewOptions | string {
467
+ let dir = resolve(process.cwd(), "dist");
468
+ let port = 4173;
469
+ let open = false;
470
+
471
+ for (let index = 0; index < args.length; index += 1) {
472
+ const arg = args[index];
473
+
474
+ if (arg === "--dir") {
475
+ const value = args[index + 1];
476
+
477
+ if (value === undefined) {
478
+ return "Missing value for --dir";
479
+ }
480
+
481
+ dir = resolveCliPath(value);
482
+ index += 1;
483
+ continue;
484
+ }
485
+
486
+ if (arg === "--port") {
487
+ const value = args[index + 1];
488
+ const parsedPort = Number(value);
489
+
490
+ if (
491
+ !Number.isInteger(parsedPort) ||
492
+ parsedPort < 1 ||
493
+ parsedPort > 65535
494
+ ) {
495
+ return `Invalid port: ${value ?? ""}`;
496
+ }
497
+
498
+ port = parsedPort;
499
+ index += 1;
500
+ continue;
501
+ }
502
+
503
+ if (arg === "--open") {
504
+ open = true;
505
+ continue;
506
+ }
507
+
508
+ return `Unknown preview option: ${arg}`;
509
+ }
510
+
511
+ return { dir, port, open };
512
+ }
513
+
514
+ async function resolveDevProject(options: DevOptions): Promise<{
515
+ readonly entryPath: string;
516
+ readonly projectRoot: string;
517
+ }> {
518
+ if (options.entryPath !== undefined) {
519
+ return {
520
+ entryPath: options.entryPath,
521
+ projectRoot:
522
+ options.configPath === undefined
523
+ ? process.cwd()
524
+ : dirname(options.configPath),
525
+ };
526
+ }
527
+
528
+ const configPath =
529
+ options.configPath ?? resolve(process.cwd(), "velumo.config.ts");
530
+ const loadedConfig = await loadConfig(configPath);
531
+
532
+ if (loadedConfig.entryPath === undefined) {
533
+ throw new Error(`config entry is missing: ${configPath}`);
534
+ }
535
+
536
+ return {
537
+ entryPath: loadedConfig.entryPath,
538
+ projectRoot: loadedConfig.projectRoot,
539
+ };
540
+ }
541
+
542
+ async function resolveBuildProject(
543
+ options: BuildOptions,
544
+ ): Promise<BuildProject> {
545
+ if (
546
+ options.entryPath !== undefined &&
547
+ options.outDir !== undefined &&
548
+ options.configPath === undefined
549
+ ) {
550
+ return {
551
+ entryPath: options.entryPath,
552
+ outDir: options.outDir,
553
+ projectRoot: process.cwd(),
554
+ };
555
+ }
556
+
557
+ const configPath =
558
+ options.configPath ?? resolve(process.cwd(), "velumo.config.ts");
559
+ const loadedConfig = await loadConfig(configPath);
560
+ const entryPath =
561
+ options.entryPath === undefined
562
+ ? loadedConfig.entryPath
563
+ : options.entryPath;
564
+
565
+ if (entryPath === undefined) {
566
+ throw new Error(`config entry is missing: ${configPath}`);
567
+ }
568
+
569
+ return {
570
+ entryPath,
571
+ outDir:
572
+ options.outDir ??
573
+ loadedConfig.outDir ??
574
+ resolve(loadedConfig.projectRoot, "dist"),
575
+ projectRoot: loadedConfig.projectRoot,
576
+ };
577
+ }
578
+
579
+ async function compileDevManifest(entryPath: string): Promise<Manifest> {
580
+ const extension = extname(entryPath);
581
+
582
+ if (extension === ".ts" || extension === ".tsx") {
583
+ return compileDeck(entryPath);
584
+ }
585
+
586
+ if (extension === ".md" || extension === ".markdown") {
587
+ return compileMarkdownDeck(entryPath);
588
+ }
589
+
590
+ throw new Error(`unsupported dev entry extension: ${extension}`);
591
+ }
592
+
593
+ function send(
594
+ response: ServerResponse,
595
+ statusCode: number,
596
+ body: string | Buffer,
597
+ contentType: string,
598
+ ): void {
599
+ response.writeHead(statusCode, { "content-type": contentType });
600
+ response.end(body);
601
+ }
602
+
603
+ function contentTypeFor(path: string): string {
604
+ switch (extname(path).toLowerCase()) {
605
+ case ".css":
606
+ return "text/css; charset=utf-8";
607
+ case ".gif":
608
+ return "image/gif";
609
+ case ".html":
610
+ return "text/html; charset=utf-8";
611
+ case ".jpg":
612
+ case ".jpeg":
613
+ return "image/jpeg";
614
+ case ".js":
615
+ return "text/javascript; charset=utf-8";
616
+ case ".json":
617
+ return "application/json; charset=utf-8";
618
+ case ".mp3":
619
+ return "audio/mpeg";
620
+ case ".mp4":
621
+ return "video/mp4";
622
+ case ".png":
623
+ return "image/png";
624
+ case ".svg":
625
+ return "image/svg+xml";
626
+ case ".txt":
627
+ return "text/plain; charset=utf-8";
628
+ case ".webp":
629
+ return "image/webp";
630
+ default:
631
+ return "application/octet-stream";
632
+ }
633
+ }
634
+
635
+ async function readProjectIndexHtml(
636
+ projectRoot: string,
637
+ ): Promise<string | undefined> {
638
+ const path = resolve(projectRoot, "index.html");
639
+
640
+ try {
641
+ return await readFile(path, "utf8");
642
+ } catch (error) {
643
+ if (
644
+ error !== null &&
645
+ typeof error === "object" &&
646
+ "code" in error &&
647
+ (error as { code: unknown }).code === "ENOENT"
648
+ ) {
649
+ return undefined;
650
+ }
651
+
652
+ const message = error instanceof Error ? error.message : String(error);
653
+ console.error(
654
+ `Failed to read project index.html at ${path}: ${message}; falling back to generated shell`,
655
+ );
656
+ return undefined;
657
+ }
658
+ }
659
+
660
+ function indexHtml(
661
+ manifestPath = "/velumo/manifest.json",
662
+ browserAssetBase = "/velumo/",
663
+ ): string {
664
+ const browserRuntimePath = `${browserAssetBase}browser-runtime.js`;
665
+ const runtimePath = `${browserAssetBase}runtime.js`;
666
+ const rendererDomPath = `${browserAssetBase}renderer-dom.js`;
667
+ const defaultThemeCssPath = `${browserAssetBase}${defaultThemeCssAssetName}`;
668
+ const effectsCssPath = `${browserAssetBase}${effectsCssAssetName}`;
669
+
670
+ return `<!doctype html>
671
+ <html lang="en">
672
+ <head>
673
+ <meta charset="utf-8">
674
+ <title>Velumo Dev</title>
675
+ <style>
676
+ html, body { margin: 0; height: 100%; overflow: hidden; background: #000; }
677
+ body { display: flex; align-items: center; justify-content: center; }
678
+ #velumo-root { width: min(100vw, calc(100vh * 16 / 9)); aspect-ratio: 16 / 9; }
679
+ </style>
680
+ <link rel="stylesheet" href="${defaultThemeCssPath}">
681
+ <link rel="stylesheet" href="${effectsCssPath}">
682
+ </head>
683
+ <body>
684
+ <main id="velumo-root"></main>
685
+ <script type="importmap">
686
+ {
687
+ "imports": {
688
+ "@velumo/runtime": ${JSON.stringify(runtimePath)},
689
+ "@velumo/renderer-dom": ${JSON.stringify(rendererDomPath)}
690
+ }
691
+ }
692
+ </script>
693
+ <script type="module">
694
+ import { mountVelumoBrowserRuntime } from ${JSON.stringify(browserRuntimePath)};
695
+
696
+ await mountVelumoBrowserRuntime({
697
+ root: document.querySelector("#velumo-root"),
698
+ manifestUrl: ${JSON.stringify(manifestPath)},
699
+ });
700
+ </script>
701
+ </body>
702
+ </html>
703
+ `;
704
+ }
705
+
706
+ function presenterHtml(
707
+ manifestPath = "/velumo/manifest.json",
708
+ browserAssetBase = "/velumo/",
709
+ ): string {
710
+ const browserPresenterPath = `${browserAssetBase}browser-presenter.js`;
711
+ const runtimePath = `${browserAssetBase}runtime.js`;
712
+ const rendererDomPath = `${browserAssetBase}renderer-dom.js`;
713
+ const defaultThemeCssPath = `${browserAssetBase}${defaultThemeCssAssetName}`;
714
+
715
+ return `<!doctype html>
716
+ <html lang="en">
717
+ <head>
718
+ <meta charset="utf-8">
719
+ <title>Velumo Presenter View</title>
720
+ <!-- The slide previews are real DOM renderers; load the default theme so its
721
+ (renderer-scoped) typography + layout rules — including the var() that
722
+ resolves to the active theme's fonts — apply to the previews. The active
723
+ theme's own css + @font-face ride the renderer injection. -->
724
+ <link rel="stylesheet" href="${defaultThemeCssPath}">
725
+ <style>
726
+ :root {
727
+ color-scheme: dark;
728
+ font-family: ui-serif, Georgia, "Times New Roman", serif;
729
+ }
730
+
731
+ body {
732
+ margin: 0;
733
+ min-height: 100vh;
734
+ background:
735
+ radial-gradient(circle at 20% 10%, rgba(251, 191, 36, 0.18), transparent 30rem),
736
+ linear-gradient(135deg, #0f172a 0%, #111827 58%, #1f2937 100%);
737
+ color: #f8fafc;
738
+ }
739
+
740
+ #velumo-presenter-root {
741
+ min-height: 100vh;
742
+ padding: 1.5rem;
743
+ box-sizing: border-box;
744
+ }
745
+
746
+ [data-velumo-presenter="mounted"] {
747
+ display: grid;
748
+ gap: 1rem;
749
+ }
750
+
751
+ [data-velumo-presenter-slides] {
752
+ display: grid;
753
+ grid-template-columns: minmax(0, 1.5fr) minmax(18rem, 0.8fr);
754
+ gap: 1rem;
755
+ }
756
+
757
+ [data-velumo-presenter-current],
758
+ [data-velumo-presenter-next],
759
+ [data-velumo-presenter-notes],
760
+ [data-velumo-presenter-controls],
761
+ [data-velumo-presenter-header] {
762
+ border: 1px solid rgba(248, 250, 252, 0.18);
763
+ border-radius: 1rem;
764
+ padding: 1rem;
765
+ background: rgba(15, 23, 42, 0.72);
766
+ }
767
+
768
+ [data-velumo-presenter-controls] {
769
+ display: flex;
770
+ gap: 0.75rem;
771
+ }
772
+
773
+ button {
774
+ border: 1px solid rgba(251, 191, 36, 0.6);
775
+ border-radius: 999px;
776
+ padding: 0.6rem 1rem;
777
+ background: #fbbf24;
778
+ color: #111827;
779
+ font-weight: 700;
780
+ }
781
+
782
+ [data-velumo-fragment="hidden"] {
783
+ display: none;
784
+ }
785
+
786
+ [data-velumo-presenter-preview] {
787
+ aspect-ratio: 16 / 9;
788
+ width: 100%;
789
+ overflow: hidden;
790
+ }
791
+ </style>
792
+ </head>
793
+ <body>
794
+ <main id="velumo-presenter-root"></main>
795
+ <script type="importmap">
796
+ {
797
+ "imports": {
798
+ "@velumo/runtime": ${JSON.stringify(runtimePath)},
799
+ "@velumo/renderer-dom": ${JSON.stringify(rendererDomPath)}
800
+ }
801
+ }
802
+ </script>
803
+ <script type="module">
804
+ import { mountVelumoPresenterView } from ${JSON.stringify(browserPresenterPath)};
805
+ import { mountSlideThumbnail } from "@velumo/renderer-dom";
806
+
807
+ await mountVelumoPresenterView({
808
+ root: document.querySelector("#velumo-presenter-root"),
809
+ manifestUrl: ${JSON.stringify(manifestPath)},
810
+ mountThumbnail: mountSlideThumbnail,
811
+ });
812
+ </script>
813
+ </body>
814
+ </html>
815
+ `;
816
+ }
817
+
818
+ function isPathInside(parentPath: string, candidatePath: string): boolean {
819
+ const relativePath = relative(parentPath, candidatePath);
820
+
821
+ return (
822
+ relativePath === "" ||
823
+ (!relativePath.startsWith("..") && !isAbsolute(relativePath))
824
+ );
825
+ }
826
+
827
+ async function servePublicAsset(
828
+ projectRoot: string,
829
+ pathname: string,
830
+ response: ServerResponse,
831
+ ): Promise<boolean> {
832
+ const publicRoot = resolve(projectRoot, "public");
833
+ const assetPath = resolve(publicRoot, `.${pathname}`);
834
+
835
+ if (!isPathInside(publicRoot, assetPath)) {
836
+ send(response, 403, "Forbidden", "text/plain; charset=utf-8");
837
+ return true;
838
+ }
839
+
840
+ try {
841
+ const assetStat = await stat(assetPath);
842
+
843
+ if (!assetStat.isFile()) {
844
+ return false;
845
+ }
846
+
847
+ send(response, 200, await readFile(assetPath), contentTypeFor(assetPath));
848
+ return true;
849
+ } catch {
850
+ return false;
851
+ }
852
+ }
853
+
854
+ async function copyPublicFiles(
855
+ sourceDir: string,
856
+ targetDir: string,
857
+ ): Promise<void> {
858
+ let entries: Dirent[];
859
+ try {
860
+ entries = await readdir(sourceDir, { withFileTypes: true });
861
+ } catch {
862
+ return;
863
+ }
864
+
865
+ await mkdir(targetDir, { recursive: true });
866
+
867
+ for (const entry of entries) {
868
+ const sourcePath = resolve(sourceDir, entry.name);
869
+ const targetPath = resolve(targetDir, entry.name);
870
+
871
+ if (entry.isDirectory()) {
872
+ await copyPublicFiles(sourcePath, targetPath);
873
+ continue;
874
+ }
875
+
876
+ if (entry.isFile()) {
877
+ await mkdir(dirname(targetPath), { recursive: true });
878
+ await copyFile(sourcePath, targetPath);
879
+ }
880
+ }
881
+ }
882
+
883
+ async function readBrowserRuntimeAsset(
884
+ assetName: BrowserRuntimeAssetName,
885
+ ): Promise<Buffer> {
886
+ return readFile(browserRuntimeAssets[assetName]());
887
+ }
888
+
889
+ async function copyBrowserRuntimeAssets(outDir: string): Promise<void> {
890
+ const targetDir = resolve(outDir, "velumo");
891
+ await mkdir(targetDir, { recursive: true });
892
+
893
+ await Promise.all(
894
+ browserRuntimeAssetNames.map(async (assetName) => {
895
+ await writeFile(
896
+ resolve(targetDir, assetName),
897
+ await readBrowserRuntimeAsset(assetName),
898
+ );
899
+ }),
900
+ );
901
+ }
902
+
903
+ async function writeDefaultThemeCssAsset(outDir: string): Promise<void> {
904
+ const targetDir = resolve(outDir, "velumo");
905
+ await mkdir(targetDir, { recursive: true });
906
+ await writeFile(
907
+ resolve(targetDir, defaultThemeCssAssetName),
908
+ defaultThemeCss,
909
+ );
910
+ await writeFile(resolve(targetDir, effectsCssAssetName), effectsCss);
911
+ }
912
+
913
+ function browserRuntimeAssetNameForPath(
914
+ pathname: string,
915
+ ): BrowserRuntimeAssetName | undefined {
916
+ const prefix = "/velumo/";
917
+
918
+ if (!pathname.startsWith(prefix)) {
919
+ return undefined;
920
+ }
921
+
922
+ const assetName = pathname.slice(prefix.length) as BrowserRuntimeAssetName;
923
+ return browserRuntimeAssetNames.includes(assetName) ? assetName : undefined;
924
+ }
925
+
926
+ async function serveBrowserRuntimeAsset(
927
+ pathname: string,
928
+ response: ServerResponse,
929
+ ): Promise<boolean> {
930
+ const assetName = browserRuntimeAssetNameForPath(pathname);
931
+
932
+ if (assetName === undefined) {
933
+ return false;
934
+ }
935
+
936
+ send(
937
+ response,
938
+ 200,
939
+ await readBrowserRuntimeAsset(assetName),
940
+ contentTypeFor(assetName),
941
+ );
942
+ return true;
943
+ }
944
+
945
+ function serveDefaultThemeCssAsset(
946
+ pathname: string,
947
+ response: ServerResponse,
948
+ ): boolean {
949
+ if (pathname !== `/velumo/${defaultThemeCssAssetName}`) {
950
+ return false;
951
+ }
952
+
953
+ send(
954
+ response,
955
+ 200,
956
+ defaultThemeCss,
957
+ contentTypeFor(defaultThemeCssAssetName),
958
+ );
959
+ return true;
960
+ }
961
+
962
+ function serveEffectsCssAsset(
963
+ pathname: string,
964
+ response: ServerResponse,
965
+ ): boolean {
966
+ if (pathname !== `/velumo/${effectsCssAssetName}`) {
967
+ return false;
968
+ }
969
+
970
+ send(response, 200, effectsCss, contentTypeFor(effectsCssAssetName));
971
+ return true;
972
+ }
973
+
974
+ async function runBuild(args: readonly string[]): Promise<number> {
975
+ const options = parseBuildArgs(args);
976
+
977
+ if (typeof options === "string") {
978
+ console.error(options);
979
+ return 1;
980
+ }
981
+
982
+ let project: BuildProject;
983
+
984
+ try {
985
+ project = await resolveBuildProject(options);
986
+ } catch (error) {
987
+ console.error(error instanceof Error ? error.message : "config error");
988
+ return 1;
989
+ }
990
+
991
+ const validation = await validateDeck(project.entryPath);
992
+
993
+ for (const warning of validation.warnings) {
994
+ console.log(`WARN: ${warning}`);
995
+ }
996
+
997
+ if (!validation.valid) {
998
+ for (const error of validation.errors) {
999
+ console.error(`ERROR: ${error}`);
1000
+ }
1001
+
1002
+ return 1;
1003
+ }
1004
+
1005
+ let manifest: Manifest;
1006
+
1007
+ try {
1008
+ manifest = await compileDevManifest(project.entryPath);
1009
+ } catch (error) {
1010
+ console.error(error instanceof Error ? error.message : "build failed");
1011
+ return 1;
1012
+ }
1013
+
1014
+ await mkdir(project.outDir, { recursive: true });
1015
+ await copyPublicFiles(resolve(project.projectRoot, "public"), project.outDir);
1016
+ await copyBrowserRuntimeAssets(project.outDir);
1017
+ await writeDefaultThemeCssAsset(project.outDir);
1018
+ await writeFile(
1019
+ resolve(project.outDir, "manifest.json"),
1020
+ JSON.stringify(manifest, null, 2),
1021
+ );
1022
+ const projectShell = await readProjectIndexHtml(project.projectRoot);
1023
+ await writeFile(
1024
+ resolve(project.outDir, "index.html"),
1025
+ projectShell ?? indexHtml("manifest.json", "./velumo/"),
1026
+ );
1027
+ await writeFile(
1028
+ resolve(project.outDir, "velumo", "presenter.html"),
1029
+ presenterHtml("../manifest.json", "./"),
1030
+ );
1031
+ console.log(`Built Velumo output at ${project.outDir}`);
1032
+ return 0;
1033
+ }
1034
+
1035
+ async function serveStaticFile(
1036
+ rootDir: string,
1037
+ pathname: string,
1038
+ response: ServerResponse,
1039
+ ): Promise<void> {
1040
+ const requestedPath = pathname === "/" ? "/index.html" : pathname;
1041
+ const filePath = resolve(rootDir, `.${requestedPath}`);
1042
+
1043
+ if (!isPathInside(rootDir, filePath)) {
1044
+ send(response, 403, "Forbidden", "text/plain; charset=utf-8");
1045
+ return;
1046
+ }
1047
+
1048
+ try {
1049
+ const fileStat = await stat(filePath);
1050
+
1051
+ if (!fileStat.isFile()) {
1052
+ send(response, 404, "Not found", "text/plain; charset=utf-8");
1053
+ return;
1054
+ }
1055
+
1056
+ send(response, 200, await readFile(filePath), contentTypeFor(filePath));
1057
+ } catch {
1058
+ send(response, 404, "Not found", "text/plain; charset=utf-8");
1059
+ }
1060
+ }
1061
+
1062
+ async function runPreview(args: readonly string[]): Promise<number> {
1063
+ const options = parsePreviewArgs(args);
1064
+
1065
+ if (typeof options === "string") {
1066
+ console.error(options);
1067
+ return 1;
1068
+ }
1069
+
1070
+ if (options.open) {
1071
+ console.log(
1072
+ "Open requested; browser launch is not implemented in this Ward.",
1073
+ );
1074
+ }
1075
+
1076
+ const server = createServer(async (request, response) => {
1077
+ const url = new URL(request.url ?? "/", `http://${request.headers.host}`);
1078
+ await serveStaticFile(options.dir, url.pathname, response);
1079
+ });
1080
+
1081
+ return new Promise((resolveExitCode) => {
1082
+ server.once("error", (error) => {
1083
+ console.error(error.message);
1084
+ resolveExitCode(1);
1085
+ });
1086
+
1087
+ server.listen(options.port, "localhost", () => {
1088
+ console.log(
1089
+ `Velumo preview server running at http://localhost:${options.port}`,
1090
+ );
1091
+ });
1092
+ });
1093
+ }
1094
+
1095
+ async function runDev(args: readonly string[]): Promise<number> {
1096
+ const options = parseDevArgs(args);
1097
+
1098
+ if (typeof options === "string") {
1099
+ console.error(options);
1100
+ return 1;
1101
+ }
1102
+
1103
+ let project: Awaited<ReturnType<typeof resolveDevProject>>;
1104
+
1105
+ try {
1106
+ project = await resolveDevProject(options);
1107
+ } catch (error) {
1108
+ console.error(error instanceof Error ? error.message : "config error");
1109
+ return 1;
1110
+ }
1111
+
1112
+ if (options.open) {
1113
+ console.log(
1114
+ "Open requested; browser launch is not implemented in this Ward.",
1115
+ );
1116
+ }
1117
+
1118
+ const server = createServer(async (request, response) => {
1119
+ const url = new URL(request.url ?? "/", `http://${request.headers.host}`);
1120
+
1121
+ if (url.pathname === "/") {
1122
+ const projectShell = await readProjectIndexHtml(project.projectRoot);
1123
+ const shell = projectShell ?? indexHtml();
1124
+ send(response, 200, shell, "text/html; charset=utf-8");
1125
+ return;
1126
+ }
1127
+
1128
+ if (url.pathname === "/velumo/presenter.html") {
1129
+ send(response, 200, presenterHtml(), "text/html; charset=utf-8");
1130
+ return;
1131
+ }
1132
+
1133
+ if (await serveBrowserRuntimeAsset(url.pathname, response)) {
1134
+ return;
1135
+ }
1136
+
1137
+ if (serveDefaultThemeCssAsset(url.pathname, response)) {
1138
+ return;
1139
+ }
1140
+
1141
+ if (serveEffectsCssAsset(url.pathname, response)) {
1142
+ return;
1143
+ }
1144
+
1145
+ if (url.pathname === "/velumo/manifest.json") {
1146
+ try {
1147
+ send(
1148
+ response,
1149
+ 200,
1150
+ JSON.stringify(await compileDevManifest(project.entryPath)),
1151
+ "application/json; charset=utf-8",
1152
+ );
1153
+ } catch (error) {
1154
+ send(
1155
+ response,
1156
+ 500,
1157
+ error instanceof Error ? error.message : "invalid manifest",
1158
+ "text/plain; charset=utf-8",
1159
+ );
1160
+ }
1161
+
1162
+ return;
1163
+ }
1164
+
1165
+ if (await servePublicAsset(project.projectRoot, url.pathname, response)) {
1166
+ return;
1167
+ }
1168
+
1169
+ if (url.pathname === "/manifest.json") {
1170
+ try {
1171
+ send(
1172
+ response,
1173
+ 200,
1174
+ JSON.stringify(await compileDevManifest(project.entryPath)),
1175
+ "application/json; charset=utf-8",
1176
+ );
1177
+ } catch (error) {
1178
+ send(
1179
+ response,
1180
+ 500,
1181
+ error instanceof Error ? error.message : "invalid manifest",
1182
+ "text/plain; charset=utf-8",
1183
+ );
1184
+ }
1185
+
1186
+ return;
1187
+ }
1188
+
1189
+ send(response, 404, "Not found", "text/plain; charset=utf-8");
1190
+ });
1191
+
1192
+ return new Promise((resolveExitCode) => {
1193
+ server.once("error", (error) => {
1194
+ console.error(error.message);
1195
+ resolveExitCode(1);
1196
+ });
1197
+
1198
+ server.listen(options.port, "localhost", () => {
1199
+ console.log(
1200
+ `Velumo dev server running at http://localhost:${options.port}`,
1201
+ );
1202
+ });
1203
+ });
1204
+ }
1205
+
1206
+ async function runValidate(entryPath: string | undefined): Promise<number> {
1207
+ if (entryPath === undefined) {
1208
+ console.error(usage());
1209
+ return 1;
1210
+ }
1211
+
1212
+ const result = await validateDeck(entryPath);
1213
+
1214
+ for (const warning of result.warnings) {
1215
+ console.log(`WARN: ${warning}`);
1216
+ }
1217
+
1218
+ if (!result.valid) {
1219
+ for (const error of result.errors) {
1220
+ console.error(`ERROR: ${error}`);
1221
+ }
1222
+
1223
+ return 1;
1224
+ }
1225
+
1226
+ console.log(`Valid deck: ${entryPath}`);
1227
+ return 0;
1228
+ }
1229
+
1230
+ export async function main(args = process.argv.slice(2)): Promise<number> {
1231
+ const [command, ...rest] = args;
1232
+
1233
+ if (command === "validate") {
1234
+ return runValidate(rest[0]);
1235
+ }
1236
+
1237
+ if (command === "create") {
1238
+ return runCreate(rest);
1239
+ }
1240
+
1241
+ if (command === "dev") {
1242
+ return runDev(rest);
1243
+ }
1244
+
1245
+ if (command === "build") {
1246
+ return runBuild(rest);
1247
+ }
1248
+
1249
+ if (command === "preview") {
1250
+ return runPreview(rest);
1251
+ }
1252
+
1253
+ console.error(usage());
1254
+ return 1;
1255
+ }
1256
+
1257
+ function isDirectInvocation(
1258
+ moduleUrl: string,
1259
+ argvScript: string | undefined,
1260
+ ): boolean {
1261
+ if (argvScript === undefined) {
1262
+ return false;
1263
+ }
1264
+ try {
1265
+ return moduleUrl === pathToFileURL(realpathSync(argvScript)).href;
1266
+ } catch {
1267
+ return false;
1268
+ }
1269
+ }
1270
+
1271
+ if (isDirectInvocation(import.meta.url, process.argv[1])) {
1272
+ process.exitCode = await main();
1273
+ }