@hajimism/dek 0.1.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 (187) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +75 -0
  3. package/README.md +75 -0
  4. package/package.json +65 -0
  5. package/src/cli/address.ts +53 -0
  6. package/src/cli/build.ts +85 -0
  7. package/src/cli/check.ts +97 -0
  8. package/src/cli/commands.ts +851 -0
  9. package/src/cli/contract.ts +444 -0
  10. package/src/cli/cues.ts +18 -0
  11. package/src/cli/files.ts +309 -0
  12. package/src/cli/flags.ts +295 -0
  13. package/src/cli/format.ts +156 -0
  14. package/src/cli/goto.ts +96 -0
  15. package/src/cli/init.ts +136 -0
  16. package/src/cli/keep-alive.ts +72 -0
  17. package/src/cli/lint.ts +84 -0
  18. package/src/cli/ls.ts +119 -0
  19. package/src/cli/main.ts +231 -0
  20. package/src/cli/marks.ts +16 -0
  21. package/src/cli/mv.ts +37 -0
  22. package/src/cli/new.ts +64 -0
  23. package/src/cli/pdf.ts +16 -0
  24. package/src/cli/qr.ts +466 -0
  25. package/src/cli/ref.ts +165 -0
  26. package/src/cli/rehearse.ts +42 -0
  27. package/src/cli/result.ts +161 -0
  28. package/src/cli/scope.ts +308 -0
  29. package/src/cli/serve.ts +125 -0
  30. package/src/cli/shot.ts +134 -0
  31. package/src/cli/show.ts +103 -0
  32. package/src/cli/sync.ts +37 -0
  33. package/src/cli/text.ts +479 -0
  34. package/src/cli/theme.ts +56 -0
  35. package/src/cli/tty.ts +81 -0
  36. package/src/cli/usage.ts +198 -0
  37. package/src/cli/video.ts +17 -0
  38. package/src/cli/voice.ts +113 -0
  39. package/src/cli.ts +4 -0
  40. package/src/core/assets.ts +239 -0
  41. package/src/core/build.ts +77 -0
  42. package/src/core/bundled-theme.ts +15 -0
  43. package/src/core/capture-go.ts +72 -0
  44. package/src/core/chrome.ts +128 -0
  45. package/src/core/config-keys.ts +82 -0
  46. package/src/core/config.ts +95 -0
  47. package/src/core/css-scan.ts +158 -0
  48. package/src/core/css-transform.ts +140 -0
  49. package/src/core/css-value.ts +127 -0
  50. package/src/core/css.ts +545 -0
  51. package/src/core/cue.ts +187 -0
  52. package/src/core/deck-paths.ts +34 -0
  53. package/src/core/diagnostic.ts +114 -0
  54. package/src/core/document.ts +218 -0
  55. package/src/core/error.ts +31 -0
  56. package/src/core/escape.ts +16 -0
  57. package/src/core/fill.ts +100 -0
  58. package/src/core/finish-beat.ts +30 -0
  59. package/src/core/github.ts +234 -0
  60. package/src/core/html-scan.ts +253 -0
  61. package/src/core/html.ts +234 -0
  62. package/src/core/in-page-go.ts +128 -0
  63. package/src/core/lines.ts +43 -0
  64. package/src/core/lint/asset-refs.ts +109 -0
  65. package/src/core/lint/context.ts +116 -0
  66. package/src/core/lint/files.ts +117 -0
  67. package/src/core/lint/keys.ts +48 -0
  68. package/src/core/lint/morph.ts +91 -0
  69. package/src/core/lint/script.ts +102 -0
  70. package/src/core/lint/shared-styles.ts +165 -0
  71. package/src/core/lint/slide-html.ts +466 -0
  72. package/src/core/lint/slide-script.ts +179 -0
  73. package/src/core/lint/theme-css.ts +295 -0
  74. package/src/core/lint/timing.ts +66 -0
  75. package/src/core/lint/tokens.ts +520 -0
  76. package/src/core/lint/voice.ts +76 -0
  77. package/src/core/lint.ts +137 -0
  78. package/src/core/live-protocol.ts +130 -0
  79. package/src/core/marks.ts +198 -0
  80. package/src/core/mv.ts +293 -0
  81. package/src/core/ogp.ts +99 -0
  82. package/src/core/optional.ts +24 -0
  83. package/src/core/overflow.ts +220 -0
  84. package/src/core/page-pool.ts +53 -0
  85. package/src/core/page.ts +66 -0
  86. package/src/core/parse.ts +307 -0
  87. package/src/core/path.ts +58 -0
  88. package/src/core/pdf.ts +71 -0
  89. package/src/core/playwright-module.d.ts +43 -0
  90. package/src/core/playwright-motion.ts +42 -0
  91. package/src/core/playwright-sheet.ts +32 -0
  92. package/src/core/playwright-visual.ts +391 -0
  93. package/src/core/playwright-worker.ts +5 -0
  94. package/src/core/playwright.ts +431 -0
  95. package/src/core/position.ts +146 -0
  96. package/src/core/presenter-state.ts +43 -0
  97. package/src/core/presenter.ts +131 -0
  98. package/src/core/prose.ts +16 -0
  99. package/src/core/pseudo-text.ts +67 -0
  100. package/src/core/rail-width.ts +4 -0
  101. package/src/core/ref-name.ts +110 -0
  102. package/src/core/ref.ts +238 -0
  103. package/src/core/resolve.ts +228 -0
  104. package/src/core/rumdl.ts +99 -0
  105. package/src/core/safe-fs.ts +196 -0
  106. package/src/core/sarif.ts +135 -0
  107. package/src/core/schema.ts +89 -0
  108. package/src/core/script-lines.ts +53 -0
  109. package/src/core/sheet.ts +223 -0
  110. package/src/core/shot/morph.ts +80 -0
  111. package/src/core/shot/motion.ts +187 -0
  112. package/src/core/shot/still.ts +216 -0
  113. package/src/core/shot-cache.ts +140 -0
  114. package/src/core/size.ts +11 -0
  115. package/src/core/skeleton-record.ts +51 -0
  116. package/src/core/skeleton.ts +89 -0
  117. package/src/core/slide-draw.ts +165 -0
  118. package/src/core/slide-eval-worker.ts +58 -0
  119. package/src/core/slide-measure.ts +272 -0
  120. package/src/core/slide-script-eval.ts +258 -0
  121. package/src/core/slide-script.ts +252 -0
  122. package/src/core/spawn.ts +128 -0
  123. package/src/core/step.ts +94 -0
  124. package/src/core/still-page.ts +70 -0
  125. package/src/core/suggest.ts +33 -0
  126. package/src/core/sync.ts +316 -0
  127. package/src/core/text-contrast.ts +466 -0
  128. package/src/core/theme-excerpt.ts +187 -0
  129. package/src/core/theme-facts.ts +51 -0
  130. package/src/core/timeline.ts +216 -0
  131. package/src/core/timing.ts +88 -0
  132. package/src/core/toml-keys.ts +349 -0
  133. package/src/core/url-attributes.ts +103 -0
  134. package/src/core/video-document.ts +15 -0
  135. package/src/core/visual-cache.ts +100 -0
  136. package/src/core/visual.ts +558 -0
  137. package/src/core/voice.ts +321 -0
  138. package/src/core/zod.ts +79 -0
  139. package/src/runtime/browser.ts +203 -0
  140. package/src/runtime/clone.ts +52 -0
  141. package/src/runtime/deck-control.ts +43 -0
  142. package/src/runtime/fit.ts +32 -0
  143. package/src/runtime/go.ts +56 -0
  144. package/src/runtime/input.ts +128 -0
  145. package/src/runtime/laser-view.ts +110 -0
  146. package/src/runtime/laser.ts +145 -0
  147. package/src/runtime/live-host.ts +33 -0
  148. package/src/runtime/live-reload.ts +29 -0
  149. package/src/runtime/live.ts +88 -0
  150. package/src/runtime/marks-view.ts +74 -0
  151. package/src/runtime/motion.ts +113 -0
  152. package/src/runtime/navigator.ts +189 -0
  153. package/src/runtime/player.ts +28 -0
  154. package/src/runtime/presenter-view.ts +289 -0
  155. package/src/runtime/presenter.ts +52 -0
  156. package/src/runtime/rail-view.ts +229 -0
  157. package/src/runtime/rail.ts +52 -0
  158. package/src/runtime/rehearse.ts +264 -0
  159. package/src/runtime/routes.ts +142 -0
  160. package/src/runtime/settle.ts +45 -0
  161. package/src/runtime/slide.d.ts +28 -0
  162. package/src/runtime/socket.ts +143 -0
  163. package/src/runtime/stage.ts +259 -0
  164. package/src/runtime/step.ts +139 -0
  165. package/src/runtime/window.ts +38 -0
  166. package/src/server/auth.ts +210 -0
  167. package/src/server/deck-snapshot.ts +146 -0
  168. package/src/server/dev.ts +251 -0
  169. package/src/server/hub.ts +104 -0
  170. package/src/server/lan.ts +51 -0
  171. package/src/server/lock.ts +97 -0
  172. package/src/server/pages.ts +71 -0
  173. package/src/server/pairing.ts +40 -0
  174. package/src/server/project-watch.ts +142 -0
  175. package/src/server/rooms.ts +133 -0
  176. package/src/server/routes.ts +286 -0
  177. package/src/server/serial.ts +25 -0
  178. package/src/server/watch.ts +308 -0
  179. package/src/theme/default.css +175 -0
  180. package/src/video/bake.ts +152 -0
  181. package/src/video/mux.ts +103 -0
  182. package/src/video/recorder.ts +115 -0
  183. package/src/video/sidecar.ts +60 -0
  184. package/src/video/worker.ts +51 -0
  185. package/src/voice/engine.ts +177 -0
  186. package/src/voice/synth.ts +127 -0
  187. package/src/voice/wav.ts +115 -0
@@ -0,0 +1,104 @@
1
+ import type { LiveEvent } from "../core/live-protocol.ts";
2
+
3
+ /** A live event and the deck it happened in; a page hears only its own deck's. */
4
+ export type DeckEvent = LiveEvent & { deck: string };
5
+
6
+ type Listener = (event: DeckEvent) => void;
7
+
8
+ /** What the server's own readers see of the hub: subscribe, and later unsubscribe. */
9
+ export type EventFeed = { listen(fn: Listener): () => void };
10
+
11
+ export type EventHub = EventFeed & {
12
+ emit(event: DeckEvent): void;
13
+ /** An SSE stream of every event `accept` lets through, all of them when it is left out. */
14
+ sse(accept?: (event: DeckEvent) => boolean): ReadableStream<Uint8Array>;
15
+ /** Drop every listener and end every stream; later events go nowhere. */
16
+ close(): void;
17
+ };
18
+
19
+ /** Bun closes a request that is idle for 10s; an SSE comment keeps `/events` open. */
20
+ export const SSE_HEARTBEAT_MS = 5_000;
21
+
22
+ /**
23
+ * Fan-out: each event goes to whoever listens at the time, and to no one else. Nothing is kept
24
+ * for a listener that comes later, so a reader that stops reading cannot make the hub grow.
25
+ */
26
+ export function createEventHub(options: { heartbeatMs?: number } = {}): EventHub {
27
+ const listeners = new Set<Listener>();
28
+ const streams = new Set<() => void>();
29
+ const encoder = new TextEncoder();
30
+ let closed = false;
31
+
32
+ const listen = (fn: Listener): (() => void) => {
33
+ if (closed) {
34
+ return () => undefined;
35
+ }
36
+ listeners.add(fn);
37
+ return () => {
38
+ listeners.delete(fn);
39
+ };
40
+ };
41
+
42
+ return {
43
+ listen,
44
+ emit(event) {
45
+ for (const fn of [...listeners]) {
46
+ try {
47
+ fn(event);
48
+ } catch {
49
+ // One reader's failure is its own; the rest still hear the event.
50
+ }
51
+ }
52
+ },
53
+ sse(accept = () => true) {
54
+ let end = (): void => undefined;
55
+ return new ReadableStream<Uint8Array>({
56
+ start(controller) {
57
+ const send = (bytes: Uint8Array): void => {
58
+ try {
59
+ controller.enqueue(bytes);
60
+ } catch {
61
+ end();
62
+ }
63
+ };
64
+ const heartbeat = setInterval(
65
+ () => send(encoder.encode(": ping\n\n")),
66
+ options.heartbeatMs ?? SSE_HEARTBEAT_MS,
67
+ );
68
+ // The page's stream is its deck's already, so the deck stays off the wire.
69
+ const unlisten = listen((event) => {
70
+ if (accept(event)) {
71
+ const { deck: _deck, ...wire } = event;
72
+ send(encoder.encode(`data: ${JSON.stringify(wire)}\n\n`));
73
+ }
74
+ });
75
+ end = () => {
76
+ clearInterval(heartbeat);
77
+ unlisten();
78
+ streams.delete(end);
79
+ try {
80
+ controller.close();
81
+ } catch {
82
+ /* already closed */
83
+ }
84
+ };
85
+ streams.add(end);
86
+ send(encoder.encode(": connected\n\n"));
87
+ if (closed) {
88
+ end();
89
+ }
90
+ },
91
+ cancel() {
92
+ end();
93
+ },
94
+ });
95
+ },
96
+ close() {
97
+ closed = true;
98
+ listeners.clear();
99
+ for (const end of [...streams]) {
100
+ end();
101
+ }
102
+ },
103
+ };
104
+ }
@@ -0,0 +1,51 @@
1
+ import { type NetworkInterfaceInfo, networkInterfaces } from "node:os";
2
+
3
+ const PASSWORD_ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789";
4
+
5
+ export function lanUrls(
6
+ port: number,
7
+ ifaces: NodeJS.Dict<NetworkInterfaceInfo[]> = networkInterfaces(),
8
+ ): string[] {
9
+ const urls: string[] = [];
10
+ for (const entries of Object.values(ifaces)) {
11
+ for (const info of entries ?? []) {
12
+ const family = String(info.family);
13
+ if (info.internal || (family !== "IPv4" && family !== "4")) {
14
+ continue;
15
+ }
16
+ urls.push(`http://${info.address}:${port}/`);
17
+ }
18
+ }
19
+ return urls;
20
+ }
21
+
22
+ /** Ten letters from an alphabet without look-alikes: 50 bits, still easy to read out to a room. */
23
+ export function generateRemotePassword(length = 10): string {
24
+ const bytes = new Uint8Array(length);
25
+ crypto.getRandomValues(bytes);
26
+ return [...bytes].map((byte) => PASSWORD_ALPHABET[byte % PASSWORD_ALPHABET.length]).join("");
27
+ }
28
+
29
+ export function remoteBanner(
30
+ loopback: string,
31
+ remoteUrls: string[],
32
+ options: { password?: string; presenterPaths?: string[] } = {},
33
+ ): string {
34
+ const password = options.password;
35
+ if (!password) {
36
+ return loopback;
37
+ }
38
+ const lines = [loopback];
39
+ for (const url of remoteUrls) {
40
+ if (url !== loopback) {
41
+ lines.push(url);
42
+ }
43
+ }
44
+ const presenterHost = remoteUrls.find((url) => !url.includes("127.0.0.1")) ?? loopback;
45
+ for (const path of options.presenterPaths ?? []) {
46
+ lines.push(`presenter: ${new URL(path, presenterHost).href}`);
47
+ }
48
+ // The browser asks for a user name too; the password alone unlocks the presenter.
49
+ lines.push(`password: ${password} (any user name)`);
50
+ return lines.join("\n");
51
+ }
@@ -0,0 +1,97 @@
1
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { DekError } from "../core/error.ts";
4
+ import { writeInside } from "../core/safe-fs.ts";
5
+
6
+ export type DevServerLock = {
7
+ url: string;
8
+ pid: number;
9
+ password?: string;
10
+ /** The one deck a server scoped to a deck serves; absent when it serves the project. */
11
+ deck?: string;
12
+ };
13
+
14
+ function serverLockPath(root: string): string {
15
+ return join(root, ".dek", "server.json");
16
+ }
17
+
18
+ export function isPidAlive(pid: number): boolean {
19
+ try {
20
+ process.kill(pid, 0);
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ export function readDevServerLock(root: string): DevServerLock | undefined {
28
+ const path = serverLockPath(root);
29
+ if (!existsSync(path)) {
30
+ return undefined;
31
+ }
32
+ try {
33
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as DevServerLock;
34
+ if (typeof parsed.url === "string" && typeof parsed.pid === "number") {
35
+ return {
36
+ url: parsed.url,
37
+ pid: parsed.pid,
38
+ ...(typeof parsed.password === "string" ? { password: parsed.password } : {}),
39
+ ...(typeof parsed.deck === "string" ? { deck: parsed.deck } : {}),
40
+ };
41
+ }
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ /** The URL of each root a server in this process holds, which two racing servers both see. */
49
+ const claimedRoots = new Map<string, string>();
50
+
51
+ /** Throw when a live server, in this process or another, already holds `root`. */
52
+ export function assertNoDevServer(root: string): void {
53
+ const existing = readDevServerLock(root);
54
+ const running =
55
+ claimedRoots.get(root) ?? (existing && isPidAlive(existing.pid) ? existing.url : undefined);
56
+ if (running !== undefined) {
57
+ throw new DekError(`dev server already running at ${running}`, {
58
+ path: serverLockPath(root),
59
+ hint: `open ${running}`,
60
+ });
61
+ }
62
+ }
63
+
64
+ export function writeDevServerLock(
65
+ root: string,
66
+ url: string,
67
+ password?: string,
68
+ deck?: string,
69
+ ): void {
70
+ assertNoDevServer(root);
71
+ claimedRoots.set(root, url);
72
+ try {
73
+ // A fresh file renamed into place, so it may hold the --remote password: its owner's only.
74
+ writeInside(
75
+ serverLockPath(root),
76
+ `${JSON.stringify({ url, pid: process.pid, ...(password ? { password } : {}), ...(deck ? { deck } : {}) })}\n`,
77
+ root,
78
+ { mode: 0o600 },
79
+ );
80
+ } catch (error) {
81
+ claimedRoots.delete(root);
82
+ throw error;
83
+ }
84
+ }
85
+
86
+ export function removeDevServerLock(root: string): void {
87
+ claimedRoots.delete(root);
88
+ const current = readDevServerLock(root);
89
+ if (current && current.pid !== process.pid) {
90
+ return;
91
+ }
92
+ try {
93
+ unlinkSync(serverLockPath(root));
94
+ } catch {
95
+ /* already gone */
96
+ }
97
+ }
@@ -0,0 +1,71 @@
1
+ import { renderDeckHtml } from "../core/document.ts";
2
+ import type { Project } from "../core/resolve.ts";
3
+ import { errorPage, htmlResponse, renderIndexHtml } from "./routes.ts";
4
+
5
+ export type DevPages = {
6
+ /** A deck's live page; a deck that fails to render answers with its error. */
7
+ deckPage(dir: string, mode: "player" | "presenter"): Response;
8
+ /** The project's list of decks. */
9
+ indexPage(): Response;
10
+ /** Forget every rendered page, after an edit that may change what they show. */
11
+ clear(): void;
12
+ };
13
+
14
+ /** The pages a dev server renders, each kept until `clear`. */
15
+ export function createDevPages(options: {
16
+ embed: { playerScript: string; liveReloadScript: string };
17
+ remote: boolean;
18
+ /** Embedded on the presenter page, so its stream and moves are let through. */
19
+ password: string | undefined;
20
+ project: () => Project;
21
+ }): DevPages {
22
+ const { embed, remote, password } = options;
23
+ const pages = new Map<string, string>();
24
+
25
+ const cached = (key: string, render: () => string): string => {
26
+ const hit = pages.get(key);
27
+ if (hit !== undefined) {
28
+ return hit;
29
+ }
30
+ const html = render();
31
+ pages.set(key, html);
32
+ return html;
33
+ };
34
+
35
+ return {
36
+ deckPage(dir, mode) {
37
+ try {
38
+ return htmlResponse(
39
+ cached(`${dir}:${mode}`, () =>
40
+ renderDeckHtml(dir, {
41
+ playerScript: embed.playerScript,
42
+ target: {
43
+ kind: "dev",
44
+ mode,
45
+ includeNotes: mode === "presenter" || !remote,
46
+ liveReloadScript: embed.liveReloadScript,
47
+ ...(mode === "presenter" && password ? { liveToken: password } : {}),
48
+ },
49
+ }),
50
+ ),
51
+ );
52
+ } catch (error) {
53
+ return htmlResponse(errorPage(error), 500);
54
+ }
55
+ },
56
+ indexPage() {
57
+ return htmlResponse(
58
+ cached("index", () => {
59
+ const { decks, failed } = options.project();
60
+ return renderIndexHtml(
61
+ decks.map((deck) => ({ name: deck.name, title: deck.deck.title })),
62
+ failed.map((entry) => ({ name: entry.name })),
63
+ );
64
+ }),
65
+ );
66
+ },
67
+ clear() {
68
+ pages.clear();
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,40 @@
1
+ import { generateRemotePassword } from "./lan.ts";
2
+
3
+ /** How long a pairing code shown as a QR code stays good, used or not. */
4
+ export const PAIRING_TTL_MS = 5 * 60 * 1000;
5
+
6
+ /**
7
+ * One-use codes that let a phone in as the presenter by scanning a QR code, without typing the
8
+ * password. A code works once and for a few minutes, so a photo of a projected terminal is worth
9
+ * nothing by the time anyone could use it; the password stays for any other device.
10
+ */
11
+ export type Pairings = {
12
+ /** A fresh code; the ones before it stay good until used or expired. */
13
+ issue(): string;
14
+ /** True, once, for a code that was issued and has not expired. */
15
+ redeem(code: string): boolean;
16
+ };
17
+
18
+ export function createPairings(options: { ttlMs?: number; now?: () => number } = {}): Pairings {
19
+ const ttlMs = options.ttlMs ?? PAIRING_TTL_MS;
20
+ const now = options.now ?? Date.now;
21
+ const open = new Map<string, number>();
22
+ return {
23
+ issue() {
24
+ // 16 letters of the password alphabet: 80 bits, for a code that lives five minutes.
25
+ const code = generateRemotePassword(16);
26
+ open.set(code, now() + ttlMs);
27
+ return code;
28
+ },
29
+ redeem(code) {
30
+ const expires = open.get(code);
31
+ open.delete(code);
32
+ for (const [other, at] of open) {
33
+ if (at <= now()) {
34
+ open.delete(other);
35
+ }
36
+ }
37
+ return expires !== undefined && expires > now();
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,142 @@
1
+ import { watch } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { LiveEvent } from "../core/live-protocol.ts";
4
+ import type { PlaywrightRunner } from "../core/playwright.ts";
5
+ import { type Project, resolveProject } from "../core/resolve.ts";
6
+ import { createEventHub, type DeckEvent, type EventHub } from "./hub.ts";
7
+ import { type Stoppable, watchDeck } from "./watch.ts";
8
+
9
+ export type ProjectWatch = {
10
+ /** The project as last read: re-read after a sync, so decks that come and go show. */
11
+ project(): Project;
12
+ /** Every event the decks' watchers emit, each with its deck. */
13
+ hub: EventHub;
14
+ /** Stop every watcher, then end the event stream. */
15
+ close(): void;
16
+ };
17
+
18
+ /**
19
+ * Watch the decks a server serves: the one it is scoped to, or every deck of the project and
20
+ * the decks directory for decks that come and go. `onChange` runs whenever what a page shows
21
+ * may have changed: after each event but diagnostics and the timeline, and when the deck set does.
22
+ */
23
+ export function createProjectWatch(options: {
24
+ root: string;
25
+ project: Project;
26
+ scopedDeckName?: string;
27
+ visual: boolean;
28
+ visualRunner?: PlaywrightRunner;
29
+ pollIntervalMs: number;
30
+ onChange: () => void;
31
+ }): ProjectWatch {
32
+ const { root, scopedDeckName } = options;
33
+ let project = options.project;
34
+ let stopped = false;
35
+ const hub = createEventHub();
36
+ const watchers = new Map<string, Stoppable>();
37
+ const watchOptions = {
38
+ visual: options.visual,
39
+ visualRunner: options.visualRunner,
40
+ pollIntervalMs: options.pollIntervalMs,
41
+ };
42
+
43
+ /** Bring the server up to date before any page hears the event and asks for what changed. */
44
+ const emit = (event: DeckEvent): void => {
45
+ if (event.type === "sync") {
46
+ refreshProject();
47
+ }
48
+ if (event.type !== "diagnostics" && event.type !== "timeline") {
49
+ options.onChange();
50
+ }
51
+ hub.emit(event);
52
+ };
53
+
54
+ const reconcileWatchers = (): void => {
55
+ const targets = scopedDeckName
56
+ ? new Map([[join(project.root, "decks", scopedDeckName), scopedDeckName]])
57
+ : watchTargets(project);
58
+ for (const [dir, deck] of targets) {
59
+ if (!watchers.has(dir)) {
60
+ const emitFor = (event: LiveEvent) => emit({ ...event, deck });
61
+ watchers.set(dir, watchDeck(dir, emitFor, watchOptions));
62
+ }
63
+ }
64
+ for (const [dir, watcher] of watchers) {
65
+ if (!targets.has(dir)) {
66
+ watcher.close();
67
+ watchers.delete(dir);
68
+ }
69
+ }
70
+ };
71
+
72
+ /** Re-read the project after decks come and go; the index lists the deck set. */
73
+ const refreshProject = (): void => {
74
+ // fs.watch can deliver an event after close; a watcher made then would outlive the server.
75
+ if (stopped) {
76
+ return;
77
+ }
78
+ try {
79
+ const fresh = resolveProject(root);
80
+ const changed = deckNames(fresh) !== deckNames(project);
81
+ project = fresh;
82
+ if (changed) {
83
+ options.onChange();
84
+ }
85
+ reconcileWatchers();
86
+ } catch {
87
+ // dek.toml may disappear mid-write; keep the last known project.
88
+ }
89
+ };
90
+
91
+ reconcileWatchers();
92
+ let decksDirWatcher: Stoppable | undefined;
93
+ if (!scopedDeckName) {
94
+ try {
95
+ const dirWatcher = watch(join(project.root, "decks"), { recursive: true }, refreshProject);
96
+ const timer = setInterval(refreshProject, options.pollIntervalMs);
97
+ decksDirWatcher = {
98
+ close() {
99
+ dirWatcher.close();
100
+ clearInterval(timer);
101
+ },
102
+ };
103
+ } catch {
104
+ decksDirWatcher = undefined;
105
+ }
106
+ }
107
+
108
+ return {
109
+ project: () => project,
110
+ hub,
111
+ close() {
112
+ stopped = true;
113
+ for (const watcher of watchers.values()) {
114
+ watcher.close();
115
+ }
116
+ decksDirWatcher?.close();
117
+ hub.close();
118
+ },
119
+ };
120
+ }
121
+
122
+ /** The decks to watch, parsed or not: each deck's name by its directory. */
123
+ export function watchTargets(project: {
124
+ decks: Array<{ name: string; dir: string }>;
125
+ failed: Array<{ name: string; dir: string }>;
126
+ }): Map<string, string> {
127
+ const targets = new Map<string, string>();
128
+ for (const { name, dir } of [...project.decks, ...project.failed]) {
129
+ if (!targets.has(dir)) {
130
+ targets.set(dir, name);
131
+ }
132
+ }
133
+ return targets;
134
+ }
135
+
136
+ /** Every deck name the index lists, parsed or not, as one comparable key. */
137
+ function deckNames(project: Project): string {
138
+ return [...project.decks, ...project.failed]
139
+ .map((entry) => entry.name)
140
+ .sort()
141
+ .join("\n");
142
+ }
@@ -0,0 +1,133 @@
1
+ import {
2
+ decodePointer,
3
+ decodePosition,
4
+ encodePointer,
5
+ encodePosition,
6
+ } from "../core/live-protocol.ts";
7
+ import { clampPosition, deckStops } from "../core/position.ts";
8
+ import type { ProjectDeck } from "../core/resolve.ts";
9
+ import type { Position } from "../core/step.ts";
10
+
11
+ /** A socket in a room, as far as a room needs one. */
12
+ export type RoomClient = { send(payload: string): unknown };
13
+
14
+ /**
15
+ * Where a deck stands, as `dekc current` and `dekc goto` print it, and how many pages show it. With
16
+ * none, the position is where the next page opened lands.
17
+ */
18
+ type RoomPosition = { slug: string; slideIndex: number; beatIndex: number; viewers: number };
19
+
20
+ export type Rooms<C extends RoomClient> = {
21
+ /** A socket joins its deck's room, and is told where the talk stands. */
22
+ join(room: string, client: C): void;
23
+ leave(room: string, client: C): void;
24
+ /**
25
+ * What a presenter sends, relayed to everyone else in the room: a move, kept within the deck and
26
+ * as where the room stands; or where the laser points, kept nowhere, since a page that joins
27
+ * later must not see a laser that pointed before it came.
28
+ */
29
+ relay(room: string, from: C, payload: string): void;
30
+ /** Send the room to a slide; the reason when the deck or slide is not there. */
31
+ goto(room: string, slug: string): { ok: true; at: RoomPosition } | { ok: false; message: string };
32
+ current(room: string): RoomPosition;
33
+ /** After the script changed: a kept position past a slide or beat now gone is pulled back. */
34
+ reclamp(): void;
35
+ };
36
+
37
+ /**
38
+ * The presenter rooms, one per deck: who is connected, and the position each deck is at.
39
+ * `decks` is read on each call, so a room follows the script as it is now.
40
+ */
41
+ export function createRooms<C extends RoomClient>(decks: () => ProjectDeck[]): Rooms<C> {
42
+ const rooms = new Map<string, Set<C>>();
43
+ const positions = new Map<string, Position>();
44
+
45
+ const deckFor = (room: string): ProjectDeck | undefined =>
46
+ decks().find((entry) => entry.name === room);
47
+
48
+ /** `pos` within the room's deck; kept as sent while the deck fails to parse. */
49
+ const within = (room: string, pos: Position): Position | undefined => {
50
+ const deck = deckFor(room);
51
+ return deck ? clampPosition(pos, deckStops(deck.deck.sections)) : pos;
52
+ };
53
+
54
+ const broadcast = (room: string, payload: string, except?: C): void => {
55
+ for (const client of rooms.get(room) ?? []) {
56
+ if (client !== except) {
57
+ client.send(payload);
58
+ }
59
+ }
60
+ };
61
+
62
+ const current = (room: string): RoomPosition => {
63
+ const deck = deckFor(room);
64
+ const viewers = rooms.get(room)?.size ?? 0;
65
+ const pos = positions.get(room) ?? { slideIndex: 0, beatIndex: 0 };
66
+ const slug = deck?.deck.sections[pos.slideIndex]?.slug;
67
+ if (!slug) {
68
+ return { slug: deck?.deck.sections[0]?.slug ?? room, slideIndex: 0, beatIndex: 0, viewers };
69
+ }
70
+ return { slug, slideIndex: pos.slideIndex, beatIndex: pos.beatIndex, viewers };
71
+ };
72
+
73
+ return {
74
+ join(room, client) {
75
+ const clients = rooms.get(room) ?? new Set();
76
+ clients.add(client);
77
+ rooms.set(room, clients);
78
+ const pos = positions.get(room);
79
+ if (pos) {
80
+ client.send(encodePosition(pos));
81
+ }
82
+ },
83
+ leave(room, client) {
84
+ const clients = rooms.get(room);
85
+ if (!clients) {
86
+ return;
87
+ }
88
+ clients.delete(client);
89
+ if (clients.size === 0) {
90
+ rooms.delete(room);
91
+ }
92
+ },
93
+ relay(room, from, payload) {
94
+ const laser = decodePointer(payload);
95
+ if (laser) {
96
+ broadcast(room, encodePointer(laser.pointer), from);
97
+ return;
98
+ }
99
+ const sent = decodePosition(payload);
100
+ const pos = sent && within(room, sent);
101
+ if (!pos) {
102
+ return;
103
+ }
104
+ positions.set(room, pos);
105
+ broadcast(room, encodePosition(pos), from);
106
+ },
107
+ goto(room, slug) {
108
+ const deck = deckFor(room);
109
+ if (!deck) {
110
+ return { ok: false, message: `deck "${room}" not found` };
111
+ }
112
+ const slideIndex = deck.deck.sections.findIndex((section) => section.slug === slug);
113
+ if (slideIndex < 0) {
114
+ return { ok: false, message: `section "${slug}" not found` };
115
+ }
116
+ const pos = { slideIndex, beatIndex: 0 };
117
+ positions.set(room, pos);
118
+ broadcast(room, encodePosition(pos));
119
+ return { ok: true, at: current(room) };
120
+ },
121
+ current,
122
+ reclamp() {
123
+ for (const [room, pos] of positions) {
124
+ const kept = within(room, pos);
125
+ if (kept) {
126
+ positions.set(room, kept);
127
+ } else {
128
+ positions.delete(room);
129
+ }
130
+ }
131
+ },
132
+ };
133
+ }