@hajimism/dek 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  Release notes for `@hajimism/dek`. From 0.1.0 on, release-please writes this file from the Conventional Commits on `main`.
4
4
 
5
+ ## [0.3.1](https://github.com/hajimism/dek/compare/v0.3.0...v0.3.1) (2026-10-08)
6
+
7
+
8
+ ### Performance
9
+
10
+ * **build:** leave the dev server's line out of built files ([1e74ba0](https://github.com/hajimism/dek/commit/1e74ba0fb2512eeecf8ca4ba13a747374b1a651c))
11
+ * **build:** leave the dev server's line out of built files ([93bb4fc](https://github.com/hajimism/dek/commit/93bb4fc56105940cc5b19dc86f890dc9246df850))
12
+
13
+ ## [0.3.0](https://github.com/hajimism/dek/compare/v0.2.2...v0.3.0) (2026-10-07)
14
+
15
+
16
+ ### ⚠ BREAKING CHANGES
17
+
18
+ * a DEK_ variable kept in a project's .env, such as DEK_VOICE_URL, no longer applies; set it in the shell, or use `engine` in voice.toml.
19
+ * reading a private ref needs GITHUB_TOKEN or GH_TOKEN; being signed in to gh is not enough, and DEK_GH is gone. `GITHUB_TOKEN=$(gh auth token) dekc ref ...` does what dek did before. No token is sent to DEK_GITHUB_API.
20
+
21
+ ### Bug Fixes
22
+
23
+ * draw pages in Chromium's sandbox with the network off ([550cc3a](https://github.com/hajimism/dek/commit/550cc3ab1c44005d4ef89420d3bb92a44a7da3a9))
24
+ * ignore what the .env.*.local files set, and NODE_TLS_REJECT_UNAUTHORIZED from any .env ([da02b7c](https://github.com/hajimism/dek/commit/da02b7c4375b41ddd9ea2d879714950e5017cbc2))
25
+ * keep a ref's title to one line of AGENTS.md ([c1b0b60](https://github.com/hajimism/dek/commit/c1b0b609a8710985c32988cff19b8cb65a0e6bc6))
26
+ * run dekc, and every bun it starts, without the working directory's bunfig.toml ([0418909](https://github.com/hajimism/dek/commit/04189098b6975ba37506f039e865388205c5817b))
27
+ * ship tsconfig.json, so a project's paths cannot take over dek's imports ([3eed8f0](https://github.com/hajimism/dek/commit/3eed8f0961a232a48be1cb06cd08dc03e121d026))
28
+ * stop asking gh for a token, and send one only to api.github.com ([62c896b](https://github.com/hajimism/dek/commit/62c896ba1b69424e5426534040a087a2d42bef1a))
29
+ * take DEK_ variables only from the environment, never from a .env ([7f4629b](https://github.com/hajimism/dek/commit/7f4629b3099e23a4c46b6261fa95627659fbfe51))
30
+
31
+
32
+ ### Documentation
33
+
34
+ * **adr:** number the GHSA-mj6q-67q7-q923 decision after the ones on main ([4a888b0](https://github.com/hajimism/dek/commit/4a888b06594c188bf0efa88fb9111f48f70263e9))
35
+ * **adr:** take GitHub tokens and DEK_ variables only from what the user sets ([c07fa4c](https://github.com/hajimism/dek/commit/c07fa4ceb7e1df69fb184ee8955f1819ee58a069))
36
+ * record the ref title and shot fixes in ADR 12 and the architecture guide ([a6bfb93](https://github.com/hajimism/dek/commit/a6bfb9383c9c614c5c39f0e2f6817db24dc83e06))
37
+ * say what a project cannot set for the Bun that runs dek, and what stays out of scope ([632efbb](https://github.com/hajimism/dek/commit/632efbbe4697a2175bf9cd0c8b5db77e53dbfa17))
38
+
5
39
  ## [0.2.2](https://github.com/hajimism/dek/compare/v0.2.1...v0.2.2) (2026-10-06)
6
40
 
7
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hajimism/dek",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "A build system for talks.",
5
5
  "license": "MIT",
6
6
  "author": "Hajime Nakagawa (https://github.com/hajimism)",
@@ -11,6 +11,7 @@
11
11
  "files": [
12
12
  "src",
13
13
  "skills",
14
+ "tsconfig.json",
14
15
  "LICENSE",
15
16
  "README.md",
16
17
  "README.ja.md",
@@ -0,0 +1,78 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ansi, shouldColor, terminalSafe } from "./tty.ts";
4
+
5
+ /**
6
+ * The files Bun may read into process.env from the working directory before dek starts: `.env`,
7
+ * `.env.local`, and the two for NODE_ENV, plain and `.local`. All of them are read whatever
8
+ * NODE_ENV is, so a change in which ones Bun picks cannot let a variable through.
9
+ */
10
+ const ENV_FILES = [
11
+ ".env",
12
+ ".env.local",
13
+ ...["development", "production", "test"].flatMap((mode) => [
14
+ `.env.${mode}`,
15
+ `.env.${mode}.local`,
16
+ ]),
17
+ ];
18
+
19
+ /**
20
+ * Every name dek takes only from the environment that a file holds, however the line around it is
21
+ * written: `K=v`, `export K=v`, `K: v`. Besides DEK_ variables, NODE_TLS_REJECT_UNAUTHORIZED: Bun
22
+ * reads it on each request, and with it a proxy that a `.env` named could read the token `dekc ref`
23
+ * sends. Other variables Bun acts on, such as HTTPS_PROXY, take effect before dek starts, and
24
+ * deleting them changes nothing.
25
+ */
26
+ const NAME_RE = /DEK_[A-Z0-9_]*|NODE_TLS_REJECT_UNAUTHORIZED/gi;
27
+
28
+ /**
29
+ * Takes out of `env` every variable in NAME_RE that a `.env` file in `cwd` may have set, and
30
+ * returns their names. Bun loads those files before dek runs, so a repository someone else wrote
31
+ * could otherwise choose the programs dek runs and the host `dekc ref` calls. These variables come
32
+ * only from the environment dek was started in. Under `--no-env-file`, which the dekc bin passes,
33
+ * Bun loaded nothing, so every one there is the user's own.
34
+ */
35
+ export function dropEnvFileVariables(
36
+ cwd: string,
37
+ env: NodeJS.ProcessEnv = process.env,
38
+ execArgv: readonly string[] = process.execArgv,
39
+ ): string[] {
40
+ if (execArgv.includes("--no-env-file")) {
41
+ return [];
42
+ }
43
+ const names = new Set<string>();
44
+ for (const file of ENV_FILES) {
45
+ let text: string;
46
+ try {
47
+ text = readFileSync(join(cwd, file), "utf8");
48
+ } catch {
49
+ continue;
50
+ }
51
+ for (const [name] of text.matchAll(NAME_RE)) {
52
+ names.add(name.toUpperCase());
53
+ }
54
+ }
55
+ const dropped = [...names].filter((name) => env[name] !== undefined).sort();
56
+ for (const name of dropped) {
57
+ delete env[name];
58
+ }
59
+ return dropped;
60
+ }
61
+
62
+ /** What dek says on stderr about the variables `dropEnvFileVariables` took out. */
63
+ export function envFileWarning(names: readonly string[], color = false): string {
64
+ const c = ansi(color);
65
+ const list = names.join(", ");
66
+ return [
67
+ `${c.yellow("warning:")} ignored ${list}: a .env file in this directory sets it, and dek takes it only from the environment`,
68
+ ` ${c.yellow("help:")} set it in the shell that runs dekc instead`,
69
+ ].join("\n");
70
+ }
71
+
72
+ /** The bin's first step: drop what a `.env` set, and say so. */
73
+ export function ignoreEnvFileVariables(cwd: string): void {
74
+ const dropped = dropEnvFileVariables(cwd);
75
+ if (dropped.length > 0) {
76
+ process.stderr.write(`${terminalSafe(envFileWarning(dropped, shouldColor(process.stderr)))}\n`);
77
+ }
78
+ }
package/src/cli.ts CHANGED
@@ -1,4 +1,8 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env -S bun --no-env-file --config=/dev/null
2
+ import { ignoreEnvFileVariables } from "./cli/env-file.ts";
2
3
  import { main } from "./cli/main.ts";
3
4
 
5
+ // `bun ./node_modules/.bin/dekc` skips the line above and loads `.env` anyway. It reads
6
+ // `bunfig.toml` too, and nothing here can undo a preload that has already run.
7
+ ignoreEnvFileVariables(process.cwd());
4
8
  await main(Bun.argv.slice(2), process.cwd());
@@ -142,7 +142,6 @@ export function renderDeckDocument(
142
142
  bodyAttrs: `${presenterOpen ? ' class="is-presenter"' : ""}${pageConfigAttrs({
143
143
  mode,
144
144
  deck: deck.name,
145
- live,
146
145
  ...(liveToken ? { liveToken } : {}),
147
146
  })}`,
148
147
  body: `${progress}
@@ -3,7 +3,7 @@ import { DekError } from "./error.ts";
3
3
  /**
4
4
  * The two GitHub calls dekc ref needs: pin a rev to a commit, and download
5
5
  * that commit. DEK_GITHUB_API points them elsewhere, the way DEK_VOICE_URL
6
- * does for the voice engine.
6
+ * does for the voice engine, but a token only ever goes here.
7
7
  */
8
8
  const DEFAULT_API = "https://api.github.com";
9
9
 
@@ -16,9 +16,6 @@ const COMMIT_TIMEOUT_MS = 30 * 1000;
16
16
  /** A tarball near the size cap on a slow line still fits well inside this. */
17
17
  const TARBALL_TIMEOUT_MS = 10 * 60 * 1000;
18
18
 
19
- const SIGN_IN_HINT =
20
- "if the repository is private, set GITHUB_TOKEN or run `gh auth login`, then run the command again";
21
-
22
19
  export type GithubOptions = {
23
20
  /** Covers the whole call, from connecting to the last byte of the body. */
24
21
  timeoutMs?: number;
@@ -32,22 +29,27 @@ function apiBase(): string {
32
29
  return (process.env.DEK_GITHUB_API ?? DEFAULT_API).replace(/\/+$/, "");
33
30
  }
34
31
 
35
- /** GITHUB_TOKEN, else what `gh auth token` prints, else nothing: public repositories need no token. */
36
- function token(): string | undefined {
37
- const fromEnv = process.env.GITHUB_TOKEN?.trim();
38
- if (fromEnv) {
39
- return fromEnv;
40
- }
41
- try {
42
- const result = Bun.spawnSync([process.env.DEK_GH ?? "gh", "auth", "token"], {
43
- stdout: "pipe",
44
- stderr: "ignore",
45
- });
46
- const printed = result.exitCode === 0 ? result.stdout.toString().trim() : "";
47
- return printed || undefined;
48
- } catch {
32
+ /**
33
+ * The Authorization header for a call to `base`: the token the user set in GITHUB_TOKEN, else
34
+ * GH_TOKEN, and only for api.github.com. Public repositories need none. dek never asks `gh` for
35
+ * one: that token can write to every repository its owner can, and a tool that takes it unasked
36
+ * reads as credential theft to the machine's security software.
37
+ */
38
+ export function authorization(
39
+ base: string,
40
+ env: NodeJS.ProcessEnv = process.env,
41
+ ): string | undefined {
42
+ if (base !== DEFAULT_API) {
49
43
  return undefined;
50
44
  }
45
+ const token = env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim();
46
+ return token ? `Bearer ${token}` : undefined;
47
+ }
48
+
49
+ function signInHint(base: string): string {
50
+ return base === DEFAULT_API
51
+ ? "if the repository is private, set GITHUB_TOKEN to a token that can read it (`gh auth token` prints yours), then run the command again"
52
+ : "DEK_GITHUB_API points dek away from api.github.com, and a token goes only there; unset it to read a private repository";
51
53
  }
52
54
 
53
55
  /**
@@ -78,7 +80,7 @@ function deadline(what: string, timeoutMs: number): Deadline {
78
80
 
79
81
  function rateLimitHint(retryAfter: string | null): string {
80
82
  const wait = retryAfter ? `wait ${retryAfter}s, and ` : "";
81
- return `${wait}set GITHUB_TOKEN or run \`gh auth login\` to raise the limit, then run the command again`;
83
+ return `${wait}set GITHUB_TOKEN to raise the limit, then run the command again`;
82
84
  }
83
85
 
84
86
  async function request(
@@ -87,14 +89,15 @@ async function request(
87
89
  accept: string,
88
90
  { signal, timeout, expired }: Deadline,
89
91
  ): Promise<Response> {
90
- const auth = token();
92
+ const base = apiBase();
93
+ const auth = authorization(base);
91
94
  let response: Response;
92
95
  try {
93
- response = await fetch(`${apiBase()}${path}`, {
96
+ response = await fetch(`${base}${path}`, {
94
97
  headers: {
95
98
  accept,
96
99
  "user-agent": "dek",
97
- ...(auth ? { authorization: `Bearer ${auth}` } : {}),
100
+ ...(auth ? { authorization: auth } : {}),
98
101
  },
99
102
  redirect: "follow",
100
103
  signal,
@@ -112,7 +115,7 @@ async function request(
112
115
  return response;
113
116
  }
114
117
  if (response.status === 404) {
115
- throw new DekError(`${what} not found on GitHub`, { hint: SIGN_IN_HINT });
118
+ throw new DekError(`${what} not found on GitHub`, { hint: signInHint(base) });
116
119
  }
117
120
  const retryAfter = response.headers.get("retry-after");
118
121
  if (
@@ -125,7 +128,7 @@ async function request(
125
128
  }
126
129
  if (response.status === 401 || response.status === 403) {
127
130
  throw new DekError(`GitHub refused access to ${what} (${response.status})`, {
128
- hint: SIGN_IN_HINT,
131
+ hint: signInHint(base),
129
132
  });
130
133
  }
131
134
  throw new DekError(`GitHub answered ${response.status} for ${what}`, {
package/src/core/page.ts CHANGED
@@ -45,8 +45,6 @@ export type PageMode = "player" | "presenter" | "video";
45
45
  export type PageConfig = {
46
46
  mode: PageMode;
47
47
  deck: string;
48
- /** The dev server's page: it follows the server's socket and events. */
49
- live: boolean;
50
48
  /** What a presenter page sends as `?token=` on its live channels. */
51
49
  liveToken?: string;
52
50
  };
@@ -56,18 +54,16 @@ export function pageConfigAttrs(config: PageConfig): string {
56
54
  return [
57
55
  ` data-mode="${config.mode}"`,
58
56
  ` data-deck="${escapeAttr(config.deck)}"`,
59
- config.live ? ` data-live="true"` : "",
60
57
  config.liveToken ? ` data-live-token="${escapeAttr(config.liveToken)}"` : "",
61
58
  ].join("");
62
59
  }
63
60
 
64
61
  /** The settings `pageConfigAttrs` wrote on `body`. */
65
62
  export function readPageConfig(body: HTMLElement): PageConfig {
66
- const { mode, deck, live, liveToken } = body.dataset;
63
+ const { mode, deck, liveToken } = body.dataset;
67
64
  return {
68
65
  mode: mode === "presenter" || mode === "video" ? mode : "player",
69
66
  deck: deck ?? "",
70
- live: live === "true",
71
67
  ...(liveToken ? { liveToken } : {}),
72
68
  };
73
69
  }
@@ -33,14 +33,15 @@ declare module "playwright" {
33
33
  close(): Promise<void>;
34
34
  };
35
35
  export type Browser = {
36
- newPage(options?: { viewport?: Viewport }): Promise<Page>;
36
+ newPage(options?: { viewport?: Viewport; offline?: boolean }): Promise<Page>;
37
37
  newContext(options?: {
38
38
  viewport?: Viewport;
39
39
  deviceScaleFactor?: number;
40
+ offline?: boolean;
40
41
  }): Promise<BrowserContext>;
41
42
  close(): Promise<void>;
42
43
  };
43
44
  export const chromium: {
44
- launch(options?: { headless?: boolean }): Promise<Browser>;
45
+ launch(options?: { headless?: boolean; chromiumSandbox?: boolean }): Promise<Browser>;
45
46
  };
46
47
  }
@@ -50,9 +50,11 @@ export async function runVisualRequest<R extends VisualRequest>(
50
50
  browser: Browser,
51
51
  request: R,
52
52
  ): Promise<ResponseTo<R>> {
53
- // A PPTX's pictures are shown full screen, so they are drawn at twice the slide's size.
53
+ // A PPTX's pictures are shown full screen, so they are drawn at twice the slide's size. A deck is
54
+ // self-contained (DEK020), so the page needs no network, and a ref's slide scripts get none.
54
55
  const context = await browser.newContext({
55
56
  viewport: request.viewport,
57
+ offline: true,
56
58
  ...(request.kind === "pptx" ? { deviceScaleFactor: 2 } : {}),
57
59
  });
58
60
  try {
@@ -260,7 +260,7 @@ export async function runPlaywrightWorker<Request, Response>(
260
260
  }
261
261
  const request = await readWorkerRequest<Request>();
262
262
  try {
263
- const browser = await playwright.chromium.launch({ headless: true });
263
+ const browser = await launchChromium(playwright.chromium);
264
264
  try {
265
265
  const response = await handle(browser, request);
266
266
  process.stdout.write(`${JSON.stringify(response)}\n`);
@@ -272,6 +272,19 @@ export async function runPlaywrightWorker<Request, Response>(
272
272
  }
273
273
  }
274
274
 
275
+ /**
276
+ * Headless Chromium with its sandbox, which Playwright leaves off by default: slide scripts run in
277
+ * it, a ref's included. Where the sandbox cannot start, as in a container running as root, it
278
+ * runs without one rather than not at all.
279
+ */
280
+ async function launchChromium(chromium: typeof import("playwright").chromium): Promise<Browser> {
281
+ try {
282
+ return await chromium.launch({ headless: true, chromiumSandbox: true });
283
+ } catch {
284
+ return await chromium.launch({ headless: true });
285
+ }
286
+ }
287
+
275
288
  export async function defaultPlaywrightRunner(
276
289
  request: VisualRequest,
277
290
  options: SpawnTimeoutOptions = {},
@@ -7,6 +7,7 @@ import {
7
7
  type SlideScriptProblem,
8
8
  staticProblems,
9
9
  } from "./slide-script.ts";
10
+ import { BUN_FLAGS } from "./spawn.ts";
10
11
  import { suggest } from "./suggest.ts";
11
12
 
12
13
  /** One slide script as lint checks it: its source, and the slide's step keys to check `motion` by. */
@@ -149,7 +150,7 @@ const EVALUATOR_OPTIONS = { stdout: "pipe", stderr: "ignore", env: {} } as const
149
150
 
150
151
  function evaluatorCommand(): string[] {
151
152
  const worker = moduleFilePath(new URL("./slide-eval-worker.ts", import.meta.url));
152
- return [process.execPath, "--no-install", worker];
153
+ return [process.execPath, ...BUN_FLAGS, worker];
153
154
  }
154
155
 
155
156
  /** A worker that was killed still wrote a line for every script it finished. */
package/src/core/spawn.ts CHANGED
@@ -13,9 +13,16 @@ export async function awaitPiped(proc: {
13
13
  return { stdout, stderr, exitCode };
14
14
  }
15
15
 
16
+ /**
17
+ * How dek runs bun: with no auto install, and without the working directory's `.env` or
18
+ * `bunfig.toml`, which a repository someone else wrote can commit to set variables or preload code.
19
+ * The dekc bin's shebang passes the last two to dek itself; a child does not inherit them.
20
+ */
21
+ export const BUN_FLAGS = ["--no-install", "--no-env-file", "--config=/dev/null"];
22
+
16
23
  /** `bin` as a command line: a .ts worker runs under bun, anything else as is. */
17
24
  export function workerCommand(bin: string, args: string[] = []): string[] {
18
- return bin.endsWith(".ts") ? ["bun", "--no-install", bin, ...args] : [bin, ...args];
25
+ return bin.endsWith(".ts") ? ["bun", ...BUN_FLAGS, bin, ...args] : [bin, ...args];
19
26
  }
20
27
 
21
28
  /** A browser that hangs must not hang dek; a whole visual run or capture fits well inside this. */
package/src/core/sync.ts CHANGED
@@ -293,6 +293,25 @@ For commands, run \`dekc help --agent\`.
293
293
  `;
294
294
  }
295
295
 
296
+ /** The longest ref title AGENTS.md shows: a title names a deck, it does not describe one. */
297
+ const REF_TITLE_MAX = 80;
298
+
299
+ /**
300
+ * A ref's title as it goes into AGENTS.md. Someone else wrote it, and agents read dek's block as
301
+ * dek's own guidance, so a title must not start a line of its own there, such as a heading or an
302
+ * instruction: line breaks and control characters become one space, and it is cut to
303
+ * REF_TITLE_MAX characters.
304
+ */
305
+ function refTitleLine(title: string | undefined): string | undefined {
306
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point.
307
+ const line = title?.replace(/[\s\x00-\x1f\x7f-\x9f]+/gu, " ").trim();
308
+ if (!line) {
309
+ return undefined;
310
+ }
311
+ const chars = [...line];
312
+ return chars.length > REF_TITLE_MAX ? `${chars.slice(0, REF_TITLE_MAX - 1).join("")}…` : line;
313
+ }
314
+
296
315
  /**
297
316
  * The refs dek.toml pins, one line each, so an agent knows which decks it may
298
317
  * read as models. Empty when there are none, leaving AGENTS.md as before. A
@@ -311,7 +330,7 @@ function referencesSection(root: string): string {
311
330
  return "";
312
331
  }
313
332
  const lines = refs.map((name) => {
314
- const title = refTitle(refDir(root, name), parseRefSource(name).deck)?.title;
333
+ const title = refTitleLine(refTitle(refDir(root, name), parseRefSource(name).deck)?.title);
315
334
  return title ? `- \`${name}\`: ${title}` : `- \`${name}\``;
316
335
  });
317
336
  return `
@@ -0,0 +1,6 @@
1
+ // The player of the dev server's pages: the same player, with its line to the server.
2
+
3
+ import { createDevLine } from "./dev-line.ts";
4
+ import { startPlayer } from "./start.ts";
5
+
6
+ startPlayer(createDevLine);
@@ -1,207 +1,5 @@
1
- /// <reference lib="dom" />
1
+ // The player of a file that stands alone: a build, a video, a shot. It has no dev server to reach.
2
2
 
3
- import { isLiveEvent } from "../core/live-protocol.ts";
4
- import { ANNOTATIONS_CHANGED, PAGE_ID, readPageConfig } from "../core/page.ts";
5
- import { deckStops, moveTarget, positionsEqual } from "../core/position.ts";
6
- import type { PresenterSlide } from "../core/presenter-state.ts";
7
- import type { Position } from "../core/step.ts";
8
- import { createDeckControl } from "./deck-control.ts";
9
- import { bindInput } from "./input.ts";
10
- import { createLaserView, type LaserView } from "./laser-view.ts";
11
- import { applyLiveEvent, hydrateLiveEvent } from "./live.ts";
12
- import { documentLiveHost } from "./live-host.ts";
13
- import { createMarksView } from "./marks-view.ts";
14
- import { createNavigator } from "./navigator.ts";
15
- import { createPresenterView } from "./presenter-view.ts";
16
- import { createRailView } from "./rail-view.ts";
17
- import { createRehearseController, fetchRehearsal } from "./rehearse.ts";
18
- import { deckChannelName, deckUrl, liveTokenQuery } from "./routes.ts";
19
- import { createStage } from "./stage.ts";
3
+ import { startPlayer } from "./start.ts";
20
4
 
21
- const dataEl = document.getElementById(PAGE_ID.data);
22
- if (dataEl?.textContent) {
23
- const page = readPageConfig(document.body);
24
- const slides = JSON.parse(dataEl.textContent) as PresenterSlide[];
25
- const deck = deckStops(slides);
26
- const stage = createStage({ slides, deck, videoMode: page.mode === "video" });
27
- const presenter = createPresenterView({ slides, deck, stage, mode: page.mode });
28
-
29
- function fitAll(): void {
30
- stage.fit();
31
- presenter.fit();
32
- rail.fit();
33
- }
34
-
35
- /** Draw every view at `pos`. */
36
- function render(pos: Position): void {
37
- stage.render(pos);
38
- presenter.render(pos);
39
- rail.markCurrent(pos.slideIndex);
40
- marks?.render(pos);
41
- }
42
-
43
- /**
44
- * The first key or move takes the key hint away. Removing it also drops its animation, which
45
- * the first move would otherwise wait on before it settles.
46
- */
47
- function dismissKeyHint(): void {
48
- document.getElementById(PAGE_ID.hint)?.remove();
49
- }
50
-
51
- const rehearse = createRehearseController({
52
- load: () =>
53
- fetchRehearsal(location.pathname, {
54
- fetch: (input, init) => fetch(input, init),
55
- audio: (src) => new Audio(src),
56
- }),
57
- go: (position, origin) => nav.go(position, origin),
58
- current: () => nav.target(),
59
- });
60
- const control = createDeckControl({ go: (next, origin) => nav.go(next, origin), rehearse });
61
-
62
- // A video shows the talk as narrated; nobody points at it.
63
- const laser: LaserView | undefined =
64
- page.mode === "video"
65
- ? undefined
66
- : createLaserView({
67
- current: () => nav.position().slideIndex,
68
- send: (pointer) => nav.point(pointer),
69
- });
70
-
71
- // Only the dev server has a socket to follow; a built file on a static host must not dial one.
72
- const socketUrl = page.live
73
- ? `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}${deckUrl(location.pathname, { kind: "socket" })}${liveTokenQuery(page.liveToken)}`
74
- : undefined;
75
- const nav = createNavigator({
76
- deck,
77
- channelName: deckChannelName(
78
- page.deck,
79
- deck.map((slide) => slide.slug),
80
- ),
81
- ...(socketUrl ? { socketUrl } : {}),
82
- present: stage.present,
83
- hurry: stage.hurry,
84
- beforeMove: dismissKeyHint,
85
- onMove: (from, to) => {
86
- render(to);
87
- if (from.slideIndex !== to.slideIndex) {
88
- stage.announce(to);
89
- laser?.sync();
90
- }
91
- // A hashchange echoing this same position must not cut a running animation short.
92
- if (!positionsEqual(from, to)) {
93
- stage.showMove(from, to);
94
- }
95
- },
96
- afterMove: presenter.startClock,
97
- route: (next) => control.request(next, "remote"),
98
- ...(laser ? { onPointer: laser.receive } : {}),
99
- });
100
-
101
- const marks = createMarksView({
102
- url: `${deckUrl(location.pathname, { kind: "marks" })}${liveTokenQuery(page.liveToken)}`,
103
- current: () => nav.position(),
104
- fetch: (input, init) => fetch(input, init),
105
- });
106
-
107
- const rail = createRailView({
108
- slides,
109
- stage,
110
- onPick: (slideIndex) => control.request({ slideIndex, beatIndex: 0 }, "local"),
111
- onResize: fitAll,
112
- });
113
-
114
- function setPresenterOpen(open: boolean): void {
115
- presenter.setOpen(open);
116
- fitAll();
117
- render(nav.position());
118
- }
119
-
120
- bindInput({
121
- onKey: dismissKeyHint,
122
- togglePresenter: () => {
123
- if (!presenter.available()) {
124
- return false;
125
- }
126
- setPresenterOpen(!presenter.isOpen());
127
- return true;
128
- },
129
- toggleFullscreen: () => {
130
- if (document.fullscreenElement) {
131
- void document.exitFullscreen?.().catch(() => undefined);
132
- } else {
133
- void document.documentElement.requestFullscreen?.().catch(() => undefined);
134
- }
135
- },
136
- toggleRail: () => {
137
- if (!rail.available() || presenter.isOpen()) {
138
- return false;
139
- }
140
- rail.setOpen(!rail.isOpen());
141
- return true;
142
- },
143
- togglePlay: control.togglePlay,
144
- toggleLaser: () => laser?.toggle() ?? false,
145
- toggleMark: () => marks?.toggle() ?? false,
146
- pointing: () => laser?.isOn() ?? false,
147
- move: (move) => control.request(moveTarget(move, nav.target(), deck), "local"),
148
- });
149
-
150
- window.addEventListener("resize", fitAll);
151
- window.addEventListener("beforeprint", () => stage.setPrinting(true, nav.position()));
152
- window.addEventListener("afterprint", () => stage.setPrinting(false, nav.position()));
153
- matchMedia("print").addEventListener?.("change", (event) =>
154
- stage.setPrinting(event.matches, nav.position()),
155
- );
156
-
157
- rail.fill();
158
- fitAll();
159
- render(nav.position());
160
- // A hash that named a beat past the slide's last now says where the deck opened.
161
- nav.writeHash("replace");
162
- stage.showMotion(nav.position(), page.mode === "video" ? "hold" : "final");
163
- // biome-ignore lint/complexity/useLiteralKeys: video recorder looks up window["dekGo"]
164
- window["dekGo"] = (next) => nav.go(next, "capture");
165
- // The video recorder seeks slide scripts the way it seeks Web Animations.
166
- // biome-ignore lint/complexity/useLiteralKeys: video recorder looks up window["dekMotion"]
167
- window["dekMotion"] = stage.motion;
168
-
169
- const rehearseMode = new URLSearchParams(location.search).has("rehearse");
170
- if (rehearseMode) {
171
- void rehearse.load();
172
- }
173
-
174
- const liveHost = documentLiveHost();
175
- // Keep a string key so minify does not rename the hook liveReloadScript calls.
176
- // biome-ignore lint/complexity/useLiteralKeys: liveReloadScript looks up window["dekLive"]
177
- window["dekLive"] = async (raw: unknown) => {
178
- if (!isLiveEvent(raw)) {
179
- location.reload();
180
- return;
181
- }
182
- if (raw.type === "timeline") {
183
- if (rehearseMode) {
184
- await rehearse.load();
185
- }
186
- return;
187
- }
188
- if (raw.type === "annotations") {
189
- document.dispatchEvent(new Event(ANNOTATIONS_CHANGED));
190
- return;
191
- }
192
- const event = await hydrateLiveEvent(raw, location.pathname);
193
- if (!event) {
194
- location.reload();
195
- return;
196
- }
197
- if (applyLiveEvent(event, liveHost).reload) {
198
- location.reload();
199
- return;
200
- }
201
- // Whatever changed, drawing the beat again puts it back as it stood.
202
- const pos = nav.position();
203
- rail.fill();
204
- render(pos);
205
- stage.showMotion(pos, "final");
206
- };
207
- }
5
+ startPlayer();
@@ -18,7 +18,8 @@ export type DeckControl = {
18
18
  */
19
19
  export function createDeckControl(options: {
20
20
  go: (next: Position, origin: RequestOrigin) => Promise<void>;
21
- rehearse: Pick<RehearseController, "ready" | "move" | "toggle">;
21
+ /** The rehearsal the dev server can load; a file that stands alone has none. */
22
+ rehearse?: Pick<RehearseController, "ready" | "move" | "toggle">;
22
23
  }): DeckControl {
23
24
  const { go, rehearse } = options;
24
25
  return {
@@ -26,14 +27,14 @@ export function createDeckControl(options: {
26
27
  if (!next) {
27
28
  return;
28
29
  }
29
- if (rehearse.ready()) {
30
+ if (rehearse?.ready()) {
30
31
  rehearse.move(next, origin);
31
32
  } else {
32
33
  void go(next, origin);
33
34
  }
34
35
  },
35
36
  togglePlay() {
36
- if (!rehearse.ready()) {
37
+ if (!rehearse?.ready()) {
37
38
  return false;
38
39
  }
39
40
  rehearse.toggle();
@@ -0,0 +1,110 @@
1
+ /// <reference lib="dom" />
2
+
3
+ import { isLiveEvent } from "../core/live-protocol.ts";
4
+ import { ANNOTATIONS_CHANGED, type PageConfig } from "../core/page.ts";
5
+ import type { Position } from "../core/step.ts";
6
+ import { applyLiveEvent, hydrateLiveEvent } from "./live.ts";
7
+ import { documentLiveHost } from "./live-host.ts";
8
+ import { createMarksView, type MarksView } from "./marks-view.ts";
9
+ import type { GoOrigin } from "./navigator.ts";
10
+ import { createRehearseController, fetchRehearsal, type RehearseController } from "./rehearse.ts";
11
+ import { deckUrl, liveTokenQuery } from "./routes.ts";
12
+ import { createPositionSocket, type PositionHandlers, type PositionSocket } from "./socket.ts";
13
+
14
+ /**
15
+ * What the page has of the dev server: the position socket, the marks, the rehearsal, and the
16
+ * live updates. Only the player the dev server embeds is built with it; a file that stands alone
17
+ * has no server to reach, so its player never imports this module.
18
+ */
19
+ export type DevLine = {
20
+ /** Open the position socket, for the navigator. */
21
+ connect: (handlers: PositionHandlers) => PositionSocket;
22
+ rehearse: RehearseController;
23
+ /** Only the presenter bar has the mark button. */
24
+ marks: MarksView | undefined;
25
+ /**
26
+ * Load the rehearsal the URL asks for, and take the server's live updates from here on.
27
+ * `redraw` puts the beat back as it stood once one has changed the page.
28
+ */
29
+ start(redraw: () => void): void;
30
+ };
31
+
32
+ export type CreateDevLine = typeof createDevLine;
33
+
34
+ export function createDevLine(options: {
35
+ page: PageConfig;
36
+ /** Move the deck. */
37
+ go: (position: Position, origin: GoOrigin) => Promise<void>;
38
+ /** Where the deck is headed. */
39
+ target: () => Position;
40
+ /** What is on screen. */
41
+ position: () => Position;
42
+ }): DevLine {
43
+ const token = liveTokenQuery(options.page.liveToken);
44
+ const route = (kind: "socket" | "marks"): string =>
45
+ `${deckUrl(location.pathname, { kind })}${token}`;
46
+
47
+ const rehearse = createRehearseController({
48
+ load: () =>
49
+ fetchRehearsal(location.pathname, {
50
+ fetch: (input, init) => fetch(input, init),
51
+ audio: (src) => new Audio(src),
52
+ }),
53
+ go: options.go,
54
+ current: options.target,
55
+ });
56
+
57
+ const marks = createMarksView({
58
+ url: route("marks"),
59
+ current: options.position,
60
+ fetch: (input, init) => fetch(input, init),
61
+ });
62
+
63
+ function connect(handlers: PositionHandlers): PositionSocket {
64
+ const url = `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}${route("socket")}`;
65
+ return createPositionSocket({
66
+ connect: () => new WebSocket(url),
67
+ ...handlers,
68
+ setTimer: (fn, ms) => window.setTimeout(fn, ms),
69
+ clearTimer: (id) => window.clearTimeout(id),
70
+ });
71
+ }
72
+
73
+ function start(redraw: () => void): void {
74
+ const rehearseMode = new URLSearchParams(location.search).has("rehearse");
75
+ if (rehearseMode) {
76
+ void rehearse.load();
77
+ }
78
+ const liveHost = documentLiveHost();
79
+ // Keep a string key so minify does not rename the hook liveReloadScript calls.
80
+ // biome-ignore lint/complexity/useLiteralKeys: liveReloadScript looks up window["dekLive"]
81
+ window["dekLive"] = async (raw: unknown) => {
82
+ if (!isLiveEvent(raw)) {
83
+ location.reload();
84
+ return;
85
+ }
86
+ if (raw.type === "timeline") {
87
+ if (rehearseMode) {
88
+ await rehearse.load();
89
+ }
90
+ return;
91
+ }
92
+ if (raw.type === "annotations") {
93
+ document.dispatchEvent(new Event(ANNOTATIONS_CHANGED));
94
+ return;
95
+ }
96
+ const event = await hydrateLiveEvent(raw, location.pathname);
97
+ if (!event) {
98
+ location.reload();
99
+ return;
100
+ }
101
+ if (applyLiveEvent(event, liveHost).reload) {
102
+ location.reload();
103
+ return;
104
+ }
105
+ redraw();
106
+ };
107
+ }
108
+
109
+ return { connect, rehearse, marks, start };
110
+ }
@@ -18,7 +18,7 @@ import {
18
18
  } from "../core/position.ts";
19
19
  import type { Position } from "../core/step.ts";
20
20
  import { applyIncomingPosition, createGuardedGo } from "./go.ts";
21
- import { createPositionSocket, type PositionSocket } from "./socket.ts";
21
+ import type { PositionHandlers, PositionSocket } from "./socket.ts";
22
22
 
23
23
  /**
24
24
  * Where a move came from. Local moves, and the rehearsal clock's, are published to peers; remote
@@ -52,8 +52,8 @@ type GoRequest = { position: Position; origin: GoOrigin };
52
52
  export function createNavigator(options: {
53
53
  deck: DeckStops;
54
54
  channelName: string;
55
- /** The dev server's position socket; a built file on a static host has none. */
56
- socketUrl?: string;
55
+ /** Open the dev server's position socket; a file that stands alone has none to open. */
56
+ connect?: (handlers: PositionHandlers) => PositionSocket;
57
57
  /**
58
58
  * Draw the move from `from` to `to`. `apply` puts it on screen, perhaps inside a transition;
59
59
  * the promise settles once the move has played.
@@ -156,14 +156,10 @@ export function createNavigator(options: {
156
156
  receive(next);
157
157
  }
158
158
  });
159
- if (options.socketUrl) {
160
- const url = options.socketUrl;
161
- remote = createPositionSocket({
162
- connect: () => new WebSocket(url),
159
+ if (options.connect) {
160
+ remote = options.connect({
163
161
  onPosition: receive,
164
162
  ...(options.onPointer ? { onPointer: options.onPointer } : {}),
165
- setTimer: (fn, ms) => window.setTimeout(fn, ms),
166
- clearTimer: (id) => window.clearTimeout(id),
167
163
  });
168
164
  // A page that is really going away must not dial back in; one kept for Back reconnects.
169
165
  window.addEventListener("pagehide", (event) => {
@@ -3,14 +3,24 @@ import { join } from "node:path";
3
3
  export { liveReloadScript } from "./live-reload.ts";
4
4
 
5
5
  let player: Promise<string> | undefined;
6
+ let livePlayer: Promise<string> | undefined;
6
7
  let annotate: Promise<string> | undefined;
7
8
 
8
- /** The player every deck page runs. */
9
+ /** The player a file that stands alone runs: a build, a video, a shot. */
9
10
  export function playerScript(): Promise<string> {
10
11
  player ??= compile("browser.ts", "player");
11
12
  return player;
12
13
  }
13
14
 
15
+ /**
16
+ * The player the dev server's pages run: the same player, and its line to the server besides,
17
+ * which a file that stands alone has no server for.
18
+ */
19
+ export function livePlayerScript(): Promise<string> {
20
+ livePlayer ??= compile("browser-live.ts", "live player");
21
+ return livePlayer;
22
+ }
23
+
14
24
  /** Annotate mode, which only the dev server's pages for the speaker run, after the player. */
15
25
  export function annotateScript(): Promise<string> {
16
26
  annotate ??= compile("annotate.ts", "annotate mode");
@@ -46,6 +46,13 @@ export type PositionSocket = {
46
46
  close(): void;
47
47
  };
48
48
 
49
+ /** Where what a peer sends over the line goes. */
50
+ export type PositionHandlers = {
51
+ onPosition: (position: Position) => void;
52
+ /** Where a presenter's laser points, or null once it went away. */
53
+ onPointer?: (pointer: Pointer | null) => void;
54
+ };
55
+
49
56
  /**
50
57
  * The deck's line to the server, which relays positions between the presenter, the audience,
51
58
  * and a phone remote, and where the presenter's laser points. Like an EventSource, it reconnects by itself after a drop (a restarted
@@ -55,15 +62,14 @@ export type PositionSocket = {
55
62
  * missed whatever moved meanwhile. A move this deck made while away is sent instead, and that
56
63
  * greeting, if it is still where the deck was when the line dropped, is stale and passed over.
57
64
  */
58
- export function createPositionSocket(options: {
59
- connect: () => SocketLike;
60
- onPosition: (position: Position) => void;
61
- /** Where a presenter's laser points, or null once it went away. */
62
- onPointer?: (pointer: Pointer | null) => void;
63
- setTimer: (fn: () => void, ms: number) => number;
64
- clearTimer: (id: number) => void;
65
- random?: () => number;
66
- }): PositionSocket {
65
+ export function createPositionSocket(
66
+ options: PositionHandlers & {
67
+ connect: () => SocketLike;
68
+ setTimer: (fn: () => void, ms: number) => number;
69
+ clearTimer: (id: number) => void;
70
+ random?: () => number;
71
+ },
72
+ ): PositionSocket {
67
73
  let socket: SocketLike | undefined;
68
74
  let timer: number | undefined;
69
75
  let attempt = 0;
@@ -0,0 +1,171 @@
1
+ /// <reference lib="dom" />
2
+
3
+ import { PAGE_ID, readPageConfig } from "../core/page.ts";
4
+ import { deckStops, moveTarget, positionsEqual } from "../core/position.ts";
5
+ import type { PresenterSlide } from "../core/presenter-state.ts";
6
+ import type { Position } from "../core/step.ts";
7
+ import { createDeckControl } from "./deck-control.ts";
8
+ import type { CreateDevLine } from "./dev-line.ts";
9
+ import { bindInput } from "./input.ts";
10
+ import { createLaserView, type LaserView } from "./laser-view.ts";
11
+ import { createNavigator } from "./navigator.ts";
12
+ import { createPresenterView } from "./presenter-view.ts";
13
+ import { createRailView } from "./rail-view.ts";
14
+ import { deckChannelName } from "./routes.ts";
15
+ import { createStage } from "./stage.ts";
16
+
17
+ /**
18
+ * Start the player on the page. The dev server's player passes `createDevLine`, its line to the
19
+ * server; a file that stands alone has no server, and its player is built without one.
20
+ */
21
+ export function startPlayer(createDevLine?: CreateDevLine): void {
22
+ const dataEl = document.getElementById(PAGE_ID.data);
23
+ if (!dataEl?.textContent) {
24
+ return;
25
+ }
26
+ const page = readPageConfig(document.body);
27
+ const slides = JSON.parse(dataEl.textContent) as PresenterSlide[];
28
+ const deck = deckStops(slides);
29
+ const stage = createStage({ slides, deck, videoMode: page.mode === "video" });
30
+ const presenter = createPresenterView({ slides, deck, stage, mode: page.mode });
31
+
32
+ function fitAll(): void {
33
+ stage.fit();
34
+ presenter.fit();
35
+ rail.fit();
36
+ }
37
+
38
+ /** Draw every view at `pos`. */
39
+ function render(pos: Position): void {
40
+ stage.render(pos);
41
+ presenter.render(pos);
42
+ rail.markCurrent(pos.slideIndex);
43
+ marks?.render(pos);
44
+ }
45
+
46
+ /**
47
+ * The first key or move takes the key hint away. Removing it also drops its animation, which
48
+ * the first move would otherwise wait on before it settles.
49
+ */
50
+ function dismissKeyHint(): void {
51
+ document.getElementById(PAGE_ID.hint)?.remove();
52
+ }
53
+
54
+ const dev = createDevLine?.({
55
+ page,
56
+ go: (position, origin) => nav.go(position, origin),
57
+ target: () => nav.target(),
58
+ position: () => nav.position(),
59
+ });
60
+ const control = createDeckControl({
61
+ go: (next, origin) => nav.go(next, origin),
62
+ ...(dev ? { rehearse: dev.rehearse } : {}),
63
+ });
64
+
65
+ // A video shows the talk as narrated; nobody points at it.
66
+ const laser: LaserView | undefined =
67
+ page.mode === "video"
68
+ ? undefined
69
+ : createLaserView({
70
+ current: () => nav.position().slideIndex,
71
+ send: (pointer) => nav.point(pointer),
72
+ });
73
+
74
+ const nav = createNavigator({
75
+ deck,
76
+ channelName: deckChannelName(
77
+ page.deck,
78
+ deck.map((slide) => slide.slug),
79
+ ),
80
+ ...(dev ? { connect: dev.connect } : {}),
81
+ present: stage.present,
82
+ hurry: stage.hurry,
83
+ beforeMove: dismissKeyHint,
84
+ onMove: (from, to) => {
85
+ render(to);
86
+ if (from.slideIndex !== to.slideIndex) {
87
+ stage.announce(to);
88
+ laser?.sync();
89
+ }
90
+ // A hashchange echoing this same position must not cut a running animation short.
91
+ if (!positionsEqual(from, to)) {
92
+ stage.showMove(from, to);
93
+ }
94
+ },
95
+ afterMove: presenter.startClock,
96
+ route: (next) => control.request(next, "remote"),
97
+ ...(laser ? { onPointer: laser.receive } : {}),
98
+ });
99
+
100
+ const marks = dev?.marks;
101
+
102
+ const rail = createRailView({
103
+ slides,
104
+ stage,
105
+ onPick: (slideIndex) => control.request({ slideIndex, beatIndex: 0 }, "local"),
106
+ onResize: fitAll,
107
+ });
108
+
109
+ function setPresenterOpen(open: boolean): void {
110
+ presenter.setOpen(open);
111
+ fitAll();
112
+ render(nav.position());
113
+ }
114
+
115
+ bindInput({
116
+ onKey: dismissKeyHint,
117
+ togglePresenter: () => {
118
+ if (!presenter.available()) {
119
+ return false;
120
+ }
121
+ setPresenterOpen(!presenter.isOpen());
122
+ return true;
123
+ },
124
+ toggleFullscreen: () => {
125
+ if (document.fullscreenElement) {
126
+ void document.exitFullscreen?.().catch(() => undefined);
127
+ } else {
128
+ void document.documentElement.requestFullscreen?.().catch(() => undefined);
129
+ }
130
+ },
131
+ toggleRail: () => {
132
+ if (!rail.available() || presenter.isOpen()) {
133
+ return false;
134
+ }
135
+ rail.setOpen(!rail.isOpen());
136
+ return true;
137
+ },
138
+ togglePlay: control.togglePlay,
139
+ toggleLaser: () => laser?.toggle() ?? false,
140
+ toggleMark: () => marks?.toggle() ?? false,
141
+ pointing: () => laser?.isOn() ?? false,
142
+ move: (move) => control.request(moveTarget(move, nav.target(), deck), "local"),
143
+ });
144
+
145
+ window.addEventListener("resize", fitAll);
146
+ window.addEventListener("beforeprint", () => stage.setPrinting(true, nav.position()));
147
+ window.addEventListener("afterprint", () => stage.setPrinting(false, nav.position()));
148
+ matchMedia("print").addEventListener?.("change", (event) =>
149
+ stage.setPrinting(event.matches, nav.position()),
150
+ );
151
+
152
+ rail.fill();
153
+ fitAll();
154
+ render(nav.position());
155
+ // A hash that named a beat past the slide's last now says where the deck opened.
156
+ nav.writeHash("replace");
157
+ stage.showMotion(nav.position(), page.mode === "video" ? "hold" : "final");
158
+ // biome-ignore lint/complexity/useLiteralKeys: video recorder looks up window["dekGo"]
159
+ window["dekGo"] = (next) => nav.go(next, "capture");
160
+ // The video recorder seeks slide scripts the way it seeks Web Animations.
161
+ // biome-ignore lint/complexity/useLiteralKeys: video recorder looks up window["dekMotion"]
162
+ window["dekMotion"] = stage.motion;
163
+
164
+ // Whatever a live update changed, drawing the beat again puts it back as it stood.
165
+ dev?.start(() => {
166
+ const pos = nav.position();
167
+ rail.fill();
168
+ render(pos);
169
+ stage.showMotion(pos, "final");
170
+ });
171
+ }
package/src/server/dev.ts CHANGED
@@ -2,7 +2,7 @@ import { join } from "node:path";
2
2
  import { DekError } from "../core/error.ts";
3
3
  import type { PlaywrightRunner } from "../core/playwright.ts";
4
4
  import { locateDeck, type Project, resolveProject } from "../core/resolve.ts";
5
- import { annotateScript, liveReloadScript, playerScript } from "../runtime/player.ts";
5
+ import { annotateScript, livePlayerScript, liveReloadScript } from "../runtime/player.ts";
6
6
  import {
7
7
  clearance,
8
8
  mayRead,
@@ -69,7 +69,7 @@ export async function startDevServer(options: DevServerOptions): Promise<DevServ
69
69
  const { scopedDeckName, deckDir } = serverScope(project, options);
70
70
  const access = accessFor(options);
71
71
  const embed = {
72
- playerScript: await playerScript(),
72
+ playerScript: await livePlayerScript(),
73
73
  liveReloadScript: liveReloadScript(),
74
74
  annotateScript: await annotateScript(),
75
75
  };
@@ -21,8 +21,10 @@ async function capture(
21
21
  request: VideoCaptureRequest,
22
22
  ): Promise<VideoCaptureResponse> {
23
23
  mkdirSync(request.outDir, { recursive: true });
24
+ // A deck is self-contained (DEK020), so the page needs no network.
24
25
  const page = await browser.newPage({
25
26
  viewport: { width: request.viewport.width, height: request.viewport.height },
27
+ offline: true,
26
28
  });
27
29
  await loadVideoDoc(page, request.html);
28
30
 
package/tsconfig.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext"],
4
+ "target": "ESNext",
5
+ "module": "Preserve",
6
+ "moduleDetection": "force",
7
+ "allowJs": true,
8
+ "types": ["bun"],
9
+
10
+ "moduleResolution": "bundler",
11
+ "allowImportingTsExtensions": true,
12
+ "verbatimModuleSyntax": true,
13
+ "noEmit": true,
14
+
15
+ "strict": true,
16
+ "skipLibCheck": true,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "noUncheckedIndexedAccess": true,
19
+ "noImplicitOverride": true,
20
+
21
+ "noUnusedLocals": false,
22
+ "noUnusedParameters": false,
23
+ "noPropertyAccessFromIndexSignature": false
24
+ },
25
+ "include": ["src", "tests"]
26
+ }